feat: P&ID 연결 엑셀 라운드트립 — id 안정 키 + 운전자 문서규칙

- ExportToExcelAsync: 17번째 컬럼 id(pid_equipment.id) 추가 (col1~16 위치 불변)
- ImportFromExcelAsync: id 우선 매칭 — id 있으면 그 행만 in-place UPDATE
  (다중경로 보존), 빈 id면 INSERT, col17 헤더가 'id'가 아닌 옛 파일은 tag_no 폴백
- PidImportResult.RowsInserted 추가 + import 로그 신규건수 포함
- 구조설명-6-2차플랜트-byPBK.xlsx 문서규칙: upsert_pid_connection(9bcba0a) 연동
  규칙으로 슬림화 (콤마=병렬 병합, 카테고리 매핑, 멱등/잠금/변경금지는 도구가 처리)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
windpacer
2026-05-24 21:34:24 +09:00
parent 9bcba0a317
commit 50705ab0e8
4 changed files with 48 additions and 17 deletions

View File

@@ -23,6 +23,7 @@ public record PidImportResult
public int SheetsProcessed { get; init; } public int SheetsProcessed { get; init; }
public int RowsRead { get; init; } public int RowsRead { get; init; }
public int RowsUpdated { get; init; } public int RowsUpdated { get; init; }
public int RowsInserted { get; init; }
public int Unmatched { get; init; } public int Unmatched { get; init; }
public List<string> UnmatchedTags { get; init; } = new(); public List<string> UnmatchedTags { get; init; } = new();
} }

View File

