Add audit CSV central source option
This commit is contained in:
@@ -13,7 +13,7 @@
|
|||||||
<MudStack Row AlignItems="AlignItems.Center" Class="mb-3">
|
<MudStack Row AlignItems="AlignItems.Center" Class="mb-3">
|
||||||
<div>
|
<div>
|
||||||
<MudText Typo="Typo.h6">@T("Net Sales Actuals 2025 Referenz", "Net sales actuals 2025 reference")</MudText>
|
<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>
|
</div>
|
||||||
<MudSpacer />
|
<MudSpacer />
|
||||||
<MudButton Variant="@(_hideRowsWithoutActual ? Variant.Filled : Variant.Outlined)"
|
<MudButton Variant="@(_hideRowsWithoutActual ? Variant.Filled : Variant.Outlined)"
|
||||||
|
|||||||
@@ -815,7 +815,7 @@
|
|||||||
<MudPaper Class="pa-4 mb-4" Elevation="1">
|
<MudPaper Class="pa-4 mb-4" Elevation="1">
|
||||||
<MudText Typo="Typo.h6" Class="mb-3">@T("Zentrale Roh-Auswertung", "Central raw analysis")</MudText>
|
<MudText Typo="Typo.h6" Class="mb-3">@T("Zentrale Roh-Auswertung", "Central raw analysis")</MudText>
|
||||||
<MudAlert Severity="Severity.Info" Dense Variant="Variant.Outlined" Class="mb-3">
|
<MudAlert Severity="Severity.Info" Dense Variant="Variant.Outlined" Class="mb-3">
|
||||||
@T("Diese Sicht arbeitet direkt auf `CentralSalesRecords`. Summenfeld und Anzeige-Waehrung koennen gewaehlt werden; fachliche Filter wie Intercompany, Budget und Spartenlogik sind weiterhin nicht enthalten.", "This view works directly on `CentralSalesRecords`. Value field and display currency can be selected; business filters such as intercompany, budget and divisional logic are still not included.")
|
@T("Diese Sicht arbeitet auf der aktuell konfigurierten zentralen Datenquelle (DB oder Audit-CSV). Summenfeld und Anzeige-Waehrung koennen gewaehlt werden; fachliche Filter wie Intercompany, Budget und Spartenlogik sind weiterhin nicht enthalten.", "This view works on the currently configured central data source (DB or audit CSV). Value field and display currency can be selected; business filters such as intercompany, budget and divisional logic are still not included.")
|
||||||
</MudAlert>
|
</MudAlert>
|
||||||
<MudAlert Severity="Severity.Warning" Dense Variant="Variant.Outlined" Class="mb-3">
|
<MudAlert Severity="Severity.Warning" Dense Variant="Variant.Outlined" Class="mb-3">
|
||||||
@T("Diese Analyse ist eine Plausibilitaets- und Rohdatensicht. Fuer den verbindlichen Finance-Abgleich bitte `Soll/Ist Vergleich` oder im Endexcel die `Finance | ...`-Spalten verwenden.",
|
@T("Diese Analyse ist eine Plausibilitaets- und Rohdatensicht. Fuer den verbindlichen Finance-Abgleich bitte `Soll/Ist Vergleich` oder im Endexcel die `Finance | ...`-Spalten verwenden.",
|
||||||
|
|||||||
@@ -284,6 +284,18 @@
|
|||||||
Schreibt zusätzliche technische Fortschrittsmeldungen für HANA- und SAP-Lesevorgänge ins Dashboard und in die Logs.
|
Schreibt zusätzliche technische Fortschrittsmeldungen für HANA- und SAP-Lesevorgänge ins Dashboard und in die Logs.
|
||||||
</MudText>
|
</MudText>
|
||||||
</MudItem>
|
</MudItem>
|
||||||
|
<MudItem xs="12" md="4">
|
||||||
|
<MudSwitch @bind-Value="_exportSettings.AuditCsvEnabled" Label="Audit-CSV je Standort schreiben" Color="Color.Primary" />
|
||||||
|
<MudText Typo="Typo.caption">
|
||||||
|
Schreibt nach Mapping und Transformation eine lesbare CSV-Datei je Standort.
|
||||||
|
</MudText>
|
||||||
|
</MudItem>
|
||||||
|
<MudItem xs="12" md="4">
|
||||||
|
<MudSwitch @bind-Value="_exportSettings.UseAuditCsvAsCentralSource" Label="Zentrale Auswertung aus Audit-CSV" Color="Color.Warning" />
|
||||||
|
<MudText Typo="Typo.caption">
|
||||||
|
Zentrale Excel, Finance Summary und Management-Analyse lesen die neuesten Standort-CSV-Dateien statt CentralSalesRecords.
|
||||||
|
</MudText>
|
||||||
|
</MudItem>
|
||||||
<MudItem xs="12" md="6">
|
<MudItem xs="12" md="6">
|
||||||
<MudTextField @bind-Value="_exportSettings.LocalSiteExportFolder" Label="Lokaler Standardpfad Standort-Dateien"
|
<MudTextField @bind-Value="_exportSettings.LocalSiteExportFolder" Label="Lokaler Standardpfad Standort-Dateien"
|
||||||
HelperText="Wenn leer, wird ./output unter dem Programmverzeichnis verwendet." />
|
HelperText="Wenn leer, wird ./output unter dem Programmverzeichnis verwendet." />
|
||||||
@@ -292,6 +304,10 @@
|
|||||||
<MudTextField @bind-Value="_exportSettings.LocalConsolidatedExportFolder" Label="Lokaler Pfad Zentrale Datei"
|
<MudTextField @bind-Value="_exportSettings.LocalConsolidatedExportFolder" Label="Lokaler Pfad Zentrale Datei"
|
||||||
HelperText="Optional. Wenn leer, wird der Standardpfad der Standort-Dateien verwendet." />
|
HelperText="Optional. Wenn leer, wird der Standardpfad der Standort-Dateien verwendet." />
|
||||||
</MudItem>
|
</MudItem>
|
||||||
|
<MudItem xs="12" md="6">
|
||||||
|
<MudTextField @bind-Value="_exportSettings.LocalAuditCsvFolder" Label="Lokaler Pfad Audit-CSV"
|
||||||
|
HelperText="Optional. Wenn leer, wird der Standardpfad der Standort-Dateien verwendet." />
|
||||||
|
</MudItem>
|
||||||
<MudItem xs="12">
|
<MudItem xs="12">
|
||||||
<MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="SaveExportSettings"
|
<MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="SaveExportSettings"
|
||||||
StartIcon="@Icons.Material.Filled.Save">
|
StartIcon="@Icons.Material.Filled.Save">
|
||||||
|
|||||||
@@ -51,6 +51,9 @@ public class ConfigTransferExportSettings
|
|||||||
public bool DebugLoggingEnabled { get; set; }
|
public bool DebugLoggingEnabled { get; set; }
|
||||||
public string LocalSiteExportFolder { get; set; } = string.Empty;
|
public string LocalSiteExportFolder { get; set; } = string.Empty;
|
||||||
public string LocalConsolidatedExportFolder { 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 string ExchangeRateDateField { get; set; } = ExchangeRateDateFields.PostingDate;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,9 @@ public class ExportSettings
|
|||||||
public bool DebugLoggingEnabled { get; set; }
|
public bool DebugLoggingEnabled { get; set; }
|
||||||
public string LocalSiteExportFolder { get; set; } = string.Empty;
|
public string LocalSiteExportFolder { get; set; } = string.Empty;
|
||||||
public string LocalConsolidatedExportFolder { 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 string ExchangeRateDateField { get; set; } = ExchangeRateDateFields.PostingDate;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -80,9 +80,11 @@ builder.Services.AddSingleton<IAppEventLogService, AppEventLogService>();
|
|||||||
builder.Services.AddSingleton<IManagementCockpitService, ManagementCockpitService>();
|
builder.Services.AddSingleton<IManagementCockpitService, ManagementCockpitService>();
|
||||||
builder.Services.AddSingleton<IHrKpiService, HrKpiService>();
|
builder.Services.AddSingleton<IHrKpiService, HrKpiService>();
|
||||||
builder.Services.AddSingleton<IManualExcelImportService, ManualExcelImportService>();
|
builder.Services.AddSingleton<IManualExcelImportService, ManualExcelImportService>();
|
||||||
|
builder.Services.AddSingleton<IExportAuditCsvService, ExportAuditCsvService>();
|
||||||
builder.Services.AddSingleton<IConsolidatedExportService, ConsolidatedExportService>();
|
builder.Services.AddSingleton<IConsolidatedExportService, ConsolidatedExportService>();
|
||||||
builder.Services.AddSingleton<IExportLogService, ExportLogService>();
|
builder.Services.AddSingleton<IExportLogService, ExportLogService>();
|
||||||
builder.Services.AddSingleton<ICentralSalesRecordService, CentralSalesRecordService>();
|
builder.Services.AddSingleton<ICentralSalesRecordService, CentralSalesRecordService>();
|
||||||
|
builder.Services.AddSingleton<ICentralSalesDataProvider, CentralSalesDataProvider>();
|
||||||
builder.Services.AddSingleton<IConfigTransferService, ConfigTransferService>();
|
builder.Services.AddSingleton<IConfigTransferService, ConfigTransferService>();
|
||||||
builder.Services.AddSingleton<IFinanceReconciliationService, FinanceReconciliationService>();
|
builder.Services.AddSingleton<IFinanceReconciliationService, FinanceReconciliationService>();
|
||||||
builder.Services.AddSingleton<IDatabaseSchemaMaintenanceService, DatabaseSchemaMaintenanceService>();
|
builder.Services.AddSingleton<IDatabaseSchemaMaintenanceService, DatabaseSchemaMaintenanceService>();
|
||||||
|
|||||||
@@ -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_*.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)
|
.ThenBy(r => r.Tsc)
|
||||||
.Select(r => new SalesRecord
|
.Select(r => new SalesRecord
|
||||||
{
|
{
|
||||||
|
SourceSystem = r.SourceSystem,
|
||||||
ExtractionDate = r.ExtractionDate,
|
ExtractionDate = r.ExtractionDate,
|
||||||
Tsc = r.Tsc,
|
Tsc = r.Tsc,
|
||||||
DocumentEntry = r.DocumentEntry,
|
DocumentEntry = r.DocumentEntry,
|
||||||
|
|||||||
@@ -71,6 +71,9 @@ public class ConfigTransferService : IConfigTransferService
|
|||||||
DebugLoggingEnabled = exportSettings.DebugLoggingEnabled,
|
DebugLoggingEnabled = exportSettings.DebugLoggingEnabled,
|
||||||
LocalSiteExportFolder = exportSettings.LocalSiteExportFolder,
|
LocalSiteExportFolder = exportSettings.LocalSiteExportFolder,
|
||||||
LocalConsolidatedExportFolder = exportSettings.LocalConsolidatedExportFolder,
|
LocalConsolidatedExportFolder = exportSettings.LocalConsolidatedExportFolder,
|
||||||
|
AuditCsvEnabled = exportSettings.AuditCsvEnabled,
|
||||||
|
UseAuditCsvAsCentralSource = exportSettings.UseAuditCsvAsCentralSource,
|
||||||
|
LocalAuditCsvFolder = exportSettings.LocalAuditCsvFolder,
|
||||||
ExchangeRateDateField = SettingsPageService.NormalizeExchangeRateDateField(exportSettings.ExchangeRateDateField)
|
ExchangeRateDateField = SettingsPageService.NormalizeExchangeRateDateField(exportSettings.ExchangeRateDateField)
|
||||||
},
|
},
|
||||||
SourceSystemDefinitions = sourceSystems.Select(system => new ConfigTransferSourceSystemDefinition
|
SourceSystemDefinitions = sourceSystems.Select(system => new ConfigTransferSourceSystemDefinition
|
||||||
@@ -285,6 +288,9 @@ public class ConfigTransferService : IConfigTransferService
|
|||||||
DebugLoggingEnabled = importedSettings.DebugLoggingEnabled,
|
DebugLoggingEnabled = importedSettings.DebugLoggingEnabled,
|
||||||
LocalSiteExportFolder = importedSettings.LocalSiteExportFolder,
|
LocalSiteExportFolder = importedSettings.LocalSiteExportFolder,
|
||||||
LocalConsolidatedExportFolder = importedSettings.LocalConsolidatedExportFolder,
|
LocalConsolidatedExportFolder = importedSettings.LocalConsolidatedExportFolder,
|
||||||
|
AuditCsvEnabled = importedSettings.AuditCsvEnabled,
|
||||||
|
UseAuditCsvAsCentralSource = importedSettings.UseAuditCsvAsCentralSource,
|
||||||
|
LocalAuditCsvFolder = importedSettings.LocalAuditCsvFolder,
|
||||||
ExchangeRateDateField = SettingsPageService.NormalizeExchangeRateDateField(importedSettings.ExchangeRateDateField)
|
ExchangeRateDateField = SettingsPageService.NormalizeExchangeRateDateField(importedSettings.ExchangeRateDateField)
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -7,25 +7,25 @@ namespace TrafagSalesExporter.Services;
|
|||||||
public class ConsolidatedExportService : IConsolidatedExportService
|
public class ConsolidatedExportService : IConsolidatedExportService
|
||||||
{
|
{
|
||||||
private readonly IDbContextFactory<AppDbContext> _dbFactory;
|
private readonly IDbContextFactory<AppDbContext> _dbFactory;
|
||||||
private readonly ICentralSalesRecordService _centralSalesRecordService;
|
private readonly ICentralSalesDataProvider _centralSalesDataProvider;
|
||||||
private readonly IExcelExportService _excelService;
|
private readonly IExcelExportService _excelService;
|
||||||
private readonly ISharePointUploadService _sharePointService;
|
private readonly ISharePointUploadService _sharePointService;
|
||||||
|
|
||||||
public ConsolidatedExportService(
|
public ConsolidatedExportService(
|
||||||
IDbContextFactory<AppDbContext> dbFactory,
|
IDbContextFactory<AppDbContext> dbFactory,
|
||||||
ICentralSalesRecordService centralSalesRecordService,
|
ICentralSalesDataProvider centralSalesDataProvider,
|
||||||
IExcelExportService excelService,
|
IExcelExportService excelService,
|
||||||
ISharePointUploadService sharePointService)
|
ISharePointUploadService sharePointService)
|
||||||
{
|
{
|
||||||
_dbFactory = dbFactory;
|
_dbFactory = dbFactory;
|
||||||
_centralSalesRecordService = centralSalesRecordService;
|
_centralSalesDataProvider = centralSalesDataProvider;
|
||||||
_excelService = excelService;
|
_excelService = excelService;
|
||||||
_sharePointService = sharePointService;
|
_sharePointService = sharePointService;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<string?> ExportAsync()
|
public async Task<string?> ExportAsync()
|
||||||
{
|
{
|
||||||
var consolidatedRecords = await _centralSalesRecordService.GetAllAsync();
|
var consolidatedRecords = await _centralSalesDataProvider.GetRecordsAsync();
|
||||||
if (consolidatedRecords.Count == 0)
|
if (consolidatedRecords.Count == 0)
|
||||||
return null;
|
return null;
|
||||||
|
|
||||||
|
|||||||
@@ -28,6 +28,9 @@ CREATE TABLE ExportSettings (
|
|||||||
DebugLoggingEnabled INTEGER NOT NULL DEFAULT 0,
|
DebugLoggingEnabled INTEGER NOT NULL DEFAULT 0,
|
||||||
LocalSiteExportFolder TEXT NOT NULL DEFAULT '',
|
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'
|
ExchangeRateDateField TEXT NOT NULL DEFAULT 'PostingDate'
|
||||||
);";
|
);";
|
||||||
|
|
||||||
|
|||||||
@@ -29,6 +29,9 @@ public class DatabaseSchemaMaintenanceService : IDatabaseSchemaMaintenanceServic
|
|||||||
AddColumnIfMissing(db, "ExportSettings", "DebugLoggingEnabled", "INTEGER NOT NULL DEFAULT 0");
|
AddColumnIfMissing(db, "ExportSettings", "DebugLoggingEnabled", "INTEGER NOT NULL DEFAULT 0");
|
||||||
AddColumnIfMissing(db, "ExportSettings", "LocalSiteExportFolder", "TEXT NOT NULL DEFAULT ''");
|
AddColumnIfMissing(db, "ExportSettings", "LocalSiteExportFolder", "TEXT NOT NULL DEFAULT ''");
|
||||||
AddColumnIfMissing(db, "ExportSettings", "LocalConsolidatedExportFolder", "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, "ExportSettings", "ExchangeRateDateField", "TEXT NOT NULL DEFAULT 'PostingDate'");
|
||||||
AddColumnIfMissing(db, "SharePointConfigs", "CentralExportFolder", "TEXT NOT NULL DEFAULT ''");
|
AddColumnIfMissing(db, "SharePointConfigs", "CentralExportFolder", "TEXT NOT NULL DEFAULT ''");
|
||||||
AddColumnIfMissing(db, "ExportLogs", "FilePath", "TEXT NOT NULL DEFAULT ''");
|
AddColumnIfMissing(db, "ExportLogs", "FilePath", "TEXT NOT NULL DEFAULT ''");
|
||||||
|
|||||||
@@ -0,0 +1,376 @@
|
|||||||
|
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 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 = $"Sales_{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 = Directory.EnumerateFiles(directory, "Sales_*.csv", SearchOption.TopDirectoryOnly)
|
||||||
|
.GroupBy(ResolveTscFromFileName, StringComparer.OrdinalIgnoreCase)
|
||||||
|
.Select(group => group
|
||||||
|
.OrderByDescending(File.GetLastWriteTimeUtc)
|
||||||
|
.ThenByDescending(Path.GetFileName, StringComparer.OrdinalIgnoreCase)
|
||||||
|
.First())
|
||||||
|
.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(settings.LocalAuditCsvFolder))
|
||||||
|
return settings.LocalAuditCsvFolder.Trim();
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(settings.LocalSiteExportFolder))
|
||||||
|
return Path.Combine(settings.LocalSiteExportFolder.Trim(), "audit-csv");
|
||||||
|
|
||||||
|
return Path.Combine(AppContext.BaseDirectory, "output", "audit-csv");
|
||||||
|
}
|
||||||
|
|
||||||
|
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("Sales_", StringComparison.OrdinalIgnoreCase))
|
||||||
|
return name;
|
||||||
|
|
||||||
|
var withoutPrefix = name["Sales_".Length..];
|
||||||
|
var lastUnderscore = withoutPrefix.LastIndexOf('_');
|
||||||
|
return lastUnderscore <= 0 ? withoutPrefix : withoutPrefix[..lastUnderscore];
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -12,10 +12,19 @@ public interface IFinanceReconciliationService
|
|||||||
public sealed class FinanceReconciliationService : IFinanceReconciliationService
|
public sealed class FinanceReconciliationService : IFinanceReconciliationService
|
||||||
{
|
{
|
||||||
private readonly IDbContextFactory<AppDbContext> _dbFactory;
|
private readonly IDbContextFactory<AppDbContext> _dbFactory;
|
||||||
|
private readonly ICentralSalesDataProvider? _centralSalesDataProvider;
|
||||||
|
|
||||||
public FinanceReconciliationService(IDbContextFactory<AppDbContext> dbFactory)
|
public FinanceReconciliationService(IDbContextFactory<AppDbContext> dbFactory)
|
||||||
|
: this(dbFactory, null)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public FinanceReconciliationService(
|
||||||
|
IDbContextFactory<AppDbContext> dbFactory,
|
||||||
|
ICentralSalesDataProvider? centralSalesDataProvider)
|
||||||
{
|
{
|
||||||
_dbFactory = dbFactory;
|
_dbFactory = dbFactory;
|
||||||
|
_centralSalesDataProvider = centralSalesDataProvider;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<List<NetSalesReferenceRow>> BuildNetSalesReferenceRowsAsync(int year = 2025)
|
public async Task<List<NetSalesReferenceRow>> BuildNetSalesReferenceRowsAsync(int year = 2025)
|
||||||
@@ -41,35 +50,7 @@ public sealed class FinanceReconciliationService : IFinanceReconciliationService
|
|||||||
financeRules = FinanceRuleEngine.CreateDefaultRules().ToList();
|
financeRules = FinanceRuleEngine.CreateDefaultRules().ToList();
|
||||||
var financeRuleEngine = new FinanceRuleEngine(financeRules);
|
var financeRuleEngine = new FinanceRuleEngine(financeRules);
|
||||||
|
|
||||||
var centralRecords = await db.CentralSalesRecords
|
var centralRecords = await LoadCentralRecordsAsync(db);
|
||||||
.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 centralRows = centralRecords
|
var centralRows = centralRecords
|
||||||
.Select(record => ApplyFinanceRules(record, year, financeRuleEngine))
|
.Select(record => ApplyFinanceRules(record, year, financeRuleEngine))
|
||||||
@@ -165,6 +146,42 @@ public sealed class FinanceReconciliationService : IFinanceReconciliationService
|
|||||||
return result;
|
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)
|
private static NetSalesActualSourceRow? ApplyFinanceRules(SalesRecord record, int year, FinanceRuleEngine financeRuleEngine)
|
||||||
{
|
{
|
||||||
var referenceKey = ResolveReferenceKey(record.Land, record.Tsc);
|
var referenceKey = ResolveReferenceKey(record.Land, record.Tsc);
|
||||||
|
|||||||
@@ -9,16 +9,26 @@ public class ManagementCockpitService : IManagementCockpitService
|
|||||||
{
|
{
|
||||||
private readonly IDbContextFactory<AppDbContext> _dbFactory;
|
private readonly IDbContextFactory<AppDbContext> _dbFactory;
|
||||||
private readonly ICurrencyExchangeRateService _exchangeRateService;
|
private readonly ICurrencyExchangeRateService _exchangeRateService;
|
||||||
|
private readonly ICentralSalesDataProvider? _centralSalesDataProvider;
|
||||||
|
|
||||||
public ManagementCockpitService(IDbContextFactory<AppDbContext> dbFactory)
|
public ManagementCockpitService(IDbContextFactory<AppDbContext> dbFactory)
|
||||||
: this(dbFactory, new CurrencyExchangeRateService(dbFactory))
|
: this(dbFactory, new CurrencyExchangeRateService(dbFactory), null)
|
||||||
{
|
{
|
||||||
}
|
}
|
||||||
|
|
||||||
public ManagementCockpitService(IDbContextFactory<AppDbContext> dbFactory, ICurrencyExchangeRateService exchangeRateService)
|
public ManagementCockpitService(IDbContextFactory<AppDbContext> dbFactory, ICurrencyExchangeRateService exchangeRateService)
|
||||||
|
: this(dbFactory, exchangeRateService, null)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public ManagementCockpitService(
|
||||||
|
IDbContextFactory<AppDbContext> dbFactory,
|
||||||
|
ICurrencyExchangeRateService exchangeRateService,
|
||||||
|
ICentralSalesDataProvider? centralSalesDataProvider)
|
||||||
{
|
{
|
||||||
_dbFactory = dbFactory;
|
_dbFactory = dbFactory;
|
||||||
_exchangeRateService = exchangeRateService;
|
_exchangeRateService = exchangeRateService;
|
||||||
|
_centralSalesDataProvider = centralSalesDataProvider;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static readonly List<ValueFieldDefinition> ValueFieldDefinitions =
|
private static readonly List<ValueFieldDefinition> ValueFieldDefinitions =
|
||||||
@@ -166,12 +176,12 @@ public class ManagementCockpitService : IManagementCockpitService
|
|||||||
|
|
||||||
public async Task<List<int>> GetAvailableCentralYearsAsync()
|
public async Task<List<int>> GetAvailableCentralYearsAsync()
|
||||||
{
|
{
|
||||||
using var db = await _dbFactory.CreateDbContextAsync();
|
var records = await LoadCentralRecordsAsync();
|
||||||
var years = await db.CentralSalesRecords
|
var years = records
|
||||||
.Select(r => r.InvoiceDate.HasValue ? r.InvoiceDate.Value.Year : r.ExtractionDate.Year)
|
.Select(r => r.InvoiceDate.HasValue ? r.InvoiceDate.Value.Year : r.ExtractionDate.Year)
|
||||||
.Distinct()
|
.Distinct()
|
||||||
.OrderBy(x => x)
|
.OrderBy(x => x)
|
||||||
.ToListAsync();
|
.ToList();
|
||||||
|
|
||||||
return years;
|
return years;
|
||||||
}
|
}
|
||||||
@@ -186,7 +196,8 @@ public class ManagementCockpitService : IManagementCockpitService
|
|||||||
using var db = await _dbFactory.CreateDbContextAsync();
|
using var db = await _dbFactory.CreateDbContextAsync();
|
||||||
var settings = await db.ExportSettings.AsNoTracking().FirstOrDefaultAsync() ?? new ExportSettings();
|
var settings = await db.ExportSettings.AsNoTracking().FirstOrDefaultAsync() ?? new ExportSettings();
|
||||||
var exchangeRateDateField = SettingsPageService.NormalizeExchangeRateDateField(settings.ExchangeRateDateField);
|
var exchangeRateDateField = SettingsPageService.NormalizeExchangeRateDateField(settings.ExchangeRateDateField);
|
||||||
var baseRows = await db.CentralSalesRecords
|
var centralRecords = await LoadCentralRecordsAsync();
|
||||||
|
var baseRows = centralRecords
|
||||||
.Select(r => new CentralCockpitRow
|
.Select(r => new CentralCockpitRow
|
||||||
{
|
{
|
||||||
SourceSystem = r.SourceSystem,
|
SourceSystem = r.SourceSystem,
|
||||||
@@ -204,7 +215,7 @@ public class ManagementCockpitService : IManagementCockpitService
|
|||||||
PeriodDate = r.InvoiceDate ?? r.ExtractionDate,
|
PeriodDate = r.InvoiceDate ?? r.ExtractionDate,
|
||||||
ExchangeRateDate = r.ExtractionDate
|
ExchangeRateDate = r.ExtractionDate
|
||||||
})
|
})
|
||||||
.ToListAsync();
|
.ToList();
|
||||||
|
|
||||||
foreach (var row in baseRows)
|
foreach (var row in baseRows)
|
||||||
row.ExchangeRateDate = ResolveExchangeRateDate(exchangeRateDateField, row.PostingDate, row.InvoiceDate, row.ExtractionDate);
|
row.ExchangeRateDate = ResolveExchangeRateDate(exchangeRateDateField, row.PostingDate, row.InvoiceDate, row.ExtractionDate);
|
||||||
@@ -318,6 +329,7 @@ public class ManagementCockpitService : IManagementCockpitService
|
|||||||
public async Task<ManagementFinanceSummaryResult> AnalyzeFinanceSummaryAsync(int year, string? countryKey, string? currency)
|
public async Task<ManagementFinanceSummaryResult> AnalyzeFinanceSummaryAsync(int year, string? countryKey, string? currency)
|
||||||
{
|
{
|
||||||
using var db = await _dbFactory.CreateDbContextAsync();
|
using var db = await _dbFactory.CreateDbContextAsync();
|
||||||
|
var settings = await db.ExportSettings.AsNoTracking().FirstOrDefaultAsync() ?? new ExportSettings();
|
||||||
var financeRules = await db.FinanceRules
|
var financeRules = await db.FinanceRules
|
||||||
.AsNoTracking()
|
.AsNoTracking()
|
||||||
.Where(rule => rule.IsActive)
|
.Where(rule => rule.IsActive)
|
||||||
@@ -332,40 +344,7 @@ public class ManagementCockpitService : IManagementCockpitService
|
|||||||
.Where(rule => rule.IsActive)
|
.Where(rule => rule.IsActive)
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
var financeRuleEngine = new FinanceRuleEngine(financeRules);
|
var financeRuleEngine = new FinanceRuleEngine(financeRules);
|
||||||
var records = await db.CentralSalesRecords
|
var records = await LoadCentralRecordsAsync();
|
||||||
.AsNoTracking()
|
|
||||||
.Select(r => new SalesRecord
|
|
||||||
{
|
|
||||||
SourceSystem = r.SourceSystem,
|
|
||||||
Land = r.Land,
|
|
||||||
Tsc = r.Tsc,
|
|
||||||
DocumentEntry = r.DocumentEntry,
|
|
||||||
InvoiceNumber = r.InvoiceNumber,
|
|
||||||
PositionOnInvoice = r.PositionOnInvoice,
|
|
||||||
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,
|
|
||||||
CustomerName = r.CustomerName,
|
|
||||||
SalesCurrency = r.SalesCurrency,
|
|
||||||
DocumentCurrency = r.DocumentCurrency,
|
|
||||||
CompanyCurrency = r.CompanyCurrency,
|
|
||||||
SalesPriceValue = r.SalesPriceValue,
|
|
||||||
DocumentType = r.DocumentType,
|
|
||||||
PostingDate = r.PostingDate,
|
|
||||||
InvoiceDate = r.InvoiceDate,
|
|
||||||
ExtractionDate = r.ExtractionDate
|
|
||||||
})
|
|
||||||
.ToListAsync();
|
|
||||||
|
|
||||||
if (records.Count == 0)
|
if (records.Count == 0)
|
||||||
throw new InvalidOperationException("Die zentrale Tabelle enthaelt noch keine Datensaetze.");
|
throw new InvalidOperationException("Die zentrale Tabelle enthaelt noch keine Datensaetze.");
|
||||||
@@ -484,7 +463,7 @@ public class ManagementCockpitService : IManagementCockpitService
|
|||||||
group => group.Select(reference => reference.CheckValue ?? reference.LocalCurrencyValue).FirstOrDefault(value => value.HasValue),
|
group => group.Select(reference => reference.CheckValue ?? reference.LocalCurrencyValue).FirstOrDefault(value => value.HasValue),
|
||||||
StringComparer.OrdinalIgnoreCase);
|
StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
var dataStatusRows = await BuildFinanceDataStatusRowsAsync(db);
|
var dataStatusRows = await BuildFinanceDataStatusRowsAsync(db, records, settings.UseAuditCsvAsCentralSource);
|
||||||
var countryRows = BuildFinanceCountryStatusRows(scopedRows, referenceByKey);
|
var countryRows = BuildFinanceCountryStatusRows(scopedRows, referenceByKey);
|
||||||
var productAssignmentRows = BuildProductAssignmentRows(scopedRows, allRows);
|
var productAssignmentRows = BuildProductAssignmentRows(scopedRows, allRows);
|
||||||
var productFinanceSummary = BuildProductFinanceSummary(productAssignmentRows, resultCurrencies);
|
var productFinanceSummary = BuildProductFinanceSummary(productAssignmentRows, resultCurrencies);
|
||||||
@@ -536,24 +515,38 @@ public class ManagementCockpitService : IManagementCockpitService
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task<List<ManagementFinanceDataStatusRow>> BuildFinanceDataStatusRowsAsync(AppDbContext db)
|
private static async Task<List<ManagementFinanceDataStatusRow>> BuildFinanceDataStatusRowsAsync(
|
||||||
|
AppDbContext db,
|
||||||
|
IReadOnlyCollection<SalesRecord> centralRecords,
|
||||||
|
bool useAuditCsv)
|
||||||
{
|
{
|
||||||
var sites = await db.Sites
|
var sites = await db.Sites
|
||||||
.AsNoTracking()
|
.AsNoTracking()
|
||||||
.OrderBy(site => site.Land)
|
.OrderBy(site => site.Land)
|
||||||
.ThenBy(site => site.TSC)
|
.ThenBy(site => site.TSC)
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
var records = await db.CentralSalesRecords
|
var records = useAuditCsv
|
||||||
.AsNoTracking()
|
? centralRecords
|
||||||
.GroupBy(record => record.Tsc)
|
.GroupBy(record => record.Tsc)
|
||||||
.Select(group => new
|
.Select(group => new
|
||||||
{
|
{
|
||||||
Tsc = group.Key,
|
Tsc = group.Key,
|
||||||
RowCount = group.Count(),
|
RowCount = group.Count(),
|
||||||
LatestStoredAtUtc = group.Max(record => record.StoredAtUtc),
|
LatestStoredAtUtc = (DateTime?)null,
|
||||||
LatestExtractionDate = group.Max(record => record.ExtractionDate)
|
LatestExtractionDate = group.Max(record => record.ExtractionDate)
|
||||||
})
|
})
|
||||||
.ToListAsync();
|
.ToList()
|
||||||
|
: await db.CentralSalesRecords
|
||||||
|
.AsNoTracking()
|
||||||
|
.GroupBy(record => record.Tsc)
|
||||||
|
.Select(group => new
|
||||||
|
{
|
||||||
|
Tsc = group.Key,
|
||||||
|
RowCount = group.Count(),
|
||||||
|
LatestStoredAtUtc = (DateTime?)group.Max(record => record.StoredAtUtc),
|
||||||
|
LatestExtractionDate = group.Max(record => record.ExtractionDate)
|
||||||
|
})
|
||||||
|
.ToListAsync();
|
||||||
var logs = await db.ExportLogs
|
var logs = await db.ExportLogs
|
||||||
.AsNoTracking()
|
.AsNoTracking()
|
||||||
.GroupBy(log => log.TSC)
|
.GroupBy(log => log.TSC)
|
||||||
@@ -582,7 +575,7 @@ public class ManagementCockpitService : IManagementCockpitService
|
|||||||
{
|
{
|
||||||
Land = site.Land,
|
Land = site.Land,
|
||||||
Tsc = site.TSC,
|
Tsc = site.TSC,
|
||||||
SourceSystem = site.SourceSystem,
|
SourceSystem = useAuditCsv ? $"{site.SourceSystem} / Audit-CSV" : site.SourceSystem,
|
||||||
IsActive = site.IsActive,
|
IsActive = site.IsActive,
|
||||||
RowCount = record?.RowCount ?? 0,
|
RowCount = record?.RowCount ?? 0,
|
||||||
LatestStoredAtUtc = record?.LatestStoredAtUtc,
|
LatestStoredAtUtc = record?.LatestStoredAtUtc,
|
||||||
@@ -595,6 +588,63 @@ public class ManagementCockpitService : IManagementCockpitService
|
|||||||
}).ToList();
|
}).ToList();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async Task<List<SalesRecord>> LoadCentralRecordsAsync()
|
||||||
|
{
|
||||||
|
if (_centralSalesDataProvider is not null)
|
||||||
|
return await _centralSalesDataProvider.GetRecordsAsync();
|
||||||
|
|
||||||
|
using var db = await _dbFactory.CreateDbContextAsync();
|
||||||
|
return await db.CentralSalesRecords
|
||||||
|
.AsNoTracking()
|
||||||
|
.Select(r => new SalesRecord
|
||||||
|
{
|
||||||
|
SourceSystem = r.SourceSystem,
|
||||||
|
Land = r.Land,
|
||||||
|
Tsc = r.Tsc,
|
||||||
|
DocumentEntry = r.DocumentEntry,
|
||||||
|
InvoiceNumber = r.InvoiceNumber,
|
||||||
|
PositionOnInvoice = r.PositionOnInvoice,
|
||||||
|
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,
|
||||||
|
SupplierCountry = r.SupplierCountry,
|
||||||
|
CustomerNumber = r.CustomerNumber,
|
||||||
|
CustomerName = r.CustomerName,
|
||||||
|
CustomerCountry = r.CustomerCountry,
|
||||||
|
CustomerIndustry = r.CustomerIndustry,
|
||||||
|
StandardCost = r.StandardCost,
|
||||||
|
StandardCostCurrency = r.StandardCostCurrency,
|
||||||
|
PurchaseOrderNumber = r.PurchaseOrderNumber,
|
||||||
|
SalesPriceValue = r.SalesPriceValue,
|
||||||
|
SalesCurrency = r.SalesCurrency,
|
||||||
|
DocumentCurrency = r.DocumentCurrency,
|
||||||
|
DocumentTotalForeignCurrency = r.DocumentTotalForeignCurrency,
|
||||||
|
DocumentTotalLocalCurrency = r.DocumentTotalLocalCurrency,
|
||||||
|
VatSumForeignCurrency = r.VatSumForeignCurrency,
|
||||||
|
VatSumLocalCurrency = r.VatSumLocalCurrency,
|
||||||
|
DocumentRate = r.DocumentRate,
|
||||||
|
CompanyCurrency = r.CompanyCurrency,
|
||||||
|
Incoterms2020 = r.Incoterms2020,
|
||||||
|
SalesResponsibleEmployee = r.SalesResponsibleEmployee,
|
||||||
|
PostingDate = r.PostingDate,
|
||||||
|
InvoiceDate = r.InvoiceDate,
|
||||||
|
OrderDate = r.OrderDate,
|
||||||
|
ExtractionDate = r.ExtractionDate,
|
||||||
|
DocumentType = r.DocumentType
|
||||||
|
})
|
||||||
|
.ToListAsync();
|
||||||
|
}
|
||||||
|
|
||||||
private static List<ManagementFinanceCountryStatusRow> BuildFinanceCountryStatusRows(
|
private static List<ManagementFinanceCountryStatusRow> BuildFinanceCountryStatusRows(
|
||||||
IReadOnlyCollection<FinanceAggregationRow> rows,
|
IReadOnlyCollection<FinanceAggregationRow> rows,
|
||||||
IReadOnlyDictionary<string, decimal?> referenceByKey)
|
IReadOnlyDictionary<string, decimal?> referenceByKey)
|
||||||
|
|||||||
@@ -108,6 +108,9 @@ public sealed class SettingsPageService : ISettingsPageService
|
|||||||
existing.DebugLoggingEnabled = settings.DebugLoggingEnabled;
|
existing.DebugLoggingEnabled = settings.DebugLoggingEnabled;
|
||||||
existing.LocalSiteExportFolder = settings.LocalSiteExportFolder;
|
existing.LocalSiteExportFolder = settings.LocalSiteExportFolder;
|
||||||
existing.LocalConsolidatedExportFolder = settings.LocalConsolidatedExportFolder;
|
existing.LocalConsolidatedExportFolder = settings.LocalConsolidatedExportFolder;
|
||||||
|
existing.AuditCsvEnabled = settings.AuditCsvEnabled;
|
||||||
|
existing.UseAuditCsvAsCentralSource = settings.UseAuditCsvAsCentralSource;
|
||||||
|
existing.LocalAuditCsvFolder = settings.LocalAuditCsvFolder;
|
||||||
existing.ExchangeRateDateField = NormalizeExchangeRateDateField(settings.ExchangeRateDateField);
|
existing.ExchangeRateDateField = NormalizeExchangeRateDateField(settings.ExchangeRateDateField);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ public class SiteExportService : ISiteExportService
|
|||||||
private readonly ISharePointUploadService _sharePointService;
|
private readonly ISharePointUploadService _sharePointService;
|
||||||
private readonly IRecordTransformationService _transformationService;
|
private readonly IRecordTransformationService _transformationService;
|
||||||
private readonly ICentralSalesRecordService _centralSalesRecordService;
|
private readonly ICentralSalesRecordService _centralSalesRecordService;
|
||||||
|
private readonly IExportAuditCsvService _auditCsvService;
|
||||||
private readonly IAppEventLogService _appEventLogService;
|
private readonly IAppEventLogService _appEventLogService;
|
||||||
private readonly ILogger<SiteExportService> _logger;
|
private readonly ILogger<SiteExportService> _logger;
|
||||||
|
|
||||||
@@ -24,6 +25,7 @@ public class SiteExportService : ISiteExportService
|
|||||||
ISharePointUploadService sharePointService,
|
ISharePointUploadService sharePointService,
|
||||||
IRecordTransformationService transformationService,
|
IRecordTransformationService transformationService,
|
||||||
ICentralSalesRecordService centralSalesRecordService,
|
ICentralSalesRecordService centralSalesRecordService,
|
||||||
|
IExportAuditCsvService auditCsvService,
|
||||||
IAppEventLogService appEventLogService,
|
IAppEventLogService appEventLogService,
|
||||||
ILogger<SiteExportService> logger)
|
ILogger<SiteExportService> logger)
|
||||||
{
|
{
|
||||||
@@ -33,6 +35,7 @@ public class SiteExportService : ISiteExportService
|
|||||||
_sharePointService = sharePointService;
|
_sharePointService = sharePointService;
|
||||||
_transformationService = transformationService;
|
_transformationService = transformationService;
|
||||||
_centralSalesRecordService = centralSalesRecordService;
|
_centralSalesRecordService = centralSalesRecordService;
|
||||||
|
_auditCsvService = auditCsvService;
|
||||||
_appEventLogService = appEventLogService;
|
_appEventLogService = appEventLogService;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
}
|
}
|
||||||
@@ -76,6 +79,15 @@ public class SiteExportService : ISiteExportService
|
|||||||
details: $"Records vor Transformation={records.Count}");
|
details: $"Records vor Transformation={records.Count}");
|
||||||
_transformationService.Apply(records, rules);
|
_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;
|
var filePath = fetchResult.ReferenceFilePath;
|
||||||
if (string.IsNullOrWhiteSpace(filePath))
|
if (string.IsNullOrWhiteSpace(filePath))
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,175 @@
|
|||||||
|
using Microsoft.Data.Sqlite;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using TrafagSalesExporter.Data;
|
||||||
|
using TrafagSalesExporter.Models;
|
||||||
|
using TrafagSalesExporter.Services;
|
||||||
|
|
||||||
|
namespace TrafagSalesExporter.Tests;
|
||||||
|
|
||||||
|
public sealed class ExportAuditCsvServiceTests : IDisposable
|
||||||
|
{
|
||||||
|
private readonly string _tempDirectory;
|
||||||
|
|
||||||
|
public ExportAuditCsvServiceTests()
|
||||||
|
{
|
||||||
|
_tempDirectory = Path.Combine("C:\\TMP", $"trafag-audit-csv-tests-{Guid.NewGuid():N}");
|
||||||
|
Directory.CreateDirectory(_tempDirectory);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
if (Directory.Exists(_tempDirectory))
|
||||||
|
Directory.Delete(_tempDirectory, recursive: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task WriteSiteAuditCsvAsync_Roundtrips_Transformed_SalesRecord()
|
||||||
|
{
|
||||||
|
var service = new ExportAuditCsvService();
|
||||||
|
var settings = new ExportSettings
|
||||||
|
{
|
||||||
|
AuditCsvEnabled = true,
|
||||||
|
LocalAuditCsvFolder = _tempDirectory
|
||||||
|
};
|
||||||
|
var site = new Site { TSC = "TRCH", Land = "Schweiz" };
|
||||||
|
var record = new SalesRecord
|
||||||
|
{
|
||||||
|
SourceSystem = "SAP",
|
||||||
|
ExtractionDate = new DateTime(2026, 6, 11, 8, 30, 0, DateTimeKind.Utc),
|
||||||
|
Tsc = "TRCH",
|
||||||
|
SourceLineId = "line-1",
|
||||||
|
DocumentEntry = 42,
|
||||||
|
InvoiceNumber = "INV-1",
|
||||||
|
PositionOnInvoice = 7,
|
||||||
|
Material = "MAT;1",
|
||||||
|
Name = "Artikel \"Audit\"",
|
||||||
|
ProductDivisionCode = "0001",
|
||||||
|
ProductDivisionText = "Pressure",
|
||||||
|
ProductMappingAssigned = "TRUE",
|
||||||
|
Quantity = 2.5m,
|
||||||
|
SalesPriceValue = 1234.56m,
|
||||||
|
SalesCurrency = "CHF",
|
||||||
|
DocumentCurrency = "EUR",
|
||||||
|
DocumentTotalForeignCurrency = 1300m,
|
||||||
|
DocumentTotalLocalCurrency = 1234.56m,
|
||||||
|
VatSumForeignCurrency = 0m,
|
||||||
|
VatSumLocalCurrency = 0m,
|
||||||
|
DocumentRate = 0.95m,
|
||||||
|
CompanyCurrency = "CHF",
|
||||||
|
PostingDate = new DateTime(2026, 6, 10),
|
||||||
|
InvoiceDate = new DateTime(2026, 6, 11),
|
||||||
|
Land = "Schweiz",
|
||||||
|
DocumentType = "INV"
|
||||||
|
};
|
||||||
|
|
||||||
|
var path = await service.WriteSiteAuditCsvAsync(site, settings, "SAP", _tempDirectory, [record]);
|
||||||
|
|
||||||
|
Assert.True(File.Exists(path));
|
||||||
|
var records = await service.ReadLatestSiteAuditCsvRecordsAsync(settings);
|
||||||
|
var roundtrip = Assert.Single(records);
|
||||||
|
Assert.Equal("SAP", roundtrip.SourceSystem);
|
||||||
|
Assert.Equal("TRCH", roundtrip.Tsc);
|
||||||
|
Assert.Equal("line-1", roundtrip.SourceLineId);
|
||||||
|
Assert.Equal("MAT;1", roundtrip.Material);
|
||||||
|
Assert.Equal("Artikel \"Audit\"", roundtrip.Name);
|
||||||
|
Assert.Equal(1234.56m, roundtrip.SalesPriceValue);
|
||||||
|
Assert.Equal("CHF", roundtrip.SalesCurrency);
|
||||||
|
Assert.Equal(new DateTime(2026, 6, 10), roundtrip.PostingDate);
|
||||||
|
Assert.Equal(new DateTime(2026, 6, 11), roundtrip.InvoiceDate);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task CentralSalesDataProvider_Uses_AuditCsv_When_Configured()
|
||||||
|
{
|
||||||
|
var csvService = new ExportAuditCsvService();
|
||||||
|
await csvService.WriteSiteAuditCsvAsync(
|
||||||
|
new Site { TSC = "TRUK", Land = "England" },
|
||||||
|
new ExportSettings { AuditCsvEnabled = true, LocalAuditCsvFolder = _tempDirectory },
|
||||||
|
"MANUAL_EXCEL",
|
||||||
|
_tempDirectory,
|
||||||
|
[
|
||||||
|
new SalesRecord
|
||||||
|
{
|
||||||
|
SourceSystem = "MANUAL_EXCEL",
|
||||||
|
ExtractionDate = new DateTime(2026, 6, 11),
|
||||||
|
Tsc = "TRUK",
|
||||||
|
Land = "England",
|
||||||
|
InvoiceNumber = "UK-1",
|
||||||
|
SalesPriceValue = 10m,
|
||||||
|
SalesCurrency = "GBP",
|
||||||
|
InvoiceDate = new DateTime(2026, 1, 1)
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
|
||||||
|
await using var connection = new SqliteConnection("DataSource=:memory:");
|
||||||
|
await connection.OpenAsync();
|
||||||
|
var options = new DbContextOptionsBuilder<AppDbContext>()
|
||||||
|
.UseSqlite(connection)
|
||||||
|
.Options;
|
||||||
|
await using (var db = new AppDbContext(options))
|
||||||
|
{
|
||||||
|
await db.Database.EnsureCreatedAsync();
|
||||||
|
db.ExportSettings.Add(new ExportSettings
|
||||||
|
{
|
||||||
|
UseAuditCsvAsCentralSource = true,
|
||||||
|
LocalAuditCsvFolder = _tempDirectory
|
||||||
|
});
|
||||||
|
db.Sites.Add(new Site
|
||||||
|
{
|
||||||
|
Id = 1,
|
||||||
|
Schema = "DB",
|
||||||
|
TSC = "TRDB",
|
||||||
|
Land = "DB",
|
||||||
|
SourceSystem = "DB",
|
||||||
|
IsActive = true
|
||||||
|
});
|
||||||
|
db.CentralSalesRecords.Add(new CentralSalesRecord
|
||||||
|
{
|
||||||
|
StoredAtUtc = DateTime.UtcNow,
|
||||||
|
SiteId = 1,
|
||||||
|
SourceSystem = "DB",
|
||||||
|
ExtractionDate = new DateTime(2026, 6, 11),
|
||||||
|
Tsc = "TRDB",
|
||||||
|
InvoiceNumber = "DB-1",
|
||||||
|
Land = "DB",
|
||||||
|
DocumentType = "INV"
|
||||||
|
});
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
var dbFactory = new TestDbContextFactory(options);
|
||||||
|
var centralService = new CentralSalesRecordService(dbFactory, new NullAppEventLogService());
|
||||||
|
var provider = new CentralSalesDataProvider(dbFactory, centralService, csvService);
|
||||||
|
|
||||||
|
var records = await provider.GetRecordsAsync();
|
||||||
|
|
||||||
|
var record = Assert.Single(records);
|
||||||
|
Assert.Equal("TRUK", record.Tsc);
|
||||||
|
Assert.Equal("UK-1", record.InvoiceNumber);
|
||||||
|
Assert.Equal(10m, record.SalesPriceValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class NullAppEventLogService : IAppEventLogService
|
||||||
|
{
|
||||||
|
public Task WriteAsync(string category, string message, string level = "Info", int? siteId = null, string? land = null, string? details = null)
|
||||||
|
=> Task.CompletedTask;
|
||||||
|
|
||||||
|
public Task WriteDebugAsync(string category, string message, int? siteId = null, string? land = null, string? details = null)
|
||||||
|
=> Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class TestDbContextFactory : IDbContextFactory<AppDbContext>
|
||||||
|
{
|
||||||
|
private readonly DbContextOptions<AppDbContext> _options;
|
||||||
|
|
||||||
|
public TestDbContextFactory(DbContextOptions<AppDbContext> options)
|
||||||
|
{
|
||||||
|
_options = options;
|
||||||
|
}
|
||||||
|
|
||||||
|
public AppDbContext CreateDbContext() => new(_options);
|
||||||
|
|
||||||
|
public Task<AppDbContext> CreateDbContextAsync(CancellationToken cancellationToken = default)
|
||||||
|
=> Task.FromResult(new AppDbContext(_options));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ Fokus nur Wechselkurs/Kursanwendung: `docs/FINANCE_KURS_WORKFLOW_2026-06-09.md`.
|
|||||||
## Kurzfazit
|
## Kurzfazit
|
||||||
|
|
||||||
- Finance Summary, Management Analyse und Spartenanalyse lesen nicht aus dem SharePoint-Excel, sondern direkt aus der App-Datenbank `CentralSalesRecords`.
|
- Finance Summary, Management Analyse und Spartenanalyse lesen nicht aus dem SharePoint-Excel, sondern direkt aus der App-Datenbank `CentralSalesRecords`.
|
||||||
|
- Nachtrag 2026-06-11: Fuer Finance/Revision gibt es lokal einen Audit-CSV-Modus. Standortexporte koennen nach Mapping und Transformation je Standort eine CSV schreiben; per Setting koennen zentrale Excel, Finance Summary und Management-Analyse aus den neuesten Audit-CSV statt aus `CentralSalesRecords` lesen.
|
||||||
- Das SharePoint-Excel `Sales_All_*.xlsx` ist ein Export-/Ablageergebnis, nicht die Live-Quelle der Cockpit-Anzeige.
|
- Das SharePoint-Excel `Sales_All_*.xlsx` ist ein Export-/Ablageergebnis, nicht die Live-Quelle der Cockpit-Anzeige.
|
||||||
- Jeder Standortexport ersetzt in `CentralSalesRecords` nur die Daten dieses Standorts.
|
- Jeder Standortexport ersetzt in `CentralSalesRecords` nur die Daten dieses Standorts.
|
||||||
- Die zentrale Excel wird danach aus dem aktuellen Stand von `CentralSalesRecords` erzeugt.
|
- Die zentrale Excel wird danach aus dem aktuellen Stand von `CentralSalesRecords` erzeugt.
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ Stand: 2026-06-10
|
|||||||
## Kurzstand
|
## Kurzstand
|
||||||
|
|
||||||
- Fuehrende Sicht: `Finance Summary`.
|
- Fuehrende Sicht: `Finance Summary`.
|
||||||
|
- Neu lokal: `Finance Summary`, zentrale Excel und Management-Analyse koennen wahlweise aus Audit-CSV statt direkt aus `CentralSalesRecords` lesen. Die Audit-CSV werden nach Mapping und Transformation geschrieben und dienen der Nachvollziehbarkeit fuer Finance/Revision.
|
||||||
- `Finance Summary` nutzt dieselbe `FinanceRuleEngine` wie das zentrale Excel.
|
- `Finance Summary` nutzt dieselbe `FinanceRuleEngine` wie das zentrale Excel.
|
||||||
- `Management Analyse` bleibt Diagnose-/Plausibilitaetssicht, nicht fuehrende Finance-Zahl.
|
- `Management Analyse` bleibt Diagnose-/Plausibilitaetssicht, nicht fuehrende Finance-Zahl.
|
||||||
- Nach UX-Vereinfachung gibt es links eine schnellere Finance-Uebersicht; tiefe Diagnosefunktionen sind unter `Experten` gebuendelt.
|
- Nach UX-Vereinfachung gibt es links eine schnellere Finance-Uebersicht; tiefe Diagnosefunktionen sind unter `Experten` gebuendelt.
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ Stand: 2026-06-10
|
|||||||
## Kurzstand
|
## Kurzstand
|
||||||
|
|
||||||
- Fuehrende App: `TrafagSalesExporter`, publiziert als `BiDashboard`.
|
- Fuehrende App: `TrafagSalesExporter`, publiziert als `BiDashboard`.
|
||||||
|
- Neu lokal: Audit-CSV-Modus fuer Finance/Revision. Standortexporte schreiben optional nach Mapping/Transformation je Standort eine lesbare CSV; zentrale Excel, Finance Summary und Management-Analyse koennen per Setting aus den neuesten Standort-CSV statt aus der internen DB lesen.
|
||||||
- Letzter dokumentierter Stand: CH/AT-Produktsparten-Fallback ueber `ProductDivisionMapSet` deployed; India/TRIN SAGE-HANA-Fix und Spanien-SharePoint-Pfad bleiben abgesichert.
|
- Letzter dokumentierter Stand: CH/AT-Produktsparten-Fallback ueber `ProductDivisionMapSet` deployed; India/TRIN SAGE-HANA-Fix und Spanien-SharePoint-Pfad bleiben abgesichert.
|
||||||
- Validierung laut Doku: `87/87` Tests gruen fuer den Produktsparten-Fallback; fruehere UI-/Deploy-Schritte wurden einzeln umgesetzt und deployed.
|
- Validierung laut Doku: `87/87` Tests gruen fuer den Produktsparten-Fallback; fruehere UI-/Deploy-Schritte wurden einzeln umgesetzt und deployed.
|
||||||
- Letzter dokumentierter Deploy: 2026-06-10 auf `\\trch-webapp-bidashboard.trafagch.local\BiDashboard$\`.
|
- Letzter dokumentierter Deploy: 2026-06-10 auf `\\trch-webapp-bidashboard.trafagch.local\BiDashboard$\`.
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ Diese Datei ist fuer tokenarme RAG-Nutzung komprimiert.
|
|||||||
|
|
||||||
- Fuehrender Kurzkontext: `docs/rag/PROJECT.md`.
|
- Fuehrender Kurzkontext: `docs/rag/PROJECT.md`.
|
||||||
- Themenrouter: `docs/RAG_ROUTER.md`.
|
- Themenrouter: `docs/RAG_ROUTER.md`.
|
||||||
|
- Neu lokal umgesetzt: Standortexporte koennen nach Mapping und Transformation eine lesbare Audit-CSV je Standort schreiben; zentrale Excel, Finance Summary und Management-Analyse koennen per Setting wahlweise aus den neuesten Audit-CSV statt aus `CentralSalesRecords` lesen.
|
||||||
- Letzter dokumentierter Code-Stand: CH/AT-Produktsparten-Fallback ueber `ProductDivisionMapSet` deployed; India/TRIN HANA-Route und Spanien-SharePoint-Pfad bleiben im Seed abgesichert.
|
- Letzter dokumentierter Code-Stand: CH/AT-Produktsparten-Fallback ueber `ProductDivisionMapSet` deployed; India/TRIN HANA-Route und Spanien-SharePoint-Pfad bleiben im Seed abgesichert.
|
||||||
- Letzte dokumentierte Validierung: `dotnet test TrafagSalesExporter.sln --verbosity minimal` mit `87/87` Tests gruen.
|
- Letzte dokumentierte Validierung: `dotnet test TrafagSalesExporter.sln --verbosity minimal` mit `87/87` Tests gruen.
|
||||||
- Letzter dokumentierter Deploy: 2026-06-10 Produktsparten-Fallback nach `\\trch-webapp-bidashboard.trafagch.local\BiDashboard$\`.
|
- Letzter dokumentierter Deploy: 2026-06-10 Produktsparten-Fallback nach `\\trch-webapp-bidashboard.trafagch.local\BiDashboard$\`.
|
||||||
|
|||||||
Reference in New Issue
Block a user