逻辑修改

This commit is contained in:
18631081161 2026-01-16 00:43:02 +08:00
parent 1c162e54c4
commit 61f89dfda6
25 changed files with 2644 additions and 127 deletions

View File

@ -21,4 +21,9 @@ public interface IOrganizationalAuthorizationService
/// 获取用户可访问的所有组织单位ID包括自身和所有下级
/// </summary>
Task<IEnumerable<int>> GetAccessibleUnitIdsAsync(int userUnitId);
/// <summary>
/// 检查目标单位是否是当前单位的上级单位
/// </summary>
Task<bool> IsAncestorUnitAsync(int targetUnitId, int currentUnitId);
}

View File

@ -31,4 +31,11 @@ public class OrganizationalAuthorizationService : IOrganizationalAuthorizationSe
var subordinateIds = await _organizationService.GetAllSubordinateIdsAsync(userUnitId);
return new[] { userUnitId }.Concat(subordinateIds);
}
public async Task<bool> IsAncestorUnitAsync(int targetUnitId, int currentUnitId)
{
// 检查 targetUnitId 是否是 currentUnitId 的上级单位
var ancestorIds = await _organizationService.GetAllAncestorIdsAsync(currentUnitId);
return ancestorIds.Contains(targetUnitId);
}
}

View File

@ -110,10 +110,12 @@ public class AllocationsController : BaseApiController
if (allocation == null)
return NotFound(new { message = "配额不存在" });
// 检查访问权限:只能查看自己创建的或分配给自己及下级的配额
// 检查访问权限:可以查看自己创建的、分配给自己及下级的、或分配给上级单位的配额
var canAccess = allocation.CreatedByUnitId == unitId.Value ||
allocation.Distributions.Any(d =>
_authorizationService.CanAccessUnitAsync(unitId.Value, d.TargetUnitId).GetAwaiter().GetResult());
_authorizationService.CanAccessUnitAsync(unitId.Value, d.TargetUnitId).GetAwaiter().GetResult()) ||
allocation.Distributions.Any(d =>
_authorizationService.IsAncestorUnitAsync(d.TargetUnitId, unitId.Value).GetAwaiter().GetResult());
if (!canAccess)
return Forbid();
@ -318,6 +320,50 @@ public class AllocationsController : BaseApiController
});
}
/// <summary>
/// 获取配额分配的上报历史记录
/// </summary>
[HttpGet("distributions/{distributionId}/reports")]
public async Task<IActionResult> GetConsumptionReports(int distributionId)
{
var unitId = GetCurrentUnitId();
var unitLevel = GetCurrentUnitLevel();
if (unitId == null)
return Unauthorized(new { message = "无法获取用户组织信息" });
// 检查分配记录是否存在
var distribution = await _allocationService.GetDistributionByIdAsync(distributionId);
if (distribution == null)
return NotFound(new { message = "配额分配记录不存在" });
// 检查访问权限:
// 1. 师团级可以查看所有记录
// 2. 可以查看分配给自己单位的记录
// 3. 可以查看分配给上级单位的记录
// 4. 可以查看分配给下级单位的记录
var canAccess = unitLevel == OrganizationalLevel.Division ||
distribution.TargetUnitId == unitId.Value ||
await _authorizationService.IsAncestorUnitAsync(distribution.TargetUnitId, unitId.Value) ||
await _authorizationService.CanAccessUnitAsync(unitId.Value, distribution.TargetUnitId);
if (!canAccess)
return Forbid();
var reports = await _allocationService.GetConsumptionReportsAsync(distributionId);
var response = reports.Select(r => new
{
id = r.Id,
reportedAmount = r.ReportedAmount,
cumulativeAmount = r.CumulativeAmount,
remarks = r.Remarks,
reportedByUserName = r.ReportedByUser?.DisplayName,
reportedAt = r.ReportedAt
});
return Ok(response);
}
/// <summary>
/// 映射实体到响应DTO
/// </summary>

View File

@ -91,6 +91,7 @@ public class PersonnelController : BaseApiController
var items = allPersonnel
.Skip((pageNumber - 1) * pageSize)
.Take(pageSize)
.Select(MapToResponse)
.ToList();
return Ok(new
@ -103,6 +104,45 @@ public class PersonnelController : BaseApiController
});
}
/// <summary>
/// 映射人员实体到响应DTO
/// </summary>
private static PersonnelResponse MapToResponse(Personnel personnel)
{
return new PersonnelResponse
{
Id = personnel.Id,
Name = personnel.Name,
PhotoPath = personnel.PhotoPath,
Position = personnel.Position,
Rank = personnel.Rank,
Gender = personnel.Gender,
IdNumber = personnel.IdNumber,
ProfessionalTitle = personnel.ProfessionalTitle,
EducationLevel = personnel.EducationLevel,
Age = personnel.Age,
Height = personnel.Height,
ContactInfo = personnel.ContactInfo,
Hometown = personnel.Hometown,
TrainingParticipation = personnel.TrainingParticipation,
Achievements = personnel.Achievements,
SupportingDocuments = personnel.SupportingDocuments,
Ethnicity = personnel.Ethnicity,
PoliticalStatus = personnel.PoliticalStatus,
BirthDate = personnel.BirthDate,
EnlistmentDate = personnel.EnlistmentDate,
Specialty = personnel.Specialty,
SubmittedByUnitId = personnel.SubmittedByUnitId,
SubmittedByUnitName = personnel.SubmittedByUnit?.Name,
ApprovedLevel = personnel.ApprovedLevel,
ApprovedByUnitId = personnel.ApprovedByUnitId,
ApprovedByUnitName = personnel.ApprovedByUnit?.Name,
Status = personnel.Status,
SubmittedAt = personnel.SubmittedAt,
ApprovedAt = personnel.ApprovedAt
};
}
/// <summary>
/// 获取待审批的人员列表
/// </summary>
@ -143,7 +183,7 @@ public class PersonnelController : BaseApiController
return Forbid();
}
return Ok(personnel);
return Ok(MapToResponse(personnel));
}
/// <summary>
@ -473,7 +513,7 @@ public class PersonnelController : BaseApiController
/// </summary>
[HttpPost("{id}/approve")]
[Authorize(Policy = "RegimentLevel")] // 团级及以上权限
public async Task<IActionResult> Approve(int id, [FromBody] ApprovePersonnelRequest request)
public async Task<IActionResult> Approve(int id)
{
var unitId = GetCurrentUnitId();
var userId = GetCurrentUserId();
@ -492,8 +532,9 @@ public class PersonnelController : BaseApiController
try
{
var personnel = await _personnelService.ApproveAsync(id, unitId.Value, request.Level);
return Ok(personnel);
// 不传递level参数让服务层根据人员所在单位自动确定等级
var personnel = await _personnelService.ApproveAsync(id, unitId.Value);
return Ok(MapToResponse(personnel));
}
catch (ArgumentException ex)
{

View File

@ -35,9 +35,22 @@ public class StatsController : BaseApiController
var unitIds = await GetUnitAndSubordinateIds(unitId.Value);
// 统计配额数
var allocationsCount = await _context.MaterialAllocations
.Where(a => a.CreatedByUnitId == unitId.Value || unitIds.Contains(a.CreatedByUnitId))
.CountAsync();
int allocationsCount;
if (unitLevel == OrganizationalLevel.Division)
{
// 师团级:统计创建的配额数
allocationsCount = await _context.MaterialAllocations
.Where(a => a.CreatedByUnitId == unitId.Value)
.CountAsync();
}
else
{
// 团部及以下:统计分配给本单位及下级单位的配额数
allocationsCount = await _context.MaterialAllocations
.Include(a => a.Distributions)
.Where(a => a.Distributions.Any(d => unitIds.Contains(d.TargetUnitId)))
.CountAsync();
}
// 统计完成率
var distributions = await _context.AllocationDistributions

View File

@ -22,6 +22,7 @@ public class ApplicationDbContext : DbContext
public DbSet<PersonnelApprovalHistory> PersonnelApprovalHistories => Set<PersonnelApprovalHistory>();
public DbSet<ApprovalRequest> ApprovalRequests => Set<ApprovalRequest>();
public DbSet<AuditLog> AuditLogs => Set<AuditLog>();
public DbSet<ConsumptionReport> ConsumptionReports => Set<ConsumptionReport>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
@ -188,5 +189,24 @@ public class ApplicationDbContext : DbContext
.HasForeignKey(e => e.ReviewedByUnitId)
.OnDelete(DeleteBehavior.NoAction);
});
// ConsumptionReport 配置
modelBuilder.Entity<ConsumptionReport>(entity =>
{
entity.HasKey(e => e.Id);
entity.Property(e => e.ReportedAmount).HasPrecision(18, 2);
entity.Property(e => e.CumulativeAmount).HasPrecision(18, 2);
entity.Property(e => e.Remarks).HasMaxLength(500);
entity.HasOne(e => e.AllocationDistribution)
.WithMany()
.HasForeignKey(e => e.AllocationDistributionId)
.OnDelete(DeleteBehavior.Cascade);
entity.HasOne(e => e.ReportedByUser)
.WithMany()
.HasForeignKey(e => e.ReportedByUserId)
.OnDelete(DeleteBehavior.NoAction);
entity.HasIndex(e => e.AllocationDistributionId);
entity.HasIndex(e => e.ReportedAt);
});
}
}

View File

