feat: 판매관리 시스템 Phase 1 및 마일리지 시스템 구현

- 판매 관리 기능 추가
  - compounds 테이블에 판매 관련 컬럼 추가 (payment_method, discount_rate, delivery_method 등)
  - 판매 상태 관리 (조제완료→결제대기→결제완료→배송대기→배송완료)
  - 판매 처리 모달 UI 구현
  - 9가지 상태별 뱃지 표시

- 마일리지 시스템 구축
  - patients 테이블에 마일리지 컬럼 추가 (balance, earned, used)
  - mileage_transactions 테이블 생성 (거래 이력 관리)
  - 마일리지 사용/적립 기능 구현

- 복합 결제 기능
  - 할인율(%) / 할인액(원) 직접 입력 선택 가능
  - 마일리지 + 현금 + 카드 + 계좌이체 복합 결제
  - 결제 금액 자동 검증
  - 결제 방법 자동 분류 (복합결제 지원)

- API 엔드포인트 추가
  - POST /api/compounds/<id>/status (상태 업데이트)
  - PUT /api/compounds/<id>/price (가격 조정)
  - GET /api/sales/statistics (판매 통계)

- 데이터베이스 설정 통합
  - config.py 생성하여 DB 경로 중앙화

TODO: 처방별 기본가격 정책 시스템 (price_policies 테이블 활용)

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
시골약사 2026-02-18 05:42:29 +00:00
parent ad9ac396e2
commit f3f1efd8c2
12 changed files with 2154 additions and 4 deletions

194
add_mileage_system.py Normal file
View File

@ -0,0 +1,194 @@
#!/usr/bin/env python3
"""
마일리지 시스템 구축 - patients 테이블에 마일리지 컬럼 추가 이력 테이블 생성
"""
import sqlite3
from datetime import datetime
from config import DATABASE_PATH
def add_mileage_system():
"""환자 테이블에 마일리지 컬럼 추가 및 이력 테이블 생성"""
conn = sqlite3.connect(DATABASE_PATH)
cursor = conn.cursor()
# 1. patients 테이블에 마일리지 관련 컬럼 추가
try:
cursor.execute("""
ALTER TABLE patients
ADD COLUMN mileage_balance INTEGER DEFAULT 0
""")
print("✓ mileage_balance 컬럼 추가 완료")
except sqlite3.OperationalError as e:
if "duplicate column name" in str(e):
print("- mileage_balance 컬럼 이미 존재")
else:
raise
try:
cursor.execute("""
ALTER TABLE patients
ADD COLUMN total_mileage_earned INTEGER DEFAULT 0
""")
print("✓ total_mileage_earned 컬럼 추가 완료")
except sqlite3.OperationalError as e:
if "duplicate column name" in str(e):
print("- total_mileage_earned 컬럼 이미 존재")
else:
raise
try:
cursor.execute("""
ALTER TABLE patients
ADD COLUMN total_mileage_used INTEGER DEFAULT 0
""")
print("✓ total_mileage_used 컬럼 추가 완료")
except sqlite3.OperationalError as e:
if "duplicate column name" in str(e):
print("- total_mileage_used 컬럼 이미 존재")
else:
raise
# 2. 마일리지 거래 이력 테이블 생성
cursor.execute("""
CREATE TABLE IF NOT EXISTS mileage_transactions (
transaction_id INTEGER PRIMARY KEY AUTOINCREMENT,
patient_id INTEGER REFERENCES patients(patient_id),
compound_id INTEGER REFERENCES compounds(compound_id),
transaction_type TEXT NOT NULL, -- EARNED, USED, EXPIRED, ADMIN_ADJUST
amount INTEGER NOT NULL,
balance_after INTEGER NOT NULL,
description TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
created_by TEXT
)
""")
print("✓ mileage_transactions 테이블 생성 완료")
# 인덱스 생성
cursor.execute("""
CREATE INDEX IF NOT EXISTS idx_mileage_transactions_patient
ON mileage_transactions(patient_id)
""")
print("✓ 인덱스 생성 완료")
# 3. compounds 테이블에 마일리지 사용 컬럼 추가
try:
cursor.execute("""
ALTER TABLE compounds
ADD COLUMN mileage_used INTEGER DEFAULT 0
""")
print("✓ compounds.mileage_used 컬럼 추가 완료")
except sqlite3.OperationalError as e:
if "duplicate column name" in str(e):
print("- compounds.mileage_used 컬럼 이미 존재")
else:
raise
try:
cursor.execute("""
ALTER TABLE compounds
ADD COLUMN mileage_earned INTEGER DEFAULT 0
""")
print("✓ compounds.mileage_earned 컬럼 추가 완료")
except sqlite3.OperationalError as e:
if "duplicate column name" in str(e):
print("- compounds.mileage_earned 컬럼 이미 존재")
else:
raise
# 4. 복합 결제를 위한 컬럼 추가
try:
cursor.execute("""
ALTER TABLE compounds
ADD COLUMN payment_cash INTEGER DEFAULT 0
""")
print("✓ payment_cash 컬럼 추가 완료")
except sqlite3.OperationalError as e:
if "duplicate column name" in str(e):
print("- payment_cash 컬럼 이미 존재")
else:
raise
try:
cursor.execute("""
ALTER TABLE compounds
ADD COLUMN payment_card INTEGER DEFAULT 0
""")
print("✓ payment_card 컬럼 추가 완료")
except sqlite3.OperationalError as e:
if "duplicate column name" in str(e):
print("- payment_card 컬럼 이미 존재")
else:
raise
try:
cursor.execute("""
ALTER TABLE compounds
ADD COLUMN payment_transfer INTEGER DEFAULT 0
""")
print("✓ payment_transfer 컬럼 추가 완료")
except sqlite3.OperationalError as e:
if "duplicate column name" in str(e):
print("- payment_transfer 컬럼 이미 존재")
else:
raise
try:
cursor.execute("""
ALTER TABLE compounds
ADD COLUMN discount_amount INTEGER DEFAULT 0
""")
print("✓ discount_amount 컬럼 추가 완료")
except sqlite3.OperationalError as e:
if "duplicate column name" in str(e):
print("- discount_amount 컬럼 이미 존재")
else:
raise
conn.commit()
# 5. 테스트용 마일리지 데이터 추가 (박주호 회원)
cursor.execute("""
SELECT patient_id, name FROM patients
WHERE name LIKE '%박주호%' OR name LIKE '%주호%'
""")
patients = cursor.fetchall()
if patients:
for patient in patients:
print(f"\n박주호 회원 발견: ID={patient[0]}, 이름={patient[1]}")
# 초기 마일리지 부여
cursor.execute("""
UPDATE patients
SET mileage_balance = 50000,
total_mileage_earned = 50000,
total_mileage_used = 0
WHERE patient_id = ?
""", (patient[0],))
# 마일리지 이력 추가
cursor.execute("""
INSERT INTO mileage_transactions
(patient_id, transaction_type, amount, balance_after, description, created_by)
VALUES (?, 'ADMIN_ADJUST', 50000, 50000, '초기 마일리지 부여', 'system')
""", (patient[0],))
print(f" → 50,000 마일리지 부여 완료")
else:
print("\n박주호 회원을 찾을 수 없습니다.")
conn.commit()
# 현재 patients 테이블 구조 확인
cursor.execute("PRAGMA table_info(patients)")
columns = cursor.fetchall()
print("\n현재 patients 테이블 구조 (마일리지 관련):")
for col in columns:
if 'mileage' in col[1].lower():
print(f" - {col[1]}: {col[2]}")
conn.close()
print("\n마일리지 시스템 구축 완료!")
if __name__ == "__main__":
add_mileage_system()

131
add_sales_columns.py Normal file
View File

@ -0,0 +1,131 @@
#!/usr/bin/env python3
"""
판매 관리 시스템 Phase 1 - compounds 테이블에 판매 관련 컬럼 추가
"""
import sqlite3
from datetime import datetime
def add_sales_columns():
"""compounds 테이블에 판매 관련 컬럼 추가"""
conn = sqlite3.connect('database/kdrug.db')
cursor = conn.cursor()
try:
# 1. payment_method 컬럼 추가
cursor.execute("""
ALTER TABLE compounds
ADD COLUMN payment_method TEXT
""")
print("✓ payment_method 컬럼 추가 완료")
except sqlite3.OperationalError as e:
if "duplicate column name" in str(e):
print("- payment_method 컬럼 이미 존재")
else:
raise
try:
# 2. payment_date 컬럼 추가
cursor.execute("""
ALTER TABLE compounds
ADD COLUMN payment_date DATETIME
""")
print("✓ payment_date 컬럼 추가 완료")
except sqlite3.OperationalError as e:
if "duplicate column name" in str(e):
print("- payment_date 컬럼 이미 존재")
else:
raise
try:
# 3. discount_rate 컬럼 추가
cursor.execute("""
ALTER TABLE compounds
ADD COLUMN discount_rate REAL DEFAULT 0
""")
print("✓ discount_rate 컬럼 추가 완료")
except sqlite3.OperationalError as e:
if "duplicate column name" in str(e):
print("- discount_rate 컬럼 이미 존재")
else:
raise
try:
# 4. discount_reason 컬럼 추가
cursor.execute("""
ALTER TABLE compounds
ADD COLUMN discount_reason TEXT
""")
print("✓ discount_reason 컬럼 추가 완료")
except sqlite3.OperationalError as e:
if "duplicate column name" in str(e):
print("- discount_reason 컬럼 이미 존재")
else:
raise
try:
# 5. delivery_method 컬럼 추가
cursor.execute("""
ALTER TABLE compounds
ADD COLUMN delivery_method TEXT
""")
print("✓ delivery_method 컬럼 추가 완료")
except sqlite3.OperationalError as e:
if "duplicate column name" in str(e):
print("- delivery_method 컬럼 이미 존재")
else:
raise
try:
# 6. delivery_date 컬럼 추가
cursor.execute("""
ALTER TABLE compounds
ADD COLUMN delivery_date DATETIME
""")
print("✓ delivery_date 컬럼 추가 완료")
except sqlite3.OperationalError as e:
if "duplicate column name" in str(e):
print("- delivery_date 컬럼 이미 존재")
else:
raise
try:
# 7. invoice_number 컬럼 추가
cursor.execute("""
ALTER TABLE compounds
ADD COLUMN invoice_number TEXT
""")
print("✓ invoice_number 컬럼 추가 완료")
except sqlite3.OperationalError as e:
if "duplicate column name" in str(e):
print("- invoice_number 컬럼 이미 존재")
else:
raise
try:
# 8. actual_payment_amount 컬럼 추가 (실제 결제 금액)
cursor.execute("""
ALTER TABLE compounds
ADD COLUMN actual_payment_amount REAL
""")
print("✓ actual_payment_amount 컬럼 추가 완료")
except sqlite3.OperationalError as e:
if "duplicate column name" in str(e):
print("- actual_payment_amount 컬럼 이미 존재")
else:
raise
conn.commit()
print("\n판매 관련 컬럼 추가 완료!")
# 현재 compounds 테이블 구조 확인
cursor.execute("PRAGMA table_info(compounds)")
columns = cursor.fetchall()
print("\n현재 compounds 테이블 구조:")
for col in columns:
print(f" - {col[1]}: {col[2]}")
conn.close()
if __name__ == "__main__":
add_sales_columns()

322
app.py
View File

@ -15,11 +15,12 @@ from werkzeug.utils import secure_filename
import json import json
from contextlib import contextmanager from contextlib import contextmanager
from excel_processor import ExcelProcessor from excel_processor import ExcelProcessor
from config import DATABASE_PATH, STATIC_PATH, TEMPLATES_PATH
# Flask 앱 초기화 # Flask 앱 초기화
app = Flask(__name__, static_folder='static', template_folder='templates') app = Flask(__name__, static_folder='static', template_folder='templates')
app.config['SECRET_KEY'] = 'your-secret-key-change-in-production' app.config['SECRET_KEY'] = 'your-secret-key-change-in-production'
app.config['DATABASE'] = 'database/kdrug.db' app.config['DATABASE'] = str(DATABASE_PATH) # config.py의 경로 사용
app.config['UPLOAD_FOLDER'] = 'uploads' app.config['UPLOAD_FOLDER'] = 'uploads'
app.config['MAX_CONTENT_LENGTH'] = 16 * 1024 * 1024 # 16MB max file size app.config['MAX_CONTENT_LENGTH'] = 16 * 1024 * 1024 # 16MB max file size
@ -2735,6 +2736,325 @@ def get_all_efficacy_tags():
except Exception as e: except Exception as e:
return jsonify({'error': str(e)}), 500 return jsonify({'error': str(e)}), 500
# ==================== 판매 관리 API ====================
@app.route('/api/compounds/<int:compound_id>/status', methods=['POST'])
def update_compound_status(compound_id):
"""조제 상태 업데이트"""
try:
data = request.json
new_status = data.get('status')
valid_statuses = ['PREPARED', 'PENDING_PAYMENT', 'PAID', 'PENDING_DELIVERY',
'DELIVERED', 'COMPLETED', 'OTC_CONVERTED', 'CANCELLED', 'REFUNDED']
if new_status not in valid_statuses:
return jsonify({'error': '유효하지 않은 상태입니다'}), 400
with get_db() as conn:
cursor = conn.cursor()
# 현재 상태 조회
cursor.execute("SELECT status FROM compounds WHERE compound_id = ?", (compound_id,))
current = cursor.fetchone()
if not current:
return jsonify({'error': '조제 정보를 찾을 수 없습니다'}), 404
old_status = current['status']
# 상태 업데이트
update_fields = ['status = ?']
update_values = [new_status]
# 결제 관련 정보
if new_status == 'PAID':
if 'payment_method' in data:
update_fields.append('payment_method = ?')
update_values.append(data['payment_method'])
if 'payment_date' in data:
update_fields.append('payment_date = ?')
update_values.append(data['payment_date'])
if 'actual_payment_amount' in data:
update_fields.append('actual_payment_amount = ?')
update_values.append(data['actual_payment_amount'])
# 배송 관련 정보
elif new_status in ['PENDING_DELIVERY', 'DELIVERED']:
if 'delivery_method' in data:
update_fields.append('delivery_method = ?')
update_values.append(data['delivery_method'])
if 'delivery_date' in data:
update_fields.append('delivery_date = ?')
update_values.append(data['delivery_date'])
if 'invoice_number' in data:
update_fields.append('invoice_number = ?')
update_values.append(data['invoice_number'])
update_values.append(compound_id)
cursor.execute(f"""
UPDATE compounds
SET {', '.join(update_fields)}, updated_at = CURRENT_TIMESTAMP
WHERE compound_id = ?
""", update_values)
# 상태 변경 이력 저장
cursor.execute("""
INSERT INTO sales_status_history (compound_id, old_status, new_status, changed_by, change_reason)
VALUES (?, ?, ?, ?, ?)
""", (compound_id, old_status, new_status,
data.get('changed_by', 'system'), data.get('reason', '')))
# 판매 거래 기록 (결제 완료시)
if new_status == 'PAID' and 'actual_payment_amount' in data:
cursor.execute("""
INSERT INTO sales_transactions (compound_id, transaction_date, transaction_type,
amount, payment_method, payment_status, created_by)
VALUES (?, ?, 'SALE', ?, ?, 'COMPLETED', ?)
""", (compound_id, data.get('payment_date', datetime.now()),
data['actual_payment_amount'], data.get('payment_method'),
data.get('changed_by', 'system')))
conn.commit()
return jsonify({'success': True, 'message': '상태가 업데이트되었습니다'})
except Exception as e:
return jsonify({'error': str(e)}), 500
@app.route('/api/compounds/<int:compound_id>/price', methods=['PUT'])
def update_compound_price(compound_id):
"""조제 가격 조정 (복합결제 지원)"""
try:
data = request.json
with get_db() as conn:
cursor = conn.cursor()
# 현재 정보 조회
cursor.execute("""
SELECT sell_price_total, cost_total, patient_id
FROM compounds WHERE compound_id = ?
""", (compound_id,))
current = cursor.fetchone()
if not current:
return jsonify({'error': '조제 정보를 찾을 수 없습니다'}), 404
update_fields = []
update_values = []
# 판매가격
if 'sell_price_total' in data:
update_fields.append('sell_price_total = ?')
update_values.append(data['sell_price_total'])
# 할인 정보
if 'discount_rate' in data:
update_fields.append('discount_rate = ?')
update_values.append(data['discount_rate'])
if 'discount_amount' in data:
update_fields.append('discount_amount = ?')
update_values.append(data['discount_amount'])
if 'discount_reason' in data:
update_fields.append('discount_reason = ?')
update_values.append(data['discount_reason'])
# 복합 결제 정보
if 'payment_cash' in data:
update_fields.append('payment_cash = ?')
update_values.append(data['payment_cash'])
if 'payment_card' in data:
update_fields.append('payment_card = ?')
update_values.append(data['payment_card'])
if 'payment_transfer' in data:
update_fields.append('payment_transfer = ?')
update_values.append(data['payment_transfer'])
# 마일리지 사용
if 'mileage_used' in data and data['mileage_used'] > 0:
update_fields.append('mileage_used = ?')
update_values.append(data['mileage_used'])
# 환자 마일리지 차감
if current['patient_id']:
cursor.execute("""
UPDATE patients
SET mileage_balance = mileage_balance - ?,
total_mileage_used = total_mileage_used + ?
WHERE patient_id = ?
""", (data['mileage_used'], data['mileage_used'], current['patient_id']))
# 마일리지 거래 이력 추가
cursor.execute("""
INSERT INTO mileage_transactions
(patient_id, compound_id, transaction_type, amount, balance_after, description, created_by)
SELECT patient_id, ?, 'USED', ?, mileage_balance, '한약 결제 사용', 'system'
FROM patients WHERE patient_id = ?
""", (compound_id, data['mileage_used'], current['patient_id']))
# 실제 결제 금액 계산
total_payment = (data.get('mileage_used', 0) +
data.get('payment_cash', 0) +
data.get('payment_card', 0) +
data.get('payment_transfer', 0))
if total_payment > 0:
update_fields.append('actual_payment_amount = ?')
update_values.append(total_payment)
update_values.append(compound_id)
if update_fields:
cursor.execute(f"""
UPDATE compounds
SET {', '.join(update_fields)}, updated_at = CURRENT_TIMESTAMP
WHERE compound_id = ?
""", update_values)
conn.commit()
return jsonify({'success': True, 'message': '가격이 조정되었습니다'})
except Exception as e:
return jsonify({'error': str(e)}), 500
@app.route('/api/sales/statistics', methods=['GET'])
def get_sales_statistics():
"""판매 통계 조회"""
try:
start_date = request.args.get('start_date')
end_date = request.args.get('end_date')
with get_db() as conn:
cursor = conn.cursor()
# 기간 내 매출 통계
query = """
SELECT
COUNT(*) as total_count,
SUM(COALESCE(actual_payment_amount, sell_price_total)) as total_sales,
AVG(COALESCE(actual_payment_amount, sell_price_total)) as avg_price,
COUNT(CASE WHEN status = 'COMPLETED' THEN 1 END) as completed_count,
COUNT(CASE WHEN status = 'CANCELLED' THEN 1 END) as cancelled_count,
COUNT(CASE WHEN status = 'REFUNDED' THEN 1 END) as refunded_count
FROM compounds
WHERE status IN ('PAID', 'PENDING_DELIVERY', 'DELIVERED', 'COMPLETED')
"""
params = []
if start_date:
query += " AND compound_date >= ?"
params.append(start_date)
if end_date:
query += " AND compound_date <= ?"
params.append(end_date)
cursor.execute(query, params)
stats = cursor.fetchone()
# 일별 매출 추이
daily_query = """
SELECT
DATE(compound_date) as sale_date,
COUNT(*) as count,
SUM(COALESCE(actual_payment_amount, sell_price_total)) as daily_total
FROM compounds
WHERE status IN ('PAID', 'PENDING_DELIVERY', 'DELIVERED', 'COMPLETED')
"""
if start_date:
daily_query += " AND compound_date >= ?"
if end_date:
daily_query += " AND compound_date <= ?"
daily_query += " GROUP BY DATE(compound_date) ORDER BY sale_date"
cursor.execute(daily_query, params)
daily_sales = []
for row in cursor.fetchall():
daily_sales.append({
'date': row['sale_date'],
'count': row['count'],
'total': row['daily_total']
})
# 처방별 매출
formula_query = """
SELECT
f.formula_name,
COUNT(*) as count,
SUM(COALESCE(c.actual_payment_amount, c.sell_price_total)) as total
FROM compounds c
LEFT JOIN formulas f ON c.formula_id = f.formula_id
WHERE c.status IN ('PAID', 'PENDING_DELIVERY', 'DELIVERED', 'COMPLETED')
"""
if start_date:
formula_query += " AND c.compound_date >= ?"
if end_date:
formula_query += " AND c.compound_date <= ?"
formula_query += " GROUP BY f.formula_name ORDER BY total DESC LIMIT 10"
cursor.execute(formula_query, params)
formula_sales = []
for row in cursor.fetchall():
formula_sales.append({
'formula_name': row['formula_name'] or '직접조제',
'count': row['count'],
'total': row['total']
})
return jsonify({
'summary': {
'total_count': stats['total_count'],
'total_sales': stats['total_sales'],
'avg_price': stats['avg_price'],
'completed_count': stats['completed_count'],
'cancelled_count': stats['cancelled_count'],
'refunded_count': stats['refunded_count']
},
'daily_sales': daily_sales,
'formula_sales': formula_sales
})
except Exception as e:
return jsonify({'error': str(e)}), 500
@app.route('/api/compounds/<int:compound_id>/convert-to-otc', methods=['POST'])
def convert_to_otc(compound_id):
"""OTC 전환"""
try:
data = request.json
with get_db() as conn:
cursor = conn.cursor()
# 조제 정보 업데이트
cursor.execute("""
UPDATE compounds
SET status = 'OTC_CONVERTED',
notes = COALESCE(notes || ' | ', '') || 'OTC 전환: ' || ?,
updated_at = CURRENT_TIMESTAMP
WHERE compound_id = ?
""", (data.get('reason', ''), compound_id))
# 상태 변경 이력
cursor.execute("""
INSERT INTO sales_status_history (compound_id, old_status, new_status,
changed_by, change_reason)
SELECT compound_id, status, 'OTC_CONVERTED', ?, ?
FROM compounds WHERE compound_id = ?
""", (data.get('changed_by', 'system'), data.get('reason', ''), compound_id))
conn.commit()
return jsonify({'success': True, 'message': 'OTC로 전환되었습니다'})
except Exception as e:
return jsonify({'error': str(e)}), 500
if __name__ == '__main__': if __name__ == '__main__':
# 데이터베이스 초기화 # 데이터베이스 초기화
if not os.path.exists(app.config['DATABASE']): if not os.path.exists(app.config['DATABASE']):

102
check_compound_detail.py Normal file
View File

@ -0,0 +1,102 @@
#!/usr/bin/env python3
"""
조제 데이터 상세 확인 - formula_id가 NULL인 케이스 분석
"""
import sqlite3
def check_compound_details():
conn = sqlite3.connect('database/kdrug.db')
cursor = conn.cursor()
print("=" * 80)
print("조제 데이터 상세 분석")
print("=" * 80)
# formula_id가 NULL인 조제건 확인
print("\n1. Formula ID가 NULL인 조제건:")
print("-" * 80)
cursor.execute("""
SELECT compound_id, patient_id, formula_id, is_custom, custom_type,
custom_summary, compound_date, status
FROM compounds
WHERE formula_id IS NULL
ORDER BY compound_id DESC
""")
null_formulas = cursor.fetchall()
for comp in null_formulas:
print(f" ID: {comp[0]}")
print(f" 환자ID: {comp[1]}, is_custom: {comp[3]}, custom_type: {comp[4]}")
print(f" custom_summary: {comp[5]}")
print(f" 조제일: {comp[6]}, 상태: {comp[7]}")
print()
# 전체 조제 데이터 요약
print("\n2. 전체 조제 데이터 요약:")
print("-" * 80)
cursor.execute("""
SELECT
COUNT(*) as total,
COUNT(formula_id) as with_formula,
COUNT(*) - COUNT(formula_id) as without_formula,
SUM(CASE WHEN is_custom = 1 THEN 1 ELSE 0 END) as custom_count
FROM compounds
""")
summary = cursor.fetchone()
print(f" 총 조제건수: {summary[0]}")
print(f" 처방 있음: {summary[1]}")
print(f" 처방 없음(직접조제): {summary[2]}")
print(f" 커스텀 플래그: {summary[3]}")
# compound_items 테이블 확인
print("\n3. Compound_items 테이블 구조 및 데이터:")
print("-" * 80)
# 테이블 구조 확인
cursor.execute("PRAGMA table_info(compound_items)")
columns = cursor.fetchall()
if columns:
print(" 테이블 구조:")
for col in columns:
print(f" {col[1]:20s} {col[2]:15s}")
# 샘플 데이터
cursor.execute("""
SELECT * FROM compound_items
LIMIT 5
""")
items = cursor.fetchall()
if items:
print("\n 샘플 데이터:")
for item in items:
print(f" {item}")
else:
print(" compound_items 테이블이 비어있거나 없습니다.")
# 직접조제의 약재 구성 확인 (compound_items와 연결)
print("\n4. 직접조제(formula_id=NULL) 약재 구성:")
print("-" * 80)
for comp_id in [10, 8, 7]: # NULL인 compound_id들
cursor.execute("""
SELECT ci.*, h.herb_name
FROM compound_items ci
LEFT JOIN herb_items h ON ci.herb_item_id = h.herb_item_id
WHERE ci.compound_id = ?
LIMIT 5
""", (comp_id,))
items = cursor.fetchall()
if items:
print(f" Compound ID {comp_id}의 약재:")
for item in items:
print(f" {item}")
else:
print(f" Compound ID {comp_id}: 약재 데이터 없음")
conn.close()
if __name__ == "__main__":
check_compound_details()

View File

@ -0,0 +1,103 @@
#!/usr/bin/env python3
"""
compound_ingredients 테이블 확인 - 직접조제 데이터 분석
"""
import sqlite3
def analyze_compound_ingredients():
conn = sqlite3.connect('database/kdrug.db')
cursor = conn.cursor()
print("=" * 80)
print("Compound_ingredients 테이블 분석")
print("=" * 80)
# 테이블 구조
print("\n1. 테이블 구조:")
print("-" * 80)
cursor.execute("PRAGMA table_info(compound_ingredients)")
for col in cursor.fetchall():
print(f" {col[1]:25s} {col[2]:15s} {'NOT NULL' if col[3] else 'NULL':10s}")
# formula_id가 NULL인 compound의 약재 구성
print("\n2. Formula_id가 NULL인 조제건의 약재 구성:")
print("-" * 80)
cursor.execute("""
SELECT
c.compound_id,
c.formula_id,
c.is_custom,
c.custom_type,
COUNT(ci.compound_ingredient_id) as ingredient_count,
GROUP_CONCAT(h.herb_name || ':' || ci.grams_per_cheop || 'g', ', ') as ingredients
FROM compounds c
LEFT JOIN compound_ingredients ci ON c.compound_id = ci.compound_id
LEFT JOIN herb_items h ON ci.herb_item_id = h.herb_item_id
WHERE c.formula_id IS NULL
GROUP BY c.compound_id
ORDER BY c.compound_id DESC
""")
results = cursor.fetchall()
for row in results:
print(f"\n Compound ID: {row[0]}")
print(f" Formula ID: {row[1]}, is_custom: {row[2]}, custom_type: {row[3]}")
print(f" 약재 수: {row[4]}")
print(f" 구성: {row[5]}")
# 전체 통계
print("\n3. 전체 조제 통계:")
print("-" * 80)
cursor.execute("""
SELECT
c.formula_id IS NOT NULL as has_formula,
c.is_custom,
COUNT(DISTINCT c.compound_id) as compound_count,
COUNT(ci.compound_ingredient_id) as total_ingredients
FROM compounds c
LEFT JOIN compound_ingredients ci ON c.compound_id = ci.compound_id
GROUP BY has_formula, is_custom
""")
print(f" {'처방있음':10s} {'커스텀':8s} {'조제수':8s} {'총약재수':10s}")
print(" " + "-" * 40)
for row in cursor.fetchall():
has_formula = "" if row[0] else "아니오"
is_custom = "" if row[1] else "아니오"
print(f" {has_formula:10s} {is_custom:8s} {row[2]:8d} {row[3]:10d}")
# 특정 조제건 상세
print("\n4. Compound ID 10번 상세 (formula_id=NULL):")
print("-" * 80)
cursor.execute("""
SELECT
ci.herb_item_id,
h.herb_name,
ci.grams_per_cheop,
c.cheop_total,
ci.total_grams,
ci.notes
FROM compound_ingredients ci
LEFT JOIN herb_items h ON ci.herb_item_id = h.herb_item_id
LEFT JOIN compounds c ON ci.compound_id = c.compound_id
WHERE ci.compound_id = 10
ORDER BY ci.compound_ingredient_id
""")
ingredients = cursor.fetchall()
if ingredients:
print(f" {'약재코드':15s} {'약재명':15s} {'1첩용량':10s} {'첩수':8s} {'총용량':10s}")
print(" " + "-" * 60)
for ing in ingredients:
print(f" {ing[0]:15s} {ing[1] or 'Unknown':15s} {ing[2]:10.1f}g {ing[3]:8.0f} {ing[4]:10.1f}g")
else:
print(" 약재 데이터 없음")
conn.close()
if __name__ == "__main__":
analyze_compound_ingredients()

