feat: 환산계수 모달 구현 전 백업

This commit is contained in:
thug0bin 2026-03-12 10:14:17 +09:00
parent e254c5c23d
commit 9531b74d0e
5 changed files with 517 additions and 26 deletions

View File

@ -3906,6 +3906,53 @@ def api_products():
return jsonify({'success': False, 'error': str(e)}), 500
# ==================== 건조시럽 환산계수 API ====================
@app.route('/api/drug-info/conversion-factor/<sung_code>')
def api_conversion_factor(sung_code):
"""
건조시럽 환산계수 조회 API
PostgreSQL drysyrup 테이블에서 SUNG_CODE로 환산계수 조회
mL g 변환에 사용 (: 120ml * 0.11 = 13.2g)
Args:
sung_code: 성분코드 (: "535000ASY")
Returns:
{
"sung_code": "535000ASY",
"conversion_factor": 0.11,
"ingredient_name": "아목시실린수화물·클라불란산칼륨",
"product_name": "일성오구멘틴듀오시럽 228mg/5ml"
}
연결 실패/데이터 없음 :
{"sung_code": "...", "conversion_factor": null, ...}
"""
try:
result = db_manager.get_conversion_factor(sung_code)
return jsonify({
'success': True,
'sung_code': sung_code,
'conversion_factor': result['conversion_factor'],
'ingredient_name': result['ingredient_name'],
'product_name': result['product_name']
})
except Exception as e:
logging.error(f"환산계수 조회 오류 (SUNG_CODE={sung_code}): {e}")
# 에러 발생해도 null 반환 (서비스 중단 방지)
return jsonify({
'success': True,
'sung_code': sung_code,
'conversion_factor': None,
'ingredient_name': None,
'product_name': None
})
# ==================== 입고이력 API ====================
@app.route('/api/drugs/<drug_code>/purchase-history')

View File

