Compare commits

..

No commits in common. "724af5000abcac7f3a3d863b288fc334024ea804" and "2a14af59c322a3447b10037f55c9531d7fc1cea2" have entirely different histories.

28 changed files with 52 additions and 3914 deletions

View File

@ -1,67 +0,0 @@
# 한약 재고관리 시스템 - 프로젝트 상태
## ✅ 해결된 이슈 (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. 에러 로깅 개선

View File

@ -1,62 +0,0 @@
#!/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()

View File

@ -1,84 +0,0 @@
#!/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
View File

@ -159,78 +159,6 @@ 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'])
@ -434,26 +362,12 @@ 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, receipt_no, total_amount, source_file)
VALUES (?, ?, ?, ?, ?)
""", (supplier_id, str(receipt_date), receipt_no, total_amount, filename))
INSERT INTO purchase_receipts (supplier_id, receipt_date, total_amount, source_file)
VALUES (?, ?, ?, ?)
""", (supplier_id, str(receipt_date), total_amount, filename))
receipt_id = cursor.lastrowid
# 입고장 라인 생성
@ -809,149 +723,6 @@ 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():
"""조제 실행"""
@ -1074,130 +845,6 @@ 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'])
@ -1319,31 +966,12 @@ 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_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 # 보유율
'total_value': total_value
}
})
except Exception as e:
@ -1422,164 +1050,6 @@ 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']):

View File

@ -1,57 +0,0 @@
# 백업 정보
## 백업 일시
- **생성일**: 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

View File

@ -1,65 +0,0 @@
#!/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()

View File

@ -1,69 +0,0 @@
#!/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()

View File

@ -1,35 +0,0 @@
-- 재고 보정 테이블 추가
-- 재고 조정/보정 내역을 기록
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);

View File

@ -1,103 +0,0 @@
#!/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()

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

View File

@ -1,58 +0,0 @@
우리 기존설계에 변화가 필요해
약품 마스터 테이블을 별도로만들어야겟어
대한민국에서 주기적으로
한약재 제품코드를 표준화해서 제공한다
한약재 품목명, 업체명, 제품명 약품규격 (숫자) 약품 규격(단위) 제공을하고
"주성분코드" 를 제공하고 이게 FK가 되야할거같아
그리고 해당 성분코드에 여러 제품들이 있는거지
▶ 표준코드: 개개의 의약품을 식별하기 위해 고유하게 설정된 번호
- 국가식별코드, 업체식별코드, 품목코드* 및 검증번호로 구성(13자리 숫자)
* 품목코드는 함량포함한 품목코드(4자리)와 포장단위(1자리)로 구성되어 있음
▶ 대표코드: 표준코드의 12번째 자리가 '0'인 코드를 말함(실제 의약품에 부착하는 코드로 사용불가)
▶ 제품코드: 한약재 비용 청구시 사용하는 코드
- 업체식별코드와 품목코드로 구성(9자리 숫자)
- 9자리 중 제일 마지막 숫자인 포장단위는 대표코드와 동일하게 "0"임
우리 입고장에
건강 : 062400730 9자리숫자는 진짜 해당제품에 "제품코드" 인것이고
해당 건강에 성분 코드는 별도로 존재하는거지 "성분코드로는"
같은 성분 코드를 지닌 다른 회사 제품도 있을수잇는거고
우리는 성분 코드 기반으로 효능 태그를 사실상 해야할수도잇어
성분코드 = 실제성분이름 1:1 맵핑일거니까
일단 기존 코드를 두고 프론트에서 봐가면서 마이그레이션 해가야하니까
우리 일단 xls파일을 확인하고 sqlite에 마이그레이션 해줘 약품 마스터 테이블을 만들어서 가능할까?

View File

@ -1,210 +0,0 @@
# 한약재 재고관리 시스템 데이터베이스 리팩토링 제안
## 📊 현황 분석
### 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

View File

@ -1,128 +0,0 @@
# 📋 주성분코드 기반 약재 관리 체계 개선 계획
## 🎯 목표
**"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시간
우선순위: 높음

View File

