K8: add cashbook service implementations
- BookingCategoryService: CRUD + EnsureSystemCategoriesAsync - CashBookService: entries, balance, reports, membership fees - CashBookMappingProfile: entity to DTO mappings - DI registration for both services
This commit is contained in:
parent
1a7ba8837d
commit
2c09cfa991
|
|
@ -1706,7 +1706,7 @@ public enum CashBookEntryType { Income = 0, Expense = 1 }
|
|||
| ✓ | K5 | Infrastructure | Repositories | 5 |
|
||||
| ✓ | K6 | Application | DTOs | 3 |
|
||||
| ✓ | K7 | Application | Service Interfaces | 2 |
|
||||
| ☐ | K8 | Application | Service Implementations | 4 |
|
||||
| ✓ | K8 | Application | Service Implementations | 4 |
|
||||
| ☐ | K9 | Application | Category Seeder | 1 |
|
||||
| ☐ | K10 | Application | Day Close Integration | 1 |
|
||||
| ☐ | K11 | Web | Fluxor CategoryState | 4 |
|
||||
|
|
|
|||
|
|
@ -40,6 +40,8 @@ namespace Koogle.Application
|
|||
services.AddScoped<IPlayerStatisticsService, PlayerStatisticsService>();
|
||||
services.AddScoped<IClubGifService, ClubGifService>();
|
||||
services.AddScoped<IClubRequestService, ClubRequestService>();
|
||||
services.AddScoped<IBookingCategoryService, BookingCategoryService>();
|
||||
services.AddScoped<ICashBookService, CashBookService>();
|
||||
|
||||
// Note: Game types are registered in Koogle.Web where Blazor components are defined
|
||||
// Use services.AddGameType<TDefinition, TLogicService>() to register game types
|
||||
|
|
|
|||
|
|
@ -0,0 +1,25 @@
|
|||
using AutoMapper;
|
||||
using Koogle.Application.DTOs;
|
||||
using Koogle.Domain.Entities;
|
||||
|
||||
namespace Koogle.Application.Mapping;
|
||||
|
||||
/// <summary>
|
||||
/// AutoMapper profile for cash book entity mappings.
|
||||
/// </summary>
|
||||
public class CashBookMappingProfile : Profile
|
||||
{
|
||||
/// <summary>
|
||||
/// Initializes cash book entity to DTO mappings.
|
||||
/// </summary>
|
||||
public CashBookMappingProfile()
|
||||
{
|
||||
// BookingCategory -> BookingCategoryDto
|
||||
CreateMap<BookingCategory, BookingCategoryDto>();
|
||||
|
||||
// CashBookEntry -> CashBookEntryDto
|
||||
CreateMap<CashBookEntry, CashBookEntryDto>()
|
||||
.ForMember(d => d.CategoryName, o => o.MapFrom(s => s.Category != null ? s.Category.Name : string.Empty))
|
||||
.ForMember(d => d.PersonName, o => o.MapFrom(s => s.Person != null ? s.Person.Name : null));
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,137 @@
|
|||
using AutoMapper;
|
||||
using Koogle.Application.DTOs;
|
||||
using Koogle.Application.Interfaces;
|
||||
using Koogle.Domain.Entities;
|
||||
using Koogle.Domain.Enums;
|
||||
using Koogle.Domain.Interfaces;
|
||||
|
||||
namespace Koogle.Application.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Service for managing booking category operations within the current club context.
|
||||
/// </summary>
|
||||
public class BookingCategoryService : IBookingCategoryService
|
||||
{
|
||||
private readonly IBookingCategoryRepository _repository;
|
||||
private readonly ICurrentClubContext _clubContext;
|
||||
private readonly IMapper _mapper;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="BookingCategoryService"/> class.
|
||||
/// </summary>
|
||||
public BookingCategoryService(
|
||||
IBookingCategoryRepository repository,
|
||||
ICurrentClubContext clubContext,
|
||||
IMapper mapper)
|
||||
{
|
||||
_repository = repository;
|
||||
_clubContext = clubContext;
|
||||
_mapper = mapper;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<List<BookingCategoryDto>> GetAllAsync(bool includeInactive = false, CancellationToken ct = default)
|
||||
{
|
||||
var categories = await _repository.GetByClubIdAsync(_clubContext.ClubId, includeInactive, ct);
|
||||
return _mapper.Map<List<BookingCategoryDto>>(categories);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<BookingCategoryDto?> GetByIdAsync(Guid id, CancellationToken ct = default)
|
||||
{
|
||||
var category = await _repository.GetByIdAsync(id, ct);
|
||||
if (category is null || category.ClubId != _clubContext.ClubId)
|
||||
return null;
|
||||
|
||||
return _mapper.Map<BookingCategoryDto>(category);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<BookingCategoryDto> CreateAsync(CreateBookingCategoryDto dto, CancellationToken ct = default)
|
||||
{
|
||||
var entity = new BookingCategory
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
ClubId = _clubContext.ClubId,
|
||||
Name = dto.Name,
|
||||
Description = dto.Description,
|
||||
CategoryType = dto.CategoryType,
|
||||
IsSystemCategory = false,
|
||||
Color = dto.Color,
|
||||
Icon = dto.Icon,
|
||||
IsActive = true,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
};
|
||||
|
||||
var created = await _repository.AddAsync(entity, ct);
|
||||
return _mapper.Map<BookingCategoryDto>(created);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<BookingCategoryDto> UpdateAsync(UpdateBookingCategoryDto dto, CancellationToken ct = default)
|
||||
{
|
||||
var existing = await _repository.GetByIdAsync(dto.Id, ct)
|
||||
?? throw new InvalidOperationException($"Category with id {dto.Id} not found.");
|
||||
|
||||
if (existing.ClubId != _clubContext.ClubId)
|
||||
throw new InvalidOperationException("Category does not belong to current club.");
|
||||
|
||||
existing.Name = dto.Name;
|
||||
existing.Description = dto.Description;
|
||||
existing.Color = dto.Color;
|
||||
existing.Icon = dto.Icon;
|
||||
existing.IsActive = dto.IsActive;
|
||||
existing.ModifiedAt = DateTime.UtcNow;
|
||||
|
||||
var updated = await _repository.UpdateAsync(existing, ct);
|
||||
return _mapper.Map<BookingCategoryDto>(updated);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<bool> DeleteAsync(Guid id, CancellationToken ct = default)
|
||||
{
|
||||
var existing = await _repository.GetByIdAsync(id, ct);
|
||||
if (existing is null || existing.ClubId != _clubContext.ClubId)
|
||||
return false;
|
||||
|
||||
if (existing.IsSystemCategory)
|
||||
throw new InvalidOperationException("System categories cannot be deleted.");
|
||||
|
||||
existing.IsDeleted = true;
|
||||
existing.ModifiedAt = DateTime.UtcNow;
|
||||
await _repository.UpdateAsync(existing, ct);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task EnsureSystemCategoriesAsync(CancellationToken ct = default)
|
||||
{
|
||||
var systemCategories = new[]
|
||||
{
|
||||
new { Name = "Spielstrafe", Type = BookingCategoryType.Income, Color = "#4CAF50", Icon = "Gavel" },
|
||||
new { Name = "Mitgliedsbeitrag", Type = BookingCategoryType.Income, Color = "#2196F3", Icon = "CardMembership" },
|
||||
new { Name = "Korrekturbuchung", Type = BookingCategoryType.Income, Color = "#FF9800", Icon = "Build" },
|
||||
new { Name = "Saldoanpassung", Type = BookingCategoryType.Income, Color = "#9E9E9E", Icon = "Balance" }
|
||||
};
|
||||
|
||||
foreach (var cat in systemCategories)
|
||||
{
|
||||
var existing = await _repository.GetSystemCategoryAsync(_clubContext.ClubId, cat.Name, ct);
|
||||
if (existing is null)
|
||||
{
|
||||
await _repository.AddAsync(new BookingCategory
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
ClubId = _clubContext.ClubId,
|
||||
Name = cat.Name,
|
||||
CategoryType = cat.Type,
|
||||
IsSystemCategory = true,
|
||||
Color = cat.Color,
|
||||
Icon = cat.Icon,
|
||||
IsActive = true,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
}, ct);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,249 @@
|
|||
using AutoMapper;
|
||||
using Koogle.Application.DTOs;
|
||||
using Koogle.Application.Interfaces;
|
||||
using Koogle.Domain.Entities;
|
||||
using Koogle.Domain.Enums;
|
||||
using Koogle.Domain.Interfaces;
|
||||
using KoogleApp.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Koogle.Application.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Service for managing cash book operations within the current club context.
|
||||
/// </summary>
|
||||
public class CashBookService : ICashBookService
|
||||
{
|
||||
private readonly ICashBookEntryRepository _entryRepository;
|
||||
private readonly IBookingCategoryRepository _categoryRepository;
|
||||
private readonly IPersonExpenseRepository _personExpenseRepository;
|
||||
private readonly IPersonRepository _personRepository;
|
||||
private readonly IClubRepository _clubRepository;
|
||||
private readonly ICurrentClubContext _clubContext;
|
||||
private readonly IDbContextFactory<AppDbContext> _contextFactory;
|
||||
private readonly IMapper _mapper;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="CashBookService"/> class.
|
||||
/// </summary>
|
||||
public CashBookService(
|
||||
ICashBookEntryRepository entryRepository,
|
||||
IBookingCategoryRepository categoryRepository,
|
||||
IPersonExpenseRepository personExpenseRepository,
|
||||
IPersonRepository personRepository,
|
||||
IClubRepository clubRepository,
|
||||
ICurrentClubContext clubContext,
|
||||
IDbContextFactory<AppDbContext> contextFactory,
|
||||
IMapper mapper)
|
||||
{
|
||||
_entryRepository = entryRepository;
|
||||
_categoryRepository = categoryRepository;
|
||||
_personExpenseRepository = personExpenseRepository;
|
||||
_personRepository = personRepository;
|
||||
_clubRepository = clubRepository;
|
||||
_clubContext = clubContext;
|
||||
_contextFactory = contextFactory;
|
||||
_mapper = mapper;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<List<CashBookEntryDto>> GetEntriesAsync(DateTime? from = null, DateTime? to = null, CancellationToken ct = default)
|
||||
{
|
||||
var entries = await _entryRepository.GetByClubIdAsync(_clubContext.ClubId, from, to, ct);
|
||||
return _mapper.Map<List<CashBookEntryDto>>(entries);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<CashBookEntryDto?> GetByIdAsync(Guid id, CancellationToken ct = default)
|
||||
{
|
||||
var entry = await _entryRepository.GetByIdAsync(id, ct);
|
||||
if (entry is null || entry.ClubId != _clubContext.ClubId)
|
||||
return null;
|
||||
|
||||
return _mapper.Map<CashBookEntryDto>(entry);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<CashBookEntryDto> CreateAsync(CreateCashBookEntryDto dto, CancellationToken ct = default)
|
||||
{
|
||||
var entity = new CashBookEntry
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
ClubId = _clubContext.ClubId,
|
||||
CategoryId = dto.CategoryId,
|
||||
EntryType = dto.EntryType,
|
||||
Amount = dto.Amount,
|
||||
BookingDate = dto.BookingDate,
|
||||
Comment = dto.Comment,
|
||||
ReceiptReference = dto.ReceiptReference,
|
||||
PersonId = dto.PersonId,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
};
|
||||
|
||||
var created = await _entryRepository.AddAsync(entity, ct);
|
||||
|
||||
// Reload with navigation properties
|
||||
var reloaded = await _entryRepository.GetByIdAsync(created.Id, ct);
|
||||
return _mapper.Map<CashBookEntryDto>(reloaded);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<CashBookEntryDto> UpdateAsync(UpdateCashBookEntryDto dto, CancellationToken ct = default)
|
||||
{
|
||||
await using var context = await _contextFactory.CreateDbContextAsync(ct);
|
||||
|
||||
var existing = await context.CashBookEntries
|
||||
.FirstOrDefaultAsync(e => e.Id == dto.Id && !e.IsDeleted, ct)
|
||||
?? throw new InvalidOperationException($"Entry with id {dto.Id} not found.");
|
||||
|
||||
if (existing.ClubId != _clubContext.ClubId)
|
||||
throw new InvalidOperationException("Entry does not belong to current club.");
|
||||
|
||||
existing.CategoryId = dto.CategoryId;
|
||||
existing.Amount = dto.Amount;
|
||||
existing.BookingDate = dto.BookingDate;
|
||||
existing.Comment = dto.Comment;
|
||||
existing.ReceiptReference = dto.ReceiptReference;
|
||||
existing.ModifiedAt = DateTime.UtcNow;
|
||||
|
||||
await context.SaveChangesAsync(ct);
|
||||
|
||||
var reloaded = await _entryRepository.GetByIdAsync(dto.Id, ct);
|
||||
return _mapper.Map<CashBookEntryDto>(reloaded);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<bool> DeleteAsync(Guid id, CancellationToken ct = default)
|
||||
{
|
||||
await using var context = await _contextFactory.CreateDbContextAsync(ct);
|
||||
|
||||
var existing = await context.CashBookEntries
|
||||
.FirstOrDefaultAsync(e => e.Id == id && !e.IsDeleted, ct);
|
||||
|
||||
if (existing is null || existing.ClubId != _clubContext.ClubId)
|
||||
return false;
|
||||
|
||||
existing.IsDeleted = true;
|
||||
existing.ModifiedAt = DateTime.UtcNow;
|
||||
await context.SaveChangesAsync(ct);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<decimal> GetBalanceAsync(DateTime? asOfDate = null, CancellationToken ct = default)
|
||||
{
|
||||
return await _entryRepository.GetBalanceAsync(_clubContext.ClubId, asOfDate, ct);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<CashBookReportDto> GetReportAsync(DateTime from, DateTime to, CancellationToken ct = default)
|
||||
{
|
||||
var entries = await _entryRepository.GetByClubIdAsync(_clubContext.ClubId, from, to, ct);
|
||||
var openingBalance = await _entryRepository.GetBalanceAsync(_clubContext.ClubId, from.AddDays(-1), ct);
|
||||
|
||||
var totalIncome = entries.Where(e => e.EntryType == CashBookEntryType.Income).Sum(e => e.Amount);
|
||||
var totalExpense = entries.Where(e => e.EntryType == CashBookEntryType.Expense).Sum(e => e.Amount);
|
||||
|
||||
var incomeByCategory = entries
|
||||
.Where(e => e.EntryType == CashBookEntryType.Income)
|
||||
.GroupBy(e => new { e.CategoryId, e.Category.Name })
|
||||
.Select(g => new CategorySummaryDto
|
||||
{
|
||||
CategoryId = g.Key.CategoryId,
|
||||
CategoryName = g.Key.Name,
|
||||
Total = g.Sum(e => e.Amount),
|
||||
Count = g.Count()
|
||||
})
|
||||
.ToList();
|
||||
|
||||
var expenseByCategory = entries
|
||||
.Where(e => e.EntryType == CashBookEntryType.Expense)
|
||||
.GroupBy(e => new { e.CategoryId, e.Category.Name })
|
||||
.Select(g => new CategorySummaryDto
|
||||
{
|
||||
CategoryId = g.Key.CategoryId,
|
||||
CategoryName = g.Key.Name,
|
||||
Total = g.Sum(e => e.Amount),
|
||||
Count = g.Count()
|
||||
})
|
||||
.ToList();
|
||||
|
||||
return new CashBookReportDto
|
||||
{
|
||||
ReportStart = from,
|
||||
ReportEnd = to,
|
||||
OpeningBalance = openingBalance,
|
||||
ClosingBalance = openingBalance + totalIncome - totalExpense,
|
||||
TotalIncome = totalIncome,
|
||||
TotalExpense = totalExpense,
|
||||
IncomeByCategory = incomeByCategory,
|
||||
ExpenseByCategory = expenseByCategory,
|
||||
Entries = _mapper.Map<List<CashBookEntryDto>>(entries)
|
||||
};
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<List<CashBookEntryDto>> GetByDayIdAsync(Guid dayId, CancellationToken ct = default)
|
||||
{
|
||||
var entries = await _entryRepository.GetByDayIdAsync(dayId, ct);
|
||||
return _mapper.Map<List<CashBookEntryDto>>(entries);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<List<CashBookEntryDto>> CreateMembershipFeesAsync(CreateMembershipFeesDto dto, CancellationToken ct = default)
|
||||
{
|
||||
// Check if fees already exist
|
||||
if (await _entryRepository.ExistsMembershipFeeForMonthAsync(_clubContext.ClubId, dto.Year, dto.Month, ct))
|
||||
{
|
||||
throw new InvalidOperationException($"Membership fees for {dto.Month}/{dto.Year} already exist.");
|
||||
}
|
||||
|
||||
// Get club to read fee amount
|
||||
var club = await _clubRepository.GetByIdAsync(_clubContext.ClubId, ct)
|
||||
?? throw new InvalidOperationException("Club not found.");
|
||||
|
||||
var feeAmount = dto.OverrideAmount ?? club.MonthlyMembershipFee;
|
||||
if (feeAmount <= 0)
|
||||
{
|
||||
throw new InvalidOperationException("Membership fee amount must be greater than zero.");
|
||||
}
|
||||
|
||||
// Get membership category
|
||||
var category = await _categoryRepository.GetSystemCategoryAsync(_clubContext.ClubId, "Mitgliedsbeitrag", ct)
|
||||
?? throw new InvalidOperationException("Membership fee category not found. Please ensure system categories are created.");
|
||||
|
||||
// Get active members
|
||||
var members = await _personRepository.GetByClubIdAsync(_clubContext.ClubId, ct);
|
||||
var activeMembers = members.Where(p => p.PersonStatus == PersonStatus.Member).ToList();
|
||||
|
||||
var bookingDate = new DateTime(dto.Year, dto.Month, 1);
|
||||
var createdEntries = new List<CashBookEntry>();
|
||||
|
||||
foreach (var member in activeMembers)
|
||||
{
|
||||
var entry = new CashBookEntry
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
ClubId = _clubContext.ClubId,
|
||||
CategoryId = category.Id,
|
||||
EntryType = CashBookEntryType.Income,
|
||||
Amount = feeAmount,
|
||||
BookingDate = bookingDate,
|
||||
Comment = $"Mitgliedsbeitrag {dto.Month:D2}/{dto.Year} - {member.Name}",
|
||||
PersonId = member.Id,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
};
|
||||
|
||||
var created = await _entryRepository.AddAsync(entry, ct);
|
||||
createdEntries.Add(created);
|
||||
}
|
||||
|
||||
return _mapper.Map<List<CashBookEntryDto>>(createdEntries);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<bool> MembershipFeesExistAsync(int year, int month, CancellationToken ct = default)
|
||||
{
|
||||
return await _entryRepository.ExistsMembershipFeeForMonthAsync(_clubContext.ClubId, year, month, ct);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue