feat: Point Builder 컨트롤러 스코핑 + realtime_table 기준 필터/표시
- 컨트롤러 선택 드롭다운 추가, 요약/목록/일괄작업을 선택 컨트롤러로 스코프 - 요약 카드 재정의: 카탈로그/등록(realtime)/라이브/비실시간/미등록 - 목록 필터를 is_active → realtime_table 멤버십(rt: 전체/실시간/실시간 제외)으로 교체 → 미등록 태그를 골라 실시간으로 보내는 워크플로 지원 - 목록 정렬을 controller_id → tagname 순으로(다중 컨트롤러 대비) - liveDict 조인을 (controller_id, tagname) 복합키로 정정 - Apply/Append/Sync에 controllerId 스코프 적용(타 컨트롤러 오염/손상 방지) - 전체 태그 목록을 2단 레이아웃 밖 하단 전체폭으로 이동, 컬럼폭 조정 - Steam Advisor TC 태그 .PV 접미사 정정 - 작업지시서 문서 추가 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
185
docs/작업지시서-포인트빌더-컨트롤러별-realtime_table-기준.md
Normal file
185
docs/작업지시서-포인트빌더-컨트롤러별-realtime_table-기준.md
Normal file
@@ -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` 상단(요약 카드 위)
|
||||
- `<select id="pf-controller">` + **`GET /api/hc900/tags/controller-ids`**로 옵션 채움.
|
||||
- 변경 시 `pbLoadSummary()` + `pbReload()` 재호출. 선택값 localStorage 유지(`pb-controller`).
|
||||
|
||||
**(F2) 요약 카드 재구성** — `pb.html` L38-39, `pb.js` `pbLoadSummary` L168-173
|
||||
- 카드: **카탈로그 / 등록(realtime_table) / 라이브 / 비실시간 / 미등록** (선택 컨트롤러 기준).
|
||||
- "활성(폴링 중)" 라벨 → **"등록(realtime_table)"** 으로 교체(오해 해소). 라이브/비실시간 별도 카드.
|
||||
|
||||
**(F3) 목록에 컨트롤러 파라미터 전달 + 등록 컬럼** — `pb.js` `pbReload` L143-151, `pb.html` L201 / `pb.js` L251-261
|
||||
- `params.set('controllerId', 선택값)` 추가.
|
||||
- "현재 활성" 컬럼 → **"등록/상태"** 로: `assigned` 기준 `등록(라이브)` / `등록(비실시간·stale)` / `미등록` 3분류 + 색상.
|
||||
- timestamp가 오래된(예: > 5분) 등록행은 **stale 뱃지** 표기(비실시간 식별 보조).
|
||||
|
||||
**(F4) 일괄작업 컨트롤러 스코프 안내** — `pb.js` `pbBulkActivate`/Apply/Append (L74-99)
|
||||
- 확인 다이얼로그에 "선택 컨트롤러(Cx) 범위" 명시. 호출 시 controllerId 전달.
|
||||
|
||||
---
|
||||
|
||||
## 5. 검증 (Acceptance)
|
||||
|
||||
선택 컨트롤러 = **C3** 기준:
|
||||
1. 요약 카드: 카탈로그 2146 / 등록 2146 / 라이브 2026 / 비실시간 120 / 미등록 0 으로 표시.
|
||||
2. 컨트롤러를 **C1**로 바꾸면: 카탈로그 1076 / 등록 0 / 미등록 1076.
|
||||
3. 목록에서 `LICA-5113.AL1SP1`(FlexibleParameter) → **등록(비실시간·stale)**, 타임스탬프 06-07.
|
||||
4. 목록에서 `FICQ-6101.PV` → **등록(라이브)**, 타임스탬프 현재.
|
||||
5. liveValue가 **다른 컨트롤러 동명 태그와 섞이지 않음**(controller 조인 확인).
|
||||
6. Apply를 C3에서 실행해도 C1/C2/C4의 is_active·realtime_table은 변하지 않음.
|
||||
|
||||
검증 쿼리(참고):
|
||||
```sql
|
||||
-- C3 기대치
|
||||
SELECT
|
||||
(SELECT count(*) FROM hc900.hc900_map_master WHERE controller_id='C3') catalog,
|
||||
(SELECT count(*) FROM hc900.realtime_table WHERE controller_id='C3') assigned,
|
||||
(SELECT count(*) FROM hc900.hc900_map_master WHERE controller_id='C3' AND is_active AND realtime_enabled) live;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. 영향 범위 / 주의
|
||||
|
||||
- **하위호환**: summary `controllerId` 미지정 시 기존 전체합산 유지 → 타 화면 영향 없음.
|
||||
- **B4-a/b는 데이터 손상·오염을 일으키는 활성 위험**(Append/Apply 클릭 시 발생): C3 Apply가 C1/C2/C4 is_active를 끄고(L341), Sync가 realtime_table에 C1/C2/C4 4715행을 추가. **반드시 controller 스코프 적용 후 다른 작업 진행.**
|
||||
- 본 작업은 realtime_enabled 정책을 바꾸지 않으므로 stale 120개는 그대로 남는다(의도). 가시화로 "stale임"만 드러낸다.
|
||||
- realtime_table은 C3만 존재 → C1/C2/C4는 "미등록"으로 정상 표기됨(게이트웨이 미가동).
|
||||
|
||||
---
|
||||
|
||||
## 7. 작업 순서 권장
|
||||
|
||||
1. **B4-a/b 먼저**(Apply 전역 UPDATE + Sync 오염 차단 — HIGH) → 2. B1/B2/B3/B4-c → 3. F1~F4 → 4. §5 검증.
|
||||
빌드/테스트: `cd src/Hc900Crawler && dotnet build` 후 Point Builder 탭에서 컨트롤러 전환·카드·목록 상태 육안 확인.
|
||||
|
||||
---
|
||||
|
||||
*본 문서는 작업지시(설계·범위)만 정의한다. 구현은 별도 진행하며, FlexibleParameters 실시간 편입은 범위 외(추후 Point Builder 개별 추가).*
|
||||
@@ -135,6 +135,7 @@ public class Hc900PointBuilderPreviewResult
|
||||
public class Hc900PointBuilderApplyDto
|
||||
{
|
||||
public List<string> SelectedTagNames { get; set; } = new();
|
||||
public string? ControllerId { get; set; }
|
||||
}
|
||||
|
||||
public class Hc900PointBuilderAddDto
|
||||
|
||||
@@ -315,6 +315,7 @@ public class Hc900TagManagerController : ControllerBase
|
||||
if (!string.IsNullOrEmpty(dto.ParamType)) q = q.Where(x => x.ParamType == dto.ParamType);
|
||||
if (dto.LoopNo.HasValue) q = q.Where(x => x.LoopNo == dto.LoopNo.Value);
|
||||
if (dto.Ids?.Any() == true) q = q.Where(x => dto.Ids.Contains(x.Id));
|
||||
if (!string.IsNullOrEmpty(dto.ControllerId)) q = q.Where(x => x.ControllerId == dto.ControllerId);
|
||||
|
||||
var count = await q.ExecuteUpdateAsync(s => s.SetProperty(x => x.IsActive, dto.Active));
|
||||
return Ok(new { updated = count, active = dto.Active });
|
||||
@@ -354,17 +355,37 @@ public class Hc900TagManagerController : ControllerBase
|
||||
}
|
||||
|
||||
[HttpGet("summary")]
|
||||
public async Task<IActionResult> GetSummary()
|
||||
public async Task<IActionResult> GetSummary([FromQuery] string? controllerId = null)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(controllerId))
|
||||
{
|
||||
var catalog = await _ctx.Hc900MapEntries
|
||||
.CountAsync(x => x.ControllerId == controllerId);
|
||||
var assigned = await _ctx.RealtimePoints
|
||||
.CountAsync(r => r.ControllerId == controllerId);
|
||||
var live = await _ctx.Hc900MapEntries
|
||||
.CountAsync(x => x.ControllerId == controllerId && x.IsActive && x.RealtimeEnabled);
|
||||
var config = assigned - live;
|
||||
var unassigned = catalog - assigned;
|
||||
var byType = await _ctx.Hc900MapEntries
|
||||
.Where(x => x.ControllerId == controllerId)
|
||||
.GroupBy(x => x.ParamType)
|
||||
.Select(g => new { paramType = g.Key, total = g.Count(), active = g.Count(x => x.IsActive) })
|
||||
.OrderBy(x => x.paramType)
|
||||
.ToListAsync();
|
||||
|
||||
return Ok(new { controllerId, catalog, assigned, live, config, unassigned, byType });
|
||||
}
|
||||
|
||||
var total = await _ctx.Hc900MapEntries.CountAsync();
|
||||
var active = await _ctx.Hc900MapEntries.CountAsync(x => x.IsActive);
|
||||
var byType = await _ctx.Hc900MapEntries
|
||||
var byTypeAll = await _ctx.Hc900MapEntries
|
||||
.GroupBy(x => x.ParamType)
|
||||
.Select(g => new { paramType = g.Key, total = g.Count(), active = g.Count(x => x.IsActive) })
|
||||
.OrderBy(x => x.paramType)
|
||||
.ToListAsync();
|
||||
var liveCount = await _ctx.RealtimePoints.CountAsync();
|
||||
return Ok(new { total, active, inactive = total - active, liveCount, byType });
|
||||
return Ok(new { total, active, inactive = total - active, liveCount, byType = byTypeAll });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -374,6 +395,7 @@ public class BulkActiveDto
|
||||
public string? ParamType { get; set; }
|
||||
public int? LoopNo { get; set; }
|
||||
public List<int>? Ids { get; set; }
|
||||
public string? ControllerId { get; set; }
|
||||
}
|
||||
|
||||
// ── Sub-Area ──────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -311,9 +311,11 @@ public class PointBuilderController : ControllerBase
|
||||
{
|
||||
if (dto.SelectedTagNames == null || dto.SelectedTagNames.Count == 0)
|
||||
return BadRequest(new { success = false, error = "selectedTagNames는 최소 1개 이상 필요합니다" });
|
||||
if (string.IsNullOrEmpty(dto.ControllerId))
|
||||
return BadRequest(new { success = false, error = "controllerId는 필수입니다" });
|
||||
|
||||
var count = await _db.Hc900ApplySelectedPointsAsync(dto.SelectedTagNames);
|
||||
_log.LogInformation("[PointBuilder] Apply 완료: {Count}개 활성화", count);
|
||||
var count = await _db.Hc900ApplySelectedPointsAsync(dto.ControllerId, dto.SelectedTagNames);
|
||||
_log.LogInformation("[PointBuilder] Apply 완료: {Count}개 활성화 (controller={Ctrl})", count, dto.ControllerId);
|
||||
return Ok(new { success = true, count, message = $"{count}개 포인트 활성화 완료" });
|
||||
}
|
||||
|
||||
@@ -322,15 +324,17 @@ public class PointBuilderController : ControllerBase
|
||||
{
|
||||
if (dto.SelectedTagNames == null || dto.SelectedTagNames.Count == 0)
|
||||
return BadRequest(new { success = false, error = "selectedTagNames는 최소 1개 이상 필요합니다" });
|
||||
if (string.IsNullOrEmpty(dto.ControllerId))
|
||||
return BadRequest(new { success = false, error = "controllerId는 필수입니다" });
|
||||
|
||||
var count = await _db.Hc900AppendPointsAsync(dto.SelectedTagNames);
|
||||
_log.LogInformation("[PointBuilder] Append 완료: {Count}개 추가", count);
|
||||
var count = await _db.Hc900AppendPointsAsync(dto.ControllerId, dto.SelectedTagNames);
|
||||
_log.LogInformation("[PointBuilder] Append 완료: {Count}개 추가 (controller={Ctrl})", count, dto.ControllerId);
|
||||
return Ok(new { success = true, count, message = $"{count}개 포인트 추가 완료" });
|
||||
}
|
||||
|
||||
[HttpGet("points")]
|
||||
public async Task<IActionResult> GetPoints(
|
||||
[FromQuery] bool? active = null,
|
||||
[FromQuery] string? rt = null, // null/"" = 전체, "only" = 실시간(realtime_table만), "exclude" = 실시간 제외
|
||||
[FromQuery] string? search = null,
|
||||
[FromQuery] string? paramType = null,
|
||||
[FromQuery] int? loopNo = null,
|
||||
@@ -340,8 +344,11 @@ public class PointBuilderController : ControllerBase
|
||||
{
|
||||
var q = _db.Hc900MapEntries.AsQueryable();
|
||||
|
||||
if (active.HasValue)
|
||||
q = q.Where(x => x.IsActive == active.Value);
|
||||
// realtime_table 멤버십 기준 필터 (is_active 아님). EXISTS로 번역됨.
|
||||
if (rt == "only")
|
||||
q = q.Where(m => _db.RealtimePoints.Any(r => r.ControllerId == m.ControllerId && r.TagName == m.TagName));
|
||||
else if (rt == "exclude")
|
||||
q = q.Where(m => !_db.RealtimePoints.Any(r => r.ControllerId == m.ControllerId && r.TagName == m.TagName));
|
||||
if (!string.IsNullOrEmpty(search))
|
||||
q = q.Where(x => x.TagName.Contains(search));
|
||||
if (!string.IsNullOrEmpty(paramType))
|
||||
@@ -360,24 +367,32 @@ public class PointBuilderController : ControllerBase
|
||||
if (pageSize > 1000) pageSize = 1000;
|
||||
var totalPages = (int)Math.Ceiling(total / (double)pageSize);
|
||||
|
||||
var entries = await q.OrderBy(x => x.TagName)
|
||||
var entries = await q.OrderBy(x => x.ControllerId).ThenBy(x => x.TagName)
|
||||
.Skip((page - 1) * pageSize)
|
||||
.Take(pageSize)
|
||||
.ToListAsync();
|
||||
|
||||
var tagNames = entries.Select(e => e.TagName).ToList();
|
||||
var liveDict = await _db.RealtimePoints
|
||||
var livePoints = await _db.RealtimePoints
|
||||
.Where(r => tagNames.Contains(r.TagName))
|
||||
.GroupBy(r => r.TagName)
|
||||
.ToDictionaryAsync(g => g.Key, g =>
|
||||
.ToListAsync();
|
||||
var liveDict = livePoints
|
||||
.GroupBy(r => new { r.ControllerId, r.TagName })
|
||||
.ToDictionary(g => g.Key, g =>
|
||||
{
|
||||
var first = g.OrderByDescending(r => r.Timestamp).FirstOrDefault();
|
||||
return new { first?.LiveValue, Timestamp = first?.Timestamp };
|
||||
});
|
||||
|
||||
var assignedSet = livePoints
|
||||
.Select(r => new { r.ControllerId, r.TagName })
|
||||
.ToHashSet();
|
||||
|
||||
var items = entries.Select(e =>
|
||||
{
|
||||
liveDict.TryGetValue(e.TagName, out var live);
|
||||
var key = new { ControllerId = e.ControllerId, TagName = e.TagName };
|
||||
liveDict.TryGetValue(key, out var live);
|
||||
var assigned = assignedSet.Contains(key);
|
||||
return new
|
||||
{
|
||||
id = e.Id,
|
||||
@@ -392,6 +407,7 @@ public class PointBuilderController : ControllerBase
|
||||
access = e.Access,
|
||||
liveValue = live?.LiveValue,
|
||||
timestamp = live?.Timestamp,
|
||||
assigned,
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -77,13 +77,13 @@
|
||||
"ModelDir": "/home/windpacer/projects/hc900_ax/scripts/analysis",
|
||||
"DefaultColumn": "C-6111",
|
||||
"Columns": {
|
||||
"C-6111": { "Feed": "FICQ-6101.PV", "Product": "FICQ-6118.PV", "TC": "TI-6111C", "SteamOp": "TICA-6111A.OP", "SteamFlow": "FIQ-6115" },
|
||||
"C-6211": { "Feed": "FICQ-6201.PV", "Product": "FICQ-6218.PV", "TC": "TI-6211C", "SteamOp": "TICA-6211A.OP", "SteamFlow": "FIQ-6215" },
|
||||
"C-8111": { "Feed": "FICQ-8101.PV", "Product": "FICQ-8118.PV", "TC": "TI-8111C", "SteamOp": "TICA-8111A.OP", "SteamFlow": "FIQ-8115" },
|
||||
"C-9111": { "Feed": "FICQ-9101.PV", "Product": "FICQ-9118.PV", "TC": "TI-9111C", "SteamOp": "TICA-9111A.OP", "SteamFlow": "FIQ-9115" },
|
||||
"C-9211": { "Feed": "FICQ-9201.PV", "Product": "FICQ-9218.PV", "TC": "TI-9211C", "SteamOp": "TICA-9211A.OP", "SteamFlow": "FIQ-9215" },
|
||||
"C-10111": { "Feed": "FICQ-10101.PV", "Product": "FICQ-10118.PV", "TC": "TI-10111C", "SteamOp": "TICA-10111A.OP", "SteamFlow": "FIQ-10115" },
|
||||
"C-10211": { "Feed": "FICQ-10201.PV", "Product": "FICQ-10218.PV", "TC": "TI-10211C", "SteamOp": "TICA-10211A.OP", "SteamFlow": "FIQ-10215" }
|
||||
"C-6111": { "Feed": "FICQ-6101.PV", "Product": "FICQ-6118.PV", "TC": "TI-6111C.PV", "SteamOp": "TICA-6111A.OP", "SteamFlow": "FIQ-6115" },
|
||||
"C-6211": { "Feed": "FICQ-6201.PV", "Product": "FICQ-6218.PV", "TC": "TI-6211C.PV", "SteamOp": "TICA-6211A.OP", "SteamFlow": "FIQ-6215" },
|
||||
"C-8111": { "Feed": "FICQ-8101.PV", "Product": "FICQ-8118.PV", "TC": "TI-8111C.PV", "SteamOp": "TICA-8111A.OP", "SteamFlow": "FIQ-8115" },
|
||||
"C-9111": { "Feed": "FICQ-9101.PV", "Product": "FICQ-9118.PV", "TC": "TI-9111C.PV", "SteamOp": "TICA-9111A.OP", "SteamFlow": "FIQ-9115" },
|
||||
"C-9211": { "Feed": "FICQ-9201.PV", "Product": "FICQ-9218.PV", "TC": "TI-9211C.PV", "SteamOp": "TICA-9211A.OP", "SteamFlow": "FIQ-9215" },
|
||||
"C-10111": { "Feed": "FICQ-10101.PV", "Product": "FICQ-10118.PV", "TC": "TI-10111C.PV", "SteamOp": "TICA-10111A.OP", "SteamFlow": "FIQ-10115" },
|
||||
"C-10211": { "Feed": "FICQ-10201.PV", "Product": "FICQ-10218.PV", "TC": "TI-10211C.PV", "SteamOp": "TICA-10211A.OP", "SteamFlow": "FIQ-10215" }
|
||||
}
|
||||
},
|
||||
"Kestrel": {
|
||||
|
||||
@@ -79,11 +79,17 @@ async function pbBuild() {
|
||||
pbLoadSummary();
|
||||
}
|
||||
|
||||
function pbGetControllerId() {
|
||||
return document.getElementById('pf-controller')?.value || '';
|
||||
}
|
||||
|
||||
async function pbApply() {
|
||||
const selected = pbGetSelectedTagNames();
|
||||
const ctrl = pbGetControllerId();
|
||||
if (selected.length === 0) { alert('미리보기에서 활성화할 태그를 선택해주세요.'); return; }
|
||||
if (!confirm(`기존 활성화를 모두 해제하고 선택한 ${selected.length}개만 활성화합니다. 계속하시겠습니까?`)) return;
|
||||
const res = await pbApi('/api/pointbuilder/apply', 'POST', { selectedTagNames: selected });
|
||||
if (!ctrl) { alert('컨트롤러를 선택해주세요.'); return; }
|
||||
if (!confirm(`[${ctrl}] 기존 활성화를 모두 해제하고 선택한 ${selected.length}개만 활성화합니다. 계속하시겠습니까?`)) return;
|
||||
const res = await pbApi('/api/pointbuilder/apply', 'POST', { selectedTagNames: selected, controllerId: ctrl });
|
||||
alert(res.message || `${res.count}개 활성화됨`);
|
||||
pbRefresh();
|
||||
pbLoadSummary();
|
||||
@@ -91,8 +97,10 @@ async function pbApply() {
|
||||
|
||||
async function pbAppend() {
|
||||
const selected = pbGetSelectedTagNames();
|
||||
const ctrl = pbGetControllerId();
|
||||
if (selected.length === 0) { alert('미리보기에서 추가할 태그를 선택해주세요.'); return; }
|
||||
const res = await pbApi('/api/pointbuilder/append', 'POST', { selectedTagNames: selected });
|
||||
if (!ctrl) { alert('컨트롤러를 선택해주세요.'); return; }
|
||||
const res = await pbApi('/api/pointbuilder/append', 'POST', { selectedTagNames: selected, controllerId: ctrl });
|
||||
if (res.count > 0) {
|
||||
alert(`${res.count}개 추가됨`);
|
||||
} else {
|
||||
@@ -143,10 +151,12 @@ function pbScheduleReload() {
|
||||
async function pbReload(resetPage = true) {
|
||||
if (resetPage) pbPage = 1;
|
||||
const search = document.getElementById('pf-search')?.value.trim() || '';
|
||||
const active = document.getElementById('pf-active')?.value || '';
|
||||
const rt = document.getElementById('pf-rt')?.value || '';
|
||||
const ctrl = pbGetControllerId();
|
||||
const params = new URLSearchParams();
|
||||
if (search) params.set('search', search);
|
||||
if (active) params.set('active', active);
|
||||
if (rt) params.set('rt', rt);
|
||||
if (ctrl) params.set('controllerId', ctrl);
|
||||
params.set('page', pbPage);
|
||||
params.set('pageSize', pbPageSize);
|
||||
const res = await pbApi(`/api/pointbuilder/points?${params.toString()}`, 'GET');
|
||||
@@ -166,11 +176,25 @@ function pbGoPage(delta) {
|
||||
}
|
||||
|
||||
async function pbLoadSummary() {
|
||||
const s = await pbApi('/api/hc900/tags/summary', 'GET');
|
||||
document.getElementById('s-total').textContent = s.total ?? '—';
|
||||
document.getElementById('s-active').textContent = s.active ?? '—';
|
||||
document.getElementById('s-inactive').textContent = s.inactive ?? '—';
|
||||
document.getElementById('s-live').textContent = s.liveCount ?? '—';
|
||||
const ctrl = pbGetControllerId();
|
||||
const url = ctrl ? `/api/hc900/tags/summary?controllerId=${encodeURIComponent(ctrl)}` : '/api/hc900/tags/summary';
|
||||
const s = await pbApi(url, 'GET');
|
||||
|
||||
if (s.controllerId) {
|
||||
// 컨트롤러 지정 응답: catalog / assigned / live / config / unassigned
|
||||
document.getElementById('s-catalog').textContent = s.catalog ?? '—';
|
||||
document.getElementById('s-assigned').textContent = s.assigned ?? '—';
|
||||
document.getElementById('s-live').textContent = s.live ?? '—';
|
||||
document.getElementById('s-config').textContent = s.config ?? '—';
|
||||
document.getElementById('s-unassigned').textContent = s.unassigned ?? '—';
|
||||
} else {
|
||||
// 전체 응답 (하위호환)
|
||||
document.getElementById('s-catalog').textContent = s.total ?? '—';
|
||||
document.getElementById('s-assigned').textContent = s.liveCount ?? '—';
|
||||
document.getElementById('s-live').textContent = '—';
|
||||
document.getElementById('s-config').textContent = '—';
|
||||
document.getElementById('s-unassigned').textContent = '—';
|
||||
}
|
||||
}
|
||||
|
||||
async function pbLoadGatewayStatus() {
|
||||
@@ -246,11 +270,28 @@ function pbRenderPoints(res) {
|
||||
return;
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
const staleMs = 5 * 60 * 1000;
|
||||
|
||||
tbody.innerHTML = res.items.map(item => {
|
||||
const badgeClass = `param-badge p-${item.paramType}`.replace(/[^a-zA-Z0-9\s-]/g, '');
|
||||
const activeLabel = item.isActive
|
||||
? '<span style="color:#3c3;font-weight:600">활성</span>'
|
||||
: '<span style="color:#c55">비활성</span>';
|
||||
let statusLabel, statusStyle;
|
||||
if (item.assigned && item.liveValue != null && item.timestamp) {
|
||||
const ts = new Date(item.timestamp).getTime();
|
||||
if (now - ts > staleMs) {
|
||||
statusLabel = '<span style="color:#a60;font-weight:600">등록(stale)</span>';
|
||||
statusStyle = '';
|
||||
} else {
|
||||
statusLabel = '<span style="color:#3c3;font-weight:600">등록(라이브)</span>';
|
||||
statusStyle = '';
|
||||
}
|
||||
} else if (item.assigned) {
|
||||
statusLabel = '<span style="color:#a60;font-weight:600">등록(비실시간)</span>';
|
||||
statusStyle = '';
|
||||
} else {
|
||||
statusLabel = '<span style="color:#666">미등록</span>';
|
||||
statusStyle = 'opacity:0.5';
|
||||
}
|
||||
return `<tr style="${item.isActive ? '' : 'opacity:0.6'}">
|
||||
<td><input type="checkbox" value="${item.id}" class="pb-point-chk" onchange="pbUpdateBulkBar()"></td>
|
||||
<td>${item.tagName}</td>
|
||||
@@ -258,7 +299,7 @@ function pbRenderPoints(res) {
|
||||
<td>${item.dataType}</td>
|
||||
<td>0x${(item.modbusAddr ?? 0).toString(16).toUpperCase().padStart(4,'0')}</td>
|
||||
<td>${item.controllerId}</td>
|
||||
<td>${activeLabel}</td>
|
||||
<td style="${statusStyle}">${statusLabel}</td>
|
||||
<td style="white-space:nowrap">
|
||||
<span style="cursor:pointer;font-size:14px" onclick="pbToggleOne(${item.id}, ${!item.isActive})" title="${item.isActive ? '비활성화' : '활성화'}">${item.isActive ? '🟢' : '🔴'}</span>
|
||||
<button class="btn-b btn-sm" style="margin-left:6px;color:var(--red,#e55);border-color:var(--red,#e55)" onclick="pbDeleteOne(${item.id})">🗑 삭제</button>
|
||||
@@ -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();
|
||||
|
||||
@@ -32,17 +32,26 @@
|
||||
|
||||
<div class="pane-body" style="display:flex;flex-direction:column;gap:12px;padding:16px">
|
||||
|
||||
<!-- 컨트롤러 선택 -->
|
||||
<div style="display:flex;gap:10px;flex-wrap:wrap;align-items:center">
|
||||
<label style="font-size:12px;color:var(--t1);font-weight:600">컨트롤러:</label>
|
||||
<select id="pf-controller" class="inp" style="width:110px" onchange="pbOnControllerChange()">
|
||||
<option value="">전체</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- 요약 카드 -->
|
||||
<div style="display:flex;gap:10px;flex-wrap:wrap">
|
||||
<div class="pb-scard"><div class="num" id="s-total">—</div><div class="lbl">전체 태그</div></div>
|
||||
<div class="pb-scard active"><div class="num" id="s-active">—</div><div class="lbl">활성 (폴링 중)</div></div>
|
||||
<div class="pb-scard inactive"><div class="num" id="s-inactive">—</div><div class="lbl">비활성</div></div>
|
||||
<div class="pb-scard live"><div class="num" id="s-live">—</div><div class="lbl">실시간 값 보유</div></div>
|
||||
<div class="pb-scard"><div class="num" id="s-catalog">—</div><div class="lbl">카탈로그</div></div>
|
||||
<div class="pb-scard active"><div class="num" id="s-assigned">—</div><div class="lbl">등록(realtime)</div></div>
|
||||
<div class="pb-scard live"><div class="num" id="s-live">—</div><div class="lbl">라이브</div></div>
|
||||
<div class="pb-scard" style="border-color:#a60"><div class="num" id="s-config" style="color:#a60">—</div><div class="lbl">비실시간</div></div>
|
||||
<div class="pb-scard inactive"><div class="num" id="s-unassigned">—</div><div class="lbl">미등록</div></div>
|
||||
</div>
|
||||
|
||||
<div style="display:flex;gap:14px;flex-wrap:wrap">
|
||||
<!-- 좌측: 그룹 카드 + 미리보기 -->
|
||||
<div style="flex:5;min-width:480px">
|
||||
<div style="flex:3;min-width:360px">
|
||||
|
||||
<!-- 루프 파라미터 -->
|
||||
<div class="pb-group-card" data-group="loop">
|
||||
@@ -208,53 +217,11 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 전체 태그 목록 -->
|
||||
<div style="margin-top:14px">
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:8px;flex-wrap:wrap;gap:6px">
|
||||
<h3 style="font-size:13px;font-weight:600;margin:0;color:var(--t1)">전체 태그 목록</h3>
|
||||
<div style="display:flex;gap:6px;flex-wrap:wrap;align-items:center">
|
||||
<input id="pf-search" class="inp" style="width:160px" placeholder="태그명 검색..." oninput="pbScheduleReload()">
|
||||
<select id="pf-active" class="inp" style="width:110px" onchange="pbReload()">
|
||||
<option value="">전체</option>
|
||||
<option value="true">활성만</option>
|
||||
<option value="false">비활성만</option>
|
||||
</select>
|
||||
<button class="btn-c" onclick="pbReload()" style="padding:3px 10px;font-size:11px">조회</button>
|
||||
<div style="display:flex;gap:6px" id="pb-points-bulk" hidden>
|
||||
<button class="btn-a" style="padding:3px 10px;font-size:11px" onclick="pbBulkActivate(true)">✓ 선택 활성화</button>
|
||||
<button class="btn-b" style="padding:3px 10px;font-size:11px;background:#5a1a1a;color:#faa" onclick="pbBulkActivate(false)">✗ 선택 비활성화</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pb-table-wrap">
|
||||
<table class="tbl" style="width:100%;table-layout:fixed">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width:30px"><input type="checkbox" id="pb-chk-all-points" onchange="pbToggleAllPoints(this)"></th>
|
||||
<th style="width:120px">태그명</th>
|
||||
<th style="width:60px">파라미터</th>
|
||||
<th style="width:85px">데이터 타입</th>
|
||||
<th style="width:85px">Modbus</th>
|
||||
<th style="width:80px">컨트롤러</th>
|
||||
<th style="width:70px">상태</th>
|
||||
<th style="width:130px">관리</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="pb-points-tbody">
|
||||
<tr><td colspan="8" style="text-align:center;padding:30px;color:#555">로딩 중...</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div style="display:flex;align-items:center;justify-content:flex-end;gap:10px;margin-top:8px;font-size:12px;color:var(--t1)">
|
||||
<span id="pb-pager-info">—</span>
|
||||
<button class="btn-c" id="pb-pager-prev" style="padding:3px 10px;font-size:11px" onclick="pbGoPage(-1)">‹ 이전</button>
|
||||
<button class="btn-c" id="pb-pager-next" style="padding:3px 10px;font-size:11px" onclick="pbGoPage(1)">다음 ›</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 전체 태그 목록은 2단 레이아웃 밖 하단 전체폭으로 이동 -->
|
||||
</div>
|
||||
|
||||
<!-- 우측: Sinam 파싱 + 상태 + 수동 추가 -->
|
||||
<div style="flex:1;min-width:260px;max-width:340px">
|
||||
<div style="flex:2;min-width:300px;max-width:560px">
|
||||
|
||||
<div class="pb-right-card">
|
||||
<h3>Sinam xlsx 파싱</h3>
|
||||
@@ -317,4 +284,48 @@
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 전체 태그 목록 (전체 폭) -->
|
||||
<div style="margin-top:4px">
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:8px;flex-wrap:wrap;gap:6px">
|
||||
<h3 style="font-size:13px;font-weight:600;margin:0;color:var(--t1)">전체 태그 목록</h3>
|
||||
<div style="display:flex;gap:6px;flex-wrap:wrap;align-items:center">
|
||||
<input id="pf-search" class="inp" style="width:160px" placeholder="태그명 검색..." oninput="pbScheduleReload()">
|
||||
<select id="pf-rt" class="inp" style="width:120px" onchange="pbReload()">
|
||||
<option value="">전체</option>
|
||||
<option value="only">실시간</option>
|
||||
<option value="exclude">실시간 제외</option>
|
||||
</select>
|
||||
<button class="btn-c" onclick="pbReload()" style="padding:3px 10px;font-size:11px">조회</button>
|
||||
<div style="display:flex;gap:6px" id="pb-points-bulk" hidden>
|
||||
<button class="btn-a" style="padding:3px 10px;font-size:11px" onclick="pbBulkActivate(true)">✓ 선택 활성화</button>
|
||||
<button class="btn-b" style="padding:3px 10px;font-size:11px;background:#5a1a1a;color:#faa" onclick="pbBulkActivate(false)">✗ 선택 비활성화</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pb-table-wrap">
|
||||
<table class="tbl" style="width:100%;table-layout:fixed">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width:30px"><input type="checkbox" id="pb-chk-all-points" onchange="pbToggleAllPoints(this)"></th>
|
||||
<th>태그명</th>
|
||||
<th style="width:60px">파라미터</th>
|
||||
<th style="width:85px">데이터 타입</th>
|
||||
<th style="width:85px">Modbus</th>
|
||||
<th style="width:50px">컨트롤러</th>
|
||||
<th style="width:75px">등록/상태</th>
|
||||
<th style="width:110px">관리</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="pb-points-tbody">
|
||||
<tr><td colspan="8" style="text-align:center;padding:30px;color:#555">로딩 중...</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div style="display:flex;align-items:center;justify-content:flex-end;gap:10px;margin-top:8px;font-size:12px;color:var(--t1)">
|
||||
<span id="pb-pager-info">—</span>
|
||||
<button class="btn-c" id="pb-pager-prev" style="padding:3px 10px;font-size:11px" onclick="pbGoPage(-1)">‹ 이전</button>
|
||||
<button class="btn-c" id="pb-pager-next" style="padding:3px 10px;font-size:11px" onclick="pbGoPage(1)">다음 ›</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -328,7 +328,7 @@ public class Hc900DbContext : DbContext
|
||||
return new Hc900PointBuilderPreviewResult { Count = items.Count, Items = items };
|
||||
}
|
||||
|
||||
public async Task<int> Hc900ApplySelectedPointsAsync(IEnumerable<string> selectedTagNames)
|
||||
public async Task<int> Hc900ApplySelectedPointsAsync(string controllerId, IEnumerable<string> 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<int> Hc900AppendPointsAsync(IEnumerable<string> tagNames)
|
||||
public async Task<int> Hc900AppendPointsAsync(string controllerId, IEnumerable<string> 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();
|
||||
|
||||
Reference in New Issue
Block a user