65
check_compound_schema.py Normal file
View File

@ -0,0 +1,65 @@
#!/usr/bin/env python3
"""
조제(compound) 관련 테이블 스키마 확인
"""
import sqlite3
def check_compound_tables():
conn = sqlite3.connect('database/kdrug.db')
cursor = conn.cursor()
# compounds 테이블 구조 확인
print("=" * 80)
print("1. COMPOUNDS 테이블 구조:")
print("=" * 80)
cursor.execute("PRAGMA table_info(compounds)")
columns = cursor.fetchall()
for col in columns:
print(f" {col[1]:25s} {col[2]:15s} {'NOT NULL' if col[3] else 'NULL':10s} {col[4] or ''}")
# compound_items 테이블 구조 확인
print("\n" + "=" * 80)
print("2. COMPOUND_ITEMS 테이블 구조:")
print("=" * 80)
cursor.execute("PRAGMA table_info(compound_items)")
columns = cursor.fetchall()
for col in columns:
print(f" {col[1]:25s} {col[2]:15s} {'NOT NULL' if col[3] else 'NULL':10s} {col[4] or ''}")
# 관련 테이블 확인
print("\n" + "=" * 80)
print("3. 관련 테이블 목록:")
print("=" * 80)
cursor.execute("""
SELECT name FROM sqlite_master
WHERE type='table' AND (name LIKE '%sale%' OR name LIKE '%payment%' OR name LIKE '%invoice%')
ORDER BY name
""")
related_tables = cursor.fetchall()
if related_tables:
for table in related_tables:
print(f" - {table[0]}")
else:
print(" 판매/결제 관련 테이블 없음")
# 샘플 데이터 확인
print("\n" + "=" * 80)
print("4. 최근 조제 데이터 샘플:")
print("=" * 80)
cursor.execute("""
SELECT c.compound_id, p.name, f.formula_name, c.compound_date, c.created_at, c.status
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 5
""")
compounds = cursor.fetchall()
for comp in compounds:
print(f" ID:{comp[0]} | 환자:{comp[1]} | 처방:{comp[2]} | 조제일:{comp[3]} | 상태:{comp[5]}")
conn.close()
if __name__ == "__main__":
check_compound_tables()

42
config.py Normal file
View File

@ -0,0 +1,42 @@
#!/usr/bin/env python3
"""
kdrug 프로젝트 공통 설정 파일
모든 Python 스크립트에서 설정을 import하여 사용
"""
import os
from pathlib import Path
# 프로젝트 루트 디렉토리
PROJECT_ROOT = Path(__file__).parent
# 데이터베이스 경로 - 항상 절대 경로 사용
DATABASE_PATH = PROJECT_ROOT / 'database' / 'kdrug.db'
# 기타 자주 사용하는 경로들
STATIC_PATH = PROJECT_ROOT / 'static'
TEMPLATES_PATH = PROJECT_ROOT / 'templates'
DOCS_PATH = PROJECT_ROOT / 'docs'
BACKUP_PATH = PROJECT_ROOT / 'backups'
# 데이터베이스 연결 헬퍼 함수
def get_db_connection():
"""표준 데이터베이스 연결 반환"""
import sqlite3
conn = sqlite3.connect(str(DATABASE_PATH))
conn.row_factory = sqlite3.Row # 컬럼명으로 접근 가능하도록 설정
return conn
# 설정 확인용 (디버그)
if __name__ == "__main__":
print(f"프로젝트 루트: {PROJECT_ROOT}")
print(f"데이터베이스 경로: {DATABASE_PATH}")
print(f"데이터베이스 존재: {DATABASE_PATH.exists()}")
if DATABASE_PATH.exists():
conn = get_db_connection()
cursor = conn.cursor()
cursor.execute("SELECT COUNT(*) FROM sqlite_master WHERE type='table'")
table_count = cursor.fetchone()[0]
print(f"테이블 개수: {table_count}")
conn.close()

94
create_sales_tables.py Normal file
View File

@ -0,0 +1,94 @@
#!/usr/bin/env python3
"""
판매 관리 시스템 - 신규 테이블 생성
"""
import sqlite3
from datetime import datetime
def create_sales_tables():
"""판매 관련 신규 테이블 생성"""
conn = sqlite3.connect('database/kdrug.db')
cursor = conn.cursor()
# 1. sales_transactions 테이블 생성
cursor.execute("""
CREATE TABLE IF NOT EXISTS sales_transactions (
transaction_id INTEGER PRIMARY KEY AUTOINCREMENT,
compound_id INTEGER REFERENCES compounds(compound_id),
transaction_date DATETIME NOT NULL,
transaction_type TEXT NOT NULL, -- SALE, REFUND, CANCEL
amount REAL NOT NULL,
payment_method TEXT,
payment_status TEXT, -- PENDING, COMPLETED, FAILED
notes TEXT,
created_by TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
""")
print("✓ sales_transactions 테이블 생성 완료")
# 2. price_policies 테이블 생성
cursor.execute("""
CREATE TABLE IF NOT EXISTS price_policies (
policy_id INTEGER PRIMARY KEY AUTOINCREMENT,
formula_id INTEGER REFERENCES formulas(formula_id),
base_price REAL NOT NULL,
dispensing_fee REAL DEFAULT 0,
is_active BOOLEAN DEFAULT 1,
effective_date DATE,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
""")
print("✓ price_policies 테이블 생성 완료")
# 3. sales_status_history 테이블 생성
cursor.execute("""
CREATE TABLE IF NOT EXISTS sales_status_history (
history_id INTEGER PRIMARY KEY AUTOINCREMENT,
compound_id INTEGER REFERENCES compounds(compound_id),
old_status TEXT,
new_status TEXT NOT NULL,
changed_by TEXT,
change_reason TEXT,
changed_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
""")
print("✓ sales_status_history 테이블 생성 완료")
# 인덱스 생성
cursor.execute("""
CREATE INDEX IF NOT EXISTS idx_sales_transactions_compound
ON sales_transactions(compound_id)
""")
cursor.execute("""
CREATE INDEX IF NOT EXISTS idx_price_policies_formula
ON price_policies(formula_id)
""")
cursor.execute("""
CREATE INDEX IF NOT EXISTS idx_sales_status_history_compound
ON sales_status_history(compound_id)
""")
print("✓ 인덱스 생성 완료")
conn.commit()
# 생성된 테이블 확인
cursor.execute("""
SELECT name FROM sqlite_master
WHERE type='table'
AND name IN ('sales_transactions', 'price_policies', 'sales_status_history')
""")
tables = cursor.fetchall()
print("\n생성된 테이블:")
for table in tables:
print(f" - {table[0]}")
conn.close()
print("\n판매 관련 테이블 생성 완료!")
if __name__ == "__main__":
create_sales_tables()

View File

