diff --git a/backend/analyze_bag.py b/backend/analyze_bag.py new file mode 100644 index 0000000..e57e266 --- /dev/null +++ b/backend/analyze_bag.py @@ -0,0 +1,16 @@ +# -*- coding: utf-8 -*- +import sys; sys.path.insert(0, '.'); import wholesale_path +from wholesale import SooinSession + +s = SooinSession() +s.login() + +# Bag.asp HTML 가져오기 +resp = s.session.get(f'{s.BAG_VIEW_URL}?currVenCd={s.vendor_code}', timeout=15) + +# 파일로 저장 +with open('bag_page.html', 'w', encoding='utf-8') as f: + f.write(resp.text) + +print('bag_page.html 저장됨') +print(f'응답 길이: {len(resp.text)}') diff --git a/backend/bag_page.html b/backend/bag_page.html index 5672a01..6c9f734 100644 --- a/backend/bag_page.html +++ b/backend/bag_page.html @@ -99,63 +99,7 @@ - - - - - (향)스틸녹스정 10mg(병)100T - - - - - - - - - - - - - - - - - - - - 17,300 - - - - - - - (오가논)코자정 50mg(PTP)30T - - - - - - - - - - - - - - - - - - - - 14,220 - - - + 장바구니에 담긴 제품이 없습니다. @@ -168,7 +112,7 @@
주문품목
-
2개
+
0개
취소품목
@@ -177,15 +121,15 @@
주문금액
-
- 31,520원 +
+ 0원
- + diff --git a/backend/capture_order.py b/backend/capture_order.py new file mode 100644 index 0000000..5b9a8bb --- /dev/null +++ b/backend/capture_order.py @@ -0,0 +1,82 @@ +# -*- coding: utf-8 -*- +""" +네트워크 캡처용 - 약사님이 직접 주문 버튼 클릭 +""" +import sys; sys.path.insert(0, '.'); import wholesale_path +from wholesale import SooinSession +from playwright.sync_api import sync_playwright +import time + +s = SooinSession() +print('로그인...') +s.login() + +# 장바구니에 코자정 담기 +print('\n코자정 검색...') +result = s.search_products('코자정 50mg PTP') +product = None +for item in result.get('items', []): + if 'PTP' in item['name']: + product = item + break + +if product: + print(f"제품: {product['name']} - {product['price']:,}원") + s.add_to_cart(product['internal_code'], qty=1, + price=product['price'], stock=product['stock']) + print('장바구니에 담음!') +else: + print('제품 못 찾음') + +# 장바구니 확인 +cart = s.get_cart() +print(f"\n장바구니: {cart['total_items']}개, {cart['total_amount']:,}원") + +print('\n' + '='*50) +print('브라우저 열기 + 네트워크 캡처 시작') +print('='*50) + +with sync_playwright() as p: + browser = p.chromium.launch(headless=False) # 브라우저 보임 + context = browser.new_context() + + # 세션 쿠키 복사 + for c in s.session.cookies: + context.add_cookies([{ + 'name': c.name, + 'value': c.value, + 'domain': c.domain or 'sooinpharm.co.kr', + 'path': c.path or '/' + }]) + + page = context.new_page() + + # 네트워크 요청 캡처 + def on_request(request): + if 'BagOrder' in request.url and request.method == 'POST': + print('\n' + '='*50) + print('🎯 POST 요청 캡처!') + print('='*50) + print(f'URL: {request.url}') + print(f'\nPOST 데이터:') + data = request.post_data or '' + # 파라미터별로 출력 + for param in data.split('&'): + if '=' in param: + key, val = param.split('=', 1) + print(f' {key}: {val[:50]}') + print('='*50) + + page.on('request', on_request) + + # 주문 페이지로 이동 + page.goto('http://sooinpharm.co.kr/Service/Order/Order.asp') + + print('\n✅ 브라우저 준비 완료!') + print('👆 주문전송 버튼을 클릭해주세요!') + print('\n(Enter 누르면 브라우저 닫힘)') + input() + + browser.close() + +print('\n완료!') diff --git a/backend/capture_order2.py b/backend/capture_order2.py new file mode 100644 index 0000000..7e86360 --- /dev/null +++ b/backend/capture_order2.py @@ -0,0 +1,79 @@ +# -*- coding: utf-8 -*- +""" +네트워크 캡처 v2 - 새로고침 후에도 캡처 +""" +import sys; sys.path.insert(0, '.'); import wholesale_path +from wholesale import SooinSession +from playwright.sync_api import sync_playwright +import time + +s = SooinSession() +print('로그인...') +s.login() + +# 먼저 장바구니 비우기 +s.clear_cart() + +# 코자정 담기 +print('코자정 검색...') +result = s.search_products('코자정') +product = result['items'][0] if result.get('items') else None + +if product: + print(f"제품: {product['name']} - {product['price']:,}원") + s.add_to_cart(product['internal_code'], qty=1, + price=product['price'], stock=product['stock']) + print('장바구니에 담음!') + +cart = s.get_cart() +print(f"장바구니: {cart['total_items']}개") + +print('\n브라우저 열기...') + +with sync_playwright() as p: + browser = p.chromium.launch(headless=False) + context = browser.new_context() + + # 쿠키 복사 + for c in s.session.cookies: + context.add_cookies([{ + 'name': c.name, + 'value': c.value, + 'domain': c.domain or 'sooinpharm.co.kr', + 'path': c.path or '/' + }]) + + page = context.new_page() + + # 모든 요청 캡처 (지속적) + captured = [] + def capture(request): + if 'BagOrder' in request.url and request.method == 'POST': + data = request.post_data or '' + captured.append(data) + print('\n' + '='*60) + print('🎯 POST 캡처!') + print('='*60) + for param in data.split('&')[:30]: # 주요 파라미터만 + if '=' in param: + k, v = param.split('=', 1) + if v: # 값이 있는 것만 + print(f' {k}: {v[:60]}') + print('='*60) + + context.on('request', capture) # context 레벨에서 캡처 + + page.goto('http://sooinpharm.co.kr/Service/Order/Order.asp') + + print('\n✅ 준비 완료!') + print('👆 F5로 새로고침 후 주문전송 버튼 클릭!') + print('\n(Enter 누르면 종료)') + input() + + # 캡처된 데이터 파일로 저장 + if captured: + with open('captured_post.txt', 'w', encoding='utf-8') as f: + f.write(captured[0]) + print('\n📁 captured_post.txt 저장됨') + + browser.close() diff --git a/backend/check_cart.py b/backend/check_cart.py new file mode 100644 index 0000000..bd0aa60 --- /dev/null +++ b/backend/check_cart.py @@ -0,0 +1,8 @@ +# -*- coding: utf-8 -*- +import sys; sys.path.insert(0, '.'); import wholesale_path +from wholesale import SooinSession + +s = SooinSession() +s.login() +cart = s.get_cart() +print(f"장바구니: {cart['total_items']}개, {cart['total_amount']:,}원") diff --git a/backend/check_sooin_cart.py b/backend/check_sooin_cart.py new file mode 100644 index 0000000..576bf11 --- /dev/null +++ b/backend/check_sooin_cart.py @@ -0,0 +1,21 @@ +# -*- coding: utf-8 -*- +import sys; sys.path.insert(0, '.'); import wholesale_path +from wholesale import SooinSession + +s = SooinSession() +s.login() + +cart = s.get_cart() +print(f'성공: {cart["success"]}') +print(f'품목 수: {cart["total_items"]}') +print(f'총액: {cart["total_amount"]:,}원') +print() + +if cart['items']: + print('=== 장바구니 품목 ===') + for item in cart['items']: + status = '✅' if item.get('active') else '❌취소' + name = item['product_name'][:30] + print(f"{status} {name:30} x{item['quantity']} = {item['amount']:,}원") +else: + print('🛒 장바구니 비어있음') diff --git a/backend/geo_cart_before.png b/backend/geo_cart_before.png new file mode 100644 index 0000000..42b9c65 Binary files /dev/null and b/backend/geo_cart_before.png differ diff --git a/backend/order_api.py b/backend/order_api.py index ff3b505..ea0be5f 100644 --- a/backend/order_api.py +++ b/backend/order_api.py @@ -295,14 +295,22 @@ def submit_geoyoung_order(order: dict, dry_run: bool, cart_only: bool = True) -> item_internal_code = item.get('internal_code') # 프론트에서 이미 선택한 품목 result = {} + # 🔍 디버그 로그 + logger.info(f"[GEO DEBUG] item keys: {list(item.keys())}") + logger.info(f"[GEO DEBUG] kd_code={kd_code}, internal_code={item_internal_code}, qty={order_qty}, spec={spec}") + logger.info(f"[GEO DEBUG] full item: {item}") + try: if item_internal_code: # internal_code가 있으면 검색 없이 바로 장바구니 추가! + logger.info(f"[GEO DEBUG] Using internal_code directly: {item_internal_code}") result = geo_session.add_to_cart(item_internal_code, order_qty) + logger.info(f"[GEO DEBUG] add_to_cart result: {result}") if result.get('success'): result['product'] = {'internal_code': item_internal_code, 'name': item.get('product_name', '')} else: # internal_code 없으면 검색 후 장바구니 추가 + logger.info(f"[GEO DEBUG] No internal_code, using full_order with kd_code={kd_code}") result = geo_session.full_order( kd_code=kd_code, quantity=order_qty, @@ -311,6 +319,7 @@ def submit_geoyoung_order(order: dict, dry_run: bool, cart_only: bool = True) -> auto_confirm=False, memo=f"자동주문 - {item.get('product_name', '')}" ) + logger.info(f"[GEO DEBUG] full_order result: {result}") if result.get('success'): status = 'success' @@ -366,6 +375,11 @@ def submit_geoyoung_order(order: dict, dry_run: bool, cart_only: bool = True) -> ordered_codes = [r['internal_code'] for r in results if r['status'] == 'success' and r.get('internal_code')] + # 🔧 디버그: 선별 주문 전 상세 로그 + logger.info(f"[GEO DEBUG] 선별 주문 시작") + logger.info(f"[GEO DEBUG] ordered_codes: {ordered_codes}") + logger.info(f"[GEO DEBUG] results: {[(r.get('product_name', '')[:20], r.get('internal_code')) for r in results if r['status'] == 'success']}") + if ordered_codes: # 선별 주문: 기존 품목은 건드리지 않고, 이번에 담은 것만 주문 confirm_result = geo_session.submit_order_selective(ordered_codes) diff --git a/backend/order_db.py b/backend/order_db.py index 770d0a9..2c16b66 100644 --- a/backend/order_db.py +++ b/backend/order_db.py @@ -105,6 +105,7 @@ def init_db(): -- 약품 정보 drug_code TEXT NOT NULL, -- PIT3000 약품코드 kd_code TEXT, -- 보험코드 (지오영 검색용) + internal_code TEXT, -- 🔧 도매상 내부 코드 (장바구니 직접 추가용!) product_name TEXT NOT NULL, manufacturer TEXT, @@ -372,14 +373,15 @@ def create_order(wholesaler_id: str, items: List[Dict], cursor.execute(''' INSERT INTO order_items ( - order_id, drug_code, kd_code, product_name, manufacturer, + order_id, drug_code, kd_code, internal_code, product_name, manufacturer, specification, unit_qty, order_qty, total_dose, usage_qty, current_stock, status - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'pending') + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'pending') ''', ( order_id, item.get('drug_code'), item.get('kd_code'), + item.get('internal_code'), # 🔧 도매상 내부 코드 저장! item.get('product_name'), item.get('manufacturer'), item.get('specification'), diff --git a/backend/templates/admin_rx_usage.html b/backend/templates/admin_rx_usage.html index 6ef66df..0cc59ab 100644 --- a/backend/templates/admin_rx_usage.html +++ b/backend/templates/admin_rx_usage.html @@ -1464,6 +1464,10 @@ showToast(`📤 ${item.product_name} 주문 중...`, 'info'); + // 🔍 디버그: 장바구니 아이템 확인 + console.log('[DEBUG] orderSingleItem - cart item:', JSON.stringify(item, null, 2)); + console.log('[DEBUG] internal_code:', item.internal_code); + try { const payload = { wholesaler_id: wholesaler, @@ -1999,6 +2003,11 @@ baekje_code: wholesaler === 'baekje' ? item.internal_code : null }; + // 🔍 디버그: 장바구니 추가 시 internal_code 확인 + console.log('[DEBUG] addToCartFromWholesale'); + console.log('[DEBUG] wholesaler item:', JSON.stringify(item, null, 2)); + console.log('[DEBUG] cartItem internal_code:', cartItem.internal_code); + // 기존 항목 체크 (같은 도매상 + 같은 규격) const existing = cart.find(c => c.drug_code === currentWholesaleItem.drug_code && diff --git a/backend/test_api_debug.py b/backend/test_api_debug.py new file mode 100644 index 0000000..d1fb4e7 --- /dev/null +++ b/backend/test_api_debug.py @@ -0,0 +1,60 @@ +# -*- coding: utf-8 -*- +"""API 직접 테스트 - 디버그용""" +import requests +import json + +# 지오영에서 실제 품목 검색해서 internal_code 얻기 +import sys; sys.path.insert(0, '.'); import wholesale_path +from wholesale import GeoYoungSession + +g = GeoYoungSession() +g.login() + +# 재고 있는 품목 검색 +r = g.search_products('라식스') +if r.get('items'): + item = r['items'][0] + print("="*60) + print("검색된 품목:") + print(f" name: {item['name']}") + print(f" internal_code: {item['internal_code']}") + print(f" stock: {item['stock']}") + print(f" price: {item['price']}") + print("="*60) + + # API 호출 + payload = { + "wholesaler_id": "geoyoung", + "items": [{ + "drug_code": "652100200", + "kd_code": "라식스", + "internal_code": item['internal_code'], # 검색된 internal_code 사용 + "product_name": item['name'], + "manufacturer": "한독", + "specification": item.get('spec', ''), + "order_qty": 1, + "usage_qty": 100, + "current_stock": 0 + }], + "reference_period": "2026-02-01~2026-03-07", + "dry_run": False, + "cart_only": False + } + + print("\n" + "="*60) + print("API 요청:") + print(json.dumps(payload, ensure_ascii=False, indent=2)) + print("="*60) + + response = requests.post( + 'http://localhost:7001/api/order/quick-submit', + json=payload, + timeout=60 + ) + + print("\n" + "="*60) + print(f"응답 (status: {response.status_code}):") + print(json.dumps(response.json(), ensure_ascii=False, indent=2)) + print("="*60) +else: + print("품목을 찾을 수 없습니다") diff --git a/backend/test_baekje_ledger_api.py b/backend/test_baekje_ledger_api.py deleted file mode 100644 index b229c87..0000000 --- a/backend/test_baekje_ledger_api.py +++ /dev/null @@ -1,101 +0,0 @@ -# -*- coding: utf-8 -*- -"""백제약품 주문 원장 API 분석""" - -import json -import requests -from datetime import datetime, timedelta -import calendar - -# 저장된 토큰 로드 -TOKEN_FILE = r'c:\Users\청춘약국\source\pharmacy-wholesale-api\.baekje_token.json' -with open(TOKEN_FILE, 'r', encoding='utf-8') as f: - token_data = json.load(f) - -token = token_data['token'] -cust_cd = token_data['cust_cd'] - -print(f"Token expires: {datetime.fromtimestamp(token_data['expires'])}") -print(f"Customer code: {cust_cd}") - -# API 세션 설정 -session = requests.Session() -session.headers.update({ - 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36', - 'Accept': 'application/json, text/plain, */*', - 'Accept-Language': 'ko-KR,ko;q=0.9,en-US;q=0.8,en;q=0.7', - 'Origin': 'https://ibjp.co.kr', - 'Referer': 'https://ibjp.co.kr/', - 'Authorization': f'Bearer {token}' -}) - -API_URL = "https://www.ibjp.co.kr" - -# 1. 주문 원장 API 시도 - 다양한 엔드포인트 -endpoints = [ - '/ordLedger/listSearch', - '/ordLedger/list', - '/ord/ledgerList', - '/ord/ledgerSearch', - '/cust/ordLedger', - '/custOrd/ledgerList', - '/ordHist/listSearch', - '/ordHist/list', -] - -# 날짜 설정 (이번 달) -today = datetime.now() -year = today.year -month = today.month -_, last_day = calendar.monthrange(year, month) -from_date = f"{year}{month:02d}01" -to_date = f"{year}{month:02d}{last_day:02d}" - -print(f"\n조회 기간: {from_date} ~ {to_date}") -print("\n=== API 엔드포인트 탐색 ===\n") - -params = { - 'custCd': cust_cd, - 'startDt': from_date, - 'endDt': to_date, - 'stDate': from_date, - 'edDate': to_date, - 'year': str(year), - 'month': f"{month:02d}", -} - -for endpoint in endpoints: - try: - # GET 시도 - resp = session.get(f"{API_URL}{endpoint}", params=params, timeout=10) - print(f"GET {endpoint}: {resp.status_code}") - if resp.status_code == 200: - try: - data = resp.json() - print(f" -> JSON Response (first 500 chars): {str(data)[:500]}") - except: - print(f" -> Text (first 200 chars): {resp.text[:200]}") - except Exception as e: - print(f"GET {endpoint}: Error - {e}") - - try: - # POST 시도 - resp = session.post(f"{API_URL}{endpoint}", json=params, timeout=10) - print(f"POST {endpoint}: {resp.status_code}") - if resp.status_code == 200: - try: - data = resp.json() - print(f" -> JSON Response (first 500 chars): {str(data)[:500]}") - except: - print(f" -> Text (first 200 chars): {resp.text[:200]}") - except Exception as e: - print(f"POST {endpoint}: Error - {e}") - -# 2. 이미 알려진 API로 데이터 확인 -print("\n=== 알려진 API 테스트 ===\n") - -# 월간 잔고 조회 (이미 있는 함수에서 사용) -resp = session.get(f"{API_URL}/custMonth/listSearch", params={'custCd': cust_cd, 'year': str(year), 'endDt': to_date}, timeout=10) -print(f"custMonth/listSearch: {resp.status_code}") -if resp.status_code == 200: - data = resp.json() - print(f" -> {json.dumps(data, ensure_ascii=False, indent=2)[:1500]}") diff --git a/backend/test_baekje_ledger_api2.py b/backend/test_baekje_ledger_api2.py deleted file mode 100644 index eae0740..0000000 --- a/backend/test_baekje_ledger_api2.py +++ /dev/null @@ -1,126 +0,0 @@ -# -*- coding: utf-8 -*- -"""백제약품 주문 원장 API 분석 - 상세 탐색""" - -import json -import requests -from datetime import datetime -import calendar - -# 저장된 토큰 로드 -TOKEN_FILE = r'c:\Users\청춘약국\source\pharmacy-wholesale-api\.baekje_token.json' -with open(TOKEN_FILE, 'r', encoding='utf-8') as f: - token_data = json.load(f) - -token = token_data['token'] -cust_cd = token_data['cust_cd'] - -# API 세션 설정 -session = requests.Session() -session.headers.update({ - 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36', - 'Accept': 'application/json, text/plain, */*', - 'Accept-Language': 'ko-KR,ko;q=0.9,en-US;q=0.8,en;q=0.7', - 'Origin': 'https://ibjp.co.kr', - 'Referer': 'https://ibjp.co.kr/', - 'Authorization': f'Bearer {token}' -}) - -API_URL = "https://www.ibjp.co.kr" - -today = datetime.now() -year = today.year -month = today.month -_, last_day = calendar.monthrange(year, month) - -print("=== 주문 원장 API 탐색 (다양한 파라미터) ===\n") - -# 날짜 형식 변형 -date_formats = [ - {'startDt': f'{year}{month:02d}01', 'endDt': f'{year}{month:02d}{last_day:02d}'}, - {'stDt': f'{year}{month:02d}01', 'edDt': f'{year}{month:02d}{last_day:02d}'}, - {'fromDate': f'{year}-{month:02d}-01', 'toDate': f'{year}-{month:02d}-{last_day:02d}'}, - {'strDt': f'{year}{month:02d}01', 'endDt': f'{year}{month:02d}{last_day:02d}'}, - {'ordDt': f'{year}{month:02d}'}, -] - -endpoints = [ - '/ordLedger/listSearch', - '/ordLedger/search', - '/ordLedger/ledgerList', - '/cust/ordLedgerList', - '/cust/ledger', - '/ord/histList', - '/ord/history', - '/ord/list', -] - -for endpoint in endpoints: - for params in date_formats: - full_params = {**params, 'custCd': cust_cd} - try: - resp = session.get(f"{API_URL}{endpoint}", params=full_params, timeout=10) - if resp.status_code == 200: - print(f"✓ GET {endpoint} {params}: {resp.status_code}") - try: - data = resp.json() - print(f" -> {str(data)[:300]}") - except: - print(f" -> {resp.text[:200]}") - except Exception as e: - pass - - try: - resp = session.post(f"{API_URL}{endpoint}", json=full_params, timeout=10) - if resp.status_code == 200: - print(f"✓ POST {endpoint} {params}: {resp.status_code}") - try: - data = resp.json() - print(f" -> {str(data)[:300]}") - except: - print(f" -> {resp.text[:200]}") - except Exception as e: - pass - -print("\n=== 주문 이력 관련 API ===\n") - -# 주문 이력 조회 시도 -order_endpoints = [ - '/ord/ordList', - '/ord/orderHistory', - '/ordReg/list', - '/ordReg/history', - '/order/list', - '/order/history', -] - -for endpoint in order_endpoints: - try: - params = {'custCd': cust_cd, 'startDt': f'{year}{month:02d}01', 'endDt': f'{year}{month:02d}{last_day:02d}'} - resp = session.get(f"{API_URL}{endpoint}", params=params, timeout=10) - print(f"GET {endpoint}: {resp.status_code}") - if resp.status_code == 200: - try: - data = resp.json() - print(f" -> {str(data)[:500]}") - except: - print(f" -> {resp.text[:200]}") - except: - pass - -print("\n=== custMonth/listSearch 상세 데이터 분석 ===\n") - -# 이미 작동하는 API의 데이터 상세 분석 -resp = session.get(f"{API_URL}/custMonth/listSearch", params={'custCd': cust_cd, 'year': str(year), 'endDt': f'{year}{month:02d}{last_day:02d}'}, timeout=10) -if resp.status_code == 200: - data = resp.json() - print("월간 데이터 구조:") - for item in data: - print(f"\n월: {item.get('BALANCE_YM')}") - print(f" 매출액(SALE_AMT): {item.get('SALE_AMT'):,}") - print(f" 반품액(BACK_AMT): {item.get('BACK_AMT'):,}") - print(f" 순반품(PURE_BACK_AMT): {item.get('PURE_BACK_AMT'):,}") - print(f" 순매출(TOTAL_AMT): {item.get('TOTAL_AMT'):,}") - print(f" 입금액(PAY_CASH_AMT): {item.get('PAY_CASH_AMT'):,}") - print(f" 전월이월(PRE_TOTAL_AMT): {item.get('PRE_TOTAL_AMT'):,}") - print(f" 월말잔고(BALANCE_A_AMT): {item.get('BALANCE_A_AMT'):,}") - print(f" 회전일수(ROTATE_DAY): {item.get('ROTATE_DAY')}") diff --git a/backend/test_baekje_monthly_sales.py b/backend/test_baekje_monthly_sales.py deleted file mode 100644 index 83718e1..0000000 --- a/backend/test_baekje_monthly_sales.py +++ /dev/null @@ -1,84 +0,0 @@ -# -*- coding: utf-8 -*- -"""백제약품 get_monthly_sales() 테스트""" - -import os -import sys - -# wholesale 패키지 경로 추가 -sys.path.insert(0, r'c:\Users\청춘약국\source\pharmacy-wholesale-api') -os.chdir(r'c:\Users\청춘약국\source\pharmacy-pos-qr-system\backend') - -from dotenv import load_dotenv -load_dotenv() - -from wholesale import BaekjeSession - -def test_monthly_sales(): - print("=" * 60) - print("백제약품 월간 매출 조회 테스트") - print("=" * 60) - - session = BaekjeSession() - - # 현재 월 조회 - from datetime import datetime - now = datetime.now() - year = now.year - month = now.month - - print(f"\n1. 현재 월 ({year}-{month:02d}) 조회:") - result = session.get_monthly_sales(year, month) - print(f" Success: {result.get('success')}") - if result.get('success'): - print(f" 월간 매출: {result.get('total_amount'):,}원") - print(f" 월간 반품: {result.get('total_returns'):,}원") - print(f" 순매출: {result.get('net_amount'):,}원") - print(f" 월간 입금: {result.get('total_paid'):,}원") - print(f" 월말 잔고: {result.get('ending_balance'):,}원") - print(f" 전월이월: {result.get('prev_balance'):,}원") - print(f" 회전일수: {result.get('rotate_days')}") - print(f" 조회기간: {result.get('from_date')} ~ {result.get('to_date')}") - else: - print(f" Error: {result.get('error')}") - - # 전월 조회 - prev_month = month - 1 if month > 1 else 12 - prev_year = year if month > 1 else year - 1 - - print(f"\n2. 전월 ({prev_year}-{prev_month:02d}) 조회:") - result = session.get_monthly_sales(prev_year, prev_month) - print(f" Success: {result.get('success')}") - if result.get('success'): - print(f" 월간 매출: {result.get('total_amount'):,}원") - print(f" 월간 반품: {result.get('total_returns'):,}원") - print(f" 순매출: {result.get('net_amount'):,}원") - print(f" 월간 입금: {result.get('total_paid'):,}원") - print(f" 월말 잔고: {result.get('ending_balance'):,}원") - print(f" 전월이월: {result.get('prev_balance'):,}원") - print(f" 회전일수: {result.get('rotate_days')}") - print(f" 조회기간: {result.get('from_date')} ~ {result.get('to_date')}") - else: - print(f" Error: {result.get('error')}") - - # 2달 전 조회 - prev_month2 = prev_month - 1 if prev_month > 1 else 12 - prev_year2 = prev_year if prev_month > 1 else prev_year - 1 - - print(f"\n3. 2달 전 ({prev_year2}-{prev_month2:02d}) 조회:") - result = session.get_monthly_sales(prev_year2, prev_month2) - print(f" Success: {result.get('success')}") - if result.get('success'): - print(f" 월간 매출: {result.get('total_amount'):,}원") - print(f" 월간 반품: {result.get('total_returns'):,}원") - print(f" 순매출: {result.get('net_amount'):,}원") - print(f" 월간 입금: {result.get('total_paid'):,}원") - print(f" 월말 잔고: {result.get('ending_balance'):,}원") - else: - print(f" Error: {result.get('error')}") - - print("\n" + "=" * 60) - print("테스트 완료!") - print("=" * 60) - -if __name__ == '__main__': - test_monthly_sales() diff --git a/backend/test_bagjs.py b/backend/test_bagjs.py deleted file mode 100644 index 93cdf38..0000000 --- a/backend/test_bagjs.py +++ /dev/null @@ -1,16 +0,0 @@ -# -*- 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 deleted file mode 100644 index afcabcb..0000000 --- a/backend/test_bagjs2.py +++ /dev/null @@ -1,18 +0,0 @@ -# -*- 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 deleted file mode 100644 index 7877922..0000000 --- a/backend/test_bagjs3.py +++ /dev/null @@ -1,16 +0,0 @@ -# -*- 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 deleted file mode 100644 index 510ccda..0000000 --- a/backend/test_bagjs4.py +++ /dev/null @@ -1,19 +0,0 @@ -# -*- 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 deleted file mode 100644 index 09b5044..0000000 --- a/backend/test_cancel.py +++ /dev/null @@ -1,46 +0,0 @@ -# -*- 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 deleted file mode 100644 index cf3f6e3..0000000 --- a/backend/test_cart.py +++ /dev/null @@ -1,60 +0,0 @@ -# -*- 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 deleted file mode 100644 index 4a12c32..0000000 --- a/backend/test_cart_api.py +++ /dev/null @@ -1,114 +0,0 @@ -# -*- 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 deleted file mode 100644 index aa73dee..0000000 --- a/backend/test_cart_debug.py +++ /dev/null @@ -1,44 +0,0 @@ -# -*- 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 deleted file mode 100644 index bdee6bf..0000000 --- a/backend/test_cart_list.py +++ /dev/null @@ -1,127 +0,0 @@ -# -*- 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_checkbox.py b/backend/test_checkbox.py new file mode 100644 index 0000000..530140d --- /dev/null +++ b/backend/test_checkbox.py @@ -0,0 +1,52 @@ +# -*- coding: utf-8 -*- +import sys; sys.path.insert(0, '.'); import wholesale_path +from wholesale import SooinSession +from bs4 import BeautifulSoup +import re + +s = SooinSession() +s.login() +s.clear_cart() + +result = s.search_products('코자정') +product = result['items'][0] +s.add_to_cart(product['internal_code'], qty=1, price=product['price'], stock=product['stock']) + +resp = s.session.get(f'{s.BAG_VIEW_URL}?currVenCd={s.vendor_code}', timeout=15) +soup = BeautifulSoup(resp.content, 'html.parser') +form = soup.find('form', {'id': 'frmBag'}) + +form_data = {} +for inp in form.find_all('input'): + name = inp.get('name', '') + if not name: continue + inp_type = inp.get('type', '').lower() + if inp_type == 'checkbox': + # 체크박스는 'on' 값으로 전송! + form_data[name] = 'on' + else: + form_data[name] = inp.get('value', '') + +form_data['kind'] = 'order' +form_data['x'] = '10' +form_data['y'] = '10' + +print('체크박스 포함된 form_data:') +print(f" chk_0: {form_data.get('chk_0')}") + +resp = s.session.post(s.BAG_URL, data=form_data, timeout=30) +alert_match = re.search(r'alert\("([^"]*)"\)', resp.text) +alert_msg = alert_match.group(1) if alert_match else 'N/A' +print(f'alert 메시지: {alert_msg}') + +# 장바구니 확인 +resp2 = s.session.get(f'{s.BAG_VIEW_URL}?currVenCd={s.vendor_code}', timeout=15) +soup2 = BeautifulSoup(resp2.content, 'html.parser') +int_array = soup2.find('input', {'name': 'intArray'}) +val = int_array.get('value') if int_array else '없음' +print(f'주문 후 intArray: {val}') + +if val == '-1': + print('\n🎉 주문 성공!') +else: + print('\n❌ 주문 실패') diff --git a/backend/test_checkbox_html.py b/backend/test_checkbox_html.py new file mode 100644 index 0000000..864849b --- /dev/null +++ b/backend/test_checkbox_html.py @@ -0,0 +1,42 @@ +# -*- coding: utf-8 -*- +"""체크박스 HTML 상태 확인""" +import sys; sys.path.insert(0, '.'); import wholesale_path +from wholesale import SooinSession +from bs4 import BeautifulSoup +import re + +s = SooinSession() +s.login() +s.clear_cart() + +# 품목 담기 +r1 = s.search_products('코자정') +p1 = r1['items'][0] +s.add_to_cart(p1['internal_code'], qty=1, price=p1['price'], stock=p1['stock']) + +# 취소하기 전 HTML +print('=== 취소 전 HTML ===') +resp = s.session.get(f'{s.BAG_VIEW_URL}?currVenCd={s.vendor_code}', timeout=15) +soup = BeautifulSoup(resp.content, 'html.parser') +for cb in soup.find_all('input', {'type': 'checkbox'}): + name = cb.get('name', '') + checked = cb.get('checked') + print(f"체크박스 {name}: checked={checked}") + +# 취소 +print('\n=== 취소 실행 ===') +s.cancel_item(row_index=0) + +# 취소 후 HTML +print('\n=== 취소 후 HTML ===') +resp2 = s.session.get(f'{s.BAG_VIEW_URL}?currVenCd={s.vendor_code}', timeout=15) +soup2 = BeautifulSoup(resp2.content, 'html.parser') +for cb in soup2.find_all('input', {'type': 'checkbox'}): + name = cb.get('name', '') + checked = cb.get('checked') + print(f"체크박스 {name}: checked={checked}") + +# 체크박스 HTML 전체 출력 +cb = soup2.find('input', {'type': 'checkbox'}) +if cb: + print(f"\n전체 HTML: {cb}") diff --git a/backend/test_checkbox_logic.py b/backend/test_checkbox_logic.py new file mode 100644 index 0000000..c561344 --- /dev/null +++ b/backend/test_checkbox_logic.py @@ -0,0 +1,58 @@ +# -*- coding: utf-8 -*- +"""체크박스 로직 테스트 - 체크 안 함 vs 체크함""" +import sys; sys.path.insert(0, '.'); import wholesale_path +from wholesale import SooinSession +from bs4 import BeautifulSoup +import re + +s = SooinSession() +s.login() +s.clear_cart() + +# 장바구니에 품목 추가 +result = s.search_products('코자정') +product = result['items'][0] +s.add_to_cart(product['internal_code'], qty=1, price=product['price'], stock=product['stock']) + +print("="*60) +print("테스트 1: 체크박스 제외 (체크 안 함 = 주문 포함)") +print("="*60) + +resp = s.session.get(f'{s.BAG_VIEW_URL}?currVenCd={s.vendor_code}', timeout=15) +soup = BeautifulSoup(resp.content, 'html.parser') +form = soup.find('form', {'id': 'frmBag'}) + +form_data = {} +for inp in form.find_all('input'): + name = inp.get('name', '') + if not name: continue + inp_type = inp.get('type', '').lower() + if inp_type == 'checkbox': + continue # 체크박스 제외! + form_data[name] = inp.get('value', '') + +print(f"chk_0 전송됨? {'chk_0' in form_data}") +print(f"intArray: {form_data.get('intArray')}") + +resp = s.session.post( + s.ORDER_END_URL, + data=form_data, + headers={'Content-Type': 'application/x-www-form-urlencoded'}, + timeout=30 +) + +alert_match = re.search(r'alert\("([^"]*)"\)', resp.text) +alert_msg = alert_match.group(1) if alert_match else '' +print(f"응답 alert: '{alert_msg}'") + +# 장바구니 확인 +resp2 = s.session.get(f'{s.BAG_VIEW_URL}?currVenCd={s.vendor_code}', timeout=15) +soup2 = BeautifulSoup(resp2.content, 'html.parser') +int_array = soup2.find('input', {'name': 'intArray'}) +val = int_array.get('value') if int_array else 'N/A' +print(f"주문 후 intArray: {val}") + +if '주문이 완료' in alert_msg: + print("✅ 성공!") +else: + print("❌ 실패") diff --git a/backend/test_datacart.py b/backend/test_datacart.py deleted file mode 100644 index 52124b9..0000000 --- a/backend/test_datacart.py +++ /dev/null @@ -1,74 +0,0 @@ -# -*- 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 deleted file mode 100644 index cd49680..0000000 --- a/backend/test_datacart2.py +++ /dev/null @@ -1,83 +0,0 @@ -# -*- 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 deleted file mode 100644 index fdd7802..0000000 --- a/backend/test_datacart3.py +++ /dev/null @@ -1,105 +0,0 @@ -# -*- 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 deleted file mode 100644 index 6791489..0000000 --- a/backend/test_dataorder.py +++ /dev/null @@ -1,101 +0,0 @@ -# -*- 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_debug_submit.py b/backend/test_debug_submit.py new file mode 100644 index 0000000..d9924db --- /dev/null +++ b/backend/test_debug_submit.py @@ -0,0 +1,51 @@ +# -*- coding: utf-8 -*- +"""submit_order 디버깅""" +import sys; sys.path.insert(0, '.'); import wholesale_path +from bs4 import BeautifulSoup +import re + +import importlib +import wholesale.sooin +importlib.reload(wholesale.sooin) +from wholesale import SooinSession + +SooinSession._instance = None +s = SooinSession() +s.login() +s.clear_cart() + +# 품목 담기 +r1 = s.search_products('코자정') +s.add_to_cart(r1['items'][0]['internal_code'], qty=1, price=r1['items'][0]['price'], stock=r1['items'][0]['stock']) + +# 취소 +s.cancel_item(row_index=0) + +# Bag.asp GET +print('=== Bag.asp GET 후 form 분석 ===') +resp = s.session.get(f'{s.BAG_VIEW_URL}?currVenCd={s.vendor_code}', timeout=15) +soup = BeautifulSoup(resp.content, 'html.parser') +form = soup.find('form', {'id': 'frmBag'}) + +form_data = {} +for inp in form.find_all('input'): + name = inp.get('name', '') + if not name: + continue + + inp_type = inp.get('type', 'text').lower() + + if inp_type == 'checkbox': + checked = inp.get('checked') + print(f"체크박스 {name}: checked={checked}, type={type(checked)}") + + if checked is not None: + form_data[name] = 'on' + print(f" → form_data['{name}'] = 'on' (취소됨, 제외)") + else: + print(f" → 안 보냄 (활성, 포함)") + continue + + form_data[name] = inp.get('value', '') + +print(f"\n체크박스 관련 form_data: {[(k,v) for k,v in form_data.items() if 'chk' in k]}") diff --git a/backend/test_del.py b/backend/test_del.py deleted file mode 100644 index 7e16345..0000000 --- a/backend/test_del.py +++ /dev/null @@ -1,32 +0,0 @@ -# -*- 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 deleted file mode 100644 index 9c2f45e..0000000 --- a/backend/test_del2.py +++ /dev/null @@ -1,26 +0,0 @@ -# -*- 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 deleted file mode 100644 index a236313..0000000 --- a/backend/test_del3.py +++ /dev/null @@ -1,25 +0,0 @@ -# -*- 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 deleted file mode 100644 index daae993..0000000 --- a/backend/test_del_chk.py +++ /dev/null @@ -1,29 +0,0 @@ -# -*- 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 deleted file mode 100644 index 74f756d..0000000 --- a/backend/test_del_html.py +++ /dev/null @@ -1,21 +0,0 @@ -# -*- 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 deleted file mode 100644 index c181017..0000000 --- a/backend/test_del_one.py +++ /dev/null @@ -1,38 +0,0 @@ -# -*- 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 deleted file mode 100644 index 095c7a2..0000000 --- a/backend/test_del_pc.py +++ /dev/null @@ -1,33 +0,0 @@ -# -*- 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 deleted file mode 100644 index 81968ec..0000000 --- a/backend/test_del_post.py +++ /dev/null @@ -1,26 +0,0 @@ -# -*- 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 deleted file mode 100644 index e654121..0000000 --- a/backend/test_encoding.py +++ /dev/null @@ -1,39 +0,0 @@ -# -*- 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 deleted file mode 100644 index 06a660d..0000000 --- a/backend/test_flask_api.py +++ /dev/null @@ -1,25 +0,0 @@ -# -*- 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_geo_api_compare.py b/backend/test_geo_api_compare.py new file mode 100644 index 0000000..0926755 --- /dev/null +++ b/backend/test_geo_api_compare.py @@ -0,0 +1,49 @@ +# -*- coding: utf-8 -*- +"""지오영 full_order vs quick_order 비교 테스트""" +import sys; sys.path.insert(0, '.'); import wholesale_path + +import importlib +import wholesale.geoyoung +importlib.reload(wholesale.geoyoung) +from wholesale import GeoYoungSession + +GeoYoungSession._instance = None +g = GeoYoungSession() +g.login() +g.clear_cart() + +print('='*60) +print('테스트 1: quick_order 직접 호출') +print('='*60) + +result1 = g.quick_order( + kd_code='라식스', + quantity=1, + spec=None, + check_stock=True +) +print(f"결과: {result1}") + +print('\n' + '='*60) +print('테스트 2: full_order 호출 (auto_confirm=False)') +print('='*60) + +g.clear_cart() + +result2 = g.full_order( + kd_code='코자정', + quantity=1, + specification=None, + check_stock=True, + auto_confirm=False # 장바구니만 +) +print(f"결과: {result2}") + +print('\n' + '='*60) +print('장바구니 확인') +print('='*60) + +cart = g.get_cart() +print(f"장바구니: {cart['total_items']}개") +for item in cart['items']: + print(f" - {item['product_name']}") diff --git a/backend/test_geo_cart_debug.py b/backend/test_geo_cart_debug.py new file mode 100644 index 0000000..d7c5e88 --- /dev/null +++ b/backend/test_geo_cart_debug.py @@ -0,0 +1,60 @@ +# -*- coding: utf-8 -*- +""" +지오영 장바구니 키 매칭 디버그 테스트 +- 장바구니 조회 시 반환되는 키와 add_to_cart 시 사용하는 키가 일치하는지 확인 +""" + +import sys +sys.path.insert(0, r'c:\Users\청춘약국\source\pharmacy-wholesale-api') + +from wholesale.geoyoung import GeoYoungSession + +def test_cart_keys(): + """장바구니 항목의 키 확인""" + session = GeoYoungSession() + + print("=" * 60) + print("지오영 장바구니 키 매칭 디버그") + print("=" * 60) + + # 로그인 + if not session.login(): + print("❌ 로그인 실패") + return + + print("✅ 로그인 성공") + + # 현재 장바구니 조회 + cart = session.get_cart() + + print(f"\n📦 장바구니 조회 결과:") + print(f" - success: {cart.get('success')}") + print(f" - total_items: {cart.get('total_items')}") + print(f" - total_amount: {cart.get('total_amount'):,}원") + + if not cart.get('items'): + print("\n⚠️ 장바구니가 비어있습니다!") + return + + print(f"\n📋 장바구니 항목 상세:") + for i, item in enumerate(cart.get('items', [])): + print(f"\n [{i}] {item.get('product_name')}") + print(f" - row_index: {item.get('row_index')}") + print(f" - product_code: {item.get('product_code')}") + print(f" - internal_code: {item.get('internal_code')}") + print(f" - center: {item.get('center')}") + print(f" - quantity: {item.get('quantity')}") + print(f" - unit_price: {item.get('unit_price'):,}원") + print(f" - amount: {item.get('amount'):,}원") + print(f" - active: {item.get('active')}") + + # 키 확인 + code = item.get('product_code') or item.get('internal_code') + print(f" → 사용될 키: {code}") + + print("\n" + "=" * 60) + print("💡 submit_order_selective()에서 사용하는 키가 위 'product_code'와 일치해야 합니다!") + print("=" * 60) + +if __name__ == "__main__": + test_cart_keys() diff --git a/backend/test_geo_cart_keys.py b/backend/test_geo_cart_keys.py new file mode 100644 index 0000000..cca4667 --- /dev/null +++ b/backend/test_geo_cart_keys.py @@ -0,0 +1,16 @@ +# -*- coding: utf-8 -*- +"""지오영 장바구니 아이템 키 확인""" +import sys; sys.path.insert(0, '.'); import wholesale_path +from wholesale import GeoYoungSession +import json + +g = GeoYoungSession() +g.login() + +cart = g.get_cart() +print(f"장바구니: {cart['total_items']}개\n") + +if cart['items']: + print("첫 번째 아이템 키:") + item = cart['items'][0] + print(json.dumps(item, indent=2, ensure_ascii=False, default=str)) diff --git a/backend/test_geo_clear.py b/backend/test_geo_clear.py new file mode 100644 index 0000000..0b29a0e --- /dev/null +++ b/backend/test_geo_clear.py @@ -0,0 +1,60 @@ +# -*- coding: utf-8 -*- +""" +지오영 clear_cart API 테스트 +""" +import sys +sys.path.insert(0, r'c:\Users\청춘약국\source\pharmacy-wholesale-api') + +from dotenv import load_dotenv +load_dotenv(r'c:\Users\청춘약국\source\pharmacy-wholesale-api\.env') + +from wholesale.geoyoung import GeoYoungSession + +def test_clear_cart(): + session = GeoYoungSession() + + print("=" * 60) + print("지오영 clear_cart API 테스트") + print("=" * 60) + + # 1. 로그인 + if not session.login(): + print("❌ 로그인 실패") + return + print("✅ 로그인 성공\n") + + # 2. 현재 장바구니 조회 + print("📦 [BEFORE] 장바구니 조회:") + cart = session.get_cart() + print(f" - 성공: {cart.get('success')}") + print(f" - 품목 수: {cart.get('total_items')}") + for item in cart.get('items', []): + print(f" • {item.get('product_name')} (code: {item.get('product_code')}, qty: {item.get('quantity')})") + + if not cart.get('items'): + print("\n⚠️ 장바구니가 이미 비어있어요! 테스트를 위해 뭔가 담아주세요.") + return + + # 3. clear_cart 호출 + print("\n🗑️ clear_cart() 호출...") + clear_result = session.clear_cart() + print(f" - 결과: {clear_result}") + + # 4. 다시 장바구니 조회 + import time + time.sleep(1) # 서버 처리 대기 + + print("\n📦 [AFTER] 장바구니 조회:") + cart_after = session.get_cart() + print(f" - 성공: {cart_after.get('success')}") + print(f" - 품목 수: {cart_after.get('total_items')}") + for item in cart_after.get('items', []): + print(f" • {item.get('product_name')} (code: {item.get('product_code')})") + + if not cart_after.get('items'): + print("\n✅ clear_cart 성공! 장바구니가 비워졌습니다.") + else: + print(f"\n❌ clear_cart 실패! 아직 {len(cart_after.get('items', []))}개 품목 남아있음") + +if __name__ == "__main__": + test_clear_cart() diff --git a/backend/test_geo_clear_button.py b/backend/test_geo_clear_button.py new file mode 100644 index 0000000..c0e4a17 --- /dev/null +++ b/backend/test_geo_clear_button.py @@ -0,0 +1,136 @@ +# -*- coding: utf-8 -*- +""" +지오영 "전체삭제" 버튼 분석 - Playwright로 실제 버튼 클릭 시 API 확인 +""" +import asyncio +import os +from dotenv import load_dotenv +load_dotenv(r'c:\Users\청춘약국\source\pharmacy-wholesale-api\.env') + +async def analyze_delete_all(): + from playwright.async_api import async_playwright + + username = os.getenv('GEOYOUNG_USER_ID') + password = os.getenv('GEOYOUNG_PASSWORD') + + if not username or not password: + print("❌ GEOYOUNG_USER_ID, GEOYOUNG_PASSWORD 환경변수 필요") + return + + print("=" * 60) + print("지오영 '전체삭제' 버튼 분석") + print("=" * 60) + + async with async_playwright() as p: + browser = await p.chromium.launch(headless=False) # 보이게 + page = await browser.new_page() + + # 네트워크 요청 감시 + requests_log = [] + + def log_request(request): + if 'Cart' in request.url or 'cart' in request.url or 'del' in request.url.lower(): + requests_log.append({ + 'method': request.method, + 'url': request.url, + 'post_data': request.post_data + }) + print(f"🔗 {request.method} {request.url}") + if request.post_data: + print(f" POST data: {request.post_data}") + + page.on('request', log_request) + + # 1. 로그인 + print("\n1️⃣ 로그인 중...") + await page.goto("https://gwn.geoweb.kr/Member/Login") + await page.fill('input[type="text"]', username) + await page.fill('input[type="password"]', password) + await page.click('button[type="submit"], input[type="submit"], .btn-login') + await page.wait_for_load_state('networkidle', timeout=15000) + print("✅ 로그인 완료") + + # 2. 주문 페이지로 이동 (장바구니 표시됨) + print("\n2️⃣ 주문 페이지로 이동...") + await page.goto("https://gwn.geoweb.kr/Home/Order") + await page.wait_for_load_state('networkidle', timeout=15000) + + # 3. 장바구니 확인 + print("\n3️⃣ 장바구니 확인...") + await asyncio.sleep(2) + + # 스크린샷 + await page.screenshot(path='geo_cart_before.png') + print("📸 스크린샷 저장: geo_cart_before.png") + + # 4. "전체삭제" 버튼 찾기 + print("\n4️⃣ '전체삭제' 버튼 찾기...") + + # 가능한 선택자들 + selectors = [ + 'button:has-text("전체삭제")', + 'a:has-text("전체삭제")', + '.btn-del-all', + '#btnDelAll', + '[onclick*="delAll"]', + 'button:has-text("전체 삭제")', + 'button:has-text("삭제")', + ] + + delete_btn = None + for sel in selectors: + try: + btn = page.locator(sel).first + if await btn.count() > 0: + print(f" ✅ 버튼 발견: {sel}") + # 버튼의 HTML 확인 + btn_html = await btn.evaluate('el => el.outerHTML') + print(f" HTML: {btn_html[:200]}...") + delete_btn = btn + break + except: + pass + + if not delete_btn: + print(" ❌ 전체삭제 버튼을 찾지 못함") + # 페이지 HTML에서 삭제 관련 요소 검색 + html = await page.content() + if '전체삭제' in html or 'delAll' in html: + print(" ⚠️ 페이지에 관련 텍스트는 있음. 수동 확인 필요") + await browser.close() + return + + # 5. 버튼 클릭 (네트워크 요청 감시) + print("\n5️⃣ '전체삭제' 버튼 클릭...") + requests_log.clear() + + try: + await delete_btn.click() + await asyncio.sleep(2) + + # confirm 다이얼로그 처리 + page.on('dialog', lambda dialog: asyncio.create_task(dialog.accept())) + + await page.wait_for_load_state('networkidle', timeout=5000) + except Exception as e: + print(f" ⚠️ 클릭 중 오류: {e}") + + # 6. 캡처된 요청 출력 + print("\n6️⃣ 캡처된 API 요청:") + for req in requests_log: + print(f" 📤 {req['method']} {req['url']}") + if req['post_data']: + print(f" Body: {req['post_data']}") + + # 스크린샷 + await page.screenshot(path='geo_cart_after.png') + print("\n📸 스크린샷 저장: geo_cart_after.png") + + print("\n잠시 대기 (확인용)...") + await asyncio.sleep(5) + + await browser.close() + print("\n✅ 완료") + +if __name__ == "__main__": + asyncio.run(analyze_delete_all()) diff --git a/backend/test_geo_clear_new.py b/backend/test_geo_clear_new.py new file mode 100644 index 0000000..c50afa2 --- /dev/null +++ b/backend/test_geo_clear_new.py @@ -0,0 +1,69 @@ +# -*- coding: utf-8 -*- +""" +지오영 clear_cart (개선된 버전) 테스트 +""" +import sys +sys.path.insert(0, r'c:\Users\청춘약국\source\pharmacy-wholesale-api') + +# 싱글톤 초기화 강제 +import importlib +import wholesale.geoyoung +importlib.reload(wholesale.geoyoung) + +from dotenv import load_dotenv +load_dotenv(r'c:\Users\청춘약국\source\pharmacy-wholesale-api\.env') + +from wholesale.geoyoung import GeoYoungSession + +def test_clear_cart_new(): + # 싱글톤 리셋 + GeoYoungSession._instance = None + session = GeoYoungSession() + + print("=" * 60) + print("지오영 clear_cart (개선된 버전) 테스트") + print("=" * 60) + + # 1. 로그인 + if not session.login(): + print("❌ 로그인 실패") + return + print("✅ 로그인 성공\n") + + # 2. 제품 추가 (테스트용) + print("📦 테스트용 제품 추가 중...") + add_result = session.add_to_cart('033133', 1) # 코자르탄 + print(f" 결과: {add_result}") + + import time + time.sleep(1) + + # 3. 장바구니 확인 + print("\n📦 [BEFORE] 장바구니:") + cart = session.get_cart() + print(f" 품목 수: {cart.get('total_items')}") + for item in cart.get('items', []): + print(f" • {item.get('product_name')} (code: {item.get('product_code')})") + + if not cart.get('items'): + print(" ⚠️ 장바구니 비어있음. 제품 추가 실패") + return + + # 4. clear_cart 호출 + print("\n🗑️ clear_cart() 호출 (개선된 버전)...") + clear_result = session.clear_cart() + print(f" 결과: {clear_result}") + + # 5. 다시 확인 + time.sleep(1) + print("\n📦 [AFTER] 장바구니:") + cart_after = session.get_cart() + print(f" 품목 수: {cart_after.get('total_items')}") + + if not cart_after.get('items'): + print("\n✅ clear_cart 성공!") + else: + print(f"\n❌ clear_cart 실패! 아직 {len(cart_after.get('items', []))}개 남아있음") + +if __name__ == "__main__": + test_clear_cart_new() diff --git a/backend/test_geo_debug.py b/backend/test_geo_debug.py new file mode 100644 index 0000000..a584bb1 --- /dev/null +++ b/backend/test_geo_debug.py @@ -0,0 +1,13 @@ +# -*- coding: utf-8 -*- +import sys; sys.path.insert(0, '.'); import wholesale_path +from wholesale import GeoYoungSession +import json + +g = GeoYoungSession() +g.login() + +r = g.search_products('라식스') +if r.get('items'): + item = r['items'][0] + print("첫 번째 품목 전체 데이터:") + print(json.dumps(item, indent=2, ensure_ascii=False, default=str)) diff --git a/backend/test_geo_delete.py b/backend/test_geo_delete.py new file mode 100644 index 0000000..2b88398 --- /dev/null +++ b/backend/test_geo_delete.py @@ -0,0 +1,64 @@ +# -*- coding: utf-8 -*- +""" +지오영 개별 삭제 API 테스트 +""" +import sys +sys.path.insert(0, r'c:\Users\청춘약국\source\pharmacy-wholesale-api') + +from dotenv import load_dotenv +load_dotenv(r'c:\Users\청춘약국\source\pharmacy-wholesale-api\.env') + +from wholesale.geoyoung import GeoYoungSession + +def test_delete_item(): + session = GeoYoungSession() + + print("=" * 60) + print("지오영 개별 삭제(cancel_item) API 테스트") + print("=" * 60) + + # 1. 로그인 + if not session.login(): + print("❌ 로그인 실패") + return + print("✅ 로그인 성공\n") + + # 2. 현재 장바구니 조회 + print("📦 [BEFORE] 장바구니:") + cart = session.get_cart() + print(f" 품목 수: {cart.get('total_items')}") + + items = cart.get('items', []) + if not items: + print(" ⚠️ 장바구니 비어있음") + return + + for item in items: + print(f" • {item.get('product_name')} (code: {item.get('product_code')})") + + # 3. 첫 번째 항목 삭제 + first_item = items[0] + product_code = first_item.get('product_code') + print(f"\n🗑️ 첫 번째 항목 삭제 시도: {product_code}") + + del_result = session.cancel_item(product_code=product_code) + print(f" 결과: {del_result}") + + # 4. 다시 장바구니 조회 + import time + time.sleep(1) + + print("\n📦 [AFTER] 장바구니:") + cart_after = session.get_cart() + print(f" 품목 수: {cart_after.get('total_items')}") + + for item in cart_after.get('items', []): + print(f" • {item.get('product_name')} (code: {item.get('product_code')})") + + if len(cart_after.get('items', [])) < len(items): + print("\n✅ cancel_item 성공!") + else: + print("\n❌ cancel_item 실패!") + +if __name__ == "__main__": + test_delete_item() diff --git a/backend/test_geo_html.py b/backend/test_geo_html.py new file mode 100644 index 0000000..d97967b --- /dev/null +++ b/backend/test_geo_html.py @@ -0,0 +1,145 @@ +# -*- coding: utf-8 -*- +""" +지오영 장바구니 HTML 분석 및 삭제 버튼 API 캡처 +""" +import asyncio +import os +import re +from dotenv import load_dotenv +load_dotenv(r'c:\Users\청춘약국\source\pharmacy-wholesale-api\.env') + +async def analyze_cart_html(): + from playwright.async_api import async_playwright + + username = os.getenv('GEOYOUNG_USER_ID') + password = os.getenv('GEOYOUNG_PASSWORD') + + print("=" * 60) + print("지오영 장바구니 HTML 분석") + print("=" * 60) + + async with async_playwright() as p: + browser = await p.chromium.launch(headless=True) + context = await browser.new_context() + page = await context.new_page() + + # 1. 로그인 + print("\n1️⃣ 로그인 중...") + await page.goto("https://gwn.geoweb.kr/Member/Login") + await page.wait_for_load_state('networkidle') + + # 로그인 폼 찾기 + await page.fill('input[type="text"], input[name*="id"], #userId', username) + await page.fill('input[type="password"], input[name*="pw"], #userPwd', password) + + # 로그인 버튼 클릭 + login_btns = ['button[type="submit"]', 'input[type="submit"]', '.btn-login', 'button:has-text("로그인")'] + for btn_sel in login_btns: + try: + btn = page.locator(btn_sel).first + if await btn.count() > 0: + await btn.click() + break + except: + pass + + await asyncio.sleep(3) + await page.wait_for_load_state('networkidle') + + # 로그인 확인 + if 'Login' in page.url: + print("❌ 로그인 실패") + await browser.close() + return + + print(f"✅ 로그인 성공 (URL: {page.url})") + + # 2. 장바구니 HTML 가져오기 (AJAX) + print("\n2️⃣ 장바구니 HTML 가져오기...") + + # PartialProductCart API 직접 호출 + cart_response = await page.evaluate(''' + async () => { + const response = await fetch('/Home/PartialProductCart', { + method: 'POST', + headers: {'X-Requested-With': 'XMLHttpRequest'} + }); + return await response.text(); + } + ''') + + print(f"\n📦 장바구니 HTML 길이: {len(cart_response)} bytes") + + # 삭제 관련 키워드 찾기 + patterns = [ + r'onclick="[^"]*del[^"]*"', + r'onclick="[^"]*Del[^"]*"', + r'onclick="[^"]*삭제[^"]*"', + r'class="[^"]*del[^"]*"', + r'id="[^"]*del[^"]*"', + r'function\s+\w*[dD]el\w*\s*\(', + r'전체\s*삭제', + r'delAll', + r'deleteAll', + ] + + print("\n🔍 삭제 관련 패턴 검색:") + for pattern in patterns: + matches = re.findall(pattern, cart_response, re.IGNORECASE) + if matches: + for m in matches[:3]: # 최대 3개만 + print(f" ✅ {pattern}: {m[:100]}...") + + # 버튼 요소 찾기 + print("\n🔘 버튼 요소:") + button_pattern = r']*>.*?|]*class="[^"]*btn[^"]*"[^>]*>.*?' + buttons = re.findall(button_pattern, cart_response, re.DOTALL | re.IGNORECASE) + for btn in buttons[:10]: + clean_btn = re.sub(r'\s+', ' ', btn)[:150] + print(f" • {clean_btn}") + + # JavaScript 함수 찾기 + print("\n📜 JavaScript 함수 (del/remove 관련):") + js_pattern = r'function\s+(\w*[dD]el\w*|\w*[rR]emove\w*)\s*\([^)]*\)\s*\{[^}]*\}' + js_funcs = re.findall(js_pattern, cart_response) + for func in js_funcs[:5]: + print(f" • {func}") + + # 전체 스크립트 태그에서 DataCart 관련 찾기 + print("\n🔧 DataCart API 호출 패턴:") + datacart_pattern = r'DataCart[^"\']*' + datacart_matches = re.findall(datacart_pattern, cart_response) + for m in set(datacart_matches): + print(f" • {m}") + + # 페이지 전체 HTML에서도 검색 + print("\n3️⃣ 메인 페이지에서 추가 검색...") + await page.goto("https://gwn.geoweb.kr/Home/Order") + await page.wait_for_load_state('networkidle') + await asyncio.sleep(2) + + full_html = await page.content() + + # DataCart 관련 전체 검색 + print("\n🔧 전체 페이지 DataCart API:") + datacart_all = re.findall(r'/Home/DataCart/\w+', full_html) + for api in set(datacart_all): + print(f" • {api}") + + # 삭제 함수 찾기 + print("\n📜 삭제 관련 함수:") + del_funcs = re.findall(r'function\s+(\w*[dD]el\w*)\s*\(', full_html) + for func in set(del_funcs): + print(f" • {func}()") + + # 삭제 onclick 찾기 + print("\n🖱️ 삭제 onclick:") + del_onclick = re.findall(r'onclick="([^"]*[dD]el[^"]*)"', full_html) + for onclick in set(del_onclick)[:5]: + print(f" • {onclick[:100]}") + + await browser.close() + print("\n✅ 분석 완료") + +if __name__ == "__main__": + asyncio.run(analyze_cart_html()) diff --git a/backend/test_geo_search.py b/backend/test_geo_search.py new file mode 100644 index 0000000..b3070d9 --- /dev/null +++ b/backend/test_geo_search.py @@ -0,0 +1,15 @@ +# -*- coding: utf-8 -*- +import sys; sys.path.insert(0, '.'); import wholesale_path +from wholesale import GeoYoungSession + +g = GeoYoungSession() +g.login() + +for keyword in ['라식스', '코자정', '아스피린']: + r = g.search_products(keyword) + print(f"\n{keyword} 검색:") + if r.get('items'): + for item in r['items'][:2]: + print(f" {item['name'][:30]} | code: {item.get('product_code', '?')}") + else: + print(" 없음") diff --git a/backend/test_geo_search2.py b/backend/test_geo_search2.py new file mode 100644 index 0000000..907e505 --- /dev/null +++ b/backend/test_geo_search2.py @@ -0,0 +1,14 @@ +# -*- coding: utf-8 -*- +import sys; sys.path.insert(0, '.'); import wholesale_path +from wholesale import GeoYoungSession + +g = GeoYoungSession() +g.login() + +for kw in ['베아제', '신신파스', '마그밀', '활명수', '트라스트', '카베진']: + r = g.search_products(kw) + if r.get('items'): + item = r['items'][0] + print(f"{kw}: {item['name'][:30]} (code: {item.get('internal_code')})") + else: + print(f"{kw}: 없음") diff --git a/backend/test_geo_selective.py b/backend/test_geo_selective.py new file mode 100644 index 0000000..510a49a --- /dev/null +++ b/backend/test_geo_selective.py @@ -0,0 +1,64 @@ +# -*- coding: utf-8 -*- +"""지오영 선별 주문 테스트""" +import sys; sys.path.insert(0, '.'); import wholesale_path + +import importlib +import wholesale.geoyoung +importlib.reload(wholesale.geoyoung) +from wholesale import GeoYoungSession + +GeoYoungSession._instance = None +g = GeoYoungSession() +g.login() +g.clear_cart() + +# 재고 있는 품목 검색 +print('=== 1. 재고 확인 ===') +r1 = g.search_products('라식스') +r2 = g.search_products('코자정') + +if not r1.get('items') or not r2.get('items'): + print('품목을 찾을 수 없습니다') + exit() + +p1 = r1['items'][0] +p2 = r2['items'][0] +print(f"라식스: {p1['name']}, 재고 {p1.get('stock', '?')}, code: {p1['internal_code']}") +print(f"코자정: {p2['name']}, 재고 {p2.get('stock', '?')}, code: {p2['internal_code']}") + +# 기존 품목 담기 (라식스 - 나중에 복원할 것) +print('\n=== 2. 기존 품목 (라식스) 담기 ===') +g.add_to_cart(p1['internal_code'], quantity=1) + +# 새 품목 담기 (코자정 - 주문할 것) +print('=== 3. 새 품목 (코자정) 담기 ===') +g.add_to_cart(p2['internal_code'], quantity=1) + +cart = g.get_cart() +print(f"현재 장바구니: {cart['total_items']}개") +for item in cart['items']: + code = item.get('product_code') or item.get('internal_code', '?') + print(f" - {item['product_name'][:30]} (code: {code})") + +# === 선별 주문 === +print('\n' + '='*50) +print('=== 코자정만 주문! ===') +print('='*50) + +# 코자정의 internal_code만 전달 +print(f"\n주문할 internal_code: [{p2['internal_code']}]") +result = g.submit_order_selective([p2['internal_code']]) +print(f"결과: {result}") + +# 최종 확인 +final = g.get_cart() +print(f"\n=== 최종 장바구니: {final['total_items']}개 ===") +for item in final['items']: + print(f" - {item['product_name'][:30]}") + +if final['total_items'] == 1 and '라식스' in final['items'][0]['product_name']: + print('\n🎉 성공! 코자정만 주문됨, 라식스 복원됨!') +elif final['total_items'] == 0: + print('\n⚠️ 둘 다 주문됨 - 선별 주문 실패') +else: + print(f'\n🤔 예상 외 결과: {final["total_items"]}개 남음') diff --git a/backend/test_geo_selective_final.py b/backend/test_geo_selective_final.py new file mode 100644 index 0000000..77b1295 --- /dev/null +++ b/backend/test_geo_selective_final.py @@ -0,0 +1,70 @@ +# -*- coding: utf-8 -*- +"""지오영 선별 주문 최종 테스트""" +import sys; sys.path.insert(0, '.'); import wholesale_path + +import importlib +import wholesale.geoyoung +importlib.reload(wholesale.geoyoung) +from wholesale import GeoYoungSession + +GeoYoungSession._instance = None +g = GeoYoungSession() +g.login() + +# 기존 장바구니 확인 +print('=== 0. 기존 장바구니 확인 ===') +cart0 = g.get_cart() +print(f"기존 품목: {cart0['total_items']}개") +for item in cart0['items']: + print(f" - {item['product_name'][:30]} (code: {item.get('product_code')})") + +existing_codes = [item.get('product_code') for item in cart0['items']] + +# 새 품목 담기 (디아맥스) +print('\n=== 1. 새 품목 (비타민D) 담기 ===') +result = g.full_order( + kd_code='썬비타민', + quantity=1, + specification=None, + check_stock=True, + auto_confirm=False # 장바구니만 +) +print(f"결과: {result.get('success')}, {result.get('message', result.get('error'))}") + +new_code = result.get('product', {}).get('internal_code') if result.get('success') else None +print(f"새 품목 internal_code: {new_code}") + +# 장바구니 확인 +print('\n=== 2. 장바구니 확인 ===') +cart1 = g.get_cart() +print(f"현재 품목: {cart1['total_items']}개") +for item in cart1['items']: + print(f" - {item['product_name'][:30]} (code: {item.get('product_code')})") + +# 선별 주문: 새 품목만! +if new_code: + print('\n=== 3. 선별 주문 (새 품목만!) ===') + print(f"주문할 코드: [{new_code}]") + + confirm_result = g.submit_order_selective([new_code]) + print(f"결과: {confirm_result}") + + # 최종 장바구니 확인 + print('\n=== 4. 최종 장바구니 ===') + cart2 = g.get_cart() + print(f"남은 품목: {cart2['total_items']}개") + for item in cart2['items']: + print(f" - {item['product_name'][:30]}") + + # 기존 품목이 모두 남아있는지 확인 + remaining_codes = [item.get('product_code') for item in cart2['items']] + preserved = all(code in remaining_codes for code in existing_codes if code) + + if preserved and cart2['total_items'] == len(existing_codes): + print('\n🎉 성공! 새 품목만 주문됨, 기존 품목 모두 복원!') + elif cart2['total_items'] == 0: + print('\n⚠️ 모든 품목 주문됨 - 선별 주문 실패') + else: + print(f'\n🤔 예상 외 결과') +else: + print('\n❌ 새 품목 담기 실패') diff --git a/backend/test_geo_selective_final2.py b/backend/test_geo_selective_final2.py new file mode 100644 index 0000000..9c83d94 --- /dev/null +++ b/backend/test_geo_selective_final2.py @@ -0,0 +1,83 @@ +# -*- coding: utf-8 -*- +"""지오영 선별 주문 최종 테스트 2""" +import sys; sys.path.insert(0, '.'); import wholesale_path + +import importlib +import wholesale.geoyoung +importlib.reload(wholesale.geoyoung) +from wholesale import GeoYoungSession + +GeoYoungSession._instance = None +g = GeoYoungSession() +g.login() + +# 기존 장바구니 확인 +print('=== 0. 기존 장바구니 확인 ===') +cart0 = g.get_cart() +print(f"기존 품목: {cart0['total_items']}개") +for item in cart0['items']: + print(f" - {item['product_name'][:30]} (code: {item.get('product_code')})") + +existing_codes = [item.get('product_code') for item in cart0['items']] + +# 새 품목 담기 (타이레놀) +print('\n=== 1. 새 품목 (게보린) 담기 ===') +result = g.full_order( + kd_code='게보린', + quantity=1, + specification=None, + check_stock=True, + auto_confirm=False # 장바구니만 +) +print(f"결과: {result.get('success')}, {result.get('message', result.get('error'))}") + +new_code = result.get('product', {}).get('internal_code') if result.get('success') else None +print(f"새 품목 internal_code: {new_code}") + +if not new_code: + # 다른 품목 시도 + print('\n=== 1-2. 다른 품목 (판피린) 시도 ===') + result = g.full_order( + kd_code='판피린', + quantity=1, + specification=None, + check_stock=True, + auto_confirm=False + ) + print(f"결과: {result.get('success')}, {result.get('message', result.get('error'))}") + new_code = result.get('product', {}).get('internal_code') if result.get('success') else None + +# 장바구니 확인 +print('\n=== 2. 장바구니 확인 ===') +cart1 = g.get_cart() +print(f"현재 품목: {cart1['total_items']}개") +for item in cart1['items']: + print(f" - {item['product_name'][:30]} (code: {item.get('product_code')})") + +# 선별 주문: 새 품목만! +if new_code: + print('\n=== 3. 선별 주문 (새 품목만!) ===') + print(f"주문할 코드: [{new_code}]") + + confirm_result = g.submit_order_selective([new_code]) + print(f"결과: {confirm_result}") + + # 최종 장바구니 확인 + print('\n=== 4. 최종 장바구니 ===') + cart2 = g.get_cart() + print(f"남은 품목: {cart2['total_items']}개") + for item in cart2['items']: + print(f" - {item['product_name'][:30]}") + + # 기존 품목이 모두 남아있는지 확인 + remaining_codes = [item.get('product_code') for item in cart2['items']] + preserved = all(code in remaining_codes for code in existing_codes if code) + + if preserved and cart2['total_items'] == len(existing_codes): + print('\n🎉 성공! 새 품목만 주문됨, 기존 품목 모두 복원!') + elif cart2['total_items'] == 0: + print('\n⚠️ 모든 품목 주문됨 - 선별 주문 실패') + else: + print(f'\n🤔 결과: 기존 {len(existing_codes)}개 중 {len([c for c in existing_codes if c in remaining_codes])}개 복원') +else: + print('\n❌ 새 품목 담기 실패') diff --git a/backend/test_geo_selective_final3.py b/backend/test_geo_selective_final3.py new file mode 100644 index 0000000..f474b27 --- /dev/null +++ b/backend/test_geo_selective_final3.py @@ -0,0 +1,71 @@ +# -*- coding: utf-8 -*- +"""지오영 선별 주문 최종 테스트 - 마그밀""" +import sys; sys.path.insert(0, '.'); import wholesale_path + +import importlib +import wholesale.geoyoung +importlib.reload(wholesale.geoyoung) +from wholesale import GeoYoungSession + +GeoYoungSession._instance = None +g = GeoYoungSession() +g.login() + +# 기존 장바구니 확인 +print('=== 0. 기존 장바구니 확인 ===') +cart0 = g.get_cart() +print(f"기존 품목: {cart0['total_items']}개") +for item in cart0['items']: + print(f" - {item['product_name'][:30]} (code: {item.get('product_code')})") + +existing_count = cart0['total_items'] +existing_codes = [item.get('product_code') for item in cart0['items']] + +# 새 품목 담기 (마그밀) +print('\n=== 1. 새 품목 (마그밀) 담기 ===') +result = g.full_order( + kd_code='마그밀', + quantity=1, + specification=None, + check_stock=True, + auto_confirm=False # 장바구니만 +) +print(f"결과: {result.get('success')}, {result.get('message', result.get('error'))}") + +new_code = result.get('product', {}).get('internal_code') if result.get('success') else None +print(f"새 품목 internal_code: {new_code}") + +# 장바구니 확인 +print('\n=== 2. 장바구니 확인 ===') +cart1 = g.get_cart() +print(f"현재 품목: {cart1['total_items']}개") +for item in cart1['items']: + print(f" - {item['product_name'][:30]} (code: {item.get('product_code')})") + +# 선별 주문: 새 품목만! +if new_code: + print('\n=== 3. 선별 주문 (마그밀만!) ===') + print(f"주문할 코드: [{new_code}]") + + confirm_result = g.submit_order_selective([new_code]) + print(f"결과: {confirm_result}") + + # 최종 장바구니 확인 + print('\n=== 4. 최종 장바구니 ===') + cart2 = g.get_cart() + print(f"남은 품목: {cart2['total_items']}개") + for item in cart2['items']: + print(f" - {item['product_name'][:30]}") + + # 기존 품목이 모두 남아있는지 확인 + remaining_codes = [item.get('product_code') for item in cart2['items']] + preserved_count = len([c for c in existing_codes if c in remaining_codes]) + + if cart2['total_items'] == existing_count and preserved_count == existing_count: + print(f'\n🎉 성공! 마그밀만 주문됨, 기존 {existing_count}개 품목 모두 복원!') + elif cart2['total_items'] == 0: + print('\n⚠️ 모든 품목 주문됨 - 선별 주문 실패') + else: + print(f'\n🤔 결과: 기존 {existing_count}개 중 {preserved_count}개 복원, 현재 {cart2["total_items"]}개') +else: + print('\n❌ 새 품목 담기 실패') diff --git a/backend/test_geoyoung_api.py b/backend/test_geoyoung_api.py deleted file mode 100644 index 22482db..0000000 --- a/backend/test_geoyoung_api.py +++ /dev/null @@ -1,112 +0,0 @@ -# -*- 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_integration.py b/backend/test_integration.py deleted file mode 100644 index 95f42d2..0000000 --- a/backend/test_integration.py +++ /dev/null @@ -1,98 +0,0 @@ -""" -통합 테스트: QR 라벨 전체 흐름 -토큰 생성 → DB 저장 → QR 라벨 이미지 생성 -""" - -import sys -import os -from datetime import datetime - -# Path setup -sys.path.insert(0, os.path.join(os.path.dirname(__file__))) - -from utils.qr_token_generator import generate_claim_token, save_token_to_db -from utils.qr_label_printer import print_qr_label - -def test_full_flow(): - """전체 흐름 테스트""" - - # 1. 테스트 데이터 (새로운 거래 ID) - test_tx_id = datetime.now().strftime("TEST%Y%m%d%H%M%S") - test_amount = 75000.0 - test_time = datetime.now() - - print("=" * 80) - print("QR 라벨 통합 테스트") - print("=" * 80) - print(f"거래 ID: {test_tx_id}") - print(f"판매 금액: {test_amount:,}원") - print() - - # 2. 토큰 생성 - print("[1/3] Claim Token 생성...") - token_info = generate_claim_token(test_tx_id, test_amount) - - print(f" [OK] 토큰 원문: {token_info['token_raw'][:50]}...") - print(f" [OK] 토큰 해시: {token_info['token_hash'][:32]}...") - print(f" [OK] QR URL: {token_info['qr_url']}") - print(f" [OK] URL 길이: {len(token_info['qr_url'])} 문자") - print(f" [OK] 적립 포인트: {token_info['claimable_points']}P") - print() - - # 3. DB 저장 - print("[2/3] SQLite DB 저장...") - success, error = save_token_to_db( - test_tx_id, - token_info['token_hash'], - test_amount, - token_info['claimable_points'], - token_info['expires_at'], - token_info['pharmacy_id'] - ) - - if not success: - print(f" [ERROR] DB 저장 실패: {error}") - return False - - print(f" [OK] DB 저장 성공") - print() - - # 4. QR 라벨 생성 (미리보기 모드) - print("[3/3] QR 라벨 이미지 생성...") - success, image_path = print_qr_label( - token_info['qr_url'], - test_tx_id, - test_amount, - token_info['claimable_points'], - test_time, - preview_mode=True - ) - - if not success: - print(f" [ERROR] 이미지 생성 실패") - return False - - print(f" [OK] 이미지 저장: {image_path}") - print() - - # 5. 결과 요약 - print("=" * 80) - print("[SUCCESS] 통합 테스트 성공!") - print("=" * 80) - print(f"QR URL: {token_info['qr_url']}") - print(f"이미지 파일: {image_path}") - print(f"\n다음 명령으로 확인:") - print(f" start {image_path}") - print("=" * 80) - - return True - -if __name__ == "__main__": - try: - success = test_full_flow() - sys.exit(0 if success else 1) - except Exception as e: - print(f"\n[ERROR] 테스트 실패: {e}") - import traceback - traceback.print_exc() - sys.exit(1) diff --git a/backend/test_order_end.py b/backend/test_order_end.py new file mode 100644 index 0000000..fb9525a --- /dev/null +++ b/backend/test_order_end.py @@ -0,0 +1,76 @@ +# -*- coding: utf-8 -*- +import sys; sys.path.insert(0, '.'); import wholesale_path +from wholesale import SooinSession +from bs4 import BeautifulSoup +import re + +s = SooinSession() +s.login() +s.clear_cart() + +result = s.search_products('코자정') +product = result['items'][0] +s.add_to_cart(product['internal_code'], qty=1, price=product['price'], stock=product['stock']) + +resp = s.session.get(f'{s.BAG_VIEW_URL}?currVenCd={s.vendor_code}', timeout=15) +soup = BeautifulSoup(resp.content, 'html.parser') +form = soup.find('form', {'id': 'frmBag'}) + +# form action 확인 +form_action = form.get('action', '') +print(f'form action: {form_action}') + +# 올바른 URL 구성 +ORDER_END_URL = 'http://sooinpharm.co.kr/Service/Order/OrderEnd.asp' + +form_data = {} +for inp in form.find_all('input'): + name = inp.get('name', '') + if not name: continue + inp_type = inp.get('type', '').lower() + if inp_type == 'checkbox': + form_data[name] = 'on' # 체크박스 선택 + else: + form_data[name] = inp.get('value', '') + +# x, y 좌표 (image input 클릭) +form_data['x'] = '10' +form_data['y'] = '10' + +print(f"chk_0: {form_data.get('chk_0')}") +print(f"kind: {form_data.get('kind')}") + +print(f'\nPOST to: {ORDER_END_URL}') +resp = s.session.post( + ORDER_END_URL, + data=form_data, + headers={ + 'Content-Type': 'application/x-www-form-urlencoded', + 'Referer': f'{s.BAG_VIEW_URL}?currVenCd={s.vendor_code}' + }, + timeout=30 +) + +print(f'응답 상태: {resp.status_code}') +print(f'응답 길이: {len(resp.text)}') + +# alert 확인 +alert_match = re.search(r'alert\("([^"]*)"\)', resp.text) +alert_msg = alert_match.group(1) if alert_match else 'N/A' +print(f'alert 메시지: "{alert_msg}"') + +# 응답 일부 출력 +print('\n응답 앞부분:') +print(resp.text[:1000]) + +# 장바구니 확인 +resp2 = s.session.get(f'{s.BAG_VIEW_URL}?currVenCd={s.vendor_code}', timeout=15) +soup2 = BeautifulSoup(resp2.content, 'html.parser') +int_array = soup2.find('input', {'name': 'intArray'}) +val = int_array.get('value') if int_array else '없음' +print(f'\n주문 후 intArray: {val}') + +if val == '-1': + print('\n🎉 주문 성공!') +else: + print('\n❌ 주문 실패') diff --git a/backend/test_pg.py b/backend/test_pg.py deleted file mode 100644 index e8183b1..0000000 --- a/backend/test_pg.py +++ /dev/null @@ -1,8 +0,0 @@ -from sqlalchemy import create_engine, text - -pg_engine = create_engine('postgresql://admin:trajet6640@192.168.0.87:5432/apdb_master') -with pg_engine.connect() as conn: - result = conn.execute(text("SELECT apc, product_name, company_name, main_ingredient FROM apc WHERE product_name LIKE '%아시엔로%' LIMIT 20")) - print('아시엔로 검색 결과:') - for row in result: - print(f' APC: {row[0]} | {row[1]} | {row[2]} | {row[3]}') diff --git a/backend/test_post_data.py b/backend/test_post_data.py new file mode 100644 index 0000000..911ce30 --- /dev/null +++ b/backend/test_post_data.py @@ -0,0 +1,69 @@ +# -*- coding: utf-8 -*- +"""실제 POST 데이터 확인""" +import sys; sys.path.insert(0, '.'); import wholesale_path +from bs4 import BeautifulSoup +import re + +import importlib +import wholesale.sooin +importlib.reload(wholesale.sooin) +from wholesale import SooinSession + +SooinSession._instance = None +s = SooinSession() +s.login() +s.clear_cart() + +# 2개 품목 담기 +r1 = s.search_products('코자정') +s.add_to_cart(r1['items'][0]['internal_code'], qty=1, price=r1['items'][0]['price'], stock=r1['items'][0]['stock']) +r2 = s.search_products('디카맥스') +s.add_to_cart(r2['items'][0]['internal_code'], qty=1, price=r2['items'][0]['price'], stock=r2['items'][0]['stock']) + +# row 0 취소 (디카맥스) +s.cancel_item(row_index=0) + +# Bag.asp GET +resp = s.session.get(f'{s.BAG_VIEW_URL}?currVenCd={s.vendor_code}', timeout=15) +soup = BeautifulSoup(resp.content, 'html.parser') +form = soup.find('form', {'id': 'frmBag'}) + +form_data = {} +for inp in form.find_all('input'): + name = inp.get('name', '') + if not name: + continue + + inp_type = inp.get('type', 'text').lower() + + if inp_type == 'checkbox': + if inp.get('checked') is not None: + form_data[name] = 'on' + continue + + form_data[name] = inp.get('value', '') + +form_data['kind'] = 'order' +form_data['tx_memo'] = '선별 테스트' + +print('=== POST할 데이터 (체크박스 관련) ===') +for k, v in form_data.items(): + if 'chk' in k.lower(): + print(f" {k}: {v}") + +print(f"\n=== 실제 POST ===") +resp = s.session.post( + s.ORDER_END_URL, + data=form_data, + timeout=30 +) + +alert_match = re.search(r'alert\("([^"]*)"\)', resp.text) +alert_msg = alert_match.group(1) if alert_match else 'N/A' +print(f"응답: {alert_msg}") + +# 장바구니 확인 +cart = s.get_cart() +print(f"\n남은 품목: {cart['total_items']}개") +for item in cart['items']: + print(f" - {item['product_name']}") diff --git a/backend/test_qr_methods.py b/backend/test_qr_methods.py deleted file mode 100644 index ece06f4..0000000 --- a/backend/test_qr_methods.py +++ /dev/null @@ -1,298 +0,0 @@ -""" -ESC/POS QR 코드 인쇄 방식 테스트 -여러 가지 방법을 한 번에 시도하여 어떤 방식이 작동하는지 확인 -""" - -import socket -import qrcode -import time -from PIL import Image - -# 프린터 설정 (고정) -PRINTER_IP = "192.168.0.174" -PRINTER_PORT = 9100 - -# 테스트 URL (짧은 버전) -TEST_URL = "https://mile.0bin.in/test" - - -def send_to_printer(data, method_name): - """프린터로 데이터 전송""" - try: - print(f"\n{'='*60}") - print(f"[{method_name}] 전송 시작...") - print(f"데이터 크기: {len(data)} bytes") - - sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) - sock.settimeout(10) - sock.connect((PRINTER_IP, PRINTER_PORT)) - sock.sendall(data) - sock.close() - - print(f"[{method_name}] ✅ 전송 완료!") - time.sleep(2) # 프린터 처리 대기 - return True - except Exception as e: - print(f"[{method_name}] ❌ 실패: {e}") - return False - - -def method_1_native_qr_model2(): - """ - 방법 1: 프린터 내장 QR 생성 (GS ( k) - Model 2 - 가장 안정적이지만 프린터 지원 필요 - """ - ESC = b'\x1b' - GS = b'\x1d' - - commands = [] - - # 초기화 - commands.append(ESC + b'@') - - # 헤더 - commands.append("\n".encode('euc-kr')) - commands.append("================================\n".encode('euc-kr')) - commands.append(" *** 방법 1 ***\n".encode('euc-kr')) - commands.append(" 내장 QR (GS ( k)\n".encode('euc-kr')) - commands.append("================================\n".encode('euc-kr')) - commands.append("\n".encode('euc-kr')) - - # QR 설정 - # GS ( k pL pH cn fn n (QR Code) - # cn = 49 (Model 1/2 선택) - # fn = 65 (모델 선택) - # n = 50 (Model 2) - - # 모델 설정 - commands.append(GS + b'(k' + bytes([3, 0, 49, 65, 50])) # Model 2 - - # 에러 정정 레벨 설정 (fn=69, n=48=L) - commands.append(GS + b'(k' + bytes([3, 0, 49, 69, 48])) - - # 모듈 크기 설정 (fn=67, n=8) - commands.append(GS + b'(k' + bytes([3, 0, 49, 67, 8])) - - # QR 데이터 저장 (fn=80) - qr_data = TEST_URL.encode('utf-8') - data_len = len(qr_data) + 3 - pL = data_len & 0xFF - pH = (data_len >> 8) & 0xFF - commands.append(GS + b'(k' + bytes([pL, pH, 49, 80, 48]) + qr_data) - - # QR 인쇄 (fn=81) - commands.append(GS + b'(k' + bytes([3, 0, 49, 81, 48])) - - # 푸터 - commands.append("\n".encode('euc-kr')) - commands.append(f"URL: {TEST_URL}\n".encode('euc-kr')) - commands.append("\n\n\n".encode('euc-kr')) - - # 용지 커트 - commands.append(GS + b'V' + bytes([1])) - - return b''.join(commands) - - -def method_2_raster_bitmap_gs_v(): - """ - 방법 2: Raster Bit Image (GS v 0) - """ - ESC = b'\x1b' - GS = b'\x1d' - - commands = [] - - # 초기화 - commands.append(ESC + b'@') - - # 헤더 - commands.append("\n".encode('euc-kr')) - commands.append("================================\n".encode('euc-kr')) - commands.append(" *** 방법 2 ***\n".encode('euc-kr')) - commands.append(" Raster (GS v 0)\n".encode('euc-kr')) - commands.append("================================\n".encode('euc-kr')) - commands.append("\n".encode('euc-kr')) - - # QR 이미지 생성 (작게: 80x80) - qr = qrcode.QRCode(version=1, box_size=2, border=2) - qr.add_data(TEST_URL) - qr.make(fit=True) - qr_image = qr.make_image(fill_color="black", back_color="white") - qr_image = qr_image.resize((80, 80)) - - # 1비트 흑백으로 변환 - qr_image = qr_image.convert('1') - width, height = qr_image.size - pixels = qr_image.load() - - # GS v 0 명령어 - width_bytes = (width + 7) // 8 - commands.append(GS + b'v0' + bytes([0])) # 보통 모드 - commands.append(bytes([width_bytes & 0xFF, (width_bytes >> 8) & 0xFF])) # xL, xH - commands.append(bytes([height & 0xFF, (height >> 8) & 0xFF])) # yL, yH - - # 이미지 데이터 - for y in range(height): - for x in range(0, width, 8): - byte = 0 - for bit in range(8): - if x + bit < width: - if pixels[x + bit, y] == 0: # 검은색 - byte |= (1 << (7 - bit)) - commands.append(bytes([byte])) - - # 푸터 - commands.append("\n".encode('euc-kr')) - commands.append(f"URL: {TEST_URL}\n".encode('euc-kr')) - commands.append("\n\n\n".encode('euc-kr')) - - # 용지 커트 - commands.append(GS + b'V' + bytes([1])) - - return b''.join(commands) - - -def method_3_bit_image_esc_star(): - """ - 방법 3: Bit Image (ESC *) - 24-dot double-density - 현재 사용 중인 방식 - """ - ESC = b'\x1b' - GS = b'\x1d' - - commands = [] - - # 초기화 - commands.append(ESC + b'@') - - # 헤더 - commands.append("\n".encode('euc-kr')) - commands.append("================================\n".encode('euc-kr')) - commands.append(" *** 방법 3 ***\n".encode('euc-kr')) - commands.append(" Bit Image (ESC *)\n".encode('euc-kr')) - commands.append("================================\n".encode('euc-kr')) - commands.append("\n".encode('euc-kr')) - - # QR 이미지 생성 (작게: 80x80) - qr = qrcode.QRCode(version=1, box_size=2, border=2) - qr.add_data(TEST_URL) - qr.make(fit=True) - qr_image = qr.make_image(fill_color="black", back_color="white") - qr_image = qr_image.resize((80, 80)) - - # 1비트 흑백으로 변환 - qr_image = qr_image.convert('1') - width, height = qr_image.size - pixels = qr_image.load() - - # ESC * 명령어로 라인별 인쇄 - for y in range(0, height, 24): - line_height = min(24, height - y) - - # ESC * m nL nH - nL = width & 0xFF - nH = (width >> 8) & 0xFF - commands.append(ESC + b'*' + bytes([33, nL, nH])) # m=33 (24-dot double-density) - - # 라인 데이터 - for x in range(width): - byte1, byte2, byte3 = 0, 0, 0 - - for bit in range(line_height): - pixel_y = y + bit - if pixel_y < height: - if pixels[x, pixel_y] == 0: # 검은색 - if bit < 8: - byte1 |= (1 << (7 - bit)) - elif bit < 16: - byte2 |= (1 << (15 - bit)) - else: - byte3 |= (1 << (23 - bit)) - - commands.append(bytes([byte1, byte2, byte3])) - - commands.append(b'\n') - - # 푸터 - commands.append("\n".encode('euc-kr')) - commands.append(f"URL: {TEST_URL}\n".encode('euc-kr')) - commands.append("\n\n\n".encode('euc-kr')) - - # 용지 커트 - commands.append(GS + b'V' + bytes([1])) - - return b''.join(commands) - - -def method_4_simple_text_only(): - """ - 방법 4: 텍스트만 (비교용) - """ - ESC = b'\x1b' - GS = b'\x1d' - - commands = [] - - # 초기화 - commands.append(ESC + b'@') - - # 헤더 - commands.append("\n".encode('euc-kr')) - commands.append("================================\n".encode('euc-kr')) - commands.append(" *** 방법 4 ***\n".encode('euc-kr')) - commands.append(" 텍스트만 (비교용)\n".encode('euc-kr')) - commands.append("================================\n".encode('euc-kr')) - commands.append("\n".encode('euc-kr')) - commands.append("QR 이미지 대신 URL만 출력\n".encode('euc-kr')) - commands.append("\n".encode('euc-kr')) - commands.append(f"URL: {TEST_URL}\n".encode('euc-kr')) - commands.append("\n\n\n".encode('euc-kr')) - - # 용지 커트 - commands.append(GS + b'V' + bytes([1])) - - return b''.join(commands) - - -def main(): - """메인 실행""" - print("="*60) - print("ESC/POS QR 코드 인쇄 방식 테스트") - print("="*60) - print(f"프린터: {PRINTER_IP}:{PRINTER_PORT}") - print(f"테스트 URL: {TEST_URL}") - print("="*60) - - methods = [ - ("방법 1: 프린터 내장 QR (GS ( k)", method_1_native_qr_model2), - ("방법 2: Raster Bitmap (GS v 0)", method_2_raster_bitmap_gs_v), - ("방법 3: Bit Image (ESC *)", method_3_bit_image_esc_star), - ("방법 4: 텍스트만", method_4_simple_text_only), - ] - - results = [] - - for name, method_func in methods: - try: - data = method_func() - success = send_to_printer(data, name) - results.append((name, success)) - except Exception as e: - print(f"[{name}] ❌ 함수 실행 오류: {e}") - results.append((name, False)) - - # 결과 요약 - print("\n" + "="*60) - print("테스트 결과 요약") - print("="*60) - for name, success in results: - status = "✅ 성공" if success else "❌ 실패" - print(f"{name}: {status}") - - print("\n인쇄된 영수증을 확인하여 어떤 방법이 QR을 제대로 출력했는지 확인하세요!") - print("="*60) - - -if __name__ == "__main__": - main() diff --git a/backend/test_qr_methods_v2.py b/backend/test_qr_methods_v2.py deleted file mode 100644 index 7b0a97a..0000000 --- a/backend/test_qr_methods_v2.py +++ /dev/null @@ -1,281 +0,0 @@ -""" -ESC/POS QR 코드 인쇄 방식 테스트 v2 -더 많은 변형 시도 (크기, 밀도, 파라미터) -""" - -import socket -import qrcode -import time - -# 프린터 설정 -PRINTER_IP = "192.168.0.174" -PRINTER_PORT = 9100 - -# 테스트 URL (더 짧게) -TEST_URL = "https://bit.ly/test" - - -def send_to_printer(data, method_name): - """프린터로 데이터 전송""" - try: - print(f"\n[{method_name}] 전송 중... ({len(data)} bytes)") - sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) - sock.settimeout(10) - sock.connect((PRINTER_IP, PRINTER_PORT)) - sock.sendall(data) - sock.close() - print(f"[{method_name}] ✅ 완료") - time.sleep(1.5) - return True - except Exception as e: - print(f"[{method_name}] ❌ 실패: {e}") - return False - - -def method_1_tiny_qr_escstar(): - """방법 1: 아주 작은 QR (30x30) + ESC *""" - ESC = b'\x1b' - GS = b'\x1d' - commands = [] - commands.append(ESC + b'@') - commands.append("\n================================\n".encode('euc-kr')) - commands.append(" *** 방법 1 ***\n".encode('euc-kr')) - commands.append(" 작은 QR 30x30 (ESC *)\n".encode('euc-kr')) - commands.append("================================\n\n".encode('euc-kr')) - - # 30x30 QR - qr = qrcode.QRCode(version=1, box_size=1, border=1) - qr.add_data(TEST_URL) - qr.make(fit=True) - img = qr.make_image(fill_color="black", back_color="white").resize((30, 30)).convert('1') - width, height = img.size - pixels = img.load() - - # ESC * m=0 (8-dot single-density) - for y in range(0, height, 8): - commands.append(ESC + b'*' + bytes([0, width & 0xFF, (width >> 8) & 0xFF])) - for x in range(width): - byte = 0 - for bit in range(min(8, height - y)): - if pixels[x, y + bit] == 0: - byte |= (1 << (7 - bit)) - commands.append(bytes([byte])) - commands.append(b'\n') - - commands.append(f"\nURL: {TEST_URL}\n\n\n".encode('euc-kr')) - commands.append(GS + b'V' + bytes([1])) - return b''.join(commands) - - -def method_2_medium_qr_escstar_mode32(): - """방법 2: 중간 QR (50x50) + ESC * mode 32""" - ESC = b'\x1b' - GS = b'\x1d' - commands = [] - commands.append(ESC + b'@') - commands.append("\n================================\n".encode('euc-kr')) - commands.append(" *** 방법 2 ***\n".encode('euc-kr')) - commands.append(" 중간 QR 50x50 (ESC * m=32)\n".encode('euc-kr')) - commands.append("================================\n\n".encode('euc-kr')) - - # 50x50 QR - qr = qrcode.QRCode(version=1, box_size=2, border=1) - qr.add_data(TEST_URL) - qr.make(fit=True) - img = qr.make_image(fill_color="black", back_color="white").resize((50, 50)).convert('1') - width, height = img.size - pixels = img.load() - - # ESC * m=32 (24-dot single-density) - for y in range(0, height, 24): - commands.append(ESC + b'*' + bytes([32, width & 0xFF, (width >> 8) & 0xFF])) - for x in range(width): - byte1 = byte2 = byte3 = 0 - for bit in range(min(24, height - y)): - if pixels[x, y + bit] == 0: - if bit < 8: - byte1 |= (1 << (7 - bit)) - elif bit < 16: - byte2 |= (1 << (15 - bit)) - else: - byte3 |= (1 << (23 - bit)) - commands.append(bytes([byte1, byte2, byte3])) - commands.append(b'\n') - - commands.append(f"\nURL: {TEST_URL}\n\n\n".encode('euc-kr')) - commands.append(GS + b'V' + bytes([1])) - return b''.join(commands) - - -def method_3_native_qr_simple(): - """방법 3: 내장 QR (더 간단한 설정)""" - ESC = b'\x1b' - GS = b'\x1d' - commands = [] - commands.append(ESC + b'@') - commands.append("\n================================\n".encode('euc-kr')) - commands.append(" *** 방법 3 ***\n".encode('euc-kr')) - commands.append(" 내장 QR 간단 설정\n".encode('euc-kr')) - commands.append("================================\n\n".encode('euc-kr')) - - qr_data = TEST_URL.encode('utf-8') - data_len = len(qr_data) + 3 - - # Model 2 - commands.append(GS + b'(k' + bytes([3, 0, 49, 65, 50])) - # Error correction L - commands.append(GS + b'(k' + bytes([3, 0, 49, 69, 48])) - # Size 4 - commands.append(GS + b'(k' + bytes([3, 0, 49, 67, 4])) - # Store data - commands.append(GS + b'(k' + bytes([data_len & 0xFF, (data_len >> 8) & 0xFF, 49, 80, 48]) + qr_data) - # Print - commands.append(GS + b'(k' + bytes([3, 0, 49, 81, 48])) - - commands.append(f"\n\nURL: {TEST_URL}\n\n\n".encode('euc-kr')) - commands.append(GS + b'V' + bytes([1])) - return b''.join(commands) - - -def method_4_native_qr_model1(): - """방법 4: 내장 QR Model 1 (구형)""" - ESC = b'\x1b' - GS = b'\x1d' - commands = [] - commands.append(ESC + b'@') - commands.append("\n================================\n".encode('euc-kr')) - commands.append(" *** 방법 4 ***\n".encode('euc-kr')) - commands.append(" 내장 QR Model 1\n".encode('euc-kr')) - commands.append("================================\n\n".encode('euc-kr')) - - qr_data = TEST_URL.encode('utf-8') - data_len = len(qr_data) + 3 - - # Model 1 (n=49) - commands.append(GS + b'(k' + bytes([3, 0, 49, 65, 49])) - # Error correction L - commands.append(GS + b'(k' + bytes([3, 0, 49, 69, 48])) - # Size 4 - commands.append(GS + b'(k' + bytes([3, 0, 49, 67, 4])) - # Store data - commands.append(GS + b'(k' + bytes([data_len & 0xFF, (data_len >> 8) & 0xFF, 49, 80, 48]) + qr_data) - # Print - commands.append(GS + b'(k' + bytes([3, 0, 49, 81, 48])) - - commands.append(f"\n\nURL: {TEST_URL}\n\n\n".encode('euc-kr')) - commands.append(GS + b'V' + bytes([1])) - return b''.join(commands) - - -def method_5_raster_tiny(): - """방법 5: Raster 초소형 (40x40)""" - ESC = b'\x1b' - GS = b'\x1d' - commands = [] - commands.append(ESC + b'@') - commands.append("\n================================\n".encode('euc-kr')) - commands.append(" *** 방법 5 ***\n".encode('euc-kr')) - commands.append(" Raster 40x40 (GS v 0)\n".encode('euc-kr')) - commands.append("================================\n\n".encode('euc-kr')) - - # 40x40 QR - qr = qrcode.QRCode(version=1, box_size=1, border=1) - qr.add_data(TEST_URL) - qr.make(fit=True) - img = qr.make_image(fill_color="black", back_color="white").resize((40, 40)).convert('1') - width, height = img.size - pixels = img.load() - - width_bytes = (width + 7) // 8 - commands.append(GS + b'v0' + bytes([0])) - commands.append(bytes([width_bytes & 0xFF, (width_bytes >> 8) & 0xFF])) - commands.append(bytes([height & 0xFF, (height >> 8) & 0xFF])) - - for y in range(height): - for x in range(0, width, 8): - byte = 0 - for bit in range(8): - if x + bit < width and pixels[x + bit, y] == 0: - byte |= (1 << (7 - bit)) - commands.append(bytes([byte])) - - commands.append(f"\n\nURL: {TEST_URL}\n\n\n".encode('euc-kr')) - commands.append(GS + b'V' + bytes([1])) - return b''.join(commands) - - -def method_6_no_align(): - """방법 6: 정렬 없이 + 작은 QR""" - ESC = b'\x1b' - GS = b'\x1d' - commands = [] - commands.append(ESC + b'@') - # 정렬 명령 없음! - commands.append("\n================================\n".encode('euc-kr')) - commands.append(" *** 방법 6 ***\n".encode('euc-kr')) - commands.append(" 정렬 없음 + QR 35x35\n".encode('euc-kr')) - commands.append("================================\n\n".encode('euc-kr')) - - # 35x35 QR - qr = qrcode.QRCode(version=1, box_size=1, border=1) - qr.add_data(TEST_URL) - qr.make(fit=True) - img = qr.make_image(fill_color="black", back_color="white").resize((35, 35)).convert('1') - width, height = img.size - pixels = img.load() - - # ESC * m=1 (8-dot double-density) - for y in range(0, height, 8): - commands.append(ESC + b'*' + bytes([1, width & 0xFF, (width >> 8) & 0xFF])) - for x in range(width): - byte = 0 - for bit in range(min(8, height - y)): - if pixels[x, y + bit] == 0: - byte |= (1 << (7 - bit)) - commands.append(bytes([byte])) - commands.append(b'\n') - - commands.append(f"\nURL: {TEST_URL}\n\n\n".encode('euc-kr')) - commands.append(GS + b'V' + bytes([1])) - return b''.join(commands) - - -def main(): - print("="*60) - print("ESC/POS QR 테스트 v2 - 더 많은 변형") - print("="*60) - print(f"프린터: {PRINTER_IP}:{PRINTER_PORT}") - print(f"테스트 URL: {TEST_URL}") - print("="*60) - - methods = [ - ("방법 1: 30x30 ESC * m=0", method_1_tiny_qr_escstar), - ("방법 2: 50x50 ESC * m=32", method_2_medium_qr_escstar_mode32), - ("방법 3: 내장 QR 간단", method_3_native_qr_simple), - ("방법 4: 내장 QR Model 1", method_4_native_qr_model1), - ("방법 5: 40x40 Raster", method_5_raster_tiny), - ("방법 6: 정렬 없음 35x35", method_6_no_align), - ] - - results = [] - for name, method_func in methods: - try: - data = method_func() - success = send_to_printer(data, name) - results.append((name, success)) - except Exception as e: - print(f"[{name}] ❌ 오류: {e}") - results.append((name, False)) - - print("\n" + "="*60) - print("결과 요약") - print("="*60) - for name, success in results: - print(f"{name}: {'✅' if success else '❌'}") - - print("\n6장의 영수증이 나옵니다. QR이 보이는 번호를 알려주세요!") - print("="*60) - - -if __name__ == "__main__": - main() diff --git a/backend/test_qr_with_escpos_lib.py b/backend/test_qr_with_escpos_lib.py deleted file mode 100644 index b7cb4b7..0000000 --- a/backend/test_qr_with_escpos_lib.py +++ /dev/null @@ -1,263 +0,0 @@ -""" -python-escpos 라이브러리를 사용한 QR 코드 인쇄 테스트 -훨씬 더 간단하고 안정적! - -설치: pip install python-escpos -""" - -from escpos.printer import Network -from escpos import escpos -import time - -# 프린터 설정 -PRINTER_IP = "192.168.0.174" -PRINTER_PORT = 9100 - -# 테스트 URL -TEST_URL = "https://mile.0bin.in/test" - - -def test_method_1_native_qr(): - """방법 1: escpos 라이브러리 내장 QR 함수""" - print("\n" + "="*60) - print("방법 1: escpos.qr() - 프린터 내장 QR") - print("="*60) - - try: - p = Network(PRINTER_IP, port=PRINTER_PORT) - - p.text("\n") - p.text("================================\n") - p.text(" *** 방법 1 ***\n") - p.text(" escpos.qr() 내장\n") - p.text("================================\n") - p.text("\n") - - # QR 코드 인쇄 (프린터 내장) - p.qr(TEST_URL, size=4, center=True) - - p.text("\n") - p.text(f"URL: {TEST_URL}\n") - p.text("\n\n\n") - p.cut() - - print("✅ 방법 1 성공!") - time.sleep(2) - return True - except Exception as e: - print(f"❌ 방법 1 실패: {e}") - import traceback - traceback.print_exc() - return False - - -def test_method_2_image(): - """방법 2: escpos 라이브러리 이미지 함수""" - print("\n" + "="*60) - print("방법 2: escpos.image() - QR을 이미지로 변환하여 인쇄") - print("="*60) - - try: - import qrcode - from io import BytesIO - - p = Network(PRINTER_IP, port=PRINTER_PORT) - - p.text("\n") - p.text("================================\n") - p.text(" *** 방법 2 ***\n") - p.text(" escpos.image()\n") - p.text("================================\n") - p.text("\n") - - # QR 이미지 생성 - qr = qrcode.QRCode(version=1, box_size=3, border=2) - qr.add_data(TEST_URL) - qr.make(fit=True) - qr_img = qr.make_image(fill_color="black", back_color="white") - - # escpos.image()로 인쇄 - p.image(qr_img, center=True) - - p.text("\n") - p.text(f"URL: {TEST_URL}\n") - p.text("\n\n\n") - p.cut() - - print("✅ 방법 2 성공!") - time.sleep(2) - return True - except Exception as e: - print(f"❌ 방법 2 실패: {e}") - import traceback - traceback.print_exc() - return False - - -def test_method_3_qr_small(): - """방법 3: 작은 QR (size=3)""" - print("\n" + "="*60) - print("방법 3: 작은 QR (size=3)") - print("="*60) - - try: - p = Network(PRINTER_IP, port=PRINTER_PORT) - - p.text("\n") - p.text("================================\n") - p.text(" *** 방법 3 ***\n") - p.text(" 작은 QR (size=3)\n") - p.text("================================\n") - p.text("\n") - - p.qr(TEST_URL, size=3, center=True) - - p.text("\n") - p.text(f"URL: {TEST_URL}\n") - p.text("\n\n\n") - p.cut() - - print("✅ 방법 3 성공!") - time.sleep(2) - return True - except Exception as e: - print(f"❌ 방법 3 실패: {e}") - import traceback - traceback.print_exc() - return False - - -def test_method_4_qr_large(): - """방법 4: 큰 QR (size=8)""" - print("\n" + "="*60) - print("방법 4: 큰 QR (size=8)") - print("="*60) - - try: - p = Network(PRINTER_IP, port=PRINTER_PORT) - - p.text("\n") - p.text("================================\n") - p.text(" *** 방법 4 ***\n") - p.text(" 큰 QR (size=8)\n") - p.text("================================\n") - p.text("\n") - - p.qr(TEST_URL, size=8, center=True) - - p.text("\n") - p.text(f"URL: {TEST_URL}\n") - p.text("\n\n\n") - p.cut() - - print("✅ 방법 4 성공!") - time.sleep(2) - return True - except Exception as e: - print(f"❌ 방법 4 실패: {e}") - import traceback - traceback.print_exc() - return False - - -def test_method_5_full_receipt(): - """방법 5: 완전한 영수증 (청춘약국)""" - print("\n" + "="*60) - print("방법 5: 완전한 영수증") - print("="*60) - - try: - p = Network(PRINTER_IP, port=PRINTER_PORT) - - p.text("\n") - p.text("================================\n") - p.text(" *** 방법 5 ***\n") - p.text(" 완전한 영수증\n") - p.text("================================\n") - p.text("\n") - - # 헤더 - p.set(align='center') - p.text("청춘약국\n") - p.text("================================\n") - - # 거래 정보 - p.set(align='left') - p.text("거래일시: 2026-01-29 14:30\n") - p.text("거래번호: 20260129000042\n") - p.text("\n") - p.text("결제금액: 50,000원\n") - p.text("적립예정: 1,500P\n") - p.text("\n") - p.text("================================\n") - p.text("\n") - - # QR 코드 - p.qr(TEST_URL, size=6, center=True) - - p.text("\n") - p.set(align='center') - p.text("QR 촬영하고 포인트 받으세요!\n") - p.text("\n") - p.text("================================\n") - - p.text("\n\n\n") - p.cut() - - print("✅ 방법 5 성공!") - time.sleep(2) - return True - except Exception as e: - print(f"❌ 방법 5 실패: {e}") - import traceback - traceback.print_exc() - return False - - -def main(): - print("="*60) - print("python-escpos 라이브러리 QR 테스트") - print("="*60) - print(f"프린터: {PRINTER_IP}:{PRINTER_PORT}") - print(f"테스트 URL: {TEST_URL}") - print("\n먼저 라이브러리 설치 확인:") - print(" pip install python-escpos") - print("="*60) - - try: - import escpos - print("✅ python-escpos 설치됨") - except ImportError: - print("❌ python-escpos가 설치되지 않았습니다!") - print(" 실행: pip install python-escpos") - return - - methods = [ - ("방법 1: 내장 QR (size=4)", test_method_1_native_qr), - ("방법 2: 이미지로 변환", test_method_2_image), - ("방법 3: 작은 QR (size=3)", test_method_3_qr_small), - ("방법 4: 큰 QR (size=8)", test_method_4_qr_large), - ("방법 5: 완전한 영수증", test_method_5_full_receipt), - ] - - results = [] - for name, method_func in methods: - try: - success = method_func() - results.append((name, success)) - except Exception as e: - print(f"[{name}] ❌ 예외 발생: {e}") - results.append((name, False)) - - print("\n" + "="*60) - print("결과 요약") - print("="*60) - for name, success in results: - print(f"{name}: {'✅ 성공' if success else '❌ 실패'}") - - print("\n5장의 영수증을 확인하여 QR이 보이는 번호를 알려주세요!") - print("="*60) - - -if __name__ == "__main__": - main() diff --git a/backend/test_rxusage_playwright.py b/backend/test_rxusage_playwright.py new file mode 100644 index 0000000..4a60c98 --- /dev/null +++ b/backend/test_rxusage_playwright.py @@ -0,0 +1,122 @@ +# -*- coding: utf-8 -*- +"""rx-usage 페이지 Playwright 테스트""" +from playwright.sync_api import sync_playwright +import time +import json + +def test_rx_usage_quick_order(): + with sync_playwright() as p: + browser = p.chromium.launch(headless=False) # 화면 보이게 + page = browser.new_page() + + # 콘솔 로그 캡처 + page.on("console", lambda msg: print(f"[CONSOLE] {msg.type}: {msg.text}")) + + # 네트워크 요청/응답 캡처 + def log_response(response): + if 'api/order' in response.url or 'quick-submit' in response.url: + print(f"\n[RESPONSE] {response.url}") + print(f" Status: {response.status}") + try: + body = response.json() + print(f" Body: {json.dumps(body, ensure_ascii=False, indent=2)}") + except: + print(f" Body: {response.text()[:500]}") + + page.on("response", log_response) + + print("="*60) + print("1. rx-usage 페이지 접속") + print("="*60) + page.goto("http://localhost:7001/admin/rx-usage") + page.wait_for_load_state("networkidle") + time.sleep(2) + + print("\n" + "="*60) + print("2. 데이터 로드 (조회 버튼 클릭)") + print("="*60) + # 조회 버튼 클릭 + search_btn = page.locator("button:has-text('조회')") + if search_btn.count() > 0: + search_btn.first.click() + time.sleep(3) + + print("\n" + "="*60) + print("3. 첫 번째 품목 행 더블클릭 (도매상 모달 열기)") + print("="*60) + # 테이블 행 찾기 + rows = page.locator("tr[data-idx]") + row_count = rows.count() + print(f" 품목 수: {row_count}") + + if row_count > 0: + # 첫 번째 품목 더블클릭 + rows.first.dblclick() + time.sleep(3) + + print("\n" + "="*60) + print("4. 도매상 모달에서 지오영 품목 확인") + print("="*60) + + # 지오영 테이블에서 재고 있는 품목 찾기 + geo_rows = page.locator(".geo-table tbody tr:not(.no-stock)") + geo_count = geo_rows.count() + print(f" 지오영 재고 있는 품목: {geo_count}개") + + if geo_count > 0: + # 첫 번째 품목 정보 출력 + first_row = geo_rows.first + product_name = first_row.locator(".geo-name").text_content() + stock = first_row.locator(".geo-stock").text_content() + print(f" 선택할 품목: {product_name}, 재고: {stock}") + + print("\n" + "="*60) + print("5. '담기' 버튼 클릭") + print("="*60) + add_btn = first_row.locator("button.geo-add-btn") + if add_btn.count() > 0: + add_btn.click() + time.sleep(1) + + # prompt 창에 수량 입력 (기본값 사용) + page.on("dialog", lambda dialog: dialog.accept()) + time.sleep(2) + + print("\n" + "="*60) + print("6. 장바구니 확인") + print("="*60) + cart_items = page.locator(".cart-item") + cart_count = cart_items.count() + print(f" 장바구니 품목: {cart_count}개") + + if cart_count > 0: + print("\n" + "="*60) + print("7. 퀵주문 버튼 클릭!") + print("="*60) + + # 퀵주문 버튼 찾기 + quick_order_btn = page.locator("button.cart-item-order").first + if quick_order_btn.count() > 0: + quick_order_btn.click() + time.sleep(1) + + # confirm 대화상자 수락 + page.on("dialog", lambda dialog: dialog.accept()) + time.sleep(5) + + print("\n" + "="*60) + print("8. 결과 확인") + print("="*60) + + # 토스트 메시지 확인 + toast = page.locator(".toast") + if toast.count() > 0: + toast_text = toast.text_content() + print(f" 토스트 메시지: {toast_text}") + + print("\n테스트 완료. 10초 후 브라우저 닫힘...") + time.sleep(10) + browser.close() + +if __name__ == "__main__": + test_rx_usage_quick_order() diff --git a/backend/test_selective_order.py b/backend/test_selective_order.py new file mode 100644 index 0000000..d587bae --- /dev/null +++ b/backend/test_selective_order.py @@ -0,0 +1,59 @@ +# -*- coding: utf-8 -*- +"""선별 주문 테스트 - 체크박스로 특정 품목만 주문""" +import sys; sys.path.insert(0, '.'); import wholesale_path +from wholesale import SooinSession + +s = SooinSession() +s.login() +s.clear_cart() + +# 1. 품목 2개 담기 +print('=== 1. 품목 2개 담기 ===') +r1 = s.search_products('코자정') +p1 = r1['items'][0] +s.add_to_cart(p1['internal_code'], qty=1, price=p1['price'], stock=p1['stock']) +print(f"담음: {p1['name']}") + +r2 = s.search_products('디카맥스') +p2 = r2['items'][0] +s.add_to_cart(p2['internal_code'], qty=1, price=p2['price'], stock=p2['stock']) +print(f"담음: {p2['name']}") + +# 2. 장바구니 확인 +print('\n=== 2. 장바구니 확인 ===') +cart = s.get_cart() +print(f"품목 수: {cart['total_items']}") +for item in cart['items']: + status = '활성' if item.get('active') else '취소' + print(f" [{status}] {item['product_name'][:25]} (row:{item['row_index']})") + +# 3. 코자정(row 0)만 취소 → 디카맥스만 주문되어야 함 +print('\n=== 3. 코자정 취소 (row 0) ===') +cancel_result = s.cancel_item(row_index=0) +print(f"취소 결과: {cancel_result}") + +# 4. 장바구니 다시 확인 +print('\n=== 4. 장바구니 재확인 ===') +cart2 = s.get_cart() +for item in cart2['items']: + status = '✅활성' if item.get('active') else '❌취소' + print(f" {status} {item['product_name'][:25]}") + +# 5. 주문 (취소 안 된 것만 나감) +print('\n=== 5. 주문 전송 ===') +order_result = s.submit_order() +print(f"주문 결과: {order_result}") + +# 6. 장바구니 확인 - 디카맥스만 주문됐으면, 코자정은 남아있어야 함 +print('\n=== 6. 주문 후 장바구니 ===') +cart3 = s.get_cart() +print(f"품목 수: {cart3['total_items']}") +for item in cart3['items']: + print(f" - {item['product_name'][:25]}") + +if cart3['total_items'] == 1: + print('\n🎉 성공! 취소된 품목(코자정)은 남고, 디카맥스만 주문됨!') +elif cart3['total_items'] == 0: + print('\n⚠️ 둘 다 주문됨 - 체크박스 로직 안 먹힘') +else: + print('\n🤔 예상 외 결과') diff --git a/backend/test_selective_order2.py b/backend/test_selective_order2.py new file mode 100644 index 0000000..a33f0de --- /dev/null +++ b/backend/test_selective_order2.py @@ -0,0 +1,68 @@ +# -*- coding: utf-8 -*- +"""선별 주문 테스트 - 모듈 리로드 포함""" +import sys; sys.path.insert(0, '.'); import wholesale_path + +# 모듈 리로드! +import importlib +import wholesale.sooin +importlib.reload(wholesale.sooin) + +from wholesale import SooinSession + +# 싱글톤 리셋 +SooinSession._instance = None + +s = SooinSession() +s.login() +s.clear_cart() + +# 1. 품목 2개 담기 +print('=== 1. 품목 2개 담기 ===') +r1 = s.search_products('코자정') +p1 = r1['items'][0] +s.add_to_cart(p1['internal_code'], qty=1, price=p1['price'], stock=p1['stock']) +print(f"담음: {p1['name']}") + +r2 = s.search_products('디카맥스') +p2 = r2['items'][0] +s.add_to_cart(p2['internal_code'], qty=1, price=p2['price'], stock=p2['stock']) +print(f"담음: {p2['name']}") + +# 2. 장바구니 확인 +print('\n=== 2. 장바구니 확인 ===') +cart = s.get_cart() +print(f"품목 수: {cart['total_items']}") +for item in cart['items']: + status = '활성' if item.get('active') else '취소' + print(f" [{status}] {item['product_name'][:25]} (row:{item['row_index']})") + +# 3. 첫 번째 품목(row 0) 취소 → 두 번째만 주문되어야 함 +print('\n=== 3. 첫 번째 품목 취소 (row 0) ===') +cancel_result = s.cancel_item(row_index=0) +print(f"취소 결과: {cancel_result.get('message')}") + +# 4. 장바구니 다시 확인 +print('\n=== 4. 장바구니 재확인 ===') +cart2 = s.get_cart() +for item in cart2['items']: + status = '✅활성' if item.get('active') else '❌취소' + print(f" {status} {item['product_name'][:25]}") + +# 5. 주문 (취소 안 된 것만 나감) +print('\n=== 5. 주문 전송 ===') +order_result = s.submit_order() +print(f"주문 결과: {order_result}") + +# 6. 장바구니 확인 +print('\n=== 6. 주문 후 장바구니 ===') +cart3 = s.get_cart() +print(f"품목 수: {cart3['total_items']}") +for item in cart3['items']: + print(f" - {item['product_name'][:25]}") + +if cart3['total_items'] == 1: + print('\n🎉 성공! 취소된 품목은 남고, 나머지만 주문됨!') +elif cart3['total_items'] == 0: + print('\n⚠️ 둘 다 주문됨 - 체크박스 로직 안 먹힘') +else: + print(f'\n🤔 예상 외 결과: {cart3["total_items"]}개 남음') diff --git a/backend/test_session.py b/backend/test_session.py new file mode 100644 index 0000000..b6242ec --- /dev/null +++ b/backend/test_session.py @@ -0,0 +1,38 @@ +# -*- coding: utf-8 -*- +import sys; sys.path.insert(0, '.'); import wholesale_path +from wholesale import SooinSession +from bs4 import BeautifulSoup + +s = SooinSession() +print('1. 로그인...') +s.login() + +print('\n2. 장바구니 비우기...') +s.clear_cart() + +print('\n3. Bag.asp 확인 (비우기 후)...') +resp1 = s.session.get(f'{s.BAG_VIEW_URL}?currVenCd={s.vendor_code}', timeout=15) +soup1 = BeautifulSoup(resp1.content, 'html.parser') +int_array1 = soup1.find('input', {'name': 'intArray'}) +print(f" intArray: {int_array1.get('value') if int_array1 else 'N/A'}") + +print('\n4. 코자정 검색...') +result = s.search_products('코자정') +product = result['items'][0] if result.get('items') else None +print(f" 제품: {product['name']}, 코드: {product['internal_code']}") + +print('\n5. add_to_cart 호출...') +cart_result = s.add_to_cart(product['internal_code'], qty=1, + price=product['price'], stock=product['stock']) +print(f" 결과: {cart_result}") + +print('\n6. Bag.asp 확인 (담기 후)...') +resp2 = s.session.get(f'{s.BAG_VIEW_URL}?currVenCd={s.vendor_code}', timeout=15) +soup2 = BeautifulSoup(resp2.content, 'html.parser') +int_array2 = soup2.find('input', {'name': 'intArray'}) +print(f" intArray: {int_array2.get('value') if int_array2 else 'N/A'}") + +# 품목 확인 +import re +rows = soup2.find_all('tr', id=re.compile(r'^bagLine')) +print(f" 품목 수: {len(rows)}") diff --git a/backend/test_sooin.py b/backend/test_sooin.py deleted file mode 100644 index 8f9c7ad..0000000 --- a/backend/test_sooin.py +++ /dev/null @@ -1,49 +0,0 @@ -# -*- 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 deleted file mode 100644 index 1da8b90..0000000 --- a/backend/test_sooin_full.py +++ /dev/null @@ -1,40 +0,0 @@ -# -*- 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_submit_detail.py b/backend/test_submit_detail.py new file mode 100644 index 0000000..64015c9 --- /dev/null +++ b/backend/test_submit_detail.py @@ -0,0 +1,64 @@ +# -*- coding: utf-8 -*- +import sys; sys.path.insert(0, '.'); import wholesale_path +from wholesale import SooinSession +from bs4 import BeautifulSoup + +s = SooinSession() +print('1. 로그인 & 장바구니 담기...') +s.login() +s.clear_cart() + +result = s.search_products('코자정') +product = result['items'][0] +s.add_to_cart(product['internal_code'], qty=1, + price=product['price'], stock=product['stock']) + +# 장바구니 확인 +resp = s.session.get(f'{s.BAG_VIEW_URL}?currVenCd={s.vendor_code}', timeout=15) +soup = BeautifulSoup(resp.content, 'html.parser') +int_array = soup.find('input', {'name': 'intArray'}) +print(f" intArray: {int_array.get('value')}") + +print('\n2. Form 데이터 수집...') +form = soup.find('form', {'id': 'frmBag'}) +form_data = {} +for inp in form.find_all('input'): + name = inp.get('name', '') + if not name: + continue + inp_type = inp.get('type', 'text').lower() + if inp_type == 'checkbox': + continue + form_data[name] = inp.get('value', '') + +# 주요 필드 출력 +print(f" kind: {form_data.get('kind')}") +print(f" intArray: {form_data.get('intArray')}") +print(f" currVenCd: {form_data.get('currVenCd')}") + +print('\n3. kind=order로 변경 후 POST...') +form_data['kind'] = 'order' +form_data['tx_memo'] = '디버그 테스트' + +print(f" 전송할 필드 수: {len(form_data)}") + +resp = s.session.post( + s.BAG_URL, # BagOrder.asp + data=form_data, + headers={ + 'Content-Type': 'application/x-www-form-urlencoded', + 'Referer': f'{s.BAG_VIEW_URL}?currVenCd={s.vendor_code}' + }, + timeout=30 +) + +print(f'\n4. 응답 분석...') +print(f" 상태코드: {resp.status_code}") +print(f" 응답 길이: {len(resp.text)}") +print(f"\n 응답 내용:\n{resp.text[:1000]}") + +print('\n5. 주문 후 장바구니 확인...') +resp2 = s.session.get(f'{s.BAG_VIEW_URL}?currVenCd={s.vendor_code}', timeout=15) +soup2 = BeautifulSoup(resp2.content, 'html.parser') +int_array2 = soup2.find('input', {'name': 'intArray'}) +print(f" intArray: {int_array2.get('value')}") diff --git a/backend/test_submit_order.py b/backend/test_submit_order.py new file mode 100644 index 0000000..e426d4e --- /dev/null +++ b/backend/test_submit_order.py @@ -0,0 +1,43 @@ +# -*- coding: utf-8 -*- +"""submit_order 메서드 테스트""" +import sys; sys.path.insert(0, '.'); import wholesale_path + +# 모듈 리로드 +import importlib +import wholesale.sooin +importlib.reload(wholesale.sooin) + +from wholesale import SooinSession + +s = SooinSession() +print('1. 로그인...') +s.login() + +print('2. 장바구니 비우기...') +s.clear_cart() + +print('3. 제품 검색 및 추가...') +result = s.search_products('코자정') +product = result['items'][0] +print(f" 제품: {product['name']} / {product['price']:,}원") + +s.add_to_cart(product['internal_code'], qty=1, + price=product['price'], stock=product['stock']) + +print('4. 장바구니 확인...') +cart = s.get_cart() +print(f" 품목 수: {cart['total_items']}") +print(f" 총액: {cart['total_amount']:,}원") + +print('\n5. 주문 전송...') +order_result = s.submit_order(memo="API 테스트") +print(f" 결과: {order_result}") + +if order_result.get('success'): + print('\n🎉 주문 성공!') +else: + print(f"\n❌ 주문 실패: {order_result.get('error')}") + +print('\n6. 주문 후 장바구니...') +cart2 = s.get_cart() +print(f" 품목 수: {cart2['total_items']}") diff --git a/backend/test_submit_xy.py b/backend/test_submit_xy.py new file mode 100644 index 0000000..5ae0d3e --- /dev/null +++ b/backend/test_submit_xy.py @@ -0,0 +1,66 @@ +# -*- coding: utf-8 -*- +import sys; sys.path.insert(0, '.'); import wholesale_path +from wholesale import SooinSession +from bs4 import BeautifulSoup + +s = SooinSession() +print('1. 준비...') +s.login() +s.clear_cart() + +result = s.search_products('코자정') +product = result['items'][0] +s.add_to_cart(product['internal_code'], qty=1, + price=product['price'], stock=product['stock']) + +resp = s.session.get(f'{s.BAG_VIEW_URL}?currVenCd={s.vendor_code}', timeout=15) +soup = BeautifulSoup(resp.content, 'html.parser') +form = soup.find('form', {'id': 'frmBag'}) + +form_data = {} +for inp in form.find_all('input'): + name = inp.get('name', '') + if not name: + continue + inp_type = inp.get('type', 'text').lower() + if inp_type == 'checkbox': + continue + form_data[name] = inp.get('value', '') + +form_data['kind'] = 'order' +form_data['tx_memo'] = '좌표 테스트' + +# x, y 좌표 추가! +form_data['x'] = '10' +form_data['y'] = '10' + +print(f" intArray: {form_data.get('intArray')}") +print(f" x, y 추가: {form_data.get('x')}, {form_data.get('y')}") + +print('\n2. POST...') +resp = s.session.post( + s.BAG_URL, + data=form_data, + headers={ + 'Content-Type': 'application/x-www-form-urlencoded', + 'Referer': f'{s.BAG_VIEW_URL}?currVenCd={s.vendor_code}' + }, + timeout=30 +) + +print(f" 응답 길이: {len(resp.text)}") + +# alert 내용 확인 +import re +alert_match = re.search(r'alert\("([^"]*)"\)', resp.text) +alert_msg = alert_match.group(1) if alert_match else 'N/A' +print(f" alert 메시지: '{alert_msg}'") + +print('\n3. 주문 후 장바구니...') +resp2 = s.session.get(f'{s.BAG_VIEW_URL}?currVenCd={s.vendor_code}', timeout=15) +soup2 = BeautifulSoup(resp2.content, 'html.parser') +int_array2 = soup2.find('input', {'name': 'intArray'}) +print(f" intArray: {int_array2.get('value')}") + +if int_array2.get('value') == '-1': + print('\n🎉 주문 성공!') diff --git a/backend/test_temp_save.py b/backend/test_temp_save.py new file mode 100644 index 0000000..b24cc48 --- /dev/null +++ b/backend/test_temp_save.py @@ -0,0 +1,81 @@ +# -*- coding: utf-8 -*- +"""방안 1: 임시 보관 방식 테스트""" +import sys; sys.path.insert(0, '.'); import wholesale_path + +import importlib +import wholesale.sooin +importlib.reload(wholesale.sooin) +from wholesale import SooinSession + +SooinSession._instance = None +s = SooinSession() +s.login() +s.clear_cart() + +# 시나리오: 기존 코자정이 담겨있고, 디카맥스만 주문하고 싶음 + +print('=== 1. 기존 품목 (코자정) 담기 ===') +r1 = s.search_products('코자정') +p1 = r1['items'][0] +s.add_to_cart(p1['internal_code'], qty=1, price=p1['price'], stock=p1['stock']) +print(f"기존 품목: {p1['name']}") + +print('\n=== 2. 새 품목 (디카맥스) 담기 ===') +r2 = s.search_products('디카맥스') +p2 = r2['items'][0] +s.add_to_cart(p2['internal_code'], qty=1, price=p2['price'], stock=p2['stock']) +print(f"새 품목: {p2['name']}") + +# 장바구니 확인 +cart = s.get_cart() +print(f"\n현재 장바구니: {cart['total_items']}개") + +# === 선별 주문 시작 === +print('\n' + '='*50) +print('=== 선별 주문: 디카맥스만 주문 ===') +print('='*50) + +# 3. 기존 품목 정보 저장 +print('\n3. 기존 품목 정보 저장') +existing_items = [] +for item in cart['items']: + # 디카맥스는 제외 (이번에 주문할 품목) + if '디카맥스' not in item['product_name']: + existing_items.append({ + 'internal_code': item['internal_code'], + 'quantity': item['quantity'], + 'price': item['unit_price'], + 'name': item['product_name'] + }) + print(f" 저장: {item['product_name']}") + +# 4. 장바구니 비우기 +print('\n4. 장바구니 비우기') +s.clear_cart() + +# 5. 주문할 품목만 다시 담기 +print('\n5. 디카맥스만 다시 담기') +s.add_to_cart(p2['internal_code'], qty=1, price=p2['price'], stock=p2['stock']) + +# 6. 주문 +print('\n6. 주문!') +result = s.submit_order() +print(f"결과: {result}") + +# 7. 기존 품목 복원 +print('\n7. 기존 품목 복원') +for item in existing_items: + s.add_to_cart(item['internal_code'], qty=item['quantity'], price=item['price'], stock=999) + print(f" 복원: {item['name']}") + +# 8. 최종 확인 +print('\n=== 8. 최종 장바구니 ===') +final_cart = s.get_cart() +print(f"품목 수: {final_cart['total_items']}") +for item in final_cart['items']: + print(f" - {item['product_name']}") + +if final_cart['total_items'] == 1 and '코자정' in final_cart['items'][0]['product_name']: + print('\n🎉 성공! 디카맥스만 주문되고 코자정은 복원됨!') +else: + print('\n❌ 실패') diff --git a/backend/test_temp_save2.py b/backend/test_temp_save2.py new file mode 100644 index 0000000..92c4410 --- /dev/null +++ b/backend/test_temp_save2.py @@ -0,0 +1,72 @@ +# -*- coding: utf-8 -*- +"""방안 1: 재고 있는 품목으로 테스트""" +import sys; sys.path.insert(0, '.'); import wholesale_path + +import importlib +import wholesale.sooin +importlib.reload(wholesale.sooin) +from wholesale import SooinSession + +SooinSession._instance = None +s = SooinSession() +s.login() +s.clear_cart() + +# 재고 있는 품목 검색 +print('=== 1. 재고 확인 ===') +r1 = s.search_products('코자정') +r2 = s.search_products('라식스') + +p1 = r1['items'][0] +p2 = r2['items'][0] +print(f"코자정: 재고 {p1['stock']}") +print(f"라식스: 재고 {p2['stock']}") + +# 기존 품목 담기 (코자정 - 나중에 복원할 것) +print('\n=== 2. 기존 품목 (코자정) 담기 ===') +s.add_to_cart(p1['internal_code'], qty=1, price=p1['price'], stock=p1['stock']) + +# 새 품목 담기 (라식스 - 주문할 것) +print('=== 3. 새 품목 (라식스) 담기 ===') +s.add_to_cart(p2['internal_code'], qty=1, price=p2['price'], stock=p2['stock']) + +cart = s.get_cart() +print(f"현재 장바구니: {cart['total_items']}개") +for item in cart['items']: + print(f" - {item['product_name'][:30]}") + +# === 선별 주문 === +print('\n' + '='*50) +print('=== 라식스만 주문! ===') +print('='*50) + +# 기존 품목 저장 +existing = [{'ic': p1['internal_code'], 'qty': 1, 'price': p1['price'], 'stock': p1['stock'], 'name': p1['name']}] +print(f"\n저장: {p1['name']}") + +# 장바구니 비우기 +print('장바구니 비우기...') +s.clear_cart() + +# 라식스만 담기 +print('라식스만 담기...') +s.add_to_cart(p2['internal_code'], qty=1, price=p2['price'], stock=p2['stock']) + +# 주문 +print('주문 전송...') +result = s.submit_order() +print(f"결과: {result}") + +# 복원 +print('\n코자정 복원...') +for e in existing: + s.add_to_cart(e['ic'], qty=e['qty'], price=e['price'], stock=e['stock']) + +# 최종 확인 +final = s.get_cart() +print(f"\n=== 최종 장바구니: {final['total_items']}개 ===") +for item in final['items']: + print(f" - {item['product_name'][:30]}") + +if final['total_items'] == 1: + print('\n🎉 성공! 라식스만 주문됨, 코자정 복원됨!') diff --git a/backend/test_wholesale_integration.py b/backend/test_wholesale_integration.py deleted file mode 100644 index af31d76..0000000 --- a/backend/test_wholesale_integration.py +++ /dev/null @@ -1,32 +0,0 @@ -# -*- 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/docs/AI_자동발주시스템_통합기획서_v1.html b/docs/AI_자동발주시스템_통합기획서_v1.html new file mode 100644 index 0000000..1f9154b --- /dev/null +++ b/docs/AI_자동발주시스템_통합기획서_v1.html @@ -0,0 +1,1148 @@ + + + + + + 🤖 AI 자동발주시스템 통합 기획서 + + + +

