feat: 처방 관리 및 재고 원장 시스템 구현

## 처방 관리 (조제) 기능
- compounds API 추가 (목록/상세/환자별 조회)
- 조제 시 자동 재고 차감 (FIFO)
- 조제 내역 UI (EMR 스타일)
- 조제 상세보기 모달 (처방구성, 재고소비내역)
- 오늘/이번달 조제 통계 표시

## 재고 원장 시스템
- stock-ledger API 구현
- 입출고 내역 실시간 추적
- 재고 현황 페이지 개선 (통계 카드 추가)
- 입출고 원장 모달 UI
- 약재별/전체 입출고 내역 조회

## 확인된 동작
- 박주호 환자 오미자 200g 조제
- 재고 2000g → 1800g 정확히 차감
- 모든 입출고 stock_ledger에 기록

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
시골약사 2026-02-15 11:21:20 +00:00
parent 63128fdccb
commit 38838e5ecf
18 changed files with 1835 additions and 46 deletions

67
.claude/project_state.md Normal file
View File

@ -0,0 +1,67 @@
# 한약 재고관리 시스템 - 프로젝트 상태
## ✅ 해결된 이슈 (2026-02-15)
1. **입고장 상세보기 500 에러** - 해결 완료!
- 원인: receipt_date가 튜플 문자열로 저장됨, total_amount가 bytes로 저장됨
- 해결: 데이터베이스 값 수정 완료
2. **Flask 프로세스 중복 실행 문제** - 해결 완료!
- 해결: run_server.sh 스크립트 생성으로 단일 프로세스 관리
## 📝 최근 수정 사항 (2026-02-15)
### ✅ 완료된 작업
1. **총금액 계산 문제 해결**
- `app.py` 쿼리에서 중복된 `pr.total_amount` 제거
- `SUM(prl.line_total) as total_amount` 사용
- API가 정확한 총금액 1,551,900원 반환
2. **UI 개선**
- 입고장 목록에서 총금액을 굵은 파란색으로 강조
- 총수량을 작은 회색 글씨로 표시
- 테이블 헤더 순서 변경 (총금액이 먼저)
3. **원산지 데이터 처리**
- Excel에서 원산지(origin_country) 데이터 읽기 확인
- 데이터베이스 저장 확인
- 입고장 상세 화면에 원산지 표시
### 🔧 해결 필요
1. **입고장 상세보기 오류**
- 증상: 상세보기 버튼 클릭 시 500 에러
- 위치: `/api/purchase-receipts/<id>` API
- 원인: 조사 중
2. **Flask 프로세스 관리**
- 문제: 여러 Flask 프로세스가 중복 실행
- 해결 방법: 단일 프로세스로 관리 필요
## 🗄️ 데이터베이스 상태
- 입고장 1건 (ID: 6, 날짜: 2026-02-11)
- 총 29개 품목, 총금액 1,551,900원
- 도매상: (주)휴먼허브
## 🌐 서버 정보
- **포트**: 5001
- **모드**: Debug (자동 재시작 활성화)
- **URL**: http://localhost:5001
## 📂 주요 파일
- `app.py` - Flask 백엔드
- `database/kdrug.db` - SQLite 데이터베이스
- `templates/index.html` - 프론트엔드 HTML
- `static/app.js` - JavaScript
- `excel_processor.py` - Excel 파일 처리
## 🔄 Flask 서버 관리 명령어
```bash
# 모든 Flask 프로세스 종료
lsof -ti:5001 | xargs -r kill -9
# Flask 서버 시작 (디버그 모드)
source venv/bin/activate && python app.py
```
## 📋 다음 작업
1. 입고장 상세보기 500 에러 수정
2. Flask 프로세스 중복 실행 방지 설정
3. 에러 로깅 개선

62
analyze_product_code.py Normal file
View File

@ -0,0 +1,62 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
한약재 제품 코드 엑셀 파일 분석
"""
import pandas as pd
import openpyxl
def analyze_excel_file():
file_path = 'sample/(게시)한약재제품코드_2510.xlsx'
# 엑셀 파일 열기
wb = openpyxl.load_workbook(file_path, read_only=True)
print("=== 엑셀 파일 시트 목록 ===")
for i, sheet_name in enumerate(wb.sheetnames, 1):
print(f"{i}. {sheet_name}")
# 4번째 시트 데이터 읽기
if len(wb.sheetnames) >= 4:
sheet_name = wb.sheetnames[3] # 0-based index
print(f"\n=== 4번째 시트 '{sheet_name}' 분석 ===")
# pandas로 데이터 읽기
df = pd.read_excel(file_path, sheet_name=sheet_name)
print(f"\n데이터 크기: {df.shape[0]}행 x {df.shape[1]}")
print(f"\n컬럼 목록:")
for i, col in enumerate(df.columns, 1):
# NaN이 아닌 값들의 예시
non_null_count = df[col].notna().sum()
sample_values = df[col].dropna().head(3).tolist()
print(f" {i}. {col} (유효값: {non_null_count}개)")
if sample_values:
print(f" 예시: {sample_values[:3]}")
print(f"\n=== 데이터 샘플 (처음 10행) ===")
pd.set_option('display.max_columns', None)
pd.set_option('display.width', None)
pd.set_option('display.max_colwidth', 50)
print(df.head(10))
# 주요 컬럼 분석
if '주성분코드' in df.columns:
print(f"\n=== 주성분코드 분석 ===")
print(f"유일한 주성분코드 수: {df['주성분코드'].nunique()}")
print(f"주성분코드 샘플: {df['주성분코드'].unique()[:10].tolist()}")
if '제품명' in df.columns:
print(f"\n=== 제품명 분석 ===")
print(f"유일한 제품 수: {df['제품명'].nunique()}")
print(f"제품명 샘플: {df['제품명'].head(10).tolist()}")
# 컬럼 정보를 더 자세히 분석
print(f"\n=== 데이터 타입 및 null 값 정보 ===")
print(df.info())
wb.close()
if __name__ == "__main__":
analyze_excel_file()

84
analyze_product_deep.py Normal file
View File

@ -0,0 +1,84 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
한약재 제품 코드 심층 분석
"""
import pandas as pd
def deep_analyze():
file_path = 'sample/(게시)한약재제품코드_2510.xlsx'
sheet_name = '한약재 제품코드_20250930기준(유효코드만 공지)'
# 데이터 읽기 - 제품코드를 문자열로 읽어서 0 유지
df = pd.read_excel(file_path, sheet_name=sheet_name, dtype={'제품코드': str})
print("=== 한약재 제품 코드 데이터 심층 분석 ===")
print(f"전체 데이터: {len(df):,}개 제품")
print(f"유일한 주성분코드: {df['주성분코드'].nunique()}")
print(f"유일한 약재 품목명: {df['한약재 품목명'].nunique()}")
print(f"유일한 업체: {df['업체명'].nunique()}")
# 주성분코드별 통계
print("\n=== 주성분코드별 제품 수 (상위 20개) ===")
ingredient_stats = df.groupby(['주성분코드', '한약재 품목명']).size().reset_index(name='제품수')
ingredient_stats = ingredient_stats.sort_values('제품수', ascending=False).head(20)
for _, row in ingredient_stats.iterrows():
print(f" {row['주성분코드']} ({row['한약재 품목명']}): {row['제품수']}개 제품")
# 업체별 통계
print("\n=== 업체별 제품 수 (상위 10개) ===")
company_stats = df['업체명'].value_counts().head(10)
for company, count in company_stats.items():
print(f" {company}: {count}개 제품")
# 규격별 분석
print("\n=== 약품 규격 분석 ===")
spec_stats = df['약품규격(단위)'].value_counts()
print("규격 단위별 제품 수:")
for spec, count in spec_stats.items():
print(f" {spec}: {count}")
# 특정 약재들 확인
print("\n=== 주요 약재 확인 ===")
target_herbs = ['건강', '감초', '당귀', '황기', '숙지황', '백출', '천궁', '육계', '백작약', '인삼', '생강', '대추']
for herb in target_herbs:
herb_data = df[df['한약재 품목명'] == herb]
if not herb_data.empty:
unique_code = herb_data['주성분코드'].iloc[0] if len(herb_data) > 0 else 'N/A'
product_count = len(herb_data)
company_count = herb_data['업체명'].nunique()
print(f" {herb}: 주성분코드={unique_code}, {product_count}개 제품, {company_count}개 업체")
else:
print(f" {herb}: 데이터 없음")
# 한 약재에 여러 제품이 있는 예시 - 건강
print("\n=== '건강' 약재의 제품 예시 (처음 10개) ===")
gangang_data = df[df['한약재 품목명'] == '건강'].head(10)
if not gangang_data.empty:
for _, row in gangang_data.iterrows():
# 제품코드를 9자리로 표시 (0 패딩)
product_code = str(row['제품코드']).zfill(9)
print(f" 업체: {row['업체명']}, 제품명: {row['제품명']}, 제품코드: {product_code}, 규격: {row['약품규격(숫자)']} {row['약품규격(단위)']}")
# 현재 시스템과의 비교
print("\n=== 현재 DB 설계와의 차이점 ===")
print("1. 현재 시스템:")
print(" - herb_items: 약재 기본 정보 (예: 건강)")
print(" - inventory_lots: 로트별 재고 (원산지, 가격 등)")
print("\nㅇ2. 제품코드 시스템:")
print(" - 주성분코드: 약재별 고유 코드 (예: 3050H1AHM = 건강)")
print(" - 제품코드: 업체별 제품 고유코드")
print(" - 표준코드/대표코드: 바코드 시스템")
print(" - 규격: 포장 단위 (500g, 1000g 등)")
print("\n=== 시사점 ===")
print("- 54,000개 이상의 유통 제품이 454개 주성분코드로 분류됨")
print("- 같은 약재(주성분)라도 업체별로 다른 제품명과 코드를 가짐")
print("- 제품별로 다양한 포장 규격 존재 (-, 500g, 600g, 1000g 등)")
print("- 표준코드(바코드)를 통한 제품 식별 가능")
if __name__ == "__main__":
deep_analyze()

