서식 목록을 분류(ShtClsCod)로 묶기 — 평면 1,629건 → 접이식 24분류

사용자 지적: "서식 목록 구분없이 하나로 다 나오는거 불편한데 묶여서 나와야해".

[기준 선정] 후보 컬럼 7종을 실DB로 전수 집계해(신규 --db-cls) ShtClsCod 를 택했다. 이것만이
판정 3기준을 통과한다 — 그룹 24종, 최대 그룹 269건(16%), 빈값 1건(0.06%). 나머지는 전부 한쪽
쏠림이다: ShtTyp D 79% / ShtPatTyp C 92% / ShtLstTyp NONREC 88% / ShtDepCls 빈값 74%.
의미상으로도 ShtClsCod 만 '분류'이고 나머지는 동작 규칙이다(ShtWrtTyp=1장 제약, ShtUsrTyp·
ShtPatTyp=작성 게이트, ShtOprTyp=.NET 클래스명, ShtDspSeq=정렬키).

무엇보다 이건 사용자가 이미 아는 축이다. 레거시에서 서식생성기를 여는 화면(기록지정보 fmShtMst)
상단이 `[ 분류별 전체 ]` + 분류별 탭이고, EMR 기록지 트리 1단 헤더도 같은 값이다. 재교육 비용 0.

[라벨] 코드→한글명은 M_DtlMst 의 DtlTblCod='EMR_SHEET_GROUP' 이 소유한다. 소스 어디에도 매핑표가
없고 병원별 마스터라(HspCod 분기가 레거시에 다수) 하드코딩하면 다른 병원에서 즉시 오답이 된다.
런타임 조인만 쓴다. 실측: 마스터 24행 ↔ 서식 24코드 완전 일치, 고아 코드 0건.

[조인 방향] E_ShtMst 드라이빙 + LEFT JOIN 이다. 레거시 EMR 트리(bizSheetList.vb)는 M_DtlMst 를
드라이빙으로 써서 분류 미지정 서식이 목록에서 통째로 사라지는데, 열기 목록에서 그건 "그 서식을
영영 못 여는" 장애다. 기록지정보 화면 방식(dtShtMst.vb:75-81)을 따랐다.

[조인 증식 방어] M_DtlMst 는 PK 에 DtlStrDte 가 들어가 같은 코드가 기간별 다중행일 수 있다.
그대로 조인하면 서식이 목록에 두 번 뜬다. 실측상 현재 증식 0건이지만 그건 데이터 운이지 쿼리가
막은 게 아니므로, ROW_NUMBER 로 적용일 최신 1행만 택하게 했다.

[그룹 순서] LPAD(DtlDspSeq,4,'0') 우선, 그다음 코드순, 미분류는 맨 뒤. 병원이 정한 표시순서가
1순위여야 레거시 분류 탭과 같은 자리에 뜬다. 이 병원은 DspSeq 가 전부 비어 있어 결과적으로
코드순(A~X)이지만, 채워 쓰는 병원에서 어긋나지 않는다. LPAD 는 레거시의 2자리 대신 4자리 —
'100' 과 '99' 가 뒤집히는 것을 막는다.

[상한 300 → 2,000] 종전 300 상한은 정렬이 분류순으로 바뀌면서 성격이 달라진다. 코드순 300 은
고르게 잘렸지만 분류순 300 은 뒤쪽 분류가 통째로 사라져 "그 분류가 없는 것"처럼 보인다.
모집단 1,629건이라 이제 전량이 온다. 도달 시 하단에 잘렸다고 명시한다(조용한 절단 금지).

[분류명도 검색 대상] 헤더에서 본 '간호기록'을 그대로 쳤는데 0건이면 배신감이 크다.
LIKE 토큰에 DtlCodNam 을 추가했다.

[접기 상태] 기본 접힘 + 검색 시 자동 펼침. 다만 레거시는 서식이 꽉 찬 평면 목록으로 시작하므로
접힌 첫 화면을 낯설어하는 사용자가 있다 — 전체 여닫기 토글을 검색칩 옆에 두고, 그 선택을
%LocalAppData%\SheetMe\prefs.json 에 보존한다(신규 UserPrefs). 매 실행마다 다시 펴지 않아도 된다.

[WPF 함정] 그룹핑을 걸면 WPF 는 기본적으로 가상화를 끈다 — 1,600여 항목이 한꺼번에 실체화돼
탭 전환이 눈에 띄게 멈춘다. IsVirtualizingWhenGrouping + VirtualizationMode=Recycling 필수.
그룹 키를 문자열이 아니라 SheetGroupViewModel 인스턴스로 둔 것도 의도다 — CollectionViewSource 가
값 동등성으로 묶으므로 코드당 같은 인스턴스를 주면(SheetGroupRegistry) 헤더에서
Name.IsExpanded 를 TwoWay 로 바인딩해 접기 상태를 VM 이 소유할 수 있다.

[열기 대화상자] 같은 ListSheets 를 쓰는 SheetOpenDialogView 는 그리드라 트리 대신 '분류' 열을
추가했다(같은 정보, 그 화면에 맞는 형태).

[진단 모드 신설] --db-cls (분류 후보 분포 + 라벨 마스터 + 조인 증식 검사 + 고아 코드 + 모집단),
--db-tables <패턴> (이름 모르는 마스터 테이블 탐색). 둘 다 읽기 전용.

검증: 테스트 124/124, edit-smoke 실패 0, 왕복 1,271건 diff 0/예외 0,
**종이 렌더 P062 픽셀 대조 차이 0**.
실 UI: 24분류 한 화면 · 의사기록 펼침 266건 · '경과기록' 45건/6분류 자동 펼침 ·
'간호기록' 204건/4분류(분류명 매칭 동작 확인).

별건 보고: 목록 모집단에 폐기 서식(ShtUseYon='N')이 452건(28%) 섞여 그룹 건수를 부풀린다.
레거시 서식생성기도 이 필터를 걸지 않으므로 이번엔 동작을 바꾸지 않았다.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Msystech
2026-08-12 11:14:17 +09:00
co-authored by Claude Opus 5
parent 9d20cc2971
commit ca16630f3a
10 changed files with 638 additions and 16 deletions
@@ -23,6 +23,11 @@ internal sealed class MainViewModel : ViewModelBase
private string statusText = "준비";
private string sheetSearchKeyword = string.Empty;
private bool isSheetListLoading;
private int sheetGroupCount;
private bool sheetListTruncated;
private string appliedSheetKeyword = string.Empty;
private const string SheetGroupsExpandedPref = "sheetGroupsExpanded";
private bool areSheetGroupsExpanded = UserPrefs.GetBool(SheetGroupsExpandedPref, false);
private int selectedLeftTabIndex;
private bool isHandTool;
private const string XmlFilter = "서식 XML (*.xml)|*.xml|모든 파일 (*.*)|*.*";
@@ -104,10 +109,59 @@ internal sealed class MainViewModel : ViewModelBase
{
return "불러오는 중…";
}
return SheetList.Count == 0 ? "결과 없음" : $"{SheetList.Count}건";
if (SheetList.Count == 0)
{
return "결과 없음";
}
var text = sheetGroupCount > 0 ? $"{SheetList.Count}건 · 분류 {sheetGroupCount}종" : $"{SheetList.Count}건";
if (sheetListTruncated)
{
text += $" · 상한 {FormDesignDataBusiness.SheetListLimit}건에 도달해 뒤쪽 분류가 잘렸습니다 — 검색어로 좁히세요";
}
// 입력 중인 검색어가 아니라 '적용된' 검색어를 본다 — 타이핑 중 문구가 깜빡이면 안 된다
if (appliedSheetKeyword.Length > 0)
{
text += " (검색 결과 기준)";
}
return text;
}
}
/// <summary>현재 목록에 나타난 분류 수 — 목록 하단 표시용</summary>
public int SheetGroupCount
{
get => sheetGroupCount;
set
{
if (SetProperty(ref sheetGroupCount, value))
{
OnPropertyChanged(nameof(SheetListStatusText));
}
}
}
/// <summary>
/// 서식 목록 분류 그룹 전체 펼치기/접기 — 목록 헤더 버튼.
/// 선택을 보존한다: 레거시는 서식이 꽉 찬 평면 목록으로 시작하므로 접힌 첫 화면을 낯설어하는
/// 사용자가 있다. 한 번 펴 두면 다음 실행에도 그대로 열려 있어야 매번 다시 펴지 않는다.
/// </summary>
public bool AreSheetGroupsExpanded
{
get => areSheetGroupsExpanded;
set
{
if (SetProperty(ref areSheetGroupsExpanded, value))
{
SheetGroupRegistry.SetAllExpanded(value);
UserPrefs.SetBool(SheetGroupsExpandedPref, value);
OnPropertyChanged(nameof(SheetGroupToggleIcon));
}
}
}
/// <summary>전체 펼치기/접기 버튼 아이콘</summary>
public string SheetGroupToggleIcon => areSheetGroupsExpanded ? "chevrons-down-up" : "chevrons-up-down";
/// <summary>열린 문서 존재 여부 — 빈 상태 오버레이 표시 판정</summary>
public bool HasOpenDocuments => OpenDesigners.Count > 0;
@@ -664,12 +718,28 @@ internal sealed class MainViewModel : ViewModelBase
IsSheetListLoading = true;
var keyword = SheetSearchKeyword;
var sheets = await Task.Run(() => dataBusiness.ListSheets(keyword));
// 그룹 라벨은 목록에 담기 전에 등록해야 한다 — 나중에 바꾸면 이미 만들어진 헤더가 코드로 남는다
SheetGroupRegistry.Register(sheets);
SheetList.Clear();
foreach (var sheet in sheets)
{
SheetList.Add(sheet);
}
StatusText = $"서식 목록 {sheets.Count}건";
// 검색 중이면 결과가 접힌 헤더 뒤에 숨지 않도록 전부 펼친다. 검색어를 지우면 사용자가
// 보존해 둔 취향으로 되돌아간다. 새로 생긴 그룹은 기본 접힘이라 값이 같아도 매번 밀어 넣어야
// 토글 버튼 표시와 어긋나지 않는다.
var expand = keyword.Trim().Length > 0 || UserPrefs.GetBool(SheetGroupsExpandedPref, false);
SheetGroupRegistry.SetAllExpanded(expand);
areSheetGroupsExpanded = expand;
OnPropertyChanged(nameof(AreSheetGroupsExpanded));
OnPropertyChanged(nameof(SheetGroupToggleIcon));
// 상한에 닿았다는 건 뒤쪽 분류가 통째로 잘렸다는 뜻이다 — 조용히 넘기면 "그 분류가 없다"로 읽힌다
sheetListTruncated = sheets.Count >= FormDesignDataBusiness.SheetListLimit;
appliedSheetKeyword = keyword.Trim();
SheetGroupCount = sheets.Select(s => s.ClassCode).Distinct(StringComparer.Ordinal).Count();
StatusText = $"서식 목록 {sheets.Count}건 · 분류 {SheetGroupCount}종";
}
catch (Exception ex)
{