# 디지털 태그 추가 작업 계획 (Phase 1)

## 개요

`realtime-tag-expansion-design.md` 설계안을 기반으로 Phase 1 기본 확장을 구현합니다.
기존 `realtime_table` 구조를 유지하면서 디지털 장비(Pump, XV) 태그와 메타데이터 지원을 추가합니다.

---

## Todo List (작업 진행 상태 추적)

> 각 단계는 독립적으로 실행 가능하며, 완료 상태를 표시하여 다음 작업에서 바로 읽을 수 있습니다.

- [ ] **Step 1**: DB Migration — `tag_metadata` 테이블 + `v_tag_summary` 뷰 생성
- [ ] **Step 2**: TagMetadata Entity — 신규 Entity 클래스 + DbContext 등록
- [ ] **Step 3**: MetadataLoaderService — OPC UA에서 메타데이터 읽기/저장 서비스
- [ ] **Step 4**: API Endpoint — `POST /api/tags/metadata/reload` 컨트롤러 추가
- [ ] **Step 5**: Frontend UI 변경 — 포인트빌더 최대 10개, 메타데이터 재로드 버튼
- [ ] **Step 6**: NL2SQL DB_SCHEMA 업데이트 — `tag_metadata`, `v_tag_summary` 추가
- [ ] **Step 7**: 테스트 계획 — xv-6124 등록 및 OPC UA 구독 확인

---

## Step 1: DB Migration — `tag_metadata` 테이블 + `v_tag_summary` 뷰 생성

### 상태: [ ] 미완료

### 변경 파일
- `src/Infrastructure/Database/ExperionDbContext.cs`

### 변경 내용

[`ExperionDbService.InitializeAsync()`](src/Infrastructure/Database/ExperionDbContext.cs:176) 메서드 내에 다음 DDL을 추가합니다.
기존 DDL(`realtime_table` 생성 이후, `EnsureCreatedAsync` 설명 주석 이전)에 추가합니다.

**추가 위치:** 276번 라인 (`// realtime_table은 실시간 값 저장용이므로 하이퍼테이블로 변환하지 않음`) 직후

```sql
// tag_metadata 테이블 생성 (메타데이터 - 변경 드묾)
await _ctx.Database.ExecuteSqlRawAsync("""
    CREATE TABLE IF NOT EXISTS tag_metadata (
        id          SERIAL PRIMARY KEY,
        base_tag    TEXT        NOT NULL,
        attribute   TEXT        NOT NULL,
        value       TEXT,
        node_id     TEXT,
        loaded_at   TIMESTAMPTZ DEFAULT NOW(),
        UNIQUE(base_tag, attribute)
    )
    """);

await _ctx.Database.ExecuteSqlRawAsync("""
    CREATE INDEX IF NOT EXISTS idx_tag_metadata_base ON tag_metadata(base_tag)
    """);

// v_tag_summary 뷰 생성 (실시간 + 메타데이터 통합)
await _ctx.Database.ExecuteSqlRawAsync("""
    CREATE OR REPLACE VIEW v_tag_summary AS
    SELECT
      rt_base.base_tag,
      pv_rt.livevalue AS pv,
      sp_rt.livevalue AS sp,
      op_rt.livevalue AS op,
      instate0_rt.livevalue AS instate0,
      instate1_rt.livevalue AS instate1,
      instate2_rt.livevalue AS instate2,
      desc_md.value AS description,
      area_md.value AS area,
      s0d_md.value AS state0_descriptor,
      s1d_md.value AS state1_descriptor,
      s2d_md.value AS state2_descriptor
    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'
    LEFT JOIN realtime_table op_rt ON op_rt.tagname = rt_base.base_tag || '.op'
    LEFT JOIN realtime_table instate0_rt ON instate0_rt.tagname = rt_base.base_tag || '.instate0'
    LEFT JOIN realtime_table instate1_rt ON instate1_rt.tagname = rt_base.base_tag || '.instate1'
    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 s0d_md ON s0d_md.base_tag = rt_base.base_tag AND s0d_md.attribute = 'state0descriptor'
    LEFT JOIN tag_metadata s1d_md ON s1d_md.base_tag = rt_base.base_tag AND s1d_md.attribute = 'state1descriptor'
    LEFT JOIN tag_metadata s2d_md ON s2d_md.base_tag = rt_base.base_tag AND s2d_md.attribute = 'state2descriptor'
    """);
```

