Files
HC900-Crawler/docs/포인트빌더-이식-설계서.md
windpacer d88784635e docs: 작업지시·진단·아키텍처 설계 문서 추가
온도프로파일/PV일관성/PointBuilder/history 작업지시, 신호태그·스팀유량 진단, 베이직아키텍처 재설계, MSDS, LLM채팅 구조 등.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 08:12:01 +09:00

31 KiB

HC900 Crawler 포인트빌더 이식 설계서

목적: ExperionCrawler포인트빌더-페이지-구현-명세.md에 정의된 Point Builder 페이지를 HC900 Crawler 프로젝트에 맞게 변환하여 이식하기 위한 상세 설계서.

핵심 차이: ExperionCrawler는 OPC UA node_map_master를 소스로 사용하지만, HC900 Crawler는 Modbus TCP hc900_map_master를 소스로 사용한다.


진단: 아래 내용은 diagnosis-checklist.md 진단 룰 8단계를 적용한 설계 검토 결과. 우선순위: 🔴 HIGH(즉시 수정 권장) > 🟠 MED > 🟡 LOW


진단 결과 요약

🔴 1. 설계 원칙과 구현 코드 모순 — TRUNCATE vs IsActive 플래그

문제: §4.2가 "TRUNCATE realtime_table 대신 IsActive 플래그 사용"이라고 명시했으나, §6.5 SyncRealtimeTableAsyncTRUNCATE 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_activerealtime_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 BuildGroupQueryLoopNo 조건을 구현하지 않음. 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. 현황 분석
  2. 핵심 차이점
  3. 데이터 모델 변경
  4. API 엔드포인트 설계
  5. 프론트엔드 변경
  6. Hc900DbService 변경
  7. 마이그레이션 순서

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, PreviewRealtimeBuildAsyncNotImplementedException

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, HealthCheck
  • Hc900RealtimeService — 폴링 서비스 상태 노출

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_masterrealtime_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로 변경.

동작:

  1. UPDATE hc900_map_master SET is_active = false (전체 비활성화)
  2. 조건 매칭된 태그들의 is_active = true 설정
  3. 활성화된 태그를 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" }

동작:

  1. hc900_map_master에 INSERT (중복 TagName+ControllerId 체크)
  2. is_active = true 설정
  3. C++ Gateway가 다음 폴링 사이클에 자동으로 포함

4.7 DELETE /api/pointbuilder/{id}?purgeHistory=false

포인트 삭제 (= 비활성화).

실제 동작:

  1. hc900_map_master에서 is_active = false
  2. realtime_table에서 해당 행 DELETE
  3. 같은 base_tag의 잔여 행이 0이면 tag_metadata 고아 정리
  4. purgeHistory=trueDELETE 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일)

  1. DTO 신규 작성

    • Hc900PointBuilderGroupDtoPointBuilderGroupDto 대체 (TagPatterns, ParamTypes, DataType)
    • Hc900PointBuilderPreviewItem — preview item (TagName, ModbusAddr, ParamType, Group 등)
    • Hc900PointBuilderPreviewResult — preview 결과
    • Hc900PointBuilderBuildDto — build 요청
    • Hc900PointBuilderApplyDto — apply 요청 (SelectedTagNames)
    • Hc900PointBuilderAddDto — 수동 추가 요청
  2. Controller 신규 작성PointBuilderController.cs

    • /api/pointbuilder/preview
    • /api/pointbuilder/build
    • /api/pointbuilder/apply
    • /api/pointbuilder/append
    • /api/pointbuilder/points
    • /api/pointbuilder/add
    • DELETE /api/pointbuilder/{id}
  3. Hc900DbService 구현

    • BuildRealtimeTableAsync — 새 구현 (기존 NotImplementedException 대체)
    • PreviewRealtimeBuildAsync — 새 구현
    • ApplySelectedPointsAsync — 새 구현
    • AppendPointsAsync — 새 구현
    • AddRealtimePointAsync — 수정
    • SyncRealtimeTableAsync — 신규

Phase B: 프론트엔드 (1일)

  1. pb.html 재작성

    • ExperionCrawler 원본 레이아웃 유지
    • 그룹명/필드명 HC900에 맞게 수정
    • 우측 컬럼: 구독 제어 → 제거 (Gateway 상태만 표시)
    • 우측 컬럼: 수동 추가 → TagName + ModbusAddr + DataType + ParamType + Access + ControllerId
  2. pb.js 재작성

    • 데이터 수집: attributesparamTypes
    • Preview: NodeIdTagName + ParamType + LoopNo
    • Build/Apply: 전체 교체는 IsActive 플립
    • rtStart/rtStop/rtStatus → 제거
    • 수동 추가: 확장된 필드
  3. pb.css — 유지 (수정 불필요)

Phase C: 통합 테스트 (0.5일)

  1. 테스트 시나리오
    • Preview: 루프/시그널/디지털 그룹별 필터 확인
    • Build: 전체 재구축 후 활성 태그 변경 확인
    • Apply: 선택 적용 확인
    • Append: 기존 유지 + 추가 확인
    • Add: 수동 추가 후 Gateway 폴링 확인
    • Delete: 비활성화 + 이력 삭제 확인

Phase D: 기존 태그 관리 페이지 처리 (0.5일)

  1. 기존 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 이미 구현됨