diff --git a/api/openapi.yaml b/api/openapi.yaml index 65828e38..86e905e2 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -3414,6 +3414,94 @@ paths: "404": description: 经费申请不存在 + /api/v1/budget/applications/{applicationId}/reviews: + get: + summary: 获取经费申请审核记录 + description: 返回指定经费申请的全部审核历史记录,按时间倒序排列。 + operationId: getBudgetApplicationReviews + security: + - bearerAuth: [] + parameters: + - name: applicationId + in: path + required: true + schema: + type: integer + responses: + "200": + description: 审核记录列表 + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/BudgetReviewRecord" + "401": + description: 当前用户未登录或登录状态已失效 + "403": + description: 当前用户没有该社团的经费查看权限 + "404": + description: 经费申请不存在 + + /api/v1/budget/applications/{applicationId}/resubmit: + post: + summary: 重新提交被驳回的经费申请 + description: 申请人或具备经费申请权限的负责人可将被驳回的申请修改后重新提交,状态回到待审核,历史审核记录保留。 + operationId: resubmitBudgetApplication + x-idempotency-required: true + security: + - bearerAuth: [] + parameters: + - $ref: "#/components/parameters/IdempotencyKey" + - name: applicationId + in: path + required: true + schema: + type: integer + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/ResubmitBudgetApplicationRequest" + responses: + "200": + description: 经费申请已重新提交,状态回到待审核 + content: + application/json: + schema: + $ref: "#/components/schemas/BudgetApplication" + "400": + description: 当前申请状态不允许重新提交或参数不合法 + content: + application/json: + schema: + $ref: "#/components/schemas/ApiError" + "401": + description: 当前用户未登录或登录状态已失效 + content: + application/json: + schema: + $ref: "#/components/schemas/ApiError" + "403": + description: 当前用户没有重新提交该申请的权限 + content: + application/json: + schema: + $ref: "#/components/schemas/ApiError" + "404": + description: 经费申请不存在 + content: + application/json: + schema: + $ref: "#/components/schemas/ApiError" + "409": + description: 并发写入冲突 + content: + application/json: + schema: + $ref: "#/components/schemas/ApiError" + /api/v1/budget/transactions: get: summary: 获取经费流水列表 @@ -10206,6 +10294,49 @@ components: description: 撤销说明。 example: 活动方案调整,暂不申请经费。 + BudgetApplicationType: + type: string + nullable: true + enum: [activity_budget, purchase, reimbursement] + description: 经费申请类型。 + example: activity_budget + + ResubmitBudgetApplicationRequest: + type: object + description: 重新提交被驳回经费申请的请求,所有字段均为可选,仅更新传入的字段。 + properties: + activityId: + type: integer + nullable: true + description: 关联活动,可不填。 + example: 12 + type: + $ref: "#/components/schemas/BudgetApplicationType" + title: + type: string + minLength: 1 + maxLength: 255 + description: 经费申请标题。 + example: 迎新活动物料采购(修订) + amount: + type: number + format: double + minimum: 0.01 + description: 申请金额,必须大于 0。 + example: 1200 + purpose: + type: string + minLength: 1 + maxLength: 255 + description: 经费用途。 + example: 活动布置、设备耗材、报名物料 + detail: + type: string + nullable: true + maxLength: 4000 + description: 经费明细说明。 + example: 横幅 2 条,单价 120 元;矿泉水 10 箱,单价 45 元。 + BudgetReviewRecord: type: object description: 经费审核记录,用于保留审核动作历史。 diff --git a/backend.Tests/BudgetApprovalFlowTests.cs b/backend.Tests/BudgetApprovalFlowTests.cs new file mode 100644 index 00000000..8efe0bc7 --- /dev/null +++ b/backend.Tests/BudgetApprovalFlowTests.cs @@ -0,0 +1,420 @@ +using System.Net; +using System.Net.Http.Headers; +using System.Net.Http.Json; +using System.Text; +using System.Text.Json; +using ClubHub.Api.Data; +using ClubHub.Api.Data.Entities; +using ClubHub.Api.Services; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Storage; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; + +namespace ClubHub.Api.Tests; + +public sealed class BudgetApprovalFlowTests : IClassFixture +{ + private const int ApplicantUserId = 101; + private const int ReviewerUserId = 102; + private const int ClubId = 201; + private const int AccountId = 301; + private const int ApplicationId = 401; + private const int OriginalActivityId = 501; + private readonly ClubHubWebApplicationFactory _factory; + + public BudgetApprovalFlowTests(ClubHubWebApplicationFactory factory) => _factory = factory; + + [Fact] + public async Task GetReviewRecords_WithoutToken_ReturnsUnauthorized() + { + using var client = _factory.CreateClient(); + using var response = await client.GetAsync( + $"/api/v1/budget/applications/{ApplicationId}/reviews"); + + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + } + + [Fact] + public async Task ResubmitEndpoint_WithoutToken_ReturnsUnauthorized() + { + using var client = _factory.CreateClient(); + using var content = new StringContent( + """{"title":"test"}""", + Encoding.UTF8, + "application/json"); + using var response = await client.PostAsync( + $"/api/v1/budget/applications/{ApplicationId}/resubmit", + content); + + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + } + + [Fact] + public async Task GetReviewRecords_ApplicationOwnerWithoutBudgetView_ReturnsHistory() + { + await using var factory = await RelationalBudgetWebApplicationFactory.CreateAsync(); + using var client = await SeedScenarioAsync( + factory, + applicationStatus: "rejected", + grantApplicantReviewPermission: false, + includeReviewRecord: true); + + using var response = await client.GetAsync( + $"/api/v1/budget/applications/{ApplicationId}/reviews"); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + using var document = JsonDocument.Parse(await response.Content.ReadAsStringAsync()); + var record = Assert.Single(document.RootElement.EnumerateArray()); + Assert.Equal(ReviewerUserId, record.GetProperty("reviewerUserId").GetInt32()); + Assert.False(record.GetProperty("approved").GetBoolean()); + Assert.Equal("请补充预算明细", record.GetProperty("comment").GetString()); + } + + [Fact] + public async Task ReviewApplication_ByApplicantWithReviewPermission_ReturnsForbidden() + { + await using var factory = await RelationalBudgetWebApplicationFactory.CreateAsync(); + using var client = await SeedScenarioAsync( + factory, + applicationStatus: "pending", + grantApplicantReviewPermission: true); + + using var response = await client.PostAsJsonAsync( + $"/api/v1/budget/applications/{ApplicationId}/review", + new { approved = true, comment = "同意" }); + + Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); + using var document = JsonDocument.Parse(await response.Content.ReadAsStringAsync()); + Assert.Equal("FORBIDDEN", document.RootElement.GetProperty("code").GetString()); + Assert.Equal( + "申请人不能审批自己的经费申请。", + document.RootElement.GetProperty("message").GetString()); + } + + [Fact] + public async Task ResubmitApplication_WhenRejected_UpdatesFieldsAndReturnsPending() + { + await using var factory = await RelationalBudgetWebApplicationFactory.CreateAsync(); + using var client = await SeedScenarioAsync( + factory, + applicationStatus: "rejected", + grantApplicantReviewPermission: false, + includeReviewRecord: true); + + using var response = await client.PostAsJsonAsync( + $"/api/v1/budget/applications/{ApplicationId}/resubmit", + new + { + type = "purchase", + title = "修订后的采购申请", + amount = 800, + purpose = "采购活动物料", + detail = "横幅与打印材料" + }); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + using var document = JsonDocument.Parse(await response.Content.ReadAsStringAsync()); + Assert.Equal("pending", document.RootElement.GetProperty("status").GetString()); + Assert.Equal("purchase", document.RootElement.GetProperty("type").GetString()); + Assert.Equal("修订后的采购申请", document.RootElement.GetProperty("title").GetString()); + + await using var scope = factory.Services.CreateAsyncScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var application = await db.BudgetApplications.SingleAsync( + item => item.ApplicationId == ApplicationId); + Assert.Equal("pending", application.ApplicationStatus); + Assert.Null(application.ReviewerUserId); + Assert.Null(application.ReviewComment); + Assert.Null(application.ReviewedAt); + Assert.Equal( + 1, + await db.BudgetReviewRecords.CountAsync( + item => item.ApplicationId == ApplicationId)); + Assert.Equal(OriginalActivityId, application.ActivityId); + Assert.InRange( + application.SubmittedAt, + DateTime.UtcNow.AddMinutes(-1), + DateTime.UtcNow.AddMinutes(1)); + } + + [Fact] + public async Task ResubmitApplication_WhenNullableFieldsAreExplicitlyNull_ClearsExistingValues() + { + await using var factory = await RelationalBudgetWebApplicationFactory.CreateAsync(); + using var client = await SeedScenarioAsync( + factory, + applicationStatus: "rejected", + grantApplicantReviewPermission: false); + + using var content = new StringContent( + """{"activityId":null,"title":"清空可选字段","amount":800,"purpose":"采购活动物料","detail":null}""", + Encoding.UTF8, + "application/json"); + using var response = await client.PostAsync( + $"/api/v1/budget/applications/{ApplicationId}/resubmit", + content); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + using var document = JsonDocument.Parse(await response.Content.ReadAsStringAsync()); + Assert.Equal(JsonValueKind.Null, document.RootElement.GetProperty("activityId").ValueKind); + Assert.Equal(JsonValueKind.Null, document.RootElement.GetProperty("detail").ValueKind); + + await using var scope = factory.Services.CreateAsyncScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var application = await db.BudgetApplications.SingleAsync( + item => item.ApplicationId == ApplicationId); + Assert.Null(application.ActivityId); + Assert.Null(application.Detail); + } + + [Fact] + public async Task ResubmitApplication_WhenTypeIsOmitted_PreservesExistingType() + { + await using var factory = await RelationalBudgetWebApplicationFactory.CreateAsync(); + using var client = await SeedScenarioAsync( + factory, + applicationStatus: "rejected", + grantApplicantReviewPermission: false); + + using var response = await client.PostAsJsonAsync( + $"/api/v1/budget/applications/{ApplicationId}/resubmit", + new { title = "保留原申请类型" }); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + using var document = JsonDocument.Parse(await response.Content.ReadAsStringAsync()); + Assert.Equal("activity_budget", document.RootElement.GetProperty("type").GetString()); + + await using var scope = factory.Services.CreateAsyncScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var application = await db.BudgetApplications.SingleAsync( + item => item.ApplicationId == ApplicationId); + Assert.Equal(OriginalActivityId, application.ActivityId); + Assert.Equal("原预算明细", application.Detail); + } + + [Fact] + public async Task ResubmitApplication_WhenTypeIsNull_PreservesExistingType() + { + await using var factory = await RelationalBudgetWebApplicationFactory.CreateAsync(); + using var client = await SeedScenarioAsync( + factory, + applicationStatus: "rejected", + grantApplicantReviewPermission: false); + + using var response = await client.PostAsJsonAsync( + $"/api/v1/budget/applications/{ApplicationId}/resubmit", + new { type = (string?)null, title = "接受可空申请类型" }); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + using var document = JsonDocument.Parse(await response.Content.ReadAsStringAsync()); + Assert.Equal("activity_budget", document.RootElement.GetProperty("type").GetString()); + } + + [Fact] + public async Task ResubmitApplication_WhenNotRejected_ReturnsBadRequest() + { + await using var factory = await RelationalBudgetWebApplicationFactory.CreateAsync(); + using var client = await SeedScenarioAsync( + factory, + applicationStatus: "pending", + grantApplicantReviewPermission: false); + + using var response = await client.PostAsJsonAsync( + $"/api/v1/budget/applications/{ApplicationId}/resubmit", + new { title = "不应被接受" }); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + using var document = JsonDocument.Parse(await response.Content.ReadAsStringAsync()); + Assert.Equal("VALIDATION_ERROR", document.RootElement.GetProperty("code").GetString()); + Assert.Equal( + "只有已驳回的经费申请才能重新提交。", + document.RootElement.GetProperty("message").GetString()); + } + + private static async Task SeedScenarioAsync( + RelationalBudgetWebApplicationFactory factory, + string applicationStatus, + bool grantApplicantReviewPermission, + bool includeReviewRecord = false) + { + await using var scope = factory.Services.CreateAsyncScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var now = DateTime.UtcNow; + var applicant = new User + { + UserId = ApplicantUserId, + Username = "budget-applicant", + PasswordHash = "unused", + RealName = "申请人", + AccountStatus = "normal", + CreatedAt = now + }; + var reviewer = new User + { + UserId = ReviewerUserId, + Username = "budget-reviewer", + PasswordHash = "unused", + RealName = "审核人", + AccountStatus = "normal", + CreatedAt = now + }; + + db.Users.AddRange(applicant, reviewer); + db.Clubs.Add(new Club + { + ClubId = ClubId, + ClubName = "预算测试社团", + ClubStatus = "active", + CreatedAt = now + }); + db.BudgetAccounts.Add(new BudgetAccount + { + AccountId = AccountId, + ClubId = ClubId, + FiscalYear = "2026", + AccountName = "2026 年度经费", + InitialAmount = 10_000m, + AccountStatus = "active", + CreatedAt = now, + UpdatedAt = now + }); + db.Activities.Add(new Activity + { + ActivityId = OriginalActivityId, + ClubId = ClubId, + Title = "原关联活动", + ActivityStatus = "published", + CreatedAt = now.AddDays(-2) + }); + db.BudgetApplications.Add(new BudgetApplication + { + ApplicationId = ApplicationId, + AccountId = AccountId, + ClubId = ClubId, + ActivityId = OriginalActivityId, + ApplicantUserId = ApplicantUserId, + ApplicationType = "activity_budget", + Title = "原经费申请", + Amount = 1_000m, + Purpose = "原经费用途", + Detail = "原预算明细", + ApplicationStatus = applicationStatus, + SubmittedAt = now.AddDays(-1), + ReviewerUserId = applicationStatus == "rejected" ? ReviewerUserId : null, + ReviewComment = applicationStatus == "rejected" ? "请补充预算明细" : null, + ReviewedAt = applicationStatus == "rejected" ? now.AddHours(-1) : null, + CreatedAt = now.AddDays(-1), + UpdatedAt = now + }); + + if (grantApplicantReviewPermission) + { + db.Roles.Add(new Role + { + RoleId = 501, + RoleCode = "SYSTEM_ADMIN", + RoleName = "系统管理员", + RoleScope = "system", + CreatedAt = now + }); + db.UserRoles.Add(new UserRole + { + UserRoleId = 601, + UserId = ApplicantUserId, + RoleId = 501, + AssignedAt = now + }); + } + + if (includeReviewRecord) + { + db.BudgetReviewRecords.Add(new BudgetReviewRecord + { + ReviewId = 701, + ApplicationId = ApplicationId, + ReviewerUserId = ReviewerUserId, + Approved = 0, + CommentText = "请补充预算明细", + ReviewedAt = now.AddHours(-1) + }); + } + + await db.SaveChangesAsync(); + var token = scope.ServiceProvider + .GetRequiredService() + .CreateToken(applicant); + var client = factory.CreateClient(); + client.DefaultRequestHeaders.Authorization = + new AuthenticationHeaderValue("Bearer", token); + client.DefaultRequestHeaders.Add("Idempotency-Key", "budget-flow-test-key"); + return client; + } +} + +internal sealed class RelationalBudgetWebApplicationFactory : WebApplicationFactory +{ + private readonly SqliteConnection _connection = new("Data Source=:memory:"); + + private RelationalBudgetWebApplicationFactory() => _connection.Open(); + + public static async Task CreateAsync() + { + var factory = new RelationalBudgetWebApplicationFactory(); + await using var scope = factory.Services.CreateAsyncScope(); + var db = scope.ServiceProvider.GetRequiredService(); + await db.Database.EnsureCreatedAsync(); + return factory; + } + + protected override void ConfigureWebHost(IWebHostBuilder builder) + { + builder.UseEnvironment("Testing"); + builder.ConfigureAppConfiguration((_, configuration) => + configuration.AddInMemoryCollection(new Dictionary + { + ["Authentication:TokenSigningKey"] = "ClubHub.Tests.TokenSigningKey", + ["ConnectionStrings:Default"] = "Tests use an isolated SQLite in-memory database" + })); + builder.ConfigureTestServices(services => + { + services.RemoveAll(); + services.RemoveAll>(); + services.RemoveAll>(); + services.RemoveAll(); + + var options = new DbContextOptionsBuilder() + .UseSqlite(_connection) + .Options; + services.AddScoped(_ => new SqliteClubHubDbContext(options)); + }); + } + + protected override void Dispose(bool disposing) + { + base.Dispose(disposing); + if (disposing) _connection.Dispose(); + } + + private sealed class SqliteClubHubDbContext(DbContextOptions options) + : ClubHubDbContext(options) + { + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + base.OnModelCreating(modelBuilder); + foreach (var property in modelBuilder.Model + .GetEntityTypes() + .SelectMany(entity => entity.GetProperties())) + { + property.SetDefaultValueSql(null); + } + } + } + +} diff --git a/backend.Tests/ClubHub.Api.Tests.csproj b/backend.Tests/ClubHub.Api.Tests.csproj index 78803647..e16363cb 100644 --- a/backend.Tests/ClubHub.Api.Tests.csproj +++ b/backend.Tests/ClubHub.Api.Tests.csproj @@ -11,6 +11,8 @@ + + diff --git a/backend.Tests/OpenApiRestContractTests.cs b/backend.Tests/OpenApiRestContractTests.cs index 4c9773f7..963aeef1 100644 --- a/backend.Tests/OpenApiRestContractTests.cs +++ b/backend.Tests/OpenApiRestContractTests.cs @@ -119,6 +119,23 @@ public void ApiErrorCodeUsesStandardRequiredValues() Assert.Contains(" required:\n - code", NormalizeNewLines(apiErrorSchema), StringComparison.Ordinal); } + [Fact] + public void ResubmitBudgetApplicationDeclaresApiErrorResponses() + { + var document = ReadDocument(); + var operation = Slice( + document, + " /api/v1/budget/applications/{applicationId}/resubmit:", + " /api/v1/budget/transactions:"); + + foreach (var status in new[] { "400", "401", "403", "404", "409" }) + { + var responsePattern = + $@"(?ms)^ ""{status}"":\s*$.*?^ content:\s*$.*?^ application/json:\s*$.*?^ schema:\s*$.*?^ \$ref: ""#/components/schemas/ApiError""\s*$"; + Assert.Matches(responsePattern, operation); + } + } + private static string ReadDocument() { var path = Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "../../../../api/openapi.yaml")); diff --git a/backend.Tests/RedisAuthIdempotencyContractTests.cs b/backend.Tests/RedisAuthIdempotencyContractTests.cs index 0bbb9183..a5184e54 100644 --- a/backend.Tests/RedisAuthIdempotencyContractTests.cs +++ b/backend.Tests/RedisAuthIdempotencyContractTests.cs @@ -32,6 +32,7 @@ public sealed class RedisAuthIdempotencyContractTests "registerActivity", "reviewActivity", "createBudgetApplication", + "resubmitBudgetApplication", "reviewBudgetApplication", "createVenueReservation", "reviewVenueReservation", diff --git a/backend/Controllers/BudgetController.cs b/backend/Controllers/BudgetController.cs index 1e54a42e..03888d83 100644 --- a/backend/Controllers/BudgetController.cs +++ b/backend/Controllers/BudgetController.cs @@ -12,11 +12,13 @@ using Microsoft.EntityFrameworkCore.Storage; using ApiBudgetAccount = Org.OpenAPITools.Models.BudgetAccount; using ApiBudgetApplication = Org.OpenAPITools.Models.BudgetApplication; +using ApiBudgetReviewRecord = Org.OpenAPITools.Models.BudgetReviewRecord; using ApiBudgetTransaction = Org.OpenAPITools.Models.BudgetTransaction; using ApiCancelBudgetApplicationRequest = Org.OpenAPITools.Models.CancelBudgetApplicationRequest; using ApiCreateBudgetAccountRequest = Org.OpenAPITools.Models.CreateBudgetAccountRequest; using ApiCreateBudgetApplicationRequest = Org.OpenAPITools.Models.CreateBudgetApplicationRequest; using ApiError = Org.OpenAPITools.Models.ApiError; +using ApiResubmitBudgetApplicationRequest = Org.OpenAPITools.Models.ResubmitBudgetApplicationRequest; using ApiReviewBudgetApplicationRequest = Org.OpenAPITools.Models.ReviewBudgetApplicationRequest; using ApiUpdateBudgetAccountRequest = Org.OpenAPITools.Models.UpdateBudgetAccountRequest; @@ -423,6 +425,11 @@ public async Task ReviewApplication(int applicationId, [FromBody] "当前用户没有该社团的经费审核权限。"); if (permission is not null) return permission; + if (application.ApplicantUserId == currentUserId.Value) + { + return StatusCode(403, Error("budget_self_review_forbidden", "申请人不能审批自己的经费申请。")); + } + if (!string.Equals(application.ApplicationStatus, ApplicationStatusPending, StringComparison.OrdinalIgnoreCase)) { return BadRequest(Error("budget_application_status_invalid", "只有待审核的经费申请才能审核。")); @@ -538,6 +545,137 @@ public async Task CancelApplication( "经费申请正在被其他操作修改,请刷新后重试。"); } + [HttpGet("applications/{applicationId:int}/reviews")] + public async Task GetReviewRecords(int applicationId) + { + var currentUserId = User.GetUserId(); + if (currentUserId is null) return Unauthorized(Error("auth_required", "登录状态已失效,请重新登录。")); + + var application = await ApplicationQuery() + .AsNoTracking() + .FirstOrDefaultAsync(item => item.ApplicationId == applicationId); + if (application is null) return NotFound(Error("budget_application_not_found", "经费申请不存在。")); + + if (application.ApplicantUserId != currentUserId.Value) + { + var permission = await RequireClubPermissionAsync( + currentUserId.Value, + BudgetViewPermission, + application.ClubId, + "当前用户没有该社团的经费查看权限。"); + if (permission is not null) return permission; + } + + var records = await _db.BudgetReviewRecords + .AsNoTracking() + .Include(record => record.ReviewerUser) + .Where(record => record.ApplicationId == applicationId) + .OrderByDescending(record => record.ReviewedAt) + .ToListAsync(); + + return Ok(records.Select(ToDto)); + } + + [HttpPost("applications/{applicationId:int}/resubmit")] + [ClubHub.Api.Infrastructure.Idempotency.IdempotentOperation("resubmitBudgetApplication")] + public async Task ResubmitApplication( + int applicationId, + [FromBody] ApiResubmitBudgetApplicationRequest req) + { + var currentUserId = User.GetUserId(); + if (currentUserId is null) return Unauthorized(Error("auth_required", "登录状态已失效,请重新登录。")); + + return await ExecuteSerializableWriteAsync( + async () => + { + var application = await LockBudgetApplicationAsync(applicationId); + if (application is null) return NotFound(Error("budget_application_not_found", "经费申请不存在。")); + + if (!string.Equals(application.ApplicationStatus, ApplicationStatusRejected, StringComparison.OrdinalIgnoreCase)) + { + return BadRequest(Error("budget_application_status_invalid", "只有已驳回的经费申请才能重新提交。")); + } + + if (application.ApplicantUserId != currentUserId.Value) + { + var permission = await RequireClubPermissionAsync( + currentUserId.Value, + BudgetApplyPermission, + application.ClubId, + "当前用户没有重新提交该社团经费申请的权限。"); + if (permission is not null) return permission; + } + + var account = await AccountQuery() + .AsNoTracking() + .FirstOrDefaultAsync(item => item.AccountId == application.AccountId); + if (account is null) return NotFound(Error("budget_account_not_found", "经费账户不存在。")); + + if (!string.Equals(account.AccountStatus, AccountStatusActive, StringComparison.OrdinalIgnoreCase)) + { + return Conflict(Error("budget_account_closed", "经费账户已关闭,不能重新提交经费申请。")); + } + + var title = NullIfBlank(req.Title) ?? application.Title; + var purpose = NullIfBlank(req.Purpose) ?? application.Purpose; + var detail = req.DetailWasProvided ? NullIfBlank(req.Detail) : application.Detail; + var amount = req.Amount.HasValue ? Convert.ToDecimal(req.Amount.Value) : application.Amount; + var newType = req.Type is { } type ? EnumMemberValue(type) : null; + var applicationType = newType ?? application.ApplicationType; + + if (!AllowedApplicationTypes.Contains(applicationType)) + { + return BadRequest(Error("budget_application_type_invalid", "经费申请类型不合法。")); + } + + var validation = ValidateApplicationInput(title, amount, purpose, detail); + if (validation is not null) return validation; + + Activity? activity = null; + var activityId = req.ActivityIdWasProvided ? req.ActivityId : application.ActivityId; + if (activityId is not null) + { + activity = await _db.Activities + .AsNoTracking() + .FirstOrDefaultAsync(item => item.ActivityId == activityId.Value); + if (activity is null) return NotFound(Error("activity_not_found", "关联活动不存在。")); + if (activity.ClubId != application.ClubId) + { + return BadRequest(Error("budget_activity_club_mismatch", "关联活动必须属于经费账户对应社团。")); + } + } + + var remainingAmount = await GetRemainingAmountAsync(application.AccountId, account.InitialAmount); + if (amount > remainingAmount) + { + return Conflict(Error("budget_insufficient_balance", "经费账户余额不足,不能提交该金额的申请。")); + } + + var now = DateTime.UtcNow; + application.ActivityId = activityId; + application.ApplicationType = applicationType; + application.Title = title; + application.Amount = amount; + application.Purpose = purpose; + application.Detail = detail; + application.ApplicationStatus = ApplicationStatusPending; + application.SubmittedAt = now; + application.ReviewerUserId = null; + application.ReviewComment = null; + application.ReviewedAt = null; + application.UpdatedAt = now; + + await _db.SaveChangesAsync(); + + var resubmitted = await ApplicationQuery() + .AsNoTracking() + .FirstAsync(item => item.ApplicationId == application.ApplicationId); + return Ok(ToDto(resubmitted)); + }, + "budget_resubmit_write_conflict", + "经费申请正在被其他操作修改,请稍后重试。"); + } + [HttpGet("transactions")] public async Task GetTransactions([FromQuery] int? clubId, [FromQuery] int? accountId) { @@ -646,6 +784,20 @@ private IQueryable TransactionQuery() => _db.BudgetTransactio private async Task LockRowIdAsync(string sql, params int[] values) { + // Isolated relational tests use SQLite, which does not support FOR UPDATE. + // Oracle keeps the locking clause unchanged in production. + if (string.Equals( + _db.Database.ProviderName, + "Microsoft.EntityFrameworkCore.Sqlite", + StringComparison.Ordinal)) + { + const string forUpdateSuffix = " FOR UPDATE"; + if (sql.EndsWith(forUpdateSuffix, StringComparison.OrdinalIgnoreCase)) + { + sql = sql[..^forUpdateSuffix.Length]; + } + } + var connection = _db.Database.GetDbConnection(); await using var command = connection.CreateCommand(); command.CommandText = sql; @@ -796,6 +948,17 @@ private async Task> ToAccountDtosAsync(IReadOnlyCollectio ReviewComment = application.ReviewComment }; + private static ApiBudgetReviewRecord ToDto(BudgetReviewRecord record) => new() + { + Id = record.ReviewId, + ApplicationId = record.ApplicationId, + ReviewerUserId = record.ReviewerUserId, + ReviewerName = DisplayName(record.ReviewerUser), + Approved = record.Approved == 1, + Comment = record.CommentText, + ReviewedAt = StoredTimeToUtc(record.ReviewedAt) + }; + private static ApiBudgetTransaction ToDto(BudgetTransaction transaction) => new() { Id = transaction.TransactionId, diff --git a/backend/Models/BudgetApplicationType.cs b/backend/Models/BudgetApplicationType.cs new file mode 100644 index 00000000..31d88b4e --- /dev/null +++ b/backend/Models/BudgetApplicationType.cs @@ -0,0 +1,48 @@ +/* + * ClubHub API + * + * ClubHub 高校社团运营与协同管理平台 API。 **API-first 开发流程**:修改本文件 → push → CI 自动生成前后端代码 → git pull 拉取生成代码 → 在 Controller / Services / Vue 组件中手写业务逻辑。 + * + * The version of the OpenAPI document: 0.1.0 + * + * Generated by: https://openapi-generator.tech + */ + +using System; +using System.Linq; +using System.Text; +using System.Collections.Generic; +using System.ComponentModel; +using System.ComponentModel.DataAnnotations; +using System.Runtime.Serialization; +using System.Text.Json; + +namespace Org.OpenAPITools.Models +{ + /// + /// 经费申请类型。 + /// + /// 经费申请类型。 + + public enum BudgetApplicationType + { + + /// + /// Enum ActivityBudgetEnum for activity_budget + /// + [EnumMember(Value = "activity_budget")] + ActivityBudgetEnum = 1, + + /// + /// Enum PurchaseEnum for purchase + /// + [EnumMember(Value = "purchase")] + PurchaseEnum = 2, + + /// + /// Enum ReimbursementEnum for reimbursement + /// + [EnumMember(Value = "reimbursement")] + ReimbursementEnum = 3 + } +} diff --git a/backend/Models/ResubmitBudgetApplicationRequest.cs b/backend/Models/ResubmitBudgetApplicationRequest.cs new file mode 100644 index 00000000..63ae265a --- /dev/null +++ b/backend/Models/ResubmitBudgetApplicationRequest.cs @@ -0,0 +1,78 @@ +/* + * ClubHub API + * + * ClubHub 高校社团运营与协同管理平台 API。 **API-first 开发流程**:修改本文件 → push → CI 自动生成前后端代码 → git pull 拉取生成代码 → 在 Controller / Services / Vue 组件中手写业务逻辑。 + * + * The version of the OpenAPI document: 0.1.0 + * + * Generated by: https://openapi-generator.tech + */ + +using System; +using System.Linq; +using System.Text; +using System.Collections.Generic; +using System.ComponentModel; +using System.ComponentModel.DataAnnotations; +using System.Runtime.Serialization; +using System.Text.Json; + +namespace Org.OpenAPITools.Models +{ + /// + /// 重新提交被驳回经费申请的请求,所有字段均为可选,仅更新传入的字段。 + /// + [DataContract] + public partial class ResubmitBudgetApplicationRequest + { + /// + /// 关联活动,可不填。 + /// + /// 关联活动,可不填。 + /* 12 */ + [DataMember(Name="activityId", EmitDefaultValue=true)] + public int? ActivityId { get; set; } + + /// + /// Gets or Sets Type + /// + [DataMember(Name="type", EmitDefaultValue=true)] + public BudgetApplicationType? Type { get; set; } + + /// + /// 经费申请标题。 + /// + /// 经费申请标题。 + /* 迎新活动物料采购(修订) */ + [StringLength(255, MinimumLength=1)] + [DataMember(Name="title", EmitDefaultValue=false)] + public string? Title { get; set; } + + /// + /// 申请金额,必须大于 0。 + /// + /// 申请金额,必须大于 0。 + /* 1200 */ + [DataMember(Name="amount", EmitDefaultValue=true)] + public double? Amount { get; set; } + + /// + /// 经费用途。 + /// + /// 经费用途。 + /* 活动布置、设备耗材、报名物料 */ + [StringLength(255, MinimumLength=1)] + [DataMember(Name="purpose", EmitDefaultValue=false)] + public string? Purpose { get; set; } + + /// + /// 经费明细说明。 + /// + /// 经费明细说明。 + /* 横幅 2 条,单价 120 元;矿泉水 10 箱,单价 45 元。 */ + [MaxLength(4000)] + [DataMember(Name="detail", EmitDefaultValue=true)] + public string? Detail { get; set; } + + } +} diff --git a/backend/Validation/GeneratedJsonRequiredMembers.cs b/backend/Validation/GeneratedJsonRequiredMembers.cs index 0e9d2f9a..314eab16 100644 --- a/backend/Validation/GeneratedJsonRequiredMembers.cs +++ b/backend/Validation/GeneratedJsonRequiredMembers.cs @@ -21,6 +21,8 @@ public static class GeneratedJsonRequiredMembers public static void Apply(JsonTypeInfo typeInfo) { + TrackResubmitRequestPresence(typeInfo); + if (!RequiredJsonProperties.TryGetValue(typeInfo.Type, out var requiredProperties)) { return; @@ -34,4 +36,44 @@ public static void Apply(JsonTypeInfo typeInfo) } } } + + private static void TrackResubmitRequestPresence(JsonTypeInfo typeInfo) + { + // Nullable PATCH fields need to distinguish an omitted property from an + // explicitly supplied null so the controller can preserve or clear values. + if (typeInfo.Type != typeof(ResubmitBudgetApplicationRequest)) + { + return; + } + + foreach (var property in typeInfo.Properties) + { + var propertyName = property.Name; + if (!propertyName.Equals("activityId", StringComparison.OrdinalIgnoreCase) && + !propertyName.Equals("detail", StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + var setter = property.Set; + if (setter is null) + { + continue; + } + + property.Set = (obj, value) => + { + setter(obj, value); + var request = (ResubmitBudgetApplicationRequest)obj; + if (propertyName.Equals("activityId", StringComparison.OrdinalIgnoreCase)) + { + request.ActivityIdWasProvided = true; + } + else + { + request.DetailWasProvided = true; + } + }; + } + } } diff --git a/backend/Validation/ResubmitBudgetApplicationRequestPresence.cs b/backend/Validation/ResubmitBudgetApplicationRequestPresence.cs new file mode 100644 index 00000000..73c401f4 --- /dev/null +++ b/backend/Validation/ResubmitBudgetApplicationRequestPresence.cs @@ -0,0 +1,8 @@ +namespace Org.OpenAPITools.Models; + +public partial class ResubmitBudgetApplicationRequest +{ + internal bool ActivityIdWasProvided { get; set; } + + internal bool DetailWasProvided { get; set; } +} diff --git a/frontend/src/api/apis/DefaultApi.ts b/frontend/src/api/apis/DefaultApi.ts index 019f0b56..dc631384 100644 --- a/frontend/src/api/apis/DefaultApi.ts +++ b/frontend/src/api/apis/DefaultApi.ts @@ -91,6 +91,11 @@ import { BudgetApplicationFromJSON, BudgetApplicationToJSON, } from "../models/BudgetApplication"; +import { + type BudgetReviewRecord, + BudgetReviewRecordFromJSON, + BudgetReviewRecordToJSON, +} from "../models/BudgetReviewRecord"; import { type BudgetTransaction, BudgetTransactionFromJSON, @@ -368,6 +373,11 @@ import { RegisterRequestFromJSON, RegisterRequestToJSON, } from "../models/RegisterRequest"; +import { + type ResubmitBudgetApplicationRequest, + ResubmitBudgetApplicationRequestFromJSON, + ResubmitBudgetApplicationRequestToJSON, +} from "../models/ResubmitBudgetApplicationRequest"; import { type ReviewActivityBudgetRequest, ReviewActivityBudgetRequestFromJSON, @@ -1098,6 +1108,13 @@ export interface GetBudgetAccountsRequest { fiscalYear?: string; } +export interface GetBudgetApplicationReviewsRequest { + /** + * + */ + applicationId: number; +} + export interface GetBudgetApplicationsRequest { /** * 显式启用分页时的页码,从 1 开始;省略 page 和 pageSize 时返回完整集合以兼容 lookup 调用。 @@ -1881,6 +1898,21 @@ export interface RemoveProjectMemberRequest { projectMemberId: number; } +export interface ResubmitBudgetApplicationOperationRequest { + /** + * 标记一次业务提交;带有 x-idempotency-required: true 的操作必须提供,并在同一次提交及网络重试中复用。 + */ + idempotencyKey: string; + /** + * + */ + applicationId: number; + /** + * + */ + resubmitBudgetApplicationRequest: ResubmitBudgetApplicationRequest; +} + export interface ResubmitClubApplicationRequest { /** * 标记一次业务提交;带有 x-idempotency-required: true 的操作必须提供,并在同一次提交及网络重试中复用。 @@ -6197,6 +6229,74 @@ export class DefaultApi extends runtime.BaseAPI { return await response.value(); } + /** + * Creates request options for getBudgetApplicationReviews without sending the request + */ + async getBudgetApplicationReviewsRequestOpts( + requestParameters: GetBudgetApplicationReviewsRequest, + ): Promise { + if (requestParameters["applicationId"] == null) { + throw new runtime.RequiredError( + "applicationId", + 'Required parameter "applicationId" was null or undefined when calling getBudgetApplicationReviews().', + ); + } + + const queryParameters: any = {}; + + const headerParameters: runtime.HTTPHeaders = {}; + + if (this.configuration && this.configuration.accessToken) { + const token = this.configuration.accessToken; + const tokenString = await token("bearerAuth", []); + + if (tokenString) { + headerParameters["Authorization"] = `Bearer ${tokenString}`; + } + } + + let urlPath = `/api/v1/budget/applications/{applicationId}/reviews`; + urlPath = urlPath.replace( + "{applicationId}", + encodeURIComponent(String(requestParameters["applicationId"])), + ); + + return { + path: urlPath, + method: "GET", + headers: headerParameters, + query: queryParameters, + }; + } + + /** + * 返回指定经费申请的全部审核历史记录,按时间倒序排列。 + * 获取经费申请审核记录 + */ + async getBudgetApplicationReviewsRaw( + requestParameters: GetBudgetApplicationReviewsRequest, + initOverrides?: RequestInit | runtime.InitOverrideFunction, + ): Promise>> { + const requestOptions = await this.getBudgetApplicationReviewsRequestOpts(requestParameters); + const response = await this.request(requestOptions, initOverrides); + + return new runtime.JSONApiResponse(response, (jsonValue) => + jsonValue.map(BudgetReviewRecordFromJSON), + ); + } + + /** + * 返回指定经费申请的全部审核历史记录,按时间倒序排列。 + * 获取经费申请审核记录 + */ + async getBudgetApplicationReviews( + requestParameters: GetBudgetApplicationReviewsRequest, + initOverrides?: RequestInit | runtime.InitOverrideFunction, + ): Promise> { + const response = await this.getBudgetApplicationReviewsRaw(requestParameters, initOverrides); + return await response.value(); + } + /** * Creates request options for getBudgetApplications without sending the request */ @@ -9979,6 +10079,97 @@ export class DefaultApi extends runtime.BaseAPI { await this.removeProjectMemberRaw(requestParameters, initOverrides); } + /** + * Creates request options for resubmitBudgetApplication without sending the request + */ + async resubmitBudgetApplicationRequestOpts( + requestParameters: ResubmitBudgetApplicationOperationRequest, + ): Promise { + if (requestParameters["idempotencyKey"] == null) { + throw new runtime.RequiredError( + "idempotencyKey", + 'Required parameter "idempotencyKey" was null or undefined when calling resubmitBudgetApplication().', + ); + } + + if (requestParameters["applicationId"] == null) { + throw new runtime.RequiredError( + "applicationId", + 'Required parameter "applicationId" was null or undefined when calling resubmitBudgetApplication().', + ); + } + + if (requestParameters["resubmitBudgetApplicationRequest"] == null) { + throw new runtime.RequiredError( + "resubmitBudgetApplicationRequest", + 'Required parameter "resubmitBudgetApplicationRequest" was null or undefined when calling resubmitBudgetApplication().', + ); + } + + const queryParameters: any = {}; + + const headerParameters: runtime.HTTPHeaders = {}; + + headerParameters["Content-Type"] = "application/json"; + + if (requestParameters["idempotencyKey"] != null) { + headerParameters["Idempotency-Key"] = String(requestParameters["idempotencyKey"]); + } + + if (this.configuration && this.configuration.accessToken) { + const token = this.configuration.accessToken; + const tokenString = await token("bearerAuth", []); + + if (tokenString) { + headerParameters["Authorization"] = `Bearer ${tokenString}`; + } + } + + let urlPath = `/api/v1/budget/applications/{applicationId}/resubmit`; + urlPath = urlPath.replace( + "{applicationId}", + encodeURIComponent(String(requestParameters["applicationId"])), + ); + + return { + path: urlPath, + method: "POST", + headers: headerParameters, + query: queryParameters, + body: ResubmitBudgetApplicationRequestToJSON( + requestParameters["resubmitBudgetApplicationRequest"], + ), + }; + } + + /** + * 申请人或具备经费申请权限的负责人可将被驳回的申请修改后重新提交,状态回到待审核,历史审核记录保留。 + * 重新提交被驳回的经费申请 + */ + async resubmitBudgetApplicationRaw( + requestParameters: ResubmitBudgetApplicationOperationRequest, + initOverrides?: RequestInit | runtime.InitOverrideFunction, + ): Promise> { + const requestOptions = await this.resubmitBudgetApplicationRequestOpts(requestParameters); + const response = await this.request(requestOptions, initOverrides); + + return new runtime.JSONApiResponse(response, (jsonValue) => + BudgetApplicationFromJSON(jsonValue), + ); + } + + /** + * 申请人或具备经费申请权限的负责人可将被驳回的申请修改后重新提交,状态回到待审核,历史审核记录保留。 + * 重新提交被驳回的经费申请 + */ + async resubmitBudgetApplication( + requestParameters: ResubmitBudgetApplicationOperationRequest, + initOverrides?: RequestInit | runtime.InitOverrideFunction, + ): Promise { + const response = await this.resubmitBudgetApplicationRaw(requestParameters, initOverrides); + return await response.value(); + } + /** * Creates request options for resubmitClubApplication without sending the request */ diff --git a/frontend/src/api/models/BudgetApplicationType.ts b/frontend/src/api/models/BudgetApplicationType.ts new file mode 100644 index 00000000..db1671e6 --- /dev/null +++ b/frontend/src/api/models/BudgetApplicationType.ts @@ -0,0 +1,59 @@ +// @ts-nocheck +/* tslint:disable */ +/* eslint-disable */ +/** + * ClubHub API + * ClubHub 高校社团运营与协同管理平台 API。 **API-first 开发流程**:修改本文件 → push → CI 自动生成前后端代码 → git pull 拉取生成代码 → 在 Controller / Services / Vue 组件中手写业务逻辑。 + * + * The version of the OpenAPI document: 0.1.0 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + +/** + * 经费申请类型。 + * @export + */ +export const BudgetApplicationType = { + ActivityBudget: "activity_budget", + Purchase: "purchase", + Reimbursement: "reimbursement", +} as const; +export type BudgetApplicationType = + (typeof BudgetApplicationType)[keyof typeof BudgetApplicationType]; + +export function instanceOfBudgetApplicationType(value: any): boolean { + for (const key in BudgetApplicationType) { + if (Object.prototype.hasOwnProperty.call(BudgetApplicationType, key)) { + if (BudgetApplicationType[key as keyof typeof BudgetApplicationType] === value) { + return true; + } + } + } + return false; +} + +export function BudgetApplicationTypeFromJSON(json: any): BudgetApplicationType { + return BudgetApplicationTypeFromJSONTyped(json, false); +} + +export function BudgetApplicationTypeFromJSONTyped( + json: any, + ignoreDiscriminator: boolean, +): BudgetApplicationType { + return json as BudgetApplicationType; +} + +export function BudgetApplicationTypeToJSON(value?: BudgetApplicationType | null): any { + return value as any; +} + +export function BudgetApplicationTypeToJSONTyped( + value: any, + ignoreDiscriminator: boolean, +): BudgetApplicationType { + return value as BudgetApplicationType; +} diff --git a/frontend/src/api/models/ResubmitBudgetApplicationRequest.ts b/frontend/src/api/models/ResubmitBudgetApplicationRequest.ts new file mode 100644 index 00000000..e2060b9c --- /dev/null +++ b/frontend/src/api/models/ResubmitBudgetApplicationRequest.ts @@ -0,0 +1,122 @@ +// @ts-nocheck +/* tslint:disable */ +/* eslint-disable */ +/** + * ClubHub API + * ClubHub 高校社团运营与协同管理平台 API。 **API-first 开发流程**:修改本文件 → push → CI 自动生成前后端代码 → git pull 拉取生成代码 → 在 Controller / Services / Vue 组件中手写业务逻辑。 + * + * The version of the OpenAPI document: 0.1.0 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + +import { mapValues } from "../runtime"; +import type { BudgetApplicationType } from "./BudgetApplicationType"; +import { + BudgetApplicationTypeFromJSON, + BudgetApplicationTypeFromJSONTyped, + BudgetApplicationTypeToJSON, + BudgetApplicationTypeToJSONTyped, +} from "./BudgetApplicationType"; + +/** + * 重新提交被驳回经费申请的请求,所有字段均为可选,仅更新传入的字段。 + * @export + * @interface ResubmitBudgetApplicationRequest + */ +export interface ResubmitBudgetApplicationRequest { + /** + * 关联活动,可不填。 + */ + activityId?: number | null; + /** + * + */ + type?: BudgetApplicationType | null; + /** + * 经费申请标题。 + */ + title?: string; + /** + * 申请金额,必须大于 0。 + */ + amount?: number; + /** + * 经费用途。 + */ + purpose?: string; + /** + * 经费明细说明。 + */ + detail?: string | null; +} + +/** + * Check if a given object implements the ResubmitBudgetApplicationRequest interface. + */ +export function instanceOfResubmitBudgetApplicationRequest( + value: object, +): value is ResubmitBudgetApplicationRequest { + return true; +} + +export function ResubmitBudgetApplicationRequestFromJSON( + json: any, +): ResubmitBudgetApplicationRequest { + return ResubmitBudgetApplicationRequestFromJSONTyped(json, false); +} + +export function ResubmitBudgetApplicationRequestFromJSONTyped( + json: any, + ignoreDiscriminator: boolean, +): ResubmitBudgetApplicationRequest { + if (json == null) { + return json; + } + return { + activityId: + json["activityId"] === undefined + ? undefined + : json["activityId"] === null + ? null + : json["activityId"], + type: + json["type"] === undefined + ? undefined + : json["type"] === null + ? null + : BudgetApplicationTypeFromJSON(json["type"]), + title: json["title"] == null ? undefined : json["title"], + amount: json["amount"] == null ? undefined : json["amount"], + purpose: json["purpose"] == null ? undefined : json["purpose"], + detail: + json["detail"] === undefined ? undefined : json["detail"] === null ? null : json["detail"], + }; +} + +export function ResubmitBudgetApplicationRequestToJSON( + json: any, +): ResubmitBudgetApplicationRequest { + return ResubmitBudgetApplicationRequestToJSONTyped(json, false); +} + +export function ResubmitBudgetApplicationRequestToJSONTyped( + value?: ResubmitBudgetApplicationRequest | null, + ignoreDiscriminator: boolean = false, +): any { + if (value == null) { + return value; + } + + return { + activityId: value["activityId"], + type: BudgetApplicationTypeToJSON(value["type"]), + title: value["title"], + amount: value["amount"], + purpose: value["purpose"], + detail: value["detail"], + }; +} diff --git a/frontend/src/api/models/index.ts b/frontend/src/api/models/index.ts index 8875f23a..a2000b3a 100644 --- a/frontend/src/api/models/index.ts +++ b/frontend/src/api/models/index.ts @@ -25,6 +25,7 @@ export * from "./AwardSchemeRecord"; export * from "./BorrowMaterialRequest"; export * from "./BudgetAccount"; export * from "./BudgetApplication"; +export * from "./BudgetApplicationType"; export * from "./BudgetReviewRecord"; export * from "./BudgetTransaction"; export * from "./CancelBudgetApplicationRequest"; @@ -93,6 +94,7 @@ export * from "./RecruitmentApplication"; export * from "./RecruitmentWorkflowStatus"; export * from "./RegisterMaterialDamageRequest"; export * from "./RegisterRequest"; +export * from "./ResubmitBudgetApplicationRequest"; export * from "./ReviewActivityBudgetRequest"; export * from "./ReviewActivityRequest"; export * from "./ReviewAwardApplicationRequest"; diff --git a/frontend/src/authLayout.test.ts b/frontend/src/authLayout.test.ts new file mode 100644 index 00000000..2397b625 --- /dev/null +++ b/frontend/src/authLayout.test.ts @@ -0,0 +1,14 @@ +import { describe, expect, it } from "vitest"; +import authFlowSource from "./views/AuthFlow.vue?raw"; + +describe("登录注册页面布局", () => { + it("使用完整视口高度垂直居中认证内容", () => { + const authShellRule = authFlowSource.match(/\.auth-shell\s*\{([\s\S]*?)\}/)?.[1]; + + expect(authShellRule).toBeDefined(); + expect(authShellRule).toContain("min-height: 100vh"); + expect(authShellRule).toContain("align-items: center"); + expect(authShellRule).toContain("padding-block: 24px"); + expect(authShellRule).not.toContain("padding-top"); + }); +}); diff --git a/frontend/src/views/AuthFlow.vue b/frontend/src/views/AuthFlow.vue index 444fc425..97c3a1e9 100644 --- a/frontend/src/views/AuthFlow.vue +++ b/frontend/src/views/AuthFlow.vue @@ -630,12 +630,12 @@ if (!auth.value) void loadCaptcha(false); } .auth-shell { - min-height: 520px; + min-height: 100vh; display: grid; grid-template-columns: minmax(260px, 1fr) minmax(320px, 420px); gap: 28px; align-items: center; - padding-top: 24px; + padding-block: 24px; } .register-shell { diff --git a/frontend/src/views/BudgetManagement.vue b/frontend/src/views/BudgetManagement.vue index 7368abad..40dded02 100644 --- a/frontend/src/views/BudgetManagement.vue +++ b/frontend/src/views/BudgetManagement.vue @@ -9,7 +9,12 @@ import { BUDGET_APPLY_PERMISSION, BUDGET_REVIEW_PERMISSION, } from "../budgetPermissions"; -import type { BudgetAccount, BudgetApplication, BudgetTransaction } from "../api/models"; +import type { + BudgetAccount, + BudgetApplication, + BudgetReviewRecord, + BudgetTransaction, +} from "../api/models"; import { apiClient, createIdempotencyKey } from "../apiClient"; import { requestJson } from "../composables/useApiRequest"; import { formatVenueReservationDateTime } from "../beijingTime"; @@ -50,6 +55,15 @@ interface ReviewForm { comment: string; } +interface ResubmitForm { + activityId?: number | null; + type: "activity_budget" | "purchase" | "reimbursement"; + title: string; + amount: number; + purpose: string; + detail: string; +} + const auth = ref(readAuth()); const clubs = ref([]); const accounts = ref([]); @@ -67,6 +81,12 @@ const reviewTarget = ref(null); const accountFormRef = ref(); const applicationFormRef = ref(); const reviewFormRef = ref(); +const resubmitDialogVisible = ref(false); +const resubmitTarget = ref(null); +const resubmitFormRef = ref(); +const reviewHistoryVisible = ref(false); +const reviewRecords = ref([]); +const loadingReviews = ref(false); const accountForm = reactive({ clubId: undefined, @@ -91,6 +111,15 @@ const reviewForm = reactive({ comment: "", }); +const resubmitForm = reactive({ + activityId: undefined, + type: "activity_budget", + title: "", + amount: 0, + purpose: "", + detail: "", +}); + const hasGlobalAccess = computed(() => { const permissions = auth.value?.permissions ?? []; return permissions.includes("*"); @@ -232,6 +261,22 @@ const reviewRules: FormRules = { comment: [{ max: 255, message: "审核意见不能超过 255 个字符", trigger: "blur" }], }; +const resubmitRules: FormRules = { + type: [{ required: true, message: "请选择申请类型", trigger: "change" }], + title: [ + { required: true, message: "请输入申请标题", trigger: "blur" }, + { max: 255, message: "申请标题不能超过 255 个字符", trigger: "blur" }, + ], + amount: [ + { required: true, message: "请输入申请金额", trigger: "change" }, + { type: "number", min: 0.01, message: "申请金额必须大于 0", trigger: "change" }, + ], + purpose: [ + { required: true, message: "请输入经费用途", trigger: "blur" }, + { max: 255, message: "经费用途不能超过 255 个字符", trigger: "blur" }, + ], +}; + function ensureActiveClub() { if (activeClubId.value !== undefined) return; activeClubId.value = canViewAllBudgets.value ? 0 : visibleClubs.value[0]?.id; @@ -536,6 +581,72 @@ async function cancelApplication(application: BudgetApplication) { } } +function openResubmitApplication(application: BudgetApplication) { + const currentUserId = auth.value?.user.id; + if (application.applicantUserId !== currentUserId && !canApplyForClub(application.clubId)) { + ElMessage.warning("当前账号没有重新提交该经费申请的权限。"); + return; + } + + resubmitTarget.value = application; + resubmitForm.activityId = application.activityId; + resubmitForm.type = application.type; + resubmitForm.title = application.title; + resubmitForm.amount = application.amount; + resubmitForm.purpose = application.purpose; + resubmitForm.detail = application.detail ?? ""; + resubmitDialogVisible.value = true; +} + +async function submitResubmit() { + if (!resubmitTarget.value || !(await resubmitFormRef.value?.validate())) return; + + try { + await apiClient.resubmitBudgetApplication({ + idempotencyKey: createIdempotencyKey(), + applicationId: resubmitTarget.value.id, + resubmitBudgetApplicationRequest: { + activityId: resubmitForm.activityId ?? null, + type: resubmitForm.type, + title: resubmitForm.title, + amount: resubmitForm.amount, + purpose: resubmitForm.purpose, + detail: resubmitForm.detail.trim() || null, + }, + }); + + resubmitDialogVisible.value = false; + ElMessage.success("经费申请已重新提交,等待审核"); + await refreshData(); + } catch (error) { + ElMessage.error(error instanceof Error ? error.message : "重新提交经费申请失败"); + } +} + +async function openReviewHistory(application: BudgetApplication) { + reviewHistoryVisible.value = true; + loadingReviews.value = true; + try { + reviewRecords.value = await apiClient.getBudgetApplicationReviews({ + applicationId: application.id, + }); + } catch (error) { + ElMessage.error(error instanceof Error ? error.message : "加载审核历史失败"); + reviewRecords.value = []; + } finally { + loadingReviews.value = false; + } +} + +function reviewActionLabel(approved: boolean) { + return approved ? "审核通过" : "审核驳回"; +} + +function activityOptionsForSelectedResubmitAccount() { + if (!resubmitTarget.value) return []; + return activities.value.filter((activity) => activity.clubId === resubmitTarget.value?.clubId); +} + onMounted(() => { refreshAllData(); }); @@ -726,6 +837,21 @@ onMounted(() => { > 撤销 + + 重新提交 + + + 审核历史 + @@ -902,6 +1028,88 @@ onMounted(() => { 保存审核 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + +

+ {{ record.reviewerName }} + + {{ reviewActionLabel(record.approved) }} + +

+

+ {{ record.comment }} +

+
+
+ +
+ +