QR-Scanner: Kamera-Fallbacks und Foto-Upload; weitere Aenderungen
This commit is contained in:
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;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user