fix: tagDcs 수동 변경이 재시작 시 리셋되는 버그 수정 + PID 테이블 신규 레코드 추가 기능 + natural sort

- tagDcs backfill(Step-4)을 컬럼 존재 여부로 가드하여 최초 1회만 실행
- UpdatedAt EF 매핑 ValueGeneratedOnAddOrUpdate → ValueGeneratedOnAdd
- PropertyNameCaseInsensitive = true 추가
- PID 추출 결과 테이블에 '+ 추가' 버튼 및 빈 editable 행 (POST /api/pid)
- 태그번호 정렬: prefix → 길이 → case-insensitive natural sort
- 페이지네이션 표시 범위 ±3 → ±10
This commit is contained in:
windpacer
2026-05-28 16:24:16 +09:00
parent 543ce85af5
commit 8f5dabbbc7
10 changed files with 402 additions and 101 deletions

View File

@@ -17,3 +17,25 @@ public record PidEquipmentDto(
string? Role,
string? FromTag,
string? ToTag);
public record CreateEquipmentRequest(
string TagNo,
string? EquipmentName = null,
string? InstrumentType = null,
string? Category = null,
bool? TagDcs = null,
string? Role = null,
string? FromTag = null,
string? ToTag = null,
string? TagClass = null);
public record UpdateEquipmentRequest(
string? TagNo = null,
string? EquipmentName = null,
string? InstrumentType = null,
string? Category = null,
bool? TagDcs = null,
string? Role = null,
string? FromTag = null,
string? ToTag = null,
string? TagClass = null);

View File

@@ -318,7 +318,8 @@ public interface IPidExtractorService
// 조회 (페이지네이션)
Task<(int Total, IEnumerable<PidEquipment> Items)> GetEquipmentAsync(
string? tagNo, int page, int pageSize);
string? tagNo, string? category, int page, int pageSize,
string? sortBy = null, bool sortDesc = false);
Task<PidEquipment?> GetByIdAsync(long id);
@@ -326,6 +327,9 @@ public interface IPidExtractorService
Task UpdateConfidenceAsync(long id, double confidence);
Task ActivateAsync(long id);
Task DeactivateAsync(long id);
Task<PidEquipment> CreateEquipmentAsync(CreateEquipmentRequest request);
Task<bool> UpdateEquipmentAsync(long id, UpdateEquipmentRequest request);
Task<(bool Found, string? TagName)> AutoMapTagAsync(long id);
Task<bool> DeleteAsync(long id);
// 통계

View File

@@ -378,24 +378,60 @@ public class PidExtractorService : IPidExtractorService
private async Task<RealtimePoint?> FindFallbackTagAsync(string tagNo)
{
var normalized = tagNo.Split('.')[0];
var normalized = tagNo.Split('.')[0].ToLowerInvariant();
return await _dbContext.RealtimePoints
.FirstOrDefaultAsync(t => t.TagName == normalized
|| t.TagName.StartsWith(normalized + "."));
.FirstOrDefaultAsync(t => t.TagName.ToLower() == normalized
|| t.TagName.ToLower().StartsWith(normalized + "."));
}
public async Task<(int Total, IEnumerable<PidEquipment> Items)> GetEquipmentAsync(
string? tagNo, int page, int pageSize)
string? tagNo, string? category, int page, int pageSize,
string? sortBy = null, bool sortDesc = false)
{
var q = _dbContext.PidEquipment.AsQueryable();
if (!string.IsNullOrEmpty(tagNo))
q = q.Where(e => e.TagNo.Contains(tagNo));
if (!string.IsNullOrEmpty(category))
{
if (category == "__unmatched__")
q = q.Where(e => e.Category == null);
else if (category == "instrument_dcs")
q = q.Where(e => e.Category == PidEquipment.CategoryInstrument && e.TagDcs);
else if (category == "instrument_field")
q = q.Where(e => e.Category == PidEquipment.CategoryInstrument && !e.TagDcs);
else
q = q.Where(e => e.Category == category);
}
var total = await q.CountAsync();
var items = await q.OrderByDescending(e => e.ExtractedAt)
.Skip((page - 1) * pageSize).Take(pageSize).ToListAsync();
List<PidEquipment> items;
if (sortBy == "tagName")
{
// DB에서 기본 정렬로 가져온 뒤 C#에서 natural sort
var all = await q.OrderBy(e => e.TagNo.Length).ThenBy(e => e.TagNo).ToListAsync();
if (sortDesc)
all = all.OrderByDescending(e => TagSortKey(e.TagNo)).ThenByDescending(e => e.TagNo.ToLowerInvariant()).ToList();
else
all = all.OrderBy(e => TagSortKey(e.TagNo)).ThenBy(e => e.TagNo.ToLowerInvariant()).ToList();
items = all.Skip((page - 1) * pageSize).Take(pageSize).ToList();
}
else
{
items = await q.OrderByDescending(e => e.ExtractedAt)
.Skip((page - 1) * pageSize).Take(pageSize).ToListAsync();
}
return (total, items);
}
private static string TagSortKey(string tagNo)
{
// prefix (첫 digit 앞까지의 문자) + 길이 + 원본 순으로 정렬 가능한 키 생성
var m = Regex.Match(tagNo, @"^([A-Za-z-]+)");
var prefix = m.Success ? m.Groups[1].Value.ToLowerInvariant() : tagNo.ToLowerInvariant();
return $"{prefix}\x00{tagNo.Length:D4}\x00{tagNo.ToLowerInvariant()}";
}
public async Task<PidEquipment?> GetByIdAsync(long id)
=> await _dbContext.PidEquipment.Include(e => e.ExperionTag).FirstOrDefaultAsync(e => e.Id == id);
@@ -407,6 +443,54 @@ public class PidExtractorService : IPidExtractorService
await _dbContext.SaveChangesAsync();
}
public async Task<PidEquipment> CreateEquipmentAsync(CreateEquipmentRequest request)
{
var e = new PidEquipment
{
TagNo = request.TagNo.ToLowerInvariant(),
EquipmentName = request.EquipmentName,
InstrumentType = request.InstrumentType,
Category = request.Category,
TagDcs = request.TagDcs ?? false,
Role = request.Role,
FromTag = request.FromTag,
ToTag = request.ToTag,
TagClass = request.TagClass ?? ClassifyTagClass(request.Category, request.TagDcs ?? false),
IsActive = true,
Confidence = 1.0,
ExtractedAt = DateTime.UtcNow,
UpdatedAt = DateTime.UtcNow
};
if (request.FromTag != null || request.ToTag != null)
e.ConnectionLocked = true;
_dbContext.PidEquipment.Add(e);
await _dbContext.SaveChangesAsync();
_logger.LogInformation("[PID] Created equipment id={Id} tagNo={Tag}", e.Id, e.TagNo);
return e;
}
public async Task<bool> UpdateEquipmentAsync(long id, UpdateEquipmentRequest request)
{
var e = await _dbContext.PidEquipment.FindAsync(id);
if (e == null) return false;
if (request.TagNo != null) e.TagNo = request.TagNo.ToLowerInvariant();
if (request.EquipmentName != null) e.EquipmentName = request.EquipmentName;
if (request.InstrumentType != null) e.InstrumentType = request.InstrumentType;
if (request.Category != null) e.Category = request.Category;
if (request.TagDcs.HasValue) e.TagDcs = request.TagDcs.Value;
if (request.Role != null) e.Role = request.Role;
if (request.FromTag != null) e.FromTag = request.FromTag;
if (request.ToTag != null) e.ToTag = request.ToTag;
if (request.TagClass != null) e.TagClass = request.TagClass;
if (request.Category != null || request.TagDcs.HasValue)
e.TagClass = ClassifyTagClass(e.Category, e.TagDcs);
if (request.FromTag != null || request.ToTag != null)
e.ConnectionLocked = true;
e.UpdatedAt = DateTime.UtcNow;
await _dbContext.SaveChangesAsync();
return true;
}
public async Task ActivateAsync(long id)
{
var e = await _dbContext.PidEquipment.FindAsync(id);
@@ -432,6 +516,18 @@ public class PidExtractorService : IPidExtractorService
return true;
}
public async Task<(bool Found, string? TagName)> AutoMapTagAsync(long id)
{
var e = await _dbContext.PidEquipment.FindAsync(id);
if (e == null) return (false, null);
var match = await FindFallbackTagAsync(e.TagNo ?? "");
if (match == null) return (false, null);
e.ExperionTagId = match.Id;
e.UpdatedAt = DateTime.UtcNow;
await _dbContext.SaveChangesAsync();
return (true, match.TagName);
}
public async Task<int> DeleteAllEquipmentAsync()
{
var count = await _dbContext.PidEquipment.CountAsync();
@@ -485,12 +581,19 @@ public class PidExtractorService : IPidExtractorService
var rules = await GetRulesCachedAsync();
var grouped = items
.GroupBy(i => string.IsNullOrEmpty(i.Category) ? "__unmatched__" : i.Category!)
.GroupBy(i =>
{
var cat = string.IsNullOrEmpty(i.Category) ? "__unmatched__" : i.Category!;
if (cat == PidEquipment.CategoryInstrument)
return i.TagDcs ? "instrument_dcs" : "instrument_field";
return cat;
})
.ToDictionary(g => g.Key, g => g.ToList());
var sheetOrder = new[]
{
PidEquipment.CategoryInstrument,
"instrument_dcs",
"instrument_field",
PidEquipment.CategoryPowerEquipment,
PidEquipment.CategoryStorageEquipment,
PidEquipment.CategoryProcessEquipment,
@@ -501,7 +604,8 @@ public class PidExtractorService : IPidExtractorService
var sheetNames = new Dictionary<string, string>
{
[PidEquipment.CategoryInstrument] = "Instrument",
["instrument_dcs"] = "DCS 태그",
["instrument_field"] = "현장 계기",
[PidEquipment.CategoryPowerEquipment] = "Power Equipment",
[PidEquipment.CategoryStorageEquipment] = "Storage Equipment",
[PidEquipment.CategoryProcessEquipment] = "Process Equipment",
@@ -891,10 +995,12 @@ public class PidExtractorService : IPidExtractorService
{
const int batchSize = 1000;
int total = 0;
long lastId = 0;
while (true)
{
var batch = await _dbContext.PidEquipment
.Where(e => e.Category == null)
.Where(e => e.Id > lastId)
.OrderBy(e => e.Id)
.Take(batchSize)
.ToListAsync();
if (!batch.Any()) break;
@@ -902,36 +1008,17 @@ public class PidExtractorService : IPidExtractorService
foreach (var item in batch)
{
var category = await MatchCategoryAsync(item.TagNo);
if (category != null)
var tagDcs = await ResolveTagDcsAsync(item.TagNo);
var tagClass = ClassifyTagClass(category, tagDcs);
if (item.Category != category || item.TagDcs != tagDcs || item.TagClass != tagClass)
{
var tagDcs = await ResolveTagDcsAsync(item.TagNo);
item.Category = category;
item.TagDcs = tagDcs;
item.TagClass = ClassifyTagClass(category, tagDcs);
item.TagClass = tagClass;
item.UpdatedAt = DateTime.UtcNow;
total++;
}
}
await _dbContext.SaveChangesAsync();
}
// 이미 instrument 로 분류됐으나 tag_class 미지정인 기존 행 backfill
// (Unmatched/category 자체는 건드리지 않음)
while (true)
{
var batch = await _dbContext.PidEquipment
.Where(e => e.Category == PidEquipment.CategoryInstrument && e.TagClass == null)
.Take(batchSize)
.ToListAsync();
if (!batch.Any()) break;
foreach (var item in batch)
{
var tagDcs = await ResolveTagDcsAsync(item.TagNo);
item.TagDcs = tagDcs;
item.TagClass = ClassifyTagClass(item.Category, tagDcs);
item.UpdatedAt = DateTime.UtcNow;
total++;
lastId = item.Id;
}
await _dbContext.SaveChangesAsync();
}

View File

@@ -131,7 +131,7 @@ public class ExperionDbContext : DbContext
.HasDefaultValueSql("NOW()");
entity.Property(e => e.UpdatedAt)
.ValueGeneratedOnAddOrUpdate()
.ValueGeneratedOnAdd()
.HasDefaultValueSql("NOW()");
entity.HasIndex(e => e.TagNo);
@@ -605,19 +605,27 @@ public class ExperionDbService : IExperionDbService
WHERE prefix IN ('FIC','TIC','PIC','LIC','FY','TY','PY','LY','FV','TV','PV','LV');
""");
// Step 3: pid_equipment 컬럼 추가
// Step 3+4: pid_equipment 컬럼 추가 + backfill (최초 1회만 실행)
// ADD COLUMN IF NOT EXISTS는 멱등하지만 뒤따르는 UPDATE backfill이
// 매번 재실행되면 수동 변경(FIT-9128 tag_dcs=false)을 덮어쓰는 문제가 있음.
// → PL/pgSQL 블록으로 컬럼 존재 여부를 검사하여 최초 1회만 실행
await _ctx.Database.ExecuteSqlRawAsync("""
ALTER TABLE pid_equipment
ADD COLUMN IF NOT EXISTS tag_dcs BOOLEAN NOT NULL DEFAULT FALSE;
""");
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name='pid_equipment' AND column_name='tag_dcs'
) THEN
ALTER TABLE pid_equipment
ADD COLUMN tag_dcs BOOLEAN NOT NULL DEFAULT FALSE;
// Step 4: 기존 행 backfill — StartsWith 매칭 (FICQ/FICA 등 compound형 자동 포함)
await _ctx.Database.ExecuteSqlRawAsync("""
UPDATE pid_equipment pe
SET tag_dcs = TRUE
FROM pid_prefix_rules pr
WHERE pe.instrument_type LIKE (pr.prefix || '%')
AND pr.tag_dcs = TRUE;
UPDATE pid_equipment pe
SET tag_dcs = TRUE
FROM pid_prefix_rules pr
WHERE pe.instrument_type LIKE (pr.prefix || '%')
AND pr.tag_dcs = TRUE;
END IF;
END $$;
""");
// ─────────────────────────────────────────────────────────────────────────

View File

@@ -107,9 +107,9 @@ public class PidController : ControllerBase
}
[HttpGet("equipment")]
public async Task<IActionResult> GetEquipment(string? tagNo = null, int page = 1, int pageSize = 50)
public async Task<IActionResult> GetEquipment(string? tagNo = null, string? category = null, int page = 1, int pageSize = 50, string? sortBy = null, bool sortDesc = false)
{
var (total, items) = await _pidExtractor.GetEquipmentAsync(tagNo, page, pageSize);
var (total, items) = await _pidExtractor.GetEquipmentAsync(tagNo, category, page, pageSize, sortBy, sortDesc);
var itemList = items.ToList();
var equipmentDtos = itemList.Select(e => new
@@ -127,6 +127,8 @@ public class PidController : ControllerBase
experionTagId = e.ExperionTagId,
experionTagName = e.ExperionTag?.TagName,
category = e.Category,
tagClass = e.TagClass,
tagDcs = e.TagDcs,
role = e.Role,
fromTag = e.FromTag,
toTag = e.ToTag,
@@ -144,7 +146,42 @@ public class PidController : ControllerBase
items = equipmentDtos
});
}
[HttpPost("{id}/auto-map")]
public async Task<IActionResult> AutoMap(long id)
{
var (found, tagName) = await _pidExtractor.AutoMapTagAsync(id);
if (!found) return NotFound(new { error = "매칭되는 Experion 태그가 없습니다." });
return Ok(new { tagName });
}
[HttpPut("{id}")]
public async Task<IActionResult> UpdateEquipment(long id, [FromBody] UpdateEquipmentRequest request)
{
var ok = await _pidExtractor.UpdateEquipmentAsync(id, request);
if (!ok) return NotFound(new { error = "레코드를 찾을 수 없습니다." });
return NoContent();
}
[HttpPost]
public async Task<IActionResult> CreateEquipment([FromBody] CreateEquipmentRequest request)
{
var e = await _pidExtractor.CreateEquipmentAsync(request);
return CreatedAtAction(nameof(CreateEquipment), new { id = e.Id }, new
{
id = e.Id,
tagName = e.TagNo,
equipmentName = e.EquipmentName,
instrumentType = e.InstrumentType,
category = e.Category,
tagDcs = e.TagDcs,
tagClass = e.TagClass,
role = e.Role,
fromTag = e.FromTag,
toTag = e.ToTag
});
}
[HttpGet("statistics")]
public async Task<IActionResult> GetStatistics()
{

View File

@@ -20,6 +20,8 @@ var mvcBuilder = builder.Services.AddControllers()
{
// JSON 직렬화 시 대소문자 구분 없이 처리하도록 PascalCase 유지
opt.JsonSerializerOptions.PropertyNamingPolicy = null;
// Deserialize 시 camelCase 키를 C# PascalCase 속성에 매핑 (프론트엔드 호환)
opt.JsonSerializerOptions.PropertyNameCaseInsensitive = true;
});
builder.Services.AddMemoryCache();

View File

@@ -4,7 +4,7 @@
"Default": "Information",
"Microsoft.AspNetCore": "Warning",
"Microsoft.EntityFrameworkCore": "Warning",
"Microsoft.EntityFrameworkCore.Database.Command": "Warning",
"Microsoft.EntityFrameworkCore.Database.Command": "Information",
"ExperionCrawler.Infrastructure.OpcUa.ExperionHistoryService": "Warning"
},
"Console": {

View File

@@ -88,10 +88,6 @@
font-size: 13px;
}
#pane-pid .pid-cat-count {
font-size: 11px;
color: var(--t2);
}
#pane-pid .pid-cat-add {
display: flex;

View File

@@ -4,6 +4,9 @@
let pidCurrentPage = 1;
let pidPageSize = 20;
let pidLastResult = null; // Excel export용
let pidCatFilter = ''; // 카테고리 필터
let pidSortBy = ''; // 정렬 필드
let pidSortDesc = false; // 내림차순
async function pidUpload() {
const fileInput = document.getElementById('pid-file-input');
@@ -149,6 +152,23 @@ async function pidExtract() {
}
}
function pidOnCatFilter() {
pidCatFilter = document.getElementById('pid-cat-filter').value;
pidCurrentPage = 1;
pidLoadTable(1);
}
function pidToggleSort(field) {
if (pidSortBy === field) {
pidSortDesc = !pidSortDesc;
} else {
pidSortBy = field;
pidSortDesc = false;
}
pidCurrentPage = 1;
pidLoadTable(1);
}
async function pidLoadTable(page = 1) {
pidCurrentPage = page;
const container = document.getElementById('pid-table-container');
@@ -156,54 +176,71 @@ async function pidLoadTable(page = 1) {
if (!container || !tbody) return;
// tbody만 비우고 로딩 상태 표시 — container.innerHTML을 덮어쓰면 tbody가 DOM에서 떨어져 나가
// 이후 tbody.innerHTML 할당이 화면에 반영되지 않음.
tbody.innerHTML = '<tr><td colspan="10" style="text-align:center;padding:20px">로딩 중...</td></tr>';
const catParam = pidCatFilter ? `&category=${encodeURIComponent(pidCatFilter)}` : '';
const sortParam = pidSortBy ? `&sortBy=${pidSortBy}&sortDesc=${pidSortDesc}` : '';
const si = document.getElementById('pid-sort-indicator');
if (si) si.textContent = pidSortBy === 'tagName' ? (pidSortDesc ? '▼' : '▲') : '';
tbody.innerHTML = '<tr><td colspan="11" style="text-align:center;padding:20px">로딩 중...</td></tr>';
try {
const res = await fetch(`/api/pid/equipment?page=${page}&pageSize=${pidPageSize}`);
const res = await fetch(`/api/pid/equipment?page=${page}&pageSize=${pidPageSize}${catParam}${sortParam}`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
pidLastResult = data;
if (!data.items || data.items.length === 0) {
tbody.innerHTML = '<tr><td colspan="10" style="text-align:center;padding:20px">데이터가 없습니다</td></tr>';
tbody.innerHTML = '<tr><td colspan="11" style="text-align:center;padding:20px">데이터가 없습니다</td></tr>';
document.getElementById('pid-pagination').innerHTML = '';
return;
}
tbody.innerHTML = data.items.map(item => `
<tr>
const catOpts = ['','instrument','power_equipment','storage_equipment','process_equipment','utility_equipment','pipings']
.map(v => `<option value="${v}">${v ? v : '-'}</option>`).join('');
// 현재 카테고리 → 가상 키 변환 (instrument + tagDcs 조합)
function pidVcat(item) {
if (item.category === 'instrument') return item.tagDcs ? 'instrument_dcs' : 'instrument_field';
return item.category || '';
}
tbody.innerHTML = data.items.map(item => {
const vcat = pidVcat(item);
const catOpts = CATEGORY_ORDER.map(k =>
`<option value="${k}" ${vcat === k ? 'selected' : ''}>${CATEGORY_META[k]?.label || k}</option>`
).join('');
return `<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><input class="inp pid-edit" data-id="${item.id}" data-field="tagNo" value="${esc((item.tagName||'').toUpperCase())}" style="width:100%;font-family:var(--mono)"/></td>
<td><input class="inp pid-edit" data-id="${item.id}" data-field="equipmentName" value="${esc(item.equipmentName) || ''}" style="width:100%"/></td>
<td><input class="inp pid-edit" data-id="${item.id}" data-field="instrumentType" value="${esc((item.instrumentType||'').toUpperCase())}" style="width:100%"/></td>
<td>
<select class="inp pid-edit-vcat" data-id="${item.id}" style="width:100%">
<option value="">-</option>
${catOpts}
</select>
</td>
<td><input class="inp pid-edit" data-id="${item.id}" data-field="role" value="${esc(item.role) || ''}" style="width:100%"/></td>
<td><input class="inp pid-edit" data-id="${item.id}" data-field="fromTag" value="${esc(item.fromTag) || ''}" style="width:100%"/></td>
<td><input class="inp pid-edit" data-id="${item.id}" data-field="toTag" value="${esc(item.toTag) || ''}" style="width:100%"/></td>
<td>
${item.experionTagId
? `<span class="badge ok">✅ ${esc(item.experionTagName || '')}</span>`
: `<button class="btn-sm btn-b" onclick="pidOpenMapping(${item.id})">매핑</button>`
? `<span class="badge ok">✅ ${esc((item.experionTagName || '').toUpperCase())}</span>`
: `<button class="btn-sm btn-b" onclick="pidOpenMapping(${item.id}, this)">매핑</button>`
}
</td>
<td>${item.category
? `<span class="badge ${pidCategoryBadge(item.category)}">${esc(item.category)}</span>`
: '-'}
<td style="text-align:center">
<button class="btn-sm btn-a" onclick="pidSaveRow(${item.id})">💾</button>
</td>
<td style="text-align:center">
<button class="btn-sm btn-b" onclick="pidDeleteRow(${item.id})" title="삭제">삭제</button>
<button class="btn-sm btn-b" onclick="pidDeleteRow(${item.id})" title="삭제"></button>
</td>
</tr>
`).join('');
</tr>`;
}).join('');
pidRenderPagination(data.total, page);
} catch (e) {
tbody.innerHTML = `<tr><td colspan="10" style="text-align:center;padding:20px;color:var(--red)">오류: ${e.message}</td></tr>`;
tbody.innerHTML = `<tr><td colspan="11" style="text-align:center;padding:20px;color:var(--red)">오류: ${e.message}</td></tr>`;
}
}
@@ -218,8 +255,8 @@ function pidRenderPagination(total, currentPage) {
}
let html = '';
const start = Math.max(1, currentPage - 3);
const end = Math.min(totalPages, currentPage + 3);
const start = Math.max(1, currentPage - 10);
const end = Math.min(totalPages, currentPage + 10);
if (currentPage > 1) {
html += `<button class="btn-sm" onclick="pidLoadTable(${currentPage - 1})"></button>`;
@@ -237,6 +274,89 @@ function pidRenderPagination(total, currentPage) {
pagination.innerHTML = html;
}
function pidShowAddRow() {
const tbody = document.getElementById('pid-table-body');
if (!tbody) return;
// 이미 추가 행이 있으면 제거
const existing = tbody.querySelector('tr.pid-add-row');
if (existing) { existing.remove(); return; }
const row = document.createElement('tr');
row.className = 'pid-add-row';
row.innerHTML = `
<td style="color:var(--t2);font-size:11px">NEW</td>
<td><input class="inp pid-add-input" data-field="tagNo" placeholder="예: FT-9999" style="width:100%;font-family:var(--mono)"/></td>
<td><input class="inp pid-add-input" data-field="equipmentName" placeholder="장비명" style="width:100%"/></td>
<td><input class="inp pid-add-input" data-field="instrumentType" placeholder="유형" style="width:100%"/></td>
<td>
<select class="inp pid-add-vcat" style="width:100%">
<option value="">-</option>
${CATEGORY_ORDER.map(k =>
`<option value="${k}">${CATEGORY_META[k]?.label || k}</option>`
).join('')}
</select>
</td>
<td><input class="inp pid-add-input" data-field="role" placeholder="Role" style="width:100%"/></td>
<td><input class="inp pid-add-input" data-field="fromTag" placeholder="From" style="width:100%"/></td>
<td><input class="inp pid-add-input" data-field="toTag" placeholder="To" style="width:100%"/></td>
<td></td>
<td style="text-align:center"><button class="btn-sm btn-a" onclick="pidCreateRow(this)">💾</button></td>
<td style="text-align:center"><button class="btn-sm btn-b" onclick="this.closest('tr').remove()">✕</button></td>
`;
tbody.insertBefore(row, tbody.firstChild);
row.querySelector('.pid-add-input[data-field="tagNo"]').focus();
}
async function pidCreateRow(btn) {
const row = btn.closest('tr');
const inputs = row.querySelectorAll('.pid-add-input');
const vcatEl = row.querySelector('.pid-add-vcat');
const vcat = vcatEl ? vcatEl.value : '';
const { category, tagDcs } = pidResolveCat(vcat);
const body = { tagNo: '', category: category || null, tagDcs };
for (const inp of inputs) {
body[inp.dataset.field] = inp.value || null;
}
if (!body.tagNo) { alert('태그번호를 입력하세요.'); return; }
try {
const res = await fetch('/api/pid', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
});
if (!res.ok) {
const err = await res.json().catch(() => ({ error: res.statusText }));
throw new Error(err.error || res.statusText);
}
row.remove();
await pidLoadTable(pidCurrentPage);
pidUpdateStats();
} catch (e) {
alert(`추가 실패: ${e.message}`);
}
}
async function pidSaveRow(id) {
const inputs = document.querySelectorAll(`.pid-edit[data-id="${id}"]`);
const vcatEl = document.querySelector(`.pid-edit-vcat[data-id="${id}"]`);
const vcat = vcatEl ? vcatEl.value : '';
const { category, tagDcs } = pidResolveCat(vcat);
const body = { category: category || null, tagDcs };
for (const inp of inputs) {
body[inp.dataset.field] = inp.value || null;
}
try {
const res = await fetch(`/api/pid/${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
} catch (e) {
alert(`저장 실패: ${e.message}`);
}
}
async function pidDeleteRow(id) {
if (!confirm('정말 이 레코드를 삭제하시겠습니까?')) return;
@@ -408,13 +528,9 @@ async function pidRefreshPrefixRules() {
const meta = CATEGORY_META[vcat] || { label: vcat, badge: '', dbCat: vcat, tagDcs: false };
rules?.sort((a, b) => a.sortOrder - b.sortOrder);
// 그룹 헤더: 규칙이 0건이어도 추가 입력행은 항상 표시
const count = rules ? rules.length : 0;
html += `<div class="pid-cat-group" data-vcat="${vcat}">
<div class="pid-cat-header">
<span class="badge ${meta.badge}">${meta.label}</span>
<span class="pid-cat-count">${count}</span>
<span class="pid-cat-add" style="margin-left:auto">
<input class="inp pid-cat-prefix-input" placeholder="예: ${vcat === 'instrument_dcs' ? 'FIC' : vcat === 'instrument_field' ? 'FT' : 'P-'}" style="width:90px;font-family:var(--mono)" />
<input class="inp pid-cat-desc-input" placeholder="설명 (선택)" style="width:160px" />
@@ -426,10 +542,9 @@ async function pidRefreshPrefixRules() {
if (rules) {
for (const r of rules) {
html += `<div class="pid-cat-row">
html += `<div class="pid-cat-row" data-sort-order="${r.sortOrder}">
<input class="inp pid-cat-prefix-input" value="${esc(r.prefix)}" style="width:80px;font-family:var(--mono)" />
<input class="inp pid-cat-desc-input" value="${esc(r.description) || ''}" style="flex:1;min-width:0" />
<input class="inp pid-cat-order-input" type="number" value="${r.sortOrder}" style="width:44px" />
<span class="pid-cat-actions">
<button class="btn-sm btn-a" onclick="pidUpdatePrefixRule(${r.id}, this)">수정</button>
<button class="btn-sm btn-b" onclick="pidDeletePrefixRule(${r.id}, '${esc(r.prefix)}')">삭제</button>
@@ -490,7 +605,7 @@ async function pidUpdatePrefixRule(id, btn) {
const { category } = pidResolveCat(vcat);
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;
const order = parseInt(row.dataset.sortOrder) || 10; // data-sort-order 속성에서 읽기
// tagDcs는 그룹(vcat)이 결정 — 행에 별도 체크박스 없음
// 그룹 이동이 필요하면 삭제 후 반대 그룹에서 추가
const tagDcs = CATEGORY_META[vcat]?.tagDcs ?? false;
@@ -525,7 +640,7 @@ async function pidDeletePrefixRule(id, prefix) {
}
async function pidApplyCategories() {
if (!confirm('기존 미분류 항목에 category를 재적용하시겠습니까?')) return;
if (!confirm('전체 레코드에 현재 PREFIX 분류 정의를 재적용하시겠습니까?\n수동으로 변경한 카테고리도 덮어쓰여집니다.')) return;
try {
const res = await fetch('/api/pid/apply-categories', { method: 'POST' });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
@@ -538,9 +653,22 @@ async function pidApplyCategories() {
}
}
function pidOpenMapping(id) {
// 매핑 모달 열기 (추후 구현)
console.log('pidOpenMapping:', id);
async function pidOpenMapping(id, btn) {
btn.disabled = true;
btn.textContent = '⏳';
try {
const res = await fetch(`/api/pid/${id}/auto-map`, { method: 'POST' });
if (!res.ok) {
btn.textContent = '❌';
btn.title = '매칭 실패';
return;
}
const data = await res.json();
btn.outerHTML = `<span class="badge ok">✅ ${esc((data.tagName||'').toUpperCase())}</span>`;
} catch {
btn.textContent = '❌';
btn.title = '오류';
}
}
// 탭 진입 시 초기화

View File

@@ -54,7 +54,7 @@
</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>
<button class="btn-sm btn-b" onclick="pidApplyCategories()" title="전체 레코드에 현재 PREFIX 분류 정의 재적용 (수동 변경도 덮어쓰기)">재적용</button>
</div>
</div>
</div>
@@ -64,25 +64,42 @@
<div class="card-cap" style="display:flex;justify-content:space-between;align-items:center">
<span>추출 결과</span>
<div style="display:flex;gap:6px;flex-wrap:wrap">
<button class="btn-b btn-sm" onclick="pidShowAddRow()">+ 추가</button>
<button id="btn-pid-export-csv" class="btn-b btn-sm">CSV</button>
<button id="btn-pid-export-excel" class="btn-a btn-sm">Excel</button>
</div>
</div>
<div style="display:flex;gap:8px;align-items:center;padding:6px 0;flex-wrap:wrap;border-bottom:1px solid var(--b1)">
<label style="font-size:12px;color:var(--t2)">카테고리:</label>
<select id="pid-cat-filter" class="inp" style="width:auto;min-width:140px" onchange="pidOnCatFilter()">
<option value="">전체</option>
<option value="instrument_dcs">DCS 태그</option>
<option value="instrument_field">현장 계기</option>
<option value="power_equipment">Power Equipment</option>
<option value="storage_equipment">Storage Equipment</option>
<option value="process_equipment">Process Equipment</option>
<option value="utility_equipment">Utility Equipment</option>
<option value="pipings">Pipings</option>
<option value="__unmatched__">미분류</option>
</select>
</div>
<div id="pid-table-container" style="overflow-x:auto">
<table class="table" id="pid-table">
<thead>
<tr>
<th style="width:60px">ID</th>
<th>태그번호</th>
<th style="cursor:pointer;user-select:none" onclick="pidToggleSort('tagName')">
태그번호 <span id="pid-sort-indicator" style="font-size:11px;color:var(--t2)"></span>
</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>
<th style="width:60px">삭제</th>
<th>카테고리</th>
<th>Role</th>
<th>From</th>
<th>To</th>
<th style="width:80px">매핑</th>
<th style="width:60px">저장</th>
<th style="width:40px">삭제</th>
</tr>
</thead>
<tbody id="pid-table-body"></tbody>