From f3f1efd8c2c78a50f9f066a745ef8c729d427dfa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=8B=9C=EA=B3=A8=EC=95=BD=EC=82=AC?= Date: Wed, 18 Feb 2026 05:42:29 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20=ED=8C=90=EB=A7=A4=EA=B4=80=EB=A6=AC=20?= =?UTF-8?q?=EC=8B=9C=EC=8A=A4=ED=85=9C=20Phase=201=20=EB=B0=8F=20=EB=A7=88?= =?UTF-8?q?=EC=9D=BC=EB=A6=AC=EC=A7=80=20=EC=8B=9C=EC=8A=A4=ED=85=9C=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 판매 관리 기능 추가 - compounds 테이블에 판매 관련 컬럼 추가 (payment_method, discount_rate, delivery_method 등) - 판매 상태 관리 (조제완료→결제대기→결제완료→배송대기→배송완료) - 판매 처리 모달 UI 구현 - 9가지 상태별 뱃지 표시 - 마일리지 시스템 구축 - patients 테이블에 마일리지 컬럼 추가 (balance, earned, used) - mileage_transactions 테이블 생성 (거래 이력 관리) - 마일리지 사용/적립 기능 구현 - 복합 결제 기능 - 할인율(%) / 할인액(원) 직접 입력 선택 가능 - 마일리지 + 현금 + 카드 + 계좌이체 복합 결제 - 결제 금액 자동 검증 - 결제 방법 자동 분류 (복합결제 지원) - API 엔드포인트 추가 - POST /api/compounds//status (상태 업데이트) - PUT /api/compounds//price (가격 조정) - GET /api/sales/statistics (판매 통계) - 데이터베이스 설정 통합 - config.py 생성하여 DB 경로 중앙화 TODO: 처방별 기본가격 정책 시스템 (price_policies 테이블 활용) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- add_mileage_system.py | 194 +++++++++++++++++++ add_sales_columns.py | 131 +++++++++++++ app.py | 322 ++++++++++++++++++++++++++++++- check_compound_detail.py | 102 ++++++++++ check_compound_ingredients.py | 103 ++++++++++ check_compound_schema.py | 65 +++++++ config.py | 42 ++++ create_sales_tables.py | 94 +++++++++ docs/직접조제_고도화_기획문서.md | 317 ++++++++++++++++++++++++++++++ docs/판매관리_기획문서.md | 298 ++++++++++++++++++++++++++++ static/app.js | 318 +++++++++++++++++++++++++++++- templates/index.html | 172 +++++++++++++++++ 12 files changed, 2154 insertions(+), 4 deletions(-) create mode 100644 add_mileage_system.py create mode 100644 add_sales_columns.py create mode 100644 check_compound_detail.py create mode 100644 check_compound_ingredients.py create mode 100644 check_compound_schema.py create mode 100644 config.py create mode 100644 create_sales_tables.py create mode 100644 docs/직접조제_고도화_기획문서.md create mode 100644 docs/판매관리_기획문서.md diff --git a/add_mileage_system.py b/add_mileage_system.py new file mode 100644 index 0000000..4b9f73a --- /dev/null +++ b/add_mileage_system.py @@ -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() \ No newline at end of file diff --git a/add_sales_columns.py b/add_sales_columns.py new file mode 100644 index 0000000..4c95160 --- /dev/null +++ b/add_sales_columns.py @@ -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() \ No newline at end of file diff --git a/app.py b/app.py index 8ef19cf..5c9c372 100644 --- a/app.py +++ b/app.py @@ -15,11 +15,12 @@ from werkzeug.utils import secure_filename import json from contextlib import contextmanager from excel_processor import ExcelProcessor +from config import DATABASE_PATH, STATIC_PATH, TEMPLATES_PATH # Flask 앱 초기화 app = Flask(__name__, static_folder='static', template_folder='templates') 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['MAX_CONTENT_LENGTH'] = 16 * 1024 * 1024 # 16MB max file size @@ -2735,6 +2736,325 @@ def get_all_efficacy_tags(): except Exception as e: return jsonify({'error': str(e)}), 500 +# ==================== 판매 관리 API ==================== + +@app.route('/api/compounds//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//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//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 not os.path.exists(app.config['DATABASE']): diff --git a/check_compound_detail.py b/check_compound_detail.py new file mode 100644 index 0000000..84cfe6c --- /dev/null +++ b/check_compound_detail.py @@ -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() \ No newline at end of file diff --git a/check_compound_ingredients.py b/check_compound_ingredients.py new file mode 100644 index 0000000..98ede0c --- /dev/null +++ b/check_compound_ingredients.py @@ -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() \ No newline at end of file diff --git a/check_compound_schema.py b/check_compound_schema.py new file mode 100644 index 0000000..1cd7267 --- /dev/null +++ b/check_compound_schema.py @@ -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() \ No newline at end of file diff --git a/config.py b/config.py new file mode 100644 index 0000000..2e7579d --- /dev/null +++ b/config.py @@ -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() \ No newline at end of file diff --git a/create_sales_tables.py b/create_sales_tables.py new file mode 100644 index 0000000..a307423 --- /dev/null +++ b/create_sales_tables.py @@ -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() \ No newline at end of file diff --git a/docs/직접조제_고도화_기획문서.md b/docs/직접조제_고도화_기획문서.md new file mode 100644 index 0000000..061a20c --- /dev/null +++ b/docs/직접조제_고도화_기획문서.md @@ -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. **고객 만족** + - 개인 맞춤 서비스 + - 투명한 가격 정책 \ No newline at end of file diff --git a/docs/판매관리_기획문서.md b/docs/판매관리_기획문서.md new file mode 100644 index 0000000..e83a494 --- /dev/null +++ b/docs/판매관리_기획문서.md @@ -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. **의사결정 지원** + - 데이터 기반 가격 정책 수립 + - 인기 처방 파악 및 재고 관리 최적화 \ No newline at end of file diff --git a/static/app.js b/static/app.js index c62a30b..ff8de97 100644 --- a/static/app.js +++ b/static/app.js @@ -1267,14 +1267,32 @@ $(document).ready(function() { let statusBadge = ''; switch(compound.status) { case 'PREPARED': - statusBadge = '조제완료'; + statusBadge = '조제완료'; break; - case 'DISPENSED': - statusBadge = '출고완료'; + case 'PENDING_PAYMENT': + statusBadge = '결제대기'; + break; + case 'PAID': + statusBadge = '결제완료'; + break; + case 'PENDING_DELIVERY': + statusBadge = '배송대기'; + break; + case 'DELIVERED': + statusBadge = '배송완료'; + break; + case 'COMPLETED': + statusBadge = '판매완료'; + break; + case 'OTC_CONVERTED': + statusBadge = 'OTC전환'; break; case 'CANCELLED': statusBadge = '취소'; break; + case 'REFUNDED': + statusBadge = '환불'; + break; default: statusBadge = '대기'; } @@ -1297,6 +1315,20 @@ $(document).ready(function() { + ${compound.status === 'PREPARED' ? ` + + ` : ''} + ${compound.status === 'PAID' ? ` + + ` : ''} `); @@ -1312,6 +1344,23 @@ $(document).ready(function() { const compoundId = $(this).data('id'); 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 { tbody.html('조제 내역이 없습니다.'); $('#todayCompoundCount').text(0); @@ -3439,4 +3488,267 @@ $(document).ready(function() { alert('약재 정보 수정 기능은 준비 중입니다.'); // 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 || '알 수 없는 오류')); + } + }); + } }); \ No newline at end of file diff --git a/templates/index.html b/templates/index.html index a6ccf8b..854f9bc 100644 --- a/templates/index.html +++ b/templates/index.html @@ -478,6 +478,178 @@ + + +