using System.Globalization; using System.Text.Json; using System.Text.RegularExpressions; using Lagerbestand.Models; namespace Lagerbestand.Services; /// /// Parsed data of a tire measurement report (Launch TTS01 tread depth scanner). /// public class TireReportData { public string RecordSn { get; set; } = string.Empty; public string ReportUrl { get; set; } = string.Empty; public DateTime? MeasuredAt { get; set; } public string? LicensePlate { get; set; } public int? Mileage { get; set; } public decimal? TreadDepthFL { get; set; } public decimal? TreadDepthFR { get; set; } public decimal? TreadDepthRL { get; set; } public decimal? TreadDepthRR { get; set; } public TireMeasureRating RatingFL { get; set; } public TireMeasureRating RatingFR { get; set; } public TireMeasureRating RatingRL { get; set; } public TireMeasureRating RatingRR { get; set; } public decimal? WarningThreshold { get; set; } public decimal? CriticalThreshold { get; set; } public decimal? BrakingDistanceDelta { get; set; } public string? Recommendation { get; set; } public string? DeviceSerial { get; set; } public string? RawJson { get; set; } /// True if the report itself contained tread depth values. public bool HasTreadData => TreadDepthFL.HasValue || TreadDepthFR.HasValue || TreadDepthRL.HasValue || TreadDepthRR.HasValue; public TireInspection ToInspection(Guid tireStackId, Guid? userId) => new() { TireStackId = tireStackId, Source = "Launch TTS01", RecordSn = Truncate(RecordSn, 100), ReportUrl = Truncate(ReportUrl, 500), MeasuredAt = MeasuredAt, LicensePlate = Truncate(LicensePlate?.Trim().ToUpperInvariant(), 20), Mileage = Mileage is < 0 ? null : Mileage, TreadDepthFL = TreadDepthFL, TreadDepthFR = TreadDepthFR, TreadDepthRL = TreadDepthRL, TreadDepthRR = TreadDepthRR, RatingFL = RatingFL, RatingFR = RatingFR, RatingRL = RatingRL, RatingRR = RatingRR, Recommendation = Truncate(Recommendation, 1000), RawJson = RawJson, CreatedByUserId = userId }; /// /// Re-evaluates the per-wheel ratings from the current tread depths using the /// thresholds delivered by the device (falls back to legal defaults). /// public void RecomputeRatings() { RatingFL = TireInspection.RateDepth(TreadDepthFL, CriticalThreshold, WarningThreshold); RatingFR = TireInspection.RateDepth(TreadDepthFR, CriticalThreshold, WarningThreshold); RatingRL = TireInspection.RateDepth(TreadDepthRL, CriticalThreshold, WarningThreshold); RatingRR = TireInspection.RateDepth(TreadDepthRR, CriticalThreshold, WarningThreshold); } private static string? Truncate(string? s, int max) { if (string.IsNullOrEmpty(s)) return s; return s.Length <= max ? s : s[..max]; } } public interface ITireReportService { /// /// Tries to extract the report record number from a scanned QR text /// (full share URL or bare record number). /// bool TryParseReportUrl(string? qrText, out string recordSn, out string reportUrl); /// /// Loads and parses the report. Returns a data object with at least /// RecordSn/ReportUrl; HasTreadData indicates whether values could be read. /// Throws with a user-friendly message on failure. /// Task FetchAsync(string recordSn, CancellationToken cancellationToken = default); } public class TireReportException : Exception { public TireReportException(string message, Exception? inner = null) : base(message, inner) { } } public class TireReportService : ITireReportService { private const string DefaultShareUrl = "https://files.cnlaunch.com/assets/TTS01DetectReportShare.html?dataRecordSn="; private const string DefaultApiUrl = "https://files.cnlaunch.com/api/ttm/getTtmReportData"; private static readonly Regex RecordSnRegex = new(@"dataRecordSn=([A-Za-z0-9_-]+)", RegexOptions.IgnoreCase | RegexOptions.Compiled); private static readonly Regex BareSnRegex = new(@"^[A-Za-z0-9_-]{16,64}$", RegexOptions.Compiled); private readonly IHttpClientFactory _httpClientFactory; private readonly ILogger _logger; private readonly string _apiUrl; private readonly string _shareUrl; public TireReportService(IHttpClientFactory httpClientFactory, IConfiguration configuration, ILogger logger) { _httpClientFactory = httpClientFactory; _logger = logger; _apiUrl = configuration["LaunchReport:ApiUrl"] ?? DefaultApiUrl; _shareUrl = configuration["LaunchReport:ShareUrl"] ?? DefaultShareUrl; } public bool TryParseReportUrl(string? qrText, out string recordSn, out string reportUrl) { recordSn = string.Empty; reportUrl = string.Empty; if (string.IsNullOrWhiteSpace(qrText)) return false; var text = qrText.Trim(); var m = RecordSnRegex.Match(text); if (m.Success) { recordSn = m.Groups[1].Value; reportUrl = text.StartsWith("http", StringComparison.OrdinalIgnoreCase) ? text : _shareUrl + recordSn; return true; } if (BareSnRegex.IsMatch(text)) { recordSn = text; reportUrl = _shareUrl + recordSn; return true; } return false; } public async Task FetchAsync(string recordSn, CancellationToken cancellationToken = default) { var result = new TireReportData { RecordSn = recordSn, ReportUrl = _shareUrl + recordSn }; var client = _httpClientFactory.CreateClient("LaunchReport"); string json; try { using var response = await client.PostAsJsonAsync(_apiUrl, new { dataRecordSn = recordSn }, cancellationToken); if (!response.IsSuccessStatusCode) { _logger.LogWarning("Launch report {Sn}: HTTP {Status}", recordSn, (int)response.StatusCode); throw new TireReportException($"Report-Server antwortete mit HTTP {(int)response.StatusCode}."); } json = await response.Content.ReadAsStringAsync(cancellationToken); } catch (TireReportException) { throw; } catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) { throw new TireReportException("Zeitüberschreitung beim Laden des Reports."); } catch (HttpRequestException ex) { _logger.LogWarning(ex, "Launch report {Sn}: Netzwerkfehler", recordSn); throw new TireReportException("Report-Server nicht erreichbar. Bitte Internetverbindung prüfen.", ex); } result.RawJson = json; try { using var doc = JsonDocument.Parse(json); var root = doc.RootElement; if (root.TryGetProperty("code", out var codeEl) && codeEl.ValueKind == JsonValueKind.Number && codeEl.GetInt32() != 200) { _logger.LogWarning("Launch report {Sn} returned code {Code}", recordSn, codeEl.GetInt32()); return result; } JsonElement report; if (root.TryGetProperty("data", out var dataEl) && dataEl.ValueKind == JsonValueKind.Object && dataEl.TryGetProperty("reportInfoData", out var rid)) { report = rid; } else if (root.TryGetProperty("detectData", out var dd)) { report = dd; } else { return result; } Parse(report, result); } catch (JsonException ex) { _logger.LogWarning(ex, "Launch report {Sn}: JSON konnte nicht gelesen werden", recordSn); } return result; } private static void Parse(JsonElement report, TireReportData result) { if (report.TryGetProperty("t", out var t)) { long seconds = t.ValueKind == JsonValueKind.Number ? t.GetInt64() : long.TryParse(t.GetString(), out var ls) ? ls : 0; if (seconds > 100000) result.MeasuredAt = DateTimeOffset.FromUnixTimeSeconds(seconds).UtcDateTime; } if (report.TryGetProperty("sn", out var sn) && sn.ValueKind == JsonValueKind.String) result.DeviceSerial = sn.GetString(); if (report.TryGetProperty("i", out var info) && info.ValueKind == JsonValueKind.Object) { if (info.TryGetProperty("cn", out var cn) && cn.ValueKind == JsonValueKind.String) result.LicensePlate = NullIfEmpty(cn.GetString()); if (info.TryGetProperty("dt", out var dt)) { if (dt.ValueKind == JsonValueKind.Number && dt.TryGetInt32(out var km)) result.Mileage = km; else if (dt.ValueKind == JsonValueKind.String && int.TryParse(dt.GetString(), out var km2)) result.Mileage = km2; } } if (!report.TryGetProperty("dr", out var dr) || dr.ValueKind != JsonValueKind.Object) return; // Thresholds "critical-warning", e.g. "1.6-2.5" if (dr.TryGetProperty("ts", out var ts) && ts.ValueKind == JsonValueKind.String) { var parts = ts.GetString()!.Split('-'); if (parts.Length == 2) { result.CriticalThreshold = ParseDecimal(parts[0]); result.WarningThreshold = ParseDecimal(parts[1]); } } if (dr.TryGetProperty("d", out var d)) result.BrakingDistanceDelta = d.ValueKind == JsonValueKind.Number ? d.GetDecimal() : ParseDecimal(d.GetString()); // Minimum values per wheel: l1 = front left, r1 = front right, ll1 = rear left, rr1 = rear right if (dr.TryGetProperty("rl", out var rl) && rl.ValueKind == JsonValueKind.Object) { result.TreadDepthFL = GetDecimal(rl, "l1"); result.TreadDepthFR = GetDecimal(rl, "r1"); result.TreadDepthRL = GetDecimal(rl, "ll1"); result.TreadDepthRR = GetDecimal(rl, "rr1"); } // Fallback: compute minimum from raw readings (z = axle, p = side, gd = readings) if (!result.HasTreadData && dr.TryGetProperty("r", out var r) && r.ValueKind == JsonValueKind.Array) { foreach (var tire in r.EnumerateArray()) { var z = tire.TryGetProperty("z", out var ze) ? ze.GetInt32() : 0; var p = tire.TryGetProperty("p", out var pe) ? pe.GetInt32() : 0; decimal? min = null; if (tire.TryGetProperty("gd", out var gd) && gd.ValueKind == JsonValueKind.Array) { foreach (var s in gd.EnumerateArray()) { foreach (var v in (s.GetString() ?? "").Split(',', StringSplitOptions.RemoveEmptyEntries)) { var dv = ParseDecimal(v); if (dv.HasValue && (!min.HasValue || dv < min)) min = dv; } } } switch (z, p) { case (1, 1): result.TreadDepthFL = min; break; case (1, 2): result.TreadDepthFR = min; break; case (2, 1): result.TreadDepthRL = min; break; case (2, 2): result.TreadDepthRR = min; break; } } } result.RecomputeRatings(); result.Recommendation = BuildRecommendation(result); } private static string? BuildRecommendation(TireReportData d) { var worst = new[] { d.RatingFL, d.RatingFR, d.RatingRL, d.RatingRR }.Max(); var parts = new List(); switch (worst) { case TireMeasureRating.Critical: parts.Add("Profiltiefe unter Mindestwert – Reifen umgehend ersetzen."); break; case TireMeasureRating.Warning: parts.Add("Profiltiefe im Grenzbereich – Reifenwechsel empfohlen."); break; case TireMeasureRating.Ok: parts.Add("Profiltiefe in Ordnung."); break; } var depths = new[] { d.TreadDepthFL, d.TreadDepthFR, d.TreadDepthRL, d.TreadDepthRR }.Where(x => x.HasValue).Select(x => x!.Value).ToList(); if (depths.Count >= 2 && depths.Max() - depths.Min() >= 2m) parts.Add("Ungleichmäßige Abnutzung (Differenz ≥ 2 mm) – Achsvermessung/Rotation prüfen."); if (d.BrakingDistanceDelta is > 0) parts.Add($"Bremsweg ca. +{d.BrakingDistanceDelta.Value.ToString("0.#", CultureInfo.GetCultureInfo("de-DE"))} m gegenüber Neureifen."); return parts.Count == 0 ? null : string.Join(" ", parts); } private static decimal? GetDecimal(JsonElement obj, string name) { if (!obj.TryGetProperty(name, out var el)) return null; return el.ValueKind switch { JsonValueKind.Number => el.GetDecimal(), JsonValueKind.String => ParseDecimal(el.GetString()), _ => null }; } private static decimal? ParseDecimal(string? s) { if (string.IsNullOrWhiteSpace(s)) return null; return decimal.TryParse(s.Trim(), NumberStyles.Float, CultureInfo.InvariantCulture, out var v) ? v : null; } private static string? NullIfEmpty(string? s) => string.IsNullOrWhiteSpace(s) ? null : s.Trim(); }