Release 1.1.0: QR-Reportimport, Regal-Auswahlmodal, Einlagern/Auslagern-Verknuepfungen aus Reifenlager, Stabilitaetsfixes
This commit is contained in:
1 parent
cba9414e92
commit
3404fb9875
25 files changed
+5106
-744
No files matched your search
@@ -0,0 +1,343 @@
|
||||
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();
|
||||
}
|
||||
Reference in new issue
Block a user