diff --git a/backend/SOOIN_API_REVERSE_ENGINEERING.md b/backend/SOOIN_API_REVERSE_ENGINEERING.md
new file mode 100644
index 0000000..0d5d7b5
--- /dev/null
+++ b/backend/SOOIN_API_REVERSE_ENGINEERING.md
@@ -0,0 +1,277 @@
+# 수인약품 API 리버스 엔지니어링 문서
+
+## 개요
+수인약품 웹 주문 시스템의 API 구조를 분석한 문서입니다.
+지오영 API와 같은 하이브리드 방식 (Playwright 로그인 → requests 직접 호출)으로 구현합니다.
+
+## 기본 정보
+
+- **Base URL**: `http://sooinpharm.co.kr`
+- **인코딩**: EUC-KR (한글 파라미터 인코딩 시 주의)
+- **거래처 코드**: `50911` (청춘약국)
+- **세션 관리**: 쿠키 기반 (ASP 세션)
+
+---
+
+## 1. 로그인
+
+### 로그인 페이지
+- **URL**: `/Homepage/intro.asp`
+- **Method**: POST (JavaScript 함수 `chkLogin()` 호출)
+
+### 필드
+| 필드명 | 설명 | 예시 |
+|--------|------|------|
+| tx_id | 아이디 | thug0bin |
+| tx_pw | 비밀번호 | @Trajet6640 |
+
+### 인증 쿠키
+로그인 성공 시 ASP 세션 쿠키가 발급됨:
+- `ASPSESSIONID*` (세션 ID)
+
+### 로그인 성공 확인
+- 로그인 후 페이지에 "로그아웃" 링크 존재 여부로 확인
+- 로그인 후 자동으로 `/Service/Order/Order.asp`로 리다이렉트
+
+---
+
+## 2. 제품 검색 API
+
+### URL
+```
+GET /Service/Order/Order.asp
+```
+
+### 파라미터
+| 파라미터 | 필수 | 설명 | 값 예시 |
+|----------|------|------|---------|
+| so | N | 제품분류 | 0=전체, 1=전문, 2=일반 |
+| so2 | N | 주문분류 | 0=전체, 1=다빈도, 2=관심, 3=재주문 |
+| so3 | N | 검색타입 | **1=제품명, 2=KD코드, 3=표준코드** |
+| tx_maker | N | 제조사 | 한독 |
+| tx_physic | N | 검색어 | 073100220 (KD코드) |
+| tx_ven | Y | 거래처코드 | 50911 |
+| currVenNm | Y | 약국명 | 청춘약국 (URL인코딩) |
+| sDate | N | 시작일 | 20260306 |
+| eDate | N | 종료일 | 20260306 |
+| sa | N | 정렬 | phy=제품명순, ven=제조사순 |
+| Page | N | 페이지번호 | 1 |
+| tx_StockLoc | N | 재고위치 | '00001' |
+| df | N | 기간필터 | t=3개월 |
+
+### KD코드 검색 예시 URL
+```
+/Service/Order/Order.asp?so=0&so2=0&so3=2&tx_physic=073100220&tx_ven=50911&currVenNm=%EC%B2%AD%EC%B6%98%EC%95%BD%EA%B5%AD&sDate=20260306&eDate=20260306&df=t
+```
+
+### 응답 (HTML)
+HTML 테이블 형식으로 반환. BeautifulSoup로 파싱 필요.
+
+#### 테이블 구조
+```html
+
+ | 073100220 |
+ 한국오가논 |
+
+
+ (오가논)코자정 50mg(PTP)
+
+ |
+ 30T |
+ 보험전문 |
+ 14,220 |
+ 238 |
+
+
+
+
+
+ |
+
+```
+
+#### 핵심 필드 추출
+- **KD코드**: 첫 번째 td
+- **제조사**: 두 번째 td
+- **제품명**: 세 번째 td의 a 태그 텍스트
+- **내부코드(pc)**: a 태그 href에서 `pc=xxxxx` 추출
+- **규격**: 네 번째 td
+- **단가**: 여섯 번째 td (콤마 제거 후 int)
+- **재고**: 일곱 번째 td
+
+---
+
+## 3. 장바구니 추가 API
+
+### URL
+```
+POST /Service/Order/BagOrder.asp
+```
+
+### Content-Type
+```
+application/x-www-form-urlencoded
+```
+
+### 파라미터 (각 제품당)
+| 파라미터 | 설명 | 예시 |
+|----------|------|------|
+| qty_N | 수량 | 1 |
+| pc_N | 내부 제품코드 | 32495 |
+| stock_N | 현재 재고 | 238 |
+| saleqty_N | 판매수량 | 0 |
+| price_N | 단가 | 14220 |
+| soldout_N | 품절여부 | N |
+| ordunitqty_N | 주문단위수량 | 1 |
+| bidqty_N | 입찰수량 | 0 |
+| outqty_N | 출고수량 | 0 |
+| overqty_N | 초과수량 | 0 |
+| manage_N | 관리여부 | N |
+| prodno_N | 제품번호 | (빈값) |
+| termdt_N | 종료일자 | (빈값) |
+
+> N은 0부터 시작하는 행 인덱스
+
+### 요청 예시
+```
+qty_0=1&pc_0=32495&stock_0=238&saleqty_0=0&price_0=14220&soldout_0=N&ordunitqty_0=1&bidqty_0=0&outqty_0=0&overqty_0=0&manage_0=N&prodno_0=&termdt_0=
+```
+
+### 응답
+HTML (장바구니 iframe 내용)
+
+---
+
+## 4. 장바구니 비우기 API
+
+### URL
+```
+GET /Service/Order/BagOrder.asp?kind=del&currVenCd=50911&currMkind=&currRealVenCd=
+```
+
+### 파라미터
+| 파라미터 | 설명 | 값 |
+|----------|------|-----|
+| kind | 동작 | del |
+| currVenCd | 거래처코드 | 50911 |
+| currMkind | 종류 | (빈값) |
+| currRealVenCd | 실제거래처코드 | (빈값) |
+
+---
+
+## 5. 장바구니 조회 API
+
+### URL
+```
+GET /Service/Order/BagOrder.asp?currVenCd=50911
+```
+
+### 응답 (HTML)
+```html
+
+
+ | 건별취소 |
+ 제품명 |
+ 수량 |
+ 금액 |
+
+
+ | X |
+ (오가논)코자정 50mg(PTP) |
+ 1 |
+ 14,220 |
+
+
+
+
주문품목1개
+ 주문금액14,220원
+
+```
+
+---
+
+## 6. 주문 전송 API
+
+### URL (추정)
+```
+POST /Service/Order/BagOrder.asp
+```
+
+### 파라미터
+| 파라미터 | 설명 |
+|----------|------|
+| kind | order (추정) |
+| memo | 주문메모 |
+| currVenCd | 거래처코드 |
+
+> 실제 주문 전송은 iframe 내 버튼 클릭으로 수행됨
+> 정확한 API 파라미터는 추가 분석 필요
+
+---
+
+## 7. 제품 상세 정보 API
+
+### URL
+```
+GET /Service/Order/PhysicInfo.asp
+```
+
+### 파라미터
+| 파라미터 | 설명 | 예시 |
+|----------|------|------|
+| pc | 내부제품코드 | 32495 |
+| ln | 행번호 | 0 |
+| currVenCd | 거래처코드 | 50911 |
+| currLoc | 재고위치 | '00001' |
+
+---
+
+## 구현 전략
+
+### 지오영 API 패턴 적용
+
+1. **Playwright 로그인**
+ - 초기 로그인만 Playwright 사용
+ - 쿠키 획득 후 requests 세션에 복사
+ - 세션 30분 유효 (재로그인 필요 시 자동 갱신)
+
+2. **requests 직접 호출**
+ - 검색: GET /Service/Order/Order.asp
+ - 장바구니 추가: POST /Service/Order/BagOrder.asp
+ - 장바구니 비우기: GET /Service/Order/BagOrder.asp?kind=del
+ - 장바구니 조회: GET /Service/Order/BagOrder.asp
+
+3. **HTML 파싱**
+ - BeautifulSoup 사용
+ - 테이블 행에서 제품 정보 추출
+ - 내부코드(pc) 추출 (장바구니 추가용)
+
+### 예상 성능
+- 기존 Playwright: ~30초/주문
+- requests 직접 호출: **~1초/주문**
+
+---
+
+## 주의사항
+
+1. **EUC-KR 인코딩**
+ - 한글 파라미터는 EUC-KR로 인코딩
+ - `urllib.parse.quote(text.encode('euc-kr'))`
+
+2. **세션 관리**
+ - ASP 세션 쿠키 유지 필수
+ - 장시간 미사용 시 세션 만료
+
+3. **동시 접속**
+ - 동일 계정 동시 접속 시 세션 충돌 가능
+
+4. **재고 실시간성**
+ - 검색 시점의 재고 정보
+ - 주문 전 재고 재확인 권장
+
+---
+
+## 작성일
+- 2026-03-06
+- 리버스 엔지니어링 by Claude
+
diff --git a/backend/analyze_geoyoung.py b/backend/analyze_geoyoung.py
new file mode 100644
index 0000000..190343d
--- /dev/null
+++ b/backend/analyze_geoyoung.py
@@ -0,0 +1,77 @@
+# -*- coding: utf-8 -*-
+"""지오영 API 엔드포인트 분석 - 간단 버전"""
+
+import asyncio
+from playwright.async_api import async_playwright
+
+async def analyze():
+ async with async_playwright() as p:
+ browser = await p.chromium.launch(headless=True)
+ page = await browser.new_page()
+
+ # 모든 요청 로깅
+ all_requests = []
+
+ def log_request(request):
+ all_requests.append({
+ 'url': request.url,
+ 'method': request.method,
+ 'data': request.post_data
+ })
+
+ page.on('request', log_request)
+
+ # 로그인
+ await page.goto('https://gwn.geoweb.kr/Member/Login')
+ await page.fill('input[type="text"]', '7390')
+ await page.fill('input[type="password"]', 'trajet6640')
+ await page.click('button, input[type="submit"]')
+ await page.wait_for_load_state('networkidle')
+
+ # 메인 페이지 HTML 분석
+ await page.goto('https://gwn.geoweb.kr/Home/Index')
+ await page.wait_for_load_state('networkidle')
+
+ # JavaScript에서 API 엔드포인트 찾기
+ js_content = await page.content()
+
+ await browser.close()
+
+ # POST 요청만 필터
+ print("="*60)
+ print("POST 요청들:")
+ print("="*60)
+ for r in all_requests:
+ if r['method'] == 'POST':
+ print(f"URL: {r['url']}")
+ if r['data']:
+ print(f"Data: {r['data'][:300]}")
+ print()
+
+ # HTML에서 API 힌트 찾기
+ print("="*60)
+ print("HTML에서 발견된 API 관련 패턴:")
+ print("="*60)
+
+ import re
+ # ajax, fetch, url 패턴 찾기
+ patterns = [
+ r'url:\s*[\'"]([^"\']+)[\'"]',
+ r'action=[\'"]([^"\']+)[\'"]',
+ r'\.post\([\'"]([^"\']+)[\'"]',
+ r'\.get\([\'"]([^"\']+)[\'"]',
+ r'fetch\([\'"]([^"\']+)[\'"]',
+ ]
+
+ found_urls = set()
+ for pattern in patterns:
+ matches = re.findall(pattern, js_content)
+ for m in matches:
+ if 'Order' in m or 'Cart' in m or 'Add' in m or 'Product' in m:
+ found_urls.add(m)
+
+ for url in sorted(found_urls):
+ print(url)
+
+if __name__ == "__main__":
+ asyncio.run(analyze())
diff --git a/backend/app.py b/backend/app.py
index a700980..39f529a 100644
--- a/backend/app.py
+++ b/backend/app.py
@@ -59,6 +59,15 @@ app.register_blueprint(pmr_bp)
from paai_feedback import paai_feedback_bp
app.register_blueprint(paai_feedback_bp)
+from geoyoung_api import geoyoung_bp
+app.register_blueprint(geoyoung_bp)
+
+from sooin_api import sooin_bp
+app.register_blueprint(sooin_bp)
+
+from order_api import order_bp
+app.register_blueprint(order_bp)
+
# 데이터베이스 매니저
db_manager = DatabaseManager()
diff --git a/backend/bag_page.html b/backend/bag_page.html
new file mode 100644
index 0000000..5672a01
--- /dev/null
+++ b/backend/bag_page.html
@@ -0,0 +1,206 @@
+
+
+
+
+
+
+
+
+
+
+ 수인약품(주) :: 장바구니
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
17시 이후 주문은 다음근무일로 주문됩니다
+
+
+장바구니
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/backend/capture_geoyoung_api.py b/backend/capture_geoyoung_api.py
new file mode 100644
index 0000000..30a90cf
--- /dev/null
+++ b/backend/capture_geoyoung_api.py
@@ -0,0 +1,79 @@
+# -*- coding: utf-8 -*-
+"""지오영 API 엔드포인트 분석"""
+
+import asyncio
+from playwright.async_api import async_playwright
+
+async def capture_network():
+ async with async_playwright() as p:
+ browser = await p.chromium.launch(headless=True)
+ page = await browser.new_page()
+
+ # 네트워크 요청 캡처
+ requests_log = []
+
+ def log_request(request):
+ if 'geoweb' in request.url:
+ requests_log.append({
+ 'url': request.url,
+ 'method': request.method,
+ 'post_data': request.post_data
+ })
+
+ page.on('request', log_request)
+
+ # 로그인
+ print("로그인 중...")
+ await page.goto('https://gwn.geoweb.kr/Member/Login')
+ await page.fill('input[type="text"]', '7390')
+ await page.fill('input[type="password"]', 'trajet6640')
+ await page.click('button, input[type="submit"]')
+ await page.wait_for_load_state('networkidle')
+ print("로그인 완료")
+
+ # 메인 페이지
+ await page.goto('https://gwn.geoweb.kr/Home/Index')
+ await page.wait_for_load_state('networkidle')
+
+ # 검색
+ print("검색 중...")
+ search_input = await page.query_selector('input#srchText, input[name="srchText"]')
+ if search_input:
+ await search_input.fill('643104281')
+
+ # 검색 버튼
+ search_btn = await page.query_selector('button:has-text("검색"), input[type="submit"]')
+ if search_btn:
+ await search_btn.click()
+ else:
+ await page.keyboard.press('Enter')
+
+ await page.wait_for_timeout(3000)
+
+ # 제품 행 클릭
+ print("제품 선택 중...")
+ rows = await page.query_selector_all('table tbody tr')
+ if rows:
+ await rows[0].click()
+ await page.wait_for_timeout(2000)
+
+ # 담기 버튼
+ print("담기 버튼 클릭...")
+ add_btn = await page.query_selector('button:has-text("담기")')
+ if add_btn:
+ await add_btn.click()
+ await page.wait_for_timeout(3000)
+
+ await browser.close()
+
+ print("\n" + "="*60)
+ print("캡처된 요청들:")
+ print("="*60)
+ for r in requests_log:
+ if r['method'] == 'POST' or 'cart' in r['url'].lower() or 'order' in r['url'].lower():
+ print(f"\n[{r['method']}] {r['url']}")
+ if r['post_data']:
+ print(f" Data: {r['post_data'][:200]}")
+
+if __name__ == "__main__":
+ asyncio.run(capture_network())
diff --git a/backend/check_db.py b/backend/check_db.py
new file mode 100644
index 0000000..d39cee3
--- /dev/null
+++ b/backend/check_db.py
@@ -0,0 +1,11 @@
+import sqlite3
+conn = sqlite3.connect('db/orders.db')
+cursor = conn.cursor()
+cursor.execute("SELECT name FROM sqlite_master WHERE type='table'")
+tables = [r[0] for r in cursor.fetchall()]
+print('Tables:', tables)
+for t in tables:
+ cursor.execute(f"PRAGMA table_info({t})")
+ cols = [r[1] for r in cursor.fetchall()]
+ print(f" {t}: {cols}")
+conn.close()
diff --git a/backend/check_order_db.py b/backend/check_order_db.py
new file mode 100644
index 0000000..d0d3b77
--- /dev/null
+++ b/backend/check_order_db.py
@@ -0,0 +1,13 @@
+# -*- coding: utf-8 -*-
+import sqlite3
+
+conn = sqlite3.connect('db/orders.db')
+
+# 테이블 목록
+tables = conn.execute("SELECT name FROM sqlite_master WHERE type='table'").fetchall()
+print('=== orders.db 테이블 ===')
+for t in tables:
+ count = conn.execute(f'SELECT COUNT(*) FROM {t[0]}').fetchone()[0]
+ print(f' {t[0]}: {count}개 레코드')
+
+conn.close()
diff --git a/backend/check_paai_db.py b/backend/check_paai_db.py
new file mode 100644
index 0000000..aee2a6c
--- /dev/null
+++ b/backend/check_paai_db.py
@@ -0,0 +1,28 @@
+# -*- coding: utf-8 -*-
+import sqlite3
+
+conn = sqlite3.connect('db/paai_logs.db')
+
+# 테이블 목록
+cursor = conn.execute("SELECT name FROM sqlite_master WHERE type='table'")
+tables = cursor.fetchall()
+print('테이블 목록:', [t[0] for t in tables])
+
+# 로그 개수
+count = conn.execute('SELECT COUNT(*) FROM paai_logs').fetchone()[0]
+print(f'PAAI 로그 수: {count}개')
+
+# 최근 로그
+print('\n최근 로그 3개:')
+recent = conn.execute('SELECT id, created_at, patient_name, status FROM paai_logs ORDER BY id DESC LIMIT 3').fetchall()
+for r in recent:
+ print(f' #{r[0]} | {r[1]} | {r[2]} | {r[3]}')
+
+# 피드백 통계
+feedback = conn.execute('SELECT feedback_useful, COUNT(*) FROM paai_logs GROUP BY feedback_useful').fetchall()
+print('\n피드백 통계:')
+for f in feedback:
+ label = '유용' if f[0] == 1 else ('아님' if f[0] == 0 else '미응답')
+ print(f' {label}: {f[1]}건')
+
+conn.close()
diff --git a/backend/download_js.py b/backend/download_js.py
new file mode 100644
index 0000000..0957b7d
--- /dev/null
+++ b/backend/download_js.py
@@ -0,0 +1,85 @@
+# -*- coding: utf-8 -*-
+"""지오영 JS 파일 다운로드 및 분석"""
+
+import requests
+import asyncio
+from playwright.async_api import async_playwright
+import re
+
+async def download_and_analyze():
+ async with async_playwright() as p:
+ browser = await p.chromium.launch(headless=True)
+ page = await browser.new_page()
+
+ # 로그인
+ await page.goto('https://gwn.geoweb.kr/Member/Login')
+ await page.fill('input[type="text"]', '7390')
+ await page.fill('input[type="password"]', 'trajet6640')
+ await page.click('button, input[type="submit"]')
+ await page.wait_for_load_state('networkidle')
+
+ cookies = await page.context.cookies()
+ await browser.close()
+
+ # 세션 설정
+ session = requests.Session()
+ for c in cookies:
+ session.cookies.set(c['name'], c['value'])
+
+ # JS 파일 다운로드
+ js_urls = [
+ 'https://gwn.geoweb.kr/bundles/order_product_cart?v=JPwFQ8DWaNMW1VmbtWYKTJqxT-5255z351W5iZE1qew1',
+ 'https://gwn.geoweb.kr/bundles/order?v=PGhSOAjQ9z6ruAJgJUFuhW9tGQSiJeX6ek-ky3E-tOk1'
+ ]
+
+ for url in js_urls:
+ print(f"\n{'='*60}")
+ print(f"분석: {url.split('/')[-1].split('?')[0]}")
+ print('='*60)
+
+ resp = session.get(url)
+ content = resp.text
+
+ # 장바구니/주문 관련 함수 찾기
+ patterns = [
+ (r'function\s+(fn\w*Cart\w*|add\w*Cart\w*|insert\w*Order\w*)\s*\([^)]*\)', 'function'),
+ (r'(fn\w*Cart\w*|add\w*Cart\w*)\s*=\s*function', 'var function'),
+ (r'url\s*:\s*["\']([^"\']*(?:Cart|Order|Add)[^"\']*)["\']', 'ajax url'),
+ (r'\$\.(?:ajax|post|get)\s*\(\s*["\']([^"\']+)["\']', 'ajax call'),
+ ]
+
+ found = {}
+ for pattern, name in patterns:
+ matches = re.findall(pattern, content, re.IGNORECASE)
+ if matches:
+ for m in matches:
+ if m not in found:
+ found[m] = name
+
+ for item, ptype in found.items():
+ print(f"[{ptype}] {item}")
+
+ # InsertOrder 함수 찾기
+ if 'InsertOrder' in content or 'insertOrder' in content:
+ print("\n--- InsertOrder 함수 발견! ---")
+ # 해당 부분 추출
+ idx = content.lower().find('insertorder')
+ if idx > 0:
+ snippet = content[max(0, idx-100):idx+500]
+ print(snippet[:600])
+
+ # AddCart 패턴 찾기
+ add_patterns = re.findall(r'.{50}AddCart.{100}|.{50}addCart.{100}', content, re.IGNORECASE)
+ if add_patterns:
+ print("\n--- AddCart 관련 ---")
+ for p in add_patterns[:3]:
+ print(p)
+
+ # ajax 호출 상세
+ ajax_pattern = r'\$\.ajax\s*\(\s*\{[^}]{50,500}(Cart|Order)[^}]{0,200}\}'
+ ajax_matches = re.findall(ajax_pattern, content, re.IGNORECASE | re.DOTALL)
+ if ajax_matches:
+ print(f"\n--- AJAX 호출 {len(ajax_matches)}개 발견 ---")
+
+if __name__ == "__main__":
+ asyncio.run(download_and_analyze())
diff --git a/backend/extract_addcart.py b/backend/extract_addcart.py
new file mode 100644
index 0000000..b4b1d1e
--- /dev/null
+++ b/backend/extract_addcart.py
@@ -0,0 +1,86 @@
+# -*- coding: utf-8 -*-
+"""AddCart 함수 전체 추출"""
+
+import requests
+import asyncio
+from playwright.async_api import async_playwright
+import re
+
+async def extract():
+ async with async_playwright() as p:
+ browser = await p.chromium.launch(headless=True)
+ page = await browser.new_page()
+
+ await page.goto('https://gwn.geoweb.kr/Member/Login')
+ await page.fill('input[type="text"]', '7390')
+ await page.fill('input[type="password"]', 'trajet6640')
+ await page.click('button, input[type="submit"]')
+ await page.wait_for_load_state('networkidle')
+
+ cookies = await page.context.cookies()
+ await browser.close()
+
+ session = requests.Session()
+ for c in cookies:
+ session.cookies.set(c['name'], c['value'])
+
+ resp = session.get('https://gwn.geoweb.kr/bundles/order?v=PGhSOAjQ9z6ruAJgJUFuhW9tGQSiJeX6ek-ky3E-tOk1')
+ content = resp.text
+
+ # AddCart 함수 전체 찾기
+ # function AddCart(n,t,i){ ... }
+ start = content.find('function AddCart')
+ if start > 0:
+ # 중괄호 매칭으로 함수 끝 찾기
+ depth = 0
+ end = start
+ in_func = False
+
+ for i in range(start, min(start + 5000, len(content))):
+ if content[i] == '{':
+ depth += 1
+ in_func = True
+ elif content[i] == '}':
+ depth -= 1
+ if in_func and depth == 0:
+ end = i + 1
+ break
+
+ func_content = content[start:end]
+ print("="*60)
+ print("AddCart 함수 전체:")
+ print("="*60)
+ print(func_content)
+
+ # ajax 호출 찾기
+ ajax_match = re.search(r'\$\.ajax\s*\(\s*\{[^}]+\}', func_content, re.DOTALL)
+ if ajax_match:
+ print("\n" + "="*60)
+ print("AJAX 호출:")
+ print("="*60)
+ print(ajax_match.group())
+
+ # InsertOrder 함수도 찾기
+ start2 = content.find('function InsertOrder')
+ if start2 > 0:
+ depth = 0
+ end2 = start2
+ in_func = False
+
+ for i in range(start2, min(start2 + 3000, len(content))):
+ if content[i] == '{':
+ depth += 1
+ in_func = True
+ elif content[i] == '}':
+ depth -= 1
+ if in_func and depth == 0:
+ end2 = i + 1
+ break
+
+ print("\n" + "="*60)
+ print("InsertOrder 함수:")
+ print("="*60)
+ print(content[start2:end2][:1500])
+
+if __name__ == "__main__":
+ asyncio.run(extract())
diff --git a/backend/extract_processcart.py b/backend/extract_processcart.py
new file mode 100644
index 0000000..5af50d9
--- /dev/null
+++ b/backend/extract_processcart.py
@@ -0,0 +1,71 @@
+# -*- coding: utf-8 -*-
+"""ProcessCart 함수 추출"""
+
+import requests
+import asyncio
+from playwright.async_api import async_playwright
+
+async def extract():
+ async with async_playwright() as p:
+ browser = await p.chromium.launch(headless=True)
+ page = await browser.new_page()
+
+ await page.goto('https://gwn.geoweb.kr/Member/Login')
+ await page.fill('input[type="text"]', '7390')
+ await page.fill('input[type="password"]', 'trajet6640')
+ await page.click('button, input[type="submit"]')
+ await page.wait_for_load_state('networkidle')
+
+ cookies = await page.context.cookies()
+ await browser.close()
+
+ session = requests.Session()
+ for c in cookies:
+ session.cookies.set(c['name'], c['value'])
+
+ resp = session.get('https://gwn.geoweb.kr/bundles/order?v=PGhSOAjQ9z6ruAJgJUFuhW9tGQSiJeX6ek-ky3E-tOk1')
+ content = resp.text
+
+ # ProcessCart 함수 찾기
+ start = content.find('function ProcessCart')
+ if start > 0:
+ depth = 0
+ end = start
+ in_func = False
+
+ for i in range(start, min(start + 5000, len(content))):
+ if content[i] == '{':
+ depth += 1
+ in_func = True
+ elif content[i] == '}':
+ depth -= 1
+ if in_func and depth == 0:
+ end = i + 1
+ break
+
+ func_content = content[start:end]
+ print("="*60)
+ print("ProcessCart 함수:")
+ print("="*60)
+ print(func_content)
+ else:
+ # 다른 패턴으로 찾기
+ print("ProcessCart를 변수로 찾기...")
+ start = content.find('ProcessCart=function')
+ if start > 0:
+ print(content[start:start+2000])
+ else:
+ # ajax 호출 찾기
+ import re
+ ajax_calls = re.findall(r'\$\.ajax\s*\(\s*\{[^}]{100,1000}(Cart|Order)[^}]{0,500}\}', content, re.IGNORECASE | re.DOTALL)
+ print(f"\nAJAX 호출 {len(ajax_calls)}개 발견")
+
+ # url 패턴 찾기
+ urls = re.findall(r'url\s*:\s*["\']([^"\']+)["\']', content)
+ print("\n모든 URL:")
+ for url in set(urls):
+ if 'Cart' in url or 'Order' in url or 'Add' in url or 'Insert' in url:
+ print(f" {url}")
+
+if __name__ == "__main__":
+ asyncio.run(extract())
diff --git a/backend/find_cart_js.py b/backend/find_cart_js.py
new file mode 100644
index 0000000..91153ef
--- /dev/null
+++ b/backend/find_cart_js.py
@@ -0,0 +1,90 @@
+# -*- coding: utf-8 -*-
+"""지오영 JavaScript에서 장바구니 추가 함수 찾기"""
+
+import requests
+from bs4 import BeautifulSoup
+import asyncio
+from playwright.async_api import async_playwright
+import re
+
+async def analyze_js():
+ async with async_playwright() as p:
+ browser = await p.chromium.launch(headless=True)
+ page = await browser.new_page()
+
+ # 로그인
+ await page.goto('https://gwn.geoweb.kr/Member/Login')
+ await page.fill('input[type="text"]', '7390')
+ await page.fill('input[type="password"]', 'trajet6640')
+ await page.click('button, input[type="submit"]')
+ await page.wait_for_load_state('networkidle')
+
+ # 메인 페이지
+ await page.goto('https://gwn.geoweb.kr/Home/Index')
+ await page.wait_for_timeout(3000)
+
+ # 모든 스크립트 태그 내용 가져오기
+ scripts = await page.evaluate('''() => {
+ var result = [];
+ var scripts = document.querySelectorAll('script');
+ scripts.forEach(s => {
+ if (s.src) {
+ result.push({type: 'src', url: s.src});
+ }
+ if (s.textContent && s.textContent.length > 100) {
+ result.push({type: 'inline', content: s.textContent});
+ }
+ });
+ return result;
+ }''')
+
+ print(f"스크립트 {len(scripts)}개 발견")
+
+ # 장바구니 관련 함수 찾기
+ for s in scripts:
+ if s['type'] == 'inline':
+ content = s['content']
+ # 담기, Cart, Add 관련 찾기
+ if '담기' in content or 'AddCart' in content or 'addCart' in content or 'InsertOrder' in content:
+ print("\n" + "="*60)
+ print("장바구니 관련 스크립트 발견!")
+ print("="*60)
+
+ # 함수 정의 찾기
+ func_patterns = [
+ r'function\s+(\w*[Cc]art\w*)\s*\([^)]*\)\s*{[^}]+}',
+ r'function\s+(\w*[Aa]dd\w*)\s*\([^)]*\)\s*{[^}]+}',
+ r'(\w+)\s*=\s*function\s*\([^)]*\)\s*{[^}]*[Cc]art[^}]*}',
+ ]
+
+ for pattern in func_patterns:
+ matches = re.findall(pattern, content, re.DOTALL)
+ for m in matches:
+ print(f"함수 발견: {m}")
+
+ # ajax 호출 찾기
+ ajax_pattern = r'\$\.ajax\s*\(\s*{[^}]+url[^}]+}'
+ ajax_matches = re.findall(ajax_pattern, content, re.DOTALL)
+ for m in ajax_matches:
+ if 'cart' in m.lower() or 'order' in m.lower() or 'add' in m.lower():
+ print(f"\nAJAX 호출:\n{m[:500]}")
+
+ # 일부 내용 출력
+ lines = content.split('\n')
+ for i, line in enumerate(lines):
+ if '담기' in line or 'addCart' in line.lower() or 'insertorder' in line.lower():
+ print(f"\n관련 라인 {i}:")
+ print('\n'.join(lines[max(0,i-3):min(len(lines),i+10)]))
+
+ # 외부 JS 파일 확인
+ print("\n" + "="*60)
+ print("외부 스크립트 파일:")
+ print("="*60)
+ for s in scripts:
+ if s['type'] == 'src':
+ print(s['url'])
+
+ await browser.close()
+
+if __name__ == "__main__":
+ asyncio.run(analyze_js())
diff --git a/backend/find_frmsave.py b/backend/find_frmsave.py
new file mode 100644
index 0000000..5e5f570
--- /dev/null
+++ b/backend/find_frmsave.py
@@ -0,0 +1,82 @@
+# -*- coding: utf-8 -*-
+"""frmSave 폼과 주문 저장 로직 찾기"""
+
+import requests
+from bs4 import BeautifulSoup
+import asyncio
+from playwright.async_api import async_playwright
+import re
+
+async def analyze():
+ async with async_playwright() as p:
+ browser = await p.chromium.launch(headless=True)
+ page = await browser.new_page()
+
+ # 네트워크 요청 캡처
+ requests_log = []
+ def log_req(req):
+ if req.method == 'POST':
+ requests_log.append({'url': req.url, 'data': req.post_data})
+ page.on('request', log_req)
+
+ await page.goto('https://gwn.geoweb.kr/Member/Login')
+ await page.fill('input[type="text"]', '7390')
+ await page.fill('input[type="password"]', 'trajet6640')
+ await page.click('button, input[type="submit"]')
+ await page.wait_for_load_state('networkidle')
+
+ # 메인 페이지
+ await page.goto('https://gwn.geoweb.kr/Home/Index')
+ await page.wait_for_timeout(2000)
+
+ # 페이지 HTML에서 frmSave 폼 찾기
+ html = await page.content()
+
+ print("="*60)
+ print("frmSave 폼 찾기:")
+ print("="*60)
+
+ soup = BeautifulSoup(html, 'html.parser')
+
+ # 모든 form 찾기
+ forms = soup.find_all('form')
+ for form in forms:
+ form_id = form.get('id', '')
+ form_action = form.get('action', '')
+ print(f"폼: id={form_id}, action={form_action}")
+
+ if 'save' in form_id.lower() or 'order' in form_id.lower():
+ print(f" >>> 주문 관련 폼 발견!")
+ inputs = form.find_all('input')
+ for inp in inputs[:10]:
+ print(f" - {inp.get('name')}: {inp.get('value', '')[:30]}")
+
+ # 주문저장 버튼 찾기
+ print("\n" + "="*60)
+ print("주문저장 버튼:")
+ print("="*60)
+
+ buttons = soup.find_all(['button', 'input'], type=['button', 'submit'])
+ for btn in buttons:
+ text = btn.get_text(strip=True) or btn.get('value', '')
+ onclick = btn.get('onclick', '')
+ if '저장' in text or '주문' in text:
+ print(f"버튼: {text}")
+ print(f" onclick: {onclick[:100]}")
+
+ # JavaScript에서 폼 action 찾기
+ scripts = soup.find_all('script')
+ for script in scripts:
+ text = script.get_text() or ''
+ if 'frmSave' in text:
+ print("\n" + "="*60)
+ print("frmSave 관련 스크립트:")
+ print("="*60)
+ # frmSave 근처 코드 출력
+ idx = text.find('frmSave')
+ print(text[max(0,idx-100):idx+300])
+
+ await browser.close()
+
+if __name__ == "__main__":
+ asyncio.run(analyze())
diff --git a/backend/find_order_api.py b/backend/find_order_api.py
new file mode 100644
index 0000000..35e0500
--- /dev/null
+++ b/backend/find_order_api.py
@@ -0,0 +1,70 @@
+# -*- coding: utf-8 -*-
+"""주문 확정 API 찾기"""
+
+import requests
+import asyncio
+from playwright.async_api import async_playwright
+import re
+
+async def find_order_api():
+ async with async_playwright() as p:
+ browser = await p.chromium.launch(headless=True)
+ page = await browser.new_page()
+
+ await page.goto('https://gwn.geoweb.kr/Member/Login')
+ await page.fill('input[type="text"]', '7390')
+ await page.fill('input[type="password"]', 'trajet6640')
+ await page.click('button, input[type="submit"]')
+ await page.wait_for_load_state('networkidle')
+
+ cookies = await page.context.cookies()
+ await browser.close()
+
+ session = requests.Session()
+ for c in cookies:
+ session.cookies.set(c['name'], c['value'])
+
+ # order.js 다운로드
+ resp = session.get('https://gwn.geoweb.kr/bundles/order?v=PGhSOAjQ9z6ruAJgJUFuhW9tGQSiJeX6ek-ky3E-tOk1')
+ content = resp.text
+
+ # InsertOrder, ConfirmOrder, SubmitOrder 등 찾기
+ print("="*60)
+ print("주문 관련 함수 찾기")
+ print("="*60)
+
+ # 함수 찾기
+ funcs = ['InsertOrder', 'ConfirmOrder', 'SubmitOrder', 'SaveOrder', 'ProcessOrder', 'DataOrder']
+ for func in funcs:
+ start = content.find(f'function {func}')
+ if start < 0:
+ start = content.find(f'{func}=function')
+ if start < 0:
+ start = content.find(f'{func}(')
+
+ if start > 0:
+ print(f"\n{func} 발견!")
+ # 함수 내용 출력
+ snippet = content[max(0, start-20):start+800]
+ print(snippet[:600])
+
+ # DataOrder URL 찾기
+ print("\n" + "="*60)
+ print("DataOrder 관련")
+ print("="*60)
+
+ dataorder_pattern = re.findall(r'.{30}DataOrder.{100}', content)
+ for p in dataorder_pattern[:5]:
+ print(p)
+
+ # 모든 ajax URL 찾기
+ print("\n" + "="*60)
+ print("주문 관련 URL")
+ print("="*60)
+
+ urls = re.findall(r'url\s*:\s*["\']([^"\']*(?:Order|Submit|Confirm|Save)[^"\']*)["\']', content, re.IGNORECASE)
+ for url in set(urls):
+ print(url)
+
+if __name__ == "__main__":
+ asyncio.run(find_order_api())
diff --git a/backend/find_order_api2.py b/backend/find_order_api2.py
new file mode 100644
index 0000000..42859c3
--- /dev/null
+++ b/backend/find_order_api2.py
@@ -0,0 +1,76 @@
+# -*- coding: utf-8 -*-
+"""주문 확정 API 찾기 - 전체 검색"""
+
+import requests
+import asyncio
+from playwright.async_api import async_playwright
+import re
+
+async def analyze():
+ async with async_playwright() as p:
+ browser = await p.chromium.launch(headless=True)
+ page = await browser.new_page()
+
+ await page.goto('https://gwn.geoweb.kr/Member/Login')
+ await page.fill('input[type="text"]', '7390')
+ await page.fill('input[type="password"]', 'trajet6640')
+ await page.click('button, input[type="submit"]')
+ await page.wait_for_load_state('networkidle')
+
+ cookies = await page.context.cookies()
+ await browser.close()
+
+ session = requests.Session()
+ for c in cookies:
+ session.cookies.set(c['name'], c['value'])
+
+ # 모든 JS 번들 다운로드
+ js_urls = [
+ 'https://gwn.geoweb.kr/bundles/order_product_cart?v=JPwFQ8DWaNMW1VmbtWYKTJqxT-5255z351W5iZE1qew1',
+ 'https://gwn.geoweb.kr/bundles/order?v=PGhSOAjQ9z6ruAJgJUFuhW9tGQSiJeX6ek-ky3E-tOk1',
+ 'https://gwn.geoweb.kr/bundles/javascript?v=Tn_AqbA-PX_uu3d0zjfQOYS6NPSDLtOVqjW95a949Ow1'
+ ]
+
+ all_content = ""
+ for url in js_urls:
+ resp = session.get(url)
+ all_content += resp.text + "\n"
+
+ print(f"총 JS 길이: {len(all_content)}")
+
+ # 모든 ajax POST URL 찾기
+ print("\n" + "="*60)
+ print("모든 POST URL:")
+ print("="*60)
+
+ # $.ajax 패턴
+ ajax_patterns = re.findall(r'\$\.ajax\s*\(\s*\{[^}]*url\s*:\s*["\']([^"\']+)["\'][^}]*type\s*:\s*["\']POST["\']', all_content, re.IGNORECASE | re.DOTALL)
+ ajax_patterns += re.findall(r'\$\.ajax\s*\(\s*\{[^}]*type\s*:\s*["\']POST["\'][^}]*url\s*:\s*["\']([^"\']+)["\']', all_content, re.IGNORECASE | re.DOTALL)
+
+ for url in set(ajax_patterns):
+ print(url)
+
+ # 주문저장, 저장 관련
+ print("\n" + "="*60)
+ print("저장/주문 관련 키워드:")
+ print("="*60)
+
+ keywords = ['주문저장', '저장', 'save', 'submit', 'confirm', 'order', 'insert']
+ for kw in keywords:
+ matches = re.findall(rf'.{{50}}{kw}.{{50}}', all_content, re.IGNORECASE)
+ if matches:
+ print(f"\n--- {kw} ---")
+ for m in matches[:3]:
+ print(m.replace('\n', ' ')[:100])
+
+ # 버튼 onclick 찾기
+ print("\n" + "="*60)
+ print("주문저장 버튼:")
+ print("="*60)
+
+ save_btn = re.findall(r'주문저장.{0,200}', all_content)
+ for s in save_btn[:5]:
+ print(s[:150])
+
+if __name__ == "__main__":
+ asyncio.run(analyze())
diff --git a/backend/geoyoung_api.py b/backend/geoyoung_api.py
new file mode 100644
index 0000000..5b41c63
--- /dev/null
+++ b/backend/geoyoung_api.py
@@ -0,0 +1,316 @@
+# -*- coding: utf-8 -*-
+"""
+지오영 도매상 API - Flask Blueprint
+
+핵심 로직은 wholesale 패키지에서 가져옴
+이 파일은 Flask 웹 API 연동만 담당
+"""
+
+import re
+import time
+import logging
+
+from flask import Blueprint, jsonify, request
+
+# wholesale 패키지 경로 설정
+import wholesale_path
+
+# wholesale 패키지에서 핵심 클래스 가져오기
+from wholesale import GeoYoungSession
+
+logger = logging.getLogger(__name__)
+
+# Blueprint 생성
+geoyoung_bp = Blueprint('geoyoung', __name__, url_prefix='/api/geoyoung')
+
+
+# ========== 세션 관리 ==========
+
+_geo_session = None
+
+def get_geo_session():
+ global _geo_session
+ if _geo_session is None:
+ _geo_session = GeoYoungSession()
+ return _geo_session
+
+
+def search_geoyoung_stock(keyword: str):
+ """지오영 재고 검색 (동기, 빠름)"""
+ try:
+ session = get_geo_session()
+ products = session.search_stock(keyword)
+
+ return {
+ 'success': True,
+ 'keyword': keyword,
+ 'count': len(products),
+ 'items': products
+ }
+
+ except Exception as e:
+ logger.error(f"지오영 검색 오류: {e}")
+ return {'success': False, 'error': 'SEARCH_ERROR', 'message': str(e)}
+
+
+# ========== Flask API Routes ==========
+
+@geoyoung_bp.route('/stock', methods=['GET'])
+def api_geoyoung_stock():
+ """
+ 지오영 재고 조회 API (빠름)
+
+ GET /api/geoyoung/stock?kd_code=670400830
+ GET /api/geoyoung/stock?keyword=레바미피드
+ """
+ kd_code = request.args.get('kd_code', '').strip()
+ keyword = request.args.get('keyword', '').strip()
+
+ search_term = kd_code or keyword
+
+ if not search_term:
+ return jsonify({
+ 'success': False,
+ 'error': 'MISSING_PARAM',
+ 'message': 'kd_code 또는 keyword 파라미터가 필요합니다'
+ }), 400
+
+ try:
+ result = search_geoyoung_stock(search_term)
+ return jsonify(result)
+ except Exception as e:
+ logger.error(f"지오영 API 오류: {e}")
+ return jsonify({
+ 'success': False,
+ 'error': 'API_ERROR',
+ 'message': str(e)
+ }), 500
+
+
+@geoyoung_bp.route('/stock-by-name', methods=['GET'])
+def api_geoyoung_stock_by_name():
+ """
+ 제품명에서 성분명 추출 후 지오영 검색
+
+ GET /api/geoyoung/stock-by-name?product_name=휴니즈레바미피드정_(0.1g/1정)
+ """
+ product_name = request.args.get('product_name', '').strip()
+
+ if not product_name:
+ return jsonify({
+ 'success': False,
+ 'error': 'MISSING_PARAM',
+ 'message': 'product_name 파라미터가 필요합니다'
+ }), 400
+
+ # 성분명 추출
+ prefixes = ['휴니즈', '휴온스', '대웅', '한미', '종근당', '유한', '녹십자', '동아', '일동', '광동',
+ '삼성', '안국', '보령', '광동', '경동', '현대', '일양', '태극', '환인', '에스케이']
+ ingredient = product_name
+
+ for prefix in prefixes:
+ if ingredient.startswith(prefix):
+ ingredient = ingredient[len(prefix):]
+ break
+
+ match = re.match(r'^([가-힣a-zA-Z]+)', ingredient)
+ if match:
+ ingredient = match.group(1)
+ if ingredient.endswith('정'):
+ ingredient = ingredient[:-1]
+ elif ingredient.endswith('캡슐'):
+ ingredient = ingredient[:-2]
+
+ if not ingredient:
+ ingredient = product_name[:10]
+
+ try:
+ result = search_geoyoung_stock(ingredient)
+ result['extracted_ingredient'] = ingredient
+ result['original_product_name'] = product_name
+ return jsonify(result)
+ except Exception as e:
+ logger.error(f"지오영 API 오류: {e}")
+ return jsonify({
+ 'success': False,
+ 'error': 'API_ERROR',
+ 'message': str(e)
+ }), 500
+
+
+@geoyoung_bp.route('/session-status', methods=['GET'])
+def api_session_status():
+ """세션 상태 확인"""
+ session = get_geo_session()
+ return jsonify({
+ 'logged_in': session._logged_in,
+ 'last_login': session._last_login,
+ 'session_age_sec': int(time.time() - session._last_login) if session._last_login else None
+ })
+
+
+@geoyoung_bp.route('/cart', methods=['GET'])
+def api_geoyoung_cart():
+ """장바구니 조회 API"""
+ try:
+ session = get_geo_session()
+ result = session.get_cart()
+ return jsonify(result)
+ except Exception as e:
+ return jsonify({'success': False, 'error': str(e), 'items': []}), 500
+
+
+@geoyoung_bp.route('/cart/clear', methods=['POST'])
+def api_geoyoung_cart_clear():
+ """장바구니 비우기 API"""
+ try:
+ session = get_geo_session()
+ result = session.clear_cart()
+ return jsonify(result)
+ except Exception as e:
+ return jsonify({'success': False, 'error': str(e)}), 500
+
+
+@geoyoung_bp.route('/confirm', methods=['POST'])
+def api_geoyoung_confirm():
+ """주문 확정 API"""
+ data = request.get_json() or {}
+ memo = data.get('memo', '')
+
+ try:
+ session = get_geo_session()
+ result = session.submit_order(memo)
+ return jsonify(result)
+ except Exception as e:
+ return jsonify({'success': False, 'error': str(e)}), 500
+
+
+@geoyoung_bp.route('/full-order', methods=['POST'])
+def api_geoyoung_full_order():
+ """전체 주문 API (검색 → 장바구니 → 확정)"""
+ data = request.get_json()
+
+ if not data or not data.get('kd_code'):
+ return jsonify({'success': False, 'error': 'kd_code required'}), 400
+
+ try:
+ session = get_geo_session()
+ result = session.full_order(
+ kd_code=data['kd_code'],
+ quantity=data.get('quantity', 1),
+ specification=data.get('specification'),
+ check_stock=data.get('check_stock', True),
+ auto_confirm=data.get('auto_confirm', True),
+ memo=data.get('memo', '')
+ )
+ return jsonify(result)
+ except Exception as e:
+ return jsonify({'success': False, 'error': str(e)}), 500
+
+
+@geoyoung_bp.route('/order', methods=['POST'])
+def api_geoyoung_order():
+ """지오영 주문 API (장바구니 추가)"""
+ data = request.get_json()
+
+ if not data:
+ return jsonify({'success': False, 'error': 'NO_DATA'}), 400
+
+ kd_code = data.get('kd_code', '').strip()
+ quantity = data.get('quantity', 1)
+ specification = data.get('specification')
+ check_stock = data.get('check_stock', True)
+
+ if not kd_code:
+ return jsonify({
+ 'success': False,
+ 'error': 'MISSING_PARAM',
+ 'message': 'kd_code가 필요합니다'
+ }), 400
+
+ try:
+ session = get_geo_session()
+ result = session.quick_order(
+ kd_code=kd_code,
+ quantity=quantity,
+ spec=specification,
+ check_stock=check_stock
+ )
+ return jsonify(result)
+ except Exception as e:
+ logger.error(f"지오영 주문 오류: {e}")
+ return jsonify({
+ 'success': False,
+ 'error': 'ORDER_ERROR',
+ 'message': str(e)
+ }), 500
+
+
+@geoyoung_bp.route('/order-batch', methods=['POST'])
+def api_geoyoung_order_batch():
+ """지오영 일괄 주문 API"""
+ data = request.get_json()
+
+ if not data or not data.get('items'):
+ return jsonify({'success': False, 'error': 'NO_ITEMS'}), 400
+
+ items = data.get('items', [])
+ check_stock = data.get('check_stock', True)
+
+ session = get_geo_session()
+ results = []
+ success_count = 0
+ failed_count = 0
+
+ for item in items:
+ kd_code = item.get('kd_code', '').strip()
+ quantity = item.get('quantity', 1)
+ specification = item.get('specification')
+
+ if not kd_code:
+ results.append({
+ 'kd_code': kd_code,
+ 'success': False,
+ 'error': 'MISSING_KD_CODE'
+ })
+ failed_count += 1
+ continue
+
+ try:
+ result = session.quick_order(
+ kd_code=kd_code,
+ quantity=quantity,
+ spec=specification,
+ check_stock=check_stock
+ )
+ result['kd_code'] = kd_code
+ result['requested_qty'] = quantity
+ results.append(result)
+
+ if result.get('success'):
+ success_count += 1
+ else:
+ failed_count += 1
+
+ except Exception as e:
+ results.append({
+ 'kd_code': kd_code,
+ 'success': False,
+ 'error': 'EXCEPTION',
+ 'message': str(e)
+ })
+ failed_count += 1
+
+ return jsonify({
+ 'success': True,
+ 'total': len(items),
+ 'success_count': success_count,
+ 'failed_count': failed_count,
+ 'results': results
+ })
+
+
+# ========== 하위 호환성 ==========
+
+# 기존 코드에서 직접 클래스 참조하는 경우를 위해
+GeoyoungSession = GeoYoungSession
diff --git a/backend/order_db.py b/backend/order_db.py
new file mode 100644
index 0000000..770d0a9
--- /dev/null
+++ b/backend/order_db.py
@@ -0,0 +1,859 @@
+# -*- coding: utf-8 -*-
+"""
+주문 관리 DB (SQLite)
+- 다중 도매상 지원 (지오영, 수인, 백제 등)
+- 주문 상태 추적
+- 품목별 결과 관리
+- 자동화 ERP 확장 대비
+"""
+
+import sqlite3
+import os
+from datetime import datetime
+from typing import Optional, List, Dict
+import json
+
+# DB 경로
+DB_PATH = os.path.join(os.path.dirname(__file__), 'db', 'orders.db')
+
+
+def get_connection():
+ """DB 연결"""
+ os.makedirs(os.path.dirname(DB_PATH), exist_ok=True)
+ conn = sqlite3.connect(DB_PATH)
+ conn.row_factory = sqlite3.Row
+ return conn
+
+
+def init_db():
+ """DB 초기화 - 테이블 생성"""
+ conn = get_connection()
+ cursor = conn.cursor()
+
+ # ─────────────────────────────────────────────
+ # 도매상 마스터
+ # ─────────────────────────────────────────────
+ cursor.execute('''
+ CREATE TABLE IF NOT EXISTS wholesalers (
+ id TEXT PRIMARY KEY, -- 'geoyoung', 'sooin', 'baekje'
+ name TEXT NOT NULL, -- '지오영', '수인', '백제'
+ api_type TEXT, -- 'playwright', 'api', 'manual'
+ base_url TEXT,
+ is_active INTEGER DEFAULT 1,
+ config_json TEXT, -- 로그인 정보 등 (암호화 권장)
+ created_at TEXT DEFAULT CURRENT_TIMESTAMP
+ )
+ ''')
+
+ # 기본 도매상 등록
+ cursor.execute('''
+ INSERT OR IGNORE INTO wholesalers (id, name, api_type, base_url)
+ VALUES
+ ('geoyoung', '지오영', 'playwright', 'https://gwn.geoweb.kr'),
+ ('sooin', '수인', 'manual', NULL),
+ ('baekje', '백제', 'manual', NULL)
+ ''')
+
+ # ─────────────────────────────────────────────
+ # 주문 헤더
+ # ─────────────────────────────────────────────
+ cursor.execute('''
+ CREATE TABLE IF NOT EXISTS orders (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ order_no TEXT UNIQUE, -- 주문번호 (ORD-20260306-001)
+ wholesaler_id TEXT NOT NULL, -- 도매상 ID
+
+ -- 주문 정보
+ order_date TEXT NOT NULL, -- 주문일 (YYYY-MM-DD)
+ order_time TEXT, -- 주문시간 (HH:MM:SS)
+ order_type TEXT DEFAULT 'manual', -- 'manual', 'auto', 'scheduled'
+ order_session TEXT, -- 'morning', 'afternoon', 'evening'
+
+ -- 상태
+ status TEXT DEFAULT 'draft', -- draft, pending, submitted, partial, completed, failed, cancelled
+
+ -- 집계
+ total_items INTEGER DEFAULT 0,
+ total_qty INTEGER DEFAULT 0,
+ success_items INTEGER DEFAULT 0,
+ failed_items INTEGER DEFAULT 0,
+
+ -- 참조
+ parent_order_id INTEGER, -- 재주문 시 원주문 참조
+ reference_period TEXT, -- 사용량 조회 기간 (2026-03-01~2026-03-06)
+
+ -- 메타
+ note TEXT,
+ created_at TEXT DEFAULT CURRENT_TIMESTAMP,
+ updated_at TEXT DEFAULT CURRENT_TIMESTAMP,
+ submitted_at TEXT, -- 실제 제출 시간
+ completed_at TEXT,
+
+ FOREIGN KEY (wholesaler_id) REFERENCES wholesalers(id),
+ FOREIGN KEY (parent_order_id) REFERENCES orders(id)
+ )
+ ''')
+
+ # ─────────────────────────────────────────────
+ # 주문 품목 상세
+ # ─────────────────────────────────────────────
+ cursor.execute('''
+ CREATE TABLE IF NOT EXISTS order_items (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ order_id INTEGER NOT NULL,
+
+ -- 약품 정보
+ drug_code TEXT NOT NULL, -- PIT3000 약품코드
+ kd_code TEXT, -- 보험코드 (지오영 검색용)
+ product_name TEXT NOT NULL,
+ manufacturer TEXT,
+
+ -- 규격/수량
+ specification TEXT, -- '30T', '300T', '500T'
+ unit_qty INTEGER, -- 규격당 수량 (30, 300, 500)
+ order_qty INTEGER NOT NULL, -- 주문 수량 (단위 개수)
+ total_dose INTEGER, -- 총 정제수 (order_qty * unit_qty)
+
+ -- 주문 근거
+ usage_qty INTEGER, -- 사용량 (조회 기간)
+ current_stock INTEGER, -- 주문 시점 재고
+
+ -- 가격 (선택)
+ unit_price INTEGER,
+ total_price INTEGER,
+
+ -- 상태
+ status TEXT DEFAULT 'pending', -- pending, submitted, success, failed, cancelled
+
+ -- 결과
+ result_code TEXT, -- 'OK', 'OUT_OF_STOCK', 'NOT_FOUND', 'ERROR'
+ result_message TEXT,
+ wholesaler_order_no TEXT, -- 도매상 측 주문번호
+
+ -- 메타
+ created_at TEXT DEFAULT CURRENT_TIMESTAMP,
+ updated_at TEXT DEFAULT CURRENT_TIMESTAMP,
+
+ FOREIGN KEY (order_id) REFERENCES orders(id) ON DELETE CASCADE
+ )
+ ''')
+
+ # ─────────────────────────────────────────────
+ # 주문 로그 (상태 변경 이력)
+ # ─────────────────────────────────────────────
+ cursor.execute('''
+ CREATE TABLE IF NOT EXISTS order_logs (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ order_id INTEGER NOT NULL,
+ order_item_id INTEGER, -- NULL이면 주문 전체 로그
+
+ action TEXT NOT NULL, -- 'created', 'submitted', 'success', 'failed', 'cancelled'
+ old_status TEXT,
+ new_status TEXT,
+
+ message TEXT,
+ detail_json TEXT, -- API 응답 등 상세 정보
+
+ created_at TEXT DEFAULT CURRENT_TIMESTAMP,
+
+ FOREIGN KEY (order_id) REFERENCES orders(id) ON DELETE CASCADE,
+ FOREIGN KEY (order_item_id) REFERENCES order_items(id) ON DELETE CASCADE
+ )
+ ''')
+
+ # ─────────────────────────────────────────────
+ # 주문 컨텍스트 (AI 학습용 스냅샷)
+ # ─────────────────────────────────────────────
+ cursor.execute('''
+ CREATE TABLE IF NOT EXISTS order_context (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ order_item_id INTEGER NOT NULL,
+
+ -- 약품 정보
+ drug_code TEXT NOT NULL,
+ product_name TEXT,
+
+ -- 주문 시점 재고
+ stock_at_order INTEGER, -- 주문 시점 현재고
+
+ -- 사용량 분석
+ usage_1d INTEGER, -- 최근 1일 사용량
+ usage_7d INTEGER, -- 최근 7일 사용량
+ usage_30d INTEGER, -- 최근 30일 사용량
+ avg_daily_usage REAL, -- 일평균 사용량 (30일 기준)
+
+ -- 주문 패턴
+ ordered_spec TEXT, -- 주문한 규격 (30T, 300T)
+ ordered_qty INTEGER, -- 주문 수량 (단위 개수)
+ ordered_dose INTEGER, -- 주문 총 정제수
+
+ -- 규격 선택 이유 (AI 분석용)
+ available_specs TEXT, -- 가능한 규격들 JSON ["30T", "300T"]
+ spec_stocks TEXT, -- 규격별 도매상 재고 JSON {"30T": 50, "300T": 0}
+ selection_reason TEXT, -- 'stock_available', 'best_fit', 'only_option', 'user_choice'
+
+ -- 예측 vs 실제 (나중에 업데이트)
+ days_until_stockout REAL, -- 주문 시점 예상 재고 소진일
+ actual_reorder_days INTEGER, -- 실제 재주문까지 일수 (나중에 업데이트)
+
+ -- 메타
+ created_at TEXT DEFAULT CURRENT_TIMESTAMP,
+
+ FOREIGN KEY (order_item_id) REFERENCES order_items(id) ON DELETE CASCADE
+ )
+ ''')
+
+ # ─────────────────────────────────────────────
+ # 일별 사용량 추적 (시계열 데이터)
+ # ─────────────────────────────────────────────
+ cursor.execute('''
+ CREATE TABLE IF NOT EXISTS daily_usage (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ drug_code TEXT NOT NULL,
+ usage_date TEXT NOT NULL, -- YYYY-MM-DD
+
+ -- 처방 데이터
+ rx_count INTEGER DEFAULT 0, -- 처방 건수
+ rx_qty INTEGER DEFAULT 0, -- 처방 수량 (정제수)
+
+ -- POS 데이터 (일반약)
+ pos_count INTEGER DEFAULT 0,
+ pos_qty INTEGER DEFAULT 0,
+
+ -- 집계
+ total_qty INTEGER DEFAULT 0,
+
+ -- 재고 스냅샷
+ stock_start INTEGER, -- 시작 재고
+ stock_end INTEGER, -- 종료 재고
+
+ created_at TEXT DEFAULT CURRENT_TIMESTAMP,
+
+ UNIQUE(drug_code, usage_date)
+ )
+ ''')
+
+ # ─────────────────────────────────────────────
+ # AI 분석 결과/패턴
+ # ─────────────────────────────────────────────
+ cursor.execute('''
+ CREATE TABLE IF NOT EXISTS order_patterns (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ drug_code TEXT NOT NULL,
+
+ -- 분석 기간
+ analysis_date TEXT NOT NULL,
+ analysis_period_days INTEGER,
+
+ -- 사용 패턴
+ avg_daily_usage REAL,
+ usage_stddev REAL, -- 사용량 표준편차 (변동성)
+ peak_usage INTEGER, -- 최대 사용량
+
+ -- 주문 패턴
+ typical_order_spec TEXT, -- 주로 주문하는 규격
+ typical_order_qty INTEGER, -- 주로 주문하는 수량
+ order_frequency_days REAL, -- 평균 주문 주기 (일)
+
+ -- AI 추천
+ recommended_spec TEXT, -- 추천 규격
+ recommended_qty INTEGER, -- 추천 수량
+ recommended_reorder_point INTEGER,-- 추천 재주문점 (재고가 이 이하면 주문)
+ confidence_score REAL, -- 추천 신뢰도 (0-1)
+
+ -- 모델 정보
+ model_version TEXT,
+
+ created_at TEXT DEFAULT CURRENT_TIMESTAMP,
+
+ UNIQUE(drug_code, analysis_date)
+ )
+ ''')
+
+ # ─────────────────────────────────────────────
+ # 인덱스
+ # ─────────────────────────────────────────────
+ cursor.execute('CREATE INDEX IF NOT EXISTS idx_orders_date ON orders(order_date)')
+ cursor.execute('CREATE INDEX IF NOT EXISTS idx_orders_status ON orders(status)')
+ cursor.execute('CREATE INDEX IF NOT EXISTS idx_orders_wholesaler ON orders(wholesaler_id)')
+ cursor.execute('CREATE INDEX IF NOT EXISTS idx_order_items_drug ON order_items(drug_code)')
+ cursor.execute('CREATE INDEX IF NOT EXISTS idx_order_items_status ON order_items(status)')
+ cursor.execute('CREATE INDEX IF NOT EXISTS idx_order_context_drug ON order_context(drug_code)')
+ cursor.execute('CREATE INDEX IF NOT EXISTS idx_daily_usage_drug ON daily_usage(drug_code)')
+ cursor.execute('CREATE INDEX IF NOT EXISTS idx_daily_usage_date ON daily_usage(usage_date)')
+ cursor.execute('CREATE INDEX IF NOT EXISTS idx_order_patterns_drug ON order_patterns(drug_code)')
+
+ conn.commit()
+ conn.close()
+
+ return True
+
+
+def generate_order_no(wholesaler_id: str) -> str:
+ """주문번호 생성 (ORD-GEO-20260306-001)"""
+ prefix_map = {
+ 'geoyoung': 'GEO',
+ 'sooin': 'SOO',
+ 'baekje': 'BAK'
+ }
+ prefix = prefix_map.get(wholesaler_id, 'ORD')
+ date_str = datetime.now().strftime('%Y%m%d')
+
+ conn = get_connection()
+ cursor = conn.cursor()
+
+ # 오늘 해당 도매상 주문 수 카운트
+ cursor.execute('''
+ SELECT COUNT(*) FROM orders
+ WHERE wholesaler_id = ? AND order_date = ?
+ ''', (wholesaler_id, datetime.now().strftime('%Y-%m-%d')))
+
+ count = cursor.fetchone()[0] + 1
+ conn.close()
+
+ return f"ORD-{prefix}-{date_str}-{count:03d}"
+
+
+def create_order(wholesaler_id: str, items: List[Dict],
+ order_type: str = 'manual',
+ order_session: str = None,
+ reference_period: str = None,
+ note: str = None) -> Dict:
+ """
+ 주문 생성 (draft 상태)
+
+ items: [
+ {
+ 'drug_code': '670400830',
+ 'kd_code': '670400830',
+ 'product_name': '레바미피드정 30T',
+ 'manufacturer': '휴온스',
+ 'specification': '30T',
+ 'unit_qty': 30,
+ 'order_qty': 10,
+ 'usage_qty': 280,
+ 'current_stock': 50
+ }
+ ]
+ """
+ conn = get_connection()
+ cursor = conn.cursor()
+
+ try:
+ order_no = generate_order_no(wholesaler_id)
+ now = datetime.now()
+
+ # 주문 헤더 생성
+ cursor.execute('''
+ INSERT INTO orders (
+ order_no, wholesaler_id, order_date, order_time,
+ order_type, order_session, reference_period, note,
+ total_items, total_qty, status
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'draft')
+ ''', (
+ order_no,
+ wholesaler_id,
+ now.strftime('%Y-%m-%d'),
+ now.strftime('%H:%M:%S'),
+ order_type,
+ order_session,
+ reference_period,
+ note,
+ len(items),
+ sum(item.get('order_qty', 0) for item in items)
+ ))
+
+ order_id = cursor.lastrowid
+
+ # 주문 품목 생성
+ for item in items:
+ unit_qty = item.get('unit_qty', 1)
+ order_qty = item.get('order_qty', 0)
+
+ cursor.execute('''
+ INSERT INTO order_items (
+ order_id, drug_code, kd_code, product_name, manufacturer,
+ specification, unit_qty, order_qty, total_dose,
+ usage_qty, current_stock, status
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'pending')
+ ''', (
+ order_id,
+ item.get('drug_code'),
+ item.get('kd_code'),
+ item.get('product_name'),
+ item.get('manufacturer'),
+ item.get('specification'),
+ unit_qty,
+ order_qty,
+ order_qty * unit_qty,
+ item.get('usage_qty'),
+ item.get('current_stock')
+ ))
+
+ # 로그
+ cursor.execute('''
+ INSERT INTO order_logs (order_id, action, new_status, message)
+ VALUES (?, 'created', 'draft', ?)
+ ''', (order_id, f'{len(items)}개 품목 주문 생성'))
+
+ conn.commit()
+
+ return {
+ 'success': True,
+ 'order_id': order_id,
+ 'order_no': order_no,
+ 'total_items': len(items)
+ }
+
+ except Exception as e:
+ conn.rollback()
+ return {'success': False, 'error': str(e)}
+ finally:
+ conn.close()
+
+
+def get_order(order_id: int) -> Optional[Dict]:
+ """주문 조회 (품목 포함)"""
+ conn = get_connection()
+ cursor = conn.cursor()
+
+ cursor.execute('SELECT * FROM orders WHERE id = ?', (order_id,))
+ order = cursor.fetchone()
+
+ if not order:
+ conn.close()
+ return None
+
+ cursor.execute('SELECT * FROM order_items WHERE order_id = ?', (order_id,))
+ items = cursor.fetchall()
+
+ conn.close()
+
+ return {
+ **dict(order),
+ 'items': [dict(item) for item in items]
+ }
+
+
+def update_order_status(order_id: int, status: str, message: str = None) -> bool:
+ """주문 상태 업데이트"""
+ conn = get_connection()
+ cursor = conn.cursor()
+
+ try:
+ # 현재 상태 조회
+ cursor.execute('SELECT status FROM orders WHERE id = ?', (order_id,))
+ row = cursor.fetchone()
+ if not row:
+ return False
+
+ old_status = row['status']
+
+ # 상태 업데이트
+ now = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
+
+ update_fields = ['status = ?', 'updated_at = ?']
+ params = [status, now]
+
+ if status == 'submitted':
+ update_fields.append('submitted_at = ?')
+ params.append(now)
+ elif status in ('completed', 'failed'):
+ update_fields.append('completed_at = ?')
+ params.append(now)
+
+ params.append(order_id)
+
+ cursor.execute(f'''
+ UPDATE orders SET {', '.join(update_fields)} WHERE id = ?
+ ''', params)
+
+ # 로그
+ cursor.execute('''
+ INSERT INTO order_logs (order_id, action, old_status, new_status, message)
+ VALUES (?, ?, ?, ?, ?)
+ ''', (order_id, status, old_status, status, message))
+
+ conn.commit()
+ return True
+
+ except Exception as e:
+ conn.rollback()
+ return False
+ finally:
+ conn.close()
+
+
+def update_item_result(item_id: int, status: str, result_code: str = None,
+ result_message: str = None, wholesaler_order_no: str = None) -> bool:
+ """품목 결과 업데이트"""
+ conn = get_connection()
+ cursor = conn.cursor()
+
+ try:
+ cursor.execute('''
+ UPDATE order_items SET
+ status = ?,
+ result_code = ?,
+ result_message = ?,
+ wholesaler_order_no = ?,
+ updated_at = CURRENT_TIMESTAMP
+ WHERE id = ?
+ ''', (status, result_code, result_message, wholesaler_order_no, item_id))
+
+ # 주문 집계 업데이트
+ cursor.execute('SELECT order_id FROM order_items WHERE id = ?', (item_id,))
+ order_id = cursor.fetchone()['order_id']
+
+ cursor.execute('''
+ UPDATE orders SET
+ success_items = (SELECT COUNT(*) FROM order_items WHERE order_id = ? AND status = 'success'),
+ failed_items = (SELECT COUNT(*) FROM order_items WHERE order_id = ? AND status = 'failed'),
+ updated_at = CURRENT_TIMESTAMP
+ WHERE id = ?
+ ''', (order_id, order_id, order_id))
+
+ conn.commit()
+ return True
+
+ except Exception as e:
+ conn.rollback()
+ return False
+ finally:
+ conn.close()
+
+
+def get_order_history(wholesaler_id: str = None,
+ start_date: str = None,
+ end_date: str = None,
+ status: str = None,
+ limit: int = 50) -> List[Dict]:
+ """주문 이력 조회"""
+ conn = get_connection()
+ cursor = conn.cursor()
+
+ query = 'SELECT * FROM orders WHERE 1=1'
+ params = []
+
+ if wholesaler_id:
+ query += ' AND wholesaler_id = ?'
+ params.append(wholesaler_id)
+
+ if start_date:
+ query += ' AND order_date >= ?'
+ params.append(start_date)
+
+ if end_date:
+ query += ' AND order_date <= ?'
+ params.append(end_date)
+
+ if status:
+ query += ' AND status = ?'
+ params.append(status)
+
+ query += ' ORDER BY created_at DESC LIMIT ?'
+ params.append(limit)
+
+ cursor.execute(query, params)
+ orders = [dict(row) for row in cursor.fetchall()]
+
+ conn.close()
+ return orders
+
+
+# ─────────────────────────────────────────────
+# AI 학습용 함수들
+# ─────────────────────────────────────────────
+
+def save_order_context(order_item_id: int, context: Dict) -> bool:
+ """
+ 주문 시점 컨텍스트 저장 (AI 학습용)
+
+ context: {
+ 'drug_code': '670400830',
+ 'product_name': '레바미피드정',
+ 'stock_at_order': 50,
+ 'usage_1d': 30,
+ 'usage_7d': 180,
+ 'usage_30d': 800,
+ 'ordered_spec': '30T',
+ 'ordered_qty': 10,
+ 'available_specs': ['30T', '300T'],
+ 'spec_stocks': {'30T': 50, '300T': 0},
+ 'selection_reason': 'stock_available'
+ }
+ """
+ conn = get_connection()
+ cursor = conn.cursor()
+
+ try:
+ # 일평균 사용량 계산
+ usage_30d = context.get('usage_30d', 0)
+ avg_daily = usage_30d / 30.0 if usage_30d else 0
+
+ # 재고 소진 예상일 계산
+ stock = context.get('stock_at_order', 0)
+ days_until_stockout = stock / avg_daily if avg_daily > 0 else None
+
+ # 주문 총 정제수
+ ordered_qty = context.get('ordered_qty', 0)
+ spec = context.get('ordered_spec', '')
+ unit_qty = int(''.join(filter(str.isdigit, spec))) if spec else 1
+ ordered_dose = ordered_qty * unit_qty
+
+ cursor.execute('''
+ INSERT INTO order_context (
+ order_item_id, drug_code, product_name,
+ stock_at_order, usage_1d, usage_7d, usage_30d, avg_daily_usage,
+ ordered_spec, ordered_qty, ordered_dose,
+ available_specs, spec_stocks, selection_reason,
+ days_until_stockout
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
+ ''', (
+ order_item_id,
+ context.get('drug_code'),
+ context.get('product_name'),
+ context.get('stock_at_order'),
+ context.get('usage_1d'),
+ context.get('usage_7d'),
+ context.get('usage_30d'),
+ avg_daily,
+ context.get('ordered_spec'),
+ ordered_qty,
+ ordered_dose,
+ json.dumps(context.get('available_specs', []), ensure_ascii=False),
+ json.dumps(context.get('spec_stocks', {}), ensure_ascii=False),
+ context.get('selection_reason'),
+ days_until_stockout
+ ))
+
+ conn.commit()
+ return True
+
+ except Exception as e:
+ conn.rollback()
+ return False
+ finally:
+ conn.close()
+
+
+def update_daily_usage(drug_code: str, usage_date: str,
+ rx_count: int = 0, rx_qty: int = 0,
+ pos_count: int = 0, pos_qty: int = 0,
+ stock_end: int = None) -> bool:
+ """일별 사용량 업데이트 (UPSERT)"""
+ conn = get_connection()
+ cursor = conn.cursor()
+
+ try:
+ total_qty = rx_qty + pos_qty
+
+ cursor.execute('''
+ INSERT INTO daily_usage (
+ drug_code, usage_date, rx_count, rx_qty, pos_count, pos_qty,
+ total_qty, stock_end
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
+ ON CONFLICT(drug_code, usage_date) DO UPDATE SET
+ rx_count = rx_count + excluded.rx_count,
+ rx_qty = rx_qty + excluded.rx_qty,
+ pos_count = pos_count + excluded.pos_count,
+ pos_qty = pos_qty + excluded.pos_qty,
+ total_qty = total_qty + excluded.total_qty,
+ stock_end = COALESCE(excluded.stock_end, stock_end)
+ ''', (drug_code, usage_date, rx_count, rx_qty, pos_count, pos_qty,
+ total_qty, stock_end))
+
+ conn.commit()
+ return True
+
+ except Exception as e:
+ conn.rollback()
+ return False
+ finally:
+ conn.close()
+
+
+def get_usage_stats(drug_code: str, days: int = 30) -> Dict:
+ """약품 사용량 통계 조회 (AI 분석용)"""
+ conn = get_connection()
+ cursor = conn.cursor()
+
+ from datetime import datetime, timedelta
+ end_date = datetime.now().strftime('%Y-%m-%d')
+ start_date = (datetime.now() - timedelta(days=days)).strftime('%Y-%m-%d')
+
+ cursor.execute('''
+ SELECT
+ COUNT(*) as days_with_data,
+ SUM(total_qty) as total_usage,
+ AVG(total_qty) as avg_daily,
+ MAX(total_qty) as max_daily,
+ MIN(total_qty) as min_daily
+ FROM daily_usage
+ WHERE drug_code = ? AND usage_date BETWEEN ? AND ?
+ ''', (drug_code, start_date, end_date))
+
+ row = cursor.fetchone()
+ conn.close()
+
+ if row and row['total_usage']:
+ return {
+ 'drug_code': drug_code,
+ 'period_days': days,
+ 'days_with_data': row['days_with_data'],
+ 'total_usage': row['total_usage'],
+ 'avg_daily': round(row['avg_daily'], 2) if row['avg_daily'] else 0,
+ 'max_daily': row['max_daily'],
+ 'min_daily': row['min_daily']
+ }
+
+ return {
+ 'drug_code': drug_code,
+ 'period_days': days,
+ 'days_with_data': 0,
+ 'total_usage': 0,
+ 'avg_daily': 0,
+ 'max_daily': 0,
+ 'min_daily': 0
+ }
+
+
+def get_order_pattern(drug_code: str) -> Optional[Dict]:
+ """약품 주문 패턴 조회"""
+ conn = get_connection()
+ cursor = conn.cursor()
+
+ # 최근 주문 이력 분석
+ cursor.execute('''
+ SELECT
+ oi.specification,
+ oi.order_qty,
+ oi.total_dose,
+ o.order_date
+ FROM order_items oi
+ JOIN orders o ON oi.order_id = o.id
+ WHERE oi.drug_code = ? AND oi.status = 'success'
+ ORDER BY o.order_date DESC
+ LIMIT 10
+ ''', (drug_code,))
+
+ orders = [dict(row) for row in cursor.fetchall()]
+
+ if not orders:
+ conn.close()
+ return None
+
+ # 가장 많이 사용된 규격
+ spec_counts = {}
+ for o in orders:
+ spec = o['specification']
+ spec_counts[spec] = spec_counts.get(spec, 0) + 1
+
+ typical_spec = max(spec_counts, key=spec_counts.get)
+
+ # 평균 주문 수량
+ typical_qty = sum(o['order_qty'] for o in orders) // len(orders)
+
+ # 주문 주기 계산
+ if len(orders) >= 2:
+ dates = [datetime.strptime(o['order_date'], '%Y-%m-%d') for o in orders]
+ intervals = [(dates[i] - dates[i+1]).days for i in range(len(dates)-1)]
+ avg_interval = sum(intervals) / len(intervals) if intervals else 0
+ else:
+ avg_interval = 0
+
+ conn.close()
+
+ return {
+ 'drug_code': drug_code,
+ 'order_count': len(orders),
+ 'typical_spec': typical_spec,
+ 'typical_qty': typical_qty,
+ 'avg_order_interval_days': round(avg_interval, 1),
+ 'recent_orders': orders[:5]
+ }
+
+
+def get_ai_training_data(limit: int = 1000) -> List[Dict]:
+ """AI 학습용 데이터 추출"""
+ conn = get_connection()
+ cursor = conn.cursor()
+
+ cursor.execute('''
+ SELECT
+ oc.*,
+ oi.status as order_status,
+ oi.result_code,
+ o.order_date,
+ o.wholesaler_id
+ FROM order_context oc
+ JOIN order_items oi ON oc.order_item_id = oi.id
+ JOIN orders o ON oi.order_id = o.id
+ ORDER BY oc.created_at DESC
+ LIMIT ?
+ ''', (limit,))
+
+ data = []
+ for row in cursor.fetchall():
+ item = dict(row)
+ # JSON 필드 파싱
+ if item.get('available_specs'):
+ item['available_specs'] = json.loads(item['available_specs'])
+ if item.get('spec_stocks'):
+ item['spec_stocks'] = json.loads(item['spec_stocks'])
+ data.append(item)
+
+ conn.close()
+ return data
+
+
+def save_ai_pattern(drug_code: str, pattern: Dict) -> bool:
+ """AI 분석 결과 저장"""
+ conn = get_connection()
+ cursor = conn.cursor()
+
+ try:
+ today = datetime.now().strftime('%Y-%m-%d')
+
+ cursor.execute('''
+ INSERT INTO order_patterns (
+ drug_code, analysis_date, analysis_period_days,
+ avg_daily_usage, usage_stddev, peak_usage,
+ typical_order_spec, typical_order_qty, order_frequency_days,
+ recommended_spec, recommended_qty, recommended_reorder_point,
+ confidence_score, model_version
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
+ ON CONFLICT(drug_code, analysis_date) DO UPDATE SET
+ avg_daily_usage = excluded.avg_daily_usage,
+ recommended_spec = excluded.recommended_spec,
+ recommended_qty = excluded.recommended_qty,
+ confidence_score = excluded.confidence_score
+ ''', (
+ drug_code,
+ today,
+ pattern.get('period_days', 30),
+ pattern.get('avg_daily_usage'),
+ pattern.get('usage_stddev'),
+ pattern.get('peak_usage'),
+ pattern.get('typical_order_spec'),
+ pattern.get('typical_order_qty'),
+ pattern.get('order_frequency_days'),
+ pattern.get('recommended_spec'),
+ pattern.get('recommended_qty'),
+ pattern.get('recommended_reorder_point'),
+ pattern.get('confidence_score'),
+ pattern.get('model_version', 'v1')
+ ))
+
+ conn.commit()
+ return True
+
+ except Exception as e:
+ conn.rollback()
+ return False
+ finally:
+ conn.close()
+
+
+# 초기화 실행
+init_db()
diff --git a/backend/sooin_api.py b/backend/sooin_api.py
new file mode 100644
index 0000000..9841736
--- /dev/null
+++ b/backend/sooin_api.py
@@ -0,0 +1,360 @@
+# -*- coding: utf-8 -*-
+"""
+수인약품 도매상 API - Flask Blueprint
+
+핵심 로직은 wholesale 패키지에서 가져옴
+이 파일은 Flask 웹 API 연동만 담당
+"""
+
+import time
+import logging
+
+from flask import Blueprint, jsonify, request as flask_request
+
+# wholesale 패키지 경로 설정
+import wholesale_path
+
+# wholesale 패키지에서 핵심 클래스 가져오기
+from wholesale import SooinSession
+
+logger = logging.getLogger(__name__)
+
+# Blueprint 생성
+sooin_bp = Blueprint('sooin', __name__, url_prefix='/api/sooin')
+
+
+# ========== 세션 관리 ==========
+
+_sooin_session = None
+
+def get_sooin_session():
+ global _sooin_session
+ if _sooin_session is None:
+ _sooin_session = SooinSession()
+ return _sooin_session
+
+
+def search_sooin_stock(keyword: str, search_type: str = 'kd_code'):
+ """수인약품 재고 검색 (동기, 빠름)"""
+ try:
+ session = get_sooin_session()
+ result = session.search_products(keyword)
+
+ if result.get('success'):
+ return {
+ 'success': True,
+ 'keyword': keyword,
+ 'search_type': search_type,
+ 'count': result['total'],
+ 'items': result['items']
+ }
+ else:
+ return result
+
+ except Exception as e:
+ logger.error(f"수인약품 검색 오류: {e}")
+ return {'success': False, 'error': 'SEARCH_ERROR', 'message': str(e)}
+
+
+# ========== Flask API Routes ==========
+
+@sooin_bp.route('/stock', methods=['GET'])
+def api_sooin_stock():
+ """
+ 수인약품 재고 조회 API
+
+ GET /api/sooin/stock?kd_code=073100220
+ GET /api/sooin/stock?keyword=코자정&type=name
+ """
+ kd_code = flask_request.args.get('kd_code', '').strip()
+ keyword = flask_request.args.get('keyword', '').strip()
+ search_type = flask_request.args.get('type', 'kd_code').strip()
+
+ search_term = kd_code or keyword
+ if kd_code:
+ search_type = 'kd_code'
+
+ if not search_term:
+ return jsonify({
+ 'success': False,
+ 'error': 'MISSING_PARAM',
+ 'message': 'kd_code 또는 keyword 파라미터가 필요합니다'
+ }), 400
+
+ try:
+ result = search_sooin_stock(search_term, search_type)
+ return jsonify(result)
+ except Exception as e:
+ logger.error(f"수인약품 API 오류: {e}")
+ return jsonify({
+ 'success': False,
+ 'error': 'API_ERROR',
+ 'message': str(e)
+ }), 500
+
+
+@sooin_bp.route('/session-status', methods=['GET'])
+def api_session_status():
+ """세션 상태 확인"""
+ session = get_sooin_session()
+ return jsonify({
+ 'logged_in': session._logged_in,
+ 'last_login': session._last_login,
+ 'session_age_sec': int(time.time() - session._last_login) if session._last_login else None
+ })
+
+
+@sooin_bp.route('/cart', methods=['GET'])
+def api_sooin_cart():
+ """장바구니 조회 API"""
+ try:
+ session = get_sooin_session()
+ result = session.get_cart()
+ return jsonify(result)
+ except Exception as e:
+ return jsonify({'success': False, 'error': str(e), 'items': []}), 500
+
+
+@sooin_bp.route('/cart/clear', methods=['POST'])
+def api_sooin_cart_clear():
+ """장바구니 비우기 API"""
+ try:
+ session = get_sooin_session()
+ result = session.clear_cart()
+ return jsonify(result)
+ except Exception as e:
+ return jsonify({'success': False, 'error': str(e)}), 500
+
+
+@sooin_bp.route('/cart/cancel', methods=['POST'])
+def api_sooin_cart_cancel():
+ """
+ 장바구니 항목 취소 API
+
+ POST /api/sooin/cart/cancel
+ { "row_index": 0 }
+ 또는
+ { "internal_code": "32495" }
+ """
+ data = flask_request.get_json() or {}
+ row_index = data.get('row_index')
+ internal_code = data.get('internal_code')
+
+ if row_index is None and not internal_code:
+ return jsonify({
+ 'success': False,
+ 'error': 'MISSING_PARAM',
+ 'message': 'row_index 또는 internal_code가 필요합니다'
+ }), 400
+
+ try:
+ session = get_sooin_session()
+ result = session.cancel_item(row_index=row_index, product_code=internal_code)
+ return jsonify(result)
+ except Exception as e:
+ return jsonify({'success': False, 'error': str(e)}), 500
+
+
+@sooin_bp.route('/cart/restore', methods=['POST'])
+def api_sooin_cart_restore():
+ """
+ 취소된 항목 복원 API
+
+ POST /api/sooin/cart/restore
+ { "row_index": 0 }
+ """
+ data = flask_request.get_json() or {}
+ row_index = data.get('row_index')
+ internal_code = data.get('internal_code')
+
+ try:
+ session = get_sooin_session()
+ result = session.restore_item(row_index=row_index, product_code=internal_code)
+ return jsonify(result)
+ except Exception as e:
+ return jsonify({'success': False, 'error': str(e)}), 500
+
+
+@sooin_bp.route('/order', methods=['POST'])
+def api_sooin_order():
+ """
+ 수인약품 주문 API (장바구니 추가)
+
+ POST /api/sooin/order
+ {
+ "kd_code": "073100220",
+ "quantity": 1,
+ "specification": "30T",
+ "check_stock": true
+ }
+ """
+ data = flask_request.get_json()
+
+ if not data:
+ return jsonify({'success': False, 'error': 'NO_DATA'}), 400
+
+ kd_code = data.get('kd_code', '').strip()
+ quantity = data.get('quantity', 1)
+ specification = data.get('specification')
+ check_stock = data.get('check_stock', True)
+
+ if not kd_code:
+ return jsonify({
+ 'success': False,
+ 'error': 'MISSING_PARAM',
+ 'message': 'kd_code가 필요합니다'
+ }), 400
+
+ try:
+ session = get_sooin_session()
+ result = session.quick_order(
+ kd_code=kd_code,
+ quantity=quantity,
+ spec=specification,
+ check_stock=check_stock
+ )
+ return jsonify(result)
+ except Exception as e:
+ logger.error(f"수인약품 주문 오류: {e}")
+ return jsonify({
+ 'success': False,
+ 'error': 'ORDER_ERROR',
+ 'message': str(e)
+ }), 500
+
+
+@sooin_bp.route('/confirm', methods=['POST'])
+def api_sooin_confirm():
+ """주문 확정 API"""
+ data = flask_request.get_json() or {}
+ memo = data.get('memo', '')
+
+ try:
+ session = get_sooin_session()
+ result = session.submit_order(memo)
+ return jsonify(result)
+ except Exception as e:
+ return jsonify({'success': False, 'error': str(e)}), 500
+
+
+@sooin_bp.route('/full-order', methods=['POST'])
+def api_sooin_full_order():
+ """
+ 전체 주문 API (검색 → 장바구니 → 확정)
+
+ POST /api/sooin/full-order
+ {
+ "kd_code": "073100220",
+ "quantity": 1,
+ "specification": "30T",
+ "auto_confirm": true,
+ "memo": "자동주문"
+ }
+ """
+ data = flask_request.get_json()
+
+ if not data or not data.get('kd_code'):
+ return jsonify({'success': False, 'error': 'kd_code required'}), 400
+
+ try:
+ session = get_sooin_session()
+
+ # 장바구니에 담기
+ cart_result = session.quick_order(
+ kd_code=data['kd_code'],
+ quantity=data.get('quantity', 1),
+ spec=data.get('specification'),
+ check_stock=data.get('check_stock', True)
+ )
+
+ if not cart_result.get('success'):
+ return jsonify(cart_result)
+
+ if not data.get('auto_confirm', True):
+ return jsonify(cart_result)
+
+ # 주문 확정
+ confirm_result = session.submit_order(data.get('memo', ''))
+
+ if confirm_result.get('success'):
+ return jsonify({
+ 'success': True,
+ 'message': f"{cart_result['product']['name']} {cart_result['quantity']}개 주문 완료",
+ 'product': cart_result['product'],
+ 'quantity': cart_result['quantity'],
+ 'confirmed': True
+ })
+ else:
+ return jsonify({
+ 'success': False,
+ 'error': confirm_result.get('error', 'CONFIRM_FAILED'),
+ 'message': f"장바구니 담기 성공, 주문 확정 실패",
+ 'product': cart_result['product'],
+ 'cart_added': True
+ })
+ except Exception as e:
+ return jsonify({'success': False, 'error': str(e)}), 500
+
+
+@sooin_bp.route('/order-batch', methods=['POST'])
+def api_sooin_order_batch():
+ """수인약품 일괄 주문 API"""
+ data = flask_request.get_json()
+
+ if not data or not data.get('items'):
+ return jsonify({'success': False, 'error': 'NO_ITEMS'}), 400
+
+ items = data.get('items', [])
+ check_stock = data.get('check_stock', True)
+
+ session = get_sooin_session()
+ results = []
+ success_count = 0
+ failed_count = 0
+
+ for item in items:
+ kd_code = item.get('kd_code', '').strip()
+ quantity = item.get('quantity', 1)
+ specification = item.get('specification')
+
+ if not kd_code:
+ results.append({
+ 'kd_code': kd_code,
+ 'success': False,
+ 'error': 'MISSING_KD_CODE'
+ })
+ failed_count += 1
+ continue
+
+ try:
+ result = session.quick_order(
+ kd_code=kd_code,
+ quantity=quantity,
+ spec=specification,
+ check_stock=check_stock
+ )
+ result['kd_code'] = kd_code
+ result['requested_qty'] = quantity
+ results.append(result)
+
+ if result.get('success'):
+ success_count += 1
+ else:
+ failed_count += 1
+
+ except Exception as e:
+ results.append({
+ 'kd_code': kd_code,
+ 'success': False,
+ 'error': 'EXCEPTION',
+ 'message': str(e)
+ })
+ failed_count += 1
+
+ return jsonify({
+ 'success': True,
+ 'total': len(items),
+ 'success_count': success_count,
+ 'failed_count': failed_count,
+ 'results': results
+ })
diff --git a/backend/static/docs/AI_ERP.html b/backend/static/docs/AI_ERP.html
new file mode 100644
index 0000000..9144cc6
--- /dev/null
+++ b/backend/static/docs/AI_ERP.html
@@ -0,0 +1,1072 @@
+
+
+
+
+ 스마트헬스케어 사업제안서
+
+
+
+AI ERP 자동 주문 시스템 기획서
+
+버전: 1.0
+작성일: 2026-03-06
+목표: 약국 재고 관리 및 주문을 AI가 학습하여 완전 자동화
+
+
+📋 Executive Summary
+비전
+"약사님이 주문에 신경 쓰지 않아도 되는 약국"
+AI가 사용량, 재고, 도매상 상황, 과거 주문 패턴을 학습하여:
+- 언제 주문할지
+- 어느 도매상에 주문할지
+- 어떤 규격으로 주문할지
+- 얼마나 주문할지
+모든 것을 자동으로 결정하고 실행합니다.
+핵심 가치
+
+
+
+| AS-IS |
+TO-BE |
+
+
+
+
+| 매일 재고 확인 |
+AI가 자동 모니터링 |
+
+
+| 수동으로 도매상 선택 |
+AI가 최적 도매상 선택 |
+
+
+| 경험에 의존한 주문량 |
+데이터 기반 최적 주문량 |
+
+
+| 주문 누락/지연 발생 |
+선제적 자동 주문 |
+
+
+
+
+🎯 시스템 목표
+1차 목표 (자동화)
+
+- [ ] 재고 부족 품목 자동 감지
+- [ ] 도매상 자동 선택 및 주문
+- [ ] 주문 결과 자동 피드백
+
+2차 목표 (최적화)
+
+- [ ] 비용 최소화 (가격, 배송비)
+- [ ] 재고 최적화 (과잉/부족 방지)
+- [ ] 주문 타이밍 최적화
+
+3차 목표 (예측)
+
+- [ ] 수요 예측 (계절, 요일, 이벤트)
+- [ ] 공급 리스크 예측 (품절, 단종)
+- [ ] 가격 변동 예측
+
+
+🧠 AI 학습 요소
+1. 주문 패턴 학습
+1.1 규격 선택 패턴 (Spec Selection)
+학습 데이터:
+- 약품별 과거 주문 규격 (30T, 100T, 300T, 500T)
+- 각 규격 선택 시점의 재고/사용량
+- 선택 결과 (남은 재고, 다음 주문까지 기간)
+
+학습 목표:
+- 사용량 대비 최적 규격 예측
+- 낭비 최소화 (유통기한 고려)
+- 단가 최적화 (대용량 할인 vs 소량 회전)
+
+예시 시나리오:
+| 사용량/월 | 학습된 최적 규격 | 이유 |
+|-----------|-----------------|------|
+| 50개 | 30T x 2 | 소량, 빠른 회전 |
+| 200개 | 100T x 2 | 중간, 적정 재고 |
+| 800개 | 300T x 3 | 대량, 단가 절감 |
+1.2 재고 전략 학습 (Inventory Strategy)
+학습 데이터:
+- 주문 시점의 재고 수준
+- 재고 소진까지 남은 일수
+- 주문 후 입고까지 리드타임
+- 품절 발생 이력
+
+학습 목표:
+- 약사님의 재고 선호도 파악
+ - 타이트형: 최소 재고 유지 (현금 흐름 중시)
+ - 여유형: 안전 재고 확보 (품절 방지 중시)
+
+재고 전략 프로파일:
+class InventoryStrategy:
+ TIGHT = {
+ 'safety_days': 2, # 안전 재고 2일치
+ 'reorder_point': 0.8, # 80% 소진 시 주문
+ 'order_coverage': 7 # 7일치 주문
+ }
+
+ MODERATE = {
+ 'safety_days': 5,
+ 'reorder_point': 0.6,
+ 'order_coverage': 14
+ }
+
+ CONSERVATIVE = {
+ 'safety_days': 10,
+ 'reorder_point': 0.5,
+ 'order_coverage': 30
+ }
+
+1.3 주문량 전략 학습 (Order Quantity)
+학습 데이터:
+- 사용량 (일별, 주별, 월별)
+- 주문량
+- 주문 후 소진까지 기간
+- 사용량 변동성 (표준편차)
+
+학습 패턴:
+1. 정확 매칭형: 사용량 = 주문량
+2. 안전 마진형: 사용량 + α
+3. 라운드업형: 규격 단위로 올림
+4. 할인 최적형: MOQ(최소주문량) 충족
+
+1.4 도매상 선택 학습 (Wholesaler Selection)
+학습 데이터:
+- 도매상별 주문 빈도
+- 도매상별 가격
+- 도매상별 재고 상황
+- 도매상별 배송 속도
+- 분할 주문 패턴
+
+학습 목표:
+- 기본 도매상 선호도
+- 상황별 대체 도매상
+- 분할 주문 조건
+
+도매상 선택 로직:
+def select_wholesaler(product, quantity, urgency):
+ """
+ AI가 학습한 도매상 선택 로직
+
+ 고려 요소:
+ 1. 재고 (있는 곳 우선)
+ 2. 가격 (저렴한 곳)
+ 3. 선호도 (과거 패턴)
+ 4. 긴급도 (배송 속도)
+ """
+ candidates = []
+
+ for ws in wholesalers:
+ score = 0
+
+ # 재고 체크
+ if ws.has_stock(product, quantity):
+ score += 100
+
+ # 가격 (낮을수록 높은 점수)
+ score += (1 - ws.price_ratio) * 50
+
+ # 학습된 선호도
+ score += ai_model.preference_score(ws, product) * 30
+
+ # 긴급도 반영
+ if urgency == 'high':
+ score += ws.delivery_speed * 20
+
+ candidates.append((ws, score))
+
+ return max(candidates, key=lambda x: x[1])
+
+
+📊 데이터 모델
+주문 컨텍스트 (AI 학습용)
+CREATE TABLE order_context (
+ id INTEGER PRIMARY KEY,
+ order_item_id INTEGER,
+
+ -- 약품 정보
+ drug_code TEXT,
+ product_name TEXT,
+
+ -- 주문 시점 상황
+ stock_at_order INTEGER, -- 주문 시점 재고
+ usage_1d INTEGER, -- 최근 1일 사용량
+ usage_7d INTEGER, -- 최근 7일 사용량
+ usage_30d INTEGER, -- 최근 30일 사용량
+ avg_daily_usage REAL, -- 일평균 사용량
+ usage_stddev REAL, -- 사용량 변동성
+
+ -- 주문 결정
+ ordered_spec TEXT, -- 선택한 규격 (30T, 300T)
+ ordered_qty INTEGER, -- 주문 수량
+ ordered_dose INTEGER, -- 총 정제수
+ wholesaler_id TEXT, -- 선택한 도매상
+
+ -- 선택지 정보
+ available_specs JSON, -- 가능했던 규격들
+ available_wholesalers JSON, -- 가능했던 도매상들
+ spec_stocks JSON, -- 규격별 재고
+ wholesaler_prices JSON, -- 도매상별 가격
+
+ -- 선택 이유 (AI 분석용)
+ selection_reason TEXT, -- 'price', 'stock', 'preference', 'urgency'
+
+ -- 예측 vs 실제
+ predicted_days_coverage REAL, -- 예상 커버 일수
+ actual_days_to_reorder INT, -- 실제 재주문까지 일수
+
+ -- 결과 평가
+ was_optimal BOOLEAN, -- 최적 선택이었나
+ waste_amount INTEGER, -- 낭비량 (폐기, 유통기한)
+ stockout_occurred BOOLEAN, -- 품절 발생했나
+
+ created_at TIMESTAMP
+);
+
+사용량 시계열
+CREATE TABLE daily_usage (
+ id INTEGER PRIMARY KEY,
+ drug_code TEXT,
+ usage_date DATE,
+
+ -- 출처별 사용량
+ rx_qty INTEGER, -- 처방전 사용량
+ pos_qty INTEGER, -- POS 판매량
+ return_qty INTEGER, -- 반품량
+
+ -- 집계
+ net_usage INTEGER, -- 순 사용량
+
+ -- 재고 스냅샷
+ stock_start INTEGER,
+ stock_end INTEGER,
+
+ -- 특이사항
+ is_holiday BOOLEAN,
+ is_event BOOLEAN, -- 프로모션 등
+ weather TEXT, -- 날씨 (선택)
+
+ UNIQUE(drug_code, usage_date)
+);
+
+AI 분석 결과
+CREATE TABLE ai_recommendations (
+ id INTEGER PRIMARY KEY,
+ drug_code TEXT,
+ analysis_date DATE,
+
+ -- 현재 상황
+ current_stock INTEGER,
+ avg_daily_usage REAL,
+ days_of_stock REAL,
+
+ -- AI 추천
+ should_order BOOLEAN,
+ recommended_qty INTEGER,
+ recommended_spec TEXT,
+ recommended_wholesaler TEXT,
+ urgency_level TEXT, -- 'low', 'medium', 'high', 'critical'
+
+ -- 추천 근거
+ reasoning JSON,
+ confidence_score REAL,
+
+ -- 실행 상태
+ auto_executed BOOLEAN,
+ executed_at TIMESTAMP,
+ execution_result TEXT,
+
+ created_at TIMESTAMP
+);
+
+
+🔄 시스템 아키텍처
+전체 흐름
+┌─────────────────────────────────────────────────────────────────┐
+│ AI ERP 자동 주문 시스템 │
+└─────────────────────────────────────────────────────────────────┘
+ │
+ ┌───────────────────────┼───────────────────────┐
+ ▼ ▼ ▼
+┌───────────────┐ ┌───────────────┐ ┌───────────────┐
+│ 데이터 수집 │ │ AI 분석 │ │ 자동 실행 │
+│ │ │ │ │ │
+│ • POS 판매 │─────▶│ • 사용량 예측 │─────▶│ • 도매상 API │
+│ • 처방전 조제 │ │ • 재고 분석 │ │ • 주문 실행 │
+│ • 현재 재고 │ │ • 주문 추천 │ │ • 결과 피드백 │
+│ • 도매상 재고 │ │ • 패턴 학습 │ │ │
+└───────────────┘ └───────────────┘ └───────────────┘
+ │ │ │
+ └───────────────────────┼───────────────────────┘
+ ▼
+ ┌───────────────────┐
+ │ 학습 루프 │
+ │ │
+ │ 주문 결과 평가 │
+ │ → 모델 업데이트 │
+ │ → 전략 조정 │
+ └───────────────────┘
+
+컴포넌트 상세
+┌──────────────────────────────────────────────────────────────────┐
+│ 데이터 레이어 │
+├──────────────────────────────────────────────────────────────────┤
+│ │
+│ ┌────────────┐ ┌────────────┐ ┌────────────┐ ┌────────────┐ │
+│ │ PIT3000 │ │ SQLite │ │ 지오영 │ │ 수인 │ │
+│ │ (MSSQL) │ │ Orders DB │ │ API │ │ API │ │
+│ └─────┬──────┘ └─────┬──────┘ └─────┬──────┘ └─────┬──────┘ │
+│ │ │ │ │ │
+│ └───────────────┴───────────────┴───────────────┘ │
+│ │ │
+└────────────────────────────────┼─────────────────────────────────┘
+ ▼
+┌──────────────────────────────────────────────────────────────────┐
+│ 서비스 레이어 │
+├──────────────────────────────────────────────────────────────────┤
+│ │
+│ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ │
+│ │ InventorySync │ │ UsageAnalyzer │ │ OrderExecutor │ │
+│ │ │ │ │ │ │ │
+│ │ • 재고 동기화 │ │ • 사용량 집계 │ │ • 주문 실행 │ │
+│ │ • 실시간 추적 │ │ • 트렌드 분석 │ │ • 결과 처리 │ │
+│ └─────────────────┘ └─────────────────┘ └─────────────────┘ │
+│ │
+│ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ │
+│ │ AIPredictor │ │ AIOptimizer │ │ AILearner │ │
+│ │ │ │ │ │ │ │
+│ │ • 수요 예측 │ │ • 규격 최적화 │ │ • 패턴 학습 │ │
+│ │ • 재고 예측 │ │ • 도매상 선택 │ │ • 모델 업데이트 │ │
+│ └─────────────────┘ └─────────────────┘ └─────────────────┘ │
+│ │
+└──────────────────────────────────────────────────────────────────┘
+ │
+ ▼
+┌──────────────────────────────────────────────────────────────────┐
+│ 인터페이스 레이어 │
+├──────────────────────────────────────────────────────────────────┤
+│ │
+│ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ │
+│ │ 웹 대시보드 │ │ 알림 시스템 │ │ 관리자 앱 │ │
+│ │ │ │ │ │ │ │
+│ │ • 재고 현황 │ │ • 주문 알림 │ │ • 수동 개입 │ │
+│ │ • 주문 이력 │ │ • 이상 감지 │ │ • 설정 조정 │ │
+│ │ • AI 추천 │ │ • 승인 요청 │ │ │ │
+│ └─────────────────┘ └─────────────────┘ └─────────────────┘ │
+│ │
+└──────────────────────────────────────────────────────────────────┘
+
+
+🤖 AI 모델 설계
+1. 수요 예측 모델
+class DemandPredictor:
+ """
+ 약품별 일간 수요 예측
+
+ 입력:
+ - 과거 30일 사용량
+ - 요일 (월~일)
+ - 계절/월
+ - 특수일 (공휴일, 이벤트)
+
+ 출력:
+ - 향후 7일 예측 사용량
+ - 예측 신뢰구간
+ """
+
+ def predict(self, drug_code: str, days: int = 7) -> dict:
+ features = self._extract_features(drug_code)
+
+ prediction = {
+ 'daily_forecast': [], # 일별 예측
+ 'total_forecast': 0, # 총 예측량
+ 'confidence': 0.0, # 신뢰도
+ 'lower_bound': 0, # 하한
+ 'upper_bound': 0 # 상한
+ }
+
+ return prediction
+
+2. 재고 최적화 모델
+class InventoryOptimizer:
+ """
+ 최적 재고 수준 및 재주문점 계산
+
+ 입력:
+ - 예측 수요
+ - 리드타임 (주문~입고)
+ - 서비스 수준 (품절 허용률)
+ - 재고 유지 비용
+
+ 출력:
+ - 재주문점 (Reorder Point)
+ - 안전 재고 (Safety Stock)
+ - 최적 주문량 (EOQ)
+ """
+
+ def calculate_reorder_point(self, drug_code: str) -> dict:
+ demand = self.demand_predictor.predict(drug_code)
+ lead_time = self._get_lead_time(drug_code)
+
+ # 재주문점 = 리드타임 수요 + 안전재고
+ lead_time_demand = demand['daily_avg'] * lead_time
+ safety_stock = self._calculate_safety_stock(drug_code)
+
+ return {
+ 'reorder_point': lead_time_demand + safety_stock,
+ 'safety_stock': safety_stock,
+ 'lead_time_days': lead_time
+ }
+
+3. 규격 선택 모델
+class SpecSelector:
+ """
+ 최적 규격 선택
+
+ 고려 요소:
+ - 예상 사용량
+ - 규격별 단가
+ - 유통기한
+ - 과거 선택 패턴
+ """
+
+ def select_spec(self, drug_code: str, needed_qty: int,
+ available_specs: list) -> dict:
+
+ candidates = []
+
+ for spec in available_specs:
+ spec_qty = self._parse_spec_qty(spec) # "300T" → 300
+
+ # 필요 단위 수 계산
+ units_needed = math.ceil(needed_qty / spec_qty)
+ total_qty = units_needed * spec_qty
+ waste = total_qty - needed_qty
+
+ # 비용 계산
+ unit_price = self._get_unit_price(drug_code, spec)
+ total_cost = units_needed * unit_price
+ cost_per_dose = total_cost / total_qty
+
+ # 학습된 선호도
+ preference = self.ai_model.spec_preference(drug_code, spec)
+
+ # 점수 계산
+ score = self._calculate_score(
+ waste_ratio=waste / total_qty,
+ cost_efficiency=1 / cost_per_dose,
+ preference=preference
+ )
+
+ candidates.append({
+ 'spec': spec,
+ 'units': units_needed,
+ 'total_qty': total_qty,
+ 'waste': waste,
+ 'cost': total_cost,
+ 'score': score
+ })
+
+ return max(candidates, key=lambda x: x['score'])
+
+4. 도매상 선택 모델
+class WholesalerSelector:
+ """
+ 최적 도매상 선택 (다중 도매상 지원)
+
+ 고려 요소:
+ - 재고 유무
+ - 가격
+ - 배송 속도
+ - 과거 선호도
+ - 최소 주문 금액
+ """
+
+ def select_wholesaler(self, drug_code: str, spec: str,
+ quantity: int, urgency: str) -> dict:
+
+ wholesalers = ['geoyoung', 'sooin', 'baekje']
+ candidates = []
+
+ for ws in wholesalers:
+ # 재고 확인
+ stock = self._check_stock(ws, drug_code, spec)
+ if stock < quantity:
+ continue
+
+ # 가격 조회
+ price = self._get_price(ws, drug_code, spec)
+
+ # 배송 속도
+ delivery_hours = self._get_delivery_time(ws)
+
+ # AI 학습 선호도
+ preference = self.ai_model.wholesaler_preference(
+ drug_code, ws
+ )
+
+ # 종합 점수
+ score = self._calculate_score(
+ has_stock=True,
+ price=price,
+ delivery=delivery_hours,
+ preference=preference,
+ urgency=urgency
+ )
+
+ candidates.append({
+ 'wholesaler': ws,
+ 'stock': stock,
+ 'price': price,
+ 'delivery_hours': delivery_hours,
+ 'score': score
+ })
+
+ if not candidates:
+ return self._handle_no_stock(drug_code, spec, quantity)
+
+ return max(candidates, key=lambda x: x['score'])
+
+ def _handle_no_stock(self, drug_code, spec, quantity):
+ """재고 없을 때: 분할 주문 또는 대체품"""
+ # 1. 다른 규격으로 분할
+ # 2. 다중 도매상 분할
+ # 3. 대체 약품 추천
+ pass
+
+5. 주문 결정 엔진
+class OrderDecisionEngine:
+ """
+ 종합 주문 결정
+
+ 매일 실행:
+ 1. 모든 약품 재고 스캔
+ 2. 재주문점 도달 품목 식별
+ 3. 각 품목별 최적 주문 계획 수립
+ 4. 자동 실행 또는 승인 요청
+ """
+
+ def daily_analysis(self) -> list:
+ recommendations = []
+
+ for drug in self._get_all_drugs():
+ current_stock = self._get_stock(drug.code)
+ reorder_point = self.inventory_optimizer.calculate_reorder_point(drug.code)
+
+ if current_stock <= reorder_point['reorder_point']:
+ # 주문 필요
+ order_plan = self._create_order_plan(drug)
+ recommendations.append(order_plan)
+
+ return recommendations
+
+ def _create_order_plan(self, drug) -> dict:
+ # 1. 필요 수량 계산
+ needed_qty = self._calculate_needed_qty(drug)
+
+ # 2. 최적 규격 선택
+ spec = self.spec_selector.select_spec(
+ drug.code, needed_qty, drug.available_specs
+ )
+
+ # 3. 최적 도매상 선택
+ wholesaler = self.wholesaler_selector.select_wholesaler(
+ drug.code, spec['spec'], spec['units'],
+ urgency=self._determine_urgency(drug)
+ )
+
+ return {
+ 'drug_code': drug.code,
+ 'drug_name': drug.name,
+ 'current_stock': self._get_stock(drug.code),
+ 'needed_qty': needed_qty,
+ 'recommended_spec': spec['spec'],
+ 'recommended_units': spec['units'],
+ 'recommended_wholesaler': wholesaler['wholesaler'],
+ 'estimated_cost': wholesaler['price'] * spec['units'],
+ 'urgency': self._determine_urgency(drug),
+ 'confidence': self._calculate_confidence(),
+ 'auto_execute': self._should_auto_execute(drug)
+ }
+
+
+📈 학습 파이프라인
+피드백 루프
+주문 실행 → 결과 기록 → 평가 → 학습 → 모델 업데이트
+ │ │
+ └────────────────────────────────────────┘
+
+평가 지표
+class OrderEvaluator:
+ """주문 결과 평가"""
+
+ def evaluate(self, order_id: int) -> dict:
+ order = self._get_order(order_id)
+
+ # 1. 재고 효율성
+ days_covered = self._calculate_days_covered(order)
+ expected_days = order.expected_coverage
+ coverage_accuracy = days_covered / expected_days
+
+ # 2. 비용 효율성
+ actual_cost_per_dose = order.total_cost / order.total_dose
+ market_avg_cost = self._get_market_avg_cost(order.drug_code)
+ cost_efficiency = market_avg_cost / actual_cost_per_dose
+
+ # 3. 낭비율
+ waste = self._calculate_waste(order)
+ waste_ratio = waste / order.total_dose
+
+ # 4. 품절 발생 여부
+ stockout = self._check_stockout_before_next_order(order)
+
+ return {
+ 'coverage_accuracy': coverage_accuracy,
+ 'cost_efficiency': cost_efficiency,
+ 'waste_ratio': waste_ratio,
+ 'stockout_occurred': stockout,
+ 'overall_score': self._calculate_overall_score(...)
+ }
+
+모델 업데이트
+class AILearner:
+ """주문 결과로부터 학습"""
+
+ def learn_from_order(self, order_id: int):
+ evaluation = self.evaluator.evaluate(order_id)
+ context = self._get_order_context(order_id)
+
+ # 1. 규격 선택 학습
+ self.spec_model.update(
+ drug_code=context.drug_code,
+ chosen_spec=context.ordered_spec,
+ was_optimal=evaluation['waste_ratio'] < 0.1
+ )
+
+ # 2. 재고 전략 학습
+ self.inventory_model.update(
+ drug_code=context.drug_code,
+ reorder_point=context.stock_at_order,
+ was_optimal=not evaluation['stockout_occurred']
+ )
+
+ # 3. 도매상 선호도 학습
+ self.wholesaler_model.update(
+ drug_code=context.drug_code,
+ chosen_wholesaler=context.wholesaler_id,
+ satisfaction=evaluation['cost_efficiency']
+ )
+
+
+⚙️ 자동화 레벨
+Level 0: 수동
+
+- AI 추천만 제공
+- 모든 주문은 수동 실행
+
+Level 1: 반자동
+
+- AI가 주문 계획 생성
+- 약사님 승인 후 자동 실행
+- 알림: 승인 요청
+
+Level 2: 조건부 자동
+
+- 신뢰도 높은 주문은 자동 실행
+- 신뢰도 낮은 주문만 승인 요청
+- 조건 예시:
+- 자주 주문하는 품목
+- 금액 임계값 이하
+- 긴급하지 않은 주문
+
+Level 3: 완전 자동
+
+- 모든 주문 자동 실행
+- 이상 상황만 알림
+- 약사님은 대시보드로 모니터링
+
+class AutomationLevel:
+ def should_auto_execute(self, order_plan: dict) -> bool:
+ level = self.settings.automation_level
+
+ if level == 0:
+ return False
+
+ if level == 1:
+ return False # 항상 승인 필요
+
+ if level == 2:
+ # 조건부 자동
+ conditions = [
+ order_plan['confidence'] > 0.9,
+ order_plan['estimated_cost'] < 100000,
+ order_plan['drug_code'] in self.trusted_drugs,
+ order_plan['urgency'] != 'critical'
+ ]
+ return all(conditions)
+
+ if level == 3:
+ # 완전 자동 (이상 상황만 제외)
+ return not self._is_anomaly(order_plan)
+
+
+🔔 알림 시스템
+알림 유형
+
+
+
+| 유형 |
+조건 |
+채널 |
+
+
+
+
+| 승인 요청 |
+Level 1-2에서 자동 실행 안 되는 주문 |
+카톡, 앱 푸시 |
+
+
+| 주문 완료 |
+자동 주문 실행됨 |
+앱 푸시 |
+
+
+| 재고 경고 |
+안전 재고 이하 |
+카톡 |
+
+
+| 품절 긴급 |
+재고 0, 당일 필요 |
+전화, 카톡 |
+
+
+| 이상 감지 |
+비정상 사용량, 가격 급등 |
+앱 푸시 |
+
+
+| 일간 리포트 |
+매일 오전 |
+이메일 |
+
+
+
+알림 메시지 예시
+📦 주문 승인 요청
+
+약품: 콩코르정 2.5mg
+현재고: 45개 (3일치)
+추천 주문: 300T x 2박스
+도매상: 지오영
+예상 금액: 72,000원
+
+[승인] [수정] [거절]
+
+
+📅 개발 로드맵
+Phase 1: 기반 구축 (1-2주)
+
+- [x] 지오영 API 연동
+- [x] 주문 DB 스키마 설계
+- [x] 주문 컨텍스트 로깅
+- [ ] 수인 API 연동
+- [ ] 일별 사용량 집계 자동화
+
+Phase 2: AI 기본 (2-3주)
+
+- [ ] 수요 예측 모델 (단순 이동평균)
+- [ ] 재주문점 계산
+- [ ] 규격 선택 로직 (규칙 기반)
+- [ ] 도매상 선택 로직 (규칙 기반)
+- [ ] 주문 추천 대시보드
+
+Phase 3: 학습 시스템 (2-3주)
+
+- [ ] 피드백 루프 구현
+- [ ] 주문 평가 시스템
+- [ ] 패턴 학습 (규격, 도매상)
+- [ ] 재고 전략 프로파일링
+
+Phase 4: 자동화 (1-2주)
+
+- [ ] Level 1 (승인 후 자동)
+- [ ] 알림 시스템 연동
+- [ ] Level 2 (조건부 자동)
+- [ ] 모니터링 대시보드
+
+Phase 5: 고도화 (지속)
+
+- [ ] ML 모델 적용 (XGBoost, LSTM)
+- [ ] Level 3 (완전 자동)
+- [ ] 다중 약국 지원
+- [ ] 수요 예측 정교화
+
+
+📊 성공 지표 (KPI)
+
+
+
+| 지표 |
+현재 |
+목표 |
+
+
+
+
+| 주문 소요 시간 |
+30분/일 |
+0분 (자동) |
+
+
+| 품절 발생률 |
+5% |
+<1% |
+
+
+| 재고 회전율 |
+- |
++20% |
+
+
+| 주문 비용 절감 |
+- |
+5-10% |
+
+
+| 폐기 손실 |
+- |
+-30% |
+
+
+
+
+🔐 보안 및 안전장치
+자동 주문 제한
+
+- 일일 자동 주문 금액 상한
+- 단일 품목 최대 수량
+- 신규 품목 자동 주문 제외
+- 가격 급등 시 수동 전환
+
+롤백 메커니즘
+
+- 모든 주문 취소 가능 (확정 전)
+- 자동화 레벨 즉시 변경
+- 긴급 수동 모드 전환
+
+감사 로그
+
+- 모든 AI 결정 기록
+- 자동 실행 이력
+- 승인/거절 이력
+
+
+💡 핵심 인사이트
+
+"AI는 약사님의 주문 습관을 학습합니다."
+
+
+- 약사님이 항상 지오영에 먼저 주문하면 → AI도 지오영 우선
+- 약사님이 300T보다 30T를 선호하면 → AI도 소량 주문
+- 약사님이 여유 있게 주문하면 → AI도 안전 재고 확보
+- 약사님이 가격에 민감하면 → AI도 최저가 추적
+
+AI는 대체하는 것이 아니라, 약사님의 방식을 자동화합니다.
+
+📚 참고 자료
+
+- 지오영 API 문서:
docs/GEOYOUNG_API_REVERSE_ENGINEERING.md
+- 주문 DB 스키마:
backend/order_db.py
+- 사용량 조회 페이지:
docs/RX_USAGE_GEOYOUNG_GUIDE.md
+
+
+
\ No newline at end of file
diff --git a/backend/templates/admin_rx_usage.html b/backend/templates/admin_rx_usage.html
index 9e533aa..badb590 100644
--- a/backend/templates/admin_rx_usage.html
+++ b/backend/templates/admin_rx_usage.html
@@ -1054,8 +1054,172 @@
// ──────────────── 주문 제출 ────────────────
function submitOrder() {
if (cart.length === 0) return;
+
+ // 지오영 품목만 필터
+ const geoItems = cart.filter(c => c.supplier === '지오영' || c.geoyoung_code);
+
+ if (geoItems.length === 0) {
+ // 지오영 품목 없으면 기존 방식 (클립보드)
+ submitOrderClipboard();
+ return;
+ }
+
+ // 지오영 주문 모달 열기
+ openOrderConfirmModal(geoItems);
+ }
+
+ function openOrderConfirmModal(items) {
+ const modal = document.getElementById('orderConfirmModal');
+ const tbody = document.getElementById('orderConfirmBody');
+
+ let html = '';
+ items.forEach((item, idx) => {
+ html += `
+
+ | ${escapeHtml(item.product_name)} |
+ ${item.specification || '-'} |
+ ${item.qty} |
+
`;
+ });
+
+ tbody.innerHTML = html;
+ document.getElementById('orderConfirmCount').textContent = items.length;
+ modal.classList.add('show');
+ }
+
+ function closeOrderConfirmModal() {
+ document.getElementById('orderConfirmModal').classList.remove('show');
+ }
+
+ async function executeOrder(dryRun = true) {
+ const geoItems = cart.filter(c => c.supplier === '지오영' || c.geoyoung_code);
+
+ if (geoItems.length === 0) {
+ showToast('지오영 품목이 없습니다', 'error');
+ return;
+ }
+
+ // 버튼 비활성화
+ const btnTest = document.getElementById('btnOrderTest');
+ const btnReal = document.getElementById('btnOrderReal');
+ btnTest.disabled = true;
+ btnReal.disabled = true;
+ btnTest.textContent = dryRun ? '처리 중...' : '🧪 테스트';
+ btnReal.textContent = !dryRun ? '처리 중...' : '🚀 실제 주문';
+
+ try {
+ const payload = {
+ wholesaler_id: 'geoyoung',
+ items: geoItems.map(item => ({
+ drug_code: item.drug_code,
+ kd_code: item.geoyoung_code || item.drug_code,
+ product_name: item.product_name,
+ manufacturer: item.supplier,
+ specification: item.specification || '',
+ order_qty: item.qty,
+ usage_qty: item.usage_qty || 0,
+ current_stock: item.current_stock || 0
+ })),
+ reference_period: `${document.getElementById('startDate').value}~${document.getElementById('endDate').value}`,
+ dry_run: dryRun
+ };
+
+ // 실제 주문은 시간이 오래 걸림 (Playwright 사용)
+ const timeoutMs = dryRun ? 60000 : 180000; // 테스트 1분, 실제 3분
+
+ const controller = new AbortController();
+ const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
+
+ const response = await fetch('/api/order/quick-submit', {
+ method: 'POST',
+ headers: {'Content-Type': 'application/json'},
+ body: JSON.stringify(payload),
+ signal: controller.signal
+ });
+
+ clearTimeout(timeoutId);
+
+ const result = await response.json();
+
+ closeOrderConfirmModal();
+
+ if (result.success) {
+ showOrderResultModal(result);
+ } else {
+ showToast(`❌ 주문 실패: ${result.error}`, 'error');
+ }
+
+ } catch (err) {
+ showToast(`❌ 오류: ${err.message}`, 'error');
+ } finally {
+ btnTest.disabled = false;
+ btnReal.disabled = false;
+ btnTest.textContent = '🧪 테스트';
+ btnReal.textContent = '🚀 실제 주문';
+ }
+ }
+
+ function showOrderResultModal(result) {
+ const modal = document.getElementById('orderResultModal');
+ const content = document.getElementById('orderResultContent');
+
+ const isDryRun = result.dry_run;
+ const statusEmoji = result.failed_count === 0 ? '✅' : result.success_count === 0 ? '❌' : '⚠️';
+
+ let html = `
+
+
+
+ 주문번호
+ ${result.order_no}
+
+
+ 성공
+ ${result.success_count}개
+
+
+ 실패
+ ${result.failed_count}개
+
+
+
+ | 품목 | 수량 | 결과 |
+ `;
+
+ (result.results || []).forEach(item => {
+ const isSuccess = item.status === 'success';
+ html += `
+
+ | ${escapeHtml(item.product_name)} |
+ ${item.order_qty} |
+
+ ${isSuccess ? '✓' : '✗'} ${item.result_code}
+ ${item.result_message ? ` ${escapeHtml(item.result_message)}` : ''}
+ |
+
`;
+ });
+
+ html += '
';
+
+ if (isDryRun && result.success_count > 0) {
+ html += `💡 테스트 모드입니다. 실제 주문은 "실제 주문" 버튼을 누르세요.
`;
+ }
+
+ content.innerHTML = html;
+ modal.classList.add('show');
+ }
+
+ function closeOrderResultModal() {
+ document.getElementById('orderResultModal').classList.remove('show');
+ }
+
+ // 기존 클립보드 방식 (지오영 아닌 품목용)
+ function submitOrderClipboard() {
+ if (cart.length === 0) return;
- // 제조사별 그룹화
const bySupplier = {};
cart.forEach(item => {
const sup = item.supplier || '미지정';
@@ -1063,7 +1227,6 @@
bySupplier[sup].push(item);
});
- // 주문서 텍스트 생성
let orderText = `💊 청춘약국 전문의약품 발주서\n`;
orderText += `━━━━━━━━━━━━━━━━━━━━━━━━\n`;
orderText += `📅 작성일: ${new Date().toLocaleDateString('ko-KR')}\n`;
@@ -1081,7 +1244,6 @@
orderText += `\n━━━━━━━━━━━━━━━━━━━━━━━━\n`;
orderText += `총 ${cart.length}개 품목\n`;
- // 클립보드 복사
navigator.clipboard.writeText(orderText).then(() => {
showToast('📋 주문서가 클립보드에 복사되었습니다!', 'success');
}).catch(() => {
@@ -1116,6 +1278,573 @@
document.getElementById('searchInput').addEventListener('keypress', e => {
if (e.key === 'Enter') loadUsageData();
});
+
+ // ──────────────── 지오영 재고 조회 ────────────────
+ let currentGeoyoungItem = null;
+
+ function openGeoyoungModal(idx) {
+ const item = usageData[idx];
+ if (!item) return;
+
+ currentGeoyoungItem = item;
+
+ // 모달 열기
+ document.getElementById('geoModalProductName').textContent = item.product_name;
+ document.getElementById('geoModalDrugCode').textContent = item.drug_code;
+ document.getElementById('geoModalUsage').textContent = item.total_dose.toLocaleString() + '개';
+ document.getElementById('geoModalStock').textContent = item.current_stock.toLocaleString() + '개';
+
+ document.getElementById('geoyoungModal').classList.add('show');
+
+ // 로딩 표시
+ document.getElementById('geoResultBody').innerHTML = `
+ `;
+
+ // API 호출 (보험코드로 먼저 시도)
+ searchGeoyoung(item.drug_code, item.product_name);
+ }
+
+ function closeGeoyoungModal() {
+ document.getElementById('geoyoungModal').classList.remove('show');
+ currentGeoyoungItem = null;
+ }
+
+ async function searchGeoyoung(kdCode, productName) {
+ const resultBody = document.getElementById('geoResultBody');
+
+ try {
+ // 1차: 보험코드(KD코드)로 검색
+ let response = await fetch(`/api/geoyoung/stock?kd_code=${encodeURIComponent(kdCode)}`);
+ let data = await response.json();
+
+ // 결과 없으면 성분명으로 재검색
+ if (data.success && data.count === 0) {
+ document.getElementById('geoResultBody').innerHTML = `
+ `;
+
+ response = await fetch(`/api/geoyoung/stock-by-name?product_name=${encodeURIComponent(productName)}`);
+ data = await response.json();
+ }
+
+ if (!data.success) {
+ resultBody.innerHTML = `
+
+
❌ ${data.message || '조회 실패'}
+
`;
+ return;
+ }
+
+ if (data.count === 0) {
+ resultBody.innerHTML = `
+ `;
+ return;
+ }
+
+ // 검색어 표시
+ if (data.extracted_ingredient) {
+ document.getElementById('geoSearchKeyword').textContent = `검색: "${data.extracted_ingredient}"`;
+ document.getElementById('geoSearchKeyword').style.display = 'block';
+ }
+
+ // 결과 렌더링
+ renderGeoyoungResults(data.items);
+
+ } catch (err) {
+ resultBody.innerHTML = `
+
+
❌ 네트워크 오류: ${err.message}
+
`;
+ }
+ }
+
+ function renderGeoyoungResults(items) {
+ const resultBody = document.getElementById('geoResultBody');
+
+ // 재고 있는 것 먼저 정렬
+ items.sort((a, b) => (b.stock > 0 ? 1 : 0) - (a.stock > 0 ? 1 : 0) || b.stock - a.stock);
+
+ let html = `
+
+
+ | 제품명 |
+ 규격 |
+ 재고 |
+ |
+
+
+ `;
+
+ items.forEach((item, idx) => {
+ const hasStock = item.stock > 0;
+ html += `
+
+ |
+
+ ${escapeHtml(item.product_name)}
+ ${item.insurance_code}
+
+ |
+ ${item.specification} |
+ ${item.stock} |
+
+ ${hasStock ? `` : ''}
+ |
+
`;
+ });
+
+ html += '
';
+
+ // 전역에 저장 (담기용)
+ window.geoyoungItems = items;
+
+ resultBody.innerHTML = html;
+ }
+
+ function addGeoyoungToCart(idx) {
+ const item = window.geoyoungItems[idx];
+ if (!item || !currentGeoyoungItem) return;
+
+ // 수량 계산 (규격에서 숫자 추출)
+ const specMatch = item.specification.match(/(\d+)/);
+ const specQty = specMatch ? parseInt(specMatch[1]) : 1;
+
+ // 필요 수량 계산
+ const needed = currentGeoyoungItem.total_dose;
+ const suggestedQty = Math.ceil(needed / specQty);
+
+ const qty = prompt(`주문 수량 (${item.specification} 기준)\n\n필요량: ${needed}개\n규격: ${specQty}개/단위\n추천: ${suggestedQty}단위 (${suggestedQty * specQty}개)`, suggestedQty);
+
+ if (!qty || isNaN(qty)) return;
+
+ // 장바구니에 추가 (지오영 정보 포함)
+ const cartItem = {
+ drug_code: currentGeoyoungItem.drug_code,
+ product_name: item.product_name,
+ supplier: '지오영',
+ qty: parseInt(qty),
+ specification: item.specification,
+ geoyoung_code: item.insurance_code
+ };
+
+ // 기존 항목 체크
+ const existing = cart.find(c => c.drug_code === currentGeoyoungItem.drug_code && c.specification === item.specification);
+ if (existing) {
+ existing.qty = parseInt(qty);
+ } else {
+ cart.push(cartItem);
+ }
+
+ updateCartUI();
+ closeGeoyoungModal();
+ showToast(`✅ ${item.product_name} (${item.specification}) ${qty}개 추가`, 'success');
+ }
+
+ // 테이블 행 더블클릭으로 지오영 모달 열기
+ document.addEventListener('dblclick', function(e) {
+ const row = e.target.closest('tr[data-idx]');
+ if (row) {
+ const idx = parseInt(row.dataset.idx);
+ openGeoyoungModal(idx);
+ }
+ });
+
+
+
+
+
+
+
+ 약품명
+ -
+
+
+ 보험코드
+ -
+
+
+ 사용량
+ -
+
+
+ 현재고
+ -
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 0개 품목을 지오영에 주문합니다.
+
+
+
+
+
+
+
+
+