QR-Scanner: Kamera-Fallbacks und Foto-Upload; weitere Aenderungen

This commit is contained in:
spaethf committed 2026-09-29 18:08:01 +02:00
1 parent 7a0406eaa2
commit e2aef67099
13 files changed
+965 -58

No files matched your search

@@ -0,0 +1,161 @@
using Lagerbestand.Data;
using Lagerbestand.Models;
using Microsoft.EntityFrameworkCore;
namespace Lagerbestand.Services;
/// <summary>
/// Result of merging a local customer into a Lexware customer.
/// </summary>
public class CustomerMergeResult
{
public Guid TargetCustomerId { get; set; }
public int VehiclesMoved { get; set; }
public int TireStacksMoved { get; set; }
public int TasksMoved { get; set; }
}
public interface ICustomerMergeService
{
/// <summary>
/// Local customers are created by this app and use the "K-" number prefix.
/// Lexware customers carry the numeric Lexware customer number.
/// </summary>
bool IsLocalCustomer(Customer customer);
/// <summary>
/// Moves all vehicles, tire stacks and task links from a locally created customer
/// to an existing Lexware customer and permanently deletes the local customer.
/// </summary>
Task<CustomerMergeResult> MergeAsync(Guid sourceId, Guid targetId);
}
public class CustomerMergeService : ICustomerMergeService
{
private readonly AppDbContext _context;
private readonly ILogger<CustomerMergeService> _logger;
public CustomerMergeService(AppDbContext context, ILogger<CustomerMergeService> logger)
{
_context = context;
_logger = logger;
}
public static bool IsLocalCustomerNumber(string? customerNumber) =>
string.IsNullOrWhiteSpace(customerNumber) ||
customerNumber.StartsWith("K-", StringComparison.OrdinalIgnoreCase);
public bool IsLocalCustomer(Customer customer) => IsLocalCustomerNumber(customer.CustomerNumber);
public async Task<CustomerMergeResult> MergeAsync(Guid sourceId, Guid targetId)
{
if (sourceId == targetId)
throw new InvalidOperationException("Quell- und Zielkunde dürfen nicht identisch sein.");
var strategy = _context.Database.CreateExecutionStrategy();
return await strategy.ExecuteAsync(async () =>
{
await using var transaction = await _context.Database.BeginTransactionAsync();
var source = await _context.Customers.FirstOrDefaultAsync(c => c.Id == sourceId)
?? throw new InvalidOperationException("Lokaler Kunde wurde nicht gefunden.");
var target = await _context.Customers.FirstOrDefaultAsync(c => c.Id == targetId)
?? throw new InvalidOperationException("Lexware-Kunde wurde nicht gefunden.");
if (!IsLocalCustomerNumber(source.CustomerNumber))
throw new InvalidOperationException("Nur lokal angelegte Kunden (K-…) können zusammengeführt werden.");
if (IsLocalCustomerNumber(target.CustomerNumber))
throw new InvalidOperationException("Der Zielkunde muss ein Lexware-Kunde sein.");
var result = new CustomerMergeResult { TargetCustomerId = targetId };
var now = DateTime.UtcNow;
// Vehicles
var targetHasPrimary = await _context.Vehicles
.AnyAsync(v => v.CustomerId == targetId && v.IsActive && v.IsPrimary);
var vehicles = await _context.Vehicles
.Where(v => v.CustomerId == sourceId)
.ToListAsync();
foreach (var v in vehicles)
{
v.CustomerId = targetId;
if (targetHasPrimary && v.IsPrimary)
v.IsPrimary = false;
v.UpdatedAt = now;
}
result.VehiclesMoved = vehicles.Count;
// Tire stacks (including history / picked-up ones)
var stacks = await _context.TireStacks
.Where(ts => ts.CustomerId == sourceId)
.ToListAsync();
foreach (var ts in stacks)
{
ts.CustomerId = targetId;
ts.UpdatedAt = now;
}
result.TireStacksMoved = stacks.Count;
// Kanban task links (composite key TaskId + CustomerId -> recreate)
var taskLinks = await _context.KanbanTaskCustomers
.Where(tc => tc.CustomerId == sourceId)
.ToListAsync();
if (taskLinks.Count > 0)
{
var taskIds = taskLinks.Select(tc => tc.TaskId).ToList();
var existingTargetTaskIds = (await _context.KanbanTaskCustomers
.Where(tc => tc.CustomerId == targetId && taskIds.Contains(tc.TaskId))
.Select(tc => tc.TaskId)
.ToListAsync()).ToHashSet();
foreach (var link in taskLinks)
{
if (!existingTargetTaskIds.Contains(link.TaskId))
{
_context.KanbanTaskCustomers.Add(new KanbanTaskCustomer
{
TaskId = link.TaskId,
CustomerId = targetId,
VehicleId = link.VehicleId
});
result.TasksMoved++;
}
_context.KanbanTaskCustomers.Remove(link);
}
}
// Fill empty contact data on target from source
var targetChanged = false;
if (string.IsNullOrWhiteSpace(target.Phone) && !string.IsNullOrWhiteSpace(source.Phone)) { target.Phone = source.Phone; targetChanged = true; }
if (string.IsNullOrWhiteSpace(target.Email) && !string.IsNullOrWhiteSpace(source.Email)) { target.Email = source.Email; targetChanged = true; }
if (!string.IsNullOrWhiteSpace(source.Notes))
{
var mergedNotes = string.IsNullOrWhiteSpace(target.Notes)
? source.Notes
: $"{target.Notes}\n[Zusammengeführt aus {source.CustomerNumber}] {source.Notes}";
if (mergedNotes.Length > 500) mergedNotes = mergedNotes[..500];
target.Notes = mergedNotes;
targetChanged = true;
}
if (!target.IsActive) { target.IsActive = true; targetChanged = true; }
if (targetChanged) target.UpdatedAt = now;
// Persist FK changes first, then hard-delete the source customer
await _context.SaveChangesAsync();
_context.Customers.Remove(source);
await _context.SaveChangesAsync();
await transaction.CommitAsync();
_logger.LogInformation(
"Customer {SourceNumber} merged into {TargetNumber}: {Vehicles} vehicles, {Stacks} tire stacks, {Tasks} task links",
source.CustomerNumber, target.CustomerNumber, result.VehiclesMoved, result.TireStacksMoved, result.TasksMoved);
return result;
});
}
}
+4 -27
View File
@@ -16,7 +16,6 @@ public class LexwareSyncResult
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; }
}
@@ -324,9 +323,6 @@ public class LexwareSyncService : ILexwareSyncService
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;
@@ -357,36 +353,17 @@ public class LexwareSyncService : ILexwareSyncService
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);
// 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, {result.CustomersDeactivated} deaktiviert";
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.CustomersDeactivated} deaktiviert ({result.TotalProcessed} verarbeitet)";
result.Message = $"Synchronisierung erfolgreich: {result.CustomersCreated} erstellt, {result.CustomersUpdated} aktualisiert ({result.TotalProcessed} verarbeitet)";
_logger.LogInformation("Lexware sync completed: {Message}", result.Message);
}
@@ -0,0 +1,184 @@
using Lagerbestand.Models;
using QuestPDF.Fluent;
using QuestPDF.Helpers;
using QuestPDF.Infrastructure;
namespace Lagerbestand.Services;
public interface IStorageRowPdfService
{
/// <summary>
/// Generates a printable A4 inventory sheet for one shelf row (Area + Row).
/// </summary>
byte[] GenerateRowPdf(AreaType area, int row, IReadOnlyList<StorageLocation> locations);
}
public class StorageRowPdfService : IStorageRowPdfService
{
private static readonly string HeaderBackground = Colors.Grey.Lighten3;
private static readonly string BorderColor = Colors.Grey.Medium;
public byte[] GenerateRowPdf(AreaType area, int row, IReadOnlyList<StorageLocation> locations)
{
var rowLocations = locations
.Where(l => l.Area == area && l.Row == row)
.ToList();
var occupied = rowLocations.Count(l => l.IsOccupied);
var total = StorageLocation.PositionsPerSlot * StorageLocation.MaxSlot;
var printedAt = DateTime.Now;
var document = Document.Create(container =>
{
container.Page(page =>
{
page.Size(PageSizes.A4);
page.Margin(1.5f, Unit.Centimetre);
page.DefaultTextStyle(x => x.FontSize(9));
page.Header()
.PaddingBottom(12)
.AlignCenter()
.Text($"{area.GetDisplayName()} – Regal {row}")
.Bold()
.FontSize(20);
page.Content().Row(r =>
{
r.RelativeItem().Column(col =>
{
col.Spacing(14);
col.Item().Element(c => RenderPositionTable(c, area, row, ShelfPosition.LinksOben, rowLocations));
col.Item().Element(c => RenderPositionTable(c, area, row, ShelfPosition.LinksUnten, rowLocations));
});
r.ConstantItem(14);
r.RelativeItem().Column(col =>
{
col.Spacing(14);
col.Item().Element(c => RenderPositionTable(c, area, row, ShelfPosition.RechtsOben, rowLocations));
col.Item().Element(c => RenderPositionTable(c, area, row, ShelfPosition.RechtsUnten, rowLocations));
});
});
page.Footer()
.PaddingTop(10)
.Row(f =>
{
f.RelativeItem().Text($"Gedruckt am {printedAt:dd.MM.yyyy HH:mm}").FontSize(8).FontColor(Colors.Grey.Darken1);
f.RelativeItem().AlignRight().Text($"{occupied} / {total} belegt").FontSize(8).FontColor(Colors.Grey.Darken1);
});
});
});
return document.GeneratePdf();
}
private static void RenderPositionTable(IContainer container, AreaType area, int row, ShelfPosition position, List<StorageLocation> rowLocations)
{
var bySlot = rowLocations
.Where(l => l.Position == position)
.GroupBy(l => l.SlotNumber)
.ToDictionary(g => g.Key, g => g.First());
container.Column(col =>
{
col.Spacing(4);
col.Item().Text(position.GetDisplayName()).Bold().FontSize(11);
col.Item().Table(table =>
{
table.ColumnsDefinition(columns =>
{
columns.ConstantColumn(28);
columns.ConstantColumn(34);
columns.RelativeColumn(3);
columns.RelativeColumn(2);
columns.ConstantColumn(44);
});
table.Header(header =>
{
header.Cell().Element(HeaderCell).Text("Platz");
header.Cell().Element(HeaderCell).Text("Code");
header.Cell().Element(HeaderCell).Text("Kunde/Inhalt");
header.Cell().Element(HeaderCell).Text("Fahrzeug");
header.Cell().Element(HeaderCell).Text("Saison");
});
for (var slot = StorageLocation.MinSlot; slot <= StorageLocation.MaxSlot; slot++)
{
bySlot.TryGetValue(slot, out var location);
var code = location?.Code ?? $"{(int)area}{row}{(int)position}{slot}";
string content, vehicle, season;
var muted = false;
if (location is { IsOccupied: true, OccupiedTireStack: not null })
{
var stack = location.OccupiedTireStack;
content = stack.Customer?.FullName ?? stack.CatalogItem?.Name ?? "Lager";
vehicle = stack.Vehicle?.ShortName ?? "–";
season = SeasonText(stack.Season);
}
else if (location is { IsOccupied: true })
{
content = "Belegt";
vehicle = "–";
season = "–";
}
else if (location is { IsAvailable: false })
{
content = "Gesperrt";
vehicle = "–";
season = "–";
muted = true;
}
else
{
content = "Frei";
vehicle = "–";
season = "–";
muted = true;
}
table.Cell().Element(BodyCell).Text(slot.ToString());
table.Cell().Element(BodyCell).Text(code);
table.Cell().Element(BodyCell).Text(t => Styled(t.Span(content), muted));
table.Cell().Element(BodyCell).Text(t => Styled(t.Span(vehicle), muted));
table.Cell().Element(BodyCell).Text(t => Styled(t.Span(season), muted));
}
});
});
}
private static void Styled(TextSpanDescriptor span, bool muted)
{
if (muted)
span.FontColor(Colors.Grey.Medium).Italic();
}
private static string SeasonText(TireSeason season) => season switch
{
TireSeason.Sommer => "Sommer",
TireSeason.Winter => "Winter",
TireSeason.Allrad => "Ganzjahr",
_ => season.ToString()
};
private static IContainer HeaderCell(IContainer container) => container
.Background(HeaderBackground)
.Border(0.5f)
.BorderColor(BorderColor)
.PaddingVertical(3)
.PaddingHorizontal(4)
.DefaultTextStyle(x => x.Bold().FontSize(8));
private static IContainer BodyCell(IContainer container) => container
.Border(0.5f)
.BorderColor(BorderColor)
.PaddingVertical(3)
.PaddingHorizontal(4)
.DefaultTextStyle(x => x.FontSize(8));
}
+3 -3
View File
@@ -577,8 +577,8 @@ public class TireStackService : ITireStackService
.Select(g => new { Season = g.Key, Count = g.Count() })
.ToListAsync();
var totalLocations = await _context.StorageLocations
.Where(s => s.IsActive)
var availableLocations = await _context.StorageLocations
.Where(s => s.IsActive && s.IsAvailable && !s.IsOccupied)
.CountAsync();
var occupiedLocations = await _context.TireStacks
@@ -598,7 +598,7 @@ public class TireStackService : ITireStackService
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,
AvailableLocations = availableLocations,
OccupiedLocations = occupiedLocations,
SummerStacksStored = storedStacks.FirstOrDefault(x => x.Season == TireSeason.Sommer)?.Count ?? 0,
WinterStacksStored = storedStacks.FirstOrDefault(x => x.Season == TireSeason.Winter)?.Count ?? 0,