From 3dca09924806eec008102202e11dfe5ae139ef25 Mon Sep 17 00:00:00 2001 From: Lorenz Stechauner Date: Fri, 25 Sep 2026 16:55:46 +0200 Subject: [PATCH] [#67] Elwig: Add OrganicService --- Elwig/Helpers/AppDbUpdater.cs | 64 ++++++++- Elwig/Services/MemberService.cs | 80 ++++++++++- Elwig/Services/OrganicService.cs | 162 +++++++++++++++++++++++ Elwig/ViewModels/MemberAdminViewModel.cs | 12 ++ Elwig/Windows/MemberAdminWindow.xaml | 47 +++++-- Elwig/Windows/MemberAdminWindow.xaml.cs | 82 +++++++++++- 6 files changed, 424 insertions(+), 23 deletions(-) create mode 100644 Elwig/Services/OrganicService.cs diff --git a/Elwig/Helpers/AppDbUpdater.cs b/Elwig/Helpers/AppDbUpdater.cs index f1c79ee..a0f826d 100644 --- a/Elwig/Helpers/AppDbUpdater.cs +++ b/Elwig/Helpers/AppDbUpdater.cs @@ -1,9 +1,12 @@ +using Elwig.Services; +using Microsoft.Data.Sqlite; using System; using System.Collections.Generic; using System.IO; using System.Linq; using System.Reflection; using System.Threading.Tasks; +using System.Windows.Input; namespace Elwig.Helpers { public static class AppDbUpdater { @@ -68,11 +71,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 +90,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 +109,57 @@ namespace Elwig.Helpers { File.Delete(backup); } } + + private static async Task UpdateDbSchema_41_To_42(SqliteConnection cnx) { + List<(int MgNr, string LfbisNr, string Name, string Address, int Plz)> members = []; + using (var cmd = cnx.CreateCommand()) { + cmd.CommandText = """ + SELECT m.mgnr, m.lfbis_nr, + COALESCE(a.name, (COALESCE(m.prefix || ' ', '') || COALESCE(m.given_name || ' ', '') || m.name || COALESCE(' ' || m.middle_names, '') || COALESCE(' ' || m.suffix, ''))), + COALESCE(a.address, m.address), + COALESCE(aplz.plz, mplz.plz) + FROM member m + JOIN postal_dest mp ON (mp.country, mp.id) = (m.country, m.postal_dest) + JOIN AT_plz_dest ma ON (ma.country, ma.id) = (mp.country, mp.id) + JOIN AT_plz mplz ON mplz.plz = ma.plz + LEFT JOIN member_billing_address a ON a.mgnr = m.mgnr + LEFT JOIN postal_dest ap ON (ap.country, ap.id) = (a.country, a.postal_dest) + LEFT JOIN AT_plz_dest aa ON (aa.country, aa.id) = (ap.country, mp.id) + LEFT JOIN AT_plz aplz ON aplz.plz = aa.plz + WHERE organic + """; + using var reader = await cmd.ExecuteReaderAsync(); + var header = await reader.GetColumnSchemaAsync(); + while (await reader.ReadAsync()) { + members.Add((reader.GetInt32(0), reader.GetString(1), reader.GetString(2), reader.GetString(3), reader.GetInt32(4))); + } + } + + if (members.Count > 0) + InteractionService.ShowInformation("Datenbank-Update", "Es wird versucht, die Bio-Kontrollstellen-Informationen der Mitglieder automatisch abzurufen. Dies könnte einen kurzen Moment dauern."); + + try { + Mouse.OverrideCursor = Cursors.Wait; + foreach (var m in members) { + string? oocId = null; + string? authCode = null; + + var certs = await OrganicService.FetchTracesCertificates(lfbisNr: m.LfbisNr, address: (m.Name, m.Address, m.Plz.ToString())); + + if (certs.Length > 0) { + oocId = certs[0].OperatorId; + authCode = certs[0].AuthorityCode; + } + + 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}"); + } + } + } finally { + Mouse.OverrideCursor = null; + } + } } } diff --git a/Elwig/Services/MemberService.cs b/Elwig/Services/MemberService.cs index 13155f5..01f2f70 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 { @@ -19,6 +20,7 @@ namespace Elwig.Services { }; public static async Task InitInputs(this MemberAdminViewModel vm) { + vm.IsMemberSelectedOrCreating = true; using var ctx = new AppDbContext(); vm.MgNr = await ctx.NextMgNr(); vm.EntryDate = DateTime.Now.ToString("dd.MM.yyyy"); @@ -31,6 +33,7 @@ namespace Elwig.Services { public static void ClearInputs(this MemberAdminViewModel vm) { vm.IsMemberSelected = false; + vm.IsMemberSelectedOrCreating = false; vm.MemberHasEmail = false; vm.MemberCanSendEmail = false; vm.EnableMemberReferenceButton = false; @@ -49,10 +52,15 @@ namespace Elwig.Services { vm.ModifiedAtShort = "-"; vm.ExportedAt = "-"; vm.ImportedAt = "-"; + vm.OrganicState = null; + vm.OrganicColor = Brushes.Black; + vm.OrganicDetails = "-"; + vm.OrganicToolTip = null; } public static void FillInputs(this MemberAdminViewModel vm, Member m) { vm.IsMemberSelected = true; + vm.IsMemberSelectedOrCreating = true; vm.MgNr = m.MgNr; vm.PredecessorMgNr = m.PredecessorMgNr; vm.IsJuridicalPerson = m.IsJuridicalPerson; @@ -189,10 +197,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); @@ -205,13 +212,76 @@ namespace Elwig.Services { vm.StatusAreaCommitmentInfo = $"{Utils.CurrentLastSeason}"; vm.StatusAreaCommitment = text; vm.StatusAreaCommitmentToolTip = grid; - vm.MemberHasDeliveries = Enumerable.Range(0, 9999).Select(i => deliveries.GetValueOrDefault(i, false)).ToList(); + vm.MemberHasDeliveries = [.. Enumerable.Range(0, 9999).Select(i => deliveries.GetValueOrDefault(i, false))]; }); }); + vm.OrganicState = null; + vm.OrganicColor = Brushes.Black; + vm.OrganicToolTip = null; + if (!vm.IsOrganic) { + vm.OrganicDetails = "-"; + } else { + vm.OrganicDetails = "..."; + Utils.RunBackground("Bio-Zertifikatsdaten laden", async () => { + if (App.MainDispatcher == null) return; + var certs = await OrganicService.GetTracesCertificates(m); + await App.MainDispatcher.BeginInvoke(() => { + if (m.MgNr != vm.MgNr) return; + vm.FillOrganicInputs(certs.Length > 0 ? certs[0] : null); + }); + }); + } + 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(); + vm.MemberHasDeliveries = [.. Enumerable.Range(0, 9999).Select(i => false)]; + } + + public static void FillOrganicInputs(this MemberAdminViewModel vm, OrganicService.TracesCertificate? cert) { + vm.OrganicDetails = null; + if (cert == null) { + vm.OrganicState = "Nicht gefunden"; + vm.OrganicColor = Brushes.DarkRed; + vm.OrganicToolTip = null; + return; + } + + vm.OrganicToolTip = $""" + Zertifikat-Nr.: {cert.Id} + Status: {cert.Status} + Bio-KSt.: {cert.AuthorityCode} + EU Operator-ID: {cert.OperatorId} + Operator: {cert.OperatorName} + Ausgestellt am: {cert.IssuedOn:dd.MM.yyyy} + Aktualisiert am: {cert.LastStatusUpdateOn:dd.MM.yyyy} + Erlischt am: {cert.ExpiresOn:dd.MM.yyyy} + """; + if (cert.IsValidForWineProduction) { + vm.OrganicState = "Gültig"; + vm.OrganicDetails = $" ({cert.ExpiresOn:MMM. yyyy})"; + vm.OrganicColor = Brushes.DarkGreen; + } else if (cert.IsValid) { + vm.OrganicState = "Gültig"; + vm.OrganicDetails = $" ({cert.ExpiresOn:MMM. yyyy})"; + vm.OrganicColor = Brushes.OrangeRed; + } else { + vm.OrganicColor = Brushes.DarkRed; + switch (cert.Status) { + case OrganicService.TracesStatus.ISSUED: + vm.OrganicState = "Ausgestellt"; + vm.OrganicDetails = $" ({cert.LastStatusUpdateOn:MMM. yyyy})"; break; + case OrganicService.TracesStatus.SUSPENDED: + vm.OrganicState = "Gesperrt"; + vm.OrganicDetails = $" ({cert.LastStatusUpdateOn:MMM. yyyy})"; break; + case OrganicService.TracesStatus.EXPIRED: + vm.OrganicState = "Ausgelaufen"; + vm.OrganicDetails = $" ({cert.LastStatusUpdateOn:MMM. yyyy})"; break; + case OrganicService.TracesStatus.WITHDRAWN: + vm.OrganicState = "Widerrufen"; + vm.OrganicDetails = $" ({cert.LastStatusUpdateOn:MMM. yyyy})"; break; + } + } } public static async Task<(List, IQueryable, List)> GetFilters(this MemberAdminViewModel vm, AppDbContext ctx) { diff --git a/Elwig/Services/OrganicService.cs b/Elwig/Services/OrganicService.cs new file mode 100644 index 0000000..ae76e29 --- /dev/null +++ b/Elwig/Services/OrganicService.cs @@ -0,0 +1,162 @@ +using Elwig.Helpers; +using Elwig.Models.Entities; +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Text.RegularExpressions; +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 static readonly Dictionary _memberCerts = []; + + public async static Task GetTracesCertificates(Member m, bool? tryNameAndAddress = null) { + if (_memberCerts.TryGetValue(m.MgNr, out var entry) && entry.Timestamp >= DateTime.Now.AddDays(-1)) + return entry.Certificates; + var certs = await FetchTracesCertificates(m, tryNameAndAddress); + _memberCerts[m.MgNr] = (DateTime.Now, certs); + return certs; + } + + 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()); + return resJson?.AsArray() ?? throw new JsonException(); + } + + public static Task FetchTracesCertificates(Member m, bool? tryNameAndAddress = null) { + return FetchTracesCertificates(m.OrganicOperatorId, m.LfbisNr, + (tryNameAndAddress ?? m.IsOrganic) ? m.BillingAddress is BillingAddr a ? + (a.FullName, a.Address, $"{a.PostalDest.AtPlz?.Plz}") : + (m.FullName, m.Address, $"{m.PostalDest.AtPlz?.Plz}") : + null); + } + + public async static Task FetchTracesCertificates(string? oocId = null, string? lfbisNr = null, (string Name, string Address, string PostalCode)? address = null) { + bool searchedByName = false; + JsonArray jsonCerts = []; + + if (jsonCerts.Count == 0 && oocId != null) { + jsonCerts = await TryFetchTracesCertificates(new Dictionary { + { "operatorIdentifierType", "ooc_identifier" }, + { "operatorIdentifierSearchOperator", "STRICT" }, + { "operatorIdentifier", oocId }, + }); + } + + if (jsonCerts.Count == 0 && lfbisNr != null) { + jsonCerts = await TryFetchTracesCertificates(new Dictionary { + { "operatorIdentifierType", "comp_reg" }, + { "operatorIdentifierSearchOperator", "STRICT" }, + { "operatorIdentifier", lfbisNr.TrimStart('0') }, + }); + } + + if (jsonCerts.Count == 0 && address != null) { + var query = $"{Regex.Replace(address.Value.Name, @"\(.*?\)", "")} {address.Value.Address}"; + jsonCerts = await TryFetchTracesCertificates(new Dictionary { + { "operatorPostalCode", address.Value.PostalCode }, + { "query", query.Length > 40 ? query[..query.LastIndexOf(' ', 40)] : query }, + }); + searchedByName = true; + } + + if (jsonCerts.Count == 0) + return []; + + TracesCertificate[] 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()), + })]; + + return (!searchedByName || certs.Where(c => c.Status == TracesStatus.ISSUED).Select(c => c.OperatorId).ToHashSet().Count == 1) ? certs : []; + } + + public static string? GetTracesSearchUrl(Member m, bool? tryNameAndAddress = null) { + return GetTracesSearchUrl(m.OrganicOperatorId, m.LfbisNr, + (tryNameAndAddress ?? m.IsOrganic) ? m.BillingAddress is BillingAddr a ? + (a.FullName, a.Address, $"{a.PostalDest.AtPlz?.Plz}") : + (m.FullName, m.Address, $"{m.PostalDest.AtPlz?.Plz}") : + null); + } + + public static string? GetTracesSearchUrl(string? oocId = null, string? lfbisNr = null, (string Name, string Address, string PostalCode)? address = null) { + var baseUrl = "https://webgate.ec.europa.eu/tracesnt/directory/publication/organic-operator/index"; + if (oocId != null) { + return $"{baseUrl}#!?countryCode=AT&operatorIdentifierType=ooc_identifier&operatorIdentifierSearchOperator=STRICT&operatorIdentifier={oocId}&states=ISSUED"; + } else if (lfbisNr != null) { + return $"{baseUrl}#!?countryCode=AT&operatorIdentifierType=comp_reg&operatorIdentifierSearchOperator=STRICT&operatorIdentifier={lfbisNr.TrimStart('0')}&states=ISSUED"; + } else if (address != null) { + return $"{baseUrl}#!?countryCode=AT&operatorPostalCode={address?.PostalCode}&query={address?.Name.Replace(' ', '+')}+{address?.Address.Replace(' ', '+')}&states=ISSUED"; + } else { + return null; + } + } + + public static string? GetEasyCertSearchUrl(string lfbisNr) { + var baseUrl = "https://www.easy-cert.com/htm/suchergebnis.htm"; + return $"{baseUrl}?CountryCode=AT&CustomerNumber={lfbisNr.TrimStart('0')}"; + } + + public static string? GetBiocSearchUrl(string lfbisNr) { + var baseUrl = "https://www.bioc.info/search/producersearchresult"; + return $"{baseUrl}?producerSearch[country]=a1e51f85-27b5-a1e3-65f7-561569884d38&producerSearch[operatorId]={lfbisNr.TrimStart('0')}"; + } + } +} diff --git a/Elwig/ViewModels/MemberAdminViewModel.cs b/Elwig/ViewModels/MemberAdminViewModel.cs index 6fa4555..1928a9f 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 { @@ -33,6 +34,8 @@ namespace Elwig.ViewModels { [ObservableProperty] private bool _isMemberSelected; [ObservableProperty] + private bool _isMemberSelectedOrCreating; + [ObservableProperty] private bool _memberHasEmail; [ObservableProperty] private bool _memberCanSendEmail; @@ -126,6 +129,15 @@ namespace Elwig.ViewModels { [ObservableProperty] private bool _isOrganic; + [ObservableProperty] + private string? _organicState; + [ObservableProperty] + private string? _organicDetails = "-"; + [ObservableProperty] + private Brush? _organicColor = Brushes.Black; + [ObservableProperty] + private string? _organicToolTip; + [ObservableProperty] private string? _entryDate; [ObservableProperty] diff --git a/Elwig/Windows/MemberAdminWindow.xaml b/Elwig/Windows/MemberAdminWindow.xaml index c1c616c..4f46d25 100644 --- a/Elwig/Windows/MemberAdminWindow.xaml +++ b/Elwig/Windows/MemberAdminWindow.xaml @@ -566,7 +566,7 @@ TextChanged="UstIdNrInput_TextChanged" LostFocus="UstIdNrInput_LostFocus"/> - + + @@ -657,46 +658,64 @@ + Grid.Column="2" HorizontalAlignment="Left" Margin="0,15,0,0" VerticalAlignment="Top"/> + Grid.Column="2" HorizontalAlignment="Left" Margin="0,45,0,0" VerticalAlignment="Top"/> + Grid.Column="2" HorizontalAlignment="Left" Margin="0,75,0,0" VerticalAlignment="Top"/> + +