diff --git a/docs/작업지시서-포인트빌더-컨트롤러별-realtime_table-기준.md b/docs/작업지시서-포인트빌더-컨트롤러별-realtime_table-기준.md new file mode 100644 index 0000000..d263847 --- /dev/null +++ b/docs/작업지시서-포인트빌더-컨트롤러별-realtime_table-기준.md @@ -0,0 +1,185 @@ +# 작업지시서 — Point Builder를 컨트롤러별 realtime_table 기준으로 전환 + +> 작성일: 2026-06-10 +> 대상: `src/Hc900Crawler` (Point Builder 탭 + 관련 API) +> 목적: Point Builder가 **선택한 컨트롤러(플랜트)의 realtime_table 실제 등록 상태**를 진실로 표시하도록 전환. +> 현재 `is_active`가 전 컨트롤러 모두 TRUE라 "무엇이 실제로 폴링/저장되는지"를 분간할 수 없는 문제를 해결. + +--- + +> ## 진단 결과 (코드 + 실데이터 교차 검증, 2026-06-10 재진단) +> +> 실제 코드(Hc900Controllers.cs, PointBuilderController.cs, Hc900DbContext.cs, Hc900RealtimeService.cs, pb.html, pb.js)와 **실 DB 데이터**(컨트롤러 간 동일 태그명 0건 확인)까지 대조한 결과: +> +> ### 🔴 HIGH — 1순위 +> +> 1. **Apply의 전역 `is_active=false`** (`Hc900DbContext.cs:341`, `Hc900ApplySelectedPointsAsync`) +> - `UPDATE hc900_map_master SET is_active = false` — **WHERE 절 없음**. C3에서 Apply 한 번이면 **C1/C2/C4의 is_active까지 전부 꺼져** 카탈로그 상태가 손상됨. +> - 수정: `WHERE controller_id = @c` 추가 (선택 컨트롤러만 비활성화). +> +> 2. **`Hc900SyncRealtimeTableAsync` 컨트롤러 미필터 → realtime_table 오염** (`Hc900DbContext.cs:446-483`) +> - `activeTags` = is_active 전체(전 컨트롤러), `existing` = realtime_table(C3만). → `toRemove = existing − active = 0`(제거 아님), **`toAdd = active − existing = 4715`(C1/C2/C4 행을 추가)**. +> - 즉 실패 양상은 "잘못 제거"가 아니라 **"잘못 추가(오염)"** — realtime_table이 2146→6861로 부풀어 "realtime_table=실제 폴링" 불변식이 깨짐. +> - **Append/Apply가 항상 Sync를 호출**(L347/L372)하므로, is_active 전역 TRUE인 현 상태에선 **Point Builder에서 Append/Apply를 누르는 순간 오염**됨 → 잠재버그 아닌 활성 위험. +> - 수정: controllerId 파라미터 추가 → activeTags·RealtimePoints 조회 모두 controller 필터. +> +> ### 🟡 DOC — 문서 오기 +> +> 3. **API 경로 오기** — `§4.1 B3`/`§F1`: 클래스 `[Route("api/hc900/tags")]`(`Hc900Controllers.cs:226`) + `[HttpGet("controller-ids")]`(L345) → 실제 경로는 **`GET /api/hc900/tags/controller-ids`**. +> - (내 초안 `/api/hc900/controllers`도, 1차 진단의 `/api/hc900/controller-ids`도 틀림 — `/tags` 세그먼트 누락) +> +> ### 🟢 LOW — 방어적 보강 (현재 무해) +> +> 4. **`Hc900AppendPointsAsync` 컨트롤러 미필터**(`Hc900DbContext.cs:368-370`) 및 **liveDict TagName 단일 매칭**(`PointBuilderController.cs:369-371`) +> - 코드상 tagName만 매칭은 맞으나, **실 DB에서 컨트롤러 간 동일 태그명이 0건**(`GROUP BY tagname HAVING count(DISTINCT controller_id)>1` → 0행)이라 **현재로선 오작동 불가**. 향후 동명 태그 도입 대비 방어적 보강 항목으로 강등. +> +> ### ✅ 현황 분석 정확성 +> 인용된 14개 코드 위치 모두 실제 파일과 일치. 다만 위 1·2는 심각도 HIGH(일상 클릭에 발생), 4는 LOW(데이터상 무해)로 재평가됨. + +--- + +## 1. 배경 (왜) + +### 1.1 확인된 현재 데이터 (2026-06-10) + +| | C1 | C2 | C3 | C4 | 합계 | +|---|---|---|---|---|---| +| map_master (카탈로그) | 1076 | 1538 | 2146 | 2101 | 6861 | +| is_active=TRUE | 1076 | 1538 | 2146 | 2101 | 6861 (전부) | +| **realtime_table 등록** | 0 | 0 | **2146** | 0 | **2146** | + +- **map_master = `build_register_map_from_sinam.py`가 만든 C1~C4 전체 카탈로그.** +- **realtime_table = 현재 가동 중인 C3 게이트웨이 것만 등록됨.** +- `is_active`는 전 컨트롤러 일괄 TRUE → **필터로서 의미 없음.** 실제 "등록/폴링" 진실은 **realtime_table 멤버십**. + +### 1.2 현재 동작·문제점 (코드 위치) + +| 구분 | 위치 | 문제 | +|---|---|---| +| 요약 카드 | `Hc900Controllers.cs` `GetSummary` L356-367 | 컨트롤러 무필터 → total/active를 4개 컨트롤러 합산(6861). "활성(폴링 중)"이 실제 폴링 수와 무관 | +| 요약 카드 UI | `pb.html` L38-39 (s-active "활성(폴링 중)", s-inactive) | 합산값 표시 → 어느 컨트롤러 것인지·실제 등록인지 불명 | +| 목록 API | `PointBuilderController.cs` `GetPoints` L331-398 | `controllerId` 파라미터는 **있으나** 프론트가 미사용. liveDict 조인이 **TagName만**으로 매칭(L369-371) → 컨트롤러 무시(동명 태그 0건이라 현재 무해, 방어적 정정 대상) | +| 목록 UI 필터 | `pb.html` L216-217 (pf-search, pf-active) | **컨트롤러 selector 없음** | +| 목록 로드 | `pb.js` `pbReload` L143-151 | params에 `controllerId` **미포함** | +| 요약 로드 | `pb.js` `pbLoadSummary` L168-173 | 무필터 summary 호출 | +| "현재 활성" 컬럼 | `pb.html` L201 / `pb.js` L251-261 | `isActive`만 표시 → realtime_table 등록·라이브 여부 구분 없음 | + +--- + +## 2. 용어 정의 (작업 공통 기준) + +| 용어 | 정의 (판정식) | 의미 | +|---|---|---| +| **카탈로그** | `hc900_map_master` 행 (controller_id별) | from_sinam로 만든 전체 후보 | +| **등록(assigned)** | `realtime_table`에 (controller_id, tagname) 존재 | 실제로 그 컨트롤러에 할당되어 값이 저장되는 태그 ← **Point Builder의 새 진실 기준** | +| **라이브(live)** | `is_active AND realtime_enabled` (= 폴링 서비스 `LoadMappingAsync` 조건, `Hc900RealtimeService.cs` L143) | 실시간 갱신됨 | +| **비실시간(config)** | 등록됐으나 `realtime_enabled=FALSE` (FlexibleParameters) | 행은 있으나 값이 stale (예: 알람SP·HZ) | +| **미등록** | 카탈로그엔 있으나 realtime_table에 없음 | C1/C2/C4 전체, 또는 C3 중 미할당분 | + +--- + +## 3. 변경 목표 (To-Be) + +1. Point Builder 상단에 **컨트롤러 선택 드롭다운(C1/C2/C3/C4)** 추가. 이후 모든 조회·요약·일괄작업이 **선택 컨트롤러로 스코프**된다. +2. 요약 카드를 **선택 컨트롤러 기준 + realtime_table 기준**으로 재정의: + - 카탈로그(총) / **등록(realtime_table)** / 라이브 / 비실시간(stale) / 미등록 +3. 목록에 **"등록"(realtime_table 존재) 상태 컬럼** 추가, "라이브/비실시간/미등록"을 시각 구분. +4. liveDict 조인을 **(controller_id, tagname)** 으로 정정. +5. 일괄 활성/비활성·Apply·Append가 **선택 컨트롤러 범위 안에서만** 동작. + +> ⚠ **범위 외(이번에 건드리지 않음)**: FlexibleParameters의 `realtime_enabled` 정책. HZ/HZSET 등 필요한 것은 **나중에 Point Builder에서 개별 추가**한다(별도 작업). 본 작업은 *가시화·컨트롤러 스코프*에 한정. + +--- + +## 4. 작업 항목 + +### 4.1 백엔드 + +**(B1) 요약 API에 컨트롤러 + realtime_table 기준 추가** +`Hc900Controllers.cs` `GetSummary` (L356-367) +- 쿼리파라미터 `controllerId` 추가(필수 또는 기본값). 미지정 시 전체 유지(하위호환). +- 카운트를 컨트롤러로 필터하고, **realtime_table 기준 항목**을 추가: + ``` + catalog = map_master WHERE controller_id=@c + assigned = realtime_table WHERE controller_id=@c -- 등록(진실) + live = map_master WHERE controller_id=@c AND is_active AND realtime_enabled + config = assigned - live (등록됐으나 비실시간) + unassigned= catalog - assigned + ``` +- 응답 예: `{ controllerId, catalog, assigned, live, config, unassigned, byType:[...] }` +- byType도 controller로 필터. + +**(B2) 목록 API liveDict 조인 정정** +`PointBuilderController.cs` `GetPoints` L369-376 +- `RealtimePoints.Where(r => tagNames.Contains(r.TagName))` → + **`(controllerId, tagName)` 복합 매칭**으로 변경. (controllerId 필터가 적용된 목록이므로 같은 controller로 조인) +- 응답 item에 **`assigned`(realtime_table 존재 bool)** 필드 추가. (현재 `liveValue/timestamp`로 간접 판단 → 명시 필드로) + +**(B3) 컨트롤러 목록 API 확인** +- 컨트롤러 드롭다운 채울 소스 필요. 기존 **`GET /api/hc900/tags/controller-ids`**(Hc900Controllers L345-353, map_master DISTINCT controller_id) 재사용. (응답: `["C1","C2","C3","C4"]`) + +**(B4) 🔴 Apply/Sync/Append 컨트롤러 스코프 — 최우선 (데이터 손상/오염 방지)** +`Hc900DbContext.cs` `Hc900ApplySelectedPointsAsync`(L331), `Hc900AppendPointsAsync`(L359), `Hc900SyncRealtimeTableAsync`(L446) +- **(B4-a) HIGH** `UPDATE hc900_map_master SET is_active=false`(L341)에 **`WHERE controller_id=@c` 추가.** 현재 WHERE가 없어 C3 Apply가 C1/C2/C4의 is_active까지 전부 끔. +- **(B4-b) HIGH** `Hc900SyncRealtimeTableAsync`에 **controllerId 파라미터 추가** → `activeTags`(L448)·`existingTagNames`(L455) 모두 `WHERE controller_id=@c` 적용. 현재 미필터라 Apply/Append 시 `toAdd`로 C1/C2/C4 4715행을 realtime_table에 **오염 추가**(2146→6861). Apply/Append가 controllerId를 Sync로 전달하도록 시그니처 변경. +- **(B4-c) LOW** `Hc900AppendPointsAsync`(L368-370) WHERE에 controllerId 추가(방어용 — 현재 동명 태그 0건이라 무해하나 일관성). +- ※ 검증: Apply/Append 후 §5 기대치(C3 외 컨트롤러 행 불변, realtime_table C3=2146 유지)와 즉시 일치하는지 확인. + +### 4.2 프론트 + +**(F1) 컨트롤러 드롭다운 추가** — `pb.html` 상단(요약 카드 위) +- ` ${item.tagName} @@ -258,7 +299,7 @@ function pbRenderPoints(res) { ${item.dataType} 0x${(item.modbusAddr ?? 0).toString(16).toUpperCase().padStart(4,'0')} ${item.controllerId} - ${activeLabel} + ${statusLabel} ${item.isActive ? '🟢' : '🔴'} @@ -304,8 +345,10 @@ async function pbDeleteOne(id) { async function pbBulkActivate(active) { const ids = Array.from(document.querySelectorAll('#pb-points-tbody .pb-point-chk:checked')) .map(cb => parseInt(cb.value)); + const ctrl = pbGetControllerId(); if (ids.length === 0) return; - const res = await pbApi('/api/hc900/tags/bulk-active', 'POST', { ids, active }); + if (!confirm(`[${ctrl || '전체'}] 선택한 ${ids.length}개를 ${active ? '활성화' : '비활성화'}합니다. 계속하시겠습니까?`)) return; + const res = await pbApi('/api/hc900/tags/bulk-active', 'POST', { ids, active, controllerId: ctrl || undefined }); if (res) { pbRefresh(); pbLoadSummary(); @@ -447,9 +490,37 @@ async function pbSinamGwRestart() { } } +function pbOnControllerChange() { + const ctrl = pbGetControllerId(); + try { localStorage.setItem('pb-controller', ctrl); } catch {} + pbLoadSummary(); + pbReload(); +} + +async function pbLoadControllers() { + try { + const ids = await pbApi('/api/hc900/tags/controller-ids', 'GET'); + const sel = document.getElementById('pf-controller'); + if (!sel) return; + const saved = (() => { try { return localStorage.getItem('pb-controller'); } catch { return ''; } })(); + ids.forEach(id => { + const opt = document.createElement('option'); + opt.value = id; + opt.textContent = id; + sel.appendChild(opt); + }); + if (saved && ids.includes(saved)) { + sel.value = saved; + } else if (ids.includes('C3')) { + sel.value = 'C3'; + } + } catch {} +} + // ── Init ────────────────────────────────────────────────────────────────────── paneInit.pb = function() { + pbLoadControllers(); pbLoadSummary(); pbReload(); pbLoadGatewayStatus(); diff --git a/src/Hc900Crawler/wwwroot/panes/pb.html b/src/Hc900Crawler/wwwroot/panes/pb.html index 1183e92..b6db557 100644 --- a/src/Hc900Crawler/wwwroot/panes/pb.html +++ b/src/Hc900Crawler/wwwroot/panes/pb.html @@ -32,17 +32,26 @@
+ +
+ + +
+
-
전체 태그
-
활성 (폴링 중)
-
비활성
-
실시간 값 보유
+
카탈로그
+
등록(realtime)
+
라이브
+
비실시간
+
미등록
-
+
@@ -208,53 +217,11 @@
- -
-
-

