Commit pending finance and Power BI work

This commit is contained in:
2026-05-13 07:33:00 +02:00
parent 1cd0ad998f
commit 001e2a73d5
44 changed files with 3210 additions and 104 deletions
@@ -92,6 +92,7 @@ public class CentralSalesRecordService : ICentralSalesRecordService
CompanyCurrency = r.CompanyCurrency,
Incoterms2020 = r.Incoterms2020,
SalesResponsibleEmployee = r.SalesResponsibleEmployee,
PostingDate = r.PostingDate,
InvoiceDate = r.InvoiceDate,
OrderDate = r.OrderDate,
Land = r.Land,
@@ -167,7 +168,7 @@ public class CentralSalesRecordService : ICentralSalesRecordService
CustomerNumber, CustomerName, CustomerCountry, CustomerIndustry, StandardCost,
StandardCostCurrency, PurchaseOrderNumber, SalesPriceValue, SalesCurrency, Incoterms2020,
DocumentCurrency, DocumentTotalForeignCurrency, DocumentTotalLocalCurrency, VatSumForeignCurrency,
VatSumLocalCurrency, DocumentRate, CompanyCurrency, SalesResponsibleEmployee, InvoiceDate, OrderDate, Land, DocumentType
VatSumLocalCurrency, DocumentRate, CompanyCurrency, SalesResponsibleEmployee, PostingDate, InvoiceDate, OrderDate, Land, DocumentType
)
VALUES (
$storedAtUtc, $siteId, $sourceSystem, $extractionDate, $tsc, $documentEntry, $invoiceNumber, $positionOnInvoice,
@@ -175,7 +176,7 @@ public class CentralSalesRecordService : ICentralSalesRecordService
$customerNumber, $customerName, $customerCountry, $customerIndustry, $standardCost,
$standardCostCurrency, $purchaseOrderNumber, $salesPriceValue, $salesCurrency, $incoterms2020,
$documentCurrency, $documentTotalForeignCurrency, $documentTotalLocalCurrency, $vatSumForeignCurrency,
$vatSumLocalCurrency, $documentRate, $companyCurrency, $salesResponsibleEmployee, $invoiceDate, $orderDate, $land, $documentType
$vatSumLocalCurrency, $documentRate, $companyCurrency, $salesResponsibleEmployee, $postingDate, $invoiceDate, $orderDate, $land, $documentType
);
""";
@@ -212,6 +213,7 @@ public class CentralSalesRecordService : ICentralSalesRecordService
command.Parameters.Add("$companyCurrency", SqliteType.Text);
command.Parameters.Add("$incoterms2020", SqliteType.Text);
command.Parameters.Add("$salesResponsibleEmployee", SqliteType.Text);
command.Parameters.Add("$postingDate", SqliteType.Text);
command.Parameters.Add("$invoiceDate", SqliteType.Text);
command.Parameters.Add("$orderDate", SqliteType.Text);
command.Parameters.Add("$land", SqliteType.Text);
@@ -255,6 +257,7 @@ public class CentralSalesRecordService : ICentralSalesRecordService
command.Parameters["$companyCurrency"].Value = record.CompanyCurrency ?? string.Empty;
command.Parameters["$incoterms2020"].Value = record.Incoterms2020 ?? string.Empty;
command.Parameters["$salesResponsibleEmployee"].Value = record.SalesResponsibleEmployee ?? string.Empty;
command.Parameters["$postingDate"].Value = record.PostingDate?.ToString("O") ?? (object)DBNull.Value;
command.Parameters["$invoiceDate"].Value = record.InvoiceDate?.ToString("O") ?? (object)DBNull.Value;
command.Parameters["$orderDate"].Value = record.OrderDate?.ToString("O") ?? (object)DBNull.Value;
command.Parameters["$land"].Value = record.Land ?? string.Empty;
@@ -413,6 +413,7 @@ public class ConfigTransferService : IConfigTransferService
CompanyCurrency = record.CompanyCurrency,
Incoterms2020 = record.Incoterms2020,
SalesResponsibleEmployee = record.SalesResponsibleEmployee,
PostingDate = record.PostingDate,
InvoiceDate = record.InvoiceDate,
OrderDate = record.OrderDate,
Land = record.Land,
@@ -9,4 +9,5 @@ public sealed class DataSourceFetchContext
public required ExportSettings Settings { get; init; }
public SharePointConfig? SharePointConfig { get; init; }
public Action<string>? UpdateStatus { get; init; }
public int? PreferredImportYear { get; init; }
}
@@ -31,7 +31,7 @@ public sealed class ManualExcelDataSourceAdapter : IDataSourceAdapter
string filePath;
string? localOutputDirectory = null;
string? sharePointUploadFolder = null;
string? tempManualImportPath = null;
var tempManualImportPaths = new List<string>();
try
{
if (File.Exists(manualImportPath))
@@ -59,19 +59,28 @@ public sealed class ManualExcelDataSourceAdapter : IDataSourceAdapter
siteId: site.Id, land: site.Land, details: manualImportPath);
var sharePointFileReference = manualImportPath;
var sharePointFileReferences = new List<string>();
if (LooksLikeSharePointFolderReference(manualImportPath))
{
var latestFile = await _sharePointService.ResolveLatestFileInFolderAsync(
var files = await _sharePointService.ResolveManualImportFilesInFolderAsync(
spConfig.TenantId, spConfig.ClientId, spConfig.ClientSecret,
spConfig.SiteUrl, manualImportPath, site.TSC);
sharePointFileReference = latestFile.FileReference;
spConfig.SiteUrl, manualImportPath, site.TSC, context.PreferredImportYear);
sharePointFileReferences.AddRange(files.Select(file => file.FileReference));
sharePointFileReference = sharePointFileReferences.FirstOrDefault() ?? manualImportPath;
await _appEventLogService.WriteAsync("Export", "Neueste SharePoint-Datei ausgewaehlt",
siteId: site.Id, land: site.Land, details: sharePointFileReference);
siteId: site.Id, land: site.Land, details: string.Join(" | ", sharePointFileReferences));
}
else
{
sharePointFileReferences.Add(sharePointFileReference);
}
tempManualImportPath = await _sharePointService.DownloadToTempFileAsync(
spConfig.TenantId, spConfig.ClientId, spConfig.ClientSecret,
spConfig.SiteUrl, sharePointFileReference);
foreach (var fileReference in sharePointFileReferences)
{
tempManualImportPaths.Add(await _sharePointService.DownloadToTempFileAsync(
spConfig.TenantId, spConfig.ClientId, spConfig.ClientSecret,
spConfig.SiteUrl, fileReference));
}
filePath = sharePointFileReference;
sharePointUploadFolder = ResolveSharePointParentFolder(sharePointFileReference, spConfig.SiteUrl);
}
@@ -81,12 +90,14 @@ public sealed class ManualExcelDataSourceAdapter : IDataSourceAdapter
$"Die manuelle Excel-Datei wurde nicht gefunden: {manualImportPath}");
}
var readPath = tempManualImportPath ?? filePath;
context.UpdateStatus?.Invoke("Manuelle Excel lesen...");
await _appEventLogService.WriteAsync("Export", "Manuelle Excel lesen",
siteId: site.Id, land: site.Land, details: filePath);
var records = await _manualExcelImportService.ReadSalesRecordsAsync(readPath, site);
var records = new List<SalesRecord>();
var readPaths = tempManualImportPaths.Count > 0 ? tempManualImportPaths : [filePath];
foreach (var readPath in readPaths)
records.AddRange(await _manualExcelImportService.ReadSalesRecordsAsync(readPath, site));
return new DataSourceFetchResult
{
Records = records,
@@ -97,8 +108,11 @@ public sealed class ManualExcelDataSourceAdapter : IDataSourceAdapter
}
finally
{
if (!string.IsNullOrWhiteSpace(tempManualImportPath) && File.Exists(tempManualImportPath))
File.Delete(tempManualImportPath);
foreach (var tempManualImportPath in tempManualImportPaths)
{
if (File.Exists(tempManualImportPath))
File.Delete(tempManualImportPath);
}
}
}
@@ -50,7 +50,7 @@ public sealed class SapGatewayDataSourceAdapter : IDataSourceAdapter
var effectiveSite = CloneSiteWithSapServiceUrl(site, sapServiceUrl);
var records = await _sapCompositionService.BuildSalesRecordsAsync(
effectiveSite, sapSources, sapJoins, sapMappings,
credentials.Username, credentials.Password);
credentials.Username, credentials.Password, context.PreferredImportYear);
return new DataSourceFetchResult { Records = records };
}
@@ -113,6 +113,7 @@ CREATE TABLE CentralSalesRecords (
CompanyCurrency TEXT NOT NULL DEFAULT '',
Incoterms2020 TEXT NOT NULL,
SalesResponsibleEmployee TEXT NOT NULL,
PostingDate TEXT NULL,
InvoiceDate TEXT NULL,
OrderDate TEXT NULL,
Land TEXT NOT NULL,
@@ -51,6 +51,7 @@ public class DatabaseSchemaMaintenanceService : IDatabaseSchemaMaintenanceServic
AddColumnIfMissing(db, "CentralSalesRecords", "VatSumLocalCurrency", "TEXT NOT NULL DEFAULT '0'");
AddColumnIfMissing(db, "CentralSalesRecords", "DocumentRate", "TEXT NOT NULL DEFAULT '0'");
AddColumnIfMissing(db, "CentralSalesRecords", "CompanyCurrency", "TEXT NOT NULL DEFAULT ''");
AddColumnIfMissing(db, "CentralSalesRecords", "PostingDate", "TEXT NULL");
EnsureAppEventLogTable(db);
}
@@ -308,12 +308,105 @@ public class DatabaseSeedService : IDatabaseSeedService
}
if (string.Equals(existing.SourceSystem, "MANUAL_EXCEL", StringComparison.OrdinalIgnoreCase) &&
string.IsNullOrWhiteSpace(existing.ManualImportFilePath))
(string.IsNullOrWhiteSpace(existing.ManualImportFilePath) ||
existing.ManualImportFilePath.Contains("/England", StringComparison.OrdinalIgnoreCase)))
{
existing.ManualImportFilePath = "https://trafagag.sharepoint.com/sites/WorldwideBIPlatform/Import/Finance/UK_B1";
changed = true;
}
if (changed)
db.SaveChanges();
if (CanSeedSiteDependentTable(db, "ManualExcelColumnMappings"))
EnsureUkManualExcelMapping(db, existing.Id);
}
private static bool CanSeedSiteDependentTable(AppDbContext db, string tableName)
{
var conn = db.Database.GetDbConnection();
if (conn.State != System.Data.ConnectionState.Open)
conn.Open();
var columns = DatabaseSchemaTools.GetTableColumns(conn, transaction: null, tableName);
if (columns.Count == 0)
return false;
return !DatabaseSchemaTools.TableReferences(conn, tableName, "Sites_old") &&
!DatabaseSchemaTools.TableReferencesObsoleteTable(conn, tableName, "Sites");
}
private static void EnsureUkManualExcelMapping(AppDbContext db, int siteId)
{
var mappings = new (string Target, string Source, bool Required)[]
{
(nameof(SalesRecord.Tsc), "TSC", false),
(nameof(SalesRecord.Land), "Land", false),
(nameof(SalesRecord.InvoiceNumber), "Invoice Number", true),
(nameof(SalesRecord.PositionOnInvoice), "Position on invoice", false),
(nameof(SalesRecord.Material), "Material", false),
(nameof(SalesRecord.Name), "Name", false),
(nameof(SalesRecord.ProductGroup), "Product Group", false),
(nameof(SalesRecord.Quantity), "Quantity", true),
(nameof(SalesRecord.CustomerNumber), "Customer number", false),
(nameof(SalesRecord.CustomerName), "Customer name", false),
(nameof(SalesRecord.CustomerCountry), "Customer country", false),
(nameof(SalesRecord.SalesPriceValue), "=[Sales Price/Value]*[Quantity]", true),
(nameof(SalesRecord.SalesCurrency), "=GBP", false),
(nameof(SalesRecord.DocumentCurrency), "=GBP", false),
(nameof(SalesRecord.CompanyCurrency), "=GBP", false),
(nameof(SalesRecord.PostingDate), "invoice date", false),
(nameof(SalesRecord.InvoiceDate), "invoice date", false),
(nameof(SalesRecord.DocumentType), "=Manual Excel", false)
};
var changed = false;
for (var i = 0; i < mappings.Length; i++)
{
var mapping = db.ManualExcelColumnMappings
.OrderBy(x => x.Id)
.FirstOrDefault(x => x.SiteId == siteId && x.TargetField == mappings[i].Target);
if (mapping is null)
{
db.ManualExcelColumnMappings.Add(new ManualExcelColumnMapping
{
SiteId = siteId,
TargetField = mappings[i].Target,
SourceHeader = mappings[i].Source,
IsRequired = mappings[i].Required,
IsActive = true,
SortOrder = i
});
changed = true;
continue;
}
if (mapping.SourceHeader != mappings[i].Source)
{
mapping.SourceHeader = mappings[i].Source;
changed = true;
}
if (mapping.IsRequired != mappings[i].Required)
{
mapping.IsRequired = mappings[i].Required;
changed = true;
}
if (!mapping.IsActive)
{
mapping.IsActive = true;
changed = true;
}
if (mapping.SortOrder != i)
{
mapping.SortOrder = i;
changed = true;
}
}
if (changed)
db.SaveChanges();
}
@@ -386,7 +479,7 @@ public class DatabaseSeedService : IDatabaseSeedService
{
SiteId = siteId,
Alias = "Z",
EntitySet = "ZSCHWEIZSet",
EntitySet = "FinanzdataSchweizOeSet",
IsPrimary = true,
IsActive = true,
SortOrder = 0
@@ -395,9 +488,9 @@ public class DatabaseSeedService : IDatabaseSeedService
}
else
{
if (source.EntitySet != "ZSCHWEIZSet")
if (source.EntitySet != "FinanzdataSchweizOeSet")
{
source.EntitySet = "ZSCHWEIZSet";
source.EntitySet = "FinanzdataSchweizOeSet";
changed = true;
}
@@ -420,33 +513,52 @@ public class DatabaseSeedService : IDatabaseSeedService
}
}
var obsoleteSources = db.SapSourceDefinitions
.Where(x => x.SiteId == siteId && x.Alias != "Z")
.ToList();
foreach (var obsoleteSource in obsoleteSources)
{
if (obsoleteSource.IsActive)
{
obsoleteSource.IsActive = false;
changed = true;
}
if (obsoleteSource.IsPrimary)
{
obsoleteSource.IsPrimary = false;
changed = true;
}
}
var mappings = new (string Target, string Source, bool Required)[]
{
(nameof(SalesRecord.Tsc), "Z.TSC", true),
(nameof(SalesRecord.Land), "Z.LAND1", true),
(nameof(SalesRecord.DocumentEntry), "Z.VBELN", false),
(nameof(SalesRecord.InvoiceNumber), "Z.VBELN", true),
(nameof(SalesRecord.PositionOnInvoice), "Z.POSNR", true),
(nameof(SalesRecord.InvoiceDate), "Z.FKDAT", true),
(nameof(SalesRecord.Material), "Z.MATNR", false),
(nameof(SalesRecord.Name), "Z.ARKTX", false),
(nameof(SalesRecord.ProductGroup), "Z.PRODH", false),
(nameof(SalesRecord.Quantity), "Z.FKIMG", false),
(nameof(SalesRecord.CustomerNumber), "Z.KUNNR", false),
(nameof(SalesRecord.CustomerName), "Z.NAME1", false),
(nameof(SalesRecord.CustomerCountry), "Z.CUSTOMER_LAND", false),
(nameof(SalesRecord.Tsc), "Z.Tsc", true),
(nameof(SalesRecord.Land), "Z.Land1", true),
(nameof(SalesRecord.DocumentEntry), "Z.Vbeln", false),
(nameof(SalesRecord.InvoiceNumber), "Z.Vbeln", true),
(nameof(SalesRecord.PositionOnInvoice), "Z.Posnr", true),
(nameof(SalesRecord.PostingDate), "Z.Fkdat", true),
(nameof(SalesRecord.InvoiceDate), "Z.Fkdat", true),
(nameof(SalesRecord.Material), "Z.Matnr", false),
(nameof(SalesRecord.Name), "Z.Arktx", false),
(nameof(SalesRecord.ProductGroup), "Z.Prodh", false),
(nameof(SalesRecord.Quantity), "Z.Fkimg", false),
(nameof(SalesRecord.CustomerNumber), "Z.Kunnr", false),
(nameof(SalesRecord.CustomerName), "Z.Name1", false),
(nameof(SalesRecord.CustomerCountry), "Z.CustomerLand", false),
(nameof(SalesRecord.StandardCost), "=0", false),
(nameof(SalesRecord.StandardCostCurrency), "Z.HWAER", false),
(nameof(SalesRecord.SalesPriceValue), "Z.NETWR_HC", true),
(nameof(SalesRecord.SalesCurrency), "Z.HWAER", true),
(nameof(SalesRecord.DocumentCurrency), "Z.WAERK", false),
(nameof(SalesRecord.DocumentTotalForeignCurrency), "Z.NETWR_DC", false),
(nameof(SalesRecord.DocumentTotalLocalCurrency), "Z.NETWR_HC", false),
(nameof(SalesRecord.VatSumForeignCurrency), "Z.TAX_DC", false),
(nameof(SalesRecord.VatSumLocalCurrency), "Z.TAX_HC", false),
(nameof(SalesRecord.DocumentRate), "Z.KURRF", false),
(nameof(SalesRecord.CompanyCurrency), "Z.HWAER", true),
(nameof(SalesRecord.DocumentType), "Z.FKART", false)
(nameof(SalesRecord.StandardCostCurrency), "Z.Hwaer", false),
(nameof(SalesRecord.SalesPriceValue), "Z.NetwrHc", true),
(nameof(SalesRecord.SalesCurrency), "Z.Hwaer", true),
(nameof(SalesRecord.DocumentCurrency), "Z.Waerk", false),
(nameof(SalesRecord.DocumentTotalForeignCurrency), "Z.NetwrDc", false),
(nameof(SalesRecord.DocumentTotalLocalCurrency), "Z.NetwrHc", false),
(nameof(SalesRecord.VatSumForeignCurrency), "=0", false),
(nameof(SalesRecord.VatSumLocalCurrency), "=0", false),
(nameof(SalesRecord.DocumentRate), "Z.Kurrf", false),
(nameof(SalesRecord.CompanyCurrency), "Z.Hwaer", true),
(nameof(SalesRecord.DocumentType), "Z.Fkart", false)
};
for (var i = 0; i < mappings.Length; i++)
@@ -70,6 +70,7 @@ public class ExcelExportService : IExcelExportService
"Company Currency",
"Incoterms 2020",
"Sales responsible employee",
"posting date",
"invoice date",
"order date",
"Land",
@@ -115,10 +116,11 @@ public class ExcelExportService : IExcelExportService
ws.Cell(row, 28).Value = record.CompanyCurrency;
ws.Cell(row, 29).Value = record.Incoterms2020;
ws.Cell(row, 30).Value = record.SalesResponsibleEmployee;
ws.Cell(row, 31).Value = record.InvoiceDate?.ToString("dd.MM.yyyy") ?? string.Empty;
ws.Cell(row, 32).Value = record.OrderDate?.ToString("dd.MM.yyyy") ?? string.Empty;
ws.Cell(row, 33).Value = record.Land;
ws.Cell(row, 34).Value = record.DocumentType;
ws.Cell(row, 31).Value = record.PostingDate?.ToString("dd.MM.yyyy") ?? string.Empty;
ws.Cell(row, 32).Value = record.InvoiceDate?.ToString("dd.MM.yyyy") ?? string.Empty;
ws.Cell(row, 33).Value = record.OrderDate?.ToString("dd.MM.yyyy") ?? string.Empty;
ws.Cell(row, 34).Value = record.Land;
ws.Cell(row, 35).Value = record.DocumentType;
row++;
}
@@ -81,15 +81,15 @@ public class ExportOrchestrationService
return await RunConsolidatedExportAsync();
}
public async Task<SiteExportResult?> ExportSiteByIdAsync(int siteId)
public async Task<SiteExportResult?> ExportSiteByIdAsync(int siteId, int? preferredImportYear = null)
{
using var db = await _dbFactory.CreateDbContextAsync();
var site = await db.Sites.Include(s => s.HanaServer).FirstOrDefaultAsync(s => s.Id == siteId);
if (site is null) return null;
return await ExportSiteAsync(site);
return await ExportSiteAsync(site, preferredImportYear);
}
private async Task<SiteExportResult?> ExportSiteAsync(Site site)
private async Task<SiteExportResult?> ExportSiteAsync(Site site, int? preferredImportYear = null)
{
SiteExportResult? result = null;
@@ -102,7 +102,7 @@ public class ExportOrchestrationService
try
{
result = await _siteExportService.ExportAsync(site, status => UpdateStatus(site.Id, status));
result = await _siteExportService.ExportAsync(site, status => UpdateStatus(site.Id, status), preferredImportYear);
return result;
}
finally
@@ -34,13 +34,16 @@ public sealed class FinanceReconciliationService : IFinanceReconciliationService
var centralRows = await db.CentralSalesRecords
.AsNoTracking()
.Where(r => (r.InvoiceDate ?? r.ExtractionDate).Year == year)
.Where(r => (r.PostingDate ?? r.InvoiceDate ?? r.ExtractionDate).Year == year)
.Select(r => new NetSalesActualSourceRow(
r.Land,
r.Tsc,
r.DocumentEntry,
r.InvoiceNumber,
r.DocumentType,
r.PostingDate,
r.InvoiceDate,
r.ExtractionDate,
r.CustomerNumber,
r.CustomerName,
r.SalesCurrency,
@@ -57,7 +60,7 @@ public sealed class FinanceReconciliationService : IFinanceReconciliationService
.GroupBy(r => ResolveReferenceKey(r.Land, r.Tsc), StringComparer.OrdinalIgnoreCase)
.ToDictionary(
g => g.Key,
rows => BuildNetSalesActual(rows, budgetRatesToChf, intercompanyRules),
g => BuildNetSalesActual(g.Key, g, budgetRatesToChf, intercompanyRules),
StringComparer.OrdinalIgnoreCase);
return financeReferences
@@ -73,7 +76,9 @@ public sealed class FinanceReconciliationService : IFinanceReconciliationService
groupedActuals.TryGetValue(reference.Key, out var actual);
var referenceValue = reference.CheckValue ?? reference.LocalCurrencyValue;
var selected = actual?.Candidates
.OrderByDescending(candidate => candidate.Key == "NetDocumentLocalCurrency")
.OrderByDescending(candidate => candidate.IsPreferred)
.ThenByDescending(candidate => candidate.Key == "NetDocumentLocalCurrencyPosition")
.ThenByDescending(candidate => candidate.Key == "NetDocumentLocalCurrencyDocument")
.ThenByDescending(candidate => candidate.Key == "SalesPriceValue")
.FirstOrDefault();
var difference = selected is null || !referenceValue.HasValue ? (decimal?)null : selected.Value - referenceValue.Value;
@@ -106,6 +111,7 @@ public sealed class FinanceReconciliationService : IFinanceReconciliationService
Value = candidate.Value,
IntercompanyValue = candidate.IntercompanyValue,
ValueExcludingIntercompany = candidate.ValueExcludingIntercompany,
IsPreferred = candidate.IsPreferred,
Difference = referenceValue.HasValue ? candidate.Value - referenceValue.Value : null,
DifferenceExcludingIntercompany = referenceValue.HasValue
? candidate.ValueExcludingIntercompany - referenceValue.Value
@@ -139,24 +145,30 @@ public sealed class FinanceReconciliationService : IFinanceReconciliationService
}
private static NetSalesActual BuildNetSalesActual(
string referenceKey,
IEnumerable<NetSalesActualSourceRow> rows,
IReadOnlyDictionary<string, decimal> budgetRatesToChf,
IReadOnlyList<FinanceIntercompanyRule> intercompanyRules)
{
var rowList = rows.ToList();
var houseCurrency = ResolveHouseCurrency(referenceKey, rowList);
var documentRows = rowList
.GroupBy(row => BuildDocumentKey(row.Tsc, row.DocumentType, row.DocumentEntry, row.InvoiceNumber), StringComparer.OrdinalIgnoreCase)
.Select(g => g.First())
.ToList();
var repeatedDocumentTotals = LooksLikeRepeatedDocumentTotals(rowList);
var salesPriceValue = rowList.Sum(row => row.SalesPriceValue);
var salesPriceIntercompanyValue = rowList.Where(row => IsIntercompanyCustomer(row, intercompanyRules)).Sum(row => row.SalesPriceValue);
var candidates = new List<NetSalesCandidate>
{
new(
"SalesPriceValue",
"Sales Price/Value",
ResolveCurrencyLabel(rowList.Select(row => row.SalesCurrency)),
rowList.Sum(row => row.SalesPriceValue),
rowList.Where(row => IsIntercompanyCustomer(row, intercompanyRules)).Sum(row => row.SalesPriceValue))
"Positions-Netto (Sales Price/Value)",
houseCurrency,
salesPriceValue,
salesPriceIntercompanyValue,
repeatedDocumentTotals && salesPriceValue != 0m)
};
var netDocumentForeignCurrency = documentRows.Sum(row => row.DocumentTotalForeignCurrency - row.VatSumForeignCurrency);
@@ -166,46 +178,100 @@ public sealed class FinanceReconciliationService : IFinanceReconciliationService
"DocTotalFC - VatSumFC",
ResolveCurrencyLabel(rowList.Select(row => row.DocumentCurrency)),
netDocumentForeignCurrency,
documentRows.Where(row => IsIntercompanyCustomer(row, intercompanyRules)).Sum(row => row.DocumentTotalForeignCurrency - row.VatSumForeignCurrency)));
documentRows.Where(row => IsIntercompanyCustomer(row, intercompanyRules)).Sum(row => row.DocumentTotalForeignCurrency - row.VatSumForeignCurrency),
false));
var positionNetDocumentLocalCurrency = rowList.Sum(row => row.DocumentTotalLocalCurrency - row.VatSumLocalCurrency);
if (positionNetDocumentLocalCurrency != 0m)
candidates.Add(new(
"NetDocumentLocalCurrencyPosition",
"Nettofakturawert Hauswaehrung pro Position",
houseCurrency,
positionNetDocumentLocalCurrency,
rowList.Where(row => IsIntercompanyCustomer(row, intercompanyRules)).Sum(row => row.DocumentTotalLocalCurrency - row.VatSumLocalCurrency),
!repeatedDocumentTotals));
var netDocumentLocalCurrency = documentRows.Sum(row => row.DocumentTotalLocalCurrency - row.VatSumLocalCurrency);
if (netDocumentLocalCurrency != 0m)
candidates.Add(new(
"NetDocumentLocalCurrency",
"Nettofakturawert Hauswaehrung",
ResolveCurrencyLabel(rowList.Select(row => row.CompanyCurrency)),
"NetDocumentLocalCurrencyDocument",
"Nettofakturawert Hauswaehrung pro Beleg dedupliziert",
houseCurrency,
netDocumentLocalCurrency,
documentRows.Where(row => IsIntercompanyCustomer(row, intercompanyRules)).Sum(row => row.DocumentTotalLocalCurrency - row.VatSumLocalCurrency)));
documentRows.Where(row => IsIntercompanyCustomer(row, intercompanyRules)).Sum(row => row.DocumentTotalLocalCurrency - row.VatSumLocalCurrency),
repeatedDocumentTotals && salesPriceValue == 0m));
var selectedNetRows = repeatedDocumentTotals ? documentRows : rowList;
var budgetChf = selectedNetRows.Sum(row => ConvertHouseCurrencyNetToBudgetChf(houseCurrency, row, row.DocumentTotalLocalCurrency - row.VatSumLocalCurrency, budgetRatesToChf));
var budgetChf = documentRows.Sum(row => ConvertHouseCurrencyNetToBudgetChf(row, row.DocumentTotalLocalCurrency - row.VatSumLocalCurrency, budgetRatesToChf));
if (budgetChf != 0m)
candidates.Add(new(
"NetDocumentLocalCurrencyBudgetChf",
"Nettofakturawert Hauswaehrung -> CHF Budget 2025",
$"Nettofakturawert Hauswaehrung -> CHF Budget 2025 ({(repeatedDocumentTotals ? "Beleg" : "Position")})",
"CHF",
budgetChf,
documentRows.Where(row => IsIntercompanyCustomer(row, intercompanyRules)).Sum(row => ConvertHouseCurrencyNetToBudgetChf(row, row.DocumentTotalLocalCurrency - row.VatSumLocalCurrency, budgetRatesToChf))));
selectedNetRows.Where(row => IsIntercompanyCustomer(row, intercompanyRules)).Sum(row => ConvertHouseCurrencyNetToBudgetChf(houseCurrency, row, row.DocumentTotalLocalCurrency - row.VatSumLocalCurrency, budgetRatesToChf)),
false));
return new NetSalesActual
{
RowCount = rowList.Count,
Currencies = string.Join(", ", rowList.Select(row => string.IsNullOrWhiteSpace(row.CompanyCurrency) ? row.SalesCurrency : row.CompanyCurrency)
.Where(x => !string.IsNullOrWhiteSpace(x))
.Distinct(StringComparer.OrdinalIgnoreCase)
.OrderBy(x => x, StringComparer.OrdinalIgnoreCase)),
Currencies = houseCurrency,
Candidates = candidates
};
}
private static bool LooksLikeRepeatedDocumentTotals(IReadOnlyList<NetSalesActualSourceRow> rows)
{
var multiLineGroups = rows
.GroupBy(row => BuildDocumentKey(row.Tsc, row.DocumentType, row.DocumentEntry, row.InvoiceNumber), StringComparer.OrdinalIgnoreCase)
.Where(group => group.Count() > 1)
.ToList();
if (multiLineGroups.Count == 0)
return false;
var repeatedGroups = multiLineGroups.Count(group =>
group.Select(row => Math.Round(row.DocumentTotalLocalCurrency - row.VatSumLocalCurrency, 2))
.Distinct()
.Count() == 1);
return repeatedGroups / (decimal)multiLineGroups.Count >= 0.8m;
}
private static decimal ConvertHouseCurrencyNetToBudgetChf(
string houseCurrency,
NetSalesActualSourceRow row,
decimal value,
IReadOnlyDictionary<string, decimal> budgetRatesToChf)
{
var currency = (row.CompanyCurrency ?? string.Empty).Trim().ToUpperInvariant();
var currency = !string.IsNullOrWhiteSpace(houseCurrency) && houseCurrency != "-"
? houseCurrency.Trim().ToUpperInvariant()
: (row.CompanyCurrency ?? string.Empty).Trim().ToUpperInvariant();
return budgetRatesToChf.TryGetValue(currency, out var rate) ? value * rate : 0m;
}
private static string ResolveHouseCurrency(string referenceKey, IReadOnlyList<NetSalesActualSourceRow> rows)
{
var configured = referenceKey.ToUpperInvariant() switch
{
"CH" => "CHF",
"AT" => "EUR",
"DE" => "EUR",
"ES" => "EUR",
"FR" => "EUR",
"IN" => "INR",
"IT" => "EUR",
"UK" => "GBP",
"US" => "USD",
_ => string.Empty
};
return string.IsNullOrWhiteSpace(configured)
? ResolveCurrencyLabel(rows.Select(row => string.IsNullOrWhiteSpace(row.CompanyCurrency) ? row.SalesCurrency : row.CompanyCurrency))
: configured;
}
private static bool IsIntercompanyCustomer(NetSalesActualSourceRow row, IReadOnlyList<FinanceIntercompanyRule> rules)
{
var customerNumber = row.CustomerNumber?.Trim() ?? string.Empty;
@@ -315,6 +381,7 @@ public sealed class NetSalesCandidateRow
public decimal Value { get; set; }
public decimal IntercompanyValue { get; set; }
public decimal ValueExcludingIntercompany { get; set; }
public bool IsPreferred { get; set; }
public decimal? Difference { get; set; }
public decimal? DifferenceExcludingIntercompany { get; set; }
}
@@ -332,6 +399,9 @@ internal sealed record NetSalesActualSourceRow(
int DocumentEntry,
string InvoiceNumber,
string DocumentType,
DateTime? PostingDate,
DateTime? InvoiceDate,
DateTime ExtractionDate,
string CustomerNumber,
string CustomerName,
string SalesCurrency,
@@ -343,7 +413,7 @@ internal sealed record NetSalesActualSourceRow(
decimal VatSumForeignCurrency,
decimal VatSumLocalCurrency);
internal sealed record NetSalesCandidate(string Key, string Label, string Currency, decimal Value, decimal IntercompanyValue)
internal sealed record NetSalesCandidate(string Key, string Label, string Currency, decimal Value, decimal IntercompanyValue, bool IsPreferred)
{
public decimal ValueExcludingIntercompany => Value - IntercompanyValue;
}
@@ -267,6 +267,7 @@ public class HanaQueryService : IHanaQueryService
DocumentEntry = Convert.ToInt32(reader["document_entry"]),
InvoiceNumber = reader["invoice_number"]?.ToString() ?? string.Empty,
PositionOnInvoice = Convert.ToInt32(reader["invoice_position"]),
PostingDate = reader.IsDBNull(reader.GetOrdinal("posting_date")) ? null : reader.GetDateTime(reader.GetOrdinal("posting_date")),
InvoiceDate = reader.IsDBNull(reader.GetOrdinal("invoice_date")) ? null : reader.GetDateTime(reader.GetOrdinal("invoice_date")),
Material = reader["material"]?.ToString() ?? string.Empty,
Name = reader["material_name"]?.ToString() ?? string.Empty,
@@ -373,7 +374,8 @@ SELECT
h.""DocEntry"" AS document_entry,
h.""DocNum"" AS invoice_number,
p.""LineNum"" AS invoice_position,
h.""DocDate"" AS invoice_date,
h.""DocDate"" AS posting_date,
h.""TaxDate"" AS invoice_date,
p.""ItemCode"" AS material,
p.""Dscription"" AS material_name,
COALESCE(grp.""ItmsGrpNam"", '') AS product_group,
@@ -391,7 +393,7 @@ SELECT
THEN CAST(p.""BaseRef"" AS NVARCHAR(20))
ELSE '' END AS purchase_order_number,
p.""LineTotal"" AS sales_value,
COALESCE(p.""Currency"", h.""DocCur"") AS sales_currency,
COALESCE(adm.""MainCurncy"", '') AS sales_currency,
COALESCE(h.""DocCur"", '') AS document_currency,
COALESCE(h.""DocTotalFC"", 0) AS document_total_fc,
COALESCE(h.""DocTotal"", 0) AS document_total_lc,
@@ -434,7 +436,8 @@ SELECT
h.""DocEntry"" AS document_entry,
h.""DocNum"" AS invoice_number,
p.""LineNum"" AS invoice_position,
h.""DocDate"" AS invoice_date,
h.""DocDate"" AS posting_date,
h.""TaxDate"" AS invoice_date,
p.""ItemCode"" AS material,
p.""Dscription"" AS material_name,
COALESCE(grp.""ItmsGrpNam"", '') AS product_group,
@@ -450,7 +453,7 @@ SELECT
COALESCE(adm.""MainCurncy"", '') AS standard_cost_currency,
'' AS purchase_order_number,
p.""LineTotal"" * -1 AS sales_value,
COALESCE(p.""Currency"", h.""DocCur"") AS sales_currency,
COALESCE(adm.""MainCurncy"", '') AS sales_currency,
COALESCE(h.""DocCur"", '') AS document_currency,
COALESCE(h.""DocTotalFC"", 0) * -1 AS document_total_fc,
COALESCE(h.""DocTotal"", 0) * -1 AS document_total_lc,
@@ -11,5 +11,6 @@ public interface ISapCompositionService
IReadOnlyList<SapFieldMapping> mappings,
string username,
string password,
int? preferredYear = null,
CancellationToken cancellationToken = default);
}
@@ -5,5 +5,5 @@ public interface ISapGatewayService
Task TestConnectionAsync(string serviceUrl, string username, string password, CancellationToken cancellationToken = default);
Task<List<string>> GetEntitySetsAsync(string serviceUrl, string username, string password, CancellationToken cancellationToken = default);
Task<List<string>> GetEntityFieldNamesAsync(string serviceUrl, string entitySet, string username, string password, CancellationToken cancellationToken = default);
Task<List<Dictionary<string, object?>>> GetEntityRowsAsync(string serviceUrl, string entitySet, string username, string password, CancellationToken cancellationToken = default);
Task<List<Dictionary<string, object?>>> GetEntityRowsAsync(string serviceUrl, string entitySet, string username, string password, string? filter = null, CancellationToken cancellationToken = default);
}
@@ -4,7 +4,8 @@ public interface ISharePointUploadService
{
Task UploadAsync(string tenantId, string clientId, string clientSecret, string siteUrl, string exportFolder, string land, string localFilePath);
Task<string> DownloadToTempFileAsync(string tenantId, string clientId, string clientSecret, string siteUrl, string fileReference);
Task<SharePointFileReference> ResolveLatestFileInFolderAsync(string tenantId, string clientId, string clientSecret, string siteUrl, string folderReference, string siteTsc);
Task<SharePointFileReference> ResolveLatestFileInFolderAsync(string tenantId, string clientId, string clientSecret, string siteUrl, string folderReference, string siteTsc, int? preferredYear = null);
Task<IReadOnlyList<SharePointFileReference>> ResolveManualImportFilesInFolderAsync(string tenantId, string clientId, string clientSecret, string siteUrl, string folderReference, string siteTsc, int? preferredYear = null);
Task TestConnectionAsync(string tenantId, string clientId, string clientSecret, string siteUrl);
}
@@ -4,5 +4,5 @@ namespace TrafagSalesExporter.Services;
public interface ISiteExportService
{
Task<SiteExportResult> ExportAsync(Site site, Action<string>? updateStatus = null);
Task<SiteExportResult> ExportAsync(Site site, Action<string>? updateStatus = null, int? preferredImportYear = null);
}
@@ -50,7 +50,11 @@ public class ManualExcelImportService : IManualExcelImportService
["companycurrency"] = nameof(SalesRecord.CompanyCurrency),
["incoterms2020"] = nameof(SalesRecord.Incoterms2020),
["salesresponsibleemployee"] = nameof(SalesRecord.SalesResponsibleEmployee),
["postingdate"] = nameof(SalesRecord.PostingDate),
["buchungsdatum"] = nameof(SalesRecord.PostingDate),
["lineregistrationdate"] = nameof(SalesRecord.PostingDate),
["invoicedate"] = nameof(SalesRecord.InvoiceDate),
["fakturadatum"] = nameof(SalesRecord.InvoiceDate),
["orderdate"] = nameof(SalesRecord.OrderDate),
["land"] = nameof(SalesRecord.Land),
["documenttype"] = nameof(SalesRecord.DocumentType)
@@ -180,6 +184,7 @@ public class ManualExcelImportService : IManualExcelImportService
CompanyCurrency = ReadString(headerIndexes, fields, nameof(SalesRecord.CompanyCurrency)),
Incoterms2020 = ReadString(headerIndexes, fields, nameof(SalesRecord.Incoterms2020)),
SalesResponsibleEmployee = ReadString(headerIndexes, fields, nameof(SalesRecord.SalesResponsibleEmployee)),
PostingDate = ReadDate(headerIndexes, fields, nameof(SalesRecord.PostingDate)),
InvoiceDate = ReadDate(headerIndexes, fields, nameof(SalesRecord.InvoiceDate)),
OrderDate = ReadDate(headerIndexes, fields, nameof(SalesRecord.OrderDate)),
Land = ReadString(headerIndexes, fields, nameof(SalesRecord.Land), site.Land),
@@ -290,6 +295,7 @@ public class ManualExcelImportService : IManualExcelImportService
CompanyCurrency = ReadString(headerIndexes, row, nameof(SalesRecord.CompanyCurrency)),
Incoterms2020 = ReadString(headerIndexes, row, nameof(SalesRecord.Incoterms2020)),
SalesResponsibleEmployee = ReadString(headerIndexes, row, nameof(SalesRecord.SalesResponsibleEmployee)),
PostingDate = ReadDate(headerIndexes, row, nameof(SalesRecord.PostingDate)),
InvoiceDate = ReadDate(headerIndexes, row, nameof(SalesRecord.InvoiceDate)),
OrderDate = ReadDate(headerIndexes, row, nameof(SalesRecord.OrderDate)),
Land = ReadString(headerIndexes, row, nameof(SalesRecord.Land), site.Land),
@@ -442,7 +448,9 @@ public class ManualExcelImportService : IManualExcelImportService
{
var trimmed = sourceHeader.Trim();
if (trimmed.StartsWith('='))
return trimmed[1..];
return EvaluateMappedExpression(trimmed[1..], headerIndexes, header => TryResolveHeaderIndex(headerIndexes, header, out var index)
? row.Cell(index).GetFormattedString().Trim()
: null);
return TryResolveHeaderIndex(headerIndexes, trimmed, out var index)
? row.Cell(index).GetFormattedString().Trim()
@@ -453,13 +461,41 @@ public class ManualExcelImportService : IManualExcelImportService
{
var trimmed = sourceHeader.Trim();
if (trimmed.StartsWith('='))
return trimmed[1..];
return EvaluateMappedExpression(trimmed[1..], headerIndexes, header => TryResolveHeaderIndex(headerIndexes, header, out var index) && index < fields.Length
? fields[index].Trim()
: null);
return TryResolveHeaderIndex(headerIndexes, trimmed, out var index) && index < fields.Length
? fields[index].Trim()
: null;
}
private static object? EvaluateMappedExpression(string expression, Dictionary<string, int> headerIndexes, Func<string, string?> readHeader)
{
if (!expression.Contains('[') || !expression.Contains(']'))
return expression;
var parts = expression.Split('*', 2, StringSplitOptions.TrimEntries);
if (parts.Length != 2)
return expression;
var left = ResolveExpressionOperand(parts[0], headerIndexes, readHeader);
var right = ResolveExpressionOperand(parts[1], headerIndexes, readHeader);
return left * right;
}
private static decimal ResolveExpressionOperand(string operand, Dictionary<string, int> headerIndexes, Func<string, string?> readHeader)
{
var trimmed = operand.Trim();
if (trimmed.StartsWith('[') && trimmed.EndsWith(']'))
{
var header = trimmed[1..^1].Trim();
return ParseDecimal(readHeader(header) ?? string.Empty);
}
return ParseDecimal(trimmed);
}
private static bool IsRowEmpty(IXLRangeRow row)
=> row.CellsUsed().All(cell => string.IsNullOrWhiteSpace(cell.GetFormattedString()));
@@ -25,6 +25,7 @@ public class SapCompositionService : ISapCompositionService
IReadOnlyList<SapFieldMapping> mappings,
string username,
string password,
int? preferredYear = null,
CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(site.SapServiceUrl))
@@ -44,7 +45,8 @@ public class SapCompositionService : ISapCompositionService
{
await _appEventLogService.WriteDebugAsync("SAP", "Quelle wird gelesen", site.Id, site.Land,
$"Alias={source.Alias} | EntitySet={source.EntitySet}");
var rows = await _sapGatewayService.GetEntityRowsAsync(site.SapServiceUrl, source.EntitySet, username, password, cancellationToken);
var filter = BuildODataYearFilter(source.EntitySet, preferredYear);
var rows = await _sapGatewayService.GetEntityRowsAsync(site.SapServiceUrl, source.EntitySet, username, password, filter, cancellationToken);
sourceRows[source.Alias] = rows;
await _appEventLogService.WriteDebugAsync("SAP", "Quelle gelesen", site.Id, site.Land,
$"Alias={source.Alias} | EntitySet={source.EntitySet} | Zeilen={rows.Count}");
@@ -57,4 +59,14 @@ public class SapCompositionService : ISapCompositionService
$"SalesRecords={result.Count} | Mappings={mappings.Count(x => x.IsActive)}");
return result;
}
private static string? BuildODataYearFilter(string entitySet, int? preferredYear)
{
if (preferredYear is null)
return null;
return string.Equals(entitySet, "FinanzdataSchweizOeSet", StringComparison.OrdinalIgnoreCase)
? $"Gjahr eq '{preferredYear.Value}'"
: null;
}
}
@@ -87,10 +87,13 @@ public class SapGatewayService : ISapGatewayService
.ToList();
}
public async Task<List<Dictionary<string, object?>>> GetEntityRowsAsync(string serviceUrl, string entitySet, string username, string password, CancellationToken cancellationToken = default)
public async Task<List<Dictionary<string, object?>>> GetEntityRowsAsync(string serviceUrl, string entitySet, string username, string password, string? filter = null, CancellationToken cancellationToken = default)
{
using var client = CreateClient(username, password);
var requestUrl = $"{BuildServiceUri(serviceUrl)}{entitySet}?$format=json";
var query = string.IsNullOrWhiteSpace(filter)
? "$format=json"
: $"$format=json&$filter={Uri.EscapeDataString(filter)}";
var requestUrl = $"{BuildServiceUri(serviceUrl)}{entitySet}?{query}";
await _appEventLogService.WriteAsync("SAP", "Entity-Read gestartet", details: requestUrl);
using var response = await client.GetAsync(requestUrl, cancellationToken);
response.EnsureSuccessStatusCode();
@@ -91,7 +91,22 @@ public class SharePointUploadService : ISharePointUploadService
string clientSecret,
string siteUrl,
string folderReference,
string siteTsc)
string siteTsc,
int? preferredYear = null)
{
var files = await ResolveManualImportFilesInFolderAsync(
tenantId, clientId, clientSecret, siteUrl, folderReference, siteTsc, preferredYear);
return files.First();
}
public async Task<IReadOnlyList<SharePointFileReference>> ResolveManualImportFilesInFolderAsync(
string tenantId,
string clientId,
string clientSecret,
string siteUrl,
string folderReference,
string siteTsc,
int? preferredYear = null)
{
var normalizedTenantId = Normalize(tenantId);
var normalizedClientId = Normalize(clientId);
@@ -119,18 +134,56 @@ public class SharePointUploadService : ISharePointUploadService
var folderPath = ResolveRemotePath(normalizedReference, siteUri);
var children = await graphClient.Drives[drive.Id].Root.ItemWithPath(folderPath).Children.GetAsync();
var candidates = children?.Value?
var allCandidates = children?.Value?
.Where(item => item.File is not null)
.Where(item => IsSupportedManualImportFile(item.Name))
.Where(item => MatchesTsc(item.Name, normalizedTsc))
.Select(item => new
{
Item = item,
FileDate = TryParseDatedSiteFileName(item.Name, normalizedTsc, out var fileDate) ? fileDate : (DateTime?)null
FileDate = TryParseDatedSiteFileName(item.Name, normalizedTsc, out var fileDate) ? fileDate : (DateTime?)null,
AnnualYear = TryParseAnnualSiteFileName(item.Name, normalizedTsc, out var annualYear) ? annualYear : (int?)null,
SnapshotDate = TryParseSnapshotDate(item.Name, out var snapshotDate) ? snapshotDate : (DateTime?)null
})
.ToList() ?? [];
if (preferredYear is not null)
{
var annual = allCandidates
.Where(x => x.AnnualYear == preferredYear.Value)
.OrderByDescending(x => x.SnapshotDate ?? x.Item.LastModifiedDateTime?.UtcDateTime ?? DateTime.MinValue)
.FirstOrDefault()
?? throw new InvalidOperationException(
$"Im SharePoint-Ordner '{folderPath}' wurde keine Jahresdatei fuer '{normalizedTsc}' und Jahr {preferredYear.Value} gefunden.");
var references = new List<SharePointFileReference>
{
new(string.Join("/", folderPath.Trim('/'), annual.Item.Name).Trim('/'), annual.Item.LastModifiedDateTime)
};
if (preferredYear.Value >= DateTime.Today.Year)
{
var baseDate = annual.SnapshotDate
?? annual.Item.LastModifiedDateTime?.UtcDateTime.Date
?? new DateTime(preferredYear.Value, 1, 1);
references.AddRange(allCandidates
.Where(x => x.FileDate is not null)
.Where(x => x.FileDate!.Value.Year == preferredYear.Value)
.Where(x => x.FileDate!.Value.Date > baseDate.Date)
.OrderBy(x => x.FileDate)
.Select(x => new SharePointFileReference(
string.Join("/", folderPath.Trim('/'), x.Item.Name).Trim('/'),
x.Item.LastModifiedDateTime)));
}
return references;
}
var candidates = allCandidates
.OrderByDescending(x => x.FileDate ?? x.Item.LastModifiedDateTime?.UtcDateTime ?? DateTime.MinValue)
.ThenByDescending(x => x.Item.LastModifiedDateTime?.UtcDateTime ?? DateTime.MinValue)
.ToList() ?? [];
.ToList();
var selected = candidates.FirstOrDefault()
?? throw new InvalidOperationException(
@@ -138,9 +191,12 @@ public class SharePointUploadService : ISharePointUploadService
? $"Im SharePoint-Ordner '{folderPath}' wurde keine Excel-/CSV-Datei gefunden."
: $"Im SharePoint-Ordner '{folderPath}' wurde keine Excel-/CSV-Datei fuer '{normalizedTsc}' gefunden.");
return new SharePointFileReference(
string.Join("/", folderPath.Trim('/'), selected.Item.Name).Trim('/'),
selected.Item.LastModifiedDateTime);
return
[
new SharePointFileReference(
string.Join("/", folderPath.Trim('/'), selected.Item.Name).Trim('/'),
selected.Item.LastModifiedDateTime)
];
}
public async Task TestConnectionAsync(string tenantId, string clientId, string clientSecret, string siteUrl)
@@ -217,7 +273,8 @@ public class SharePointUploadService : ISharePointUploadService
return true;
var nameWithoutExtension = Path.GetFileNameWithoutExtension(fileName ?? string.Empty);
return nameWithoutExtension.EndsWith($"_{normalizedTsc}", StringComparison.OrdinalIgnoreCase);
return nameWithoutExtension.EndsWith($"_{normalizedTsc}", StringComparison.OrdinalIgnoreCase) ||
Regex.IsMatch(nameWithoutExtension, $@"(^|[^A-Z0-9]){Regex.Escape(normalizedTsc)}([^A-Z0-9]|$)", RegexOptions.IgnoreCase);
}
private static bool TryParseDatedSiteFileName(string? fileName, string normalizedTsc, out DateTime fileDate)
@@ -239,6 +296,33 @@ public class SharePointUploadService : ISharePointUploadService
out fileDate);
}
private static bool TryParseAnnualSiteFileName(string? fileName, string normalizedTsc, out int year)
{
year = default;
var nameWithoutExtension = Path.GetFileNameWithoutExtension(fileName ?? string.Empty);
if (!Regex.IsMatch(nameWithoutExtension, $@"(^|[^A-Z0-9]){Regex.Escape(normalizedTsc)}([^A-Z0-9]|$)", RegexOptions.IgnoreCase))
return false;
if (TryParseDatedSiteFileName(fileName, normalizedTsc, out _))
return false;
var match = Regex.Match(nameWithoutExtension, @"(?<!\d)(20\d{2})(?!\d)");
return match.Success && int.TryParse(match.Groups[1].Value, CultureInfo.InvariantCulture, out year);
}
private static bool TryParseSnapshotDate(string? fileName, out DateTime snapshotDate)
{
snapshotDate = default;
var nameWithoutExtension = Path.GetFileNameWithoutExtension(fileName ?? string.Empty);
var match = Regex.Match(nameWithoutExtension, @"(?<!\d)(?<date>20\d{2}[-_.]\d{2}[-_.]\d{2})(?!\d)");
return match.Success &&
DateTime.TryParseExact(
match.Groups["date"].Value.Replace('_', '-').Replace('.', '-'),
"yyyy-MM-dd",
CultureInfo.InvariantCulture,
DateTimeStyles.None,
out snapshotDate);
}
private static string BuildInputPreview(string tenantId, string clientId, string clientSecret, string siteUrl)
{
var maskedSecret = string.IsNullOrEmpty(clientSecret)
@@ -37,7 +37,7 @@ public class SiteExportService : ISiteExportService
_logger = logger;
}
public async Task<SiteExportResult> ExportAsync(Site site, Action<string>? updateStatus = null)
public async Task<SiteExportResult> ExportAsync(Site site, Action<string>? updateStatus = null, int? preferredImportYear = null)
{
var sw = Stopwatch.StartNew();
var log = new ExportLog
@@ -63,7 +63,8 @@ public class SiteExportService : ISiteExportService
SourceDefinition = sourceDefinition,
Settings = settings,
SharePointConfig = spConfig,
UpdateStatus = updateStatus
UpdateStatus = updateStatus,
PreferredImportYear = preferredImportYear
});
var records = fetchResult.Records;