325
app.py
View File

@ -159,6 +159,78 @@ def get_herbs():
except Exception as e:
return jsonify({'success': False, 'error': str(e)}), 500
@app.route('/api/herbs/masters', methods=['GET'])
def get_herb_masters():
"""주성분코드 기준 전체 약재 목록 조회 (454개)"""
try:
with get_db() as conn:
cursor = conn.cursor()
cursor.execute("""
SELECT
m.ingredient_code,
m.herb_name,
m.herb_name_hanja,
m.herb_name_latin,
-- 재고 정보
COALESCE(inv.total_quantity, 0) as stock_quantity,
COALESCE(inv.lot_count, 0) as lot_count,
COALESCE(inv.avg_price, 0) as avg_price,
CASE WHEN inv.total_quantity > 0 THEN 1 ELSE 0 END as has_stock,
-- 효능 태그
GROUP_CONCAT(DISTINCT et.tag_name) as efficacy_tags,
-- 제품 정보
COUNT(DISTINCT p.company_name) as company_count,
COUNT(DISTINCT p.product_id) as product_count
FROM herb_masters m
LEFT JOIN (
-- 재고 정보 서브쿼리
SELECT
h.ingredient_code,
SUM(il.quantity_onhand) as total_quantity,
COUNT(DISTINCT il.lot_id) as lot_count,
AVG(il.unit_price_per_g) as avg_price
FROM herb_items h
INNER JOIN inventory_lots il ON h.herb_item_id = il.herb_item_id
WHERE il.is_depleted = 0 AND il.quantity_onhand > 0
GROUP BY h.ingredient_code
) inv ON m.ingredient_code = inv.ingredient_code
LEFT JOIN herb_products p ON m.ingredient_code = p.ingredient_code
LEFT JOIN herb_items hi ON m.ingredient_code = hi.ingredient_code
LEFT JOIN herb_item_tags hit ON hi.herb_item_id = hit.herb_item_id
LEFT JOIN herb_efficacy_tags et ON hit.tag_id = et.tag_id
WHERE m.is_active = 1
GROUP BY m.ingredient_code, m.herb_name, inv.total_quantity, inv.lot_count, inv.avg_price
ORDER BY has_stock DESC, m.herb_name
""")
herbs = []
for row in cursor.fetchall():
herb = dict(row)
# 효능 태그를 리스트로 변환
if herb['efficacy_tags']:
herb['efficacy_tags'] = herb['efficacy_tags'].split(',')
else:
herb['efficacy_tags'] = []
herbs.append(herb)
# 통계 정보
total_herbs = len(herbs)
herbs_with_stock = sum(1 for h in herbs if h['has_stock'])
coverage_rate = round(herbs_with_stock * 100 / total_herbs, 1) if total_herbs > 0 else 0
return jsonify({
'success': True,
'data': herbs,
'summary': {
'total_herbs': total_herbs,
'herbs_with_stock': herbs_with_stock,
'herbs_without_stock': total_herbs - herbs_with_stock,
'coverage_rate': coverage_rate
}
})
except Exception as e:
return jsonify({'success': False, 'error': str(e)}), 500
# ==================== 처방 관리 API ====================
@app.route('/api/formulas', methods=['GET'])
@ -723,6 +795,149 @@ def delete_purchase_receipt(receipt_id):
# ==================== 조제 관리 API ====================
@app.route('/api/compounds', methods=['GET'])
def get_compounds():
"""조제 목록 조회"""
try:
with get_db() as conn:
cursor = conn.cursor()
cursor.execute("""
SELECT
c.compound_id,
c.patient_id,
p.name as patient_name,
p.phone as patient_phone,
c.formula_id,
f.formula_name,
f.formula_code,
c.compound_date,
c.je_count,
c.cheop_total,
c.pouch_total,
c.cost_total,
c.sell_price_total,
c.prescription_no,
c.status,
c.notes,
c.created_at,
c.created_by
FROM compounds c
LEFT JOIN patients p ON c.patient_id = p.patient_id
LEFT JOIN formulas f ON c.formula_id = f.formula_id
ORDER BY c.created_at DESC
LIMIT 100
""")
compounds = [dict(row) for row in cursor.fetchall()]
return jsonify({'success': True, 'data': compounds})
except Exception as e:
return jsonify({'success': False, 'error': str(e)}), 500
@app.route('/api/compounds/<int:compound_id>', methods=['GET'])
def get_compound_detail(compound_id):
"""조제 상세 정보 조회"""
try:
with get_db() as conn:
cursor = conn.cursor()
# 조제 마스터 정보
cursor.execute("""
SELECT
c.*,
p.name as patient_name,
p.phone as patient_phone,
f.formula_name,
f.formula_code
FROM compounds c
LEFT JOIN patients p ON c.patient_id = p.patient_id
LEFT JOIN formulas f ON c.formula_id = f.formula_id
WHERE c.compound_id = ?
""", (compound_id,))
compound = dict(cursor.fetchone())
# 조제 약재 구성
cursor.execute("""
SELECT
ci.*,
h.herb_name,
h.insurance_code
FROM compound_ingredients ci
JOIN herb_items h ON ci.herb_item_id = h.herb_item_id
WHERE ci.compound_id = ?
ORDER BY ci.compound_ingredient_id
""", (compound_id,))
ingredients = [dict(row) for row in cursor.fetchall()]
# 소비 내역
cursor.execute("""
SELECT
cc.*,
h.herb_name,
il.origin_country,
il.supplier_id,
s.name as supplier_name
FROM compound_consumptions cc
JOIN herb_items h ON cc.herb_item_id = h.herb_item_id
JOIN inventory_lots il ON cc.lot_id = il.lot_id
LEFT JOIN suppliers s ON il.supplier_id = s.supplier_id
WHERE cc.compound_id = ?
ORDER BY cc.consumption_id
""", (compound_id,))
consumptions = [dict(row) for row in cursor.fetchall()]
compound['ingredients'] = ingredients
compound['consumptions'] = consumptions
return jsonify({'success': True, 'data': compound})
except Exception as e:
return jsonify({'success': False, 'error': str(e)}), 500
@app.route('/api/patients/<int:patient_id>/compounds', methods=['GET'])
def get_patient_compounds(patient_id):
"""환자별 처방 기록 조회"""
try:
with get_db() as conn:
cursor = conn.cursor()
cursor.execute("""
SELECT
c.compound_id,
c.formula_id,
f.formula_name,
f.formula_code,
c.compound_date,
c.je_count,
c.cheop_total,
c.pouch_total,
c.cost_total,
c.sell_price_total,
c.prescription_no,
c.status,
c.notes,
c.created_at,
c.created_by
FROM compounds c
LEFT JOIN formulas f ON c.formula_id = f.formula_id
WHERE c.patient_id = ?
ORDER BY c.compound_date DESC, c.created_at DESC
""", (patient_id,))
compounds = [dict(row) for row in cursor.fetchall()]
# 환자 정보도 함께 반환
cursor.execute("""
SELECT patient_id, name, phone, gender, birth_date, notes
FROM patients
WHERE patient_id = ?
""", (patient_id,))
patient_row = cursor.fetchone()
patient = dict(patient_row) if patient_row else None
return jsonify({
'success': True,
'patient': patient,
'compounds': compounds
})
except Exception as e:
return jsonify({'success': False, 'error': str(e)}), 500
@app.route('/api/compounds', methods=['POST'])
def create_compound():
"""조제 실행"""
@ -845,6 +1060,116 @@ def create_compound():
except Exception as e:
return jsonify({'success': False, 'error': str(e)}), 500
# ==================== 재고 원장 API ====================
@app.route('/api/stock-ledger', methods=['GET'])
def get_stock_ledger():
"""재고 원장 (입출고 내역) 조회"""
try:
herb_id = request.args.get('herb_id')
limit = request.args.get('limit', 100, type=int)
with get_db() as conn:
cursor = conn.cursor()
if herb_id:
cursor.execute("""
SELECT
sl.ledger_id,
sl.event_type,
sl.event_time,
h.herb_name,
h.insurance_code,
sl.quantity_delta,
sl.unit_cost_per_g,
sl.reference_table,
sl.reference_id,
il.origin_country,
s.name as supplier_name,
CASE
WHEN sl.event_type = 'PURCHASE' THEN pr.receipt_no
WHEN sl.event_type = 'CONSUME' THEN c.compound_id
ELSE NULL
END as reference_no,
CASE
WHEN sl.event_type = 'CONSUME' THEN p.name
ELSE NULL
END as patient_name
FROM stock_ledger sl
JOIN herb_items h ON sl.herb_item_id = h.herb_item_id
LEFT JOIN inventory_lots il ON sl.lot_id = il.lot_id
LEFT JOIN suppliers s ON il.supplier_id = s.supplier_id
LEFT JOIN purchase_receipts pr ON sl.reference_table = 'purchase_receipts' AND sl.reference_id = pr.receipt_id
LEFT JOIN compounds c ON sl.reference_table = 'compounds' AND sl.reference_id = c.compound_id
LEFT JOIN patients p ON c.patient_id = p.patient_id
WHERE sl.herb_item_id = ?
ORDER BY sl.event_time DESC
LIMIT ?
""", (herb_id, limit))
else:
cursor.execute("""
SELECT
sl.ledger_id,
sl.event_type,
sl.event_time,
h.herb_name,
h.insurance_code,
sl.quantity_delta,
sl.unit_cost_per_g,
sl.reference_table,
sl.reference_id,
il.origin_country,
s.name as supplier_name,
CASE
WHEN sl.event_type = 'PURCHASE' THEN pr.receipt_no
WHEN sl.event_type = 'CONSUME' THEN c.compound_id
ELSE NULL
END as reference_no,
CASE
WHEN sl.event_type = 'CONSUME' THEN p.name
ELSE NULL
END as patient_name
FROM stock_ledger sl
JOIN herb_items h ON sl.herb_item_id = h.herb_item_id
LEFT JOIN inventory_lots il ON sl.lot_id = il.lot_id
LEFT JOIN suppliers s ON il.supplier_id = s.supplier_id
LEFT JOIN purchase_receipts pr ON sl.reference_table = 'purchase_receipts' AND sl.reference_id = pr.receipt_id
LEFT JOIN compounds c ON sl.reference_table = 'compounds' AND sl.reference_id = c.compound_id
LEFT JOIN patients p ON c.patient_id = p.patient_id
ORDER BY sl.event_time DESC
LIMIT ?
""", (limit,))
ledger_entries = [dict(row) for row in cursor.fetchall()]
# 약재별 현재 재고 요약
cursor.execute("""
SELECT
h.herb_item_id,
h.herb_name,
h.insurance_code,
COALESCE(SUM(il.quantity_onhand), 0) as total_stock,
COUNT(DISTINCT il.lot_id) as active_lots,
AVG(il.unit_price_per_g) as avg_price
FROM herb_items h
LEFT JOIN inventory_lots il ON h.herb_item_id = il.herb_item_id
AND il.is_depleted = 0
WHERE h.is_active = 1
GROUP BY h.herb_item_id
HAVING total_stock > 0
ORDER BY h.herb_name
""")
stock_summary = [dict(row) for row in cursor.fetchall()]
return jsonify({
'success': True,
'ledger': ledger_entries,
'summary': stock_summary
})
except Exception as e:
return jsonify({'success': False, 'error': str(e)}), 500
# ==================== 조제용 재고 조회 API ====================
@app.route('/api/herbs/<int:herb_item_id>/available-lots', methods=['GET'])

