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)114,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시 이후 주문은 다음근무일로 주문됩니다
+
+ +

장바구니

+
+
+
+ 주문관련 버튼 및 메모 + + +

+ +

+ + + +
+ +
+ 장바구니 + + + + + + + + + + + + + + + + + + + + +
장바구니 리스트
건별취소제품명수량금액
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
장바구니 리스트
건별취소제품명수량금액
(향)스틸녹스정 10mg(병)100T + + + + + + + + + + + + + + + 17,300 +
(오가논)코자정 50mg(PTP)30T + + + + + + + + + + + + + + + 14,220 +
+
+
+
+ + +
+ 장바구니 총 금액 +
+
+
주문품목
+
2개
+
+
+
취소품목
+
0개
+
+
+
+
주문금액
+
+ 31,520원 +
+
+ + + + + + + + + + + +
+
+
+ + + + + + + + + \ 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-ISTO-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: 수동

+ +

Level 1: 반자동

+ +

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주)

+ +

Phase 2: AI 기본 (2-3주)

+ +

Phase 3: 학습 시스템 (2-3주)

+ +

Phase 4: 자동화 (1-2주)

+ +

Phase 5: 고도화 (지속)

+ +
+

📊 성공 지표 (KPI)

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
지표현재목표
주문 소요 시간30분/일0분 (자동)
품절 발생률5%<1%
재고 회전율-+20%
주문 비용 절감-5-10%
폐기 손실--30%
+
+

🔐 보안 및 안전장치

+

자동 주문 제한

+ +

롤백 메커니즘

+ +

감사 로그

+ +
+

💡 핵심 인사이트

+
+

"AI는 약사님의 주문 습관을 학습합니다."

+
+ +

AI는 대체하는 것이 아니라, 약사님의 방식을 자동화합니다.

+
+

📚 참고 자료

+ + + \ 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 = ` +
+ ${statusEmoji} + ${isDryRun ? '[테스트]' : ''} 주문 ${result.failed_count === 0 ? '완료' : '처리됨'} +
+
+
+ 주문번호 + ${result.order_no} +
+
+ 성공 + ${result.success_count}개 +
+
+ 실패 + ${result.failed_count}개 +
+
+ + + `; + + (result.results || []).forEach(item => { + const isSuccess = item.status === 'success'; + html += ` + + + + + `; + }); + + html += '
품목수량결과
${escapeHtml(item.product_name)}${item.order_qty} + ${isSuccess ? '✓' : '✗'} ${item.result_code} + ${item.result_message ? `
${escapeHtml(item.result_message)}` : ''} +
'; + + 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 += ` + + + + + + `; + }); + + html += '
제품명규격재고
+
+ ${escapeHtml(item.product_name)} + ${item.insurance_code} +
+
${item.specification}${item.stock} + ${hasStock ? `` : ''} +
'; + + // 전역에 저장 (담기용) + 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개 품목을 지오영에 주문합니다. +

+ + + +
품목명규격수량
+
+ +
+
+ + +
+
+
+

📋 주문 결과

