using System.Security.Claims; using Microsoft.EntityFrameworkCore; using Rmsys.Domain; using Rmsys.Domain.Entities; using Rmsys.Infrastructure; namespace Rmsys.Api.Endpoints; /// /// 任免文件 ↔ 上会干部 的姓名自动匹配与一致性检查。 /// /// 解决的问题:以前"文件涉及哪些人"只能手工勾选,漏勾就统计不到, /// 出包时也看不出文件里的名单和审批表名单是否对得上。 /// public static class DocMatchEndpoints { public static void MapDocMatchEndpoints(this WebApplication app) { // ---------- 只检查,不落库(供界面预览 / 出包前提示) ---------- app.MapGet("/api/admin/appointdocs/{id:int}/match-report", async ( int id, RmsysDbContext db, IWebHostEnvironment env) => { var (doc, cads, err) = await LoadAsync(id, db, env); if (err != null) return Results.Ok(new { ok = false, message = err }); var r = DocNameMatcher.Match(Path.Combine(env.ContentRootPath, "App_Data", doc!.FilePath!), cads); var t30 = await T30IdsAsync(db, doc.MeetingId); return Results.Ok(new { ok = true, textOk = r.TextOk, error = r.Error, textLength = r.TextLength, inFile = r.InFile.Select(x => new { cadreId = x.CadreId, name = x.Name, pos = x.Pos, hasT30 = t30.Contains(x.CadreId) }), ambiguous = r.Ambiguous.Select(x => new { name = x.Name, cadreIds = x.CadreIds }), unknownNames = r.UnknownNames, t30NotInDoc = cads.Where(c => t30.Contains(c.Id) && r.InFile.All(x => x.CadreId != c.Id)) .Select(c => new { cadreId = c.Id, name = c.Name }).ToList(), inDocNoT30 = r.InFile.Where(x => !t30.Contains(x.CadreId)) .Select(x => new { cadreId = x.CadreId, name = x.Name }).ToList(), linkedCount = await db.AppointDocCadres.CountAsync(x => x.DocId == id) }); }).RequireAuthorization(); // ---------- 自动匹配并写入关联(可覆盖手工结果) ---------- app.MapPost("/api/admin/appointdocs/{id:int}/auto-match", async ( int id, ClaimsPrincipal p, RmsysDbContext db, IWebHostEnvironment env) => { var (doc, cads, err) = await LoadAsync(id, db, env); if (err != null) return Results.Ok(new { ok = false, message = err }); var r = DocNameMatcher.Match(Path.Combine(env.ContentRootPath, "App_Data", doc!.FilePath!), cads); if (!r.TextOk) return Results.Ok(new { ok = false, message = r.Error ?? "无法读取文件内容,请在「人员」里手动勾选" }); // 覆盖式写入:按姓名在文件中出现的先后排序 var old = await db.AppointDocCadres.Where(x => x.DocId == id).ToListAsync(); db.AppointDocCadres.RemoveRange(old); var sort = 1; foreach (var x in r.InFile) { db.AppointDocCadres.Add(new AppointDocCadre { DocId = id, CadreId = x.CadreId, Sort = sort++ }); } var t30 = await T30IdsAsync(db, doc.MeetingId); await Audit(db, p, "任免文件自动匹配", $"{doc.DocNo ?? ("#" + id)} {doc.Title}", $"匹配到 {r.InFile.Count} 人;同名待确认 {r.Ambiguous.Count};文件内库外姓名 {r.UnknownNames.Count}"); return Results.Ok(new { ok = true, message = $"已按姓名自动匹配 {r.InFile.Count} 人", matched = r.InFile.Select(x => new { cadreId = x.CadreId, name = x.Name, hasT30 = t30.Contains(x.CadreId) }), ambiguous = r.Ambiguous.Select(x => new { name = x.Name, cadreIds = x.CadreIds }), unknownNames = r.UnknownNames, t30NotInDoc = cads.Where(c => t30.Contains(c.Id) && r.InFile.All(x => x.CadreId != c.Id)) .Select(c => new { cadreId = c.Id, name = c.Name }).ToList(), inDocNoT30 = r.InFile.Where(x => !t30.Contains(x.CadreId)) .Select(x => new { cadreId = x.CadreId, name = x.Name }).ToList() }); }).RequireAuthorization(); // ---------- 出包前一致性检查(会议级汇总) ---------- app.MapPost("/api/admin/packages/precheck", async ( PrecheckReq req, RmsysDbContext db, IWebHostEnvironment env) => { var meeting = await db.Meetings.FirstOrDefaultAsync(x => x.Id == req.meetingId && x.IsDeleted == 0); if (meeting == null) return Results.Ok(new { ok = false, message = "会议不存在" }); var cads = await db.MeetingCadres.Where(x => x.MeetingId == meeting.Id) .Join(db.Cadres, mc => mc.CadreId, c => c.Id, (mc, c) => new { c.Id, c.Name }) .Select(x => new { x.Id, x.Name }).ToListAsync(); var cadreTuples = cads.Select(x => (x.Id, x.Name)).ToList(); var deptOfCadre = await db.Cadres.Where(c => cadreTuples.Select(t => t.Id).Contains(c.Id)) .Select(c => new { c.Id, c.DeptId }).ToDictionaryAsync(x => x.Id, x => x.DeptId); var t30 = await T30IdsAsync(db, meeting.Id); var docs = await db.AppointDocs.Where(x => x.MeetingId == meeting.Id && x.IsDeleted == 0).ToListAsync(); var deptNames = await db.Depts.ToDictionaryAsync(x => x.Id, x => x.Name); var issues = new List(); var covered = new HashSet(); var root = Path.Combine(env.ContentRootPath, "App_Data"); foreach (var d in docs) { var tag = (d.DeptId.HasValue && deptNames.ContainsKey(d.DeptId.Value) ? deptNames[d.DeptId.Value] : "整会") + " " + (d.DocNo ?? d.Title ?? ("#" + d.Id)); if (string.IsNullOrWhiteSpace(d.FilePath) || !File.Exists(Path.Combine(root, d.FilePath!))) { issues.Add(new { level = "error", type = "fileMissing", text = $"{tag}:记录的任免文件在磁盘上找不到" }); continue; } var scope = d.DeptId.HasValue ? cadreTuples.Where(t => deptOfCadre.TryGetValue(t.Id, out var dd) && dd == d.DeptId.Value).ToList() : cadreTuples; var r = DocNameMatcher.Match(Path.Combine(root, d.FilePath!), scope); if (!r.TextOk) { issues.Add(new { level = "warn", type = "textUnreadable", text = $"{tag}:{r.Error}" }); continue; } foreach (var x in r.InFile) covered.Add(x.CadreId); if (r.InFile.Count == 0) issues.Add(new { level = "error", type = "noMatch", text = $"{tag}:文件里没有出现任何本场上会干部的姓名,可能传错文件" }); if (r.UnknownNames.Count > 0) issues.Add(new { level = "warn", type = "unknownName", text = $"{tag}:文件里出现名单外姓名({string.Join("、", r.UnknownNames)}),请核对" }); foreach (var a in r.Ambiguous) issues.Add(new { level = "warn", type = "ambiguous", text = $"{tag}:「{a.Name}」在名单中有 {a.CadreIds.Count} 人同名,需在「人员」里人工确认" }); var linked = await db.AppointDocCadres.CountAsync(x => x.DocId == d.Id); if (linked == 0 && r.InFile.Count > 0) issues.Add(new { level = "warn", type = "notLinked", text = $"{tag}:还没建立「文件↔人员」关联,点「自动匹配」即可" }); else if (linked != r.InFile.Count && r.InFile.Count > 0) issues.Add(new { level = "warn", type = "linkMismatch", text = $"{tag}:已关联 {linked} 人,与文件中的 {r.InFile.Count} 人不一致" }); } /* 处室维度只看会议类型:部务会按启用的处室各一份,其余会议整会一份 */ if (meeting.Type == MeetingType.BuWu) { var enabledDepts = await db.Depts.Where(x => x.Enabled == 1).OrderBy(x => x.Sort).ToListAsync(); var withDoc = docs.Where(x => x.DeptId.HasValue).Select(x => x.DeptId!.Value).ToHashSet(); foreach (var dp in enabledDepts.Where(x => !withDoc.Contains(x.Id))) issues.Add(new { level = "error", type = "deptNoDoc", text = $"{dp.Name}:还没有上传任免文件" }); } else if (docs.Count == 0) { issues.Add(new { level = "error", type = "noDoc", text = "本场会议还没有上传任免文件" }); } foreach (var c in cads.Where(c => t30.Contains(c.Id) && !covered.Contains(c.Id))) issues.Add(new { level = "warn", type = "t30NotInDoc", text = $"{c.Name}:有任免审批表,但任何任免文件里都没提到他" }); foreach (var c in cads.Where(c => !t30.Contains(c.Id) && covered.Contains(c.Id))) issues.Add(new { level = "warn", type = "inDocNoT30", text = $"{c.Name}:任免文件里有他,但没有上传任免审批表" }); return Results.Ok(new { ok = true, meeting = meeting.Name, checkedDocs = docs.Count, cadreCount = cads.Count, issueCount = issues.Count, errorCount = issues.Count(x => x.GetType().GetProperty("level")!.GetValue(x)!.ToString() == "error"), issues }); }).RequireAuthorization(); } private static async Task<(AppointDoc? doc, List<(int Id, string Name)> cadres, string? err)> LoadAsync( int id, RmsysDbContext db, IWebHostEnvironment env) { var doc = await db.AppointDocs.FirstOrDefaultAsync(x => x.Id == id && x.IsDeleted == 0); if (doc == null) return (null, new(), "任免文件不存在"); if (string.IsNullOrWhiteSpace(doc.FilePath)) return (null, new(), "这份任免文件没有文件本体"); if (!File.Exists(Path.Combine(env.ContentRootPath, "App_Data", doc.FilePath!))) return (null, new(), "文件在磁盘上找不到(可能已被替换或删除)"); /* 匹配范围按文件归属收窄:部务会每处室一份文件,只涉及本处室干部; 整会一份(DeptId 为空)时用全会议名单。否则会把别的处室的人判成"文件里没提到"。 */ var q = db.MeetingCadres.Where(x => x.MeetingId == doc.MeetingId) .Join(db.Cadres, mc => mc.CadreId, c => c.Id, (mc, c) => new { c.Id, c.Name, c.DeptId }); if (doc.DeptId.HasValue) q = q.Where(x => x.DeptId == doc.DeptId.Value); var cads = await q.Select(x => new { x.Id, x.Name }).ToListAsync(); return (doc, cads.Select(x => (x.Id, x.Name)).ToList(), null); } /// 本场会议里已上传任免审批表的干部 Id private static async Task> T30IdsAsync(RmsysDbContext db, int meetingId) { var ids = await db.Materials .Where(m => m.MeetingId == meetingId && m.CadreId != null && m.Type == MaterialType.AppointmentForm && m.IsDeleted == 0 && m.FilePath != null && m.FilePath != "") .Select(m => m.CadreId!.Value).Distinct().ToListAsync(); return ids.ToHashSet(); } private static async Task Audit(RmsysDbContext db, ClaimsPrincipal p, string action, string target, string detail) { db.AuditLogs.Add(new AuditLog { At = DateTime.Now, UserName = p.FindFirst(ClaimTypes.Name)?.Value ?? "—", Action = action, Target = target, Detail = detail, Ip = "", Device = "PC-管理后台", Success = 1 }); await db.SaveChangesAsync(); } } public record PrecheckReq(int meetingId);