chore: 프로젝트 파일 구조 정리 - 루트 파일 폴더별 이동, 테스트/구버전 삭제

루트 파일 정리:
- DXF/P&ID 관련 → dxf-graph/
- fastTable 관련 → fastTable/
- plan/ → plans/ 통합 (최신 버전 유지)
- 테스트 출력 파일, 구버전 프로젝트 삭제
- 불필요한 루트 문서 삭제
This commit is contained in:
windpacer
2026-05-10 17:39:58 +09:00
parent f73ec217ad
commit 7330711499
79 changed files with 1371 additions and 10074 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,440 @@
# 포인트빌더 개선 방안 - 코딩 Todo List
## 현황
이전 세션에서 부분적 수정이 적용됨 (pb-n9/n10 제거, custom input 2개 추가 등).
이번 작업은 **전면 재구성**으로, 기존 코드를 완전히 대체합니다.
---
## Step 1: HTML - 포인트빌더 pane 전면 재구성
**변경 파일:** `src/Web/wwwroot/index.html`
### 작업 내용
1. 기존 "조건으로 테이블 작성" card 내부 완전 교체
2. 5개 그룹 카드 생성:
- 컨트롤러 포인트 #1 (`pb-group-controller1`)
- 아날로그 모니터링 포인트 #2 (`pb-group-analogmon1`)
- 디지털 포인트 #1 (`pb-group-digital1`)
- 디지털 포인트 #2 (`pb-group-digital2`)
- 사용자 정의 (`pb-group-custom`)
3. 각 그룹 카드 구성:
- 그룹명 제목 (`card-sub-cap`)
- 태그명 패턴 input (placeholder 포함)
- 속성 체크박스: pv, op, sp, md
- 사용자 정의 속성 input 2개 (작은 크기)
- 데이터 타입 select (전체, Double, i=7594, Boolean, String, Int16, Int32, UInt16, UInt32, Float, DateTime)
4. 수동 포인트 추가, 실시간 구독 제어, 메타데이터 관리 → 유지
5. 포인트 목록 → 유지
### 완성 기준
- [x] 기존 `pb-n1`~`pb-n8` select 드롭다운 제거됨 — **이미 완료** (이전 세션)
- [x] 기존 `pb-dt1`, `pb-dt2` input 제거됨 — **이미 완료**
- [x] 기존 `pb-custom1`, `pb-custom2` input 제거됨 — **이미 완료**
- [x] `pbLoad()` 버튼 제거됨 — **이미 완료**
- [x] 5개 그룹 카드 HTML 생성됨 — **이미 완료** (`index.html:421-598`)
- [x] 각 그룹에 태그명 패턴 input 1개 존재 — **이미 완료**
- [x] 각 그룹에 pv/op/sp/md 체크박스 4개 존재 — **이미 완료**
- [x] 각 그룹에 사용자 속성 input 2개 존재 — **이미 완료**
- [x] 각 그룹에 데이터 타입 select 존재 — **이미 완료**
- [x] 수동 포인트 추가 pane 유지됨 — **이미 완료**
- [x] 실시간 구독 제어 pane 유지됨 — **이미 완료**
- [x] 메타데이터 관리 pane 유지됨 — **이미 완료**
- [x] 포인트 목록 pane 유지됨 — **이미 완료**
- [x] HTML 유효성 검사 통과 (브라우저에서 렌더링 오류 없음) — **이미 완료**
---
## Step 2: CSS - 그룹 카드, 체크박스, 입력창 스타일
**변경 파일:** `src/Web/wwwroot/css/style.css`
### 작업 내용
1. `.pb-group-card` — 그룹 카드 컨테이너 스타일
2. `.pb-group-header` — 그룹명 + 활성화 체크박스
3. `.pb-pattern-input` — 태그명 패턴 input (전체 너비)
4. `.pb-attr-checkboxes` — 속성 체크박스 행 (flex)
5. `.pb-custom-attr-inputs` — 사용자 속성 input 2개 (작은 크기)
6. `.pb-datatype-select` — 데이터 타입 select
### 완성 기준
- [ ] CSS 클래스 정의됨
- [ ] 체크박스 라벨 정렬 정상
- [ ] 작은 input 2개는 inline 배치
- [ ] 반응형 레이아웃 유지 (900px 이하)
- [ ] 기존 스타일과 충돌 없음
---
## Step 3: JS - pbBuild() 그룹 기반 전송 로직
**변경 파일:** `src/Web/wwwroot/js/app.js`
### 작업 내용
1. **중복 `pbBuild()` 함수 삭제 (L671-686)** — 🔴 치명적 버그
- 두 번째 `pbBuild()``PB_NAME_IDS`(미정의 상수)를 참조하여 `ReferenceError` 발생
- JS에서同名 function declaration은 후자가 전자를 덮어씀 → 첫 번째(정상) `pbBuild()`가 동작하지 않음
2. 새 그룹 ID 상수 정의:
- `PB_GROUPS = ['controller1', 'analogmon1', 'digital1', 'digital2', 'custom']`
3. `pbCollectGroupData(groupKey)` 함수:
- 해당 그룹의 태그명 패턴 input 읽기 (쉼표 분할)
- 체크된 속성 체크박스 읽기
- 사용자 속성 input 2개 읽기
- 데이터 타입 select 읽기
- `{ tagPatterns: [], attributes: [], dataType: string|null }` 반환
4. `pbBuild()` 함수 재작성 (첫 번째 정의만 유지, L599-630):
- 5개 그룹 데이터 수집
- 빈 그룹(태그명 패턴 없음)은 제외
- `POST /api/pointbuilder/build` 전송
- 응답 처리 동일 유지
5. `pbRefresh()`, `pbRender()`, `pbAddManual()`, `pbDelete()` → 유지
### 완성 기준
- [x] `PB_GROUPS` 상수 정의됨 — **이미 완료** (`app.js:575`)
- [x] `pbCollectGroupData()` 함수 정의됨 — **이미 완료** (`app.js:577-597`)
- [x] `pbBuild()` 함수 정의됨 — **이미 완료** (`app.js:599-630`)
- [x] 쉼표 구분 패턴 분할 로직 작동 — **이미 완료**
- [x] 체크박스 상태 정확히 읽기 — **이미 완료**
- [x] JSON 구조: `{ controller1: {...}, analogmon1: {...}, ... }` (groups 래퍼 없음) — **이미 완료**
- [x] 빈 그룹은 전송 제외 — **이미 완료**
- [x] **중복 `pbBuild()` (L671-686) 삭제****완료** (2026-05-10)
- [x] 콘솔 에러 없음
---
## Step 4: DTO - PointBuilderGroupDto, PointBuilderBuildDto 재정의
**변경 파일:** `src/Core/Application/DTOs/ExperionDtos.cs`
### 작업 내용
1. 기존 `PointBuilderBuildDto` 클래스 제거
2.`PointBuilderGroupDto` 클래스 추가:
```csharp
public class PointBuilderGroupDto
{
public List<string> TagPatterns { get; set; } = new();
public List<string> Attributes { get; set; } = new();
public string? DataType { get; set; }
}
```
3. 새 `PointBuilderBuildDto` 클래스:
```csharp
public class PointBuilderBuildDto
{
public PointBuilderGroupDto Controller1 { get; set; } = new();
[JsonPropertyName("analogmon1")]
public PointBuilderGroupDto AnalogMonitor1 { get; set; } = new();
public PointBuilderGroupDto Digital1 { get; set; } = new();
public PointBuilderGroupDto Digital2 { get; set; } = new();
public PointBuilderGroupDto Custom { get; set; } = new();
}
```
### 완성 기준
- [x] 기존 `PointBuilderBuildDto` (Names, DataTypes, CustomPatterns) 제거됨 — **이미 완료**
- [x] `PointBuilderGroupDto` 클래스 정의됨 — **이미 완료** (`ExperionDtos.cs:54-59`)
- [x] 새 `PointBuilderBuildDto` 클래스 정의됨 — **이미 완료** (`ExperionDtos.cs:61-69`)
- [x] JSON camelCase 속성: `tagPatterns`, `attributes`, `dataType` — **이미 완료**
- [x] `[JsonPropertyName("analogmon1")]` 속성 적용 — **이미 완료** (`ExperionDtos.cs:64`)
- [x] 빌드 컴파일 성공 — **이미 완료**
---
## Step 5: Interface - BuildRealtimeTableAsync 시그니처 변경
**변경 파일:** `src/Core/Application/Interfaces/IExperionServices.cs`
### 작업 내용
1. 기존 시그니처:
```csharp
Task<int> BuildRealtimeTableAsync(IEnumerable<string> names, IEnumerable<string> dataTypes, IEnumerable<string> customPatterns);
```
2. 새 시그니처:
```csharp
Task<int> BuildRealtimeTableAsync(IEnumerable<PointBuilderGroupDto> groups);
```
### 완성 기준
- [x] 시그니처 변경됨 — **이미 완료** (구현체 `ExperionDbContext.cs:514`가 이미 해당 시그니처 사용)
- [x] `PointBuilderGroupDto` 참조 가능 — **이미 완료**
- [x] 빌드 컴파일 성공 — **이미 완료**
---
## Step 6: DB - BuildRealtimeTableAsync 구현
**변경 파일:** `src/Infrastructure/Database/ExperionDbContext.cs`
### ⚠️ 진단 결과: 기존 EF Core LINQ 구현 유지
Plan이 UNION + Raw SQL 방식을 제안했으나, 진단 결과:
1. 기존 EF Core LINQ 구현(`ExperionDbContext.cs:514-573`)이 이미 정상 동작 중
2. 제안된 Raw SQL 코드는 `.Join()` 컴파일 에러 포함
3. Raw SQL 방식은 SQL Injection 위험 (parameterized query 아님)
4. 기존 LINQ 방식이 type-safe + EF Core 추적 + 테스트 커버리지 보유
**결정: 기존 코드 유지, 변경 없음.**
### 현재 구현 (이미 완료)
```csharp
public async Task<int> BuildRealtimeTableAsync(IEnumerable<PointBuilderGroupDto> groups)
{
var activeGroups = groups.Where(g =>
g.TagPatterns != null && g.TagPatterns.Count > 0
).ToList();
if (activeGroups.Count == 0)
return 0;
var allSources = new List<NodeMapMaster>();
foreach (var g in activeGroups)
{
var patterns = g.TagPatterns.Where(p => !string.IsNullOrEmpty(p)).ToList();
var attrs = g.Attributes.Where(a => !string.IsNullOrEmpty(a)).ToList();
if (patterns.Count == 0) continue;
var q = _ctx.NodeMapMasters.Where(x => x.Level == 3);
var patternList = patterns;
q = q.Where(x => patternList.Any(p => EF.Functions.Like(x.NodeId, p)));
if (attrs.Count > 0)
{
var attrList = attrs;
q = q.Where(x => attrList.Contains(x.Name));
}
if (!string.IsNullOrEmpty(g.DataType))
{
var dt = g.DataType;
q = q.Where(x => x.DataType == dt);
}
var sources = await q.ToListAsync();
allSources.AddRange(sources);
}
var distinctSources = allSources
.GroupBy(s => s.NodeId)
.Select(g => g.First())
.ToList();
await _ctx.Database.ExecuteSqlRawAsync(
"TRUNCATE TABLE realtime_table RESTART IDENTITY");
var points = distinctSources.Select(s => new RealtimePoint
{
TagName = ExtractTagName(s.NodeId),
NodeId = s.NodeId,
LiveValue = null,
Timestamp = DateTime.UtcNow
}).ToList();
await _ctx.RealtimePoints.AddRangeAsync(points);
var saved = await _ctx.SaveChangesAsync();
_logger.LogInformation("[ExperionDb] realtime_table 빌드: {Count}건", saved);
return saved;
}
```
### 완성 기준
- [x] 그룹별 EF.Core LINQ 쿼리 구현됨 — **이미 완료** (`ExperionDbContext.cs:514-573`)
- [x] level = 3 필터 고정 적용 — **이미 완료**
- [x] TagPatterns → EF.Functions.Like OR 조건 — **이미 완료**
- [x] Attributes → Contains (name IN) 조건 — **이미 완료**
- [x] DataType → 등가 조건 (null 생략) — **이미 완료**
- [x] 빈 그룹은 쿼리에서 제외 — **이미 완료**
- [x] 중복 제거 (GroupBy nodeId) — **이미 완료**
- [x] TRUNCATE 후 INSERT 로직 — **이미 완료**
- [x] 빌드 컴파일 성공 — **이미 완료**
---
## Step 7: Controller - DTO 매핑
**변경 파일:** `src/Web/Controllers/ExperionControllers.cs`
### 작업 내용
1. `Build` 액션 수정:
- 기존: `await _dbSvc.BuildRealtimeTableAsync(dto.Names, dto.DataTypes, dto.CustomPatterns)`
- 새: `await _dbSvc.BuildRealtimeTableAsync([dto.Controller1, dto.AnalogMonitor1, dto.Digital1, dto.Digital2, dto.Custom])`
### 완성 기준
- [x] Build 액션에서 DTO 그룹 배열 전달 — **이미 완료** (`ExperionControllers.cs:290-293`)
- [x] 빌드 컴파일 성공 — **이미 완료**
---
## Step 8: 빌드 및 검증
### 작업 내용
1. `dotnet build src/Web/ExperionCrawler.csproj`
2. 빌드 성공 확인 (0 warning, 0 error)
3. HTML 파일 브라우저에서 렌더링 확인 (선택)
### 완성 기준
- [ ] 빌드 성공
- [ ] 0 warning
- [ ] 0 error
- [ ] 모든 변경 파일 반영됨
---
## 진행 상태 추적
| Step | 상태 | 완료 기준 충족 | 비고 |
|------|------|---------------|------|
| 1. HTML | ✅ 완료 | 전체 체크 | 이전 세션에서 완료 |
| 2. CSS | ✅ 완료 | - | `width: auto` 이미 적용 |
| 3. JS | ✅ 완료 | 전체 체크 | 중복 pbBuild() 삭제 완료 |
| 4. DTO | ✅ 완료 | 전체 체크 | 이미 구현 완료 |
| 5. Interface | ✅ 완료 | 전체 체크 | 이미 구현 완료 |
| 6. DB | ✅ 완료 | 전체 체크 | 기존 EF Core LINQ 유지 |
| 7. Controller | ✅ 완료 | 전체 체크 | 이미 구현 완료 |
| 8. 빌드 | ✅ 완료 | 전체 체크 | 0 Warning, 0 Error |
---
## 진단 결과 (2026-05-10 진단 체크리스트 8단계 적용)
> diagnosis-checklist.md STEP 1~8 순서대로 실행. STEP 6 교차 검증(Q1~Q4) 적용.
---
### 🔴 1. JS 중복 `pbBuild()` — 치명적 버그, 즉시 처리 필요 (HIGH)
**문제**: `app.js`에 `pbBuild()` 함수가 2개 정의되어 있음 (L599, L671). JS에서同名 function declaration은 후자가 전자를 덮어씁니다. L671의 `pbBuild()`는 `PB_NAME_IDS`, `PB_DT_IDS`, `PB_CUSTOM_IDS`를 참조하는데 이 상수들은 어디에도 정의되어 있지 않습니다. 즉 "테이블 작성하기" 버튼 클릭 시 즉시 `ReferenceError: PB_NAME_IDS is not defined` 발생.
**근거**: `app.js:671-686` — 두 번째 `pbBuild()` 정의; `PB_NAME_IDS` 미정의
**영향**: "테이블 작성하기" 버튼 클릭 시 즉시 JS 에러, 포인트 빌드 불가
**수정**: Step 3에 "중복 `pbBuild()` (L671-686) 삭제" 작업 항목 추가 — **본 문서에 반영 완료**
---
### ✅ 2. JS JSON 구조와 C# DTO 불일치 — 오진, 실제 코드에서는 문제 없음 (Q4 탈락)
**문제**: 이전 진단에서 "JS가 `{ groups: {...} }` 래퍼를 전송하여 C# 역직렬화 실패"라고 지적함.
하지만 실제 코드(`app.js:599-618`)는 `groups` 객체를 직접 전송합니다:
```js
const groups = {};
for (const gk of PB_GROUPS) { groups[gk] = gd; }
await api('POST', '/api/pointbuilder/build', groups);
```
C# DTO(`ExperionDtos.cs:64`)는 `[JsonPropertyName("analogmon1")]`으로 JS 키와 일치하므로 실제로 문제 없음.
**근거**: `app.js:599-618` — groups 래퍼 없이 직접 전송; `ExperionDtos.cs:64` — `[JsonPropertyName("analogmon1")]` 존재
**교차 검증**: STEP 6 Q4("실제 장애 시나리오가 있는가?") 탈락 — 재현 불가
**결론**: 이전 진단 삭제, 문서에서 제거
---
### ✅ 3. `.Join()` 컴파일 에러 — Plan 제안 코드의 문제, 실제 코드는 아님 (Q1 탈락)
**문제**: 이전 진단에서 `.Join(separator)` 미존재 메서드를 지적함. 그러나 이 코드는 `ExperionDbContext.cs`의 실제 구현이 아닙니다. 실제 코드는 EF Core LINQ를 사용하며 `Join()`을 전혀 호출하지 않음.
**근거**: `ExperionDbContext.cs:514-573` — 실제 코드는 EF Core LINQ 사용; Plan Step 6 제안 코드에만 `.Join()` 존재
**교차 검증**: STEP 6 Q1("이미 수정된 문제인가?") 탈락 — 실제 코드에 존재하지 않음
**결론**: Plan Step 6에서 UNION+Raw SQL 제안 삭제, 기존 EF Core LINQ 유지 — **본 문서에 반영 완료**
---
### ✅ 4. Union Raw SQL 변경 — 불필요한 변경 (Q3 탈락)
**문제**: Plan Step 6이 기존 EF Core LINQ를 Raw SQL UNION으로 대체하라고 제안함. 그러나:
1. 기존 LINQ 방식이 이미 정상 동작 중
2. 제안된 Raw SQL 방식은 SQL Injection 위험
3. 제안 코드는 컴파일 에러 포함
**근거**: `ExperionDbContext.cs:514-573` — 기존 EF Core LINQ 구현 정상 동작 중
**교차 검증**: STEP 6 Q3("의도적 설계인가?") — EF Core LINQ가 의도적 설계
**결론**: Step 6에서 Raw SQL 제안 삭제 — **본 문서에 반영 완료**
---
### ✅ 5. `PB_NAME_IDS` 상수 제거 — 이미 존재하지 않음 (Q1 탈락)
**문제**: Plan Step 3에서 "기존 `PB_NAME_IDS`, `PB_DT_IDS`, `PB_CUSTOM_IDS` 상수 제거"라고 함. 그러나 이 상수들은 현재 코드베이스에 존재하지 않음.
**근거**: `app.js` 전체 grep 결과 `PB_NAME_IDS` 미존재
**교차 검증**: STEP 6 Q1("이미 수정된 문제인가?") 탈락
**결론**: Step 3 작업 내용에서 해당 문구 제거 — **본 문서에 반영 완료**
---
### ✅ 6. `pbLoad()` 함수 제거 — 이미 존재하지 않음 (Q1 탈락)
**문제**: Plan Step 3에서 "기존 `pbLoad()` 함수 제거"라고 함. 그러나 `app.js`에 `pbLoad()` 함수는 존재하지 않음.
**근거**: `app.js` 전체 grep 결과 `pbLoad` 미존재
**교차 검증**: STEP 6 Q1("이미 수정된 문제인가?") 탈락
**결론**: Step 3 작업 내용에서 해당 문구 제거 — **본 문서에 반영 완료**
---
### ✅ 7. Step 1 완성 기준 — 이미 완료됨 (Q1 탈락)
**문제**: `pb-n1`~`pb-n8` select, `pb-dt1`, `pb-dt2`, `pb-custom1`, `pb-custom2` 제거를 검증 항목으로 포함함. 그러나 현재 HTML에는 이 요소들이 이미 존재하지 않음.
**근거**: `index.html:400-600` — 해당 ID의 요소 없음
**교차 검증**: STEP 6 Q1("이미 수정된 문제인가?") 탈락
**결론**: 완성 기준에서 "[x] 이미 완료"로 표시 — **본 문서에 반영 완료**
---
### ✅ 8. Step 5 Interface 시그니처 — 이미 완료됨 (Q1 탈락)
**문제**: Plan Step 5에서 시그니처 변경을 미완료로 표시함. 그러나 구현체(`ExperionDbContext.cs:514`)가 이미 해당 시그니처를 사용하고 있음.
**근거**: `ExperionDbContext.cs:514` — 이미 `IEnumerable<PointBuilderGroupDto>` 시그니처
**교차 검증**: STEP 6 Q1("이미 수정된 문제인가?") 탈락
**결론**: Step 5에서 "[x] 이미 완료"로 표시 — **본 문서에 반영 완료**
---
### 🟡 참고 — LIKE 패턴 내 `%`, `_` 메타문자
**문제**: EF.Functions.Like에서 `_`가 LIKE 와일드카드(임의 1문자)로 동작. `p-602` 대신 `p602`도 매칭될 수 있음.
**판단**: 사용자가 `%`를 직접 입력하는 방식으로 결정. `_` 문제는 현장에서 실용적으로 문제없음 → **수용**
---
### ✅ 이상 없는 항목
- Step 5 인터페이스 시그니처 변경: 구현체·컨트롤러와 일관됨 — **이미 완료**
- Step 7 컨트롤러 배열 전달: C# 12 컬렉션 표현식 `[...]` 문법 — .NET 8 이상에서 정상 컴파일 — **이미 완료**
- Step 6 TRUNCATE 후 INSERT 흐름: 정상 — **이미 완료**
- Step 3 빈 그룹 제외 로직: Step 6의 `activeGroups.Count == 0` 가드와 일관됨 — **이미 완료**
---
## 최종 요약
| 등급 | 수 | 내용 |
|------|---|------|
| 🔴 HIGH | 1 | 중복 `pbBuild()` (L671-686) 삭제 필요 — 즉시 처리 |
| ✅ 오진 | 7 | 이미 완료됨, 존재하지 않음, 재현 불가 → 문서 반영 완료 |
| 🟡 참고 | 1 | LIKE `_` 메타문자 — 수용 |
**현재 남은 작업:**
1. 🔴 `app.js` L671-686 중복 `pbBuild()` 삭제
2. Step 2 CSS: `.pb-custom-attr-inputs .inp`에 `width: auto` 추가 (이미 완료됨)
3. 빌드 검증 (`dotnet build`)

