feat(order): 지오영/수인 선택적 주문 + 장바구니 보존 기능

- internal_code DB 저장 → 프론트에서 선택한 제품 그대로 주문
- 기존 장바구니 백업/복구로 사용자 장바구니 보존
- 수인약품 submit_order() 수정 (체크박스 제외 방식)
- 테스트 파일 정리 및 문서 추가
This commit is contained in:
thug0bin 2026-03-06 23:26:44 +09:00
parent f48e657e12
commit a672c7a2a0
79 changed files with 4851 additions and 2672 deletions

16
backend/analyze_bag.py Normal file
View File

@ -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)}')

View File

@ -99,63 +99,7 @@
</tr>
</thead>
<tbody>
<tr id="bagLine0">
<td class="first"><input type="checkbox" name="chk_0" id="chk_0" class="chkBox" /></td>
<td class="td_nm" title="(향)스틸녹스정 10mg(병) 100T" ><a href="./PhysicInfo.asp?pc=02719&currVenCd=50911" target="_blank" class="bagPhysic_ln">(향)스틸녹스정 10mg(병)100T</a></td>
<td >
<input type="text" name="bagQty_0" id="bagQty_0" maxlength="10" class="setInput_h18_qty" value="1" data="1"
style="width:25px;"/>
<input type="hidden" name="pc_0" id="pc_0" value="02719" />
<input type="hidden" name="stock_0" id="stock_0" value="50" />
<input type="hidden" name="price_0" value="17300" />
<input type="hidden" name="physic_nm0" value="(향)스틸녹스정 10mg(병)" />
<input type="hidden" name="totalPrice0" id="totalPrice0" value="17300" />
<input type="hidden" name="ordunitqty_0" id="ordunitqty_0" value="0" />
<input type="hidden" name="bidqty_0" id="bidqty_0" value="" />
<input type="hidden" name="outqty_0" id="outqty_0" value="" />
<input type="hidden" name="pg_0" id="pg_0" value="" />
<input type="hidden" name="prodno_0" id="prodno_0" value="" />
<input type="hidden" name="termdt_0" id="termdt_0" value="" />
</td>
<td class="td_num" >
17,300
</td>
</tr>
<tr id="bagLine1">
<td class="first"><input type="checkbox" name="chk_1" id="chk_1" class="chkBox" /></td>
<td class="td_nm" title="(오가논)코자정 50mg(PTP) 30T" ><a href="./PhysicInfo.asp?pc=32495&currVenCd=50911" target="_blank" class="bagPhysic_ln">(오가논)코자정 50mg(PTP)30T</a></td>
<td >
<input type="text" name="bagQty_1" id="bagQty_1" maxlength="10" class="setInput_h18_qty" value="1" data="1"
style="width:25px;"/>
<input type="hidden" name="pc_1" id="pc_1" value="32495" />
<input type="hidden" name="stock_1" id="stock_1" value="234" />
<input type="hidden" name="price_1" value="14220" />
<input type="hidden" name="physic_nm1" value="(오가논)코자정 50mg(PTP)" />
<input type="hidden" name="totalPrice1" id="totalPrice1" value="14220" />
<input type="hidden" name="ordunitqty_1" id="ordunitqty_1" value="0" />
<input type="hidden" name="bidqty_1" id="bidqty_1" value="" />
<input type="hidden" name="outqty_1" id="outqty_1" value="" />
<input type="hidden" name="pg_1" id="pg_1" value="" />
<input type="hidden" name="prodno_1" id="prodno_1" value="" />
<input type="hidden" name="termdt_1" id="termdt_1" value="" />
</td>
<td class="td_num" >
14,220
</td>
</tr>
<tr><td colspan="4">장바구니에 담긴 제품이 없습니다.</td></tr>
</tbody>
</table>
</div><!--scroll-->
@ -168,7 +112,7 @@
<div class="cntPhysic">
<dl class="orderPhy">
<dt><span>주문품목</span></dt>
<dd class=""><span id="cnt_order">2</span></dd>
<dd class=""><span id="cnt_order">0</span></dd>
</dl>
<dl class="cancelPhy">
<dt><span>취소품목</span></dt>
@ -177,15 +121,15 @@
</div>
<dl class="total">
<dt>주문금액</dt>
<dd id="bag_totPrice" class="" data="31520">
31,520원
<dd id="bag_totPrice" class="" data="0">
0원
</dd>
</dl>
<input type="hidden" name="chkOrderOk" id="chkOrderOk" value="Y" />
<input type="hidden" name="order_min_amt" id="order_min_amt" value="" />
<input type="hidden" name="intArray" id="intArray" value="1" />
<input type="hidden" name="intArray" id="intArray" value="-1" />
<input type="hidden" name="currVenCd" id="currVenCd" value="50911" />
<input type="hidden" name="currMkind" id="currMkind" value="" />
<input type="hidden" name="kind" value="bag_saveall" />

