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:
parent
63128fdccb
commit
38838e5ecf
67
.claude/project_state.md
Normal file
67
.claude/project_state.md
Normal 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
62
analyze_product_code.py
Normal 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
84
analyze_product_deep.py
Normal 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
325
app.py
@ -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'])
|
||||
|
||||
BIN
backups/kdrug_full_backup_20260215_before_refactoring.tar.gz
Normal file
BIN
backups/kdrug_full_backup_20260215_before_refactoring.tar.gz
Normal file
Binary file not shown.
65
check_totals.py
Normal file
65
check_totals.py
Normal 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
103
debug_receipt_detail.py
Normal 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
BIN
direct_test.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 58 KiB |
128
docs/주성분코드_기반_개선_계획.md
Normal file
128
docs/주성분코드_기반_개선_계획.md
Normal 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
55
fix_database.py
Normal 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
BIN
purchase_test.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 42 KiB |
BIN
sample/(게시)한약재제품코드_2510.xlsx
Normal file
BIN
sample/(게시)한약재제품코드_2510.xlsx
Normal file
Binary file not shown.
422
static/app.js
422
static/app.js
@ -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);
|
||||
});
|
||||
});
|
||||
@ -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
68
test_direct.py
Normal 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
78
test_frontend.py
Normal 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
60
test_simple.py
Normal 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()
|
||||
BIN
uploads/20260215_083747_xlsx
Normal file
BIN
uploads/20260215_083747_xlsx
Normal file
Binary file not shown.
Loading…
Reference in New Issue
Block a user