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:
@@ -17,3 +17,25 @@ public record PidEquipmentDto(
|
|||||||
string? Role,
|
string? Role,
|
||||||
string? FromTag,
|
string? FromTag,
|
||||||
string? ToTag);
|
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);
|
||||||
|
|||||||
@@ -318,7 +318,8 @@ public interface IPidExtractorService
|
|||||||
|
|
||||||
// 조회 (페이지네이션)
|
// 조회 (페이지네이션)
|
||||||
Task<(int Total, IEnumerable<PidEquipment> Items)> GetEquipmentAsync(
|
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);
|
Task<PidEquipment?> GetByIdAsync(long id);
|
||||||
|
|
||||||
@@ -326,6 +327,9 @@ public interface IPidExtractorService
|
|||||||
Task UpdateConfidenceAsync(long id, double confidence);
|
Task UpdateConfidenceAsync(long id, double confidence);
|
||||||
Task ActivateAsync(long id);
|
Task ActivateAsync(long id);
|
||||||
Task DeactivateAsync(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);
|
Task<bool> DeleteAsync(long id);
|
||||||
|
|
||||||
// 통계
|
// 통계
|
||||||
|
|||||||
@@ -378,24 +378,60 @@ public class PidExtractorService : IPidExtractorService
|
|||||||
|
|
||||||
private async Task<RealtimePoint?> FindFallbackTagAsync(string tagNo)
|
private async Task<RealtimePoint?> FindFallbackTagAsync(string tagNo)
|
||||||
{
|
{
|
||||||
var normalized = tagNo.Split('.')[0];
|
var normalized = tagNo.Split('.')[0].ToLowerInvariant();
|
||||||
return await _dbContext.RealtimePoints
|
return await _dbContext.RealtimePoints
|
||||||
.FirstOrDefaultAsync(t => t.TagName == normalized
|
.FirstOrDefaultAsync(t => t.TagName.ToLower() == normalized
|
||||||
|| t.TagName.StartsWith(normalized + "."));
|
|| t.TagName.ToLower().StartsWith(normalized + "."));
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<(int Total, IEnumerable<PidEquipment> Items)> GetEquipmentAsync(
|
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();
|
var q = _dbContext.PidEquipment.AsQueryable();
|
||||||
if (!string.IsNullOrEmpty(tagNo))
|
if (!string.IsNullOrEmpty(tagNo))
|
||||||
q = q.Where(e => e.TagNo.Contains(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 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);
|
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)
|
public async Task<PidEquipment?> GetByIdAsync(long id)
|
||||||
=> await _dbContext.PidEquipment.Include(e => e.ExperionTag).FirstOrDefaultAsync(e => e.Id == id);
|
=> await _dbContext.PidEquipment.Include(e => e.ExperionTag).FirstOrDefaultAsync(e => e.Id == id);
|
||||||
|
|
||||||
@@ -407,6 +443,54 @@ public class PidExtractorService : IPidExtractorService
|
|||||||
await _dbContext.SaveChangesAsync();
|
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)
|
public async Task ActivateAsync(long id)
|
||||||
{
|
{
|
||||||
var e = await _dbContext.PidEquipment.FindAsync(id);
|
var e = await _dbContext.PidEquipment.FindAsync(id);
|
||||||
@@ -432,6 +516,18 @@ public class PidExtractorService : IPidExtractorService
|
|||||||
return true;
|
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()
|
public async Task<int> DeleteAllEquipmentAsync()
|
||||||
{
|
{
|
||||||
var count = await _dbContext.PidEquipment.CountAsync();
|
var count = await _dbContext.PidEquipment.CountAsync();
|
||||||
@@ -485,12 +581,19 @@ public class PidExtractorService : IPidExtractorService
|
|||||||
var rules = await GetRulesCachedAsync();
|
var rules = await GetRulesCachedAsync();
|
||||||
|
|
||||||
var grouped = items
|
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());
|
.ToDictionary(g => g.Key, g => g.ToList());
|
||||||
|
|
||||||
var sheetOrder = new[]
|
var sheetOrder = new[]
|
||||||
{
|
{
|
||||||
PidEquipment.CategoryInstrument,
|
"instrument_dcs",
|
||||||
|
"instrument_field",
|
||||||
PidEquipment.CategoryPowerEquipment,
|
PidEquipment.CategoryPowerEquipment,
|
||||||
PidEquipment.CategoryStorageEquipment,
|
PidEquipment.CategoryStorageEquipment,
|
||||||
PidEquipment.CategoryProcessEquipment,
|
PidEquipment.CategoryProcessEquipment,
|
||||||
@@ -501,7 +604,8 @@ public class PidExtractorService : IPidExtractorService
|
|||||||
|
|
||||||
var sheetNames = new Dictionary<string, string>
|
var sheetNames = new Dictionary<string, string>
|
||||||
{
|
{
|
||||||
[PidEquipment.CategoryInstrument] = "Instrument",
|
["instrument_dcs"] = "DCS 태그",
|
||||||
|
["instrument_field"] = "현장 계기",
|
||||||
[PidEquipment.CategoryPowerEquipment] = "Power Equipment",
|
[PidEquipment.CategoryPowerEquipment] = "Power Equipment",
|
||||||
[PidEquipment.CategoryStorageEquipment] = "Storage Equipment",
|
[PidEquipment.CategoryStorageEquipment] = "Storage Equipment",
|
||||||
[PidEquipment.CategoryProcessEquipment] = "Process Equipment",
|
[PidEquipment.CategoryProcessEquipment] = "Process Equipment",
|
||||||
@@ -891,10 +995,12 @@ public class PidExtractorService : IPidExtractorService
|
|||||||
{
|
{
|
||||||
const int batchSize = 1000;
|
const int batchSize = 1000;
|
||||||
int total = 0;
|
int total = 0;
|
||||||
|
long lastId = 0;
|
||||||
while (true)
|
while (true)
|
||||||
{
|
{
|
||||||
var batch = await _dbContext.PidEquipment
|
var batch = await _dbContext.PidEquipment
|
||||||
.Where(e => e.Category == null)
|
.Where(e => e.Id > lastId)
|
||||||
|
.OrderBy(e => e.Id)
|
||||||
.Take(batchSize)
|
.Take(batchSize)
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
if (!batch.Any()) break;
|
if (!batch.Any()) break;
|
||||||
@@ -902,36 +1008,17 @@ public class PidExtractorService : IPidExtractorService
|
|||||||
foreach (var item in batch)
|
foreach (var item in batch)
|
||||||
{
|
{
|
||||||
var category = await MatchCategoryAsync(item.TagNo);
|
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.Category = category;
|
||||||
item.TagDcs = tagDcs;
|
item.TagDcs = tagDcs;
|
||||||
item.TagClass = ClassifyTagClass(category, tagDcs);
|
item.TagClass = tagClass;
|
||||||
item.UpdatedAt = DateTime.UtcNow;
|
item.UpdatedAt = DateTime.UtcNow;
|
||||||
total++;
|
total++;
|
||||||
}
|
}
|
||||||
}
|
lastId = item.Id;
|
||||||
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++;
|
|
||||||
}
|
}
|
||||||
await _dbContext.SaveChangesAsync();
|
await _dbContext.SaveChangesAsync();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -131,7 +131,7 @@ public class ExperionDbContext : DbContext
|
|||||||
.HasDefaultValueSql("NOW()");
|
.HasDefaultValueSql("NOW()");
|
||||||
|
|
||||||
entity.Property(e => e.UpdatedAt)
|
entity.Property(e => e.UpdatedAt)
|
||||||
.ValueGeneratedOnAddOrUpdate()
|
.ValueGeneratedOnAdd()
|
||||||
.HasDefaultValueSql("NOW()");
|
.HasDefaultValueSql("NOW()");
|
||||||
|
|
||||||
entity.HasIndex(e => e.TagNo);
|
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');
|
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("""
|
await _ctx.Database.ExecuteSqlRawAsync("""
|
||||||
ALTER TABLE pid_equipment
|
DO $$
|
||||||
ADD COLUMN IF NOT EXISTS tag_dcs BOOLEAN NOT NULL DEFAULT FALSE;
|
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형 자동 포함)
|
UPDATE pid_equipment pe
|
||||||
await _ctx.Database.ExecuteSqlRawAsync("""
|
SET tag_dcs = TRUE
|
||||||
UPDATE pid_equipment pe
|
FROM pid_prefix_rules pr
|
||||||
SET tag_dcs = TRUE
|
WHERE pe.instrument_type LIKE (pr.prefix || '%')
|
||||||
FROM pid_prefix_rules pr
|
AND pr.tag_dcs = TRUE;
|
||||||
WHERE pe.instrument_type LIKE (pr.prefix || '%')
|
END IF;
|
||||||
AND pr.tag_dcs = TRUE;
|
END $$;
|
||||||
""");
|
""");
|
||||||
// ─────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|||||||
@@ -107,9 +107,9 @@ public class PidController : ControllerBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("equipment")]
|
[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 itemList = items.ToList();
|
||||||
|
|
||||||
var equipmentDtos = itemList.Select(e => new
|
var equipmentDtos = itemList.Select(e => new
|
||||||
@@ -127,6 +127,8 @@ public class PidController : ControllerBase
|
|||||||
experionTagId = e.ExperionTagId,
|
experionTagId = e.ExperionTagId,
|
||||||
experionTagName = e.ExperionTag?.TagName,
|
experionTagName = e.ExperionTag?.TagName,
|
||||||
category = e.Category,
|
category = e.Category,
|
||||||
|
tagClass = e.TagClass,
|
||||||
|
tagDcs = e.TagDcs,
|
||||||
role = e.Role,
|
role = e.Role,
|
||||||
fromTag = e.FromTag,
|
fromTag = e.FromTag,
|
||||||
toTag = e.ToTag,
|
toTag = e.ToTag,
|
||||||
@@ -144,7 +146,42 @@ public class PidController : ControllerBase
|
|||||||
items = equipmentDtos
|
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")]
|
[HttpGet("statistics")]
|
||||||
public async Task<IActionResult> GetStatistics()
|
public async Task<IActionResult> GetStatistics()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ var mvcBuilder = builder.Services.AddControllers()
|
|||||||
{
|
{
|
||||||
// JSON 직렬화 시 대소문자 구분 없이 처리하도록 PascalCase 유지
|
// JSON 직렬화 시 대소문자 구분 없이 처리하도록 PascalCase 유지
|
||||||
opt.JsonSerializerOptions.PropertyNamingPolicy = null;
|
opt.JsonSerializerOptions.PropertyNamingPolicy = null;
|
||||||
|
// Deserialize 시 camelCase 키를 C# PascalCase 속성에 매핑 (프론트엔드 호환)
|
||||||
|
opt.JsonSerializerOptions.PropertyNameCaseInsensitive = true;
|
||||||
});
|
});
|
||||||
builder.Services.AddMemoryCache();
|
builder.Services.AddMemoryCache();
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
"Default": "Information",
|
"Default": "Information",
|
||||||
"Microsoft.AspNetCore": "Warning",
|
"Microsoft.AspNetCore": "Warning",
|
||||||
"Microsoft.EntityFrameworkCore": "Warning",
|
"Microsoft.EntityFrameworkCore": "Warning",
|
||||||
"Microsoft.EntityFrameworkCore.Database.Command": "Warning",
|
"Microsoft.EntityFrameworkCore.Database.Command": "Information",
|
||||||
"ExperionCrawler.Infrastructure.OpcUa.ExperionHistoryService": "Warning"
|
"ExperionCrawler.Infrastructure.OpcUa.ExperionHistoryService": "Warning"
|
||||||
},
|
},
|
||||||
"Console": {
|
"Console": {
|
||||||
|
|||||||
@@ -88,10 +88,6 @@
|
|||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
}
|
}
|
||||||
|
|
||||||
#pane-pid .pid-cat-count {
|
|
||||||
font-size: 11px;
|
|
||||||
color: var(--t2);
|
|
||||||
}
|
|
||||||
|
|
||||||
#pane-pid .pid-cat-add {
|
#pane-pid .pid-cat-add {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -4,6 +4,9 @@
|
|||||||
let pidCurrentPage = 1;
|
let pidCurrentPage = 1;
|
||||||
let pidPageSize = 20;
|
let pidPageSize = 20;
|
||||||
let pidLastResult = null; // Excel export용
|
let pidLastResult = null; // Excel export용
|
||||||
|
let pidCatFilter = ''; // 카테고리 필터
|
||||||
|
let pidSortBy = ''; // 정렬 필드
|
||||||
|
let pidSortDesc = false; // 내림차순
|
||||||
|
|
||||||
async function pidUpload() {
|
async function pidUpload() {
|
||||||
const fileInput = document.getElementById('pid-file-input');
|
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) {
|
async function pidLoadTable(page = 1) {
|
||||||
pidCurrentPage = page;
|
pidCurrentPage = page;
|
||||||
const container = document.getElementById('pid-table-container');
|
const container = document.getElementById('pid-table-container');
|
||||||
@@ -156,54 +176,71 @@ async function pidLoadTable(page = 1) {
|
|||||||
|
|
||||||
if (!container || !tbody) return;
|
if (!container || !tbody) return;
|
||||||
|
|
||||||
// tbody만 비우고 로딩 상태 표시 — container.innerHTML을 덮어쓰면 tbody가 DOM에서 떨어져 나가
|
const catParam = pidCatFilter ? `&category=${encodeURIComponent(pidCatFilter)}` : '';
|
||||||
// 이후 tbody.innerHTML 할당이 화면에 반영되지 않음.
|
const sortParam = pidSortBy ? `&sortBy=${pidSortBy}&sortDesc=${pidSortDesc}` : '';
|
||||||
tbody.innerHTML = '<tr><td colspan="10" style="text-align:center;padding:20px">로딩 중...</td></tr>';
|
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 {
|
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}`);
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||||
|
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
pidLastResult = data;
|
pidLastResult = data;
|
||||||
|
|
||||||
if (!data.items || data.items.length === 0) {
|
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 = '';
|
document.getElementById('pid-pagination').innerHTML = '';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
tbody.innerHTML = data.items.map(item => `
|
const catOpts = ['','instrument','power_equipment','storage_equipment','process_equipment','utility_equipment','pipings']
|
||||||
<tr>
|
.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>${item.id}</td>
|
||||||
<td><strong>${esc(item.tagName)}</strong></td>
|
<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>${esc(item.equipmentName) || '-'}</td>
|
<td><input class="inp pid-edit" data-id="${item.id}" data-field="equipmentName" value="${esc(item.equipmentName) || ''}" style="width:100%"/></td>
|
||||||
<td>${esc(item.instrumentType) || '-'}</td>
|
<td><input class="inp pid-edit" data-id="${item.id}" data-field="instrumentType" value="${esc((item.instrumentType||'').toUpperCase())}" style="width:100%"/></td>
|
||||||
<td>${esc(item.lineNumber) || '-'}</td>
|
<td>
|
||||||
<td>${esc(item.pidDrawingNo) || '-'}</td>
|
<select class="inp pid-edit-vcat" data-id="${item.id}" style="width:100%">
|
||||||
<td style="text-align:center">${(item.confidence * 100).toFixed(1)}%</td>
|
<option value="">-</option>
|
||||||
<td style="text-align:center">
|
${catOpts}
|
||||||
<span class="badge ${item.isActive ? 'ok' : 'warn'}">${item.isActive ? '활성' : '비활성'}</span>
|
</select>
|
||||||
</td>
|
</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>
|
<td>
|
||||||
${item.experionTagId
|
${item.experionTagId
|
||||||
? `<span class="badge ok">✅ ${esc(item.experionTagName || '')}</span>`
|
? `<span class="badge ok">✅ ${esc((item.experionTagName || '').toUpperCase())}</span>`
|
||||||
: `<button class="btn-sm btn-b" onclick="pidOpenMapping(${item.id})">매핑</button>`
|
: `<button class="btn-sm btn-b" onclick="pidOpenMapping(${item.id}, this)">매핑</button>`
|
||||||
}
|
}
|
||||||
</td>
|
</td>
|
||||||
<td>${item.category
|
<td style="text-align:center">
|
||||||
? `<span class="badge ${pidCategoryBadge(item.category)}">${esc(item.category)}</span>`
|
<button class="btn-sm btn-a" onclick="pidSaveRow(${item.id})">💾</button>
|
||||||
: '-'}
|
|
||||||
</td>
|
</td>
|
||||||
<td style="text-align:center">
|
<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>
|
</td>
|
||||||
</tr>
|
</tr>`;
|
||||||
`).join('');
|
}).join('');
|
||||||
|
|
||||||
pidRenderPagination(data.total, page);
|
pidRenderPagination(data.total, page);
|
||||||
} catch (e) {
|
} 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 = '';
|
let html = '';
|
||||||
const start = Math.max(1, currentPage - 3);
|
const start = Math.max(1, currentPage - 10);
|
||||||
const end = Math.min(totalPages, currentPage + 3);
|
const end = Math.min(totalPages, currentPage + 10);
|
||||||
|
|
||||||
if (currentPage > 1) {
|
if (currentPage > 1) {
|
||||||
html += `<button class="btn-sm" onclick="pidLoadTable(${currentPage - 1})">‹</button>`;
|
html += `<button class="btn-sm" onclick="pidLoadTable(${currentPage - 1})">‹</button>`;
|
||||||
@@ -237,6 +274,89 @@ function pidRenderPagination(total, currentPage) {
|
|||||||
pagination.innerHTML = html;
|
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) {
|
async function pidDeleteRow(id) {
|
||||||
if (!confirm('정말 이 레코드를 삭제하시겠습니까?')) return;
|
if (!confirm('정말 이 레코드를 삭제하시겠습니까?')) return;
|
||||||
|
|
||||||
@@ -408,13 +528,9 @@ async function pidRefreshPrefixRules() {
|
|||||||
const meta = CATEGORY_META[vcat] || { label: vcat, badge: '', dbCat: vcat, tagDcs: false };
|
const meta = CATEGORY_META[vcat] || { label: vcat, badge: '', dbCat: vcat, tagDcs: false };
|
||||||
rules?.sort((a, b) => a.sortOrder - b.sortOrder);
|
rules?.sort((a, b) => a.sortOrder - b.sortOrder);
|
||||||
|
|
||||||
// 그룹 헤더: 규칙이 0건이어도 추가 입력행은 항상 표시
|
|
||||||
const count = rules ? rules.length : 0;
|
|
||||||
|
|
||||||
html += `<div class="pid-cat-group" data-vcat="${vcat}">
|
html += `<div class="pid-cat-group" data-vcat="${vcat}">
|
||||||
<div class="pid-cat-header">
|
<div class="pid-cat-header">
|
||||||
<span class="badge ${meta.badge}">${meta.label}</span>
|
<span class="badge ${meta.badge}">${meta.label}</span>
|
||||||
<span class="pid-cat-count">${count}</span>
|
|
||||||
<span class="pid-cat-add" style="margin-left:auto">
|
<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-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" />
|
<input class="inp pid-cat-desc-input" placeholder="설명 (선택)" style="width:160px" />
|
||||||
@@ -426,10 +542,9 @@ async function pidRefreshPrefixRules() {
|
|||||||
|
|
||||||
if (rules) {
|
if (rules) {
|
||||||
for (const r of 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-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-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">
|
<span class="pid-cat-actions">
|
||||||
<button class="btn-sm btn-a" onclick="pidUpdatePrefixRule(${r.id}, this)">수정</button>
|
<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>
|
<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 { category } = pidResolveCat(vcat);
|
||||||
const prefix = row.querySelector('.pid-cat-prefix-input').value.trim();
|
const prefix = row.querySelector('.pid-cat-prefix-input').value.trim();
|
||||||
const desc = row.querySelector('.pid-cat-desc-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)이 결정 — 행에 별도 체크박스 없음
|
// tagDcs는 그룹(vcat)이 결정 — 행에 별도 체크박스 없음
|
||||||
// 그룹 이동이 필요하면 삭제 후 반대 그룹에서 추가
|
// 그룹 이동이 필요하면 삭제 후 반대 그룹에서 추가
|
||||||
const tagDcs = CATEGORY_META[vcat]?.tagDcs ?? false;
|
const tagDcs = CATEGORY_META[vcat]?.tagDcs ?? false;
|
||||||
@@ -525,7 +640,7 @@ async function pidDeletePrefixRule(id, prefix) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function pidApplyCategories() {
|
async function pidApplyCategories() {
|
||||||
if (!confirm('기존 미분류 항목에 category를 재적용하시겠습니까?')) return;
|
if (!confirm('전체 레코드에 현재 PREFIX 분류 정의를 재적용하시겠습니까?\n수동으로 변경한 카테고리도 덮어쓰여집니다.')) return;
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/pid/apply-categories', { method: 'POST' });
|
const res = await fetch('/api/pid/apply-categories', { method: 'POST' });
|
||||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||||
@@ -538,9 +653,22 @@ async function pidApplyCategories() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function pidOpenMapping(id) {
|
async function pidOpenMapping(id, btn) {
|
||||||
// 매핑 모달 열기 (추후 구현)
|
btn.disabled = true;
|
||||||
console.log('pidOpenMapping:', id);
|
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 = '오류';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 탭 진입 시 초기화
|
// 탭 진입 시 초기화
|
||||||
|
|||||||
@@ -54,7 +54,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div style="margin-top:8px;display:flex;gap:4px;flex-wrap:wrap;align-items:center">
|
<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="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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -64,25 +64,42 @@
|
|||||||
<div class="card-cap" style="display:flex;justify-content:space-between;align-items:center">
|
<div class="card-cap" style="display:flex;justify-content:space-between;align-items:center">
|
||||||
<span>추출 결과</span>
|
<span>추출 결과</span>
|
||||||
<div style="display:flex;gap:6px;flex-wrap:wrap">
|
<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-csv" class="btn-b btn-sm">CSV</button>
|
||||||
<button id="btn-pid-export-excel" class="btn-a btn-sm">Excel</button>
|
<button id="btn-pid-export-excel" class="btn-a btn-sm">Excel</button>
|
||||||
</div>
|
</div>
|
||||||
</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">
|
<div id="pid-table-container" style="overflow-x:auto">
|
||||||
<table class="table" id="pid-table">
|
<table class="table" id="pid-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th style="width:60px">ID</th>
|
<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>라인번호</th>
|
<th>카테고리</th>
|
||||||
<th>도면번호</th>
|
<th>Role</th>
|
||||||
<th style="width:80px">신뢰도</th>
|
<th>From</th>
|
||||||
<th style="width:80px">상태</th>
|
<th>To</th>
|
||||||
<th style="width:120px">매핑</th>
|
<th style="width:80px">매핑</th>
|
||||||
<th style="width:120px">카테고리</th>
|
<th style="width:60px">저장</th>
|
||||||
<th style="width:60px">삭제</th>
|
<th style="width:40px">삭제</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody id="pid-table-body"></tbody>
|
<tbody id="pid-table-body"></tbody>
|
||||||
|
|||||||
Reference in New Issue
Block a user