### 검증 방법
1. 앱을 실행하여 DB 초기화가 자동으로 수행되는지 확인
2. psql로 접속하여 `SELECT * FROM tag_metadata LIMIT 1;` 실행
3. `SELECT * FROM v_tag_summary LIMIT 5;` 실행하여 뷰가 정상 동작하는지 확인

---

## Step 2: TagMetadata Entity — 신규 Entity 클래스 + DbContext 등록

### 상태: [ ] 미완료

### 변경 파일
- `src/Core/Domain/Entities/ExperionEntities.cs` (신규 Entity 추가)
- `src/Infrastructure/Database/ExperionDbContext.cs` (DbSet + ModelBuilder 등록)

### 2.1 신규 Entity 클래스 추가

**파일:** [`src/Core/Domain/Entities/ExperionEntities.cs`](src/Core/Domain/Entities/ExperionEntities.cs:138)

[`FastRecord`](src/Core/Domain/Entities/ExperionEntities.cs:129) 클래스 직후에 다음 Entity를 추가합니다.

```csharp
/// <summary>tag_metadata — 태그 메타데이터 (변경 드묾)</summary>
[Table("tag_metadata")]
public class TagMetadata
{
    [Column("id")]          public int       Id          { get; set; }
    [Column("base_tag")]    public string    BaseTag     { get; set; } = string.Empty;
    [Column("attribute")]   public string    Attribute   { get; set; } = string.Empty;
    [Column("value")]       public string?   Value       { get; set; }
    [Column("node_id")]     public string?   NodeId      { get; set; }
    [Column("loaded_at")]   public DateTime  LoadedAt    { get; set; } = DateTime.UtcNow;
}
```

### 2.2 DbContext 에 DbSet 등록

**파일:** [`src/Infrastructure/Database/ExperionDbContext.cs`](src/Infrastructure/Database/ExperionDbContext.cs:20)

기존 DbSet 목록 ([`FastRecords`](src/Infrastructure/Database/ExperionDbContext.cs:23) 이후, P&ID DbSet 이전) 에 추가:

```csharp
public DbSet<TagMetadata>    TagMetadata    => Set<TagMetadata>();
```

### 2.3 ModelBuilder 설정 추가

**파일:** [`src/Infrastructure/Database/ExperionDbContext.cs`](src/Infrastructure/Database/ExperionDbContext.cs:30)

[`OnModelCreating()`](src/Infrastructure/Database/ExperionDbContext.cs:30) 메서드 내, [`FastRecord`](src/Infrastructure/Database/ExperionDbContext.cs:75) 설정 이후, P&ID 엔티티 설정 이전에 추가:

```csharp
modelBuilder.Entity<TagMetadata>(e =>
{
    e.HasKey(x => x.Id);
    e.HasIndex(x => new { x.BaseTag, x.Attribute }).IsUnique();
    e.HasIndex(x => x.BaseTag);
});
```

### 2.4 변경 후 파일 구조 확인

[`ExperionEntities.cs`](src/Core/Domain/Entities/ExperionEntities.cs) 최종 구조:
```
ExperionTag (line 7)
ExperionRecord (line 21)
ExperionServerConfig (line 32)
RawNodeMap (line 47)
NodeMapMaster (line 59)
RealtimePoint (line 71)
HistoryRecord (line 82)
ExperionStatusCodeInfo (line 92)
PidGraphStatus (line 102)
FastSession (line 113)
FastRecord (line 130)
TagMetadata (신규 추가) ← 여기
```

[`ExperionDbContext.cs`](src/Infrastructure/Database/ExperionDbContext.cs) DbSet 최종 구조:
```csharp
public DbSet<ExperionRecord> ExperionRecords => Set<ExperionRecord>();
public DbSet<RawNodeMap>     RawNodeMaps     => Set<RawNodeMap>();
public DbSet<NodeMapMaster>  NodeMapMasters  => Set<NodeMapMaster>();
public DbSet<RealtimePoint>  RealtimePoints  => Set<RealtimePoint>();
public DbSet<HistoryRecord>  HistoryRecords  => Set<HistoryRecord>();
public DbSet<FastSession>    FastSessions    => Set<FastSession>();
public DbSet<FastRecord>     FastRecords     => Set<FastRecord>();
public DbSet<TagMetadata>    TagMetadata     => Set<TagMetadata>(); // ← 신규
// P&ID DbSet ...
```