@ -2,6 +2,8 @@
PIT3000 Database Setup
SQLAlchemy 기반 데이터베이스 연결 스키마 정의
Windows/Linux 크로스 플랫폼 지원
PostgreSQL 지원 추가: 건조시럽 환산계수 조회 (drysyrup 테이블)
"""
from sqlalchemy import create_engine, MetaData, text
@ -87,6 +89,9 @@ class DatabaseConfig:
# URL 인코딩된 드라이버
DRIVER_ENCODED = urllib.parse.quote_plus(DRIVER)
# PostgreSQL 연결 정보 (건조시럽 환산계수 DB)
POSTGRES_URL = "postgresql+psycopg2://admin:trajet6640@192.168.0.39:5432/label10"
# 데이터베이스별 연결 문자열 (동적 드라이버 사용)
@classmethod
@ -135,6 +140,10 @@ class DatabaseManager:
# SQLite 연결 추가
self.sqlite_conn = None
self.sqlite_db_path = Path(__file__).parent / 'mileage.db'
# PostgreSQL 연결 (건조시럽 환산계수)
self.postgres_engine = None
self.postgres_session = None
def get_engine(self, database='PM_BASE'):
"""특정 데이터베이스 엔진 반환"""
@ -220,6 +229,127 @@ class DatabaseManager:
# 새 세션 생성
return self.get_session(database)
# ─────────────────────────────────────────────────────────────
# PostgreSQL 연결 (건조시럽 환산계수)
# ─────────────────────────────────────────────────────────────
def get_postgres_engine(self):
"""
PostgreSQL 엔진 반환 (건조시럽 환산계수 DB)
Returns:
Engine 또는 None (연결 실패 )
"""
if self.postgres_engine is not None:
return self.postgres_engine
try:
self.postgres_engine = create_engine(
DatabaseConfig.POSTGRES_URL,
pool_size=5,
max_overflow=5,
pool_timeout=30,
pool_recycle=1800,
pool_pre_ping=True,
echo=False
)
# 연결 테스트
with self.postgres_engine.connect() as conn:
conn.execute(text("SELECT 1"))
print("[DB Manager] PostgreSQL 연결 성공")
return self.postgres_engine
except Exception as e:
print(f"[DB Manager] PostgreSQL 연결 실패 (무시됨): {e}")
self.postgres_engine = None
return None
def get_postgres_session(self):
"""
PostgreSQL 세션 반환 (건조시럽 환산계수 조회용)
Returns:
Session 또는 None (연결 실패 )
"""
engine = self.get_postgres_engine()
if engine is None:
return None
if self.postgres_session is None:
try:
Session = sessionmaker(bind=engine)
self.postgres_session = Session()
except Exception as e:
print(f"[DB Manager] PostgreSQL 세션 생성 실패: {e}")
return None
else:
# 세션 상태 체크
try:
self.postgres_session.execute(text("SELECT 1"))
except Exception as e:
print(f"[DB Manager] PostgreSQL 세션 복구 시도: {e}")
try:
self.postgres_session.rollback()
except:
pass
try:
self.postgres_session.close()
except:
pass
try:
Session = sessionmaker(bind=engine)
self.postgres_session = Session()
except:
self.postgres_session = None
return None
return self.postgres_session
def get_conversion_factor(self, sung_code):
"""
건조시럽 환산계수 조회
Args:
sung_code: SUNG_CODE (: "535000ASY")
Returns:
dict: {
'conversion_factor': float 또는 None,
'ingredient_name': str 또는 None,
'product_name': str 또는 None
}
"""
result = {
'conversion_factor': None,
'ingredient_name': None,
'product_name': None
}
session = self.get_postgres_session()
if session is None:
return result
try:
query = text("""
SELECT conversion_factor, ingredient_name, product_name
FROM drysyrup
WHERE ingredient_code = :sung_code
LIMIT 1
""")
row = session.execute(query, {'sung_code': sung_code}).fetchone()
if row:
result['conversion_factor'] = float(row[0]) if row[0] is not None else None
result['ingredient_name'] = row[1]
result['product_name'] = row[2]
except Exception as e:
print(f"[DB Manager] 환산계수 조회 실패 (SUNG_CODE={sung_code}): {e}")
# 세션 롤백
try:
session.rollback()
except:
pass
return result
def get_sqlite_connection(self, new_connection=False):
"""
SQLite mileage.db 연결 반환
@ -442,6 +572,20 @@ class DatabaseManager:
if self.sqlite_conn:
self.sqlite_conn.close()
self.sqlite_conn = None
# PostgreSQL 연결 종료
if self.postgres_session:
try:
self.postgres_session.close()
except:
pass
self.postgres_session = None
if self.postgres_engine:
try:
self.postgres_engine.dispose()
except:
pass
self.postgres_engine = None
# 전역 데이터베이스 매니저 인스턴스
db_manager = DatabaseManager()

View File