@@ -532,8 +532,9 @@ public class PidExtractorService : IPidExtractorService
worksheet.Cells[1, 14].Value = "From_at"; worksheet.Cells[1, 14].Value = "From_at";
worksheet.Cells[1, 15].Value = "To_at"; worksheet.Cells[1, 15].Value = "To_at";
worksheet.Cells[1, 16].Value = "태그분류"; worksheet.Cells[1, 16].Value = "태그분류";
worksheet.Cells[1, 17].Value = "id";
using var headerRange = worksheet.Cells[1, 1, 1, 16]; using var headerRange = worksheet.Cells[1, 1, 1, 17];
headerRange.Style.Font.Bold = true; headerRange.Style.Font.Bold = true;
headerRange.Style.Fill.PatternType = OfficeOpenXml.Style.ExcelFillStyle.Solid; headerRange.Style.Fill.PatternType = OfficeOpenXml.Style.ExcelFillStyle.Solid;
headerRange.Style.Fill.BackgroundColor.SetColor(System.Drawing.Color.LightGray); headerRange.Style.Fill.BackgroundColor.SetColor(System.Drawing.Color.LightGray);
@@ -564,6 +565,7 @@ public class PidExtractorService : IPidExtractorService
PidEquipment.TagClassField => "현장", PidEquipment.TagClassField => "현장",
_ => "" _ => ""
}; };
worksheet.Cells[row, 17].Value = item.Id; // 안정 키(라운드트립 매칭용)
row++; row++;
} }
@@ -575,8 +577,9 @@ public class PidExtractorService : IPidExtractorService
} }
/// <summary> /// <summary>
/// 편집 엑셀(ExportToExcelAsync 포맷, 16컬럼) → pid_equipment UPSERT. /// 편집 엑셀(ExportToExcelAsync 포맷, 17컬럼) → pid_equipment UPSERT.
/// 매칭 키 = 태그번호(col1, 대소문자 무시·trim). 같은 TagNo 다중 행이면 전부 갱신. /// 매칭 키 = id(col17, 안정 키). id 있으면 그 행만 in-place UPDATE(다중경로 보존),
/// id 비어있으면 신규 INSERT. col17 헤더가 "id"가 아닌 옛 파일은 태그번호(col1) 매칭으로 폴백.
/// 빈 셀 → null 로 기록(엑셀에서 값을 비우면 DB에서도 삭제 = 라운드트립 교정 가능). /// 빈 셀 → null 로 기록(엑셀에서 값을 비우면 DB에서도 삭제 = 라운드트립 교정 가능).
/// 갱신 컬럼: 장비명·장비타입·라인번호·도면번호·신뢰도·상태·카테고리·Role·From·To·From_at·To_at·태그분류. /// 갱신 컬럼: 장비명·장비타입·라인번호·도면번호·신뢰도·상태·카테고리·Role·From·To·From_at·To_at·태그분류.
/// 읽기전용(미반영): 추출일시(col8), Experion 태그(col9). /// 읽기전용(미반영): 추출일시(col8), Experion 태그(col9).
@@ -592,11 +595,12 @@ public class PidExtractorService : IPidExtractorService
using var package = new OfficeOpenXml.ExcelPackage(ms); using var package = new OfficeOpenXml.ExcelPackage(ms);
int sheets = 0, rowsRead = 0, rowsUpdated = 0; int sheets = 0, rowsRead = 0, rowsUpdated = 0, rowsInserted = 0;
var unmatched = new List<string>(); var unmatched = new List<string>();
// TagNo → DB 레코드(들) 인덱스 // 인덱스: id(안정 키, in-place 갱신) + TagNo(옛 파일 폴백)
var all = await _dbContext.PidEquipment.ToListAsync(); var all = await _dbContext.PidEquipment.ToListAsync();
var byId = all.ToDictionary(e => e.Id);
var byTag = all var byTag = all
.Where(e => !string.IsNullOrWhiteSpace(e.TagNo)) .Where(e => !string.IsNullOrWhiteSpace(e.TagNo))
.GroupBy(e => e.TagNo.Trim(), StringComparer.OrdinalIgnoreCase) .GroupBy(e => e.TagNo.Trim(), StringComparer.OrdinalIgnoreCase)
@@ -615,6 +619,9 @@ public class PidExtractorService : IPidExtractorService
if (!string.Equals(ws.Cells[1, 1].Text?.Trim(), "태그번호", if (!string.Equals(ws.Cells[1, 1].Text?.Trim(), "태그번호",
StringComparison.Ordinal)) StringComparison.Ordinal))
continue; continue;
// col17 헤더가 "id" 면 안정 키 매칭, 아니면 옛 포맷(태그번호 매칭)으로 폴백
bool hasIdCol = string.Equals(ws.Cells[1, 17].Text?.Trim(), "id",
StringComparison.Ordinal);
sheets++; sheets++;
for (int r = 2; r <= ws.Dimension.End.Row; r++) for (int r = 2; r <= ws.Dimension.End.Row; r++)
@@ -623,12 +630,6 @@ public class PidExtractorService : IPidExtractorService
if (tagNo == null) continue; if (tagNo == null) continue;
rowsRead++; rowsRead++;
if (!byTag.TryGetValue(tagNo, out var recs))
{
if (unmatched.Count < 200) unmatched.Add(tagNo);
continue;
}
var equipName = Norm(ws, r, 2); var equipName = Norm(ws, r, 2);
var instType = Norm(ws, r, 3); var instType = Norm(ws, r, 3);
var lineNo = Norm(ws, r, 4); var lineNo = Norm(ws, r, 4);
@@ -657,7 +658,7 @@ public class PidExtractorService : IPidExtractorService
_ => null _ => null
}; };
foreach (var e in recs) void Apply(PidEquipment e)
{ {
e.EquipmentName = equipName; e.EquipmentName = equipName;
e.InstrumentType = instType; e.InstrumentType = instType;
@@ -676,21 +677,50 @@ public class PidExtractorService : IPidExtractorService
// 둘 다 비우면 잠금 해제 → 연결분석이 다시 도출 가능. // 둘 다 비우면 잠금 해제 → 연결분석이 다시 도출 가능.
e.ConnectionLocked = fromTag != null || toTag != null; e.ConnectionLocked = fromTag != null || toTag != null;
e.UpdatedAt = DateTime.UtcNow; e.UpdatedAt = DateTime.UtcNow;
rowsUpdated++; }
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(); await _dbContext.SaveChangesAsync();
_logger.LogInformation( _logger.LogInformation(
"[PID Import] 시트 {Sheets} · 읽음 {Read} · 갱신 {Upd}레코드 · 미매칭 {Un}", "[PID Import] 시트 {Sheets} · 읽음 {Read} · 갱신 {Upd} · 신규 {Ins} · 미매칭 {Un}",
sheets, rowsRead, rowsUpdated, unmatched.Count); sheets, rowsRead, rowsUpdated, rowsInserted, unmatched.Count);
return new PidImportResult return new PidImportResult
{ {
SheetsProcessed = sheets, SheetsProcessed = sheets,
RowsRead = rowsRead, RowsRead = rowsRead,
RowsUpdated = rowsUpdated, RowsUpdated = rowsUpdated,
RowsInserted = rowsInserted,
Unmatched = unmatched.Count, Unmatched = unmatched.Count,
UnmatchedTags = unmatched UnmatchedTags = unmatched
}; };

View File

@@ -336,8 +336,8 @@ public class PidController : ControllerBase
await using var stream = file.OpenReadStream(); await using var stream = file.OpenReadStream();
var result = await _pidExtractor.ImportFromExcelAsync(stream); var result = await _pidExtractor.ImportFromExcelAsync(stream);
_logger.LogInformation( _logger.LogInformation(
"[PID] 엑셀 import: {File} → {Upd}레코드 갱신, {Un}건 미매칭", "[PID] 엑셀 import: {File} → {Upd}건 갱신, {Ins}건 신규, {Un}건 미매칭",
file.FileName, result.RowsUpdated, result.Unmatched); file.FileName, result.RowsUpdated, result.RowsInserted, result.Unmatched);
return Ok(result); return Ok(result);
} }
catch (Exception ex) catch (Exception ex)

Binary file not shown.