### 검증 방법
1. `dotnet build` 컴파일 오류 없는지 확인
2. Entity 클래스가 `[Table("tag_metadata")]` 속성으로 DB 테이블과 매핑되는지 확인
3. DbContext에서 `TagMetadata` DbSet를 통해 CRUD가 가능한지 확인

---

## Step 3: MetadataLoaderService — OPC UA에서 메타데이터 읽기/저장 서비스

### 상태: [ ] 미완료

### 신규 파일
- `src/Infrastructure/OpcUa/MetadataLoaderService.cs`

### 변경 파일
- `src/Core/Application/Interfaces/IExperionServices.cs` (인터페이스 추가)
- `src/Web/Program.cs` (DI 등록)

### 3.1 인터페이스 정의

**파일:** [`src/Core/Application/Interfaces/IExperionServices.cs`](src/Core/Application/Interfaces/IExperionServices.cs)

파일 끝에 다음 인터페이스 추가:

```csharp
// ── Metadata Loader ──────────────────────────────────────────────────────────

public interface IMetadataLoaderService
{
    /// <summary>
    /// 태그 등록 시 메타데이터(desc, area, state0~7descriptor)를 OPC UA에서 한 번 읽어서 tag_metadata 저장
    /// </summary>
    Task<int> LoadMetadataAsync(ExperionServerConfig cfg, IEnumerable<string> baseTags);

    /// <summary>
    /// UI 재로드 버튼 클릭 시 메타데이터 재조회 + tag_metadata UPDATE + node_map_master UPSERT
    /// </summary>
    Task<int> ReloadMetadataAsync(ExperionServerConfig cfg, IEnumerable<string>? baseTags = null);
}
```

### 3.2 서비스 구현

**파일:** `src/Infrastructure/OpcUa/MetadataLoaderService.cs` (신규)

```csharp
using ExperionCrawler.Core.Application.Interfaces;
using ExperionCrawler.Core.Domain.Entities;
using ExperionCrawler.Infrastructure.Database;
using Microsoft.Extensions.Logging;
using Microsoft.EntityFrameworkCore;

namespace ExperionCrawler.Infrastructure.OpcUa;

/// <summary>
/// 메타데이터(desc, area, state0~7descriptor)를 OPC UA에서 읽어서 tag_metadata 테이블에 저장/갱신
/// </summary>
public class MetadataLoaderService : IMetadataLoaderService
{
    private readonly IExperionOpcClient _opcClient;
    private readonly ExperionDbContext _ctx;
    private readonly ILogger<MetadataLoaderService> _logger;

    // 로드할 메타데이터 속성 목록
    private static readonly string[] MetaAttributes =
    {
        "desc", "area",
        "state0descriptor", "state1descriptor", "state2descriptor",
        "state3descriptor", "state4descriptor", "state5descriptor",
        "state6descriptor", "state7descriptor"
    };

    public MetadataLoaderService(
        IExperionOpcClient opcClient,
        ExperionDbContext ctx,
        ILogger<MetadataLoaderService> logger)
    {
        _opcClient = opcClient;
        _ctx = ctx;
        _logger = logger;
    }

    public async Task<int> LoadMetadataAsync(ExperionServerConfig cfg, IEnumerable<string> baseTags)
    {
        var count = 0;
        var entries = new List<(string baseTag, string attr, string? value, string nodeId)>();

        foreach (var baseTag in baseTags)
        {
            foreach (var attr in MetaAttributes)
            {
                var nodeId = $"{cfg.ServerHostName}:{baseTag}.{attr}";
                var fullNodeId = $"ns=1;s={nodeId}";

                try
                {
                    var result = await _opcClient.ReadTagAsync(cfg, fullNodeId);
                    if (result.IsGood && result.Value != null)
                    {
                        entries.Add((baseTag.ToLowerInvariant(), attr, result.Value, fullNodeId));
                        count++;
                    }
                }
                catch (Exception ex)
                {
                    _logger.LogWarning(ex, "[Metadata] 읽기 실패: {BaseTag}.{Attr}", baseTag, attr);
                }
            }
        }

        // 벌크 UPSERT
        foreach (var (bt, attr, val, nid) in entries)
        {
            await _ctx.Database.ExecuteSqlRawAsync(@"
                INSERT INTO tag_metadata (base_tag, attribute, value, node_id, loaded_at)
                VALUES (@bt, @attr, @val, @nid, NOW())
                ON CONFLICT (base_tag, attribute)
                DO UPDATE SET value = @val, node_id = @nid, loaded_at = NOW()",
                new NpgsqlTypes.NpgsqlParameter("@bt", bt),
                new NpgsqlTypes.NpgsqlParameter("@attr", attr),
                new NpgsqlTypes.NpgsqlParameter("@val", (object?)val ?? DBNull.Value),
                new NpgsqlTypes.NpgsqlParameter("@nid", nid));
        }

        await _ctx.Database.ExecuteSqlRawAsync("REFRESH MATERIALIZED VIEW IF EXISTS v_tag_summary");
        _logger.LogInformation("[Metadata] 로드 완료: {Count}개 속성 ({BaseTags}개 태그)", count, baseTags.Count());
        return count;
    }

    public async Task<int> ReloadMetadataAsync(ExperionServerConfig cfg, IEnumerable<string>? baseTags = null)
    {
        // baseTags가 null이면 tag_metadata에서 전체 base_tag 조회
        var tags = baseTags ?? await _ctx.TagMetadata.Select(t => t.BaseTag).Distinct().ToListAsync();
        return await LoadMetadataAsync(cfg, tags);
    }
}
```