65
check_totals.py Normal file
View File

@ -0,0 +1,65 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
import sqlite3
# 데이터베이스 연결
conn = sqlite3.connect('database/kdrug.db')
conn.row_factory = sqlite3.Row
cursor = conn.cursor()
print("=== 입고장별 총금액 확인 ===\n")
# 각 입고장의 라인별 총액 확인
cursor.execute("""
SELECT
pr.receipt_id,
pr.receipt_date,
s.name as supplier_name,
COUNT(prl.line_id) as line_count,
SUM(prl.quantity_g) as total_quantity,
SUM(prl.line_total) as calculated_total
FROM purchase_receipts pr
JOIN suppliers s ON pr.supplier_id = s.supplier_id
LEFT JOIN purchase_receipt_lines prl ON pr.receipt_id = prl.receipt_id
GROUP BY pr.receipt_id
ORDER BY pr.receipt_date DESC
""")
results = cursor.fetchall()
for row in results:
print(f"입고장 ID: {row['receipt_id']}")
print(f" 날짜: {row['receipt_date']}")
print(f" 도매상: {row['supplier_name']}")
print(f" 품목 수: {row['line_count']}")
print(f" 총 수량: {row['total_quantity']}g")
print(f" 총 금액: {row['calculated_total']:,.0f}" if row['calculated_total'] else " 총 금액: 0원")
print("-" * 40)
print("\n=== 입고장 라인 상세 (첫 번째 입고장) ===\n")
# 첫 번째 입고장의 라인 상세 확인
if results:
first_receipt_id = results[0]['receipt_id']
cursor.execute("""
SELECT
herb_item_id,
quantity_g,
unit_price_per_g,
line_total
FROM purchase_receipt_lines
WHERE receipt_id = ?
LIMIT 5
""", (first_receipt_id,))
lines = cursor.fetchall()
for line in lines:
print(f"약재 ID: {line['herb_item_id']}")
print(f" 수량: {line['quantity_g']}g")
print(f" 단가: {line['unit_price_per_g']}원/g")
print(f" 라인 총액: {line['line_total']}")
print(f" 계산 검증: {line['quantity_g']} × {line['unit_price_per_g']} = {line['quantity_g'] * line['unit_price_per_g']}")
print()
conn.close()

103
debug_receipt_detail.py Normal file
View File

