온도프로파일/PV일관성/PointBuilder/history 작업지시, 신호태그·스팀유량 진단, 베이직아키텍처 재설계, MSDS, LLM채팅 구조 등. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
31 KiB
HC900 Crawler 포인트빌더 이식 설계서
목적:
ExperionCrawler의포인트빌더-페이지-구현-명세.md에 정의된 Point Builder 페이지를 HC900 Crawler 프로젝트에 맞게 변환하여 이식하기 위한 상세 설계서.핵심 차이: ExperionCrawler는 OPC UA
node_map_master를 소스로 사용하지만, HC900 Crawler는 Modbus TCPhc900_map_master를 소스로 사용한다.
진단: 아래 내용은
diagnosis-checklist.md진단 룰 8단계를 적용한 설계 검토 결과. 우선순위: 🔴 HIGH(즉시 수정 권장) > 🟠 MED > 🟡 LOW
진단 결과 요약
🔴 1. 설계 원칙과 구현 코드 모순 — TRUNCATE vs IsActive 플래그
문제: §4.2가 "TRUNCATE realtime_table 대신 IsActive 플래그 사용"이라고 명시했으나,
§6.5 SyncRealtimeTableAsync가 TRUNCATE TABLE realtime_table RESTART IDENTITY를 사용함.
근거: §4.2 L227 vs §6.5 L573-574
영향: 실시간 값(LiveValue)이 모두 null로 초기화됨. DB 유저에게 TRUNCATE 권한이 없으면 런타임 오류. 설계 원칙과 직접 충돌하여 구현 방향에 혼란 초래.
수정: TRUNCATE 대신 DELETE FROM realtime_table WHERE tagname NOT IN (...active tags...)
패턴으로 변경하여 LiveValue 보존.
🔴 2. BuildRealtimeTableAsync 시그니처/내부 타입 불일치
문제: §6.1 BuildRealtimeTableAsync(IEnumerable<Hc900PointBuilderGroupDto>)의 foreach에서
(groupKey, group) 튜플 분해를 사용하지만 단일 DTO 타입이므로 컴파일 불가.
§6.2 PreviewRealtimeBuildAsync는 올바르게 튜플 타입을 사용함.
근거: §6.1 L431 (시그니처) vs L439 (foreach destructure)
영향: 해당 코드는 컴파일되지 않음.
수정: 시그니처를 IEnumerable<(string GroupKey, Hc900PointBuilderGroupDto Group)>로 변경.
🟠 3. DB 트랜잭션 미적용
문제: Build/Apply/Append 모두 다중 DB 작업(전체 UPDATE → 조건부 UPDATE → TRUNCATE → INSERT)을
트랜잭션 없이 순차 실행. 중간 실패 시 is_active와 realtime_table이 불일치 상태로 남음.
근거: §6.1 L434-451, §6.3 L526-535, §6.4 L544-554
수정: using var tx = await _ctx.Database.BeginTransactionAsync()로 전체 작업 감싸기.
🟠 4. Build/Apply 중 Gateway 폴링에 빈 활성 목록 노출
문제: "전체 is_active=false" → "조건부 is_active=true" 순서로 인해 두 UPDATE 사이에
Gateway가 폴링하면 모든 태그가 비활성 상태로 보임.
근거: §6.1 L434-435 (전체 비활성화) vs L447 (SaveChangesAsync)
수정: 조건부 UPDATE를 먼저 실행하거나 단일 SQL로 전환.
🟠 5. BuildGroupQuery에 LoopNo 필터 미구현
문제: §4.1 Request Body에 loopNo 필드가 명세되었으나 §6.1 BuildGroupQuery는
LoopNo 조건을 구현하지 않음. SQL 명세(L176-182)와 C# 구현 불일치.
근거: §4.1 L159 (loopNo 필드) vs §6.1 L458-479 (LoopNo 조건 누락)
수정: if (group.LoopNo.HasValue) q = q.Where(x => x.LoopNo == group.LoopNo.Value);
🟠 6. Hc900PointBuilderGroupDto 클래스 정의 누락
문제: 문서 전체에서 Hc900PointBuilderGroupDto를 사용하지만 필드 정의가 없음.
특히 LoopNo 필드의 타입(int?)과 PointBuilderGroupDto(Experion 잔재)와의 관계 불명확.
근거: §4.1 ~ §6.6 전반 — 사용만 있고 정의는 없음.
수정: §3.4로 명시적 클래스 정의 추가.
🟡 7. TagPatterns 와일드카드 처리 불일치
문제: §4.1 명세는 "ILIKE 패턴 (% 와일드카드)"이나 §6.1 구현은 %를 제거 후
항상 %p%로 감싸 contains 매칭만 수행.
수정: 사용자 패턴을 그대로 ILIKE에 전달하거나, %...% 고정 규칙으로 명세 통일.
🟡 8. 그룹별 도메인 필터 부재
문제: 5개 그룹(loop/signal/digital/variable/custom)이 모두 동일한 BuildGroupQuery를
사용하므로 그룹 간 도메인 경계가 무시됨.
수정: groupKey 기반 도메인 필터 추가 또는 "사용자 패턴에 전적으로 의존"한다고 문서화.
🟡 9. API 에러 응답 명세 부재
문제: 모든 API가 성공 응답만 정의하고 HTTP status code별 에러 응답 형식이 없음.
수정: 400/404/500 각각의 에러 응답 예시 추가.
목차
1. 현황 분석
1.1 현재 구현된 것 (이식 대상 아님, 교체 필요)
| 파일 | 역할 | 현재 상태 |
|---|---|---|
wwwroot/panes/pb.html (94줄) |
태그 관리 페이지 | Hc900MapEntry 조회/필터/활성화 토글 UI |
wwwroot/js/pb.js (213줄) |
태그 관리 로직 | pbReload, pbToggleOne, pbBulkSelected 등 |
wwwroot/css/pb.css (106줄) |
CSS | ExperionCrawler 원본과 동일 (잔재) |
Controllers/Hc900Controllers.cs |
컨트롤러 | Hc900TagManagerController (태그 CRUD), RealtimeController (point CRUD) |
Infrastructure/Database/Hc900DbContext.cs |
DbService | BuildRealtimeTableAsync, PreviewRealtimeBuildAsync → NotImplementedException |
1.2 ExperionCrawler 원본과의 대응 관계
| ExperionCrawler | HC900 Crawler |
|---|---|
| OPC UA 서버 연결 | C++ Gateway (gRPC) |
node_map_master (OPC UA 노드 카탈로그) |
hc900_map_master (Modbus 레지스터 맵) |
| OPC UA 구독 시작/중지 | Gateway Polling 시작/중지 (프로세스 제어) |
realtime_table = 구독 대상 포인트 목록 |
realtime_table = 게이트웨이가 폴링하는 값 캐시 |
IExperionRealtimeService.AddMonitoredItemAsync() |
Hc900RealtimeService (이미 폴링 중) |
1.3 이미 존재하는 것 (재사용 가능)
Hc900TagManagerController—/api/hc900/tags(Hc900MapEntry CRUD, param-types, controller-ids)RealtimeController—/api/realtime/points(realtime_table 조회/삭제)MetadataController—/api/metadata(tag_metadata 조회)SubAreaController—/api/subarea(sub-area 관리)Hc900GatewayClient— gRPC ListTags, HealthCheckHc900RealtimeService— 폴링 서비스 상태 노출
2. 핵심 차이점
2.1 데이터 소스 차이
| 항목 | ExperionCrawler | HC900 Crawler |
|---|---|---|
| 소스 테이블 | node_map_master (Level=3, OPC UA Variables) |
hc900_map_master (Modbus 레지스터) |
| 태그 식별자 | NodeId (ns=1;s=ti-6101.pv) |
TagName (FICQ-6101.PV) |
| 데이터 타입 | DataType 컬럼 (Boolean/Double/Int16 ...) |
DataType 컬럼 (float32/uint16/int32 ...) |
| 속성 | Name 컬럼 (pv/sp/op/md) |
ParamType 컬럼 (PV/SP/OP/MODE/STATUS) |
| 그룹 기준 | OPC UA Browse 계층 (controller1/analogmon1/...) | HC900 구조 (Loop# + ParamType) |
| 태그 계층 | OPC UA NodeId의 ns/s prefix | base_tag.attribute dot notation |
2.2 동작 방식 차이
| 항목 | ExperionCrawler | HC900 Crawler |
|---|---|---|
| 포인트 등록 | node_map_master → realtime_table INSERT |
hc900_map_master.IsActive = true 설정 |
| 포인트 삭제 | realtime_table DELETE |
hc900_map_master.IsActive = false 설정 + realtime_table DELETE |
| 실시간 구독 | OPC UA AddMonitoredItem | Gateway가 is_active 태그 자동 폴링 |
| 구독 시작/중지 | OPC UA 세션 제어 | Gateway 프로세스 시작/중지 |
| 수동 추가 | OPC UA NodeId 입력 + 유효성 검증 | hc900_map_master에 직접 INSERT |
2.3 그룹 체계 차이
ExperionCrawler는 5개 그룹(controller1, analogmon1, digital1, digital2, custom)을
OPC UA Browse 결과에 따라 나누지만, HC900은 구조가 다르므로 아래와 같이 변환:
| ExperionCrawler 그룹 | HC900 Crawler 그룹 (대체) |
|---|---|
controller1 |
loop — PID Loop 파라미터 (PV/SP/OP/MODE 등) |
analogmon1 |
signal — Signal Tag (아날로그 모니터링) |
digital1 |
digital — 디지털 입력 (타입이 digital/Boolean인 것) |
digital2 |
variable — Variable 태그 (R/W 커스텀 변수) |
custom |
custom — 사용자 정의 필터 |
3. 데이터 모델 변경
3.1 hc900_map_master — 변경 없음 (이미 적절함)
현재 Hc900MapEntry의 컬럼이 Point Builder에 필요한 정보를 이미 포함:
public class Hc900MapEntry {
int Id; // PK
string TagName; // e.g. "FICQ-6101.PV"
string Hc900Tag; // e.g. "FICQ-6101.PV"
int ModbusAddr; // 0x40 (0-based)
string DataType; // "float32", "uint16", "int32"
string Access; // "R" or "RW"
int? LoopNo; // PID loop number
string? ParamType; // "PV", "SP", "OP", "MODE", "STATUS", "SIG"
bool IsActive; // 폴링 대상 여부
string ControllerId; // "HC1", "C2", ...
}
3.2 realtime_table — 변경 없음
public class RealtimePoint {
int Id; // PK
string TagName; // e.g. "FICQ-6101.PV"
string NodeId; // hc900_tag (호환성 유지)
string? LiveValue; // 실시간 값
DateTime Timestamp; // 갱신 시각
string ControllerId; // "HC1"
}
3.3 tag_metadata — 변경 없음
public class TagMetadata {
int Id; // PK
string BaseTag; // e.g. "FICQ-6101"
string Attribute; // "desc", "area", "sub_area", "state0"~"state7"
string? Value; // actual value
string? NodeId; // unused in HC900
DateTime LoadedAt;
string ControllerId;
}
4. API 엔드포인트 설계
Base:
/api/pointbuilder기존
Hc900TagManagerController는 유지. Point Builder는 별도 컨트롤러로 분리.
4.1 POST /api/pointbuilder/preview
hc900_map_master에서 조건에 맞는 태그를 미리보기.
Request Body:
{
"controller1": { "tagPatterns": ["FICQ-61", "TICQ-61"], "paramTypes": ["PV","SP"], "dataType": null, "loopNo": null },
"analogmon1": { "tagPatterns": ["TI-61", "PI-62"], "paramTypes": ["PV"], "dataType": null, "loopNo": null },
"digital1": { "tagPatterns": ["YS-61", "YT-62"], "paramTypes": ["STATUS"], "dataType": null, "loopNo": null },
"variable": { "tagPatterns": [], "paramTypes": [], "dataType": null, "loopNo": null },
"custom": { "tagPatterns": [], "paramTypes": [], "dataType": null, "loopNo": null }
}
필드 설명:
| 필드 | 타입 | 설명 |
|---|---|---|
tagPatterns |
string[] |
hc900_map_master.TagName에 대한 ILIKE 패턴 (% 와일드카드) |
paramTypes |
string[] |
ParamType 필터 (PV / SP / OP / MODE / STATUS / SIG / VAR) |
dataType |
string? |
DataType 필터 (float32 / uint16 / int32 etc). null=전체 |
loopNo |
int? |
LoopNo 필터. null=전체 |
DB 조건:
SELECT * FROM hc900_map_master
WHERE (TagName ILIKE <pattern1> OR TagName ILIKE <pattern2> ...)
AND ParamType IN (<paramTypes>) -- paramTypes가 비어있으면 생략
AND DataType = <dataType> -- dataType이 null이면 생략
AND (loop_no = <loopNo> OR <loopNo> IS NULL)
ORDER BY TagName
Response:
{
"count": 42,
"items": [
{
"tagName": "FICQ-6101.PV",
"hc900Tag": "FICQ-6101.PV",
"modbusAddr": 64,
"paramType": "PV",
"dataType": "float32",
"loopNo": 1,
"access": "R",
"controllerId": "HC1",
"group": "controller1",
"isActive": true
}
]
}
새 DTO (PointBuilderPreviewItem 확장):
public class Hc900PointBuilderPreviewItem
{
public string TagName { get; set; } = "";
public string Hc900Tag { get; set; } = "";
public int ModbusAddr { get; set; }
public string ParamType { get; set; } = "";
public string DataType { get; set; } = "";
public int? LoopNo { get; set; }
public string Access { get; set; } = "R";
public string ControllerId { get; set; } = "HC1";
public string Group { get; set; } = "";
public bool IsActive { get; set; }
}
4.2 POST /api/pointbuilder/build
조건에 맞는 모든 태그를 활성화 (기존 활성 태그는 모두 비활성화 후 조건 매칭 태그만 활성화).
실제 동작:
TRUNCATE realtime_table대신IsActive플래그 사용. 즉, 기존에 활성화된 태그 중 조건에 포함되지 않은 것은IsActive=false로 변경.
동작:
UPDATE hc900_map_master SET is_active = false(전체 비활성화)- 조건 매칭된 태그들의
is_active = true설정 - 활성화된 태그를
realtime_table에 반영 (기존 행은 TRUNCATE 후 INSERT, 또는 upsert)
Response:
{
"success": true,
"count": 42,
"message": "42개 포인트 활성화 완료"
}
4.3 POST /api/pointbuilder/apply
미리보기에서 선택한 태그만 활성화 (기존 활성화 모두 비활성화 → 선택만 활성화).
Request: { "selectedTagNames": ["FICQ-6101.PV", "FICQ-6101.SP", ...] }
4.4 POST /api/pointbuilder/append
선택한 태그를 기존 활성화 목록에 추가 (중복 제외).
Request: { "selectedTagNames": [...] }
4.5 GET /api/pointbuilder/points
등록된 모든 active 태그 조회 (= GET /api/hc900/tags?active=true + 실시간 값 join).
Response:
{
"total": 42,
"items": [
{
"id": 1,
"tagName": "FICQ-6101.PV",
"modbusAddr": 64,
"paramType": "PV",
"dataType": "float32",
"controllerId": "HC1",
"liveValue": "152.7",
"timestamp": "2026-06-08T12:34:56Z",
"isActive": true
}
]
}
4.6 POST /api/pointbuilder/add
태그를 수동으로 hc900_map_master에 추가하고 활성화.
Request: { "tagName": "FICQ-6201.PV", "modbusAddr": 320, "dataType": "float32", "loopNo": null, "paramType": "PV", "access": "R", "controllerId": "HC1" }
동작:
hc900_map_master에 INSERT (중복 TagName+ControllerId 체크)is_active = true설정- C++ Gateway가 다음 폴링 사이클에 자동으로 포함
4.7 DELETE /api/pointbuilder/{id}?purgeHistory=false
포인트 삭제 (= 비활성화).
실제 동작:
hc900_map_master에서is_active = falserealtime_table에서 해당 행 DELETE- 같은 base_tag의 잔여 행이 0이면
tag_metadata고아 정리 purgeHistory=true→DELETE FROM history_table WHERE tagname = ?
4.8 보조 API (재사용, URL만 정리)
| Method | Endpoint | 설명 | 재사용 |
|---|---|---|---|
GET |
/api/gateway/health |
Gateway 헬스체크 | GatewayController 기존 |
GET |
/api/gateway/status |
폴링 서비스 상태 | GatewayController 기존 |
GET |
/api/metadata |
tag_metadata 조회 | MetadataController 기존 |
GET |
/api/subarea/{area} |
Sub-Area 현황 | SubAreaController 기존 |
PUT |
/api/subarea/{baseTag} |
Sub-Area 수정 | SubAreaController 기존 |
POST |
/api/subarea/seed |
Sub-Area 일괄 분류 | SubAreaController 기존 |
4.9 Gateway 구독 제어 (OPC UA 대체)
ExperionCrawler의 실시간 구독 시작/중지는 HC900에서 Gateway 프로세스 제어로 대체.
이는 setup 탭에 이미 구현되어 있으므로 Point Builder에서는 상태 확인만 제공:
| Method | Endpoint | 설명 |
|---|---|---|
GET |
/api/gateway/health |
Gateway 상태 |
5. 프론트엔드 변경
5.1 pb.html — 전체 교체 (ExperionCrawler 원본 기반)
ExperionCrawler 원본 pb.html (311줄)을 기반으로 HC900에 맞게 수정.
수정 사항:
| 섹션 | ExperionCrawler | HC900 변경 |
|---|---|---|
| 헤더 | "포인트빌더 — node_map_master → realtime_table 구성" | "포인트빌더 — HC900 태그 활성화 관리" |
| 그룹명 | controller1/analogmon1/digital1/digital2/custom | loop/signal/digital/variable/custom |
| 속성 체크박스 | pv/op/sp/md | PV/SP/OP/MODE/STATUS/SIG/VAR |
| 데이터타입 선택 | Double/Boolean/String/Int16/UInt32/Float/... | float32/uint16/int32/int64/float64 |
| 패턴 매칭 | NodeId LIKE (ns=1;s=ti-6101.pv) |
TagName ILIKE (FICQ-6101.PV) |
| 우측 카드: 구독 제어 | 서버IP/포트/계정 + 시작/중지 | 제거 (setup 탭에서 관리) |
| 우측 카드: 수동 추가 | Node ID 직접 입력 | 변경: TagName + ModbusAddr + DataType + ParamType 입력 |
| 우측 하단: Sub-Area | 유지 | 유지 (동일 로직) |
| 포인트 목록 테이블 | NodeId/TagName/LiveValue/Timestamp | TagName/ParamType/DataType/ModbusAddr/LiveValue/Timestamp/ControllerId |
그룹 카드 레이아웃 수정:
<!-- 루프 파라미터 (기존: controller1) -->
<div class="pb-group-card" data-group="loop">
<div class="pb-group-header">
<span class="card-sub-cap">PID 루프 파라미터 #1</span>
</div>
<input class="inp pb-pattern-input"
data-group="loop" data-field="tagPatterns"
placeholder="FICQ-61, TICQ-62, ... (쉼표 구분)">
<div class="pb-attr-checkboxes">
<label><input type="checkbox" value="PV" checked data-group="loop" data-field="paramTypes"> PV</label>
<label><input type="checkbox" value="SP" checked data-group="loop" data-field="paramTypes"> SP</label>
<label><input type="checkbox" value="OP" checked data-group="loop" data-field="paramTypes"> OP</label>
<label><input type="checkbox" value="MODE" data-group="loop" data-field="paramTypes"> MODE</label>
<label><input type="checkbox" value="STATUS" data-group="loop" data-field="paramTypes"> STATUS</label>
</div>
<div class="pb-custom-attr-inputs">
<input class="inp" data-group="loop" data-field="customParamTypes" placeholder="추가 속성 (SIG, VAR ...)">
</div>
<select class="inp pb-datatype-select" data-group="loop" data-field="dataType">
<option value="">모든 타입</option>
<option value="float32">float32</option>
<option value="uint16">uint16</option>
<option value="int32">int32</option>
<option value="int64">int64</option>
</select>
</div>
5.2 pb.js — 전체 교체
핵심 변경 함수:
| 함수 | ExperionCrawler | HC900 변경 |
|---|---|---|
PB_GROUPS |
['controller1','analogmon1','digital1','digital2','custom'] |
['loop','signal','digital','variable','custom'] |
pbCollectGroupData() |
querySelector by attributes, dataType |
paramTypes로 변경 |
pbBuild() |
POST /api/pointbuilder/build |
동일 엔드포인트, 새로운 동작 |
pbPreview() |
POST /api/pointbuilder/preview |
동일 (internal 로직 변경) |
pbRenderPreview() |
NodeId/TagName/Name/DataType/Group | TagName/ParamType/DataType/LoopNo/Group/ControllerId |
pbAddManual() |
NodeId 하나만 입력 | TagName + ModbusAddr + DataType + ParamType + Access + ControllerId |
pbRefresh() |
GET /api/pointbuilder/points |
Active 태그 + 실시간 값 조인 |
rtStart() / rtStop() / rtStatus() |
OPC UA 구독 제어 | 삭제 (setup 탭에서 처리) |
데이터 수집 함수 (pbCollectGroupData):
// HC900 버전
function pbCollectGroupData(groupKey) {
const tagPatterns = document.querySelector(
`input[data-group="${groupKey}"][data-field="tagPatterns"]`
).value.split(',').map(s => s.trim()).filter(Boolean);
const checkedParamTypes = Array.from(
document.querySelectorAll(
`input[data-group="${groupKey}"][data-field="paramTypes"]:checked`
)
).map(cb => cb.value);
const customInputs = document.querySelectorAll(
`input[data-group="${groupKey}"][data-field="customParamTypes"]`
);
customInputs.forEach(inp => {
if (inp.value.trim()) checkedParamTypes.push(inp.value.trim());
});
const dataType = document.querySelector(
`select[data-group="${groupKey}"][data-field="dataType"]`
)?.value || null;
return { tagPatterns, paramTypes: checkedParamTypes, dataType };
}
5.3 pb.css — 재사용 가능
ExperionCrawler 원본 pb.css (106줄)은 그대로 재사용 가능하다.
pb-group-card, pb-preview, .group-badge 등의 클래스가 동일하게 적용된다.
6. Hc900DbService 변경
6.1 BuildRealtimeTableAsync — 새 구현
public async Task<int> BuildRealtimeTableAsync(IEnumerable<Hc900PointBuilderGroupDto> groups)
{
// 1. 전체 비활성화
await _ctx.Database.ExecuteSqlRawAsync(
"UPDATE hc900_map_master SET is_active = false");
// 2. 조건별 태그 활성화
int total = 0;
foreach (var (groupKey, group) in groups)
{
var matched = await BuildGroupQuery(group).ToListAsync();
foreach (var entry in matched)
{
entry.IsActive = true;
total++;
}
}
await _ctx.SaveChangesAsync();
// 3. realtime_table 동기화 (활성 태그만 존재하도록)
await SyncRealtimeTableAsync();
return total;
}
private IQueryable<Hc900MapEntry> BuildGroupQuery(Hc900PointBuilderGroupDto group)
{
var q = _ctx.Hc900MapEntries.AsQueryable();
if (group.TagPatterns?.Any() == true)
{
var patterns = group.TagPatterns.Select(p => p.Replace("%", "").ToLower()).ToList();
var likeClauses = patterns.Select(p =>
(Expression<Func<Hc900MapEntry, bool>>)(x =>
EF.Functions.ILike(x.TagName, $"%{p}%")));
// OR 결합
q = q.Where(likeClauses.Aggregate((a, b) =>
Expression.Lambda<Func<Hc900MapEntry, bool>>(
Expression.OrElse(a.Body, Expression.Invoke(b, a.Parameters[0])),
a.Parameters[0])));
}
if (group.ParamTypes?.Any() == true)
q = q.Where(x => group.ParamTypes.Contains(x.ParamType));
if (!string.IsNullOrEmpty(group.DataType))
q = q.Where(x => x.DataType == group.DataType);
return q;
}
6.2 PreviewRealtimeBuildAsync — 새 구현
public async Task<Hc900PointBuilderPreviewResult> PreviewRealtimeBuildAsync(
IEnumerable<(string GroupKey, Hc900PointBuilderGroupDto Group)> groups)
{
var items = new List<Hc900PointBuilderPreviewItem>();
foreach (var (groupKey, group) in groups)
{
var matched = await BuildGroupQuery(group)
.Select(e => new Hc900PointBuilderPreviewItem
{
TagName = e.TagName,
Hc900Tag = e.Hc900Tag,
ModbusAddr = e.ModbusAddr,
ParamType = e.ParamType ?? "",
DataType = e.DataType,
LoopNo = e.LoopNo,
Access = e.Access,
ControllerId = e.ControllerId,
Group = groupKey,
IsActive = e.IsActive
})
.ToListAsync();
items.AddRange(matched);
}
return new Hc900PointBuilderPreviewResult
{
Count = items.Count,
Items = items
};
}
6.3 ApplySelectedPointsAsync — 새 구현
public async Task<int> ApplySelectedPointsAsync(IEnumerable<string> selectedTagNames)
{
var tagNames = selectedTagNames.Where(n => !string.IsNullOrEmpty(n)).ToList();
if (tagNames.Count == 0) return 0;
// 1. 전체 비활성화
await _ctx.Database.ExecuteSqlRawAsync(
"UPDATE hc900_map_master SET is_active = false");
// 2. 선택만 활성화
var count = await _ctx.Hc900MapEntries
.Where(x => tagNames.Contains(x.TagName))
.ExecuteUpdateAsync(s => s.SetProperty(x => x.IsActive, true));
// 3. realtime_table 동기화
await SyncRealtimeTableAsync();
return count;
}
6.4 AppendPointsAsync — 새 구현
public async Task<int> AppendPointsAsync(IEnumerable<string> tagNames)
{
var tagNamesList = tagNames.Where(n => !string.IsNullOrEmpty(n)).ToList();
if (tagNamesList.Count == 0) return 0;
var count = await _ctx.Hc900MapEntries
.Where(x => tagNamesList.Contains(x.TagName) && !x.IsActive)
.ExecuteUpdateAsync(s => s.SetProperty(x => x.IsActive, true));
await SyncRealtimeTableAsync();
return count;
}
6.5 SyncRealtimeTableAsync — 신규 헬퍼
/// <summary>
/// hc900_map_master의 활성 태그 목록과 realtime_table을 동기화.
/// realtime_table에는 활성 태그만 존재해야 함.
/// </summary>
private async Task SyncRealtimeTableAsync()
{
var activeTags = await _ctx.Hc900MapEntries
.Where(x => x.IsActive)
.Select(x => new { x.TagName, x.Hc900Tag, x.ControllerId })
.ToListAsync();
// TRUNCATE 후 INSERT (간단한 approach)
await _ctx.Database.ExecuteSqlRawAsync(
"TRUNCATE TABLE realtime_table RESTART IDENTITY");
var points = activeTags.Select(t => new RealtimePoint
{
TagName = t.TagName,
NodeId = t.Hc900Tag,
LiveValue = null,
Timestamp = DateTime.UtcNow,
ControllerId = t.ControllerId
}).ToList();
if (points.Count > 0)
{
_ctx.RealtimePoints.AddRange(points);
await _ctx.SaveChangesAsync();
}
}
6.6 AddRealtimePointAsync — 수정
NodeId 대신 TagName으로 Hc900MapEntry를 생성/조회:
public async Task<RealtimePoint> AddRealtimePointAsync(string tagName, string hc900Tag,
int modbusAddr, string dataType, string? paramType, string access, string controllerId)
{
// 1. hc900_map_master에 추가
var existing = await _ctx.Hc900MapEntries
.FirstOrDefaultAsync(x => x.TagName == tagName && x.ControllerId == controllerId);
if (existing == null)
{
existing = new Hc900MapEntry
{
TagName = tagName,
Hc900Tag = hc900Tag ?? tagName,
ModbusAddr = modbusAddr,
DataType = dataType,
ParamType = paramType,
Access = access,
ControllerId = controllerId,
IsActive = true
};
_ctx.Hc900MapEntries.Add(existing);
}
else
{
existing.IsActive = true;
}
await _ctx.SaveChangesAsync();
// 2. realtime_table에 반영
return await SyncSinglePointAsync(tagName, controllerId);
}
7. 마이그레이션 순서
Phase A: 백엔드 (1일)
-
DTO 신규 작성
Hc900PointBuilderGroupDto—PointBuilderGroupDto대체 (TagPatterns, ParamTypes, DataType)Hc900PointBuilderPreviewItem— preview item (TagName, ModbusAddr, ParamType, Group 등)Hc900PointBuilderPreviewResult— preview 결과Hc900PointBuilderBuildDto— build 요청Hc900PointBuilderApplyDto— apply 요청 (SelectedTagNames)Hc900PointBuilderAddDto— 수동 추가 요청
-
Controller 신규 작성 —
PointBuilderController.cs/api/pointbuilder/preview/api/pointbuilder/build/api/pointbuilder/apply/api/pointbuilder/append/api/pointbuilder/points/api/pointbuilder/addDELETE /api/pointbuilder/{id}
-
Hc900DbService 구현
BuildRealtimeTableAsync— 새 구현 (기존 NotImplementedException 대체)PreviewRealtimeBuildAsync— 새 구현ApplySelectedPointsAsync— 새 구현AppendPointsAsync— 새 구현AddRealtimePointAsync— 수정SyncRealtimeTableAsync— 신규
Phase B: 프론트엔드 (1일)
-
pb.html재작성- ExperionCrawler 원본 레이아웃 유지
- 그룹명/필드명 HC900에 맞게 수정
- 우측 컬럼: 구독 제어 → 제거 (Gateway 상태만 표시)
- 우측 컬럼: 수동 추가 → TagName + ModbusAddr + DataType + ParamType + Access + ControllerId
-
pb.js재작성- 데이터 수집:
attributes→paramTypes - Preview:
NodeId→TagName+ParamType+LoopNo - Build/Apply: 전체 교체는
IsActive플립 - rtStart/rtStop/rtStatus → 제거
- 수동 추가: 확장된 필드
- 데이터 수집:
-
pb.css— 유지 (수정 불필요)
Phase C: 통합 테스트 (0.5일)
- 테스트 시나리오
- Preview: 루프/시그널/디지털 그룹별 필터 확인
- Build: 전체 재구축 후 활성 태그 변경 확인
- Apply: 선택 적용 확인
- Append: 기존 유지 + 추가 확인
- Add: 수동 추가 후 Gateway 폴링 확인
- Delete: 비활성화 + 이력 삭제 확인
Phase D: 기존 태그 관리 페이지 처리 (0.5일)
- 기존
pb.html태그 관리 기능- 현재 "태그 관리" 탭의 요약 카드, 필터, 테이블, 활성화 토글, Bulk 액션 등은 Point Builder의 "포인트 목록" 섹션과 통합하거나 별도 탭으로 유지
- 제안:
pb탭을 Point Builder로 완전 교체하고, 기존 태그 관리 기능은setup탭에 통합하거나#pane-tag-manager로 분리 - 또는
pb탭에 두 가지 서브뷰 제공:- 빌더 뷰 (기본): ExperionCrawler 스타일 포인트 선택/활성화
- 관리 뷰: 현재의 태그 목록 테이블 + Bulk 액션
부록: API 라우트 비교표
| ExperionCrawler | HC900 (변경 후) | 비고 |
|---|---|---|
POST /api/pointbuilder/preview |
POST /api/pointbuilder/preview |
동일 (내부 로직 변경) |
POST /api/pointbuilder/build |
POST /api/pointbuilder/build |
동일 (IsActive 토글로 변경) |
POST /api/pointbuilder/apply |
POST /api/pointbuilder/apply |
동일 (IsActive 토글로 변경) |
POST /api/pointbuilder/append |
POST /api/pointbuilder/append |
동일 (IsActive 토글로 변경) |
GET /api/pointbuilder/points |
GET /api/pointbuilder/points |
동일 (쿼리만 변경) |
POST /api/pointbuilder/add |
POST /api/pointbuilder/add |
동일 (필드 확장) |
DELETE /api/pointbuilder/{id} |
DELETE /api/pointbuilder/{id} |
동일 |
POST /api/realtime/start |
POST /api/gateway/start |
Gateway process 제어로 이동 |
POST /api/realtime/stop |
POST /api/gateway/stop |
Gateway process 제어로 이동 |
GET /api/realtime/status |
GET /api/gateway/status |
이미 구현됨 |
POST /api/tags/metadata/reload |
— | 불필요 (OPC UA 재조회 불가) |
GET /api/tags/metadata |
GET /api/metadata |
이미 구현됨 |
GET /api/tags/sub-area |
GET /api/subarea/{area} |
이미 구현됨 |
PUT /api/tags/sub-area |
PUT /api/subarea/{baseTag} |
이미 구현됨 |
POST /api/tags/sub-area/seed |
POST /api/subarea/seed |
이미 구현됨 |