Compare commits

..

5 Commits

Author SHA1 Message Date
2ca5622bbd feat: 의약품 마스터 DB 연동 및 한약재/OTC 구분 체계 구축
- herb_items 테이블에 product_type, standard_code 컬럼 추가
- POST /api/purchase-receipts/from-cart API 구현 (표준코드 기반 입고)
- 5개 API에 product_type/standard_code 필드 추가
- 프론트엔드 전역 구분 표시: 한약재/OTC 배지, 보험코드/표준코드 구분
- 경방신약 주문 매핑 문서 작성 (38건, 총액 1,561,800원)
- DB 스키마 백업 추가

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

Co-Authored-By: Claude <noreply@anthropic.com>
2026-02-23 13:39:59 +00:00
9dd1f41bbb feat: 의약품 마스터 입고 장바구니 UI 구현
검색 결과에서 제품을 장바구니에 담고, 종이 입고장 기준으로
수량/단가 입력 시 g당단가·금액을 자동 계산하는 프론트엔드 플로우.
DB 연동은 추후 구현 예정.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 16:11:56 +00:00
c0d55f8e16 feat: 의약품 마스터 DB 연동 (ATTACH DATABASE)
- medicine_master.db (305,522행) CSV→SQLite 변환 완료
- get_db()에서 ATTACH DATABASE로 자동 연결
- GET /api/medicine-master/search: 상품명/업체명/표준코드 검색
- GET /api/medicine-master/categories: 전문일반구분별 통계
- config.py에 MEDICINE_MASTER_PATH 추가
- 취소된 제품 자동 필터링, 카테고리 필터 지원

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 15:19:14 +00:00
725f14c59a feat: 조제 원가 미리보기 및 재고 상태 표시 개선
원가 미리보기:
- 조제 실행 전 약재별 예상 원가(용량×단가) 및 합계 표시
- 용량/원산지/로트 변경 시 실시간 갱신
- 추가 약재의 이름 표시 오류 수정 (select 내 전체 옵션 텍스트 → 선택값만)

원산지 자동 선택:
- 처방 로드 시 재고 충분한 최저가 원산지를 자동 선택
- "자동 선택" 상태가 아닌 실제 원산지가 선택되어 원가 즉시 계산

재고 상태 표시:
- checkStockForCompound() TODO 제거, 실제 API 호출로 재고 확인
- 기존 원산지 선택을 덮어쓰지 않고 재고 상태만 갱신
- 선택 가능한 원산지가 2개 이상이면 "N종" 뱃지 표시

조제 폼 초기화:
- 새 조제 시 제수 기본값(1)으로 총 첩수(20)/파우치(30) 자동 설정
- 처방 선택 시 총 첩수가 비어있으면 자동 계산

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 14:50:21 +00:00
974ce5f655 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>
2026-02-19 14:34:10 +00:00
12 changed files with 1957 additions and 43 deletions

304
app.py
View File

