K10: create penalty entries on day close

- ICashBookService.CreatePenaltyEntriesForDayAsync
- Groups PersonExpenses by person, sums amounts
- Marks PersonExpenses as Done
- DayService calls after SaveChanges
This commit is contained in:
beo3000 2026-01-03 15:00:42 +01:00
parent 21c3d03c61
commit 595c92df76
4 changed files with 103 additions and 1 deletions

View File

@ -1708,7 +1708,7 @@ public enum CashBookEntryType { Income = 0, Expense = 1 }
| ✓ | K7 | Application | Service Interfaces | 2 |
| ✓ | K8 | Application | Service Implementations | 4 |
| ✓ | K9 | Application | Category Seeder | 1 |
| | K10 | Application | Day Close Integration | 1 |
| | K10 | Application | Day Close Integration | 1 |
| ☐ | K11 | Web | Fluxor CategoryState | 4 |
| ☐ | K12 | Web | Fluxor CashBookState | 4 |
| ☐ | K13 | Web | CashBook UI | 3 |

View File

@ -89,4 +89,12 @@ public interface ICashBookService
/// <param name="ct">Cancellation token.</param>
/// <returns>True if fees exist; otherwise, false.</returns>
Task<bool> MembershipFeesExistAsync(int year, int month, CancellationToken ct = default);
/// <summary>
/// Creates cash book entries for monetary penalties from a closed day.
/// </summary>
/// <param name="dayId">The day identifier.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>The number of entries created.</returns>
Task<int> CreatePenaltyEntriesForDayAsync(Guid dayId, CancellationToken ct = default);
}

View File

@ -246,4 +246,80 @@ public class CashBookService : ICashBookService
{
return await _entryRepository.ExistsMembershipFeeForMonthAsync(_clubContext.ClubId, year, month, ct);
}
/// <inheritdoc />
public async Task<int> CreatePenaltyEntriesForDayAsync(Guid dayId, CancellationToken ct = default)
{
await using var context = await _contextFactory.CreateDbContextAsync(ct);
// Get penalty category
var penaltyCategory = await _categoryRepository.GetSystemCategoryAsync(_clubContext.ClubId, "Spielstrafe", ct);
if (penaltyCategory is null)
{
return 0; // No category, skip silently
}
// Get all monetary PersonExpenses for the day that are still open
var personExpenses = await context.PersonExpenses
.Include(pe => pe.Person)
.Where(pe => pe.DayId == dayId
&& pe.ClubId == _clubContext.ClubId
&& pe.ExpenseType == ExpenseType.Monetary
&& pe.PersonExpenseStatus == PersonExpenseStatus.Open
&& !pe.IsDeleted)
.ToListAsync(ct);
if (personExpenses.Count == 0)
{
return 0;
}
// Get day for booking date
var day = await context.Days.FirstOrDefaultAsync(d => d.Id == dayId, ct);
var bookingDate = day?.PostDate ?? DateTime.UtcNow;
// Group by person and create entries
var groupedByPerson = personExpenses.GroupBy(pe => pe.PersonId);
var entriesCreated = 0;
foreach (var group in groupedByPerson)
{
var personId = group.Key;
var personName = group.First().Person?.Name ?? "Unbekannt";
var totalAmount = group.Sum(pe => pe.Price);
if (totalAmount <= 0)
{
continue;
}
// Create cash book entry
var entry = new CashBookEntry
{
Id = Guid.NewGuid(),
ClubId = _clubContext.ClubId,
CategoryId = penaltyCategory.Id,
EntryType = CashBookEntryType.Income,
Amount = totalAmount,
BookingDate = bookingDate,
Comment = $"Spielstrafen - {personName}",
DayId = dayId,
PersonId = personId,
CreatedAt = DateTime.UtcNow
};
await _entryRepository.AddAsync(entry, ct);
entriesCreated++;
// Mark PersonExpenses as Done
foreach (var pe in group)
{
pe.PersonExpenseStatus = PersonExpenseStatus.Done;
pe.ModifiedAt = DateTime.UtcNow;
}
}
await context.SaveChangesAsync(ct);
return entriesCreated;
}
}

View File

@ -21,6 +21,7 @@ public class DayService : IDayService
private readonly ICurrentClubContext _clubContext;
private readonly IMapper _mapper;
private readonly IEmailService _emailService;
private readonly ICashBookService _cashBookService;
private readonly ILogger<DayService> _logger;
/// <summary>
@ -33,6 +34,7 @@ public class DayService : IDayService
ICurrentClubContext clubContext,
IMapper mapper,
IEmailService emailService,
ICashBookService cashBookService,
ILogger<DayService> logger)
{
_contextFactory = contextFactory;
@ -41,6 +43,7 @@ public class DayService : IDayService
_clubContext = clubContext;
_mapper = mapper;
_emailService = emailService;
_cashBookService = cashBookService;
_logger = logger;
}
@ -320,6 +323,21 @@ public class DayService : IDayService
day.ModifiedAt = DateTime.UtcNow;
await context.SaveChangesAsync(ct);
// Create cash book entries for penalties when closing
if (day.Status == DayStatus.Closed)
{
try
{
var entriesCreated = await _cashBookService.CreatePenaltyEntriesForDayAsync(day.Id, ct);
_logger.LogInformation("Created {Count} cash book entries for day {DayId}", entriesCreated, day.Id);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to create cash book entries for day {DayId}", day.Id);
// Don't rethrow - cashbook failure should not fail day closing
}
}
// Send protocol emails after closing (fire and forget, log errors)
if (day.Status == DayStatus.Closed)
{