From 0709c9fe0515c25170807d84c6c3a92d6dad3423 Mon Sep 17 00:00:00 2001 From: Leopold Strobl Date: Wed, 21 Jan 2026 10:50:17 +0100 Subject: [PATCH] =?UTF-8?q?Projektdateien=20hinzuf=C3=BCgen.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- EPI2CrewbrainFile.slnx | 3 + EPI2CrewbrainFile/DocumentFile.cs | 4 + EPI2CrewbrainFile/Documents.cs | 79 +++ EPI2CrewbrainFile/EPI2CrewbrainFile.csproj | 28 + EPI2CrewbrainFile/Program.cs | 659 +++++++++++++++++++++ EPI2CrewbrainFile/README.md | 128 ++++ EPI2CrewbrainFile/appsettings.json | 21 + EPI2CrewbrainFile/log4net.config | 28 + 8 files changed, 950 insertions(+) create mode 100644 EPI2CrewbrainFile.slnx create mode 100644 EPI2CrewbrainFile/DocumentFile.cs create mode 100644 EPI2CrewbrainFile/Documents.cs create mode 100644 EPI2CrewbrainFile/EPI2CrewbrainFile.csproj create mode 100644 EPI2CrewbrainFile/Program.cs create mode 100644 EPI2CrewbrainFile/README.md create mode 100644 EPI2CrewbrainFile/appsettings.json create mode 100644 EPI2CrewbrainFile/log4net.config diff --git a/EPI2CrewbrainFile.slnx b/EPI2CrewbrainFile.slnx new file mode 100644 index 0000000..2131a1b --- /dev/null +++ b/EPI2CrewbrainFile.slnx @@ -0,0 +1,3 @@ + + + diff --git a/EPI2CrewbrainFile/DocumentFile.cs b/EPI2CrewbrainFile/DocumentFile.cs new file mode 100644 index 0000000..6389944 --- /dev/null +++ b/EPI2CrewbrainFile/DocumentFile.cs @@ -0,0 +1,4 @@ +namespace EPI2CrewbrainFile +{ + internal record DocumentFile(string Dateiname, string Dateiendung, string Datei); +} \ No newline at end of file diff --git a/EPI2CrewbrainFile/Documents.cs b/EPI2CrewbrainFile/Documents.cs new file mode 100644 index 0000000..7428741 --- /dev/null +++ b/EPI2CrewbrainFile/Documents.cs @@ -0,0 +1,79 @@ +// Documents.cs +namespace EPI2CrewbrainFile +{ + using System; + using System.Collections.Generic; + using System.Globalization; + using Newtonsoft.Json; + using Newtonsoft.Json.Converters; + + public partial class Documents + { + [JsonProperty("ID")] + public long Id { get; set; } + + [JsonProperty("DokumentID")] + public long DokumentId { get; set; } + + [JsonProperty("Zugehoerigkeit")] + public string Zugehoerigkeit { get; set; } + + [JsonProperty("ZugehoerigkeitID")] + public long ZugehoerigkeitId { get; set; } + + [JsonProperty("Sichtbarkeit")] + public long Sichtbarkeit { get; set; } + + [JsonProperty("AngelegtID")] + public long AngelegtId { get; set; } + + [JsonProperty("AngelegtDatum")] + public DateTimeOffset AngelegtDatum { get; set; } + + [JsonProperty("GeaendertID")] + public long GeaendertId { get; set; } + + [JsonProperty("GeaendertDatum")] + public DateTimeOffset GeaendertDatum { get; set; } + + [JsonProperty("Geloescht")] + public object Geloescht { get; set; } + + [JsonProperty("Dateiname")] + public string Dateiname { get; set; } + + [JsonProperty("Dateiendung")] + public string Dateiendung { get; set; } + + [JsonProperty("Groesse")] + public long Groesse { get; set; } + + [JsonProperty("FremdKundenID")] + public long FremdKundenId { get; set; } + + [JsonProperty("URL")] + public Uri Url { get; set; } + + public static List FromJson(string json) => + JsonConvert.DeserializeObject>(json, Converter.Settings); + } + + public static class Serialize + { + public static string ToJson(this List self) => + JsonConvert.SerializeObject(self, Converter.Settings); + } + + internal static class Converter + { + public static readonly JsonSerializerSettings Settings = new JsonSerializerSettings + { + MetadataPropertyHandling = MetadataPropertyHandling.Ignore, + DateParseHandling = DateParseHandling.None, + Converters = + { + new IsoDateTimeConverter { DateTimeStyles = DateTimeStyles.AssumeUniversal } + }, + }; + } +} diff --git a/EPI2CrewbrainFile/EPI2CrewbrainFile.csproj b/EPI2CrewbrainFile/EPI2CrewbrainFile.csproj new file mode 100644 index 0000000..542a5c6 --- /dev/null +++ b/EPI2CrewbrainFile/EPI2CrewbrainFile.csproj @@ -0,0 +1,28 @@ + + + + Exe + net10.0 + enable + enable + + + + + + + + + + + + + + Always + + + Always + + + + diff --git a/EPI2CrewbrainFile/Program.cs b/EPI2CrewbrainFile/Program.cs new file mode 100644 index 0000000..2450ef5 --- /dev/null +++ b/EPI2CrewbrainFile/Program.cs @@ -0,0 +1,659 @@ +using log4net; +using log4net.Config; +using Microsoft.Extensions.Configuration; +using RestSharp; +using RestSharp.Authenticators; +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Reflection; +using System.Text; +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Text.RegularExpressions; + +internal class Program +{ + private static readonly ILog Log = LogManager.GetLogger(typeof(Program)); + + static int Main(string[] args) + { + Console.OutputEncoding = Encoding.UTF8; + Console.InputEncoding = Encoding.UTF8; + ConfigureLogging(); + + AppDomain.CurrentDomain.UnhandledException += (_, e) => + Log.Fatal("Unhandled exception", e.ExceptionObject as Exception); + + try + { + var baseDir = AppContext.BaseDirectory; + var appSettingsPath = Path.Combine(baseDir, "appsettings.json"); + var config = LoadConfig(baseDir); + + var apiBase = (config["Crewbrain:ApiBase"] ?? "").TrimEnd('/'); + if (string.IsNullOrWhiteSpace(apiBase)) + throw new Exception("Missing config 'Crewbrain:ApiBase' (e.g. https://vt-media.crewbrain.com/api)"); + + // Paths + var angebotPath = config["Paths:Angebot"] ?? ""; + var auftragPath = config["Paths:Auftrag"] ?? ""; + var lieferscheinPath = config["Paths:Lieferschein"] ?? ""; + + // Upload settings (3 unterschiedliche TypeIDs / Flags) + var settingsAngebot = UploadSettings.FromConfig(config, "Crewbrain:Upload:Angebot"); + var settingsAuftrag = UploadSettings.FromConfig(config, "Crewbrain:Upload:Auftrag"); + var settingsLieferschein = UploadSettings.FromConfig(config, "Crewbrain:Upload:Lieferschein"); + + Log.Info("=== EPI2CrewbrainFile.Console started ==="); + Log.Info($"Config path: {appSettingsPath}"); + Log.Info($"Crewbrain ApiBase: {apiBase}"); + + Log.Info($"AngebotPath: {angebotPath} | TypeId={settingsAngebot.TypeId} | OverwriteSingleDocument={settingsAngebot.OverwriteSingleDocument}"); + Log.Info($"AuftragPath: {auftragPath} | TypeId={settingsAuftrag.TypeId} | OverwriteSingleDocument={settingsAuftrag.OverwriteSingleDocument}"); + Log.Info($"LieferscheinPath: {lieferscheinPath} | TypeId={settingsLieferschein.TypeId} | OverwriteSingleDocument={settingsLieferschein.OverwriteSingleDocument}"); + + // Access token (prompt once, store) + var accessToken = TokenAuthHelper.EnsureAccessToken( + config: config, + appSettingsPath: appSettingsPath, + log: Log, + apiBase: apiBase + ); + + Log.Info($"TokenAuth: AccessToken loaded (len={accessToken.Length})"); + + // Uploader service + var service = new UploaderService(apiBase, accessToken, appSettingsPath, Log); + + // Process + ProcessFolder(folder: angebotPath, uploader: service, tag: "Angebot", settings: settingsAngebot); + ProcessFolder(folder: auftragPath, uploader: service, tag: "Auftrag", settings: settingsAuftrag); + ProcessFolder(folder: lieferscheinPath, uploader: service, tag: "Lieferschein", settings: settingsLieferschein); + + Log.Info("=== EPI2CrewbrainFile.Console finished successfully ==="); + return 0; + } + catch (Exception ex) + { + Log.Fatal("Fatal error", ex); + return 1; + } + finally + { + LogManager.Shutdown(); + } + } + + private static IConfigurationRoot LoadConfig(string baseDir) + => new ConfigurationBuilder() + .SetBasePath(baseDir) + .AddJsonFile("appsettings.json", optional: false, reloadOnChange: false) + .Build(); + + private static void ConfigureLogging() + { + Directory.CreateDirectory(Path.Combine(AppContext.BaseDirectory, "logs")); + var configFile = Path.Combine(AppContext.BaseDirectory, "log4net.config"); + XmlConfigurator.Configure(LogManager.GetRepository(Assembly.GetEntryAssembly()!), new FileInfo(configFile)); + } + + private static void ProcessFolder(string folder, UploaderService uploader, string tag, UploadSettings settings) + { + if (string.IsNullOrWhiteSpace(folder) || !Directory.Exists(folder)) + { + Log.Warn($"[{tag}] Folder missing: {folder}"); + return; + } + + var pdfs = new DirectoryInfo(folder).GetFiles("*.pdf"); + Log.Info($"[{tag}] Found {pdfs.Length} PDF(s) in {folder}"); + + foreach (var file in pdfs) + { + try + { + var eventIdManual = ExtractEventIdManual(file.Name); + if (string.IsNullOrWhiteSpace(eventIdManual)) + { + Log.Warn($"[{tag}] Could not extract EventIDManual from filename: {file.Name} (skip)"); + continue; + } + + Log.Info($"[{tag}] File '{file.Name}' -> EventIDManual={eventIdManual}"); + + var eventId = uploader.ResolveCrewbrainEventIdByManualId(eventIdManual); + if (eventId == null) + { + Log.Warn($"[{tag}] No CrewBrain event found for EventIDManual={eventIdManual}. File not moved."); + continue; + } + + Log.Info($"[{tag}] Resolved EventIDManual={eventIdManual} -> CrewBrain ID={eventId}"); + + var filenameWithoutExt = Path.GetFileNameWithoutExtension(file.Name); + var ok = uploader.UploadDocumentToEvent( + eventId: eventId.Value, + typeId: settings.TypeId, + overwriteSingleDocument: settings.OverwriteSingleDocument, + fullPath: file.FullName, + filename: filenameWithoutExt, + filetype: file.Extension.TrimStart('.') + ); + + if (ok) + { + MoveToUploaded(folder, file.Name, tag); + } + else + { + Log.Warn($"[{tag}] Upload failed -> file NOT moved. ({file.Name})"); + } + } + catch (Exception ex) + { + Log.Error($"[{tag}] Error processing '{file.FullName}'", ex); + } + } + } + + /// + /// Robust: findet 3- bis 5-stellige IDs im Format NNN-NN / NNNN-NN / NNNNN-NN. + /// Wenn mehrere Treffer existieren, nimmt er den längsten (also bevorzugt 5-stellig vor 4-stellig vor 3-stellig). + /// + private static string ExtractEventIdManual(string fileName) + { + // Match: 3..5 digits - 2 digits, nicht Teil einer größeren Zahl + var matches = Regex.Matches(fileName, @"(? m.Groups[1].Value) + .OrderByDescending(s => s.Length) // bevorzugt 12345-01 + .First(); + } + + private static void MoveToUploaded(string baseFolder, string fileName, string tag) + { + var uploadedDir = Path.Combine(baseFolder, "Uploaded"); + Directory.CreateDirectory(uploadedDir); + + var source = Path.Combine(baseFolder, fileName); + if (!File.Exists(source)) + { + Log.Warn($"[{tag}] Move: source missing: {source}"); + return; + } + + var nameNoExt = Path.GetFileNameWithoutExtension(fileName); + var ext = Path.GetExtension(fileName); // inkl ".pdf" + if (string.IsNullOrWhiteSpace(ext)) ext = ""; + + var dest = Path.Combine(uploadedDir, fileName); + if (File.Exists(dest)) + { + var stamp = DateTime.Now.ToString("yyyyMMddHHmmssffff"); + var newName = $"{nameNoExt}_{stamp}{ext}"; + dest = Path.Combine(uploadedDir, newName); + } + + File.Move(source, dest); + Log.Info($"[{tag}] Moved to '{dest}'"); + } + + private sealed record UploadSettings(int TypeId, bool OverwriteSingleDocument) + { + public static UploadSettings FromConfig(IConfiguration cfg, string prefix) + { + int typeId = 0; + bool overwrite = false; + + int.TryParse(cfg[$"{prefix}:TypeId"], out typeId); + bool.TryParse(cfg[$"{prefix}:OverwriteSingleDocument"], out overwrite); + + return new UploadSettings(typeId, overwrite); + } + } +} + +internal static class TokenAuthHelper +{ + public static string EnsureAccessToken(IConfiguration config, string appSettingsPath, ILog log, string apiBase) + { + apiBase = (apiBase ?? "").TrimEnd('/'); + + var token = (config["Crewbrain:TokenAuth:AccessToken"] ?? "").Trim(); + if (!string.IsNullOrWhiteSpace(token)) + { + log.Info("TokenAuth: Using stored AccessToken from appsettings.json"); + return token; + } + + log.Warn("TokenAuth: No AccessToken stored. Prompting for username/password to request one-time token..."); + + Console.Write("CrewBrain Username: "); + var user = (Console.ReadLine() ?? "").Trim(); + + var pass = ReadPassword("CrewBrain Password: "); + + if (string.IsNullOrWhiteSpace(user) || string.IsNullOrWhiteSpace(pass)) + throw new Exception("TokenAuth: Missing username/password; cannot request access token."); + + var fetched = FetchAccessToken(apiBase, user, pass, log); + SaveAccessToken(appSettingsPath, fetched, log); + + log.Info("TokenAuth: AccessToken saved to appsettings.json"); + return fetched; + } + + public static void ClearStoredToken(string appSettingsPath, ILog log) + { + try + { + var json = File.Exists(appSettingsPath) ? File.ReadAllText(appSettingsPath) : ""; + var root = (JsonNode.Parse(json) as JsonObject) ?? new JsonObject(); + + var crew = root["Crewbrain"] as JsonObject; + var tokenAuth = crew?["TokenAuth"] as JsonObject; + if (tokenAuth == null) return; + + tokenAuth["AccessToken"] = ""; + tokenAuth["ClearedAtUtc"] = DateTime.UtcNow.ToString("o"); + + File.WriteAllText(appSettingsPath, root.ToJsonString(new JsonSerializerOptions { WriteIndented = true })); + log.Warn("TokenAuth: Stored token cleared in appsettings.json (will re-prompt next time)."); + } + catch (Exception ex) + { + log.Warn("TokenAuth: Failed to clear token from appsettings.json", ex); + } + } + + private static string FetchAccessToken(string apiBase, string user, string pass, ILog log) + { + var options = new RestClientOptions(apiBase.TrimEnd('/') + "/") + { + Authenticator = new HttpBasicAuthenticator(user, pass), + FollowRedirects = true + }; + var client = new RestClient(options); + + var req = new RestRequest("accesstoken", Method.Get); + req.AddHeader("Accept", "application/json"); + + var resp = client.Execute(req); + + log.Info($"GET /accesstoken -> {(int)resp.StatusCode} {resp.StatusCode}"); + if (!resp.IsSuccessful) + throw new Exception($"AccessToken request failed: {(int)resp.StatusCode} {resp.StatusCode} Body={resp.Content}"); + + var body = resp.Content ?? ""; + var node = JsonNode.Parse(body) as JsonObject + ?? throw new Exception($"AccessToken response not a JSON object: {body}"); + + var token = (node["Accesstoken"]?.GetValue() ?? "").Trim(); + if (string.IsNullOrWhiteSpace(token)) + throw new Exception($"TokenAuth: 'Accesstoken' missing in response: {body}"); + + log.Info($"TokenAuth: token received (len={token.Length})"); + return token; + } + + private static void SaveAccessToken(string appSettingsPath, string accessToken, ILog log) + { + JsonObject root; + try + { + var json = File.Exists(appSettingsPath) ? File.ReadAllText(appSettingsPath) : ""; + root = (JsonNode.Parse(json) as JsonObject) ?? new JsonObject(); + } + catch (Exception ex) + { + if (File.Exists(appSettingsPath)) + { + var backupPath = appSettingsPath + ".broken_" + DateTime.Now.ToString("yyyyMMddHHmmss"); + File.Copy(appSettingsPath, backupPath, overwrite: true); + log.Warn($"TokenAuth: appsettings.json invalid JSON. Backed up to: {backupPath}", ex); + } + root = new JsonObject(); + } + + var crew = root["Crewbrain"] as JsonObject ?? new JsonObject(); + root["Crewbrain"] = crew; + + var tokenAuth = crew["TokenAuth"] as JsonObject ?? new JsonObject(); + crew["TokenAuth"] = tokenAuth; + + tokenAuth["AccessToken"] = accessToken; + tokenAuth["SavedAtUtc"] = DateTime.UtcNow.ToString("o"); + + File.WriteAllText(appSettingsPath, root.ToJsonString(new JsonSerializerOptions { WriteIndented = true })); + } + + private static string ReadPassword(string prompt) + { + Console.Write(prompt); + var sb = new StringBuilder(); + + while (true) + { + var key = Console.ReadKey(intercept: true); + + if (key.Key == ConsoleKey.Enter) + { + Console.WriteLine(); + break; + } + + if (key.Key == ConsoleKey.Backspace) + { + if (sb.Length > 0) + { + sb.Length--; + Console.Write("\b \b"); + } + continue; + } + + if (!char.IsControl(key.KeyChar)) + { + sb.Append(key.KeyChar); + Console.Write("*"); + } + } + + return sb.ToString(); + } +} + +internal class UploaderService +{ + private static readonly ILog Log = LogManager.GetLogger(typeof(UploaderService)); + + private readonly string _baseUrl; // https://.../api/ + private string _accessToken; // X-API-KEY + private readonly string _appSettingsPath; // for clearing token on 401/403 + private readonly ILog _rootLog; + + public UploaderService(string apiBase, string accessToken, string appSettingsPath, ILog rootLog) + { + _baseUrl = (apiBase ?? "").TrimEnd('/') + "/"; + _accessToken = (accessToken ?? "").Trim(); + _appSettingsPath = appSettingsPath; + _rootLog = rootLog; + } + + public int? ResolveCrewbrainEventIdByManualId(string eventIdManual) + => ResolveCrewbrainEventIdByManualIdInternal(eventIdManual, allowTokenRetry: true); + + private int? ResolveCrewbrainEventIdByManualIdInternal(string eventIdManual, bool allowTokenRetry) + { + try + { + var client = new RestClient(new RestClientOptions(_baseUrl) { FollowRedirects = true }); + + var req = new RestRequest("v2/events", Method.Get); + req.AddHeader("Accept", "application/json"); + req.AddHeader("X-API-KEY", _accessToken); + req.AddQueryParameter("EventIDManual", eventIdManual); + + DebugLogRawRequest(client, req, bodyPreview: null); + + var resp = client.Execute(req); + Log.Info($"GET v2/events?EventIDManual={eventIdManual} -> {(int)resp.StatusCode} {resp.StatusCode}"); + + if ((int)resp.StatusCode == 401 || (int)resp.StatusCode == 403) + { + Log.Error("Resolve: unauthorized/forbidden. Token might be invalid/expired."); + + if (allowTokenRetry && TryReAuthToken()) + return ResolveCrewbrainEventIdByManualIdInternal(eventIdManual, allowTokenRetry: false); + + return null; + } + + if (!resp.IsSuccessful) + { + var b = resp.Content ?? ""; + if (b.Length > 1200) b = b.Substring(0, 1200) + "...(truncated)"; + Log.Warn($"Resolve event failed: {(int)resp.StatusCode} {resp.StatusCode} Body={b}"); + return null; + } + + var body = resp.Content ?? ""; + var root = JsonNode.Parse(body) as JsonObject; + var data = root?["data"] as JsonArray; + + if (data == null || data.Count == 0) + { + Log.Warn($"Resolve event: no items for EventIDManual={eventIdManual}"); + return null; + } + + if (data.Count > 1) + Log.Warn($"Resolve event: multiple items ({data.Count}) for EventIDManual={eventIdManual}. Using first."); + + var first = data[0] as JsonObject; + var idNode = first?["ID"]; + if (idNode == null) + { + Log.Warn($"Resolve event: 'ID' missing in first item for EventIDManual={eventIdManual}"); + return null; + } + + return idNode.GetValue(); + } + catch (Exception ex) + { + Log.Error($"ResolveCrewbrainEventIdByManualId failed (EventIDManual={eventIdManual})", ex); + return null; + } + } + + public bool UploadDocumentToEvent(int eventId, int typeId, bool overwriteSingleDocument, string fullPath, string filename, string filetype) + => UploadDocumentToEventInternal(eventId, typeId, overwriteSingleDocument, fullPath, filename, filetype, allowTokenRetry: true); + + private bool UploadDocumentToEventInternal(int eventId, int typeId, bool overwriteSingleDocument, string fullPath, string filename, string filetype, bool allowTokenRetry) + { + try + { + filename = (filename ?? "").Trim(); + filetype = (filetype ?? "").Trim().TrimStart('.'); + + if (string.IsNullOrWhiteSpace(filename)) + filename = Path.GetFileNameWithoutExtension(fullPath) ?? ""; + if (string.IsNullOrWhiteSpace(filename)) + throw new Exception("UploadDocumentToEvent: filename is empty."); + + if (string.IsNullOrWhiteSpace(filetype)) + filetype = "pdf"; + + var bytes = File.ReadAllBytes(fullPath); + var base64 = Convert.ToBase64String(bytes); + + // API fordert "Filename" mandatory → wir geben hier den kompletten Dateinamen inkl. Extension mit, + // aber lassen dein "filename" (ohne Ext) drin lesbar. + var filenameWithExt = filename.EndsWith("." + filetype, StringComparison.OrdinalIgnoreCase) + ? filename + : $"{filename}.{filetype}"; + + var payload = new + { + Base64String = base64, + Filename = filenameWithExt, + Filetype = filetype + }; + + var client = new RestClient(new RestClientOptions(_baseUrl) { FollowRedirects = true }); + + var req = new RestRequest($"v2/events/{eventId}/additionalDatas/{typeId}/addDocument", Method.Put); + req.AddHeader("Accept", "application/json"); + req.AddHeader("Content-Type", "application/json"); + req.AddHeader("X-API-KEY", _accessToken); + + if (overwriteSingleDocument) + req.AddQueryParameter("overwriteSingleDocument", "true"); + + var jsonBody = JsonSerializer.Serialize(payload); + req.AddStringBody(jsonBody, DataFormat.Json); + + // DEBUG Raw Request (ohne Base64) + var bodyPreview = JsonSerializer.Serialize(new + { + Base64String = $"", + Filename = filenameWithExt, + Filetype = filetype + }); + DebugLogRawRequest(client, req, bodyPreview); + + var resp = client.Execute(req); + Log.Info($"PUT v2/events/{eventId}/additionalDatas/{typeId}/addDocument -> {(int)resp.StatusCode} {resp.StatusCode}"); + + if ((int)resp.StatusCode == 401 || (int)resp.StatusCode == 403) + { + Log.Error("Upload: unauthorized/forbidden. Token might be invalid/expired."); + + // NOTE: Bei euch kommt 403 auch für Validierungsfehler (z.B. Filename fehlt) + // Darum: ReAuth nur dann, wenn Response "wirklich" nach Auth aussieht. + // Wir machen es pragmatisch: wenn allowTokenRetry -> einmal neu Token holen und retry, + // ABER nur wenn message NICHT nach Feldvalidierung klingt. + var content = resp.Content ?? ""; + if (allowTokenRetry && LooksLikeAuthFailure(content) && TryReAuthToken()) + return UploadDocumentToEventInternal(eventId, typeId, overwriteSingleDocument, fullPath, filename, filetype, allowTokenRetry: false); + } + + if (!resp.IsSuccessful) + { + var body = resp.Content ?? ""; + if (body.Length > 2000) body = body.Substring(0, 2000) + "...(truncated)"; + Log.Warn($"Upload failed: {(int)resp.StatusCode} {resp.StatusCode} Body={body}"); + return false; + } + + return true; + } + catch (Exception ex) + { + Log.Error($"UploadDocumentToEvent failed (EventID={eventId}, File={fullPath})", ex); + return false; + } + } + + private bool TryReAuthToken() + { + try + { + // Token leeren, dann per EnsureAccessToken neu holen (Prompt) + TokenAuthHelper.ClearStoredToken(_appSettingsPath, _rootLog); + + // Config neu laden, damit EnsureAccessToken die aktuelle Datei sieht + var baseDir = AppContext.BaseDirectory; + var cfg = new ConfigurationBuilder() + .SetBasePath(baseDir) + .AddJsonFile("appsettings.json", optional: false, reloadOnChange: false) + .Build(); + + var apiBase = (cfg["Crewbrain:ApiBase"] ?? "").TrimEnd('/'); + var newToken = TokenAuthHelper.EnsureAccessToken(cfg, _appSettingsPath, _rootLog, apiBase); + + if (string.IsNullOrWhiteSpace(newToken)) + return false; + + _accessToken = newToken.Trim(); + _rootLog.Warn("TokenAuth: Re-auth succeeded, token replaced in memory."); + return true; + } + catch (Exception ex) + { + _rootLog.Warn("TokenAuth: Re-auth failed.", ex); + return false; + } + } + + private static bool LooksLikeAuthFailure(string body) + { + if (string.IsNullOrWhiteSpace(body)) return true; + + // Wenn es klar nach Validierungsfehler aussieht (Missing mandatory field etc.), NICHT reauthen + var b = body.ToLowerInvariant(); + if (b.Contains("missing mandatory field")) return false; + if (b.Contains("\"field\"")) return false; + + // Auth-typische Signale + if (b.Contains("unauthorized")) return true; + if (b.Contains("forbidden")) return true; + if (b.Contains("invalid token")) return true; + if (b.Contains("not authorized")) return true; + + // default: lieber einmal reauthen als endlos scheitern + return true; + } + + private static void DebugLogRawRequest(RestClient client, RestRequest req, string? bodyPreview) + { + if (!Log.IsDebugEnabled) return; + + try + { + var baseUrl = client.Options.BaseUrl?.ToString() ?? "(null)"; + var resource = req.Resource ?? ""; + var method = req.Method.ToString(); + + var fullUrl = CombineUrl(baseUrl, resource); + var headers = new List(); + var query = new List(); + + foreach (var p in req.Parameters) + { + if (p.Type == ParameterType.HttpHeader) + { + var val = p.Value?.ToString() ?? ""; + if (p.Name != null && p.Name.Equals("X-API-KEY", StringComparison.OrdinalIgnoreCase)) + { + val = Mask(val); + } + headers.Add($"{p.Name}: {val}"); + } + else if (p.Type == ParameterType.QueryString) + { + query.Add($"{p.Name}={p.Value}"); + } + } + + Log.Debug("----- RAW REQUEST -----"); + Log.Debug($"{method} {fullUrl}"); + if (query.Count > 0) Log.Debug("Query: " + string.Join("&", query)); + if (headers.Count > 0) + { + Log.Debug("Headers:"); + foreach (var h in headers) Log.Debug(" " + h); + } + + if (!string.IsNullOrWhiteSpace(bodyPreview)) + { + Log.Debug("Body (preview):"); + Log.Debug(bodyPreview); + } + Log.Debug("-----------------------"); + } + catch (Exception ex) + { + Log.Debug("Failed to build raw request debug log.", ex); + } + } + + private static string CombineUrl(string baseUrl, string resource) + { + baseUrl = (baseUrl ?? "").TrimEnd('/'); + resource = (resource ?? "").TrimStart('/'); + return baseUrl + "/" + resource; + } + + private static string Mask(string s) + { + s = s ?? ""; + if (s.Length <= 6) return new string('*', s.Length); + return s.Substring(0, 3) + "***" + s.Substring(s.Length - 3) + $" (len={s.Length})"; + } +} diff --git a/EPI2CrewbrainFile/README.md b/EPI2CrewbrainFile/README.md new file mode 100644 index 0000000..f65f30e --- /dev/null +++ b/EPI2CrewbrainFile/README.md @@ -0,0 +1,128 @@ +# EPI → CrewBrain Dokumenten-Uploader + +Dieses Tool überträgt PDF-Dokumente (Angebote, Aufträge, Lieferscheine) automatisiert aus definierten Ordnern in **CrewBrain** und ordnet sie den passenden Jobs/Events zu. + +Die Zuordnung erfolgt über die **EventIDManual** (z. B. `4843-01`), die aus dem Dateinamen extrahiert wird. + +--- + +## Voraussetzungen in CrewBrain (wichtig) + +Damit Dokumente hochgeladen werden können, **müssen in CrewBrain passende Zusatzinformationen vom Typ „Dokument“ angelegt sein**. + +### 1. Zusatzinformationen anlegen + +Öffne in CrewBrain: + +https:///administration/jobs/jobdata + + +(z. B. `https://vt-media.crewbrain.com/administration/jobs/jobdata`) + +Lege dort für **jede Dokumentart** eine Zusatzinformation an: + +- Angebot +- Auftrag +- Lieferschein + +**Wichtig:** +- Der Typ der Zusatzinformation **muss „Dokument“ sein** +- Jede Zusatzinformation erhält intern eine **TypeID** + +--- + +### 2. TypeID ermitteln + +Nach dem Anlegen oder Bearbeiten einer Zusatzinformation kannst du die **TypeID** direkt aus der URL ablesen: + +Beispiel: + +https://vt-media.crewbrain.com/administration/jobs/jobdata/additionaldatatype/2 + + +➡️ **TypeID = 2** + +Diese TypeID wird später in der Anwendung konfiguriert. + +--- + +## Konfiguration (`appsettings.json`) + +### Relevanter Bereich: `Crewbrain` + +```json +"Crewbrain": { + "ApiBase": "https://vt-media.crewbrain.com/api", + + "Upload": { + "Angebot": { + "TypeId": 1, + "OverwriteSingleDocument": false + }, + "Auftrag": { + "TypeId": 0, + "OverwriteSingleDocument": true + }, + "Lieferschein": { + "TypeId": 2, + "OverwriteSingleDocument": false + } + }, + + "TokenAuth": { + "AccessToken": "" + } +} +Erklärung der Felder +Feld Bedeutung +ApiBase Basis-URL der CrewBrain API +Upload..TypeId TypeID der Zusatzinformation (siehe oben) +Upload..OverwriteSingleDocument Ersetzt vorhandenes Dokument (nur erlaubt bei DOCUMENT_SINGLE) +TokenAuth.AccessToken Wird automatisch vom Tool gesetzt +Dokumentverarbeitung +Unterstützte Dateitypen: PDF + +Event-Zuordnung erfolgt über EventIDManual im Dateinamen + +Unterstützte Formate: + +905-01 + +4843-01 + +12345-01 + +Bei mehreren Treffern im Dateinamen wird automatisch die längste ID verwendet + +Beispiel +ABM4843-01-Kunde-Angebot.pdf +→ EventIDManual = 4843-01 +Token-Authentifizierung +Das Tool verwendet CrewBrain Access Tokens (X-API-KEY): + +Beim ersten Start: + +Benutzername & Passwort werden einmalig abgefragt + +Token wird automatisch gespeichert + +Tokens bleiben 14 Tage nach letzter Nutzung gültig + +Bei 401/403 wird automatisch ein neuer Token angefordert + +⚠️ Passwörter werden nicht gespeichert + +Ergebnis +Erfolgreich hochgeladene Dateien werden in einen Uploaded/-Unterordner verschoben + +Bei Namenskonflikten wird ein Zeitstempel vor der Dateiendung ergänzt: + +Angebot_4843-01.pdf +→ Angebot_4843-01_202601210915301234.pdf +Hinweise +Fehlerhafte oder nicht zuordenbare Dateien werden nicht verschoben + +Alle API-Requests (inkl. Payload-Preview) sind im DEBUG-Log sichtbar + +Base64-Inhalte werden im Log nicht vollständig ausgegeben + diff --git a/EPI2CrewbrainFile/appsettings.json b/EPI2CrewbrainFile/appsettings.json new file mode 100644 index 0000000..ad057e9 --- /dev/null +++ b/EPI2CrewbrainFile/appsettings.json @@ -0,0 +1,21 @@ +{ + "Paths": { + "Angebot": "\\\\SRHNDATEN01\\GemeinsameDaten\\201 Dokumentation\\01 EpiPDF\\Angebot", + "Auftrag": "\\\\SRHNDATEN01\\GemeinsameDaten\\201 Dokumentation\\01 EpiPDF\\Auftrag", + "Lieferschein": "\\\\SRHNDATEN01\\GemeinsameDaten\\201 Dokumentation\\01 EpiPDF\\Lieferschein" + + }, + "Crewbrain": { + "Upload": { + "Angebot": { "TypeId": 2, "OverwriteSingleDocument": false }, + "Auftrag": { "TypeId": 3, "OverwriteSingleDocument": false }, + "Lieferschein": { "TypeId": 4, "OverwriteSingleDocument": false } + }, + "ApiBase": "https://vt-media.crewbrain.com/api", + + "TokenAuth": { + "AccessToken": "", + "SavedAtUtc": "" + } + } +} diff --git a/EPI2CrewbrainFile/log4net.config b/EPI2CrewbrainFile/log4net.config new file mode 100644 index 0000000..48dadda --- /dev/null +++ b/EPI2CrewbrainFile/log4net.config @@ -0,0 +1,28 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + +