Compare commits
11 Commits
2a14af59c3
...
724af5000a
| Author | SHA1 | Date | |
|---|---|---|---|
| 724af5000a | |||
| b58e46f8fd | |||
| 91ebfc2984 | |||
| 1826ea5ca4 | |||
| 76a2b5c1a6 | |||
| 6de812cfe6 | |||
| 496a99ff98 | |||
| 11ca86ca41 | |||
| 38838e5ecf | |||
| 63128fdccb | |||
| 8f2823e6df |
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()
|
||||
538
app.py
538
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'])
|
||||
@ -362,12 +434,26 @@ def upload_purchase_excel():
|
||||
|
||||
for receipt_date, group in grouped:
|
||||
|
||||
# 입고장 번호 생성 (PR-YYYYMMDD-XXXX)
|
||||
date_str = str(receipt_date).replace('-', '')
|
||||
|
||||
# 해당 날짜의 최대 번호 찾기
|
||||
cursor.execute("""
|
||||
SELECT MAX(CAST(SUBSTR(receipt_no, -4) AS INTEGER))
|
||||
FROM purchase_receipts
|
||||
WHERE receipt_no LIKE ?
|
||||
""", (f'PR-{date_str}-%',))
|
||||
|
||||
max_num = cursor.fetchone()[0]
|
||||
next_num = (max_num or 0) + 1
|
||||
receipt_no = f"PR-{date_str}-{next_num:04d}"
|
||||
|
||||
# 입고장 헤더 생성
|
||||
total_amount = group['total_amount'].sum()
|
||||
cursor.execute("""
|
||||
INSERT INTO purchase_receipts (supplier_id, receipt_date, total_amount, source_file)
|
||||
VALUES (?, ?, ?, ?)
|
||||
""", (supplier_id, str(receipt_date), total_amount, filename))
|
||||
INSERT INTO purchase_receipts (supplier_id, receipt_date, receipt_no, total_amount, source_file)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
""", (supplier_id, str(receipt_date), receipt_no, total_amount, filename))
|
||||
receipt_id = cursor.lastrowid
|
||||
|
||||
# 입고장 라인 생성
|
||||
@ -723,6 +809,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 +1074,130 @@ 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' OR sl.event_type = 'RECEIPT' THEN pr.receipt_no
|
||||
WHEN sl.event_type = 'CONSUME' THEN
|
||||
CASE
|
||||
WHEN c.prescription_no IS NOT NULL THEN c.prescription_no || ' (' || COALESCE(f.formula_name, '직접조제') || ')'
|
||||
ELSE '조제#' || c.compound_id || ' (' || COALESCE(f.formula_name, '직접조제') || ')'
|
||||
END
|
||||
WHEN sl.event_type = 'ADJUST' THEN adj.adjustment_no
|
||||
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
|
||||
LEFT JOIN formulas f ON c.formula_id = f.formula_id
|
||||
LEFT JOIN stock_adjustments adj ON sl.reference_table = 'stock_adjustments' AND sl.reference_id = adj.adjustment_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' OR sl.event_type = 'RECEIPT' THEN pr.receipt_no
|
||||
WHEN sl.event_type = 'CONSUME' THEN
|
||||
CASE
|
||||
WHEN c.prescription_no IS NOT NULL THEN c.prescription_no || ' (' || COALESCE(f.formula_name, '직접조제') || ')'
|
||||
ELSE '조제#' || c.compound_id || ' (' || COALESCE(f.formula_name, '직접조제') || ')'
|
||||
END
|
||||
WHEN sl.event_type = 'ADJUST' THEN adj.adjustment_no
|
||||
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
|
||||
LEFT JOIN formulas f ON c.formula_id = f.formula_id
|
||||
LEFT JOIN stock_adjustments adj ON sl.reference_table = 'stock_adjustments' AND sl.reference_id = adj.adjustment_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'])
|
||||
@ -966,12 +1319,31 @@ def get_inventory_summary():
|
||||
total_value = sum(item['total_value'] for item in inventory)
|
||||
total_items = len(inventory)
|
||||
|
||||
# 주성분코드 기준 보유 현황 추가
|
||||
cursor.execute("""
|
||||
SELECT COUNT(DISTINCT ingredient_code)
|
||||
FROM herb_masters
|
||||
""")
|
||||
total_ingredient_codes = cursor.fetchone()[0]
|
||||
|
||||
cursor.execute("""
|
||||
SELECT COUNT(DISTINCT h.ingredient_code)
|
||||
FROM herb_items h
|
||||
INNER JOIN inventory_lots il ON h.herb_item_id = il.herb_item_id
|
||||
WHERE il.quantity_onhand > 0 AND il.is_depleted = 0
|
||||
AND h.ingredient_code IS NOT NULL
|
||||
""")
|
||||
owned_ingredient_codes = cursor.fetchone()[0]
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'data': inventory,
|
||||
'summary': {
|
||||
'total_items': total_items,
|
||||
'total_value': total_value
|
||||
'total_value': total_value,
|
||||
'total_ingredient_codes': total_ingredient_codes, # 전체 급여 약재 수
|
||||
'owned_ingredient_codes': owned_ingredient_codes, # 보유 약재 수
|
||||
'coverage_rate': round(owned_ingredient_codes * 100 / total_ingredient_codes, 1) if total_ingredient_codes > 0 else 0 # 보유율
|
||||
}
|
||||
})
|
||||
except Exception as e:
|
||||
@ -1050,6 +1422,164 @@ def get_inventory_detail(herb_item_id):
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
# 서버 실행
|
||||
# ==================== 재고 보정 API ====================
|
||||
|
||||
@app.route('/api/stock-adjustments', methods=['GET'])
|
||||
def get_stock_adjustments():
|
||||
"""재고 보정 내역 조회"""
|
||||
try:
|
||||
limit = request.args.get('limit', 100, type=int)
|
||||
|
||||
with get_db() as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("""
|
||||
SELECT
|
||||
sa.adjustment_id,
|
||||
sa.adjustment_date,
|
||||
sa.adjustment_no,
|
||||
sa.adjustment_type,
|
||||
sa.notes,
|
||||
sa.created_by,
|
||||
sa.created_at,
|
||||
COUNT(sad.detail_id) as detail_count,
|
||||
SUM(ABS(sad.quantity_delta)) as total_adjusted
|
||||
FROM stock_adjustments sa
|
||||
LEFT JOIN stock_adjustment_details sad ON sa.adjustment_id = sad.adjustment_id
|
||||
GROUP BY sa.adjustment_id
|
||||
ORDER BY sa.adjustment_date DESC, sa.created_at DESC
|
||||
LIMIT ?
|
||||
""", (limit,))
|
||||
|
||||
adjustments = [dict(row) for row in cursor.fetchall()]
|
||||
return jsonify({'success': True, 'data': adjustments})
|
||||
except Exception as e:
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
@app.route('/api/stock-adjustments/<int:adjustment_id>', methods=['GET'])
|
||||
def get_stock_adjustment_detail(adjustment_id):
|
||||
"""재고 보정 상세 조회"""
|
||||
try:
|
||||
with get_db() as conn:
|
||||
cursor = conn.cursor()
|
||||
|
||||
# 보정 헤더
|
||||
cursor.execute("""
|
||||
SELECT * FROM stock_adjustments
|
||||
WHERE adjustment_id = ?
|
||||
""", (adjustment_id,))
|
||||
adjustment = dict(cursor.fetchone())
|
||||
|
||||
# 보정 상세
|
||||
cursor.execute("""
|
||||
SELECT
|
||||
sad.*,
|
||||
h.herb_name,
|
||||
h.insurance_code,
|
||||
il.origin_country,
|
||||
s.name as supplier_name
|
||||
FROM stock_adjustment_details sad
|
||||
JOIN herb_items h ON sad.herb_item_id = h.herb_item_id
|
||||
JOIN inventory_lots il ON sad.lot_id = il.lot_id
|
||||
LEFT JOIN suppliers s ON il.supplier_id = s.supplier_id
|
||||
WHERE sad.adjustment_id = ?
|
||||
""", (adjustment_id,))
|
||||
|
||||
details = [dict(row) for row in cursor.fetchall()]
|
||||
adjustment['details'] = details
|
||||
|
||||
return jsonify({'success': True, 'data': adjustment})
|
||||
except Exception as e:
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
@app.route('/api/stock-adjustments', methods=['POST'])
|
||||
def create_stock_adjustment():
|
||||
"""재고 보정 생성"""
|
||||
try:
|
||||
data = request.json
|
||||
|
||||
with get_db() as conn:
|
||||
cursor = conn.cursor()
|
||||
|
||||
# 보정 번호 생성 (ADJ-YYYYMMDD-XXXX)
|
||||
adjustment_date = data.get('adjustment_date', datetime.now().strftime('%Y-%m-%d'))
|
||||
date_str = adjustment_date.replace('-', '')
|
||||
|
||||
cursor.execute("""
|
||||
SELECT MAX(CAST(SUBSTR(adjustment_no, -4) AS INTEGER))
|
||||
FROM stock_adjustments
|
||||
WHERE adjustment_no LIKE ?
|
||||
""", (f'ADJ-{date_str}-%',))
|
||||
|
||||
max_num = cursor.fetchone()[0]
|
||||
next_num = (max_num or 0) + 1
|
||||
adjustment_no = f"ADJ-{date_str}-{next_num:04d}"
|
||||
|
||||
# 보정 헤더 생성
|
||||
cursor.execute("""
|
||||
INSERT INTO stock_adjustments (adjustment_date, adjustment_no, adjustment_type, notes, created_by)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
""", (
|
||||
adjustment_date,
|
||||
adjustment_no,
|
||||
data['adjustment_type'],
|
||||
data.get('notes'),
|
||||
data.get('created_by', 'system')
|
||||
))
|
||||
adjustment_id = cursor.lastrowid
|
||||
|
||||
# 보정 상세 처리
|
||||
for detail in data['details']:
|
||||
herb_item_id = detail['herb_item_id']
|
||||
lot_id = detail['lot_id']
|
||||
quantity_before = detail['quantity_before']
|
||||
quantity_after = detail['quantity_after']
|
||||
quantity_delta = quantity_after - quantity_before
|
||||
|
||||
# 보정 상세 기록
|
||||
cursor.execute("""
|
||||
INSERT INTO stock_adjustment_details (adjustment_id, herb_item_id, lot_id,
|
||||
quantity_before, quantity_after, quantity_delta, reason)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
""", (
|
||||
adjustment_id, herb_item_id, lot_id,
|
||||
quantity_before, quantity_after, quantity_delta,
|
||||
detail.get('reason')
|
||||
))
|
||||
|
||||
# 재고 로트 업데이트
|
||||
cursor.execute("""
|
||||
UPDATE inventory_lots
|
||||
SET quantity_onhand = ?,
|
||||
is_depleted = ?,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE lot_id = ?
|
||||
""", (quantity_after, 1 if quantity_after == 0 else 0, lot_id))
|
||||
|
||||
# 재고 원장 기록
|
||||
cursor.execute("""
|
||||
SELECT unit_price_per_g FROM inventory_lots WHERE lot_id = ?
|
||||
""", (lot_id,))
|
||||
unit_price = cursor.fetchone()[0]
|
||||
|
||||
cursor.execute("""
|
||||
INSERT INTO stock_ledger (event_type, herb_item_id, lot_id,
|
||||
quantity_delta, unit_cost_per_g,
|
||||
reference_table, reference_id, notes, created_by)
|
||||
VALUES ('ADJUST', ?, ?, ?, ?, 'stock_adjustments', ?, ?, ?)
|
||||
""", (
|
||||
herb_item_id, lot_id, quantity_delta, unit_price,
|
||||
adjustment_id, detail.get('reason'), data.get('created_by', 'system')
|
||||
))
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'message': '재고 보정이 완료되었습니다',
|
||||
'adjustment_id': adjustment_id,
|
||||
'adjustment_no': adjustment_no
|
||||
})
|
||||
except Exception as e:
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
if __name__ == '__main__':
|
||||
# 데이터베이스 초기화
|
||||
if not os.path.exists(app.config['DATABASE']):
|
||||
|
||||
57
backups/BACKUP_INFO.md
Normal file
57
backups/BACKUP_INFO.md
Normal file
@ -0,0 +1,57 @@
|
||||
# 백업 정보
|
||||
|
||||
## 백업 일시
|
||||
- **생성일**: 2026년 2월 15일
|
||||
- **목적**: 데이터베이스 리팩토링 전 전체 백업
|
||||
|
||||
## 백업 파일
|
||||
|
||||
### 1. 데이터베이스 백업
|
||||
- **파일명**: `kdrug_backup_20260215_before_refactoring.db`
|
||||
- **크기**: 140KB
|
||||
- **내용**: SQLite 데이터베이스 전체
|
||||
- **테이블**:
|
||||
- herb_items (약재 마스터)
|
||||
- inventory_lots (재고 로트)
|
||||
- formulas (처방)
|
||||
- compounds (조제 내역)
|
||||
- 기타 관련 테이블
|
||||
|
||||
### 2. 전체 코드 백업
|
||||
- **파일명**: `kdrug_full_backup_20260215_before_refactoring.tar.gz`
|
||||
- **크기**: 3.3MB
|
||||
- **내용**:
|
||||
- Python 소스코드 (app.py, 기타 스크립트)
|
||||
- 템플릿 (HTML)
|
||||
- 정적 파일 (CSS, JavaScript)
|
||||
- 설정 파일
|
||||
- 문서
|
||||
|
||||
## 복원 방법
|
||||
|
||||
### 데이터베이스 복원
|
||||
```bash
|
||||
cp backups/kdrug_backup_20260215_before_refactoring.db database/kdrug.db
|
||||
```
|
||||
|
||||
### 전체 프로젝트 복원
|
||||
```bash
|
||||
# 새 디렉토리에 압축 해제
|
||||
mkdir kdrug_restored
|
||||
cd kdrug_restored
|
||||
tar -xzf ../backups/kdrug_full_backup_20260215_before_refactoring.tar.gz
|
||||
```
|
||||
|
||||
## 현재 시스템 상태
|
||||
- **효능 태그 시스템**: 구현 완료
|
||||
- **쌍화탕 처방**: 12종 약재 등록 완료
|
||||
- **원산지별 재고 관리**: 구현 완료
|
||||
- **직접조제 기능**: 구현 완료
|
||||
|
||||
## 다음 작업 예정
|
||||
- 주성분코드 기반 약재 마스터 테이블 생성
|
||||
- 53,775개 제품 데이터 임포트
|
||||
- 바코드 시스템 구축
|
||||
|
||||
---
|
||||
백업 생성: 2026-02-15 10:16
|
||||
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()
|
||||
69
create_adjustment_tables.py
Normal file
69
create_adjustment_tables.py
Normal file
@ -0,0 +1,69 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
"""
|
||||
재고 보정 테이블 생성 스크립트
|
||||
"""
|
||||
|
||||
import sqlite3
|
||||
|
||||
def create_tables():
|
||||
conn = sqlite3.connect('database/kdrug.db')
|
||||
cursor = conn.cursor()
|
||||
|
||||
try:
|
||||
# 재고 보정 테이블 생성
|
||||
cursor.execute("""
|
||||
CREATE TABLE IF NOT EXISTS stock_adjustments (
|
||||
adjustment_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
adjustment_date DATE NOT NULL,
|
||||
adjustment_no TEXT,
|
||||
adjustment_type TEXT NOT NULL CHECK(adjustment_type IN ('LOSS', 'FOUND', 'RECOUNT', 'DAMAGE', 'EXPIRE')),
|
||||
notes TEXT,
|
||||
created_by TEXT,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
""")
|
||||
|
||||
# 재고 보정 상세 테이블 생성
|
||||
cursor.execute("""
|
||||
CREATE TABLE IF NOT EXISTS stock_adjustment_details (
|
||||
detail_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
adjustment_id INTEGER NOT NULL,
|
||||
herb_item_id INTEGER NOT NULL,
|
||||
lot_id INTEGER NOT NULL,
|
||||
quantity_before REAL NOT NULL,
|
||||
quantity_after REAL NOT NULL,
|
||||
quantity_delta REAL NOT NULL,
|
||||
reason TEXT,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (adjustment_id) REFERENCES stock_adjustments(adjustment_id),
|
||||
FOREIGN KEY (herb_item_id) REFERENCES herb_items(herb_item_id),
|
||||
FOREIGN KEY (lot_id) REFERENCES inventory_lots(lot_id)
|
||||
)
|
||||
""")
|
||||
|
||||
# 인덱스 생성
|
||||
cursor.execute("CREATE INDEX IF NOT EXISTS idx_stock_adjustments_date ON stock_adjustments(adjustment_date)")
|
||||
cursor.execute("CREATE INDEX IF NOT EXISTS idx_adjustment_details_herb ON stock_adjustment_details(herb_item_id)")
|
||||
cursor.execute("CREATE INDEX IF NOT EXISTS idx_adjustment_details_lot ON stock_adjustment_details(lot_id)")
|
||||
|
||||
conn.commit()
|
||||
print("✅ 재고 보정 테이블 생성 완료!")
|
||||
|
||||
# 테이블 확인
|
||||
cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name LIKE '%adjustment%'")
|
||||
tables = cursor.fetchall()
|
||||
print("\n생성된 테이블:")
|
||||
for table in tables:
|
||||
print(f" - {table[0]}")
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ 오류 발생: {e}")
|
||||
conn.rollback()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
if __name__ == "__main__":
|
||||
create_tables()
|
||||
35
database/add_adjustments_table.sql
Normal file
35
database/add_adjustments_table.sql
Normal file
@ -0,0 +1,35 @@
|
||||
-- 재고 보정 테이블 추가
|
||||
-- 재고 조정/보정 내역을 기록
|
||||
|
||||
CREATE TABLE IF NOT EXISTS stock_adjustments (
|
||||
adjustment_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
adjustment_date DATE NOT NULL,
|
||||
adjustment_no TEXT, -- 보정 번호 (ADJ-YYYYMMDD-XXXX)
|
||||
adjustment_type TEXT NOT NULL CHECK(adjustment_type IN ('LOSS', 'FOUND', 'RECOUNT', 'DAMAGE', 'EXPIRE')),
|
||||
-- LOSS: 감모(손실), FOUND: 발견, RECOUNT: 재고조사, DAMAGE: 파손, EXPIRE: 유통기한
|
||||
notes TEXT,
|
||||
created_by TEXT, -- 보정 담당자 (나중에 계정 연동)
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- 재고 보정 상세 (로트별)
|
||||
CREATE TABLE IF NOT EXISTS stock_adjustment_details (
|
||||
detail_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
adjustment_id INTEGER NOT NULL,
|
||||
herb_item_id INTEGER NOT NULL,
|
||||
lot_id INTEGER NOT NULL,
|
||||
quantity_before REAL NOT NULL, -- 보정 전 재고
|
||||
quantity_after REAL NOT NULL, -- 보정 후 재고
|
||||
quantity_delta REAL NOT NULL, -- 증감량
|
||||
reason TEXT, -- 보정 사유
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (adjustment_id) REFERENCES stock_adjustments(adjustment_id),
|
||||
FOREIGN KEY (herb_item_id) REFERENCES herb_items(herb_item_id),
|
||||
FOREIGN KEY (lot_id) REFERENCES inventory_lots(lot_id)
|
||||
);
|
||||
|
||||
-- 인덱스 생성
|
||||
CREATE INDEX IF NOT EXISTS idx_stock_adjustments_date ON stock_adjustments(adjustment_date);
|
||||
CREATE INDEX IF NOT EXISTS idx_adjustment_details_herb ON stock_adjustment_details(herb_item_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_adjustment_details_lot ON stock_adjustment_details(lot_id);
|
||||
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 |
58
docs/대규모리팩토링.md
Normal file
58
docs/대규모리팩토링.md
Normal file
@ -0,0 +1,58 @@
|
||||
|
||||
우리 기존설계에 변화가 필요해
|
||||
|
||||
약품 마스터 테이블을 별도로만들어야겟어
|
||||
|
||||
대한민국에서 주기적으로
|
||||
|
||||
한약재 제품코드를 표준화해서 제공한다
|
||||
|
||||
|
||||
|
||||
한약재 품목명, 업체명, 제품명 약품규격 (숫자) 약품 규격(단위) 제공을하고
|
||||
|
||||
"주성분코드" 를 제공하고 이게 FK가 되야할거같아
|
||||
|
||||
|
||||
그리고 해당 성분코드에 여러 제품들이 있는거지
|
||||
|
||||
|
||||
|
||||
|
||||
▶ 표준코드: 개개의 의약품을 식별하기 위해 고유하게 설정된 번호
|
||||
- 국가식별코드, 업체식별코드, 품목코드* 및 검증번호로 구성(13자리 숫자)
|
||||
* 품목코드는 함량포함한 품목코드(4자리)와 포장단위(1자리)로 구성되어 있음
|
||||
|
||||
|
||||
▶ 대표코드: 표준코드의 12번째 자리가 '0'인 코드를 말함(실제 의약품에 부착하는 코드로 사용불가)
|
||||
|
||||
|
||||
▶ 제품코드: 한약재 비용 청구시 사용하는 코드
|
||||
- 업체식별코드와 품목코드로 구성(9자리 숫자)
|
||||
- 9자리 중 제일 마지막 숫자인 포장단위는 대표코드와 동일하게 "0"임
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
우리 입고장에
|
||||
|
||||
건강 : 062400730 9자리숫자는 진짜 해당제품에 "제품코드" 인것이고
|
||||
|
||||
|
||||
해당 건강에 성분 코드는 별도로 존재하는거지 "성분코드로는"
|
||||
|
||||
|
||||
같은 성분 코드를 지닌 다른 회사 제품도 있을수잇는거고
|
||||
|
||||
우리는 성분 코드 기반으로 효능 태그를 사실상 해야할수도잇어
|
||||
|
||||
|
||||
성분코드 = 실제성분이름 1:1 맵핑일거니까
|
||||
|
||||
일단 기존 코드를 두고 프론트에서 봐가면서 마이그레이션 해가야하니까
|
||||
|
||||
|
||||
우리 일단 xls파일을 확인하고 sqlite에 마이그레이션 해줘 약품 마스터 테이블을 만들어서 가능할까?
|
||||
210
docs/데이터베이스_리팩토링_제안.md
Normal file
210
docs/데이터베이스_리팩토링_제안.md
Normal file
@ -0,0 +1,210 @@
|
||||
# 한약재 재고관리 시스템 데이터베이스 리팩토링 제안
|
||||
|
||||
## 📊 현황 분석
|
||||
|
||||
### 1. 한약재제품코드 엑셀 분석 결과
|
||||
- **전체 데이터**: 53,775개 제품
|
||||
- **주성분코드**: 454개 (약재별 고유 코드)
|
||||
- **업체 수**: 128개
|
||||
- **포장 규격**: -, 500g, 600g, 1000g, 1200g, 6000g 등 다양
|
||||
|
||||
### 2. 핵심 발견사항
|
||||
1. **표준화된 주성분코드 체계 존재**
|
||||
- 예: 3017H1AHM = 건강
|
||||
- 예: 3007H1AHM = 감초
|
||||
- 예: 3105H1AHM = 당귀
|
||||
|
||||
2. **동일 약재, 다양한 제품**
|
||||
- 건강: 246개 제품, 70개 업체
|
||||
- 감초: 284개 제품, 73개 업체
|
||||
- 각 업체마다 고유한 제품명과 제품코드 보유
|
||||
|
||||
3. **바코드 시스템**
|
||||
- 표준코드 (13자리)
|
||||
- 대표코드 (13자리)
|
||||
- 제품코드 (9자리, 0 포함)
|
||||
|
||||
## 🏗️ 현재 시스템 vs 개선안
|
||||
|
||||
### 현재 시스템 구조
|
||||
```
|
||||
herb_items (약재 마스터)
|
||||
├── herb_item_id
|
||||
├── insurance_code
|
||||
├── herb_name
|
||||
└── is_active
|
||||
|
||||
inventory_lots (재고 로트)
|
||||
├── lot_id
|
||||
├── herb_item_id (FK)
|
||||
├── origin_country
|
||||
├── unit_price_per_g
|
||||
└── quantity_onhand
|
||||
```
|
||||
|
||||
### 제안하는 개선 구조
|
||||
|
||||
#### 1단계: 주성분코드 기반 약재 마스터
|
||||
```sql
|
||||
-- 약재 마스터 (주성분코드 기준)
|
||||
CREATE TABLE herb_masters (
|
||||
ingredient_code VARCHAR(10) PRIMARY KEY, -- 예: 3017H1AHM
|
||||
herb_name VARCHAR(100) NOT NULL, -- 예: 건강
|
||||
herb_name_hanja VARCHAR(100), -- 예: 乾薑
|
||||
herb_name_latin VARCHAR(200), -- 예: Zingiberis Rhizoma Siccus
|
||||
is_active BOOLEAN DEFAULT TRUE,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- 제품 마스터 (업체별 제품)
|
||||
CREATE TABLE herb_products (
|
||||
product_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
ingredient_code VARCHAR(10) NOT NULL, -- 주성분코드
|
||||
product_code VARCHAR(9) NOT NULL UNIQUE, -- 9자리 제품코드 (0 포함)
|
||||
company_name VARCHAR(200) NOT NULL, -- 업체명
|
||||
product_name VARCHAR(200) NOT NULL, -- 제품명
|
||||
standard_code VARCHAR(13), -- 표준코드 (바코드)
|
||||
representative_code VARCHAR(13), -- 대표코드
|
||||
package_size VARCHAR(20), -- 약품규격(숫자)
|
||||
package_unit VARCHAR(20), -- 약품규격(단위)
|
||||
valid_from DATE, -- 적용시작일
|
||||
valid_to DATE, -- 적용종료일
|
||||
is_active BOOLEAN DEFAULT TRUE,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (ingredient_code) REFERENCES herb_masters(ingredient_code)
|
||||
);
|
||||
|
||||
-- 인덱스 추가
|
||||
CREATE INDEX idx_product_ingredient ON herb_products(ingredient_code);
|
||||
CREATE INDEX idx_product_company ON herb_products(company_name);
|
||||
CREATE INDEX idx_product_barcode ON herb_products(standard_code);
|
||||
```
|
||||
|
||||
#### 2단계: 재고 관리 개선
|
||||
```sql
|
||||
-- 재고 로트 (제품별 관리)
|
||||
CREATE TABLE inventory_lots_v2 (
|
||||
lot_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
product_id INTEGER NOT NULL, -- 제품 ID
|
||||
lot_no VARCHAR(50), -- 로트번호
|
||||
origin_country VARCHAR(50), -- 원산지
|
||||
manufacture_date DATE, -- 제조일자
|
||||
expiry_date DATE, -- 유통기한
|
||||
received_date DATE NOT NULL, -- 입고일자
|
||||
quantity_onhand DECIMAL(10,2) NOT NULL, -- 현재고량
|
||||
unit_price_per_g DECIMAL(10,2) NOT NULL, -- 단가
|
||||
is_depleted BOOLEAN DEFAULT FALSE,
|
||||
supplier_id INTEGER,
|
||||
receipt_id INTEGER,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (product_id) REFERENCES herb_products(product_id)
|
||||
);
|
||||
```
|
||||
|
||||
## 🔄 마이그레이션 전략
|
||||
|
||||
### Phase 1: 기초 데이터 구축
|
||||
1. **주성분코드 마스터 데이터 임포트**
|
||||
- 454개 주성분코드와 약재명 매핑
|
||||
- 기존 herb_items와 매칭
|
||||
|
||||
2. **제품 데이터 임포트**
|
||||
- 53,775개 제품 데이터 임포트
|
||||
- 업체별, 규격별 제품 정보 저장
|
||||
|
||||
### Phase 2: 기존 데이터 마이그레이션
|
||||
1. **기존 재고 데이터 매핑**
|
||||
- 현재 herb_items → 적절한 product_id로 매핑
|
||||
- 원산지 정보 유지
|
||||
|
||||
2. **입고 내역 연결**
|
||||
- purchase_receipt_lines → 새로운 product_id 연결
|
||||
|
||||
### Phase 3: 시스템 전환
|
||||
1. **바코드 스캔 기능 추가**
|
||||
- 표준코드로 제품 식별
|
||||
- 자동 입고 처리
|
||||
|
||||
2. **업체별 제품 선택 UI**
|
||||
- 약재 선택 시 업체/제품 선택 가능
|
||||
- 규격별 재고 관리
|
||||
|
||||
## 💡 장점
|
||||
|
||||
1. **표준화**
|
||||
- 건강보험 급여 코드체계와 일치
|
||||
- 업계 표준 바코드 시스템 활용
|
||||
|
||||
2. **정확성**
|
||||
- 업체별, 규격별 정확한 재고 관리
|
||||
- 제품 추적성 향상
|
||||
|
||||
3. **확장성**
|
||||
- 새로운 업체/제품 쉽게 추가
|
||||
- 바코드 스캔 등 자동화 가능
|
||||
|
||||
4. **호환성**
|
||||
- 외부 시스템과 데이터 교환 용이
|
||||
- 도매상 시스템과 연동 가능
|
||||
|
||||
## 🚀 구현 우선순위
|
||||
|
||||
### 즉시 구현 가능
|
||||
1. herb_masters 테이블 생성 및 데이터 임포트
|
||||
2. herb_products 테이블 생성 및 데이터 임포트
|
||||
3. 기존 herb_items에 ingredient_code 컬럼 추가
|
||||
|
||||
### 단계적 구현
|
||||
1. 입고 시 제품코드/바코드 입력 기능
|
||||
2. 재고 조회 시 제품별 표시
|
||||
3. 바코드 스캔 기능 (웹캠 또는 스캐너)
|
||||
|
||||
### 장기 계획
|
||||
1. 도매상 API 연동
|
||||
2. 자동 발주 시스템
|
||||
3. 유통기한 관리
|
||||
|
||||
## 📝 예시 쿼리
|
||||
|
||||
### 건강(乾薑) 제품 조회
|
||||
```sql
|
||||
SELECT
|
||||
p.product_name,
|
||||
p.company_name,
|
||||
p.package_size || p.package_unit as package,
|
||||
p.product_code
|
||||
FROM herb_products p
|
||||
JOIN herb_masters m ON p.ingredient_code = m.ingredient_code
|
||||
WHERE m.herb_name = '건강'
|
||||
ORDER BY p.company_name, p.package_size;
|
||||
```
|
||||
|
||||
### 바코드로 제품 찾기
|
||||
```sql
|
||||
SELECT
|
||||
m.herb_name,
|
||||
p.product_name,
|
||||
p.company_name
|
||||
FROM herb_products p
|
||||
JOIN herb_masters m ON p.ingredient_code = m.ingredient_code
|
||||
WHERE p.standard_code = '8800680001104';
|
||||
```
|
||||
|
||||
## ⚠️ 주의사항
|
||||
|
||||
1. **데이터 무결성**
|
||||
- 제품코드 9자리 유지 (앞자리 0 포함)
|
||||
- 날짜 형식 변환 (20201120 → 2020-11-20)
|
||||
|
||||
2. **하위 호환성**
|
||||
- 기존 기능 유지하면서 점진적 마이그레이션
|
||||
- 임시로 dual-write 전략 사용 가능
|
||||
|
||||
3. **성능 고려**
|
||||
- 53,775개 제품 데이터 인덱싱 필수
|
||||
- 자주 사용하는 쿼리 최적화
|
||||
|
||||
---
|
||||
|
||||
작성일: 2026-02-15
|
||||
작성자: Claude Assistant
|
||||
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 |
172
refactoring/01_create_new_tables.py
Normal file
172
refactoring/01_create_new_tables.py
Normal file
@ -0,0 +1,172 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Step 1: 주성분코드 기반 새로운 테이블 생성
|
||||
"""
|
||||
|
||||
import sqlite3
|
||||
from datetime import datetime
|
||||
|
||||
def create_new_tables():
|
||||
conn = sqlite3.connect('database/kdrug.db')
|
||||
cursor = conn.cursor()
|
||||
|
||||
try:
|
||||
print("=== 주성분코드 기반 테이블 생성 시작 ===\n")
|
||||
|
||||
# 1. 약재 마스터 테이블 (주성분코드 기준)
|
||||
print("1. herb_masters 테이블 생성...")
|
||||
cursor.execute("""
|
||||
CREATE TABLE IF NOT EXISTS herb_masters (
|
||||
ingredient_code VARCHAR(10) PRIMARY KEY,
|
||||
herb_name VARCHAR(100) NOT NULL,
|
||||
herb_name_hanja VARCHAR(100),
|
||||
herb_name_latin VARCHAR(200),
|
||||
description TEXT,
|
||||
is_active BOOLEAN DEFAULT 1,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
""")
|
||||
print(" ✅ herb_masters 테이블 생성 완료")
|
||||
|
||||
# 2. 제품 마스터 테이블 (업체별 제품)
|
||||
print("\n2. herb_products 테이블 생성...")
|
||||
cursor.execute("""
|
||||
CREATE TABLE IF NOT EXISTS herb_products (
|
||||
product_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
ingredient_code VARCHAR(10) NOT NULL,
|
||||
product_code VARCHAR(9) NOT NULL,
|
||||
company_name VARCHAR(200) NOT NULL,
|
||||
product_name VARCHAR(200) NOT NULL,
|
||||
standard_code VARCHAR(20),
|
||||
representative_code VARCHAR(20),
|
||||
package_size VARCHAR(20),
|
||||
package_unit VARCHAR(20),
|
||||
valid_from DATE,
|
||||
valid_to DATE,
|
||||
is_active BOOLEAN DEFAULT 1,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (ingredient_code) REFERENCES herb_masters(ingredient_code),
|
||||
UNIQUE(product_code, package_size, package_unit)
|
||||
)
|
||||
""")
|
||||
print(" ✅ herb_products 테이블 생성 완료")
|
||||
|
||||
# 3. 인덱스 생성
|
||||
print("\n3. 인덱스 생성...")
|
||||
|
||||
# herb_products 인덱스
|
||||
cursor.execute("""
|
||||
CREATE INDEX IF NOT EXISTS idx_product_ingredient
|
||||
ON herb_products(ingredient_code)
|
||||
""")
|
||||
cursor.execute("""
|
||||
CREATE INDEX IF NOT EXISTS idx_product_company
|
||||
ON herb_products(company_name)
|
||||
""")
|
||||
cursor.execute("""
|
||||
CREATE INDEX IF NOT EXISTS idx_product_barcode
|
||||
ON herb_products(standard_code)
|
||||
""")
|
||||
cursor.execute("""
|
||||
CREATE INDEX IF NOT EXISTS idx_product_code
|
||||
ON herb_products(product_code)
|
||||
""")
|
||||
print(" ✅ 인덱스 생성 완료")
|
||||
|
||||
# 4. 기존 herb_items 테이블에 ingredient_code 컬럼 추가
|
||||
print("\n4. 기존 herb_items 테이블에 ingredient_code 컬럼 추가...")
|
||||
|
||||
# 컬럼이 이미 있는지 확인
|
||||
cursor.execute("PRAGMA table_info(herb_items)")
|
||||
columns = [col[1] for col in cursor.fetchall()]
|
||||
|
||||
if 'ingredient_code' not in columns:
|
||||
cursor.execute("""
|
||||
ALTER TABLE herb_items
|
||||
ADD COLUMN ingredient_code VARCHAR(10)
|
||||
""")
|
||||
print(" ✅ ingredient_code 컬럼 추가 완료")
|
||||
else:
|
||||
print(" ⚠️ ingredient_code 컬럼이 이미 존재합니다")
|
||||
|
||||
# 5. 개선된 재고 로트 테이블 (선택사항)
|
||||
print("\n5. inventory_lots_v2 테이블 생성...")
|
||||
cursor.execute("""
|
||||
CREATE TABLE IF NOT EXISTS inventory_lots_v2 (
|
||||
lot_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
product_id INTEGER,
|
||||
herb_item_id INTEGER, -- 하위 호환성을 위해 유지
|
||||
lot_no VARCHAR(50),
|
||||
origin_country VARCHAR(50),
|
||||
manufacture_date DATE,
|
||||
expiry_date DATE,
|
||||
received_date DATE NOT NULL,
|
||||
quantity_onhand DECIMAL(10,2) NOT NULL DEFAULT 0,
|
||||
unit_price_per_g DECIMAL(10,2) NOT NULL,
|
||||
total_value DECIMAL(10,2),
|
||||
is_depleted BOOLEAN DEFAULT 0,
|
||||
supplier_id INTEGER,
|
||||
receipt_id INTEGER,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (product_id) REFERENCES herb_products(product_id),
|
||||
FOREIGN KEY (herb_item_id) REFERENCES herb_items(herb_item_id)
|
||||
)
|
||||
""")
|
||||
print(" ✅ inventory_lots_v2 테이블 생성 완료")
|
||||
|
||||
# 6. 제품 업체 테이블 (업체 정보 관리)
|
||||
print("\n6. product_companies 테이블 생성...")
|
||||
cursor.execute("""
|
||||
CREATE TABLE IF NOT EXISTS product_companies (
|
||||
company_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
company_name VARCHAR(200) NOT NULL UNIQUE,
|
||||
business_no VARCHAR(50),
|
||||
contact_person VARCHAR(100),
|
||||
phone VARCHAR(50),
|
||||
email VARCHAR(100),
|
||||
address TEXT,
|
||||
is_active BOOLEAN DEFAULT 1,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
""")
|
||||
print(" ✅ product_companies 테이블 생성 완료")
|
||||
|
||||
conn.commit()
|
||||
|
||||
print("\n=== 테이블 생성 완료 ===")
|
||||
print("\n생성된 테이블:")
|
||||
print(" • herb_masters - 주성분코드 기반 약재 마스터")
|
||||
print(" • herb_products - 업체별 제품 정보")
|
||||
print(" • inventory_lots_v2 - 개선된 재고 관리")
|
||||
print(" • product_companies - 제품 업체 정보")
|
||||
print("\n기존 테이블 수정:")
|
||||
print(" • herb_items - ingredient_code 컬럼 추가")
|
||||
|
||||
# 테이블 정보 확인
|
||||
print("\n=== 테이블 구조 확인 ===")
|
||||
cursor.execute("""
|
||||
SELECT name FROM sqlite_master
|
||||
WHERE type='table'
|
||||
AND name IN ('herb_masters', 'herb_products', 'inventory_lots_v2', 'product_companies')
|
||||
ORDER BY name
|
||||
""")
|
||||
|
||||
tables = cursor.fetchall()
|
||||
print(f"\n신규 테이블 수: {len(tables)}개")
|
||||
for table in tables:
|
||||
print(f" - {table[0]}")
|
||||
|
||||
except Exception as e:
|
||||
print(f"\n❌ 오류 발생: {e}")
|
||||
conn.rollback()
|
||||
raise
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
if __name__ == "__main__":
|
||||
create_new_tables()
|
||||
222
refactoring/02_import_product_codes.py
Normal file
222
refactoring/02_import_product_codes.py
Normal file
@ -0,0 +1,222 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Step 2: 한약재제품코드 데이터 임포트
|
||||
"""
|
||||
|
||||
import sqlite3
|
||||
import pandas as pd
|
||||
from datetime import datetime
|
||||
|
||||
def import_product_codes():
|
||||
"""한약재제품코드 엑셀 파일에서 데이터를 임포트"""
|
||||
|
||||
file_path = 'sample/(게시)한약재제품코드_2510.xlsx'
|
||||
sheet_name = '한약재 제품코드_20250930기준(유효코드만 공지)'
|
||||
|
||||
conn = sqlite3.connect('database/kdrug.db')
|
||||
cursor = conn.cursor()
|
||||
|
||||
try:
|
||||
print("=== 한약재제품코드 데이터 임포트 시작 ===\n")
|
||||
|
||||
# 엑셀 파일 읽기 - 제품코드를 문자열로 유지
|
||||
print("1. 엑셀 파일 읽기 중...")
|
||||
df = pd.read_excel(file_path, sheet_name=sheet_name,
|
||||
dtype={'제품코드': str, '적용시작일': str, '적용종료일': str})
|
||||
print(f" ✅ {len(df):,}개 데이터 로드 완료")
|
||||
|
||||
# 제품코드 9자리로 패딩
|
||||
df['제품코드'] = df['제품코드'].apply(lambda x: str(x).zfill(9) if pd.notna(x) else None)
|
||||
|
||||
# 날짜 형식 변환 (20201120 → 2020-11-20)
|
||||
def convert_date(date_str):
|
||||
if pd.isna(date_str) or date_str == '99991231':
|
||||
return None
|
||||
date_str = str(int(date_str))
|
||||
return f"{date_str[:4]}-{date_str[4:6]}-{date_str[6:8]}"
|
||||
|
||||
df['valid_from'] = df['적용시작일'].apply(convert_date)
|
||||
df['valid_to'] = df['적용종료일'].apply(convert_date)
|
||||
|
||||
# 1. herb_masters 테이블 채우기 (주성분코드별 약재)
|
||||
print("\n2. herb_masters 테이블 데이터 임포트...")
|
||||
|
||||
# 유일한 주성분코드 추출
|
||||
unique_herbs = df[['주성분코드', '한약재 품목명']].drop_duplicates()
|
||||
herb_count = 0
|
||||
|
||||
for _, row in unique_herbs.iterrows():
|
||||
if pd.notna(row['주성분코드']):
|
||||
try:
|
||||
cursor.execute("""
|
||||
INSERT OR IGNORE INTO herb_masters
|
||||
(ingredient_code, herb_name, is_active)
|
||||
VALUES (?, ?, 1)
|
||||
""", (row['주성분코드'], row['한약재 품목명']))
|
||||
if cursor.rowcount > 0:
|
||||
herb_count += 1
|
||||
except Exception as e:
|
||||
print(f" ⚠️ {row['한약재 품목명']} 임포트 실패: {e}")
|
||||
|
||||
print(f" ✅ {herb_count}개 약재 마스터 등록 완료")
|
||||
|
||||
# 2. product_companies 테이블 채우기
|
||||
print("\n3. product_companies 테이블 데이터 임포트...")
|
||||
|
||||
unique_companies = df['업체명'].unique()
|
||||
company_count = 0
|
||||
|
||||
for company in unique_companies:
|
||||
if pd.notna(company):
|
||||
try:
|
||||
cursor.execute("""
|
||||
INSERT OR IGNORE INTO product_companies
|
||||
(company_name, is_active)
|
||||
VALUES (?, 1)
|
||||
""", (company,))
|
||||
if cursor.rowcount > 0:
|
||||
company_count += 1
|
||||
except Exception as e:
|
||||
print(f" ⚠️ {company} 임포트 실패: {e}")
|
||||
|
||||
print(f" ✅ {company_count}개 업체 등록 완료")
|
||||
|
||||
# 3. herb_products 테이블 채우기
|
||||
print("\n4. herb_products 테이블 데이터 임포트 (시간이 걸릴 수 있습니다)...")
|
||||
|
||||
product_count = 0
|
||||
error_count = 0
|
||||
batch_size = 1000
|
||||
|
||||
for idx, row in df.iterrows():
|
||||
if pd.notna(row['주성분코드']) and pd.notna(row['제품코드']):
|
||||
try:
|
||||
# 표준코드와 대표코드를 문자열로 변환
|
||||
standard_code = str(int(row['표준코드'])) if pd.notna(row['표준코드']) else None
|
||||
rep_code = str(int(row['대표코드'])) if pd.notna(row['대표코드']) else None
|
||||
|
||||
cursor.execute("""
|
||||
INSERT OR IGNORE INTO herb_products
|
||||
(ingredient_code, product_code, company_name, product_name,
|
||||
standard_code, representative_code,
|
||||
package_size, package_unit, valid_from, valid_to, is_active)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1)
|
||||
""", (
|
||||
row['주성분코드'],
|
||||
row['제품코드'],
|
||||
row['업체명'],
|
||||
row['제품명'],
|
||||
standard_code,
|
||||
rep_code,
|
||||
row['약품규격(숫자)'] if row['약품규격(숫자)'] != '-' else None,
|
||||
row['약품규격(단위)'] if row['약품규격(단위)'] != '-' else None,
|
||||
row['valid_from'],
|
||||
row['valid_to']
|
||||
))
|
||||
if cursor.rowcount > 0:
|
||||
product_count += 1
|
||||
except Exception as e:
|
||||
error_count += 1
|
||||
if error_count < 10: # 처음 10개만 오류 표시
|
||||
print(f" ⚠️ 행 {idx} 임포트 실패: {e}")
|
||||
|
||||
# 진행상황 표시
|
||||
if (idx + 1) % batch_size == 0:
|
||||
print(f" ... {idx + 1:,}/{len(df):,} 처리 중 ({product_count:,}개 등록)")
|
||||
conn.commit() # 배치 단위로 커밋
|
||||
|
||||
print(f" ✅ {product_count:,}개 제품 등록 완료 (오류: {error_count}개)")
|
||||
|
||||
# 4. 기존 herb_items와 매핑
|
||||
print("\n5. 기존 herb_items에 ingredient_code 매핑...")
|
||||
|
||||
# 약재명 기준으로 매핑
|
||||
cursor.execute("""
|
||||
UPDATE herb_items
|
||||
SET ingredient_code = (
|
||||
SELECT ingredient_code
|
||||
FROM herb_masters
|
||||
WHERE REPLACE(herb_masters.herb_name, ' ', '') = REPLACE(herb_items.herb_name, ' ', '')
|
||||
LIMIT 1
|
||||
)
|
||||
WHERE ingredient_code IS NULL
|
||||
""")
|
||||
mapped_count = cursor.rowcount
|
||||
print(f" ✅ {mapped_count}개 약재 매핑 완료")
|
||||
|
||||
# 매핑 확인
|
||||
cursor.execute("""
|
||||
SELECT COUNT(*) FROM herb_items WHERE ingredient_code IS NOT NULL
|
||||
""")
|
||||
mapped_total = cursor.fetchone()[0]
|
||||
|
||||
cursor.execute("""
|
||||
SELECT COUNT(*) FROM herb_items
|
||||
""")
|
||||
total_herbs = cursor.fetchone()[0]
|
||||
|
||||
print(f" 📊 매핑 결과: {mapped_total}/{total_herbs} ({mapped_total*100//total_herbs}%)")
|
||||
|
||||
# 매핑되지 않은 약재 확인
|
||||
cursor.execute("""
|
||||
SELECT herb_name FROM herb_items
|
||||
WHERE ingredient_code IS NULL
|
||||
ORDER BY herb_name
|
||||
""")
|
||||
unmapped = cursor.fetchall()
|
||||
|
||||
if unmapped:
|
||||
print(f"\n ⚠️ 매핑되지 않은 약재 ({len(unmapped)}개):")
|
||||
for herb in unmapped[:10]: # 처음 10개만 표시
|
||||
print(f" - {herb[0]}")
|
||||
if len(unmapped) > 10:
|
||||
print(f" ... 외 {len(unmapped)-10}개")
|
||||
|
||||
conn.commit()
|
||||
|
||||
# 최종 통계
|
||||
print("\n=== 임포트 완료 ===")
|
||||
|
||||
# 통계 조회
|
||||
cursor.execute("SELECT COUNT(*) FROM herb_masters")
|
||||
herb_master_count = cursor.fetchone()[0]
|
||||
|
||||
cursor.execute("SELECT COUNT(*) FROM herb_products")
|
||||
product_total = cursor.fetchone()[0]
|
||||
|
||||
cursor.execute("SELECT COUNT(*) FROM product_companies")
|
||||
company_total = cursor.fetchone()[0]
|
||||
|
||||
print(f"\n📊 최종 통계:")
|
||||
print(f" • herb_masters: {herb_master_count}개 약재")
|
||||
print(f" • herb_products: {product_total:,}개 제품")
|
||||
print(f" • product_companies: {company_total}개 업체")
|
||||
|
||||
# 샘플 데이터 확인
|
||||
print("\n📋 샘플 데이터 (건강):")
|
||||
cursor.execute("""
|
||||
SELECT p.product_code, p.company_name, p.product_name,
|
||||
p.package_size || COALESCE(' ' || p.package_unit, '') as package
|
||||
FROM herb_products p
|
||||
JOIN herb_masters m ON p.ingredient_code = m.ingredient_code
|
||||
WHERE m.herb_name = '건강'
|
||||
LIMIT 5
|
||||
""")
|
||||
|
||||
for row in cursor.fetchall():
|
||||
print(f" - [{row[0]}] {row[1]} - {row[2]} ({row[3]})")
|
||||
|
||||
except Exception as e:
|
||||
print(f"\n❌ 오류 발생: {e}")
|
||||
conn.rollback()
|
||||
raise
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
if __name__ == "__main__":
|
||||
import time
|
||||
start = time.time()
|
||||
import_product_codes()
|
||||
elapsed = time.time() - start
|
||||
print(f"\n⏱️ 실행 시간: {elapsed:.2f}초")
|
||||
126
refactoring/03_fix_mappings.py
Normal file
126
refactoring/03_fix_mappings.py
Normal file
@ -0,0 +1,126 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Step 3: 매핑되지 않은 약재 수정
|
||||
"""
|
||||
|
||||
import sqlite3
|
||||
|
||||
def fix_mappings():
|
||||
conn = sqlite3.connect('database/kdrug.db')
|
||||
cursor = conn.cursor()
|
||||
|
||||
try:
|
||||
print("=== 약재 매핑 수정 ===\n")
|
||||
|
||||
# 1. 백작약 → 작약으로 매핑
|
||||
print("1. '백작약'을 '작약'으로 매핑...")
|
||||
|
||||
# 작약의 주성분코드 확인
|
||||
cursor.execute("""
|
||||
SELECT ingredient_code FROM herb_masters
|
||||
WHERE herb_name = '작약'
|
||||
""")
|
||||
result = cursor.fetchone()
|
||||
|
||||
if result:
|
||||
jaknyak_code = result[0]
|
||||
cursor.execute("""
|
||||
UPDATE herb_items
|
||||
SET ingredient_code = ?
|
||||
WHERE herb_name = '백작약'
|
||||
""", (jaknyak_code,))
|
||||
print(f" ✅ 백작약 → 작약 ({jaknyak_code}) 매핑 완료")
|
||||
else:
|
||||
print(" ⚠️ '작약'을 찾을 수 없습니다")
|
||||
|
||||
# 2. 진피 매핑
|
||||
print("\n2. '진피' 매핑 확인...")
|
||||
|
||||
# 진피 관련 항목 찾기
|
||||
cursor.execute("""
|
||||
SELECT ingredient_code, herb_name
|
||||
FROM herb_masters
|
||||
WHERE herb_name LIKE '%진피%'
|
||||
""")
|
||||
jinpi_options = cursor.fetchall()
|
||||
|
||||
if jinpi_options:
|
||||
print(f" 발견된 진피 관련 항목:")
|
||||
for code, name in jinpi_options:
|
||||
print(f" - {name} ({code})")
|
||||
|
||||
# 첫 번째 항목으로 매핑
|
||||
if len(jinpi_options) > 0:
|
||||
jinpi_code = jinpi_options[0][0]
|
||||
cursor.execute("""
|
||||
UPDATE herb_items
|
||||
SET ingredient_code = ?
|
||||
WHERE herb_name = '진피'
|
||||
""", (jinpi_code,))
|
||||
print(f" ✅ 진피 → {jinpi_options[0][1]} ({jinpi_code}) 매핑 완료")
|
||||
else:
|
||||
print(" ⚠️ '진피' 관련 항목을 찾을 수 없습니다")
|
||||
|
||||
# 3. 최종 확인
|
||||
print("\n3. 매핑 상태 확인...")
|
||||
|
||||
cursor.execute("""
|
||||
SELECT herb_name, ingredient_code
|
||||
FROM herb_items
|
||||
ORDER BY herb_name
|
||||
""")
|
||||
all_items = cursor.fetchall()
|
||||
|
||||
mapped = 0
|
||||
unmapped = []
|
||||
|
||||
for herb_name, code in all_items:
|
||||
if code:
|
||||
mapped += 1
|
||||
else:
|
||||
unmapped.append(herb_name)
|
||||
|
||||
print(f"\n📊 최종 매핑 결과:")
|
||||
print(f" • 전체: {len(all_items)}개")
|
||||
print(f" • 매핑됨: {mapped}개 ({mapped*100//len(all_items)}%)")
|
||||
print(f" • 미매핑: {len(unmapped)}개")
|
||||
|
||||
if unmapped:
|
||||
print(f"\n ⚠️ 아직 매핑되지 않은 약재:")
|
||||
for name in unmapped:
|
||||
print(f" - {name}")
|
||||
|
||||
# 4. 중요 약재들의 매핑 확인
|
||||
print("\n4. 주요 약재 매핑 확인...")
|
||||
|
||||
important_herbs = [
|
||||
'건강', '감초', '당귀', '황기', '숙지황',
|
||||
'백출', '천궁', '육계', '인삼', '생강', '대추'
|
||||
]
|
||||
|
||||
for herb_name in important_herbs:
|
||||
cursor.execute("""
|
||||
SELECT h.herb_name, h.ingredient_code, m.herb_name
|
||||
FROM herb_items h
|
||||
LEFT JOIN herb_masters m ON h.ingredient_code = m.ingredient_code
|
||||
WHERE h.herb_name = ?
|
||||
""", (herb_name,))
|
||||
result = cursor.fetchone()
|
||||
|
||||
if result and result[1]:
|
||||
print(f" ✅ {result[0]} → {result[1]} ({result[2]})")
|
||||
else:
|
||||
print(f" ❌ {herb_name} - 매핑 안 됨")
|
||||
|
||||
conn.commit()
|
||||
print("\n✅ 매핑 수정 완료!")
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ 오류 발생: {e}")
|
||||
conn.rollback()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
if __name__ == "__main__":
|
||||
fix_mappings()
|
||||
124
refactoring/REFACTORING_RESULT.md
Normal file
124
refactoring/REFACTORING_RESULT.md
Normal file
@ -0,0 +1,124 @@
|
||||
# 데이터베이스 리팩토링 결과 보고서
|
||||
|
||||
## 📅 실행 일시
|
||||
- **날짜**: 2026년 2월 15일
|
||||
- **백업 완료**: backups/kdrug_full_backup_20260215_before_refactoring.tar.gz
|
||||
|
||||
## ✅ 완료된 작업
|
||||
|
||||
### 1. 새로운 테이블 생성
|
||||
#### herb_masters (주성분코드 기반 약재 마스터)
|
||||
- **레코드 수**: 454개
|
||||
- **주요 필드**: ingredient_code (주성분코드), herb_name (약재명)
|
||||
- **용도**: 표준화된 약재 코드 관리
|
||||
|
||||
#### herb_products (업체별 제품)
|
||||
- **레코드 수**: 53,769개
|
||||
- **주요 필드**: product_code (9자리), company_name, product_name, package_size, package_unit
|
||||
- **용도**: 업체별 제품 정보 및 포장 규격 관리
|
||||
|
||||
#### product_companies (제품 업체)
|
||||
- **레코드 수**: 128개
|
||||
- **주요 필드**: company_name
|
||||
- **용도**: 한약재 제조/유통 업체 정보 관리
|
||||
|
||||
#### inventory_lots_v2 (개선된 재고 관리)
|
||||
- **용도**: 제품 단위 재고 관리 (향후 마이그레이션용)
|
||||
|
||||
### 2. 기존 테이블 수정
|
||||
#### herb_items
|
||||
- **변경사항**: ingredient_code 컬럼 추가
|
||||
- **매핑 완료**: 32/32 (100%)
|
||||
- **주요 매핑**:
|
||||
- 건강 → 3017H1AHM
|
||||
- 감초 → 3007H1AHM
|
||||
- 당귀 → 3105H1AHM
|
||||
- 황기 → 3583H1AHM
|
||||
- 백작약 → 3419H1AHM (작약)
|
||||
- 진피 → 3467H1AHM
|
||||
|
||||
### 3. 데이터 임포트 통계
|
||||
- **처리 시간**: 10.86초
|
||||
- **총 처리 건수**: 53,775개
|
||||
- **임포트 성공**: 53,769개 (99.99%)
|
||||
- **오류**: 6개 (중복 데이터)
|
||||
|
||||
## 📊 현재 시스템 상태
|
||||
|
||||
### 데이터베이스 구조
|
||||
```
|
||||
기존 시스템 (유지)
|
||||
├── herb_items (32개) - ingredient_code 추가됨
|
||||
├── inventory_lots (재고 로트)
|
||||
├── formulas (처방)
|
||||
└── compounds (조제)
|
||||
|
||||
신규 시스템 (추가)
|
||||
├── herb_masters (454개 주성분코드)
|
||||
├── herb_products (53,769개 제품)
|
||||
├── product_companies (128개 업체)
|
||||
└── inventory_lots_v2 (미사용)
|
||||
```
|
||||
|
||||
### 주요 약재별 제품 수
|
||||
- 복령: 284개 제품
|
||||
- 감초: 284개 제품
|
||||
- 마황: 282개 제품
|
||||
- 작약: 280개 제품
|
||||
- 황기: 275개 제품
|
||||
- 천궁: 272개 제품
|
||||
- 당귀: 264개 제품
|
||||
- 건강: 246개 제품
|
||||
|
||||
### 주요 업체별 제품 수
|
||||
- 주식회사 바른한방제약: 1,609개
|
||||
- 나눔제약주식회사: 1,605개
|
||||
- 씨케이주식회사: 1,603개
|
||||
- (주)현진제약: 1,591개
|
||||
- (주)자연세상: 1,476개
|
||||
|
||||
## 🔄 향후 작업 계획
|
||||
|
||||
### 단기 (즉시 가능)
|
||||
1. ✅ 주성분코드 기반 검색 API 추가
|
||||
2. ✅ 제품 선택 UI 개선
|
||||
3. ✅ 바코드 조회 기능
|
||||
|
||||
### 중기 (단계적 구현)
|
||||
1. ⏳ inventory_lots_v2로 재고 마이그레이션
|
||||
2. ⏳ 제품별 입고/재고 관리
|
||||
3. ⏳ 업체별 가격 비교
|
||||
|
||||
### 장기 (추가 개발)
|
||||
1. 📋 바코드 스캔 기능
|
||||
2. 📋 도매상 API 연동
|
||||
3. 📋 자동 발주 시스템
|
||||
|
||||
## ⚡ 성능 개선 사항
|
||||
- 인덱스 생성 완료:
|
||||
- idx_product_ingredient (약재별 제품 검색)
|
||||
- idx_product_company (업체별 제품 검색)
|
||||
- idx_product_barcode (바코드 검색)
|
||||
- idx_product_code (제품코드 검색)
|
||||
|
||||
## 📝 주의사항
|
||||
1. **하위 호환성 유지**: 기존 시스템은 그대로 작동
|
||||
2. **제품코드 형식**: 9자리 (앞자리 0 포함 필수)
|
||||
3. **중복 관리**: 동일 제품의 다양한 규격은 별도 레코드로 관리
|
||||
|
||||
## 🎯 달성 효과
|
||||
1. **표준화**: 건강보험 급여 코드체계 준수
|
||||
2. **확장성**: 53,000개 이상 제품 관리 가능
|
||||
3. **정확성**: 업체별, 규격별 정확한 관리
|
||||
4. **호환성**: 외부 시스템과 데이터 교환 가능
|
||||
|
||||
## 📁 관련 파일
|
||||
- `/refactoring/01_create_new_tables.py` - 테이블 생성
|
||||
- `/refactoring/02_import_product_codes.py` - 데이터 임포트
|
||||
- `/refactoring/03_fix_mappings.py` - 매핑 수정
|
||||
- `/backups/kdrug_backup_20260215_before_refactoring.db` - DB 백업
|
||||
- `/backups/kdrug_full_backup_20260215_before_refactoring.tar.gz` - 전체 백업
|
||||
|
||||
---
|
||||
작성일: 2026-02-15
|
||||
작성자: Claude Assistant
|
||||
BIN
sample/(게시)한약재제품코드_2510.xlsx
Normal file
BIN
sample/(게시)한약재제품코드_2510.xlsx
Normal file
Binary file not shown.
914
static/app.js
914
static/app.js
@ -68,7 +68,51 @@ $(document).ready(function() {
|
||||
}
|
||||
});
|
||||
|
||||
// TODO: 오늘 조제 수, 최근 조제 내역
|
||||
// 오늘 조제 수 및 최근 조제 내역
|
||||
$.get('/api/compounds', function(response) {
|
||||
if (response.success) {
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
const todayCompounds = response.data.filter(c => c.compound_date === today);
|
||||
$('#todayCompounds').text(todayCompounds.length);
|
||||
|
||||
// 최근 조제 내역 (최근 5개)
|
||||
const tbody = $('#recentCompounds');
|
||||
tbody.empty();
|
||||
|
||||
const recentCompounds = response.data.slice(0, 5);
|
||||
if (recentCompounds.length > 0) {
|
||||
recentCompounds.forEach(compound => {
|
||||
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>';
|
||||
}
|
||||
|
||||
tbody.append(`
|
||||
<tr>
|
||||
<td>${compound.compound_date || '-'}</td>
|
||||
<td><strong>${compound.patient_name || '직접조제'}</strong></td>
|
||||
<td>${compound.formula_name || '직접조제'}</td>
|
||||
<td>${compound.je_count}제</td>
|
||||
<td>${compound.pouch_total}개</td>
|
||||
<td>${statusBadge}</td>
|
||||
</tr>
|
||||
`);
|
||||
});
|
||||
} else {
|
||||
tbody.html('<tr><td colspan="6" class="text-center text-muted">조제 내역이 없습니다.</td></tr>');
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 환자 목록 로드
|
||||
@ -532,8 +576,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 +724,57 @@ $(document).ready(function() {
|
||||
const tbody = $('#inventoryList');
|
||||
tbody.empty();
|
||||
|
||||
let totalValue = 0;
|
||||
let herbsInStock = 0;
|
||||
|
||||
// 주성분코드 기준 보유 현황 표시
|
||||
if (response.summary) {
|
||||
const summary = response.summary;
|
||||
const coverageHtml = `
|
||||
<div class="alert alert-info mb-3">
|
||||
<div class="row align-items-center">
|
||||
<div class="col-md-8">
|
||||
<h6 class="mb-2">📊 급여 약재 보유 현황</h6>
|
||||
<div class="d-flex align-items-center">
|
||||
<div class="me-4">
|
||||
<strong>전체 급여 약재:</strong> ${summary.total_ingredient_codes || 454}개 주성분
|
||||
</div>
|
||||
<div class="me-4">
|
||||
<strong>보유 약재:</strong> ${summary.owned_ingredient_codes || 0}개 주성분
|
||||
</div>
|
||||
<div>
|
||||
<strong>보유율:</strong>
|
||||
<span class="badge bg-primary fs-6">${summary.coverage_rate || 0}%</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4 text-end">
|
||||
<div class="progress" style="height: 30px;">
|
||||
<div class="progress-bar bg-success" role="progressbar"
|
||||
style="width: ${summary.coverage_rate || 0}%"
|
||||
aria-valuenow="${summary.coverage_rate || 0}"
|
||||
aria-valuemin="0" aria-valuemax="100">
|
||||
${summary.owned_ingredient_codes || 0} / ${summary.total_ingredient_codes || 454}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-2">
|
||||
<small class="text-muted">
|
||||
※ 건강보험 급여 한약재 ${summary.total_ingredient_codes || 454}개 주성분 중 ${summary.owned_ingredient_codes || 0}개 보유
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// 재고 테이블 위에 통계 표시
|
||||
if ($('#inventoryCoverage').length === 0) {
|
||||
$('#inventoryList').parent().before(`<div id="inventoryCoverage">${coverageHtml}</div>`);
|
||||
} else {
|
||||
$('#inventoryCoverage').html(coverageHtml);
|
||||
}
|
||||
}
|
||||
|
||||
response.data.forEach(item => {
|
||||
// 원산지가 여러 개인 경우 표시
|
||||
const originBadge = item.origin_count > 1
|
||||
@ -563,23 +795,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);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
@ -1080,6 +1337,496 @@ $(document).ready(function() {
|
||||
});
|
||||
}
|
||||
|
||||
// 재고 원장 보기
|
||||
let currentLedgerData = []; // 원본 데이터 저장
|
||||
|
||||
function viewStockLedger(herbId, herbName) {
|
||||
const url = herbId ? `/api/stock-ledger?herb_id=${herbId}` : '/api/stock-ledger';
|
||||
|
||||
$.get(url, function(response) {
|
||||
if (response.success) {
|
||||
// 원본 데이터 저장
|
||||
currentLedgerData = response.ledger;
|
||||
|
||||
// 헤더 업데이트
|
||||
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> 전체 입출고 원장`);
|
||||
}
|
||||
|
||||
// 필터 적용하여 표시
|
||||
applyLedgerFilters();
|
||||
|
||||
// 약재 필터 옵션 업데이트
|
||||
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('입출고 내역을 불러오는데 실패했습니다.');
|
||||
});
|
||||
}
|
||||
|
||||
// 필터 적용 함수
|
||||
function applyLedgerFilters() {
|
||||
const typeFilter = $('#ledgerTypeFilter').val();
|
||||
const tbody = $('#stockLedgerList');
|
||||
tbody.empty();
|
||||
|
||||
// 필터링된 데이터
|
||||
let filteredData = currentLedgerData;
|
||||
|
||||
// 타입 필터 적용
|
||||
if (typeFilter) {
|
||||
filteredData = currentLedgerData.filter(entry => entry.event_type === typeFilter);
|
||||
}
|
||||
|
||||
// 데이터 표시
|
||||
filteredData.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;
|
||||
case 'ADJUST':
|
||||
typeLabel = '보정';
|
||||
typeBadge = 'badge bg-warning';
|
||||
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>
|
||||
`);
|
||||
});
|
||||
|
||||
// 데이터가 없는 경우
|
||||
if (filteredData.length === 0) {
|
||||
tbody.append(`
|
||||
<tr>
|
||||
<td colspan="8" class="text-center text-muted">데이터가 없습니다.</td>
|
||||
</tr>
|
||||
`);
|
||||
}
|
||||
}
|
||||
|
||||
// 입출고 원장 모달 버튼 이벤트
|
||||
$('#showStockLedgerBtn').on('click', function() {
|
||||
viewStockLedger(null, null);
|
||||
});
|
||||
|
||||
// 필터 변경 이벤트
|
||||
$('#ledgerHerbFilter').on('change', function() {
|
||||
const herbId = $(this).val();
|
||||
|
||||
// 약재 필터 변경 시 데이터 재로드
|
||||
if (herbId) {
|
||||
const herbName = $('#ledgerHerbFilter option:selected').text();
|
||||
viewStockLedger(herbId, herbName);
|
||||
} else {
|
||||
viewStockLedger(null, null);
|
||||
}
|
||||
});
|
||||
|
||||
// 타입 필터 변경 이벤트 (현재 데이터에서 필터링만)
|
||||
$('#ledgerTypeFilter').on('change', function() {
|
||||
applyLedgerFilters();
|
||||
});
|
||||
|
||||
// ==================== 재고 보정 ====================
|
||||
|
||||
// 재고 보정 모달 열기
|
||||
$('#showStockAdjustmentBtn').on('click', function() {
|
||||
// 현재 날짜 설정
|
||||
$('#adjustmentDate').val(new Date().toISOString().split('T')[0]);
|
||||
$('#adjustmentItemsList').empty();
|
||||
$('#stockAdjustmentForm')[0].reset();
|
||||
$('#stockAdjustmentModal').modal('show');
|
||||
});
|
||||
|
||||
// 재고 보정 내역 모달 열기
|
||||
$('#showAdjustmentHistoryBtn').on('click', function() {
|
||||
loadAdjustmentHistory();
|
||||
});
|
||||
|
||||
// 재고 보정 내역 로드
|
||||
function loadAdjustmentHistory() {
|
||||
$.get('/api/stock-adjustments', function(response) {
|
||||
if (response.success) {
|
||||
const tbody = $('#adjustmentHistoryList');
|
||||
tbody.empty();
|
||||
|
||||
if (response.data.length === 0) {
|
||||
tbody.append(`
|
||||
<tr>
|
||||
<td colspan="7" class="text-center text-muted">보정 내역이 없습니다.</td>
|
||||
</tr>
|
||||
`);
|
||||
} else {
|
||||
response.data.forEach(adj => {
|
||||
// 보정 유형 한글 변환
|
||||
let typeLabel = '';
|
||||
switch(adj.adjustment_type) {
|
||||
case 'LOSS': typeLabel = '감모/손실'; break;
|
||||
case 'FOUND': typeLabel = '발견'; break;
|
||||
case 'RECOUNT': typeLabel = '재고조사'; break;
|
||||
case 'DAMAGE': typeLabel = '파손'; break;
|
||||
case 'EXPIRE': typeLabel = '유통기한 경과'; break;
|
||||
default: typeLabel = adj.adjustment_type;
|
||||
}
|
||||
|
||||
tbody.append(`
|
||||
<tr>
|
||||
<td>${adj.adjustment_date}</td>
|
||||
<td><code>${adj.adjustment_no}</code></td>
|
||||
<td><span class="badge bg-warning">${typeLabel}</span></td>
|
||||
<td>${adj.detail_count || 0}개</td>
|
||||
<td>${adj.created_by || '-'}</td>
|
||||
<td>${adj.notes || '-'}</td>
|
||||
<td>
|
||||
<button class="btn btn-sm btn-outline-info view-adjustment-detail"
|
||||
data-id="${adj.adjustment_id}">
|
||||
<i class="bi bi-eye"></i> 상세
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
`);
|
||||
});
|
||||
|
||||
// 상세보기 버튼 이벤트
|
||||
$('.view-adjustment-detail').on('click', function() {
|
||||
const adjustmentId = $(this).data('id');
|
||||
viewAdjustmentDetail(adjustmentId);
|
||||
});
|
||||
}
|
||||
|
||||
$('#adjustmentHistoryModal').modal('show');
|
||||
}
|
||||
}).fail(function() {
|
||||
alert('보정 내역을 불러오는데 실패했습니다.');
|
||||
});
|
||||
}
|
||||
|
||||
// 재고 보정 상세 조회
|
||||
function viewAdjustmentDetail(adjustmentId) {
|
||||
$.get(`/api/stock-adjustments/${adjustmentId}`, function(response) {
|
||||
if (response.success) {
|
||||
const data = response.data;
|
||||
|
||||
// 보정 정보 표시
|
||||
$('#detailAdjustmentNo').text(data.adjustment_no);
|
||||
$('#detailAdjustmentDate').text(data.adjustment_date);
|
||||
|
||||
// 보정 유형 한글 변환
|
||||
let typeLabel = '';
|
||||
switch(data.adjustment_type) {
|
||||
case 'LOSS': typeLabel = '감모/손실'; break;
|
||||
case 'FOUND': typeLabel = '발견'; break;
|
||||
case 'RECOUNT': typeLabel = '재고조사'; break;
|
||||
case 'DAMAGE': typeLabel = '파손'; break;
|
||||
case 'EXPIRE': typeLabel = '유통기한 경과'; break;
|
||||
default: typeLabel = data.adjustment_type;
|
||||
}
|
||||
$('#detailAdjustmentType').html(`<span class="badge bg-warning">${typeLabel}</span>`);
|
||||
$('#detailAdjustmentCreatedBy').text(data.created_by || '-');
|
||||
$('#detailAdjustmentNotes').text(data.notes || '-');
|
||||
|
||||
// 보정 상세 항목 표시
|
||||
const itemsBody = $('#detailAdjustmentItems');
|
||||
itemsBody.empty();
|
||||
|
||||
if (data.details && data.details.length > 0) {
|
||||
data.details.forEach(item => {
|
||||
const delta = item.quantity_delta;
|
||||
let deltaHtml = '';
|
||||
if (delta > 0) {
|
||||
deltaHtml = `<span class="text-success">+${delta.toFixed(1)}g</span>`;
|
||||
} else if (delta < 0) {
|
||||
deltaHtml = `<span class="text-danger">${delta.toFixed(1)}g</span>`;
|
||||
} else {
|
||||
deltaHtml = '<span class="text-muted">0g</span>';
|
||||
}
|
||||
|
||||
itemsBody.append(`
|
||||
<tr>
|
||||
<td>${item.herb_name}</td>
|
||||
<td>${item.insurance_code || '-'}</td>
|
||||
<td>${item.origin_country || '-'}</td>
|
||||
<td>#${item.lot_id}</td>
|
||||
<td>${item.quantity_before.toFixed(1)}g</td>
|
||||
<td>${item.quantity_after.toFixed(1)}g</td>
|
||||
<td>${deltaHtml}</td>
|
||||
<td>${item.reason || '-'}</td>
|
||||
</tr>
|
||||
`);
|
||||
});
|
||||
}
|
||||
|
||||
// 보정 상세 모달 표시
|
||||
$('#adjustmentDetailModal').modal('show');
|
||||
}
|
||||
}).fail(function() {
|
||||
alert('보정 상세 정보를 불러오는데 실패했습니다.');
|
||||
});
|
||||
}
|
||||
|
||||
// 보정 대상 약재 추가
|
||||
let adjustmentItemCount = 0;
|
||||
$('#addAdjustmentItemBtn').on('click', function() {
|
||||
addAdjustmentItemRow();
|
||||
});
|
||||
|
||||
function addAdjustmentItemRow() {
|
||||
adjustmentItemCount++;
|
||||
const rowId = `adj-item-${adjustmentItemCount}`;
|
||||
|
||||
const newRow = $(`
|
||||
<tr data-row-id="${rowId}">
|
||||
<td>
|
||||
<select class="form-select form-select-sm adj-herb-select" required>
|
||||
<option value="">약재 선택</option>
|
||||
</select>
|
||||
</td>
|
||||
<td>
|
||||
<select class="form-select form-select-sm adj-lot-select" disabled required>
|
||||
<option value="">약재 먼저 선택</option>
|
||||
</select>
|
||||
</td>
|
||||
<td class="before-qty text-end">-</td>
|
||||
<td>
|
||||
<input type="number" class="form-control form-control-sm after-qty-input"
|
||||
min="0" step="0.1" placeholder="0.0" required>
|
||||
</td>
|
||||
<td class="delta-qty text-end">-</td>
|
||||
<td>
|
||||
<input type="text" class="form-control form-control-sm reason-input"
|
||||
placeholder="사유">
|
||||
</td>
|
||||
<td>
|
||||
<button type="button" class="btn btn-sm btn-outline-danger remove-adj-item">
|
||||
<i class="bi bi-x"></i>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
`);
|
||||
|
||||
$('#adjustmentItemsList').append(newRow);
|
||||
|
||||
// 약재 목록 로드
|
||||
loadHerbsForSelect(newRow.find('.adj-herb-select'));
|
||||
|
||||
// 약재 선택 이벤트
|
||||
newRow.find('.adj-herb-select').on('change', function() {
|
||||
const herbId = $(this).val();
|
||||
const row = $(this).closest('tr');
|
||||
|
||||
if (herbId) {
|
||||
loadLotsForAdjustment(herbId, row);
|
||||
} else {
|
||||
row.find('.adj-lot-select').empty().append('<option value="">약재 먼저 선택</option>').prop('disabled', true);
|
||||
row.find('.before-qty').text('-');
|
||||
row.find('.after-qty-input').val('');
|
||||
row.find('.delta-qty').text('-');
|
||||
}
|
||||
});
|
||||
|
||||
// 로트 선택 이벤트
|
||||
newRow.find('.adj-lot-select').on('change', function() {
|
||||
const selectedOption = $(this).find('option:selected');
|
||||
const row = $(this).closest('tr');
|
||||
|
||||
if (selectedOption.val()) {
|
||||
const beforeQty = parseFloat(selectedOption.data('qty')) || 0;
|
||||
row.find('.before-qty').text(beforeQty.toFixed(1) + 'g');
|
||||
row.data('before-qty', beforeQty);
|
||||
|
||||
// 기존 변경후 값이 있으면 델타 재계산
|
||||
const afterQty = parseFloat(row.find('.after-qty-input').val());
|
||||
if (!isNaN(afterQty)) {
|
||||
updateDelta(row, beforeQty, afterQty);
|
||||
}
|
||||
} else {
|
||||
row.find('.before-qty').text('-');
|
||||
row.find('.after-qty-input').val('');
|
||||
row.find('.delta-qty').text('-');
|
||||
}
|
||||
});
|
||||
|
||||
// 변경후 수량 입력 이벤트
|
||||
newRow.find('.after-qty-input').on('input', function() {
|
||||
const row = $(this).closest('tr');
|
||||
const beforeQty = row.data('before-qty') || 0;
|
||||
const afterQty = parseFloat($(this).val()) || 0;
|
||||
|
||||
updateDelta(row, beforeQty, afterQty);
|
||||
});
|
||||
|
||||
// 삭제 버튼
|
||||
newRow.find('.remove-adj-item').on('click', function() {
|
||||
$(this).closest('tr').remove();
|
||||
});
|
||||
}
|
||||
|
||||
// 약재별 로트 목록 로드
|
||||
function loadLotsForAdjustment(herbId, row) {
|
||||
$.get(`/api/inventory/detail/${herbId}`, function(response) {
|
||||
if (response.success) {
|
||||
const lotSelect = row.find('.adj-lot-select');
|
||||
lotSelect.empty();
|
||||
lotSelect.append('<option value="">로트/원산지 선택</option>');
|
||||
|
||||
const data = response.data;
|
||||
|
||||
// 원산지별로 로트 표시
|
||||
data.origins.forEach(origin => {
|
||||
const optgroup = $(`<optgroup label="${origin.origin_country}">`);
|
||||
|
||||
origin.lots.forEach(lot => {
|
||||
optgroup.append(`
|
||||
<option value="${lot.lot_id}"
|
||||
data-qty="${lot.quantity_onhand}"
|
||||
data-origin="${origin.origin_country}">
|
||||
로트#${lot.lot_id} - ${lot.quantity_onhand.toFixed(1)}g (${lot.received_date})
|
||||
</option>
|
||||
`);
|
||||
});
|
||||
|
||||
lotSelect.append(optgroup);
|
||||
});
|
||||
|
||||
lotSelect.prop('disabled', false);
|
||||
}
|
||||
}).fail(function() {
|
||||
alert('재고 정보를 불러오는데 실패했습니다.');
|
||||
});
|
||||
}
|
||||
|
||||
// 델타 계산 및 표시
|
||||
function updateDelta(row, beforeQty, afterQty) {
|
||||
const delta = afterQty - beforeQty;
|
||||
const deltaElement = row.find('.delta-qty');
|
||||
|
||||
if (delta > 0) {
|
||||
deltaElement.html(`<span class="text-success">+${delta.toFixed(1)}g</span>`);
|
||||
} else if (delta < 0) {
|
||||
deltaElement.html(`<span class="text-danger">${delta.toFixed(1)}g</span>`);
|
||||
} else {
|
||||
deltaElement.html('<span class="text-muted">0g</span>');
|
||||
}
|
||||
|
||||
row.data('delta', delta);
|
||||
}
|
||||
|
||||
// 재고 보정 저장 버튼
|
||||
$('#saveAdjustmentBtn').on('click', function() {
|
||||
saveStockAdjustment();
|
||||
});
|
||||
|
||||
// 재고 보정 저장
|
||||
$('#stockAdjustmentForm').on('submit', function(e) {
|
||||
e.preventDefault();
|
||||
saveStockAdjustment();
|
||||
});
|
||||
|
||||
function saveStockAdjustment() {
|
||||
const items = [];
|
||||
let hasError = false;
|
||||
|
||||
$('#adjustmentItemsList tr').each(function() {
|
||||
const herbId = $(this).find('.adj-herb-select').val();
|
||||
const lotId = $(this).find('.adj-lot-select').val();
|
||||
const beforeQty = $(this).data('before-qty');
|
||||
const afterQty = parseFloat($(this).find('.after-qty-input').val());
|
||||
const delta = $(this).data('delta');
|
||||
const reason = $(this).find('.reason-input').val();
|
||||
|
||||
if (!herbId || !lotId) {
|
||||
hasError = true;
|
||||
return false;
|
||||
}
|
||||
|
||||
items.push({
|
||||
herb_item_id: parseInt(herbId),
|
||||
lot_id: parseInt(lotId),
|
||||
quantity_before: beforeQty,
|
||||
quantity_after: afterQty,
|
||||
quantity_delta: delta,
|
||||
reason: reason
|
||||
});
|
||||
});
|
||||
|
||||
if (hasError) {
|
||||
alert('모든 항목의 약재와 로트를 선택해주세요.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (items.length === 0) {
|
||||
alert('보정할 항목을 추가해주세요.');
|
||||
return;
|
||||
}
|
||||
|
||||
const adjustmentData = {
|
||||
adjustment_date: $('#adjustmentDate').val(),
|
||||
adjustment_type: $('#adjustmentType').val(),
|
||||
created_by: $('#adjustmentCreatedBy').val() || 'SYSTEM',
|
||||
notes: $('#adjustmentNotes').val(),
|
||||
details: items // API expects 'details', not 'items'
|
||||
};
|
||||
|
||||
$.ajax({
|
||||
url: '/api/stock-adjustments',
|
||||
method: 'POST',
|
||||
contentType: 'application/json',
|
||||
data: JSON.stringify(adjustmentData),
|
||||
success: function(response) {
|
||||
if (response.success) {
|
||||
alert(`재고 보정이 완료되었습니다.\n보정번호: ${response.adjustment_no}\n항목 수: ${items.length}개`);
|
||||
$('#stockAdjustmentModal').modal('hide');
|
||||
|
||||
// 재고 목록 새로고침
|
||||
loadInventory();
|
||||
}
|
||||
},
|
||||
error: function(xhr) {
|
||||
alert('오류: ' + (xhr.responseJSON?.error || '재고 보정 실패'));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function formatCurrency(amount) {
|
||||
if (amount === null || amount === undefined) return '0원';
|
||||
return new Intl.NumberFormat('ko-KR', {
|
||||
@ -1087,4 +1834,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,179 @@
|
||||
</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>
|
||||
<div>
|
||||
<button class="btn btn-warning me-2" id="showStockAdjustmentBtn">
|
||||
<i class="bi bi-sliders"></i> 재고 보정
|
||||
</button>
|
||||
<button class="btn btn-outline-warning me-2" id="showAdjustmentHistoryBtn">
|
||||
<i class="bi bi-clock-history"></i> 보정 내역
|
||||
</button>
|
||||
<button class="btn btn-outline-info" id="showStockLedgerBtn">
|
||||
<i class="bi bi-journal-text"></i> 입출고 원장
|
||||
</button>
|
||||
</div>
|
||||
</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 +640,7 @@
|
||||
<th>로트 수</th>
|
||||
<th>평균 단가</th>
|
||||
<th>재고 금액</th>
|
||||
<th>작업</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="inventoryList">
|
||||
@ -459,27 +651,317 @@
|
||||
</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="RECEIPT">입고만</option>
|
||||
<option value="CONSUME">출고만</option>
|
||||
<option value="ADJUST">보정만</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 class="modal fade" id="stockAdjustmentModal" tabindex="-1">
|
||||
<div class="modal-dialog modal-xl">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header bg-warning text-dark">
|
||||
<h5 class="modal-title"><i class="bi bi-sliders"></i> 재고 보정</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<form id="stockAdjustmentForm">
|
||||
<div class="row mb-3">
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">보정일자 *</label>
|
||||
<input type="date" class="form-control" id="adjustmentDate" required>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">보정 유형 *</label>
|
||||
<select class="form-control" id="adjustmentType" required>
|
||||
<option value="">선택하세요</option>
|
||||
<option value="LOSS">감모/손실</option>
|
||||
<option value="FOUND">발견</option>
|
||||
<option value="RECOUNT">재고조사</option>
|
||||
<option value="DAMAGE">파손</option>
|
||||
<option value="EXPIRE">유통기한 경과</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">담당자</label>
|
||||
<input type="text" class="form-control" id="adjustmentCreatedBy" placeholder="담당자 이름">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row mb-3">
|
||||
<div class="col-md-12">
|
||||
<label class="form-label">비고</label>
|
||||
<textarea class="form-control" id="adjustmentNotes" rows="2" placeholder="보정 사유를 입력하세요"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr>
|
||||
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<h6><i class="bi bi-list-check"></i> 보정 대상 약재</h6>
|
||||
<button type="button" class="btn btn-sm btn-primary" id="addAdjustmentItemBtn">
|
||||
<i class="bi bi-plus-circle"></i> 약재 추가
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm table-bordered">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th width="200">약재명</th>
|
||||
<th width="250">로트/원산지</th>
|
||||
<th width="100">보정 전 재고</th>
|
||||
<th width="100">보정 후 재고</th>
|
||||
<th width="80">증감량</th>
|
||||
<th>보정 사유</th>
|
||||
<th width="60">작업</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="adjustmentItemsList">
|
||||
<!-- Dynamic content -->
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="alert alert-info mt-3">
|
||||
<i class="bi bi-info-circle"></i>
|
||||
<strong>안내:</strong> 보정 전 재고는 현재 시스템 재고가 자동으로 표시됩니다.
|
||||
실사 재고를 "보정 후 재고"에 입력하면 증감량이 자동 계산됩니다.
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">취소</button>
|
||||
<button type="button" class="btn btn-warning" id="saveAdjustmentBtn">
|
||||
<i class="bi bi-check-circle"></i> 보정 실행
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 재고 보정 내역 모달 -->
|
||||
<div class="modal fade" id="adjustmentHistoryModal" tabindex="-1">
|
||||
<div class="modal-dialog modal-xl">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header bg-warning text-dark">
|
||||
<h5 class="modal-title"><i class="bi bi-clock-history"></i> 재고 보정 내역</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover">
|
||||
<thead class="table-dark">
|
||||
<tr>
|
||||
<th width="120">보정일자</th>
|
||||
<th width="150">보정번호</th>
|
||||
<th width="100">보정 유형</th>
|
||||
<th width="80">항목 수</th>
|
||||
<th width="100">담당자</th>
|
||||
<th>비고</th>
|
||||
<th width="120">작업</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="adjustmentHistoryList">
|
||||
<!-- 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 class="modal fade" id="adjustmentDetailModal" 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-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-4">
|
||||
<div class="col-md-6">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<h6 class="card-title">보정 정보</h6>
|
||||
<table class="table table-sm table-borderless">
|
||||
<tr>
|
||||
<th width="100">보정번호:</th>
|
||||
<td id="detailAdjustmentNo"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>보정일자:</th>
|
||||
<td id="detailAdjustmentDate"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>보정 유형:</th>
|
||||
<td id="detailAdjustmentType"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>담당자:</th>
|
||||
<td id="detailAdjustmentCreatedBy"></td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<h6 class="card-title">비고</h6>
|
||||
<p id="detailAdjustmentNotes" class="mb-0"></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 보정 상세 항목 -->
|
||||
<h6><i class="bi bi-list-check"></i> 보정 상세 내역</h6>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm table-bordered">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th>약재명</th>
|
||||
<th>보험코드</th>
|
||||
<th>원산지</th>
|
||||
<th>로트ID</th>
|
||||
<th>보정 전</th>
|
||||
<th>보정 후</th>
|
||||
<th>증감량</th>
|
||||
<th>사유</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="detailAdjustmentItems">
|
||||
<!-- 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>
|
||||
@ -655,6 +1137,6 @@
|
||||
<!-- Scripts -->
|
||||
<script src="https://code.jquery.com/jquery-3.6.0.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/js/bootstrap.bundle.min.js"></script>
|
||||
<script src="/static/app.js"></script>
|
||||
<script src="/static/app.js?v=20260215"></script>
|
||||
</body>
|
||||
</html>
|
||||
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()
|
||||
103
update_receipt_numbers.py
Normal file
103
update_receipt_numbers.py
Normal file
@ -0,0 +1,103 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
"""
|
||||
기존 입고 데이터에 입고장 번호를 자동으로 부여하는 스크립트
|
||||
형식: PR-YYYYMMDD-XXXX (PR: Purchase Receipt)
|
||||
"""
|
||||
|
||||
import sqlite3
|
||||
from datetime import datetime
|
||||
|
||||
def update_receipt_numbers():
|
||||
conn = sqlite3.connect('database/kdrug.db')
|
||||
cursor = conn.cursor()
|
||||
|
||||
try:
|
||||
# 입고장 번호가 없는 레코드 조회
|
||||
cursor.execute("""
|
||||
SELECT receipt_id, receipt_date, supplier_id
|
||||
FROM purchase_receipts
|
||||
WHERE receipt_no IS NULL OR receipt_no = ''
|
||||
ORDER BY receipt_date, receipt_id
|
||||
""")
|
||||
|
||||
receipts = cursor.fetchall()
|
||||
|
||||
if not receipts:
|
||||
print("모든 입고장에 이미 번호가 있습니다.")
|
||||
return
|
||||
|
||||
print(f"입고장 번호를 부여할 레코드: {len(receipts)}개")
|
||||
|
||||
# 날짜별 카운터 딕셔너리
|
||||
date_counters = {}
|
||||
|
||||
for receipt in receipts:
|
||||
receipt_id, receipt_date, supplier_id = receipt
|
||||
|
||||
# 날짜 형식 변환
|
||||
if receipt_date:
|
||||
# receipt_date가 문자열인 경우
|
||||
if isinstance(receipt_date, str):
|
||||
date_str = receipt_date.replace('-', '') # YYYYMMDD 형식
|
||||
# receipt_date가 정수인 경우 (YYYYMMDD 형식으로 저장된 경우)
|
||||
else:
|
||||
date_str = str(receipt_date)
|
||||
else:
|
||||
date_str = datetime.now().strftime('%Y%m%d')
|
||||
|
||||
# 해당 날짜의 카운터 증가
|
||||
if date_str not in date_counters:
|
||||
# 해당 날짜의 기존 최대 번호 확인
|
||||
cursor.execute("""
|
||||
SELECT MAX(CAST(SUBSTR(receipt_no, -4) AS INTEGER))
|
||||
FROM purchase_receipts
|
||||
WHERE receipt_no LIKE ?
|
||||
""", (f'PR-{date_str}-%',))
|
||||
|
||||
max_num = cursor.fetchone()[0]
|
||||
date_counters[date_str] = (max_num or 0) + 1
|
||||
else:
|
||||
date_counters[date_str] += 1
|
||||
|
||||
# 입고장 번호 생성 (PR-YYYYMMDD-XXXX)
|
||||
receipt_no = f"PR-{date_str}-{date_counters[date_str]:04d}"
|
||||
|
||||
# 업데이트
|
||||
cursor.execute("""
|
||||
UPDATE purchase_receipts
|
||||
SET receipt_no = ?
|
||||
WHERE receipt_id = ?
|
||||
""", (receipt_no, receipt_id))
|
||||
|
||||
print(f" 입고장 ID {receipt_id}: {receipt_no} 부여 완료")
|
||||
|
||||
conn.commit()
|
||||
print(f"\n총 {len(receipts)}개의 입고장 번호 부여 완료!")
|
||||
|
||||
# 결과 확인
|
||||
cursor.execute("""
|
||||
SELECT receipt_id, receipt_no, receipt_date,
|
||||
(SELECT name FROM suppliers WHERE supplier_id = pr.supplier_id) as supplier_name
|
||||
FROM purchase_receipts pr
|
||||
ORDER BY receipt_date DESC, receipt_id DESC
|
||||
LIMIT 10
|
||||
""")
|
||||
|
||||
print("\n최근 입고장 목록:")
|
||||
print("-" * 80)
|
||||
print(f"{'ID':>6} | {'입고장 번호':<20} | {'입고일':<12} | {'공급처'}")
|
||||
print("-" * 80)
|
||||
|
||||
for row in cursor.fetchall():
|
||||
print(f"{row[0]:>6} | {row[1]:<20} | {row[2]:<12} | {row[3]}")
|
||||
|
||||
except Exception as e:
|
||||
print(f"오류 발생: {e}")
|
||||
conn.rollback()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
if __name__ == "__main__":
|
||||
update_receipt_numbers()
|
||||
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