# P&ID 추출 개선안 v1 — 카테고리 분류 시스템

## 1. 개요

### 1.1 목적
DXF/PDF P&ID 도면에서 추출한 장비/계기 태그를 5가지 비즈니스 카테고리로 분류하여:
- **사용자 검토**: Excel 다운로드 시 카테고리 컬럼 포함 → 검토/정제 용이
- **RAG 인덱싱**: 카테고리별 KB 업로드, 태그 필터, 컬렉션 분리 기반 마련
- **Prefix 사전 사용자 정의**: 도면 작성자/플랜트별 prefix 규칙을 UI에서 직접 설정

### 1.2 분류 체계

| 카테고리 | 설명 | 예시 태그 |
|----------|------|-----------|
| `instrument` | 계측/제어 기기 | FT-101, TT-201, FCV-101, PSV-6203, XV-101 |
| `power_equipment` | 동력/회전 기기 | P-10101, C-6111, K-901A, F-10900 |
| `storage_equipment` | 저장 설비 | T-10100, D-6113, V-10101 |
| `process_equipment` | 공정 설비 | E-10103, H-10901, R-101, COL-101, S-101 |
| `utility_equipment` | 유틸리티 설비 | CWR-10621, CWS-10600, IA-10900, ST-10511, WW-10191 |

### 1.3 아키텍처

```
사용자: Prefix 규칙 정의 (UI)
                    ↓
        pid_prefix_rules 테이블 저장
                    ↓
DXF 업로드 → 추출 실행 → prefix 매칭 → category 결정
                                         ↓
                              pid_equipment.category 저장
                                         ↓
                              Excel export (category 컬럼 포함)
                                         ↓
                              사용자 검토/정제
                                         ↓
                              KB 업로드 → RAG 인덱싱
```

---

## 2. DB 변경사항

### 2.1 pid_equipment — 컬럼 추가

```sql
ALTER TABLE pid_equipment
    ADD COLUMN category   TEXT,
    ADD COLUMN role       TEXT,
    ADD COLUMN from_tag   TEXT,
    ADD COLUMN to_tag     TEXT;
CREATE INDEX IF NOT EXISTS idx_pid_equipment_category
    ON pid_equipment(category);
```

| 컬럼 | 타입 | 설명 | 입력 주체 |
|------|------|------|-----------|
| `category` | TEXT | 5개 카테고리 중 하나 | 추출 시 자동 (prefix 매칭) |
| `role` | TEXT | 해당 장비의 역할 (예: Cooling Water Supply, Process Feed) | 사용자가 Excel에서 작성 |
| `from_tag` | TEXT | 연결 원천 태그 (예: T-101, P-10101) | 사용자가 Excel에서 작성 |
| `to_tag` | TEXT | 연결 대상 태그 (예: E-10103, C-6111) | 사용자가 Excel에서 작성 |

`role`, `from_tag`, `to_tag`는 추출 시점에는 빈 값이며, 사용자가 Excel을 다운로드하여 검토/보완한 후 KB에 업로드할 때 RAG가 인덱싱합니다.

### 2.2 pid_prefix_rules — 신규 테이블

```sql
CREATE TABLE IF NOT EXISTS pid_prefix_rules (
    id          SERIAL PRIMARY KEY,
    prefix      TEXT NOT NULL UNIQUE,
    category    TEXT NOT NULL,
    description TEXT,
    sort_order  INT NOT NULL DEFAULT 0,
    created_at  TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    updated_at  TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

CREATE INDEX IF NOT EXISTS idx_pid_prefix_rules_category
    ON pid_prefix_rules(category);
```

### 2.3 기본 시드 데이터

```sql
INSERT INTO pid_prefix_rules (prefix, category, description, sort_order) VALUES
    -- Instrument (계측/제어)
    ('FT',  'instrument',        'Flow Transmitter',              10),
    ('TT',  'instrument',        'Temperature Transmitter',       10),
    ('PT',  'instrument',        'Pressure Transmitter',          10),
    ('LT',  'instrument',        'Level Transmitter',             10),
    ('FCV', 'instrument',        'Flow Control Valve',            10),
    ('PCV', 'instrument',        'Pressure Control Valve',        10),
    ('LCV', 'instrument',        'Level Control Valve',           10),
    ('TCV', 'instrument',        'Temperature Control Valve',     10),
    ('PSV', 'instrument',        'Pressure Safety Valve',         10),
    ('XV',  'instrument',        'Shut-off Valve',                10),
    ('FIC', 'instrument',        'Flow Indicator Controller',     10),
    ('TIC', 'instrument',        'Temperature Indicator Controller', 10),
    ('PIC', 'instrument',        'Pressure Indicator Controller', 10),
    ('LIC', 'instrument',        'Level Indicator Controller',    10),
    ('DP',  'instrument',        'Differential Pressure',         10),
    ('FV',  'instrument',        'Flow Valve',                    10),
    ('TV',  'instrument',        'Temperature Valve',             10),
    ('PV',  'instrument',        'Pressure Valve',                10),
    ('LV',  'instrument',        'Level Valve',                   10),
    ('FG',  'instrument',        'Flow Gauge',                    10),
    ('TG',  'instrument',        'Temperature Gauge',             10),
    ('PG',  'instrument',        'Pressure Gauge',                10),
    ('LG',  'instrument',        'Level Gauge',                   10),
    ('FY',  'instrument',        'Flow Relay/Converter',          10),
    ('TY',  'instrument',        'Temperature Relay/Converter',   10),
    ('PY',  'instrument',        'Pressure Relay/Converter',      10),
    ('LY',  'instrument',        'Level Relay/Converter',         10),
    ('BV',  'instrument',        'Ball/Butterfly Valve',          10),
    ('VIP', 'instrument',        'Vibration Probe',               10),
    ('VIT', 'instrument',        'Vibration Transmitter',         10),

    -- Power Equipment (동력/회전)
    ('P-',  'power_equipment',   'Pump',                          20),
    ('C-',  'power_equipment',   'Compressor',                    20),
    ('K-',  'power_equipment',   'Agitator/Mixer',                20),
    ('F-',  'power_equipment',   'Fan/Blower',                    20),
    ('M-',  'power_equipment',   'Motor',                         20),

    -- Storage Equipment (저장)
    ('T-',  'storage_equipment', 'Tank',                          30),
    ('D-',  'storage_equipment', 'Drum',                          30),
    ('V-',  'storage_equipment', 'Vessel',                        30),
    ('TK-', 'storage_equipment', 'Tank (alt)',                    30),

    -- Process Equipment (공정)
    ('E-',  'process_equipment', 'Heat Exchanger',                40),
    ('H-',  'process_equipment', 'Heater/Furnace',                40),
    ('R-',  'process_equipment', 'Reactor',                       40),
    ('COL-','process_equipment', 'Column',                        40),
    ('S-',  'process_equipment', 'Separator',                     40),
    ('FIL-','process_equipment', 'Filter',                        40),
    ('SP-', 'process_equipment', 'Separator',                     40),

    -- Utility Equipment (유틸리티)
    ('CWR-','utility_equipment', 'Cooling Water Return',          50),
    ('CWS-','utility_equipment', 'Cooling Water Supply',          50),
    ('IA-', 'utility_equipment', 'Instrument Air',                50),
    ('ST-', 'utility_equipment', 'Steam',                         50),
    ('WW-', 'utility_equipment', 'Waste Water',                   50),
    ('PW-', 'utility_equipment', 'Process/Potable Water',         50),
    ('SW-', 'utility_equipment', 'Service Water',                 50),
    ('VG-', 'utility_equipment', 'Vent Gas',                      50),
    ('CD-', 'utility_equipment', 'Condensate',                    50),
    ('SAM-','utility_equipment', 'Sample Connection',             50),
    ('CHR-','utility_equipment', 'Chemical Return',               50),
    ('CHS-','utility_equipment', 'Chemical Supply',               50),
    ('CH-', 'utility_equipment', 'Chemical',                      50),
    ('NG-', 'utility_equipment', 'Natural Gas',                   50),
    ('BD-', 'utility_equipment', 'Blowdown',                      50),
    ('FL-', 'utility_equipment', 'Flare',                         50),
    ('LO-', 'utility_equipment', 'Lube Oil',                      50)
ON CONFLICT (prefix) DO NOTHING;
```

