From 825e8063a02385301c422659f839f14a0d4ec219 Mon Sep 17 00:00:00 2001 From: metacube Date: Fri, 5 Jun 2026 06:40:31 +0200 Subject: [PATCH] Support Spain sales delta folder sync --- TrafagSalesExporter/Models/SalesRecord.cs | 1 + .../ManualExcelDataSourceAdapter.cs | 94 ++++++++++++++++++- .../Services/ManualExcelImportService.cs | 3 + .../Services/SharePointUploadService.cs | 71 ++++++++++++-- .../ManualExcelDataSourceAdapterTests.cs | 47 +++++++++- .../MANUAL_IMPORT_DELTA_STAND_2026-05-21.md | 19 ++-- TrafagSalesExporter/docs/rag/MANUAL_IMPORT.md | 24 ++++- TrafagSalesExporter/docs/rag/PROJECT.md | 1 + TrafagSalesExporter/lastchange.md | 44 +++++++++ 9 files changed, 279 insertions(+), 25 deletions(-) diff --git a/TrafagSalesExporter/Models/SalesRecord.cs b/TrafagSalesExporter/Models/SalesRecord.cs index 96b80ee..fddbfdd 100644 --- a/TrafagSalesExporter/Models/SalesRecord.cs +++ b/TrafagSalesExporter/Models/SalesRecord.cs @@ -5,6 +5,7 @@ public class SalesRecord public DateTime ExtractionDate { get; set; } public string SourceSystem { get; set; } = string.Empty; public string Tsc { get; set; } = string.Empty; + public string SourceLineId { get; set; } = string.Empty; public int DocumentEntry { get; set; } public string InvoiceNumber { get; set; } = string.Empty; public int PositionOnInvoice { get; set; } diff --git a/TrafagSalesExporter/Services/DataSources/ManualExcelDataSourceAdapter.cs b/TrafagSalesExporter/Services/DataSources/ManualExcelDataSourceAdapter.cs index d63208b..62071cf 100644 --- a/TrafagSalesExporter/Services/DataSources/ManualExcelDataSourceAdapter.cs +++ b/TrafagSalesExporter/Services/DataSources/ManualExcelDataSourceAdapter.cs @@ -31,6 +31,7 @@ public sealed class ManualExcelDataSourceAdapter : IDataSourceAdapter string filePath; string? localOutputDirectory = null; string? sharePointUploadFolder = null; + var localManualImportPaths = new List(); var tempManualImportPaths = new List(); try { @@ -39,6 +40,12 @@ public sealed class ManualExcelDataSourceAdapter : IDataSourceAdapter filePath = manualImportPath; localOutputDirectory = Path.GetDirectoryName(Path.GetFullPath(manualImportPath)); } + else if (Directory.Exists(manualImportPath)) + { + localManualImportPaths.AddRange(ResolveLocalManualImportFilesInFolder(manualImportPath, site)); + filePath = manualImportPath; + localOutputDirectory = Path.GetFullPath(manualImportPath); + } else if (LooksLikeSharePointReference(manualImportPath)) { var spConfig = context.SharePointConfig @@ -95,9 +102,15 @@ public sealed class ManualExcelDataSourceAdapter : IDataSourceAdapter siteId: site.Id, land: site.Land, details: filePath); var records = new List(); - var readPaths = tempManualImportPaths.Count > 0 ? tempManualImportPaths : [filePath]; + var readPaths = tempManualImportPaths.Count > 0 + ? tempManualImportPaths + : localManualImportPaths.Count > 0 + ? localManualImportPaths + : [filePath]; foreach (var readPath in readPaths) records.AddRange(await _manualExcelImportService.ReadSalesRecordsAsync(readPath, site)); + if (IsSpainSite(site)) + records = DeduplicateSpainSalesRecords(records); return new DataSourceFetchResult { Records = records, @@ -126,6 +139,85 @@ public sealed class ManualExcelDataSourceAdapter : IDataSourceAdapter => LooksLikeSharePointReference(path) && string.IsNullOrWhiteSpace(Path.GetExtension(path.TrimEnd('/'))); + private static List ResolveLocalManualImportFilesInFolder(string folderPath, Site site) + { + var files = Directory.EnumerateFiles(folderPath) + .Where(IsSupportedManualImportFile) + .Where(path => !IsSpainSite(site) || IsSpainSalesFile(path)) + .OrderBy(GetManualImportFileSortKey, StringComparer.OrdinalIgnoreCase) + .ThenBy(path => path, StringComparer.OrdinalIgnoreCase) + .ToList(); + + if (files.Count == 0) + { + var expected = IsSpainSite(site) ? "Spain_Sales*.csv" : "*.xlsx/*.csv"; + throw new InvalidOperationException($"Im Ordner '{folderPath}' wurde keine passende Importdatei gefunden ({expected})."); + } + + return files; + } + + private static bool IsSupportedManualImportFile(string path) + { + var extension = Path.GetExtension(path); + return extension.Equals(".xlsx", StringComparison.OrdinalIgnoreCase) || + extension.Equals(".csv", StringComparison.OrdinalIgnoreCase); + } + + private static bool IsSpainSite(Site site) + => string.Equals(site.TSC, "TRES", StringComparison.OrdinalIgnoreCase) || + string.Equals(site.TSC, "TRSE", StringComparison.OrdinalIgnoreCase) || + string.Equals(site.Land, "Spanien", StringComparison.OrdinalIgnoreCase) || + string.Equals(site.Land, "Spain", StringComparison.OrdinalIgnoreCase); + + private static bool IsSpainSalesFile(string path) + => Path.GetFileName(path).StartsWith("Spain_Sales", StringComparison.OrdinalIgnoreCase) && + Path.GetExtension(path).Equals(".csv", StringComparison.OrdinalIgnoreCase); + + private static string GetManualImportFileSortKey(string path) + { + var name = Path.GetFileNameWithoutExtension(path); + var rangeIndex = name.IndexOf("_range_", StringComparison.OrdinalIgnoreCase); + if (rangeIndex >= 0) + return "1_" + name[(rangeIndex + "_range_".Length)..]; + + return "0_" + name; + } + + private static List DeduplicateSpainSalesRecords(IEnumerable records) + { + var ordered = records.ToList(); + var keyed = new Dictionary(StringComparer.OrdinalIgnoreCase); + var unkeyed = new List(); + + foreach (var record in ordered) + { + var key = BuildSpainSalesRecordKey(record); + if (string.IsNullOrWhiteSpace(key)) + unkeyed.Add(record); + else + keyed[key] = record; + } + + return keyed.Values.Concat(unkeyed).ToList(); + } + + private static string BuildSpainSalesRecordKey(SalesRecord record) + { + if (!string.IsNullOrWhiteSpace(record.SourceLineId)) + return $"source:{record.SourceLineId.Trim()}"; + + if (!string.IsNullOrWhiteSpace(record.InvoiceNumber)) + return string.Join("|", + "invoice", + record.Tsc?.Trim() ?? string.Empty, + record.InvoiceNumber.Trim(), + record.PositionOnInvoice.ToString(System.Globalization.CultureInfo.InvariantCulture), + record.Material?.Trim() ?? string.Empty); + + return string.Empty; + } + private static string ResolveSharePointParentFolder(string fileReference, string siteUrl) { var remotePath = fileReference.Trim('/').Trim(); diff --git a/TrafagSalesExporter/Services/ManualExcelImportService.cs b/TrafagSalesExporter/Services/ManualExcelImportService.cs index 48639b1..0c14050 100644 --- a/TrafagSalesExporter/Services/ManualExcelImportService.cs +++ b/TrafagSalesExporter/Services/ManualExcelImportService.cs @@ -18,6 +18,7 @@ public class ManualExcelImportService : IManualExcelImportService { ["extractiondate"] = nameof(SalesRecord.ExtractionDate), ["tsc"] = nameof(SalesRecord.Tsc), + ["sourcelineid"] = nameof(SalesRecord.SourceLineId), ["documententry"] = nameof(SalesRecord.DocumentEntry), ["invoicenumber"] = nameof(SalesRecord.InvoiceNumber), ["positiononinvoice"] = nameof(SalesRecord.PositionOnInvoice), @@ -163,6 +164,7 @@ public class ManualExcelImportService : IManualExcelImportService { ExtractionDate = ReadDate(headerIndexes, fields, nameof(SalesRecord.ExtractionDate)) ?? DateTime.UtcNow, Tsc = ReadString(headerIndexes, fields, nameof(SalesRecord.Tsc), site.TSC), + SourceLineId = ReadString(headerIndexes, fields, nameof(SalesRecord.SourceLineId)), DocumentEntry = (int)Math.Round(ReadDecimal(headerIndexes, fields, nameof(SalesRecord.DocumentEntry))), InvoiceNumber = ReadString(headerIndexes, fields, nameof(SalesRecord.InvoiceNumber)), PositionOnInvoice = (int)Math.Round(ReadDecimal(headerIndexes, fields, nameof(SalesRecord.PositionOnInvoice))), @@ -281,6 +283,7 @@ public class ManualExcelImportService : IManualExcelImportService { ExtractionDate = ReadDate(headerIndexes, row, nameof(SalesRecord.ExtractionDate)) ?? DateTime.UtcNow, Tsc = ReadString(headerIndexes, row, nameof(SalesRecord.Tsc), site.TSC), + SourceLineId = ReadString(headerIndexes, row, nameof(SalesRecord.SourceLineId)), DocumentEntry = (int)Math.Round(ReadDecimal(headerIndexes, row, nameof(SalesRecord.DocumentEntry))), InvoiceNumber = ReadString(headerIndexes, row, nameof(SalesRecord.InvoiceNumber)), PositionOnInvoice = (int)Math.Round(ReadDecimal(headerIndexes, row, nameof(SalesRecord.PositionOnInvoice))), diff --git a/TrafagSalesExporter/Services/SharePointUploadService.cs b/TrafagSalesExporter/Services/SharePointUploadService.cs index e4bb80d..2de1310 100644 --- a/TrafagSalesExporter/Services/SharePointUploadService.cs +++ b/TrafagSalesExporter/Services/SharePointUploadService.cs @@ -113,6 +113,7 @@ public class SharePointUploadService : ISharePointUploadService var normalizedSiteUrl = Normalize(siteUrl); var normalizedReference = Normalize(folderReference); var normalizedTsc = Normalize(siteTsc).ToUpperInvariant(); + var isSpainImport = IsSpainManualImport(normalizedTsc, normalizedReference); if (string.IsNullOrWhiteSpace(normalizedReference)) throw new InvalidOperationException("SharePoint-Ordnerreferenz fehlt."); @@ -136,16 +137,41 @@ public class SharePointUploadService : ISharePointUploadService 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 + .Where(item => isSpainImport ? IsSpainSalesFile(item.Name) : MatchesTsc(item.Name, normalizedTsc)) + .Select(item => { - Item = item, - 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 + var hasSpainRange = TryParseSpainSalesRangeFileName(item.Name, out var rangeStart, out var rangeEnd); + return new + { + Item = item, + FileDate = TryParseDatedSiteFileName(item.Name, normalizedTsc, out var fileDate) ? fileDate : (DateTime?)null, + SpainRangeStart = hasSpainRange ? rangeStart : (DateTime?)null, + SpainRangeEnd = hasSpainRange ? rangeEnd : (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 (isSpainImport) + { + var spainCandidates = allCandidates + .OrderBy(x => x.SpainRangeStart is null ? 0 : 1) + .ThenBy(x => x.SpainRangeStart ?? DateTime.MinValue) + .ThenBy(x => x.SpainRangeEnd ?? DateTime.MinValue) + .ThenBy(x => x.Item.Name, StringComparer.OrdinalIgnoreCase) + .ToList(); + + if (spainCandidates.Count == 0) + throw new InvalidOperationException($"Im SharePoint-Ordner '{folderPath}' wurde keine Spain_Sales*.csv gefunden."); + + return spainCandidates + .Select(x => new SharePointFileReference( + string.Join("/", folderPath.Trim('/'), x.Item.Name).Trim('/'), + x.Item.LastModifiedDateTime)) + .ToList(); + } + if (preferredYear is not null) { var annual = allCandidates @@ -315,6 +341,39 @@ public class SharePointUploadService : ISharePointUploadService Regex.IsMatch(nameWithoutExtension, $@"(^|[^A-Z0-9]){Regex.Escape(normalizedTsc)}([^A-Z0-9]|$)", RegexOptions.IgnoreCase); } + private static bool IsSpainManualImport(string normalizedTsc, string folderReference) + => string.Equals(normalizedTsc, "TRES", StringComparison.OrdinalIgnoreCase) || + string.Equals(normalizedTsc, "TRSE", StringComparison.OrdinalIgnoreCase) || + folderReference.Contains("Spanien", StringComparison.OrdinalIgnoreCase) || + folderReference.Contains("Spain", StringComparison.OrdinalIgnoreCase); + + private static bool IsSpainSalesFile(string? fileName) + => Path.GetFileName(fileName ?? string.Empty).StartsWith("Spain_Sales", StringComparison.OrdinalIgnoreCase) && + Path.GetExtension(fileName ?? string.Empty).Equals(".csv", StringComparison.OrdinalIgnoreCase); + + private static bool TryParseSpainSalesRangeFileName(string? fileName, out DateTime rangeStart, out DateTime rangeEnd) + { + rangeStart = default; + rangeEnd = default; + var nameWithoutExtension = Path.GetFileNameWithoutExtension(fileName ?? string.Empty); + var match = Regex.Match(nameWithoutExtension, @"^Spain_Sales_range_(?\d{8})_to_(?\d{8})$", RegexOptions.IgnoreCase); + if (!match.Success) + return false; + + return DateTime.TryParseExact( + match.Groups["from"].Value, + "yyyyMMdd", + CultureInfo.InvariantCulture, + DateTimeStyles.None, + out rangeStart) && + DateTime.TryParseExact( + match.Groups["to"].Value, + "yyyyMMdd", + CultureInfo.InvariantCulture, + DateTimeStyles.None, + out rangeEnd); + } + private static bool TryParseDatedSiteFileName(string? fileName, string normalizedTsc, out DateTime fileDate) { fileDate = default; diff --git a/TrafagSalesExporter/TrafagSalesExporter.Tests/ManualExcelDataSourceAdapterTests.cs b/TrafagSalesExporter/TrafagSalesExporter.Tests/ManualExcelDataSourceAdapterTests.cs index 4a3fcdc..a92f83d 100644 --- a/TrafagSalesExporter/TrafagSalesExporter.Tests/ManualExcelDataSourceAdapterTests.cs +++ b/TrafagSalesExporter/TrafagSalesExporter.Tests/ManualExcelDataSourceAdapterTests.cs @@ -80,6 +80,37 @@ public class ManualExcelDataSourceAdapterTests } } + [Fact] + public async Task FetchAsync_Reads_Local_Spain_Folder_And_Deduplicates_DeltaRows() + { + var folder = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(folder); + try + { + WriteSpainCsv(Path.Combine(folder, "Spain_Sales_2025.csv"), + ("line-a", "1001", 10, 100m)); + WriteSpainCsv(Path.Combine(folder, "Spain_Sales_range_20260528_to_20260603.csv"), + ("line-a", "1001", 10, 125m), + ("line-b", "1002", 20, 50m)); + + var adapter = new ManualExcelDataSourceAdapter( + new FakeSharePointUploadService(Path.Combine(folder, "Spain_Sales_2025.csv")), + new ManualExcelImportService(), + new NoopAppEventLogService()); + + var result = await adapter.FetchAsync(CreateContext(folder)); + + Assert.Equal(2, result.Records.Count); + Assert.Equal(125m, Assert.Single(result.Records, r => r.SourceLineId == "line-a").SalesPriceValue); + Assert.Equal(50m, Assert.Single(result.Records, r => r.SourceLineId == "line-b").SalesPriceValue); + Assert.Equal(folder, result.LocalOutputDirectoryOverride); + } + finally + { + Directory.Delete(folder, recursive: true); + } + } + private static DataSourceFetchContext CreateContext(string manualImportPath, string tsc = "TRES", string land = "Spanien") => new() { Site = new Site @@ -107,13 +138,21 @@ public class ManualExcelDataSourceAdapterTests private static string CreateSpainCsv() { var filePath = Path.Combine(Path.GetTempPath(), $"{Guid.NewGuid():N}.csv"); - var csv = string.Join(Environment.NewLine, - "\"TSC\";\"Land\";\"InvoiceNumber\";\"PositionOnInvoice\";\"Material\";\"Name\";\"ProductGroup\";\"Quantity\";\"CustomerNumber\";\"CustomerName\";\"CustomerCountry\";\"StandardCost\";\"StandardCostCurrency\";\"PurchaseOrderNumber\";\"SalesPriceValue\";\"SalesCurrency\";\"DocumentCurrency\";\"CompanyCurrency\";\"Incoterms2020\";\"SalesResponsibleEmployee\";\"InvoiceDate\";\"DocumentType\"", - "\"TRES\";\"Spanien\";\"20241332\";\"20\";\"52871\";\"ECL1.0AP\";\"TRANS\";\"1.000000\";\"302208\";\"INTRONIK AUTOMATIZACION E INST. SL\";\"ESPANA\";\"160.760000\";\"EUR\";\"PC240330\";\"265.000000\";\"EUR\";\"EUR\";\"EUR\";\"EXW\";\"1\";\"2025-01-02 00:00:00\";\"Invoice\""); - File.WriteAllText(filePath, csv); + WriteSpainCsv(filePath, ("line-a", "20241332", 20, 265m)); return filePath; } + private static void WriteSpainCsv(string filePath, params (string SourceLineId, string InvoiceNumber, int Position, decimal SalesPriceValue)[] rows) + { + var csv = string.Join(Environment.NewLine, + new[] + { + "\"TSC\";\"Land\";\"SourceLineId\";\"InvoiceNumber\";\"PositionOnInvoice\";\"Material\";\"Name\";\"ProductGroup\";\"Quantity\";\"CustomerNumber\";\"CustomerName\";\"CustomerCountry\";\"StandardCost\";\"StandardCostCurrency\";\"PurchaseOrderNumber\";\"SalesPriceValue\";\"SalesCurrency\";\"DocumentCurrency\";\"CompanyCurrency\";\"Incoterms2020\";\"SalesResponsibleEmployee\";\"InvoiceDate\";\"DocumentType\"" + }.Concat(rows.Select(row => + $"\"TRES\";\"Spanien\";\"{row.SourceLineId}\";\"{row.InvoiceNumber}\";\"{row.Position}\";\"52871\";\"ECL1.0AP\";\"TRANS\";\"1.000000\";\"302208\";\"INTRONIK AUTOMATIZACION E INST. SL\";\"ESPANA\";\"160.760000\";\"EUR\";\"PC240330\";\"{row.SalesPriceValue.ToString(System.Globalization.CultureInfo.InvariantCulture)}\";\"EUR\";\"EUR\";\"EUR\";\"EXW\";\"1\";\"2025-01-02 00:00:00\";\"Invoice\""))); + File.WriteAllText(filePath, csv); + } + private sealed class FakeSharePointUploadService : ISharePointUploadService { private readonly string _sourceFilePath; diff --git a/TrafagSalesExporter/docs/MANUAL_IMPORT_DELTA_STAND_2026-05-21.md b/TrafagSalesExporter/docs/MANUAL_IMPORT_DELTA_STAND_2026-05-21.md index 179e860..030439f 100644 --- a/TrafagSalesExporter/docs/MANUAL_IMPORT_DELTA_STAND_2026-05-21.md +++ b/TrafagSalesExporter/docs/MANUAL_IMPORT_DELTA_STAND_2026-05-21.md @@ -1,6 +1,6 @@ # Manual-Import und Delta-Stand -Stand: 2026-05-21 +Stand: 2026-06-05 Diese Datei beschreibt, wie manuelle Excel-/CSV-Importe aktuell behandelt werden und wie neue Eintraege bzw. Delta-Dateien verarbeitet werden. @@ -9,7 +9,7 @@ Diese Datei beschreibt, wie manuelle Excel-/CSV-Importe aktuell behandelt werden | Land / Standort | Quelle aktuell | Dateityp | Neue Eintraege / Deltas | Wie die App auswaehlt | Was beim Standortexport passiert | Finance-Wert | | --- | --- | --- | --- | --- | --- | --- | | UK / England `TRUK` | SharePoint-Ordner `Import/Finance/UK_B1` | Sage Excel `.xlsx` | Delta-faehig | Bei Jahreslauf: Jahresdatei fuer `TRUK` plus spaetere datierte Dateien `ddMMyy_TRUK.xlsx` oder `.csv` | Alle gefundenen Dateien werden gelesen und zusammen in `CentralSalesRecords` fuer `TRUK` ersetzt | `Sales Price/Value * Quantity`, Credit Notes negativ, GBP | -| Spanien `TRSE` / `TRES` | SharePoint-Datei oder Ordner / Sage CSV | `.csv` | Vollfile erforderlich, keine Deltas | Wenn Ordner: neueste passende Vollfile-Datei nach TSC/Datum | Datei wird gelesen, Standortdaten werden ersetzt | Sage `ImporteNeto`, REC/Abono/Credit negativ, EUR | +| Spanien `TRSE` / `TRES` | SharePoint-Ordner `Import/Finance/Spanien` / Sage CSV | `.csv` | Delta-faehig mit Basis + `Spain_Sales_range_YYYYMMDD_to_YYYYMMDD.csv` | Wenn Ordner: alle `Spain_Sales*.csv`, Basis zuerst, danach Range-Dateien nach Datum | Alle Dateien werden gelesen, nach `SourceLineId` bzw. Invoice/Position/Material dedupliziert, danach ersetzen die deduplizierten Spanien-Zeilen den bisherigen Spanien-Stand | Sage `SalesPriceValue`/`ImporteNeto`, REC/Abono/Credit negativ, EUR | | Deutschland `TRDE` | Alphaplan Excel | `.xlsx` | Vollfile/Jahresfile erforderlich, keine Deltas | Pfad/Datei am Standort hinterlegt | Datei wird gelesen, DE-Zeilen ersetzen bisherigen DE-Stand | `NettoPreisGesamtX`, Finance-Regeln: Ausschluesse, GS negativ, 2025-Zwang | | CH/AT `ZSCHWEIZ` | SAP OData | OData | Kein manueller Delta-Excel-Prozess | App liest SAP-Service | ZSCHWEIZ-Zeilen ersetzen bisherigen Stand | `NetwrHc`, CHF/EUR nach Land | | FR / IT / US | HANA / SAP B1 | direkte DB | Kein manueller Delta-Excel-Prozess | App liest HANA nach Datum/Schema | Standortdaten werden neu aus HANA aufgebaut | B1 Positions-Netto, Credit Notes negativ | @@ -44,13 +44,14 @@ Spanien nutzt technisch ebenfalls `MANUAL_EXCEL`, fachlich aber Sage CSV. Aktueller Implementierungsstand: - Datei/Ordner kann ueber SharePoint oder lokal hinterlegt werden. -- Bei SharePoint-Ordnern wird die neueste passende Datei nach TSC/Datum ausgewaehlt. -- Spanien muss immer den kompletten relevanten Datenstand liefern. -- Delta-Dateien sind fuer Spanien nicht vorgesehen. -- Praktisch gilt Spanien deshalb als Vollfile-Import. -- Beim Standortexport ersetzt die App den bisherigen Spanien-Stand in `CentralSalesRecords`. -- Wenn versehentlich nur eine Delta-Datei als neueste Datei im Ordner liegt oder direkt als Pfad hinterlegt wird, wuerde die App technisch nur dieses Delta lesen und damit den bisherigen Spanien-Stand ersetzen. -- Es gibt aktuell keine explizite Sperre, die eine Spanien-Delta-Datei erkennt und ablehnt. +- Bei Spanien-Ordnern werden alle `Spain_Sales*.csv` gelesen, auch wenn der Dateiname kein `TRES` enthaelt. +- Basis-/Vollfiles werden vor Range-Dateien gelesen. +- Range-Dateien wie `Spain_Sales_range_20260528_to_20260603.csv` werden nach Range-Start/Ende sortiert. +- Die App dedupliziert die gelesenen Zeilen vor dem Speichern: + - primaer ueber `SourceLineId`. + - Fallback ueber `TSC + InvoiceNumber + PositionOnInvoice + Material`. +- Beim Standortexport ersetzt die App weiterhin den bisherigen Spanien-Stand in `CentralSalesRecords`, aber mit dem zuvor zusammengesetzten und deduplizierten Gesamtstand. +- Wenn nur eine einzelne Delta-Datei direkt als Dateipfad hinterlegt wird, kann weiterhin nur dieses Delta gelesen werden. Fuer Delta-Sync muss deshalb der Ordner hinterlegt sein. Finance-Logik: diff --git a/TrafagSalesExporter/docs/rag/MANUAL_IMPORT.md b/TrafagSalesExporter/docs/rag/MANUAL_IMPORT.md index 6015609..6a39803 100644 --- a/TrafagSalesExporter/docs/rag/MANUAL_IMPORT.md +++ b/TrafagSalesExporter/docs/rag/MANUAL_IMPORT.md @@ -1,20 +1,22 @@ # RAG Manual Import -Stand: 2026-05-27 +Stand: 2026-06-05 ## Kurzstand - Manual-Importe ersetzen pro Standort den aktuellen Stand in `CentralSalesRecords`. - Delta-Dateien muessen zusammen mit der passenden Basisdatei gelesen werden. -- Das ist aktuell nur fuer UK vorgesehen. -- ES und DE muessen Vollfiles liefern. +- UK liest Jahresdatei plus spaetere Deltas. +- ES/Spanien liest im Ordner alle `Spain_Sales*.csv`, also Basisdatei plus taegliche `Spain_Sales_range_YYYYMMDD_to_YYYYMMDD.csv`. +- Spanien-Deltas werden vor dem Speichern dedupliziert: zuerst `SourceLineId`, sonst Invoice/Position/Material. +- DE muss weiterhin Vollfiles liefern. ## Laender | Standort | Quelle | Delta | Finance-Wert | | --- | --- | --- | --- | | UK / `TRUK` | SharePoint `Import/Finance/UK_B1`, Sage Excel | ja | `[Sales Price/Value] * [Quantity]`, Credit Notes negativ, GBP | -| ES / `TRSE`/`TRES` | Sage CSV | nein | `ImporteNeto`, REC/Credit negativ, EUR | +| ES / `TRSE`/`TRES` | Sage CSV `Spain_Sales*.csv` | ja, wenn Ordner mit Basis + Deltas | `SalesPriceValue`/`ImporteNeto`, REC/Credit negativ, EUR | | DE / `TRDE` | Alphaplan Excel | nein | `NettoPreisGesamtX`, GS negativ, Ausschlussregeln | ## Bedienreihenfolge @@ -25,8 +27,20 @@ Stand: 2026-05-27 4. Zentrale Datei neu erzeugen. 5. `Finance Summary` und `Finance Details` pruefen. +## Spanien Delta-Sync + +- SharePoint-Ordner: `Import/Finance/Spanien`. +- Dateimuster: + - Basis/Vollfile: z. B. `Spain_Sales_2025.csv`. + - Delta/Range: `Spain_Sales_range_20260528_to_20260603.csv`. +- Die App liest bei Spanien-Ordnern alle `Spain_Sales*.csv`, nicht nur die neueste Datei. +- Reihenfolge: Basisdateien zuerst, danach Range-Dateien nach Datum. +- Deduplizierung: + - primaer `SourceLineId`. + - Fallback `TSC + InvoiceNumber + PositionOnInvoice + Material`. +- Danach ersetzt die App den Spanien-Stand in `CentralSalesRecords` mit diesem deduplizierten Gesamtstand. + ## Rohquellen Nur Bei Bedarf - Detailstand: `docs/MANUAL_IMPORT_DELTA_STAND_2026-05-21.md` - Workflow-Historie: `NEXT_STEPS_2026-04-15.md` - diff --git a/TrafagSalesExporter/docs/rag/PROJECT.md b/TrafagSalesExporter/docs/rag/PROJECT.md index f00cc9a..4622c3d 100644 --- a/TrafagSalesExporter/docs/rag/PROJECT.md +++ b/TrafagSalesExporter/docs/rag/PROJECT.md @@ -13,6 +13,7 @@ Stand: 2026-06-05 - Spanien: `Run-SpainRangeExportAndUpload-AllInOne.ps1` exportiert Sage-Range direkt und laedt CSV/Summary via rclone nach SharePoint `trafag-bi:Import/Finance/Spanien`. - Spanien: Default-Range ist heute minus 7 Tage bis heute; `ToDate` ist exklusiv. - Spanien: rclone-Fehler `Can't set -v and --log-level` im All-in-one-Script behoben; aktuelle Datei enthaelt kein `--verbose` im Upload. +- Spanien-Import: Ordner mit `Spain_Sales*.csv` werden komplett gelesen; Basis + taegliche Range-Dateien werden nach `SourceLineId` bzw. Invoice/Position/Material dedupliziert. - Fuer normale Weiterarbeit diese Datei plus den passenden Themen-RAG laden. ## Aktive Themen diff --git a/TrafagSalesExporter/lastchange.md b/TrafagSalesExporter/lastchange.md index 10bac54..e2dff69 100644 --- a/TrafagSalesExporter/lastchange.md +++ b/TrafagSalesExporter/lastchange.md @@ -21,6 +21,8 @@ Diese Datei ist fuer tokenarme RAG-Nutzung komprimiert. - Neu fuer Spanien: rclone-Uploadfehler `Can't set -v and --log-level` behoben; `--verbose` wurde aus dem All-in-one-Upload entfernt. - Neu fuer Spanien: rclone wird automatisch an mehreren Standardpfaden gesucht, inkl. `C:\Tools\rclone.exe`, `C:\Tools\rclone\rclone.exe`, `C:\Tools\rclone\rclone\rclone.exe` und `PATH`. - Wichtig fuer Spanien: Nur das All-in-one-Script benoetigt keine separate `Export-SageSpainSalesCsv.ps1`; der alte Wrapper `Run-SpainExportAndUpload.ps1` braucht weiterhin das Export-Script daneben. +- Neu fuer Spanien-Import: SharePoint-/lokale Ordner mit `Spain_Sales*.csv` werden komplett gelesen; Basisdateien und taegliche Range-/Delta-Dateien werden zu einem deduplizierten Gesamtstand zusammengefuehrt. +- Spanien-Dedupe-Regel: primaer `SourceLineId`, Fallback `TSC + InvoiceNumber + PositionOnInvoice + Material`. - Neu dokumentiert: Spanien-rclone-Anleitung und Package-README auf den All-in-one-Workflow aktualisiert. - Neu umgesetzt: ES-Referenz 2025 auf `3'082'320.18 EUR` korrigiert; alter Sollwert `3'102'333.61 EUR` als Referenz-/Excel-Fehler dokumentiert. - Neu umgesetzt: `FinanceProbe` nutzt dieselbe korrigierte ES-Referenz. @@ -133,6 +135,48 @@ Commits: - `af097ca Fix Spain all-in-one rclone upload` - `3fd19a8 Detect nested Spain rclone executable` +## Nachtrag 2026-06-05 Spanien Delta-Sync im Dashboard-Import + +Problem: + +- Der Sage-Server laedt per rclone taeglich neue Delta-Dateien in den SharePoint-Ordner. +- Dateinamen sind z. B. `Spain_Sales_range_20260528_to_20260603.csv`. +- Bisher haette ein einzelnes Delta beim Standortexport den kompletten Spanienbestand ersetzt, wenn nur dieses Delta gelesen wird. + +Umgesetzt: + +- `ManualExcelDataSourceAdapter` erkennt Spanien-Ordner lokal und in SharePoint. +- Fuer Spanien werden alle `Spain_Sales*.csv` gelesen, nicht nur die neueste Datei. +- SharePoint-Auswahl akzeptiert Spanien-Dateien ohne `TRES` im Namen. +- Sortierung: + - Basis-/Vollfiles zuerst. + - danach `Spain_Sales_range_YYYYMMDD_to_YYYYMMDD.csv` nach Datumsbereich. +- `ManualExcelImportService` liest `SourceLineId` aus dem CSV. +- Vor dem Speichern wird Spanien dedupliziert: + - primaer `SourceLineId`. + - Fallback `TSC + InvoiceNumber + PositionOnInvoice + Material`. +- `CentralSalesRecords` werden weiterhin pro Standort ersetzt, aber mit dem zusammengesetzten und deduplizierten Gesamtstand aus Basis + Deltas. + +Wichtige Bedienregel: + +- Fuer Delta-Sync muss im Standort/Manuellen Import der Ordner hinterlegt sein, nicht eine einzelne Delta-Datei. +- Beispielordner lokal/testweise: `SageSpainExportPackage`. +- Beispiel SharePoint: `Import/Finance/Spanien`. + +Validierung: + +```text +dotnet test TrafagSalesExporter.sln --verbosity minimal --filter ManualExcel +``` + +Ergebnis: `12/12` Tests gruen. + +```text +dotnet test TrafagSalesExporter.sln --verbosity minimal +``` + +Ergebnis: `83/83` Tests gruen. + ## Nachtrag 2026-06-04 Finance Schnelluebersicht / Experten / 3D Datenanalyse Ziel: