diff --git a/TrafagSalesExporter/Components/Pages/ManagementCockpit.razor b/TrafagSalesExporter/Components/Pages/ManagementCockpit.razor
index 7d6a3ff..e2c6320 100644
--- a/TrafagSalesExporter/Components/Pages/ManagementCockpit.razor
+++ b/TrafagSalesExporter/Components/Pages/ManagementCockpit.razor
@@ -284,6 +284,115 @@
+
+
+
+
+ @T("Materialien", "Materials")
+ @_financeResult.ProductAssignmentSummary.DistinctMaterialCount.ToString("N0")
+
+
+
+
+ @T("Zugeordnet", "Assigned")
+ @_financeResult.ProductAssignmentSummary.MatchedMaterialCount.ToString("N0")
+
+
+
+
+ @T("Nicht zugeordnet", "Unassigned")
+ @_financeResult.ProductAssignmentSummary.UnassignedMaterialCount.ToString("N0")
+
+
+
+
+ @T("Nicht im Stamm", "Not in master")
+ @_financeResult.ProductAssignmentSummary.MissingReferenceMaterialCount.ToString("N0")
+
+
+
+
+ @T("Material fehlt", "Material missing")
+ @_financeResult.ProductAssignmentSummary.MissingMaterialNumberCount.ToString("N0")
+
+
+
+
+ @T("TR-AG Referenz", "TR AG reference")
+ @_financeResult.ProductAssignmentSummary.ReferenceMaterialCount.ToString("N0")
+
+
+
+
+
+ @T("Diese Sicht prueft Materialnummern aller gefilterten Laender gegen die fuehrende TR-AG-Referenz aus `ProductDivisionRefSet`. Die Produktsparten der lokalen ERPs werden nicht verwendet.",
+ "This view checks material numbers from all filtered countries against the leading TR AG reference from `ProductDivisionRefSet`. Local ERP product divisions are not used.")
+
+
+
+ @T("Abdeckung nach Land", "Coverage by country")
+
+
+ @T("Land", "Country")
+ TSC
+ @T("Materialien", "Materials")
+ @T("Zugeordnet", "Assigned")
+ @T("Nicht zugeordnet", "Unassigned")
+ @T("Nicht im Stamm", "Not in master")
+ @T("Material fehlt", "Material missing")
+ @T("Trefferquote", "Match rate")
+
+
+ @context.CountryKey
+ @context.Tsc
+ @context.DistinctMaterialCount.ToString("N0")
+ @context.MatchedMaterialCount.ToString("N0")
+ @context.UnassignedMaterialCount.ToString("N0")
+ @context.MissingReferenceMaterialCount.ToString("N0")
+ @context.MissingMaterialNumberCount.ToString("N0")
+ @FormatPercent(context.MatchPercent)
+
+
+ @T("Keine Materialdaten fuer diese Filter.", "No material data for these filters.")
+
+
+
+
+
+ @T("Materialpruefung gegen TR-AG-Referenz", "Material check against TR AG reference")
+
+
+ @T("Status", "Status")
+ @T("Land", "Country")
+ TSC
+ @T("Land-Material", "Local material")
+ @T("Land-Text", "Local text")
+ @T("TR-AG-MATNR", "TR AG MATNR")
+ PAPH1
+ @T("Produktfamilie", "Product family")
+ @T("Produktsparte", "Product division")
+ @T("Zeilen", "Rows")
+ @T("Finance-Wert", "Finance value")
+
+
+ @context.Status
+ @context.CountryKey
+ @context.Tsc
+ @context.Material
+ @context.ArticleName
+ @context.ReferenceMaterial
+ @BuildCodeText(context.ProductHierarchyCode, context.ProductHierarchyText)
+ @BuildCodeText(context.ProductFamilyCode, context.ProductFamilyText)
+ @BuildCodeText(context.ProductDivisionCode, context.ProductDivisionText)
+ @context.RowCount.ToString("N0")
+ @FormatValue(context.NetSalesActual, context.Currency)
+
+
+ @T("Keine Materialpruefung fuer diese Filter.", "No material check for these filters.")
+
+
+
+
@@ -827,6 +936,23 @@
_ => Color.Info
};
+ private static Color ProductAssignmentColor(string status) => status switch
+ {
+ "Zugeordnet" => Color.Success,
+ "Nicht zugeordnet" => Color.Warning,
+ "Nicht im TR-AG-Stamm" => Color.Error,
+ "Material fehlt" => Color.Default,
+ _ => Color.Info
+ };
+
+ private static string BuildCodeText(string code, string text)
+ {
+ if (string.IsNullOrWhiteSpace(code))
+ return string.IsNullOrWhiteSpace(text) ? "-" : text;
+
+ return string.IsNullOrWhiteSpace(text) ? code : $"{code} - {text}";
+ }
+
private void SetSelectedCentralAdditionalValueFields(IEnumerable values)
{
_selectedCentralAdditionalValueFields = values
diff --git a/TrafagSalesExporter/Models/ManagementCockpitModels.cs b/TrafagSalesExporter/Models/ManagementCockpitModels.cs
index 3fea0c5..90bc973 100644
--- a/TrafagSalesExporter/Models/ManagementCockpitModels.cs
+++ b/TrafagSalesExporter/Models/ManagementCockpitModels.cs
@@ -215,6 +215,49 @@ public class ManagementFinanceDataQualityRow
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 ManagementProductAssignmentCountryRow
+{
+ public string CountryKey { get; set; } = string.Empty;
+ public string Tsc { get; set; } = string.Empty;
+ public int DistinctMaterialCount { get; set; }
+ public int MatchedMaterialCount { get; set; }
+ public int UnassignedMaterialCount { get; set; }
+ public int MissingReferenceMaterialCount { get; set; }
+ public int MissingMaterialNumberCount { get; set; }
+ public decimal MatchPercent { get; set; }
+}
+
+public class ManagementProductAssignmentRow
+{
+ public string Status { get; set; } = string.Empty;
+ public string CountryKey { get; set; } = string.Empty;
+ public string Tsc { get; set; } = string.Empty;
+ public string SourceSystem { get; set; } = string.Empty;
+ public string Material { get; set; } = string.Empty;
+ public string ArticleName { get; set; } = string.Empty;
+ public string ReferenceMaterial { get; set; } = string.Empty;
+ public string ProductHierarchyCode { get; set; } = string.Empty;
+ public string ProductHierarchyText { get; set; } = string.Empty;
+ public string ProductFamilyCode { get; set; } = string.Empty;
+ public string ProductFamilyText { get; set; } = string.Empty;
+ public string ProductDivisionCode { get; set; } = string.Empty;
+ public string ProductDivisionText { get; set; } = string.Empty;
+ public string ProductMappingAssigned { get; set; } = string.Empty;
+ public int RowCount { get; set; }
+ public decimal NetSalesActual { get; set; }
+ public string Currency { get; set; } = string.Empty;
+}
+
public class ManagementFinanceSummaryResult
{
public ManagementFinanceSummaryFilter Filter { get; set; } = new();
@@ -235,4 +278,7 @@ public class ManagementFinanceSummaryResult
public List DataStatusRows { get; set; } = [];
public List CreditCandidates { get; set; } = [];
public List DataQualityRows { get; set; } = [];
+ public ManagementProductAssignmentSummary ProductAssignmentSummary { get; set; } = new();
+ public List ProductAssignmentCountryRows { get; set; } = [];
+ public List ProductAssignmentRows { get; set; } = [];
}
diff --git a/TrafagSalesExporter/Services/ManagementCockpitService.cs b/TrafagSalesExporter/Services/ManagementCockpitService.cs
index 3871b7a..e9f8076 100644
--- a/TrafagSalesExporter/Services/ManagementCockpitService.cs
+++ b/TrafagSalesExporter/Services/ManagementCockpitService.cs
@@ -53,6 +53,14 @@ public class ManagementCockpitService : IManagementCockpitService
}
];
+ private static class ProductAssignmentStatuses
+ {
+ public const string Assigned = "Zugeordnet";
+ public const string Unassigned = "Nicht zugeordnet";
+ public const string NoReference = "Nicht im TR-AG-Stamm";
+ public const string MissingMaterial = "Material fehlt";
+ }
+
public async Task> GetAvailableFilesAsync()
{
using var db = await _dbFactory.CreateDbContextAsync();
@@ -322,6 +330,13 @@ public class ManagementCockpitService : IManagementCockpitService
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,
SupplierCountry = r.SupplierCountry,
CustomerNumber = r.CustomerNumber,
@@ -363,7 +378,15 @@ public class ManagementCockpitService : IManagementCockpitService
InvoiceNumber = record.InvoiceNumber,
DocumentType = record.DocumentType,
Material = record.Material,
+ ArticleName = 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,
CustomerName = record.CustomerName,
PostingDate = record.PostingDate,
InvoiceDate = record.InvoiceDate,
@@ -436,6 +459,7 @@ public class ManagementCockpitService : IManagementCockpitService
var dataStatusRows = await BuildFinanceDataStatusRowsAsync(db);
var countryRows = BuildFinanceCountryStatusRows(scopedRows, referenceByKey);
+ var productAssignmentRows = BuildProductAssignmentRows(scopedRows, allRows);
return new ManagementFinanceSummaryResult
{
@@ -472,7 +496,10 @@ public class ManagementCockpitService : IManagementCockpitService
.ToList(),
DataStatusRows = dataStatusRows,
CreditCandidates = BuildFinanceCreditCandidates(scopedRows),
- DataQualityRows = BuildFinanceDataQualityRows(scopedRows)
+ DataQualityRows = BuildFinanceDataQualityRows(scopedRows),
+ ProductAssignmentSummary = BuildProductAssignmentSummary(productAssignmentRows),
+ ProductAssignmentCountryRows = BuildProductAssignmentCountryRows(productAssignmentRows),
+ ProductAssignmentRows = productAssignmentRows
};
}
@@ -609,6 +636,144 @@ public class ManagementCockpitService : IManagementCockpitService
.ToList();
}
+ private static List BuildProductAssignmentRows(
+ IReadOnlyCollection scopedRows,
+ IReadOnlyCollection allRows)
+ {
+ var referenceByMaterial = allRows
+ .Where(row => !string.IsNullOrWhiteSpace(row.Material))
+ .Where(row => HasProductReference(row))
+ .GroupBy(row => NormalizeMaterialKey(row.Material), StringComparer.OrdinalIgnoreCase)
+ .ToDictionary(
+ group => group.Key,
+ group => group
+ .OrderByDescending(row => IsAssignedProductReference(row))
+ .ThenBy(row => row.Tsc, StringComparer.OrdinalIgnoreCase)
+ .First(),
+ StringComparer.OrdinalIgnoreCase);
+
+ return scopedRows
+ .GroupBy(row => new
+ {
+ MaterialKey = NormalizeMaterialKey(row.Material),
+ row.Material,
+ row.ArticleName,
+ row.CountryKey,
+ row.Tsc,
+ row.SourceSystem,
+ row.Currency
+ })
+ .Select(group =>
+ {
+ var material = group.Key.Material?.Trim() ?? string.Empty;
+ referenceByMaterial.TryGetValue(group.Key.MaterialKey, out var reference);
+ var status = BuildProductAssignmentStatus(material, reference);
+ return new ManagementProductAssignmentRow
+ {
+ Status = status,
+ CountryKey = group.Key.CountryKey,
+ Tsc = group.Key.Tsc,
+ SourceSystem = group.Key.SourceSystem,
+ Material = material,
+ ArticleName = group.Key.ArticleName,
+ ReferenceMaterial = reference?.Material ?? string.Empty,
+ ProductHierarchyCode = reference?.ProductHierarchyCode ?? string.Empty,
+ ProductHierarchyText = reference?.ProductHierarchyText ?? string.Empty,
+ ProductFamilyCode = reference?.ProductFamilyCode ?? string.Empty,
+ ProductFamilyText = reference?.ProductFamilyText ?? string.Empty,
+ ProductDivisionCode = reference?.ProductDivisionCode ?? string.Empty,
+ ProductDivisionText = reference?.ProductDivisionText ?? string.Empty,
+ ProductMappingAssigned = reference?.ProductMappingAssigned ?? string.Empty,
+ RowCount = group.Count(),
+ NetSalesActual = group.Sum(row => row.Value),
+ Currency = group.Key.Currency
+ };
+ })
+ .OrderBy(row => ProductAssignmentStatusSort(row.Status))
+ .ThenBy(row => row.CountryKey, StringComparer.OrdinalIgnoreCase)
+ .ThenByDescending(row => Math.Abs(row.NetSalesActual))
+ .ThenBy(row => row.Material, StringComparer.OrdinalIgnoreCase)
+ .ToList();
+ }
+
+ private static ManagementProductAssignmentSummary BuildProductAssignmentSummary(IReadOnlyCollection rows)
+ => new()
+ {
+ DistinctMaterialCount = rows.Count,
+ MatchedMaterialCount = rows.Count(row => row.Status == ProductAssignmentStatuses.Assigned),
+ UnassignedMaterialCount = rows.Count(row => row.Status == ProductAssignmentStatuses.Unassigned),
+ MissingReferenceMaterialCount = rows.Count(row => row.Status == ProductAssignmentStatuses.NoReference),
+ MissingMaterialNumberCount = rows.Count(row => row.Status == ProductAssignmentStatuses.MissingMaterial),
+ ReferenceMaterialCount = rows
+ .Where(row => !string.IsNullOrWhiteSpace(row.ReferenceMaterial))
+ .Select(row => row.ReferenceMaterial)
+ .Distinct(StringComparer.OrdinalIgnoreCase)
+ .Count()
+ };
+
+ private static List BuildProductAssignmentCountryRows(IEnumerable rows)
+ => rows
+ .GroupBy(row => new { row.CountryKey, row.Tsc })
+ .OrderBy(group => group.Key.CountryKey, StringComparer.OrdinalIgnoreCase)
+ .ThenBy(group => group.Key.Tsc, StringComparer.OrdinalIgnoreCase)
+ .Select(group =>
+ {
+ var rowList = group.ToList();
+ var matched = rowList.Count(row => row.Status == ProductAssignmentStatuses.Assigned);
+ var relevant = rowList.Count(row => row.Status != ProductAssignmentStatuses.MissingMaterial);
+ return new ManagementProductAssignmentCountryRow
+ {
+ CountryKey = group.Key.CountryKey,
+ Tsc = group.Key.Tsc,
+ DistinctMaterialCount = rowList.Count,
+ MatchedMaterialCount = matched,
+ UnassignedMaterialCount = rowList.Count(row => row.Status == ProductAssignmentStatuses.Unassigned),
+ MissingReferenceMaterialCount = rowList.Count(row => row.Status == ProductAssignmentStatuses.NoReference),
+ MissingMaterialNumberCount = rowList.Count(row => row.Status == ProductAssignmentStatuses.MissingMaterial),
+ MatchPercent = relevant == 0 ? 0m : matched * 100m / relevant
+ };
+ })
+ .ToList();
+
+ private static string BuildProductAssignmentStatus(string material, FinanceAggregationRow? reference)
+ {
+ if (string.IsNullOrWhiteSpace(material))
+ return ProductAssignmentStatuses.MissingMaterial;
+ if (reference is null)
+ return ProductAssignmentStatuses.NoReference;
+ return IsAssignedProductReference(reference)
+ ? ProductAssignmentStatuses.Assigned
+ : ProductAssignmentStatuses.Unassigned;
+ }
+
+ private static bool HasProductReference(FinanceAggregationRow row)
+ => !string.IsNullOrWhiteSpace(row.ProductHierarchyCode) ||
+ !string.IsNullOrWhiteSpace(row.ProductFamilyCode) ||
+ !string.IsNullOrWhiteSpace(row.ProductDivisionCode) ||
+ !string.IsNullOrWhiteSpace(row.ProductMappingAssigned);
+
+ private static bool IsAssignedProductReference(FinanceAggregationRow row)
+ => IsTruthy(row.ProductMappingAssigned) &&
+ !string.IsNullOrWhiteSpace(row.ProductDivisionCode) &&
+ !string.Equals(row.ProductDivisionCode, "UNASS", StringComparison.OrdinalIgnoreCase);
+
+ private static bool IsTruthy(string value)
+ => value.Equals("X", StringComparison.OrdinalIgnoreCase) ||
+ value.Equals("TRUE", StringComparison.OrdinalIgnoreCase) ||
+ value.Equals("1", StringComparison.OrdinalIgnoreCase) ||
+ value.Equals("JA", StringComparison.OrdinalIgnoreCase);
+
+ private static int ProductAssignmentStatusSort(string status) => status switch
+ {
+ ProductAssignmentStatuses.NoReference => 0,
+ ProductAssignmentStatuses.Unassigned => 1,
+ ProductAssignmentStatuses.MissingMaterial => 2,
+ _ => 3
+ };
+
+ private static string NormalizeMaterialKey(string value)
+ => string.IsNullOrWhiteSpace(value) ? string.Empty : value.Trim().ToUpperInvariant();
+
private static ManagementFinanceDataQualityRow BuildQualityRow(string issue, int count, int totalRows)
{
var share = totalRows == 0 ? 0m : count / (decimal)totalRows;
@@ -1325,7 +1490,15 @@ public class ManagementCockpitService : IManagementCockpitService
public string InvoiceNumber { get; set; } = string.Empty;
public string DocumentType { get; set; } = string.Empty;
public string Material { get; set; } = string.Empty;
+ public string ArticleName { 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 string CustomerName { get; set; } = string.Empty;
public DateTime? PostingDate { get; set; }
public DateTime? InvoiceDate { get; set; }
diff --git a/TrafagSalesExporter/TrafagSalesExporter.Tests/ManagementCockpitServiceTests.cs b/TrafagSalesExporter/TrafagSalesExporter.Tests/ManagementCockpitServiceTests.cs
index 7ccc7ea..9da2858 100644
--- a/TrafagSalesExporter/TrafagSalesExporter.Tests/ManagementCockpitServiceTests.cs
+++ b/TrafagSalesExporter/TrafagSalesExporter.Tests/ManagementCockpitServiceTests.cs
@@ -313,6 +313,63 @@ public class ManagementCockpitServiceTests : IDisposable
Assert.Contains(result.DataQualityRows, row => row.Issue == "Nullwerte im Finance-Wert" && row.Count == 1);
}
+ [Fact]
+ public async Task AnalyzeFinanceSummaryAsync_Builds_Central_Product_Assignment_Tab_Data()
+ {
+ await SeedCentralRowsAsync(
+ CreateRow("SAP", "Schweiz", "ZSCHWEIZ", "CH-1", "CHF", 100m, new DateTime(2025, 1, 10),
+ material: "MAT-OK",
+ name: "Reference article",
+ productHierarchyCode: "0414",
+ productHierarchyText: "Industat innen",
+ productFamilyCode: "0004",
+ productFamilyText: "Industat",
+ productDivisionCode: "0001",
+ productDivisionText: "Thermostate",
+ productMappingAssigned: "X"),
+ CreateRow("SAP", "Schweiz", "ZSCHWEIZ", "CH-2", "CHF", 10m, new DateTime(2025, 1, 10),
+ material: "MAT-UNASS",
+ productHierarchyCode: "0509",
+ productHierarchyText: "Multistat",
+ productDivisionCode: "UNASS",
+ productDivisionText: "Nicht zugeordnet",
+ productMappingAssigned: "false"),
+ CreateRow("MANUAL_EXCEL", "Deutschland", "TRDE", "DE-1", "EUR", 80m, new DateTime(2025, 1, 11),
+ material: "MAT-OK",
+ name: "German article"),
+ CreateRow("MANUAL_EXCEL", "Italien", "TRIT", "IT-1", "EUR", 50m, new DateTime(2025, 1, 12),
+ material: "MAT-MISSING",
+ name: "Unknown article"),
+ CreateRow("MANUAL_EXCEL", "Deutschland", "TRDE", "DE-2", "EUR", 20m, new DateTime(2025, 1, 13),
+ material: "MAT-UNASS",
+ name: "Unassigned article"));
+
+ var result = await _service.AnalyzeFinanceSummaryAsync(2025, null, null);
+
+ Assert.Equal(5, result.ProductAssignmentSummary.DistinctMaterialCount);
+ Assert.Equal(2, result.ProductAssignmentSummary.MatchedMaterialCount);
+ Assert.Equal(2, result.ProductAssignmentSummary.UnassignedMaterialCount);
+ Assert.Equal(1, result.ProductAssignmentSummary.MissingReferenceMaterialCount);
+
+ var assigned = Assert.Single(result.ProductAssignmentRows, row => row.Material == "MAT-OK" && row.Tsc == "TRDE");
+ Assert.Equal("Zugeordnet", assigned.Status);
+ Assert.Equal("0414", assigned.ProductHierarchyCode);
+ Assert.Equal("0001", assigned.ProductDivisionCode);
+
+ var missing = Assert.Single(result.ProductAssignmentRows, row => row.Material == "MAT-MISSING" && row.Tsc == "TRIT");
+ Assert.Equal("Nicht im TR-AG-Stamm", missing.Status);
+
+ var unassigned = Assert.Single(result.ProductAssignmentRows, row => row.Material == "MAT-UNASS" && row.Tsc == "TRDE");
+ Assert.Equal("Nicht zugeordnet", unassigned.Status);
+ Assert.Equal("UNASS", unassigned.ProductDivisionCode);
+
+ Assert.Contains(result.ProductAssignmentCountryRows, row =>
+ row.CountryKey == "DE" &&
+ row.Tsc == "TRDE" &&
+ row.MatchedMaterialCount == 1 &&
+ row.UnassignedMaterialCount == 1);
+ }
+
private async Task SeedCentralRowsAsync(params CentralSalesRecord[] rows)
{
await using var db = await _dbFactory.CreateDbContextAsync();
@@ -351,7 +408,16 @@ public class ManagementCockpitServiceTests : IDisposable
DateTime? invoiceDate,
DateTime? extractionDate = null,
decimal quantity = 1m,
- decimal standardCost = 1m)
+ decimal standardCost = 1m,
+ string material = "MAT",
+ string name = "Article",
+ string productHierarchyCode = "",
+ string productHierarchyText = "",
+ string productFamilyCode = "",
+ string productFamilyText = "",
+ string productDivisionCode = "",
+ string productDivisionText = "",
+ string productMappingAssigned = "")
{
return new CentralSalesRecord
{
@@ -362,9 +428,16 @@ public class ManagementCockpitServiceTests : IDisposable
Tsc = tsc,
InvoiceNumber = invoiceNumber,
PositionOnInvoice = 1,
- Material = "MAT",
- Name = "Article",
+ Material = material,
+ Name = name,
ProductGroup = "PG",
+ ProductHierarchyCode = productHierarchyCode,
+ ProductHierarchyText = productHierarchyText,
+ ProductFamilyCode = productFamilyCode,
+ ProductFamilyText = productFamilyText,
+ ProductDivisionCode = productDivisionCode,
+ ProductDivisionText = productDivisionText,
+ ProductMappingAssigned = productMappingAssigned,
Quantity = quantity,
SupplierNumber = "SUP",
SupplierName = "Supplier",
diff --git a/TrafagSalesExporter/docs/PRODUCT_SPARTEN_MAPPING_2026-05-27.md b/TrafagSalesExporter/docs/PRODUCT_SPARTEN_MAPPING_2026-05-27.md
index 43f56c9..9efa5b1 100644
--- a/TrafagSalesExporter/docs/PRODUCT_SPARTEN_MAPPING_2026-05-27.md
+++ b/TrafagSalesExporter/docs/PRODUCT_SPARTEN_MAPPING_2026-05-27.md
@@ -275,3 +275,87 @@ Naechster fachlicher/technischer Schritt:
- Stimmen Join-Treffer fuer bekannte Materialien?
- Wie viele Zeilen bleiben `UNASS` / `Nicht zugeordnet`?
- SAP-seitig muss `FINANZDATASCHWEI_GET_ENTITYSET` auf den alten `ZSCHWEIZ`-Select-Code zurueckgesetzt sein, falls er versehentlich mit Produktsparten-Code ueberschrieben wurde.
+
+## Nachtrag 2026-05-29 Zentrale Spartenzuordnung
+
+Fachliches Ziel aus Finance-Input:
+
+- Die Produktsparten-/Produktfamilienzuordnung der anderen Laender-ERPs ist nicht fuehrend.
+- Fuehrend ist die Trafag-AG-/SAP-Referenz aus dem eigenen SAP-System.
+- Jede Umsatzzeile aus `CentralSalesRecords` wird ueber ihre Materialnummer gegen die TR-AG-Referenz geprueft.
+- Wenn die Materialnummer im TR-AG-Stamm vorhanden ist, wird die dortige Produktzuordnung angezeigt.
+- Wenn die Materialnummer nicht im TR-AG-Stamm vorhanden ist, gilt der Status `Nicht im TR-AG-Stamm`.
+- Wenn die Materialnummer im TR-AG-Stamm vorhanden ist, aber dort `UNASS`/nicht zugeordnet ist, gilt der Status `Nicht zugeordnet`.
+
+Umsetzung im Web:
+
+- Neuer Reiter in `Management Analyse`:
+ - `Zentrale Spartenzuordnung`
+- Der Reiter arbeitet auf dem bestehenden Finance-Filter:
+ - Jahr
+ - Land
+ - Waehrung
+- Die Referenz wird aus zentral gespeicherten Zeilen mit Produktfeldern gebildet.
+- Der Abgleich erfolgt ueber normalisierte Materialnummer:
+ - Land-ERP-Material links
+ - TR-AG-Referenz-Material plus Produktzuordnung rechts
+- Angezeigte Statuswerte:
+ - `Zugeordnet`
+ - `Nicht zugeordnet`
+ - `Nicht im TR-AG-Stamm`
+ - `Material fehlt`
+
+UI-Inhalte:
+
+- Kennzahlen:
+ - Materialien
+ - Zugeordnet
+ - Nicht zugeordnet
+ - Nicht im Stamm
+ - Material fehlt
+ - TR-AG Referenz
+- Laenderuebersicht:
+ - Land
+ - TSC
+ - Materialanzahl
+ - Zugeordnet
+ - Nicht zugeordnet
+ - Nicht im Stamm
+ - Material fehlt
+ - Trefferquote
+- Detailtabelle:
+ - Status
+ - Land
+ - TSC
+ - Land-Material
+ - Land-Text
+ - TR-AG-MATNR
+ - PAPH1
+ - Produktfamilie
+ - Produktsparte
+ - Zeilen
+ - Finance-Wert
+
+Technische Dateien:
+
+- `Models/ManagementCockpitModels.cs`
+ - neue Modelle fuer Produktzuordnungs-Summary, Laenderzeilen und Detailzeilen.
+- `Services/ManagementCockpitService.cs`
+ - baut die TR-AG-Referenz aus Produktfeldern.
+ - prueft gefilterte Finance-Zeilen ueber `Material`.
+ - erzeugt Summary, Laenderabdeckung und Detailzeilen.
+- `Components/Pages/ManagementCockpit.razor`
+ - neuer Reiter `Zentrale Spartenzuordnung`.
+- `TrafagSalesExporter.Tests/ManagementCockpitServiceTests.cs`
+ - Test fuer Treffer, fehlende Referenz und `UNASS`.
+
+Validierung:
+
+- `dotnet test TrafagSalesExporter.sln --verbosity minimal --artifacts-path C:\TMP\trafag-test-artifacts-central-product-assignment`
+- Ergebnis: `80/80` Tests gruen.
+
+Wichtig:
+
+- Die Sicht ist zunaechst eine Pruef-/Analyseansicht.
+- Sie veraendert noch keine bestehenden Umsatzzeilen der anderen Laender.
+- Persistente Anreicherung aller `CentralSalesRecords` kann spaeter folgen, wenn die Treffer-/Fehlerquote fachlich akzeptiert ist.
diff --git a/TrafagSalesExporter/docs/rag/PRODUCT_MAPPING.md b/TrafagSalesExporter/docs/rag/PRODUCT_MAPPING.md
index 33ef963..8f4c1f1 100644
--- a/TrafagSalesExporter/docs/rag/PRODUCT_MAPPING.md
+++ b/TrafagSalesExporter/docs/rag/PRODUCT_MAPPING.md
@@ -60,6 +60,21 @@ Stand: 2026-05-29
- Lokale App wurde neu gestartet; `http://localhost:55416/` antwortet mit HTTP 200.
- Validierung: `79/79` Tests gruen mit separatem Artefaktpfad.
+## Zentrale Spartenzuordnung
+
+- Neuer Reiter in `Management Analyse`: `Zentrale Spartenzuordnung`.
+- Zweck: Materialnummern aller Laender gegen die fuehrende TR-AG-/SAP-Referenz pruefen.
+- Lokale ERP-Produktzuordnungen anderer Laender sind nicht fuehrend.
+- Statuslogik:
+ - Treffer mit zugeordneter TR-AG-Sparte: `Zugeordnet`.
+ - Treffer mit `UNASS`/nicht zugeordnet: `Nicht zugeordnet`.
+ - Kein Treffer im TR-AG-Stamm: `Nicht im TR-AG-Stamm`.
+ - Leere Materialnummer: `Material fehlt`.
+- Die Sicht nutzt den bestehenden Finance-Filter fuer Jahr/Land/Waehrung.
+- Sie zeigt Kennzahlen, Laenderabdeckung und Detailzeilen mit Land-Material links und TR-AG-Referenz rechts.
+- Umsetzung ist eine Analyseansicht, keine persistente Mutation anderer Laenderzeilen.
+- Validierung nach Umsetzung: `80/80` Tests gruen.
+
## Offene Punkte Fuer Sitzung
- Normalisierung der Materialnummern.
@@ -71,6 +86,7 @@ Stand: 2026-05-29
- Richtige Texttabellen fuer `WWPFA`/`WWPSP` bestaetigen.
- VKORG/VTWEG fuer TR-AG-Referenzlauf bestaetigen.
- Standort `ZSCHWEIZ` im Export Dashboard neu laufen lassen und Fuellung der neuen Produktfelder pruefen.
+- Treffer-/Fehlerquote im Reiter `Zentrale Spartenzuordnung` pruefen.
## Rohquelle Nur Bei Bedarf
diff --git a/TrafagSalesExporter/lastchange.md b/TrafagSalesExporter/lastchange.md
index 7754ebd..950ea99 100644
--- a/TrafagSalesExporter/lastchange.md
+++ b/TrafagSalesExporter/lastchange.md
@@ -61,6 +61,35 @@ Offen:
- Danach Fuellung der neuen Produktfelder und Quote `UNASS` pruefen.
- Fachliche Mapping-Luecken wie `0509`/`0540` spaeter mit Andreas/Kendra klaeren.
+## Nachtrag 2026-05-29 Zentrale Spartenzuordnung
+
+Umgesetzt:
+
+- Neuer Reiter in `Management Analyse`: `Zentrale Spartenzuordnung`.
+- Fachlogik:
+ - Andere Laender-ERPs sind fuer Produktsparten nicht fuehrend.
+ - Fuehrend ist die TR-AG-/SAP-Referenz aus `ProductDivisionRefSet`.
+ - Umsatzzeilen aus `CentralSalesRecords` werden ueber `Material` gegen die TR-AG-Referenz geprueft.
+- Statuswerte:
+ - `Zugeordnet`
+ - `Nicht zugeordnet`
+ - `Nicht im TR-AG-Stamm`
+ - `Material fehlt`
+- Der Reiter zeigt:
+ - Summary-Kennzahlen
+ - Abdeckung nach Land/TSC
+ - Detailtabelle mit Land-Material links und TR-AG-MATNR/PAPH1/Familie/Sparte rechts.
+- Die Sicht verwendet die bestehenden Finance-Filter fuer Jahr, Land und Waehrung.
+- Noch keine persistente Mutation anderer Laenderzeilen; es ist bewusst eine Pruefansicht.
+
+Technisch:
+
+- Neue Modelle in `ManagementCockpitModels`.
+- Produktzuordnungsanalyse in `ManagementCockpitService`.
+- Neuer Reiter in `Components/Pages/ManagementCockpit.razor`.
+- Test ergaenzt: `AnalyzeFinanceSummaryAsync_Builds_Central_Product_Assignment_Tab_Data`.
+- Validierung: `dotnet test TrafagSalesExporter.sln --verbosity minimal --artifacts-path C:\TMP\trafag-test-artifacts-central-product-assignment` mit `80/80` Tests gruen.
+
## Nachtrag 2026-05-28 ABAP Produktsparten-Mapping
Erstellt: