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