@ -0,0 +1,748 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Metadata;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using MilitaryTrainingManagement.Data;
#nullable disable
namespace MilitaryTrainingManagement.Migrations
{
[DbContext(typeof(ApplicationDbContext))]
[Migration("20260115152942_AddConsumptionReportTable")]
partial class AddConsumptionReportTable
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "8.0.0")
.HasAnnotation("Relational:MaxIdentifierLength", 128);
SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder);
modelBuilder.Entity("MilitaryTrainingManagement.Models.Entities.AllocationDistribution", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<decimal?>("ActualCompletion")
.HasPrecision(18, 2)
.HasColumnType("decimal(18,2)");
b.Property<int>("AllocationId")
.HasColumnType("int");
b.Property<DateTime?>("ApprovedAt")
.HasColumnType("datetime2");
b.Property<int?>("ApprovedByUserId")
.HasColumnType("int");
b.Property<bool>("IsApproved")
.HasColumnType("bit");
b.Property<DateTime?>("ReportedAt")
.HasColumnType("datetime2");
b.Property<int?>("ReportedByUserId")
.HasColumnType("int");
b.Property<int>("TargetUnitId")
.HasColumnType("int");
b.Property<decimal>("UnitQuota")
.HasPrecision(18, 2)
.HasColumnType("decimal(18,2)");
b.HasKey("Id");
b.HasIndex("AllocationId");
b.HasIndex("ApprovedByUserId");
b.HasIndex("ReportedByUserId");
b.HasIndex("TargetUnitId");
b.ToTable("AllocationDistributions");
});
modelBuilder.Entity("MilitaryTrainingManagement.Models.Entities.ApprovalRequest", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<string>("OriginalData")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.Property<string>("Reason")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("nvarchar(500)");
b.Property<DateTime>("RequestedAt")
.HasColumnType("datetime2");
b.Property<int>("RequestedByUnitId")
.HasColumnType("int");
b.Property<int>("RequestedByUserId")
.HasColumnType("int");
b.Property<string>("RequestedChanges")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.Property<string>("ReviewComments")
.HasColumnType("nvarchar(max)");
b.Property<DateTime?>("ReviewedAt")
.HasColumnType("datetime2");
b.Property<int?>("ReviewedByUserId")
.HasColumnType("int");
b.Property<int>("Status")
.HasColumnType("int");
b.Property<int>("TargetEntityId")
.HasColumnType("int");
b.Property<int>("Type")
.HasColumnType("int");
b.HasKey("Id");
b.HasIndex("RequestedByUnitId");
b.HasIndex("RequestedByUserId");
b.HasIndex("ReviewedByUserId");
b.ToTable("ApprovalRequests");
});
modelBuilder.Entity("MilitaryTrainingManagement.Models.Entities.AuditLog", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<string>("Action")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("nvarchar(50)");
b.Property<string>("ChangedFields")
.HasColumnType("nvarchar(max)");
b.Property<string>("Description")
.HasMaxLength(500)
.HasColumnType("nvarchar(500)");
b.Property<int>("EntityId")
.HasColumnType("int");
b.Property<string>("EntityType")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("nvarchar(100)");
b.Property<string>("ErrorMessage")
.HasMaxLength(2000)
.HasColumnType("nvarchar(2000)");
b.Property<string>("IpAddress")
.HasMaxLength(50)
.HasColumnType("nvarchar(50)");
b.Property<bool>("IsSuccess")
.HasColumnType("bit");
b.Property<string>("NewValues")
.HasColumnType("nvarchar(max)");
b.Property<string>("OldValues")
.HasColumnType("nvarchar(max)");
b.Property<int?>("OrganizationalUnitId")
.HasColumnType("int");
b.Property<string>("RequestPath")
.HasMaxLength(500)
.HasColumnType("nvarchar(500)");
b.Property<DateTime>("Timestamp")
.HasColumnType("datetime2");
b.Property<string>("UserAgent")
.HasMaxLength(500)
.HasColumnType("nvarchar(500)");
b.Property<int?>("UserId")
.HasColumnType("int");
b.HasKey("Id");
b.HasIndex("Action");
b.HasIndex("EntityId");
b.HasIndex("EntityType");
b.HasIndex("OrganizationalUnitId");
b.HasIndex("Timestamp");
b.HasIndex("UserId");
b.ToTable("AuditLogs");
});
modelBuilder.Entity("MilitaryTrainingManagement.Models.Entities.ConsumptionReport", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<int>("AllocationDistributionId")
.HasColumnType("int");
b.Property<decimal>("CumulativeAmount")
.HasPrecision(18, 2)
.HasColumnType("decimal(18,2)");
b.Property<string>("Remarks")
.HasMaxLength(500)
.HasColumnType("nvarchar(500)");
b.Property<decimal>("ReportedAmount")
.HasPrecision(18, 2)
.HasColumnType("decimal(18,2)");
b.Property<DateTime>("ReportedAt")
.HasColumnType("datetime2");
b.Property<int>("ReportedByUserId")
.HasColumnType("int");
b.HasKey("Id");
b.HasIndex("AllocationDistributionId");
b.HasIndex("ReportedAt");
b.HasIndex("ReportedByUserId");
b.ToTable("ConsumptionReports");
});
modelBuilder.Entity("MilitaryTrainingManagement.Models.Entities.MaterialAllocation", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<string>("Category")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("nvarchar(100)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime2");
b.Property<int>("CreatedByUnitId")
.HasColumnType("int");
b.Property<string>("MaterialName")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("nvarchar(200)");
b.Property<decimal>("TotalQuota")
.HasPrecision(18, 2)
.HasColumnType("decimal(18,2)");
b.Property<string>("Unit")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("nvarchar(50)");
b.HasKey("Id");
b.HasIndex("CreatedByUnitId");
b.ToTable("MaterialAllocations");
});
modelBuilder.Entity("MilitaryTrainingManagement.Models.Entities.MaterialCategory", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime2");
b.Property<string>("Description")
.HasMaxLength(200)
.HasColumnType("nvarchar(200)");
b.Property<bool>("IsActive")
.HasColumnType("bit");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("nvarchar(50)");
b.Property<int>("SortOrder")
.HasColumnType("int");
b.HasKey("Id");
b.HasIndex("Name")
.IsUnique();
b.ToTable("MaterialCategories");
});
modelBuilder.Entity("MilitaryTrainingManagement.Models.Entities.OrganizationalUnit", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime2");
b.Property<int>("Level")
.HasColumnType("int");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("nvarchar(100)");
b.Property<int?>("ParentId")
.HasColumnType("int");
b.HasKey("Id");
b.HasIndex("ParentId");
b.ToTable("OrganizationalUnits");
});
modelBuilder.Entity("MilitaryTrainingManagement.Models.Entities.Personnel", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<string>("Achievements")
.HasColumnType("nvarchar(max)");
b.Property<int>("Age")
.HasColumnType("int");
b.Property<DateTime?>("ApprovedAt")
.HasColumnType("datetime2");
b.Property<int?>("ApprovedByUnitId")
.HasColumnType("int");
b.Property<int?>("ApprovedLevel")
.HasColumnType("int");
b.Property<string>("BirthDate")
.HasColumnType("nvarchar(max)");
b.Property<string>("ContactInfo")
.HasColumnType("nvarchar(max)");
b.Property<string>("EducationLevel")
.HasColumnType("nvarchar(max)");
b.Property<string>("EnlistmentDate")
.HasColumnType("nvarchar(max)");
b.Property<string>("Ethnicity")
.HasColumnType("nvarchar(max)");
b.Property<string>("Gender")
.IsRequired()
.HasMaxLength(10)
.HasColumnType("nvarchar(10)");
b.Property<decimal?>("Height")
.HasPrecision(5, 2)
.HasColumnType("decimal(5,2)");
b.Property<string>("Hometown")
.HasColumnType("nvarchar(max)");
b.Property<string>("IdNumber")
.HasMaxLength(50)
.HasColumnType("nvarchar(50)");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("nvarchar(50)");
b.Property<string>("PhotoPath")
.HasColumnType("nvarchar(max)");
b.Property<string>("PoliticalStatus")
.HasColumnType("nvarchar(max)");
b.Property<string>("Position")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("nvarchar(100)");
b.Property<string>("ProfessionalTitle")
.HasColumnType("nvarchar(max)");
b.Property<string>("Rank")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("nvarchar(50)");
b.Property<string>("Specialty")
.HasColumnType("nvarchar(max)");
b.Property<int>("Status")
.HasColumnType("int");
b.Property<DateTime>("SubmittedAt")
.HasColumnType("datetime2");
b.Property<int>("SubmittedByUnitId")
.HasColumnType("int");
b.Property<string>("SupportingDocuments")
.HasColumnType("nvarchar(max)");
b.Property<string>("TrainingParticipation")
.HasColumnType("nvarchar(max)");
b.Property<string>("Unit")
.HasColumnType("nvarchar(max)");
b.HasKey("Id");
b.HasIndex("ApprovedByUnitId");
b.HasIndex("SubmittedByUnitId");
b.ToTable("Personnel");
});
modelBuilder.Entity("MilitaryTrainingManagement.Models.Entities.PersonnelApprovalHistory", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<int>("Action")
.HasColumnType("int");
b.Property<string>("Comments")
.HasColumnType("nvarchar(max)");
b.Property<int?>("NewLevel")
.HasColumnType("int");
b.Property<int>("NewStatus")
.HasColumnType("int");
b.Property<int>("PersonnelId")
.HasColumnType("int");
b.Property<int?>("PreviousLevel")
.HasColumnType("int");
b.Property<int?>("PreviousStatus")
.HasColumnType("int");
b.Property<DateTime>("ReviewedAt")
.HasColumnType("datetime2");
b.Property<int?>("ReviewedByUnitId")
.HasColumnType("int");
b.Property<int>("ReviewedByUserId")
.HasColumnType("int");
b.HasKey("Id");
b.HasIndex("PersonnelId");
b.HasIndex("ReviewedByUnitId");
b.HasIndex("ReviewedByUserId");
b.ToTable("PersonnelApprovalHistories");
});
modelBuilder.Entity("MilitaryTrainingManagement.Models.Entities.UserAccount", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime2");
b.Property<string>("DisplayName")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("nvarchar(100)");
b.Property<bool>("IsActive")
.HasColumnType("bit");
b.Property<DateTime?>("LastLoginAt")
.HasColumnType("datetime2");
b.Property<int>("OrganizationalUnitId")
.HasColumnType("int");
b.Property<string>("PasswordHash")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.Property<string>("PlainPassword")
.HasColumnType("nvarchar(max)");
b.Property<string>("Username")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("nvarchar(50)");
b.HasKey("Id");
b.HasIndex("OrganizationalUnitId");
b.HasIndex("Username")
.IsUnique();
b.ToTable("UserAccounts");
});
modelBuilder.Entity("MilitaryTrainingManagement.Models.Entities.AllocationDistribution", b =>
{
b.HasOne("MilitaryTrainingManagement.Models.Entities.MaterialAllocation", "Allocation")
.WithMany("Distributions")
.HasForeignKey("AllocationId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("MilitaryTrainingManagement.Models.Entities.UserAccount", "ApprovedByUser")
.WithMany()
.HasForeignKey("ApprovedByUserId")
.OnDelete(DeleteBehavior.NoAction);
b.HasOne("MilitaryTrainingManagement.Models.Entities.UserAccount", "ReportedByUser")
.WithMany()
.HasForeignKey("ReportedByUserId")
.OnDelete(DeleteBehavior.NoAction);
b.HasOne("MilitaryTrainingManagement.Models.Entities.OrganizationalUnit", "TargetUnit")
.WithMany()
.HasForeignKey("TargetUnitId")
.OnDelete(DeleteBehavior.NoAction)
.IsRequired();
b.Navigation("Allocation");
b.Navigation("ApprovedByUser");
b.Navigation("ReportedByUser");
b.Navigation("TargetUnit");
});
modelBuilder.Entity("MilitaryTrainingManagement.Models.Entities.ApprovalRequest", b =>
{
b.HasOne("MilitaryTrainingManagement.Models.Entities.OrganizationalUnit", "RequestedByUnit")
.WithMany()
.HasForeignKey("RequestedByUnitId")
.OnDelete(DeleteBehavior.NoAction)
.IsRequired();
b.HasOne("MilitaryTrainingManagement.Models.Entities.UserAccount", "RequestedByUser")
.WithMany()
.HasForeignKey("RequestedByUserId")
.OnDelete(DeleteBehavior.NoAction)
.IsRequired();
b.HasOne("MilitaryTrainingManagement.Models.Entities.UserAccount", "ReviewedByUser")
.WithMany()
.HasForeignKey("ReviewedByUserId")
.OnDelete(DeleteBehavior.NoAction);
b.Navigation("RequestedByUnit");
b.Navigation("RequestedByUser");
b.Navigation("ReviewedByUser");
});
modelBuilder.Entity("MilitaryTrainingManagement.Models.Entities.AuditLog", b =>
{
b.HasOne("MilitaryTrainingManagement.Models.Entities.OrganizationalUnit", "OrganizationalUnit")
.WithMany()
.HasForeignKey("OrganizationalUnitId")
.OnDelete(DeleteBehavior.NoAction);
b.HasOne("MilitaryTrainingManagement.Models.Entities.UserAccount", "User")
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.NoAction);
b.Navigation("OrganizationalUnit");
b.Navigation("User");
});
modelBuilder.Entity("MilitaryTrainingManagement.Models.Entities.ConsumptionReport", b =>
{
b.HasOne("MilitaryTrainingManagement.Models.Entities.AllocationDistribution", "AllocationDistribution")
.WithMany()
.HasForeignKey("AllocationDistributionId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("MilitaryTrainingManagement.Models.Entities.UserAccount", "ReportedByUser")
.WithMany()
.HasForeignKey("ReportedByUserId")
.OnDelete(DeleteBehavior.NoAction)
.IsRequired();
b.Navigation("AllocationDistribution");
b.Navigation("ReportedByUser");
});
modelBuilder.Entity("MilitaryTrainingManagement.Models.Entities.MaterialAllocation", b =>
{
b.HasOne("MilitaryTrainingManagement.Models.Entities.OrganizationalUnit", "CreatedByUnit")
.WithMany()
.HasForeignKey("CreatedByUnitId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("CreatedByUnit");
});
modelBuilder.Entity("MilitaryTrainingManagement.Models.Entities.OrganizationalUnit", b =>
{
b.HasOne("MilitaryTrainingManagement.Models.Entities.OrganizationalUnit", "Parent")
.WithMany("Children")
.HasForeignKey("ParentId")
.OnDelete(DeleteBehavior.Restrict);
b.Navigation("Parent");
});
modelBuilder.Entity("MilitaryTrainingManagement.Models.Entities.Personnel", b =>
{
b.HasOne("MilitaryTrainingManagement.Models.Entities.OrganizationalUnit", "ApprovedByUnit")
.WithMany()
.HasForeignKey("ApprovedByUnitId")
.OnDelete(DeleteBehavior.NoAction);
b.HasOne("MilitaryTrainingManagement.Models.Entities.OrganizationalUnit", "SubmittedByUnit")
.WithMany()
.HasForeignKey("SubmittedByUnitId")
.OnDelete(DeleteBehavior.NoAction)
.IsRequired();
b.Navigation("ApprovedByUnit");
b.Navigation("SubmittedByUnit");
});
modelBuilder.Entity("MilitaryTrainingManagement.Models.Entities.PersonnelApprovalHistory", b =>
{
b.HasOne("MilitaryTrainingManagement.Models.Entities.Personnel", "Personnel")
.WithMany()
.HasForeignKey("PersonnelId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("MilitaryTrainingManagement.Models.Entities.OrganizationalUnit", "ReviewedByUnit")
.WithMany()
.HasForeignKey("ReviewedByUnitId")
.OnDelete(DeleteBehavior.NoAction);
b.HasOne("MilitaryTrainingManagement.Models.Entities.UserAccount", "ReviewedByUser")
.WithMany()
.HasForeignKey("ReviewedByUserId")
.OnDelete(DeleteBehavior.NoAction)
.IsRequired();
b.Navigation("Personnel");
b.Navigation("ReviewedByUnit");
b.Navigation("ReviewedByUser");
});
modelBuilder.Entity("MilitaryTrainingManagement.Models.Entities.UserAccount", b =>
{
b.HasOne("MilitaryTrainingManagement.Models.Entities.OrganizationalUnit", "OrganizationalUnit")
.WithMany("Accounts")
.HasForeignKey("OrganizationalUnitId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("OrganizationalUnit");
});
modelBuilder.Entity("MilitaryTrainingManagement.Models.Entities.MaterialAllocation", b =>
{
b.Navigation("Distributions");
});
modelBuilder.Entity("MilitaryTrainingManagement.Models.Entities.OrganizationalUnit", b =>
{
b.Navigation("Accounts");
b.Navigation("Children");
});
#pragma warning restore 612, 618
}
}
}

View File

@ -0,0 +1,86 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace MilitaryTrainingManagement.Migrations
{
/// <inheritdoc />
public partial class AddConsumptionReportTable : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "PlainPassword",
table: "UserAccounts",
type: "nvarchar(max)",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "Unit",
table: "Personnel",
type: "nvarchar(max)",
nullable: true);
migrationBuilder.CreateTable(
name: "ConsumptionReports",
columns: table => new
{
Id = table.Column<int>(type: "int", nullable: false)
.Annotation("SqlServer:Identity", "1, 1"),
AllocationDistributionId = table.Column<int>(type: "int", nullable: false),
ReportedAmount = table.Column<decimal>(type: "decimal(18,2)", precision: 18, scale: 2, nullable: false),
CumulativeAmount = table.Column<decimal>(type: "decimal(18,2)", precision: 18, scale: 2, nullable: false),
Remarks = table.Column<string>(type: "nvarchar(500)", maxLength: 500, nullable: true),
ReportedByUserId = table.Column<int>(type: "int", nullable: false),
ReportedAt = table.Column<DateTime>(type: "datetime2", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_ConsumptionReports", x => x.Id);
table.ForeignKey(
name: "FK_ConsumptionReports_AllocationDistributions_AllocationDistributionId",
column: x => x.AllocationDistributionId,
principalTable: "AllocationDistributions",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_ConsumptionReports_UserAccounts_ReportedByUserId",
column: x => x.ReportedByUserId,
principalTable: "UserAccounts",
principalColumn: "Id");
});
migrationBuilder.CreateIndex(
name: "IX_ConsumptionReports_AllocationDistributionId",
table: "ConsumptionReports",
column: "AllocationDistributionId");
migrationBuilder.CreateIndex(
name: "IX_ConsumptionReports_ReportedAt",
table: "ConsumptionReports",
column: "ReportedAt");
migrationBuilder.CreateIndex(
name: "IX_ConsumptionReports_ReportedByUserId",
table: "ConsumptionReports",
column: "ReportedByUserId");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "ConsumptionReports");
migrationBuilder.DropColumn(
name: "PlainPassword",
table: "UserAccounts");
migrationBuilder.DropColumn(
name: "Unit",
table: "Personnel");
}
}
}

