diff --git a/app.py b/app.py index b86ebea..ff9c093 100644 --- a/app.py +++ b/app.py @@ -908,7 +908,7 @@ def create_supplier(): @app.route('/api/upload/purchase', methods=['POST']) def upload_purchase_excel(): - """Excel 파일 업로드 및 입고 처리 (한의사랑/한의정보 형식 자동 감지)""" + """Excel 파일 업로드 및 입고 처리 (한의사랑/한의정보/한퓨어 형식 자동 감지)""" try: if 'file' not in request.files: return jsonify({'success': False, 'error': '파일이 없습니다'}), 400 @@ -1056,7 +1056,9 @@ def upload_purchase_excel(): WHERE herb_item_id = ? """, (ingredient_code, company_name, herb_item_id)) else: - # herb_products에 없는 경우 기존 로직 + # herb_products에 없는 경우 — Excel에서 제공한 ingredient_code 활용 + row_ingredient_code = row.get('ingredient_code') if pd.notna(row.get('ingredient_code')) else None + cursor.execute(""" SELECT herb_item_id FROM herb_items WHERE insurance_code = ? OR herb_name = ? @@ -1065,12 +1067,18 @@ def upload_purchase_excel(): if not herb: cursor.execute(""" - INSERT INTO herb_items (insurance_code, herb_name) - VALUES (?, ?) - """, (insurance_code, row['herb_name'])) + INSERT INTO herb_items (ingredient_code, insurance_code, herb_name) + VALUES (?, ?, ?) + """, (row_ingredient_code, insurance_code, row['herb_name'])) herb_item_id = cursor.lastrowid else: herb_item_id = herb[0] + if row_ingredient_code: + cursor.execute(""" + UPDATE herb_items + SET ingredient_code = COALESCE(ingredient_code, ?) + WHERE herb_item_id = ? + """, (row_ingredient_code, herb_item_id)) else: # 보험코드가 없는 경우 약재명으로만 처리 cursor.execute(""" @@ -1126,7 +1134,8 @@ def upload_purchase_excel(): # 응답 메시지 생성 format_name = { 'hanisarang': '한의사랑', - 'haninfo': '한의정보' + 'haninfo': '한의정보', + 'hanpure': '한퓨어' }.get(summary['format_type'], '알 수 없음') return jsonify({ @@ -1771,7 +1780,8 @@ def get_compounds(): c.status, c.notes, c.created_at, - c.created_by + c.created_by, + c.usage_type 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 @@ -2002,13 +2012,17 @@ def create_compound(): custom_summary = " | ".join(summary_parts) if summary_parts else "" + # 용도 구분 (SALE: 판매, SELF_USE: 자가소비, SAMPLE: 샘플, DISPOSAL: 폐기) + usage_type = data.get('usage_type', 'SALE') + # 조제 마스터 생성 (커스텀 정보 포함) cursor.execute(""" INSERT INTO compounds (patient_id, formula_id, compound_date, je_count, cheop_total, pouch_total, prescription_no, notes, created_by, - is_custom, custom_summary, custom_type) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + is_custom, custom_summary, custom_type, + usage_type, sell_price_total) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) """, ( data.get('patient_id'), formula_id, @@ -2021,7 +2035,9 @@ def create_compound(): data.get('created_by', 'system'), 1 if is_custom else 0, custom_summary if is_custom else None, - 'custom' if is_custom else 'standard' + 'custom' if is_custom else 'standard', + usage_type, + 0 if usage_type != 'SALE' else data.get('sell_price_total') )) compound_id = cursor.lastrowid @@ -3471,6 +3487,50 @@ def update_compound_status(compound_id): except Exception as e: return jsonify({'error': str(e)}), 500 +@app.route('/api/compounds//usage-type', methods=['PUT']) +def update_compound_usage_type(compound_id): + """조제 용도 변경 (SALE, SELF_USE, SAMPLE, DISPOSAL)""" + try: + data = request.json + new_usage = data.get('usage_type') + valid_types = ['SALE', 'SELF_USE', 'SAMPLE', 'DISPOSAL'] + + if new_usage not in valid_types: + return jsonify({'error': f'유효하지 않은 용도입니다: {new_usage}'}), 400 + + with get_db() as conn: + cursor = conn.cursor() + + cursor.execute("SELECT usage_type, status FROM compounds WHERE compound_id = ?", (compound_id,)) + row = cursor.fetchone() + if not row: + return jsonify({'error': '조제를 찾을 수 없습니다'}), 404 + + old_usage = row['usage_type'] or 'SALE' + + # 용도 변경 + cursor.execute(""" + UPDATE compounds + SET usage_type = ?, + sell_price_total = CASE WHEN ? != 'SALE' THEN 0 ELSE sell_price_total END, + updated_at = CURRENT_TIMESTAMP + WHERE compound_id = ? + """, (new_usage, new_usage, compound_id)) + + # 이력 기록 + cursor.execute(""" + INSERT INTO sales_status_history (compound_id, old_status, new_status, changed_by, change_reason) + VALUES (?, ?, ?, 'system', ?) + """, (compound_id, f'usage:{old_usage}', f'usage:{new_usage}', + f'용도 변경: {old_usage} → {new_usage}')) + + conn.commit() + + return jsonify({'success': True, 'message': f'용도가 변경되었습니다: {new_usage}'}) + + except Exception as e: + return jsonify({'error': str(e)}), 500 + @app.route('/api/compounds//price', methods=['PUT']) def update_compound_price(compound_id): """조제 가격 조정 (복합결제 지원)""" @@ -3591,6 +3651,7 @@ def get_sales_statistics(): COUNT(CASE WHEN status = 'REFUNDED' THEN 1 END) as refunded_count FROM compounds WHERE status IN ('PAID', 'PENDING_DELIVERY', 'DELIVERED', 'COMPLETED') + AND COALESCE(usage_type, 'SALE') = 'SALE' """ params = [] @@ -3612,6 +3673,7 @@ def get_sales_statistics(): SUM(COALESCE(actual_payment_amount, sell_price_total)) as daily_total FROM compounds WHERE status IN ('PAID', 'PENDING_DELIVERY', 'DELIVERED', 'COMPLETED') + AND COALESCE(usage_type, 'SALE') = 'SALE' """ if start_date: @@ -3639,6 +3701,7 @@ def get_sales_statistics(): FROM compounds c LEFT JOIN formulas f ON c.formula_id = f.formula_id WHERE c.status IN ('PAID', 'PENDING_DELIVERY', 'DELIVERED', 'COMPLETED') + AND COALESCE(c.usage_type, 'SALE') = 'SALE' """ if start_date: diff --git a/docs/조제_용도구분_usage_type.md b/docs/조제_용도구분_usage_type.md new file mode 100644 index 0000000..a4edd44 --- /dev/null +++ b/docs/조제_용도구분_usage_type.md @@ -0,0 +1,122 @@ +# 조제 용도 구분 (usage_type) + +> 조제된 약은 반드시 판매 목적이 아닐 수 있다. +> 자가소비, 샘플 제공, 폐기 등 **재고는 차감되지만 매출이 아닌 경우**를 구분한다. + +--- + +## 1. 기존 status와의 차이 + +| 구분 | `status` | `usage_type` | +|------|----------|-------------| +| 역할 | 판매 진행 **흐름 상태** | 조제의 **용도 분류** | +| 변화 | 시간에 따라 전이 (PREPARED → PAID → COMPLETED) | 조제 시점에 결정, 사후 변경 가능 | +| 예시 | "이 조제는 결제 완료 상태" | "이 조제는 자가소비 용도" | + +**핵심**: `status`와 `usage_type`은 서로 다른 차원. 자가소비도 `PREPARED → COMPLETED` 상태 흐름을 탈 수 있다. + +--- + +## 2. usage_type 값 정의 + +| 값 | 한글명 | 설명 | 매출 포함 | 판매가 | +|----|--------|------|:---------:|--------| +| `SALE` | 판매 | 환자에게 판매 (기본값) | O | 설정 가격 | +| `SELF_USE` | 자가소비 | 약국 자체 사용 | X | 0원 | +| `SAMPLE` | 샘플 | 시음/샘플 제공 | X | 0원 | +| `DISPOSAL` | 폐기 | 유통기한 초과 등 폐기 처리 | X | 0원 | + +--- + +## 3. DB 스키마 + +```sql +-- compounds 테이블에 추가된 컬럼 +ALTER TABLE compounds ADD COLUMN usage_type TEXT DEFAULT 'SALE'; +``` + +- 기본값 `'SALE'` — 기존 데이터 호환 +- `COALESCE(usage_type, 'SALE')` 패턴으로 NULL 안전 처리 + +--- + +## 4. 동작 규칙 + +### 4-1. 조제 생성 시 +- 조제 실행 폼에서 **용도** 드롭다운 선택 (기본: 판매) +- `SALE`이 아닌 용도 → `sell_price_total = 0` 자동 설정 +- 재고 차감은 용도에 관계없이 **항상 발생** + +### 4-2. 사후 변경 +- 조제 목록의 **용도 뱃지 클릭** → 번호 입력으로 변경 +- API: `PUT /api/compounds/:id/usage-type` +- 판매 → 자가소비 변경 시 `sell_price_total = 0`으로 자동 변경 +- 변경 이력이 `sales_status_history`에 기록됨 + +### 4-3. 매출 통계 제외 +- 대시보드 월매출: `usage_type = 'SALE'`만 집계 +- 판매 통계 API: `COALESCE(usage_type, 'SALE') = 'SALE'` 조건 +- 자가소비/샘플/폐기는 매출에서 완전 제외 + +### 4-4. UI 표시 + +| 용도 | 뱃지 색상 | 판매 버튼 | 판매가 표시 | +|------|----------|:---------:|:----------:| +| 판매 | 초록 | O | 금액 표시 | +| 자가소비 | 노랑 | X | `-` | +| 샘플 | 파랑 | X | `-` | +| 폐기 | 회색 | X | `-` | + +--- + +## 5. API 명세 + +### 용도 변경 +``` +PUT /api/compounds/:compound_id/usage-type +Content-Type: application/json + +{ + "usage_type": "SELF_USE" // SALE, SELF_USE, SAMPLE, DISPOSAL +} + +Response: +{ + "success": true, + "message": "용도가 변경되었습니다: SELF_USE" +} +``` + +### 조제 생성 시 용도 지정 +``` +POST /api/compounds +{ + "patient_id": 1, + "formula_id": 5, + "je_count": 1, + "cheop_total": 30, + "pouch_total": 30, + "usage_type": "SELF_USE", // 생략 시 기본 SALE + "ingredients": [...] +} +``` + +--- + +## 6. 활용 시나리오 + +### 자가소비 조제 +1. 약사가 본인/가족용으로 쌍화탕 1제 조제 +2. 조제 실행 시 용도 → "자가소비" 선택 +3. 재고 차감됨, 판매가 = 0, 매출 통계 제외 +4. 원가만 기록 → 비용 관리 목적 + +### 기존 조제를 자가소비로 변경 +1. 실수로 판매로 조제했는데 실제로는 자가소비 +2. 조제 내역 목록에서 "판매" 뱃지 클릭 +3. "2" (자가소비) 입력 +4. 즉시 변경 → 매출에서 제외 + +--- + +*최종 수정: 2026-02-19* diff --git a/excel_processor.py b/excel_processor.py index d351916..63c605e 100644 --- a/excel_processor.py +++ b/excel_processor.py @@ -36,6 +36,18 @@ class ExcelProcessor: '비고': 'notes' } + # 한퓨어 형식 컬럼 매핑 + HANPURE_MAPPING = { + '상품명': 'herb_name', + '제조사코드': 'insurance_code', + '주성분코드': 'ingredient_code', + '제조사명': 'supplier_name', + '원산지': 'origin_country', + '소계': 'total_amount', + '옵션항목': 'option_detail', + '주문번호': 'order_number', + } + def __init__(self): self.format_type = None self.df_original = None @@ -45,6 +57,11 @@ class ExcelProcessor: """Excel 형식 자동 감지""" columns = df.columns.tolist() + # 한퓨어 형식 체크 (주문번호, 주성분코드, 제조사코드, 옵션항목이 특징) + hanpure_cols = ['주문번호', '주성분코드', '제조사코드', '상품명', '옵션항목'] + if all(col in columns for col in hanpure_cols): + return 'hanpure' + # 한의사랑 형식 체크 hanisarang_cols = ['품목명', '제품코드', '일그램당단가', '총구입량', '총구입단가'] if all(col in columns for col in hanisarang_cols): @@ -64,8 +81,10 @@ class ExcelProcessor: def read_excel(self, file_path): """Excel 파일 읽기""" try: - # 제품코드를 문자열로 읽기 위한 dtype 설정 - self.df_original = pd.read_excel(file_path, dtype={'제품코드': str}) + # 코드 컬럼을 문자열로 읽기 위한 dtype 설정 + self.df_original = pd.read_excel(file_path, dtype={ + '제품코드': str, '제조사코드': str, '주성분코드': str, '대표코드': str + }) self.format_type = self.detect_format(self.df_original) return True except Exception as e: @@ -137,12 +156,96 @@ class ExcelProcessor: self.df_processed = df_mapped return df_mapped + @staticmethod + def parse_option_quantity_g(option_text): + """옵션항목에서 총 중량(g) 파싱 + 예: '인삼 특A (4~5년근) 600g*5개' → 3000 + '감초 1kg' → 1000 + '복령 500g' → 500 + '백출 300g*3개' → 900 + """ + if not option_text or pd.isna(option_text): + return None + + text = str(option_text) + + # 패턴1: NNNg*N개 또는 NNNg×N개 + m = re.search(r'(\d+(?:\.\d+)?)\s*g\s*[*×x]\s*(\d+)', text, re.IGNORECASE) + if m: + return float(m.group(1)) * int(m.group(2)) + + # 패턴2: N kg*N개 + m = re.search(r'(\d+(?:\.\d+)?)\s*kg\s*[*×x]\s*(\d+)', text, re.IGNORECASE) + if m: + return float(m.group(1)) * 1000 * int(m.group(2)) + + # 패턴3: NNNg (단독) + m = re.search(r'(\d+(?:\.\d+)?)\s*g(?!\w)', text, re.IGNORECASE) + if m: + return float(m.group(1)) + + # 패턴4: Nkg (단독) + m = re.search(r'(\d+(?:\.\d+)?)\s*kg(?!\w)', text, re.IGNORECASE) + if m: + return float(m.group(1)) * 1000 + + return None + + def process_hanpure(self): + """한퓨어 형식 처리""" + df = self.df_original.copy() + + df_mapped = pd.DataFrame() + + for old_col, new_col in self.HANPURE_MAPPING.items(): + if old_col in df.columns: + df_mapped[new_col] = df[old_col] + + # 보험코드 9자리 패딩 처리 + if 'insurance_code' in df_mapped.columns: + df_mapped['insurance_code'] = df_mapped['insurance_code'].apply( + lambda x: str(x).zfill(9) if pd.notna(x) and str(x).strip().isdigit() else str(x).strip() if pd.notna(x) else None + ) + + # 주문번호에서 날짜 추출 (20260211-22511888 → 20260211) + if 'order_number' in df_mapped.columns: + df_mapped['receipt_date'] = df_mapped['order_number'].apply( + lambda x: str(x).split('-')[0] if pd.notna(x) else None + ) + + # 옵션항목에서 중량(g) 파싱 + if 'option_detail' in df_mapped.columns: + df_mapped['quantity'] = df_mapped['option_detail'].apply(self.parse_option_quantity_g) + + # 업체명 기본값 + if 'supplier_name' not in df_mapped.columns or df_mapped['supplier_name'].isnull().all(): + df_mapped['supplier_name'] = '한퓨어' + + # 단가 계산 (소계 / 중량g) + if 'total_amount' in df_mapped.columns and 'quantity' in df_mapped.columns: + df_mapped['unit_price'] = df_mapped.apply( + lambda row: round(row['total_amount'] / row['quantity'], 2) + if pd.notna(row.get('quantity')) and row.get('quantity', 0) > 0 + else None, axis=1 + ) + + # 비고에 옵션항목 원문 저장 + df_mapped['notes'] = df_mapped.get('option_detail', '') + + # 임시 컬럼 제거 + df_mapped.drop(columns=['order_number', 'option_detail'], errors='ignore', inplace=True) + + self.df_processed = df_mapped + return df_mapped + def process(self): """형식에 따라 자동 처리""" if self.format_type == 'hanisarang': return self.process_hanisarang() elif self.format_type == 'haninfo': return self.process_haninfo() + elif self.format_type == 'hanpure': + return self.process_hanpure() else: raise ValueError(f"지원하지 않는 형식: {self.format_type}") @@ -221,7 +324,8 @@ class ExcelProcessor: standard_columns = [ 'insurance_code', 'supplier_name', 'herb_name', 'receipt_date', 'quantity', 'total_amount', - 'unit_price', 'origin_country', 'notes' + 'unit_price', 'origin_country', 'notes', + 'ingredient_code' ] # 있는 컬럼만 선택 diff --git a/static/app.js b/static/app.js index ec7ab48..e77b38b 100644 --- a/static/app.js +++ b/static/app.js @@ -141,10 +141,11 @@ $(document).ready(function() { const todayCompounds = response.data.filter(c => c.compound_date === today); $('#todayCompounds').text(todayCompounds.length); - // 이번달 매출/마진 계산 + // 이번달 매출/마진 계산 (자가소비/샘플/폐기 제외) const monthData = response.data.filter(c => c.compound_date && c.compound_date.startsWith(currentMonth) && - ['PAID', 'PENDING_DELIVERY', 'DELIVERED', 'COMPLETED'].includes(c.status) + ['PAID', 'PENDING_DELIVERY', 'DELIVERED', 'COMPLETED'].includes(c.status) && + (!c.usage_type || c.usage_type === 'SALE') ); const monthSales = monthData.reduce((sum, c) => sum + (c.actual_payment_amount || c.sell_price_total || 0), 0); const monthCost = monthData.reduce((sum, c) => sum + (c.cost_total || 0), 0); @@ -1627,6 +1628,7 @@ $(document).ready(function() { je_count: parseFloat($('#jeCount').val()), cheop_total: parseFloat($('#cheopTotal').val()), pouch_total: parseFloat($('#pouchTotal').val()), + usage_type: $('#compoundUsageType').val() || 'SALE', ingredients: ingredients }; @@ -1637,8 +1639,11 @@ $(document).ready(function() { data: JSON.stringify(compoundData), success: function(response) { if (response.success) { - alert(`조제가 완료되었습니다.\n원가: ${formatCurrency(response.total_cost)}`); + const usageType = $('#compoundUsageType').val(); + const usageLabel = {SELF_USE: '자가소비', SAMPLE: '샘플', DISPOSAL: '폐기'}[usageType] || '판매'; + alert(`조제가 완료되었습니다. [${usageLabel}]\n원가: ${formatCurrency(response.total_cost)}`); $('#compoundForm').hide(); + $('#compoundUsageType').val('SALE'); loadCompounds(); } }, @@ -1701,6 +1706,13 @@ $(document).ready(function() { statusBadge = '대기'; } + // 용도 뱃지 (클릭으로 변경 가능) + const usageLabels = {SALE: '판매', SELF_USE: '자가소비', SAMPLE: '샘플', DISPOSAL: '폐기'}; + const usageColors = {SALE: 'success', SELF_USE: 'warning text-dark', SAMPLE: 'info', DISPOSAL: 'secondary'}; + const curUsage = compound.usage_type || 'SALE'; + const usageBadge = `${usageLabels[curUsage]}`; + const isSale = curUsage === 'SALE'; + const row = $(` ${response.data.length - index} @@ -1712,14 +1724,14 @@ $(document).ready(function() { ${compound.cheop_total || 0} ${compound.pouch_total || 0} ${formatCurrency(compound.cost_total || 0)} - ${formatCurrency(compound.sell_price_total || 0)} - ${statusBadge} + ${isSale ? formatCurrency(compound.sell_price_total || 0) : '-'} + ${usageBadge} ${statusBadge} ${compound.prescription_no || '-'} - ${compound.status === 'PREPARED' ? ` + ${compound.status === 'PREPARED' && isSale ? ` + ` : ''} + ${compound.status === 'PREPARED' ? ` @@ -1795,6 +1809,40 @@ $(document).ready(function() { }); } }); + + // 용도 변경 뱃지 클릭 이벤트 + $('.change-usage').on('click', function() { + const compoundId = $(this).data('id'); + const current = $(this).data('current'); + const options = {SALE: '판매', SELF_USE: '자가소비', SAMPLE: '샘플', DISPOSAL: '폐기'}; + const choices = Object.entries(options) + .map(([k, v]) => `${k === current ? '● ' : ' '}${v}`) + .join('\n'); + const input = prompt(`용도를 선택하세요 (현재: ${options[current]})\n\n1: 판매\n2: 자가소비\n3: 샘플\n4: 폐기`, current === 'SALE' ? '1' : current === 'SELF_USE' ? '2' : current === 'SAMPLE' ? '3' : '4'); + if (!input) return; + const typeMap = {'1': 'SALE', '2': 'SELF_USE', '3': 'SAMPLE', '4': 'DISPOSAL'}; + const newType = typeMap[input.trim()]; + if (!newType) { alert('잘못된 입력입니다.'); return; } + if (newType === current) return; + + $.ajax({ + url: `/api/compounds/${compoundId}/usage-type`, + method: 'PUT', + contentType: 'application/json', + data: JSON.stringify({ usage_type: newType }), + success: function(response) { + if (response.success) { + loadCompounds(); + loadDashboard(); + } else { + alert(response.error || '변경 실패'); + } + }, + error: function(xhr) { + alert(xhr.responseJSON?.error || '변경 실패'); + } + }); + }); } else { tbody.html('조제 내역이 없습니다.'); $('#todayCompoundCount').text(0); diff --git a/templates/index.html b/templates/index.html index d765ee4..a60ea5b 100644 --- a/templates/index.html +++ b/templates/index.html @@ -443,18 +443,27 @@
-
+
-
+
-
+
+
+ + +
약재 구성 (가감 가능)