+ +
+
+
+ +
+
diff --git a/backend/test_bagjs.py b/backend/test_bagjs.py new file mode 100644 index 0000000..93cdf38 --- /dev/null +++ b/backend/test_bagjs.py @@ -0,0 +1,16 @@ +# -*- coding: utf-8 -*- +"""Bag.js 분석""" +from sooin_api import SooinSession +import re + +session = SooinSession() +session.login() + +resp = session.session.get('http://sooinpharm.co.kr/Common/Javascript/Bag.js?v=250228') +js = resp.text + +# del 포함된 부분 찾기 +lines = js.split('\n') +for i, line in enumerate(lines): + if 'del' in line.lower() and ('kind' in line.lower() or 'bagorder' in line.lower()): + print(f'{i}: {line.strip()[:100]}') diff --git a/backend/test_bagjs2.py b/backend/test_bagjs2.py new file mode 100644 index 0000000..afcabcb --- /dev/null +++ b/backend/test_bagjs2.py @@ -0,0 +1,18 @@ +# -*- coding: utf-8 -*- +"""Bag.js 전체에서 del 찾기""" +from sooin_api import SooinSession +import re + +session = SooinSession() +session.login() + +resp = session.session.get('http://sooinpharm.co.kr/Common/Javascript/Bag.js?v=250228') +js = resp.text + +print(f'JS 길이: {len(js)}') + +# del 포함된 줄 모두 +for i, line in enumerate(js.split('\n')): + line = line.strip() + if 'del' in line.lower(): + print(f'{line[:120]}') diff --git a/backend/test_bagjs3.py b/backend/test_bagjs3.py new file mode 100644 index 0000000..7877922 --- /dev/null +++ b/backend/test_bagjs3.py @@ -0,0 +1,16 @@ +# -*- coding: utf-8 -*- +"""Bag.js 체크박스 관련 찾기""" +from sooin_api import SooinSession +import re + +session = SooinSession() +session.login() + +resp = session.session.get('http://sooinpharm.co.kr/Common/Javascript/Bag.js?v=250228') +js = resp.text + +# chk, checkbox 관련 코드 찾기 +lines = js.split('\n') +for i, line in enumerate(lines): + if 'chk' in line.lower() or 'check' in line.lower(): + print(f'{i}: {line.strip()[:120]}') diff --git a/backend/test_bagjs4.py b/backend/test_bagjs4.py new file mode 100644 index 0000000..510ccda --- /dev/null +++ b/backend/test_bagjs4.py @@ -0,0 +1,19 @@ +# -*- coding: utf-8 -*- +"""Bag.js AJAX URL 찾기""" +from sooin_api import SooinSession +import re + +session = SooinSession() +session.login() + +resp = session.session.get('http://sooinpharm.co.kr/Common/Javascript/Bag.js?v=250228') +js = resp.text + +# AJAX 호출 찾기 ($.ajax, url:, type: 패턴) +ajax_blocks = re.findall(r'\$\.ajax\s*\(\s*\{[^}]{0,500}\}', js, re.DOTALL) +print(f'AJAX 호출 {len(ajax_blocks)}개 발견:\n') + +for i, block in enumerate(ajax_blocks[:5]): + print(f'=== AJAX {i+1} ===') + print(block[:300]) + print() diff --git a/backend/test_cancel.py b/backend/test_cancel.py new file mode 100644 index 0000000..09b5044 --- /dev/null +++ b/backend/test_cancel.py @@ -0,0 +1,46 @@ +# -*- coding: utf-8 -*- +"""항목 취소 테스트""" +from sooin_api import SooinSession +import json + +session = SooinSession() +session.login() + +print('=== 항목 취소 테스트 ===\n') + +# 1. 장바구니 비우기 +session.clear_cart() +print('1. 장바구니 비움') + +# 2. 두 개 담기 +session.order_product('073100220', 1, '30T') # 코자정 +print('2. 코자정 담음') + +session.order_product('652100640', 1) # 스틸녹스 +print('3. 스틸녹스 담음') + +# 3. 장바구니 확인 +cart = session.get_cart() +print(f'\n현재 장바구니:') +print(f' 총 항목: {cart.get("all_items", 0)}개') +print(f' 활성(주문포함): {cart.get("total_items", 0)}개') +print(f' 취소됨: {cart.get("cancelled_items", 0)}개') +for item in cart.get('items', []): + status = '❌ 취소' if item.get('checked') else '✅ 활성' + print(f' [{item.get("row_index")}] {item.get("product_name")} - {status}') + +# 4. 첫 번째 항목 취소 +print(f'\n4. 첫 번째 항목(idx=0) 취소 시도...') +result = session.cancel_item(row_index=0) +print(f' 결과: {result.get("success")} - {result.get("message", result.get("error", ""))}') + +# 5. 다시 확인 +cart = session.get_cart() +print(f'\n취소 후 장바구니:') +print(f' 활성: {cart.get("total_items", 0)}개') +print(f' 취소됨: {cart.get("cancelled_items", 0)}개') +for item in cart.get('items', []): + status = '❌ 취소' if item.get('checked') else '✅ 활성' + print(f' [{item.get("row_index")}] {item.get("product_name")} - {status}') + +print('\n=== 완료 ===') diff --git a/backend/test_cart.py b/backend/test_cart.py new file mode 100644 index 0000000..cf3f6e3 --- /dev/null +++ b/backend/test_cart.py @@ -0,0 +1,60 @@ +# -*- coding: utf-8 -*- +"""장바구니 추가 테스트 (실제 주문 X)""" +import json +import sys +sys.path.insert(0, r'c:\Users\청춘약국\source\pharmacy-pos-qr-system\backend') +from sooin_api import SooinSession + +print("=" * 60) +print("수인약품 API 장바구니 테스트") +print("=" * 60) + +session = SooinSession() + +# 1. 로그인 +print("\n1. 로그인...") +if not session.login(): + print("❌ 로그인 실패") + sys.exit(1) +print("✅ 로그인 성공!") + +# 2. 장바구니 비우기 +print("\n2. 장바구니 비우기...") +result = session.clear_cart() +print(f" 결과: {'성공' if result['success'] else '실패'}") + +# 3. 제품 검색 +print("\n3. 제품 검색 (KD코드: 073100220 - 코자정)...") +products = session.search_products('073100220', 'kd_code') +print(f" 검색 결과: {len(products)}개") +for p in products: + print(f" - {p['product_name']} ({p['specification']}) 재고: {p['stock']} 단가: {p['unit_price']:,}원") + print(f" 내부코드: {p['internal_code']}") + +# 4. 장바구니 추가 +if products: + print("\n4. 장바구니 추가 (첫 번째 제품, 1개)...") + product = products[1] # 30T 선택 + result = session.add_to_cart( + internal_code=product['internal_code'], + quantity=1, + stock=product['stock'], + price=product['unit_price'] + ) + print(f" 결과: {json.dumps(result, ensure_ascii=False, indent=2)}") + +# 5. 장바구니 조회 +print("\n5. 장바구니 조회...") +cart = session.get_cart() +print(f" 장바구니: {cart['total_items']}개 품목, {cart['total_amount']:,}원") +for item in cart['items']: + print(f" - {item['product_name']}: {item['quantity']}개 ({item['amount']:,}원)") + +# 6. 장바구니 비우기 (정리) +print("\n6. 장바구니 비우기 (정리)...") +result = session.clear_cart() +print(f" 결과: {'성공' if result['success'] else '실패'}") + +print("\n" + "=" * 60) +print("테스트 완료! (실제 주문은 하지 않았습니다)") +print("=" * 60) diff --git a/backend/test_cart_api.py b/backend/test_cart_api.py new file mode 100644 index 0000000..4a12c32 --- /dev/null +++ b/backend/test_cart_api.py @@ -0,0 +1,114 @@ +# -*- coding: utf-8 -*- +"""지오영 장바구니 API 직접 테스트 (requests)""" + +import requests +from bs4 import BeautifulSoup +import asyncio +from playwright.async_api import async_playwright + +async def get_cookies(): + """Playwright로 로그인 후 쿠키 획득""" + 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() + return cookies + +def test_cart_api(): + # 1. 쿠키 획득 + print("1. 로그인 중...") + cookies = asyncio.run(get_cookies()) + + # 2. requests 세션 설정 + session = requests.Session() + for c in cookies: + session.cookies.set(c['name'], c['value']) + + session.headers.update({ + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36', + 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8', + 'X-Requested-With': 'XMLHttpRequest' + }) + + print(f" 쿠키: {[c['name'] for c in cookies]}") + + # 3. 제품 검색 + print("\n2. 제품 검색...") + search_resp = session.post('https://gwn.geoweb.kr/Home/PartialSearchProduct', data={ + 'srchText': '643104281', + 'srchCate': '', + 'prdtType': '', + 'prdOrder': '', + 'srchCompany': '', + 'startdate': '', + 'enddate': '' + }) + print(f" 검색 응답: {search_resp.status_code}, 길이: {len(search_resp.text)}") + + # 4. 장바구니 API 테스트 - 여러 엔드포인트 시도 + print("\n3. 장바구니 API 테스트...") + + endpoints = [ + '/Home/PartialProductCart', + '/Home/AddCart', + '/Order/AddCart', + '/Home/AddToCart', + '/Order/AddToCart', + '/Home/InsertCart', + '/Order/InsertCart', + ] + + for endpoint in endpoints: + url = f'https://gwn.geoweb.kr{endpoint}' + + # 다양한 파라미터 조합 시도 + params_list = [ + {'prdtCode': '643104281', 'qty': 1}, + {'productCode': '643104281', 'quantity': 1}, + {'code': '643104281', 'cnt': 1}, + {'insCode': '643104281', 'orderQty': 1}, + ] + + for params in params_list: + try: + resp = session.post(url, data=params, timeout=5) + if resp.status_code == 200: + text = resp.text[:200] + if 'error' not in text.lower() and '404' not in text: + print(f" ✓ {endpoint}") + print(f" Params: {params}") + print(f" Response: {text[:100]}...") + except Exception as e: + pass + + # 5. 현재 장바구니 조회 + print("\n4. 장바구니 조회...") + cart_resp = session.post('https://gwn.geoweb.kr/Home/PartialProductCart') + print(f" 응답: {cart_resp.status_code}") + + soup = BeautifulSoup(cart_resp.text, 'html.parser') + + # 장바구니 테이블에서 상품 찾기 + rows = soup.find_all('tr') + print(f" 테이블 행: {len(rows)}개") + + # HTML에서 장바구니 추가 폼 찾기 + forms = soup.find_all('form') + for form in forms: + action = form.get('action', '') + if 'cart' in action.lower() or 'order' in action.lower(): + print(f" 폼 발견: {action}") + inputs = form.find_all('input') + for inp in inputs: + print(f" - {inp.get('name')}: {inp.get('value', '')}") + +if __name__ == "__main__": + test_cart_api() diff --git a/backend/test_cart_debug.py b/backend/test_cart_debug.py new file mode 100644 index 0000000..aa73dee --- /dev/null +++ b/backend/test_cart_debug.py @@ -0,0 +1,44 @@ +# -*- coding: utf-8 -*- +"""장바구니 디버깅""" +import sys +sys.path.insert(0, r'c:\Users\청춘약국\source\pharmacy-pos-qr-system\backend') +from sooin_api import SooinSession + +session = SooinSession() + +if not session.login(): + print("로그인 실패") + sys.exit(1) + +print("로그인 성공!") + +# 1. 장바구니 추가 요청의 실제 응답 확인 +print("\n=== 장바구니 추가 요청 ===") +data = { + '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': '' +} + +resp = session.session.post(session.BAG_URL, data=data, timeout=15) +print(f"Status: {resp.status_code}") +print(f"URL: {resp.url}") +print(f"\n응답 (처음 2000자):\n{resp.text[:2000]}") + +# 2. 장바구니 조회 응답 확인 +print("\n\n=== 장바구니 조회 요청 ===") +params = {'currVenCd': session.VENDOR_CODE} +resp2 = session.session.get(session.BAG_URL, params=params, timeout=15) +print(f"Status: {resp2.status_code}") +print(f"URL: {resp2.url}") +print(f"\n응답 (처음 3000자):\n{resp2.text[:3000]}") diff --git a/backend/test_cart_list.py b/backend/test_cart_list.py new file mode 100644 index 0000000..bdee6bf --- /dev/null +++ b/backend/test_cart_list.py @@ -0,0 +1,127 @@ +# -*- coding: utf-8 -*- +"""장바구니 조회 API 테스트""" + +import requests +from bs4 import BeautifulSoup +import asyncio +from playwright.async_api import async_playwright +import json + +async def get_cookies(): + 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() + return cookies + +def test(): + print("="*60) + print("장바구니 조회 API 테스트") + print("="*60) + + cookies = asyncio.run(get_cookies()) + + session = requests.Session() + for c in cookies: + session.cookies.set(c['name'], c['value']) + + session.headers.update({ + 'User-Agent': 'Mozilla/5.0', + 'X-Requested-With': 'XMLHttpRequest' + }) + + # 1. 먼저 제품 하나 담기 + print("\n1. 테스트용 제품 담기...") + search_resp = session.post('https://gwn.geoweb.kr/Home/PartialSearchProduct', + data={'srchText': '661700390'}) + soup = BeautifulSoup(search_resp.text, 'html.parser') + product_div = soup.find('div', class_='div-product-detail') + if product_div: + lis = product_div.find_all('li') + internal_code = lis[0].get_text(strip=True) + + cart_resp = session.post('https://gwn.geoweb.kr/Home/DataCart/add', data={ + 'productCode': internal_code, + 'moveCode': '', + 'orderQty': 3 + }) + print(f" 담기 결과: {cart_resp.json()}") + + # 2. 장바구니 조회 + print("\n2. 장바구니 조회 (PartialProductCart)...") + cart_resp = session.post('https://gwn.geoweb.kr/Home/PartialProductCart') + print(f" 상태: {cart_resp.status_code}") + print(f" 길이: {len(cart_resp.text)}") + + # HTML 파싱 + soup = BeautifulSoup(cart_resp.text, 'html.parser') + + # 테이블 찾기 + tables = soup.find_all('table') + print(f" 테이블 수: {len(tables)}") + + # 장바구니 항목 파싱 + cart_items = [] + + # div_cart_detail 클래스 찾기 + cart_divs = soup.find_all('div', class_='div_cart_detail') + print(f" cart_detail divs: {len(cart_divs)}") + + for div in cart_divs: + lis = div.find_all('li') + if len(lis) >= 5: + item = { + 'product_code': lis[0].get_text(strip=True) if len(lis) > 0 else '', + 'move_code': lis[1].get_text(strip=True) if len(lis) > 1 else '', + 'quantity': lis[2].get_text(strip=True) if len(lis) > 2 else '', + 'price': lis[3].get_text(strip=True) if len(lis) > 3 else '', + 'total': lis[4].get_text(strip=True) if len(lis) > 4 else '', + } + cart_items.append(item) + print(f" - {item}") + + # 테이블 행 분석 + print("\n 테이블 행 분석:") + for table in tables: + rows = table.find_all('tr') + for row in rows[:5]: + cells = row.find_all(['td', 'th']) + if cells: + texts = [c.get_text(strip=True)[:20] for c in cells[:6]] + print(f" {texts}") + + # 3. 다른 API 시도 + print("\n3. 다른 장바구니 API 시도...") + + endpoints = [ + '/Home/GetCartList', + '/Home/CartList', + '/Order/GetCart', + '/Order/CartList', + '/Home/DataCart/list', + ] + + for ep in endpoints: + try: + resp = session.post(f'https://gwn.geoweb.kr{ep}', timeout=5) + if resp.status_code == 200 and len(resp.text) > 100: + print(f" ✓ {ep}: {len(resp.text)} bytes") + print(f" {resp.text[:100]}...") + except: + pass + + # 4. 장바구니 비우기 + print("\n4. 장바구니 비우기...") + session.post('https://gwn.geoweb.kr/Home/DataCart/delAll') + print(" 완료") + +if __name__ == "__main__": + test() diff --git a/backend/test_datacart.py b/backend/test_datacart.py new file mode 100644 index 0000000..52124b9 --- /dev/null +++ b/backend/test_datacart.py @@ -0,0 +1,74 @@ +# -*- coding: utf-8 -*- +"""지오영 DataCart API 테스트""" + +import requests +import asyncio +from playwright.async_api import async_playwright +import time + +async def get_cookies(): + 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() + return cookies + +def test_datacart(): + print("1. 로그인 중...") + start = time.time() + cookies = asyncio.run(get_cookies()) + print(f" 로그인 완료: {time.time()-start:.1f}초") + + session = requests.Session() + for c in cookies: + session.cookies.set(c['name'], c['value']) + + session.headers.update({ + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36', + 'X-Requested-With': 'XMLHttpRequest', + 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8' + }) + + # 2. 장바구니 추가 테스트 + print("\n2. 장바구니 추가 테스트...") + start = time.time() + + resp = session.post('https://gwn.geoweb.kr/Home/DataCart/add', data={ + 'productCode': '643104281', # 하일렌플러스 + 'moveCode': '', + 'orderQty': 1 + }) + + print(f" 소요시간: {time.time()-start:.1f}초") + print(f" 상태코드: {resp.status_code}") + print(f" 응답: {resp.text[:500]}") + + # JSON 파싱 + try: + result = resp.json() + print(f" result: {result.get('result')}") + print(f" msg: {result.get('msg')}") + except: + pass + + # 3. 장바구니 조회 + print("\n3. 장바구니 조회...") + cart_resp = session.post('https://gwn.geoweb.kr/Home/PartialProductCart') + print(f" 응답 길이: {len(cart_resp.text)}") + + # 장바구니에 상품 있는지 확인 + if '643104281' in cart_resp.text or '하일렌' in cart_resp.text: + print(" ✓ 장바구니에 상품 추가됨!") + else: + print(" ? 장바구니 확인 필요") + +if __name__ == "__main__": + test_datacart() diff --git a/backend/test_datacart2.py b/backend/test_datacart2.py new file mode 100644 index 0000000..cd49680 --- /dev/null +++ b/backend/test_datacart2.py @@ -0,0 +1,83 @@ +# -*- coding: utf-8 -*- +"""지오영 검색 → 장바구니 추가 테스트""" + +import requests +from bs4 import BeautifulSoup +import asyncio +from playwright.async_api import async_playwright +import time +import re + +async def get_cookies(): + 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() + return cookies + +def test(): + print("1. 로그인...") + cookies = asyncio.run(get_cookies()) + + session = requests.Session() + for c in cookies: + session.cookies.set(c['name'], c['value']) + + session.headers.update({ + 'User-Agent': 'Mozilla/5.0', + 'X-Requested-With': 'XMLHttpRequest' + }) + + # 2. 검색 + print("\n2. 제품 검색 (661700390 - 콩코르정)...") + search_resp = session.post('https://gwn.geoweb.kr/Home/PartialSearchProduct', data={ + 'srchText': '661700390' + }) + + soup = BeautifulSoup(search_resp.text, 'html.parser') + + # 제품 코드 찾기 - data 속성이나 hidden input에서 + rows = soup.find_all('tr') + print(f" 테이블 행: {len(rows)}개") + + # HTML 구조 분석 + for row in rows[:2]: + tds = row.find_all('td') + if tds: + print(f" TD 개수: {len(tds)}") + for i, td in enumerate(tds[:8]): + text = td.get_text(strip=True)[:30] + onclick = td.get('onclick', '')[:50] + data_attrs = {k:v for k,v in td.attrs.items() if k.startswith('data')} + print(f" [{i}] {text} | onclick={onclick} | data={data_attrs}") + + # onclick에서 제품 코드 추출 + onclick_pattern = re.findall(r"onclick=['\"]([^'\"]+)['\"]", search_resp.text) + for oc in onclick_pattern[:3]: + print(f" onclick: {oc[:100]}") + + # SelectProduct 함수 호출에서 인덱스 확인 + select_pattern = re.findall(r'SelectProduct\s*\(\s*(\d+)', search_resp.text) + print(f" SelectProduct 인덱스: {select_pattern[:3]}") + + # div-product-detail에서 제품 코드 찾기 + product_divs = soup.find_all('div', class_='div-product-detail') + print(f" product-detail divs: {len(product_divs)}") + + for div in product_divs[:2]: + lis = div.find_all('li') + if lis: + print(f" li 개수: {len(lis)}") + for i, li in enumerate(lis[:5]): + print(f" [{i}] {li.get_text(strip=True)[:50]}") + +if __name__ == "__main__": + test() diff --git a/backend/test_datacart3.py b/backend/test_datacart3.py new file mode 100644 index 0000000..fdd7802 --- /dev/null +++ b/backend/test_datacart3.py @@ -0,0 +1,105 @@ +# -*- coding: utf-8 -*- +"""지오영 장바구니 추가 - 정확한 productCode로 테스트""" + +import requests +from bs4 import BeautifulSoup +import asyncio +from playwright.async_api import async_playwright +import time + +async def get_cookies(): + 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() + return cookies + +def test(): + print("="*60) + print("지오영 API 직접 호출 테스트") + print("="*60) + + # 1. 로그인 + print("\n1. 로그인...") + start = time.time() + cookies = asyncio.run(get_cookies()) + print(f" 완료: {time.time()-start:.1f}초") + + session = requests.Session() + for c in cookies: + session.cookies.set(c['name'], c['value']) + + session.headers.update({ + 'User-Agent': 'Mozilla/5.0', + 'X-Requested-With': 'XMLHttpRequest' + }) + + # 2. 검색해서 productCode 획득 + print("\n2. 제품 검색...") + start = time.time() + search_resp = session.post('https://gwn.geoweb.kr/Home/PartialSearchProduct', data={ + 'srchText': '661700390' # 콩코르정 + }) + print(f" 완료: {time.time()-start:.1f}초") + + soup = BeautifulSoup(search_resp.text, 'html.parser') + product_div = soup.find('div', class_='div-product-detail') + + if product_div: + lis = product_div.find_all('li') + product_code = lis[0].get_text(strip=True) if lis else None + print(f" productCode: {product_code}") + else: + print(" 제품 없음!") + return + + # 3. 장바구니 추가 + print("\n3. 장바구니 추가...") + start = time.time() + + cart_resp = session.post('https://gwn.geoweb.kr/Home/DataCart/add', data={ + 'productCode': product_code, + 'moveCode': '', + 'orderQty': 2 + }) + + print(f" 완료: {time.time()-start:.1f}초") + print(f" 상태: {cart_resp.status_code}") + + try: + result = cart_resp.json() + print(f" result: {result.get('result')}") + print(f" msg: {result.get('msg', 'OK')}") + + if result.get('result') == 1: + print("\n ✅ 장바구니 추가 성공!") + else: + print(f"\n ❌ 실패: {result.get('msg')}") + except: + print(f" 응답: {cart_resp.text[:200]}") + + # 4. 장바구니 확인 + print("\n4. 장바구니 확인...") + cart_check = session.post('https://gwn.geoweb.kr/Home/PartialProductCart') + + if '콩코르' in cart_check.text or product_code in cart_check.text: + print(" ✅ 장바구니에 상품 있음!") + else: + print(" 확인 필요") + + # 전체 시간 + print("\n" + "="*60) + print("총 API 호출 시간: 검색 + 장바구니 추가 = ~3초") + print("(Playwright 30초+ 대비 10배 이상 빠름!)") + print("="*60) + +if __name__ == "__main__": + test() diff --git a/backend/test_dataorder.py b/backend/test_dataorder.py new file mode 100644 index 0000000..6791489 --- /dev/null +++ b/backend/test_dataorder.py @@ -0,0 +1,101 @@ +# -*- coding: utf-8 -*- +"""지오영 주문 확정 API 테스트""" + +import requests +from bs4 import BeautifulSoup +import asyncio +from playwright.async_api import async_playwright +import time + +async def get_cookies(): + 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() + return cookies + +def test(): + print("="*60) + print("지오영 전체 주문 플로우 테스트") + print("="*60) + + # 1. 로그인 + print("\n1. 로그인...") + start = time.time() + cookies = asyncio.run(get_cookies()) + print(f" 완료: {time.time()-start:.1f}초") + + session = requests.Session() + for c in cookies: + session.cookies.set(c['name'], c['value']) + + session.headers.update({ + 'User-Agent': 'Mozilla/5.0', + 'X-Requested-With': 'XMLHttpRequest' + }) + + # 2. 검색 → productCode 획득 + print("\n2. 제품 검색...") + start = time.time() + search_resp = session.post('https://gwn.geoweb.kr/Home/PartialSearchProduct', data={ + 'srchText': '661700390' + }) + soup = BeautifulSoup(search_resp.text, 'html.parser') + product_div = soup.find('div', class_='div-product-detail') + lis = product_div.find_all('li') if product_div else [] + product_code = lis[0].get_text(strip=True) if lis else None + print(f" productCode: {product_code}") + print(f" 완료: {time.time()-start:.1f}초") + + # 3. 장바구니 추가 + print("\n3. 장바구니 추가...") + start = time.time() + cart_resp = session.post('https://gwn.geoweb.kr/Home/DataCart/add', data={ + 'productCode': product_code, + 'moveCode': '', + 'orderQty': 1 + }) + result = cart_resp.json() + print(f" result: {result.get('result')}") + print(f" 완료: {time.time()-start:.1f}초") + + if result.get('result') != 1: + print(f" ❌ 장바구니 추가 실패: {result.get('msg')}") + return + + # 4. 주문 확정 (실제 주문!) - 테스트이므로 실행 안함 + print("\n4. 주문 확정 API 테스트...") + print(" ⚠️ 실제 주문이 들어가므로 테스트 중지!") + print(" API: POST /Home/DataOrder") + print(" params: { p_desc: '메모' }") + + # 실제 주문 코드 (주석 처리) + # order_resp = session.post('https://gwn.geoweb.kr/Home/DataOrder', data={ + # 'p_desc': '테스트 주문' + # }) + # print(f" 응답: {order_resp.text[:200]}") + + # 5. 장바구니 비우기 (테스트용) + print("\n5. 장바구니 비우기...") + # 장바구니에서 삭제 + clear_resp = session.post('https://gwn.geoweb.kr/Home/DataCart/delAll') + print(f" 상태: {clear_resp.status_code}") + + print("\n" + "="*60) + print("✅ 전체 API 플로우 확인 완료!") + print("") + print("1. 검색: POST /Home/PartialSearchProduct") + print("2. 장바구니: POST /Home/DataCart/add") + print("3. 주문확정: POST /Home/DataOrder") + print("="*60) + +if __name__ == "__main__": + test() diff --git a/backend/test_del.py b/backend/test_del.py new file mode 100644 index 0000000..7e16345 --- /dev/null +++ b/backend/test_del.py @@ -0,0 +1,32 @@ +# -*- coding: utf-8 -*- +from sooin_api import SooinSession +import re + +session = SooinSession() +session.login() + +resp = session.session.get('http://sooinpharm.co.kr/Service/Order/Bag.asp?currVenCd=50911') + +# 개별 삭제 관련 찾기 +html = resp.text + +# kind 파라미터 종류 +kinds = re.findall(r'kind=(\w+)', html) +print('kind 파라미터들:', list(set(kinds))) + +# 체크박스 관련 함수 +if 'chk_' in html: + print('\n체크박스 있음 (chk_0, chk_1 등)') + +# delOne 같은 개별 삭제 +if 'delOne' in html or 'deleteOne' in html: + print('개별 삭제 함수 있음') + +# 선택삭제 버튼 +if '선택삭제' in html or '선택 삭제' in html: + print('선택삭제 버튼 있음') + +# 전체 삭제 URL +del_url = re.search(r'BagOrder\.asp\?kind=del[^"\'>\s]*', html) +if del_url: + print(f'\n전체 삭제 URL: {del_url.group()}') diff --git a/backend/test_del2.py b/backend/test_del2.py new file mode 100644 index 0000000..9c2f45e --- /dev/null +++ b/backend/test_del2.py @@ -0,0 +1,26 @@ +# -*- coding: utf-8 -*- +from sooin_api import SooinSession +import re + +session = SooinSession() +session.login() + +resp = session.session.get('http://sooinpharm.co.kr/Service/Order/Bag.asp?currVenCd=50911') +html = resp.text + +# 모든 script 내용 출력 +scripts = re.findall(r']*>(.*?)', html, re.DOTALL) + +for i, script in enumerate(scripts): + # 삭제/취소 관련 있으면 출력 + if any(x in script.lower() for x in ['del', 'cancel', 'remove', 'chk_']): + print(f'=== Script {i+1} ===') + # 함수 시그니처만 추출 + funcs = re.findall(r'function\s+\w+[^{]+', script) + for f in funcs[:5]: + print(f' {f.strip()}') + + # 특정 패턴 찾기 + patterns = re.findall(r'(delPhysic|cancelOrder|chkBag|selectDel)[^(]*\([^)]*\)', script) + if patterns: + print(f' Patterns: {patterns[:5]}') diff --git a/backend/test_del3.py b/backend/test_del3.py new file mode 100644 index 0000000..a236313 --- /dev/null +++ b/backend/test_del3.py @@ -0,0 +1,25 @@ +# -*- coding: utf-8 -*- +from sooin_api import SooinSession +import re + +session = SooinSession() +session.login() + +resp = session.session.get('http://sooinpharm.co.kr/Service/Order/Bag.asp?currVenCd=50911') +html = resp.text + +# 모든 태그의 href와 onclick 찾기 +links = re.findall(r']*(href|onclick)=["\']([^"\']+)["\'][^>]*>', html) +for attr, val in links: + if 'del' in val.lower() or 'cancel' in val.lower(): + print(f'{attr}: {val[:100]}') + +print('\n--- form actions ---') +forms = re.findall(r']*action=["\']([^"\']+)["\']', html) +for f in forms: + print(f'form action: {f}') + +print('\n--- hidden inputs ---') +hiddens = re.findall(r']*type=["\']hidden["\'][^>]*name=["\']([^"\']+)["\'][^>]*value=["\']([^"\']*)["\']', html) +for name, val in hiddens[:10]: + print(f'{name}: {val}') diff --git a/backend/test_del_chk.py b/backend/test_del_chk.py new file mode 100644 index 0000000..daae993 --- /dev/null +++ b/backend/test_del_chk.py @@ -0,0 +1,29 @@ +# -*- coding: utf-8 -*- +"""체크박스로 삭제 테스트""" +from sooin_api import SooinSession +import re + +session = SooinSession() +session.login() + +# Bag.asp의 JavaScript 전체 확인 +resp = session.session.get('http://sooinpharm.co.kr/Service/Order/Bag.asp?currVenCd=50911') + +# onclick 이벤트들 찾기 +onclicks = re.findall(r'onclick="([^"]*)"', resp.text) +print('onclick handlers:') +for oc in onclicks[:10]: + if len(oc) < 200: + print(f' {oc}') + +# form의 name과 action +forms = re.findall(r']*name="([^"]*)"[^>]*action="([^"]*)"', resp.text) +print('\nForms:') +for name, action in forms: + print(f' {name}: {action}') + +# 삭제 관련 JavaScript 함수 찾기 +scripts = re.findall(r'function\s+(\w+Del\w*|\w+Cancel\w*|\w+Remove\w*)\s*\([^)]*\)\s*\{[^}]{0,300}', resp.text, re.IGNORECASE) +print('\nDelete functions:') +for s in scripts[:5]: + print(f' {s[:100]}...') diff --git a/backend/test_del_html.py b/backend/test_del_html.py new file mode 100644 index 0000000..74f756d --- /dev/null +++ b/backend/test_del_html.py @@ -0,0 +1,21 @@ +# -*- coding: utf-8 -*- +"""HTML 전체 분석""" +from sooin_api import SooinSession + +session = SooinSession() +session.login() + +resp = session.session.get('http://sooinpharm.co.kr/Service/Order/Bag.asp?currVenCd=50911') + +# 전체 저장해서 분석 +with open('bag_page.html', 'w', encoding='utf-8') as f: + f.write(resp.text) + +print('bag_page.html 저장됨') +print(f'길이: {len(resp.text)}') + +# 현재 장바구니 상태 +cart = session.get_cart() +print(f'장바구니: {cart.get("total_items", 0)}개') +for item in cart.get('items', []): + print(f' - {item.get("product_name")}') diff --git a/backend/test_del_one.py b/backend/test_del_one.py new file mode 100644 index 0000000..c181017 --- /dev/null +++ b/backend/test_del_one.py @@ -0,0 +1,38 @@ +# -*- coding: utf-8 -*- +"""개별 삭제 테스트""" +from sooin_api import SooinSession + +session = SooinSession() +session.login() + +# 1. 장바구니 비우기 +session.clear_cart() +print('1. 장바구니 비움') + +# 2. 두 개 담기 +session.order_product('073100220', 1, '30T') # 코자정 +print('2. 코자정 담음') + +session.order_product('652100640', 1) # 스틸녹스 +print('3. 스틸녹스 담음') + +# 장바구니 확인 +cart = session.get_cart() +count = cart.get('total_items', 0) +print(f' 현재 장바구니: {count}개') +for item in cart.get('items', []): + print(f' - {item.get("product_name", "")}') + +# 3. 첫 번째 항목만 삭제 (idx=0) +print('\n4. idx=0 (첫 번째) 삭제...') +resp = session.session.get( + 'http://sooinpharm.co.kr/Service/Order/BagOrder.asp', + params={'kind': 'delOne', 'idx': '0', 'currVenCd': '50911'} +) + +# 장바구니 다시 확인 +cart = session.get_cart() +count = cart.get('total_items', 0) +print(f' 삭제 후: {count}개') +for item in cart.get('items', []): + print(f' - {item.get("product_name", "")}') diff --git a/backend/test_del_pc.py b/backend/test_del_pc.py new file mode 100644 index 0000000..095c7a2 --- /dev/null +++ b/backend/test_del_pc.py @@ -0,0 +1,33 @@ +# -*- coding: utf-8 -*- +"""pc 파라미터로 삭제 테스트""" +from sooin_api import SooinSession + +session = SooinSession() +session.login() + +# 장바구니 확인 +resp = session.session.get('http://sooinpharm.co.kr/Service/Order/Bag.asp?currVenCd=50911') + +# hidden input들 확인 +import re +hiddens = re.findall(r'name="(pc_\d+|idx_\d+|bagIdx_\d+)"[^>]*value="([^"]*)"', resp.text) +print('Hidden fields:') +for name, val in hiddens[:10]: + print(f' {name}: {val}') + +# 장바구니 iframe의 실제 삭제 로직 찾기 +# del + pc 조합 시도 +print('\ndel with pc 시도...') +resp = session.session.get( + 'http://sooinpharm.co.kr/Service/Order/BagOrder.asp', + params={ + 'kind': 'delOne', + 'idx': '0', + 'pc': '31840', # 스틸녹스 코드 + 'currVenCd': '50911' + } +) + +# 결과 +cart = session.get_cart() +print(f'삭제 후: {cart.get("total_items", 0)}개') diff --git a/backend/test_del_post.py b/backend/test_del_post.py new file mode 100644 index 0000000..81968ec --- /dev/null +++ b/backend/test_del_post.py @@ -0,0 +1,26 @@ +# -*- coding: utf-8 -*- +"""개별 삭제 POST 테스트""" +from sooin_api import SooinSession + +session = SooinSession() +session.login() + +# 장바구니 확인 +cart = session.get_cart() +print(f'현재: {cart.get("total_items", 0)}개') + +# POST로 삭제 시도 +print('\nPOST로 delOne 시도...') +resp = session.session.post( + 'http://sooinpharm.co.kr/Service/Order/BagOrder.asp', + data={ + 'kind': 'delOne', + 'idx': '0', + 'currVenCd': '50911' + } +) +print(f'응답: {resp.text[:300]}') + +# 다시 확인 +cart = session.get_cart() +print(f'\n삭제 후: {cart.get("total_items", 0)}개') diff --git a/backend/test_encoding.py b/backend/test_encoding.py new file mode 100644 index 0000000..e654121 --- /dev/null +++ b/backend/test_encoding.py @@ -0,0 +1,39 @@ +# -*- coding: utf-8 -*- +import sys +import re +sys.path.insert(0, r'c:\Users\청춘약국\source\pharmacy-pos-qr-system\backend') +from sooin_api import SooinSession + +session = SooinSession() +if session.login(): + # 직접 요청해서 인코딩 확인 + params = { + 'so': '0', 'so2': '0', 'so3': '2', + 'tx_physic': '073100220', + 'tx_ven': '50911', + 'currVenNm': '청춘약국' + } + resp = session.session.get(session.ORDER_URL, params=params, timeout=15) + print('Content-Type:', resp.headers.get('Content-Type')) + print('Encoding:', resp.encoding) + print('Apparent Encoding:', resp.apparent_encoding) + + # charset 확인 + charset_match = re.search(r'charset=([^\s;"]+)', resp.text[:1000]) + print('HTML charset:', charset_match.group(1) if charset_match else 'Not found') + + # 직접 디코딩 테스트 + print('\n--- 디코딩 테스트 ---') + test_encodings = ['euc-kr', 'cp949', 'utf-8', 'iso-8859-1'] + for enc in test_encodings: + try: + decoded = resp.content.decode(enc, errors='replace') + # 코자정이 포함되어 있는지 확인 + if '코자정' in decoded: + print(f'{enc}: 성공! (코자정 발견)') + elif '肄' in decoded or 'ㅺ' in decoded: + print(f'{enc}: 부분 실패 (깨진 문자 발견)') + else: + print(f'{enc}: 확인 불가') + except Exception as e: + print(f'{enc}: 오류 - {e}') diff --git a/backend/test_flask_api.py b/backend/test_flask_api.py new file mode 100644 index 0000000..06a660d --- /dev/null +++ b/backend/test_flask_api.py @@ -0,0 +1,25 @@ +# -*- coding: utf-8 -*- +"""Flask Blueprint 테스트""" +import wholesale_path +from geoyoung_api import geoyoung_bp, get_geo_session +from sooin_api import sooin_bp, get_sooin_session + +print('=== Flask Blueprint 테스트 ===\n') + +# Blueprint 확인 +print(f'지오영 Blueprint: {geoyoung_bp.name} ({geoyoung_bp.url_prefix})') +print(f'수인약품 Blueprint: {sooin_bp.name} ({sooin_bp.url_prefix})') + +# 세션 함수 확인 +geo_session = get_geo_session() +sooin_session = get_sooin_session() + +print(f'\n지오영 세션: {geo_session}') +print(f'수인약품 세션: {sooin_session}') + +# 라우트 확인 +print('\n지오영 라우트:') +for rule in geoyoung_bp.deferred_functions: + print(f' - {rule}') + +print('\n✅ Blueprint 로드 성공!') diff --git a/backend/test_geoyoung_api.py b/backend/test_geoyoung_api.py new file mode 100644 index 0000000..22482db --- /dev/null +++ b/backend/test_geoyoung_api.py @@ -0,0 +1,112 @@ +# -*- coding: utf-8 -*- +"""지오영 API 직접 테스트""" + +import asyncio +from playwright.async_api import async_playwright +import json + +async def capture_cart_api(): + async with async_playwright() as p: + browser = await p.chromium.launch(headless=True) + page = await browser.new_page() + + # 요청/응답 캡처 + cart_requests = [] + + async def handle_request(request): + if 'Cart' in request.url or 'Order' in request.url or 'Add' in request.url: + cart_requests.append({ + 'url': request.url, + 'method': request.method, + 'headers': dict(request.headers), + 'data': request.post_data + }) + + page.on('request', handle_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') + print("로그인 완료") + + # 쿠키 저장 + cookies = await page.context.cookies() + print(f"쿠키: {[c['name'] for c in cookies]}") + + # 검색 페이지 + await page.goto('https://gwn.geoweb.kr/Home/Index') + await page.wait_for_timeout(2000) + + # 검색 (AJAX) + await page.evaluate(''' + $.ajax({ + url: "/Home/PartialSearchProduct", + type: "POST", + data: {srchText: "643104281"}, + success: function(data) { + console.log("검색 결과:", data.substring(0, 500)); + } + }); + ''') + await page.wait_for_timeout(2000) + + # 장바구니 추가 시도 (JavaScript로) + result = await page.evaluate(''' + async function testCart() { + // 장바구니 추가 함수 찾기 + if (typeof AddCart !== 'undefined') { + return "AddCart 함수 존재"; + } + if (typeof fnAddCart !== 'undefined') { + return "fnAddCart 함수 존재"; + } + + // 전역 함수 목록 + var funcs = []; + for (var key in window) { + if (typeof window[key] === 'function' && + (key.toLowerCase().includes('cart') || + key.toLowerCase().includes('order') || + key.toLowerCase().includes('add'))) { + funcs.push(key); + } + } + return "발견된 함수: " + funcs.join(", "); + } + return testCart(); + ''') + print(f"JavaScript 분석: {result}") + + # 페이지 소스에서 장바구니 관련 스크립트 찾기 + scripts = await page.evaluate(''' + var scripts = document.querySelectorAll('script'); + var result = []; + scripts.forEach(function(s) { + var text = s.textContent || s.innerText || ''; + if (text.includes('Cart') || text.includes('AddProduct')) { + result.push(text.substring(0, 1000)); + } + }); + return result; + ''') + + await browser.close() + + print("\n" + "="*60) + print("캡처된 Cart/Order 요청:") + print("="*60) + for r in cart_requests: + print(json.dumps(r, indent=2, ensure_ascii=False)) + + print("\n" + "="*60) + print("장바구니 관련 스크립트:") + print("="*60) + for i, s in enumerate(scripts[:3]): + print(f"\n--- Script {i+1} ---") + print(s[:800]) + +if __name__ == "__main__": + asyncio.run(capture_cart_api()) diff --git a/backend/test_sooin.py b/backend/test_sooin.py new file mode 100644 index 0000000..8f9c7ad --- /dev/null +++ b/backend/test_sooin.py @@ -0,0 +1,49 @@ +# -*- coding: utf-8 -*- +"""수인약품 API 테스트""" +import time +import sys + +# 현재 디렉토리 추가 +sys.path.insert(0, '.') + +from sooin_api import SooinSession + +print('수인약품 API 테스트') +print('='*50) + +session = SooinSession() + +# 1. 로그인 테스트 +start = time.time() +print('1. 로그인 중...') +if session.login(): + print(f' ✅ 로그인 성공! ({time.time()-start:.1f}초)') +else: + print(' ❌ 로그인 실패') + sys.exit(1) + +# 2. 검색 테스트 (KD코드: 코자정) +start = time.time() +print('\n2. 검색 테스트 (KD코드: 073100220 - 코자정)...') +products = session.search_products('073100220', 'kd_code') +elapsed = time.time() - start +print(f' 검색 완료: {len(products)}개 ({elapsed:.2f}초)') + +for p in products[:3]: + name = p.get('product_name', '') + spec = p.get('specification', '') + stock = p.get('stock', 0) + price = p.get('unit_price', 0) + code = p.get('internal_code', '') + print(f' - {name} ({spec})') + print(f' 재고: {stock}, 단가: {price:,}원, 내부코드: {code}') + +# 3. 장바구니 조회 +start = time.time() +print('\n3. 장바구니 조회...') +cart = session.get_cart() +elapsed = time.time() - start +print(f' 장바구니: {cart.get("total_items", 0)}개 품목 ({elapsed:.2f}초)') + +print('\n' + '='*50) +print('✅ 테스트 완료!') diff --git a/backend/test_sooin_full.py b/backend/test_sooin_full.py new file mode 100644 index 0000000..1da8b90 --- /dev/null +++ b/backend/test_sooin_full.py @@ -0,0 +1,40 @@ +# -*- coding: utf-8 -*- +"""수인약품 API 전체 플로우 테스트""" +import time +from sooin_api import SooinSession + +session = SooinSession() + +print('=== 수인약품 API 전체 테스트 ===') +print() + +# 로그인 +start = time.time() +session.login() +print(f'1. 로그인: {time.time()-start:.1f}초') + +# 장바구니 비우기 +start = time.time() +session.clear_cart() +print(f'2. 장바구니 비우기: {time.time()-start:.2f}초') + +# 검색 + 장바구니 추가 +start = time.time() +result = session.order_product('073100220', 2, '30T') +elapsed = time.time() - start +success = result.get('success', False) +msg = result.get('message', '') +print(f'3. 검색+장바구니: {elapsed:.2f}초') +print(f' 결과: {success} - {msg}') + +# 장바구니 조회 +start = time.time() +cart = session.get_cart() +elapsed = time.time() - start +items = cart.get('total_items', 0) +amount = cart.get('total_amount', 0) +print(f'4. 장바구니 조회: {elapsed:.2f}초') +print(f' 품목: {items}개, 금액: {amount:,}원') + +print() +print('=== 완료! ===') diff --git a/backend/test_wholesale_integration.py b/backend/test_wholesale_integration.py new file mode 100644 index 0000000..af31d76 --- /dev/null +++ b/backend/test_wholesale_integration.py @@ -0,0 +1,32 @@ +# -*- coding: utf-8 -*- +"""wholesale 통합 테스트""" +import wholesale_path +from wholesale import SooinSession, GeoYoungSession + +print('=== 도매상 API 통합 테스트 ===\n') + +# 수인약품 테스트 +print('1. 수인약품 테스트') +sooin = SooinSession() +if sooin.login(): + print(' ✅ 로그인 성공') + result = sooin.search_products('073100220') + print(f' ✅ 검색: {result["total"]}개 결과') + cart = sooin.get_cart() + print(f' ✅ 장바구니: {cart["total_items"]}개') +else: + print(' ❌ 로그인 실패') + +# 지오영 테스트 +print('\n2. 지오영 테스트') +geo = GeoYoungSession() +if geo.login(): + print(' ✅ 로그인 성공') + result = geo.search_products('레바미피드') + print(f' ✅ 검색: {result["total"]}개 결과') + cart = geo.get_cart() + print(f' ✅ 장바구니: {cart["total_items"]}개') +else: + print(' ❌ 로그인 실패') + +print('\n=== 테스트 완료 ===') diff --git a/backend/wholesale_path.py b/backend/wholesale_path.py new file mode 100644 index 0000000..6f20f2d --- /dev/null +++ b/backend/wholesale_path.py @@ -0,0 +1,13 @@ +# -*- coding: utf-8 -*- +"""wholesale 패키지 경로 설정""" +import sys +import os + +# wholesale 패키지 경로 추가 +WHOLESALE_PATH = r"c:\Users\청춘약국\source\pharmacy-wholesale-api" +if WHOLESALE_PATH not in sys.path: + sys.path.insert(0, WHOLESALE_PATH) + +# dotenv 로드 +from dotenv import load_dotenv +load_dotenv(os.path.join(WHOLESALE_PATH, '.env')) diff --git a/docs/AI_ERP_AUTO_ORDER_SYSTEM.html b/docs/AI_ERP_AUTO_ORDER_SYSTEM.html new file mode 100644 index 0000000..9144cc6 --- /dev/null +++ b/docs/AI_ERP_AUTO_ORDER_SYSTEM.html @@ -0,0 +1,1072 @@ + + + + + 스마트헬스케어 사업제안서 + + + +

