Add purchasing period analytics

This commit is contained in:
2026-06-05 13:54:36 +02:00
parent 43250a4abc
commit aa6d0d0804
4 changed files with 598 additions and 44 deletions
@@ -304,13 +304,20 @@ CREATE TABLE IF NOT EXISTS FieldTransformationRules (
cmd.ExecuteNonQuery();
}
using var ekpoIndex = conn.CreateCommand();
ekpoIndex.CommandText = "CREATE INDEX IF NOT EXISTS IX_PurchasingEkpoCache_Matkl ON PurchasingEkpoCache (Matkl);";
ekpoIndex.ExecuteNonQuery();
using var eketDateIndex = conn.CreateCommand();
eketDateIndex.CommandText = "CREATE INDEX IF NOT EXISTS IX_PurchasingEketCache_Eindt ON PurchasingEketCache (Eindt);";
eketDateIndex.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)
@@ -2,7 +2,12 @@ namespace TrafagSalesExporter.Services;
public interface IPurchasingDashboardService
{
Task<PurchasingDashboardLiveState> LoadAsync(CancellationToken cancellationToken = default);
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
@@ -19,6 +24,8 @@ public sealed class PurchasingDashboardLiveState
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; }
@@ -29,7 +36,16 @@ public sealed class PurchasingDashboardLiveState
public List<PurchasingLiveChartPoint> SpendChartRows { get; set; } = [];
public List<PurchasingLiveChartPoint> OpenValueChartRows { get; set; } = [];
public List<PurchasingLiveChartPoint> ContractChartRows { 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<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);
@@ -17,14 +17,23 @@ public sealed class PurchasingDashboardService : IPurchasingDashboardService
_dbFactory = dbFactory;
}
public async Task<PurchasingDashboardLiveState> LoadAsync(CancellationToken cancellationToken = default)
private static PurchasingDashboardFilter BuildDefaultFilter()
{
var today = DateTime.Today;
return new PurchasingDashboardFilter(new DateTime(today.Year - 2, 1, 1), today);
}
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, cancellationToken))
if (await TryLoadCacheStateAsync(db, state, filter, cancellationToken))
return state;
var sap = await db.SourceSystemDefinitions.AsNoTracking().FirstOrDefaultAsync(x => x.Code == "SAP", cancellationToken);
@@ -108,16 +117,22 @@ public sealed class PurchasingDashboardService : IPurchasingDashboardService
return state;
}
private static async Task<bool> TryLoadCacheStateAsync(AppDbContext db, PurchasingDashboardLiveState state, CancellationToken cancellationToken)
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 ekkoRows = await ExecuteScalarIntAsync(conn, "SELECT COUNT(1) FROM PurchasingEkkoCache;", cancellationToken);
var ekpoRows = await ExecuteScalarIntAsync(conn, "SELECT COUNT(1) FROM PurchasingEkpoCache;", cancellationToken);
var eketRows = await ExecuteScalarIntAsync(conn, "SELECT COUNT(1) FROM PurchasingEketCache;", cancellationToken);
if (ekkoRows <= 0 || ekpoRows <= 0 || eketRows <= 0)
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);
@@ -126,47 +141,57 @@ public sealed class PurchasingDashboardService : IPurchasingDashboardService
state.EkkoLoaded = true;
state.EkpoLoaded = true;
state.EketLoaded = true;
state.PurchaseOrderCount = ekkoRows;
state.PositionSampleCount = ekpoRows;
state.ScheduleSampleCount = eketRows;
state.SupplierCount = await ExecuteScalarIntAsync(conn, "SELECT COUNT(DISTINCT Lifnr) FROM PurchasingEkkoCache WHERE Lifnr <> '';", cancellationToken);
state.LatestOrderDate = await ExecuteScalarDateAsync(conn, "SELECT MAX(Bedat) FROM PurchasingEkkoCache;", cancellationToken);
state.SpendChfSample = await ExecuteScalarDecimalAsync(conn, "SELECT COALESCE(SUM(CAST(Netwr AS REAL)), 0) FROM PurchasingEkpoCache WHERE Loekz = '';", 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;", cancellationToken);
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, '') = '';", cancellationToken);
WHERE COALESCE(p.Loekz, '') = '' AND " + eketPeriod + ";", cancellationToken);
state.ContractValueSample = state.OpenValueSample;
state.TopSupplierLabel = await ExecuteTopLabelAsync(conn, @"
SELECT COALESCE(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 p.Loekz = ''
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
WHERE Loekz = ''
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(Matnr, ''), NULLIF(Txz01, ''), 'ohne Artikel') AS Label, SUM(CAST(Netwr AS REAL)) AS Value
FROM PurchasingEkpoCache
WHERE Loekz = ''
FROM PurchasingEkpoCache p
LEFT JOIN PurchasingEkkoCache k ON k.Ebeln = p.Ebeln
WHERE COALESCE(p.Loekz, '') = '' AND " + joinedEkkoPeriod + @"
GROUP BY COALESCE(NULLIF(Matnr, ''), NULLIF(Txz01, ''), 'ohne Artikel')
ORDER BY Value DESC
LIMIT 1;", "Artikel", cancellationToken);
state.SpendChartRows = await ExecuteChartRowsAsync(conn, @"
SELECT 'Lief. ' || COALESCE(NULLIF(k.Lifnr, ''), 'ohne Lieferant') AS Label, SUM(CAST(p.Netwr AS REAL)) AS Value
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 p.Loekz = ''
WHERE COALESCE(p.Loekz, '') = '' AND " + joinedEkkoPeriod + @"
GROUP BY COALESCE(NULLIF(k.Lifnr, ''), 'ohne Lieferant')
ORDER BY Value DESC
LIMIT 6;", cancellationToken);
@@ -176,17 +201,164 @@ SELECT COALESCE(substr(e.Eindt, 1, 7), 'ohne Termin') AS Label,
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, '') = ''
WHERE COALESCE(p.Loekz, '') = '' AND " + eketPeriod + @"
GROUP BY COALESCE(substr(e.Eindt, 1, 7), 'ohne Termin')
ORDER BY Label
LIMIT 6;", cancellationToken);
state.ContractChartRows = state.OpenValueChartRows.ToList();
await ApplyIdeaAnalyticsAsync(conn, state, joinedEkkoPeriod, eketPeriod, cancellationToken);
state.CacheStatus = latestStatus.Status;
state.CacheCompletedAtUtc = latestStatus.CompletedAtUtc;
state.Message = $"Einkauf Cache geladen: EKKO={ekkoRows:N0}, EKPO={ekpoRows:N0}, EKET={eketRows:N0}. {latestStatus.Message}";
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,
SUM(CAST(p.Netwr AS REAL)) AS Value,
SUM(CAST(p.Menge AS REAL)) AS Quantity
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
),
current_year AS (
SELECT Supplier, Article, Value, Quantity, Value / Quantity AS UnitPrice
FROM priced
WHERE Year = '2026'
),
previous_year AS (
SELECT Supplier, Article, Value / Quantity AS UnitPrice
FROM priced
WHERE Year = '2025' AND Quantity > 0
)
SELECT
c.Supplier || ' / ' || c.Article AS Label,
printf('%.1f%%', ((c.UnitPrice - p.UnitPrice) / p.UnitPrice) * 100.0) AS Value,
'Wirkung CHF ' || printf('%,.0f', (c.UnitPrice - p.UnitPrice) * c.Quantity) AS Detail,
CASE WHEN ((c.UnitPrice - p.UnitPrice) / p.UnitPrice) >= 0.10 THEN 'High'
WHEN ((c.UnitPrice - p.UnitPrice) / p.UnitPrice) >= 0.03 THEN 'Medium'
ELSE 'Low' END AS Severity
FROM current_year c
JOIN previous_year p ON p.Supplier = c.Supplier AND p.Article = c.Article
WHERE p.UnitPrice > 0 AND c.UnitPrice > p.UnitPrice
ORDER BY (c.UnitPrice - p.UnitPrice) * c.Quantity DESC
LIMIT 10;", cancellationToken);
state.PriceVarianceChartRows = await ExecuteChartRowsAsync(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,
SUM(CAST(p.Netwr AS REAL)) AS Value,
SUM(CAST(p.Menge AS REAL)) AS Quantity
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
),
delta AS (
SELECT c.Supplier, (c.Value / c.Quantity - p.Value / p.Quantity) * c.Quantity AS Impact
FROM priced c
JOIN priced p ON p.Supplier = c.Supplier AND p.Article = c.Article
WHERE c.Year = '2026' AND p.Year = '2025' AND p.Quantity > 0 AND c.Quantity > 0 AND c.Value / c.Quantity > p.Value / p.Quantity
)
SELECT Supplier, SUM(Impact) AS Value
FROM delta
GROUP BY Supplier
ORDER BY Value DESC
LIMIT 6;", cancellationToken);
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,
@@ -378,6 +550,32 @@ LIMIT 6;", cancellationToken);
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();