@ -0,0 +1,103 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
입고장 상세보기 오류 디버그
"""
import sqlite3
import traceback
def debug_receipt_detail():
conn = sqlite3.connect('database/kdrug.db')
conn.row_factory = sqlite3.Row
cursor = conn.cursor()
receipt_id = 6
print("=== 1. 입고장 헤더 조회 ===")
try:
cursor.execute("""
SELECT
pr.*,
s.name as supplier_name,
s.business_no as supplier_business_no,
s.phone as supplier_phone
FROM purchase_receipts pr
JOIN suppliers s ON pr.supplier_id = s.supplier_id
WHERE pr.receipt_id = ?
""", (receipt_id,))
receipt = cursor.fetchone()
if receipt:
receipt_dict = dict(receipt)
print("헤더 조회 성공!")
for key, value in receipt_dict.items():
print(f" {key}: {value} (type: {type(value).__name__})")
else:
print("입고장을 찾을 수 없습니다.")
return
except Exception as e:
print(f"헤더 조회 오류: {e}")
traceback.print_exc()
return
print("\n=== 2. 입고장 상세 라인 조회 ===")
try:
cursor.execute("""
SELECT
prl.*,
h.herb_name,
h.insurance_code,
il.lot_id,
il.quantity_onhand as current_stock
FROM purchase_receipt_lines prl
JOIN herb_items h ON prl.herb_item_id = h.herb_item_id
LEFT JOIN inventory_lots il ON prl.line_id = il.receipt_line_id
WHERE prl.receipt_id = ?
ORDER BY prl.line_id
""", (receipt_id,))
lines = cursor.fetchall()
print(f"라인 수: {len(lines)}")
if lines:
first_line = dict(lines[0])
print("\n첫 번째 라인 데이터:")
for key, value in first_line.items():
print(f" {key}: {value} (type: {type(value).__name__})")
except Exception as e:
print(f"라인 조회 오류: {e}")
traceback.print_exc()
print("\n=== 3. JSON 변환 테스트 ===")
try:
import json
# receipt_data 구성
receipt_data = dict(receipt)
receipt_data['lines'] = [dict(row) for row in lines]
# JSON 변환 시도
json_str = json.dumps(receipt_data, ensure_ascii=False, default=str)
print("JSON 변환 성공!")
print(f"JSON 길이: {len(json_str)} 문자")
except Exception as e:
print(f"JSON 변환 오류: {e}")
traceback.print_exc()
# 문제가 되는 필드 찾기
print("\n각 필드별 JSON 변환 테스트:")
for key, value in receipt_data.items():
try:
json.dumps({key: value}, default=str)
print(f"{key}: OK")
except Exception as field_error:
print(f"{key}: {field_error}")
print(f" 값: {value}")
print(f" 타입: {type(value)}")
conn.close()
if __name__ == "__main__":
debug_receipt_detail()

BIN
direct_test.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

View File

@ -0,0 +1,128 @@
# 📋 주성분코드 기반 약재 관리 체계 개선 계획
## 🎯 목표
**"454개 주성분코드를 기준으로 약재를 관리하고, 입고되지 않은 약재도 처방 가능한 체계 구축"**
## 🏗️ 현재 문제점
1. 약재 관리가 입고된 제품 중심 (28개만 표시)
2. 입고되지 않은 약재는 처방 생성 불가
3. 보험코드와 주성분코드가 혼재
## ✨ 개선 후 모습
### 3단계 계층 구조
```
1. 주성분코드 (454개) - 약재 마스터
2. 처방 구성 - 주성분코드 기반
3. 실제 조제 - 입고된 제품으로 매핑
```
## 📝 구현 단계
### Phase 1: 약재 관리 UI 개선 (우선)
#### 1-1. 약재 목록 페이지 개선
- **현재**: 입고된 약재만 표시 (28개)
- **개선**:
```
전체 454개 주성분코드 표시
✅ 재고 있음 (28개) - 녹색 표시
⬜ 재고 없음 (426개) - 회색 표시
```
#### 1-2. API 수정
- `/api/herbs/masters` - 454개 전체 약재 (재고 유무 표시)
- `/api/herbs/inventory` - 재고 있는 약재만 (현재 방식 유지)
#### 1-3. 필터링 기능
- 전체 보기 / 재고 있음 / 재고 없음
- 효능별 필터
- 검색 기능
### Phase 2: 처방 관리 개선
#### 2-1. 처방 생성 개선
- 454개 주성분코드에서 선택
- 재고 없는 약재도 선택 가능
- 재고 상태 시각적 표시
#### 2-2. 처방 구성 표시
```
쌍화탕 (12개 약재)
✅ 건강 (재고: 6,500g)
✅ 감초 (재고: 5,000g)
⚠️ 특정약재 (재고: 0g) - 입고 필요
```
### Phase 3: 조제 프로세스 개선
#### 3-1. 조제 시 자동 매핑
```
주성분코드 → 실제 제품 선택
3017H1AHM (건강) →
• 경희한약 건강 500g (페루산)
• 고강제약 건강 600g (한국산)
```
#### 3-2. 재고 부족 알림
- 조제 불가능한 약재 강조
- 대체 가능 제품 제안
### Phase 4: 입고 관리 개선
#### 4-1. 제품 매핑
- 입고 시 주성분코드 자동 매핑
- 바코드로 제품 식별
## 🚀 구현 순서
### Step 1: 약재 마스터 API (30분)
1. `/api/herbs/masters` API 생성
2. 454개 전체 약재 + 재고 상태 반환
### Step 2: 약재 관리 UI (1시간)
1. 약재 관리 페이지 새로 구성
2. 필터링 기능 추가
3. 재고 상태 표시
### Step 3: 처방 관리 수정 (1시간)
1. formula_ingredients를 주성분코드 기반으로 변경
2. 처방 생성 UI 수정
3. 재고 체크 로직 분리
### Step 4: 조제 프로세스 (1시간)
1. 주성분코드 → 제품 매핑 로직
2. 제품 선택 UI
3. 재고 부족 처리
### Step 5: 테스트 및 마무리 (30분)
1. 전체 프로세스 테스트
2. 버그 수정
3. 문서 업데이트
## 📊 예상 효과
### Before
- 28개 약재만 관리
- 입고된 약재로만 처방 생성
- 제한적인 시스템
### After
- 454개 전체 급여 약재 관리
- 재고 없어도 처방 생성 가능
- 표준화된 체계
- 확장 가능한 구조
## ⚠️ 주의사항
1. **하위 호환성**: 기존 데이터 유지
2. **단계적 적용**: 한 번에 하나씩 구현
3. **백업**: 각 단계별 백업
## 🔍 검증 기준
1. 454개 약재 모두 표시되는가?
2. 재고 없는 약재로 처방 생성 가능한가?
3. 조제 시 적절한 제품이 매핑되는가?
4. 기존 기능이 정상 작동하는가?
---
작성일: 2026-02-15
예상 소요시간: 4시간
우선순위: 높음

55
fix_database.py Normal file
View File

@ -0,0 +1,55 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
데이터베이스 데이터 수정
"""
import sqlite3
def fix_database():
conn = sqlite3.connect('database/kdrug.db')
cursor = conn.cursor()
print("=== 데이터베이스 수정 시작 ===")
# 1. receipt_date 수정 (튜플 문자열을 일반 문자열로)
print("\n1. receipt_date 수정...")
cursor.execute("""
UPDATE purchase_receipts
SET receipt_date = '20260211'
WHERE receipt_id = 6
""")
print(f" receipt_date 수정 완료: {cursor.rowcount}")
# 2. total_amount 계산 및 수정
print("\n2. total_amount 재계산...")
cursor.execute("""
UPDATE purchase_receipts
SET total_amount = (
SELECT SUM(line_total)
FROM purchase_receipt_lines
WHERE receipt_id = 6
)
WHERE receipt_id = 6
""")
print(f" total_amount 수정 완료: {cursor.rowcount}")
# 변경 사항 저장
conn.commit()
# 수정 결과 확인
print("\n=== 수정 후 데이터 확인 ===")
cursor.execute("""
SELECT receipt_date, total_amount
FROM purchase_receipts
WHERE receipt_id = 6
""")
result = cursor.fetchone()
print(f" receipt_date: {result[0]} (type: {type(result[0]).__name__})")
print(f" total_amount: {result[1]:,.0f}원 (type: {type(result[1]).__name__})")
conn.close()
print("\n✅ 데이터베이스 수정 완료!")
if __name__ == "__main__":
fix_database()

BIN
purchase_test.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

View File

