diff --git a/docs/제품 3단계분류.md b/docs/제품 3단계분류.md new file mode 100644 index 0000000..d57af65 --- /dev/null +++ b/docs/제품 3단계분류.md @@ -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* diff --git a/scripts/reset_operational_data.py b/scripts/reset_operational_data.py new file mode 100644 index 0000000..3170663 --- /dev/null +++ b/scripts/reset_operational_data.py @@ -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("취소되었습니다.") diff --git a/scripts/restore_backup.py b/scripts/restore_backup.py new file mode 100644 index 0000000..1c49a0a --- /dev/null +++ b/scripts/restore_backup.py @@ -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()