Add audit CSV central source option
This commit is contained in:
@@ -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)
|
||||
.Select(r => new SalesRecord
|
||||
{
|
||||
SourceSystem = r.SourceSystem,
|
||||
ExtractionDate = r.ExtractionDate,
|
||||
Tsc = r.Tsc,
|
||||
DocumentEntry = r.DocumentEntry,
|
||||
|
||||
@@ -71,6 +71,9 @@ public class ConfigTransferService : IConfigTransferService
|
||||
DebugLoggingEnabled = exportSettings.DebugLoggingEnabled,
|
||||
LocalSiteExportFolder = exportSettings.LocalSiteExportFolder,
|
||||
LocalConsolidatedExportFolder = exportSettings.LocalConsolidatedExportFolder,
|
||||
AuditCsvEnabled = exportSettings.AuditCsvEnabled,
|
||||
UseAuditCsvAsCentralSource = exportSettings.UseAuditCsvAsCentralSource,
|
||||
LocalAuditCsvFolder = exportSettings.LocalAuditCsvFolder,
|
||||
ExchangeRateDateField = SettingsPageService.NormalizeExchangeRateDateField(exportSettings.ExchangeRateDateField)
|
||||
},
|
||||
SourceSystemDefinitions = sourceSystems.Select(system => new ConfigTransferSourceSystemDefinition
|
||||
@@ -285,6 +288,9 @@ public class ConfigTransferService : IConfigTransferService
|
||||
DebugLoggingEnabled = importedSettings.DebugLoggingEnabled,
|
||||
LocalSiteExportFolder = importedSettings.LocalSiteExportFolder,
|
||||
LocalConsolidatedExportFolder = importedSettings.LocalConsolidatedExportFolder,
|
||||
AuditCsvEnabled = importedSettings.AuditCsvEnabled,
|
||||
UseAuditCsvAsCentralSource = importedSettings.UseAuditCsvAsCentralSource,
|
||||
LocalAuditCsvFolder = importedSettings.LocalAuditCsvFolder,
|
||||
ExchangeRateDateField = SettingsPageService.NormalizeExchangeRateDateField(importedSettings.ExchangeRateDateField)
|
||||
});
|
||||
|
||||
|
||||
@@ -7,25 +7,25 @@ namespace TrafagSalesExporter.Services;
|
||||
public class ConsolidatedExportService : IConsolidatedExportService
|
||||
{
|
||||
private readonly IDbContextFactory<AppDbContext> _dbFactory;
|
||||
private readonly ICentralSalesRecordService _centralSalesRecordService;
|
||||
private readonly ICentralSalesDataProvider _centralSalesDataProvider;
|
||||
private readonly IExcelExportService _excelService;
|
||||
private readonly ISharePointUploadService _sharePointService;
|
||||
|
||||
public ConsolidatedExportService(
|
||||
IDbContextFactory<AppDbContext> dbFactory,
|
||||
ICentralSalesRecordService centralSalesRecordService,
|
||||
ICentralSalesDataProvider centralSalesDataProvider,
|
||||
IExcelExportService excelService,
|
||||
ISharePointUploadService sharePointService)
|
||||
{
|
||||
_dbFactory = dbFactory;
|
||||
_centralSalesRecordService = centralSalesRecordService;
|
||||
_centralSalesDataProvider = centralSalesDataProvider;
|
||||
_excelService = excelService;
|
||||
_sharePointService = sharePointService;
|
||||
}
|
||||
|
||||
public async Task<string?> ExportAsync()
|
||||
{
|
||||
var consolidatedRecords = await _centralSalesRecordService.GetAllAsync();
|
||||
var consolidatedRecords = await _centralSalesDataProvider.GetRecordsAsync();
|
||||
if (consolidatedRecords.Count == 0)
|
||||
return null;
|
||||
|
||||
|
||||
@@ -28,6 +28,9 @@ CREATE TABLE ExportSettings (
|
||||
DebugLoggingEnabled INTEGER NOT NULL DEFAULT 0,
|
||||
LocalSiteExportFolder 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'
|
||||
);";
|
||||
|
||||
|
||||
@@ -29,6 +29,9 @@ public class DatabaseSchemaMaintenanceService : IDatabaseSchemaMaintenanceServic
|
||||
AddColumnIfMissing(db, "ExportSettings", "DebugLoggingEnabled", "INTEGER NOT NULL DEFAULT 0");
|
||||
AddColumnIfMissing(db, "ExportSettings", "LocalSiteExportFolder", "TEXT NOT NULL DEFAULT ''");
|
||||
AddColumnIfMissing(db, "ExportSettings", "LocalConsolidatedExportFolder", "TEXT NOT NULL DEFAULT ''");
|
||||
AddColumnIfMissing(db, "ExportSettings", "AuditCsvEnabled", "INTEGER NOT NULL DEFAULT 1");
|
||||
AddColumnIfMissing(db, "ExportSettings", "UseAuditCsvAsCentralSource", "INTEGER NOT NULL DEFAULT 0");
|
||||
AddColumnIfMissing(db, "ExportSettings", "LocalAuditCsvFolder", "TEXT NOT NULL DEFAULT ''");
|
||||
AddColumnIfMissing(db, "ExportSettings", "ExchangeRateDateField", "TEXT NOT NULL DEFAULT 'PostingDate'");
|
||||
AddColumnIfMissing(db, "SharePointConfigs", "CentralExportFolder", "TEXT NOT NULL DEFAULT ''");
|
||||
AddColumnIfMissing(db, "ExportLogs", "FilePath", "TEXT NOT NULL DEFAULT ''");
|
||||
|
||||
@@ -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
|
||||
{
|
||||
private readonly IDbContextFactory<AppDbContext> _dbFactory;
|
||||
private readonly ICentralSalesDataProvider? _centralSalesDataProvider;
|
||||
|
||||
public FinanceReconciliationService(IDbContextFactory<AppDbContext> dbFactory)
|
||||
: this(dbFactory, null)
|
||||
{
|
||||
}
|
||||
|
||||
public FinanceReconciliationService(
|
||||
IDbContextFactory<AppDbContext> dbFactory,
|
||||
ICentralSalesDataProvider? centralSalesDataProvider)
|
||||
{
|
||||
_dbFactory = dbFactory;
|
||||
_centralSalesDataProvider = centralSalesDataProvider;
|
||||
}
|
||||
|
||||
public async Task<List<NetSalesReferenceRow>> BuildNetSalesReferenceRowsAsync(int year = 2025)
|
||||
@@ -41,35 +50,7 @@ public sealed class FinanceReconciliationService : IFinanceReconciliationService
|
||||
financeRules = FinanceRuleEngine.CreateDefaultRules().ToList();
|
||||
var financeRuleEngine = new FinanceRuleEngine(financeRules);
|
||||
|
||||
var centralRecords = await db.CentralSalesRecords
|
||||
.AsNoTracking()
|
||||
.Select(r => new SalesRecord
|
||||
{
|
||||
Land = r.Land,
|
||||
Tsc = r.Tsc,
|
||||
DocumentEntry = r.DocumentEntry,
|
||||
InvoiceNumber = r.InvoiceNumber,
|
||||
PositionOnInvoice = r.PositionOnInvoice,
|
||||
Material = r.Material,
|
||||
Name = r.Name,
|
||||
Quantity = r.Quantity,
|
||||
DocumentType = r.DocumentType,
|
||||
PostingDate = r.PostingDate,
|
||||
InvoiceDate = r.InvoiceDate,
|
||||
ExtractionDate = r.ExtractionDate,
|
||||
CustomerNumber = r.CustomerNumber,
|
||||
CustomerName = r.CustomerName,
|
||||
SupplierCountry = r.SupplierCountry,
|
||||
SalesCurrency = r.SalesCurrency,
|
||||
DocumentCurrency = r.DocumentCurrency,
|
||||
CompanyCurrency = r.CompanyCurrency,
|
||||
SalesPriceValue = r.SalesPriceValue,
|
||||
DocumentTotalForeignCurrency = r.DocumentTotalForeignCurrency,
|
||||
DocumentTotalLocalCurrency = r.DocumentTotalLocalCurrency,
|
||||
VatSumForeignCurrency = r.VatSumForeignCurrency,
|
||||
VatSumLocalCurrency = r.VatSumLocalCurrency
|
||||
})
|
||||
.ToListAsync();
|
||||
var centralRecords = await LoadCentralRecordsAsync(db);
|
||||
|
||||
var centralRows = centralRecords
|
||||
.Select(record => ApplyFinanceRules(record, year, financeRuleEngine))
|
||||
@@ -165,6 +146,42 @@ public sealed class FinanceReconciliationService : IFinanceReconciliationService
|
||||
return result;
|
||||
}
|
||||
|
||||
private async Task<List<SalesRecord>> LoadCentralRecordsAsync(AppDbContext db)
|
||||
{
|
||||
if (_centralSalesDataProvider is not null)
|
||||
return await _centralSalesDataProvider.GetRecordsAsync();
|
||||
|
||||
return await db.CentralSalesRecords
|
||||
.AsNoTracking()
|
||||
.Select(r => new SalesRecord
|
||||
{
|
||||
Land = r.Land,
|
||||
Tsc = r.Tsc,
|
||||
DocumentEntry = r.DocumentEntry,
|
||||
InvoiceNumber = r.InvoiceNumber,
|
||||
PositionOnInvoice = r.PositionOnInvoice,
|
||||
Material = r.Material,
|
||||
Name = r.Name,
|
||||
Quantity = r.Quantity,
|
||||
DocumentType = r.DocumentType,
|
||||
PostingDate = r.PostingDate,
|
||||
InvoiceDate = r.InvoiceDate,
|
||||
ExtractionDate = r.ExtractionDate,
|
||||
CustomerNumber = r.CustomerNumber,
|
||||
CustomerName = r.CustomerName,
|
||||
SupplierCountry = r.SupplierCountry,
|
||||
SalesCurrency = r.SalesCurrency,
|
||||
DocumentCurrency = r.DocumentCurrency,
|
||||
CompanyCurrency = r.CompanyCurrency,
|
||||
SalesPriceValue = r.SalesPriceValue,
|
||||
DocumentTotalForeignCurrency = r.DocumentTotalForeignCurrency,
|
||||
DocumentTotalLocalCurrency = r.DocumentTotalLocalCurrency,
|
||||
VatSumForeignCurrency = r.VatSumForeignCurrency,
|
||||
VatSumLocalCurrency = r.VatSumLocalCurrency
|
||||
})
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
private static NetSalesActualSourceRow? ApplyFinanceRules(SalesRecord record, int year, FinanceRuleEngine financeRuleEngine)
|
||||
{
|
||||
var referenceKey = ResolveReferenceKey(record.Land, record.Tsc);
|
||||
|
||||
@@ -9,16 +9,26 @@ public class ManagementCockpitService : IManagementCockpitService
|
||||
{
|
||||
private readonly IDbContextFactory<AppDbContext> _dbFactory;
|
||||
private readonly ICurrencyExchangeRateService _exchangeRateService;
|
||||
private readonly ICentralSalesDataProvider? _centralSalesDataProvider;
|
||||
|
||||
public ManagementCockpitService(IDbContextFactory<AppDbContext> dbFactory)
|
||||
: this(dbFactory, new CurrencyExchangeRateService(dbFactory))
|
||||
: this(dbFactory, new CurrencyExchangeRateService(dbFactory), null)
|
||||
{
|
||||
}
|
||||
|
||||
public ManagementCockpitService(IDbContextFactory<AppDbContext> dbFactory, ICurrencyExchangeRateService exchangeRateService)
|
||||
: this(dbFactory, exchangeRateService, null)
|
||||
{
|
||||
}
|
||||
|
||||
public ManagementCockpitService(
|
||||
IDbContextFactory<AppDbContext> dbFactory,
|
||||
ICurrencyExchangeRateService exchangeRateService,
|
||||
ICentralSalesDataProvider? centralSalesDataProvider)
|
||||
{
|
||||
_dbFactory = dbFactory;
|
||||
_exchangeRateService = exchangeRateService;
|
||||
_centralSalesDataProvider = centralSalesDataProvider;
|
||||
}
|
||||
|
||||
private static readonly List<ValueFieldDefinition> ValueFieldDefinitions =
|
||||
@@ -166,12 +176,12 @@ public class ManagementCockpitService : IManagementCockpitService
|
||||
|
||||
public async Task<List<int>> GetAvailableCentralYearsAsync()
|
||||
{
|
||||
using var db = await _dbFactory.CreateDbContextAsync();
|
||||
var years = await db.CentralSalesRecords
|
||||
var records = await LoadCentralRecordsAsync();
|
||||
var years = records
|
||||
.Select(r => r.InvoiceDate.HasValue ? r.InvoiceDate.Value.Year : r.ExtractionDate.Year)
|
||||
.Distinct()
|
||||
.OrderBy(x => x)
|
||||
.ToListAsync();
|
||||
.ToList();
|
||||
|
||||
return years;
|
||||
}
|
||||
@@ -186,7 +196,8 @@ public class ManagementCockpitService : IManagementCockpitService
|
||||
using var db = await _dbFactory.CreateDbContextAsync();
|
||||
var settings = await db.ExportSettings.AsNoTracking().FirstOrDefaultAsync() ?? new ExportSettings();
|
||||
var exchangeRateDateField = SettingsPageService.NormalizeExchangeRateDateField(settings.ExchangeRateDateField);
|
||||
var baseRows = await db.CentralSalesRecords
|
||||
var centralRecords = await LoadCentralRecordsAsync();
|
||||
var baseRows = centralRecords
|
||||
.Select(r => new CentralCockpitRow
|
||||
{
|
||||
SourceSystem = r.SourceSystem,
|
||||
@@ -204,7 +215,7 @@ public class ManagementCockpitService : IManagementCockpitService
|
||||
PeriodDate = r.InvoiceDate ?? r.ExtractionDate,
|
||||
ExchangeRateDate = r.ExtractionDate
|
||||
})
|
||||
.ToListAsync();
|
||||
.ToList();
|
||||
|
||||
foreach (var row in baseRows)
|
||||
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)
|
||||
{
|
||||
using var db = await _dbFactory.CreateDbContextAsync();
|
||||
var settings = await db.ExportSettings.AsNoTracking().FirstOrDefaultAsync() ?? new ExportSettings();
|
||||
var financeRules = await db.FinanceRules
|
||||
.AsNoTracking()
|
||||
.Where(rule => rule.IsActive)
|
||||
@@ -332,40 +344,7 @@ public class ManagementCockpitService : IManagementCockpitService
|
||||
.Where(rule => rule.IsActive)
|
||||
.ToListAsync();
|
||||
var financeRuleEngine = new FinanceRuleEngine(financeRules);
|
||||
var records = 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,
|
||||
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();
|
||||
var records = await LoadCentralRecordsAsync();
|
||||
|
||||
if (records.Count == 0)
|
||||
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),
|
||||
StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
var dataStatusRows = await BuildFinanceDataStatusRowsAsync(db);
|
||||
var dataStatusRows = await BuildFinanceDataStatusRowsAsync(db, records, settings.UseAuditCsvAsCentralSource);
|
||||
var countryRows = BuildFinanceCountryStatusRows(scopedRows, referenceByKey);
|
||||
var productAssignmentRows = BuildProductAssignmentRows(scopedRows, allRows);
|
||||
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
|
||||
.AsNoTracking()
|
||||
.OrderBy(site => site.Land)
|
||||
.ThenBy(site => site.TSC)
|
||||
.ToListAsync();
|
||||
var records = await db.CentralSalesRecords
|
||||
.AsNoTracking()
|
||||
.GroupBy(record => record.Tsc)
|
||||
.Select(group => new
|
||||
{
|
||||
Tsc = group.Key,
|
||||
RowCount = group.Count(),
|
||||
LatestStoredAtUtc = group.Max(record => record.StoredAtUtc),
|
||||
LatestExtractionDate = group.Max(record => record.ExtractionDate)
|
||||
})
|
||||
.ToListAsync();
|
||||
var records = useAuditCsv
|
||||
? centralRecords
|
||||
.GroupBy(record => record.Tsc)
|
||||
.Select(group => new
|
||||
{
|
||||
Tsc = group.Key,
|
||||
RowCount = group.Count(),
|
||||
LatestStoredAtUtc = (DateTime?)null,
|
||||
LatestExtractionDate = group.Max(record => record.ExtractionDate)
|
||||
})
|
||||
.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
|
||||
.AsNoTracking()
|
||||
.GroupBy(log => log.TSC)
|
||||
@@ -582,7 +575,7 @@ public class ManagementCockpitService : IManagementCockpitService
|
||||
{
|
||||
Land = site.Land,
|
||||
Tsc = site.TSC,
|
||||
SourceSystem = site.SourceSystem,
|
||||
SourceSystem = useAuditCsv ? $"{site.SourceSystem} / Audit-CSV" : site.SourceSystem,
|
||||
IsActive = site.IsActive,
|
||||
RowCount = record?.RowCount ?? 0,
|
||||
LatestStoredAtUtc = record?.LatestStoredAtUtc,
|
||||
@@ -595,6 +588,63 @@ public class ManagementCockpitService : IManagementCockpitService
|
||||
}).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(
|
||||
IReadOnlyCollection<FinanceAggregationRow> rows,
|
||||
IReadOnlyDictionary<string, decimal?> referenceByKey)
|
||||
|
||||
@@ -108,6 +108,9 @@ public sealed class SettingsPageService : ISettingsPageService
|
||||
existing.DebugLoggingEnabled = settings.DebugLoggingEnabled;
|
||||
existing.LocalSiteExportFolder = settings.LocalSiteExportFolder;
|
||||
existing.LocalConsolidatedExportFolder = settings.LocalConsolidatedExportFolder;
|
||||
existing.AuditCsvEnabled = settings.AuditCsvEnabled;
|
||||
existing.UseAuditCsvAsCentralSource = settings.UseAuditCsvAsCentralSource;
|
||||
existing.LocalAuditCsvFolder = settings.LocalAuditCsvFolder;
|
||||
existing.ExchangeRateDateField = NormalizeExchangeRateDateField(settings.ExchangeRateDateField);
|
||||
}
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ public class SiteExportService : ISiteExportService
|
||||
private readonly ISharePointUploadService _sharePointService;
|
||||
private readonly IRecordTransformationService _transformationService;
|
||||
private readonly ICentralSalesRecordService _centralSalesRecordService;
|
||||
private readonly IExportAuditCsvService _auditCsvService;
|
||||
private readonly IAppEventLogService _appEventLogService;
|
||||
private readonly ILogger<SiteExportService> _logger;
|
||||
|
||||
@@ -24,6 +25,7 @@ public class SiteExportService : ISiteExportService
|
||||
ISharePointUploadService sharePointService,
|
||||
IRecordTransformationService transformationService,
|
||||
ICentralSalesRecordService centralSalesRecordService,
|
||||
IExportAuditCsvService auditCsvService,
|
||||
IAppEventLogService appEventLogService,
|
||||
ILogger<SiteExportService> logger)
|
||||
{
|
||||
@@ -33,6 +35,7 @@ public class SiteExportService : ISiteExportService
|
||||
_sharePointService = sharePointService;
|
||||
_transformationService = transformationService;
|
||||
_centralSalesRecordService = centralSalesRecordService;
|
||||
_auditCsvService = auditCsvService;
|
||||
_appEventLogService = appEventLogService;
|
||||
_logger = logger;
|
||||
}
|
||||
@@ -76,6 +79,15 @@ public class SiteExportService : ISiteExportService
|
||||
details: $"Records vor Transformation={records.Count}");
|
||||
_transformationService.Apply(records, rules);
|
||||
|
||||
var auditCsvPath = await _auditCsvService.WriteSiteAuditCsvAsync(
|
||||
site, settings, sourceSystem, outputDir, records);
|
||||
if (!string.IsNullOrWhiteSpace(auditCsvPath))
|
||||
{
|
||||
await _appEventLogService.WriteAsync("Export", "Audit-CSV geschrieben",
|
||||
siteId: site.Id, land: site.Land,
|
||||
details: auditCsvPath);
|
||||
}
|
||||
|
||||
var filePath = fetchResult.ReferenceFilePath;
|
||||
if (string.IsNullOrWhiteSpace(filePath))
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user