View File

@@ -0,0 +1,689 @@
# 포인트빌더 개선 방안 - 코딩 2
## 1. 문제 정의
현재 "테이블 작성하기" 버튼은 조건에 맞는 포인트를 즉시 `realtime_table`에 TRUNCATE + INSERT 합니다.
**문제:**
- 빌드 결과가 의도와 다른지 확인할 수 없음
- 잘못된 조건으로 빌드 시 기존 liveValue 데이터 손실
- 521,958개 node_map_master 레코드 중 실수로 전체 선택 시 대규모 데이터 삽입 가능
**목표:** 빌드 전에 결과 미리보기 → 개별 체크 → 원하는 것만 선택하여 적용
---
## 2. 효율적 UI 구성 방안
### 2.1 왜 모달(팝업)이 아닌 인라인 확장인가?
| 고려사항 | 모달 | 인라인 확장 |
|----------|------|-------------|
| 포인트 수 | 수백~수천 개 | 동일 |
| 스크롤 | 모달 내부 스크롤 + 페이지 스크롤 충돌 | 자연스러운 페이지 흐름 |
| 체크 상태 유지 | 모달 닫으면 초기화 | 유지 가능 |
| 테이블 비교 | 기존 테이블과 별도 창 | 바로 아래에 표시, 비교 용이 |
| 모바일 | 화면 절반 가림 | 자연스럽게 스크롤 |
**결정: 인라인 확장** — "테이블 작성하기" 옆에 "미리보기" 버튼 추가, 결과를 기존 포인트 목록 위에 인라인으로 표시.
### 2.2 UI 플로우
```
┌─────────────────────────────────────────────────────────────────────┐
│ 조건으로 테이블 작성 │
│ ┌─ 컨트롤러 포인트 #1 ──────────────────────────────────────────────┐ │
│ │ ... (조건 입력) │ │
│ └───────────────────────────────────────────────────────────────────┘ │
│ ┌─ 아날로그 모니터링 포인트 #2 ────────────────────────────────────┐ │
│ │ ... (조건 입력) │ │
│ └───────────────────────────────────────────────────────────────────┘ │
│ (나머지 그룹...) │
│ │
│ [🔍 미리보기] [🔨 테이블 작성하기] [📋 테이블 조회] │
│ │
│ ▼ (미리보기 클릭 후 인라인 표시) │
│ ┌───────────────────────────────────────────────────────────────────┐ │
│ │ 미리보기 결과 (127개 포인트) │ │
│ │ [전체 선택] [전체 해제] [역전] 선택: 127/127 │ │
│ │ ┌──┬ ID ┬ TagName ┬ NodeType ┬ DataType┐ │ │
│ │ │☑ │ 1 │ FICQ-2113.PV │ pv │ Double │ │ │
│ │ │☑ │ 2 │ FICQ-2113.OP │ op │ Double │ │ │
│ │ │☐ │ 3 │ FICQ-2113.SP │ sp │ Double │ │ │
│ │ │☑ │ 4 │ TIC-2101.PV │ pv │ Double │ │ │
│ │ │ │ ... │ ... │ ... │ ... │ │ │
│ │ └──┴─────┴──────────────────┴──────────┴─────────┘ │ │
│ │ [취소] [선택된 126개 적용하기] │ │
│ └───────────────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────┘
```
### 2.3 핵심 UX 결정
1. **미리보기는 READ-ONLY**: DB를 변경하지 않음. node_map_master에서 조건에 맞는 레코드만 조회
2. **기본 전체 체크**: 미리보기 시 모든 포인트가 체크된 상태로 표시 (대부분의 경우 전체 적용)
3. **그룹별 색상 라벨**: 어떤 그룹 조건에서 매칭되었는지 표시
4. **검색/필터**: 미리보기 테이블에 태그명 검색 입력창 제공 (수천 개 중 찾기)
5. **"테이블 작성하기"는 유지**: 기존처럼 조건 → 즉시 빌드 (미리보기 없이 빠른 빌드)
---
## 3. 아키텍처 변경
### 3.1 변경 파일
| 파일 | 변경 내용 |
|------|----------|
| `src/Web/wwwroot/index.html` | 미리보기 버튼 + 인라인 미리보기 영역 추가 |
| `src/Web/wwwroot/js/app.js` | `pbPreview()`, `pbCancelPreview()`, `pbApplySelected()`, `pbRenderPreview()` 추가 |
| `src/Web/wwwroot/css/style.css` | `.pb-preview` 스타일 추가 |
| `src/Core/Application/DTOs/ExperionDtos.cs` | `PointBuilderPreviewResultDto` 추가 |
| `src/Core/Application/Interfaces/IExperionServices.cs` | `PreviewRealtimeBuildAsync()` 인터페이스 추가 |
| `src/Infrastructure/Database/ExperionDbContext.cs` | `PreviewRealtimeBuildAsync()` 구현 |
| `src/Web/Controllers/ExperionControllers.cs` | `POST /api/pointbuilder/preview`, `POST /api/pointbuilder/apply` 추가 |
### 3.2 API 엔드포인트
```
GET /api/pointbuilder/points — 기존 유지 (realtime_table 조회)
POST /api/pointbuilder/build — 기존 유지 (즉시 빌드)
POST /api/pointbuilder/preview — NEW: 조건에 맞는 포인트 조회 (DB 변경 없음)
POST /api/pointbuilder/apply — NEW: 선택된 포인트만 realtime_table에 적용
POST /api/pointbuilder/add — 기존 유지 (수동 추가)
DELETE /api/pointbuilder/{id} — 기존 유지 (삭제)
```
### 3.3 데이터 흐름
```
┌────────────────────────────────────────────────────────────────────┐
│ [미리보기] 클릭 │
│ POST /api/pointbuilder/preview ← groups (기존 build와 동일) │
│ ↓ │
│ C#: PreviewRealtimeBuildAsync() │
│ → 각 그룹별 node_map_master 쿼리 (기존 BuildRealtimeTableAsync와 │
│ 동일한 쿼리 로직, 하지만 TRUNCATE/INSERT 없이 결과만 반환) │
│ → 중복 제거 (GroupBy nodeId) │
│ → { count, items: [{ nodeId, tagName, name, dataType, group }] } │
│ ↓ │
│ JS: pbRenderPreview() — 체크박스 테이블 렌더링 │
│ ↓ │
│ 사용자가 체크/해제 → [선택된 N개 적용하기] 클릭 │
│ ↓ │
│ POST /api/pointbuilder/apply ← { selectedNodeIds: [...] } │
│ ↓ │
│ C#: ApplySelectedPointsAsync() │
│ → TRUNCATE realtime_table │
│ → selectedNodeIds만 INSERT │
│ → pbRefresh() → 포인트 목록 갱신 │
└────────────────────────────────────────────────────────────────────┘
```
---
## 4. 코딩 Todo List
### Step 1: C# DTO — Preview 결과 DTO 추가
**변경 파일:** `src/Core/Application/DTOs/ExperionDtos.cs`
```csharp
public class PointBuilderPreviewItem
{
public string NodeId { get; set; } = string.Empty;
public string TagName { get; set; } = string.Empty;
public string Name { get; set; } = string.Empty; // pv, op, sp, md 등
public string DataType { get; set; } = string.Empty;
public string Group { get; set; } = string.Empty; // 어떤 그룹에서 매칭
}
public class PointBuilderPreviewResult
{
public int Count { get; set; }
public List<PointBuilderPreviewItem> Items { get; set; } = new();
}
```
**완성 기준:**
- [ ] 두 클래스 추가됨
- [ ] 빌드 컴파일 성공
---
### Step 2: C# Interface — Preview 메서드 추가
**변경 파일:** `src/Core/Application/Interfaces/IExperionServices.cs`
```csharp
Task<PointBuilderPreviewResult> PreviewRealtimeBuildAsync(IEnumerable<(string GroupKey, PointBuilderGroupDto Group)> groups);
Task<int> ApplySelectedPointsAsync(IEnumerable<string> selectedNodeIds);
```
**참고:** `(string GroupKey, PointBuilderGroupDto Group)` 튜플 사용 — Step 3에서 그룹명 태그에 필요.
**완성 기준:**
- [ ] 두 메서드 시그니처 추가됨
- [ ] 빌드 컴파일 성공 (구현체는 다음 Step)
---
### Step 3: C# DB — PreviewRealtimeBuildAsync 구현
**변경 파일:** `src/Infrastructure/Database/ExperionDbContext.cs`
**구현 로직:**
- 기존 `BuildRealtimeTableAsync`와 동일한 쿼리 로직 재사용
- 각 그룹별로 쿼리 실행 → 결과에 `Group` 속성 태그
- `GroupBy(NodeId)` 중복 제거
- TRUNCATE/INSERT 없이 `PointBuilderPreviewResult` 반환
```csharp
public async Task<PointBuilderPreviewResult> PreviewRealtimeBuildAsync(IEnumerable<PointBuilderGroupDto> groups)
{
var activeGroups = groups.Where(g =>
g.TagPatterns != null && g.TagPatterns.Count > 0
).ToList();
if (activeGroups.Count == 0)
return new PointBuilderPreviewResult();
var allSources = new List<(NodeMapMaster Node, string Group)>();
foreach (var g in activeGroups)
{
var patterns = g.TagPatterns.Where(p => !string.IsNullOrEmpty(p)).ToList();
var attrs = g.Attributes.Where(a => !string.IsNullOrEmpty(a)).ToList();
if (patterns.Count == 0) continue;
var q = _ctx.NodeMapMasters.Where(x => x.Level == 3);
var patternList = patterns;
q = q.Where(x => patternList.Any(p => EF.Functions.Like(x.NodeId, p)));
if (attrs.Count > 0)
{
var attrList = attrs;
q = q.Where(x => attrList.Contains(x.Name));
}
if (!string.IsNullOrEmpty(g.DataType))
{
var dt = g.DataType;
q = q.Where(x => x.DataType == dt);
}
var sources = await q.ToListAsync();
var groupName = GetGroupName(g); // Controller1, AnalogMonitor1 등
foreach (var s in sources)
allSources.Add((s, groupName));
}
var distinct = allSources
.GroupBy(x => x.Node.NodeId)
.Select(g => g.First())
.ToList();
var items = distinct.Select(x => new PointBuilderPreviewItem
{
NodeId = x.Node.NodeId,
TagName = ExtractTagName(x.Node.NodeId),
Name = x.Node.Name,
DataType = x.Node.DataType,
Group = x.Group
}).ToList();
return new PointBuilderPreviewResult { Count = items.Count, Items = items };
}
```
**문제:** `GetGroupName(g)``PointBuilderGroupDto`에는 그룹명이 없음. 해결: 컨트롤러에서 그룹 키와 함께 전달하도록 변경.
**수정안:** 인터페이스 시그니처를 `IEnumerable<(string GroupKey, PointBuilderGroupDto Group)>`로 변경.
**완성 기준:**
- [ ] 쿼리 로직 구현됨 (기존 BuildRealtimeTableAsync와 동일)
- [ ] TRUNCATE/INSERT 없음 (READ-ONLY)
- [ ] 중복 제거 (GroupBy nodeId)
- [ ] 그룹 정보 포함
- [ ] 빌드 컴파일 성공
---
### Step 4: C# DB — ApplySelectedPointsAsync 구현
**변경 파일:** `src/Infrastructure/Database/ExperionDbContext.cs`
```csharp
public async Task<int> ApplySelectedPointsAsync(IEnumerable<string> selectedNodeIds)
{
var nodeIds = selectedNodeIds.Where(n => !string.IsNullOrEmpty(n)).ToList();
if (nodeIds.Count == 0) return 0;
await _ctx.Database.ExecuteSqlRawAsync(
"TRUNCATE TABLE realtime_table RESTART IDENTITY");
var points = nodeIds.Select(nodeId => new RealtimePoint
{
TagName = ExtractTagName(nodeId),
NodeId = nodeId,
LiveValue = null,
Timestamp = DateTime.UtcNow
}).ToList();
await _ctx.RealtimePoints.AddRangeAsync(points);
await _ctx.SaveChangesAsync();
_logger.LogInformation("[ExperionDb] realtime_table 적용: {Count}건 (선택)", points.Count);
return points.Count;
}
```
**완성 기준:**
- [ ] TRUNCATE 후 선택된 nodeId만 INSERT
- [ ] 빌드 컴파일 성공
---
### Step 5: C# Controller — preview + apply 엔드포인트 추가
**변경 파일:** `src/Web/Controllers/ExperionControllers.cs`
```csharp
[HttpPost("preview")]
public async Task<IActionResult> Preview([FromBody] PointBuilderBuildDto dto)
{
var groups = new[]
{
("controller1", dto.Controller1),
("analogmon1", dto.AnalogMonitor1),
("digital1", dto.Digital1),
("digital2", dto.Digital2),
("custom", dto.Custom)
};
var result = await _dbSvc.PreviewRealtimeBuildAsync(groups);
return Ok(new
{
count = result.Count,
items = result.Items.Select(i => new
{
nodeId = i.NodeId,
tagName = i.TagName,
name = i.Name,
dataType = i.DataType,
group = i.Group
})
});
}
[HttpPost("apply")]
public async Task<IActionResult> Apply([FromBody] PointBuilderApplyDto dto)
{
if (dto.SelectedNodeIds == null || dto.SelectedNodeIds.Count == 0)
return BadRequest(new { success = false, message = "선택된 포인트가 없습니다." });
var count = await _dbSvc.ApplySelectedPointsAsync(dto.SelectedNodeIds);
return Ok(new { success = true, count, message = $"{count}개 포인트 적용 완료" });
}
```
**추가 DTO:** `ExperionDtos.cs`
```csharp
public class PointBuilderApplyDto
{
public List<string> SelectedNodeIds { get; set; } = new();
}
```
**완성 기준:**
- [ ] 두 엔드포인트 추가됨
- [ ] camelCase JSON 응답
- [ ] PointBuilderApplyDto 추가됨
- [ ] 빌드 컴파일 성공
---
### Step 6: HTML — 미리보기 버튼 + 영역 추가
**변경 파일:** `src/Web/wwwroot/index.html`
**6a. 버튼 행 수정 (기존 L600-603):**
```html
<div class="btn-row">
<button class="btn-b" onclick="pbPreview()">🔍 미리보기</button>
<button class="btn-a" onclick="pbBuild()">🔨 테이블 작성하기</button>
<button class="btn-b" onclick="pbRefresh()">📋 테이블 조회</button>
</div>
```
**6b. 미리보기 영역 추가 (조건으로 테이블 작성 card 내부, 버튼 행 바로 아래):**
```html
<div id="pb-preview" class="pb-preview hidden">
<div class="pb-preview-header">
<span>미리보기 결과 <span id="pb-preview-count" class="mut">(0개)</span></span>
<div class="pb-preview-actions">
<button class="btn-sm btn-b" onclick="pbPreviewSelectAll()">전체 선택</button>
<button class="btn-sm btn-b" onclick="pbPreviewDeselectAll()">전체 해제</button>
<button class="btn-sm btn-b" onclick="pbPreviewInvert()">역전</button>
<span id="pb-preview-selected" class="mut">선택: 0/0</span>
</div>
</div>
<div class="fg" style="margin-bottom:8px">
<input class="inp" id="pb-preview-search" placeholder="태그명으로 검색..." oninput="pbPreviewFilter()"/>
</div>
<div id="pb-preview-table" class="tbl-wrap" style="max-height:420px;overflow:auto"></div>
<div class="btn-row" style="margin-top:10px;margin-bottom:0">
<button class="btn-b" onclick="pbCancelPreview()">취소</button>
<button class="btn-a" id="pb-apply-btn" onclick="pbApplySelected()">✓ 선택된 포인트 적용하기</button>
</div>
</div>
```
**완성 기준:**
- [ ] 미리보기 버튼 추가됨
- [ ] 미리보기 영역 HTML 추가됨 (hidden 기본)
- [ ] 전체 선택/해제/역전 버튼 포함
- [ ] 검색 입력창 포함
- [ ] 취소 + 적용 버튼 포함
- [ ] HTML 유효성 검사 통과
---
### Step 7: CSS — 미리보기 스타일 추가
**변경 파일:** `src/Web/wwwroot/css/style.css`
```css
.pb-preview {
background: var(--s3);
border: 1px solid var(--bd);
border-radius: var(--r);
padding: 14px 16px;
margin-top: 10px;
}
.pb-preview-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 4px;
font-weight: 600;
font-size: 13px;
}
.pb-preview-actions {
display: flex;
gap: 6px;
align-items: center;
}
.pb-preview table th:first-child,
.pb-preview table td:first-child {
width: 36px;
text-align: center;
}
.pb-preview table input[type="checkbox"] {
cursor: pointer;
width: 15px;
height: 15px;
}
.pb-preview .group-badge {
display: inline-block;
font-size: 10px;
padding: 1px 6px;
border-radius: 3px;
background: var(--ag);
color: var(--a);
font-weight: 600;
}
@media (max-width: 900px) {
.pb-preview-header { flex-direction: column; gap: 8px; align-items: flex-start; }
.pb-preview-actions { flex-wrap: wrap; }
}
```
**완성 기준:**
- [ ] `.pb-preview` 스타일 정의됨
- [ ] 체크박스 컬럼 너비 조정
- [ ] 그룹 배지 스타일
- [ ] 반응형 대응
- [ ] 기존 스타일과 충돌 없음
---
### Step 8: JS — 미리보기 로직 추가
**변경 파일:** `src/Web/wwwroot/js/app.js`
**8a. 전역 변수 (L575 근처):**
```javascript
let pbPreviewData = []; // 미리보기 원본 데이터
```
**8b. 새 함수 추가:**
```javascript
async function pbPreview() {
const groups = {};
for (const gk of PB_GROUPS) {
const gd = pbCollectGroupData(gk);
if (gd.tagPatterns.length > 0) {
groups[gk] = gd;
}
}
const activeKeys = Object.keys(groups);
if (activeKeys.length === 0) {
const logEl = document.getElementById('pb-build-log');
logEl.classList.remove('hidden');
logEl.innerHTML = '<div class="ll err">⚠️ 태그명 패턴을 최소 1개 입력하세요.</div>';
return;
}
setGlobal('busy', '미리보기 조회 중');
try {
const d = await api('POST', '/api/pointbuilder/preview', groups);
pbPreviewData = (d.items || []).map((item, idx) => ({ ...item, selected: true, idx }));
document.getElementById('pb-preview-count').textContent = `(${d.count}개)`;
document.getElementById('pb-preview').classList.remove('hidden');
pbRenderPreview(pbPreviewData);
setGlobal('ok', `미리보기: ${d.count}개 포인트`);
} catch (e) {
setGlobal('err', '미리보기 실패');
}
}
function pbRenderPreview(data) {
const el = document.getElementById('pb-preview-table');
const filtered = pbGetFilteredPreview();
const pts = filtered.length > 0 ? filtered : data;
if (pts.length === 0) {
el.innerHTML = '<div style="padding:20px;color:var(--t2)">조건에 맞는 포인트가 없습니다.</div>';
pbUpdatePreviewCount();
return;
}
el.innerHTML = `
<table>
<thead>
<tr>
<th><input type="checkbox" onchange="pbPreviewToggleAll(this.checked)" title="전체 선택/해제"/></th>
<th>ID</th>
<th>TagName</th>
<th>NodeType</th>
<th>DataType</th>
<th>Group</th>
</tr>
</thead>
<tbody>
${pts.map((p, i) => `
<tr style="${!p.selected ? 'opacity:0.5' : ''}">
<td><input type="checkbox" ${p.selected ? 'checked' : ''} onchange="pbPreviewToggleItem(${p.idx})"/></td>
<td class="mut">${i + 1}</td>
<td style="font-weight:600">${esc((p?.tagName)?.toUpperCase() || '')}</td>
<td>${esc(p?.name || '')}</td>
<td class="mut">${esc(p?.dataType || '')}</td>
<td><span class="group-badge">${esc(p?.group || '')}</span></td>
</tr>
`).join('')}
</tbody>
</table>
`;
pbUpdatePreviewCount();
}
function pbPreviewToggleItem(idx) {
pbPreviewData[idx].selected = !pbPreviewData[idx].selected;
pbUpdatePreviewCount();
}
function pbPreviewToggleAll(checked) {
pbPreviewData.forEach((item, idx) => {
const searchVal = document.getElementById('pb-preview-search')?.value?.toLowerCase();
if (searchVal) {
const filtered = pbGetFilteredPreview();
if (filtered.includes(item)) {
item.selected = checked;
}
} else {
item.selected = checked;
}
});
pbRenderPreview(pbPreviewData);
}
function pbPreviewSelectAll() {
pbPreviewData.forEach(p => p.selected = true);
pbRenderPreview(pbPreviewData);
}
function pbPreviewDeselectAll() {
pbPreviewData.forEach(p => p.selected = false);
pbRenderPreview(pbPreviewData);
}
function pbPreviewInvert() {
pbPreviewData.forEach(p => p.selected = !p.selected);
pbRenderPreview(pbPreviewData);
}
function pbGetFilteredPreview() {
const searchVal = document.getElementById('pb-preview-search')?.value?.toLowerCase();
if (!searchVal) return [];
return pbPreviewData.filter(p =>
(p.tagName || '').toLowerCase().includes(searchVal) ||
(p.nodeId || '').toLowerCase().includes(searchVal) ||
(p.name || '').toLowerCase().includes(searchVal)
);
}
function pbPreviewFilter() {
pbRenderPreview(pbPreviewData);
}
function pbUpdatePreviewCount() {
const selected = pbPreviewData.filter(p => p.selected).length;
const total = pbPreviewData.length;
document.getElementById('pb-preview-selected').textContent = `선택: ${selected}/${total}`;
}
function pbCancelPreview() {
document.getElementById('pb-preview').classList.add('hidden');
pbPreviewData = [];
}
async function pbApplySelected() {
const selected = pbPreviewData.filter(p => p.selected).map(p => p.nodeId);
if (selected.length === 0) {
setGlobal('err', '적용할 포인트를 선택하세요.');
return;
}
setGlobal('busy', `${selected.length}개 포인트 적용 중`);
try {
const d = await api('POST', '/api/pointbuilder/apply', { selectedNodeIds: selected });
setGlobal(d.success ? 'ok' : 'err', d.success ? `${d.count}개 포인트 적용 완료` : '적용 실패');
if (d.success) {
pbCancelPreview();
await pbRefresh();
}
} catch (e) {
setGlobal('err', '적용 오류');
}
}
```
**완성 기준:**
- [ ] `pbPreviewData` 전역 변수 추가됨
- [ ] `pbPreview()` 함수 추가됨
- [ ] `pbRenderPreview()` 함수 추가됨
- [ ] `pbPreviewToggleItem()`, `pbPreviewToggleAll()` 추가됨
- [ ] `pbPreviewSelectAll()`, `pbPreviewDeselectAll()`, `pbPreviewInvert()` 추가됨
- [ ] `pbGetFilteredPreview()`, `pbPreviewFilter()` 검색 기능 추가됨
- [ ] `pbUpdatePreviewCount()` 카운트 업데이트
- [ ] `pbCancelPreview()` 취소
- [ ] `pbApplySelected()` 선택된 것만 적용
- [ ] `pbPreviewData``idx` 속성 부여 (indexOf 버그 방지)
- [ ] 콘솔 에러 없음
---
### Step 9: 빌드 및 검증
**작업 내용:**
1. `dotnet build src/Web/ExperionCrawler.csproj`
2. 빌드 성공 (0 error, 0 warning)
3. 브라우저에서 테스트:
- 조건 입력 → 미리보기 → 결과 확인
- 체크/해제/역전 동작
- 검색 필터 동작
- 선택된 것만 적용 → 포인트 목록 갱신
- 기존 "테이블 작성하기" 버튼도 정상 동작
**완성 기준:**
- [ ] 빌드 성공 (0 error, 0 warning)
- [ ] 미리보기 버튼 클릭 시 결과 표시
- [ ] 전체 선택/해제/역전 정상
- [ ] 검색 필터 정상
- [ ] 선택 적용 후 포인트 목록 갱신
- [ ] 기존 빌드 버튼 영향 없음
- [ ] 기존 pbRefresh/pbDelete 영향 없음
---
## 5. 진행 상태 추적
| Step | 상태 | 비고 |
|------|------|------|
| 1. DTO | ⬜ 미완료 | PointBuilderPreviewItem, PointBuilderPreviewResult, PointBuilderApplyDto |
| 2. Interface | ⬜ 미완료 | PreviewRealtimeBuildAsync, ApplySelectedPointsAsync |
| 3. DB Preview | ⬜ 미완료 | READ-ONLY 쿼리 |
| 4. DB Apply | ⬜ 미완료 | 선택된 nodeId만 TRUNCATE + INSERT |
| 5. Controller | ⬜ 미완료 | preview + apply 엔드포인트 |
| 6. HTML | ⬜ 미완료 | 미리보기 버튼 + 영역 |
| 7. CSS | ⬜ 미완료 | .pb-preview 스타일 |
| 8. JS | ⬜ 미완료 | 미리보기 로직 (중복 pbBuild 이미 삭제 완료) |
| 9. 빌드 | ⬜ 미완료 | 검증 |
---
## 6. 부가 사항
### 6.1 기존 버그 수정 (이미 완료)
- **중복 `pbBuild()` 함수 (app.js L671-686):** ✅ 이미 삭제 완료 (코딩.md 반영 시)
- **`pbRender()` 테이블 컬럼 불일치 (app.js L660-662):** ✅ 컬럼 순서 확인 완료
### 6.2 향후 개선 (별도 작업)
- 그룹별 색상 배지 (컨트롤러=파랑, 아날로그=초록, 디지털=주황)
- 미리보기 결과 Excel/PDF 내보내기
- 최근 미리보기 조건 저장 (localStorage)
- 페이지네이션 (1000개 이상 시)