> **주의**: `ST-`(Steam)는 `S-`(Separator)보다 prefix 길이가 길어 우선 매칭됩니다.
> `SAM-`(Sample)도 `S-`보다 길어 정상 매칭됩니다.

### 2.4 매칭 로직

prefix 길이 **내림차순** 정렬 후 `tag_no`가 prefix로 시작하는 첫 번째 규칙을 적용:
```
tag_no = "FT-101"   → "FT" matches (길이 2)  → category = instrument
tag_no = "P-10101"  → "P-" matches (길이 2)   → category = power_equipment
tag_no = "PSV-6203" → "PSV" matches (길이 3)  → category = instrument  ("P-"보다 PSV가 우선)
tag_no = "FCV-101"  → "FCV" matches (길이 3)  → category = instrument
tag_no = "COL-101"  → "COL-" matches (길이 4) → category = process_equipment
tag_no = "C-6111"   → "C-" matches (길이 2)   → category = power_equipment
tag_no = "E-10103"  → "E-" matches (길이 2)   → category = process_equipment
tag_no = "T-10100"  → "T-" matches (길이 2)   → category = storage_equipment
tag_no = "CWR-10621" → "CWR-" matches (길이 4) → category = utility_equipment
tag_no = "IA-10900"  → "IA-" matches (길이 3)  → category = utility_equipment
tag_no = "ST-10511"  → "ST-" matches (길이 3)  → category = utility_equipment ("S-"보다 ST-가 우선)
tag_no = "AX-9999"   → no match               → category = NULL
```

---

## 3. 백엔드 구현

### 3.1 Entity — PidEquipment.cs (변경)

`PidEquipment.cs`에 `Category` 속성 추가 (기존 52행 → 58행):

```csharp
// PidEquipment.cs (기존 속성 아래에 추가)
public const string CategoryInstrument       = "instrument";
public const string CategoryPowerEquipment   = "power_equipment";
public const string CategoryStorageEquipment = "storage_equipment";
public const string CategoryProcessEquipment = "process_equipment";
public const string CategoryUtilityEquipment = "utility_equipment";

public static readonly string[] AllCategories =
    [CategoryInstrument, CategoryPowerEquipment, CategoryStorageEquipment,
     CategoryProcessEquipment, CategoryUtilityEquipment];

[MaxLength(30)]
[Column("category")]
public string? Category { get; set; }

[MaxLength(100)]
[Column("role")]
public string? Role { get; set; }

[MaxLength(50)]
[Column("from_tag")]
public string? FromTag { get; set; }

[MaxLength(50)]
[Column("to_tag")]
public string? ToTag { get; set; }
```

### 3.2 Entity — PidPrefixRule.cs (신규)

`src/Core/Domain/Entities/PidPrefixRule.cs`:

```csharp
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;

namespace ExperionCrawler.Core.Domain.Entities;

[Table("pid_prefix_rules")]
public class PidPrefixRule
{
    [Column("id")]
    public int Id { get; set; }

    [Required]
    [MaxLength(30)]
    [Column("prefix")]
    public string Prefix { get; set; } = string.Empty;

    [Required]
    [MaxLength(30)]
    [Column("category")]
    public string Category { get; set; } = string.Empty;

    [MaxLength(200)]
    [Column("description")]
    public string? Description { get; set; }

    [Column("sort_order")]
    public int SortOrder { get; set; }

    [Column("created_at")]
    public DateTime CreatedAt { get; set; } = DateTime.UtcNow;

    [Column("updated_at")]
    public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
}
```

### 3.3 DTO — PidEquipmentDto.cs (변경)

```csharp
public record PidEquipmentDto(
    long Id,
    string TagNo,
    string? EquipmentName,
    string? InstrumentType,
    string? LineNumber,
    string? PidDrawingNo,
    double Confidence,
    bool IsActive,
    DateTime ExtractedAt,
    DateTime? UpdatedAt,
    int? ExperionTagId,
    string? ExperionTagName,
    string? Category,
    string? Role,
    string? FromTag,
    string? ToTag);
```

### 3.4 DTO — PidPrefixRuleDto.cs (신규)

`src/Core/Application/DTOs/PidPrefixRuleDto.cs`:

```csharp
namespace ExperionCrawler.Core.Application.DTOs;

public record PidPrefixRuleDto(
    int Id,
    string Prefix,
    string Category,
    string? Description,
    int SortOrder,
    DateTime CreatedAt);

public record CreatePidPrefixRuleRequest(
    string Prefix,
    string Category,
    string? Description,
    int SortOrder = 0);

public record UpdatePidPrefixRuleRequest(
    string Prefix,
    string Category,
    string? Description,
    int SortOrder = 0);
```

### 3.5 Interface — IPidExtractorService.cs (변경)

```csharp
public interface IPidExtractorService
{
    // 기존 메서드 유지 ...

    // Prefix 규칙
    Task<List<PidPrefixRule>> GetPrefixRulesAsync();
    Task<PidPrefixRule> CreatePrefixRuleAsync(CreatePidPrefixRuleRequest request);
    Task<PidPrefixRule?> UpdatePrefixRuleAsync(int id, UpdatePidPrefixRuleRequest request);
    Task<bool> DeletePrefixRuleAsync(int id);

    // 카테고리 적용
    Task<int> ApplyCategoriesToExistingAsync(); // 기존 데이터 재적용
}
```