82
backend/capture_order.py Normal file
View File

@ -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완료!')

79
backend/capture_order2.py Normal file
View File

@ -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()

8
backend/check_cart.py Normal file
View File

@ -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']:,}")

View File

@ -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('🛒 장바구니 비어있음')

BIN
backend/geo_cart_before.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

View File

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

View File

@ -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'),

View File

@ -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 &&

60
backend/test_api_debug.py Normal file
View File

@ -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("품목을 찾을 수 없습니다")

View File

@ -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]}")

View File

@ -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')}")

View File

@ -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()

View File

@ -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]}')

View File

@ -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]}')

View File

@ -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]}')

View File

@ -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()

View File

@ -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=== 완료 ===')

View File

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

View File

@ -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()

View File

@ -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]}")

View File

@ -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()

52
backend/test_checkbox.py Normal file
View File

@ -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❌ 주문 실패')

View File

@ -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}")

View File

@ -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("❌ 실패")

View File

@ -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()

View File

@ -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()

View File

@ -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()

View File

@ -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()

View File

@ -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]}")

View File

@ -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()}')

View File

@ -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'<script[^>]*>(.*?)</script>', 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]}')

View File

@ -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
# 모든 <a> 태그의 href와 onclick 찾기
links = re.findall(r'<a[^>]*(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'<form[^>]*action=["\']([^"\']+)["\']', html)
for f in forms:
print(f'form action: {f}')
print('\n--- hidden inputs ---')
hiddens = re.findall(r'<input[^>]*type=["\']hidden["\'][^>]*name=["\']([^"\']+)["\'][^>]*value=["\']([^"\']*)["\']', html)
for name, val in hiddens[:10]:
print(f'{name}: {val}')

View File

@ -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'<form[^>]*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]}...')

View File

@ -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")}')

View File

@ -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", "")}')

View File

@ -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)}')

View File

@ -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)}')

View File

@ -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}')

View File

@ -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 로드 성공!')

View File

@ -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']}")

View File

@ -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()

View File

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

60
backend/test_geo_clear.py Normal file
View File

@ -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()

View File

@ -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())

View File

@ -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()

13
backend/test_geo_debug.py Normal file
View File

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

View File

@ -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()

145
backend/test_geo_html.py Normal file
View File

@ -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'<button[^>]*>.*?</button>|<a[^>]*class="[^"]*btn[^"]*"[^>]*>.*?</a>'
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())

View File

@ -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(" 없음")

View File

@ -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}: 없음")

View File

@ -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"]}개 남음')

View File

@ -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❌ 새 품목 담기 실패')

View File

@ -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❌ 새 품목 담기 실패')

View File

@ -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❌ 새 품목 담기 실패')

View File

@ -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())

View File

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

76
backend/test_order_end.py Normal file
View File

@ -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❌ 주문 실패')

View File

@ -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]}')

69
backend/test_post_data.py Normal file
View File

@ -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']}")

View File

@ -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()

View File

@ -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()

View File

@ -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()

View File

@ -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()

View File

@ -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🤔 예상 외 결과')

View File

@ -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"]}개 남음')

38
backend/test_session.py Normal file
View File

@ -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)}")

View File

@ -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('✅ 테스트 완료!')

View File

@ -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('=== 완료! ===')

View File

@ -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')}")

View File

@ -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']}")

66
backend/test_submit_xy.py Normal file
View File

@ -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🎉 주문 성공!')

81
backend/test_temp_save.py Normal file
View File

@ -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❌ 실패')

View File

@ -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🎉 성공! 라식스만 주문됨, 코자정 복원됨!')

View File

@ -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=== 테스트 완료 ===')

File diff suppressed because it is too large Load Diff

View File

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

View File

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