전체 태그 목록

-
- - - - -
-
-
- - - - - - - - - - - - - - - - -
태그명파라미터데이터 타입Modbus컨트롤러상태관리
로딩 중...
-
-
- - - -
-
+
-
+

Sinam xlsx 파싱

@@ -317,4 +284,48 @@
+ + +
+
+

전체 태그 목록

+
+ + + + +
+
+
+ + + + + + + + + + + + + + + + +
태그명파라미터데이터 타입Modbus컨트롤러등록/상태관리
로딩 중...
+
+
+ + + +
+
diff --git a/src/Infrastructure/Database/Hc900DbContext.cs b/src/Infrastructure/Database/Hc900DbContext.cs index 49146cc..baac8f3 100644 --- a/src/Infrastructure/Database/Hc900DbContext.cs +++ b/src/Infrastructure/Database/Hc900DbContext.cs @@ -328,7 +328,7 @@ public class Hc900DbContext : DbContext return new Hc900PointBuilderPreviewResult { Count = items.Count, Items = items }; } - public async Task Hc900ApplySelectedPointsAsync(IEnumerable selectedTagNames) + public async Task Hc900ApplySelectedPointsAsync(string controllerId, IEnumerable selectedTagNames) { var tagNames = selectedTagNames.Where(n => !string.IsNullOrEmpty(n)).ToList(); if (tagNames.Count == 0) return 0; @@ -337,14 +337,14 @@ public class Hc900DbContext : DbContext try { - await Database.ExecuteSqlRawAsync( - "UPDATE hc900_map_master SET is_active = false"); + await Database.ExecuteSqlInterpolatedAsync( + $"UPDATE hc900.hc900_map_master SET is_active = false WHERE controller_id = {controllerId}"); var count = await Hc900MapEntries - .Where(x => tagNames.Contains(x.TagName)) + .Where(x => tagNames.Contains(x.TagName) && x.ControllerId == controllerId) .ExecuteUpdateAsync(s => s.SetProperty(x => x.IsActive, true)); - await Hc900SyncRealtimeTableAsync(); + await Hc900SyncRealtimeTableAsync(controllerId); await tx.CommitAsync(); return count; @@ -356,7 +356,7 @@ public class Hc900DbContext : DbContext } } - public async Task Hc900AppendPointsAsync(IEnumerable tagNames) + public async Task Hc900AppendPointsAsync(string controllerId, IEnumerable tagNames) { var tagNamesList = tagNames.Where(n => !string.IsNullOrEmpty(n)).ToList(); if (tagNamesList.Count == 0) return 0; @@ -366,10 +366,10 @@ public class Hc900DbContext : DbContext try { var count = await Hc900MapEntries - .Where(x => tagNamesList.Contains(x.TagName) && !x.IsActive) + .Where(x => tagNamesList.Contains(x.TagName) && x.ControllerId == controllerId && !x.IsActive) .ExecuteUpdateAsync(s => s.SetProperty(x => x.IsActive, true)); - await Hc900SyncRealtimeTableAsync(); + await Hc900SyncRealtimeTableAsync(controllerId); await tx.CommitAsync(); return count; @@ -443,24 +443,33 @@ public class Hc900DbContext : DbContext return q; } - private async Task Hc900SyncRealtimeTableAsync() + private async Task Hc900SyncRealtimeTableAsync(string? controllerId = null) { - var activeTags = await Hc900MapEntries - .Where(x => x.IsActive) + var activeQuery = Hc900MapEntries.Where(x => x.IsActive); + if (!string.IsNullOrEmpty(controllerId)) + activeQuery = activeQuery.Where(x => x.ControllerId == controllerId); + + var activeTags = await activeQuery .Select(x => new { x.TagName, x.Hc900Tag, x.ControllerId }) .ToListAsync(); var activeTagNames = activeTags.Select(t => t.TagName).ToList(); - var existingTagNames = await RealtimePoints + var existingQuery = RealtimePoints.AsQueryable(); + if (!string.IsNullOrEmpty(controllerId)) + existingQuery = existingQuery.Where(r => r.ControllerId == controllerId); + + var existingTagNames = await existingQuery .Select(r => r.TagName) .ToListAsync(); var toRemove = existingTagNames.Except(activeTagNames).ToList(); if (toRemove.Count > 0) { - RealtimePoints.RemoveRange( - RealtimePoints.Where(r => toRemove.Contains(r.TagName))); + var removeQuery = RealtimePoints.Where(r => toRemove.Contains(r.TagName)); + if (!string.IsNullOrEmpty(controllerId)) + removeQuery = removeQuery.Where(r => r.ControllerId == controllerId); + RealtimePoints.RemoveRange(removeQuery); } var toAddTagNames = activeTagNames.Except(existingTagNames).ToList();