@ -0,0 +1,317 @@
# 직접조제(Custom Compound) 고도화 기획 문서
## 1. 현황 분석
### 1.1 현재 직접조제 데이터 구조
#### 실제 데이터 사례
```
Compound ID 10: 휴먼건강 3.0g (단품)
Compound ID 8: 휴먼건강 2.0g (단품)
Compound ID 7: 휴먼일당귀 100.0g (단품)
Compound ID 6: 휴먼일당귀 100.0g (단품)
```
#### 현재 저장 방식
- formula_id: NULL
- is_custom: 0 (잘못된 설정)
- custom_type: 'standard' (잘못된 설정)
- custom_summary: NULL
- 약재 정보: compound_ingredients에 단일 약재로 저장
### 1.2 문제점
1. **분류 체계 미비**
- 직접조제임에도 is_custom=0으로 저장
- custom_type이 'standard'로 잘못 설정
- 표준처방과 직접조제 구분 불명확
2. **데이터 무결성**
- formula_id NULL이지만 custom 플래그 미설정
- 직접조제 사유/목적 미기록
- 처방명 없음 (custom_summary 미사용)
3. **관리 기능 부족**
- 직접조제 이력 추적 어려움
- 가격 책정 기준 불명확
- 재조제시 참조 데이터 부재
## 2. 직접조제 유형 분류
### 2.1 단품 판매 (Single Item)
- **특징**: 단일 약재 판매
- **사례**: 녹용 100g, 홍삼 50g
- **용도**: 환자 요청에 의한 약재 구매
### 2.2 맞춤 조제 (Custom Formula)
- **특징**: 여러 약재 조합하여 맞춤 처방
- **사례**: 기존 처방 가감방, 한약사 임의 조제
- **용도**: 환자 체질/증상에 맞춘 개별화 처방
### 2.3 OTC 조제 (Over The Counter)
- **특징**: 처방전 없이 판매 가능한 제품
- **사례**: 쌍화탕 파우치, 공진단
- **용도**: 일반 판매용 제품
### 2.4 테스트/샘플 (Test/Sample)
- **특징**: 시음용, 테스트용 소량 조제
- **사례**: 처방 샘플 1일분
- **용도**: 환자 시음, 품질 테스트
## 3. 개선 방안
### 3.1 데이터 구조 개선
#### 3.1.1 compounds 테이블 수정
```sql
-- 직접조제 분류 강화
ALTER TABLE compounds ADD COLUMN custom_category TEXT;
-- 'SINGLE_ITEM', 'CUSTOM_FORMULA', 'OTC', 'SAMPLE'
ALTER TABLE compounds ADD COLUMN custom_name TEXT;
-- 직접조제명 (예: '감기 맞춤처방', '홍삼 단품')
ALTER TABLE compounds ADD COLUMN custom_purpose TEXT;
-- 조제 목적/사유
ALTER TABLE compounds ADD COLUMN reference_compound_id INTEGER;
-- 재조제시 참조할 이전 조제 ID
```
#### 3.1.2 custom_formulas 테이블 신규
```sql
CREATE TABLE custom_formulas (
custom_formula_id INTEGER PRIMARY KEY AUTOINCREMENT,
compound_id INTEGER REFERENCES compounds(compound_id),
formula_name TEXT NOT NULL,
formula_description TEXT,
base_formula_id INTEGER REFERENCES formulas(formula_id),
-- 기본이 된 표준처방 (가감방인 경우)
modification_summary TEXT,
-- 가감 내용 요약
symptoms TEXT,
-- 대상 증상
contraindications TEXT,
-- 금기사항
created_by TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
is_reusable BOOLEAN DEFAULT 0,
-- 재사용 가능 여부 (다른 환자에게도 적용 가능)
reuse_count INTEGER DEFAULT 0
-- 재사용 횟수
);
```
### 3.2 비즈니스 로직 개선
#### 3.2.1 직접조제 생성 프로세스
```
1. 조제 유형 선택
└─ 단품/맞춤/OTC/샘플
2. 유형별 정보 입력
├─ 단품: 약재, 용량, 판매사유
├─ 맞춤: 처방명, 약재구성, 증상, 기반처방
├─ OTC: 제품명, 수량
└─ 샘플: 목적, 대상처방, 용량
3. 가격 책정
├─ 자동계산: 약재원가 + 조제료
└─ 수동입력: 특별가 적용
4. 데이터 저장
├─ compounds: is_custom=1, custom_category 설정
├─ compound_ingredients: 약재 구성
└─ custom_formulas: 맞춤처방 상세정보
```
#### 3.2.2 직접조제 관리 기능
1. **템플릿 관리**
- 자주 사용하는 직접조제 템플릿 저장
- 템플릿에서 빠른 조제 생성
2. **이력 관리**
- 환자별 직접조제 이력 조회
- 동일 처방 재조제 기능
3. **가격 정책**
- 직접조제 유형별 조제료 설정
- 단품 판매 마진율 관리
### 3.3 UI/UX 개선
#### 3.3.1 직접조제 입력 화면
```
┌─────────────── 직접조제 등록 ──────────────┐
│ │
│ 조제 유형: [단품 판매 ▼] │
│ │
│ ─────── 기본 정보 ───────── │
│ 조제명: [________________] │
│ 환자: [환자선택 ▼] 또는 [비회원] │
│ │
│ ─────── 약재 구성 ───────── │
│ [+ 약재 추가] │
│ ┌────────┬──────┬──────┬────┐ │
│ │약재명 │용량 │단가 │삭제 │ │
│ ├────────┼──────┼──────┼────┤ │
│ │홍삼 │100g │500원/g│ X │ │
│ └────────┴──────┴──────┴────┘ │
│ │
│ ─────── 가격 정보 ───────── │
│ 약재비: 50,000원 │
│ 조제료: [10,000원] │
│ 판매가: [60,000원] │
│ │
│ 조제 목적: [________________] │
│ 비고: [____________________] │
│ │
│ [템플릿 저장] [취소] [조제 등록] │
└────────────────────────────────────────────┘
```
#### 3.3.2 직접조제 목록 화면
```
[직접조제 관리]
필터: [전체 ▼] [2024-02-01] ~ [2024-02-29] [검색]
┌────┬──────┬────────┬──────┬──────┬────────┬──────┐
│번호│유형 │조제명 │환자 │약재수 │판매가 │작업 │
├────┼──────┼────────┼──────┼──────┼────────┼──────┤
│ 1 │단품 │홍삼100g │홍길동 │ 1 │ 60,000 │[상세] │
│ 2 │맞춤 │감기처방 │김철수 │ 8 │120,000 │[재조제]│
│ 3 │OTC │공진단3환 │비회원 │ 3 │ 90,000 │[상세] │
│ 4 │샘플 │시음1일분 │이영희 │ 5 │ 0 │[상세] │
└────┴──────┴────────┴──────┴──────┴────────┴──────┘
```
### 3.4 데이터 마이그레이션
#### 3.4.1 기존 데이터 정리
```sql
-- formula_id가 NULL인 조제 데이터 업데이트
UPDATE compounds
SET is_custom = 1,
custom_type = 'custom',
custom_category = 'SINGLE_ITEM',
custom_name = (
SELECT h.herb_name || ' ' || ci.total_grams || 'g'
FROM compound_ingredients ci
JOIN herb_items h ON ci.herb_item_id = h.herb_item_id
WHERE ci.compound_id = compounds.compound_id
LIMIT 1
)
WHERE formula_id IS NULL;
```
#### 3.4.2 데이터 검증
- 모든 직접조제 데이터의 무결성 확인
- is_custom 플래그와 formula_id NULL 일치 여부 검증
- custom_category 분류 정확성 확인
## 4. API 설계
### 4.1 직접조제 API
```python
# 직접조제 생성
POST /api/compounds/custom
{
"patient_id": 1, # nullable for OTC
"custom_category": "SINGLE_ITEM",
"custom_name": "홍삼 100g",
"custom_purpose": "면역력 증진",
"ingredients": [
{
"herb_item_id": 123,
"grams": 100,
"unit_price": 500
}
],
"dispensing_fee": 10000,
"sell_price": 60000
}
# 직접조제 템플릿 저장
POST /api/custom-templates
{
"template_name": "감기 기본방",
"category": "CUSTOM_FORMULA",
"ingredients": [...],
"default_price": 80000
}
# 직접조제 이력 조회
GET /api/compounds/custom?patient_id=1&category=SINGLE_ITEM
# 재조제
POST /api/compounds/{compound_id}/reorder
{
"patient_id": 1,
"quantity_multiplier": 1.5 # 1.5배 용량
}
```
## 5. 구현 로드맵
### Phase 1: 기반 구축 (1주)
- [ ] DB 스키마 수정 (custom_category, custom_name 등 추가)
- [ ] 기존 데이터 마이그레이션
- [ ] 직접조제 분류 체계 구현
### Phase 2: 핵심 기능 (2주)
- [ ] 직접조제 생성 UI/API
- [ ] 유형별 입력 폼 구현
- [ ] 가격 자동계산 로직
### Phase 3: 관리 기능 (1주)
- [ ] 직접조제 목록/검색
- [ ] 템플릿 관리
- [ ] 재조제 기능
### Phase 4: 고도화 (2주)
- [ ] 통계 및 리포트
- [ ] 환자별 구매 패턴 분석
- [ ] 인기 직접조제 랭킹
## 6. 주의사항
### 6.1 규제 준수
- 의약품 판매 관련 법규 확인
- 처방전 필요 여부 명확히 구분
- OTC 판매 가능 품목 관리
### 6.2 데이터 정합성
- formula_id NULL과 is_custom=1 일치 유지
- 직접조제는 반드시 custom_category 설정
- 가격 정보 필수 입력
### 6.3 사용자 교육
- 직접조제 유형별 사용 가이드
- 가격 책정 기준 안내
- 템플릿 활용 방법
## 7. 기대 효과
1. **체계적 관리**
- 직접조제 데이터 일관성 확보
- 판매 이력 추적 가능
2. **업무 효율성**
- 템플릿으로 빠른 조제
- 재조제 간소화
3. **매출 증대**
- 단품 판매 활성화
- 맞춤 처방 서비스 확대
4. **고객 만족**
- 개인 맞춤 서비스
- 투명한 가격 정책

View File

