Compare commits

...

97 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
205 changed files with 51820 additions and 7553 deletions
+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
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; }
}
+12 -2
View File
@@ -5,16 +5,26 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Trafag Finanze/Sales Management Cockpit</title>
<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>
@@ -1,8 +1,10 @@
@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>
@@ -17,21 +19,58 @@
@T("Finance-Cockpit-Zugang ist noch nicht konfiguriert. Bitte Username und PasswordHash in FinanceCockpitAccess konfigurieren.", "Finance Cockpit access is not configured yet. Please configure Username and PasswordHash in FinanceCockpitAccess.")
</MudAlert>
}
<MudTextField @bind-Value="_username" Label="@T("Name", "Name")" Disabled="@(!FinanceAccess.IsConfigured)" />
<MudTextField @bind-Value="_password" Label="@T("Passwort", "Password")" InputType="InputType.Password" Disabled="@(!FinanceAccess.IsConfigured)" />
<MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="UnlockAsync"
StartIcon="@Icons.Material.Filled.LockOpen" Disabled="@(!FinanceAccess.IsConfigured)">
@T("Finance Cockpit entsperren", "Unlock Finance Cockpit")
</MudButton>
<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);
@@ -43,5 +82,32 @@
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);
}
@@ -2,9 +2,13 @@
@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">
@@ -24,9 +28,13 @@
@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">
@@ -67,14 +75,22 @@
@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">
@@ -110,9 +126,13 @@
</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">
@@ -145,19 +165,28 @@
</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">
@@ -170,6 +199,42 @@
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;
@@ -629,6 +694,14 @@
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));
@@ -12,18 +12,30 @@
<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>
@@ -71,6 +83,8 @@
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)
@@ -1,68 +1,96 @@
@using Microsoft.AspNetCore.Authorization
@using Microsoft.AspNetCore.Components.Authorization
@using TrafagSalesExporter.Models
@using TrafagSalesExporter.Security
@inject TrafagSalesExporter.Services.IUiTextService UiText
@inject TrafagSalesExporter.Services.IFinanceCockpitAccessService FinanceAccess
@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>
<MudNavGroup Title="@T("Finance Cockpit", "Finance Cockpit")" Icon="@Icons.Material.Filled.Analytics" Expanded="true">
<MudNavLink Href="/" Match="NavLinkMatch.All" Icon="@Icons.Material.Filled.Dashboard">
@T("Export Dashboard", "Export dashboard")
</MudNavLink>
<MudNavLink Href="/management-cockpit" Match="NavLinkMatch.Prefix" Icon="@Icons.Material.Filled.QueryStats">
@T("Management Analyse", "Management analysis")
</MudNavLink>
@if (ShowFinanceComparison)
{
<MudNavLink Href="/finance-cockpit/vergleich" Match="NavLinkMatch.Prefix" Icon="@Icons.Material.Filled.CompareArrows">
@T("Soll/Ist Vergleich", "Actual/reference comparison")
</MudNavLink>
}
<MudNavLink Href="/manual-imports" Match="NavLinkMatch.Prefix" Icon="@Icons.Material.Filled.UploadFile">
@T("Manuelle Importe", "Manual imports")
</MudNavLink>
<MudNavGroup Title="@T("Admin", "Admin")" Icon="@Icons.Material.Filled.AdminPanelSettings">
<AuthorizeView Policy="@SecurityPolicies.AdminOnly">
<Authorized>
<MudNavLink Href="/standorte" Match="NavLinkMatch.Prefix" Icon="@Icons.Material.Filled.LocationOn">
@T("Standorte", "Sites")
</MudNavLink>
<MudNavLink Href="/transformations" Match="NavLinkMatch.Prefix" Icon="@Icons.Material.Filled.Transform">
@T("Transformationen", "Transformations")
</MudNavLink>
<MudNavLink Href="/finance-rules" Match="NavLinkMatch.Prefix" Icon="@Icons.Material.Filled.Rule">
@T("Finance Regeln", "Finance rules")
</MudNavLink>
<MudNavLink Href="/settings" Match="NavLinkMatch.Prefix" Icon="@Icons.Material.Filled.Settings">
@T("Settings", "Settings")
</MudNavLink>
</Authorized>
</AuthorizeView>
<MudNavLink Href="/logs" Match="NavLinkMatch.Prefix" Icon="@Icons.Material.Filled.List">
@T("Logs", "Logs")
</MudNavLink>
</MudNavGroup>
@if (FinanceAccess.IsEnabled && FinanceAccess.IsUnlocked)
{
<MudButton Variant="Variant.Text" Color="Color.Secondary" Size="Size.Small"
StartIcon="@Icons.Material.Filled.Lock" OnClick="LockFinanceCockpit" Class="ml-3">
@T("Finance sperren", "Lock finance")
</MudButton>
}
</MudNavGroup>
<MudNavLink Href="/hr-kpi" Match="NavLinkMatch.Prefix" Icon="@Icons.Material.Filled.Groups">
@T("HR KPI (Login)", "HR KPI (login)")
</MudNavLink>
@foreach (var item in RootItems)
{
<NavMenuNode Item="item"
Items="_visibleItems"
HiddenKeys="_hiddenKeys"
OnAction="HandleMenuActionAsync" />
}
</MudNavMenu>
@code {
private List<NavigationMenuItem> _visibleItems = [];
private readonly HashSet<string> _hiddenKeys = [];
private bool ShowFinanceComparison => Configuration.GetValue("Navigation:ShowFinanceComparison", true);
private void LockFinanceCockpit()
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()
{
FinanceAccess.Lock();
Navigation.NavigateTo("/");
UiText.Changed += HandleLanguageChanged;
await LoadMenuAsync();
}
private string T(string german, string english) => UiText.Text(german, english);
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,481 +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("Export Dashboard", "Export dashboard")</PageTitle>
<PageTitle>@T("Trafag Cockpit", "Trafag Cockpit")</PageTitle>
<MudText Typo="Typo.h4" Class="mb-4">@T("Export Dashboard", "Export 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" />
@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)
<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>
<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)
{
<MudText Typo="Typo.caption">@warning</MudText>
<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>
}
</MudAlert>
}
</div>
</div>
@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>
@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();
<style>
.home-shell {
min-height: calc(100vh - 112px);
display: flex;
align-items: center;
justify-content: center;
background: #fff;
}
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;
.home-content {
display: flex;
flex-direction: column;
align-items: center;
gap: 18px;
}
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);
.home-manometer {
width: min(336px, 58vw);
height: auto;
display: block;
}
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);
.home-welcome {
color: #050505;
font-size: 24px;
font-weight: 700;
text-align: center;
letter-spacing: 0;
}
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);
.walking-stage {
width: min(360px, 70vw);
height: 96px;
position: relative;
overflow: hidden;
background: #fff;
border-bottom: 2px solid #050505;
}
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();
});
.walking-person {
position: absolute;
left: 0;
bottom: 4px;
width: 48px;
height: 82px;
animation: lab-walk-path 7s linear infinite;
}
public void Dispose()
{
StopPolling();
Orchestrator.OnExportStatusChanged -= HandleStatusChanged;
.walking-person span {
position: absolute;
display: block;
background: #050505;
}
private void OpenExportFile(DashboardRow row)
{
OpenFile(row.FilePath);
.walking-person .head {
left: 15px;
top: 0;
width: 18px;
height: 18px;
border: 3px solid #050505;
border-radius: 50%;
background: #fff;
}
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);
}
.walking-person .body {
left: 22px;
top: 22px;
width: 4px;
height: 34px;
}
private void StartPolling()
{
if (_pollingCts is not null && !_pollingCts.IsCancellationRequested)
return;
_pollingCts = new CancellationTokenSource();
_ = PollDashboardAsync(_pollingCts.Token);
.walking-person .coat {
top: 25px;
width: 17px;
height: 36px;
border: 3px solid #050505;
background: #fff;
}
private void StopPolling()
{
_pollingCts?.Cancel();
_pollingCts?.Dispose();
_pollingCts = null;
.walking-person .coat-left {
left: 8px;
transform: skewY(10deg);
border-right: 0;
}
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)
{
}
.walking-person .coat-right {
left: 23px;
transform: skewY(-10deg);
border-left: 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-person .arm,
.walking-person .leg {
width: 4px;
border-radius: 4px;
transform-origin: 50% 0;
}
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;
.walking-person .arm {
top: 28px;
height: 28px;
animation: limb-swing 0.72s ease-in-out infinite alternate;
}
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;
.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);
}
@@ -1,4 +1,5 @@
@page "/finance-cockpit/vergleich"
@rendermode @(Microsoft.AspNetCore.Components.Web.RenderMode.InteractiveServer)
@using TrafagSalesExporter.Models
@using TrafagSalesExporter.Services
@inject IFinanceReconciliationService FinanceReconciliationService
@@ -12,7 +13,7 @@
<MudStack Row AlignItems="AlignItems.Center" Class="mb-3">
<div>
<MudText Typo="Typo.h6">@T("Net Sales Actuals 2025 Referenz", "Net sales actuals 2025 reference")</MudText>
<MudText Typo="Typo.caption">@T("Verbindliche Finance-Sicht aus CentralSalesRecords", "Authoritative finance view from CentralSalesRecords")</MudText>
<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)"
@@ -1,4 +1,5 @@
@page "/finance-rules"
@rendermode @(Microsoft.AspNetCore.Components.Web.RenderMode.InteractiveServer)
@attribute [Authorize(Policy = TrafagSalesExporter.Security.SecurityPolicies.AdminOnly)]
@using System.Reflection
@using TrafagSalesExporter.Models
@@ -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);
}
+418 -12
View File
@@ -1,13 +1,16 @@
@page "/hr-kpi"
@using Microsoft.Extensions.Options
@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 IOptions<HrKpiDataSourceOptions> DataSourceOptions
@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>
@@ -26,12 +29,35 @@
@T("HR-KPI-Zugang ist noch nicht konfiguriert. Bitte Username und PasswordHash in HrKpiAccess konfigurieren.", "HR KPI access is not configured yet. Please configure Username and PasswordHash in HrKpiAccess.")
</MudAlert>
}
<MudTextField @bind-Value="_hrUsername" Label="@T("Name", "Name")" Disabled="@(!HrKpiAccess.IsConfigured)" />
<MudTextField @bind-Value="_hrPassword" Label="@T("Passwort", "Password")" InputType="InputType.Password" Disabled="@(!HrKpiAccess.IsConfigured)" />
<MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="UnlockHrKpiAsync"
StartIcon="@Icons.Material.Filled.LockOpen" Disabled="@(!HrKpiAccess.IsConfigured)">
@T("HR KPI entsperren", "Unlock HR KPI")
</MudButton>
<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>
}
@@ -42,7 +68,24 @@ else
<MudItem xs="12" md="5">
<MudTextField @bind-Value="_dataFolder"
Label="@T("Datenordner fuer Rexx/SAP-Dateien", "Data folder for Rexx/SAP files")"
HelperText="@T("Standard ist C:\\temp. Der Ordner kann hier fuer den aktuellen Lauf angepasst oder dauerhaft in appsettings.json unter HrKpi:DataFolder geaendert werden.", "Default is C:\\temp. The folder can be changed here for the current run or permanently in appsettings.json under HrKpi:DataFolder.")" />
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>
@@ -71,10 +114,10 @@ else
Label="@T("Managementsicht", "Management view")" />
</MudItem>
<MudItem xs="12" md="3">
<MudDatePicker @bind-Date="_fromDate" Label="@T("Von Austritt", "Exit from")" Clearable DateFormat="dd.MM.yyyy" />
<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 Austritt", "Exit to")" Clearable DateFormat="dd.MM.yyyy" />
<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>
@@ -139,6 +182,50 @@ else
@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>
}
@@ -175,8 +262,30 @@ else
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"),
@@ -186,10 +295,20 @@ else
];
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()
{
_dataFolder = DataSourceOptions.Value.Normalize().DataFolder;
_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();
@@ -222,6 +341,8 @@ else
SearchText = _searchText,
ManagementView = _managementView
});
_selectionStore.LastSelection = CreateSelectionState();
await WriteSelectionStoreAsync();
}
catch (Exception ex)
{
@@ -233,8 +354,243 @@ else
}
}
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);
@@ -245,6 +601,33 @@ else
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();
@@ -260,4 +643,27 @@ else
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
@@ -1,4 +1,5 @@
@page "/manual-imports"
@rendermode @(Microsoft.AspNetCore.Components.Web.RenderMode.InteractiveServer)
@using Microsoft.AspNetCore.Components.Forms
@using Microsoft.EntityFrameworkCore
@using TrafagSalesExporter.Data
@@ -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,33 +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>
@@ -36,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">
@@ -48,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" />
@@ -63,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>
@@ -83,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>
@@ -91,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>
@@ -137,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>
@@ -153,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>
@@ -161,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>
@@ -175,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>
@@ -252,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();
@@ -331,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()
@@ -340,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
{
@@ -355,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()
@@ -397,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;
}
@@ -445,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)
{
@@ -473,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()
@@ -486,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
{
@@ -510,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
{
@@ -539,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
{
@@ -556,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;
}
@@ -599,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,5 @@
@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
@@ -85,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)
{
@@ -791,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))
@@ -1052,6 +1100,13 @@
[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"],
@@ -1,4 +1,5 @@
@page "/transformations"
@rendermode @(Microsoft.AspNetCore.Components.Web.RenderMode.InteractiveServer)
@attribute [Authorize(Policy = TrafagSalesExporter.Security.SecurityPolicies.AdminOnly)]
@using System.Reflection
@using TrafagSalesExporter.Models
+3 -1
View File
@@ -41,9 +41,11 @@
.Trim('/')
.ToLowerInvariant();
return path is "" or
return path is
"export-dashboard" or
"management-cockpit" or
"finance-cockpit/vergleich" or
"finance-cockpit/schulung" or
"standorte" or
"transformations" or
"finance-rules" or
@@ -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
View File
@@ -24,4 +24,5 @@ public class AppDbContext : DbContext
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
+11 -725
View File
@@ -1,733 +1,19 @@
# TrafagSalesExporter LLM System Guide
# LLM System Guide
Stand: 2026-05-05
Stand: 2026-05-27
## Aktueller Projektstand 2026-05-05
Diese Datei ist fuer tokenarme RAG-Nutzung komprimiert.
Fuer den aktuellen Finance-/Laenderabgleich zuerst diese Dateien lesen:
## Kontext-Regel
- [HANDOFF_2026-04-15.md](C:/Users/koi/source/repos/Ai/TrafagSalesExporter/HANDOFF_2026-04-15.md)
- [NEXT_STEPS_2026-04-15.md](C:/Users/koi/source/repos/Ai/TrafagSalesExporter/NEXT_STEPS_2026-04-15.md)
- [lastchange.md](C:/Users/koi/source/repos/Ai/TrafagSalesExporter/lastchange.md)
- [SAGE_SPAIN_EXPORT_2026-05-05.md](C:/Users/koi/source/repos/Ai/TrafagSalesExporter/SAGE_SPAIN_EXPORT_2026-05-05.md)
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.
Lokaler FinanceProbe:
## Volltext Bei Bedarf
Die Detailhistorie liegt hier:
```text
http://localhost:55417/finance
docs/raw_md_archive/HISTORY_CANONICAL.md.raw
```
## Aktueller Zusatzstand 2026-05-07 SAP OData / ZSCHWEIZ
Schweiz/Oesterreich werden ueber eine neue SAP-Tabelle `ZSCHWEIZ` bereitgestellt.
Wichtige Punkte:
- ABAP-Report: `report.abap`
- SAP-Tabelle: `ZSCHWEIZ`
- OData EntitySet: `ZSCHWEIZSet`
- App-Standort: `ZSCHWEIZ` / `Schweiz/Oesterreich`
- Geplanter App-Pfad: `SAP` = `SAP OData`, nicht direkter HANA-Spezialcode
Quellsystem-Codes:
- `SAP`: SAP OData/Gateway, DisplayName `SAP OData`
- `SAP_HANA`: direkte HANA-Tabellen/Views, DisplayName `SAP HANA Tables/Views`
- `BI1`: HANA
- `SAGE`: HANA
- `MANUAL_EXCEL`: Excel/CSV
Mapper:
- SAP OData nutzt `SapSourceDefinition`, `SapJoinDefinition`, `SapFieldMapping`.
- Direkte HANA-Tabellen/Views koennen dieselben Mapping-Tabellen ebenfalls nutzen.
- Gemeinsame Mapping-Engine ist `MappedSalesRecordComposer`.
- `SapCompositionService` und `HanaQueryService.GetMappedSalesRecordsAsync` unterscheiden sich nur noch in der Quellenbeschaffung; Join und `SalesRecord`-Mapping sind zentral.
- Bei HANA mit gepflegten Quellen/Mappings nutzt `HanaDataSourceAdapter` den generischen Mapping-Pfad.
- Ohne HANA-Mapping bleibt der alte B1-HANA-Standardpfad fuer `OINV/INV1/ORIN/RIN1` aktiv.
Finance-Konfiguration:
- `FinanceReferences` enthaelt Soll-/check.xlsx-Referenzen.
- `FinanceIntercompanyRules` enthaelt 2nd-party/IC-Regeln.
- Budgetkurse werden als `CurrencyExchangeRates` mit `Notes = Budget 2025` gepflegt.
- Config-Export/-Import umfasst Finance-Referenzen und IC-Regeln.
ZSCHWEIZ-Seed:
- Quelle Alias `Z`
- EntitySet `ZSCHWEIZSet`
- Mapping auf `SalesRecord` ist vorbefuellt und grafisch editierbar.
- Beim App-Start wird die ZSCHWEIZ-Quelle samt Feldmapping per Upsert angelegt oder repariert.
- Wenn Gateway `$metadata` liefert, koennen Felder in der UI per `Felder aus Quellen laden` gelesen werden.
ABAP-Fachlogik:
- `BUKRS 1100` = Schweiz, `TSC TRCH`, `LAND1 CH`
- `BUKRS 1200` = Oesterreich, `TSC TRAT`, `LAND1 AT`
- `CUSTOMER_LAND` = Kundenland aus `KNA1-LAND1`
- Netto-/Steuerwerte werden in Belegwaehrung und Hauswaehrung geschrieben.
Aktuelle FinanceProbe-Funktionen:
- `Meeting Ampel 2025` fuer alle Laender aus `check.xlsx`
- `Detail alle Laender`
- `Spain CSV direct check`
- `Germany Excel sample check`
Spanien:
- Datei: `sagespain/v2/Spain_Sales_2025.csv`
- Ist: `3'082'320.18` EUR
- Soll: `3'102'333.61`
- Differenz: `-20'013.43`
- Status: Gelb / Pruefen
- Technisch lesbar, fachliche Differenz noch offen
Deutschland:
- Datei: `DE_Beispiel_Export_Daten.xlsx`
- Sample-Summe `NettoPreisGesamtX`: `8'290.70` EUR
- Nur Beispielfile, keine finale Jahreszahl
- Mapping technisch verstanden, finaler DE-Jahresfile fehlt
Letzte Verifikation:
- `dotnet build .\Tools\FinanceProbe\FinanceProbe.csproj --verbosity minimal --no-restore`
- `dotnet test .\TrafagSalesExporter.Tests\TrafagSalesExporter.Tests.csproj --verbosity minimal --no-restore`
- Ergebnis: Build OK, Tests `50/50`, FinanceProbe `HTTP 200`
Diese Datei ist fuer andere LLMs gedacht, die das Projekt schnell verstehen und daraus Architekturtexte, Visualisierungen, Ablaufdiagramme oder UI-/Datenflussgrafiken erzeugen sollen.
## Zweck des Systems
`TrafagSalesExporter` ist eine Blazor Server App auf `.NET 8`, die Verkaufsdaten aus mehreren Quellsystemen in ein gemeinsames Zielschema ueberfuehrt.
Quellsysteme:
- `HANA`-basierte Systeme wie `BI1` und `SAGE`
- `SAP_GATEWAY` ueber OData
- `MANUAL_EXCEL` aus hochgeladenen oder referenzierten Excel-Dateien
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
- Authentifizierung: ASP.NET Core Authentication/Authorization, produktiv Windows Authentication / Active Directory
- 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.
Zusaetzlich registriert `Program.cs` den Zugriffsschutz:
- `AddCascadingAuthenticationState`
- Windows Authentication fuer produktive Umgebungen
- Development-Authentication-Handler nur bei `ASPNETCORE_ENVIRONMENT=Development` und `Security:DevelopmentBypass=true`
- globale Fallback-Policy fuer authentifizierte/berechtigte User
- Policy `AdminOnly` fuer administrative Seiten
## Hauptseiten
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
Security:
- alle Routen erfordern Authentifizierung
- `Settings`, `Standorte` und `Transformations` sind `AdminOnly`
- Admin-Navigation wird nur fuer Admins angezeigt
- eingeloggter Benutzer wird im App-Bar angezeigt
## Kernmodelle
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)
`SalesRecord` / `CentralSalesRecord` enthalten neben den positionsnahen Feldern auch B1-Belegwaehrungsfelder:
- `DocumentCurrency` aus `DocCur`
- `DocumentTotalForeignCurrency` aus `DocTotalFC`
- `DocumentTotalLocalCurrency` aus `DocTotal`
- `VatSumForeignCurrency` aus `VatSumFC`
- `VatSumLocalCurrency` aus `VatSum`
- `DocumentRate` aus `DocRate`
- `CompanyCurrency` aus `OADM.MainCurncy`
Wichtig: diese Dokumentwerte sind Belegkopfwerte und werden in der positionsbasierten Excel pro Position wiederholt. Fuer Belegkopfsummen muessen Auswertungen nach Beleg deduplizieren.
Wichtige Relationen:
- `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
- Summenfeld waehlen
- Anzeige-Waehrung waehlen
- Kennzahlen, Top-Listen, Datenqualitaet, Findings erzeugen
2. Zentraldatenbasiert
- direkt aus `CentralSalesRecords`
- Jahr/Monat Filter
- Summenfeld waehlen
- optionale weitere Summenfelder fuer Zeitreihen waehlen
- Anzeige-Waehrung waehlen
- Rohsicht ohne Intercompany-, Budget- oder Spartelogik
Aktuelle Summenfelder:
- `Sales Price/Value`
- `Quantity`
- `Standard cost`
- `Quantity * Standard cost`
Aktuelle Anzeige-Waehrungen:
- `EUR`
- `USD`
- `Original`
Die Waehrungsumrechnung nutzt `CurrencyExchangeRateService`. Bei `Original` bleiben Werte in Quellwaehrungen gruppiert. Nicht-betragliche Summenfelder wie `Quantity` haben keine Waehrung. Fehlende Wechselkurse werden gezaehlt und in Hinweisen bzw. Findings sichtbar; betroffene Werte werden in der Zielwaehrung mit `0` einbezogen.
## Quellsystemlogik
### 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`
- `ManagementCockpitService` kann betragliche Cockpit-Kennzahlen in `EUR` oder `USD` umrechnen
Wichtig:
- die Rohsicht im `Management Cockpit` kann jetzt Anzeige-Waehrungen nutzen
- `CHF` ist im Cockpit aktuell nicht als direkte Anzeige-Waehrung in der UI angeboten
- CHF bleibt weiterhin Teil des allgemeinen Transformationssystems
- fachlich ist noch zu klaeren, ob CHF als Standard- oder zusaetzliche Cockpit-Anzeige-Waehrung gebraucht wird
## SharePoint-Rolle im Gesamtsystem
`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
## Authentifizierung / Autorisierung
Dateien:
- [Security/SecurityOptions.cs](C:/Users/koi/source/repos/Ai/TrafagSalesExporter/Security/SecurityOptions.cs)
- [Security/SecurityPolicies.cs](C:/Users/koi/source/repos/Ai/TrafagSalesExporter/Security/SecurityPolicies.cs)
- [Security/DevelopmentAuthenticationHandler.cs](C:/Users/koi/source/repos/Ai/TrafagSalesExporter/Security/DevelopmentAuthenticationHandler.cs)
- [Components/Routes.razor](C:/Users/koi/source/repos/Ai/TrafagSalesExporter/Components/Routes.razor)
- [Components/Layout/NavMenu.razor](C:/Users/koi/source/repos/Ai/TrafagSalesExporter/Components/Layout/NavMenu.razor)
- [Components/Layout/MainLayout.razor](C:/Users/koi/source/repos/Ai/TrafagSalesExporter/Components/Layout/MainLayout.razor)
Produktives Ziel:
- Windows Authentication / Active Directory
- keine eigene Benutzerverwaltung
- Zugriff ueber AD-Gruppen
- Adminrechte ueber separate AD-Gruppe
Konfiguration in `appsettings.json`:
- `Security:AccessGroups`
- `Security:AdminGroups`
- `Security:DevelopmentBypass`
- `Security:DevelopmentUserIsAdmin`
- `Security:DevelopmentUserName`
Default-Gruppen:
- `TRAFAG\\TrafagSalesExporter-Users`
- `TRAFAG\\TrafagSalesExporter-Admins`
Development:
- `appsettings.Development.json` aktiviert einen lokalen Development-Auth-Handler
- dieser ist nur fuer lokale Entwicklung gedacht
- produktiv darf `ASPNETCORE_ENVIRONMENT` nicht `Development` sein
IIS-Betrieb:
- Windows Authentication aktivieren
- Anonymous Authentication deaktivieren
- AD-Gruppennamen in produktiver Konfiguration setzen
## Config Import / Export
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
`ManagementCockpitServiceTests` decken inzwischen auch ab:
- zentrale Analyse nach Jahr/Monat
- Tages-, Monats-, Jahres-, Quellen- und Laenderwerte
- waehlbare Summenfelder
- Waehrungsumrechnung in EUR
- Wechselkurs-Caching
- Mengen-Auswertung ohne Waehrungsumrechnung
- Zusatz-Summenfelder in Zeitreihen
`SecurityPolicyFactoryTests` decken inzwischen ab:
- App-Zugriff fuer User in `AccessGroups`
- Ablehnung fuer User ausserhalb der Access-Gruppen
- Development-Auth-Zugriff im lokalen Modus
- Admin-Zugriff fuer User in `AdminGroups`
- Ablehnung normaler User fuer `AdminOnly`
- Development-Admin-Claim
`CentralSalesRecordServiceTests` decken inzwischen ab:
- Persistenz und Ruecklesen der B1-Belegwaehrungsfelder in `CentralSalesRecords`
Wichtig:
- 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.
@@ -20,6 +20,13 @@ public class CentralSalesRecord
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;
@@ -51,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
@@ -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);
}
@@ -184,6 +184,8 @@ public sealed class HrAbsenceRow
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; }
@@ -18,6 +18,7 @@ public static class ManagementCockpitValueFieldKeys
public static class ManagementCockpitCurrencyOptions
{
public const string Native = "NATIVE";
public const string Chf = "CHF";
public const string Eur = "EUR";
public const string Usd = "USD";
}
@@ -107,6 +108,8 @@ public class ManagementCockpitCentralSummary
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; }
}
@@ -168,7 +171,145 @@ public class ManagementFinanceSummaryRow
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
@@ -180,10 +321,22 @@ public class ManagementFinanceSummaryResult
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 @@
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);
@@ -3,13 +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;
File diff suppressed because it is too large Load Diff
+80
View File
@@ -2,6 +2,7 @@ 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;
@@ -19,6 +20,7 @@ builder.Logging.AddFilter("Microsoft.EntityFrameworkCore.Database.Command", LogL
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;
@@ -47,6 +49,8 @@ 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"));
@@ -76,15 +80,20 @@ 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>();
@@ -107,8 +116,12 @@ 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"];
@@ -133,7 +146,74 @@ 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}/";
}
@@ -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"
}
}
}
@@ -1,16 +1,23 @@
# Sage Spain Export
Stand: 2026-05-05
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
- Soll aus `check.xlsx`: `3'102'333.61`
- Differenz: `-20'013.43`
- Status FinanceProbe: Gelb / Pruefen
- Finale Aussage: technisch importierbar, aber fachlich noch nicht abgestimmt.
- 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:
@@ -72,7 +79,7 @@ Beobachtung:
## Export v2
Finaler Export-Kandidat wurde mit `SageSpainFinalExportPackage.zip` bzw. danach `v2.zip` erstellt.
Finaler Export-Kandidat wurde mit `SageSpainFinalExportPackage.zip` bzw. danach `v2.zip` erstellt; aktueller Paketordner im Repo: `SageSpainExportPackage/SageSpainFinalExportPackage/`.
Script:
@@ -1,9 +1,15 @@
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]$OutputDirectory = (Join-Path $env:USERPROFILE "Desktop"),
[string]$OutputFileName = ""
)
$ErrorActionPreference = "Stop"
@@ -97,12 +103,41 @@ function Export-QueryToCsv {
}
}
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
$csvPath = Join-Path $runDirectory "Spain_Sales_2025.csv"
$summaryPath = Join-Path $runDirectory "Spain_Sales_2025_summary.txt"
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
@@ -170,8 +205,7 @@ JOIN dbo.LineasAlbaranCliente l
AND l.EjercicioAlbaran = c.EjercicioAlbaran
AND l.SerieAlbaran = c.SerieAlbaran
AND l.NumeroAlbaran = c.NumeroAlbaran
WHERE c.FechaFactura >= @FromDate
AND c.FechaFactura < @ToDate
WHERE $datePredicate
ORDER BY
c.FechaFactura,
c.SerieFactura,
@@ -188,6 +222,8 @@ 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"))
@@ -204,14 +240,15 @@ Source:
dbo.CabeceraAlbaranCliente joined with dbo.LineasAlbaranCliente
Filter:
CabeceraAlbaranCliente.FechaFactura >= FromDate
CabeceraAlbaranCliente.FechaFactura < ToDate
$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:"
@@ -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."
Binary file not shown.
@@ -1,32 +0,0 @@
Sage Spain final sales export candidate
======================================
Run on the Spain Sage SQL Server machine.
PowerShell commands:
Set-ExecutionPolicy -Scope Process Bypass
.\Export-SageSpainSalesCsv.ps1
Output folder on Desktop:
Sage_Spain_Sales_Export_YYYYMMDD_HHMMSS
Files created:
- Spain_Sales_2025.csv
- Spain_Sales_2025_summary.txt
The script only reads SQL Server data. It does not change Sage or SQL Server.
Default source:
- Database: Sage
- Header: dbo.CabeceraAlbaranCliente
- Lines: dbo.LineasAlbaranCliente
- Date filter: CabeceraAlbaranCliente.FechaFactura from 2025-01-01 to 2026-01-01
- Sales value: LineasAlbaranCliente.ImporteNeto
If the SQL instance or database name differs:
.\Export-SageSpainSalesCsv.ps1 -ServerInstance "localhost" -Database "Sage"
@@ -0,0 +1,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,8 @@
namespace TrafagSalesExporter.Security;
public sealed class LandingPageOptions
{
public const string SectionName = "LandingPage";
public bool ShowWalkingLabFigure { get; set; }
}
@@ -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);
}
}
@@ -0,0 +1,59 @@
using Microsoft.EntityFrameworkCore;
using TrafagSalesExporter.Data;
using TrafagSalesExporter.Models;
namespace TrafagSalesExporter.Services;
public interface ICentralSalesDataProvider
{
Task<List<SalesRecord>> GetRecordsAsync();
Task<bool> UsesAuditCsvAsync();
}
public sealed class CentralSalesDataProvider : ICentralSalesDataProvider
{
private readonly IDbContextFactory<AppDbContext> _dbFactory;
private readonly ICentralSalesRecordService _centralSalesRecordService;
private readonly IExportAuditCsvService _auditCsvService;
public CentralSalesDataProvider(
IDbContextFactory<AppDbContext> dbFactory,
ICentralSalesRecordService centralSalesRecordService,
IExportAuditCsvService auditCsvService)
{
_dbFactory = dbFactory;
_centralSalesRecordService = centralSalesRecordService;
_auditCsvService = auditCsvService;
}
public async Task<List<SalesRecord>> GetRecordsAsync()
{
using var db = await _dbFactory.CreateDbContextAsync();
var settings = await db.ExportSettings.AsNoTracking().FirstOrDefaultAsync() ?? new ExportSettings();
if (!settings.UseAuditCsvAsCentralSource)
return await _centralSalesRecordService.GetAllAsync();
var records = await _auditCsvService.ReadLatestSiteAuditCsvRecordsAsync(settings);
if (records.Count == 0)
{
var directory = _auditCsvService.ResolveAuditCsvDirectory(settings);
throw new InvalidOperationException(
$"Audit-CSV ist als zentrale Quelle aktiv, aber im Ordner '{directory}' wurden keine Sales_ProcessedMergeInput_*.csv-Dateien gefunden.");
}
return records
.OrderBy(r => r.Land)
.ThenBy(r => r.Tsc)
.ThenByDescending(r => r.InvoiceDate ?? DateTime.MinValue)
.ThenBy(r => r.InvoiceNumber)
.ThenBy(r => r.PositionOnInvoice)
.ToList();
}
public async Task<bool> UsesAuditCsvAsync()
{
using var db = await _dbFactory.CreateDbContextAsync();
var settings = await db.ExportSettings.AsNoTracking().FirstOrDefaultAsync() ?? new ExportSettings();
return settings.UseAuditCsvAsCentralSource;
}
}
@@ -62,6 +62,7 @@ public class CentralSalesRecordService : ICentralSalesRecordService
.ThenBy(r => r.Tsc)
.Select(r => new SalesRecord
{
SourceSystem = r.SourceSystem,
ExtractionDate = r.ExtractionDate,
Tsc = r.Tsc,
DocumentEntry = r.DocumentEntry,
@@ -70,6 +71,13 @@ public class CentralSalesRecordService : ICentralSalesRecordService
Material = r.Material,
Name = r.Name,
ProductGroup = r.ProductGroup,
ProductHierarchyCode = r.ProductHierarchyCode,
ProductHierarchyText = r.ProductHierarchyText,
ProductFamilyCode = r.ProductFamilyCode,
ProductFamilyText = r.ProductFamilyText,
ProductDivisionCode = r.ProductDivisionCode,
ProductDivisionText = r.ProductDivisionText,
ProductMappingAssigned = r.ProductMappingAssigned,
Quantity = r.Quantity,
SupplierNumber = r.SupplierNumber,
SupplierName = r.SupplierName,
@@ -164,7 +172,8 @@ public class CentralSalesRecordService : ICentralSalesRecordService
command.CommandText = """
INSERT INTO CentralSalesRecords (
StoredAtUtc, SiteId, SourceSystem, ExtractionDate, Tsc, DocumentEntry, InvoiceNumber, PositionOnInvoice,
Material, Name, ProductGroup, Quantity, SupplierNumber, SupplierName, SupplierCountry,
Material, Name, ProductGroup, ProductHierarchyCode, ProductHierarchyText, ProductFamilyCode, ProductFamilyText,
ProductDivisionCode, ProductDivisionText, ProductMappingAssigned, Quantity, SupplierNumber, SupplierName, SupplierCountry,
CustomerNumber, CustomerName, CustomerCountry, CustomerIndustry, StandardCost,
StandardCostCurrency, PurchaseOrderNumber, SalesPriceValue, SalesCurrency, Incoterms2020,
DocumentCurrency, DocumentTotalForeignCurrency, DocumentTotalLocalCurrency, VatSumForeignCurrency,
@@ -172,7 +181,8 @@ public class CentralSalesRecordService : ICentralSalesRecordService
)
VALUES (
$storedAtUtc, $siteId, $sourceSystem, $extractionDate, $tsc, $documentEntry, $invoiceNumber, $positionOnInvoice,
$material, $name, $productGroup, $quantity, $supplierNumber, $supplierName, $supplierCountry,
$material, $name, $productGroup, $productHierarchyCode, $productHierarchyText, $productFamilyCode, $productFamilyText,
$productDivisionCode, $productDivisionText, $productMappingAssigned, $quantity, $supplierNumber, $supplierName, $supplierCountry,
$customerNumber, $customerName, $customerCountry, $customerIndustry, $standardCost,
$standardCostCurrency, $purchaseOrderNumber, $salesPriceValue, $salesCurrency, $incoterms2020,
$documentCurrency, $documentTotalForeignCurrency, $documentTotalLocalCurrency, $vatSumForeignCurrency,
@@ -191,6 +201,13 @@ public class CentralSalesRecordService : ICentralSalesRecordService
command.Parameters.Add("$material", SqliteType.Text);
command.Parameters.Add("$name", SqliteType.Text);
command.Parameters.Add("$productGroup", SqliteType.Text);
command.Parameters.Add("$productHierarchyCode", SqliteType.Text);
command.Parameters.Add("$productHierarchyText", SqliteType.Text);
command.Parameters.Add("$productFamilyCode", SqliteType.Text);
command.Parameters.Add("$productFamilyText", SqliteType.Text);
command.Parameters.Add("$productDivisionCode", SqliteType.Text);
command.Parameters.Add("$productDivisionText", SqliteType.Text);
command.Parameters.Add("$productMappingAssigned", SqliteType.Text);
command.Parameters.Add("$quantity", SqliteType.Real);
command.Parameters.Add("$supplierNumber", SqliteType.Text);
command.Parameters.Add("$supplierName", SqliteType.Text);
@@ -235,6 +252,13 @@ public class CentralSalesRecordService : ICentralSalesRecordService
command.Parameters["$material"].Value = record.Material ?? string.Empty;
command.Parameters["$name"].Value = record.Name ?? string.Empty;
command.Parameters["$productGroup"].Value = record.ProductGroup ?? string.Empty;
command.Parameters["$productHierarchyCode"].Value = record.ProductHierarchyCode ?? string.Empty;
command.Parameters["$productHierarchyText"].Value = record.ProductHierarchyText ?? string.Empty;
command.Parameters["$productFamilyCode"].Value = record.ProductFamilyCode ?? string.Empty;
command.Parameters["$productFamilyText"].Value = record.ProductFamilyText ?? string.Empty;
command.Parameters["$productDivisionCode"].Value = record.ProductDivisionCode ?? string.Empty;
command.Parameters["$productDivisionText"].Value = record.ProductDivisionText ?? string.Empty;
command.Parameters["$productMappingAssigned"].Value = record.ProductMappingAssigned ?? string.Empty;
command.Parameters["$quantity"].Value = record.Quantity;
command.Parameters["$supplierNumber"].Value = record.SupplierNumber ?? string.Empty;
command.Parameters["$supplierName"].Value = record.SupplierName ?? string.Empty;
@@ -70,7 +70,11 @@ public class ConfigTransferService : IConfigTransferService
TimerEnabled = exportSettings.TimerEnabled,
DebugLoggingEnabled = exportSettings.DebugLoggingEnabled,
LocalSiteExportFolder = exportSettings.LocalSiteExportFolder,
LocalConsolidatedExportFolder = exportSettings.LocalConsolidatedExportFolder
LocalConsolidatedExportFolder = exportSettings.LocalConsolidatedExportFolder,
AuditCsvEnabled = exportSettings.AuditCsvEnabled,
UseAuditCsvAsCentralSource = exportSettings.UseAuditCsvAsCentralSource,
LocalAuditCsvFolder = exportSettings.LocalAuditCsvFolder,
ExchangeRateDateField = SettingsPageService.NormalizeExchangeRateDateField(exportSettings.ExchangeRateDateField)
},
SourceSystemDefinitions = sourceSystems.Select(system => new ConfigTransferSourceSystemDefinition
{
@@ -283,7 +287,11 @@ public class ConfigTransferService : IConfigTransferService
TimerEnabled = importedSettings.TimerEnabled,
DebugLoggingEnabled = importedSettings.DebugLoggingEnabled,
LocalSiteExportFolder = importedSettings.LocalSiteExportFolder,
LocalConsolidatedExportFolder = importedSettings.LocalConsolidatedExportFolder
LocalConsolidatedExportFolder = importedSettings.LocalConsolidatedExportFolder,
AuditCsvEnabled = importedSettings.AuditCsvEnabled,
UseAuditCsvAsCentralSource = importedSettings.UseAuditCsvAsCentralSource,
LocalAuditCsvFolder = importedSettings.LocalAuditCsvFolder,
ExchangeRateDateField = SettingsPageService.NormalizeExchangeRateDateField(importedSettings.ExchangeRateDateField)
});
foreach (var sourceSystem in importedSourceSystems)
@@ -428,6 +436,13 @@ public class ConfigTransferService : IConfigTransferService
Material = record.Material,
Name = record.Name,
ProductGroup = record.ProductGroup,
ProductHierarchyCode = record.ProductHierarchyCode,
ProductHierarchyText = record.ProductHierarchyText,
ProductFamilyCode = record.ProductFamilyCode,
ProductFamilyText = record.ProductFamilyText,
ProductDivisionCode = record.ProductDivisionCode,
ProductDivisionText = record.ProductDivisionText,
ProductMappingAssigned = record.ProductMappingAssigned,
Quantity = record.Quantity,
SupplierNumber = record.SupplierNumber,
SupplierName = record.SupplierName,
@@ -7,25 +7,25 @@ namespace TrafagSalesExporter.Services;
public class ConsolidatedExportService : IConsolidatedExportService
{
private readonly IDbContextFactory<AppDbContext> _dbFactory;
private readonly ICentralSalesRecordService _centralSalesRecordService;
private readonly ICentralSalesDataProvider _centralSalesDataProvider;
private readonly IExcelExportService _excelService;
private readonly ISharePointUploadService _sharePointService;
public ConsolidatedExportService(
IDbContextFactory<AppDbContext> dbFactory,
ICentralSalesRecordService centralSalesRecordService,
ICentralSalesDataProvider centralSalesDataProvider,
IExcelExportService excelService,
ISharePointUploadService sharePointService)
{
_dbFactory = dbFactory;
_centralSalesRecordService = centralSalesRecordService;
_centralSalesDataProvider = centralSalesDataProvider;
_excelService = excelService;
_sharePointService = sharePointService;
}
public async Task<string?> ExportAsync()
{
var consolidatedRecords = await _centralSalesRecordService.GetAllAsync();
var consolidatedRecords = await _centralSalesDataProvider.GetRecordsAsync();
if (consolidatedRecords.Count == 0)
return null;
@@ -57,7 +57,8 @@ public class ConsolidatedExportService : IConsolidatedExportService
await _sharePointService.UploadAsync(
spConfig.TenantId, spConfig.ClientId, spConfig.ClientSecret,
spConfig.SiteUrl, sharePointFolder, landSubfolder, consolidatedPath);
spConfig.SiteUrl, sharePointFolder, landSubfolder, consolidatedPath,
uploadTimestampedCopyIfLocked: true);
}
return consolidatedPath;
@@ -1,4 +1,5 @@
using Microsoft.EntityFrameworkCore;
using System.Data;
using TrafagSalesExporter.Data;
using TrafagSalesExporter.Models;
@@ -28,14 +29,7 @@ public sealed class DashboardPageService : IDashboardPageService
.GroupBy(l => l.SiteId)
.Select(g => g.OrderByDescending(l => l.Timestamp).First())
.ToListAsync();
var appLogs = await db.AppEventLogs
.Where(l => l.SiteId != null)
.OrderByDescending(l => l.Timestamp)
.Take(1000)
.ToListAsync();
var latestAppLogsBySite = appLogs
.GroupBy(l => l.SiteId!.Value)
.ToDictionary(g => g.Key, g => g.OrderByDescending(x => x.Timestamp).First());
var latestAppLogsBySite = await LoadLatestAppLogsBySiteAsync(db);
var rows = sites.Select(s =>
{
@@ -86,6 +80,75 @@ public sealed class DashboardPageService : IDashboardPageService
};
}
private static async Task<Dictionary<int, AppEventLog>> LoadLatestAppLogsBySiteAsync(AppDbContext db)
{
var connection = db.Database.GetDbConnection();
var shouldClose = connection.State != ConnectionState.Open;
if (shouldClose)
await connection.OpenAsync();
try
{
await using var command = connection.CreateCommand();
command.CommandText = """
SELECT Id, Timestamp, Level, Category, SiteId, Land, Message, Details
FROM AppEventLogs
WHERE SiteId IS NOT NULL
ORDER BY Id DESC
LIMIT 1000;
""";
var logs = new List<AppEventLog>();
await using var reader = await command.ExecuteReaderAsync();
while (await reader.ReadAsync())
{
if (!TryReadInt(reader["SiteId"], out var siteId))
continue;
if (!DateTime.TryParse(Convert.ToString(reader["Timestamp"]), out var timestamp))
continue;
logs.Add(new AppEventLog
{
Id = TryReadInt(reader["Id"], out var id) ? id : 0,
Timestamp = timestamp,
Level = Convert.ToString(reader["Level"]) ?? string.Empty,
Category = Convert.ToString(reader["Category"]) ?? string.Empty,
SiteId = siteId,
Land = Convert.ToString(reader["Land"]) ?? string.Empty,
Message = Convert.ToString(reader["Message"]) ?? string.Empty,
Details = Convert.ToString(reader["Details"]) ?? string.Empty
});
}
return logs
.GroupBy(l => l.SiteId!.Value)
.ToDictionary(g => g.Key, g => g.OrderByDescending(x => x.Timestamp).First());
}
finally
{
if (shouldClose)
await connection.CloseAsync();
}
}
private static bool TryReadInt(object? value, out int number)
{
if (value is int intValue)
{
number = intValue;
return true;
}
if (value is long longValue && longValue >= int.MinValue && longValue <= int.MaxValue)
{
number = (int)longValue;
return true;
}
return int.TryParse(Convert.ToString(value), out number);
}
private static List<string> BuildReadinessWarnings(List<Site> activeSites, List<SourceSystemDefinition> sourceSystems)
{
var warnings = new List<string>();
@@ -31,6 +31,7 @@ public sealed class ManualExcelDataSourceAdapter : IDataSourceAdapter
string filePath;
string? localOutputDirectory = null;
string? sharePointUploadFolder = null;
var localManualImportPaths = new List<string>();
var tempManualImportPaths = new List<string>();
try
{
@@ -39,6 +40,12 @@ public sealed class ManualExcelDataSourceAdapter : IDataSourceAdapter
filePath = manualImportPath;
localOutputDirectory = Path.GetDirectoryName(Path.GetFullPath(manualImportPath));
}
else if (Directory.Exists(manualImportPath))
{
localManualImportPaths.AddRange(ResolveLocalManualImportFilesInFolder(manualImportPath, site));
filePath = manualImportPath;
localOutputDirectory = Path.GetFullPath(manualImportPath);
}
else if (LooksLikeSharePointReference(manualImportPath))
{
var spConfig = context.SharePointConfig
@@ -95,9 +102,15 @@ public sealed class ManualExcelDataSourceAdapter : IDataSourceAdapter
siteId: site.Id, land: site.Land, details: filePath);
var records = new List<SalesRecord>();
var readPaths = tempManualImportPaths.Count > 0 ? tempManualImportPaths : [filePath];
var readPaths = tempManualImportPaths.Count > 0
? tempManualImportPaths
: localManualImportPaths.Count > 0
? localManualImportPaths
: [filePath];
foreach (var readPath in readPaths)
records.AddRange(await _manualExcelImportService.ReadSalesRecordsAsync(readPath, site));
if (IsSpainSite(site))
records = DeduplicateSpainSalesRecords(records);
return new DataSourceFetchResult
{
Records = records,
@@ -126,6 +139,85 @@ public sealed class ManualExcelDataSourceAdapter : IDataSourceAdapter
=> LooksLikeSharePointReference(path) &&
string.IsNullOrWhiteSpace(Path.GetExtension(path.TrimEnd('/')));
private static List<string> ResolveLocalManualImportFilesInFolder(string folderPath, Site site)
{
var files = Directory.EnumerateFiles(folderPath)
.Where(IsSupportedManualImportFile)
.Where(path => !IsSpainSite(site) || IsSpainSalesFile(path))
.OrderBy(GetManualImportFileSortKey, StringComparer.OrdinalIgnoreCase)
.ThenBy(path => path, StringComparer.OrdinalIgnoreCase)
.ToList();
if (files.Count == 0)
{
var expected = IsSpainSite(site) ? "Spain_Sales*.csv" : "*.xlsx/*.csv";
throw new InvalidOperationException($"Im Ordner '{folderPath}' wurde keine passende Importdatei gefunden ({expected}).");
}
return files;
}
private static bool IsSupportedManualImportFile(string path)
{
var extension = Path.GetExtension(path);
return extension.Equals(".xlsx", StringComparison.OrdinalIgnoreCase) ||
extension.Equals(".csv", StringComparison.OrdinalIgnoreCase);
}
private static bool IsSpainSite(Site site)
=> string.Equals(site.TSC, "TRES", StringComparison.OrdinalIgnoreCase) ||
string.Equals(site.TSC, "TRSE", StringComparison.OrdinalIgnoreCase) ||
string.Equals(site.Land, "Spanien", StringComparison.OrdinalIgnoreCase) ||
string.Equals(site.Land, "Spain", StringComparison.OrdinalIgnoreCase);
private static bool IsSpainSalesFile(string path)
=> Path.GetFileName(path).StartsWith("Spain_Sales", StringComparison.OrdinalIgnoreCase) &&
Path.GetExtension(path).Equals(".csv", StringComparison.OrdinalIgnoreCase);
private static string GetManualImportFileSortKey(string path)
{
var name = Path.GetFileNameWithoutExtension(path);
var rangeIndex = name.IndexOf("_range_", StringComparison.OrdinalIgnoreCase);
if (rangeIndex >= 0)
return "1_" + name[(rangeIndex + "_range_".Length)..];
return "0_" + name;
}
private static List<SalesRecord> DeduplicateSpainSalesRecords(IEnumerable<SalesRecord> records)
{
var ordered = records.ToList();
var keyed = new Dictionary<string, SalesRecord>(StringComparer.OrdinalIgnoreCase);
var unkeyed = new List<SalesRecord>();
foreach (var record in ordered)
{
var key = BuildSpainSalesRecordKey(record);
if (string.IsNullOrWhiteSpace(key))
unkeyed.Add(record);
else
keyed[key] = record;
}
return keyed.Values.Concat(unkeyed).ToList();
}
private static string BuildSpainSalesRecordKey(SalesRecord record)
{
if (!string.IsNullOrWhiteSpace(record.SourceLineId))
return $"source:{record.SourceLineId.Trim()}";
if (!string.IsNullOrWhiteSpace(record.InvoiceNumber))
return string.Join("|",
"invoice",
record.Tsc?.Trim() ?? string.Empty,
record.InvoiceNumber.Trim(),
record.PositionOnInvoice.ToString(System.Globalization.CultureInfo.InvariantCulture),
record.Material?.Trim() ?? string.Empty);
return string.Empty;
}
private static string ResolveSharePointParentFolder(string fileReference, string siteUrl)
{
var remotePath = fileReference.Trim('/').Trim();
@@ -27,7 +27,11 @@ CREATE TABLE ExportSettings (
TimerEnabled INTEGER NOT NULL,
DebugLoggingEnabled INTEGER NOT NULL DEFAULT 0,
LocalSiteExportFolder TEXT NOT NULL DEFAULT '',
LocalConsolidatedExportFolder TEXT NOT NULL DEFAULT ''
LocalConsolidatedExportFolder TEXT NOT NULL DEFAULT '',
AuditCsvEnabled INTEGER NOT NULL DEFAULT 1,
UseAuditCsvAsCentralSource INTEGER NOT NULL DEFAULT 0,
LocalAuditCsvFolder TEXT NOT NULL DEFAULT '',
ExchangeRateDateField TEXT NOT NULL DEFAULT 'PostingDate'
);";
internal static string GetHanaServersCreateSql() => @"
@@ -91,6 +95,13 @@ CREATE TABLE CentralSalesRecords (
Material TEXT NOT NULL,
Name TEXT NOT NULL,
ProductGroup TEXT NOT NULL,
ProductHierarchyCode TEXT NOT NULL DEFAULT '',
ProductHierarchyText TEXT NOT NULL DEFAULT '',
ProductFamilyCode TEXT NOT NULL DEFAULT '',
ProductFamilyText TEXT NOT NULL DEFAULT '',
ProductDivisionCode TEXT NOT NULL DEFAULT '',
ProductDivisionText TEXT NOT NULL DEFAULT '',
ProductMappingAssigned TEXT NOT NULL DEFAULT '',
Quantity TEXT NOT NULL,
SupplierNumber TEXT NOT NULL,
SupplierName TEXT NOT NULL,
@@ -207,4 +218,79 @@ CREATE TABLE FinanceRules (
SortOrder INTEGER NOT NULL DEFAULT 0,
IsActive INTEGER NOT NULL DEFAULT 1
);";
internal static string GetNavigationMenuItemsCreateSql() => @"
CREATE TABLE NavigationMenuItems (
Id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
Key TEXT NOT NULL,
ParentKey TEXT NULL,
TitleDe TEXT NOT NULL DEFAULT '',
TitleEn TEXT NOT NULL DEFAULT '',
Icon TEXT NOT NULL DEFAULT '',
Href TEXT NOT NULL DEFAULT '',
ItemType TEXT NOT NULL DEFAULT 'Link',
Match TEXT NOT NULL DEFAULT 'Prefix',
RequiredPolicy TEXT NOT NULL DEFAULT '',
IsVisible INTEGER NOT NULL DEFAULT 1,
IsExpanded INTEGER NOT NULL DEFAULT 0,
IsSystem INTEGER NOT NULL DEFAULT 1,
SortOrder INTEGER NOT NULL DEFAULT 0
);";
internal static string GetPurchasingEkkoCacheCreateSql() => @"
CREATE TABLE PurchasingEkkoCache (
Ebeln TEXT NOT NULL PRIMARY KEY,
Bedat TEXT NULL,
Aedat TEXT NULL,
Lifnr TEXT NOT NULL DEFAULT '',
Bukrs TEXT NOT NULL DEFAULT '',
Bsart TEXT NOT NULL DEFAULT '',
RawJson TEXT NOT NULL DEFAULT '',
LastLoadedAtUtc TEXT NOT NULL
);";
internal static string GetPurchasingEkpoCacheCreateSql() => @"
CREATE TABLE PurchasingEkpoCache (
Ebeln TEXT NOT NULL,
Ebelp TEXT NOT NULL,
Matnr TEXT NOT NULL DEFAULT '',
Txz01 TEXT NOT NULL DEFAULT '',
Matkl TEXT NOT NULL DEFAULT '',
Menge TEXT NOT NULL DEFAULT '0',
Meins TEXT NOT NULL DEFAULT '',
Netwr TEXT NOT NULL DEFAULT '0',
Loekz TEXT NOT NULL DEFAULT '',
RawJson TEXT NOT NULL DEFAULT '',
LastLoadedAtUtc TEXT NOT NULL,
PRIMARY KEY (Ebeln, Ebelp)
);";
internal static string GetPurchasingEketCacheCreateSql() => @"
CREATE TABLE PurchasingEketCache (
Ebeln TEXT NOT NULL,
Ebelp TEXT NOT NULL,
Etenr TEXT NOT NULL,
Eindt TEXT NULL,
Menge TEXT NOT NULL DEFAULT '0',
Wemng TEXT NOT NULL DEFAULT '0',
RawJson TEXT NOT NULL DEFAULT '',
LastLoadedAtUtc TEXT NOT NULL,
PRIMARY KEY (Ebeln, Ebelp, Etenr)
);";
internal static string GetPurchasingSyncStateCreateSql() => @"
CREATE TABLE PurchasingSyncState (
Id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
Mode TEXT NOT NULL DEFAULT '',
Status TEXT NOT NULL DEFAULT '',
StartedAtUtc TEXT NULL,
CompletedAtUtc TEXT NULL,
FromDate TEXT NULL,
ToDate TEXT NULL,
LastSuccessfulDeltaAtUtc TEXT NULL,
EkkoRows INTEGER NOT NULL DEFAULT 0,
EkpoRows INTEGER NOT NULL DEFAULT 0,
EketRows INTEGER NOT NULL DEFAULT 0,
Message TEXT NOT NULL DEFAULT ''
);";
}
@@ -29,6 +29,10 @@ public class DatabaseSchemaMaintenanceService : IDatabaseSchemaMaintenanceServic
AddColumnIfMissing(db, "ExportSettings", "DebugLoggingEnabled", "INTEGER NOT NULL DEFAULT 0");
AddColumnIfMissing(db, "ExportSettings", "LocalSiteExportFolder", "TEXT NOT NULL DEFAULT ''");
AddColumnIfMissing(db, "ExportSettings", "LocalConsolidatedExportFolder", "TEXT NOT NULL DEFAULT ''");
AddColumnIfMissing(db, "ExportSettings", "AuditCsvEnabled", "INTEGER NOT NULL DEFAULT 1");
AddColumnIfMissing(db, "ExportSettings", "UseAuditCsvAsCentralSource", "INTEGER NOT NULL DEFAULT 0");
AddColumnIfMissing(db, "ExportSettings", "LocalAuditCsvFolder", "TEXT NOT NULL DEFAULT ''");
AddColumnIfMissing(db, "ExportSettings", "ExchangeRateDateField", "TEXT NOT NULL DEFAULT 'PostingDate'");
AddColumnIfMissing(db, "SharePointConfigs", "CentralExportFolder", "TEXT NOT NULL DEFAULT ''");
AddColumnIfMissing(db, "ExportLogs", "FilePath", "TEXT NOT NULL DEFAULT ''");
EnsureTransformationTable(db);
@@ -44,6 +48,8 @@ public class DatabaseSchemaMaintenanceService : IDatabaseSchemaMaintenanceServic
EnsureSapFieldMappingTable(db);
EnsureManualExcelColumnMappingTable(db);
EnsureCentralSalesRecordTable(db);
EnsureNavigationMenuItemTable(db);
EnsurePurchasingCacheTables(db);
AddColumnIfMissing(db, "CentralSalesRecords", "DocumentEntry", "INTEGER NOT NULL DEFAULT 0");
AddColumnIfMissing(db, "CentralSalesRecords", "DocumentCurrency", "TEXT NOT NULL DEFAULT ''");
AddColumnIfMissing(db, "CentralSalesRecords", "DocumentTotalForeignCurrency", "TEXT NOT NULL DEFAULT '0'");
@@ -52,6 +58,13 @@ public class DatabaseSchemaMaintenanceService : IDatabaseSchemaMaintenanceServic
AddColumnIfMissing(db, "CentralSalesRecords", "VatSumLocalCurrency", "TEXT NOT NULL DEFAULT '0'");
AddColumnIfMissing(db, "CentralSalesRecords", "DocumentRate", "TEXT NOT NULL DEFAULT '0'");
AddColumnIfMissing(db, "CentralSalesRecords", "CompanyCurrency", "TEXT NOT NULL DEFAULT ''");
AddColumnIfMissing(db, "CentralSalesRecords", "ProductHierarchyCode", "TEXT NOT NULL DEFAULT ''");
AddColumnIfMissing(db, "CentralSalesRecords", "ProductHierarchyText", "TEXT NOT NULL DEFAULT ''");
AddColumnIfMissing(db, "CentralSalesRecords", "ProductFamilyCode", "TEXT NOT NULL DEFAULT ''");
AddColumnIfMissing(db, "CentralSalesRecords", "ProductFamilyText", "TEXT NOT NULL DEFAULT ''");
AddColumnIfMissing(db, "CentralSalesRecords", "ProductDivisionCode", "TEXT NOT NULL DEFAULT ''");
AddColumnIfMissing(db, "CentralSalesRecords", "ProductDivisionText", "TEXT NOT NULL DEFAULT ''");
AddColumnIfMissing(db, "CentralSalesRecords", "ProductMappingAssigned", "TEXT NOT NULL DEFAULT ''");
AddColumnIfMissing(db, "CentralSalesRecords", "PostingDate", "TEXT NULL");
EnsureAppEventLogTable(db);
}
@@ -264,6 +277,52 @@ CREATE TABLE IF NOT EXISTS FieldTransformationRules (
cmd.ExecuteNonQuery();
}
private static void EnsureNavigationMenuItemTable(AppDbContext db)
{
var conn = db.Database.GetDbConnection();
if (conn.State != System.Data.ConnectionState.Open)
conn.Open();
using var cmd = conn.CreateCommand();
cmd.CommandText = DatabaseSchemaSql.GetNavigationMenuItemsCreateSql().Replace("CREATE TABLE", "CREATE TABLE IF NOT EXISTS");
cmd.ExecuteNonQuery();
}
private static void EnsurePurchasingCacheTables(AppDbContext db)
{
var conn = db.Database.GetDbConnection();
if (conn.State != System.Data.ConnectionState.Open)
conn.Open();
foreach (var createSql in new[]
{
DatabaseSchemaSql.GetPurchasingEkkoCacheCreateSql(),
DatabaseSchemaSql.GetPurchasingEkpoCacheCreateSql(),
DatabaseSchemaSql.GetPurchasingEketCacheCreateSql(),
DatabaseSchemaSql.GetPurchasingSyncStateCreateSql()
})
{
using var cmd = conn.CreateCommand();
cmd.CommandText = createSql.Replace("CREATE TABLE", "CREATE TABLE IF NOT EXISTS");
cmd.ExecuteNonQuery();
}
foreach (var indexSql in new[]
{
"CREATE INDEX IF NOT EXISTS IX_PurchasingEkkoCache_Bedat ON PurchasingEkkoCache (Bedat);",
"CREATE INDEX IF NOT EXISTS IX_PurchasingEkkoCache_Lifnr ON PurchasingEkkoCache (Lifnr);",
"CREATE INDEX IF NOT EXISTS IX_PurchasingEkpoCache_Ebeln ON PurchasingEkpoCache (Ebeln);",
"CREATE INDEX IF NOT EXISTS IX_PurchasingEkpoCache_Matkl ON PurchasingEkpoCache (Matkl);",
"CREATE INDEX IF NOT EXISTS IX_PurchasingEketCache_Eindt ON PurchasingEketCache (Eindt);",
"CREATE INDEX IF NOT EXISTS IX_PurchasingEketCache_EbelnEbelp ON PurchasingEketCache (Ebeln, Ebelp);"
})
{
using var indexCommand = conn.CreateCommand();
indexCommand.CommandText = indexSql;
indexCommand.ExecuteNonQuery();
}
}
private static void EnsureSapSourceTable(AppDbContext db)
{
var conn = db.Database.GetDbConnection();
@@ -1,25 +1,31 @@
using Microsoft.EntityFrameworkCore;
using TrafagSalesExporter.Data;
using TrafagSalesExporter.Models;
using TrafagSalesExporter.Security;
namespace TrafagSalesExporter.Services;
public class DatabaseSeedService : IDatabaseSeedService
{
private const string SpainSharePointFolder = "https://trafagag.sharepoint.com/sites/WorldwideBIPlatform/Import/Finance/Spanien";
public void SeedDefaults(AppDbContext db)
{
SeedIfEmpty(db);
EnsureRecommendedTransformationRules(db);
EnsureSourceSystemDefinitions(db);
EnsureIndiaSageHanaConfiguration(db);
EnsureCentralHanaServerRecords(db);
EnsureSpainManualExcelSite(db);
EnsureGermanyManualExcelSite(db);
EnsureUkManualExcelFolder(db);
EnsureSapODataDachSite(db);
EnsurePurchasingSapSite(db);
EnsureFinanceReferenceDefaults(db);
EnsureBudgetExchangeRateDefaults(db);
EnsureFinanceIntercompanyRuleDefaults(db);
EnsureFinanceRuleDefaults(db);
EnsureNavigationMenuDefaults(db);
}
private static void SeedIfEmpty(AppDbContext db)
@@ -57,7 +63,8 @@ public class DatabaseSeedService : IDatabaseSeedService
TimerEnabled = true,
DebugLoggingEnabled = false,
LocalSiteExportFolder = "",
LocalConsolidatedExportFolder = ""
LocalConsolidatedExportFolder = "",
ExchangeRateDateField = ExchangeRateDateFields.PostingDate
});
db.SaveChanges();
@@ -114,6 +121,135 @@ public class DatabaseSeedService : IDatabaseSeedService
db.SaveChanges();
}
private static void EnsureNavigationMenuDefaults(AppDbContext db)
{
var defaults = BuildDefaultNavigationMenuItems();
var changed = false;
foreach (var item in defaults)
{
var existing = db.NavigationMenuItems.FirstOrDefault(x => x.Key == item.Key);
if (existing is null)
{
db.NavigationMenuItems.Add(item);
changed = true;
continue;
}
if (string.IsNullOrWhiteSpace(existing.TitleDe)) existing.TitleDe = item.TitleDe;
if (string.IsNullOrWhiteSpace(existing.TitleEn)) existing.TitleEn = item.TitleEn;
if (string.IsNullOrWhiteSpace(existing.Icon)) existing.Icon = item.Icon;
if (string.IsNullOrWhiteSpace(existing.Href)) existing.Href = item.Href;
if (string.IsNullOrWhiteSpace(existing.ItemType)) existing.ItemType = item.ItemType;
if (string.IsNullOrWhiteSpace(existing.Match)) existing.Match = item.Match;
if (string.IsNullOrWhiteSpace(existing.RequiredPolicy)) existing.RequiredPolicy = item.RequiredPolicy;
if (existing.Key == "purchasing-ideas")
{
existing.ItemType = item.ItemType;
existing.Href = item.Href;
existing.Match = item.Match;
existing.Icon = item.Icon;
existing.IsExpanded = item.IsExpanded;
}
existing.IsSystem = true;
changed = true;
}
if (changed)
db.SaveChanges();
}
private static List<NavigationMenuItem> BuildDefaultNavigationMenuItems() =>
[
Group("finance", null, "Finance Cockpit", "Finance Cockpit", "Analytics", 10, expanded: true),
Link("export-dashboard", "finance", "Export Dashboard", "Export dashboard", "Dashboard", "export-dashboard", 10),
Group("management-analysis", "finance", "Management Analyse", "Management analysis", "QueryStats", 20),
Link("management-quick", "management-analysis", "Schnelluebersicht", "Quick overview", "Speed", "management-cockpit", 10, "All"),
Group("experts", "management-analysis", "Experten", "Experts", "Tune", 20),
Link("finance-summary", "experts", "Finance Summary", "Finance summary", "Dashboard", "management-cockpit?section=summary", 10, "All"),
Link("country-diagnostics", "experts", "Laender Diagnose", "Country diagnostics", "Public", "management-cockpit?section=countries", 20, "All"),
Link("data-status", "experts", "Datenstatus", "Data status", "FactCheck", "management-cockpit?section=status", 30, "All"),
Link("deviations", "experts", "Abweichungen", "Deviations", "WarningAmber", "management-cockpit?section=deviations", 40, "All"),
Link("credits", "experts", "Gutschriften", "Credit notes", "AssignmentReturn", "management-cockpit?section=credits", 50, "All"),
Link("data-quality", "experts", "Datenqualitaet", "Data quality", "Rule", "management-cockpit?section=quality", 60, "All"),
Link("division-finance", "experts", "Sparten-Finanzanalyse", "Division finance", "PieChart", "management-cockpit?section=division&division=finance", 70, "All"),
Link("division-central", "experts", "Zentrale Spartenzuordnung", "Central division mapping", "AccountTree", "management-cockpit?section=division&division=central", 80, "All"),
Link("finance-3d", "experts", "3D Datenanalyse", "3D data analysis", "ViewInAr", "management-cockpit?section=3d", 90, "All"),
Link("raw-diagnostics", "experts", "Rohdaten Diagnose", "Raw-data diagnostics", "QueryStats", "management-cockpit?section=raw", 100, "All"),
Link("finance-comparison", "finance", "Soll/Ist Vergleich", "Actual/reference comparison", "CompareArrows", "finance-cockpit/vergleich", 30),
Link("finance-training", "finance", "Finance Schulung", "Finance training", "School", "finance-cockpit/schulung", 40),
Link("manual-imports", "finance", "Manuelle Importe", "Manual imports", "UploadFile", "manual-imports", 50),
Group("finance-admin", "finance", "Admin", "Admin", "AdminPanelSettings", 60),
Link("sites", "finance-admin", "Standorte", "Sites", "LocationOn", "standorte", 10, requiredPolicy: SecurityPolicies.AdminOnly),
Link("transformations", "finance-admin", "Transformationen", "Transformations", "Transform", "transformations", 20, requiredPolicy: SecurityPolicies.AdminOnly),
Link("finance-rules", "finance-admin", "Finance Regeln", "Finance rules", "Rule", "finance-rules", 30, requiredPolicy: SecurityPolicies.AdminOnly),
Link("settings", "finance-admin", "Settings", "Settings", "Settings", "settings", 40, requiredPolicy: SecurityPolicies.AdminOnly),
Link("menu-structure", "finance-admin", "Menuestruktur", "Menu structure", "AccountTree", "admin/menu-structure", 45, requiredPolicy: SecurityPolicies.AdminOnly),
Link("logs", "finance-admin", "Logs", "Logs", "List", "logs", 50),
Action("finance-lock", "finance", "Finance sperren", "Lock finance", "Lock", 70),
Group("hr", null, "HR KPI (Login)", "HR KPI (login)", "Groups", 20),
Link("hr-dashboard", "hr", "HR Dashboard", "HR dashboard", "Dashboard", "hr-kpi", 10, "All"),
Link("hr-training", "hr", "HR KPI Schulung", "HR KPI training", "School", "hr-kpi/schulung", 20),
Group("purchasing", null, "Einkauf", "Purchasing", "ShoppingCart", 30),
Link("purchasing-dashboard", "purchasing", "Einkauf Dashboard", "Purchasing dashboard", "Dashboard", "einkauf", 10, "All"),
Link("purchasing-spend", "purchasing", "Spend", "Spend", "Payments", "einkauf/spend", 20, "All"),
Link("purchasing-open-orders", "purchasing", "Offene Bestellungen", "Open orders", "PendingActions", "einkauf/offene-bestellungen", 30, "All"),
Link("purchasing-contracts", "purchasing", "Kontrakte", "Contracts", "Assignment", "einkauf/kontrakte", 40, "All"),
Link("purchasing-suppliers", "purchasing", "Lieferanten", "Suppliers", "Verified", "einkauf/lieferanten", 50, "All"),
Group("purchasing-ideas", "purchasing", "Ideen", "Ideas", "Lightbulb", 60, expanded: true),
Link("purchasing-ideas-overview", "purchasing-ideas", "Uebersicht", "Overview", "Lightbulb", "einkauf/ideen", 10, "All"),
Link("purchasing-idea-data-service", "purchasing-ideas", "Einkauf-Datenservice", "Purchasing data service", "Storage", "einkauf/ideen/datenservice", 20, "All"),
Link("purchasing-idea-delivery-risk", "purchasing-ideas", "Liefertermin-Risiko", "Delivery due-date risk", "PendingActions", "einkauf/ideen/liefertermin-risiko", 30, "All"),
Link("purchasing-idea-price-variance", "purchasing-ideas", "Preisentwicklung", "Price trend", "TrendingUp", "einkauf/ideen/preisabweichung", 40, "All"),
Link("purchasing-idea-spend-concentration", "purchasing-ideas", "Spend-Konzentration", "Spend concentration", "PieChart", "einkauf/ideen/spend-konzentration", 50, "All"),
Link("purchasing-idea-data-quality", "purchasing-ideas", "Datenqualitaet", "Data quality", "FactCheck", "einkauf/ideen/datenqualitaet", 60, "All"),
Link("purchasing-kpi-catalog", "purchasing", "Kennzahlen-Katalog", "KPI catalogue", "Checklist", "einkauf/kennzahlen", 70, "All"),
Link("purchasing-pbix", "purchasing", "PBIX Vorlage", "PBIX template", "InsertChart", "einkauf/pbix", 80, "All"),
Link("purchasing-3d", "purchasing", "3D Simulation", "3D simulation", "ViewInAr", "einkauf/3d", 90, "All"),
Link("purchasing-data-sources", "purchasing", "Datenquellen", "Data sources", "Hub", "einkauf/verbindungen", 100, "All"),
Link("admin-sessions", null, "Admin Bereich", "Admin area", "PeopleAlt", "admin/sessions", 90)
];
private static NavigationMenuItem Group(string key, string? parentKey, string titleDe, string titleEn, string icon, int sortOrder, bool expanded = false)
=> new()
{
Key = key,
ParentKey = parentKey,
TitleDe = titleDe,
TitleEn = titleEn,
Icon = icon,
ItemType = NavigationMenuItemTypes.Group,
IsExpanded = expanded,
SortOrder = sortOrder
};
private static NavigationMenuItem Link(string key, string? parentKey, string titleDe, string titleEn, string icon, string href, int sortOrder, string match = "Prefix", string requiredPolicy = "")
=> new()
{
Key = key,
ParentKey = parentKey,
TitleDe = titleDe,
TitleEn = titleEn,
Icon = icon,
Href = href,
Match = match,
RequiredPolicy = requiredPolicy,
ItemType = NavigationMenuItemTypes.Link,
SortOrder = sortOrder
};
private static NavigationMenuItem Action(string key, string? parentKey, string titleDe, string titleEn, string icon, int sortOrder)
=> new()
{
Key = key,
ParentKey = parentKey,
TitleDe = titleDe,
TitleEn = titleEn,
Icon = icon,
ItemType = NavigationMenuItemTypes.Action,
SortOrder = sortOrder
};
private static void EnsureCentralHanaServerRecords(AppDbContext db)
{
var centralSystems = db.SourceSystemDefinitions
@@ -175,6 +311,128 @@ public class DatabaseSeedService : IDatabaseSeedService
db.SaveChanges();
}
private static void EnsureIndiaSageHanaConfiguration(AppDbContext db)
{
const string sageSourceSystem = "SAGE";
const string indiaTsc = "TRIN";
const string indiaSchema = "TRAFAG_LIVE";
const string indiaHost = "20.197.20.60";
const int indiaPort = 30015;
var site = db.Sites
.Include(x => x.HanaServer)
.OrderBy(x => x.Id)
.FirstOrDefault(x => x.TSC == indiaTsc || x.Land == "Indien" || x.Land == "India");
if (site is null)
return;
var changed = false;
var sourceServer = db.HanaServers
.OrderBy(x => x.Id)
.FirstOrDefault(x => x.Host == indiaHost)
?? site.HanaServer;
var centralSageServer = db.HanaServers
.OrderBy(x => x.Id)
.FirstOrDefault(x => x.SourceSystem == sageSourceSystem);
if (centralSageServer is null)
{
centralSageServer = sourceServer ?? new HanaServer
{
Name = sageSourceSystem,
Host = indiaHost,
Port = indiaPort,
Username = string.Empty,
Password = string.Empty,
DatabaseName = string.Empty,
AdditionalParams = string.Empty
};
if (centralSageServer.Id == 0)
{
db.HanaServers.Add(centralSageServer);
}
}
if (centralSageServer.SourceSystem != sageSourceSystem)
{
centralSageServer.SourceSystem = sageSourceSystem;
changed = true;
}
if (string.IsNullOrWhiteSpace(centralSageServer.Name))
{
centralSageServer.Name = sageSourceSystem;
changed = true;
}
if (string.IsNullOrWhiteSpace(centralSageServer.Host))
{
centralSageServer.Host = !string.IsNullOrWhiteSpace(sourceServer?.Host)
? sourceServer.Host
: indiaHost;
changed = true;
}
if (centralSageServer.Port <= 0)
{
centralSageServer.Port = sourceServer?.Port > 0 ? sourceServer.Port : indiaPort;
changed = true;
}
if (sourceServer is not null && !ReferenceEquals(sourceServer, centralSageServer))
{
if (string.IsNullOrWhiteSpace(centralSageServer.DatabaseName) &&
!string.IsNullOrWhiteSpace(sourceServer.DatabaseName))
{
centralSageServer.DatabaseName = sourceServer.DatabaseName;
changed = true;
}
if (string.IsNullOrWhiteSpace(centralSageServer.AdditionalParams) &&
!string.IsNullOrWhiteSpace(sourceServer.AdditionalParams))
{
centralSageServer.AdditionalParams = sourceServer.AdditionalParams;
changed = true;
}
if (centralSageServer.UseSsl != sourceServer.UseSsl)
{
centralSageServer.UseSsl = sourceServer.UseSsl;
changed = true;
}
if (centralSageServer.ValidateCertificate != sourceServer.ValidateCertificate)
{
centralSageServer.ValidateCertificate = sourceServer.ValidateCertificate;
changed = true;
}
}
if (site.SourceSystem != sageSourceSystem)
{
site.SourceSystem = sageSourceSystem;
changed = true;
}
if (string.IsNullOrWhiteSpace(site.Schema))
{
site.Schema = indiaSchema;
changed = true;
}
if (site.HanaServerId != centralSageServer.Id || site.HanaServerId is null)
{
site.HanaServer = centralSageServer;
changed = true;
}
if (changed || centralSageServer.Id == 0)
db.SaveChanges();
}
private static void EnsureSourceSystemDefinitions(AppDbContext db)
{
var defaults = new[]
@@ -273,6 +531,12 @@ public class DatabaseSeedService : IDatabaseSeedService
changed = true;
}
if (ShouldRepairSpainManualImportPath(existing.ManualImportFilePath))
{
existing.ManualImportFilePath = SpainSharePointFolder;
changed = true;
}
if (changed)
db.SaveChanges();
@@ -285,11 +549,22 @@ public class DatabaseSeedService : IDatabaseSeedService
TSC = "TRES",
Land = "Spanien",
SourceSystem = "MANUAL_EXCEL",
ManualImportFilePath = SpainSharePointFolder,
IsActive = false
});
db.SaveChanges();
}
private static bool ShouldRepairSpainManualImportPath(string? path)
{
if (string.IsNullOrWhiteSpace(path))
return true;
var normalized = path.Trim().Replace('\\', '/');
return normalized.Contains("/Import/Finance/Spanien/Spain_Sales_", StringComparison.OrdinalIgnoreCase) ||
normalized.EndsWith("/Import/Finance/Spanien/Spain_Sales_2025.csv", StringComparison.OrdinalIgnoreCase);
}
private static void EnsureGermanyManualExcelSite(AppDbContext db)
{
if (db.Sites.Count() <= 1)
@@ -656,7 +931,7 @@ public class DatabaseSeedService : IDatabaseSeedService
}
var obsoleteSources = db.SapSourceDefinitions
.Where(x => x.SiteId == siteId && x.Alias != "Z")
.Where(x => x.SiteId == siteId && x.Alias != "Z" && x.Alias != "P" && x.Alias != "M")
.ToList();
foreach (var obsoleteSource in obsoleteSources)
{
@@ -673,6 +948,204 @@ public class DatabaseSeedService : IDatabaseSeedService
}
}
var productSource = db.SapSourceDefinitions
.OrderBy(x => x.Id)
.FirstOrDefault(x => x.SiteId == siteId && x.Alias == "P");
if (productSource is null)
{
db.SapSourceDefinitions.Add(new SapSourceDefinition
{
SiteId = siteId,
Alias = "P",
EntitySet = "ProductDivisionRefSet",
IsPrimary = false,
IsActive = true,
SortOrder = 1
});
changed = true;
}
else
{
if (productSource.EntitySet != "ProductDivisionRefSet")
{
productSource.EntitySet = "ProductDivisionRefSet";
changed = true;
}
if (productSource.IsPrimary)
{
productSource.IsPrimary = false;
changed = true;
}
if (!productSource.IsActive)
{
productSource.IsActive = true;
changed = true;
}
if (productSource.SortOrder != 1)
{
productSource.SortOrder = 1;
changed = true;
}
}
var productMapSource = db.SapSourceDefinitions
.OrderBy(x => x.Id)
.FirstOrDefault(x => x.SiteId == siteId && x.Alias == "M");
if (productMapSource is null)
{
db.SapSourceDefinitions.Add(new SapSourceDefinition
{
SiteId = siteId,
Alias = "M",
EntitySet = "ProductDivisionMapSet",
IsPrimary = false,
IsActive = true,
SortOrder = 2
});
changed = true;
}
else
{
if (productMapSource.EntitySet != "ProductDivisionMapSet")
{
productMapSource.EntitySet = "ProductDivisionMapSet";
changed = true;
}
if (productMapSource.IsPrimary)
{
productMapSource.IsPrimary = false;
changed = true;
}
if (!productMapSource.IsActive)
{
productMapSource.IsActive = true;
changed = true;
}
if (productMapSource.SortOrder != 2)
{
productMapSource.SortOrder = 2;
changed = true;
}
}
var productJoin = db.SapJoinDefinitions
.OrderBy(x => x.Id)
.FirstOrDefault(x =>
x.SiteId == siteId &&
x.LeftAlias == "Z" &&
x.RightAlias == "P");
if (productJoin is null)
{
db.SapJoinDefinitions.Add(new SapJoinDefinition
{
SiteId = siteId,
LeftAlias = "Z",
RightAlias = "P",
LeftKeys = "Matnr",
RightKeys = "Matnr",
JoinType = "Left",
IsActive = true,
SortOrder = 1
});
changed = true;
}
else
{
if (productJoin.LeftKeys != "Matnr")
{
productJoin.LeftKeys = "Matnr";
changed = true;
}
if (productJoin.RightKeys != "Matnr")
{
productJoin.RightKeys = "Matnr";
changed = true;
}
if (productJoin.JoinType != "Left")
{
productJoin.JoinType = "Left";
changed = true;
}
if (!productJoin.IsActive)
{
productJoin.IsActive = true;
changed = true;
}
if (productJoin.SortOrder != 1)
{
productJoin.SortOrder = 1;
changed = true;
}
}
var productMapJoin = db.SapJoinDefinitions
.OrderBy(x => x.Id)
.FirstOrDefault(x =>
x.SiteId == siteId &&
x.LeftAlias == "Z" &&
x.RightAlias == "M");
if (productMapJoin is null)
{
db.SapJoinDefinitions.Add(new SapJoinDefinition
{
SiteId = siteId,
LeftAlias = "Z",
RightAlias = "M",
LeftKeys = "Prodh",
RightKeys = "Paph1",
JoinType = "Left",
IsActive = true,
SortOrder = 2
});
changed = true;
}
else
{
if (productMapJoin.LeftKeys != "Prodh")
{
productMapJoin.LeftKeys = "Prodh";
changed = true;
}
if (productMapJoin.RightKeys != "Paph1")
{
productMapJoin.RightKeys = "Paph1";
changed = true;
}
if (productMapJoin.JoinType != "Left")
{
productMapJoin.JoinType = "Left";
changed = true;
}
if (!productMapJoin.IsActive)
{
productMapJoin.IsActive = true;
changed = true;
}
if (productMapJoin.SortOrder != 2)
{
productMapJoin.SortOrder = 2;
changed = true;
}
}
var mappings = new (string Target, string Source, bool Required)[]
{
(nameof(SalesRecord.Tsc), "Z.Tsc", true),
@@ -685,6 +1158,13 @@ public class DatabaseSeedService : IDatabaseSeedService
(nameof(SalesRecord.Material), "Z.Matnr", false),
(nameof(SalesRecord.Name), "Z.Arktx", false),
(nameof(SalesRecord.ProductGroup), "Z.Prodh", false),
(nameof(SalesRecord.ProductHierarchyCode), "FirstNonEmpty(P.Paph1, M.Paph1)", false),
(nameof(SalesRecord.ProductHierarchyText), "FirstNonEmpty(P.Paph1Text, M.Paph1Text)", false),
(nameof(SalesRecord.ProductFamilyCode), "FirstNonEmpty(P.Wwpfa, M.Wwpfa)", false),
(nameof(SalesRecord.ProductFamilyText), "FirstNonEmpty(P.WwpfaText, M.WwpfaText)", false),
(nameof(SalesRecord.ProductDivisionCode), "FirstNonEmpty(P.Wwpsp, M.Wwpsp)", false),
(nameof(SalesRecord.ProductDivisionText), "FirstNonEmpty(P.WwpspText, M.WwpspText)", false),
(nameof(SalesRecord.ProductMappingAssigned), "FirstNonEmpty(P.IsAssigned, M.IsAssigned)", false),
(nameof(SalesRecord.Quantity), "Z.Fkimg", false),
(nameof(SalesRecord.CustomerNumber), "Z.Kunnr", false),
(nameof(SalesRecord.CustomerName), "Z.Name1", false),
@@ -753,6 +1233,89 @@ public class DatabaseSeedService : IDatabaseSeedService
db.SaveChanges();
}
private static void EnsurePurchasingSapSite(AppDbContext db)
{
if (db.Sites.Count() <= 1)
return;
var site = db.Sites
.OrderBy(x => x.Id)
.FirstOrDefault(x => x.TSC == PurchasingDataSourcePageService.PurchasingTsc);
var changed = false;
if (site is null)
{
site = new Site
{
Schema = string.Empty,
TSC = PurchasingDataSourcePageService.PurchasingTsc,
Land = "Einkauf SAP",
SourceSystem = "SAP",
IsActive = false
};
db.Sites.Add(site);
db.SaveChanges();
}
else
{
if (site.SourceSystem != "SAP")
{
site.SourceSystem = "SAP";
changed = true;
}
if (string.IsNullOrWhiteSpace(site.Land))
{
site.Land = "Einkauf SAP";
changed = true;
}
}
if (!db.SapSourceDefinitions.Any(x => x.SiteId == site.Id))
{
db.SapSourceDefinitions.AddRange(
new SapSourceDefinition { SiteId = site.Id, Alias = "EKKO", EntitySet = "EKKOSet", IsPrimary = true, IsActive = true, SortOrder = 10 },
new SapSourceDefinition { SiteId = site.Id, Alias = "EKPO", EntitySet = "EKPOSet", IsPrimary = false, IsActive = true, SortOrder = 20 },
new SapSourceDefinition { SiteId = site.Id, Alias = "EKET", EntitySet = "eketSet", IsPrimary = false, IsActive = true, SortOrder = 30 },
new SapSourceDefinition { SiteId = site.Id, Alias = "LIEF", EntitySet = "Data", IsPrimary = false, IsActive = true, SortOrder = 40 },
new SapSourceDefinition { SiteId = site.Id, Alias = "WG", EntitySet = "Data2", IsPrimary = false, IsActive = true, SortOrder = 50 });
changed = true;
}
if (!db.SapJoinDefinitions.Any(x => x.SiteId == site.Id))
{
db.SapJoinDefinitions.AddRange(
new SapJoinDefinition { SiteId = site.Id, LeftAlias = "EKKO", RightAlias = "EKPO", LeftKeys = "Ebeln", RightKeys = "Ebeln", JoinType = "Left", IsActive = true, SortOrder = 10 },
new SapJoinDefinition { SiteId = site.Id, LeftAlias = "EKPO", RightAlias = "EKET", LeftKeys = "Ebeln,Ebelp", RightKeys = "Ebeln,Ebelp", JoinType = "Left", IsActive = true, SortOrder = 20 },
new SapJoinDefinition { SiteId = site.Id, LeftAlias = "EKKO", RightAlias = "LIEF", LeftKeys = "Lifnr", RightKeys = "Lifnr", JoinType = "Left", IsActive = true, SortOrder = 30 },
new SapJoinDefinition { SiteId = site.Id, LeftAlias = "EKPO", RightAlias = "WG", LeftKeys = "Matkl", RightKeys = "Matkl", JoinType = "Left", IsActive = true, SortOrder = 40 });
changed = true;
}
if (!db.SapFieldMappings.Any(x => x.SiteId == site.Id))
{
db.SapFieldMappings.AddRange(
new SapFieldMapping { SiteId = site.Id, TargetField = "PurchaseOrder", SourceExpression = "EKKO.Ebeln", IsRequired = true, IsActive = true, SortOrder = 10 },
new SapFieldMapping { SiteId = site.Id, TargetField = "PurchaseOrderDate", SourceExpression = "EKKO.Bedat", IsRequired = true, IsActive = true, SortOrder = 20 },
new SapFieldMapping { SiteId = site.Id, TargetField = "SupplierNumber", SourceExpression = "EKKO.Lifnr", IsRequired = false, IsActive = true, SortOrder = 30 },
new SapFieldMapping { SiteId = site.Id, TargetField = "SupplierName", SourceExpression = "LIEF.Name", IsRequired = false, IsActive = true, SortOrder = 40 },
new SapFieldMapping { SiteId = site.Id, TargetField = "Position", SourceExpression = "EKPO.Ebelp", IsRequired = true, IsActive = true, SortOrder = 50 },
new SapFieldMapping { SiteId = site.Id, TargetField = "Material", SourceExpression = "EKPO.Matnr", IsRequired = false, IsActive = true, SortOrder = 60 },
new SapFieldMapping { SiteId = site.Id, TargetField = "MaterialText", SourceExpression = "EKPO.Txz01", IsRequired = false, IsActive = true, SortOrder = 70 },
new SapFieldMapping { SiteId = site.Id, TargetField = "MaterialGroup", SourceExpression = "EKPO.Matkl", IsRequired = false, IsActive = true, SortOrder = 80 },
new SapFieldMapping { SiteId = site.Id, TargetField = "MaterialGroupText", SourceExpression = "WG.WgKomplett", IsRequired = false, IsActive = true, SortOrder = 90 },
new SapFieldMapping { SiteId = site.Id, TargetField = "NetValueChf", SourceExpression = "EKPO.NetwrChf", IsRequired = false, IsActive = true, SortOrder = 100 },
new SapFieldMapping { SiteId = site.Id, TargetField = "NetValueChfPerPiece", SourceExpression = "EKPO.NetwrChfStk", IsRequired = false, IsActive = true, SortOrder = 110 },
new SapFieldMapping { SiteId = site.Id, TargetField = "OrderQuantity", SourceExpression = "EKPO.Menge", IsRequired = false, IsActive = true, SortOrder = 120 },
new SapFieldMapping { SiteId = site.Id, TargetField = "ScheduleDate", SourceExpression = "EKET.Eindt", IsRequired = false, IsActive = true, SortOrder = 130 },
new SapFieldMapping { SiteId = site.Id, TargetField = "ScheduleQuantity", SourceExpression = "EKET.Menge", IsRequired = false, IsActive = true, SortOrder = 140 });
changed = true;
}
if (changed)
db.SaveChanges();
}
private static void EnsureFinanceReferenceDefaults(AppDbContext db)
{
var defaults = new[]
@@ -762,7 +1325,7 @@ public class DatabaseSeedService : IDatabaseSeedService
new FinanceReference { Key = "CN", Label = "Trafag CN", Year = 2025 },
new FinanceReference { Key = "CZ", Label = "Trafag CZ", Year = 2025, LocalCurrencyValue = 95458782m },
new FinanceReference { Key = "DE", Label = "Trafag DE", Year = 2025, LocalCurrencyValue = 3652394.46m },
new FinanceReference { Key = "ES", Label = "Trafag ES", Year = 2025, LocalCurrencyValue = 3102333.61m },
new FinanceReference { Key = "ES", Label = "Trafag ES", Year = 2025, LocalCurrencyValue = 3082320.18m, Notes = "Sitzung 2026-06-01: ES-Ist 3'082'320.18 EUR fachlich bestaetigt; alter Sollwert 3'102'333.61 war Referenz-/Excel-Fehler." },
new FinanceReference { Key = "FR", Label = "Trafag FR", Year = 2025, LocalCurrencyValue = 1450582m, CheckValue = 1471218m },
new FinanceReference { Key = "GFS", Label = "Trafag GfS", Year = 2025, LocalCurrencyValue = 6495513m },
new FinanceReference { Key = "IN", Label = "Trafag IN", Year = 2025, LocalCurrencyValue = 747341702m, CheckValue = 750936591m },
@@ -798,9 +1361,11 @@ public class DatabaseSeedService : IDatabaseSeedService
}
}
if (current.Key == "ES" && current.Year == 2025 && current.LocalCurrencyValue != 3102333.61m)
if (current.Key == "ES" && current.Year == 2025 && current.LocalCurrencyValue != 3082320.18m)
{
current.LocalCurrencyValue = 3102333.61m;
current.LocalCurrencyValue = 3082320.18m;
current.CheckValue = null;
current.Notes = "Sitzung 2026-06-01: ES-Ist 3'082'320.18 EUR fachlich bestaetigt; alter Sollwert 3'102'333.61 war Referenz-/Excel-Fehler.";
changed = true;
}
@@ -823,27 +1388,38 @@ public class DatabaseSeedService : IDatabaseSeedService
private static void EnsureBudgetExchangeRateDefaults(AppDbContext db)
{
var defaults = new (string From, string To, decimal Rate)[]
var defaults = new (int Year, string From, string To, decimal Rate)[]
{
("CHF", "CHF", 1m),
("USD", "CHF", 0.85m),
("EUR", "CHF", 0.95m),
("GBP", "CHF", 1.13m),
("CNY", "CHF", 1m / 8.50m),
("INR", "CHF", 1m / 90.91m),
("CZK", "CHF", 1m / 25.64m),
("PLN", "CHF", 0.22m),
("JPY", "CHF", 1m / 156.25m)
(2025, "CHF", "CHF", 1m),
(2025, "USD", "CHF", 0.85m),
(2025, "EUR", "CHF", 0.95m),
(2025, "GBP", "CHF", 1.13m),
(2025, "CNY", "CHF", 1m / 8.50m),
(2025, "INR", "CHF", 1m / 90.91m),
(2025, "CZK", "CHF", 1m / 25.64m),
(2025, "PLN", "CHF", 0.22m),
(2025, "JPY", "CHF", 1m / 156.25m),
(2026, "CHF", "CHF", 1m),
(2026, "USD", "CHF", 0.80m),
(2026, "EUR", "CHF", 0.94m),
(2026, "GBP", "CHF", 1.09m),
(2026, "CNY", "CHF", 1m / 8.50m),
(2026, "INR", "CHF", 1m / 110m),
(2026, "CZK", "CHF", 1m / 26m),
(2026, "PLN", "CHF", 0.22m),
(2026, "JPY", "CHF", 1m / 175m)
};
var changed = false;
foreach (var item in defaults)
{
var validFrom = new DateTime(item.Year, 1, 1);
var notes = $"Budget {item.Year}";
var exists = db.CurrencyExchangeRates.Any(x =>
x.FromCurrency == item.From &&
x.ToCurrency == item.To &&
x.ValidFrom == new DateTime(2025, 1, 1) &&
x.Notes == "Budget 2025");
x.ValidFrom == validFrom &&
x.Notes == notes);
if (exists)
continue;
@@ -852,9 +1428,9 @@ public class DatabaseSeedService : IDatabaseSeedService
FromCurrency = item.From,
ToCurrency = item.To,
Rate = item.Rate,
ValidFrom = new DateTime(2025, 1, 1),
ValidTo = new DateTime(2025, 12, 31),
Notes = "Budget 2025",
ValidFrom = validFrom,
ValidTo = new DateTime(item.Year, 12, 31),
Notes = notes,
IsActive = true
});
changed = true;
@@ -84,6 +84,13 @@ public class ExcelExportService : IExcelExportService
"Material",
"Name",
"Product Group",
"Product Hierarchy Code",
"Product Hierarchy Text",
"Product Family Code",
"Product Family Text",
"Product Division Code",
"Product Division Text",
"Product Mapping Assigned",
"Quantity",
"Supplier number",
"Supplier name",
@@ -137,44 +144,51 @@ public class ExcelExportService : IExcelExportService
ws.Cell(row, 6).Value = record.Material;
ws.Cell(row, 7).Value = record.Name;
ws.Cell(row, 8).Value = record.ProductGroup;
ws.Cell(row, 9).Value = record.Quantity;
ws.Cell(row, 10).Value = record.SupplierNumber;
ws.Cell(row, 11).Value = record.SupplierName;
ws.Cell(row, 12).Value = record.SupplierCountry;
ws.Cell(row, 13).Value = record.CustomerNumber;
ws.Cell(row, 14).Value = record.CustomerName;
ws.Cell(row, 15).Value = record.CustomerCountry;
ws.Cell(row, 16).Value = record.CustomerIndustry;
ws.Cell(row, 17).Value = record.StandardCost;
ws.Cell(row, 18).Value = record.StandardCostCurrency;
ws.Cell(row, 19).Value = record.PurchaseOrderNumber;
ws.Cell(row, 20).Value = record.SalesPriceValue;
ws.Cell(row, 21).Value = record.SalesCurrency;
ws.Cell(row, 22).Value = record.DocumentCurrency;
ws.Cell(row, 23).Value = record.DocumentTotalForeignCurrency;
ws.Cell(row, 24).Value = record.DocumentTotalLocalCurrency;
ws.Cell(row, 25).Value = record.VatSumForeignCurrency;
ws.Cell(row, 26).Value = record.VatSumLocalCurrency;
ws.Cell(row, 27).Value = record.DocumentRate;
ws.Cell(row, 28).Value = record.CompanyCurrency;
ws.Cell(row, 29).Value = record.Incoterms2020;
ws.Cell(row, 30).Value = record.SalesResponsibleEmployee;
ws.Cell(row, 31).Value = record.PostingDate?.ToString("dd.MM.yyyy") ?? string.Empty;
ws.Cell(row, 32).Value = record.InvoiceDate?.ToString("dd.MM.yyyy") ?? string.Empty;
ws.Cell(row, 33).Value = record.OrderDate?.ToString("dd.MM.yyyy") ?? string.Empty;
ws.Cell(row, 34).Value = record.Land;
ws.Cell(row, 35).Value = record.DocumentType;
ws.Cell(row, 9).Value = record.ProductHierarchyCode;
ws.Cell(row, 10).Value = record.ProductHierarchyText;
ws.Cell(row, 11).Value = record.ProductFamilyCode;
ws.Cell(row, 12).Value = record.ProductFamilyText;
ws.Cell(row, 13).Value = record.ProductDivisionCode;
ws.Cell(row, 14).Value = record.ProductDivisionText;
ws.Cell(row, 15).Value = record.ProductMappingAssigned;
ws.Cell(row, 16).Value = record.Quantity;
ws.Cell(row, 17).Value = record.SupplierNumber;
ws.Cell(row, 18).Value = record.SupplierName;
ws.Cell(row, 19).Value = record.SupplierCountry;
ws.Cell(row, 20).Value = record.CustomerNumber;
ws.Cell(row, 21).Value = record.CustomerName;
ws.Cell(row, 22).Value = record.CustomerCountry;
ws.Cell(row, 23).Value = record.CustomerIndustry;
ws.Cell(row, 24).Value = record.StandardCost;
ws.Cell(row, 25).Value = record.StandardCostCurrency;
ws.Cell(row, 26).Value = record.PurchaseOrderNumber;
ws.Cell(row, 27).Value = record.SalesPriceValue;
ws.Cell(row, 28).Value = record.SalesCurrency;
ws.Cell(row, 29).Value = record.DocumentCurrency;
ws.Cell(row, 30).Value = record.DocumentTotalForeignCurrency;
ws.Cell(row, 31).Value = record.DocumentTotalLocalCurrency;
ws.Cell(row, 32).Value = record.VatSumForeignCurrency;
ws.Cell(row, 33).Value = record.VatSumLocalCurrency;
ws.Cell(row, 34).Value = record.DocumentRate;
ws.Cell(row, 35).Value = record.CompanyCurrency;
ws.Cell(row, 36).Value = record.Incoterms2020;
ws.Cell(row, 37).Value = record.SalesResponsibleEmployee;
ws.Cell(row, 38).Value = record.PostingDate?.ToString("dd.MM.yyyy") ?? string.Empty;
ws.Cell(row, 39).Value = record.InvoiceDate?.ToString("dd.MM.yyyy") ?? string.Empty;
ws.Cell(row, 40).Value = record.OrderDate?.ToString("dd.MM.yyyy") ?? string.Empty;
ws.Cell(row, 41).Value = record.Land;
ws.Cell(row, 42).Value = record.DocumentType;
var financeCountryKey = ResolveFinanceCountryKey(record.Land, record.Tsc);
var financeDate = financeRuleEngine.ResolveFinanceDate(record, financeCountryKey);
var financeInclude = financeRuleEngine.ShouldInclude(record, financeCountryKey);
var financeNetSalesActual = financeRuleEngine.ResolveNetSalesActual(record, financeCountryKey, financeInclude);
ws.Cell(row, 36).Value = financeDate.Year;
ws.Cell(row, 37).Value = financeCountryKey;
ws.Cell(row, 38).Value = financeDate.ToString("dd.MM.yyyy");
ws.Cell(row, 39).Value = financeNetSalesActual;
ws.Cell(row, 40).Value = ResolveFinanceCurrency(record);
ws.Cell(row, 41).Value = financeInclude && financeNetSalesActual != 0m ? "TRUE" : "FALSE";
ws.Cell(row, 42).Value = financeInclude
ws.Cell(row, 43).Value = financeDate.Year;
ws.Cell(row, 44).Value = financeCountryKey;
ws.Cell(row, 45).Value = financeDate.ToString("dd.MM.yyyy");
ws.Cell(row, 46).Value = financeNetSalesActual;
ws.Cell(row, 47).Value = ResolveFinanceCurrency(record);
ws.Cell(row, 48).Value = financeInclude && financeNetSalesActual != 0m ? "TRUE" : "FALSE";
ws.Cell(row, 49).Value = financeInclude
? "Sales Price/Value"
: financeRuleEngine.ResolveExclusionReason(record, financeCountryKey);
row++;
@@ -184,6 +198,7 @@ public class ExcelExportService : IExcelExportService
if (includeFinanceHelpSheet)
{
AddFinanceSummarySheet(workbook, records, financeRules);
AddFinanceDetailsSheet(workbook, records, financeRules);
AddFinanceHelpSheet(workbook);
}
@@ -266,6 +281,107 @@ public class ExcelExportService : IExcelExportService
ws.Columns().AdjustToContents();
}
private static void AddFinanceDetailsSheet(XLWorkbook workbook, List<SalesRecord> records, IReadOnlyList<FinanceRule> financeRules)
{
var ws = workbook.Worksheets.Add("Finance Details");
var financeRuleEngine = new FinanceRuleEngine(financeRules);
ws.Position = 2;
ws.Cell(1, 1).Value = "Finance Details";
ws.Cell(1, 1).Style.Font.Bold = true;
ws.Cell(1, 1).Style.Font.FontSize = 14;
ws.Cell(2, 1).Value = "Diese Zeilen fuehren zur Summe im Blatt Finance Summary. Summe ueber Net Sales Actual bilden.";
var headers = new[]
{
"Year",
"Country Key",
"Currency",
"Finance Date",
"Net Sales Actual",
"Source Value Field",
"TSC",
"Land",
"Document Type",
"Invoice Number",
"Position on invoice",
"Document Entry",
"Material",
"Name",
"Quantity",
"Customer number",
"Customer name",
"Customer country",
"Supplier number",
"Supplier name",
"Supplier country",
"posting date",
"invoice date",
"Sales Price/Value",
"Sales Currency",
"Document Currency",
"Document Total FC",
"Document Total LC",
"Company Currency"
};
for (var i = 0; i < headers.Length; i++)
{
ws.Cell(4, i + 1).Value = headers[i];
ws.Cell(4, i + 1).Style.Font.Bold = true;
}
var rowIndex = 5;
foreach (var record in records)
{
var countryKey = ResolveFinanceCountryKey(record.Land, record.Tsc);
var financeDate = financeRuleEngine.ResolveFinanceDate(record, countryKey);
var rawInclude = financeRuleEngine.ShouldInclude(record, countryKey);
var netSalesActual = financeRuleEngine.ResolveNetSalesActual(record, countryKey, rawInclude);
var include = rawInclude && netSalesActual != 0m;
if (!include)
continue;
ws.Cell(rowIndex, 1).Value = financeDate.Year;
ws.Cell(rowIndex, 2).Value = countryKey;
ws.Cell(rowIndex, 3).Value = ResolveFinanceCurrency(record);
ws.Cell(rowIndex, 4).Value = financeDate.ToString("dd.MM.yyyy");
ws.Cell(rowIndex, 5).Value = netSalesActual;
ws.Cell(rowIndex, 6).Value = "Sales Price/Value";
ws.Cell(rowIndex, 7).Value = record.Tsc;
ws.Cell(rowIndex, 8).Value = record.Land;
ws.Cell(rowIndex, 9).Value = record.DocumentType;
ws.Cell(rowIndex, 10).Value = record.InvoiceNumber;
ws.Cell(rowIndex, 11).Value = record.PositionOnInvoice;
ws.Cell(rowIndex, 12).Value = record.DocumentEntry;
ws.Cell(rowIndex, 13).Value = record.Material;
ws.Cell(rowIndex, 14).Value = record.Name;
ws.Cell(rowIndex, 15).Value = record.Quantity;
ws.Cell(rowIndex, 16).Value = record.CustomerNumber;
ws.Cell(rowIndex, 17).Value = record.CustomerName;
ws.Cell(rowIndex, 18).Value = record.CustomerCountry;
ws.Cell(rowIndex, 19).Value = record.SupplierNumber;
ws.Cell(rowIndex, 20).Value = record.SupplierName;
ws.Cell(rowIndex, 21).Value = record.SupplierCountry;
ws.Cell(rowIndex, 22).Value = record.PostingDate?.ToString("dd.MM.yyyy") ?? string.Empty;
ws.Cell(rowIndex, 23).Value = record.InvoiceDate?.ToString("dd.MM.yyyy") ?? string.Empty;
ws.Cell(rowIndex, 24).Value = record.SalesPriceValue;
ws.Cell(rowIndex, 25).Value = record.SalesCurrency;
ws.Cell(rowIndex, 26).Value = record.DocumentCurrency;
ws.Cell(rowIndex, 27).Value = record.DocumentTotalForeignCurrency;
ws.Cell(rowIndex, 28).Value = record.DocumentTotalLocalCurrency;
ws.Cell(rowIndex, 29).Value = record.CompanyCurrency;
rowIndex++;
}
ws.Column(5).Style.NumberFormat.Format = "#,##0.00";
ws.Column(24).Style.NumberFormat.Format = "#,##0.00";
ws.Column(27).Style.NumberFormat.Format = "#,##0.00";
ws.Column(28).Style.NumberFormat.Format = "#,##0.00";
ws.Columns().AdjustToContents();
}
private static string BuildFinanceSummaryHint(string countryKey)
=> countryKey.ToUpperInvariant() switch
{
@@ -290,6 +406,7 @@ public class ExcelExportService : IExcelExportService
("2. Land filtern", "Finance | Country Key = CH, AT, DE, ES, FR, IN, IT, UK oder US"),
("3. Gueltige Zeilen filtern", "Finance | Include = TRUE"),
("4. Summe bilden", "Finance | Net Sales Actual summieren"),
("Detailblatt", "Finance Details enthaelt nur die Zeilen, die zur Summe im Blatt Finance Summary fuehren."),
("Waehrung", "Finance | Currency zeigt die fuer den Finance-Abgleich fuehrende Hauswaehrung."),
("Datum", "Finance | Date verwendet PostingDate, danach InvoiceDate, danach ExtractionDate. DE Alphaplan wird als Jahresfile 2025 behandelt."),
("Wertquelle", "Finance | Source Value Field zeigt, aus welchem Rohfeld der Finance-Wert kommt."),
@@ -0,0 +1,397 @@
using System.Globalization;
using System.Text;
using TrafagSalesExporter.Models;
namespace TrafagSalesExporter.Services;
public interface IExportAuditCsvService
{
Task<string?> WriteSiteAuditCsvAsync(
Site site,
ExportSettings settings,
string sourceSystem,
string fallbackOutputDirectory,
IReadOnlyList<SalesRecord> records);
Task<List<SalesRecord>> ReadLatestSiteAuditCsvRecordsAsync(ExportSettings settings);
string ResolveAuditCsvDirectory(ExportSettings settings, string? fallbackOutputDirectory = null);
}
public sealed class ExportAuditCsvService : IExportAuditCsvService
{
private const char Delimiter = ';';
private const string ProcessedMergeInputFilePrefix = "Sales_ProcessedMergeInput_";
private const string LegacyFilePrefix = "Sales_";
private static readonly string[] Headers =
[
"SourceSystem",
"ExtractionDate",
"TSC",
"SourceLineId",
"DocumentEntry",
"InvoiceNumber",
"PositionOnInvoice",
"Material",
"Name",
"ProductGroup",
"ProductHierarchyCode",
"ProductHierarchyText",
"ProductFamilyCode",
"ProductFamilyText",
"ProductDivisionCode",
"ProductDivisionText",
"ProductMappingAssigned",
"Quantity",
"SupplierNumber",
"SupplierName",
"SupplierCountry",
"CustomerNumber",
"CustomerName",
"CustomerCountry",
"CustomerIndustry",
"StandardCost",
"StandardCostCurrency",
"PurchaseOrderNumber",
"SalesPriceValue",
"SalesCurrency",
"DocumentCurrency",
"DocumentTotalForeignCurrency",
"DocumentTotalLocalCurrency",
"VatSumForeignCurrency",
"VatSumLocalCurrency",
"DocumentRate",
"CompanyCurrency",
"Incoterms2020",
"SalesResponsibleEmployee",
"PostingDate",
"InvoiceDate",
"OrderDate",
"Land",
"DocumentType"
];
public async Task<string?> WriteSiteAuditCsvAsync(
Site site,
ExportSettings settings,
string sourceSystem,
string fallbackOutputDirectory,
IReadOnlyList<SalesRecord> records)
{
if (!settings.AuditCsvEnabled)
return null;
var directory = ResolveAuditCsvDirectory(settings, fallbackOutputDirectory);
Directory.CreateDirectory(directory);
var tsc = string.IsNullOrWhiteSpace(site.TSC) ? "UNKNOWN" : site.TSC.Trim();
var fileName = $"{ProcessedMergeInputFilePrefix}{SanitizeFileNamePart(tsc)}_{DateTime.UtcNow:yyyy-MM-dd}.csv";
var path = Path.Combine(directory, fileName);
await using var stream = new FileStream(path, FileMode.Create, FileAccess.Write, FileShare.Read);
await using var writer = new StreamWriter(stream, new UTF8Encoding(encoderShouldEmitUTF8Identifier: true));
await writer.WriteLineAsync(string.Join(Delimiter, Headers.Select(Escape)));
foreach (var record in records)
{
await writer.WriteLineAsync(string.Join(Delimiter, BuildRow(site, sourceSystem, record).Select(Escape)));
}
return path;
}
public async Task<List<SalesRecord>> ReadLatestSiteAuditCsvRecordsAsync(ExportSettings settings)
{
var directory = ResolveAuditCsvDirectory(settings);
if (!Directory.Exists(directory))
return [];
var latestFiles = EnumerateAuditCsvFiles(directory)
.Select(path => new { Path = path, Tsc = ResolveTscFromFileName(path) })
.Where(file => !string.IsNullOrWhiteSpace(file.Tsc))
.GroupBy(file => file.Tsc, StringComparer.OrdinalIgnoreCase)
.Select(group => group
.OrderByDescending(file => File.GetLastWriteTimeUtc(file.Path))
.ThenByDescending(file => IsProcessedMergeInputFile(file.Path))
.ThenByDescending(file => Path.GetFileName(file.Path), StringComparer.OrdinalIgnoreCase)
.First()
.Path)
.OrderBy(path => path, StringComparer.OrdinalIgnoreCase)
.ToList();
var records = new List<SalesRecord>();
foreach (var file in latestFiles)
records.AddRange(await ReadFileAsync(file));
return records;
}
public string ResolveAuditCsvDirectory(ExportSettings settings, string? fallbackOutputDirectory = null)
{
if (!string.IsNullOrWhiteSpace(fallbackOutputDirectory))
return fallbackOutputDirectory.Trim();
if (!string.IsNullOrWhiteSpace(settings.LocalSiteExportFolder))
return settings.LocalSiteExportFolder.Trim();
return Path.Combine(AppContext.BaseDirectory, "output");
}
private static IEnumerable<string> BuildRow(Site site, string sourceSystem, SalesRecord record)
{
yield return string.IsNullOrWhiteSpace(record.SourceSystem) ? sourceSystem : record.SourceSystem;
yield return FormatDate(record.ExtractionDate);
yield return record.Tsc;
yield return record.SourceLineId;
yield return FormatInt(record.DocumentEntry);
yield return record.InvoiceNumber;
yield return FormatInt(record.PositionOnInvoice);
yield return record.Material;
yield return record.Name;
yield return record.ProductGroup;
yield return record.ProductHierarchyCode;
yield return record.ProductHierarchyText;
yield return record.ProductFamilyCode;
yield return record.ProductFamilyText;
yield return record.ProductDivisionCode;
yield return record.ProductDivisionText;
yield return record.ProductMappingAssigned;
yield return FormatDecimal(record.Quantity);
yield return record.SupplierNumber;
yield return record.SupplierName;
yield return record.SupplierCountry;
yield return record.CustomerNumber;
yield return record.CustomerName;
yield return record.CustomerCountry;
yield return record.CustomerIndustry;
yield return FormatDecimal(record.StandardCost);
yield return record.StandardCostCurrency;
yield return record.PurchaseOrderNumber;
yield return FormatDecimal(record.SalesPriceValue);
yield return record.SalesCurrency;
yield return record.DocumentCurrency;
yield return FormatDecimal(record.DocumentTotalForeignCurrency);
yield return FormatDecimal(record.DocumentTotalLocalCurrency);
yield return FormatDecimal(record.VatSumForeignCurrency);
yield return FormatDecimal(record.VatSumLocalCurrency);
yield return FormatDecimal(record.DocumentRate);
yield return record.CompanyCurrency;
yield return record.Incoterms2020;
yield return record.SalesResponsibleEmployee;
yield return FormatNullableDate(record.PostingDate);
yield return FormatNullableDate(record.InvoiceDate);
yield return FormatNullableDate(record.OrderDate);
yield return string.IsNullOrWhiteSpace(record.Land) ? site.Land : record.Land;
yield return record.DocumentType;
}
private static async Task<List<SalesRecord>> ReadFileAsync(string path)
{
using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite);
using var reader = new StreamReader(stream, Encoding.UTF8, detectEncodingFromByteOrderMarks: true);
var headerLine = await reader.ReadLineAsync();
if (string.IsNullOrWhiteSpace(headerLine))
return [];
var headers = ParseLine(headerLine)
.Select((value, index) => new { Header = NormalizeHeader(value), Index = index })
.Where(x => !string.IsNullOrWhiteSpace(x.Header))
.ToDictionary(x => x.Header, x => x.Index, StringComparer.OrdinalIgnoreCase);
var records = new List<SalesRecord>();
while (!reader.EndOfStream)
{
var line = await reader.ReadLineAsync();
if (string.IsNullOrWhiteSpace(line))
continue;
var values = ParseLine(line);
records.Add(new SalesRecord
{
SourceSystem = GetText(values, headers, "SourceSystem"),
ExtractionDate = GetDate(values, headers, "ExtractionDate") ?? File.GetLastWriteTime(path),
Tsc = GetText(values, headers, "TSC"),
SourceLineId = GetText(values, headers, "SourceLineId"),
DocumentEntry = GetInt(values, headers, "DocumentEntry"),
InvoiceNumber = GetText(values, headers, "InvoiceNumber"),
PositionOnInvoice = GetInt(values, headers, "PositionOnInvoice"),
Material = GetText(values, headers, "Material"),
Name = GetText(values, headers, "Name"),
ProductGroup = GetText(values, headers, "ProductGroup"),
ProductHierarchyCode = GetText(values, headers, "ProductHierarchyCode"),
ProductHierarchyText = GetText(values, headers, "ProductHierarchyText"),
ProductFamilyCode = GetText(values, headers, "ProductFamilyCode"),
ProductFamilyText = GetText(values, headers, "ProductFamilyText"),
ProductDivisionCode = GetText(values, headers, "ProductDivisionCode"),
ProductDivisionText = GetText(values, headers, "ProductDivisionText"),
ProductMappingAssigned = GetText(values, headers, "ProductMappingAssigned"),
Quantity = GetDecimal(values, headers, "Quantity"),
SupplierNumber = GetText(values, headers, "SupplierNumber"),
SupplierName = GetText(values, headers, "SupplierName"),
SupplierCountry = GetText(values, headers, "SupplierCountry"),
CustomerNumber = GetText(values, headers, "CustomerNumber"),
CustomerName = GetText(values, headers, "CustomerName"),
CustomerCountry = GetText(values, headers, "CustomerCountry"),
CustomerIndustry = GetText(values, headers, "CustomerIndustry"),
StandardCost = GetDecimal(values, headers, "StandardCost"),
StandardCostCurrency = GetText(values, headers, "StandardCostCurrency"),
PurchaseOrderNumber = GetText(values, headers, "PurchaseOrderNumber"),
SalesPriceValue = GetDecimal(values, headers, "SalesPriceValue"),
SalesCurrency = GetText(values, headers, "SalesCurrency"),
DocumentCurrency = GetText(values, headers, "DocumentCurrency"),
DocumentTotalForeignCurrency = GetDecimal(values, headers, "DocumentTotalForeignCurrency"),
DocumentTotalLocalCurrency = GetDecimal(values, headers, "DocumentTotalLocalCurrency"),
VatSumForeignCurrency = GetDecimal(values, headers, "VatSumForeignCurrency"),
VatSumLocalCurrency = GetDecimal(values, headers, "VatSumLocalCurrency"),
DocumentRate = GetDecimal(values, headers, "DocumentRate"),
CompanyCurrency = GetText(values, headers, "CompanyCurrency"),
Incoterms2020 = GetText(values, headers, "Incoterms2020"),
SalesResponsibleEmployee = GetText(values, headers, "SalesResponsibleEmployee"),
PostingDate = GetDate(values, headers, "PostingDate"),
InvoiceDate = GetDate(values, headers, "InvoiceDate"),
OrderDate = GetDate(values, headers, "OrderDate"),
Land = GetText(values, headers, "Land"),
DocumentType = GetText(values, headers, "DocumentType")
});
}
return records;
}
private static string ResolveTscFromFileName(string path)
{
var name = Path.GetFileNameWithoutExtension(path);
if (name.StartsWith(ProcessedMergeInputFilePrefix, StringComparison.OrdinalIgnoreCase))
return ResolveTscFromSuffix(name[ProcessedMergeInputFilePrefix.Length..]);
if (name.StartsWith(LegacyFilePrefix, StringComparison.OrdinalIgnoreCase))
return ResolveTscFromSuffix(name[LegacyFilePrefix.Length..]);
return string.Empty;
}
private static string ResolveTscFromSuffix(string suffix)
{
var lastUnderscore = suffix.LastIndexOf('_');
return lastUnderscore <= 0 ? suffix : suffix[..lastUnderscore];
}
private static IEnumerable<string> EnumerateAuditCsvFiles(string directory)
=> Directory.EnumerateFiles(directory, $"{ProcessedMergeInputFilePrefix}*.csv", SearchOption.TopDirectoryOnly)
.Concat(Directory.EnumerateFiles(directory, $"{LegacyFilePrefix}*.csv", SearchOption.TopDirectoryOnly)
.Where(path => !IsProcessedMergeInputFile(path)));
private static bool IsProcessedMergeInputFile(string path)
=> Path.GetFileName(path).StartsWith(ProcessedMergeInputFilePrefix, StringComparison.OrdinalIgnoreCase);
private static string SanitizeFileNamePart(string value)
{
var invalid = Path.GetInvalidFileNameChars();
var chars = value.Select(ch => invalid.Contains(ch) ? '_' : ch).ToArray();
return new string(chars);
}
private static string Escape(string? value)
{
var text = (value ?? string.Empty)
.Replace("\r\n", " ", StringComparison.Ordinal)
.Replace('\r', ' ')
.Replace('\n', ' ');
if (text.Contains(Delimiter) || text.Contains('"') || text.Contains('\r') || text.Contains('\n'))
return $"\"{text.Replace("\"", "\"\"")}\"";
return text;
}
private static List<string> ParseLine(string line)
{
var values = new List<string>();
var current = new StringBuilder();
var inQuotes = false;
for (var i = 0; i < line.Length; i++)
{
var ch = line[i];
if (ch == '"')
{
if (inQuotes && i + 1 < line.Length && line[i + 1] == '"')
{
current.Append('"');
i++;
}
else
{
inQuotes = !inQuotes;
}
continue;
}
if (ch == Delimiter && !inQuotes)
{
values.Add(current.ToString());
current.Clear();
continue;
}
current.Append(ch);
}
values.Add(current.ToString());
return values;
}
private static string NormalizeHeader(string value)
=> new(value.Where(char.IsLetterOrDigit).ToArray());
private static string GetText(IReadOnlyList<string> values, IReadOnlyDictionary<string, int> headers, string header)
=> headers.TryGetValue(NormalizeHeader(header), out var index) && index >= 0 && index < values.Count
? values[index].Trim()
: string.Empty;
private static int GetInt(IReadOnlyList<string> values, IReadOnlyDictionary<string, int> headers, string header)
=> int.TryParse(GetText(values, headers, header), NumberStyles.Integer, CultureInfo.InvariantCulture, out var value)
? value
: 0;
private static decimal GetDecimal(IReadOnlyList<string> values, IReadOnlyDictionary<string, int> headers, string header)
{
var text = GetText(values, headers, header);
if (decimal.TryParse(text, NumberStyles.Any, CultureInfo.InvariantCulture, out var invariant))
return invariant;
if (decimal.TryParse(text, NumberStyles.Any, CultureInfo.GetCultureInfo("de-CH"), out var swiss))
return swiss;
return 0m;
}
private static DateTime? GetDate(IReadOnlyList<string> values, IReadOnlyDictionary<string, int> headers, string header)
{
var text = GetText(values, headers, header);
if (string.IsNullOrWhiteSpace(text))
return null;
if (DateTime.TryParse(text, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind, out var roundtrip))
return roundtrip;
if (DateTime.TryParse(text, CultureInfo.GetCultureInfo("de-CH"), DateTimeStyles.AssumeLocal, out var swiss))
return swiss;
return null;
}
private static string FormatInt(int value)
=> value.ToString(CultureInfo.InvariantCulture);
private static string FormatDecimal(decimal value)
=> value.ToString(CultureInfo.InvariantCulture);
private static string FormatDate(DateTime value)
=> value.ToString("O", CultureInfo.InvariantCulture);
private static string FormatNullableDate(DateTime? value)
=> value?.ToString("O", CultureInfo.InvariantCulture) ?? string.Empty;
}
@@ -11,16 +11,31 @@ public interface IFinanceCockpitAccessService
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 FinanceCockpitAccessService : IFinanceCockpitAccessService
public sealed class FinanceCockpitAccessService : IFinanceCockpitAccessService, IDisposable
{
private readonly FinanceCockpitAccessOptions _options;
private readonly IHostEnvironment _environment;
private readonly IHttpContextAccessor _httpContextAccessor;
private readonly IAccessSessionTracker _sessionTracker;
private readonly ILogger<FinanceCockpitAccessService> _logger;
private readonly string _sessionId = Guid.NewGuid().ToString("N");
public FinanceCockpitAccessService(IOptions<FinanceCockpitAccessOptions> options)
public FinanceCockpitAccessService(
IOptions<FinanceCockpitAccessOptions> options,
IHostEnvironment environment,
IHttpContextAccessor httpContextAccessor,
IAccessSessionTracker sessionTracker,
ILogger<FinanceCockpitAccessService> logger)
{
_options = options.Value;
_environment = environment;
_httpContextAccessor = httpContextAccessor;
_sessionTracker = sessionTracker;
_logger = logger;
}
public bool IsEnabled => _options.Enabled;
@@ -30,13 +45,21 @@ public sealed class FinanceCockpitAccessService : IFinanceCockpitAccessService
!string.IsNullOrWhiteSpace(_options.Username) &&
(!string.IsNullOrWhiteSpace(_options.PasswordHash) || !string.IsNullOrEmpty(_options.Password));
public bool IsUnlocked { get; private set; }
public bool IsUnlocked =>
_isUnlocked ||
AccessUnlockCookie.IsUnlocked(
_httpContextAccessor.HttpContext,
AccessUnlockCookie.FinanceCookieName,
_options.PasswordHash);
private bool _isUnlocked;
public bool TryUnlock(string username, string password)
{
if (!IsEnabled)
{
IsUnlocked = true;
_isUnlocked = true;
_logger.LogInformation("Finance Cockpit access unlocked because FinanceCockpitAccess is disabled.");
return true;
}
@@ -45,6 +68,12 @@ public sealed class FinanceCockpitAccessService : IFinanceCockpitAccessService
string.IsNullOrEmpty(password) ||
!FixedEquals(username.Trim(), _options.Username.Trim()))
{
_logger.LogWarning(
"Finance Cockpit 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;
}
@@ -52,15 +81,56 @@ public sealed class FinanceCockpitAccessService : IFinanceCockpitAccessService
? VerifyPasswordHash(password, _options.PasswordHash)
: FixedEquals(password, _options.Password);
IsUnlocked = valid;
_isUnlocked = valid;
_logger.Log(
valid ? LogLevel.Information : LogLevel.Warning,
"Finance Cockpit password check completed. Success={Success}, Username={Username}, PasswordLength={PasswordLength}, UsesHash={UsesHash}",
valid,
username.Trim(),
password.Length,
!string.IsNullOrWhiteSpace(_options.PasswordHash));
if (valid)
_sessionTracker.Register(_sessionId, "Finance Cockpit", username.Trim(), GetRemoteAddress());
return valid;
}
public void Lock() => IsUnlocked = false;
public void Lock()
{
_isUnlocked = false;
_sessionTracker.Unregister(_sessionId);
}
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, FinanceCockpitAccessOptions.SectionName, passwordHash);
_options.PasswordHash = passwordHash;
_options.Password = string.Empty;
_isUnlocked = true;
_sessionTracker.Register(_sessionId, "Finance Cockpit", username.Trim(), GetRemoteAddress());
return true;
}
public void Dispose()
{
_sessionTracker.Unregister(_sessionId);
}
private string? GetRemoteAddress()
=> _httpContextAccessor.HttpContext?.Connection.RemoteIpAddress?.ToString();
private static bool VerifyPasswordHash(string password, string configuredHash)
{
var passwordHash = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(password)));
var passwordHash = AccessPasswordSettingsWriter.HashPassword(password);
return FixedEquals(passwordHash, configuredHash.Trim());
}
@@ -12,10 +12,19 @@ public interface IFinanceReconciliationService
public sealed class FinanceReconciliationService : IFinanceReconciliationService
{
private readonly IDbContextFactory<AppDbContext> _dbFactory;
private readonly ICentralSalesDataProvider? _centralSalesDataProvider;
public FinanceReconciliationService(IDbContextFactory<AppDbContext> dbFactory)
: this(dbFactory, null)
{
}
public FinanceReconciliationService(
IDbContextFactory<AppDbContext> dbFactory,
ICentralSalesDataProvider? centralSalesDataProvider)
{
_dbFactory = dbFactory;
_centralSalesDataProvider = centralSalesDataProvider;
}
public async Task<List<NetSalesReferenceRow>> BuildNetSalesReferenceRowsAsync(int year = 2025)
@@ -41,35 +50,7 @@ public sealed class FinanceReconciliationService : IFinanceReconciliationService
financeRules = FinanceRuleEngine.CreateDefaultRules().ToList();
var financeRuleEngine = new FinanceRuleEngine(financeRules);
var centralRecords = await db.CentralSalesRecords
.AsNoTracking()
.Select(r => new SalesRecord
{
Land = r.Land,
Tsc = r.Tsc,
DocumentEntry = r.DocumentEntry,
InvoiceNumber = r.InvoiceNumber,
PositionOnInvoice = r.PositionOnInvoice,
Material = r.Material,
Name = r.Name,
Quantity = r.Quantity,
DocumentType = r.DocumentType,
PostingDate = r.PostingDate,
InvoiceDate = r.InvoiceDate,
ExtractionDate = r.ExtractionDate,
CustomerNumber = r.CustomerNumber,
CustomerName = r.CustomerName,
SupplierCountry = r.SupplierCountry,
SalesCurrency = r.SalesCurrency,
DocumentCurrency = r.DocumentCurrency,
CompanyCurrency = r.CompanyCurrency,
SalesPriceValue = r.SalesPriceValue,
DocumentTotalForeignCurrency = r.DocumentTotalForeignCurrency,
DocumentTotalLocalCurrency = r.DocumentTotalLocalCurrency,
VatSumForeignCurrency = r.VatSumForeignCurrency,
VatSumLocalCurrency = r.VatSumLocalCurrency
})
.ToListAsync();
var centralRecords = await LoadCentralRecordsAsync(db);
var centralRows = centralRecords
.Select(record => ApplyFinanceRules(record, year, financeRuleEngine))
@@ -165,6 +146,42 @@ public sealed class FinanceReconciliationService : IFinanceReconciliationService
return result;
}
private async Task<List<SalesRecord>> LoadCentralRecordsAsync(AppDbContext db)
{
if (_centralSalesDataProvider is not null)
return await _centralSalesDataProvider.GetRecordsAsync();
return await db.CentralSalesRecords
.AsNoTracking()
.Select(r => new SalesRecord
{
Land = r.Land,
Tsc = r.Tsc,
DocumentEntry = r.DocumentEntry,
InvoiceNumber = r.InvoiceNumber,
PositionOnInvoice = r.PositionOnInvoice,
Material = r.Material,
Name = r.Name,
Quantity = r.Quantity,
DocumentType = r.DocumentType,
PostingDate = r.PostingDate,
InvoiceDate = r.InvoiceDate,
ExtractionDate = r.ExtractionDate,
CustomerNumber = r.CustomerNumber,
CustomerName = r.CustomerName,
SupplierCountry = r.SupplierCountry,
SalesCurrency = r.SalesCurrency,
DocumentCurrency = r.DocumentCurrency,
CompanyCurrency = r.CompanyCurrency,
SalesPriceValue = r.SalesPriceValue,
DocumentTotalForeignCurrency = r.DocumentTotalForeignCurrency,
DocumentTotalLocalCurrency = r.DocumentTotalLocalCurrency,
VatSumForeignCurrency = r.VatSumForeignCurrency,
VatSumLocalCurrency = r.VatSumLocalCurrency
})
.ToListAsync();
}
private static NetSalesActualSourceRow? ApplyFinanceRules(SalesRecord record, int year, FinanceRuleEngine financeRuleEngine)
{
var referenceKey = ResolveReferenceKey(record.Land, record.Tsc);
@@ -90,6 +90,7 @@ internal sealed class HrKpiDashboardBuilder
var turnoverEmployees = ApplyTurnoverEmployeeFilters(employees, normalizedOptions).ToList();
var turnoverHeadcountLeavers = ApplyTurnoverHeadcountLeaverFilters(leavers, normalizedOptions).ToList();
var analysisPeriod = ResolveAnalysisPeriod(normalizedOptions);
var filteredEmployees = ApplyEmployeeFilters(employees, normalizedOptions).ToList();
var filteredEmployeeNumbers = filteredEmployees
.Where(x => x.Personalnummer.HasValue)
@@ -97,6 +98,7 @@ internal sealed class HrKpiDashboardBuilder
.ToHashSet();
employees = filteredEmployees;
var absenceRowsWithoutDates = absences.Count(x => !x.VonDatum.HasValue && !x.BisDatum.HasValue);
absences = ApplyAbsenceFilters(absences, normalizedOptions, filteredEmployeeNumbers).ToList();
leavers = ApplyLeaverFilters(leavers, normalizedOptions).ToList();
var turnoverPeriod = ResolveTurnoverPeriodScope(normalizedOptions, leavers);
@@ -104,9 +106,9 @@ internal sealed class HrKpiDashboardBuilder
result.Employees = employees;
result.Absences = absences;
result.Leavers = leavers;
result.Metrics = BuildOverviewMetrics(employees, absences, turnoverEmployees, turnoverHeadcountLeavers, leavers, turnoverPeriod);
result.Metrics = BuildOverviewMetrics(employees, absences, turnoverEmployees, turnoverHeadcountLeavers, leavers, turnoverPeriod, analysisPeriod);
result.TurnoverMetrics = BuildTurnoverMetrics(turnoverEmployees, turnoverHeadcountLeavers, leavers, turnoverPeriod);
result.AbsenceMetrics = BuildAbsenceMetrics(employees, absences);
result.AbsenceMetrics = BuildAbsenceMetrics(employees, absences, analysisPeriod);
result.TimeVacationMetrics = BuildTimeVacationMetrics(employees);
result.PeriodComparisonMetrics = BuildPeriodComparisonMetrics(turnoverEmployees, turnoverHeadcountLeavers, leavers, turnoverPeriod);
result.TrafficLights = BuildTrafficLights(result.Metrics, result.TurnoverMetrics, result.AbsenceMetrics, result.TimeVacationMetrics, context);
@@ -158,6 +160,8 @@ internal sealed class HrKpiDashboardBuilder
result.Notices.Add($"{missingFteCount:N0} aktive Mitarbeitendenzeilen ohne SAP-Beschaeftigungsgrad verwenden einen FTE-Fallback aus Rexx-Arbeitszeitmodell/Sollzeit.");
if (HasEmployeeOnlyTurnoverFilters(normalizedOptions))
result.Notices.Add("Kostenstelle, GLZ und Restferien filtern aktive Mitarbeitende und Absenzen, aber nicht die Fluktuation. Die Austrittsdatei enthaelt diese Felder nicht stabil genug fuer denselben Schnitt.");
if (analysisPeriod.HasPeriod && absenceRowsWithoutDates > 0)
result.Notices.Add("Rexx-Absenzen enthalten keine Datumsfelder. Der Zeitraumfilter setzt voraus, dass Abwesenheitinstunden.xlsx bereits fuer den gewaehlten Zeitraum exportiert wurde; die Absenzquote nutzt den gewaehlten Zeitraum als Nenner.");
if (!context.HasFile(_dataSources.MainFile))
result.Notices.Add($"Hauptdatei fehlt: {_dataSources.MainFile}. Ohne diese Datei sind keine HR-KPIs moeglich.");
if (!context.HasFile(_dataSources.SapFile))
@@ -299,6 +303,8 @@ internal sealed class HrKpiDashboardBuilder
{
return context.ReadRows(_dataSources.AbsenceFile, "Rexx #744 Absenzen", (row, headers) =>
{
var fromDate = ReadDate(row, headers, "Von Datum", "Von", "Beginn", "Startdatum", "Abwesenheit von", "Datum");
var toDate = ReadDate(row, headers, "Bis Datum", "Bis", "Ende", "Enddatum", "Abwesenheit bis", "Datum");
var kurz = ReadDecimal(row, headers, "Krankheit angetreten (Stunden Ind.)", "Krankheit_Kurz_Std");
var lang = ReadDecimal(row, headers, "Krank nicht buchbar angetreten (Stunden Ind.)", "Krankheit_Lang_Std");
var gesamt = kurz + lang;
@@ -310,6 +316,8 @@ internal sealed class HrKpiDashboardBuilder
Organisationseinheit = ReadString(row, headers, "Organisation"),
Stelle = ReadString(row, headers, "Stelle"),
Status = ReadString(row, headers, "Personal Status", "Status"),
VonDatum = fromDate,
BisDatum = toDate ?? fromDate,
KrankheitKurzStd = kurz,
KrankheitLangStd = lang,
KrankheitGesamtStd = gesamt,
@@ -406,6 +414,7 @@ internal sealed class HrKpiDashboardBuilder
=> rows.Where(x => MatchesFilter(x.Organisationseinheit, options.Organisationseinheit) &&
x.Personalnummer.HasValue &&
filteredEmployeeNumbers.Contains(x.Personalnummer.Value) &&
MatchesAbsencePeriodFilter(x, options) &&
MatchesTextSearch(options.SearchText, x.Name, x.Personalnummer?.ToString(CultureInfo.InvariantCulture) ?? string.Empty));
private static IEnumerable<HrLeaverRow> ApplyLeaverFilters(IEnumerable<HrLeaverRow> rows, HrKpiOptions options)
@@ -429,7 +438,8 @@ internal sealed class HrKpiDashboardBuilder
IReadOnlyCollection<HrKpiEmployeeRow> turnoverEmployees,
IReadOnlyCollection<HrLeaverRow> turnoverHeadcountLeavers,
IReadOnlyCollection<HrLeaverRow> leavers,
TurnoverPeriodScope period)
TurnoverPeriodScope period,
AnalysisPeriod analysisPeriod)
{
var activeCount = CountDistinctPersons(employees.Select(x => x.Personalnummer));
var activeFixedCount = CountDistinctPersons(employees
@@ -439,7 +449,8 @@ internal sealed class HrKpiDashboardBuilder
var turnoverDenominator = ResolveTurnoverDenominator(turnoverEmployees, turnoverIntervals, period);
var fte = employees.Sum(x => x.Fte);
var sickDays = absences.Sum(x => x.KrankheitstageGesamt);
var absenceRate = fte <= 0 ? 0 : sickDays / (fte * 21m);
var absenceDenominator = fte * analysisPeriod.Workdays;
var absenceRate = absenceDenominator <= 0 ? 0 : sickDays / absenceDenominator;
var relevantLeavers = CountDistinctPersons(leavers.Where(x => x.IstFluktuationsrelevant).Select(x => x.Personalnummer));
var employeeLeavers = CountDistinctPersons(leavers.Where(x => x.IstArbeitnehmerkuendigung).Select(x => x.Personalnummer));
var turnover = turnoverDenominator == 0 ? 0 : relevantLeavers / turnoverDenominator;
@@ -558,13 +569,15 @@ internal sealed class HrKpiDashboardBuilder
private static List<HrKpiMetric> BuildAbsenceMetrics(
IReadOnlyCollection<HrKpiEmployeeRow> employees,
IReadOnlyCollection<HrAbsenceRow> absences)
IReadOnlyCollection<HrAbsenceRow> absences,
AnalysisPeriod analysisPeriod)
{
var totalSick = absences.Sum(x => x.KrankheitstageGesamt);
var shortSick = absences.Sum(x => x.KrankheitstageKurz);
var longSick = absences.Sum(x => x.KrankheitstageLang);
var fte = employees.Sum(x => x.Fte);
var absenceRate = fte <= 0 ? 0 : totalSick / (fte * 21m);
var denominator = fte * analysisPeriod.Workdays;
var absenceRate = denominator <= 0 ? 0 : totalSick / denominator;
var bu = employees.Sum(x => x.BuTage);
var nbu = employees.Sum(x => x.NbuTage);
@@ -573,7 +586,7 @@ internal sealed class HrKpiDashboardBuilder
new() { Label = "Krankheitstage Gesamt", Value = totalSick.ToString("N1"), Detail = $"{absences.Count:N0} aktive Absenzenzeilen", Severity = absenceRate > 0.05m ? "Warning" : "Normal" },
new() { Label = "Krankheit Kurz", Value = shortSick.ToString("N1"), Detail = "Rexx kurz / 8.4h", Severity = "Normal" },
new() { Label = "Krankheit Lang", Value = longSick.ToString("N1"), Detail = "Rexx lang / 8.4h", Severity = longSick > shortSick ? "Warning" : "Normal" },
new() { Label = "Krankenquote", Value = absenceRate.ToString("P1"), Detail = "Krankheitstage / (FTE * 21 Tage)", Severity = absenceRate > 0.05m ? "Warning" : "Normal" },
new() { Label = "Krankenquote", Value = absenceRate.ToString("P1"), Detail = $"Krankheitstage / (FTE * {analysisPeriod.Workdays:N0} Arbeitstage), {analysisPeriod.Label}", Severity = absenceRate > 0.05m ? "Warning" : "Normal" },
new() { Label = "BU-Tage", Value = bu.ToString("N1"), Detail = "SAP HR KPI", Severity = "Normal" },
new() { Label = "NBU-Tage", Value = nbu.ToString("N1"), Detail = "SAP HR KPI", Severity = "Normal" },
new() { Label = "Unfalltage Total", Value = (bu + nbu).ToString("N1"), Detail = "BU + NBU", Severity = "Normal" }
@@ -1052,6 +1065,23 @@ internal sealed class HrKpiDashboardBuilder
(row.Austrittsjahr.HasValue && row.Austrittsjahr.Value == options.Year.Value);
}
private static bool MatchesAbsencePeriodFilter(HrAbsenceRow row, HrKpiOptions options)
{
var period = ResolveEmploymentPeriod(options);
if (!period.HasValue)
return true;
if (!row.VonDatum.HasValue && !row.BisDatum.HasValue)
return true;
var start = row.VonDatum?.Date ?? row.BisDatum!.Value.Date;
var end = row.BisDatum?.Date ?? start;
if (end < start)
(start, end) = (end, start);
return start <= period.Value.End && end >= period.Value.Start;
}
private static bool MatchesLeaverEmploymentPeriodFilter(HrLeaverRow row, HrKpiOptions options)
{
var period = ResolveEmploymentPeriod(options);
@@ -1078,6 +1108,34 @@ internal sealed class HrKpiDashboardBuilder
return start <= end ? (start, end) : (end, start);
}
private static AnalysisPeriod ResolveAnalysisPeriod(HrKpiOptions options)
{
var period = ResolveEmploymentPeriod(options);
if (!period.HasValue)
{
return new AnalysisPeriod(null, null, 21m, "ohne Zeitraumfilter", false);
}
var workdays = CountWeekdays(period.Value.Start, period.Value.End);
var label = $"{period.Value.Start:dd.MM.yyyy} - {period.Value.End:dd.MM.yyyy}";
return new AnalysisPeriod(period.Value.Start, period.Value.End, Math.Max(1, workdays), label, true);
}
private static int CountWeekdays(DateTime start, DateTime end)
{
if (end < start)
(start, end) = (end, start);
var days = 0;
for (var date = start.Date; date <= end.Date; date = date.AddDays(1))
{
if (date.DayOfWeek is not DayOfWeek.Saturday and not DayOfWeek.Sunday)
days++;
}
return days;
}
private static int CountDistinctPersons(IEnumerable<int?> personalNumbers)
=> personalNumbers
.Where(x => x.HasValue)
@@ -1368,6 +1426,8 @@ internal sealed class HrKpiDashboardBuilder
private sealed record TurnoverPeriodScope(int? BreakdownYear, DateTime AnchorDate, string Label, bool ShowPeriodMetrics);
private sealed record AnalysisPeriod(DateTime? Start, DateTime? End, decimal Workdays, string Label, bool HasPeriod);
private sealed record TurnoverEmploymentInterval(int Personalnummer, DateTime? Eintrittsdatum, DateTime? Austrittsdatum);
private sealed record TimeRow(string NameKey, DateTime? Geburtsdatum, string Arbeitszeitmodell, decimal AvgSollzeitTag);
@@ -11,16 +11,28 @@ public interface IHrKpiAccessService
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 HrKpiAccessService : IHrKpiAccessService
public sealed class HrKpiAccessService : IHrKpiAccessService, IDisposable
{
private readonly HrKpiAccessOptions _options;
private readonly IHostEnvironment _environment;
private readonly IHttpContextAccessor _httpContextAccessor;
private readonly IAccessSessionTracker _sessionTracker;
private readonly string _sessionId = Guid.NewGuid().ToString("N");
public HrKpiAccessService(IOptions<HrKpiAccessOptions> options)
public HrKpiAccessService(
IOptions<HrKpiAccessOptions> options,
IHostEnvironment environment,
IHttpContextAccessor httpContextAccessor,
IAccessSessionTracker sessionTracker)
{
_options = options.Value;
_environment = environment;
_httpContextAccessor = httpContextAccessor;
_sessionTracker = sessionTracker;
}
public bool IsEnabled => _options.Enabled;
@@ -30,13 +42,20 @@ public sealed class HrKpiAccessService : IHrKpiAccessService
!string.IsNullOrWhiteSpace(_options.Username) &&
(!string.IsNullOrWhiteSpace(_options.PasswordHash) || !string.IsNullOrEmpty(_options.Password));
public bool IsUnlocked { get; private set; }
public bool IsUnlocked =>
_isUnlocked ||
AccessUnlockCookie.IsUnlocked(
_httpContextAccessor.HttpContext,
AccessUnlockCookie.HrCookieName,
_options.PasswordHash);
private bool _isUnlocked;
public bool TryUnlock(string username, string password)
{
if (!IsEnabled)
{
IsUnlocked = true;
_isUnlocked = true;
return true;
}
@@ -52,15 +71,49 @@ public sealed class HrKpiAccessService : IHrKpiAccessService
? VerifyPasswordHash(password, _options.PasswordHash)
: FixedEquals(password, _options.Password);
IsUnlocked = valid;
_isUnlocked = valid;
if (valid)
_sessionTracker.Register(_sessionId, "HR KPI", username.Trim(), GetRemoteAddress());
return valid;
}
public void Lock() => IsUnlocked = false;
public void Lock()
{
_isUnlocked = false;
_sessionTracker.Unregister(_sessionId);
}
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, HrKpiAccessOptions.SectionName, passwordHash);
_options.PasswordHash = passwordHash;
_options.Password = string.Empty;
_isUnlocked = true;
_sessionTracker.Register(_sessionId, "HR KPI", username.Trim(), GetRemoteAddress());
return true;
}
public void Dispose()
{
_sessionTracker.Unregister(_sessionId);
}
private string? GetRemoteAddress()
=> _httpContextAccessor.HttpContext?.Connection.RemoteIpAddress?.ToString();
private static bool VerifyPasswordHash(string password, string configuredHash)
{
var passwordHash = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(password)));
var passwordHash = AccessPasswordSettingsWriter.HashPassword(password);
return FixedEquals(passwordHash, configuredHash.Trim());
}
@@ -0,0 +1,10 @@
using TrafagSalesExporter.Models;
namespace TrafagSalesExporter.Services;
public interface INavigationMenuService
{
Task<List<NavigationMenuItem>> GetItemsAsync();
Task SaveItemsAsync(IEnumerable<NavigationMenuItem> items);
Task ResetToDefaultsAsync();
}
@@ -0,0 +1,54 @@
namespace TrafagSalesExporter.Services;
public interface IPurchasingDashboardService
{
Task<PurchasingDashboardLiveState> LoadAsync(PurchasingDashboardFilter? filter = null, CancellationToken cancellationToken = default);
}
public sealed record PurchasingDashboardFilter(DateTime FromDate, DateTime ToDate)
{
public string Label => $"{FromDate:yyyy-MM-dd} bis {ToDate:yyyy-MM-dd}";
}
public sealed class PurchasingDashboardLiveState
{
public bool SapReachable { get; set; }
public bool EkkoLoaded { get; set; }
public bool EkpoLoaded { get; set; }
public bool EketLoaded { get; set; }
public int PurchaseOrderCount { get; set; }
public int SupplierCount { get; set; }
public DateTime? LatestOrderDate { get; set; }
public int PositionSampleCount { get; set; }
public int ScheduleSampleCount { get; set; }
public bool UsesCache { get; set; }
public string CacheStatus { get; set; } = string.Empty;
public DateTime? CacheCompletedAtUtc { get; set; }
public DateTime? PeriodFrom { get; set; }
public DateTime? PeriodTo { get; set; }
public decimal SpendChfSample { get; set; }
public decimal OpenQuantitySample { get; set; }
public decimal OpenValueSample { get; set; }
public decimal ContractValueSample { get; set; }
public string TopSupplierLabel { get; set; } = string.Empty;
public string TopMaterialGroupLabel { get; set; } = string.Empty;
public string TopArticleLabel { get; set; } = string.Empty;
public string TopCommitmentLabel { get; set; } = string.Empty;
public List<PurchasingLiveChartPoint> SpendChartRows { get; set; } = [];
public List<PurchasingLiveChartPoint> OpenValueChartRows { get; set; } = [];
public List<PurchasingLiveChartPoint> ContractChartRows { get; set; } = [];
public List<PurchasingLiveChartPoint> CommitmentDetailChartRows { get; set; } = [];
public List<PurchasingLiveChartPoint> DeliveryRiskChartRows { get; set; } = [];
public List<PurchasingLiveChartPoint> PriceVarianceChartRows { get; set; } = [];
public List<PurchasingLiveChartPoint> SpendConcentrationChartRows { get; set; } = [];
public List<PurchasingLiveChartPoint> DataQualityChartRows { get; set; } = [];
public List<PurchasingLiveChartPoint> PriceTrendChartRows { get; set; } = [];
public List<PurchasingIdeaAnalysisRow> DeliveryRiskRows { get; set; } = [];
public List<PurchasingIdeaAnalysisRow> PriceVarianceRows { get; set; } = [];
public List<PurchasingIdeaAnalysisRow> SpendConcentrationRows { get; set; } = [];
public List<PurchasingIdeaAnalysisRow> DataQualityRows { get; set; } = [];
public string Message { get; set; } = string.Empty;
}
public sealed record PurchasingLiveChartPoint(string Label, decimal Value);
public sealed record PurchasingIdeaAnalysisRow(string Label, string Value, string Detail, string Severity);
@@ -0,0 +1,24 @@
namespace TrafagSalesExporter.Services;
public interface IPurchasingDataRefreshService
{
Task<PurchasingDataRefreshStatus> GetStatusAsync(CancellationToken cancellationToken = default);
Task<PurchasingDataRefreshStatus> RunFullLoadAsync(DateTime? fromDate = null, CancellationToken cancellationToken = default);
Task<PurchasingDataRefreshStatus> RunDeltaAsync(DateTime? fromDate = null, CancellationToken cancellationToken = default);
}
public sealed class PurchasingDataRefreshStatus
{
public string Mode { get; set; } = string.Empty;
public string Status { get; set; } = string.Empty;
public DateTime? StartedAtUtc { get; set; }
public DateTime? CompletedAtUtc { get; set; }
public DateTime? FromDate { get; set; }
public DateTime? ToDate { get; set; }
public DateTime? LastSuccessfulDeltaAtUtc { get; set; }
public int EkkoRows { get; set; }
public int EkpoRows { get; set; }
public int EketRows { get; set; }
public string Message { get; set; } = string.Empty;
public bool IsComplete => string.Equals(Status, "Success", StringComparison.OrdinalIgnoreCase);
}
@@ -0,0 +1,20 @@
using TrafagSalesExporter.Models;
namespace TrafagSalesExporter.Services;
public interface IPurchasingDataSourcePageService
{
Task<PurchasingDataSourceState> LoadAsync();
Task<PurchasingDataSourceState> SaveAsync(PurchasingDataSourceState state);
Task<PurchasingDataSourceState> ResetDefaultsAsync();
Task<PageActionResult> TestConnectionAsync(PurchasingDataSourceState state);
}
public sealed class PurchasingDataSourceState
{
public Site Site { get; set; } = new();
public SourceSystemDefinition? SourceSystem { get; set; }
public List<SapSourceDefinition> Sources { get; set; } = [];
public List<SapJoinDefinition> Joins { get; set; } = [];
public List<SapFieldMapping> Mappings { get; set; } = [];
}
@@ -2,7 +2,7 @@ namespace TrafagSalesExporter.Services;
public interface ISharePointUploadService
{
Task UploadAsync(string tenantId, string clientId, string clientSecret, string siteUrl, string exportFolder, string land, string localFilePath);
Task UploadAsync(string tenantId, string clientId, string clientSecret, string siteUrl, string exportFolder, string land, string localFilePath, bool uploadTimestampedCopyIfLocked = false);
Task<string> DownloadToTempFileAsync(string tenantId, string clientId, string clientSecret, string siteUrl, string fileReference);
Task<SharePointFileReference> ResolveLatestFileInFolderAsync(string tenantId, string clientId, string clientSecret, string siteUrl, string folderReference, string siteTsc, int? preferredYear = null);
Task<IReadOnlyList<SharePointFileReference>> ResolveManualImportFilesInFolderAsync(string tenantId, string clientId, string clientSecret, string siteUrl, string folderReference, string siteTsc, int? preferredYear = null);
@@ -0,0 +1,58 @@
using System.Text;
using System.Text.Json;
using System.Text.Json.Nodes;
using Microsoft.Extensions.Options;
using TrafagSalesExporter.Security;
namespace TrafagSalesExporter.Services;
public interface ILandingPageSettingsService
{
bool ShowWalkingLabFigure { get; }
void SetShowWalkingLabFigure(bool value);
}
public sealed class LandingPageSettingsService : ILandingPageSettingsService
{
private static readonly object FileLock = new();
private readonly LandingPageOptions _options;
private readonly IHostEnvironment _environment;
public LandingPageSettingsService(IOptions<LandingPageOptions> options, IHostEnvironment environment)
{
_options = options.Value;
_environment = environment;
}
public bool ShowWalkingLabFigure => _options.ShowWalkingLabFigure;
public void SetShowWalkingLabFigure(bool value)
{
_options.ShowWalkingLabFigure = value;
SaveSetting(value);
}
private void SaveSetting(bool value)
{
var path = Path.Combine(_environment.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[LandingPageOptions.SectionName] as JsonObject;
if (section is null)
{
section = new JsonObject();
root[LandingPageOptions.SectionName] = section;
}
section[nameof(LandingPageOptions.ShowWalkingLabFigure)] = value;
var options = new JsonSerializerOptions { WriteIndented = true };
File.WriteAllText(path, root.ToJsonString(options), new UTF8Encoding(false));
}
}
}
File diff suppressed because it is too large Load Diff
@@ -18,12 +18,20 @@ public class ManualExcelImportService : IManualExcelImportService
{
["extractiondate"] = nameof(SalesRecord.ExtractionDate),
["tsc"] = nameof(SalesRecord.Tsc),
["sourcelineid"] = nameof(SalesRecord.SourceLineId),
["documententry"] = nameof(SalesRecord.DocumentEntry),
["invoicenumber"] = nameof(SalesRecord.InvoiceNumber),
["positiononinvoice"] = nameof(SalesRecord.PositionOnInvoice),
["material"] = nameof(SalesRecord.Material),
["name"] = nameof(SalesRecord.Name),
["productgroup"] = nameof(SalesRecord.ProductGroup),
["producthierarchycode"] = nameof(SalesRecord.ProductHierarchyCode),
["producthierarchytext"] = nameof(SalesRecord.ProductHierarchyText),
["productfamilycode"] = nameof(SalesRecord.ProductFamilyCode),
["productfamilytext"] = nameof(SalesRecord.ProductFamilyText),
["productdivisioncode"] = nameof(SalesRecord.ProductDivisionCode),
["productdivisiontext"] = nameof(SalesRecord.ProductDivisionText),
["productmappingassigned"] = nameof(SalesRecord.ProductMappingAssigned),
["quantity"] = nameof(SalesRecord.Quantity),
["suppliernumber"] = nameof(SalesRecord.SupplierNumber),
["suppliername"] = nameof(SalesRecord.SupplierName),
@@ -156,12 +164,20 @@ public class ManualExcelImportService : IManualExcelImportService
{
ExtractionDate = ReadDate(headerIndexes, fields, nameof(SalesRecord.ExtractionDate)) ?? DateTime.UtcNow,
Tsc = ReadString(headerIndexes, fields, nameof(SalesRecord.Tsc), site.TSC),
SourceLineId = ReadString(headerIndexes, fields, nameof(SalesRecord.SourceLineId)),
DocumentEntry = (int)Math.Round(ReadDecimal(headerIndexes, fields, nameof(SalesRecord.DocumentEntry))),
InvoiceNumber = ReadString(headerIndexes, fields, nameof(SalesRecord.InvoiceNumber)),
PositionOnInvoice = (int)Math.Round(ReadDecimal(headerIndexes, fields, nameof(SalesRecord.PositionOnInvoice))),
Material = ReadString(headerIndexes, fields, nameof(SalesRecord.Material)),
Name = ReadString(headerIndexes, fields, nameof(SalesRecord.Name)),
ProductGroup = ReadString(headerIndexes, fields, nameof(SalesRecord.ProductGroup)),
ProductHierarchyCode = ReadString(headerIndexes, fields, nameof(SalesRecord.ProductHierarchyCode)),
ProductHierarchyText = ReadString(headerIndexes, fields, nameof(SalesRecord.ProductHierarchyText)),
ProductFamilyCode = ReadString(headerIndexes, fields, nameof(SalesRecord.ProductFamilyCode)),
ProductFamilyText = ReadString(headerIndexes, fields, nameof(SalesRecord.ProductFamilyText)),
ProductDivisionCode = ReadString(headerIndexes, fields, nameof(SalesRecord.ProductDivisionCode)),
ProductDivisionText = ReadString(headerIndexes, fields, nameof(SalesRecord.ProductDivisionText)),
ProductMappingAssigned = ReadString(headerIndexes, fields, nameof(SalesRecord.ProductMappingAssigned)),
Quantity = ReadDecimal(headerIndexes, fields, nameof(SalesRecord.Quantity)),
SupplierNumber = ReadString(headerIndexes, fields, nameof(SalesRecord.SupplierNumber)),
SupplierName = ReadString(headerIndexes, fields, nameof(SalesRecord.SupplierName)),
@@ -267,6 +283,7 @@ public class ManualExcelImportService : IManualExcelImportService
{
ExtractionDate = ReadDate(headerIndexes, row, nameof(SalesRecord.ExtractionDate)) ?? DateTime.UtcNow,
Tsc = ReadString(headerIndexes, row, nameof(SalesRecord.Tsc), site.TSC),
SourceLineId = ReadString(headerIndexes, row, nameof(SalesRecord.SourceLineId)),
DocumentEntry = (int)Math.Round(ReadDecimal(headerIndexes, row, nameof(SalesRecord.DocumentEntry))),
InvoiceNumber = ReadString(headerIndexes, row, nameof(SalesRecord.InvoiceNumber)),
PositionOnInvoice = (int)Math.Round(ReadDecimal(headerIndexes, row, nameof(SalesRecord.PositionOnInvoice))),
@@ -129,12 +129,54 @@ public sealed class MappedSalesRecordComposer : IMappedSalesRecordComposer
if (value.StartsWith('='))
return value[1..];
if (TryEvaluateFirstNonEmpty(row, value, out var firstNonEmpty))
return firstNonEmpty;
if (row.TryGetValue(value, out var direct))
return direct;
return null;
}
private static bool TryEvaluateFirstNonEmpty(Dictionary<string, object?> row, string expression, out object? result)
{
result = null;
const string functionName = "FirstNonEmpty";
if (!expression.StartsWith(functionName, StringComparison.OrdinalIgnoreCase))
return false;
var openParen = expression.IndexOf('(');
var closeParen = expression.LastIndexOf(')');
if (openParen < functionName.Length || closeParen <= openParen)
return false;
var arguments = expression[(openParen + 1)..closeParen]
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
foreach (var argument in arguments)
{
var value = EvaluateExpression(row, argument);
if (!IsEmptyValue(value))
{
result = value;
return true;
}
}
return true;
}
private static bool IsEmptyValue(object? value)
{
if (value is null)
return true;
if (value is string text)
return string.IsNullOrWhiteSpace(text);
return string.IsNullOrWhiteSpace(value.ToString());
}
private static void ApplyValue(SalesRecord record, string targetField, object? value)
{
var property = typeof(SalesRecord).GetProperty(targetField);
@@ -0,0 +1,46 @@
using MudBlazor;
namespace TrafagSalesExporter.Services;
public static class NavigationIconResolver
{
public static string Resolve(string icon) => icon switch
{
"AccountTree" => Icons.Material.Filled.AccountTree,
"AdminPanelSettings" => Icons.Material.Filled.AdminPanelSettings,
"Analytics" => Icons.Material.Filled.Analytics,
"Assignment" => Icons.Material.Filled.Assignment,
"AssignmentReturn" => Icons.Material.Filled.AssignmentReturn,
"Checklist" => Icons.Material.Filled.Checklist,
"CompareArrows" => Icons.Material.Filled.CompareArrows,
"Dashboard" => Icons.Material.Filled.Dashboard,
"FactCheck" => Icons.Material.Filled.FactCheck,
"Groups" => Icons.Material.Filled.Groups,
"Hub" => Icons.Material.Filled.Hub,
"InsertChart" => Icons.Material.Filled.InsertChart,
"Lightbulb" => Icons.Material.Filled.Lightbulb,
"List" => Icons.Material.Filled.List,
"LocationOn" => Icons.Material.Filled.LocationOn,
"Lock" => Icons.Material.Filled.Lock,
"Payments" => Icons.Material.Filled.Payments,
"PeopleAlt" => Icons.Material.Filled.PeopleAlt,
"PendingActions" => Icons.Material.Filled.PendingActions,
"PieChart" => Icons.Material.Filled.PieChart,
"Public" => Icons.Material.Filled.Public,
"QueryStats" => Icons.Material.Filled.QueryStats,
"Rule" => Icons.Material.Filled.Rule,
"School" => Icons.Material.Filled.School,
"Settings" => Icons.Material.Filled.Settings,
"ShoppingCart" => Icons.Material.Filled.ShoppingCart,
"Speed" => Icons.Material.Filled.Speed,
"Storage" => Icons.Material.Filled.Storage,
"Transform" => Icons.Material.Filled.Transform,
"TrendingUp" => Icons.Material.Filled.TrendingUp,
"Tune" => Icons.Material.Filled.Tune,
"UploadFile" => Icons.Material.Filled.UploadFile,
"Verified" => Icons.Material.Filled.Verified,
"ViewInAr" => Icons.Material.Filled.ViewInAr,
"WarningAmber" => Icons.Material.Filled.WarningAmber,
_ => Icons.Material.Filled.Circle
};
}
@@ -0,0 +1,74 @@
using Microsoft.EntityFrameworkCore;
using TrafagSalesExporter.Data;
using TrafagSalesExporter.Models;
namespace TrafagSalesExporter.Services;
public sealed class NavigationMenuService : INavigationMenuService
{
private readonly IDbContextFactory<AppDbContext> _dbFactory;
public NavigationMenuService(IDbContextFactory<AppDbContext> dbFactory)
{
_dbFactory = dbFactory;
}
public async Task<List<NavigationMenuItem>> GetItemsAsync()
{
await using var db = await _dbFactory.CreateDbContextAsync();
return await db.NavigationMenuItems
.AsNoTracking()
.OrderBy(x => x.ParentKey ?? string.Empty)
.ThenBy(x => x.SortOrder)
.ThenBy(x => x.TitleDe)
.Select(x => new NavigationMenuItem
{
Id = x.Id,
Key = x.Key ?? string.Empty,
ParentKey = string.IsNullOrWhiteSpace(x.ParentKey) ? null : x.ParentKey,
TitleDe = x.TitleDe ?? string.Empty,
TitleEn = x.TitleEn ?? string.Empty,
Icon = x.Icon ?? string.Empty,
Href = x.Href ?? string.Empty,
ItemType = x.ItemType ?? NavigationMenuItemTypes.Link,
Match = x.Match ?? "Prefix",
RequiredPolicy = x.RequiredPolicy ?? string.Empty,
IsVisible = x.IsVisible,
IsExpanded = x.IsExpanded,
IsSystem = x.IsSystem,
SortOrder = x.SortOrder
})
.ToListAsync();
}
public async Task SaveItemsAsync(IEnumerable<NavigationMenuItem> items)
{
await using var db = await _dbFactory.CreateDbContextAsync();
var incoming = items.ToDictionary(x => x.Key, StringComparer.OrdinalIgnoreCase);
var existing = await db.NavigationMenuItems.ToListAsync();
foreach (var item in existing)
{
if (!incoming.TryGetValue(item.Key, out var source))
continue;
item.ParentKey = string.IsNullOrWhiteSpace(source.ParentKey) ? null : source.ParentKey;
item.SortOrder = source.SortOrder;
item.IsVisible = source.IsVisible;
item.IsExpanded = source.IsExpanded;
item.TitleDe = source.TitleDe.Trim();
item.TitleEn = source.TitleEn.Trim();
}
await db.SaveChangesAsync();
}
public async Task ResetToDefaultsAsync()
{
await using var db = await _dbFactory.CreateDbContextAsync();
db.NavigationMenuItems.RemoveRange(db.NavigationMenuItems);
await db.SaveChangesAsync();
new DatabaseSeedService().SeedDefaults(db);
}
}
@@ -0,0 +1,694 @@
using System.Globalization;
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using TrafagSalesExporter.Data;
namespace TrafagSalesExporter.Services;
public sealed class PurchasingDashboardService : IPurchasingDashboardService
{
private readonly IDbContextFactory<AppDbContext> _dbFactory;
public PurchasingDashboardService(IDbContextFactory<AppDbContext> dbFactory)
{
_dbFactory = dbFactory;
}
private static PurchasingDashboardFilter BuildDefaultFilter()
{
var today = DateTime.Today;
return new PurchasingDashboardFilter(new DateTime(today.Year - 2, 1, 1), today);
}
private static string SupplierLabelSql(string lifnrExpression)
=> $@"CASE
WHEN COALESCE(NULLIF({lifnrExpression}, ''), '') = '' THEN 'ohne Lieferant'
ELSE 'Lief. ' || {lifnrExpression} || ' (Name fehlt)'
END";
public async Task<PurchasingDashboardLiveState> LoadAsync(PurchasingDashboardFilter? filter = null, CancellationToken cancellationToken = default)
{
var state = new PurchasingDashboardLiveState();
filter ??= BuildDefaultFilter();
state.PeriodFrom = filter.FromDate;
state.PeriodTo = filter.ToDate;
try
{
await using var db = await _dbFactory.CreateDbContextAsync(cancellationToken);
if (await TryLoadCacheStateAsync(db, state, filter, cancellationToken))
return state;
var sap = await db.SourceSystemDefinitions.AsNoTracking().FirstOrDefaultAsync(x => x.Code == "SAP", cancellationToken);
var site = await db.Sites.AsNoTracking().FirstOrDefaultAsync(x => x.TSC == PurchasingDataSourcePageService.PurchasingTsc, cancellationToken);
if (sap is null || site is null)
{
state.Message = "SAP Einkaufsquelle ist noch nicht konfiguriert.";
return state;
}
var serviceUrl = string.IsNullOrWhiteSpace(site.SapServiceUrl) ? sap.CentralServiceUrl : site.SapServiceUrl;
var username = string.IsNullOrWhiteSpace(site.UsernameOverride) ? sap.CentralUsername : site.UsernameOverride;
var password = string.IsNullOrWhiteSpace(site.PasswordOverride) ? sap.CentralPassword : site.PasswordOverride;
if (string.IsNullOrWhiteSpace(serviceUrl) || string.IsNullOrWhiteSpace(username) || string.IsNullOrWhiteSpace(password))
{
state.Message = "SAP URL oder Zugangsdaten fehlen.";
return state;
}
using var client = CreateClient(username, password);
var baseUrl = serviceUrl.TrimEnd('/') + "/";
var currentYear = DateTime.Today.Year;
var ekkoFilter = Uri.EscapeDataString($"Bedat ge '{currentYear}-01-01'");
var ekkoCount = await ReadCountAsync(
client,
$"{baseUrl}EKKOSet/$count?$filter={ekkoFilter}",
cancellationToken);
var ekkoRows = await ReadRowsAsync(
client,
$"{baseUrl}EKKOSet?$format=json&$top=1000&$filter={ekkoFilter}&$select=Ebeln,Bedat,Lifnr",
cancellationToken);
state.SapReachable = true;
state.EkkoLoaded = ekkoRows.Count > 0;
state.PurchaseOrderCount = ekkoCount ?? ekkoRows.Count;
state.SupplierCount = ekkoRows
.Select(row => GetText(row, "Lifnr"))
.Where(value => !string.IsNullOrWhiteSpace(value))
.Distinct(StringComparer.OrdinalIgnoreCase)
.Count();
state.LatestOrderDate = ekkoRows
.Select(row => TryParseSapDate(GetText(row, "Bedat")))
.Where(date => date.HasValue)
.Select(date => date!.Value)
.OrderByDescending(date => date)
.Cast<DateTime?>()
.FirstOrDefault();
var firstEbeln = ekkoRows.Select(row => GetText(row, "Ebeln")).FirstOrDefault(value => !string.IsNullOrWhiteSpace(value));
if (!string.IsNullOrWhiteSpace(firstEbeln))
{
var ekpoRows = await ReadRowsAsync(
client,
$"{baseUrl}EKPOSet?$format=json&$top=1000&$filter={Uri.EscapeDataString($"Ebeln ge '{firstEbeln}'")}",
cancellationToken);
state.PositionSampleCount = ekpoRows.Count;
state.EkpoLoaded = ekpoRows.Count > 0;
var eketRows = await ReadRowsAsync(
client,
$"{baseUrl}eketSet?$format=json&$top=1000&$filter={Uri.EscapeDataString($"Ebeln ge '{firstEbeln}'")}",
cancellationToken);
state.ScheduleSampleCount = eketRows.Count;
state.EketLoaded = eketRows.Count > 0;
ApplyEkpoMetrics(state, ekkoRows, ekpoRows);
ApplyEketMetrics(state, ekkoRows, ekpoRows, eketRows);
}
state.Message = state.EkpoLoaded && state.EketLoaded
? "SAP Einkaufsdaten inkl. EKPO/EKET geladen."
: state.EkpoLoaded
? "SAP Einkaufsdaten inkl. EKPO geladen; EKET liefert noch keine Termindaten."
: "EKKO ist live geladen; EKPO/EKET liefern aktuell noch keine Positionsdaten.";
}
catch (Exception ex)
{
state.Message = $"SAP Einkauf konnte nicht geladen werden: {ex.Message}";
}
return state;
}
private static async Task<bool> TryLoadCacheStateAsync(AppDbContext db, PurchasingDashboardLiveState state, PurchasingDashboardFilter filter, CancellationToken cancellationToken)
{
var conn = (SqliteConnection)db.Database.GetDbConnection();
if (conn.State != System.Data.ConnectionState.Open)
await conn.OpenAsync(cancellationToken);
var from = filter.FromDate.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture);
var to = filter.ToDate.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture);
var ekkoPeriod = $"Bedat >= '{from}' AND Bedat <= '{to}'";
var joinedEkkoPeriod = $"k.Bedat >= '{from}' AND k.Bedat <= '{to}'";
var eketPeriod = $"e.Eindt >= '{from}' AND e.Eindt <= '{to}'";
var cacheEkkoRows = await ExecuteScalarIntAsync(conn, "SELECT COUNT(1) FROM PurchasingEkkoCache;", cancellationToken);
var cacheEkpoRows = await ExecuteScalarIntAsync(conn, "SELECT COUNT(1) FROM PurchasingEkpoCache;", cancellationToken);
var cacheEketRows = await ExecuteScalarIntAsync(conn, "SELECT COUNT(1) FROM PurchasingEketCache;", cancellationToken);
if (cacheEkkoRows <= 0 || cacheEkpoRows <= 0 || cacheEketRows <= 0)
return false;
var latestStatus = await ReadCacheStatusAsync(conn, cancellationToken);
state.UsesCache = true;
state.SapReachable = true;
state.EkkoLoaded = true;
state.EkpoLoaded = true;
state.EketLoaded = true;
state.PurchaseOrderCount = await ExecuteScalarIntAsync(conn, $"SELECT COUNT(1) FROM PurchasingEkkoCache WHERE {ekkoPeriod};", cancellationToken);
state.PositionSampleCount = await ExecuteScalarIntAsync(conn, $@"
SELECT COUNT(1)
FROM PurchasingEkpoCache p
LEFT JOIN PurchasingEkkoCache k ON k.Ebeln = p.Ebeln
WHERE {joinedEkkoPeriod};", cancellationToken);
state.ScheduleSampleCount = await ExecuteScalarIntAsync(conn, $"SELECT COUNT(1) FROM PurchasingEketCache e WHERE {eketPeriod};", cancellationToken);
state.SupplierCount = await ExecuteScalarIntAsync(conn, $"SELECT COUNT(DISTINCT Lifnr) FROM PurchasingEkkoCache WHERE Lifnr <> '' AND {ekkoPeriod};", cancellationToken);
state.LatestOrderDate = await ExecuteScalarDateAsync(conn, $"SELECT MAX(Bedat) FROM PurchasingEkkoCache WHERE {ekkoPeriod};", cancellationToken);
state.SpendChfSample = await ExecuteScalarDecimalAsync(conn, $@"
SELECT COALESCE(SUM(CAST(p.Netwr AS REAL)), 0)
FROM PurchasingEkpoCache p
LEFT JOIN PurchasingEkkoCache k ON k.Ebeln = p.Ebeln
WHERE COALESCE(p.Loekz, '') = '' AND {joinedEkkoPeriod};", cancellationToken);
state.OpenQuantitySample = await ExecuteScalarDecimalAsync(conn, $"SELECT COALESCE(SUM(MAX(CAST(e.Menge AS REAL) - CAST(e.Wemng AS REAL), 0)), 0) FROM PurchasingEketCache e WHERE {eketPeriod};", cancellationToken);
state.OpenValueSample = await ExecuteScalarDecimalAsync(conn, @"
SELECT COALESCE(SUM(MAX(CAST(e.Menge AS REAL) - CAST(e.Wemng AS REAL), 0) *
CASE WHEN CAST(p.Menge AS REAL) = 0 THEN 0 ELSE CAST(p.Netwr AS REAL) / CAST(p.Menge AS REAL) END), 0)
FROM PurchasingEketCache e
LEFT JOIN PurchasingEkpoCache p ON p.Ebeln = e.Ebeln AND p.Ebelp = e.Ebelp
WHERE COALESCE(p.Loekz, '') = '' AND " + eketPeriod + ";", cancellationToken);
state.ContractValueSample = state.OpenValueSample;
state.TopSupplierLabel = await ExecuteTopLabelAsync(conn, @"
SELECT " + SupplierLabelSql("k.Lifnr") + @" AS Label, SUM(CAST(p.Netwr AS REAL)) AS Value
FROM PurchasingEkpoCache p
LEFT JOIN PurchasingEkkoCache k ON k.Ebeln = p.Ebeln
WHERE COALESCE(p.Loekz, '') = '' AND " + joinedEkkoPeriod + @"
GROUP BY COALESCE(k.Lifnr, 'ohne Lieferant')
ORDER BY Value DESC
LIMIT 1;", "Lieferant", cancellationToken);
state.TopMaterialGroupLabel = await ExecuteTopLabelAsync(conn, @"
SELECT COALESCE(NULLIF(Matkl, ''), 'ohne Warengruppe') AS Label, SUM(CAST(Netwr AS REAL)) AS Value
FROM PurchasingEkpoCache p
LEFT JOIN PurchasingEkkoCache k ON k.Ebeln = p.Ebeln
WHERE COALESCE(p.Loekz, '') = '' AND " + joinedEkkoPeriod + @"
GROUP BY COALESCE(NULLIF(Matkl, ''), 'ohne Warengruppe')
ORDER BY Value DESC
LIMIT 1;", "Warengruppe", cancellationToken);
state.TopArticleLabel = await ExecuteTopLabelAsync(conn, @"
SELECT
COALESCE(NULLIF(p.Matnr, ''), NULLIF(p.Txz01, ''), 'ohne Artikel') || ' | ' ||
" + SupplierLabelSql("k.Lifnr") + @" || ' | Monat ' ||
COALESCE(substr(k.Bedat, 1, 7), 'ohne Datum') AS Label,
SUM(CAST(p.Netwr AS REAL)) AS Value
FROM PurchasingEkpoCache p
LEFT JOIN PurchasingEkkoCache k ON k.Ebeln = p.Ebeln
WHERE COALESCE(p.Loekz, '') = '' AND " + joinedEkkoPeriod + @"
GROUP BY COALESCE(NULLIF(p.Matnr, ''), NULLIF(p.Txz01, ''), 'ohne Artikel'), COALESCE(k.Lifnr, 'ohne Lieferant'), COALESCE(substr(k.Bedat, 1, 7), 'ohne Datum')
ORDER BY Value DESC
LIMIT 1;", "Artikel", cancellationToken);
state.SpendChartRows = await ExecuteChartRowsAsync(conn, @"
SELECT " + SupplierLabelSql("k.Lifnr") + @" AS Label, SUM(CAST(p.Netwr AS REAL)) AS Value
FROM PurchasingEkpoCache p
LEFT JOIN PurchasingEkkoCache k ON k.Ebeln = p.Ebeln
WHERE COALESCE(p.Loekz, '') = '' AND " + joinedEkkoPeriod + @"
GROUP BY COALESCE(NULLIF(k.Lifnr, ''), 'ohne Lieferant')
ORDER BY Value DESC
LIMIT 6;", cancellationToken);
state.OpenValueChartRows = await ExecuteChartRowsAsync(conn, @"
SELECT COALESCE(substr(e.Eindt, 1, 7), 'ohne Termin') AS Label,
SUM(MAX(CAST(e.Menge AS REAL) - CAST(e.Wemng AS REAL), 0) *
CASE WHEN CAST(p.Menge AS REAL) = 0 THEN 0 ELSE CAST(p.Netwr AS REAL) / CAST(p.Menge AS REAL) END) AS Value
FROM PurchasingEketCache e
LEFT JOIN PurchasingEkpoCache p ON p.Ebeln = e.Ebeln AND p.Ebelp = e.Ebelp
WHERE COALESCE(p.Loekz, '') = '' AND " + eketPeriod + @"
GROUP BY COALESCE(substr(e.Eindt, 1, 7), 'ohne Termin')
ORDER BY Label
LIMIT 6;", cancellationToken);
state.CommitmentDetailChartRows = await ExecuteChartRowsAsync(conn, @"
SELECT
" + SupplierLabelSql("k.Lifnr") + @" || ' | ' ||
COALESCE(NULLIF(p.Matnr, ''), NULLIF(p.Txz01, ''), 'ohne Artikel') || ' | faellig ' ||
COALESCE(substr(e.Eindt, 1, 7), 'ohne Termin') AS Label,
SUM(MAX(CAST(e.Menge AS REAL) - CAST(e.Wemng AS REAL), 0) *
CASE WHEN CAST(p.Menge AS REAL) = 0 THEN 0 ELSE CAST(p.Netwr AS REAL) / CAST(p.Menge AS REAL) END) AS Value
FROM PurchasingEketCache e
LEFT JOIN PurchasingEkpoCache p ON p.Ebeln = e.Ebeln AND p.Ebelp = e.Ebelp
LEFT JOIN PurchasingEkkoCache k ON k.Ebeln = e.Ebeln
WHERE COALESCE(p.Loekz, '') = '' AND " + eketPeriod + @" AND MAX(CAST(e.Menge AS REAL) - CAST(e.Wemng AS REAL), 0) > 0
GROUP BY COALESCE(NULLIF(k.Lifnr, ''), 'ohne Lieferant'), COALESCE(NULLIF(p.Matnr, ''), NULLIF(p.Txz01, ''), 'ohne Artikel'), COALESCE(substr(e.Eindt, 1, 7), 'ohne Termin')
ORDER BY Value DESC
LIMIT 6;", cancellationToken);
state.ContractChartRows = state.CommitmentDetailChartRows.Count > 0
? state.CommitmentDetailChartRows.ToList()
: state.OpenValueChartRows.ToList();
state.TopCommitmentLabel = state.CommitmentDetailChartRows.Count > 0
? $"{state.CommitmentDetailChartRows[0].Label}: CHF {state.CommitmentDetailChartRows[0].Value:N0}"
: string.Empty;
await ApplyIdeaAnalyticsAsync(conn, state, joinedEkkoPeriod, eketPeriod, cancellationToken);
state.CacheStatus = latestStatus.Status;
state.CacheCompletedAtUtc = latestStatus.CompletedAtUtc;
state.Message = $"Einkauf Cache geladen fuer {filter.Label}: EKKO={state.PurchaseOrderCount:N0}, EKPO={state.PositionSampleCount:N0}, EKET={state.ScheduleSampleCount:N0}. {latestStatus.Message}";
return true;
}
private static async Task ApplyIdeaAnalyticsAsync(SqliteConnection conn, PurchasingDashboardLiveState state, string joinedEkkoPeriod, string eketPeriod, CancellationToken cancellationToken)
{
state.DeliveryRiskChartRows = await ExecuteChartRowsAsync(conn, @"
WITH open_rows AS (
SELECT
CASE
WHEN date(e.Eindt) < date('now', 'localtime') THEN 'Ueberfaellig'
WHEN date(e.Eindt) <= date('now', 'localtime', '+7 day') THEN '0-7 Tage'
WHEN date(e.Eindt) <= date('now', 'localtime', '+30 day') THEN '8-30 Tage'
ELSE 'Spaeter'
END AS Label,
MAX(CAST(e.Menge AS REAL) - CAST(e.Wemng AS REAL), 0) *
CASE WHEN CAST(p.Menge AS REAL) = 0 THEN 0 ELSE CAST(p.Netwr AS REAL) / CAST(p.Menge AS REAL) END AS OpenValue
FROM PurchasingEketCache e
LEFT JOIN PurchasingEkpoCache p ON p.Ebeln = e.Ebeln AND p.Ebelp = e.Ebelp
WHERE COALESCE(p.Loekz, '') = '' AND " + eketPeriod + @" AND MAX(CAST(e.Menge AS REAL) - CAST(e.Wemng AS REAL), 0) > 0
)
SELECT Label, SUM(OpenValue) AS Value
FROM open_rows
GROUP BY Label
ORDER BY CASE Label WHEN 'Ueberfaellig' THEN 1 WHEN '0-7 Tage' THEN 2 WHEN '8-30 Tage' THEN 3 ELSE 4 END;", cancellationToken);
state.DeliveryRiskRows = await ExecuteAnalysisRowsAsync(conn, @"
SELECT
COALESCE(NULLIF(k.Lifnr, ''), 'ohne Lieferant') || ' / ' || COALESCE(NULLIF(p.Matnr, ''), NULLIF(p.Txz01, ''), 'ohne Artikel') AS Label,
'CHF ' || printf('%,.0f', SUM(MAX(CAST(e.Menge AS REAL) - CAST(e.Wemng AS REAL), 0) *
CASE WHEN CAST(p.Menge AS REAL) = 0 THEN 0 ELSE CAST(p.Netwr AS REAL) / CAST(p.Menge AS REAL) END)) AS Value,
'Faellig ' || COALESCE(MIN(e.Eindt), 'ohne Termin') AS Detail,
CASE WHEN MIN(date(e.Eindt)) < date('now', 'localtime') THEN 'High' ELSE 'Medium' END AS Severity
FROM PurchasingEketCache e
LEFT JOIN PurchasingEkpoCache p ON p.Ebeln = e.Ebeln AND p.Ebelp = e.Ebelp
LEFT JOIN PurchasingEkkoCache k ON k.Ebeln = e.Ebeln
WHERE COALESCE(p.Loekz, '') = '' AND " + eketPeriod + @" AND MAX(CAST(e.Menge AS REAL) - CAST(e.Wemng AS REAL), 0) > 0
GROUP BY COALESCE(NULLIF(k.Lifnr, ''), 'ohne Lieferant'), COALESCE(NULLIF(p.Matnr, ''), NULLIF(p.Txz01, ''), 'ohne Artikel')
ORDER BY SUM(MAX(CAST(e.Menge AS REAL) - CAST(e.Wemng AS REAL), 0) *
CASE WHEN CAST(p.Menge AS REAL) = 0 THEN 0 ELSE CAST(p.Netwr AS REAL) / CAST(p.Menge AS REAL) END) DESC
LIMIT 10;", cancellationToken);
state.PriceVarianceRows = await ExecuteAnalysisRowsAsync(conn, @"
WITH priced AS (
SELECT
COALESCE(NULLIF(k.Lifnr, ''), 'ohne Lieferant') AS Supplier,
COALESCE(NULLIF(p.Matnr, ''), NULLIF(p.Txz01, ''), 'ohne Artikel') AS Article,
substr(k.Bedat, 1, 4) AS Year,
MIN(CASE WHEN CAST(p.Menge AS REAL) = 0 THEN NULL ELSE CAST(p.Netwr AS REAL) / CAST(p.Menge AS REAL) END) AS UnitPrice
FROM PurchasingEkpoCache p
LEFT JOIN PurchasingEkkoCache k ON k.Ebeln = p.Ebeln
WHERE COALESCE(p.Loekz, '') = '' AND CAST(p.Menge AS REAL) > 0 AND k.Bedat IS NOT NULL AND k.Bedat <> '' AND " + joinedEkkoPeriod + @"
GROUP BY Supplier, Article, Year
)
SELECT
Supplier || ' / ' || Article AS Label,
'CHF ' || printf('%.2f', UnitPrice) AS Value,
'Jahr ' || Year || ' | PowerBI: Min(Netwr CHF/Stk)' AS Detail,
CASE WHEN UnitPrice > 1000 THEN 'High'
WHEN UnitPrice > 100 THEN 'Medium'
ELSE 'Low' END AS Severity
FROM priced
WHERE UnitPrice IS NOT NULL
ORDER BY Year DESC, UnitPrice DESC
LIMIT 10;", cancellationToken);
state.PriceVarianceChartRows = await ExecuteChartRowsAsync(conn, @"
WITH priced AS (
SELECT
substr(k.Bedat, 1, 4) AS Year,
COALESCE(NULLIF(p.Matnr, ''), NULLIF(p.Txz01, ''), 'ohne Artikel') AS Article,
MIN(CASE WHEN CAST(p.Menge AS REAL) = 0 THEN NULL ELSE CAST(p.Netwr AS REAL) / CAST(p.Menge AS REAL) END) AS UnitPrice
FROM PurchasingEkpoCache p
LEFT JOIN PurchasingEkkoCache k ON k.Ebeln = p.Ebeln
WHERE COALESCE(p.Loekz, '') = '' AND CAST(p.Menge AS REAL) > 0 AND k.Bedat IS NOT NULL AND k.Bedat <> '' AND " + joinedEkkoPeriod + @"
GROUP BY Year, Article
)
SELECT Year, MIN(UnitPrice) AS Value
FROM priced
WHERE UnitPrice IS NOT NULL
GROUP BY Year
ORDER BY Year;", cancellationToken);
state.PriceTrendChartRows = state.PriceVarianceChartRows.ToList();
state.SpendConcentrationChartRows = await ExecuteChartRowsAsync(conn, @"
SELECT 'Lieferant ' || COALESCE(NULLIF(k.Lifnr, ''), 'ohne Lieferant') AS Label, SUM(CAST(p.Netwr AS REAL)) AS Value
FROM PurchasingEkpoCache p
LEFT JOIN PurchasingEkkoCache k ON k.Ebeln = p.Ebeln
WHERE COALESCE(p.Loekz, '') = '' AND " + joinedEkkoPeriod + @"
GROUP BY COALESCE(NULLIF(k.Lifnr, ''), 'ohne Lieferant')
ORDER BY Value DESC
LIMIT 10;", cancellationToken);
var totalSpend = state.SpendChfSample <= 0 ? 1 : state.SpendChfSample;
var concentrationRows = await ExecuteAnalysisRowsAsync(conn, @"
SELECT
COALESCE(NULLIF(k.Lifnr, ''), 'ohne Lieferant') AS Label,
'CHF ' || printf('%,.0f', SUM(CAST(p.Netwr AS REAL))) AS Value,
COUNT(DISTINCT COALESCE(NULLIF(p.Matkl, ''), 'ohne Warengruppe')) || ' Warengruppen' AS Detail,
CASE WHEN SUM(CAST(p.Netwr AS REAL)) > 1000000 THEN 'High'
WHEN SUM(CAST(p.Netwr AS REAL)) > 250000 THEN 'Medium'
ELSE 'Low' END AS Severity
FROM PurchasingEkpoCache p
LEFT JOIN PurchasingEkkoCache k ON k.Ebeln = p.Ebeln
WHERE COALESCE(p.Loekz, '') = '' AND " + joinedEkkoPeriod + @"
GROUP BY COALESCE(NULLIF(k.Lifnr, ''), 'ohne Lieferant')
ORDER BY SUM(CAST(p.Netwr AS REAL)) DESC
LIMIT 10;", cancellationToken);
state.SpendConcentrationRows = concentrationRows
.Select((row, index) => row with { Detail = $"{row.Detail} | Rang {index + 1} | Anteil {CalculateSupplierShare(state.SpendConcentrationChartRows, row.Label, totalSpend):N1}%" })
.ToList();
state.DataQualityChartRows = await ExecuteChartRowsAsync(conn, @"
SELECT 'fehlender Lieferant' AS Label, COUNT(*) AS Value FROM PurchasingEkkoCache WHERE COALESCE(NULLIF(Lifnr, ''), '') = ''
UNION ALL
SELECT 'fehlende Warengruppe', COUNT(*) FROM PurchasingEkpoCache WHERE COALESCE(NULLIF(Matkl, ''), '') = ''
UNION ALL
SELECT 'fehlender Artikel/Text', COUNT(*) FROM PurchasingEkpoCache WHERE COALESCE(NULLIF(Matnr, ''), NULLIF(Txz01, ''), '') = ''
UNION ALL
SELECT 'Nullmenge', COUNT(*) FROM PurchasingEkpoCache WHERE CAST(Menge AS REAL) = 0
UNION ALL
SELECT 'Nullwert', COUNT(*) FROM PurchasingEkpoCache WHERE CAST(Netwr AS REAL) = 0;", cancellationToken);
state.DataQualityRows = await ExecuteAnalysisRowsAsync(conn, @"
SELECT 'Fehlender Lieferant' AS Label, COUNT(*) || ' Belege' AS Value, 'EKKO.Lifnr leer' AS Detail, CASE WHEN COUNT(*) > 0 THEN 'High' ELSE 'Low' END AS Severity FROM PurchasingEkkoCache WHERE COALESCE(NULLIF(Lifnr, ''), '') = ''
UNION ALL
SELECT 'Fehlende Warengruppe', COUNT(*) || ' Positionen', 'EKPO.Matkl leer', CASE WHEN COUNT(*) > 0 THEN 'Medium' ELSE 'Low' END FROM PurchasingEkpoCache WHERE COALESCE(NULLIF(Matkl, ''), '') = ''
UNION ALL
SELECT 'Fehlender Artikel/Text', COUNT(*) || ' Positionen', 'EKPO.Matnr und Txz01 leer', CASE WHEN COUNT(*) > 0 THEN 'High' ELSE 'Low' END FROM PurchasingEkpoCache WHERE COALESCE(NULLIF(Matnr, ''), NULLIF(Txz01, ''), '') = ''
UNION ALL
SELECT 'Nullmenge', COUNT(*) || ' Positionen', 'EKPO.Menge = 0', CASE WHEN COUNT(*) > 0 THEN 'Medium' ELSE 'Low' END FROM PurchasingEkpoCache WHERE CAST(Menge AS REAL) = 0
UNION ALL
SELECT 'Nullwert', COUNT(*) || ' Positionen', 'EKPO.Netwr = 0', CASE WHEN COUNT(*) > 0 THEN 'Medium' ELSE 'Low' END FROM PurchasingEkpoCache WHERE CAST(Netwr AS REAL) = 0;", cancellationToken);
}
private static void ApplyEkpoMetrics(
PurchasingDashboardLiveState state,
List<Dictionary<string, object?>> ekkoRows,
List<Dictionary<string, object?>> ekpoRows)
{
if (ekpoRows.Count == 0)
return;
var supplierByEbeln = ekkoRows
.Select(row => new { Ebeln = GetText(row, "Ebeln"), Lifnr = FormatSupplierLabel(GetText(row, "Lifnr")) })
.Where(row => !string.IsNullOrWhiteSpace(row.Ebeln))
.GroupBy(row => row.Ebeln, StringComparer.OrdinalIgnoreCase)
.ToDictionary(group => group.Key, group => group.First().Lifnr, StringComparer.OrdinalIgnoreCase);
var monthByEbeln = ekkoRows
.Select(row => new { Ebeln = GetText(row, "Ebeln"), Month = TryParseSapDate(GetText(row, "Bedat"))?.ToString("yyyy-MM") ?? "ohne Datum" })
.Where(row => !string.IsNullOrWhiteSpace(row.Ebeln))
.GroupBy(row => row.Ebeln, StringComparer.OrdinalIgnoreCase)
.ToDictionary(group => group.Key, group => group.First().Month, StringComparer.OrdinalIgnoreCase);
var enriched = ekpoRows
.Select(row =>
{
var ebeln = GetText(row, "Ebeln");
supplierByEbeln.TryGetValue(ebeln, out var supplier);
monthByEbeln.TryGetValue(ebeln, out var month);
var netwr = GetDecimal(row, "Netwr");
var quantity = GetDecimal(row, "Menge");
return new
{
Ebeln = ebeln,
Supplier = string.IsNullOrWhiteSpace(supplier) ? "ohne Lieferant" : supplier,
Month = string.IsNullOrWhiteSpace(month) ? "ohne Datum" : month,
Material = FirstNonEmpty(GetText(row, "Matnr"), GetText(row, "Txz01"), "ohne Artikel"),
MaterialGroup = FirstNonEmpty(GetText(row, "Matkl"), "ohne Warengruppe"),
NetValue = netwr,
Quantity = quantity
};
})
.ToList();
state.SpendChfSample = enriched.Sum(row => row.NetValue);
state.TopSupplierLabel = BuildTopLabel(enriched.GroupBy(row => row.Supplier), row => row.NetValue, "Lieferant");
state.TopMaterialGroupLabel = BuildTopLabel(enriched.GroupBy(row => row.MaterialGroup), row => row.NetValue, "Warengruppe");
state.TopArticleLabel = BuildTopLabel(enriched.GroupBy(row => $"{row.Material} | {row.Supplier} | Monat {row.Month}"), row => row.NetValue, "Artikel");
state.SpendChartRows = enriched
.GroupBy(row => row.Supplier)
.Select(group => new PurchasingLiveChartPoint(group.Key, group.Sum(row => row.NetValue)))
.OrderByDescending(row => row.Value)
.Take(6)
.ToList();
}
private static void ApplyEketMetrics(
PurchasingDashboardLiveState state,
List<Dictionary<string, object?>> ekkoRows,
List<Dictionary<string, object?>> ekpoRows,
List<Dictionary<string, object?>> eketRows)
{
if (eketRows.Count == 0)
return;
var supplierByEbeln = ekkoRows
.Select(row => new { Ebeln = GetText(row, "Ebeln"), Lifnr = FormatSupplierLabel(GetText(row, "Lifnr")) })
.Where(row => !string.IsNullOrWhiteSpace(row.Ebeln))
.GroupBy(row => row.Ebeln, StringComparer.OrdinalIgnoreCase)
.ToDictionary(group => group.Key, group => group.First().Lifnr, StringComparer.OrdinalIgnoreCase);
var itemByPosition = ekpoRows
.Select(row =>
{
var ebeln = GetText(row, "Ebeln");
var ebelp = GetText(row, "Ebelp");
return new
{
key = $"{ebeln}|{ebelp}",
Article = FirstNonEmpty(GetText(row, "Matnr"), GetText(row, "Txz01"), "ohne Artikel")
};
})
.GroupBy(row => row.key, StringComparer.OrdinalIgnoreCase)
.ToDictionary(group => group.Key, group => group.First().Article, StringComparer.OrdinalIgnoreCase);
var netPriceByPosition = ekpoRows
.Select(row =>
{
var ebeln = GetText(row, "Ebeln");
var ebelp = GetText(row, "Ebelp");
var key = $"{ebeln}|{ebelp}";
var quantity = GetDecimal(row, "Menge");
var netValue = GetDecimal(row, "Netwr");
var netPrice = quantity == 0 ? 0 : netValue / quantity;
return new { key, netPrice };
})
.GroupBy(row => row.key, StringComparer.OrdinalIgnoreCase)
.ToDictionary(group => group.Key, group => group.First().netPrice, StringComparer.OrdinalIgnoreCase);
var enriched = eketRows
.Select(row =>
{
var ebeln = GetText(row, "Ebeln");
var ebelp = GetText(row, "Ebelp");
var key = $"{ebeln}|{ebelp}";
netPriceByPosition.TryGetValue(key, out var netPrice);
itemByPosition.TryGetValue(key, out var article);
supplierByEbeln.TryGetValue(ebeln, out var supplier);
var quantity = GetDecimal(row, "Menge");
var received = GetDecimal(row, "Wemng");
var openQuantity = Math.Max(0, quantity - received);
return new
{
Ebeln = ebeln,
Supplier = string.IsNullOrWhiteSpace(supplier) ? "ohne Lieferant" : supplier,
Article = string.IsNullOrWhiteSpace(article) ? "ohne Artikel" : article,
DueDate = TryParseSapDate(GetText(row, "Eindt")),
OpenQuantity = openQuantity,
OpenValue = openQuantity * netPrice
};
})
.ToList();
state.OpenQuantitySample = enriched.Sum(row => row.OpenQuantity);
state.OpenValueSample = enriched.Sum(row => row.OpenValue);
state.ContractValueSample = state.OpenValueSample;
state.OpenValueChartRows = enriched
.GroupBy(row => row.DueDate?.ToString("yyyy-MM") ?? "ohne Termin")
.Select(group => new PurchasingLiveChartPoint(group.Key, group.Sum(row => row.OpenValue)))
.OrderBy(row => row.Label)
.Take(6)
.ToList();
state.CommitmentDetailChartRows = enriched
.Where(row => row.OpenValue > 0)
.GroupBy(row => $"{row.Supplier} | {row.Article} | faellig {row.DueDate?.ToString("yyyy-MM") ?? "ohne Termin"}")
.Select(group => new PurchasingLiveChartPoint(group.Key, group.Sum(row => row.OpenValue)))
.OrderByDescending(row => row.Value)
.Take(6)
.ToList();
state.ContractChartRows = state.CommitmentDetailChartRows.Count > 0
? state.CommitmentDetailChartRows.ToList()
: state.OpenValueChartRows.ToList();
state.TopCommitmentLabel = state.CommitmentDetailChartRows.Count > 0
? $"{state.CommitmentDetailChartRows[0].Label}: CHF {state.CommitmentDetailChartRows[0].Value:N0}"
: string.Empty;
}
private static HttpClient CreateClient(string username, string password)
{
var client = new HttpClient { Timeout = TimeSpan.FromSeconds(45) };
var token = Convert.ToBase64String(Encoding.UTF8.GetBytes($"{username}:{password}"));
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Basic", token);
client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
return client;
}
private static async Task<List<Dictionary<string, object?>>> ReadRowsAsync(HttpClient client, string url, CancellationToken cancellationToken)
{
using var response = await client.GetAsync(url, cancellationToken);
if (!response.IsSuccessStatusCode)
return [];
var json = await response.Content.ReadAsStringAsync(cancellationToken);
using var document = JsonDocument.Parse(json);
if (!document.RootElement.TryGetProperty("d", out var d) ||
!d.TryGetProperty("results", out var results) ||
results.ValueKind != JsonValueKind.Array)
return [];
return results.EnumerateArray()
.Select(item => item.EnumerateObject()
.Where(property => property.Name != "__metadata")
.ToDictionary(property => property.Name, property => ConvertJsonValue(property.Value), StringComparer.OrdinalIgnoreCase))
.ToList();
}
private static async Task<int?> ReadCountAsync(HttpClient client, string url, CancellationToken cancellationToken)
{
using var response = await client.GetAsync(url, cancellationToken);
if (!response.IsSuccessStatusCode)
return null;
var text = await response.Content.ReadAsStringAsync(cancellationToken);
return int.TryParse(text.Trim(), NumberStyles.Integer, CultureInfo.InvariantCulture, out var value) ? value : null;
}
private static async Task<int> ExecuteScalarIntAsync(SqliteConnection conn, string sql, CancellationToken cancellationToken)
{
await using var command = conn.CreateCommand();
command.CommandText = sql;
var value = await command.ExecuteScalarAsync(cancellationToken);
return Convert.ToInt32(value ?? 0, CultureInfo.InvariantCulture);
}
private static async Task<decimal> ExecuteScalarDecimalAsync(SqliteConnection conn, string sql, CancellationToken cancellationToken)
{
await using var command = conn.CreateCommand();
command.CommandText = sql;
var value = await command.ExecuteScalarAsync(cancellationToken);
return Convert.ToDecimal(value ?? 0, CultureInfo.InvariantCulture);
}
private static async Task<DateTime?> ExecuteScalarDateAsync(SqliteConnection conn, string sql, CancellationToken cancellationToken)
{
await using var command = conn.CreateCommand();
command.CommandText = sql;
var value = Convert.ToString(await command.ExecuteScalarAsync(cancellationToken), CultureInfo.InvariantCulture);
return string.IsNullOrWhiteSpace(value) ? null : TryParseSapDate(value);
}
private static async Task<string> ExecuteTopLabelAsync(SqliteConnection conn, string sql, string fallback, CancellationToken cancellationToken)
{
await using var command = conn.CreateCommand();
command.CommandText = sql;
await using var reader = await command.ExecuteReaderAsync(cancellationToken);
if (!await reader.ReadAsync(cancellationToken))
return fallback;
var label = reader.GetString(0);
var value = Convert.ToDecimal(reader.GetValue(1), CultureInfo.InvariantCulture);
return $"{label}: CHF {value:N0}";
}
private static async Task<List<PurchasingLiveChartPoint>> ExecuteChartRowsAsync(SqliteConnection conn, string sql, CancellationToken cancellationToken)
{
var rows = new List<PurchasingLiveChartPoint>();
await using var command = conn.CreateCommand();
command.CommandText = sql;
await using var reader = await command.ExecuteReaderAsync(cancellationToken);
while (await reader.ReadAsync(cancellationToken))
{
var label = reader.GetString(0);
var value = Convert.ToDecimal(reader.GetValue(1), CultureInfo.InvariantCulture);
rows.Add(new PurchasingLiveChartPoint(label, value));
}
return rows;
}
private static async Task<List<PurchasingIdeaAnalysisRow>> ExecuteAnalysisRowsAsync(SqliteConnection conn, string sql, CancellationToken cancellationToken)
{
var rows = new List<PurchasingIdeaAnalysisRow>();
await using var command = conn.CreateCommand();
command.CommandText = sql;
await using var reader = await command.ExecuteReaderAsync(cancellationToken);
while (await reader.ReadAsync(cancellationToken))
{
rows.Add(new PurchasingIdeaAnalysisRow(
reader.IsDBNull(0) ? string.Empty : reader.GetString(0),
reader.IsDBNull(1) ? string.Empty : reader.GetString(1),
reader.IsDBNull(2) ? string.Empty : reader.GetString(2),
reader.IsDBNull(3) ? string.Empty : reader.GetString(3)));
}
return rows;
}
private static decimal CalculateSupplierShare(IReadOnlyList<PurchasingLiveChartPoint> rows, string supplier, decimal totalSpend)
{
var value = rows
.Where(row => row.Label.Equals(supplier, StringComparison.OrdinalIgnoreCase) || row.Label.Equals($"Lieferant {supplier}", StringComparison.OrdinalIgnoreCase))
.Sum(row => row.Value);
return totalSpend <= 0 ? 0 : value / totalSpend * 100m;
}
private static async Task<(string Status, DateTime? CompletedAtUtc, string Message)> ReadCacheStatusAsync(SqliteConnection conn, CancellationToken cancellationToken)
{
await using var command = conn.CreateCommand();
command.CommandText = "SELECT Status, CompletedAtUtc, Message FROM PurchasingSyncState ORDER BY Id DESC LIMIT 1;";
await using var reader = await command.ExecuteReaderAsync(cancellationToken);
if (!await reader.ReadAsync(cancellationToken))
return ("Cache", null, string.Empty);
var completedText = reader.IsDBNull(1) ? string.Empty : reader.GetString(1);
var completed = DateTime.TryParse(completedText, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal, out var parsed)
? parsed
: (DateTime?)null;
return (reader.GetString(0), completed, reader.GetString(2));
}
private static object? ConvertJsonValue(JsonElement value) => value.ValueKind switch
{
JsonValueKind.String => value.GetString(),
JsonValueKind.Number when value.TryGetDecimal(out var number) => number,
JsonValueKind.True => true,
JsonValueKind.False => false,
JsonValueKind.Null => null,
_ => value.ToString()
};
private static string GetText(Dictionary<string, object?> row, string key)
=> row.TryGetValue(key, out var value) ? Convert.ToString(value, CultureInfo.InvariantCulture) ?? string.Empty : string.Empty;
private static decimal GetDecimal(Dictionary<string, object?> row, string key)
{
var text = GetText(row, key);
return decimal.TryParse(text, NumberStyles.Number, CultureInfo.InvariantCulture, out var value)
|| decimal.TryParse(text, NumberStyles.Number, CultureInfo.CurrentCulture, out value)
? value
: 0m;
}
private static string FirstNonEmpty(params string[] values)
=> values.FirstOrDefault(value => !string.IsNullOrWhiteSpace(value)) ?? string.Empty;
private static string FormatSupplierLabel(string supplierNumber)
=> string.IsNullOrWhiteSpace(supplierNumber)
? "ohne Lieferant"
: $"Lief. {supplierNumber} (Name fehlt)";
private static string BuildTopLabel<T>(IEnumerable<IGrouping<string, T>> groups, Func<T, decimal> selector, string fallback)
{
var top = groups
.Select(group => new { Label = group.Key, Value = group.Sum(selector) })
.OrderByDescending(row => row.Value)
.FirstOrDefault();
return top is null ? fallback : $"{top.Label}: CHF {top.Value:N0}";
}
private static DateTime? TryParseSapDate(string value)
{
if (DateTime.TryParse(value, CultureInfo.InvariantCulture, DateTimeStyles.AssumeLocal, out var parsed))
return parsed;
return DateTime.TryParseExact(value, "yyyyMMdd", CultureInfo.InvariantCulture, DateTimeStyles.None, out parsed)
? parsed
: null;
}
}
@@ -0,0 +1,382 @@
using System.Data;
using System.Globalization;
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using TrafagSalesExporter.Data;
namespace TrafagSalesExporter.Services;
public sealed class PurchasingDataRefreshService : IPurchasingDataRefreshService
{
private const int PageSize = 1000;
private readonly IDbContextFactory<AppDbContext> _dbFactory;
private readonly IAppEventLogService _logService;
public PurchasingDataRefreshService(IDbContextFactory<AppDbContext> dbFactory, IAppEventLogService logService)
{
_dbFactory = dbFactory;
_logService = logService;
}
public async Task<PurchasingDataRefreshStatus> GetStatusAsync(CancellationToken cancellationToken = default)
{
await using var db = await _dbFactory.CreateDbContextAsync(cancellationToken);
var conn = (SqliteConnection)db.Database.GetDbConnection();
if (conn.State != ConnectionState.Open)
await conn.OpenAsync(cancellationToken);
var status = await ReadLatestStatusAsync(conn, cancellationToken);
status.EkkoRows = await CountTableAsync(conn, "PurchasingEkkoCache", cancellationToken);
status.EkpoRows = await CountTableAsync(conn, "PurchasingEkpoCache", cancellationToken);
status.EketRows = await CountTableAsync(conn, "PurchasingEketCache", cancellationToken);
return status;
}
public async Task<PurchasingDataRefreshStatus> RunFullLoadAsync(DateTime? fromDate = null, CancellationToken cancellationToken = default)
{
var started = DateTime.UtcNow;
await WriteStatusAsync("Full", "Running", started, null, fromDate, null, null, 0, 0, 0, "Full Load gestartet.", cancellationToken);
await _logService.WriteAsync("Purchasing", "Einkauf Full Load gestartet", details: fromDate?.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture));
try
{
var connection = await ResolveConnectionAsync(cancellationToken);
using var client = CreateClient(connection.Username, connection.Password);
var nowText = DateTime.UtcNow.ToString("O", CultureInfo.InvariantCulture);
var ekkoFilter = fromDate.HasValue ? $"Bedat ge '{fromDate.Value:yyyy-MM-dd}'" : string.Empty;
var ekkoRows = await ReadAllRowsAsync(client, connection.BaseUrl, "EKKOSet", "Ebeln,Bedat,Aedat,Lifnr,Bukrs,Konnr,Waers,Wkurs", ekkoFilter, "Ebeln", cancellationToken);
var ekpoRows = await ReadAllRowsAsync(client, connection.BaseUrl, "EKPOSet", "Ebeln,Ebelp,Matnr,Txz01,Matkl,Menge,Ktmng,Netwr,Loekz,Bukrs,Werks", string.Empty, "Ebeln,Ebelp", cancellationToken);
var eketRows = await ReadAllRowsAsync(client, connection.BaseUrl, "eketSet", "Ebeln,Ebelp,Etenr,Eindt,Menge,Wemng", string.Empty, "Ebeln,Ebelp,Etenr", cancellationToken);
await using var db = await _dbFactory.CreateDbContextAsync(cancellationToken);
var conn = (SqliteConnection)db.Database.GetDbConnection();
if (conn.State != ConnectionState.Open)
await conn.OpenAsync(cancellationToken);
await using var transaction = (SqliteTransaction)await conn.BeginTransactionAsync(cancellationToken);
await ExecuteAsync(conn, transaction, "DELETE FROM PurchasingEkkoCache;", cancellationToken);
await ExecuteAsync(conn, transaction, "DELETE FROM PurchasingEkpoCache;", cancellationToken);
await ExecuteAsync(conn, transaction, "DELETE FROM PurchasingEketCache;", cancellationToken);
await UpsertEkkoAsync(conn, transaction, ekkoRows, nowText, cancellationToken);
await UpsertEkpoAsync(conn, transaction, ekpoRows, nowText, cancellationToken);
await UpsertEketAsync(conn, transaction, eketRows, nowText, cancellationToken);
await transaction.CommitAsync(cancellationToken);
var completed = DateTime.UtcNow;
var message = $"Full Load abgeschlossen: EKKO={ekkoRows.Count:N0}, EKPO={ekpoRows.Count:N0}, EKET={eketRows.Count:N0}.";
await WriteStatusAsync("Full", "Success", started, completed, fromDate, null, completed, ekkoRows.Count, ekpoRows.Count, eketRows.Count, message, cancellationToken);
await _logService.WriteAsync("Purchasing", "Einkauf Full Load erfolgreich", details: message);
return await GetStatusAsync(cancellationToken);
}
catch (Exception ex)
{
var message = $"Full Load fehlgeschlagen: {ex.Message}";
await WriteStatusAsync("Full", "Error", started, DateTime.UtcNow, fromDate, null, null, 0, 0, 0, message, cancellationToken);
await _logService.WriteAsync("Purchasing", "Einkauf Full Load fehlgeschlagen", "Error", details: ex.ToString());
return await GetStatusAsync(cancellationToken);
}
}
public async Task<PurchasingDataRefreshStatus> RunDeltaAsync(DateTime? fromDate = null, CancellationToken cancellationToken = default)
{
var current = await GetStatusAsync(cancellationToken);
var deltaFrom = fromDate ?? current.LastSuccessfulDeltaAtUtc ?? current.CompletedAtUtc ?? DateTime.UtcNow.AddDays(-7);
var started = DateTime.UtcNow;
await WriteStatusAsync("Delta", "Running", started, null, deltaFrom, null, current.LastSuccessfulDeltaAtUtc, current.EkkoRows, current.EkpoRows, current.EketRows, "Delta gestartet.", cancellationToken);
try
{
var connection = await ResolveConnectionAsync(cancellationToken);
using var client = CreateClient(connection.Username, connection.Password);
var filter = $"Aedat ge '{deltaFrom:yyyy-MM-dd}'";
var changedEkko = await ReadAllRowsAsync(client, connection.BaseUrl, "EKKOSet", "Ebeln,Bedat,Aedat,Lifnr,Bukrs,Konnr,Waers,Wkurs", filter, "Ebeln", cancellationToken);
var ebelnKeys = changedEkko
.Select(row => GetText(row, "Ebeln"))
.Where(value => !string.IsNullOrWhiteSpace(value))
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
var ekpoRows = new List<Dictionary<string, object?>>();
var eketRows = new List<Dictionary<string, object?>>();
foreach (var ebeln in ebelnKeys)
{
ekpoRows.AddRange(await ReadAllRowsAsync(client, connection.BaseUrl, "EKPOSet", "Ebeln,Ebelp,Matnr,Txz01,Matkl,Menge,Ktmng,Netwr,Loekz,Bukrs,Werks", $"Ebeln eq '{ebeln}'", "Ebelp", cancellationToken));
eketRows.AddRange(await ReadAllRowsAsync(client, connection.BaseUrl, "eketSet", "Ebeln,Ebelp,Etenr,Eindt,Menge,Wemng", $"Ebeln eq '{ebeln}'", "Ebelp,Etenr", cancellationToken));
}
await using var db = await _dbFactory.CreateDbContextAsync(cancellationToken);
var conn = (SqliteConnection)db.Database.GetDbConnection();
if (conn.State != ConnectionState.Open)
await conn.OpenAsync(cancellationToken);
var nowText = DateTime.UtcNow.ToString("O", CultureInfo.InvariantCulture);
await using var transaction = (SqliteTransaction)await conn.BeginTransactionAsync(cancellationToken);
await UpsertEkkoAsync(conn, transaction, changedEkko, nowText, cancellationToken);
await UpsertEkpoAsync(conn, transaction, ekpoRows, nowText, cancellationToken);
await UpsertEketAsync(conn, transaction, eketRows, nowText, cancellationToken);
await transaction.CommitAsync(cancellationToken);
var completed = DateTime.UtcNow;
var status = await GetStatusAsync(cancellationToken);
var message = $"Delta abgeschlossen: geaenderte Belege={ebelnKeys.Count:N0}, EKPO={ekpoRows.Count:N0}, EKET={eketRows.Count:N0}.";
await WriteStatusAsync("Delta", "Success", started, completed, deltaFrom, null, completed, status.EkkoRows, status.EkpoRows, status.EketRows, message, cancellationToken);
await _logService.WriteAsync("Purchasing", "Einkauf Delta erfolgreich", details: message);
return await GetStatusAsync(cancellationToken);
}
catch (Exception ex)
{
await WriteStatusAsync("Delta", "Error", started, DateTime.UtcNow, deltaFrom, null, current.LastSuccessfulDeltaAtUtc, current.EkkoRows, current.EkpoRows, current.EketRows, $"Delta fehlgeschlagen: {ex.Message}", cancellationToken);
await _logService.WriteAsync("Purchasing", "Einkauf Delta fehlgeschlagen", "Error", details: ex.ToString());
return await GetStatusAsync(cancellationToken);
}
}
private async Task<PurchasingSapConnection> ResolveConnectionAsync(CancellationToken cancellationToken)
{
await using var db = await _dbFactory.CreateDbContextAsync(cancellationToken);
var sap = await db.SourceSystemDefinitions.AsNoTracking().FirstOrDefaultAsync(x => x.Code == "SAP", cancellationToken)
?? throw new InvalidOperationException("SAP Quelle fehlt.");
var site = await db.Sites.AsNoTracking().FirstOrDefaultAsync(x => x.TSC == PurchasingDataSourcePageService.PurchasingTsc, cancellationToken)
?? throw new InvalidOperationException("Einkauf SAP Site fehlt.");
var serviceUrl = string.IsNullOrWhiteSpace(site.SapServiceUrl) ? sap.CentralServiceUrl : site.SapServiceUrl;
var username = string.IsNullOrWhiteSpace(site.UsernameOverride) ? sap.CentralUsername : site.UsernameOverride;
var password = string.IsNullOrWhiteSpace(site.PasswordOverride) ? sap.CentralPassword : site.PasswordOverride;
if (string.IsNullOrWhiteSpace(serviceUrl) || string.IsNullOrWhiteSpace(username) || string.IsNullOrWhiteSpace(password))
throw new InvalidOperationException("SAP URL oder Zugangsdaten fehlen.");
return new PurchasingSapConnection(serviceUrl.TrimEnd('/') + "/", username, password);
}
private static HttpClient CreateClient(string username, string password)
{
var client = new HttpClient { Timeout = TimeSpan.FromMinutes(5) };
var token = Convert.ToBase64String(Encoding.UTF8.GetBytes($"{username}:{password}"));
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Basic", token);
client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
return client;
}
private static async Task<List<Dictionary<string, object?>>> ReadAllRowsAsync(HttpClient client, string baseUrl, string entitySet, string select, string filter, string orderBy, CancellationToken cancellationToken)
{
var rows = new List<Dictionary<string, object?>>();
for (var skip = 0; ; skip += PageSize)
{
var url = $"{baseUrl}{entitySet}?$format=json&$top={PageSize}&$skip={skip}&$select={Uri.EscapeDataString(select)}";
if (!string.IsNullOrWhiteSpace(orderBy))
url += $"&$orderby={Uri.EscapeDataString(orderBy)}";
if (!string.IsNullOrWhiteSpace(filter))
url += $"&$filter={Uri.EscapeDataString(filter)}";
using var response = await client.GetAsync(url, cancellationToken);
if (!response.IsSuccessStatusCode)
{
var error = await response.Content.ReadAsStringAsync(cancellationToken);
throw new HttpRequestException($"SAP OData {entitySet} fehlgeschlagen ({(int)response.StatusCode} {response.ReasonPhrase}) URL={url} Antwort={TrimForLog(error)}");
}
var json = await response.Content.ReadAsStringAsync(cancellationToken);
var page = ParseRows(json);
if (page.Count == 0)
return rows;
rows.AddRange(page);
if (page.Count < PageSize)
return rows;
}
}
private static List<Dictionary<string, object?>> ParseRows(string json)
{
using var document = JsonDocument.Parse(json);
if (!document.RootElement.TryGetProperty("d", out var d) ||
!d.TryGetProperty("results", out var results) ||
results.ValueKind != JsonValueKind.Array)
return [];
return results.EnumerateArray()
.Select(item => item.EnumerateObject()
.Where(property => property.Name != "__metadata")
.ToDictionary(property => property.Name, property => ConvertJsonValue(property.Value), StringComparer.OrdinalIgnoreCase))
.ToList();
}
private static async Task UpsertEkkoAsync(SqliteConnection conn, SqliteTransaction transaction, IReadOnlyList<Dictionary<string, object?>> rows, string loadedAtUtc, CancellationToken cancellationToken)
{
const string sql = @"
INSERT OR REPLACE INTO PurchasingEkkoCache (Ebeln, Bedat, Aedat, Lifnr, Bukrs, Bsart, RawJson, LastLoadedAtUtc)
VALUES ($Ebeln, $Bedat, $Aedat, $Lifnr, $Bukrs, $Bsart, $RawJson, $LastLoadedAtUtc);";
foreach (var row in rows)
await ExecuteWithParametersAsync(conn, transaction, sql, new()
{
["$Ebeln"] = GetText(row, "Ebeln"),
["$Bedat"] = NormalizeSapDate(GetText(row, "Bedat")),
["$Aedat"] = NormalizeSapDate(GetText(row, "Aedat")),
["$Lifnr"] = GetText(row, "Lifnr"),
["$Bukrs"] = GetText(row, "Bukrs"),
["$Bsart"] = GetText(row, "Bsart"),
["$RawJson"] = JsonSerializer.Serialize(row),
["$LastLoadedAtUtc"] = loadedAtUtc
}, cancellationToken);
}
private static async Task UpsertEkpoAsync(SqliteConnection conn, SqliteTransaction transaction, IReadOnlyList<Dictionary<string, object?>> rows, string loadedAtUtc, CancellationToken cancellationToken)
{
const string sql = @"
INSERT OR REPLACE INTO PurchasingEkpoCache (Ebeln, Ebelp, Matnr, Txz01, Matkl, Menge, Meins, Netwr, Loekz, RawJson, LastLoadedAtUtc)
VALUES ($Ebeln, $Ebelp, $Matnr, $Txz01, $Matkl, $Menge, $Meins, $Netwr, $Loekz, $RawJson, $LastLoadedAtUtc);";
foreach (var row in rows)
await ExecuteWithParametersAsync(conn, transaction, sql, new()
{
["$Ebeln"] = GetText(row, "Ebeln"),
["$Ebelp"] = GetText(row, "Ebelp"),
["$Matnr"] = GetText(row, "Matnr"),
["$Txz01"] = GetText(row, "Txz01"),
["$Matkl"] = GetText(row, "Matkl"),
["$Menge"] = GetText(row, "Menge"),
["$Meins"] = GetText(row, "Meins"),
["$Netwr"] = GetText(row, "Netwr"),
["$Loekz"] = GetText(row, "Loekz"),
["$RawJson"] = JsonSerializer.Serialize(row),
["$LastLoadedAtUtc"] = loadedAtUtc
}, cancellationToken);
}
private static async Task UpsertEketAsync(SqliteConnection conn, SqliteTransaction transaction, IReadOnlyList<Dictionary<string, object?>> rows, string loadedAtUtc, CancellationToken cancellationToken)
{
const string sql = @"
INSERT OR REPLACE INTO PurchasingEketCache (Ebeln, Ebelp, Etenr, Eindt, Menge, Wemng, RawJson, LastLoadedAtUtc)
VALUES ($Ebeln, $Ebelp, $Etenr, $Eindt, $Menge, $Wemng, $RawJson, $LastLoadedAtUtc);";
foreach (var row in rows)
await ExecuteWithParametersAsync(conn, transaction, sql, new()
{
["$Ebeln"] = GetText(row, "Ebeln"),
["$Ebelp"] = GetText(row, "Ebelp"),
["$Etenr"] = GetText(row, "Etenr"),
["$Eindt"] = NormalizeSapDate(GetText(row, "Eindt")),
["$Menge"] = GetText(row, "Menge"),
["$Wemng"] = GetText(row, "Wemng"),
["$RawJson"] = JsonSerializer.Serialize(row),
["$LastLoadedAtUtc"] = loadedAtUtc
}, cancellationToken);
}
private async Task WriteStatusAsync(string mode, string status, DateTime? startedAtUtc, DateTime? completedAtUtc, DateTime? fromDate, DateTime? toDate, DateTime? lastSuccessfulDeltaAtUtc, int ekkoRows, int ekpoRows, int eketRows, string message, CancellationToken cancellationToken)
{
await using var db = await _dbFactory.CreateDbContextAsync(cancellationToken);
var conn = (SqliteConnection)db.Database.GetDbConnection();
if (conn.State != ConnectionState.Open)
await conn.OpenAsync(cancellationToken);
const string sql = @"
INSERT INTO PurchasingSyncState (Mode, Status, StartedAtUtc, CompletedAtUtc, FromDate, ToDate, LastSuccessfulDeltaAtUtc, EkkoRows, EkpoRows, EketRows, Message)
VALUES ($Mode, $Status, $StartedAtUtc, $CompletedAtUtc, $FromDate, $ToDate, $LastSuccessfulDeltaAtUtc, $EkkoRows, $EkpoRows, $EketRows, $Message);";
await ExecuteWithParametersAsync(conn, null, sql, new()
{
["$Mode"] = mode,
["$Status"] = status,
["$StartedAtUtc"] = FormatDateTime(startedAtUtc),
["$CompletedAtUtc"] = FormatDateTime(completedAtUtc),
["$FromDate"] = FormatDate(fromDate),
["$ToDate"] = FormatDate(toDate),
["$LastSuccessfulDeltaAtUtc"] = FormatDateTime(lastSuccessfulDeltaAtUtc),
["$EkkoRows"] = ekkoRows,
["$EkpoRows"] = ekpoRows,
["$EketRows"] = eketRows,
["$Message"] = message
}, cancellationToken);
}
private static async Task<PurchasingDataRefreshStatus> ReadLatestStatusAsync(SqliteConnection conn, CancellationToken cancellationToken)
{
await using var command = conn.CreateCommand();
command.CommandText = @"
SELECT Mode, Status, StartedAtUtc, CompletedAtUtc, FromDate, ToDate, LastSuccessfulDeltaAtUtc, EkkoRows, EkpoRows, EketRows, Message
FROM PurchasingSyncState
ORDER BY Id DESC
LIMIT 1;";
await using var reader = await command.ExecuteReaderAsync(cancellationToken);
if (!await reader.ReadAsync(cancellationToken))
return new PurchasingDataRefreshStatus { Status = "Empty", Message = "Noch kein Einkauf Full Load ausgefuehrt." };
return new PurchasingDataRefreshStatus
{
Mode = reader.GetString(0),
Status = reader.GetString(1),
StartedAtUtc = ParseDateTime(reader.GetString(2)),
CompletedAtUtc = ParseDateTime(reader.GetString(3)),
FromDate = ParseDate(reader.GetString(4)),
ToDate = ParseDate(reader.GetString(5)),
LastSuccessfulDeltaAtUtc = ParseDateTime(reader.GetString(6)),
EkkoRows = reader.GetInt32(7),
EkpoRows = reader.GetInt32(8),
EketRows = reader.GetInt32(9),
Message = reader.GetString(10)
};
}
private static async Task<int> CountTableAsync(SqliteConnection conn, string tableName, CancellationToken cancellationToken)
{
await using var command = conn.CreateCommand();
command.CommandText = $"SELECT COUNT(1) FROM {tableName};";
return Convert.ToInt32(await command.ExecuteScalarAsync(cancellationToken), CultureInfo.InvariantCulture);
}
private static async Task ExecuteAsync(SqliteConnection conn, SqliteTransaction transaction, string sql, CancellationToken cancellationToken)
{
await using var command = conn.CreateCommand();
command.Transaction = transaction;
command.CommandText = sql;
await command.ExecuteNonQueryAsync(cancellationToken);
}
private static async Task ExecuteWithParametersAsync(SqliteConnection conn, SqliteTransaction? transaction, string sql, Dictionary<string, object?> parameters, CancellationToken cancellationToken)
{
await using var command = conn.CreateCommand();
command.Transaction = transaction;
command.CommandText = sql;
foreach (var (key, value) in parameters)
command.Parameters.AddWithValue(key, value ?? DBNull.Value);
await command.ExecuteNonQueryAsync(cancellationToken);
}
private static object? ConvertJsonValue(JsonElement value) => value.ValueKind switch
{
JsonValueKind.String => value.GetString(),
JsonValueKind.Number when value.TryGetDecimal(out var number) => number,
JsonValueKind.True => true,
JsonValueKind.False => false,
JsonValueKind.Null => null,
_ => value.ToString()
};
private static string GetText(Dictionary<string, object?> row, string key)
=> row.TryGetValue(key, out var value) ? Convert.ToString(value, CultureInfo.InvariantCulture) ?? string.Empty : string.Empty;
private static string TrimForLog(string value)
=> value.Length <= 1000 ? value : value[..1000] + "...";
private static string? NormalizeSapDate(string value)
{
if (string.IsNullOrWhiteSpace(value))
return null;
if (DateTime.TryParse(value, CultureInfo.InvariantCulture, DateTimeStyles.AssumeLocal, out var parsed))
return parsed.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture);
return DateTime.TryParseExact(value, "yyyyMMdd", CultureInfo.InvariantCulture, DateTimeStyles.None, out parsed)
? parsed.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture)
: value;
}
private static string FormatDateTime(DateTime? value)
=> value?.ToString("O", CultureInfo.InvariantCulture) ?? string.Empty;
private static string FormatDate(DateTime? value)
=> value?.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture) ?? string.Empty;
private static DateTime? ParseDateTime(string value)
=> DateTime.TryParse(value, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal, out var parsed) ? parsed : null;
private static DateTime? ParseDate(string value)
=> DateTime.TryParseExact(value, "yyyy-MM-dd", CultureInfo.InvariantCulture, DateTimeStyles.None, out var parsed) ? parsed : null;
private sealed record PurchasingSapConnection(string BaseUrl, string Username, string Password);
}
@@ -0,0 +1,240 @@
using Microsoft.EntityFrameworkCore;
using TrafagSalesExporter.Data;
using TrafagSalesExporter.Models;
namespace TrafagSalesExporter.Services;
public sealed class PurchasingDataSourcePageService : IPurchasingDataSourcePageService
{
public const string PurchasingTsc = "PURCHASING_SAP";
private readonly IDbContextFactory<AppDbContext> _dbFactory;
private readonly ISapGatewayService _sapGatewayService;
public PurchasingDataSourcePageService(IDbContextFactory<AppDbContext> dbFactory, ISapGatewayService sapGatewayService)
{
_dbFactory = dbFactory;
_sapGatewayService = sapGatewayService;
}
public async Task<PurchasingDataSourceState> LoadAsync()
{
await using var db = await _dbFactory.CreateDbContextAsync();
await EnsureDefaultsAsync(db);
return await LoadStateAsync(db);
}
public async Task<PurchasingDataSourceState> SaveAsync(PurchasingDataSourceState state)
{
await using var db = await _dbFactory.CreateDbContextAsync();
var site = await GetOrCreateSiteAsync(db);
site.SapServiceUrl = state.Site.SapServiceUrl.Trim();
site.UsernameOverride = state.Site.UsernameOverride.Trim();
site.PasswordOverride = state.Site.PasswordOverride;
site.IsActive = state.Site.IsActive;
Replace(db, db.SapSourceDefinitions.Where(x => x.SiteId == site.Id), state.Sources.Select((x, i) => new SapSourceDefinition
{
SiteId = site.Id,
Alias = x.Alias.Trim(),
EntitySet = x.EntitySet.Trim(),
IsPrimary = x.IsPrimary,
IsActive = x.IsActive,
SortOrder = x.SortOrder == 0 ? i * 10 : x.SortOrder
}));
Replace(db, db.SapJoinDefinitions.Where(x => x.SiteId == site.Id), state.Joins.Select((x, i) => new SapJoinDefinition
{
SiteId = site.Id,
LeftAlias = x.LeftAlias.Trim(),
RightAlias = x.RightAlias.Trim(),
LeftKeys = x.LeftKeys.Trim(),
RightKeys = x.RightKeys.Trim(),
JoinType = string.IsNullOrWhiteSpace(x.JoinType) ? "Left" : x.JoinType.Trim(),
IsActive = x.IsActive,
SortOrder = x.SortOrder == 0 ? i * 10 : x.SortOrder
}));
Replace(db, db.SapFieldMappings.Where(x => x.SiteId == site.Id), state.Mappings.Select((x, i) => new SapFieldMapping
{
SiteId = site.Id,
TargetField = x.TargetField.Trim(),
SourceExpression = x.SourceExpression.Trim(),
IsRequired = x.IsRequired,
IsActive = x.IsActive,
SortOrder = x.SortOrder == 0 ? i * 10 : x.SortOrder
}));
await db.SaveChangesAsync();
return await LoadStateAsync(db);
}
public async Task<PurchasingDataSourceState> ResetDefaultsAsync()
{
await using var db = await _dbFactory.CreateDbContextAsync();
var site = await GetOrCreateSiteAsync(db);
db.SapSourceDefinitions.RemoveRange(db.SapSourceDefinitions.Where(x => x.SiteId == site.Id));
db.SapJoinDefinitions.RemoveRange(db.SapJoinDefinitions.Where(x => x.SiteId == site.Id));
db.SapFieldMappings.RemoveRange(db.SapFieldMappings.Where(x => x.SiteId == site.Id));
await db.SaveChangesAsync();
AddDefaultSources(db, site.Id);
AddDefaultJoins(db, site.Id);
AddDefaultMappings(db, site.Id);
await db.SaveChangesAsync();
return await LoadStateAsync(db);
}
public async Task<PageActionResult> TestConnectionAsync(PurchasingDataSourceState state)
{
await using var db = await _dbFactory.CreateDbContextAsync();
var sourceSystem = await db.SourceSystemDefinitions
.AsNoTracking()
.OrderBy(x => x.Id)
.FirstOrDefaultAsync(x => x.Code == "SAP");
var serviceUrl = string.IsNullOrWhiteSpace(state.Site.SapServiceUrl)
? sourceSystem?.CentralServiceUrl ?? string.Empty
: state.Site.SapServiceUrl;
var username = string.IsNullOrWhiteSpace(state.Site.UsernameOverride)
? sourceSystem?.CentralUsername ?? string.Empty
: state.Site.UsernameOverride;
var password = string.IsNullOrWhiteSpace(state.Site.PasswordOverride)
? sourceSystem?.CentralPassword ?? string.Empty
: state.Site.PasswordOverride;
if (string.IsNullOrWhiteSpace(serviceUrl))
return PageActionResult.WarningResult("Keine SAP Service URL gepflegt.");
if (string.IsNullOrWhiteSpace(username) || string.IsNullOrWhiteSpace(password))
return PageActionResult.WarningResult("Keine SAP Gateway Zugangsdaten gepflegt.");
try
{
await _sapGatewayService.TestConnectionAsync(serviceUrl.Trim(), username.Trim(), password);
return PageActionResult.SuccessResult("SAP OData Verbindung erfolgreich.");
}
catch (Exception ex)
{
return PageActionResult.ErrorResult($"SAP OData Verbindung fehlgeschlagen: {ex.Message}");
}
}
private async Task<PurchasingDataSourceState> LoadStateAsync(AppDbContext db)
{
var site = await GetOrCreateSiteAsync(db);
var sourceSystem = await db.SourceSystemDefinitions
.AsNoTracking()
.OrderBy(x => x.Id)
.FirstOrDefaultAsync(x => x.Code == "SAP");
return new PurchasingDataSourceState
{
Site = Clone(site),
SourceSystem = sourceSystem,
Sources = await db.SapSourceDefinitions.AsNoTracking().Where(x => x.SiteId == site.Id).OrderBy(x => x.SortOrder).ThenBy(x => x.Id).ToListAsync(),
Joins = await db.SapJoinDefinitions.AsNoTracking().Where(x => x.SiteId == site.Id).OrderBy(x => x.SortOrder).ThenBy(x => x.Id).ToListAsync(),
Mappings = await db.SapFieldMappings.AsNoTracking().Where(x => x.SiteId == site.Id).OrderBy(x => x.SortOrder).ThenBy(x => x.Id).ToListAsync()
};
}
private static async Task EnsureDefaultsAsync(AppDbContext db)
{
var site = await GetOrCreateSiteAsync(db);
var hasSources = await db.SapSourceDefinitions.AnyAsync(x => x.SiteId == site.Id);
if (hasSources)
return;
AddDefaultSources(db, site.Id);
AddDefaultJoins(db, site.Id);
AddDefaultMappings(db, site.Id);
await db.SaveChangesAsync();
}
private static async Task<Site> GetOrCreateSiteAsync(AppDbContext db)
{
var site = await db.Sites.OrderBy(x => x.Id).FirstOrDefaultAsync(x => x.TSC == PurchasingTsc);
if (site is not null)
return site;
site = new Site
{
Schema = string.Empty,
TSC = PurchasingTsc,
Land = "Einkauf SAP",
SourceSystem = "SAP",
IsActive = false
};
db.Sites.Add(site);
await db.SaveChangesAsync();
return site;
}
private static void AddDefaultSources(AppDbContext db, int siteId)
{
db.SapSourceDefinitions.AddRange(
new SapSourceDefinition { SiteId = siteId, Alias = "EKKO", EntitySet = "EKKOSet", IsPrimary = true, IsActive = true, SortOrder = 10 },
new SapSourceDefinition { SiteId = siteId, Alias = "EKPO", EntitySet = "EKPOSet", IsPrimary = false, IsActive = true, SortOrder = 20 },
new SapSourceDefinition { SiteId = siteId, Alias = "EKET", EntitySet = "eketSet", IsPrimary = false, IsActive = true, SortOrder = 30 },
new SapSourceDefinition { SiteId = siteId, Alias = "LIEF", EntitySet = "Data", IsPrimary = false, IsActive = true, SortOrder = 40 },
new SapSourceDefinition { SiteId = siteId, Alias = "WG", EntitySet = "Data2", IsPrimary = false, IsActive = true, SortOrder = 50 });
}
private static void AddDefaultJoins(AppDbContext db, int siteId)
{
db.SapJoinDefinitions.AddRange(
new SapJoinDefinition { SiteId = siteId, LeftAlias = "EKKO", RightAlias = "EKPO", LeftKeys = "Ebeln", RightKeys = "Ebeln", JoinType = "Left", IsActive = true, SortOrder = 10 },
new SapJoinDefinition { SiteId = siteId, LeftAlias = "EKPO", RightAlias = "EKET", LeftKeys = "Ebeln,Ebelp", RightKeys = "Ebeln,Ebelp", JoinType = "Left", IsActive = true, SortOrder = 20 },
new SapJoinDefinition { SiteId = siteId, LeftAlias = "EKKO", RightAlias = "LIEF", LeftKeys = "Lifnr", RightKeys = "Lifnr", JoinType = "Left", IsActive = true, SortOrder = 30 },
new SapJoinDefinition { SiteId = siteId, LeftAlias = "EKPO", RightAlias = "WG", LeftKeys = "Matkl", RightKeys = "Matkl", JoinType = "Left", IsActive = true, SortOrder = 40 });
}
private static void AddDefaultMappings(AppDbContext db, int siteId)
{
db.SapFieldMappings.AddRange(
new SapFieldMapping { SiteId = siteId, TargetField = "PurchaseOrder", SourceExpression = "EKKO.Ebeln", IsRequired = true, IsActive = true, SortOrder = 10 },
new SapFieldMapping { SiteId = siteId, TargetField = "PurchaseOrderDate", SourceExpression = "EKKO.Bedat", IsRequired = true, IsActive = true, SortOrder = 20 },
new SapFieldMapping { SiteId = siteId, TargetField = "SupplierNumber", SourceExpression = "EKKO.Lifnr", IsRequired = false, IsActive = true, SortOrder = 30 },
new SapFieldMapping { SiteId = siteId, TargetField = "SupplierName", SourceExpression = "LIEF.Name", IsRequired = false, IsActive = true, SortOrder = 40 },
new SapFieldMapping { SiteId = siteId, TargetField = "Position", SourceExpression = "EKPO.Ebelp", IsRequired = true, IsActive = true, SortOrder = 50 },
new SapFieldMapping { SiteId = siteId, TargetField = "Material", SourceExpression = "EKPO.Matnr", IsRequired = false, IsActive = true, SortOrder = 60 },
new SapFieldMapping { SiteId = siteId, TargetField = "MaterialText", SourceExpression = "EKPO.Txz01", IsRequired = false, IsActive = true, SortOrder = 70 },
new SapFieldMapping { SiteId = siteId, TargetField = "MaterialGroup", SourceExpression = "EKPO.Matkl", IsRequired = false, IsActive = true, SortOrder = 80 },
new SapFieldMapping { SiteId = siteId, TargetField = "MaterialGroupText", SourceExpression = "WG.WgKomplett", IsRequired = false, IsActive = true, SortOrder = 90 },
new SapFieldMapping { SiteId = siteId, TargetField = "NetValueChf", SourceExpression = "EKPO.NetwrChf", IsRequired = false, IsActive = true, SortOrder = 100 },
new SapFieldMapping { SiteId = siteId, TargetField = "NetValueChfPerPiece", SourceExpression = "EKPO.NetwrChfStk", IsRequired = false, IsActive = true, SortOrder = 110 },
new SapFieldMapping { SiteId = siteId, TargetField = "OrderQuantity", SourceExpression = "EKPO.Menge", IsRequired = false, IsActive = true, SortOrder = 120 },
new SapFieldMapping { SiteId = siteId, TargetField = "ScheduleDate", SourceExpression = "EKET.Eindt", IsRequired = false, IsActive = true, SortOrder = 130 },
new SapFieldMapping { SiteId = siteId, TargetField = "ScheduleQuantity", SourceExpression = "EKET.Menge", IsRequired = false, IsActive = true, SortOrder = 140 });
}
private static void Replace<TEntity>(AppDbContext db, IQueryable<TEntity> oldRows, IEnumerable<TEntity> newRows)
where TEntity : class
{
var set = db.Set<TEntity>();
set.RemoveRange(oldRows);
set.AddRange(newRows);
}
private static Site Clone(Site site) => new()
{
Id = site.Id,
HanaServerId = site.HanaServerId,
Schema = site.Schema,
TSC = site.TSC,
Land = site.Land,
SourceSystem = site.SourceSystem,
UsernameOverride = site.UsernameOverride,
PasswordOverride = site.PasswordOverride,
LocalExportFolderOverride = site.LocalExportFolderOverride,
ManualImportFilePath = site.ManualImportFilePath,
ManualImportLastUploadedAtUtc = site.ManualImportLastUploadedAtUtc,
SapServiceUrl = site.SapServiceUrl,
SapEntitySet = site.SapEntitySet,
SapEntitySetsCache = site.SapEntitySetsCache,
SapEntitySetsRefreshedAtUtc = site.SapEntitySetsRefreshedAtUtc,
IsActive = site.IsActive
};
}
@@ -96,6 +96,7 @@ public sealed class SettingsPageService : ISettingsPageService
var existing = await db.ExportSettings.FirstOrDefaultAsync();
if (existing is null)
{
settings.ExchangeRateDateField = NormalizeExchangeRateDateField(settings.ExchangeRateDateField);
db.ExportSettings.Add(settings);
}
else
@@ -107,6 +108,10 @@ public sealed class SettingsPageService : ISettingsPageService
existing.DebugLoggingEnabled = settings.DebugLoggingEnabled;
existing.LocalSiteExportFolder = settings.LocalSiteExportFolder;
existing.LocalConsolidatedExportFolder = settings.LocalConsolidatedExportFolder;
existing.AuditCsvEnabled = settings.AuditCsvEnabled;
existing.UseAuditCsvAsCentralSource = settings.UseAuditCsvAsCentralSource;
existing.LocalAuditCsvFolder = settings.LocalAuditCsvFolder;
existing.ExchangeRateDateField = NormalizeExchangeRateDateField(settings.ExchangeRateDateField);
}
await db.SaveChangesAsync();
@@ -281,6 +286,18 @@ public sealed class SettingsPageService : ISettingsPageService
public static string NormalizeConfigValue(string? value) => value?.Trim() ?? string.Empty;
public static string NormalizeExchangeRateDateField(string? value)
{
var normalized = NormalizeConfigValue(value);
return normalized switch
{
ExchangeRateDateFields.PostingDate => ExchangeRateDateFields.PostingDate,
ExchangeRateDateFields.InvoiceDate => ExchangeRateDateFields.InvoiceDate,
ExchangeRateDateFields.ExtractionDate => ExchangeRateDateFields.ExtractionDate,
_ => ExchangeRateDateFields.PostingDate
};
}
public static string BuildSharePointTestPreview(string tenantId, string clientId, string clientSecret, string siteUrl)
{
var maskedSecret = string.IsNullOrEmpty(clientSecret)
@@ -10,7 +10,7 @@ namespace TrafagSalesExporter.Services;
public class SharePointUploadService : ISharePointUploadService
{
public async Task UploadAsync(string tenantId, string clientId, string clientSecret,
string siteUrl, string exportFolder, string land, string localFilePath)
string siteUrl, string exportFolder, string land, string localFilePath, bool uploadTimestampedCopyIfLocked = false)
{
var normalizedTenantId = Normalize(tenantId);
var normalizedClientId = Normalize(clientId);
@@ -33,17 +33,16 @@ public class SharePointUploadService : ISharePointUploadService
if (drive?.Id is null)
throw new InvalidOperationException("SharePoint Dokumentenbibliothek konnte nicht gefunden werden.");
var fileName = Path.GetFileName(localFilePath);
var remotePath = string.Join("/",
new[]
{
normalizedExportFolder.Trim('/').Trim(),
normalizedLand.Trim('/').Trim(),
fileName
}.Where(segment => !string.IsNullOrWhiteSpace(segment)));
await using var stream = File.OpenRead(localFilePath);
await graphClient.Drives[drive.Id].Root.ItemWithPath(remotePath).Content.PutAsync(stream);
var remotePath = BuildUploadPath(normalizedExportFolder, normalizedLand, Path.GetFileName(localFilePath));
try
{
await UploadWithLockRetryAsync(graphClient, drive.Id, remotePath, localFilePath);
}
catch (Microsoft.Graph.Models.ODataErrors.ODataError ex) when (uploadTimestampedCopyIfLocked && IsLockedSharePointResource(ex))
{
var timestampedPath = BuildUploadPath(normalizedExportFolder, normalizedLand, BuildTimestampedFileName(localFilePath));
await UploadWithLockRetryAsync(graphClient, drive.Id, timestampedPath, localFilePath);
}
}
public async Task<string> DownloadToTempFileAsync(string tenantId, string clientId, string clientSecret, string siteUrl, string fileReference)
@@ -114,6 +113,7 @@ public class SharePointUploadService : ISharePointUploadService
var normalizedSiteUrl = Normalize(siteUrl);
var normalizedReference = Normalize(folderReference);
var normalizedTsc = Normalize(siteTsc).ToUpperInvariant();
var isSpainImport = IsSpainManualImport(normalizedTsc, normalizedReference);
if (string.IsNullOrWhiteSpace(normalizedReference))
throw new InvalidOperationException("SharePoint-Ordnerreferenz fehlt.");
@@ -137,16 +137,41 @@ public class SharePointUploadService : ISharePointUploadService
var allCandidates = children?.Value?
.Where(item => item.File is not null)
.Where(item => IsSupportedManualImportFile(item.Name))
.Where(item => MatchesTsc(item.Name, normalizedTsc))
.Select(item => new
.Where(item => isSpainImport ? IsSpainSalesFile(item.Name) : MatchesTsc(item.Name, normalizedTsc))
.Select(item =>
{
Item = item,
FileDate = TryParseDatedSiteFileName(item.Name, normalizedTsc, out var fileDate) ? fileDate : (DateTime?)null,
AnnualYear = TryParseAnnualSiteFileName(item.Name, normalizedTsc, out var annualYear) ? annualYear : (int?)null,
SnapshotDate = TryParseSnapshotDate(item.Name, out var snapshotDate) ? snapshotDate : (DateTime?)null
var hasSpainRange = TryParseSpainSalesRangeFileName(item.Name, out var rangeStart, out var rangeEnd);
return new
{
Item = item,
FileDate = TryParseDatedSiteFileName(item.Name, normalizedTsc, out var fileDate) ? fileDate : (DateTime?)null,
SpainRangeStart = hasSpainRange ? rangeStart : (DateTime?)null,
SpainRangeEnd = hasSpainRange ? rangeEnd : (DateTime?)null,
AnnualYear = TryParseAnnualSiteFileName(item.Name, normalizedTsc, out var annualYear) ? annualYear : (int?)null,
SnapshotDate = TryParseSnapshotDate(item.Name, out var snapshotDate) ? snapshotDate : (DateTime?)null
};
})
.ToList() ?? [];
if (isSpainImport)
{
var spainCandidates = allCandidates
.OrderBy(x => x.SpainRangeStart is null ? 0 : 1)
.ThenBy(x => x.SpainRangeStart ?? DateTime.MinValue)
.ThenBy(x => x.SpainRangeEnd ?? DateTime.MinValue)
.ThenBy(x => x.Item.Name, StringComparer.OrdinalIgnoreCase)
.ToList();
if (spainCandidates.Count == 0)
throw new InvalidOperationException($"Im SharePoint-Ordner '{folderPath}' wurde keine Spain_Sales*.csv gefunden.");
return spainCandidates
.Select(x => new SharePointFileReference(
string.Join("/", folderPath.Trim('/'), x.Item.Name).Trim('/'),
x.Item.LastModifiedDateTime))
.ToList();
}
if (preferredYear is not null)
{
var annual = allCandidates
@@ -242,6 +267,45 @@ public class SharePointUploadService : ISharePointUploadService
private static string Normalize(string value) => value?.Trim() ?? string.Empty;
private static async Task UploadWithLockRetryAsync(GraphServiceClient graphClient, string driveId, string remotePath, string localFilePath)
{
const int attempts = 4;
for (var attempt = 1; attempt <= attempts; attempt++)
{
try
{
await using var stream = File.OpenRead(localFilePath);
await graphClient.Drives[driveId].Root.ItemWithPath(remotePath).Content.PutAsync(stream);
return;
}
catch (Microsoft.Graph.Models.ODataErrors.ODataError ex) when (attempt < attempts && IsLockedSharePointResource(ex))
{
await Task.Delay(TimeSpan.FromSeconds(5 * attempt));
}
}
}
private static string BuildUploadPath(string exportFolder, string land, string fileName)
=> string.Join("/",
new[]
{
exportFolder.Trim('/').Trim(),
land.Trim('/').Trim(),
fileName
}.Where(segment => !string.IsNullOrWhiteSpace(segment)));
private static string BuildTimestampedFileName(string localFilePath)
{
var name = Path.GetFileNameWithoutExtension(localFilePath);
var extension = Path.GetExtension(localFilePath);
var timestamp = DateTime.Now.ToString("yyyyMMdd-HHmmss", CultureInfo.InvariantCulture);
return $"{name}_{timestamp}{extension}";
}
private static bool IsLockedSharePointResource(Exception ex)
=> ex.Message.Contains("locked", StringComparison.OrdinalIgnoreCase) ||
ex.ToString().Contains("locked", StringComparison.OrdinalIgnoreCase);
private static string ResolveRemotePath(string fileReference, Uri siteUri)
{
if (Uri.TryCreate(fileReference, UriKind.Absolute, out var fileUri))
@@ -277,6 +341,39 @@ public class SharePointUploadService : ISharePointUploadService
Regex.IsMatch(nameWithoutExtension, $@"(^|[^A-Z0-9]){Regex.Escape(normalizedTsc)}([^A-Z0-9]|$)", RegexOptions.IgnoreCase);
}
private static bool IsSpainManualImport(string normalizedTsc, string folderReference)
=> string.Equals(normalizedTsc, "TRES", StringComparison.OrdinalIgnoreCase) ||
string.Equals(normalizedTsc, "TRSE", StringComparison.OrdinalIgnoreCase) ||
folderReference.Contains("Spanien", StringComparison.OrdinalIgnoreCase) ||
folderReference.Contains("Spain", StringComparison.OrdinalIgnoreCase);
private static bool IsSpainSalesFile(string? fileName)
=> Path.GetFileName(fileName ?? string.Empty).StartsWith("Spain_Sales", StringComparison.OrdinalIgnoreCase) &&
Path.GetExtension(fileName ?? string.Empty).Equals(".csv", StringComparison.OrdinalIgnoreCase);
private static bool TryParseSpainSalesRangeFileName(string? fileName, out DateTime rangeStart, out DateTime rangeEnd)
{
rangeStart = default;
rangeEnd = default;
var nameWithoutExtension = Path.GetFileNameWithoutExtension(fileName ?? string.Empty);
var match = Regex.Match(nameWithoutExtension, @"^Spain_Sales_range_(?<from>\d{8})_to_(?<to>\d{8})$", RegexOptions.IgnoreCase);
if (!match.Success)
return false;
return DateTime.TryParseExact(
match.Groups["from"].Value,
"yyyyMMdd",
CultureInfo.InvariantCulture,
DateTimeStyles.None,
out rangeStart) &&
DateTime.TryParseExact(
match.Groups["to"].Value,
"yyyyMMdd",
CultureInfo.InvariantCulture,
DateTimeStyles.None,
out rangeEnd);
}
private static bool TryParseDatedSiteFileName(string? fileName, string normalizedTsc, out DateTime fileDate)
{
fileDate = default;
@@ -14,6 +14,7 @@ public class SiteExportService : ISiteExportService
private readonly ISharePointUploadService _sharePointService;
private readonly IRecordTransformationService _transformationService;
private readonly ICentralSalesRecordService _centralSalesRecordService;
private readonly IExportAuditCsvService _auditCsvService;
private readonly IAppEventLogService _appEventLogService;
private readonly ILogger<SiteExportService> _logger;
@@ -24,6 +25,7 @@ public class SiteExportService : ISiteExportService
ISharePointUploadService sharePointService,
IRecordTransformationService transformationService,
ICentralSalesRecordService centralSalesRecordService,
IExportAuditCsvService auditCsvService,
IAppEventLogService appEventLogService,
ILogger<SiteExportService> logger)
{
@@ -33,6 +35,7 @@ public class SiteExportService : ISiteExportService
_sharePointService = sharePointService;
_transformationService = transformationService;
_centralSalesRecordService = centralSalesRecordService;
_auditCsvService = auditCsvService;
_appEventLogService = appEventLogService;
_logger = logger;
}
@@ -76,6 +79,15 @@ public class SiteExportService : ISiteExportService
details: $"Records vor Transformation={records.Count}");
_transformationService.Apply(records, rules);
var auditCsvPath = await _auditCsvService.WriteSiteAuditCsvAsync(
site, settings, sourceSystem, outputDir, records);
if (!string.IsNullOrWhiteSpace(auditCsvPath))
{
await _appEventLogService.WriteAsync("Export", "Audit-CSV geschrieben",
siteId: site.Id, land: site.Land,
details: auditCsvPath);
}
var filePath = fetchResult.ReferenceFilePath;
if (string.IsNullOrWhiteSpace(filePath))
{
@@ -94,7 +106,7 @@ public class SiteExportService : ISiteExportService
details: $"Records={records.Count}");
await _centralSalesRecordService.ReplaceForSiteAsync(site, records, updateStatus);
await UploadToSharePointIfConfiguredAsync(site, spConfig, filePath, updateStatus, fetchResult);
await UploadToSharePointIfConfiguredAsync(site, spConfig, filePath, auditCsvPath, updateStatus, fetchResult);
sw.Stop();
log.Status = "OK";
@@ -159,6 +171,7 @@ public class SiteExportService : ISiteExportService
Site site,
SharePointConfig? spConfig,
string filePath,
string? auditCsvPath,
Action<string>? updateStatus,
DataSourceFetchResult fetchResult)
{
@@ -179,6 +192,17 @@ public class SiteExportService : ISiteExportService
await _sharePointService.UploadAsync(
spConfig.TenantId, spConfig.ClientId, spConfig.ClientSecret,
spConfig.SiteUrl, uploadFolder, uploadLand, filePath);
if (string.IsNullOrWhiteSpace(auditCsvPath) || !File.Exists(auditCsvPath))
return;
updateStatus?.Invoke("Audit-CSV SharePoint Upload...");
await _appEventLogService.WriteAsync("Export", "Audit-CSV SharePoint Upload gestartet",
siteId: site.Id, land: site.Land,
details: $"{spConfig.SiteUrl} | {uploadFolder}");
await _sharePointService.UploadAsync(
spConfig.TenantId, spConfig.ClientId, spConfig.ClientSecret,
spConfig.SiteUrl, uploadFolder, uploadLand, auditCsvPath);
}
private static string NormalizeSourceSystem(string? sourceSystem)
File diff suppressed because it is too large Load Diff
@@ -556,7 +556,7 @@ static SpainSalesCsvProbe? LoadSpainSalesCsvProbe(string? path)
AddGroupValue(bySeries, series, sales);
}
const decimal reference = 3102333.61m;
const decimal reference = 3082320.18m;
return new SpainSalesCsvProbe
{
Path = path,
@@ -102,6 +102,54 @@ public class DatabaseInitializationServiceTests : IDisposable
x.TargetField == nameof(SalesRecord.DocumentType) &&
x.SourceHeader == "=Alphaplan Excel");
Assert.Equal(2, db.FieldTransformationRules.Count(x => x.SourceSystem == "MANUAL_EXCEL"));
var purchasing = Assert.Single(db.Sites, x => x.TSC == PurchasingDataSourcePageService.PurchasingTsc);
Assert.Equal("SAP", purchasing.SourceSystem);
Assert.Contains(db.SapSourceDefinitions, x => x.SiteId == purchasing.Id && x.Alias == "EKKO" && x.EntitySet == "EKKOSet" && x.IsPrimary);
Assert.Contains(db.SapSourceDefinitions, x => x.SiteId == purchasing.Id && x.Alias == "EKPO" && x.EntitySet == "EKPOSet");
Assert.Contains(db.SapSourceDefinitions, x => x.SiteId == purchasing.Id && x.Alias == "EKET" && x.EntitySet == "eketSet");
Assert.Contains(db.SapJoinDefinitions, x => x.SiteId == purchasing.Id && x.LeftAlias == "EKKO" && x.RightAlias == "EKPO");
Assert.Contains(db.SapFieldMappings, x => x.SiteId == purchasing.Id && x.TargetField == "NetValueChf" && x.SourceExpression == "EKPO.NetwrChf");
}
[Fact]
public async Task InitializeAsync_Repairs_India_Sage_Hana_Mapping()
{
await PrepareIndiaSourceSystemDriftAsync();
var service = CreateService();
await service.InitializeAsync();
await using var db = await _dbFactory.CreateDbContextAsync();
var india = Assert.Single(db.Sites.Include(x => x.HanaServer), x => x.TSC == "TRIN");
var sageServer = db.HanaServers
.OrderBy(x => x.Id)
.First(x => x.SourceSystem == "SAGE");
Assert.Equal("SAGE", india.SourceSystem);
Assert.Equal("TRAFAG_LIVE", india.Schema);
Assert.Equal("india-user", india.UsernameOverride);
Assert.Equal("india-password", india.PasswordOverride);
Assert.Equal("20.197.20.60", sageServer.Host);
Assert.Equal(30015, sageServer.Port);
Assert.Equal(sageServer.Id, india.HanaServerId);
}
[Fact]
public async Task InitializeAsync_Repairs_Spain_Manual_Import_File_To_Folder()
{
await PrepareSpainManualImportFilePathAsync();
var service = CreateService();
await service.InitializeAsync();
await using var db = await _dbFactory.CreateDbContextAsync();
var spain = Assert.Single(db.Sites, x => x.TSC == "TRSE");
Assert.Equal("MANUAL_EXCEL", spain.SourceSystem);
Assert.Equal(
"https://trafagag.sharepoint.com/sites/WorldwideBIPlatform/Import/Finance/Spanien",
spain.ManualImportFilePath);
}
private async Task PrepareLegacySitesTableAsync()
@@ -156,6 +204,88 @@ VALUES (
await db.Database.ExecuteSqlRawAsync("PRAGMA foreign_keys = ON;");
}
private async Task PrepareIndiaSourceSystemDriftAsync()
{
await using var db = await _dbFactory.CreateDbContextAsync();
db.HanaServers.RemoveRange(db.HanaServers);
db.Sites.RemoveRange(db.Sites);
await db.SaveChangesAsync();
var bi1Server = new HanaServer
{
SourceSystem = "BI1",
Name = "Internal",
Host = "travtrp0",
Port = 30015,
Username = string.Empty,
Password = string.Empty
};
var indiaServer = new HanaServer
{
SourceSystem = string.Empty,
Name = "India",
Host = "20.197.20.60",
Port = 30015,
Username = string.Empty,
Password = string.Empty
};
var emptySageServer = new HanaServer
{
SourceSystem = "SAGE",
Name = "SAGE",
Host = string.Empty,
Port = 30015,
Username = string.Empty,
Password = string.Empty
};
db.HanaServers.AddRange(bi1Server, indiaServer, emptySageServer);
await db.SaveChangesAsync();
db.Sites.Add(new Site
{
HanaServerId = indiaServer.Id,
Schema = "TRAFAG_LIVE",
TSC = "TRIN",
Land = "Indien",
SourceSystem = "BI1",
UsernameOverride = "india-user",
PasswordOverride = "india-password",
IsActive = true
});
await db.SaveChangesAsync();
}
private async Task PrepareSpainManualImportFilePathAsync()
{
await using var db = await _dbFactory.CreateDbContextAsync();
db.HanaServers.RemoveRange(db.HanaServers);
db.Sites.RemoveRange(db.Sites);
await db.SaveChangesAsync();
db.Sites.AddRange(
new Site
{
Schema = "fr01_p",
TSC = "TRFR",
Land = "Frankreich",
SourceSystem = "BI1",
IsActive = true
},
new Site
{
Schema = "Spanien",
TSC = "TRSE",
Land = "Spanien",
SourceSystem = "MANUAL_EXCEL",
ManualImportFilePath = "https://trafagag.sharepoint.com/sites/WorldwideBIPlatform/Import/Finance/Spanien/Spain_Sales_2025.csv",
IsActive = true
});
await db.SaveChangesAsync();
}
private async Task PrepareBrokenHanaServerForeignKeyAsync()
{
await using var db = await _dbFactory.CreateDbContextAsync();
@@ -37,13 +37,24 @@ public class ExcelExportServiceTests
var sales = workbook.Worksheet("Sales");
var includedGermanyRows = sales.RowsUsed()
.Skip(1)
.Where(row => row.Cell(36).GetValue<int>() == 2025)
.Where(row => row.Cell(37).GetString() == "DE")
.Where(row => row.Cell(41).GetString() == "TRUE")
.Where(row => row.Cell(43).GetValue<int>() == 2025)
.Where(row => row.Cell(44).GetString() == "DE")
.Where(row => row.Cell(48).GetString() == "TRUE")
.ToList();
Assert.Equal(2, includedGermanyRows.Count);
Assert.Equal(80m, includedGermanyRows.Sum(row => row.Cell(39).GetValue<decimal>()));
Assert.Equal(80m, includedGermanyRows.Sum(row => row.Cell(46).GetValue<decimal>()));
var details = workbook.Worksheet("Finance Details");
var includedGermanyDetailRows = details.RowsUsed()
.Where(row => row.RowNumber() > 4)
.Where(row => row.Cell(1).GetValue<int>() == 2025)
.Where(row => row.Cell(2).GetString() == "DE")
.ToList();
Assert.Equal(2, includedGermanyDetailRows.Count);
Assert.Equal(80m, includedGermanyDetailRows.Sum(row => row.Cell(5).GetValue<decimal>()));
Assert.All(includedGermanyDetailRows, row => Assert.Equal("Sales Price/Value", row.Cell(6).GetString()));
}
finally
{

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