From 2b3d38ee202a56fb3dd30f2d246930e7f7a835bd Mon Sep 17 00:00:00 2001 From: Lorenz Stechauner Date: Wed, 9 Sep 2026 15:13:11 +0200 Subject: [PATCH] [WIP] certs --- Elwig/Models/Entities/Member.cs | 3 + Elwig/Resources/Sql/41-42.sql | 1 + Elwig/Services/MemberService.cs | 58 +++++++++- Elwig/Services/OrganicService.cs | 132 +++++++++++++++++++++++ Elwig/ViewModels/MemberAdminViewModel.cs | 10 ++ Elwig/Windows/MemberAdminWindow.xaml | 4 + 6 files changed, 205 insertions(+), 3 deletions(-) create mode 100644 Elwig/Services/OrganicService.cs diff --git a/Elwig/Models/Entities/Member.cs b/Elwig/Models/Entities/Member.cs index afa460c..5c7e2ea 100644 --- a/Elwig/Models/Entities/Member.cs +++ b/Elwig/Models/Entities/Member.cs @@ -96,6 +96,9 @@ namespace Elwig.Models.Entities { [Column("ustid_nr")] public string? UstIdNr { get; set; } + [Column("org_op_id")] + public string? OrganicOperatorId { get; set; } + [Column("org_auth_code")] public string? OrganicAuthorityCode { get; set; } diff --git a/Elwig/Resources/Sql/41-42.sql b/Elwig/Resources/Sql/41-42.sql index 428c4fc..6af84ee 100644 --- a/Elwig/Resources/Sql/41-42.sql +++ b/Elwig/Resources/Sql/41-42.sql @@ -1,3 +1,4 @@ -- schema version 41 to 42 +ALTER TABLE member ADD COLUMN org_op_id TEXT CHECK (org_op_id REGEXP '^[0-9]{3}-[0-9]{7}$') DEFAULT NULL; ALTER TABLE member ADD COLUMN org_auth_code TEXT CHECK (org_auth_code REGEXP '^[A-Z]{2}-[A-ZÖØ]{3}-[0-9]{2,3}(-[A-Z]{2})?$') DEFAULT NULL; diff --git a/Elwig/Services/MemberService.cs b/Elwig/Services/MemberService.cs index 3624c4f..aba27b0 100644 --- a/Elwig/Services/MemberService.cs +++ b/Elwig/Services/MemberService.cs @@ -10,6 +10,7 @@ using System; using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; +using System.Windows.Media; namespace Elwig.Services { public static class MemberService { @@ -188,10 +189,9 @@ namespace Elwig.Services { .GroupBy(d => d.Year) .ToDictionaryAsync(g => g.Key, g => g.Any()); - if (m.MgNr != vm.MgNr) - return; - await App.MainDispatcher.BeginInvoke(() => { + if (m.MgNr != vm.MgNr) + return; var (d1Grid, _) = DeliveryService.GenerateToolTip(d1GridData, []); var (d2Grid, _) = DeliveryService.GenerateToolTip(d2GridData, []); var grid = AreaComService.GenerateToolTip(gridData); @@ -208,6 +208,58 @@ namespace Elwig.Services { }); }); + vm.OrganicTest1 = null; + vm.OrganicTest2 = "..."; + vm.OrganicTest3 = Brushes.Black; + vm.OrganicTest4 = null; + Utils.RunBackground("Bio-Zertifikatsdaten laden", async () => { + if (App.MainDispatcher == null) + return; + + var certs = await OrganicService.FetchTracesCertificatesOfMember(m); + + // TODO button for TRACES search url + // TODO button for authority search url + + await App.MainDispatcher.BeginInvoke(() => { + if (m.MgNr != vm.MgNr) + return; + vm.OrganicTest2 = null; + if (certs.Length == 0) { + vm.OrganicTest1 = "Nicht gefunden"; + vm.OrganicTest3 = Brushes.DarkRed; + } else { + var cert = certs[0]; + vm.OrganicTest4 = $"Zertifikat-Nr.: {cert.Id}\nBio-KSt.: {cert.AuthorityCode}\nEU Operator-ID: {cert.OperatorId}\nOperator: {cert.OperatorName}"; + if (cert.IsValidForWineProduction) { + vm.OrganicTest1 = "Gültig"; + vm.OrganicTest2 = $" (bis {cert.ExpiresOn:dd.MM.yyyy})"; + vm.OrganicTest3 = Brushes.DarkGreen; + } else if (cert.IsValid) { + vm.OrganicTest1 = "Teilw. gültig"; + vm.OrganicTest2 = $" (bis {cert.ExpiresOn:dd.MM.yyyy})"; + vm.OrganicTest3 = Brushes.OrangeRed; + } else { + vm.OrganicTest3 = Brushes.DarkRed; + switch (cert.Status) { + case OrganicService.TracesStatus.ISSUED: + vm.OrganicTest1 = "Ausgestellt"; + vm.OrganicTest2 = $" (am {cert.LastStatusUpdateOn:dd.MM.yyyy})"; break; + case OrganicService.TracesStatus.SUSPENDED: + vm.OrganicTest1 = "Gesperrt"; + vm.OrganicTest2 = $" (am {cert.LastStatusUpdateOn:dd.MM.yyyy})"; break; + case OrganicService.TracesStatus.EXPIRED: + vm.OrganicTest1 = "Ausgelaufen"; + vm.OrganicTest2 = $" (am {cert.LastStatusUpdateOn:dd.MM.yyyy})"; break; + case OrganicService.TracesStatus.WITHDRAWN: + vm.OrganicTest1 = "Widerrufen"; + vm.OrganicTest2 = $" (am {cert.LastStatusUpdateOn:dd.MM.yyyy})"; break; + } + } + } + }); + }); + vm.MemberHasEmail = m.EmailAddresses.Count > 0; vm.MemberCanSendEmail = App.Config.Smtp != null && m.EmailAddresses.Count > 0; vm.MemberHasDeliveries = Enumerable.Range(0, 9999).Select(i => false).ToList(); diff --git a/Elwig/Services/OrganicService.cs b/Elwig/Services/OrganicService.cs new file mode 100644 index 0000000..71863d5 --- /dev/null +++ b/Elwig/Services/OrganicService.cs @@ -0,0 +1,132 @@ +using Elwig.Helpers; +using Elwig.Models.Entities; +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Text.Json.Nodes; +using System.Threading.Tasks; +using System.Web; + +namespace Elwig.Services { + public static class OrganicService { + + public const string TRACES_API_URL = "https://webgate.ec.europa.eu/tracesnt/directory/publication/organic-operator/for/query"; + public const string TRACES_PDF_URL_PREFIX = "https://webgate.ec.europa.eu/tracesnt/directory/publication/organic-operator/digitally-signed/"; + + public enum TracesStatus { + ISSUED, SUSPENDED, WITHDRAWN, EXPIRED + } + + public enum TracesActivity { + PREPARATION, EXPORT, IMPORT, STORING, PRODUCTION, DISTRIBUTION, DISTRIBUTION_PLACING_ON_THE_MARKET + } + + public enum TracesCategoryOfProduct { + UNPROCESSED_PLANT_PRODUCTS_INCLUDING_SEEDS, LIVESTOCK_AND_UNPROCESSED_LIVESTOCK_PRODUCTS, ALGAE_AND_UNPROCESSED_AQUACULTURE_PRODUCTS, + PROCESSED_AGRICULTURAL_PRODUCTS_INCLUDING_AQUACULTURE_FOR_USE_AS_FOOD, FEED, WINE, OTHER_PRODUCTS_NOT_COVERED_BY_PREVIOUS_CATEGORIES + } + + public record TracesCertificate { + public required string Id { get; init; } + public required string AuthorityCode { get; init; } + public required string OperatorId { get; init; } + public required string OperatorName { get; init; } + public required TracesActivity[] Activities { get; init; } + public required TracesCategoryOfProduct[] CategoriesOfProduct { get; init; } + public required DateOnly IssuedOn { get; init; } + public required DateOnly ExpiresOn { get; init; } + public required DateOnly LastStatusUpdateOn { get; init; } + public required TracesStatus Status { get; init; } + + public string PdfUrl => TRACES_PDF_URL_PREFIX + Id + ".pdf"; + public bool IsValid => Status == TracesStatus.ISSUED && IssuedOn.ToDateTime(new()) <= DateTime.Today && ExpiresOn.ToDateTime(new()) >= DateTime.Today; + public bool IsValidForWineProduction => IsValid && Activities.Contains(TracesActivity.PRODUCTION) && + (CategoriesOfProduct.Contains(TracesCategoryOfProduct.UNPROCESSED_PLANT_PRODUCTS_INCLUDING_SEEDS) || CategoriesOfProduct.Contains(TracesCategoryOfProduct.WINE)); + } + + private async static Task TryFetchTracesCertificates(Dictionary query) { + var q = HttpUtility.ParseQueryString(""); + q.Add("sort", "-issuedOn"); + q.Add("countryCode", "AT"); + foreach (var (k, v) in query) { + q.Add(k, v); + } + + using var client = Utils.GetHttpClient(); + using var res = await client.GetAsync($"{TRACES_API_URL}?{q}"); + res.EnsureSuccessStatusCode(); + var resJson = JsonNode.Parse(await res.Content.ReadAsStringAsync()); + var certs = resJson?.AsArray() ?? throw new Exception(); + if (certs.Count == 0) + throw new Exception(); + return certs; + } + + public async static Task FetchTracesCertificatesOfMember(Member m, bool? tryNameAndAddress = null) { + bool searchedByName = false; + + JsonArray? jsonCerts = null; + if (m.OrganicOperatorId != null) { + try { + jsonCerts = await TryFetchTracesCertificates(new Dictionary { + { "operatorIdentifierType", "ooc_identifier" }, + { "operatorIdentifierSearchOperator", "STRICT" }, + { "operatorIdentifier", m.OrganicOperatorId }, + }); + } catch { + jsonCerts = null; + } + } + + if (jsonCerts == null && m.LfbisNr != null) { + try { + jsonCerts = await TryFetchTracesCertificates(new Dictionary { + { "operatorIdentifierType", "comp_reg" }, + { "operatorIdentifierSearchOperator", "STRICT" }, + { "operatorIdentifier", m.LfbisNr.TrimStart('0') }, + }); + } catch { + jsonCerts = null; + } + } + + if (jsonCerts == null && (tryNameAndAddress ?? m.IsOrganic)) { + try { + jsonCerts = await TryFetchTracesCertificates(new Dictionary { + { "operatorPostalCode", $"{(m.BillingAddress?.PostalDest ?? m.PostalDest).AtPlz?.Plz}" }, + { "query", m.BillingAddress is BillingAddr a ? $"{a.FullName} {a.Address}" : $"{m.FullName} {m.Address}" }, + }); + searchedByName = true; + } catch { + jsonCerts = null; + } + } + + if (jsonCerts == null) + return []; + + TracesCertificate[] certs = []; + try { + certs = [.. jsonCerts.Select(j => new TracesCertificate { + Id = j!["reference"]?.GetValue() ?? throw new Exception(), + AuthorityCode = j["issuingBody"]?["code"]?.GetValue() ?? throw new Exception(), + OperatorId = j["operatorIdentifier"]?.GetValue() ?? throw new Exception(), + OperatorName = j["operator"]?["name"]?.GetValue() ?? throw new Exception(), + Activities = [.. j["activities"]?.AsArray().Select(a => Enum.Parse(a?["id"]?.GetValue().ToUpper().Replace("_IMPORT", "IMPORT") ?? throw new Exception())) ?? []], + CategoriesOfProduct = [.. j["categoriesOfProduct"]?.AsArray().Select(c => Enum.Parse(c?["id"]?.GetValue() ?? throw new Exception())) ?? []], + IssuedOn = DateOnly.FromDateTime(DateTime.ParseExact(j["issuedOn"]?.GetValue() ?? throw new Exception(), "yyyy-MM-ddTHH:mm:ss.fffK", CultureInfo.InvariantCulture, DateTimeStyles.None)), + ExpiresOn = DateOnly.ParseExact(j["expiresOn"]?.GetValue() ?? throw new Exception(), "yyyy-MM-dd"), + LastStatusUpdateOn = DateOnly.FromDateTime(DateTime.ParseExact(j["lastStatusUpdateDateTime"]?.GetValue() ?? throw new Exception(), "yyyy-MM-ddTHH:mm:ss.fffK", CultureInfo.InvariantCulture, DateTimeStyles.None)), + Status = Enum.Parse(j["status"]?["id"]?.GetValue() ?? throw new Exception()), + })]; + } catch { + return []; + } + + return (!searchedByName || certs.Where(c => c.Status == TracesStatus.ISSUED).Select(c => c.OperatorId).ToHashSet().Count == 1) ? certs : []; + } + + + } +} diff --git a/Elwig/ViewModels/MemberAdminViewModel.cs b/Elwig/ViewModels/MemberAdminViewModel.cs index ea882f5..512af0f 100644 --- a/Elwig/ViewModels/MemberAdminViewModel.cs +++ b/Elwig/ViewModels/MemberAdminViewModel.cs @@ -6,6 +6,7 @@ using System.Collections.ObjectModel; using System.Linq; using System.Windows; using System.Windows.Controls; +using System.Windows.Media; namespace Elwig.ViewModels { public partial class MemberAdminViewModel : ObservableObject { @@ -124,6 +125,15 @@ namespace Elwig.ViewModels { [ObservableProperty] private bool _isOrganic; + [ObservableProperty] + private string? _organicTest1; + [ObservableProperty] + private string? _organicTest2; + [ObservableProperty] + private Brush? _organicTest3; + [ObservableProperty] + private string? _organicTest4; + [ObservableProperty] private string? _entryDate; [ObservableProperty] diff --git a/Elwig/Windows/MemberAdminWindow.xaml b/Elwig/Windows/MemberAdminWindow.xaml index 012ef95..915f4c1 100644 --- a/Elwig/Windows/MemberAdminWindow.xaml +++ b/Elwig/Windows/MemberAdminWindow.xaml @@ -568,6 +568,10 @@ +