View File

@ -210,6 +210,46 @@ namespace MilitaryTrainingManagement.Migrations
b.ToTable("AuditLogs");
});
modelBuilder.Entity("MilitaryTrainingManagement.Models.Entities.ConsumptionReport", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<int>("AllocationDistributionId")
.HasColumnType("int");
b.Property<decimal>("CumulativeAmount")
.HasPrecision(18, 2)
.HasColumnType("decimal(18,2)");
b.Property<string>("Remarks")
.HasMaxLength(500)
.HasColumnType("nvarchar(500)");
b.Property<decimal>("ReportedAmount")
.HasPrecision(18, 2)
.HasColumnType("decimal(18,2)");
b.Property<DateTime>("ReportedAt")
.HasColumnType("datetime2");
b.Property<int>("ReportedByUserId")
.HasColumnType("int");
b.HasKey("Id");
b.HasIndex("AllocationDistributionId");
b.HasIndex("ReportedAt");
b.HasIndex("ReportedByUserId");
b.ToTable("ConsumptionReports");
});
modelBuilder.Entity("MilitaryTrainingManagement.Models.Entities.MaterialAllocation", b =>
{
b.Property<int>("Id")
@ -409,6 +449,9 @@ namespace MilitaryTrainingManagement.Migrations
b.Property<string>("TrainingParticipation")
.HasColumnType("nvarchar(max)");
b.Property<string>("Unit")
.HasColumnType("nvarchar(max)");
b.HasKey("Id");
b.HasIndex("ApprovedByUnitId");
@ -496,6 +539,9 @@ namespace MilitaryTrainingManagement.Migrations
.IsRequired()
.HasColumnType("nvarchar(max)");
b.Property<string>("PlainPassword")
.HasColumnType("nvarchar(max)");
b.Property<string>("Username")
.IsRequired()
.HasMaxLength(50)
@ -587,6 +633,25 @@ namespace MilitaryTrainingManagement.Migrations
b.Navigation("User");
});
modelBuilder.Entity("MilitaryTrainingManagement.Models.Entities.ConsumptionReport", b =>
{
b.HasOne("MilitaryTrainingManagement.Models.Entities.AllocationDistribution", "AllocationDistribution")
.WithMany()
.HasForeignKey("AllocationDistributionId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("MilitaryTrainingManagement.Models.Entities.UserAccount", "ReportedByUser")
.WithMany()
.HasForeignKey("ReportedByUserId")
.OnDelete(DeleteBehavior.NoAction)
.IsRequired();
b.Navigation("AllocationDistribution");
b.Navigation("ReportedByUser");
});
modelBuilder.Entity("MilitaryTrainingManagement.Models.Entities.MaterialAllocation", b =>
{
b.HasOne("MilitaryTrainingManagement.Models.Entities.OrganizationalUnit", "CreatedByUnit")

View File

@ -0,0 +1,63 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace MilitaryTrainingManagement.Models.Entities;
/// <summary>
/// 消耗上报记录
/// </summary>
public class ConsumptionReport
{
[Key]
public int Id { get; set; }
/// <summary>
/// 配额分配ID
/// </summary>
[Required]
public int AllocationDistributionId { get; set; }
/// <summary>
/// 配额分配
/// </summary>
[ForeignKey(nameof(AllocationDistributionId))]
public AllocationDistribution AllocationDistribution { get; set; } = null!;
/// <summary>
/// 本次上报数量
/// </summary>
[Required]
[Column(TypeName = "decimal(18,2)")]
public decimal ReportedAmount { get; set; }
/// <summary>
/// 上报后累计数量
/// </summary>
[Required]
[Column(TypeName = "decimal(18,2)")]
public decimal CumulativeAmount { get; set; }
/// <summary>
/// 备注
/// </summary>
[MaxLength(500)]
public string? Remarks { get; set; }
/// <summary>
/// 上报人ID
/// </summary>
[Required]
public int ReportedByUserId { get; set; }
/// <summary>
/// 上报人
/// </summary>
[ForeignKey(nameof(ReportedByUserId))]
public UserAccount ReportedByUser { get; set; } = null!;
/// <summary>
/// 上报时间
/// </summary>
[Required]
public DateTime ReportedAt { get; set; }
}

View File

@ -28,5 +28,10 @@ public enum PersonnelApprovalAction
/// <summary>
/// 等级调整
/// </summary>
LevelAdjusted = 5
LevelAdjusted = 5,
/// <summary>
/// 向上申报等级升级
/// </summary>
LevelUpgraded = 6
}

View File

@ -253,6 +253,39 @@ using (var scope = app.Services.CreateScope())
Console.WriteLine($"添加 UserAccounts 表 PlainPassword 列时出错: {ex.Message}");
}
// 创建 ConsumptionReports 表(如果不存在)
try
{
context.Database.ExecuteSqlRaw(@"
IF NOT EXISTS (SELECT * FROM sys.tables WHERE name = 'ConsumptionReports')
BEGIN
CREATE TABLE ConsumptionReports (
Id INT PRIMARY KEY IDENTITY(1,1),
AllocationDistributionId INT NOT NULL,
ReportedAmount DECIMAL(18,2) NOT NULL,
CumulativeAmount DECIMAL(18,2) NOT NULL,
Remarks NVARCHAR(500) NULL,
ReportedByUserId INT NOT NULL,
ReportedAt DATETIME2 NOT NULL,
CONSTRAINT FK_ConsumptionReports_AllocationDistributions
FOREIGN KEY (AllocationDistributionId)
REFERENCES AllocationDistributions(Id) ON DELETE CASCADE,
CONSTRAINT FK_ConsumptionReports_UserAccounts
FOREIGN KEY (ReportedByUserId)
REFERENCES UserAccounts(Id)
);
CREATE INDEX IX_ConsumptionReports_AllocationDistributionId ON ConsumptionReports(AllocationDistributionId);
CREATE INDEX IX_ConsumptionReports_ReportedAt ON ConsumptionReports(ReportedAt);
CREATE INDEX IX_ConsumptionReports_ReportedByUserId ON ConsumptionReports(ReportedByUserId);
END
");
Console.WriteLine("ConsumptionReports 表检查完成");
}
catch (Exception ex)
{
Console.WriteLine($"创建 ConsumptionReports 表时出错: {ex.Message}");
}
// 如果没有物资类别,创建默认类别
if (!context.MaterialCategories.Any())
{

View File

@ -57,6 +57,14 @@ public class AllocationService : IAllocationService
var subordinateIds = await _organizationService.GetAllSubordinateIdsAsync(unitId);
var allUnitIds = new HashSet<int>(subordinateIds) { unitId };
// 获取该单位的所有上级单位ID用于营部及以下账号查看团的配额
var ancestorIds = await GetAncestorUnitIdsAsync(unitId);
var allRelatedUnitIds = new HashSet<int>(allUnitIds);
foreach (var ancestorId in ancestorIds)
{
allRelatedUnitIds.Add(ancestorId);
}
// 获取分配给这些单位的配额
var allocations = await _context.MaterialAllocations
.Include(a => a.CreatedByUnit)
@ -64,21 +72,38 @@ public class AllocationService : IAllocationService
.ThenInclude(d => d.TargetUnit)
.Include(a => a.Distributions)
.ThenInclude(d => d.ReportedByUser)
.Where(a => a.Distributions.Any(d => allUnitIds.Contains(d.TargetUnitId)))
.Where(a => a.Distributions.Any(d => allRelatedUnitIds.Contains(d.TargetUnitId)))
.OrderByDescending(a => a.CreatedAt)
.ToListAsync();
// 过滤每个配额的分配记录,只保留分配给当前单位及其下级的记录
// 过滤每个配额的分配记录,只保留分配给当前单位及其下级的记录
foreach (var allocation in allocations)
{
allocation.Distributions = allocation.Distributions
.Where(d => allUnitIds.Contains(d.TargetUnitId))
.Where(d => allRelatedUnitIds.Contains(d.TargetUnitId))
.ToList();
}
return allocations;
}
/// <summary>
/// 获取单位的所有上级单位ID
/// </summary>
private async Task<List<int>> GetAncestorUnitIdsAsync(int unitId)
{
var result = new List<int>();
var currentUnit = await _context.OrganizationalUnits.FindAsync(unitId);
while (currentUnit?.ParentId != null)
{
result.Add(currentUnit.ParentId.Value);
currentUnit = await _context.OrganizationalUnits.FindAsync(currentUnit.ParentId.Value);
}
return result;
}
public async Task<IEnumerable<AllocationDistribution>> GetDistributionsForUnitAsync(int unitId)
{
return await _context.AllocationDistributions
@ -256,8 +281,16 @@ public class AllocationService : IAllocationService
if (distribution == null)
throw new ArgumentException("配额分配记录不存在");
// 验证权限:只能更新分配给自己单位的记录
if (distribution.TargetUnitId != unitId)
// 验证权限:可以更新分配给自己单位或上级单位的记录
var canReport = distribution.TargetUnitId == unitId;
if (!canReport)
{
// 检查是否是上级单位的配额
var ancestorIds = await GetAncestorUnitIdsAsync(unitId);
canReport = ancestorIds.Contains(distribution.TargetUnitId);
}
if (!canReport)
throw new UnauthorizedAccessException("无权更新此配额分配记录");
// 验证实际完成数量
@ -267,6 +300,24 @@ public class AllocationService : IAllocationService
if (actualCompletion > distribution.UnitQuota)
throw new ArgumentException($"实际完成数量不能超过分配配额({distribution.UnitQuota})");
// 计算本次上报数量
var previousAmount = distribution.ActualCompletion ?? 0;
var reportedAmount = actualCompletion - previousAmount;
// 如果本次上报数量大于0保存历史记录
if (reportedAmount > 0)
{
var consumptionReport = new ConsumptionReport
{
AllocationDistributionId = distributionId,
ReportedAmount = reportedAmount,
CumulativeAmount = actualCompletion,
ReportedByUserId = userId,
ReportedAt = DateTime.UtcNow
};
_context.ConsumptionReports.Add(consumptionReport);
}
// 更新实际完成数量
distribution.ActualCompletion = actualCompletion;
distribution.ReportedAt = DateTime.UtcNow;
@ -308,4 +359,13 @@ public class AllocationService : IAllocationService
return existingIds.Count == targetUnitIds.Distinct().Count();
}
public async Task<IEnumerable<ConsumptionReport>> GetConsumptionReportsAsync(int distributionId)
{
return await _context.ConsumptionReports
.Include(r => r.ReportedByUser)
.Where(r => r.AllocationDistributionId == distributionId)
.OrderByDescending(r => r.ReportedAt)
.ToListAsync();
}
}

View File

@ -193,4 +193,18 @@ public class OrganizationService : IOrganizationService
// 检查是否是上级单位(递归向上查找)
return await IsSubordinateOfAsync(childUnitId, parentUnitId);
}
public async Task<IEnumerable<int>> GetAllAncestorIdsAsync(int unitId)
{
var result = new List<int>();
var currentUnit = await _context.OrganizationalUnits.FindAsync(unitId);
while (currentUnit?.ParentId != null)
{
result.Add(currentUnit.ParentId.Value);
currentUnit = await _context.OrganizationalUnits.FindAsync(currentUnit.ParentId.Value);
}
return result;
}
}

View File