🤖 AI 자동발주시스템 통합 기획서

+
+

버전: 1.0
+작성일: 2026-03-06
+작성자: 용림 (with 약사님)
+상태: 기획 완료, 개발 대기

+
+
+

📋 목차

+
    +
  1. 비전 및 목표
  2. +
  3. 현재 구현 현황
  4. +
  5. 시스템 아키텍처
  6. +
  7. AI 학습 요소
  8. +
  9. 핵심 기능 설계
  10. +
  11. 데이터 모델
  12. +
  13. API 설계
  14. +
  15. 자동화 레벨
  16. +
  17. 알림 시스템
  18. +
  19. 개발 로드맵
  20. +
  21. 성공 지표
  22. +
+
+

1. 비전 및 목표

+

🎯 비전

+
+

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

+
+

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

+

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

+

핵심 가치

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
AS-ISTO-BE
매일 재고 확인AI가 자동 모니터링
수동으로 도매상 선택AI가 최적 도매상 선택
경험에 의존한 주문량데이터 기반 최적 주문량
주문 누락/지연 발생선제적 자동 주문
배송 마감 놓침마감시간 자동 알림
+

핵심 원칙

+
+

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

+
+ +
+

2. 현재 구현 현황

+

2.1 도매상 API (✅ 완료)

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
도매상재고조회장바구니주문취소/복원잔고월매출
지오영✅ 확정포함
수인약품
백제약품
+