@ -0,0 +1,298 @@
# 한약 판매 관리 시스템 기획 문서
## 1. 개요
### 1.1 배경
- 한약사가 조제한 한약은 재고 차감과 별개로 판매 프로세스를 거쳐야 함
- 조제 완료 후 판매 상태 관리 및 가격 책정이 필요
- 환자 관리 및 마케팅을 위한 카카오 채널 연동 계획
### 1.2 목적
- 조제된 한약의 판매 상태 관리
- 유연한 가격 정책 적용
- 판매 이력 추적 및 매출 관리
- 환자 정보 연계 강화
## 2. 현재 시스템 분석
### 2.1 기존 구조
```
compounds 테이블:
- compound_id: 조제 고유번호
- status: 현재 'PREPARED'로 고정
- sell_price_total: 판매가격 필드 존재하나 미활용
- cost_total: 원가 필드 존재
```
### 2.2 현재 문제점
- 조제 후 판매 상태 추적 불가
- 가격 정책 유연성 부족
- 판매 완료/취소/반품 등 프로세스 미비
- 매출 통계 기능 없음
## 3. 기능 요구사항
### 3.1 판매 상태 관리
#### 3.1.1 상태 유형
```
PREPARED (조제완료) → 현재 기본값
PENDING_PAYMENT (결제대기)
PAID (결제완료)
PENDING_DELIVERY (배송대기)
DELIVERED (배송완료)
COMPLETED (판매완료)
OTC_CONVERTED (OTC전환)
CANCELLED (취소)
REFUNDED (환불)
```
#### 3.1.2 상태 전이 규칙
```
조제완료 → 결제대기 → 결제완료 → 배송대기 → 배송완료 → 판매완료
↓ ↓
OTC전환 취소/환불
```
### 3.2 가격 관리
#### 3.2.1 가격 정책
1. **기본가격 산정**
- 표준처방: 처방별 기본가격 테이블 참조
- 직접조제: 약재별 단가 × 용량 × 첩수 자동 계산
2. **가격 조정**
- 가감방 적용시 자동 재계산
- 수동 가격 조정 가능
- 할인율 적용 기능
3. **가격 구성**
```
약재원가 + 조제료 + 배송비 = 기본가격
기본가격 × (1 - 할인율) = 최종판매가
```
### 3.3 판매 처리 기능
#### 3.3.1 판매 정보 입력
- 결제 방법 (현금/카드/계좌이체/카카오페이)
- 결제 일시
- 실제 판매가격
- 할인 사유 및 금액
- 배송 정보 (택배/직접수령/퀵서비스)
- 영수증 발행 여부
#### 3.3.2 OTC 전환
- 처방전 없이 판매 가능한 경우
- 환자 정보 없이도 판매 처리
- 별도 재고 관리 필요
## 4. 데이터베이스 설계
### 4.1 기존 테이블 수정
#### compounds 테이블 수정사항
```sql
ALTER TABLE compounds ADD COLUMN payment_method TEXT;
ALTER TABLE compounds ADD COLUMN payment_date DATETIME;
ALTER TABLE compounds ADD COLUMN discount_rate REAL DEFAULT 0;
ALTER TABLE compounds ADD COLUMN discount_reason TEXT;
ALTER TABLE compounds ADD COLUMN delivery_method TEXT;
ALTER TABLE compounds ADD COLUMN delivery_date DATETIME;
ALTER TABLE compounds ADD COLUMN invoice_number TEXT;
```
### 4.2 신규 테이블
#### sales_transactions (판매 거래)
```sql
CREATE TABLE sales_transactions (
transaction_id INTEGER PRIMARY KEY AUTOINCREMENT,
compound_id INTEGER REFERENCES compounds(compound_id),
transaction_date DATETIME NOT NULL,
transaction_type TEXT NOT NULL, -- SALE, REFUND, CANCEL
amount REAL NOT NULL,
payment_method TEXT,
payment_status TEXT, -- PENDING, COMPLETED, FAILED
notes TEXT,
created_by TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
```
#### price_policies (가격 정책)
```sql
CREATE TABLE price_policies (
policy_id INTEGER PRIMARY KEY AUTOINCREMENT,
formula_id INTEGER REFERENCES formulas(formula_id),
base_price REAL NOT NULL,
dispensing_fee REAL DEFAULT 0,
is_active BOOLEAN DEFAULT 1,
effective_date DATE,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
```
#### sales_status_history (판매 상태 이력)
```sql
CREATE TABLE sales_status_history (
history_id INTEGER PRIMARY KEY AUTOINCREMENT,
compound_id INTEGER REFERENCES compounds(compound_id),
old_status TEXT,
new_status TEXT NOT NULL,
changed_by TEXT,
change_reason TEXT,
changed_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
```
## 5. 사용자 인터페이스
### 5.1 판매 관리 화면
#### 5.1.1 조제 목록 화면 개선
```
[조제 목록]
┌────┬──────┬──────┬────────┬────────┬────────┬──────────┐
│번호│환자명│처방명│조제일자│ 상태 │판매가격│ 작업 │
├────┼──────┼──────┼────────┼────────┼────────┼──────────┤
│ 1 │홍길동│갈근탕│02-18 │조제완료│ 80,000 │[판매처리]│
│ 2 │김철수│쌍화탕│02-18 │결제대기│ 60,000 │[결제확인]│
│ 3 │이영희│십전대│02-17 │배송대기│120,000 │[배송처리]│
└────┴──────┴──────┴────────┴────────┴────────┴──────────┘
```
#### 5.1.2 판매 처리 모달
```
┌─────────────── 판매 처리 ──────────────┐
│ │
│ 처방명: 갈근탕 │
│ 환자명: 홍길동 │
│ 조제일: 2026-02-18 │
│ │
│ ─────── 가격 정보 ───────── │
│ 약재원가: 45,000원 │
│ 조제료: 20,000원 │
│ 기본가격: 65,000원 │
│ │
│ 할인율: [10%▼] │
│ 할인사유: [_______________] │
│ 최종가격: 58,500원 │
│ │
│ ─────── 결제 정보 ───────── │
│ 결제방법: [카드▼] │
│ 결제일시: [2026-02-18 14:30] │
│ │
│ ─────── 배송 정보 ───────── │
│ 배송방법: [택배▼] │
│ 배송예정: [2026-02-19] │
│ │
│ [취소] [판매확정] │
└─────────────────────────────────────────┘
```
### 5.2 매출 통계 화면
```
[매출 현황]
기간: [2026-02-01] ~ [2026-02-29]
┌─────────────────────────────────┐
│ 총 매출: 3,540,000원 │
│ 총 건수: 42건 │
│ 평균 단가: 84,286원 │
└─────────────────────────────────┘
[일별 매출 추이 그래프]
[처방별 매출 비중 차트]
[결제 방법별 통계]
```
## 6. API 설계
### 6.1 판매 관련 API
```python
# 판매 상태 업데이트
POST /api/compounds/{compound_id}/status
{
"status": "PAID",
"payment_method": "CARD",
"payment_date": "2026-02-18T14:30:00",
"amount": 58500
}
# 가격 조정
PUT /api/compounds/{compound_id}/price
{
"sell_price_total": 58500,
"discount_rate": 10,
"discount_reason": "단골 할인"
}
# 판매 통계 조회
GET /api/sales/statistics?start_date=2026-02-01&end_date=2026-02-29
# OTC 전환
POST /api/compounds/{compound_id}/convert-to-otc
{
"reason": "처방전 미제출",
"notes": "환자 요청"
}
```
## 7. 구현 우선순위
### Phase 1 (1주차)
1. compounds 테이블 칼럼 추가
2. 판매 상태 변경 기능
3. 기본 가격 입력/수정 기능
4. 판매 처리 UI 구현
### Phase 2 (2주차)
1. sales_transactions 테이블 생성
2. 판매 이력 관리 기능
3. 매출 통계 API
4. 통계 화면 구현
### Phase 3 (3주차)
1. 가격 정책 테이블 구현
2. 자동 가격 계산 로직
3. OTC 전환 기능
4. 영수증 발행 기능
## 8. 추후 확장 계획
### 8.1 카카오 채널 연동
- QR 코드 생성 및 출력
- 카카오 회원가입 유도
- 알림톡 발송 (조제완료, 배송안내)
### 8.2 고객 관리
- 구매 이력 관리
- 재구매 주기 분석
- 맞춤 처방 추천
### 8.3 재고 연계
- OTC 전환시 별도 재고 관리
- 유통기한 관리
- 재고 부족 알림
## 9. 기대 효과
1. **업무 효율성 향상**
- 조제부터 판매까지 일원화된 관리
- 자동 가격 계산으로 실수 방지
2. **매출 관리 개선**
- 실시간 매출 현황 파악
- 처방별/기간별 분석 가능
3. **고객 서비스 향상**
- 체계적인 배송 관리
- 투명한 가격 정책
4. **의사결정 지원**
- 데이터 기반 가격 정책 수립
- 인기 처방 파악 및 재고 관리 최적화

View File

