Compare commits

...

12 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
69be63d00d docs: 제품 3단계분류 문서 추가, DB 초기화/복원 스크립트
- 제품 3단계분류.md: 성분→제품→로트 분류 체계, AI display_name 채우기 절차
- reset_operational_data.py: 마스터 보존 + 운영 데이터 초기화
- restore_backup.py: 백업 선택 복원 스크립트

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 14:42:31 +00:00
50883a6a84 feat: 100처방 구성약재 수 표시, 등록 여부 백엔드 판정, 이름매칭 개선
- official_formulas API에 ingredient_count 추가 (LEFT JOIN COUNT)
- 등록 여부를 백엔드에서 판정 (official_formula_id FK 1차 + 이름 포함 매칭 fallback)
- 내 처방 100처방 뱃지 매칭: startsWith → includes 변경
- 100처방 목록에 구성약재 수 뱃지 컬럼 추가

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 14:41:40 +00:00
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
3d13c0b1f3 feat: 전화번호/주민번호 포맷팅 및 대시보드 매출 통계 추가
- 전화번호 포맷팅 (010-1234-5678 형식) 전역 적용
- 주민번호 마스킹 포맷팅 (980520-1****** 형식)
- 대시보드에 총 마일리지, 이번달 매출, 마진, 마진율 통계 추가

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 06:41:28 +00:00
23 changed files with 5233 additions and 77 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이지만 사용자가 입력했으면 반드시 저장해야 함.

978
app.py

