493 lines
17 KiB
C#
493 lines
17 KiB
C#
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 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; }
|
|
|
|
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);
|
|
|
|
// Note: customers not present in Lexware are intentionally left untouched.
|
|
// The local IsActive flag is managed exclusively within this application.
|
|
|
|
// Update sync timestamp
|
|
settings.LastSyncAt = DateTime.UtcNow;
|
|
settings.LastSyncCount = result.TotalProcessed;
|
|
settings.LastSyncResult = $"{result.CustomersCreated} erstellt, {result.CustomersUpdated} aktualisiert";
|
|
await _siteSettingsService.SaveLexwareApiSettingsAsync(settings);
|
|
|
|
result.Success = true;
|
|
result.Message = $"Synchronisierung erfolgreich: {result.CustomersCreated} erstellt, {result.CustomersUpdated} aktualisiert ({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");
|
|
}
|
|
}
|