KoogleApp/src/GoodWood.Application/Services/DayService.cs

459 lines
16 KiB
C#

using AutoMapper;
using GoodWood.Application.DTOs;
using GoodWood.Application.Interfaces;
using GoodWood.Domain.Entities;
using GoodWood.Domain.Enums;
using GoodWood.Domain.Interfaces;
using GoodWood.Infrastructure.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
namespace GoodWood.Application.Services;
/// <summary>
/// Service for managing game day operations within the current club context.
/// </summary>
public class DayService : IDayService
{
private readonly IDbContextFactory<AppDbContext> _contextFactory;
private readonly IDayRepository _dayRepository;
private readonly IPersonRepository _personRepository;
private readonly ICurrentClubContext _clubContext;
private readonly IMapper _mapper;
private readonly IEmailService _emailService;
private readonly ICashBookService _cashBookService;
private readonly ILogger<DayService> _logger;
/// <summary>
/// Initializes a new instance of the <see cref="DayService"/> class.
/// </summary>
public DayService(
IDbContextFactory<AppDbContext> contextFactory,
IDayRepository dayRepository,
IPersonRepository personRepository,
ICurrentClubContext clubContext,
IMapper mapper,
IEmailService emailService,
ICashBookService cashBookService,
ILogger<DayService> logger)
{
_contextFactory = contextFactory;
_dayRepository = dayRepository;
_personRepository = personRepository;
_clubContext = clubContext;
_mapper = mapper;
_emailService = emailService;
_cashBookService = cashBookService;
_logger = logger;
}
/// <inheritdoc />
public async Task<PagedResultDto<DaySummaryDto>> GetAllAsync(DayFilterDto? filter = null, CancellationToken ct = default)
{
await using var context = await _contextFactory.CreateDbContextAsync(ct);
var query = context.Days
.Where(d => d.ClubId == _clubContext.ClubId && !d.IsDeleted)
.Include(d => d.DayPersons)
.AsQueryable();
// Apply year filter
if (filter?.Year.HasValue == true)
{
query = query.Where(d => d.PostDate.Year == filter.Year.Value);
}
// Apply status filter
if (filter?.Status.HasValue == true)
{
query = query.Where(d => d.Status == filter.Status.Value);
}
var totalCount = await query.CountAsync(ct);
// Order by PostDate descending (newest first)
var days = await query
.OrderByDescending(d => d.PostDate)
.ToListAsync(ct);
var items = days.Select(d => new DaySummaryDto
{
Id = d.Id,
PostDate = d.PostDate,
Status = d.Status,
ParticipantCount = d.DayPersons.Count
}).ToList();
return new PagedResultDto<DaySummaryDto>
{
Items = items,
TotalCount = totalCount
};
}
/// <inheritdoc />
public async Task<DayDto?> GetByIdAsync(Guid id, CancellationToken ct = default)
{
await using var context = await _contextFactory.CreateDbContextAsync(ct);
var day = await context.Days
.Where(d => d.Id == id && d.ClubId == _clubContext.ClubId && !d.IsDeleted)
.Include(d => d.DayPersons)
.ThenInclude(dp => dp.Person)
.Include(d => d.Club)
.FirstOrDefaultAsync(ct);
if (day is null)
return null;
return new DayDto
{
Id = day.Id,
PostDate = day.PostDate,
Status = day.Status,
ClubId = day.ClubId,
ClubName = day.Club?.Name,
ParticipantCount = day.DayPersons.Count,
Participants = day.DayPersons.Select(dp => new DayParticipantDto
{
PersonId = dp.PersonId,
PersonName = dp.Person?.Name ?? string.Empty,
PersonStatus = dp.Person?.PersonStatus ?? PersonStatus.Member
}).ToList(),
CreatedAt = day.CreatedAt,
ModifiedAt = day.ModifiedAt
};
}
/// <inheritdoc />
public async Task<DayDto> CreateAsync(CreateDayDto dto, CancellationToken ct = default)
{
await using var context = await _contextFactory.CreateDbContextAsync(ct);
var day = new Day
{
Id = Guid.NewGuid(),
PostDate = dto.PostDate,
Status = DayStatus.New,
ClubId = _clubContext.ClubId,
CreatedAt = DateTime.UtcNow,
IsDeleted = false
};
// Add initial participants
foreach (var personId in dto.ParticipantIds)
{
day.DayPersons.Add(new DayPerson
{
DayId = day.Id,
PersonId = personId,
ClubId = _clubContext.ClubId
});
}
context.Days.Add(day);
await context.SaveChangesAsync(ct);
return await GetByIdAsync(day.Id, ct)
?? throw new InvalidOperationException("Failed to create day.");
}
/// <inheritdoc />
public async Task<DayDto> UpdateAsync(UpdateDayDto dto, CancellationToken ct = default)
{
await using var context = await _contextFactory.CreateDbContextAsync(ct);
var day = await context.Days
.Where(d => d.Id == dto.Id && d.ClubId == _clubContext.ClubId && !d.IsDeleted)
.FirstOrDefaultAsync(ct)
?? throw new InvalidOperationException($"Day with id {dto.Id} not found.");
day.PostDate = dto.PostDate;
day.Status = dto.Status;
day.ModifiedAt = DateTime.UtcNow;
await context.SaveChangesAsync(ct);
return await GetByIdAsync(day.Id, ct)
?? throw new InvalidOperationException("Failed to update day.");
}
/// <inheritdoc />
public async Task<bool> DeleteAsync(Guid id, CancellationToken ct = default)
{
await using var context = await _contextFactory.CreateDbContextAsync(ct);
var day = await context.Days
.Where(d => d.Id == id && d.ClubId == _clubContext.ClubId && !d.IsDeleted)
.FirstOrDefaultAsync(ct);
if (day is null)
return false;
day.IsDeleted = true;
day.ModifiedAt = DateTime.UtcNow;
await context.SaveChangesAsync(ct);
return true;
}
/// <inheritdoc />
public async Task<DayDto?> GetActiveDayAsync(CancellationToken ct = default)
{
await using var context = await _contextFactory.CreateDbContextAsync(ct);
var today = DateTime.Today;
var day = await context.Days
.Where(d => d.ClubId == _clubContext.ClubId
&& !d.IsDeleted
&& d.PostDate.Date == today
&& d.Status != DayStatus.Closed)
.Include(d => d.DayPersons)
.ThenInclude(dp => dp.Person)
.Include(d => d.Club)
.FirstOrDefaultAsync(ct);
if (day is null)
return null;
return new DayDto
{
Id = day.Id,
PostDate = day.PostDate,
Status = day.Status,
ClubId = day.ClubId,
ClubName = day.Club?.Name,
ParticipantCount = day.DayPersons.Count,
Participants = day.DayPersons.Select(dp => new DayParticipantDto
{
PersonId = dp.PersonId,
PersonName = dp.Person?.Name ?? string.Empty,
PersonStatus = dp.Person?.PersonStatus ?? PersonStatus.Member
}).ToList(),
CreatedAt = day.CreatedAt,
ModifiedAt = day.ModifiedAt
};
}
/// <inheritdoc />
public async Task<DayDto> AddParticipantAsync(AddDayParticipantDto dto, CancellationToken ct = default)
{
await using var context = await _contextFactory.CreateDbContextAsync(ct);
var day = await context.Days
.Where(d => d.Id == dto.DayId && d.ClubId == _clubContext.ClubId && !d.IsDeleted)
.Include(d => d.DayPersons)
.FirstOrDefaultAsync(ct)
?? throw new InvalidOperationException($"Day with id {dto.DayId} not found.");
// Check if participant already exists
if (day.DayPersons.Any(dp => dp.PersonId == dto.PersonId))
throw new InvalidOperationException("Person is already a participant of this day.");
// Verify person belongs to club
var person = await context.Persons
.Where(p => p.Id == dto.PersonId && p.ClubId == _clubContext.ClubId && !p.IsDeleted)
.FirstOrDefaultAsync(ct)
?? throw new InvalidOperationException($"Person with id {dto.PersonId} not found.");
day.DayPersons.Add(new DayPerson
{
DayId = day.Id,
PersonId = dto.PersonId,
ClubId = _clubContext.ClubId
});
day.ModifiedAt = DateTime.UtcNow;
await context.SaveChangesAsync(ct);
return await GetByIdAsync(day.Id, ct)
?? throw new InvalidOperationException("Failed to add participant.");
}
/// <inheritdoc />
public async Task<DayDto> RemoveParticipantAsync(RemoveDayParticipantDto dto, CancellationToken ct = default)
{
await using var context = await _contextFactory.CreateDbContextAsync(ct);
var day = await context.Days
.Where(d => d.Id == dto.DayId && d.ClubId == _clubContext.ClubId && !d.IsDeleted)
.Include(d => d.DayPersons)
.FirstOrDefaultAsync(ct)
?? throw new InvalidOperationException($"Day with id {dto.DayId} not found.");
var dayPerson = day.DayPersons.FirstOrDefault(dp => dp.PersonId == dto.PersonId);
if (dayPerson is null)
throw new InvalidOperationException("Person is not a participant of this day.");
day.DayPersons.Remove(dayPerson);
day.ModifiedAt = DateTime.UtcNow;
await context.SaveChangesAsync(ct);
return await GetByIdAsync(day.Id, ct)
?? throw new InvalidOperationException("Failed to remove participant.");
}
/// <inheritdoc />
public async Task<DayDto> AdvanceStatusAsync(Guid id, CancellationToken ct = default)
{
await using var context = await _contextFactory.CreateDbContextAsync(ct);
var day = await context.Days
.Where(d => d.Id == id && d.ClubId == _clubContext.ClubId && !d.IsDeleted)
.FirstOrDefaultAsync(ct)
?? throw new InvalidOperationException($"Day with id {id} not found.");
// Advance status following workflow: New → Started → Closed
day.Status = day.Status switch
{
DayStatus.New => DayStatus.Started,
DayStatus.Started => DayStatus.Closed,
DayStatus.Postponed => DayStatus.Closed,
DayStatus.Closed => throw new InvalidOperationException("Day is already closed."),
_ => throw new InvalidOperationException($"Unknown day status: {day.Status}")
};
// Create absent member expenses when closing
if (day.Status == DayStatus.Closed)
{
await CreateAbsentMemberExpensesAsync(context, day.Id, ct);
}
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)
{
try
{
var emailsSent = await _emailService.SendDayProtocolAsync(day.Id, _clubContext.ClubId, ct);
_logger.LogInformation("Sent {Count} day protocol emails for day {DayId}", emailsSent, day.Id);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to send day protocol emails for day {DayId}", day.Id);
// Don't rethrow - email failure should not fail day closing
}
}
return await GetByIdAsync(day.Id, ct)
?? throw new InvalidOperationException("Failed to advance status.");
}
/// <summary>
/// Creates PersonExpense for absent members when day closes.
/// </summary>
private async Task CreateAbsentMemberExpensesAsync(
AppDbContext context,
Guid dayId,
CancellationToken ct)
{
// 1. Load day with club and participants
var day = await context.Days
.Include(d => d.Club)
.Include(d => d.DayPersons)
.FirstAsync(d => d.Id == dayId, ct);
var club = day.Club;
// 2. Skip if ExpenseCalculation is None
if (club.ExpenseCalculation == ExpenseCalculation.None)
return;
// 3. Get total expenses per participant for this day
var personExpenseTotals = await context.PersonExpenses
.Where(pe => pe.DayId == dayId && !pe.IsDeleted)
.GroupBy(pe => pe.PersonId)
.Select(g => g.Sum(pe => pe.Price))
.ToListAsync(ct);
// 4. Edge case: No expenses - skip
if (personExpenseTotals.Count == 0)
return;
// 5. Calculate price based on per-person totals
decimal calculatedPrice = club.ExpenseCalculation switch
{
ExpenseCalculation.Average => Math.Round(personExpenseTotals.Average(), 2),
ExpenseCalculation.Maximum => personExpenseTotals.Max(),
_ => 0m
};
if (calculatedPrice <= 0)
return;
// 6. Get Expense linked to Absent trigger
var absentExpense = await context.ExpenseTriggers
.Where(et => et.ClubId == club.Id
&& et.Trigger.ExpenseTriggerType == ExpenseTriggerType.Absent
&& !et.Expense.IsDeleted)
.Select(et => et.Expense)
.FirstOrDefaultAsync(ct);
// 7. No Absent expense configured - skip
if (absentExpense is null)
return;
// 8. Get all club members
var allMemberIds = await context.Persons
.Where(p => p.ClubId == club.Id
&& p.PersonStatus == PersonStatus.Member
&& !p.IsDeleted)
.Select(p => p.Id)
.ToListAsync(ct);
// 9. Get participant IDs
var participantIds = day.DayPersons
.Select(dp => dp.PersonId)
.ToHashSet();
// 10. Absent members = members NOT in DayPersons
var absentMemberIds = allMemberIds
.Where(id => !participantIds.Contains(id))
.ToList();
if (absentMemberIds.Count == 0)
return;
// 11. Create PersonExpense for each absent member
foreach (var memberId in absentMemberIds)
{
var personExpense = new PersonExpense
{
Id = Guid.NewGuid(),
PersonId = memberId,
ExpenseId = absentExpense.Id,
DayId = dayId,
GameId = null,
ClubId = club.Id,
Name = absentExpense.Name,
Price = calculatedPrice,
ExpenseType = absentExpense.ExpenseType,
PersonExpenseStatus = PersonExpenseStatus.Open,
AssignedById = Guid.Empty,
CreatedAt = DateTime.UtcNow,
IsDeleted = false
};
context.PersonExpenses.Add(personExpense);
}
// SaveChanges called by AdvanceStatusAsync
}
}