2.2 주문 DB (✅ 완료)

+
orders.db
+├── wholesalers        # 도매상 마스터
+├── orders             # 주문 헤더
+├── order_items        # 주문 품목
+├── order_logs         # 주문 이력
+├── order_context      # AI 학습용 컨텍스트 ⭐
+├── daily_usage        # 일별 사용량 시계열
+└── order_patterns     # AI 분석 결과
+
+

2.3 배송 스케줄 (✅ 확인 완료)

+
┌──────────┬──────────┬──────────────┬──────────────┬──────────┐
+│ 도매상   │ 배송     │ 주문 마감    │ 도착 예정    │ 비고     │
+├──────────┼──────────┼──────────────┼──────────────┼──────────┤
+│ 지오영   │ 오전     │ 10:00        │ 11:30        │ 당일     │
+│          │ 오후     │ 13:00        │ 15:00        │ 당일     │
+├──────────┼──────────┼──────────────┼──────────────┼──────────┤
+│ 수인     │ 오후     │ 13:00        │ 14:30        │ 당일     │
+├──────────┼──────────┼──────────────┼──────────────┼──────────┤
+│ 백제     │ 익일     │ 16:00        │ 다음날 15:00 │ ⚠️ 익일  │
+└──────────┴──────────┴──────────────┴──────────────┴──────────┘
+
+

