ETC_요양기관명칭_* 계열(bzDataInterface.vb:15656-16709). 12종 전부 <b>같은 조인(M_DepMst⨝M_HspMst)의 다른 컬럼</b>이라는 것을 본문에서 확인했다 — 병원명(이미 이식)·병원명_영문·병원대표자·병원장·병원장귀하·전화번호·팩스번호· 전화_팩스·영문_전화_팩스·주소·주소_영문·사업자등록번호·요양기관기호. 레거시는 태그마다 이 조인을 따로 돌린다(12종 = 왕복 12회). 컬럼 9개를 한 번에 뽑아 행으로 들고, 값 선택은 해석기가 한다. HospitalName(문자열) → HospitalRow(행) 로 일반화했다. 접미·결합도 본문 그대로다: 병원장 = 병원명+"장" / 병원장귀하 = +"장 귀하" 전화_팩스 = "Tel : … FAX : …" / 영문판 = "Tel : +82 - … FAX : +82 - …" 접두·접미가 붙는 태그는 <b>재료 컬럼</b>으로 빈 값을 판정한다 — 병원명이 비면 "장"만 찍히는 것을 값으로 세지 않는다. 실DB 확인: --db-patient ㉔ 가 9열·병원명 6자를 돌려줬다. ## 게이트 - dotnet test 338/338 · --edit-smoke 실패 0(이름 검사 75종) - --db-patient 전건 통과 · --db-render P062 md5 불변 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
270 lines
14 KiB
C#
270 lines
14 KiB
C#
using Oracle.ManagedDataAccess.Client;
|
|
|
|
namespace SheetMe.Data.Stores;
|
|
|
|
/// <summary>
|
|
/// 한 내원의 환자 문맥 — 레거시 bzPatientInfo 의 5 DataRow 에 대응한다.
|
|
/// 각 행은 컬럼명 → 값 사전이다. 태그가 <c><<Patient.PatInfDR.item("PatNam")>></c> 처럼
|
|
/// 컬럼명으로 지목하므로(LegacyQueryVariableCatalog.cs:130-135) 이 모양이 그대로 맞는다.
|
|
/// </summary>
|
|
public sealed record PatientContext(
|
|
decimal ComNum,
|
|
string AdpDtm,
|
|
IReadOnlyDictionary<string, string> PatInf,
|
|
IReadOnlyDictionary<string, string> ComInf,
|
|
IReadOnlyDictionary<string, string> CodInf,
|
|
IReadOnlyDictionary<string, string> CoiInf,
|
|
IReadOnlyDictionary<string, string> CowInf)
|
|
{
|
|
/// <summary>행 하나에서 컬럼 값 — 없으면 빈 문자열</summary>
|
|
public static string Value(IReadOnlyDictionary<string, string> row, string column)
|
|
=> row.TryGetValue(column, out var value) ? value : string.Empty;
|
|
}
|
|
|
|
/// <summary>
|
|
/// 내원번호 하나로 환자 문맥을 읽는다 — 레거시 <c>bzPatientInfo.SetPatientInfo</c> 대응.
|
|
///
|
|
/// <b>다섯 행이 전부 적용일시를 키로 쓴다.</b> 진료과·담당의(P_CodInf), 보험 자격(P_CoiInf),
|
|
/// 병동·병실(P_CowInf) 은 <c>:AdpDtm BETWEEN StrDtm AND EndDtm</c> 이다 —
|
|
/// 시각이 어긋나면 조인이 0행이 되고 태그가 통째로 빈다.
|
|
/// 그래서 시각 판정은 <see cref="Core.Catalog.VisitMoment"/> 가 따로 맡고(테스트 9건) 여기서는 그것을 받아 쓴다.
|
|
///
|
|
/// <b>레거시와 다른 점</b>: 레거시는 5행을 프로퍼티 접근 시점에 하나씩 지연 로딩한다
|
|
/// (bzPatientInfo.vb:764-980, 각각 별도 왕복). 여기서는 <b>한 번에 다섯 번</b> 읽고 들고 있는다 —
|
|
/// 태그마다 다시 읽으면 태그 40개짜리 서식에 왕복 40회가 된다.
|
|
///
|
|
/// 전부 바인드 변수다. 레거시 dtHISOperatingInfo 도 이 다섯 개만은 바인드를 쓴다.
|
|
/// </summary>
|
|
public sealed class PatientContextStore
|
|
{
|
|
#region Member Fields
|
|
private readonly string connectionString;
|
|
#endregion
|
|
|
|
#region Constructors
|
|
public PatientContextStore(string connectionString) => this.connectionString = connectionString;
|
|
#endregion
|
|
|
|
#region Methods
|
|
/// <summary>이 내원의 문맥 — 내원 자체가 없으면 null</summary>
|
|
public PatientContext? Load(decimal comNum, Core.Catalog.VisitEdge edge = Core.Catalog.VisitEdge.Last)
|
|
{
|
|
using var connection = new OracleConnection(connectionString);
|
|
connection.Open();
|
|
|
|
var comInf = Row(connection, "SELECT * FROM P_ComInf WHERE ComNum = :k", ("k", comNum));
|
|
if (comInf.Count == 0)
|
|
{
|
|
return null;
|
|
}
|
|
var window = new Core.Catalog.VisitWindow(
|
|
PatientContext.Value(comInf, "COMPATTYP") == "I",
|
|
PatientContext.Value(comInf, "COMACPDTM"),
|
|
PatientContext.Value(comInf, "COMLEVDTM"),
|
|
string.Empty, string.Empty);
|
|
|
|
// 자격 구간은 P_CoiInf 가 갖고 있다 — 시각을 정하려면 먼저 읽어야 하고,
|
|
// 그 읽기 자체는 시각을 쓰지 않는다(MtiSeq 0 의 최신 행).
|
|
var coiSeed = Row(connection,
|
|
"SELECT * FROM (SELECT * FROM P_CoiInf WHERE CoiComNum = :k AND CoiMtiSeq = 0"
|
|
+ " ORDER BY CoiStrDte DESC) WHERE ROWNUM = 1", ("k", comNum));
|
|
window = window with
|
|
{
|
|
CoverageFrom = PatientContext.Value(coiSeed, "COISTRDTE"),
|
|
CoverageTo = PatientContext.Value(coiSeed, "COIENDDTE"),
|
|
};
|
|
|
|
var adpDtm = Core.Catalog.VisitMoment.Decide(window, edge, ServerClock.Read(connection, null).Minute12);
|
|
var chtNum = PatientContext.Value(comInf, "COMCHTNUM");
|
|
|
|
var patInf = Row(connection, "SELECT * FROM P_PatInf WHERE TRIM(PatChtNum) = :c", ("c", chtNum.Trim()));
|
|
var codInf = Row(connection,
|
|
"SELECT * FROM (SELECT * FROM P_CodInf WHERE CodComNum = :k AND CodMtiSeq = 0"
|
|
+ " AND :t BETWEEN CodStrDtm AND CodEndDtm ORDER BY CodStrDtm DESC) WHERE ROWNUM = 1",
|
|
("k", comNum), ("t", adpDtm));
|
|
var coiInf = Row(connection,
|
|
"SELECT * FROM (SELECT * FROM P_CoiInf WHERE CoiComNum = :k AND CoiMtiSeq = 0"
|
|
+ " AND :d BETWEEN CoiStrDte AND CoiEndDte ORDER BY CoiStrDte DESC) WHERE ROWNUM = 1",
|
|
("k", comNum), ("d", Day(adpDtm)));
|
|
var cowInf = Row(connection,
|
|
"SELECT * FROM (SELECT * FROM P_CowInf WHERE CowComNum = :k"
|
|
+ " AND :t BETWEEN CowStrDtm AND CowEndDtm ORDER BY CowStrDtm DESC) WHERE ROWNUM = 1",
|
|
("k", comNum), ("t", adpDtm));
|
|
|
|
return new PatientContext(comNum, adpDtm, patInf, comInf, codInf, coiInf, cowInf);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 한 행을 컬럼명 → 값 사전으로. 없으면 빈 사전.
|
|
/// 컬럼명은 <b>대문자</b>로 담는다 — 오라클이 대문자로 돌려주고, 호출부가 대소문자로 헷갈리면
|
|
/// 값이 조용히 빈다. 조회는 대소문자 무시 비교로 받는다.
|
|
/// </summary>
|
|
private static Dictionary<string, string> Row(OracleConnection connection, string sql,
|
|
params (string Name, object Value)[] parameters)
|
|
{
|
|
var result = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
|
using var command = connection.CreateCommand();
|
|
command.BindByName = true;
|
|
command.CommandText = sql;
|
|
foreach (var (name, value) in parameters)
|
|
{
|
|
command.Parameters.Add(new OracleParameter(name, value));
|
|
}
|
|
using var reader = command.ExecuteReader();
|
|
if (!reader.Read())
|
|
{
|
|
return result;
|
|
}
|
|
for (var i = 0; i < reader.FieldCount; i++)
|
|
{
|
|
// CHAR 고정폭 잔여 공백을 여기서 한 번 걷는다 — 태그마다 걷으면 빠뜨린다
|
|
result[reader.GetName(i)] = reader.IsDBNull(i) ? string.Empty : reader.GetValue(i).ToString()!.Trim();
|
|
}
|
|
return result;
|
|
}
|
|
|
|
private static string Day(string stamp) => stamp.Length >= 8 ? stamp[..8] : stamp;
|
|
|
|
/// <summary>
|
|
/// 이 내원의 담당의 — <c>OCM_담당의사</c> 계열이 쓴다. 없으면 빈 사전.
|
|
///
|
|
/// <b>레거시와 의도적으로 다른 곳이 한 군데 있다.</b>
|
|
/// 원본 <c>GetCodInfDT(..., With_Join)</c> 는 <c>CodComNum</c> + <c>CodMtiSeq='0'</c> 으로만 거르고
|
|
/// <b>ORDER BY 가 없는데</b> 호출부는 <c>Rows(0)</c> 을 집는다.
|
|
/// 이 DB 에서 세어 보니 그런 내원이 <b>25,488건</b>이고 최대 <b>41행</b>이다 —
|
|
/// 즉 레거시는 그 내원들의 담당의를 확정적으로 찍지 못한다. 재현할 대상이 없으므로
|
|
/// "그대로 옮긴다"가 성립하지 않는다.
|
|
///
|
|
/// 그래서 적용일시로 거르고 최신순 1행을 집는다 — 문맥의 <c>CodInf</c> 와 같은 규칙이다.
|
|
/// 레거시도 이 시각을 <b>넘기기는 한다</b>(마스터 유효기간 조인에만 쓸 뿐이다).
|
|
/// 근거는 <c>docs/TAG-PORTABLE-48.md</c>.
|
|
///
|
|
/// 담당의 조회는 레거시 <c>GetUidMst</c>(clsCommonLib.vb:10615)를 그대로 옮긴다 —
|
|
/// 유효기간으로 먼저 찾고, <b>0행이면 코드만으로 다시 찾는다.</b>
|
|
/// 그 폴백을 빼면 유효기간이 지난 의사가 통째로 빈칸이 된다.
|
|
/// </summary>
|
|
/// <param name="moment">진료과 행을 고를 적용일시 12자리</param>
|
|
/// <param name="doctorDay">담당의 유효기간을 볼 날짜 8자리. 비면 <paramref name="moment"/> 의 앞 8자리</param>
|
|
public IReadOnlyDictionary<string, string> Doctor(decimal comNum, string moment, string? doctorDay = null)
|
|
{
|
|
using var connection = new OracleConnection(connectionString);
|
|
connection.Open();
|
|
|
|
var cod = Row(connection,
|
|
"SELECT * FROM (SELECT * FROM P_CodInf WHERE CodComNum = :k AND CodMtiSeq = 0"
|
|
+ " AND :t BETWEEN CodStrDtm AND CodEndDtm ORDER BY CodStrDtm DESC) WHERE ROWNUM = 1",
|
|
("k", comNum), ("t", moment));
|
|
var code = PatientContext.Value(cod, "CodDtrCod");
|
|
if (code.Length == 0)
|
|
{
|
|
return cod;
|
|
}
|
|
// 레거시는 입원이면 CodEndDtm 의 날짜로, 외래면 접수일자로 본다 — 호출부가 정해 넘긴다
|
|
var day = Day((doctorDay ?? string.Empty).Trim().Length > 0 ? doctorDay!.Trim() : moment);
|
|
|
|
var uid = Row(connection,
|
|
"SELECT * FROM (SELECT * FROM M_UidMst WHERE UidCod = :c"
|
|
+ " AND :d BETWEEN UidStrDte AND UidEndDte) WHERE ROWNUM = 1",
|
|
("c", code), ("d", day));
|
|
return uid.Count > 0
|
|
? uid
|
|
// 폴백 — 유효기간 밖이어도 그 코드의 행을 쓴다(레거시 :10641-10644)
|
|
: Row(connection, "SELECT * FROM (SELECT * FROM M_UidMst WHERE UidCod = :c) WHERE ROWNUM = 1",
|
|
("c", code));
|
|
}
|
|
|
|
/// <summary>
|
|
/// 진료과 마스터 한 행 — <c>OCM_진료과</c>(bzDataInterface.vb:5437-5495)의 조회.
|
|
/// <c>WHERE DepCod = :d AND :day BETWEEN DepStrDte AND DepEndDte</c>.
|
|
///
|
|
/// 유효기간 판정 일자는 레거시가 <b>입원·외래 무관하게 접수일자</b>를 쓴다(:5445 sAcpDte).
|
|
/// 원본에는 ORDER BY 가 없고 Rows(0) 을 집는다 — 담당의 때와 같은 이유로
|
|
/// 최신순 1행으로 고정한다(기간이 겹친 이력에서 비결정적이 되는 것을 막는다).
|
|
/// </summary>
|
|
public IReadOnlyDictionary<string, string> Department(string? depCod, string? day)
|
|
{
|
|
var code = (depCod ?? string.Empty).Trim();
|
|
var date = (day ?? string.Empty).Trim();
|
|
if (code.Length == 0 || date.Length < 8)
|
|
{
|
|
return new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
|
}
|
|
using var connection = new OracleConnection(connectionString);
|
|
connection.Open();
|
|
return Row(connection,
|
|
"SELECT * FROM (SELECT * FROM M_DepMst WHERE TRIM(DepCod) = :d"
|
|
+ " AND :y BETWEEN DepStrDte AND DepEndDte ORDER BY DepStrDte DESC) WHERE ROWNUM = 1",
|
|
("d", code), ("y", date[..8]));
|
|
}
|
|
|
|
/// <summary>
|
|
/// 요양기관 행 — <c>ETC_요양기관명칭_*</c> 12종(bzDataInterface.vb:15656-16709)의 공통 조회.
|
|
/// <c>M_DepMst ⨝ M_HspMst(DepHspCod=HspCod)</c> 에서 병원 컬럼들을 뽑는다.
|
|
/// 진료과 코드로 병원을 찾는 구조다 — 다부지(병원 코드가 다른 진료과) 구성 때문으로 보인다.
|
|
///
|
|
/// 레거시는 태그마다 이 조인을 <b>따로</b> 돌린다(12종 = 왕복 12회).
|
|
/// 컬럼만 다르므로 한 번에 다 뽑아 행으로 돌려준다 — 값 선택은 해석기가 한다.
|
|
/// 컬럼 9개는 전부 각 태그 본문의 SELECT 에서 확인한 것이다.
|
|
/// </summary>
|
|
public IReadOnlyDictionary<string, string> HospitalRow(string? depCod, string? day)
|
|
{
|
|
var code = (depCod ?? string.Empty).Trim();
|
|
var date = (day ?? string.Empty).Trim();
|
|
if (code.Length == 0 || date.Length < 8)
|
|
{
|
|
return new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
|
}
|
|
using var connection = new OracleConnection(connectionString);
|
|
connection.Open();
|
|
return Row(connection,
|
|
"SELECT * FROM (SELECT HspInsNam, HspOwnNam, HspTelNum, HspFaxNum,"
|
|
+ " HspKorAdr, HspEngNam, HspEngAdr, HspRgnNum, HspInsNum FROM M_DepMst"
|
|
+ " INNER JOIN M_HspMst ON DepHspCod = HspCod AND :y BETWEEN HspStrDte AND HspEndDte"
|
|
+ " WHERE TRIM(DepCod) = :d AND :y BETWEEN DepStrDte AND DepEndDte) WHERE ROWNUM = 1",
|
|
("d", code), ("y", date[..8]));
|
|
}
|
|
|
|
/// <summary>
|
|
/// 건물관리번호로 주소 — 레거시 <c>GetZipInfoByBuildingNumber</c>(clsCommonLib.vb:16600).
|
|
///
|
|
/// 결합식을 <b>그대로</b> 옮긴다. DECODE 로 빈 조각을 건너뛰고 구분자를 붙이는 규칙이
|
|
/// 도로명·지번·영문이 각각 다르다 — 손으로 다시 짜면 주소가 미묘하게 달라진다.
|
|
///
|
|
/// 없으면 빈 사전. 레거시도 못 찾으면 Nothing 을 돌려주고 호출부가 상세주소만 쓴다.
|
|
/// </summary>
|
|
public IReadOnlyDictionary<string, string> Address(string? buildingNumber)
|
|
{
|
|
var key = (buildingNumber ?? string.Empty).Trim();
|
|
if (key.Length == 0)
|
|
{
|
|
return new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
|
}
|
|
using var connection = new OracleConnection(connectionString);
|
|
connection.Open();
|
|
return Row(connection,
|
|
"SELECT 구역번호, 우편번호"
|
|
+ ", TRIM(시도) || ' ' || DECODE(TRIM(시군구), NULL, '', TRIM(시군구) || ' ')"
|
|
+ " || DECODE(TRIM(읍면), NULL, '', TRIM(읍면) || ' ')"
|
|
+ " || DECODE(TRIM(도로명), NULL, '', TRIM(도로명) || ' ')"
|
|
+ " || DECODE(지하여부, 1, '지하 ', '')"
|
|
+ " || DECODE(TRIM(건물번호본번), NULL, '', TRIM(건물번호본번))"
|
|
+ " || DECODE(TRIM(건물번호부번), NULL, ' ', 0, ' ', '-' || TRIM(건물번호부번) || ' ')"
|
|
+ " || DECODE(TRIM(시군구용건물명), NULL, '', TRIM(시군구용건물명) || '') AS 도로명주소"
|
|
+ ", TRIM(시도) || ' ' || DECODE(TRIM(시군구), NULL, '', TRIM(시군구) || ' ')"
|
|
+ " || DECODE(TRIM(읍면), NULL, '', TRIM(읍면) || ' ')"
|
|
+ " || DECODE(TRIM(법정동명), NULL, '', TRIM(법정동명) || ' ')"
|
|
+ " || DECODE(TRIM(리명), NULL, '', TRIM(리명) || ' ')"
|
|
+ " || DECODE(산여부, 1, ' 산 ', '')"
|
|
+ " || DECODE(TRIM(지번본번), NULL, '', TRIM(지번본번))"
|
|
+ " || DECODE(TRIM(지번부번), NULL, ' ', 0, ' ', '-' || TRIM(지번부번) || ' ')"
|
|
+ " || DECODE(TRIM(시군구용건물명), NULL, '', TRIM(시군구용건물명) || '') AS 지번주소"
|
|
+ ", DECODE(TRIM(건물번호본번), NULL, '', TRIM(건물번호본번) || ' ')"
|
|
+ " || DECODE(TRIM(도로명영문), NULL, '', TRIM(도로명영문) || ', ')"
|
|
+ " || DECODE(TRIM(읍면영문), NULL, '', TRIM(읍면영문) || ', ')"
|
|
+ " || DECODE(TRIM(시군구영문), NULL, '', TRIM(시군구영문) || ', ')"
|
|
+ " || TRIM(시도영문) AS 영문도로명주소"
|
|
+ " FROM M_ZipMst WHERE 건물관리번호 = :b", ("b", key));
|
|
}
|
|
#endregion
|
|
}
|