Release 1.1.0: QR-Reportimport, Regal-Auswahlmodal, Einlagern/Auslagern-Verknuepfungen aus Reifenlager, Stabilitaetsfixes

This commit is contained in:
spaethf committed 2026-09-26 23:24:14 +02:00
1 parent cba9414e92
commit 3404fb9875
25 files changed
+5106 -744

No files matched your search

+52 -4
View File
@@ -123,10 +123,7 @@ public class DatabaseInitializer : IDatabaseInitializer
?? throw new InvalidOperationException("Connection string nicht gefunden.");
await using var connection = new MySqlConnection(connectionString);
// Set connection timeout
connection.ConnectionTimeout.ToString();
await connection.OpenAsync(cancellationToken);
// Core tables
@@ -141,6 +138,7 @@ public class DatabaseInitializer : IDatabaseInitializer
await ExecuteSqlSafeAsync(connection, CreateStorageLocationsTableSql, "StorageLocations", cancellationToken);
await ExecuteSqlSafeAsync(connection, CreateTireStacksTableSql, "TireStacks", cancellationToken);
await ExecuteSqlSafeAsync(connection, CreateTireItemsTableSql, "TireItems", cancellationToken);
await ExecuteSqlSafeAsync(connection, CreateTireInspectionsTableSql, "TireInspections", cancellationToken);
// Kanban tables
await ExecuteSqlSafeAsync(connection, CreateKanbanColumnsTableSql, "KanbanColumns", cancellationToken);
@@ -205,6 +203,27 @@ public class DatabaseInitializer : IDatabaseInitializer
"ALTER TABLE `Customers` MODIFY COLUMN `FirstName` VARCHAR(100) NULL;",
"MakeCustomerFirstNameNullable", cancellationToken);
// Data repair: backfill NULLs in columns mapped as non-nullable strings
// (prevents InvalidCastException: DBNull -> String when materializing entities)
await ExecuteSqlSafeAsync(connection,
"UPDATE `Customers` SET `LastName` = '-' WHERE `LastName` IS NULL;",
"BackfillCustomerLastName", cancellationToken);
await ExecuteSqlSafeAsync(connection,
"UPDATE `Customers` SET `CustomerNumber` = CONCAT('AUTO-', SUBSTRING(REPLACE(`Id`, '-', ''), 1, 12)) WHERE `CustomerNumber` IS NULL;",
"BackfillCustomerNumber", cancellationToken);
await ExecuteSqlSafeAsync(connection,
"ALTER TABLE `Customers` MODIFY COLUMN `LastName` VARCHAR(100) NOT NULL, MODIFY COLUMN `CustomerNumber` VARCHAR(20) NOT NULL;",
"EnforceCustomerNotNullColumns", cancellationToken);
await ExecuteSqlSafeAsync(connection,
"UPDATE `Vehicles` SET `Make` = 'Unbekannt' WHERE `Make` IS NULL;",
"BackfillVehicleMake", cancellationToken);
await ExecuteSqlSafeAsync(connection,
"UPDATE `Vehicles` SET `TireSize` = '' WHERE `TireSize` IS NULL;",
"BackfillVehicleTireSize", cancellationToken);
await ExecuteSqlSafeAsync(connection,
"ALTER TABLE `Vehicles` MODIFY COLUMN `Make` VARCHAR(100) NOT NULL, MODIFY COLUMN `TireSize` VARCHAR(50) NOT NULL DEFAULT '';",
"EnforceVehicleNotNullColumns", cancellationToken);
// Migration: Add 2FA columns to Users table
await MigrateAdd2FaColumnsToUsersAsync(connection, cancellationToken);
@@ -1020,6 +1039,35 @@ public class DatabaseInitializer : IDatabaseInitializer
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
""";
private const string CreateTireInspectionsTableSql = """
CREATE TABLE IF NOT EXISTS `TireInspections` (
`Id` CHAR(36) CHARACTER SET ascii NOT NULL,
`TireStackId` CHAR(36) CHARACTER SET ascii NOT NULL,
`Source` VARCHAR(50) NOT NULL DEFAULT 'Launch TTS01',
`RecordSn` VARCHAR(100) NULL,
`ReportUrl` VARCHAR(500) NULL,
`MeasuredAt` DATETIME(6) NULL,
`LicensePlate` VARCHAR(20) NULL,
`Mileage` INT NULL,
`TreadDepthFL` DECIMAL(5,2) NULL,
`TreadDepthFR` DECIMAL(5,2) NULL,
`TreadDepthRL` DECIMAL(5,2) NULL,
`TreadDepthRR` DECIMAL(5,2) NULL,
`RatingFL` INT NOT NULL DEFAULT 0,
`RatingFR` INT NOT NULL DEFAULT 0,
`RatingRL` INT NOT NULL DEFAULT 0,
`RatingRR` INT NOT NULL DEFAULT 0,
`Recommendation` VARCHAR(1000) NULL,
`RawJson` LONGTEXT NULL,
`CreatedAt` DATETIME(6) NOT NULL,
`CreatedByUserId` CHAR(36) CHARACTER SET ascii NULL,
PRIMARY KEY (`Id`),
INDEX `IX_TireInspections_TireStackId` (`TireStackId`),
INDEX `IX_TireInspections_RecordSn` (`RecordSn`),
CONSTRAINT `FK_TireInspections_TireStacks` FOREIGN KEY (`TireStackId`) REFERENCES `TireStacks` (`Id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
""";
#region Kanban SQL Statements
private const string CreateKanbanColumnsTableSql = """
@@ -290,24 +290,25 @@ public class StorageLocationService : IStorageLocationService
var toLocation = await GetByCodeAsync(toCode)
?? throw new LocationNotFoundException(toCode);
if (!fromLocation.IsOccupied)
if (!fromLocation.IsOccupied || fromLocation.TireStackId == null)
throw new InvalidOperationException($"Lagerplatz {fromCode} ist nicht belegt.");
if (toLocation.IsOccupied)
throw new LocationOccupiedException(toCode, toLocation.TireStackId!.Value);
throw toLocation.TireStackId.HasValue
? new LocationOccupiedException(toCode, toLocation.TireStackId.Value)
: new LocationOccupiedException(toCode);
if (!toLocation.IsAvailable)
throw new InvalidOperationException($"Lagerplatz {toCode} ist gesperrt.");
var tireStackId = fromLocation.TireStackId!.Value;
var tireStackId = fromLocation.TireStackId.Value;
// Update the tire stack's storage location reference
var tireStack = await _context.TireStacks.FindAsync(tireStackId);
if (tireStack != null)
{
tireStack.StorageLocationId = toLocation.Id;
tireStack.UpdatedAt = DateTime.UtcNow;
}
var tireStack = await _context.TireStacks.FindAsync(tireStackId)
?? throw new InvalidOperationException($"Reifenstapel auf Lagerplatz {fromCode} wurde nicht gefunden.");
tireStack.StorageLocationId = toLocation.Id;
tireStack.UpdatedAt = DateTime.UtcNow;
// Clear source and assign to destination
fromLocation.ClearLocation();
+343
View File
@@ -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();
}
+152 -2
View File
@@ -23,10 +23,25 @@ public interface ITireStackService
Task<TireStack> CreateAndStoreAsync(TireStack tireStack, Guid storageLocationId, Guid? storedByUserId = null);
Task<TireStack> PickupAsync(Guid tireStackId, Guid? pickedUpByUserId = null);
Task<List<TireStack>> PickupBatchAsync(IEnumerable<Guid> tireStackIds, Guid? pickedUpByUserId = null);
Task<SwapResult> SwapAsync(Guid outgoingTireStackId, TireStack incomingTireStack, Guid? targetLocationId = null, Guid? userId = null);
Task<bool> DeleteAsync(Guid id);
Task<string> GenerateTicketNumberAsync(bool isInventory = false);
Task<StorageStatistics> GetStatisticsAsync();
Task<List<TireStack>> GetExpiringPickupsAsync(int daysAhead = 14);
Task<TireInspection> AddInspectionAsync(TireInspection inspection);
Task<List<TireInspection>> GetInspectionsAsync(Guid tireStackId);
Task<TireInspection?> GetLatestInspectionAsync(Guid tireStackId);
}
/// <summary>
/// Result of a seasonal tire swap (one stack out, another stack in)
/// </summary>
public class SwapResult
{
public required TireStack Outgoing { get; init; }
public required TireStack Incoming { get; init; }
public string? FreedLocationCode { get; init; }
public string? NewLocationCode { get; init; }
}
/// <summary>
@@ -303,13 +318,13 @@ public class TireStackService : ITireStackService
var tireStack = await _context.TireStacks
.Include(ts => ts.Customer)
.Include(ts => ts.StorageLocation)
.FirstOrDefaultAsync(ts => ts.Id == tireStackId);
.FirstOrDefaultAsync(ts => ts.Id == tireStackId && ts.IsActive);
if (tireStack == null)
throw new InvalidOperationException("Reifenstapel nicht gefunden.");
if (tireStack.Status != TireStackStatus.Stored && tireStack.Status != TireStackStatus.Reserved)
throw new InvalidOperationException("Reifenstapel ist nicht eingelagert.");
throw new InvalidOperationException($"Reifenstapel {tireStack.TicketNumber} ist nicht eingelagert.");
// Clear the storage location (both sides)
if (tireStack.StorageLocation != null)
@@ -402,6 +417,111 @@ public class TireStackService : ITireStackService
}
}
public async Task<SwapResult> SwapAsync(Guid outgoingTireStackId, TireStack incomingTireStack, Guid? targetLocationId = null, Guid? userId = null)
{
var strategy = _context.Database.CreateExecutionStrategy();
try
{
return await strategy.ExecuteAsync(async () =>
{
await using var transaction = await _context.Database.BeginTransactionAsync();
try
{
var outgoing = await _context.TireStacks
.Include(ts => ts.Customer)
.Include(ts => ts.Vehicle)
.Include(ts => ts.StorageLocation)
.FirstOrDefaultAsync(ts => ts.Id == outgoingTireStackId && ts.IsActive);
if (outgoing == null)
throw new InvalidOperationException("Herauszugebender Reifenstapel nicht gefunden.");
if (outgoing.Status != TireStackStatus.Stored && outgoing.Status != TireStackStatus.Reserved)
throw new InvalidOperationException("Herauszugebender Reifenstapel ist nicht eingelagert.");
if (outgoing.CustomerId == null)
throw new InvalidOperationException("Reifenwechsel ist nur für Kundenreifen möglich.");
var freedLocationId = outgoing.StorageLocationId;
var freedLocationCode = outgoing.StorageLocation?.Code;
// 1. Release outgoing stack: mark as mounted on vehicle, free the location
if (outgoing.StorageLocation != null)
outgoing.StorageLocation.ClearLocation();
outgoing.Status = TireStackStatus.OnVehicle;
outgoing.PickedUpAt = DateTime.UtcNow;
outgoing.PickedUpByUserId = userId;
outgoing.StorageLocationId = null;
outgoing.UpdatedAt = DateTime.UtcNow;
await _context.SaveChangesAsync();
// 2. Resolve incoming stack: existing (from history) or new
TireStack incoming;
var existing = await _context.TireStacks
.FirstOrDefaultAsync(ts => ts.Id == incomingTireStack.Id && ts.IsActive);
if (existing != null)
{
if (existing.Status != TireStackStatus.OnVehicle && existing.Status != TireStackStatus.PickedUp)
throw new InvalidOperationException($"Reifenstapel {existing.TicketNumber} kann nicht wieder eingelagert werden (Status: {existing.StatusText}).");
if (existing.CustomerId != outgoing.CustomerId)
throw new InvalidOperationException("Der einzulagernde Reifenstapel gehört einem anderen Kunden.");
existing.PickedUpAt = null;
existing.PickedUpByUserId = null;
existing.ExpectedPickupDate = incomingTireStack.ExpectedPickupDate;
if (!string.IsNullOrWhiteSpace(incomingTireStack.Notes))
existing.Notes = incomingTireStack.Notes;
existing.Condition = incomingTireStack.Condition;
if (incomingTireStack.VehicleId != null)
existing.VehicleId = incomingTireStack.VehicleId;
incoming = existing;
}
else
{
incomingTireStack.CustomerId = outgoing.CustomerId;
incomingTireStack.VehicleId ??= outgoing.VehicleId;
incomingTireStack.CatalogItemId = null;
incomingTireStack.ItemCount = TireStack.TiresPerStack;
incomingTireStack.PickedUpAt = null;
incomingTireStack.PickedUpByUserId = null;
incoming = await CreateAsync(incomingTireStack);
}
// 3. Store incoming stack on target (default: the location just freed)
var locationId = targetLocationId ?? freedLocationId
?? throw new InvalidOperationException("Kein Lagerplatz für die Einlagerung angegeben.");
incoming = await StoreAsync(incoming, locationId, userId);
if (incoming.StorageLocation == null)
incoming.StorageLocation = await _context.StorageLocations.FindAsync(locationId);
await transaction.CommitAsync();
_logger.LogInformation("Tire swap: {Outgoing} -> vehicle, {Incoming} -> location {Location}",
outgoing.TicketNumber, incoming.TicketNumber, incoming.StorageLocation?.Code);
return new SwapResult
{
Outgoing = outgoing,
Incoming = incoming,
FreedLocationCode = freedLocationCode,
NewLocationCode = incoming.StorageLocation?.Code
};
}
catch
{
await transaction.RollbackAsync();
_context.ChangeTracker.Clear();
throw;
}
});
}
catch (DbUpdateConcurrencyException)
{
throw new InvalidOperationException("Der Lagerplatz wurde zwischenzeitlich geändert. Bitte erneut versuchen.");
}
}
public async Task<bool> DeleteAsync(Guid id)
{
var tireStack = await _context.TireStacks.FindAsync(id);
@@ -501,4 +621,34 @@ public class TireStackService : ITireStackService
.OrderBy(ts => ts.ExpectedPickupDate)
.ToListAsync();
}
public async Task<TireInspection> AddInspectionAsync(TireInspection inspection)
{
var exists = await _context.TireStacks.AnyAsync(ts => ts.Id == inspection.TireStackId);
if (!exists)
throw new InvalidOperationException("Reifenstapel nicht gefunden.");
if (inspection.Id == Guid.Empty) inspection.Id = Guid.NewGuid();
inspection.CreatedAt = DateTime.UtcNow;
_context.TireInspections.Add(inspection);
await _context.SaveChangesAsync();
return inspection;
}
public async Task<List<TireInspection>> GetInspectionsAsync(Guid tireStackId)
{
return await _context.TireInspections
.Where(i => i.TireStackId == tireStackId)
.OrderByDescending(i => i.MeasuredAt ?? i.CreatedAt)
.ToListAsync();
}
public async Task<TireInspection?> GetLatestInspectionAsync(Guid tireStackId)
{
return await _context.TireInspections
.Where(i => i.TireStackId == tireStackId)
.OrderByDescending(i => i.MeasuredAt ?? i.CreatedAt)
.FirstOrDefaultAsync();
}
}