2.4 UI (✅ 완료)

+ +
+

3. 시스템 아키텍처

+

전체 흐름

+
┌─────────────────────────────────────────────────────────────────┐
+│                     AI 자동발주시스템                            │
+└─────────────────────────────────────────────────────────────────┘
+                                │
+        ┌───────────────────────┼───────────────────────┐
+        ▼                       ▼                       ▼
+┌───────────────┐      ┌───────────────┐      ┌───────────────┐
+│  데이터 수집   │      │   AI 분석     │      │   자동 실행    │
+│               │      │               │      │               │
+│ • POS 판매    │─────▶│ • 사용량 예측  │─────▶│ • 도매상 API  │
+│ • 처방전 조제  │      │ • 재고 분석   │      │ • 주문 실행   │
+│ • 현재 재고   │      │ • 주문 추천   │      │ • 결과 피드백  │
+│ • 도매상 재고  │      │ • 패턴 학습   │      │               │
+└───────────────┘      └───────────────┘      └───────────────┘
+        │                       │                       │
+        └───────────────────────┼───────────────────────┘
+                                ▼
+                    ┌───────────────────┐
+                    │    학습 루프       │
+                    │                   │
+                    │  주문 결과 평가    │
+                    │  → 모델 업데이트   │
+                    │  → 전략 조정      │
+                    └───────────────────┘
+
+

