Compare commits

..

159 Commits

Author SHA1 Message Date
admin 690ecc2053 Document purchasing translation deploy 2026-06-11 12:36:29 +02:00
admin 1dbaa66206 Add purchasing translations 2026-06-11 12:22:52 +02:00
admin 41103bbc5a Document finance training deploy 2026-06-11 12:13:25 +02:00
admin f751295449 Update finance training and dashboard UI 2026-06-11 11:32:35 +02:00
admin 2444a01fc7 Clarify processed merge audit CSV filenames 2026-06-11 11:08:54 +02:00
admin 957fdb7dc8 Upload audit CSV with site exports 2026-06-11 10:38:15 +02:00
admin ca6196234e Translate settings and purchasing UI text 2026-06-11 10:08:37 +02:00
admin 2e7aeda684 Clarify audit CSV settings section 2026-06-11 09:28:10 +02:00
admin 0cecb1eddf Keep finance references in expert analysis 2026-06-11 09:04:25 +02:00
admin dcd845d337 Add audit CSV central source option 2026-06-11 08:57:18 +02:00
admin f23fa1662e Add product division map fallback 2026-06-10 16:17:02 +02:00
admin d1d50e9c0c Document workflow deltas and clean up docs 2026-06-10 09:15:46 +02:00
admin 586adc33fe Fix India SAGE HANA mapping 2026-06-10 08:25:40 +02:00
admin 4ca8c31e6c Add Alphaplan discovery exporter and finance docs 2026-06-08 16:06:44 +02:00
admin d7e96edf14 Document purchasing database restore 2026-06-08 06:53:20 +02:00
admin d882755485 Prevent publishing runtime database 2026-06-05 14:33:39 +02:00
admin 9b43d483fc Show purchasing commitments with supplier context 2026-06-05 14:28:01 +02:00
admin a41ef0a564 Align purchasing analytics with Power BI 2026-06-05 14:04:27 +02:00
admin aa6d0d0804 Add purchasing period analytics 2026-06-05 13:54:36 +02:00
admin 43250a4abc Add purchasing full load cache 2026-06-05 12:43:12 +02:00
admin b1bff57370 Expand purchasing ideas and fix navigation refresh 2026-06-05 11:29:59 +02:00
admin fe729e026d Split purchasing dashboard into navigation pages 2026-06-05 11:13:45 +02:00
admin 2fa410ec31 Load purchasing EKPO and EKET metrics 2026-06-05 11:05:04 +02:00
admin e7e408fc20 Add purchasing ideas tab and SAP table test 2026-06-05 10:45:48 +02:00
admin 989ff66102 Polish purchasing cockpit visuals 2026-06-05 10:17:25 +02:00
admin bf20b3a240 Build out purchasing dashboard sections 2026-06-05 09:11:55 +02:00
admin 146c7481e1 Load purchasing dashboard live EKKO data 2026-06-05 08:58:20 +02:00
admin baeee3e49b Stabilize export dashboard and fill purchasing KPIs 2026-06-05 08:15:50 +02:00
admin 0ce96d9eb4 Handle null navigation menu values 2026-06-05 07:57:49 +02:00
admin bb5e5150b9 Add purchasing data sources and 3D simulation 2026-06-05 07:45:30 +02:00
admin 9b287c15ef Expand purchasing dashboard from PBIX 2026-06-05 07:22:05 +02:00
admin 6f094fcac6 Add configurable menu structure and purchasing area 2026-06-05 07:03:08 +02:00
admin bed1f5f0ba Document Spain delta import deployment 2026-06-05 06:50:42 +02:00
admin 825e8063a0 Support Spain sales delta folder sync 2026-06-05 06:40:31 +02:00
admin 195b430836 Document finance and Spain rclone updates 2026-06-05 06:26:51 +02:00
admin 3fd19a8bb5 Detect nested Spain rclone executable 2026-06-04 15:40:51 +02:00
admin af097cafad Fix Spain all-in-one rclone upload 2026-06-04 15:38:47 +02:00
admin 8e0b696150 Default Spain export range to last seven days 2026-06-04 15:27:21 +02:00
admin e55a86ccca Add Spain all-in-one export upload script 2026-06-04 15:24:31 +02:00
admin cad2140da6 Add finance 3D label size control 2026-06-04 14:32:51 +02:00
admin 9c63c36128 Fix finance 3D canvas sizing 2026-06-04 14:27:21 +02:00
admin e33a2fd9e6 Expand finance 3D indicators 2026-06-04 14:22:22 +02:00
admin 1049216049 Label finance 3D axes 2026-06-04 14:16:55 +02:00
admin fde7f6bc95 Add finance 3D chart modes 2026-06-04 14:11:11 +02:00
admin 9409174a07 Fix finance 3D scenario scaling 2026-06-04 13:48:27 +02:00
admin 13a7331f3d Improve finance 3D controls and simulation 2026-06-04 13:42:38 +02:00
admin a8dc565478 Add finance 3D data analysis 2026-06-04 13:36:03 +02:00
admin b44e8babf4 Expose quick finance overview in navigation 2026-06-04 12:37:30 +02:00
admin 40805e0222 Simplify finance dashboard overview 2026-06-04 10:42:11 +02:00
admin 37a175551b Document finance and mapping updates 2026-06-02 18:06:30 +02:00
admin 6470cb8751 Update finance session follow-ups 2026-06-01 15:35:23 +02:00
admin 715977beda Document finance dashboard status 2026-06-01 06:38:18 +02:00
admin d1c9d21227 Document management division analysis updates 2026-05-29 14:08:50 +02:00
admin 36ca822fbc Add browser favicon 2026-05-29 13:47:21 +02:00
admin 674c103478 Expose management analysis tabs in navigation 2026-05-29 13:42:00 +02:00
admin 61de1bebe9 Document division analysis in finance training 2026-05-29 13:39:48 +02:00
admin 18208cbcc5 Add product division category icons 2026-05-29 13:23:06 +02:00
admin 3c827472e1 Add division finance grouping controls 2026-05-29 13:18:46 +02:00
admin 0a7aafbd51 Add management analysis navigation group 2026-05-29 13:13:08 +02:00
admin dc2bc7dd83 Group division analysis tabs 2026-05-29 13:03:48 +02:00
admin 26854b999c Document product division finance deployment 2026-05-29 11:49:07 +02:00
admin aeb20fc565 Add product division finance analysis 2026-05-29 10:40:46 +02:00
admin 6593bf41be Keep SAP product division source active 2026-05-29 10:26:45 +02:00
admin 41d663254e Document product mapping deployment 2026-05-29 09:29:53 +02:00
admin 8cb5f98562 Add central product assignment tab 2026-05-29 08:54:30 +02:00
admin 7e9a61f877 Add product division gateway mapping 2026-05-29 08:49:37 +02:00
admin d3d75ccea7 Clarify ABAP product provider class pool 2026-05-29 07:12:29 +02:00
admin c638529dd0 Document ABAP product division mapping reports 2026-05-28 14:43:13 +02:00
admin da0f39235c Add finance management analysis tabs 2026-05-28 12:51:18 +02:00
admin d0762ec18b Document Upgreat firewall requirements 2026-05-27 11:30:49 +02:00
admin a3ab38a7c0 Document product division mapping task 2026-05-27 10:25:32 +02:00
admin 9a74e248e9 Organize markdown docs for RAG routing 2026-05-27 09:40:12 +02:00
admin 48fd2f65a0 Document rebase and refactoring changes 2026-05-26 14:18:39 +02:00
admin d853f53df8 Add published HR KPI workflow fixes 2026-05-26 13:33:09 +02:00
admin 5f3c3497b8 Separate admin access from finance lock 2026-05-26 13:33:09 +02:00
admin 9471c5c310 Add admin access and landing dashboard 2026-05-26 13:33:09 +02:00
admin 6b3dc2de60 Document local dev server fallback 2026-05-26 13:33:09 +02:00
admin c8728595a4 Add in-app training documentation 2026-05-26 13:33:09 +02:00
admin 16449f1dc1 Add finance details export and translations 2026-05-26 13:33:09 +02:00
admin b2ede7f8fd Add importable trading engine package 2026-05-24 19:22:05 +02:00
admin 20f6fd9b51 Add trading cockpit engine 2026-05-24 10:28:10 +02:00
admin f9971253a8 Add trading cockpit frontend logic 2026-05-24 09:40:25 +02:00
admin 307887366d Add trading cockpit documentation 2026-05-24 09:37:32 +02:00
admin ed68f2f885 Add trading cockpit readme 2026-05-24 09:36:35 +02:00
admin a8f6c38a6c Add trading cockpit styles 2026-05-24 09:35:56 +02:00
admin e24a1ddfa0 Add trading cockpit Flask app 2026-05-24 09:34:33 +02:00
admin bc3c244052 Add trading cockpit web template 2026-05-24 09:33:44 +02:00
admin 3a80c35a59 Add trading cockpit tests 2026-05-24 09:33:20 +02:00
admin e8166e8016 Add trading cockpit storage 2026-05-24 09:33:07 +02:00
admin e9ccc49d11 Add trading cockpit exchange guard 2026-05-24 09:32:47 +02:00
admin e20d87a64a Add trading cockpit deploy script 2026-05-24 09:32:36 +02:00
admin aab932727b Add trading cockpit config 2026-05-24 09:32:29 +02:00
admin 5b1ffa94dd Add trading cockpit docker compose 2026-05-24 09:32:18 +02:00
admin 0059c1e418 Add trading cockpit requirements 2026-05-24 09:32:13 +02:00
admin 0653322337 Add trading cockpit Dockerfile 2026-05-24 09:32:08 +02:00
admin 217d0a8ca3 Add trading cockpit gitignore 2026-05-24 09:31:55 +02:00
admin ab74f851c8 Add trading cockpit web app 2026-05-24 09:31:20 +02: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
315 changed files with 247280 additions and 3315 deletions
+1
View File
@@ -8,3 +8,4 @@ TrafagSalesExporter/*.suo
TrafagSalesExporter/*.db
TrafagSalesExporter/*.db-shm
TrafagSalesExporter/*.db-wal
Trafag/
+15
View File
@@ -1,8 +1,23 @@
# Build artifacts
bin/
obj/
verify_probe_out*/
build_verify/
output/
# Visual Studio user/IDE files
.vs/
*.user
*.suo
# Local diagnostics and scratch artifacts
.config/
.tmp_tools/
Tools/FinanceProbe/.tmp_tools/
Tools/FinanceProbe/verify_probe_out*/
*.out.log
*.err.log
mainapp*.log
financeprobe*.log
netsh
11.15.0
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">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<TargetFramework>net48</TargetFramework>
<PlatformTarget>x86</PlatformTarget>
<Prefer32Bit>true</Prefer32Bit>
<LangVersion>latest</LangVersion>
<Nullable>disable</Nullable>
<AssemblyName>SapProbe</AssemblyName>
<RootNamespace>SapProbe</RootNamespace>
</PropertyGroup>
<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>
</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
**********************
Binary file not shown.
@@ -0,0 +1,136 @@
Alphaplan SQL Discovery Exporter
================================
Purpose
-------
Run this package on the German Alphaplan SQL Server machine.
It performs Phase 1 discovery only:
- scan accessible SQL Server databases
- identify tables/views that look relevant for finance, invoices, sales, customers, articles and amounts
- write candidate_objects.csv
- write export_summary.csv
- optionally write small sample_*.csv files
- optionally upload the run folder to SharePoint with rclone
The script only reads SQL Server metadata/data. It does not change Alphaplan, SQL Server or BiDashboard.
Default SharePoint target
-------------------------
The default rclone target is:
trafag-bi:Import/Finance/Deutschland/AlphaplanRaw
Use this raw folder first so the existing Germany import is not disturbed.
Typical commands
----------------
Open PowerShell on the Alphaplan server in this package folder.
Allow script execution for this PowerShell window:
Set-ExecutionPolicy -Scope Process Bypass
Run discovery and upload with defaults:
.\Run-AlphaplanDiscoveryAndUpload.ps1
Run discovery for one known database:
.\Run-AlphaplanDiscoveryAndUpload.ps1 -Database "ALPHAPLAN"
Run discovery without SharePoint upload:
.\Run-AlphaplanDiscoveryAndUpload.ps1 -SkipUpload
Run discovery and include small samples from top candidate tables/views:
.\Run-AlphaplanDiscoveryAndUpload.ps1 -Database "ALPHAPLAN" -ExportSamples
Use another SQL Server instance:
.\Run-AlphaplanDiscoveryAndUpload.ps1 -ServerInstance "SERVERNAME\INSTANCE" -Database "ALPHAPLAN"
Use SQL authentication:
$cred = Get-Credential
.\Run-AlphaplanDiscoveryAndUpload.ps1 -ServerInstance "SERVERNAME\INSTANCE" -Database "ALPHAPLAN" -SqlCredential $cred
Use another rclone remote name:
.\Run-AlphaplanDiscoveryAndUpload.ps1 -RcloneRemote "YOUR_REMOTE"
Use another rclone executable:
.\Run-AlphaplanDiscoveryAndUpload.ps1 -RcloneExe "C:\Tools\rclone\rclone.exe"
Output
------
Default local folder:
C:\Trafag\AlphaplanExport\out\Alphaplan_SQL_Discovery_YYYYMMDD_HHMMSS
Main files:
- candidate_objects.csv
- export_summary.csv
- README.txt
- sample_*.csv when -ExportSamples is used
rclone prerequisites
--------------------
rclone must already be configured on the Alphaplan server.
Expected remote:
trafag-bi
The remote should point to the "Shared Documents" document library of:
https://trafagag.sharepoint.com/sites/WorldwideBIPlatform
Quick rclone checks:
rclone lsd trafag-bi:
rclone lsd trafag-bi:"Import/Finance"
rclone lsd trafag-bi:"Import/Finance/Deutschland"
Recommended Phase 1 workflow
----------------------------
1. Run discovery without samples:
.\Run-AlphaplanDiscoveryAndUpload.ps1 -SkipUpload
2. Check candidate_objects.csv locally.
3. If the result looks plausible, run with upload:
.\Run-AlphaplanDiscoveryAndUpload.ps1
4. If Andreas/DE IT needs examples, run samples:
.\Run-AlphaplanDiscoveryAndUpload.ps1 -ExportSamples
5. Use candidate_objects.csv to identify the correct invoice header, invoice line, customer, article and credit note/storno objects.
Notes
-----
- If -Database is empty, all accessible user databases are scanned.
- If the SQL user has limited permissions, candidate_objects.csv may be empty or incomplete.
- Use a read-only SQL user or Windows account.
- For Phase 1, no BiDashboard import mapping is required.
@@ -0,0 +1,584 @@
param(
[string]$ServerInstance = "localhost",
[string]$Database = "",
[System.Management.Automation.PSCredential]$SqlCredential,
[string]$BaseDirectory = "C:\Trafag\AlphaplanExport",
[int]$MaxCandidatesPerDatabase = 80,
[switch]$ExportSamples,
[int]$MaxSampleObjects = 15,
[int]$SampleRows = 200,
[switch]$IncludeSystemDatabases,
[switch]$SkipUpload,
[string]$RcloneExe = "C:\Tools\rclone.exe",
[string]$RcloneRemote = "trafag-bi",
[string]$RcloneTarget = "Import/Finance/Deutschland/AlphaplanRaw"
)
$ErrorActionPreference = "Stop"
function New-Connection {
param([string]$DbName)
$builder = New-Object System.Data.SqlClient.SqlConnectionStringBuilder
$builder["Data Source"] = $ServerInstance
$builder["Initial Catalog"] = $DbName
$builder["TrustServerCertificate"] = $true
$builder["Connect Timeout"] = 15
if ($null -ne $SqlCredential) {
$builder["Integrated Security"] = $false
$builder["User ID"] = $SqlCredential.UserName
$builder["Password"] = $SqlCredential.GetNetworkCredential().Password
}
else {
$builder["Integrated Security"] = $true
}
return New-Object System.Data.SqlClient.SqlConnection($builder.ConnectionString)
}
function Add-SqlParameter {
param(
[System.Data.SqlClient.SqlCommand]$Command,
[string]$Name,
$Value
)
if ($Value -is [int]) {
$parameter = $Command.Parameters.Add("@$Name", [System.Data.SqlDbType]::Int)
$parameter.Value = $Value
return
}
if ($Value -is [datetime]) {
$parameter = $Command.Parameters.Add("@$Name", [System.Data.SqlDbType]::DateTime)
$parameter.Value = $Value
return
}
$textParameter = $Command.Parameters.Add("@$Name", [System.Data.SqlDbType]::NVarChar, 4000)
if ($null -eq $Value) {
$textParameter.Value = [System.DBNull]::Value
}
else {
$textParameter.Value = [string]$Value
}
}
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) {
Add-SqlParameter -Command $cmd -Name $key -Value $Parameters[$key]
}
$table = New-Object System.Data.DataTable
try {
$conn.Open()
$reader = $cmd.ExecuteReader()
$table.Load($reader)
$reader.Dispose()
}
finally {
$cmd.Dispose()
$conn.Dispose()
}
return $table
}
function Convert-DataRowToObject {
param([System.Data.DataRow]$Row)
$props = [ordered]@{}
foreach ($column in $Row.Table.Columns) {
$value = $Row[$column.ColumnName]
if ($null -eq $value -or $value -is [System.DBNull]) {
$props[$column.ColumnName] = ""
}
else {
$props[$column.ColumnName] = $value
}
}
return [pscustomobject]$props
}
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++
}
$reader.Dispose()
}
finally {
$writer.Dispose()
$cmd.Dispose()
$conn.Dispose()
}
return $rowCount
}
function Quote-NamePart {
param([string]$Name)
return "[" + $Name.Replace("]", "]]") + "]"
}
function Normalize-FileName {
param([string]$Value)
$safe = ($Value -replace '[\\/:*?"<>|]', '_')
if ($safe.Length -gt 150) {
return $safe.Substring(0, 150)
}
return $safe
}
function Get-UserDatabases {
$systemFilter = if ($IncludeSystemDatabases) { "" } else { "AND database_id > 4" }
$sql = @"
SELECT name
FROM sys.databases
WHERE state_desc = 'ONLINE'
AND HAS_DBACCESS(name) = 1
$systemFilter
ORDER BY name;
"@
$table = Invoke-DataTable "master" $sql
$result = New-Object System.Collections.Generic.List[string]
foreach ($row in $table.Rows) {
$result.Add([string]$row["name"])
}
return @($result)
}
function Get-CandidateObjects {
param([string]$DbName)
$sql = @"
WITH object_columns AS (
SELECT
o.object_id AS ObjectId,
s.name AS SchemaName,
o.name AS ObjectName,
o.type_desc AS ObjectType,
c.name AS ColumnName,
t.name AS TypeName,
c.column_id AS ColumnId
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
ObjectId,
SchemaName,
ObjectName,
ObjectType,
COUNT(*) AS ColumnCount,
SUM(
CASE WHEN LOWER(ObjectName) LIKE '%rechnung%' OR LOWER(ObjectName) LIKE '%fakt%' OR LOWER(ObjectName) LIKE '%invoice%' OR LOWER(ObjectName) LIKE '%fact%' OR LOWER(ObjectName) LIKE '%beleg%' THEN 8 ELSE 0 END +
CASE WHEN LOWER(ObjectName) LIKE '%umsatz%' OR LOWER(ObjectName) LIKE '%verkauf%' OR LOWER(ObjectName) LIKE '%sales%' OR LOWER(ObjectName) LIKE '%revenue%' THEN 6 ELSE 0 END +
CASE WHEN LOWER(ObjectName) LIKE '%position%' OR LOWER(ObjectName) LIKE '%zeile%' OR LOWER(ObjectName) LIKE '%line%' THEN 4 ELSE 0 END +
CASE WHEN LOWER(ObjectName) LIKE '%auftrag%' OR LOWER(ObjectName) LIKE '%order%' THEN 4 ELSE 0 END +
CASE WHEN LOWER(ObjectName) LIKE '%artikel%' OR LOWER(ObjectName) LIKE '%material%' OR LOWER(ObjectName) LIKE '%item%' OR LOWER(ObjectName) LIKE '%produkt%' THEN 5 ELSE 0 END +
CASE WHEN LOWER(ObjectName) LIKE '%kunde%' OR LOWER(ObjectName) LIKE '%debitor%' OR LOWER(ObjectName) LIKE '%customer%' OR LOWER(ObjectName) LIKE '%adresse%' THEN 4 ELSE 0 END +
CASE WHEN LOWER(ObjectName) LIKE '%gutschrift%' OR LOWER(ObjectName) LIKE '%storno%' OR LOWER(ObjectName) LIKE '%credit%' THEN 4 ELSE 0 END +
CASE WHEN LOWER(ColumnName) LIKE '%datum%' OR LOWER(ColumnName) LIKE '%date%' OR LOWER(ColumnName) LIKE '%zeit%' THEN 2 ELSE 0 END +
CASE WHEN LOWER(ColumnName) LIKE '%rechnung%' OR LOWER(ColumnName) LIKE '%fakt%' OR LOWER(ColumnName) LIKE '%invoice%' OR LOWER(ColumnName) LIKE '%beleg%' THEN 2 ELSE 0 END +
CASE WHEN LOWER(ColumnName) LIKE '%kunde%' OR LOWER(ColumnName) LIKE '%debitor%' OR LOWER(ColumnName) LIKE '%customer%' OR LOWER(ColumnName) LIKE '%adresse%' THEN 2 ELSE 0 END +
CASE WHEN LOWER(ColumnName) LIKE '%artikel%' OR LOWER(ColumnName) LIKE '%material%' OR LOWER(ColumnName) LIKE '%item%' OR LOWER(ColumnName) LIKE '%produkt%' OR LOWER(ColumnName) LIKE '%artnr%' THEN 2 ELSE 0 END +
CASE WHEN LOWER(ColumnName) LIKE '%betrag%' OR LOWER(ColumnName) LIKE '%netto%' OR LOWER(ColumnName) LIKE '%umsatz%' OR LOWER(ColumnName) LIKE '%amount%' OR LOWER(ColumnName) LIKE '%price%' OR LOWER(ColumnName) LIKE '%preis%' OR LOWER(ColumnName) LIKE '%summe%' THEN 3 ELSE 0 END +
CASE WHEN LOWER(ColumnName) LIKE '%menge%' OR LOWER(ColumnName) LIKE '%anzahl%' OR LOWER(ColumnName) LIKE '%quantity%' OR LOWER(ColumnName) LIKE '%qty%' THEN 2 ELSE 0 END +
CASE WHEN LOWER(ColumnName) LIKE '%waehr%' OR LOWER(ColumnName) LIKE '%currency%' OR LOWER(ColumnName) LIKE '%whrg%' THEN 1 ELSE 0 END +
CASE WHEN LOWER(ColumnName) LIKE '%warengruppe%' OR LOWER(ColumnName) LIKE '%produktgruppe%' OR LOWER(ColumnName) LIKE '%productgroup%' THEN 1 ELSE 0 END
) AS Score
FROM object_columns
GROUP BY ObjectId, SchemaName, ObjectName, ObjectType
)
SELECT TOP (@MaxCandidates)
DB_NAME() AS DatabaseName,
s.SchemaName,
s.ObjectName,
s.ObjectType,
s.Score,
s.ColumnCount,
ISNULL((
SELECT SUM(p.row_count)
FROM sys.dm_db_partition_stats p
WHERE p.object_id = s.ObjectId
AND p.index_id IN (0, 1)
), 0) AS RowCountEstimate,
STUFF((
SELECT ', ' + oc.ColumnName
FROM object_columns oc
WHERE oc.ObjectId = s.ObjectId
AND (
oc.TypeName IN ('date', 'datetime', 'datetime2', 'smalldatetime')
OR LOWER(oc.ColumnName) LIKE '%datum%'
OR LOWER(oc.ColumnName) LIKE '%date%'
OR LOWER(oc.ColumnName) LIKE '%zeit%'
)
ORDER BY oc.ColumnId
FOR XML PATH(''), TYPE
).value('.', 'nvarchar(max)'), 1, 2, '') AS DateColumnCandidates,
STUFF((
SELECT ', ' + oc.ColumnName
FROM object_columns oc
WHERE oc.ObjectId = s.ObjectId
AND (
LOWER(oc.ColumnName) LIKE '%betrag%'
OR LOWER(oc.ColumnName) LIKE '%netto%'
OR LOWER(oc.ColumnName) LIKE '%umsatz%'
OR LOWER(oc.ColumnName) LIKE '%amount%'
OR LOWER(oc.ColumnName) LIKE '%price%'
OR LOWER(oc.ColumnName) LIKE '%preis%'
OR LOWER(oc.ColumnName) LIKE '%summe%'
)
ORDER BY oc.ColumnId
FOR XML PATH(''), TYPE
).value('.', 'nvarchar(max)'), 1, 2, '') AS AmountColumnCandidates,
STUFF((
SELECT ', ' + oc.ColumnName
FROM object_columns oc
WHERE oc.ObjectId = s.ObjectId
AND (
LOWER(oc.ColumnName) LIKE '%rechnung%'
OR LOWER(oc.ColumnName) LIKE '%fakt%'
OR LOWER(oc.ColumnName) LIKE '%beleg%'
OR LOWER(oc.ColumnName) LIKE '%kunde%'
OR LOWER(oc.ColumnName) LIKE '%debitor%'
OR LOWER(oc.ColumnName) LIKE '%artikel%'
OR LOWER(oc.ColumnName) LIKE '%material%'
OR LOWER(oc.ColumnName) LIKE '%position%'
)
ORDER BY oc.ColumnId
FOR XML PATH(''), TYPE
).value('.', 'nvarchar(max)'), 1, 2, '') AS KeyColumnCandidates,
STUFF((
SELECT ', ' + oc.ColumnName
FROM object_columns oc
WHERE oc.ObjectId = s.ObjectId
ORDER BY oc.ColumnId
FOR XML PATH(''), TYPE
).value('.', 'nvarchar(max)'), 1, 2, '') AS Columns
FROM scored s
WHERE s.Score > 0
ORDER BY s.Score DESC, RowCountEstimate DESC, s.ObjectName;
"@
$table = Invoke-DataTable $DbName $sql @{ MaxCandidates = $MaxCandidatesPerDatabase }
$result = New-Object System.Collections.Generic.List[object]
foreach ($row in $table.Rows) {
$result.Add((Convert-DataRowToObject $row))
}
return @($result)
}
function Build-SampleSql {
param(
[string]$SchemaName,
[string]$ObjectName,
[int]$Rows
)
$safeRows = [Math]::Max(1, $Rows)
$qualifiedName = "$(Quote-NamePart $SchemaName).$(Quote-NamePart $ObjectName)"
return "SELECT TOP ($safeRows) * FROM $qualifiedName;"
}
function Resolve-RcloneExecutable {
param([string]$ConfiguredPath)
$scriptDirectory = Split-Path -Parent $MyInvocation.MyCommand.Path
$candidates = @(
$ConfiguredPath,
(Join-Path $scriptDirectory "rclone.exe"),
"C:\Tools\rclone.exe",
"C:\Tools\rclone\rclone.exe",
"C:\Tools\rclone\rclone\rclone.exe",
"rclone"
) | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }
foreach ($candidate in $candidates) {
if (Test-Path -LiteralPath $candidate) {
return (Resolve-Path -LiteralPath $candidate).Path
}
$command = Get-Command $candidate -ErrorAction SilentlyContinue
if ($null -ne $command) {
return $command.Source
}
}
throw "rclone executable not found. Checked: $($candidates -join ', ')"
}
function Throw-RcloneError {
param(
[string]$Message,
[string]$LogPath
)
if (Test-Path -LiteralPath $LogPath) {
Write-Host ""
Write-Host "Last rclone log lines:"
Get-Content -LiteralPath $LogPath -Tail 80 | ForEach-Object { Write-Host $_ }
}
throw "$Message Log: $LogPath"
}
$timestamp = Get-Date -Format "yyyyMMdd_HHmmss"
$outputDirectory = Join-Path $BaseDirectory "out"
$logDirectory = Join-Path $BaseDirectory "logs"
New-Item -ItemType Directory -Force -Path $outputDirectory, $logDirectory | Out-Null
$runDirectory = Join-Path $outputDirectory "Alphaplan_SQL_Discovery_$timestamp"
New-Item -ItemType Directory -Path $runDirectory -Force | Out-Null
$summary = New-Object System.Collections.Generic.List[object]
$allCandidates = New-Object System.Collections.Generic.List[object]
Write-Host "Alphaplan SQL discovery"
Write-Host "Server instance: $ServerInstance"
Write-Host "Database filter: $(if ([string]::IsNullOrWhiteSpace($Database)) { '(all accessible user databases)' } else { $Database })"
Write-Host "Run directory: $runDirectory"
$databases = if ([string]::IsNullOrWhiteSpace($Database)) {
@(Get-UserDatabases)
}
else {
@($Database)
}
if ($databases.Count -eq 0) {
throw "No accessible databases found."
}
foreach ($db in $databases) {
Write-Host "Scanning database: $db"
try {
$candidates = @(Get-CandidateObjects $db)
foreach ($candidate in $candidates) {
$allCandidates.Add($candidate)
}
$summary.Add([pscustomobject]@{
Created = (Get-Date -Format "yyyy-MM-dd HH:mm:ss")
Database = $db
Object = ""
Action = "Discovery completed"
Rows = $candidates.Count
File = "candidate_objects.csv"
Error = ""
})
}
catch {
$summary.Add([pscustomobject]@{
Created = (Get-Date -Format "yyyy-MM-dd HH:mm:ss")
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 |
Sort-Object DatabaseName, @{ Expression = "Score"; Descending = $true }, ObjectName |
Export-Csv -LiteralPath $candidatePath -NoTypeInformation -Encoding UTF8 -Delimiter ";"
}
else {
"" | Set-Content -LiteralPath $candidatePath -Encoding UTF8
}
if ($ExportSamples -and $allCandidates.Count -gt 0) {
$sampleObjects = @(
$allCandidates |
Sort-Object @{ Expression = "Score"; Descending = $true }, DatabaseName, ObjectName |
Select-Object -First $MaxSampleObjects
)
foreach ($candidate in $sampleObjects) {
$db = [string]$candidate.DatabaseName
$schema = [string]$candidate.SchemaName
$objectName = [string]$candidate.ObjectName
$fileName = Normalize-FileName "sample_$db.$schema.$objectName.csv"
$samplePath = Join-Path $runDirectory $fileName
try {
Write-Host "Exporting sample: $db.$schema.$objectName"
$sql = Build-SampleSql -SchemaName $schema -ObjectName $objectName -Rows $SampleRows
$rows = Export-QueryToCsv -DbName $db -Sql $sql -Path $samplePath
$summary.Add([pscustomobject]@{
Created = (Get-Date -Format "yyyy-MM-dd HH:mm:ss")
Database = $db
Object = "$schema.$objectName"
Action = "Sample exported"
Rows = $rows
File = $fileName
Error = ""
})
}
catch {
$summary.Add([pscustomobject]@{
Created = (Get-Date -Format "yyyy-MM-dd HH:mm:ss")
Database = $db
Object = "$schema.$objectName"
Action = "Sample export failed"
Rows = 0
File = $fileName
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"
@"
Alphaplan SQL discovery export
==============================
Created: $(Get-Date -Format "yyyy-MM-dd HH:mm:ss")
Server instance: $ServerInstance
Database filter: $(if ([string]::IsNullOrWhiteSpace($Database)) { "(all accessible user databases)" } else { $Database })
Export samples: $ExportSamples
Sample rows: $SampleRows
Files:
- candidate_objects.csv: SQL tables/views that look relevant for finance, invoices, sales, customers, articles or amounts.
- export_summary.csv: discovery and optional sample export status.
- sample_*.csv: optional small samples from top candidate objects when -ExportSamples is used.
Important:
- The script only reads SQL Server metadata and data.
- It does not change Alphaplan or SQL Server.
- Sample exports are limited with SELECT TOP.
- This is Phase 1 only. The BiDashboard import mapping is a separate step.
Recommended next step:
Send candidate_objects.csv and export_summary.csv to the Alphaplan/DE key user or IT.
They should identify the correct invoice header, invoice line, customer, article and credit note/storno objects.
"@ | Set-Content -LiteralPath $readmePath -Encoding UTF8
if (-not $SkipUpload) {
$resolvedRclone = Resolve-RcloneExecutable -ConfiguredPath $RcloneExe
$target = "${RcloneRemote}:$RcloneTarget"
$rcloneLog = Join-Path $logDirectory ("rclone-alphaplan-discovery-" + (Get-Date -Format "yyyyMMdd") + ".log")
Write-Host "Using rclone: $resolvedRclone"
Write-Host "Checking SharePoint target: $target"
& $resolvedRclone mkdir $target --log-file $rcloneLog --log-level INFO
if ($LASTEXITCODE -ne 0) {
Throw-RcloneError -Message "Could not create/check SharePoint target '$target'. rclone exit code $LASTEXITCODE." -LogPath $rcloneLog
}
$targetListing = & $resolvedRclone lsf $target --max-depth 1 --log-file $rcloneLog --log-level INFO
if ($LASTEXITCODE -ne 0) {
Throw-RcloneError -Message "SharePoint target '$target' is not reachable. rclone exit code $LASTEXITCODE." -LogPath $rcloneLog
}
Write-Host "SharePoint target reachable. Existing items: $(@($targetListing).Count)"
Write-Host "Uploading discovery folder to SharePoint target: $target"
& $resolvedRclone copy $runDirectory $target `
--include "*.csv" `
--include "*.txt" `
--log-file $rcloneLog `
--log-level INFO
if ($LASTEXITCODE -ne 0) {
Throw-RcloneError -Message "rclone upload failed with exit code $LASTEXITCODE." -LogPath $rcloneLog
}
$uploadedCandidate = & $resolvedRclone lsf $target --files-only --include "candidate_objects.csv" --log-file $rcloneLog --log-level INFO
if ($LASTEXITCODE -ne 0 -or -not ($uploadedCandidate | Where-Object { $_ -eq "candidate_objects.csv" })) {
Throw-RcloneError -Message "Upload verification failed. candidate_objects.csv was not listed in '$target'." -LogPath $rcloneLog
}
Write-Host "Upload verified."
Write-Host "rclone log: $rcloneLog"
}
else {
Write-Host "Upload skipped because -SkipUpload was used."
}
Write-Host ""
Write-Host "Alphaplan discovery finished."
Write-Host "Local export: $runDirectory"
Write-Host "Candidates: $candidatePath"
Write-Host "Summary: $summaryPath"
Write-Host "Candidate count: $($allCandidates.Count)"
Write-Host "Upload target: $(if ($SkipUpload) { '(skipped)' } else { "${RcloneRemote}:$RcloneTarget" })"
@@ -0,0 +1,111 @@
@using TrafagSalesExporter.Services
@inject IAdminAccessService AdminAccess
@inject ISnackbar Snackbar
@inject IUiTextService UiText
@inject ILogger<AdminAccessPanel> Logger
@inject NavigationManager Navigation
<MudPaper Class="pa-4 mb-4" Elevation="1" Style="max-width:520px;">
<MudStack Spacing="3">
<MudAlert Severity="Severity.Warning" Variant="Variant.Outlined">
@T("Adminbereich ist geschützt. Bitte anmelden.", "Admin area is protected. Please sign in.")
</MudAlert>
@if (!AdminAccess.IsConfigured)
{
<MudAlert Severity="Severity.Error" Variant="Variant.Filled">
@T("Admin-Zugang ist noch nicht konfiguriert.", "Admin access is not configured yet.")
</MudAlert>
}
<form method="post" action="@AccessUrl">
<input type="hidden" name="returnUrl" value="@Navigation.Uri" />
<MudStack Spacing="3">
<MudTextField T="string" Name="username" Label="@T("Name", "Name")" Disabled="@(!AdminAccess.IsConfigured)" />
<MudTextField T="string" Name="password" Label="@T("Passwort", "Password")" InputType="InputType.Password" Disabled="@(!AdminAccess.IsConfigured)" />
<button type="submit" class="mud-button-root mud-button mud-button-filled mud-button-filled-primary mud-button-filled-size-medium mud-ripple">
@T("Admin entsperren", "Unlock admin")
</button>
</MudStack>
</form>
<MudText Typo="Typo.caption">
@T("Server-Klicks", "Server clicks"): @_unlockClickCount |
@T("Konfiguriert", "Configured"): @(AdminAccess.IsConfigured ? "JA" : "NEIN")
</MudText>
<MudDivider />
<MudExpansionPanels Elevation="0">
<MudExpansionPanel Text="@T("Passwort ändern", "Change password")" Icon="@Icons.Material.Filled.Password">
<MudStack Spacing="3" Class="pt-2">
<MudTextField @bind-Value="_changeUsername" Label="@T("Name", "Name")" Disabled="@(!AdminAccess.IsConfigured)" />
<MudTextField @bind-Value="_currentPassword" Label="@T("Aktuelles Passwort", "Current password")" InputType="InputType.Password" Disabled="@(!AdminAccess.IsConfigured)" />
<MudTextField @bind-Value="_newPassword" Label="@T("Neues Passwort", "New password")" InputType="InputType.Password" HelperText="@T("Mindestens 8 Zeichen.", "At least 8 characters.")" Disabled="@(!AdminAccess.IsConfigured)" />
<MudTextField @bind-Value="_newPasswordRepeat" Label="@T("Neues Passwort wiederholen", "Repeat new password")" InputType="InputType.Password" Disabled="@(!AdminAccess.IsConfigured)" />
<MudButton Variant="Variant.Outlined" Color="Color.Primary" OnClick="ChangePassword"
StartIcon="@Icons.Material.Filled.Save" Disabled="@(!AdminAccess.IsConfigured)">
@T("Passwort speichern", "Save password")
</MudButton>
</MudStack>
</MudExpansionPanel>
</MudExpansionPanels>
</MudStack>
</MudPaper>
@code {
private string? _username;
private string? _password;
private string? _changeUsername;
private string? _currentPassword;
private string? _newPassword;
private string? _newPasswordRepeat;
private int _unlockClickCount;
private string AccessUrl => new Uri(new Uri(Navigation.BaseUri), "access/admin").ToString();
private void Unlock()
{
_unlockClickCount++;
Logger.LogInformation(
"Admin unlock button handler reached. ClickCount={ClickCount}, IsConfigured={IsConfigured}, UsernameLength={UsernameLength}, PasswordLength={PasswordLength}",
_unlockClickCount,
AdminAccess.IsConfigured,
_username?.Length ?? 0,
_password?.Length ?? 0);
if (!AdminAccess.TryUnlock(_username ?? string.Empty, _password ?? string.Empty))
{
Snackbar.Add(T("Admin-Anmeldung fehlgeschlagen.", "Admin sign-in failed."), Severity.Error);
return;
}
_password = string.Empty;
OnUnlocked.InvokeAsync();
}
private void ChangePassword()
{
if (string.IsNullOrWhiteSpace(_newPassword) || _newPassword.Length < 8)
{
Snackbar.Add(T("Das neue Passwort muss mindestens 8 Zeichen lang sein.", "The new password must be at least 8 characters long."), Severity.Warning);
return;
}
if (_newPassword != _newPasswordRepeat)
{
Snackbar.Add(T("Die neuen Passwörter stimmen nicht überein.", "The new passwords do not match."), Severity.Warning);
return;
}
if (!AdminAccess.TryChangePassword(_changeUsername ?? string.Empty, _currentPassword ?? string.Empty, _newPassword))
{
Snackbar.Add(T("Passwort konnte nicht geändert werden. Name oder aktuelles Passwort prüfen.", "Password could not be changed. Check the name or current password."), Severity.Error);
return;
}
_currentPassword = string.Empty;
_newPassword = string.Empty;
_newPasswordRepeat = string.Empty;
Snackbar.Add(T("Passwort wurde geändert.", "Password has been changed."), Severity.Success);
}
private string T(string german, string english) => UiText.Text(german, english);
[Parameter]
public EventCallback OnUnlocked { get; set; }
}
+31 -3
View File
@@ -4,16 +4,44 @@
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Trafag Finanze/Sales Management Cockpit</title>
<base href="/" />
<base href="@BaseHref" />
<link rel="icon" type="image/svg+xml" href="favicon.svg" />
<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=Open+Sans:300,400,600,700,800&display=swap" rel="stylesheet" />
<link href="_content/MudBlazor/MudBlazor.min.css" rel="stylesheet" />
<HeadOutlet @rendermode="@Microsoft.AspNetCore.Components.Web.RenderMode.InteractiveServer" />
</head>
<body>
<Routes @rendermode="@Microsoft.AspNetCore.Components.Web.RenderMode.InteractiveServer" />
<script src="_framework/blazor.web.js"></script>
<script src="@($"{BaseHref}_framework/blazor.web.js")" autostart="false"></script>
<script>
Blazor.start({
circuit: {
configureSignalR: builder => builder.withUrl('@($"{BaseHref}_blazor")')
}
});
</script>
<script src="_content/MudBlazor/MudBlazor.min.js"></script>
<script src="js/download.js"></script>
<script src="js/vendor/three.min.js"></script>
<script src="js/finance3d.js"></script>
</body>
</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,113 @@
@using TrafagSalesExporter.Services
@rendermode @(Microsoft.AspNetCore.Components.Web.RenderMode.InteractiveServer)
@inject IFinanceCockpitAccessService FinanceAccess
@inject ISnackbar Snackbar
@inject NavigationManager Navigation
@inject IUiTextService UiText
@inject ILogger<FinanceCockpitUnlockPanel> Logger
<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>
}
<form method="post" action="@AccessUrl">
<input type="hidden" name="returnUrl" value="@Navigation.Uri" />
<MudStack Spacing="3">
<MudTextField T="string" Name="username" Label="@T("Name", "Name")" Disabled="@(!FinanceAccess.IsConfigured)" />
<MudTextField T="string" Name="password" Label="@T("Passwort", "Password")" InputType="InputType.Password" Disabled="@(!FinanceAccess.IsConfigured)" />
<button type="submit" class="mud-button-root mud-button mud-button-filled mud-button-filled-primary mud-button-filled-size-medium mud-ripple">
@T("Finance Cockpit entsperren", "Unlock Finance Cockpit")
</button>
</MudStack>
</form>
<MudText Typo="Typo.caption">
@T("Server-Klicks", "Server clicks"): @_unlockClickCount |
@T("Konfiguriert", "Configured"): @(FinanceAccess.IsConfigured ? "JA" : "NEIN")
</MudText>
<MudDivider />
<MudExpansionPanels Elevation="0">
<MudExpansionPanel Text="@T("Passwort ändern", "Change password")" Icon="@Icons.Material.Filled.Password">
<MudStack Spacing="3" Class="pt-2">
<MudTextField @bind-Value="_changeUsername" Label="@T("Name", "Name")" Disabled="@(!FinanceAccess.IsConfigured)" />
<MudTextField @bind-Value="_currentPassword" Label="@T("Aktuelles Passwort", "Current password")" InputType="InputType.Password" Disabled="@(!FinanceAccess.IsConfigured)" />
<MudTextField @bind-Value="_newPassword" Label="@T("Neues Passwort", "New password")" InputType="InputType.Password" HelperText="@T("Mindestens 8 Zeichen.", "At least 8 characters.")" Disabled="@(!FinanceAccess.IsConfigured)" />
<MudTextField @bind-Value="_newPasswordRepeat" Label="@T("Neues Passwort wiederholen", "Repeat new password")" InputType="InputType.Password" Disabled="@(!FinanceAccess.IsConfigured)" />
<MudButton Variant="Variant.Outlined" Color="Color.Primary" OnClick="ChangePasswordAsync"
StartIcon="@Icons.Material.Filled.Save" Disabled="@(!FinanceAccess.IsConfigured)">
@T("Passwort speichern", "Save password")
</MudButton>
</MudStack>
</MudExpansionPanel>
</MudExpansionPanels>
</MudStack>
</MudPaper>
@code {
private string? _username;
private string? _password;
private string? _changeUsername;
private string? _currentPassword;
private string? _newPassword;
private string? _newPasswordRepeat;
private int _unlockClickCount;
private string AccessUrl => new Uri(new Uri(Navigation.BaseUri), "access/finance").ToString();
private Task UnlockAsync()
{
_unlockClickCount++;
Logger.LogInformation(
"Finance unlock button handler reached. ClickCount={ClickCount}, IsConfigured={IsConfigured}, UsernameLength={UsernameLength}, PasswordLength={PasswordLength}",
_unlockClickCount,
FinanceAccess.IsConfigured,
_username?.Length ?? 0,
_password?.Length ?? 0);
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 Task ChangePasswordAsync()
{
if (string.IsNullOrWhiteSpace(_newPassword) || _newPassword.Length < 8)
{
Snackbar.Add(T("Das neue Passwort muss mindestens 8 Zeichen lang sein.", "The new password must be at least 8 characters long."), Severity.Warning);
return Task.CompletedTask;
}
if (_newPassword != _newPasswordRepeat)
{
Snackbar.Add(T("Die neuen Passwörter stimmen nicht überein.", "The new passwords do not match."), Severity.Warning);
return Task.CompletedTask;
}
if (!FinanceAccess.TryChangePassword(_changeUsername ?? string.Empty, _currentPassword ?? string.Empty, _newPassword))
{
Snackbar.Add(T("Passwort konnte nicht geändert werden. Name oder aktuelles Passwort prüfen.", "Password could not be changed. Check the name or current password."), Severity.Error);
return Task.CompletedTask;
}
_currentPassword = string.Empty;
_newPassword = string.Empty;
_newPasswordRepeat = string.Empty;
Snackbar.Add(T("Passwort wurde geändert.", "Password has been changed."), Severity.Success);
return Task.CompletedTask;
}
private string T(string german, string english) => UiText.Text(german, english);
}
@@ -0,0 +1,943 @@
@using Microsoft.AspNetCore.Components
@using TrafagSalesExporter.Models
@using TrafagSalesExporter.Services
@inject IUiTextService UiText
@inject IJSRuntime JsRuntime
<MudTabs Elevation="1" Rounded="false" PanelClass="pt-4">
<MudTabPanel Text="@T("Ueberblick", "Overview")" Icon="@Icons.Material.Filled.Dashboard">
@PrintToolbar("hr-kpi-print-overview", T("Ueberblick als PDF", "Overview as PDF"))
<div id="hr-kpi-print-overview" class="hr-print-section">
@PrintHeader(T("Ueberblick", "Overview"))
@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>
</div>
</MudTabPanel>
<MudTabPanel Text="@T("Fluktuation", "Turnover")" Icon="@Icons.Material.Filled.TrendingDown">
@PrintToolbar("hr-kpi-print-turnover", T("Fluktuation als PDF", "Turnover as PDF"))
<div id="hr-kpi-print-turnover" class="hr-print-section">
@PrintHeader(T("Fluktuation", "Turnover"))
@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>
</div>
</MudTabPanel>
<MudTabPanel Text="@T("Ampel", "Status")" Icon="@Icons.Material.Filled.Traffic">
@PrintToolbar("hr-kpi-print-status", T("Ampel als PDF", "Status as PDF"))
<div id="hr-kpi-print-status" class="hr-print-section">
@PrintHeader(T("Ampel", "Status"))
@TrafficLightPanel(Result.TrafficLights)
@MetricGrid(Result.PeriodComparisonMetrics)
</div>
</MudTabPanel>
<MudTabPanel Text="@T("Absenzen", "Absences")" Icon="@Icons.Material.Filled.Sick">
@PrintToolbar("hr-kpi-print-absences", T("Absenzen als PDF", "Absences as PDF"))
<div id="hr-kpi-print-absences" class="hr-print-section">
@PrintHeader(T("Absenzen", "Absences"))
@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>
</div>
</MudTabPanel>
<MudTabPanel Text="@T("Zeit / Ferien", "Time / Vacation")" Icon="@Icons.Material.Filled.EventAvailable">
@PrintToolbar("hr-kpi-print-time-vacation", T("Zeit/Ferien als PDF", "Time/vacation as PDF"))
<div id="hr-kpi-print-time-vacation" class="hr-print-section">
@PrintHeader(T("Zeit / Ferien", "Time / Vacation"))
@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>
</div>
</MudTabPanel>
<MudTabPanel Text="@T("Mitarbeitende", "Employees")" Icon="@Icons.Material.Filled.Groups">
@PrintToolbar("hr-kpi-print-employees", T("Mitarbeitende als PDF", "Employees as PDF"))
<div id="hr-kpi-print-employees" class="hr-print-section">
@PrintHeader(T("Mitarbeitende", "Employees"))
@EmployeesTable(Result.Employees)
</div>
</MudTabPanel>
<MudTabPanel Text="@T("Datenstatus", "Data status")" Icon="@Icons.Material.Filled.FactCheck">
@PrintToolbar("hr-kpi-print-data-status", T("Datenstatus als PDF", "Data status as PDF"))
<div id="hr-kpi-print-data-status" class="hr-print-section">
@PrintHeader(T("Datenstatus", "Data status"))
@FileStatusTable(Result.FileStatuses)
<MudGrid Class="mt-4">
<MudItem xs="12">
@DataQualityTable(Result.DataQualityIssues)
</MudItem>
</MudGrid>
</div>
</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 RenderFragment PrintToolbar(string targetId, string label) => @<MudStack Row Justify="Justify.FlexEnd" Class="mb-3 hr-print-toolbar">
<MudButton Variant="Variant.Outlined" Color="Color.Primary" Size="Size.Small"
StartIcon="@Icons.Material.Filled.PictureAsPdf"
OnClick="@(() => PrintSectionAsync(targetId))">
@label
</MudButton>
</MudStack>;
private RenderFragment PrintHeader(string title) => @<div class="hr-print-header">
<h1>@title</h1>
<p>@Result.Options.DataFolder</p>
<p>@BuildFilterSummary()</p>
</div>;
private async Task PrintSectionAsync(string targetId)
{
await JsRuntime.InvokeVoidAsync("trafagDownload.printElement", targetId);
}
private string BuildFilterSummary()
{
var parts = new List<string>();
if (Result.Options.FromDate.HasValue || Result.Options.ToDate.HasValue)
parts.Add($"{T("Zeitraum", "Period")}: {FormatDate(Result.Options.FromDate)} - {FormatDate(Result.Options.ToDate)}");
if (Result.Options.Year.HasValue)
parts.Add($"{T("Austrittsjahr", "Leaver year")}: {Result.Options.Year.Value}");
parts.Add($"{T("Organisation", "Organisation")}: {BlankAsAll(Result.Options.Organisationseinheit)}");
parts.Add($"{T("Mitarbeitertyp", "Employee type")}: {BlankAsAll(Result.Options.Mitarbeitertyp)}");
if (!string.IsNullOrWhiteSpace(Result.Options.KostenstelleText))
parts.Add($"{T("Kostenstelle", "Cost center")}: {Result.Options.KostenstelleText}");
return string.Join(" | ", parts);
}
private string BlankAsAll(string? value)
=> string.IsNullOrWhiteSpace(value) ? T("Alle", "All") : value;
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-print-header {
display: none;
}
.hr-print-toolbar {
width: 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
@implements IDisposable
@using System.Security.Claims
@inject TrafagSalesExporter.Services.IUiTextService UiText
<MudThemeProvider Theme="_theme" />
@@ -11,18 +12,35 @@
<MudAppBar Elevation="1" Color="Color.Primary">
<MudIconButton Icon="@Icons.Material.Filled.Menu" Color="Color.Inherit" Edge="Edge.Start"
OnClick="ToggleDrawer" />
<MudText Typo="Typo.h6" Class="ml-3 app-title">@T("Trafag Finanze/Sales Management Cockpit", "Trafag Finance/Sales Management Cockpit")</MudText>
<MudText Typo="Typo.h6" Class="ml-3 app-title">@T("Trafag Finance/Sales Management Cockpit", "Trafag Finance/Sales Management Cockpit")</MudText>
<MudSpacer />
<MudSelect T="string"
Value="@UiText.CurrentLanguage"
ValueChanged="ChangeLanguage"
Dense
Variant="Variant.Outlined"
Class="mr-3"
Style="min-width:100px; color:white;">
<MudSelectItem Value="@("de")">DE</MudSelectItem>
<MudSelectItem Value="@("en")">EN</MudSelectItem>
</MudSelect>
<MudMenu Class="mr-3 language-menu"
AnchorOrigin="Origin.BottomRight"
TransformOrigin="Origin.TopRight"
Dense>
<ActivatorContent>
<MudButton Variant="Variant.Outlined"
Color="Color.Inherit"
Size="Size.Small"
StartIcon="@Icons.Material.Filled.Translate"
EndIcon="@Icons.Material.Filled.ExpandMore"
Class="language-button">
@LanguageLabel
</MudButton>
</ActivatorContent>
<ChildContent>
<MudMenuItem OnClick="@(() => ChangeLanguage("de"))">Deutsch</MudMenuItem>
<MudMenuItem OnClick="@(() => ChangeLanguage("en"))">English</MudMenuItem>
<MudMenuItem OnClick="@(() => ChangeLanguage("es"))">Español</MudMenuItem>
<MudMenuItem OnClick="@(() => ChangeLanguage("it"))">Italiano</MudMenuItem>
<MudMenuItem OnClick="@(() => ChangeLanguage("hi"))">हिन्दी</MudMenuItem>
</ChildContent>
</MudMenu>
<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" />
</MudAppBar>
@@ -65,8 +83,17 @@
InvokeAsync(StateHasChanged);
}
private string LanguageLabel => UiText.CurrentLanguage.ToUpperInvariant();
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()
{
UiText.Changed -= HandleLanguageChanged;
@@ -1,26 +1,96 @@
@inject TrafagSalesExporter.Services.IUiTextService UiText
@using Microsoft.AspNetCore.Authorization
@using Microsoft.AspNetCore.Components.Authorization
@using TrafagSalesExporter.Models
@using TrafagSalesExporter.Security
@using TrafagSalesExporter.Services
@implements IDisposable
@inject IUiTextService UiText
@inject IFinanceCockpitAccessService FinanceAccess
@inject INavigationMenuService NavigationMenuService
@inject IConfiguration Configuration
@inject NavigationManager Navigation
@inject AuthenticationStateProvider AuthenticationStateProvider
@inject IAuthorizationService AuthorizationService
<MudNavMenu>
<MudNavLink Href="/" Match="NavLinkMatch.All" Icon="@Icons.Material.Filled.Dashboard">
@T("Dashboard", "Dashboard")
</MudNavLink>
<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="/management-cockpit" Match="NavLinkMatch.Prefix" Icon="@Icons.Material.Filled.Analytics">
@T("Management Cockpit", "Management Cockpit")
</MudNavLink>
<MudNavLink Href="/settings" Match="NavLinkMatch.Prefix" Icon="@Icons.Material.Filled.Settings">
@T("Settings", "Settings")
</MudNavLink>
<MudNavLink Href="/logs" Match="NavLinkMatch.Prefix" Icon="@Icons.Material.Filled.List">
@T("Logs", "Logs")
</MudNavLink>
@foreach (var item in RootItems)
{
<NavMenuNode Item="item"
Items="_visibleItems"
HiddenKeys="_hiddenKeys"
OnAction="HandleMenuActionAsync" />
}
</MudNavMenu>
@code {
private string T(string german, string english) => UiText.Text(german, english);
private List<NavigationMenuItem> _visibleItems = [];
private readonly HashSet<string> _hiddenKeys = [];
private bool ShowFinanceComparison => Configuration.GetValue("Navigation:ShowFinanceComparison", true);
private IEnumerable<NavigationMenuItem> RootItems => _visibleItems
.Where(x => string.IsNullOrWhiteSpace(x.ParentKey))
.Where(x => !_hiddenKeys.Contains(x.Key))
.OrderBy(x => x.SortOrder)
.ThenBy(x => x.TitleDe);
protected override async Task OnInitializedAsync()
{
UiText.Changed += HandleLanguageChanged;
await LoadMenuAsync();
}
private async Task LoadMenuAsync()
{
var items = await NavigationMenuService.GetItemsAsync();
var authenticationState = await AuthenticationStateProvider.GetAuthenticationStateAsync();
var user = authenticationState.User;
var filtered = new List<NavigationMenuItem>();
foreach (var item in items.Where(x => x.IsVisible))
{
if (!await IsAuthorizedAsync(user, item))
continue;
filtered.Add(item);
}
_hiddenKeys.Clear();
if (!ShowFinanceComparison)
_hiddenKeys.Add("finance-comparison");
if (!FinanceAccess.IsEnabled || !FinanceAccess.IsUnlocked)
_hiddenKeys.Add("finance-lock");
_visibleItems = filtered;
}
private async Task<bool> IsAuthorizedAsync(System.Security.Claims.ClaimsPrincipal user, NavigationMenuItem item)
{
if (string.IsNullOrWhiteSpace(item.RequiredPolicy))
return true;
var result = await AuthorizationService.AuthorizeAsync(user, item.RequiredPolicy);
return result.Succeeded;
}
private Task HandleMenuActionAsync(string key)
{
if (key == "finance-lock")
{
FinanceAccess.Lock();
Navigation.NavigateTo(string.Empty);
}
return Task.CompletedTask;
}
private void HandleLanguageChanged()
{
InvokeAsync(StateHasChanged);
}
public void Dispose()
{
UiText.Changed -= HandleLanguageChanged;
}
}
@@ -0,0 +1,51 @@
@using TrafagSalesExporter.Models
@using TrafagSalesExporter.Services
@using Microsoft.AspNetCore.Components.Routing
@if (Item.ItemType == NavigationMenuItemTypes.Group)
{
<MudNavGroup Title="@Title" Icon="@Icon" Expanded="@Item.IsExpanded">
@foreach (var child in Children)
{
<NavMenuNode Item="child"
Items="Items"
HiddenKeys="HiddenKeys"
OnAction="OnAction" />
}
</MudNavGroup>
}
else if (Item.ItemType == NavigationMenuItemTypes.Action)
{
<MudButton Variant="Variant.Text" Color="Color.Secondary" Size="Size.Small"
StartIcon="@Icon" OnClick="() => OnAction.InvokeAsync(Item.Key)" Class="ml-3">
@Title
</MudButton>
}
else
{
<MudNavLink Href="@Item.Href" Match="@Match" Icon="@Icon">
@Title
</MudNavLink>
}
@code {
[Parameter, EditorRequired] public NavigationMenuItem Item { get; set; } = default!;
[Parameter, EditorRequired] public IReadOnlyList<NavigationMenuItem> Items { get; set; } = [];
[Parameter] public HashSet<string> HiddenKeys { get; set; } = [];
[Parameter] public EventCallback<string> OnAction { get; set; }
private string Title => UiText.Text(Item.TitleDe, Item.TitleEn);
private string Icon => NavigationIconResolver.Resolve(Item.Icon);
private NavLinkMatch Match => string.Equals(Item.Match, "All", StringComparison.OrdinalIgnoreCase)
? NavLinkMatch.All
: NavLinkMatch.Prefix;
[Inject] private IUiTextService UiText { get; set; } = default!;
private IEnumerable<NavigationMenuItem> Children => Items
.Where(x => x.IsVisible)
.Where(x => !HiddenKeys.Contains(x.Key))
.Where(x => string.Equals(x.ParentKey, Item.Key, StringComparison.OrdinalIgnoreCase))
.OrderBy(x => x.SortOrder)
.ThenBy(x => x.TitleDe);
}
@@ -0,0 +1,104 @@
@page "/admin/sessions"
@rendermode @(Microsoft.AspNetCore.Components.Web.RenderMode.InteractiveServer)
@attribute [Authorize(Policy = TrafagSalesExporter.Security.SecurityPolicies.AdminOnly)]
@using TrafagSalesExporter.Services
@inject IAccessSessionTracker SessionTracker
@inject IAdminAccessService AdminAccess
@inject ILandingPageSettingsService LandingSettings
@inject IUiTextService UiText
<PageTitle>@T("Aktive Logins", "Active logins")</PageTitle>
<MudText Typo="Typo.h4" Class="mb-4">@T("Aktive Logins", "Active logins")</MudText>
@if (!AdminAccess.IsUnlocked)
{
<AdminAccessPanel OnUnlocked="Refresh" />
}
else
{
<MudPaper Class="pa-4 mb-4" Elevation="1">
<MudStack Row AlignItems="AlignItems.Center" Class="mb-4">
<div>
<MudText Typo="Typo.h6">@T("Startseite", "Landing page")</MudText>
<MudText Typo="Typo.caption">
@T("Optionale Animation unter dem Willkommens-Text.", "Optional animation below the welcome text.")
</MudText>
</div>
<MudSpacer />
<MudSwitch T="bool" Value="LandingSettings.ShowWalkingLabFigure" ValueChanged="SetWalkingFigure"
Color="Color.Primary"
Label="@T("Strichmännchen anzeigen", "Show stick figure")" />
</MudStack>
<MudDivider Class="mb-4" />
<MudStack Row AlignItems="AlignItems.Center" Class="mb-3">
<div>
<MudText Typo="Typo.h6">@T("HR-/Finance-Cockpit Sessions", "HR/Finance cockpit sessions")</MudText>
<MudText Typo="Typo.caption">
@T("Gezählt werden App-interne Entsperrungen seit dem letzten App-Start.", "Counts app-internal unlocks since the last app start.")
</MudText>
</div>
<MudSpacer />
<MudButton Variant="Variant.Outlined" Color="Color.Secondary" StartIcon="@Icons.Material.Filled.Lock" OnClick="LockAdmin">
@T("Admin sperren", "Lock admin")
</MudButton>
<MudButton Variant="Variant.Outlined" Color="Color.Primary" StartIcon="@Icons.Material.Filled.Refresh" OnClick="Refresh">
@T("Aktualisieren", "Refresh")
</MudButton>
</MudStack>
<MudTable Items="_sessions" Dense Hover Striped>
<HeaderContent>
<MudTh>@T("Bereich", "Area")</MudTh>
<MudTh>@T("Name", "Name")</MudTh>
<MudTh>@T("IP-Adresse", "IP address")</MudTh>
<MudTh>@T("Entsperrt seit", "Unlocked since")</MudTh>
<MudTh>@T("Zuletzt gesehen", "Last seen")</MudTh>
</HeaderContent>
<RowTemplate>
<MudTd>@context.Area</MudTd>
<MudTd>@context.Username</MudTd>
<MudTd>@context.RemoteAddress</MudTd>
<MudTd>@FormatDate(context.StartedAt)</MudTd>
<MudTd>@FormatDate(context.LastSeenAt)</MudTd>
</RowTemplate>
<NoRecordsContent>
<MudText Typo="Typo.caption">@T("Keine aktiven HR-/Finance-Logins erfasst.", "No active HR/Finance logins recorded.")</MudText>
</NoRecordsContent>
</MudTable>
</MudPaper>
<MudAlert Severity="Severity.Info" Variant="Variant.Outlined">
@T("Hinweis: HR und Finance verwenden gemeinsame App-Logins. Diese Seite zeigt daher den verwendeten Login-Namen und die Session, nicht zwingend die echte Person.", "Note: HR and Finance use shared app logins. This page therefore shows the used login name and session, not necessarily the real person.")
</MudAlert>
}
@code {
private IReadOnlyList<AccessSessionSnapshot> _sessions = [];
protected override void OnInitialized()
{
Refresh();
}
private void Refresh()
{
_sessions = SessionTracker.GetActiveSessions();
}
private void LockAdmin()
{
AdminAccess.Lock();
_sessions = [];
}
private void SetWalkingFigure(bool value)
{
LandingSettings.SetShowWalkingLabFigure(value);
}
private static string FormatDate(DateTimeOffset value)
=> value.ToString("dd.MM.yyyy HH:mm:ss");
private string T(string german, string english) => UiText.Text(german, english);
}
@@ -1,372 +1,251 @@
@page "/"
@using System.Diagnostics
@rendermode @(Microsoft.AspNetCore.Components.Web.RenderMode.InteractiveServer)
@using TrafagSalesExporter.Services
@inject IDashboardPageService DashboardPageActions
@inject ExportOrchestrationService Orchestrator
@inject TimerBackgroundService TimerService
@inject ISnackbar Snackbar
@inject IUiTextService UiText
@implements IDisposable
@inject ILandingPageSettingsService LandingSettings
<PageTitle>@T("Dashboard", "Dashboard")</PageTitle>
<PageTitle>@T("Trafag Cockpit", "Trafag Cockpit")</PageTitle>
<MudText Typo="Typo.h4" Class="mb-4">@T("Dashboard", "Dashboard")</MudText>
<div class="home-shell">
<div class="home-content">
<svg class="home-manometer" viewBox="0 0 600 340" role="img" aria-label="Trafag cockpit manometer">
<rect x="0" y="0" width="600" height="340" fill="#fff" />
<path d="M70 260 A230 230 0 0 1 530 260" class="gauge-outer" />
<path d="M115 260 A185 185 0 0 1 485 260" class="gauge-inner" />
<MudPaper Class="pa-4 mb-4" Elevation="1">
<MudStack Row AlignItems="AlignItems.Center" Spacing="4">
<MudButton Variant="Variant.Filled" Color="Color.Primary" StartIcon="@Icons.Material.Filled.PlayArrow"
OnClick="ExportAll" Disabled="_anyRunning">
@T("Alle exportieren", "Export all")
</MudButton>
<MudButton Variant="Variant.Outlined" Color="Color.Secondary" StartIcon="@Icons.Material.Filled.TableView"
OnClick="ExportConsolidatedOnly" Disabled="_anyRunning">
@T("Zentrale Datei neu erzeugen", "Rebuild consolidated file")
</MudButton>
<MudText Typo="Typo.body1">
@if (TimerService.NextRun < DateTime.MaxValue)
{
<MudIcon Icon="@Icons.Material.Filled.Schedule" Size="Size.Small" Class="mr-1" />
@(string.Format(T("Naechster automatischer Lauf: {0}", "Next automatic run: {0}"), TimerService.NextRun.ToString("dd.MM.yyyy HH:mm")))
}
else
{
<MudIcon Icon="@Icons.Material.Filled.TimerOff" Size="Size.Small" Class="mr-1" />
@T("Timer deaktiviert", "Timer disabled")
}
</MudText>
</MudStack>
</MudPaper>
<line x1="126" y1="260" x2="95" y2="260" class="gauge-tick" />
<line x1="177" y1="137" x2="155" y2="115" class="gauge-tick" />
<line x1="300" y1="86" x2="300" y2="55" class="gauge-tick" />
<line x1="423" y1="137" x2="445" y2="115" class="gauge-tick" />
<line x1="474" y1="260" x2="505" y2="260" class="gauge-tick" />
<MudTable Items="_dashboardRows" Dense Hover Striped Loading="_loading">
<HeaderContent>
<MudTh>@T("Land", "Country")</MudTh>
<MudTh>TSC</MudTh>
<MudTh>@T("Schema", "Schema")</MudTh>
<MudTh>@T("Server", "Server")</MudTh>
<MudTh>@T("Status", "Status")</MudTh>
<MudTh>@T("Live-Status", "Live status")</MudTh>
<MudTh>@T("Zeilen", "Rows")</MudTh>
<MudTh>@T("Letzter Lauf", "Last run")</MudTh>
<MudTh>@T("Dauer", "Duration")</MudTh>
<MudTh>@T("Aktion", "Action")</MudTh>
</HeaderContent>
<RowTemplate>
<MudTd>@context.Land</MudTd>
<MudTd>@context.TSC</MudTd>
<MudTd>@context.Schema</MudTd>
<MudTd>@context.ServerName</MudTd>
<MudTd>
@if (Orchestrator.IsExporting(context.SiteId))
{
<MudProgressCircular Size="Size.Small" Indeterminate Color="Color.Primary" Class="mr-1" />
<MudText Typo="Typo.caption">@Orchestrator.GetExportStatus(context.SiteId)</MudText>
}
else if (context.LastStatus == "OK")
{
<MudIcon Icon="@Icons.Material.Filled.CheckCircle" Color="Color.Success" Size="Size.Small" />
}
else if (context.LastStatus == "Error")
{
<MudTooltip Text="@context.ErrorMessage">
<MudIcon Icon="@Icons.Material.Filled.Error" Color="Color.Error" Size="Size.Small" />
</MudTooltip>
}
else
{
<MudText Typo="Typo.caption" Color="Color.Default">-</MudText>
}
</MudTd>
<MudTd>
@if (!string.IsNullOrWhiteSpace(context.LiveMessage))
{
<MudTooltip Text="@context.LiveDetails">
<MudText Typo="Typo.caption" Style="max-width:360px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; display:block;">
@context.LiveMessage
</MudText>
</MudTooltip>
}
else
{
<MudText Typo="Typo.caption" Color="Color.Default">-</MudText>
}
</MudTd>
<MudTd>@(context.RowCount > 0 ? context.RowCount.ToString("N0") : "-")</MudTd>
<MudTd>@(context.LastRun.HasValue ? context.LastRun.Value.ToString("dd.MM.yyyy HH:mm:ss") : "-")</MudTd>
<MudTd>@(context.DurationSeconds > 0 ? $"{context.DurationSeconds:F1}s" : "-")</MudTd>
<MudTd>
<MudStack Row Spacing="1">
<MudButton Size="Size.Small" Variant="Variant.Outlined" Color="Color.Primary"
StartIcon="@Icons.Material.Filled.FileDownload"
OnClick="() => ExportSingle(context.SiteId)"
Disabled="Orchestrator.IsExporting(context.SiteId)">
Export
</MudButton>
<MudButton Size="Size.Small" Variant="Variant.Text" Color="Color.Info"
StartIcon="@Icons.Material.Filled.OpenInNew"
OnClick="() => OpenExportFile(context)"
Disabled="@(!context.HasOpenableFile || Orchestrator.IsExporting(context.SiteId))">
@T("Excel oeffnen", "Open Excel")
</MudButton>
</MudStack>
</MudTd>
</RowTemplate>
</MudTable>
<text x="150" y="230" class="gauge-label">0</text>
<text x="205" y="154" class="gauge-label">25</text>
<text x="300" y="126" class="gauge-label">50</text>
<text x="395" y="154" class="gauge-label">75</text>
<text x="450" y="230" class="gauge-label">100</text>
<text x="300" y="222" class="gauge-brand">TRAFAG</text>
<MudPaper Class="pa-4 mt-4" Elevation="1">
<MudText Typo="Typo.h6" Class="mb-3">@T("Zentrale Datei", "Consolidated file")</MudText>
<MudTable Items="_consolidatedRows" Dense Hover Striped>
<HeaderContent>
<MudTh>@T("Datei", "File")</MudTh>
<MudTh>Pfad</MudTh>
<MudTh>Letzte Änderung</MudTh>
<MudTh>@T("Status", "Status")</MudTh>
<MudTh>@T("Aktion", "Action")</MudTh>
</HeaderContent>
<RowTemplate>
<MudTd>@context.Label</MudTd>
<MudTd>@context.DisplayPath</MudTd>
<MudTd>@(context.LastModified.HasValue ? context.LastModified.Value.ToString("dd.MM.yyyy HH:mm:ss") : "-")</MudTd>
<MudTd>
@if (Orchestrator.IsConsolidatedExporting())
{
<MudProgressCircular Size="Size.Small" Indeterminate Color="Color.Primary" Class="mr-1" />
<MudText Typo="Typo.caption">@Orchestrator.GetConsolidatedExportStatus()</MudText>
}
else
{
<MudText Typo="Typo.caption" Color="Color.Default">-</MudText>
}
</MudTd>
<MudTd>
<MudButton Size="Size.Small" Variant="Variant.Text" Color="Color.Info"
StartIcon="@Icons.Material.Filled.OpenInNew"
OnClick="() => OpenFile(context.FilePath)"
Disabled="@(!context.HasOpenableFile)">
@T("Excel oeffnen", "Open Excel")
</MudButton>
</MudTd>
</RowTemplate>
<NoRecordsContent>
<MudText Typo="Typo.caption">@T("Keine zentrale Excel-Datei gefunden.", "No consolidated Excel file found.")</MudText>
</NoRecordsContent>
</MudTable>
</MudPaper>
@code {
private List<DashboardRow> _dashboardRows = new();
private List<ConsolidatedDashboardRow> _consolidatedRows = new();
private bool _loading = true;
private bool _anyRunning;
private CancellationTokenSource? _pollingCts;
protected override async Task OnInitializedAsync()
{
Orchestrator.OnExportStatusChanged += HandleStatusChanged;
await LoadDataAsync();
}
private async Task LoadDataAsync()
{
_loading = true;
var state = await DashboardPageActions.LoadAsync();
_dashboardRows = state.DashboardRows;
_consolidatedRows = state.ConsolidatedRows;
_anyRunning = _dashboardRows.Any(r => Orchestrator.IsExporting(r.SiteId)) || Orchestrator.IsConsolidatedExporting();
_loading = false;
}
private async Task ExportAll()
{
_anyRunning = true;
await LoadDataAsync();
StartPolling();
_ = Task.Run(async () =>
<g class="gauge-needle">
<line x1="300" y1="260" x2="300" y2="96" class="needle-line" />
</g>
<circle cx="300" cy="260" r="28" fill="#050505" />
</svg>
<div class="home-welcome">@T("Willkommen im Trafag Analyse Dashboard", "Welcome to the Trafag Analytical Dashboard")</div>
@if (LandingSettings.ShowWalkingLabFigure)
{
await Orchestrator.ExportAllAsync();
await InvokeAsync(async () =>
{
await LoadDataAsync();
StateHasChanged();
});
});
Snackbar.Add(T("Export fuer alle Standorte gestartet", "Export started for all sites"), Severity.Info);
}
private async Task ExportConsolidatedOnly()
{
_anyRunning = true;
await LoadDataAsync();
StartPolling();
_ = Task.Run(async () =>
{
var filePath = await Orchestrator.ExportConsolidatedOnlyAsync();
await InvokeAsync(async () =>
{
await LoadDataAsync();
StateHasChanged();
});
if (!string.IsNullOrWhiteSpace(filePath))
{
await InvokeAsync(() =>
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.", "Consolidated file could not be created."), Severity.Warning));
}
});
Snackbar.Add(T("Zentrale Datei wird erzeugt", "Building consolidated file"), Severity.Info);
}
private void ExportSingle(int siteId)
{
_anyRunning = true;
_ = InvokeAsync(async () => await LoadDataAsync());
StartPolling();
_ = Task.Run(async () =>
{
var result = await Orchestrator.ExportSiteByIdAsync(siteId);
await InvokeAsync(async () =>
{
await LoadDataAsync();
StateHasChanged();
});
if (result?.Log.Status == "OK" && !string.IsNullOrWhiteSpace(result.FilePath))
{
await InvokeAsync(() =>
Snackbar.Add(string.Format(T("Export gespeichert: {0}", "Export saved: {0}"), result.FilePath), Severity.Success));
}
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));
}
});
Snackbar.Add(T("Export gestartet", "Export started"), Severity.Info);
}
private async void HandleStatusChanged()
{
await InvokeAsync(async () =>
{
_anyRunning = _dashboardRows.Any(r => Orchestrator.IsExporting(r.SiteId)) || Orchestrator.IsConsolidatedExporting() || _dashboardRows.Count == 0;
if (_anyRunning)
{
StartPolling();
await RefreshLiveDataAsync();
StateHasChanged();
return;
}
StopPolling();
await LoadDataAsync();
StateHasChanged();
});
}
public void Dispose()
{
StopPolling();
Orchestrator.OnExportStatusChanged -= HandleStatusChanged;
}
private void OpenExportFile(DashboardRow row)
{
OpenFile(row.FilePath);
}
private void OpenFile(string filePath)
{
if (string.IsNullOrWhiteSpace(filePath) || !File.Exists(filePath))
{
Snackbar.Add(T("Exportdatei nicht gefunden.", "Export file not found."), Severity.Warning);
return;
<div class="walking-stage" aria-label="Walking lab figure">
<div class="walking-person">
<span class="head"></span>
<span class="body"></span>
<span class="coat coat-left"></span>
<span class="coat coat-right"></span>
<span class="arm arm-left"></span>
<span class="arm arm-right"></span>
<span class="leg leg-left"></span>
<span class="leg leg-right"></span>
</div>
</div>
}
</div>
</div>
try
{
Process.Start(new ProcessStartInfo
{
FileName = filePath,
UseShellExecute = true
});
}
catch (Exception ex)
{
Snackbar.Add(string.Format(T("Datei konnte nicht geoeffnet werden: {0}", "Could not open file: {0}"), ex.Message), Severity.Error);
}
<style>
.home-shell {
min-height: calc(100vh - 112px);
display: flex;
align-items: center;
justify-content: center;
background: #fff;
}
private void StartPolling()
{
if (_pollingCts is not null && !_pollingCts.IsCancellationRequested)
return;
_pollingCts = new CancellationTokenSource();
_ = PollDashboardAsync(_pollingCts.Token);
.home-content {
display: flex;
flex-direction: column;
align-items: center;
gap: 18px;
}
private void StopPolling()
{
_pollingCts?.Cancel();
_pollingCts?.Dispose();
_pollingCts = null;
.home-manometer {
width: min(336px, 58vw);
height: auto;
display: block;
}
private async Task PollDashboardAsync(CancellationToken cancellationToken)
{
using var timer = new PeriodicTimer(TimeSpan.FromSeconds(3));
try
{
while (await timer.WaitForNextTickAsync(cancellationToken))
{
var anyRunning = _dashboardRows.Any(r => Orchestrator.IsExporting(r.SiteId)) || Orchestrator.IsConsolidatedExporting();
if (!anyRunning)
{
await InvokeAsync(async () =>
{
_anyRunning = false;
await LoadDataAsync();
StateHasChanged();
});
StopPolling();
break;
}
await InvokeAsync(async () =>
{
_anyRunning = true;
await RefreshLiveDataAsync();
StateHasChanged();
});
}
}
catch (OperationCanceledException)
{
}
.home-welcome {
color: #050505;
font-size: 24px;
font-weight: 700;
text-align: center;
letter-spacing: 0;
}
private Task RefreshLiveDataAsync()
{
foreach (var row in _dashboardRows)
{
if (!Orchestrator.IsExporting(row.SiteId))
continue;
row.LiveMessage = Orchestrator.GetExportStatus(row.SiteId);
row.LiveDetails = string.Empty;
}
_anyRunning = _dashboardRows.Any(r => Orchestrator.IsExporting(r.SiteId)) || Orchestrator.IsConsolidatedExporting();
return Task.CompletedTask;
.walking-stage {
width: min(360px, 70vw);
height: 96px;
position: relative;
overflow: hidden;
background: #fff;
border-bottom: 2px solid #050505;
}
}
.walking-person {
position: absolute;
left: 0;
bottom: 4px;
width: 48px;
height: 82px;
animation: lab-walk-path 7s linear infinite;
}
.walking-person span {
position: absolute;
display: block;
background: #050505;
}
.walking-person .head {
left: 15px;
top: 0;
width: 18px;
height: 18px;
border: 3px solid #050505;
border-radius: 50%;
background: #fff;
}
.walking-person .body {
left: 22px;
top: 22px;
width: 4px;
height: 34px;
}
.walking-person .coat {
top: 25px;
width: 17px;
height: 36px;
border: 3px solid #050505;
background: #fff;
}
.walking-person .coat-left {
left: 8px;
transform: skewY(10deg);
border-right: 0;
}
.walking-person .coat-right {
left: 23px;
transform: skewY(-10deg);
border-left: 0;
}
.walking-person .arm,
.walking-person .leg {
width: 4px;
border-radius: 4px;
transform-origin: 50% 0;
}
.walking-person .arm {
top: 28px;
height: 28px;
animation: limb-swing 0.72s ease-in-out infinite alternate;
}
.walking-person .arm-left {
left: 13px;
}
.walking-person .arm-right {
left: 31px;
animation-direction: alternate-reverse;
}
.walking-person .leg {
top: 56px;
height: 28px;
animation: limb-swing 0.72s ease-in-out infinite alternate-reverse;
}
.walking-person .leg-left {
left: 20px;
}
.walking-person .leg-right {
left: 27px;
animation-direction: alternate;
}
@@keyframes lab-walk-path {
0% { transform: translateX(-54px); }
100% { transform: translateX(calc(min(360px, 70vw) + 54px)); }
}
@@keyframes limb-swing {
0% { transform: rotate(-24deg); }
100% { transform: rotate(24deg); }
}
.gauge-outer,
.gauge-inner,
.gauge-tick,
.needle-line {
fill: none;
stroke: #050505;
stroke-linecap: round;
}
.gauge-outer {
stroke-width: 16;
}
.gauge-inner {
stroke-width: 4;
}
.gauge-tick {
stroke-width: 7;
}
.gauge-label {
fill: #050505;
font-size: 24px;
font-weight: 800;
text-anchor: middle;
dominant-baseline: middle;
}
.gauge-brand {
fill: #050505;
font-size: 28px;
font-weight: 900;
letter-spacing: 4px;
text-anchor: middle;
}
.needle-line {
stroke-width: 9;
}
.gauge-needle {
transform-origin: 300px 260px;
animation: home-gauge-sweep 6.2s infinite cubic-bezier(.42, 0, .2, 1);
}
@@keyframes home-gauge-sweep {
0% { transform: rotate(-58deg); }
9% { transform: rotate(-12deg); }
18% { transform: rotate(43deg); }
31% { transform: rotate(8deg); }
44% { transform: rotate(68deg); }
58% { transform: rotate(-35deg); }
72% { transform: rotate(24deg); }
86% { transform: rotate(56deg); }
100% { transform: rotate(-58deg); }
}
</style>
@code {
private string T(string german, string english) => UiText.Text(german, english);
@@ -0,0 +1,610 @@
@page "/export-dashboard"
@rendermode @(Microsoft.AspNetCore.Components.Web.RenderMode.InteractiveServer)
@using System.Diagnostics
@using TrafagSalesExporter.Services
@inject IDashboardPageService DashboardPageActions
@inject ExportOrchestrationService Orchestrator
@inject TimerBackgroundService TimerService
@inject ISnackbar Snackbar
@inject IUiTextService UiText
@implements IDisposable
<PageTitle>@T("Export Dashboard", "Export dashboard")</PageTitle>
<MudText Typo="Typo.h4" Class="mb-4">@T("Export Dashboard", "Export dashboard")</MudText>
<MudPaper Class="pa-4 mb-4" Elevation="1">
<div class="dashboard-header">
<MudStack Row AlignItems="AlignItems.Center" Spacing="4" Class="dashboard-actions">
<MudButton Variant="Variant.Filled" Color="Color.Primary" StartIcon="@Icons.Material.Filled.PlayArrow"
OnClick="ExportAll" Disabled="_anyRunning">
@T("Alle exportieren", "Export all")
</MudButton>
<MudButton Variant="Variant.Outlined" Color="Color.Secondary" StartIcon="@Icons.Material.Filled.TableView"
OnClick="ExportConsolidatedOnly" Disabled="_anyRunning">
@T("Zentrale Datei neu erzeugen", "Rebuild consolidated file")
</MudButton>
<MudText Typo="Typo.body1">
@if (TimerService.NextRun < DateTime.MaxValue)
{
<MudIcon Icon="@Icons.Material.Filled.Schedule" Size="Size.Small" Class="mr-1" />
@(string.Format(T("Naechster automatischer Lauf: {0}", "Next automatic run: {0}"), TimerService.NextRun.ToString("dd.MM.yyyy HH:mm")))
}
else
{
<MudIcon Icon="@Icons.Material.Filled.TimerOff" Size="Size.Small" Class="mr-1" />
@T("Timer deaktiviert", "Timer disabled")
}
</MudText>
</MudStack>
<div class="dashboard-manometer">
<svg class="manometer-svg" viewBox="0 0 210 118" role="img" aria-label="Export activity manometer">
<path class="manometer-outer" d="M25 98 A80 80 0 0 1 185 98" />
<path class="manometer-inner" d="M47 98 A58 58 0 0 1 163 98" />
<line class="manometer-tick" x1="38" y1="98" x2="56" y2="98" />
<line class="manometer-tick" x1="61" y1="54" x2="74" y2="67" />
<line class="manometer-tick" x1="105" y1="34" x2="105" y2="52" />
<line class="manometer-tick" x1="149" y1="54" x2="136" y2="67" />
<line class="manometer-tick" x1="172" y1="98" x2="154" y2="98" />
<text class="manometer-label" x="43" y="89">0</text>
<text class="manometer-label" x="67" y="53">25</text>
<text class="manometer-label" x="105" y="28">50</text>
<text class="manometer-label" x="143" y="53">75</text>
<text class="manometer-label" x="167" y="89">100</text>
<text class="manometer-caption" x="105" y="113">EXPORT</text>
<g class="manometer-needle">
<line class="needle-line" x1="105" y1="98" x2="105" y2="38" />
</g>
<circle class="manometer-hub" cx="105" cy="98" r="11" />
</svg>
</div>
</div>
</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">
<HeaderContent>
<MudTh>@T("Land", "Country")</MudTh>
<MudTh>@T("Basis", "Basis")</MudTh>
<MudTh>TSC</MudTh>
<MudTh>@T("Schema", "Schema")</MudTh>
<MudTh>@T("Server", "Server")</MudTh>
<MudTh>@T("Status", "Status")</MudTh>
<MudTh>@T("Live-Status", "Live status")</MudTh>
<MudTh>@T("Zeilen", "Rows")</MudTh>
<MudTh>@T("Letzter Lauf", "Last run")</MudTh>
<MudTh>@T("Dauer", "Duration")</MudTh>
<MudTh>@T("Aktion", "Action")</MudTh>
</HeaderContent>
<RowTemplate>
<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.Schema</MudTd>
<MudTd>@context.ServerName</MudTd>
<MudTd>
@if (Orchestrator.IsExporting(context.SiteId))
{
<MudProgressCircular Size="Size.Small" Indeterminate Color="Color.Primary" Class="mr-1" />
<MudText Typo="Typo.caption">@Orchestrator.GetExportStatus(context.SiteId)</MudText>
}
else if (context.LastStatus == "OK")
{
<MudIcon Icon="@Icons.Material.Filled.CheckCircle" Color="Color.Success" Size="Size.Small" />
}
else if (context.LastStatus == "Error")
{
<MudTooltip Text="@context.ErrorMessage">
<MudIcon Icon="@Icons.Material.Filled.Error" Color="Color.Error" Size="Size.Small" />
</MudTooltip>
}
else
{
<MudText Typo="Typo.caption" Color="Color.Default">-</MudText>
}
</MudTd>
<MudTd>
@if (!string.IsNullOrWhiteSpace(context.LiveMessage))
{
<MudTooltip Text="@context.LiveDetails">
<MudText Typo="Typo.caption" Style="max-width:360px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; display:block;">
@context.LiveMessage
</MudText>
</MudTooltip>
}
else
{
<MudText Typo="Typo.caption" Color="Color.Default">-</MudText>
}
</MudTd>
<MudTd>@(context.RowCount > 0 ? context.RowCount.ToString("N0") : "-")</MudTd>
<MudTd>@(context.LastRun.HasValue ? context.LastRun.Value.ToString("dd.MM.yyyy HH:mm:ss") : "-")</MudTd>
<MudTd>@(context.DurationSeconds > 0 ? $"{context.DurationSeconds:F1}s" : "-")</MudTd>
<MudTd>
<MudStack Row Spacing="1">
<MudButton Size="Size.Small" Variant="Variant.Outlined" Color="Color.Primary"
StartIcon="@Icons.Material.Filled.FileDownload"
OnClick="() => ExportSingle(context.SiteId)"
Disabled="Orchestrator.IsExporting(context.SiteId)">
Export
</MudButton>
<MudButton Size="Size.Small" Variant="Variant.Text" Color="Color.Info"
StartIcon="@Icons.Material.Filled.OpenInNew"
OnClick="() => OpenExportFile(context)"
Disabled="@(!context.HasOpenableFile || Orchestrator.IsExporting(context.SiteId))">
@T("Excel oeffnen", "Open Excel")
</MudButton>
</MudStack>
</MudTd>
</RowTemplate>
</MudTable>
<MudPaper Class="pa-4 mt-4" Elevation="1">
<MudText Typo="Typo.h6" Class="mb-3">@T("Zentrale Datei", "Consolidated file")</MudText>
<MudTable Items="_consolidatedRows" Dense Hover Striped>
<HeaderContent>
<MudTh>@T("Datei", "File")</MudTh>
<MudTh>Pfad</MudTh>
<MudTh>Letzte Änderung</MudTh>
<MudTh>@T("Status", "Status")</MudTh>
<MudTh>@T("Aktion", "Action")</MudTh>
</HeaderContent>
<RowTemplate>
<MudTd>@context.Label</MudTd>
<MudTd>@context.DisplayPath</MudTd>
<MudTd>@(context.LastModified.HasValue ? context.LastModified.Value.ToString("dd.MM.yyyy HH:mm:ss") : "-")</MudTd>
<MudTd>
@if (Orchestrator.IsConsolidatedExporting())
{
<MudProgressCircular Size="Size.Small" Indeterminate Color="Color.Primary" Class="mr-1" />
<MudText Typo="Typo.caption">@Orchestrator.GetConsolidatedExportStatus()</MudText>
}
else
{
<MudText Typo="Typo.caption" Color="Color.Default">-</MudText>
}
</MudTd>
<MudTd>
<MudButton Size="Size.Small" Variant="Variant.Text" Color="Color.Info"
StartIcon="@Icons.Material.Filled.OpenInNew"
OnClick="() => OpenFile(context.FilePath)"
Disabled="@(!context.HasOpenableFile)">
@T("Excel oeffnen", "Open Excel")
</MudButton>
</MudTd>
</RowTemplate>
<NoRecordsContent>
<MudText Typo="Typo.caption">@T("Keine zentrale Excel-Datei gefunden.", "No consolidated Excel file found.")</MudText>
</NoRecordsContent>
</MudTable>
</MudPaper>
<style>
.dashboard-header {
display: grid;
grid-template-columns: minmax(0, 1fr) 220px;
gap: 24px;
align-items: center;
}
.dashboard-actions {
min-width: 0;
flex-wrap: wrap;
}
.dashboard-manometer {
justify-self: end;
width: 210px;
height: 118px;
background: #fff;
border: 1px solid #111;
border-radius: 6px;
overflow: hidden;
}
.manometer-svg {
display: block;
width: 100%;
height: 100%;
}
.manometer-outer,
.manometer-inner {
fill: none;
stroke: #111;
stroke-linecap: square;
}
.manometer-outer {
stroke-width: 8;
}
.manometer-inner {
stroke-width: 2;
}
.manometer-tick,
.needle-line {
stroke: #111;
stroke-width: 3;
stroke-linecap: square;
}
.manometer-needle {
transform-box: view-box;
transform-origin: 105px 98px;
animation: manometer-sweep 5.8s infinite cubic-bezier(.45, 0, .25, 1);
}
.manometer-hub {
fill: #111;
}
.manometer-label,
.manometer-caption {
fill: #111;
font-family: Arial, sans-serif;
text-anchor: middle;
dominant-baseline: middle;
user-select: none;
}
.manometer-label {
font-size: 10px;
font-weight: 600;
}
.manometer-caption {
font-size: 9px;
font-weight: 700;
letter-spacing: 0;
}
@@keyframes manometer-sweep {
0% { transform: rotate(-52deg); }
11% { transform: rotate(18deg); }
19% { transform: rotate(-8deg); }
33% { transform: rotate(63deg); }
48% { transform: rotate(4deg); }
61% { transform: rotate(38deg); }
74% { transform: rotate(-41deg); }
88% { transform: rotate(55deg); }
100% { transform: rotate(-52deg); }
}
@@media (max-width: 900px) {
.dashboard-header {
grid-template-columns: 1fr;
}
.dashboard-manometer {
justify-self: start;
}
}
</style>
@code {
private List<DashboardRow> _dashboardRows = new();
private List<ConsolidatedDashboardRow> _consolidatedRows = new();
private List<string> _readinessWarnings = new();
private bool _consolidatedStale;
private bool _loading = true;
private bool _anyRunning;
private CancellationTokenSource? _pollingCts;
protected override async Task OnInitializedAsync()
{
Orchestrator.OnExportStatusChanged += HandleStatusChanged;
await LoadDataAsync();
}
private async Task LoadDataAsync()
{
_loading = true;
var state = await DashboardPageActions.LoadAsync();
_dashboardRows = state.DashboardRows;
_consolidatedRows = state.ConsolidatedRows;
_readinessWarnings = state.ReadinessWarnings;
_consolidatedStale = state.IsConsolidatedStale;
_anyRunning = _dashboardRows.Any(r => Orchestrator.IsExporting(r.SiteId)) || Orchestrator.IsConsolidatedExporting();
_loading = false;
}
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;
await LoadDataAsync();
StartPolling();
_ = Task.Run(async () =>
{
try
{
await Orchestrator.ExportAllAsync();
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);
}
private async Task ExportConsolidatedOnly()
{
_anyRunning = true;
await LoadDataAsync();
StartPolling();
_ = Task.Run(async () =>
{
try
{
var filePath = await Orchestrator.ExportConsolidatedOnlyAsync();
if (!string.IsNullOrWhiteSpace(filePath))
{
await InvokeAsync(() =>
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));
}
}
catch (Exception ex)
{
await InvokeAsync(() =>
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);
}
private void ExportSingle(int siteId)
{
_anyRunning = true;
_ = InvokeAsync(async () => await LoadDataAsync());
StartPolling();
_ = Task.Run(async () =>
{
try
{
var result = await Orchestrator.ExportSiteByIdAsync(siteId);
if (result?.Log.Status == "OK" && !string.IsNullOrWhiteSpace(result.FilePath))
{
await InvokeAsync(() =>
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));
}
}
catch (Exception ex)
{
await InvokeAsync(() =>
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);
}
private async void HandleStatusChanged()
{
await InvokeAsync(async () =>
{
_anyRunning = _dashboardRows.Any(r => Orchestrator.IsExporting(r.SiteId)) || Orchestrator.IsConsolidatedExporting() || _dashboardRows.Count == 0;
if (_anyRunning)
{
StartPolling();
await RefreshLiveDataAsync();
StateHasChanged();
return;
}
StopPolling();
await LoadDataAsync();
StateHasChanged();
});
}
public void Dispose()
{
StopPolling();
Orchestrator.OnExportStatusChanged -= HandleStatusChanged;
}
private void OpenExportFile(DashboardRow row)
{
OpenFile(row.FilePath);
}
private void OpenFile(string filePath)
{
if (string.IsNullOrWhiteSpace(filePath) || !File.Exists(filePath))
{
Snackbar.Add(T("Exportdatei nicht gefunden.", "Export file not found."), Severity.Warning);
return;
}
try
{
Process.Start(new ProcessStartInfo
{
FileName = filePath,
UseShellExecute = true
});
}
catch (Exception ex)
{
Snackbar.Add(string.Format(T("Datei konnte nicht geoeffnet werden: {0}", "Could not open file: {0}"), ex.Message), Severity.Error);
}
}
private void StartPolling()
{
if (_pollingCts is not null && !_pollingCts.IsCancellationRequested)
return;
_pollingCts = new CancellationTokenSource();
_ = PollDashboardAsync(_pollingCts.Token);
}
private void StopPolling()
{
_pollingCts?.Cancel();
_pollingCts?.Dispose();
_pollingCts = null;
}
private async Task PollDashboardAsync(CancellationToken cancellationToken)
{
using var timer = new PeriodicTimer(TimeSpan.FromSeconds(3));
try
{
while (await timer.WaitForNextTickAsync(cancellationToken))
{
var anyRunning = _dashboardRows.Any(r => Orchestrator.IsExporting(r.SiteId)) || Orchestrator.IsConsolidatedExporting();
if (!anyRunning)
{
await InvokeAsync(async () =>
{
_anyRunning = false;
await LoadDataAsync();
StateHasChanged();
});
StopPolling();
break;
}
await InvokeAsync(async () =>
{
_anyRunning = true;
await RefreshLiveDataAsync();
StateHasChanged();
});
}
}
catch (OperationCanceledException)
{
}
}
private Task RefreshLiveDataAsync()
{
foreach (var row in _dashboardRows)
{
if (!Orchestrator.IsExporting(row.SiteId))
continue;
row.LiveMessage = Orchestrator.GetExportStatus(row.SiteId);
row.LiveDetails = string.Empty;
}
_anyRunning = _dashboardRows.Any(r => Orchestrator.IsExporting(r.SiteId)) || Orchestrator.IsConsolidatedExporting();
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 {
private string T(string german, string english) => UiText.Text(german, english);
}
@@ -0,0 +1,217 @@
@page "/finance-cockpit/vergleich"
@rendermode @(Microsoft.AspNetCore.Components.Web.RenderMode.InteractiveServer)
@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 der aktuellen zentralen Datenquelle", "Authoritative finance view from the current central data source")</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,168 @@
@page "/finance-rules"
@rendermode @(Microsoft.AspNetCore.Components.Web.RenderMode.InteractiveServer)
@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,275 @@
@page "/finance-cockpit/schulung"
@rendermode @(Microsoft.AspNetCore.Components.Web.RenderMode.InteractiveServer)
@inject TrafagSalesExporter.Services.IUiTextService UiText
<PageTitle>@T("Finance Schulung", "Finance training")</PageTitle>
<MudText Typo="Typo.h4" Class="mb-2">@T("Finance Schulung", "Finance training")</MudText>
<MudText Typo="Typo.body1" Class="mb-4 training-lead">
Ausführliche Anwenderunterlage für Finance-Keyuser, CFO/Finance-Leitung und Administratoren. Die Schulung beschreibt den
produktiven Ablauf vom manuellen Import bis zur zentralen Excel-Datei, inklusive Finance Summary, Finance Details und Soll/Ist Vergleich.
</MudText>
<MudPaper Class="pa-4 mb-4" Elevation="1">
<MudGrid>
<MudItem xs="12" md="7">
<img class="training-hero-image" src="training/finance_cockpit_preview.png" alt="Finance Cockpit Vorschau" />
</MudItem>
<MudItem xs="12" md="5">
<MudAlert Severity="Severity.Info" Variant="Variant.Outlined" Class="mb-3">
Verbindlich ist die Finance-Sicht: Finance Summary, Finance Details und Soll/Ist Vergleich müssen zusammen plausibel sein.
</MudAlert>
<MudSimpleTable Dense Hover>
<thead>
<tr><th>Rolle</th><th>Aufgabe</th></tr>
</thead>
<tbody>
<tr><td>Finance Keyuser</td><td>Importe bereitstellen, Standorte exportieren, Summen prüfen</td></tr>
<tr><td>CFO/Finance Leitung</td><td>Soll/Ist-Abgleich freigeben und Abweichungen entscheiden</td></tr>
<tr><td>Admin</td><td>Standorte, Mapping, Regeln, Settings und SharePoint konfigurieren</td></tr>
</tbody>
</MudSimpleTable>
</MudItem>
</MudGrid>
</MudPaper>
<MudTabs Rounded Border>
<MudTabPanel Text="Prozess">
<TrainingSection Title="1. Ziel der Finance-Sicht">
<p>Das Finance Cockpit bereitet Sales- und Finance-Daten so auf, dass Länder, Systeme und unterschiedliche Spaltenlogiken in
einer zentralen Sicht vergleichbar werden. Die Rohdaten bleiben nachvollziehbar, die Finance-Spalten liefern die verbindliche
Abgrenzung für Summen und Soll/Ist-Vergleich.</p>
<ul>
<li><strong>Finance Summary</strong> zeigt die aggregierten Summen nach Jahr, Land und Währung.</li>
<li><strong>Finance Details</strong> zeigt die Detailzeilen, die zu diesen Summen führen.</li>
<li><strong>Sales</strong> enthält die breitere Rohdatensicht inklusive Finance-Spaltenblock.</li>
<li><strong>Soll/Ist Vergleich</strong> prüft die App-Daten gegen die gepflegten Referenzwerte.</li>
</ul>
</TrainingSection>
<TrainingSection Title="2. Bedienreihenfolge im Tagesgeschäft">
<div class="training-flow">
<div><span>1</span><strong>Dateien bereitstellen</strong><small>SharePoint, lokaler Pfad oder Quellsystem</small></div>
<div><span>2</span><strong>Standort exportieren</strong><small>CentralSalesRecords je Standort ersetzen</small></div>
<div><span>3</span><strong>Zentrale Excel erzeugen</strong><small>Sales, Summary und Details neu schreiben</small></div>
<div><span>4</span><strong>Summen prüfen</strong><small>Summary gegen Details aggregieren</small></div>
<div><span>5</span><strong>Soll/Ist freigeben</strong><small>Abweichungen dokumentieren</small></div>
</div>
<p>Nach jeder neuen Datei muss zuerst der betroffene Standort exportiert werden. Erst danach ist die zentrale Excel-Datei aktuell.</p>
</TrainingSection>
<TrainingSection Title="3. Prozessgrafik und Systembild">
<MudGrid>
<MudItem xs="12" md="6">
<img class="training-doc-image" src="training/keyuser-prozess.svg" alt="Keyuser Prozess" />
</MudItem>
<MudItem xs="12" md="6">
<img class="training-doc-image" src="training/systemarchitektur.svg" alt="Systemarchitektur" />
</MudItem>
</MudGrid>
</TrainingSection>
</MudTabPanel>
<MudTabPanel Text="Importe">
<TrainingSection Title="4. Manuelle Importe und Delta-Regeln">
<p>Manuelle Importe ersetzen beim Standortexport den aktuellen Datenstand dieses Standorts in <code>CentralSalesRecords</code>.
Deshalb darf eine Delta-Datei nur dann verwendet werden, wenn die App sie zusammen mit einer Basisdatei liest.</p>
<MudSimpleTable Dense Hover>
<thead><tr><th>Land</th><th>Quelle</th><th>Lieferlogik</th><th>Konsequenz</th></tr></thead>
<tbody>
<tr><td>UK / England</td><td>Sage Excel/CSV im Ordner UK_B1</td><td>Jahresdatei plus Deltas</td><td>Delta-fähig, weil Basis und Deltas zusammen gelesen werden</td></tr>
<tr><td>Spanien</td><td>Sage CSV / Manual Excel</td><td>Vollfile erforderlich</td><td>Keine Delta-Dateien verwenden</td></tr>
<tr><td>Deutschland</td><td>Alphaplan Excel</td><td>Vollfile/Jahresfile erforderlich</td><td>Keine Delta-Dateien verwenden</td></tr>
<tr><td>CH/AT</td><td>SAP OData</td><td>Quellsystem wird neu gelesen</td><td>Kein manueller Delta-Prozess</td></tr>
<tr><td>FR/IT/US/IN</td><td>HANA/SAP B1/Sage</td><td>direkte Quelle</td><td>Standortdaten werden aus Quelle aufgebaut</td></tr>
</tbody>
</MudSimpleTable>
<MudAlert Severity="Severity.Warning" Variant="Variant.Outlined" Class="mt-3">
Spanien und Deutschland müssen vollständige Dateien liefern. Eine einzelne Delta-Datei würde technisch den bisherigen Stand ersetzen und damit unvollständig werden.
</MudAlert>
</TrainingSection>
<TrainingSection Title="5. Standortexport verstehen">
<p>Der Standortexport ist der Schritt, der Rohdaten aus der jeweiligen Quelle in die zentrale Datenbank schreibt. Er liest nicht nur
eine Datei, sondern wendet auch Mapping, Transformationen und Finance-Regeln an.</p>
<ul>
<li>Bei SAP/HANA wird die definierte Quelle abgefragt.</li>
<li>Bei manuellen Excel-/CSV-Importen wird die hinterlegte Datei oder der hinterlegte Ordner verwendet.</li>
<li>Bestehende Zeilen des Standorts werden ersetzt, damit keine veralteten Dubletten stehen bleiben.</li>
<li>Fehler stehen in den Logs und müssen vor der zentralen Excel-Erzeugung geklärt werden.</li>
</ul>
</TrainingSection>
<TrainingSection Title="6. Zentrale Excel-Datei">
<MudSimpleTable Dense Hover>
<thead><tr><th>Blatt</th><th>Zweck</th><th>Prüfung</th></tr></thead>
<tbody>
<tr><td>Sales</td><td>Rohdaten plus normalisierte Spalten</td><td>Land, TSC, Beleg, Kunde, Wert, Währung</td></tr>
<tr><td>Finance Summary</td><td>verbindliche Aggregation</td><td>Jahr, Land, Währung, Net Sales Actual</td></tr>
<tr><td>Finance Details</td><td>Detailzeilen zur Summary</td><td>Summe je Land muss Summary ergeben</td></tr>
<tr><td>Finance Filter Hilfe</td><td>Hinweise zur Excel-Prüfung</td><td>Filter und Pivot-Anleitung</td></tr>
</tbody>
</MudSimpleTable>
</TrainingSection>
</MudTabPanel>
<MudTabPanel Text="Abgleich">
<TrainingSection Title="7. Finance Summary lesen">
<p>Finance Summary ist die kompakte Sicht für Finance. Sie enthält nur Zeilen, die nach Finance-Regel eingeschlossen sind.
Entscheidend sind Finance-Jahr, Country Key, Währung und Net Sales Actual.</p>
<ul>
<li>Immer zuerst nach Jahr 2025 filtern, wenn der 2025-Abgleich geprüft wird.</li>
<li>Country Key ist die Finance-Länderlogik, nicht zwingend nur der sichtbare Rohdaten-Ländername.</li>
<li>Währung muss zum Referenzwert passen, zum Beispiel GBP für UK oder INR für Indien.</li>
<li>Included Rows zeigt, wie viele Detailzeilen in die Summe geflossen sind.</li>
</ul>
</TrainingSection>
<TrainingSection Title="8. Finance Details gegen Summary prüfen">
<p>Finance Details ist das Kontrollblatt für Rückfragen. Die Summe über <code>Net Sales Actual</code> in Finance Details muss
je Jahr, Land und Währung exakt mit Finance Summary übereinstimmen.</p>
<MudSimpleTable Dense Hover>
<thead><tr><th>Prüfung</th><th>Vorgehen</th><th>Erwartung</th></tr></thead>
<tbody>
<tr><td>Landessumme</td><td>Details nach Year, Country Key und Currency aggregieren</td><td>identisch zu Finance Summary</td></tr>
<tr><td>Einzelbeleg</td><td>Invoice Number, Position und Document Entry suchen</td><td>Beleg nachvollziehbar</td></tr>
<tr><td>Ausschluss</td><td>Sales-Blatt mit Finance Include vergleichen</td><td>Regelgrund sichtbar</td></tr>
<tr><td>Dubletten</td><td>Belegkopfwerte und Positionen prüfen</td><td>B1-Kopflogik wird nicht doppelt gezählt</td></tr>
</tbody>
</MudSimpleTable>
</TrainingSection>
<TrainingSection Title="9. Soll/Ist Vergleich">
<p>Der Soll/Ist Vergleich nutzt dieselbe Finance-Reconciliation-Logik wie die zentrale Finance-Sicht. Er ist die Seite für
Freigabe, Abweichungsanalyse und Status je Land.</p>
<MudSimpleTable Dense Hover>
<thead><tr><th>Status</th><th>Bedeutung</th><th>Aktion</th></tr></thead>
<tbody>
<tr><td>OK</td><td>Istwert passt gegen Referenz innerhalb Toleranz</td><td>für Freigabe vormerken</td></tr>
<tr><td>Prüfen</td><td>Differenz vorhanden oder Regel noch nicht final</td><td>Details und Länderregel prüfen</td></tr>
<tr><td>Keine Daten</td><td>Kein aktueller Stand in CentralSalesRecords</td><td>Standortexport oder Import prüfen</td></tr>
</tbody>
</MudSimpleTable>
</TrainingSection>
<TrainingSection Title="10. Länderlogik kompakt">
<MudSimpleTable Dense Hover>
<thead><tr><th>Land</th><th>Aktuelle Hauptlogik</th><th>Hinweis</th></tr></thead>
<tbody>
<tr><td>DE</td><td>Alphaplan NettoPreisGesamtX, GS negativ, Finance-Ausschlüsse</td><td>Vollfile erforderlich</td></tr>
<tr><td>ES</td><td>Sage ImporteNeto, REC/Credit negativ</td><td>Vollfile erforderlich</td></tr>
<tr><td>IT</td><td>B1 Positions-Netto, Trafag Italia ausgeschlossen, Dublettenlogik</td><td>Detailprüfung wichtig</td></tr>
<tr><td>UK</td><td>Sage Netto in GBP, Credit Notes negativ</td><td>Basis plus Deltas möglich</td></tr>
<tr><td>FR/US</td><td>B1 Sales Price/Value bevorzugt</td><td>gegen CheckValue prüfen</td></tr>
<tr><td>IN</td><td>Hauswährung INR</td><td>Referenz in Local Currency</td></tr>
<tr><td>CH/AT</td><td>SAP OData NetwrHc</td><td>AT hat Referenz, CH aktuell ohne Sollwert</td></tr>
</tbody>
</MudSimpleTable>
</TrainingSection>
</MudTabPanel>
<MudTabPanel Text="Spartenanalyse">
<TrainingSection Title="11. Spartenanalyse oeffnen">
<p>Die Spartenanalyse liegt unter <strong>Management Analyse</strong>. Im linken Menue kann Management Analyse aufgeklappt werden.
Dort fuehren die Punkte <strong>Sparten-Finanzanalyse</strong> und <strong>Zentrale Spartenzuordnung</strong> direkt in die passende Sicht.</p>
<ul>
<li><strong>Sparten-Finanzanalyse</strong> zeigt Umsatz, Anteil, Materialien, Zeilen und Laender nach Produktspartenlogik.</li>
<li><strong>Zentrale Spartenzuordnung</strong> prueft lokale Materialnummern gegen die fuehrende TR-AG-Referenz.</li>
<li>Die lokalen ERP-Produktsparten werden fuer diese Fuehrungssicht nicht verwendet; massgebend ist die TR-AG-Referenz aus SAP.</li>
</ul>
</TrainingSection>
<TrainingSection Title="12. Gruppierung, Top 10 und Laender">
<MudSimpleTable Dense Hover>
<thead><tr><th>Funktion</th><th>Zweck</th><th>Pruefung</th></tr></thead>
<tbody>
<tr><td>Gruppierung PAPH1 Detail</td><td>Feinste Sicht auf einzelne Produkt-Hierarchie-Codes</td><td>Geeignet fuer Detailanalyse und auffaellige Einzelgruppen</td></tr>
<tr><td>Gruppierung Produktfamilie</td><td>Fasst mehrere PAPH1-Zeilen zusammen, z.B. Gas Density Monitor</td><td>Geeignet fuer Managementsicht und Portfolioanalyse</td></tr>
<tr><td>Gruppierung Produktsparte</td><td>Verdichtet auf die oberste Spartenebene</td><td>Geeignet fuer schnelle Umsatzverteilung nach Sparten</td></tr>
<tr><td>Top 10 anzeigen</td><td>Reduziert die Tabelle auf die groessten Umsatzbloecke</td><td>Hilft, die wichtigsten Sparten ohne lange Detailliste zu beurteilen</td></tr>
<tr><td>Flaggen bei Laendern</td><td>Zeigt Landkuerzel optisch schneller erkennbar an</td><td>Bei mehreren Laendern werden die Laender kommasepariert angezeigt</td></tr>
</tbody>
</MudSimpleTable>
<MudAlert Severity="Severity.Info" Variant="Variant.Outlined" Class="mt-3">
Die Umsaetze und Anteile werden je Gruppierung neu aggregiert. Top 10 filtert nur die Anzeige, nicht die Berechnungsbasis.
</MudAlert>
</TrainingSection>
<TrainingSection Title="13. Sparten-Icons lesen">
<p>Die Icons neben der Produktsparte sind eine visuelle Orientierung. Sie werden aus Sparten-, Familien- und PAPH1-Texten abgeleitet
und aendern keine Zahlen oder Zuordnung.</p>
<MudSimpleTable Dense Hover>
<thead><tr><th>Textmuster</th><th>Icon</th><th>Bedeutung</th></tr></thead>
<tbody>
<tr><td>Gas / Density</td><td>Sensors</td><td>Gas-Density-Produkte</td></tr>
<tr><td>Pressure / Druck</td><td>Compress</td><td>Druckprodukte</td></tr>
<tr><td>Temp / Thermostat</td><td>DeviceThermostat</td><td>Temperatur und Thermostate</td></tr>
<tr><td>Switch / Schalter</td><td>ToggleOn</td><td>Schalterbasierte Produkte</td></tr>
<tr><td>Access / Zubehoer</td><td>Extension</td><td>Zubehoer</td></tr>
<tr><td>UNASS / Nicht zugeordnet</td><td>HelpOutline</td><td>TR-AG-Referenz vorhanden, aber ohne gueltige Sparte</td></tr>
<tr><td>Sonstige Texte</td><td>Category</td><td>Keine spezifische Icon-Regel getroffen</td></tr>
</tbody>
</MudSimpleTable>
</TrainingSection>
<TrainingSection Title="14. Interpretation fuer Finance">
<ul>
<li><strong>Zugeordnet</strong>: Material wurde im TR-AG-Stamm gefunden und hat eine verwertbare Sparteninformation.</li>
<li><strong>Nicht zugeordnet</strong>: Material wurde gefunden, aber die TR-AG-Referenz ist unassigned oder leer.</li>
<li><strong>Nicht im TR-AG-Stamm</strong>: Lokale Materialnummer konnte nicht gegen die TR-AG-Referenz gematcht werden.</li>
<li><strong>Material fehlt</strong>: In der Finance-Zeile fehlt die Materialnummer; diese Zeilen koennen nicht zugeordnet werden.</li>
</ul>
<p>Fuer Finanzfragen ist die wichtigste Sicht zuerst die aggregierte Produktfamilie oder Produktsparte. PAPH1 Detail wird genutzt,
wenn eine Abweichung oder ein auffaelliger Umsatzblock genauer erklaert werden muss.</p>
</TrainingSection>
</MudTabPanel>
<MudTabPanel Text="Fehler & Freigabe">
<TrainingSection Title="15. Typische Fehler und Massnahmen">
<MudSimpleTable Dense Hover>
<thead><tr><th>Fehler</th><th>Ursache</th><th>Massnahme</th></tr></thead>
<tbody>
<tr><td>Zentrale Datei konnte nicht erzeugt werden</td><td>Excel/SharePoint-Datei gesperrt oder Uploadfehler</td><td>Datei schliessen, Logs prüfen, ggf. Zeitstempeldatei verwenden</td></tr>
<tr><td>Land fehlt in Summary</td><td>Standort nicht exportiert oder keine Finance Include Zeilen</td><td>Standortexport und Finance-Regeln prüfen</td></tr>
<tr><td>Summe passt nicht zum Soll</td><td>falsche Datei, falsches Jahr, Länderregel offen</td><td>Finance Details aggregieren und Länderlogik prüfen</td></tr>
<tr><td>Zu wenige Zeilen nach Import</td><td>Delta statt Vollfile verwendet</td><td>bei ES/DE vollständige Datei neu liefern lassen</td></tr>
<tr><td>Keine Verbindung zur Quelle</td><td>Credentials, Netzwerk, HANA/SAP nicht erreichbar</td><td>Settings und Logs prüfen</td></tr>
</tbody>
</MudSimpleTable>
</TrainingSection>
<TrainingSection Title="16. Freigabe-Checkliste">
<MudSimpleTable Dense Hover>
<thead><tr><th>Nr.</th><th>Prüfpunkt</th><th>OK</th></tr></thead>
<tbody>
<tr><td>1</td><td>Alle relevanten Standorte wurden nach letzter Dateiänderung exportiert</td><td></td></tr>
<tr><td>2</td><td>Zentrale Excel wurde danach neu erzeugt</td><td></td></tr>
<tr><td>3</td><td>Finance Summary stimmt aggregiert mit Finance Details überein</td><td></td></tr>
<tr><td>4</td><td>Soll/Ist Vergleich enthält keine unerwarteten Abweichungen</td><td></td></tr>
<tr><td>5</td><td>ES und DE wurden als Vollfile geliefert</td><td></td></tr>
<tr><td>6</td><td>Offene Länder- oder Regelentscheidungen sind dokumentiert</td><td></td></tr>
<tr><td>7</td><td>SharePoint/Excel-Datei ist nicht mehr gesperrt</td><td></td></tr>
</tbody>
</MudSimpleTable>
</TrainingSection>
</MudTabPanel>
</MudTabs>
<style>
.training-lead { max-width: 980px; color: var(--mud-palette-text-secondary); }
.training-hero-image, .training-doc-image { width: 100%; border: 1px solid var(--mud-palette-lines-default); border-radius: 6px; background: #fff; }
.training-doc-image { margin-bottom: 12px; }
.training-section { margin: 18px 0 28px; max-width: 1180px; }
.training-section p { margin-bottom: 12px; line-height: 1.55; }
.training-section ul { margin-top: 8px; }
.training-flow { display: grid; grid-template-columns: repeat(5, minmax(120px, 1fr)); gap: 10px; margin: 12px 0 18px; }
.training-flow div { border: 1px solid var(--mud-palette-lines-default); border-radius: 6px; padding: 12px; background: var(--mud-palette-surface); }
.training-flow span { display: inline-flex; width: 28px; height: 28px; align-items: center; justify-content: center; border-radius: 50%; background: var(--mud-palette-primary); color: var(--mud-palette-primary-text); font-weight: 700; margin-bottom: 8px; }
.training-flow strong, .training-flow small { display: block; }
.training-flow small { color: var(--mud-palette-text-secondary); margin-top: 4px; }
@@media (max-width: 900px) { .training-flow { grid-template-columns: 1fr; } }
</style>
@code {
private string T(string german, string english) => UiText.Text(german, english);
}
@@ -0,0 +1,669 @@
@page "/hr-kpi"
@rendermode @(Microsoft.AspNetCore.Components.Web.RenderMode.InteractiveServer)
@using System.Globalization
@using System.Text.Json
@using TrafagSalesExporter.Components.HrKpi
@using TrafagSalesExporter.Services
@inject IHrKpiService HrKpiService
@inject IHrKpiAccessService HrKpiAccess
@inject ISnackbar Snackbar
@inject IUiTextService UiText
@inject IJSRuntime JsRuntime
@inject NavigationManager Navigation
@inject IWebHostEnvironment Environment
<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>
}
<form method="post" action="@AccessUrl">
<input type="hidden" name="returnUrl" value="@Navigation.Uri" />
<MudStack Spacing="3">
<MudTextField T="string" Name="username" Label="@T("Name", "Name")" Disabled="@(!HrKpiAccess.IsConfigured)" />
<MudTextField T="string" Name="password" Label="@T("Passwort", "Password")" InputType="InputType.Password" Disabled="@(!HrKpiAccess.IsConfigured)" />
<button type="submit" class="mud-button-root mud-button mud-button-filled mud-button-filled-primary mud-button-filled-size-medium mud-ripple">
@T("HR KPI entsperren", "Unlock HR KPI")
</button>
</MudStack>
</form>
<MudText Typo="Typo.caption">
@T("Server-Klicks", "Server clicks"): @_unlockClickCount |
@T("Konfiguriert", "Configured"): @(HrKpiAccess.IsConfigured ? "JA" : "NEIN")
</MudText>
<MudDivider />
<MudExpansionPanels Elevation="0">
<MudExpansionPanel Text="@T("Passwort ändern", "Change password")" Icon="@Icons.Material.Filled.Password">
<MudStack Spacing="3" Class="pt-2">
<MudTextField @bind-Value="_changeUsername" Label="@T("Name", "Name")" Disabled="@(!HrKpiAccess.IsConfigured)" />
<MudTextField @bind-Value="_currentPassword" Label="@T("Aktuelles Passwort", "Current password")" InputType="InputType.Password" Disabled="@(!HrKpiAccess.IsConfigured)" />
<MudTextField @bind-Value="_newPassword" Label="@T("Neues Passwort", "New password")" InputType="InputType.Password" HelperText="@T("Mindestens 8 Zeichen.", "At least 8 characters.")" Disabled="@(!HrKpiAccess.IsConfigured)" />
<MudTextField @bind-Value="_newPasswordRepeat" Label="@T("Neues Passwort wiederholen", "Repeat new password")" InputType="InputType.Password" Disabled="@(!HrKpiAccess.IsConfigured)" />
<MudButton Variant="Variant.Outlined" Color="Color.Primary" OnClick="ChangeHrPasswordAsync"
StartIcon="@Icons.Material.Filled.Save" Disabled="@(!HrKpiAccess.IsConfigured)">
@T("Passwort speichern", "Save password")
</MudButton>
</MudStack>
</MudExpansionPanel>
</MudExpansionPanels>
</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("Serverordner fuer hochgeladene HR-KPI-Dateien. Auf der publizierten Webseite ist das ein Ordner auf dem Webserver, nicht C:\\temp auf dem lokalen PC.", "Server folder for uploaded HR KPI files. On the published site this is a folder on the web server, not C:\\temp on the local PC.")" />
</MudItem>
<MudItem xs="12" md="5">
<MudStack Spacing="1">
<MudText Typo="Typo.caption">
@T("Erwartete Dateien", "Expected files"): @string.Join(", ", ExpectedUploadFileNames)
</MudText>
<InputFile OnChange="UploadHrKpiFilesAsync" multiple accept=".xlsx,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" disabled="@(_loading || _uploading)" />
<MudText Typo="Typo.caption">
@T("Uploadziel", "Upload target"): @_serverUploadFolder
</MudText>
</MudStack>
</MudItem>
<MudItem xs="12" md="2">
<MudButton Variant="Variant.Outlined" Color="Color.Primary" OnClick="UseServerUploadFolderAsync"
StartIcon="@Icons.Material.Filled.Folder" Disabled="@(_loading || _uploading)" FullWidth>
@T("Serverordner nutzen", "Use server folder")
</MudButton>
</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 Datum", "From date")" Clearable DateFormat="dd.MM.yyyy" Culture="_dateCulture" />
</MudItem>
<MudItem xs="12" md="3">
<MudDatePicker @bind-Date="_toDate" Label="@T("Bis Datum", "To date")" Clearable DateFormat="dd.MM.yyyy" Culture="_dateCulture" />
</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>
<MudItem xs="12">
<MudDivider Class="my-2" />
</MudItem>
<MudItem xs="12" md="3">
<MudSelect T="string" @bind-Value="_selectedVariantName" Label="@T("Variante", "Variant")" Dense Clearable>
@foreach (var variant in _variantNames)
{
<MudSelectItem Value="@variant">@variant</MudSelectItem>
}
</MudSelect>
</MudItem>
<MudItem xs="12" md="3">
<MudTextField @bind-Value="_variantName" Label="@T("Variantenname", "Variant name")" />
</MudItem>
<MudItem xs="12" md="2">
<MudButton Variant="Variant.Outlined" Color="Color.Primary" OnClick="SaveVariantAsync"
StartIcon="@Icons.Material.Filled.Save" Disabled="_loading" FullWidth>
@T("Variante speichern", "Save variant")
</MudButton>
</MudItem>
<MudItem xs="12" md="2">
<MudButton Variant="Variant.Outlined" Color="Color.Primary" OnClick="RenameVariantAsync"
StartIcon="@Icons.Material.Filled.Edit" Disabled="@(_loading || string.IsNullOrWhiteSpace(_selectedVariantName) || string.IsNullOrWhiteSpace(_variantName))" FullWidth>
@T("Umbenennen", "Rename")
</MudButton>
</MudItem>
<MudItem xs="12" md="2">
<MudButton Variant="Variant.Outlined" Color="Color.Primary" OnClick="LoadVariantAsync"
StartIcon="@Icons.Material.Filled.FileOpen" Disabled="@(_loading || string.IsNullOrWhiteSpace(_selectedVariantName))" FullWidth>
@T("Variante laden", "Load variant")
</MudButton>
</MudItem>
<MudItem xs="12" md="2">
<MudButton Variant="Variant.Outlined" Color="Color.Error" OnClick="DeleteVariantAsync"
StartIcon="@Icons.Material.Filled.Delete" Disabled="@(_loading || string.IsNullOrWhiteSpace(_selectedVariantName))" FullWidth>
@T("Löschen", "Delete")
</MudButton>
</MudItem>
<MudItem xs="12" md="2">
<MudButton Variant="Variant.Outlined" Color="Color.Secondary" OnClick="UpdateSelectedVariantAsync"
StartIcon="@Icons.Material.Filled.Update" Disabled="@(_loading || string.IsNullOrWhiteSpace(_selectedVariantName))" FullWidth>
@T("Bestehende anpassen", "Update existing")
</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 string? _changeUsername;
private string? _currentPassword;
private string? _newPassword;
private string? _newPasswordRepeat;
private int _unlockClickCount;
private string AccessUrl => new Uri(new Uri(Navigation.BaseUri), "access/hr").ToString();
private bool _loading;
private bool _uploading;
private string _serverUploadFolder = string.Empty;
private HrKpiResult? _result;
private string _selectionStorePath = string.Empty;
private HrKpiSelectionStore _selectionStore = new();
private string? _variantName;
private string? _selectedVariantName;
private List<string> _variantNames = [];
private static readonly SemaphoreSlim SelectionStoreLock = new(1, 1);
private static readonly string[] ExpectedUploadFileNames =
[
"Saldiperstichdatum.xlsx",
"Exportkommengehen.xlsx",
"HR_KPI_Export.xlsx",
"Abwesenheitinstunden.xlsx",
"Personalausgeschieden.xlsx"
];
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"];
private readonly CultureInfo _dateCulture = CultureInfo.GetCultureInfo("de-CH");
protected override async Task OnInitializedAsync()
{
_serverUploadFolder = Path.Combine(Environment.ContentRootPath, "hrdata");
Directory.CreateDirectory(_serverUploadFolder);
_selectionStorePath = Path.Combine(_serverUploadFolder, "hr-kpi-variants.json");
_selectionStore = await ReadSelectionStoreAsync();
_dataFolder = _serverUploadFolder;
if (_selectionStore.LastSelection is not null)
ApplySelectionState(_selectionStore.LastSelection);
else
_mitarbeitertyp = "Festangestellt";
RefreshVariantNames();
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
});
_selectionStore.LastSelection = CreateSelectionState();
await WriteSelectionStoreAsync();
}
catch (Exception ex)
{
Snackbar.Add(ex.Message, Severity.Error);
}
finally
{
_loading = false;
}
}
private async Task UploadHrKpiFilesAsync(InputFileChangeEventArgs args)
{
if (!CanShowHrKpi)
{
return;
}
_uploading = true;
try
{
Directory.CreateDirectory(_serverUploadFolder);
var uploaded = 0;
var skipped = new List<string>();
var expected = ExpectedUploadFileNames.ToHashSet(StringComparer.OrdinalIgnoreCase);
foreach (var file in args.GetMultipleFiles(10))
{
var fileName = Path.GetFileName(file.Name);
if (!expected.Contains(fileName))
{
skipped.Add(fileName);
continue;
}
var targetPath = Path.Combine(_serverUploadFolder, fileName);
await using var source = file.OpenReadStream(50 * 1024 * 1024);
await using var target = File.Create(targetPath);
await source.CopyToAsync(target);
uploaded++;
}
_dataFolder = _serverUploadFolder;
if (uploaded > 0)
{
Snackbar.Add($"{uploaded} HR-KPI-Datei(en) auf den Server geladen.", Severity.Success);
await LoadAsync();
}
if (skipped.Count > 0)
Snackbar.Add($"Nicht uebernommen, weil Dateiname nicht erwartet wird: {string.Join(", ", skipped)}", Severity.Warning);
}
catch (Exception ex)
{
Snackbar.Add($"Upload fehlgeschlagen: {ex.Message}", Severity.Error);
}
finally
{
_uploading = false;
}
}
private async Task UseServerUploadFolderAsync()
{
_dataFolder = _serverUploadFolder;
await LoadAsync();
}
private async Task SaveVariantAsync()
{
var name = (_variantName ?? _selectedVariantName)?.Trim();
if (string.IsNullOrWhiteSpace(name))
{
Snackbar.Add(T("Bitte Variantenname eingeben.", "Please enter a variant name."), Severity.Warning);
return;
}
_selectionStore = await ReadSelectionStoreAsync();
_selectionStore.Variants[name] = CreateSelectionState();
await WriteSelectionStoreAsync();
RefreshVariantNames();
_selectedVariantName = name;
_variantName = name;
Snackbar.Add($"{T("Variante gespeichert", "Variant saved")}: {name}", Severity.Success);
}
private async Task UpdateSelectedVariantAsync()
{
if (string.IsNullOrWhiteSpace(_selectedVariantName))
return;
var name = _selectedVariantName.Trim();
_selectionStore = await ReadSelectionStoreAsync();
_selectionStore.Variants[name] = CreateSelectionState();
await WriteSelectionStoreAsync();
RefreshVariantNames();
_selectedVariantName = name;
_variantName = name;
Snackbar.Add($"{T("Variante aktualisiert", "Variant updated")}: {name}", Severity.Success);
}
private async Task RenameVariantAsync()
{
if (string.IsNullOrWhiteSpace(_selectedVariantName) || string.IsNullOrWhiteSpace(_variantName))
return;
var oldName = _selectedVariantName.Trim();
var newName = _variantName.Trim();
if (string.Equals(oldName, newName, StringComparison.OrdinalIgnoreCase))
{
Snackbar.Add(T("Der Variantenname ist unverändert.", "The variant name is unchanged."), Severity.Info);
return;
}
_selectionStore = await ReadSelectionStoreAsync();
if (!_selectionStore.Variants.TryGetValue(oldName, out var selection))
{
Snackbar.Add(T("Variante nicht gefunden.", "Variant not found."), Severity.Warning);
RefreshVariantNames();
return;
}
_selectionStore.Variants.Remove(oldName);
_selectionStore.Variants[newName] = selection;
await WriteSelectionStoreAsync();
RefreshVariantNames();
_selectedVariantName = newName;
_variantName = newName;
Snackbar.Add($"{T("Variante umbenannt", "Variant renamed")}: {oldName} -> {newName}", Severity.Success);
}
private async Task LoadVariantAsync()
{
if (string.IsNullOrWhiteSpace(_selectedVariantName))
return;
_selectionStore = await ReadSelectionStoreAsync();
if (!_selectionStore.Variants.TryGetValue(_selectedVariantName.Trim(), out var selection))
{
Snackbar.Add(T("Variante nicht gefunden.", "Variant not found."), Severity.Warning);
RefreshVariantNames();
return;
}
ApplySelectionState(selection);
_variantName = _selectedVariantName;
await LoadAsync();
}
private async Task DeleteVariantAsync()
{
if (string.IsNullOrWhiteSpace(_selectedVariantName))
return;
var name = _selectedVariantName.Trim();
_selectionStore = await ReadSelectionStoreAsync();
_selectionStore.Variants.Remove(name);
await WriteSelectionStoreAsync();
RefreshVariantNames();
_selectedVariantName = null;
_variantName = null;
Snackbar.Add($"{T("Variante gelöscht", "Variant deleted")}: {name}", Severity.Success);
}
private void RefreshVariantNames()
{
_variantNames = _selectionStore.Variants.Keys
.OrderBy(x => x, StringComparer.OrdinalIgnoreCase)
.ToList();
}
private HrKpiSelectionState CreateSelectionState()
=> new()
{
DataFolder = _dataFolder,
Year = _year,
FromDate = _fromDate,
ToDate = _toDate,
EntryYear = _entryYear,
Organisation = _organisation,
Kostenstelle = _kostenstelle,
Mitarbeitertyp = _mitarbeitertyp,
FluktuationFilter = _fluktuationFilter,
GlzAmpel = _glzAmpel,
RestferienAmpel = _restferienAmpel,
SearchText = _searchText,
ManagementView = _managementView
};
private void ApplySelectionState(HrKpiSelectionState state)
{
_dataFolder = string.IsNullOrWhiteSpace(state.DataFolder) ? _serverUploadFolder : state.DataFolder;
_year = state.Year;
_fromDate = state.FromDate;
_toDate = state.ToDate;
_entryYear = state.EntryYear;
_organisation = state.Organisation;
_kostenstelle = state.Kostenstelle;
_mitarbeitertyp = string.IsNullOrWhiteSpace(state.Mitarbeitertyp) ? "Festangestellt" : state.Mitarbeitertyp;
_fluktuationFilter = string.IsNullOrWhiteSpace(state.FluktuationFilter) ? "Alle" : state.FluktuationFilter;
_glzAmpel = state.GlzAmpel;
_restferienAmpel = state.RestferienAmpel;
_searchText = state.SearchText;
_managementView = state.ManagementView;
}
private async Task<HrKpiSelectionStore> ReadSelectionStoreAsync()
{
await SelectionStoreLock.WaitAsync();
try
{
if (!File.Exists(_selectionStorePath))
return new HrKpiSelectionStore();
await using var stream = File.OpenRead(_selectionStorePath);
return await JsonSerializer.DeserializeAsync<HrKpiSelectionStore>(stream) ?? new HrKpiSelectionStore();
}
catch
{
return new HrKpiSelectionStore();
}
finally
{
SelectionStoreLock.Release();
}
}
private async Task WriteSelectionStoreAsync()
{
await SelectionStoreLock.WaitAsync();
try
{
Directory.CreateDirectory(Path.GetDirectoryName(_selectionStorePath) ?? _serverUploadFolder);
var options = new JsonSerializerOptions { WriteIndented = true };
await using var stream = File.Create(_selectionStorePath);
await JsonSerializer.SerializeAsync(stream, _selectionStore, options);
}
finally
{
SelectionStoreLock.Release();
}
}
private async Task UnlockHrKpiAsync()
{
_unlockClickCount++;
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 Task ChangeHrPasswordAsync()
{
if (string.IsNullOrWhiteSpace(_newPassword) || _newPassword.Length < 8)
{
Snackbar.Add(T("Das neue Passwort muss mindestens 8 Zeichen lang sein.", "The new password must be at least 8 characters long."), Severity.Warning);
return Task.CompletedTask;
}
if (_newPassword != _newPasswordRepeat)
{
Snackbar.Add(T("Die neuen Passwörter stimmen nicht überein.", "The new passwords do not match."), Severity.Warning);
return Task.CompletedTask;
}
if (!HrKpiAccess.TryChangePassword(_changeUsername ?? string.Empty, _currentPassword ?? string.Empty, _newPassword))
{
Snackbar.Add(T("Passwort konnte nicht geändert werden. Name oder aktuelles Passwort prüfen.", "Password could not be changed. Check the name or current password."), Severity.Error);
return Task.CompletedTask;
}
_currentPassword = string.Empty;
_newPassword = string.Empty;
_newPasswordRepeat = string.Empty;
Snackbar.Add(T("Passwort wurde geändert.", "Password has been changed."), Severity.Success);
return Task.CompletedTask;
}
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);
private sealed class HrKpiSelectionStore
{
public HrKpiSelectionState? LastSelection { get; set; }
public Dictionary<string, HrKpiSelectionState> Variants { get; set; } = new(StringComparer.OrdinalIgnoreCase);
}
private sealed class HrKpiSelectionState
{
public string? DataFolder { get; set; }
public int? Year { get; set; }
public DateTime? FromDate { get; set; }
public DateTime? ToDate { get; set; }
public int? EntryYear { get; set; }
public string? Organisation { get; set; }
public string? Kostenstelle { 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; }
}
}
@@ -0,0 +1,188 @@
@page "/hr-kpi/schulung"
@rendermode @(Microsoft.AspNetCore.Components.Web.RenderMode.InteractiveServer)
@inject TrafagSalesExporter.Services.IUiTextService UiText
<PageTitle>@T("HR KPI Schulung", "HR KPI training")</PageTitle>
<MudText Typo="Typo.h4" Class="mb-2">@T("HR KPI Schulung", "HR KPI training")</MudText>
<MudText Typo="Typo.body1" Class="mb-4 training-lead">
Ausführliche Anwenderunterlage für HR-Keyuser, HR-Leitung und Stellvertretungen. Die Seite erklärt nicht nur die Bedienung,
sondern auch die Reihenfolge, die Prüfpflichten und die fachliche Interpretation der wichtigsten Kennzahlen.
</MudText>
<MudPaper Class="pa-4 mb-4" Elevation="1">
<MudGrid>
<MudItem xs="12" md="7">
<img class="training-hero-image" src="training/hr_kpi_cockpit_preview.png" alt="HR KPI Cockpit Vorschau" />
</MudItem>
<MudItem xs="12" md="5">
<MudAlert Severity="Severity.Info" Variant="Variant.Outlined" Class="mb-3">
Diese Schulung ist als Arbeitsanleitung gedacht: erst Datenstatus prüfen, dann filtern, danach Kennzahlen interpretieren.
</MudAlert>
<MudSimpleTable Dense Hover>
<thead>
<tr><th>Rolle</th><th>Schwerpunkt</th></tr>
</thead>
<tbody>
<tr><td>HR Keyuser</td><td>Daten bereitstellen, Plausibilität prüfen, Monatsauswertung erstellen</td></tr>
<tr><td>HR Leitung</td><td>Kennzahlen bewerten, Managementsicht freigeben, Massnahmen ableiten</td></tr>
<tr><td>IT/Admin</td><td>Zugriff, Pfad, Konfiguration und technische Fehler klären</td></tr>
</tbody>
</MudSimpleTable>
</MudItem>
</MudGrid>
</MudPaper>
<MudTabs Rounded Border>
<MudTabPanel Text="Überblick">
<TrainingSection Title="1. Zweck und Grundprinzip">
<p>Das HR KPI Cockpit bündelt operative HR-Auswertungen in einer geschützten Oberfläche. Es ersetzt keine fachliche Freigabe,
sondern liefert eine nachvollziehbare Sicht auf Headcount, Fluktuation, Absenzen, GLZ, Restferien und Datenqualität.</p>
<p>Die wichtigste Regel lautet: Eine Kennzahl ist nur so belastbar wie die Quelldateien, Filter und fachlichen Ausschlüsse,
die zu ihrer Berechnung verwendet wurden. Deshalb beginnt jede Auswertung mit dem Datenstatus.</p>
<ul>
<li>Vor jeder Weitergabe den Reiter <strong>Datenstatus</strong> öffnen.</li>
<li>Rote Datenqualitätsmeldungen zuerst klären.</li>
<li>Filter und Zeitraum in Bericht oder E-Mail nennen.</li>
<li>Für Managementberichte personenbezogene Detailtabellen vermeiden.</li>
</ul>
</TrainingSection>
<TrainingSection Title="2. Bedienreihenfolge">
<div class="training-flow">
<div><span>1</span><strong>Dateien exportieren</strong><small>Rexx/SAP-Quellen aktualisieren</small></div>
<div><span>2</span><strong>Datenordner prüfen</strong><small>Pfad und Dateistand kontrollieren</small></div>
<div><span>3</span><strong>Laden</strong><small>Cockpit neu aufbauen</small></div>
<div><span>4</span><strong>Datenqualität</strong><small>Warnungen und Fehler lesen</small></div>
<div><span>5</span><strong>KPI freigeben</strong><small>Interpretieren und dokumentieren</small></div>
</div>
<p>Diese Reihenfolge verhindert, dass alte Dateien, leere Filter oder unvollständige Exporte als Managementzahlen verwendet werden.</p>
</TrainingSection>
<TrainingSection Title="3. Datenquellen und Dateistatus">
<MudSimpleTable Dense Hover>
<thead><tr><th>Datei</th><th>Inhalt</th><th>Prüfung</th></tr></thead>
<tbody>
<tr><td>Saldiperstichdatum.xlsx</td><td>Aktive Mitarbeitende, Saldi, Ferien, Organisation, Kostenstelle</td><td>Zeilenanzahl, Alter, Organisationsabdeckung</td></tr>
<tr><td>Exportkommengehen.xlsx</td><td>Arbeitszeitmodell, Sollzeit, Geburtsdatum</td><td>FTE-/Sollzeit-Fallback prüfen</td></tr>
<tr><td>HR_KPI_Export.xlsx</td><td>SAP-HR-Felder, Beschäftigungsgrad, Geschlecht, BU/NBU, Planstelle</td><td>Join auf Personalnummer plausibilisieren</td></tr>
<tr><td>Abwesenheitinstunden.xlsx</td><td>Krankheit kurz/lang, Unfall, Stundenwerte</td><td>Stunden-zu-Tage-Logik und Zeitraum prüfen</td></tr>
<tr><td>Personalausgeschieden.xlsx</td><td>Austritte, Austrittsart, Austrittsdatum</td><td>Austrittsarten und Ausschlüsse kontrollieren</td></tr>
</tbody>
</MudSimpleTable>
<MudAlert Severity="Severity.Warning" Variant="Variant.Outlined" Class="mt-3">
PDF-Dateien sind für diese Auswertung ungeeignet. Die Dateien müssen als strukturierte Excel-Dateien vorliegen.
</MudAlert>
</TrainingSection>
</MudTabPanel>
<MudTabPanel Text="Auswertung">
<TrainingSection Title="4. Filter richtig verwenden">
<p>Filter sind fachliche Eingriffe in die Sicht. Deshalb muss bei jeder Auswertung klar sein, ob ein Filter nur aktive Mitarbeitende,
Austritte oder beide Datenbereiche betrifft.</p>
<MudSimpleTable Dense Hover>
<thead><tr><th>Filter</th><th>Wirkung</th><th>Hinweis</th></tr></thead>
<tbody>
<tr><td>Austrittsjahr</td><td>grenzt Austritte nach Jahr ein</td><td>Leer bedeutet alle verfügbaren Jahre</td></tr>
<tr><td>Von/Bis Austritt</td><td>hat Vorrang vor Austrittsjahr</td><td>Für Quartals- und Sonderauswertungen verwenden</td></tr>
<tr><td>Organisation</td><td>wirkt auf aktive Mitarbeitende und passende Austrittsdaten</td><td>Leere Organisationen sind Datenqualitätsthema</td></tr>
<tr><td>Kostenstelle</td><td>wirkt stabil auf aktive Mitarbeitende</td><td>Nicht jede Austrittsquelle enthält Kostenstellen sauber</td></tr>
<tr><td>GLZ/Restferien Ampel</td><td>fokussiert operative Prüffälle</td><td>Nicht als Fluktuationsfilter interpretieren</td></tr>
<tr><td>Managementsicht</td><td>reduziert personenbezogene Details</td><td>Für Weitergabe und Ausdruck bevorzugen</td></tr>
</tbody>
</MudSimpleTable>
</TrainingSection>
<TrainingSection Title="5. Headcount und Mitarbeitende">
<p>Headcount ist die Basis fast aller HR-Kennzahlen. Sprünge gegenüber Vormonat oder Vorjahr müssen erklärbar sein,
zum Beispiel durch Eintritte, Austritte, Organisationswechsel oder geänderte Quelldateien.</p>
<ul>
<li>Headcount nach Organisation zählt eindeutige Personalnummern.</li>
<li>Leere Personalnummern werden nicht als verlässlicher Distinct-Headcount verwendet.</li>
<li>FTE kann aus SAP-Beschäftigungsgrad oder aus Arbeitszeitmodell/Sollzeit abgeleitet werden.</li>
<li>Bei unerwarteten Sprüngen zuerst Datenstatus und Join-Hinweise prüfen.</li>
</ul>
</TrainingSection>
<TrainingSection Title="6. Fluktuation fachlich lesen">
<p>Die Fluktuation wird aus ausgeschiedenen Personen berechnet. Relevant sind vor allem Arbeitnehmerkündigungen.
Praktikanten, befristete Verträge, Pensionierungen und Arbeitgeberkündigungen werden für die relevante Fluktuation ausgeschlossen.</p>
<MudSimpleTable Dense Hover>
<thead><tr><th>Kennzahl</th><th>Berechnung</th><th>Interpretation</th></tr></thead>
<tbody>
<tr><td>Monatsfluktuation</td><td>relevante Austritte im Monat / Headcount des Monats</td><td>Operativer Frühindikator</td></tr>
<tr><td>Quartalsfluktuation</td><td>relevante Austritte im Quartal / durchschnittlicher Quartals-Headcount</td><td>Stabiler als Monatswert</td></tr>
<tr><td>Jahresfluktuation</td><td>relevante Austritte im Jahr / durchschnittlicher Jahres-Headcount</td><td>Management-KPI</td></tr>
<tr><td>Hochrechnung</td><td>aktuelle Quartalsfluktuation x 4</td><td>Nur als Prognose lesen</td></tr>
</tbody>
</MudSimpleTable>
</TrainingSection>
<TrainingSection Title="7. Absenzen, GLZ und Restferien">
<p>Absenzen werden nach Stunden/Tagen und Organisation ausgewertet. Die Krankenquote nutzt Krankheitstage im Verhältnis
zu FTE und Arbeitstagen. Unfalltage können je nach Quelle anders abgegrenzt sein und müssen daher vorsichtig interpretiert werden.</p>
<ul>
<li>Top-Absenzen dienen der operativen Prüfung, nicht der direkten Weitergabe.</li>
<li>Kritische Restferien zeigen Planungsbedarf oder Datenfehler.</li>
<li>Kritische GLZ-Saldi sollten mit Linienverantwortlichen geprüft werden.</li>
<li>Gelbe Ampeln sind Beobachtungspunkte, rote Ampeln brauchen aktive Klärung.</li>
</ul>
</TrainingSection>
</MudTabPanel>
<MudTabPanel Text="Qualität & Freigabe">
<TrainingSection Title="8. Datenqualität systematisch prüfen">
<MudSimpleTable Dense Hover>
<thead><tr><th>Fehlerbild</th><th>Mögliche Ursache</th><th>Massnahme</th></tr></thead>
<tbody>
<tr><td>Keine Daten</td><td>Datei fehlt, falscher Ordner, falscher Dateiname</td><td>Pfad korrigieren und neu laden</td></tr>
<tr><td>Alter Dateistand</td><td>Export wurde nicht erneuert</td><td>Quelle neu exportieren</td></tr>
<tr><td>Leere Organisation</td><td>Join oder Stammdatenfeld fehlt</td><td>Quelldaten und Personalnummer prüfen</td></tr>
<tr><td>Sprung im Headcount</td><td>neuer Export, Filter, Stichtagswechsel</td><td>Vorperiode und Dateistatus vergleichen</td></tr>
<tr><td>Ungewöhnliche Absenzen</td><td>Stundenlogik, Zeitraum, Doppelerfassung</td><td>Einzelzeilen und Quelle prüfen</td></tr>
</tbody>
</MudSimpleTable>
</TrainingSection>
<TrainingSection Title="9. Managementsicht und Datenschutz">
<p>HR-Daten enthalten personenbezogene Informationen. Für Berichte an Management oder Dritte ist die Managementsicht zu bevorzugen.
Detailtabellen mit Namen, Personalnummern oder Einzelfällen dürfen nur an berechtigte Personen gehen.</p>
<MudAlert Severity="Severity.Error" Variant="Variant.Outlined">
Keine HR-Dateien per E-Mail weiterleiten und keine Kopien in ungeschützten Ordnern liegen lassen.
</MudAlert>
</TrainingSection>
<TrainingSection Title="10. Monatsabschluss-Checkliste">
<MudSimpleTable Dense Hover>
<thead><tr><th>Nr.</th><th>Prüfpunkt</th><th>Erledigt</th></tr></thead>
<tbody>
<tr><td>1</td><td>Alle HR-Quelldateien neu exportiert und im richtigen Ordner abgelegt</td><td></td></tr>
<tr><td>2</td><td>Cockpit neu geladen, keine roten Dateistatusmeldungen</td><td></td></tr>
<tr><td>3</td><td>Headcount gegen Vorperiode plausibilisiert</td><td></td></tr>
<tr><td>4</td><td>Fluktuationsausschlüsse fachlich geprüft</td><td></td></tr>
<tr><td>5</td><td>Absenzen, GLZ und Restferien auf Ausreisser geprüft</td><td></td></tr>
<tr><td>6</td><td>Filter, Zeitraum und Datenstand im Bericht dokumentiert</td><td></td></tr>
<tr><td>7</td><td>Managementsicht für Weitergabe verwendet</td><td></td></tr>
</tbody>
</MudSimpleTable>
</TrainingSection>
</MudTabPanel>
</MudTabs>
<style>
.training-lead { max-width: 980px; color: var(--mud-palette-text-secondary); }
.training-hero-image { width: 100%; border: 1px solid var(--mud-palette-lines-default); border-radius: 6px; }
.training-section { margin: 18px 0 28px; max-width: 1180px; }
.training-section p { margin-bottom: 12px; line-height: 1.55; }
.training-section ul { margin-top: 8px; }
.training-flow { display: grid; grid-template-columns: repeat(5, minmax(120px, 1fr)); gap: 10px; margin: 12px 0 18px; }
.training-flow div { border: 1px solid var(--mud-palette-lines-default); border-radius: 6px; padding: 12px; background: var(--mud-palette-surface); }
.training-flow span { display: inline-flex; width: 28px; height: 28px; align-items: center; justify-content: center; border-radius: 50%; background: var(--mud-palette-primary); color: var(--mud-palette-primary-text); font-weight: 700; margin-bottom: 8px; }
.training-flow strong, .training-flow small { display: block; }
.training-flow small { color: var(--mud-palette-text-secondary); margin-top: 4px; }
@@media (max-width: 900px) { .training-flow { grid-template-columns: 1fr; } }
</style>
@code {
private string T(string german, string english) => UiText.Text(german, english);
}
@@ -0,0 +1,60 @@
@page "/diagnostics/interactive"
@rendermode @(Microsoft.AspNetCore.Components.Web.RenderMode.InteractiveServer)
@inject ILogger<InteractiveDiagnostics> Logger
@inject NavigationManager Navigation
<PageTitle>Interaktivitaet Diagnose</PageTitle>
<MudText Typo="Typo.h4" Class="mb-4">Interaktivitaet Diagnose</MudText>
<MudPaper Class="pa-4" Elevation="1" Style="max-width:760px;">
<MudStack Spacing="3">
<MudAlert Severity="Severity.Info" Variant="Variant.Outlined">
HTML wurde vom Server gerendert.
</MudAlert>
<MudText>Adresse: @Navigation.Uri</MudText>
<MudText>Blazor interaktiv verbunden: @(_interactive ? "JA" : "NEIN")</MudText>
<MudText>Server-Klicks angekommen: @_clickCount</MudText>
<MudText>JavaScript Diagnose: <span id="js-diagnostic-status">nicht ausgefuehrt</span></MudText>
<MudText>Blazor Objekt im Browser: <span id="blazor-diagnostic-status">unbekannt</span></MudText>
<MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="RegisterClick">
Server-Klick testen
</MudButton>
</MudStack>
</MudPaper>
<script>
(function () {
var jsStatus = document.getElementById('js-diagnostic-status');
var blazorStatus = document.getElementById('blazor-diagnostic-status');
if (jsStatus) {
jsStatus.textContent = 'ausgefuehrt';
}
if (blazorStatus) {
blazorStatus.textContent = window.Blazor ? 'vorhanden' : 'fehlt';
}
})();
</script>
@code {
private bool _interactive;
private int _clickCount;
protected override void OnAfterRender(bool firstRender)
{
if (!firstRender)
return;
_interactive = true;
Logger.LogInformation("Interactive diagnostics became interactive for {Uri}", Navigation.Uri);
StateHasChanged();
}
private void RegisterClick()
{
_clickCount++;
Logger.LogInformation("Interactive diagnostics server click received. Count={ClickCount}", _clickCount);
}
}
@@ -1,4 +1,5 @@
@page "/logs"
@rendermode @(Microsoft.AspNetCore.Components.Web.RenderMode.InteractiveServer)
@using TrafagSalesExporter.Services
@inject ILogsPageService LogsPageActions
@inject ISnackbar Snackbar
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,368 @@
@page "/manual-imports"
@rendermode @(Microsoft.AspNetCore.Components.Web.RenderMode.InteractiveServer)
@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; }
}
}
@@ -0,0 +1,295 @@
@page "/admin/menu-structure"
@using Microsoft.AspNetCore.Authorization
@using TrafagSalesExporter.Models
@using TrafagSalesExporter.Security
@using TrafagSalesExporter.Services
@attribute [Authorize(Policy = SecurityPolicies.AdminOnly)]
@inject INavigationMenuService NavigationMenuService
@inject IUiTextService UiText
@inject ISnackbar Snackbar
<PageTitle>@T("Menuestruktur", "Menu structure")</PageTitle>
<MudText Typo="Typo.h4" Class="mb-4">@T("Menuestruktur", "Menu structure")</MudText>
<MudStack Row="true" Spacing="2" Class="mb-3">
<MudButton Variant="Variant.Filled" Color="Color.Primary" StartIcon="@Icons.Material.Filled.Save" OnClick="SaveAsync" Disabled="_loading">
@T("Speichern", "Save")
</MudButton>
<MudButton Variant="Variant.Outlined" Color="Color.Secondary" StartIcon="@Icons.Material.Filled.Restore" OnClick="ResetAsync" Disabled="_loading">
@T("Standard wiederherstellen", "Restore default")
</MudButton>
</MudStack>
<MudAlert Severity="Severity.Info" Variant="Variant.Outlined" Class="mb-4">
@T("Bestehende Menuepunkte koennen in andere Gruppen gehaengt, sortiert, aus- oder eingeblendet und umbenannt werden. Die Zielseite bleibt unveraendert.",
"Existing menu entries can be moved into other groups, sorted, hidden/shown and renamed. The target page stays unchanged.")
</MudAlert>
@if (_loading)
{
<MudProgressCircular Indeterminate="true" />
}
else
{
<MudText Typo="Typo.h6" Class="mb-2">@T("Drag & Drop", "Drag & drop")</MudText>
<div class="menu-drop-root"
@ondragover:preventDefault
@ondrop="DropOnRoot">
@T("Hier ablegen fuer Hauptmenue", "Drop here for root menu")
</div>
<MudPaper Class="pa-2 mb-4" Outlined="true">
@foreach (var item in OrderedItems)
{
<div class="menu-drag-row"
draggable="true"
@ondragstart="() => StartDrag(item)"
@ondragover:preventDefault
@ondrop="() => DropOn(item)">
<MudIcon Icon="@NavigationIconResolver.Resolve(item.Icon)" Size="Size.Small" />
<span class="menu-drag-title">@Indent(item)@item.TitleDe</span>
<span class="menu-drag-meta">@item.ItemType</span>
</div>
}
</MudPaper>
<MudText Typo="Typo.h6" Class="mb-2">@T("Details", "Details")</MudText>
<MudTable Items="@OrderedItems" Dense="true" Hover="true">
<HeaderContent>
<MudTh>@T("Reihenfolge", "Order")</MudTh>
<MudTh>@T("Titel", "Title")</MudTh>
<MudTh>@T("Typ", "Type")</MudTh>
<MudTh>@T("Untermenue von", "Parent")</MudTh>
<MudTh>@T("Sichtbar", "Visible")</MudTh>
<MudTh>@T("Aktion", "Action")</MudTh>
</HeaderContent>
<RowTemplate>
<MudTd>
<MudIconButton Icon="@Icons.Material.Filled.KeyboardArrowUp" Size="Size.Small" OnClick="@(() => Move(context, -1))" />
<MudIconButton Icon="@Icons.Material.Filled.KeyboardArrowDown" Size="Size.Small" OnClick="@(() => Move(context, 1))" />
</MudTd>
<MudTd>
<MudTextField @bind-Value="context.TitleDe" Label="DE" Dense="true" Margin="Margin.Dense" />
<MudTextField @bind-Value="context.TitleEn" Label="EN" Dense="true" Margin="Margin.Dense" />
@if (!string.IsNullOrWhiteSpace(context.Href))
{
<MudText Typo="Typo.caption">@context.Href</MudText>
}
</MudTd>
<MudTd>@context.ItemType</MudTd>
<MudTd>
<MudSelect T="string" Value="@NormalizeParent(context.ParentKey)" ValueChanged="value => ChangeParent(context, value)" Dense="true">
<MudSelectItem Value="@RootParentValue">@T("Hauptmenue", "Root menu")</MudSelectItem>
@foreach (var group in ParentOptions(context))
{
<MudSelectItem Value="@group.Key">@GroupLabel(group)</MudSelectItem>
}
</MudSelect>
</MudTd>
<MudTd>
<MudSwitch @bind-Value="context.IsVisible" Color="Color.Primary" />
</MudTd>
<MudTd>
<MudText Typo="Typo.caption">@context.Key</MudText>
</MudTd>
</RowTemplate>
</MudTable>
}
@code {
private const string RootParentValue = "__root__";
private List<NavigationMenuItem> _items = [];
private NavigationMenuItem? _draggedItem;
private bool _loading = true;
private IEnumerable<NavigationMenuItem> OrderedItems => _items
.OrderBy(x => x.ParentKey ?? string.Empty)
.ThenBy(x => x.SortOrder)
.ThenBy(x => x.TitleDe);
protected override async Task OnInitializedAsync()
{
await LoadAsync();
}
private async Task LoadAsync()
{
_loading = true;
_items = await NavigationMenuService.GetItemsAsync();
_loading = false;
}
private async Task SaveAsync()
{
NormalizeSortOrders();
await NavigationMenuService.SaveItemsAsync(_items);
Snackbar.Add(T("Menuestruktur gespeichert.", "Menu structure saved."), Severity.Success);
}
private async Task ResetAsync()
{
await NavigationMenuService.ResetToDefaultsAsync();
await LoadAsync();
Snackbar.Add(T("Standard-Menuestruktur wiederhergestellt.", "Default menu structure restored."), Severity.Info);
}
private IEnumerable<NavigationMenuItem> ParentOptions(NavigationMenuItem item)
=> _items
.Where(x => x.ItemType == NavigationMenuItemTypes.Group)
.Where(x => x.Key != item.Key)
.Where(x => !WouldCreateCycle(item.Key, x.Key))
.OrderBy(x => x.TitleDe);
private void ChangeParent(NavigationMenuItem item, string parentKey)
{
item.ParentKey = parentKey == RootParentValue ? null : parentKey;
item.SortOrder = NextSortOrder(item.ParentKey);
}
private void Move(NavigationMenuItem item, int direction)
{
var siblings = _items
.Where(x => string.Equals(x.ParentKey, item.ParentKey, StringComparison.OrdinalIgnoreCase))
.OrderBy(x => x.SortOrder)
.ThenBy(x => x.TitleDe)
.ToList();
var index = siblings.FindIndex(x => x.Key == item.Key);
var targetIndex = index + direction;
if (index < 0 || targetIndex < 0 || targetIndex >= siblings.Count)
return;
(siblings[index].SortOrder, siblings[targetIndex].SortOrder) = (siblings[targetIndex].SortOrder, siblings[index].SortOrder);
}
private void StartDrag(NavigationMenuItem item)
{
_draggedItem = item;
}
private void DropOnRoot()
{
if (_draggedItem is null)
return;
_draggedItem.ParentKey = null;
_draggedItem.SortOrder = NextSortOrder(null);
_draggedItem = null;
}
private void DropOn(NavigationMenuItem target)
{
if (_draggedItem is null || _draggedItem.Key == target.Key)
return;
if (target.ItemType == NavigationMenuItemTypes.Group && !WouldCreateCycle(_draggedItem.Key, target.Key))
{
_draggedItem.ParentKey = target.Key;
_draggedItem.SortOrder = NextSortOrder(target.Key);
}
else
{
_draggedItem.ParentKey = target.ParentKey;
_draggedItem.SortOrder = target.SortOrder - 1;
NormalizeSortOrders();
}
_draggedItem = null;
}
private bool WouldCreateCycle(string itemKey, string candidateParentKey)
{
var current = _items.FirstOrDefault(x => x.Key == candidateParentKey);
while (current is not null)
{
if (current.Key == itemKey)
return true;
current = string.IsNullOrWhiteSpace(current.ParentKey)
? null
: _items.FirstOrDefault(x => x.Key == current.ParentKey);
}
return false;
}
private int NextSortOrder(string? parentKey)
=> _items
.Where(x => string.Equals(x.ParentKey, parentKey, StringComparison.OrdinalIgnoreCase))
.Select(x => x.SortOrder)
.DefaultIfEmpty(0)
.Max() + 10;
private void NormalizeSortOrders()
{
foreach (var group in _items.GroupBy(x => x.ParentKey ?? string.Empty))
{
var sortOrder = 10;
foreach (var item in group.OrderBy(x => x.SortOrder).ThenBy(x => x.TitleDe))
{
item.SortOrder = sortOrder;
sortOrder += 10;
}
}
}
private static string NormalizeParent(string? parentKey)
=> string.IsNullOrWhiteSpace(parentKey) ? RootParentValue : parentKey;
private string GroupLabel(NavigationMenuItem item)
=> UiText.Text(item.TitleDe, item.TitleEn);
private string T(string german, string english) => UiText.Text(german, english);
private string Indent(NavigationMenuItem item)
{
var depth = 0;
var current = item;
while (!string.IsNullOrWhiteSpace(current.ParentKey))
{
depth++;
var next = _items.FirstOrDefault(x => x.Key == current.ParentKey);
if (next is null || next.Key == current.Key)
break;
current = next;
}
return new string(' ', depth * 4);
}
}
<style>
.menu-drop-root {
border: 1px dashed var(--mud-palette-lines-default);
border-radius: 4px;
padding: 8px 10px;
margin-bottom: 8px;
color: var(--mud-palette-text-secondary);
font-size: 0.875rem;
}
.menu-drag-row {
display: grid;
grid-template-columns: 28px minmax(0, 1fr) auto;
align-items: center;
gap: 8px;
min-height: 34px;
padding: 4px 8px;
border-bottom: 1px solid var(--mud-palette-lines-default);
cursor: grab;
}
.menu-drag-row:last-child {
border-bottom: 0;
}
.menu-drag-title {
white-space: pre;
overflow: hidden;
text-overflow: ellipsis;
}
.menu-drag-meta {
color: var(--mud-palette-text-secondary);
font-size: 0.75rem;
}
</style>
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,289 @@
@page "/einkauf/verbindungen"
@using TrafagSalesExporter.Services
@inject IPurchasingDataSourcePageService DataSourceService
@inject TrafagSalesExporter.Services.IUiTextService UiText
@inject ISnackbar Snackbar
<PageTitle>@T("Einkauf Datenquellen", "Purchasing data sources")</PageTitle>
<MudText Typo="Typo.h4" Class="mb-1">@T("Einkauf Datenquellen", "Purchasing data sources")</MudText>
<MudText Typo="Typo.body2" Class="mb-4 purchasing-muted">
@T("Grafische SAP/OData-Anbindung fuer das Einkaufsdashboard, analog zur Finance-Quellenpflege.",
"Graphical SAP/OData connection for the purchasing dashboard, following the finance source configuration pattern.")
</MudText>
@if (_loading)
{
<MudProgressLinear Indeterminate="true" />
}
else
{
<MudGrid Spacing="2" Class="mb-4">
<MudItem xs="12" md="7">
<MudPaper Class="pa-3" Outlined="true">
<MudText Typo="Typo.h6" Class="mb-3">@T("Verbindung", "Connection")</MudText>
<MudGrid Spacing="2">
<MudItem xs="12" sm="6">
<MudTextField Value="_state.Site.TSC" Label="TSC" ReadOnly="true" />
</MudItem>
<MudItem xs="12" sm="6">
<MudTextField Value="_state.Site.SourceSystem" Label="@T("Quellsystem", "Source system")" ReadOnly="true" />
</MudItem>
<MudItem xs="12">
<MudTextField @bind-Value="_state.Site.SapServiceUrl"
Label="SAP Service URL Override"
HelperText="@T("Leer lassen = zentrale SAP OData URL aus Settings verwenden.", "Leave empty = use central SAP OData URL from settings.")" />
</MudItem>
<MudItem xs="12" sm="6">
<MudTextField @bind-Value="_state.Site.UsernameOverride"
Label="Username Override"
HelperText="@T("Leer lassen = zentraler SAP User.", "Leave empty = central SAP user.")" />
</MudItem>
<MudItem xs="12" sm="6">
<MudTextField @bind-Value="_state.Site.PasswordOverride"
Label="Password Override"
InputType="InputType.Password"
HelperText="@T("Leer lassen = zentrales SAP Passwort.", "Leave empty = central SAP password.")" />
</MudItem>
<MudItem xs="12">
<MudCheckBox @bind-Value="_state.Site.IsActive"
Label="@T("Einkaufsquelle fuer Import aktivieren", "Activate purchasing source for import")" />
</MudItem>
</MudGrid>
<MudStack Row="true" Spacing="2" Class="mt-3">
<MudButton Variant="Variant.Filled" Color="Color.Primary" StartIcon="@Icons.Material.Filled.Save" OnClick="SaveAsync" Disabled="_busy">
@T("Speichern", "Save")
</MudButton>
<MudButton Variant="Variant.Outlined" Color="Color.Info" StartIcon="@Icons.Material.Filled.NetworkCheck" OnClick="TestConnectionAsync" Disabled="_busy">
@T("Verbindung testen", "Test connection")
</MudButton>
<MudButton Variant="Variant.Outlined" StartIcon="@Icons.Material.Filled.Restore" OnClick="ResetDefaultsAsync" Disabled="_busy">
@T("Defaults wiederherstellen", "Restore defaults")
</MudButton>
</MudStack>
</MudPaper>
</MudItem>
<MudItem xs="12" md="5">
<MudPaper Class="pa-3" Outlined="true">
<MudText Typo="Typo.h6" Class="mb-2">@T("Aktuelle Basis", "Current basis")</MudText>
<div class="purchasing-source-row">
<MudIcon Icon="@Icons.Material.Filled.CloudQueue" Size="Size.Small" />
<span>@T("Zentrale URL", "Central URL"): <code>@Display(_state.SourceSystem?.CentralServiceUrl)</code></span>
</div>
<div class="purchasing-source-row">
<MudIcon Icon="@Icons.Material.Filled.TableChart" Size="Size.Small" />
<span>@T("Quellen", "Sources"): @_state.Sources.Count(x => x.IsActive)</span>
</div>
<div class="purchasing-source-row">
<MudIcon Icon="@Icons.Material.Filled.AccountTree" Size="Size.Small" />
<span>@T("Joins", "Joins"): @_state.Joins.Count(x => x.IsActive)</span>
</div>
<div class="purchasing-source-row">
<MudIcon Icon="@Icons.Material.Filled.Schema" Size="Size.Small" />
<span>@T("Mappings", "Mappings"): @_state.Mappings.Count(x => x.IsActive)</span>
</div>
</MudPaper>
</MudItem>
</MudGrid>
<MudTabs Elevation="1" Rounded="false" PanelClass="pt-4">
<MudTabPanel Text="@T("Quellen", "Sources")" Icon="@Icons.Material.Filled.Hub">
<MudStack Row="true" Justify="Justify.SpaceBetween" AlignItems="AlignItems.Center" Class="mb-2">
<MudText Typo="Typo.h6">@T("OData Entity Sets", "OData entity sets")</MudText>
<MudButton Variant="Variant.Outlined" StartIcon="@Icons.Material.Filled.Add" OnClick="AddSource">@T("Quelle", "Source")</MudButton>
</MudStack>
<MudTable Items="_state.Sources" Dense="true" Hover="true" Striped="true">
<HeaderContent>
<MudTh>Alias</MudTh>
<MudTh>Entity Set</MudTh>
<MudTh>@T("Primaer", "Primary")</MudTh>
<MudTh>@T("Aktiv", "Active")</MudTh>
<MudTh></MudTh>
</HeaderContent>
<RowTemplate>
<MudTd><MudTextField @bind-Value="context.Alias" Dense="true" /></MudTd>
<MudTd><MudTextField @bind-Value="context.EntitySet" Dense="true" /></MudTd>
<MudTd><MudCheckBox @bind-Value="context.IsPrimary" Dense="true" /></MudTd>
<MudTd><MudCheckBox @bind-Value="context.IsActive" Dense="true" /></MudTd>
<MudTd><MudIconButton Icon="@Icons.Material.Filled.Delete" Color="Color.Error" Size="Size.Small" OnClick="() => RemoveSource(context)" /></MudTd>
</RowTemplate>
</MudTable>
</MudTabPanel>
<MudTabPanel Text="@T("Join-Fluss", "Join flow")" Icon="@Icons.Material.Filled.AccountTree">
<MudGrid Spacing="2" Class="mb-3">
@foreach (var join in _state.Joins.Where(x => x.IsActive))
{
<MudItem xs="12" sm="6" lg="3">
<MudPaper Class="pa-3 purchasing-flow" Outlined="true">
<MudText Typo="Typo.subtitle2">@join.LeftAlias -> @join.RightAlias</MudText>
<MudText Typo="Typo.caption">@join.LeftKeys = @join.RightKeys</MudText>
</MudPaper>
</MudItem>
}
</MudGrid>
<MudStack Row="true" Justify="Justify.SpaceBetween" AlignItems="AlignItems.Center" Class="mb-2">
<MudText Typo="Typo.h6">@T("Joins", "Joins")</MudText>
<MudButton Variant="Variant.Outlined" StartIcon="@Icons.Material.Filled.Add" OnClick="AddJoin">@T("Join", "Join")</MudButton>
</MudStack>
<MudTable Items="_state.Joins" Dense="true" Hover="true" Striped="true">
<HeaderContent>
<MudTh>@T("Links", "Left")</MudTh>
<MudTh>Left Keys</MudTh>
<MudTh>@T("Rechts", "Right")</MudTh>
<MudTh>Right Keys</MudTh>
<MudTh>@T("Typ", "Type")</MudTh>
<MudTh>@T("Aktiv", "Active")</MudTh>
<MudTh></MudTh>
</HeaderContent>
<RowTemplate>
<MudTd><MudTextField @bind-Value="context.LeftAlias" Dense="true" /></MudTd>
<MudTd><MudTextField @bind-Value="context.LeftKeys" Dense="true" /></MudTd>
<MudTd><MudTextField @bind-Value="context.RightAlias" Dense="true" /></MudTd>
<MudTd><MudTextField @bind-Value="context.RightKeys" Dense="true" /></MudTd>
<MudTd><MudTextField @bind-Value="context.JoinType" Dense="true" /></MudTd>
<MudTd><MudCheckBox @bind-Value="context.IsActive" Dense="true" /></MudTd>
<MudTd><MudIconButton Icon="@Icons.Material.Filled.Delete" Color="Color.Error" Size="Size.Small" OnClick="() => RemoveJoin(context)" /></MudTd>
</RowTemplate>
</MudTable>
</MudTabPanel>
<MudTabPanel Text="@T("Mapping", "Mapping")" Icon="@Icons.Material.Filled.Schema">
<MudStack Row="true" Justify="Justify.SpaceBetween" AlignItems="AlignItems.Center" Class="mb-2">
<MudText Typo="Typo.h6">@T("Zielfelder", "Target fields")</MudText>
<MudButton Variant="Variant.Outlined" StartIcon="@Icons.Material.Filled.Add" OnClick="AddMapping">@T("Mapping", "Mapping")</MudButton>
</MudStack>
<MudTable Items="_state.Mappings" Dense="true" Hover="true" Striped="true">
<HeaderContent>
<MudTh>@T("Ziel", "Target")</MudTh>
<MudTh>@T("Quelle", "Source")</MudTh>
<MudTh>@T("Pflicht", "Required")</MudTh>
<MudTh>@T("Aktiv", "Active")</MudTh>
<MudTh></MudTh>
</HeaderContent>
<RowTemplate>
<MudTd><MudTextField @bind-Value="context.TargetField" Dense="true" /></MudTd>
<MudTd><MudTextField @bind-Value="context.SourceExpression" Dense="true" /></MudTd>
<MudTd><MudCheckBox @bind-Value="context.IsRequired" Dense="true" /></MudTd>
<MudTd><MudCheckBox @bind-Value="context.IsActive" Dense="true" /></MudTd>
<MudTd><MudIconButton Icon="@Icons.Material.Filled.Delete" Color="Color.Error" Size="Size.Small" OnClick="() => RemoveMapping(context)" /></MudTd>
</RowTemplate>
</MudTable>
</MudTabPanel>
</MudTabs>
}
@code {
private PurchasingDataSourceState _state = new();
private bool _loading = true;
private bool _busy;
protected override async Task OnInitializedAsync()
{
_state = await DataSourceService.LoadAsync();
_loading = false;
}
private async Task SaveAsync()
{
await RunAsync(async () =>
{
_state = await DataSourceService.SaveAsync(_state);
Snackbar.Add(T("Einkaufsdatenquellen gespeichert.", "Purchasing data sources saved."), Severity.Success);
});
}
private async Task ResetDefaultsAsync()
{
await RunAsync(async () =>
{
_state = await DataSourceService.ResetDefaultsAsync();
Snackbar.Add(T("Einkaufsdatenquellen auf Defaults gesetzt.", "Purchasing data sources restored to defaults."), Severity.Info);
});
}
private async Task TestConnectionAsync()
{
await RunAsync(async () =>
{
var result = await DataSourceService.TestConnectionAsync(_state);
Snackbar.Add(TranslateConnectionMessage(result.Message), result.Success ? Severity.Success : result.Warning ? Severity.Warning : Severity.Error);
});
}
private async Task RunAsync(Func<Task> action)
{
if (_busy)
return;
_busy = true;
try
{
await action();
}
catch (Exception ex)
{
Snackbar.Add(ex.Message, Severity.Error);
}
finally
{
_busy = false;
}
}
private void AddSource()
=> _state.Sources.Add(new SapSourceDefinition { Alias = "NEW", EntitySet = "NewEntitySet", IsActive = true, SortOrder = (_state.Sources.Count + 1) * 10 });
private void AddJoin()
=> _state.Joins.Add(new SapJoinDefinition { LeftAlias = "EKKO", RightAlias = "NEW", LeftKeys = "Key", RightKeys = "Key", JoinType = "Left", IsActive = true, SortOrder = (_state.Joins.Count + 1) * 10 });
private void AddMapping()
=> _state.Mappings.Add(new SapFieldMapping { TargetField = "NewField", SourceExpression = "Alias.Field", IsActive = true, SortOrder = (_state.Mappings.Count + 1) * 10 });
private void RemoveSource(SapSourceDefinition source) => _state.Sources.Remove(source);
private void RemoveJoin(SapJoinDefinition join) => _state.Joins.Remove(join);
private void RemoveMapping(SapFieldMapping mapping) => _state.Mappings.Remove(mapping);
private string T(string german, string english) => UiText.Text(german, english);
private static string Display(string? value) => string.IsNullOrWhiteSpace(value) ? "-" : value;
private string TranslateConnectionMessage(string message)
{
if (string.IsNullOrWhiteSpace(message))
return string.Empty;
if (message.Contains("Keine SAP Service URL gepflegt", StringComparison.OrdinalIgnoreCase))
return T("Keine SAP Service URL gepflegt.", "No SAP service URL maintained.");
if (message.Contains("Keine SAP Gateway Zugangsdaten gepflegt", StringComparison.OrdinalIgnoreCase))
return T("Keine SAP Gateway Zugangsdaten gepflegt.", "No SAP Gateway credentials maintained.");
if (message.Contains("SAP OData Verbindung erfolgreich", StringComparison.OrdinalIgnoreCase))
return T("SAP OData Verbindung erfolgreich.", "SAP OData connection successful.");
if (message.StartsWith("SAP OData Verbindung fehlgeschlagen", StringComparison.OrdinalIgnoreCase))
return message.Replace("SAP OData Verbindung fehlgeschlagen", T("SAP OData Verbindung fehlgeschlagen", "SAP OData connection failed"), StringComparison.OrdinalIgnoreCase);
return message;
}
}
<style>
.purchasing-muted {
color: var(--mud-palette-text-secondary);
}
.purchasing-flow {
min-height: 86px;
}
.purchasing-source-row {
display: grid;
grid-template-columns: 26px minmax(0, 1fr);
align-items: center;
gap: 8px;
padding: 7px 0;
border-bottom: 1px solid var(--mud-palette-lines-default);
}
.purchasing-source-row:last-child {
border-bottom: 0;
}
</style>
@@ -0,0 +1,223 @@
@inject TrafagSalesExporter.Services.IUiTextService UiText
@using TrafagSalesExporter.Models
<MudPaper Class="pa-3 purchasing-section-shell" Outlined="true">
<div class="purchasing-section-head">
<div>
<MudText Typo="Typo.h6">@T(TitleDe, TitleEn)</MudText>
<MudText Typo="Typo.body2" Class="purchasing-section-muted">@T(DescriptionDe, DescriptionEn)</MudText>
</div>
<MudChip T="string" Color="@ResolveSectionColor()" Variant="Variant.Outlined" Size="Size.Small">@ResolveSectionStatus()</MudChip>
</div>
<MudGrid Spacing="2" Class="mb-3">
@foreach (var kpi in Kpis)
{
<MudItem xs="12" sm="6" lg="3">
<MudPaper Class="pa-3 purchasing-section-kpi" Outlined="true">
<MudText Typo="Typo.caption" Class="purchasing-section-muted">@T(kpi.LabelDe, kpi.LabelEn)</MudText>
<MudText Typo="Typo.h6">@kpi.Value</MudText>
<MudText Typo="Typo.caption">@T(kpi.DetailDe, kpi.DetailEn)</MudText>
</MudPaper>
</MudItem>
}
</MudGrid>
<MudGrid Spacing="2" Class="mb-3">
<MudItem xs="12" lg="7">
<MudPaper Class="pa-3 purchasing-section-panel" Outlined="true">
<div class="purchasing-section-panel-head">
<MudText Typo="Typo.subtitle1">@T(ChartTitleDe, ChartTitleEn)</MudText>
<MudIcon Icon="@Icons.Material.Filled.StackedBarChart" Color="Color.Info" Size="Size.Small" />
</div>
<div class="purchasing-bars">
@foreach (var item in ChartRows)
{
<div class="purchasing-bar-row">
<div class="purchasing-bar-label">@item.Label</div>
<div class="purchasing-bar-track">
<div class="purchasing-bar-fill" style="@($"width:{BuildWidth(item.Percent)}%; background:{item.Color}")"></div>
</div>
<div class="purchasing-bar-value">@item.Value</div>
</div>
}
</div>
</MudPaper>
</MudItem>
<MudItem xs="12" lg="5">
<MudPaper Class="pa-3 purchasing-section-panel" Outlined="true">
<div class="purchasing-section-panel-head">
<MudText Typo="Typo.subtitle1">@T("Datenstatus", "Data status")</MudText>
<MudIcon Icon="@Icons.Material.Filled.Route" Color="Color.Info" Size="Size.Small" />
</div>
@foreach (var status in StatusRows)
{
<div class="purchasing-status-row">
<MudIcon Icon="@status.Icon" Color="@status.Color" Size="Size.Small" />
<span>@T(status.LabelDe, status.LabelEn)</span>
<strong>@status.Value</strong>
</div>
}
</MudPaper>
</MudItem>
</MudGrid>
<MudTable Items="@DetailRows" Dense="true" Hover="true" Striped="true">
<HeaderContent>
<MudTh>@T("Bereich", "Area")</MudTh>
<MudTh>@T("Wert", "Value")</MudTh>
<MudTh>@T("Dimension", "Dimension")</MudTh>
<MudTh>@T("Quelle", "Source")</MudTh>
</HeaderContent>
<RowTemplate>
<MudTd>@T(context.LabelDe, context.LabelEn)</MudTd>
<MudTd><strong>@context.Value</strong></MudTd>
<MudTd>@context.Dimension</MudTd>
<MudTd>
<MudChip T="string" Size="Size.Small" Variant="Variant.Outlined" Color="@ResolveSourceColor(context.Source)">
@context.Source
</MudChip>
</MudTd>
</RowTemplate>
</MudTable>
</MudPaper>
@code {
[Parameter, EditorRequired] public string TitleDe { get; set; } = string.Empty;
[Parameter, EditorRequired] public string TitleEn { get; set; } = string.Empty;
[Parameter, EditorRequired] public string DescriptionDe { get; set; } = string.Empty;
[Parameter, EditorRequired] public string DescriptionEn { get; set; } = string.Empty;
[Parameter, EditorRequired] public string ChartTitleDe { get; set; } = string.Empty;
[Parameter, EditorRequired] public string ChartTitleEn { get; set; } = string.Empty;
[Parameter, EditorRequired] public IReadOnlyList<PurchasingSectionKpi> Kpis { get; set; } = [];
[Parameter, EditorRequired] public IReadOnlyList<PurchasingSectionChartRow> ChartRows { get; set; } = [];
[Parameter, EditorRequired] public IReadOnlyList<PurchasingSectionStatusRow> StatusRows { get; set; } = [];
[Parameter, EditorRequired] public IReadOnlyList<PurchasingSectionDetailRow> DetailRows { get; set; } = [];
private string T(string german, string english) => UiText.Text(german, english);
private static string BuildWidth(double percent)
=> Math.Clamp(percent, 3d, 100d).ToString("0.##", System.Globalization.CultureInfo.InvariantCulture);
private static Color ResolveSourceColor(string source)
=> source.Equals("SAP live", StringComparison.OrdinalIgnoreCase)
? Color.Success
: source.Equals("Wartet auf SAP", StringComparison.OrdinalIgnoreCase)
? Color.Warning
: Color.Primary;
private Color ResolveSectionColor()
=> DetailRows.Any(row => row.Source.Equals("SAP live", StringComparison.OrdinalIgnoreCase))
? Color.Success
: DetailRows.Any(row => row.Source.Equals("Simulation", StringComparison.OrdinalIgnoreCase))
? Color.Info
: Color.Warning;
private string ResolveSectionStatus()
=> DetailRows.Any(row => row.Source.Equals("SAP live", StringComparison.OrdinalIgnoreCase))
? T("Live + Analyse", "Live + analysis")
: DetailRows.Any(row => row.Source.Equals("Simulation", StringComparison.OrdinalIgnoreCase))
? T("Simulation aktiv", "Simulation active")
: T("Wartet auf SAP", "Waiting for SAP");
}
<style>
.purchasing-section-muted {
color: var(--mud-palette-text-secondary);
}
.purchasing-section-shell {
border-radius: 8px;
background:
linear-gradient(180deg, rgba(21,101,192,.045), rgba(21,101,192,0) 210px),
var(--mud-palette-surface);
}
.purchasing-section-head,
.purchasing-section-panel-head {
display: flex;
align-items: start;
justify-content: space-between;
gap: 14px;
}
.purchasing-section-head {
margin-bottom: 14px;
}
.purchasing-section-panel-head {
align-items: center;
margin-bottom: 12px;
}
.purchasing-section-kpi {
min-height: 104px;
border-top: 4px solid var(--mud-palette-primary);
background: var(--mud-palette-surface);
}
.purchasing-section-panel {
min-height: 240px;
background: rgba(255,255,255,.02);
}
.purchasing-bars {
display: grid;
gap: 10px;
}
.purchasing-bar-row,
.purchasing-status-row {
display: grid;
grid-template-columns: minmax(120px, 1fr) 2fr auto;
gap: 10px;
align-items: center;
}
.purchasing-status-row {
grid-template-columns: 28px minmax(120px, 1fr) auto;
padding: 9px 0;
border-bottom: 1px solid var(--mud-palette-lines-default);
}
.purchasing-status-row:last-child {
border-bottom: 0;
}
.purchasing-bar-track {
height: 24px;
background: rgba(0,0,0,.08);
border-radius: 4px;
overflow: hidden;
}
.purchasing-bar-fill {
height: 100%;
border-radius: 4px;
min-width: 26px;
box-shadow: inset 0 -8px 16px rgba(0,0,0,.12);
}
.purchasing-bar-value {
font-weight: 700;
text-align: right;
}
.purchasing-bar-label {
min-width: 0;
overflow-wrap: anywhere;
line-height: 1.2;
}
@@media (max-width: 760px) {
.purchasing-bar-row,
.purchasing-status-row,
.purchasing-section-head {
grid-template-columns: 1fr;
}
.purchasing-section-head,
.purchasing-section-panel-head {
display: grid;
}
}
</style>
@@ -1,32 +1,36 @@
@page "/settings"
@rendermode @(Microsoft.AspNetCore.Components.Web.RenderMode.InteractiveServer)
@attribute [Authorize(Policy = TrafagSalesExporter.Security.SecurityPolicies.AdminOnly)]
@using TrafagSalesExporter.Models
@using TrafagSalesExporter.Services
@inject ISettingsPageService SettingsPageActions
@inject IJSRuntime JS
@inject ISnackbar Snackbar
@inject IUiTextService UiText
<PageTitle>Settings</PageTitle>
<PageTitle>@T("Einstellungen", "Settings")</PageTitle>
<MudText Typo="Typo.h4" Class="mb-4">Settings</MudText>
<MudText Typo="Typo.h4" Class="mb-4">@T("Einstellungen", "Settings")</MudText>
<MudText Typo="Typo.h5" Class="mb-2">Konfiguration Import/Export</MudText>
<MudText Typo="Typo.h5" Class="mb-2">@T("Konfiguration Import/Export", "Import/export configuration")</MudText>
<MudPaper Class="pa-4 mb-6" Elevation="1">
<MudGrid>
<MudItem xs="12" md="6">
<MudCheckBox @bind-Value="_includeSecretsInExport" Label="Mit Secrets exportieren" />
<MudCheckBox @bind-Value="_includeSecretsInExport" Label="@T("Mit Secrets exportieren", "Export with secrets")" />
<MudText Typo="Typo.caption">
Wenn deaktiviert, bleiben Passwörter und Secrets beim Export leer. Beim Import ohne Secrets werden bestehende Secrets auf dem Zielsystem beibehalten.
@T("Wenn deaktiviert, bleiben Passwoerter und Secrets beim Export leer. Beim Import ohne Secrets werden bestehende Secrets auf dem Zielsystem beibehalten.",
"If disabled, passwords and secrets remain empty during export. When importing without secrets, existing secrets on the target system are kept.")
</MudText>
</MudItem>
<MudItem xs="12" md="6">
<MudStack Row Spacing="2">
<MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="ExportConfiguration"
StartIcon="@Icons.Material.Filled.Download" Disabled="_exportingConfig">
@(_exportingConfig ? "Exportiere..." : "Konfiguration exportieren")
@(_exportingConfig ? T("Exportiere...", "Exporting...") : T("Konfiguration exportieren", "Export configuration"))
</MudButton>
<MudButton Variant="Variant.Outlined" Color="Color.Warning" HtmlTag="label"
StartIcon="@Icons.Material.Filled.UploadFile" Disabled="_importingConfig">
@(_importingConfig ? "Importiere..." : "Konfiguration importieren")
@(_importingConfig ? T("Importiere...", "Importing...") : T("Konfiguration importieren", "Import configuration"))
<InputFile OnChange="ImportConfiguration" accept=".json,application/json" style="display:none" />
</MudButton>
</MudStack>
@@ -35,7 +39,7 @@
</MudPaper>
@* SharePoint Config *@
<MudText Typo="Typo.h5" Class="mb-2">SharePoint Konfiguration</MudText>
<MudText Typo="Typo.h5" Class="mb-2">@T("SharePoint Konfiguration", "SharePoint configuration")</MudText>
<MudPaper Class="pa-4 mb-6" Elevation="1">
<MudGrid>
<MudItem xs="12" md="6">
@@ -47,7 +51,7 @@
<MudItem xs="12" md="6">
<MudTextField @bind-Value="_spConfig.CentralExportFolder"
Label="Central Export Folder"
HelperText="Optional. Wenn leer, wird weiterhin Export Folder/Alle verwendet." />
HelperText="@T("Optional. Wenn leer, wird weiterhin Export Folder/Alle verwendet.", "Optional. If empty, Export Folder/All is still used.")" />
</MudItem>
<MudItem xs="12" md="4">
<MudTextField @bind-Value="_spConfig.TenantId" Label="Tenant ID" />
@@ -62,18 +66,18 @@
<MudStack Row Spacing="2">
<MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="SaveSharePoint"
StartIcon="@Icons.Material.Filled.Save">
Speichern
@T("Speichern", "Save")
</MudButton>
<MudButton Variant="Variant.Outlined" Color="Color.Info" OnClick="TestSharePoint"
StartIcon="@Icons.Material.Filled.NetworkCheck" Disabled="_testingSp">
@if (_testingSp)
{
<MudProgressCircular Size="Size.Small" Indeterminate Class="mr-2" />
@("Teste...")
@T("Teste...", "Testing...")
}
else
{
@("SharePoint Verbindung testen")
@T("SharePoint Verbindung testen", "Test SharePoint connection")
}
</MudButton>
</MudStack>
@@ -82,7 +86,7 @@
{
<MudItem xs="12">
<MudAlert Severity="Severity.Info" Dense="true" Variant="Variant.Outlined" Class="mt-3">
<div><b>Test Preview</b></div>
<div><b>@T("Testvorschau", "Test preview")</b></div>
<div style="white-space: pre-wrap">@_sharePointTestPreview</div>
</MudAlert>
</MudItem>
@@ -90,28 +94,29 @@
</MudGrid>
</MudPaper>
<MudText Typo="Typo.h5" Class="mb-2">Quellsysteme</MudText>
<MudText Typo="Typo.h5" Class="mb-2">@T("Quellsysteme", "Source systems")</MudText>
<MudPaper Class="pa-4 mb-6" Elevation="1">
<MudGrid>
<MudItem xs="12">
<MudAlert Severity="Severity.Info" Dense="true" Variant="Variant.Outlined">
Diese Zugangsdaten werden pro Quellsystem als Standard verwendet. Ein Standort kann sie bei Bedarf mit eigenen Overrides überschreiben.
@T("Diese Zugangsdaten werden pro Quellsystem als Standard verwendet. Ein Standort kann sie bei Bedarf mit eigenen Overrides ueberschreiben.",
"These credentials are used as defaults per source system. A site can override them if needed.")
</MudAlert>
</MudItem>
<MudItem xs="12">
<MudButton Variant="Variant.Outlined" Color="Color.Primary" OnClick="AddSourceSystem"
StartIcon="@Icons.Material.Filled.Add" Class="mb-3">
Quellsystem hinzufuegen
@T("Quellsystem hinzufuegen", "Add source system")
</MudButton>
<MudTable Items="_sourceSystems" Dense Hover Striped Breakpoint="Breakpoint.Md">
<HeaderContent>
<MudTh>Code</MudTh>
<MudTh>Name</MudTh>
<MudTh>Anschlussart</MudTh>
<MudTh>Zentrale URL</MudTh>
<MudTh>@T("Name", "Name")</MudTh>
<MudTh>@T("Anschlussart", "Connection type")</MudTh>
<MudTh>@T("Zentrale URL", "Central URL")</MudTh>
<MudTh>User</MudTh>
<MudTh>Aktiv</MudTh>
<MudTh>Test</MudTh>
<MudTh>@T("Aktiv", "Active")</MudTh>
<MudTh>@T("Test", "Test")</MudTh>
<MudTh></MudTh>
</HeaderContent>
<RowTemplate>
@@ -136,7 +141,7 @@
<MudButton Variant="Variant.Outlined" Color="Color.Info" Size="Size.Small"
OnClick='@(() => TestCentralCredentials(context.Code))'
Disabled='@_testingSystems.Contains(context.Code)'>
@(_testingSystems.Contains(context.Code) ? "Teste..." : "Testen")
@(_testingSystems.Contains(context.Code) ? T("Teste...", "Testing...") : T("Testen", "Test"))
</MudButton>
}
</MudTd>
@@ -152,7 +157,7 @@
<MudItem xs="12">
<MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="SaveSourceSystems"
StartIcon="@Icons.Material.Filled.Save">
Quellsysteme speichern
@T("Quellsysteme speichern", "Save source systems")
</MudButton>
</MudItem>
</MudGrid>
@@ -160,12 +165,12 @@
<MudDialog @bind-Visible="_sourceSystemDialogVisible" Options="_sourceSystemDialogOptions">
<TitleContent>
<MudText Typo="Typo.h6">@(_editingSourceSystem.Id == 0 ? "Quellsystem hinzufuegen" : "Quellsystem bearbeiten")</MudText>
<MudText Typo="Typo.h6">@(_editingSourceSystem.Id == 0 ? T("Quellsystem hinzufuegen", "Add source system") : T("Quellsystem bearbeiten", "Edit source system"))</MudText>
</TitleContent>
<DialogContent>
<MudTextField @bind-Value="_editingSourceSystem.Code" Label="Code" Required />
<MudTextField @bind-Value="_editingSourceSystem.DisplayName" Label="Name" Required />
<MudSelect T="string" @bind-Value="_editingSourceSystem.ConnectionKind" Label="Anschlussart" Required>
<MudTextField @bind-Value="_editingSourceSystem.DisplayName" Label="@T("Name", "Name")" Required />
<MudSelect T="string" @bind-Value="_editingSourceSystem.ConnectionKind" Label="@T("Anschlussart", "Connection type")" Required>
@foreach (var kind in SourceSystemConnectionKinds.All)
{
<MudSelectItem Value="@kind">@GetConnectionKindLabel(kind)</MudSelectItem>
@@ -174,46 +179,47 @@
@if (UsesSapGateway(_editingSourceSystem))
{
<MudTextField @bind-Value="_editingSourceSystem.CentralServiceUrl" Label="Zentrale SAP Service URL"
HelperText="Zentrale Standard-URL fuer SAP Gateway. Ein Standort darf sie nur bei Bedarf ueberschreiben." />
HelperText="@T("Zentrale Standard-URL fuer SAP Gateway. Ein Standort darf sie nur bei Bedarf ueberschreiben.", "Central default URL for SAP Gateway. A site may override it only if needed.")" />
}
<MudTextField @bind-Value="_editingSourceSystem.CentralUsername" Label="Zentraler Username" />
<MudTextField @bind-Value="_editingSourceSystem.CentralPassword" Label="Zentrales Passwort" InputType="InputType.Password" />
<MudCheckBox @bind-Value="_editingSourceSystem.IsActive" Label="Aktiv" />
<MudTextField @bind-Value="_editingSourceSystem.CentralUsername" Label="@T("Zentraler Username", "Central username")" />
<MudTextField @bind-Value="_editingSourceSystem.CentralPassword" Label="@T("Zentrales Passwort", "Central password")" InputType="InputType.Password" />
<MudCheckBox @bind-Value="_editingSourceSystem.IsActive" Label="@T("Aktiv", "Active")" />
</DialogContent>
<DialogActions>
<MudButton OnClick="CloseSourceSystemDialog">Abbrechen</MudButton>
<MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="SaveSourceSystemEdit">Uebernehmen</MudButton>
<MudButton OnClick="CloseSourceSystemDialog">@T("Abbrechen", "Cancel")</MudButton>
<MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="SaveSourceSystemEdit">@T("Uebernehmen", "Apply")</MudButton>
</DialogActions>
</MudDialog>
<MudText Typo="Typo.h5" Class="mb-2">Wechselkurse</MudText>
<MudText Typo="Typo.h5" Class="mb-2">@T("Wechselkurse", "Exchange rates")</MudText>
<MudPaper Class="pa-4 mb-6" Elevation="1">
<MudText Typo="Typo.body2" Class="mb-3">
Diese Kurstabelle wird von der Transformation <b>ConvertCurrency</b> verwendet. Gleiche Waehrung rechnet automatisch mit Faktor 1.
@((MarkupString)T("Diese Kurstabelle wird von der Transformation <b>ConvertCurrency</b> verwendet. Gleiche Waehrung rechnet automatisch mit Faktor 1.",
"This rate table is used by the <b>ConvertCurrency</b> transformation. Same-currency conversion automatically uses factor 1."))
</MudText>
<MudStack Row Spacing="2" Class="mb-3">
<MudButton Variant="Variant.Outlined" Color="Color.Primary" OnClick="AddExchangeRate"
StartIcon="@Icons.Material.Filled.Add">
Kurs hinzufuegen
@T("Kurs hinzufuegen", "Add rate")
</MudButton>
<MudButton Variant="Variant.Outlined" Color="Color.Info" OnClick="RefreshEcbRates"
StartIcon="@Icons.Material.Filled.Refresh" Disabled="_refreshingExchangeRates">
@(_refreshingExchangeRates ? "Aktualisiere ECB-Kurse..." : "Refresh Kurse")
@(_refreshingExchangeRates ? T("Aktualisiere ECB-Kurse...", "Refreshing ECB rates...") : T("Refresh Kurse", "Refresh rates"))
</MudButton>
<MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="SaveExchangeRates"
StartIcon="@Icons.Material.Filled.Save">
Kurse speichern
@T("Kurse speichern", "Save rates")
</MudButton>
</MudStack>
<MudTable Items="_exchangeRates" Hover="true" Breakpoint="Breakpoint.Md">
<HeaderContent>
<MudTh>Von</MudTh>
<MudTh>Nach</MudTh>
<MudTh>Kurs</MudTh>
<MudTh>Gueltig ab</MudTh>
<MudTh>Gueltig bis</MudTh>
<MudTh>Notiz</MudTh>
<MudTh>Aktiv</MudTh>
<MudTh>@T("Von", "From")</MudTh>
<MudTh>@T("Nach", "To")</MudTh>
<MudTh>@T("Kurs", "Rate")</MudTh>
<MudTh>@T("Gueltig ab", "Valid from")</MudTh>
<MudTh>@T("Gueltig bis", "Valid to")</MudTh>
<MudTh>@T("Notiz", "Note")</MudTh>
<MudTh>@T("Aktiv", "Active")</MudTh>
<MudTh></MudTh>
</HeaderContent>
<RowTemplate>
@@ -251,57 +257,121 @@
</MudPaper>
@* Export Settings *@
<MudText Typo="Typo.h5" Class="mb-2">Export Einstellungen</MudText>
<MudText Typo="Typo.h5" Class="mb-2">@T("Export Einstellungen", "Export settings")</MudText>
<MudPaper Class="pa-4 mb-6" Elevation="1">
<MudGrid>
<MudItem xs="12" md="4">
<MudTextField @bind-Value="_exportSettings.DateFilter" Label="Datum-Filter (ab)"
HelperText="Format: yyyy-MM-dd" />
<MudTextField @bind-Value="_exportSettings.DateFilter" Label="@T("Datum-Filter (ab)", "Date filter from")"
HelperText="@T("Format: yyyy-MM-dd", "Format: yyyy-MM-dd")" />
</MudItem>
<MudItem xs="12" md="2">
<MudNumericField @bind-Value="_exportSettings.TimerHour" Label="Timer Stunde" Min="0" Max="23" />
<MudNumericField @bind-Value="_exportSettings.TimerHour" Label="@T("Timer Stunde", "Timer hour")" Min="0" Max="23" />
</MudItem>
<MudItem xs="12" md="2">
<MudNumericField @bind-Value="_exportSettings.TimerMinute" Label="Timer Minute" Min="0" Max="59" />
<MudNumericField @bind-Value="_exportSettings.TimerMinute" Label="@T("Timer Minute", "Timer minute")" Min="0" Max="59" />
</MudItem>
<MudItem xs="12" md="4">
<MudSwitch @bind-Value="_exportSettings.TimerEnabled" Label="Timer aktiviert" Color="Color.Primary" />
<MudSwitch @bind-Value="_exportSettings.TimerEnabled" Label="@T("Timer aktiviert", "Timer enabled")" Color="Color.Primary" />
</MudItem>
<MudItem xs="12" md="4">
<MudSwitch @bind-Value="_exportSettings.DebugLoggingEnabled" Label="Debug Live-Logging" Color="Color.Warning" />
<MudSelect T="string" @bind-Value="_exportSettings.ExchangeRateDateField"
Label="@T("Wechselkurse anwenden auf", "Apply exchange rates to")"
HelperText="@T("Datumsfeld fuer Kursgueltigkeit in Management-Analysen.", "Date field for rate validity in management analyses.")">
<MudSelectItem Value="@ExchangeRateDateFields.PostingDate">@T("PostingDate / Buchungsdatum", "PostingDate / posting date")</MudSelectItem>
<MudSelectItem Value="@ExchangeRateDateFields.InvoiceDate">@T("InvoiceDate / Rechnungsdatum", "InvoiceDate / invoice date")</MudSelectItem>
<MudSelectItem Value="@ExchangeRateDateFields.ExtractionDate">@T("ExtractionDate / Extraktionsdatum", "ExtractionDate / extraction date")</MudSelectItem>
</MudSelect>
</MudItem>
<MudItem xs="12" md="4">
<MudSwitch @bind-Value="_exportSettings.DebugLoggingEnabled" Label="@T("Debug Live-Logging", "Debug live logging")" Color="Color.Warning" />
<MudText Typo="Typo.caption">
Schreibt zusätzliche technische Fortschrittsmeldungen für HANA- und SAP-Lesevorgänge ins Dashboard und in die Logs.
@T("Schreibt zusaetzliche technische Fortschrittsmeldungen fuer HANA- und SAP-Lesevorgaenge ins Dashboard und in die Logs.",
"Writes additional technical progress messages for HANA and SAP reads to the dashboard and logs.")
</MudText>
</MudItem>
<MudItem xs="12" md="6">
<MudTextField @bind-Value="_exportSettings.LocalSiteExportFolder" Label="Lokaler Standardpfad Standort-Dateien"
HelperText="Wenn leer, wird ./output unter dem Programmverzeichnis verwendet." />
<MudTextField @bind-Value="_exportSettings.LocalSiteExportFolder" Label="@T("Lokaler Standardpfad Standort-Dateien", "Local default path for site files")"
HelperText="@T("Wenn leer, wird ./output unter dem Programmverzeichnis verwendet.", "If empty, ./output under the application directory is used.")" />
</MudItem>
<MudItem xs="12" md="6">
<MudTextField @bind-Value="_exportSettings.LocalConsolidatedExportFolder" Label="Lokaler Pfad Zentrale Datei"
HelperText="Optional. Wenn leer, wird der Standardpfad der Standort-Dateien verwendet." />
<MudTextField @bind-Value="_exportSettings.LocalConsolidatedExportFolder" Label="@T("Lokaler Pfad Zentrale Datei", "Local path for central file")"
HelperText="@T("Optional. Wenn leer, wird der Standardpfad der Standort-Dateien verwendet.", "Optional. If empty, the default path for site files is used.")" />
</MudItem>
<MudItem xs="12">
<div class="audit-csv-settings">
<div class="audit-csv-header">
<MudIcon Icon="@Icons.Material.Filled.RuleFolder" Color="Color.Info" Size="Size.Medium" />
<div>
<MudText Typo="Typo.h6">@T("Audit-CSV / nachvollziehbarer Datenfluss", "Audit CSV / traceable data flow")</MudText>
<MudText Typo="Typo.body2">
@T("Fuer Finance und Wirtschaftspruefung: lesbare Standort-CSV nach Mapping und Konvertierung, optional als Quelle fuer zentrale Auswertungen.",
"For finance and auditors: readable site CSV after mapping and conversion, optionally as the source for central analyses.")
</MudText>
</div>
</div>
<MudGrid Spacing="2">
<MudItem xs="12" md="4">
<MudSwitch @bind-Value="_exportSettings.AuditCsvEnabled" Label="@T("Audit-CSV je Standort schreiben", "Write audit CSV per site")" Color="Color.Primary" />
<MudText Typo="Typo.caption">
@T("Schreibt beim Laenderexport je Standort eine Sales_ProcessedMergeInput_*.csv mit den transformierten Daten.",
"Writes one Sales_ProcessedMergeInput_*.csv per site during country export with the transformed data.")
</MudText>
</MudItem>
<MudItem xs="12" md="4">
<MudSwitch @bind-Value="_exportSettings.UseAuditCsvAsCentralSource" Label="@T("Zentrale Auswertung aus Audit-CSV", "Central analysis from audit CSV")" Color="Color.Warning" />
<MudText Typo="Typo.caption">
@T("Dashboard, zentrale Excel-Datei und Finance-Auswertungen lesen die neuesten Standort-CSV-Dateien statt CentralSalesRecords.",
"Dashboard, central Excel file and finance analyses read the latest site CSV files instead of CentralSalesRecords.")
</MudText>
</MudItem>
<MudItem xs="12" md="4">
<MudAlert Severity="Severity.Info" Dense="true" Variant="Variant.Filled">
@((MarkupString)T("Audit-CSV wird immer im gleichen Ordner wie die lokalen Standort-Dateien abgelegt. Der Pfad wird oben bei <b>Lokaler Standardpfad Standort-Dateien</b> gesetzt.",
"Audit CSV is always stored in the same folder as the local site files. The path is set above under <b>Local default path for site files</b>."))
</MudAlert>
</MudItem>
</MudGrid>
</div>
</MudItem>
<MudItem xs="12">
<MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="SaveExportSettings"
StartIcon="@Icons.Material.Filled.Save">
Speichern
@T("Speichern", "Save")
</MudButton>
</MudItem>
</MudGrid>
</MudPaper>
@* Filename Preview *@
<MudText Typo="Typo.h5" Class="mb-2">Dateiname Vorschau</MudText>
<MudText Typo="Typo.h5" Class="mb-2">@T("Dateiname Vorschau", "Filename preview")</MudText>
<MudPaper Class="pa-4" Elevation="1">
<MudText Typo="Typo.body1">
<MudIcon Icon="@Icons.Material.Filled.InsertDriveFile" Size="Size.Small" Class="mr-1" />
Sales_{"{TSC}"}_{DateTime.Now:yyyy-MM-dd}.xlsx
</MudText>
<MudText Typo="Typo.caption" Class="mt-1">
Beispiel: Sales_TRFR_@(DateTime.Now.ToString("yyyy-MM-dd")).xlsx
@T("Beispiel:", "Example:") Sales_TRFR_@(DateTime.Now.ToString("yyyy-MM-dd")).xlsx
/ Sales_ProcessedMergeInput_TRFR_@(DateTime.Now.ToString("yyyy-MM-dd")).csv
</MudText>
</MudPaper>
<style>
.audit-csv-settings {
background: #e8f3ff;
border: 1px solid #90caf9;
border-left: 6px solid #1976d2;
border-radius: 8px;
padding: 18px 20px;
}
.audit-csv-header {
display: flex;
align-items: flex-start;
gap: 12px;
margin-bottom: 14px;
}
</style>
@code {
private SharePointConfig _spConfig = new();
private ExportSettings _exportSettings = new();
@@ -330,7 +400,7 @@
private async Task SaveSharePoint()
{
await SettingsPageActions.SaveSharePointAsync(_spConfig);
Snackbar.Add("SharePoint Konfiguration gespeichert", Severity.Success);
Snackbar.Add(T("SharePoint Konfiguration gespeichert", "SharePoint configuration saved"), Severity.Success);
}
private async Task TestSharePoint()
@@ -339,11 +409,11 @@
try
{
_sharePointTestPreview = await SettingsPageActions.BuildSharePointTestPreviewAsync(_spConfig);
Snackbar.Add("SharePoint Verbindung erfolgreich!", Severity.Success);
Snackbar.Add(T("SharePoint Verbindung erfolgreich!", "SharePoint connection successful!"), Severity.Success);
}
catch (Exception ex)
{
Snackbar.Add($"Verbindung fehlgeschlagen: {ex.Message}", Severity.Error);
Snackbar.Add($"{T("Verbindung fehlgeschlagen", "Connection failed")}: {ex.Message}", Severity.Error);
}
finally
{
@@ -354,7 +424,7 @@
private async Task SaveExportSettings()
{
await SettingsPageActions.SaveExportSettingsAsync(_exportSettings);
Snackbar.Add("Export Einstellungen gespeichert", Severity.Success);
Snackbar.Add(T("Export Einstellungen gespeichert", "Export settings saved"), Severity.Success);
}
private void AddSourceSystem()
@@ -396,13 +466,13 @@
if (string.IsNullOrWhiteSpace(_editingSourceSystem.Code) || string.IsNullOrWhiteSpace(_editingSourceSystem.DisplayName))
{
Snackbar.Add("Code und Name fuer das Quellsystem sind Pflicht.", Severity.Warning);
Snackbar.Add(T("Code und Name fuer das Quellsystem sind Pflicht.", "Code and name are required for the source system."), Severity.Warning);
return;
}
if (_sourceSystems.Any(x => x.Id != _editingSourceSystem.Id && x.Code == _editingSourceSystem.Code))
{
Snackbar.Add($"Quellsystem-Code doppelt vorhanden: {_editingSourceSystem.Code}", Severity.Warning);
Snackbar.Add($"{T("Quellsystem-Code doppelt vorhanden", "Duplicate source-system code")}: {_editingSourceSystem.Code}", Severity.Warning);
return;
}
@@ -444,7 +514,7 @@
try
{
_sourceSystems = await SettingsPageActions.SaveSourceSystemsAsync(_sourceSystems);
Snackbar.Add("Quellsysteme gespeichert", Severity.Success);
Snackbar.Add(T("Quellsysteme gespeichert", "Source systems saved"), Severity.Success);
}
catch (Exception ex)
{
@@ -472,7 +542,7 @@
private async Task SaveExchangeRates()
{
_exchangeRates = await SettingsPageActions.SaveExchangeRatesAsync(_exchangeRates);
Snackbar.Add("Wechselkurse gespeichert", Severity.Success);
Snackbar.Add(T("Wechselkurse gespeichert", "Exchange rates saved"), Severity.Success);
}
private async Task RefreshEcbRates()
@@ -485,11 +555,11 @@
{
var result = await SettingsPageActions.RefreshEcbRatesAsync();
_exchangeRates = result.ExchangeRates;
Snackbar.Add($"ECB-Kurse aktualisiert: {result.ImportedCount} Kurse vom {result.RateDate:yyyy-MM-dd}.", Severity.Success);
Snackbar.Add($"{T("ECB-Kurse aktualisiert", "ECB rates refreshed")}: {result.ImportedCount} {T("Kurse vom", "rates from")} {result.RateDate:yyyy-MM-dd}.", Severity.Success);
}
catch (Exception ex)
{
Snackbar.Add($"ECB-Kursimport fehlgeschlagen: {ex.Message}", Severity.Error);
Snackbar.Add($"{T("ECB-Kursimport fehlgeschlagen", "ECB rate import failed")}: {ex.Message}", Severity.Error);
}
finally
{
@@ -509,11 +579,11 @@
var suffix = _includeSecretsInExport ? "with-secrets" : "without-secrets";
var fileName = $"trafag-config-{DateTime.UtcNow:yyyyMMdd-HHmmss}-{suffix}.json";
await JS.InvokeVoidAsync("trafagDownload.saveTextFile", fileName, json, "application/json;charset=utf-8");
Snackbar.Add("Konfiguration exportiert", Severity.Success);
Snackbar.Add(T("Konfiguration exportiert", "Configuration exported"), Severity.Success);
}
catch (Exception ex)
{
Snackbar.Add($"Export fehlgeschlagen: {ex.Message}", Severity.Error);
Snackbar.Add($"{T("Export fehlgeschlagen", "Export failed")}: {ex.Message}", Severity.Error);
}
finally
{
@@ -538,11 +608,11 @@
_exportSettings = state.ExportSettings;
_sourceSystems = state.SourceSystems;
_exchangeRates = state.ExchangeRates;
Snackbar.Add("Konfiguration importiert", Severity.Success);
Snackbar.Add(T("Konfiguration importiert", "Configuration imported"), Severity.Success);
}
catch (Exception ex)
{
Snackbar.Add($"Import fehlgeschlagen: {ex.Message}", Severity.Error);
Snackbar.Add($"{T("Import fehlgeschlagen", "Import failed")}: {ex.Message}", Severity.Error);
}
finally
{
@@ -555,7 +625,7 @@
var definition = _sourceSystems.FirstOrDefault(x => string.Equals(x.Code, sourceSystem, StringComparison.OrdinalIgnoreCase));
if (definition is null)
{
Snackbar.Add($"Quellsystem '{sourceSystem}' nicht gefunden.", Severity.Warning);
Snackbar.Add($"{T("Quellsystem nicht gefunden", "Source system not found")}: {sourceSystem}", Severity.Warning);
return;
}
@@ -598,5 +668,7 @@
=> string.IsNullOrWhiteSpace(definition.CentralUsername) ? "-" : definition.CentralUsername;
private static string NormalizeConfigValue(string? value) => Services.SettingsPageService.NormalizeConfigValue(value);
private string T(string german, string english) => UiText.Text(german, english);
}
@@ -1,4 +1,5 @@
@page "/source-viewer"
@rendermode @(Microsoft.AspNetCore.Components.Web.RenderMode.InteractiveServer)
@using Microsoft.AspNetCore.Components
@using Microsoft.AspNetCore.WebUtilities
@inject IWebHostEnvironment Environment
@@ -1,4 +1,6 @@
@page "/standorte"
@rendermode @(Microsoft.AspNetCore.Components.Web.RenderMode.InteractiveServer)
@attribute [Authorize(Policy = TrafagSalesExporter.Security.SecurityPolicies.AdminOnly)]
@using Microsoft.AspNetCore.Components.Forms
@using System.Text.Json
@using System.Reflection
@@ -84,8 +86,22 @@
<MudTd>@context.Land</MudTd>
<MudTd>@context.TSC</MudTd>
<MudTd>@context.Schema</MudTd>
<MudTd>@context.SourceSystem</MudTd>
<MudTd>@GetConnectionTarget(context)</MudTd>
<MudTd>
<MudTooltip Text="@GetConnectionKindTooltip(context)">
<MudChip T="string" Size="Size.Small" Variant="Variant.Outlined" Color="@GetConnectionKindColor(context)"
Icon="@GetConnectionKindIcon(context)">
@context.SourceSystem
</MudChip>
</MudTooltip>
</MudTd>
<MudTd>
<MudStack Row AlignItems="AlignItems.Center" Spacing="1">
<MudTooltip Text="@GetConnectionKindTooltip(context)">
<MudIcon Icon="@GetConnectionKindIcon(context)" Color="@GetConnectionKindColor(context)" Size="Size.Small" />
</MudTooltip>
<span>@GetConnectionTarget(context)</span>
</MudStack>
</MudTd>
<MudTd>
@if (context.IsActive)
{
@@ -190,15 +206,22 @@
<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">
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>
<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." />
@if (IsSapSite())
{
<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">
<MudButton Variant="Variant.Outlined" Color="Color.Info" OnClick="RefreshSapEntitySets"
StartIcon="@Icons.Material.Filled.Refresh" Disabled="_refreshingSapEntitySets">
@@ -209,7 +232,7 @@
}
else
{
@("Quellen refreshen")
@(IsSapSite() ? "Entity Sets refreshen" : "Tabellen/Views refreshen")
}
</MudButton>
@if (_editingSite.SapEntitySetsRefreshedAtUtc.HasValue)
@@ -221,16 +244,16 @@
</MudStack>
<MudDivider Class="my-4" />
<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>
</MudStack>
<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>
<MudTable Items="_sapSources" Dense Hover Striped>
<HeaderContent>
<MudTh>Alias</MudTh>
<MudTh>Entity Set</MudTh>
<MudTh>@(IsSapSite() ? "Entity Set" : "Tabelle/View")</MudTh>
<MudTh>Primär</MudTh>
<MudTh>Aktiv</MudTh>
<MudTh>Aktionen</MudTh>
@@ -343,7 +366,7 @@
</MudStack>
</MudStack>
<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>
<MudTable Items="_sapMappings" Dense Hover Striped>
<HeaderContent>
@@ -378,18 +401,18 @@
}
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">
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>
<MudTextField @bind-Value="_editingSite.ManualImportFilePath" Label="Excel-Dateipfad"
HelperText="Unterstuetzt lokale Pfade, UNC-Pfade und SharePoint-Referenzen wie https://... oder Shared Documents/Ordner/Datei.xlsx."
<MudTextField @bind-Value="_editingSite.ManualImportFilePath" Label="Excel-/CSV-Dateipfad"
HelperText="Unterstuetzt lokale Pfade, UNC-Pfade, SharePoint-Dateien und SharePoint-Ordner. Bei Ordnern wird die neueste passende Excel-/CSV-Datei geladen."
Class="mb-2" />
<MudButton Variant="Variant.Outlined" Color="Color.Info" OnClick="ValidateManualImportPathAsync"
Disabled="_uploadingManualImport" Class="mb-3">
Pfad pruefen
</MudButton>
<InputFile OnChange="UploadManualImportFileAsync" accept=".xlsx" />
<InputFile OnChange="UploadManualImportFileAsync" accept=".xlsx,.csv" />
@if (_uploadingManualImport)
{
<MudText Typo="Typo.caption" Class="mt-2">Datei wird hochgeladen...</MudText>
@@ -407,6 +430,63 @@
{
<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
{
@@ -421,8 +501,8 @@
}
</DialogContent>
<DialogActions>
<MudButton OnClick="CloseSiteDialog" Disabled="_savingSite || _uploadingManualImport">Abbrechen</MudButton>
<MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="SaveSite" Disabled="_savingSite || _refreshingSapEntitySets || _uploadingManualImport">Speichern</MudButton>
<MudButton OnClick="CloseSiteDialog" Disabled="_savingSite || _uploadingManualImport || _loadingManualExcelHeaders">Abbrechen</MudButton>
<MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="SaveSite" Disabled="_savingSite || _refreshingSapEntitySets || _uploadingManualImport || _loadingManualExcelHeaders">Speichern</MudButton>
</DialogActions>
</MudDialog>
@@ -438,6 +518,8 @@
private List<SapSourceDefinition> _sapSources = [];
private List<SapJoinDefinition> _sapJoins = [];
private List<SapFieldMapping> _sapMappings = [];
private List<ManualExcelColumnMapping> _manualExcelMappings = [];
private List<string> _manualExcelHeaders = [];
private readonly string[] _salesRecordFields = typeof(SalesRecord)
.GetProperties(BindingFlags.Public | BindingFlags.Instance)
.Select(p => p.Name)
@@ -452,6 +534,7 @@
private bool _savingSite;
private bool _loadingSchemas;
private bool _uploadingManualImport;
private bool _loadingManualExcelHeaders;
private readonly DialogOptions _dialogOptions = new() { MaxWidth = MaxWidth.Small, FullWidth = true };
protected override async Task OnInitializedAsync()
@@ -564,6 +647,8 @@
_sapSources = [];
_sapJoins = [];
_sapMappings = [];
_manualExcelMappings = [];
_manualExcelHeaders = [];
_siteDialogVisible = true;
}
@@ -581,6 +666,8 @@
_sapSources = editorState.SapSources;
_sapJoins = editorState.SapJoins;
_sapMappings = editorState.SapMappings;
_manualExcelMappings = editorState.ManualExcelMappings;
_manualExcelHeaders = BuildHeadersFromManualExcelMappings();
_sapAvailableSourceExpressions = BuildSourceExpressionsFromMappings();
_sapSourceFieldMap = BuildSourceFieldMapFromJoins();
_siteDialogVisible = true;
@@ -594,7 +681,7 @@
_savingSite = true;
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;
await LoadDataAsync();
Snackbar.Add("Standort gespeichert", Severity.Success);
@@ -618,9 +705,16 @@
if (result != true) return;
await StandortePageService.DeleteSiteAsync(site);
await LoadDataAsync();
Snackbar.Add("Standort gelöscht", Severity.Info);
try
{
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)
@@ -687,11 +781,17 @@
private bool IsSapSite()
=> string.Equals(GetSourceSystemConnectionKind(_editingSite.SourceSystem), SourceSystemConnectionKinds.SapGateway, StringComparison.OrdinalIgnoreCase);
private bool IsMappedSourceSite()
=> IsSapSite() || UsesHanaConnection();
private bool IsManualExcelSite()
=> string.Equals(GetSourceSystemConnectionKind(_editingSite.SourceSystem), SourceSystemConnectionKinds.ManualExcel, StringComparison.OrdinalIgnoreCase);
private bool UsesHanaConnection() => IsHanaSourceSystem(_editingSite.SourceSystem);
private string GetMappingSectionTitle()
=> IsSapSite() ? "SAP OData Mapping" : "HANA Quellen und Feldmapping";
private string GetSourceSystemLabel(SourceSystemDefinition definition)
=> string.IsNullOrWhiteSpace(definition.DisplayName) ? definition.Code : $"{definition.DisplayName} ({definition.Code})";
@@ -706,6 +806,39 @@
return GetServerNode(site.HanaServer);
}
private string GetConnectionKindIcon(Site site)
{
var connectionKind = GetSourceSystemConnectionKind(site.SourceSystem);
if (string.Equals(connectionKind, SourceSystemConnectionKinds.ManualExcel, StringComparison.OrdinalIgnoreCase))
return Icons.Material.Filled.UploadFile;
if (string.Equals(connectionKind, SourceSystemConnectionKinds.SapGateway, StringComparison.OrdinalIgnoreCase))
return Icons.Material.Filled.CloudSync;
return Icons.Material.Filled.Storage;
}
private Color GetConnectionKindColor(Site site)
{
var connectionKind = GetSourceSystemConnectionKind(site.SourceSystem);
if (string.Equals(connectionKind, SourceSystemConnectionKinds.ManualExcel, StringComparison.OrdinalIgnoreCase))
return Color.Warning;
if (string.Equals(connectionKind, SourceSystemConnectionKinds.SapGateway, StringComparison.OrdinalIgnoreCase))
return Color.Info;
return Color.Primary;
}
private string GetConnectionKindTooltip(Site site)
{
var connectionKind = GetSourceSystemConnectionKind(site.SourceSystem);
if (string.Equals(connectionKind, SourceSystemConnectionKinds.ManualExcel, StringComparison.OrdinalIgnoreCase))
return "Manual Excel / CSV";
if (string.Equals(connectionKind, SourceSystemConnectionKinds.SapGateway, StringComparison.OrdinalIgnoreCase))
return "SAP OData Server";
return "HANA / Server";
}
private string GetEffectiveSapServiceUrl(Site site)
{
if (!string.IsNullOrWhiteSpace(site.SapServiceUrl))
@@ -810,7 +943,7 @@
private void CloseSiteDialog()
{
if (_savingSite || _refreshingSapEntitySets || _uploadingManualImport)
if (_savingSite || _refreshingSapEntitySets || _uploadingManualImport || _loadingManualExcelHeaders)
return;
_siteDialogVisible = false;
@@ -829,9 +962,10 @@
try
{
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");
@@ -877,6 +1011,177 @@
}
}
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.ProductHierarchyCode)] = ["Product Hierarchy Code", "Produkthierarchie Code", "PAPH1"],
[nameof(SalesRecord.ProductHierarchyText)] = ["Product Hierarchy Text", "Produkthierarchie Text", "PAPH1_TEXT"],
[nameof(SalesRecord.ProductFamilyCode)] = ["Product Family Code", "Produktfamilie Code", "WWPFA"],
[nameof(SalesRecord.ProductFamilyText)] = ["Product Family Text", "Produktfamilie Text", "WWPFA_TEXT"],
[nameof(SalesRecord.ProductDivisionCode)] = ["Product Division Code", "Produktsparte Code", "WWPSP"],
[nameof(SalesRecord.ProductDivisionText)] = ["Product Division Text", "Produktsparte Text", "WWPSP_TEXT"],
[nameof(SalesRecord.ProductMappingAssigned)] = ["Product Mapping Assigned", "Produktmapping Zugeordnet", "IS_ASSIGNED"],
[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)
{
if (string.IsNullOrWhiteSpace(json))
@@ -953,7 +1258,7 @@
.ToList();
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);
_sapAvailableSourceExpressions = result.SourceExpressions;
@@ -1,4 +1,6 @@
@page "/transformations"
@rendermode @(Microsoft.AspNetCore.Components.Web.RenderMode.InteractiveServer)
@attribute [Authorize(Policy = TrafagSalesExporter.Security.SecurityPolicies.AdminOnly)]
@using System.Reflection
@using TrafagSalesExporter.Models
@using TrafagSalesExporter.Services
+56 -6
View File
@@ -1,6 +1,56 @@
<Router AppAssembly="typeof(Program).Assembly">
<Found Context="routeData">
<RouteView RouteData="routeData" DefaultLayout="typeof(Layout.MainLayout)" />
<FocusOnNavigate RouteData="routeData" Selector="h1" />
</Found>
</Router>
@using Microsoft.AspNetCore.Components.Authorization
@inject NavigationManager Navigation
@inject TrafagSalesExporter.Services.IFinanceCockpitAccessService FinanceAccess
<CascadingAuthenticationState>
<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
"export-dashboard" or
"management-cockpit" or
"finance-cockpit/vergleich" or
"finance-cockpit/schulung" or
"standorte" or
"transformations" or
"finance-rules" or
"settings" or
"logs" or
"source-viewer";
}
}
@@ -0,0 +1,12 @@
<section class="training-section">
<MudText Typo="Typo.h5" Class="mb-2">@Title</MudText>
@ChildContent
</section>
@code {
[Parameter]
public string Title { get; set; } = string.Empty;
[Parameter]
public RenderFragment? ChildContent { get; set; }
}
@@ -1,9 +1,12 @@
@using System.Net.Http
@using Microsoft.AspNetCore.Components.Forms
@using Microsoft.AspNetCore.Components.Authorization
@using Microsoft.AspNetCore.Components.Routing
@using Microsoft.AspNetCore.Components.Web
@using Microsoft.AspNetCore.Authorization
@using Microsoft.JSInterop
@using MudBlazor
@using TrafagSalesExporter.Components
@using TrafagSalesExporter.Components.FinanceCockpit
@using TrafagSalesExporter.Components.Layout
@using TrafagSalesExporter.Models
Binary file not shown.
+5
View File
@@ -16,8 +16,13 @@ public class AppDbContext : DbContext
public DbSet<AppEventLog> AppEventLogs => Set<AppEventLog>();
public DbSet<FieldTransformationRule> FieldTransformationRules => Set<FieldTransformationRule>();
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<SapJoinDefinition> SapJoinDefinitions => Set<SapJoinDefinition>();
public DbSet<SapFieldMapping> SapFieldMappings => Set<SapFieldMapping>();
public DbSet<ManualExcelColumnMapping> ManualExcelColumnMappings => Set<ManualExcelColumnMapping>();
public DbSet<CentralSalesRecord> CentralSalesRecords => Set<CentralSalesRecord>();
public DbSet<NavigationMenuItem> NavigationMenuItems => Set<NavigationMenuItem>();
}
File diff suppressed because it is too large Load Diff
@@ -1,119 +0,0 @@
# Handoff: DataSourceAdapter-Refactoring (2026-04-17)
**Branch:** `claude/review-trafag-tool-JONMq`
**Commit:** `82ac7df` ("DataSourceAdapter-Pattern + SiteExportService schlanker + Page-Services Scoped")
**Basis:** `main` @ `2a56ba5` (umfangreiches refactoring)
## Kontext fuer den naechsten LLM
Vorheriges Review hatte drei Architektur-Punkte beanstandet:
1. `SiteExportService` war zu gross (338 Zeilen, if/else auf ConnectionKind)
2. Fehlende Adapter-Abstraktion fuer Datenquellen (HANA / SAP_GATEWAY / MANUAL_EXCEL)
3. Alle Services Singleton, auch UI-nahe Page-Services
Dieses Refactoring adressiert alle drei Punkte. **Nicht** im Scope (absichtlich offen gelassen):
- SQL-Injection-Risiko in `HanaQueryService:191,204`
- `.GetAwaiter().GetResult()` Blocking in `HanaQueryService`
- Secret-Store-Integration
- Retry/Polly
## Was konkret geaendert wurde
### Neu: `Services/DataSources/`
| Datei | Zweck |
|---|---|
| `IDataSourceAdapter.cs` | Interface mit `ConnectionKind` + `FetchAsync(context)` |
| `DataSourceFetchContext.cs` | Input: Site, SourceDefinition, Settings, SharePointConfig, UpdateStatus |
| `DataSourceFetchResult.cs` | Output: Records + optionaler `ReferenceFilePath` (Manual Excel liefert Quell-Datei als Referenz) |
| `IDataSourceAdapterResolver.cs` + `DataSourceAdapterResolver.cs` | Dictionary-Lookup nach ConnectionKind |
| `HanaDataSourceAdapter.cs` | Baut `HanaServer` aus zentraler Config + Site-Overrides, ruft `IHanaQueryService.GetSalesRecords` |
| `SapGatewayDataSourceAdapter.cs` | Laedt SapSources/Joins/Mappings, ruft `ISapCompositionService.BuildSalesRecordsAsync` |
| `ManualExcelDataSourceAdapter.cs` | Lokale Datei oder SharePoint-Download, ruft `IManualExcelImportService.ReadSalesRecordsAsync` |
| `DataSourceCredentials.cs` | Interner Helper (FirstNonEmpty, Resolve, ResolveSapServiceUrl) |
### Geaendert: `Services/SiteExportService.cs`
338 -> 187 Zeilen. Jetzt reine Pipeline:
```
1. NormalizeSourceSystem
2. LoadExportConfigAsync (settings, spConfig, sourceDefinition, rules) - 1x DbContext
3. Resolve adapter per ConnectionKind
4. adapter.FetchAsync -> records (+ optional ReferenceFilePath)
5. Transform (_transformationService.Apply)
6. Excel erzeugen (falls Adapter keine Referenzdatei liefert)
7. CentralSalesRecordService.ReplaceForSiteAsync
8. UploadToSharePointIfConfiguredAsync
```
Entferntes Dead-Injection: `ISapGatewayService` (wurde konstruiert aber nie benutzt).
### Geaendert: `Program.cs`
- Adapter registriert (3x `AddSingleton<IDataSourceAdapter, ...>` + Resolver)
- **Page-Services auf Scoped** (`ISettingsPageService`, `IStandortePageService`, `IStandorteSapEditorService`, `IManagementCockpitPageService`, `IDashboardPageService`, `ILogsPageService`, `ITransformationsPageService`) — pro Blazor-Circuit
- `ExportOrchestrationService` bleibt bewusst Singleton (geteilter Export-Status ueber Circuits via `OnExportStatusChanged`)
- Stateless Connector-/Infra-Services bleiben Singleton
## Was der naechste LLM pruefen / testen soll
### 1. Build (ICH KONNTE NICHT BAUEN — kein dotnet SDK in der Sandbox)
```bash
cd TrafagSalesExporter
dotnet restore
dotnet build
```
Falls Fehler: hohe Wahrscheinlichkeit, dass ich ein `using` vergessen oder einen Interface-Namen vertippt habe. Kandidaten fuer Tippfehler: `DataSourceCredentials.FirstNonEmpty` in `SiteExportService.cs:181`, Adapter-Constructoren in `Services/DataSources/*.cs`.
### 2. Tests laufen lassen
```bash
cd TrafagSalesExporter
dotnet test
```
Bestehende Tests in `TrafagSalesExporter.Tests/` referenzieren **keinen** der refactorierten Services direkt (siehe grep: `SiteExportService|IDataSource` liefert keine Treffer in Tests). Sollten also gruen bleiben.
### 3. Manueller Smoke-Test der drei Quellsysteme
In der Blazor-UI (Standorte-Seite, Export-Button):
- **HANA-Standort**: Export starten — muss wie vorher Records aus HANA ziehen, Excel erzeugen, zentrale Tabelle aktualisieren, optional nach SharePoint uploaden.
- **SAP_GATEWAY-Standort**: Export starten — muss SAP-Quellen/Joins/Mappings laden, Records ueber `SapCompositionService` bauen.
- **MANUAL_EXCEL-Standort** (lokaler Pfad): Referenz-Excel wird gelesen, **keine** neue Excel-Datei erzeugt (Referenzdatei bleibt).
- **MANUAL_EXCEL-Standort** (SharePoint-Pfad, `/Shared Documents/...`): temporaerer Download, lesen, Temp-Datei wird im `finally` wieder geloescht.
**Verhaltens-Aequivalenz** zur vorherigen Implementierung ist das Pruefkriterium — keine neue Funktionalitaet, nur Struktur.
### 4. Captive-Dependency-Check
Scoped -> Singleton wuerde DI-Fehler werfen. Ich habe per grep verifiziert, dass kein Singleton eine `I*PageService` konsumiert. Wer das nochmal manuell pruefen moechte:
```bash
grep -rn "PageService" TrafagSalesExporter/Services/ | grep -v "PageService.cs"
```
Sollte nur Registrierungen in Program.cs und UI-Komponenten zeigen.
### 5. Erweiterbarkeit testen
Um ein viertes Quellsystem hinzuzufuegen, reicht jetzt:
1. Konstante in `Models/SourceSystemDefinition.cs::SourceSystemConnectionKinds`
2. Neuer `IDataSourceAdapter` in `Services/DataSources/`
3. `builder.Services.AddSingleton<IDataSourceAdapter, NeuerAdapter>();` in `Program.cs`
Kein Eingriff in `SiteExportService` noetig.
## Offene Themen fuer Follow-up-PRs
1. **SQL-Injection (kritisch)**`HanaQueryService.cs:191,204`: `schema`, `tsc`, `dateFilter` via String-Interpolation. Auf `HanaCommand`-Parameter umstellen (Beispiel: `GetAvailableSchemas()` nutzt das bereits korrekt).
2. **Blocking async**`HanaQueryService` hat 8x `.GetAwaiter().GetResult()`. In Blazor Server Deadlock-Risiko — auf echtes `async/await` migrieren.
3. **Tests fuer Adapter** — Unit-Tests fuer die drei neuen Adapter mit Fakes der Connector-Services waeren sinnvoll. `DataSourceAdapterResolver`-Test (Dictionary-Lookup, Fehler bei unbekanntem Kind) einfach zu schreiben.
4. **Retry-Layer** — HTTP-Requests zu SharePoint/SAP Gateway ohne Polly. Bei Netzflackern bricht Export ab.
## Dateien-Cheatsheet
```
TrafagSalesExporter/
├── Program.cs [MOD: Lifetimes + Adapter-Registrierung]
├── Services/
│ ├── SiteExportService.cs [MOD: 338 -> 187 Zeilen, pure Pipeline]
│ └── DataSources/ [NEU]
│ ├── IDataSourceAdapter.cs
│ ├── IDataSourceAdapterResolver.cs
│ ├── DataSourceAdapterResolver.cs
│ ├── DataSourceFetchContext.cs
│ ├── DataSourceFetchResult.cs
│ ├── DataSourceCredentials.cs
│ ├── HanaDataSourceAdapter.cs
│ ├── SapGatewayDataSourceAdapter.cs
│ └── ManualExcelDataSourceAdapter.cs
```
+12 -514
View File
@@ -1,521 +1,19 @@
# TrafagSalesExporter LLM System Guide
# LLM System Guide
Stand: 2026-04-17
Stand: 2026-05-27
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 tokenarme RAG-Nutzung komprimiert.
## Zweck des Systems
## Kontext-Regel
`TrafagSalesExporter` ist eine Blazor Server App auf `.NET 8`, die Verkaufsdaten aus mehreren Quellsystemen in ein gemeinsames Zielschema ueberfuehrt.
1. Zuerst `docs/RAG_ROUTER.md` laden.
2. Danach genau eine passende Kurzdatei aus `docs/rag/` laden.
3. Original-/Rohdokus nur bei Detail-, Audit- oder Fehleranalysebedarf laden.
Quellsysteme:
## Volltext Bei Bedarf
- `HANA`-basierte Systeme wie `BI1` und `SAGE`
- `SAP_GATEWAY` ueber OData
- `MANUAL_EXCEL` aus hochgeladenen oder referenzierten Excel-Dateien
Die Detailhistorie liegt hier:
Zielbild:
- jede Quelle wird in `SalesRecord` normalisiert
- Standortdaten koennen lokal als Excel exportiert werden
- alle Datensaetze werden in `CentralSalesRecords` gespeichert
- eine zentrale konsolidierte Datei wird aus dem zentralen Datenbestand erzeugt
- ein `Management Cockpit` analysiert sowohl exportierte Dateien als auch zentrale Rohdaten
## Technologie-Stack
- UI: Blazor Server + MudBlazor
- Datenbank: SQLite (`trafag_exporter.db`)
- Excel lesen/schreiben: ClosedXML
- SAP HANA Zugriff: `Sap.Data.Hana.Core.v2.1.dll`
- SAP Gateway / OData: eigener Service ueber HTTP
- SharePoint Upload/Download: Microsoft Graph + Azure Identity
- Tests: xUnit
## Einstiegspunkte
Wichtige Dateien:
- [Program.cs](C:/Users/koi/source/repos/Ai/TrafagSalesExporter/Program.cs)
- [Data/AppDbContext.cs](C:/Users/koi/source/repos/Ai/TrafagSalesExporter/Data/AppDbContext.cs)
- [Components/Layout/NavMenu.razor](C:/Users/koi/source/repos/Ai/TrafagSalesExporter/Components/Layout/NavMenu.razor)
`Program.cs` registriert fast die komplette Architektur ueber DI und fuehrt beim Start `DatabaseInitializationService.InitializeAsync()` aus.
## Hauptseiten
Navigation:
- `/` Dashboard
- `/standorte`
- `/transformations`
- `/management-cockpit`
- `/settings`
- `/logs`
Dateien:
- [Components/Pages/Dashboard.razor](C:/Users/koi/source/repos/Ai/TrafagSalesExporter/Components/Pages/Dashboard.razor)
- [Components/Pages/Standorte.razor](C:/Users/koi/source/repos/Ai/TrafagSalesExporter/Components/Pages/Standorte.razor)
- [Components/Pages/Transformations.razor](C:/Users/koi/source/repos/Ai/TrafagSalesExporter/Components/Pages/Transformations.razor)
- [Components/Pages/ManagementCockpit.razor](C:/Users/koi/source/repos/Ai/TrafagSalesExporter/Components/Pages/ManagementCockpit.razor)
- [Components/Pages/Settings.razor](C:/Users/koi/source/repos/Ai/TrafagSalesExporter/Components/Pages/Settings.razor)
- [Components/Pages/Logs.razor](C:/Users/koi/source/repos/Ai/TrafagSalesExporter/Components/Pages/Logs.razor)
Kurzrollen:
- `Dashboard`: Einzel-Export, Alle exportieren, zentrale Datei neu erzeugen, Live-Status
- `Standorte`: Standortpflege, zentrale HANA-Technik, SAP-Konfiguration pro Standort, manueller Excel-Import
- `Transformations`: feldweise und record-basierte Regeln
- `Management Cockpit`: Dateianalyse und Rohanalyse aus `CentralSalesRecords`
- `Settings`: SharePoint, Exportpfade, Quellsysteme, Wechselkurse, Config Import/Export
- `Logs`: technische Ereignisprotokolle
## Kernmodelle
Wichtige Entity-Klassen:
- [Models/Site.cs](C:/Users/koi/source/repos/Ai/TrafagSalesExporter/Models/Site.cs)
- [Models/SourceSystemDefinition.cs](C:/Users/koi/source/repos/Ai/TrafagSalesExporter/Models/SourceSystemDefinition.cs)
- [Models/HanaServer.cs](C:/Users/koi/source/repos/Ai/TrafagSalesExporter/Models/HanaServer.cs)
- [Models/SalesRecord.cs](C:/Users/koi/source/repos/Ai/TrafagSalesExporter/Models/SalesRecord.cs)
- [Models/CentralSalesRecord.cs](C:/Users/koi/source/repos/Ai/TrafagSalesExporter/Models/CentralSalesRecord.cs)
- [Models/FieldTransformationRule.cs](C:/Users/koi/source/repos/Ai/TrafagSalesExporter/Models/FieldTransformationRule.cs)
- [Models/SapSourceDefinition.cs](C:/Users/koi/source/repos/Ai/TrafagSalesExporter/Models/SapSourceDefinition.cs)
- [Models/SapJoinDefinition.cs](C:/Users/koi/source/repos/Ai/TrafagSalesExporter/Models/SapJoinDefinition.cs)
- [Models/SapFieldMapping.cs](C:/Users/koi/source/repos/Ai/TrafagSalesExporter/Models/SapFieldMapping.cs)
- [Models/SharePointConfig.cs](C:/Users/koi/source/repos/Ai/TrafagSalesExporter/Models/SharePointConfig.cs)
- [Models/ExportSettings.cs](C:/Users/koi/source/repos/Ai/TrafagSalesExporter/Models/ExportSettings.cs)
- [Models/ExportLog.cs](C:/Users/koi/source/repos/Ai/TrafagSalesExporter/Models/ExportLog.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)
Wichtige Relationen:
- `Site -> HanaServer` optional
- `Site -> SapSourceDefinitions`
- `Site -> SapJoinDefinitions`
- `Site -> SapFieldMappings`
- `Site -> CentralSalesRecords`
- `SourceSystemDefinition` ist zentrale Stammdatenquelle fuer Quellsysteme
## Datenbanktabellen
`AppDbContext` enthaelt:
- `HanaServers`
- `SourceSystemDefinitions`
- `Sites`
- `SharePointConfigs`
- `ExportSettings`
- `ExportLogs`
- `AppEventLogs`
- `FieldTransformationRules`
- `CurrencyExchangeRates`
- `SapSourceDefinitions`
- `SapJoinDefinitions`
- `SapFieldMappings`
- `CentralSalesRecords`
## Architekturrollen der Services
### Export / Orchestrierung
- [Services/ExportOrchestrationService.cs](C:/Users/koi/source/repos/Ai/TrafagSalesExporter/Services/ExportOrchestrationService.cs)
- [Services/SiteExportService.cs](C:/Users/koi/source/repos/Ai/TrafagSalesExporter/Services/SiteExportService.cs)
- [Services/ConsolidatedExportService.cs](C:/Users/koi/source/repos/Ai/TrafagSalesExporter/Services/ConsolidatedExportService.cs)
- [Services/CentralSalesRecordService.cs](C:/Users/koi/source/repos/Ai/TrafagSalesExporter/Services/CentralSalesRecordService.cs)
- [Services/ExportLogService.cs](C:/Users/koi/source/repos/Ai/TrafagSalesExporter/Services/ExportLogService.cs)
Rollen:
- `ExportOrchestrationService` steuert UI-nahe Exportlaeufe und Live-Status
- `SiteExportService` entscheidet anhand des Quellsystems, wie ein Standort gelesen wird
- `CentralSalesRecordService` ersetzt zentrale Saetze pro Standort
- `ConsolidatedExportService` erzeugt die zentrale Datei
### Datenquellen
- [Services/HanaQueryService.cs](C:/Users/koi/source/repos/Ai/TrafagSalesExporter/Services/HanaQueryService.cs)
- [Services/SapGatewayService.cs](C:/Users/koi/source/repos/Ai/TrafagSalesExporter/Services/SapGatewayService.cs)
- [Services/SapCompositionService.cs](C:/Users/koi/source/repos/Ai/TrafagSalesExporter/Services/SapCompositionService.cs)
- [Services/ManualExcelImportService.cs](C:/Users/koi/source/repos/Ai/TrafagSalesExporter/Services/ManualExcelImportService.cs)
- [Services/SharePointUploadService.cs](C:/Users/koi/source/repos/Ai/TrafagSalesExporter/Services/SharePointUploadService.cs)
Rollen:
- `HanaQueryService`: SQL gegen SAP B1/HANA-nahe Schemata
- `SapGatewayService`: OData-Metadaten und Reads
- `SapCompositionService`: Mehrquellen-/Join-/Mapping-Aufbau fuer SAP
- `ManualExcelImportService`: Import im Exportformat aus `.xlsx`
- `SharePointUploadService`: Upload fuer Exportdateien und Download fuer manuelle Excel-Dateien
### Transformation / Mapping
- [Services/TransformationCatalog.cs](C:/Users/koi/source/repos/Ai/TrafagSalesExporter/Services/TransformationCatalog.cs)
- [Services/TransformationStrategies.cs](C:/Users/koi/source/repos/Ai/TrafagSalesExporter/Services/TransformationStrategies.cs)
- [Services/RecordTransformationService.cs](C:/Users/koi/source/repos/Ai/TrafagSalesExporter/Services/RecordTransformationService.cs)
- [Services/CurrencyExchangeRateService.cs](C:/Users/koi/source/repos/Ai/TrafagSalesExporter/Services/CurrencyExchangeRateService.cs)
- [Services/ExchangeRateImportService.cs](C:/Users/koi/source/repos/Ai/TrafagSalesExporter/Services/ExchangeRateImportService.cs)
Rollen:
- `Value`-Transformationen fuer einzelne Felder
- `Record`-Transformationen fuer zeilenweite Regeln
- Wechselkursimport und -umrechnung
### Reporting / Monitoring / Infrastruktur
- [Services/ManagementCockpitService.cs](C:/Users/koi/source/repos/Ai/TrafagSalesExporter/Services/ManagementCockpitService.cs)
- [Services/AppEventLogService.cs](C:/Users/koi/source/repos/Ai/TrafagSalesExporter/Services/AppEventLogService.cs)
- [Services/ConfigTransferService.cs](C:/Users/koi/source/repos/Ai/TrafagSalesExporter/Services/ConfigTransferService.cs)
- [Services/DatabaseInitializationService.cs](C:/Users/koi/source/repos/Ai/TrafagSalesExporter/Services/DatabaseInitializationService.cs)
- [Services/TimerBackgroundService.cs](C:/Users/koi/source/repos/Ai/TrafagSalesExporter/Services/TimerBackgroundService.cs)
## Der wichtigste technische Ablauf
### 1. Standort-Export
Pfad:
`Dashboard/Standorte -> ExportOrchestrationService -> SiteExportService`
`SiteExportService` unterscheidet drei Modi:
1. `SAP_GATEWAY`
- SAP-Quellen lesen
- SAP-Joins anwenden
- SAP-Feldmappings auf `SalesRecord`
- Transformationen anwenden
- Standort-Excel erzeugen
- `CentralSalesRecords` ersetzen
- optional SharePoint-Upload
2. `HANA`
- effektive zentrale HANA-Konfiguration laden
- optionale Standort-Credential-Overrides anwenden
- SQL in HANA ausfuehren
- `SalesRecord` erzeugen
- Transformationen anwenden
- Standort-Excel erzeugen
- `CentralSalesRecords` ersetzen
- optional SharePoint-Upload
3. `MANUAL_EXCEL`
- `ManualImportFilePath` auswerten
- wenn lokal/UNC vorhanden: lokal lesen
- wenn SharePoint-Referenz: via Graph temp herunterladen
- Excel in `SalesRecord` lesen
- Transformationen anwenden
- keine neue Standortdatei erzeugen, bestehende Excel dient als Eingabe
- `CentralSalesRecords` ersetzen
### 2. Konsolidierter Export
Pfad:
`Dashboard -> ExportOrchestrationService -> ConsolidatedExportService`
Semantik aktuell:
- die zentrale Datei basiert fachlich auf `CentralSalesRecords`
- `ExportAllAsync()` sammelt zwar auch `consolidatedRecords`, aber die zentrale Exportsemantik ist historisch noch nicht vollkommen bereinigt
### 3. Management Cockpit
Zwei Betriebsarten:
1. Dateibasiert
- vorhandene `.xlsx` waehlen
- Datei mit ClosedXML lesen
- Kennzahlen, Top-Listen, Datenqualitaet, Findings erzeugen
2. Zentraldatenbasiert
- direkt aus `CentralSalesRecords`
- Jahr/Monat Filter
- Rohsicht ohne Intercompany-, CHF-, Budget- oder Spartelogik
## Quellsystemlogik
### SourceSystemDefinition
`SourceSystemDefinition` ist die fuehrende Wahrheit fuer:
- `Code`
- `DisplayName`
- `ConnectionKind`
- `IsActive`
- `CentralUsername`
- `CentralPassword`
- `CentralServiceUrl` fuer SAP
Anschlussarten:
- `HANA`
- `SAP_GATEWAY`
- `MANUAL_EXCEL`
### HANA
Fachliche Logik:
- zentrale technische HANA-Konfiguration pro Quellsystem
- keine separaten Vollverbindungen pro Standort
- Standort speichert nur Fachdaten plus optionale Username-/Password-Overrides
Schema-Lookup:
- in `Standorte` gibt es jetzt `Schemas laden`
- Lookup fragt `sys.tables` in HANA ab
- eingeschraenkt auf typische B1-Schemas mit Tabellen wie `OINV`, `INV1`, `ORIN`, `RIN1`, `OCRD`, `OITM`
### SAP
Fachliche Logik:
- zentrale SAP Service URL in `SourceSystemDefinition.CentralServiceUrl`
- Standort kann `SapServiceUrl` als Override pflegen
- pro Standort gibt es SAP-Quellen, Joins und Feldmappings
### Manual Excel
Fachliche Logik:
- `Site.ManualImportFilePath` kann sein:
- lokaler Windows-Pfad
- UNC-Pfad
- SharePoint-URL
- SharePoint-Pfad unterhalb der konfigurierten Site
- Standortdaten werden aus der Excel eingelesen und in `CentralSalesRecords` uebernommen
- SharePoint dient hier als Eingangsquelle, nicht nur als Exportziel
## Transformationen
Das System unterscheidet:
- `Value`-Transformationen
- `Record`-Transformationen
Beispiele:
- `Copy`
- `Uppercase`
- `Lowercase`
- `Prefix`
- `Suffix`
- `Replace`
- `Constant`
- `NormalizeCurrencyCode`
- `FirstNonEmpty`
- `ConvertCurrency`
Technischer Ablauf:
- Regeln liegen in `FieldTransformationRules`
- `TransformationCatalog` meldet verfuegbare Strategien an die UI
- `RecordTransformationService` wendet record-basierte Strategien an
## Wechselkurse
Vorhanden:
- `CurrencyExchangeRates`
- `ExchangeRateImportService` fuer ECB-Tageskurse
- `NormalizeCurrencyCode`
- `ConvertCurrency`
Wichtig:
- die Rohsicht im `Management Cockpit` rechnet aktuell bewusst nicht in CHF um
- CHF ist derzeit Teil des allgemeinen Transformationssystems, nicht Default in der Cockpit-Rohsicht
## SharePoint-Rolle im Gesamtsystem
`SharePointConfig` enthaelt:
- `SiteUrl`
- `ExportFolder`
- `CentralExportFolder`
- `TenantId`
- `ClientId`
- `ClientSecret`
Verwendung:
- Upload von Standort-Exporten
- Upload der zentralen Datei
- Download von manuellen Excel-Dateien fuer `MANUAL_EXCEL`
Wichtig:
- die App arbeitet gegen dieselbe SharePoint-Site, die in `Settings` konfiguriert ist
- fuer `MANUAL_EXCEL` muessen Referenzen auf derselben Site aufloesbar sein
## Startinitialisierung / Migrationen
Kritische Datei:
- [Services/DatabaseInitializationService.cs](C:/Users/koi/source/repos/Ai/TrafagSalesExporter/Services/DatabaseInitializationService.cs)
Aktuelle Rolle:
- `EnsureCreated`
- Schema-Ergaenzungen per `ALTER TABLE`
- Tabellen-Rebuilds bei Legacy-Schemas
- FK-Reparaturen
- Stammdaten-Seeding
- empfohlene Transformationsregeln
Bekannte Architekturrealitaet:
- das ist funktional hilfreich, aber kein sauberes Migrationssystem
- die Startlogik traegt produktive Schema-Reparaturverantwortung
- das ist einer der wichtigsten technischen Risikobloecke
Bereits gehaertete Fehlerbilder:
- kaputte FK-Referenzen auf `Sites_old`
- kaputte FK-Referenzen auf `HanaServers_repair_old`
- Legacy-Credential-Spalten in `ExportSettings`
- Legacy-Credential-Spalten in `HanaServers`
- verschobene Spalten im `Sites_old -> Sites`-Kopierpfad
## Config Import / Export
Dateien:
- [Services/ConfigTransferService.cs](C:/Users/koi/source/repos/Ai/TrafagSalesExporter/Services/ConfigTransferService.cs)
- [Models/ConfigTransferPackage.cs](C:/Users/koi/source/repos/Ai/TrafagSalesExporter/Models/ConfigTransferPackage.cs)
Aktueller Stand:
- JSON Export/Import fuer Konfiguration
- Secrets optional
- `SourceSystemDefinitions` im aktuellen Modell enthalten
- HANA-Technik ohne HANA-Credentials
- Standort-Overrides bleiben erhalten
Wichtige Punkte:
- Import laeuft jetzt transaktional
- alte `ConnectionKind`-lose Formate bekommen Fallbacks
- `CentralSalesRecords` werden nicht mehr blind geloescht
- bestehende zentrale Laufzeitdaten werden fuer weiterhin vorhandene Standorte remappt
## Logging
Es gibt zwei Log-Ebenen:
- `ExportLogs` fuer fachliche Exporthistorie
- `AppEventLogs` fuer technische und UI-nahe Ereignisse
Die `Logs`-Seite liest vor allem `AppEventLogs`.
## Tests
Testprojekt:
- [TrafagSalesExporter.Tests](C:/Users/koi/source/repos/Ai/TrafagSalesExporter/TrafagSalesExporter.Tests)
Aktuell vorhandene Schwerpunkte:
- Transformationen
- Record-Transformationen
- TransformationCatalog
- CurrencyExchangeRateService
- ExchangeRateImportService
- ManualExcelImportService
- ManagementCockpitService
- ConfigTransferService
- DatabaseInitializationService
Wichtig:
- es gibt aktuell keine echten UI-Komponententests mit `bUnit`
- es gibt keine Browser-E2E-Tests mit `Playwright`
- viele Button-Aktionen sind nur indirekt ueber Services und Persistenz getestet
## Bekannte offene Architekturfragen
Fuer andere LLMs wichtig, damit Visualisierungen nicht zu glatt oder zu idealisiert werden:
1. `DatabaseInitializationService` ist ein produktiver Reparatur-/Migrationslayer, nicht nur Bootstrap.
2. `Settings.razor` und `Standorte.razor` enthalten weiterhin relativ viel Anwendungslogik.
3. Die Semantik der konsolidierten Datei ist historisch teilweise doppelt angelegt.
4. Das `Management Cockpit` ist noch kein voll generalisierter Reporting-Layer.
5. SharePoint ist sowohl Exportziel als auch bei `MANUAL_EXCEL` mittlerweile moegliche Eingangsquelle.
## Empfohlene Diagramme fuer andere LLMs
### 1. Kontextdiagramm
Zeige:
- Benutzer
- Blazor App
- SQLite
- SAP HANA
- SAP Gateway
- lokale Dateisystempfade
- SharePoint
### 2. Komponenten-/Service-Diagramm
Gruppiere:
- UI
- Orchestrierung
- Quelladapter
- Transformation
- Persistenz
- Reporting
### 3. Datenflussdiagramm pro Quelltyp
Je ein separater Flow fuer:
- HANA
- SAP Gateway
- Manual Excel lokal
- Manual Excel SharePoint
### 4. ER-Diagramm
Fokussiere auf:
- `SourceSystemDefinition`
- `HanaServer`
- `Site`
- `SapSourceDefinition`
- `SapJoinDefinition`
- `SapFieldMapping`
- `CentralSalesRecord`
- `FieldTransformationRule`
### 5. Sequenzdiagramm fuer Export
Wichtige Stationen:
- Dashboard
- ExportOrchestrationService
- SiteExportService
- spezifischer Quellservice
- Transformation
- CentralSalesRecordService
- Excel/SharePoint
- ExportLog/AppEventLog
## Prompt-Vorlage fuer ein anderes LLM
Wenn ein anderes LLM daraus Visualisierungen erzeugen soll, funktioniert diese Anweisung gut:
> Lies `LLM_SYSTEM_GUIDE.md` als primaeren Systemkontext. Erzeuge daraus ein Architekturdiagramm, ein Datenflussdiagramm fuer HANA/SAP/MANUAL_EXCEL, ein ER-Diagramm der wichtigsten Tabellen und ein Sequenzdiagramm fuer `ExportAsync`. Achte darauf, dass `DatabaseInitializationService` produktive Reparaturlogik enthaelt und dass `MANUAL_EXCEL` sowohl lokal als auch ueber SharePoint gelesen werden kann.
## Weitere Kontextdateien
Zusatzkontext fuer Verlauf und Risiken:
- [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)
Diese beiden Dateien sind wichtig, wenn ein anderes LLM nicht nur Struktur, sondern auch historische Umbauten, Risiken und Prioritaeten verstehen soll.
```text
docs/raw_md_archive/HISTORY_CANONICAL.md.raw
```
@@ -14,11 +14,19 @@ public class CentralSalesRecord
public string SourceSystem { get; set; } = string.Empty;
public DateTime ExtractionDate { get; set; }
public string Tsc { get; set; } = string.Empty;
public int DocumentEntry { get; set; }
public string InvoiceNumber { get; set; } = string.Empty;
public int PositionOnInvoice { get; set; }
public string Material { get; set; } = string.Empty;
public string Name { get; set; } = string.Empty;
public string ProductGroup { get; set; } = string.Empty;
public string ProductHierarchyCode { get; set; } = string.Empty;
public string ProductHierarchyText { get; set; } = string.Empty;
public string ProductFamilyCode { get; set; } = string.Empty;
public string ProductFamilyText { get; set; } = string.Empty;
public string ProductDivisionCode { get; set; } = string.Empty;
public string ProductDivisionText { get; set; } = string.Empty;
public string ProductMappingAssigned { get; set; } = string.Empty;
public decimal Quantity { get; set; }
public string SupplierNumber { get; set; } = string.Empty;
public string SupplierName { get; set; } = string.Empty;
@@ -32,8 +40,16 @@ public class CentralSalesRecord
public string PurchaseOrderNumber { get; set; } = string.Empty;
public decimal SalesPriceValue { get; set; }
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 SalesResponsibleEmployee { get; set; } = string.Empty;
public DateTime? PostingDate { get; set; }
public DateTime? InvoiceDate { get; set; }
public DateTime? OrderDate { get; set; }
public string Land { get; set; } = string.Empty;
@@ -9,12 +9,16 @@ public class ConfigTransferPackage
public ConfigTransferExportSettings? ExportSettings { get; set; }
public List<ConfigTransferSourceSystemDefinition> SourceSystemDefinitions { 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<ConfigTransferSite> Sites { get; set; } = [];
public List<FieldTransformationRule> FieldTransformationRules { get; set; } = [];
public List<ConfigTransferSapSourceDefinition> SapSourceDefinitions { get; set; } = [];
public List<ConfigTransferSapJoinDefinition> SapJoinDefinitions { get; set; } = [];
public List<ConfigTransferSapFieldMapping> SapFieldMappings { get; set; } = [];
public List<ConfigTransferManualExcelColumnMapping> ManualExcelColumnMappings { get; set; } = [];
}
public class ConfigTransferSourceSystemDefinition
@@ -47,6 +51,10 @@ public class ConfigTransferExportSettings
public bool DebugLoggingEnabled { get; set; }
public string LocalSiteExportFolder { get; set; } = string.Empty;
public string LocalConsolidatedExportFolder { get; set; } = string.Empty;
public bool AuditCsvEnabled { get; set; } = true;
public bool UseAuditCsvAsCentralSource { get; set; }
public string LocalAuditCsvFolder { get; set; } = string.Empty;
public string ExchangeRateDateField { get; set; } = ExchangeRateDateFields.PostingDate;
}
public class ConfigTransferCurrencyExchangeRate
@@ -60,6 +68,26 @@ public class ConfigTransferCurrencyExchangeRate
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 string Key { get; set; } = Guid.NewGuid().ToString("N");
@@ -124,3 +152,13 @@ public class ConfigTransferSapFieldMapping
public bool IsActive { get; set; } = true;
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; }
}
@@ -10,4 +10,15 @@ public class ExportSettings
public bool DebugLoggingEnabled { get; set; }
public string LocalSiteExportFolder { get; set; } = string.Empty;
public string LocalConsolidatedExportFolder { get; set; } = string.Empty;
public bool AuditCsvEnabled { get; set; } = true;
public bool UseAuditCsvAsCentralSource { get; set; }
public string LocalAuditCsvFolder { get; set; } = string.Empty;
public string ExchangeRateDateField { get; set; } = ExchangeRateDateFields.PostingDate;
}
public static class ExchangeRateDateFields
{
public const string PostingDate = nameof(PostingDate);
public const string InvoiceDate = nameof(InvoiceDate);
public const string ExtractionDate = nameof(ExtractionDate);
}
@@ -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
];
}
+219
View File
@@ -0,0 +1,219 @@
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 DateTime? VonDatum { get; set; }
public DateTime? BisDatum { get; set; }
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,38 @@ public class ManagementCockpitFileOption
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 Chf = "CHF";
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 string Land { get; set; } = string.Empty;
@@ -15,6 +47,11 @@ public class ManagementCockpitSummary
public int RowCount { get; set; }
public int InvoiceCount { 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 EstimatedCostTotal { get; set; }
public decimal EstimatedMarginTotal { get; set; }
@@ -53,6 +90,10 @@ public class ManagementCockpitCentralFilter
{
public int Year { 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
@@ -62,6 +103,13 @@ public class ManagementCockpitCentralSummary
public int SiteCount { get; set; }
public int CountryCount { 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 string ExchangeRateDateField { get; set; } = ExchangeRateDateFields.PostingDate;
public string ExchangeRateDateLabel { get; set; } = "PostingDate / Buchungsdatum";
public DateTime? PeriodStart { get; set; }
public DateTime? PeriodEnd { get; set; }
}
@@ -74,9 +122,19 @@ public class ManagementCockpitTimeValueRow
public int? Day { get; set; }
public string Currency { get; set; } = string.Empty;
public decimal SalesValue { get; set; }
public Dictionary<string, ManagementCockpitAggregatedFieldValue> AdditionalValues { get; set; } = new(StringComparer.OrdinalIgnoreCase);
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 string Label { get; set; } = string.Empty;
@@ -91,9 +149,194 @@ public class ManagementCockpitCentralResult
public ManagementCockpitCentralFilter Filter { get; set; } = new();
public ManagementCockpitCentralSummary Summary { get; set; } = new();
public List<string> Notices { get; set; } = [];
public List<ManagementCockpitValueFieldOption> AdditionalValueFields { get; set; } = [];
public List<ManagementCockpitTimeValueRow> YearlyTotals { get; set; } = [];
public List<ManagementCockpitTimeValueRow> MonthlyTotals { get; set; } = [];
public List<ManagementCockpitTimeValueRow> DailyTotals { get; set; } = [];
public List<ManagementCockpitDimensionValueRow> SourceSystemTotals { 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 int TotalRows { get; set; }
public decimal NetSalesActual { get; set; }
public decimal NetSalesActualExcludingIntercompany { get; set; }
public decimal IntercompanyValue { get; set; }
public decimal IntercompanySharePercent { get; set; }
public decimal Quantity { get; set; }
public decimal CreditValue { get; set; }
public int CreditRows { get; set; }
public decimal IncludeRatePercent { get; set; }
public decimal ExcludeRatePercent { get; set; }
}
public class ManagementFinanceCountryStatusRow : ManagementFinanceSummaryRow
{
public string SourceSystems { get; set; } = string.Empty;
public string Tscs { get; set; } = string.Empty;
public decimal? ReferenceValue { get; set; }
public decimal? Difference { get; set; }
public decimal? DifferencePercent { get; set; }
public string Status { get; set; } = string.Empty;
}
public class ManagementFinanceDataStatusRow
{
public string Land { get; set; } = string.Empty;
public string Tsc { get; set; } = string.Empty;
public string SourceSystem { get; set; } = string.Empty;
public bool IsActive { get; set; }
public int RowCount { get; set; }
public DateTime? LatestStoredAtUtc { get; set; }
public DateTime? LatestExtractionDate { get; set; }
public DateTime? LatestExportAt { get; set; }
public string LatestExportStatus { get; set; } = string.Empty;
public string ManualImportFilePath { get; set; } = string.Empty;
public DateTime? ManualImportLastUploadedAtUtc { get; set; }
}
public class ManagementFinanceCreditCandidateRow
{
public string CountryKey { get; set; } = string.Empty;
public string Tsc { get; set; } = string.Empty;
public string InvoiceNumber { get; set; } = string.Empty;
public string DocumentType { get; set; } = string.Empty;
public string Currency { get; set; } = string.Empty;
public decimal NetSalesActual { get; set; }
public decimal Quantity { get; set; }
public string Reason { get; set; } = string.Empty;
}
public class ManagementFinanceDataQualityRow
{
public string Issue { get; set; } = string.Empty;
public int Count { get; set; }
public string Severity { get; set; } = "Info";
}
public class ManagementProductAssignmentSummary
{
public int DistinctMaterialCount { get; set; }
public int MatchedMaterialCount { get; set; }
public int UnassignedMaterialCount { get; set; }
public int MissingReferenceMaterialCount { get; set; }
public int MissingMaterialNumberCount { get; set; }
public int ReferenceMaterialCount { get; set; }
}
public class ManagementProductFinanceSummary
{
public decimal TotalValue { get; set; }
public decimal AssignedValue { get; set; }
public decimal UnassignedValue { get; set; }
public decimal MissingReferenceValue { get; set; }
public decimal MissingMaterialValue { get; set; }
public decimal AssignedValuePercent { get; set; }
public decimal UnassignedValuePercent { get; set; }
public decimal MissingReferenceValuePercent { get; set; }
public string DisplayCurrency { get; set; } = string.Empty;
}
public class ManagementProductDivisionFinanceRow
{
public string ProductDivisionCode { get; set; } = string.Empty;
public string ProductDivisionText { get; set; } = string.Empty;
public string ProductFamilyCode { get; set; } = string.Empty;
public string ProductFamilyText { get; set; } = string.Empty;
public string ProductHierarchyCode { get; set; } = string.Empty;
public string ProductHierarchyText { get; set; } = string.Empty;
public string Currency { get; set; } = string.Empty;
public decimal NetSalesActual { get; set; }
public decimal SharePercent { get; set; }
public int MaterialCount { get; set; }
public int RowCount { get; set; }
public string Countries { get; set; } = string.Empty;
}
public class ManagementProductFinanceCountryRow
{
public string CountryKey { get; set; } = string.Empty;
public string Tsc { get; set; } = string.Empty;
public string Currency { get; set; } = string.Empty;
public decimal TotalValue { get; set; }
public decimal AssignedValue { get; set; }
public decimal UnassignedValue { get; set; }
public decimal MissingReferenceValue { get; set; }
public decimal MissingMaterialValue { get; set; }
public decimal AssignedValuePercent { get; set; }
}
public class ManagementProductAssignmentCountryRow
{
public string CountryKey { get; set; } = string.Empty;
public string Tsc { get; set; } = string.Empty;
public int DistinctMaterialCount { get; set; }
public int MatchedMaterialCount { get; set; }
public int UnassignedMaterialCount { get; set; }
public int MissingReferenceMaterialCount { get; set; }
public int MissingMaterialNumberCount { get; set; }
public decimal MatchPercent { get; set; }
}
public class ManagementProductAssignmentRow
{
public string Status { get; set; } = string.Empty;
public string CountryKey { get; set; } = string.Empty;
public string Tsc { get; set; } = string.Empty;
public string SourceSystem { get; set; } = string.Empty;
public string Material { get; set; } = string.Empty;
public string ArticleName { get; set; } = string.Empty;
public string ReferenceMaterial { get; set; } = string.Empty;
public string ProductHierarchyCode { get; set; } = string.Empty;
public string ProductHierarchyText { get; set; } = string.Empty;
public string ProductFamilyCode { get; set; } = string.Empty;
public string ProductFamilyText { get; set; } = string.Empty;
public string ProductDivisionCode { get; set; } = string.Empty;
public string ProductDivisionText { get; set; } = string.Empty;
public string ProductMappingAssigned { get; set; } = string.Empty;
public int RowCount { get; set; }
public decimal NetSalesActual { get; set; }
public string Currency { get; set; } = string.Empty;
}
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 List<ManagementFinanceSummaryRow> YearCountryRows { 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;
public List<ManagementFinanceCountryStatusRow> CountryRows { get; set; } = [];
public List<ManagementFinanceCountryStatusRow> DeviationRows { get; set; } = [];
public List<ManagementFinanceDataStatusRow> DataStatusRows { get; set; } = [];
public List<ManagementFinanceCreditCandidateRow> CreditCandidates { get; set; } = [];
public List<ManagementFinanceDataQualityRow> DataQualityRows { get; set; } = [];
public ManagementProductFinanceSummary ProductFinanceSummary { get; set; } = new();
public List<ManagementProductDivisionFinanceRow> ProductDivisionFinanceRows { get; set; } = [];
public List<ManagementProductFinanceCountryRow> ProductFinanceCountryRows { get; set; } = [];
public ManagementProductAssignmentSummary ProductAssignmentSummary { get; set; } = new();
public List<ManagementProductAssignmentCountryRow> ProductAssignmentCountryRows { get; set; } = [];
public List<ManagementProductAssignmentRow> ProductAssignmentRows { get; set; } = [];
}
@@ -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; }
}
@@ -0,0 +1,26 @@
namespace TrafagSalesExporter.Models;
public class NavigationMenuItem
{
public int Id { get; set; }
public string Key { get; set; } = string.Empty;
public string? ParentKey { get; set; }
public string TitleDe { get; set; } = string.Empty;
public string TitleEn { get; set; } = string.Empty;
public string Icon { get; set; } = string.Empty;
public string Href { get; set; } = string.Empty;
public string ItemType { get; set; } = NavigationMenuItemTypes.Link;
public string Match { get; set; } = "Prefix";
public string RequiredPolicy { get; set; } = string.Empty;
public bool IsVisible { get; set; } = true;
public bool IsExpanded { get; set; }
public bool IsSystem { get; set; } = true;
public int SortOrder { get; set; }
}
public static class NavigationMenuItemTypes
{
public const string Group = "Group";
public const string Link = "Link";
public const string Action = "Action";
}
@@ -0,0 +1,3 @@
namespace TrafagSalesExporter.Models;
public sealed record PurchasingAnalysisRow(string TitleDe, string TitleEn, string Measure, string Dimension, string Source);
@@ -0,0 +1,11 @@
using MudBlazor;
namespace TrafagSalesExporter.Models;
public sealed record PurchasingSectionKpi(string LabelDe, string LabelEn, string Value, string DetailDe, string DetailEn);
public sealed record PurchasingSectionChartRow(string Label, string Value, double Percent, string Color);
public sealed record PurchasingSectionStatusRow(string LabelDe, string LabelEn, string Value, string Icon, Color Color);
public sealed record PurchasingSectionDetailRow(string LabelDe, string LabelEn, string Value, string Dimension, string Source);
+18
View File
@@ -3,12 +3,22 @@ namespace TrafagSalesExporter.Models;
public class SalesRecord
{
public DateTime ExtractionDate { get; set; }
public string SourceSystem { get; set; } = string.Empty;
public string Tsc { get; set; } = string.Empty;
public string SourceLineId { get; set; } = string.Empty;
public int DocumentEntry { get; set; }
public string InvoiceNumber { get; set; } = string.Empty;
public int PositionOnInvoice { get; set; }
public string Material { get; set; } = string.Empty;
public string Name { get; set; } = string.Empty;
public string ProductGroup { get; set; } = string.Empty;
public string ProductHierarchyCode { get; set; } = string.Empty;
public string ProductHierarchyText { get; set; } = string.Empty;
public string ProductFamilyCode { get; set; } = string.Empty;
public string ProductFamilyText { get; set; } = string.Empty;
public string ProductDivisionCode { get; set; } = string.Empty;
public string ProductDivisionText { get; set; } = string.Empty;
public string ProductMappingAssigned { get; set; } = string.Empty;
public decimal Quantity { get; set; }
public string SupplierNumber { get; set; } = string.Empty;
public string SupplierName { get; set; } = string.Empty;
@@ -22,8 +32,16 @@ public class SalesRecord
public string PurchaseOrderNumber { get; set; } = string.Empty;
public decimal SalesPriceValue { get; set; }
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 SalesResponsibleEmployee { get; set; } = string.Empty;
public DateTime? PostingDate { get; set; }
public DateTime? InvoiceDate { get; set; }
public DateTime? OrderDate { get; set; }
public string Land { get; set; } = string.Empty;
+25 -347
View File
@@ -1,360 +1,38 @@
# Next Steps
Stand: 2026-04-15
Stand: 2026-05-27
## Nachtrag 2026-04-17
Diese Datei ist fuer tokenarme RAG-Nutzung komprimiert.
Der Punkt `CHF-Umrechnung / Wechselkurse` ist nicht mehr komplett offen.
## Aktueller Kurzstand
Der aktuelle Ist-Stand ist:
- Fuehrender Kurzkontext: `docs/rag/PROJECT.md`.
- Themenrouter: `docs/RAG_ROUTER.md`.
- Offene aktive Fachpunkte stehen in den jeweiligen Kurzdateien:
- Finance: `docs/rag/FINANCE.md`
- Manual Import: `docs/rag/MANUAL_IMPORT.md`
- HR KPI: `docs/rag/HR_KPI.md`
- Deployment: `docs/rag/DEPLOYMENT.md`
- Admin: `docs/rag/ADMIN.md`
- `CurrencyExchangeRateService` ist implementiert
- `ExchangeRateImportService` importiert ECB-Kurse
- `NormalizeCurrencyCode` und `ConvertCurrency` sind im Transformationssystem registriert
- fehlende Unit-Tests dafuer wurden am 2026-04-17 ergaenzt
## Offene Hauptpunkte
Neuer Teststand:
- DE Alphaplan-Fachabgrenzung muss durch Finance/Munir bestaetigt werden.
- IIS/TLS Serverkonfiguration muss durch IT/Serveradmin korrigiert werden.
- IT/ES/UK Finance-Spezialfaelle nur bei konkreter Nachfrage im Detail laden.
- `dotnet test .\TrafagSalesExporter.Tests\TrafagSalesExporter.Tests.csproj --verbosity minimal`
- erfolgreich
- `31/31` Tests gruen
## Volltext Bei Bedarf
Was fuer Waehrungen trotzdem noch offen bleibt:
Die kanonische Detailhistorie liegt hier:
- fachlicher Einsatz der `ConvertCurrency`-Regeln in echten Standortkonfigurationen pruefen
- UI-Flow fuer Wechselkurspflege in `Settings.razor` manuell gegenpruefen
- ECB-Import einmal real ueber die UI bzw. App-Funktion pruefen
- bestaetigen, fuer welche Sichten CHF die Zielwaehrung sein soll
- Management-Cockpit-Rohsicht nur dann auf CHF umstellen, wenn fachlich gewuenscht
```text
docs/raw_md_archive/HISTORY_CANONICAL.md.raw
```
## Architektur-Nachtrag 2026-04-17
Die frueheren Original-Volltexte liegen als Wiederherstellungs-Backup hier:
Nach einer separaten Architekturpruefung wurden die naechsten Schritte neu priorisiert.
```text
docs/raw_md_archive/original_history_raws.zip
```
Wichtig:
- neue Fachfeatures sind aktuell **nicht** der erste Engpass
- zuerst muessen die Architektur-Risiken in Initialisierung, Config-Import und UI-Service-Schnitt bereinigt werden
### Neue Top-Prioritaeten
#### 1. `DatabaseInitializationService` absichern
Prio sehr hoch.
Gruende:
- Startlogik enthaelt manuelle Schema-Migrationen
- FK-Reparaturen laufen produktiv beim App-Start
- dort wurde ein konkretes Risiko fuer verschobene Spaltenwerte beim `Sites_old`-Kopierpfad erkannt
Vor weiterer Fachentwicklung:
- Initialisierungspfad genau pruefen
- SQL-Kopierlogik validieren
- moeglichst Richtung versionierte Migrationen bewegen
#### 2. `ConfigTransferService.ImportJsonAsync` neu denken
Prio sehr hoch.
Aktuelles Problem:
- Import loescht sehr viel und baut danach stueckweise neu auf
- nicht atomar
- potenziell teilzerstoerter Zustand bei Fehlern
- `CentralSalesRecords` werden mitimportiert/mitgeloescht, obwohl sie eher Laufzeitdaten als Konfiguration sind
Ziel:
- atomarer Import
- saubere Trennung zwischen Konfiguration und Betriebsdaten
#### 3. Razor-Seiten entlasten
Prio hoch.
Betroffen vor allem:
- `Components/Pages/Settings.razor`
- `Components/Pages/Standorte.razor`
Ziel:
- DB- und Fachlogik aus UI-Code in Services / Application-Layer verschieben
- Seiten nur noch fuer Interaktion und Formularzustand
#### 4. Konsolidierten Export semantisch klaeren
Prio mittel.
Offene Frage:
- zentrale Datei aus laufendem Snapshot
oder
- zentrale Datei immer aus `CentralSalesRecords`
Aktuell ist die Verantwortung unscharf.
#### 5. Reporting verallgemeinern
Prio mittel.
Erst nach den Infrastrukturthemen:
- hartcodierte Jahreslogik im Cockpit entfernen
- fachlich entscheiden, ob und wo CHF-Rohsicht gebraucht wird
### Praktische Reihenfolge fuer den naechsten Wiedereinstieg
Wenn nach erneutem Absturz oder Kontextverlust weitergemacht wird:
1. `HANDOFF_2026-04-15.md` lesen, speziell die Architekturpruefung vom 2026-04-17
2. `DatabaseInitializationService` als ersten Risikoblock ansehen
3. `ConfigTransferService.ImportJsonAsync` als zweiten Risikoblock ansehen
4. erst danach wieder an Cockpit / CHF / weitere Fachfeatures gehen
## Nachtrag HANA-/Standort-Workflow 2026-04-17
Der doppelte HANA-Workflow wurde inzwischen bereits bereinigt.
Neuer Stand:
- oben zentrale HANA-Konfiguration pro Quellsystem `BI1` / `SAGE`
- unten im Standort keine eigene wirksame Voll-HANA-Konfiguration mehr
- HANA-basierte Standorte ziehen ihre technische Verbindung aus der zentralen Quellsystem-Konfiguration
- Standort bleibt fuer fachliche Daten und optionale Credential-Overrides zustaendig
- die frueher doppelte HANA-UI im Standortdialog ist inzwischen auch sichtbar entfernt
- der Verbindungstest in `Settings.razor` prueft und meldet jetzt die zentrale HANA-Verbindung klar
### Was dazu noch praktisch geprueft werden sollte
- `Standorte`-Seite im UI manuell durchklicken
- pruefen, ob `BI1`- und `SAGE`-Standort beim Speichern sauber auf die zentrale HANA-Konfiguration zeigen
- pruefen, ob Aenderung oben bei zentraler HANA-Konfiguration in nachfolgenden Exporten wirklich greift
### Anschlussarbeiten
- `ConfigTransferService` spaeter auf das neue zentrale HANA-Modell fachlich nachziehen und kritisch pruefen
- `DatabaseInitializationService` weiter konsolidieren, damit die Zuordnung alter HANA-Daten langfristig robuster wird
## Nachtrag Quellsystem-Verwaltung 2026-04-17
Die bisher hart codierten Quellsystem-Listen wurden ersetzt.
Neuer Stand:
- `SourceSystemDefinition` ist jetzt die zentrale Stammdatenquelle fuer Quellsysteme
- `Settings.razor` hat jetzt eine GUI zur Pflege von Quellsystemen
- `Standorte.razor` zieht seine Quellsystem-Auswahl aus diesen Stammdaten
- `Transformations.razor` zieht die Systemauswahl ebenfalls aus diesen Stammdaten
- zentrale Credentials haengen jetzt am Quellsystem selbst
- HANA-Zentralverbindungen werden nur noch fuer Quellsysteme mit Anschlussart `HANA` gezeigt
- alte zentrale Credential-Felder in `ExportSettings` sind aus dem aktiven Codepfad entfernt
- `ExportSettings` wird beim Start auch schematisch auf das neue Feldset bereinigt
- HANA speichert zentral keine eigenen Credentials mehr; dort bleiben nur technische Verbindungsdaten
- `HanaServer.Username` / `Password` sind nur noch Laufzeitfelder und nicht mehr im EF-Schema gemappt
- SAP Service URL wird jetzt zentral im Quellsystem gepflegt; der Standort haelt nur noch ein optionales Override
- Quellsysteme werden jetzt per Dialog bearbeitet statt nur ueber Inline-Tabellenfelder
### Was dazu noch praktisch geprueft werden sollte
- in `Settings` ein neues Quellsystem per GUI anlegen
- pruefen, ob es danach in `Standorte` und `Transformations` sofort auswählbar ist
- pruefen, ob deaktivierte Quellsysteme in neuen Standort-/Regelanlagen nicht mehr normal angeboten werden
- pruefen, ob Aenderung der Anschlussart von `HANA` auf `SAP_GATEWAY` oder `MANUAL_EXCEL` fachlich sauber wirkt
- pruefen, ob bestehende BI1/SAGE/SAP-Daten nach Startmigration korrekt in `SourceSystemDefinitions` stehen
- pruefen, ob Konfiguration-Export/Import ohne die alten Credential-Felder sauber mit `SourceSystemDefinitions` arbeitet
- pruefen, ob zentrale SAP Service URL ohne Override sauber fuer Refresh, Export und Dashboard greift
- pruefen, ob SAP Service URL Override am Standort die zentrale URL erwartungsgemaess uebersteuert
## Nachtrag 2026-04-16
Seit dem letzten Stand kamen mehrere groessere Erweiterungen dazu. Die offenen Punkte unten muessen deshalb im neuen Kontext gelesen werden.
## 0. Neuer Ist-Stand
Zusaetzlich zum alten Stand ist jetzt vorhanden:
- manueller Standort-Import ueber `MANUAL_EXCEL`
- Dashboard mit `Alle exportieren`, `Zentrale Datei neu erzeugen` und zentralem `Excel oeffnen`
- Roh-Auswertung im `Management Cockpit` direkt aus `CentralSalesRecords`
- erweitertes Transformationssystem mit `Value`- und `Record`-Regeln
- HANA-Schema-Lookup im Standortdialog
- Testprojekt mit aktuell 18 gruenden Tests
## 1. Status
Der Export geht jetzt wieder durch.
Die zuletzt gefundene Hauptursache war nicht mehr ein reiner SQLite-Lock beim Batch-Insert, sondern ein kaputter FK-Schemazustand in der bestehenden DB:
- SQLite referenzierte in mindestens einer Tabelle noch `main.Sites_old`
- dadurch scheiterte `SaveChangesAsync()` beim Schreiben z. B. in `AppEventLogs` oder `ExportLogs`
- sichtbarer Effekt: Export blieb nach `Zentrale Tabelle: ... Datensaetze gespeichert.` haengen
## 2. Umgesetzter Fix
Umgesetzt wurde:
- Dashboard-Live-Status liest waehrend laufendem Export nicht mehr staendig aus `AppEventLogs`, sondern nutzt den In-Memory-Status des `ExportOrchestrationService`
- SQLite `Default Timeout` in `Program.cs` auf `60` erhoeht
- `CentralSalesRecordService` setzt nach den Batches explizit `Zentrale Tabelle aktualisiert`
- `DatabaseInitializationService` repariert beim App-Start automatisch Tabellen, deren FK-SQL noch `Sites_old` referenziert
Betroffene Dateien:
- `Program.cs`
- `Components/Pages/Dashboard.razor`
- `Services/CentralSalesRecordService.cs`
- `Services/DatabaseInitializationService.cs`
## 3. Was noch getestet werden sollte
Kurz gegenpruefen:
- Export eines Standorts erneut
- `Excel oeffnen` nach erfolgreichem Export
- `Export erfolgreich` inkl. `Pfad=...`
- Dashboard-Live-Status setzt sich nach Abschluss sauber zurueck
- `Alle exportieren`
- `Zentrale Datei neu erzeugen`
- zentrale Datei im Dashboard oeffnen
## 3a. Manuellen Excel-Import pruefen
Zu testen:
- Standort auf `MANUAL_EXCEL` stellen
- Excel im Standort hochladen
- Standort exportieren
- pruefen, ob `CentralSalesRecords` fuer diesen Standort ersetzt wurden
- pruefen, ob der zentrale Export den Standort korrekt enthaelt
Dateien:
- `Components/Pages/Standorte.razor`
- `Services/ManualExcelImportService.cs`
- `Services/SiteExportService.cs`
## 3b. HANA-Schema-Lookup pruefen
Zu testen:
- bei `BI1`-Standort `Schemas laden`
- bei `SAGE`-Standort `Schemas laden`
- wird ein plausibles B1-Schema angeboten?
- funktioniert danach Export ohne manuelle Schema-Eingabe?
- zeigt England / Spezialstandort jetzt schneller, wenn Schema oder Rechte nicht passen?
Dateien:
- `Components/Pages/Standorte.razor`
- `Services/HanaQueryService.cs`
## 4. Falls wieder ein Fehler auftritt
In dieser Reihenfolge pruefen:
1. Exakte Fehlermeldung aus `AppEventLogs` bzw. Console notieren
2. Pruefen, ob die Reparaturlogik beim Start gelaufen ist
3. Pruefen, ob noch weitere Tabellen mit veralteter FK-Referenz existieren
4. Erst danach wieder am Batch-/Commit-Pfad der zentralen Speicherung arbeiten
## 5. SAP-Funktionalitaet kurz gegenpruefen
Zu testen:
- `Quellen refreshen`
- `Felder aus Quellen laden`
- `Auto-Match`
- SAP-Export eines Standorts
Dateien:
- `Components/Pages/Standorte.razor`
- `Services/SapGatewayService.cs`
- `Services/SapCompositionService.cs`
## 6. Management Cockpit pruefen
Zu testen:
- vorhandene Excel-Datei auswaehlbar
- Analyse laeuft
- Kennzahlen plausibel
- Roh-Auswertung aus `CentralSalesRecords` laeuft
- Jahr/Monat-Filter funktionieren
- Summen nach Quelle / Land plausibel
Dateien:
- `Components/Pages/ManagementCockpit.razor`
- `Services/ManagementCockpitService.cs`
## 6a. Fachlich bewusst noch offen
Noch nicht final umsetzen ohne Rueckmeldung Fachseite:
- Intercompany-Filter
- fachliche Nutzung der CHF-Umrechnung in Cockpit / Reports
- Budgetvergleich
- Gruppenlogik
- Spartenlogik
- Margenlogik
Diese Punkte sollen spaeter moeglichst dynamisch auf dem neuen Transformations-/Mapping-Ansatz aufsetzen, aber aktuell nicht hart geraten werden.
## 6b. Naechste sinnvolle Testkandidaten
Wenn weiter in Tests investiert wird, sind die naechsten Kandidaten:
- `ExportOrchestrationService`
- spaeter End-to-End-Tests fuer den Wechselkurs-/Transformationspfad
- spaeter evtl. SQLite-nahe Integrationstests fuer `DatabaseInitializationService`
Aktueller Teststatus:
- `dotnet test .\TrafagSalesExporter.Tests\TrafagSalesExporter.Tests.csproj --verbosity minimal`
- erfolgreich
- `31/31` Tests gruen
## 7. Referenzdatei
Fuer den vollstaendigen Kontext zuerst lesen:
- `HANDOFF_2026-04-15.md`
## 8. Letzte bereinigte UI-Irritation
Stand 2026-04-17:
- In `Standorte` wurde die obere Box auf `Zentrale HANA-Technik` geklaert.
- Dort gibt es keinen `Server hinzufuegen`-Pfad mehr.
- Grund: zentrale HANA-Eintraege werden aus `Quellsystemen` mit Anschlussart `HANA` abgeleitet.
- `SAP` gehoert fachlich nicht in diese Box, sondern in `Settings -> Quellsysteme`.
Wichtig fuer den naechsten Wiedereinstieg:
- Wenn ein Benutzer fragt `wo ist SAP?`, ist die richtige Antwort: nicht in der HANA-Box, sondern in der zentralen Quellsystem-Verwaltung.
- Wenn ein HANA-System oben fehlt, zuerst `Settings -> Quellsysteme` pruefen und dort Anschlussart `HANA` setzen.
## 9. Config-Transfer erneut geprueft
Stand 2026-04-17:
- Der aktuelle Config-Import/-Export passt zum neuen Datenmodell.
- Zentral verwaltete Quellsysteme, SAP-Zentral-URL, HANA-Technik ohne HANA-Credentials und Standort-Overrides werden korrekt im Transferformat abgebildet.
- Die vorhandenen `ConfigTransferServiceTests` bestaetigen den aktuellen Rundlauf.
Fuer den naechsten Wiedereinstieg wichtig:
- Das aktuelle Format ist fuer heutige Exporte konsistent.
- `ImportJsonAsync` ist aber weiterhin nicht atomar und loescht zuerst produktive Konfiguration.
- Zusaetzlich gibt es ein Altformat-Risiko:
- aeltere JSONs mit `SourceSystemDefinitions`, aber ohne `ConnectionKind`, koennen wegen DTO-Default falsch als `HANA` interpretiert werden.
Naechste saubere Haertung fuer dieses Thema:
- Config-Import transaktional machen
- Legacy-Fallback fuer fehlendes `ConnectionKind` einbauen
Nur laden, wenn alte Review-Historie, genaue erledigte Punkte oder fruehere Priorisierung benoetigt werden.
+129
View File
@@ -1,16 +1,56 @@
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Server.IISIntegration;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
using MudBlazor.Services;
using TrafagSalesExporter.Data;
using TrafagSalesExporter.Models;
using TrafagSalesExporter.Security;
using TrafagSalesExporter.Services;
using TrafagSalesExporter.Services.DataSources;
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()
.AddInteractiveServerComponents();
builder.Services.AddCascadingAuthenticationState();
builder.Services.AddHttpContextAccessor();
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.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.Configure<AdminAccessOptions>(builder.Configuration.GetSection(AdminAccessOptions.SectionName));
builder.Services.Configure<LandingPageOptions>(builder.Configuration.GetSection(LandingPageOptions.SectionName));
builder.Services.AddDbContextFactory<AppDbContext>(options =>
options.UseSqlite("Data Source=trafag_exporter.db;Default Timeout=60"));
@@ -20,6 +60,7 @@ builder.Services.AddSingleton<IHanaQueryService, HanaQueryService>();
builder.Services.AddSingleton<IExcelExportService, ExcelExportService>();
builder.Services.AddSingleton<ISharePointUploadService, SharePointUploadService>();
builder.Services.AddSingleton<ISapGatewayService, SapGatewayService>();
builder.Services.AddSingleton<IMappedSalesRecordComposer, MappedSalesRecordComposer>();
builder.Services.AddSingleton<ISapCompositionService, SapCompositionService>();
builder.Services.AddSingleton<ITransformationStrategy, CopyTransformationStrategy>();
builder.Services.AddSingleton<ITransformationStrategy, UppercaseTransformationStrategy>();
@@ -37,15 +78,22 @@ builder.Services.AddSingleton<ITransformationCatalog, TransformationCatalog>();
builder.Services.AddSingleton<IRecordTransformationService, RecordTransformationService>();
builder.Services.AddSingleton<IAppEventLogService, AppEventLogService>();
builder.Services.AddSingleton<IManagementCockpitService, ManagementCockpitService>();
builder.Services.AddSingleton<IHrKpiService, HrKpiService>();
builder.Services.AddSingleton<IManualExcelImportService, ManualExcelImportService>();
builder.Services.AddSingleton<IExportAuditCsvService, ExportAuditCsvService>();
builder.Services.AddSingleton<IConsolidatedExportService, ConsolidatedExportService>();
builder.Services.AddSingleton<IExportLogService, ExportLogService>();
builder.Services.AddSingleton<ICentralSalesRecordService, CentralSalesRecordService>();
builder.Services.AddSingleton<ICentralSalesDataProvider, CentralSalesDataProvider>();
builder.Services.AddSingleton<IConfigTransferService, ConfigTransferService>();
builder.Services.AddSingleton<IFinanceReconciliationService, FinanceReconciliationService>();
builder.Services.AddSingleton<IDatabaseSchemaMaintenanceService, DatabaseSchemaMaintenanceService>();
builder.Services.AddSingleton<IDatabaseSeedService, DatabaseSeedService>();
builder.Services.AddSingleton<IDatabaseInitializationService, DatabaseInitializationService>();
builder.Services.AddSingleton<IUiTextService, UiTextService>();
builder.Services.AddSingleton<IAccessSessionTracker, AccessSessionTracker>();
builder.Services.AddSingleton<ILandingPageSettingsService, LandingPageSettingsService>();
builder.Services.AddSingleton<INavigationMenuService, NavigationMenuService>();
// Datenquellen-Adapter (Strategy per ConnectionKind).
builder.Services.AddSingleton<IDataSourceAdapter, HanaDataSourceAdapter>();
@@ -67,8 +115,20 @@ builder.Services.AddScoped<IManagementCockpitPageService, ManagementCockpitPageS
builder.Services.AddScoped<IDashboardPageService, DashboardPageService>();
builder.Services.AddScoped<ILogsPageService, LogsPageService>();
builder.Services.AddScoped<ITransformationsPageService, TransformationsPageService>();
builder.Services.AddScoped<IFinanceRulesPageService, FinanceRulesPageService>();
builder.Services.AddScoped<IPurchasingDataSourcePageService, PurchasingDataSourcePageService>();
builder.Services.AddScoped<IPurchasingDashboardService, PurchasingDashboardService>();
builder.Services.AddScoped<IPurchasingDataRefreshService, PurchasingDataRefreshService>();
builder.Services.AddScoped<IHrKpiAccessService, HrKpiAccessService>();
builder.Services.AddScoped<IFinanceCockpitAccessService, FinanceCockpitAccessService>();
builder.Services.AddScoped<IAdminAccessService, AdminAccessService>();
var app = builder.Build();
var pathBase = app.Configuration["ASPNETCORE_PATHBASE"];
if (!string.IsNullOrWhiteSpace(pathBase))
{
app.UsePathBase(pathBase.Trim());
}
using (var scope = app.Services.CreateScope())
{
@@ -82,9 +142,78 @@ if (!app.Environment.IsDevelopment())
}
app.UseStaticFiles();
app.UseAuthentication();
app.UseAuthorization();
app.UseAntiforgery();
app.MapPost("/access/finance", async (HttpContext httpContext, IOptions<FinanceCockpitAccessOptions> options) =>
{
var form = await httpContext.Request.ReadFormAsync();
var settings = options.Value;
var username = form["username"].ToString();
var password = form["password"].ToString();
if (MatchesAccess(settings.Enabled, settings.Username, settings.PasswordHash, settings.Password, username, password))
AccessUnlockCookie.SetUnlocked(httpContext, AccessUnlockCookie.FinanceCookieName, settings.PasswordHash);
return Results.Redirect(ResolveReturnUrl(httpContext, form["returnUrl"].ToString()));
}).DisableAntiforgery();
app.MapPost("/access/admin", async (HttpContext httpContext, IOptions<AdminAccessOptions> options) =>
{
var form = await httpContext.Request.ReadFormAsync();
var settings = options.Value;
var username = form["username"].ToString();
var password = form["password"].ToString();
if (MatchesAccess(settings.Enabled, settings.Username, settings.PasswordHash, settings.Password, username, password))
AccessUnlockCookie.SetUnlocked(httpContext, AccessUnlockCookie.AdminCookieName, settings.PasswordHash);
return Results.Redirect(ResolveReturnUrl(httpContext, form["returnUrl"].ToString()));
}).DisableAntiforgery();
app.MapPost("/access/hr", async (HttpContext httpContext, IOptions<HrKpiAccessOptions> options) =>
{
var form = await httpContext.Request.ReadFormAsync();
var settings = options.Value;
var username = form["username"].ToString();
var password = form["password"].ToString();
if (MatchesAccess(settings.Enabled, settings.Username, settings.PasswordHash, settings.Password, username, password))
AccessUnlockCookie.SetUnlocked(httpContext, AccessUnlockCookie.HrCookieName, settings.PasswordHash);
return Results.Redirect(ResolveReturnUrl(httpContext, form["returnUrl"].ToString()));
}).DisableAntiforgery();
app.MapRazorComponents<TrafagSalesExporter.Components.App>()
.AddInteractiveServerRenderMode();
app.Run();
static bool MatchesAccess(bool enabled, string configuredUsername, string configuredHash, string configuredPassword, string username, string password)
{
if (!enabled)
return true;
if (string.IsNullOrWhiteSpace(username) ||
string.IsNullOrEmpty(password) ||
!string.Equals(username.Trim(), configuredUsername.Trim(), StringComparison.Ordinal))
{
return false;
}
return !string.IsNullOrWhiteSpace(configuredHash)
? string.Equals(AccessPasswordSettingsWriter.HashPassword(password), configuredHash.Trim(), StringComparison.Ordinal)
: string.Equals(password, configuredPassword, StringComparison.Ordinal);
}
static string ResolveReturnUrl(HttpContext httpContext, string returnUrl)
{
if (Uri.TryCreate(returnUrl, UriKind.Absolute, out var absolute) &&
string.Equals(absolute.Host, httpContext.Request.Host.Host, StringComparison.OrdinalIgnoreCase))
{
return absolute.PathAndQuery;
}
if (Uri.TryCreate(returnUrl, UriKind.Relative, out _))
return returnUrl;
return $"{httpContext.Request.PathBase}/";
}
@@ -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>
@@ -6,7 +6,7 @@
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
},
"applicationUrl": "https://localhost:55415;http://localhost:55416"
"applicationUrl": "https://localhost:55415;http://localhost:55416;http://0.0.0.0:5000"
}
}
}
}
+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,241 @@
# Sage Spain Export
Stand: 2026-06-01
Nachtrag 2026-06-01:
- Finance/Andreas bestaetigt: Spanien hat keine echte Ist-Abweichung.
- Der Wert `3'082'320.18 EUR` ist fachlich plausibel und wird als ES-Referenz 2025 verwendet.
- Der alte Sollwert `3'102'333.61 EUR` war ein Referenz-/Excel-Fehler.
- Die historischen Abschnitte unten dokumentieren den frueheren Analysepfad und sind nicht mehr als aktueller ES-Status zu lesen.
## 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
- Korrigierte Referenz: `3'082'320.18`
- Differenz: `0.00`
- Status FinanceProbe: OK, sofern die korrigierte Referenz geladen ist
- Finale Aussage: technisch importierbar und laut Sitzung fachlich plausibel; alter Sollwert war falsch.
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; aktueller Paketordner im Repo: `SageSpainExportPackage/SageSpainFinalExportPackage/`.
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,258 @@
param(
[string]$ServerInstance = "localhost",
[string]$Database = "Sage",
[ValidateSet("Full", "Range")]
[string]$ExportMode = "Full",
[ValidateSet("InvoiceDate", "LineRegistrationDate")]
[string]$DateFilter = "InvoiceDate",
[int]$Year = 2025,
[datetime]$FromDate = "2025-01-01",
[datetime]$ToDate = "2026-01-01",
[string]$OutputDirectory = (Join-Path $env:USERPROFILE "Desktop"),
[string]$OutputFileName = ""
)
$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
}
}
if ($ExportMode -eq "Full") {
$FromDate = [datetime]::new($Year, 1, 1)
$ToDate = $FromDate.AddYears(1)
}
else {
if (-not $PSBoundParameters.ContainsKey("ToDate")) {
throw "Range export requires -ToDate. Example: -ExportMode Range -FromDate '2025-05-01' -ToDate '2025-06-01'"
}
}
if ($ToDate.Date -le $FromDate.Date) {
throw "ToDate must be later than FromDate. FromDate=$($FromDate.ToString("yyyy-MM-dd")), ToDate=$($ToDate.ToString("yyyy-MM-dd"))"
}
$timestamp = Get-Date -Format "yyyyMMdd_HHmmss"
$runDirectory = Join-Path $OutputDirectory "Sage_Spain_Sales_Export_$timestamp"
New-Item -ItemType Directory -Path $runDirectory -Force | Out-Null
if ([string]::IsNullOrWhiteSpace($OutputFileName)) {
$fromToken = $FromDate.ToString("yyyyMMdd")
$toToken = $ToDate.Date.AddDays(-1).ToString("yyyyMMdd")
$kindToken = $ExportMode.ToLowerInvariant()
$OutputFileName = "Spain_Sales_${kindToken}_${fromToken}_to_${toToken}.csv"
}
$csvPath = Join-Path $runDirectory $OutputFileName
$summaryPath = Join-Path $runDirectory ([System.IO.Path]::GetFileNameWithoutExtension($OutputFileName) + "_summary.txt")
$datePredicate = if ($DateFilter -eq "LineRegistrationDate") {
"COALESCE(l.FechaRegistro, c.FechaFactura) >= @FromDate
AND COALESCE(l.FechaRegistro, c.FechaFactura) < @ToDate"
} else {
"c.FechaFactura >= @FromDate
AND c.FechaFactura < @ToDate"
}
$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 $datePredicate
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
Export mode: $ExportMode
Date filter mode: $DateFilter
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:
$datePredicate
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.
- Full exports use the complete selected year.
- Range exports use the explicit FromDate/ToDate window.
"@ | 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,164 @@
Sage Spain final sales export candidate
======================================
Run on the Spain Sage SQL Server machine.
PowerShell commands:
Set-ExecutionPolicy -Scope Process Bypass
.\Export-SageSpainSalesCsv.ps1
Full export, default year 2025:
.\Export-SageSpainSalesCsv.ps1 -ExportMode Full -Year 2025
Range export, explicit window:
.\Export-SageSpainSalesCsv.ps1 -ExportMode Range -FromDate "2025-05-01" -ToDate "2025-06-01"
Range export by registration date, useful for new/changed records registered in a period:
.\Export-SageSpainSalesCsv.ps1 -ExportMode Range -DateFilter LineRegistrationDate -FromDate "2025-05-01" -ToDate "2025-06-01"
Output folder on Desktop:
Sage_Spain_Sales_Export_YYYYMMDD_HHMMSS
Files created:
- Spain_Sales_full_YYYY0101_to_YYYY1231.csv for full export
- Spain_Sales_range_YYYYMMDD_to_YYYYMMDD.csv for range export
- Matching *_summary.txt file
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
- Full export date filter: CabeceraAlbaranCliente.FechaFactura from YYYY-01-01 to next YYYY-01-01
- Range export date filter: explicit FromDate/ToDate
- DateFilter InvoiceDate uses CabeceraAlbaranCliente.FechaFactura
- DateFilter LineRegistrationDate uses LineasAlbaranCliente.FechaRegistro, with fallback to FechaFactura
- Sales value: LineasAlbaranCliente.ImporteNeto
If the SQL instance or database name differs:
.\Export-SageSpainSalesCsv.ps1 -ServerInstance "localhost" -Database "Sage" -ExportMode Full -Year 2025
Automatic upload to SharePoint with rclone
==========================================
Target SharePoint folder:
https://trafagag.sharepoint.com/sites/WorldwideBIPlatform/Shared%20Documents/Import/Finance/Spanien
Decoded folder path:
Shared Documents/Import/Finance/Spanien
Recommended rclone setup on the Spain Sage server:
1. Install rclone.
2. Run:
rclone config
3. Create a new remote for the SharePoint document library.
Recommended remote name:
trafag-bi
The remote should point to the document library root "Shared Documents" of:
https://trafagag.sharepoint.com/sites/WorldwideBIPlatform
Then this target path is used by the wrapper script:
trafag-bi:Import/Finance/Spanien
Test rclone:
rclone lsd trafag-bi:
rclone lsd trafag-bi:"Import/Finance"
rclone lsd trafag-bi:"Import/Finance/Spanien"
Run range export and upload with default window last 7 days until today:
.\Run-SpainExportAndUpload.ps1
Explicit range:
.\Run-SpainExportAndUpload.ps1 -ExportMode Range -DateFilter LineRegistrationDate -FromDate "2026-06-02" -ToDate "2026-06-03"
Simple starter script with default window last 7 days until today:
.\Start-SpainRangeExportAndUpload.ps1
Same starter script with another range:
.\Start-SpainRangeExportAndUpload.ps1 -FromDate "2026-06-01" -ToDate "2026-06-04"
Single-file all-in-one range export and upload.
This file does not require Export-SageSpainSalesCsv.ps1 or Run-SpainExportAndUpload.ps1:
.\Run-SpainRangeExportAndUpload-AllInOne.ps1
Default date window:
- FromDate = today - 7 days
- ToDate = today
- ToDate is exclusive
Override the all-in-one default date window:
.\Run-SpainRangeExportAndUpload-AllInOne.ps1 -FromDate "2026-06-01" -ToDate "2026-06-04"
The all-in-one script checks/creates the SharePoint folder before export, uploads the generated CSV and summary, and verifies that the uploaded files are listed in SharePoint.
rclone.exe lookup for the all-in-one script:
- explicit -RcloneExe parameter
- rclone.exe in the same folder as the script
- C:\Tools\rclone.exe
- C:\Tools\rclone\rclone.exe
- C:\Tools\rclone\rclone\rclone.exe
- rclone from PATH
Known rclone upload issue:
If the log says:
CRITICAL: Can't set -v and --log-level
then the server is running an old script copy that still contains:
--verbose `
Remove that line from the rclone copy block or replace the file with the current Run-SpainRangeExportAndUpload-AllInOne.ps1.
Full export and upload:
.\Run-SpainExportAndUpload.ps1 -ExportMode Full -Year 2025
If the rclone remote has another name:
.\Run-SpainExportAndUpload.ps1 -RcloneRemote "YOUR_REMOTE_NAME"
If rclone.exe is not in PATH:
.\Run-SpainExportAndUpload.ps1 -RcloneExe "C:\Tools\rclone\rclone.exe"
Suggested Windows Task Scheduler command:
powershell.exe -NoProfile -ExecutionPolicy Bypass -File C:\Trafag\SageSpain\Run-SpainExportAndUpload.ps1
Important:
- The export script only reads SQL Server data.
- rclone only uploads the generated CSV and matching summary file.
- For daily deltas use ExportMode Range with DateFilter LineRegistrationDate.
- ToDate is exclusive.
@@ -0,0 +1,107 @@
param(
[string]$ServerInstance = "localhost",
[string]$Database = "Sage",
[ValidateSet("Full", "Range")]
[string]$ExportMode = "Range",
[ValidateSet("InvoiceDate", "LineRegistrationDate")]
[string]$DateFilter = "LineRegistrationDate",
[int]$Year = 2025,
[datetime]$FromDate = (Get-Date).Date.AddDays(-7),
[datetime]$ToDate = (Get-Date).Date,
[string]$BaseDirectory = "C:\Trafag\SageSpain",
[string]$RcloneExe = "rclone",
[string]$RcloneRemote = "trafag-bi",
[string]$RcloneTarget = "Import/Finance/Spanien"
)
$ErrorActionPreference = "Stop"
$scriptDirectory = Split-Path -Parent $MyInvocation.MyCommand.Path
$exportScript = Join-Path $scriptDirectory "Export-SageSpainSalesCsv.ps1"
if (-not (Test-Path -LiteralPath $exportScript)) {
throw "Export script not found: $exportScript"
}
$outputDirectory = Join-Path $BaseDirectory "out"
$logDirectory = Join-Path $BaseDirectory "logs"
New-Item -ItemType Directory -Force -Path $outputDirectory, $logDirectory | Out-Null
if (-not (Get-Command $RcloneExe -ErrorAction SilentlyContinue)) {
throw "rclone executable not found: $RcloneExe"
}
$target = "${RcloneRemote}:$RcloneTarget"
$rcloneLog = Join-Path $logDirectory ("rclone-spain-" + (Get-Date -Format "yyyyMMdd") + ".log")
Write-Host "Checking SharePoint target with rclone: $target"
& $RcloneExe mkdir $target --log-file $rcloneLog --log-level INFO
if ($LASTEXITCODE -ne 0) {
throw "Could not create/check SharePoint target '$target'. rclone exit code $LASTEXITCODE. Log: $rcloneLog"
}
& $RcloneExe lsf $target --max-depth 1 --log-file $rcloneLog --log-level INFO | Out-Null
if ($LASTEXITCODE -ne 0) {
throw "SharePoint target '$target' is not reachable. rclone exit code $LASTEXITCODE. Log: $rcloneLog"
}
$exportArgs = @(
"-ServerInstance", $ServerInstance,
"-Database", $Database,
"-ExportMode", $ExportMode,
"-DateFilter", $DateFilter,
"-Year", $Year,
"-OutputDirectory", $outputDirectory
)
if ($ExportMode -eq "Range") {
$exportArgs += @(
"-FromDate", $FromDate.ToString("yyyy-MM-dd"),
"-ToDate", $ToDate.ToString("yyyy-MM-dd")
)
}
& $exportScript @exportArgs
if ($LASTEXITCODE -ne 0) {
throw "Spain Sage export failed with exit code $LASTEXITCODE"
}
$latestRun = Get-ChildItem -LiteralPath $outputDirectory -Directory |
Sort-Object LastWriteTime -Descending |
Select-Object -First 1
if ($null -eq $latestRun) {
throw "No export run directory found in $outputDirectory"
}
$filesToUpload = Get-ChildItem -LiteralPath $latestRun.FullName -File |
Where-Object { $_.Name -like "*.csv" -or $_.Name -like "*_summary.txt" }
if ($filesToUpload.Count -eq 0) {
throw "No CSV or summary files found for upload in $($latestRun.FullName)"
}
Write-Host "Uploading $($filesToUpload.Count) file(s) to SharePoint target: $target"
& $RcloneExe copy $latestRun.FullName $target `
--include "*.csv" `
--include "*_summary.txt" `
--verbose `
--log-file $rcloneLog `
--log-level INFO
if ($LASTEXITCODE -ne 0) {
throw "rclone upload failed with exit code $LASTEXITCODE"
}
foreach ($file in $filesToUpload) {
$uploadedMatch = & $RcloneExe lsf $target --files-only --include $file.Name --log-file $rcloneLog --log-level INFO
if ($LASTEXITCODE -ne 0) {
throw "Could not verify uploaded file '$($file.Name)' in '$target'. rclone exit code $LASTEXITCODE. Log: $rcloneLog"
}
if (-not ($uploadedMatch | Where-Object { $_ -eq $file.Name })) {
throw "Upload verification failed. File '$($file.Name)' was not listed in '$target'. Log: $rcloneLog"
}
}
Write-Host "Spain export and upload finished."
Write-Host "Local export: $($latestRun.FullName)"
Write-Host "SharePoint target: $target"
Write-Host "rclone log: $rcloneLog"
@@ -0,0 +1,343 @@
param(
[string]$ServerInstance = "localhost",
[string]$Database = "Sage",
[ValidateSet("InvoiceDate", "LineRegistrationDate")]
[string]$DateFilter = "LineRegistrationDate",
[datetime]$FromDate = (Get-Date).Date.AddDays(-7),
[datetime]$ToDate = (Get-Date).Date,
[string]$BaseDirectory = "C:\Trafag\SageSpain",
[string]$RcloneExe = "C:\Tools\rclone.exe",
[string]$RcloneRemote = "trafag-bi",
[string]$RcloneTarget = "Import/Finance/Spanien"
)
$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
}
}
function Resolve-RcloneExecutable {
param([string]$ConfiguredPath)
$scriptDirectory = Split-Path -Parent $MyInvocation.MyCommand.Path
$candidates = @(
$ConfiguredPath,
(Join-Path $scriptDirectory "rclone.exe"),
"C:\Tools\rclone.exe",
"C:\Tools\rclone\rclone.exe",
"C:\Tools\rclone\rclone\rclone.exe",
"rclone"
) | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }
foreach ($candidate in $candidates) {
if (Test-Path -LiteralPath $candidate) {
return (Resolve-Path -LiteralPath $candidate).Path
}
$command = Get-Command $candidate -ErrorAction SilentlyContinue
if ($null -ne $command) {
return $command.Source
}
}
throw "rclone executable not found. Checked: $($candidates -join ', ')"
}
function Throw-RcloneError {
param(
[string]$Message,
[string]$LogPath
)
if (Test-Path -LiteralPath $LogPath) {
Write-Host ""
Write-Host "Last rclone log lines:"
Get-Content -LiteralPath $LogPath -Tail 80 | ForEach-Object { Write-Host $_ }
}
throw "$Message Log: $LogPath"
}
if ($ToDate.Date -le $FromDate.Date) {
throw "ToDate must be later than FromDate. FromDate=$($FromDate.ToString("yyyy-MM-dd")), ToDate=$($ToDate.ToString("yyyy-MM-dd"))"
}
$RcloneExe = Resolve-RcloneExecutable -ConfiguredPath $RcloneExe
Write-Host "Using rclone: $RcloneExe"
$outputDirectory = Join-Path $BaseDirectory "out"
$logDirectory = Join-Path $BaseDirectory "logs"
New-Item -ItemType Directory -Force -Path $outputDirectory, $logDirectory | Out-Null
$target = "${RcloneRemote}:$RcloneTarget"
$rcloneLog = Join-Path $logDirectory ("rclone-spain-" + (Get-Date -Format "yyyyMMdd") + ".log")
Write-Host "Checking SharePoint target with rclone: $target"
& $RcloneExe mkdir $target --log-file $rcloneLog --log-level INFO
if ($LASTEXITCODE -ne 0) {
Throw-RcloneError -Message "Could not create/check SharePoint target '$target'. rclone exit code $LASTEXITCODE." -LogPath $rcloneLog
}
$targetListing = & $RcloneExe lsf $target --max-depth 1 --log-file $rcloneLog --log-level INFO
if ($LASTEXITCODE -ne 0) {
Throw-RcloneError -Message "SharePoint target '$target' is not reachable. rclone exit code $LASTEXITCODE." -LogPath $rcloneLog
}
Write-Host "SharePoint target reachable. Existing items: $(@($targetListing).Count)"
$timestamp = Get-Date -Format "yyyyMMdd_HHmmss"
$runDirectory = Join-Path $outputDirectory "Sage_Spain_Sales_Export_$timestamp"
New-Item -ItemType Directory -Path $runDirectory -Force | Out-Null
$fromToken = $FromDate.ToString("yyyyMMdd")
$toToken = $ToDate.Date.AddDays(-1).ToString("yyyyMMdd")
$outputFileName = "Spain_Sales_range_${fromToken}_to_${toToken}.csv"
$csvPath = Join-Path $runDirectory $outputFileName
$summaryPath = Join-Path $runDirectory ([System.IO.Path]::GetFileNameWithoutExtension($outputFileName) + "_summary.txt")
$datePredicate = if ($DateFilter -eq "LineRegistrationDate") {
"COALESCE(l.FechaRegistro, c.FechaFactura) >= @FromDate
AND COALESCE(l.FechaRegistro, c.FechaFactura) < @ToDate"
} else {
"c.FechaFactura >= @FromDate
AND c.FechaFactura < @ToDate"
}
$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 $datePredicate
ORDER BY
c.FechaFactura,
c.SerieFactura,
c.NumeroFactura,
l.Orden;
"@
Write-Host "Exporting Sage Spain range..."
Write-Host "FromDate: $($FromDate.ToString("yyyy-MM-dd"))"
Write-Host "ToDate: $($ToDate.ToString("yyyy-MM-dd"))"
Write-Host "DateFilter: $DateFilter"
$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
Export mode: Range
Date filter mode: $DateFilter
From date: $($FromDate.ToString("yyyy-MM-dd"))
To date: $($ToDate.ToString("yyyy-MM-dd"))
Output:
$csvPath
Rows:
$($result.Rows)
SalesPriceValue sum:
$($result.SalesPriceValueSum)
SharePoint target:
$target
Source:
dbo.CabeceraAlbaranCliente joined with dbo.LineasAlbaranCliente
Filter:
$datePredicate
Notes:
- ToDate is exclusive.
- Currency is set to EUR.
- SalesPriceValue uses LineasAlbaranCliente.ImporteNeto; 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
$filesToUpload = Get-ChildItem -LiteralPath $runDirectory -File |
Where-Object { $_.Name -like "*.csv" -or $_.Name -like "*_summary.txt" }
if ($filesToUpload.Count -eq 0) {
throw "No CSV or summary files found for upload in $runDirectory"
}
Write-Host "Uploading $($filesToUpload.Count) file(s) to SharePoint target: $target"
& $RcloneExe copy $runDirectory $target `
--include "*.csv" `
--include "*_summary.txt" `
--log-file $rcloneLog `
--log-level INFO
if ($LASTEXITCODE -ne 0) {
Throw-RcloneError -Message "rclone upload failed with exit code $LASTEXITCODE." -LogPath $rcloneLog
}
foreach ($file in $filesToUpload) {
$uploadedMatch = & $RcloneExe lsf $target --files-only --include $file.Name --log-file $rcloneLog --log-level INFO
if ($LASTEXITCODE -ne 0) {
Throw-RcloneError -Message "Could not verify uploaded file '$($file.Name)' in '$target'. rclone exit code $LASTEXITCODE." -LogPath $rcloneLog
}
if (-not ($uploadedMatch | Where-Object { $_ -eq $file.Name })) {
Throw-RcloneError -Message "Upload verification failed. File '$($file.Name)' was not listed in '$target'." -LogPath $rcloneLog
}
}
Write-Host "Spain range export and SharePoint upload finished."
Write-Host "Local export: $runDirectory"
Write-Host "CSV: $csvPath"
Write-Host "Summary: $summaryPath"
Write-Host "Rows: $($result.Rows)"
Write-Host "SalesPriceValue sum: $($result.SalesPriceValueSum)"
Write-Host "SharePoint target: $target"
Write-Host "rclone log: $rcloneLog"
@@ -0,0 +1,46 @@
param(
[datetime]$FromDate = (Get-Date).Date.AddDays(-7),
[datetime]$ToDate = (Get-Date).Date,
[string]$ServerInstance = "localhost",
[string]$Database = "Sage",
[string]$BaseDirectory = "C:\Trafag\SageSpain",
[string]$RcloneExe = "C:\Tools\rclone.exe",
[string]$RcloneRemote = "trafag-bi",
[string]$RcloneTarget = "Import/Finance/Spanien"
)
$ErrorActionPreference = "Stop"
$scriptDirectory = Split-Path -Parent $MyInvocation.MyCommand.Path
$workflowScript = Join-Path $scriptDirectory "Run-SpainExportAndUpload.ps1"
if (-not (Test-Path -LiteralPath $workflowScript)) {
throw "Workflow script not found: $workflowScript"
}
if (-not (Test-Path -LiteralPath $RcloneExe)) {
throw "rclone not found: $RcloneExe"
}
Write-Host "Starting Spain Sage range export and SharePoint upload..."
Write-Host "FromDate: $($FromDate.ToString("yyyy-MM-dd"))"
Write-Host "ToDate: $($ToDate.ToString("yyyy-MM-dd"))"
Write-Host "Target: ${RcloneRemote}:$RcloneTarget"
& $workflowScript `
-ServerInstance $ServerInstance `
-Database $Database `
-ExportMode Range `
-DateFilter LineRegistrationDate `
-FromDate $FromDate `
-ToDate $ToDate `
-BaseDirectory $BaseDirectory `
-RcloneExe $RcloneExe `
-RcloneRemote $RcloneRemote `
-RcloneTarget $RcloneTarget
if ($LASTEXITCODE -ne 0) {
throw "Spain range export and upload failed with exit code $LASTEXITCODE"
}
Write-Host "Finished Spain Sage range export and SharePoint upload."
@@ -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,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,11 @@
namespace TrafagSalesExporter.Security;
public sealed class AdminAccessOptions
{
public const string SectionName = "AdminAccess";
public bool Enabled { get; set; } = true;
public string Username { get; set; } = "admin";
public string PasswordHash { get; set; } = string.Empty;
public string Password { get; set; } = string.Empty;
}
@@ -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,8 @@
namespace TrafagSalesExporter.Security;
public sealed class LandingPageOptions
{
public const string SectionName = "LandingPage";
public bool ShowWalkingLabFigure { get; set; }
}
@@ -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();
}
}
@@ -0,0 +1,40 @@
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Text.Json.Nodes;
namespace TrafagSalesExporter.Services;
internal static class AccessPasswordSettingsWriter
{
private static readonly object FileLock = new();
public static string HashPassword(string password)
=> Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(password)));
public static void SavePasswordHash(string contentRootPath, string sectionName, string passwordHash)
{
var path = Path.Combine(contentRootPath, "appsettings.json");
lock (FileLock)
{
var json = File.Exists(path)
? File.ReadAllText(path, Encoding.UTF8)
: "{}";
var root = JsonNode.Parse(json)?.AsObject() ?? new JsonObject();
var section = root[sectionName] as JsonObject;
if (section is null)
{
section = new JsonObject();
root[sectionName] = section;
}
section["PasswordHash"] = passwordHash;
section["Password"] = string.Empty;
var options = new JsonSerializerOptions { WriteIndented = true };
File.WriteAllText(path, root.ToJsonString(options), new UTF8Encoding(false));
}
}
}
@@ -0,0 +1,54 @@
using System.Collections.Concurrent;
namespace TrafagSalesExporter.Services;
public interface IAccessSessionTracker
{
IReadOnlyList<AccessSessionSnapshot> GetActiveSessions();
void Register(string sessionId, string area, string username, string? remoteAddress);
void Touch(string sessionId);
void Unregister(string sessionId);
}
public sealed class AccessSessionTracker : IAccessSessionTracker
{
private readonly ConcurrentDictionary<string, AccessSessionSnapshot> _sessions = new(StringComparer.OrdinalIgnoreCase);
public IReadOnlyList<AccessSessionSnapshot> GetActiveSessions()
=> _sessions.Values
.OrderByDescending(session => session.LastSeenAt)
.ToList();
public void Register(string sessionId, string area, string username, string? remoteAddress)
{
var now = DateTimeOffset.Now;
_sessions[sessionId] = new AccessSessionSnapshot(
sessionId,
area,
username,
string.IsNullOrWhiteSpace(remoteAddress) ? "unbekannt" : remoteAddress,
now,
now);
}
public void Touch(string sessionId)
{
if (!_sessions.TryGetValue(sessionId, out var session))
return;
_sessions[sessionId] = session with { LastSeenAt = DateTimeOffset.Now };
}
public void Unregister(string sessionId)
{
_sessions.TryRemove(sessionId, out _);
}
}
public sealed record AccessSessionSnapshot(
string SessionId,
string Area,
string Username,
string RemoteAddress,
DateTimeOffset StartedAt,
DateTimeOffset LastSeenAt);
@@ -0,0 +1,44 @@
using System.Security.Cryptography;
using System.Text;
namespace TrafagSalesExporter.Services;
internal static class AccessUnlockCookie
{
public const string FinanceCookieName = "TrafagFinanceUnlocked";
public const string AdminCookieName = "TrafagAdminUnlocked";
public const string HrCookieName = "TrafagHrUnlocked";
public static bool IsUnlocked(HttpContext? httpContext, string cookieName, string passwordHash)
{
if (httpContext is null ||
string.IsNullOrWhiteSpace(passwordHash) ||
!httpContext.Request.Cookies.TryGetValue(cookieName, out var value))
{
return false;
}
return CryptographicOperations.FixedTimeEquals(
Encoding.UTF8.GetBytes(value),
Encoding.UTF8.GetBytes(CreateValue(cookieName, passwordHash)));
}
public static void SetUnlocked(HttpContext httpContext, string cookieName, string passwordHash)
{
httpContext.Response.Cookies.Append(cookieName, CreateValue(cookieName, passwordHash), new CookieOptions
{
HttpOnly = true,
IsEssential = true,
SameSite = SameSiteMode.Strict,
Secure = httpContext.Request.IsHttps,
Path = string.IsNullOrWhiteSpace(httpContext.Request.PathBase) ? "/" : httpContext.Request.PathBase.Value!,
Expires = DateTimeOffset.UtcNow.AddHours(12)
});
}
private static string CreateValue(string cookieName, string passwordHash)
{
var input = $"TrafagSalesExporter|{cookieName}|{passwordHash.Trim()}";
return AccessPasswordSettingsWriter.HashPassword(input);
}
}
@@ -0,0 +1,125 @@
using System.Security.Cryptography;
using System.Text;
using Microsoft.Extensions.Options;
using TrafagSalesExporter.Security;
namespace TrafagSalesExporter.Services;
public interface IAdminAccessService
{
bool IsEnabled { get; }
bool IsConfigured { get; }
bool IsUnlocked { get; }
bool TryUnlock(string username, string password);
bool TryChangePassword(string username, string currentPassword, string newPassword);
void Lock();
}
public sealed class AdminAccessService : IAdminAccessService
{
private readonly AdminAccessOptions _options;
private readonly IHostEnvironment _environment;
private readonly ILogger<AdminAccessService> _logger;
private readonly IHttpContextAccessor _httpContextAccessor;
public AdminAccessService(
IOptions<AdminAccessOptions> options,
IHostEnvironment environment,
ILogger<AdminAccessService> logger,
IHttpContextAccessor httpContextAccessor)
{
_options = options.Value;
_environment = environment;
_logger = logger;
_httpContextAccessor = httpContextAccessor;
}
public bool IsEnabled => _options.Enabled;
public bool IsConfigured =>
!IsEnabled ||
!string.IsNullOrWhiteSpace(_options.Username) &&
(!string.IsNullOrWhiteSpace(_options.PasswordHash) || !string.IsNullOrEmpty(_options.Password));
public bool IsUnlocked =>
_isUnlocked ||
AccessUnlockCookie.IsUnlocked(
_httpContextAccessor.HttpContext,
AccessUnlockCookie.AdminCookieName,
_options.PasswordHash);
private bool _isUnlocked;
public bool TryUnlock(string username, string password)
{
if (!IsEnabled)
{
_isUnlocked = true;
_logger.LogInformation("Admin access unlocked because AdminAccess is disabled.");
return true;
}
if (!IsConfigured ||
string.IsNullOrWhiteSpace(username) ||
string.IsNullOrEmpty(password) ||
!FixedEquals(username.Trim(), _options.Username.Trim()))
{
_logger.LogWarning(
"Admin access unlock failed before password check. IsConfigured={IsConfigured}, HasUsername={HasUsername}, PasswordLength={PasswordLength}, UsernameMatches={UsernameMatches}",
IsConfigured,
!string.IsNullOrWhiteSpace(username),
password?.Length ?? 0,
!string.IsNullOrWhiteSpace(username) && FixedEquals(username.Trim(), _options.Username.Trim()));
return false;
}
var valid = !string.IsNullOrWhiteSpace(_options.PasswordHash)
? VerifyPasswordHash(password, _options.PasswordHash)
: FixedEquals(password, _options.Password);
_isUnlocked = valid;
_logger.Log(
valid ? LogLevel.Information : LogLevel.Warning,
"Admin access password check completed. Success={Success}, Username={Username}, PasswordLength={PasswordLength}, UsesHash={UsesHash}",
valid,
username.Trim(),
password.Length,
!string.IsNullOrWhiteSpace(_options.PasswordHash));
return valid;
}
public bool TryChangePassword(string username, string currentPassword, string newPassword)
{
if (!IsEnabled ||
!IsConfigured ||
string.IsNullOrWhiteSpace(newPassword) ||
newPassword.Length < 8 ||
!TryUnlock(username, currentPassword))
{
return false;
}
var passwordHash = AccessPasswordSettingsWriter.HashPassword(newPassword);
AccessPasswordSettingsWriter.SavePasswordHash(_environment.ContentRootPath, AdminAccessOptions.SectionName, passwordHash);
_options.PasswordHash = passwordHash;
_options.Password = string.Empty;
_isUnlocked = true;
return true;
}
public void Lock() => _isUnlocked = false;
private static bool VerifyPasswordHash(string password, string configuredHash)
{
var passwordHash = AccessPasswordSettingsWriter.HashPassword(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);
}
}

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