using Oracle.ManagedDataAccess.Client; using SheetMe.Core.Models; using SheetMe.Core.Serialization; namespace SheetMe.Data.Stores; /// 서식 목록 1건(열기 대화상자) /// 서식생성기 사용 허용(E_ShtMst.ShtUsrDesYon) — 목록 잠금 표시용 /// 분류 코드(E_ShtMst.ShtClsCod) — 그룹 정렬 키 /// 분류 한글명(M_DtlMst.DtlCodNam) — 그룹 헤더 표시 public sealed record SheetSummary( string ShtCod, string Name, bool HasDesign, bool DesignAllowed, string ClassCode, string ClassName); /// 서식생성기 사용 권한 판정 결과 public enum SheetDesignPermission { /// E_ShtMst 에 서식 코드가 없음 NotRegistered, /// 허용 — ShtUsrDesYon 이 'Y' 또는 미설정 Allowed, /// 거부 — ShtUsrDesYon 이 'N' 등 Denied, } /// 디자인 버전 1건 public sealed record DesignVersionInfo(decimal SdgKey, string UpdDtm, string UpdUid, bool Deleted); /// /// 레거시 테이블(E_ShtMst/E_SdgMst/E_SctMst) 기반 서식 저장소 — 레거시 디자이너와 동일 저장 의미론. /// 저장 흐름(bzSaveSheetDesignNControlInfo 이식, 단일 트랜잭션): /// ShtCneYon='Y'+활성행 존재 → 제자리 갱신(E_SctMst 는 신규 이름만 추가 + 기존 행 SctObjTxt 갱신), /// 그 외 → 기존 활성행 SdgDelYon='Y' 후 신규 행 INSERT + E_SctMst 전체 재생성. /// 채번: 전용 시퀀스 NEXTVAL 우선, 부재 시 MAX+1 폴백(트랜잭션 내 — [200]SheetMe 검증 방식). /// public sealed class OracleLegacyFormStore { #region Member Fields /// Spread AsTemplate 항목 행의 SctObjTyp — 이 행은 ObjName 이 항목 텍스트라 컨트롤 이름과 구분해야 한다 private const string SpreadItemType = "ItemOfSpreadAsTemplate"; private readonly string connectionString; private readonly LegacyXmlSerializer serializer = new(); #endregion #region Constructors public OracleLegacyFormStore(string connectionString) { this.connectionString = connectionString; } #endregion #region Methods - 조회 /// /// 서식 목록 검색 — 공백제거 UPPER LIKE 토큰 AND 매칭, 스캔서식(ShtTyp='S') 제외. /// /// 분류(ShtClsCod)를 함께 읽어 목록을 묶는다. 코드→한글명은 M_DtlMst 의 /// DtlTblCod='EMR_SHEET_GROUP' 행이 소유하며, EMR 뷰어 트리가 쓰는 것과 같은 조인이라 /// (`[200]SheetMe/src/Data/SheetMe.Data/EmrTreeDataBusiness.cs:55`) 사용자가 이미 아는 분류가 그대로 나온다. /// TRIM 비교 필수 — 두 컬럼 다 고정폭 잔여 공백이 섞여 있다. /// 실측(운영 1,628건): 코드 24종 전부 마스터에 존재, 미분류는 1건뿐. /// public List ListSheets(string? keyword, int max = 300) { using var connection = Open(); using var command = connection.CreateCommand(); command.BindByName = true; var where = "WHERE NVL(s.ShtTyp,' ') <> 'S'"; var tokens = (keyword ?? string.Empty) .Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); for (var i = 0; i < tokens.Length; i++) { // 분류명도 검색 대상 — 헤더에 보이는 '간호기록'을 그대로 쳤는데 0건이면 배신감이 크다 where += $" AND (UPPER(REPLACE(s.ShtKorNam,' ','')) LIKE :k{i} OR UPPER(s.ShtCod) LIKE :k{i}" + $" OR UPPER(REPLACE(dtl.DtlCodNam,' ','')) LIKE :k{i})"; command.Parameters.Add(new OracleParameter($"k{i}", $"%{tokens[i].ToUpperInvariant()}%")); } command.CommandText = "SELECT * FROM (" + " SELECT s.ShtCod, s.ShtKorNam," + " (SELECT COUNT(*) FROM E_SdgMst d WHERE d.SdgShtCod = s.ShtCod AND NVL(d.SdgDelYon,' ') <> 'Y') AS DesignCount," + " NVL(TRIM(s.ShtUsrDesYon),' ') AS UsrDesYon," + " NVL(TRIM(s.ShtClsCod),' ') AS ClsCod," + " NVL(dtl.DtlCodNam,' ') AS ClsNam" + " FROM E_ShtMst s" + // 코드당 1행으로 접어서 조인한다 — M_DtlMst 는 PK 에 DtlStrDte 가 들어가 같은 분류 코드가 // 기간별 다중행일 수 있고, 그대로 조인하면 서식이 목록에 두 번 뜬다(운영 실측은 현재 증식 0건이지만 // 스키마상 가능하므로 데이터 운에 기대지 않는다). 적용일 최신 행을 택한다. " LEFT JOIN (SELECT TRIM(DtlCod) AS Cod, DtlCodNam, DtlDspSeq," + " ROW_NUMBER() OVER (PARTITION BY TRIM(DtlCod) ORDER BY DtlStrDte DESC) AS Rn" + " FROM M_DtlMst WHERE DtlTblCod = 'EMR_SHEET_GROUP') dtl" + " ON dtl.Cod = TRIM(s.ShtClsCod) AND dtl.Rn = 1" + // 그룹 순서는 이 정렬이 그대로 정한다(CollectionView 는 등장 순서로 그룹을 만든다). // 병원이 정한 표시순서(DtlDspSeq)를 1순위로 — 레거시 기록지정보 분류 탭이 이 순서다 // (`[002]Master/[024]기록지정보/M.MST.ShtMst/dtShtMst.vb:361-368`). 이 병원은 DspSeq 가 전부 // 비어 있어 결과적으로 코드순이 되지만, 채워 쓰는 병원에서 순서가 어긋나지 않는다. // LPAD 는 레거시(2자리)보다 넉넉한 4자리 — '100' 과 '99' 가 뒤집히는 것을 막는다. $" {where} ORDER BY CASE WHEN TRIM(s.ShtClsCod) IS NULL THEN 1 ELSE 0 END," + " LPAD(NVL(dtl.DtlDspSeq,'9999'), 4, '0'), s.ShtClsCod, s.ShtCod" + $") WHERE ROWNUM <= {max}"; var result = new List(); using var reader = command.ExecuteReader(); while (reader.Read()) { var classCode = reader.GetString(4).Trim(); var className = reader.GetString(5).Trim(); result.Add(new SheetSummary( reader.GetString(0), reader.IsDBNull(1) ? string.Empty : reader.GetString(1), reader.GetDecimal(2) > 0, IsDesignAllowed(reader.GetString(3)), classCode, // 마스터에 없는 코드는 코드 자체를 헤더로 — 사이트가 코드를 추가했는데 마스터를 안 채운 경우 // '(미분류)'로 뭉뚱그리면 서로 다른 분류가 한 덩어리가 된다. className.Length > 0 ? className : classCode)); } return result; } /// /// 서식생성기 사용 허용 여부(E_ShtMst.ShtUsrDesYon) — 레거시 frmSheetDesigner.vb:71-84 규칙. /// 'Y' 또는 빈 문자열/NULL(미설정 = 레거시 데이터 호환)이면 허용, 그 외('N' 등)만 거부한다. /// public SheetDesignPermission GetDesignPermission(string shtCod) { using var connection = Open(); var value = Scalar(connection, null, "SELECT NVL(TRIM(ShtUsrDesYon),' ') FROM E_ShtMst WHERE ShtCod = :s", new OracleParameter("s", shtCod)) as string; if (value is null) { return SheetDesignPermission.NotRegistered; } return IsDesignAllowed(value) ? SheetDesignPermission.Allowed : SheetDesignPermission.Denied; } /// ShtUsrDesYon 판정 — CHAR 패딩을 흡수하려 TRIM 후 비교(레거시보다 관대한 방향이라 오탐만 줄인다) private static bool IsDesignAllowed(string flag) { var trimmed = flag.Trim(); return trimmed is "Y" or ""; } /// 활성(비삭제 최신) 디자인 로드 — 없으면 null. FormId/Title/SourceSdgKey 채움 public FormDocument? LoadActiveDesign(string shtCod) { using var connection = Open(); var sdgKey = ResolveActiveSdgKey(connection, null, shtCod); if (sdgKey == 0) { return null; } using var command = connection.CreateCommand(); command.BindByName = true; command.CommandText = "SELECT SdgDesign FROM E_SdgMst WHERE SdgKey = :k"; command.Parameters.Add(new OracleParameter("k", sdgKey)); var xml = command.ExecuteScalar() as string; if (string.IsNullOrWhiteSpace(xml)) { return null; } var document = serializer.Read(xml); document.FormId = shtCod; document.Title = QueryTitle(connection, shtCod) ?? shtCod; document.Meta.SourceSdgKey = sdgKey; return document; } /// 활성 디자인 원문 XML — (SdgKey, XML), 없으면 null (일괄 왕복 스모크용) public (decimal SdgKey, string Xml)? LoadActiveDesignRaw(string shtCod) { using var connection = Open(); var sdgKey = ResolveActiveSdgKey(connection, null, shtCod); if (sdgKey == 0) { return null; } using var command = connection.CreateCommand(); command.BindByName = true; command.CommandText = "SELECT SdgDesign FROM E_SdgMst WHERE SdgKey = :k"; command.Parameters.Add(new OracleParameter("k", sdgKey)); return command.ExecuteScalar() is string xml && !string.IsNullOrWhiteSpace(xml) ? (sdgKey, xml) : null; } /// 디자인 버전의 E_SctMst 행 수(검증용) public int CountSctRows(decimal sdgKey) { using var connection = Open(); return Convert.ToInt32(Scalar(connection, null, "SELECT COUNT(*) FROM E_SctMst WHERE SctSdgKey = :k", new OracleParameter("k", sdgKey))!); } /// 서식의 제자리 갱신 여부(ShtCneYon) public string GetShtCneYon(string shtCod) { using var connection = Open(); return Scalar(connection, null, "SELECT NVL(ShtCneYon,' ') FROM E_ShtMst WHERE ShtCod = :s", new OracleParameter("s", shtCod)) as string ?? " "; } /// 특정 버전(SdgKey) 디자인 로드 — 이력 열람/복원용. 없으면 null public FormDocument? LoadDesignByKey(string shtCod, decimal sdgKey) { using var connection = Open(); using var command = connection.CreateCommand(); command.BindByName = true; command.CommandText = "SELECT SdgDesign FROM E_SdgMst WHERE SdgKey = :k"; command.Parameters.Add(new OracleParameter("k", sdgKey)); if (command.ExecuteScalar() is not string xml || string.IsNullOrWhiteSpace(xml)) { return null; } var document = serializer.Read(xml); document.FormId = shtCod; document.Title = QueryTitle(connection, shtCod) ?? shtCod; document.Meta.SourceSdgKey = sdgKey; return document; } /// /// 서식의 Spread 디자인들(E_SpdMst) — 컨트롤명(SpdName) → FarPoint XML. /// SpdShtCod 는 CHAR 패딩 컬럼이라 TRIM 비교(실측). /// public Dictionary LoadSpreadDesigns(string shtCod) { var result = new Dictionary(StringComparer.Ordinal); using var connection = Open(); using var command = connection.CreateCommand(); command.BindByName = true; command.CommandText = "SELECT TRIM(SpdName), SpdDesign FROM E_SpdMst " + "WHERE TRIM(SpdShtCod) = :s AND NVL(SpdDelYon,' ') <> 'Y'"; command.Parameters.Add(new OracleParameter("s", shtCod)); using var reader = command.ExecuteReader(); while (reader.Read()) { if (!reader.IsDBNull(1)) { result[reader.GetString(0)] = reader.GetString(1); } } return result; } /// 디자인 버전 이력(본문 제외) public List ListVersions(string shtCod) { using var connection = Open(); using var command = connection.CreateCommand(); command.BindByName = true; command.CommandText = "SELECT SdgKey, NVL(SdgUpdDtm,' '), NVL(SdgUidCod,' '), NVL(SdgDelYon,' ') " + "FROM E_SdgMst WHERE SdgShtCod = :s ORDER BY SdgKey DESC"; command.Parameters.Add(new OracleParameter("s", shtCod)); var result = new List(); using var reader = command.ExecuteReader(); while (reader.Read()) { result.Add(new DesignVersionInfo( reader.GetDecimal(0), reader.GetString(1).Trim(), reader.GetString(2).Trim(), reader.GetString(3) == "Y")); } return result; } #endregion #region Methods - 저장 /// /// 디자인 저장 — 레거시 동일 의미론(제자리 갱신 vs 버저닝), 단일 트랜잭션. /// 반환: 저장된 SdgKey. /// public decimal SaveDesign(FormDocument document, string uid) { if (string.IsNullOrWhiteSpace(document.FormId)) { throw new InvalidOperationException("서식 코드(FormId)가 없습니다."); } var mergedXml = serializer.Write(document); using var connection = Open(); using var transaction = connection.BeginTransaction(); try { // 레거시 SystemDateTime 과 동일하게 DB 서버 시각을 쓴다 — 단말 시계가 밀리면 // ResolveActiveSdgKey 의 ORDER BY SdgUpdDtm 이 활성 버전을 잘못 고를 수 있다. var now = ServerClock.Read(connection, transaction).Minute12; var shtCneYon = Scalar(connection, transaction, "SELECT NVL(ShtCneYon,' ') FROM E_ShtMst WHERE ShtCod = :s", new OracleParameter("s", document.FormId)) as string; if (shtCneYon is null) { throw new InvalidOperationException($"E_ShtMst 에 서식이 없습니다: {document.FormId}"); } var activeSdgKey = ResolveActiveSdgKey(connection, transaction, document.FormId); var updateInPlace = shtCneYon == "Y" && activeSdgKey > 0; decimal sdgKey; if (updateInPlace) { // 제자리 갱신 — 레거시 ShtCneYon='Y' 경로 sdgKey = activeSdgKey; NonQuery(connection, transaction, "UPDATE E_SdgMst SET SdgDesign = :d, SdgUidCod = :u, SdgUpdDtm = :t WHERE SdgKey = :k", new OracleParameter("d", OracleDbType.Clob) { Value = mergedXml }, new OracleParameter("u", uid), new OracleParameter("t", now), new OracleParameter("k", sdgKey)); } else { // 기존 활성행 히스토리 처리 후 신규 행 if (activeSdgKey > 0) { NonQuery(connection, transaction, "UPDATE E_SdgMst SET SdgDelYon = 'Y' WHERE SdgKey = :k", new OracleParameter("k", activeSdgKey)); } sdgKey = NextVal(connection, transaction, "E_SDGMST_SDGKEY", "SELECT NVL(MAX(SdgKey),0)+1 FROM E_SdgMst"); NonQuery(connection, transaction, "INSERT INTO E_SdgMst (SdgKey, SdgShtCod, SdgDesign, SdgUidCod, SdgUpdDtm, SdgDelYon, SdgDelUid, SdgDelDtm) " + "VALUES (:k, :s, :d, :u, :t, '', '', '')", new OracleParameter("k", sdgKey), new OracleParameter("s", document.FormId), new OracleParameter("d", OracleDbType.Clob) { Value = mergedXml }, new OracleParameter("u", uid), new OracleParameter("t", now)); } SaveSctMst(connection, transaction, sdgKey, mergedXml, updateInPlace); transaction.Commit(); document.Meta.SourceSdgKey = sdgKey; document.Meta.UpdatedBy = uid; document.Meta.UpdatedAt = now; return sdgKey; } catch { transaction.Rollback(); throw; } } /// E_ShtMst 존재 여부 public bool SheetExists(string shtCod) { using var connection = Open(); return Convert.ToInt32(Scalar(connection, null, "SELECT COUNT(*) FROM E_ShtMst WHERE ShtCod = :s", new OracleParameter("s", shtCod))!) > 0; } /// /// 신규 서식 최소 등록 — 실DB 디자이너 서식 관행값(ShtTyp='D', 사용/디자인/컨트롤/EMR 'Y', 열람권한 'A' 등). /// 상세 속성(권한/출력 등)은 레거시 기록지정보 마스터에서 후속 설정. /// public void RegisterSheet(string shtCod, string korName, string? clsCod, string uid) { if (shtCod.Length is 0 or > 10) { throw new ArgumentException("서식 코드는 1~10자여야 합니다."); } using var connection = Open(); using var transaction = connection.BeginTransaction(); try { var stamp = ServerClock.Read(connection, transaction); // ShtUsrDesYon='Y' 하드코딩 — 여기서 만든 서식은 서식생성기가 만든 것이므로 열기 게이트를 자동 통과한다 NonQuery(connection, transaction, "INSERT INTO E_ShtMst (ShtCod, ShtAdpDte, ShtKorNam, ShtClsCod, ShtTyp, ShtUseYon, " + "ShtUsrDesYon, ShtUseSdg, ShtUseSct, ShtUseEmr, ShtRedAth, ShtWrtTyp, ShtCneYon, ShtTraSto, " + "ShtEleSig, ShtUpdUid, ShtUpdDtm) " + "VALUES (:cod, :adp, :nam, :cls, 'D', 'Y', 'Y', 'Y', 'Y', 'Y', 'A', '9A', 'N', 'N', 'N', :uid, :dtm)", new OracleParameter("cod", shtCod), new OracleParameter("adp", stamp.Date8), new OracleParameter("nam", korName), new OracleParameter("cls", string.IsNullOrWhiteSpace(clsCod) ? (object)DBNull.Value : clsCod), new OracleParameter("uid", uid), new OracleParameter("dtm", stamp.Minute12)); transaction.Commit(); } catch { transaction.Rollback(); throw; } } private void SaveSctMst(OracleConnection connection, OracleTransaction transaction, decimal sdgKey, string mergedXml, bool updateInPlace) { var rows = SctMstXmlWalker.Walk(mergedXml); // 제자리 갱신일 때만 기존 행을 읽는다. 컨트롤 행은 SctKey 로 갱신해야 하므로 키까지 가져오고, // Spread AsTemplate 항목 행은 ObjName 이 항목 텍스트라 컨트롤 이름과 섞이면 안 되므로 따로 담는다. Dictionary? existingControls = null; HashSet? existingSpreadItems = null; if (updateInPlace) { existingControls = new Dictionary(StringComparer.Ordinal); existingSpreadItems = new HashSet(StringComparer.Ordinal); using var query = connection.CreateCommand(); query.Transaction = transaction; query.BindByName = true; query.CommandText = "SELECT SctKey, SctObjNam, NVL(SctObjTyp,' ') FROM E_SctMst WHERE SctSdgKey = :k ORDER BY SctKey"; query.Parameters.Add(new OracleParameter("k", sdgKey)); using var reader = query.ExecuteReader(); while (reader.Read()) { var key = reader.GetDecimal(0); var name = reader.GetString(1); var type = reader.GetString(2).Trim(); if (string.Equals(type, SpreadItemType, StringComparison.OrdinalIgnoreCase)) { existingSpreadItems.Add(name); } else if (!existingControls.ContainsKey(name)) { // 동명 행이 이미 중복된 오염 데이터에서는 레거시 Select(...)(0) 과 동일하게 최초 1건만 채택 existingControls[name] = key; } } } foreach (var row in rows) { if (updateInPlace) { if (string.Equals(row.ObjType, SpreadItemType, StringComparison.OrdinalIgnoreCase)) { // 레거시(bzSaveSheetDesignNControlInfo.vb:355-370)는 존재 검사 없이 매 저장마다 항목 행을 // 중복 INSERT 한다. 런타임은 개수를 세지 않으므로 그 버그는 복제하지 않고 행 수를 안정시킨다. if (existingSpreadItems!.Contains(row.ObjName)) { continue; } } else if (existingControls!.TryGetValue(row.ObjName, out var existingKey)) { // 레거시 bzSaveSheetDesignNControlInfo.vb:378 — 기존 행은 SctObjTxt 만 덮어쓴다. // SctObjTyp/SctParObj/SctObjSeq/SctObjID 는 의도적으로 갱신하지 않는다(SctKey 보존 의미론). // 삭제된 컨트롤의 잔존 행도 레거시와 동일하게 지우지 않는다. NonQuery(connection, transaction, "UPDATE E_SctMst SET SctObjTxt = :txt WHERE SctKey = :k", new OracleParameter("txt", row.ObjText.Length == 0 ? (object)DBNull.Value : row.ObjText), new OracleParameter("k", existingKey)); continue; } } var sctKey = NextVal(connection, transaction, "E_SCTMST_SCTKEY", "SELECT NVL(MAX(SctKey),0)+1 FROM E_SctMst"); NonQuery(connection, transaction, "INSERT INTO E_SctMst (SctKey, SctSdgKey, SctObjNam, SctObjID, SctObjTyp, SctParObj, SctObjSeq, SctObjTxt) " + "VALUES (:k, :sdg, :nam, :oid, :typ, :par, :seq, :txt)", new OracleParameter("k", sctKey), new OracleParameter("sdg", sdgKey), new OracleParameter("nam", row.ObjName), new OracleParameter("oid", row.ObjId), new OracleParameter("typ", row.ObjType), new OracleParameter("par", row.ParentId), new OracleParameter("seq", row.ObjSeq.Length == 0 ? "0" : row.ObjSeq), new OracleParameter("txt", row.ObjText.Length == 0 ? (object)DBNull.Value : row.ObjText)); } } #endregion #region Methods - Private private OracleConnection Open() { var connection = new OracleConnection(connectionString); connection.Open(); return connection; } /// 활성(비삭제 최신) SdgKey — 없으면 0 ([200] SdgKeyResolver 의미론) private static decimal ResolveActiveSdgKey(OracleConnection connection, OracleTransaction? transaction, string shtCod) { var value = Scalar(connection, transaction, "SELECT NVL((SELECT SdgKey FROM (SELECT SdgKey FROM E_SdgMst " + "WHERE SdgShtCod = :s AND NVL(SdgDelYon,' ') <> 'Y' ORDER BY SdgUpdDtm DESC, SdgKey DESC) " + "WHERE ROWNUM = 1), 0) FROM DUAL", new OracleParameter("s", shtCod)); return value is null or DBNull ? 0m : Convert.ToDecimal(value); } private static string? QueryTitle(OracleConnection connection, string shtCod) => Scalar(connection, null, "SELECT ShtKorNam FROM E_ShtMst WHERE ShtCod = :s", new OracleParameter("s", shtCod)) as string; /// 채번 — 전용 시퀀스 NEXTVAL 우선, 시퀀스 부재(ORA-02289/00942) 시 MAX+1 폴백 private static decimal NextVal(OracleConnection connection, OracleTransaction transaction, string sequence, string fallbackSql) { try { return Convert.ToDecimal(Scalar(connection, transaction, $"SELECT {sequence}.NEXTVAL FROM DUAL")!); } catch (OracleException ex) when (ex.Number is 2289 or 942) { return Convert.ToDecimal(Scalar(connection, transaction, fallbackSql)!); } } private static object? Scalar(OracleConnection connection, OracleTransaction? transaction, string sql, params OracleParameter[] parameters) { using var command = connection.CreateCommand(); if (transaction is not null) { command.Transaction = transaction; } command.BindByName = true; command.CommandText = sql; command.Parameters.AddRange(parameters); return command.ExecuteScalar(); } /// DML 실행 — 영향 행 수 반환(호출부가 무시해도 무방) private static int NonQuery(OracleConnection connection, OracleTransaction transaction, string sql, params OracleParameter[] parameters) { using var command = connection.CreateCommand(); command.Transaction = transaction; command.BindByName = true; command.CommandText = sql; command.Parameters.AddRange(parameters); return command.ExecuteNonQuery(); } #endregion }