### 3.3 DI 등록

**파일:** `src/Web/Program.cs`

기존 서비스 등록 부분 근처에 추가:

```csharp
builder.Services.AddScoped<IMetadataLoaderService, MetadataLoaderService>();
```

### 검증 방법
1. `dotnet build` 컴파일 오류 없는지 확인
2. 테스트용 코드에서 `LoadMetadataAsync` 호출 시 OPC UA에서 desc/area 값이 정상 읽히는지 로그 확인
3. DB에서 `SELECT * FROM tag_metadata;` 실행하여 데이터 저장 확인

---

## Step 4: API Endpoint — `POST /api/tags/metadata/reload` 컨트롤러 추가

### 상태: [ ] 미완료

### 변경 파일
- `src/Web/Controllers/ExperionControllers.cs` (신규 컨트롤러 추가)

### 4.1 컨트롤러 추가

**파일:** [`src/Web/Controllers/ExperionControllers.cs`](src/Web/Controllers/ExperionControllers.cs)

[`ExperionPointBuilderController`](src/Web/Controllers/ExperionControllers.cs:272) 직후, [`ExperionRealtimeController`](src/Web/Controllers/ExperionControllers.cs:348) 이전에 추가:

```csharp
// ── 태그 메타데이터 관리 ──────────────────────────────────────────────────────

[ApiController]
[Route("api/tags/metadata")]
public class TagMetadataController : ControllerBase
{
    private readonly IMetadataLoaderService _metaSvc;
    private readonly IOpcUaConfigProvider _configProvider;

    public TagMetadataController(
        IMetadataLoaderService metaSvc,
        IOpcUaConfigProvider configProvider)
    {
        _metaSvc = metaSvc;
        _configProvider = configProvider;
    }

    /// <summary>
    /// 지정된 태그(또는 전체)의 메타데이터를 OPC UA에서 재조회하여 갱신
    /// </summary>
    [HttpPost("reload")]
    public async Task<IActionResult> Reload([FromBody] MetadataReloadRequest? req)
    {
        var cfg = new ExperionServerConfig
        {
            ServerHostName = req?.ServerHostName ?? "192.168.0.20",
            Port = req?.Port ?? 4840,
            ClientHostName = req?.ClientHostName ?? "dbsvr",
            UserName = req?.UserName ?? "mngr",
            Password = req?.Password ?? "mngr"
        };

        try
        {
            var count = await _metaSvc.ReloadMetadataAsync(cfg, req?.BaseTags);
            return Ok(new { success = true, count, message = $"{count}개 메타데이터 갱신 완료" });
        }
        catch (Exception ex)
        {
            return StatusCode(500, new { success = false, message = ex.Message });
        }
    }

    /// <summary>
    /// tag_metadata 전체 조회
    /// </summary>
    [HttpGet]
    public async Task<IActionResult> GetMetadata([FromQuery] string? baseTag = null)
    {
        using var ctx = new ExperionDbContext(/* DI에서 해결 필요 */);
        var query = ctx.TagMetadata.AsQueryable();
        if (!string.IsNullOrEmpty(baseTag))
            query = query.Where(t => t.BaseTag == baseTag.ToLowerInvariant());

        var items = await query.Select(t => new
        {
            t.BaseTag,
            t.Attribute,
            t.Value,
            t.NodeId,
            t.LoadedAt
        }).ToListAsync();

        return Ok(new { count = items.Count, items });
    }
}

public record MetadataReloadRequest(
    string? ServerHostName,
    int? Port,
    string? ClientHostName,
    string? UserName,
    string? Password,
    IEnumerable<string>? BaseTags);
```

