459 lines
16 KiB
C#
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
|
|
}
|
|
}
|