Files

344 lines
14 KiB
C#
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using System.Globalization;
using System.Text.Json;
using System.Text.RegularExpressions;
using Lagerbestand.Models;
namespace Lagerbestand.Services;
/// <summary>
/// Parsed data of a tire measurement report (Launch TTS01 tread depth scanner).
/// </summary>
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; }
/// <summary>True if the report itself contained tread depth values.</summary>
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
};
/// <summary>
/// Re-evaluates the per-wheel ratings from the current tread depths using the
/// thresholds delivered by the device (falls back to legal defaults).
/// </summary>
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
{
/// <summary>
/// Tries to extract the report record number from a scanned QR text
/// (full share URL or bare record number).
/// </summary>
bool TryParseReportUrl(string? qrText, out string recordSn, out string reportUrl);
/// <summary>
/// Loads and parses the report. Returns a data object with at least
/// RecordSn/ReportUrl; HasTreadData indicates whether values could be read.
/// Throws <see cref="TireReportException"/> with a user-friendly message on failure.
/// </summary>
Task<TireReportData> 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<TireReportService> _logger;
private readonly string _apiUrl;
private readonly string _shareUrl;
public TireReportService(IHttpClientFactory httpClientFactory, IConfiguration configuration, ILogger<TireReportService> 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<TireReportData> 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<string>();
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();
}