[#67] Elwig: Add OrganicService

This commit is contained in:
2026-09-29 19:04:13 +02:00
parent 155a599878
commit 3dca099248
6 changed files with 424 additions and 23 deletions
+162
View File
@@ -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<int, (DateTime Timestamp, TracesCertificate[] Certificates)> _memberCerts = [];
public async static Task<TracesCertificate[]> 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<JsonArray> TryFetchTracesCertificates(Dictionary<string, string> 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<TracesCertificate[]> 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<TracesCertificate[]> 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<string, string> {
{ "operatorIdentifierType", "ooc_identifier" },
{ "operatorIdentifierSearchOperator", "STRICT" },
{ "operatorIdentifier", oocId },
});
}
if (jsonCerts.Count == 0 && lfbisNr != null) {
jsonCerts = await TryFetchTracesCertificates(new Dictionary<string, string> {
{ "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<string, string> {
{ "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<string>() ?? throw new Exception(),
AuthorityCode = j["issuingBody"]?["code"]?.GetValue<string>() ?? throw new Exception(),
OperatorId = j["operatorIdentifier"]?.GetValue<string>() ?? throw new Exception(),
OperatorName = j["operator"]?["name"]?.GetValue<string>() ?? throw new Exception(),
Activities = [.. j["activities"]?.AsArray().Select(a => Enum.Parse<TracesActivity>(a?["id"]?.GetValue<string>().ToUpper().Replace("_IMPORT", "IMPORT") ?? throw new Exception())) ?? []],
CategoriesOfProduct = [.. j["categoriesOfProduct"]?.AsArray().Select(c => Enum.Parse<TracesCategoryOfProduct>(c?["id"]?.GetValue<string>() ?? throw new Exception())) ?? []],
IssuedOn = DateOnly.FromDateTime(DateTime.ParseExact(j["issuedOn"]?.GetValue<string>() ?? throw new Exception(), "yyyy-MM-ddTHH:mm:ss.fffK", CultureInfo.InvariantCulture, DateTimeStyles.None)),
ExpiresOn = DateOnly.ParseExact(j["expiresOn"]?.GetValue<string>() ?? throw new Exception(), "yyyy-MM-dd"),
LastStatusUpdateOn = DateOnly.FromDateTime(DateTime.ParseExact(j["lastStatusUpdateDateTime"]?.GetValue<string>() ?? throw new Exception(), "yyyy-MM-ddTHH:mm:ss.fffK", CultureInfo.InvariantCulture, DateTimeStyles.None)),
Status = Enum.Parse<TracesStatus>(j["status"]?["id"]?.GetValue<string>() ?? 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')}";
}
}
}