File diff suppressed because it is too large Load Diff

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

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

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,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,190 @@
# 제품 3단계 분류 체계
> 한약재는 **성분 → 제품 → 로트** 3단계로 식별된다.
> 같은 성분코드의 약재도 제조사, 형태, 산지, 등급에 따라 품질과 가격이 크게 다르다.
---
## 1. 3단계 구조
```
[1단계] 성분 (herb_masters) — 약재의 본질
└─ ingredient_code: 3002H1AHM = "갈근"
[2단계] 제품 (herb_items) — 도매상별 상품
└─ insurance_code: 062401050 = "휴먼갈근" (주식회사휴먼허브)
[3단계] 로트 (inventory_lots) — 입고 건별 실물
└─ lot_id: 190 = "갈근.각" (한국산, ₩17/g)
```
### 테이블 매핑
| 단계 | 테이블 | PK | 식별키 | 행수 | 예시 |
|------|--------|-----|-------|------|------|
| 성분 | `herb_masters` | herb_id | `ingredient_code` | 454 | 갈근 (3002H1AHM) |
| 제품 | `herb_items` | herb_item_id | `insurance_code` | ~30 | 휴먼갈근 (062401050) |
| 로트 | `inventory_lots` | lot_id | receipt_line_id | ~30 | 갈근.각 (한국, ₩17) |
### herb_items 컬럼 역할
| 컬럼 | 실제 내용 | 예시 |
|------|----------|------|
| `herb_name` | 제품명 (품명) | "휴먼갈근" |
| `insurance_code` | 보험코드 (=product_code) | "062401050" |
| `ingredient_code` | 성분코드 (herb_masters FK) | "3002H1AHM" |
| `specification` | 제조사명 | "주식회사휴먼허브" |
---
## 2. 로트 세부 분류 (display_name + lot_variants)
### 2-1. `inventory_lots.display_name`
**엑셀 입고 시 NULL로 들어감**. AI가 쇼핑몰/카탈로그 정보를 참고하여 사후에 채워넣는 값.
도매상 카탈로그의 실제 품명으로, 같은 제품(herb_items)이라도 로트마다 다를 수 있다:
| herb_name (제품) | display_name (로트) | 차이점 |
|-----------------|-------------------|--------|
| 휴먼건강 | 건강 | 페루산, ₩12/g |
| 휴먼건강 | 건강.土 | 한국산(토종), ₩51/g |
| 휴먼일당귀 | 일당귀(한국산) | 한국산, ₩19/g |
| 휴먼일당귀 | 일당귀.中(1kg) | 중국산 중품, ₩13/g |
### 2-2. `lot_variants` 테이블 (파싱 결과)
`display_name`을 구조화된 필드로 파싱한 결과를 저장:
| 컬럼 | 용도 | 파싱 예시 |
|------|------|----------|
| `raw_name` | 원본 품명 | "건강.土" |
| `form` | 형태 | 각(角), 片(편), 절편, 통 |
| `processing` | 포제/가공 | 초(炒), 자(炙), 酒炙, 9증 |
| `selection_state` | 선별/원산 | 土(토종), 正, 中, 재배, 야생 |
| `grade` | 등급 | 1호, 특, 名品, 소(小) |
| `age_years` | 연근 | 4, 6 (년근) |
---
## 3. display_name 명명 패턴 (도매상 기준)
### 기본 구조
```
약재명.형태[등급](포장단위)
약재명.선별<유통경로>(포장단위)[비고]
```
### 실제 패턴 분석 (30개 로트)
| display_name | 약재 | form | processing | selection | grade |
|-------------|------|------|-----------|-----------|-------|
| `갈근.각` | 갈근 | 각(角) | - | - | - |
| `감초.1호[야생](1kg)` | 감초 | - | - | 야생 | 1호 |
| `건강` | 건강 | - | - | - | - |
| `건강.土` | 건강 | - | - | 土(토종) | - |
| `길경.片[특]` | 길경 | 片(편) | - | - | 특 |
| `세신.中` | 세신 | - | - | 中(중품) | - |
| `백출.당[1kg]` | 백출 | - | - | 당(當) | - |
| `작약주자.土[酒炙]` | 작약주자 | - | 酒炙 | 土(토종) | - |
| `숙지황(9증)(신흥.1kg)[완]` | 숙지황 | - | 9증 | - | 완 |
| `육계.YB` | 육계 | - | - | YB | - |
| `진피.비열[非熱](1kg)` | 진피 | - | 非熱(비열) | - | - |
| `창출[북창술.재배](1kg)` | 창출 | - | - | 재배 | - |
| `천궁.일<토매지>(1kg)` | 천궁 | - | - | 일(日) | - |
| `황기(직절.小)(1kg)` | 황기 | 직절 | - | - | 小 |
| `용안육.名品(1kg)` | 용안육 | - | - | - | 名品 |
| `오미자<토매지>(1kg)` | 오미자 | - | - | - | - |
| `전호[재배]` | 전호 | - | - | 재배 | - |
| `지황.건[회](1kg)` | 지황 | - | 건(乾) | 회(灰) | - |
### 구분자 규칙
| 구분자 | 의미 | 예시 |
|--------|------|------|
| `.` | 주 속성 구분 | `건강.土` → 토종 |
| `[...]` | 부가 정보/등급 | `길경.片[특]` → 특등 |
| `(...)` | 포장/가공/산지 | `대추(절편)(1kg)` |
| `<...>` | 유통경로 | `오미자<토매지>(1kg)` |
---
## 4. AI가 display_name / lot_variants를 채우는 절차
### 언제 실행하는가
1. 엑셀 입고 완료 후 (`inventory_lots.display_name = NULL`)
2. 도매상 쇼핑몰에서 해당 품목 정보를 AI에게 제공
3. AI가 정보를 파싱하여 `display_name` + `lot_variants` 업데이트
### Step 1: NULL인 로트 확인
```sql
SELECT il.lot_id, h.herb_name, h.insurance_code, il.origin_country,
il.unit_price_per_g, il.quantity_received
FROM inventory_lots il
JOIN herb_items h ON il.herb_item_id = h.herb_item_id
WHERE il.display_name IS NULL AND il.is_depleted = 0;
```
### Step 2: 쇼핑몰/카탈로그 정보 참고
사용자가 도매상 쇼핑몰에서 해당 제품의 상세 품명을 제공하면,
AI가 **가격, 단가, 원산지, 포장단위** 등을 교차 참고하여 올바른 로트에 매칭.
참고 가능한 매칭 단서:
- **보험코드** (insurance_code) — 제품 특정
- **원산지** (origin_country) — 같은 제품의 로트 구분
- **단가** (unit_price_per_g) — 등급/선별 구분 (土 > 일반, 한국산 > 중국산)
- **수량** (quantity_received) — 포장 단위 매칭
### Step 3: display_name 업데이트
```sql
UPDATE inventory_lots
SET display_name = '건강.土'
WHERE lot_id = 193;
```
### Step 4: lot_variants 파싱 결과 저장
```sql
INSERT INTO lot_variants (lot_id, raw_name, form, processing, selection_state, grade, parsed_method)
VALUES (193, '건강.土', NULL, NULL, '', NULL, 'ai_parsing');
```
### Step 5: 검증
```sql
SELECT il.lot_id, il.display_name, h.herb_name, il.origin_country,
lv.form, lv.processing, lv.selection_state, lv.grade
FROM inventory_lots il
JOIN herb_items h ON il.herb_item_id = h.herb_item_id
LEFT JOIN lot_variants lv ON il.lot_id = lv.lot_id
WHERE il.lot_id = 193;
```
---
## 5. 활용처
| 화면 | 사용 값 | 표시 예시 |
|------|---------|----------|
| 입고장 상세 | `display_name` | 갈근.각, 건강.土 |
| 재고 상세 모달 | `display_name` + `origin_country` | 건강.土 (한국) |
| 조제 시 로트 선택 | `display_name` + `unit_price` | 건강.土 ₩51/g vs 건강 ₩12/g |
| 재고 원장 | `display_name` | 입출고 이력에 로트 구분 |
---
## 6. 주의사항
1. **엑셀 입고 로직은 수정하지 않는다**`display_name`은 항상 NULL로 입고
2. **AI가 사후에 채운다** — 쇼핑몰 정보 기반, `parsed_method = 'ai_parsing'`
3. **같은 herb_item_id에 여러 display_name 가능** — 로트마다 다른 실물이므로 정상
4. **lot_variants 파싱이 안 되어도 display_name만으로 구분 가능** — 파싱은 선택적
---
*이 문서는 kdrug 시스템의 약재 3단계 분류 체계와 AI 기반 로트 분류 절차를 정의합니다.*
*최종 수정: 2026-02-18*

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

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

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