컴포넌트 구조

+
┌──────────────────────────────────────────────────────────────────┐
+│                         데이터 레이어                             │
+├──────────────────────────────────────────────────────────────────┤
+│  ┌────────────┐  ┌────────────┐  ┌────────────┐  ┌────────────┐ │
+│  │  PIT3000   │  │   SQLite   │  │  지오영    │  │   수인     │ │
+│  │  (MSSQL)   │  │  Orders DB │  │   API      │  │   API      │ │
+│  └─────┬──────┘  └─────┬──────┘  └─────┬──────┘  └─────┬──────┘ │
+│        └───────────────┴───────────────┴───────────────┘        │
+└────────────────────────────────┬─────────────────────────────────┘
+                                 ▼
+┌──────────────────────────────────────────────────────────────────┐
+│                         서비스 레이어                             │
+├──────────────────────────────────────────────────────────────────┤
+│  ┌─────────────────┐  ┌─────────────────┐  ┌─────────────────┐  │
+│  │  InventorySync  │  │  UsageAnalyzer  │  │  OrderExecutor  │  │
+│  │  재고 동기화     │  │  사용량 분석    │  │  주문 실행      │  │
+│  └─────────────────┘  └─────────────────┘  └─────────────────┘  │
+│  ┌─────────────────┐  ┌─────────────────┐  ┌─────────────────┐  │
+│  │  AIPredictor    │  │  AIOptimizer    │  │  AILearner      │  │
+│  │  수요 예측      │  │  규격/도매상    │  │  패턴 학습      │  │
+│  └─────────────────┘  └─────────────────┘  └─────────────────┘  │
+└──────────────────────────────────────────────────────────────────┘
+                                 │
+                                 ▼
+┌──────────────────────────────────────────────────────────────────┐
+│                         인터페이스 레이어                          │
+├──────────────────────────────────────────────────────────────────┤
+│  ┌─────────────────┐  ┌─────────────────┐  ┌─────────────────┐  │
+│  │   웹 대시보드    │  │  알림 시스템    │  │   관리자 앱     │  │
+│  │  재고/주문/AI   │  │  카톡/텔레그램  │  │  수동 개입      │  │
+│  └─────────────────┘  └─────────────────┘  └─────────────────┘  │
+└──────────────────────────────────────────────────────────────────┘
+
+
+

