diff --git a/src/Jiaowu.Api/Controllers/CampusRegistrationsController.cs b/src/Jiaowu.Api/Controllers/CampusRegistrationsController.cs new file mode 100644 index 0000000..c07fb2b --- /dev/null +++ b/src/Jiaowu.Api/Controllers/CampusRegistrationsController.cs @@ -0,0 +1,405 @@ +using System.Data; +using Jiaowu.Api.Contracts; +using Jiaowu.Api.Domain.Academic; +using Jiaowu.Api.Domain.Identity; +using Jiaowu.Api.Infrastructure.Auth; +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/campus-registrations")] +public sealed class CampusRegistrationsController(AppDbContext db, ICurrentUserDataScope scope) : ControllerBase +{ + private const string Managers = SystemRoles.SuperAdmin + "," + SystemRoles.AcademicAdmin + "," + SystemRoles.CollegeAdmin; + + [HttpGet("projects")] + public async Task GetProjects([FromQuery] string? keyword, CancellationToken ct) + { + var source = ScopedProjects().AsNoTracking(); + if (!string.IsNullOrWhiteSpace(keyword)) + source = source.Where(x => x.Name.Contains(keyword) || x.Code.Contains(keyword)); + if (scope.Current.IsInRole(SystemRoles.Student)) + source = source.Where(x => x.Status == CampusRegistrationProjectStatus.Published); + var now = DateTime.UtcNow; + return Ok(await source.OrderByDescending(x => x.RegistrationEndsAt).Select(x => new + { + x.Id, x.Code, x.Name, x.Description, x.RegistrationStartsAt, x.RegistrationEndsAt, + x.Capacity, x.FeeAmount, x.RequiresReview, x.Status, x.ContactName, x.ContactPhone, x.Notes, + CollegeName = x.College == null ? "全校" : x.College.Name, + ConfirmedCount = x.Applications.Count(a => a.Status == CampusRegistrationApplicationStatus.Confirmed), + IsAvailableNow = x.Status == CampusRegistrationProjectStatus.Published && now >= x.RegistrationStartsAt && now <= x.RegistrationEndsAt + }).ToListAsync(ct)); + } + + [HttpPost("projects")] + [Authorize(Roles = Managers)] + public async Task CreateProject(ProjectRequest request, CancellationToken ct) + { + var error = ValidateProject(request); + if (error is not null) return BadRequest(new { message = error }); + if (!CanManageCollege(request.CollegeId)) return Forbid(); + var code = request.Code.Trim().ToUpperInvariant(); + if (await db.CampusRegistrationProjects.AnyAsync(x => x.Code == code, ct)) return Conflict(new { message = "项目编码已存在。" }); + var project = new CampusRegistrationProject + { + Code = code, Name = request.Name.Trim(), Description = TrimOrNull(request.Description), CollegeId = request.CollegeId, + RegistrationStartsAt = request.RegistrationStartsAt, RegistrationEndsAt = request.RegistrationEndsAt, + Capacity = request.Capacity, FeeAmount = request.FeeAmount, RequiresReview = request.RequiresReview, + Status = request.Status, ContactName = TrimOrNull(request.ContactName), ContactPhone = TrimOrNull(request.ContactPhone), + Notes = TrimOrNull(request.Notes), CreatedByUserId = scope.Current.UserId + }; + db.CampusRegistrationProjects.Add(project); + await db.SaveChangesAsync(ct); + return Created($"api/campus-registrations/projects/{project.Id}", new { project.Id }); + } + + [HttpPut("projects/{id:guid}")] + [Authorize(Roles = Managers)] + public async Task UpdateProject(Guid id, ProjectRequest request, CancellationToken ct) + { + var project = await db.CampusRegistrationProjects.FindAsync([id], ct); + if (project is null) return NotFound(); + if (!CanManageProject(project)) return Forbid(); + var error = ValidateProject(request); + if (error is not null) return BadRequest(new { message = error }); + if (!CanManageCollege(request.CollegeId)) return Forbid(); + var code = request.Code.Trim().ToUpperInvariant(); + if (await db.CampusRegistrationProjects.AnyAsync(x => x.Id != id && x.Code == code, ct)) return Conflict(new { message = "项目编码已存在。" }); + project.Code = code; project.Name = request.Name.Trim(); project.Description = TrimOrNull(request.Description); project.CollegeId = request.CollegeId; + project.RegistrationStartsAt = request.RegistrationStartsAt; project.RegistrationEndsAt = request.RegistrationEndsAt; project.Capacity = request.Capacity; + project.FeeAmount = request.FeeAmount; project.RequiresReview = request.RequiresReview; project.Status = request.Status; + project.ContactName = TrimOrNull(request.ContactName); project.ContactPhone = TrimOrNull(request.ContactPhone); project.Notes = TrimOrNull(request.Notes); + await db.SaveChangesAsync(ct); + return NoContent(); + } + + [HttpPost("projects/{id:guid}/apply")] + [Authorize(Roles = SystemRoles.Student)] + public async Task Apply(Guid id, ApplyRequest request, CancellationToken ct) + { + var student = await db.Students.SingleOrDefaultAsync(x => x.UserId == scope.Current.UserId, ct); + if (student is null) return BadRequest(new { message = "当前账号未关联学生档案。" }); + var project = await db.CampusRegistrationProjects.Include(x => x.Applications).SingleOrDefaultAsync(x => x.Id == id, ct); + if (project is null || !CanStudentAccessProject(project, student)) return NotFound(); + var now = DateTime.UtcNow; + if (project.Status != CampusRegistrationProjectStatus.Published || now < project.RegistrationStartsAt || now > project.RegistrationEndsAt) return Conflict(new { message = "当前不在报名时间内。" }); + if (await db.CampusRegistrationApplications.AnyAsync(x => x.ProjectId == id && x.StudentId == student.Id, ct)) return Conflict(new { message = "请勿重复报名。" }); + var confirmed = project.Applications.Count(x => x.Status == CampusRegistrationApplicationStatus.Confirmed); + var status = confirmed >= project.Capacity ? CampusRegistrationApplicationStatus.Waitlisted : project.RequiresReview ? CampusRegistrationApplicationStatus.PendingReview : CampusRegistrationApplicationStatus.PendingPayment; + var application = new CampusRegistrationApplication { ProjectId = id, StudentId = student.Id, Status = status, ApplicantNote = TrimOrNull(request.Note) }; + db.CampusRegistrationApplications.Add(application); + if (status == CampusRegistrationApplicationStatus.PendingPayment) CreateOrder(application, project); + await db.SaveChangesAsync(ct); + return Created($"api/campus-registrations/applications/{application.Id}", new { application.Id, application.Status }); + } + + [HttpGet("mine")] + [Authorize(Roles = SystemRoles.Student)] + public async Task Mine(CancellationToken ct) => Ok(await ApplicationsForCurrentStudent() + .OrderByDescending(x => x.CreatedAt).Select(ProjectApplicationDto()).ToListAsync(ct)); + + [HttpGet("wallet/mine")] + [Authorize(Roles = SystemRoles.Student)] + public async Task GetMyWallet(CancellationToken ct) + { + var wallet = await db.CampusWalletAccounts.AsNoTracking() + .SingleOrDefaultAsync(x => x.Student!.UserId == scope.Current.UserId, ct); + return Ok(new { balance = wallet?.Balance ?? 0m }); + } + + [HttpPost("wallets/students/{studentId:guid}/adjust")] + [Authorize(Roles = Managers)] + public async Task AdjustWallet(Guid studentId, WalletAdjustmentRequest request, CancellationToken ct) + { + if (request.Amount == 0 || string.IsNullOrWhiteSpace(request.Reason)) + return BadRequest(new { message = "请填写非零调整金额和调整原因。" }); + var student = await db.Students.Include(x => x.AdministrativeClass).ThenInclude(x => x!.Major) + .SingleOrDefaultAsync(x => x.Id == studentId, ct); + if (student is null) return NotFound(); + if (!CanManageCollege(student.AdministrativeClass!.Major!.CollegeId)) return Forbid(); + var wallet = await db.CampusWalletAccounts.SingleOrDefaultAsync(x => x.StudentId == studentId, ct); + if (wallet is null) + { + wallet = new CampusWalletAccount { StudentId = studentId, Balance = 0m }; + db.CampusWalletAccounts.Add(wallet); + } + var nextBalance = wallet.Balance + request.Amount; + if (nextBalance < 0) return Conflict(new { message = "调整后余额不能小于零。" }); + wallet.Balance = nextBalance; + db.CampusWalletTransactions.Add(new CampusWalletTransaction + { + WalletAccount = wallet, Amount = request.Amount, BalanceAfter = nextBalance, + Type = CampusWalletTransactionType.AdministratorAdjustment, Reason = request.Reason.Trim(), + OperatedByUserId = scope.Current.UserId + }); + await db.SaveChangesAsync(ct); + return Ok(new { balance = wallet.Balance }); + } + + [HttpGet("wallets/students")] + [Authorize(Roles = Managers)] + public async Task GetStudentWallets( + [FromQuery] string? keyword, + [FromQuery] int? grade, + [FromQuery] Guid? collegeId, + [FromQuery] Guid? majorId, + [FromQuery] Guid? administrativeClassId, + [FromQuery] int page = 1, + [FromQuery] int pageSize = 20, + CancellationToken ct = default) + { + var students = db.Students.AsNoTracking().AsQueryable(); + if (scope.Current.RestrictedCollegeId is { } restrictedCollegeId) + { + if (collegeId.HasValue && collegeId != restrictedCollegeId) return Forbid(); + collegeId = restrictedCollegeId; + } + if (collegeId.HasValue) students = students.Where(x => x.AdministrativeClass!.Major!.CollegeId == collegeId); + if (majorId.HasValue) students = students.Where(x => x.AdministrativeClass!.MajorId == majorId); + if (administrativeClassId.HasValue) students = students.Where(x => x.AdministrativeClassId == administrativeClassId); + if (grade.HasValue) students = students.Where(x => x.AdministrativeClass!.Grade == grade); + if (!string.IsNullOrWhiteSpace(keyword)) + { + var term = keyword.Trim(); + students = students.Where(x => x.StudentNumber.Contains(term) || x.Name.Contains(term)); + } + var total = await students.CountAsync(ct); + page = Math.Max(1, page); pageSize = Math.Clamp(pageSize, 1, 100); + var items = await students.OrderBy(x => x.StudentNumber).Skip((page - 1) * pageSize).Take(pageSize) + .Select(x => new + { + x.Id, x.StudentNumber, x.Name, + CollegeName = x.AdministrativeClass!.Major!.College!.Name, + MajorName = x.AdministrativeClass.Major.Name, + ClassName = x.AdministrativeClass.Name, + Balance = db.CampusWalletAccounts.Where(wallet => wallet.StudentId == x.Id) + .Select(wallet => (decimal?)wallet.Balance).FirstOrDefault() ?? 0m, + LastChangedAt = db.CampusWalletTransactions.Where(transaction => transaction.WalletAccount!.StudentId == x.Id) + .OrderByDescending(transaction => transaction.CreatedAt).Select(transaction => (DateTime?)transaction.CreatedAt).FirstOrDefault() + }).ToListAsync(ct); + return Ok(new PagedResult(items.Cast().ToList(), total, page, pageSize)); + } + + [HttpGet("wallets/filter-options")] + [Authorize(Roles = Managers)] + public async Task GetWalletFilterOptions(CancellationToken ct) + { + var classes = db.AdministrativeClasses.AsNoTracking().AsQueryable(); + if (scope.Current.RestrictedCollegeId is { } collegeId) + classes = classes.Where(x => x.Major!.CollegeId == collegeId); + return Ok(new + { + colleges = await db.Colleges.AsNoTracking() + .Where(x => scope.Current.RestrictedCollegeId == null || x.Id == scope.Current.RestrictedCollegeId) + .OrderBy(x => x.Name).Select(x => new { x.Id, x.Name }).ToListAsync(ct), + majors = await db.Majors.AsNoTracking() + .Where(x => scope.Current.RestrictedCollegeId == null || x.CollegeId == scope.Current.RestrictedCollegeId) + .OrderBy(x => x.Name).Select(x => new { x.Id, x.Name, x.CollegeId }).ToListAsync(ct), + classes = await classes.OrderBy(x => x.Grade).ThenBy(x => x.Name) + .Select(x => new { x.Id, x.Name, x.Grade, x.MajorId }).ToListAsync(ct) + }); + } + + [HttpGet("wallets/adjustment-template.xlsx")] + [Authorize(Roles = Managers)] + public IActionResult DownloadWalletAdjustmentTemplate() => File( + ExcelWorkbookHelper.Create("余额批量调整", ["学号", "调整金额", "调整原因"], [], + ["调整金额:正数充值,负数扣减;调整后余额不得小于零。", "每行都会生成独立余额流水,调整原因必填。"]), + ExcelWorkbookHelper.ContentType, DownloadFileName.Template("学生余额批量调整", ".xlsx")); + + [HttpGet("wallets/export.xlsx")] + [Authorize(Roles = Managers)] + public async Task ExportWallets(CancellationToken ct) + { + var students = db.Students.AsNoTracking().AsQueryable(); + if (scope.Current.RestrictedCollegeId is { } collegeId) + students = students.Where(x => x.AdministrativeClass!.Major!.CollegeId == collegeId); + var rows = await students.OrderBy(x => x.StudentNumber).Select(x => new { x.StudentNumber, x.Name, CollegeName = x.AdministrativeClass!.Major!.College!.Name, MajorName = x.AdministrativeClass.Major.Name, ClassName = x.AdministrativeClass.Name, Balance = db.CampusWalletAccounts.Where(a => a.StudentId == x.Id).Select(a => (decimal?)a.Balance).FirstOrDefault() ?? 0m }).ToListAsync(ct); + return File(ExcelWorkbookHelper.Create("学生余额", ["学号", "姓名", "学院", "专业", "行政班", "当前余额"], rows.Select(x => (IReadOnlyList)[x.StudentNumber, x.Name, x.CollegeName, x.MajorName, x.ClassName, x.Balance])), ExcelWorkbookHelper.ContentType, DownloadFileName.Export("学生余额", ".xlsx")); + } + + [HttpPost("wallets/import-adjustments")] + [Authorize(Roles = Managers)] + public async Task ImportWalletAdjustments(IFormFile file, CancellationToken ct) + { + IReadOnlyList rows; + try { rows = await ExcelWorkbookHelper.ReadAsync(file, ["学号", "调整金额", "调整原因"], ct); } + catch (InvalidDataException ex) { return BadRequest(new { message = ex.Message }); } + var errors = new List(); var inputs = new List<(string Number, decimal Amount, string Reason)>(); + foreach (var row in rows) { if (!decimal.TryParse(row["调整金额"], out var amount) || amount == 0 || string.IsNullOrWhiteSpace(row["学号"]) || string.IsNullOrWhiteSpace(row["调整原因"])) errors.Add($"第 {row.RowNumber} 行:请填写学号、非零调整金额和调整原因。"); else inputs.Add((row["学号"], amount, row["调整原因"].Trim())); } + var students = await db.Students.Include(x => x.AdministrativeClass).ThenInclude(x => x!.Major).Where(x => inputs.Select(i => i.Number).Contains(x.StudentNumber)).ToDictionaryAsync(x => x.StudentNumber, ct); + foreach (var input in inputs) if (!students.TryGetValue(input.Number, out var student) || !CanManageCollege(student.AdministrativeClass!.Major!.CollegeId)) errors.Add($"学号 {input.Number} 不存在或不在当前管理范围内。"); + if (errors.Count > 0) return BadRequest(new { message = string.Join(";", errors.Take(20)) }); + var accounts = await db.CampusWalletAccounts.Where(x => inputs.Select(i => students[i.Number].Id).Contains(x.StudentId)).ToDictionaryAsync(x => x.StudentId, ct); + foreach (var input in inputs) { var student = students[input.Number]; if (!accounts.TryGetValue(student.Id, out var account)) { account = new CampusWalletAccount { StudentId = student.Id }; db.CampusWalletAccounts.Add(account); accounts[student.Id] = account; } var balance = account.Balance + input.Amount; if (balance < 0) { errors.Add($"学号 {input.Number} 调整后余额不能小于零。"); continue; } account.Balance = balance; db.CampusWalletTransactions.Add(new CampusWalletTransaction { WalletAccount = account, Amount = input.Amount, BalanceAfter = balance, Type = CampusWalletTransactionType.AdministratorAdjustment, Reason = input.Reason, OperatedByUserId = scope.Current.UserId }); } + if (errors.Count > 0) return BadRequest(new { message = string.Join(";", errors) }); + await db.SaveChangesAsync(ct); return Ok(new { adjusted = inputs.Count }); + } + + [HttpPost("applications/{id:guid}/cancel")] + [Authorize(Roles = SystemRoles.Student)] + public async Task Cancel(Guid id, CancellationToken ct) + { + var application = await db.CampusRegistrationApplications.Include(x => x.Project).Include(x => x.PaymentOrder).SingleOrDefaultAsync(x => x.Id == id && x.Student!.UserId == scope.Current.UserId, ct); + if (application is null) return NotFound(); + if (application.Status is CampusRegistrationApplicationStatus.Confirmed or CampusRegistrationApplicationStatus.Rejected or CampusRegistrationApplicationStatus.Cancelled) return Conflict(new { message = "当前报名不能撤销。" }); + application.Status = CampusRegistrationApplicationStatus.Cancelled; application.CancelledAt = DateTime.UtcNow; + if (application.PaymentOrder?.Status == CampusPaymentOrderStatus.Pending) application.PaymentOrder.Status = CampusPaymentOrderStatus.Cancelled; + try + { + await db.SaveChangesAsync(ct); + return NoContent(); + } + catch (DbUpdateConcurrencyException) + { + return Conflict(new { message = "该报名已被其他管理员处理,请刷新后查看最新状态。" }); + } + } + + [HttpGet("applications")] + [Authorize(Roles = Managers)] + public async Task GetApplications([FromQuery] Guid? projectId, [FromQuery] CampusRegistrationApplicationStatus? status, CancellationToken ct) + { + var source = ScopedApplications().AsNoTracking(); + if (projectId.HasValue) source = source.Where(x => x.ProjectId == projectId); + if (status.HasValue) source = source.Where(x => x.Status == status); + return Ok(await source.OrderByDescending(x => x.CreatedAt).Select(ProjectApplicationDto()).ToListAsync(ct)); + } + + [HttpGet("applications/export.xlsx")] + [Authorize(Roles = Managers)] + public async Task ExportApplications([FromQuery] Guid? projectId, CancellationToken ct) + { + var rows = await ScopedApplications().AsNoTracking().Where(x => !projectId.HasValue || x.ProjectId == projectId).OrderByDescending(x => x.CreatedAt) + .Select(x => new { x.Project!.Code, ProjectName = x.Project.Name, x.Student!.StudentNumber, StudentName = x.Student.Name, Status = x.Status.ToString(), x.CreatedAt, Amount = x.PaymentOrder == null ? 0m : x.PaymentOrder.Amount, PaymentStatus = x.PaymentOrder == null ? "—" : x.PaymentOrder.Status.ToString(), x.ReviewComment }).ToListAsync(ct); + var content = ExcelWorkbookHelper.Create("校园报名与缴费", ["项目编码", "项目名称", "学号", "姓名", "报名状态", "提交时间", "应收金额", "支付状态", "审核意见"], rows.Select(x => (IReadOnlyList)[x.Code, x.ProjectName, x.StudentNumber, x.StudentName, x.Status, x.CreatedAt, x.Amount, x.PaymentStatus, x.ReviewComment]).ToList()); + return File(content, ExcelWorkbookHelper.ContentType, DownloadFileName.Export("校园报名与缴费", ".xlsx")); + } + + [HttpPost("applications/{id:guid}/review")] + [Authorize(Roles = Managers)] + public async Task Review(Guid id, ReviewRequest request, CancellationToken ct) + { + var application = await db.CampusRegistrationApplications.Include(x => x.Project).SingleOrDefaultAsync(x => x.Id == id, ct); + if (application is null) return NotFound(); if (!CanManageProject(application.Project!)) return Forbid(); + if (application.Status != CampusRegistrationApplicationStatus.PendingReview) return Conflict(new { message = "该报名当前无需审核。" }); + application.ReviewedByUserId = scope.Current.UserId; application.ReviewedAt = DateTime.UtcNow; application.ReviewComment = TrimOrNull(request.Comment); + if (!request.Approved) application.Status = CampusRegistrationApplicationStatus.Rejected; + else if (await ConfirmedCount(application.ProjectId, ct) >= application.Project!.Capacity) application.Status = CampusRegistrationApplicationStatus.Waitlisted; + else { application.Status = CampusRegistrationApplicationStatus.PendingPayment; CreateOrder(application, application.Project!); } + try + { + await db.SaveChangesAsync(ct); + return NoContent(); + } + catch (DbUpdateConcurrencyException) + { + return Conflict(new { message = "该报名已被其他管理员处理,请刷新后查看最新状态。" }); + } + } + + [HttpPost("orders/{id:guid}/confirm-offline")] + [Authorize(Roles = Managers)] + public async Task ConfirmOffline(Guid id, OfflinePaymentRequest request, CancellationToken ct) + { + var order = await db.CampusPaymentOrders.Include(x => x.Application).ThenInclude(x => x!.Project).SingleOrDefaultAsync(x => x.Id == id, ct); + if (order is null) return NotFound(); if (!CanManageProject(order.Application!.Project!)) return Forbid(); + if (order.Status != CampusPaymentOrderStatus.Pending || order.PaymentMethod != CampusPaymentMethod.Offline) return Conflict(new { message = "该订单不能线下确认。" }); + if (await ConfirmedCount(order.Application.ProjectId, ct) >= order.Application.Project!.Capacity) { order.Application.Status = CampusRegistrationApplicationStatus.Waitlisted; await db.SaveChangesAsync(ct); return Conflict(new { message = "名额已满,报名已转为候补。" }); } + order.Status = CampusPaymentOrderStatus.Paid; order.ConfirmedAt = DateTime.UtcNow; order.ConfirmedByUserId = scope.Current.UserId; order.ReferenceNumber = TrimOrNull(request.ReferenceNumber); order.Note = TrimOrNull(request.Note); + order.Application.Status = CampusRegistrationApplicationStatus.Confirmed; order.Application.ConfirmedAt = DateTime.UtcNow; + try + { + await db.SaveChangesAsync(ct); + return NoContent(); + } + catch (DbUpdateConcurrencyException) + { + return Conflict(new { message = "该订单已被其他管理员处理,请刷新后查看最新状态。" }); + } + } + + [HttpPost("orders/{id:guid}/pay-balance")] + [Authorize(Roles = SystemRoles.Student)] + public async Task PayByBalance(Guid id, CancellationToken ct) + { + var order = await db.CampusPaymentOrders.Include(x => x.Application).ThenInclude(x => x!.Project) + .SingleOrDefaultAsync(x => x.Id == id && x.Application!.Student!.UserId == scope.Current.UserId, ct); + if (order is null) return NotFound(); + if (order.Status != CampusPaymentOrderStatus.Pending || order.Application!.Status != CampusRegistrationApplicationStatus.PendingPayment) + return Conflict(new { message = "该订单当前不能使用余额支付。" }); + if (await ConfirmedCount(order.Application.ProjectId, ct) >= order.Application.Project!.Capacity) + { + order.Application.Status = CampusRegistrationApplicationStatus.Waitlisted; + await db.SaveChangesAsync(ct); + return Conflict(new { message = "名额已满,报名已转为候补。" }); + } + await using var transaction = await db.Database.BeginTransactionAsync(IsolationLevel.Serializable, ct); + var debited = await db.CampusWalletAccounts + .Where(x => x.StudentId == order.Application.StudentId && x.Balance >= order.Amount) + .ExecuteUpdateAsync(setters => setters + .SetProperty(x => x.Balance, x => x.Balance - order.Amount) + .SetProperty(x => x.UpdatedAt, _ => DateTime.UtcNow), ct); + if (debited == 0) + { + await transaction.RollbackAsync(ct); + return Conflict(new { message = "余额不足,请联系管理员调整余额或选择线下缴费。" }); + } + var balanceAfter = await db.CampusWalletAccounts.AsNoTracking() + .Where(x => x.StudentId == order.Application.StudentId) + .Select(x => x.Balance).SingleAsync(ct); + db.CampusWalletTransactions.Add(new CampusWalletTransaction + { + WalletAccountId = await db.CampusWalletAccounts.Where(x => x.StudentId == order.Application.StudentId) + .Select(x => x.Id).SingleAsync(ct), Amount = -order.Amount, BalanceAfter = balanceAfter, + Type = CampusWalletTransactionType.Payment, Reason = $"支付报名项目:{order.Application.Project!.Name}", + PaymentOrderId = order.Id, OperatedByUserId = scope.Current.UserId + }); + order.Status = CampusPaymentOrderStatus.Paid; order.PaymentMethod = CampusPaymentMethod.Balance; + order.ConfirmedAt = DateTime.UtcNow; order.ConfirmedByUserId = scope.Current.UserId; + order.Application.Status = CampusRegistrationApplicationStatus.Confirmed; order.Application.ConfirmedAt = DateTime.UtcNow; + try { await db.SaveChangesAsync(ct); await transaction.CommitAsync(ct); return NoContent(); } + catch (DbUpdateConcurrencyException) { return Conflict(new { message = "订单或余额已变化,请刷新后重试。" }); } + catch (DbUpdateException) { return Conflict(new { message = "该订单已完成支付或余额已变化,请刷新后查看最新状态。" }); } + } + + private IQueryable ScopedProjects() => scope.Current.RestrictedCollegeId is { } collegeId ? db.CampusRegistrationProjects.Where(x => x.CollegeId == null || x.CollegeId == collegeId) : db.CampusRegistrationProjects; + private IQueryable ScopedApplications() => scope.Current.RestrictedCollegeId is { } collegeId ? db.CampusRegistrationApplications.Where(x => x.Project!.CollegeId == null || x.Project.CollegeId == collegeId) : db.CampusRegistrationApplications; + private IQueryable ApplicationsForCurrentStudent() => db.CampusRegistrationApplications.Where(x => x.Student!.UserId == scope.Current.UserId); + private bool CanManageCollege(Guid? collegeId) => scope.Current.RestrictedCollegeId is not { } restricted || collegeId is null || collegeId == restricted; + private bool CanManageProject(CampusRegistrationProject project) => CanManageCollege(project.CollegeId); + private static bool CanStudentAccessProject(CampusRegistrationProject project, Student student) => project.CollegeId is null || project.CollegeId == student.AdministrativeClass!.Major!.CollegeId; + private Task ConfirmedCount(Guid projectId, CancellationToken ct) => db.CampusRegistrationApplications.CountAsync(x => x.ProjectId == projectId && x.Status == CampusRegistrationApplicationStatus.Confirmed, ct); + private void CreateOrder(CampusRegistrationApplication application, CampusRegistrationProject project) + { + var free = project.FeeAmount == 0; + var order = new CampusPaymentOrder + { + ApplicationId = application.Id, + OrderNumber = $"CR{DateTime.UtcNow:yyyyMMddHHmmss}{Guid.NewGuid():N}"[..38], + Amount = project.FeeAmount, + PaymentMethod = free ? CampusPaymentMethod.Free : CampusPaymentMethod.Offline, + Status = free ? CampusPaymentOrderStatus.Paid : CampusPaymentOrderStatus.Pending, + ConfirmedAt = free ? DateTime.UtcNow : null, + ConfirmedByUserId = free ? scope.Current.UserId : null + }; + db.CampusPaymentOrders.Add(order); + if (free) { application.Status = CampusRegistrationApplicationStatus.Confirmed; application.ConfirmedAt = DateTime.UtcNow; } + } + private static string? ValidateProject(ProjectRequest r) => string.IsNullOrWhiteSpace(r.Code) || r.Code.Trim().Length > 40 ? "请填写不超过 40 字的项目编码。" : string.IsNullOrWhiteSpace(r.Name) || r.Name.Trim().Length > 120 ? "请填写不超过 120 字的项目名称。" : r.RegistrationEndsAt <= r.RegistrationStartsAt ? "报名结束时间必须晚于开始时间。" : r.Capacity < 1 ? "名额必须至少为 1。" : r.FeeAmount < 0 ? "金额不能为负数。" : null; + private static string? TrimOrNull(string? value) => string.IsNullOrWhiteSpace(value) ? null : value.Trim(); + private static System.Linq.Expressions.Expression> ProjectApplicationDto() => x => new { x.Id, x.ProjectId, ProjectCode = x.Project!.Code, ProjectName = x.Project.Name, x.Status, x.ApplicantNote, x.CreatedAt, x.ReviewComment, x.ConfirmedAt, x.CancelledAt, StudentId = x.StudentId, StudentNumber = x.Student!.StudentNumber, StudentName = x.Student.Name, Order = x.PaymentOrder == null ? null : new { x.PaymentOrder.Id, x.PaymentOrder.OrderNumber, x.PaymentOrder.Amount, x.PaymentOrder.PaymentMethod, x.PaymentOrder.Status, x.PaymentOrder.ReferenceNumber } }; +} + +public sealed record ProjectRequest(string Code, string Name, string? Description, Guid? CollegeId, DateTime RegistrationStartsAt, DateTime RegistrationEndsAt, int Capacity, decimal FeeAmount, bool RequiresReview, CampusRegistrationProjectStatus Status, string? ContactName, string? ContactPhone, string? Notes); +public sealed record ApplyRequest(string? Note); +public sealed record ReviewRequest(bool Approved, string? Comment); +public sealed record OfflinePaymentRequest(string? ReferenceNumber, string? Note); +public sealed record WalletAdjustmentRequest(decimal Amount, string? Reason); diff --git a/src/Jiaowu.Api/Controllers/TuitionController.cs b/src/Jiaowu.Api/Controllers/TuitionController.cs new file mode 100644 index 0000000..4bade2a --- /dev/null +++ b/src/Jiaowu.Api/Controllers/TuitionController.cs @@ -0,0 +1,105 @@ +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 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 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 Import(Guid termId, IFormFile file, CancellationToken ct) + { + IReadOnlyList 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 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 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 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 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 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 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 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); diff --git a/src/Jiaowu.Api/Domain/Academic/CampusRegistrationEntities.cs b/src/Jiaowu.Api/Domain/Academic/CampusRegistrationEntities.cs new file mode 100644 index 0000000..1e437a7 --- /dev/null +++ b/src/Jiaowu.Api/Domain/Academic/CampusRegistrationEntities.cs @@ -0,0 +1,85 @@ +using Jiaowu.Api.Domain.Common; +using Jiaowu.Api.Domain.Identity; + +namespace Jiaowu.Api.Domain.Academic; + +public sealed class CampusRegistrationProject : EntityBase +{ + public required string Code { get; set; } + public required string Name { get; set; } + public string? Description { get; set; } + public Guid? CollegeId { get; set; } + public College? College { get; set; } + public DateTime RegistrationStartsAt { get; set; } + public DateTime RegistrationEndsAt { get; set; } + public int Capacity { get; set; } + public decimal FeeAmount { get; set; } + public bool RequiresReview { get; set; } + public CampusRegistrationProjectStatus Status { get; set; } = CampusRegistrationProjectStatus.Draft; + public string? ContactName { get; set; } + public string? ContactPhone { get; set; } + public string? Notes { get; set; } + public Guid CreatedByUserId { get; set; } + public ApplicationUser? CreatedByUser { get; set; } + public ICollection Applications { get; set; } = []; +} + +public sealed class CampusRegistrationApplication : EntityBase +{ + public Guid ProjectId { get; set; } + public CampusRegistrationProject? Project { get; set; } + public Guid StudentId { get; set; } + public Student? Student { get; set; } + public CampusRegistrationApplicationStatus Status { get; set; } + public string? ApplicantNote { get; set; } + public Guid? ReviewedByUserId { get; set; } + public ApplicationUser? ReviewedByUser { get; set; } + public DateTime? ReviewedAt { get; set; } + public string? ReviewComment { get; set; } + public DateTime? ConfirmedAt { get; set; } + public DateTime? CancelledAt { get; set; } + public CampusPaymentOrder? PaymentOrder { get; set; } +} + +public sealed class CampusPaymentOrder : EntityBase +{ + public Guid ApplicationId { get; set; } + public CampusRegistrationApplication? Application { get; set; } + public required string OrderNumber { get; set; } + public decimal Amount { get; set; } + public CampusPaymentMethod PaymentMethod { get; set; } + public CampusPaymentOrderStatus Status { get; set; } = CampusPaymentOrderStatus.Pending; + public Guid? ConfirmedByUserId { get; set; } + public ApplicationUser? ConfirmedByUser { get; set; } + public DateTime? ConfirmedAt { get; set; } + public string? ReferenceNumber { get; set; } + public string? Note { get; set; } +} + +public sealed class CampusWalletAccount : EntityBase +{ + public Guid StudentId { get; set; } + public Student? Student { get; set; } + public decimal Balance { get; set; } + public ICollection Transactions { get; set; } = []; +} + +public sealed class CampusWalletTransaction : EntityBase +{ + public Guid WalletAccountId { get; set; } + public CampusWalletAccount? WalletAccount { get; set; } + public decimal Amount { get; set; } + public decimal BalanceAfter { get; set; } + public CampusWalletTransactionType Type { get; set; } + public required string Reason { get; set; } + public Guid? PaymentOrderId { get; set; } + public CampusPaymentOrder? PaymentOrder { get; set; } + public Guid? OperatedByUserId { get; set; } + public ApplicationUser? OperatedByUser { get; set; } +} + +public enum CampusRegistrationProjectStatus { Draft = 1, Published = 2, Closed = 3 } +public enum CampusRegistrationApplicationStatus { PendingReview = 1, PendingPayment = 2, Confirmed = 3, Waitlisted = 4, Rejected = 5, Cancelled = 6 } +public enum CampusPaymentMethod { Free = 1, Offline = 2, Balance = 3 } +public enum CampusPaymentOrderStatus { Pending = 1, Paid = 2, Cancelled = 3 } +public enum CampusWalletTransactionType { AdministratorAdjustment = 1, Payment = 2 } diff --git a/src/Jiaowu.Api/Domain/Academic/TuitionEntities.cs b/src/Jiaowu.Api/Domain/Academic/TuitionEntities.cs new file mode 100644 index 0000000..be9f576 --- /dev/null +++ b/src/Jiaowu.Api/Domain/Academic/TuitionEntities.cs @@ -0,0 +1,47 @@ +using Jiaowu.Api.Domain.Common; + +namespace Jiaowu.Api.Domain.Academic; + +public sealed class TuitionTermSetting : EntityBase +{ + public Guid AcademicTermId { get; set; } + public AcademicTerm? AcademicTerm { get; set; } + public decimal CreditUnitPrice { get; set; } + public TuitionTermStatus Status { get; set; } = TuitionTermStatus.Draft; + public DateTime? CreditsLockedAt { get; set; } + public Guid? CreditsLockedByUserId { get; set; } +} + +public sealed class TuitionBill : EntityBase +{ + public Guid AcademicTermId { get; set; } + public AcademicTerm? AcademicTerm { get; set; } + public Guid StudentId { get; set; } + public Student? Student { get; set; } + public decimal BaseTuition { get; set; } + public decimal AccommodationFee { get; set; } + public decimal CreditCount { get; set; } + public decimal CreditFee { get; set; } + public decimal ReconciliationAdjustment { get; set; } + public decimal BasePaid { get; set; } + public decimal AccommodationPaid { get; set; } + public decimal CreditPaid { get; set; } + public decimal AdjustmentPaid { get; set; } + public ICollection Payments { get; set; } = []; +} + +public sealed class TuitionPayment : EntityBase +{ + public Guid TuitionBillId { get; set; } + public TuitionBill? TuitionBill { get; set; } + public TuitionFeeItem Item { get; set; } + public decimal Amount { get; set; } + public TuitionPaymentMethod Method { get; set; } + public string? ReferenceNumber { get; set; } + public Guid ConfirmedByUserId { get; set; } + public DateTime ConfirmedAt { get; set; } +} + +public enum TuitionTermStatus { Draft = 1, CreditsLocked = 2, OpenForPayment = 3, Reconciliation = 4, Closed = 5 } +public enum TuitionFeeItem { BaseTuition = 1, CreditFee = 2, AccommodationFee = 3, ReconciliationAdjustment = 4 } +public enum TuitionPaymentMethod { Offline = 1, Balance = 2 } diff --git a/src/Jiaowu.Api/Infrastructure/Persistence/AppDbContext.cs b/src/Jiaowu.Api/Infrastructure/Persistence/AppDbContext.cs index ed4a82b..6fee540 100644 --- a/src/Jiaowu.Api/Infrastructure/Persistence/AppDbContext.cs +++ b/src/Jiaowu.Api/Infrastructure/Persistence/AppDbContext.cs @@ -55,6 +55,16 @@ public sealed class AppDbContext(DbContextOptions options) Set(); public DbSet ClassroomReservations => Set(); + public DbSet CampusRegistrationProjects => + Set(); + public DbSet CampusRegistrationApplications => + Set(); + public DbSet CampusPaymentOrders => Set(); + public DbSet CampusWalletAccounts => Set(); + public DbSet CampusWalletTransactions => Set(); + public DbSet TuitionTermSettings => Set(); + public DbSet TuitionBills => Set(); + public DbSet TuitionPayments => Set(); public DbSet ExperimentProjects => Set(); public DbSet ExperimentSessions => Set(); public DbSet ExperimentSessionInstructors => @@ -1495,6 +1505,78 @@ public sealed class AppDbContext(DbContextOptions options) entity.HasOne(x => x.Student).WithMany() .HasForeignKey(x => x.StudentId).OnDelete(DeleteBehavior.Restrict); }); + + builder.Entity(entity => + { + entity.Property(x => x.Code).HasMaxLength(40); + entity.Property(x => x.Name).HasMaxLength(120); + entity.Property(x => x.Description).HasMaxLength(2000); + entity.Property(x => x.FeeAmount).HasPrecision(10, 2); + entity.Property(x => x.ContactName).HasMaxLength(50); + entity.Property(x => x.ContactPhone).HasMaxLength(30); + entity.Property(x => x.Notes).HasMaxLength(500); + entity.HasIndex(x => x.Code).IsUnique(); + entity.HasIndex(x => new { x.CollegeId, x.Status, x.RegistrationEndsAt }); + entity.HasOne(x => x.College).WithMany().HasForeignKey(x => x.CollegeId) + .OnDelete(DeleteBehavior.SetNull); + entity.HasOne(x => x.CreatedByUser).WithMany().HasForeignKey(x => x.CreatedByUserId) + .OnDelete(DeleteBehavior.Restrict); + }); + + builder.Entity(entity => + { + entity.Property(x => x.ApplicantNote).HasMaxLength(1000); + entity.Property(x => x.ReviewComment).HasMaxLength(500); + entity.HasIndex(x => new { x.ProjectId, x.StudentId }).IsUnique(); + entity.HasIndex(x => new { x.ProjectId, x.Status, x.CreatedAt }); + entity.HasOne(x => x.Project).WithMany(x => x.Applications).HasForeignKey(x => x.ProjectId) + .OnDelete(DeleteBehavior.Cascade); + entity.HasOne(x => x.Student).WithMany().HasForeignKey(x => x.StudentId) + .OnDelete(DeleteBehavior.Restrict); + entity.HasOne(x => x.ReviewedByUser).WithMany().HasForeignKey(x => x.ReviewedByUserId) + .OnDelete(DeleteBehavior.SetNull); + }); + + builder.Entity(entity => + { + entity.Property(x => x.OrderNumber).HasMaxLength(40); + entity.Property(x => x.Amount).HasPrecision(10, 2); + entity.Property(x => x.ReferenceNumber).HasMaxLength(100); + entity.Property(x => x.Note).HasMaxLength(500); + entity.HasIndex(x => x.OrderNumber).IsUnique(); + entity.HasIndex(x => new { x.Status, x.CreatedAt }); + entity.HasOne(x => x.Application).WithOne(x => x.PaymentOrder).HasForeignKey(x => x.ApplicationId) + .OnDelete(DeleteBehavior.Cascade); + entity.HasOne(x => x.ConfirmedByUser).WithMany().HasForeignKey(x => x.ConfirmedByUserId) + .OnDelete(DeleteBehavior.SetNull); + }); + + builder.Entity(entity => + { + entity.Property(x => x.Balance).HasPrecision(10, 2); + entity.HasIndex(x => x.StudentId).IsUnique(); + entity.HasOne(x => x.Student).WithMany().HasForeignKey(x => x.StudentId) + .OnDelete(DeleteBehavior.Restrict); + }); + + builder.Entity(entity => + { + entity.Property(x => x.Amount).HasPrecision(10, 2); + entity.Property(x => x.BalanceAfter).HasPrecision(10, 2); + entity.Property(x => x.Reason).HasMaxLength(500); + entity.HasIndex(x => new { x.WalletAccountId, x.CreatedAt }); + entity.HasIndex(x => x.PaymentOrderId).IsUnique(); + entity.HasOne(x => x.WalletAccount).WithMany(x => x.Transactions).HasForeignKey(x => x.WalletAccountId) + .OnDelete(DeleteBehavior.Cascade); + entity.HasOne(x => x.PaymentOrder).WithMany().HasForeignKey(x => x.PaymentOrderId) + .OnDelete(DeleteBehavior.Restrict); + entity.HasOne(x => x.OperatedByUser).WithMany().HasForeignKey(x => x.OperatedByUserId) + .OnDelete(DeleteBehavior.SetNull); + }); + + builder.Entity(entity => { entity.Property(x => x.CreditUnitPrice).HasPrecision(10, 2); entity.HasIndex(x => x.AcademicTermId).IsUnique(); entity.HasOne(x => x.AcademicTerm).WithMany().HasForeignKey(x => x.AcademicTermId).OnDelete(DeleteBehavior.Restrict); }); + builder.Entity(entity => { entity.Property(x => x.BaseTuition).HasPrecision(10, 2); entity.Property(x => x.AccommodationFee).HasPrecision(10, 2); entity.Property(x => x.CreditCount).HasPrecision(8, 2); entity.Property(x => x.CreditFee).HasPrecision(10, 2); entity.Property(x => x.ReconciliationAdjustment).HasPrecision(10, 2); entity.Property(x => x.BasePaid).HasPrecision(10, 2); entity.Property(x => x.AccommodationPaid).HasPrecision(10, 2); entity.Property(x => x.CreditPaid).HasPrecision(10, 2); entity.Property(x => x.AdjustmentPaid).HasPrecision(10, 2); entity.HasIndex(x => new { x.AcademicTermId, x.StudentId }).IsUnique(); entity.HasOne(x => x.AcademicTerm).WithMany().HasForeignKey(x => x.AcademicTermId).OnDelete(DeleteBehavior.Restrict); entity.HasOne(x => x.Student).WithMany().HasForeignKey(x => x.StudentId).OnDelete(DeleteBehavior.Restrict); }); + builder.Entity(entity => { entity.Property(x => x.Amount).HasPrecision(10, 2); entity.Property(x => x.ReferenceNumber).HasMaxLength(100); entity.HasIndex(x => new { x.TuitionBillId, x.Item, x.CreatedAt }); entity.HasOne(x => x.TuitionBill).WithMany(x => x.Payments).HasForeignKey(x => x.TuitionBillId).OnDelete(DeleteBehavior.Cascade); }); builder.Entity(entity => { entity.HasIndex(x => x.WarningRecordId).IsUnique(); diff --git a/src/Jiaowu.Api/Infrastructure/Persistence/DevelopmentSqliteMigrator.cs b/src/Jiaowu.Api/Infrastructure/Persistence/DevelopmentSqliteMigrator.cs index 147fd20..6dd2148 100644 --- a/src/Jiaowu.Api/Infrastructure/Persistence/DevelopmentSqliteMigrator.cs +++ b/src/Jiaowu.Api/Infrastructure/Persistence/DevelopmentSqliteMigrator.cs @@ -132,6 +132,11 @@ public sealed class DevelopmentSqliteMigrator( "20260819_65_venue_display_announcements"; private const string GradeAnalyticsCommentsMigration = "20260822_66_grade_analytics_comments"; + private const string CampusRegistrationPaymentsMigration = + "20260822_67_campus_registration_payments"; + private const string CampusWalletBalancesMigration = + "20260822_68_campus_wallet_balances"; + private const string TuitionBillingMigration = "20260822_69_tuition_billing"; public async Task MigrateAsync(CancellationToken cancellationToken = default) { @@ -939,6 +944,18 @@ public sealed class DevelopmentSqliteMigrator( GradeAnalyticsCommentsMigration, gradeAnalyticsCommentsExist ? [] : GradeAnalyticsCommentStatements, cancellationToken); + var campusRegistrationProjectsExist = await db.Database.SqlQueryRaw( + "SELECT COUNT(*) AS \"Value\" FROM sqlite_master WHERE type = 'table' AND name = 'CampusRegistrationProjects'") + .AnyAsync(value => value > 0, cancellationToken); + await ApplyMigrationAsync(CampusRegistrationPaymentsMigration, + campusRegistrationProjectsExist ? [] : CampusRegistrationPaymentStatements, cancellationToken); + var campusWalletAccountsExist = await db.Database.SqlQueryRaw( + "SELECT COUNT(*) AS \"Value\" FROM sqlite_master WHERE type = 'table' AND name = 'CampusWalletAccounts'") + .AnyAsync(value => value > 0, cancellationToken); + await ApplyMigrationAsync(CampusWalletBalancesMigration, + campusWalletAccountsExist ? [] : CampusWalletBalanceStatements, cancellationToken); + var tuitionExists = await db.Database.SqlQueryRaw("SELECT COUNT(*) AS \"Value\" FROM sqlite_master WHERE type = 'table' AND name = 'TuitionBills'").AnyAsync(value => value > 0, cancellationToken); + await ApplyMigrationAsync(TuitionBillingMigration, tuitionExists ? [] : TuitionBillingStatements, cancellationToken); } private async Task ApplyMigrationAsync( @@ -3537,6 +3554,39 @@ public sealed class DevelopmentSqliteMigrator( "CREATE INDEX \"IX_GradeAnalyticsComments_AuthorUserId\" ON \"GradeAnalyticsComments\" (\"AuthorUserId\");" ]; + private static readonly string[] CampusRegistrationPaymentStatements = + [ + """CREATE TABLE "CampusRegistrationProjects" ("Id" TEXT NOT NULL CONSTRAINT "PK_CampusRegistrationProjects" PRIMARY KEY, "Code" TEXT NOT NULL, "Name" TEXT NOT NULL, "Description" TEXT NULL, "CollegeId" TEXT NULL, "RegistrationStartsAt" TEXT NOT NULL, "RegistrationEndsAt" TEXT NOT NULL, "Capacity" INTEGER NOT NULL, "FeeAmount" TEXT NOT NULL, "RequiresReview" INTEGER NOT NULL, "Status" INTEGER NOT NULL, "ContactName" TEXT NULL, "ContactPhone" TEXT NULL, "Notes" TEXT NULL, "CreatedByUserId" TEXT NOT NULL, "CreatedAt" TEXT NOT NULL, "UpdatedAt" TEXT NOT NULL, CONSTRAINT "FK_CampusRegistrationProjects_Colleges_CollegeId" FOREIGN KEY ("CollegeId") REFERENCES "Colleges" ("Id") ON DELETE SET NULL, CONSTRAINT "FK_CampusRegistrationProjects_AspNetUsers_CreatedByUserId" FOREIGN KEY ("CreatedByUserId") REFERENCES "AspNetUsers" ("Id") ON DELETE RESTRICT);""", + """CREATE TABLE "CampusRegistrationApplications" ("Id" TEXT NOT NULL CONSTRAINT "PK_CampusRegistrationApplications" PRIMARY KEY, "ProjectId" TEXT NOT NULL, "StudentId" TEXT NOT NULL, "Status" INTEGER NOT NULL, "ApplicantNote" TEXT NULL, "ReviewedByUserId" TEXT NULL, "ReviewedAt" TEXT NULL, "ReviewComment" TEXT NULL, "ConfirmedAt" TEXT NULL, "CancelledAt" TEXT NULL, "CreatedAt" TEXT NOT NULL, "UpdatedAt" TEXT NOT NULL, CONSTRAINT "FK_CampusRegistrationApplications_ProjectId" FOREIGN KEY ("ProjectId") REFERENCES "CampusRegistrationProjects" ("Id") ON DELETE CASCADE, CONSTRAINT "FK_CampusRegistrationApplications_StudentId" FOREIGN KEY ("StudentId") REFERENCES "Students" ("Id") ON DELETE RESTRICT, CONSTRAINT "FK_CampusRegistrationApplications_ReviewedByUserId" FOREIGN KEY ("ReviewedByUserId") REFERENCES "AspNetUsers" ("Id") ON DELETE SET NULL);""", + """CREATE TABLE "CampusPaymentOrders" ("Id" TEXT NOT NULL CONSTRAINT "PK_CampusPaymentOrders" PRIMARY KEY, "ApplicationId" TEXT NOT NULL, "OrderNumber" TEXT NOT NULL, "Amount" TEXT NOT NULL, "PaymentMethod" INTEGER NOT NULL, "Status" INTEGER NOT NULL, "ConfirmedByUserId" TEXT NULL, "ConfirmedAt" TEXT NULL, "ReferenceNumber" TEXT NULL, "Note" TEXT NULL, "CreatedAt" TEXT NOT NULL, "UpdatedAt" TEXT NOT NULL, CONSTRAINT "FK_CampusPaymentOrders_ApplicationId" FOREIGN KEY ("ApplicationId") REFERENCES "CampusRegistrationApplications" ("Id") ON DELETE CASCADE, CONSTRAINT "FK_CampusPaymentOrders_ConfirmedByUserId" FOREIGN KEY ("ConfirmedByUserId") REFERENCES "AspNetUsers" ("Id") ON DELETE SET NULL);""", + "CREATE UNIQUE INDEX \"IX_CampusRegistrationProjects_Code\" ON \"CampusRegistrationProjects\" (\"Code\");", + "CREATE INDEX \"IX_CampusRegistrationProjects_CollegeId_Status_RegistrationEndsAt\" ON \"CampusRegistrationProjects\" (\"CollegeId\", \"Status\", \"RegistrationEndsAt\");", + "CREATE UNIQUE INDEX \"IX_CampusRegistrationApplications_ProjectId_StudentId\" ON \"CampusRegistrationApplications\" (\"ProjectId\", \"StudentId\");", + "CREATE INDEX \"IX_CampusRegistrationApplications_ProjectId_Status_CreatedAt\" ON \"CampusRegistrationApplications\" (\"ProjectId\", \"Status\", \"CreatedAt\");", + "CREATE UNIQUE INDEX \"IX_CampusPaymentOrders_ApplicationId\" ON \"CampusPaymentOrders\" (\"ApplicationId\");", + "CREATE UNIQUE INDEX \"IX_CampusPaymentOrders_OrderNumber\" ON \"CampusPaymentOrders\" (\"OrderNumber\");", + "CREATE INDEX \"IX_CampusPaymentOrders_Status_CreatedAt\" ON \"CampusPaymentOrders\" (\"Status\", \"CreatedAt\");" + ]; + + private static readonly string[] CampusWalletBalanceStatements = + [ + """CREATE TABLE "CampusWalletAccounts" ("Id" TEXT NOT NULL CONSTRAINT "PK_CampusWalletAccounts" PRIMARY KEY, "StudentId" TEXT NOT NULL, "Balance" TEXT NOT NULL, "CreatedAt" TEXT NOT NULL, "UpdatedAt" TEXT NOT NULL, CONSTRAINT "FK_CampusWalletAccounts_StudentId" FOREIGN KEY ("StudentId") REFERENCES "Students" ("Id") ON DELETE RESTRICT);""", + """CREATE TABLE "CampusWalletTransactions" ("Id" TEXT NOT NULL CONSTRAINT "PK_CampusWalletTransactions" PRIMARY KEY, "WalletAccountId" TEXT NOT NULL, "Amount" TEXT NOT NULL, "BalanceAfter" TEXT NOT NULL, "Type" INTEGER NOT NULL, "Reason" TEXT NOT NULL, "PaymentOrderId" TEXT NULL, "OperatedByUserId" TEXT NULL, "CreatedAt" TEXT NOT NULL, "UpdatedAt" TEXT NOT NULL, CONSTRAINT "FK_CampusWalletTransactions_WalletAccountId" FOREIGN KEY ("WalletAccountId") REFERENCES "CampusWalletAccounts" ("Id") ON DELETE CASCADE, CONSTRAINT "FK_CampusWalletTransactions_PaymentOrderId" FOREIGN KEY ("PaymentOrderId") REFERENCES "CampusPaymentOrders" ("Id") ON DELETE RESTRICT, CONSTRAINT "FK_CampusWalletTransactions_OperatedByUserId" FOREIGN KEY ("OperatedByUserId") REFERENCES "AspNetUsers" ("Id") ON DELETE SET NULL);""", + "CREATE UNIQUE INDEX \"IX_CampusWalletAccounts_StudentId\" ON \"CampusWalletAccounts\" (\"StudentId\");", + "CREATE INDEX \"IX_CampusWalletTransactions_WalletAccountId_CreatedAt\" ON \"CampusWalletTransactions\" (\"WalletAccountId\", \"CreatedAt\");", + "CREATE UNIQUE INDEX \"IX_CampusWalletTransactions_PaymentOrderId\" ON \"CampusWalletTransactions\" (\"PaymentOrderId\");" + ]; + + private static readonly string[] TuitionBillingStatements = + [ + """CREATE TABLE "TuitionTermSettings" ("Id" TEXT NOT NULL PRIMARY KEY,"AcademicTermId" TEXT NOT NULL,"CreditUnitPrice" TEXT NOT NULL,"Status" INTEGER NOT NULL,"CreditsLockedAt" TEXT NULL,"CreditsLockedByUserId" TEXT NULL,"CreatedAt" TEXT NOT NULL,"UpdatedAt" TEXT NOT NULL, FOREIGN KEY("AcademicTermId") REFERENCES "AcademicTerms"("Id") ON DELETE RESTRICT);""", + """CREATE TABLE "TuitionBills" ("Id" TEXT NOT NULL PRIMARY KEY,"AcademicTermId" TEXT NOT NULL,"StudentId" TEXT NOT NULL,"BaseTuition" TEXT NOT NULL,"AccommodationFee" TEXT NOT NULL,"CreditCount" TEXT NOT NULL,"CreditFee" TEXT NOT NULL,"ReconciliationAdjustment" TEXT NOT NULL,"BasePaid" TEXT NOT NULL,"AccommodationPaid" TEXT NOT NULL,"CreditPaid" TEXT NOT NULL,"AdjustmentPaid" TEXT NOT NULL,"CreatedAt" TEXT NOT NULL,"UpdatedAt" TEXT NOT NULL, FOREIGN KEY("AcademicTermId") REFERENCES "AcademicTerms"("Id") ON DELETE RESTRICT, FOREIGN KEY("StudentId") REFERENCES "Students"("Id") ON DELETE RESTRICT);""", + """CREATE TABLE "TuitionPayments" ("Id" TEXT NOT NULL PRIMARY KEY,"TuitionBillId" TEXT NOT NULL,"Item" INTEGER NOT NULL,"Amount" TEXT NOT NULL,"Method" INTEGER NOT NULL,"ReferenceNumber" TEXT NULL,"ConfirmedByUserId" TEXT NOT NULL,"ConfirmedAt" TEXT NOT NULL,"CreatedAt" TEXT NOT NULL,"UpdatedAt" TEXT NOT NULL, FOREIGN KEY("TuitionBillId") REFERENCES "TuitionBills"("Id") ON DELETE CASCADE);""", + "CREATE UNIQUE INDEX \"IX_TuitionTermSettings_AcademicTermId\" ON \"TuitionTermSettings\" (\"AcademicTermId\");", + "CREATE UNIQUE INDEX \"IX_TuitionBills_AcademicTermId_StudentId\" ON \"TuitionBills\" (\"AcademicTermId\",\"StudentId\");", + "CREATE INDEX \"IX_TuitionPayments_TuitionBillId_Item_CreatedAt\" ON \"TuitionPayments\" (\"TuitionBillId\",\"Item\",\"CreatedAt\");" + ]; + private static readonly string[] ReusableCourseGroupsStatements = [ """ diff --git a/src/Jiaowu.Api/Infrastructure/Persistence/Migrations/MySql/20260822090000_CampusRegistrationPayments.Designer.cs b/src/Jiaowu.Api/Infrastructure/Persistence/Migrations/MySql/20260822090000_CampusRegistrationPayments.Designer.cs new file mode 100644 index 0000000..8ccbcc9 --- /dev/null +++ b/src/Jiaowu.Api/Infrastructure/Persistence/Migrations/MySql/20260822090000_CampusRegistrationPayments.Designer.cs @@ -0,0 +1,13 @@ +using Jiaowu.Api.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; + +namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql; + +[DbContext(typeof(AppDbContext))] +[Migration("20260822090000_CampusRegistrationPayments")] +public partial class CampusRegistrationPayments +{ + protected override void BuildTargetModel(ModelBuilder modelBuilder) { } +} diff --git a/src/Jiaowu.Api/Infrastructure/Persistence/Migrations/MySql/20260822090000_CampusRegistrationPayments.cs b/src/Jiaowu.Api/Infrastructure/Persistence/Migrations/MySql/20260822090000_CampusRegistrationPayments.cs new file mode 100644 index 0000000..309b6d1 --- /dev/null +++ b/src/Jiaowu.Api/Infrastructure/Persistence/Migrations/MySql/20260822090000_CampusRegistrationPayments.cs @@ -0,0 +1,27 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql; + +public partial class CampusRegistrationPayments : Migration +{ + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable(name: "CampusRegistrationProjects", columns: table => new + { + Id = table.Column(type: "char(36)", nullable: false), Code = table.Column(type: "varchar(40)", maxLength: 40, nullable: false), Name = table.Column(type: "varchar(120)", maxLength: 120, nullable: false), Description = table.Column(type: "varchar(2000)", maxLength: 2000, nullable: true), CollegeId = table.Column(type: "char(36)", nullable: true), RegistrationStartsAt = table.Column(type: "datetime(6)", nullable: false), RegistrationEndsAt = table.Column(type: "datetime(6)", nullable: false), Capacity = table.Column(type: "int", nullable: false), FeeAmount = table.Column(type: "decimal(10,2)", nullable: false), RequiresReview = table.Column(type: "tinyint(1)", nullable: false), Status = table.Column(type: "int", nullable: false), ContactName = table.Column(type: "varchar(50)", maxLength: 50, nullable: true), ContactPhone = table.Column(type: "varchar(30)", maxLength: 30, nullable: true), Notes = table.Column(type: "varchar(500)", maxLength: 500, nullable: true), CreatedByUserId = table.Column(type: "char(36)", nullable: false), CreatedAt = table.Column(type: "datetime(6)", nullable: false), UpdatedAt = table.Column(type: "datetime(6)", nullable: false) + }, constraints: table => { table.PrimaryKey("PK_CampusRegistrationProjects", x => x.Id); table.ForeignKey("FK_CampusRegistrationProjects_Colleges_CollegeId", x => x.CollegeId, "Colleges", "Id", onDelete: ReferentialAction.SetNull); table.ForeignKey("FK_CampusRegistrationProjects_AspNetUsers_CreatedByUserId", x => x.CreatedByUserId, "AspNetUsers", "Id", onDelete: ReferentialAction.Restrict); }).Annotation("MySQL:Charset", "utf8mb4"); + migrationBuilder.CreateTable(name: "CampusRegistrationApplications", columns: table => new + { + Id = table.Column(type: "char(36)", nullable: false), ProjectId = table.Column(type: "char(36)", nullable: false), StudentId = table.Column(type: "char(36)", nullable: false), Status = table.Column(type: "int", nullable: false), ApplicantNote = table.Column(type: "varchar(1000)", maxLength: 1000, nullable: true), ReviewedByUserId = table.Column(type: "char(36)", nullable: true), ReviewedAt = table.Column(type: "datetime(6)", nullable: true), ReviewComment = table.Column(type: "varchar(500)", maxLength: 500, nullable: true), ConfirmedAt = table.Column(type: "datetime(6)", nullable: true), CancelledAt = table.Column(type: "datetime(6)", nullable: true), CreatedAt = table.Column(type: "datetime(6)", nullable: false), UpdatedAt = table.Column(type: "datetime(6)", nullable: false) + }, constraints: table => { table.PrimaryKey("PK_CampusRegistrationApplications", x => x.Id); table.ForeignKey("FK_CampusRegistrationApplications_CampusRegistrationProjects_ProjectId", x => x.ProjectId, "CampusRegistrationProjects", "Id", onDelete: ReferentialAction.Cascade); table.ForeignKey("FK_CampusRegistrationApplications_Students_StudentId", x => x.StudentId, "Students", "Id", onDelete: ReferentialAction.Restrict); table.ForeignKey("FK_CampusRegistrationApplications_AspNetUsers_ReviewedByUserId", x => x.ReviewedByUserId, "AspNetUsers", "Id", onDelete: ReferentialAction.SetNull); }).Annotation("MySQL:Charset", "utf8mb4"); + migrationBuilder.CreateTable(name: "CampusPaymentOrders", columns: table => new + { + Id = table.Column(type: "char(36)", nullable: false), ApplicationId = table.Column(type: "char(36)", nullable: false), OrderNumber = table.Column(type: "varchar(40)", maxLength: 40, nullable: false), Amount = table.Column(type: "decimal(10,2)", nullable: false), PaymentMethod = table.Column(type: "int", nullable: false), Status = table.Column(type: "int", nullable: false), ConfirmedByUserId = table.Column(type: "char(36)", nullable: true), ConfirmedAt = table.Column(type: "datetime(6)", nullable: true), ReferenceNumber = table.Column(type: "varchar(100)", maxLength: 100, nullable: true), Note = table.Column(type: "varchar(500)", maxLength: 500, nullable: true), CreatedAt = table.Column(type: "datetime(6)", nullable: false), UpdatedAt = table.Column(type: "datetime(6)", nullable: false) + }, constraints: table => { table.PrimaryKey("PK_CampusPaymentOrders", x => x.Id); table.ForeignKey("FK_CampusPaymentOrders_CampusRegistrationApplications_ApplicationId", x => x.ApplicationId, "CampusRegistrationApplications", "Id", onDelete: ReferentialAction.Cascade); table.ForeignKey("FK_CampusPaymentOrders_AspNetUsers_ConfirmedByUserId", x => x.ConfirmedByUserId, "AspNetUsers", "Id", onDelete: ReferentialAction.SetNull); }).Annotation("MySQL:Charset", "utf8mb4"); + migrationBuilder.CreateIndex(name: "IX_CampusRegistrationProjects_Code", table: "CampusRegistrationProjects", column: "Code", unique: true); migrationBuilder.CreateIndex(name: "IX_CampusRegistrationProjects_CollegeId_Status_RegistrationEndsAt", table: "CampusRegistrationProjects", columns: new[] { "CollegeId", "Status", "RegistrationEndsAt" }); migrationBuilder.CreateIndex(name: "IX_CampusRegistrationProjects_CreatedByUserId", table: "CampusRegistrationProjects", column: "CreatedByUserId"); migrationBuilder.CreateIndex(name: "IX_CampusRegistrationApplications_ProjectId_StudentId", table: "CampusRegistrationApplications", columns: new[] { "ProjectId", "StudentId" }, unique: true); migrationBuilder.CreateIndex(name: "IX_CampusRegistrationApplications_ProjectId_Status_CreatedAt", table: "CampusRegistrationApplications", columns: new[] { "ProjectId", "Status", "CreatedAt" }); migrationBuilder.CreateIndex(name: "IX_CampusRegistrationApplications_StudentId", table: "CampusRegistrationApplications", column: "StudentId"); migrationBuilder.CreateIndex(name: "IX_CampusRegistrationApplications_ReviewedByUserId", table: "CampusRegistrationApplications", column: "ReviewedByUserId"); migrationBuilder.CreateIndex(name: "IX_CampusPaymentOrders_ApplicationId", table: "CampusPaymentOrders", column: "ApplicationId", unique: true); migrationBuilder.CreateIndex(name: "IX_CampusPaymentOrders_OrderNumber", table: "CampusPaymentOrders", column: "OrderNumber", unique: true); migrationBuilder.CreateIndex(name: "IX_CampusPaymentOrders_Status_CreatedAt", table: "CampusPaymentOrders", columns: new[] { "Status", "CreatedAt" }); migrationBuilder.CreateIndex(name: "IX_CampusPaymentOrders_ConfirmedByUserId", table: "CampusPaymentOrders", column: "ConfirmedByUserId"); + } + protected override void Down(MigrationBuilder migrationBuilder) { migrationBuilder.DropTable(name: "CampusPaymentOrders"); migrationBuilder.DropTable(name: "CampusRegistrationApplications"); migrationBuilder.DropTable(name: "CampusRegistrationProjects"); } +} diff --git a/src/Jiaowu.Api/Infrastructure/Persistence/Migrations/MySql/20260822100000_CampusWalletBalances.Designer.cs b/src/Jiaowu.Api/Infrastructure/Persistence/Migrations/MySql/20260822100000_CampusWalletBalances.Designer.cs new file mode 100644 index 0000000..474e77b --- /dev/null +++ b/src/Jiaowu.Api/Infrastructure/Persistence/Migrations/MySql/20260822100000_CampusWalletBalances.Designer.cs @@ -0,0 +1,8 @@ +using Jiaowu.Api.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql; +[DbContext(typeof(AppDbContext))] +[Migration("20260822100000_CampusWalletBalances")] +public partial class CampusWalletBalances { protected override void BuildTargetModel(ModelBuilder modelBuilder) { } } diff --git a/src/Jiaowu.Api/Infrastructure/Persistence/Migrations/MySql/20260822100000_CampusWalletBalances.cs b/src/Jiaowu.Api/Infrastructure/Persistence/Migrations/MySql/20260822100000_CampusWalletBalances.cs new file mode 100644 index 0000000..5a5ddf2 --- /dev/null +++ b/src/Jiaowu.Api/Infrastructure/Persistence/Migrations/MySql/20260822100000_CampusWalletBalances.cs @@ -0,0 +1,17 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql; + +public partial class CampusWalletBalances : Migration +{ + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable(name: "CampusWalletAccounts", columns: table => new { Id = table.Column(type: "char(36)", nullable: false), StudentId = table.Column(type: "char(36)", nullable: false), Balance = table.Column(type: "decimal(10,2)", nullable: false), CreatedAt = table.Column(type: "datetime(6)", nullable: false), UpdatedAt = table.Column(type: "datetime(6)", nullable: false) }, constraints: table => { table.PrimaryKey("PK_CampusWalletAccounts", x => x.Id); table.ForeignKey("FK_CampusWalletAccounts_Students_StudentId", x => x.StudentId, "Students", "Id", onDelete: ReferentialAction.Restrict); }).Annotation("MySQL:Charset", "utf8mb4"); + migrationBuilder.CreateTable(name: "CampusWalletTransactions", columns: table => new { Id = table.Column(type: "char(36)", nullable: false), WalletAccountId = table.Column(type: "char(36)", nullable: false), Amount = table.Column(type: "decimal(10,2)", nullable: false), BalanceAfter = table.Column(type: "decimal(10,2)", nullable: false), Type = table.Column(type: "int", nullable: false), Reason = table.Column(type: "varchar(500)", maxLength: 500, nullable: false), PaymentOrderId = table.Column(type: "char(36)", nullable: true), OperatedByUserId = table.Column(type: "char(36)", nullable: true), CreatedAt = table.Column(type: "datetime(6)", nullable: false), UpdatedAt = table.Column(type: "datetime(6)", nullable: false) }, constraints: table => { table.PrimaryKey("PK_CampusWalletTransactions", x => x.Id); table.ForeignKey("FK_CampusWalletTransactions_CampusWalletAccounts_WalletAccountId", x => x.WalletAccountId, "CampusWalletAccounts", "Id", onDelete: ReferentialAction.Cascade); table.ForeignKey("FK_CampusWalletTransactions_CampusPaymentOrders_PaymentOrderId", x => x.PaymentOrderId, "CampusPaymentOrders", "Id", onDelete: ReferentialAction.Restrict); table.ForeignKey("FK_CampusWalletTransactions_AspNetUsers_OperatedByUserId", x => x.OperatedByUserId, "AspNetUsers", "Id", onDelete: ReferentialAction.SetNull); }).Annotation("MySQL:Charset", "utf8mb4"); + migrationBuilder.CreateIndex(name: "IX_CampusWalletAccounts_StudentId", table: "CampusWalletAccounts", column: "StudentId", unique: true); migrationBuilder.CreateIndex(name: "IX_CampusWalletTransactions_WalletAccountId_CreatedAt", table: "CampusWalletTransactions", columns: new[] { "WalletAccountId", "CreatedAt" }); migrationBuilder.CreateIndex(name: "IX_CampusWalletTransactions_PaymentOrderId", table: "CampusWalletTransactions", column: "PaymentOrderId", unique: true); migrationBuilder.CreateIndex(name: "IX_CampusWalletTransactions_OperatedByUserId", table: "CampusWalletTransactions", column: "OperatedByUserId"); + } + protected override void Down(MigrationBuilder migrationBuilder) { migrationBuilder.DropTable(name: "CampusWalletTransactions"); migrationBuilder.DropTable(name: "CampusWalletAccounts"); } +} diff --git a/src/Jiaowu.Api/Infrastructure/Persistence/Migrations/MySql/20260822110000_TuitionBilling.Designer.cs b/src/Jiaowu.Api/Infrastructure/Persistence/Migrations/MySql/20260822110000_TuitionBilling.Designer.cs new file mode 100644 index 0000000..fc0a027 --- /dev/null +++ b/src/Jiaowu.Api/Infrastructure/Persistence/Migrations/MySql/20260822110000_TuitionBilling.Designer.cs @@ -0,0 +1,3 @@ +using Jiaowu.Api.Infrastructure.Persistence; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Migrations; +namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql; +[DbContext(typeof(AppDbContext))][Migration("20260822110000_TuitionBilling")] public partial class TuitionBilling { protected override void BuildTargetModel(ModelBuilder modelBuilder) { } } diff --git a/src/Jiaowu.Api/Infrastructure/Persistence/Migrations/MySql/20260822110000_TuitionBilling.cs b/src/Jiaowu.Api/Infrastructure/Persistence/Migrations/MySql/20260822110000_TuitionBilling.cs new file mode 100644 index 0000000..53c3dc3 --- /dev/null +++ b/src/Jiaowu.Api/Infrastructure/Persistence/Migrations/MySql/20260822110000_TuitionBilling.cs @@ -0,0 +1,11 @@ +using Microsoft.EntityFrameworkCore.Migrations; +namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql; +public partial class TuitionBilling : Migration +{ + protected override void Up(MigrationBuilder m) => m.Sql(""" +CREATE TABLE `TuitionTermSettings` (`Id` char(36) NOT NULL,`AcademicTermId` char(36) NOT NULL,`CreditUnitPrice` decimal(10,2) NOT NULL,`Status` int NOT NULL,`CreditsLockedAt` datetime(6) NULL,`CreditsLockedByUserId` char(36) NULL,`CreatedAt` datetime(6) NOT NULL,`UpdatedAt` datetime(6) NOT NULL,PRIMARY KEY (`Id`),UNIQUE KEY `IX_TuitionTermSettings_AcademicTermId` (`AcademicTermId`),CONSTRAINT `FK_TuitionTermSettings_AcademicTerms_AcademicTermId` FOREIGN KEY (`AcademicTermId`) REFERENCES `AcademicTerms` (`Id`) ON DELETE RESTRICT) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; +CREATE TABLE `TuitionBills` (`Id` char(36) NOT NULL,`AcademicTermId` char(36) NOT NULL,`StudentId` char(36) NOT NULL,`BaseTuition` decimal(10,2) NOT NULL,`AccommodationFee` decimal(10,2) NOT NULL,`CreditCount` decimal(8,2) NOT NULL,`CreditFee` decimal(10,2) NOT NULL,`ReconciliationAdjustment` decimal(10,2) NOT NULL,`BasePaid` decimal(10,2) NOT NULL,`AccommodationPaid` decimal(10,2) NOT NULL,`CreditPaid` decimal(10,2) NOT NULL,`AdjustmentPaid` decimal(10,2) NOT NULL,`CreatedAt` datetime(6) NOT NULL,`UpdatedAt` datetime(6) NOT NULL,PRIMARY KEY (`Id`),UNIQUE KEY `IX_TuitionBills_AcademicTermId_StudentId` (`AcademicTermId`,`StudentId`),CONSTRAINT `FK_TuitionBills_AcademicTerms` FOREIGN KEY (`AcademicTermId`) REFERENCES `AcademicTerms` (`Id`) ON DELETE RESTRICT,CONSTRAINT `FK_TuitionBills_Students` FOREIGN KEY (`StudentId`) REFERENCES `Students` (`Id`) ON DELETE RESTRICT) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; +CREATE TABLE `TuitionPayments` (`Id` char(36) NOT NULL,`TuitionBillId` char(36) NOT NULL,`Item` int NOT NULL,`Amount` decimal(10,2) NOT NULL,`Method` int NOT NULL,`ReferenceNumber` varchar(100) NULL,`ConfirmedByUserId` char(36) NOT NULL,`ConfirmedAt` datetime(6) NOT NULL,`CreatedAt` datetime(6) NOT NULL,`UpdatedAt` datetime(6) NOT NULL,PRIMARY KEY (`Id`),KEY `IX_TuitionPayments_TuitionBillId_Item_CreatedAt` (`TuitionBillId`,`Item`,`CreatedAt`),CONSTRAINT `FK_TuitionPayments_TuitionBills` FOREIGN KEY (`TuitionBillId`) REFERENCES `TuitionBills` (`Id`) ON DELETE CASCADE) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; +"""); + protected override void Down(MigrationBuilder m) => m.Sql("DROP TABLE `TuitionPayments`; DROP TABLE `TuitionBills`; DROP TABLE `TuitionTermSettings`;"); +} diff --git a/web/src/layouts/AdminLayout.vue b/web/src/layouts/AdminLayout.vue index 2871c99..8b9d29b 100644 --- a/web/src/layouts/AdminLayout.vue +++ b/web/src/layouts/AdminLayout.vue @@ -163,6 +163,15 @@ const navigationGroups = computed(() => [ path: '/classroom-reservations', label: hasAnyRole(['CollegeAdmin']) ? '教室借用审核' : '教室借用', }, + ...whenVisible( + hasAnyRole(['SuperAdmin', 'AcademicAdmin', 'CollegeAdmin', 'Student']), + { path: '/campus-registrations', label: isStudent.value ? '校园报名与缴费' : '校园支付与报名' }, + ), + ...whenVisible( + hasAnyRole(['SuperAdmin', 'AcademicAdmin', 'CollegeAdmin']), + { path: '/campus-wallets', label: '学生余额管理' }, + ), + ...whenVisible(hasAnyRole(['SuperAdmin', 'AcademicAdmin', 'Student']), { path: '/tuition', label: isStudent.value ? '我的学费' : '学费管理' }), ...whenVisible( hasAnyRole(['SuperAdmin', 'AcademicAdmin', 'MaintenanceAdmin', 'MaintenanceStaff']), { path: '/classroom-repairs', label: '教室报修与维修' }, diff --git a/web/src/router/index.ts b/web/src/router/index.ts index 7c97895..1da601b 100644 --- a/web/src/router/index.ts +++ b/web/src/router/index.ts @@ -233,6 +233,19 @@ const router = createRouter({ name: 'classroom-reservations', component: () => import('../views/ClassroomReservationsView.vue'), }, + { + path: 'campus-registrations', + name: 'campus-registrations', + component: () => import('../views/CampusRegistrationsView.vue'), + meta: { roles: ['SuperAdmin', 'AcademicAdmin', 'CollegeAdmin', 'Student'] }, + }, + { + path: 'campus-wallets', + name: 'campus-wallets', + component: () => import('../views/CampusWalletManagementView.vue'), + meta: { roles: ['SuperAdmin', 'AcademicAdmin', 'CollegeAdmin'] }, + }, + { path: 'tuition', name: 'tuition', component: () => import('../views/TuitionView.vue'), meta: { roles: ['SuperAdmin','AcademicAdmin','Student'] } }, { path: 'classroom-repairs', name: 'classroom-repairs', diff --git a/web/src/views/CampusRegistrationsView.vue b/web/src/views/CampusRegistrationsView.vue new file mode 100644 index 0000000..e87c8d7 --- /dev/null +++ b/web/src/views/CampusRegistrationsView.vue @@ -0,0 +1,54 @@ + + + + + diff --git a/web/src/views/CampusWalletManagementView.vue b/web/src/views/CampusWalletManagementView.vue new file mode 100644 index 0000000..cc41659 --- /dev/null +++ b/web/src/views/CampusWalletManagementView.vue @@ -0,0 +1,90 @@ + + + + + diff --git a/web/src/views/TuitionView.vue b/web/src/views/TuitionView.vue new file mode 100644 index 0000000..1f7f187 --- /dev/null +++ b/web/src/views/TuitionView.vue @@ -0,0 +1,142 @@ + + + + +