feat: 한퓨어 엑셀 형식 지원 및 조제 용도 구분(usage_type) 추가

한퓨어 엑셀:
- ExcelProcessor에 hanpure 형식 자동 감지 및 처리 추가
- 옵션항목에서 중량 파싱 (600g*5개 → 3000g 등)
- 주문번호에서 입고일 추출, ingredient_code 직접 활용

조제 용도 구분:
- compounds.usage_type 컬럼 추가 (SALE/SELF_USE/SAMPLE/DISPOSAL)
- 조제 실행 시 용도 선택 드롭다운
- 조제 목록에서 용도 뱃지 클릭으로 사후 변경 가능
- 비판매 용도 시 sell_price_total=0, 매출 통계 제외
- PUT /api/compounds/:id/usage-type API 추가
- 용도 구분 설계 문서 (docs/조제_용도구분_usage_type.md)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
시골약사 2026-02-19 14:34:10 +00:00
parent 69be63d00d
commit 974ce5f655
5 changed files with 368 additions and 22 deletions

83
app.py
View File

@ -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/<int:compound_id>/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/<int:compound_id>/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:

View File

@ -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*

View File

@ -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'
]
# 있는 컬럼만 선택

View File

@ -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 = '<span class="badge bg-secondary">대기</span>';
}
// 용도 뱃지 (클릭으로 변경 가능)
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 = `<span class="badge bg-${usageColors[curUsage]} change-usage" style="cursor:pointer" data-id="${compound.compound_id}" data-current="${curUsage}" title="클릭하여 용도 변경">${usageLabels[curUsage]}</span>`;
const isSale = curUsage === 'SALE';
const row = $(`
<tr>
<td>${response.data.length - index}</td>
@ -1712,14 +1724,14 @@ $(document).ready(function() {
<td>${compound.cheop_total || 0}</td>
<td>${compound.pouch_total || 0}</td>
<td>${formatCurrency(compound.cost_total || 0)}</td>
<td>${formatCurrency(compound.sell_price_total || 0)}</td>
<td>${statusBadge}</td>
<td>${isSale ? formatCurrency(compound.sell_price_total || 0) : '-'}</td>
<td>${usageBadge} ${statusBadge}</td>
<td>${compound.prescription_no || '-'}</td>
<td>
<button class="btn btn-sm btn-outline-info view-compound-detail" data-id="${compound.compound_id}">
<i class="bi bi-eye"></i>
</button>
${compound.status === 'PREPARED' ? `
${compound.status === 'PREPARED' && isSale ? `
<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 || '직접조제'}"
@ -1728,6 +1740,8 @@ $(document).ready(function() {
data-price="${compound.sell_price_total || 0}">
<i class="bi bi-cash-coin"></i>
</button>
` : ''}
${compound.status === 'PREPARED' ? `
<button class="btn btn-sm btn-outline-danger cancel-compound" data-id="${compound.compound_id}">
<i class="bi bi-x-circle"></i>
</button>
@ -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('<tr><td colspan="13" class="text-center text-muted">조제 내역이 없습니다.</td></tr>');
$('#todayCompoundCount').text(0);

View File

@ -443,18 +443,27 @@
</div>
</div>
<div class="row mt-3">
<div class="col-md-4">
<div class="col-md-3">
<label class="form-label">제수</label>
<input type="number" class="form-control" id="jeCount" value="1" min="0.5" step="0.5" required>
</div>
<div class="col-md-4">
<div class="col-md-3">
<label class="form-label">총 첩수</label>
<input type="number" class="form-control" id="cheopTotal" readonly>
</div>
<div class="col-md-4">
<div class="col-md-3">
<label class="form-label">총 파우치수</label>
<input type="number" class="form-control" id="pouchTotal" required>
</div>
<div class="col-md-3">
<label class="form-label">용도</label>
<select class="form-control" id="compoundUsageType">
<option value="SALE">판매</option>
<option value="SELF_USE">자가소비</option>
<option value="SAMPLE">샘플</option>
<option value="DISPOSAL">폐기</option>
</select>
</div>
</div>
<div class="mt-3">
<h6>약재 구성 (가감 가능)</h6>