### 3.6 Service — PidExtractorService.cs (변경)

**3.6.1 캐시된 규칙 + 카테고리 매칭 로직 추가:**

```csharp
// PidExtractorService.cs — 캐시 필드
private List<PidPrefixRule>? _cachedRules;
private readonly SemaphoreSlim _cacheLock = new(1, 1);

private async Task<List<PidPrefixRule>> GetRulesCachedAsync()
{
    if (_cachedRules != null) return _cachedRules;
    await _cacheLock.WaitAsync();
    try
    {
        if (_cachedRules != null) return _cachedRules; // double-check
        _cachedRules = await _dbContext.PidPrefixRules
            .OrderByDescending(r => r.Prefix.Length)
            .ThenBy(r => r.SortOrder)
            .ToListAsync();
        return _cachedRules;
    }
    finally
    {
        _cacheLock.Release();
    }
}

private void InvalidateRulesCache()
{
    Interlocked.Exchange(ref _cachedRules, null);
}

// PidExtractorService.cs — 매칭 메서드
private async Task<string?> MatchCategoryAsync(string tagNo)
{
    var rules = await GetRulesCachedAsync();
    return rules.FirstOrDefault(r =>
        tagNo.StartsWith(r.Prefix, StringComparison.OrdinalIgnoreCase))?.Category;
}
```

**3.6.2 ExtractFromStreamAsync — category 적용 (기존 67-86행 사이):**

```csharp
// DB 저장 (기존 코드)
var dbItems = new List<PidEquipment>();
foreach (var item in extractedItems)
{
    mappings.TryGetValue(item.TagNo, out var matched);
    var experionTag = matched != null
        ? await _dbContext.RealtimePoints.FirstOrDefaultAsync(r => r.TagName == matched)
        : await FindFallbackTagAsync(item.TagNo);

    // ← 카테고리 매칭 추가
    var category = await MatchCategoryAsync(item.TagNo);

    dbItems.Add(new PidEquipment
    {
        TagNo = item.TagNo,
        EquipmentName = item.EquipmentName,
        InstrumentType = item.InstrumentType,
        LineNumber = item.LineNumber,
        PidDrawingNo = item.PidDrawingNo,
        Confidence = item.Confidence,
        ExperionTagId = experionTag?.Id,
        Category = category,         // ← 추가
        ExtractedAt = DateTime.UtcNow,
        UpdatedAt = DateTime.UtcNow
    });
}
```

**3.6.3 PrefixRule CRUD 메서드:**

```csharp
public async Task<List<PidPrefixRule>> GetPrefixRulesAsync()
{
    return await _dbContext.PidPrefixRules
        .OrderBy(r => r.SortOrder)
        .ThenBy(r => r.Prefix)
        .ToListAsync();
}

public async Task<PidPrefixRule> CreatePrefixRuleAsync(CreatePidPrefixRuleRequest request)
{
    var rule = new PidPrefixRule
    {
        Prefix = request.Prefix.Trim(),
        Category = request.Category,
        Description = request.Description?.Trim(),
        SortOrder = request.SortOrder,
        CreatedAt = DateTime.UtcNow,
        UpdatedAt = DateTime.UtcNow
    };
    _dbContext.PidPrefixRules.Add(rule);
    await _dbContext.SaveChangesAsync();
    InvalidateRulesCache();
    return rule;
}

public async Task<PidPrefixRule?> UpdatePrefixRuleAsync(int id, UpdatePidPrefixRuleRequest request)
{
    var rule = await _dbContext.PidPrefixRules.FindAsync(id);
    if (rule == null) return null;
    rule.Prefix = request.Prefix.Trim();
    rule.Category = request.Category;
    rule.Description = request.Description?.Trim();
    rule.SortOrder = request.SortOrder;
    rule.UpdatedAt = DateTime.UtcNow;
    await _dbContext.SaveChangesAsync();
    InvalidateRulesCache();
    return rule;
}

public async Task<bool> DeletePrefixRuleAsync(int id)
{
    var rule = await _dbContext.PidPrefixRules.FindAsync(id);
    if (rule == null) return false;
    _dbContext.PidPrefixRules.Remove(rule);
    await _dbContext.SaveChangesAsync();
    InvalidateRulesCache();
    return true;
}

public async Task<int> ApplyCategoriesToExistingAsync()
{
    var items = await _dbContext.PidEquipment
        .Where(e => e.Category == null)
        .ToListAsync();

    int count = 0;
    foreach (var item in items)
    {
        var category = await MatchCategoryAsync(item.TagNo);
        if (category != null)
        {
            item.Category = category;
            item.UpdatedAt = DateTime.UtcNow;
            count++;
        }
    }
    await _dbContext.SaveChangesAsync();
    return count;
}
```

**3.6.4 ExportToExcelAsync — category + role + from/to 컬럼 추가 (CPU-bound 작업은 Task.Run으로 오프로드):**

```csharp
public async Task<byte[]> ExportToExcelAsync(IEnumerable<PidEquipment> items)
{
    return await Task.Run(() =>
    {
        using var package = new OfficeOpenXml.ExcelPackage();
        var worksheet = package.Workbook.Worksheets.Add("P&ID Equipment");

        // 헤더
        worksheet.Cells[1, 1].Value = "태그번호";
        worksheet.Cells[1, 2].Value = "장비명";
        worksheet.Cells[1, 3].Value = "계기유형";
        worksheet.Cells[1, 4].Value = "라인번호";
        worksheet.Cells[1, 5].Value = "도면번호";
        worksheet.Cells[1, 6].Value = "신뢰도";
        worksheet.Cells[1, 7].Value = "상태";
        worksheet.Cells[1, 8].Value = "추출일시";
        worksheet.Cells[1, 9].Value = "Experion 태그";
        worksheet.Cells[1, 10].Value = "카테고리";
        worksheet.Cells[1, 11].Value = "Role";         // ← 추가
        worksheet.Cells[1, 12].Value = "From";         // ← 추가
        worksheet.Cells[1, 13].Value = "To";           // ← 추가

        // 헤더 스타일
        using var headerRange = worksheet.Cells[1, 1, 1, 13];
        headerRange.Style.Font.Bold = true;
        headerRange.Style.Fill.PatternType = OfficeOpenXml.Style.ExcelFillStyle.Solid;
        headerRange.Style.Fill.BackgroundColor.SetColor(System.Drawing.Color.LightGray);

        int row = 2;
        foreach (var item in items)
        {
            worksheet.Cells[row, 1].Value = item.TagNo;
            worksheet.Cells[row, 2].Value = item.EquipmentName ?? "";
            worksheet.Cells[row, 3].Value = item.InstrumentType ?? "";
            worksheet.Cells[row, 4].Value = item.LineNumber ?? "";
            worksheet.Cells[row, 5].Value = item.PidDrawingNo ?? "";
            worksheet.Cells[row, 6].Value = item.Confidence;
            worksheet.Cells[row, 7].Value = item.IsActive ? "활성" : "비활성";
            worksheet.Cells[row, 8].Value = item.ExtractedAt;
            worksheet.Cells[row, 9].Value = item.ExperionTag?.TagName ?? "";
            worksheet.Cells[row, 10].Value = item.Category ?? "";
            worksheet.Cells[row, 11].Value = item.Role ?? "";       // ← 추가
            worksheet.Cells[row, 12].Value = item.FromTag ?? "";    // ← 추가
            worksheet.Cells[row, 13].Value = item.ToTag ?? "";      // ← 추가
            row++;
        }

        worksheet.Cells.AutoFitColumns();
        return package.GetAsByteArray();
    });
}
```

**3.6.5 ExportToCsvAsync — category 컬럼 추가:**

```csharp
public Task<string> ExportToCsvAsync(IEnumerable<PidEquipment> items)
{
    var sb = new StringBuilder();
    sb.AppendLine("TagNo,EquipmentName,InstrumentType,LineNumber,PidDrawingNo,Confidence,IsActive,ExtractedAt,ExperionTagId,Category,Role,From,To");
    foreach (var i in items)
        sb.AppendLine($"{Csv(i.TagNo)},{Csv(i.EquipmentName)},{Csv(i.InstrumentType)},{Csv(i.LineNumber)},{Csv(i.PidDrawingNo)},{i.Confidence},{i.IsActive},{i.ExtractedAt:O},{i.ExperionTagId},{Csv(i.Category)},{Csv(i.Role)},{Csv(i.FromTag)},{Csv(i.ToTag)}");
    return Task.FromResult(sb.ToString());
}
```

### 3.7 DbContext — ExperionDbContext.cs (변경)

**3.7.1 DbSet 추가:**

```csharp
public DbSet<PidPrefixRule> PidPrefixRules => Set<PidPrefixRule>();
```

**3.7.2 OnModelCreating — PidPrefixRule 설정 추가 (기존 141행 `});` 다음):**

```csharp
modelBuilder.Entity<PidPrefixRule>(entity =>
{
    entity.ToTable("pid_prefix_rules");
    entity.HasKey(e => e.Id);

    entity.Property(e => e.Prefix)
        .IsRequired()
        .HasMaxLength(30);

    entity.HasIndex(e => e.Prefix).IsUnique();

    entity.Property(e => e.Category)
        .IsRequired()
        .HasMaxLength(30);

    entity.Property(e => e.Description)
        .HasMaxLength(200);

    entity.Property(e => e.SortOrder)
        .HasDefaultValue(0);

    entity.Property(e => e.CreatedAt)
        .HasDefaultValueSql("NOW()");

    entity.Property(e => e.UpdatedAt)
        .HasDefaultValueSql("NOW()");

    entity.HasIndex(e => e.Category);
});
```

**3.7.3 PidEquipment — Category + Role + From/To Fluent 설정 추가 (기존 125행 `});` 전):**

```csharp
entity.Property(e => e.Category)
    .HasMaxLength(30);
entity.Property(e => e.Role)
    .HasMaxLength(100);
entity.Property(e => e.FromTag)
    .HasMaxLength(50);
entity.Property(e => e.ToTag)
    .HasMaxLength(50);
entity.HasIndex(e => e.Category);
```

**3.7.4 EnsureCreatedAsync — DDL 추가 (기존 482행 CREATE INDEX 블록 뒤):**

```csharp
// pid_equipment 컬럼 추가 (migration 없이)
await _ctx.Database.ExecuteSqlRawAsync("""
    DO $$
    BEGIN
        IF NOT EXISTS (
            SELECT 1 FROM information_schema.columns
            WHERE table_name='pid_equipment' AND column_name='category'
        ) THEN
            ALTER TABLE pid_equipment
                ADD COLUMN category   TEXT,
                ADD COLUMN role       TEXT,
                ADD COLUMN from_tag   TEXT,
                ADD COLUMN to_tag     TEXT;
            CREATE INDEX IF NOT EXISTS idx_pid_equipment_category
                ON pid_equipment(category);
        END IF;
    END $$;
    """);

// pid_prefix_rules 테이블 (UNIQUE 제약으로 중복 방지)
await _ctx.Database.ExecuteSqlRawAsync("""
    CREATE TABLE IF NOT EXISTS pid_prefix_rules (
        id          SERIAL PRIMARY KEY,
        prefix      TEXT NOT NULL UNIQUE,
        category    TEXT NOT NULL,
        description TEXT,
        sort_order  INT NOT NULL DEFAULT 0,
        created_at  TIMESTAMPTZ NOT NULL DEFAULT NOW(),
        updated_at  TIMESTAMPTZ NOT NULL DEFAULT NOW()
    );
    CREATE INDEX IF NOT EXISTS idx_pid_prefix_rules_category
        ON pid_prefix_rules(category);
    """);

// 시드 데이터 (기존 시드 블록 안에 추가)
await _ctx.Database.ExecuteSqlRawAsync("""
    INSERT INTO pid_prefix_rules (prefix, category, description, sort_order) VALUES
        ('FT',  'instrument',        'Flow Transmitter',              10),
        ('TT',  'instrument',        'Temperature Transmitter',       10),
        ...
        ('FIL-','process_equipment', 'Filter',                        40),
        ('SP-', 'process_equipment', 'Separator',                     40),

    -- Utility Equipment
    ('CWR-','utility_equipment', 'Cooling Water Return',          50),
    ('CWS-','utility_equipment', 'Cooling Water Supply',          50),
    ('IA-', 'utility_equipment', 'Instrument Air',                50),
    ('ST-', 'utility_equipment', 'Steam',                         50),
    ('WW-', 'utility_equipment', 'Waste Water',                   50),
    ('PW-', 'utility_equipment', 'Process/Potable Water',         50),
    ('SW-', 'utility_equipment', 'Service Water',                 50),
    ('VG-', 'utility_equipment', 'Vent Gas',                      50),
    ('CD-', 'utility_equipment', 'Condensate',                    50),
    ('SAM-','utility_equipment', 'Sample Connection',             50),
    ('CHR-','utility_equipment', 'Chemical Return',               50),
    ('CHS-','utility_equipment', 'Chemical Supply',               50),
    ('CH-', 'utility_equipment', 'Chemical',                      50),
    ('NG-', 'utility_equipment', 'Natural Gas',                   50),
    ('BD-', 'utility_equipment', 'Blowdown',                      50),
    ('FL-', 'utility_equipment', 'Flare',                         50),
    ('LO-', 'utility_equipment', 'Lube Oil',                      50)
    ON CONFLICT (prefix) DO NOTHING;
    """);
```

### 3.8 Controller — PidController.cs (prefix-rule endpoints 추가)

```csharp
// ── Prefix 규칙 CRUD ──────────────────────────────────────────────────

[HttpGet("prefix-rules")]
public async Task<IActionResult> GetPrefixRules()
{
    var rules = await _pidExtractor.GetPrefixRulesAsync();
    return Ok(new
    {
        items = rules.Select(r => new
        {
            id = r.Id,
            prefix = r.Prefix,
            category = r.Category,
            description = r.Description,
            sortOrder = r.SortOrder,
            createdAt = r.CreatedAt
        })
    });
}

[HttpPost("prefix-rules")]
public async Task<IActionResult> CreatePrefixRule([FromBody] CreatePidPrefixRuleRequest request)
{
    if (string.IsNullOrWhiteSpace(request.Prefix))
        return BadRequest(new { error = "prefix는 필수입니다." });
    if (!PidEquipment.AllCategories.Contains(request.Category))
        return BadRequest(new { error = $"category는 {string.Join(", ", PidEquipment.AllCategories)} 중 하나여야 합니다." });

    var rule = await _pidExtractor.CreatePrefixRuleAsync(request);
    return Ok(new { id = rule.Id, prefix = rule.Prefix, category = rule.Category });
}

[HttpPut("prefix-rules/{id}")]
public async Task<IActionResult> UpdatePrefixRule(int id, [FromBody] UpdatePidPrefixRuleRequest request)
{
    if (string.IsNullOrWhiteSpace(request.Prefix))
        return BadRequest(new { error = "prefix는 필수입니다." });
    if (!PidEquipment.AllCategories.Contains(request.Category))
        return BadRequest(new { error = $"category는 {string.Join(", ", PidEquipment.AllCategories)} 중 하나여야 합니다." });

    var rule = await _pidExtractor.UpdatePrefixRuleAsync(id, request);
    if (rule == null) return NotFound(new { error = "규칙을 찾을 수 없습니다." });
    return Ok(new { id = rule.Id, prefix = rule.Prefix, category = rule.Category });
}

[HttpDelete("prefix-rules/{id}")]
public async Task<IActionResult> DeletePrefixRule(int id)
{
    var ok = await _pidExtractor.DeletePrefixRuleAsync(id);
    if (!ok) return NotFound(new { error = "규칙을 찾을 수 없습니다." });
    return NoContent();
}

[HttpPost("apply-categories")]
public async Task<IActionResult> ApplyCategories()
{
    var count = await _pidExtractor.ApplyCategoriesToExistingAsync();
    return Ok(new { applied = count });
}
```

### 3.9 Controller — PidController.cs equipment 응답에 category 추가

```csharp
// GetEquipment 메서드의 DTO 생성 부분
var equipmentDtos = itemList.Select(e => new
{
    id = e.Id,
    tagName = e.TagNo,
    equipmentName = e.EquipmentName,
    instrumentType = e.InstrumentType,
    lineNumber = e.LineNumber,
    pidDrawingNo = e.PidDrawingNo,
    confidence = e.Confidence,
    isActive = e.IsActive,
    extractedAt = e.ExtractedAt,
    updatedAt = e.UpdatedAt,
    experionTagId = e.ExperionTagId,
    experionTagName = e.ExperionTag?.TagName,
    category = e.Category,
    role = e.Role,
    fromTag = e.FromTag,
    toTag = e.ToTag
});
```

---

## 4. 프론트엔드 구현

### 4.1 index.html — Prefix 분류 정의 패널 (카테고리별 그룹화)

P&ID 탭 내, "② 서버 파일 선택 → 추출" 카드 아래에 추가. 단일 테이블 대신 카테고리별로 그룹화된 구조로 변경하여 각 카테고리의 prefix 정의를 한눈에 확인할 수 있습니다.

```html
<!-- Prefix 분류 정의 (접이식) -->
<div class="card" style="margin-bottom:12px">
  <div class="card-cap" onclick="pidTogglePrefixPanel()" style="cursor:pointer;user-select:none">
    <span>Prefix 분류 정의 <span id="pid-prefix-toggle">▼</span></span>
    <span style="font-weight:400;font-size:12px;color:var(--t2)">
      태그 prefix 기준으로 카테고리 자동 분류
    </span>
  </div>
  <div id="pid-prefix-panel" style="display:none;padding-top:8px">
    <!-- 카테고리별 그룹 (JS 동적 렌더링) -->
    <div id="pid-prefix-groups">
      <div style="text-align:center;padding:12px;color:var(--t2)">로딩 중...</div>
    </div>
    <!-- 공통 버튼 -->
    <div style="margin-top:8px;display:flex;gap:4px;flex-wrap:wrap;align-items:center">
      <button class="btn-sm btn-b" onclick="pidRefreshPrefixRules()">새로고침</button>
      <button class="btn-sm btn-b" onclick="pidApplyCategories()" title="기존 미분류 항목에 category 재적용">재적용</button>
    </div>
  </div>
</div>
```

**UI 구조**:
- `#pid-prefix-groups` — JS가 카테고리별로 `.pid-cat-group` 섹션을 동적 생성
- 각 그룹: 헤더(카테고리 badge + 규칙 수 + inline 추가 폼) + 본문(prefix 목록)
- 카테고리별 추가 폼은 해당 그룹 헤더에 내장 (카테고리 선택 드롭다운 불필요)

### 4.2 app.js — Prefix 규칙 함수 (카테고리별 그룹 렌더링)

**prefix 패널 토글:**

```javascript
let pidPrefixPanelVisible = false;
function pidTogglePrefixPanel() {
    const panel = document.getElementById('pid-prefix-panel');
    const toggle = document.getElementById('pid-prefix-toggle');
    pidPrefixPanelVisible = !pidPrefixPanelVisible;
    if (panel) panel.style.display = pidPrefixPanelVisible ? 'block' : 'none';
    if (toggle) toggle.textContent = pidPrefixPanelVisible ? '▲' : '▼';
    if (pidPrefixPanelVisible) pidRefreshPrefixRules();
}
```

**카테고리 메타데이터 + 정렬 순서:**

```javascript
const CATEGORY_META = {
    instrument:          { label: 'Instrument',         badge: 'ok' },
    power_equipment:     { label: 'Power Equipment',    badge: 'warn' },
    storage_equipment:   { label: 'Storage Equipment',  badge: 'inf' },
    process_equipment:   { label: 'Process Equipment',  badge: '' },
    utility_equipment:   { label: 'Utility Equipment',  badge: 'warn' }
};
const CATEGORY_ORDER = ['instrument', 'power_equipment', 'storage_equipment', 'process_equipment', 'utility_equipment'];
```

**규칙 목록 조회 — 카테고리별 그룹 렌더링:**

```javascript
async function pidRefreshPrefixRules() {
    const container = document.getElementById('pid-prefix-groups');
    if (!container) return;
    container.innerHTML = '<div style="text-align:center;padding:12px;color:var(--t2)">로딩 중...</div>';

    try {
        const res = await fetch('/api/pid/prefix-rules');
        if (!res.ok) throw new Error(`HTTP ${res.status}`);
        const data = await res.json();
        const items = data.items || [];

        if (items.length === 0) {
            container.innerHTML = '<div style="text-align:center;padding:12px;color:var(--t2)">규칙이 없습니다. 아래에서 추가하세요.</div>';
            return;
        }

        // 카테고리별 그룹화
        const grouped = {};
        for (const r of items) {
            if (!grouped[r.category]) grouped[r.category] = [];
            grouped[r.category].push(r);
        }

        let html = '';
        for (const cat of CATEGORY_ORDER) {
            const rules = grouped[cat];
            if (!rules) continue;
            const meta = CATEGORY_META[cat] || { label: cat, badge: '' };
            rules.sort((a, b) => a.sortOrder - b.sortOrder);

            html += `<div class="pid-cat-group">
                <div class="pid-cat-header">
                    <span class="badge ${meta.badge}">${meta.label}</span>
                    <span class="pid-cat-count">${rules.length}</span>
                    <span class="pid-cat-add" style="margin-left:auto">
                        <input class="inp pid-cat-prefix-input" placeholder="예: FT" style="width:90px;font-family:var(--mono)" />
                        <input class="inp pid-cat-desc-input" placeholder="설명 (선택)" style="width:160px" />
                        <input class="inp pid-cat-order-input" type="number" value="10" style="width:50px" />
                        <button class="btn-a btn-sm" onclick="pidAddPrefixRule('${cat}')">추가</button>
                    </span>
                </div>
                <div class="pid-cat-body">`;

            for (const r of rules) {
                html += `<div class="pid-cat-row">
                    <span class="pid-cat-prefix"><strong style="font-family:var(--mono)">${esc(r.prefix)}</strong></span>
                    <span class="pid-cat-desc" style="color:var(--t2)">${esc(r.description) || '-'}</span>
                    <span class="pid-cat-order">${r.sortOrder}</span>
                    <span class="pid-cat-actions">
                        <button class="btn-sm btn-b" onclick="pidDeletePrefixRule(${r.id}, '${esc(r.prefix)}')">삭제</button>
                    </span>
                </div>`;
            }

            html += `</div></div>`;
        }

        container.innerHTML = html;
    } catch (e) {
        container.innerHTML = `<div style="text-align:center;padding:12px;color:var(--red)">오류: ${e.message}</div>`;
    }
}
```

**규칙 추가 — 카테고리 매개변수 + event.target.closest:**

```javascript
async function pidAddPrefixRule(category) {
    const row = event.target.closest('.pid-cat-add');
    const prefix = row.querySelector('.pid-cat-prefix-input').value.trim();
    const desc = row.querySelector('.pid-cat-desc-input').value.trim();
    const order = parseInt(row.querySelector('.pid-cat-order-input').value) || 10;

    if (!prefix) { alert('Prefix를 입력하세요.'); return; }

    try {
        const res = await fetch('/api/pid/prefix-rules', {
            method: 'POST',
            headers: { 'Content-Type': 'application/json' },
            body: JSON.stringify({ prefix, category, description: desc, sortOrder: order })
        });
        if (!res.ok) {
            const err = await res.json().catch(() => ({ error: res.statusText }));
            throw new Error(err.error || res.statusText);
        }
        row.querySelector('.pid-cat-prefix-input').value = '';
        row.querySelector('.pid-cat-desc-input').value = '';
        await pidRefreshPrefixRules();
    } catch (e) {
        alert(`추가 실패: ${e.message}`);
    }
}
```

**규칙 삭제:**

```javascript
async function pidDeletePrefixRule(id, prefix) {
    if (!confirm(`"${prefix}" 규칙을 삭제하시겠습니까?`)) return;
    try {
        const res = await fetch(`/api/pid/prefix-rules/${id}`, { method: 'DELETE' });
        if (!res.ok) throw new Error(`HTTP ${res.status}`);
        await pidRefreshPrefixRules();
    } catch (e) {
        alert(`삭제 실패: ${e.message}`);
    }
}
```

**기존 데이터 category 재적용:**

```javascript
async function pidApplyCategories() {
    if (!confirm('기존 미분류 항목에 category를 재적용하시겠습니까?')) return;
    try {
        const res = await fetch('/api/pid/apply-categories', { method: 'POST' });
        if (!res.ok) throw new Error(`HTTP ${res.status}`);
        const data = await res.json();
        alert(`✅ ${data.applied}건 category 적용 완료`);
        await pidLoadTable(pidCurrentPage);
        pidUpdateStats();
    } catch (e) {
        alert(`재적용 실패: ${e.message}`);
    }
}
```

**테이블 렌더링 — category 컬럼 추가:**

```javascript
// pidLoadTable 함수의 tbody.innerHTML 부분 — category 컬럼 추가
tbody.innerHTML = data.items.map(item => `
    <tr>
        <td>${item.id}</td>
        <td><strong>${esc(item.tagName)}</strong></td>
        <td>${esc(item.equipmentName) || '-'}</td>
        <td>${esc(item.instrumentType) || '-'}</td>
        <td>${esc(item.lineNumber) || '-'}</td>
        <td>${esc(item.pidDrawingNo) || '-'}</td>
        <td style="text-align:center">${(item.confidence * 100).toFixed(1)}%</td>
        <td style="text-align:center">
            <span class="badge ${item.isActive ? 'ok' : 'warn'}">${item.isActive ? '활성' : '비활성'}</span>
        </td>
        <td>
            ${item.experionTagId
                ? `<span class="badge ok">✅ ${esc(item.experionTagName || '')}</span>`
                : `<button class="btn-sm btn-b" onclick="pidOpenMapping(${item.id})">매핑</button>`
            }
        </td>
        <td>${item.category
            ? `<span class="badge ${pidCategoryBadge(item.category)}">${esc(item.category)}</span>`
            : '-'}
        </td>
    </tr>
`).join('');
```

### 4.3 index.html — 테이블 헤더에 category 컬럼 추가

```html
<thead>
    <tr>
        <th style="width:60px">ID</th>
        <th>태그번호</th>
        <th>장비명</th>
        <th>유형</th>
        <th>라인번호</th>
        <th>도면번호</th>
        <th style="width:80px">신뢰도</th>
        <th style="width:80px">상태</th>
        <th style="width:120px">매핑</th>
        <th style="width:120px">카테고리</th>   <!-- ← 추가 -->
    </tr>
</thead>
```

### 4.4 style.css — 카테고리 그룹 스타일 추가