### 4.2 GET 엔드포인트용 DbContext 주입 수정

위 코드에서 `GetMetadata` 메서드는 DI를 통해 DbContext를 주입받는 방식으로 수정해야 합니다.
최종 형태:

```csharp
[ApiController]
[Route("api/tags/metadata")]
public class TagMetadataController : ControllerBase
{
    private readonly IMetadataLoaderService _metaSvc;
    private readonly ExperionDbContext _ctx;

    public TagMetadataController(
        IMetadataLoaderService metaSvc,
        ExperionDbContext ctx)
    {
        _metaSvc = metaSvc;
        _ctx = ctx;
    }

    [HttpPost("reload")]
    public async Task<IActionResult> Reload([FromBody] MetadataReloadRequest? req)
    {
        // ... 동일
    }

    [HttpGet]
    public async Task<IActionResult> GetMetadata([FromQuery] string? baseTag = null)
    {
        var query = _ctx.TagMetadata.AsQueryable();
        if (!string.IsNullOrEmpty(baseTag))
            query = query.Where(t => t.BaseTag == baseTag.ToLowerInvariant());

        var items = await query.Select(t => new
        {
            t.BaseTag, t.Attribute, t.Value, t.NodeId, t.LoadedAt
        }).ToListAsync();

        return Ok(new { count = items.Count, items });
    }
}
```

### 검증 방법
1. `dotnet build` 컴파일 오류 없는지 확인
2. Postman/curl로 `POST /api/tags/metadata/reload` 호출하여 메타데이터 갱신 확인
3. `GET /api/tags/metadata?baseTag=xv-6124` 호출하여 조회 확인

---

## Step 5: Frontend UI 변경 — 포인트빌더 최대 10개, 메타데이터 재로드 버튼

### 상태: [ ] 미완료

### 변경 파일
- `src/Web/wwwroot/index.html` (포인트빌더 탭 HTML 변경)
- `src/Web/wwwroot/js/app.js` (JS 로직 변경)

### 5.1 HTML 변경 — 포인트빌더 최대 10개로 확장

**파일:** [`src/Web/wwwroot/index.html`](src/Web/wwwroot/index.html:422)

현재 포인트빌더 탭(`pane-pb`) 내 name 선택 드롭다운이 8개(`pb-n1`~`pb-n8`)입니다. 10개로 확장:

**변경 위치:** `pb-name-grid` 내부 select 요소 2개 추가

```html
<!-- 신규 추가 -->
<select id="pb-n9" class="inp"><option value="">— 선택 안 함 —</option></select>
<select id="pb-n10" class="inp"><option value="">— 선택 안 함 —</option></select>
```

**레이블 텍스트 변경:**
```html
<label>이름(name) 선택 <em>(OR 조건, 최대 10개)</em>
```

### 5.2 HTML 변경 — 메타데이터 재로드 버튼 추가

**파일:** [`src/Web/wwwroot/index.html`](src/Web/wwwroot/index.html:482)

`pb-rt-status` 로그 박스 직후, 포인트 목록 카드 이전에 추가:

```html
<div class="card" style="margin-top:18px">
  <div class="card-cap">메타데이터 관리</div>
  <p style="color:var(--t2);font-size:13px;margin-bottom:12px">
    태그의 desc, area, state descriptor 정보를 OPC UA에서 조회하여 저장합니다.
  </p>
  <div class="btn-row">
    <button class="btn-a" onclick="metaReload()">🔄 메타데이터 재로드</button>
    <button class="btn-b" onclick="metaView()">📋 메타데이터 조회</button>
  </div>
  <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>
```

