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:
windpacer
2026-05-24 06:31:51 +09:00
parent 302183c97e
commit f81044c451
10 changed files with 2159 additions and 53 deletions

View File

@@ -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

File diff suppressed because it is too large Load Diff

View File

@@ -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],

View File

@@ -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 중상부 제품 노즐")

View 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; }
}

View File

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

View File

@@ -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

View File

@@ -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]

View File

@@ -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>
<!-- 포인트 목록 -->

View File

@@ -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 이력 조회
───────────────────────────────────────────────────────────── */