```css
/* .badge.inf — Storage Equipment 파란색 */
#pane-pid .badge.inf {
  background: rgba(59,130,246,.15);
  color: var(--blu);
}

/* 카테고리별 그룹 컨테이너 */
#pane-pid .pid-cat-group {
  margin-bottom: 8px;
  border: 1px solid var(--bd);
  border-radius: var(--r);
  overflow: hidden;
}

/* 그룹 헤더: badge + 규칙 수 + inline 추가 폼 */
#pane-pid .pid-cat-header {
  display: flex;
  align-items: center;
  gap: 8px;
  padding: 6px 10px;
  background: var(--s2);
  border-bottom: 1px solid var(--bd);
  font-size: 13px;
}

#pane-pid .pid-cat-count {
  font-size: 11px;
  color: var(--t2);
}

#pane-pid .pid-cat-add {
  display: flex;
  align-items: center;
  gap: 4px;
}

/* 그룹 본문 */
#pane-pid .pid-cat-body {
  padding: 0;
}

/* 개별 prefix 행 */
#pane-pid .pid-cat-row {
  display: flex;
  align-items: center;
  gap: 8px;
  padding: 5px 10px;
  font-size: 12px;
  border-bottom: 1px solid var(--bd);
}

#pane-pid .pid-cat-row:last-child {
  border-bottom: none;
}

#pane-pid .pid-cat-prefix {
  width: 80px;
  flex-shrink: 0;
}

#pane-pid .pid-cat-desc {
  flex: 1;
  min-width: 0;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

#pane-pid .pid-cat-order {
  width: 40px;
  text-align: center;
  flex-shrink: 0;
  color: var(--t2);
}

#pane-pid .pid-cat-actions {
  width: 50px;
  text-align: center;
  flex-shrink: 0;
}
```

---

## 5. 전체 변경 파일 목록

| 파일 | 변경 유형 | 설명 |
|------|-----------|------|
| `src/Core/Domain/Entities/PidEquipment.cs` | 수정 | `Category`(5종 상수) + `Role`/`FromTag`/`ToTag` 속성 |
| `src/Core/Domain/Entities/PidPrefixRule.cs` | **신규** | prefix 규칙 엔티티 |
| `src/Core/Application/DTOs/PidEquipmentDto.cs` | 수정 | `Category` + `Role`/`FromTag`/`ToTag` 필드 추가 |
| `src/Core/Application/DTOs/PidPrefixRuleDto.cs` | **신규** | prefix 규칙 DTO 3종 |
| `src/Core/Application/Interfaces/IExperionServices.cs` | 수정 | `IPidExtractorService`에 prefix 규칙 메서드 추가 |
| `src/Core/Application/Services/PidExtractorService.cs` | 수정 | prefix 매칭, CRUD, export (category+role+from/to) |
| `src/Infrastructure/Database/ExperionDbContext.cs` | 수정 | DbSet, Fluent API, DDL 추가 |
| `src/Web/Controllers/PidController.cs` | 수정 | prefix-rule CRUD + apply-categories 엔드포인트 |
| `src/Web/wwwroot/index.html` | 수정 | prefix 패널 + 테이블 category 컬럼 |
| `src/Web/wwwroot/js/app.js` | 수정 | prefix CRUD 함수 + 테이블 category 렌더링 |

---

## 6. RAG 인덱싱 연계 방안 (Phase 2)

### 6.1 Excel 업로드 시 category 활용

사용자가 검토/정제한 Excel을 KB에 업로드할 때:

```
POST /api/kb/upload
  file: pid-equipment-2026-05-16_clean.xlsx
  collectionKey: system_instrument
  title: "9차플랜트 P&ID 추출"
  tags: "pid, 9차플랜트"
```

`system_instrument` 컬렉션의 `chunking_policy`는 `{"xlsx":"row+sheet"}`로,
XLSX 파서가 시트별/행별로 청크를 생성하고 각 청크에 `sheet`/`row` locator가 포함됩니다.

### 6.2 KB 검색 시 category 필터

Qdrant point의 `tags` payload에 category를 저장하면:

```javascript
// search_kb MCP 도구 호출 시
{
  query: "FT-101",
  tags: ["instrument"],        // ← category를 tags로 활용
  collection_keys: ["system_instrument"]
}
```

### 6.3 향후 확장: category별 컬렉션 분리 (선택)

대량 데이터가 쌓이면 category별로 Qdrant 컬렉션을 분리:
- `kb_pid_instrument`
- `kb_pid_power_equipment`
- `kb_pid_storage_equipment`
- `kb_pid_process_equipment`
- `kb_pid_utility_equipment`

→ Excel 업로드 시 category에 따라 다른 컬렉션으로 자동 분기

### 6.4 Role / From / To 활용 방안

Excel의 각 컬럼이 RAG에서 인덱싱될 때:

| Excel 컬럼 | Qdrant payload | 검색 활용 |
|-----------|---------------|-----------|
| 태그번호 | `text` 포함 | "FT-101의 역할은?" → 청크 텍스트에서 검색 |
| 장비명 | `text` 포함 | "Cooling Water Pump 역할" 검색 가능 |
| 카테고리 | `tags` 배열 | category 필터: `tags: ["power_equipment"]` |
| Role | `text` 포함 | "Cooling Water Supply" role을 가진 장비 검색 |
| From | `text` 포함 | "T-101에서 E-10103으로 연결" 질의 가능 |
| To | `text` 포함 | 위와 동일 |

XLSX 파서(`xlsx_parser.py`)가 각 행을 하나의 청크로 만들므로,
`text` 필드에 전체 행 정보가 응축됨:

```
[9차플랜트] FT-101 | Flow Transmitter | instrument | Role: Cooling Water Flow | From: T-201 | To: E-10103 | Confidence: 0.95
```

---

## 7. 실행 순서

1. **DB 마이그레이션**: 서버 재시작 시 `ExperionDbContext.EnsureCreatedAsync()`가 category 컬럼 ALTER + prefix_rules 테이블 생성
2. **서버 재빌드**: `dotnet build src/Web/ExperionCrawler.csproj`
 3. **서버 재시작**: `sudo systemctl restart experioncrawler`
 4. **사용자 설정**: P&ID 추출 탭 → Prefix 분류 정의 패널에서 규칙 확인/수정
 5. **기존 데이터 재적용**: "재적용" 버튼으로 미분류 항목에 category 부여
 6. **재추출**: 새 DXF 도면 추출 시 자동으로 category 적용
 7. **Excel 다운로드**: category 컬럼 포함된 Excel 다운로드 → 검토 → KB 업로드

---

## 8. 구현 적용 시 발견된 문제점 및 수정 내역

diagnosis-checklist.md 8단계 진단 + 실제 빌드/검증 과정에서 발견된 문제점을 기록합니다.

