细节优化

This commit is contained in:
18631081161 2026-01-16 17:19:58 +08:00
parent 9595119163
commit b4c5ebc926
15 changed files with 1478 additions and 192 deletions

View File

@ -0,0 +1,138 @@
# Design Document
## Overview
本设计实现营部及以下级别账号的消耗上报可见性过滤功能。核心思路是在 `IOrganizationService` 中添加一个新方法 `GetVisibleUnitIdsAsync`根据用户的组织层级返回可见的单位ID列表。对于营部及以下级别只返回本单位及其直接下级对于师团级别返回本单位及所有下级。
## Architecture
```mermaid
flowchart TD
A[ReportsController] --> B[ReportingService]
B --> C[OrganizationService]
C --> D{User Level Check}
D -->|Battalion/Company| E[GetVisibleUnitIdsAsync - Restricted]
D -->|Division/Regiment| F[GetAllSubordinateIdsAsync - Full]
E --> G[Return: Own Unit + Direct Subordinates]
F --> H[Return: Own Unit + All Subordinates]
```
## Components and Interfaces
### 1. IOrganizationService 扩展
新增方法用于获取用户可见的单位ID列表
```csharp
/// <summary>
/// 获取用户可见的单位ID列表
/// 营部及以下级别:本单位 + 直接下级
/// 师团级别:本单位 + 所有下级
/// </summary>
Task<IEnumerable<int>> GetVisibleUnitIdsAsync(int unitId, OrganizationalLevel userLevel);
```
### 2. IReportingService 修改
修改现有方法以支持可见性过滤:
```csharp
/// <summary>
/// 获取汇总数据(应用可见性过滤)
/// </summary>
Task<IEnumerable<AllocationDistribution>> GetAggregatedDataAsync(int unitId, OrganizationalLevel userLevel);
/// <summary>
/// 获取详细上报记录(应用可见性过滤)
/// </summary>
Task<IEnumerable<ReportResponse>> GetDetailedReportsAsync(int unitId, OrganizationalLevel userLevel);
```
### 3. ReportsController 修改
在调用服务方法时传递用户的组织层级:
```csharp
var unitLevel = GetCurrentUnitLevel();
var reports = await _reportingService.GetDetailedReportsAsync(unitId.Value, unitLevel.Value);
```
## Data Models
无需新增数据模型,使用现有的:
- `OrganizationalUnit` - 组织单位实体
- `OrganizationalLevel` - 组织层级枚举
- `AllocationDistribution` - 配额分配记录
- `ConsumptionReport` - 消耗上报记录
### 可见性规则数据流
| 用户层级 | 可见范围 | 示例 |
|---------|---------|------|
| Division (师团) | 本单位 + 所有下级 | 师团A → 团B, 团C, 营D, 营E, 连F... |
| Regiment (团) | 本单位 + 所有下级 | 团B → 营D, 营E, 连F, 连G... |
| Battalion (营) | 本单位 + 直接下级 | 营D → 连F, 连G (不含同级营E及其下级) |
| Company (连) | 仅本单位 | 连F → 仅连F |
## Correctness Properties
*A property is a characteristic or behavior that should hold true across all valid executions of a system-essentially, a formal statement about what the system should do. Properties serve as the bridge between human-readable specifications and machine-verifiable correctness guarantees.*
### Property 1: Restricted visibility for battalion/company level users
*For any* organization hierarchy and any user at Battalion_Level or Company_Level, the visible unit IDs returned by `GetVisibleUnitIdsAsync` SHALL:
- Include the user's own unit ID
- Include only direct subordinate unit IDs (children)
- Exclude all sibling unit IDs (units with the same parent)
- Exclude all subordinates of sibling units
**Validates: Requirements 1.1, 1.2, 1.3, 3.1, 3.2**
### Property 2: Full visibility for division/regiment level users
*For any* organization hierarchy and any user at Division_Level or Regiment_Level, the visible unit IDs returned by `GetVisibleUnitIdsAsync` SHALL equal the union of the user's own unit ID and all subordinate unit IDs (as returned by `GetAllSubordinateIdsAsync`).
**Validates: Requirements 1.4, 3.3**
### Property 3: Access control respects visibility scope
*For any* user and any target unit ID, the `CanViewUnitDataAsync` method SHALL return `true` if and only if the target unit ID is contained in the user's visible unit IDs set.
**Validates: Requirements 2.3**
## Error Handling
| Scenario | Handling |
|----------|----------|
| User unit not found | Return empty visible units list |
| Invalid organizational level | Default to restricted visibility (fail-safe) |
| Database query failure | Propagate exception to controller for 500 response |
## Testing Strategy
### Property-Based Testing
使用 FsCheck 库进行属性测试(项目已使用 xUnit + FsCheck
**测试配置:**
- 每个属性测试运行最少 100 次迭代
- 使用自定义生成器创建有效的组织层级树
**属性测试标注格式:**
```csharp
// **Feature: consumption-report-visibility-filter, Property 1: Restricted visibility for battalion/company level users**
// **Validates: Requirements 1.1, 1.2, 1.3, 3.1, 3.2**
```
### Unit Tests
- 测试边界情况:无下级单位的营级用户
- 测试边界情况:连级用户(最底层)
- 测试师团级用户的完整可见性
### Integration Tests (Optional)
- 验证 API 端点正确应用可见性过滤
- 验证聚合数据计算使用过滤后的单位列表

View File

@ -0,0 +1,47 @@
# Requirements Document
## Introduction
本功能用于限制营部及以下级别账号在上报消耗页面和上报历史中的数据可见性。当前系统允许用户查看所有下级单位的上报记录,但营部及以下级别用户不应看到同级单位及同级单位下级的上报记录,只能查看本单位及本单位直接下级的记录。
## Glossary
- **Consumption_Report_System**: 消耗上报管理系统,负责处理物资消耗的上报、查询和历史记录展示
- **Battalion_Level**: 营级组织层级OrganizationalLevel.Battalion = 3
- **Company_Level**: 连级组织层级OrganizationalLevel.Company = 4
- **Sibling_Unit**: 同级单位,指与当前用户所属单位具有相同父级单位的其他组织单位
- **Subordinate_Unit**: 下级单位,指当前用户所属单位的直接或间接子单位
- **Report_History**: 上报历史,包含已提交的消耗上报记录列表
## Requirements
### Requirement 1
**User Story:** As a battalion-level or company-level user, I want to only see consumption reports from my own unit and my direct subordinates, so that I cannot access data from sibling units or their subordinates.
#### Acceptance Criteria
1. WHEN a user with Battalion_Level or Company_Level accesses the report history THEN the Consumption_Report_System SHALL return only records belonging to the user's own unit and the user's direct subordinate units
2. WHEN a user with Battalion_Level or Company_Level queries consumption reports THEN the Consumption_Report_System SHALL exclude all records from sibling units
3. WHEN a user with Battalion_Level or Company_Level queries consumption reports THEN the Consumption_Report_System SHALL exclude all records from subordinates of sibling units
4. WHILE a user is at Division_Level or Regiment_Level THEN the Consumption_Report_System SHALL continue to display all subordinate unit records without filtering
### Requirement 2
**User Story:** As a system administrator, I want the visibility filtering to be applied consistently across all report-related API endpoints, so that data access control is enforced uniformly.
#### Acceptance Criteria
1. WHEN the GetDetailedReports endpoint is called by a Battalion_Level or Company_Level user THEN the Consumption_Report_System SHALL apply the visibility filter to exclude sibling unit records
2. WHEN the GetAggregatedData endpoint is called by a Battalion_Level or Company_Level user THEN the Consumption_Report_System SHALL apply the visibility filter to aggregation calculations
3. WHEN the GetByUnit endpoint is called by a Battalion_Level or Company_Level user THEN the Consumption_Report_System SHALL verify the target unit is within the user's visibility scope before returning data
### Requirement 3
**User Story:** As a developer, I want a reusable visibility filter function, so that the filtering logic can be consistently applied and easily tested.
#### Acceptance Criteria
1. WHEN determining visible units for a user THEN the Consumption_Report_System SHALL provide a method that returns only the user's own unit ID and all subordinate unit IDs
2. WHEN the user's organizational level is Battalion_Level or Company_Level THEN the Consumption_Report_System SHALL use the restricted visibility scope
3. WHEN the user's organizational level is Division_Level or Regiment_Level THEN the Consumption_Report_System SHALL use the full subordinate visibility scope

View File

@ -0,0 +1,87 @@
# Implementation Plan
- [x] 1. Implement visibility filter in OrganizationService
- [x] 1.1 Add GetVisibleUnitIdsAsync method to IOrganizationService interface
- Add method signature with unitId and userLevel parameters
- Return IEnumerable<int> of visible unit IDs
- _Requirements: 3.1_
- [x] 1.2 Implement GetVisibleUnitIdsAsync in OrganizationService
- For Battalion/Company level: return own unit + direct children only
- For Division/Regiment level: return own unit + all subordinates
- _Requirements: 1.1, 1.2, 1.3, 1.4, 3.2, 3.3_
- [x] 1.3 Write property test for restricted visibility (Property 1)
- **Property 1: Restricted visibility for battalion/company level users**
- **Validates: Requirements 1.1, 1.2, 1.3, 3.1, 3.2**
- [x] 1.4 Write property test for full visibility (Property 2)
- **Property 2: Full visibility for division/regiment level users**
- **Validates: Requirements 1.4, 3.3**
- [x] 2. Update ReportingService to use visibility filter
- [x] 2.1 Modify GetAggregatedDataAsync to accept userLevel parameter
- Use GetVisibleUnitIdsAsync instead of GetAllSubordinateIdsAsync for filtering
- _Requirements: 2.2_
- [x] 2.2 Modify GetDetailedReportsAsync to accept userLevel parameter
- Apply visibility filter to returned records
- _Requirements: 2.1_
- [x] 2.3 Update CanViewUnitDataAsync to use visibility scope
- Check if target unit is in user's visible units
- _Requirements: 2.3_
- [x] 2.4 Write property test for access control (Property 3)
- **Property 3: Access control respects visibility scope**
- **Validates: Requirements 2.3**
- [x] 3. Update ReportsController to pass user level
- [x] 3.1 Update GetDetailedReports endpoint
- Pass unitLevel to service method
- _Requirements: 2.1_
- [x] 3.2 Update GetAggregatedData endpoint
- Pass unitLevel to service method
- _Requirements: 2.2_
- [x] 3.3 Update GetByTargetUnit endpoint
- Use updated CanViewUnitDataAsync with visibility check
- _Requirements: 2.3_
- [x] 4. Checkpoint - Ensure all tests pass
- Ensure all tests pass, ask the user if questions arise.

View File

@ -22,11 +22,8 @@ public class RequiredFieldValidationPropertyTests
public bool Personnel_WithAllRequiredFields_ValidationPasses( public bool Personnel_WithAllRequiredFields_ValidationPasses(
NonEmptyString name, NonEmptyString name,
NonEmptyString position, NonEmptyString position,
NonEmptyString rank, NonEmptyString rank)
PositiveInt ageInt)
{ {
var age = Math.Min(60, Math.Max(18, ageInt.Get % 43 + 18)); // 18-60岁
// 确保生成的字符串是有效的(非空白字符) // 确保生成的字符串是有效的(非空白字符)
var validName = SanitizeString(name.Get, 50, 2); var validName = SanitizeString(name.Get, 50, 2);
var validPosition = SanitizeString(position.Get, 100, 1); var validPosition = SanitizeString(position.Get, 100, 1);
@ -37,9 +34,7 @@ public class RequiredFieldValidationPropertyTests
Name = validName, Name = validName,
Position = validPosition, Position = validPosition,
Rank = validRank, Rank = validRank,
Gender = "男", IdNumber = GenerateValidIdNumber()
IdNumber = GenerateValidIdNumber(),
Age = age
}; };
var validationResults = ValidateModel(request); var validationResults = ValidateModel(request);
@ -53,19 +48,14 @@ public class RequiredFieldValidationPropertyTests
[Property(MaxTest = 100)] [Property(MaxTest = 100)]
public bool Personnel_WithoutName_ValidationFails( public bool Personnel_WithoutName_ValidationFails(
NonEmptyString position, NonEmptyString position,
NonEmptyString rank, NonEmptyString rank)
PositiveInt ageInt)
{ {
var age = Math.Min(60, Math.Max(18, ageInt.Get % 43 + 18));
var request = new CreatePersonnelRequest var request = new CreatePersonnelRequest
{ {
Name = "", // 缺少姓名 Name = "", // 缺少姓名
Position = TruncateString(position.Get, 100), Position = TruncateString(position.Get, 100),
Rank = TruncateString(rank.Get, 50), Rank = TruncateString(rank.Get, 50),
Gender = "男", IdNumber = GenerateValidIdNumber()
IdNumber = GenerateValidIdNumber(),
Age = age
}; };
var validationResults = ValidateModel(request); var validationResults = ValidateModel(request);
@ -80,19 +70,14 @@ public class RequiredFieldValidationPropertyTests
[Property(MaxTest = 100)] [Property(MaxTest = 100)]
public bool Personnel_WithoutPosition_ValidationFails( public bool Personnel_WithoutPosition_ValidationFails(
NonEmptyString name, NonEmptyString name,
NonEmptyString rank, NonEmptyString rank)
PositiveInt ageInt)
{ {
var age = Math.Min(60, Math.Max(18, ageInt.Get % 43 + 18));
var request = new CreatePersonnelRequest var request = new CreatePersonnelRequest
{ {
Name = TruncateString(name.Get, 50), Name = TruncateString(name.Get, 50),
Position = "", // 缺少职位 Position = "", // 缺少职位
Rank = TruncateString(rank.Get, 50), Rank = TruncateString(rank.Get, 50),
Gender = "男", IdNumber = GenerateValidIdNumber()
IdNumber = GenerateValidIdNumber(),
Age = age
}; };
var validationResults = ValidateModel(request); var validationResults = ValidateModel(request);
@ -106,60 +91,20 @@ public class RequiredFieldValidationPropertyTests
[Property(MaxTest = 100)] [Property(MaxTest = 100)]
public bool Personnel_WithoutRank_ValidationFails( public bool Personnel_WithoutRank_ValidationFails(
NonEmptyString name, NonEmptyString name,
NonEmptyString position, NonEmptyString position)
PositiveInt ageInt)
{ {
var age = Math.Min(60, Math.Max(18, ageInt.Get % 43 + 18));
var request = new CreatePersonnelRequest var request = new CreatePersonnelRequest
{ {
Name = TruncateString(name.Get, 50), Name = TruncateString(name.Get, 50),
Position = TruncateString(position.Get, 100), Position = TruncateString(position.Get, 100),
Rank = "", // 缺少军衔 Rank = "", // 缺少军衔
Gender = "男", IdNumber = GenerateValidIdNumber()
IdNumber = GenerateValidIdNumber(),
Age = age
}; };
var validationResults = ValidateModel(request); var validationResults = ValidateModel(request);
return validationResults.Any(r => r.MemberNames.Contains("Rank")); return validationResults.Any(r => r.MemberNames.Contains("Rank"));
} }
/// <summary>
/// 属性7扩展无效性别的人员数据应该验证失败
/// **验证需求需求6.1**
/// </summary>
[Property(MaxTest = 100)]
public bool Personnel_WithInvalidGender_ValidationFails(
NonEmptyString name,
NonEmptyString position,
NonEmptyString rank,
NonEmptyString invalidGender,
PositiveInt ageInt)
{
// 确保生成的性别不是有效值
var gender = invalidGender.Get;
if (gender == "男" || gender == "女")
{
gender = "无效";
}
var age = Math.Min(60, Math.Max(18, ageInt.Get % 43 + 18));
var request = new CreatePersonnelRequest
{
Name = TruncateString(name.Get, 50),
Position = TruncateString(position.Get, 100),
Rank = TruncateString(rank.Get, 50),
Gender = gender, // 无效性别
IdNumber = GenerateValidIdNumber(),
Age = age
};
var validationResults = ValidateModel(request);
return validationResults.Any(r => r.MemberNames.Contains("Gender"));
}
/// <summary> /// <summary>
/// 属性7扩展无效身份证号的人员数据应该验证失败 /// 属性7扩展无效身份证号的人员数据应该验证失败
/// **验证需求需求6.1** /// **验证需求需求6.1**
@ -168,55 +113,20 @@ public class RequiredFieldValidationPropertyTests
public bool Personnel_WithInvalidIdNumber_ValidationFails( public bool Personnel_WithInvalidIdNumber_ValidationFails(
NonEmptyString name, NonEmptyString name,
NonEmptyString position, NonEmptyString position,
NonEmptyString rank, NonEmptyString rank)
PositiveInt ageInt)
{ {
var age = Math.Min(60, Math.Max(18, ageInt.Get % 43 + 18));
var request = new CreatePersonnelRequest var request = new CreatePersonnelRequest
{ {
Name = TruncateString(name.Get, 50), Name = TruncateString(name.Get, 50),
Position = TruncateString(position.Get, 100), Position = TruncateString(position.Get, 100),
Rank = TruncateString(rank.Get, 50), Rank = TruncateString(rank.Get, 50),
Gender = "男", IdNumber = "invalid123" // 无效身份证号
IdNumber = "invalid123", // 无效身份证号
Age = age
}; };
var validationResults = ValidateModel(request); var validationResults = ValidateModel(request);
return validationResults.Any(r => r.MemberNames.Contains("IdNumber")); return validationResults.Any(r => r.MemberNames.Contains("IdNumber"));
} }
/// <summary>
/// 属性7扩展年龄超出范围的人员数据应该验证失败
/// **验证需求需求6.1**
/// </summary>
[Property(MaxTest = 100)]
public bool Personnel_WithInvalidAge_ValidationFails(
NonEmptyString name,
NonEmptyString position,
NonEmptyString rank,
PositiveInt invalidAgeInt)
{
// 生成超出范围的年龄小于18或大于60
var invalidAge = invalidAgeInt.Get % 2 == 0
? invalidAgeInt.Get % 17 + 1 // 1-17岁
: invalidAgeInt.Get % 40 + 61; // 61-100岁
var request = new CreatePersonnelRequest
{
Name = TruncateString(name.Get, 50),
Position = TruncateString(position.Get, 100),
Rank = TruncateString(rank.Get, 50),
Gender = "男",
IdNumber = GenerateValidIdNumber(),
Age = invalidAge
};
var validationResults = ValidateModel(request);
return validationResults.Any(r => r.MemberNames.Contains("Age"));
}
/// <summary> /// <summary>
/// 属性7扩展物资配额必填字段验证 /// 属性7扩展物资配额必填字段验证

View File

@ -0,0 +1,878 @@
using FsCheck;
using FsCheck.Xunit;
using Microsoft.EntityFrameworkCore;
using MilitaryTrainingManagement.Data;
using MilitaryTrainingManagement.Models.Entities;
using MilitaryTrainingManagement.Models.Enums;
using MilitaryTrainingManagement.Services.Implementations;
namespace MilitaryTrainingManagement.Tests.Properties;
/// <summary>
/// 消耗上报可见性过滤属性测试
/// **Feature: consumption-report-visibility-filter**
/// </summary>
public class VisibilityFilterPropertyTests
{
/// <summary>
/// 创建内存数据库上下文
/// </summary>
private static ApplicationDbContext CreateInMemoryContext(string dbName)
{
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
.UseInMemoryDatabase(databaseName: dbName)
.Options;
return new ApplicationDbContext(options);
}
/// <summary>
/// 创建测试用的组织层级结构(包含同级单位)
/// 结构:
/// - Division (师团)
/// - Regiment (团)
/// - Battalion1 (营1) - 测试用户所在单位
/// - Company1 (连1)
/// - Company2 (连2)
/// - Battalion2 (营2) - 同级单位
/// - Company3 (连3)
/// - Company4 (连4)
/// </summary>
private static TestHierarchy CreateHierarchyWithSiblings(ApplicationDbContext context)
{
var division = new OrganizationalUnit
{
Name = "师团",
Level = OrganizationalLevel.Division,
CreatedAt = DateTime.UtcNow
};
context.OrganizationalUnits.Add(division);
context.SaveChanges();
var regiment = new OrganizationalUnit
{
Name = "团",
Level = OrganizationalLevel.Regiment,
ParentId = division.Id,
CreatedAt = DateTime.UtcNow
};
context.OrganizationalUnits.Add(regiment);
context.SaveChanges();
// 营1 - 测试用户所在单位
var battalion1 = new OrganizationalUnit
{
Name = "营1",
Level = OrganizationalLevel.Battalion,
ParentId = regiment.Id,
CreatedAt = DateTime.UtcNow
};
context.OrganizationalUnits.Add(battalion1);
context.SaveChanges();
// 营2 - 同级单位
var battalion2 = new OrganizationalUnit
{
Name = "营2",
Level = OrganizationalLevel.Battalion,
ParentId = regiment.Id,
CreatedAt = DateTime.UtcNow
};
context.OrganizationalUnits.Add(battalion2);
context.SaveChanges();
// 连1, 连2 - 营1的下级
var company1 = new OrganizationalUnit
{
Name = "连1",
Level = OrganizationalLevel.Company,
ParentId = battalion1.Id,
CreatedAt = DateTime.UtcNow
};
context.OrganizationalUnits.Add(company1);
var company2 = new OrganizationalUnit
{
Name = "连2",
Level = OrganizationalLevel.Company,
ParentId = battalion1.Id,
CreatedAt = DateTime.UtcNow
};
context.OrganizationalUnits.Add(company2);
context.SaveChanges();
// 连3, 连4 - 营2的下级同级单位的下级
var company3 = new OrganizationalUnit
{
Name = "连3",
Level = OrganizationalLevel.Company,
ParentId = battalion2.Id,
CreatedAt = DateTime.UtcNow
};
context.OrganizationalUnits.Add(company3);
var company4 = new OrganizationalUnit
{
Name = "连4",
Level = OrganizationalLevel.Company,
ParentId = battalion2.Id,
CreatedAt = DateTime.UtcNow
};
context.OrganizationalUnits.Add(company4);
context.SaveChanges();
return new TestHierarchy
{
Division = division,
Regiment = regiment,
Battalion1 = battalion1,
Battalion2 = battalion2,
Company1 = company1,
Company2 = company2,
Company3 = company3,
Company4 = company4
};
}
private class TestHierarchy
{
public OrganizationalUnit Division { get; set; } = null!;
public OrganizationalUnit Regiment { get; set; } = null!;
public OrganizationalUnit Battalion1 { get; set; } = null!;
public OrganizationalUnit Battalion2 { get; set; } = null!;
public OrganizationalUnit Company1 { get; set; } = null!;
public OrganizationalUnit Company2 { get; set; } = null!;
public OrganizationalUnit Company3 { get; set; } = null!;
public OrganizationalUnit Company4 { get; set; } = null!;
}
/// <summary>
/// **Feature: consumption-report-visibility-filter, Property 1: Restricted visibility for battalion/company level users**
/// **Validates: Requirements 1.1, 1.2, 1.3, 3.1, 3.2**
///
/// *For any* organization hierarchy and any user at Battalion_Level or Company_Level,
/// the visible unit IDs returned by GetVisibleUnitIdsAsync SHALL:
/// - Include the user's own unit ID
/// - Include only direct subordinate unit IDs (children)
/// - Exclude all sibling unit IDs (units with the same parent)
/// - Exclude all subordinates of sibling units
/// </summary>
[Property(MaxTest = 100)]
public bool RestrictedVisibility_BattalionCompanyLevel_OnlyShowsOwnUnitAndDirectChildren()
{
var dbName = Guid.NewGuid().ToString();
using var context = CreateInMemoryContext(dbName);
var hierarchy = CreateHierarchyWithSiblings(context);
var organizationService = new OrganizationService(context);
// Test for Battalion level user (营1)
var battalionVisibleUnits = organizationService
.GetVisibleUnitIdsAsync(hierarchy.Battalion1.Id, OrganizationalLevel.Battalion)
.GetAwaiter().GetResult()
.ToList();
// 营级用户应该能看到:本单位 + 直接下级连1, 连2
var expectedBattalionVisible = new HashSet<int>
{
hierarchy.Battalion1.Id,
hierarchy.Company1.Id,
hierarchy.Company2.Id
};
// 验证包含所有预期的单位
var containsAllExpected = expectedBattalionVisible.All(id => battalionVisibleUnits.Contains(id));
// 验证不包含同级单位营2
var excludesSibling = !battalionVisibleUnits.Contains(hierarchy.Battalion2.Id);
// 验证不包含同级单位的下级连3, 连4
var excludesSiblingSubordinates = !battalionVisibleUnits.Contains(hierarchy.Company3.Id)
&& !battalionVisibleUnits.Contains(hierarchy.Company4.Id);
// 验证数量正确只有3个营1, 连1, 连2
var correctCount = battalionVisibleUnits.Count == 3;
return containsAllExpected && excludesSibling && excludesSiblingSubordinates && correctCount;
}
/// <summary>
/// **Feature: consumption-report-visibility-filter, Property 1: Restricted visibility for battalion/company level users**
/// **Validates: Requirements 1.1, 1.2, 1.3, 3.1, 3.2**
///
/// 连级用户只能看到本单位
/// </summary>
[Property(MaxTest = 100)]
public bool RestrictedVisibility_CompanyLevel_OnlyShowsOwnUnit()
{
var dbName = Guid.NewGuid().ToString();
using var context = CreateInMemoryContext(dbName);
var hierarchy = CreateHierarchyWithSiblings(context);
var organizationService = new OrganizationService(context);
// Test for Company level user (连1)
var companyVisibleUnits = organizationService
.GetVisibleUnitIdsAsync(hierarchy.Company1.Id, OrganizationalLevel.Company)
.GetAwaiter().GetResult()
.ToList();
// 连级用户应该只能看到本单位(连级没有下级)
var containsOwnUnit = companyVisibleUnits.Contains(hierarchy.Company1.Id);
// 验证不包含同级单位连2
var excludesSibling = !companyVisibleUnits.Contains(hierarchy.Company2.Id);
// 验证不包含上级单位
var excludesParent = !companyVisibleUnits.Contains(hierarchy.Battalion1.Id);
// 验证数量正确只有1个连1
var correctCount = companyVisibleUnits.Count == 1;
return containsOwnUnit && excludesSibling && excludesParent && correctCount;
}
/// <summary>
/// **Feature: consumption-report-visibility-filter, Property 1: Restricted visibility for battalion/company level users**
/// **Validates: Requirements 1.1, 1.2, 1.3, 3.1, 3.2**
///
/// *For any* user at Battalion or Company level, visible units must include own unit
/// </summary>
[Property(MaxTest = 100)]
public bool RestrictedVisibility_AlwaysIncludesOwnUnit(PositiveInt seed)
{
var dbName = $"test_{seed.Get}_{Guid.NewGuid()}";
using var context = CreateInMemoryContext(dbName);
var hierarchy = CreateHierarchyWithSiblings(context);
var organizationService = new OrganizationService(context);
// Test both Battalion and Company levels
var restrictedLevels = new[] { OrganizationalLevel.Battalion, OrganizationalLevel.Company };
var testUnits = new[] { hierarchy.Battalion1, hierarchy.Company1 };
for (int i = 0; i < restrictedLevels.Length; i++)
{
var visibleUnits = organizationService
.GetVisibleUnitIdsAsync(testUnits[i].Id, restrictedLevels[i])
.GetAwaiter().GetResult()
.ToList();
// 必须包含自己的单位ID
if (!visibleUnits.Contains(testUnits[i].Id))
return false;
}
return true;
}
/// <summary>
/// **Feature: consumption-report-visibility-filter, Property 1: Restricted visibility for battalion/company level users**
/// **Validates: Requirements 1.1, 1.2, 1.3, 3.1, 3.2**
///
/// *For any* user at Battalion or Company level, visible units must exclude sibling units
/// </summary>
[Property(MaxTest = 100)]
public bool RestrictedVisibility_ExcludesSiblingUnits(PositiveInt seed)
{
var dbName = $"test_{seed.Get}_{Guid.NewGuid()}";
using var context = CreateInMemoryContext(dbName);
var hierarchy = CreateHierarchyWithSiblings(context);
var organizationService = new OrganizationService(context);
// 营1用户不应该看到营2同级单位
var battalionVisibleUnits = organizationService
.GetVisibleUnitIdsAsync(hierarchy.Battalion1.Id, OrganizationalLevel.Battalion)
.GetAwaiter().GetResult()
.ToList();
// 不应包含同级单位
if (battalionVisibleUnits.Contains(hierarchy.Battalion2.Id))
return false;
// 不应包含同级单位的下级
if (battalionVisibleUnits.Contains(hierarchy.Company3.Id) ||
battalionVisibleUnits.Contains(hierarchy.Company4.Id))
return false;
return true;
}
}
/// <summary>
/// 师团/团级用户完整可见性属性测试
/// **Feature: consumption-report-visibility-filter, Property 2: Full visibility for division/regiment level users**
/// </summary>
public class FullVisibilityPropertyTests
{
/// <summary>
/// 创建内存数据库上下文
/// </summary>
private static ApplicationDbContext CreateInMemoryContext(string dbName)
{
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
.UseInMemoryDatabase(databaseName: dbName)
.Options;
return new ApplicationDbContext(options);
}
/// <summary>
/// 创建测试用的组织层级结构(包含同级单位)
/// </summary>
private static TestHierarchy CreateHierarchyWithSiblings(ApplicationDbContext context)
{
var division = new OrganizationalUnit
{
Name = "师团",
Level = OrganizationalLevel.Division,
CreatedAt = DateTime.UtcNow
};
context.OrganizationalUnits.Add(division);
context.SaveChanges();
var regiment = new OrganizationalUnit
{
Name = "团",
Level = OrganizationalLevel.Regiment,
ParentId = division.Id,
CreatedAt = DateTime.UtcNow
};
context.OrganizationalUnits.Add(regiment);
context.SaveChanges();
// 营1
var battalion1 = new OrganizationalUnit
{
Name = "营1",
Level = OrganizationalLevel.Battalion,
ParentId = regiment.Id,
CreatedAt = DateTime.UtcNow
};
context.OrganizationalUnits.Add(battalion1);
context.SaveChanges();
// 营2 - 同级单位
var battalion2 = new OrganizationalUnit
{
Name = "营2",
Level = OrganizationalLevel.Battalion,
ParentId = regiment.Id,
CreatedAt = DateTime.UtcNow
};
context.OrganizationalUnits.Add(battalion2);
context.SaveChanges();
// 连1, 连2 - 营1的下级
var company1 = new OrganizationalUnit
{
Name = "连1",
Level = OrganizationalLevel.Company,
ParentId = battalion1.Id,
CreatedAt = DateTime.UtcNow
};
context.OrganizationalUnits.Add(company1);
var company2 = new OrganizationalUnit
{
Name = "连2",
Level = OrganizationalLevel.Company,
ParentId = battalion1.Id,
CreatedAt = DateTime.UtcNow
};
context.OrganizationalUnits.Add(company2);
context.SaveChanges();
// 连3, 连4 - 营2的下级
var company3 = new OrganizationalUnit
{
Name = "连3",
Level = OrganizationalLevel.Company,
ParentId = battalion2.Id,
CreatedAt = DateTime.UtcNow
};
context.OrganizationalUnits.Add(company3);
var company4 = new OrganizationalUnit
{
Name = "连4",
Level = OrganizationalLevel.Company,
ParentId = battalion2.Id,
CreatedAt = DateTime.UtcNow
};
context.OrganizationalUnits.Add(company4);
context.SaveChanges();
return new TestHierarchy
{
Division = division,
Regiment = regiment,
Battalion1 = battalion1,
Battalion2 = battalion2,
Company1 = company1,
Company2 = company2,
Company3 = company3,
Company4 = company4
};
}
private class TestHierarchy
{
public OrganizationalUnit Division { get; set; } = null!;
public OrganizationalUnit Regiment { get; set; } = null!;
public OrganizationalUnit Battalion1 { get; set; } = null!;
public OrganizationalUnit Battalion2 { get; set; } = null!;
public OrganizationalUnit Company1 { get; set; } = null!;
public OrganizationalUnit Company2 { get; set; } = null!;
public OrganizationalUnit Company3 { get; set; } = null!;
public OrganizationalUnit Company4 { get; set; } = null!;
}
/// <summary>
/// **Feature: consumption-report-visibility-filter, Property 2: Full visibility for division/regiment level users**
/// **Validates: Requirements 1.4, 3.3**
///
/// *For any* organization hierarchy and any user at Division_Level or Regiment_Level,
/// the visible unit IDs returned by GetVisibleUnitIdsAsync SHALL equal the union of
/// the user's own unit ID and all subordinate unit IDs (as returned by GetAllSubordinateIdsAsync).
/// </summary>
[Property(MaxTest = 100)]
public bool FullVisibility_DivisionLevel_ShowsAllSubordinates()
{
var dbName = Guid.NewGuid().ToString();
using var context = CreateInMemoryContext(dbName);
var hierarchy = CreateHierarchyWithSiblings(context);
var organizationService = new OrganizationService(context);
// Test for Division level user
var divisionVisibleUnits = organizationService
.GetVisibleUnitIdsAsync(hierarchy.Division.Id, OrganizationalLevel.Division)
.GetAwaiter().GetResult()
.ToHashSet();
// 获取所有下级单位ID
var allSubordinates = organizationService
.GetAllSubordinateIdsAsync(hierarchy.Division.Id)
.GetAwaiter().GetResult()
.ToHashSet();
// 预期可见单位 = 本单位 + 所有下级
var expectedVisible = new HashSet<int> { hierarchy.Division.Id };
expectedVisible.UnionWith(allSubordinates);
// 验证可见单位集合与预期完全相等
return divisionVisibleUnits.SetEquals(expectedVisible);
}
/// <summary>
/// **Feature: consumption-report-visibility-filter, Property 2: Full visibility for division/regiment level users**
/// **Validates: Requirements 1.4, 3.3**
///
/// 团级用户应该能看到所有下级单位
/// </summary>
[Property(MaxTest = 100)]
public bool FullVisibility_RegimentLevel_ShowsAllSubordinates()
{
var dbName = Guid.NewGuid().ToString();
using var context = CreateInMemoryContext(dbName);
var hierarchy = CreateHierarchyWithSiblings(context);
var organizationService = new OrganizationService(context);
// Test for Regiment level user
var regimentVisibleUnits = organizationService
.GetVisibleUnitIdsAsync(hierarchy.Regiment.Id, OrganizationalLevel.Regiment)
.GetAwaiter().GetResult()
.ToHashSet();
// 获取所有下级单位ID
var allSubordinates = organizationService
.GetAllSubordinateIdsAsync(hierarchy.Regiment.Id)
.GetAwaiter().GetResult()
.ToHashSet();
// 预期可见单位 = 本单位 + 所有下级
var expectedVisible = new HashSet<int> { hierarchy.Regiment.Id };
expectedVisible.UnionWith(allSubordinates);
// 验证可见单位集合与预期完全相等
return regimentVisibleUnits.SetEquals(expectedVisible);
}
/// <summary>
/// **Feature: consumption-report-visibility-filter, Property 2: Full visibility for division/regiment level users**
/// **Validates: Requirements 1.4, 3.3**
///
/// 师团级用户应该能看到所有营和连级单位(包括同级单位的下级)
/// </summary>
[Property(MaxTest = 100)]
public bool FullVisibility_DivisionLevel_IncludesAllBattalionsAndCompanies()
{
var dbName = Guid.NewGuid().ToString();
using var context = CreateInMemoryContext(dbName);
var hierarchy = CreateHierarchyWithSiblings(context);
var organizationService = new OrganizationService(context);
// Test for Division level user
var divisionVisibleUnits = organizationService
.GetVisibleUnitIdsAsync(hierarchy.Division.Id, OrganizationalLevel.Division)
.GetAwaiter().GetResult()
.ToList();
// 师团级用户应该能看到所有单位
var allUnits = new[]
{
hierarchy.Division.Id,
hierarchy.Regiment.Id,
hierarchy.Battalion1.Id,
hierarchy.Battalion2.Id,
hierarchy.Company1.Id,
hierarchy.Company2.Id,
hierarchy.Company3.Id,
hierarchy.Company4.Id
};
// 验证包含所有单位
return allUnits.All(id => divisionVisibleUnits.Contains(id))
&& divisionVisibleUnits.Count == allUnits.Length;
}
/// <summary>
/// **Feature: consumption-report-visibility-filter, Property 2: Full visibility for division/regiment level users**
/// **Validates: Requirements 1.4, 3.3**
///
/// 团级用户应该能看到所有营和连级单位
/// </summary>
[Property(MaxTest = 100)]
public bool FullVisibility_RegimentLevel_IncludesAllBattalionsAndCompanies()
{
var dbName = Guid.NewGuid().ToString();
using var context = CreateInMemoryContext(dbName);
var hierarchy = CreateHierarchyWithSiblings(context);
var organizationService = new OrganizationService(context);
// Test for Regiment level user
var regimentVisibleUnits = organizationService
.GetVisibleUnitIdsAsync(hierarchy.Regiment.Id, OrganizationalLevel.Regiment)
.GetAwaiter().GetResult()
.ToList();
// 团级用户应该能看到:团 + 营1 + 营2 + 连1 + 连2 + 连3 + 连4
var expectedUnits = new[]
{
hierarchy.Regiment.Id,
hierarchy.Battalion1.Id,
hierarchy.Battalion2.Id,
hierarchy.Company1.Id,
hierarchy.Company2.Id,
hierarchy.Company3.Id,
hierarchy.Company4.Id
};
// 验证包含所有预期单位
return expectedUnits.All(id => regimentVisibleUnits.Contains(id))
&& regimentVisibleUnits.Count == expectedUnits.Length;
}
}
/// <summary>
/// 访问控制属性测试
/// **Feature: consumption-report-visibility-filter, Property 3: Access control respects visibility scope**
/// </summary>
public class AccessControlPropertyTests
{
/// <summary>
/// 创建内存数据库上下文
/// </summary>
private static ApplicationDbContext CreateInMemoryContext(string dbName)
{
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
.UseInMemoryDatabase(databaseName: dbName)
.Options;
return new ApplicationDbContext(options);
}
/// <summary>
/// 创建测试用的组织层级结构(包含同级单位)
/// </summary>
private static TestHierarchy CreateHierarchyWithSiblings(ApplicationDbContext context)
{
var division = new OrganizationalUnit
{
Name = "师团",
Level = OrganizationalLevel.Division,
CreatedAt = DateTime.UtcNow
};
context.OrganizationalUnits.Add(division);
context.SaveChanges();
var regiment = new OrganizationalUnit
{
Name = "团",
Level = OrganizationalLevel.Regiment,
ParentId = division.Id,
CreatedAt = DateTime.UtcNow
};
context.OrganizationalUnits.Add(regiment);
context.SaveChanges();
// 营1
var battalion1 = new OrganizationalUnit
{
Name = "营1",
Level = OrganizationalLevel.Battalion,
ParentId = regiment.Id,
CreatedAt = DateTime.UtcNow
};
context.OrganizationalUnits.Add(battalion1);
context.SaveChanges();
// 营2 - 同级单位
var battalion2 = new OrganizationalUnit
{
Name = "营2",
Level = OrganizationalLevel.Battalion,
ParentId = regiment.Id,
CreatedAt = DateTime.UtcNow
};
context.OrganizationalUnits.Add(battalion2);
context.SaveChanges();
// 连1, 连2 - 营1的下级
var company1 = new OrganizationalUnit
{
Name = "连1",
Level = OrganizationalLevel.Company,
ParentId = battalion1.Id,
CreatedAt = DateTime.UtcNow
};
context.OrganizationalUnits.Add(company1);
var company2 = new OrganizationalUnit
{
Name = "连2",
Level = OrganizationalLevel.Company,
ParentId = battalion1.Id,
CreatedAt = DateTime.UtcNow
};
context.OrganizationalUnits.Add(company2);
context.SaveChanges();
// 连3, 连4 - 营2的下级
var company3 = new OrganizationalUnit
{
Name = "连3",
Level = OrganizationalLevel.Company,
ParentId = battalion2.Id,
CreatedAt = DateTime.UtcNow
};
context.OrganizationalUnits.Add(company3);
var company4 = new OrganizationalUnit
{
Name = "连4",
Level = OrganizationalLevel.Company,
ParentId = battalion2.Id,
CreatedAt = DateTime.UtcNow
};
context.OrganizationalUnits.Add(company4);
context.SaveChanges();
return new TestHierarchy
{
Division = division,
Regiment = regiment,
Battalion1 = battalion1,
Battalion2 = battalion2,
Company1 = company1,
Company2 = company2,
Company3 = company3,
Company4 = company4
};
}
private class TestHierarchy
{
public OrganizationalUnit Division { get; set; } = null!;
public OrganizationalUnit Regiment { get; set; } = null!;
public OrganizationalUnit Battalion1 { get; set; } = null!;
public OrganizationalUnit Battalion2 { get; set; } = null!;
public OrganizationalUnit Company1 { get; set; } = null!;
public OrganizationalUnit Company2 { get; set; } = null!;
public OrganizationalUnit Company3 { get; set; } = null!;
public OrganizationalUnit Company4 { get; set; } = null!;
}
/// <summary>
/// **Feature: consumption-report-visibility-filter, Property 3: Access control respects visibility scope**
/// **Validates: Requirements 2.3**
///
/// *For any* user and any target unit ID, the CanViewUnitDataAsync method SHALL return true
/// if and only if the target unit ID is contained in the user's visible unit IDs set.
/// </summary>
[Property(MaxTest = 100)]
public bool AccessControl_ReturnsTrue_WhenTargetInVisibleUnits()
{
var dbName = Guid.NewGuid().ToString();
using var context = CreateInMemoryContext(dbName);
var hierarchy = CreateHierarchyWithSiblings(context);
var organizationService = new OrganizationService(context);
var reportingService = new ReportingService(context, organizationService);
// 测试营级用户访问可见单位
var battalionVisibleUnits = organizationService
.GetVisibleUnitIdsAsync(hierarchy.Battalion1.Id, OrganizationalLevel.Battalion)
.GetAwaiter().GetResult()
.ToList();
// 对于每个可见单位CanViewUnitDataAsync 应该返回 true
foreach (var visibleUnitId in battalionVisibleUnits)
{
var canView = reportingService
.CanViewUnitDataAsync(hierarchy.Battalion1.Id, visibleUnitId, OrganizationalLevel.Battalion)
.GetAwaiter().GetResult();
if (!canView)
return false;
}
return true;
}
/// <summary>
/// **Feature: consumption-report-visibility-filter, Property 3: Access control respects visibility scope**
/// **Validates: Requirements 2.3**
///
/// *For any* user and any target unit ID not in visible units, CanViewUnitDataAsync SHALL return false.
/// </summary>
[Property(MaxTest = 100)]
public bool AccessControl_ReturnsFalse_WhenTargetNotInVisibleUnits()
{
var dbName = Guid.NewGuid().ToString();
using var context = CreateInMemoryContext(dbName);
var hierarchy = CreateHierarchyWithSiblings(context);
var organizationService = new OrganizationService(context);
var reportingService = new ReportingService(context, organizationService);
// 测试营级用户访问不可见单位(同级单位及其下级)
var invisibleUnits = new[]
{
hierarchy.Battalion2.Id, // 同级单位
hierarchy.Company3.Id, // 同级单位的下级
hierarchy.Company4.Id, // 同级单位的下级
hierarchy.Regiment.Id, // 上级单位
hierarchy.Division.Id // 更上级单位
};
foreach (var invisibleUnitId in invisibleUnits)
{
var canView = reportingService
.CanViewUnitDataAsync(hierarchy.Battalion1.Id, invisibleUnitId, OrganizationalLevel.Battalion)
.GetAwaiter().GetResult();
if (canView)
return false;
}
return true;
}
/// <summary>
/// **Feature: consumption-report-visibility-filter, Property 3: Access control respects visibility scope**
/// **Validates: Requirements 2.3**
///
/// *For any* user at Division/Regiment level, CanViewUnitDataAsync SHALL return true for all subordinates.
/// </summary>
[Property(MaxTest = 100)]
public bool AccessControl_DivisionLevel_CanViewAllSubordinates()
{
var dbName = Guid.NewGuid().ToString();
using var context = CreateInMemoryContext(dbName);
var hierarchy = CreateHierarchyWithSiblings(context);
var organizationService = new OrganizationService(context);
var reportingService = new ReportingService(context, organizationService);
// 师团级用户应该能访问所有下级单位
var allUnits = new[]
{
hierarchy.Division.Id,
hierarchy.Regiment.Id,
hierarchy.Battalion1.Id,
hierarchy.Battalion2.Id,
hierarchy.Company1.Id,
hierarchy.Company2.Id,
hierarchy.Company3.Id,
hierarchy.Company4.Id
};
foreach (var unitId in allUnits)
{
var canView = reportingService
.CanViewUnitDataAsync(hierarchy.Division.Id, unitId, OrganizationalLevel.Division)
.GetAwaiter().GetResult();
if (!canView)
return false;
}
return true;
}
/// <summary>
/// **Feature: consumption-report-visibility-filter, Property 3: Access control respects visibility scope**
/// **Validates: Requirements 2.3**
///
/// CanViewUnitDataAsync result is consistent with GetVisibleUnitIdsAsync for all user levels.
/// </summary>
[Property(MaxTest = 100)]
public bool AccessControl_ConsistentWithVisibleUnits_AllLevels()
{
var dbName = Guid.NewGuid().ToString();
using var context = CreateInMemoryContext(dbName);
var hierarchy = CreateHierarchyWithSiblings(context);
var organizationService = new OrganizationService(context);
var reportingService = new ReportingService(context, organizationService);
// 测试所有层级
var testCases = new[]
{
(hierarchy.Division.Id, OrganizationalLevel.Division),
(hierarchy.Regiment.Id, OrganizationalLevel.Regiment),
(hierarchy.Battalion1.Id, OrganizationalLevel.Battalion),
(hierarchy.Company1.Id, OrganizationalLevel.Company)
};
var allUnits = new[]
{
hierarchy.Division.Id,
hierarchy.Regiment.Id,
hierarchy.Battalion1.Id,
hierarchy.Battalion2.Id,
hierarchy.Company1.Id,
hierarchy.Company2.Id,
hierarchy.Company3.Id,
hierarchy.Company4.Id
};
foreach (var (userUnitId, userLevel) in testCases)
{
var visibleUnits = organizationService
.GetVisibleUnitIdsAsync(userUnitId, userLevel)
.GetAwaiter().GetResult()
.ToHashSet();
foreach (var targetUnitId in allUnits)
{
var canView = reportingService
.CanViewUnitDataAsync(userUnitId, targetUnitId, userLevel)
.GetAwaiter().GetResult();
var shouldBeVisible = visibleUnits.Contains(targetUnitId);
// CanViewUnitDataAsync 的结果应该与 GetVisibleUnitIdsAsync 一致
if (canView != shouldBeVisible)
return false;
}
}
return true;
}
}

View File

@ -275,8 +275,8 @@ public class ReportingServiceTests
CreateDistribution(context, hierarchy[OrganizationalLevel.Division], CreateDistribution(context, hierarchy[OrganizationalLevel.Division],
hierarchy[OrganizationalLevel.Battalion], 50m); hierarchy[OrganizationalLevel.Battalion], 50m);
// Act // Act - 使用师团级别,应该能看到所有下级
var result = await service.GetAggregatedDataAsync(hierarchy[OrganizationalLevel.Division].Id); var result = await service.GetAggregatedDataAsync(hierarchy[OrganizationalLevel.Division].Id, OrganizationalLevel.Division);
// Assert // Assert
Assert.Equal(2, result.Count()); Assert.Equal(2, result.Count());
@ -352,7 +352,7 @@ public class ReportingServiceTests
var unitId = hierarchy[OrganizationalLevel.Regiment].Id; var unitId = hierarchy[OrganizationalLevel.Regiment].Id;
// Act // Act
var canView = await service.CanViewUnitDataAsync(unitId, unitId); var canView = await service.CanViewUnitDataAsync(unitId, unitId, OrganizationalLevel.Regiment);
// Assert // Assert
Assert.True(canView); Assert.True(canView);
@ -374,8 +374,8 @@ public class ReportingServiceTests
var divisionId = hierarchy[OrganizationalLevel.Division].Id; var divisionId = hierarchy[OrganizationalLevel.Division].Id;
var regimentId = hierarchy[OrganizationalLevel.Regiment].Id; var regimentId = hierarchy[OrganizationalLevel.Regiment].Id;
// Act // Act - 师团级别可以查看所有下级
var canView = await service.CanViewUnitDataAsync(divisionId, regimentId); var canView = await service.CanViewUnitDataAsync(divisionId, regimentId, OrganizationalLevel.Division);
// Assert // Assert
Assert.True(canView); Assert.True(canView);
@ -397,8 +397,8 @@ public class ReportingServiceTests
var divisionId = hierarchy[OrganizationalLevel.Division].Id; var divisionId = hierarchy[OrganizationalLevel.Division].Id;
var regimentId = hierarchy[OrganizationalLevel.Regiment].Id; var regimentId = hierarchy[OrganizationalLevel.Regiment].Id;
// Act // Act - 团级不能查看师团级
var canView = await service.CanViewUnitDataAsync(regimentId, divisionId); var canView = await service.CanViewUnitDataAsync(regimentId, divisionId, OrganizationalLevel.Regiment);
// Assert // Assert
Assert.False(canView); Assert.False(canView);
@ -420,8 +420,8 @@ public class ReportingServiceTests
var divisionId = hierarchy[OrganizationalLevel.Division].Id; var divisionId = hierarchy[OrganizationalLevel.Division].Id;
var companyId = hierarchy[OrganizationalLevel.Company].Id; var companyId = hierarchy[OrganizationalLevel.Company].Id;
// Act // Act - 师团级别可以查看所有下级(包括连级)
var canView = await service.CanViewUnitDataAsync(divisionId, companyId); var canView = await service.CanViewUnitDataAsync(divisionId, companyId, OrganizationalLevel.Division);
// Assert // Assert
Assert.True(canView); Assert.True(canView);
@ -448,8 +448,8 @@ public class ReportingServiceTests
hierarchy[OrganizationalLevel.Regiment], 100m); hierarchy[OrganizationalLevel.Regiment], 100m);
await service.SubmitReportAsync(distribution.Id, 80m, user.Id); await service.SubmitReportAsync(distribution.Id, 80m, user.Id);
// Act // Act - 使用师团级别,应该能看到所有下级
var reports = await service.GetDetailedReportsAsync(hierarchy[OrganizationalLevel.Division].Id); var reports = await service.GetDetailedReportsAsync(hierarchy[OrganizationalLevel.Division].Id, OrganizationalLevel.Division);
// Assert // Assert
var report = reports.First(); var report = reports.First();
@ -475,8 +475,8 @@ public class ReportingServiceTests
hierarchy[OrganizationalLevel.Regiment], 100m); hierarchy[OrganizationalLevel.Regiment], 100m);
await service.SubmitReportAsync(distribution.Id, 80m, user.Id); await service.SubmitReportAsync(distribution.Id, 80m, user.Id);
// Act // Act - 使用师团级别,应该能看到所有下级
var reports = await service.GetDetailedReportsAsync(hierarchy[OrganizationalLevel.Division].Id); var reports = await service.GetDetailedReportsAsync(hierarchy[OrganizationalLevel.Division].Id, OrganizationalLevel.Division);
// Assert // Assert
var report = reports.First(); var report = reports.First();

View File

@ -98,29 +98,78 @@ public class AllocationsController : BaseApiController
/// <summary> /// <summary>
/// 根据ID获取物资配额 /// 根据ID获取物资配额
/// 需求1.1, 1.2, 1.3:营部及以下级别用户只能查看本单位及直接下级的配额
/// </summary> /// </summary>
[HttpGet("{id}")] [HttpGet("{id}")]
public async Task<IActionResult> GetById(int id) public async Task<IActionResult> GetById(int id)
{ {
var unitId = GetCurrentUnitId(); var unitId = GetCurrentUnitId();
if (unitId == null) var unitLevel = GetCurrentUnitLevel();
if (unitId == null || unitLevel == null)
return Unauthorized(new { message = "无法获取用户组织信息" }); return Unauthorized(new { message = "无法获取用户组织信息" });
var allocation = await _allocationService.GetByIdAsync(id); var allocation = await _allocationService.GetByIdAsync(id);
if (allocation == null) if (allocation == null)
return NotFound(new { message = "配额不存在" }); return NotFound(new { message = "配额不存在" });
// 检查访问权限:可以查看自己创建的、分配给自己及下级的、或分配给上级单位的配额 // 检查访问权限:
var canAccess = allocation.CreatedByUnitId == unitId.Value || // 1. 可以查看自己创建的配额
allocation.Distributions.Any(d => // 2. 使用可见性过滤检查是否可以访问配额中的任何一个分配记录
_authorizationService.CanAccessUnitAsync(unitId.Value, d.TargetUnitId).GetAwaiter().GetResult()) || var canAccess = allocation.CreatedByUnitId == unitId.Value;
allocation.Distributions.Any(d =>
_authorizationService.IsAncestorUnitAsync(d.TargetUnitId, unitId.Value).GetAwaiter().GetResult()); if (!canAccess)
{
// 检查是否有任何分配记录在用户的可见范围内
foreach (var dist in allocation.Distributions)
{
if (await _allocationService.CanViewDistributionAsync(unitId.Value, unitLevel.Value, dist.TargetUnitId))
{
canAccess = true;
break;
}
}
}
if (!canAccess) if (!canAccess)
return Forbid(); return Forbid();
return Ok(MapToResponse(allocation)); // 过滤分配记录,只返回用户可见范围内的记录
var filteredAllocation = await FilterAllocationDistributions(allocation, unitId.Value, unitLevel.Value);
return Ok(MapToResponse(filteredAllocation));
}
/// <summary>
/// 过滤配额的分配记录,只保留用户可见范围内的记录
/// </summary>
private async Task<Models.Entities.MaterialAllocation> FilterAllocationDistributions(
Models.Entities.MaterialAllocation allocation,
int userUnitId,
OrganizationalLevel userLevel)
{
var visibleDistributions = new List<Models.Entities.AllocationDistribution>();
foreach (var dist in allocation.Distributions)
{
if (await _allocationService.CanViewDistributionAsync(userUnitId, userLevel, dist.TargetUnitId))
{
visibleDistributions.Add(dist);
}
}
// 创建一个新的配额对象,只包含可见的分配记录
return new Models.Entities.MaterialAllocation
{
Id = allocation.Id,
Category = allocation.Category,
MaterialName = allocation.MaterialName,
Unit = allocation.Unit,
TotalQuota = allocation.TotalQuota,
CreatedByUnitId = allocation.CreatedByUnitId,
CreatedByUnit = allocation.CreatedByUnit,
CreatedAt = allocation.CreatedAt,
Distributions = visibleDistributions
};
} }
/// <summary> /// <summary>
@ -322,6 +371,7 @@ public class AllocationsController : BaseApiController
/// <summary> /// <summary>
/// 获取配额分配的上报历史记录 /// 获取配额分配的上报历史记录
/// 需求1.1, 1.2, 1.3:营部及以下级别用户只能查看本单位及直接下级的记录
/// </summary> /// </summary>
[HttpGet("distributions/{distributionId}/reports")] [HttpGet("distributions/{distributionId}/reports")]
public async Task<IActionResult> GetConsumptionReports(int distributionId) public async Task<IActionResult> GetConsumptionReports(int distributionId)
@ -329,7 +379,7 @@ public class AllocationsController : BaseApiController
var unitId = GetCurrentUnitId(); var unitId = GetCurrentUnitId();
var unitLevel = GetCurrentUnitLevel(); var unitLevel = GetCurrentUnitLevel();
if (unitId == null) if (unitId == null || unitLevel == null)
return Unauthorized(new { message = "无法获取用户组织信息" }); return Unauthorized(new { message = "无法获取用户组织信息" });
// 检查分配记录是否存在 // 检查分配记录是否存在
@ -337,20 +387,20 @@ public class AllocationsController : BaseApiController
if (distribution == null) if (distribution == null)
return NotFound(new { message = "配额分配记录不存在" }); return NotFound(new { message = "配额分配记录不存在" });
// 检查访问权限: // 使用可见性过滤检查访问权限
// 1. 师团级可以查看所有记录 // 营部及以下级别:只能查看本单位及直接下级的记录
// 2. 可以查看分配给自己单位的记录 // 师团/团级:可以查看所有下级的记录
// 3. 可以查看分配给上级单位的记录 var canAccess = await _allocationService.CanViewDistributionAsync(
// 4. 可以查看分配给下级单位的记录 unitId.Value,
var canAccess = unitLevel == OrganizationalLevel.Division || unitLevel.Value,
distribution.TargetUnitId == unitId.Value || distribution.TargetUnitId);
await _authorizationService.IsAncestorUnitAsync(distribution.TargetUnitId, unitId.Value) ||
await _authorizationService.CanAccessUnitAsync(unitId.Value, distribution.TargetUnitId);
if (!canAccess) if (!canAccess)
return Forbid(); return Forbid();
var reports = await _allocationService.GetConsumptionReportsAsync(distributionId); // 获取上报历史记录(带可见性过滤)
// 只返回用户可见范围内的单位的上报记录
var reports = await _allocationService.GetConsumptionReportsAsync(distributionId, unitId.Value, unitLevel.Value);
var response = reports.Select(r => new var response = reports.Select(r => new
{ {
id = r.Id, id = r.Id,

View File

@ -54,13 +54,14 @@ public class ReportsController : BaseApiController
public async Task<IActionResult> GetByTargetUnit(int targetUnitId) public async Task<IActionResult> GetByTargetUnit(int targetUnitId)
{ {
var unitId = GetCurrentUnitId(); var unitId = GetCurrentUnitId();
if (unitId == null) var unitLevel = GetCurrentUnitLevel();
if (unitId == null || unitLevel == null)
{ {
return Unauthorized(new { message = "无法获取用户组织信息" }); return Unauthorized(new { message = "无法获取用户组织信息" });
} }
// 检查访问权限 // 检查访问权限
var canAccess = await _reportingService.CanViewUnitDataAsync(unitId.Value, targetUnitId); var canAccess = await _reportingService.CanViewUnitDataAsync(unitId.Value, targetUnitId, unitLevel.Value);
if (!canAccess) if (!canAccess)
{ {
return Forbid(); return Forbid();
@ -79,7 +80,8 @@ public class ReportsController : BaseApiController
public async Task<IActionResult> GetById(int id) public async Task<IActionResult> GetById(int id)
{ {
var unitId = GetCurrentUnitId(); var unitId = GetCurrentUnitId();
if (unitId == null) var unitLevel = GetCurrentUnitLevel();
if (unitId == null || unitLevel == null)
{ {
return Unauthorized(new { message = "无法获取用户组织信息" }); return Unauthorized(new { message = "无法获取用户组织信息" });
} }
@ -91,7 +93,7 @@ public class ReportsController : BaseApiController
} }
// 检查访问权限 // 检查访问权限
var canAccess = await _reportingService.CanViewUnitDataAsync(unitId.Value, distribution.TargetUnitId); var canAccess = await _reportingService.CanViewUnitDataAsync(unitId.Value, distribution.TargetUnitId, unitLevel.Value);
if (!canAccess) if (!canAccess)
{ {
return Forbid(); return Forbid();
@ -139,7 +141,7 @@ public class ReportsController : BaseApiController
} }
// 检查是否有权限为该单位提交上报 // 检查是否有权限为该单位提交上报
var canReport = await _reportingService.CanViewUnitDataAsync(unitId.Value, distribution.TargetUnitId); var canReport = await _reportingService.CanViewUnitDataAsync(unitId.Value, distribution.TargetUnitId, unitLevel.Value);
if (!canReport) if (!canReport)
{ {
return Forbid(); return Forbid();
@ -193,7 +195,7 @@ public class ReportsController : BaseApiController
} }
// 检查是否有权限修改 // 检查是否有权限修改
var canModify = await _reportingService.CanViewUnitDataAsync(unitId.Value, distribution.TargetUnitId); var canModify = await _reportingService.CanViewUnitDataAsync(unitId.Value, distribution.TargetUnitId, unitLevel.Value);
if (!canModify) if (!canModify)
{ {
return Forbid(); return Forbid();
@ -212,19 +214,21 @@ public class ReportsController : BaseApiController
/// 获取汇总数据 /// 获取汇总数据
/// 需求4.1:师团用户查看所有下级单位的总配额、实际完成和整体完成率 /// 需求4.1:师团用户查看所有下级单位的总配额、实际完成和整体完成率
/// 需求4.2:团级用户查看直接下级单位的详细数据 /// 需求4.2:团级用户查看直接下级单位的详细数据
/// 需求2.2:对营部及以下级别用户应用可见性过滤
/// </summary> /// </summary>
[HttpGet("aggregated")] [HttpGet("aggregated")]
public async Task<IActionResult> GetAggregatedData() public async Task<IActionResult> GetAggregatedData()
{ {
var unitId = GetCurrentUnitId(); var unitId = GetCurrentUnitId();
if (unitId == null) var unitLevel = GetCurrentUnitLevel();
if (unitId == null || unitLevel == null)
{ {
return Unauthorized(new { message = "无法获取用户组织信息" }); return Unauthorized(new { message = "无法获取用户组织信息" });
} }
try try
{ {
var data = await _dataAggregatorService.CalculateHierarchicalAggregationAsync(unitId.Value); var data = await _reportingService.GetHierarchicalAggregatedDataAsync(unitId.Value, unitLevel.Value);
return Ok(data); return Ok(data);
} }
catch (ArgumentException ex) catch (ArgumentException ex)
@ -241,13 +245,14 @@ public class ReportsController : BaseApiController
public async Task<IActionResult> GetAggregatedDataByUnit(int targetUnitId) public async Task<IActionResult> GetAggregatedDataByUnit(int targetUnitId)
{ {
var unitId = GetCurrentUnitId(); var unitId = GetCurrentUnitId();
if (unitId == null) var unitLevel = GetCurrentUnitLevel();
if (unitId == null || unitLevel == null)
{ {
return Unauthorized(new { message = "无法获取用户组织信息" }); return Unauthorized(new { message = "无法获取用户组织信息" });
} }
// 检查访问权限 // 检查访问权限
var canAccess = await _reportingService.CanViewUnitDataAsync(unitId.Value, targetUnitId); var canAccess = await _reportingService.CanViewUnitDataAsync(unitId.Value, targetUnitId, unitLevel.Value);
if (!canAccess) if (!canAccess)
{ {
return Forbid(); return Forbid();
@ -305,12 +310,13 @@ public class ReportsController : BaseApiController
public async Task<IActionResult> GetDetailedReports() public async Task<IActionResult> GetDetailedReports()
{ {
var unitId = GetCurrentUnitId(); var unitId = GetCurrentUnitId();
if (unitId == null) var unitLevel = GetCurrentUnitLevel();
if (unitId == null || unitLevel == null)
{ {
return Unauthorized(new { message = "无法获取用户组织信息" }); return Unauthorized(new { message = "无法获取用户组织信息" });
} }
var reports = await _reportingService.GetDetailedReportsAsync(unitId.Value); var reports = await _reportingService.GetDetailedReportsAsync(unitId.Value, unitLevel.Value);
return Ok(reports); return Ok(reports);
} }
@ -322,7 +328,8 @@ public class ReportsController : BaseApiController
public async Task<IActionResult> GetCompletionRate(int id) public async Task<IActionResult> GetCompletionRate(int id)
{ {
var unitId = GetCurrentUnitId(); var unitId = GetCurrentUnitId();
if (unitId == null) var unitLevel = GetCurrentUnitLevel();
if (unitId == null || unitLevel == null)
{ {
return Unauthorized(new { message = "无法获取用户组织信息" }); return Unauthorized(new { message = "无法获取用户组织信息" });
} }
@ -334,7 +341,7 @@ public class ReportsController : BaseApiController
} }
// 检查访问权限 // 检查访问权限
var canAccess = await _reportingService.CanViewUnitDataAsync(unitId.Value, distribution.TargetUnitId); var canAccess = await _reportingService.CanViewUnitDataAsync(unitId.Value, distribution.TargetUnitId, unitLevel.Value);
if (!canAccess) if (!canAccess)
{ {
return Forbid(); return Forbid();

View File

@ -368,4 +368,52 @@ public class AllocationService : IAllocationService
.OrderByDescending(r => r.ReportedAt) .OrderByDescending(r => r.ReportedAt)
.ToListAsync(); .ToListAsync();
} }
/// <summary>
/// 获取配额分配的上报历史记录(带可见性过滤)
/// 只返回用户可见范围内的单位的上报记录
/// </summary>
public async Task<IEnumerable<ConsumptionReport>> GetConsumptionReportsAsync(int distributionId, int userUnitId, Models.Enums.OrganizationalLevel userLevel)
{
// 获取用户可见的单位ID列表
var visibleUnitIds = (await _organizationService.GetVisibleUnitIdsAsync(userUnitId, userLevel)).ToHashSet();
// 获取所有上报记录
var allReports = await _context.ConsumptionReports
.Include(r => r.ReportedByUser)
.ThenInclude(u => u.OrganizationalUnit)
.Where(r => r.AllocationDistributionId == distributionId)
.OrderByDescending(r => r.ReportedAt)
.ToListAsync();
// 过滤:只返回上报人所属单位在用户可见范围内的记录
return allReports.Where(r => visibleUnitIds.Contains(r.ReportedByUser.OrganizationalUnitId)).ToList();
}
/// <summary>
/// 检查用户是否有权限查看指定单位的配额分配记录
/// 使用可见性过滤:营部及以下级别只能查看本单位及直接下级
/// 但用户始终可以查看分配给自己单位或上级单位的记录
/// </summary>
public async Task<bool> CanViewDistributionAsync(int userUnitId, Models.Enums.OrganizationalLevel userLevel, int targetUnitId)
{
// 用户始终可以查看分配给自己单位的记录
if (targetUnitId == userUnitId)
{
return true;
}
// 用户可以查看分配给上级单位的记录(因为下级可以查看上级的配额并上报消耗)
var ancestorIds = await _organizationService.GetAllAncestorIdsAsync(userUnitId);
if (ancestorIds.Contains(targetUnitId))
{
return true;
}
// 获取用户可见的单位ID列表本单位 + 直接下级)
var visibleUnitIds = await _organizationService.GetVisibleUnitIdsAsync(userUnitId, userLevel);
// 检查目标单位是否在可见范围内
return visibleUnitIds.Contains(targetUnitId);
}
} }

View File

@ -207,4 +207,40 @@ public class OrganizationService : IOrganizationService
return result; return result;
} }
/// <summary>
/// 获取用户可见的单位ID列表
/// 营部及以下级别:本单位 + 直接下级
/// 师团级别:本单位 + 所有下级
/// </summary>
public async Task<IEnumerable<int>> GetVisibleUnitIdsAsync(int unitId, OrganizationalLevel userLevel)
{
var result = new List<int> { unitId };
// 检查单位是否存在
var unit = await _context.OrganizationalUnits.FindAsync(unitId);
if (unit == null)
{
return Enumerable.Empty<int>();
}
// 营部及以下级别Battalion = 3, Company = 4只返回本单位 + 直接下级
if (userLevel == OrganizationalLevel.Battalion || userLevel == OrganizationalLevel.Company)
{
var directChildren = await _context.OrganizationalUnits
.Where(u => u.ParentId == unitId)
.Select(u => u.Id)
.ToListAsync();
result.AddRange(directChildren);
}
// 师团级别Division = 1, Regiment = 2返回本单位 + 所有下级
else
{
var allSubordinates = await GetAllSubordinateIdsAsync(unitId);
result.AddRange(allSubordinates);
}
return result;
}
} }

View File

@ -65,11 +65,14 @@ public class ReportingService : IReportingService
throw new ArgumentException("分配记录不存在"); throw new ArgumentException("分配记录不存在");
// 验证上报用户是否属于目标单位或其上级 // 验证上报用户是否属于目标单位或其上级
var user = await _context.UserAccounts.FindAsync(reportedByUserId); var user = await _context.UserAccounts
.Include(u => u.OrganizationalUnit)
.FirstOrDefaultAsync(u => u.Id == reportedByUserId);
if (user == null) if (user == null)
throw new ArgumentException("用户不存在"); throw new ArgumentException("用户不存在");
var canReport = await CanViewUnitDataAsync(user.OrganizationalUnitId, distribution.TargetUnitId); var userLevel = user.OrganizationalUnit?.Level ?? OrganizationalLevel.Company;
var canReport = await CanViewUnitDataAsync(user.OrganizationalUnitId, distribution.TargetUnitId, userLevel);
if (!canReport) if (!canReport)
throw new UnauthorizedAccessException("无权限为该单位提交上报"); throw new UnauthorizedAccessException("无权限为该单位提交上报");
@ -107,14 +110,14 @@ public class ReportingService : IReportingService
} }
/// <summary> /// <summary>
/// 获取汇总数据(包含下级单位 /// 获取汇总数据(包含下级单位,应用可见性过滤
/// 需求3.4, 4.1:显示下级单位的汇总信息 /// 需求3.4, 4.1:显示下级单位的汇总信息
/// 需求2.2:对营部及以下级别用户应用可见性过滤
/// </summary> /// </summary>
public async Task<IEnumerable<AllocationDistribution>> GetAggregatedDataAsync(int unitId) public async Task<IEnumerable<AllocationDistribution>> GetAggregatedDataAsync(int unitId, OrganizationalLevel userLevel)
{ {
var subordinateIds = await _organizationService.GetAllSubordinateIdsAsync(unitId); var visibleUnitIds = await _organizationService.GetVisibleUnitIdsAsync(unitId, userLevel);
var allUnitIds = subordinateIds.ToList(); var allUnitIds = visibleUnitIds.ToList();
allUnitIds.Add(unitId);
return await _context.AllocationDistributions return await _context.AllocationDistributions
.Include(d => d.Allocation) .Include(d => d.Allocation)
@ -134,7 +137,22 @@ public class ReportingService : IReportingService
if (unit == null) if (unit == null)
throw new ArgumentException("组织单位不存在"); throw new ArgumentException("组织单位不存在");
var distributions = await GetAggregatedDataAsync(unitId); // 使用单位自身的层级来确定可见性
return await GetHierarchicalAggregatedDataAsync(unitId, unit.Level);
}
/// <summary>
/// 获取层级汇总数据(应用可见性过滤)
/// 需求4.1, 4.2:显示总配额、实际完成和整体完成率
/// 需求2.2:对营部及以下级别用户应用可见性过滤
/// </summary>
public async Task<HierarchicalAggregatedResponse> GetHierarchicalAggregatedDataAsync(int unitId, OrganizationalLevel userLevel)
{
var unit = await _organizationService.GetByIdAsync(unitId);
if (unit == null)
throw new ArgumentException("组织单位不存在");
var distributions = await GetAggregatedDataAsync(unitId, userLevel);
var distributionList = distributions.ToList(); var distributionList = distributions.ToList();
// 按配额分组汇总 // 按配额分组汇总
@ -179,7 +197,11 @@ public class ReportingService : IReportingService
/// </summary> /// </summary>
public async Task<IEnumerable<AggregatedReportResponse>> GetAggregatedByAllocationAsync(int unitId) public async Task<IEnumerable<AggregatedReportResponse>> GetAggregatedByAllocationAsync(int unitId)
{ {
var distributions = await GetAggregatedDataAsync(unitId); // 获取单位信息以确定可见性
var unit = await _organizationService.GetByIdAsync(unitId);
var userLevel = unit?.Level ?? OrganizationalLevel.Division;
var distributions = await GetAggregatedDataAsync(unitId, userLevel);
var distributionList = distributions.ToList(); var distributionList = distributions.ToList();
return distributionList return distributionList
@ -261,24 +283,25 @@ public class ReportingService : IReportingService
/// <summary> /// <summary>
/// 检查用户是否有权限查看指定单位的数据 /// 检查用户是否有权限查看指定单位的数据
/// 需求4.4, 8.1, 8.2:基于组织层级的数据访问控制 /// 需求4.4, 8.1, 8.2:基于组织层级的数据访问控制
/// 需求2.3:使用可见性范围检查
/// </summary> /// </summary>
public async Task<bool> CanViewUnitDataAsync(int userUnitId, int targetUnitId) public async Task<bool> CanViewUnitDataAsync(int userUnitId, int targetUnitId, OrganizationalLevel userLevel)
{ {
// 同一单位可以查看 // 获取用户可见的单位ID列表
if (userUnitId == targetUnitId) var visibleUnitIds = await _organizationService.GetVisibleUnitIdsAsync(userUnitId, userLevel);
return true;
// 检查目标单位是否在可见范围内
// 检查目标单位是否是用户单位的下级 return visibleUnitIds.Contains(targetUnitId);
return await _organizationService.IsUnitOrSubordinateAsync(userUnitId, targetUnitId);
} }
/// <summary> /// <summary>
/// 获取详细上报记录 /// 获取详细上报记录
/// 需求4.3:提供具体上报记录的访问,包括时间戳和上报人员 /// 需求4.3:提供具体上报记录的访问,包括时间戳和上报人员
/// 需求2.1:对营部及以下级别用户应用可见性过滤
/// </summary> /// </summary>
public async Task<IEnumerable<ReportResponse>> GetDetailedReportsAsync(int unitId) public async Task<IEnumerable<ReportResponse>> GetDetailedReportsAsync(int unitId, OrganizationalLevel userLevel)
{ {
var distributions = await GetAggregatedDataAsync(unitId); var distributions = await GetAggregatedDataAsync(unitId, userLevel);
return distributions.Select(d => new ReportResponse return distributions.Select(d => new ReportResponse
{ {

View File

@ -1,4 +1,5 @@
using MilitaryTrainingManagement.Models.Entities; using MilitaryTrainingManagement.Models.Entities;
using MilitaryTrainingManagement.Models.Enums;
namespace MilitaryTrainingManagement.Services.Interfaces; namespace MilitaryTrainingManagement.Services.Interfaces;
@ -76,4 +77,16 @@ public interface IAllocationService
/// 获取配额分配的上报历史记录 /// 获取配额分配的上报历史记录
/// </summary> /// </summary>
Task<IEnumerable<ConsumptionReport>> GetConsumptionReportsAsync(int distributionId); Task<IEnumerable<ConsumptionReport>> GetConsumptionReportsAsync(int distributionId);
/// <summary>
/// 获取配额分配的上报历史记录(带可见性过滤)
/// 只返回用户可见范围内的单位的上报记录
/// </summary>
Task<IEnumerable<ConsumptionReport>> GetConsumptionReportsAsync(int distributionId, int userUnitId, OrganizationalLevel userLevel);
/// <summary>
/// 检查用户是否有权限查看指定单位的配额分配记录
/// 使用可见性过滤:营部及以下级别只能查看本单位及直接下级
/// </summary>
Task<bool> CanViewDistributionAsync(int userUnitId, OrganizationalLevel userLevel, int targetUnitId);
} }

View File

@ -19,4 +19,14 @@ public interface IOrganizationService
Task<bool> IsSubordinateOfAsync(int unitId, int potentialParentId); Task<bool> IsSubordinateOfAsync(int unitId, int potentialParentId);
Task<bool> IsUnitOrSubordinateAsync(int userUnitId, int targetUnitId); Task<bool> IsUnitOrSubordinateAsync(int userUnitId, int targetUnitId);
Task<bool> IsParentUnitAsync(int parentUnitId, int childUnitId); Task<bool> IsParentUnitAsync(int parentUnitId, int childUnitId);
/// <summary>
/// 获取用户可见的单位ID列表
/// 营部及以下级别:本单位 + 直接下级
/// 师团级别:本单位 + 所有下级
/// </summary>
/// <param name="unitId">用户所属单位ID</param>
/// <param name="userLevel">用户的组织层级</param>
/// <returns>可见的单位ID列表</returns>
Task<IEnumerable<int>> GetVisibleUnitIdsAsync(int unitId, OrganizationalLevel userLevel);
} }

View File

@ -1,5 +1,6 @@
using MilitaryTrainingManagement.Models.DTOs; using MilitaryTrainingManagement.Models.DTOs;
using MilitaryTrainingManagement.Models.Entities; using MilitaryTrainingManagement.Models.Entities;
using MilitaryTrainingManagement.Models.Enums;
namespace MilitaryTrainingManagement.Services.Interfaces; namespace MilitaryTrainingManagement.Services.Interfaces;
@ -34,15 +35,20 @@ public interface IReportingService
decimal CalculateCompletionRate(decimal? actualCompletion, decimal unitQuota); decimal CalculateCompletionRate(decimal? actualCompletion, decimal unitQuota);
/// <summary> /// <summary>
/// 获取汇总数据(包含下级单位 /// 获取汇总数据(包含下级单位,应用可见性过滤
/// </summary> /// </summary>
Task<IEnumerable<AllocationDistribution>> GetAggregatedDataAsync(int unitId); Task<IEnumerable<AllocationDistribution>> GetAggregatedDataAsync(int unitId, OrganizationalLevel userLevel);
/// <summary> /// <summary>
/// 获取层级汇总数据 /// 获取层级汇总数据
/// </summary> /// </summary>
Task<HierarchicalAggregatedResponse> GetHierarchicalAggregatedDataAsync(int unitId); Task<HierarchicalAggregatedResponse> GetHierarchicalAggregatedDataAsync(int unitId);
/// <summary>
/// 获取层级汇总数据(应用可见性过滤)
/// </summary>
Task<HierarchicalAggregatedResponse> GetHierarchicalAggregatedDataAsync(int unitId, OrganizationalLevel userLevel);
/// <summary> /// <summary>
/// 获取按配额分组的汇总数据 /// 获取按配额分组的汇总数据
/// </summary> /// </summary>
@ -54,12 +60,12 @@ public interface IReportingService
Task<IEnumerable<SubordinateUnitSummary>> GetDirectSubordinateSummariesAsync(int unitId); Task<IEnumerable<SubordinateUnitSummary>> GetDirectSubordinateSummariesAsync(int unitId);
/// <summary> /// <summary>
/// 检查用户是否有权限查看指定单位的数据 /// 检查用户是否有权限查看指定单位的数据(使用可见性范围)
/// </summary> /// </summary>
Task<bool> CanViewUnitDataAsync(int userUnitId, int targetUnitId); Task<bool> CanViewUnitDataAsync(int userUnitId, int targetUnitId, OrganizationalLevel userLevel);
/// <summary> /// <summary>
/// 获取详细上报记录(包含时间戳和上报人员 /// 获取详细上报记录(包含时间戳和上报人员,应用可见性过滤
/// </summary> /// </summary>
Task<IEnumerable<ReportResponse>> GetDetailedReportsAsync(int unitId); Task<IEnumerable<ReportResponse>> GetDetailedReportsAsync(int unitId, OrganizationalLevel userLevel);
} }

View File

@ -37,15 +37,17 @@
<el-descriptions-item label="目标单位"> <el-descriptions-item label="目标单位">
{{ distribution.targetUnitName }} {{ distribution.targetUnitName }}
</el-descriptions-item> </el-descriptions-item>
<el-descriptions-item label="分配配额"> <!-- 师团/团级显示分配配额 -->
<el-descriptions-item v-if="!isBattalionOrBelow" label="分配配额">
<span class="quota-value">{{ formatNumber(distribution.unitQuota) }} {{ allocation?.unit }}</span> <span class="quota-value">{{ formatNumber(distribution.unitQuota) }} {{ allocation?.unit }}</span>
</el-descriptions-item> </el-descriptions-item>
<el-descriptions-item label="已上报数量"> <el-descriptions-item label="已上报数量">
<span :class="['completion-value', distribution.actualCompletion ? '' : 'no-data']"> <span :class="['completion-value', totalReportedAmount ? '' : 'no-data']">
{{ distribution.actualCompletion ? formatNumber(distribution.actualCompletion) : '0' }} {{ allocation?.unit }} {{ totalReportedAmount ? formatNumber(totalReportedAmount) : '0' }} {{ allocation?.unit }}
</span> </span>
</el-descriptions-item> </el-descriptions-item>
<el-descriptions-item label="剩余配额"> <!-- 师团/团级显示剩余配额 -->
<el-descriptions-item v-if="!isBattalionOrBelow" label="剩余配额">
<span class="remaining-value"> <span class="remaining-value">
{{ formatNumber((distribution.unitQuota || 0) - (distribution.actualCompletion || 0)) }} {{ allocation?.unit }} {{ formatNumber((distribution.unitQuota || 0) - (distribution.actualCompletion || 0)) }} {{ allocation?.unit }}
</span> </span>
@ -69,7 +71,7 @@
<el-form <el-form
ref="formRef" ref="formRef"
:model="form" :model="form"
:rules="rules" :rules="formRules"
label-width="120px" label-width="120px"
@submit.prevent="handleSubmit" @submit.prevent="handleSubmit"
> >
@ -77,7 +79,7 @@
<el-input-number <el-input-number
v-model="form.actualCompletion" v-model="form.actualCompletion"
:min="0" :min="0"
:max="(distribution.unitQuota || 0) - (distribution.actualCompletion || 0)" :max="isBattalionOrBelow ? undefined : (distribution.unitQuota || 0) - (distribution.actualCompletion || 0)"
:precision="2" :precision="2"
:step="1" :step="1"
style="width: 300px" style="width: 300px"
@ -85,7 +87,8 @@
<span class="unit-hint">{{ allocation?.unit }}</span> <span class="unit-hint">{{ allocation?.unit }}</span>
</el-form-item> </el-form-item>
<el-form-item label="上报后总数"> <!-- 师团/团级显示上报后总数 -->
<el-form-item v-if="!isBattalionOrBelow" label="上报后总数">
<div class="total-after-report"> <div class="total-after-report">
<span class="total-number">{{ formatNumber((distribution.actualCompletion || 0) + (form.actualCompletion || 0)) }}</span> <span class="total-number">{{ formatNumber((distribution.actualCompletion || 0) + (form.actualCompletion || 0)) }}</span>
<span class="unit-text">{{ allocation?.unit }}</span> <span class="unit-text">{{ allocation?.unit }}</span>
@ -95,7 +98,8 @@
</div> </div>
</el-form-item> </el-form-item>
<el-form-item label="完成率"> <!-- 师团/团级显示完成率 -->
<el-form-item v-if="!isBattalionOrBelow" label="完成率">
<div class="completion-rate"> <div class="completion-rate">
<el-progress <el-progress
:percentage="getCompletionPercentage()" :percentage="getCompletionPercentage()"
@ -185,10 +189,12 @@ import { useRouter, useRoute } from 'vue-router'
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus' import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus'
import { DocumentAdd, Back, Check, Close, Clock } from '@element-plus/icons-vue' import { DocumentAdd, Back, Check, Close, Clock } from '@element-plus/icons-vue'
import { allocationsApi, type ConsumptionReport } from '@/api' import { allocationsApi, type ConsumptionReport } from '@/api'
import { useAuthStore } from '@/stores/auth'
import type { MaterialAllocation, AllocationDistribution } from '@/types' import type { MaterialAllocation, AllocationDistribution } from '@/types'
const router = useRouter() const router = useRouter()
const route = useRoute() const route = useRoute()
const authStore = useAuthStore()
const loading = ref(false) const loading = ref(false)
const submitting = ref(false) const submitting = ref(false)
@ -202,16 +208,36 @@ const form = reactive({
remarks: '' remarks: ''
}) })
const rules: FormRules = { // Battalion = 3, Company = 4
actualCompletion: [ const isBattalionOrBelow = computed(() => {
{ required: true, message: '请输入本次上报数量', trigger: 'blur' }, return authStore.organizationalLevelNum >= 3
{ })
type: 'number',
min: 0.01, //
message: '数量必须大于0', const totalReportedAmount = computed(() => {
trigger: 'blur' if (consumptionReports.value.length === 0) {
}, return distribution.value?.actualCompletion || 0
{ }
return consumptionReports.value.reduce((sum, report) => sum + report.reportedAmount, 0)
})
//
const formRules = computed<FormRules>(() => {
const baseRules: FormRules = {
actualCompletion: [
{ required: true, message: '请输入本次上报数量', trigger: 'blur' },
{
type: 'number',
min: 0.01,
message: '数量必须大于0',
trigger: 'blur'
}
]
}
// /
if (!isBattalionOrBelow.value) {
baseRules.actualCompletion.push({
validator: (_rule, value, callback) => { validator: (_rule, value, callback) => {
const remaining = (distribution.value?.unitQuota || 0) - (distribution.value?.actualCompletion || 0) const remaining = (distribution.value?.unitQuota || 0) - (distribution.value?.actualCompletion || 0)
if (value > remaining) { if (value > remaining) {
@ -221,9 +247,11 @@ const rules: FormRules = {
} }
}, },
trigger: 'blur' trigger: 'blur'
} })
] }
}
return baseRules
})
function formatDate(dateStr: string): string { function formatDate(dateStr: string): string {
return new Date(dateStr).toLocaleString('zh-CN') return new Date(dateStr).toLocaleString('zh-CN')
@ -268,8 +296,13 @@ async function handleSubmit() {
const newTotal = (distribution.value?.actualCompletion || 0) + form.actualCompletion const newTotal = (distribution.value?.actualCompletion || 0) + form.actualCompletion
//
const confirmMessage = isBattalionOrBelow.value
? `本次上报数量:${form.actualCompletion} ${allocation.value?.unit}\n\n确认提交吗`
: `本次上报数量:${form.actualCompletion} ${allocation.value?.unit}\n上报后总数${newTotal} ${allocation.value?.unit}\n\n确认提交吗`
await ElMessageBox.confirm( await ElMessageBox.confirm(
`本次上报数量:${form.actualCompletion} ${allocation.value?.unit}\n上报后总数${newTotal} ${allocation.value?.unit}\n\n确认提交吗`, confirmMessage,
'确认上报', '确认上报',
{ {
type: 'warning', type: 'warning',