@ -1267,14 +1267,32 @@ $(document).ready(function() {
let statusBadge = ''; let statusBadge = '';
switch(compound.status) { switch(compound.status) {
case 'PREPARED': case 'PREPARED':
statusBadge = '<span class="badge bg-success">조제완료</span>'; statusBadge = '<span class="badge bg-primary">조제완료</span>';
break; break;
case 'DISPENSED': case 'PENDING_PAYMENT':
statusBadge = '<span class="badge bg-primary">출고완료</span>'; statusBadge = '<span class="badge bg-warning">결제대기</span>';
break;
case 'PAID':
statusBadge = '<span class="badge bg-success">결제완료</span>';
break;
case 'PENDING_DELIVERY':
statusBadge = '<span class="badge bg-info">배송대기</span>';
break;
case 'DELIVERED':
statusBadge = '<span class="badge bg-secondary">배송완료</span>';
break;
case 'COMPLETED':
statusBadge = '<span class="badge bg-dark">판매완료</span>';
break;
case 'OTC_CONVERTED':
statusBadge = '<span class="badge bg-purple">OTC전환</span>';
break; break;
case 'CANCELLED': case 'CANCELLED':
statusBadge = '<span class="badge bg-danger">취소</span>'; statusBadge = '<span class="badge bg-danger">취소</span>';
break; break;
case 'REFUNDED':
statusBadge = '<span class="badge bg-danger">환불</span>';
break;
default: default:
statusBadge = '<span class="badge bg-secondary">대기</span>'; statusBadge = '<span class="badge bg-secondary">대기</span>';
} }
@ -1297,6 +1315,20 @@ $(document).ready(function() {
<button class="btn btn-sm btn-outline-info view-compound-detail" data-id="${compound.compound_id}"> <button class="btn btn-sm btn-outline-info view-compound-detail" data-id="${compound.compound_id}">
<i class="bi bi-eye"></i> <i class="bi bi-eye"></i>
</button> </button>
${compound.status === 'PREPARED' ? `
<button class="btn btn-sm btn-outline-success process-sale" data-id="${compound.compound_id}"
data-formula="${compound.formula_name || '직접조제'}"
data-patient="${compound.patient_name || '직접조제'}"
data-cost="${compound.cost_total || 0}"
data-price="${compound.sell_price_total || 0}">
<i class="bi bi-cash-coin"></i>
</button>
` : ''}
${compound.status === 'PAID' ? `
<button class="btn btn-sm btn-outline-primary process-delivery" data-id="${compound.compound_id}">
<i class="bi bi-truck"></i>
</button>
` : ''}
</td> </td>
</tr> </tr>
`); `);
@ -1312,6 +1344,23 @@ $(document).ready(function() {
const compoundId = $(this).data('id'); const compoundId = $(this).data('id');
viewCompoundDetail(compoundId); viewCompoundDetail(compoundId);
}); });
// 판매 처리 버튼 이벤트
$('.process-sale').on('click', function() {
const compoundId = $(this).data('id');
const formulaName = $(this).data('formula');
const patientName = $(this).data('patient');
const costTotal = $(this).data('cost');
const priceTotal = $(this).data('price');
openSalesModal(compoundId, formulaName, patientName, costTotal, priceTotal);
});
// 배송 처리 버튼 이벤트
$('.process-delivery').on('click', function() {
const compoundId = $(this).data('id');
processDelivery(compoundId);
});
} else { } else {
tbody.html('<tr><td colspan="13" class="text-center text-muted">조제 내역이 없습니다.</td></tr>'); tbody.html('<tr><td colspan="13" class="text-center text-muted">조제 내역이 없습니다.</td></tr>');
$('#todayCompoundCount').text(0); $('#todayCompoundCount').text(0);
@ -3439,4 +3488,267 @@ $(document).ready(function() {
alert('약재 정보 수정 기능은 준비 중입니다.'); alert('약재 정보 수정 기능은 준비 중입니다.');
// TODO: 정보 수정 폼 구현 // TODO: 정보 수정 폼 구현
} }
// ==================== 판매 관리 기능 ====================
// 판매 모달 열기
function openSalesModal(compoundId, formulaName, patientName, costTotal, priceTotal) {
$('#salesCompoundId').val(compoundId);
$('#salesFormulaName').val(formulaName);
$('#salesPatientName').val(patientName);
$('#salesCostTotal').val(costTotal);
// 환자 마일리지 조회
loadPatientMileage(patientName);
// 기본 가격 계산 (원가 + 조제료)
const dispensingFee = parseFloat($('#salesDispensingFee').val()) || 20000;
const basePrice = costTotal + dispensingFee;
$('#salesBasePrice').val(basePrice);
// 판매가가 없으면 기본가격으로 설정
if (!priceTotal || priceTotal === 0) {
priceTotal = basePrice;
}
// 초기화
$('#salesDiscountValue').val(0);
$('#salesPriceAfterDiscount').val(priceTotal);
$('#salesMileageUse').val(0);
$('#salesPaymentCash').val(0);
$('#salesPaymentCard').val(0);
$('#salesPaymentTransfer').val(0);
updatePaymentSummary();
// 현재 시간 설정
const now = new Date();
const localDateTime = new Date(now.getTime() - now.getTimezoneOffset() * 60000).toISOString().slice(0, 16);
$('#salesPaymentDate').val(localDateTime);
// 내일 날짜 설정 (배송예정일)
const tomorrow = new Date(now);
tomorrow.setDate(tomorrow.getDate() + 1);
$('#salesDeliveryDate').val(tomorrow.toISOString().slice(0, 10));
$('#salesModal').modal('show');
// 가격 자동 계산 이벤트
calculateSalesPrice();
}
// 환자 마일리지 조회
function loadPatientMileage(patientName) {
if (patientName && patientName !== '직접조제') {
$.get('/api/patients/search', { name: patientName }, function(response) {
if (response.success && response.data.length > 0) {
const patient = response.data[0];
const mileage = patient.mileage_balance || 0;
$('#patientMileageBalance').text(mileage.toLocaleString());
$('#salesMileageUse').attr('max', mileage);
} else {
$('#patientMileageBalance').text('0');
$('#salesMileageUse').attr('max', 0);
}
});
} else {
$('#patientMileageBalance').text('0');
$('#salesMileageUse').attr('max', 0);
}
}
// 가격 자동 계산
function calculateSalesPrice() {
const costTotal = parseFloat($('#salesCostTotal').val()) || 0;
const dispensingFee = parseFloat($('#salesDispensingFee').val()) || 0;
const basePrice = costTotal + dispensingFee;
$('#salesBasePrice').val(basePrice);
const discountType = $('input[name="discountType"]:checked').val();
const discountValue = parseFloat($('#salesDiscountValue').val()) || 0;
let discountAmount = 0;
if (discountType === 'rate') {
discountAmount = basePrice * (discountValue / 100);
} else {
discountAmount = discountValue;
}
const priceAfterDiscount = Math.max(0, basePrice - discountAmount);
$('#salesPriceAfterDiscount').val(Math.round(priceAfterDiscount));
updatePaymentSummary();
}
// 결제 요약 업데이트
function updatePaymentSummary() {
const priceAfterDiscount = parseFloat($('#salesPriceAfterDiscount').val()) || 0;
const mileageUse = parseFloat($('#salesMileageUse').val()) || 0;
const cashPayment = parseFloat($('#salesPaymentCash').val()) || 0;
const cardPayment = parseFloat($('#salesPaymentCard').val()) || 0;
const transferPayment = parseFloat($('#salesPaymentTransfer').val()) || 0;
const totalPayment = mileageUse + cashPayment + cardPayment + transferPayment;
const needToPay = Math.max(0, priceAfterDiscount - totalPayment);
$('#salesNeedToPay').text(needToPay.toLocaleString());
$('#salesTotalPayment').text(totalPayment.toLocaleString());
// 결제 방법 자동 설정
if (totalPayment > 0) {
let methodCount = 0;
if (mileageUse > 0) methodCount++;
if (cashPayment > 0) methodCount++;
if (cardPayment > 0) methodCount++;
if (transferPayment > 0) methodCount++;
if (methodCount > 1) {
$('#salesPaymentMethod').val('COMPLEX');
} else if (cashPayment > 0) {
$('#salesPaymentMethod').val('CASH');
} else if (cardPayment > 0) {
$('#salesPaymentMethod').val('CARD');
} else if (transferPayment > 0) {
$('#salesPaymentMethod').val('TRANSFER');
}
}
}
// 할인 방식 변경 이벤트
$('input[name="discountType"]').on('change', function() {
const discountType = $(this).val();
if (discountType === 'rate') {
$('#discountUnit').text('%');
$('#salesDiscountValue').attr('max', 100);
} else {
$('#discountUnit').text('원');
$('#salesDiscountValue').removeAttr('max');
}
calculateSalesPrice();
});
// 가격 계산 이벤트 핸들러
$('#salesDispensingFee, #salesDiscountValue').on('input', calculateSalesPrice);
$('#salesMileageUse, #salesPaymentCash, #salesPaymentCard, #salesPaymentTransfer').on('input', updatePaymentSummary);
// 마일리지 전액 사용 버튼
$('#applyMaxMileage').on('click', function() {
const maxMileage = parseFloat($('#salesMileageUse').attr('max')) || 0;
const priceAfterDiscount = parseFloat($('#salesPriceAfterDiscount').val()) || 0;
const useAmount = Math.min(maxMileage, priceAfterDiscount);
$('#salesMileageUse').val(useAmount);
updatePaymentSummary();
});
// 판매 저장
$('#saveSalesBtn').on('click', function() {
const compoundId = $('#salesCompoundId').val();
const priceAfterDiscount = parseFloat($('#salesPriceAfterDiscount').val()) || 0;
const discountType = $('input[name="discountType"]:checked').val();
const discountValue = parseFloat($('#salesDiscountValue').val()) || 0;
const discountReason = $('#salesDiscountReason').val();
// 복합 결제 정보
const mileageUse = parseFloat($('#salesMileageUse').val()) || 0;
const cashPayment = parseFloat($('#salesPaymentCash').val()) || 0;
const cardPayment = parseFloat($('#salesPaymentCard').val()) || 0;
const transferPayment = parseFloat($('#salesPaymentTransfer').val()) || 0;
const totalPayment = mileageUse + cashPayment + cardPayment + transferPayment;
// 결제 금액 검증
if (Math.abs(totalPayment - priceAfterDiscount) > 1) {
alert(`결제 금액이 일치하지 않습니다.\n필요금액: ${priceAfterDiscount.toLocaleString()}\n결제금액: ${totalPayment.toLocaleString()}`);
return;
}
const paymentMethod = $('#salesPaymentMethod').val();
const paymentDate = $('#salesPaymentDate').val();
const deliveryMethod = $('#salesDeliveryMethod').val();
const deliveryDate = $('#salesDeliveryDate').val();
if (!paymentMethod) {
alert('결제방법을 선택해주세요.');
return;
}
// 할인액 계산
const basePrice = parseFloat($('#salesBasePrice').val()) || 0;
let discountAmount = 0;
if (discountType === 'rate') {
discountAmount = basePrice * (discountValue / 100);
} else {
discountAmount = discountValue;
}
// 1. 가격 및 결제 정보 업데이트
$.ajax({
url: `/api/compounds/${compoundId}/price`,
type: 'PUT',
contentType: 'application/json',
data: JSON.stringify({
sell_price_total: priceAfterDiscount,
discount_rate: discountType === 'rate' ? discountValue : 0,
discount_amount: Math.round(discountAmount),
discount_reason: discountReason,
mileage_used: mileageUse,
payment_cash: cashPayment,
payment_card: cardPayment,
payment_transfer: transferPayment
}),
success: function() {
// 2. 상태를 PAID로 변경
$.ajax({
url: `/api/compounds/${compoundId}/status`,
type: 'POST',
contentType: 'application/json',
data: JSON.stringify({
status: 'PAID',
payment_method: paymentMethod,
payment_date: paymentDate,
actual_payment_amount: priceAfterDiscount,
delivery_method: deliveryMethod,
delivery_date: deliveryDate,
mileage_used: mileageUse,
changed_by: 'user'
}),
success: function() {
alert('판매 처리가 완료되었습니다.');
$('#salesModal').modal('hide');
loadCompounds(); // 목록 새로고침
},
error: function(xhr) {
alert('상태 업데이트 실패: ' + (xhr.responseJSON?.error || '알 수 없는 오류'));
}
});
},
error: function(xhr) {
alert('가격 업데이트 실패: ' + (xhr.responseJSON?.error || '알 수 없는 오류'));
}
});
});
// 배송 처리
function processDelivery(compoundId) {
if (!confirm('배송 처리를 하시겠습니까?')) {
return;
}
$.ajax({
url: `/api/compounds/${compoundId}/status`,
type: 'POST',
contentType: 'application/json',
data: JSON.stringify({
status: 'DELIVERED',
delivery_date: new Date().toISOString(),
changed_by: 'user'
}),
success: function() {
alert('배송 처리가 완료되었습니다.');
loadCompounds(); // 목록 새로고침
},
error: function(xhr) {
alert('배송 처리 실패: ' + (xhr.responseJSON?.error || '알 수 없는 오류'));
}
});
}
}); });