@@ -15,7 +15,7 @@ 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 from config import DATABASE_PATH, MEDICINE_MASTER_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')
@@ -42,6 +42,10 @@ def get_db():
conn = sqlite3.connect(app.config['DATABASE']) conn = sqlite3.connect(app.config['DATABASE'])
conn.row_factory = sqlite3.Row # 딕셔너리 형태로 반환 conn.row_factory = sqlite3.Row # 딕셔너리 형태로 반환
conn.execute('PRAGMA foreign_keys = ON') # 외래키 제약 활성화 conn.execute('PRAGMA foreign_keys = ON') # 외래키 제약 활성화
# 의약품 마스터 DB 연결 (읽기 전용 참조)
medicine_db = str(MEDICINE_MASTER_PATH)
if os.path.exists(medicine_db):
conn.execute(f"ATTACH DATABASE '{medicine_db}' AS med_master")
try: try:
yield conn yield conn
conn.commit() conn.commit()
@@ -73,6 +77,14 @@ def init_db():
if 'reference_notes' not in of_cols: if 'reference_notes' not in of_cols:
cursor.execute("ALTER TABLE official_formulas ADD COLUMN reference_notes TEXT") cursor.execute("ALTER TABLE official_formulas ADD COLUMN reference_notes TEXT")
# herb_items 마이그레이션: product_type, standard_code 컬럼 추가 (OTC 재고 구분)
cursor.execute("PRAGMA table_info(herb_items)")
hi_cols = {row[1] for row in cursor.fetchall()}
if 'product_type' not in hi_cols:
cursor.execute("ALTER TABLE herb_items ADD COLUMN product_type TEXT DEFAULT 'HERB'")
if 'standard_code' not in hi_cols:
cursor.execute("ALTER TABLE herb_items ADD COLUMN standard_code TEXT")
# 100처방 원방 마스터 시드 데이터 로드 # 100처방 원방 마스터 시드 데이터 로드
cursor.execute("SELECT COUNT(*) FROM official_formulas") cursor.execute("SELECT COUNT(*) FROM official_formulas")
if cursor.fetchone()[0] == 0: if cursor.fetchone()[0] == 0:
@@ -286,6 +298,8 @@ def get_herbs():
h.insurance_code, h.insurance_code,
h.herb_name, h.herb_name,
h.is_active, h.is_active,
h.product_type,
h.standard_code,
COALESCE(SUM(il.quantity_onhand), 0) as current_stock, COALESCE(SUM(il.quantity_onhand), 0) as current_stock,
GROUP_CONCAT(DISTINCT het.tag_name) as efficacy_tags GROUP_CONCAT(DISTINCT het.tag_name) as efficacy_tags
FROM herb_items h FROM herb_items h
@@ -296,7 +310,7 @@ def get_herbs():
LEFT JOIN herb_item_tags hit ON COALESCE(h.ingredient_code, hp.ingredient_code) = hit.ingredient_code LEFT JOIN herb_item_tags hit ON COALESCE(h.ingredient_code, hp.ingredient_code) = hit.ingredient_code
LEFT JOIN herb_efficacy_tags het ON hit.tag_id = het.tag_id LEFT JOIN herb_efficacy_tags het ON hit.tag_id = het.tag_id
WHERE h.is_active = 1 WHERE h.is_active = 1
GROUP BY h.herb_item_id, h.insurance_code, h.herb_name, h.is_active GROUP BY h.herb_item_id, h.insurance_code, h.herb_name, h.is_active, h.product_type, h.standard_code
ORDER BY h.herb_name ORDER BY h.herb_name
""") """)
@@ -908,7 +922,7 @@ def create_supplier():
@app.route('/api/upload/purchase', methods=['POST']) @app.route('/api/upload/purchase', methods=['POST'])
def upload_purchase_excel(): def upload_purchase_excel():
"""Excel 파일 업로드 및 입고 처리 (한의사랑/한의정보 형식 자동 감지)""" """Excel 파일 업로드 및 입고 처리 (한의사랑/한의정보/한퓨어 형식 자동 감지)"""
try: try:
if 'file' not in request.files: if 'file' not in request.files:
return jsonify({'success': False, 'error': '파일이 없습니다'}), 400 return jsonify({'success': False, 'error': '파일이 없습니다'}), 400
@@ -1056,7 +1070,9 @@ def upload_purchase_excel():
WHERE herb_item_id = ? WHERE herb_item_id = ?
""", (ingredient_code, company_name, herb_item_id)) """, (ingredient_code, company_name, herb_item_id))
else: 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(""" cursor.execute("""
SELECT herb_item_id FROM herb_items SELECT herb_item_id FROM herb_items
WHERE insurance_code = ? OR herb_name = ? WHERE insurance_code = ? OR herb_name = ?
@@ -1065,12 +1081,18 @@ def upload_purchase_excel():
if not herb: if not herb:
cursor.execute(""" cursor.execute("""
INSERT INTO herb_items (insurance_code, herb_name) INSERT INTO herb_items (ingredient_code, insurance_code, herb_name)
VALUES (?, ?) VALUES (?, ?, ?)
""", (insurance_code, row['herb_name'])) """, (row_ingredient_code, insurance_code, row['herb_name']))
herb_item_id = cursor.lastrowid herb_item_id = cursor.lastrowid
else: else:
herb_item_id = herb[0] 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: else:
# 보험코드가 없는 경우 약재명으로만 처리 # 보험코드가 없는 경우 약재명으로만 처리
cursor.execute(""" cursor.execute("""
@@ -1126,7 +1148,8 @@ def upload_purchase_excel():
# 응답 메시지 생성 # 응답 메시지 생성
format_name = { format_name = {
'hanisarang': '한의사랑', 'hanisarang': '한의사랑',
'haninfo': '한의정보' 'haninfo': '한의정보',
'hanpure': '한퓨어'
}.get(summary['format_type'], '알 수 없음') }.get(summary['format_type'], '알 수 없음')
return jsonify({ return jsonify({
@@ -1278,6 +1301,127 @@ def create_manual_receipt():
except Exception as e: except Exception as e:
return jsonify({'success': False, 'error': str(e)}), 500 return jsonify({'success': False, 'error': str(e)}), 500
@app.route('/api/purchase-receipts/from-cart', methods=['POST'])
def create_receipt_from_cart():
"""의약품 마스터 장바구니 → 입고 처리 (standard_code 기반, OTC 포함)"""
try:
data = request.get_json()
supplier_id = data.get('supplier_id')
receipt_date = data.get('receipt_date')
notes = data.get('notes', '')
lines = data.get('lines', [])
if not supplier_id:
return jsonify({'success': False, 'error': '도매상을 선택해주세요.'}), 400
if not receipt_date:
return jsonify({'success': False, 'error': '입고일을 입력해주세요.'}), 400
if not lines or len(lines) == 0:
return jsonify({'success': False, 'error': '입고 품목을 1개 이상 추가해주세요.'}), 400
with get_db() as conn:
cursor = conn.cursor()
# 도매상 확인
cursor.execute("SELECT name FROM suppliers WHERE supplier_id = ?", (supplier_id,))
if not cursor.fetchone():
return jsonify({'success': False, 'error': '유효하지 않은 도매상입니다.'}), 400
# 입고장 번호 생성 (PR-YYYYMMDD-XXXX)
date_str = receipt_date.replace('-', '')
cursor.execute("""
SELECT MAX(CAST(SUBSTR(receipt_no, -4) AS INTEGER))
FROM purchase_receipts WHERE receipt_no LIKE ?
""", (f'PR-{date_str}-%',))
max_num = cursor.fetchone()[0]
receipt_no = f"PR-{date_str}-{(max_num or 0) + 1:04d}"
# 총 금액 계산
total_amount = sum(
float(line.get('qty', 0)) * float(line.get('unit_price', 0))
for line in lines
)
# 입고장 헤더
cursor.execute("""
INSERT INTO purchase_receipts (supplier_id, receipt_date, receipt_no, total_amount, source_file, notes)
VALUES (?, ?, ?, ?, 'CART', ?)
""", (supplier_id, receipt_date, receipt_no, total_amount, notes))
receipt_id = cursor.lastrowid
processed_count = 0
for line in lines:
standard_code = line.get('standard_code')
product_name = line.get('product_name', '')
company_name = line.get('company_name', '')
spec_grams = float(line.get('spec_grams', 0))
qty = int(line.get('qty', 0))
unit_price = float(line.get('unit_price', 0))
origin_country = line.get('origin_country', '')
if not standard_code or qty <= 0 or unit_price <= 0:
continue
# g 환산
quantity_g = spec_grams * qty if spec_grams > 0 else 0
unit_price_per_g = unit_price / spec_grams if spec_grams > 0 else 0
line_total = qty * unit_price
# herb_items에서 standard_code로 조회
cursor.execute("SELECT herb_item_id FROM herb_items WHERE standard_code = ?", (standard_code,))
herb = cursor.fetchone()
if not herb:
# 새 herb_item 자동 생성 (OTC)
cursor.execute("""
INSERT INTO herb_items (herb_name, specification, product_type, standard_code)
VALUES (?, ?, 'OTC', ?)
""", (product_name, company_name, standard_code))
herb_item_id = cursor.lastrowid
else:
herb_item_id = herb[0]
# 입고장 라인
cursor.execute("""
INSERT INTO purchase_receipt_lines
(receipt_id, herb_item_id, origin_country, quantity_g, unit_price_per_g, line_total)
VALUES (?, ?, ?, ?, ?, ?)
""", (receipt_id, herb_item_id, origin_country, quantity_g, unit_price_per_g, line_total))
line_id = cursor.lastrowid
# 재고 로트 생성
cursor.execute("""
INSERT INTO inventory_lots
(herb_item_id, supplier_id, receipt_line_id, received_date, origin_country,
unit_price_per_g, quantity_received, quantity_onhand)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
""", (herb_item_id, supplier_id, line_id, receipt_date,
origin_country, unit_price_per_g, quantity_g, quantity_g))
lot_id = cursor.lastrowid
# 재고 원장
cursor.execute("""
INSERT INTO stock_ledger
(event_type, herb_item_id, lot_id, quantity_delta, unit_cost_per_g,
reference_table, reference_id)
VALUES ('RECEIPT', ?, ?, ?, ?, 'purchase_receipts', ?)
""", (herb_item_id, lot_id, quantity_g, unit_price_per_g, receipt_id))
processed_count += 1
return jsonify({
'success': True,
'message': '입고가 완료되었습니다.',
'receipt_no': receipt_no,
'summary': {
'item_count': processed_count,
'total_amount': f"{total_amount:,.0f}"
}
})
except Exception as e:
return jsonify({'success': False, 'error': str(e)}), 500
# ==================== 입고장 조회/관리 API ==================== # ==================== 입고장 조회/관리 API ====================
@app.route('/api/purchase-receipts', methods=['GET']) @app.route('/api/purchase-receipts', methods=['GET'])
@@ -1393,6 +1537,8 @@ def get_purchase_receipt_detail(receipt_id):
h.herb_name, h.herb_name,
h.insurance_code, h.insurance_code,
h.ingredient_code, h.ingredient_code,
h.product_type,
h.standard_code,
il.lot_id, il.lot_id,
il.quantity_onhand as current_stock, il.quantity_onhand as current_stock,
il.display_name, il.display_name,
@@ -1771,7 +1917,8 @@ def get_compounds():
c.status, c.status,
c.notes, c.notes,
c.created_at, c.created_at,
c.created_by c.created_by,
c.usage_type
FROM compounds c FROM compounds c
LEFT JOIN patients p ON c.patient_id = p.patient_id LEFT JOIN patients p ON c.patient_id = p.patient_id
LEFT JOIN formulas f ON c.formula_id = f.formula_id LEFT JOIN formulas f ON c.formula_id = f.formula_id
@@ -2002,13 +2149,17 @@ def create_compound():
custom_summary = " | ".join(summary_parts) if summary_parts else "" custom_summary = " | ".join(summary_parts) if summary_parts else ""
# 용도 구분 (SALE: 판매, SELF_USE: 자가소비, SAMPLE: 샘플, DISPOSAL: 폐기)
usage_type = data.get('usage_type', 'SALE')
# 조제 마스터 생성 (커스텀 정보 포함) # 조제 마스터 생성 (커스텀 정보 포함)
cursor.execute(""" cursor.execute("""
INSERT INTO compounds (patient_id, formula_id, compound_date, INSERT INTO compounds (patient_id, formula_id, compound_date,
je_count, cheop_total, pouch_total, je_count, cheop_total, pouch_total,
prescription_no, notes, created_by, prescription_no, notes, created_by,
is_custom, custom_summary, custom_type) is_custom, custom_summary, custom_type,
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) usage_type, sell_price_total)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""", ( """, (
data.get('patient_id'), data.get('patient_id'),
formula_id, formula_id,
@@ -2021,7 +2172,9 @@ def create_compound():
data.get('created_by', 'system'), data.get('created_by', 'system'),
1 if is_custom else 0, 1 if is_custom else 0,
custom_summary if is_custom else None, 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 compound_id = cursor.lastrowid
@@ -2447,6 +2600,8 @@ def get_inventory_summary():
h.insurance_code, h.insurance_code,
h.herb_name, h.herb_name,
h.ingredient_code, h.ingredient_code,
h.product_type,
h.standard_code,
COALESCE(SUM(il.quantity_onhand), 0) as total_quantity, COALESCE(SUM(il.quantity_onhand), 0) as total_quantity,
COUNT(DISTINCT il.lot_id) as lot_count, COUNT(DISTINCT il.lot_id) as lot_count,
COUNT(DISTINCT il.origin_country) as origin_count, COUNT(DISTINCT il.origin_country) as origin_count,
@@ -2456,7 +2611,7 @@ def get_inventory_summary():
COALESCE(SUM(il.quantity_onhand * il.unit_price_per_g), 0) as total_value COALESCE(SUM(il.quantity_onhand * il.unit_price_per_g), 0) as total_value
FROM herb_items h FROM herb_items h
LEFT JOIN inventory_lots il ON h.herb_item_id = il.herb_item_id AND {where_clause} LEFT JOIN inventory_lots il ON h.herb_item_id = il.herb_item_id AND {where_clause}
GROUP BY h.herb_item_id, h.insurance_code, h.herb_name, h.ingredient_code GROUP BY h.herb_item_id, h.insurance_code, h.herb_name, h.ingredient_code, h.product_type, h.standard_code
HAVING total_quantity > 0 HAVING total_quantity > 0
ORDER BY h.herb_name ORDER BY h.herb_name
""") """)
@@ -2575,7 +2730,7 @@ def get_inventory_detail(herb_item_id):
# 약재 기본 정보 # 약재 기본 정보
cursor.execute(""" cursor.execute("""
SELECT herb_item_id, insurance_code, herb_name SELECT herb_item_id, insurance_code, herb_name, product_type, standard_code
FROM herb_items FROM herb_items
WHERE herb_item_id = ? WHERE herb_item_id = ?
""", (herb_item_id,)) """, (herb_item_id,))
@@ -2699,6 +2854,8 @@ def get_stock_adjustment_detail(adjustment_id):
sad.*, sad.*,
h.herb_name, h.herb_name,
h.insurance_code, h.insurance_code,
h.product_type,
h.standard_code,
il.origin_country, il.origin_country,
s.name as supplier_name s.name as supplier_name
FROM stock_adjustment_details sad FROM stock_adjustment_details sad
@@ -3471,6 +3628,50 @@ def update_compound_status(compound_id):
except Exception as e: except Exception as e:
return jsonify({'error': str(e)}), 500 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']) @app.route('/api/compounds/<int:compound_id>/price', methods=['PUT'])
def update_compound_price(compound_id): def update_compound_price(compound_id):
"""조제 가격 조정 (복합결제 지원)""" """조제 가격 조정 (복합결제 지원)"""
@@ -3591,6 +3792,7 @@ def get_sales_statistics():
COUNT(CASE WHEN status = 'REFUNDED' THEN 1 END) as refunded_count COUNT(CASE WHEN status = 'REFUNDED' THEN 1 END) as refunded_count
FROM compounds FROM compounds
WHERE status IN ('PAID', 'PENDING_DELIVERY', 'DELIVERED', 'COMPLETED') WHERE status IN ('PAID', 'PENDING_DELIVERY', 'DELIVERED', 'COMPLETED')
AND COALESCE(usage_type, 'SALE') = 'SALE'
""" """
params = [] params = []
@@ -3612,6 +3814,7 @@ def get_sales_statistics():
SUM(COALESCE(actual_payment_amount, sell_price_total)) as daily_total SUM(COALESCE(actual_payment_amount, sell_price_total)) as daily_total
FROM compounds FROM compounds
WHERE status IN ('PAID', 'PENDING_DELIVERY', 'DELIVERED', 'COMPLETED') WHERE status IN ('PAID', 'PENDING_DELIVERY', 'DELIVERED', 'COMPLETED')
AND COALESCE(usage_type, 'SALE') = 'SALE'
""" """
if start_date: if start_date:
@@ -3639,6 +3842,7 @@ def get_sales_statistics():
FROM compounds c FROM compounds c
LEFT JOIN formulas f ON c.formula_id = f.formula_id LEFT JOIN formulas f ON c.formula_id = f.formula_id
WHERE c.status IN ('PAID', 'PENDING_DELIVERY', 'DELIVERED', 'COMPLETED') WHERE c.status IN ('PAID', 'PENDING_DELIVERY', 'DELIVERED', 'COMPLETED')
AND COALESCE(c.usage_type, 'SALE') = 'SALE'
""" """
if start_date: if start_date:
@@ -3705,6 +3909,78 @@ def convert_to_otc(compound_id):
except Exception as e: except Exception as e:
return jsonify({'error': str(e)}), 500 return jsonify({'error': str(e)}), 500
# ==================== 의약품 마스터 검색 API ====================
@app.route('/api/medicine-master/search', methods=['GET'])
def search_medicine_master():
"""의약품 마스터 검색 (medicine_master.db ATTACH 활용)"""
try:
q = request.args.get('q', '').strip()
category = request.args.get('category', '') # 전문일반구분 필터
package_type = request.args.get('package_type', '') # 포장형태 필터
limit = min(int(request.args.get('limit', 50)), 200)
if not q or len(q) < 2:
return jsonify({'success': False, 'error': '검색어는 2자 이상 입력하세요'}), 400
with get_db() as conn:
cursor = conn.cursor()
where_clauses = ["(m.product_name LIKE ? OR m.company_name LIKE ? OR m.standard_code LIKE ? OR m.notes LIKE ?)"]
params = [f'%{q}%', f'%{q}%', f'%{q}%', f'%{q}%']
if category:
where_clauses.append("m.category = ?")
params.append(category)
if package_type:
where_clauses.append("m.package_type = ?")
params.append(package_type)
# 취소된 제품 제외
where_clauses.append("(m.cancel_date IS NULL OR m.cancel_date = '')")
params.append(limit)
cursor.execute(f"""
SELECT m.id, m.product_name, m.company_name, m.spec,
m.total_quantity, m.form_type, m.package_type,
m.item_std_code, m.category, m.representative_code,
m.standard_code, m.ingredient_name_code, m.notes,
m.atc_code
FROM med_master.medicine_master m
WHERE {' AND '.join(where_clauses)}
ORDER BY m.product_name, m.spec
LIMIT ?
""", params)
results = [dict(row) for row in cursor.fetchall()]
return jsonify({'success': True, 'data': results, 'count': len(results)})
except Exception as e:
return jsonify({'success': False, 'error': str(e)}), 500
@app.route('/api/medicine-master/categories', methods=['GET'])
def get_medicine_categories():
"""의약품 마스터 카테고리 목록 (전문일반구분)"""
try:
with get_db() as conn:
cursor = conn.cursor()
cursor.execute("""
SELECT category, COUNT(*) as cnt
FROM med_master.medicine_master
WHERE category != '' AND (cancel_date IS NULL OR cancel_date = '')
GROUP BY category
ORDER BY cnt DESC
""")
categories = [{'name': row['category'], 'count': row['cnt']} for row in cursor.fetchall()]
return jsonify({'success': True, 'data': categories})
except Exception as e:
return jsonify({'success': False, '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']):

View File

@@ -12,6 +12,7 @@ PROJECT_ROOT = Path(__file__).parent
# 데이터베이스 경로 - 항상 절대 경로 사용 # 데이터베이스 경로 - 항상 절대 경로 사용
DATABASE_PATH = PROJECT_ROOT / 'database' / 'kdrug.db' DATABASE_PATH = PROJECT_ROOT / 'database' / 'kdrug.db'
MEDICINE_MASTER_PATH = PROJECT_ROOT / 'database' / 'medicine_master.db'
# 기타 자주 사용하는 경로들 # 기타 자주 사용하는 경로들
STATIC_PATH = PROJECT_ROOT / 'static' STATIC_PATH = PROJECT_ROOT / 'static'

View File

@@ -0,0 +1,258 @@
-- 한약 재고관리 시스템 데이터베이스 스키마
-- SQLite 기준
-- 1) 도매상/공급업체
CREATE TABLE IF NOT EXISTS suppliers (
supplier_id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
business_no TEXT,
contact_person TEXT,
phone TEXT,
address TEXT,
is_active INTEGER DEFAULT 1,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
-- 2) 약재 마스터 (보험코드 9자리 기준)
CREATE TABLE IF NOT EXISTS herb_items (
herb_item_id INTEGER PRIMARY KEY AUTOINCREMENT,
insurance_code TEXT UNIQUE, -- 보험코드 (9자리)
herb_name TEXT NOT NULL, -- 약재명
specification TEXT, -- 규격/품질
default_unit TEXT DEFAULT 'g', -- 기본 단위
is_active INTEGER DEFAULT 1,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
-- 3) 환자 정보
CREATE TABLE IF NOT EXISTS patients (
patient_id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
phone TEXT NOT NULL,
jumin_no TEXT, -- 주민번호 (암호화 필요)
gender TEXT CHECK(gender IN ('M', 'F')),
birth_date DATE,
address TEXT,
notes TEXT,
is_active INTEGER DEFAULT 1,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE(phone, name)
);
-- 4) 입고장 헤더
CREATE TABLE IF NOT EXISTS purchase_receipts (
receipt_id INTEGER PRIMARY KEY AUTOINCREMENT,
supplier_id INTEGER NOT NULL,
receipt_date DATE NOT NULL,
receipt_no TEXT, -- 입고 번호/전표번호
vat_included INTEGER DEFAULT 1, -- 부가세 포함 여부
vat_rate REAL DEFAULT 0.10, -- 부가세율
total_amount REAL, -- 총 입고액
source_file TEXT, -- Excel 파일명
notes TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (supplier_id) REFERENCES suppliers(supplier_id)
);
-- 5) 입고장 상세 라인
CREATE TABLE IF NOT EXISTS purchase_receipt_lines (
line_id INTEGER PRIMARY KEY AUTOINCREMENT,
receipt_id INTEGER NOT NULL,
herb_item_id INTEGER NOT NULL,
origin_country TEXT, -- 원산지
quantity_g REAL NOT NULL, -- 구입량(g)
unit_price_per_g REAL NOT NULL, -- g당 단가 (VAT 포함)
line_total REAL, -- 라인 총액
expiry_date DATE, -- 유효기간
lot_number TEXT, -- 로트번호
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (receipt_id) REFERENCES purchase_receipts(receipt_id),
FOREIGN KEY (herb_item_id) REFERENCES herb_items(herb_item_id)
);
-- 6) 재고 로트 (입고 라인별 재고 관리)
CREATE TABLE IF NOT EXISTS inventory_lots (
lot_id INTEGER PRIMARY KEY AUTOINCREMENT,
herb_item_id INTEGER NOT NULL,
supplier_id INTEGER NOT NULL,
receipt_line_id INTEGER NOT NULL,
received_date DATE NOT NULL,
origin_country TEXT,
unit_price_per_g REAL NOT NULL,
quantity_received REAL NOT NULL, -- 입고 수량
quantity_onhand REAL NOT NULL, -- 현재 재고
expiry_date DATE,
lot_number TEXT,
is_depleted INTEGER DEFAULT 0, -- 소진 여부
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (herb_item_id) REFERENCES herb_items(herb_item_id),
FOREIGN KEY (supplier_id) REFERENCES suppliers(supplier_id),
FOREIGN KEY (receipt_line_id) REFERENCES purchase_receipt_lines(line_id)
);
-- 7) 재고 원장 (모든 재고 변동 기록)
CREATE TABLE IF NOT EXISTS stock_ledger (
ledger_id INTEGER PRIMARY KEY AUTOINCREMENT,
event_time DATETIME DEFAULT CURRENT_TIMESTAMP,
event_type TEXT NOT NULL CHECK(event_type IN ('RECEIPT', 'CONSUME', 'ADJUST', 'DISCARD', 'RETURN')),
herb_item_id INTEGER NOT NULL,
lot_id INTEGER,
quantity_delta REAL NOT NULL, -- 증감량 (+입고, -사용)
unit_cost_per_g REAL,
reference_table TEXT, -- 참조 테이블 (compounds, adjustments 등)
reference_id INTEGER, -- 참조 ID
notes TEXT,
created_by TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (herb_item_id) REFERENCES herb_items(herb_item_id),
FOREIGN KEY (lot_id) REFERENCES inventory_lots(lot_id)
);
-- 8) 처방 마스터 (약속 처방)
-- 8-1) 100처방 원방 마스터
CREATE TABLE IF NOT EXISTS official_formulas (
official_formula_id INTEGER PRIMARY KEY AUTOINCREMENT,
formula_number INTEGER NOT NULL UNIQUE, -- 연번 (1~100)
formula_name TEXT NOT NULL, -- 처방명 (예: 쌍화탕)
formula_name_hanja TEXT, -- 한자명 (예: 雙和湯)
source_text TEXT, -- 출전 (예: 화제국방)
description TEXT, -- 설명/효능
reference_notes TEXT, -- 상담참고자료 (OTC 대비 차별점, 구성 해설, 업셀링 포인트 등)
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
-- 8-2) 100처방 원방 구성 약재 (성분코드 기반)
CREATE TABLE IF NOT EXISTS official_formula_ingredients (
ingredient_id INTEGER PRIMARY KEY AUTOINCREMENT,
official_formula_id INTEGER NOT NULL,
ingredient_code TEXT NOT NULL, -- herb_masters.ingredient_code 기준
grams_per_cheop REAL NOT NULL, -- 1첩당 그램수
notes TEXT, -- 역할 (예: 군약, 신약, 좌약, 사약)
sort_order INTEGER DEFAULT 0,
FOREIGN KEY (official_formula_id) REFERENCES official_formulas(official_formula_id),
UNIQUE (official_formula_id, ingredient_code)
);
-- 8-3) 운영 처방 (조제에 사용)
CREATE TABLE IF NOT EXISTS formulas (
formula_id INTEGER PRIMARY KEY AUTOINCREMENT,
formula_code TEXT UNIQUE, -- 처방 코드
formula_name TEXT NOT NULL, -- 처방명 (예: 쌍화탕)
formula_type TEXT DEFAULT 'CUSTOM', -- CUSTOM(약속처방), STANDARD 등
official_formula_id INTEGER, -- 100처방 마스터 참조 (원방 기반인 경우)
base_cheop INTEGER DEFAULT 20, -- 기본 첩수 (1제 기준)
base_pouches INTEGER DEFAULT 30, -- 기본 파우치수 (1제 기준)
description TEXT,
is_active INTEGER DEFAULT 1,
created_by TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (official_formula_id) REFERENCES official_formulas(official_formula_id)
);
-- 9) 처방 구성 약재
CREATE TABLE IF NOT EXISTS formula_ingredients (
ingredient_id INTEGER PRIMARY KEY AUTOINCREMENT,
formula_id INTEGER NOT NULL,
herb_item_id INTEGER NOT NULL,
grams_per_cheop REAL NOT NULL, -- 1첩당 그램수
notes TEXT,
sort_order INTEGER DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (formula_id) REFERENCES formulas(formula_id),
FOREIGN KEY (herb_item_id) REFERENCES herb_items(herb_item_id),
UNIQUE (formula_id, herb_item_id)
);
-- 10) 조제 작업 (처방 실행)
CREATE TABLE IF NOT EXISTS compounds (
compound_id INTEGER PRIMARY KEY AUTOINCREMENT,
patient_id INTEGER,
formula_id INTEGER,
compound_date DATE NOT NULL,
je_count REAL NOT NULL, -- 제수 (1제, 0.5제 등)
cheop_total REAL NOT NULL, -- 총 첩수
pouch_total REAL NOT NULL, -- 총 파우치수
cost_total REAL, -- 원가 총액
sell_price_total REAL, -- 판매 총액
prescription_no TEXT, -- 처방전 번호
status TEXT DEFAULT 'PREPARED', -- PREPARED, DISPENSED, CANCELLED
notes TEXT,
created_by TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (patient_id) REFERENCES patients(patient_id),
FOREIGN KEY (formula_id) REFERENCES formulas(formula_id)
);
-- 11) 조제 약재 구성 (실제 조제시 사용된 약재 - 가감 포함)
CREATE TABLE IF NOT EXISTS compound_ingredients (
compound_ingredient_id INTEGER PRIMARY KEY AUTOINCREMENT,
compound_id INTEGER NOT NULL,
herb_item_id INTEGER NOT NULL,
grams_per_cheop REAL NOT NULL, -- 1첩당 그램수 (가감 반영)
total_grams REAL NOT NULL, -- 총 사용량
notes TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (compound_id) REFERENCES compounds(compound_id),
FOREIGN KEY (herb_item_id) REFERENCES herb_items(herb_item_id)
);
-- 12) 조제 소비 내역 (로트별 차감)
CREATE TABLE IF NOT EXISTS compound_consumptions (
consumption_id INTEGER PRIMARY KEY AUTOINCREMENT,
compound_id INTEGER NOT NULL,
herb_item_id INTEGER NOT NULL,
lot_id INTEGER NOT NULL,
quantity_used REAL NOT NULL, -- 사용량(g)
unit_cost_per_g REAL NOT NULL, -- 단가
cost_amount REAL, -- 원가액
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (compound_id) REFERENCES compounds(compound_id),
FOREIGN KEY (herb_item_id) REFERENCES herb_items(herb_item_id),
FOREIGN KEY (lot_id) REFERENCES inventory_lots(lot_id)
);
-- 인덱스 생성
CREATE INDEX IF NOT EXISTS idx_herb_items_name ON herb_items(herb_name);
CREATE INDEX IF NOT EXISTS idx_herb_items_code ON herb_items(insurance_code);
CREATE INDEX IF NOT EXISTS idx_inventory_lots_herb ON inventory_lots(herb_item_id, is_depleted);
CREATE INDEX IF NOT EXISTS idx_stock_ledger_herb ON stock_ledger(herb_item_id, event_time);
CREATE INDEX IF NOT EXISTS idx_compounds_patient ON compounds(patient_id);
CREATE INDEX IF NOT EXISTS idx_compounds_date ON compounds(compound_date);
CREATE INDEX IF NOT EXISTS idx_patients_phone ON patients(phone);
-- 뷰 생성 (자주 사용되는 조회)
-- 현재 재고 현황
CREATE VIEW IF NOT EXISTS v_current_stock AS
SELECT
h.herb_item_id,
h.insurance_code,
h.herb_name,
SUM(il.quantity_onhand) as total_quantity,
COUNT(DISTINCT il.lot_id) as lot_count,
AVG(il.unit_price_per_g) as avg_unit_price
FROM herb_items h
LEFT JOIN inventory_lots il ON h.herb_item_id = il.herb_item_id AND il.is_depleted = 0
GROUP BY h.herb_item_id, h.insurance_code, h.herb_name;
-- 처방별 구성 약재 뷰
CREATE VIEW IF NOT EXISTS v_formula_details AS
SELECT
f.formula_id,
f.formula_name,
f.formula_code,
h.herb_name,
fi.grams_per_cheop,
h.insurance_code
FROM formulas f
JOIN formula_ingredients fi ON f.formula_id = fi.formula_id
JOIN herb_items h ON fi.herb_item_id = h.herb_item_id
WHERE f.is_active = 1
ORDER BY f.formula_id, fi.sort_order;

