Initial commit: Lagerbestand Blazor Projekt
This commit is contained in:
commit
cba9414e92
105 files changed
+36431
No files matched your search
@@ -0,0 +1,477 @@
|
||||
using Lagerbestand.Data;
|
||||
using Lagerbestand.Models;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
|
||||
namespace Lagerbestand.Services;
|
||||
|
||||
public interface IAuthService
|
||||
{
|
||||
Task<AppUser?> LoginAsync(string username, string password);
|
||||
Task<AppUser?> AuthenticateAsync(string username, string password);
|
||||
Task<AppUser?> GetUserByIdAsync(Guid id);
|
||||
Task<List<AppUser>> GetAllUsersAsync();
|
||||
Task<AppUser> CreateUserAsync(string username, string password, string? displayName, UserRole role);
|
||||
Task<AppUser> UpdateUserAsync(Guid id, string? username, string? displayName, UserRole role, bool isActive);
|
||||
Task<bool> ChangePasswordAsync(Guid id, string newPassword);
|
||||
Task DeleteUserAsync(Guid id);
|
||||
Task EnsureAdminExistsAsync();
|
||||
Task<bool> UsernameExistsAsync(string username, Guid? excludeUserId = null);
|
||||
Task<int> GetActiveAdminCountAsync();
|
||||
(bool IsValid, string? ErrorMessage) ValidatePassword(string password);
|
||||
(bool IsValid, string? ErrorMessage) ValidateUsername(string username);
|
||||
|
||||
// 2FA methods
|
||||
Task<(string Secret, string OtpAuthUri)> Enable2FaAsync(Guid userId);
|
||||
Task<bool> Confirm2FaAsync(Guid userId, string code);
|
||||
Task Disable2FaAsync(Guid userId);
|
||||
Task Reset2FaAsync(Guid userId);
|
||||
Task SetEnforce2FaAsync(Guid userId, bool enforce);
|
||||
}
|
||||
|
||||
public class AuthService : IAuthService
|
||||
{
|
||||
private const string LegacyPasswordSalt = "ReifenSpaethSalt2025";
|
||||
private const int Pbkdf2Iterations = 100_000;
|
||||
private const int SaltSize = 16;
|
||||
private const int HashSize = 32;
|
||||
private const int MinPasswordLength = 4;
|
||||
private const int MaxPasswordLength = 128;
|
||||
private const int MinUsernameLength = 3;
|
||||
private const int MaxUsernameLength = 50;
|
||||
private const int MaxFailedLoginAttempts = 5;
|
||||
private static readonly TimeSpan LockoutDuration = TimeSpan.FromMinutes(5);
|
||||
private static readonly System.Collections.Concurrent.ConcurrentDictionary<string, (int Count, DateTime LastAttempt)> FailedLoginAttempts = new();
|
||||
|
||||
private readonly AppDbContext _context;
|
||||
private readonly IConfiguration _configuration;
|
||||
private readonly ILogger<AuthService> _logger;
|
||||
private readonly ITwoFactorService _twoFactorService;
|
||||
private readonly ISiteSettingsService _siteSettingsService;
|
||||
|
||||
public AuthService(AppDbContext context, IConfiguration configuration, ILogger<AuthService> logger,
|
||||
ITwoFactorService twoFactorService, ISiteSettingsService siteSettingsService)
|
||||
{
|
||||
_context = context;
|
||||
_configuration = configuration;
|
||||
_logger = logger;
|
||||
_twoFactorService = twoFactorService;
|
||||
_siteSettingsService = siteSettingsService;
|
||||
}
|
||||
|
||||
public async Task<AppUser?> LoginAsync(string username, string password)
|
||||
{
|
||||
var lockoutKey = username.Trim().ToLowerInvariant();
|
||||
if (IsLockedOut(lockoutKey))
|
||||
{
|
||||
_logger.LogWarning("Login blocked due to too many failed attempts for username: {Username}", username);
|
||||
return null;
|
||||
}
|
||||
|
||||
var user = await GetActiveUserByUsernameAsync(username);
|
||||
if (user == null || !VerifyPassword(password, user.PasswordHash))
|
||||
{
|
||||
RegisterFailedAttempt(lockoutKey);
|
||||
_logger.LogWarning("Failed login attempt for username: {Username}", username);
|
||||
return null;
|
||||
}
|
||||
|
||||
FailedLoginAttempts.TryRemove(lockoutKey, out _);
|
||||
|
||||
// Re-hash legacy SHA256 passwords to PBKDF2
|
||||
await ReHashIfLegacyAsync(user, password);
|
||||
|
||||
user.LastLoginAt = DateTime.UtcNow;
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
_logger.LogInformation("User {Username} logged in successfully", username);
|
||||
return user;
|
||||
}
|
||||
|
||||
private static bool IsLockedOut(string key)
|
||||
{
|
||||
if (FailedLoginAttempts.TryGetValue(key, out var entry))
|
||||
{
|
||||
if (entry.Count >= MaxFailedLoginAttempts && DateTime.UtcNow - entry.LastAttempt < LockoutDuration)
|
||||
return true;
|
||||
|
||||
if (DateTime.UtcNow - entry.LastAttempt >= LockoutDuration)
|
||||
FailedLoginAttempts.TryRemove(key, out _);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static void RegisterFailedAttempt(string key)
|
||||
{
|
||||
FailedLoginAttempts.AddOrUpdate(key,
|
||||
_ => (1, DateTime.UtcNow),
|
||||
(_, existing) => (existing.Count + 1, DateTime.UtcNow));
|
||||
}
|
||||
|
||||
public async Task<AppUser?> AuthenticateAsync(string username, string password)
|
||||
{
|
||||
var user = await GetActiveUserByUsernameAsync(username);
|
||||
if (user == null || !VerifyPassword(password, user.PasswordHash))
|
||||
return null;
|
||||
|
||||
return user;
|
||||
}
|
||||
|
||||
public async Task<AppUser?> GetUserByIdAsync(Guid id)
|
||||
{
|
||||
return await _context.Users.FindAsync(id);
|
||||
}
|
||||
|
||||
public async Task<List<AppUser>> GetAllUsersAsync()
|
||||
{
|
||||
return await _context.Users
|
||||
.OrderBy(u => u.Username)
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
public async Task<bool> UsernameExistsAsync(string username, Guid? excludeUserId = null)
|
||||
{
|
||||
var normalizedUsername = username.Trim().ToLowerInvariant();
|
||||
var query = _context.Users.Where(u => u.Username.ToLower() == normalizedUsername);
|
||||
|
||||
if (excludeUserId.HasValue)
|
||||
query = query.Where(u => u.Id != excludeUserId.Value);
|
||||
|
||||
return await query.AnyAsync();
|
||||
}
|
||||
|
||||
public async Task<int> GetActiveAdminCountAsync()
|
||||
{
|
||||
return await _context.Users
|
||||
.CountAsync(u => u.Role == UserRole.Admin && u.IsActive);
|
||||
}
|
||||
|
||||
public (bool IsValid, string? ErrorMessage) ValidatePassword(string password)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(password))
|
||||
return (false, "Passwort ist erforderlich.");
|
||||
|
||||
if (password.Length < MinPasswordLength)
|
||||
return (false, $"Passwort muss mindestens {MinPasswordLength} Zeichen lang sein.");
|
||||
|
||||
if (password.Length > MaxPasswordLength)
|
||||
return (false, $"Passwort darf maximal {MaxPasswordLength} Zeichen lang sein.");
|
||||
|
||||
return (true, null);
|
||||
}
|
||||
|
||||
public (bool IsValid, string? ErrorMessage) ValidateUsername(string username)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(username))
|
||||
return (false, "Benutzername ist erforderlich.");
|
||||
|
||||
var trimmed = username.Trim();
|
||||
|
||||
if (trimmed.Length < MinUsernameLength)
|
||||
return (false, $"Benutzername muss mindestens {MinUsernameLength} Zeichen lang sein.");
|
||||
|
||||
if (trimmed.Length > MaxUsernameLength)
|
||||
return (false, $"Benutzername darf maximal {MaxUsernameLength} Zeichen lang sein.");
|
||||
|
||||
// Allow only alphanumeric, dots, underscores, hyphens
|
||||
if (!trimmed.All(c => char.IsLetterOrDigit(c) || c == '.' || c == '_' || c == '-'))
|
||||
return (false, "Benutzername darf nur Buchstaben, Zahlen, Punkte, Unterstriche und Bindestriche enthalten.");
|
||||
|
||||
return (true, null);
|
||||
}
|
||||
|
||||
public async Task<AppUser> CreateUserAsync(string username, string password, string? displayName, UserRole role)
|
||||
{
|
||||
var usernameValidation = ValidateUsername(username);
|
||||
if (!usernameValidation.IsValid)
|
||||
throw new InvalidOperationException(usernameValidation.ErrorMessage);
|
||||
|
||||
var passwordValidation = ValidatePassword(password);
|
||||
if (!passwordValidation.IsValid)
|
||||
throw new InvalidOperationException(passwordValidation.ErrorMessage);
|
||||
|
||||
var normalizedUsername = username.Trim();
|
||||
|
||||
if (await UsernameExistsAsync(normalizedUsername))
|
||||
throw new InvalidOperationException($"Benutzer '{normalizedUsername}' existiert bereits.");
|
||||
|
||||
var user = new AppUser
|
||||
{
|
||||
Username = normalizedUsername,
|
||||
PasswordHash = HashPassword(password),
|
||||
DisplayName = string.IsNullOrWhiteSpace(displayName) ? null : displayName.Trim(),
|
||||
Role = role,
|
||||
IsActive = true,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
};
|
||||
|
||||
_context.Users.Add(user);
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
_logger.LogInformation("User {Username} created with role {Role}", normalizedUsername, role);
|
||||
return user;
|
||||
}
|
||||
|
||||
public async Task<AppUser> UpdateUserAsync(Guid id, string? username, string? displayName, UserRole role, bool isActive)
|
||||
{
|
||||
var user = await _context.Users.FindAsync(id)
|
||||
?? throw new InvalidOperationException("Benutzer nicht gefunden.");
|
||||
|
||||
// Prevent deactivating or demoting the last admin
|
||||
if (user.Role == UserRole.Admin && (role != UserRole.Admin || !isActive))
|
||||
{
|
||||
var adminCount = await GetActiveAdminCountAsync();
|
||||
if (adminCount <= 1)
|
||||
throw new InvalidOperationException("Der letzte aktive Administrator kann nicht deaktiviert oder zu einer anderen Rolle geändert werden.");
|
||||
}
|
||||
|
||||
// Update username if provided and different
|
||||
if (!string.IsNullOrWhiteSpace(username))
|
||||
{
|
||||
var newUsername = username.Trim();
|
||||
if (!newUsername.Equals(user.Username, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var usernameValidation = ValidateUsername(newUsername);
|
||||
if (!usernameValidation.IsValid)
|
||||
throw new InvalidOperationException(usernameValidation.ErrorMessage);
|
||||
|
||||
if (await UsernameExistsAsync(newUsername, id))
|
||||
throw new InvalidOperationException($"Benutzer '{newUsername}' existiert bereits.");
|
||||
|
||||
user.Username = newUsername;
|
||||
}
|
||||
}
|
||||
|
||||
user.DisplayName = string.IsNullOrWhiteSpace(displayName) ? null : displayName.Trim();
|
||||
user.Role = role;
|
||||
user.IsActive = isActive;
|
||||
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
_logger.LogInformation("User {UserId} updated", id);
|
||||
return user;
|
||||
}
|
||||
|
||||
public async Task<bool> ChangePasswordAsync(Guid id, string newPassword)
|
||||
{
|
||||
var passwordValidation = ValidatePassword(newPassword);
|
||||
if (!passwordValidation.IsValid)
|
||||
throw new InvalidOperationException(passwordValidation.ErrorMessage);
|
||||
|
||||
var user = await _context.Users.FindAsync(id);
|
||||
if (user == null)
|
||||
return false;
|
||||
|
||||
user.PasswordHash = HashPassword(newPassword);
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
_logger.LogInformation("Password changed for user {UserId}", id);
|
||||
return true;
|
||||
}
|
||||
|
||||
public async Task DeleteUserAsync(Guid id)
|
||||
{
|
||||
var user = await _context.Users.FindAsync(id);
|
||||
if (user == null)
|
||||
return;
|
||||
|
||||
// Prevent deleting the last admin
|
||||
if (user.Role == UserRole.Admin && user.IsActive)
|
||||
{
|
||||
var adminCount = await GetActiveAdminCountAsync();
|
||||
if (adminCount <= 1)
|
||||
throw new InvalidOperationException("Der letzte aktive Administrator kann nicht gelöscht werden.");
|
||||
}
|
||||
|
||||
// Soft delete
|
||||
user.IsActive = false;
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
_logger.LogInformation("User {UserId} ({Username}) deactivated", id, user.Username);
|
||||
}
|
||||
|
||||
public async Task EnsureAdminExistsAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
var adminUsername = _configuration["AdminUser:Username"] ?? "admin";
|
||||
var adminPassword = _configuration["AdminUser:Password"] ?? "admin123";
|
||||
var adminDisplayName = _configuration["AdminUser:DisplayName"] ?? "Administrator";
|
||||
|
||||
var existingAdmin = await _context.Users
|
||||
.FirstOrDefaultAsync(u => u.Username.ToLower() == adminUsername.ToLower());
|
||||
|
||||
if (existingAdmin == null)
|
||||
{
|
||||
var admin = new AppUser
|
||||
{
|
||||
Username = adminUsername,
|
||||
PasswordHash = HashPassword(adminPassword),
|
||||
DisplayName = adminDisplayName,
|
||||
Role = UserRole.Admin,
|
||||
IsActive = true,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
};
|
||||
|
||||
_context.Users.Add(admin);
|
||||
await _context.SaveChangesAsync();
|
||||
_logger.LogInformation("Admin user '{Username}' created.", adminUsername);
|
||||
}
|
||||
else
|
||||
{
|
||||
existingAdmin.PasswordHash = HashPassword(adminPassword);
|
||||
existingAdmin.DisplayName = adminDisplayName;
|
||||
existingAdmin.Role = UserRole.Admin;
|
||||
existingAdmin.IsActive = true;
|
||||
await _context.SaveChangesAsync();
|
||||
_logger.LogInformation("Admin user '{Username}' updated.", adminUsername);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error creating/updating admin user.");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<AppUser?> GetActiveUserByUsernameAsync(string username)
|
||||
{
|
||||
return await _context.Users
|
||||
.FirstOrDefaultAsync(u => u.Username.ToLower() == username.ToLower() && u.IsActive);
|
||||
}
|
||||
|
||||
private static string HashPassword(string password)
|
||||
{
|
||||
var salt = RandomNumberGenerator.GetBytes(SaltSize);
|
||||
var hash = Rfc2898DeriveBytes.Pbkdf2(
|
||||
Encoding.UTF8.GetBytes(password),
|
||||
salt,
|
||||
Pbkdf2Iterations,
|
||||
HashAlgorithmName.SHA256,
|
||||
HashSize);
|
||||
return $"{Pbkdf2Iterations}.{Convert.ToBase64String(salt)}.{Convert.ToBase64String(hash)}";
|
||||
}
|
||||
|
||||
private static bool VerifyPassword(string password, string storedHash)
|
||||
{
|
||||
// New PBKDF2 format: "iterations.salt.hash"
|
||||
if (storedHash.Contains('.'))
|
||||
{
|
||||
var parts = storedHash.Split('.', 3);
|
||||
if (parts.Length == 3
|
||||
&& int.TryParse(parts[0], out var iterations))
|
||||
{
|
||||
var salt = Convert.FromBase64String(parts[1]);
|
||||
var expectedHash = Convert.FromBase64String(parts[2]);
|
||||
var computedHash = Rfc2898DeriveBytes.Pbkdf2(
|
||||
Encoding.UTF8.GetBytes(password),
|
||||
salt,
|
||||
iterations,
|
||||
HashAlgorithmName.SHA256,
|
||||
expectedHash.Length);
|
||||
return CryptographicOperations.FixedTimeEquals(computedHash, expectedHash);
|
||||
}
|
||||
}
|
||||
|
||||
// Legacy SHA256 + hardcoded salt fallback
|
||||
var legacyBytes = Encoding.UTF8.GetBytes(password + LegacyPasswordSalt);
|
||||
var legacyHash = SHA256.HashData(legacyBytes);
|
||||
return Convert.ToBase64String(legacyHash) == storedHash;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Re-hashes a legacy password to the new PBKDF2 format on successful login.
|
||||
/// </summary>
|
||||
private async Task ReHashIfLegacyAsync(AppUser user, string password)
|
||||
{
|
||||
// If the stored hash is in legacy format (no dots), upgrade it
|
||||
if (!user.PasswordHash.Contains('.'))
|
||||
{
|
||||
user.PasswordHash = HashPassword(password);
|
||||
await _context.SaveChangesAsync();
|
||||
_logger.LogInformation("Re-hashed legacy password for user {Username}", user.Username);
|
||||
}
|
||||
}
|
||||
|
||||
#region Two-Factor Authentication
|
||||
|
||||
public async Task<(string Secret, string OtpAuthUri)> Enable2FaAsync(Guid userId)
|
||||
{
|
||||
var user = await _context.Users.FindAsync(userId)
|
||||
?? throw new InvalidOperationException("Benutzer nicht gefunden.");
|
||||
|
||||
var tfSettings = await _siteSettingsService.GetTwoFactorSettingsAsync();
|
||||
if (!tfSettings.IsEnabled)
|
||||
throw new InvalidOperationException("Zwei-Faktor-Authentifizierung ist global deaktiviert.");
|
||||
|
||||
var secret = _twoFactorService.GenerateSecretKey();
|
||||
var otpAuthUri = _twoFactorService.GenerateOtpAuthUri(secret, user.Username, tfSettings.Issuer);
|
||||
|
||||
// Store the secret but don't enable 2FA yet – user must confirm with a code first
|
||||
user.TwoFactorSecret = secret;
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
_logger.LogInformation("2FA setup initiated for user {Username}", user.Username);
|
||||
return (secret, otpAuthUri);
|
||||
}
|
||||
|
||||
public async Task<bool> Confirm2FaAsync(Guid userId, string code)
|
||||
{
|
||||
var user = await _context.Users.FindAsync(userId)
|
||||
?? throw new InvalidOperationException("Benutzer nicht gefunden.");
|
||||
|
||||
if (string.IsNullOrEmpty(user.TwoFactorSecret))
|
||||
throw new InvalidOperationException("Kein 2FA-Secret vorhanden. Bitte 2FA zuerst einrichten.");
|
||||
|
||||
if (!_twoFactorService.ValidateTotpCode(user.TwoFactorSecret, code))
|
||||
return false;
|
||||
|
||||
user.TwoFactorEnabled = true;
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
_logger.LogInformation("2FA confirmed and enabled for user {Username}", user.Username);
|
||||
return true;
|
||||
}
|
||||
|
||||
public async Task Disable2FaAsync(Guid userId)
|
||||
{
|
||||
var user = await _context.Users.FindAsync(userId)
|
||||
?? throw new InvalidOperationException("Benutzer nicht gefunden.");
|
||||
|
||||
if (user.TwoFactorEnforced)
|
||||
throw new InvalidOperationException("2FA wurde vom Administrator erzwungen und kann nicht deaktiviert werden.");
|
||||
|
||||
user.TwoFactorEnabled = false;
|
||||
user.TwoFactorSecret = null;
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
_logger.LogInformation("2FA disabled for user {Username}", user.Username);
|
||||
}
|
||||
|
||||
public async Task Reset2FaAsync(Guid userId)
|
||||
{
|
||||
var user = await _context.Users.FindAsync(userId)
|
||||
?? throw new InvalidOperationException("Benutzer nicht gefunden.");
|
||||
|
||||
user.TwoFactorEnabled = false;
|
||||
user.TwoFactorSecret = null;
|
||||
user.TwoFactorEnforced = false;
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
_logger.LogInformation("2FA reset by admin for user {Username}", user.Username);
|
||||
}
|
||||
|
||||
public async Task SetEnforce2FaAsync(Guid userId, bool enforce)
|
||||
{
|
||||
var user = await _context.Users.FindAsync(userId)
|
||||
?? throw new InvalidOperationException("Benutzer nicht gefunden.");
|
||||
|
||||
user.TwoFactorEnforced = enforce;
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
_logger.LogInformation("2FA enforce set to {Enforce} for user {Username}", enforce, user.Username);
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
using Lagerbestand.Data;
|
||||
using Lagerbestand.Models;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Lagerbestand.Services;
|
||||
|
||||
public interface ICatalogService
|
||||
{
|
||||
Task<List<CatalogItem>> GetAllAsync();
|
||||
Task<CatalogItem?> GetByIdAsync(Guid id);
|
||||
Task<CatalogItem> CreateAsync(CatalogItem item);
|
||||
Task<CatalogItem> UpdateAsync(CatalogItem item);
|
||||
Task DeleteAsync(Guid id);
|
||||
Task<string> SaveImageAsync(Stream imageStream, string fileName);
|
||||
Task DeleteImageAsync(string? fileName);
|
||||
}
|
||||
|
||||
public class CatalogService : ICatalogService
|
||||
{
|
||||
private static readonly string[] AllowedImageExtensions = [".jpg", ".jpeg", ".png", ".gif", ".webp"];
|
||||
|
||||
private readonly AppDbContext _context;
|
||||
private readonly ILogger<CatalogService> _logger;
|
||||
private readonly string _uploadPath;
|
||||
|
||||
public CatalogService(AppDbContext context, IWebHostEnvironment environment, ILogger<CatalogService> logger)
|
||||
{
|
||||
_context = context;
|
||||
_logger = logger;
|
||||
_uploadPath = Path.Combine(environment.WebRootPath, "uploads", "images");
|
||||
|
||||
Directory.CreateDirectory(_uploadPath);
|
||||
}
|
||||
|
||||
public async Task<List<CatalogItem>> GetAllAsync()
|
||||
{
|
||||
return await _context.CatalogItems
|
||||
.OrderByDescending(x => x.CreatedAt)
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
public async Task<CatalogItem?> GetByIdAsync(Guid id)
|
||||
{
|
||||
return await _context.CatalogItems.FindAsync(id);
|
||||
}
|
||||
|
||||
public async Task<CatalogItem> CreateAsync(CatalogItem item)
|
||||
{
|
||||
item.Id = Guid.NewGuid();
|
||||
item.CreatedAt = DateTime.UtcNow;
|
||||
|
||||
_context.CatalogItems.Add(item);
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
return item;
|
||||
}
|
||||
|
||||
public async Task<CatalogItem> UpdateAsync(CatalogItem item)
|
||||
{
|
||||
var existing = await _context.CatalogItems.FindAsync(item.Id)
|
||||
?? throw new InvalidOperationException("Artikel nicht gefunden");
|
||||
|
||||
existing.Name = item.Name;
|
||||
existing.TireSize = item.TireSize;
|
||||
existing.Season = item.Season;
|
||||
existing.Type = item.Type;
|
||||
existing.PictureFileName = item.PictureFileName;
|
||||
existing.UpdatedAt = DateTime.UtcNow;
|
||||
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
return existing;
|
||||
}
|
||||
|
||||
public async Task DeleteAsync(Guid id)
|
||||
{
|
||||
var item = await _context.CatalogItems.FindAsync(id);
|
||||
if (item != null)
|
||||
{
|
||||
await DeleteImageAsync(item.PictureFileName);
|
||||
_context.CatalogItems.Remove(item);
|
||||
await _context.SaveChangesAsync();
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<string> SaveImageAsync(Stream imageStream, string fileName)
|
||||
{
|
||||
var extension = Path.GetExtension(fileName).ToLowerInvariant();
|
||||
|
||||
if (!AllowedImageExtensions.Contains(extension))
|
||||
throw new InvalidOperationException("Ungültiger Dateityp. Erlaubt: jpg, jpeg, png, gif, webp");
|
||||
|
||||
var uniqueFileName = $"{Guid.NewGuid()}{extension}";
|
||||
var filePath = Path.Combine(_uploadPath, uniqueFileName);
|
||||
|
||||
await using var fileStream = new FileStream(filePath, FileMode.Create);
|
||||
await imageStream.CopyToAsync(fileStream);
|
||||
|
||||
return uniqueFileName;
|
||||
}
|
||||
|
||||
public Task DeleteImageAsync(string? fileName)
|
||||
{
|
||||
if (string.IsNullOrEmpty(fileName))
|
||||
return Task.CompletedTask;
|
||||
|
||||
var filePath = Path.Combine(_uploadPath, fileName);
|
||||
|
||||
if (File.Exists(filePath))
|
||||
File.Delete(filePath);
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,315 @@
|
||||
using Lagerbestand.Data;
|
||||
using Lagerbestand.Models;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Lagerbestand.Services;
|
||||
|
||||
public interface ICustomerService
|
||||
{
|
||||
Task<List<Customer>> GetAllAsync(bool includeInactive = false);
|
||||
Task<Customer?> GetByIdAsync(Guid id);
|
||||
Task<Customer?> GetByCustomerNumberAsync(string customerNumber);
|
||||
Task<List<Customer>> SearchAsync(string searchTerm);
|
||||
Task<Customer?> FindByVehicleAsync(string searchTerm);
|
||||
Task<Customer> CreateAsync(Customer customer);
|
||||
Task<Customer> UpdateAsync(Customer customer);
|
||||
Task<bool> DeleteAsync(Guid id);
|
||||
Task<string> GenerateCustomerNumberAsync();
|
||||
Task<int> GetStoredTireStackCountAsync(Guid customerId);
|
||||
|
||||
// Vehicle methods
|
||||
Task<List<Vehicle>> GetVehiclesAsync(Guid customerId);
|
||||
Task<Vehicle?> GetVehicleByIdAsync(Guid vehicleId);
|
||||
Task<Vehicle> AddVehicleAsync(Guid customerId, Vehicle vehicle);
|
||||
Task<Vehicle> UpdateVehicleAsync(Vehicle vehicle);
|
||||
Task<bool> DeleteVehicleAsync(Guid vehicleId);
|
||||
Task SetPrimaryVehicleAsync(Guid customerId, Guid vehicleId);
|
||||
Task<Customer> CreateWithVehicleAsync(Customer customer, Vehicle vehicle);
|
||||
}
|
||||
|
||||
public class CustomerService : ICustomerService
|
||||
{
|
||||
private readonly AppDbContext _context;
|
||||
private readonly ILogger<CustomerService> _logger;
|
||||
|
||||
public CustomerService(AppDbContext context, ILogger<CustomerService> logger)
|
||||
{
|
||||
_context = context;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<List<Customer>> GetAllAsync(bool includeInactive = false)
|
||||
{
|
||||
var query = _context.Customers
|
||||
.Include(c => c.Vehicles.Where(v => v.IsActive))
|
||||
.AsQueryable();
|
||||
|
||||
if (!includeInactive)
|
||||
query = query.Where(c => c.IsActive);
|
||||
|
||||
return await query
|
||||
.OrderBy(c => c.LastName)
|
||||
.ThenBy(c => c.FirstName)
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
public async Task<Customer?> GetByIdAsync(Guid id)
|
||||
{
|
||||
return await _context.Customers
|
||||
.Include(c => c.Vehicles.Where(v => v.IsActive))
|
||||
.Include(c => c.TireStacks.Where(ts => ts.Status == TireStackStatus.Stored))
|
||||
.ThenInclude(ts => ts.StorageLocation)
|
||||
.Include(c => c.TireStacks.Where(ts => ts.Status == TireStackStatus.Stored))
|
||||
.ThenInclude(ts => ts.Vehicle)
|
||||
.AsSplitQuery()
|
||||
.FirstOrDefaultAsync(c => c.Id == id);
|
||||
}
|
||||
|
||||
public async Task<Customer?> GetByCustomerNumberAsync(string customerNumber)
|
||||
{
|
||||
return await _context.Customers
|
||||
.Include(c => c.Vehicles.Where(v => v.IsActive))
|
||||
.FirstOrDefaultAsync(c => c.CustomerNumber == customerNumber);
|
||||
}
|
||||
|
||||
public async Task<List<Customer>> SearchAsync(string searchTerm)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(searchTerm))
|
||||
return await GetAllAsync();
|
||||
|
||||
var term = searchTerm.Trim().ToLower();
|
||||
|
||||
return await _context.Customers
|
||||
.AsNoTracking()
|
||||
.AsSplitQuery()
|
||||
.Include(c => c.Vehicles.Where(v => v.IsActive))
|
||||
.Include(c => c.TireStacks.Where(ts => ts.Status == TireStackStatus.Stored))
|
||||
.Where(c => c.IsActive && (
|
||||
c.FirstName != null && c.FirstName.ToLower().Contains(term) ||
|
||||
c.LastName.ToLower().Contains(term) ||
|
||||
(c.Company != null && c.Company.ToLower().Contains(term)) ||
|
||||
c.CustomerNumber.ToLower().Contains(term) ||
|
||||
(c.Phone != null && c.Phone.Contains(term)) ||
|
||||
// Search in vehicles
|
||||
c.Vehicles.Any(v => v.IsActive && (
|
||||
v.Make.ToLower().Contains(term) ||
|
||||
(v.Model != null && v.Model.ToLower().Contains(term))))
|
||||
))
|
||||
.OrderBy(c => c.LastName)
|
||||
.ThenBy(c => c.FirstName)
|
||||
.Take(50)
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
public async Task<Customer?> FindByVehicleAsync(string searchTerm)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(searchTerm))
|
||||
return null;
|
||||
|
||||
var term = searchTerm.Trim().ToLower();
|
||||
|
||||
// Server-side search in Vehicles table by Make or Model
|
||||
var matchingVehicle = await _context.Vehicles
|
||||
.Include(v => v.Customer)
|
||||
.Where(v => v.IsActive && v.Customer != null && v.Customer.IsActive)
|
||||
.FirstOrDefaultAsync(v =>
|
||||
v.Make.ToLower().Contains(term) ||
|
||||
(v.Model != null && v.Model.ToLower().Contains(term)));
|
||||
|
||||
if (matchingVehicle?.Customer != null)
|
||||
{
|
||||
return await GetByIdAsync(matchingVehicle.Customer.Id);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public async Task<Customer> CreateAsync(Customer customer)
|
||||
{
|
||||
if (string.IsNullOrEmpty(customer.CustomerNumber))
|
||||
customer.CustomerNumber = await GenerateCustomerNumberAsync();
|
||||
|
||||
customer.CreatedAt = DateTime.UtcNow;
|
||||
|
||||
_context.Customers.Add(customer);
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
_logger.LogInformation("Customer created: {CustomerNumber} - {Name}",
|
||||
customer.CustomerNumber, customer.FullName);
|
||||
|
||||
return customer;
|
||||
}
|
||||
|
||||
public async Task<Customer> CreateWithVehicleAsync(Customer customer, Vehicle vehicle)
|
||||
{
|
||||
if (string.IsNullOrEmpty(customer.CustomerNumber))
|
||||
customer.CustomerNumber = await GenerateCustomerNumberAsync();
|
||||
|
||||
customer.CreatedAt = DateTime.UtcNow;
|
||||
vehicle.IsPrimary = true;
|
||||
vehicle.CreatedAt = DateTime.UtcNow;
|
||||
customer.Vehicles.Add(vehicle);
|
||||
|
||||
_context.Customers.Add(customer);
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
_logger.LogInformation("Customer created with vehicle: {CustomerNumber} - {Name}, Vehicle: {Make}",
|
||||
customer.CustomerNumber, customer.FullName, vehicle.Make);
|
||||
|
||||
return customer;
|
||||
}
|
||||
|
||||
public async Task<Customer> UpdateAsync(Customer customer)
|
||||
{
|
||||
customer.UpdatedAt = DateTime.UtcNow;
|
||||
_context.Customers.Update(customer);
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
_logger.LogInformation("Customer updated: {CustomerNumber}", customer.CustomerNumber);
|
||||
|
||||
return customer;
|
||||
}
|
||||
|
||||
public async Task<bool> DeleteAsync(Guid id)
|
||||
{
|
||||
var customer = await _context.Customers.FindAsync(id);
|
||||
if (customer == null)
|
||||
return false;
|
||||
|
||||
// Soft delete - just mark as inactive
|
||||
customer.IsActive = false;
|
||||
customer.UpdatedAt = DateTime.UtcNow;
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
_logger.LogInformation("Customer deactivated: {CustomerNumber}", customer.CustomerNumber);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public async Task<string> GenerateCustomerNumberAsync()
|
||||
{
|
||||
var year = DateTime.UtcNow.Year;
|
||||
var prefix = $"K-{year}-";
|
||||
|
||||
var lastNumber = await _context.Customers
|
||||
.Where(c => c.CustomerNumber.StartsWith(prefix))
|
||||
.OrderByDescending(c => c.CustomerNumber)
|
||||
.Select(c => c.CustomerNumber)
|
||||
.FirstOrDefaultAsync();
|
||||
|
||||
var nextNumber = 1;
|
||||
if (lastNumber != null)
|
||||
{
|
||||
var numPart = lastNumber.Replace(prefix, "");
|
||||
if (int.TryParse(numPart, out var lastNum))
|
||||
nextNumber = lastNum + 1;
|
||||
}
|
||||
|
||||
return $"{prefix}{nextNumber:D4}";
|
||||
}
|
||||
|
||||
public async Task<int> GetStoredTireStackCountAsync(Guid customerId)
|
||||
{
|
||||
return await _context.TireStacks
|
||||
.CountAsync(ts => ts.IsActive && ts.CustomerId == customerId && ts.Status == TireStackStatus.Stored);
|
||||
}
|
||||
|
||||
// Vehicle methods
|
||||
|
||||
public async Task<List<Vehicle>> GetVehiclesAsync(Guid customerId)
|
||||
{
|
||||
return await _context.Vehicles
|
||||
.Where(v => v.CustomerId == customerId && v.IsActive)
|
||||
.OrderByDescending(v => v.IsPrimary)
|
||||
.ThenBy(v => v.CreatedAt)
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
public async Task<Vehicle?> GetVehicleByIdAsync(Guid vehicleId)
|
||||
{
|
||||
return await _context.Vehicles
|
||||
.Include(v => v.Customer)
|
||||
.Include(v => v.TireStacks.Where(ts => ts.Status == TireStackStatus.Stored))
|
||||
.FirstOrDefaultAsync(v => v.Id == vehicleId);
|
||||
}
|
||||
|
||||
public async Task<Vehicle> AddVehicleAsync(Guid customerId, Vehicle vehicle)
|
||||
{
|
||||
vehicle.CustomerId = customerId;
|
||||
vehicle.CreatedAt = DateTime.UtcNow;
|
||||
|
||||
// If this is the first vehicle, make it primary
|
||||
var existingVehicles = await _context.Vehicles
|
||||
.CountAsync(v => v.CustomerId == customerId && v.IsActive);
|
||||
|
||||
if (existingVehicles == 0)
|
||||
vehicle.IsPrimary = true;
|
||||
|
||||
_context.Vehicles.Add(vehicle);
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
_logger.LogInformation("Vehicle added for customer {CustomerId}: {Make}",
|
||||
customerId, vehicle.Make);
|
||||
|
||||
return vehicle;
|
||||
}
|
||||
|
||||
public async Task<Vehicle> UpdateVehicleAsync(Vehicle vehicle)
|
||||
{
|
||||
vehicle.UpdatedAt = DateTime.UtcNow;
|
||||
_context.Vehicles.Update(vehicle);
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
_logger.LogInformation("Vehicle updated: {VehicleId} - {Make}",
|
||||
vehicle.Id, vehicle.Make);
|
||||
|
||||
return vehicle;
|
||||
}
|
||||
|
||||
public async Task<bool> DeleteVehicleAsync(Guid vehicleId)
|
||||
{
|
||||
var vehicle = await _context.Vehicles.FindAsync(vehicleId);
|
||||
if (vehicle == null)
|
||||
return false;
|
||||
|
||||
// Soft delete
|
||||
vehicle.IsActive = false;
|
||||
vehicle.UpdatedAt = DateTime.UtcNow;
|
||||
|
||||
// If this was primary, assign primary to another vehicle
|
||||
if (vehicle.IsPrimary)
|
||||
{
|
||||
vehicle.IsPrimary = false;
|
||||
var nextVehicle = await _context.Vehicles
|
||||
.Where(v => v.CustomerId == vehicle.CustomerId && v.IsActive && v.Id != vehicleId)
|
||||
.FirstOrDefaultAsync();
|
||||
|
||||
if (nextVehicle != null)
|
||||
nextVehicle.IsPrimary = true;
|
||||
}
|
||||
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
_logger.LogInformation("Vehicle deactivated: {VehicleId}", vehicleId);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public async Task SetPrimaryVehicleAsync(Guid customerId, Guid vehicleId)
|
||||
{
|
||||
var vehicles = await _context.Vehicles
|
||||
.Where(v => v.CustomerId == customerId && v.IsActive)
|
||||
.ToListAsync();
|
||||
|
||||
foreach (var v in vehicles)
|
||||
{
|
||||
v.IsPrimary = v.Id == vehicleId;
|
||||
v.UpdatedAt = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
_logger.LogInformation("Primary vehicle set for customer {CustomerId}: {VehicleId}",
|
||||
customerId, vehicleId);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,254 @@
|
||||
using Lagerbestand.Models;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace Lagerbestand.Services;
|
||||
|
||||
/// <summary>
|
||||
/// File storage service for disk/NFS-based file operations
|
||||
/// </summary>
|
||||
public class FileStorageService : IFileStorageService
|
||||
{
|
||||
private readonly ISiteSettingsService _siteSettingsService;
|
||||
private readonly ILogger<FileStorageService> _logger;
|
||||
private const string SettingsKey = "FileStorage";
|
||||
|
||||
public FileStorageService(ISiteSettingsService siteSettingsService, ILogger<FileStorageService> logger)
|
||||
{
|
||||
_siteSettingsService = siteSettingsService;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<FileStorageSettings> GetSettingsAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
var json = await _siteSettingsService.GetValueAsync(SettingsKey);
|
||||
if (string.IsNullOrEmpty(json))
|
||||
{
|
||||
return new FileStorageSettings();
|
||||
}
|
||||
|
||||
return JsonSerializer.Deserialize<FileStorageSettings>(json) ?? new FileStorageSettings();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error loading file storage settings");
|
||||
return new FileStorageSettings();
|
||||
}
|
||||
}
|
||||
|
||||
public async Task SaveSettingsAsync(FileStorageSettings settings)
|
||||
{
|
||||
var json = JsonSerializer.Serialize(settings);
|
||||
await _siteSettingsService.SetValueAsync(SettingsKey, json);
|
||||
_logger.LogInformation("File storage settings saved. Path: {BasePath}, Enabled: {IsEnabled}",
|
||||
settings.BasePath, settings.IsEnabled);
|
||||
}
|
||||
|
||||
public Task<(bool IsValid, string? ErrorMessage)> ValidateStoragePathAsync(string path)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(path))
|
||||
{
|
||||
return Task.FromResult((false, (string?)"Der Pfad darf nicht leer sein."));
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
// Check if directory exists or can be created
|
||||
if (!Directory.Exists(path))
|
||||
{
|
||||
try
|
||||
{
|
||||
Directory.CreateDirectory(path);
|
||||
_logger.LogInformation("Created storage directory: {Path}", path);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return Task.FromResult((false, (string?)$"Verzeichnis kann nicht erstellt werden: {ex.Message}"));
|
||||
}
|
||||
}
|
||||
|
||||
// Test write access
|
||||
var testFile = Path.Combine(path, $".write_test_{Guid.NewGuid():N}");
|
||||
try
|
||||
{
|
||||
File.WriteAllText(testFile, "test");
|
||||
File.Delete(testFile);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return Task.FromResult((false, (string?)$"Keine Schreibrechte: {ex.Message}"));
|
||||
}
|
||||
|
||||
return Task.FromResult((true, (string?)null));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return Task.FromResult((false, (string?)$"Fehler: {ex.Message}"));
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<string> SaveFileAsync(byte[] content, string fileName, string subDirectory = "attachments")
|
||||
{
|
||||
var settings = await GetSettingsAsync();
|
||||
|
||||
if (!settings.IsEnabled || string.IsNullOrWhiteSpace(settings.BasePath))
|
||||
{
|
||||
throw new InvalidOperationException("Dateispeicher ist nicht konfiguriert.");
|
||||
}
|
||||
|
||||
// Create unique filename to avoid collisions
|
||||
var timestamp = DateTime.UtcNow.ToString("yyyyMMdd_HHmmss");
|
||||
var uniqueId = Guid.NewGuid().ToString("N")[..8];
|
||||
var safeFileName = SanitizeFileName(fileName);
|
||||
var extension = Path.GetExtension(safeFileName);
|
||||
var nameWithoutExt = Path.GetFileNameWithoutExtension(safeFileName);
|
||||
var finalFileName = $"{nameWithoutExt}_{timestamp}_{uniqueId}{extension}";
|
||||
|
||||
// Create subdirectory path with year/month structure
|
||||
var datePath = DateTime.UtcNow.ToString("yyyy/MM");
|
||||
var relativePath = Path.Combine(subDirectory, datePath, finalFileName);
|
||||
var fullPath = Path.Combine(settings.BasePath, relativePath);
|
||||
|
||||
// Ensure directory exists
|
||||
var directory = Path.GetDirectoryName(fullPath);
|
||||
if (!string.IsNullOrEmpty(directory) && !Directory.Exists(directory))
|
||||
{
|
||||
Directory.CreateDirectory(directory);
|
||||
}
|
||||
|
||||
await File.WriteAllBytesAsync(fullPath, content);
|
||||
_logger.LogInformation("File saved: {RelativePath} ({Size} bytes)", relativePath, content.Length);
|
||||
|
||||
return relativePath;
|
||||
}
|
||||
|
||||
public async Task<byte[]?> ReadFileAsync(string relativePath)
|
||||
{
|
||||
var settings = await GetSettingsAsync();
|
||||
|
||||
if (!settings.IsEnabled || string.IsNullOrWhiteSpace(settings.BasePath))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var fullPath = ResolveSafePath(settings.BasePath, relativePath);
|
||||
|
||||
if (fullPath == null || !File.Exists(fullPath))
|
||||
{
|
||||
_logger.LogWarning("File not found: {RelativePath}", relativePath);
|
||||
return null;
|
||||
}
|
||||
|
||||
return await File.ReadAllBytesAsync(fullPath);
|
||||
}
|
||||
|
||||
public async Task<bool> DeleteFileAsync(string relativePath)
|
||||
{
|
||||
var settings = await GetSettingsAsync();
|
||||
|
||||
if (!settings.IsEnabled || string.IsNullOrWhiteSpace(settings.BasePath))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var fullPath = ResolveSafePath(settings.BasePath, relativePath);
|
||||
if (fullPath == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!File.Exists(fullPath))
|
||||
{
|
||||
return true; // Already deleted
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
File.Delete(fullPath);
|
||||
_logger.LogInformation("File deleted: {RelativePath}", relativePath);
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error deleting file: {RelativePath}", relativePath);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<bool> FileExistsAsync(string relativePath)
|
||||
{
|
||||
var settings = await GetSettingsAsync();
|
||||
|
||||
if (!settings.IsEnabled || string.IsNullOrWhiteSpace(settings.BasePath))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var fullPath = ResolveSafePath(settings.BasePath, relativePath);
|
||||
return await Task.FromResult(fullPath != null && File.Exists(fullPath));
|
||||
}
|
||||
|
||||
public async Task<long> GetFileSizeAsync(string relativePath)
|
||||
{
|
||||
var settings = await GetSettingsAsync();
|
||||
|
||||
if (!settings.IsEnabled || string.IsNullOrWhiteSpace(settings.BasePath))
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
var fullPath = ResolveSafePath(settings.BasePath, relativePath);
|
||||
|
||||
if (fullPath == null || !File.Exists(fullPath))
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
var fileInfo = new FileInfo(fullPath);
|
||||
return await Task.FromResult(fileInfo.Length);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolves a relative path against the base path and blocks path traversal attempts.
|
||||
/// Returns null if the resolved path escapes the base directory.
|
||||
/// </summary>
|
||||
private string? ResolveSafePath(string basePath, string relativePath)
|
||||
{
|
||||
try
|
||||
{
|
||||
var fullBasePath = Path.GetFullPath(basePath);
|
||||
var fullPath = Path.GetFullPath(Path.Combine(fullBasePath, relativePath));
|
||||
|
||||
if (!fullPath.StartsWith(fullBasePath.TrimEnd(Path.DirectorySeparatorChar) + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
_logger.LogWarning("Path traversal attempt blocked: {RelativePath}", relativePath);
|
||||
return null;
|
||||
}
|
||||
|
||||
return fullPath;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Invalid path rejected: {RelativePath}", relativePath);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static string SanitizeFileName(string fileName)
|
||||
{
|
||||
var invalidChars = Path.GetInvalidFileNameChars();
|
||||
var sanitized = string.Join("_", fileName.Split(invalidChars, StringSplitOptions.RemoveEmptyEntries));
|
||||
|
||||
// Limit length
|
||||
if (sanitized.Length > 200)
|
||||
{
|
||||
var ext = Path.GetExtension(sanitized);
|
||||
var name = Path.GetFileNameWithoutExtension(sanitized);
|
||||
sanitized = name[..(200 - ext.Length)] + ext;
|
||||
}
|
||||
|
||||
return sanitized;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
using Lagerbestand.Models;
|
||||
|
||||
namespace Lagerbestand.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Service interface for file storage operations (disk/NFS based)
|
||||
/// </summary>
|
||||
public interface IFileStorageService
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the current storage settings
|
||||
/// </summary>
|
||||
Task<FileStorageSettings> GetSettingsAsync();
|
||||
|
||||
/// <summary>
|
||||
/// Updates the storage settings
|
||||
/// </summary>
|
||||
Task SaveSettingsAsync(FileStorageSettings settings);
|
||||
|
||||
/// <summary>
|
||||
/// Validates if the storage path is accessible and writable
|
||||
/// </summary>
|
||||
Task<(bool IsValid, string? ErrorMessage)> ValidateStoragePathAsync(string path);
|
||||
|
||||
/// <summary>
|
||||
/// Saves a file to storage and returns the relative path
|
||||
/// </summary>
|
||||
Task<string> SaveFileAsync(byte[] content, string fileName, string subDirectory = "attachments");
|
||||
|
||||
/// <summary>
|
||||
/// Reads a file from storage
|
||||
/// </summary>
|
||||
Task<byte[]?> ReadFileAsync(string relativePath);
|
||||
|
||||
/// <summary>
|
||||
/// Deletes a file from storage
|
||||
/// </summary>
|
||||
Task<bool> DeleteFileAsync(string relativePath);
|
||||
|
||||
/// <summary>
|
||||
/// Checks if a file exists in storage
|
||||
/// </summary>
|
||||
Task<bool> FileExistsAsync(string relativePath);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the file size in bytes
|
||||
/// </summary>
|
||||
Task<long> GetFileSizeAsync(string relativePath);
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
using Lagerbestand.Models;
|
||||
|
||||
namespace Lagerbestand.Services;
|
||||
|
||||
public interface IKanbanService
|
||||
{
|
||||
// Columns
|
||||
Task<List<KanbanColumn>> GetColumnsAsync();
|
||||
Task<KanbanColumn?> GetColumnByIdAsync(Guid id);
|
||||
Task<KanbanColumn> CreateColumnAsync(KanbanColumn column);
|
||||
Task<KanbanColumn> UpdateColumnAsync(KanbanColumn column);
|
||||
Task DeleteColumnAsync(Guid id);
|
||||
Task ReorderColumnsAsync(List<Guid> columnIds);
|
||||
|
||||
// Labels
|
||||
Task<List<KanbanLabel>> GetLabelsAsync();
|
||||
Task<KanbanLabel?> GetLabelByIdAsync(Guid id);
|
||||
Task<KanbanLabel> CreateLabelAsync(KanbanLabel label);
|
||||
Task<KanbanLabel> UpdateLabelAsync(KanbanLabel label);
|
||||
Task DeleteLabelAsync(Guid id);
|
||||
|
||||
// Tasks
|
||||
Task<List<KanbanTask>> GetTasksAsync(bool includeArchived = false);
|
||||
Task<List<KanbanTask>> GetTasksByColumnAsync(Guid columnId);
|
||||
Task<KanbanTask?> GetTaskByIdAsync(Guid id);
|
||||
Task<KanbanTask> CreateTaskAsync(KanbanTask task, Guid? userId = null);
|
||||
Task<KanbanTask> CreateTaskFromTemplateAsync(Guid templateId, string? title = null, Guid? customerId = null, Guid? userId = null);
|
||||
Task<KanbanTask> UpdateTaskAsync(KanbanTask task, Guid? userId = null);
|
||||
Task MoveTaskAsync(Guid taskId, Guid newColumnId, int newSortOrder, Guid? userId = null);
|
||||
Task DeleteTaskAsync(Guid id);
|
||||
Task ArchiveTaskAsync(Guid id);
|
||||
Task UnarchiveTaskAsync(Guid id);
|
||||
Task<List<KanbanTask>> GetRecentlyArchivedTasksAsync(int days = 7);
|
||||
Task ArchiveOldCompletedTasksAsync(int daysAfterCompletion = 30);
|
||||
|
||||
// Task Todos
|
||||
Task<KanbanTaskTodo> AddTodoAsync(Guid taskId, string text);
|
||||
Task<KanbanTaskTodo> UpdateTodoAsync(KanbanTaskTodo todo);
|
||||
Task ToggleTodoAsync(Guid todoId);
|
||||
Task DeleteTodoAsync(Guid todoId);
|
||||
Task ReorderTodosAsync(Guid taskId, List<Guid> todoIds);
|
||||
|
||||
// Task Comments/Chat
|
||||
Task<List<KanbanTaskComment>> GetCommentsAsync(Guid taskId);
|
||||
Task<KanbanTaskComment> AddCommentAsync(Guid taskId, string content, Guid? userId);
|
||||
Task AddActivityAsync(Guid taskId, string activity, Guid? userId = null);
|
||||
|
||||
// Task Notes
|
||||
Task<List<KanbanTaskNote>> GetNotesAsync(Guid taskId);
|
||||
Task<KanbanTaskNote> AddNoteAsync(Guid taskId, string title, string content);
|
||||
Task<KanbanTaskNote> UpdateNoteAsync(KanbanTaskNote note, Guid? userId = null);
|
||||
Task DeleteNoteAsync(Guid noteId);
|
||||
|
||||
// Task Attachments
|
||||
Task<List<KanbanTaskAttachment>> GetAttachmentsAsync(Guid taskId);
|
||||
Task<KanbanTaskAttachment?> GetAttachmentByIdAsync(Guid id);
|
||||
Task<KanbanTaskAttachment> AddAttachmentAsync(Guid taskId, string fileName, string contentType, byte[] content, Guid? userId);
|
||||
Task<byte[]?> GetAttachmentContentAsync(Guid attachmentId);
|
||||
Task DeleteAttachmentAsync(Guid attachmentId);
|
||||
|
||||
// Task Labels
|
||||
Task AddLabelToTaskAsync(Guid taskId, Guid labelId);
|
||||
Task RemoveLabelFromTaskAsync(Guid taskId, Guid labelId);
|
||||
Task SetTaskLabelsAsync(Guid taskId, List<Guid> labelIds);
|
||||
|
||||
// Task Assignees
|
||||
Task AddAssigneeAsync(Guid taskId, Guid userId);
|
||||
Task RemoveAssigneeAsync(Guid taskId, Guid userId);
|
||||
Task SetAssigneesAsync(Guid taskId, List<Guid> userIds, Guid? changedByUserId = null);
|
||||
|
||||
// Task Customers
|
||||
Task AddCustomerToTaskAsync(Guid taskId, Guid customerId);
|
||||
Task RemoveCustomerFromTaskAsync(Guid taskId, Guid customerId);
|
||||
Task SetTaskCustomersAsync(Guid taskId, Dictionary<Guid, Guid?> customerVehicleMap);
|
||||
|
||||
// Task TireStacks
|
||||
Task AddTireStackToTaskAsync(Guid taskId, Guid tireStackId);
|
||||
Task RemoveTireStackFromTaskAsync(Guid taskId, Guid tireStackId);
|
||||
Task SetTaskTireStacksAsync(Guid taskId, List<Guid> tireStackIds);
|
||||
|
||||
// Templates
|
||||
Task<List<KanbanTaskTemplate>> GetTemplatesAsync();
|
||||
Task<KanbanTaskTemplate?> GetTemplateByIdAsync(Guid id);
|
||||
Task<KanbanTaskTemplate> CreateTemplateAsync(KanbanTaskTemplate template);
|
||||
Task<KanbanTaskTemplate> UpdateTemplateAsync(KanbanTaskTemplate template);
|
||||
Task DeleteTemplateAsync(Guid id);
|
||||
|
||||
// Template Todos
|
||||
Task<KanbanTaskTemplateTodo> AddTemplateTodoAsync(Guid templateId, string text);
|
||||
Task DeleteTemplateTodoAsync(Guid todoId);
|
||||
Task SyncTemplateTodosAsync(Guid templateId, List<KanbanTaskTemplateTodo> todos);
|
||||
|
||||
// Template Labels
|
||||
Task SetTemplateLabelsAsync(Guid templateId, List<Guid> labelIds);
|
||||
|
||||
// Statistics/Dashboard
|
||||
Task<KanbanStatistics> GetStatisticsAsync();
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
using Lagerbestand.Data;
|
||||
using Lagerbestand.Models;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Lagerbestand.Services;
|
||||
|
||||
public interface IInventoryService
|
||||
{
|
||||
Task<List<InventoryItem>> GetAllAsync();
|
||||
Task<InventoryItem?> GetByIdAsync(Guid id);
|
||||
Task<InventoryItem?> GetByQrCodeAsync(string qrCode);
|
||||
Task<bool> ExistsAsync(string qrCode);
|
||||
Task<InventoryItem> AddItemAsync(string qrCode, Guid? catalogItemId = null, string? name = null);
|
||||
Task<List<InventoryItem>> AddItemsAsync(List<string> qrCodes, Guid? catalogItemId = null);
|
||||
Task<bool> RemoveItemAsync(string qrCode);
|
||||
Task<int> RemoveItemsAsync(List<string> qrCodes);
|
||||
Task<InventoryItem> UpdateCategoryAsync(Guid id, Guid? catalogItemId);
|
||||
Task<int> UpdateCategoryForItemsAsync(List<Guid> itemIds, Guid? catalogItemId);
|
||||
Task DeleteAsync(Guid id);
|
||||
}
|
||||
|
||||
public class InventoryService : IInventoryService
|
||||
{
|
||||
private readonly AppDbContext _context;
|
||||
private readonly ILogger<InventoryService> _logger;
|
||||
|
||||
public InventoryService(AppDbContext context, ILogger<InventoryService> logger)
|
||||
{
|
||||
_context = context;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<List<InventoryItem>> GetAllAsync()
|
||||
{
|
||||
return await _context.InventoryItems
|
||||
.AsNoTracking()
|
||||
.Include(x => x.CatalogItem)
|
||||
.OrderByDescending(x => x.CreatedAt)
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
public async Task<InventoryItem?> GetByIdAsync(Guid id)
|
||||
{
|
||||
return await _context.InventoryItems
|
||||
.Include(x => x.CatalogItem)
|
||||
.FirstOrDefaultAsync(x => x.Id == id);
|
||||
}
|
||||
|
||||
public async Task<InventoryItem?> GetByQrCodeAsync(string qrCode)
|
||||
{
|
||||
return await _context.InventoryItems
|
||||
.Include(x => x.CatalogItem)
|
||||
.FirstOrDefaultAsync(x => x.QrCode == qrCode);
|
||||
}
|
||||
|
||||
public async Task<bool> ExistsAsync(string qrCode)
|
||||
{
|
||||
return await _context.InventoryItems.AnyAsync(x => x.QrCode == qrCode);
|
||||
}
|
||||
|
||||
public async Task<InventoryItem> AddItemAsync(string qrCode, Guid? catalogItemId = null, string? name = null)
|
||||
{
|
||||
if (await _context.InventoryItems.AnyAsync(x => x.QrCode == qrCode))
|
||||
throw new InvalidOperationException($"Artikel mit QR-Code '{qrCode}' ist bereits im Bestand.");
|
||||
|
||||
var item = new InventoryItem
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
QrCode = qrCode,
|
||||
CatalogItemId = catalogItemId,
|
||||
Name = name,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
};
|
||||
|
||||
_context.InventoryItems.Add(item);
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
return await GetByIdAsync(item.Id) ?? item;
|
||||
}
|
||||
|
||||
public async Task<List<InventoryItem>> AddItemsAsync(List<string> qrCodes, Guid? catalogItemId = null)
|
||||
{
|
||||
var addedItems = new List<InventoryItem>();
|
||||
var now = DateTime.UtcNow;
|
||||
var distinctCodes = qrCodes.Distinct().ToList();
|
||||
|
||||
var existingCodes = await _context.InventoryItems
|
||||
.Where(x => distinctCodes.Contains(x.QrCode))
|
||||
.Select(x => x.QrCode)
|
||||
.ToListAsync();
|
||||
var existingSet = existingCodes.ToHashSet();
|
||||
|
||||
foreach (var qrCode in distinctCodes)
|
||||
{
|
||||
if (existingSet.Contains(qrCode))
|
||||
continue;
|
||||
|
||||
var item = new InventoryItem
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
QrCode = qrCode,
|
||||
CatalogItemId = catalogItemId,
|
||||
CreatedAt = now
|
||||
};
|
||||
|
||||
_context.InventoryItems.Add(item);
|
||||
addedItems.Add(item);
|
||||
}
|
||||
|
||||
if (addedItems.Count > 0)
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
return addedItems;
|
||||
}
|
||||
|
||||
public async Task<bool> RemoveItemAsync(string qrCode)
|
||||
{
|
||||
var item = await _context.InventoryItems.FirstOrDefaultAsync(x => x.QrCode == qrCode);
|
||||
if (item == null)
|
||||
return false;
|
||||
|
||||
_context.InventoryItems.Remove(item);
|
||||
await _context.SaveChangesAsync();
|
||||
return true;
|
||||
}
|
||||
|
||||
public async Task<int> RemoveItemsAsync(List<string> qrCodes)
|
||||
{
|
||||
var count = 0;
|
||||
foreach (var qrCode in qrCodes.Distinct())
|
||||
{
|
||||
var item = await _context.InventoryItems.FirstOrDefaultAsync(x => x.QrCode == qrCode);
|
||||
if (item != null)
|
||||
{
|
||||
_context.InventoryItems.Remove(item);
|
||||
count++;
|
||||
}
|
||||
}
|
||||
|
||||
if (count > 0)
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
return count;
|
||||
}
|
||||
|
||||
public async Task<InventoryItem> UpdateCategoryAsync(Guid id, Guid? catalogItemId)
|
||||
{
|
||||
var item = await _context.InventoryItems.FindAsync(id)
|
||||
?? throw new InvalidOperationException("Artikel nicht gefunden");
|
||||
|
||||
item.CatalogItemId = catalogItemId;
|
||||
item.UpdatedAt = DateTime.UtcNow;
|
||||
|
||||
await _context.SaveChangesAsync();
|
||||
return item;
|
||||
}
|
||||
|
||||
public async Task<int> UpdateCategoryForItemsAsync(List<Guid> itemIds, Guid? catalogItemId)
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
var count = 0;
|
||||
|
||||
foreach (var id in itemIds)
|
||||
{
|
||||
var item = await _context.InventoryItems.FindAsync(id);
|
||||
if (item != null)
|
||||
{
|
||||
item.CatalogItemId = catalogItemId;
|
||||
item.UpdatedAt = now;
|
||||
count++;
|
||||
}
|
||||
}
|
||||
|
||||
if (count > 0)
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
return count;
|
||||
}
|
||||
|
||||
public async Task DeleteAsync(Guid id)
|
||||
{
|
||||
var item = await _context.InventoryItems.FindAsync(id);
|
||||
if (item != null)
|
||||
{
|
||||
_context.InventoryItems.Remove(item);
|
||||
await _context.SaveChangesAsync();
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,515 @@
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Lagerbestand.Data;
|
||||
using Lagerbestand.Models;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Lagerbestand.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Result of a Lexware customer sync operation
|
||||
/// </summary>
|
||||
public class LexwareSyncResult
|
||||
{
|
||||
public bool Success { get; set; }
|
||||
public string Message { get; set; } = string.Empty;
|
||||
public int CustomersCreated { get; set; }
|
||||
public int CustomersUpdated { get; set; }
|
||||
public int CustomersDeactivated { get; set; }
|
||||
public int TotalProcessed { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Lexware API contact response models
|
||||
/// </summary>
|
||||
public class LexwareContactsResponse
|
||||
{
|
||||
[JsonPropertyName("content")]
|
||||
public List<LexwareContact> Content { get; set; } = new();
|
||||
|
||||
[JsonPropertyName("totalPages")]
|
||||
public int TotalPages { get; set; }
|
||||
|
||||
[JsonPropertyName("totalElements")]
|
||||
public int TotalElements { get; set; }
|
||||
|
||||
[JsonPropertyName("last")]
|
||||
public bool Last { get; set; }
|
||||
|
||||
[JsonPropertyName("number")]
|
||||
public int Number { get; set; }
|
||||
}
|
||||
|
||||
public class LexwareContact
|
||||
{
|
||||
[JsonPropertyName("id")]
|
||||
public string Id { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("organizationId")]
|
||||
public string? OrganizationId { get; set; }
|
||||
|
||||
[JsonPropertyName("version")]
|
||||
public int Version { get; set; }
|
||||
|
||||
[JsonPropertyName("roles")]
|
||||
public LexwareRoles? Roles { get; set; }
|
||||
|
||||
[JsonPropertyName("company")]
|
||||
public LexwareCompany? Company { get; set; }
|
||||
|
||||
[JsonPropertyName("person")]
|
||||
public LexwarePerson? Person { get; set; }
|
||||
|
||||
[JsonPropertyName("addresses")]
|
||||
public LexwareAddresses? Addresses { get; set; }
|
||||
|
||||
[JsonPropertyName("emailAddresses")]
|
||||
public LexwareEmailAddresses? EmailAddresses { get; set; }
|
||||
|
||||
[JsonPropertyName("phoneNumbers")]
|
||||
public LexwarePhoneNumbers? PhoneNumbers { get; set; }
|
||||
|
||||
[JsonPropertyName("note")]
|
||||
public string? Note { get; set; }
|
||||
|
||||
[JsonPropertyName("archived")]
|
||||
public bool Archived { get; set; }
|
||||
}
|
||||
|
||||
public class LexwareRoles
|
||||
{
|
||||
[JsonPropertyName("customer")]
|
||||
public LexwareCustomerRole? Customer { get; set; }
|
||||
}
|
||||
|
||||
public class LexwareCustomerRole
|
||||
{
|
||||
[JsonPropertyName("number")]
|
||||
public long? Number { get; set; }
|
||||
}
|
||||
|
||||
public class LexwareCompany
|
||||
{
|
||||
[JsonPropertyName("name")]
|
||||
public string? Name { get; set; }
|
||||
}
|
||||
|
||||
public class LexwarePerson
|
||||
{
|
||||
[JsonPropertyName("salutation")]
|
||||
public string? Salutation { get; set; }
|
||||
|
||||
[JsonPropertyName("firstName")]
|
||||
public string? FirstName { get; set; }
|
||||
|
||||
[JsonPropertyName("lastName")]
|
||||
public string? LastName { get; set; }
|
||||
}
|
||||
|
||||
public class LexwareAddresses
|
||||
{
|
||||
[JsonPropertyName("billing")]
|
||||
public List<LexwareAddress>? Billing { get; set; }
|
||||
}
|
||||
|
||||
public class LexwareAddress
|
||||
{
|
||||
[JsonPropertyName("street")]
|
||||
public string? Street { get; set; }
|
||||
|
||||
[JsonPropertyName("zip")]
|
||||
public string? Zip { get; set; }
|
||||
|
||||
[JsonPropertyName("city")]
|
||||
public string? City { get; set; }
|
||||
|
||||
[JsonPropertyName("countryCode")]
|
||||
public string? CountryCode { get; set; }
|
||||
}
|
||||
|
||||
public class LexwareEmailAddresses
|
||||
{
|
||||
[JsonPropertyName("business")]
|
||||
public List<string>? Business { get; set; }
|
||||
|
||||
[JsonPropertyName("private")]
|
||||
public List<string>? Private { get; set; }
|
||||
|
||||
[JsonPropertyName("other")]
|
||||
public List<string>? Other { get; set; }
|
||||
}
|
||||
|
||||
public class LexwarePhoneNumbers
|
||||
{
|
||||
[JsonPropertyName("business")]
|
||||
public List<string>? Business { get; set; }
|
||||
|
||||
[JsonPropertyName("private")]
|
||||
public List<string>? Private { get; set; }
|
||||
|
||||
[JsonPropertyName("mobile")]
|
||||
public List<string>? Mobile { get; set; }
|
||||
}
|
||||
|
||||
public interface ILexwareSyncService
|
||||
{
|
||||
/// <summary>
|
||||
/// Tests the API connection with the given settings
|
||||
/// </summary>
|
||||
Task<(bool Success, string Message)> TestConnectionAsync(LexwareApiSettings settings);
|
||||
|
||||
/// <summary>
|
||||
/// Syncs customers from Lexware API to the local database
|
||||
/// </summary>
|
||||
Task<LexwareSyncResult> SyncCustomersAsync(CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
public class LexwareSyncService : ILexwareSyncService
|
||||
{
|
||||
private readonly AppDbContext _context;
|
||||
private readonly ISiteSettingsService _siteSettingsService;
|
||||
private readonly ICustomerService _customerService;
|
||||
private readonly ILogger<LexwareSyncService> _logger;
|
||||
private readonly IHttpClientFactory _httpClientFactory;
|
||||
|
||||
public LexwareSyncService(
|
||||
AppDbContext context,
|
||||
ISiteSettingsService siteSettingsService,
|
||||
ICustomerService customerService,
|
||||
ILogger<LexwareSyncService> logger,
|
||||
IHttpClientFactory httpClientFactory)
|
||||
{
|
||||
_context = context;
|
||||
_siteSettingsService = siteSettingsService;
|
||||
_customerService = customerService;
|
||||
_logger = logger;
|
||||
_httpClientFactory = httpClientFactory;
|
||||
}
|
||||
|
||||
public async Task<(bool Success, string Message)> TestConnectionAsync(LexwareApiSettings settings)
|
||||
{
|
||||
if (!settings.IsConfigured)
|
||||
return (false, "API URL und API-Key müssen konfiguriert sein.");
|
||||
|
||||
try
|
||||
{
|
||||
using var client = CreateHttpClient(settings);
|
||||
var response = await client.GetAsync("contacts?page=0&size=1");
|
||||
|
||||
if (response.IsSuccessStatusCode)
|
||||
{
|
||||
var json = await response.Content.ReadAsStringAsync();
|
||||
var result = JsonSerializer.Deserialize<LexwareContactsResponse>(json);
|
||||
return (true, $"Verbindung erfolgreich! {result?.TotalElements ?? 0} Kontakte gefunden.");
|
||||
}
|
||||
|
||||
return response.StatusCode switch
|
||||
{
|
||||
System.Net.HttpStatusCode.Unauthorized => (false, "Authentifizierung fehlgeschlagen. Bitte API-Key prüfen."),
|
||||
System.Net.HttpStatusCode.Forbidden => (false, "Zugriff verweigert. API-Key hat keine ausreichenden Berechtigungen."),
|
||||
System.Net.HttpStatusCode.NotFound => (false, "API-Endpunkt nicht gefunden. Bitte URL prüfen."),
|
||||
_ => (false, $"API-Fehler: {response.StatusCode} - {await response.Content.ReadAsStringAsync()}")
|
||||
};
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
return (false, $"Verbindungsfehler: {ex.Message}");
|
||||
}
|
||||
catch (TaskCanceledException)
|
||||
{
|
||||
return (false, "Zeitüberschreitung bei der Verbindung.");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return (false, $"Fehler: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<LexwareSyncResult> SyncCustomersAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
var settings = await _siteSettingsService.GetLexwareApiSettingsAsync();
|
||||
|
||||
if (!settings.IsEnabled || !settings.IsConfigured)
|
||||
{
|
||||
return new LexwareSyncResult
|
||||
{
|
||||
Success = false,
|
||||
Message = "Lexware API ist nicht konfiguriert oder deaktiviert."
|
||||
};
|
||||
}
|
||||
|
||||
var result = new LexwareSyncResult();
|
||||
|
||||
try
|
||||
{
|
||||
_logger.LogInformation("Starting Lexware customer sync...");
|
||||
|
||||
using var client = CreateHttpClient(settings);
|
||||
var allContacts = new List<LexwareContact>();
|
||||
|
||||
// Fetch all pages of customers
|
||||
var page = 0;
|
||||
bool lastPage = false;
|
||||
while (!lastPage)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var response = await client.GetAsync($"contacts?page={page}&size=250&customer=true", cancellationToken);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
var json = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||
var pageResult = JsonSerializer.Deserialize<LexwareContactsResponse>(json);
|
||||
|
||||
if (pageResult?.Content != null)
|
||||
{
|
||||
// Only include contacts with customer role that are not archived
|
||||
allContacts.AddRange(pageResult.Content.Where(c => !c.Archived));
|
||||
}
|
||||
|
||||
lastPage = pageResult?.Last ?? true;
|
||||
page++;
|
||||
}
|
||||
|
||||
_logger.LogInformation("Fetched {Count} customer contacts from Lexware", allContacts.Count);
|
||||
|
||||
// Get all existing customers indexed by CustomerNumber (include inactive for reactivation)
|
||||
var existingCustomers = await _context.Customers
|
||||
.Include(c => c.Vehicles.Where(v => v.IsActive))
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
var customersByNumber = existingCustomers
|
||||
.Where(c => !string.IsNullOrEmpty(c.CustomerNumber))
|
||||
.ToDictionary(c => c.CustomerNumber, c => c);
|
||||
|
||||
foreach (var contact in allContacts)
|
||||
{
|
||||
result.TotalProcessed++;
|
||||
|
||||
var customerNumber = contact.Roles?.Customer?.Number?.ToString() ?? "";
|
||||
if (string.IsNullOrEmpty(customerNumber))
|
||||
continue;
|
||||
|
||||
var firstName = contact.Person?.FirstName?.Trim() ?? "";
|
||||
var lastName = contact.Person?.LastName?.Trim() ?? "";
|
||||
|
||||
// Skip contacts without a name
|
||||
if (string.IsNullOrEmpty(firstName) && string.IsNullOrEmpty(lastName))
|
||||
{
|
||||
// Use company name as last name fallback
|
||||
if (!string.IsNullOrEmpty(contact.Company?.Name))
|
||||
{
|
||||
lastName = contact.Company.Name;
|
||||
}
|
||||
else
|
||||
{
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
var company = contact.Company?.Name?.Trim();
|
||||
var phone = GetFirstPhone(contact.PhoneNumbers);
|
||||
var email = GetFirstEmail(contact.EmailAddresses);
|
||||
var notes = contact.Note?.Trim();
|
||||
|
||||
if (customersByNumber.TryGetValue(customerNumber, out var existing))
|
||||
{
|
||||
// Update existing customer
|
||||
bool changed = false;
|
||||
|
||||
if (existing.FirstName != firstName) { existing.FirstName = firstName; changed = true; }
|
||||
if (existing.LastName != lastName) { existing.LastName = lastName; changed = true; }
|
||||
if (existing.Company != company) { existing.Company = company; changed = true; }
|
||||
if (existing.Phone != phone) { existing.Phone = phone; changed = true; }
|
||||
if (existing.Email != email) { existing.Email = email; changed = true; }
|
||||
if (existing.Notes != notes) { existing.Notes = notes; changed = true; }
|
||||
|
||||
// Reactivate if previously deactivated
|
||||
if (!existing.IsActive) { existing.IsActive = true; changed = true; }
|
||||
|
||||
if (changed)
|
||||
{
|
||||
existing.UpdatedAt = DateTime.UtcNow;
|
||||
_context.Customers.Update(existing);
|
||||
result.CustomersUpdated++;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// Create new customer
|
||||
var newCustomer = new Customer
|
||||
{
|
||||
CustomerNumber = customerNumber,
|
||||
FirstName = string.IsNullOrEmpty(firstName) ? "-" : firstName,
|
||||
LastName = string.IsNullOrEmpty(lastName) ? "-" : lastName,
|
||||
Company = company,
|
||||
Phone = phone,
|
||||
Email = email,
|
||||
Notes = notes,
|
||||
IsActive = true,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
};
|
||||
|
||||
_context.Customers.Add(newCustomer);
|
||||
result.CustomersCreated++;
|
||||
}
|
||||
}
|
||||
|
||||
await _context.SaveChangesAsync(cancellationToken);
|
||||
|
||||
// Deactivate customers not found in Lexware (Lexware is single source of truth)
|
||||
var lexwareCustomerNumbers = allContacts
|
||||
.Select(c => c.Roles?.Customer?.Number?.ToString())
|
||||
.Where(n => !string.IsNullOrEmpty(n))
|
||||
.ToHashSet();
|
||||
|
||||
var customersToDeactivate = await _context.Customers
|
||||
.Where(c => c.IsActive && !string.IsNullOrEmpty(c.CustomerNumber) && !lexwareCustomerNumbers.Contains(c.CustomerNumber))
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
foreach (var customer in customersToDeactivate)
|
||||
{
|
||||
customer.IsActive = false;
|
||||
customer.UpdatedAt = DateTime.UtcNow;
|
||||
result.CustomersDeactivated++;
|
||||
_logger.LogWarning("Customer {CustomerNumber} ({Name}) deactivated - not found in Lexware",
|
||||
customer.CustomerNumber, customer.FullName);
|
||||
}
|
||||
|
||||
if (customersToDeactivate.Count > 0)
|
||||
await _context.SaveChangesAsync(cancellationToken);
|
||||
|
||||
// Update sync timestamp
|
||||
settings.LastSyncAt = DateTime.UtcNow;
|
||||
settings.LastSyncCount = result.TotalProcessed;
|
||||
settings.LastSyncResult = $"{result.CustomersCreated} erstellt, {result.CustomersUpdated} aktualisiert, {result.CustomersDeactivated} deaktiviert";
|
||||
await _siteSettingsService.SaveLexwareApiSettingsAsync(settings);
|
||||
|
||||
result.Success = true;
|
||||
result.Message = $"Synchronisierung erfolgreich: {result.CustomersCreated} erstellt, {result.CustomersUpdated} aktualisiert, {result.CustomersDeactivated} deaktiviert ({result.TotalProcessed} verarbeitet)";
|
||||
|
||||
_logger.LogInformation("Lexware sync completed: {Message}", result.Message);
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
result.Success = false;
|
||||
result.Message = $"API-Verbindungsfehler: {ex.Message}";
|
||||
_logger.LogError(ex, "Lexware sync failed: HTTP error");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
result.Success = false;
|
||||
result.Message = $"Synchronisierungsfehler: {ex.Message}";
|
||||
_logger.LogError(ex, "Lexware sync failed");
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private HttpClient CreateHttpClient(LexwareApiSettings settings)
|
||||
{
|
||||
var client = _httpClientFactory.CreateClient("LexwareApi");
|
||||
client.BaseAddress = new Uri(settings.BaseUrl.TrimEnd('/') + "/");
|
||||
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", settings.ApiKey);
|
||||
client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
|
||||
client.Timeout = TimeSpan.FromSeconds(30);
|
||||
return client;
|
||||
}
|
||||
|
||||
private static string? GetFirstPhone(LexwarePhoneNumbers? phones)
|
||||
{
|
||||
if (phones == null) return null;
|
||||
|
||||
return phones.Mobile?.FirstOrDefault()
|
||||
?? phones.Business?.FirstOrDefault()
|
||||
?? phones.Private?.FirstOrDefault();
|
||||
}
|
||||
|
||||
private static string? GetFirstEmail(LexwareEmailAddresses? emails)
|
||||
{
|
||||
if (emails == null) return null;
|
||||
|
||||
return emails.Business?.FirstOrDefault()
|
||||
?? emails.Private?.FirstOrDefault()
|
||||
?? emails.Other?.FirstOrDefault();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Background service that runs daily Lexware customer sync
|
||||
/// </summary>
|
||||
public class LexwareSyncBackgroundService : BackgroundService
|
||||
{
|
||||
private readonly IServiceProvider _serviceProvider;
|
||||
private readonly ILogger<LexwareSyncBackgroundService> _logger;
|
||||
|
||||
public LexwareSyncBackgroundService(IServiceProvider serviceProvider, ILogger<LexwareSyncBackgroundService> logger)
|
||||
{
|
||||
_serviceProvider = serviceProvider;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
_logger.LogInformation("Lexware sync background service started");
|
||||
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var scope = _serviceProvider.CreateScope();
|
||||
var settingsService = scope.ServiceProvider.GetRequiredService<ISiteSettingsService>();
|
||||
var settings = await settingsService.GetLexwareApiSettingsAsync();
|
||||
|
||||
if (settings is { IsEnabled: true, IsConfigured: true, AutoSyncEnabled: true })
|
||||
{
|
||||
// Parse target time
|
||||
if (TimeSpan.TryParse(settings.AutoSyncTime, out var targetTime))
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
var nextRun = now.Date.Add(targetTime);
|
||||
|
||||
// If we've already passed the target time today, schedule for tomorrow
|
||||
if (now > nextRun)
|
||||
nextRun = nextRun.AddDays(1);
|
||||
|
||||
var delay = nextRun - now;
|
||||
_logger.LogDebug("Lexware auto-sync scheduled for {NextRun} (in {Delay})", nextRun, delay);
|
||||
|
||||
await Task.Delay(delay, stoppingToken);
|
||||
|
||||
if (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
_logger.LogInformation("Running scheduled Lexware customer sync...");
|
||||
using var syncScope = _serviceProvider.CreateScope();
|
||||
var syncService = syncScope.ServiceProvider.GetRequiredService<ILexwareSyncService>();
|
||||
var result = await syncService.SyncCustomersAsync(stoppingToken);
|
||||
_logger.LogInformation("Scheduled Lexware sync result: {Message}", result.Message);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// Invalid time format, wait and retry
|
||||
await Task.Delay(TimeSpan.FromHours(1), stoppingToken);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// Not configured or disabled, check again in 15 minutes
|
||||
await Task.Delay(TimeSpan.FromMinutes(15), stoppingToken);
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
break;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error in Lexware sync background service");
|
||||
await Task.Delay(TimeSpan.FromMinutes(5), stoppingToken);
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogInformation("Lexware sync background service stopped");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,275 @@
|
||||
using System.Text.RegularExpressions;
|
||||
using Lagerbestand.Data;
|
||||
using Lagerbestand.Exceptions;
|
||||
using Lagerbestand.Models;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Lagerbestand.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Result of location code validation.
|
||||
/// </summary>
|
||||
public record LocationCodeValidationResult(
|
||||
bool IsValid,
|
||||
string? ErrorMessage = null,
|
||||
AreaType? Area = null,
|
||||
int? Row = null,
|
||||
ShelfPosition? Position = null,
|
||||
int? SlotNumber = null
|
||||
);
|
||||
|
||||
/// <summary>
|
||||
/// Parsed components of a location code.
|
||||
/// </summary>
|
||||
public record ParsedLocationCode(AreaType Area, int Row, ShelfPosition Position, int SlotNumber)
|
||||
{
|
||||
/// <summary>
|
||||
/// Generates the 4-digit code string.
|
||||
/// </summary>
|
||||
public string ToCode() => $"{(int)Area}{Row}{(int)Position}{SlotNumber}";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Service for generating, parsing, and validating storage location codes.
|
||||
/// Location codes follow the ABCD format where:
|
||||
/// - A: Gebäude (1=Werkstatt, 2=Lager)
|
||||
/// - B: Reihe (Werkstatt: 1-3, Lager: 1-5)
|
||||
/// - C: Seite/Ebene (1=links oben, 2=links unten, 3=rechts oben, 4=rechts unten)
|
||||
/// - D: Platz (1-9)
|
||||
/// </summary>
|
||||
public interface ILocationCodeService
|
||||
{
|
||||
/// <summary>
|
||||
/// Generates a location code from component parts.
|
||||
/// </summary>
|
||||
/// <param name="area">Storage building</param>
|
||||
/// <param name="row">Row number (Werkstatt: 1-3, Lager: 1-5)</param>
|
||||
/// <param name="position">Shelf position (side/level)</param>
|
||||
/// <param name="slot">Slot number (1-9)</param>
|
||||
/// <returns>4-digit location code string</returns>
|
||||
string GenerateLocationCode(AreaType area, int row, ShelfPosition position, int slot);
|
||||
|
||||
/// <summary>
|
||||
/// Parses a location code string into its component parts.
|
||||
/// </summary>
|
||||
/// <param name="code">4-digit location code</param>
|
||||
/// <returns>Parsed location code components</returns>
|
||||
/// <exception cref="InvalidLocationCodeException">If code format is invalid</exception>
|
||||
ParsedLocationCode ParseLocationCode(string code);
|
||||
|
||||
/// <summary>
|
||||
/// Tries to parse a location code without throwing exceptions.
|
||||
/// </summary>
|
||||
bool TryParseLocationCode(string code, out ParsedLocationCode? result);
|
||||
|
||||
/// <summary>
|
||||
/// Validates a location code format.
|
||||
/// </summary>
|
||||
/// <param name="code">Code to validate</param>
|
||||
/// <returns>Validation result with details</returns>
|
||||
LocationCodeValidationResult ValidateLocationCode(string code);
|
||||
|
||||
/// <summary>
|
||||
/// Finds the next available (existing, unoccupied) location in a given area and row.
|
||||
/// </summary>
|
||||
/// <param name="area">Storage building</param>
|
||||
/// <param name="row">Row number</param>
|
||||
/// <returns>Parsed code of next free location, or null if row is full</returns>
|
||||
Task<ParsedLocationCode?> GetNextAvailableSlotAsync(AreaType area, int row);
|
||||
|
||||
/// <summary>
|
||||
/// Checks if a specific location code is available (exists and not occupied).
|
||||
/// </summary>
|
||||
Task<bool> IsLocationAvailableAsync(string code);
|
||||
|
||||
/// <summary>
|
||||
/// Checks if a location code already exists in the database.
|
||||
/// </summary>
|
||||
Task<bool> CodeExistsAsync(string code);
|
||||
|
||||
/// <summary>
|
||||
/// Gets all occupied slots in a specific area and row.
|
||||
/// </summary>
|
||||
Task<List<int>> GetOccupiedSlotsAsync(AreaType area, int row);
|
||||
|
||||
/// <summary>
|
||||
/// Gets all existing slot numbers in a specific area and row.
|
||||
/// </summary>
|
||||
Task<List<int>> GetExistingSlotsAsync(AreaType area, int row);
|
||||
|
||||
/// <summary>
|
||||
/// Formats a location code for display with human-readable text.
|
||||
/// </summary>
|
||||
string FormatForDisplay(string code);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public class LocationCodeService : ILocationCodeService
|
||||
{
|
||||
private readonly AppDbContext _context;
|
||||
private readonly ILogger<LocationCodeService> _logger;
|
||||
|
||||
private static readonly Regex CodeRegex = new(StorageLocation.LocationCodePattern, RegexOptions.Compiled);
|
||||
|
||||
public LocationCodeService(AppDbContext context, ILogger<LocationCodeService> logger)
|
||||
{
|
||||
_context = context;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string GenerateLocationCode(AreaType area, int row, ShelfPosition position, int slot)
|
||||
{
|
||||
var maxRow = area.GetMaxRow();
|
||||
if (row < StorageLocation.MinRow || row > maxRow)
|
||||
throw new ArgumentOutOfRangeException(nameof(row),
|
||||
$"Reihe muss für {area.GetDisplayName()} zwischen {StorageLocation.MinRow} und {maxRow} liegen.");
|
||||
|
||||
if (slot < StorageLocation.MinSlot || slot > StorageLocation.MaxSlot)
|
||||
throw new ArgumentOutOfRangeException(nameof(slot),
|
||||
$"Platz muss zwischen {StorageLocation.MinSlot} und {StorageLocation.MaxSlot} liegen.");
|
||||
|
||||
return $"{(int)area}{row}{(int)position}{slot}";
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public ParsedLocationCode ParseLocationCode(string code)
|
||||
{
|
||||
var validation = ValidateLocationCode(code);
|
||||
if (!validation.IsValid)
|
||||
throw new InvalidLocationCodeException(code, validation.ErrorMessage ?? "Ungültiges Format");
|
||||
|
||||
return new ParsedLocationCode(
|
||||
validation.Area!.Value,
|
||||
validation.Row!.Value,
|
||||
validation.Position!.Value,
|
||||
validation.SlotNumber!.Value
|
||||
);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool TryParseLocationCode(string code, out ParsedLocationCode? result)
|
||||
{
|
||||
result = null;
|
||||
var validation = ValidateLocationCode(code);
|
||||
if (!validation.IsValid)
|
||||
return false;
|
||||
|
||||
result = new ParsedLocationCode(
|
||||
validation.Area!.Value,
|
||||
validation.Row!.Value,
|
||||
validation.Position!.Value,
|
||||
validation.SlotNumber!.Value
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public LocationCodeValidationResult ValidateLocationCode(string code)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(code))
|
||||
return new LocationCodeValidationResult(false, "Lagerplatz-Code darf nicht leer sein.");
|
||||
|
||||
code = code.Trim();
|
||||
|
||||
if (code.Length != 4)
|
||||
return new LocationCodeValidationResult(false,
|
||||
$"Lagerplatz-Code muss genau 4 Zeichen lang sein (aktuell: {code.Length}).");
|
||||
|
||||
if (!CodeRegex.IsMatch(code))
|
||||
return new LocationCodeValidationResult(false,
|
||||
"Ungültiges Format. Erwartet: ABCD wobei A=Gebäude (1-2), B=Reihe, C=Seite/Ebene (1-4), D=Platz (1-9).");
|
||||
|
||||
// Parse area (position A)
|
||||
if (!AreaTypeExtensions.TryFromCode(code[0], out var area))
|
||||
return new LocationCodeValidationResult(false,
|
||||
$"Ungültiges Gebäude '{code[0]}'. Erlaubt: 1 (Werkstatt), 2 (Lager).");
|
||||
|
||||
// Parse row (position B) - max row depends on building
|
||||
if (!int.TryParse(code[1].ToString(), out var row))
|
||||
return new LocationCodeValidationResult(false,
|
||||
$"Ungültige Reihe '{code[1]}'.");
|
||||
|
||||
var maxRow = area.GetMaxRow();
|
||||
if (row < StorageLocation.MinRow || row > maxRow)
|
||||
return new LocationCodeValidationResult(false,
|
||||
$"Reihe {row} außerhalb des gültigen Bereichs für {area.GetDisplayName()} ({StorageLocation.MinRow}-{maxRow}).");
|
||||
|
||||
// Parse position (position C)
|
||||
if (!ShelfPositionExtensions.TryFromCode(code[2], out var position))
|
||||
return new LocationCodeValidationResult(false,
|
||||
$"Ungültige Seite/Ebene '{code[2]}'. Erlaubt: 1 (links oben), 2 (links unten), 3 (rechts oben), 4 (rechts unten).");
|
||||
|
||||
// Parse slot (position D)
|
||||
if (!int.TryParse(code[3].ToString(), out var slot) || slot < StorageLocation.MinSlot || slot > StorageLocation.MaxSlot)
|
||||
return new LocationCodeValidationResult(false,
|
||||
$"Ungültiger Platz '{code[3]}'. Erlaubt: {StorageLocation.MinSlot}-{StorageLocation.MaxSlot}.");
|
||||
|
||||
return new LocationCodeValidationResult(true, null, area, row, position, slot);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<ParsedLocationCode?> GetNextAvailableSlotAsync(AreaType area, int row)
|
||||
{
|
||||
var freeLocation = await _context.StorageLocations
|
||||
.AsNoTracking()
|
||||
.Where(l => l.Area == area && l.Row == row && l.IsActive && l.IsAvailable && !l.IsOccupied)
|
||||
.OrderBy(l => l.SlotNumber)
|
||||
.ThenBy(l => l.Position)
|
||||
.FirstOrDefaultAsync();
|
||||
|
||||
if (freeLocation == null)
|
||||
return null; // Row is full
|
||||
|
||||
return new ParsedLocationCode(freeLocation.Area, freeLocation.Row, freeLocation.Position, freeLocation.SlotNumber);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<bool> IsLocationAvailableAsync(string code)
|
||||
{
|
||||
var validation = ValidateLocationCode(code);
|
||||
if (!validation.IsValid)
|
||||
return false;
|
||||
|
||||
var location = await _context.StorageLocations
|
||||
.FirstOrDefaultAsync(l => l.Code == code && l.IsActive);
|
||||
|
||||
return location != null && !location.IsOccupied && location.IsAvailable;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<bool> CodeExistsAsync(string code)
|
||||
{
|
||||
return await _context.StorageLocations
|
||||
.AnyAsync(l => l.Code == code);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<List<int>> GetOccupiedSlotsAsync(AreaType area, int row)
|
||||
{
|
||||
return await _context.StorageLocations
|
||||
.Where(l => l.Area == area && l.Row == row && l.IsActive && l.IsOccupied)
|
||||
.Select(l => l.SlotNumber)
|
||||
.OrderBy(s => s)
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<List<int>> GetExistingSlotsAsync(AreaType area, int row)
|
||||
{
|
||||
return await _context.StorageLocations
|
||||
.Where(l => l.Area == area && l.Row == row && l.IsActive)
|
||||
.Select(l => l.SlotNumber)
|
||||
.OrderBy(s => s)
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string FormatForDisplay(string code)
|
||||
{
|
||||
if (!TryParseLocationCode(code, out var parsed) || parsed == null)
|
||||
return code;
|
||||
|
||||
return $"{parsed.Area.GetDisplayName()}, Reihe {parsed.Row}, {parsed.Position.GetDisplayName()}, Platz {parsed.SlotNumber}";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,452 @@
|
||||
using System.Text.Json;
|
||||
using Lagerbestand.Data;
|
||||
using Lagerbestand.Models;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Lagerbestand.Services;
|
||||
|
||||
public interface ISiteSettingsService
|
||||
{
|
||||
Task<PromoBannerSettings> GetPromoBannerSettingsAsync();
|
||||
Task SavePromoBannerSettingsAsync(PromoBannerSettings settings);
|
||||
Task<LexwareApiSettings> GetLexwareApiSettingsAsync();
|
||||
Task SaveLexwareApiSettingsAsync(LexwareApiSettings settings);
|
||||
Task<TwoFactorSettings> GetTwoFactorSettingsAsync();
|
||||
Task SaveTwoFactorSettingsAsync(TwoFactorSettings settings);
|
||||
Task<SmtpSettings> GetSmtpSettingsAsync();
|
||||
Task SaveSmtpSettingsAsync(SmtpSettings settings);
|
||||
Task<SessionSettings> GetSessionSettingsAsync();
|
||||
Task SaveSessionSettingsAsync(SessionSettings settings);
|
||||
|
||||
/// <summary>
|
||||
/// Gets a raw setting value by key
|
||||
/// </summary>
|
||||
Task<string?> GetValueAsync(string key);
|
||||
|
||||
/// <summary>
|
||||
/// Sets a raw setting value by key
|
||||
/// </summary>
|
||||
Task SetValueAsync(string key, string value);
|
||||
}
|
||||
|
||||
public class SiteSettingsService : ISiteSettingsService
|
||||
{
|
||||
private const string PromoBannerKey = "PromoBanner";
|
||||
private const string LexwareApiKey = "LexwareApi";
|
||||
private const string TwoFactorKey = "TwoFactor";
|
||||
private const string SmtpKey = "Smtp";
|
||||
private const string SessionKey = "Session";
|
||||
|
||||
private readonly AppDbContext _context;
|
||||
private readonly ILogger<SiteSettingsService> _logger;
|
||||
|
||||
public SiteSettingsService(AppDbContext context, ILogger<SiteSettingsService> logger)
|
||||
{
|
||||
_context = context;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<PromoBannerSettings> GetPromoBannerSettingsAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
// Quick timeout to prevent blocking app startup
|
||||
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
||||
|
||||
var setting = await _context.SiteSettings
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(s => s.Key == PromoBannerKey, cts.Token);
|
||||
|
||||
if (setting == null)
|
||||
{
|
||||
return new PromoBannerSettings();
|
||||
}
|
||||
|
||||
var settings = JsonSerializer.Deserialize<PromoBannerSettings>(setting.Value);
|
||||
return settings ?? new PromoBannerSettings();
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
_logger.LogDebug("Banner settings query timed out, returning default");
|
||||
return new PromoBannerSettings();
|
||||
}
|
||||
catch (MySqlConnector.MySqlException ex) when (ex.Number == 1146)
|
||||
{
|
||||
// Table doesn't exist yet - this is normal during first startup
|
||||
_logger.LogDebug("SiteSettings table not found, returning default settings");
|
||||
return new PromoBannerSettings();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to load promo banner settings");
|
||||
return new PromoBannerSettings();
|
||||
}
|
||||
}
|
||||
|
||||
public async Task SavePromoBannerSettingsAsync(PromoBannerSettings settings)
|
||||
{
|
||||
try
|
||||
{
|
||||
var existing = await _context.SiteSettings
|
||||
.FirstOrDefaultAsync(s => s.Key == PromoBannerKey);
|
||||
|
||||
var json = JsonSerializer.Serialize(settings);
|
||||
|
||||
if (existing != null)
|
||||
{
|
||||
existing.Value = json;
|
||||
existing.UpdatedAt = DateTime.UtcNow;
|
||||
_context.SiteSettings.Update(existing);
|
||||
}
|
||||
else
|
||||
{
|
||||
var newSetting = new SiteSettings
|
||||
{
|
||||
Key = PromoBannerKey,
|
||||
Value = json,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
};
|
||||
_context.SiteSettings.Add(newSetting);
|
||||
}
|
||||
|
||||
await _context.SaveChangesAsync();
|
||||
_logger.LogInformation("Promo banner settings saved successfully");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to save promo banner settings");
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<LexwareApiSettings> GetLexwareApiSettingsAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
||||
|
||||
var setting = await _context.SiteSettings
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(s => s.Key == LexwareApiKey, cts.Token);
|
||||
|
||||
if (setting == null)
|
||||
return new LexwareApiSettings();
|
||||
|
||||
var settings = JsonSerializer.Deserialize<LexwareApiSettings>(setting.Value);
|
||||
return settings ?? new LexwareApiSettings();
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
_logger.LogDebug("Lexware settings query timed out, returning default");
|
||||
return new LexwareApiSettings();
|
||||
}
|
||||
catch (MySqlConnector.MySqlException ex) when (ex.Number == 1146)
|
||||
{
|
||||
_logger.LogDebug("SiteSettings table not found, returning default Lexware settings");
|
||||
return new LexwareApiSettings();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to load Lexware API settings");
|
||||
return new LexwareApiSettings();
|
||||
}
|
||||
}
|
||||
|
||||
public async Task SaveLexwareApiSettingsAsync(LexwareApiSettings settings)
|
||||
{
|
||||
try
|
||||
{
|
||||
var existing = await _context.SiteSettings
|
||||
.FirstOrDefaultAsync(s => s.Key == LexwareApiKey);
|
||||
|
||||
var json = JsonSerializer.Serialize(settings);
|
||||
|
||||
if (existing != null)
|
||||
{
|
||||
existing.Value = json;
|
||||
existing.UpdatedAt = DateTime.UtcNow;
|
||||
_context.SiteSettings.Update(existing);
|
||||
}
|
||||
else
|
||||
{
|
||||
var newSetting = new SiteSettings
|
||||
{
|
||||
Key = LexwareApiKey,
|
||||
Value = json,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
};
|
||||
_context.SiteSettings.Add(newSetting);
|
||||
}
|
||||
|
||||
await _context.SaveChangesAsync();
|
||||
_logger.LogInformation("Lexware API settings saved successfully");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to save Lexware API settings");
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<TwoFactorSettings> GetTwoFactorSettingsAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
||||
var setting = await _context.SiteSettings
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(s => s.Key == TwoFactorKey, cts.Token);
|
||||
|
||||
if (setting == null)
|
||||
return new TwoFactorSettings();
|
||||
|
||||
return JsonSerializer.Deserialize<TwoFactorSettings>(setting.Value) ?? new TwoFactorSettings();
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
_logger.LogDebug("TwoFactor settings query timed out, returning default");
|
||||
return new TwoFactorSettings();
|
||||
}
|
||||
catch (MySqlConnector.MySqlException ex) when (ex.Number == 1146)
|
||||
{
|
||||
return new TwoFactorSettings();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to load TwoFactor settings");
|
||||
return new TwoFactorSettings();
|
||||
}
|
||||
}
|
||||
|
||||
public async Task SaveTwoFactorSettingsAsync(TwoFactorSettings settings)
|
||||
{
|
||||
try
|
||||
{
|
||||
var existing = await _context.SiteSettings
|
||||
.FirstOrDefaultAsync(s => s.Key == TwoFactorKey);
|
||||
|
||||
var json = JsonSerializer.Serialize(settings);
|
||||
|
||||
if (existing != null)
|
||||
{
|
||||
existing.Value = json;
|
||||
existing.UpdatedAt = DateTime.UtcNow;
|
||||
_context.SiteSettings.Update(existing);
|
||||
}
|
||||
else
|
||||
{
|
||||
_context.SiteSettings.Add(new SiteSettings
|
||||
{
|
||||
Key = TwoFactorKey,
|
||||
Value = json,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
});
|
||||
}
|
||||
|
||||
await _context.SaveChangesAsync();
|
||||
_logger.LogInformation("TwoFactor settings saved successfully");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to save TwoFactor settings");
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<SmtpSettings> GetSmtpSettingsAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
||||
var setting = await _context.SiteSettings
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(s => s.Key == SmtpKey, cts.Token);
|
||||
|
||||
if (setting == null)
|
||||
return new SmtpSettings();
|
||||
|
||||
return JsonSerializer.Deserialize<SmtpSettings>(setting.Value) ?? new SmtpSettings();
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
_logger.LogDebug("SMTP settings query timed out, returning default");
|
||||
return new SmtpSettings();
|
||||
}
|
||||
catch (MySqlConnector.MySqlException ex) when (ex.Number == 1146)
|
||||
{
|
||||
return new SmtpSettings();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to load SMTP settings");
|
||||
return new SmtpSettings();
|
||||
}
|
||||
}
|
||||
|
||||
public async Task SaveSmtpSettingsAsync(SmtpSettings settings)
|
||||
{
|
||||
try
|
||||
{
|
||||
var existing = await _context.SiteSettings
|
||||
.FirstOrDefaultAsync(s => s.Key == SmtpKey);
|
||||
|
||||
var json = JsonSerializer.Serialize(settings);
|
||||
|
||||
if (existing != null)
|
||||
{
|
||||
existing.Value = json;
|
||||
existing.UpdatedAt = DateTime.UtcNow;
|
||||
_context.SiteSettings.Update(existing);
|
||||
}
|
||||
else
|
||||
{
|
||||
_context.SiteSettings.Add(new SiteSettings
|
||||
{
|
||||
Key = SmtpKey,
|
||||
Value = json,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
});
|
||||
}
|
||||
|
||||
await _context.SaveChangesAsync();
|
||||
_logger.LogInformation("SMTP settings saved successfully");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to save SMTP settings");
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<SessionSettings> GetSessionSettingsAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
||||
var setting = await _context.SiteSettings
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(s => s.Key == SessionKey, cts.Token);
|
||||
|
||||
if (setting == null)
|
||||
return new SessionSettings();
|
||||
|
||||
return JsonSerializer.Deserialize<SessionSettings>(setting.Value) ?? new SessionSettings();
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
_logger.LogDebug("Session settings query timed out, returning default");
|
||||
return new SessionSettings();
|
||||
}
|
||||
catch (MySqlConnector.MySqlException ex) when (ex.Number == 1146)
|
||||
{
|
||||
return new SessionSettings();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to load Session settings");
|
||||
return new SessionSettings();
|
||||
}
|
||||
}
|
||||
|
||||
public async Task SaveSessionSettingsAsync(SessionSettings settings)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Clamp to safe ranges before persisting
|
||||
settings.IdleTimeoutMinutes = settings.GetSafeIdleTimeoutMinutes();
|
||||
settings.CircuitRetentionHours = settings.GetSafeCircuitRetentionHours();
|
||||
|
||||
var existing = await _context.SiteSettings
|
||||
.FirstOrDefaultAsync(s => s.Key == SessionKey);
|
||||
|
||||
var json = JsonSerializer.Serialize(settings);
|
||||
|
||||
if (existing != null)
|
||||
{
|
||||
existing.Value = json;
|
||||
existing.UpdatedAt = DateTime.UtcNow;
|
||||
_context.SiteSettings.Update(existing);
|
||||
}
|
||||
else
|
||||
{
|
||||
_context.SiteSettings.Add(new SiteSettings
|
||||
{
|
||||
Key = SessionKey,
|
||||
Value = json,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
});
|
||||
}
|
||||
|
||||
await _context.SaveChangesAsync();
|
||||
_logger.LogInformation("Session settings saved successfully");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to save Session settings");
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<string?> GetValueAsync(string key)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
||||
|
||||
var setting = await _context.SiteSettings
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(s => s.Key == key, cts.Token);
|
||||
|
||||
return setting?.Value;
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
_logger.LogDebug("Settings query for '{Key}' timed out", key);
|
||||
return null;
|
||||
}
|
||||
catch (MySqlConnector.MySqlException ex) when (ex.Number == 1146)
|
||||
{
|
||||
_logger.LogDebug("SiteSettings table not found for key '{Key}'", key);
|
||||
return null;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to load setting '{Key}'", key);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task SetValueAsync(string key, string value)
|
||||
{
|
||||
try
|
||||
{
|
||||
var existing = await _context.SiteSettings
|
||||
.FirstOrDefaultAsync(s => s.Key == key);
|
||||
|
||||
if (existing != null)
|
||||
{
|
||||
existing.Value = value;
|
||||
existing.UpdatedAt = DateTime.UtcNow;
|
||||
_context.SiteSettings.Update(existing);
|
||||
}
|
||||
else
|
||||
{
|
||||
var newSetting = new SiteSettings
|
||||
{
|
||||
Key = key,
|
||||
Value = value,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
};
|
||||
_context.SiteSettings.Add(newSetting);
|
||||
}
|
||||
|
||||
await _context.SaveChangesAsync();
|
||||
_logger.LogInformation("Setting '{Key}' saved successfully", key);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to save setting '{Key}'", key);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,447 @@
|
||||
using Lagerbestand.Data;
|
||||
using Lagerbestand.Exceptions;
|
||||
using Lagerbestand.Models;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Lagerbestand.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Statistics about storage location occupancy.
|
||||
/// </summary>
|
||||
public record OccupancyStatistics(
|
||||
int TotalLocations,
|
||||
int OccupiedLocations,
|
||||
int AvailableLocations,
|
||||
int BlockedLocations,
|
||||
int TotalByLager,
|
||||
int OccupiedByLager,
|
||||
int TotalByWerkstatt,
|
||||
int OccupiedByWerkstatt,
|
||||
Dictionary<int, int> LocationsByRow,
|
||||
Dictionary<int, int> OccupiedByRow
|
||||
);
|
||||
|
||||
public interface IStorageLocationService
|
||||
{
|
||||
Task<List<StorageLocation>> GetAllAsync(bool includeInactive = false);
|
||||
Task<StorageLocation?> GetByIdAsync(Guid id);
|
||||
Task<StorageLocation?> GetByCodeAsync(string code);
|
||||
Task<List<StorageLocation>> GetAvailableAsync();
|
||||
Task<List<StorageLocation>> GetByAreaAsync(AreaType area);
|
||||
Task<List<StorageLocation>> GetByRowAsync(AreaType area, int row);
|
||||
Task<StorageLocation> CreateAsync(StorageLocation location);
|
||||
Task<StorageLocation> UpdateAsync(StorageLocation location);
|
||||
Task<bool> DeleteAsync(Guid id);
|
||||
Task<List<AreaType>> GetAreasAsync();
|
||||
Task<List<int>> GetRowsAsync(AreaType area);
|
||||
Task<bool> IsLocationAvailableAsync(Guid locationId);
|
||||
Task<int> GetOccupancyAsync(Guid locationId);
|
||||
Task AssignTireStackAsync(string locationCode, Guid tireStackId);
|
||||
Task ClearLocationAsync(string locationCode);
|
||||
Task MoveStackAsync(string fromCode, string toCode);
|
||||
Task<OccupancyStatistics> GetOccupancyStatisticsAsync();
|
||||
Task<List<StorageLocation>> CreateBatchAsync(AreaType area, int row, int fromSlot, int toSlot);
|
||||
|
||||
/// <summary>
|
||||
/// Ensures all default storage locations exist (Werkstatt: 3 rows, Lager: 5 rows,
|
||||
/// each row 9 slots × 4 positions = 288 total). Creates missing, returns number created.
|
||||
/// </summary>
|
||||
Task<int> SeedDefaultLocationsAsync();
|
||||
}
|
||||
|
||||
public class StorageLocationService : IStorageLocationService
|
||||
{
|
||||
private readonly AppDbContext _context;
|
||||
private readonly ILocationCodeService _locationCodeService;
|
||||
private readonly ILogger<StorageLocationService> _logger;
|
||||
|
||||
public StorageLocationService(
|
||||
AppDbContext context,
|
||||
ILocationCodeService locationCodeService,
|
||||
ILogger<StorageLocationService> logger)
|
||||
{
|
||||
_context = context;
|
||||
_locationCodeService = locationCodeService;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<List<StorageLocation>> GetAllAsync(bool includeInactive = false)
|
||||
{
|
||||
var query = _context.StorageLocations
|
||||
.Include(s => s.OccupiedTireStack)
|
||||
.ThenInclude(ts => ts!.Customer)
|
||||
.Include(s => s.OccupiedTireStack)
|
||||
.ThenInclude(ts => ts!.CatalogItem)
|
||||
.AsQueryable();
|
||||
|
||||
if (!includeInactive)
|
||||
query = query.Where(s => s.IsActive);
|
||||
|
||||
return await query
|
||||
.OrderBy(s => s.Area)
|
||||
.ThenBy(s => s.Row)
|
||||
.ThenBy(s => s.SlotNumber)
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
public async Task<StorageLocation?> GetByIdAsync(Guid id)
|
||||
{
|
||||
return await _context.StorageLocations
|
||||
.Include(s => s.OccupiedTireStack)
|
||||
.ThenInclude(ts => ts!.Customer)
|
||||
.Include(s => s.OccupiedTireStack)
|
||||
.ThenInclude(ts => ts!.CatalogItem)
|
||||
.FirstOrDefaultAsync(s => s.Id == id);
|
||||
}
|
||||
|
||||
public async Task<StorageLocation?> GetByCodeAsync(string code)
|
||||
{
|
||||
return await _context.StorageLocations
|
||||
.Include(s => s.OccupiedTireStack)
|
||||
.ThenInclude(ts => ts!.Customer)
|
||||
.Include(s => s.OccupiedTireStack)
|
||||
.ThenInclude(ts => ts!.CatalogItem)
|
||||
.FirstOrDefaultAsync(s => s.Code == code);
|
||||
}
|
||||
|
||||
public async Task<List<StorageLocation>> GetAvailableAsync()
|
||||
{
|
||||
return await _context.StorageLocations
|
||||
.AsNoTracking()
|
||||
.Where(s => s.IsActive && s.IsAvailable && !s.IsOccupied)
|
||||
.OrderBy(s => s.Area)
|
||||
.ThenBy(s => s.Row)
|
||||
.ThenBy(s => s.SlotNumber)
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
public async Task<List<StorageLocation>> GetByAreaAsync(AreaType area)
|
||||
{
|
||||
return await _context.StorageLocations
|
||||
.Include(s => s.OccupiedTireStack)
|
||||
.ThenInclude(ts => ts!.Customer)
|
||||
.Where(s => s.IsActive && s.Area == area)
|
||||
.OrderBy(s => s.Row)
|
||||
.ThenBy(s => s.SlotNumber)
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
public async Task<List<StorageLocation>> GetByRowAsync(AreaType area, int row)
|
||||
{
|
||||
return await _context.StorageLocations
|
||||
.Include(s => s.OccupiedTireStack)
|
||||
.ThenInclude(ts => ts!.Customer)
|
||||
.Where(s => s.IsActive && s.Area == area && s.Row == row)
|
||||
.OrderBy(s => s.SlotNumber)
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
public async Task<StorageLocation> CreateAsync(StorageLocation location)
|
||||
{
|
||||
// Ensure code is generated from components
|
||||
location.GenerateCode();
|
||||
|
||||
// Validate the generated code
|
||||
var validation = _locationCodeService.ValidateLocationCode(location.Code);
|
||||
if (!validation.IsValid)
|
||||
throw new InvalidLocationCodeException(location.Code, validation.ErrorMessage!);
|
||||
|
||||
// Check for duplicate code
|
||||
if (await _locationCodeService.CodeExistsAsync(location.Code))
|
||||
throw new LocationCodeExistsException(location.Code);
|
||||
|
||||
location.CreatedAt = DateTime.UtcNow;
|
||||
|
||||
_context.StorageLocations.Add(location);
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
_logger.LogInformation("Storage location created: {Code} ({Display})",
|
||||
location.Code, location.DisplayName);
|
||||
|
||||
return location;
|
||||
}
|
||||
|
||||
public async Task<StorageLocation> UpdateAsync(StorageLocation location)
|
||||
{
|
||||
// Regenerate code from components
|
||||
location.GenerateCode();
|
||||
|
||||
// Validate the code
|
||||
var validation = _locationCodeService.ValidateLocationCode(location.Code);
|
||||
if (!validation.IsValid)
|
||||
throw new InvalidLocationCodeException(location.Code, validation.ErrorMessage!);
|
||||
|
||||
// Check for duplicate code (excluding this location)
|
||||
var existingWithCode = await _context.StorageLocations
|
||||
.FirstOrDefaultAsync(l => l.Code == location.Code && l.Id != location.Id);
|
||||
if (existingWithCode != null)
|
||||
throw new LocationCodeExistsException(location.Code);
|
||||
|
||||
location.UpdatedAt = DateTime.UtcNow;
|
||||
_context.StorageLocations.Update(location);
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
_logger.LogInformation("Storage location updated: {Code}", location.Code);
|
||||
|
||||
return location;
|
||||
}
|
||||
|
||||
public async Task<bool> DeleteAsync(Guid id)
|
||||
{
|
||||
var location = await _context.StorageLocations
|
||||
.FirstOrDefaultAsync(s => s.Id == id);
|
||||
|
||||
if (location == null)
|
||||
return false;
|
||||
|
||||
// Cannot delete occupied location
|
||||
if (location.IsOccupied)
|
||||
{
|
||||
_logger.LogWarning("Cannot delete storage location {Code} - is occupied", location.Code);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Soft delete
|
||||
location.IsActive = false;
|
||||
location.UpdatedAt = DateTime.UtcNow;
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
_logger.LogInformation("Storage location deactivated: {Code}", location.Code);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public async Task<List<AreaType>> GetAreasAsync()
|
||||
{
|
||||
var areas = await _context.StorageLocations
|
||||
.Where(s => s.IsActive)
|
||||
.Select(s => s.Area)
|
||||
.Distinct()
|
||||
.ToListAsync();
|
||||
|
||||
return areas.OrderBy(a => (int)a).ToList();
|
||||
}
|
||||
|
||||
public async Task<List<int>> GetRowsAsync(AreaType area)
|
||||
{
|
||||
return await _context.StorageLocations
|
||||
.Where(s => s.IsActive && s.Area == area)
|
||||
.Select(s => s.Row)
|
||||
.Distinct()
|
||||
.OrderBy(r => r)
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
public async Task<bool> IsLocationAvailableAsync(Guid locationId)
|
||||
{
|
||||
var location = await _context.StorageLocations
|
||||
.FirstOrDefaultAsync(s => s.Id == locationId);
|
||||
|
||||
if (location == null || !location.IsActive || !location.IsAvailable)
|
||||
return false;
|
||||
|
||||
return !location.IsOccupied;
|
||||
}
|
||||
|
||||
public async Task<int> GetOccupancyAsync(Guid locationId)
|
||||
{
|
||||
var location = await _context.StorageLocations
|
||||
.FirstOrDefaultAsync(s => s.Id == locationId);
|
||||
|
||||
return location?.IsOccupied == true ? 1 : 0;
|
||||
}
|
||||
|
||||
public async Task AssignTireStackAsync(string locationCode, Guid tireStackId)
|
||||
{
|
||||
var location = await GetByCodeAsync(locationCode)
|
||||
?? throw new LocationNotFoundException(locationCode);
|
||||
|
||||
if (location.IsOccupied)
|
||||
throw new LocationOccupiedException(locationCode, location.TireStackId!.Value);
|
||||
|
||||
if (!location.IsAvailable)
|
||||
throw new InvalidOperationException($"Lagerplatz {locationCode} ist gesperrt.");
|
||||
|
||||
location.AssignTireStack(tireStackId);
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
_logger.LogInformation("Tire stack {TireStackId} assigned to location {Code}",
|
||||
tireStackId, locationCode);
|
||||
}
|
||||
|
||||
public async Task ClearLocationAsync(string locationCode)
|
||||
{
|
||||
var location = await GetByCodeAsync(locationCode)
|
||||
?? throw new LocationNotFoundException(locationCode);
|
||||
|
||||
var previousTireStackId = location.TireStackId;
|
||||
location.ClearLocation();
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
_logger.LogInformation("Location {Code} cleared (previous tire stack: {TireStackId})",
|
||||
locationCode, previousTireStackId);
|
||||
}
|
||||
|
||||
public async Task MoveStackAsync(string fromCode, string toCode)
|
||||
{
|
||||
var fromLocation = await GetByCodeAsync(fromCode)
|
||||
?? throw new LocationNotFoundException(fromCode);
|
||||
|
||||
var toLocation = await GetByCodeAsync(toCode)
|
||||
?? throw new LocationNotFoundException(toCode);
|
||||
|
||||
if (!fromLocation.IsOccupied)
|
||||
throw new InvalidOperationException($"Lagerplatz {fromCode} ist nicht belegt.");
|
||||
|
||||
if (toLocation.IsOccupied)
|
||||
throw new LocationOccupiedException(toCode, toLocation.TireStackId!.Value);
|
||||
|
||||
if (!toLocation.IsAvailable)
|
||||
throw new InvalidOperationException($"Lagerplatz {toCode} ist gesperrt.");
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
// Clear source and assign to destination
|
||||
fromLocation.ClearLocation();
|
||||
toLocation.AssignTireStack(tireStackId);
|
||||
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
_logger.LogInformation("Tire stack {TireStackId} moved from {FromCode} to {ToCode}",
|
||||
tireStackId, fromCode, toCode);
|
||||
}
|
||||
|
||||
public async Task<OccupancyStatistics> GetOccupancyStatisticsAsync()
|
||||
{
|
||||
var locations = await _context.StorageLocations
|
||||
.Where(l => l.IsActive)
|
||||
.ToListAsync();
|
||||
|
||||
var locationsByRow = locations
|
||||
.GroupBy(l => l.Row)
|
||||
.ToDictionary(g => g.Key, g => g.Count());
|
||||
|
||||
var occupiedByRow = locations
|
||||
.Where(l => l.IsOccupied)
|
||||
.GroupBy(l => l.Row)
|
||||
.ToDictionary(g => g.Key, g => g.Count());
|
||||
|
||||
return new OccupancyStatistics(
|
||||
TotalLocations: locations.Count,
|
||||
OccupiedLocations: locations.Count(l => l.IsOccupied),
|
||||
AvailableLocations: locations.Count(l => !l.IsOccupied && l.IsAvailable),
|
||||
BlockedLocations: locations.Count(l => !l.IsAvailable),
|
||||
TotalByLager: locations.Count(l => l.Area == AreaType.Lager),
|
||||
OccupiedByLager: locations.Count(l => l.Area == AreaType.Lager && l.IsOccupied),
|
||||
TotalByWerkstatt: locations.Count(l => l.Area == AreaType.Werkstatt),
|
||||
OccupiedByWerkstatt: locations.Count(l => l.Area == AreaType.Werkstatt && l.IsOccupied),
|
||||
LocationsByRow: locationsByRow,
|
||||
OccupiedByRow: occupiedByRow
|
||||
);
|
||||
}
|
||||
|
||||
public async Task<List<StorageLocation>> CreateBatchAsync(AreaType area, int row, int fromSlot, int toSlot)
|
||||
{
|
||||
var maxRow = area.GetMaxRow();
|
||||
if (row < StorageLocation.MinRow || row > maxRow)
|
||||
throw new ArgumentOutOfRangeException(nameof(row),
|
||||
$"Reihe muss für {area.GetDisplayName()} zwischen {StorageLocation.MinRow} und {maxRow} liegen.");
|
||||
|
||||
if (fromSlot < StorageLocation.MinSlot || toSlot > StorageLocation.MaxSlot || fromSlot > toSlot)
|
||||
throw new ArgumentOutOfRangeException(nameof(fromSlot),
|
||||
$"Plätze müssen zwischen {StorageLocation.MinSlot} und {StorageLocation.MaxSlot} liegen.");
|
||||
|
||||
var existingCodes = await _context.StorageLocations
|
||||
.Where(l => l.Area == area && l.Row == row)
|
||||
.Select(l => l.Code)
|
||||
.ToListAsync();
|
||||
var existingSet = existingCodes.ToHashSet();
|
||||
|
||||
var createdLocations = new List<StorageLocation>();
|
||||
|
||||
for (int slot = fromSlot; slot <= toSlot; slot++)
|
||||
{
|
||||
foreach (var position in ShelfPositionExtensions.All)
|
||||
{
|
||||
var location = new StorageLocation
|
||||
{
|
||||
Area = area,
|
||||
Row = row,
|
||||
Position = position,
|
||||
SlotNumber = slot
|
||||
};
|
||||
location.GenerateCode();
|
||||
|
||||
if (existingSet.Contains(location.Code))
|
||||
{
|
||||
_logger.LogDebug("Skipping existing location: {Code}", location.Code);
|
||||
continue;
|
||||
}
|
||||
|
||||
_context.StorageLocations.Add(location);
|
||||
createdLocations.Add(location);
|
||||
}
|
||||
}
|
||||
|
||||
if (createdLocations.Count > 0)
|
||||
{
|
||||
await _context.SaveChangesAsync();
|
||||
_logger.LogInformation("Created {Count} storage locations in {Area} Row {Row}",
|
||||
createdLocations.Count, area.GetDisplayName(), row);
|
||||
}
|
||||
|
||||
return createdLocations;
|
||||
}
|
||||
|
||||
public async Task<int> SeedDefaultLocationsAsync()
|
||||
{
|
||||
var existingCodes = (await _context.StorageLocations
|
||||
.Select(l => l.Code)
|
||||
.ToListAsync()).ToHashSet();
|
||||
|
||||
var created = 0;
|
||||
|
||||
foreach (var area in new[] { AreaType.Werkstatt, AreaType.Lager })
|
||||
{
|
||||
for (int row = StorageLocation.MinRow; row <= area.GetMaxRow(); row++)
|
||||
{
|
||||
foreach (var position in ShelfPositionExtensions.All)
|
||||
{
|
||||
for (int slot = StorageLocation.MinSlot; slot <= StorageLocation.MaxSlot; slot++)
|
||||
{
|
||||
var location = new StorageLocation
|
||||
{
|
||||
Area = area,
|
||||
Row = row,
|
||||
Position = position,
|
||||
SlotNumber = slot
|
||||
};
|
||||
location.GenerateCode();
|
||||
|
||||
if (existingCodes.Contains(location.Code))
|
||||
continue;
|
||||
|
||||
_context.StorageLocations.Add(location);
|
||||
created++;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (created > 0)
|
||||
{
|
||||
await _context.SaveChangesAsync();
|
||||
_logger.LogInformation("Seeded {Count} default storage locations", created);
|
||||
}
|
||||
|
||||
return created;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,504 @@
|
||||
using Lagerbestand.Data;
|
||||
using Lagerbestand.Models;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Lagerbestand.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Service interface for managing tire stacks
|
||||
/// </summary>
|
||||
public interface ITireStackService
|
||||
{
|
||||
Task<List<TireStack>> GetAllAsync(TireStackStatus? status = null);
|
||||
Task<List<TireStack>> GetAllAsync(TireStackStatus? status, bool? isInventory);
|
||||
Task<TireStack?> GetByIdAsync(Guid id);
|
||||
Task<TireStack?> GetByTicketNumberAsync(string ticketNumber);
|
||||
Task<List<TireStack>> GetByCustomerAsync(Guid customerId, TireStackStatus? status = null);
|
||||
Task<List<TireStack>> GetByCategoryAsync(Guid catalogItemId, TireStackStatus? status = null);
|
||||
Task<List<TireStack>> GetByStorageLocationAsync(Guid locationId);
|
||||
Task<List<TireStack>> SearchAsync(string searchTerm);
|
||||
Task<TireStack> CreateAsync(TireStack tireStack);
|
||||
Task<TireStack> UpdateAsync(TireStack tireStack);
|
||||
Task<TireStack> StoreAsync(TireStack tireStack, Guid storageLocationId, Guid? storedByUserId = null);
|
||||
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<bool> DeleteAsync(Guid id);
|
||||
Task<string> GenerateTicketNumberAsync(bool isInventory = false);
|
||||
Task<StorageStatistics> GetStatisticsAsync();
|
||||
Task<List<TireStack>> GetExpiringPickupsAsync(int daysAhead = 14);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Statistics for the tire storage system
|
||||
/// </summary>
|
||||
public class StorageStatistics
|
||||
{
|
||||
/// <summary>
|
||||
/// Total number of stacks currently stored
|
||||
/// </summary>
|
||||
public int TotalStacksStored { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Total number of individual tires stored (stacks * 4)
|
||||
/// </summary>
|
||||
public int TotalTiresStored => TotalStacksStored * TireStack.TiresPerStack;
|
||||
|
||||
/// <summary>
|
||||
/// Total stacks picked up (historical)
|
||||
/// </summary>
|
||||
public int TotalStacksPickedUp { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Number of active customers
|
||||
/// </summary>
|
||||
public int TotalCustomers { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Number of available storage locations
|
||||
/// </summary>
|
||||
public int AvailableLocations { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Number of occupied storage locations
|
||||
/// </summary>
|
||||
public int OccupiedLocations { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Summer tire stacks stored
|
||||
/// </summary>
|
||||
public int SummerStacksStored { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Winter tire stacks stored
|
||||
/// </summary>
|
||||
public int WinterStacksStored { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// All-season tire stacks stored
|
||||
/// </summary>
|
||||
public int AllSeasonStacksStored { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Inventory stacks (shop stock)
|
||||
/// </summary>
|
||||
public int InventoryStackCount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Customer tire stacks
|
||||
/// </summary>
|
||||
public int CustomerStackCount { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Service implementation for managing tire stacks
|
||||
/// </summary>
|
||||
public class TireStackService : ITireStackService
|
||||
{
|
||||
private readonly AppDbContext _context;
|
||||
private readonly ILogger<TireStackService> _logger;
|
||||
|
||||
public TireStackService(AppDbContext context, ILogger<TireStackService> logger)
|
||||
{
|
||||
_context = context;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<List<TireStack>> GetAllAsync(TireStackStatus? status = null)
|
||||
{
|
||||
return await GetAllAsync(status, null);
|
||||
}
|
||||
|
||||
public async Task<List<TireStack>> GetAllAsync(TireStackStatus? status, bool? isInventory)
|
||||
{
|
||||
var query = _context.TireStacks
|
||||
.Include(ts => ts.Customer)
|
||||
.Include(ts => ts.Vehicle)
|
||||
.Include(ts => ts.StorageLocation)
|
||||
.Include(ts => ts.CatalogItem)
|
||||
.Where(ts => ts.IsActive);
|
||||
|
||||
if (status.HasValue)
|
||||
query = query.Where(ts => ts.Status == status.Value);
|
||||
|
||||
if (isInventory.HasValue)
|
||||
{
|
||||
if (isInventory.Value)
|
||||
query = query.Where(ts => ts.CustomerId == null);
|
||||
else
|
||||
query = query.Where(ts => ts.CustomerId != null);
|
||||
}
|
||||
|
||||
return await query
|
||||
.OrderByDescending(ts => ts.StoredAt)
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
public async Task<TireStack?> GetByIdAsync(Guid id)
|
||||
{
|
||||
return await _context.TireStacks
|
||||
.Include(ts => ts.Customer)
|
||||
.Include(ts => ts.Vehicle)
|
||||
.Include(ts => ts.StorageLocation)
|
||||
.Include(ts => ts.CatalogItem)
|
||||
.FirstOrDefaultAsync(ts => ts.Id == id && ts.IsActive);
|
||||
}
|
||||
|
||||
public async Task<TireStack?> GetByTicketNumberAsync(string ticketNumber)
|
||||
{
|
||||
return await _context.TireStacks
|
||||
.Include(ts => ts.Customer)
|
||||
.Include(ts => ts.Vehicle)
|
||||
.Include(ts => ts.StorageLocation)
|
||||
.Include(ts => ts.CatalogItem)
|
||||
.FirstOrDefaultAsync(ts => ts.TicketNumber == ticketNumber && ts.IsActive);
|
||||
}
|
||||
|
||||
public async Task<List<TireStack>> GetByCustomerAsync(Guid customerId, TireStackStatus? status = null)
|
||||
{
|
||||
var query = _context.TireStacks
|
||||
.Include(ts => ts.StorageLocation)
|
||||
.Include(ts => ts.Vehicle)
|
||||
.Include(ts => ts.CatalogItem)
|
||||
.Where(ts => ts.CustomerId == customerId && ts.IsActive);
|
||||
|
||||
if (status.HasValue)
|
||||
query = query.Where(ts => ts.Status == status.Value);
|
||||
|
||||
return await query
|
||||
.OrderByDescending(ts => ts.StoredAt)
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
public async Task<List<TireStack>> GetByCategoryAsync(Guid catalogItemId, TireStackStatus? status = null)
|
||||
{
|
||||
var query = _context.TireStacks
|
||||
.Include(ts => ts.Customer)
|
||||
.Include(ts => ts.Vehicle)
|
||||
.Include(ts => ts.StorageLocation)
|
||||
.Include(ts => ts.CatalogItem)
|
||||
.Where(ts => ts.CatalogItemId == catalogItemId && ts.IsActive);
|
||||
|
||||
if (status.HasValue)
|
||||
query = query.Where(ts => ts.Status == status.Value);
|
||||
|
||||
return await query
|
||||
.OrderByDescending(ts => ts.StoredAt)
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
public async Task<List<TireStack>> GetByStorageLocationAsync(Guid locationId)
|
||||
{
|
||||
return await _context.TireStacks
|
||||
.Include(ts => ts.Customer)
|
||||
.Include(ts => ts.Vehicle)
|
||||
.Include(ts => ts.CatalogItem)
|
||||
.Where(ts => ts.StorageLocationId == locationId && ts.Status == TireStackStatus.Stored && ts.IsActive)
|
||||
.OrderBy(ts => ts.StoredAt)
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
public async Task<List<TireStack>> SearchAsync(string searchTerm)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(searchTerm))
|
||||
return await GetAllAsync(TireStackStatus.Stored);
|
||||
|
||||
var term = searchTerm.Trim().ToLower();
|
||||
|
||||
return await _context.TireStacks
|
||||
.AsNoTracking()
|
||||
.Include(ts => ts.Customer)
|
||||
.Include(ts => ts.Vehicle)
|
||||
.Include(ts => ts.StorageLocation)
|
||||
.Include(ts => ts.CatalogItem)
|
||||
.Where(ts => ts.IsActive && ts.Status == TireStackStatus.Stored && (
|
||||
ts.TicketNumber.ToLower().Contains(term) ||
|
||||
(ts.TireBrand != null && ts.TireBrand.ToLower().Contains(term)) ||
|
||||
(ts.TireSize != null && ts.TireSize.ToLower().Contains(term)) ||
|
||||
(ts.Customer != null && ts.Customer.FirstName != null && ts.Customer.FirstName.ToLower().Contains(term)) ||
|
||||
(ts.Customer != null && ts.Customer.LastName.ToLower().Contains(term)) ||
|
||||
(ts.Customer != null && ts.Customer.CustomerNumber.ToLower().Contains(term)) ||
|
||||
(ts.Vehicle != null && ts.Vehicle.Make.ToLower().Contains(term)) ||
|
||||
(ts.Vehicle != null && ts.Vehicle.Model != null && ts.Vehicle.Model.ToLower().Contains(term)) ||
|
||||
(ts.StorageLocation != null && ts.StorageLocation.Code.ToLower().Contains(term)) ||
|
||||
(ts.CatalogItem != null && ts.CatalogItem.Name.ToLower().Contains(term))
|
||||
))
|
||||
.OrderByDescending(ts => ts.StoredAt)
|
||||
.Take(50)
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
public async Task<TireStack> CreateAsync(TireStack tireStack)
|
||||
{
|
||||
var isInventory = tireStack.CustomerId == null;
|
||||
|
||||
if (string.IsNullOrEmpty(tireStack.TicketNumber))
|
||||
tireStack.TicketNumber = await GenerateTicketNumberAsync(isInventory);
|
||||
|
||||
tireStack.CreatedAt = DateTime.UtcNow;
|
||||
tireStack.StoredAt = DateTime.UtcNow;
|
||||
|
||||
_context.TireStacks.Add(tireStack);
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
_logger.LogInformation("Tire stack created: {TicketNumber}, IsInventory: {IsInventory}",
|
||||
tireStack.TicketNumber, isInventory);
|
||||
|
||||
return tireStack;
|
||||
}
|
||||
|
||||
public async Task<TireStack> UpdateAsync(TireStack tireStack)
|
||||
{
|
||||
tireStack.UpdatedAt = DateTime.UtcNow;
|
||||
_context.TireStacks.Update(tireStack);
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
_logger.LogInformation("Tire stack updated: {TicketNumber}", tireStack.TicketNumber);
|
||||
|
||||
return tireStack;
|
||||
}
|
||||
|
||||
public async Task<TireStack> StoreAsync(TireStack tireStack, Guid storageLocationId, Guid? storedByUserId = null)
|
||||
{
|
||||
// Verify storage location is available (force fresh values in case a
|
||||
// stale tracked instance exists in this long-lived scoped context)
|
||||
var location = await _context.StorageLocations
|
||||
.FirstOrDefaultAsync(s => s.Id == storageLocationId);
|
||||
|
||||
if (location == null)
|
||||
throw new InvalidOperationException("Lagerplatz nicht gefunden.");
|
||||
|
||||
await _context.Entry(location).ReloadAsync();
|
||||
|
||||
if (!location.IsActive || !location.IsAvailable)
|
||||
throw new InvalidOperationException("Lagerplatz ist nicht verfügbar.");
|
||||
|
||||
// AssignTireStack handles the IsOccupied check and sets TireStackId/IsOccupied/OccupiedSince
|
||||
location.AssignTireStack(tireStack.Id);
|
||||
|
||||
tireStack.StorageLocationId = storageLocationId;
|
||||
tireStack.Status = TireStackStatus.Stored;
|
||||
tireStack.StoredAt = DateTime.UtcNow;
|
||||
tireStack.StoredByUserId = storedByUserId;
|
||||
tireStack.UpdatedAt = DateTime.UtcNow;
|
||||
|
||||
_context.TireStacks.Update(tireStack);
|
||||
try
|
||||
{
|
||||
await _context.SaveChangesAsync();
|
||||
}
|
||||
catch (DbUpdateConcurrencyException)
|
||||
{
|
||||
throw new InvalidOperationException($"Lagerplatz {location.Code} wurde soeben anderweitig belegt.");
|
||||
}
|
||||
|
||||
_logger.LogInformation("Tire stack {TicketNumber} stored at location {LocationCode}",
|
||||
tireStack.TicketNumber, location.Code);
|
||||
|
||||
return tireStack;
|
||||
}
|
||||
|
||||
public async Task<TireStack> PickupAsync(Guid tireStackId, Guid? pickedUpByUserId = null)
|
||||
{
|
||||
var tireStack = await _context.TireStacks
|
||||
.Include(ts => ts.Customer)
|
||||
.Include(ts => ts.StorageLocation)
|
||||
.FirstOrDefaultAsync(ts => ts.Id == tireStackId);
|
||||
|
||||
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.");
|
||||
|
||||
// Clear the storage location (both sides)
|
||||
if (tireStack.StorageLocation != null)
|
||||
tireStack.StorageLocation.ClearLocation();
|
||||
|
||||
tireStack.Status = TireStackStatus.PickedUp;
|
||||
tireStack.PickedUpAt = DateTime.UtcNow;
|
||||
tireStack.PickedUpByUserId = pickedUpByUserId;
|
||||
tireStack.StorageLocationId = null;
|
||||
tireStack.UpdatedAt = DateTime.UtcNow;
|
||||
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
_logger.LogInformation("Tire stack {TicketNumber} picked up", tireStack.TicketNumber);
|
||||
|
||||
return tireStack;
|
||||
}
|
||||
|
||||
public async Task<TireStack> CreateAndStoreAsync(TireStack tireStack, Guid storageLocationId, Guid? storedByUserId = null)
|
||||
{
|
||||
// MySqlRetryingExecutionStrategy requires user transactions to run
|
||||
// inside the execution strategy as a single retriable unit.
|
||||
var hadTicketNumber = !string.IsNullOrEmpty(tireStack.TicketNumber);
|
||||
var strategy = _context.Database.CreateExecutionStrategy();
|
||||
try
|
||||
{
|
||||
return await strategy.ExecuteAsync(async () =>
|
||||
{
|
||||
await using var transaction = await _context.Database.BeginTransactionAsync();
|
||||
try
|
||||
{
|
||||
var created = await CreateAsync(tireStack);
|
||||
var stored = await StoreAsync(created, storageLocationId, storedByUserId);
|
||||
await transaction.CommitAsync();
|
||||
return stored;
|
||||
}
|
||||
catch
|
||||
{
|
||||
await transaction.RollbackAsync();
|
||||
// Detach the failed entities so a retry (by the strategy or
|
||||
// the user) does not re-insert stale Added/Modified state.
|
||||
_context.ChangeTracker.Clear();
|
||||
// Regenerate the ticket number on retry if we created it here
|
||||
// (avoids permanent unique-index collisions).
|
||||
if (!hadTicketNumber)
|
||||
tireStack.TicketNumber = string.Empty;
|
||||
throw;
|
||||
}
|
||||
});
|
||||
}
|
||||
catch (DbUpdateConcurrencyException)
|
||||
{
|
||||
throw new InvalidOperationException("Der Lagerplatz wurde soeben anderweitig belegt. Bitte anderen Platz wählen.");
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<List<TireStack>> PickupBatchAsync(IEnumerable<Guid> tireStackIds, Guid? pickedUpByUserId = null)
|
||||
{
|
||||
var ids = tireStackIds.Distinct().ToList();
|
||||
if (ids.Count == 0)
|
||||
return new List<TireStack>();
|
||||
|
||||
var strategy = _context.Database.CreateExecutionStrategy();
|
||||
try
|
||||
{
|
||||
return await strategy.ExecuteAsync(async () =>
|
||||
{
|
||||
await using var transaction = await _context.Database.BeginTransactionAsync();
|
||||
try
|
||||
{
|
||||
var pickedUp = new List<TireStack>(ids.Count);
|
||||
foreach (var id in ids)
|
||||
{
|
||||
pickedUp.Add(await PickupAsync(id, pickedUpByUserId));
|
||||
}
|
||||
await transaction.CommitAsync();
|
||||
return pickedUp;
|
||||
}
|
||||
catch
|
||||
{
|
||||
await transaction.RollbackAsync();
|
||||
_context.ChangeTracker.Clear();
|
||||
throw;
|
||||
}
|
||||
});
|
||||
}
|
||||
catch (DbUpdateConcurrencyException)
|
||||
{
|
||||
throw new InvalidOperationException("Ein Lagerplatz wurde zwischenzeitlich geändert. Bitte erneut versuchen.");
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<bool> DeleteAsync(Guid id)
|
||||
{
|
||||
var tireStack = await _context.TireStacks.FindAsync(id);
|
||||
if (tireStack == null)
|
||||
return false;
|
||||
|
||||
// Block deletion of stored or reserved tire stacks
|
||||
if (tireStack.Status == TireStackStatus.Stored || tireStack.Status == TireStackStatus.Reserved)
|
||||
{
|
||||
_logger.LogWarning("Cannot delete {Status} tire stack {TicketNumber}", tireStack.Status, tireStack.TicketNumber);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Soft delete
|
||||
tireStack.IsActive = false;
|
||||
tireStack.UpdatedAt = DateTime.UtcNow;
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
_logger.LogInformation("Tire stack soft-deleted: {TicketNumber}", tireStack.TicketNumber);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public async Task<string> GenerateTicketNumberAsync(bool isInventory = false)
|
||||
{
|
||||
var year = DateTime.UtcNow.Year;
|
||||
var prefix = isInventory ? $"INV-{year}-" : $"TS-{year}-";
|
||||
|
||||
var lastNumber = await _context.TireStacks
|
||||
.Where(ts => ts.TicketNumber.StartsWith(prefix))
|
||||
.OrderByDescending(ts => ts.TicketNumber)
|
||||
.Select(ts => ts.TicketNumber)
|
||||
.FirstOrDefaultAsync();
|
||||
|
||||
var nextNumber = 1;
|
||||
if (lastNumber != null)
|
||||
{
|
||||
var numPart = lastNumber.Replace(prefix, "");
|
||||
if (int.TryParse(numPart, out var lastNum))
|
||||
nextNumber = lastNum + 1;
|
||||
}
|
||||
|
||||
return $"{prefix}{nextNumber:D5}";
|
||||
}
|
||||
|
||||
public async Task<StorageStatistics> GetStatisticsAsync()
|
||||
{
|
||||
var storedStacks = await _context.TireStacks
|
||||
.Where(ts => ts.IsActive && ts.Status == TireStackStatus.Stored)
|
||||
.GroupBy(ts => ts.Season)
|
||||
.Select(g => new { Season = g.Key, Count = g.Count() })
|
||||
.ToListAsync();
|
||||
|
||||
var totalLocations = await _context.StorageLocations
|
||||
.Where(s => s.IsActive)
|
||||
.CountAsync();
|
||||
|
||||
var occupiedLocations = await _context.TireStacks
|
||||
.Where(ts => ts.IsActive && ts.Status == TireStackStatus.Stored && ts.StorageLocationId != null)
|
||||
.Select(ts => ts.StorageLocationId)
|
||||
.Distinct()
|
||||
.CountAsync();
|
||||
|
||||
var inventoryCount = await _context.TireStacks
|
||||
.CountAsync(ts => ts.IsActive && ts.Status == TireStackStatus.Stored && ts.CustomerId == null);
|
||||
|
||||
var customerStackCount = await _context.TireStacks
|
||||
.CountAsync(ts => ts.IsActive && ts.Status == TireStackStatus.Stored && ts.CustomerId != null);
|
||||
|
||||
return new StorageStatistics
|
||||
{
|
||||
TotalStacksStored = storedStacks.Sum(x => x.Count),
|
||||
TotalStacksPickedUp = await _context.TireStacks.CountAsync(ts => ts.IsActive && ts.Status == TireStackStatus.PickedUp),
|
||||
TotalCustomers = await _context.Customers.CountAsync(c => c.IsActive),
|
||||
AvailableLocations = totalLocations - occupiedLocations,
|
||||
OccupiedLocations = occupiedLocations,
|
||||
SummerStacksStored = storedStacks.FirstOrDefault(x => x.Season == TireSeason.Sommer)?.Count ?? 0,
|
||||
WinterStacksStored = storedStacks.FirstOrDefault(x => x.Season == TireSeason.Winter)?.Count ?? 0,
|
||||
AllSeasonStacksStored = storedStacks.FirstOrDefault(x => x.Season == TireSeason.Allrad)?.Count ?? 0,
|
||||
InventoryStackCount = inventoryCount,
|
||||
CustomerStackCount = customerStackCount
|
||||
};
|
||||
}
|
||||
|
||||
public async Task<List<TireStack>> GetExpiringPickupsAsync(int daysAhead = 14)
|
||||
{
|
||||
var cutoffDate = DateTime.UtcNow.AddDays(daysAhead);
|
||||
|
||||
return await _context.TireStacks
|
||||
.Include(ts => ts.Customer)
|
||||
.Include(ts => ts.StorageLocation)
|
||||
.Where(ts => ts.IsActive &&
|
||||
ts.Status == TireStackStatus.Stored &&
|
||||
ts.CustomerId != null &&
|
||||
ts.ExpectedPickupDate != null &&
|
||||
ts.ExpectedPickupDate <= cutoffDate)
|
||||
.OrderBy(ts => ts.ExpectedPickupDate)
|
||||
.ToListAsync();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
using System.Net;
|
||||
using System.Net.Mail;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using Lagerbestand.Models;
|
||||
using QRCoder;
|
||||
|
||||
namespace Lagerbestand.Services;
|
||||
|
||||
public interface ITwoFactorService
|
||||
{
|
||||
/// <summary>
|
||||
/// Generates a new random Base32-encoded TOTP secret key.
|
||||
/// </summary>
|
||||
string GenerateSecretKey();
|
||||
|
||||
/// <summary>
|
||||
/// Generates the otpauth:// URI for authenticator app setup.
|
||||
/// </summary>
|
||||
string GenerateOtpAuthUri(string secret, string username, string issuer);
|
||||
|
||||
/// <summary>
|
||||
/// Validates a 6-digit TOTP code against a secret (±1 time step tolerance).
|
||||
/// </summary>
|
||||
bool ValidateTotpCode(string secret, string code);
|
||||
|
||||
/// <summary>
|
||||
/// Generates a random 6-digit email verification code.
|
||||
/// </summary>
|
||||
string GenerateEmailCode();
|
||||
|
||||
/// <summary>
|
||||
/// Sends a 2FA code via email using configured SMTP settings.
|
||||
/// </summary>
|
||||
Task<bool> SendEmailCodeAsync(string toEmail, string code, string username);
|
||||
|
||||
/// <summary>
|
||||
/// Generates a QR code as a base64 PNG data URL from an otpauth URI.
|
||||
/// </summary>
|
||||
string GenerateQrCodeDataUrl(string otpAuthUri);
|
||||
}
|
||||
|
||||
public class TwoFactorService : ITwoFactorService
|
||||
{
|
||||
private const int SecretKeyLength = 20; // 160 bits
|
||||
private const int TotpCodeLength = 6;
|
||||
private const int TotpTimeStepSeconds = 30;
|
||||
private const int TotpTimeTolerance = 1; // ±1 step (30 seconds each side)
|
||||
|
||||
private static readonly char[] Base32Chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567".ToCharArray();
|
||||
|
||||
private readonly ISiteSettingsService _siteSettingsService;
|
||||
private readonly ILogger<TwoFactorService> _logger;
|
||||
|
||||
public TwoFactorService(ISiteSettingsService siteSettingsService, ILogger<TwoFactorService> logger)
|
||||
{
|
||||
_siteSettingsService = siteSettingsService;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public string GenerateSecretKey()
|
||||
{
|
||||
var secretBytes = RandomNumberGenerator.GetBytes(SecretKeyLength);
|
||||
return Base32Encode(secretBytes);
|
||||
}
|
||||
|
||||
public string GenerateOtpAuthUri(string secret, string username, string issuer)
|
||||
{
|
||||
var encodedIssuer = Uri.EscapeDataString(issuer);
|
||||
var encodedUsername = Uri.EscapeDataString(username);
|
||||
return $"otpauth://totp/{encodedIssuer}:{encodedUsername}?secret={secret}&issuer={encodedIssuer}&digits={TotpCodeLength}&period={TotpTimeStepSeconds}";
|
||||
}
|
||||
|
||||
public bool ValidateTotpCode(string secret, string code)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(code) || code.Length != TotpCodeLength)
|
||||
return false;
|
||||
|
||||
var secretBytes = Base32Decode(secret);
|
||||
var unixTimestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
|
||||
|
||||
// Check current time step and ±tolerance steps
|
||||
for (int i = -TotpTimeTolerance; i <= TotpTimeTolerance; i++)
|
||||
{
|
||||
var timeStep = (unixTimestamp / TotpTimeStepSeconds) + i;
|
||||
var generatedCode = ComputeTotp(secretBytes, timeStep);
|
||||
if (CryptographicOperations.FixedTimeEquals(
|
||||
Encoding.UTF8.GetBytes(generatedCode),
|
||||
Encoding.UTF8.GetBytes(code)))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public string GenerateEmailCode()
|
||||
{
|
||||
return RandomNumberGenerator.GetInt32(0, 1_000_000).ToString("D6");
|
||||
}
|
||||
|
||||
public async Task<bool> SendEmailCodeAsync(string toEmail, string code, string username)
|
||||
{
|
||||
try
|
||||
{
|
||||
var smtpSettings = await _siteSettingsService.GetSmtpSettingsAsync();
|
||||
if (!smtpSettings.IsEnabled || !smtpSettings.IsConfigured)
|
||||
{
|
||||
_logger.LogWarning("SMTP is not configured. Cannot send 2FA email to {Email}", toEmail);
|
||||
return false;
|
||||
}
|
||||
|
||||
using var client = new SmtpClient(smtpSettings.Host, smtpSettings.Port)
|
||||
{
|
||||
Credentials = new NetworkCredential(smtpSettings.Username, smtpSettings.Password),
|
||||
EnableSsl = smtpSettings.UseSsl,
|
||||
Timeout = 15_000
|
||||
};
|
||||
|
||||
using var message = new MailMessage
|
||||
{
|
||||
From = new MailAddress(smtpSettings.FromEmail, smtpSettings.FromName),
|
||||
Subject = $"Ihr Anmeldecode – {code}",
|
||||
IsBodyHtml = true,
|
||||
Body = $"""
|
||||
<div style="font-family: Arial, sans-serif; max-width: 480px; margin: 0 auto; padding: 24px;">
|
||||
<h2 style="color: #dc2626;">Reifen Späth – Anmeldecode</h2>
|
||||
<p>Hallo <strong>{username}</strong>,</p>
|
||||
<p>Ihr Einmal-Code für die Anmeldung lautet:</p>
|
||||
<div style="background: #f3f4f6; border-radius: 8px; padding: 16px; text-align: center; margin: 16px 0;">
|
||||
<span style="font-size: 32px; font-weight: bold; letter-spacing: 6px; color: #111;">{code}</span>
|
||||
</div>
|
||||
<p style="color: #666; font-size: 14px;">Dieser Code ist 5 Minuten gültig. Falls Sie diese Anmeldung nicht angefordert haben, ignorieren Sie diese E-Mail.</p>
|
||||
</div>
|
||||
"""
|
||||
};
|
||||
message.To.Add(toEmail);
|
||||
|
||||
await client.SendMailAsync(message);
|
||||
_logger.LogInformation("2FA email code sent to {Email} for user {Username}", toEmail, username);
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to send 2FA email to {Email}", toEmail);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public string GenerateQrCodeDataUrl(string otpAuthUri)
|
||||
{
|
||||
using var qrGenerator = new QRCodeGenerator();
|
||||
using var qrCodeData = qrGenerator.CreateQrCode(otpAuthUri, QRCodeGenerator.ECCLevel.M);
|
||||
using var qrCode = new PngByteQRCode(qrCodeData);
|
||||
var pngBytes = qrCode.GetGraphic(8, new byte[] { 30, 30, 32 }, new byte[] { 255, 255, 255 });
|
||||
return $"data:image/png;base64,{Convert.ToBase64String(pngBytes)}";
|
||||
}
|
||||
|
||||
#region TOTP Internals
|
||||
|
||||
private static string ComputeTotp(byte[] secret, long timeStep)
|
||||
{
|
||||
var counterBytes = BitConverter.GetBytes(timeStep);
|
||||
if (BitConverter.IsLittleEndian)
|
||||
Array.Reverse(counterBytes);
|
||||
|
||||
var hash = HMACSHA1.HashData(secret, counterBytes);
|
||||
|
||||
var offset = hash[^1] & 0x0F;
|
||||
var code = ((hash[offset] & 0x7F) << 24) |
|
||||
((hash[offset + 1] & 0xFF) << 16) |
|
||||
((hash[offset + 2] & 0xFF) << 8) |
|
||||
(hash[offset + 3] & 0xFF);
|
||||
|
||||
return (code % 1_000_000).ToString("D6");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Base32
|
||||
|
||||
private static string Base32Encode(byte[] data)
|
||||
{
|
||||
var sb = new StringBuilder((data.Length * 8 + 4) / 5);
|
||||
int buffer = data[0];
|
||||
int next = 1;
|
||||
int bitsLeft = 8;
|
||||
|
||||
while (bitsLeft > 0 || next < data.Length)
|
||||
{
|
||||
if (bitsLeft < 5)
|
||||
{
|
||||
if (next < data.Length)
|
||||
{
|
||||
buffer <<= 8;
|
||||
buffer |= data[next++] & 0xFF;
|
||||
bitsLeft += 8;
|
||||
}
|
||||
else
|
||||
{
|
||||
int pad = 5 - bitsLeft;
|
||||
buffer <<= pad;
|
||||
bitsLeft += pad;
|
||||
}
|
||||
}
|
||||
int index = 0x1F & (buffer >> (bitsLeft - 5));
|
||||
bitsLeft -= 5;
|
||||
sb.Append(Base32Chars[index]);
|
||||
}
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
private static byte[] Base32Decode(string base32)
|
||||
{
|
||||
var cleanInput = base32.TrimEnd('=').ToUpperInvariant();
|
||||
var output = new byte[cleanInput.Length * 5 / 8];
|
||||
int buffer = 0;
|
||||
int bitsLeft = 0;
|
||||
int outputIndex = 0;
|
||||
|
||||
foreach (var c in cleanInput)
|
||||
{
|
||||
var val = Array.IndexOf(Base32Chars, c);
|
||||
if (val < 0) continue;
|
||||
|
||||
buffer <<= 5;
|
||||
buffer |= val & 0x1F;
|
||||
bitsLeft += 5;
|
||||
|
||||
if (bitsLeft >= 8)
|
||||
{
|
||||
output[outputIndex++] = (byte)(buffer >> (bitsLeft - 8));
|
||||
bitsLeft -= 8;
|
||||
}
|
||||
}
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
using Lagerbestand.Models;
|
||||
|
||||
namespace Lagerbestand.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Simple session service to hold current user state.
|
||||
/// Registered as Scoped - one instance per circuit/connection.
|
||||
/// </summary>
|
||||
public class UserSessionService
|
||||
{
|
||||
public AppUser? CurrentUser { get; private set; }
|
||||
|
||||
public bool IsAuthenticated => CurrentUser != null && CurrentUser.IsActive;
|
||||
|
||||
public bool IsAdmin => CurrentUser?.Role == UserRole.Admin;
|
||||
|
||||
/// <summary>
|
||||
/// Worker role - has access to tire operations (checkin/checkout) and inventory list only
|
||||
/// </summary>
|
||||
public bool IsWorker => CurrentUser?.Role == UserRole.Worker;
|
||||
|
||||
/// <summary>
|
||||
/// Check if user is Worker or Admin (for tire operations access)
|
||||
/// </summary>
|
||||
public bool IsWorkerOrAdmin => CurrentUser?.Role == UserRole.Admin || CurrentUser?.Role == UserRole.Worker;
|
||||
|
||||
public string DisplayName => CurrentUser?.DisplayName ?? CurrentUser?.Username ?? string.Empty;
|
||||
|
||||
public string Username => CurrentUser?.Username ?? string.Empty;
|
||||
|
||||
public Guid? UserId => CurrentUser?.Id;
|
||||
|
||||
public UserRole? Role => CurrentUser?.Role;
|
||||
|
||||
public string RoleName => CurrentUser?.Role switch
|
||||
{
|
||||
UserRole.Admin => "Administrator",
|
||||
UserRole.Worker => "Mitarbeiter",
|
||||
UserRole.User => "Benutzer",
|
||||
_ => string.Empty
|
||||
};
|
||||
|
||||
public event Action? OnChange;
|
||||
|
||||
public void SetUser(AppUser user)
|
||||
{
|
||||
CurrentUser = user;
|
||||
NotifyStateChanged();
|
||||
}
|
||||
|
||||
public void ClearUser()
|
||||
{
|
||||
CurrentUser = null;
|
||||
NotifyStateChanged();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Updates the current user data without triggering OnChange.
|
||||
/// Useful for refreshing user data after profile updates.
|
||||
/// </summary>
|
||||
public void UpdateUser(AppUser user)
|
||||
{
|
||||
if (CurrentUser?.Id == user.Id)
|
||||
{
|
||||
CurrentUser = user;
|
||||
NotifyStateChanged();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks if the current user has at least the specified role level.
|
||||
/// Admin > Worker > User
|
||||
/// </summary>
|
||||
public bool HasMinimumRole(UserRole minimumRole)
|
||||
{
|
||||
if (CurrentUser == null || !CurrentUser.IsActive)
|
||||
return false;
|
||||
|
||||
return (int)CurrentUser.Role >= (int)minimumRole;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks if the current user can manage the specified user.
|
||||
/// Users can only be managed by admins or by themselves for limited operations.
|
||||
/// </summary>
|
||||
public bool CanManageUser(Guid targetUserId)
|
||||
{
|
||||
if (!IsAuthenticated)
|
||||
return false;
|
||||
|
||||
// Admins can manage everyone
|
||||
if (IsAdmin)
|
||||
return true;
|
||||
|
||||
// Users can only manage themselves (limited)
|
||||
return CurrentUser?.Id == targetUserId;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks if the current user can delete the specified user.
|
||||
/// Only admins can delete users, and they cannot delete themselves.
|
||||
/// </summary>
|
||||
public bool CanDeleteUser(Guid targetUserId)
|
||||
{
|
||||
if (!IsAdmin)
|
||||
return false;
|
||||
|
||||
// Admins cannot delete themselves
|
||||
return CurrentUser?.Id != targetUserId;
|
||||
}
|
||||
|
||||
private void NotifyStateChanged() => OnChange?.Invoke();
|
||||
}
|
||||
Reference in new issue
Block a user