4. AI 학습 요소

+

4.1 규격 선택 학습 (Spec Selection)

+
⚠️ 중요: 전문의약품(ETC)은 보험약가 고정!
+- 30T든 300T든 1T당 가격 동일
+- 단가 효율은 OTC/비급여에서만 의미 있음
+
+학습 데이터:
+- 약품별 과거 주문 규격 (30T, 100T, 300T, 500T)
+- 각 규격 선택 시점의 재고/사용량
+- 선택 결과 (남은 재고, 다음 주문까지 기간)
+- 도매상별 규격 재고 현황
+
+학습 목표:
+- 사용량 대비 최적 규격 예측
+- 재고 있는 규격 우선 선택
+- 낭비 최소화 (유통기한 고려)
+- 소분 vs 대용량 선호도 파악
+
+

예시 시나리오:
+| 필요량 | 가능 규격 | AI 선택 | 이유 |
+|--------|-----------|---------|------|
+| 280T | 30T(재고50), 100T(품절), 300T(재고10) | 30T x 10 | 100T 품절, 소분 선호 |
+| 800T | 30T(재고100), 300T(재고5) | 300T x 3 | 대량, 재고 충분 |
+| 50T | 30T(재고20), 100T(재고10) | 30T x 2 | 소량, 빠른 회전 |

+

4.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
+    }
+
+

4.3 도매상 선택 학습 (Wholesaler Selection)

+
학습 데이터:
+- 도매상별 주문 빈도
+- 도매상별 재고 상황
+- 도매상별 배송 스케줄
+- 월별 한도 사용량
+- 분할 주문 패턴
+
+학습 목표:
+- 기본 도매상 선호도
+- 상황별 대체 도매상
+- 한도 고려한 분배
+- 배송 시간 고려 (긴급 시)
+
+

도매상 선택 로직:

+
def select_wholesaler(drug_code, quantity, need_by_time=None):
+    """
+    AI가 학습한 도매상 선택 로직
+
+    우선순위:
+    1. 재고 (있는 곳 우선)
+    2. 배송 (need_by_time 충족 가능한 곳)
+    3. 한도 (여유 있는 곳)
+    4. 선호도 (과거 패턴)
+    """
+    candidates = []
+
+    for ws in ['geoyoung', 'sooin', 'baekje']:
+        score = 0
+
+        # 1. 재고 체크
+        if has_stock(ws, drug_code, quantity):
+            score += 100
+        else:
+            continue  # 재고 없으면 제외
+
+        # 2. 배송 시간 체크
+        if need_by_time:
+            delivery = get_next_delivery(ws, need_by_time)
+            if delivery['can_deliver']:
+                score += 50
+            else:
+                score -= 30  # 감점
+
+        # 3. 한도 체크
+        limit_usage = get_limit_usage(ws)
+        if limit_usage < 0.9:
+            score += 30
+        elif limit_usage >= 1.0:
+            score -= 50  # 한도 초과
+
+        # 4. 학습된 선호도
+        score += ai_model.preference_score(ws, drug_code) * 20
+
+        candidates.append((ws, score))
+
+    return max(candidates, key=lambda x: x[1])[0]
+
+

4.4 주문 타이밍 학습

+
학습 데이터:
+- 하루 중 주문 시점 (오전/오후)
+- 요일별 주문 패턴
+- 배송 마감 시간 전 주문 여부
+
+학습 목표:
+- 최적 주문 시점 파악
+- 배송 마감 놓치지 않기
+- 분할 주문 (오전/오후) 패턴
+
+
+

5. 핵심 기능 설계

+

5.1 선주문 반영 시스템

+

목적: 같은 날 이미 주문한 품목 자동 차감

+
def calculate_order_qty(drug_code, usage_qty, current_stock):
+    # 오늘 "실제로" 주문 완료된 수량 조회
+    today_ordered = get_today_orders(drug_code)
+
+    # 필요량 = 사용량 - 현재고 - 선주문량
+    needed = usage_qty - current_stock - today_ordered
+
+    if needed > 0:
+        return calculate_spec_qty(needed)
+    return 0
+
+

⚠️ 핵심: 실제 주문만 카운트

+
SELECT SUM(oi.total_dose) as today_ordered
+FROM order_items oi
+JOIN orders o ON oi.order_id = o.id
+WHERE oi.drug_code = ?
+  AND o.order_date = DATE('now')
+  AND o.is_dry_run = 0              -- dry_run 제외!
+  AND oi.status IN ('success', 'submitted')
+
+

5.2 도매상 한도 관리

+

목적: 월별 거래 한도 설정 및 자동 분배

+
[한도 도달 시 동작]
+1. 90% 도달 → 경고 알림
+2. 100% 도달 → 다른 도매상으로 자동 전환
+3. 장바구니 단계에서 미리 분류
+
+

5.3 배송 스케줄 기반 주문

+

목적: 주문 마감시간 + 배송 도착시간 분리 관리

+
AI 판단 예시:
+
+현재 오전 11시, "오후 3시에 필요"
+
+→ 지오영 오전: 10시 마감 지남 ❌
+→ 지오영 오후: 13시 마감 → 15:00 도착 (⚠️ 딱 맞음)
+→ 수인: 13시 마감 → 14:30 도착 (✅ 여유)
+→ 백제: 내일 도착 ❌
+
+결론: 수인 추천 (14:30 도착, 30분 여유)
+
+

5.4 주문 실패 시 재시도

+
시나리오 1: 재고 없음
+- A도매상 재고 0 → B도매상 검색 → 재고 있으면 B로 주문
+
+시나리오 2: 주문 오류
+- A도매상 API 오류 → 3회 재시도 → 실패 시 B도매상
+
+시나리오 3: 부분 성공
+- 10개 품목 중 7개 성공, 3개 실패
+- 실패한 3개 → B도매상으로 자동 재시도
+
+[리포트]
+- 최종 주문 결과 리포트
+- 알림: "A도매상 품절로 B도매상으로 변경됨"
+
+
+

6. 데이터 모델

+

6.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,
+    usage_7d INTEGER,
+    usage_30d INTEGER,
+    avg_daily_usage REAL,
+
+    -- 주문 결정
+    ordered_spec TEXT,
+    ordered_qty INTEGER,
+    wholesaler_id TEXT,
+
+    -- 선택지 정보 (AI 학습용)
+    available_specs JSON,
+    spec_stocks JSON,
+    selection_reason TEXT,
+
+    -- 예측 vs 실제
+    predicted_days_coverage REAL,
+    actual_days_to_reorder INTEGER,
+
+    -- 결과 평가
+    was_optimal BOOLEAN,
+    stockout_occurred BOOLEAN,
+
+    created_at TIMESTAMP
+);
+
+

6.2 신규 테이블

+
-- 도매상 한도 관리
+CREATE TABLE wholesaler_limits (
+    id INTEGER PRIMARY KEY,
+    wholesaler_id TEXT NOT NULL,
+    monthly_limit INTEGER DEFAULT 0,
+    warning_threshold REAL DEFAULT 0.9,
+    priority INTEGER DEFAULT 1,
+    is_active INTEGER DEFAULT 1,
+    created_at TIMESTAMP,
+    FOREIGN KEY (wholesaler_id) REFERENCES wholesalers(id)
+);
+
+-- 배송 스케줄
+CREATE TABLE delivery_schedules (
+    id INTEGER PRIMARY KEY,
+    wholesaler_id TEXT NOT NULL,
+    delivery_seq INTEGER NOT NULL,
+    delivery_name TEXT,
+    order_cutoff_time TEXT NOT NULL,      -- 주문 마감 (HH:MM)
+    delivery_days_offset INTEGER DEFAULT 0, -- 0=당일, 1=익일
+    delivery_arrival_time TEXT NOT NULL,   -- 도착 예정 (HH:MM)
+    weekdays TEXT,                         -- JSON [1,2,3,4,5]
+    is_active INTEGER DEFAULT 1,
+    UNIQUE(wholesaler_id, delivery_seq)
+);
+
+-- 실제 배송 스케줄 데이터
+INSERT INTO delivery_schedules VALUES
+('geoyoung', 1, '오전배송', '10:00', 0, '11:30'),
+('geoyoung', 2, '오후배송', '13:00', 0, '15:00'),
+('sooin', 1, '오후배송', '13:00', 0, '14:30'),
+('baekje', 1, '익일배송', '16:00', 1, '15:00');
+
+-- 월별 사용량 추적
+CREATE TABLE wholesaler_monthly_usage (
+    id INTEGER PRIMARY KEY,
+    wholesaler_id TEXT NOT NULL,
+    year_month TEXT NOT NULL,
+    total_orders INTEGER DEFAULT 0,
+    total_amount INTEGER DEFAULT 0,
+    UNIQUE(wholesaler_id, year_month)
+);
+
+-- 주문 재시도 로그
+CREATE TABLE order_fallback_log (
+    id INTEGER PRIMARY KEY,
+    order_item_id INTEGER NOT NULL,
+    original_wholesaler TEXT NOT NULL,
+    original_error TEXT,
+    fallback_wholesaler TEXT NOT NULL,
+    fallback_result TEXT,
+    created_at TIMESTAMP
+);
+
+

6.3 기존 테이블 확장

+
-- orders 테이블 확장
+ALTER TABLE orders ADD COLUMN is_dry_run INTEGER DEFAULT 0;
+
+-- order_items 테이블 확장
+ALTER TABLE order_items ADD COLUMN fallback_from_wholesaler TEXT;
+ALTER TABLE order_items ADD COLUMN prior_order_qty INTEGER DEFAULT 0;
+
+
+

7. API 설계

+

7.1 도매상 관리 API

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
엔드포인트메서드기능
/api/wholesaler/limitsGET한도 조회
/api/wholesaler/limits/{id}PUT한도 설정
/api/wholesaler/schedulesGET배송 스케줄
/api/wholesaler/can-deliver-byPOST배송 가능 여부
+

7.2 주문 API

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
엔드포인트메서드기능
/api/order/today/{drug_code}GET오늘 주문량
/api/order/recommend-specPOST규격 추천
/api/order/createPOST주문 생성
/api/order/submitPOST주문 제출 (dry_run 지원)
/api/order/retryPOST실패 재시도
+

7.3 AI API

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
엔드포인트메서드기능
/api/ai/daily-analysisGET일일 분석
/api/ai/recommendationsGET주문 추천
/api/ai/training-dataGET학습 데이터
/api/ai/patterns/{drug_code}GET패턴 분석
+
+

8. 자동화 레벨

+

Level 0: 수동

+ +

Level 1: 반자동

+ +

Level 2: 조건부 자동

+ +

Level 3: 완전 자동

+ +
def should_auto_execute(order_plan):
+    level = 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 trusted_drugs,
+            order_plan['urgency'] != 'critical'
+        ]
+        return all(conditions)
+
+    if level == 3:
+        return not is_anomaly(order_plan)
+
+
+

9. 알림 시스템

+

알림 유형

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
유형조건우선순위
승인 요청자동 실행 안 되는 주문높음
주문 완료자동 주문 실행됨보통
한도 경고90% 도달높음
품절 긴급재고 0, 당일 필요긴급
배송 마감마감 30분 전높음
도매상 변경품절로 다른 도매상보통
+

알림 예시

+
📦 주문 승인 요청
+
+약품: 콩코르정 2.5mg
+현재고: 45개 (3일치)
+추천 주문: 300T x 2박스
+도매상: 지오영 (점심배송 11:00 마감)
+예상 금액: 72,000원
+
+[승인] [수정] [거절]
+
+
⚠️ 배송 마감 알림
+
+지오영 오후배송 마감 30분 전!
+현재 장바구니: 5품목
+
+13:00까지 주문하지 않으면 다음 배송은 내일입니다.
+
+[지금 주문] [나중에]
+
+
+

10. 개발 로드맵

+

Phase 1: 핵심 기반 (1주차)

+ +

Phase 2: 주문 자동화 (2주차)

+ +

Phase 3: UI 개선 (2주차)

+ +

Phase 4: AI 학습 (3주차)

+ +

Phase 5: 완전 자동화 (4주차~)

+ +
+

11. 성공 지표 (KPI)

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
지표현재목표
주문 소요 시간30분/일0분 (자동)
품절 발생률5%<1%
재고 회전율-+20%
배송 마감 놓침가끔0회
주문 비용 절감-5-10% (OTC)
+
+

📚 참고 문서

+ +
+
+

🐉 용림: 이 문서는 AI_ERP_AUTO_ORDER_SYSTEM.md(비전/AI모델)와
+자동발주시스템_고도화_기획서_v2.md(API/DB상세)를 통합한 마스터 기획서입니다.