@ -1,55 +0,0 @@
#!/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()

Binary file not shown.

Before

Width:  |  Height:  |  Size: 42 KiB

View File

@ -1,172 +0,0 @@
#!/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()

View File

@ -1,222 +0,0 @@
#!/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}")

View File

@ -1,126 +0,0 @@
#!/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()

View File

@ -1,124 +0,0 @@
# 데이터베이스 리팩토링 결과 보고서
## 📅 실행 일시
- **날짜**: 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

View File

@ -68,51 +68,7 @@ $(document).ready(function() {
}
});
// 오늘 조제 수 및 최근 조제 내역
$.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>');
}
}
});
// TODO: 오늘 조제 수, 최근 조제 내역
}
// 환자 목록 로드
@ -576,145 +532,8 @@ $(document).ready(function() {
// 조제 내역 로드
function loadCompounds() {
$.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('조제 상세 정보를 불러오는데 실패했습니다.');
});
// TODO: 조제 내역 API 구현 필요
$('#compoundsList').html('<tr><td colspan="7" class="text-center">조제 내역이 없습니다.</td></tr>');
}
// 재고 현황 로드
@ -724,57 +543,6 @@ $(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
@ -795,48 +563,23 @@ $(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}">
<tr class="inventory-row" data-herb-id="${item.herb_item_id}" style="cursor: pointer;">
<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}`);
// 클릭 이벤트 바인딩
$('.view-inventory-detail').on('click', function(e) {
e.stopPropagation();
$('.inventory-row').on('click', function() {
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);
});
}
});
}
@ -1337,496 +1080,6 @@ $(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', {
@ -1834,161 +1087,4 @@ $(document).ready(function() {
currency: 'KRW'
}).format(amount);
}
// ==================== 주성분코드 기반 약재 관리 ====================
let allHerbMasters = []; // 전체 약재 데이터 저장
let currentFilter = 'all'; // 현재 필터 상태
// 약재 마스터 목록 로드
function loadHerbMasters() {
$.get('/api/herbs/masters', function(response) {
if (response.success) {
allHerbMasters = response.data;
// 통계 정보 표시
const summary = response.summary;
$('#herbMasterSummary').html(`
<div class="row align-items-center">
<div class="col-md-9">
<h6 class="mb-2">📊 급여 약재 현황</h6>
<div class="d-flex align-items-center">
<div class="me-4">
<strong>전체:</strong> ${summary.total_herbs}
</div>
<div class="me-4">
<strong>재고 있음:</strong> <span class="text-success">${summary.herbs_with_stock}</span>
</div>
<div class="me-4">
<strong>재고 없음:</strong> <span class="text-secondary">${summary.herbs_without_stock}</span>
</div>
<div>
<strong>보유율:</strong> <span class="badge bg-primary fs-6">${summary.coverage_rate}%</span>
</div>
</div>
</div>
<div class="col-md-3">
<div class="progress" style="height: 25px;">
<div class="progress-bar bg-success" role="progressbar"
style="width: ${summary.coverage_rate}%"
aria-valuenow="${summary.coverage_rate}"
aria-valuemin="0" aria-valuemax="100">
${summary.herbs_with_stock} / ${summary.total_herbs}
</div>
</div>
</div>
</div>
`);
// 목록 표시
displayHerbMasters(allHerbMasters);
}
});
}
// 약재 목록 표시
function displayHerbMasters(herbs) {
const tbody = $('#herbMastersList');
tbody.empty();
// 필터링
let filteredHerbs = herbs;
if (currentFilter === 'stock') {
filteredHerbs = herbs.filter(h => h.has_stock);
} else if (currentFilter === 'no-stock') {
filteredHerbs = herbs.filter(h => !h.has_stock);
}
// 검색 필터
const searchText = $('#herbSearch').val().toLowerCase();
if (searchText) {
filteredHerbs = filteredHerbs.filter(h =>
h.herb_name.toLowerCase().includes(searchText) ||
h.ingredient_code.toLowerCase().includes(searchText)
);
}
// 효능 필터
const efficacyFilter = $('#efficacyFilter').val();
if (efficacyFilter) {
filteredHerbs = filteredHerbs.filter(h =>
h.efficacy_tags && h.efficacy_tags.includes(efficacyFilter)
);
}
// 표시
filteredHerbs.forEach(herb => {
// 효능 태그 표시
let efficacyTags = '';
if (herb.efficacy_tags && herb.efficacy_tags.length > 0) {
efficacyTags = herb.efficacy_tags.map(tag =>
`<span class="badge bg-success ms-1">${tag}</span>`
).join('');
}
// 상태 표시
const statusBadge = herb.has_stock
? '<span class="badge bg-success">재고 있음</span>'
: '<span class="badge bg-secondary">재고 없음</span>';
// 재고량 표시
const stockDisplay = herb.stock_quantity > 0
? `${herb.stock_quantity.toFixed(1)}g`
: '-';
// 평균단가 표시
const priceDisplay = herb.avg_price > 0
? formatCurrency(herb.avg_price)
: '-';
tbody.append(`
<tr class="${herb.has_stock ? '' : 'table-secondary'}">
<td><code>${herb.ingredient_code}</code></td>
<td><strong>${herb.herb_name}</strong></td>
<td>${efficacyTags}</td>
<td>${stockDisplay}</td>
<td>${priceDisplay}</td>
<td>${herb.product_count || 0}</td>
<td>${statusBadge}</td>
<td>
<button class="btn btn-sm btn-outline-primary" onclick="viewHerbDetail('${herb.ingredient_code}')">
<i class="bi bi-eye"></i>
</button>
</td>
</tr>
`);
});
if (filteredHerbs.length === 0) {
tbody.append('<tr><td colspan="8" class="text-center">표시할 약재가 없습니다.</td></tr>');
}
}
// 약재 상세 보기
function viewHerbDetail(ingredientCode) {
// TODO: 약재 상세 모달 구현
console.log('View detail for:', ingredientCode);
}
// 필터 버튼 이벤트
$('#herbs .btn-group button[data-filter]').on('click', function() {
$('#herbs .btn-group button').removeClass('active');
$(this).addClass('active');
currentFilter = $(this).data('filter');
displayHerbMasters(allHerbMasters);
});
// 검색 이벤트
$('#herbSearch').on('keyup', function() {
displayHerbMasters(allHerbMasters);
});
// 효능 필터 이벤트
$('#efficacyFilter').on('change', function() {
displayHerbMasters(allHerbMasters);
});
// 약재 관리 페이지가 활성화되면 데이터 로드
$('.nav-link[data-page="herbs"]').on('click', function() {
setTimeout(() => loadHerbMasters(), 100);
});
});

View File

@ -408,49 +408,20 @@
</div>
<div class="card mt-4">
<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 class="card-header">
<h5 class="mb-0">조제 내역</h5>
</div>
<div class="card-body">
<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">
<table class="table table-hover">
<thead>
<tr>
<th width="40">#</th>
<th width="100">조제일시</th>
<th width="80">환자명</th>
<th width="100">연락처</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>
<th>제수</th>
<th>파우치</th>
<th>원가</th>
<th>상태</th>
</tr>
</thead>
<tbody id="compoundsList">
@ -458,179 +429,17 @@
</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">
<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>
<h3 class="mb-4">재고 현황</h3>
<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>
@ -640,7 +449,6 @@
<th>로트 수</th>
<th>평균 단가</th>
<th>재고 금액</th>
<th>작업</th>
</tr>
</thead>
<tbody id="inventoryList">
@ -651,317 +459,27 @@
</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>약재 관리 <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>
<h3>약재 관리</h3>
<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="herbMastersList">
<tbody id="herbsList">
<!-- Dynamic content -->
</tbody>
</table>
@ -1137,6 +655,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?v=20260215"></script>
<script src="/static/app.js"></script>
</body>
</html>

View File

@ -1,68 +0,0 @@
#!/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()

View File

@ -1,78 +0,0 @@
#!/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()

View File

@ -1,60 +0,0 @@
#!/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()

View File

@ -1,103 +0,0 @@
#!/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()

Binary file not shown.