Compare commits

...

4 Commits

Author SHA1 Message Date
1679f75d33 feat: 100처방 UI, 처방 가감 표시, 어울림 스타일링
- 처방 관리 페이지에 100처방 원방 마스터 섹션 추가 (검색 포함)
- 100처방 상세 모달 (구성약재, 참고자료 편집, 내 처방으로 등록)
- 내 처방 목록에 100처방 뱃지 및 가감 정보 표시
  - 변경: 파란 뱃지, 추가: 초록 뱃지, 제거: 빨간 뱃지
  - 원방 그대로: 회색 뱃지
- "어울림" 접두어 초록색 볼드 스타일링
- stock_ledger에 RETURN(반환)/DISCARD(폐기) 한글 라벨 추가
- 수동입고 원산지 드롭다운 변경, 재고 상세 유통기한 표시

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 14:15:44 +00:00
51e0c99c77 feat: 100처방 API, 조제취소 재고복원, 처방 가감 비교 기능
- 100처방 마스터 CRUD API (목록조회/수정/구성약재조회)
- 100처방 시드 데이터 100개 처방 로드 (init_db)
- formulas API에 official_formula_id 및 가감(추가/제거/변경) 정보 포함
- create_formula: herb_item_id → ingredient_code 기반으로 수정
- create_formula: official_formula_id 저장 지원
- 조제 취소(CANCELLED) 시 inventory_lots 재고 복원 + stock_ledger RETURN 기록
- 입고장 삭제 시 취소된 조제의 compound_consumptions 정리

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 14:15:33 +00:00
87e839be14 feat: 100처방 마스터 테이블 스키마 및 관련 문서 추가
- official_formulas, official_formula_ingredients 테이블 스키마 추가
- 100처방 마스터데이터 등록 가이드 (Agent용 절차/규칙/코드 템플릿)
- 한약국 첩제 vs OTC 상담 가이드
- 한약국 AI데이터 기본이해 문서
- 가미패독산 업셀링 칼럼, 입고장 수정기능 구현 문서
- CLAUDE.md에 참고 문서 경로 추가

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 14:15:25 +00:00
3a39951fdc feat: 수동입고 기능 구현 및 입고일 날짜 포맷 버그 수정
- 수동입고 API (POST /api/purchase-receipts/manual) 추가
- 수동입고 모달 UI 구현 (도매상 선택, 품목 동적 추가, 금액 자동계산)
- 도매상 등록 모달 z-index 처리 (수동입고 모달 위에 표시)
- Excel 입고 시 receipt_date 튜플/대시 없는 날짜 포맷 정규화
- inventory_lots에 lot_number, expiry_date 저장 누락 수정
- CLAUDE.md 추가 (lot_id vs lot_number 구분 가이드)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 07:34:56 +00:00
11 changed files with 2678 additions and 28 deletions

13
CLAUDE.md Normal file
View File

@ -0,0 +1,13 @@
# CLAUDE.md - AI 개발 가이드
## 핵심 참고 문서
- `docs/한약국_첩제_vs_OTC_상담가이드.md` — 첩제 vs OTC 차별점, 업셀링 근거, 100처방 reference_notes 작성 가이드. AI 상담/알림톡/웹 설명 자료 작성 시 반드시 참고.
- `docs/100처방_마스터데이터_등록_가이드.md`**100처방 마스터 데이터 등록 절차/규칙/코드 템플릿**. official_formulas·official_formula_ingredients 테이블에 처방 데이터를 채울 때 반드시 이 문서의 절차와 규칙을 따를 것. 성분코드(ingredient_code) 조회법, 동명이약 구분, 용량 기준, description/reference_notes 포맷, Python 코드 템플릿 포함.
## DB 주의사항
### inventory_lots 테이블: lot_id vs lot_number
- `lot_id` (INTEGER PK): 시스템이 자동 생성하는 내부 식별자. 재고 추적/조제/원장 등 모든 로직에서 로트를 참조할 때 사용.
- `lot_number` (TEXT, nullable): 도매상이 부여한 납품 로트번호. 사용자가 직접 입력하는 참고용 텍스트.
- INSERT 시 `lot_number``expiry_date`를 빠뜨리지 말 것. 둘 다 nullable이지만 사용자가 입력했으면 반드시 저장해야 함.

635
app.py
View File