View File

@@ -0,0 +1,124 @@
# DB 매핑 구조: 의약품 마스터 ↔ 한약재 코드 테이블
> 의약품 마스터(medicine_master.db)와 기존 한약재 테이블(kdrug.db)은
> **`representative_code`(대표코드)** 로 연결된다.
---
## 1. 테이블 관계도
```
herb_masters (454개 약재, 공통 성분 마스터)
│ ingredient_code (PK) 예: 3033H1AHM = 계지
├─→ herb_products (53,769건, 제품별 코드)
│ ingredient_code (FK)
│ representative_code ← 매핑 키
│ standard_code
│ product_code (보험코드 9자리)
└─→ herb_items (29건, 우리 약국 보유 약재)
ingredient_code (FK)
insurance_code
herb_name 예: "휴먼계지"
medicine_master.db (305,522건, 의약품표준코드 마스터)
│ representative_code ← 매핑 키
│ standard_code
│ item_std_code (품목기준코드)
│ product_name, company_name, spec, category, ...
```
---
## 2. 매핑 키: `representative_code` (대표코드)
| 구분 | herb_products | medicine_master |
|------|:---:|:---:|
| 대표코드 수 | 17,356 | 13,771 (한약재) |
| **매칭 수** | **13,188 (95.8%)** | |
| 표준코드 수 | 53,769 | - |
| 표준코드 매칭 | **40,364 (75.1%)** | |
- 풀체인 매칭 약재: **447 / 454개 (98.5%)**
- medicine_master에만 있는 한약재 대표코드: 6개 (무시 가능)
- herb_products에만 있는 대표코드: 4,168개 (취소/폐지 제품 등)
---
## 3. 매핑 예시: 계지
```sql
-- herb_masters: 공통명
ingredient_code = '3033H1AHM', herb_name = '계지'
-- herb_products: 제품들 (57개 제조사)
rep: 8800624003904 std: 8800624003911 (500g)
rep: 8800628002200 std: 8800628002224 (500g)
rep: 8800640000505 std: 8800640000512 (500g)
...
-- medicine_master: 동일 대표코드로 매칭
rep: 8800624003904 item_std: 200406389 category:
rep: 8800628002200 item_std: 200406525 category:
...
```
---
## 4. 매핑 SQL
### 특정 약재의 전체 제품 + 의약품 마스터 정보 조회
```sql
SELECT hm.herb_name AS ,
hp.product_name, hp.company_name,
hp.representative_code, hp.standard_code,
mm.spec, mm.form_type, mm.package_type,
mm.item_std_code, mm.category
FROM herb_masters hm
JOIN herb_products hp ON hp.ingredient_code = hm.ingredient_code
LEFT JOIN med_master.medicine_master mm
ON mm.representative_code = hp.representative_code
AND (mm.cancel_date IS NULL OR mm.cancel_date = '')
WHERE hm.herb_name = '계지';
```
### herb_items(보유 약재) → 의약품 마스터 연결
```sql
SELECT hi.herb_name, hi.ingredient_code,
mm.item_std_code, mm.representative_code, mm.standard_code,
mm.spec, mm.package_type
FROM herb_items hi
JOIN herb_products hp ON hp.ingredient_code = hi.ingredient_code
JOIN med_master.medicine_master mm
ON mm.representative_code = hp.representative_code
AND mm.product_name = hi.herb_name
WHERE mm.category = '한약재'
AND (mm.cancel_date IS NULL OR mm.cancel_date = '');
```
---
## 5. 코드 체계 정리
| 코드 | 출처 | 예시 | 설명 |
|------|------|------|------|
| `ingredient_code` | herb_masters | 3033H1AHM | 성분코드 (약재 공통) |
| `product_code` | herb_products | 062400390 | 보험 제품코드 9자리 |
| `representative_code` | 양쪽 | 8800624003904 | **대표 바코드 (매핑 키)** |
| `standard_code` | 양쪽 | 8800624003911 | 규격별 바코드 |
| `item_std_code` | medicine_master | 200406389 | 식약처 품목기준코드 |
| `insurance_code` | herb_items | 062400390 | = product_code와 동일 체계 |
---
## 6. 활용 가능 시나리오
1. **바코드 스캔 입고**: 제품 바코드(standard_code) → herb_products → ingredient_code → herb_items 자동 매칭
2. **제품 비교**: 같은 ingredient_code의 다른 업체 제품 가격/규격 비교
3. **마스터 검색 → 입고 연동**: 의약품 마스터에서 검색 → representative_code로 herb_products 조회 → 입고 등록
4. **유효성 검증**: 취소(cancel_date) 제품 식별, 현행 유통 제품만 필터링
---
*최종 수정: 2026-02-19*