View File

@ -478,6 +478,178 @@
</div> </div>
</div> </div>
<!-- 판매 처리 모달 -->
<div class="modal fade" id="salesModal" tabindex="-1">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header bg-success text-white">
<h5 class="modal-title"><i class="bi bi-cash-coin"></i> 판매 처리</h5>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<form id="salesForm">
<input type="hidden" id="salesCompoundId">
<div class="row mb-3">
<div class="col-md-6">
<label class="form-label">처방명</label>
<input type="text" class="form-control" id="salesFormulaName" readonly>
</div>
<div class="col-md-6">
<label class="form-label">환자명</label>
<input type="text" class="form-control" id="salesPatientName" readonly>
</div>
</div>
<div class="card mb-3">
<div class="card-header bg-light">
<h6 class="mb-0"><i class="bi bi-calculator"></i> 가격 정보</h6>
</div>
<div class="card-body">
<div class="row">
<div class="col-md-4">
<label class="form-label">약재원가</label>
<input type="number" class="form-control" id="salesCostTotal" readonly>
</div>
<div class="col-md-4">
<label class="form-label">조제료</label>
<input type="number" class="form-control" id="salesDispensingFee" value="20000">
</div>
<div class="col-md-4">
<label class="form-label">판매가격 (원가+조제료)</label>
<input type="number" class="form-control fw-bold" id="salesBasePrice" readonly>
</div>
</div>
<div class="row mt-3">
<div class="col-md-12">
<label class="form-label">할인 방식 선택</label>
<div class="btn-group w-100" role="group">
<input type="radio" class="btn-check" name="discountType" id="discountTypeRate" value="rate" checked>
<label class="btn btn-outline-primary" for="discountTypeRate">할인율 (%)</label>
<input type="radio" class="btn-check" name="discountType" id="discountTypeAmount" value="amount">
<label class="btn btn-outline-primary" for="discountTypeAmount">할인액 (원)</label>
</div>
</div>
</div>
<div class="row mt-2">
<div class="col-md-4">
<label class="form-label">할인 설정</label>
<div class="input-group">
<input type="number" class="form-control" id="salesDiscountValue" min="0" value="0">
<span class="input-group-text" id="discountUnit">%</span>
</div>
</div>
<div class="col-md-4">
<label class="form-label">할인사유</label>
<input type="text" class="form-control" id="salesDiscountReason" placeholder="예: 단골 할인">
</div>
<div class="col-md-4">
<label class="form-label">할인 후 가격</label>
<input type="number" class="form-control fw-bold text-danger" id="salesPriceAfterDiscount" readonly>
</div>
</div>
<div class="row mt-3">
<div class="col-md-12">
<div class="alert alert-info mb-0">
<i class="bi bi-piggy-bank"></i> <strong>보유 마일리지:</strong>
<span id="patientMileageBalance">0</span>
<button type="button" class="btn btn-sm btn-outline-info float-end" id="applyMaxMileage">전액 사용</button>
</div>
</div>
</div>
</div>
</div>
<div class="card mb-3">
<div class="card-header bg-light">
<h6 class="mb-0"><i class="bi bi-credit-card"></i> 복합 결제</h6>
</div>
<div class="card-body">
<div class="row">
<div class="col-md-3">
<label class="form-label">마일리지 사용</label>
<input type="number" class="form-control" id="salesMileageUse" min="0" value="0">
</div>
<div class="col-md-3">
<label class="form-label">현금 결제</label>
<input type="number" class="form-control" id="salesPaymentCash" min="0" value="0">
</div>
<div class="col-md-3">
<label class="form-label">카드 결제</label>
<input type="number" class="form-control" id="salesPaymentCard" min="0" value="0">
</div>
<div class="col-md-3">
<label class="form-label">계좌이체</label>
<input type="number" class="form-control" id="salesPaymentTransfer" min="0" value="0">
</div>
</div>
<div class="row mt-3">
<div class="col-md-6">
<div class="alert alert-warning mb-0">
<strong>결제 필요 금액:</strong> <span id="salesNeedToPay" class="fs-5">0</span>
</div>
</div>
<div class="col-md-6">
<div class="alert alert-success mb-0">
<strong>결제 합계:</strong> <span id="salesTotalPayment" class="fs-5">0</span>
</div>
</div>
</div>
<div class="row mt-2">
<div class="col-md-6">
<label class="form-label">주 결제방법</label>
<select class="form-control" id="salesPaymentMethod">
<option value="">선택하세요</option>
<option value="CASH">현금</option>
<option value="CARD">카드</option>
<option value="TRANSFER">계좌이체</option>
<option value="KAKAO">카카오페이</option>
<option value="COMPLEX">복합결제</option>
</select>
</div>
<div class="col-md-6">
<label class="form-label">결제일시</label>
<input type="datetime-local" class="form-control" id="salesPaymentDate">
</div>
</div>
</div>
</div>
<div class="card">
<div class="card-header bg-light">
<h6 class="mb-0"><i class="bi bi-truck"></i> 배송 정보</h6>
</div>
<div class="card-body">
<div class="row">
<div class="col-md-6">
<label class="form-label">배송방법</label>
<select class="form-control" id="salesDeliveryMethod">
<option value="">선택하세요</option>
<option value="DELIVERY">택배</option>
<option value="PICKUP">직접수령</option>
<option value="QUICK">퀵서비스</option>
</select>
</div>
<div class="col-md-6">
<label class="form-label">배송예정일</label>
<input type="date" class="form-control" id="salesDeliveryDate">
</div>
</div>
</div>
</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-success" id="saveSalesBtn">
<i class="bi bi-check-circle"></i> 판매 확정
</button>
</div>
</div>
</div>
</div>
<!-- 조제 상세보기 모달 --> <!-- 조제 상세보기 모달 -->
<div class="modal fade" id="compoundDetailModal" tabindex="-1"> <div class="modal fade" id="compoundDetailModal" tabindex="-1">
<div class="modal-dialog modal-xl"> <div class="modal-dialog modal-xl">