Files
Academic-Affairs-System/src/Jiaowu.Api/Controllers/TuitionController.cs
T
2026-08-22 18:52:41 +08:00

106 lines
11 KiB
C#

using Jiaowu.Api.Domain.Academic;
using System.Security.Claims;
using Jiaowu.Api.Domain.Identity;
using Jiaowu.Api.Infrastructure.Excel;
using Jiaowu.Api.Infrastructure.Files;
using Jiaowu.Api.Infrastructure.Persistence;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace Jiaowu.Api.Controllers;
[ApiController, Authorize, Route("api/tuition")]
public sealed class TuitionController(AppDbContext db) : ControllerBase
{
private const string Managers = SystemRoles.SuperAdmin + "," + SystemRoles.AcademicAdmin;
[HttpGet("terms"), Authorize(Roles = Managers)]
public async Task<ActionResult> Terms(CancellationToken ct) => Ok(await db.AcademicTerms.AsNoTracking().OrderByDescending(x => x.StartDate).Select(x => new { x.Id, x.Name, x.IsCurrent, Setting = db.TuitionTermSettings.Where(s => s.AcademicTermId == x.Id).Select(s => new { s.CreditUnitPrice, s.Status, s.CreditsLockedAt }).FirstOrDefault() }).ToListAsync(ct));
[HttpPut("terms/{termId:guid}/credit-price"), Authorize(Roles = Managers)]
public async Task<ActionResult> SetCreditPrice(Guid termId, TuitionPriceRequest request, CancellationToken ct)
{
if (request.CreditUnitPrice < 0) return BadRequest(new { message = "每学分金额不能为负数。" });
if (!await db.AcademicTerms.AnyAsync(x => x.Id == termId, ct)) return NotFound();
var setting = await db.TuitionTermSettings.SingleOrDefaultAsync(x => x.AcademicTermId == termId, ct);
if (setting is null) { setting = new TuitionTermSetting { AcademicTermId = termId, CreditUnitPrice = request.CreditUnitPrice }; db.TuitionTermSettings.Add(setting); }
else if (setting.Status != TuitionTermStatus.Draft) return Conflict(new { message = "学分费已锁定,不能修改单价。" });
else setting.CreditUnitPrice = request.CreditUnitPrice;
await db.SaveChangesAsync(ct); return NoContent();
}
[HttpGet("terms/{termId:guid}/import-template.xlsx"), Authorize(Roles = Managers)]
public IActionResult Template(Guid termId) => File(ExcelWorkbookHelper.Create("学费名单导入", ["学号", "基础学费", "住宿费"], [], ["按学号匹配学生;仅覆盖未缴的基础学费和住宿费。", "已缴项目的差额不会覆盖,将自动记为期末多退少补调整项。"]), ExcelWorkbookHelper.ContentType, DownloadFileName.Template("学费名单", ".xlsx"));
[HttpPost("terms/{termId:guid}/import"), Authorize(Roles = Managers)]
public async Task<ActionResult> Import(Guid termId, IFormFile file, CancellationToken ct)
{
IReadOnlyList<ExcelRow> rows; try { rows = await ExcelWorkbookHelper.ReadAsync(file, ["学号", "基础学费", "住宿费"], ct); } catch (InvalidDataException ex) { return BadRequest(new { message = ex.Message }); }
var inputs = rows.Select(r => new { r.RowNumber, Number = r["学号"], Base = decimal.TryParse(r["基础学费"], out var b) ? b : -1, Accommodation = decimal.TryParse(r["住宿费"], out var a) ? a : -1 }).ToList();
if (inputs.Any(x => string.IsNullOrWhiteSpace(x.Number) || x.Base < 0 || x.Accommodation < 0)) return BadRequest(new { message = "请检查学号、基础学费和住宿费;金额必须为非负数。" });
var students = await db.Students.Where(x => inputs.Select(i => i.Number).Contains(x.StudentNumber)).ToDictionaryAsync(x => x.StudentNumber, ct);
if (inputs.Any(x => !students.ContainsKey(x.Number))) return BadRequest(new { message = "存在无法匹配的学号,请修正后重新导入。" });
foreach (var input in inputs)
{
var bill = await db.TuitionBills.SingleOrDefaultAsync(x => x.AcademicTermId == termId && x.StudentId == students[input.Number].Id, ct);
if (bill is null) { bill = new TuitionBill { AcademicTermId = termId, StudentId = students[input.Number].Id, BaseTuition = input.Base, AccommodationFee = input.Accommodation }; db.TuitionBills.Add(bill); continue; }
if (bill.BasePaid == 0) bill.BaseTuition = input.Base; else bill.ReconciliationAdjustment += input.Base - bill.BaseTuition;
if (bill.AccommodationPaid == 0) bill.AccommodationFee = input.Accommodation; else bill.ReconciliationAdjustment += input.Accommodation - bill.AccommodationFee;
}
await db.SaveChangesAsync(ct); return Ok(new { imported = inputs.Count });
}
[HttpPost("terms/{termId:guid}/lock-credits"), Authorize(Roles = Managers)]
public async Task<ActionResult> LockCredits(Guid termId, CancellationToken ct)
{
var setting = await db.TuitionTermSettings.SingleOrDefaultAsync(x => x.AcademicTermId == termId, ct);
if (setting is null) return BadRequest(new { message = "请先设定每学分金额。" });
if (setting.Status != TuitionTermStatus.Draft) return Conflict(new { message = "学分费已经锁定。" });
if (await db.CourseSelectionRounds.AnyAsync(x => x.AcademicTermId == termId && x.Status != CourseSelectionRoundStatus.Closed, ct)) return Conflict(new { message = "本学期仍有未结束的选课轮次,不能锁定学分费。" });
var credits = await db.CourseEnrollments.Where(x => x.CourseSelectionOffering!.CourseSelectionRound!.AcademicTermId == termId && x.Status == CourseEnrollmentStatus.Enrolled).GroupBy(x => x.StudentId).Select(x => new { StudentId = x.Key, Credits = x.Sum(e => e.CourseSelectionOffering!.TeachingTask!.Course!.Credits) }).ToListAsync(ct);
foreach (var credit in credits) { var bill = await db.TuitionBills.SingleOrDefaultAsync(x => x.AcademicTermId == termId && x.StudentId == credit.StudentId, ct); if (bill is null) { bill = new TuitionBill { AcademicTermId = termId, StudentId = credit.StudentId }; db.TuitionBills.Add(bill); } bill.CreditCount = credit.Credits; bill.CreditFee = credit.Credits * setting.CreditUnitPrice; }
setting.Status = TuitionTermStatus.CreditsLocked; setting.CreditsLockedAt = DateTime.UtcNow;
await db.SaveChangesAsync(ct); return Ok(new { locked = credits.Count });
}
[HttpPost("terms/{termId:guid}/open-payment"), Authorize(Roles = Managers)]
public async Task<ActionResult> OpenPayment(Guid termId, CancellationToken ct) { var setting = await db.TuitionTermSettings.SingleOrDefaultAsync(x => x.AcademicTermId == termId, ct); if (setting is null || setting.Status != TuitionTermStatus.CreditsLocked) return Conflict(new { message = "请先锁定学分费。" }); setting.Status = TuitionTermStatus.OpenForPayment; await db.SaveChangesAsync(ct); return NoContent(); }
[HttpGet("bills"), Authorize(Roles = Managers)]
public async Task<ActionResult> Bills(Guid termId, CancellationToken ct) => Ok(await db.TuitionBills.AsNoTracking().Where(x => x.AcademicTermId == termId).OrderBy(x => x.Student!.StudentNumber).Select(x => new { x.Id, x.StudentId, x.Student!.StudentNumber, StudentName = x.Student.Name, x.BaseTuition, x.AccommodationFee, x.CreditCount, x.CreditFee, x.ReconciliationAdjustment, x.BasePaid, x.AccommodationPaid, x.CreditPaid, x.AdjustmentPaid }).ToListAsync(ct));
[HttpPost("bills/{billId:guid}/reconcile"), Authorize(Roles = Managers)]
public async Task<ActionResult> Reconcile(Guid billId, ReconcileRequest request, CancellationToken ct) { var bill = await db.TuitionBills.FindAsync([billId], ct); if (bill is null) return NotFound(); bill.ReconciliationAdjustment += request.Amount; await db.SaveChangesAsync(ct); return NoContent(); }
[HttpGet("mine"), Authorize(Roles = SystemRoles.Student)]
public async Task<ActionResult> Mine(CancellationToken ct)
{
if (!Guid.TryParse(User.FindFirstValue(ClaimTypes.NameIdentifier), out var userId)) return Forbid();
return Ok(await db.TuitionBills.AsNoTracking().Where(x => x.Student!.UserId == userId).OrderByDescending(x => x.AcademicTerm!.StartDate).Select(x => new { x.Id, TermName = x.AcademicTerm!.Name, x.BaseTuition, x.AccommodationFee, x.CreditCount, x.CreditFee, x.ReconciliationAdjustment, x.BasePaid, x.AccommodationPaid, x.CreditPaid, x.AdjustmentPaid, IsPayable = db.TuitionTermSettings.Any(s => s.AcademicTermId == x.AcademicTermId && s.Status == TuitionTermStatus.OpenForPayment) }).ToListAsync(ct));
}
[HttpPost("bills/{billId:guid}/items/{item}/pay-balance"), Authorize(Roles = SystemRoles.Student)]
public async Task<ActionResult> PayBalance(Guid billId, TuitionFeeItem item, CancellationToken ct)
{
if (!Guid.TryParse(User.FindFirstValue(ClaimTypes.NameIdentifier), out var userId)) return Forbid();
var bill = await db.TuitionBills.Include(x => x.Student).SingleOrDefaultAsync(x => x.Id == billId && x.Student!.UserId == userId, ct);
if (bill is null) return NotFound();
if (!await db.TuitionTermSettings.AnyAsync(x => x.AcademicTermId == bill.AcademicTermId && x.Status == TuitionTermStatus.OpenForPayment, ct)) return Conflict(new { message = "当前学期尚未开放缴费。" });
var due = Due(bill, item); if (due <= 0) return Conflict(new { message = "该项目没有待缴金额。" });
var wallet = await db.CampusWalletAccounts.SingleOrDefaultAsync(x => x.StudentId == bill.StudentId, ct); if (wallet is null || wallet.Balance < due) return Conflict(new { message = "校园余额不足。" });
wallet.Balance -= due; AddPaid(bill, item, due); db.TuitionPayments.Add(new TuitionPayment { TuitionBillId = bill.Id, Item = item, Amount = due, Method = TuitionPaymentMethod.Balance, ConfirmedByUserId = userId, ConfirmedAt = DateTime.UtcNow }); await db.SaveChangesAsync(ct); return NoContent();
}
[HttpPost("bills/{billId:guid}/items/{item}/confirm-offline"), Authorize(Roles = Managers)]
public async Task<ActionResult> ConfirmOffline(Guid billId, TuitionFeeItem item, OfflineTuitionPaymentRequest request, CancellationToken ct)
{ var bill = await db.TuitionBills.FindAsync([billId], ct); if (bill is null) return NotFound(); var due = Due(bill, item); if (due <= 0) return Conflict(new { message = "该项目没有待缴金额。" }); AddPaid(bill, item, due); db.TuitionPayments.Add(new TuitionPayment { TuitionBillId = bill.Id, Item = item, Amount = due, Method = TuitionPaymentMethod.Offline, ReferenceNumber = request.ReferenceNumber?.Trim(), ConfirmedByUserId = Guid.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!), ConfirmedAt = DateTime.UtcNow }); await db.SaveChangesAsync(ct); return NoContent(); }
private static decimal Due(TuitionBill b, TuitionFeeItem item) => item switch { TuitionFeeItem.BaseTuition => b.BaseTuition - b.BasePaid, TuitionFeeItem.AccommodationFee => b.AccommodationFee - b.AccommodationPaid, TuitionFeeItem.CreditFee => b.CreditFee - b.CreditPaid, TuitionFeeItem.ReconciliationAdjustment => b.ReconciliationAdjustment - b.AdjustmentPaid, _ => 0 };
private static void AddPaid(TuitionBill b, TuitionFeeItem item, decimal amount) { if (item == TuitionFeeItem.BaseTuition) b.BasePaid += amount; else if (item == TuitionFeeItem.AccommodationFee) b.AccommodationPaid += amount; else if (item == TuitionFeeItem.CreditFee) b.CreditPaid += amount; else b.AdjustmentPaid += amount; }
}
public sealed record TuitionPriceRequest(decimal CreditUnitPrice);
public sealed record ReconcileRequest(decimal Amount, string? Reason);
public sealed record OfflineTuitionPaymentRequest(string? ReferenceNumber);