From 876a8f9ed776f091800b5593d4898d317650fc1d Mon Sep 17 00:00:00 2001 From: Lorenz Stechauner Date: Wed, 9 Sep 2026 15:13:11 +0200 Subject: [PATCH] [WIP] certs --- Elwig/Helpers/AppDbUpdater.cs | 41 ++++++- Elwig/Helpers/Export/ElwigData.cs | 2 + 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 + 8 files changed, 245 insertions(+), 6 deletions(-) create mode 100644 Elwig/Services/OrganicService.cs diff --git a/Elwig/Helpers/AppDbUpdater.cs b/Elwig/Helpers/AppDbUpdater.cs index f1c79ee..8ff2c1f 100644 --- a/Elwig/Helpers/AppDbUpdater.cs +++ b/Elwig/Helpers/AppDbUpdater.cs @@ -1,3 +1,4 @@ +using Microsoft.Data.Sqlite; using System; using System.Collections.Generic; using System.IO; @@ -68,11 +69,11 @@ namespace Elwig.Helpers { }) .OrderBy(s => s.Item1).ThenBy(s => s.Item2)]; - List toExecute = []; + List<(int ToVersion, string File)> toExecute = []; var vers = fromVersion; while (vers < toVersion) { var (_, to, name) = scripts.Last(s => s.From == vers); - toExecute.Add(name); + toExecute.Add((to, name)); vers = to; } if (toExecute.Count == 0) @@ -87,8 +88,11 @@ namespace Elwig.Helpers { await cnx.IntegrityCheck(); await cnx.ForeignKeyCheck(); - foreach (var script in toExecute) { + foreach (var (to, script) in toExecute) { await cnx.ExecuteEmbeddedScript(asm, script); + if (to == 42) { + await UpdateDbSchema_41_To_42(cnx); + } } await cnx.IntegrityCheck(); @@ -103,5 +107,36 @@ namespace Elwig.Helpers { File.Delete(backup); } } + + private static async Task UpdateDbSchema_41_To_42(SqliteConnection cnx) { + List<(int MgNr, int LfbisNr, string Name, string Address, int Plz, string Locality)> members = []; + using (var cmd = cnx.CreateCommand()) { + // TODO + cmd.CommandText = """ + SELECT m.mgnr, m.lfbis_nr, '', '', 0, '' + FROM member m + LEFT JOIN member_billing_address a ON a.mgnr = m.mgnr + WHERE organic + """; + using var reader = await cmd.ExecuteReaderAsync(); + var header = await reader.GetColumnSchemaAsync(); + while (await reader.ReadAsync()) { + members.Add((reader.GetInt32(0), reader.GetInt32(1), reader.GetString(2), reader.GetString(3), reader.GetInt32(4), reader.GetString(5))); + } + } + + foreach (var m in members) { + string? oocId = null; + string? authCode = null; + + // TODO fetch traces certs + + if (oocId != null || authCode != null) { + oocId = oocId == null ? "NULL" : $"'{oocId}'"; + authCode = authCode == null ? "NULL" : $"'{authCode}'"; + await cnx.ExecuteBatch($"UPDATE member SET ooc_id = {oocId}, org_auth_code = {authCode} WHERE mgnr = {m.MgNr}"); + } + } + } } } diff --git a/Elwig/Helpers/Export/ElwigData.cs b/Elwig/Helpers/Export/ElwigData.cs index 147dc2f..b579220 100644 --- a/Elwig/Helpers/Export/ElwigData.cs +++ b/Elwig/Helpers/Export/ElwigData.cs @@ -638,6 +638,7 @@ namespace Elwig.Helpers.Export { ["zwstid"] = m.ZwstId, ["lfbis_nr"] = m.LfbisNr, ["ustid_nr"] = m.UstIdNr, + ["ooc_id"] = m.OrganicOperatorId, ["org_auth_code"] = m.OrganicAuthorityCode, ["juridical_pers"] = m.IsJuridicalPerson, ["volllieferant"] = m.IsVollLieferant, @@ -711,6 +712,7 @@ namespace Elwig.Helpers.Export { ZwstId = json["zwstid"]?.AsValue().GetValue(), LfbisNr = json["lfbis_nr"]?.AsValue().GetValue(), UstIdNr = json["ustid_nr"]?.AsValue().GetValue(), + OrganicOperatorId = json["ooc_id"]?.AsValue().GetValue(), OrganicAuthorityCode = json["org_auth_code"]?.AsValue().GetValue(), IsJuridicalPerson = json["juridical_pers"]?.AsValue().GetValue() ?? false, IsVollLieferant = json["volllieferant"]?.AsValue().GetValue() ?? false, diff --git a/Elwig/Models/Entities/Member.cs b/Elwig/Models/Entities/Member.cs index afa460c..8eda41f 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("ooc_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..db38a27 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 ooc_id TEXT CHECK (ooc_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..dfcf419 --- /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 2a6299e..01adca3 100644 --- a/Elwig/Windows/MemberAdminWindow.xaml +++ b/Elwig/Windows/MemberAdminWindow.xaml @@ -568,6 +568,10 @@ +