View File

@@ -0,0 +1,242 @@
# 포인트빌더 개선 방안
## 1. 현재 문제점
### 1.1 구조적 문제
`node_map_master` 테이블의 level 3 레코드 구조:
| name | node_id | data_type | level |
|------|---------|-----------|-------|
| `pv` | `ns=1;s=sinamserver:ficq-2113.pv` | `Double` | 3 |
| `op` | `ns=1;s=sinamserver:ficq-2113.op` | `Double` | 3 |
| `sp` | `ns=1;s=sinamserver:ficq-2113.sp` | `Double` | 3 |
| `md` | `ns=1;s=sinamserver:ficq-2113.md` | `i=7594` | 3 |
| `pv` | `ns=1;s=sinamserver:p-602.pv` | `i=7594` | 3 |
| `op` | `ns=1;s=sinamserver:xv-402.op` | `i=7594` | 3 |
- `name`은 속성명만 (`pv`, `op`, `sp`, `md` 등) — 태그명 없음
- 태그명은 `node_id` 안에 포함됨
- **521,958개**의 level 3 레코드가 존재
- 현재 드롭다운이 `name` 전체를 로드 → `pv` 선택 시 **모든** 컨트롤러의 pv全选됨
### 1.2 속성별 데이터 타입 분포
| 속성 | data_type | 설명 |
|------|-----------|------|
| `pv` | Double / i=7594 | 아날로그/디지털 |
| `op` | Double / i=7594 | 아날로그/디지털 |
| `sp` | Double / Boolean | 아날로그/디지털 |
| `md` | i=7594 | 모드 표시 (StatusCode) |
| `mode` | i=7594 | 모드 (md와 중복 → 제외) |
| `a1~a4` | Boolean / Double | Aux parameter (Alarm 아님 → 제외) |
| `qv.value` | Double | 품질 값 |
### 1.3 태그명 규칙의 다양성
- 회사마다, 프로젝트마다 태그명 규칙이 다름
- 정형화된 드롭다운/조건으로는 모든 케이스 커버 불가
- 사용자 자유도가 필수
---
## 2. 개선 방안
### 2.1 UI 구성
```
┌─────────────────────────────────────────────────────────────────────────────────┐
│ 조건으로 테이블 작성 │
├─────────────────────────────────────────────────────────────────────────────────┤
│ │
│ ┌─ 컨트롤러 포인트 #1 ────────────────────────────────────────────────────────┐ │
│ │ 태그명 패턴 (쉼표 구분, OR) │ │
│ │ [ 예) %fic%, %lic%, %tic%, %pic% ] │ │
│ │ ☑ pv ☑ op ☑ sp ☑ md 사용자 속성: [ ] [ ] │ │
│ │ 데이터 타입: [Double ▼] │ │
│ └────────────────────────────────────────────────────────────────────────────────┘ │
│ │
│ ┌─ 아날로그 모니터링 포인트 #2 ───────────────────────────────────────────────┐ │
│ │ 태그명 패턴 (쉼표 구분, OR) │ │
│ │ [ 예) %fi%, %ti%, %li%, %pia% ] │ │
│ │ ☑ pv ☐ op ☐ sp ☐ md 사용자 속성: [ ] [ ] │ │
│ │ 데이터 타입: [Double ▼] │ │
│ └────────────────────────────────────────────────────────────────────────────────┘ │
│ │
│ ┌─ 디지털 포인트 #1 ──────────────────────────────────────────────────────────┐ │
│ │ 태그명 패턴 (쉼표 구분, OR) │ │
│ │ [ 예) %p-%, %xv-%, %vp-% ] │ │
│ │ ☑ pv ☑ op ☑ sp ☑ md 사용자 속성: [ ] [ ] │ │
│ │ 데이터 타입: [i=7594 ▼] │ │
│ └────────────────────────────────────────────────────────────────────────────────┘ │
│ │
│ ┌─ 디지털 포인트 #2 ──────────────────────────────────────────────────────────┐ │
│ │ 태그명 패턴 (쉼표 구분, OR) │ │
│ │ [ ] │ │
│ │ ☑ pv ☑ op ☑ sp ☑ md 사용자 속성: [ ] [ ] │ │
│ │ 데이터 타입: [i=7594 ▼] │ │
│ └────────────────────────────────────────────────────────────────────────────────┘ │
│ │
│ ┌─ 사용자 정의 ───────────────────────────────────────────────────────────────┐ │
│ │ 태그명 패턴 (쉼표 구분, OR) │ │
│ │ [ 예) %ficq-6113%, %ti-6101% ] │ │
│ │ ☑ pv ☑ op ☑ sp ☑ md 사용자 속성: [ ] [ ] │ │
│ │ 데이터 타입: [전체 ▼] │ │
│ └────────────────────────────────────────────────────────────────────────────────┘ │
│ │
│ [🔨 테이블 작성하기] [📋 테이블 조회] │
│ │
└─────────────────────────────────────────────────────────────────────────────────┘
```
### 2.2 각 그룹의 동작
**태그명 패턴 입력:**
- 쉼표(`,`)로 구분하여 여러 패턴 입력
- 각 패턴은 `node_id`의 태그명 부분에 LIKE 매칭
- `%` 자동 추가 (선택): 사용자가 `%fic%` 입력 시 `%fic%`로, `fic` 입력 시 `%fic%`로 변환
- 예: `fic, tic, lica``%fic%` OR `%tic%` OR `%lica%`
**속성 체크박스:**
- `pv`, `op`, `sp`, `md` 기본 체크박스
- 오른쪽 작은 입력창 2개: 사용자 정의 속성 (예: `qv.value`, `tagname`)
- 체크된 속성 + 입력된 사용자 속성이 AND 조건으로 필터
**데이터 타입 풀다운:**
- `전체` (필터 없음)
- `Double` (아날로그)
- `i=7594` (디지털, StatusCode)
- `Boolean`
- `String`
- `Int16`, `Int32`, `UInt16`, `UInt32`
- `Float`, `DateTime`
### 2.3 백엔드 쿼리 로직
각 그룹별로 독립적인 쿼리를 생성하고 UNION DISTINCT:
```sql
-- 그룹 1: 컨트롤러 포인트
SELECT * FROM node_map_master WHERE level = 3
AND (node_id LIKE '%fic%' OR node_id LIKE '%tic%' OR node_id LIKE '%lica%')
AND name IN ('pv', 'op', 'sp', 'md')
AND data_type = 'Double'
UNION
-- 그룹 2: 아날로그 모니터링
SELECT * FROM node_map_master WHERE level = 3
AND (node_id LIKE '%fi%' OR node_id LIKE '%ti%' OR node_id LIKE '%li%')
AND name IN ('pv')
AND data_type = 'Double'
UNION
-- 그룹 3: 디지털 포인트 #1
SELECT * FROM node_map_master WHERE level = 3
AND (node_id LIKE '%p-%' OR node_id LIKE '%xv-%' OR node_id LIKE '%vp-%')
AND name IN ('pv', 'op', 'sp', 'md')
AND data_type = 'i=7594'
UNION
-- 그룹 4: 디지털 포인트 #2
-- (그룹 3과 동일 구조, 다른 패턴)
UNION
-- 그룹 5: 사용자 정의
SELECT * FROM node_map_master WHERE level = 3
AND (node_id LIKE '%custom1%' OR node_id LIKE '%custom2%')
AND name IN ('pv', 'op', 'sp', 'md', 'customAttr1', 'customAttr2')
-- data_type 필터 없음 (전체)
```
### 2.4 DTO 구조
```csharp
public class PointBuilderGroupDto
{
public List<string> TagPatterns { get; set; } = new(); // 쉼표 구분 → 분할
public List<string> Attributes { get; set; } = new(); // 체크박스 + 사용자 속성
public string? DataType { get; set; } // null = 전체
}
public class PointBuilderBuildDto
{
public PointBuilderGroupDto Controller1 { get; set; } = new();
public PointBuilderGroupDto AnalogMonitor1 { get; set; } = new();
public PointBuilderGroupDto Digital1 { get; set; } = new();
public PointBuilderGroupDto Digital2 { get; set; } = new();
public PointBuilderGroupDto Custom { get; set; } = new();
}
```
---
## 3. 추가 제안
### 3.1 미리보기 기능
테이블 작성 전에 조건에 맞는 레코드 수와 샘플을 표시:
```
[🔍 미리보기] → "조건에 맞는 레코드: 1,247개 (샘플: ficq-2113.pv, ficq-2113.op, ...)"
```
### 3.2 그룹 활성화 토글
각 그룹에 체크박스 추가하여 비활성화 가능한 그룹 표시:
```
☑ 컨트롤러 포인트 #1
☐ 아날로그 모니터링 포인트 #2 ← 비활성화 (쿼리에서 제외)
```
### 3.3 최근 조건 저장/로드
사용자가 자주 사용하는 조건 조합을 로컬 스토리지에 저장:
```
[💾 조건 저장] [📂 조건 로드] [삭제]
```
### 3.4 태그명 패턴 자동완성
level 2 name (`ficq-2113`, `ficq-2114` 등)을 기반으로 자동완성 제공:
```
사용자 입력: "fic" → 제안: fic, ficq-111, ficq-113, ficq-122, ficq-124, ...
```
### 3.5 중복 제거
UNION으로 결합 시 중복 레코드 자동 제거 (UNION DISTINCT).
---
## 4. 변경 범위
| 파일 | 변경 내용 |
|------|----------|
| `wwwroot/index.html` | 포인트빌더 pane 전면 재구성 |
| `wwwroot/js/app.js` | `pbLoad()` 제거, `pbBuild()` 그룹 기반 전송 |
| `wwwroot/css/style.css` | 그룹 카드, 체크박스, 입력창 스타일 |
| `Core/DTOs/ExperionDtos.cs` | `PointBuilderGroupDto`, `PointBuilderBuildDto` 재정의 |
| `Core/Interfaces/IExperionServices.cs` | `BuildRealtimeTableAsync` 시그니처 변경 |
| `Infrastructure/Database/ExperionDbContext.cs` | UNION 기반 쿼리 로직 |
| `Web/Controllers/ExperionControllers.cs` | DTO 매핑 |
---
## 5. 데이터 타입 참고
| data_type | 설명 | 포인트 유형 |
|-----------|------|------------|
| `Double` | 부동소수점 | 아날로그 (pv/op/sp) |
| `i=7594` | StatusCode | 디지털 (pv/op/sp/md) |
| `Boolean` | 참/거짓 | sp (디지털), a1~a4 |
| `String` | 문자열 | desc, name, area |
| `Int16` | 정수 16bit | eulo, euhi, pvperiod |
| `Int32` | 정수 32bit | numberofparents |
| `DateTime` | 일시 | lastscannedtime |