Add purchasing period analytics
This commit is contained in:
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user