View File

@@ -0,0 +1,72 @@
# 경방신약 주문 매핑 결과 (2026-02-19)
## 조건
- 제약사: 경방신약(주)
- 규격: 500그램
- 포장: 병/통
- DB: medicine_master (의약품 표준코드)
## 매핑 결과: 38/38건 전체 매칭 완료 (한방 OTC 37건 + 일반 OTC 1건)
### 한방 OTC (37건) — 소계 1,531,300원
| # | 주문 약품명 | 수량 | 단가(원) | 매칭 제품명 | 표준코드 | 비고 |
|---|-----------|------|---------|-----------|---------|------|
| 1 | 가미귀비탕 | 1 | 53,400 | 진경안신엑스과립(가미귀비탕엑스과립) | 8806613031439 | 500g 통 (수동매핑) |
| 2 | 가미소요산 | 1 | 35,000 | 경방가미소요산엑스과립 | 8806613001012 | |
| 3 | 가미온담탕 | 1 | 69,600 | 자양심간탕엑스과립(가미온담탕) | 8806613030616 | |
| 4 | 갈근탕 | 1 | 27,700 | 치감엑스과립(갈근탕엑스과립) | 8806613032337 | |
| 5 | 계지복령환 | 1 | 35,000 | 모시나엑스과립(계지복령환) | 8806613023816 | |
| 6 | 곽향정기산 | 1 | 31,800 | 씨즌쿨엑스과립(곽향정기산) | 8806613027531 | |
| 7 | 당귀작약산 | 1 | 36,200 | 경방당귀작약산엑스과립 | 8806613004716 | |
| 8 | 대시호탕 | 1 | 37,300 | 경방대시호탕엑스과립 | 8806613004914 | |
| 9 | 마행감석탕 | 1 | 28,500 | 사브엑스과립(마행감석탕) | 8806613025438 | |
| 10 | 맥문동탕 | 1 | 35,800 | 윤폐탕엑스과립(맥문동탕엑스과립) | 8806613030234 | |
| 11 | 반하백출천마탕 | 1 | 57,200 | 경방반하백출천마탕엑스과립 | 8806613007717 | |
| 12 | 반하사심탕 | 1 | 49,000 | 스토마큐(반하사심탕엑스과립) | 8806613026541 | |
| 13 | 방풍통성산 | 1 | 33,600 | 경방방풍통성산엑스과립 | 8806613008219 | |
| 14 | 배농산급탕 | 1 | 26,700 | 오메가엑스과립(배농산급탕) | 8806613028439 | |
| 15 | 보중익기탕 | 1 | 58,800 | 경방보중익기탕엑스과립 | 8806613008912 | |
| 16 | 사물탕 | 1 | 35,000 | 경방사물탕엑스과립 | 8806613009612 | |
| 17 | 소건중탕 | 1 | 25,000 | 포키드엑스과립(소건중탕) | 8806613033617 | |
| 18 | 소경활혈탕 | 1 | 40,300 | 경방소경활혈탕엑스과립 | 8806613011110 | |
| 19 | 소시호탕 | 1 | 55,000 | 정해탕(소시호탕엑스과립) | 8806613030937 | |
| 20 | 소청룡탕 | 1 | 41,400 | 소폐탕엑스과립(소청룡탕) | 8806613026138 | 500g 통 (수동매핑) |
| 21 | 시함탕 | 1 | 49,800 | 폐활탕엑스과립(시함탕) | 8806613033433 | |
| 22 | 시호가용골모려탕 | 1 | 46,200 | 브아피엑스과립(시호가용골모려탕엑스) | 8806613024738 | |
| 23 | 십미패독산 | 1 | 48,700 | 해스킨엑스과립(십미패독탕) | 8806613034430 | 산→탕 명칭차이 (수동매핑) |
| 24 | 오령산 | 1 | 37,200 | 자리투엑스과립(오령산엑스과립) | 8806613030531 | |
| 25 | 오적산 | 1 | 50,400 | 경방오적산엑스과립 | 8806613014814 | |
| 26 | 용담사간탕 | 1 | 37,100 | 용패탕(용담사간탕엑스과립) | 8806613029436 | |
| 27 | 월비가출탕 | 1 | 40,400 | 마이진탕엑스과립(월비가출탕) | 8806613023618 | |
| 28 | 육미지황탕 | 1 | 40,300 | 경방육미지황탕엑스과립 | 8806613015613 | |
| 29 | 인삼패독산 | 1 | 52,300 | 가쁘레엑스과립(인삼패독산) | 8806613000138 | 500g 통 (수동매핑) |
| 30 | 자음강화탕 | 1 | 41,500 | 자활탕(자음강화탕엑스과립) | 8806613030715 | |
| 31 | 저령탕 | 1 | 36,700 | 바디스엑스과립(저령탕엑스) | 8806613024332 | |
| 32 | 청화보음탕 | 1 | 34,200 | 디휘버엑스과립(청화보음탕) | 8806613023038 | |
| 33 | 팔미지황환 | 1 | 41,700 | 경방팔미지황환엑스과립 | 8806613019710 | |
| 34 | 평위산 | 1 | 28,000 | 위제나과립(평위산엑스과립) | 8806613029818 | |
| 35 | 향사육군자탕 | 1 | 58,400 | 스토비엑스과립(향사육군자탕) | 8806613026817 | |
| 36 | 형개연교탕 | 1 | 35,600 | 노넥스엑스과립(형개연교탕) | 8806613022314 | |
| 37 | 황련해독탕 | 1 | 40,500 | 오브스과립(황련해독탕엑스과립) | 8806613028613 | |
### 일반 OTC (1건) — 소계 30,500원
| # | 주문 약품명 | 수량 | 단가(원) | 매칭 제품명 | 표준코드 | 비고 |
|---|-----------|------|---------|-----------|---------|------|
| 1 | 미소그린에스과립 | 1 | 30,500 | 미소그린에스과립 | 8806613066615 | 420g 병. 한방 OTC 아님 |
### 총액: 1,561,800원 (한방 OTC 1,531,300 + 일반 OTC 30,500)
---
### 수동 매핑 처리 완료 (4건)
초기 자동매칭 시 누락되었으나, 수동 확인으로 전부 매칭 완료.
| # | 주문 약품명 | 매칭 제품명 | 표준코드 | 사유 |
|---|-----------|-----------|---------|------|
| 1 | 가미귀비탕 | 진경안신엑스과립(가미귀비탕엑스과립) | 8806613031439 | 500g 통. 브랜드명이 달라 자동매칭 실패 |
| 2 | 소청룡탕 | 소폐탕엑스과립(소청룡탕) | 8806613026138 | 500g 통. 포장이 "통"이라 "병" 필터에서 누락 |
| 3 | 십미패독산 | 해스킨엑스과립(십미패독탕) | 8806613034430 | 500g 병. DB에 "산"이 아닌 "탕"으로 등록 |
| 4 | 인삼패독산 | 가쁘레엑스과립(인삼패독산) | 8806613000138 | 500g 통. 포장이 "통"이라 "병" 필터에서 누락 |

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

@@ -0,0 +1,59 @@
약재명 보험코드 원산지 수량 단가 금액 현재고
휴먼갈근
갈근.각 062401050 한국 2500g ₩17 ₩42,000 2500g
휴먼감초
감초.1호[야생](1kg) 062400740 중국 5000g ₩22 ₩110,500 4610g
휴먼건강
건강 062400730 페루 5000g ₩12 ₩62,000 4880g
휴먼건강
건강.土 062400730 한국 1500g ₩51 ₩77,100 1390g
휴먼계지
계지 062400390 베트남 2500g ₩6 ₩14,500 2500g
휴먼구기자
구기자(영하)(1kg) 062400090 중국 3000g ₩18 ₩53,700 3000g
휴먼길경
길경.片[특] 062400980 중국 1500g ₩11 ₩15,900 1500g
휴먼대추
대추(절편)(1kg) 062401120 한국 5000g ₩20 ₩100,000 4650g
휴먼마황
마황(1kg) 062401400 중국 5000g ₩10 ₩48,000 4970g
휴먼반하생강백반제
반하생강백반제(1kg) 062401790 중국 3000g ₩34 ₩101,100 2850g
휴먼백출
백출.당[1kg] 062402150 중국 2000g ₩12 ₩23,600 1560g
휴먼복령
복령(1kg) 062402310 중국 5000g ₩12 ₩57,500 4760g
휴먼석고
석고[통포장](kg) 062402650 중국 4000g ₩5 ₩18,800 4000g
휴먼세신
세신.中 062402860 중국 1500g ₩129 ₩193,500 1500g
신흥숙지황
숙지황(9증)(신흥.1kg)[완] 060600050 중국 5000g ₩20 ₩100,000 4360g
휴먼오미자
오미자<토매지>(1kg) 062402800 중국 2000g ₩18 ₩35,000 2000g
휴먼용안육
용안육.名品(1kg) 062403120 태국 3000g ₩21 ₩62,100 3000g
휴먼육계
육계.YB 062403210 베트남 2500g ₩15 ₩36,500 2400g
휴먼일당귀
일당귀.中(1kg) 062403450 중국 5000g ₩13 ₩64,500 4640g
휴먼자소엽
자소엽.土 062403350 한국 1500g ₩14 ₩20,700 1410g
휴먼작약
작약(1kg) 062403380 한국 3000g ₩19 ₩56,100 2460g
휴먼작약주자
작약주자.土[酒炙] 062403470 한국 1500g ₩25 ₩36,900 1500g
휴먼전호
전호[재배] 062403490 중국 1500g ₩14 ₩21,000 1500g
휴먼지각
지각 062402220 중국 1500g ₩10 ₩15,000 1500g
휴먼지황
지황.건[](1kg) 062402030 중국 1000g ₩12 ₩11,500 1000g
휴먼진피(陳皮)
진피.비열[非熱](1kg) 062401950 한국 5000g ₩14 ₩68,500 5000g
휴먼창출
창출[북창출.재배](1kg) 062401830 중국 3000g ₩14 ₩40,500 3000g
휴먼천궁
천궁.일<토매지>(1kg) 062401810 중국 3000g ₩12 ₩35,700 2560g
휴먼황기
황기(직절.小)(1kg) 062400040 중국 3000g ₩10 ₩29,700 2500g