@ -81,9 +81,11 @@ public class PersonnelService : IPersonnelService
return personnel;
}
public async Task<Personnel> ApproveAsync(int personnelId, int approvedByUnitId, PersonnelLevel level)
public async Task<Personnel> ApproveAsync(int personnelId, int approvedByUnitId, PersonnelLevel? level = null)
{
var personnel = await _context.Personnel.FindAsync(personnelId);
var personnel = await _context.Personnel
.Include(p => p.SubmittedByUnit)
.FirstOrDefaultAsync(p => p.Id == personnelId);
if (personnel == null)
throw new ArgumentException("人员记录不存在");
@ -91,9 +93,21 @@ public class PersonnelService : IPersonnelService
if (approvedByUnit == null)
throw new ArgumentException("审批单位不存在");
// 人员等级变更为审批单位的等级
PersonnelLevel actualLevel;
if (level.HasValue)
{
actualLevel = level.Value;
}
else
{
// 根据审批单位的层级确定人员等级
actualLevel = (PersonnelLevel)(int)approvedByUnit.Level;
}
// 验证审批单位层级必须高于或等于人员等级(数值越小层级越高)
var unitLevelValue = (int)approvedByUnit.Level;
var personnelLevelValue = (int)level;
var personnelLevelValue = (int)actualLevel;
if (unitLevelValue > personnelLevelValue)
throw new ArgumentException("审批单位层级不足以审批该等级人才");
@ -102,23 +116,45 @@ public class PersonnelService : IPersonnelService
personnel.Status = PersonnelStatus.Approved;
personnel.ApprovedByUnitId = approvedByUnitId;
personnel.ApprovedLevel = level;
personnel.ApprovedLevel = actualLevel;
personnel.ApprovedAt = DateTime.UtcNow;
await _context.SaveChangesAsync();
// 记录审批历史
var userId = await GetUserIdByUnitAsync(approvedByUnitId);
await RecordApprovalHistoryAsync(personnelId, PersonnelApprovalAction.Approved,
previousStatus, PersonnelStatus.Approved, previousLevel, level,
userId, approvedByUnitId, "审批通过");
var actionType = previousStatus == PersonnelStatus.Approved
? PersonnelApprovalAction.LevelUpgraded
: PersonnelApprovalAction.Approved;
var comments = previousStatus == PersonnelStatus.Approved
? $"向上申报通过,等级从{GetLevelName(previousLevel)}升级为{GetLevelName(actualLevel)}"
: "审批通过";
await RecordApprovalHistoryAsync(personnelId, actionType,
previousStatus, PersonnelStatus.Approved, previousLevel, actualLevel,
userId, approvedByUnitId, comments);
_logger.LogInformation("人员 {PersonnelId} 已被单位 {UnitId} 审批通过,等级:{Level}",
personnelId, approvedByUnitId, level);
personnelId, approvedByUnitId, actualLevel);
return personnel;
}
/// <summary>
/// 获取等级名称
/// </summary>
private static string GetLevelName(PersonnelLevel? level)
{
return level switch
{
PersonnelLevel.Division => "师级人才",
PersonnelLevel.Regiment => "团级人才",
PersonnelLevel.Battalion => "营级人才",
PersonnelLevel.Company => "连级人才",
_ => "未定级"
};
}
public async Task<Personnel> RejectAsync(int personnelId, int reviewedByUserId)
{
var personnel = await _context.Personnel.FindAsync(personnelId);
@ -519,14 +555,27 @@ public class PersonnelService : IPersonnelService
var personnel = await _context.Personnel
.Include(p => p.SubmittedByUnit)
.Include(p => p.ApprovedByUnit)
.FirstOrDefaultAsync(p => p.Id == personnelId);
if (personnel == null || personnel.Status != PersonnelStatus.Pending)
if (personnel == null)
return false;
// 同一单位可以审批自己提交的人员
// 已拒绝的人员不能审批
if (personnel.Status == PersonnelStatus.Rejected)
return false;
// 本单位不能审批自己提交的人员,必须由上级单位审批
if (user.OrganizationalUnitId == personnel.SubmittedByUnitId)
return true;
return false;
// 如果是已审批的人员,检查当前用户单位是否比已审批单位层级更高
if (personnel.Status == PersonnelStatus.Approved && personnel.ApprovedByUnitId.HasValue)
{
// 用户单位层级必须高于已审批单位层级(数值越小层级越高)
if ((int)user.OrganizationalUnit!.Level >= (int)personnel.ApprovedByUnit!.Level)
return false;
}
// 检查用户的组织单位是否是提交单位的上级
var isParent = await _organizationService.IsParentUnitAsync(user.OrganizationalUnitId, personnel.SubmittedByUnitId);

View File

@ -71,4 +71,9 @@ public interface IAllocationService
/// 验证目标单位是否存在
/// </summary>
Task<bool> ValidateTargetUnitsExistAsync(IEnumerable<int> targetUnitIds);
/// <summary>
/// 获取配额分配的上报历史记录
/// </summary>
Task<IEnumerable<ConsumptionReport>> GetConsumptionReportsAsync(int distributionId);
}

View File

@ -12,6 +12,7 @@ public interface IOrganizationService
Task<IEnumerable<OrganizationalUnit>> GetAllAsync();
Task<IEnumerable<OrganizationalUnit>> GetSubordinatesAsync(int unitId);
Task<IEnumerable<int>> GetAllSubordinateIdsAsync(int unitId);
Task<IEnumerable<int>> GetAllAncestorIdsAsync(int unitId);
Task<OrganizationalUnit> CreateAsync(string name, OrganizationalLevel level, int? parentId);
Task<OrganizationalUnit> UpdateAsync(int id, string name);
Task DeleteAsync(int id);

View File

@ -12,7 +12,7 @@ public interface IPersonnelService
Task<IEnumerable<Personnel>> GetByUnitAsync(int unitId, bool includeSubordinates = false);
Task<Personnel> CreateAsync(Personnel personnel);
Task<Personnel> UpdateAsync(Personnel personnel);
Task<Personnel> ApproveAsync(int personnelId, int approvedByUnitId, PersonnelLevel level);
Task<Personnel> ApproveAsync(int personnelId, int approvedByUnitId, PersonnelLevel? level = null);
Task<Personnel> RejectAsync(int personnelId, int reviewedByUserId);
Task DeleteAsync(int id);

View File

@ -0,0 +1,47 @@
-- 创建 ConsumptionReports 表
IF NOT EXISTS (SELECT * FROM sys.objects WHERE object_id = OBJECT_ID(N'[dbo].[ConsumptionReports]') AND type in (N'U'))
BEGIN
CREATE TABLE [dbo].[ConsumptionReports](
[Id] [int] IDENTITY(1,1) NOT NULL,
[AllocationDistributionId] [int] NOT NULL,
[ReportedAmount] [decimal](18, 2) NOT NULL,
[CumulativeAmount] [decimal](18, 2) NOT NULL,
[Remarks] [nvarchar](500) NULL,
[ReportedByUserId] [int] NOT NULL,
[ReportedAt] [datetime2](7) NOT NULL,
CONSTRAINT [PK_ConsumptionReports] PRIMARY KEY CLUSTERED ([Id] ASC)
)
CREATE NONCLUSTERED INDEX [IX_ConsumptionReports_AllocationDistributionId] ON [dbo].[ConsumptionReports]
(
[AllocationDistributionId] ASC
)
CREATE NONCLUSTERED INDEX [IX_ConsumptionReports_ReportedAt] ON [dbo].[ConsumptionReports]
(
[ReportedAt] ASC
)
CREATE NONCLUSTERED INDEX [IX_ConsumptionReports_ReportedByUserId] ON [dbo].[ConsumptionReports]
(
[ReportedByUserId] ASC
)
ALTER TABLE [dbo].[ConsumptionReports] WITH CHECK ADD CONSTRAINT [FK_ConsumptionReports_AllocationDistributions_AllocationDistributionId] FOREIGN KEY([AllocationDistributionId])
REFERENCES [dbo].[AllocationDistributions] ([Id])
ON DELETE CASCADE
ALTER TABLE [dbo].[ConsumptionReports] CHECK CONSTRAINT [FK_ConsumptionReports_AllocationDistributions_AllocationDistributionId]
ALTER TABLE [dbo].[ConsumptionReports] WITH CHECK ADD CONSTRAINT [FK_ConsumptionReports_UserAccounts_ReportedByUserId] FOREIGN KEY([ReportedByUserId])
REFERENCES [dbo].[UserAccounts] ([Id])
ALTER TABLE [dbo].[ConsumptionReports] CHECK CONSTRAINT [FK_ConsumptionReports_UserAccounts_ReportedByUserId]
PRINT 'ConsumptionReports table created successfully'
END
ELSE
BEGIN
PRINT 'ConsumptionReports table already exists'
END
GO

View File