@ -532,8 +532,145 @@ $(document).ready(function() {
// 조제 내역 로드
function loadCompounds() {
// TODO: 조제 내역 API 구현 필요
$('#compoundsList').html('<tr><td colspan="7" class="text-center">조제 내역이 없습니다.</td></tr>');
$.get('/api/compounds', function(response) {
const tbody = $('#compoundsList');
tbody.empty();
if (response.success && response.data.length > 0) {
// 통계 업데이트
const today = new Date().toISOString().split('T')[0];
const currentMonth = new Date().toISOString().slice(0, 7);
let todayCount = 0;
let monthCount = 0;
response.data.forEach((compound, index) => {
// 통계 계산
if (compound.compound_date === today) todayCount++;
if (compound.compound_date && compound.compound_date.startsWith(currentMonth)) monthCount++;
// 상태 뱃지
let statusBadge = '';
switch(compound.status) {
case 'PREPARED':
statusBadge = '<span class="badge bg-success">조제완료</span>';
break;
case 'DISPENSED':
statusBadge = '<span class="badge bg-primary">출고완료</span>';
break;
case 'CANCELLED':
statusBadge = '<span class="badge bg-danger">취소</span>';
break;
default:
statusBadge = '<span class="badge bg-secondary">대기</span>';
}
const row = $(`
<tr>
<td>${response.data.length - index}</td>
<td>${compound.compound_date || ''}<br><small class="text-muted">${compound.created_at ? compound.created_at.split(' ')[1] : ''}</small></td>
<td><strong>${compound.patient_name || '직접조제'}</strong></td>
<td>${compound.patient_phone || '-'}</td>
<td>${compound.formula_name || '직접조제'}</td>
<td>${compound.je_count || 0}</td>
<td>${compound.cheop_total || 0}</td>
<td>${compound.pouch_total || 0}</td>
<td>${formatCurrency(compound.cost_total || 0)}</td>
<td>${formatCurrency(compound.sell_price_total || 0)}</td>
<td>${statusBadge}</td>
<td>${compound.prescription_no || '-'}</td>
<td>
<button class="btn btn-sm btn-outline-info view-compound-detail" data-id="${compound.compound_id}">
<i class="bi bi-eye"></i>
</button>
</td>
</tr>
`);
tbody.append(row);
});
// 통계 업데이트
$('#todayCompoundCount').text(todayCount);
$('#monthCompoundCount').text(monthCount);
// 상세보기 버튼 이벤트
$('.view-compound-detail').on('click', function() {
const compoundId = $(this).data('id');
viewCompoundDetail(compoundId);
});
} else {
tbody.html('<tr><td colspan="13" class="text-center text-muted">조제 내역이 없습니다.</td></tr>');
$('#todayCompoundCount').text(0);
$('#monthCompoundCount').text(0);
}
}).fail(function() {
$('#compoundsList').html('<tr><td colspan="13" class="text-center text-danger">데이터를 불러오는데 실패했습니다.</td></tr>');
});
}
// 조제 상세보기
function viewCompoundDetail(compoundId) {
$.get(`/api/compounds/${compoundId}`, function(response) {
if (response.success && response.data) {
const data = response.data;
// 환자 정보
$('#detailPatientName').text(data.patient_name || '직접조제');
$('#detailPatientPhone').text(data.patient_phone || '-');
$('#detailCompoundDate').text(data.compound_date || '-');
// 처방 정보
$('#detailFormulaName').text(data.formula_name || '직접조제');
$('#detailPrescriptionNo').text(data.prescription_no || '-');
$('#detailQuantities').text(`${data.je_count}제 / ${data.cheop_total}첩 / ${data.pouch_total}파우치`);
// 처방 구성 약재
const ingredientsBody = $('#detailIngredients');
ingredientsBody.empty();
if (data.ingredients && data.ingredients.length > 0) {
data.ingredients.forEach(ing => {
ingredientsBody.append(`
<tr>
<td>${ing.herb_name}</td>
<td>${ing.insurance_code || '-'}</td>
<td>${ing.grams_per_cheop}g</td>
<td>${ing.total_grams}g</td>
<td>${ing.notes || '-'}</td>
</tr>
`);
});
}
// 재고 소비 내역
const consumptionsBody = $('#detailConsumptions');
consumptionsBody.empty();
if (data.consumptions && data.consumptions.length > 0) {
data.consumptions.forEach(con => {
consumptionsBody.append(`
<tr>
<td>${con.herb_name}</td>
<td>${con.origin_country || '-'}</td>
<td>${con.supplier_name || '-'}</td>
<td>${con.quantity_used}g</td>
<td>${formatCurrency(con.unit_cost_per_g)}/g</td>
<td>${formatCurrency(con.cost_amount)}</td>
</tr>
`);
});
}
// 총 원가
$('#detailTotalCost').text(formatCurrency(data.cost_total || 0));
// 비고
$('#detailNotes').text(data.notes || '');
// 모달 표시
$('#compoundDetailModal').modal('show');
}
}).fail(function() {
alert('조제 상세 정보를 불러오는데 실패했습니다.');
});
}
// 재고 현황 로드
@ -543,6 +680,9 @@ $(document).ready(function() {
const tbody = $('#inventoryList');
tbody.empty();
let totalValue = 0;
let herbsInStock = 0;
// 주성분코드 기준 보유 현황 표시
if (response.summary) {
const summary = response.summary;
@ -611,23 +751,48 @@ $(document).ready(function() {
priceDisplay = `${formatCurrency(item.min_price)} ~ ${formatCurrency(item.max_price)}`;
}
// 통계 업데이트
totalValue += item.total_value || 0;
if (item.total_quantity > 0) herbsInStock++;
tbody.append(`
<tr class="inventory-row" data-herb-id="${item.herb_item_id}" style="cursor: pointer;">
<tr class="inventory-row" data-herb-id="${item.herb_item_id}">
<td>${item.insurance_code || '-'}</td>
<td>${item.herb_name}${originBadge}${efficacyTags}</td>
<td>${item.total_quantity.toFixed(1)}</td>
<td>${item.lot_count}</td>
<td>${priceDisplay}</td>
<td>${formatCurrency(item.total_value)}</td>
<td>
<button class="btn btn-sm btn-outline-info view-stock-ledger" data-herb-id="${item.herb_item_id}" data-herb-name="${item.herb_name}">
<i class="bi bi-journal-text"></i>
</button>
<button class="btn btn-sm btn-outline-primary view-inventory-detail" data-herb-id="${item.herb_item_id}" style="cursor: pointer;">
<i class="bi bi-eye"></i>
</button>
</td>
</tr>
`);
});
// 통계 업데이트
$('#totalInventoryValue').text(formatCurrency(totalValue));
$('#totalHerbsInStock').text(`${herbsInStock}`);
// 클릭 이벤트 바인딩
$('.inventory-row').on('click', function() {
$('.view-inventory-detail').on('click', function(e) {
e.stopPropagation();
const herbId = $(this).data('herb-id');
showInventoryDetail(herbId);
});
// 입출고 내역 버튼 이벤트
$('.view-stock-ledger').on('click', function(e) {
e.stopPropagation();
const herbId = $(this).data('herb-id');
const herbName = $(this).data('herb-name');
viewStockLedger(herbId, herbName);
});
}
});
}
@ -1128,6 +1293,98 @@ $(document).ready(function() {
});
}
// 재고 원장 보기
function viewStockLedger(herbId, herbName) {
const url = herbId ? `/api/stock-ledger?herb_id=${herbId}` : '/api/stock-ledger';
$.get(url, function(response) {
if (response.success) {
const tbody = $('#stockLedgerList');
tbody.empty();
// 헤더 업데이트
if (herbName) {
$('#stockLedgerModal .modal-title').html(`<i class="bi bi-journal-text"></i> ${herbName} 입출고 원장`);
} else {
$('#stockLedgerModal .modal-title').html(`<i class="bi bi-journal-text"></i> 전체 입출고 원장`);
}
response.ledger.forEach(entry => {
let typeLabel = '';
let typeBadge = '';
switch(entry.event_type) {
case 'PURCHASE':
case 'RECEIPT':
typeLabel = '입고';
typeBadge = 'badge bg-success';
break;
case 'CONSUME':
typeLabel = '출고';
typeBadge = 'badge bg-danger';
break;
default:
typeLabel = entry.event_type;
typeBadge = 'badge bg-secondary';
}
const quantity = Math.abs(entry.quantity_delta);
const sign = entry.quantity_delta > 0 ? '+' : '-';
const quantityDisplay = entry.quantity_delta > 0
? `<span class="text-success">+${quantity.toFixed(1)}g</span>`
: `<span class="text-danger">-${quantity.toFixed(1)}g</span>`;
const referenceInfo = entry.patient_name
? `${entry.patient_name}`
: entry.supplier_name || '-';
tbody.append(`
<tr>
<td>${entry.event_time}</td>
<td><span class="${typeBadge}">${typeLabel}</span></td>
<td>${entry.herb_name}</td>
<td>${quantityDisplay}</td>
<td>${entry.unit_cost_per_g ? formatCurrency(entry.unit_cost_per_g) + '/g' : '-'}</td>
<td>${entry.origin_country || '-'}</td>
<td>${referenceInfo}</td>
<td>${entry.reference_no || '-'}</td>
</tr>
`);
});
// 약재 필터 옵션 업데이트
const herbFilter = $('#ledgerHerbFilter');
if (herbFilter.find('option').length <= 1) {
response.summary.forEach(herb => {
herbFilter.append(`<option value="${herb.herb_item_id}">${herb.herb_name}</option>`);
});
}
$('#stockLedgerModal').modal('show');
}
}).fail(function() {
alert('입출고 내역을 불러오는데 실패했습니다.');
});
}
// 입출고 원장 모달 버튼 이벤트
$('#showStockLedgerBtn').on('click', function() {
viewStockLedger(null, null);
});
// 필터 변경 이벤트
$('#ledgerHerbFilter, #ledgerTypeFilter').on('change', function() {
const herbId = $('#ledgerHerbFilter').val();
const typeFilter = $('#ledgerTypeFilter').val();
// 재로드 (필터 적용은 프론트엔드에서 처리)
if (herbId) {
const herbName = $('#ledgerHerbFilter option:selected').text();
viewStockLedger(herbId, herbName);
} else {
viewStockLedger(null, null);
}
});
function formatCurrency(amount) {
if (amount === null || amount === undefined) return '0원';
return new Intl.NumberFormat('ko-KR', {
@ -1135,4 +1392,161 @@ $(document).ready(function() {
currency: 'KRW'
}).format(amount);
}
// ==================== 주성분코드 기반 약재 관리 ====================
let allHerbMasters = []; // 전체 약재 데이터 저장
let currentFilter = 'all'; // 현재 필터 상태
// 약재 마스터 목록 로드
function loadHerbMasters() {
$.get('/api/herbs/masters', function(response) {
if (response.success) {
allHerbMasters = response.data;
// 통계 정보 표시
const summary = response.summary;
$('#herbMasterSummary').html(`
<div class="row align-items-center">
<div class="col-md-9">
<h6 class="mb-2">📊 급여 약재 현황</h6>
<div class="d-flex align-items-center">
<div class="me-4">
<strong>전체:</strong> ${summary.total_herbs}
</div>
<div class="me-4">
<strong>재고 있음:</strong> <span class="text-success">${summary.herbs_with_stock}</span>
</div>
<div class="me-4">
<strong>재고 없음:</strong> <span class="text-secondary">${summary.herbs_without_stock}</span>
</div>
<div>
<strong>보유율:</strong> <span class="badge bg-primary fs-6">${summary.coverage_rate}%</span>
</div>
</div>
</div>
<div class="col-md-3">
<div class="progress" style="height: 25px;">
<div class="progress-bar bg-success" role="progressbar"
style="width: ${summary.coverage_rate}%"
aria-valuenow="${summary.coverage_rate}"
aria-valuemin="0" aria-valuemax="100">
${summary.herbs_with_stock} / ${summary.total_herbs}
</div>
</div>
</div>
</div>
`);
// 목록 표시
displayHerbMasters(allHerbMasters);
}
});
}
// 약재 목록 표시
function displayHerbMasters(herbs) {
const tbody = $('#herbMastersList');
tbody.empty();
// 필터링
let filteredHerbs = herbs;
if (currentFilter === 'stock') {
filteredHerbs = herbs.filter(h => h.has_stock);
} else if (currentFilter === 'no-stock') {
filteredHerbs = herbs.filter(h => !h.has_stock);
}
// 검색 필터
const searchText = $('#herbSearch').val().toLowerCase();
if (searchText) {
filteredHerbs = filteredHerbs.filter(h =>
h.herb_name.toLowerCase().includes(searchText) ||
h.ingredient_code.toLowerCase().includes(searchText)
);
}
// 효능 필터
const efficacyFilter = $('#efficacyFilter').val();
if (efficacyFilter) {
filteredHerbs = filteredHerbs.filter(h =>
h.efficacy_tags && h.efficacy_tags.includes(efficacyFilter)
);
}
// 표시
filteredHerbs.forEach(herb => {
// 효능 태그 표시
let efficacyTags = '';
if (herb.efficacy_tags && herb.efficacy_tags.length > 0) {
efficacyTags = herb.efficacy_tags.map(tag =>
`<span class="badge bg-success ms-1">${tag}</span>`
).join('');
}
// 상태 표시
const statusBadge = herb.has_stock
? '<span class="badge bg-success">재고 있음</span>'
: '<span class="badge bg-secondary">재고 없음</span>';
// 재고량 표시
const stockDisplay = herb.stock_quantity > 0
? `${herb.stock_quantity.toFixed(1)}g`
: '-';
// 평균단가 표시
const priceDisplay = herb.avg_price > 0
? formatCurrency(herb.avg_price)
: '-';
tbody.append(`
<tr class="${herb.has_stock ? '' : 'table-secondary'}">
<td><code>${herb.ingredient_code}</code></td>
<td><strong>${herb.herb_name}</strong></td>
<td>${efficacyTags}</td>
<td>${stockDisplay}</td>
<td>${priceDisplay}</td>
<td>${herb.product_count || 0}</td>
<td>${statusBadge}</td>
<td>
<button class="btn btn-sm btn-outline-primary" onclick="viewHerbDetail('${herb.ingredient_code}')">
<i class="bi bi-eye"></i>
</button>
</td>
</tr>
`);
});
if (filteredHerbs.length === 0) {
tbody.append('<tr><td colspan="8" class="text-center">표시할 약재가 없습니다.</td></tr>');
}
}
// 약재 상세 보기
function viewHerbDetail(ingredientCode) {
// TODO: 약재 상세 모달 구현
console.log('View detail for:', ingredientCode);
}
// 필터 버튼 이벤트
$('#herbs .btn-group button[data-filter]').on('click', function() {
$('#herbs .btn-group button').removeClass('active');
$(this).addClass('active');
currentFilter = $(this).data('filter');
displayHerbMasters(allHerbMasters);
});
// 검색 이벤트
$('#herbSearch').on('keyup', function() {
displayHerbMasters(allHerbMasters);
});
// 효능 필터 이벤트
$('#efficacyFilter').on('change', function() {
displayHerbMasters(allHerbMasters);
});
// 약재 관리 페이지가 활성화되면 데이터 로드
$('.nav-link[data-page="herbs"]').on('click', function() {
setTimeout(() => loadHerbMasters(), 100);
});
});

View File

@ -408,20 +408,49 @@
</div>
<div class="card mt-4">
<div class="card-header">
<h5 class="mb-0">조제 내역</h5>
<div class="card-header bg-light">
<div class="d-flex justify-content-between align-items-center">
<h5 class="mb-0"><i class="bi bi-clipboard-pulse"></i> 조제 내역</h5>
<div>
<span class="badge bg-primary me-2">오늘 조제: <span id="todayCompoundCount">0</span></span>
<span class="badge bg-info">이번달 조제: <span id="monthCompoundCount">0</span></span>
</div>
</div>
</div>
<div class="card-body">
<table class="table table-hover">
<thead>
<div class="row mb-3">
<div class="col-md-4">
<input type="date" class="form-control" id="compoundDateFilter" placeholder="날짜 선택">
</div>
<div class="col-md-4">
<input type="text" class="form-control" id="compoundPatientFilter" placeholder="환자명 검색">
</div>
<div class="col-md-4">
<button class="btn btn-outline-secondary" id="refreshCompoundsBtn">
<i class="bi bi-arrow-clockwise"></i> 새로고침
</button>
<button class="btn btn-outline-info ms-2" id="exportCompoundsBtn">
<i class="bi bi-download"></i> 엑셀 다운로드
</button>
</div>
</div>
<div class="table-responsive">
<table class="table table-hover table-striped">
<thead class="table-dark">
<tr>
<th>조제일</th>
<th>환자명</th>
<th width="40">#</th>
<th width="100">조제일시</th>
<th width="80">환자명</th>
<th width="100">연락처</th>
<th>처방명</th>
<th>제수</th>
<th>파우치</th>
<th>원가</th>
<th>상태</th>
<th width="60">제수</th>
<th width="60">첩수</th>
<th width="80">파우치</th>
<th width="100">원가</th>
<th width="100">판매가</th>
<th width="80">상태</th>
<th width="100">처방전번호</th>
<th width="120">작업</th>
</tr>
</thead>
<tbody id="compoundsList">
@ -429,17 +458,171 @@
</tbody>
</table>
</div>
<nav aria-label="Page navigation">
<ul class="pagination justify-content-center" id="compoundsPagination">
<!-- Dynamic pagination -->
</ul>
</nav>
</div>
</div>
<!-- 조제 상세보기 모달 -->
<div class="modal fade" id="compoundDetailModal" tabindex="-1">
<div class="modal-dialog modal-xl">
<div class="modal-content">
<div class="modal-header bg-info text-white">
<h5 class="modal-title"><i class="bi bi-file-medical"></i> 조제 상세 정보</h5>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div class="row mb-3">
<div class="col-md-6">
<div class="card">
<div class="card-header bg-light">
<h6 class="mb-0"><i class="bi bi-person"></i> 환자 정보</h6>
</div>
<div class="card-body">
<dl class="row mb-0">
<dt class="col-sm-4">환자명:</dt>
<dd class="col-sm-8" id="detailPatientName"></dd>
<dt class="col-sm-4">연락처:</dt>
<dd class="col-sm-8" id="detailPatientPhone"></dd>
<dt class="col-sm-4">조제일:</dt>
<dd class="col-sm-8" id="detailCompoundDate"></dd>
</dl>
</div>
</div>
</div>
<div class="col-md-6">
<div class="card">
<div class="card-header bg-light">
<h6 class="mb-0"><i class="bi bi-journal-medical"></i> 처방 정보</h6>
</div>
<div class="card-body">
<dl class="row mb-0">
<dt class="col-sm-4">처방명:</dt>
<dd class="col-sm-8" id="detailFormulaName"></dd>
<dt class="col-sm-4">처방전번호:</dt>
<dd class="col-sm-8" id="detailPrescriptionNo"></dd>
<dt class="col-sm-4">제수/첩수/파우치:</dt>
<dd class="col-sm-8" id="detailQuantities"></dd>
</dl>
</div>
</div>
</div>
</div>
<div class="card">
<div class="card-header bg-light">
<h6 class="mb-0"><i class="bi bi-list-ul"></i> 처방 구성 약재</h6>
</div>
<div class="card-body">
<table class="table table-sm">
<thead>
<tr>
<th>약재명</th>
<th>보험코드</th>
<th>1첩당 용량</th>
<th>총 사용량</th>
<th>비고</th>
</tr>
</thead>
<tbody id="detailIngredients">
<!-- Dynamic content -->
</tbody>
</table>
</div>
</div>
<div class="card mt-3">
<div class="card-header bg-light">
<h6 class="mb-0"><i class="bi bi-box-seam"></i> 재고 소비 내역</h6>
</div>
<div class="card-body">
<table class="table table-sm">
<thead>
<tr>
<th>약재명</th>
<th>원산지</th>
<th>공급처</th>
<th>사용량</th>
<th>단가</th>
<th>금액</th>
</tr>
</thead>
<tbody id="detailConsumptions">
<!-- Dynamic content -->
</tbody>
<tfoot>
<tr>
<th colspan="5" class="text-end">총 원가:</th>
<th id="detailTotalCost"></th>
</tr>
</tfoot>
</table>
</div>
</div>
<div class="row mt-3">
<div class="col-md-12">
<label>비고:</label>
<p id="detailNotes" class="form-control-plaintext"></p>
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-info" id="printCompoundBtn">
<i class="bi bi-printer"></i> 인쇄
</button>
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">닫기</button>
</div>
</div>
</div>
</div>
</div>
<!-- Inventory Page -->
<div id="inventory" class="main-content">
<h3 class="mb-4">재고 현황</h3>
<div class="d-flex justify-content-between align-items-center mb-4">
<h3><i class="bi bi-box-seam"></i> 재고 현황</h3>
<button class="btn btn-outline-info" id="showStockLedgerBtn">
<i class="bi bi-journal-text"></i> 입출고 원장
</button>
</div>
<div class="row mb-3">
<div class="col-md-3">
<div class="stat-card bg-primary text-white">
<h6>총 재고 금액</h6>
<h4 id="totalInventoryValue">₩0</h4>
</div>
</div>
<div class="col-md-3">
<div class="stat-card bg-success text-white">
<h6>재고 보유 약재</h6>
<h4 id="totalHerbsInStock">0종</h4>
</div>
</div>
<div class="col-md-3">
<div class="stat-card bg-warning text-white">
<h6>오늘 입고</h6>
<h4 id="todayPurchases">0건</h4>
</div>
</div>
<div class="col-md-3">
<div class="stat-card bg-info text-white">
<h6>오늘 출고</h6>
<h4 id="todayConsumptions">0건</h4>
</div>
</div>
</div>
<div class="card">
<div class="card-body">
<div class="mb-3">
<input type="text" class="form-control" id="inventorySearch" placeholder="약재명으로 검색...">
</div>
<div class="table-responsive">
<table class="table table-hover">
<thead>
<tr>
@ -449,6 +632,7 @@
<th>로트 수</th>
<th>평균 단가</th>
<th>재고 금액</th>
<th>작업</th>
</tr>
</thead>
<tbody id="inventoryList">
@ -459,27 +643,123 @@
</div>
</div>
<!-- 재고 원장 모달 -->
<div class="modal fade" id="stockLedgerModal" tabindex="-1">
<div class="modal-dialog modal-xl">
<div class="modal-content">
<div class="modal-header bg-dark text-white">
<h5 class="modal-title"><i class="bi bi-journal-text"></i> 입출고 원장</h5>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div class="row mb-3">
<div class="col-md-6">
<select class="form-control" id="ledgerHerbFilter">
<option value="">전체 약재</option>
</select>
</div>
<div class="col-md-6">
<select class="form-control" id="ledgerTypeFilter">
<option value="">전체 내역</option>
<option value="PURCHASE">입고만</option>
<option value="CONSUME">출고만</option>
<option value="RECEIPT">입고접수</option>
</select>
</div>
</div>
<div class="table-responsive" style="max-height: 500px; overflow-y: auto;">
<table class="table table-sm table-striped">
<thead class="table-dark sticky-top">
<tr>
<th>일시</th>
<th>구분</th>
<th>약재명</th>
<th>수량(g)</th>
<th>단가</th>
<th>원산지</th>
<th>공급처/환자</th>
<th>참조번호</th>
</tr>
</thead>
<tbody id="stockLedgerList">
<!-- Dynamic content -->
</tbody>
</table>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">닫기</button>
</div>
</div>
</div>
</div>
</div>
<!-- Herbs Page -->
<div id="herbs" class="main-content">
<div class="d-flex justify-content-between align-items-center mb-4">
<h3>약재 관리</h3>
<h3>약재 관리 <small class="text-muted">(주성분코드 기준)</small></h3>
<div>
<div class="btn-group me-2" role="group">
<button type="button" class="btn btn-outline-primary active" data-filter="all">
<i class="bi bi-list"></i> 전체
</button>
<button type="button" class="btn btn-outline-success" data-filter="stock">
<i class="bi bi-check-circle"></i> 재고 있음
</button>
<button type="button" class="btn btn-outline-secondary" data-filter="no-stock">
<i class="bi bi-x-circle"></i> 재고 없음
</button>
</div>
<button class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#herbModal">
<i class="bi bi-plus-circle"></i> 새 약재 등록
</button>
</div>
</div>
<!-- 통계 정보 -->
<div id="herbMasterSummary" class="alert alert-info mb-3">
<!-- Dynamic summary -->
</div>
<!-- 검색 바 -->
<div class="card mb-3">
<div class="card-body">
<div class="row">
<div class="col-md-6">
<input type="text" class="form-control" id="herbSearch"
placeholder="약재명 또는 주성분코드로 검색...">
</div>
<div class="col-md-6">
<select class="form-select" id="efficacyFilter">
<option value="">모든 효능</option>
<option value="보혈">보혈</option>
<option value="보기">보기</option>
<option value="활혈">활혈</option>
<option value="온중">온중</option>
<option value="청열">청열</option>
</select>
</div>
</div>
</div>
</div>
<div class="card">
<div class="card-body">
<table class="table table-hover">
<thead>
<tr>
<th>보험코드</th>
<th>주성분코드</th>
<th>약재명</th>
<th>규격</th>
<th>현재 재고</th>
<th>효능</th>
<th>재고량</th>
<th>평균단가</th>
<th>제품수</th>
<th>상태</th>
<th>작업</th>
</tr>
</thead>
<tbody id="herbsList">
<tbody id="herbMastersList">
<!-- Dynamic content -->
</tbody>
</table>

68
test_direct.py Normal file
View File

@ -0,0 +1,68 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
직접 JavaScript 함수 호출 테스트
"""
from playwright.sync_api import sync_playwright
import time
def test_direct_load():
with sync_playwright() as p:
browser = p.chromium.launch(headless=True)
page = browser.new_page()
print("페이지 로드 중...")
page.goto("http://localhost:5001")
page.wait_for_load_state("networkidle")
time.sleep(2)
# 직접 입고 관리 탭 활성화 및 함수 호출
print("\n입고 데이터 직접 로드...")
page.evaluate("""
// 입고 표시
document.querySelectorAll('.tab-pane').forEach(pane => pane.classList.remove('show', 'active'));
document.querySelector('#purchase').classList.add('show', 'active');
// 직접 함수 호출
if (typeof loadPurchaseReceipts === 'function') {
loadPurchaseReceipts();
} else {
console.error('loadPurchaseReceipts 함수를 찾을 수 없습니다');
}
""")
time.sleep(2)
# 테이블 내용 확인
table_html = page.inner_html('#purchaseReceiptsList')
print(f"\n테이블 내용 (처음 500자):\n{table_html[:500]}")
# 테이블 행 수 확인
row_count = page.evaluate("document.querySelectorAll('#purchaseReceiptsList tr').length")
print(f"\n테이블 행 수: {row_count}")
# 첫 번째 행 내용 확인
if row_count > 0:
first_row = page.evaluate("""
const row = document.querySelector('#purchaseReceiptsList tr');
if (row) {
const cells = row.querySelectorAll('td');
return Array.from(cells).map(cell => cell.textContent.trim());
}
return null;
""")
if first_row:
print(f"\n첫 번째 행 데이터:")
headers = ['입고일', '공급업체', '품목 수', '총 금액', '총 수량', '파일명', '작업']
for i, value in enumerate(first_row[:-1]): # 마지막 '작업' 열 제외
if i < len(headers):
print(f" {headers[i]}: {value}")
# 스크린샷
page.screenshot(path="/root/kdrug/direct_test.png")
print("\n스크린샷 저장: /root/kdrug/direct_test.png")
browser.close()
if __name__ == "__main__":
test_direct_load()

78
test_frontend.py Normal file
View File

@ -0,0 +1,78 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Playwright로 프론트엔드 확인
"""
from playwright.sync_api import sync_playwright
import time
import json
def test_purchase_receipts():
with sync_playwright() as p:
# 브라우저 시작
browser = p.chromium.launch(headless=True)
page = browser.new_page()
# 페이지 이동
print("1. 페이지 로드 중...")
page.goto("http://localhost:5001")
page.wait_for_load_state("networkidle")
# 입고 관리 탭 클릭
print("2. 입고 관리 탭으로 이동...")
try:
page.click('a[href="#purchase"]', timeout=5000)
except:
# 다른 방법으로 시도
page.evaluate("document.querySelector('a[href=\"#purchase\"]').click()")
time.sleep(1)
# API 호출 직접 확인
print("3. API 직접 호출 확인...")
api_response = page.evaluate("""
async () => {
const response = await fetch('/api/purchase-receipts');
const data = await response.json();
return data;
}
""")
print("\n=== API 응답 데이터 ===")
print(json.dumps(api_response, indent=2, ensure_ascii=False))
# 테이블 내용 확인
print("\n4. 테이블 렌더링 확인...")
table_rows = page.query_selector_all('#purchaseReceiptsList tr')
if len(table_rows) == 0:
print(" 테이블에 행이 없습니다.")
# "입고장이 없습니다." 메시지 확인
empty_message = page.query_selector('#purchaseReceiptsList td')
if empty_message:
print(f" 메시지: {empty_message.text_content()}")
else:
print(f" 테이블 행 수: {len(table_rows)}")
# 첫 번째 행 상세 확인
if len(table_rows) > 0:
first_row = table_rows[0]
cells = first_row.query_selector_all('td')
print("\n 첫 번째 행 내용:")
headers = ['입고일', '공급업체', '품목 수', '총 금액', '총 수량', '파일명', '작업']
for i, cell in enumerate(cells[:-1]): # 마지막 '작업' 열 제외
print(f" {headers[i]}: {cell.text_content()}")
# JavaScript 콘솔 에러 확인
page.on("console", lambda msg: print(f"콘솔: {msg.text}") if msg.type == "error" else None)
# 스크린샷 저장
print("\n5. 스크린샷 저장...")
page.screenshot(path="/root/kdrug/purchase_screenshot.png")
print(" /root/kdrug/purchase_screenshot.png 저장 완료")
browser.close()
if __name__ == "__main__":
test_purchase_receipts()

60
test_simple.py Normal file
View File

@ -0,0 +1,60 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
간단한 프론트엔드 확인
"""
from playwright.sync_api import sync_playwright
import time
def test_purchase_display():
with sync_playwright() as p:
browser = p.chromium.launch(headless=True)
page = browser.new_page()
print("페이지 로드 중...")
page.goto("http://localhost:5001", wait_until="networkidle")
# 입고 관리 화면으로 직접 이동
print("\n입고 관리 화면 확인...")
page.goto("http://localhost:5001/#purchase", wait_until="networkidle")
time.sleep(2) # JavaScript 렌더링 대기
# API 데이터와 실제 렌더링 비교
print("\n=== API 데이터 vs 화면 렌더링 확인 ===")
# API 응답 확인
api_data = page.evaluate("""
fetch('/api/purchase-receipts')
.then(response => response.json())
.then(data => data)
""")
time.sleep(1)
# 테이블 확인
table_html = page.evaluate("document.querySelector('#purchaseReceiptsList').innerHTML")
print(f"\nAPI 응답 총금액: {api_data.get('data', [{}])[0].get('total_amount', 0)}")
# 화면에 표시된 총금액 찾기
try:
total_amount_cell = page.query_selector('.fw-bold.text-primary')
if total_amount_cell:
print(f"화면 표시 총금액: {total_amount_cell.text_content()}")
else:
print("총금액 셀을 찾을 수 없습니다.")
except:
pass
# 테이블 전체 내용
print("\n테이블 HTML (처음 200자):")
print(table_html[:200] if table_html else "테이블이 비어있음")
# 스크린샷
page.screenshot(path="/root/kdrug/purchase_test.png")
print("\n스크린샷 저장: /root/kdrug/purchase_test.png")
browser.close()
if __name__ == "__main__":
test_purchase_display()

Binary file not shown.