@ -571,6 +571,7 @@ def preview_label():
- frequency: 복용 횟수
- duration: 복용 일수
- unit: 단위 (, 캡슐, mL )
- sung_code: 성분코드 (환산계수 조회용, 선택)
"""
try:
data = request.get_json()
@ -582,6 +583,17 @@ def preview_label():
frequency = int(data.get('frequency', 0))
duration = int(data.get('duration', 0))
unit = data.get('unit', '')
sung_code = data.get('sung_code', '')
# 환산계수 조회 (sung_code가 있는 경우)
conversion_factor = None
if sung_code:
try:
from db.dbsetup import db_manager
cf_result = db_manager.get_conversion_factor(sung_code)
conversion_factor = cf_result.get('conversion_factor')
except Exception as cf_err:
logging.warning(f"환산계수 조회 실패 (무시): {cf_err}")
# 라벨 이미지 생성
image = create_label_image(
@ -591,7 +603,8 @@ def preview_label():
dosage=dosage,
frequency=frequency,
duration=duration,
unit=unit
unit=unit,
conversion_factor=conversion_factor
)
# Base64 인코딩
@ -602,7 +615,8 @@ def preview_label():
return jsonify({
'success': True,
'image': f'data:image/png;base64,{img_base64}'
'image': f'data:image/png;base64,{img_base64}',
'conversion_factor': conversion_factor
})
except Exception as e:
@ -679,9 +693,14 @@ def draw_scissor_border(draw, width, height, edge_size=5, steps=20):
draw.line(right_points, fill="black", width=2)
def create_label_image(patient_name, med_name, add_info='', dosage=0, frequency=0, duration=0, unit=''):
def create_label_image(patient_name, med_name, add_info='', dosage=0, frequency=0, duration=0, unit='', conversion_factor=None):
"""
라벨 이미지 생성 (29mm 용지 기준) - 레거시 디자인 적용
Args:
conversion_factor: 건조시럽 환산계수 (mLg 변환용, 선택)
- : 0.11이면 120ml * 0.11 = 13.2g
- 총량 옆에 괄호로 표시: "총120mL (13.2g)/5일분"
"""
# 약품명 정제 (밀리그램 → mg 등)
med_name = normalize_medication_name(med_name)
@ -811,11 +830,21 @@ def create_label_image(patient_name, med_name, add_info='', dosage=0, frequency=
y += 5
# 총량 계산 및 표시
# 총량 계산 및 표시 (환산계수 반영)
if dosage > 0 and frequency > 0 and duration > 0:
total = dosage * frequency * duration
total_str = str(int(total)) if total == int(total) else f"{total:.2f}".rstrip('0').rstrip('.')
total_text = f"{total_str}{unit}/{duration}일분"
# 환산계수가 있으면 변환된 총량도 표시 (예: "총120mL (13.2g)/5일분")
if conversion_factor is not None and conversion_factor > 0:
converted_total = total * conversion_factor
if converted_total == int(converted_total):
converted_str = str(int(converted_total))
else:
converted_str = f"{converted_total:.2f}".rstrip('0').rstrip('.')
total_text = f"{total_str}{unit} ({converted_str}g)/{duration}일분"
else:
total_text = f"{total_str}{unit}/{duration}일분"
y = draw_centered(total_text, y, additional_font)
y += 5

View File

@ -277,6 +277,47 @@
background: rgba(255,255,255,0.35);
transform: scale(1.05);
}
/* 키오스크 스타일 전화번호 입력 */
.phone-input-kiosk {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
margin: 20px 0;
}
.phone-input-kiosk .phone-prefix {
font-size: 28px;
font-weight: 700;
color: #4c1d95;
background: #f3e8ff;
padding: 12px 16px;
border-radius: 10px;
}
.phone-input-kiosk .phone-hyphen {
font-size: 28px;
font-weight: 300;
color: #9ca3af;
}
.phone-input-kiosk input {
width: 80px;
font-size: 28px;
font-weight: 600;
text-align: center;
padding: 12px 8px;
border: 2px solid #e2e8f0;
border-radius: 10px;
transition: all 0.2s;
}
.phone-input-kiosk input:focus {
border-color: #f59e0b;
outline: none;
box-shadow: 0 0 0 3px rgba(245,158,11,0.2);
}
.phone-input-kiosk input::placeholder {
color: #d1d5db;
font-weight: 400;
}
.detail-header .cusetc-inline .cusetc-label {
font-weight: 600;
margin-right: 6px;
@ -1336,17 +1377,22 @@
</div>
</div>
<!-- 전화번호 모달 -->
<!-- 전화번호 모달 (키오스크 스타일) -->
<div class="cusetc-modal" id="phoneModal">
<div class="cusetc-modal-content">
<div class="cusetc-modal-content" style="max-width:360px;">
<div class="cusetc-modal-header">
<h3>📞 환자 전화번호</h3>
<h3>📞 전화번호 입력</h3>
<button class="cusetc-modal-close" onclick="closePhoneModal()">×</button>
</div>
<div class="cusetc-modal-body">
<div class="cusetc-patient-info" id="phonePatientInfo"></div>
<input type="tel" id="phoneInput" placeholder="010-0000-0000" style="width:100%;padding:12px;border:2px solid #e2e8f0;border-radius:8px;font-size:16px;text-align:center;">
<div class="cusetc-hint">💡 하이픈(-) 포함해서 입력하세요</div>
<div class="phone-input-kiosk">
<span class="phone-prefix">010</span>
<span class="phone-hyphen">-</span>
<input type="tel" id="phoneMid" maxlength="4" placeholder="0000" inputmode="numeric" pattern="[0-9]*">
<span class="phone-hyphen">-</span>
<input type="tel" id="phoneLast" maxlength="4" placeholder="0000" inputmode="numeric" pattern="[0-9]*">
</div>
</div>
<div class="cusetc-modal-footer">
<button class="cusetc-btn-cancel" onclick="closePhoneModal()">취소</button>
@ -1614,7 +1660,7 @@
</thead>
<tbody>
${data.medications.map((m, i) => `
<tr data-add-info="${escapeHtml(m.add_info || '')}" data-unit="${m.unit || '정'}" data-med-name="${escapeHtml(m.med_name || m.medication_code)}" ${m.is_substituted ? 'class="substituted-row"' : ''}>
<tr data-add-info="${escapeHtml(m.add_info || '')}" data-unit="${m.unit || '정'}" data-sung-code="${m.sung_code || ''}" data-med-name="${escapeHtml(m.med_name || m.medication_code)}" ${m.is_substituted ? 'class="substituted-row"' : ''}>
<td><input type="checkbox" class="med-check" data-code="${m.medication_code}" ${m.is_auto_print ? 'checked' : ''}></td>
<td>
<div class="med-name">
@ -1871,7 +1917,7 @@
const disabled = m.status === 'removed' ? 'disabled' : '';
return `
<tr class="${rowClass}" data-add-info="${escapeHtml(m.add_info || '')}" data-unit="${m.unit || '정'}">
<tr class="${rowClass}" data-add-info="${escapeHtml(m.add_info || '')}" data-unit="${m.unit || '정'}" data-sung-code="${m.sung_code || ''}">
<td><input type="checkbox" class="med-check" data-code="${m.medication_code}" ${disabled}></td>
<td>
<div class="med-name"><span class="med-num">${i+1}</span>${m.med_name || m.medication_code}</div>
@ -1909,7 +1955,7 @@
</thead>
<tbody>
${currentMedications.map((m, i) => `
<tr data-add-info="${escapeHtml(m.add_info || '')}" data-unit="${m.unit || '정'}">
<tr data-add-info="${escapeHtml(m.add_info || '')}" data-unit="${m.unit || '정'}" data-sung-code="${m.sung_code || ''}">
<td><input type="checkbox" class="med-check" data-code="${m.medication_code}"></td>
<td>
<div class="med-name"><span class="med-num">${i+1}</span>${m.med_name || m.medication_code}</div>
@ -2120,7 +2166,7 @@
// 전화번호 모달 함수들
// ─────────────────────────────────────────────────────────────
function openPhoneModal() {
window.openPhoneModal = function() {
if (!currentPrescriptionData) {
alert('❌ 먼저 환자를 선택하세요.');
return;
@ -2128,30 +2174,59 @@
const modal = document.getElementById('phoneModal');
const patientInfo = document.getElementById('phonePatientInfo');
const input = document.getElementById('phoneInput');
const phoneMid = document.getElementById('phoneMid');
const phoneLast = document.getElementById('phoneLast');
patientInfo.innerHTML = `
<strong>${currentPrescriptionData.name || '환자'}</strong>
<span style="margin-left: 10px; color: #6b7280;">고객코드: ${currentPrescriptionData.cus_code || '-'}</span>
`;
input.value = currentPrescriptionData.phone || '';
// 기존 전화번호 파싱 (010-1234-5678 또는 01012345678)
const existingPhone = currentPrescriptionData.phone || '';
const digits = existingPhone.replace(/\D/g, '');
if (digits.length >= 10) {
phoneMid.value = digits.slice(3, 7);
phoneLast.value = digits.slice(7, 11);
} else {
phoneMid.value = '';
phoneLast.value = '';
}
modal.style.display = 'flex';
input.focus();
}
phoneMid.focus();
// 4자리 입력 시 자동 포커스 이동
phoneMid.oninput = function() {
this.value = this.value.replace(/\D/g, '');
if (this.value.length >= 4) phoneLast.focus();
};
phoneLast.oninput = function() {
this.value = this.value.replace(/\D/g, '');
};
};
function closePhoneModal() {
window.closePhoneModal = function() {
document.getElementById('phoneModal').style.display = 'none';
}
};
async function savePhone() {
window.savePhone = async function() {
if (!currentPrescriptionData || !currentPrescriptionData.cus_code) {
alert('❌ 환자 정보가 없습니다.');
return;
}
const input = document.getElementById('phoneInput');
const newPhone = input.value.trim();
const phoneMid = document.getElementById('phoneMid').value.trim();
const phoneLast = document.getElementById('phoneLast').value.trim();
// 유효성 검사
if (phoneMid.length !== 4 || phoneLast.length !== 4) {
alert('❌ 전화번호 8자리를 모두 입력해주세요.');
return;
}
// 010-XXXX-XXXX 형식으로 조합
const newPhone = `010-${phoneMid}-${phoneLast}`;
const cusCode = currentPrescriptionData.cus_code;
try {
@ -2174,7 +2249,7 @@
} catch (err) {
alert('❌ 오류: ' + err.message);
}
}
};
function updatePhoneBadge(phone) {
const detailInfo = document.getElementById('detailInfo');
@ -2738,8 +2813,10 @@
// 단위: data-unit 속성에서 가져오기 (SUNG_CODE 기반 자동 판별)
const unit = tr.dataset.unit || '정';
// 성분코드: 환산계수 조회용
const sungCode = tr.dataset.sungCode || '';
console.log('Preview data:', { patientName, medName, addInfo, dosage, frequency, duration, unit });
console.log('Preview data:', { patientName, medName, addInfo, dosage, frequency, duration, unit, sungCode });
try {
const res = await fetch('/pmr/api/label/preview', {
@ -2752,7 +2829,8 @@
dosage: dosage,
frequency: frequency,
duration: duration,
unit: unit
unit: unit,
sung_code: sungCode
})
});
const data = await res.json();

193
docs/DRYSYRUP_CONVERSION.md Normal file
View File

@ -0,0 +1,193 @@
# 건조시럽 환산계수 기능
## 개요
건조시럽(dry syrup)은 물로 희석하여 복용하는 시럽 형태의 의약품입니다. 복용량을 mL로 표시하지만, 실제 약 성분의 양은 g(그램)으로 환산해야 정확합니다.
**환산계수(conversion_factor)**를 사용하여 총 복용량(mL)을 실제 성분량(g)으로 변환합니다.
### 예시
- 오구멘틴듀오시럽 228mg/5ml
- 환산계수: 0.11
- 총량 120mL × 0.11 = **13.2g**
## 아키텍처
```
┌─────────────────────────────────────────────────────────────┐
│ Flask Backend (7001) │
├─────────────────────────────────────────────────────────────┤
│ /api/drug-info/conversion-factor/<sung_code>
│ /pmr/api/label/preview (sung_code 파라미터 추가) │
├─────────────────────────────────────────────────────────────┤
│ DatabaseManager │
│ ├── MSSQL (192.168.0.4) - PIT3000 │
│ │ └── CD_GOODS.SUNG_CODE (성분코드) │
│ ├── PostgreSQL (192.168.0.39:5432/label10) │
│ │ └── drysyrup 테이블 (환산계수 23건) │
│ └── SQLite - 마일리지 등 │
└─────────────────────────────────────────────────────────────┘
```
## 데이터베이스
### PostgreSQL 연결 정보
```
Host: 192.168.0.39
Port: 5432
Database: label10
User: admin
Password: trajet6640
```
### drysyrup 테이블 스키마
| 컬럼명 | 타입 | 설명 |
|--------|------|------|
| ingredient_code | VARCHAR | 성분코드 (SUNG_CODE와 매칭) |
| conversion_factor | DECIMAL | 환산계수 (mL → g) |
| ingredient_name | VARCHAR | 성분명 |
| product_name | VARCHAR | 대표 제품명 |
### 매핑 관계
- MSSQL `PM_DRUG.CD_GOODS.SUNG_CODE` = PostgreSQL `drysyrup.ingredient_code`
## API 명세
### 1. 환산계수 조회 API
**Endpoint:** `GET /api/drug-info/conversion-factor/<sung_code>`
**응답 (성공):**
```json
{
"success": true,
"sung_code": "535000ASY",
"conversion_factor": 0.11,
"ingredient_name": "아목시실린수화물·클라불란산칼륨",
"product_name": "일성오구멘틴듀오시럽 228mg/5ml"
}
```
**응답 (데이터 없음/연결 실패):**
```json
{
"success": true,
"sung_code": "NOTEXIST",
"conversion_factor": null,
"ingredient_name": null,
"product_name": null
}
```
> ⚠️ 연결 실패나 데이터 없음에도 에러 없이 null 반환 (서비스 안정성 우선)
### 2. 라벨 미리보기 API (확장)
**Endpoint:** `POST /pmr/api/label/preview`
**Request Body:**
```json
{
"patient_name": "홍길동",
"med_name": "오구멘틴듀오시럽",
"dosage": 8,
"frequency": 3,
"duration": 5,
"unit": "mL",
"sung_code": "535000ASY"
}
```
**Response:**
```json
{
"success": true,
"image": "data:image/png;base64,...",
"conversion_factor": 0.11
}
```
### 라벨 출력 예시
환산계수가 있는 경우:
```
총120mL (13.2g)/5일분
```
환산계수가 없는 경우:
```
총120mL/5일분
```
## 코드 위치
### 수정된 파일
1. **dbsetup.py** - PostgreSQL 연결 관리
- `DatabaseConfig.POSTGRES_URL` 추가
- `DatabaseManager.get_postgres_engine()` 추가
- `DatabaseManager.get_postgres_session()` 추가
- `DatabaseManager.get_conversion_factor(sung_code)` 추가
2. **app.py** - 환산계수 조회 API
- `GET /api/drug-info/conversion-factor/<sung_code>`
3. **pmr_api.py** - 라벨 미리보기
- `preview_label()` - sung_code 파라미터 추가
- `create_label_image()` - conversion_factor 파라미터 추가
## 유료/무료 버전 구분 설계 (추후)
환산계수는 추후 유료 기능으로 분리 가능합니다.
### 설계 방안
1. **라이선스 체크**
- 환산계수 조회 전 라이선스 확인
- 무료 버전: `conversion_factor: null` 반환
- 유료 버전: 실제 값 반환
2. **API 분리**
- `/api/drug-info/conversion-factor` → 유료 전용
- 무료 버전은 API 자체를 비활성화
3. **현재 구현**
- 환산계수가 없어도 라벨 출력 정상 동작
- null 체크 후 기존 포맷 유지
## 예외처리
| 상황 | 동작 |
|------|------|
| PostgreSQL 연결 실패 | null 반환, 에러 로그 |
| 데이터 없음 | null 반환 |
| sung_code 미전달 | 환산계수 조회 skip |
| 환산계수 0 또는 음수 | 적용 안 함 (기존 포맷) |
## 테스트 방법
```bash
# 환산계수 조회 테스트
curl http://localhost:7001/api/drug-info/conversion-factor/535000ASY
# 존재하지 않는 코드 테스트
curl http://localhost:7001/api/drug-info/conversion-factor/NOTEXIST
# 라벨 미리보기 테스트 (PowerShell)
$body = @{
patient_name = "홍길동"
med_name = "오구멘틴듀오시럽"
dosage = 8
frequency = 3
duration = 5
unit = "mL"
sung_code = "535000ASY"
} | ConvertTo-Json
Invoke-RestMethod -Uri "http://localhost:7001/pmr/api/label/preview" `
-Method Post -ContentType "application/json" -Body $body
```
## 변경 이력
| 날짜 | 내용 |
|------|------|
| 2026-03-12 | 최초 구현 |