Spaces:
No application file
No application file
Delete app.py
Browse files
app.py
DELETED
|
@@ -1,571 +0,0 @@
|
|
| 1 |
-
import os
|
| 2 |
-
import sqlite3
|
| 3 |
-
from io import BytesIO
|
| 4 |
-
from datetime import date, datetime
|
| 5 |
-
|
| 6 |
-
import pandas as pd
|
| 7 |
-
import streamlit as st
|
| 8 |
-
from openpyxl import Workbook
|
| 9 |
-
from openpyxl.styles import Font, Alignment, Border, Side, PatternFill
|
| 10 |
-
from openpyxl.utils import get_column_letter
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
st.set_page_config(page_title="상태변화기록지", page_icon="📈", layout="wide")
|
| 14 |
-
|
| 15 |
-
CURRENT_DIR = os.path.dirname(os.path.abspath(__file__))
|
| 16 |
-
DB_CANDIDATES = [
|
| 17 |
-
os.path.join(CURRENT_DIR, "data", "clients.db"),
|
| 18 |
-
os.path.join(CURRENT_DIR, "..", "Client Center", "data", "clients.db"),
|
| 19 |
-
os.path.join(CURRENT_DIR, "..", "ClientCenter", "data", "clients.db"),
|
| 20 |
-
os.path.join(CURRENT_DIR, "..", "Client_Center", "data", "clients.db"),
|
| 21 |
-
]
|
| 22 |
-
|
| 23 |
-
DB_PATH = None
|
| 24 |
-
for candidate in DB_CANDIDATES:
|
| 25 |
-
if os.path.exists(candidate):
|
| 26 |
-
DB_PATH = candidate
|
| 27 |
-
break
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
st.markdown(
|
| 31 |
-
"""
|
| 32 |
-
<style>
|
| 33 |
-
.block-container { padding-top: 1.6rem; max-width: 1160px; }
|
| 34 |
-
.hero { background: linear-gradient(135deg,#2563EB,#60A5FA); padding:32px; border-radius:24px; text-align:center; margin-bottom:20px; }
|
| 35 |
-
.hero h1 { color:white; margin:0; font-size:42px; font-weight:800; }
|
| 36 |
-
.hero p { color:white; margin:14px 0 0 0; font-size:19px; line-height:1.55; }
|
| 37 |
-
.notice { background:#FFF8E1; border-left:7px solid #F59E0B; padding:15px 18px; border-radius:14px; margin:16px 0; line-height:1.7; }
|
| 38 |
-
.okbox { background:#E8F5E9; border-left:7px solid #43A047; padding:14px; border-radius:12px; margin:10px 0; }
|
| 39 |
-
.warnbox { background:#FFF3E0; border-left:7px solid #FB8C00; padding:14px; border-radius:12px; margin:10px 0; }
|
| 40 |
-
</style>
|
| 41 |
-
""",
|
| 42 |
-
unsafe_allow_html=True,
|
| 43 |
-
)
|
| 44 |
-
|
| 45 |
-
st.markdown(
|
| 46 |
-
"""
|
| 47 |
-
<div class="hero">
|
| 48 |
-
<h1>📈 상태변화기록지 생성기</h1>
|
| 49 |
-
<p>Client Center 정보와 직접 입력 메모를 바탕으로 5대 영역, SOAP, 위험판정, 연계계획을 자동 생성합니다.</p>
|
| 50 |
-
</div>
|
| 51 |
-
""",
|
| 52 |
-
unsafe_allow_html=True,
|
| 53 |
-
)
|
| 54 |
-
|
| 55 |
-
st.markdown(
|
| 56 |
-
"""
|
| 57 |
-
<div class="notice">
|
| 58 |
-
<strong>작성 원칙:</strong> 상태변화가 없다는 반복 문구를 피하고, 신체·인지·감정·관계·지역자원연계 영역을 기준으로 평가 적합 문체로 기록합니다.
|
| 59 |
-
</div>
|
| 60 |
-
""",
|
| 61 |
-
unsafe_allow_html=True,
|
| 62 |
-
)
|
| 63 |
-
|
| 64 |
-
st.markdown(
|
| 65 |
-
"""
|
| 66 |
-
<div class="notice">
|
| 67 |
-
※ 생성된 문서는 그대로 사용하셔도 되며, 기관의 운영방침, 수급자 상태, 보호자 의견 및 평가기준에 따라 추가 보완사항은 얼마든지 수정 가능합니다. 기관의 상황에 맞게 활용하시기 바랍니다.
|
| 68 |
-
</div>
|
| 69 |
-
""",
|
| 70 |
-
unsafe_allow_html=True,
|
| 71 |
-
)
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
RESTRICTED_REPLACEMENTS = {
|
| 75 |
-
"상태 양호": "신체·인지·정서 상태가 비교적 안정적으로 유지되나 정기 관찰이 요구됨",
|
| 76 |
-
"특이사항 없음": "즉시 개입이 필요한 돌발 상황은 확인되지 않았으나 상태변화 관찰을 지속함",
|
| 77 |
-
"이전과 동일": "기존 상태의 급격한 악화는 제한적이나 기능 유지 여부를 구체적으로 확인함",
|
| 78 |
-
"문제없음": "현재 확인된 위험요인은 제한적이나 급여제공 과정에서 재확인이 필요함",
|
| 79 |
-
"문제 없음": "현재 확인된 위험요인은 제한적이나 급여제공 과정에서 재확인이 필요함",
|
| 80 |
-
}
|
| 81 |
-
|
| 82 |
-
DOMAIN_KEYWORDS = {
|
| 83 |
-
"신체": ["통증", "낙상", "어지", "휘청", "보행", "식사", "식욕", "체중", "사레", "연하", "배변", "배뇨", "실금", "기저귀", "욕창", "발적", "상처", "피부", "수면", "입원", "퇴원"],
|
| 84 |
-
"인지": ["치매", "인지", "기억", "반복", "혼동", "섬망", "지남력", "배회", "망상", "도둑", "환각", "귀가", "집에"],
|
| 85 |
-
"감정": ["우울", "눈물", "무기력", "불안", "초조", "짜증", "화", "거부", "살기 싫", "귀찮"],
|
| 86 |
-
"관계": ["다툼", "갈등", "폭언", "공격", "보호자", "가족", "종사자", "타 수급자", "말다툼", "거부"],
|
| 87 |
-
"지역자원연계": ["독거", "결식", "경제", "주거", "복약", "병원", "진료", "보건소", "복지관", "행정복지센터", "치매안심센터", "방문간호"],
|
| 88 |
-
}
|
| 89 |
-
|
| 90 |
-
BPSD_KEYWORDS = ["도둑", "망상", "석양", "배회", "공격", "폭언", "야간섬망", "환각", "귀가", "집에", "거부"]
|
| 91 |
-
DEPRESSION_KEYWORDS = ["우울", "눈물", "식사거부", "무기력", "살기 싫", "귀찮", "외로"]
|
| 92 |
-
COMMUNITY_KEYWORDS = ["복약", "결식", "독거", "주거", "경제", "병원", "치매", "방문간호"]
|
| 93 |
-
HIGH_RISK_KEYWORDS = ["응급", "입원", "낙상", "욕창", "상처", "감염", "공격", "배회", "망상", "식사거부", "자해", "학대", "방임"]
|
| 94 |
-
WARNING_KEYWORDS = ["어지", "통증", "식사량", "실금", "반복", "불안", "우울", "갈등", "거부", "발적", "보행"]
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
def normalize_text(text):
|
| 98 |
-
value = str(text or "").strip()
|
| 99 |
-
for bad, good in RESTRICTED_REPLACEMENTS.items():
|
| 100 |
-
value = value.replace(bad, good)
|
| 101 |
-
return value
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
def has_keyword(text, keywords):
|
| 105 |
-
source = str(text or "")
|
| 106 |
-
return any(keyword in source for keyword in keywords)
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
def pick(data, *keys, default=""):
|
| 110 |
-
for key in keys:
|
| 111 |
-
value = data.get(key, "")
|
| 112 |
-
if value is None:
|
| 113 |
-
continue
|
| 114 |
-
value = str(value).strip()
|
| 115 |
-
if value:
|
| 116 |
-
return value
|
| 117 |
-
return default
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
def formal_sentence(text, fallback="지속 관찰이 요구됨"):
|
| 121 |
-
value = normalize_text(text)
|
| 122 |
-
if not value:
|
| 123 |
-
value = fallback
|
| 124 |
-
if value.endswith(("확인됨", "관찰됨", "사정됨", "판단됨", "계획함", "실시함", "요구됨", "필요함", "있음", "없음", "됨", "함")):
|
| 125 |
-
return value
|
| 126 |
-
last = value[-1]
|
| 127 |
-
if "가" <= last <= "힣" and (ord(last) - ord("가")) % 28:
|
| 128 |
-
return f"{value}으로 확인됨"
|
| 129 |
-
return f"{value}로 확인됨"
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
def load_clients():
|
| 133 |
-
if not DB_PATH:
|
| 134 |
-
return pd.DataFrame()
|
| 135 |
-
try:
|
| 136 |
-
conn = sqlite3.connect(DB_PATH)
|
| 137 |
-
cols = pd.read_sql_query("PRAGMA table_info(clients)", conn)["name"].tolist()
|
| 138 |
-
wanted = [
|
| 139 |
-
"id", "name", "masked_name", "gender", "birth_year", "care_grade",
|
| 140 |
-
"recognition_no", "recognition_number", "recognition_start_date", "recognition_end_date",
|
| 141 |
-
"institution_type", "institution_name", "main_condition", "cognitive_status",
|
| 142 |
-
"mobility_status", "diet_status", "toileting_status", "guardian_name", "guardian_phone",
|
| 143 |
-
"notes", "status",
|
| 144 |
-
]
|
| 145 |
-
select_cols = [c for c in wanted if c in cols]
|
| 146 |
-
if not select_cols:
|
| 147 |
-
conn.close()
|
| 148 |
-
return pd.DataFrame()
|
| 149 |
-
df = pd.read_sql_query("SELECT " + ", ".join(select_cols) + " FROM clients ORDER BY id DESC LIMIT 300", conn)
|
| 150 |
-
conn.close()
|
| 151 |
-
for col in wanted:
|
| 152 |
-
if col not in df.columns:
|
| 153 |
-
df[col] = ""
|
| 154 |
-
df["recognition_no"] = df["recognition_no"].where(df["recognition_no"].astype(str).str.strip() != "", df["recognition_number"])
|
| 155 |
-
return df
|
| 156 |
-
except Exception:
|
| 157 |
-
return pd.DataFrame()
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
def safe(row, key, default=""):
|
| 161 |
-
try:
|
| 162 |
-
value = row.get(key, default)
|
| 163 |
-
if pd.isna(value):
|
| 164 |
-
return default
|
| 165 |
-
return str(value)
|
| 166 |
-
except Exception:
|
| 167 |
-
return default
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
def mask_name(name):
|
| 171 |
-
name = str(name or "").strip()
|
| 172 |
-
if len(name) >= 3:
|
| 173 |
-
return name[0] + "○" + name[2:]
|
| 174 |
-
if len(name) == 2:
|
| 175 |
-
return name[0] + "○"
|
| 176 |
-
return name or "미입력"
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
def determine_plan_change(analysis):
|
| 180 |
-
if analysis["risk_level"] == "고위험(악화)":
|
| 181 |
-
return "급여제공계획 변경 검토 필요함"
|
| 182 |
-
if analysis["risk_level"] == "경고(주의)" and (analysis["bpsd"] or analysis["community_needed"]):
|
| 183 |
-
return "급여제공계획 부분 조정 검토 필요함"
|
| 184 |
-
return "현 급여제공계획 유지 가능하나 다음 제공 시 재확인 필요함"
|
| 185 |
-
|
| 186 |
-
|
| 187 |
-
def analyze_status_change(data):
|
| 188 |
-
combined = " ".join([
|
| 189 |
-
pick(data, "main_condition", "주요상태", "주요 질환 / 기존 상태"),
|
| 190 |
-
pick(data, "physical_note", "신체상태", "신체영역 변화"),
|
| 191 |
-
pick(data, "cognitive_note", "인지상태", "인지영역 변화"),
|
| 192 |
-
pick(data, "emotion_note", "감정상태", "감정영역 변화"),
|
| 193 |
-
pick(data, "relation_note", "관계상태", "관계영역 변화"),
|
| 194 |
-
pick(data, "resource_note", "지역자원", "지역자원연계", "지역자원연계 필요"),
|
| 195 |
-
pick(data, "field_memo", "현장메모", "현장 단순 메모"),
|
| 196 |
-
pick(data, "guardian_opinion", "보호자의견", "보호자 의견"),
|
| 197 |
-
])
|
| 198 |
-
|
| 199 |
-
domain_results = {}
|
| 200 |
-
for domain, keywords in DOMAIN_KEYWORDS.items():
|
| 201 |
-
if has_keyword(combined, keywords):
|
| 202 |
-
domain_results[domain] = True
|
| 203 |
-
|
| 204 |
-
bpsd = has_keyword(combined, BPSD_KEYWORDS)
|
| 205 |
-
depression = has_keyword(combined, DEPRESSION_KEYWORDS)
|
| 206 |
-
community_needed = has_keyword(combined, COMMUNITY_KEYWORDS)
|
| 207 |
-
|
| 208 |
-
high = has_keyword(combined, HIGH_RISK_KEYWORDS) and has_keyword(combined, ["악화", "반복", "지속", "응급", "입원", "감염", "공격", "배회", "식사거부"])
|
| 209 |
-
if high or (bpsd and has_keyword(combined, ["공격", "배회", "망상", "야간"])):
|
| 210 |
-
risk_level = "고위험(악화)"
|
| 211 |
-
elif has_keyword(combined, WARNING_KEYWORDS) or bpsd or depression or community_needed:
|
| 212 |
-
risk_level = "경고(주의)"
|
| 213 |
-
else:
|
| 214 |
-
risk_level = "안정(유지)"
|
| 215 |
-
|
| 216 |
-
flags = {
|
| 217 |
-
"욕구사정 재실시 필요": risk_level == "고위험(악화)" or depression,
|
| 218 |
-
"사례관리 연계 필요": risk_level == "고위험(악화)" or bpsd,
|
| 219 |
-
"급여제공계획 변경 필요": risk_level != "안정(유지)",
|
| 220 |
-
"보호자 상담 필요": bool(pick(data, "guardian_opinion", "보호자의견", "보호자 의견")) or risk_level != "안정(유지)",
|
| 221 |
-
}
|
| 222 |
-
return {
|
| 223 |
-
"combined": combined,
|
| 224 |
-
"domains": domain_results,
|
| 225 |
-
"bpsd": bpsd,
|
| 226 |
-
"depression": depression,
|
| 227 |
-
"community_needed": community_needed,
|
| 228 |
-
"risk_level": risk_level,
|
| 229 |
-
"flags": flags,
|
| 230 |
-
"plan_change": "",
|
| 231 |
-
}
|
| 232 |
-
|
| 233 |
-
|
| 234 |
-
def build_community_plan(analysis):
|
| 235 |
-
if not analysis["community_needed"]:
|
| 236 |
-
return "지역자원 연계는 즉시 필요성이 낮으나 독거, 결식, 복약 불이행, 주거환경 위험 발생 시 재검토 계획함"
|
| 237 |
-
return "보건소, 치매안심센터, 행정복지센터, 복지관, 방문간호 등 지역자원 연계를 검토하고 보호자 및 담당자와 공유 계획함"
|
| 238 |
-
|
| 239 |
-
|
| 240 |
-
def build_status_text(data):
|
| 241 |
-
analysis = analyze_status_change(data)
|
| 242 |
-
analysis["plan_change"] = determine_plan_change(analysis)
|
| 243 |
-
|
| 244 |
-
domain_lines = []
|
| 245 |
-
labels = {
|
| 246 |
-
"신체": pick(data, "physical_note", "신체상태", "신체영역 변화"),
|
| 247 |
-
"인지": pick(data, "cognitive_note", "인지상태", "인지영역 변화"),
|
| 248 |
-
"감정": pick(data, "emotion_note", "감정상태", "감정영역 변화"),
|
| 249 |
-
"관계": pick(data, "relation_note", "관계상태", "관계영역 변화"),
|
| 250 |
-
"지역자원연계": pick(data, "resource_note", "지역자원", "지역자원연계", "지역자원연계 필요"),
|
| 251 |
-
}
|
| 252 |
-
for domain, note in labels.items():
|
| 253 |
-
if analysis["domains"].get(domain) or note:
|
| 254 |
-
domain_lines.append(f"- {domain}영역: {formal_sentence(note, '관련 변화 가능성이 확인되어 관찰이 요구됨')}")
|
| 255 |
-
if not domain_lines:
|
| 256 |
-
domain_lines.append("- 신체·인지·정서 전반: 급격한 악화 단서는 제한적이나 정기 관찰이 요구됨")
|
| 257 |
-
|
| 258 |
-
bpsd_text = "BPSD 관련 단서가 확인되어 정면 반박을 지양하고 감정 공감, 짧은 설명, 화제 전환, 정서안정 지원을 실시함" if analysis["bpsd"] else "BPSD 고위험 단서는 제한적이나 인지·정서 변화 여부를 지속 관찰함"
|
| 259 |
-
depression_text = "우울 위험 단서가 확인되어 사회적 고립감, 식사거부, 무기력 지속 여부를 확인하고 정서지원 강화가 요구됨" if analysis["depression"] else "우울 고위험 단서는 제한적이나 정서표현 변화 여부를 관찰함"
|
| 260 |
-
community_text = build_community_plan(analysis)
|
| 261 |
-
flag_text = " / ".join([f"□ {name}" for name, enabled in analysis["flags"].items() if enabled]) or "□ 정기 관찰 유지"
|
| 262 |
-
|
| 263 |
-
soap = f"""
|
| 264 |
-
[SOAP 구조 기록]
|
| 265 |
-
S(주관적 정보): {formal_sentence(pick(data, 'guardian_opinion', '보호자의견', '보호자 의견') or pick(data, 'field_memo', '현장메모', '현장 단순 메모'), '수급자 및 보호자 호소는 추가 확인 예정임')}
|
| 266 |
-
O(객관적 관찰): {formal_sentence(pick(data, 'field_memo', '현장메모', '현장 단순 메모'), '서비스 제공 중 행동, 표정, 이동, 식사, 배변 등 상태 관찰이 요구됨')}
|
| 267 |
-
A(전문적 상태분석): 자동판정 결과 {analysis['risk_level']} 단계로 사정됨. {bpsd_text} {depression_text}
|
| 268 |
-
P(조치 및 계획): {analysis['plan_change']} {community_text}
|
| 269 |
-
""".strip()
|
| 270 |
-
|
| 271 |
-
content = f"""
|
| 272 |
-
[상태변화 자동판정]
|
| 273 |
-
판정: {analysis['risk_level']}
|
| 274 |
-
근거: 입력된 현장 메모, 보호자 의견, 수급자 기본상태를 종합하여 신체·인지·감정·관계·지역자원연계 영역으로 분석함.
|
| 275 |
-
|
| 276 |
-
[5대 영역 분석]
|
| 277 |
-
{chr(10).join(domain_lines)}
|
| 278 |
-
|
| 279 |
-
[BPSD 및 우울 위험 분석]
|
| 280 |
-
- 치매행동심리증상: {bpsd_text}
|
| 281 |
-
- 우울 위험: {depression_text}
|
| 282 |
-
|
| 283 |
-
[지역자원 연계계획]
|
| 284 |
-
{community_text}
|
| 285 |
-
|
| 286 |
-
{soap}
|
| 287 |
-
|
| 288 |
-
[평가 연계]
|
| 289 |
-
{flag_text}
|
| 290 |
-
급여제공계획 판단: {analysis['plan_change']}
|
| 291 |
-
""".strip()
|
| 292 |
-
return normalize_text(content), analysis
|
| 293 |
-
|
| 294 |
-
|
| 295 |
-
def evaluate_record(content, analysis, photo_count):
|
| 296 |
-
required = {
|
| 297 |
-
"자동판정": "판정:" in content,
|
| 298 |
-
"5대 영역": "5대 영역 분석" in content,
|
| 299 |
-
"SOAP": "SOAP 구조 기록" in content,
|
| 300 |
-
"BPSD": "BPSD" in content,
|
| 301 |
-
"지역자원": "지역자원" in content,
|
| 302 |
-
"평가 연계": "평가 연계" in content,
|
| 303 |
-
"사진첨부 확인": photo_count >= 0,
|
| 304 |
-
}
|
| 305 |
-
score = int(sum(required.values()) / len(required) * 100)
|
| 306 |
-
missing = [k for k, v in required.items() if not v]
|
| 307 |
-
if score >= 90:
|
| 308 |
-
comment = "평가 적합도가 높습니다. 상태판정, 영역분석, SOAP, 후속조치가 연결되어 있습니다."
|
| 309 |
-
elif score >= 75:
|
| 310 |
-
comment = "대체로 적합하나 일부 보완이 필요합니다."
|
| 311 |
-
else:
|
| 312 |
-
comment = "보완이 필요합니다. 상태변화 근거와 후속조치를 더 구체화해 주세요."
|
| 313 |
-
return score, missing, comment
|
| 314 |
-
|
| 315 |
-
|
| 316 |
-
def style_range(ws, cell_range, fill=None, font=None, alignment=None):
|
| 317 |
-
thin = Side(style="thin", color="000000")
|
| 318 |
-
border = Border(left=thin, right=thin, top=thin, bottom=thin)
|
| 319 |
-
for row in ws[cell_range]:
|
| 320 |
-
for cell in row:
|
| 321 |
-
cell.border = border
|
| 322 |
-
if fill:
|
| 323 |
-
cell.fill = fill
|
| 324 |
-
if font:
|
| 325 |
-
cell.font = font
|
| 326 |
-
if alignment:
|
| 327 |
-
cell.alignment = alignment
|
| 328 |
-
|
| 329 |
-
|
| 330 |
-
def generate_excel(data):
|
| 331 |
-
wb = Workbook()
|
| 332 |
-
ws = wb.active
|
| 333 |
-
ws.title = "상태변화기록지"
|
| 334 |
-
ws.sheet_view.showGridLines = False
|
| 335 |
-
ws.page_setup.orientation = "portrait"
|
| 336 |
-
ws.page_setup.fitToWidth = 1
|
| 337 |
-
ws.page_setup.fitToHeight = 0
|
| 338 |
-
ws.sheet_properties.pageSetUpPr.fitToPage = True
|
| 339 |
-
ws.page_margins.left = 0.3
|
| 340 |
-
ws.page_margins.right = 0.3
|
| 341 |
-
ws.page_margins.top = 0.4
|
| 342 |
-
ws.page_margins.bottom = 0.4
|
| 343 |
-
|
| 344 |
-
title_fill = PatternFill("solid", fgColor="DBEAFE")
|
| 345 |
-
header_fill = PatternFill("solid", fgColor="F3F4F6")
|
| 346 |
-
for col in range(1, 10):
|
| 347 |
-
ws.column_dimensions[get_column_letter(col)].width = 12
|
| 348 |
-
|
| 349 |
-
ws.merge_cells("A1:G2")
|
| 350 |
-
ws["A1"] = "상 태 변 화 기 록 지"
|
| 351 |
-
ws["A1"].font = Font(name="Malgun Gothic", size=24, bold=True)
|
| 352 |
-
ws["A1"].alignment = Alignment(horizontal="center", vertical="center")
|
| 353 |
-
ws["A1"].fill = title_fill
|
| 354 |
-
ws["H1"] = "담당자"
|
| 355 |
-
ws["I1"] = "시설장"
|
| 356 |
-
ws["H2"] = ""
|
| 357 |
-
ws["I2"] = ""
|
| 358 |
-
for cell in ["H1", "I1"]:
|
| 359 |
-
ws[cell].fill = header_fill
|
| 360 |
-
ws[cell].font = Font(name="Malgun Gothic", size=11, bold=True)
|
| 361 |
-
ws[cell].alignment = Alignment(horizontal="center", vertical="center")
|
| 362 |
-
style_range(ws, "A1:I2")
|
| 363 |
-
ws.row_dimensions[1].height = 28
|
| 364 |
-
ws.row_dimensions[2].height = 45
|
| 365 |
-
|
| 366 |
-
def info_row(row, a, b, c, d, e, f):
|
| 367 |
-
ws.cell(row, 1).value = a
|
| 368 |
-
ws.merge_cells(start_row=row, start_column=2, end_row=row, end_column=3)
|
| 369 |
-
ws.cell(row, 2).value = b
|
| 370 |
-
ws.cell(row, 4).value = c
|
| 371 |
-
ws.merge_cells(start_row=row, start_column=5, end_row=row, end_column=6)
|
| 372 |
-
ws.cell(row, 5).value = d
|
| 373 |
-
ws.cell(row, 7).value = e
|
| 374 |
-
ws.merge_cells(start_row=row, start_column=8, end_row=row, end_column=9)
|
| 375 |
-
ws.cell(row, 8).value = f
|
| 376 |
-
for col in [1, 4, 7]:
|
| 377 |
-
ws.cell(row, col).fill = header_fill
|
| 378 |
-
ws.cell(row, col).font = Font(name="Malgun Gothic", size=10, bold=True)
|
| 379 |
-
ws.cell(row, col).alignment = Alignment(horizontal="center", vertical="center", wrap_text=True)
|
| 380 |
-
for col in [2, 5, 8]:
|
| 381 |
-
ws.cell(row, col).font = Font(name="Malgun Gothic", size=10)
|
| 382 |
-
ws.cell(row, col).alignment = Alignment(horizontal="center", vertical="center", wrap_text=True)
|
| 383 |
-
style_range(ws, f"A{row}:I{row}")
|
| 384 |
-
ws.row_dimensions[row].height = 30
|
| 385 |
-
|
| 386 |
-
r = 3
|
| 387 |
-
info_row(r, "기관명", data["institution_name"], "기관유형", data["institution_type"], "작성일자", data["record_date"]); r += 1
|
| 388 |
-
info_row(r, "수급자", data["masked_name"], "장기요양등급", data["care_grade"], "판정", data["risk_level"]); r += 1
|
| 389 |
-
info_row(r, "인정번호", data["recognition_no"], "인정기간", data["recognition_period"], "사진첨부", data["photo_names"]); r += 1
|
| 390 |
-
|
| 391 |
-
sections = [
|
| 392 |
-
("상태변화 기록내용", data["content"], 180),
|
| 393 |
-
("평가 적합도 점검", data["compliance_result"], 55),
|
| 394 |
-
("부족한 항목", data["missing_items"], 45),
|
| 395 |
-
]
|
| 396 |
-
for title, body, min_height in sections:
|
| 397 |
-
ws.cell(r, 1).value = title
|
| 398 |
-
ws.cell(r, 1).fill = header_fill
|
| 399 |
-
ws.cell(r, 1).font = Font(name="Malgun Gothic", size=11, bold=True)
|
| 400 |
-
ws.cell(r, 1).alignment = Alignment(horizontal="center", vertical="center", wrap_text=True)
|
| 401 |
-
ws.merge_cells(start_row=r, start_column=2, end_row=r, end_column=9)
|
| 402 |
-
ws.cell(r, 2).value = body
|
| 403 |
-
ws.cell(r, 2).font = Font(name="Malgun Gothic", size=10)
|
| 404 |
-
ws.cell(r, 2).alignment = Alignment(horizontal="left", vertical="top", wrap_text=True)
|
| 405 |
-
lines = sum(max(1, len(line) // 55 + 1) for line in str(body).splitlines())
|
| 406 |
-
ws.row_dimensions[r].height = max(min_height, min(360, lines * 19 + 20))
|
| 407 |
-
style_range(ws, f"A{r}:I{r}")
|
| 408 |
-
r += 1
|
| 409 |
-
|
| 410 |
-
# 공통 안내문구 추가
|
| 411 |
-
ws.merge_cells(start_row=r, start_column=1, end_row=r, end_column=9)
|
| 412 |
-
ws.cell(r, 1).value = "※ 생성된 문서는 그대로 사용하셔도 되며, 기관의 운영방침, 수급자 상태, 보호자 의견 및 평가기준에 따라 추가 보완사항은 얼마든지 수정 가능합니다. 기관의 상황에 맞게 활용하시기 바랍니다."
|
| 413 |
-
style_range(ws, f"A{r}:I{r}",
|
| 414 |
-
font=Font(name="Malgun Gothic", size=8.5, color="595959", italic=True),
|
| 415 |
-
alignment=Alignment(horizontal="left", vertical="center", wrap_text=True))
|
| 416 |
-
ws.row_dimensions[r].height = 36
|
| 417 |
-
r += 1
|
| 418 |
-
|
| 419 |
-
hidden = wb.create_sheet("내부점검자료_출력제외")
|
| 420 |
-
hidden.sheet_state = "hidden"
|
| 421 |
-
hidden["A1"] = "구분"
|
| 422 |
-
hidden["B1"] = "내용"
|
| 423 |
-
rows = [
|
| 424 |
-
("평가적합도", f"{data['evaluation_score']}점"),
|
| 425 |
-
("부족항목", data["missing_items"]),
|
| 426 |
-
("자동판정", data["risk_level"]),
|
| 427 |
-
("사진첨부", data["photo_names"]),
|
| 428 |
-
]
|
| 429 |
-
for idx, (a, b) in enumerate(rows, 2):
|
| 430 |
-
hidden.cell(idx, 1).value = a
|
| 431 |
-
hidden.cell(idx, 2).value = b
|
| 432 |
-
hidden.column_dimensions["A"].width = 20
|
| 433 |
-
hidden.column_dimensions["B"].width = 90
|
| 434 |
-
|
| 435 |
-
output = BytesIO()
|
| 436 |
-
wb.save(output)
|
| 437 |
-
output.seek(0)
|
| 438 |
-
return output
|
| 439 |
-
|
| 440 |
-
|
| 441 |
-
clients_df = load_clients()
|
| 442 |
-
if DB_PATH:
|
| 443 |
-
st.sidebar.success("Client Center DB 연결됨")
|
| 444 |
-
st.sidebar.caption(DB_PATH)
|
| 445 |
-
else:
|
| 446 |
-
st.sidebar.warning("Client Center DB를 찾지 못해 직접입력으로 실행됩니다.")
|
| 447 |
-
|
| 448 |
-
mode = st.radio("입력 방식", ["Client Center에서 수급자 선택", "직접 입력"], horizontal=True)
|
| 449 |
-
selected_client = None
|
| 450 |
-
if mode == "Client Center에서 수급자 선택" and not clients_df.empty:
|
| 451 |
-
options = clients_df.apply(lambda x: f"{x['id']}번 - {x['masked_name']} / {x['care_grade']} / {x['institution_type']} / {x['status']}", axis=1).tolist()
|
| 452 |
-
option = st.selectbox("수급자 선택", options)
|
| 453 |
-
selected_id = int(option.split("번")[0])
|
| 454 |
-
selected_client = clients_df[clients_df["id"] == selected_id].iloc[0]
|
| 455 |
-
elif mode == "Client Center에서 수급자 선택":
|
| 456 |
-
st.warning("등록된 수급자 정보가 없거나 DB를 찾지 못했습니다. 직접 입력으로 작성할 수 있습니다.")
|
| 457 |
-
|
| 458 |
-
if selected_client is not None:
|
| 459 |
-
default_name = safe(selected_client, "name")
|
| 460 |
-
default_masked = safe(selected_client, "masked_name") or mask_name(default_name)
|
| 461 |
-
default_grade = safe(selected_client, "care_grade", "3등급")
|
| 462 |
-
default_inst = safe(selected_client, "institution_type", "방문요양")
|
| 463 |
-
default_inst_name = safe(selected_client, "institution_name")
|
| 464 |
-
default_main = safe(selected_client, "main_condition")
|
| 465 |
-
default_cog = safe(selected_client, "cognitive_status")
|
| 466 |
-
default_mob = safe(selected_client, "mobility_status")
|
| 467 |
-
default_diet = safe(selected_client, "diet_status")
|
| 468 |
-
default_toilet = safe(selected_client, "toileting_status")
|
| 469 |
-
recognition_no = safe(selected_client, "recognition_no") or safe(selected_client, "recognition_number") or "미입력"
|
| 470 |
-
recognition_period = " ~ ".join([x for x in [safe(selected_client, "recognition_start_date"), safe(selected_client, "recognition_end_date")] if x]) or "미입력"
|
| 471 |
-
else:
|
| 472 |
-
default_name = "홍길동"
|
| 473 |
-
default_masked = "홍○동"
|
| 474 |
-
default_grade = "3등급"
|
| 475 |
-
default_inst = "방문요양"
|
| 476 |
-
default_inst_name = ""
|
| 477 |
-
default_main = ""
|
| 478 |
-
default_cog = ""
|
| 479 |
-
default_mob = ""
|
| 480 |
-
default_diet = ""
|
| 481 |
-
default_toilet = ""
|
| 482 |
-
recognition_no = "미입력"
|
| 483 |
-
recognition_period = "미입력"
|
| 484 |
-
|
| 485 |
-
st.subheader("1단계. 기본정보")
|
| 486 |
-
col1, col2, col3, col4 = st.columns(4)
|
| 487 |
-
name = col1.text_input("수급자 성명", default_name)
|
| 488 |
-
masked_name = col2.text_input("가명 표기", default_masked or mask_name(name))
|
| 489 |
-
care_grade = col3.selectbox("장기요양등급", ["1등급", "2등급", "3등급", "4등급", "5등급", "인지지원등급", "미입력"], index=2 if default_grade not in ["1등급", "2등급", "4등급", "5등급", "인지지원등급", "미입력"] else ["1등급", "2등급", "3등급", "4등급", "5등급", "인지지원등급", "미입력"].index(default_grade))
|
| 490 |
-
record_date = col4.date_input("작성일자", value=date.today())
|
| 491 |
-
|
| 492 |
-
col5, col6 = st.columns(2)
|
| 493 |
-
institution_type = col5.selectbox("기관유형", ["방문요양", "방문목욕", "방문간호", "주간보호", "요양원", "공동생활가정"], index=["방문요양", "방문목욕", "방문간호", "주간보호", "요양원", "공동생활가정"].index(default_inst) if default_inst in ["방문요양", "방문목욕", "방문간호", "주간보호", "요양원", "공동생활가정"] else 0)
|
| 494 |
-
institution_name = col6.text_input("기관명", default_inst_name)
|
| 495 |
-
|
| 496 |
-
st.subheader("2단계. 상태변화 입력")
|
| 497 |
-
c1, c2 = st.columns(2)
|
| 498 |
-
main_condition = c1.text_area("주요 질환 / 기존 상태", value=default_main, height=85)
|
| 499 |
-
physical_note = c2.text_area("신체영역 변화", value=f"{default_mob} {default_diet} {default_toilet}".strip(), height=85, placeholder="예: 보행 시 휘청거림, 식사량 감소, 요실금 증가")
|
| 500 |
-
c3, c4 = st.columns(2)
|
| 501 |
-
cognitive_note = c3.text_area("인지영역 변화", value=default_cog, height=85, placeholder="예: 반복질문, 귀가욕구, 도둑망상, 배회")
|
| 502 |
-
emotion_note = c4.text_area("감정영역 변화", height=85, placeholder="예: 우울, 눈물, 무기력, 불안")
|
| 503 |
-
c5, c6 = st.columns(2)
|
| 504 |
-
relation_note = c5.text_area("관계영역 변화", height=85, placeholder="예: 타 수급자와 말다툼, 보호자 갈등")
|
| 505 |
-
resource_note = c6.text_area("지역자원연계 필요", height=85, placeholder="예: 독거, 결식, 복약 불이행, 경제적 어려움")
|
| 506 |
-
field_memo = st.text_area("현장 단순 메모", height=100, placeholder="예: 오후에 도둑년이라고 욕하고 화냄. 식사 절반만 드심.")
|
| 507 |
-
guardian_opinion = st.text_area("보호자 의견", height=85, placeholder="예: 최근 밤에 잠을 못 자고 배회가 늘어 걱정된다고 함")
|
| 508 |
-
|
| 509 |
-
photo_files = st.file_uploader("사진첨부", type=["png", "jpg", "jpeg"], accept_multiple_files=True)
|
| 510 |
-
photo_names = ", ".join([f.name for f in photo_files]) if photo_files else "첨부 없음"
|
| 511 |
-
|
| 512 |
-
if st.button("📈 상태변화기록지 생성", use_container_width=True):
|
| 513 |
-
input_data = {
|
| 514 |
-
"main_condition": main_condition,
|
| 515 |
-
"physical_note": physical_note,
|
| 516 |
-
"cognitive_note": cognitive_note,
|
| 517 |
-
"emotion_note": emotion_note,
|
| 518 |
-
"relation_note": relation_note,
|
| 519 |
-
"resource_note": resource_note,
|
| 520 |
-
"field_memo": field_memo,
|
| 521 |
-
"guardian_opinion": guardian_opinion,
|
| 522 |
-
}
|
| 523 |
-
content, analysis = build_status_text(input_data)
|
| 524 |
-
score, missing, result = evaluate_record(content, analysis, len(photo_files or []))
|
| 525 |
-
st.session_state["status_change_data"] = {
|
| 526 |
-
"name": name,
|
| 527 |
-
"masked_name": masked_name,
|
| 528 |
-
"care_grade": care_grade,
|
| 529 |
-
"institution_type": institution_type,
|
| 530 |
-
"institution_name": institution_name,
|
| 531 |
-
"record_date": str(record_date),
|
| 532 |
-
"recognition_no": recognition_no,
|
| 533 |
-
"recognition_period": recognition_period,
|
| 534 |
-
"risk_level": analysis["risk_level"],
|
| 535 |
-
"content": content,
|
| 536 |
-
"evaluation_score": score,
|
| 537 |
-
"missing_items": ", ".join(missing) if missing else "부족 항목 없음",
|
| 538 |
-
"compliance_result": result,
|
| 539 |
-
"photo_names": photo_names,
|
| 540 |
-
}
|
| 541 |
-
st.session_state.pop("status_change_excel", None)
|
| 542 |
-
|
| 543 |
-
if "status_change_data" in st.session_state:
|
| 544 |
-
data = st.session_state["status_change_data"]
|
| 545 |
-
st.markdown("---")
|
| 546 |
-
st.subheader("3단계. 생성 결과 확인/수정")
|
| 547 |
-
data["content"] = st.text_area("상태변화 기록내용", value=data["content"], height=340)
|
| 548 |
-
a, b, c = st.columns(3)
|
| 549 |
-
a.metric("자동판정", data["risk_level"])
|
| 550 |
-
b.metric("평가 적합도", f"{data['evaluation_score']}점")
|
| 551 |
-
c.metric("결재라인", "담당자/시설장 2칸")
|
| 552 |
-
if data["evaluation_score"] >= 90:
|
| 553 |
-
st.markdown(f'<div class="okbox">{data["compliance_result"]}</div>', unsafe_allow_html=True)
|
| 554 |
-
else:
|
| 555 |
-
st.markdown(f'<div class="warnbox">{data["compliance_result"]}</div>', unsafe_allow_html=True)
|
| 556 |
-
|
| 557 |
-
if st.button("📦 엑셀 파일 준비하기", use_container_width=True):
|
| 558 |
-
st.session_state["status_change_excel"] = generate_excel(data)
|
| 559 |
-
st.success("엑셀 파일이 준비되었습니다.")
|
| 560 |
-
|
| 561 |
-
if "status_change_excel" in st.session_state:
|
| 562 |
-
filename = f"상태변화기록지_{data['masked_name']}_{datetime.now().strftime('%Y%m%d')}.xlsx"
|
| 563 |
-
st.download_button(
|
| 564 |
-
"📥 상태변화기록지 엑셀 다운로드",
|
| 565 |
-
data=st.session_state["status_change_excel"],
|
| 566 |
-
file_name=filename,
|
| 567 |
-
mime="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
| 568 |
-
use_container_width=True,
|
| 569 |
-
)
|
| 570 |
-
|
| 571 |
-
st.caption("상태변화기록지 최종본: Client Center 연동, 직접입력, 사진첨부, 엑셀 다운로드, 결재라인 2칸, 내부점검 숨김시트를 포함합니다.")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|