using System.Text.Json;
using Admin.NET.Core;
using Admin.NET.Plugin.AiDOP.Entity.S8;
using Admin.NET.Plugin.AiDOP.Infrastructure.S8;
using Admin.NET.Plugin.ApprovalFlow;
using Microsoft.Extensions.Logging;
namespace Admin.NET.Plugin.AiDOP.Service.S8;
/// 审批流节点上配置的一个审批人来源(原样呈现,不做任何"修正")。
public sealed class S8ReviewApproverNodeDto
{
public string NodeName { get; set; } = string.Empty;
/// 原始 approverType(SpecificUser / Role / Department / Initiator / DepartmentLeader)。
public string ApproverType { get; set; } = string.Empty;
/// 原始 approverIds,逐字回显。
public string ApproverIds { get; set; } = string.Empty;
/// 在当前租户下实际能解析出的账号(展示名)。
public List ResolvedUsers { get; set; } = new();
/// 解析结果为 0 人 —— 该节点在本租户下没有任何人可审批。
public bool ResolvesToNobody => ResolvedUsers.Count == 0;
/// 配置里引用了不属于本租户的账号 / 角色。
public List CrossTenantRefs { get; set; } = new();
}
/// 一个审核环节的真实来源。
public sealed class S8ReviewStageDto
{
/// 环节名(复核 / 升级)。
public string Stage { get; set; } = string.Empty;
/// 这一环的审核人实际由什么决定——用业务语言,不用内部术语。
public string DecidedBy { get; set; } = string.Empty;
/// 关联的审批流 BizType(没有则为空)。
public string? FlowBizType { get; set; }
public string? FlowName { get; set; }
public List Nodes { get; set; } = new();
/// 需要管理员注意的问题(跨租户引用 / 解析为 0 人 / 未配置流程)。
public List Warnings { get; set; } = new();
}
///
/// S8-RULE-4TAB-1:只读解析「这条规则的异常,审核时到底由谁审」。
///
/// 本服务不改变任何审批语义:不写库、不改 ApproverType、不动流程节点、
/// 不新增 reviewer 配置。它唯一的职责是把当前事实如实呈现给管理员 ——
/// 包括那些不好看的事实。
///
/// 为什么必须做这件事:规则配置页此前把审核人显示为「主管」,
/// 而系统里根本不存在组织主管关系(SysOrg.DirectorId 与
/// SysUser.ManagerUserId 实测填充率均为 0)。管理员据此以为审核链是通的,
/// 实际上:UAT 租户的复核流程 EXCEPTION_CLOSURE 配的是
/// approverType=Role, approverIds=1300000000101,1300000000888,
/// 而这两个角色属于别的租户,在本租户下解析为 0 人;
/// 升级流程 EXCEPTION_ESCALATION 三个节点全部指向
/// SpecificUser=789819678195781(Demo02,默认租户)。
/// 这些都不是本批要改的东西 —— 但管理员必须看得见。
///
public class S8ReviewSourceService : ITransient
{
private readonly SqlSugarRepository _flowRep;
private readonly SqlSugarRepository _typeRep;
private readonly IS8UserScopeValidator _userScope;
private readonly S8RoleResolver _roleResolver;
private readonly ILogger _logger;
/// 复检流程的 BizType(与 S8TaskFlowService.TryStartVerificationFlowAsync 一致)。
private const string ClosureBizType = "EXCEPTION_CLOSURE";
/// 升级流程的 BizType(与 S8TaskFlowService.UpgradeAsync 一致)。
private const string EscalationBizType = "EXCEPTION_ESCALATION";
public S8ReviewSourceService(
SqlSugarRepository flowRep,
SqlSugarRepository typeRep,
IS8UserScopeValidator userScope,
S8RoleResolver roleResolver,
ILogger logger)
{
_flowRep = flowRep;
_typeRep = typeRep;
_userScope = userScope;
_roleResolver = roleResolver;
_logger = logger;
}
/// 解析该租户下两个审核环节的真实来源。
public async Task> DescribeAsync(long tenantId)
{
var stages = new List();
// ── 复核 ──
// 真正决定审核人的是 S8 自己的状态机:提交复核时手选一个本租户账号写入
// verifier_user_id。EXCEPTION_CLOSURE 流程是"双线合一"的影子,它的审批人配置
// 并不决定谁能点"检验通过"。这一点必须说清楚,否则管理员会去改流程配置,
// 然后发现毫无效果。
var closure = await DescribeFlowAsync(tenantId, ClosureBizType, "复核");
closure.DecidedBy = "提交复核时由处理人手选一名本租户账号(写入 verifier_user_id),"
+ "只有该账号能执行「检验通过 / 检验退回」。";
if (closure.Nodes.Count > 0)
closure.Warnings.Add(
"下方审批流节点仅用于同步流程状态,**不决定**谁能点检验通过;改它不会改变实际审核人。");
stages.Add(closure);
// ── 升级 ──
var escalation = await DescribeFlowAsync(tenantId, EscalationBizType, "升级");
escalation.DecidedBy = escalation.Nodes.Count > 0
? "由升级审批流程的节点配置决定。"
: "本租户尚未配置升级审批流程,升级将无人可审。";
stages.Add(escalation);
return stages;
}
///
/// 附带说明:异常类型上的 escalate_role_code。
/// 它是超时自动升级时用的角色,与上面的流程节点是两套东西;
/// 真库实测 owner 与 escalate 角色几乎全部相同(8 组完全一致),
/// 也就是"升级"实际上升给了同一批人。这同样只呈现、不修改。
///
public async Task> DescribeEscalateRolesAsync(long tenantId, string? sceneCode)
{
var rows = await _typeRep.AsQueryable().ClearFilter()
.Where(x => x.TenantId == tenantId)
.WhereIF(!string.IsNullOrWhiteSpace(sceneCode), x => x.SceneCode == sceneCode)
.Select(x => new { x.TypeCode, x.OwnerRoleCode, x.EscalateRoleCode })
.ToListAsync();
return rows
.Where(r => !string.IsNullOrWhiteSpace(r.EscalateRoleCode))
.Select(r => r.OwnerRoleCode == r.EscalateRoleCode
? $"{r.TypeCode}:{r.EscalateRoleCode}(与责任角色相同,升级后仍是同一批人)"
: $"{r.TypeCode}:{r.EscalateRoleCode}")
.Distinct()
.ToList();
}
// ────────────────────────────────────────────────────────────────
private async Task DescribeFlowAsync(long tenantId, string bizType, string stageName)
{
var dto = new S8ReviewStageDto { Stage = stageName, FlowBizType = bizType };
var flow = await _flowRep.AsQueryable().ClearFilter()
.Where(x => x.BizType == bizType && x.TenantId == tenantId && !x.IsDelete)
.OrderByDescending(x => x.Id)
.FirstAsync();
if (flow == null)
{
dto.Warnings.Add($"本租户没有 {bizType} 审批流程。");
return dto;
}
dto.FlowName = flow.Name;
List<(string NodeName, int Type, string Ids)> nodes;
try
{
nodes = ParseApproverNodes(flow.FlowJson);
}
catch (Exception ex)
{
// 解析失败要说出来,不能装作"这个流程没有审批人"。
_logger.LogWarning(ex, "s8_review_source_parse_failed bizType={BizType} tenant={Tenant}", bizType, tenantId);
dto.Warnings.Add("流程定义无法解析,无法确定审批人来源。");
return dto;
}
foreach (var (nodeName, type, ids) in nodes)
{
var node = new S8ReviewApproverNodeDto
{
NodeName = nodeName,
ApproverType = ((ApproverTypeEnum)type).ToString(),
ApproverIds = ids
};
switch ((ApproverTypeEnum)type)
{
case ApproverTypeEnum.SpecificUser:
await ResolveSpecificUsersAsync(tenantId, ids, node);
break;
case ApproverTypeEnum.Role:
await ResolveRolesAsync(tenantId, ids, node);
break;
default:
// Department / Initiator / DepartmentLeader 由流程引擎在运行时按发起人上下文解析,
// 静态解析不出确定人选。**不猜**,如实说明。
node.ResolvedUsers.Add($"(运行时按发起人解析:{node.ApproverType})");
break;
}
if (node.ResolvesToNobody)
dto.Warnings.Add($"节点「{nodeName}」在本租户下解析不出任何审批人。");
if (node.CrossTenantRefs.Count > 0)
dto.Warnings.Add($"节点「{nodeName}」引用了不属于本租户的对象:{string.Join('、', node.CrossTenantRefs)}。");
dto.Nodes.Add(node);
}
return dto;
}
private async Task ResolveSpecificUsersAsync(long tenantId, string ids, S8ReviewApproverNodeDto node)
{
var tokens = S8RoleResolver.SplitTokens(ids);
foreach (var t in tokens)
{
if (!long.TryParse(t, out var uid)) { node.CrossTenantRefs.Add($"{t}(非法账号 Id)"); continue; }
var user = await _userScope.FindActiveUserAsync(tenantId, uid);
if (user != null) { node.ResolvedUsers.Add($"{user.DisplayName} ({user.Account})"); continue; }
// 跨租户 / 已停用 —— 这里刻意查出真名并标注,因为这是给管理员排障用的
// 只读视图,不是对外接口;不说清楚他无从判断该改哪里。
var any = await _flowRep.Context.Queryable().ClearFilter()
.Where(u => u.Id == uid).Select(u => new { u.Account, u.TenantId, u.Status }).FirstAsync();
node.CrossTenantRefs.Add(any == null
? $"{t}(账号不存在)"
: $"{any.Account}({(any.TenantId == tenantId ? "已停用" : "属于其它租户")})");
}
}
private async Task ResolveRolesAsync(long tenantId, string ids, S8ReviewApproverNodeDto node)
{
var tokens = S8RoleResolver.SplitTokens(ids);
var userIds = await _roleResolver.ResolveUserIdsAsync(tenantId, tokens);
var users = await _userScope.ValidateUsersAsync(tenantId, userIds);
node.ResolvedUsers.AddRange(users.Select(u => $"{u.DisplayName} ({u.Account})"));
if (node.ResolvedUsers.Count > 0) return;
// 解析为 0 人时要指出**为什么**:token 是本租户不存在的角色,还是角色没有成员。
foreach (var t in tokens)
{
var byCode = await _flowRep.Context.Queryable().ClearFilter()
.Where(r => r.Code == t && r.TenantId == tenantId).AnyAsync();
if (byCode) continue;
if (long.TryParse(t, out var rid))
{
var role = await _flowRep.Context.Queryable().ClearFilter()
.Where(r => r.Id == rid).Select(r => new { r.Code, r.TenantId }).FirstAsync();
node.CrossTenantRefs.Add(role == null
? $"{t}(角色不存在)"
: $"{role.Code}(属于其它租户)");
}
else
{
node.CrossTenantRefs.Add($"{t}(本租户无此角色)");
}
}
}
///
/// 从流程定义 JSON 里抽出审批节点。
/// 结构由 ApprovalFlow 插件定义,本方法只做宽松读取:认不出的结构一律跳过,
/// 绝不猜测。读不出来时上层会显示"无法确定审批人来源",而不是"没有审批人"。
///
internal static List<(string NodeName, int Type, string Ids)> ParseApproverNodes(string? flowJson)
{
var result = new List<(string, int, string)>();
if (string.IsNullOrWhiteSpace(flowJson)) return result;
using var doc = JsonDocument.Parse(flowJson);
Walk(doc.RootElement);
return result;
void Walk(JsonElement el)
{
switch (el.ValueKind)
{
case JsonValueKind.Object:
if (el.TryGetProperty("approverType", out var at))
{
var name = el.TryGetProperty("nodeName", out var nn) && nn.ValueKind == JsonValueKind.String
? nn.GetString() ?? "审批节点"
: "审批节点";
var type = at.ValueKind switch
{
JsonValueKind.Number when at.TryGetInt32(out var n) => n,
JsonValueKind.String when Enum.TryParse(at.GetString(), true, out var e) => (int)e,
_ => -1
};
var ids = el.TryGetProperty("approverIds", out var ai) && ai.ValueKind == JsonValueKind.String
? ai.GetString() ?? string.Empty
: string.Empty;
if (type >= 0) result.Add((name, type, ids));
}
foreach (var p in el.EnumerateObject()) Walk(p.Value);
break;
case JsonValueKind.Array:
foreach (var item in el.EnumerateArray()) Walk(item);
break;
}
}
}
}