+
+ + \ No newline at end of file diff --git a/docs/AI_자동발주시스템_통합기획서_v1.md b/docs/AI_자동발주시스템_통합기획서_v1.md new file mode 100644 index 0000000..8bbcd5a --- /dev/null +++ b/docs/AI_자동발주시스템_통합기획서_v1.md @@ -0,0 +1,692 @@ +# 🤖 AI 자동발주시스템 통합 기획서 + +> **버전**: 1.0 +> **작성일**: 2026-03-06 +> **작성자**: 용림 (with 약사님) +> **상태**: 기획 완료, 개발 대기 + +--- + +## 📋 목차 + +1. [비전 및 목표](#1-비전-및-목표) +2. [현재 구현 현황](#2-현재-구현-현황) +3. [시스템 아키텍처](#3-시스템-아키텍처) +4. [AI 학습 요소](#4-ai-학습-요소) +5. [핵심 기능 설계](#5-핵심-기능-설계) +6. [데이터 모델](#6-데이터-모델) +7. [API 설계](#7-api-설계) +8. [자동화 레벨](#8-자동화-레벨) +9. [알림 시스템](#9-알림-시스템) +10. [개발 로드맵](#10-개발-로드맵) +11. [성공 지표](#11-성공-지표) + +--- + +## 1. 비전 및 목표 + +### 🎯 비전 +> **"약사님이 주문에 신경 쓰지 않아도 되는 약국"** + +AI가 사용량, 재고, 도매상 상황, 과거 주문 패턴을 학습하여: +- **언제** 주문할지 +- **어느 도매상**에 주문할지 +- **어떤 규격**으로 주문할지 +- **얼마나** 주문할지 + +모든 것을 자동으로 결정하고 실행합니다. + +### 핵심 가치 + +| AS-IS | TO-BE | +|-------|-------| +| 매일 재고 확인 | AI가 자동 모니터링 | +| 수동으로 도매상 선택 | AI가 최적 도매상 선택 | +| 경험에 의존한 주문량 | 데이터 기반 최적 주문량 | +| 주문 누락/지연 발생 | 선제적 자동 주문 | +| 배송 마감 놓침 | 마감시간 자동 알림 | + +### 핵심 원칙 + +> **"AI는 대체하는 것이 아니라, 약사님의 방식을 자동화합니다."** + +- 약사님이 항상 지오영에 먼저 주문하면 → AI도 지오영 우선 +- 약사님이 300T보다 30T를 선호하면 → AI도 소량 주문 +- 약사님이 여유 있게 주문하면 → AI도 안전 재고 확보 +- 약사님이 가격에 민감하면 → AI도 최저가 추적 (OTC/비급여) + +--- + +## 2. 현재 구현 현황 + +### 2.1 도매상 API (✅ 완료) + +| 도매상 | 재고조회 | 장바구니 | 주문 | 취소/복원 | 잔고 | 월매출 | +|--------|:--------:|:--------:|:----:|:---------:|:----:|:------:| +| **지오영** | ✅ | ✅ | ✅ 확정포함 | ✅ | ✅ | ✅ | +| **수인약품** | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | +| **백제약품** | ✅ | ✅ | ✅ | ⏳ | ✅ | ✅ | + +### 2.2 주문 DB (✅ 완료) + +``` +orders.db +├── wholesalers # 도매상 마스터 +├── orders # 주문 헤더 +├── order_items # 주문 품목 +├── order_logs # 주문 이력 +├── order_context # AI 학습용 컨텍스트 ⭐ +├── daily_usage # 일별 사용량 시계열 +└── order_patterns # AI 분석 결과 +``` + +### 2.3 배송 스케줄 (✅ 확인 완료) + +``` +┌──────────┬──────────┬──────────────┬──────────────┬──────────┐ +│ 도매상 │ 배송 │ 주문 마감 │ 도착 예정 │ 비고 │ +├──────────┼──────────┼──────────────┼──────────────┼──────────┤ +│ 지오영 │ 오전 │ 10:00 │ 11:30 │ 당일 │ +│ │ 오후 │ 13:00 │ 15:00 │ 당일 │ +├──────────┼──────────┼──────────────┼──────────────┼──────────┤ +│ 수인 │ 오후 │ 13:00 │ 14:30 │ 당일 │ +├──────────┼──────────┼──────────────┼──────────────┼──────────┤ +│ 백제 │ 익일 │ 16:00 │ 다음날 15:00 │ ⚠️ 익일 │ +└──────────┴──────────┴──────────────┴──────────────┴──────────┘ +``` + +### 2.4 UI (✅ 완료) + +- Rx 사용량 페이지 (처방 기반) +- 장바구니 모달 +- 도매상 잔고/월매출 모달 + +--- + +## 3. 시스템 아키텍처 + +### 전체 흐름 + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ AI 자동발주시스템 │ +└─────────────────────────────────────────────────────────────────┘ + │ + ┌───────────────────────┼───────────────────────┐ + ▼ ▼ ▼ +┌───────────────┐ ┌───────────────┐ ┌───────────────┐ +│ 데이터 수집 │ │ AI 분석 │ │ 자동 실행 │ +│ │ │ │ │ │ +│ • POS 판매 │─────▶│ • 사용량 예측 │─────▶│ • 도매상 API │ +│ • 처방전 조제 │ │ • 재고 분석 │ │ • 주문 실행 │ +│ • 현재 재고 │ │ • 주문 추천 │ │ • 결과 피드백 │ +│ • 도매상 재고 │ │ • 패턴 학습 │ │ │ +└───────────────┘ └───────────────┘ └───────────────┘ + │ │ │ + └───────────────────────┼───────────────────────┘ + ▼ + ┌───────────────────┐ + │ 학습 루프 │ + │ │ + │ 주문 결과 평가 │ + │ → 모델 업데이트 │ + │ → 전략 조정 │ + └───────────────────┘ +``` + +### 컴포넌트 구조 + +``` +┌──────────────────────────────────────────────────────────────────┐ +│ 데이터 레이어 │ +├──────────────────────────────────────────────────────────────────┤ +│ ┌────────────┐ ┌────────────┐ ┌────────────┐ ┌────────────┐ │ +│ │ PIT3000 │ │ SQLite │ │ 지오영 │ │ 수인 │ │ +│ │ (MSSQL) │ │ Orders DB │ │ API │ │ API │ │ +│ └─────┬──────┘ └─────┬──────┘ └─────┬──────┘ └─────┬──────┘ │ +│ └───────────────┴───────────────┴───────────────┘ │ +└────────────────────────────────┬─────────────────────────────────┘ + ▼ +┌──────────────────────────────────────────────────────────────────┐ +│ 서비스 레이어 │ +├──────────────────────────────────────────────────────────────────┤ +│ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ │ +│ │ InventorySync │ │ UsageAnalyzer │ │ OrderExecutor │ │ +│ │ 재고 동기화 │ │ 사용량 분석 │ │ 주문 실행 │ │ +│ └─────────────────┘ └─────────────────┘ └─────────────────┘ │ +│ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ │ +│ │ AIPredictor │ │ AIOptimizer │ │ AILearner │ │ +│ │ 수요 예측 │ │ 규격/도매상 │ │ 패턴 학습 │ │ +│ └─────────────────┘ └─────────────────┘ └─────────────────┘ │ +└──────────────────────────────────────────────────────────────────┘ + │ + ▼ +┌──────────────────────────────────────────────────────────────────┐ +│ 인터페이스 레이어 │ +├──────────────────────────────────────────────────────────────────┤ +│ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ │ +│ │ 웹 대시보드 │ │ 알림 시스템 │ │ 관리자 앱 │ │ +│ │ 재고/주문/AI │ │ 카톡/텔레그램 │ │ 수동 개입 │ │ +│ └─────────────────┘ └─────────────────┘ └─────────────────┘ │ +└──────────────────────────────────────────────────────────────────┘ +``` + +--- + +## 4. AI 학습 요소 + +### 4.1 규격 선택 학습 (Spec Selection) + +``` +⚠️ 중요: 전문의약품(ETC)은 보험약가 고정! +- 30T든 300T든 1T당 가격 동일 +- 단가 효율은 OTC/비급여에서만 의미 있음 + +학습 데이터: +- 약품별 과거 주문 규격 (30T, 100T, 300T, 500T) +- 각 규격 선택 시점의 재고/사용량 +- 선택 결과 (남은 재고, 다음 주문까지 기간) +- 도매상별 규격 재고 현황 + +학습 목표: +- 사용량 대비 최적 규격 예측 +- 재고 있는 규격 우선 선택 +- 낭비 최소화 (유통기한 고려) +- 소분 vs 대용량 선호도 파악 +``` + +**예시 시나리오:** +| 필요량 | 가능 규격 | AI 선택 | 이유 | +|--------|-----------|---------|------| +| 280T | 30T(재고50), 100T(품절), 300T(재고10) | 30T x 10 | 100T 품절, 소분 선호 | +| 800T | 30T(재고100), 300T(재고5) | 300T x 3 | 대량, 재고 충분 | +| 50T | 30T(재고20), 100T(재고10) | 30T x 2 | 소량, 빠른 회전 | + +### 4.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 + } +``` + +### 4.3 도매상 선택 학습 (Wholesaler Selection) + +``` +학습 데이터: +- 도매상별 주문 빈도 +- 도매상별 재고 상황 +- 도매상별 배송 스케줄 +- 월별 한도 사용량 +- 분할 주문 패턴 + +학습 목표: +- 기본 도매상 선호도 +- 상황별 대체 도매상 +- 한도 고려한 분배 +- 배송 시간 고려 (긴급 시) +``` + +**도매상 선택 로직:** +```python +def select_wholesaler(drug_code, quantity, need_by_time=None): + """ + AI가 학습한 도매상 선택 로직 + + 우선순위: + 1. 재고 (있는 곳 우선) + 2. 배송 (need_by_time 충족 가능한 곳) + 3. 한도 (여유 있는 곳) + 4. 선호도 (과거 패턴) + """ + candidates = [] + + for ws in ['geoyoung', 'sooin', 'baekje']: + score = 0 + + # 1. 재고 체크 + if has_stock(ws, drug_code, quantity): + score += 100 + else: + continue # 재고 없으면 제외 + + # 2. 배송 시간 체크 + if need_by_time: + delivery = get_next_delivery(ws, need_by_time) + if delivery['can_deliver']: + score += 50 + else: + score -= 30 # 감점 + + # 3. 한도 체크 + limit_usage = get_limit_usage(ws) + if limit_usage < 0.9: + score += 30 + elif limit_usage >= 1.0: + score -= 50 # 한도 초과 + + # 4. 학습된 선호도 + score += ai_model.preference_score(ws, drug_code) * 20 + + candidates.append((ws, score)) + + return max(candidates, key=lambda x: x[1])[0] +``` + +### 4.4 주문 타이밍 학습 + +``` +학습 데이터: +- 하루 중 주문 시점 (오전/오후) +- 요일별 주문 패턴 +- 배송 마감 시간 전 주문 여부 + +학습 목표: +- 최적 주문 시점 파악 +- 배송 마감 놓치지 않기 +- 분할 주문 (오전/오후) 패턴 +``` + +--- + +## 5. 핵심 기능 설계 + +### 5.1 선주문 반영 시스템 + +**목적**: 같은 날 이미 주문한 품목 자동 차감 + +```python +def calculate_order_qty(drug_code, usage_qty, current_stock): + # 오늘 "실제로" 주문 완료된 수량 조회 + today_ordered = get_today_orders(drug_code) + + # 필요량 = 사용량 - 현재고 - 선주문량 + needed = usage_qty - current_stock - today_ordered + + if needed > 0: + return calculate_spec_qty(needed) + return 0 +``` + +**⚠️ 핵심: 실제 주문만 카운트** + +```sql +SELECT SUM(oi.total_dose) as today_ordered +FROM order_items oi +JOIN orders o ON oi.order_id = o.id +WHERE oi.drug_code = ? + AND o.order_date = DATE('now') + AND o.is_dry_run = 0 -- dry_run 제외! + AND oi.status IN ('success', 'submitted') +``` + +### 5.2 도매상 한도 관리 + +**목적**: 월별 거래 한도 설정 및 자동 분배 + +``` +[한도 도달 시 동작] +1. 90% 도달 → 경고 알림 +2. 100% 도달 → 다른 도매상으로 자동 전환 +3. 장바구니 단계에서 미리 분류 +``` + +### 5.3 배송 스케줄 기반 주문 + +**목적**: 주문 마감시간 + 배송 도착시간 분리 관리 + +``` +AI 판단 예시: + +현재 오전 11시, "오후 3시에 필요" + +→ 지오영 오전: 10시 마감 지남 ❌ +→ 지오영 오후: 13시 마감 → 15:00 도착 (⚠️ 딱 맞음) +→ 수인: 13시 마감 → 14:30 도착 (✅ 여유) +→ 백제: 내일 도착 ❌ + +결론: 수인 추천 (14:30 도착, 30분 여유) +``` + +### 5.4 주문 실패 시 재시도 + +``` +시나리오 1: 재고 없음 +- A도매상 재고 0 → B도매상 검색 → 재고 있으면 B로 주문 + +시나리오 2: 주문 오류 +- A도매상 API 오류 → 3회 재시도 → 실패 시 B도매상 + +시나리오 3: 부분 성공 +- 10개 품목 중 7개 성공, 3개 실패 +- 실패한 3개 → B도매상으로 자동 재시도 + +[리포트] +- 최종 주문 결과 리포트 +- 알림: "A도매상 품절로 B도매상으로 변경됨" +``` + +--- + +## 6. 데이터 모델 + +### 6.1 핵심 테이블 (기존) + +```sql +-- 주문 컨텍스트 (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, + usage_7d INTEGER, + usage_30d INTEGER, + avg_daily_usage REAL, + + -- 주문 결정 + ordered_spec TEXT, + ordered_qty INTEGER, + wholesaler_id TEXT, + + -- 선택지 정보 (AI 학습용) + available_specs JSON, + spec_stocks JSON, + selection_reason TEXT, + + -- 예측 vs 실제 + predicted_days_coverage REAL, + actual_days_to_reorder INTEGER, + + -- 결과 평가 + was_optimal BOOLEAN, + stockout_occurred BOOLEAN, + + created_at TIMESTAMP +); +``` + +### 6.2 신규 테이블 + +```sql +-- 도매상 한도 관리 +CREATE TABLE wholesaler_limits ( + id INTEGER PRIMARY KEY, + wholesaler_id TEXT NOT NULL, + monthly_limit INTEGER DEFAULT 0, + warning_threshold REAL DEFAULT 0.9, + priority INTEGER DEFAULT 1, + is_active INTEGER DEFAULT 1, + created_at TIMESTAMP, + FOREIGN KEY (wholesaler_id) REFERENCES wholesalers(id) +); + +-- 배송 스케줄 +CREATE TABLE delivery_schedules ( + id INTEGER PRIMARY KEY, + wholesaler_id TEXT NOT NULL, + delivery_seq INTEGER NOT NULL, + delivery_name TEXT, + order_cutoff_time TEXT NOT NULL, -- 주문 마감 (HH:MM) + delivery_days_offset INTEGER DEFAULT 0, -- 0=당일, 1=익일 + delivery_arrival_time TEXT NOT NULL, -- 도착 예정 (HH:MM) + weekdays TEXT, -- JSON [1,2,3,4,5] + is_active INTEGER DEFAULT 1, + UNIQUE(wholesaler_id, delivery_seq) +); + +-- 실제 배송 스케줄 데이터 +INSERT INTO delivery_schedules VALUES +('geoyoung', 1, '오전배송', '10:00', 0, '11:30'), +('geoyoung', 2, '오후배송', '13:00', 0, '15:00'), +('sooin', 1, '오후배송', '13:00', 0, '14:30'), +('baekje', 1, '익일배송', '16:00', 1, '15:00'); + +-- 월별 사용량 추적 +CREATE TABLE wholesaler_monthly_usage ( + id INTEGER PRIMARY KEY, + wholesaler_id TEXT NOT NULL, + year_month TEXT NOT NULL, + total_orders INTEGER DEFAULT 0, + total_amount INTEGER DEFAULT 0, + UNIQUE(wholesaler_id, year_month) +); + +-- 주문 재시도 로그 +CREATE TABLE order_fallback_log ( + id INTEGER PRIMARY KEY, + order_item_id INTEGER NOT NULL, + original_wholesaler TEXT NOT NULL, + original_error TEXT, + fallback_wholesaler TEXT NOT NULL, + fallback_result TEXT, + created_at TIMESTAMP +); +``` + +### 6.3 기존 테이블 확장 + +```sql +-- orders 테이블 확장 +ALTER TABLE orders ADD COLUMN is_dry_run INTEGER DEFAULT 0; + +-- order_items 테이블 확장 +ALTER TABLE order_items ADD COLUMN fallback_from_wholesaler TEXT; +ALTER TABLE order_items ADD COLUMN prior_order_qty INTEGER DEFAULT 0; +``` + +--- + +## 7. API 설계 + +### 7.1 도매상 관리 API + +| 엔드포인트 | 메서드 | 기능 | +|------------|--------|------| +| `/api/wholesaler/limits` | GET | 한도 조회 | +| `/api/wholesaler/limits/{id}` | PUT | 한도 설정 | +| `/api/wholesaler/schedules` | GET | 배송 스케줄 | +| `/api/wholesaler/can-deliver-by` | POST | 배송 가능 여부 | + +### 7.2 주문 API + +| 엔드포인트 | 메서드 | 기능 | +|------------|--------|------| +| `/api/order/today/{drug_code}` | GET | 오늘 주문량 | +| `/api/order/recommend-spec` | POST | 규격 추천 | +| `/api/order/create` | POST | 주문 생성 | +| `/api/order/submit` | POST | 주문 제출 (dry_run 지원) | +| `/api/order/retry` | POST | 실패 재시도 | + +### 7.3 AI API + +| 엔드포인트 | 메서드 | 기능 | +|------------|--------|------| +| `/api/ai/daily-analysis` | GET | 일일 분석 | +| `/api/ai/recommendations` | GET | 주문 추천 | +| `/api/ai/training-data` | GET | 학습 데이터 | +| `/api/ai/patterns/{drug_code}` | GET | 패턴 분석 | + +--- + +## 8. 자동화 레벨 + +### Level 0: 수동 +- AI 추천만 제공 +- 모든 주문은 수동 실행 + +### Level 1: 반자동 +- AI가 주문 계획 생성 +- 약사님 승인 후 자동 실행 +- 알림: 승인 요청 + +### Level 2: 조건부 자동 +- 신뢰도 높은 주문은 자동 실행 +- 신뢰도 낮은 주문만 승인 요청 +- 조건: + - 자주 주문하는 품목 + - 금액 임계값 이하 + - 긴급하지 않은 주문 + +### Level 3: 완전 자동 +- 모든 주문 자동 실행 +- 이상 상황만 알림 +- 약사님은 대시보드로 모니터링 + +```python +def should_auto_execute(order_plan): + level = 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 trusted_drugs, + order_plan['urgency'] != 'critical' + ] + return all(conditions) + + if level == 3: + return not is_anomaly(order_plan) +``` + +--- + +## 9. 알림 시스템 + +### 알림 유형 + +| 유형 | 조건 | 우선순위 | +|------|------|----------| +| 승인 요청 | 자동 실행 안 되는 주문 | 높음 | +| 주문 완료 | 자동 주문 실행됨 | 보통 | +| 한도 경고 | 90% 도달 | 높음 | +| 품절 긴급 | 재고 0, 당일 필요 | 긴급 | +| 배송 마감 | 마감 30분 전 | 높음 | +| 도매상 변경 | 품절로 다른 도매상 | 보통 | + +### 알림 예시 + +``` +📦 주문 승인 요청 + +약품: 콩코르정 2.5mg +현재고: 45개 (3일치) +추천 주문: 300T x 2박스 +도매상: 지오영 (점심배송 11:00 마감) +예상 금액: 72,000원 + +[승인] [수정] [거절] +``` + +``` +⚠️ 배송 마감 알림 + +지오영 오후배송 마감 30분 전! +현재 장바구니: 5품목 + +13:00까지 주문하지 않으면 다음 배송은 내일입니다. + +[지금 주문] [나중에] +``` + +--- + +## 10. 개발 로드맵 + +### Phase 1: 핵심 기반 (1주차) +- [x] 도매상 API 연동 (3개) +- [x] 주문 DB 스키마 +- [x] dry_run 테스트 모드 +- [ ] 선주문 조회 API +- [ ] 도매상 한도 테이블 +- [ ] 배송 스케줄 테이블 + +### Phase 2: 주문 자동화 (2주차) +- [ ] 규격 추천 API +- [ ] 한도 체크 로직 +- [ ] 주문 재시도 로직 +- [ ] 장바구니 동기화 + +### Phase 3: UI 개선 (2주차) +- [ ] 한도 대시보드 +- [ ] 주문 화면 (선주문 반영) +- [ ] 배송 스케줄 표시 + +### Phase 4: AI 학습 (3주차) +- [ ] 피드백 루프 구현 +- [ ] 주문 평가 시스템 +- [ ] 패턴 학습 (규격, 도매상) +- [ ] 수요 예측 (단순 이동평균) + +### Phase 5: 완전 자동화 (4주차~) +- [ ] Level 1 자동화 +- [ ] 알림 시스템 연동 +- [ ] Level 2 조건부 자동화 +- [ ] 모니터링 대시보드 + +--- + +## 11. 성공 지표 (KPI) + +| 지표 | 현재 | 목표 | +|------|------|------| +| 주문 소요 시간 | 30분/일 | 0분 (자동) | +| 품절 발생률 | 5% | <1% | +| 재고 회전율 | - | +20% | +| 배송 마감 놓침 | 가끔 | 0회 | +| 주문 비용 절감 | - | 5-10% (OTC) | + +--- + +## 📚 참고 문서 + +- 어제 작성 (AI 비전/모델): `docs/AI_ERP_AUTO_ORDER_SYSTEM.md` +- 오늘 작성 (API/DB 상세): `docs/자동발주시스템_고도화_기획서_v2.md` +- 도매상 API 분석: `docs/GEOYOUNG_API_REVERSE_ENGINEERING.md` +- Rx 사용량 가이드: `docs/RX_USAGE_GEOYOUNG_GUIDE.md` + +--- + +> 🐉 **용림**: 이 문서는 AI_ERP_AUTO_ORDER_SYSTEM.md(비전/AI모델)와 +> 자동발주시스템_고도화_기획서_v2.md(API/DB상세)를 통합한 마스터 기획서입니다. diff --git a/docs/자동발주시스템_고도화_기획서_v2.md b/docs/자동발주시스템_고도화_기획서_v2.md new file mode 100644 index 0000000..54ed0dd --- /dev/null +++ b/docs/자동발주시스템_고도화_기획서_v2.md @@ -0,0 +1,823 @@ +# 🏥 자동발주시스템 고도화 기획서 v2 + +> **작성일**: 2026-03-06 +> **작성자**: 용림 (with 약사님) +> **상태**: 기획 검토 중 + +--- + +## 📋 목차 + +1. [현재 구현 현황](#1-현재-구현-현황) +2. [핵심 목표](#2-핵심-목표) +3. [신규 기능 기획](#3-신규-기능-기획) +4. [API 개발 계획](#4-api-개발-계획) +5. [DB 스키마 확장](#5-db-스키마-확장) +6. [UI 개선 계획](#6-ui-개선-계획) +7. [개발 우선순위](#7-개발-우선순위) + +--- + +## 1. 현재 구현 현황 + +### 1.1 도매상 API (✅ 완료) + +| 도매상 | 재고조회 | 장바구니 | 주문 | 취소/복원 | 잔고 | 월매출 | +|--------|:--------:|:--------:|:----:|:---------:|:----:|:------:| +| **지오영** | ✅ | ✅ | ✅ 확정포함 | ✅ (삭제만) | ✅ | ✅ | +| **수인약품** | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | +| **백제약품** | ✅ | ✅ | ✅ | ⏳ | ✅ | ✅ | + +### 1.2 주문 DB 스키마 (✅ 완료) + +``` +orders.db +├── wholesalers # 도매상 정보 +├── orders # 주문 헤더 +├── order_items # 주문 품목 +├── order_logs # 주문 로그 +├── order_context # AI 학습용 컨텍스트 +├── daily_usage # 일별 사용량 +└── order_patterns # 주문 패턴 (AI용) +``` + +### 1.3 통합 주문 API (✅ 완료) + +| 엔드포인트 | 기능 | dry_run | +|------------|------|:-------:| +| `POST /api/order/create` | 주문 생성 (draft) | - | +| `POST /api/order/submit` | 주문 제출 | ✅ | +| `POST /api/order/quick-submit` | 빠른 주문 | ✅ | +| `GET /api/order/history` | 주문 이력 | - | +| `GET /api/order/ai/training-data` | AI 학습 데이터 | - | + +### 1.4 UI 현황 (✅ 완료) + +- **Rx 사용량 페이지**: 처방 기반 사용량 조회 + 주문수량 계산 +- **장바구니 모달**: 선택 품목 담기 + 도매상 선택 +- **도매상 잔고 모달**: 잔고 + 월매출 동시 표시 + +--- + +## 2. 핵심 목표 + +### 🎯 최종 목표 +> **사용량 기반 AI 분석 통합주문 및 자동화주문 시스템** + +### 2.1 핵심 시나리오 + +``` +📅 하루 주문 흐름 + +[오전 10시] ───────────────────────────────────────────── + │ + ├─ 사용량 집계: 아세탑 500T 사용 + ├─ 규격 판단: 30T x 10개? 300T x 2개? + │ └─ 도매상 재고 확인 → 재고 있는 것 우선 + ├─ 도매상 선택: A도매상 (배송 3회/일) + │ └─ 월 한도 확인 (5000만원 중 3000만원 사용) + ├─ 주문 실행: 300T x 2개 = 600T + └─ 로깅: orders.db에 기록 + +[오후 4시] ────────────────────────────────────────────── + │ + ├─ 사용량 재집계: 아세탑 910T 사용 + ├─ 선주문 반영: 오전 300T 주문 확인 + │ └─ 남은 필요량: 910 - 300 = 610T + ├─ 추가 주문: 300T x 2개 + 30T x 1개 = 630T + └─ 도매상 재선택 (한도/재고 기반) +``` + +### 2.2 핵심 해결 과제 + +| # | 과제 | 현재 상태 | 목표 | +|---|------|----------|------| +| 1 | 선주문 반영 | ❌ 미구현 | 같은 날 선주문량 자동 차감 | +| 2 | 규격 자동 선택 | ⏳ 부분 | 재고+경제성 기반 자동 판단 | +| 3 | 도매상 한도 관리 | ❌ 미구현 | 월별 한도 설정/알림 | +| 4 | 장바구니 동기화 | ⏳ 조회만 | 양방향 동기화 | +| 5 | 실패 시 재시도 | ❌ 미구현 | A실패→B 자동 재시도 | +| 6 | 배송 스케줄 | ❌ 미구현 | 배송 횟수 고려 주문 | + +--- + +## 3. 신규 기능 기획 + +### 3.1 도매상 월별 한도 관리 🆕 + +**목적**: 도매상별 월 거래 한도 설정 및 자동 분배 + +``` +예시: +- A도매상 (지오영): 월 5000만원 한도 +- B도매상 (수인): 월 3000만원 한도 +- C도매상 (백제): 월 2000만원 한도 + +[한도 도달 시 동작] +1. A도매상 한도 90% → 경고 알림 +2. A도매상 한도 100% → B도매상으로 자동 전환 +3. 장바구니 단계에서 미리 분류 (주문 확정 전 조정 가능) +``` + +**UI 요구사항**: +- 도매상별 한도 설정 화면 +- 현재 사용량/잔여 한도 표시 +- 한도 초과 시 경고 + 대안 도매상 제안 + +### 3.2 선주문 반영 시스템 🆕 + +**목적**: 같은 날 이미 주문한 품목 자동 차감 + +```python +# 로직 예시 +def calculate_order_qty(drug_code, usage_qty, current_stock): + # 오늘 "실제로" 주문 완료된 수량 조회 + today_ordered = get_today_orders(drug_code) # 300T + + # 필요량 = 사용량 - 현재고 - 선주문량 + needed = usage_qty - current_stock - today_ordered + + # 필요량이 양수일 때만 주문 + if needed > 0: + return calculate_spec_qty(needed) # 규격별 수량 계산 + return 0 +``` + +**⚠️ 핵심: 실제 주문만 카운트** + +```sql +-- 선주문 조회 쿼리 +SELECT SUM(oi.total_dose) as today_ordered +FROM order_items oi +JOIN orders o ON oi.order_id = o.id +WHERE oi.drug_code = ? + AND o.order_date = DATE('now') + AND o.is_dry_run = 0 -- ⭐ dry_run 제외! + AND oi.status IN ('success', 'submitted') -- 실제 완료된 것만 +``` + +**DB 스키마 수정 필요**: +- `orders` 테이블에 `is_dry_run INTEGER DEFAULT 0` 컬럼 추가 +- 선주문 조회 시 `is_dry_run=0`인 것만 카운트 +- `status`가 `success` 또는 `submitted`인 것만 (pending/failed 제외) + +### 3.3 규격 자동 선택 로직 🆕 + +**목적**: 재고 기반 최적 규격 선택 + +``` +⚠️ 단가 참고사항: +- 전문의약품(ETC): 보험약가 고정 → 30T든 300T든 1T당 가격 동일 +- 일반의약품(OTC): 도매상/규격별 단가 차이 가능 +- 비급여 약품: 도매상간 가격 비교 의미 있음 + +우선순위: +1. 재고 있는 규격 (품절 규격 제외) +2. 필요량과 가장 근접한 규격 (과주문 최소화) +3. 소분 선호 (30T x 10 > 300T x 1) - 유통기한/재고관리 유리 +4. 사용자 선호 패턴 (AI 학습 데이터 기반) + +예시: +- 필요량: 280T +- 가능 규격: 30T(재고50), 100T(품절), 300T(재고10) +- 선택: 30T x 10개 = 300T (100T 품절, 소분 선호) +``` + +### 3.4 주문 실패 시 재시도 🆕 + +**목적**: A도매상 실패 → B도매상 자동 재시도 + +``` +[재시도 시나리오] + +시나리오 1: 재고 없음 +- A도매상 재고 0 → B도매상 검색 → 재고 있으면 B로 주문 + +시나리오 2: 주문 오류 +- A도매상 API 오류 → 3회 재시도 → 실패 시 B도매상 + +시나리오 3: 부분 성공 +- 10개 품목 중 7개 성공, 3개 실패 +- 실패한 3개 → B도매상으로 자동 재시도 + +[리포트] +- 최종 주문 결과 리포트 (어느 도매상에서 성공/실패) +- 알림: "A도매상 품절로 B도매상으로 주문 변경됨" +``` + +### 3.5 장바구니 동기화 🆕 + +**목적**: 약국 시스템 ↔ 도매상 사이트 장바구니 일치 + +``` +[동기화 흐름] + +1. 약국에서 장바구니 담기 + └─ 도매상 API로 add_to_cart 호출 + └─ 성공 시 로컬 DB에도 기록 + +2. 주문 확정 전 동기화 체크 + └─ 도매상 get_cart() 호출 + └─ 로컬 DB와 비교 + └─ 불일치 시 알림 (누군가 도매상 사이트에서 직접 수정?) + +3. 주문 확정 + └─ 도매상 submit_order() 호출 + └─ 결과 로깅 +``` + +### 3.6 배송 스케줄 관리 🆕 + +**목적**: 도매상별 **주문 마감시간** + **배송 도착시간** 분리 관리 + +``` +⚠️ 핵심: 두 가지 시간을 구분! + +1. 주문 마감시간 (order_cutoff) - 언제까지 주문해야 하나 +2. 배송 도착시간 (delivery_arrival) - 실제 약국에 언제 도착하나 + +┌──────────┬──────────┬──────────────┬──────────────┬──────────┐ +│ 도매상 │ 배송 │ 주문 마감 │ 도착 예정 │ 비고 │ +├──────────┼──────────┼──────────────┼──────────────┼──────────┤ +│ 지오영 │ 오전 │ 10:00 │ 11:30 │ 당일 │ +│ │ 오후 │ 13:00 │ 15:00 │ 당일 │ +├──────────┼──────────┼──────────────┼──────────────┼──────────┤ +│ 수인 │ 오후 │ 13:00 │ 14:30 │ 당일 │ +├──────────┼──────────┼──────────────┼──────────────┼──────────┤ +│ 백제 │ 익일 │ 16:00 │ 다음날 15:00 │ ⚠️ 익일 │ +└──────────┴──────────┴──────────────┴──────────────┴──────────┘ +``` + +**AI 판단 시나리오**: +``` +상황: 오전 9시, "오늘 오후 2시에 필요" + +→ 지오영 오전: 10시 마감 전 → 11:30 도착 (✅ 여유) +→ 지오영 오후: 13시 마감 전 → 15:00 도착 (❌ 늦음) +→ 수인: 13시 마감 전 → 14:30 도착 (✅ 가능) +→ 백제: 16시 마감 → 내일 15시 (❌ 늦음) +→ 결론: 지오영 오전배송 추천 (가장 빠름) + +상황: 오전 11시, "오늘 오후 3시에 필요" + +→ 지오영 오전: 10시 마감 지남 ❌ +→ 지오영 오후: 13시 마감 전 → 15:00 도착 (⚠️ 딱 맞음) +→ 수인: 13시 마감 전 → 14:30 도착 (✅ 여유) +→ 결론: 수인 추천 (14:30 도착) + +상황: 오후 2시, "오늘 필요" + +→ 지오영: 오전/오후 마감 모두 지남 ❌ +→ 수인: 13시 마감 지남 ❌ +→ 백제: 16시 마감 전 → 내일 15시 도착 +→ 결론: 오늘 배송 불가! 백제 익일배송만 가능 → 알림 발송 +``` + +--- + +## 4. API 개발 계획 + +### 4.1 신규 API 목록 + +#### 🔧 도매상 한도 관리 API + +| 엔드포인트 | 메서드 | 기능 | +|------------|--------|------| +| `/api/wholesaler/limits` | GET | 전체 도매상 한도 조회 | +| `/api/wholesaler/limits/{id}` | GET | 특정 도매상 한도 상세 | +| `/api/wholesaler/limits/{id}` | PUT | 한도 설정/수정 | +| `/api/wholesaler/limits/{id}/usage` | GET | 현재 사용량 조회 | +| `/api/wholesaler/limits/check` | POST | 주문 전 한도 체크 | + +```json +// PUT /api/wholesaler/limits/geoyoung +{ + "monthly_limit": 50000000, + "warning_threshold": 0.9, + "priority": 1, + "is_active": true +} +``` + +#### 🔧 배송 스케줄 API 🆕 + +| 엔드포인트 | 메서드 | 기능 | +|------------|--------|------| +| `/api/wholesaler/schedules` | GET | 전체 배송 스케줄 | +| `/api/wholesaler/schedules/{id}` | GET | 특정 도매상 스케줄 | +| `/api/wholesaler/schedules/{id}` | PUT | 스케줄 수정 | +| `/api/wholesaler/next-delivery` | GET | 다음 가능한 배송 조회 | +| `/api/wholesaler/can-deliver-by` | POST | 특정 시간까지 배송 가능 여부 | + +```json +// GET /api/wholesaler/schedules/geoyoung +{ + "success": true, + "wholesaler": "geoyoung", + "schedules": [ + { + "seq": 1, + "name": "오전배송", + "order_cutoff": "08:30", + "arrival": "10:30" + }, + { + "seq": 2, + "name": "점심배송", + "order_cutoff": "11:00", + "arrival": "13:30" + }, + { + "seq": 3, + "name": "오후배송", + "order_cutoff": "15:00", + "arrival": "17:30" + } + ] +} + +// POST /api/wholesaler/can-deliver-by +// "오후 3시까지 받을 수 있는 도매상은?" +{ + "need_by": "15:00", + "drug_codes": ["670400830", "654301800"] +} + +// Response +{ + "success": true, + "current_time": "10:30", + "need_by": "15:00", + "options": [ + { + "wholesaler": "geoyoung", + "delivery": "점심배송", + "order_cutoff": "11:00", + "arrival": "13:30", + "status": "✅ 주문 가능 (30분 남음)" + }, + { + "wholesaler": "sooin", + "delivery": "오전배송", + "order_cutoff": "09:00", + "arrival": "11:00", + "status": "❌ 마감됨" + }, + { + "wholesaler": "sooin", + "delivery": "오후배송", + "order_cutoff": "14:00", + "arrival": "17:00", + "status": "❌ 도착 늦음 (17:00)" + } + ], + "recommendation": "geoyoung 점심배송 (11:00 마감, 13:30 도착)" +} +``` + +#### 🔧 선주문 조회 API + +| 엔드포인트 | 메서드 | 기능 | +|------------|--------|------| +| `/api/order/today` | GET | 오늘 주문 조회 | +| `/api/order/today/{drug_code}` | GET | 특정 약품 오늘 주문량 | +| `/api/order/pending` | GET | 아직 확정 안된 주문 | + +```json +// GET /api/order/today/670400830 +{ + "success": true, + "drug_code": "670400830", + "today_ordered_qty": 300, + "orders": [ + { + "order_no": "ORD-20260306-001", + "wholesaler": "geoyoung", + "specification": "300T", + "qty": 1, + "status": "submitted", + "ordered_at": "2026-03-06T10:30:00" + } + ] +} +``` + +#### 🔧 규격 추천 API + +| 엔드포인트 | 메서드 | 기능 | +|------------|--------|------| +| `/api/order/recommend-spec` | POST | 규격 추천 (단건) | +| `/api/order/recommend-specs` | POST | 규격 추천 (복수) | +| `/api/order/optimize` | POST | 전체 주문 최적화 | + +```json +// POST /api/order/recommend-spec +{ + "drug_code": "670400830", + "needed_qty": 280, + "prefer_wholesaler": "geoyoung" +} + +// Response +{ + "success": true, + "drug_type": "ETC", // ETC: 보험약가 고정, OTC: 단가 비교 가능 + "recommendations": [ + { + "wholesaler": "geoyoung", + "spec": "30T", + "qty": 10, + "total_dose": 300, + "stock": 50, + "unit_price": 1200, + "total_price": 12000, + "reason": "재고 충분, 소분 선호" + }, + { + "wholesaler": "sooin", + "spec": "300T", + "qty": 1, + "total_dose": 300, + "stock": 5, + "unit_price": 12000, + "total_price": 12000, + "reason": "재고 있음 (ETC 단가 동일)" + } + ] +} +``` + +#### 🔧 주문 재시도 API + +| 엔드포인트 | 메서드 | 기능 | +|------------|--------|------| +| `/api/order/retry` | POST | 실패 품목 재시도 | +| `/api/order/fallback` | POST | 다른 도매상으로 재주문 | +| `/api/order/redistribute` | POST | 한도 기반 재분배 | + +```json +// POST /api/order/retry +{ + "order_id": 123, + "item_ids": [456, 457], // 실패한 품목 + "fallback_wholesaler": "sooin" +} +``` + +#### 🔧 장바구니 동기화 API + +| 엔드포인트 | 메서드 | 기능 | +|------------|--------|------| +| `/api/cart/sync` | POST | 전체 동기화 | +| `/api/cart/compare` | GET | 로컬 vs 도매상 비교 | +| `/api/cart/resolve` | POST | 불일치 해결 | + +### 4.2 기존 API 확장 + +#### 📝 `/api/order/create` 확장 + +```json +// 기존 +{ + "wholesaler_id": "geoyoung", + "items": [...] +} + +// 확장 +{ + "wholesaler_id": "geoyoung", + "items": [...], + "options": { + "check_prior_orders": true, // 선주문 반영 + "auto_spec_select": true, // 규격 자동 선택 + "respect_limits": true, // 한도 준수 + "allow_fallback": true, // 실패 시 다른 도매상 + "fallback_order": ["sooin", "baekje"] + } +} +``` + +#### 📝 `/api/order/submit` 확장 + +```json +{ + "order_id": 123, + "dry_run": false, + "options": { + "retry_on_fail": 3, // 실패 시 재시도 횟수 + "fallback_enabled": true, + "notify_on_fallback": true // 도매상 변경 시 알림 + } +} +``` + +--- + +## 5. DB 스키마 확장 + +### 5.1 신규 테이블 + +#### `wholesaler_limits` - 도매상 한도 관리 + +```sql +CREATE TABLE wholesaler_limits ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + wholesaler_id TEXT NOT NULL, + + -- 한도 설정 + monthly_limit INTEGER DEFAULT 0, -- 월 한도 (원) + warning_threshold REAL DEFAULT 0.9, -- 경고 임계값 (90%) + + -- 우선순위 + priority INTEGER DEFAULT 1, -- 1이 최우선 + + -- 상태 + is_active INTEGER DEFAULT 1, + + -- 메타 + created_at TEXT DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT DEFAULT CURRENT_TIMESTAMP, + + FOREIGN KEY (wholesaler_id) REFERENCES wholesalers(id) +); +``` + +#### `delivery_schedules` - 배송 스케줄 🆕 + +```sql +CREATE TABLE delivery_schedules ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + wholesaler_id TEXT NOT NULL, + + -- 배송 회차 + delivery_seq INTEGER NOT NULL, -- 1, 2, 3... + delivery_name TEXT, -- '오전배송', '오후배송', '익일배송' + + -- ⭐ 주문 마감시간 + order_cutoff_time TEXT NOT NULL, -- 'HH:MM' (예: '10:00') + + -- ⭐ 배송 도착 + delivery_days_offset INTEGER DEFAULT 0, -- 0=당일, 1=익일, 2=2일후... + delivery_arrival_time TEXT NOT NULL, -- 'HH:MM' (예: '11:30') + + -- 요일별 운영 (NULL=매일) + weekdays TEXT, -- JSON [1,2,3,4,5] (평일만) + + -- 상태 + is_active INTEGER DEFAULT 1, + + created_at TEXT DEFAULT CURRENT_TIMESTAMP, + + FOREIGN KEY (wholesaler_id) REFERENCES wholesalers(id), + UNIQUE(wholesaler_id, delivery_seq) +); + +-- 실제 배송 스케줄 (2026-03-06 확인) +INSERT INTO delivery_schedules +(wholesaler_id, delivery_seq, delivery_name, order_cutoff_time, delivery_days_offset, delivery_arrival_time) +VALUES +-- 지오영 (2배송, 당일) +('geoyoung', 1, '오전배송', '10:00', 0, '11:30'), +('geoyoung', 2, '오후배송', '13:00', 0, '15:00'), +-- 수인 (1배송, 당일) +('sooin', 1, '오후배송', '13:00', 0, '14:30'), +-- 백제 (1배송, 익일!) ⚠️ +('baekje', 1, '익일배송', '16:00', 1, '15:00'); -- days_offset=1 → 다음날 +``` + +#### `wholesaler_monthly_usage` - 월별 사용량 추적 + +```sql +CREATE TABLE wholesaler_monthly_usage ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + wholesaler_id TEXT NOT NULL, + year_month TEXT NOT NULL, -- 'YYYY-MM' + + -- 집계 + total_orders INTEGER DEFAULT 0, -- 주문 건수 + total_items INTEGER DEFAULT 0, -- 주문 품목 수 + total_amount INTEGER DEFAULT 0, -- 총 주문 금액 + + -- 상태별 집계 + success_amount INTEGER DEFAULT 0, + failed_amount INTEGER DEFAULT 0, + + -- 메타 + last_updated TEXT DEFAULT CURRENT_TIMESTAMP, + + UNIQUE(wholesaler_id, year_month) +); +``` + +#### `order_fallback_log` - 재시도 로그 + +```sql +CREATE TABLE order_fallback_log ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + order_item_id INTEGER NOT NULL, + + -- 원래 도매상 + original_wholesaler TEXT NOT NULL, + original_error TEXT, -- 실패 사유 + + -- 재시도 도매상 + fallback_wholesaler TEXT NOT NULL, + fallback_result TEXT, -- 'success', 'failed' + fallback_message TEXT, + + -- 메타 + created_at TEXT DEFAULT CURRENT_TIMESTAMP, + + FOREIGN KEY (order_item_id) REFERENCES order_items(id) +); +``` + +#### `cart_sync_log` - 장바구니 동기화 로그 + +```sql +CREATE TABLE cart_sync_log ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + wholesaler_id TEXT NOT NULL, + + -- 동기화 정보 + sync_type TEXT, -- 'full', 'partial', 'compare' + local_items INTEGER, + remote_items INTEGER, + matched INTEGER, + mismatched INTEGER, + + -- 상세 + detail_json TEXT, + + created_at TEXT DEFAULT CURRENT_TIMESTAMP +); +``` + +### 5.2 기존 테이블 확장 + +#### `orders` 확장 ⭐ 중요 + +```sql +-- dry_run 구분 (선주문 조회 시 제외용) +ALTER TABLE orders ADD COLUMN is_dry_run INTEGER DEFAULT 0; +``` + +#### `order_items` 확장 + +```sql +ALTER TABLE order_items ADD COLUMN fallback_from_wholesaler TEXT; +ALTER TABLE order_items ADD COLUMN fallback_reason TEXT; +ALTER TABLE order_items ADD COLUMN prior_order_qty INTEGER DEFAULT 0; -- 선주문량 +``` + +#### `order_context` 확장 + +```sql +ALTER TABLE order_context ADD COLUMN limit_check_result TEXT; +ALTER TABLE order_context ADD COLUMN recommended_by TEXT; -- 'user', 'ai', 'system' +``` + +--- + +## 6. UI 개선 계획 + +### 6.1 도매상 한도 대시보드 🆕 + +``` +┌─────────────────────────────────────────────────────────┐ +│ 💰 도매상 한도 현황 (2026년 3월) │ +├─────────────────────────────────────────────────────────┤ +│ │ +│ 🏢 지오영 │ +│ ████████████████████░░░░░ 35,124,164 / 50,000,000 │ +│ 70.2% 사용 | 남은 한도: 14,875,836원 │ +│ [배송 3회] 09:00, 13:00, 17:00 │ +│ │ +│ 🏢 수인약품 │ +│ ██████████████░░░░░░░░░░ 14,293,001 / 30,000,000 │ +│ 47.6% 사용 | 남은 한도: 15,706,999원 │ +│ [배송 2회] 09:00, 17:00 │ +│ │ +│ 🏢 백제약품 │ +│ ███████████████████░░░░░ 14,563,978 / 20,000,000 │ +│ 72.8% 사용 | 남은 한도: 5,436,022원 ⚠️ 주의 │ +│ [배송 2회] 09:00, 17:00 │ +│ │ +│ [⚙️ 한도 설정] [📊 상세 리포트] │ +└─────────────────────────────────────────────────────────┘ +``` + +### 6.2 주문 화면 개선 + +``` +┌─────────────────────────────────────────────────────────┐ +│ 📦 주문 생성 - 2026-03-06 오후 배치 │ +├─────────────────────────────────────────────────────────┤ +│ │ +│ [선주문 반영 ✓] 오늘 오전 주문: 15품목, 2,340,000원 │ +│ │ +│ ┌─────────────────────────────────────────────────┐ │ +│ │ 품목 │ 필요량 │ 선주문 │ 추가주문 │ │ +│ ├─────────────────────────────────────────────────┤ │ +│ │ 아세탑정 │ 910T │ 300T │ 610T │ │ +│ │ └ 추천: 300T x 2 (지오영) │ │ +│ │ └ 대안: 30T x 21 (수인, 재고 충분) │ │ +│ │ │ │ +│ │ 레바미피드정 │ 500T │ 0T │ 500T │ │ +│ │ └ 추천: 30T x 17 (지오영) ⚠️ 품절위험 │ │ +│ │ └ 대안: 100T x 5 (백제) │ │ +│ └─────────────────────────────────────────────────┘ │ +│ │ +│ 📊 도매상 분배 미리보기 │ +│ - 지오영: 8품목 (1,200,000원) [한도 여유 ✓] │ +│ - 수인: 3품목 (450,000원) │ +│ - 백제: 2품목 (350,000원) [한도 주의 ⚠️] │ +│ │ +│ [🔄 재분배] [✅ 주문 확정] [💾 장바구니 저장] │ +└─────────────────────────────────────────────────────────┘ +``` + +### 6.3 알림/노티피케이션 + +``` +[알림 유형] + +📢 한도 알림 +- "지오영 한도 90% 도달 (4,500만원/5,000만원)" +- "백제약품 한도 초과! 신규 주문 불가" + +📢 도매상 변경 알림 +- "아세탑정: 지오영 품절 → 수인약품으로 변경됨" + +📢 주문 결과 알림 +- "오후 주문 완료: 15품목 중 14개 성공, 1개 재시도 중" + +📢 배송 알림 +- "지오영 점심 배송 마감 30분 전 (12:30까지)" +``` + +--- + +## 7. 개발 우선순위 + +### Phase 1: 핵심 기능 (1주차) + +| 순위 | 기능 | 예상 공수 | 의존성 | +|:----:|------|:--------:|--------| +| 1 | 선주문 조회 API | 0.5일 | - | +| 2 | 도매상 한도 테이블 + API | 1일 | - | +| 3 | 규격 추천 API | 1일 | 선주문 API | +| 4 | 한도 체크 로직 | 0.5일 | 한도 테이블 | + +### Phase 2: 자동화 (2주차) + +| 순위 | 기능 | 예상 공수 | 의존성 | +|:----:|------|:--------:|--------| +| 5 | 주문 재시도 로직 | 1일 | Phase 1 | +| 6 | 장바구니 동기화 | 1일 | - | +| 7 | UI: 한도 대시보드 | 1일 | 한도 API | +| 8 | UI: 주문 화면 개선 | 1일 | 규격 추천 API | + +### Phase 3: 고도화 (3주차) + +| 순위 | 기능 | 예상 공수 | 의존성 | +|:----:|------|:--------:|--------| +| 9 | 배송 스케줄 관리 | 1일 | - | +| 10 | 알림 시스템 | 1일 | - | +| 11 | AI 학습 파이프라인 | 2일 | Phase 1-2 데이터 | +| 12 | 자동 스케줄링 | 1일 | 배송 스케줄 | + +--- + +## 📝 검토 요청 사항 + +### 1. 한도 기본값 +도매상별 초기 한도 얼마로 설정? +- 지오영: ____만원 +- 수인: ____만원 +- 백제: ____만원 + +### 2. 배송 스케줄 ✅ 확인 완료 + +| 도매상 | 배송 | 주문 마감 | 도착 예정 | 비고 | +|--------|------|----------|----------|------| +| **지오영** | 오전 | 10:00 | 11:30 | 당일 | +| | 오후 | 13:00 | 15:00 | 당일 | +| **수인** | 오후 | 13:00 | 14:30 | 당일 | +| **백제** | 익일 | 16:00 | 다음날 15:00 | ⚠️ 익일배송 | + +### 3. 알림 채널 +어디로 받으실 건가요? +- [ ] 텔레그램 +- [ ] 카카오톡 +- [ ] 웹 알림 +- [ ] 기타: ____ + +### 4. 재시도 정책 +- A도매상 실패 시 바로 B로? +- 몇 번까지 재시도? + +--- + +> 🐉 **용림 메모**: 기획서 검토 후 Phase 1부터 순차 개발 예정. +> 약사님 확인 후 수정사항 반영하겠습니다!