From a672c7a2a0768f3314dba6f95de93484ceb3b49f Mon Sep 17 00:00:00 2001 From: thug0bin Date: Fri, 6 Mar 2026 23:26:44 +0900 Subject: [PATCH] =?UTF-8?q?feat(order):=20=EC=A7=80=EC=98=A4=EC=98=81/?= =?UTF-8?q?=EC=88=98=EC=9D=B8=20=EC=84=A0=ED=83=9D=EC=A0=81=20=EC=A3=BC?= =?UTF-8?q?=EB=AC=B8=20+=20=EC=9E=A5=EB=B0=94=EA=B5=AC=EB=8B=88=20?= =?UTF-8?q?=EB=B3=B4=EC=A1=B4=20=EA=B8=B0=EB=8A=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - internal_code DB 저장 → 프론트에서 선택한 제품 그대로 주문 - 기존 장바구니 백업/복구로 사용자 장바구니 보존 - 수인약품 submit_order() 수정 (체크박스 제외 방식) - 테스트 파일 정리 및 문서 추가 --- backend/analyze_bag.py | 16 + backend/bag_page.html | 66 +- backend/capture_order.py | 82 ++ backend/capture_order2.py | 79 ++ backend/check_cart.py | 8 + backend/check_sooin_cart.py | 21 + backend/geo_cart_before.png | Bin 0 -> 57141 bytes backend/order_api.py | 14 + backend/order_db.py | 6 +- backend/templates/admin_rx_usage.html | 9 + backend/test_api_debug.py | 60 ++ backend/test_baekje_ledger_api.py | 101 -- backend/test_baekje_ledger_api2.py | 126 --- backend/test_baekje_monthly_sales.py | 84 -- backend/test_bagjs.py | 16 - backend/test_bagjs2.py | 18 - backend/test_bagjs3.py | 16 - backend/test_bagjs4.py | 19 - backend/test_cancel.py | 46 - backend/test_cart.py | 60 -- backend/test_cart_api.py | 114 -- backend/test_cart_debug.py | 44 - backend/test_cart_list.py | 127 --- backend/test_checkbox.py | 52 + backend/test_checkbox_html.py | 42 + backend/test_checkbox_logic.py | 58 ++ backend/test_datacart.py | 74 -- backend/test_datacart2.py | 83 -- backend/test_datacart3.py | 105 -- backend/test_dataorder.py | 101 -- backend/test_debug_submit.py | 51 + backend/test_del.py | 32 - backend/test_del2.py | 26 - backend/test_del3.py | 25 - backend/test_del_chk.py | 29 - backend/test_del_html.py | 21 - backend/test_del_one.py | 38 - backend/test_del_pc.py | 33 - backend/test_del_post.py | 26 - backend/test_encoding.py | 39 - backend/test_flask_api.py | 25 - backend/test_geo_api_compare.py | 49 + backend/test_geo_cart_debug.py | 60 ++ backend/test_geo_cart_keys.py | 16 + backend/test_geo_clear.py | 60 ++ backend/test_geo_clear_button.py | 136 +++ backend/test_geo_clear_new.py | 69 ++ backend/test_geo_debug.py | 13 + backend/test_geo_delete.py | 64 ++ backend/test_geo_html.py | 145 +++ backend/test_geo_search.py | 15 + backend/test_geo_search2.py | 14 + backend/test_geo_selective.py | 64 ++ backend/test_geo_selective_final.py | 70 ++ backend/test_geo_selective_final2.py | 83 ++ backend/test_geo_selective_final3.py | 71 ++ backend/test_geoyoung_api.py | 112 -- backend/test_integration.py | 98 -- backend/test_order_end.py | 76 ++ backend/test_pg.py | 8 - backend/test_post_data.py | 69 ++ backend/test_qr_methods.py | 298 ------ backend/test_qr_methods_v2.py | 281 ----- backend/test_qr_with_escpos_lib.py | 263 ----- backend/test_rxusage_playwright.py | 122 +++ backend/test_selective_order.py | 59 ++ backend/test_selective_order2.py | 68 ++ backend/test_session.py | 38 + backend/test_sooin.py | 49 - backend/test_sooin_full.py | 40 - backend/test_submit_detail.py | 64 ++ backend/test_submit_order.py | 43 + backend/test_submit_xy.py | 66 ++ backend/test_temp_save.py | 81 ++ backend/test_temp_save2.py | 72 ++ backend/test_wholesale_integration.py | 32 - docs/AI_자동발주시스템_통합기획서_v1.html | 1148 +++++++++++++++++++++ docs/AI_자동발주시스템_통합기획서_v1.md | 692 +++++++++++++ docs/자동발주시스템_고도화_기획서_v2.md | 823 +++++++++++++++ 79 files changed, 4851 insertions(+), 2672 deletions(-) create mode 100644 backend/analyze_bag.py create mode 100644 backend/capture_order.py create mode 100644 backend/capture_order2.py create mode 100644 backend/check_cart.py create mode 100644 backend/check_sooin_cart.py create mode 100644 backend/geo_cart_before.png create mode 100644 backend/test_api_debug.py delete mode 100644 backend/test_baekje_ledger_api.py delete mode 100644 backend/test_baekje_ledger_api2.py delete mode 100644 backend/test_baekje_monthly_sales.py delete mode 100644 backend/test_bagjs.py delete mode 100644 backend/test_bagjs2.py delete mode 100644 backend/test_bagjs3.py delete mode 100644 backend/test_bagjs4.py delete mode 100644 backend/test_cancel.py delete mode 100644 backend/test_cart.py delete mode 100644 backend/test_cart_api.py delete mode 100644 backend/test_cart_debug.py delete mode 100644 backend/test_cart_list.py create mode 100644 backend/test_checkbox.py create mode 100644 backend/test_checkbox_html.py create mode 100644 backend/test_checkbox_logic.py delete mode 100644 backend/test_datacart.py delete mode 100644 backend/test_datacart2.py delete mode 100644 backend/test_datacart3.py delete mode 100644 backend/test_dataorder.py create mode 100644 backend/test_debug_submit.py delete mode 100644 backend/test_del.py delete mode 100644 backend/test_del2.py delete mode 100644 backend/test_del3.py delete mode 100644 backend/test_del_chk.py delete mode 100644 backend/test_del_html.py delete mode 100644 backend/test_del_one.py delete mode 100644 backend/test_del_pc.py delete mode 100644 backend/test_del_post.py delete mode 100644 backend/test_encoding.py delete mode 100644 backend/test_flask_api.py create mode 100644 backend/test_geo_api_compare.py create mode 100644 backend/test_geo_cart_debug.py create mode 100644 backend/test_geo_cart_keys.py create mode 100644 backend/test_geo_clear.py create mode 100644 backend/test_geo_clear_button.py create mode 100644 backend/test_geo_clear_new.py create mode 100644 backend/test_geo_debug.py create mode 100644 backend/test_geo_delete.py create mode 100644 backend/test_geo_html.py create mode 100644 backend/test_geo_search.py create mode 100644 backend/test_geo_search2.py create mode 100644 backend/test_geo_selective.py create mode 100644 backend/test_geo_selective_final.py create mode 100644 backend/test_geo_selective_final2.py create mode 100644 backend/test_geo_selective_final3.py delete mode 100644 backend/test_geoyoung_api.py delete mode 100644 backend/test_integration.py create mode 100644 backend/test_order_end.py delete mode 100644 backend/test_pg.py create mode 100644 backend/test_post_data.py delete mode 100644 backend/test_qr_methods.py delete mode 100644 backend/test_qr_methods_v2.py delete mode 100644 backend/test_qr_with_escpos_lib.py create mode 100644 backend/test_rxusage_playwright.py create mode 100644 backend/test_selective_order.py create mode 100644 backend/test_selective_order2.py create mode 100644 backend/test_session.py delete mode 100644 backend/test_sooin.py delete mode 100644 backend/test_sooin_full.py create mode 100644 backend/test_submit_detail.py create mode 100644 backend/test_submit_order.py create mode 100644 backend/test_submit_xy.py create mode 100644 backend/test_temp_save.py create mode 100644 backend/test_temp_save2.py delete mode 100644 backend/test_wholesale_integration.py create mode 100644 docs/AI_자동발주시스템_통합기획서_v1.html create mode 100644 docs/AI_자동발주시스템_통합기획서_v1.md create mode 100644 docs/자동발주시스템_고도화_기획서_v2.md 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 0000000000000000000000000000000000000000..42b9c65acf83a7a891d2fd9e20cba5b35dc6a17f GIT binary patch literal 57141 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 literal 0 HcmV?d00001 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는 대체하는 것이 아니라, 약사님의 방식을 자동화합니다."

+
+
    +
  • 약사님이 항상 지오영에 먼저 주문하면 → 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)

+
학습 데이터:
+- 주문 시점의 재고 수준
+- 재고 소진까지 남은 일수
+- 주문 후 입고까지 리드타임
+- 품절 발생 이력
+
+학습 목표:
+- 약사님의 재고 선호도 파악
+  - 타이트형: 최소 재고 유지 (현금 흐름 중시)
+  - 여유형: 안전 재고 확보 (품절 방지 중시)
+
+

재고 전략 프로파일:

+
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: 수동

+
    +
  • AI 추천만 제공
  • +
  • 모든 주문은 수동 실행
  • +
+

Level 1: 반자동

+
    +
  • AI가 주문 계획 생성
  • +
  • 약사님 승인 후 자동 실행
  • +
  • 알림: 승인 요청
  • +
+

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

+
    +
  • [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상세)를 통합한 마스터 기획서입니다.

+
+ + \ 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부터 순차 개발 예정. +> 약사님 확인 후 수정사항 반영하겠습니다!