Projektdateien hinzufügen.
This commit is contained in:
3
EPI2CrewbrainFile.slnx
Normal file
3
EPI2CrewbrainFile.slnx
Normal file
@@ -0,0 +1,3 @@
|
||||
<Solution>
|
||||
<Project Path="EPI2CrewbrainFile/EPI2CrewbrainFile.csproj" />
|
||||
</Solution>
|
||||
4
EPI2CrewbrainFile/DocumentFile.cs
Normal file
4
EPI2CrewbrainFile/DocumentFile.cs
Normal file
@@ -0,0 +1,4 @@
|
||||
namespace EPI2CrewbrainFile
|
||||
{
|
||||
internal record DocumentFile(string Dateiname, string Dateiendung, string Datei);
|
||||
}
|
||||
79
EPI2CrewbrainFile/Documents.cs
Normal file
79
EPI2CrewbrainFile/Documents.cs
Normal file
@@ -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<Documents> FromJson(string json) =>
|
||||
JsonConvert.DeserializeObject<List<Documents>>(json, Converter.Settings);
|
||||
}
|
||||
|
||||
public static class Serialize
|
||||
{
|
||||
public static string ToJson(this List<Documents> 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 }
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
28
EPI2CrewbrainFile/EPI2CrewbrainFile.csproj
Normal file
28
EPI2CrewbrainFile/EPI2CrewbrainFile.csproj
Normal file
@@ -0,0 +1,28 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="log4net" Version="3.1.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration" Version="10.0.2" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.FileExtensions" Version="10.0.2" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="10.0.2" />
|
||||
<PackageReference Include="Newtonsoft.Json" Version="13.0.4" />
|
||||
<PackageReference Include="RestSharp" Version="113.1.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<None Update="appsettings.json">
|
||||
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
|
||||
</None>
|
||||
<None Update="log4net.config">
|
||||
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
|
||||
</None>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
659
EPI2CrewbrainFile/Program.cs
Normal file
659
EPI2CrewbrainFile/Program.cs
Normal file
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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).
|
||||
/// </summary>
|
||||
private static string ExtractEventIdManual(string fileName)
|
||||
{
|
||||
// Match: 3..5 digits - 2 digits, nicht Teil einer größeren Zahl
|
||||
var matches = Regex.Matches(fileName, @"(?<!\d)(\d{3,5}-\d{2})(?!\d)");
|
||||
if (matches.Count == 0) return "";
|
||||
|
||||
return matches
|
||||
.Select(m => 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<string>() ?? "").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<int>();
|
||||
}
|
||||
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 = $"<base64 len={base64.Length} bytes={bytes.Length}>",
|
||||
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<string>();
|
||||
var query = new List<string>();
|
||||
|
||||
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})";
|
||||
}
|
||||
}
|
||||
128
EPI2CrewbrainFile/README.md
Normal file
128
EPI2CrewbrainFile/README.md
Normal file
@@ -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://<crewbrain-domain>/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.<Typ>.TypeId TypeID der Zusatzinformation (siehe oben)
|
||||
Upload.<Typ>.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
|
||||
|
||||
21
EPI2CrewbrainFile/appsettings.json
Normal file
21
EPI2CrewbrainFile/appsettings.json
Normal file
@@ -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": ""
|
||||
}
|
||||
}
|
||||
}
|
||||
28
EPI2CrewbrainFile/log4net.config
Normal file
28
EPI2CrewbrainFile/log4net.config
Normal file
@@ -0,0 +1,28 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<log4net>
|
||||
<appender name="RollingFileAppender" type="log4net.Appender.RollingFileAppender">
|
||||
<file value="logs/ATEPUC.log" />
|
||||
<appendToFile value="true" />
|
||||
<rollingStyle value="Composite" />
|
||||
<datePattern value="yyyyMMdd" />
|
||||
<staticLogFileName value="false" />
|
||||
<maxSizeRollBackups value="30" />
|
||||
<maximumFileSize value="10MB" />
|
||||
<layout type="log4net.Layout.PatternLayout">
|
||||
<conversionPattern value="%date{ISO8601} %-5level %logger - %message%newline%exception" />
|
||||
</layout>
|
||||
<lockingModel type="log4net.Appender.FileAppender+MinimalLock" />
|
||||
</appender>
|
||||
|
||||
<appender name="ConsoleAppender" type="log4net.Appender.ConsoleAppender">
|
||||
<layout type="log4net.Layout.PatternLayout">
|
||||
<conversionPattern value="%date{HH:mm:ss} %-5level %message%newline" />
|
||||
</layout>
|
||||
</appender>
|
||||
|
||||
<root>
|
||||
<level value="INFO" />
|
||||
<appender-ref ref="RollingFileAppender" />
|
||||
<appender-ref ref="ConsoleAppender" />
|
||||
</root>
|
||||
</log4net>
|
||||
Reference in New Issue
Block a user