View File

@@ -36,6 +36,18 @@ class ExcelProcessor:
'비고': 'notes' '비고': 'notes'
} }
# 한퓨어 형식 컬럼 매핑
HANPURE_MAPPING = {
'상품명': 'herb_name',
'제조사코드': 'insurance_code',
'주성분코드': 'ingredient_code',
'제조사명': 'supplier_name',
'원산지': 'origin_country',
'소계': 'total_amount',
'옵션항목': 'option_detail',
'주문번호': 'order_number',
}
def __init__(self): def __init__(self):
self.format_type = None self.format_type = None
self.df_original = None self.df_original = None
@@ -45,6 +57,11 @@ class ExcelProcessor:
"""Excel 형식 자동 감지""" """Excel 형식 자동 감지"""
columns = df.columns.tolist() columns = df.columns.tolist()
# 한퓨어 형식 체크 (주문번호, 주성분코드, 제조사코드, 옵션항목이 특징)
hanpure_cols = ['주문번호', '주성분코드', '제조사코드', '상품명', '옵션항목']
if all(col in columns for col in hanpure_cols):
return 'hanpure'
# 한의사랑 형식 체크 # 한의사랑 형식 체크
hanisarang_cols = ['품목명', '제품코드', '일그램당단가', '총구입량', '총구입단가'] hanisarang_cols = ['품목명', '제품코드', '일그램당단가', '총구입량', '총구입단가']
if all(col in columns for col in hanisarang_cols): if all(col in columns for col in hanisarang_cols):
@@ -64,8 +81,10 @@ class ExcelProcessor:
def read_excel(self, file_path): def read_excel(self, file_path):
"""Excel 파일 읽기""" """Excel 파일 읽기"""
try: try:
# 제품코드를 문자열로 읽기 위한 dtype 설정 # 코드 컬럼을 문자열로 읽기 위한 dtype 설정
self.df_original = pd.read_excel(file_path, dtype={'제품코드': str}) self.df_original = pd.read_excel(file_path, dtype={
'제품코드': str, '제조사코드': str, '주성분코드': str, '대표코드': str
})
self.format_type = self.detect_format(self.df_original) self.format_type = self.detect_format(self.df_original)
return True return True
except Exception as e: except Exception as e:
@@ -137,12 +156,96 @@ class ExcelProcessor:
self.df_processed = df_mapped self.df_processed = df_mapped
return 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): def process(self):
"""형식에 따라 자동 처리""" """형식에 따라 자동 처리"""
if self.format_type == 'hanisarang': if self.format_type == 'hanisarang':
return self.process_hanisarang() return self.process_hanisarang()
elif self.format_type == 'haninfo': elif self.format_type == 'haninfo':
return self.process_haninfo() return self.process_haninfo()
elif self.format_type == 'hanpure':
return self.process_hanpure()
else: else:
raise ValueError(f"지원하지 않는 형식: {self.format_type}") raise ValueError(f"지원하지 않는 형식: {self.format_type}")
@@ -221,7 +324,8 @@ class ExcelProcessor:
standard_columns = [ standard_columns = [
'insurance_code', 'supplier_name', 'herb_name', 'insurance_code', 'supplier_name', 'herb_name',
'receipt_date', 'quantity', 'total_amount', 'receipt_date', 'quantity', 'total_amount',
'unit_price', 'origin_country', 'notes' 'unit_price', 'origin_country', 'notes',
'ingredient_code'
] ]
# 있는 컬럼만 선택 # 있는 컬럼만 선택

View File

