using System.Text; using System.Text.Json; using System.Text.RegularExpressions; using Hc900Crawler.Core.Application.DTOs; using Hc900Crawler.Core.Application.Interfaces; using Hc900Crawler.Core.Domain.Entities; using Hc900Crawler.Infrastructure.Database; using Hc900Crawler.Infrastructure.Mcp; using Microsoft.EntityFrameworkCore; using netDxf; using UglyToad.PdfPig; namespace Hc900Crawler.Core.Application.Services; public class PidExtractorService : IPidExtractorService { private readonly McpClient _mcp; private readonly Hc900DbContext _dbContext; private readonly ILogger _logger; private readonly SemaphoreSlim _cacheLock = new(1, 1); private List? _cachedRules; public PidExtractorService(McpClient mcp, Hc900DbContext dbContext, ILogger logger) { _mcp = mcp; _dbContext = dbContext; _logger = logger; } public async Task ExtractFromFileAsync(string filePath, bool useImageMode = false) { await using var stream = File.OpenRead(filePath); return await ExtractFromStreamAsync(stream, Path.GetFileName(filePath), useImageMode); } public async Task ExtractFromStreamAsync(Stream stream, string fileName, bool useImageMode = false) { var ext = Path.GetExtension(fileName).ToLowerInvariant(); string text; Dictionary? coords = null; if (ext == ".dxf") (text, coords) = ExtractDxfText(stream); else if (ext == ".pdf") text = ExtractPdfText(stream); else throw new NotSupportedException($"지원 형식: .dxf .pdf (스캔본 이미지는 Vision 모드 필요)"); if (string.IsNullOrWhiteSpace(text)) return new PidExtractionResult(0, 0, 0); // MCP → vLLM 태그 추출 var sourceType = ext.TrimStart('.'); var json = await _mcp.ExtractPidTagsAsync(text, sourceType); var extractedItems = ParseJson(json); if (extractedItems.Count == 0) { _logger.LogWarning("P&ID 추출 결과 0건 — 파일: {FileName}", fileName); return new PidExtractionResult(0, 0, 0); } // MCP → vLLM 태그 매핑 제안 var pidTagNos = extractedItems.Select(i => i.TagNo).Distinct().ToList(); var experionTagNames = await _dbContext.RealtimePoints.Select(r => r.TagName).ToListAsync(); var mappingJson = await _mcp.MatchPidTagsAsync(pidTagNos, experionTagNames); var mappings = ParseMappingJson(mappingJson); // 중복 체크: 기존 DB에 있는 TagNo는 제외 (대소문자 구분 없음) var existingTagNos = new HashSet( await _dbContext.PidEquipment.Select(e => e.TagNo).ToListAsync(), StringComparer.OrdinalIgnoreCase); var newItems = extractedItems.Where(i => !existingTagNos.Contains(i.TagNo)).ToList(); int skippedCount = extractedItems.Count - newItems.Count; if (skippedCount > 0) _logger.LogInformation("P&ID 중복 제외: {Skipped}건 스킵 (이미 존재)", skippedCount); // DB 저장 var prefixRules = await GetRulesCachedAsync(); var dbItems = new List(); foreach (var item in newItems) { mappings.TryGetValue(item.TagNo, out var matched); var experionTag = matched != null ? await _dbContext.RealtimePoints.FirstOrDefaultAsync(r => r.TagName == matched) : await FindFallbackTagAsync(item.TagNo); var category = await MatchCategoryAsync(item.TagNo); var tagDcs = await ResolveTagDcsAsync(item.TagNo); var tagClass = ClassifyTagClass(category, tagDcs); var newItem = new PidEquipment { TagNo = item.TagNo, EquipmentName = ResolveEquipmentName(item.InstrumentType, prefixRules) ?? item.EquipmentName, InstrumentType = item.InstrumentType, LineNumber = item.LineNumber, PidDrawingNo = item.PidDrawingNo, Confidence = item.Confidence, ExperionTagId = experionTag?.Id, Category = category, TagDcs = tagDcs, TagClass = tagClass, ExtractedAt = DateTime.UtcNow, UpdatedAt = DateTime.UtcNow }; if (coords != null && coords.TryGetValue(item.TagNo, out var c)) { newItem.PosX = c.X; newItem.PosY = c.Y; } if (newItem.PidDrawingNo == null) newItem.PidDrawingNo = fileName; newItem.DrawingFile = fileName; dbItems.Add(newItem); } if (dbItems.Count > 0) { await _dbContext.PidEquipment.AddRangeAsync(dbItems); await _dbContext.SaveChangesAsync(); } _logger.LogInformation( "P&ID 추출 완료: {Total}건 저장, {Skipped}건 중복 스킵 (파일: {FileName})", dbItems.Count, skippedCount, fileName); return new PidExtractionResult( TotalCount: dbItems.Count, ConfidenceItems: dbItems.Count(i => i.Confidence >= 0.7), LowConfidenceItems: dbItems.Count(i => i.Confidence < 0.5), SkippedDuplicates: skippedCount); } private (string Text, Dictionary Coords) ExtractDxfText(Stream stream) { var tmp = Path.GetTempFileName() + ".dxf"; try { using (var fs = File.Create(tmp)) stream.CopyTo(fs); var doc = DxfDocument.Load(tmp); var sb = new StringBuilder(); var positioned = new List<(string Value, double X, double Y, double H)>(); foreach (var txt in doc.Entities.Texts) { sb.AppendLine(txt.Value); if (!string.IsNullOrWhiteSpace(txt.Value)) positioned.Add((txt.Value.Trim(), txt.Position.X, txt.Position.Y, txt.Height)); } foreach (var mtxt in doc.Entities.MTexts) { var plain = mtxt.PlainText(); sb.AppendLine(plain); if (!string.IsNullOrWhiteSpace(plain)) positioned.Add((plain.Trim(), mtxt.Position.X, mtxt.Position.Y, mtxt.Height)); } foreach (var blk in doc.Blocks) foreach (var attr in blk.AttributeDefinitions.Values) sb.AppendLine(attr.Value); // CIRCLE 중심 좌표 매핑 — TEXT가 원 안에 있으면 원 중심으로 치환 var circles = doc.Entities.Circles .Select(c => (X: c.Center.X, Y: c.Center.Y, R: c.Radius)) .ToList(); var circleCoords = new Dictionary(StringComparer.OrdinalIgnoreCase); foreach (var (val, x, y, _) in positioned) { double bestR = double.MaxValue; double cx = 0, cy = 0; foreach (var (ccx, ccy, cr) in circles) { var dx = x - ccx; var dy = y - ccy; var d = Math.Sqrt(dx * dx + dy * dy); if (d < cr && cr < bestR) { bestR = cr; cx = ccx; cy = ccy; } } if (bestR < double.MaxValue) circleCoords.TryAdd(val, (cx, cy)); } // 계기 풍선 재조합 var tagRe = new Regex(@"[A-Z]{1,6}-\d{2,6}(-[A-Z0-9]+)*", RegexOptions.IgnoreCase); var balloons = ReconstructBalloonTags(positioned); foreach (var (tag, _, _, _) in balloons) sb.AppendLine(tag); var text = FilterDxfText(sb.ToString()); // 필터 통과한 태그 집합 var filteredSet = new HashSet( text.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries), StringComparer.OrdinalIgnoreCase); // 좌표 맵 — 태그당 첫 좌표만 저장 (CIRCLE 내부면 원 중심 우선) var coords = new Dictionary(StringComparer.OrdinalIgnoreCase); foreach (var (val, x, y, h) in positioned) { if (!tagRe.IsMatch(val) || !filteredSet.Contains(val)) continue; if (circleCoords.TryGetValue(val, out var cc)) coords.TryAdd(val, (cc.X, cc.Y, h)); else coords.TryAdd(val, (x, y, h)); } foreach (var (tag, x, y, h) in balloons) { if (!filteredSet.Contains(tag)) continue; var hyphenPos = tag.IndexOf('-'); var func = hyphenPos > 0 ? tag[..hyphenPos] : null; var num = hyphenPos > 0 && hyphenPos + 1 < tag.Length ? tag[(hyphenPos + 1)..] : null; if ((func != null && circleCoords.TryGetValue(func, out var cc)) || (num != null && circleCoords.TryGetValue(num, out cc))) coords.TryAdd(tag, (cc.X, cc.Y, h)); else coords.TryAdd(tag, (x, y, h)); } return (text, coords); } finally { if (File.Exists(tmp)) File.Delete(tmp); } } /// /// DXF 텍스트에서 P&ID 태그 패턴에 해당하는 라인만 필터링 /// 불필요한 텍스트를 제거하여 MCP 서버 부하 감소 및 JSON 파싱 오류 방지 /// private string FilterDxfText(string text) { var lines = text.Split('\n'); var filteredLines = new List(); foreach (var line in lines) { var trimmed = line.Trim(); // P&ID 태그 패턴 포함 라인만 유지 // - 단일 글자 장비 태그 포함: P-10101, T-10100, E-10119, C-10111 // - 다중 글자 계측 태그: FCV-101, FICQ-6113, PSV-6203 // - 복합 태그: VG-6203-15A-F1A-n, CD-10513-40A if (Regex.IsMatch(trimmed, @"[A-Z]{1,6}-\d{2,6}(-[A-Z0-9]+)*", RegexOptions.IgnoreCase)) { filteredLines.Add(trimmed); } } return string.Join("\n", filteredLines); } // ISA 계기 기능코드: 측정변수(첫 글자) + 후속문자(1~3). // 첫 글자에 L·H 포함하되 후속문자 집합에서 L·H·P 제외 → LL/HH/HP 등 // 셋포인트·재질 라벨은 배제, FT/PT/LT/TR/TE/TIC/FICQ/PSV/PG 등은 통과. private static readonly Regex _instrFuncRe = new( @"^[FPLTASHQWVXZBDCRK][ICTREVYSAQGZ]{1,3}$", RegexOptions.Compiled); // 루프번호: 3~6자리 숫자 + 선택적 말미 접미 알파벳 (예: 9101, 9111A, 10900B) private static readonly Regex _loopNumRe = new( @"^\d{3,6}[A-Z]?$", RegexOptions.Compiled); /// /// 계기 풍선의 기능코드 TEXT와 루프번호 TEXT를 근접 좌표로 짝지어 /// FUNC-NUMBER 형태로 재조합. 임계값은 텍스트 높이에 비례시켜 /// 도면 스케일에 무관하게 동작. (TE/TI/TG가 같은 루프번호 공유 → 다대일 허용) /// private List<(string Tag, double X, double Y, double H)> ReconstructBalloonTags( List<(string Value, double X, double Y, double H)> texts) { var funcs = texts.Where(t => _instrFuncRe.IsMatch(t.Value)).ToList(); var nums = texts.Where(t => _loopNumRe.IsMatch(t.Value)).ToList(); if (funcs.Count == 0 || nums.Count == 0) return []; var result = new List<(string Tag, double X, double Y, double H)>(); var seen = new HashSet(StringComparer.OrdinalIgnoreCase); foreach (var f in funcs) { double bestDist = double.MaxValue; string? bestNum = null; double nx = 0, ny = 0; foreach (var n in nums) { var dx = f.X - n.X; var dy = f.Y - n.Y; var d = Math.Sqrt(dx * dx + dy * dy); if (d < bestDist) { bestDist = d; bestNum = n.Value; nx = n.X; ny = n.Y; } } var threshold = f.H > 0 ? f.H * 5.0 : 12.0; if (bestNum != null && bestDist <= threshold) { var tag = $"{f.Value}-{bestNum}"; if (seen.Add(tag)) result.Add((tag, (f.X + nx) / 2, (f.Y + ny) / 2, f.H)); } } return result; } private string ExtractPdfText(Stream stream) { using var pdf = PdfDocument.Open(stream); var sb = new StringBuilder(); foreach (var page in pdf.GetPages()) sb.AppendLine(page.Text); return sb.ToString(); } private List ParseJson(string json) { try { // MCP 서버 응답 형식: {"success": ..., "count": ..., "tags": [...]} // 또는 기존 형식: [...] using var doc = JsonDocument.Parse(json); var root = doc.RootElement; // "tags" 필드가 있으면 중첩 구조로 간주 if (root.TryGetProperty("tags", out var tagsElement)) { return JsonSerializer.Deserialize>(tagsElement.GetRawText(), new JsonSerializerOptions { PropertyNameCaseInsensitive = true }) ?? []; } // 루트가 배열이면 직접 파싱 if (root.ValueKind == JsonValueKind.Array) { return JsonSerializer.Deserialize>(json, new JsonSerializerOptions { PropertyNameCaseInsensitive = true }) ?? []; } _logger.LogWarning("P&ID JSON 파싱 실패: 'tags' 필드 또는 배열 형식 없음"); return []; } catch (Exception ex) { _logger.LogWarning("P&ID JSON 파싱 실패: {Msg} / raw: {Raw}", ex.Message, json[..Math.Min(200, json.Length)]); return []; } } private Dictionary ParseMappingJson(string json) { try { using var doc = JsonDocument.Parse(json); var root = doc.RootElement; // MCP 응답: {"success": ..., "count": ..., "mappings": [...]} JsonElement arrayEl = root.ValueKind == JsonValueKind.Array ? root : root.TryGetProperty("mappings", out var m) ? m : default; if (arrayEl.ValueKind != JsonValueKind.Array) return []; var list = JsonSerializer.Deserialize>(arrayEl.GetRawText(), new JsonSerializerOptions { PropertyNameCaseInsensitive = true }) ?? []; return list .Where(m => m.Confidence >= 0.7 && !string.IsNullOrEmpty(m.ExperionTag)) .ToDictionary(m => m.PidTag, m => m.ExperionTag!); } catch { return []; } } private async Task FindFallbackTagAsync(string tagNo) { var baseTag = tagNo.Split('.')[0]; return await _dbContext.RealtimePoints .FirstOrDefaultAsync(t => t.TagName.Equals(baseTag, StringComparison.OrdinalIgnoreCase) || t.TagName.StartsWith(baseTag + ".", StringComparison.OrdinalIgnoreCase)); } public async Task<(int Total, IEnumerable Items)> GetEquipmentAsync( string? tagNo, string? category, int page, int pageSize, string? sortBy = null, bool sortDesc = false) { var q = _dbContext.PidEquipment.AsQueryable(); if (!string.IsNullOrEmpty(tagNo)) q = q.Where(e => e.TagNo.Contains(tagNo)); if (!string.IsNullOrEmpty(category)) { if (category == "__unmatched__") q = q.Where(e => e.Category == null); else if (category == "instrument_dcs") q = q.Where(e => e.Category == PidEquipment.CategoryInstrument && e.TagDcs); else if (category == "instrument_field") q = q.Where(e => e.Category == PidEquipment.CategoryInstrument && !e.TagDcs); else q = q.Where(e => e.Category == category); } var total = await q.CountAsync(); List items; if (sortBy == "tagName") { // DB 전체 로드 후 C#에서 natural sort (prefix 알파 → 숫자 정수값 → suffix 알파) var all = await q.ToListAsync(); if (sortDesc) all = all.OrderByDescending(e => TagSortKey(e.TagNo)).ToList(); else all = all.OrderBy(e => TagSortKey(e.TagNo)).ToList(); items = all.Skip((page - 1) * pageSize).Take(pageSize).ToList(); } else { items = await q.OrderByDescending(e => e.ExtractedAt) .Skip((page - 1) * pageSize).Take(pageSize).ToListAsync(); } // batch-load sub_area from tag_metadata if (items.Count > 0) { var tagNos = items.Select(e => e.TagNo).ToHashSet(StringComparer.OrdinalIgnoreCase); var subAreas = await _dbContext.TagMetadata .Where(m => tagNos.Contains(m.BaseTag) && m.Attribute == "sub_area") .Select(m => new { m.BaseTag, m.Value }) .ToListAsync(); var subMap = subAreas.ToDictionary(sa => sa.BaseTag, sa => sa.Value, StringComparer.OrdinalIgnoreCase); foreach (var e in items) { if (subMap.TryGetValue(e.TagNo, out var sa)) e.SubArea = sa; } } return (total, items); } private static string TagSortKey(string tagNo) { // (알파 prefix)(숫자부)(알파 suffix) 분해 → 숫자를 정수로 비교 // TI-6111B → "ti-" + 0000006111 + "b" // TI-10103 → "ti-" + 0000010103 + "" var tag = tagNo.ToLowerInvariant(); var m = Regex.Match(tag, @"^([^\d]+)(\d+)([a-z]*)$"); if (!m.Success) return tag; var prefix = m.Groups[1].Value; var num = long.TryParse(m.Groups[2].Value, out var n) ? n : 0L; var suffix = m.Groups[3].Value; return $"{prefix}\x00{num:D12}\x00{suffix}"; } public async Task GetByIdAsync(long id) => await _dbContext.PidEquipment.Include(e => e.ExperionTag).FirstOrDefaultAsync(e => e.Id == id); public async Task UpdateConfidenceAsync(long id, double confidence) { var e = await _dbContext.PidEquipment.FindAsync(id); if (e == null) return; e.Confidence = confidence; e.UpdatedAt = DateTime.UtcNow; await _dbContext.SaveChangesAsync(); } public async Task CreateEquipmentAsync(CreateEquipmentRequest request) { var e = new PidEquipment { TagNo = request.TagNo, EquipmentName = request.EquipmentName, InstrumentType = request.InstrumentType, Category = request.Category, TagDcs = request.TagDcs ?? false, Role = request.Role, FromTag = request.FromTag, ToTag = request.ToTag, FromAt = request.FromAt, ToAt = request.ToAt, TagClass = request.TagClass ?? ClassifyTagClass(request.Category, request.TagDcs ?? false), IsActive = true, Confidence = 1.0, ExtractedAt = DateTime.UtcNow, UpdatedAt = DateTime.UtcNow }; if (request.FromTag != null || request.ToTag != null) e.ConnectionLocked = true; _dbContext.PidEquipment.Add(e); await _dbContext.SaveChangesAsync(); _logger.LogInformation("[PID] Created equipment id={Id} tagNo={Tag}", e.Id, e.TagNo); return e; } public async Task UpdateEquipmentAsync(long id, UpdateEquipmentRequest request) { var e = await _dbContext.PidEquipment.FindAsync(id); if (e == null) return false; if (request.TagNo != null) e.TagNo = request.TagNo; if (request.EquipmentName != null) e.EquipmentName = request.EquipmentName; if (request.InstrumentType != null) e.InstrumentType = request.InstrumentType; if (request.Category != null) e.Category = request.Category; if (request.TagDcs.HasValue) e.TagDcs = request.TagDcs.Value; if (request.Role != null) e.Role = request.Role; if (request.FromTag != null) e.FromTag = request.FromTag; if (request.ToTag != null) e.ToTag = request.ToTag; if (request.FromAt != null) e.FromAt = request.FromAt; if (request.ToAt != null) e.ToAt = request.ToAt; if (request.TagClass != null) e.TagClass = request.TagClass; if (request.Category != null || request.TagDcs.HasValue) e.TagClass = ClassifyTagClass(e.Category, e.TagDcs); if (request.FromTag != null || request.ToTag != null) e.ConnectionLocked = true; e.UpdatedAt = DateTime.UtcNow; await _dbContext.SaveChangesAsync(); if (request.SubArea != null) { var baseTag = e.TagNo; if (string.IsNullOrWhiteSpace(request.SubArea)) await _dbContext.Database.ExecuteSqlRawAsync( "DELETE FROM tag_metadata WHERE base_tag = {0} AND attribute = 'sub_area'", baseTag); else await _dbContext.Database.ExecuteSqlRawAsync( @"INSERT INTO tag_metadata (base_tag, attribute, value) VALUES ({0}, 'sub_area', {1}) ON CONFLICT (base_tag, attribute) DO UPDATE SET value = EXCLUDED.value, loaded_at = NOW()", baseTag, request.SubArea.Trim()); } return true; } public async Task ActivateAsync(long id) { var e = await _dbContext.PidEquipment.FindAsync(id); if (e == null) return; e.IsActive = true; e.UpdatedAt = DateTime.UtcNow; await _dbContext.SaveChangesAsync(); } public async Task DeactivateAsync(long id) { var e = await _dbContext.PidEquipment.FindAsync(id); if (e == null) return; e.IsActive = false; e.UpdatedAt = DateTime.UtcNow; await _dbContext.SaveChangesAsync(); } public async Task DeleteAsync(long id) { var e = await _dbContext.PidEquipment.FindAsync(id); if (e == null) return false; _dbContext.PidEquipment.Remove(e); await _dbContext.SaveChangesAsync(); return true; } public async Task<(bool Found, string? TagName)> AutoMapTagAsync(long id) { var e = await _dbContext.PidEquipment.FindAsync(id); if (e == null) return (false, null); var match = await FindFallbackTagAsync(e.TagNo ?? ""); if (match == null) return (false, null); e.ExperionTagId = match.Id; e.UpdatedAt = DateTime.UtcNow; await _dbContext.SaveChangesAsync(); return (true, match.TagName); } public async Task DeleteAllEquipmentAsync() { var count = await _dbContext.PidEquipment.CountAsync(); if (count == 0) return 0; await _dbContext.Database.ExecuteSqlRawAsync("DELETE FROM pid_equipment"); _logger.LogInformation("[PID] 전체 삭제 완료: {Count}건", count); return count; } public Task GetTotalCountAsync() => _dbContext.PidEquipment.CountAsync(); public Task GetConfidenceItemsCountAsync() => _dbContext.PidEquipment.CountAsync(e => e.Confidence >= 0.7); public Task GetLowConfidenceItemsCountAsync() => _dbContext.PidEquipment.CountAsync(e => e.Confidence < 0.5); public Task GetDrawingCountAsync() => _dbContext.PidEquipment.Select(e => e.PidDrawingNo).Distinct().CountAsync(); public async Task> GetConfidenceDistributionAsync() { var items = await _dbContext.PidEquipment.ToListAsync(); return new Dictionary { ["High (>=0.7)"] = items.Count(i => i.Confidence >= 0.7), ["Medium (0.5-0.7)"] = items.Count(i => i.Confidence >= 0.5 && i.Confidence < 0.7), ["Low (<0.5)"] = items.Count(i => i.Confidence < 0.5) }; } public async Task ExportToCsvAsync(IEnumerable items) { return await Task.Run(() => { var sb = new StringBuilder(); sb.AppendLine("TagNo,EquipmentName,InstrumentType,LineNumber,PidDrawingNo,Confidence,IsActive,ExtractedAt,ExperionTagId,Category,Role,From,To,TagClass"); foreach (var i in items) sb.AppendLine($"{Csv(i.TagNo)},{Csv(i.EquipmentName)},{Csv(i.InstrumentType)},{Csv(i.LineNumber)},{Csv(i.PidDrawingNo)},{i.Confidence},{i.IsActive},{i.ExtractedAt:O},{i.ExperionTagId},{Csv(i.Category)},{Csv(i.Role)},{Csv(i.FromTag)},{Csv(i.ToTag)},{Csv(i.TagClass)}"); return sb.ToString(); }); } private static string Csv(string? v) { if (string.IsNullOrEmpty(v)) return ""; return (v.Contains(',') || v.Contains('"') || v.Contains('\n')) ? $"\"{v.Replace("\"", "\"\"")}\"" : v; } public async Task ExportToExcelAsync(IEnumerable items) { return await Task.Run(async () => { using var package = new OfficeOpenXml.ExcelPackage(); var rules = await GetRulesCachedAsync(); var grouped = items .GroupBy(i => { var cat = string.IsNullOrEmpty(i.Category) ? "__unmatched__" : i.Category!; if (cat == PidEquipment.CategoryInstrument) return i.TagDcs ? "instrument_dcs" : "instrument_field"; return cat; }) .ToDictionary(g => g.Key, g => g.ToList()); var sheetOrder = new[] { "instrument_dcs", "instrument_field", PidEquipment.CategoryPowerEquipment, PidEquipment.CategoryStorageEquipment, PidEquipment.CategoryProcessEquipment, PidEquipment.CategoryUtilityEquipment, PidEquipment.CategoryPipings, "__unmatched__" }; var sheetNames = new Dictionary { ["instrument_dcs"] = "DCS 태그", ["instrument_field"] = "현장 계기", [PidEquipment.CategoryPowerEquipment] = "Power Equipment", [PidEquipment.CategoryStorageEquipment] = "Storage Equipment", [PidEquipment.CategoryProcessEquipment] = "Process Equipment", [PidEquipment.CategoryUtilityEquipment] = "Utility Equipment", [PidEquipment.CategoryPipings] = "Pipings", ["__unmatched__"] = "Unmatched" }; foreach (var cat in sheetOrder) { if (!grouped.TryGetValue(cat, out var groupItems) || groupItems.Count == 0) continue; var sheetName = sheetNames[cat]; var worksheet = package.Workbook.Worksheets.Add(sheetName); worksheet.Cells[1, 1].Value = "태그번호"; worksheet.Cells[1, 2].Value = "장비명"; worksheet.Cells[1, 3].Value = "장비타입"; worksheet.Cells[1, 4].Value = "라인번호"; worksheet.Cells[1, 5].Value = "도면번호"; worksheet.Cells[1, 6].Value = "신뢰도"; worksheet.Cells[1, 7].Value = "상태"; worksheet.Cells[1, 8].Value = "추출일시"; worksheet.Cells[1, 9].Value = "Experion 태그"; worksheet.Cells[1, 10].Value = "카테고리"; worksheet.Cells[1, 11].Value = "Role"; worksheet.Cells[1, 12].Value = "From"; worksheet.Cells[1, 13].Value = "To"; worksheet.Cells[1, 14].Value = "From_at"; worksheet.Cells[1, 15].Value = "To_at"; worksheet.Cells[1, 16].Value = "태그분류"; worksheet.Cells[1, 17].Value = "id"; // 안정 키(라운드트립 매칭용) — col17 고정 worksheet.Cells[1, 18].Value = "DCS태그"; // tag_dcs: DCS 함수블록 여부 using var headerRange = worksheet.Cells[1, 1, 1, 18]; headerRange.Style.Font.Bold = true; headerRange.Style.Fill.PatternType = OfficeOpenXml.Style.ExcelFillStyle.Solid; headerRange.Style.Fill.BackgroundColor.SetColor(System.Drawing.Color.LightGray); int row = 2; foreach (var item in groupItems) { worksheet.Cells[row, 1].Value = item.TagNo; worksheet.Cells[row, 2].Value = ResolveEquipmentName(item.InstrumentType, rules) ?? item.EquipmentName ?? ""; worksheet.Cells[row, 3].Value = item.InstrumentType ?? ""; worksheet.Cells[row, 4].Value = item.LineNumber ?? ""; worksheet.Cells[row, 5].Value = item.PidDrawingNo ?? ""; worksheet.Cells[row, 6].Value = item.Confidence; worksheet.Cells[row, 7].Value = item.IsActive ? "활성" : "비활성"; worksheet.Cells[row, 8].Value = item.ExtractedAt; worksheet.Cells[row, 9].Value = item.ExperionTag?.TagName ?? ""; worksheet.Cells[row, 10].Value = item.Category ?? ""; worksheet.Cells[row, 11].Value = item.Role ?? ""; worksheet.Cells[row, 12].Value = item.FromTag ?? ""; worksheet.Cells[row, 13].Value = item.ToTag ?? ""; worksheet.Cells[row, 14].Value = item.FromAt ?? ""; worksheet.Cells[row, 15].Value = item.ToAt ?? ""; worksheet.Cells[row, 16].Value = item.TagClass switch { PidEquipment.TagClassSystem => "시스템(DCS)", PidEquipment.TagClassField => "현장", _ => "" }; worksheet.Cells[row, 17].Value = item.Id; // 안정 키(라운드트립 매칭용) worksheet.Cells[row, 18].Value = item.TagDcs ? "DCS" : "현장"; row++; } worksheet.Cells.AutoFitColumns(); } return package.GetAsByteArray(); }); } /// /// 편집 엑셀(ExportToExcelAsync 포맷, 17컬럼) → pid_equipment UPSERT. /// 매칭 키 = id(col17, 안정 키). id 있으면 그 행만 in-place UPDATE(다중경로 보존), /// id 비어있으면 신규 INSERT. col17 헤더가 "id"가 아닌 옛 파일은 태그번호(col1) 매칭으로 폴백. /// 빈 셀 → null 로 기록(엑셀에서 값을 비우면 DB에서도 삭제 = 라운드트립 교정 가능). /// 갱신 컬럼: 장비명·장비타입·라인번호·도면번호·신뢰도·상태·카테고리·Role·From·To·From_at·To_at·태그분류. /// 읽기전용(미반영): 추출일시(col8), Experion 태그(col9). /// 갱신 행은 ConnectionLocked=true 로 표시되어, 이후 AnalyzeConnectionsAsync /// 재실행 시 From/To 초기화·재계산에서 제외(사람 교정 보호). /// public async Task ImportFromExcelAsync(Stream stream) { // EPPlus 는 비동기 시킹 스트림이 필요해 메모리로 복사 using var ms = new MemoryStream(); await stream.CopyToAsync(ms); ms.Position = 0; using var package = new OfficeOpenXml.ExcelPackage(ms); int sheets = 0, rowsRead = 0, rowsUpdated = 0, rowsInserted = 0; var unmatched = new List(); // 인덱스: id(안정 키, in-place 갱신) + TagNo(옛 파일 폴백) var all = await _dbContext.PidEquipment.ToListAsync(); var byId = all.ToDictionary(e => e.Id); var byTag = all .Where(e => !string.IsNullOrWhiteSpace(e.TagNo)) .GroupBy(e => e.TagNo.Trim(), StringComparer.OrdinalIgnoreCase) .ToDictionary(g => g.Key, g => g.ToList(), StringComparer.OrdinalIgnoreCase); static string? Norm(OfficeOpenXml.ExcelWorksheet ws, int r, int c) { var t = ws.Cells[r, c].Text?.Trim(); return string.IsNullOrEmpty(t) ? null : t; } foreach (var ws in package.Workbook.Worksheets) { // 데이터 시트만 처리 (헤더 col1 == "태그번호") if (ws.Dimension == null) continue; if (!string.Equals(ws.Cells[1, 1].Text?.Trim(), "태그번호", StringComparison.Ordinal)) continue; // col17 헤더가 "id" 면 안정 키 매칭, 아니면 옛 포맷(태그번호 매칭)으로 폴백 bool hasIdCol = string.Equals(ws.Cells[1, 17].Text?.Trim(), "id", StringComparison.Ordinal); // col18 헤더가 "DCS태그" 면 tag_dcs 읽기 bool hasDcsCol = string.Equals(ws.Cells[1, 18].Text?.Trim(), "DCS태그", StringComparison.Ordinal); sheets++; for (int r = 2; r <= ws.Dimension.End.Row; r++) { var tagNo = Norm(ws, r, 1); if (tagNo == null) continue; rowsRead++; var equipName = Norm(ws, r, 2); var instType = Norm(ws, r, 3); var lineNo = Norm(ws, r, 4); var drawingNo = Norm(ws, r, 5); var confTxt = Norm(ws, r, 6); var stateTxt = Norm(ws, r, 7); var category = Norm(ws, r, 10); var role = Norm(ws, r, 11); var fromTag = Norm(ws, r, 12); var toTag = Norm(ws, r, 13); var fromAt = Norm(ws, r, 14); var toAt = Norm(ws, r, 15); var clsTxt = Norm(ws, r, 16); double? conf = double.TryParse(confTxt, out var cv) ? cv : null; bool? active = stateTxt switch { "활성" => true, "비활성" => false, _ => (bool?)null }; var tagClass = clsTxt switch { "시스템(DCS)" => PidEquipment.TagClassSystem, "현장" => PidEquipment.TagClassField, _ => null }; void Apply(PidEquipment e) { e.EquipmentName = equipName; e.InstrumentType = instType; e.LineNumber = lineNo; e.PidDrawingNo = drawingNo; if (conf.HasValue) e.Confidence = conf.Value; if (active.HasValue) e.IsActive = active.Value; e.Category = category; e.Role = role; e.FromTag = fromTag; e.ToTag = toTag; e.FromAt = fromAt; e.ToAt = toAt; e.TagClass = tagClass; // col18: DCS태그 (DCS=true, 현장=false). 헤더 없는 옛 파일은 기존 값 유지. if (hasDcsCol) { var dcsVal = Norm(ws, r, 18); e.TagDcs = dcsVal == "DCS"; } // From/To 를 채운 행만 잠금(사람이 연결을 교정한 행). // 둘 다 비우면 잠금 해제 → 연결분석이 다시 도출 가능. e.ConnectionLocked = fromTag != null || toTag != null; e.UpdatedAt = DateTime.UtcNow; } if (hasIdCol) { // id 있으면 그 행만 in-place UPDATE(다중경로 보존), 비어있으면 신규 INSERT var idTxt = Norm(ws, r, 17); if (idTxt != null && long.TryParse(idTxt, out var rid) && byId.TryGetValue(rid, out var hit)) { Apply(hit); rowsUpdated++; } else { var ne = new PidEquipment { TagNo = tagNo }; Apply(ne); _dbContext.PidEquipment.Add(ne); rowsInserted++; } } else { // 옛 포맷(id 컬럼 없음): TagNo 매칭 — 같은 TagNo 다중 행이면 전부 갱신 if (!byTag.TryGetValue(tagNo, out var recs)) { if (unmatched.Count < 200) unmatched.Add(tagNo); continue; } foreach (var e in recs) { Apply(e); rowsUpdated++; } } } } await _dbContext.SaveChangesAsync(); _logger.LogInformation( "[PID Import] 시트 {Sheets} · 읽음 {Read} · 갱신 {Upd} · 신규 {Ins} · 미매칭 {Un}", sheets, rowsRead, rowsUpdated, rowsInserted, unmatched.Count); return new PidImportResult { SheetsProcessed = sheets, RowsRead = rowsRead, RowsUpdated = rowsUpdated, RowsInserted = rowsInserted, Unmatched = unmatched.Count, UnmatchedTags = unmatched }; } /// /// 장비타입(InstrumentType: PSV, TR, C 등) → prefix 규칙 설명(Description). /// 1) prefix 정확 매칭 우선 (예: 타입 "DP" → 규칙 "DP"=Differential Pressure, /// 규칙 "DP-"=Diaphragm Pump 충돌을 정확매칭으로 해소) /// 2) 없으면 후행 '-' 제거 매칭 (예: 타입 "C" → 규칙 "C-"=Column) /// 매칭 실패 시 null → 호출부에서 기존 EquipmentName 유지. /// private static string? ResolveEquipmentName(string? instrumentType, List rules) { if (string.IsNullOrWhiteSpace(instrumentType)) return null; var it = instrumentType.Trim(); var exact = rules .Where(r => r.Prefix.Trim().Equals(it, StringComparison.OrdinalIgnoreCase)) .OrderBy(r => r.SortOrder).FirstOrDefault(); if (exact != null) return exact.Description ?? exact.Prefix; var stripped = rules .Where(r => r.Prefix.Trim().TrimEnd('-').Equals(it, StringComparison.OrdinalIgnoreCase)) .OrderBy(r => r.SortOrder).FirstOrDefault(); return stripped == null ? null : (stripped.Description ?? stripped.Prefix); } // ── Prefix Rule Cache ────────────────────────────────────────────────────── private async Task> GetRulesCachedAsync() { var rules = _cachedRules; if (rules != null) return rules; await _cacheLock.WaitAsync(); try { rules = _cachedRules; if (rules != null) return rules; rules = await _dbContext.PidPrefixRules .OrderByDescending(r => r.Prefix.Length) .ThenBy(r => r.SortOrder) .ToListAsync(); _cachedRules = rules; return rules; } finally { _cacheLock.Release(); } } private void InvalidateRulesCache() { Interlocked.Exchange(ref _cachedRules, null); } // 배관번호 패턴: SERVICE-LINENUM-SIZE(숫자+알파벳)-... 3번째 필드에 파이프 사이즈 존재 private static readonly Regex _pipeLineNoRe = new( @"^[A-Z][A-Z0-9]{0,3}-\d{3,6}-\d{1,4}[A-Za-z]-", RegexOptions.Compiled); private async Task MatchCategoryAsync(string tagNo) { if (_pipeLineNoRe.IsMatch(tagNo)) return PidEquipment.CategoryPipings; var rules = await GetRulesCachedAsync(); return rules.FirstOrDefault(r => tagNo.StartsWith(r.Prefix, StringComparison.OrdinalIgnoreCase))?.Category; } /// /// prefix rule에서 tag_dcs 값을 조회. StartsWith 매칭으로 compound형(FICQ/FICA 등) 자동 커버. /// 가장 긴 prefix 우선(FIC보다 FICQ가 더 구체적이면 FICQ rule 우선). /// private async Task ResolveTagDcsAsync(string tagNo) { var rules = await GetRulesCachedAsync(); var upper = tagNo.ToUpperInvariant(); var rule = rules .Where(r => upper.StartsWith(r.Prefix.ToUpperInvariant())) .OrderByDescending(r => r.Prefix.Length) // 가장 긴 prefix 우선 .FirstOrDefault(); return rule?.TagDcs ?? false; } /// /// 계기(instrument) 하위 분류. tag_dcs(prefix rule)가 단일 기준. /// tag_dcs=TRUE → system (DCS 함수블록: FIC/TIC/PIC류) /// tag_dcs=FALSE → field (현장 물리 계기: FT/FCV류) /// instrument 이외 카테고리는 null. /// private static string? ClassifyTagClass(string? category, bool tagDcs) { if (category != PidEquipment.CategoryInstrument) return null; return tagDcs ? PidEquipment.TagClassSystem : PidEquipment.TagClassField; } // ── Prefix Rule CRUD ─────────────────────────────────────────────────────── public async Task> GetPrefixRulesAsync() { return await _dbContext.PidPrefixRules .OrderBy(r => r.SortOrder) .ThenBy(r => r.Prefix) .ToListAsync(); } public async Task CreatePrefixRuleAsync(CreatePidPrefixRuleRequest request) { var rule = new PidPrefixRule { Prefix = request.Prefix.Trim(), Category = request.Category, TagDcs = request.TagDcs, Description = request.Description?.Trim(), SortOrder = request.SortOrder, CreatedAt = DateTime.UtcNow, UpdatedAt = DateTime.UtcNow }; _dbContext.PidPrefixRules.Add(rule); await _dbContext.SaveChangesAsync(); InvalidateRulesCache(); return rule; } public async Task UpdatePrefixRuleAsync(int id, UpdatePidPrefixRuleRequest request) { var rule = await _dbContext.PidPrefixRules.FindAsync(id); if (rule == null) return null; rule.Prefix = request.Prefix.Trim(); rule.Category = request.Category; rule.TagDcs = request.TagDcs; rule.Description = request.Description?.Trim(); rule.SortOrder = request.SortOrder; rule.UpdatedAt = DateTime.UtcNow; await _dbContext.SaveChangesAsync(); InvalidateRulesCache(); return rule; } public async Task DeletePrefixRuleAsync(int id) { var rule = await _dbContext.PidPrefixRules.FindAsync(id); if (rule == null) return false; _dbContext.PidPrefixRules.Remove(rule); await _dbContext.SaveChangesAsync(); InvalidateRulesCache(); return true; } public async Task ApplyCategoriesToExistingAsync() { const int batchSize = 1000; int total = 0; long lastId = 0; while (true) { var batch = await _dbContext.PidEquipment .Where(e => e.Id > lastId) .OrderBy(e => e.Id) .Take(batchSize) .ToListAsync(); if (!batch.Any()) break; foreach (var item in batch) { var category = await MatchCategoryAsync(item.TagNo); var tagDcs = await ResolveTagDcsAsync(item.TagNo); var tagClass = ClassifyTagClass(category, tagDcs); if (item.Category != category || item.TagDcs != tagDcs || item.TagClass != tagClass) { item.Category = category; item.TagDcs = tagDcs; item.TagClass = tagClass; item.UpdatedAt = DateTime.UtcNow; total++; } lastId = item.Id; } await _dbContext.SaveChangesAsync(); } return total; } // ── 연결 분석 (from→to) ──────────────────────────────────────────────────── // 범례 영역 제외 private const double LegendXThreshold = 4500; private const double LegendYThreshold = 5400; private const double MaxConnectionDistance = 300.0; private static readonly Regex _loopNumExtractRe = new(@"-(\d{3,})", RegexOptions.Compiled); private static string? ExtractLoopNumber(string tagNo) { var m = _loopNumExtractRe.Match(tagNo); return m.Success ? m.Groups[1].Value : null; } // mcp-server/sim_line_connection.py 가 생성하는 연결 JSON 후보 경로 private static readonly string[] _connJsonRoots = { "mcp-server/storage", "../mcp-server/storage", "../../mcp-server/storage" }; /// /// P&ID 연결 분석 — 방향표지판(off-page connector) 태그매칭 + 방향성 기반. /// 기존 loop+최단거리 anchor 방식을 폐기하고 다음 2단계로 재작성: /// 1) Python geometric 파이프라인 산출물(_connections.json)의 유향 엣지 적용 /// — ▶ 마커 V자방향(좌→우)으로 from_tag/to_tag 방향성까지 부여 /// 2) JSON 없거나 미커버 레코드: pipings line_number 체인(PosX 오름차순=하류)으로 보강 /// geometric V검출은 ezdxf 보유한 Python에 잔류, C#은 적용·영속만 담당(기존 아키텍처와 일치). /// public async Task AnalyzeConnectionsAsync(string drawingFile) { var items = await _dbContext.PidEquipment .Where(e => e.DrawingFile == drawingFile && e.IsActive) .ToListAsync(); if (items.Count == 0) return 0; // 재실행 결정성: 기존 연결 초기화 후 재계산. // 단, 엑셀 import 로 사람이 교정한 행(ConnectionLocked)은 보존. foreach (var it in items.Where(i => !i.ConnectionLocked)) { it.FromTag = null; it.ToTag = null; } // TagNo → 레코드(동일 태그 다중 위치 가능) 인덱스 var byTag = items .Where(i => !string.IsNullOrWhiteSpace(i.TagNo)) .GroupBy(i => i.TagNo.Trim(), StringComparer.OrdinalIgnoreCase) .ToDictionary(g => g.Key, g => g.ToList(), StringComparer.OrdinalIgnoreCase); int edgeApplied = 0; var touched = new HashSet(); // ── 1. Python 유향 엣지 JSON 적용 ────────────────────────────── var edges = LoadDirectedEdges(drawingFile); foreach (var (fromTag, toTag) in edges) { var f = NormalizeConnTag(fromTag); var t = NormalizeConnTag(toTag); if (f.Length == 0 || t.Length == 0 || string.Equals(f, t, StringComparison.OrdinalIgnoreCase)) continue; // from 레코드(들)의 ToTag = t, to 레코드(들)의 FromTag = f if (byTag.TryGetValue(f, out var fromRecs)) foreach (var r in fromRecs) if (!r.ConnectionLocked && r.ToTag == null) { r.ToTag = t; touched.Add(r.Id); edgeApplied++; } if (byTag.TryGetValue(t, out var toRecs)) foreach (var r in toRecs) if (!r.ConnectionLocked && r.FromTag == null) { r.FromTag = f; touched.Add(r.Id); } } // ── 2. pipings line_number 체인 보강 (PosX 오름차순 = 하류 방향) ── int chainApplied = 0; var lineGroups = items .Where(i => !string.IsNullOrWhiteSpace(i.LineNumber) && i.PosX.HasValue && string.Equals(i.Category, PidEquipment.CategoryPipings, StringComparison.OrdinalIgnoreCase)) .GroupBy(i => i.LineNumber!.Trim(), StringComparer.OrdinalIgnoreCase) .Where(g => g.Count() >= 2); foreach (var g in lineGroups) { var ordered = g.OrderBy(i => i.PosX!.Value).ToList(); for (int k = 0; k < ordered.Count - 1; k++) { var a = ordered[k]; var b = ordered[k + 1]; if (string.Equals(a.TagNo, b.TagNo, StringComparison.OrdinalIgnoreCase)) continue; if (!a.ConnectionLocked && a.ToTag == null) { a.ToTag = b.TagNo; chainApplied++; } if (!b.ConnectionLocked && b.FromTag == null) { b.FromTag = a.TagNo; } } } await _dbContext.SaveChangesAsync(); int connectionCount = touched.Count + chainApplied; _logger.LogInformation( "[PID 분석] 연결 완료: 총 {Count}개 (JSON엣지 {Edge}건/{Touched}레코드, line_number체인 {Chain}건, drawing={File})", connectionCount, edgeApplied, touched.Count, chainApplied, drawingFile); return connectionCount; } /// _connections.json 의 edges[] → (from,to) 목록. 없으면 빈 목록. private List<(string From, string To)> LoadDirectedEdges(string drawingFile) { var result = new List<(string, string)>(); var prefix = drawingFile.Split('_', '.').FirstOrDefault() ?? drawingFile; var fileName = $"{prefix}_connections.json"; string? path = _connJsonRoots .Select(r => Path.Combine(r, fileName)) .FirstOrDefault(File.Exists); if (path == null) { _logger.LogInformation("[PID 분석] 연결 JSON 없음 (prefix={Prefix}) — line_number 체인만 사용", prefix); return result; } try { using var doc = JsonDocument.Parse(File.ReadAllText(path)); if (doc.RootElement.TryGetProperty("edges", out var edgesEl) && edgesEl.ValueKind == JsonValueKind.Array) { foreach (var e in edgesEl.EnumerateArray()) { var from = e.TryGetProperty("from", out var fe) ? fe.GetString() : null; var to = e.TryGetProperty("to", out var te) ? te.GetString() : null; if (!string.IsNullOrWhiteSpace(from) && !string.IsNullOrWhiteSpace(to)) result.Add((from!, to!)); } } _logger.LogInformation("[PID 분석] 연결 JSON 로드: {Path} ({Count} 엣지)", path, result.Count); } catch (Exception ex) { _logger.LogWarning(ex, "[PID 분석] 연결 JSON 파싱 실패: {Path}", path); } return result; } // 유효 P&ID 태그 패턴 (예: C-10111, FCV-10101). VLINE 플레이스홀더·한글 // 시설명·짧은 TEXT 조각(마커 태그 오할당 노이즈)을 from/to 에서 배제. private static readonly Regex _validTagRe = new(@"^[A-Za-z]{1,6}-\d{2,6}[A-Za-z]?$", RegexOptions.Compiled); /// "P-10101@2050,5289" → "P-10101". 좌표 접미사/공백 제거 후 유효성 검사. 무효면 빈 문자열. private static string NormalizeConnTag(string tag) { if (string.IsNullOrWhiteSpace(tag)) return string.Empty; var at = tag.IndexOf('@'); var bare = (at >= 0 ? tag[..at] : tag).Trim(); return _validTagRe.IsMatch(bare) ? bare : string.Empty; } } // ── 내부 파싱용 모델 ────────────────────────────────────────────────────────── public class ExtractedItem { public string TagNo { get; set; } = ""; public string? EquipmentName { get; set; } public string? InstrumentType { get; set; } public string? LineNumber { get; set; } public string? PidDrawingNo { get; set; } public double Confidence { get; set; } = 0.5; } public class MappingItem { public string PidTag { get; set; } = ""; public string? ExperionTag { get; set; } public double Confidence { get; set; } }