Merge branch 'master' of https://git.biss.click/biss/Academic-Affairs-System
# Conflicts: # src/Jiaowu.Api/Controllers/TuitionController.cs # src/Jiaowu.Api/Infrastructure/Persistence/Migrations/MySql/20260822090000_CampusRegistrationPayments.cs # tests/Jiaowu.Api.Tests/MySqlMigrationTests.cs # web/src/views/CampusWalletManagementView.vue # web/src/views/TuitionView.vue
This commit is contained in:
commit
172577f668
17 files changed
+10415
-5
No files matched your search
@@ -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<ActionResult> 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<ActionResult> 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<ActionResult> 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<ActionResult> 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<ActionResult> Mine(CancellationToken ct) => Ok(await ApplicationsForCurrentStudent()
|
||||||
|
.OrderByDescending(x => x.CreatedAt).Select(ProjectApplicationDto()).ToListAsync(ct));
|
||||||
|
|
||||||
|
[HttpGet("wallet/mine")]
|
||||||
|
[Authorize(Roles = SystemRoles.Student)]
|
||||||
|
public async Task<ActionResult> 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<ActionResult> 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<ActionResult> 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<object>(items.Cast<object>().ToList(), total, page, pageSize));
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("wallets/filter-options")]
|
||||||
|
[Authorize(Roles = Managers)]
|
||||||
|
public async Task<ActionResult> 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<ActionResult> 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<object?>)[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<ActionResult> ImportWalletAdjustments(IFormFile file, CancellationToken ct)
|
||||||
|
{
|
||||||
|
IReadOnlyList<ExcelRow> rows;
|
||||||
|
try { rows = await ExcelWorkbookHelper.ReadAsync(file, ["学号", "调整金额", "调整原因"], ct); }
|
||||||
|
catch (InvalidDataException ex) { return BadRequest(new { message = ex.Message }); }
|
||||||
|
var errors = new List<string>(); 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<ActionResult> 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<ActionResult> 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<ActionResult> 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<object?>)[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<ActionResult> 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<ActionResult> 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<ActionResult> 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<CampusRegistrationProject> ScopedProjects() => scope.Current.RestrictedCollegeId is { } collegeId ? db.CampusRegistrationProjects.Where(x => x.CollegeId == null || x.CollegeId == collegeId) : db.CampusRegistrationProjects;
|
||||||
|
private IQueryable<CampusRegistrationApplication> ScopedApplications() => scope.Current.RestrictedCollegeId is { } collegeId ? db.CampusRegistrationApplications.Where(x => x.Project!.CollegeId == null || x.Project.CollegeId == collegeId) : db.CampusRegistrationApplications;
|
||||||
|
private IQueryable<CampusRegistrationApplication> 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<int> 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<Func<CampusRegistrationApplication, object>> 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);
|
||||||
@@ -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<CampusRegistrationApplication> 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<CampusWalletTransaction> 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 }
|
||||||
@@ -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<TuitionPayment> 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 }
|
||||||
Loaded 3 of 17 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user