@ -58,8 +58,76 @@ def init_db():
with get_db() as conn: with get_db() as conn:
conn.executescript(schema) conn.executescript(schema)
cursor = conn.cursor()
# formulas 테이블 마이그레이션: official_formula_id FK 컬럼 추가
cursor.execute("PRAGMA table_info(formulas)")
existing_cols = {row[1] for row in cursor.fetchall()}
if 'official_formula_id' not in existing_cols:
cursor.execute("ALTER TABLE formulas ADD COLUMN official_formula_id INTEGER REFERENCES official_formulas(official_formula_id)")
# official_formulas 테이블 마이그레이션: reference_notes 컬럼 추가
cursor.execute("PRAGMA table_info(official_formulas)")
of_cols = {row[1] for row in cursor.fetchall()}
if 'reference_notes' not in of_cols:
cursor.execute("ALTER TABLE official_formulas ADD COLUMN reference_notes TEXT")
# 100처방 원방 마스터 시드 데이터 로드
cursor.execute("SELECT COUNT(*) FROM official_formulas")
if cursor.fetchone()[0] == 0:
seed_official_formulas(conn)
conn.commit()
print("Database initialized successfully") print("Database initialized successfully")
def seed_official_formulas(conn):
"""100처방 원방 마스터 데이터 시드"""
official_100 = [
(1, '가미온담탕', '의종금감'), (2, '가미패독산', '경악전서'), (3, '갈근탕', '상한론'),
(4, '강활유풍탕', '의학발명'), (5, '계지가용골모려탕', '금궤요략'), (6, '계지작약지모탕', '금궤요략'),
(7, '곽향정기산', '화제국방'), (8, '구미강활탕', '차사난지'), (9, '궁귀교애탕', '금궤요략'),
(10, '귀비탕', '제생방'), (11, '귀출파징탕', '동의보감'), (12, '금수육군전', '경악전서'),
(13, '녹용대보탕', '갑병원류서촉'), (14, '당귀사역가오수유생강탕', '상한론'), (15, '당귀수산', '의학입문'),
(16, '당귀육황탕', '난실비장'), (17, '당귀작약산', '금궤요략'), (18, '대강활탕', '위생보감'),
(19, '대건중탕', '금궤요략'), (20, '대금음자', '화제국방'), (21, '대방풍탕', '화제국방'),
(22, '대청룡탕', '상한론'), (23, '대황목단피탕', '금궤요략'), (24, '독활기생탕', '천금방'),
(25, '마행의감탕', '금궤요략'), (26, '마황부자세신탕', '상한론'), (27, '반하백출천마탕', '의학심오'),
(28, '반하사심탕', '상한론'), (29, '반하후박탕', '금궤요략'), (30, '방기황기탕', '금궤요략'),
(31, '방풍통성산', '선명논방'), (32, '배농산급탕', '춘림헌방함'), (33, '백출산', '외대비요'),
(34, '보생탕', '부인양방'), (35, '보중익기탕', '비위론'), (36, '복령음', '외대비요'),
(37, '분심기음', '직지방'), (38, '사군자탕', '화제국방'), (39, '사물탕', '화제국방'),
(40, '삼령백출산', '화제국방'), (41, '삼소음', '화제국방'), (42, '삼출건비탕', '동의보감'),
(43, '삼환사심탕', '금궤요략'), (44, '생혈윤부탕', '의학정전'), (45, '세간명목탕', '중보만병회춘'),
(46, '소건중탕', '상한론'), (47, '소시호탕', '상한론'), (48, '소요산', '화제국방'),
(49, '소자강기탕', '화제국방'), (50, '소적정원산', '의학입문'), (51, '소청룡탕', '상한론'),
(52, '소풍산', '외과정종'), (53, '소풍활혈탕', '심씨존생서'), (54, '속명탕', '금궤요략'),
(55, '승마갈근탕', '염씨소아방론'), (56, '시함탕', '중정통속상한론'), (57, '시호계강탕', '상한론'),
(58, '시호억간탕', '의학입문'), (59, '시호청간탕', '구치유요'), (60, '십전대보탕', '화제국방'),
(61, '쌍화탕', '화제국방'), (62, '안중산', '화제국방'), (63, '양격산', '화제국방'),
(64, '연령고본단', '만병회춘'), (65, '영감강미신하인탕', '금궤요략'), (66, '영계출감탕', '상한론'),
(67, '오약순기산', '화제국방'), (68, '오적산', '화제국방'), (69, '온경탕', '금궤요략'),
(70, '온백원', '화제금궤'), (71, '용담사간탕', '의종금감'), (72, '월비탕', '금궤요략'),
(73, '위령탕', '만병회춘'), (74, '육군자탕', '부인양방'), (75, '육미지황환', '소아약증직결'),
(76, '육울탕', '단계심법'), (77, '이기거풍산', '고금의감'), (78, '이중환', '상한론'),
(79, '이진탕', '화제국방'), (80, '인삼양영탕', '화제국방'), (81, '인삼양위탕', '화제국방'),
(82, '인삼패독산', '소아약증질결'), (83, '인진오령산', '금궤요략'), (84, '자감초탕', '상한론'),
(85, '자음강화탕', '만병회춘'), (86, '자음건비탕', '만병회푼'), (87, '저령탕', '상한론'),
(88, '조경종옥탕', '고금의감'), (89, '지황음자', '선명논방'), (90, '진무탕', '상한론'),
(91, '청간해올탕', '증치준승'), (92, '청금강화탕', '고금의감'), (93, '청상방풍탕', '만병회춘'),
(94, '청서익기탕', '비위론'), (95, '청심연자음', '화제국방'), (96, '평위산', '화제국방'),
(97, '형계연교탕', '일관당'), (98, '형방패독산', '섭생중묘방'), (99, '황련아교탕', '상한론'),
(100, '황련해독탕', '외대비요'),
]
cursor = conn.cursor()
for num, name, source in official_100:
cursor.execute("""
INSERT INTO official_formulas (formula_number, formula_name, source_text)
VALUES (?, ?, ?)
""", (num, name, source))
# 라우트: 메인 페이지 # 라우트: 메인 페이지
@app.route('/') @app.route('/')
def index(): def index():
@ -168,6 +236,40 @@ def create_patient():
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/patients/<int:patient_id>', methods=['PUT'])
def update_patient(patient_id):
"""환자 정보 수정"""
try:
data = request.json
with get_db() as conn:
cursor = conn.cursor()
cursor.execute("SELECT patient_id FROM patients WHERE patient_id = ?", (patient_id,))
if not cursor.fetchone():
return jsonify({'success': False, 'error': '환자를 찾을 수 없습니다'}), 404
cursor.execute("""
UPDATE patients
SET name = ?, phone = ?, jumin_no = ?, gender = ?,
birth_date = ?, address = ?, notes = ?
WHERE patient_id = ?
""", (
data.get('name'),
data.get('phone'),
data.get('jumin_no'),
data.get('gender'),
data.get('birth_date'),
data.get('address'),
data.get('notes'),
patient_id
))
return jsonify({
'success': True,
'message': '환자 정보가 수정되었습니다'
})
except Exception as e:
return jsonify({'success': False, 'error': str(e)}), 500
# ==================== 약재 관리 API ==================== # ==================== 약재 관리 API ====================
@app.route('/api/herbs', methods=['GET']) @app.route('/api/herbs', methods=['GET'])
@ -346,20 +448,148 @@ def get_herbs_by_ingredient(ingredient_code):
# ==================== 처방 관리 API ==================== # ==================== 처방 관리 API ====================
@app.route('/api/formulas', methods=['GET']) @app.route('/api/official-formulas', methods=['GET'])
def get_formulas(): def get_official_formulas():
"""처방 목록 조회""" """100처방 원방 마스터 목록 조회"""
try:
search = request.args.get('search', '').strip()
with get_db() as conn:
cursor = conn.cursor()
if search:
cursor.execute("""
SELECT official_formula_id, formula_number, formula_name,
formula_name_hanja, source_text, description, reference_notes
FROM official_formulas
WHERE formula_name LIKE ? OR formula_name_hanja LIKE ?
OR source_text LIKE ? OR reference_notes LIKE ?
ORDER BY formula_number
""", (f'%{search}%', f'%{search}%', f'%{search}%', f'%{search}%'))
else:
cursor.execute("""
SELECT official_formula_id, formula_number, formula_name,
formula_name_hanja, source_text, description, reference_notes
FROM official_formulas
ORDER BY formula_number
""")
formulas = [dict(row) for row in cursor.fetchall()]
return jsonify({'success': True, 'data': formulas})
except Exception as e:
return jsonify({'success': False, 'error': str(e)}), 500
@app.route('/api/official-formulas/<int:official_formula_id>', methods=['PUT'])
def update_official_formula(official_formula_id):
"""100처방 원방 마스터 정보 수정"""
try:
data = request.json
with get_db() as conn:
cursor = conn.cursor()
update_fields = []
update_values = []
for field in ['formula_name_hanja', 'description', 'reference_notes']:
if field in data:
update_fields.append(f'{field} = ?')
update_values.append(data[field])
if not update_fields:
return jsonify({'error': '수정할 항목이 없습니다'}), 400
update_fields.append('updated_at = CURRENT_TIMESTAMP')
update_values.append(official_formula_id)
cursor.execute(f"""
UPDATE official_formulas SET {', '.join(update_fields)}
WHERE official_formula_id = ?
""", update_values)
conn.commit()
return jsonify({'success': True, 'message': '수정되었습니다'})
except Exception as e:
return jsonify({'success': False, 'error': str(e)}), 500
@app.route('/api/official-formulas/<int:official_formula_id>/ingredients', methods=['GET'])
def get_official_formula_ingredients(official_formula_id):
"""100처방 원방 구성 약재 조회"""
try: try:
with get_db() as conn: with get_db() as conn:
cursor = conn.cursor() cursor = conn.cursor()
cursor.execute(""" cursor.execute("""
SELECT formula_id, formula_code, formula_name, formula_type, SELECT ofi.ingredient_code, ofi.grams_per_cheop, ofi.notes, ofi.sort_order,
base_cheop, base_pouches, description, efficacy hm.herb_name, hm.herb_name_hanja
FROM formulas FROM official_formula_ingredients ofi
WHERE is_active = 1 JOIN herb_masters hm ON ofi.ingredient_code = hm.ingredient_code
ORDER BY formula_name WHERE ofi.official_formula_id = ?
ORDER BY ofi.sort_order
""", (official_formula_id,))
ingredients = [dict(row) for row in cursor.fetchall()]
return jsonify({'success': True, 'data': ingredients})
except Exception as e:
return jsonify({'success': False, 'error': str(e)}), 500
@app.route('/api/formulas', methods=['GET'])
def get_formulas():
"""처방 목록 조회 (100처방 대비 가감 정보 포함)"""
try:
with get_db() as conn:
cursor = conn.cursor()
cursor.execute("""
SELECT f.formula_id, f.formula_code, f.formula_name, f.formula_type,
f.base_cheop, f.base_pouches, f.description, f.efficacy,
f.official_formula_id,
of2.formula_name as official_name
FROM formulas f
LEFT JOIN official_formulas of2 ON f.official_formula_id = of2.official_formula_id
WHERE f.is_active = 1
ORDER BY f.formula_name
""") """)
formulas = [dict(row) for row in cursor.fetchall()] formulas = [dict(row) for row in cursor.fetchall()]
# 100처방 기반 처방에 대해 가감 정보 계산
for formula in formulas:
if formula.get('official_formula_id'):
official_id = formula['official_formula_id']
formula_id = formula['formula_id']
# 원방 구성 조회
cursor.execute("""
SELECT ofi.ingredient_code, hm.herb_name, ofi.grams_per_cheop
FROM official_formula_ingredients ofi
JOIN herb_masters hm ON ofi.ingredient_code = hm.ingredient_code
WHERE ofi.official_formula_id = ?
""", (official_id,))
original = {row['ingredient_code']: {'name': row['herb_name'], 'grams': row['grams_per_cheop']} for row in cursor.fetchall()}
# 내 처방 구성 조회
cursor.execute("""
SELECT fi.ingredient_code, hm.herb_name, fi.grams_per_cheop
FROM formula_ingredients fi
JOIN herb_masters hm ON fi.ingredient_code = hm.ingredient_code
WHERE fi.formula_id = ?
""", (formula_id,))
current = {row['ingredient_code']: {'name': row['herb_name'], 'grams': row['grams_per_cheop']} for row in cursor.fetchall()}
added = []
removed = []
modified = []
# 추가된 약재
for code, info in current.items():
if code not in original:
added.append(f"{info['name']} {info['grams']}g")
# 제거된 약재
for code, info in original.items():
if code not in current:
removed.append(info['name'])
# 용량 변경
for code in current:
if code in original:
orig_g = original[code]['grams']
curr_g = current[code]['grams']
if abs(orig_g - curr_g) > 0.01:
modified.append(f"{original[code]['name']} {orig_g}g→{curr_g}g")
formula['custom_added'] = added
formula['custom_removed'] = removed
formula['custom_modified'] = modified
formula['is_custom'] = bool(added or removed or modified)
return jsonify({'success': True, 'data': formulas}) return jsonify({'success': True, 'data': formulas})
except Exception as e: except Exception as e:
return jsonify({'success': False, 'error': str(e)}), 500 return jsonify({'success': False, 'error': str(e)}), 500
@ -376,8 +606,8 @@ def create_formula():
# 처방 마스터 생성 # 처방 마스터 생성
cursor.execute(""" cursor.execute("""
INSERT INTO formulas (formula_code, formula_name, formula_type, INSERT INTO formulas (formula_code, formula_name, formula_type,
base_cheop, base_pouches, description, efficacy, created_by) base_cheop, base_pouches, description, efficacy, created_by, official_formula_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
""", ( """, (
data.get('formula_code'), data.get('formula_code'),
data['formula_name'], data['formula_name'],
@ -386,20 +616,21 @@ def create_formula():
data.get('base_pouches', 30), data.get('base_pouches', 30),
data.get('description'), data.get('description'),
data.get('efficacy'), data.get('efficacy'),
data.get('created_by', 'system') data.get('created_by', 'system'),
data.get('official_formula_id')
)) ))
formula_id = cursor.lastrowid formula_id = cursor.lastrowid
# 구성 약재 추가 # 구성 약재 추가 (ingredient_code 기반)
if 'ingredients' in data: if 'ingredients' in data:
for idx, ingredient in enumerate(data['ingredients']): for idx, ingredient in enumerate(data['ingredients']):
cursor.execute(""" cursor.execute("""
INSERT INTO formula_ingredients (formula_id, herb_item_id, INSERT INTO formula_ingredients (formula_id, ingredient_code,
grams_per_cheop, notes, sort_order) grams_per_cheop, notes, sort_order)
VALUES (?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?)
""", ( """, (
formula_id, formula_id,
ingredient['herb_item_id'], ingredient.get('ingredient_code', ingredient.get('herb_item_id')),
ingredient['grams_per_cheop'], ingredient['grams_per_cheop'],
ingredient.get('notes'), ingredient.get('notes'),
idx idx
@ -710,6 +941,11 @@ def upload_purchase_excel():
# receipt_date를 문자열로 확실히 변환 # receipt_date를 문자열로 확실히 변환
receipt_date_str = str(receipt_date) receipt_date_str = str(receipt_date)
# YYYY-MM-DD 포맷으로 정규화
clean_date = receipt_date_str.replace('-', '')
if len(clean_date) == 8 and clean_date.isdigit():
receipt_date_str = f"{clean_date[:4]}-{clean_date[4:6]}-{clean_date[6:8]}"
# 입고장 번호 생성 (PR-YYYYMMDD-XXXX) # 입고장 번호 생성 (PR-YYYYMMDD-XXXX)
date_str = receipt_date_str.replace('-', '') date_str = receipt_date_str.replace('-', '')
@ -833,7 +1069,7 @@ def upload_purchase_excel():
(herb_item_id, supplier_id, receipt_line_id, received_date, origin_country, (herb_item_id, supplier_id, receipt_line_id, received_date, origin_country,
unit_price_per_g, quantity_received, quantity_onhand) unit_price_per_g, quantity_received, quantity_onhand)
VALUES (?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
""", (herb_item_id, supplier_id, line_id, str(receipt_date), """, (herb_item_id, supplier_id, line_id, receipt_date_str,
row.get('origin_country'), unit_price, quantity, quantity)) row.get('origin_country'), unit_price, quantity, quantity))
lot_id = cursor.lastrowid lot_id = cursor.lastrowid
@ -870,6 +1106,139 @@ def upload_purchase_excel():
except Exception as e: except Exception as e:
return jsonify({'success': False, 'error': str(e)}), 500 return jsonify({'success': False, 'error': str(e)}), 500
# ==================== 수동 입고 API ====================
@app.route('/api/purchase-receipts/manual', methods=['POST'])
def create_manual_receipt():
"""수동 입고 처리"""
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,))
supplier_info = cursor.fetchone()
if not supplier_info:
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]
next_num = (max_num or 0) + 1
receipt_no = f"PR-{date_str}-{next_num:04d}"
# 총 금액 계산
total_amount = sum(
float(line.get('quantity_g', 0)) * float(line.get('unit_price_per_g', 0))
for line in lines
)
# 입고장 헤더 생성
cursor.execute("""
INSERT INTO purchase_receipts (supplier_id, receipt_date, receipt_no, total_amount, source_file, notes)
VALUES (?, ?, ?, ?, 'MANUAL', ?)
""", (supplier_id, receipt_date, receipt_no, total_amount, notes))
receipt_id = cursor.lastrowid
processed_count = 0
for line in lines:
ingredient_code = line.get('ingredient_code')
quantity_g = float(line.get('quantity_g', 0))
unit_price = float(line.get('unit_price_per_g', 0))
origin_country = line.get('origin_country', '')
lot_number = line.get('lot_number', '')
expiry_date = line.get('expiry_date', '')
line_total = quantity_g * unit_price
if not ingredient_code or quantity_g <= 0:
continue
# herb_items에서 해당 ingredient_code 조회
cursor.execute("""
SELECT herb_item_id FROM herb_items
WHERE ingredient_code = ?
""", (ingredient_code,))
herb = cursor.fetchone()
if not herb:
# herb_masters에서 약재명 가져와서 herb_items 생성
cursor.execute("""
SELECT herb_name FROM herb_masters
WHERE ingredient_code = ?
""", (ingredient_code,))
master = cursor.fetchone()
herb_name = master[0] if master else ingredient_code
cursor.execute("""
INSERT INTO herb_items (ingredient_code, herb_name)
VALUES (?, ?)
""", (ingredient_code, herb_name))
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, 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, lot_number, expiry_date)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""", (herb_item_id, supplier_id, line_id, receipt_date,
origin_country, unit_price, quantity_g, quantity_g,
lot_number or None, expiry_date or None))
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, 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'])
@ -969,11 +1338,22 @@ def get_purchase_receipt_detail(receipt_id):
receipt_data = dict(receipt) receipt_data = dict(receipt)
# 입고장 상세 라인 조회 (display_name 포함) # 입고장 상세 라인 조회 (display_name 포함)
# prl.*를 쓰면 prl.lot_number/expiry_date와 il.lot_number/expiry_date가 충돌하므로 명시적으로 나열
cursor.execute(""" cursor.execute("""
SELECT SELECT
prl.*, prl.line_id,
prl.receipt_id,
prl.herb_item_id,
prl.origin_country,
prl.quantity_g,
prl.unit_price_per_g,
prl.line_total,
prl.created_at,
COALESCE(il.lot_number, prl.lot_number) as lot_number,
COALESCE(il.expiry_date, prl.expiry_date) as expiry_date,
h.herb_name, h.herb_name,
h.insurance_code, h.insurance_code,
h.ingredient_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,
@ -1096,6 +1476,165 @@ def update_purchase_receipt_line(receipt_id, line_id):
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/<int:receipt_id>/bulk', methods=['PUT'])
def bulk_update_purchase_receipt(receipt_id):
"""입고장 헤더 + 전체 라인 일괄 수정"""
try:
data = request.get_json()
notes = data.get('notes')
lines = data.get('lines', [])
with get_db() as conn:
cursor = conn.cursor()
# 입고장 존재 확인
cursor.execute("SELECT receipt_id FROM purchase_receipts WHERE receipt_id = ?", (receipt_id,))
if not cursor.fetchone():
return jsonify({'success': False, 'error': '입고장을 찾을 수 없습니다'}), 404
# 각 라인 업데이트
for line_data in lines:
line_id = line_data.get('line_id')
if not line_id:
continue
# 기존 라인 + 로트 정보 조회
cursor.execute("""
SELECT prl.*, il.lot_id, il.quantity_onhand, il.quantity_received
FROM purchase_receipt_lines prl
LEFT JOIN inventory_lots il ON prl.line_id = il.receipt_line_id
WHERE prl.line_id = ? AND prl.receipt_id = ?
""", (line_id, receipt_id))
old_line = cursor.fetchone()
if not old_line:
continue
# 수량 변경 시 재고 사용 여부 확인
new_qty = line_data.get('quantity_g')
if new_qty is not None and float(new_qty) != float(old_line['quantity_g']):
if old_line['quantity_onhand'] != old_line['quantity_received']:
used_qty = old_line['quantity_received'] - old_line['quantity_onhand']
return jsonify({
'success': False,
'error': f'이미 {used_qty}g이 사용되어 수량을 수정할 수 없습니다'
}), 400
# purchase_receipt_lines 업데이트
prl_fields = []
prl_params = []
if 'quantity_g' in line_data:
prl_fields.append('quantity_g = ?')
prl_params.append(line_data['quantity_g'])
if 'unit_price_per_g' in line_data:
prl_fields.append('unit_price_per_g = ?')
prl_params.append(line_data['unit_price_per_g'])
if 'origin_country' in line_data:
prl_fields.append('origin_country = ?')
prl_params.append(line_data['origin_country'])
if 'lot_number' in line_data:
prl_fields.append('lot_number = ?')
prl_params.append(line_data['lot_number'] or None)
if 'expiry_date' in line_data:
prl_fields.append('expiry_date = ?')
prl_params.append(line_data['expiry_date'] or None)
# line_total 자동 계산
qty = float(line_data.get('quantity_g', old_line['quantity_g']))
price = float(line_data.get('unit_price_per_g', old_line['unit_price_per_g']))
line_total = qty * price
prl_fields.append('line_total = ?')
prl_params.append(line_total)
if prl_fields:
prl_params.append(line_id)
cursor.execute(f"""
UPDATE purchase_receipt_lines
SET {', '.join(prl_fields)}
WHERE line_id = ?
""", prl_params)
# inventory_lots 업데이트
if old_line['lot_id']:
lot_fields = []
lot_params = []
if 'lot_number' in line_data:
lot_fields.append('lot_number = ?')
lot_params.append(line_data['lot_number'] or None)
if 'expiry_date' in line_data:
lot_fields.append('expiry_date = ?')
lot_params.append(line_data['expiry_date'] or None)
if 'origin_country' in line_data:
lot_fields.append('origin_country = ?')
lot_params.append(line_data['origin_country'])
if 'unit_price_per_g' in line_data:
lot_fields.append('unit_price_per_g = ?')
lot_params.append(line_data['unit_price_per_g'])
# 수량 변경 시 재고 로트도 업데이트
if new_qty is not None and float(new_qty) != float(old_line['quantity_g']):
lot_fields.append('quantity_received = ?')
lot_params.append(new_qty)
lot_fields.append('quantity_onhand = ?')
lot_params.append(new_qty)
# 재고 원장에 조정 기록
cursor.execute("""
INSERT INTO stock_ledger
(event_type, herb_item_id, lot_id, quantity_delta, notes, reference_table, reference_id)
VALUES ('ADJUST',
(SELECT herb_item_id FROM purchase_receipt_lines WHERE line_id = ?),
?, ?, '입고장 수정', 'purchase_receipt_lines', ?)
""", (line_id, old_line['lot_id'],
float(new_qty) - float(old_line['quantity_g']), line_id))
if lot_fields:
lot_params.append(old_line['lot_id'])
cursor.execute(f"""
UPDATE inventory_lots
SET {', '.join(lot_fields)}
WHERE lot_id = ?
""", lot_params)
# 입고장 헤더 업데이트
header_fields = []
header_params = []
if notes is not None:
header_fields.append('notes = ?')
header_params.append(notes)
# 총액 재계산
header_fields.append("""total_amount = (
SELECT COALESCE(SUM(line_total), 0)
FROM purchase_receipt_lines
WHERE receipt_id = ?
)""")
header_params.append(receipt_id)
header_fields.append('updated_at = CURRENT_TIMESTAMP')
header_params.append(receipt_id)
cursor.execute(f"""
UPDATE purchase_receipts
SET {', '.join(header_fields)}
WHERE receipt_id = ?
""", header_params)
return jsonify({'success': True, 'message': '입고장이 수정되었습니다'})
except Exception as e:
return jsonify({'success': False, 'error': str(e)}), 500
@app.route('/api/purchase-receipts/<int:receipt_id>', methods=['DELETE']) @app.route('/api/purchase-receipts/<int:receipt_id>', methods=['DELETE'])
def delete_purchase_receipt(receipt_id): def delete_purchase_receipt(receipt_id):
"""입고장 삭제 (재고 사용 확인 후)""" """입고장 삭제 (재고 사용 확인 후)"""
@ -1133,7 +1672,21 @@ def delete_purchase_receipt(receipt_id):
) )
""", (receipt_id,)) """, (receipt_id,))
# 2. 재고 로트 삭제 (receipt_line_id를 참조) # 2. 취소된 조제의 소비 내역 삭제 (lot_id를 참조)
cursor.execute("""
DELETE FROM compound_consumptions
WHERE lot_id IN (
SELECT lot_id FROM inventory_lots
WHERE receipt_line_id IN (
SELECT line_id FROM purchase_receipt_lines WHERE receipt_id = ?
)
)
AND compound_id IN (
SELECT compound_id FROM compounds WHERE status = 'CANCELLED'
)
""", (receipt_id,))
# 3. 재고 로트 삭제 (receipt_line_id를 참조)
cursor.execute(""" cursor.execute("""
DELETE FROM inventory_lots DELETE FROM inventory_lots
WHERE receipt_line_id IN ( WHERE receipt_line_id IN (
@ -1141,10 +1694,10 @@ def delete_purchase_receipt(receipt_id):
) )
""", (receipt_id,)) """, (receipt_id,))
# 3. 입고장 라인 삭제 (receipt_id를 참조) # 4. 입고장 라인 삭제 (receipt_id를 참조)
cursor.execute("DELETE FROM purchase_receipt_lines WHERE receipt_id = ?", (receipt_id,)) cursor.execute("DELETE FROM purchase_receipt_lines WHERE receipt_id = ?", (receipt_id,))
# 4. 입고장 헤더 삭제 # 5. 입고장 헤더 삭제
cursor.execute("DELETE FROM purchase_receipts WHERE receipt_id = ?", (receipt_id,)) cursor.execute("DELETE FROM purchase_receipts WHERE receipt_id = ?", (receipt_id,))
return jsonify({'success': True, 'message': '입고장이 삭제되었습니다'}) return jsonify({'success': True, 'message': '입고장이 삭제되었습니다'})
@ -1630,7 +2183,7 @@ def get_stock_ledger():
s.name as supplier_name, s.name as supplier_name,
CASE CASE
WHEN sl.event_type = 'PURCHASE' OR sl.event_type = 'RECEIPT' THEN pr.receipt_no WHEN sl.event_type = 'PURCHASE' OR sl.event_type = 'RECEIPT' THEN pr.receipt_no
WHEN sl.event_type = 'CONSUME' THEN WHEN sl.event_type IN ('CONSUME', 'RETURN') THEN
CASE CASE
WHEN c.prescription_no IS NOT NULL THEN c.prescription_no || ' (' || COALESCE(f.formula_name, '직접조제') || ')' WHEN c.prescription_no IS NOT NULL THEN c.prescription_no || ' (' || COALESCE(f.formula_name, '직접조제') || ')'
ELSE '조제#' || c.compound_id || ' (' || COALESCE(f.formula_name, '직접조제') || ')' ELSE '조제#' || c.compound_id || ' (' || COALESCE(f.formula_name, '직접조제') || ')'
@ -1639,7 +2192,7 @@ def get_stock_ledger():
ELSE NULL ELSE NULL
END as reference_no, END as reference_no,
CASE CASE
WHEN sl.event_type = 'CONSUME' THEN p.name WHEN sl.event_type IN ('CONSUME', 'RETURN') THEN p.name
ELSE NULL ELSE NULL
END as patient_name END as patient_name
FROM stock_ledger sl FROM stock_ledger sl
@ -1671,7 +2224,7 @@ def get_stock_ledger():
s.name as supplier_name, s.name as supplier_name,
CASE CASE
WHEN sl.event_type = 'PURCHASE' OR sl.event_type = 'RECEIPT' THEN pr.receipt_no WHEN sl.event_type = 'PURCHASE' OR sl.event_type = 'RECEIPT' THEN pr.receipt_no
WHEN sl.event_type = 'CONSUME' THEN WHEN sl.event_type IN ('CONSUME', 'RETURN') THEN
CASE CASE
WHEN c.prescription_no IS NOT NULL THEN c.prescription_no || ' (' || COALESCE(f.formula_name, '직접조제') || ')' WHEN c.prescription_no IS NOT NULL THEN c.prescription_no || ' (' || COALESCE(f.formula_name, '직접조제') || ')'
ELSE '조제#' || c.compound_id || ' (' || COALESCE(f.formula_name, '직접조제') || ')' ELSE '조제#' || c.compound_id || ' (' || COALESCE(f.formula_name, '직접조제') || ')'
@ -1680,7 +2233,7 @@ def get_stock_ledger():
ELSE NULL ELSE NULL
END as reference_no, END as reference_no,
CASE CASE
WHEN sl.event_type = 'CONSUME' THEN p.name WHEN sl.event_type IN ('CONSUME', 'RETURN') THEN p.name
ELSE NULL ELSE NULL
END as patient_name END as patient_name
FROM stock_ledger sl FROM stock_ledger sl
@ -2002,6 +2555,7 @@ def get_inventory_detail(herb_item_id):
il.quantity_onhand, il.quantity_onhand,
il.unit_price_per_g, il.unit_price_per_g,
il.received_date, il.received_date,
il.expiry_date,
il.supplier_id, il.supplier_id,
s.name as supplier_name, s.name as supplier_name,
il.quantity_onhand * il.unit_price_per_g as lot_value, il.quantity_onhand * il.unit_price_per_g as lot_value,
@ -2784,6 +3338,13 @@ def update_compound_status(compound_id):
old_status = current['status'] old_status = current['status']
# 취소 요청 시 사전 검증
if new_status == 'CANCELLED':
if old_status == 'CANCELLED':
return jsonify({'error': '이미 취소된 조제입니다'}), 400
if old_status != 'PREPARED':
return jsonify({'error': '조제완료(PREPARED) 상태에서만 취소할 수 있습니다'}), 400
# 상태 업데이트 # 상태 업데이트
update_fields = ['status = ?'] update_fields = ['status = ?']
update_values = [new_status] update_values = [new_status]
@ -2827,6 +3388,34 @@ def update_compound_status(compound_id):
""", (compound_id, old_status, new_status, """, (compound_id, old_status, new_status,
data.get('changed_by', 'system'), data.get('reason', ''))) data.get('changed_by', 'system'), data.get('reason', '')))
# 조제 취소 시 재고 복원
if new_status == 'CANCELLED':
# compound_consumptions에서 사용 내역 조회
cursor.execute("""
SELECT consumption_id, herb_item_id, lot_id, quantity_used, unit_cost_per_g
FROM compound_consumptions
WHERE compound_id = ?
""", (compound_id,))
consumptions = cursor.fetchall()
for con in consumptions:
# 재고 복원
cursor.execute("""
UPDATE inventory_lots
SET quantity_onhand = quantity_onhand + ?,
is_depleted = CASE WHEN quantity_onhand + ? > 0 THEN 0 ELSE is_depleted END
WHERE lot_id = ?
""", (con['quantity_used'], con['quantity_used'], con['lot_id']))
# stock_ledger에 RETURN 이벤트 기록 (조제 취소 복원)
cursor.execute("""
INSERT INTO stock_ledger (event_type, herb_item_id, lot_id,
quantity_delta, unit_cost_per_g,
reference_table, reference_id)
VALUES ('RETURN', ?, ?, ?, ?, 'compounds', ?)
""", (con['herb_item_id'], con['lot_id'], con['quantity_used'],
con['unit_cost_per_g'], compound_id))
# 판매 거래 기록 (결제 완료시) # 판매 거래 기록 (결제 완료시)
if new_status == 'PAID' and 'actual_payment_amount' in data: if new_status == 'PAID' and 'actual_payment_amount' in data:
cursor.execute(""" cursor.execute("""

View File

@ -114,18 +114,46 @@ CREATE TABLE IF NOT EXISTS stock_ledger (
); );
-- 8) 처방 마스터 (약속 처방) -- 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 ( CREATE TABLE IF NOT EXISTS formulas (
formula_id INTEGER PRIMARY KEY AUTOINCREMENT, formula_id INTEGER PRIMARY KEY AUTOINCREMENT,
formula_code TEXT UNIQUE, -- 처방 코드 formula_code TEXT UNIQUE, -- 처방 코드
formula_name TEXT NOT NULL, -- 처방명 (예: 쌍화탕) formula_name TEXT NOT NULL, -- 처방명 (예: 쌍화탕)
formula_type TEXT DEFAULT 'CUSTOM', -- INSURANCE(보험), CUSTOM(약속처방) formula_type TEXT DEFAULT 'CUSTOM', -- CUSTOM(약속처방), STANDARD 등
official_formula_id INTEGER, -- 100처방 마스터 참조 (원방 기반인 경우)
base_cheop INTEGER DEFAULT 20, -- 기본 첩수 (1제 기준) base_cheop INTEGER DEFAULT 20, -- 기본 첩수 (1제 기준)
base_pouches INTEGER DEFAULT 30, -- 기본 파우치수 (1제 기준) base_pouches INTEGER DEFAULT 30, -- 기본 파우치수 (1제 기준)
description TEXT, description TEXT,
is_active INTEGER DEFAULT 1, is_active INTEGER DEFAULT 1,
created_by TEXT, created_by TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP, created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (official_formula_id) REFERENCES official_formulas(official_formula_id)
); );
-- 9) 처방 구성 약재 -- 9) 처방 구성 약재

View File

@ -0,0 +1,54 @@
현재 스키마 흐름
herb_masters (성분코드 마스터)
ingredient_code: '3400H1AHM' → 인삼
├──── official_formula_ingredients (100처방 원방 구성)
│ ingredient_code + grams_per_cheop
│ → "가미패독산 원방: 인삼 3g/첩"
└──── formula_ingredients (운영 처방 구성)
ingredient_code + grams_per_cheop
→ "내 쌍화탕: 인삼 4g/첩 (가감)"
둘 다 ingredient_code 기반으로 통일되어 있어서:
- 100처방 마스터에 원방 구성을 채워두면
- 운영 처방 만들 때 원방을 복사 → 가감하는 워크플로우가 가능합니다
- 특정 제품(휴먼인삼 vs 한퓨어인삼)에 종속되지 않고, 조제 시점에 재고 기반으로 제품이 자동 선택됩니다
1. 100처방 목록에서 가미패독산 클릭 → 상세 모달
2. "내 처방으로 등록" 버튼 클릭
3. 처방 등록 모달이 열리면서 자동 프리필:
- 처방명: 어울림 가미패독산
- 구성 약재 13종 + 첩당 용량 전부 채워짐
- 각 약재의 역할(notes)도 포함
4. 여기서 자유롭게 약재 추가/제거/용량 변경 후 저장
원방을 기반으로 가감방을 만드는 워크플로우가 완성된 겁니다.
app.py - create_formula 함수:
1. formula_ingredients INSERT 컬럼을 herb_item_id → ingredient_code로 변경 (line 572)
2. official_formula_id 저장 추가 — 100처방 기반으로 생성한 처방은 원방 연결 추적 가능
static/app.js:
1. 100처방에서 "내 처방으로 등록" 시 official_formula_id를 모달 데이터에 저장
2. 처방 저장 시 official_formula_id를 API로 전달
3. 저장 완료 후 official-formula-id 데이터 초기화
이제 가미패독산 등 100처방에서 "내 처방으로 등록" → 저장이 정상 동작할 것입니다. 재고가 없는 약재도 ingredient_code 기반으로 저장되므로 문제없습니다.
1. 내 처방 목록 가감 표시 기능
처방명 "어울림" 스타일링:
- "어울림 가미패독산" → 어울림(초록색 볼드) + 가미패독산
가감 뱃지 (100처방 원방 대비 변경사항):
- 용량 변경: 생강 3g→4g — 파란색 뱃지
- 약재 추가: +건강 2g — 초록색 뱃지
- 약재 제거: -생강 — 빨간색 뱃지
- 원방 그대로인 경우: 원방 그대로 — 회색 뱃지
조제 시 가감 감지 코드(checkCustomPrescription)와 동일한 패턴을 처방 목록에도 적용했습니다.

View File

@ -0,0 +1,393 @@
# 100처방 마스터 데이터 등록 가이드
> AI Agent가 `official_formulas` / `official_formula_ingredients` 테이블을 채울 때 따라야 할 절차와 규칙.
---
## 1. DB 구조
### 1-1. `official_formulas` (처방 기본 정보)
| 컬럼 | 타입 | 설명 | 채워야 하는 값 |
|------|------|------|---------------|
| `official_formula_id` | INTEGER PK | 자동생성 | (이미 존재) |
| `formula_number` | INTEGER | 연번 1~100 | (이미 존재) |
| `formula_name` | TEXT | 처방명 (한글) | (이미 존재) |
| `formula_name_hanja` | TEXT | **한자명** | ✅ 채워야 함 |
| `source_text` | TEXT | 출전 | (이미 존재) |
| `description` | TEXT | **효능 요약** | ✅ 채워야 함 |
| `reference_notes` | TEXT | **상담 참고자료** | ✅ 채워야 함 |
### 1-2. `official_formula_ingredients` (구성 약재)
| 컬럼 | 타입 | 설명 |
|------|------|------|
| `ingredient_id` | INTEGER PK | 자동생성 |
| `official_formula_id` | INTEGER FK | 처방 ID |
| `ingredient_code` | TEXT | **herb_masters.ingredient_code** (예: `3400H1AHM`) |
| `grams_per_cheop` | REAL | **1첩당 그램수** |
| `notes` | TEXT | **역할** (군약/신약/좌약/사약 등) |
| `sort_order` | INTEGER | 정렬 순서 (1부터) |
---
## 2. 등록 절차 (Step-by-Step)
### Step 1: 대상 처방 확인
```sql
-- 미등록 처방 확인 (formula_name_hanja가 NULL이면 미등록)
SELECT official_formula_id, formula_number, formula_name, source_text
FROM official_formulas
WHERE formula_name_hanja IS NULL
ORDER BY formula_number;
```
### Step 2: 원방 조사
출전 서적 기반으로 아래 정보를 조사한다:
1. **한자명** — 예: 四君子湯
2. **효능 요약** — 한의학 용어 + 한자 병기. 예: `보기건비 (補氣健脾)`
3. **구성 약재** — 약재명, 1첩당 그램수, 군신좌사 역할
4. **상담 참고자료** — 출전, 주치, OTC 대비 장점 (1~2문장)
#### 용량 기준 원칙
- **출전(원방)의 현대 환산 용량**을 우선 적용
- 1전(錢) = 3.75g, 1냥(兩) = 37.5g 기준 환산
- 감초는 대부분 사약으로 2~3g (다른 약재보다 적은 것이 일반적)
- 동량(等分) 처방이라도 현대 임상 표준이 차등이면 표준을 따름
#### description 작성 규칙
```
한글효능 (한자효능)
```
예시:
- `발한해기, 생진서근 (發汗解肌, 生津舒筋)`
- `온보기혈 (溫補氣血)`
- `익기해표, 이기화담 (益氣解表, 理氣化痰)`
#### reference_notes 작성 규칙
1문장: 출전 + 주치 증상
나머지: OTC 대비 차별점 또는 첩제만의 장점
```
예: "상한론 원방. 외감풍한으로 인한 두통, 발열, 오한, 항강 증상에 사용. OTC 갈근탕 대비 생약량 2.16배, 생강→건강 대체 없이 원물 사용으로 발한 효과 우수."
```
#### notes(역할) 작성 규칙
| 값 | 의미 |
|----|------|
| `군약` | 주약 (主藥) — 처방의 핵심 |
| `신약` | 보조약 (臣藥) — 군약을 도움 |
| `좌약` | 보좌약 (佐藥) — 부작용 완화 또는 보조 |
| `사약` | 조화약 (使藥) — 제약 조화 (대부분 감초) |
복합 역할인 경우 괄호로 추가 설명:
- `군약(보기)` — 보기 역할의 군약
- `신약(보혈)` — 보혈 역할의 신약
### Step 3: ingredient_code 조회
```sql
-- 약재명으로 성분코드 조회 (정확 매칭 우선)
SELECT ingredient_code, herb_name
FROM herb_masters
WHERE herb_name = '인삼';
-- 정확 매칭 없으면 LIKE 검색
SELECT ingredient_code, herb_name
FROM herb_masters
WHERE herb_name LIKE '%복령%';
```
#### 주의: 동명이약 구분
| 약재 | 올바른 코드 | 주의 |
|------|------------|------|
| 진피(陳皮) | `3466H1AHM` | 진피(秦皮) `3467H1AHM`과 구분 |
| 백출 | `3204H1AHM` | 백출초 `3611H1AHM`, 백출미감침 `3610H1AHM`과 구분 |
| 감초 | `3007H1AHM` | 감초초 `3010H1AHM`, 감초밀자 `3009H1AHM`과 구분 |
| 반하 | `3182H1AHM` | 반하생강백반제 등 포제품과 구분 |
| 마황 | `3147H1AHM` | 마황탕포 `3606H1AHM`과 구분 |
### Step 4: SQL 실행
```sql
-- (1) 기본 정보 업데이트
UPDATE official_formulas SET
formula_name_hanja = '四君子湯',
description = '보기건비 (補氣健脾)',
reference_notes = '화제국방 원방. 비기허로 인한 ...',
updated_at = CURRENT_TIMESTAMP
WHERE official_formula_id = 38;
-- (2) 구성 약재 INSERT (sort_order는 1부터 순서대로)
INSERT INTO official_formula_ingredients
(official_formula_id, ingredient_code, grams_per_cheop, notes, sort_order)
VALUES
(38, '3400H1AHM', 4.0, '군약', 1), -- 인삼
(38, '3204H1AHM', 4.0, '신약', 2), -- 백출
(38, '3215H1AHM', 4.0, '좌약', 3), -- 복령
(38, '3007H1AHM', 2.0, '사약', 4); -- 감초
```
### Step 5: 검증
```sql
-- 등록 결과 확인
SELECT hm.herb_name, ofi.ingredient_code, ofi.grams_per_cheop, ofi.notes
FROM official_formula_ingredients ofi
JOIN herb_masters hm ON ofi.ingredient_code = hm.ingredient_code
WHERE ofi.official_formula_id = ?
ORDER BY ofi.sort_order;
```
검증 체크리스트:
- [ ] herb_masters에 없는 ingredient_code가 없는지
- [ ] grams_per_cheop이 0 이하인 것이 없는지
- [ ] 중복 ingredient_code가 없는지
- [ ] 1첩 총량이 상식적 범위(10~50g)인지
- [ ] 군약이 최소 1개 이상인지
---
## 3. 등록 완료 예시
### 사군자탕 (四君子湯) — ID:38
| 순서 | 약재 | 성분코드 | 1첩량 | 역할 |
|------|------|----------|-------|------|
| 1 | 인삼 | 3400H1AHM | 4.0g | 군약 |
| 2 | 백출 | 3204H1AHM | 4.0g | 신약 |
| 3 | 복령 | 3215H1AHM | 4.0g | 좌약 |
| 4 | 감초 | 3007H1AHM | 2.0g | 사약 |
- 한자명: 四君子湯
- 설명: `보기건비 (補氣健脾)`
- 1첩 총량: 14.0g
### 갈근탕 (葛根湯) — ID:3
| 순서 | 약재 | 성분코드 | 1첩량 | 역할 |
|------|------|----------|-------|------|
| 1 | 갈근 | 3002H1AHM | 8.0g | 군약 |
| 2 | 마황 | 3147H1AHM | 4.0g | 신약 |
| 3 | 계지 | 3033H1AHM | 3.0g | 좌약 |
| 4 | 작약 | 3419H1AHM | 3.0g | 좌약 |
| 5 | 생강 | 3260H1AHM | 3.0g | 좌약 |
| 6 | 대추 | 3115H1AHM | 4.0g | 좌약 |
| 7 | 감초 | 3007H1AHM | 2.0g | 사약 |
- 한자명: 葛根湯
- 설명: `발한해기, 생진서근 (發汗解肌, 生津舒筋)`
- 1첩 총량: 27.0g
---
## 4. 자주 사용되는 성분코드 (Quick Reference)
| 약재 | ingredient_code | 비고 |
|------|----------------|------|
| 감초 | 3007H1AHM | 사약 역할 빈출 |
| 갈근 | 3002H1AHM | |
| 건강 | 3017H1AHM | 생강 포제품 |
| 계지 | 3033H1AHM | |
| 길경 | 3077H1AHM | |
| 당귀 | 3105H1AHM | |
| 대추 | 3115H1AHM | |
| 마황 | 3147H1AHM | |
| 반하 | 3182H1AHM | |
| 백출 | 3204H1AHM | |
| 복령 | 3215H1AHM | |
| 생강 | 3260H1AHM | |
| 석고 | 3265H1AHM | |
| 세신 | 3285H1AHM | |
| 숙지황 | 3299H1AHM | |
| 오미자 | 3342H1AHM | |
| 인삼 | 3400H1AHM | |
| 자소엽 | 3411H1AHM | |
| 작약 | 3419H1AHM | |
| 전호 | 3433H1AHM | |
| 지각 | 3454H1AHM | |
| 진피(陳皮) | 3466H1AHM | ⚠️ 秦皮와 구분 |
| 천궁 | 3475H1AHM | |
| 황기 | 3583H1AHM | |
| 육계 | 3384H1AHM | |
---
## 5. 미등록 처방 목록 (93개)
| # | 처방명 | 출전 |
|---|--------|------|
| 1 | 가미온담탕 | 의종금감 |
| 4 | 강활유풍탕 | 의학발명 |
| 5 | 계지가용골모려탕 | 금궤요략 |
| 6 | 계지작약지모탕 | 금궤요략 |
| 7 | 곽향정기산 | 화제국방 |
| 8 | 구미강활탕 | 차사난지 |
| 9 | 궁귀교애탕 | 금궤요략 |
| 10 | 귀비탕 | 제생방 |
| 11 | 귀출파징탕 | 동의보감 |
| 12 | 금수육군전 | 경악전서 |
| 13 | 녹용대보탕 | 갑병원류서촉 |
| 14 | 당귀사역가오수유생강탕 | 상한론 |
| 15 | 당귀수산 | 의학입문 |
| 16 | 당귀육황탕 | 난실비장 |
| 17 | 당귀작약산 | 금궤요략 |
| 18 | 대강활탕 | 위생보감 |
| 19 | 대건중탕 | 금궤요략 |
| 20 | 대금음자 | 화제국방 |
| 21 | 대방풍탕 | 화제국방 |
| 22 | 대청룡탕 | 상한론 |
| 23 | 대황목단피탕 | 금궤요략 |
| 24 | 독활기생탕 | 천금방 |
| 25 | 마행의감탕 | 금궤요략 |
| 26 | 마황부자세신탕 | 상한론 |
| 27 | 반하백출천마탕 | 의학심오 |
| 28 | 반하사심탕 | 상한론 |
| 29 | 반하후박탕 | 금궤요략 |
| 30 | 방기황기탕 | 금궤요략 |
| 31 | 방풍통성산 | 선명논방 |
| 32 | 배농산급탕 | 춘림헌방함 |
| 33 | 백출산 | 외대비요 |
| 34 | 보생탕 | 부인양방 |
| 35 | 보중익기탕 | 비위론 |
| 36 | 복령음 | 외대비요 |
| 37 | 분심기음 | 직지방 |
| 39 | 사물탕 | 화제국방 |
| 40 | 삼령백출산 | 화제국방 |
| 42 | 삼출건비탕 | 동의보감 |
| 43 | 삼환사심탕 | 금궤요략 |
| 44 | 생혈윤부탕 | 의학정전 |
| 45 | 세간명목탕 | 중보만병회춘 |
| 46 | 소건중탕 | 상한론 |
| 47 | 소시호탕 | 상한론 |
| 48 | 소요산 | 화제국방 |
| 49 | 소자강기탕 | 화제국방 |
| 50 | 소적정원산 | 의학입문 |
| 52 | 소풍산 | 외과정종 |
| 53 | 소풍활혈탕 | 심씨존생서 |
| 54 | 속명탕 | 금궤요략 |
| 55 | 승마갈근탕 | 염씨소아방론 |
| 56 | 시함탕 | 중정통속상한론 |
| 57 | 시호계강탕 | 상한론 |
| 58 | 시호억간탕 | 의학입문 |
| 59 | 시호청간탕 | 구치유요 |
| 62 | 안중산 | 화제국방 |
| 63 | 양격산 | 화제국방 |
| 64 | 연령고본단 | 만병회춘 |
| 65 | 영감강미신하인탕 | 금궤요략 |
| 66 | 영계출감탕 | 상한론 |
| 67 | 오약순기산 | 화제국방 |
| 68 | 오적산 | 화제국방 |
| 69 | 온경탕 | 금궤요략 |
| 70 | 온백원 | 화제금궤 |
| 71 | 용담사간탕 | 의종금감 |
| 73 | 위령탕 | 만병회춘 |
| 74 | 육군자탕 | 부인양방 |
| 75 | 육미지황환 | 소아약증직결 |
| 76 | 육울탕 | 단계심법 |
| 77 | 이기거풍산 | 고금의감 |
| 78 | 이중환 | 상한론 |
| 79 | 이진탕 | 화제국방 |
| 80 | 인삼양영탕 | 화제국방 |
| 81 | 인삼양위탕 | 화제국방 |
| 82 | 인삼패독산 | 소아약증질결 |
| 83 | 인진오령산 | 금궤요략 |
| 84 | 자감초탕 | 상한론 |
| 85 | 자음강화탕 | 만병회춘 |
| 86 | 자음건비탕 | 만병회푼 |
| 87 | 저령탕 | 상한론 |
| 88 | 조경종옥탕 | 고금의감 |
| 89 | 지황음자 | 선명논방 |
| 90 | 진무탕 | 상한론 |
| 91 | 청간해올탕 | 증치준승 |
| 92 | 청금강화탕 | 고금의감 |
| 93 | 청상방풍탕 | 만병회춘 |
| 94 | 청서익기탕 | 비위론 |
| 95 | 청심연자음 | 화제국방 |
| 96 | 평위산 | 화제국방 |
| 97 | 형계연교탕 | 일관당 |
| 98 | 형방패독산 | 섭생중묘방 |
| 99 | 황련아교탕 | 상한론 |
| 100 | 황련해독탕 | 외대비요 |
---
## 6. Agent 실행 시 Python 코드 템플릿
```python
import sqlite3
DB_PATH = '/root/kdrug/database/kdrug.db'
conn = sqlite3.connect(DB_PATH)
c = conn.cursor()
# === 대상 처방 ===
FORMULA_ID = 38 # official_formula_id
HANJA = '四君子湯'
DESCRIPTION = '보기건비 (補氣健脾)'
REFERENCE = '화제국방 원방. 비기허로 인한 ...'
# === Step 1: 기본 정보 업데이트 ===
c.execute('''UPDATE official_formulas SET
formula_name_hanja = ?,
description = ?,
reference_notes = ?,
updated_at = CURRENT_TIMESTAMP
WHERE official_formula_id = ?''', (HANJA, DESCRIPTION, REFERENCE, FORMULA_ID))
# === Step 2: 구성 약재 등록 ===
# (official_formula_id, ingredient_code, grams_per_cheop, notes, sort_order)
ingredients = [
(FORMULA_ID, '3400H1AHM', 4.0, '군약', 1), # 인삼
(FORMULA_ID, '3204H1AHM', 4.0, '신약', 2), # 백출
(FORMULA_ID, '3215H1AHM', 4.0, '좌약', 3), # 복령
(FORMULA_ID, '3007H1AHM', 2.0, '사약', 4), # 감초
]
for ing in ingredients:
c.execute('''INSERT INTO official_formula_ingredients
(official_formula_id, ingredient_code, grams_per_cheop, notes, sort_order)
VALUES (?, ?, ?, ?, ?)''', ing)
conn.commit()
# === Step 3: 검증 ===
c.execute('''SELECT hm.herb_name, ofi.ingredient_code, ofi.grams_per_cheop, ofi.notes
FROM official_formula_ingredients ofi
JOIN herb_masters hm ON ofi.ingredient_code = hm.ingredient_code
WHERE ofi.official_formula_id = ?
ORDER BY ofi.sort_order''', (FORMULA_ID,))
total = 0
for r in c.fetchall():
total += r[2]
print(f' {r[0]} ({r[1]}): {r[2]}g ({r[3]})')
print(f' 1첩 총량: {total}g')
conn.close()
```
---
## 7. 주의사항
1. **ingredient_code는 반드시 herb_masters 테이블에 존재해야 한다** — JOIN이 실패하면 UI에 표시 안 됨
2. **한 처방에 같은 ingredient_code 중복 불가** — UNIQUE 제약
3. **기존 데이터 확인 후 INSERT** — 이미 등록된 처방에 중복 INSERT하면 에러
4. **출전(source_text)은 이미 seed 데이터로 들어가 있음** — UPDATE 불필요
5. **grams_per_cheop은 소수점 1자리까지** — 예: 4.0, 3.5, 2.0
6. **DB 경로**: `/root/kdrug/database/kdrug.db`
---
*이 문서는 kdrug 시스템의 100처방 마스터 데이터 일괄 등록을 위해 작성되었습니다.*
*최종 수정: 2026-02-18*

View File

@ -0,0 +1,9 @@
경악전서의 가미패독산과 일반 패독산은 구성 성분과 효능에서 차이가 나는 다른 처방입니다. 패독산은 감기·몸살에 쓰는 해열·발한제인 반면, 경악전서의 가미패독산은 강활, 독활, 인삼, 대황, 창출 등을 포함하여 습열독(濕熱毒)을 해소하는 益氣解表(익기해표), 散風祛濕(산풍거습) 약재들로 구성되어 있습니다.
약학정보원
약학정보원
+1
가미패독산 (경악전서 기준): 강활, 독활, 전호, 시호, 길경, 인삼, 복령, 지각, 감초, 천궁, 대황, 창출로 구성되어 있으며 생강을 더해 달입니다.
차이점: 일반 패독산(형방패독산 등)이 감기 초기 발한 위주라면, 경악전서 가미패독산은 이와 더불어 내열이나 습한 기운을 제거하는 대황과 창출 등이 추가되어 있어, 족이양경(태양방광경, 소양담경)의 열독 유주 등에 더 특화된 처방입니다.
약학정보원
약학정보원
따라서, 단순 감기 증상에는 패독산이, 열독이 심하거나 습(濕)이 겹친 경우에는 경악전서의 가미패독산이 사용될 수 있어 구성과 적응증에서 분명한 차이가 있습니

View File

@ -0,0 +1,164 @@
│ Plan to implement │
│ │
│ 입고장 수정 기능 구현 계획 │
│ │
│ Context │
│ │
│ Excel로 입고 시 유효기한, 로트번호 등을 입력할 수 없음. 기존 입고장을 수동입고 모달에 불러와 부족한 정보를 채워넣는 수정 기능이 필요함. │
│ │
│ 수정 파일 (2개) │
│ │
│ 1. /root/kdrug/app.py — 백엔드 (기존 PUT API 확장) │
│ │
│ 기존 PUT /api/purchase-receipts/<receipt_id>/lines/<line_id> 확장 (line 1171) │
│ - lot_number, expiry_date 필드를 purchase_receipt_lines UPDATE에 추가 │
│ - inventory_lots에도 lot_number, expiry_date UPDATE 추가 │
│ - 수량/단가 변경 없이 유효기한/로트번호만 추가하는 경우도 처리 가능하게 │
│ │
│ 새 엔드포인트: PUT /api/purchase-receipts/<receipt_id>
│ - 입고장 헤더(receipt_date, supplier_id, notes) 수정 │
│ - 전체 라인을 일괄 수정하는 것은 복잡하므로, 헤더만 처리 │
│ - 라인 수정은 기존 라인별 PUT API를 활용 │
│ │
│ → 대안: 전체 일괄 수정 엔드포인트 PUT /api/purchase-receipts/<receipt_id>/bulk │
│ - 모달에서 전체 라인을 한번에 보내서 일괄 업데이트 │
│ - 각 라인의 line_id + 수정 필드를 배열로 받음 │
│ - 헤더(notes) + 라인(origin_country, lot_number, expiry_date, quantity_g, unit_price_per_g) 일괄 처리 │
│ - 이 방식 채택 — 모달에서 "저장" 한 번으로 끝나므로 UX가 좋음 │
│ │
│ 2. /root/kdrug/static/app.js — 프론트엔드 │
│ │
│ A. 입고장 목록에 "수정" 버튼 추가 (loadPurchaseReceipts 함수 내) │
│ - 기존 상세 버튼 옆에 수정 버튼 추가 │
│ - 클릭 시 editReceipt(receiptId) 호출 │
│ │
│ B. editReceipt(receiptId) 함수 추가 │
│ - GET /api/purchase-receipts/<receipt_id> 로 기존 데이터 로드 │
│ - 수동입고 모달을 "수정 모드"로 열기: │
│ - 모달 제목: "입고장 수정" │
│ - 입고일, 도매상: 기존 값 세팅 (도매상은 변경 불가 — 재고 참조 때문) │
│ - 비고: 기존 값 세팅 │
│ - 품목 테이블: 기존 라인 데이터로 행 채우기 (약재 select에 기존 값 세팅, 수량/단가/원산지/로트번호/유효기한 채우기) │
│ - 약재 select: 수정 모드에서는 disabled (약재 변경은 위험) │
│ - "품목 추가" 버튼, "삭제" 버튼: 수정 모드에서는 숨김 (라인 추가/삭제는 불가) │
│ - 저장 버튼 텍스트: "수정 저장" │
│ │
│ C. 저장 로직 분기 │
│ - 모달에 data-edit-mode, data-receipt-id 속성 관리 │
│ - 저장 시: edit mode이면 PUT /api/purchase-receipts/<receipt_id>/bulk 호출 │
│ - 성공 시 모달 닫기 + 입고장 목록 새로고침 │
│ │
│ 데이터 흐름 │
│ │
│ [입고장 목록 "수정" 클릭] → GET /api/purchase-receipts/:id │
│ → 수동입고 모달에 기존 데이터 로드 (수정 모드) │
│ → 유효기한/로트번호/원산지 등 수정 │
│ → [수정 저장] → PUT /api/purchase-receipts/:id/bulk │
│ → purchase_receipt_lines UPDATE (각 라인) │
│ → inventory_lots UPDATE (lot_number, expiry_date) │
│ → purchase_receipts UPDATE (notes, total_amount) │
│ │
│ 주의사항 │
│ │
│ - 약재 변경 불가 (재고 추적 무결성) │
│ - 이미 사용된 재고의 수량 변경은 기존 validation 유지 │
│ - 라인 추가/삭제는 지원하지 않음 (수정만) │
│ │
│ 검증 │
│ │
│ 1. Excel로 입고한 입고장에서 "수정" 클릭 │
│ 2. 모달에 기존 데이터가 채워져 있는지 확인 │
│ 3. 유효기한, 로트번호 입력 후 저장 │
│ 4. 상세보기에서 변경 확인 │
│ 5. 재고 상세에서 lot_number, expiry_date 표시 확인
데이터 흐름상 이게 어디 어디 테이블에 업데이트가 되는거야?
● bulk 수정 API의 데이터 흐름을 정리하면:
PUT /api/purchase-receipts/<receipt_id>/bulk
업데이트되는 테이블 3개 (+ 조건부 1개)
┌──────┬────────────────────────┬───────────────────────────────────────────────────────────────────────────────────────────────┬──────────────────────┐
│ 순서 │ 테이블 │ 업데이트 필드 │ 조건 │
├──────┼────────────────────────┼───────────────────────────────────────────────────────────────────────────────────────────────┼──────────────────────┤
│ 1 │ purchase_receipt_lines │ quantity_g, unit_price_per_g, origin_country, lot_number, expiry_date, line_total │ 항상 (변경된 필드만) │
├──────┼────────────────────────┼───────────────────────────────────────────────────────────────────────────────────────────────┼──────────────────────┤
│ 2 │ inventory_lots │ lot_number, expiry_date, origin_country, unit_price_per_g, quantity_received, quantity_onhand │ lot_id가 있을 때 │
├──────┼────────────────────────┼───────────────────────────────────────────────────────────────────────────────────────────────┼──────────────────────┤
│ 3 │ stock_ledger │ INSERT (ADJUST 이벤트) │ 수량 변경 시에만 │
├──────┼────────────────────────┼───────────────────────────────────────────────────────────────────────────────────────────────┼──────────────────────┤
│ 4 │ purchase_receipts │ notes, total_amount, updated_at │ 항상 (헤더) │
└──────┴────────────────────────┴───────────────────────────────────────────────────────────────────────────────────────────────┴──────────────────────┘
흐름도
모달에서 "수정 저장" 클릭
├─ 각 라인 반복 ──┐
│ ├─① purchase_receipt_lines UPDATE (수량/단가/원산지/로트/유효기한)
│ ├─② inventory_lots UPDATE (로트번호/유효기한/원산지/단가/수량)
│ └─③ stock_ledger INSERT (수량 변경 시만)
└─④ purchase_receipts UPDATE (비고, 총액 재계산)
즉 purchase_receipt_lines와 inventory_lots 양쪽 다 같은 값을 저장합니다. 이전에 유효기한이 안 보였던 이유가 inventory_lots에만 저장하고 purchase_receipt_lines에는 안 넣어서 조회 시 충돌이 났던 거였기 때문에, 이제는 양쪽 모두 업데이트합니다.
정리하면:
purchase_receipt_lines — 입고 전표 (장부용)
"이 입고장에 뭘 얼마나 샀는가"를 기록하는 구매 기록 테이블
┌─────────────────────┬──────────────────────────────────────────────────────────┐
│ 용도 │ 설명 │
├─────────────────────┼──────────────────────────────────────────────────────────┤
│ 입고장 상세 조회 │ 입고장 클릭 시 "무슨 약재를 몇g, g당 얼마에 샀는지" 표시 │
├─────────────────────┼──────────────────────────────────────────────────────────┤
│ 입고장 삭제 시 참조 │ 삭제 가능 여부 판단의 기준 │
├─────────────────────┼──────────────────────────────────────────────────────────┤
│ 금액 집계 │ line_total 합산 → 입고장 total_amount │
└─────────────────────┴──────────────────────────────────────────────────────────┘
→ 입고 이후에는 거의 안 씀. 입고장 조회/출력용 원본 기록.
---
inventory_lots — 재고 로트 (실물 추적용)
"실제 재고가 지금 얼마나 남아있는가"를 추적하는 핵심 재고 테이블
┌──────────────────────────┬─────────────────────────────────────────────────────┐
│ 용도 │ 설명 │
├──────────────────────────┼─────────────────────────────────────────────────────┤
│ 조제 시 차감 │ quantity_onhand 에서 사용량 차감, FIFO 순서로 소진 │
├──────────────────────────┼─────────────────────────────────────────────────────┤
│ 재고 현황 조회 │ 약재별 현재 재고량, 로트별 잔량 표시 │
├──────────────────────────┼─────────────────────────────────────────────────────┤
│ 재고 조정 │ 실사 후 수량 보정 │
├──────────────────────────┼─────────────────────────────────────────────────────┤
│ 재고 원장 (stock_ledger) │ 입고/출고/조정 이력의 참조 대상 │
├──────────────────────────┼─────────────────────────────────────────────────────┤
│ 로트 추적 │ lot_number, expiry_date, origin_country로 품질 관리 │
├──────────────────────────┼─────────────────────────────────────────────────────┤
│ 원가 계산 │ unit_price_per_g로 조제 원가 산출 │
└──────────────────────────┴─────────────────────────────────────────────────────┘
→ 입고 이후 모든 비즈니스 로직이 이 테이블을 참조.
---
한 줄 요약
purchase_receipt_lines = "이걸 샀다" (구매 기록, 장부)
inventory_lots = "이게 남아있다" (재고 실물, 운영)
둘 다 같은 입고 건에서 1:1로 생성되지만, 이후에 실제로 조제/재고조정/원장에서 사용하는 건 inventory_lots만입니다. 그래서 lot_number나 expiry_date도 inventory_lots에 저장된 값이 중요하고, bulk 수정 시 양쪽 다 업데이트하도록 해둔 겁니다.
✻ Churned for 1m 0s
──────────────────────────────────────────────────────────────────────────────

View File

@ -0,0 +1,283 @@
1. 원방 갈근탕 vs OTC 갈근탕 성분 비교
(1) 상한론 원방 갈근탕 (기준 처방, 상대비)
전통적인 갈근탕 구성 (비율 기준):
약재 원방 용량 비율 (%)
갈근 8 29.6%
마황 4 14.8%
계지 3 11.1%
작약 3 11.1%
생강 3 11.1%
대추 4 14.8%
감초 2 7.4%
총합 27 100%
(2) 현재 OTC 갈근탕 (사진 제품 기준)
표기된 생약 환산량 총합:
작약 1 g
감초 0.67 g
마황 1.33 g
건강 0.33 g
계지 1 g
갈근 2.67 g
대추 1.33 g
총합 = 8.33 g
비율 계산:
약재 함량 비율 (%)
갈근 2.67 g 32.0%
마황 1.33 g 16.0%
계지 1.00 g 12.0%
작약 1.00 g 12.0%
건강 0.33 g 4.0%
대추 1.33 g 16.0%
감초 0.67 g 8.0%
총합 8.33 g 100%
2. 핵심 비교 결과
✔ 거의 동일한 처방 구조
특히 주요 약재:
약재 원방 OTC 평가
갈근 29.6% 32% 거의 동일
마황 14.8% 16% 동일
계지 11.1% 12% 동일
작약 11.1% 12% 동일
대추 14.8% 16% 동일
감초 7.4% 8% 동일
➡ 즉, 처방 철학은 그대로 유지됨
⚠ 차이점 1 — 생강 감소
원방 생강: 11.1%
OTC 건강: 4%
→ 이유:
보존성
안정성
위장 자극 감소
제품 표준화 목적
⚠ 차이점 2 — 총량 감소 (중요)
원방 1첩 기준 (현대 환산):
보통 30~40 g 생약
OTC:
8.33 g
약 1/4 수준
하지만
연조엑스 2.23 g로 농축됨
추출수율:
2.23 / 8.33 = 약 27%
정상적인 수율입니다.
3. 실제 임상 효과 차이
✔ 효과 방향성은 동일
발한
근육이완
해열
항염
동일
⚠ 그러나 potency 차이 존재
탕약 > OTC
이유:
탕약은
더 많은 총량
휘발성 성분 보존
생강 성분 풍부
OTC는
편의성
일정한 품질
경증에 적합
4. 실제 임상 equivalent 환산
OTC 1포 = 생약 8.33 g
탕약 1첩 = 약 30 g
OTC 3포 ≈ 탕약 1첩 약효
5. 약사 관점 핵심 요약
✔ 처방 구성 → 동일
✔ 비율 → 거의 동일
✔ 총량 → OTC가 적음
✔ 약효 강도 → 탕약 > OTC
✔ 경증 감기 → OTC 충분
✔ 근육통 강하거나 오한 심하면 → 탕약 유리
6. 결론 (핵심)
OTC 갈근탕은
✔ 원방 처방 비율을 거의 그대로 유지한 농축 extract이며
✔ 처방 자체가 바뀐 것이 아니라
✔ 총량만 줄인 표준화된 형태입니다.
“1제 20첩 → 30파우치” 기준으로 OTC 갈근탕과 정확히 potency 비교를 계산해보겠습니다.
1. 먼저 기준 정리
한약국 탕전 기준
1제 = 20첩 → 30파우치
1파우치 = 20첩 / 30
= 0.667첩
OTC 갈근탕 기준 (사진 제품)
1포 = 생약 환산량 8.33 g
2. 갈근탕 1첩의 평균 생약량
실제 한약국 갈근탕 1첩 구성:
약재 평균
갈근 8 g
마황 4 g
계지 3 g
작약 3 g
생강 3 g
대추 4 g
감초 2 g
총합 약 27 g
1첩 = 27 g 생약
3. 한약국 1파우치의 생약량
1파우치 = 0.667첩
= 27 × 0.667
= 18 g
✔ 결론:
한약국 갈근탕 1파우치 = 생약 약 18 g
4. OTC 1포 vs 한약국 1파우치 비교
구분 생약량
OTC 갈근탕 1포 8.33 g
한약국 갈근탕 1파우치 18 g
5. 비율 계산
18 / 8.33 = 2.16
✔ 한약국 1파우치 = OTC 2.16포
6. 실전 복용 기준 비교
OTC:
1회 1포
한약국:
1회 1파우치
한약국 1회 용량 = OTC 약 2.2배 potency
7. 하루 기준 비교
OTC:
1일 3포
= 8.33 × 3
= 25 g 생약
한약국:
1일 3파우치
= 18 × 3
= 54 g 생약
8. 하루 potency 비교
54 / 25 = 2.16배
✔ 동일하게 약 2.2배 차이
9. 임상적으로 매우 중요한 의미
다시 말하면
구분 potency
OTC 갈근탕 1
한약국 갈근탕 2.2
✔ 한약국 갈근탕이 약 2배 이상 강함
10. 약사 실전 해석
경증 감기
→ OTC 충분
다음 경우
→ 한약국 탕약이 훨씬 효과적
오한 심함
무한
항강 심함
근육통 심함
초기 인플루엔자
체격 큰 성인
11. 매우 중요한 추가 포인트 (추출 효율 차이)
탕약은
휘발성 성분 유지
생강 정유 유지
마황 알칼로이드 extraction optimal
OTC는
제조 안정성 위해 일부 감소
즉 실제 체감 potency는
2.2배 × (추출차이 포함)
≈ 2.5배 정도
최종 결론 (약국 기준 정확 비교)
한약국 갈근탕 1파우치 ≈ OTC 갈근탕 약 2.2포
또는
한약국 갈근탕 1일 복용량 ≈ OTC 약 6~7포 수준
원하시면
갈근탕뿐 아니라
소청룡탕
마황탕
소시호탕
도 동일 기준으로 정확 potency 비교표 만들어 드리겠습니다

View File

@ -0,0 +1,250 @@
# 한약국 첩제(탕전) vs OTC 상담 가이드
> 본 문서는 한약국에서 직접 달여 만드는 첩제(탕약)가 시중 OTC 액제/연조엑스 대비 어떤 차별점을 갖는지 체계적으로 정리한 기반 문서입니다.
> AI 상담, 알림톡, 웹 URL 설명 자료, 100처방 마스터 reference_notes 작성 시 참고합니다.
---
## 1. 용어 정리
| 용어 | 설명 |
|------|------|
| **첩제(탕약)** | 한약국에서 생약을 직접 달여 파우치로 포장한 탕전약 |
| **OTC 액제** | 약국에서 처방 없이 판매하는 한약 액상 추출물 (갈근탕, 쌍화탕 등) |
| **연조엑스** | 생약에서 추출·농축한 반고체 또는 액상 엑스제 |
| **원방** | 상한론, 금궤요략 등 원전에 기재된 원래 처방 구성 |
| **1제** | 한약국 조제 단위 = 20첩 = 30파우치 (표준) |
---
## 2. 핵심 차별점 요약 (5대 포인트)
### 2-1. 생약 총량 차이 (Potency)
한약국 첩제는 OTC 대비 **약 2~2.5배** 높은 생약량을 함유합니다.
| 구분 | 갈근탕 기준 | 비고 |
|------|-----------|------|
| 원방 1첩 생약량 | 약 27g | 전통 용량 |
| 한약국 1파우치 | 약 18g | 1제=20첩→30파우치, 0.667첩분 |
| OTC 1포 | 약 8.33g | 제품 표기 생약 환산량 |
| **파우치 vs OTC** | **2.16배** | 18g ÷ 8.33g |
- 1일 기준: 한약국 3파우치(54g) vs OTC 3포(25g) → **2.16배**
- 추출 효율 차이 감안 시 실제 체감 **약 2.5배**
> **상담 포인트:** "시중 갈근탕 1포는 저희 한약 1파우치의 절반도 안 되는 양입니다. 증상이 심하실 때는 첩제가 훨씬 효과적입니다."
### 2-2. 휘발성 성분 보존
| 구분 | 첩제 | OTC |
|------|------|-----|
| 생강 정유(gingerol) | 생강 그대로 달여 보존 | 건강(乾薑) 대체, 정유 감소 |
| 마황 알칼로이드 | 최적 추출 온도로 보존 | 공장 공정 중 일부 손실 |
| 계피 정유(cinnamaldehyde) | 탕전 직후 밀봉 보존 | 장기 보관 과정에서 감소 |
| 박하·형개 등 방향성 약재 | 후하(後下) 기법으로 보존 | 일괄 추출로 손실 |
> **상담 포인트:** "감기에 중요한 발한 성분은 휘발성이라 달인 직후가 가장 강합니다. 공장에서 만들어 유통하는 과정에서 상당 부분 날아갑니다."
### 2-3. 맞춤 처방 (가감방)
| 구분 | 첩제 | OTC |
|------|------|-----|
| 처방 조정 | 원방 기준 가감 가능 | 고정 처방, 변경 불가 |
| 용량 조절 | 환자 체질·증상에 맞춤 | 표준 1회 용량 고정 |
| 약재 추가/제거 | 가능 (가미, 거방) | 불가 |
| 첩수/파우치 조절 | 1제 기준 자유 조절 | 고정 포수 |
> **상담 포인트:** "저희는 선생님 증상에 맞춰 약재를 추가하거나 빼서 조절할 수 있습니다. 기성 제품은 모든 사람에게 같은 처방입니다."
### 2-4. 신선도와 품질 관리
| 구분 | 첩제 | OTC |
|------|------|-----|
| 조제 시점 | 주문 후 당일 탕전 | 제조일로부터 수개월~수년 |
| 유통기한 | 냉장 2주, 냉동 3개월 | 1~3년 |
| 첨가물 | 없음 (순수 탕전액) | 보존제, 감미제, 착향료 가능 |
| 추출 용매 | 정제수 | 정제수 + 에탄올(일부) |
> **상담 포인트:** "저희 한약은 오늘 달여서 바로 드리는 신선한 약입니다. 첨가물도 전혀 없습니다."
### 2-5. 복합 처방의 시너지
| 구분 | 첩제 | OTC |
|------|------|-----|
| 약재간 상호작용 | 함께 달여 시너지 극대화 | 개별 추출 후 혼합 가능 |
| 군신좌사(君臣佐使) | 전통 배합 원리 그대로 구현 | 추출 효율 중심 공정 |
| 약재 품질 확인 | 한약사가 직접 확인 | 제조사 QC에 의존 |
> **상담 포인트:** "한약은 여러 약재를 함께 달일 때 약효가 높아지는 상승 작용이 있습니다. 공장에서는 각각 추출해서 섞는 방식이라 이 시너지를 온전히 살리기 어렵습니다."
---
## 3. 가격 대비 가치 분석 (업셀링 근거)
### 3-1. 실질 복용 단가 비교
| 구분 | 단가(예시) | 1회 생약량 | g당 단가 |
|------|-----------|-----------|---------|
| OTC 갈근탕 1포 | ~1,500원 | 8.33g | ~180원/g |
| 한약국 1파우치 | ~3,000~5,000원 | 18g | ~167~278원/g |
**g당 단가는 비슷하거나 오히려 저렴** (생약량 기준)
### 3-2. 효과 대비 비용
| 시나리오 | OTC | 한약국 첩제 |
|----------|-----|-----------|
| 경증 감기 | OTC 3일분 ~13,500원 | 첩제 불필요 |
| 중등도 감기·몸살 | OTC 5일분 ~22,500원 (효과 제한적) | 첩제 3일분 ~27,000~45,000원 (빠른 회복) |
| 중증 오한·근육통 | OTC 효과 미흡 → 추가 진료비 | 첩제 단독 대응 가능 |
> **업셀링 포인트:** "OTC로 5일 드셔도 안 나으면 결국 더 쓰시게 됩니다. 첩제는 2~3일이면 확실한 차이를 느끼실 수 있어요."
---
## 4. 증상별 첩제 추천 기준
### OTC로 충분한 경우
- 경미한 감기 초기 (콧물, 미열)
- 가벼운 소화불량
- 일시적 피로감
- 예방 목적 복용
### 첩제가 확실히 유리한 경우
- 심한 오한, 고열, 무한(無汗)
- 근육통·관절통이 동반된 감기
- 만성 피로, 기혈 허약
- 수술 후 회복, 산후 보양
- 만성 소화기 질환
- 기침·천식이 심한 경우
- 체질적으로 허약한 환자
- OTC 복용 후에도 증상 지속
---
## 5. 100처방별 reference_notes 작성 가이드
각 처방의 `official_formulas.reference_notes`에 아래 구조로 작성하면 일관된 상담 자료가 됩니다.
### 작성 템플릿
```
[처방명] 상담 참고자료
■ 처방 개요
- 출전: (출전서적)
- 원방 구성: (주요 약재 나열)
- 주치: (어떤 증상에 사용하는지)
■ OTC 대비 첩제 장점
- (해당 처방 특유의 차별점)
- (OTC에서 빠지거나 줄어드는 약재)
- (potency 차이)
■ 적응 환자
- (어떤 환자에게 추천하면 좋은지)
- (OTC로는 부족한 케이스)
■ 상담 화법
- "(환자에게 직접 쓸 수 있는 멘트)"
■ 주의사항
- (금기, 주의할 체질 등)
```
### 작성 예시: 갈근탕
```
[갈근탕] 상담 참고자료
■ 처방 개요
- 출전: 상한론
- 원방 구성: 갈근 8g, 마황 4g, 계지 3g, 작약 3g, 생강 3g, 대추 4g, 감초 2g
- 주치: 외감풍한, 두통, 발열, 오한, 항강(뒷목 뻣뻣), 무한
■ OTC 대비 첩제 장점
- 생약 총량 2.16배 (파우치 18g vs OTC 8.33g)
- 생강 → OTC는 건강으로 대체, 발한 효과 감소
- 마황 알칼로이드 최적 추출 (탕전 > 공장 추출)
- 추출 효율 포함 시 실질 potency 약 2.5배
■ 적응 환자
- 심한 오한과 뒷목 뻣뻣함이 있는 감기
- 체격이 큰 성인 (OTC 용량으로는 부족)
- 근육통이 심한 초기 인플루엔자
- OTC 갈근탕 3일 복용에도 증상 지속 시
■ 상담 화법
- "시중 갈근탕은 저희 약의 절반도 안 되는 양이에요.
오한이 심하시면 저희 약이 확실히 빠릅니다."
- "뒷목이 뻣뻣하고 땀이 안 나시죠?
이런 증상에는 농도 높은 탕약이 훨씬 효과적입니다."
■ 주의사항
- 마황 함유 → 고혈압, 심장질환 환자 주의
- 자한(自汗, 저절로 땀 나는 경우) 환자에게는 부적합
- 허약 체질에는 용량 조절 필요
```
---
## 6. AI 활용 시나리오
### 6-1. 알림톡/문자 상담
```
[환자명]님, 요즘 감기가 유행이네요.
시중 감기약으로 안 낫는 심한 오한·근육통에는
저희 한약국의 갈근탕이 2배 이상 강력합니다.
▶ 자세히 보기: [웹URL]
```
### 6-2. 웹 설명 페이지 구성
1. 처방 소개 (원방 유래, 출전)
2. OTC와 비교표 (생약량, 성분 차이)
3. 이런 분께 추천 (적응 환자)
4. 가격 안내 및 주문
### 6-3. AI 챗봇 상담 데이터
- `official_formulas.reference_notes` → RAG 소스
- 환자 증상 → 100처방 중 매칭 → 첩제 장점 설명
- OTC 대비 차별점을 자동으로 안내
---
## 7. 핵심 수치 요약 (전 처방 공통)
| 지표 | 수치 | 근거 |
|------|------|------|
| 첩제 vs OTC 생약량 | 약 2~2.5배 | 1파우치 ÷ OTC 1포 |
| 휘발성 성분 보존율 | 첩제 >> OTC | 탕전 직후 밀봉 vs 공장 유통 |
| 맞춤 처방 가능 여부 | 첩제 O / OTC X | 가감방 |
| 첨가물 | 첩제 0 / OTC 有 | 보존제, 감미제 |
| 복용 편의성 | 첩제 = OTC | 파우치 포장 동일 |
---
## 부록: 처방별 OTC 존재 여부 참고
100처방 중 OTC 제품이 시판되는 주요 처방 (비교 대상):
| 처방 | OTC 시판 | 비교 포인트 |
|------|---------|-----------|
| 갈근탕 | O | 생약량 2.16배, 생강→건강 |
| 쌍화탕 | O | 녹용·당귀 품질 차이, 맞춤 가감 |
| 소청룡탕 | O | 세신·마황 용량 차이 |
| 반하사심탕 | O | 반하 품질·용량 차이 |
| 소시호탕 | O | 시호·인삼 용량 차이 |
| 보중익기탕 | O | 황기·인삼 용량 차이 |
| 십전대보탕 | O | 전체 약재 용량 차이 |
| 방풍통성산 | O | 다제 처방, 용량 차이 큼 |
| 오적산 | O | 구성 약재 다수, 총량 차이 |
| 귀비탕 | O | 용안육·산조인 품질 차이 |
> 위 처방들은 OTC와 직접 비교가 가능하므로 업셀링 효과가 가장 큰 처방군입니다.
> 나머지 처방은 OTC가 없으므로 "한약국에서만 받을 수 있는 처방"으로 포지셔닝합니다.
---
*본 문서는 한약국 운영 시스템(kdrug)의 AI 상담 기반 데이터로 활용됩니다.*
*100처방 마스터 테이블(`official_formulas.reference_notes`)과 연동하여 사용하세요.*

View File

@ -577,16 +577,69 @@ $(document).ready(function() {
// 처방 목록 로드 // 처방 목록 로드
function loadFormulas() { function loadFormulas() {
// 100처방 이름 목록을 먼저 가져온 후 내 처방 렌더링
$.get('/api/official-formulas', function(offRes) {
const officialNames = new Map();
if (offRes.success) {
offRes.data.forEach(f => officialNames.set(f.formula_name, f.formula_number));
}
$.get('/api/formulas', function(response) { $.get('/api/formulas', function(response) {
if (response.success) { if (response.success) {
const tbody = $('#formulasList'); const tbody = $('#formulasList');
tbody.empty(); tbody.empty();
response.data.forEach(formula => { response.data.forEach(formula => {
// 100처방 매칭: 정확 매칭 우선, 없으면 내 처방명이 100처방명으로 시작하는지 확인
let officialNum = officialNames.get(formula.formula_name);
if (officialNum == null) {
for (const [name, num] of officialNames) {
if (formula.formula_name.startsWith(name)) {
officialNum = num;
break;
}
}
}
const officialBadge = officialNum != null
? ` <span class="badge bg-info">100처방 #${officialNum}</span>`
: '';
// 처방명 스타일링: "어울림" 접두어 색상 처리
let displayName = formula.formula_name;
if (displayName.startsWith('어울림 ')) {
displayName = `<span class="text-success fw-bold">어울림</span> ${displayName.substring(4)}`;
}
// 가감 정보 표시 (100처방 기반 처방)
let customInfo = '';
if (formula.official_formula_id && formula.is_custom) {
let details = [];
if (formula.custom_modified && formula.custom_modified.length > 0) {
details.push(...formula.custom_modified.map(m =>
`<span class="badge bg-primary bg-opacity-75 me-1">${m}</span>`
));
}
if (formula.custom_added && formula.custom_added.length > 0) {
details.push(...formula.custom_added.map(a =>
`<span class="badge bg-success bg-opacity-75 me-1">+${a}</span>`
));
}
if (formula.custom_removed && formula.custom_removed.length > 0) {
details.push(...formula.custom_removed.map(r =>
`<span class="badge bg-danger bg-opacity-75 me-1">-${r}</span>`
));
}
if (details.length > 0) {
customInfo = `<br><span class="d-inline-flex flex-wrap gap-1 mt-1">${details.join('')}</span>`;
}
} else if (formula.official_formula_id && !formula.is_custom) {
customInfo = ` <span class="badge bg-secondary">원방 그대로</span>`;
}
tbody.append(` tbody.append(`
<tr> <tr>
<td>${formula.formula_code || '-'}</td> <td>${formula.formula_code || '-'}</td>
<td>${formula.formula_name}</td> <td>${displayName}${officialBadge}${customInfo}</td>
<td>${formula.base_cheop}</td> <td>${formula.base_cheop}</td>
<td>${formula.base_pouches}파우치</td> <td>${formula.base_pouches}파우치</td>
<td> <td>
@ -633,9 +686,214 @@ $(document).ready(function() {
} }
}); });
} }
// 내 처방 렌더링 완료 후 100처방 로드
loadOfficialFormulas();
}); });
}); // /api/official-formulas 콜백 닫기
} }
// 100처방 원방 마스터 로드
function loadOfficialFormulas(search) {
const params = search ? `?search=${encodeURIComponent(search)}` : '';
// 내 처방 이름 목록을 API에서 가져와서 비교
$.get('/api/formulas', function(formulasRes) {
const myFormulaNames = new Set();
if (formulasRes.success) {
formulasRes.data.forEach(f => myFormulaNames.add(f.formula_name));
}
$.get(`/api/official-formulas${params}`, function(response) {
if (response.success) {
const tbody = $('#officialFormulasList');
tbody.empty();
$('#officialFormulaCount').text(response.data.length);
response.data.forEach(formula => {
// 등록 여부: 정확 매칭 또는 내 처방명이 100처방명으로 시작
const isRegistered = myFormulaNames.has(formula.formula_name)
|| [...myFormulaNames].some(name => name.startsWith(formula.formula_name));
const statusBadge = isRegistered
? '<span class="badge bg-success">등록됨</span>'
: '<span class="badge bg-outline-secondary text-muted">미등록</span>';
const hasNotes = formula.reference_notes ? '<i class="bi bi-journal-text text-info ms-1" title="참고자료 있음"></i>' : '';
tbody.append(`
<tr class="official-formula-row" style="cursor:pointer"
data-id="${formula.official_formula_id}"
data-number="${formula.formula_number}"
data-name="${formula.formula_name}"
data-hanja="${formula.formula_name_hanja || ''}"
data-source="${formula.source_text || ''}"
data-description="${(formula.description || '').replace(/"/g, '&quot;')}"
data-notes="${(formula.reference_notes || '').replace(/"/g, '&quot;')}">
<td class="text-center">${formula.formula_number}</td>
<td><strong>${formula.formula_name}</strong>${hasNotes}</td>
<td class="text-muted">${formula.formula_name_hanja || '-'}</td>
<td>${formula.source_text || '-'}</td>
<td class="text-center">${statusBadge}</td>
</tr>
`);
});
if (response.data.length === 0) {
tbody.html('<tr><td colspan="5" class="text-center text-muted">검색 결과가 없습니다.</td></tr>');
}
}
});
}); // /api/formulas 콜백 닫기
}
// 100처방 검색 이벤트
let officialSearchTimer = null;
$(document).on('input', '#officialFormulaSearch', function() {
clearTimeout(officialSearchTimer);
const search = $(this).val().trim();
officialSearchTimer = setTimeout(() => {
loadOfficialFormulas(search);
}, 300);
});
// 100처방 행 클릭 → 상세/참고자료 모달
$(document).on('click', '.official-formula-row', function() {
const row = $(this);
const id = row.data('id');
$('#officialFormulaModal').data('formula-id', id);
$('#ofModalNumber').text(row.data('number'));
$('#ofModalName').text(row.data('name'));
$('#ofModalHanja').text(row.data('hanja') || '');
$('#ofModalSource').text(row.data('source') || '-');
$('#ofEditHanja').val(row.data('hanja') || '');
$('#ofEditDescription').val(row.data('description') || '');
$('#ofEditReferenceNotes').val(row.data('notes') || '');
// 원방 구성 약재 로드
$.get(`/api/official-formulas/${id}/ingredients`, function(res) {
const section = $('#ofIngredientsSection');
const tbody = $('#ofIngredientsList');
tbody.empty();
if (res.success && res.data.length > 0) {
let totalGrams = 0;
res.data.forEach((ing, idx) => {
totalGrams += ing.grams_per_cheop;
tbody.append(`
<tr>
<td class="text-muted">${idx + 1}</td>
<td><strong>${ing.herb_name}</strong> <small class="text-muted">${ing.herb_name_hanja || ''}</small></td>
<td class="text-end">${ing.grams_per_cheop}g</td>
<td><small class="text-muted">${ing.notes || '-'}</small></td>
</tr>
`);
});
$('#ofIngredientCount').text(res.data.length);
$('#ofTotalGrams').text(totalGrams.toFixed(1));
section.show();
} else {
section.hide();
}
});
$('#officialFormulaModal').modal('show');
});
// 100처방 참고자료 저장
$(document).on('click', '#saveOfficialFormulaBtn', function() {
const id = $('#officialFormulaModal').data('formula-id');
$.ajax({
url: `/api/official-formulas/${id}`,
method: 'PUT',
contentType: 'application/json',
data: JSON.stringify({
formula_name_hanja: $('#ofEditHanja').val().trim(),
description: $('#ofEditDescription').val().trim(),
reference_notes: $('#ofEditReferenceNotes').val().trim()
}),
success: function(response) {
if (response.success) {
alert('저장되었습니다.');
$('#officialFormulaModal').modal('hide');
loadOfficialFormulas($('#officialFormulaSearch').val().trim());
}
},
error: function(xhr) {
alert(xhr.responseJSON?.error || '저장 중 오류가 발생했습니다.');
}
});
});
// 100처방 → 내 처방으로 등록
$(document).on('click', '#createFromOfficialBtn', function() {
const id = $('#officialFormulaModal').data('formula-id');
const name = $('#ofModalName').text();
const description = $('#ofEditDescription').val().trim();
// 100처방 모달 닫기
$('#officialFormulaModal').modal('hide');
// 처방 등록 모달 초기화 (신규 모드)
$('#formulaModal').data('edit-mode', false);
$('#formulaModal').data('formula-id', null);
$('#formulaModal .modal-title').text('처방 등록 (원방 기반)');
$('#formulaForm')[0].reset();
$('#formulaIngredients').empty();
// 기본값 세팅
$('#formulaName').val(`어울림 ${name}`);
$('#formulaType').val('CUSTOM');
$('#baseCheop').val(20);
$('#basePouches').val(30);
$('#formulaDescription').val(description);
$('#formulaModal').data('official-formula-id', id);
// 원방 구성 약재 로드
$.get(`/api/official-formulas/${id}/ingredients`, function(res) {
if (res.success && res.data.length > 0) {
formulaIngredientCount = 0;
res.data.forEach(ing => {
formulaIngredientCount++;
$('#formulaIngredients').append(`
<tr data-row="${formulaIngredientCount}">
<td>
<select class="form-control form-control-sm herb-select">
<option value="${ing.ingredient_code}" selected>${ing.herb_name}</option>
</select>
</td>
<td>
<input type="number" class="form-control form-control-sm grams-input"
min="0.1" step="0.1" value="${ing.grams_per_cheop}">
</td>
<td>
<input type="text" class="form-control form-control-sm notes-input" value="${ing.notes || ''}">
</td>
<td>
<button type="button" class="btn btn-sm btn-outline-danger remove-ingredient">
<i class="bi bi-x"></i>
</button>
</td>
</tr>
`);
// 약재 select에 전체 목록 로드 (현재 값 유지)
const selectEl = $(`#formulaIngredients tr[data-row="${formulaIngredientCount}"] .herb-select`);
loadHerbsForSelectWithCurrent(selectEl, ing.ingredient_code, ing.herb_name);
});
// 삭제 버튼 이벤트
$('.remove-ingredient').on('click', function() {
$(this).closest('tr').remove();
});
}
// 처방 등록 모달 열기
$('#formulaModal').modal('show');
});
});
// 처방 상세 정보 표시 함수 // 처방 상세 정보 표시 함수
function showFormulaDetail(formulaId, formulaName) { function showFormulaDetail(formulaId, formulaName) {
// 모달에 formulaId 저장 // 모달에 formulaId 저장
@ -897,7 +1155,8 @@ $(document).ready(function() {
base_pouches: parseInt($('#basePouches').val()), base_pouches: parseInt($('#basePouches').val()),
description: $('#formulaDescription').val(), description: $('#formulaDescription').val(),
efficacy: $('#formulaEfficacy').val(), efficacy: $('#formulaEfficacy').val(),
ingredients: ingredients ingredients: ingredients,
official_formula_id: $('#formulaModal').data('official-formula-id') || null
}; };
// 수정 모드인지 확인 // 수정 모드인지 확인
@ -922,6 +1181,7 @@ $(document).ready(function() {
// 수정 모드 초기화 // 수정 모드 초기화
$('#formulaModal').data('edit-mode', false); $('#formulaModal').data('edit-mode', false);
$('#formulaModal').data('formula-id', null); $('#formulaModal').data('formula-id', null);
$('#formulaModal').data('official-formula-id', null);
$('#formulaModal .modal-title').text('처방 등록'); $('#formulaModal .modal-title').text('처방 등록');
loadFormulas(); loadFormulas();
} }
@ -1459,6 +1719,9 @@ $(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>
<button class="btn btn-sm btn-outline-danger cancel-compound" data-id="${compound.compound_id}">
<i class="bi bi-x-circle"></i>
</button>
` : ''} ` : ''}
${compound.status === 'PAID' ? ` ${compound.status === 'PAID' ? `
<button class="btn btn-sm btn-outline-primary process-delivery" data-id="${compound.compound_id}"> <button class="btn btn-sm btn-outline-primary process-delivery" data-id="${compound.compound_id}">
@ -1498,6 +1761,31 @@ $(document).ready(function() {
const compoundId = $(this).data('id'); const compoundId = $(this).data('id');
processDelivery(compoundId); processDelivery(compoundId);
}); });
// 조제 취소 버튼 이벤트
$('.cancel-compound').on('click', function() {
const compoundId = $(this).data('id');
if (confirm('정말 취소하시겠습니까? 사용된 재고가 복원됩니다.')) {
$.ajax({
url: `/api/compounds/${compoundId}/status`,
method: 'POST',
contentType: 'application/json',
data: JSON.stringify({ status: 'CANCELLED', reason: '조제 취소 (재고 복원)' }),
success: function(response) {
if (response.success) {
alert('조제가 취소되었고 재고가 복원되었습니다.');
loadCompounds();
} else {
alert(response.error || '취소 처리 중 오류가 발생했습니다.');
}
},
error: function(xhr) {
const err = xhr.responseJSON;
alert(err?.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);
@ -1754,6 +2042,7 @@ $(document).ready(function() {
<th>수량</th> <th>수량</th>
<th>단가</th> <th>단가</th>
<th>입고일</th> <th>입고일</th>
<th>유통기한</th>
<th>도매상</th> <th>도매상</th>
</tr> </tr>
</thead> </thead>
@ -1776,6 +2065,7 @@ $(document).ready(function() {
<td>${lot.quantity_onhand.toFixed(1)}g</td> <td>${lot.quantity_onhand.toFixed(1)}g</td>
<td>${formatCurrency(lot.unit_price_per_g)}</td> <td>${formatCurrency(lot.unit_price_per_g)}</td>
<td>${lot.received_date}</td> <td>${lot.received_date}</td>
<td>${lot.expiry_date || '-'}</td>
<td>${lot.supplier_name || '-'}</td> <td>${lot.supplier_name || '-'}</td>
</tr>`; </tr>`;
}); });
@ -1887,6 +2177,9 @@ $(document).ready(function() {
<button class="btn btn-sm btn-outline-info view-receipt" data-id="${receipt.receipt_id}"> <button class="btn btn-sm btn-outline-info view-receipt" data-id="${receipt.receipt_id}">
<i class="bi bi-eye"></i> <i class="bi bi-eye"></i>
</button> </button>
<button class="btn btn-sm btn-outline-warning edit-receipt" data-id="${receipt.receipt_id}">
<i class="bi bi-pencil"></i>
</button>
<button class="btn btn-sm btn-outline-danger delete-receipt" data-id="${receipt.receipt_id}"> <button class="btn btn-sm btn-outline-danger delete-receipt" data-id="${receipt.receipt_id}">
<i class="bi bi-trash"></i> <i class="bi bi-trash"></i>
</button> </button>
@ -1901,6 +2194,11 @@ $(document).ready(function() {
viewReceiptDetail(receiptId); viewReceiptDetail(receiptId);
}); });
$('.edit-receipt').on('click', function() {
const receiptId = $(this).data('id');
editReceipt(receiptId);
});
$('.delete-receipt').on('click', function() { $('.delete-receipt').on('click', function() {
const receiptId = $(this).data('id'); const receiptId = $(this).data('id');
if (confirm('정말 이 입고장을 삭제하시겠습니까? 사용되지 않은 재고만 삭제 가능합니다.')) { if (confirm('정말 이 입고장을 삭제하시겠습니까? 사용되지 않은 재고만 삭제 가능합니다.')) {
@ -2028,6 +2326,13 @@ $(document).ready(function() {
response.data.forEach(supplier => { response.data.forEach(supplier => {
filterSelect.append(`<option value="${supplier.supplier_id}">${supplier.name}</option>`); filterSelect.append(`<option value="${supplier.supplier_id}">${supplier.name}</option>`);
}); });
// 수동 입고용 셀렉트 박스도 업데이트
const manualSelect = $('#manualReceiptSupplier');
manualSelect.empty().append('<option value="">도매상을 선택하세요</option>');
response.data.forEach(supplier => {
manualSelect.append(`<option value="${supplier.supplier_id}">${supplier.name}</option>`);
});
} }
}); });
} }
@ -2066,6 +2371,377 @@ $(document).ready(function() {
}); });
}); });
// ==================== 수동 입고 ====================
// 전체 약재 목록 로드 (입고용 - 재고 필터 없음)
function loadAllHerbsForSelect(selectElement, initialValue) {
$.get('/api/herbs/masters', function(response) {
if (response.success) {
selectElement.empty().append('<option value="">약재 선택</option>');
response.data.forEach(herb => {
let displayName = herb.herb_name;
if (herb.herb_name_hanja) {
displayName += ` (${herb.herb_name_hanja})`;
}
selectElement.append(`<option value="${herb.ingredient_code}" data-herb-name="${herb.herb_name}">${displayName}</option>`);
});
if (initialValue) {
selectElement.val(initialValue);
}
}
});
}
// 입고장 수정 모드
function editReceipt(receiptId) {
$.get(`/api/purchase-receipts/${receiptId}`, function(response) {
if (!response.success) {
alert('입고장 데이터를 불러올 수 없습니다: ' + response.error);
return;
}
const receipt = response.data;
const modal = $('#manualReceiptModal');
// 수정 모드 플래그 설정
modal.data('edit-mode', true);
modal.data('receipt-id', receiptId);
// 모달 제목 변경
modal.find('.modal-title').html('<i class="bi bi-pencil"></i> 입고장 수정');
modal.find('.modal-header').removeClass('bg-success').addClass('bg-warning');
// 헤더 정보 채우기
$('#manualReceiptDate').val(receipt.receipt_date);
$('#manualReceiptSupplier').val(receipt.supplier_id);
$('#manualReceiptSupplier').prop('disabled', true);
$('#manualReceiptNotes').val(receipt.notes || '');
// 품목 추가 버튼 숨김
$('#addManualReceiptLineBtn').hide();
// 기존 라인 비우기
$('#manualReceiptLines').empty();
manualReceiptLineCount = 0;
// 저장 버튼 텍스트 변경
$('#saveManualReceiptBtn').html('<i class="bi bi-check-circle"></i> 수정 저장');
// 기존 라인 데이터로 행 채우기
receipt.lines.forEach(line => {
manualReceiptLineCount++;
const row = `
<tr data-row="${manualReceiptLineCount}" data-line-id="${line.line_id}">
<td>
<select class="form-control form-control-sm manual-herb-select" disabled>
<option value="">약재 선택</option>
</select>
</td>
<td>
<input type="number" class="form-control form-control-sm manual-qty-input text-end"
min="0.1" step="0.1" value="${line.quantity_g || ''}">
</td>
<td>
<input type="number" class="form-control form-control-sm manual-price-input text-end"
min="0" step="0.1" value="${line.unit_price_per_g || ''}">
</td>
<td class="text-end manual-line-total">${(line.line_total || 0).toLocaleString('ko-KR')}</td>
<td>
<select class="form-control form-control-sm manual-origin-input">
<option value="">원산지 선택</option>
${['한국','중국','베트남','인도','태국','페루','일본','기타'].map(c =>
`<option value="${c}" ${(line.origin_country || '') === c ? 'selected' : ''}>${c}</option>`
).join('')}
</select>
</td>
<td>
<input type="text" class="form-control form-control-sm manual-lot-input"
placeholder="로트번호" value="${line.lot_number || ''}">
</td>
<td>
<input type="date" class="form-control form-control-sm manual-expiry-input"
value="${line.expiry_date || ''}">
</td>
<td></td>
</tr>`;
$('#manualReceiptLines').append(row);
const newRow = $(`#manualReceiptLines tr[data-row="${manualReceiptLineCount}"]`);
// 약재 select에 옵션 로드 후 기존 값 선택
loadAllHerbsForSelect(newRow.find('.manual-herb-select'), line.ingredient_code);
// 금액 자동 계산 이벤트
newRow.find('.manual-qty-input, .manual-price-input').on('input', function() {
updateManualReceiptLineTotals();
});
});
updateManualReceiptLineTotals();
// 모달 열기 (show.bs.modal 이벤트 비활성화를 위해 직접 설정)
modal.modal('show');
});
}
let manualReceiptLineCount = 0;
function addManualReceiptLine() {
manualReceiptLineCount++;
const row = `
<tr data-row="${manualReceiptLineCount}">
<td>
<select class="form-control form-control-sm manual-herb-select">
<option value="">약재 선택</option>
</select>
</td>
<td>
<input type="number" class="form-control form-control-sm manual-qty-input text-end"
min="0.1" step="0.1" placeholder="0.0">
</td>
<td>
<input type="number" class="form-control form-control-sm manual-price-input text-end"
min="0" step="0.1" placeholder="0.0">
</td>
<td class="text-end manual-line-total">0</td>
<td>
<select class="form-control form-control-sm manual-origin-input">
<option value="">원산지 선택</option>
<option value="한국">한국</option>
<option value="중국">중국</option>
<option value="베트남">베트남</option>
<option value="인도">인도</option>
<option value="태국">태국</option>
<option value="페루">페루</option>
<option value="일본">일본</option>
<option value="기타">기타</option>
</select>
</td>
<td>
<input type="text" class="form-control form-control-sm manual-lot-input" placeholder="로트번호">
</td>
<td>
<input type="date" class="form-control form-control-sm manual-expiry-input">
</td>
<td>
<button type="button" class="btn btn-sm btn-outline-danger remove-manual-line">
<i class="bi bi-x"></i>
</button>
</td>
</tr>`;
$('#manualReceiptLines').append(row);
const newRow = $(`#manualReceiptLines tr[data-row="${manualReceiptLineCount}"]`);
loadAllHerbsForSelect(newRow.find('.manual-herb-select'));
// 금액 자동 계산
newRow.find('.manual-qty-input, .manual-price-input').on('input', function() {
updateManualReceiptLineTotals();
});
// 삭제 버튼
newRow.find('.remove-manual-line').on('click', function() {
$(this).closest('tr').remove();
updateManualReceiptLineTotals();
});
}
function updateManualReceiptLineTotals() {
let totalQty = 0;
let totalAmount = 0;
$('#manualReceiptLines tr').each(function() {
const qty = parseFloat($(this).find('.manual-qty-input').val()) || 0;
const price = parseFloat($(this).find('.manual-price-input').val()) || 0;
const lineTotal = qty * price;
$(this).find('.manual-line-total').text(lineTotal.toLocaleString('ko-KR'));
totalQty += qty;
totalAmount += lineTotal;
});
$('#manualReceiptTotalQty').text(totalQty.toLocaleString('ko-KR'));
$('#manualReceiptTotalAmount').text(totalAmount.toLocaleString('ko-KR'));
}
// 모달 열릴 때 초기화 (수정 모드가 아닐 때만)
$('#manualReceiptModal').on('show.bs.modal', function() {
const modal = $(this);
if (modal.data('edit-mode')) {
// 수정 모드에서는 초기화하지 않음 (editReceipt에서 이미 설정함)
return;
}
// 새 입고 모드 초기화
modal.find('.modal-title').html('<i class="bi bi-plus-circle"></i> 수동 입고');
modal.find('.modal-header').removeClass('bg-warning').addClass('bg-success');
$('#saveManualReceiptBtn').html('<i class="bi bi-check-circle"></i> 입고 저장');
$('#addManualReceiptLineBtn').show();
$('#manualReceiptSupplier').prop('disabled', false);
const today = new Date().toISOString().split('T')[0];
$('#manualReceiptDate').val(today);
$('#manualReceiptSupplier').val('');
$('#manualReceiptNotes').val('');
$('#manualReceiptLines').empty();
manualReceiptLineCount = 0;
updateManualReceiptLineTotals();
addManualReceiptLine();
});
// 모달 닫힐 때 수정 모드 플래그 초기화
$('#manualReceiptModal').on('hidden.bs.modal', function() {
const modal = $(this);
modal.removeData('edit-mode');
modal.removeData('receipt-id');
$('#manualReceiptSupplier').prop('disabled', false);
});
// 품목 추가 버튼
$('#addManualReceiptLineBtn').on('click', function() {
addManualReceiptLine();
});
// 새 도매상 등록 버튼 (수동 입고 모달에서)
$('#manualReceiptAddSupplierBtn').on('click', function() {
$('#supplierModal').modal('show');
});
// 도매상 모달이 수동입고 모달 위에 뜨도록 z-index 조정
$('#supplierModal').on('shown.bs.modal', function() {
if ($('#manualReceiptModal').hasClass('show')) {
$(this).css('z-index', 1060);
$('.modal-backdrop').last().css('z-index', 1055);
}
});
$('#supplierModal').on('hidden.bs.modal', function() {
$(this).css('z-index', '');
});
// 입고 저장 (새 입고 / 수정 공통)
$('#saveManualReceiptBtn').on('click', function() {
const modal = $('#manualReceiptModal');
const isEditMode = modal.data('edit-mode');
const receiptId = modal.data('receipt-id');
const supplierId = $('#manualReceiptSupplier').val();
const receiptDate = $('#manualReceiptDate').val();
const notes = $('#manualReceiptNotes').val();
if (!supplierId) {
alert('도매상을 선택해주세요.');
return;
}
if (!receiptDate) {
alert('입고일을 입력해주세요.');
return;
}
const lines = [];
let valid = true;
$('#manualReceiptLines tr').each(function() {
const ingredientCode = $(this).find('.manual-herb-select').val();
const qty = parseFloat($(this).find('.manual-qty-input').val()) || 0;
const price = parseFloat($(this).find('.manual-price-input').val()) || 0;
if (!ingredientCode) {
valid = false;
alert('약재를 선택해주세요.');
return false;
}
if (qty <= 0) {
valid = false;
alert('수량을 입력해주세요.');
return false;
}
if (price <= 0) {
valid = false;
alert('단가를 입력해주세요.');
return false;
}
const lineData = {
ingredient_code: ingredientCode,
quantity_g: qty,
unit_price_per_g: price,
origin_country: $(this).find('.manual-origin-input').val(),
lot_number: $(this).find('.manual-lot-input').val(),
expiry_date: $(this).find('.manual-expiry-input').val()
};
// 수정 모드에서는 line_id 포함
if (isEditMode) {
lineData.line_id = $(this).data('line-id');
}
lines.push(lineData);
});
if (!valid) return;
if (lines.length === 0) {
alert('입고 품목을 1개 이상 추가해주세요.');
return;
}
const btn = $(this);
btn.prop('disabled', true).text('저장 중...');
if (isEditMode) {
// 수정 모드: PUT bulk
$.ajax({
url: `/api/purchase-receipts/${receiptId}/bulk`,
method: 'PUT',
contentType: 'application/json',
data: JSON.stringify({
notes: notes,
lines: lines
}),
success: function(response) {
if (response.success) {
alert('입고장이 수정되었습니다.');
$('#manualReceiptModal').modal('hide');
loadPurchaseReceipts();
} else {
alert('오류: ' + response.error);
}
},
error: function(xhr) {
const msg = xhr.responseJSON ? xhr.responseJSON.error : '서버 오류가 발생했습니다.';
alert('오류: ' + msg);
},
complete: function() {
btn.prop('disabled', false).html('<i class="bi bi-check-circle"></i> 수정 저장');
}
});
} else {
// 새 입고 모드: POST
$.ajax({
url: '/api/purchase-receipts/manual',
method: 'POST',
contentType: 'application/json',
data: JSON.stringify({
supplier_id: supplierId,
receipt_date: receiptDate,
notes: notes,
lines: lines
}),
success: function(response) {
if (response.success) {
alert(`수동 입고 완료!\n입고번호: ${response.receipt_no}\n품목 수: ${response.summary.item_count}\n총 금액: ${response.summary.total_amount}`);
$('#manualReceiptModal').modal('hide');
loadPurchaseReceipts();
} else {
alert('오류: ' + response.error);
}
},
error: function(xhr) {
const msg = xhr.responseJSON ? xhr.responseJSON.error : '서버 오류가 발생했습니다.';
alert('오류: ' + msg);
},
complete: function() {
btn.prop('disabled', false).html('<i class="bi bi-check-circle"></i> 입고 저장');
}
});
}
});
// 입고장 업로드 // 입고장 업로드
$('#purchaseUploadForm').on('submit', function(e) { $('#purchaseUploadForm').on('submit', function(e) {
e.preventDefault(); e.preventDefault();
@ -2423,6 +3099,14 @@ $(document).ready(function() {
typeLabel = '보정'; typeLabel = '보정';
typeBadge = 'badge bg-warning'; typeBadge = 'badge bg-warning';
break; break;
case 'RETURN':
typeLabel = '반환';
typeBadge = 'badge bg-info';
break;
case 'DISCARD':
typeLabel = '폐기';
typeBadge = 'badge bg-dark';
break;
default: default:
typeLabel = entry.event_type; typeLabel = entry.event_type;
typeBadge = 'badge bg-secondary'; typeBadge = 'badge bg-secondary';

View File

@ -266,6 +266,9 @@
<div id="purchase" class="main-content"> <div id="purchase" class="main-content">
<div class="d-flex justify-content-between align-items-center mb-4"> <div class="d-flex justify-content-between align-items-center mb-4">
<h3>입고 관리</h3> <h3>입고 관리</h3>
<button class="btn btn-success" data-bs-toggle="modal" data-bs-target="#manualReceiptModal">
<i class="bi bi-plus-circle"></i> 수동 입고
</button>
</div> </div>
<!-- 입고장 목록 --> <!-- 입고장 목록 -->
@ -352,13 +355,17 @@
<!-- Formulas Page --> <!-- Formulas Page -->
<div id="formulas" class="main-content"> <div id="formulas" class="main-content">
<!-- 내 처방 목록 -->
<div class="d-flex justify-content-between align-items-center mb-4"> <div class="d-flex justify-content-between align-items-center mb-4">
<h3>처방 관리</h3> <h3>처방 관리</h3>
<button class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#formulaModal"> <button class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#formulaModal">
<i class="bi bi-plus-circle"></i> 새 처방 등록 <i class="bi bi-plus-circle"></i> 새 처방 등록
</button> </button>
</div> </div>
<div class="card"> <div class="card mb-4">
<div class="card-header bg-white">
<h6 class="mb-0"><i class="bi bi-journal-medical"></i> 내 처방 목록</h6>
</div>
<div class="card-body"> <div class="card-body">
<table class="table table-hover"> <table class="table table-hover">
<thead> <thead>
@ -377,6 +384,35 @@
</table> </table>
</div> </div>
</div> </div>
<!-- 100처방 원방 마스터 -->
<div class="card">
<div class="card-header bg-white d-flex justify-content-between align-items-center">
<h6 class="mb-0"><i class="bi bi-book"></i> 100처방 원방 마스터 <span class="badge bg-secondary" id="officialFormulaCount">0</span></h6>
<div style="width: 300px;">
<input type="text" class="form-control form-control-sm" id="officialFormulaSearch"
placeholder="처방명/출전 검색...">
</div>
</div>
<div class="card-body p-0">
<div class="table-responsive" style="max-height: 500px; overflow-y: auto;">
<table class="table table-hover table-sm mb-0">
<thead class="table-light" style="position: sticky; top: 0; z-index: 1;">
<tr>
<th width="60">연번</th>
<th>처방명</th>
<th>한자명</th>
<th>출전</th>
<th width="80">상태</th>
</tr>
</thead>
<tbody id="officialFormulasList">
<!-- Dynamic content -->
</tbody>
</table>
</div>
</div>
</div>
</div> </div>
<!-- Compound Page --> <!-- Compound Page -->
@ -1664,6 +1700,78 @@
</div> </div>
</div> </div>
<!-- 100처방 상세/참고자료 모달 -->
<div class="modal fade" id="officialFormulaModal" tabindex="-1">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header bg-info text-white">
<h5 class="modal-title">
<i class="bi bi-book"></i>
<span id="ofModalNumber"></span>. <span id="ofModalName"></span>
<small id="ofModalHanja" class="ms-2"></small>
</h5>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div class="row mb-3">
<div class="col-md-6">
<label class="form-label fw-bold">출전</label>
<p id="ofModalSource" class="text-muted">-</p>
</div>
<div class="col-md-6">
<label class="form-label fw-bold">한자명</label>
<input type="text" class="form-control form-control-sm" id="ofEditHanja" placeholder="예: 加味溫膽湯">
</div>
</div>
<div class="mb-3">
<label class="form-label fw-bold">설명/효능</label>
<textarea class="form-control" id="ofEditDescription" rows="2" placeholder="처방의 주요 효능..."></textarea>
</div>
<!-- 원방 구성 약재 -->
<div class="mb-3" id="ofIngredientsSection" style="display:none;">
<label class="form-label fw-bold">
<i class="bi bi-list-ul"></i> 원방 구성
<span class="badge bg-secondary ms-1" id="ofIngredientCount">0</span>
<span class="badge bg-primary ms-1" id="ofTotalGrams">0</span>g/첩
</label>
<div class="table-responsive" style="max-height: 300px; overflow-y: auto;">
<table class="table table-sm table-hover mb-0">
<thead class="table-light" style="position: sticky; top: 0;">
<tr>
<th width="40">#</th>
<th>약재명</th>
<th width="80" class="text-end">첩당</th>
<th>역할</th>
</tr>
</thead>
<tbody id="ofIngredientsList">
</tbody>
</table>
</div>
</div>
<div class="mb-3">
<label class="form-label fw-bold">
<i class="bi bi-lightbulb"></i> 상담 참고자료
<small class="text-muted fw-normal">(OTC 대비 차별점, 구성 해설, 업셀링 포인트 등)</small>
</label>
<textarea class="form-control" id="ofEditReferenceNotes" rows="10"
placeholder="예: 경악전서의 가미패독산은 일반 패독산과 달리...&#10;&#10;• OTC 대비 차별점&#10;• 구성 약재 해설&#10;• 적응증 상세&#10;• 상담 시 활용 포인트"></textarea>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-success" id="createFromOfficialBtn">
<i class="bi bi-plus-circle"></i> 내 처방으로 등록
</button>
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">닫기</button>
<button type="button" class="btn btn-info text-white" id="saveOfficialFormulaBtn">
<i class="bi bi-check-lg"></i> 저장
</button>
</div>
</div>
</div>
</div>
<!-- Formula Detail Modal (처방 상세 모달) --> <!-- Formula Detail Modal (처방 상세 모달) -->
<div class="modal fade" id="formulaDetailModal" tabindex="-1"> <div class="modal fade" id="formulaDetailModal" tabindex="-1">
<div class="modal-dialog modal-xl"> <div class="modal-dialog modal-xl">
@ -1838,6 +1946,81 @@
</div> </div>
</div> </div>
<!-- 수동 입고 모달 -->
<div class="modal fade" id="manualReceiptModal" tabindex="-1">
<div class="modal-dialog modal-xl">
<div class="modal-content">
<div class="modal-header bg-success text-white">
<h5 class="modal-title"><i class="bi bi-plus-circle"></i> 수동 입고</h5>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<!-- 입고 헤더 정보 -->
<div class="row mb-3">
<div class="col-md-3">
<label class="form-label">입고일 <span class="text-danger">*</span></label>
<input type="date" class="form-control" id="manualReceiptDate">
</div>
<div class="col-md-4">
<label class="form-label">도매상 <span class="text-danger">*</span></label>
<div class="input-group">
<select class="form-control" id="manualReceiptSupplier">
<option value="">도매상을 선택하세요</option>
</select>
<button class="btn btn-outline-secondary" type="button" id="manualReceiptAddSupplierBtn" title="새 도매상 등록">
<i class="bi bi-plus"></i>
</button>
</div>
</div>
<div class="col-md-5">
<label class="form-label">비고</label>
<input type="text" class="form-control" id="manualReceiptNotes" placeholder="비고 입력">
</div>
</div>
<!-- 품목 테이블 -->
<div class="table-responsive">
<table class="table table-bordered table-sm">
<thead class="table-light">
<tr>
<th style="width:25%">약재명 <span class="text-danger">*</span></th>
<th style="width:10%">수량(g) <span class="text-danger">*</span></th>
<th style="width:12%">g당 단가 <span class="text-danger">*</span></th>
<th style="width:12%">금액</th>
<th style="width:10%">원산지</th>
<th style="width:12%">로트번호</th>
<th style="width:12%">유효기한</th>
<th style="width:5%"></th>
</tr>
</thead>
<tbody id="manualReceiptLines">
<!-- 동적 행 추가 -->
</tbody>
<tfoot>
<tr class="table-warning fw-bold">
<td>합계</td>
<td id="manualReceiptTotalQty" class="text-end">0</td>
<td></td>
<td id="manualReceiptTotalAmount" class="text-end">0</td>
<td colspan="4"></td>
</tr>
</tfoot>
</table>
</div>
<button type="button" class="btn btn-outline-primary btn-sm" id="addManualReceiptLineBtn">
<i class="bi bi-plus"></i> 품목 추가
</button>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">취소</button>
<button type="button" class="btn btn-success" id="saveManualReceiptBtn">
<i class="bi bi-check-circle"></i> 입고 저장
</button>
</div>
</div>
</div>
</div>
<!-- 로트 배분 모달 --> <!-- 로트 배분 모달 -->
<div class="modal fade" id="lotAllocationModal" tabindex="-1"> <div class="modal fade" id="lotAllocationModal" tabindex="-1">
<div class="modal-dialog modal-lg"> <div class="modal-dialog modal-lg">