### 8.1 🔴 HIGH — EnsureCreatedAsync 시드 SQL 구문 오류

**문제**: `DO $$ BEGIN ... END $$` 블록 내에서 `INSERT ... VALUES (...)` 사이에 SQL 주석(`--`)이 포함되어 PostgreSQL 구문 오류 발생.

```sql
-- ❌ 개선안 원문 (636-664행)
INSERT INTO pid_prefix_rules (...) VALUES
    ('FT',  'instrument', ...),
    ...
    ('SP-', 'process_equipment', 'Separator', 40),

    -- Utility Equipment         ← VALUES 내에서 주석 = 구문 오류
    ('CWR-','utility_equipment', ...),
    ...
```

`ExecuteSqlRawAsync` 호출 시 `42601: syntax error at or near "Utility"` 에러 → 서버 시작 시 DB 초기화 실패 → 시드 데이터 전량 미적용.

**수정**: 시드 INSERT 내 모든 `--` 주석 제거. 단일 INSERT 문으로 통합 (`ExperionDbContext.cs` EnsureCreatedAsync 블록).

### 8.2 🟠 MED — GetRulesCachedAsync Race Condition

**문제**: double-check locking에서 early return 경로(`if (_cachedRules != null) return _cachedRules;`)가 lock 없이 `_cachedRules` 참조를 읽습니다. 읽기 후 반환 전에 `InvalidateRulesCache()`가 `Interlocked.Exchange(ref _cachedRules, null)`로 null 스왑하면 NRE.

```csharp
// ❌ 개선안 원문
private async Task<List<PidPrefixRule>> GetRulesCachedAsync()
{
    if (_cachedRules != null) return _cachedRules;  // ← lock 없음, 읽기-반환 사이에 null 스왑 가능
    await _cacheLock.WaitAsync();
    // ...
}
```

**수정**: early return을 로컬 변수로 복사 후 반환 (PidExtractorService.cs).

```csharp
private async Task<List<PidPrefixRule>> GetRulesCachedAsync()
{
    var rules = _cachedRules;
    if (rules != null) return rules;  // ← 로컬 변수로 안전
    await _cacheLock.WaitAsync();
    try {
        rules = _cachedRules;
        if (rules != null) return rules;
        rules = await _dbContext.PidPrefixRules
            .OrderByDescending(r => r.Prefix.Length)
            .ThenBy(r => r.SortOrder)
            .ToListAsync();
        _cachedRules = rules;
        return rules;
    } finally { _cacheLock.Release(); }
}
```

### 8.3 🟠 MED — ApplyCategoriesToExistingAsync 전체 메모리 로딩

**문제**: `Category == NULL`인 항목을 한 번에 `ToListAsync()`로 전체 메모리 로딩. 수만 건 시 EF Core 트래킹 + 엔티티 객체로 50-100MB, 일괄 저장 시 2-3배 증가.

```csharp
// ❌ 개선안 원문
var items = await _dbContext.PidEquipment
    .Where(e => e.Category == null)
    .ToListAsync();  // ← 전체 메모리 로딩
```

**수정**: 1000건 단위 batch chunking 적용 (PidExtractorService.cs).

### 8.4 🟡 LOW — ExportToCsvAsync 가짜 async

**문제**: `Task.FromResult()`로 동기 결과를 랩핑하여 CPU-bound 작업이 요청 스레드에서 실행. Excel export는 `Task.Run`으로 오프로드하지만 CSV는 불일치.

**수정**: `Task.Run(() => { ... })`으로 감싸 Excel과 일관성 확보.

### 8.5 🟡 LOW — ExportToCsvAsync 컬럼 누락

**문제**: 개선안 3.6.5에서 CSV 헤더에 `Category,Role,From,To`를 추가했지만, 실제 기존 코드의 CSV 헤더는 `UpdatedAt`과 `ExperionTagName`도 누락. 개선안 반영 시 기존 누락까지 함께 보정.

### 8.6 🟡 LOW — P- vs PSV 주석 오해

**문제**: 개선안 2.4에서 `"P-"보다 PSV가 우선`이라는 주석이 있지만, `P-`(하이픈 포함)는 `PSV-6203`과 전혀 충돌하지 않음. `"PSV-6203".StartsWith("P-")` → `false`.

**수정**: 시드 데이터에서 주석 없이 정리. `P-`는 `P-10101`만 매칭하므로 별도 처리 불필요.

### 8.7 빌드 에러 — PidController.cs using 누락

**문제**: `PidEquipment.AllCategories`를 참조하지만 `using ExperionCrawler.Core.Domain.Entities;`가 누락되어 CS0103 빌드 에러.

**수정**: using 문 추가.

### 8.8 기존 코드와의 불일치 — export endpoint 경로

**문제**: 프론트엔드(`/api/pid/export/csv`)와 실제 컨트롤러(`/api/pid/export/csv` → `ExperionPidController`)가 다른 클래스에 있어 라우트 충돌 방지를 위한 분리 구조임. 개선안에서는 별도의 컨트롤러 수정 없이 기존 구조 유지.

---

## 9. 변경 파일 목록 (실제 적용)

| 파일 | 변경 유형 | 설명 |
|------|-----------|------|
| `src/Core/Domain/Entities/PidEquipment.cs` | 수정 | `Category`(5종 상수) + `Role`/`FromTag`/`ToTag` 속성 |
| `src/Core/Domain/Entities/PidPrefixRule.cs` | **신규** | prefix 규칙 엔티티 |
| `src/Core/Application/DTOs/PidEquipmentDto.cs` | 수정 | `Category` + `Role`/`FromTag`/`ToTag` 필드 추가 |
| `src/Core/Application/DTOs/PidPrefixRuleDto.cs` | **신규** | prefix 규칙 DTO 3종 |
| `src/Core/Application/Interfaces/IExperionServices.cs` | 수정 | `IPidExtractorService`에 prefix 규칙 메서드 추가 |
| `src/Core/Application/Services/PidExtractorService.cs` | 수정 | prefix 매칭, CRUD, export (category+role+from/to), Race Condition fix, batch chunking |
| `src/Infrastructure/Database/ExperionDbContext.cs` | 수정 | DbSet, Fluent API, DDL (SQL 주석 제거), 시드 데이터 |
| `src/Web/Controllers/PidController.cs` | 수정 | prefix-rule CRUD + apply-categories 엔드포인트, using 추가 |
| `src/Web/wwwroot/index.html` | 수정 | prefix 패널 카테고리별 그룹화 + 테이블 category 컬럼 |
| `src/Web/wwwroot/js/app.js` | 수정 | 카테고리별 그룹 렌더링, inline 추가 폼, CATEGORY_META |
| `src/Web/wwwroot/css/style.css` | 수정 | `.badge.inf` + `.pid-cat-group` 계열 스타일 추가 |