@@ -116,6 +116,9 @@ $(document).ready(function() {
case 'herb-info': case 'herb-info':
loadHerbInfo(); loadHerbInfo();
break; break;
case 'medicine-master':
if (typeof loadMedicineMaster === 'function') loadMedicineMaster();
break;
} }
} }
@@ -141,10 +144,11 @@ $(document).ready(function() {
const todayCompounds = response.data.filter(c => c.compound_date === today); const todayCompounds = response.data.filter(c => c.compound_date === today);
$('#todayCompounds').text(todayCompounds.length); $('#todayCompounds').text(todayCompounds.length);
// 이번달 매출/마진 계산 // 이번달 매출/마진 계산 (자가소비/샘플/폐기 제외)
const monthData = response.data.filter(c => const monthData = response.data.filter(c =>
c.compound_date && c.compound_date.startsWith(currentMonth) && 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 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); const monthCost = monthData.reduce((sum, c) => sum + (c.cost_total || 0), 0);
@@ -1206,10 +1210,16 @@ $(document).ready(function() {
$('#compoundForm').show(); $('#compoundForm').show();
$('#compoundEntryForm')[0].reset(); $('#compoundEntryForm')[0].reset();
$('#compoundIngredients').empty(); $('#compoundIngredients').empty();
$('#costPreview').hide();
// 제수 기본값(1)으로 첩수/파우치 초기화
$('#jeCount').val(1);
$('#cheopTotal').val(20);
$('#pouchTotal').val(30);
}); });
$('#cancelCompoundBtn').on('click', function() { $('#cancelCompoundBtn').on('click', function() {
$('#compoundForm').hide(); $('#compoundForm').hide();
$('#costPreview').hide();
}); });
// 제수 변경 시 첩수 자동 계산 // 제수 변경 시 첩수 자동 계산
@@ -1229,12 +1239,20 @@ $(document).ready(function() {
$('#compoundFormula').on('change', function() { $('#compoundFormula').on('change', function() {
const formulaId = $(this).val(); const formulaId = $(this).val();
// 제수 기반 첩수/파우치 자동 계산 (초기값 반영)
const jeCount = parseFloat($('#jeCount').val()) || 0;
if (jeCount > 0 && !$('#cheopTotal').val()) {
$('#cheopTotal').val(jeCount * 20);
$('#pouchTotal').val(jeCount * 30);
}
// 원래 처방 구성 초기화 // 원래 처방 구성 초기화
originalFormulaIngredients = {}; originalFormulaIngredients = {};
$('#customPrescriptionBadge').remove(); // 커스텀 뱃지 제거 $('#customPrescriptionBadge').remove(); // 커스텀 뱃지 제거
if (!formulaId) { if (!formulaId) {
$('#compoundIngredients').empty(); $('#compoundIngredients').empty();
$('#costPreview').hide();
return; return;
} }
@@ -1363,6 +1381,7 @@ $(document).ready(function() {
}); });
// 약재별 총 용량 업데이트 // 약재별 총 용량 업데이트
let _stockCheckTimer = null;
function updateIngredientTotals() { function updateIngredientTotals() {
const cheopTotal = parseFloat($('#cheopTotal').val()) || 0; const cheopTotal = parseFloat($('#cheopTotal').val()) || 0;
@@ -1372,9 +1391,88 @@ $(document).ready(function() {
$(this).find('.total-grams').text(totalGrams.toFixed(1)); $(this).find('.total-grams').text(totalGrams.toFixed(1));
}); });
checkStockForCompound();
// 커스텀 처방 감지 호출 // 커스텀 처방 감지 호출
checkCustomPrescription(); checkCustomPrescription();
// 원가 미리보기 갱신 (즉시)
updateCostPreview();
// 재고 상태 갱신 (디바운스 300ms)
clearTimeout(_stockCheckTimer);
_stockCheckTimer = setTimeout(() => checkStockForCompound(), 300);
}
// 원가 미리보기 계산
function updateCostPreview() {
const rows = $('#compoundIngredients tr');
if (rows.length === 0) {
$('#costPreview').hide();
return;
}
const items = [];
let totalCost = 0;
let allHavePrice = true;
rows.each(function() {
// 약재명: 처방에서 로드된 행은 텍스트, 추가된 행은 select의 선택값
const firstTd = $(this).find('td:first');
const herbSelect = firstTd.find('.herb-select-compound');
const herbName = herbSelect.length > 0
? (herbSelect.find('option:selected').text().trim() || '미선택')
: firstTd.text().trim().split('(')[0].trim();
const totalGrams = parseFloat($(this).find('.total-grams').text()) || 0;
const originSelect = $(this).find('.origin-select');
const selectedOption = originSelect.find('option:selected');
const unitPrice = parseFloat(selectedOption.attr('data-price')) || 0;
// 수동 배분인 경우 data-lot-assignments에서 계산
const lotAssignmentsStr = $(this).attr('data-lot-assignments');
let itemCost = 0;
if (lotAssignmentsStr) {
try {
const assignments = JSON.parse(lotAssignmentsStr);
assignments.forEach(a => {
itemCost += (a.quantity || 0) * (a.unit_price || 0);
});
} catch(e) {
itemCost = totalGrams * unitPrice;
}
} else if (unitPrice > 0) {
itemCost = totalGrams * unitPrice;
} else {
allHavePrice = false;
}
totalCost += itemCost;
items.push({ name: herbName, grams: totalGrams, unitPrice, cost: itemCost });
});
// UI 렌더링
const tbody = $('#costPreviewItems');
tbody.empty();
items.forEach(item => {
const costText = item.cost > 0
? formatCurrency(Math.round(item.cost))
: '<span class="text-muted">-</span>';
const priceText = item.unitPrice > 0
? `${item.grams.toFixed(1)}g ×${item.unitPrice.toFixed(1)}`
: `${item.grams.toFixed(1)}g`;
tbody.append(`
<tr>
<td>${item.name} <small class="text-muted">${priceText}</small></td>
<td class="text-end">${costText}</td>
</tr>
`);
});
$('#costPreviewTotal').text(formatCurrency(Math.round(totalCost)));
const status = allHavePrice && items.length > 0
? '<span class="badge bg-success">확정</span>'
: '<span class="badge bg-warning text-dark">일부 미확정</span>';
$('#costPreviewStatus').html(status);
$('#costPreview').show();
} }
// 커스텀 처방 감지 함수 // 커스텀 처방 감지 함수
@@ -1451,13 +1549,30 @@ $(document).ready(function() {
// 재고 확인 // 재고 확인
function checkStockForCompound() { function checkStockForCompound() {
// 각 약재의 재고 상태를 API로 갱신 (기존 선택 보존)
$('#compoundIngredients tr').each(function() { $('#compoundIngredients tr').each(function() {
const herbId = $(this).data('herb-id'); const herbId = $(this).attr('data-herb-id');
if (!herbId) return;
const totalGrams = parseFloat($(this).find('.total-grams').text()) || 0; const totalGrams = parseFloat($(this).find('.total-grams').text()) || 0;
const currentSelection = $(this).find('.origin-select').val();
const $stockStatus = $(this).find('.stock-status'); const $stockStatus = $(this).find('.stock-status');
// TODO: API 호출로 실제 재고 확인 if (totalGrams > 0) {
$stockStatus.text('재고 확인 필요'); $.get(`/api/herbs/${herbId}/available-lots`, function(response) {
if (response.success) {
const totalAvailable = response.data.total_quantity;
const origins = response.data.origins;
const altCount = origins.length;
const altBadge = altCount > 1 ? ` <span class="badge bg-outline-info text-info border border-info" style="font-size:0.65rem">${altCount}종</span>` : '';
if (totalAvailable >= totalGrams) {
$stockStatus.html(`<span class="text-success">충분 (${totalAvailable.toFixed(1)}g)</span>${altBadge}`);
} else {
$stockStatus.html(`<span class="text-danger">부족 (${totalAvailable.toFixed(1)}g)</span>${altBadge}`);
}
}
});
}
}); });
} }
@@ -1627,6 +1742,7 @@ $(document).ready(function() {
je_count: parseFloat($('#jeCount').val()), je_count: parseFloat($('#jeCount').val()),
cheop_total: parseFloat($('#cheopTotal').val()), cheop_total: parseFloat($('#cheopTotal').val()),
pouch_total: parseFloat($('#pouchTotal').val()), pouch_total: parseFloat($('#pouchTotal').val()),
usage_type: $('#compoundUsageType').val() || 'SALE',
ingredients: ingredients ingredients: ingredients
}; };
@@ -1637,8 +1753,11 @@ $(document).ready(function() {
data: JSON.stringify(compoundData), data: JSON.stringify(compoundData),
success: function(response) { success: function(response) {
if (response.success) { 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(); $('#compoundForm').hide();
$('#compoundUsageType').val('SALE');
loadCompounds(); loadCompounds();
} }
}, },
@@ -1701,6 +1820,13 @@ $(document).ready(function() {
statusBadge = '<span class="badge bg-secondary">대기</span>'; 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 = $(` const row = $(`
<tr> <tr>
<td>${response.data.length - index}</td> <td>${response.data.length - index}</td>
@@ -1712,14 +1838,14 @@ $(document).ready(function() {
<td>${compound.cheop_total || 0}</td> <td>${compound.cheop_total || 0}</td>
<td>${compound.pouch_total || 0}</td> <td>${compound.pouch_total || 0}</td>
<td>${formatCurrency(compound.cost_total || 0)}</td> <td>${formatCurrency(compound.cost_total || 0)}</td>
<td>${formatCurrency(compound.sell_price_total || 0)}</td> <td>${isSale ? formatCurrency(compound.sell_price_total || 0) : '-'}</td>
<td>${statusBadge}</td> <td>${usageBadge} ${statusBadge}</td>
<td>${compound.prescription_no || '-'}</td> <td>${compound.prescription_no || '-'}</td>
<td> <td>
<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' ? ` ${compound.status === 'PREPARED' && isSale ? `
<button class="btn btn-sm btn-outline-success process-sale" data-id="${compound.compound_id}" <button class="btn btn-sm btn-outline-success process-sale" data-id="${compound.compound_id}"
data-formula="${compound.formula_name || '직접조제'}" data-formula="${compound.formula_name || '직접조제'}"
data-patient="${compound.patient_name || '직접조제'}" data-patient="${compound.patient_name || '직접조제'}"
@@ -1728,6 +1854,8 @@ $(document).ready(function() {
data-price="${compound.sell_price_total || 0}"> data-price="${compound.sell_price_total || 0}">
<i class="bi bi-cash-coin"></i> 판매 <i class="bi bi-cash-coin"></i> 판매
</button> </button>
` : ''}
${compound.status === 'PREPARED' ? `
<button class="btn btn-sm btn-outline-danger cancel-compound" data-id="${compound.compound_id}"> <button class="btn btn-sm btn-outline-danger cancel-compound" data-id="${compound.compound_id}">
<i class="bi bi-x-circle"></i> 취소 <i class="bi bi-x-circle"></i> 취소
</button> </button>
@@ -1795,6 +1923,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 { } 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);
@@ -1973,9 +2135,18 @@ $(document).ready(function() {
totalValue += item.total_value || 0; totalValue += item.total_value || 0;
if (item.total_quantity > 0) herbsInStock++; if (item.total_quantity > 0) herbsInStock++;
const isOTC = item.product_type === 'OTC';
const typeBadge = isOTC
? '<span class="badge bg-warning text-dark">OTC</span>'
: '<span class="badge bg-info">한약재</span>';
const codeDisplay = isOTC
? (item.standard_code || '-')
: (item.insurance_code || '-');
tbody.append(` tbody.append(`
<tr class="inventory-row" data-herb-id="${item.herb_item_id}"> <tr class="inventory-row" data-herb-id="${item.herb_item_id}">
<td>${item.insurance_code || '-'}</td> <td>${typeBadge}</td>
<td><small class="text-monospace">${codeDisplay}</small></td>
<td>${item.herb_name}${originBadge}${efficacyTags}</td> <td>${item.herb_name}${originBadge}${efficacyTags}</td>
<td>${item.total_quantity.toFixed(1)}</td> <td>${item.total_quantity.toFixed(1)}</td>
<td>${item.lot_count}</td> <td>${item.lot_count}</td>
@@ -2094,7 +2265,8 @@ $(document).ready(function() {
<div class="modal-header"> <div class="modal-header">
<h5 class="modal-title"> <h5 class="modal-title">
${data.herb_name} 재고 상세 ${data.herb_name} 재고 상세
<small class="text-muted">(${data.insurance_code})</small> <small class="text-muted">(${data.product_type === 'OTC' ? data.standard_code : data.insurance_code})</small>
${data.product_type === 'OTC' ? '<span class="badge bg-warning text-dark ms-2">OTC</span>' : ''}
</h5> </h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button> <button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div> </div>
@@ -2134,9 +2306,14 @@ $(document).ready(function() {
tbody.empty(); tbody.empty();
response.data.forEach(herb => { response.data.forEach(herb => {
const hIsOTC = herb.product_type === 'OTC';
const hCode = hIsOTC ? (herb.standard_code || '-') : (herb.insurance_code || '-');
const hTypeBadge = hIsOTC
? '<span class="badge bg-warning text-dark">OTC</span>'
: '<span class="badge bg-info">한약재</span>';
tbody.append(` tbody.append(`
<tr> <tr>
<td>${herb.insurance_code || '-'}</td> <td>${hTypeBadge} <small class="text-monospace">${hCode}</small></td>
<td>${herb.herb_name}</td> <td>${herb.herb_name}</td>
<td>${herb.specification || '-'}</td> <td>${herb.specification || '-'}</td>
<td>${herb.current_stock ? herb.current_stock.toFixed(1) + 'g' : '0g'}</td> <td>${herb.current_stock ? herb.current_stock.toFixed(1) + 'g' : '0g'}</td>
@@ -2235,14 +2412,24 @@ $(document).ready(function() {
if (line.processing) variantBadges += `<span class="badge bg-warning ms-1">${line.processing}</span>`; if (line.processing) variantBadges += `<span class="badge bg-warning ms-1">${line.processing}</span>`;
if (line.grade) variantBadges += `<span class="badge bg-success ms-1">${line.grade}</span>`; if (line.grade) variantBadges += `<span class="badge bg-success ms-1">${line.grade}</span>`;
const lineIsOTC = line.product_type === 'OTC';
const lineTypeBadge = lineIsOTC
? '<span class="badge bg-warning text-dark me-1">OTC</span>'
: '<span class="badge bg-info me-1">한약재</span>';
const lineCode = lineIsOTC
? (line.standard_code || '-')
: (line.insurance_code || '-');
const lineCodeLabel = lineIsOTC ? '표준' : '보험';
linesHtml += ` linesHtml += `
<tr> <tr>
<td> <td>
${lineTypeBadge}
<div>${line.herb_name}</div> <div>${line.herb_name}</div>
${line.display_name ? `<small class="text-primary">${line.display_name}</small>` : ''} ${line.display_name ? `<small class="text-primary">${line.display_name}</small>` : ''}
${variantBadges} ${variantBadges}
</td> </td>
<td>${line.insurance_code || '-'}</td> <td><small class="text-muted">${lineCodeLabel}</small><br><small class="text-monospace">${lineCode}</small></td>
<td>${line.origin_country || '-'}</td> <td>${line.origin_country || '-'}</td>
<td>${line.quantity_g}g</td> <td>${line.quantity_g}g</td>
<td>${formatCurrency(line.unit_price_per_g)}</td> <td>${formatCurrency(line.unit_price_per_g)}</td>
@@ -2269,8 +2456,8 @@ $(document).ready(function() {
<table class="table table-sm"> <table class="table table-sm">
<thead> <thead>
<tr> <tr>
<th>약재명</th> <th>품목명</th>
<th>보험코드</th> <th>코드</th>
<th>원산지</th> <th>원산지</th>
<th>수량</th> <th>수량</th>
<th>단가</th> <th>단가</th>
@@ -3007,6 +3194,14 @@ $(document).ready(function() {
selectElement.prop('disabled', false); selectElement.prop('disabled', false);
// 재고 충분한 첫 번째 원산지 자동 선택 (원가 미리보기용)
const firstAvailable = origins.find(o => o.total_quantity >= requiredQty);
if (firstAvailable) {
selectElement.val(firstAvailable.origin_country);
} else if (origins.length > 0) {
selectElement.val(origins[0].origin_country);
}
// 원산지 선택 변경 이벤트 (수동 배분 모달 트리거) // 원산지 선택 변경 이벤트 (수동 배분 모달 트리거)
selectElement.off('change').on('change', function() { selectElement.off('change').on('change', function() {
const selectedValue = $(this).val(); const selectedValue = $(this).val();
@@ -3024,18 +3219,24 @@ $(document).ready(function() {
// 기존 자동/원산지 선택 - lot_assignments 제거 // 기존 자동/원산지 선택 - lot_assignments 제거
row.removeAttr('data-lot-assignments'); row.removeAttr('data-lot-assignments');
} }
updateCostPreview();
}); });
// 재고 상태 업데이트 // 재고 상태 업데이트
const totalAvailable = response.data.total_quantity; const totalAvailable = response.data.total_quantity;
const statusElement = $(`tr[data-herb-id="${herbId}"] .stock-status`); const statusElement = $(`tr[data-herb-id="${herbId}"] .stock-status`);
const altCount = origins.length;
const altBadge = altCount > 1 ? ` <span class="badge bg-outline-info text-info border border-info" style="font-size:0.65rem; cursor:pointer" title="선택 가능한 원산지 ${altCount}종">${altCount}종</span>` : '';
if (totalAvailable >= requiredQty) { if (totalAvailable >= requiredQty) {
statusElement.html(`<span class="text-success">충분 (${totalAvailable.toFixed(1)}g)</span>`); statusElement.html(`<span class="text-success">충분 (${totalAvailable.toFixed(1)}g)</span>${altBadge}`);
} else { } else {
statusElement.html(`<span class="text-danger">부족 (${totalAvailable.toFixed(1)}g)</span>`); statusElement.html(`<span class="text-danger">부족 (${totalAvailable.toFixed(1)}g)</span>${altBadge}`);
} }
} }
// 원가 미리보기 갱신
updateCostPreview();
} }
}); });
} }
@@ -3295,7 +3496,7 @@ $(document).ready(function() {
itemsBody.append(` itemsBody.append(`
<tr> <tr>
<td>${item.herb_name}</td> <td>${item.herb_name}</td>
<td>${item.insurance_code || '-'}</td> <td>${item.product_type === 'OTC' ? (item.standard_code || '-') : (item.insurance_code || '-')}</td>
<td>${item.origin_country || '-'}</td> <td>${item.origin_country || '-'}</td>
<td>#${item.lot_id}</td> <td>#${item.lot_id}</td>
<td>${item.quantity_before.toFixed(1)}g</td> <td>${item.quantity_before.toFixed(1)}g</td>

496
static/medicine_master.js Normal file
View File

@@ -0,0 +1,496 @@
/**
* 의약품 마스터 검색 + 입고 장바구니 모듈
*/
(function() {
'use strict';
// 장바구니 데이터 (세션 메모리)
let cart = [];
const ORIGIN_OPTIONS = ['한국','중국','베트남','인도','태국','페루','일본','기타'];
// ─── 검색 ───────────────────────────────────────────────
function searchMedicine() {
const query = $('#medSearchInput').val().trim();
const category = $('#medCategoryFilter').val();
const packageType = $('#medPackageFilter').val();
if (query.length < 2) {
alert('검색어는 2자 이상 입력하세요.');
return;
}
const params = new URLSearchParams({ q: query, limit: 100 });
if (category) params.append('category', category);
if (packageType) params.append('package_type', packageType);
$('#medSearchResults').html('<tr><td colspan="8" class="text-center py-4"><div class="spinner-border spinner-border-sm"></div> 검색중...</td></tr>');
$.get(`/api/medicine-master/search?${params}`, function(response) {
if (!response.success) {
$('#medSearchResults').html(`<tr><td colspan="8" class="text-center text-danger py-4">${response.error}</td></tr>`);
return;
}
const tbody = $('#medSearchResults');
tbody.empty();
$('#medResultCount').text(response.count);
if (response.data.length === 0) {
tbody.html('<tr><td colspan="8" class="text-center text-muted py-4">검색 결과가 없습니다.</td></tr>');
return;
}
response.data.forEach(item => {
let categoryBadge = '';
switch(item.category) {
case '일반의약품':
categoryBadge = '<span class="badge bg-success">일반</span>';
break;
case '전문의약품':
categoryBadge = '<span class="badge bg-warning text-dark">전문</span>';
break;
case '한약재':
categoryBadge = '<span class="badge bg-info">한약재</span>';
break;
case '원료의약품':
categoryBadge = '<span class="badge bg-secondary">원료</span>';
break;
default:
categoryBadge = `<span class="badge bg-light text-dark">${item.category || '-'}</span>`;
}
const cleanName = item.product_name.replace(/&nbsp;/g, ' ').trim();
const notes = item.notes ? item.notes.replace(/&nbsp;/g, ' ').trim() : '';
const inCart = cart.some(c => c.standard_code === item.standard_code);
const itemJson = JSON.stringify(item).replace(/'/g, "&#39;");
tbody.append(`
<tr>
<td>
<strong>${cleanName}</strong>
${item.form_type ? `<br><small class="text-muted">${item.form_type}</small>` : ''}
</td>
<td><small>${item.company_name || '-'}</small></td>
<td><small>${item.spec || '-'}</small></td>
<td><small>${item.package_type || '-'}</small></td>
<td>${categoryBadge}</td>
<td><small class="text-monospace">${item.standard_code || '-'}</small></td>
<td><small class="text-muted">${notes.length > 30 ? notes.substring(0, 30) + '...' : notes}</small></td>
<td class="text-nowrap">
<button class="btn btn-sm btn-outline-info med-detail-btn"
data-item='${itemJson}' title="상세보기">
<i class="bi bi-eye"></i>
</button>
<button class="btn btn-sm btn-outline-success med-add-cart-btn"
data-item='${itemJson}'
data-code="${item.standard_code}"
title="장바구니 담기"
${inCart ? 'disabled' : ''}>
<i class="bi bi-plus-lg"></i>
</button>
</td>
</tr>
`);
});
// 상세보기 이벤트
$('.med-detail-btn').off('click').on('click', function() {
const item = JSON.parse($(this).attr('data-item'));
showMedicineDetail(item);
});
// 장바구니 담기 이벤트
$('.med-add-cart-btn').off('click').on('click', function() {
const item = JSON.parse($(this).attr('data-item'));
addToCart(item);
$(this).prop('disabled', true);
});
}).fail(function(xhr) {
$('#medSearchResults').html(`<tr><td colspan="8" class="text-center text-danger py-4">검색 실패: ${xhr.responseJSON?.error || '서버 오류'}</td></tr>`);
});
}
// ─── 상세보기 모달 ─────────────────────────────────────
function showMedicineDetail(item) {
const cleanName = item.product_name.replace(/&nbsp;/g, ' ').trim();
$('#medDetailTitle').text(cleanName);
const fields = [
{ label: '상품명', value: cleanName },
{ label: '업체명', value: item.company_name },
{ label: '규격', value: item.spec },
{ label: '제형구분', value: item.form_type },
{ label: '포장형태', value: item.package_type },
{ label: '전문일반구분', value: item.category },
{ label: '품목기준코드', value: item.item_std_code },
{ label: '대표코드', value: item.representative_code },
{ label: '표준코드', value: item.standard_code },
{ label: '일반명코드', value: item.ingredient_name_code },
{ label: 'ATC코드', value: item.atc_code },
{ label: '비고', value: item.notes?.replace(/&nbsp;/g, ' ') },
];
let html = '<table class="table table-sm">';
fields.forEach(f => {
if (f.value) {
html += `<tr><th width="140" class="text-muted">${f.label}</th><td>${f.value}</td></tr>`;
}
});
html += '</table>';
$('#medDetailBody').html(html);
$('#medDetailModal').modal('show');
}
// ─── 규격 파싱 ───────────────────────────────────────────
// 규격 문자열에서 그램 수 추출 (예: "500그램"→500, "1000그램"→1000)
function parseSpecToGrams(spec) {
if (!spec) return 0;
const s = spec.trim();
// "500그램", "1000그램", "1252.5그램"
let m = s.match(/^([\d.]+)\s*그램$/);
if (m) return parseFloat(m[1]);
// "500g", "1000G"
m = s.match(/^([\d.]+)\s*[gG]$/);
if (m) return parseFloat(m[1]);
// "1kg", "1.5Kg", "1킬로그램"
m = s.match(/^([\d.]+)\s*(kg|킬로그램)$/i);
if (m) return parseFloat(m[1]) * 1000;
// "500밀리그램" → 0.5g
m = s.match(/^([\d.]+)\s*밀리그램$/);
if (m) return parseFloat(m[1]) / 1000;
// "500mg"
m = s.match(/^([\d.]+)\s*mg$/i);
if (m) return parseFloat(m[1]) / 1000;
return 0; // 파싱 불가 ("없음" 등)
}
// ─── 장바구니 기능 ─────────────────────────────────────
function addToCart(item) {
// 중복 체크
if (cart.some(c => c.standard_code === item.standard_code)) {
alert('이미 장바구니에 있는 항목입니다.');
return;
}
const cleanName = item.product_name.replace(/&nbsp;/g, ' ').trim();
const specGrams = parseSpecToGrams(item.spec);
cart.push({
standard_code: item.standard_code,
product_name: cleanName,
company_name: item.company_name || '',
spec: item.spec || '',
spec_grams: specGrams, // 규격에서 파싱한 g 수
qty: 1, // 수량 (포장 단위)
unit_price: 0, // 단가 (종이 입고장 가격)
origin_country: '한국',
_raw: item
});
renderCart();
}
function removeFromCart(index) {
const removed = cart.splice(index, 1)[0];
if (removed) {
$(`.med-add-cart-btn[data-code="${removed.standard_code}"]`).prop('disabled', false);
}
renderCart();
}
function clearCart() {
if (cart.length === 0) return;
if (!confirm('장바구니를 비우시겠습니까?')) return;
const codes = cart.map(c => c.standard_code);
cart = [];
codes.forEach(code => {
$(`.med-add-cart-btn[data-code="${code}"]`).prop('disabled', false);
});
renderCart();
}
function renderCart() {
const panel = $('#cartPanel');
const tbody = $('#cartBody');
if (cart.length === 0) {
panel.hide();
return;
}
panel.show();
$('#cartCount').text(cart.length);
tbody.empty();
let totalQty = 0;
let totalAmt = 0;
cart.forEach((item, idx) => {
const amt = (item.qty || 0) * (item.unit_price || 0);
const pricePerG = (item.spec_grams && item.unit_price)
? (item.unit_price / item.spec_grams) : 0;
totalQty += (item.qty || 0);
totalAmt += amt;
const originOptions = ORIGIN_OPTIONS.map(o =>
`<option value="${o}" ${item.origin_country === o ? 'selected' : ''}>${o}</option>`
).join('');
const specDisplay = item.spec_grams
? `${item.spec} <br><small class="text-success">${item.spec_grams.toLocaleString()}g</small>`
: `${item.spec || '-'} <br><small class="text-danger">수동입력</small>`;
tbody.append(`
<tr data-cart-idx="${idx}">
<td><small><strong>${item.product_name}</strong></small></td>
<td><small>${item.company_name || '-'}</small></td>
<td>${specDisplay}</td>
<td>
<input type="number" class="form-control form-control-sm text-end cart-qty"
data-idx="${idx}" value="${item.qty || 1}"
min="1" step="1" placeholder="1">
</td>
<td>
<input type="number" class="form-control form-control-sm text-end cart-unit-price"
data-idx="${idx}" value="${item.unit_price || ''}"
min="0" step="100" placeholder="35000">
</td>
<td class="text-end cart-amt" data-idx="${idx}">
${amt ? amt.toLocaleString() : '-'}
</td>
<td class="text-end cart-ppg text-muted" data-idx="${idx}">
<small>${pricePerG ? pricePerG.toFixed(1) : '-'}</small>
</td>
<td>
<select class="form-select form-select-sm cart-origin" data-idx="${idx}">
${originOptions}
</select>
</td>
<td>
<button class="btn btn-sm btn-outline-danger cart-remove-btn" data-idx="${idx}" title="삭제">
<i class="bi bi-x"></i>
</button>
</td>
</tr>
`);
});
$('#cartTotalQty').text(totalQty ? totalQty.toLocaleString() : '0');
$('#cartTotalAmt').text(totalAmt ? totalAmt.toLocaleString() + '원' : '0');
// 이벤트 바인딩
tbody.find('.cart-qty').off('input').on('input', function() {
const idx = $(this).data('idx');
cart[idx].qty = parseInt($(this).val()) || 0;
updateCartRow(idx);
});
tbody.find('.cart-unit-price').off('input').on('input', function() {
const idx = $(this).data('idx');
cart[idx].unit_price = parseFloat($(this).val()) || 0;
updateCartRow(idx);
});
tbody.find('.cart-origin').off('change').on('change', function() {
const idx = $(this).data('idx');
cart[idx].origin_country = $(this).val();
});
tbody.find('.cart-remove-btn').off('click').on('click', function() {
removeFromCart($(this).data('idx'));
});
}
function updateCartRow(idx) {
const item = cart[idx];
const amt = (item.qty || 0) * (item.unit_price || 0);
const pricePerG = (item.spec_grams && item.unit_price)
? (item.unit_price / item.spec_grams) : 0;
$(`.cart-amt[data-idx="${idx}"]`).text(amt ? amt.toLocaleString() : '-');
$(`.cart-ppg[data-idx="${idx}"]`).html(`<small>${pricePerG ? pricePerG.toFixed(1) : '-'}</small>`);
updateCartTotals();
}
function updateCartTotals() {
let totalQty = 0;
let totalAmt = 0;
cart.forEach(item => {
totalQty += (item.qty || 0);
totalAmt += (item.qty || 0) * (item.unit_price || 0);
});
$('#cartTotalQty').text(totalQty ? totalQty.toLocaleString() : '0');
$('#cartTotalAmt').text(totalAmt ? totalAmt.toLocaleString() + '원' : '0');
}
// ─── 입고장 생성 ───────────────────────────────────────
function createReceipt() {
// 검증
const supplierId = $('#cartSupplier').val();
const receiptDate = $('#cartReceiptDate').val();
const notes = $('#cartNotes').val().trim();
if (!supplierId) {
alert('도매상을 선택해주세요.');
$('#cartSupplier').focus();
return;
}
if (!receiptDate) {
alert('입고일을 입력해주세요.');
$('#cartReceiptDate').focus();
return;
}
if (cart.length === 0) {
alert('장바구니가 비어있습니다.');
return;
}
// 단가 미입력 체크
const incomplete = cart.filter(c => !c.unit_price);
if (incomplete.length > 0) {
alert(`단가가 입력되지 않은 항목이 ${incomplete.length}건 있습니다.\n모든 항목의 단가를 입력해주세요.`);
return;
}
// 규격 미파싱 경고 (spec_grams=0이면 g 환산 불가)
const noSpec = cart.filter(c => !c.spec_grams);
if (noSpec.length > 0) {
const ok = confirm(
`규격(g)을 파싱할 수 없는 항목이 ${noSpec.length}건 있습니다.\n` +
`해당 항목은 g당단가가 계산되지 않습니다.\n\n` +
noSpec.map(c => '- ' + c.product_name + ' (' + c.spec + ')').join('\n') +
'\n\n그래도 진행하시겠습니까?'
);
if (!ok) return;
}
// 확인 요약
const supplierName = $('#cartSupplier option:selected').text();
let totalAmt = 0;
cart.forEach(item => {
totalAmt += (item.qty || 0) * (item.unit_price || 0);
});
const summary = [
`도매상: ${supplierName}`,
`입고일: ${receiptDate}`,
`품목 수: ${cart.length}`,
`총 금액: ${totalAmt.toLocaleString()}`,
].join('\n');
if (!confirm(`입고장을 생성하시겠습니까?\n\n${summary}`)) return;
// API 요청 데이터 구성
const payload = {
supplier_id: parseInt(supplierId),
receipt_date: receiptDate,
notes: notes,
lines: cart.map(c => ({
standard_code: c.standard_code,
product_name: c.product_name,
company_name: c.company_name,
spec: c.spec,
spec_grams: c.spec_grams,
qty: c.qty,
unit_price: c.unit_price,
origin_country: c.origin_country
}))
};
// 버튼 비활성화
const btn = $('#createReceiptBtn');
btn.prop('disabled', true).html('<span class="spinner-border spinner-border-sm"></span> 처리중...');
$.ajax({
url: '/api/purchase-receipts/from-cart',
method: 'POST',
contentType: 'application/json',
data: JSON.stringify(payload),
success: function(response) {
if (response.success) {
alert(
`입고 완료!\n\n` +
`입고장 번호: ${response.receipt_no}\n` +
`품목: ${response.summary.item_count}\n` +
`총 금액: ${response.summary.total_amount}`
);
// 장바구니 초기화
cart = [];
renderCart();
// 검색결과 버튼 전부 재활성화
$('.med-add-cart-btn').prop('disabled', false);
} else {
alert('입고 실패: ' + response.error);
}
},
error: function(xhr) {
const msg = xhr.responseJSON?.error || '서버 오류';
alert('입고 실패: ' + msg);
},
complete: function() {
btn.prop('disabled', false).html('<i class="bi bi-clipboard-check"></i> 입고장 생성');
}
});
}
// ─── 도매상 로드 ───────────────────────────────────────
function loadSuppliers() {
$.get('/api/suppliers', function(response) {
if (!response.success) return;
const sel = $('#cartSupplier');
sel.find('option:not(:first)').remove();
response.data.forEach(s => {
sel.append(`<option value="${s.supplier_id}">${s.name}</option>`);
});
});
}
// ─── 초기화 ────────────────────────────────────────────
function init() {
// 검색
$('#medSearchBtn').off('click').on('click', searchMedicine);
$('#medSearchInput').off('keypress').on('keypress', function(e) {
if (e.which === 13) searchMedicine();
});
$('#medCategoryFilter, #medPackageFilter').off('change').on('change', function() {
if ($('#medSearchInput').val().trim().length >= 2) {
searchMedicine();
}
});
// 장바구니
$('#cartClearBtn').off('click').on('click', clearCart);
$('#createReceiptBtn').off('click').on('click', createReceipt);
// 입고일 기본값: 오늘
$('#cartReceiptDate').val(new Date().toISOString().split('T')[0]);
// 도매상 목록 로드
loadSuppliers();
// 이전 장바구니 상태 복원 (없으면 숨김)
renderCart();
}
// 글로벌 로드 함수 등록
window.loadMedicineMaster = function() {
init();
};
})();

View File

@@ -127,6 +127,11 @@
<i class="bi bi-book"></i> 약재 정보 <i class="bi bi-book"></i> 약재 정보
</a> </a>
</li> </li>
<li class="nav-item">
<a class="nav-link" href="#" data-page="medicine-master">
<i class="bi bi-capsule"></i> 의약품 마스터
</a>
</li>
</ul> </ul>
</div> </div>
@@ -443,18 +448,27 @@
</div> </div>
</div> </div>
<div class="row mt-3"> <div class="row mt-3">
<div class="col-md-4"> <div class="col-md-3">
<label class="form-label">제수</label> <label class="form-label">제수</label>
<input type="number" class="form-control" id="jeCount" value="1" min="0.5" step="0.5" required> <input type="number" class="form-control" id="jeCount" value="1" min="0.5" step="0.5" required>
</div> </div>
<div class="col-md-4"> <div class="col-md-3">
<label class="form-label">총 첩수</label> <label class="form-label">총 첩수</label>
<input type="number" class="form-control" id="cheopTotal" readonly> <input type="number" class="form-control" id="cheopTotal" readonly>
</div> </div>
<div class="col-md-4"> <div class="col-md-3">
<label class="form-label">총 파우치수</label> <label class="form-label">총 파우치수</label>
<input type="number" class="form-control" id="pouchTotal" required> <input type="number" class="form-control" id="pouchTotal" required>
</div> </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>
<div class="mt-3"> <div class="mt-3">
<h6>약재 구성 (가감 가능)</h6> <h6>약재 구성 (가감 가능)</h6>
@@ -477,6 +491,24 @@
<i class="bi bi-plus"></i> 약재 추가 <i class="bi bi-plus"></i> 약재 추가
</button> </button>
</div> </div>
<!-- 원가 미리보기 -->
<div id="costPreview" class="mt-3 p-3 bg-light rounded border" style="display:none;">
<div class="d-flex justify-content-between align-items-center">
<h6 class="mb-0"><i class="bi bi-calculator"></i> 예상 원가</h6>
<span class="badge bg-secondary" id="costPreviewStatus">계산중...</span>
</div>
<div class="mt-2">
<table class="table table-sm table-borderless mb-0" style="font-size: 0.85rem;">
<tbody id="costPreviewItems"></tbody>
<tfoot>
<tr class="border-top fw-bold">
<td>합계</td>
<td class="text-end" id="costPreviewTotal">₩0</td>
</tr>
</tfoot>
</table>
</div>
</div>
<div class="mt-3"> <div class="mt-3">
<button type="submit" class="btn btn-success"> <button type="submit" class="btn btn-success">
<i class="bi bi-check-circle"></i> 조제 실행 <i class="bi bi-check-circle"></i> 조제 실행
@@ -888,8 +920,9 @@
<table class="table table-hover"> <table class="table table-hover">
<thead> <thead>
<tr> <tr>
<th>보험코드</th> <th>구분</th>
<th>약재명</th> <th>코드</th>
<th>품목명</th>
<th>현재 재고(g)</th> <th>현재 재고(g)</th>
<th>로트 수</th> <th>로트 수</th>
<th>평균 단가</th> <th>평균 단가</th>
@@ -1127,8 +1160,8 @@
<table class="table table-sm table-bordered"> <table class="table table-sm table-bordered">
<thead class="table-light"> <thead class="table-light">
<tr> <tr>
<th>약재</th> <th>품목</th>
<th>보험코드</th> <th>코드</th>
<th>원산지</th> <th>원산지</th>
<th>로트ID</th> <th>로트ID</th>
<th>보정 전</th> <th>보정 전</th>
@@ -1452,6 +1485,10 @@
</div> </div>
</div> </div>
</div> </div>
<!-- Medicine Master Page -->
{% include 'medicine_master.html' %}
</div> </div>
</div> </div>
</div> </div>
@@ -2080,6 +2117,7 @@
<script src="https://code.jquery.com/jquery-3.6.0.min.js"></script> <script src="https://code.jquery.com/jquery-3.6.0.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/js/bootstrap.bundle.min.js"></script> <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/js/bootstrap.bundle.min.js"></script>
<script src="/static/app.js?v=20260217"></script> <script src="/static/app.js?v=20260217"></script>
<script src="/static/medicine_master.js?v=20260219"></script>
<!-- 재고 자산 계산 설정 모달 --> <!-- 재고 자산 계산 설정 모달 -->
<div class="modal fade" id="inventorySettingsModal" tabindex="-1" aria-labelledby="inventorySettingsModalLabel" aria-hidden="true"> <div class="modal fade" id="inventorySettingsModal" tabindex="-1" aria-labelledby="inventorySettingsModalLabel" aria-hidden="true">
<div class="modal-dialog"> <div class="modal-dialog">

View File

@@ -0,0 +1,163 @@
<!-- 의약품 마스터 검색 페이지 -->
<div id="medicine-master" class="main-content">
<h3 class="mb-4"><i class="bi bi-search"></i> 의약품 마스터 검색</h3>
<!-- 검색 영역 -->
<div class="card mb-4">
<div class="card-body">
<div class="row g-2 align-items-end">
<div class="col-md-5">
<label class="form-label">검색어</label>
<input type="text" class="form-control" id="medSearchInput"
placeholder="상품명, 업체명, 표준코드 검색..." autofocus>
</div>
<div class="col-md-2">
<label class="form-label">분류</label>
<select class="form-select" id="medCategoryFilter">
<option value="">전체</option>
<option value="일반의약품">일반의약품</option>
<option value="전문의약품">전문의약품</option>
<option value="한약재">한약재</option>
<option value="원료의약품">원료의약품</option>
</select>
</div>
<div class="col-md-2">
<label class="form-label">포장형태</label>
<select class="form-select" id="medPackageFilter">
<option value="">전체</option>
<option value="병"></option>
<option value="포"></option>
<option value="박스">박스</option>
<option value="기타">기타</option>
</select>
</div>
<div class="col-md-1">
<button class="btn btn-primary w-100" id="medSearchBtn">
<i class="bi bi-search"></i>
</button>
</div>
<div class="col-md-2">
<span class="text-muted" id="medSearchCount"></span>
</div>
</div>
</div>
</div>
<!-- 검색 결과 -->
<div class="card">
<div class="card-header bg-light d-flex justify-content-between align-items-center">
<h5 class="mb-0">검색 결과</h5>
<div>
<span class="badge bg-primary" id="medResultCount">0</span>
</div>
</div>
<div class="card-body p-0">
<div class="table-responsive" style="max-height: 600px; overflow-y: auto;">
<table class="table table-hover table-sm mb-0">
<thead class="table-light sticky-top">
<tr>
<th>상품명</th>
<th>업체명</th>
<th>규격</th>
<th>포장</th>
<th>분류</th>
<th>표준코드</th>
<th>비고</th>
<th width="100">작업</th>
</tr>
</thead>
<tbody id="medSearchResults">
<tr>
<td colspan="8" class="text-center text-muted py-5">
<i class="bi bi-search" style="font-size: 2rem;"></i>
<p class="mt-2">검색어를 입력하세요 (2자 이상)</p>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
<!-- 입고 장바구니 -->
<div class="card mt-4" id="cartPanel" style="display:none;">
<div class="card-header bg-light d-flex justify-content-between align-items-center">
<h5 class="mb-0"><i class="bi bi-cart3"></i> 입고 장바구니 (<span id="cartCount">0</span>건)</h5>
<button class="btn btn-sm btn-outline-danger" id="cartClearBtn" title="비우기">
<i class="bi bi-trash"></i> 비우기
</button>
</div>
<div class="card-body">
<!-- 입고 정보 -->
<div class="row g-2 mb-3">
<div class="col-md-4">
<label class="form-label">도매상 <span class="text-danger">*</span></label>
<select class="form-select form-select-sm" id="cartSupplier">
<option value="">-- 선택 --</option>
</select>
</div>
<div class="col-md-3">
<label class="form-label">입고일 <span class="text-danger">*</span></label>
<input type="date" class="form-control form-control-sm" id="cartReceiptDate">
</div>
<div class="col-md-5">
<label class="form-label">비고</label>
<input type="text" class="form-control form-control-sm" id="cartNotes" placeholder="비고 입력...">
</div>
</div>
<!-- 장바구니 테이블 -->
<div class="table-responsive">
<table class="table table-sm table-bordered mb-0">
<thead class="table-light">
<tr>
<th>상품명</th>
<th>업체</th>
<th>규격</th>
<th width="70">수량</th>
<th width="110">단가(원)</th>
<th width="90">금액</th>
<th width="80">g당단가</th>
<th width="100">원산지</th>
<th width="40"></th>
</tr>
</thead>
<tbody id="cartBody"></tbody>
<tfoot class="table-light">
<tr class="fw-bold">
<td colspan="3" class="text-end">합계</td>
<td id="cartTotalQty" class="text-end">0</td>
<td></td>
<td id="cartTotalAmt" class="text-end">0</td>
<td colspan="3"></td>
</tr>
</tfoot>
</table>
</div>
<!-- 입고장 생성 버튼 -->
<div class="text-end mt-3">
<button class="btn btn-primary" id="createReceiptBtn">
<i class="bi bi-clipboard-check"></i> 입고장 생성
</button>
</div>
</div>
</div>
<!-- 제품 상세 모달 -->
<div class="modal fade" id="medDetailModal" tabindex="-1">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="medDetailTitle">제품 상세</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body" id="medDetailBody">
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">닫기</button>
</div>
</div>
</div>
</div>
</div>