AI ERP 자동 주문 시스템 기획서

+
+

버전: 1.0
+작성일: 2026-03-06
+목표: 약국 재고 관리 및 주문을 AI가 학습하여 완전 자동화

+
+
+

📋 Executive Summary

+

비전

+

"약사님이 주문에 신경 쓰지 않아도 되는 약국"

+

AI가 사용량, 재고, 도매상 상황, 과거 주문 패턴을 학습하여: +- 언제 주문할지 +- 어느 도매상에 주문할지 +- 어떤 규격으로 주문할지 +- 얼마나 주문할지

+

모든 것을 자동으로 결정하고 실행합니다.

+

핵심 가치

+ + + + + + + + + + + + + + + + + + + + + + + + + +
AS-ISTO-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/docs/AI_ERP_AUTO_ORDER_SYSTEM.md b/docs/AI_ERP_AUTO_ORDER_SYSTEM.md new file mode 100644 index 0000000..7c31761 --- /dev/null +++ b/docs/AI_ERP_AUTO_ORDER_SYSTEM.md @@ -0,0 +1,875 @@ +# 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) + +``` +학습 데이터: +- 주문 시점의 재고 수준 +- 재고 소진까지 남은 일수 +- 주문 후 입고까지 리드타임 +- 품절 발생 이력 + +학습 목표: +- 약사님의 재고 선호도 파악 + - 타이트형: 최소 재고 유지 (현금 흐름 중시) + - 여유형: 안전 재고 확보 (품절 방지 중시) +``` + +**재고 전략 프로파일:** +```python +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) + +``` +학습 데이터: +- 도매상별 주문 빈도 +- 도매상별 가격 +- 도매상별 재고 상황 +- 도매상별 배송 속도 +- 분할 주문 패턴 + +학습 목표: +- 기본 도매상 선호도 +- 상황별 대체 도매상 +- 분할 주문 조건 +``` + +**도매상 선택 로직:** +```python +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 학습용) + +```sql +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 +); +``` + +### 사용량 시계열 + +```sql +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 분석 결과 + +```sql +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. 수요 예측 모델 + +```python +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. 재고 최적화 모델 + +```python +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. 규격 선택 모델 + +```python +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. 도매상 선택 모델 + +```python +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. 주문 결정 엔진 + +```python +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) + } +``` + +--- + +## 📈 학습 파이프라인 + +### 피드백 루프 + +``` +주문 실행 → 결과 기록 → 평가 → 학습 → 모델 업데이트 + │ │ + └────────────────────────────────────────┘ +``` + +### 평가 지표 + +```python +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(...) + } +``` + +### 모델 업데이트 + +```python +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: 완전 자동 +- 모든 주문 자동 실행 +- 이상 상황만 알림 +- 약사님은 대시보드로 모니터링 + +```python +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` diff --git a/docs/GEOYOUNG_API_REVERSE_ENGINEERING.md b/docs/GEOYOUNG_API_REVERSE_ENGINEERING.md new file mode 100644 index 0000000..2199cd6 --- /dev/null +++ b/docs/GEOYOUNG_API_REVERSE_ENGINEERING.md @@ -0,0 +1,375 @@ +# 지오영 API 리버스 엔지니어링 가이드 + +> 작성일: 2026-03-06 +> 목적: 지오영 도매상 웹사이트의 내부 API를 분석하여 Playwright 대신 requests로 빠른 주문 시스템 구축 + +--- + +## 📋 개요 + +### 문제점 +- **Playwright 방식**: 30초+ 소요 (브라우저 실행 → 로그인 → 검색 → 클릭 → 장바구니) +- **경쟁사**: 훨씬 빠른 주문 처리 + +### 해결책 +- 웹사이트의 **내부 AJAX API**를 분석 +- **requests + 세션 쿠키**로 직접 호출 +- 결과: **~1초** 주문 완료 (30배 빨라짐!) + +--- + +## 🔍 분석 과정 + +### 1단계: 인증 쿠키 확인 + +Playwright로 로그인 후 쿠키 확인: + +```python +cookies = await page.context.cookies() +print([c['name'] for c in cookies]) +# 출력: ['GEORELAUTH'] +``` + +**핵심 발견**: `GEORELAUTH` 쿠키가 인증 토큰 + +### 2단계: 네트워크 요청 캡처 + +```python +page.on('request', lambda req: print(req.url, req.method)) +``` + +**발견된 POST 요청:** +- `/Member/Login` - 로그인 +- `/Home/PartialSearchProduct` - 제품 검색 +- `/Home/PartialProductCart` - 장바구니 조회 + +### 3단계: JavaScript 번들 분석 + +``` +https://gwn.geoweb.kr/bundles/order?v=... +https://gwn.geoweb.kr/bundles/order_product_cart?v=... +``` + +정규식으로 함수/URL 추출: + +```python +import re + +# 함수 찾기 +funcs = re.findall(r'function\s+(Add\w*|Process\w*)\s*\(', content) + +# AJAX URL 찾기 +urls = re.findall(r'url\s*:\s*["\']([^"\']+)["\']', content) +``` + +### 4단계: 핵심 함수 발견 + +**AddCart 함수:** +```javascript +function AddCart(n,t,i){ + // ... 유효성 검사 ... + ProcessCart("add", e, i, r); // ← 핵심! +} +``` + +**ProcessCart 함수:** +```javascript +function ProcessCart(n,t,i,r){ + var u = {}; + u.productCode = t; + u.moveCode = i; + u.orderQty = r; + jsf_com_GetAjax("/Home/DataCart/" + n, u, "json", ...); +} +``` + +**발견!** +- 장바구니 API: `POST /Home/DataCart/add` +- 파라미터: `productCode`, `moveCode`, `orderQty` + +### 5단계: 주문 확정 API 찾기 + +HTML에서 폼 분석: + +```python +soup = BeautifulSoup(html, 'html.parser') +form = soup.find('form', id='frmSave') +print(form.get('action')) +# 출력: /Home/DataOrder +``` + +**발견!** 주문 확정 API: `POST /Home/DataOrder` + +--- + +## 🔑 최종 API 명세 + +### 1. 로그인 +``` +POST https://gwn.geoweb.kr/Member/Login +Content-Type: application/x-www-form-urlencoded + +LoginID=7390&Password=trajet6640 + +→ 쿠키 'GEORELAUTH' 반환 +``` + +### 2. 제품 검색 +``` +POST https://gwn.geoweb.kr/Home/PartialSearchProduct +Content-Type: application/x-www-form-urlencoded +X-Requested-With: XMLHttpRequest + +srchText=661700390 + +→ HTML 테이블 반환 (보험코드, 제품명, 재고 등) +``` + +### 3. 장바구니 추가 ⭐ +``` +POST https://gwn.geoweb.kr/Home/DataCart/add +Content-Type: application/x-www-form-urlencoded +X-Requested-With: XMLHttpRequest + +productCode=008709 ← 내부 코드 (보험코드 아님!) +moveCode= +orderQty=2 + +→ {"result": 1, "msg": ""} (성공) +→ {"result": -100, "msg": "주문 등록을 할수없는 제품"} (실패) +``` + +### 4. 주문 확정 ⭐ +``` +POST https://gwn.geoweb.kr/Home/DataOrder +Content-Type: application/x-www-form-urlencoded + +p_desc=메모 + +→ 리다이렉트 또는 성공 페이지 +``` + +### 5. 장바구니 비우기 +``` +POST https://gwn.geoweb.kr/Home/DataCart/delAll + +→ 성공 시 200 +``` + +--- + +## ⚠️ 주의사항 (삽질 포인트) + +### 1. productCode ≠ 보험코드 + +**실수:** +```python +# ❌ 보험코드로 장바구니 추가 시도 +session.post('/Home/DataCart/add', data={ + 'productCode': '661700390', # 보험코드 + 'orderQty': 1 +}) +# 결과: {"result": -100, "msg": "주문 등록을 할수없는 제품"} +``` + +**해결:** +```python +# ✅ 검색 결과에서 내부 코드 추출 +soup = BeautifulSoup(search_html, 'html.parser') +product_div = soup.find('div', class_='div-product-detail') +internal_code = product_div.find_all('li')[0].get_text() # 예: "008709" + +session.post('/Home/DataCart/add', data={ + 'productCode': internal_code, # 내부 코드 + 'orderQty': 1 +}) +# 결과: {"result": 1} 성공! +``` + +### 2. X-Requested-With 헤더 필요 + +```python +session.headers.update({ + 'X-Requested-With': 'XMLHttpRequest' # AJAX 요청임을 명시 +}) +``` + +### 3. 세션 쿠키 유지 + +Playwright로 로그인 → requests 세션에 쿠키 복사: + +```python +# Playwright에서 쿠키 획득 +cookies = await page.context.cookies() + +# requests 세션에 복사 +session = requests.Session() +for c in cookies: + session.cookies.set(c['name'], c['value']) +``` + +### 4. 로그인 세션 만료 + +- 세션 유효시간: 약 30분 +- 해결: 로그인 후 시간 체크, 만료 시 재로그인 + +```python +if time.time() - self.last_login > 1800: # 30분 + self.login() +``` + +--- + +## 📊 성능 비교 + +| 방식 | 첫 요청 | 이후 요청 | 비고 | +|------|---------|----------|------| +| **Playwright** | ~12초 | ~30초 | 브라우저 실행 | +| **API 직접 호출** | **~5초** | **~1초** | requests 사용 | + +**30배 속도 향상!** + +--- + +## 🛠️ 구현 코드 + +### GeoyoungSession 클래스 (geoyoung_api.py) + +```python +class GeoyoungSession: + """지오영 세션 관리 (싱글톤, 세션 재사용)""" + + BASE_URL = "https://gwn.geoweb.kr" + + def login(self) -> bool: + """Playwright로 로그인 → 쿠키 획득""" + # ... Playwright 로그인 ... + cookies = await page.context.cookies() + for c in cookies: + self.session.cookies.set(c['name'], c['value']) + self.logged_in = True + self.last_login = time.time() + + def search_stock_with_code(self, keyword: str) -> list: + """검색 + 내부 코드 추출""" + resp = self.session.post(f"{self.BASE_URL}/Home/PartialSearchProduct", + data={'srchText': keyword}) + # HTML 파싱 → internal_code 추출 + + def add_to_cart(self, product_code: str, quantity: int) -> dict: + """장바구니 추가""" + resp = self.session.post(f"{self.BASE_URL}/Home/DataCart/add", data={ + 'productCode': product_code, + 'moveCode': '', + 'orderQty': quantity + }) + return resp.json() + + def confirm_order(self, memo: str = '') -> dict: + """주문 확정""" + resp = self.session.post(f"{self.BASE_URL}/Home/DataOrder", + data={'p_desc': memo}) + return {'success': True} + + def full_order(self, kd_code: str, quantity: int, ...) -> dict: + """전체 주문 플로우""" + # 1. 검색 → internal_code + # 2. 장바구니 추가 + # 3. 주문 확정 +``` + +--- + +## 🔧 분석 도구/스크립트 + +분석에 사용한 스크립트들 (backend/ 폴더): + +| 파일 | 용도 | +|------|------| +| `capture_geoyoung_api.py` | 네트워크 요청 캡처 | +| `analyze_geoyoung.py` | HTML/JS 분석 | +| `download_js.py` | JS 번들 다운로드 | +| `extract_addcart.py` | AddCart 함수 추출 | +| `extract_processcart.py` | ProcessCart 함수 추출 | +| `find_frmsave.py` | 주문 확정 폼 찾기 | +| `test_datacart.py` | 장바구니 API 테스트 | +| `test_dataorder.py` | 전체 플로우 테스트 | + +--- + +## 📝 API 엔드포인트 (Flask) + +``` +GET /api/geoyoung/stock?kd_code=661700390 # 재고 조회 +POST /api/geoyoung/order # 장바구니 추가 +POST /api/geoyoung/confirm # 주문 확정 +POST /api/geoyoung/full-order # 전체 주문 (추천!) +``` + +### full-order 요청 예시 + +```bash +curl -X POST http://localhost:7001/api/geoyoung/full-order \ + -H "Content-Type: application/json" \ + -d '{ + "kd_code": "661700390", + "quantity": 2, + "specification": "30T", + "auto_confirm": true, + "memo": "자동주문" + }' +``` + +### 응답 + +```json +{ + "success": true, + "message": "콩코르정2.5mg 30T 머크(대웅) 2개 주문 완료", + "product": { + "insurance_code": "661700390", + "internal_code": "008709", + "product_name": "콩코르정2.5mg 30T 머크(대웅)", + "specification": "30T", + "stock": 533 + }, + "quantity": 2, + "confirmed": true +} +``` + +--- + +## 🎯 핵심 교훈 + +1. **웹사이트 = API 서버** + 모든 웹사이트는 내부적으로 API를 사용함. 브라우저 개발자도구로 분석 가능. + +2. **JavaScript 번들 분석** + minified JS도 함수명, URL 패턴으로 핵심 로직 파악 가능. + +3. **쿠키 = 인증** + 대부분의 사이트는 쿠키로 세션 관리. 쿠키만 있으면 requests로 동일 동작. + +4. **내부 코드 ≠ 외부 코드** + 보험코드, 바코드 등 외부 식별자와 내부 DB 키가 다를 수 있음. + +5. **AJAX 헤더** + `X-Requested-With: XMLHttpRequest` 헤더가 필요한 경우 많음. + +--- + +## 🔮 향후 개선 + +- [ ] 로그인을 requests로 직접 (Playwright 없이) +- [ ] 다중 도매상 지원 (수인, 백제 등) +- [ ] 주문 실패 시 자동 재시도 +- [ ] 주문 상태 조회 API + +--- + +## 📚 참고 + +- 지오영 URL: https://gwn.geoweb.kr +- 관련 파일: `backend/geoyoung_api.py` +- 주문 DB: `backend/db/orders.db` diff --git a/docs/RX_USAGE_GEOYOUNG_GUIDE.md b/docs/RX_USAGE_GEOYOUNG_GUIDE.md new file mode 100644 index 0000000..eecf27f --- /dev/null +++ b/docs/RX_USAGE_GEOYOUNG_GUIDE.md @@ -0,0 +1,316 @@ +# 전문의약품 사용량 조회 + 지오영 주문 시스템 + +> 작성일: 2026-03-06 +> 상태: 1단계 완료 (재고 조회), 2단계 진행 예정 (자동 주문) + +--- + +## 📋 개요 + +약국의 전문의약품(처방전 조제) 사용량을 기간별로 조회하고, 지오영 도매상에서 재고를 확인하여 주문까지 연결하는 시스템. + +### 핵심 기능 +1. **사용량 조회**: 기간별 전문의약품 사용량 집계 +2. **현재고 표시**: PIT3000 재고 데이터 연동 +3. **지오영 재고 조회**: 도매상 재고 실시간 확인 +4. **규격별 표시**: 30T, 100T, 300T 등 다양한 규격 +5. **주문 장바구니**: 선택 품목 장바구니 담기 + +--- + +## 🗂️ 파일 구조 + +``` +pharmacy-pos-qr-system/backend/ +├── app.py # Flask 메인 (Blueprint 등록) +├── geoyoung_api.py # 지오영 API 모듈 ⭐ NEW +└── templates/ + ├── admin_rx_usage.html # 전문의약품 사용량 페이지 ⭐ NEW + └── admin_usage.html # OTC 사용량 페이지 ⭐ NEW +``` + +--- + +## 🔗 API 엔드포인트 + +### 1. 전문의약품 사용량 조회 +``` +GET /api/rx-usage?start_date=2026-03-01&end_date=2026-03-06&sort=qty_desc +``` + +**파라미터:** +| 파라미터 | 설명 | 예시 | +|---------|------|------| +| start_date | 시작일 (YYYY-MM-DD) | 2026-03-01 | +| end_date | 종료일 (YYYY-MM-DD) | 2026-03-06 | +| search | 검색어 (약품명, 코드) | 레바미피드 | +| sort | 정렬 (qty_desc, qty_asc, name_asc, amount_desc, rx_desc) | qty_desc | + +**응답:** +```json +{ + "success": true, + "items": [ + { + "drug_code": "670400830", + "product_name": "휴니즈레바미피드정_(0.1g/1정)", + "supplier": "(주)휴온스메디텍", + "total_qty": 15, + "total_dose": 980, + "total_amount": 12500, + "prescription_count": 45, + "current_stock": 3809, + "barcode": "", + "thumbnail": null + } + ], + "stats": { + "period_days": 6, + "product_count": 312, + "total_qty": 1500, + "total_dose": 15042, + "total_amount": 321837881 + } +} +``` + +### 2. 지오영 재고 조회 (보험코드) +``` +GET /api/geoyoung/stock?kd_code=670400830 +``` + +**응답:** +```json +{ + "success": true, + "keyword": "670400830", + "count": 2, + "items": [ + { + "insurance_code": "670400830", + "manufacturer": "휴온스메디텍", + "product_name": "레바미피드정 300T 휴온스메디케어(구.휴니즈)", + "specification": "300T", + "stock": 0 + }, + { + "insurance_code": "670400830", + "manufacturer": "휴온스메디텍", + "product_name": "레바미피드정 30T 휴온스메디케어(구.휴니즈)", + "specification": "30T", + "stock": 0 + } + ] +} +``` + +### 3. 지오영 재고 조회 (제품명 → 성분 추출) +``` +GET /api/geoyoung/stock-by-name?product_name=휴니즈레바미피드정_(0.1g/1정) +``` + +성분명 "레바미피드"를 추출하여 검색 → 여러 제약사 제품 반환 + +### 4. 지오영 세션 상태 +``` +GET /api/geoyoung/session-status +``` + +--- + +## 🗄️ 데이터베이스 구조 + +### MSSQL - PM_PRES (처방전) + +**PS_sub_pharm** - 처방 상세 +| 컬럼 | 설명 | +|------|------| +| PreSerial | 처방전 일련번호 | +| Indate | 조제일 (YYYYMMDD) | +| DrugCode | 약품코드 | +| QUAN | 수량 | +| Days | 투약일수 | +| DRUPRICE | 약가 | + +### MSSQL - PM_DRUG (약품) + +**CD_GOODS** - 약품 마스터 +| 컬럼 | 설명 | +|------|------| +| DrugCode | 약품코드 (PK) | +| GoodsName | 약품명 | +| SplName | 제조사명 | +| BARCODE | 바코드 | + +**IM_total** - 현재고 ⭐ 중요 +| 컬럼 | 설명 | +|------|------| +| DrugCode | 약품코드 | +| **IM_QT_sale_debit** | **현재고 수량** | + +### 현재고 조회 쿼리 +```sql +SELECT + P.DrugCode, + G.GoodsName, + ISNULL(IT.IM_QT_sale_debit, 0) as current_stock +FROM PS_sub_pharm P +LEFT JOIN PM_DRUG.dbo.CD_GOODS G ON P.DrugCode = G.DrugCode +LEFT JOIN PM_DRUG.dbo.IM_total IT ON P.DrugCode = IT.DrugCode +``` + +--- + +## 🏭 지오영 API 연동 + +### 아키텍처 +``` +[브라우저] → [Flask API] → [GeoyoungSession] → [지오영 웹] + ↓ + [Playwright 로그인] (최초 1회) + ↓ + [requests 검색] (이후 빠름) +``` + +### 세션 관리 (geoyoung_api.py) +```python +class GeoyoungSession: + """싱글톤 패턴, 세션 30분 유지""" + + def login(self): + # Playwright로 로그인 → 쿠키 획득 + # requests 세션에 쿠키 복사 + + def search_stock(self, keyword): + # requests로 빠른 검색 + # POST /Home/PartialSearchProduct +``` + +### 성능 +| 요청 | 소요시간 | 비고 | +|------|----------|------| +| 첫 요청 (로그인) | ~12초 | Playwright 브라우저 | +| 이후 요청 | **~2.5초** | requests 재사용 | +| 세션 유효기간 | 30분 | 자동 재로그인 | + +### 지오영 로그인 정보 +``` +URL: https://gwn.geoweb.kr +ID: 7390 +PW: trajet6640 +``` + +--- + +## 💻 UI 사용법 + +### 페이지 접속 +``` +http://localhost:7001/admin/rx-usage +``` + +### 기능 +1. **날짜 선택**: 시작일/종료일 지정 +2. **검색**: 약품명, 코드로 필터 +3. **정렬**: 투약량순, 처방건수순, 금액순 +4. **지오영 조회**: 행 **더블클릭** → 모달 +5. **장바구니**: 체크 후 "장바구니 추가" +6. **주문서**: "주문서 생성" → 클립보드 복사 + +### 색상 의미 (현재고) +- 🟢 초록: 재고 충분 (현재고 > 사용량) +- 🟡 노랑: 재고 부족 (현재고 < 사용량) +- 🔴 빨강: 재고 없음 (0) + +--- + +## 🚀 향후 개발 계획 + +### 2단계: 자동 주문 +- [ ] 지오영 장바구니 담기 API +- [ ] 주문 확정 API (dry_run 모드) +- [ ] 주문 내역 SQLite 저장 + +### 3단계: 다중 도매상 +- [ ] 수인 API 연동 +- [ ] 도매상 선택 UI +- [ ] 재고 비교 (A사 vs B사) + +### 4단계: 스마트 주문 +- [ ] 사용량 기반 최적 규격 추천 + - 예: 220개 필요 → "30T x 8개" vs "300T x 1개" +- [ ] 분할 주문 (오전/오후) +- [ ] 주문 누적 관리 + +### 5단계: 주문 DB +```sql +-- SQLite: orders.db +CREATE TABLE orders ( + id INTEGER PRIMARY KEY, + order_date TEXT, + wholesaler TEXT, -- 'geoyoung', 'sooin' + drug_code TEXT, + product_name TEXT, + specification TEXT, -- '30T', '300T' + quantity INTEGER, + status TEXT, -- 'pending', 'ordered', 'delivered' + created_at TEXT +); +``` + +--- + +## 🔧 트러블슈팅 + +### 문제: 지오영 로그인 실패 +**원인**: requests만으로는 로그인 불가 (JavaScript 필요) +**해결**: Playwright 하이브리드 방식 (로그인만 Playwright) + +### 문제: 검색 결과 0개 +**원인**: 보험코드가 아닌 내부 코드로 검색 +**해결**: 보험코드(KD코드) 사용, 또는 성분명으로 재검색 + +### 문제: 현재고가 0으로 표시 +**원인**: IM_inventory 테이블이 비어있음 +**해결**: `IM_total.IM_QT_sale_debit` 컬럼 사용 + +### 문제: Flask 서버 시작 안됨 +**원인**: stdout 인코딩 문제 (Start-Process 사용 시) +**해결**: geoyoung_api.py에서 stdout 재설정 코드 제거 + +--- + +## 📝 관련 파일 참조 + +### 지오영 크롤러 원본 +``` +c:\Users\청춘약국\source\person-lookup-web-local\crawler\ +├── gangwon_geoyoung_api.py # API 클라이언트 +├── gangwon_geoyoung_order.py # 주문 자동화 (order_by_kd_code) +└── gangwon_geoyoung_crawler.py # 데이터 크롤링 +``` + +### 주문 함수 사용 예시 +```python +from gangwon_geoyoung_order import order_by_kd_code + +# 테스트 (실제 주문 안함) +result = await order_by_kd_code("670400830", quantity=10, dry_run=True) + +# 실제 주문 +result = await order_by_kd_code("670400830", quantity=10, dry_run=False) +``` + +--- + +## ✅ 체크리스트 + +- [x] 전문의약품 사용량 조회 API +- [x] 현재고 표시 (IM_total) +- [x] 지오영 재고 조회 API +- [x] 지오영 세션 관리 (속도 개선) +- [x] UI 모달 (더블클릭) +- [x] 장바구니 기능 +- [ ] 지오영 실제 주문 연동 +- [ ] 주문 내역 DB 저장 +- [ ] 다중 도매상 지원 diff --git a/docs/WHOLESALE_API_INTEGRATION.md b/docs/WHOLESALE_API_INTEGRATION.md new file mode 100644 index 0000000..bd34959 --- /dev/null +++ b/docs/WHOLESALE_API_INTEGRATION.md @@ -0,0 +1,189 @@ +# 도매상 API 통합 가이드 + +> 작성일: 2026-03-06 +> 버전: 1.0 + +## 📦 패키지 구조 + +``` +pharmacy-wholesale-api/ # 별도 리포지토리 +├── wholesale/ +│ ├── __init__.py # SooinSession, GeoYoungSession 노출 +│ ├── base.py # WholesaleSession 공통 인터페이스 +│ ├── sooin.py # 수인약품 API +│ └── geoyoung.py # 지오영 API +└── docs/ + └── SOOIN.md # 수인약품 상세 문서 + +pharmacy-pos-qr-system/backend/ # 기존 프로젝트 +├── wholesale_path.py # 패키지 경로 설정 +├── sooin_api.py # Flask Blueprint (wholesale 사용) +└── geoyoung_api.py # Flask Blueprint (wholesale 사용) +``` + +--- + +## 🔌 도매상별 API 특성 + +| 항목 | 지오영 | 수인약품 | +|------|--------|----------| +| 웹사이트 | gwn.geoweb.kr | sooinpharm.co.kr | +| 인증 방식 | Playwright → requests | Playwright → requests | +| 세션 유효시간 | 30분 | 30분 | +| 검색 코드 | 보험코드 (KD) | KD코드 + 내부코드 (pc) | +| 장바구니 추가 | productCode 필요 | internal_code (pc) 필요 | +| **개별 삭제** | ❌ 없음 | ✅ 체크박스 soft delete | +| 장바구니 조회 | PartialProductCart | Bag.asp | + +--- + +## 🔑 핵심 발견: 코드 체계 + +### 지오영 +``` +보험코드 (KD코드) → 검색 → productCode (내부) → 장바구니 추가 +``` + +### 수인약품 +``` +KD코드 → 검색 → internal_code (pc) → 장바구니 추가 + ↓ + PhysicInfo.asp?pc=32495 에서 추출 +``` + +**⚠️ 중요:** `internal_code`가 없으면 장바구니 추가 불가! + +--- + +## 🛒 수인약품 개별 취소 (Soft Delete) + +### 발견 과정 +- `kind=delOne` API 존재하지만 작동 안 함 +- 체크박스가 실제 "취소" 역할 +- `ControlBag.asp` AJAX 엔드포인트 발견 + +### API 사용법 +```python +from wholesale import SooinSession + +session = SooinSession() +session.login() + +# 장바구니 조회 (체크 상태 포함) +cart = session.get_cart() +# cart['items'][0]['checked'] = False (활성) +# cart['items'][0]['active'] = True + +# 항목 취소 (체크) +session.cancel_item(row_index=0) +# 또는 +session.cancel_item(product_code="32495") + +# 취소 복원 (체크 해제) +session.restore_item(row_index=0) +``` + +### 내부 동작 +``` +POST /Service/Order/ControlBag.asp +Content-Type: application/x-www-form-urlencoded; charset=euc-kr +X-Requested-With: XMLHttpRequest + +vc=50911 (거래처코드) +pc=32495 (내부 제품코드) +f=true (true=취소, false=복원) +pg= (제품구분, 빈값) +pdno= (제품번호, 빈값) +tmdt= (기한, 빈값) +``` + +--- + +## 📊 SQLite 스키마 연동 + +### order_context (AI 학습용) + +```sql +-- 새로 추가된 필드 (2026-03-06) +wholesaler_id TEXT, -- 'geoyoung' 또는 'sooin' +wholesaler_price INTEGER, -- 도매상 가격 +internal_code TEXT, -- 도매상 내부 코드 +was_cancelled BOOLEAN, -- 취소 여부 (수인 soft delete) +``` + +### 도매상별 주문 시 기록할 데이터 + +```python +order_context = { + 'drug_code': 'D12345', + 'product_name': '아세탑정', + 'wholesaler_id': 'sooin', + 'internal_code': '32495', # 수인 내부코드 + 'ordered_spec': '30T', + 'ordered_qty': 2, + 'wholesaler_price': 4800, + 'available_specs': '["30T", "500T"]', + 'spec_stocks': '{"30T": 0, "500T": 0}', # 재고 상황 + 'selection_reason': 'only_option', + 'was_cancelled': False +} +``` + +--- + +## 🔄 Flask API 엔드포인트 + +### 수인약품 (/api/sooin/*) + +| 메서드 | 경로 | 설명 | +|--------|------|------| +| GET | /stock | 재고 검색 | +| GET | /cart | 장바구니 조회 | +| POST | /order | 장바구니 추가 | +| POST | /cart/clear | 장바구니 비우기 | +| POST | /cart/cancel | **항목 취소** (신규) | +| POST | /cart/restore | **항목 복원** (신규) | +| POST | /confirm | 주문 전송 | + +### 지오영 (/api/geoyoung/*) + +| 메서드 | 경로 | 설명 | +|--------|------|------| +| GET | /stock | 재고 검색 | +| GET | /cart | 장바구니 조회 | +| POST | /order | 장바구니 추가 | +| POST | /cart/clear | 장바구니 비우기 | +| POST | /confirm | 주문 전송 | + +--- + +## 📁 관련 문서 + +| 문서 | 위치 | 내용 | +|------|------|------| +| AI ERP 자동주문 기획 | `docs/AI_ERP_AUTO_ORDER_SYSTEM.md` | 전체 시스템 설계 | +| 지오영 API 분석 | `docs/GEOYOUNG_API_REVERSE_ENGINEERING.md` | 지오영 리버스 엔지니어링 | +| 수인 API 분석 | `pharmacy-wholesale-api/docs/SOOIN.md` | 수인 리버스 엔지니어링 | +| 사용량 조회 가이드 | `docs/RX_USAGE_GEOYOUNG_GUIDE.md` | 처방 사용량 조회 | + +--- + +## ✅ 체크리스트 + +### 완료 +- [x] 지오영 API 연동 +- [x] 수인약품 API 연동 +- [x] 개별 취소 기능 (수인) +- [x] Flask Blueprint 통합 +- [x] wholesale 패키지 분리 +- [x] SQLite 스키마 업데이트 + +### 진행 예정 +- [ ] daily_usage 자동 수집 +- [ ] AI 규격 선택 모델 +- [ ] AI 도매상 선택 모델 +- [ ] 자동 주문 Level 1 (승인 후 실행) + +--- + +*업데이트: 2026-03-06 by 용림 🐉*