### 5.3 JS 변경 — PB_NAME_IDS 배열 확장

**파일:** [`src/Web/wwwroot/js/app.js`](src/Web/wwwroot/js/app.js:575)

```javascript
// 변경 전:
const PB_NAME_IDS = ['pb-n1','pb-n2','pb-n3','pb-n4','pb-n5','pb-n6','pb-n7','pb-n8'];

// 변경 후:
const PB_NAME_IDS = ['pb-n1','pb-n2','pb-n3','pb-n4','pb-n5','pb-n6','pb-n7','pb-n8','pb-n9','pb-n10'];
```

### 5.4 JS 변경 — 메타데이터 재로드/조회 함수 추가

**파일:** [`src/Web/wwwroot/js/app.js`](src/Web/wwwroot/js/app.js:723)

`rtStatus()` 함수 직후에 추가:

```javascript
/* ── 메타데이터 관리 ─────────────────────────────────────────── */

async function metaReload() {
  const body = {
    serverHostName: document.getElementById('pb-rt-ip').value.trim(),
    port: parseInt(document.getElementById('pb-rt-port').value) || 4840,
    clientHostName: document.getElementById('pb-rt-client').value.trim(),
    userName: document.getElementById('pb-rt-user').value.trim(),
    password: document.getElementById('pb-rt-pw').value
  };
  const logEl = document.getElementById('meta-log');
  logEl.classList.remove('hidden');
  logEl.innerHTML = '<div class="ll inf">⏳ 메타데이터 재로드 중...</div>';
  try {
    const d = await api('POST', '/api/tags/metadata/reload', body);
    logEl.innerHTML = `<div class="ll ${d.success ? 'ok' : 'err'}">${d.success ? '✅' : '❌'} ${esc(d.message)}</div>`;
  } catch (e) {
    logEl.innerHTML = `<div class="ll err">❌ ${esc(e.message)}</div>`;
  }
}

async function metaView() {
  const viewEl = document.getElementById('meta-view');
  viewEl.classList.remove('hidden');
  viewEl.innerHTML = '<div class="ll inf">⏳ 조회 중...</div>';
  try {
    const d = await api('GET', '/api/tags/metadata');
    const items = d.items || [];
    if (items.length === 0) {
      viewEl.innerHTML = '<div class="ll inf">메타데이터가 없습니다.</div>';
      return;
    }
    viewEl.innerHTML = `
      <table style="width:100%;font-size:12px">
        <thead><tr><th>BaseTag</th><th>Attribute</th><th>Value</th><th>LoadedAt</th></tr></thead>
        <tbody>
          ${items.map(m => `
            <tr>
              <td style="font-weight:600">${esc(m.baseTag)}</td>
              <td>${esc(m.attribute)}</td>
              <td>${esc(m.value || '-')}</td>
              <td class="mut">${m.loadedAt ? new Date(m.loadedAt).toLocaleString('ko-KR') : '-'}</td>
            </tr>
          `).join('')}
        </tbody>
      </table>
    `;
  } catch (e) {
    viewEl.innerHTML = `<div class="ll err">❌ ${esc(e.message)}</div>`;
  }
}
```

### 검증 방법
1. 브라우저에서 포인트빌더 탭 확인 — 드롭다운이 10개인지 확인
2. "메타데이터 재로드" 버튼 클릭 시 API 호출되고 결과 로그 표시 확인
3. "메타데이터 조회" 버튼 클릭 시 테이블 형태로 메타데이터 표시 확인

---

## Step 6: NL2SQL DB_SCHEMA 업데이트 — `tag_metadata`, `v_tag_summary` 추가

### 상태: [ ] 미완료

### 변경 파일
- `mcp-server/worker/nl2sql_worker.py` (DB_SCHEMA 변수 업데이트)
- `mcp-server/server.py` (_DB_SCHEMA 변수 업데이트)

### 6.1 nl2sql_worker.py 변경

**파일:** [`mcp-server/worker/nl2sql_worker.py`](mcp-server/worker/nl2sql_worker.py:61)

기존 `DB_SCHEMA` 변수에 `tag_metadata` 테이블과 `v_tag_summary` 뷰 정의를 추가:

```python
DB_SCHEMA = """
PostgreSQL 시계열 데이터베이스 스키마

테이블: history_table  (시계열 이력)
  tagname     TEXT         - 태그명 (모두 소문자, 예: 'ficq-6113.pv')
  node_id     TEXT         - OPC UA 노드 ID
  value       TEXT         - 측정값, 수치 연산 시 ::double precision 캐스트 필요
  recorded_at TIMESTAMPTZ  - 기록 시각(UTC), 스냅샷 주기 약 60초

테이블: realtime_table  (실시간 최신값)
  tagname     TEXT         - 태그명 (모두 소문자)
  node_id     TEXT         - OPC UA 노드 ID
  livevalue   TEXT         - 현재값
  timestamp   TIMESTAMPTZ  - 최종 갱신 시각

테이블: tag_metadata  (태그 메타데이터 - 변경 드묾)
  base_tag    TEXT         - 기본 태그명 (예: 'ficq-6101', 'xv-6124')
  attribute   TEXT         - 속성명 ('desc', 'area', 'state0descriptor', ...)
  value       TEXT         - 메타데이터 값
  node_id     TEXT         - OPC UA 노드 ID
  loaded_at   TIMESTAMPTZ  - 마지막 로드 시각

뷰: v_tag_summary  (실시간값 + 메타데이터 통합 뷰)
  base_tag          TEXT   - 기본 태그명
  pv                TEXT   - 현재 프로세스 값
  sp                TEXT   - 설정값
  op                TEXT   - 출력값
  instate0          TEXT   - 상태 비트 0 (true/false)
  instate1          TEXT   - 상태 비트 1 (true/false)
  instate2          TEXT   - 상태 비트 2 (true/false)
  description       TEXT   - 장비 설명 (tag_metadata.desc)
  area              TEXT   - 소속 플랜트 (tag_metadata.area)
  state0_descriptor TEXT   - 비트 0 의미 (예: "Run/Stop")
  state1_descriptor TEXT   - 비트 1 의미 (예: "Remote/Local")
  state2_descriptor TEXT   - 비트 2 의미 (예: "Trip/Normal")

새로운 태그 타입:
  - 아날로그: ficq-6101.pv/sp/op (Double)
  - 디지털 XV: xv-6124.pv/op (Int32), xv-6124.instate0~7 (Boolean)
  - Pump: p-6102.pv/op (Int32), p-6102.instate0~7 (Boolean)
  - 메타데이터: desc (String), area (Enum), state0descriptor~7 (String)

BCD 상태 조회 팁:
  - instate0~7은 Boolean (true/false)
  - state0descriptor~7은 해당 비트의 의미 설명
  - instate0=true이고 state0descriptor="Run/Stop"이면 → "Run" 상태
  - v_tag_summary 뷰를 사용하면 실시간값+메타데이터 한 번에 조회 가능

N분 간격 집계 공식 (time_bucket 금지, date_trunc 사용):
  1분 버킷: date_trunc('minute', recorded_at) AS bucket
  5분 버킷: to_timestamp(FLOOR(EXTRACT(EPOCH FROM recorded_at)/300)*300) AS bucket
  N분 버킷: to_timestamp(FLOOR(EXTRACT(EPOCH FROM recorded_at)/(N*60))*(N*60)) AS bucket

규칙:
  - SELECT만 허용 (INSERT/UPDATE/DELETE/DROP 등 불가)
  - tagname은 모두 소문자로 정확히 입력
  - value 컬럼은 TEXT이므로 집계 시 ::double precision 캐스트 필수
  - time_bucket 함수 사용 금지
"""
```

### 6.2 server.py 변경

**파일:** [`mcp-server/server.py`](mcp-server/server.py:430)

`_DB_SCHEMA` 변수도 동일하게 업데이트 (nl2sql_worker.py와 동일한 내용).

### 6.3 NL2SQL 사용 예시 (새로운 시나리오)

| 자연어 질문 | 생성 SQL |
|------------|----------|
| "xv-6124의 현재 상태와 설명을 알려줘" | `SELECT instate0, instate1, state0_descriptor, state1_descriptor, description FROM v_tag_summary WHERE base_tag = 'xv-6124'` |
| "Unit-A에 있는 모든 pump의 상태를 보여줘" | `SELECT base_tag, instate0, state0_descriptor, description FROM v_tag_summary WHERE area = 'Unit-A' AND base_tag LIKE 'p-%'` |
| "모든 XV 중 instate0이 true인 것" | `SELECT base_tag, instate0, state0_descriptor FROM v_tag_summary WHERE instate0 = 'True' AND base_tag LIKE 'xv-%'` |