@ -0,0 +1,622 @@
IF OBJECT_ID(N'[__EFMigrationsHistory]') IS NULL
BEGIN
CREATE TABLE [__EFMigrationsHistory] (
[MigrationId] nvarchar(150) NOT NULL,
[ProductVersion] nvarchar(32) NOT NULL,
CONSTRAINT [PK___EFMigrationsHistory] PRIMARY KEY ([MigrationId])
);
END;
GO
BEGIN TRANSACTION;
GO
IF NOT EXISTS (
SELECT * FROM [__EFMigrationsHistory]
WHERE [MigrationId] = N'20260114141545_AddMaterialCategory'
)
BEGIN
CREATE TABLE [MaterialCategories] (
[Id] int NOT NULL IDENTITY,
[Name] nvarchar(50) NOT NULL,
[Description] nvarchar(200) NULL,
[IsActive] bit NOT NULL,
[CreatedAt] datetime2 NOT NULL,
[SortOrder] int NOT NULL,
CONSTRAINT [PK_MaterialCategories] PRIMARY KEY ([Id])
);
END;
GO
IF NOT EXISTS (
SELECT * FROM [__EFMigrationsHistory]
WHERE [MigrationId] = N'20260114141545_AddMaterialCategory'
)
BEGIN
CREATE TABLE [OrganizationalUnits] (
[Id] int NOT NULL IDENTITY,
[Name] nvarchar(100) NOT NULL,
[Level] int NOT NULL,
[ParentId] int NULL,
[CreatedAt] datetime2 NOT NULL,
CONSTRAINT [PK_OrganizationalUnits] PRIMARY KEY ([Id]),
CONSTRAINT [FK_OrganizationalUnits_OrganizationalUnits_ParentId] FOREIGN KEY ([ParentId]) REFERENCES [OrganizationalUnits] ([Id]) ON DELETE NO ACTION
);
END;
GO
IF NOT EXISTS (
SELECT * FROM [__EFMigrationsHistory]
WHERE [MigrationId] = N'20260114141545_AddMaterialCategory'
)
BEGIN
CREATE TABLE [MaterialAllocations] (
[Id] int NOT NULL IDENTITY,
[Category] nvarchar(100) NOT NULL,
[MaterialName] nvarchar(200) NOT NULL,
[Unit] nvarchar(50) NOT NULL,
[TotalQuota] decimal(18,2) NOT NULL,
[CreatedByUnitId] int NOT NULL,
[CreatedAt] datetime2 NOT NULL,
CONSTRAINT [PK_MaterialAllocations] PRIMARY KEY ([Id]),
CONSTRAINT [FK_MaterialAllocations_OrganizationalUnits_CreatedByUnitId] FOREIGN KEY ([CreatedByUnitId]) REFERENCES [OrganizationalUnits] ([Id]) ON DELETE NO ACTION
);
END;
GO
IF NOT EXISTS (
SELECT * FROM [__EFMigrationsHistory]
WHERE [MigrationId] = N'20260114141545_AddMaterialCategory'
)
BEGIN
CREATE TABLE [Personnel] (
[Id] int NOT NULL IDENTITY,
[Name] nvarchar(50) NOT NULL,
[PhotoPath] nvarchar(max) NULL,
[Position] nvarchar(100) NOT NULL,
[Rank] nvarchar(50) NOT NULL,
[Gender] nvarchar(10) NOT NULL,
[IdNumber] nvarchar(18) NOT NULL,
[ProfessionalTitle] nvarchar(max) NULL,
[EducationLevel] nvarchar(max) NULL,
[Age] int NOT NULL,
[Height] decimal(5,2) NULL,
[ContactInfo] nvarchar(max) NULL,
[Hometown] nvarchar(max) NULL,
[TrainingParticipation] nvarchar(max) NULL,
[Achievements] nvarchar(max) NULL,
[SupportingDocuments] nvarchar(max) NULL,
[SubmittedByUnitId] int NOT NULL,
[ApprovedLevel] int NULL,
[ApprovedByUnitId] int NULL,
[Status] int NOT NULL,
[SubmittedAt] datetime2 NOT NULL,
[ApprovedAt] datetime2 NULL,
CONSTRAINT [PK_Personnel] PRIMARY KEY ([Id]),
CONSTRAINT [FK_Personnel_OrganizationalUnits_ApprovedByUnitId] FOREIGN KEY ([ApprovedByUnitId]) REFERENCES [OrganizationalUnits] ([Id]),
CONSTRAINT [FK_Personnel_OrganizationalUnits_SubmittedByUnitId] FOREIGN KEY ([SubmittedByUnitId]) REFERENCES [OrganizationalUnits] ([Id])
);
END;
GO
IF NOT EXISTS (
SELECT * FROM [__EFMigrationsHistory]
WHERE [MigrationId] = N'20260114141545_AddMaterialCategory'
)
BEGIN
CREATE TABLE [UserAccounts] (
[Id] int NOT NULL IDENTITY,
[Username] nvarchar(50) NOT NULL,
[PasswordHash] nvarchar(max) NOT NULL,
[DisplayName] nvarchar(100) NOT NULL,
[OrganizationalUnitId] int NOT NULL,
[IsActive] bit NOT NULL,
[CreatedAt] datetime2 NOT NULL,
[LastLoginAt] datetime2 NULL,
CONSTRAINT [PK_UserAccounts] PRIMARY KEY ([Id]),
CONSTRAINT [FK_UserAccounts_OrganizationalUnits_OrganizationalUnitId] FOREIGN KEY ([OrganizationalUnitId]) REFERENCES [OrganizationalUnits] ([Id]) ON DELETE NO ACTION
);
END;
GO
IF NOT EXISTS (
SELECT * FROM [__EFMigrationsHistory]
WHERE [MigrationId] = N'20260114141545_AddMaterialCategory'
)
BEGIN
CREATE TABLE [AllocationDistributions] (
[Id] int NOT NULL IDENTITY,
[AllocationId] int NOT NULL,
[TargetUnitId] int NOT NULL,
[UnitQuota] decimal(18,2) NOT NULL,
[ActualCompletion] decimal(18,2) NULL,
[ReportedAt] datetime2 NULL,
[ReportedByUserId] int NULL,
[IsApproved] bit NOT NULL,
[ApprovedAt] datetime2 NULL,
[ApprovedByUserId] int NULL,
CONSTRAINT [PK_AllocationDistributions] PRIMARY KEY ([Id]),
CONSTRAINT [FK_AllocationDistributions_MaterialAllocations_AllocationId] FOREIGN KEY ([AllocationId]) REFERENCES [MaterialAllocations] ([Id]) ON DELETE CASCADE,
CONSTRAINT [FK_AllocationDistributions_OrganizationalUnits_TargetUnitId] FOREIGN KEY ([TargetUnitId]) REFERENCES [OrganizationalUnits] ([Id]),
CONSTRAINT [FK_AllocationDistributions_UserAccounts_ApprovedByUserId] FOREIGN KEY ([ApprovedByUserId]) REFERENCES [UserAccounts] ([Id]),
CONSTRAINT [FK_AllocationDistributions_UserAccounts_ReportedByUserId] FOREIGN KEY ([ReportedByUserId]) REFERENCES [UserAccounts] ([Id])
);
END;
GO
IF NOT EXISTS (
SELECT * FROM [__EFMigrationsHistory]
WHERE [MigrationId] = N'20260114141545_AddMaterialCategory'
)
BEGIN
CREATE TABLE [ApprovalRequests] (
[Id] int NOT NULL IDENTITY,
[Type] int NOT NULL,
[TargetEntityId] int NOT NULL,
[RequestedByUserId] int NOT NULL,
[RequestedByUnitId] int NOT NULL,
[Reason] nvarchar(500) NOT NULL,
[OriginalData] nvarchar(max) NOT NULL,
[RequestedChanges] nvarchar(max) NOT NULL,
[Status] int NOT NULL,
[ReviewedByUserId] int NULL,
[ReviewComments] nvarchar(max) NULL,
[RequestedAt] datetime2 NOT NULL,
[ReviewedAt] datetime2 NULL,
CONSTRAINT [PK_ApprovalRequests] PRIMARY KEY ([Id]),
CONSTRAINT [FK_ApprovalRequests_OrganizationalUnits_RequestedByUnitId] FOREIGN KEY ([RequestedByUnitId]) REFERENCES [OrganizationalUnits] ([Id]),
CONSTRAINT [FK_ApprovalRequests_UserAccounts_RequestedByUserId] FOREIGN KEY ([RequestedByUserId]) REFERENCES [UserAccounts] ([Id]),
CONSTRAINT [FK_ApprovalRequests_UserAccounts_ReviewedByUserId] FOREIGN KEY ([ReviewedByUserId]) REFERENCES [UserAccounts] ([Id])
);
END;
GO
IF NOT EXISTS (
SELECT * FROM [__EFMigrationsHistory]
WHERE [MigrationId] = N'20260114141545_AddMaterialCategory'
)
BEGIN
CREATE TABLE [AuditLogs] (
[Id] int NOT NULL IDENTITY,
[EntityType] nvarchar(100) NOT NULL,
[EntityId] int NOT NULL,
[Action] nvarchar(50) NOT NULL,
[Description] nvarchar(500) NULL,
[OldValues] nvarchar(max) NULL,
[NewValues] nvarchar(max) NULL,
[ChangedFields] nvarchar(max) NULL,
[UserId] int NULL,
[OrganizationalUnitId] int NULL,
[Timestamp] datetime2 NOT NULL,
[IpAddress] nvarchar(50) NULL,
[UserAgent] nvarchar(500) NULL,
[RequestPath] nvarchar(500) NULL,
[IsSuccess] bit NOT NULL,
[ErrorMessage] nvarchar(2000) NULL,
CONSTRAINT [PK_AuditLogs] PRIMARY KEY ([Id]),
CONSTRAINT [FK_AuditLogs_OrganizationalUnits_OrganizationalUnitId] FOREIGN KEY ([OrganizationalUnitId]) REFERENCES [OrganizationalUnits] ([Id]),
CONSTRAINT [FK_AuditLogs_UserAccounts_UserId] FOREIGN KEY ([UserId]) REFERENCES [UserAccounts] ([Id])
);
END;
GO
IF NOT EXISTS (
SELECT * FROM [__EFMigrationsHistory]
WHERE [MigrationId] = N'20260114141545_AddMaterialCategory'
)
BEGIN
CREATE TABLE [PersonnelApprovalHistories] (
[Id] int NOT NULL IDENTITY,
[PersonnelId] int NOT NULL,
[Action] int NOT NULL,
[PreviousStatus] int NULL,
[NewStatus] int NOT NULL,
[PreviousLevel] int NULL,
[NewLevel] int NULL,
[ReviewedByUserId] int NOT NULL,
[ReviewedByUnitId] int NULL,
[Comments] nvarchar(max) NULL,
[ReviewedAt] datetime2 NOT NULL,
CONSTRAINT [PK_PersonnelApprovalHistories] PRIMARY KEY ([Id]),
CONSTRAINT [FK_PersonnelApprovalHistories_OrganizationalUnits_ReviewedByUnitId] FOREIGN KEY ([ReviewedByUnitId]) REFERENCES [OrganizationalUnits] ([Id]),
CONSTRAINT [FK_PersonnelApprovalHistories_Personnel_PersonnelId] FOREIGN KEY ([PersonnelId]) REFERENCES [Personnel] ([Id]) ON DELETE CASCADE,
CONSTRAINT [FK_PersonnelApprovalHistories_UserAccounts_ReviewedByUserId] FOREIGN KEY ([ReviewedByUserId]) REFERENCES [UserAccounts] ([Id])
);
END;
GO
IF NOT EXISTS (
SELECT * FROM [__EFMigrationsHistory]
WHERE [MigrationId] = N'20260114141545_AddMaterialCategory'
)
BEGIN
CREATE INDEX [IX_AllocationDistributions_AllocationId] ON [AllocationDistributions] ([AllocationId]);
END;
GO
IF NOT EXISTS (
SELECT * FROM [__EFMigrationsHistory]
WHERE [MigrationId] = N'20260114141545_AddMaterialCategory'
)
BEGIN
CREATE INDEX [IX_AllocationDistributions_ApprovedByUserId] ON [AllocationDistributions] ([ApprovedByUserId]);
END;
GO
IF NOT EXISTS (
SELECT * FROM [__EFMigrationsHistory]
WHERE [MigrationId] = N'20260114141545_AddMaterialCategory'
)
BEGIN
CREATE INDEX [IX_AllocationDistributions_ReportedByUserId] ON [AllocationDistributions] ([ReportedByUserId]);
END;
GO
IF NOT EXISTS (
SELECT * FROM [__EFMigrationsHistory]
WHERE [MigrationId] = N'20260114141545_AddMaterialCategory'
)
BEGIN
CREATE INDEX [IX_AllocationDistributions_TargetUnitId] ON [AllocationDistributions] ([TargetUnitId]);
END;
GO
IF NOT EXISTS (
SELECT * FROM [__EFMigrationsHistory]
WHERE [MigrationId] = N'20260114141545_AddMaterialCategory'
)
BEGIN
CREATE INDEX [IX_ApprovalRequests_RequestedByUnitId] ON [ApprovalRequests] ([RequestedByUnitId]);
END;
GO
IF NOT EXISTS (
SELECT * FROM [__EFMigrationsHistory]
WHERE [MigrationId] = N'20260114141545_AddMaterialCategory'
)
BEGIN
CREATE INDEX [IX_ApprovalRequests_RequestedByUserId] ON [ApprovalRequests] ([RequestedByUserId]);
END;
GO
IF NOT EXISTS (
SELECT * FROM [__EFMigrationsHistory]
WHERE [MigrationId] = N'20260114141545_AddMaterialCategory'
)
BEGIN
CREATE INDEX [IX_ApprovalRequests_ReviewedByUserId] ON [ApprovalRequests] ([ReviewedByUserId]);
END;
GO
IF NOT EXISTS (
SELECT * FROM [__EFMigrationsHistory]
WHERE [MigrationId] = N'20260114141545_AddMaterialCategory'
)
BEGIN
CREATE INDEX [IX_AuditLogs_Action] ON [AuditLogs] ([Action]);
END;
GO
IF NOT EXISTS (
SELECT * FROM [__EFMigrationsHistory]
WHERE [MigrationId] = N'20260114141545_AddMaterialCategory'
)
BEGIN
CREATE INDEX [IX_AuditLogs_EntityId] ON [AuditLogs] ([EntityId]);
END;
GO
IF NOT EXISTS (
SELECT * FROM [__EFMigrationsHistory]
WHERE [MigrationId] = N'20260114141545_AddMaterialCategory'
)
BEGIN
CREATE INDEX [IX_AuditLogs_EntityType] ON [AuditLogs] ([EntityType]);
END;
GO
IF NOT EXISTS (
SELECT * FROM [__EFMigrationsHistory]
WHERE [MigrationId] = N'20260114141545_AddMaterialCategory'
)
BEGIN
CREATE INDEX [IX_AuditLogs_OrganizationalUnitId] ON [AuditLogs] ([OrganizationalUnitId]);
END;
GO
IF NOT EXISTS (
SELECT * FROM [__EFMigrationsHistory]
WHERE [MigrationId] = N'20260114141545_AddMaterialCategory'
)
BEGIN
CREATE INDEX [IX_AuditLogs_Timestamp] ON [AuditLogs] ([Timestamp]);
END;
GO
IF NOT EXISTS (
SELECT * FROM [__EFMigrationsHistory]
WHERE [MigrationId] = N'20260114141545_AddMaterialCategory'
)
BEGIN
CREATE INDEX [IX_AuditLogs_UserId] ON [AuditLogs] ([UserId]);
END;
GO
IF NOT EXISTS (
SELECT * FROM [__EFMigrationsHistory]
WHERE [MigrationId] = N'20260114141545_AddMaterialCategory'
)
BEGIN
CREATE INDEX [IX_MaterialAllocations_CreatedByUnitId] ON [MaterialAllocations] ([CreatedByUnitId]);
END;
GO
IF NOT EXISTS (
SELECT * FROM [__EFMigrationsHistory]
WHERE [MigrationId] = N'20260114141545_AddMaterialCategory'
)
BEGIN
CREATE UNIQUE INDEX [IX_MaterialCategories_Name] ON [MaterialCategories] ([Name]);
END;
GO
IF NOT EXISTS (
SELECT * FROM [__EFMigrationsHistory]
WHERE [MigrationId] = N'20260114141545_AddMaterialCategory'
)
BEGIN
CREATE INDEX [IX_OrganizationalUnits_ParentId] ON [OrganizationalUnits] ([ParentId]);
END;
GO
IF NOT EXISTS (
SELECT * FROM [__EFMigrationsHistory]
WHERE [MigrationId] = N'20260114141545_AddMaterialCategory'
)
BEGIN
CREATE INDEX [IX_Personnel_ApprovedByUnitId] ON [Personnel] ([ApprovedByUnitId]);
END;
GO
IF NOT EXISTS (
SELECT * FROM [__EFMigrationsHistory]
WHERE [MigrationId] = N'20260114141545_AddMaterialCategory'
)
BEGIN
CREATE UNIQUE INDEX [IX_Personnel_IdNumber] ON [Personnel] ([IdNumber]);
END;
GO
IF NOT EXISTS (
SELECT * FROM [__EFMigrationsHistory]
WHERE [MigrationId] = N'20260114141545_AddMaterialCategory'
)
BEGIN
CREATE INDEX [IX_Personnel_SubmittedByUnitId] ON [Personnel] ([SubmittedByUnitId]);
END;
GO
IF NOT EXISTS (
SELECT * FROM [__EFMigrationsHistory]
WHERE [MigrationId] = N'20260114141545_AddMaterialCategory'
)
BEGIN
CREATE INDEX [IX_PersonnelApprovalHistories_PersonnelId] ON [PersonnelApprovalHistories] ([PersonnelId]);
END;
GO
IF NOT EXISTS (
SELECT * FROM [__EFMigrationsHistory]
WHERE [MigrationId] = N'20260114141545_AddMaterialCategory'
)
BEGIN
CREATE INDEX [IX_PersonnelApprovalHistories_ReviewedByUnitId] ON [PersonnelApprovalHistories] ([ReviewedByUnitId]);
END;
GO
IF NOT EXISTS (
SELECT * FROM [__EFMigrationsHistory]
WHERE [MigrationId] = N'20260114141545_AddMaterialCategory'
)
BEGIN
CREATE INDEX [IX_PersonnelApprovalHistories_ReviewedByUserId] ON [PersonnelApprovalHistories] ([ReviewedByUserId]);
END;
GO
IF NOT EXISTS (
SELECT * FROM [__EFMigrationsHistory]
WHERE [MigrationId] = N'20260114141545_AddMaterialCategory'
)
BEGIN
CREATE INDEX [IX_UserAccounts_OrganizationalUnitId] ON [UserAccounts] ([OrganizationalUnitId]);
END;
GO
IF NOT EXISTS (
SELECT * FROM [__EFMigrationsHistory]
WHERE [MigrationId] = N'20260114141545_AddMaterialCategory'
)
BEGIN
CREATE UNIQUE INDEX [IX_UserAccounts_Username] ON [UserAccounts] ([Username]);
END;
GO
IF NOT EXISTS (
SELECT * FROM [__EFMigrationsHistory]
WHERE [MigrationId] = N'20260114141545_AddMaterialCategory'
)
BEGIN
INSERT INTO [__EFMigrationsHistory] ([MigrationId], [ProductVersion])
VALUES (N'20260114141545_AddMaterialCategory', N'8.0.0');
END;
GO
COMMIT;
GO
BEGIN TRANSACTION;
GO
IF NOT EXISTS (
SELECT * FROM [__EFMigrationsHistory]
WHERE [MigrationId] = N'20260115063457_UpdatePersonnelFieldsComplete'
)
BEGIN
DROP INDEX [IX_Personnel_IdNumber] ON [Personnel];
END;
GO
IF NOT EXISTS (
SELECT * FROM [__EFMigrationsHistory]
WHERE [MigrationId] = N'20260115063457_UpdatePersonnelFieldsComplete'
)
BEGIN
DECLARE @var0 sysname;
SELECT @var0 = [d].[name]
FROM [sys].[default_constraints] [d]
INNER JOIN [sys].[columns] [c] ON [d].[parent_column_id] = [c].[column_id] AND [d].[parent_object_id] = [c].[object_id]
WHERE ([d].[parent_object_id] = OBJECT_ID(N'[Personnel]') AND [c].[name] = N'IdNumber');
IF @var0 IS NOT NULL EXEC(N'ALTER TABLE [Personnel] DROP CONSTRAINT [' + @var0 + '];');
ALTER TABLE [Personnel] ALTER COLUMN [IdNumber] nvarchar(50) NULL;
END;
GO
IF NOT EXISTS (
SELECT * FROM [__EFMigrationsHistory]
WHERE [MigrationId] = N'20260115063457_UpdatePersonnelFieldsComplete'
)
BEGIN
ALTER TABLE [Personnel] ADD [BirthDate] nvarchar(max) NULL;
END;
GO
IF NOT EXISTS (
SELECT * FROM [__EFMigrationsHistory]
WHERE [MigrationId] = N'20260115063457_UpdatePersonnelFieldsComplete'
)
BEGIN
ALTER TABLE [Personnel] ADD [EnlistmentDate] nvarchar(max) NULL;
END;
GO
IF NOT EXISTS (
SELECT * FROM [__EFMigrationsHistory]
WHERE [MigrationId] = N'20260115063457_UpdatePersonnelFieldsComplete'
)
BEGIN
ALTER TABLE [Personnel] ADD [Ethnicity] nvarchar(max) NULL;
END;
GO
IF NOT EXISTS (
SELECT * FROM [__EFMigrationsHistory]
WHERE [MigrationId] = N'20260115063457_UpdatePersonnelFieldsComplete'
)
BEGIN
ALTER TABLE [Personnel] ADD [PoliticalStatus] nvarchar(max) NULL;
END;
GO
IF NOT EXISTS (
SELECT * FROM [__EFMigrationsHistory]
WHERE [MigrationId] = N'20260115063457_UpdatePersonnelFieldsComplete'
)
BEGIN
ALTER TABLE [Personnel] ADD [Specialty] nvarchar(max) NULL;
END;
GO
IF NOT EXISTS (
SELECT * FROM [__EFMigrationsHistory]
WHERE [MigrationId] = N'20260115063457_UpdatePersonnelFieldsComplete'
)
BEGIN
INSERT INTO [__EFMigrationsHistory] ([MigrationId], [ProductVersion])
VALUES (N'20260115063457_UpdatePersonnelFieldsComplete', N'8.0.0');
END;
GO
COMMIT;
GO
BEGIN TRANSACTION;
GO
IF NOT EXISTS (
SELECT * FROM [__EFMigrationsHistory]
WHERE [MigrationId] = N'20260115152942_AddConsumptionReportTable'
)
BEGIN
ALTER TABLE [UserAccounts] ADD [PlainPassword] nvarchar(max) NULL;
END;
GO
IF NOT EXISTS (
SELECT * FROM [__EFMigrationsHistory]
WHERE [MigrationId] = N'20260115152942_AddConsumptionReportTable'
)
BEGIN
ALTER TABLE [Personnel] ADD [Unit] nvarchar(max) NULL;
END;
GO
IF NOT EXISTS (
SELECT * FROM [__EFMigrationsHistory]
WHERE [MigrationId] = N'20260115152942_AddConsumptionReportTable'
)
BEGIN
CREATE TABLE [ConsumptionReports] (
[Id] int NOT NULL IDENTITY,
[AllocationDistributionId] int NOT NULL,
[ReportedAmount] decimal(18,2) NOT NULL,
[CumulativeAmount] decimal(18,2) NOT NULL,
[Remarks] nvarchar(500) NULL,
[ReportedByUserId] int NOT NULL,
[ReportedAt] datetime2 NOT NULL,
CONSTRAINT [PK_ConsumptionReports] PRIMARY KEY ([Id]),
CONSTRAINT [FK_ConsumptionReports_AllocationDistributions_AllocationDistributionId] FOREIGN KEY ([AllocationDistributionId]) REFERENCES [AllocationDistributions] ([Id]) ON DELETE CASCADE,
CONSTRAINT [FK_ConsumptionReports_UserAccounts_ReportedByUserId] FOREIGN KEY ([ReportedByUserId]) REFERENCES [UserAccounts] ([Id])
);
END;
GO
IF NOT EXISTS (
SELECT * FROM [__EFMigrationsHistory]
WHERE [MigrationId] = N'20260115152942_AddConsumptionReportTable'
)
BEGIN
CREATE INDEX [IX_ConsumptionReports_AllocationDistributionId] ON [ConsumptionReports] ([AllocationDistributionId]);
END;
GO
IF NOT EXISTS (
SELECT * FROM [__EFMigrationsHistory]
WHERE [MigrationId] = N'20260115152942_AddConsumptionReportTable'
)
BEGIN
CREATE INDEX [IX_ConsumptionReports_ReportedAt] ON [ConsumptionReports] ([ReportedAt]);
END;
GO
IF NOT EXISTS (
SELECT * FROM [__EFMigrationsHistory]
WHERE [MigrationId] = N'20260115152942_AddConsumptionReportTable'
)
BEGIN
CREATE INDEX [IX_ConsumptionReports_ReportedByUserId] ON [ConsumptionReports] ([ReportedByUserId]);
END;
GO
IF NOT EXISTS (
SELECT * FROM [__EFMigrationsHistory]
WHERE [MigrationId] = N'20260115152942_AddConsumptionReportTable'
)
BEGIN
INSERT INTO [__EFMigrationsHistory] ([MigrationId], [ProductVersion])
VALUES (N'20260115152942_AddConsumptionReportTable', N'8.0.0');
END;
GO
COMMIT;
GO