@@ -0,0 +1,171 @@
#!/usr/bin/env python3
"""
운영 데이터 초기화 스크립트
- 마스터 데이터는 보존
- 운영/거래 데이터만 삭제
- prescription_rules 중복 정리
실행: python3 scripts/reset_operational_data.py
"""
import sqlite3
import os
from datetime import datetime
DB_PATH = os.path.join(os.path.dirname(os.path.dirname(__file__)), 'database', 'kdrug.db')
# ============================================================
# 보존할 마스터 테이블 (절대 건드리지 않음)
# ============================================================
MASTER_TABLES = [
'herb_masters', # 454 - 급여 한약재 성분코드 마스터
'herb_master_extended', # 454 - 약재 확장 정보 (성미귀경, 효능)
'herb_products', # 53,769 - 보험 제품 목록
'product_companies', # 128 - 제조/유통사
'official_formulas', # 100 - 100처방 원방 마스터
'official_formula_ingredients', # 68 - 100처방 구성 약재
'herb_efficacy_tags', # 18 - 효능 태그 정의
'herb_item_tags', # 22 - 약재-태그 매핑
'survey_templates', # 56 - 설문 템플릿
]
# ============================================================
# 삭제할 운영 데이터 테이블 (FK 순서 고려 — 자식 먼저)
# ============================================================
CLEAR_TABLES = [
# 조제/판매 하위
'compound_consumptions',
'compound_ingredients',
'sales_status_history',
'sales_transactions',
'mileage_transactions',
# 조제 마스터
'compounds',
# 재고 하위
'stock_ledger',
'stock_adjustment_details',
'stock_adjustments',
'lot_variants',
'inventory_lots',
'inventory_lots_v2',
# 입고 하위
'purchase_receipt_lines',
'purchase_receipts',
# 처방
'formula_ingredients',
'formula_ingredients_backup',
'formulas',
'price_policies',
# 환자/설문
'survey_responses',
'survey_progress',
'patient_surveys',
'patients',
# 도매상/약재
'supplier_product_catalog',
'suppliers',
'herb_items',
# 규칙/로그 (재정비)
'prescription_rules',
'data_update_logs',
'disease_herb_mapping',
'herb_research_papers',
'herb_safety_info',
]
def reset_db():
conn = sqlite3.connect(DB_PATH)
conn.execute("PRAGMA foreign_keys = OFF")
cursor = conn.cursor()
print(f"DB: {DB_PATH}")
print(f"시각: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
print()
# 1. 마스터 테이블 행 수 확인 (보존 확인)
print("=" * 50)
print("보존 대상 마스터 테이블")
print("=" * 50)
for table in MASTER_TABLES:
try:
cursor.execute(f"SELECT COUNT(*) FROM [{table}]")
cnt = cursor.fetchone()[0]
print(f"{table}: {cnt}행 (보존)")
except:
print(f" - {table}: 테이블 없음 (skip)")
# 2. 운영 테이블 삭제
print()
print("=" * 50)
print("초기화 대상 운영 테이블")
print("=" * 50)
for table in CLEAR_TABLES:
try:
cursor.execute(f"SELECT COUNT(*) FROM [{table}]")
before = cursor.fetchone()[0]
cursor.execute(f"DELETE FROM [{table}]")
# AUTOINCREMENT 리셋
cursor.execute(f"DELETE FROM sqlite_sequence WHERE name = ?", (table,))
print(f"{table}: {before}행 → 0행")
except Exception as e:
print(f" - {table}: {e}")
# 3. prescription_rules 중복 제거 후 재삽입
print()
print("=" * 50)
print("prescription_rules 정리 (중복 제거)")
print("=" * 50)
rules = [
(298, 438, '상수', '두 약재가 함께 사용되면 보기 효과가 증강됨 (인삼+황기)', 0, 0),
(73, 358, '상수', '혈액순환 개선 효과가 증강됨 (당귀+천궁)', 0, 0),
(123, 193, '상사', '생강이 반하의 독성을 감소시킴', 0, 0),
(7, 6, '상반', '십팔반(十八反) - 함께 사용 금기', 5, 1),
(298, 252, '상반', '십구외(十九畏) - 함께 사용 주의', 4, 0),
]
for r in rules:
cursor.execute("""
INSERT INTO prescription_rules (herb1_id, herb2_id, relationship_type, description, severity_level, is_absolute)
VALUES (?, ?, ?, ?, ?, ?)
""", r)
print(f"{len(rules)}개 규칙 재삽입 (중복 제거)")
conn.commit()
conn.execute("PRAGMA foreign_keys = ON")
# 4. VACUUM
conn.execute("VACUUM")
print()
print("✓ VACUUM 완료")
# 5. 최종 확인
print()
print("=" * 50)
print("최종 상태")
print("=" * 50)
cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' ORDER BY name")
for row in cursor.fetchall():
table = row[0]
cursor.execute(f"SELECT COUNT(*) FROM [{table}]")
cnt = cursor.fetchone()[0]
marker = "" if cnt > 0 else " "
print(f" {marker} {table}: {cnt}")
conn.close()
print()
print("초기화 완료!")
if __name__ == '__main__':
confirm = input("운영 데이터를 모두 초기화합니다. 계속하시겠습니까? (yes/no): ")
if confirm.strip().lower() == 'yes':
reset_db()
else:
print("취소되었습니다.")

76
scripts/restore_backup.py Normal file
View File

@@ -0,0 +1,76 @@
#!/usr/bin/env python3
"""
백업 DB 복원 스크립트
- 백업 파일에서 운영 DB로 복원
실행: python3 scripts/restore_backup.py
"""
import os
import shutil
import glob
from datetime import datetime
DB_DIR = os.path.join(os.path.dirname(os.path.dirname(__file__)), 'database')
DB_PATH = os.path.join(DB_DIR, 'kdrug.db')
def list_backups():
"""사용 가능한 백업 파일 목록"""
pattern = os.path.join(DB_DIR, 'kdrug_backup*.db')
backups = sorted(glob.glob(pattern), key=os.path.getmtime, reverse=True)
return backups
def restore():
backups = list_backups()
if not backups:
print("사용 가능한 백업 파일이 없습니다.")
return
print("=" * 50)
print("사용 가능한 백업 파일")
print("=" * 50)
for i, path in enumerate(backups):
size_mb = os.path.getsize(path) / (1024 * 1024)
mtime = datetime.fromtimestamp(os.path.getmtime(path)).strftime('%Y-%m-%d %H:%M:%S')
name = os.path.basename(path)
print(f" [{i + 1}] {name} ({size_mb:.1f}MB, {mtime})")
print()
choice = input(f"복원할 백업 번호를 선택하세요 (1-{len(backups)}): ").strip()
try:
idx = int(choice) - 1
if idx < 0 or idx >= len(backups):
print("잘못된 번호입니다.")
return
except ValueError:
print("숫자를 입력하세요.")
return
selected = backups[idx]
print()
print(f"선택: {os.path.basename(selected)}")
confirm = input("현재 DB를 덮어쓰고 복원합니다. 계속하시겠습니까? (yes/no): ").strip().lower()
if confirm != 'yes':
print("취소되었습니다.")
return
# 현재 DB를 복원 전 백업
pre_restore = os.path.join(DB_DIR, f"kdrug_pre_restore_{datetime.now().strftime('%Y%m%d_%H%M%S')}.db")
shutil.copy2(DB_PATH, pre_restore)
print(f" 복원 전 현재 DB 백업 → {os.path.basename(pre_restore)}")
# 복원
shutil.copy2(selected, DB_PATH)
print(f" {os.path.basename(selected)} → kdrug.db 복원 완료")
print()
print("복원 완료! 앱을 재시작하세요.")
if __name__ == '__main__':
restore()

File diff suppressed because it is too large Load Diff

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>
@@ -168,6 +173,38 @@
</div> </div>
</div> </div>
<!-- 추가 통계 - 마일리지 및 마진 정보 -->
<div class="row mt-3">
<div class="col-md-3">
<div class="stat-card bg-light">
<h5><i class="bi bi-piggy-bank-fill text-warning"></i> 총 마일리지</h5>
<div class="value text-warning" id="totalMileage">0</div>
<small class="text-muted">전체 환자 보유액</small>
</div>
</div>
<div class="col-md-3">
<div class="stat-card bg-light">
<h5><i class="bi bi-graph-up-arrow text-success"></i> 이번달 매출</h5>
<div class="value text-success" id="monthSales">0</div>
<small class="text-muted">결제 완료 기준</small>
</div>
</div>
<div class="col-md-3">
<div class="stat-card bg-light">
<h5><i class="bi bi-calculator text-info"></i> 이번달 마진</h5>
<div class="value text-info" id="monthProfit">0</div>
<small class="text-muted">매출 - 원가 (마일리지 제외)</small>
</div>
</div>
<div class="col-md-3">
<div class="stat-card bg-light">
<h5><i class="bi bi-percent text-primary"></i> 마진율</h5>
<div class="value text-primary" id="profitRate">0%</div>
<small class="text-muted">이번달 평균</small>
</div>
</div>
</div>
<div class="row mt-4"> <div class="row mt-4">
<div class="col-md-12"> <div class="col-md-12">
<div class="card"> <div class="card">
@@ -234,6 +271,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>
<!-- 입고장 목록 --> <!-- 입고장 목록 -->
@@ -320,13 +360,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>
@@ -345,6 +389,36 @@
</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>
<th width="80">상태</th>
</tr>
</thead>
<tbody id="officialFormulasList">
<!-- Dynamic content -->
</tbody>
</table>
</div>
</div>
</div>
</div> </div>
<!-- Compound Page --> <!-- Compound Page -->
@@ -374,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>
@@ -408,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> 조제 실행
@@ -819,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>
@@ -1058,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>
@@ -1383,6 +1485,10 @@
</div> </div>
</div> </div>
</div> </div>
<!-- Medicine Master Page -->
{% include 'medicine_master.html' %}
</div> </div>
</div> </div>
</div> </div>
@@ -1632,6 +1738,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">
@@ -1806,6 +1984,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">
@@ -1864,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>