- FeedforwardSupervisor: PvTag() ToUpperInvariant + empty FeedTag 가드 - FeedforwardConfigStore: 모든 ToLowerInvariant() 제거 - FeedRampAdvisorService: ToLowerInvariant 제거 + StringComparison.OrdinalIgnoreCase - SimOverrideStore: ToLowerInvariant 제거 - Hc900RealtimeService: HealthCheck SERVING 판정, mapping 없는 태그 대소문자 유지 - PidExtractorService: ToLowerInvariant → OrdinalIgnoreCase 비교 - Hc900Entities: 주석 업데이트 (대문자 표준) - load_state_labels.py: 소문자 변환 금지, controller_id 파라미터 추가 - Hc900Controllers: 대소문자 무시 정렬 - write.js: .MODE → AutoManState/RemLocSPState/SP_SelectState/TuneSetState - setup.js/html: 중복 함수 제거, C5 컨트롤러 placeholder
1244 lines
53 KiB
C#
1244 lines
53 KiB
C#
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<PidExtractorService> _logger;
|
|
private readonly SemaphoreSlim _cacheLock = new(1, 1);
|
|
private List<PidPrefixRule>? _cachedRules;
|
|
|
|
public PidExtractorService(McpClient mcp, Hc900DbContext dbContext, ILogger<PidExtractorService> logger)
|
|
{
|
|
_mcp = mcp;
|
|
_dbContext = dbContext;
|
|
_logger = logger;
|
|
}
|
|
|
|
public async Task<PidExtractionResult> ExtractFromFileAsync(string filePath, bool useImageMode = false)
|
|
{
|
|
await using var stream = File.OpenRead(filePath);
|
|
return await ExtractFromStreamAsync(stream, Path.GetFileName(filePath), useImageMode);
|
|
}
|
|
|
|
public async Task<PidExtractionResult> ExtractFromStreamAsync(Stream stream, string fileName, bool useImageMode = false)
|
|
{
|
|
var ext = Path.GetExtension(fileName).ToLowerInvariant();
|
|
|
|
string text;
|
|
Dictionary<string, (double X, double Y, double H)>? 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<string>(
|
|
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<PidEquipment>();
|
|
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<string, (double X, double Y, double H)> 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<string, (double X, double Y)>(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<string>(
|
|
text.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries),
|
|
StringComparer.OrdinalIgnoreCase);
|
|
|
|
// 좌표 맵 — 태그당 첫 좌표만 저장 (CIRCLE 내부면 원 중심 우선)
|
|
var coords = new Dictionary<string, (double X, double Y, double H)>(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);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// DXF 텍스트에서 P&ID 태그 패턴에 해당하는 라인만 필터링
|
|
/// 불필요한 텍스트를 제거하여 MCP 서버 부하 감소 및 JSON 파싱 오류 방지
|
|
/// </summary>
|
|
private string FilterDxfText(string text)
|
|
{
|
|
var lines = text.Split('\n');
|
|
var filteredLines = new List<string>();
|
|
|
|
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);
|
|
|
|
/// <summary>
|
|
/// 계기 풍선의 기능코드 TEXT와 루프번호 TEXT를 근접 좌표로 짝지어
|
|
/// <c>FUNC-NUMBER</c> 형태로 재조합. 임계값은 텍스트 높이에 비례시켜
|
|
/// 도면 스케일에 무관하게 동작. (TE/TI/TG가 같은 루프번호 공유 → 다대일 허용)
|
|
/// </summary>
|
|
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<string>(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<ExtractedItem> 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<List<ExtractedItem>>(tagsElement.GetRawText(),
|
|
new JsonSerializerOptions { PropertyNameCaseInsensitive = true }) ?? [];
|
|
}
|
|
|
|
// 루트가 배열이면 직접 파싱
|
|
if (root.ValueKind == JsonValueKind.Array)
|
|
{
|
|
return JsonSerializer.Deserialize<List<ExtractedItem>>(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<string, string> 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<List<MappingItem>>(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<RealtimePoint?> 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<PidEquipment> 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<PidEquipment> 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<PidEquipment?> 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<PidEquipment> 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<bool> 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<bool> 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<int> 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<int> GetTotalCountAsync() => _dbContext.PidEquipment.CountAsync();
|
|
public Task<int> GetConfidenceItemsCountAsync() => _dbContext.PidEquipment.CountAsync(e => e.Confidence >= 0.7);
|
|
public Task<int> GetLowConfidenceItemsCountAsync() => _dbContext.PidEquipment.CountAsync(e => e.Confidence < 0.5);
|
|
public Task<int> GetDrawingCountAsync() => _dbContext.PidEquipment.Select(e => e.PidDrawingNo).Distinct().CountAsync();
|
|
|
|
public async Task<IDictionary<string, int>> GetConfidenceDistributionAsync()
|
|
{
|
|
var items = await _dbContext.PidEquipment.ToListAsync();
|
|
return new Dictionary<string, int>
|
|
{
|
|
["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<string> ExportToCsvAsync(IEnumerable<PidEquipment> 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<byte[]> ExportToExcelAsync(IEnumerable<PidEquipment> 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<string, string>
|
|
{
|
|
["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();
|
|
});
|
|
}
|
|
|
|
/// <summary>
|
|
/// 편집 엑셀(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 초기화·재계산에서 제외(사람 교정 보호).
|
|
/// </summary>
|
|
public async Task<PidImportResult> 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<string>();
|
|
|
|
// 인덱스: 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
|
|
};
|
|
}
|
|
|
|
/// <summary>
|
|
/// 장비타입(InstrumentType: PSV, TR, C 등) → prefix 규칙 설명(Description).
|
|
/// 1) prefix 정확 매칭 우선 (예: 타입 "DP" → 규칙 "DP"=Differential Pressure,
|
|
/// 규칙 "DP-"=Diaphragm Pump 충돌을 정확매칭으로 해소)
|
|
/// 2) 없으면 후행 '-' 제거 매칭 (예: 타입 "C" → 규칙 "C-"=Column)
|
|
/// 매칭 실패 시 null → 호출부에서 기존 EquipmentName 유지.
|
|
/// </summary>
|
|
private static string? ResolveEquipmentName(string? instrumentType, List<PidPrefixRule> 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<List<PidPrefixRule>> 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<string?> 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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// prefix rule에서 tag_dcs 값을 조회. StartsWith 매칭으로 compound형(FICQ/FICA 등) 자동 커버.
|
|
/// 가장 긴 prefix 우선(FIC보다 FICQ가 더 구체적이면 FICQ rule 우선).
|
|
/// </summary>
|
|
private async Task<bool> 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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// 계기(instrument) 하위 분류. tag_dcs(prefix rule)가 단일 기준.
|
|
/// tag_dcs=TRUE → system (DCS 함수블록: FIC/TIC/PIC류)
|
|
/// tag_dcs=FALSE → field (현장 물리 계기: FT/FCV류)
|
|
/// instrument 이외 카테고리는 null.
|
|
/// </summary>
|
|
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<List<PidPrefixRule>> GetPrefixRulesAsync()
|
|
{
|
|
return await _dbContext.PidPrefixRules
|
|
.OrderBy(r => r.SortOrder)
|
|
.ThenBy(r => r.Prefix)
|
|
.ToListAsync();
|
|
}
|
|
|
|
public async Task<PidPrefixRule> 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<PidPrefixRule?> 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<bool> 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<int> 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" };
|
|
|
|
/// <summary>
|
|
/// P&ID 연결 분석 — 방향표지판(off-page connector) 태그매칭 + 방향성 기반.
|
|
/// 기존 loop+최단거리 anchor 방식을 폐기하고 다음 2단계로 재작성:
|
|
/// 1) Python geometric 파이프라인 산출물(<prefix>_connections.json)의 유향 엣지 적용
|
|
/// — ▶ 마커 V자방향(좌→우)으로 from_tag/to_tag 방향성까지 부여
|
|
/// 2) JSON 없거나 미커버 레코드: pipings line_number 체인(PosX 오름차순=하류)으로 보강
|
|
/// geometric V검출은 ezdxf 보유한 Python에 잔류, C#은 적용·영속만 담당(기존 아키텍처와 일치).
|
|
/// </summary>
|
|
public async Task<int> 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<long>();
|
|
|
|
// ── 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;
|
|
}
|
|
|
|
/// <summary><prefix>_connections.json 의 edges[] → (from,to) 목록. 없으면 빈 목록.</summary>
|
|
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);
|
|
|
|
/// <summary>"P-10101@2050,5289" → "P-10101". 좌표 접미사/공백 제거 후 유효성 검사. 무효면 빈 문자열.</summary>
|
|
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; }
|
|
}
|