View File

@ -46,5 +46,19 @@ export const allocationsApi = {
async getMyDistributions(): Promise<AllocationDistribution[]> {
const response = await apiClient.get<AllocationDistribution[]>('/allocations/my-distributions')
return response.data
},
async getConsumptionReports(distributionId: number): Promise<ConsumptionReport[]> {
const response = await apiClient.get<ConsumptionReport[]>(`/allocations/distributions/${distributionId}/reports`)
return response.data
}
}
export interface ConsumptionReport {
id: number
reportedAmount: number
cumulativeAmount: number
remarks?: string
reportedByUserName?: string
reportedAt: string
}

View File

@ -40,9 +40,7 @@ export const personnelApi = {
},
async approve(data: PersonnelApprovalRequest): Promise<Personnel> {
const response = await apiClient.post<Personnel>(`/personnel/${data.personnelId}/approve`, {
level: data.level
})
const response = await apiClient.post<Personnel>(`/personnel/${data.personnelId}/approve`)
return response.data
},

View File

@ -63,12 +63,21 @@
<span class="unit-text">{{ row.unit }}</span>
</template>
</el-table-column>
<el-table-column prop="totalQuota" label="总配额" width="120" align="right">
<!-- 师团级显示总配额,团部及以下显示本单位配额 -->
<el-table-column v-if="authStore.canCreateAllocations" prop="totalQuota" label="总配额" width="120" align="right">
<template #default="{ row }">
<span class="quota-value">{{ formatNumber(row.totalQuota) }}</span>
</template>
</el-table-column>
<el-table-column label="分配情况" width="150" align="center">
<el-table-column v-else prop="unitQuota" label="配额" width="100" align="center">
<template #default="{ row }">
<div class="quota-cell">
<span class="quota-number">{{ formatNumber(getMyUnitQuota(row)) }}</span>
</div>
</template>
</el-table-column>
<!-- 师团级显示分配情况,团部及以下显示消耗情况 -->
<el-table-column v-if="authStore.canCreateAllocations" label="分配情况" width="150" align="center">
<template #default="{ row }">
<div class="distribution-info">
<span class="dist-count">{{ row.distributions?.length || 0 }} 个单位</span>
@ -81,7 +90,23 @@
</div>
</template>
</el-table-column>
<el-table-column prop="createdByUnitName" label="创建单位" width="120" show-overflow-tooltip />
<el-table-column v-else label="消耗情况" width="200" align="center">
<template #default="{ row }">
<div class="consumption-cell">
<div class="consumption-numbers">
<span class="completion-num">{{ formatNumber(getMyActualCompletion(row)) }}</span>
<span class="separator">/</span>
<span class="quota-num">{{ formatNumber(getMyUnitQuota(row)) }}</span>
</div>
<el-progress
:percentage="getMyCompletionPercentage(row)"
:stroke-width="8"
:status="getProgressStatus(getMyCompletionPercentage(row) / 100)"
/>
</div>
</template>
</el-table-column>
<el-table-column v-if="authStore.canCreateAllocations" prop="createdByUnitName" label="创建单位" width="120" show-overflow-tooltip />
<el-table-column prop="createdAt" label="创建时间" width="160" align="center">
<template #default="{ row }">
<span class="time-cell">{{ formatDate(row.createdAt) }}</span>
@ -128,39 +153,81 @@
<el-dialog
v-model="showDistributionDialog"
:title="`配额分配详情 - ${selectedAllocation?.materialName || ''}`"
width="900px"
width="1000px"
:close-on-click-modal="false"
>
<div class="distribution-summary">
<el-row :gutter="20">
<el-row :gutter="24">
<el-col :span="6">
<div class="summary-item">
<div class="summary-label">物资类别</div>
<div class="summary-value">
<el-tag :type="getCategoryTagType(selectedAllocation?.category)" size="small">
<el-tag :type="getCategoryTagType(selectedAllocation?.category)" size="large" effect="plain">
{{ selectedAllocation?.category }}
</el-tag>
</div>
</div>
</el-col>
<el-col :span="6">
<div class="summary-item">
<div class="summary-label">总配额</div>
<div class="summary-value highlight">{{ formatNumber(selectedAllocation?.totalQuota || 0) }} {{ selectedAllocation?.unit }}</div>
</div>
</el-col>
<el-col :span="6">
<div class="summary-item">
<div class="summary-label">已分配</div>
<div class="summary-value">{{ formatNumber(getTotalDistributed()) }} {{ selectedAllocation?.unit }}</div>
</div>
</el-col>
<el-col :span="6">
<div class="summary-item">
<div class="summary-label">分配单位数</div>
<div class="summary-value">{{ distributions.length }} </div>
</div>
</el-col>
<!-- 师团级显示总配额已分配分配单位数 -->
<template v-if="authStore.canCreateAllocations">
<el-col :span="6">
<div class="summary-item">
<div class="summary-label">总配额</div>
<div class="summary-value highlight">
<span class="number">{{ formatNumber(selectedAllocation?.totalQuota || 0) }}</span>
<span class="unit">{{ selectedAllocation?.unit }}</span>
</div>
</div>
</el-col>
<el-col :span="6">
<div class="summary-item">
<div class="summary-label">已分配</div>
<div class="summary-value allocated">
<span class="number">{{ formatNumber(getTotalDistributed()) }}</span>
<span class="unit">{{ selectedAllocation?.unit }}</span>
</div>
</div>
</el-col>
<el-col :span="6">
<div class="summary-item">
<div class="summary-label">分配单位数</div>
<div class="summary-value units">
<span class="number">{{ distributions.length }}</span>
<span class="unit"></span>
</div>
</div>
</el-col>
</template>
<!-- 团部及以下显示本单位配额总消耗下级单位数 -->
<template v-else>
<el-col :span="6">
<div class="summary-item">
<div class="summary-label">本单位配额</div>
<div class="summary-value highlight">
<span class="number">{{ formatNumber(getTotalDistributed()) }}</span>
<span class="unit">{{ selectedAllocation?.unit }}</span>
</div>
</div>
</el-col>
<el-col :span="6">
<div class="summary-item">
<div class="summary-label">总消耗</div>
<div class="summary-value consumed">
<span class="number">{{ formatNumber(getTotalConsumed()) }}</span>
<span class="unit">{{ selectedAllocation?.unit }}</span>
</div>
</div>
</el-col>
<el-col :span="6">
<div class="summary-item">
<div class="summary-label">下级单位数</div>
<div class="summary-value units">
<span class="number">{{ distributions.length }}</span>
<span class="unit"></span>
</div>
</div>
</el-col>
</template>
</el-row>
</div>
@ -168,47 +235,66 @@
:data="distributions"
style="width: 100%"
stripe
:header-cell-style="{ background: '#f5f7fa', color: '#606266', fontWeight: 'bold' }"
:header-cell-style="{ background: '#f5f7fa', color: '#606266', fontWeight: 'bold', fontSize: '14px' }"
:row-style="{ height: '60px' }"
>
<el-table-column prop="targetUnitName" label="目标单位" min-width="150">
<template #default="{ row }">
<span class="unit-name">{{ row.targetUnitName }}</span>
<div class="unit-cell">
<el-icon class="unit-icon" :size="16"><OfficeBuilding /></el-icon>
<span class="unit-name">{{ row.targetUnitName }}</span>
</div>
</template>
</el-table-column>
<el-table-column prop="unitQuota" label="分配配额" width="120" align="right">
<el-table-column prop="unitQuota" label="分配配额" width="120" align="center">
<template #default="{ row }">
<span class="quota-value">{{ formatNumber(row.unitQuota) }}</span>
<div class="quota-cell-dialog">
<span class="quota-number-dialog">{{ formatNumber(row.unitQuota) }}</span>
</div>
</template>
</el-table-column>
<el-table-column prop="actualCompletion" label="实际完成" width="120" align="right">
<el-table-column prop="actualCompletion" label="实际完成" width="120" align="center">
<template #default="{ row }">
<span :class="['completion-value', row.actualCompletion ? '' : 'no-data']">
{{ row.actualCompletion ? formatNumber(row.actualCompletion) : '未上报' }}
</span>
<div class="completion-cell">
<span v-if="row.actualCompletion" class="completion-number">{{ formatNumber(row.actualCompletion) }}</span>
<el-tag v-else type="info" size="small" effect="plain">未上报</el-tag>
</div>
</template>
</el-table-column>
<el-table-column prop="completionRate" label="完成率" width="150" align="center">
<el-table-column prop="completionRate" label="完成率" width="180" align="center">
<template #default="{ row }">
<div class="progress-cell">
<div class="progress-cell-dialog">
<el-progress
:percentage="Math.round(row.completionRate * 100)"
:status="getProgressStatus(row.completionRate)"
:stroke-width="8"
:stroke-width="10"
/>
</div>
</template>
</el-table-column>
<el-table-column prop="reportedAt" label="上报时间" width="160" align="center">
<el-table-column prop="reportedAt" label="上报时间" width="150" align="center">
<template #default="{ row }">
<span class="time-cell">{{ row.reportedAt ? formatDate(row.reportedAt) : '-' }}</span>
<div class="time-cell-dialog">
<el-icon v-if="row.reportedAt" class="time-icon" :size="14"><Clock /></el-icon>
<span>{{ row.reportedAt ? formatDate(row.reportedAt) : '-' }}</span>
</div>
</template>
</el-table-column>
<el-table-column label="操作" width="100" align="center">
<el-table-column label="操作" width="180" align="center" fixed="right">
<template #default="{ row }">
<el-button
v-if="row.actualCompletion"
type="warning"
size="small"
plain
@click="handleViewReports(row)"
>
上报记录
</el-button>
<el-button
v-if="canReportConsumption(row)"
type="primary"
size="small"
size="small"
@click="handleReportConsumption(row)"
>
上报消耗
@ -217,6 +303,71 @@
</el-table-column>
</el-table>
</el-dialog>
<!-- Consumption Reports Dialog -->
<el-dialog
v-model="showReportsDialog"
:title="`上报记录 - ${selectedDistribution?.targetUnitName || ''}`"
width="700px"
:close-on-click-modal="false"
>
<div class="reports-summary">
<el-row :gutter="16">
<el-col :span="8">
<div class="report-stat">
<div class="stat-label">分配配额</div>
<div class="stat-value highlight">{{ formatNumber(selectedDistribution?.unitQuota || 0) }}</div>
</div>
</el-col>
<el-col :span="8">
<div class="report-stat">
<div class="stat-label">累计消耗</div>
<div class="stat-value consumed">{{ formatNumber(selectedDistribution?.actualCompletion || 0) }}</div>
</div>
</el-col>
<el-col :span="8">
<div class="report-stat">
<div class="stat-label">上报次数</div>
<div class="stat-value">{{ consumptionReports.length }}</div>
</div>
</el-col>
</el-row>
</div>
<el-table
:data="consumptionReports"
style="width: 100%"
v-loading="loadingReports"
stripe
:header-cell-style="{ background: '#f5f7fa', color: '#606266', fontWeight: 'bold' }"
>
<el-table-column type="index" label="序号" width="60" align="center" />
<el-table-column prop="reportedAmount" label="本次上报" width="120" align="center">
<template #default="{ row }">
<span class="report-amount">+{{ formatNumber(row.reportedAmount) }}</span>
</template>
</el-table-column>
<el-table-column prop="cumulativeAmount" label="累计数量" width="120" align="center">
<template #default="{ row }">
<span class="cumulative-amount">{{ formatNumber(row.cumulativeAmount) }}</span>
</template>
</el-table-column>
<el-table-column prop="reportedByUserName" label="上报人" min-width="120">
<template #default="{ row }">
<span>{{ row.reportedByUserName || '-' }}</span>
</template>
</el-table-column>
<el-table-column prop="reportedAt" label="上报时间" width="160" align="center">
<template #default="{ row }">
<span class="time-cell">{{ formatDate(row.reportedAt) }}</span>
</template>
</el-table-column>
</el-table>
<template v-if="consumptionReports.length === 0 && !loadingReports">
<el-empty description="暂无上报记录" />
</template>
</el-dialog>
</div>
</template>
@ -224,7 +375,7 @@
import { ref, reactive, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Plus, Search, View, Edit, Delete, Box, Setting } from '@element-plus/icons-vue'
import { Plus, Search, View, Edit, Delete, Box, Setting, OfficeBuilding, Clock } from '@element-plus/icons-vue'
import { useAuthStore } from '@/stores/auth'
import { allocationsApi } from '@/api'
import type { MaterialAllocation, AllocationDistribution } from '@/types'
@ -234,11 +385,15 @@ const authStore = useAuthStore()
const allocations = ref<MaterialAllocation[]>([])
const distributions = ref<AllocationDistribution[]>([])
const consumptionReports = ref<any[]>([])
const loading = ref(false)
const loadingReports = ref(false)
const showDistributionDialog = ref(false)
const showReportsDialog = ref(false)
const searchKeyword = ref('')
const categoryFilter = ref('')
const selectedAllocation = ref<MaterialAllocation | null>(null)
const selectedDistribution = ref<AllocationDistribution | null>(null)
const pagination = reactive({
pageNumber: 1,
@ -259,7 +414,13 @@ const filteredAllocations = computed(() => {
})
function formatDate(dateStr: string): string {
return new Date(dateStr).toLocaleString('zh-CN')
const date = new Date(dateStr)
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
const hours = String(date.getHours()).padStart(2, '0')
const minutes = String(date.getMinutes()).padStart(2, '0')
return `${year}/${month}/${day} ${hours}:${minutes}`
}
function formatNumber(num: number): string {
@ -288,14 +449,48 @@ function getDistributionPercentage(allocation: MaterialAllocation): number {
return Math.round((distributed / allocation.totalQuota) * 100)
}
function getMyUnitQuota(allocation: MaterialAllocation): number {
if (!authStore.user || !allocation.distributions) return 0
//
let myDistribution = allocation.distributions.find(d => d.targetUnitId === authStore.user?.organizationalUnitId)
//
if (!myDistribution && allocation.distributions.length > 0) {
myDistribution = allocation.distributions[0]
}
return myDistribution?.unitQuota || 0
}
function getMyActualCompletion(allocation: MaterialAllocation): number {
if (!authStore.user || !allocation.distributions) return 0
//
let myDistribution = allocation.distributions.find(d => d.targetUnitId === authStore.user?.organizationalUnitId)
//
if (!myDistribution && allocation.distributions.length > 0) {
myDistribution = allocation.distributions[0]
}
return myDistribution?.actualCompletion || 0
}
function getMyCompletionPercentage(allocation: MaterialAllocation): number {
const quota = getMyUnitQuota(allocation)
if (quota === 0) return 0
const completion = getMyActualCompletion(allocation)
return Math.round((completion / quota) * 100)
}
function getTotalDistributed(): number {
return distributions.value.reduce((sum, d) => sum + (d.unitQuota || 0), 0)
}
function getTotalConsumed(): number {
return distributions.value.reduce((sum, d) => sum + (d.actualCompletion || 0), 0)
}
function canReportConsumption(distribution: AllocationDistribution): boolean {
//
//
if (!authStore.user) return false
return distribution.targetUnitId === authStore.user.organizationalUnitId
//
return true
}
function handleReportConsumption(distribution: AllocationDistribution) {
@ -309,6 +504,21 @@ function handleReportConsumption(distribution: AllocationDistribution) {
})
}
async function handleViewReports(distribution: AllocationDistribution) {
selectedDistribution.value = distribution
showReportsDialog.value = true
loadingReports.value = true
try {
const reports = await allocationsApi.getConsumptionReports(distribution.id)
consumptionReports.value = reports
} catch {
ElMessage.error('加载上报记录失败')
consumptionReports.value = []
} finally {
loadingReports.value = false
}
}
function handleSearch() {
pagination.pageNumber = 1
}
@ -424,6 +634,20 @@ onMounted(() => {
font-size: 14px;
}
.quota-cell {
display: flex;
justify-content: center;
align-items: center;
padding: 8px 0;
}
.quota-number {
font-size: 18px;
font-weight: 700;
color: #409EFF;
font-family: 'Arial', sans-serif;
}
.distribution-info {
display: flex;
align-items: center;
@ -435,6 +659,39 @@ onMounted(() => {
color: #606266;
}
.consumption-cell {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
padding: 8px 12px;
}
.consumption-numbers {
display: flex;
align-items: baseline;
gap: 4px;
font-family: 'Arial', sans-serif;
}
.completion-num {
font-size: 16px;
font-weight: 700;
color: #67C23A;
}
.separator {
font-size: 14px;
color: #909399;
margin: 0 2px;
}
.quota-num {
font-size: 14px;
font-weight: 600;
color: #606266;
}
.time-cell {
font-size: 13px;
color: #909399;
@ -458,35 +715,127 @@ onMounted(() => {
/* Distribution Dialog Styles */
.distribution-summary {
background: #f5f7fa;
border-radius: 8px;
padding: 16px;
margin-bottom: 20px;
background: linear-gradient(135deg, #f5f7fa 0%, #e8eef5 100%);
border-radius: 12px;
padding: 24px;
margin-bottom: 24px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
}
.summary-item {
text-align: center;
padding: 12px;
background: white;
border-radius: 8px;
transition: transform 0.2s;
}
.summary-item:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
}
.summary-label {
font-size: 12px;
font-size: 13px;
color: #909399;
margin-bottom: 8px;
margin-bottom: 12px;
font-weight: 500;
}
.summary-value {
font-size: 16px;
font-weight: 600;
color: #303133;
display: flex;
align-items: baseline;
justify-content: center;
gap: 4px;
}
.summary-value.highlight {
.summary-value .number {
font-size: 24px;
font-weight: 700;
font-family: 'Arial', sans-serif;
}
.summary-value .unit {
font-size: 14px;
color: #909399;
font-weight: 500;
}
.summary-value.highlight .number {
color: #409EFF;
}
.summary-value.allocated .number {
color: #67C23A;
}
.summary-value.consumed .number {
color: #F56C6C;
}
.summary-value.units .number {
color: #E6A23C;
}
.unit-cell {
display: flex;
align-items: center;
gap: 8px;
}
.unit-icon {
color: #409EFF;
font-size: 18px;
}
.unit-name {
font-weight: 500;
color: #303133;
}
.quota-cell-dialog {
display: flex;
justify-content: center;
align-items: center;
}
.quota-number-dialog {
font-size: 16px;
font-weight: 700;
color: #409EFF;
font-family: 'Arial', sans-serif;
}
.completion-cell {
display: flex;
justify-content: center;
align-items: center;
}
.completion-number {
font-size: 16px;
font-weight: 700;
color: #67C23A;
font-family: 'Arial', sans-serif;
}
.progress-cell-dialog {
padding: 0 12px;
}
.time-cell-dialog {
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
color: #606266;
font-size: 13px;
}
.time-icon {
color: #909399;
}
.completion-value {
@ -518,6 +867,73 @@ onMounted(() => {
:deep(.el-dialog__header) {
border-bottom: 1px solid #ebeef5;
padding-bottom: 16px;
padding: 20px 24px;
font-size: 18px;
font-weight: 600;
}
:deep(.el-dialog__body) {
padding: 24px;
}
:deep(.consumption-cell .el-progress) {
width: 100%;
max-width: 140px;
}
:deep(.consumption-cell .el-progress__text) {
font-size: 13px !important;
font-weight: 600;
}
:deep(.progress-cell-dialog .el-progress__text) {
font-size: 14px !important;
font-weight: 600;
}
/* Reports Dialog Styles */
.reports-summary {
background: linear-gradient(135deg, #f5f7fa 0%, #e8eef5 100%);
border-radius: 8px;
padding: 16px;
margin-bottom: 20px;
}
.report-stat {
text-align: center;
padding: 12px;
background: white;
border-radius: 6px;
}
.stat-label {
font-size: 12px;
color: #909399;
margin-bottom: 8px;
}
.stat-value {
font-size: 20px;
font-weight: 700;
color: #303133;
font-family: 'Arial', sans-serif;
}
.stat-value.highlight {
color: #409EFF;
}
.stat-value.consumed {
color: #F56C6C;
}
.report-amount {
font-weight: 600;
color: #67C23A;
}
.cumulative-amount {
font-weight: 600;
color: #409EFF;
}
</style>

View File

@ -40,9 +40,14 @@
<el-descriptions-item label="分配配额">
<span class="quota-value">{{ formatNumber(distribution.unitQuota) }} {{ allocation?.unit }}</span>
</el-descriptions-item>
<el-descriptions-item label="当前完成">
<el-descriptions-item label="已上报数量">
<span :class="['completion-value', distribution.actualCompletion ? '' : 'no-data']">
{{ distribution.actualCompletion ? formatNumber(distribution.actualCompletion) : '未上报' }}
{{ distribution.actualCompletion ? formatNumber(distribution.actualCompletion) : '0' }} {{ allocation?.unit }}
</span>
</el-descriptions-item>
<el-descriptions-item label="剩余配额">
<span class="remaining-value">
{{ formatNumber((distribution.unitQuota || 0) - (distribution.actualCompletion || 0)) }} {{ allocation?.unit }}
</span>
</el-descriptions-item>
</el-descriptions>
@ -50,7 +55,17 @@
<!-- 上报表单 -->
<div class="form-section">
<h3 class="section-title">消耗数据</h3>
<h3 class="section-title">本次消耗上报</h3>
<el-alert
type="warning"
:closable="false"
show-icon
style="margin-bottom: 20px"
>
<template #title>
<span>注意本次上报数量将累加到已上报总数中</span>
</template>
</el-alert>
<el-form
ref="formRef"
:model="form"
@ -58,11 +73,11 @@
label-width="120px"
@submit.prevent="handleSubmit"
>
<el-form-item label="实际完成数量" prop="actualCompletion">
<el-form-item label="本次上报数量" prop="actualCompletion">
<el-input-number
v-model="form.actualCompletion"
:min="0"
:max="distribution.unitQuota"
:max="(distribution.unitQuota || 0) - (distribution.actualCompletion || 0)"
:precision="2"
:step="1"
style="width: 300px"
@ -70,6 +85,16 @@
<span class="unit-hint">{{ allocation?.unit }}</span>
</el-form-item>
<el-form-item label="上报后总数">
<div class="total-after-report">
<span class="total-number">{{ formatNumber((distribution.actualCompletion || 0) + (form.actualCompletion || 0)) }}</span>
<span class="unit-text">{{ allocation?.unit }}</span>
<span class="separator">/</span>
<span class="quota-number">{{ formatNumber(distribution.unitQuota || 0) }}</span>
<span class="unit-text">{{ allocation?.unit }}</span>
</div>
</el-form-item>
<el-form-item label="完成率">
<div class="completion-rate">
<el-progress
@ -107,19 +132,45 @@
</el-form>
</div>
<!-- 历史记录 -->
<div v-if="distribution.reportedAt" class="history-section">
<!-- 上报历史 -->
<div class="history-section">
<h3 class="section-title">上报历史</h3>
<el-alert
type="info"
:closable="false"
show-icon
<el-table
v-if="consumptionReports.length > 0"
:data="consumptionReports"
stripe
:header-cell-style="{ background: '#f5f7fa', color: '#606266', fontWeight: 'bold' }"
>
<template #title>
<span>上次上报时间: {{ formatDate(distribution.reportedAt) }}</span>
</template>
<div>上次上报数量: {{ formatNumber(distribution.actualCompletion || 0) }} {{ allocation?.unit }}</div>
</el-alert>
<el-table-column label="上报时间" width="180" align="center">
<template #default="{ row }">
<div class="time-cell">
<el-icon class="time-icon" :size="14"><Clock /></el-icon>
<span>{{ formatDate(row.reportedAt) }}</span>
</div>
</template>
</el-table-column>
<el-table-column label="本次上报" width="120" align="center">
<template #default="{ row }">
<span class="reported-amount">+{{ formatNumber(row.reportedAmount) }}</span>
</template>
</el-table-column>
<el-table-column label="累计数量" width="120" align="center">
<template #default="{ row }">
<span class="cumulative-amount">{{ formatNumber(row.cumulativeAmount) }}</span>
</template>
</el-table-column>
<el-table-column label="上报人" width="120" align="center">
<template #default="{ row }">
<span>{{ row.reportedByUserName || '-' }}</span>
</template>
</el-table-column>
<el-table-column label="备注" min-width="150">
<template #default="{ row }">
<span class="remarks-text">{{ row.remarks || '-' }}</span>
</template>
</el-table-column>
</el-table>
<el-empty v-else description="暂无上报记录" :image-size="80" />
</div>
</div>
@ -132,8 +183,8 @@
import { ref, reactive, computed, onMounted } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus'
import { DocumentAdd, Back, Check, Close } from '@element-plus/icons-vue'
import { allocationsApi } from '@/api'
import { DocumentAdd, Back, Check, Close, Clock } from '@element-plus/icons-vue'
import { allocationsApi, type ConsumptionReport } from '@/api'
import type { MaterialAllocation, AllocationDistribution } from '@/types'
const router = useRouter()
@ -143,6 +194,7 @@ const loading = ref(false)
const submitting = ref(false)
const allocation = ref<MaterialAllocation | null>(null)
const distribution = ref<AllocationDistribution | null>(null)
const consumptionReports = ref<ConsumptionReport[]>([])
const formRef = ref<FormInstance>()
const form = reactive({
@ -152,17 +204,18 @@ const form = reactive({
const rules: FormRules = {
actualCompletion: [
{ required: true, message: '请输入实际完成数量', trigger: 'blur' },
{ required: true, message: '请输入本次上报数量', trigger: 'blur' },
{
type: 'number',
min: 0,
message: '数量不能小于0',
min: 0.01,
message: '数量必须大于0',
trigger: 'blur'
},
{
validator: (_rule, value, callback) => {
if (value > (distribution.value?.unitQuota || 0)) {
callback(new Error(`数量不能超过分配配额 ${distribution.value?.unitQuota}`))
const remaining = (distribution.value?.unitQuota || 0) - (distribution.value?.actualCompletion || 0)
if (value > remaining) {
callback(new Error(`本次上报数量不能超过剩余配额 ${remaining}`))
} else {
callback()
}
@ -191,8 +244,9 @@ function getCategoryTagType(category?: string): string {
}
function getCompletionPercentage(): number {
if (!distribution.value || !form.actualCompletion) return 0
return Math.round((form.actualCompletion / distribution.value.unitQuota) * 100)
if (!distribution.value) return 0
const total = (distribution.value.actualCompletion || 0) + (form.actualCompletion || 0)
return Math.round((total / distribution.value.unitQuota) * 100)
}
function getProgressStatus(): string {
@ -212,8 +266,10 @@ async function handleSubmit() {
try {
await formRef.value.validate()
const newTotal = (distribution.value?.actualCompletion || 0) + form.actualCompletion
await ElMessageBox.confirm(
`确认上报实际完成数量为 ${form.actualCompletion} ${allocation.value?.unit} 吗?`,
`本次上报数量:${form.actualCompletion} ${allocation.value?.unit}\n上报后总数${newTotal} ${allocation.value?.unit}\n\n确认提交吗?`,
'确认上报',
{
type: 'warning',
@ -226,8 +282,9 @@ async function handleSubmit() {
if (!distribution.value) return
//
await allocationsApi.updateDistribution(distribution.value.id, {
actualCompletion: form.actualCompletion
actualCompletion: newTotal
})
ElMessage.success('上报成功')
@ -265,10 +322,11 @@ async function loadData() {
return
}
// ,
if (distribution.value.actualCompletion) {
form.actualCompletion = distribution.value.actualCompletion
}
//
form.actualCompletion = 0
//
await loadConsumptionReports(distributionId)
} catch {
ElMessage.error('加载数据失败')
router.back()
@ -277,6 +335,14 @@ async function loadData() {
}
}
async function loadConsumptionReports(distributionId: number) {
try {
consumptionReports.value = await allocationsApi.getConsumptionReports(distributionId)
} catch {
console.error('加载上报历史失败')
}
}
onMounted(() => {
loadData()
})
@ -354,6 +420,42 @@ onMounted(() => {
font-weight: normal;
}
.remaining-value {
font-weight: 600;
color: #E6A23C;
font-size: 14px;
}
.total-after-report {
display: flex;
align-items: baseline;
gap: 6px;
font-family: 'Arial', sans-serif;
}
.total-number {
font-size: 24px;
font-weight: 700;
color: #67C23A;
}
.quota-number {
font-size: 18px;
font-weight: 600;
color: #909399;
}
.unit-text {
font-size: 14px;
color: #909399;
}
.separator {
font-size: 18px;
color: #909399;
margin: 0 4px;
}
.unit-hint {
margin-left: 12px;
color: #909399;
@ -390,4 +492,40 @@ onMounted(() => {
:deep(.el-form-item__label) {
font-weight: 500;
}
.time-cell {
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
color: #606266;
font-size: 13px;
}
.time-icon {
color: #909399;
}
.reported-amount {
font-size: 16px;
font-weight: 700;
color: #67C23A;
font-family: 'Arial', sans-serif;
}
.cumulative-amount {
font-size: 16px;
font-weight: 700;
color: #409EFF;
font-family: 'Arial', sans-serif;
}
.remarks-text {
color: #606266;
font-size: 13px;
}
.history-section :deep(.el-table) {
margin-top: 16px;
}
</style>

View File

@ -83,7 +83,7 @@
<span class="time-cell">{{ formatDate(row.submittedAt) }}</span>
</template>
</el-table-column>
<el-table-column label="操作" width="180" fixed="right" align="center">
<el-table-column label="操作" width="220" fixed="right" align="center">
<template #default="{ row }">
<div class="action-buttons">
<el-tooltip content="查看详情" placement="top">
@ -96,12 +96,17 @@
<el-icon><Check /></el-icon>
</el-button>
</el-tooltip>
<el-tooltip v-if="row.status === 'Approved' && authStore.canApprove && canUpgrade(row)" content="向上申报" placement="top">
<el-button type="warning" link size="small" @click="handleUpgrade(row)">
<el-icon><Top /></el-icon>
</el-button>
</el-tooltip>
<el-tooltip v-if="row.status === 'Pending'" content="编辑" placement="top">
<el-button type="warning" link size="small" @click="handleEdit(row)">
<el-icon><Edit /></el-icon>
</el-button>
</el-tooltip>
<el-tooltip content="删除" placement="top">
<el-tooltip v-if="row.status === 'Pending'" content="删除" placement="top">
<el-button type="danger" link size="small" @click="handleDelete(row)">
<el-icon><Delete /></el-icon>
</el-button>
@ -134,15 +139,7 @@
<el-descriptions-item label="军衔">{{ selectedPerson?.rank }}</el-descriptions-item>
<el-descriptions-item label="所属单位">{{ selectedPerson?.submittedByUnitName }}</el-descriptions-item>
</el-descriptions>
<el-form-item label="人员等级" style="margin-top: 20px">
<el-select v-model="approvalForm.level" placeholder="请选择人员等级" style="width: 100%">
<el-option label="师级人才" value="Division" />
<el-option label="团级人才" value="Regiment" />
<el-option label="营级人才" value="Battalion" />
<el-option label="连级人才" value="Company" />
</el-select>
</el-form-item>
<el-form-item label="审批意见">
<el-form-item label="审批意见" style="margin-top: 20px">
<el-input v-model="approvalForm.comments" type="textarea" rows="3" placeholder="请输入审批意见(可选)" />
</el-form-item>
</el-form>
@ -163,7 +160,7 @@
import { ref, reactive, watch, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Plus, Search, View, Edit, Delete, Check, Close, User } from '@element-plus/icons-vue'
import { Plus, Search, View, Edit, Delete, Check, Close, User, Top } from '@element-plus/icons-vue'
import { useAuthStore } from '@/stores/auth'
import { personnelApi } from '@/api'
import type { Personnel } from '@/types'
@ -272,6 +269,24 @@ function handleEdit(person: Personnel) {
router.push(`/personnel/${person.id}/edit`)
}
function canUpgrade(person: Personnel): boolean {
//
if (!authStore.user || !person.approvedLevel) return false
//
if (person.approvedLevel === PersonnelLevel.Division) return false
//
const userLevel = authStore.user.organizationalLevel
const personnelLevel = person.approvedLevel
// Division=1, Regiment=2, Battalion=3, Company=4
const levelOrder: Record<string, number> = {
'Division': 1,
'Regiment': 2,
'Battalion': 3,
'Company': 4
}
return levelOrder[userLevel] < levelOrder[personnelLevel]
}
function handleApprove(person: Personnel) {
selectedPerson.value = person
approvalForm.comments = ''
@ -279,6 +294,13 @@ function handleApprove(person: Personnel) {
showApprovalDialog.value = true
}
function handleUpgrade(person: Personnel) {
// 使
selectedPerson.value = person
approvalForm.comments = ''
showApprovalDialog.value = true
}
async function submitApproval(approved: boolean) {
if (!selectedPerson.value) return
@ -288,8 +310,7 @@ async function submitApproval(approved: boolean) {
await personnelApi.approve({
personnelId: selectedPerson.value.id,
approved: true,
comments: approvalForm.comments,
level: approvalForm.level
comments: approvalForm.comments
})
ElMessage.success('审批通过')
} else {