### 검증 방법
1. MCP 서버 재시작 후 NL2SQL 도구 호출 시 새로운 스키마가 반영되는지 확인
2. "xv-6124 상태 알려줘" 같은 질문으로 v_tag_summary 조회 테스트
3. tag_metadata 테이블 조회가 정상적으로 되는지 확인

---

## Step 7: 테스트 계획 — xv-6124 등록 및 OPC UA 구독 확인

### 상태: [ ] 미완료

### 테스트 시나리오

#### 7.1 디지털 태그 등록 테스트

**목표:** xv-6124의 pv, op, instate0~2를 realtime_table에 등록하고 OPC UA 구독 확인

**단계:**
1. 포인트빌더 탭에서 이름에 `xv-6124` 선택
2. 데이터 타입에 `Int32`, `Boolean` 입력
3. "🔨 테이블 작성하기" 클릭
4. 포인트 목록에 xv-6124.pv, xv-6124.op, xv-6124.instate0~2가 생성되는지 확인

**기대 결과:**
- `realtime_table`에 xv-6124 관련 행이 INSERT됨
- 포인트 목록 UI에 표시됨

#### 7.2 OPC UA 구독 테스트

**목표:** 실시간 값 업데이트 확인

**단계:**
1. "▶ 구독 시작" 클릭
2. 구독 상태 로그에 xv-6124 관련 노드가 포함된지 확인
3. 몇 분 후 포인트 목록 새로고침
4. `livevalue` 컬럼에 값이 업데이트되는지 확인

**기대 결과:**
- xv-6124.pv → Int32 BCD 값 (예: 0, 1, 3, 5 등)
- xv-6124.instate0 → Boolean (true/false)
- 구독 카운터에 xv-6124 태그가 포함됨

#### 7.3 메타데이터 로드 테스트

**목표:** desc, area, state descriptor 값이 tag_metadata에 저장되는지 확인

**단계:**
1. "🔄 메타데이터 재로드" 버튼 클릭
2. 로그에 갱신된 속성 수 확인
3. "📋 메타데이터 조회" 버튼 클릭
4. xv-6124 관련 메타데이터 행 확인

**기대 결과:**
- `tag_metadata`에 xv-6124 관련 행 INSERT됨
- desc: 장비 설명 문자열
- area: 소속 플랜트 (예: "Unit-A")
- state0descriptor: "Open/Close" 또는 유사한 문자열

#### 7.4 v_tag_summary 뷰 테스트

**목표:** 실시간값 + 메타데이터 통합 조회 확인

**단계:**
```sql
-- psql에서 실행
SELECT * FROM v_tag_summary WHERE base_tag = 'xv-6124';
```

**기대 결과:**
| base_tag | pv | op | instate0 | instate1 | instate2 | description | area | state0_descriptor | state1_descriptor | state2_descriptor |
|----------|-----|-----|----------|----------|----------|-------------|------|-------------------|-------------------|-------------------|
| xv-6124 | 1 | 1 | true | false | false | XV-6124 설명 | Unit-A | Open/Close | Remote/Local | Fault/Normal |

#### 7.5 NL2SQL 통합 테스트

**목표:** 자연어 질문으로 디지털 태그 상태 조회

**단계:**
1. Text-to-SQL 탭에서 "xv-6124 현재 상태 알려줘" 입력
2. 생성된 SQL이 v_tag_summary를 사용하는지 확인
3. 결과에 instate0~2 값과 state descriptor가 포함되는지 확인

**기대 결과:**
- SQL: `SELECT instate0, instate1, state0_descriptor, state1_descriptor, description FROM v_tag_summary WHERE base_tag = 'xv-6124'`
- 결과 테이블에 상태 비트와 의미 표시됨

### 테스트 체크리스트

- [ ] xv-6124 포인트 realtime_table에 등록됨
- [ ] OPC UA 구독 시 livevalue 업데이트됨
- [ ] 메타데이터 재로드 시 tag_metadata에 저장됨
- [ ] v_tag_summary 뷰에서 통합 조회 가능
- [ ] NL2SQL에서 자연어 질문으로 상태 조회 가능
- [ ] 포인트빌더에서 최대 10개 태그 선택 가능
