feat: Sub-Area(세부 Area) 분류 기능 + 포인트 삭제 시 메타데이터/이력 정리
하나의 area(P6)를 Column 단위 sub_area(P6-1/P6-2)로 분류. tag_metadata
attribute='sub_area'(EAV)에 저장, 공용 설비는 "P6-1,P6-2" 형식 + 토큰 매칭.
- 백엔드: GetSubAreaListByAreaAsync/UpdateSubAreaAsync/SeedSubAreaAsync,
SubAreaController(GET/PUT/POST seed), SubAreaDtos
- 포인트 삭제 개선: DeleteRealtimePointAsync(purgeHistory) — 잔여 0이면
고아 메타데이터(desc/area/sub_area) 정리, opt-in 시 history_table 영구 삭제
- MCP: find_tags(sub_area=...) + _area_or_subarea_filter('-' 포함 시 자동 토큰 매칭)
- 문서: prompts/plant_context.md, AGENTS.md, SubArea-추가플랜.md
- UI: 포인트빌더 Sub-Area 관리 카드(조회/수정/seed) + 행별 이력 삭제 체크박스
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
12
AGENTS.md
12
AGENTS.md
@@ -35,6 +35,18 @@ All controllers are in `src/Web/Controllers/ExperionControllers.cs` (single file
|
||||
- MCP 툴(`query_events`, `active_alarms`) JSON 출력 시 `prev_state_duration_s` 필드명으로 변환하여 반환하므로 LLM이 필드명만 보고 의미를 알 수 있음
|
||||
- LLM 프롬프트에 이벤트 데이터를 넘길 때는 `(직전상태유지={duration}s)` 형식으로 전달
|
||||
|
||||
### sub_area (세부 Area: P6-1/P6-2 등)
|
||||
|
||||
하나의 area(P6)는 2개 Column(증류탑)으로 나뉜다. 태그 단위 sub_area를 별도 저장한다.
|
||||
|
||||
- 저장: `tag_metadata`의 `attribute='sub_area'` (EAV). Single Source of Truth는 tag_metadata (OPC UA 아님).
|
||||
- 값 형식: 단일 `"P6-1"` 또는 **공용 `"P6-1,P6-2"`**(여러 sub_area 공용 설비, 예: 진공펌프 vp-2127a/b).
|
||||
- **매칭은 항상 토큰 비교**: `'P6-1' = ANY(string_to_array(value, ','))` — `value = 'P6-1'` 직접 비교 금지(공용 누락).
|
||||
- `MetadataLoaderService`는 `attribute IN ('desc','area')`만 건드림 → sub_area는 절대 덮어쓰지 않음.
|
||||
- `event_history_table`에는 저장 안 함 → 이벤트 조회 시 `tag_metadata`를 `split_part(tagname,'.',1)`로 JOIN.
|
||||
- seed: `IExperionDbService.SeedSubAreaAsync(dryRun)` — area-scoped 번호 prefix + pid_equipment 공용(role ILIKE '%공용%') 검출. prefix 미적용 태그는 NULL(수동 분류). `POST /api/tags/sub-area/seed`.
|
||||
- MCP: `find_tags(sub_area=...)` 또는 `active_alarms/query_events/...(area="P6-1")` — '-' 포함 시 server.py가 자동으로 sub_area 토큰 매칭.
|
||||
|
||||
### ⏰ Timezone — KST
|
||||
|
||||
DB는 UTC 저장, LLM에는 **KST(UTC+9) 변환**해서 전달.
|
||||
|
||||
1380
SubArea-추가플랜.md
Normal file
1380
SubArea-추가플랜.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -662,15 +662,37 @@ Tables:
|
||||
event_history_table(tagname TEXT, prev_value TEXT, curr_value TEXT, event_type TEXT, event_time TIMESTAMPTZ, duration_seconds INT)
|
||||
|
||||
Views:
|
||||
v_tag_summary(base_tag TEXT, pv TEXT, sp TEXT, op TEXT, description TEXT, area TEXT)
|
||||
v_tag_summary(base_tag TEXT, pv TEXT, sp TEXT, op TEXT, description TEXT, area TEXT, sub_area TEXT)
|
||||
|
||||
Rules:
|
||||
- SELECT only. tagname lowercase exact match.
|
||||
- value is TEXT; cast ::double precision when aggregating.
|
||||
- time_bucket() banned. For N-min buckets: to_timestamp(FLOOR(EXTRACT(EPOCH FROM recorded_at)/(N*60))*(N*60))
|
||||
- KST input = UTC-9 in DB.
|
||||
- sub_area는 "P6-1" 또는 공용 "P6-1,P6-2" 형식. 매칭은 항상 토큰 비교: 'P6-1' = ANY(string_to_array(sub_area, ','))
|
||||
"""
|
||||
|
||||
|
||||
def _area_or_subarea_filter(area: str | None, tagname_col: str, area_col: str) -> tuple[str, list]:
|
||||
"""area 필터 SQL 조건 + 파라미터 반환.
|
||||
|
||||
- area가 "P6-1"처럼 '-'를 포함하면 sub_area 코드로 간주 →
|
||||
tag_metadata(attribute='sub_area')를 EXISTS 조인하여 토큰 매칭 (공용 "P6-1,P6-2" 포함).
|
||||
이벤트 테이블의 tagname은 'p-6116.pv' 형식이므로 split_part로 base_tag 추출.
|
||||
- 그 외(area 코드, 예 "P6")는 기존 컬럼 정확 매칭.
|
||||
- area가 None이면 항상 참(TRUE).
|
||||
"""
|
||||
if not area:
|
||||
return "TRUE", []
|
||||
if "-" in area: # sub_area 코드
|
||||
return (
|
||||
f"EXISTS (SELECT 1 FROM tag_metadata tm "
|
||||
f"WHERE tm.base_tag = split_part({tagname_col}, '.', 1) "
|
||||
f"AND tm.attribute = 'sub_area' AND %s = ANY(string_to_array(tm.value, ',')))",
|
||||
[area],
|
||||
)
|
||||
return f"{area_col} = %s", [area]
|
||||
|
||||
# ── RAG 도구 ─────────────────────────────────────────────────────────────────
|
||||
|
||||
@mcp.tool()
|
||||
@@ -1206,57 +1228,62 @@ _VALID_EVENT_TYPES = ("ALARM", "TRIP", "NORMAL", "RUN", "CHANGE")
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def find_tags(query: str, area: str | None = None, top_k: int = 20) -> str:
|
||||
"""태그 검색 — base_tag/설명(desc)/area 통합 검색 (v_tag_summary 뷰 기반).
|
||||
async def find_tags(query: str, area: str | None = None, sub_area: str | None = None, top_k: int = 20) -> str:
|
||||
"""태그 검색 — base_tag/설명(desc)/area/sub_area 통합 검색 (v_tag_summary 뷰 기반).
|
||||
|
||||
사용 시점: 사용자가 "온도", "Tower 1 압력", "운전 중인 펌프" 같은 자연어로
|
||||
태그를 지칭할 때 실제 base_tag(예: 'ti-6101', 'p-6102')를 역으로 찾기 위해.
|
||||
|
||||
get_tag_metadata와 차이: 단순 tagname LIKE만 보지 않고 description/area에도
|
||||
매칭하며, 현재 PV/SP/OP/description/area를 함께 반환.
|
||||
매칭하며, 현재 PV/SP/OP/description/area/sub_area를 함께 반환.
|
||||
|
||||
Args:
|
||||
query: 검색어 (base_tag 또는 description 부분 일치, 대소문자 무시)
|
||||
area: (선택) area 필터 (예: 'tower-1', 'utility'). NULL이면 전체
|
||||
area: (선택) area 필터 (예: 'P6'). "P6-1"처럼 '-'가 있으면 sub_area로 자동 처리.
|
||||
sub_area: (선택) sub_area 필터 (예: 'P6-1'). 공용 태그("P6-1,P6-2")도 매칭됨.
|
||||
top_k: 반환 태그 수 (기본 20, 최대 100)
|
||||
|
||||
Returns:
|
||||
JSON: { success, query, count, tags: [{base_tag, pv, sp, op, description, area}] }
|
||||
JSON: { success, query, count, tags: [{base_tag, pv, sp, op, description, area, sub_area}] }
|
||||
"""
|
||||
conn = None
|
||||
try:
|
||||
top_k = max(1, min(top_k, 100))
|
||||
q = f"%{query.strip()}%"
|
||||
|
||||
# sub_area 명시 파라미터 우선, 없으면 area가 "P6-1" 형식이면 sub_area로 간주
|
||||
effective_sub = sub_area or (area if (area and "-" in area) else None)
|
||||
effective_area = None if (area and "-" in area) else area
|
||||
|
||||
conds = ["(base_tag ILIKE %s OR description ILIKE %s)"]
|
||||
params: list = [q, q]
|
||||
if effective_sub:
|
||||
conds.append("%s = ANY(string_to_array(sub_area, ','))")
|
||||
params.append(effective_sub)
|
||||
if effective_area:
|
||||
# v_tag_summary.area는 '{12 | P6 | }' 원본 형식이므로 코드만 추출해 비교
|
||||
conds.append("trim(split_part(area, '|', 2)) = %s")
|
||||
params.append(effective_area)
|
||||
|
||||
sql = (
|
||||
"SELECT base_tag, pv, sp, op, description, area, sub_area "
|
||||
"FROM v_tag_summary WHERE " + " AND ".join(conds) +
|
||||
" ORDER BY base_tag LIMIT %s"
|
||||
)
|
||||
params.append(top_k)
|
||||
|
||||
conn = await _get_db_connection()
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(f"SET statement_timeout = {SQL_STATEMENT_TIMEOUT_MS}")
|
||||
if area:
|
||||
cur.execute(
|
||||
"""SELECT base_tag, pv, sp, op, description, area
|
||||
FROM v_tag_summary
|
||||
WHERE (base_tag ILIKE %s OR description ILIKE %s)
|
||||
AND area = %s
|
||||
ORDER BY base_tag
|
||||
LIMIT %s""",
|
||||
(q, q, area, top_k)
|
||||
)
|
||||
else:
|
||||
cur.execute(
|
||||
"""SELECT base_tag, pv, sp, op, description, area
|
||||
FROM v_tag_summary
|
||||
WHERE base_tag ILIKE %s OR description ILIKE %s
|
||||
ORDER BY base_tag
|
||||
LIMIT %s""",
|
||||
(q, q, top_k)
|
||||
)
|
||||
cur.execute(sql, params)
|
||||
rows = cur.fetchall()
|
||||
tags = [
|
||||
{"base_tag": r[0], "pv": r[1], "sp": r[2], "op": r[3],
|
||||
"description": r[4], "area": r[5]}
|
||||
"description": r[4], "area": r[5], "sub_area": r[6]}
|
||||
for r in rows
|
||||
]
|
||||
return json.dumps({
|
||||
"success": True, "query": query, "area": area,
|
||||
"success": True, "query": query, "area": area, "sub_area": effective_sub,
|
||||
"count": len(tags), "tags": tags
|
||||
}, ensure_ascii=False, indent=2)
|
||||
except Exception as e:
|
||||
@@ -1279,7 +1306,9 @@ async def trace_connections(start_tag: str, direction: str = "downstream", max_d
|
||||
max_depth: 최대 추적 깊이 (기본 20)
|
||||
|
||||
Returns:
|
||||
JSON: { success, start_tag, direction, path: [{step, from_tag, to_tag, role, tag_no}] }
|
||||
JSON: { success, start_tag, direction, path: [{step, tag_no, from_tag, to_tag, role, live_state}] }
|
||||
- from_tag/to_tag에 쉼표가 여러 개면 병렬 펌프·라인 → 모두 경로에 포함됨(누락 없음).
|
||||
- live_state: 해당 태그의 실시간 상태(예: R-RUN/L-STOP). 병렬 펌프 중 R-RUN/L-RUN이 현재 공급원.
|
||||
"""
|
||||
conn = None
|
||||
try:
|
||||
@@ -1385,7 +1414,22 @@ async def trace_connections(start_tag: str, direction: str = "downstream", max_d
|
||||
visited.add(start_tag)
|
||||
start_tags = _split_tags(start_tag)
|
||||
_trace_upstream(start_tags, visited, 0)
|
||||
|
||||
|
||||
# 각 노드에 실시간 상태(pv) 부착 — 병렬 펌프 중 '실제 가동 중'인 것을 식별.
|
||||
# 예: F-6101A/B 상류에 P-6102(R-RUN)·P-6201(L-STOP)이 병렬이면 현재 공급원은 P-6102.
|
||||
if path:
|
||||
pv_tags = [p["tag_no"].lower() + ".pv" for p in path]
|
||||
cur.execute(
|
||||
"SELECT tagname, livevalue FROM realtime_table WHERE tagname = ANY(%s)",
|
||||
(pv_tags,),
|
||||
)
|
||||
pv_map = {}
|
||||
for tn, lv in cur.fetchall():
|
||||
m = re.match(r'\{\s*\d+\s*\|\s*([^|]+?)\s*\|', lv or '')
|
||||
pv_map[tn[:-3]] = (m.group(1).strip() if m else (lv or None))
|
||||
for p in path:
|
||||
p["live_state"] = pv_map.get(p["tag_no"].lower())
|
||||
|
||||
return json.dumps({
|
||||
"success": True,
|
||||
"start_tag": start_tag,
|
||||
@@ -1413,7 +1457,7 @@ async def query_events(
|
||||
Args:
|
||||
tag_name: (선택) 태그명 LIKE 패턴 (예: 'p-6102', 'xv-%')
|
||||
event_type: (선택) ALARM / TRIP / NORMAL / RUN / CHANGE 중 하나
|
||||
area: (선택) area 정확 매칭
|
||||
area: (선택) area 코드("P6") 정확 매칭. "P6-1"처럼 '-'가 있으면 sub_area로 자동 처리(공용 포함)
|
||||
since: (선택) 시작 시간 ISO 8601. 기본 24시간 전
|
||||
until: (선택) 종료 시간 ISO 8601. 기본 현재
|
||||
limit: 반환 행 수 (기본 100, 최대 1000)
|
||||
@@ -1440,8 +1484,10 @@ async def query_events(
|
||||
where.append("event_type = %s")
|
||||
params.append(event_type.upper())
|
||||
if area:
|
||||
where.append("area = %s")
|
||||
params.append(area)
|
||||
# area 코드 정확매칭 / "P6-1" sub_area 코드면 tag_metadata 토큰 매칭
|
||||
cond, cparams = _area_or_subarea_filter(area, "tagname", "area")
|
||||
where.append(cond)
|
||||
params.extend(cparams)
|
||||
|
||||
sql = f"""
|
||||
SELECT id, tagname, prev_value, curr_value, event_type, event_time,
|
||||
@@ -1486,7 +1532,7 @@ async def active_alarms(area: str | None = None, limit: int = 100) -> str:
|
||||
event_type이 ALARM 또는 TRIP인 것만 필터. 이후 NORMAL이 들어왔다면 해제된 것이므로 제외.
|
||||
|
||||
Args:
|
||||
area: (선택) area 필터
|
||||
area: (선택) area 코드("P6") 필터. "P6-1"처럼 '-'가 있으면 sub_area로 자동 처리(공용 포함)
|
||||
limit: 최대 반환 수 (기본 100)
|
||||
|
||||
Returns:
|
||||
@@ -1495,7 +1541,9 @@ async def active_alarms(area: str | None = None, limit: int = 100) -> str:
|
||||
conn = None
|
||||
try:
|
||||
limit = max(1, min(limit, 500))
|
||||
sql = """
|
||||
# area 코드 정확매칭 / "P6-1" sub_area 코드면 tag_metadata 토큰 매칭 (공용 포함)
|
||||
area_cond, area_params = _area_or_subarea_filter(area, "tagname", "area")
|
||||
sql = f"""
|
||||
WITH latest AS (
|
||||
SELECT DISTINCT ON (tagname)
|
||||
id, tagname, curr_value, event_type, event_time,
|
||||
@@ -1509,14 +1557,14 @@ async def active_alarms(area: str | None = None, limit: int = 100) -> str:
|
||||
EXTRACT(EPOCH FROM (NOW() - event_time))::bigint AS age_seconds
|
||||
FROM latest
|
||||
WHERE event_type IN ('ALARM', 'TRIP')
|
||||
AND (%s::text IS NULL OR area = %s)
|
||||
AND {area_cond}
|
||||
ORDER BY event_time DESC
|
||||
LIMIT %s
|
||||
"""
|
||||
conn = await _get_db_connection()
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(f"SET statement_timeout = {SQL_STATEMENT_TIMEOUT_MS}")
|
||||
cur.execute(sql, (area, area, limit))
|
||||
cur.execute(sql, (*area_params, limit))
|
||||
rows = cur.fetchall()
|
||||
alarms = [
|
||||
{"id": r[0], "tag_name": r[1], "curr_value": r[2], "event_type": r[3],
|
||||
|
||||
@@ -33,6 +33,47 @@ DB의 area 컬럼은 두 가지 표기가 혼재합니다 — 도구 호출 시
|
||||
|
||||
`unit-6`, `Unit 6`, `6번 유닛` 같은 표현은 area 코드가 아닙니다. 위 표의 정규 코드만 사용하세요.
|
||||
|
||||
## Sub-Area (세부 Area) 명명
|
||||
|
||||
하나의 area(P6 등)는 실제로는 **2개의 Column(증류탑)** 으로 나뉩니다. ExperionCrawler는
|
||||
태그 단위로 `sub_area`를 `tag_metadata`(attribute='sub_area')에 저장합니다 — OPC UA가 아니라
|
||||
ExperionCrawler가 결정한 값이 **Single Source of Truth** 입니다.
|
||||
|
||||
사용자가 "6-1차 플랜트", "6-1차"라고 부르면 → **sub_area = `P6-1`** 로 변환하세요.
|
||||
|
||||
| 운전원 호칭 | sub_area 코드 | area 코드 | Column | 번호 패턴 | 제품 |
|
||||
|---|---|---|---|---|---|
|
||||
| 6-1차 플랜트 | `P6-1` | `P6` | C-6111 | 61xx | PGMEA |
|
||||
| 6-2차 플랜트 | `P6-2` | `P6` | C-6211 | 62xx | HBM |
|
||||
| 9-1차 플랜트 | `P9-1` | `P9` | C-9111 | 91xx | |
|
||||
| 9-2차 플랜트 | `P9-2` | `P9` | C-9211 | 92xx | |
|
||||
| 10-1차 플랜트 | `P10-1` | `P10` | C-10111 | 101xx | |
|
||||
| 10-2차 플랜트 | `P10-2` | `P10` | C-10211 | 102xx | |
|
||||
| 1-1차 플랜트 | `P1-1` | `P1` | C-1111 | 11x | |
|
||||
| 1-2차 플랜트 | `P1-2` | `P1` | C-1211 | 12x, 13x | |
|
||||
| 2-1차 플랜트 | `P2-1` | `P2` | C-2111 | 211x | |
|
||||
| 2-2차 플랜트 | `P2-2` | `P2` | C-2121 | 212x, 213x | |
|
||||
|
||||
### sub_area 도구 호출 방법
|
||||
|
||||
- `active_alarms(area="P6-1")` — '-'가 있으면 server.py가 자동으로 sub_area 토큰 매칭
|
||||
- `find_tags(query="펌프", sub_area="P6-1")` — 전용 sub_area 파라미터 사용
|
||||
- `query_events(area="P6-1")` / `summarize_events(area="P6-1")` / `generate_status_report(area="P6-1")` — 동일
|
||||
- area 코드("P6")를 그대로 주면 sub_area 구분 없이 area 전체가 조회됩니다(기존 동작).
|
||||
|
||||
### ⚠️ 공용(shared) 태그
|
||||
|
||||
여러 sub_area가 함께 쓰는 설비는 `sub_area`에 **두 코드를 콤마로** 저장합니다 (예: `P6-1,P6-2`).
|
||||
이런 태그는 `P6-1` 필터에도, `P6-2` 필터에도 모두 잡힙니다. (토큰 매칭:
|
||||
`'P6-1' = ANY(string_to_array(sub_area, ','))`)
|
||||
|
||||
- P6 공용: `p-6201`(원료 투입 펌프), `ficq-6201`(공용 원료 유량 적산계) 등 → `P6-1,P6-2`
|
||||
- P2 공용: `vp-2127a/b`(진공 펌프), `p-2129a~d`(스크러버 순환 펌프), `li-2128a/b`(스크러버 레벨) → `P2-1,P2-2`
|
||||
|
||||
`sub_area`가 **NULL** 인 태그는 (1) 분류 규칙 미적용(예: 66xx/67xx/69xx cooling tower·steam·N2 같은
|
||||
area-level 유틸리티) 또는 (2) 아직 미분류 상태입니다. 공용 여부가 모호하면 `pid_equipment` 테이블에서
|
||||
같은 태그가 여러 Column에 연결되어 있는지 확인하세요.
|
||||
|
||||
## 계기 명명 약어
|
||||
|
||||
- `FIC` / `FT` — Flow Indicator Controller / Transmitter
|
||||
@@ -153,3 +194,66 @@ ORDER BY area_code;
|
||||
| "절차서/매뉴얼/설계서 검색" | `search_kb` |
|
||||
|
||||
도구 인자는 raw JSON으로 답변하지 말고, 표/요약으로 정리해 운전원이 바로 읽을 수 있게 답변하세요.
|
||||
|
||||
## P&ID 장비 연결 경로 (pid_equipment 테이블)
|
||||
|
||||
pid_equipment 테이블은 플랜트 장비의 유체 이동 경로를 저장합니다.
|
||||
|
||||
### ⚠️ 경로/흐름/공급/계통 질문 — 반드시 `trace_connections` 사용 (필수 규칙)
|
||||
|
||||
"원료 투입 경로", "스팀 흐름", "어디서 공급돼?", "C-6111로 뭐가 들어와?" 처럼 **유체/공급/계통
|
||||
경로**를 묻는 질문은 **절대 기억·추론으로 경로를 재구성하지 말고** `trace_connections`를 호출하세요.
|
||||
|
||||
1. **반드시 `trace_connections(start_tag=..., direction="upstream"|"downstream")` 1회 이상 호출.**
|
||||
2. 반환된 `path`의 **모든 노드를 누락 없이** 제시. 특히 `from_tag`/`to_tag`에 **쉼표로 여러 개**가
|
||||
있으면(예: `from_tag="P-6102, P-6201"`) 그건 **병렬 펌프·병렬 라인**이므로 **전부 나열**하세요.
|
||||
하나만 적고 끝내면 오답입니다.
|
||||
3. 각 노드의 `live_state`(R-RUN / L-STOP 등 실시간 상태)를 함께 표기하세요. 병렬 펌프가 여러 대면
|
||||
**실제 가동 중(R-RUN/L-RUN)인 것이 현재 공급원**이고, 정지(L-STOP)인 것은 예비/대기입니다.
|
||||
예: "F-6101A/B 상류 = P-6102(R-RUN, 현재 공급) + P-6201(L-STOP, 예비)".
|
||||
4. `trace_connections` 결과에 없는 장비를 임의로 추가하거나, 있는 분기를 빠뜨리지 마세요. 답변의
|
||||
경로 구성은 매번 이 도구 출력과 1:1로 일치해야 합니다.
|
||||
|
||||
### category 기준 분리
|
||||
|
||||
pid_equipment 테이블의 `category` 열로 물리적 경로와 제어 신호를 구분합니다.
|
||||
|
||||
1. **category != '제어'** → 물리적 유체 경로
|
||||
- 펌프, 열교환기, 탱크, 밸브, 계기, 필터 등
|
||||
- "A → B → C" 흐름 설명에 사용
|
||||
|
||||
2. **category = '제어'** → DCS 제어 신호
|
||||
- FICQ, LICA, TICA, PICA 등 DCS 제어기
|
||||
- from_tag: 물리적 계기 (측정값)
|
||||
- to_tag: 제어기 또는 제어 대상 밸브
|
||||
- "FT-6118 → FICQ-6118 → FCV-6118" 제어 루프 설명에 사용
|
||||
|
||||
### 경로 설명 시 조회 순서
|
||||
|
||||
1. **1단계**: `category != '제어'` 행으로 물리적 흐름 설명
|
||||
2. **2단계**: `category = '제어'` 행 중 `from_tag`가 1단계 결과에 포함되는 것 찾기
|
||||
3. **3단계**: "FT-6118(유량측정) → FICQ-6118(제어) → FCV-6118(밸브)" 형태로 제어 로직 연결
|
||||
|
||||
### 예시: 제품 추출 경로 설명
|
||||
|
||||
```sql
|
||||
-- 1단계: 물리적 경로
|
||||
SELECT tag_no, from_tag, from_at, to_tag, to_at, role, category
|
||||
FROM pid_equipment
|
||||
WHERE tag_no IN ('E-6117','P-6118','FT-6118','FCV-6118','XV-6123','T-6123',...)
|
||||
ORDER BY tag_no;
|
||||
|
||||
-- 2단계: 제어 태그 (from_tag가 1단계 결과에 포함되는 것)
|
||||
SELECT tag_no, from_tag, from_at, to_tag, to_at, role, category
|
||||
FROM pid_equipment
|
||||
WHERE category = '제어'
|
||||
AND from_tag IN ('FT-6118','P-6118',...);
|
||||
```
|
||||
|
||||
### 주의사항
|
||||
|
||||
- 같은 `tag_no`라도 여러 행이 있을 수 있음 (각 행은 서로 다른 유체 경로)
|
||||
- 예: E-6115A 조회 시 2행 반환 → 행1=스팀 경로, 행2=리보일러 경로
|
||||
- 설명할 때 "이 장비는 2개 경로가 있음"이라고 명시
|
||||
- OR 병합된 값("A, B" 같은)은 없음 — 각 행은 단일 경로
|
||||
- `from_at`, `to_at`은 연결 지점 상세 (예: "C-6111 중상부 제품 노즐")
|
||||
|
||||
40
src/Core/Application/DTOs/SubAreaDtos.cs
Normal file
40
src/Core/Application/DTOs/SubAreaDtos.cs
Normal file
@@ -0,0 +1,40 @@
|
||||
namespace ExperionCrawler.Core.Application.DTOs;
|
||||
|
||||
/// <summary>area별 sub_area 목록 조회 행</summary>
|
||||
public class SubAreaTagDto
|
||||
{
|
||||
public string BaseTag { get; set; } = "";
|
||||
public string? Area { get; set; }
|
||||
public string? SubArea { get; set; }
|
||||
public string? Description { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>sub_area bulk seed 결과 요약</summary>
|
||||
public class SubAreaSeedResultDto
|
||||
{
|
||||
public bool DryRun { get; set; }
|
||||
public int Assigned { get; set; } // 단일 sub_area 부여 (예: "P6-1")
|
||||
public int Shared { get; set; } // 두 sub_area 공용 부여 (예: "P2-1,P2-2")
|
||||
public int Unmatched { get; set; } // 규칙 미적용 → NULL 유지 (수동 분류 대상)
|
||||
public Dictionary<string, int> BySubArea { get; set; } = new();
|
||||
public List<SubAreaSeedDetailDto> Details { get; set; } = new();
|
||||
}
|
||||
|
||||
/// <summary>포인트 삭제 결과 — 연관 데이터 정리 현황 포함</summary>
|
||||
public class PointDeleteResult
|
||||
{
|
||||
public bool Deleted { get; set; } // realtime_table 행 삭제 여부
|
||||
public string? BaseTag { get; set; }
|
||||
public bool MetadataPurged { get; set; } // 고아 tag_metadata(desc/area/sub_area) 정리됨
|
||||
public int HistoryRowsDeleted { get; set; } // history_table 삭제 행수 (opt-in 시에만 >0)
|
||||
}
|
||||
|
||||
/// <summary>seed 상세 행 (dryRun 검증용)</summary>
|
||||
public class SubAreaSeedDetailDto
|
||||
{
|
||||
public string BaseTag { get; set; } = "";
|
||||
public string Area { get; set; } = "";
|
||||
public string Action { get; set; } = ""; // "assign" | "shared" | "unmatched"
|
||||
public string? SubArea { get; set; } // 공용이면 "P2-1,P2-2" 형식
|
||||
public string? Reason { get; set; }
|
||||
}
|
||||
@@ -85,7 +85,9 @@ public interface IExperionDbService
|
||||
Task<int> AppendPointsAsync(IEnumerable<string> nodeIds);
|
||||
Task<IEnumerable<RealtimePoint>> GetRealtimePointsAsync();
|
||||
Task<RealtimePoint> AddRealtimePointAsync(string nodeId);
|
||||
Task<bool> DeleteRealtimePointAsync(int id);
|
||||
/// <summary>포인트(realtime_table 행) 삭제. 같은 base_tag의 잔여 행이 0이면 고아 tag_metadata(desc/area/sub_area)도 정리.
|
||||
/// purgeHistory=true면 해당 tagname의 history_table 이력까지 영구 삭제(복구 불가, 명시적 opt-in).</summary>
|
||||
Task<PointDeleteResult> DeleteRealtimePointAsync(int id, bool purgeHistory = false);
|
||||
Task<int> UpdateLiveValueAsync(string nodeId, string? value, DateTime timestamp);
|
||||
Task<int> BatchUpdateLiveValuesAsync(IEnumerable<LiveValueUpdate> updates);
|
||||
|
||||
@@ -145,6 +147,19 @@ public interface IExperionDbService
|
||||
/// <summary>태그명으로 area 조회 (tag_metadata 기반)</summary>
|
||||
Task<string?> GetAreaByTagNameAsync(string tagName);
|
||||
|
||||
// ── Sub-Area (세부 Area: P6-1/P6-2 등) ──────────────────────────────────────
|
||||
/// <summary>태그명으로 sub_area 조회 (tag_metadata attribute='sub_area'). 공용이면 "P6-1,P6-2" 형식</summary>
|
||||
Task<string?> GetSubAreaByTagNameAsync(string tagName);
|
||||
|
||||
/// <summary>area 코드("P6") 또는 sub_area 코드("P6-1")별 태그 목록 + sub_area 현황 (페이지네이션)</summary>
|
||||
Task<(List<SubAreaTagDto> tags, int total)> GetSubAreaListByAreaAsync(string area, int page, int pageSize);
|
||||
|
||||
/// <summary>단일 태그 sub_area 수정/삭제 (subArea=null이면 삭제=미분류). "P2-1,P2-2"로 공용 지정 가능</summary>
|
||||
Task<bool> UpdateSubAreaAsync(string baseTag, string? subArea);
|
||||
|
||||
/// <summary>area-scoped 번호 prefix 규칙 + pid_equipment 공용 검출로 sub_area 일괄 seed</summary>
|
||||
Task<SubAreaSeedResultDto> SeedSubAreaAsync(bool dryRun);
|
||||
|
||||
/// <summary>이벤트 히스토리 조회</summary>
|
||||
Task<IEnumerable<EventHistoryRow>> QueryEventHistoryAsync(
|
||||
string? tagName, string? area, string? section,
|
||||
|
||||
@@ -422,7 +422,8 @@ public class ExperionDbService : IExperionDbService
|
||||
instate1_rt.livevalue AS instate1,
|
||||
instate2_rt.livevalue AS instate2,
|
||||
desc_md.value AS description,
|
||||
area_md.value AS area
|
||||
area_md.value AS area,
|
||||
sub_area_md.value AS sub_area
|
||||
FROM (SELECT DISTINCT split_part(tagname, '.', 1) AS base_tag FROM realtime_table) rt_base
|
||||
LEFT JOIN realtime_table pv_rt ON pv_rt.tagname = rt_base.base_tag || '.pv'
|
||||
LEFT JOIN realtime_table sp_rt ON sp_rt.tagname = rt_base.base_tag || '.sp'
|
||||
@@ -432,6 +433,7 @@ public class ExperionDbService : IExperionDbService
|
||||
LEFT JOIN realtime_table instate2_rt ON instate2_rt.tagname = rt_base.base_tag || '.instate2'
|
||||
LEFT JOIN tag_metadata desc_md ON desc_md.base_tag = rt_base.base_tag AND desc_md.attribute = 'desc'
|
||||
LEFT JOIN tag_metadata area_md ON area_md.base_tag = rt_base.base_tag AND area_md.attribute = 'area'
|
||||
LEFT JOIN tag_metadata sub_area_md ON sub_area_md.base_tag = rt_base.base_tag AND sub_area_md.attribute = 'sub_area'
|
||||
""");
|
||||
|
||||
// v_plant_running_state 뷰 — area별 펌프 RUN/STOP/TRIP 집계.
|
||||
@@ -1206,13 +1208,40 @@ public class ExperionDbService : IExperionDbService
|
||||
return point;
|
||||
}
|
||||
|
||||
public async Task<bool> DeleteRealtimePointAsync(int id)
|
||||
public async Task<PointDeleteResult> DeleteRealtimePointAsync(int id, bool purgeHistory = false)
|
||||
{
|
||||
var point = await _ctx.RealtimePoints.FindAsync(id);
|
||||
if (point == null) return false;
|
||||
if (point == null) return new PointDeleteResult { Deleted = false };
|
||||
|
||||
var tagName = point.TagName; // 예: "fi-6101.pv"
|
||||
var baseTag = (tagName.Contains('.') ? tagName[..tagName.IndexOf('.')] : tagName)
|
||||
.ToLowerInvariant(); // "fi-6101"
|
||||
|
||||
_ctx.RealtimePoints.Remove(point);
|
||||
await _ctx.SaveChangesAsync();
|
||||
return true;
|
||||
// (pid_equipment.experion_tag_id 는 FK ON DELETE SET NULL 로 자동 처리)
|
||||
|
||||
var result = new PointDeleteResult { Deleted = true, BaseTag = baseTag };
|
||||
|
||||
// 같은 base_tag 의 잔여 realtime 행이 0개면 → 고아 메타데이터(desc/area/sub_area) 정리.
|
||||
// v_tag_summary 는 뷰라 자동 반영되므로 별도 처리 불필요.
|
||||
var remaining = await _ctx.RealtimePoints
|
||||
.CountAsync(p => p.TagName == baseTag || p.TagName.StartsWith(baseTag + "."));
|
||||
if (remaining == 0)
|
||||
{
|
||||
var metaRemoved = await _ctx.Database.ExecuteSqlRawAsync(
|
||||
"DELETE FROM tag_metadata WHERE base_tag = {0}", baseTag);
|
||||
result.MetadataPurged = metaRemoved > 0;
|
||||
}
|
||||
|
||||
// history_table 이력 삭제는 복구 불가 → 명시적 opt-in 일 때만.
|
||||
if (purgeHistory)
|
||||
{
|
||||
result.HistoryRowsDeleted = await _ctx.Database.ExecuteSqlRawAsync(
|
||||
"DELETE FROM history_table WHERE tagname = {0}", tagName);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
public async Task<int> UpdateLiveValueAsync(string nodeId, string? value, DateTime timestamp)
|
||||
@@ -1706,6 +1735,238 @@ public class ExperionDbService : IExperionDbService
|
||||
return match.Success ? match.Groups[1].Value : null;
|
||||
}
|
||||
|
||||
// ── Sub-Area (세부 Area) ────────────────────────────────────────────────────
|
||||
// tag_metadata attribute='sub_area' (EAV). 값 형식: 단일 "P6-1" 또는 공용 "P6-1,P6-2".
|
||||
// 공용 매칭은 어디서나 `<code> = ANY(string_to_array(value, ','))` 토큰 매칭을 사용한다.
|
||||
|
||||
public async Task<string?> GetSubAreaByTagNameAsync(string tagName)
|
||||
{
|
||||
var baseTag = (tagName.Contains('.') ? tagName[..tagName.LastIndexOf('.')] : tagName).ToLowerInvariant();
|
||||
return await _ctx.TagMetadata
|
||||
.Where(m => m.BaseTag == baseTag && m.Attribute == "sub_area")
|
||||
.Select(m => m.Value)
|
||||
.FirstOrDefaultAsync();
|
||||
}
|
||||
|
||||
public async Task<(List<SubAreaTagDto> tags, int total)> GetSubAreaListByAreaAsync(
|
||||
string area, int page, int pageSize)
|
||||
{
|
||||
page = Math.Max(1, page);
|
||||
pageSize = Math.Clamp(pageSize, 1, 500);
|
||||
var isSubArea = area.Contains('-'); // "P6-1" 형식이면 sub_area 코드
|
||||
|
||||
// v_tag_summary는 base_tag별 area/sub_area를 이미 LEFT JOIN으로 노출한다.
|
||||
// sub_area 코드면 토큰 매칭(공용 포함), area 코드면 area 정확 매칭.
|
||||
var where = isSubArea
|
||||
? "@area = ANY(string_to_array(sub_area, ','))"
|
||||
: "trim(split_part(area, '|', 2)) = @area";
|
||||
|
||||
var listSql = $@"SELECT base_tag, trim(split_part(area, '|', 2)) AS area, sub_area, description
|
||||
FROM v_tag_summary
|
||||
WHERE {where}
|
||||
ORDER BY base_tag
|
||||
LIMIT @limit OFFSET @offset";
|
||||
var countSql = $"SELECT COUNT(*) FROM v_tag_summary WHERE {where}";
|
||||
|
||||
var conn = (NpgsqlConnection)_ctx.Database.GetDbConnection();
|
||||
var mustClose = conn.State != System.Data.ConnectionState.Open;
|
||||
if (mustClose) await conn.OpenAsync();
|
||||
try
|
||||
{
|
||||
int total;
|
||||
await using (var countCmd = new NpgsqlCommand(countSql, conn))
|
||||
{
|
||||
countCmd.Parameters.AddWithValue("area", area);
|
||||
total = Convert.ToInt32(await countCmd.ExecuteScalarAsync());
|
||||
}
|
||||
|
||||
var tags = new List<SubAreaTagDto>();
|
||||
await using (var cmd = new NpgsqlCommand(listSql, conn))
|
||||
{
|
||||
cmd.Parameters.AddWithValue("area", area);
|
||||
cmd.Parameters.AddWithValue("limit", pageSize);
|
||||
cmd.Parameters.AddWithValue("offset", (page - 1) * pageSize);
|
||||
await using var reader = await cmd.ExecuteReaderAsync();
|
||||
while (await reader.ReadAsync())
|
||||
{
|
||||
tags.Add(new SubAreaTagDto
|
||||
{
|
||||
BaseTag = reader.GetString(0),
|
||||
Area = reader.IsDBNull(1) ? null : reader.GetString(1),
|
||||
SubArea = reader.IsDBNull(2) ? null : reader.GetString(2),
|
||||
Description = reader.IsDBNull(3) ? null : reader.GetString(3),
|
||||
});
|
||||
}
|
||||
}
|
||||
return (tags, total);
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (mustClose) await conn.CloseAsync();
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<bool> UpdateSubAreaAsync(string baseTag, string? subArea)
|
||||
{
|
||||
baseTag = baseTag.ToLowerInvariant();
|
||||
if (string.IsNullOrWhiteSpace(subArea))
|
||||
{
|
||||
var deleted = await _ctx.Database.ExecuteSqlRawAsync(
|
||||
"DELETE FROM tag_metadata WHERE base_tag = {0} AND attribute = 'sub_area'", baseTag);
|
||||
return deleted > 0;
|
||||
}
|
||||
|
||||
var affected = await _ctx.Database.ExecuteSqlRawAsync(
|
||||
@"INSERT INTO tag_metadata (base_tag, attribute, value)
|
||||
VALUES ({0}, 'sub_area', {1})
|
||||
ON CONFLICT (base_tag, attribute) DO UPDATE SET value = EXCLUDED.value, loaded_at = NOW()",
|
||||
baseTag, subArea.Trim());
|
||||
return affected > 0;
|
||||
}
|
||||
|
||||
// area-scoped 번호 prefix 규칙 + 공용 검출을 단일 CASE로 분류.
|
||||
// 공용(여러 sub_area 공유)은 두 코드를 콤마로 부여하여 어느 sub_area 필터에서도 노출되게 한다.
|
||||
private const string SubAreaClassifySql = @"
|
||||
WITH base AS (
|
||||
SELECT DISTINCT
|
||||
base_tag,
|
||||
regexp_replace(value, '.*\|\s*(\w+)\s*\|.*', '\1') AS area,
|
||||
regexp_replace(base_tag, '[^0-9]', '', 'g') AS digits
|
||||
FROM tag_metadata
|
||||
WHERE attribute = 'area'
|
||||
-- 가드: realtime_table에 실재하는 base_tag만 (삭제된 포인트의 고아 메타데이터 제외)
|
||||
AND EXISTS (SELECT 1 FROM realtime_table r WHERE split_part(r.tagname, '.', 1) = tag_metadata.base_tag)
|
||||
),
|
||||
shared_role AS (
|
||||
SELECT DISTINCT LOWER(tag_no) AS base_tag
|
||||
FROM pid_equipment
|
||||
WHERE role ILIKE '%공용%' OR role ILIKE '%공통%'
|
||||
),
|
||||
classified AS (
|
||||
SELECT b.base_tag, b.area,
|
||||
CASE
|
||||
-- 1) 공용(여러 sub_area 공유) → 두 코드 모두 부여
|
||||
WHEN b.base_tag IN (SELECT base_tag FROM shared_role)
|
||||
OR (b.area = 'P6' AND b.digits LIKE '6201%')
|
||||
OR (b.area = 'P2' AND (b.digits LIKE '2127%' OR b.digits LIKE '2128%' OR b.digits LIKE '2129%'))
|
||||
THEN CASE b.area
|
||||
WHEN 'P6' THEN 'P6-1,P6-2'
|
||||
WHEN 'P9' THEN 'P9-1,P9-2'
|
||||
WHEN 'P10' THEN 'P10-1,P10-2'
|
||||
WHEN 'P1' THEN 'P1-1,P1-2'
|
||||
WHEN 'P2' THEN 'P2-1,P2-2'
|
||||
END
|
||||
-- 2) area-scoped 번호 prefix (긴 prefix 먼저)
|
||||
WHEN b.area = 'P10' AND b.digits LIKE '101%' THEN 'P10-1'
|
||||
WHEN b.area = 'P10' AND b.digits LIKE '102%' THEN 'P10-2'
|
||||
WHEN b.area = 'P2' AND b.digits LIKE '211%' THEN 'P2-1'
|
||||
WHEN b.area = 'P2' AND b.digits LIKE '212%' THEN 'P2-2'
|
||||
WHEN b.area = 'P2' AND b.digits LIKE '213%' THEN 'P2-2'
|
||||
WHEN b.area = 'P6' AND b.digits LIKE '61%' THEN 'P6-1'
|
||||
WHEN b.area = 'P6' AND b.digits LIKE '62%' THEN 'P6-2'
|
||||
WHEN b.area = 'P9' AND b.digits LIKE '91%' THEN 'P9-1'
|
||||
WHEN b.area = 'P9' AND b.digits LIKE '92%' THEN 'P9-2'
|
||||
WHEN b.area = 'P1' AND b.digits LIKE '11%' THEN 'P1-1'
|
||||
WHEN b.area = 'P1' AND b.digits LIKE '12%' THEN 'P1-2'
|
||||
WHEN b.area = 'P1' AND b.digits LIKE '13%' THEN 'P1-2'
|
||||
ELSE NULL
|
||||
END AS sub_area
|
||||
FROM base b
|
||||
WHERE b.area IN ('P6','P9','P10','P1','P2')
|
||||
)
|
||||
SELECT base_tag, area, sub_area FROM classified ORDER BY area, base_tag";
|
||||
|
||||
public async Task<SubAreaSeedResultDto> SeedSubAreaAsync(bool dryRun)
|
||||
{
|
||||
var result = new SubAreaSeedResultDto { DryRun = dryRun };
|
||||
|
||||
var conn = (NpgsqlConnection)_ctx.Database.GetDbConnection();
|
||||
var mustClose = conn.State != System.Data.ConnectionState.Open;
|
||||
if (mustClose) await conn.OpenAsync();
|
||||
try
|
||||
{
|
||||
await using (var cmd = new NpgsqlCommand(SubAreaClassifySql, conn))
|
||||
await using (var reader = await cmd.ExecuteReaderAsync())
|
||||
{
|
||||
while (await reader.ReadAsync())
|
||||
{
|
||||
var baseTag = reader.GetString(0);
|
||||
var area = reader.GetString(1);
|
||||
var subArea = reader.IsDBNull(2) ? null : reader.GetString(2);
|
||||
|
||||
string action;
|
||||
if (subArea == null) { action = "unmatched"; result.Unmatched++; }
|
||||
else if (subArea.Contains(',')) { action = "shared"; result.Shared++; }
|
||||
else { action = "assign"; result.Assigned++; }
|
||||
|
||||
if (subArea != null)
|
||||
result.BySubArea[subArea] = result.BySubArea.GetValueOrDefault(subArea) + 1;
|
||||
|
||||
result.Details.Add(new SubAreaSeedDetailDto
|
||||
{
|
||||
BaseTag = baseTag, Area = area, Action = action, SubArea = subArea,
|
||||
Reason = action == "shared" ? "여러 sub_area 공용" : null,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (!dryRun)
|
||||
{
|
||||
await _ctx.Database.ExecuteSqlRawAsync(@"
|
||||
WITH base AS (
|
||||
SELECT DISTINCT
|
||||
base_tag,
|
||||
regexp_replace(value, '.*\|\s*(\w+)\s*\|.*', '\1') AS area,
|
||||
regexp_replace(base_tag, '[^0-9]', '', 'g') AS digits
|
||||
FROM tag_metadata
|
||||
WHERE attribute = 'area'
|
||||
-- 가드: realtime_table에 실재하는 base_tag만 (삭제된 포인트의 고아 메타데이터 제외)
|
||||
AND EXISTS (SELECT 1 FROM realtime_table r WHERE split_part(r.tagname, '.', 1) = tag_metadata.base_tag)
|
||||
),
|
||||
shared_role AS (
|
||||
SELECT DISTINCT LOWER(tag_no) AS base_tag
|
||||
FROM pid_equipment
|
||||
WHERE role ILIKE '%공용%' OR role ILIKE '%공통%'
|
||||
),
|
||||
classified AS (
|
||||
SELECT b.base_tag,
|
||||
CASE
|
||||
WHEN b.base_tag IN (SELECT base_tag FROM shared_role)
|
||||
OR (b.area = 'P6' AND b.digits LIKE '6201%')
|
||||
OR (b.area = 'P2' AND (b.digits LIKE '2127%' OR b.digits LIKE '2128%' OR b.digits LIKE '2129%'))
|
||||
THEN CASE b.area
|
||||
WHEN 'P6' THEN 'P6-1,P6-2' WHEN 'P9' THEN 'P9-1,P9-2'
|
||||
WHEN 'P10' THEN 'P10-1,P10-2' WHEN 'P1' THEN 'P1-1,P1-2'
|
||||
WHEN 'P2' THEN 'P2-1,P2-2' END
|
||||
WHEN b.area = 'P10' AND b.digits LIKE '101%' THEN 'P10-1'
|
||||
WHEN b.area = 'P10' AND b.digits LIKE '102%' THEN 'P10-2'
|
||||
WHEN b.area = 'P2' AND b.digits LIKE '211%' THEN 'P2-1'
|
||||
WHEN b.area = 'P2' AND b.digits LIKE '212%' THEN 'P2-2'
|
||||
WHEN b.area = 'P2' AND b.digits LIKE '213%' THEN 'P2-2'
|
||||
WHEN b.area = 'P6' AND b.digits LIKE '61%' THEN 'P6-1'
|
||||
WHEN b.area = 'P6' AND b.digits LIKE '62%' THEN 'P6-2'
|
||||
WHEN b.area = 'P9' AND b.digits LIKE '91%' THEN 'P9-1'
|
||||
WHEN b.area = 'P9' AND b.digits LIKE '92%' THEN 'P9-2'
|
||||
WHEN b.area = 'P1' AND b.digits LIKE '11%' THEN 'P1-1'
|
||||
WHEN b.area = 'P1' AND b.digits LIKE '12%' THEN 'P1-2'
|
||||
WHEN b.area = 'P1' AND b.digits LIKE '13%' THEN 'P1-2'
|
||||
ELSE NULL
|
||||
END AS sub_area
|
||||
FROM base b
|
||||
WHERE b.area IN ('P6','P9','P10','P1','P2')
|
||||
)
|
||||
INSERT INTO tag_metadata (base_tag, attribute, value)
|
||||
SELECT base_tag, 'sub_area', sub_area FROM classified WHERE sub_area IS NOT NULL
|
||||
ON CONFLICT (base_tag, attribute) DO NOTHING");
|
||||
}
|
||||
return result;
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (mustClose) await conn.CloseAsync();
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<int> RecordDigitalEventAsync(DigitalEventRecord record)
|
||||
{
|
||||
var row = new EventHistoryRecord
|
||||
|
||||
@@ -446,12 +446,20 @@ public class ExperionPointBuilderController : ControllerBase
|
||||
return Ok(new { success = true, message = msg, point = new { id = point.Id, tagName = point.TagName, nodeId = point.NodeId } });
|
||||
}
|
||||
|
||||
/// <summary>포인트 삭제</summary>
|
||||
/// <summary>포인트 삭제. purgeHistory=true 면 해당 tagname 이력(history_table)까지 영구 삭제(복구 불가).
|
||||
/// 기본은 realtime 행만 삭제하고, 그 base_tag 의 잔여 행이 0이면 고아 메타데이터(sub_area 포함)도 정리.</summary>
|
||||
[HttpDelete("{id:int}")]
|
||||
public async Task<IActionResult> Delete(int id)
|
||||
public async Task<IActionResult> Delete(int id, [FromQuery] bool purgeHistory = false)
|
||||
{
|
||||
var deleted = await _dbSvc.DeleteRealtimePointAsync(id);
|
||||
return Ok(new { success = deleted, message = deleted ? "삭제 완료" : "포인트를 찾을 수 없습니다." });
|
||||
var r = await _dbSvc.DeleteRealtimePointAsync(id, purgeHistory);
|
||||
return Ok(new
|
||||
{
|
||||
success = r.Deleted,
|
||||
message = r.Deleted ? "삭제 완료" : "포인트를 찾을 수 없습니다.",
|
||||
baseTag = r.BaseTag,
|
||||
metadataPurged = r.MetadataPurged,
|
||||
historyRowsDeleted = r.HistoryRowsDeleted,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -535,6 +543,104 @@ public record MetadataReloadRequest(
|
||||
string? Password,
|
||||
IEnumerable<string>? BaseTags);
|
||||
|
||||
// ── Sub-Area (세부 Area) ──────────────────────────────────────────────────────
|
||||
|
||||
[ApiController]
|
||||
[Route("api/tags/sub-area")]
|
||||
public class SubAreaController : ControllerBase
|
||||
{
|
||||
private readonly IExperionDbService _dbSvc;
|
||||
private readonly ILogger<SubAreaController> _logger;
|
||||
|
||||
public SubAreaController(IExperionDbService dbSvc, ILogger<SubAreaController> logger)
|
||||
{
|
||||
_dbSvc = dbSvc;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>area("P6") 또는 sub_area("P6-1")별 태그 + sub_area 현황 조회</summary>
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> GetSubAreaList(
|
||||
[FromQuery] string area,
|
||||
[FromQuery] int page = 1,
|
||||
[FromQuery] int pageSize = 50)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(area))
|
||||
return BadRequest(new { success = false, message = "area 파라미터가 필요합니다." });
|
||||
|
||||
var (tags, total) = await _dbSvc.GetSubAreaListByAreaAsync(area, page, pageSize);
|
||||
return Ok(new
|
||||
{
|
||||
success = true,
|
||||
total,
|
||||
page,
|
||||
pageSize,
|
||||
tags = tags.Select(t => new
|
||||
{
|
||||
baseTag = t.BaseTag,
|
||||
area = t.Area,
|
||||
subArea = t.SubArea,
|
||||
description = t.Description,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>단일 태그 sub_area 수정/삭제 (subArea=null이면 미분류로 초기화)</summary>
|
||||
[HttpPut]
|
||||
public async Task<IActionResult> UpdateSubArea([FromBody] SubAreaUpdateRequest req)
|
||||
{
|
||||
if (req is null || string.IsNullOrWhiteSpace(req.BaseTag))
|
||||
return BadRequest(new { success = false, message = "baseTag가 필요합니다." });
|
||||
|
||||
var success = await _dbSvc.UpdateSubAreaAsync(req.BaseTag, req.SubArea);
|
||||
return Ok(new { success, baseTag = req.BaseTag, subArea = req.SubArea });
|
||||
}
|
||||
|
||||
/// <summary>pid_equipment 공용 검출 + 번호 prefix 규칙으로 sub_area 일괄 seed (dryRun 권장)</summary>
|
||||
[HttpPost("seed")]
|
||||
public async Task<IActionResult> SeedSubArea([FromBody] SubAreaSeedRequest? req)
|
||||
{
|
||||
var dryRun = req?.DryRun ?? true;
|
||||
try
|
||||
{
|
||||
var result = await _dbSvc.SeedSubAreaAsync(dryRun);
|
||||
return Ok(new
|
||||
{
|
||||
success = true,
|
||||
dryRun = result.DryRun,
|
||||
assigned = result.Assigned,
|
||||
shared = result.Shared,
|
||||
unmatched = result.Unmatched,
|
||||
bySubArea = result.BySubArea,
|
||||
details = result.Details.Select(d => new
|
||||
{
|
||||
baseTag = d.BaseTag,
|
||||
area = d.Area,
|
||||
action = d.Action,
|
||||
subArea = d.SubArea,
|
||||
reason = d.Reason,
|
||||
}),
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "sub_area seed 실패");
|
||||
return StatusCode(500, new { success = false, message = ex.Message });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public class SubAreaUpdateRequest
|
||||
{
|
||||
public string BaseTag { get; set; } = "";
|
||||
public string? SubArea { get; set; }
|
||||
}
|
||||
|
||||
public class SubAreaSeedRequest
|
||||
{
|
||||
public bool DryRun { get; set; } = true;
|
||||
}
|
||||
|
||||
// ── 실시간 서비스 ─────────────────────────────────────────────────────────────
|
||||
|
||||
[ApiController]
|
||||
|
||||
@@ -704,6 +704,30 @@
|
||||
<div id="meta-log" class="logbox hidden" style="margin-top:8px"></div>
|
||||
<div id="meta-view" class="hidden" style="margin-top:10px;max-height:300px;overflow:auto"></div>
|
||||
</div>
|
||||
|
||||
<!-- Sub-Area 관리 -->
|
||||
<div class="card" style="margin-top:18px">
|
||||
<div class="card-cap">Sub-Area (세부 Area) 관리</div>
|
||||
<p style="color:var(--t2);font-size:13px;margin-bottom:12px">
|
||||
area(P6 등)를 Column 단위 sub_area(P6-1/P6-2)로 분류합니다. 공용 설비는 <code>P6-1,P6-2</code>처럼
|
||||
두 코드가 함께 부여되어 어느 쪽 필터에도 잡힙니다. Seed는 번호 prefix 규칙 + pid_equipment 공용 검출을 사용합니다.
|
||||
</p>
|
||||
<div class="btn-row" style="align-items:center;gap:8px">
|
||||
<label style="color:var(--t2);font-size:13px">Area:</label>
|
||||
<select id="subarea-area-select" class="inp" style="max-width:220px">
|
||||
<option value="P6">P6 (6차 플랜트)</option>
|
||||
<option value="P9">P9 (9차 플랜트)</option>
|
||||
<option value="P10">P10 (10차 플랜트)</option>
|
||||
<option value="P1">P1 (1차 플랜트)</option>
|
||||
<option value="P2">P2 (2차 플랜트)</option>
|
||||
</select>
|
||||
<button class="btn-b" onclick="subAreaLoad()">📋 조회</button>
|
||||
<button class="btn-b" onclick="subAreaSeed(true)">🧪 Seed DryRun</button>
|
||||
<button class="btn-a" onclick="subAreaSeed(false)">⚙️ Seed 실행</button>
|
||||
</div>
|
||||
<div id="subarea-log" class="logbox hidden" style="margin-top:8px"></div>
|
||||
<div id="subarea-view" class="hidden" style="margin-top:10px;max-height:360px;overflow:auto"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 포인트 목록 -->
|
||||
|
||||
@@ -857,8 +857,8 @@ function pbRender(points) {
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th><th>TagName</th><th>Node ID</th>
|
||||
<th>LiveValue</th><th>Timestamp</th><th></th>
|
||||
<th>ID</th><th>TagName</th>
|
||||
<th>LiveValue</th><th>Timestamp</th><th style="text-align:center">이력 / 삭제</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -868,7 +868,12 @@ function pbRender(points) {
|
||||
<td style="font-weight:600">${esc((p?.tagName)?.toUpperCase() || '')}</td>
|
||||
<td class="val">${p?.liveValue != null ? esc(String(fmtVal(parseEnumPv(p.liveValue)))) : '<span style="color:var(--t3)">—</span>'}</td>
|
||||
<td class="mut" style="font-size:11px">${p?.liveValue != null ? fmtTs(p.timestamp) : '—'}</td>
|
||||
<td><button class="btn-sm btn-b" style="color:var(--red,#e55)" onclick="pbDelete(${p.id})">✕</button></td>
|
||||
<td style="white-space:nowrap;text-align:center">
|
||||
<label style="font-size:11px;color:var(--t2);margin-right:8px;cursor:pointer" title="체크 시 이 태그의 이력(history_table)도 함께 영구 삭제 (복구 불가)">
|
||||
<input type="checkbox" id="pb-hist-${p.id}" style="vertical-align:middle"> 이력
|
||||
</label>
|
||||
<button class="btn-sm btn-b" style="color:var(--red,#e55)" onclick="pbDelete(${p.id}, '${esc((p?.tagName)||'')}')">✕</button>
|
||||
</td>
|
||||
</tr>
|
||||
`).join('')}
|
||||
</tbody>
|
||||
@@ -893,12 +898,29 @@ async function pbAddManual() {
|
||||
}
|
||||
}
|
||||
|
||||
async function pbDelete(id) {
|
||||
if (!confirm(`포인트 #${id}를 삭제하시겠습니까?`)) return;
|
||||
async function pbDelete(id, tagName) {
|
||||
const label = tagName ? `${tagName.toUpperCase()} (#${id})` : `#${id}`;
|
||||
// 행의 "이력" 체크박스 = 이력 함께 삭제 opt-in (기본 미체크 = 이력 보존)
|
||||
const purgeHistory = !!document.getElementById('pb-hist-' + id)?.checked;
|
||||
|
||||
const msg = purgeHistory
|
||||
? `포인트 ${label} 삭제 + 이력 영구 삭제\n\n· realtime 포인트 삭제\n· 잔여 포인트 없으면 메타데이터(area/sub_area) 정리\n· ⚠️ 이력(history_table)까지 영구 삭제 — 복구 불가\n\n계속하시겠습니까?`
|
||||
: `포인트 ${label}를 삭제하시겠습니까?\n\n· realtime 포인트 삭제\n· 잔여 포인트 없으면 메타데이터(area/sub_area) 정리\n· 이력(history)은 보존 ('이력' 체크 시 함께 삭제)`;
|
||||
if (!confirm(msg)) return;
|
||||
|
||||
try {
|
||||
const d = await api('DELETE', `/api/pointbuilder/${id}`);
|
||||
if (d.success) await pbRefresh();
|
||||
else alert('삭제 실패: ' + d.message);
|
||||
const d = await api('DELETE', `/api/pointbuilder/${id}?purgeHistory=${purgeHistory}`);
|
||||
if (d.success) {
|
||||
const parts = [];
|
||||
if (d.metadataPurged) parts.push('메타데이터 정리됨');
|
||||
if (d.historyRowsDeleted > 0) parts.push(`이력 ${d.historyRowsDeleted.toLocaleString()}행 삭제`);
|
||||
else if (purgeHistory) parts.push('이력 없음');
|
||||
else parts.push('이력 보존');
|
||||
alert(`삭제 완료: ${label}\n(${parts.join(' · ')})`);
|
||||
await pbRefresh();
|
||||
} else {
|
||||
alert('삭제 실패: ' + d.message);
|
||||
}
|
||||
} catch (e) { alert('삭제 오류: ' + e.message); }
|
||||
}
|
||||
|
||||
@@ -994,6 +1016,100 @@ async function metaView() {
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Sub-Area (세부 Area) 관리 ───────────────────────────────── */
|
||||
// area별 선택 가능한 sub_area 옵션 (마지막은 공용 = 두 코드)
|
||||
const SUBAREA_OPTIONS = {
|
||||
P6: ['P6-1', 'P6-2', 'P6-1,P6-2'],
|
||||
P9: ['P9-1', 'P9-2', 'P9-1,P9-2'],
|
||||
P10: ['P10-1', 'P10-2', 'P10-1,P10-2'],
|
||||
P1: ['P1-1', 'P1-2', 'P1-1,P1-2'],
|
||||
P2: ['P2-1', 'P2-2', 'P2-1,P2-2'],
|
||||
};
|
||||
|
||||
function subAreaLabel(code) {
|
||||
if (!code) return '(미분류)';
|
||||
return code.includes(',') ? `${code} (공용)` : code;
|
||||
}
|
||||
|
||||
async function subAreaLoad() {
|
||||
const area = document.getElementById('subarea-area-select').value;
|
||||
const viewEl = document.getElementById('subarea-view');
|
||||
viewEl.classList.remove('hidden');
|
||||
viewEl.innerHTML = '<div class="ll inf">⏳ 조회 중...</div>';
|
||||
try {
|
||||
const d = await api('GET', `/api/tags/sub-area?area=${encodeURIComponent(area)}&page=1&pageSize=500`);
|
||||
const tags = d.tags || [];
|
||||
if (tags.length === 0) {
|
||||
viewEl.innerHTML = '<div class="ll inf">태그가 없습니다.</div>';
|
||||
return;
|
||||
}
|
||||
const opts = SUBAREA_OPTIONS[area] || [];
|
||||
viewEl.innerHTML = `
|
||||
<div class="mut" style="margin-bottom:6px">총 ${d.total}개 · 미분류는 (미분류)로 두면 NULL 유지</div>
|
||||
<table style="width:100%;font-size:12px">
|
||||
<thead><tr><th>BaseTag</th><th>Description</th><th>Sub-Area</th></tr></thead>
|
||||
<tbody>
|
||||
${tags.map(t => {
|
||||
const cur = t.subArea || '';
|
||||
const optionHtml = ['', ...opts].map(o =>
|
||||
`<option value="${esc(o)}" ${o === cur ? 'selected' : ''}>${esc(subAreaLabel(o))}</option>`
|
||||
).join('');
|
||||
// 현재값이 옵션에 없으면(수동 지정 등) 추가
|
||||
const extra = (cur && !opts.includes(cur))
|
||||
? `<option value="${esc(cur)}" selected>${esc(subAreaLabel(cur))}</option>` : '';
|
||||
return `
|
||||
<tr>
|
||||
<td style="font-weight:600">${esc(t.baseTag)}</td>
|
||||
<td class="mut">${esc(t.description || '-')}</td>
|
||||
<td>
|
||||
<select class="inp" style="font-size:12px;padding:2px 6px"
|
||||
onchange="subAreaUpdate('${esc(t.baseTag)}', this.value)">
|
||||
${optionHtml}${extra}
|
||||
</select>
|
||||
</td>
|
||||
</tr>`;
|
||||
}).join('')}
|
||||
</tbody>
|
||||
</table>`;
|
||||
} catch (e) {
|
||||
viewEl.innerHTML = `<div class="ll err">❌ ${esc(e.message)}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
async function subAreaUpdate(baseTag, subArea) {
|
||||
const logEl = document.getElementById('subarea-log');
|
||||
logEl.classList.remove('hidden');
|
||||
try {
|
||||
const d = await api('PUT', '/api/tags/sub-area', { baseTag, subArea: subArea || null });
|
||||
logEl.innerHTML = `<div class="ll ${d.success ? 'ok' : 'err'}">${d.success ? '✅' : '❌'} ${esc(baseTag)} → ${esc(subAreaLabel(subArea))}</div>`;
|
||||
} catch (e) {
|
||||
logEl.innerHTML = `<div class="ll err">❌ ${esc(e.message)}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
async function subAreaSeed(dryRun) {
|
||||
const msg = dryRun
|
||||
? 'DryRun으로 분류 결과만 미리 봅니다(저장 안 함). 계속할까요?'
|
||||
: '실제 seed를 실행합니다(기존 sub_area는 보존, 신규만 추가). 계속할까요?';
|
||||
if (!confirm(msg)) return;
|
||||
const logEl = document.getElementById('subarea-log');
|
||||
logEl.classList.remove('hidden');
|
||||
logEl.innerHTML = `<div class="ll inf">⏳ Seed ${dryRun ? 'DryRun' : '실행'} 중...</div>`;
|
||||
try {
|
||||
const d = await api('POST', '/api/tags/sub-area/seed', { dryRun });
|
||||
const by = d.bySubArea || {};
|
||||
const byStr = Object.keys(by).sort().map(k => `${k}=${by[k]}`).join(', ');
|
||||
logEl.innerHTML = `
|
||||
<div class="ll ${d.success ? 'ok' : 'err'}">
|
||||
${d.success ? '✅' : '❌'} ${dryRun ? '[DryRun] ' : ''}단일 ${d.assigned} · 공용 ${d.shared} · 미분류 ${d.unmatched}
|
||||
</div>
|
||||
<div class="ll inf">분류별: ${esc(byStr || '-')}</div>`;
|
||||
subAreaLoad();
|
||||
} catch (e) {
|
||||
logEl.innerHTML = `<div class="ll err">❌ ${esc(e.message)}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
/* ─────────────────────────────────────────────────────────────
|
||||
07 이력 조회
|
||||
───────────────────────────────────────────────────────────── */
|
||||
|
||||
Reference in New Issue
Block a user