feat: 도매상 API 통합 및 스키마 업데이트

- wholesale 패키지 연동 (SooinSession, GeoYoungSession)
- Flask Blueprint 분리 (sooin_api.py, geoyoung_api.py)
- order_context 스키마 확장 (wholesaler_id, internal_code 등)
- 수인약품 개별 취소 기능 (cancel_item, restore_item)
- 문서 추가: WHOLESALE_API_INTEGRATION.md
- 테스트 스크립트들
This commit is contained in:
thug0bin 2026-03-06 11:50:46 +09:00
parent e84eda928a
commit c1596a6d35
53 changed files with 8789 additions and 3 deletions

View File

@ -0,0 +1,277 @@
# 수인약품 API 리버스 엔지니어링 문서
## 개요
수인약품 웹 주문 시스템의 API 구조를 분석한 문서입니다.
지오영 API와 같은 하이브리드 방식 (Playwright 로그인 → requests 직접 호출)으로 구현합니다.
## 기본 정보
- **Base URL**: `http://sooinpharm.co.kr`
- **인코딩**: EUC-KR (한글 파라미터 인코딩 시 주의)
- **거래처 코드**: `50911` (청춘약국)
- **세션 관리**: 쿠키 기반 (ASP 세션)
---
## 1. 로그인
### 로그인 페이지
- **URL**: `/Homepage/intro.asp`
- **Method**: POST (JavaScript 함수 `chkLogin()` 호출)
### 필드
| 필드명 | 설명 | 예시 |
|--------|------|------|
| tx_id | 아이디 | thug0bin |
| tx_pw | 비밀번호 | @Trajet6640 |
### 인증 쿠키
로그인 성공 시 ASP 세션 쿠키가 발급됨:
- `ASPSESSIONID*` (세션 ID)
### 로그인 성공 확인
- 로그인 후 페이지에 "로그아웃" 링크 존재 여부로 확인
- 로그인 후 자동으로 `/Service/Order/Order.asp`로 리다이렉트
---
## 2. 제품 검색 API
### URL
```
GET /Service/Order/Order.asp
```
### 파라미터
| 파라미터 | 필수 | 설명 | 값 예시 |
|----------|------|------|---------|
| so | N | 제품분류 | 0=전체, 1=전문, 2=일반 |
| so2 | N | 주문분류 | 0=전체, 1=다빈도, 2=관심, 3=재주문 |
| so3 | N | 검색타입 | **1=제품명, 2=KD코드, 3=표준코드** |
| tx_maker | N | 제조사 | 한독 |
| tx_physic | N | 검색어 | 073100220 (KD코드) |
| tx_ven | Y | 거래처코드 | 50911 |
| currVenNm | Y | 약국명 | 청춘약국 (URL인코딩) |
| sDate | N | 시작일 | 20260306 |
| eDate | N | 종료일 | 20260306 |
| sa | N | 정렬 | phy=제품명순, ven=제조사순 |
| Page | N | 페이지번호 | 1 |
| tx_StockLoc | N | 재고위치 | '00001' |
| df | N | 기간필터 | t=3개월 |
### KD코드 검색 예시 URL
```
/Service/Order/Order.asp?so=0&so2=0&so3=2&tx_physic=073100220&tx_ven=50911&currVenNm=%EC%B2%AD%EC%B6%98%EC%95%BD%EA%B5%AD&sDate=20260306&eDate=20260306&df=t
```
### 응답 (HTML)
HTML 테이블 형식으로 반환. BeautifulSoup로 파싱 필요.
#### 테이블 구조
```html
<tr class="ln_physic">
<td>073100220</td> <!-- KD코드 -->
<td>한국오가논</td> <!-- 제조사 -->
<td>
<a href="./PhysicInfo.asp?pc=32495&...">
(오가논)코자정 50mg(PTP)
</a>
</td> <!-- 제품명 (pc=내부코드) -->
<td>30T</td> <!-- 규격 -->
<td>보험전문</td> <!-- 구분 -->
<td>14,220</td> <!-- 단가 -->
<td>238</td> <!-- 재고 -->
<td>
<input name="qty_0"> <!-- 수량입력 -->
<input type="hidden" name="pc_0" value="32495"> <!-- 내부코드 -->
<input type="hidden" name="stock_0" value="238">
<input type="hidden" name="price_0" value="14220">
</td>
</tr>
```
#### 핵심 필드 추출
- **KD코드**: 첫 번째 td
- **제조사**: 두 번째 td
- **제품명**: 세 번째 td의 a 태그 텍스트
- **내부코드(pc)**: a 태그 href에서 `pc=xxxxx` 추출
- **규격**: 네 번째 td
- **단가**: 여섯 번째 td (콤마 제거 후 int)
- **재고**: 일곱 번째 td
---
## 3. 장바구니 추가 API
### URL
```
POST /Service/Order/BagOrder.asp
```
### Content-Type
```
application/x-www-form-urlencoded
```
### 파라미터 (각 제품당)
| 파라미터 | 설명 | 예시 |
|----------|------|------|
| qty_N | 수량 | 1 |
| pc_N | 내부 제품코드 | 32495 |
| stock_N | 현재 재고 | 238 |
| saleqty_N | 판매수량 | 0 |
| price_N | 단가 | 14220 |
| soldout_N | 품절여부 | N |
| ordunitqty_N | 주문단위수량 | 1 |
| bidqty_N | 입찰수량 | 0 |
| outqty_N | 출고수량 | 0 |
| overqty_N | 초과수량 | 0 |
| manage_N | 관리여부 | N |
| prodno_N | 제품번호 | (빈값) |
| termdt_N | 종료일자 | (빈값) |
> N은 0부터 시작하는 행 인덱스
### 요청 예시
```
qty_0=1&pc_0=32495&stock_0=238&saleqty_0=0&price_0=14220&soldout_0=N&ordunitqty_0=1&bidqty_0=0&outqty_0=0&overqty_0=0&manage_0=N&prodno_0=&termdt_0=
```
### 응답
HTML (장바구니 iframe 내용)
---
## 4. 장바구니 비우기 API
### URL
```
GET /Service/Order/BagOrder.asp?kind=del&currVenCd=50911&currMkind=&currRealVenCd=
```
### 파라미터
| 파라미터 | 설명 | 값 |
|----------|------|-----|
| kind | 동작 | del |
| currVenCd | 거래처코드 | 50911 |
| currMkind | 종류 | (빈값) |
| currRealVenCd | 실제거래처코드 | (빈값) |
---
## 5. 장바구니 조회 API
### URL
```
GET /Service/Order/BagOrder.asp?currVenCd=50911
```
### 응답 (HTML)
```html
<table class="tbl_list">
<tr>
<td>건별취소</td>
<td>제품명</td>
<td>수량</td>
<td>금액</td>
</tr>
<tr>
<td><a href="...">X</a></td>
<td>(오가논)코자정 50mg(PTP)</td>
<td>1</td>
<td>14,220</td>
</tr>
</table>
<div>
<dt>주문품목</dt><dd>1개</dd>
<dt>주문금액</dt><dd>14,220원</dd>
</div>
```
---
## 6. 주문 전송 API
### URL (추정)
```
POST /Service/Order/BagOrder.asp
```
### 파라미터
| 파라미터 | 설명 |
|----------|------|
| kind | order (추정) |
| memo | 주문메모 |
| currVenCd | 거래처코드 |
> 실제 주문 전송은 iframe 내 버튼 클릭으로 수행됨
> 정확한 API 파라미터는 추가 분석 필요
---
## 7. 제품 상세 정보 API
### URL
```
GET /Service/Order/PhysicInfo.asp
```
### 파라미터
| 파라미터 | 설명 | 예시 |
|----------|------|------|
| pc | 내부제품코드 | 32495 |
| ln | 행번호 | 0 |
| currVenCd | 거래처코드 | 50911 |
| currLoc | 재고위치 | '00001' |
---
## 구현 전략
### 지오영 API 패턴 적용
1. **Playwright 로그인**
- 초기 로그인만 Playwright 사용
- 쿠키 획득 후 requests 세션에 복사
- 세션 30분 유효 (재로그인 필요 시 자동 갱신)
2. **requests 직접 호출**
- 검색: GET /Service/Order/Order.asp
- 장바구니 추가: POST /Service/Order/BagOrder.asp
- 장바구니 비우기: GET /Service/Order/BagOrder.asp?kind=del
- 장바구니 조회: GET /Service/Order/BagOrder.asp
3. **HTML 파싱**
- BeautifulSoup 사용
- 테이블 행에서 제품 정보 추출
- 내부코드(pc) 추출 (장바구니 추가용)
### 예상 성능
- 기존 Playwright: ~30초/주문
- requests 직접 호출: **~1초/주문**
---
## 주의사항
1. **EUC-KR 인코딩**
- 한글 파라미터는 EUC-KR로 인코딩
- `urllib.parse.quote(text.encode('euc-kr'))`
2. **세션 관리**
- ASP 세션 쿠키 유지 필수
- 장시간 미사용 시 세션 만료
3. **동시 접속**
- 동일 계정 동시 접속 시 세션 충돌 가능
4. **재고 실시간성**
- 검색 시점의 재고 정보
- 주문 전 재고 재확인 권장
---
## 작성일
- 2026-03-06
- 리버스 엔지니어링 by Claude

View File

@ -0,0 +1,77 @@
# -*- coding: utf-8 -*-
"""지오영 API 엔드포인트 분석 - 간단 버전"""
import asyncio
from playwright.async_api import async_playwright
async def analyze():
async with async_playwright() as p:
browser = await p.chromium.launch(headless=True)
page = await browser.new_page()
# 모든 요청 로깅
all_requests = []
def log_request(request):
all_requests.append({
'url': request.url,
'method': request.method,
'data': request.post_data
})
page.on('request', log_request)
# 로그인
await page.goto('https://gwn.geoweb.kr/Member/Login')
await page.fill('input[type="text"]', '7390')
await page.fill('input[type="password"]', 'trajet6640')
await page.click('button, input[type="submit"]')
await page.wait_for_load_state('networkidle')
# 메인 페이지 HTML 분석
await page.goto('https://gwn.geoweb.kr/Home/Index')
await page.wait_for_load_state('networkidle')
# JavaScript에서 API 엔드포인트 찾기
js_content = await page.content()
await browser.close()
# POST 요청만 필터
print("="*60)
print("POST 요청들:")
print("="*60)
for r in all_requests:
if r['method'] == 'POST':
print(f"URL: {r['url']}")
if r['data']:
print(f"Data: {r['data'][:300]}")
print()
# HTML에서 API 힌트 찾기
print("="*60)
print("HTML에서 발견된 API 관련 패턴:")
print("="*60)
import re
# ajax, fetch, url 패턴 찾기
patterns = [
r'url:\s*[\'"]([^"\']+)[\'"]',
r'action=[\'"]([^"\']+)[\'"]',
r'\.post\([\'"]([^"\']+)[\'"]',
r'\.get\([\'"]([^"\']+)[\'"]',
r'fetch\([\'"]([^"\']+)[\'"]',
]
found_urls = set()
for pattern in patterns:
matches = re.findall(pattern, js_content)
for m in matches:
if 'Order' in m or 'Cart' in m or 'Add' in m or 'Product' in m:
found_urls.add(m)
for url in sorted(found_urls):
print(url)
if __name__ == "__main__":
asyncio.run(analyze())

View File

@ -59,6 +59,15 @@ app.register_blueprint(pmr_bp)
from paai_feedback import paai_feedback_bp
app.register_blueprint(paai_feedback_bp)
from geoyoung_api import geoyoung_bp
app.register_blueprint(geoyoung_bp)
from sooin_api import sooin_bp
app.register_blueprint(sooin_bp)
from order_api import order_bp
app.register_blueprint(order_bp)
# 데이터베이스 매니저
db_manager = DatabaseManager()

206
backend/bag_page.html Normal file
View File

@ -0,0 +1,206 @@
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="ko">
<head>
<meta http-equiv="Content-Type" content="text/html;charset=utf-8" />
<meta http-equiv="X-UA-Compatible" content="IE=Edge"/>
<link rel="stylesheet" href="http://sooinpharm.co.kr/Common/Css/Reset.css" type="text/css" media="screen" />
<link rel="stylesheet" href="http://sooinpharm.co.kr/Common/Css/Bag.css?v=260116" type="text/css" media="screen" />
<link rel="stylesheet" href="http://sooinpharm.co.kr/Common/Css/jquery-ui.css" type="text/css" />
<title>수인약품(주) :: 장바구니</title>
</head>
<body oncontextmenu="return false" >
<input type="hidden" id="domainNm" name="domainNm" value="sooinpharm.co.kr" />
<input type="hidden" id="aggqty" value="N">
<input type='hidden' id='hardcoding' value='sooinpharm'>
<input type='hidden' id='bidding' value=''>
<input type='hidden' id='gumaeKind' value='U'>
<input type="hidden" id="min_order_qty" value=""><!--최소주문수량-->
<input type="hidden" id="BigWideFlag" value="">
<input type="hidden" id="DayOrdAmt" value="0">
<input type='hidden' id='baekjestockcd' value=''>
<div id="msg_order" style="margin: 0px 0 2px 0px;padding: 0px 7px 2px 0;background-position-y: 50%;position:relative;">
<div style="padding-left: 19px;line-height:13px;min-height:37px;display:table;"><span style="display: table-cell;text-align: left;vertical-align: middle;">17시 이후 주문은 다음근무일로 주문됩니다</span></div>
</div>
<h1 id="bag_title">장바구니</h1>
<div id="bag"
style='height:518px;'
>
<form name="frmBag" id="frmBag" method="post" action="./OrderEnd.asp" autocomplete=off>
<fieldset class="info">
<legend>주문관련 버튼 및 메모</legend>
<ul class="btn">
<li><a href="./BagOrder.asp?kind=del&amp;currVenCd=50911&amp;currMkind=&amp;currRealVenCd=" title="장바구니 비우기" id="btn_cancel_order">장바구니 비우기</a></li>
<input type="hidden" name="hostuser" id="hostuser" />
<li><input type="image" src="http://sooinpharm.co.kr/Images/Btn/btn_order_v2.gif" alt="주문전송" title="주문전송" /></li>
<input type='hidden' id='btnState' value='true'>
</ul>
<p class="memo">
<label for="tx_memo" >주문전송 메모</label><input type="text" name="tx_memo" id="tx_memo" maxlength="150" class="setInput_h20" title="메모" value="" />
</p>
<input type="hidden" name="pDate" id="pDate" value=""/>
</fieldset>
<fieldset class="list">
<legend>장바구니</legend>
<table class="bag_list" summary="스크롤링을 위해 고정시킬 테이블 제목">
<caption>장바구니 리스트</caption>
<colgroup>
<col width="30" />
<col width="*" />
<col width="35" />
<col width="77" />
</colgroup>
<thead>
<tr>
<th scope="col" class="title1 first">건별취소</th>
<th scope="col" class="title2">제품명</th>
<th scope="col" class="title3">수량</th>
<th scope="col" class="title4">금액</th>
</tr>
</thead>
</table>
<div id="bag_view"
style='height:375px;'
> <!--닫는 태그-->
<div class="wrap_table" style="height:375px;overflow-y:scroll;overflow-x:hidden;"><!--scroll div-->
<table class="bag_list">
<caption>장바구니 리스트</caption>
<colgroup>
<col width="30" />
<col width="*" />
<col width="35" />
<col width="60" />
</colgroup>
<thead style="display:none;">
<tr>
<th scope="col" class="title1 first">건별취소</th>
<th scope="col" class="title2">제품명</th>
<th scope="col" class="title3">수량</th>
<th scope="col" class="title4">금액</th>
</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>
</tbody>
</table>
</div><!--scroll-->
</div>
</fieldset>
<fieldset class="total_price">
<legend>장바구니 총 금액</legend>
<div class="cntPhysic">
<dl class="orderPhy">
<dt><span>주문품목</span></dt>
<dd class=""><span id="cnt_order">2개</span></dd>
</dl>
<dl class="cancelPhy">
<dt><span>취소품목</span></dt>
<dd class=""><span id="cnt_cancel">0개</span></dd>
</dl>
</div>
<dl class="total">
<dt>주문금액</dt>
<dd id="bag_totPrice" class="" data="31520">
31,520원
</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="currVenCd" id="currVenCd" value="50911" />
<input type="hidden" name="currMkind" id="currMkind" value="" />
<input type="hidden" name="kind" value="bag_saveall" />
<input type="hidden" name="currLoc" id="currLoc" value="" />
<input type="hidden" name="currRealVenCd" id="currRealVenCd" value="" />
<input type="hidden" name="ven_rotation_check" id="ven_rotation_check" value="N"/>
</fieldset>
</form>
</div><!-- //bag -->
<input type="hidden" name="cookStockFlag_order" id="cookStockFlag_order" value="N" />
<script type="text/javascript" src="http://sooinpharm.co.kr/Common/Javascript/1.7.2/jquery.min.js"></script>
<script type="text/javascript" src="http://sooinpharm.co.kr/Common/Javascript/1.8/jquery-ui.min.js"></script>
<script type="text/javascript" src="http://sooinpharm.co.kr/Common/Javascript/Bag.js?v=250228"></script>
<script type="text/javascript" src="http://sooinpharm.co.kr/Common/Javascript/Common.js?v=220125"></script>
</body>
</html>

View File

@ -0,0 +1,79 @@
# -*- coding: utf-8 -*-
"""지오영 API 엔드포인트 분석"""
import asyncio
from playwright.async_api import async_playwright
async def capture_network():
async with async_playwright() as p:
browser = await p.chromium.launch(headless=True)
page = await browser.new_page()
# 네트워크 요청 캡처
requests_log = []
def log_request(request):
if 'geoweb' in request.url:
requests_log.append({
'url': request.url,
'method': request.method,
'post_data': request.post_data
})
page.on('request', log_request)
# 로그인
print("로그인 중...")
await page.goto('https://gwn.geoweb.kr/Member/Login')
await page.fill('input[type="text"]', '7390')
await page.fill('input[type="password"]', 'trajet6640')
await page.click('button, input[type="submit"]')
await page.wait_for_load_state('networkidle')
print("로그인 완료")
# 메인 페이지
await page.goto('https://gwn.geoweb.kr/Home/Index')
await page.wait_for_load_state('networkidle')
# 검색
print("검색 중...")
search_input = await page.query_selector('input#srchText, input[name="srchText"]')
if search_input:
await search_input.fill('643104281')
# 검색 버튼
search_btn = await page.query_selector('button:has-text("검색"), input[type="submit"]')
if search_btn:
await search_btn.click()
else:
await page.keyboard.press('Enter')
await page.wait_for_timeout(3000)
# 제품 행 클릭
print("제품 선택 중...")
rows = await page.query_selector_all('table tbody tr')
if rows:
await rows[0].click()
await page.wait_for_timeout(2000)
# 담기 버튼
print("담기 버튼 클릭...")
add_btn = await page.query_selector('button:has-text("담기")')
if add_btn:
await add_btn.click()
await page.wait_for_timeout(3000)
await browser.close()
print("\n" + "="*60)
print("캡처된 요청들:")
print("="*60)
for r in requests_log:
if r['method'] == 'POST' or 'cart' in r['url'].lower() or 'order' in r['url'].lower():
print(f"\n[{r['method']}] {r['url']}")
if r['post_data']:
print(f" Data: {r['post_data'][:200]}")
if __name__ == "__main__":
asyncio.run(capture_network())

11
backend/check_db.py Normal file
View File

@ -0,0 +1,11 @@
import sqlite3
conn = sqlite3.connect('db/orders.db')
cursor = conn.cursor()
cursor.execute("SELECT name FROM sqlite_master WHERE type='table'")
tables = [r[0] for r in cursor.fetchall()]
print('Tables:', tables)
for t in tables:
cursor.execute(f"PRAGMA table_info({t})")
cols = [r[1] for r in cursor.fetchall()]
print(f" {t}: {cols}")
conn.close()

13
backend/check_order_db.py Normal file
View File

@ -0,0 +1,13 @@
# -*- coding: utf-8 -*-
import sqlite3
conn = sqlite3.connect('db/orders.db')
# 테이블 목록
tables = conn.execute("SELECT name FROM sqlite_master WHERE type='table'").fetchall()
print('=== orders.db 테이블 ===')
for t in tables:
count = conn.execute(f'SELECT COUNT(*) FROM {t[0]}').fetchone()[0]
print(f' {t[0]}: {count}개 레코드')
conn.close()

28
backend/check_paai_db.py Normal file
View File

@ -0,0 +1,28 @@
# -*- coding: utf-8 -*-
import sqlite3
conn = sqlite3.connect('db/paai_logs.db')
# 테이블 목록
cursor = conn.execute("SELECT name FROM sqlite_master WHERE type='table'")
tables = cursor.fetchall()
print('테이블 목록:', [t[0] for t in tables])
# 로그 개수
count = conn.execute('SELECT COUNT(*) FROM paai_logs').fetchone()[0]
print(f'PAAI 로그 수: {count}')
# 최근 로그
print('\n최근 로그 3개:')
recent = conn.execute('SELECT id, created_at, patient_name, status FROM paai_logs ORDER BY id DESC LIMIT 3').fetchall()
for r in recent:
print(f' #{r[0]} | {r[1]} | {r[2]} | {r[3]}')
# 피드백 통계
feedback = conn.execute('SELECT feedback_useful, COUNT(*) FROM paai_logs GROUP BY feedback_useful').fetchall()
print('\n피드백 통계:')
for f in feedback:
label = '유용' if f[0] == 1 else ('아님' if f[0] == 0 else '미응답')
print(f' {label}: {f[1]}')
conn.close()

85
backend/download_js.py Normal file
View File

@ -0,0 +1,85 @@
# -*- coding: utf-8 -*-
"""지오영 JS 파일 다운로드 및 분석"""
import requests
import asyncio
from playwright.async_api import async_playwright
import re
async def download_and_analyze():
async with async_playwright() as p:
browser = await p.chromium.launch(headless=True)
page = await browser.new_page()
# 로그인
await page.goto('https://gwn.geoweb.kr/Member/Login')
await page.fill('input[type="text"]', '7390')
await page.fill('input[type="password"]', 'trajet6640')
await page.click('button, input[type="submit"]')
await page.wait_for_load_state('networkidle')
cookies = await page.context.cookies()
await browser.close()
# 세션 설정
session = requests.Session()
for c in cookies:
session.cookies.set(c['name'], c['value'])
# JS 파일 다운로드
js_urls = [
'https://gwn.geoweb.kr/bundles/order_product_cart?v=JPwFQ8DWaNMW1VmbtWYKTJqxT-5255z351W5iZE1qew1',
'https://gwn.geoweb.kr/bundles/order?v=PGhSOAjQ9z6ruAJgJUFuhW9tGQSiJeX6ek-ky3E-tOk1'
]
for url in js_urls:
print(f"\n{'='*60}")
print(f"분석: {url.split('/')[-1].split('?')[0]}")
print('='*60)
resp = session.get(url)
content = resp.text
# 장바구니/주문 관련 함수 찾기
patterns = [
(r'function\s+(fn\w*Cart\w*|add\w*Cart\w*|insert\w*Order\w*)\s*\([^)]*\)', 'function'),
(r'(fn\w*Cart\w*|add\w*Cart\w*)\s*=\s*function', 'var function'),
(r'url\s*:\s*["\']([^"\']*(?:Cart|Order|Add)[^"\']*)["\']', 'ajax url'),
(r'\$\.(?:ajax|post|get)\s*\(\s*["\']([^"\']+)["\']', 'ajax call'),
]
found = {}
for pattern, name in patterns:
matches = re.findall(pattern, content, re.IGNORECASE)
if matches:
for m in matches:
if m not in found:
found[m] = name
for item, ptype in found.items():
print(f"[{ptype}] {item}")
# InsertOrder 함수 찾기
if 'InsertOrder' in content or 'insertOrder' in content:
print("\n--- InsertOrder 함수 발견! ---")
# 해당 부분 추출
idx = content.lower().find('insertorder')
if idx > 0:
snippet = content[max(0, idx-100):idx+500]
print(snippet[:600])
# AddCart 패턴 찾기
add_patterns = re.findall(r'.{50}AddCart.{100}|.{50}addCart.{100}', content, re.IGNORECASE)
if add_patterns:
print("\n--- AddCart 관련 ---")
for p in add_patterns[:3]:
print(p)
# ajax 호출 상세
ajax_pattern = r'\$\.ajax\s*\(\s*\{[^}]{50,500}(Cart|Order)[^}]{0,200}\}'
ajax_matches = re.findall(ajax_pattern, content, re.IGNORECASE | re.DOTALL)
if ajax_matches:
print(f"\n--- AJAX 호출 {len(ajax_matches)}개 발견 ---")
if __name__ == "__main__":
asyncio.run(download_and_analyze())

View File

@ -0,0 +1,86 @@
# -*- coding: utf-8 -*-
"""AddCart 함수 전체 추출"""
import requests
import asyncio
from playwright.async_api import async_playwright
import re
async def extract():
async with async_playwright() as p:
browser = await p.chromium.launch(headless=True)
page = await browser.new_page()
await page.goto('https://gwn.geoweb.kr/Member/Login')
await page.fill('input[type="text"]', '7390')
await page.fill('input[type="password"]', 'trajet6640')
await page.click('button, input[type="submit"]')
await page.wait_for_load_state('networkidle')
cookies = await page.context.cookies()
await browser.close()
session = requests.Session()
for c in cookies:
session.cookies.set(c['name'], c['value'])
resp = session.get('https://gwn.geoweb.kr/bundles/order?v=PGhSOAjQ9z6ruAJgJUFuhW9tGQSiJeX6ek-ky3E-tOk1')
content = resp.text
# AddCart 함수 전체 찾기
# function AddCart(n,t,i){ ... }
start = content.find('function AddCart')
if start > 0:
# 중괄호 매칭으로 함수 끝 찾기
depth = 0
end = start
in_func = False
for i in range(start, min(start + 5000, len(content))):
if content[i] == '{':
depth += 1
in_func = True
elif content[i] == '}':
depth -= 1
if in_func and depth == 0:
end = i + 1
break
func_content = content[start:end]
print("="*60)
print("AddCart 함수 전체:")
print("="*60)
print(func_content)
# ajax 호출 찾기
ajax_match = re.search(r'\$\.ajax\s*\(\s*\{[^}]+\}', func_content, re.DOTALL)
if ajax_match:
print("\n" + "="*60)
print("AJAX 호출:")
print("="*60)
print(ajax_match.group())
# InsertOrder 함수도 찾기
start2 = content.find('function InsertOrder')
if start2 > 0:
depth = 0
end2 = start2
in_func = False
for i in range(start2, min(start2 + 3000, len(content))):
if content[i] == '{':
depth += 1
in_func = True
elif content[i] == '}':
depth -= 1
if in_func and depth == 0:
end2 = i + 1
break
print("\n" + "="*60)
print("InsertOrder 함수:")
print("="*60)
print(content[start2:end2][:1500])
if __name__ == "__main__":
asyncio.run(extract())

View File

@ -0,0 +1,71 @@
# -*- coding: utf-8 -*-
"""ProcessCart 함수 추출"""
import requests
import asyncio
from playwright.async_api import async_playwright
async def extract():
async with async_playwright() as p:
browser = await p.chromium.launch(headless=True)
page = await browser.new_page()
await page.goto('https://gwn.geoweb.kr/Member/Login')
await page.fill('input[type="text"]', '7390')
await page.fill('input[type="password"]', 'trajet6640')
await page.click('button, input[type="submit"]')
await page.wait_for_load_state('networkidle')
cookies = await page.context.cookies()
await browser.close()
session = requests.Session()
for c in cookies:
session.cookies.set(c['name'], c['value'])
resp = session.get('https://gwn.geoweb.kr/bundles/order?v=PGhSOAjQ9z6ruAJgJUFuhW9tGQSiJeX6ek-ky3E-tOk1')
content = resp.text
# ProcessCart 함수 찾기
start = content.find('function ProcessCart')
if start > 0:
depth = 0
end = start
in_func = False
for i in range(start, min(start + 5000, len(content))):
if content[i] == '{':
depth += 1
in_func = True
elif content[i] == '}':
depth -= 1
if in_func and depth == 0:
end = i + 1
break
func_content = content[start:end]
print("="*60)
print("ProcessCart 함수:")
print("="*60)
print(func_content)
else:
# 다른 패턴으로 찾기
print("ProcessCart를 변수로 찾기...")
start = content.find('ProcessCart=function')
if start > 0:
print(content[start:start+2000])
else:
# ajax 호출 찾기
import re
ajax_calls = re.findall(r'\$\.ajax\s*\(\s*\{[^}]{100,1000}(Cart|Order)[^}]{0,500}\}', content, re.IGNORECASE | re.DOTALL)
print(f"\nAJAX 호출 {len(ajax_calls)}개 발견")
# url 패턴 찾기
urls = re.findall(r'url\s*:\s*["\']([^"\']+)["\']', content)
print("\n모든 URL:")
for url in set(urls):
if 'Cart' in url or 'Order' in url or 'Add' in url or 'Insert' in url:
print(f" {url}")
if __name__ == "__main__":
asyncio.run(extract())

90
backend/find_cart_js.py Normal file
View File

@ -0,0 +1,90 @@
# -*- coding: utf-8 -*-
"""지오영 JavaScript에서 장바구니 추가 함수 찾기"""
import requests
from bs4 import BeautifulSoup
import asyncio
from playwright.async_api import async_playwright
import re
async def analyze_js():
async with async_playwright() as p:
browser = await p.chromium.launch(headless=True)
page = await browser.new_page()
# 로그인
await page.goto('https://gwn.geoweb.kr/Member/Login')
await page.fill('input[type="text"]', '7390')
await page.fill('input[type="password"]', 'trajet6640')
await page.click('button, input[type="submit"]')
await page.wait_for_load_state('networkidle')
# 메인 페이지
await page.goto('https://gwn.geoweb.kr/Home/Index')
await page.wait_for_timeout(3000)
# 모든 스크립트 태그 내용 가져오기
scripts = await page.evaluate('''() => {
var result = [];
var scripts = document.querySelectorAll('script');
scripts.forEach(s => {
if (s.src) {
result.push({type: 'src', url: s.src});
}
if (s.textContent && s.textContent.length > 100) {
result.push({type: 'inline', content: s.textContent});
}
});
return result;
}''')
print(f"스크립트 {len(scripts)}개 발견")
# 장바구니 관련 함수 찾기
for s in scripts:
if s['type'] == 'inline':
content = s['content']
# 담기, Cart, Add 관련 찾기
if '담기' in content or 'AddCart' in content or 'addCart' in content or 'InsertOrder' in content:
print("\n" + "="*60)
print("장바구니 관련 스크립트 발견!")
print("="*60)
# 함수 정의 찾기
func_patterns = [
r'function\s+(\w*[Cc]art\w*)\s*\([^)]*\)\s*{[^}]+}',
r'function\s+(\w*[Aa]dd\w*)\s*\([^)]*\)\s*{[^}]+}',
r'(\w+)\s*=\s*function\s*\([^)]*\)\s*{[^}]*[Cc]art[^}]*}',
]
for pattern in func_patterns:
matches = re.findall(pattern, content, re.DOTALL)
for m in matches:
print(f"함수 발견: {m}")
# ajax 호출 찾기
ajax_pattern = r'\$\.ajax\s*\(\s*{[^}]+url[^}]+}'
ajax_matches = re.findall(ajax_pattern, content, re.DOTALL)
for m in ajax_matches:
if 'cart' in m.lower() or 'order' in m.lower() or 'add' in m.lower():
print(f"\nAJAX 호출:\n{m[:500]}")
# 일부 내용 출력
lines = content.split('\n')
for i, line in enumerate(lines):
if '담기' in line or 'addCart' in line.lower() or 'insertorder' in line.lower():
print(f"\n관련 라인 {i}:")
print('\n'.join(lines[max(0,i-3):min(len(lines),i+10)]))
# 외부 JS 파일 확인
print("\n" + "="*60)
print("외부 스크립트 파일:")
print("="*60)
for s in scripts:
if s['type'] == 'src':
print(s['url'])
await browser.close()
if __name__ == "__main__":
asyncio.run(analyze_js())

82
backend/find_frmsave.py Normal file
View File

@ -0,0 +1,82 @@
# -*- coding: utf-8 -*-
"""frmSave 폼과 주문 저장 로직 찾기"""
import requests
from bs4 import BeautifulSoup
import asyncio
from playwright.async_api import async_playwright
import re
async def analyze():
async with async_playwright() as p:
browser = await p.chromium.launch(headless=True)
page = await browser.new_page()
# 네트워크 요청 캡처
requests_log = []
def log_req(req):
if req.method == 'POST':
requests_log.append({'url': req.url, 'data': req.post_data})
page.on('request', log_req)
await page.goto('https://gwn.geoweb.kr/Member/Login')
await page.fill('input[type="text"]', '7390')
await page.fill('input[type="password"]', 'trajet6640')
await page.click('button, input[type="submit"]')
await page.wait_for_load_state('networkidle')
# 메인 페이지
await page.goto('https://gwn.geoweb.kr/Home/Index')
await page.wait_for_timeout(2000)
# 페이지 HTML에서 frmSave 폼 찾기
html = await page.content()
print("="*60)
print("frmSave 폼 찾기:")
print("="*60)
soup = BeautifulSoup(html, 'html.parser')
# 모든 form 찾기
forms = soup.find_all('form')
for form in forms:
form_id = form.get('id', '')
form_action = form.get('action', '')
print(f"폼: id={form_id}, action={form_action}")
if 'save' in form_id.lower() or 'order' in form_id.lower():
print(f" >>> 주문 관련 폼 발견!")
inputs = form.find_all('input')
for inp in inputs[:10]:
print(f" - {inp.get('name')}: {inp.get('value', '')[:30]}")
# 주문저장 버튼 찾기
print("\n" + "="*60)
print("주문저장 버튼:")
print("="*60)
buttons = soup.find_all(['button', 'input'], type=['button', 'submit'])
for btn in buttons:
text = btn.get_text(strip=True) or btn.get('value', '')
onclick = btn.get('onclick', '')
if '저장' in text or '주문' in text:
print(f"버튼: {text}")
print(f" onclick: {onclick[:100]}")
# JavaScript에서 폼 action 찾기
scripts = soup.find_all('script')
for script in scripts:
text = script.get_text() or ''
if 'frmSave' in text:
print("\n" + "="*60)
print("frmSave 관련 스크립트:")
print("="*60)
# frmSave 근처 코드 출력
idx = text.find('frmSave')
print(text[max(0,idx-100):idx+300])
await browser.close()
if __name__ == "__main__":
asyncio.run(analyze())

70
backend/find_order_api.py Normal file
View File

@ -0,0 +1,70 @@
# -*- coding: utf-8 -*-
"""주문 확정 API 찾기"""
import requests
import asyncio
from playwright.async_api import async_playwright
import re
async def find_order_api():
async with async_playwright() as p:
browser = await p.chromium.launch(headless=True)
page = await browser.new_page()
await page.goto('https://gwn.geoweb.kr/Member/Login')
await page.fill('input[type="text"]', '7390')
await page.fill('input[type="password"]', 'trajet6640')
await page.click('button, input[type="submit"]')
await page.wait_for_load_state('networkidle')
cookies = await page.context.cookies()
await browser.close()
session = requests.Session()
for c in cookies:
session.cookies.set(c['name'], c['value'])
# order.js 다운로드
resp = session.get('https://gwn.geoweb.kr/bundles/order?v=PGhSOAjQ9z6ruAJgJUFuhW9tGQSiJeX6ek-ky3E-tOk1')
content = resp.text
# InsertOrder, ConfirmOrder, SubmitOrder 등 찾기
print("="*60)
print("주문 관련 함수 찾기")
print("="*60)
# 함수 찾기
funcs = ['InsertOrder', 'ConfirmOrder', 'SubmitOrder', 'SaveOrder', 'ProcessOrder', 'DataOrder']
for func in funcs:
start = content.find(f'function {func}')
if start < 0:
start = content.find(f'{func}=function')
if start < 0:
start = content.find(f'{func}(')
if start > 0:
print(f"\n{func} 발견!")
# 함수 내용 출력
snippet = content[max(0, start-20):start+800]
print(snippet[:600])
# DataOrder URL 찾기
print("\n" + "="*60)
print("DataOrder 관련")
print("="*60)
dataorder_pattern = re.findall(r'.{30}DataOrder.{100}', content)
for p in dataorder_pattern[:5]:
print(p)
# 모든 ajax URL 찾기
print("\n" + "="*60)
print("주문 관련 URL")
print("="*60)
urls = re.findall(r'url\s*:\s*["\']([^"\']*(?:Order|Submit|Confirm|Save)[^"\']*)["\']', content, re.IGNORECASE)
for url in set(urls):
print(url)
if __name__ == "__main__":
asyncio.run(find_order_api())

View File

@ -0,0 +1,76 @@
# -*- coding: utf-8 -*-
"""주문 확정 API 찾기 - 전체 검색"""
import requests
import asyncio
from playwright.async_api import async_playwright
import re
async def analyze():
async with async_playwright() as p:
browser = await p.chromium.launch(headless=True)
page = await browser.new_page()
await page.goto('https://gwn.geoweb.kr/Member/Login')
await page.fill('input[type="text"]', '7390')
await page.fill('input[type="password"]', 'trajet6640')
await page.click('button, input[type="submit"]')
await page.wait_for_load_state('networkidle')
cookies = await page.context.cookies()
await browser.close()
session = requests.Session()
for c in cookies:
session.cookies.set(c['name'], c['value'])
# 모든 JS 번들 다운로드
js_urls = [
'https://gwn.geoweb.kr/bundles/order_product_cart?v=JPwFQ8DWaNMW1VmbtWYKTJqxT-5255z351W5iZE1qew1',
'https://gwn.geoweb.kr/bundles/order?v=PGhSOAjQ9z6ruAJgJUFuhW9tGQSiJeX6ek-ky3E-tOk1',
'https://gwn.geoweb.kr/bundles/javascript?v=Tn_AqbA-PX_uu3d0zjfQOYS6NPSDLtOVqjW95a949Ow1'
]
all_content = ""
for url in js_urls:
resp = session.get(url)
all_content += resp.text + "\n"
print(f"총 JS 길이: {len(all_content)}")
# 모든 ajax POST URL 찾기
print("\n" + "="*60)
print("모든 POST URL:")
print("="*60)
# $.ajax 패턴
ajax_patterns = re.findall(r'\$\.ajax\s*\(\s*\{[^}]*url\s*:\s*["\']([^"\']+)["\'][^}]*type\s*:\s*["\']POST["\']', all_content, re.IGNORECASE | re.DOTALL)
ajax_patterns += re.findall(r'\$\.ajax\s*\(\s*\{[^}]*type\s*:\s*["\']POST["\'][^}]*url\s*:\s*["\']([^"\']+)["\']', all_content, re.IGNORECASE | re.DOTALL)
for url in set(ajax_patterns):
print(url)
# 주문저장, 저장 관련
print("\n" + "="*60)
print("저장/주문 관련 키워드:")
print("="*60)
keywords = ['주문저장', '저장', 'save', 'submit', 'confirm', 'order', 'insert']
for kw in keywords:
matches = re.findall(rf'.{{50}}{kw}.{{50}}', all_content, re.IGNORECASE)
if matches:
print(f"\n--- {kw} ---")
for m in matches[:3]:
print(m.replace('\n', ' ')[:100])
# 버튼 onclick 찾기
print("\n" + "="*60)
print("주문저장 버튼:")
print("="*60)
save_btn = re.findall(r'주문저장.{0,200}', all_content)
for s in save_btn[:5]:
print(s[:150])
if __name__ == "__main__":
asyncio.run(analyze())

316
backend/geoyoung_api.py Normal file
View File

@ -0,0 +1,316 @@
# -*- coding: utf-8 -*-
"""
지오영 도매상 API - Flask Blueprint
핵심 로직은 wholesale 패키지에서 가져옴
파일은 Flask API 연동만 담당
"""
import re
import time
import logging
from flask import Blueprint, jsonify, request
# wholesale 패키지 경로 설정
import wholesale_path
# wholesale 패키지에서 핵심 클래스 가져오기
from wholesale import GeoYoungSession
logger = logging.getLogger(__name__)
# Blueprint 생성
geoyoung_bp = Blueprint('geoyoung', __name__, url_prefix='/api/geoyoung')
# ========== 세션 관리 ==========
_geo_session = None
def get_geo_session():
global _geo_session
if _geo_session is None:
_geo_session = GeoYoungSession()
return _geo_session
def search_geoyoung_stock(keyword: str):
"""지오영 재고 검색 (동기, 빠름)"""
try:
session = get_geo_session()
products = session.search_stock(keyword)
return {
'success': True,
'keyword': keyword,
'count': len(products),
'items': products
}
except Exception as e:
logger.error(f"지오영 검색 오류: {e}")
return {'success': False, 'error': 'SEARCH_ERROR', 'message': str(e)}
# ========== Flask API Routes ==========
@geoyoung_bp.route('/stock', methods=['GET'])
def api_geoyoung_stock():
"""
지오영 재고 조회 API (빠름)
GET /api/geoyoung/stock?kd_code=670400830
GET /api/geoyoung/stock?keyword=레바미피드
"""
kd_code = request.args.get('kd_code', '').strip()
keyword = request.args.get('keyword', '').strip()
search_term = kd_code or keyword
if not search_term:
return jsonify({
'success': False,
'error': 'MISSING_PARAM',
'message': 'kd_code 또는 keyword 파라미터가 필요합니다'
}), 400
try:
result = search_geoyoung_stock(search_term)
return jsonify(result)
except Exception as e:
logger.error(f"지오영 API 오류: {e}")
return jsonify({
'success': False,
'error': 'API_ERROR',
'message': str(e)
}), 500
@geoyoung_bp.route('/stock-by-name', methods=['GET'])
def api_geoyoung_stock_by_name():
"""
제품명에서 성분명 추출 지오영 검색
GET /api/geoyoung/stock-by-name?product_name=휴니즈레바미피드정_(0.1g/1)
"""
product_name = request.args.get('product_name', '').strip()
if not product_name:
return jsonify({
'success': False,
'error': 'MISSING_PARAM',
'message': 'product_name 파라미터가 필요합니다'
}), 400
# 성분명 추출
prefixes = ['휴니즈', '휴온스', '대웅', '한미', '종근당', '유한', '녹십자', '동아', '일동', '광동',
'삼성', '안국', '보령', '광동', '경동', '현대', '일양', '태극', '환인', '에스케이']
ingredient = product_name
for prefix in prefixes:
if ingredient.startswith(prefix):
ingredient = ingredient[len(prefix):]
break
match = re.match(r'^([가-힣a-zA-Z]+)', ingredient)
if match:
ingredient = match.group(1)
if ingredient.endswith(''):
ingredient = ingredient[:-1]
elif ingredient.endswith('캡슐'):
ingredient = ingredient[:-2]
if not ingredient:
ingredient = product_name[:10]
try:
result = search_geoyoung_stock(ingredient)
result['extracted_ingredient'] = ingredient
result['original_product_name'] = product_name
return jsonify(result)
except Exception as e:
logger.error(f"지오영 API 오류: {e}")
return jsonify({
'success': False,
'error': 'API_ERROR',
'message': str(e)
}), 500
@geoyoung_bp.route('/session-status', methods=['GET'])
def api_session_status():
"""세션 상태 확인"""
session = get_geo_session()
return jsonify({
'logged_in': session._logged_in,
'last_login': session._last_login,
'session_age_sec': int(time.time() - session._last_login) if session._last_login else None
})
@geoyoung_bp.route('/cart', methods=['GET'])
def api_geoyoung_cart():
"""장바구니 조회 API"""
try:
session = get_geo_session()
result = session.get_cart()
return jsonify(result)
except Exception as e:
return jsonify({'success': False, 'error': str(e), 'items': []}), 500
@geoyoung_bp.route('/cart/clear', methods=['POST'])
def api_geoyoung_cart_clear():
"""장바구니 비우기 API"""
try:
session = get_geo_session()
result = session.clear_cart()
return jsonify(result)
except Exception as e:
return jsonify({'success': False, 'error': str(e)}), 500
@geoyoung_bp.route('/confirm', methods=['POST'])
def api_geoyoung_confirm():
"""주문 확정 API"""
data = request.get_json() or {}
memo = data.get('memo', '')
try:
session = get_geo_session()
result = session.submit_order(memo)
return jsonify(result)
except Exception as e:
return jsonify({'success': False, 'error': str(e)}), 500
@geoyoung_bp.route('/full-order', methods=['POST'])
def api_geoyoung_full_order():
"""전체 주문 API (검색 → 장바구니 → 확정)"""
data = request.get_json()
if not data or not data.get('kd_code'):
return jsonify({'success': False, 'error': 'kd_code required'}), 400
try:
session = get_geo_session()
result = session.full_order(
kd_code=data['kd_code'],
quantity=data.get('quantity', 1),
specification=data.get('specification'),
check_stock=data.get('check_stock', True),
auto_confirm=data.get('auto_confirm', True),
memo=data.get('memo', '')
)
return jsonify(result)
except Exception as e:
return jsonify({'success': False, 'error': str(e)}), 500
@geoyoung_bp.route('/order', methods=['POST'])
def api_geoyoung_order():
"""지오영 주문 API (장바구니 추가)"""
data = request.get_json()
if not data:
return jsonify({'success': False, 'error': 'NO_DATA'}), 400
kd_code = data.get('kd_code', '').strip()
quantity = data.get('quantity', 1)
specification = data.get('specification')
check_stock = data.get('check_stock', True)
if not kd_code:
return jsonify({
'success': False,
'error': 'MISSING_PARAM',
'message': 'kd_code가 필요합니다'
}), 400
try:
session = get_geo_session()
result = session.quick_order(
kd_code=kd_code,
quantity=quantity,
spec=specification,
check_stock=check_stock
)
return jsonify(result)
except Exception as e:
logger.error(f"지오영 주문 오류: {e}")
return jsonify({
'success': False,
'error': 'ORDER_ERROR',
'message': str(e)
}), 500
@geoyoung_bp.route('/order-batch', methods=['POST'])
def api_geoyoung_order_batch():
"""지오영 일괄 주문 API"""
data = request.get_json()
if not data or not data.get('items'):
return jsonify({'success': False, 'error': 'NO_ITEMS'}), 400
items = data.get('items', [])
check_stock = data.get('check_stock', True)
session = get_geo_session()
results = []
success_count = 0
failed_count = 0
for item in items:
kd_code = item.get('kd_code', '').strip()
quantity = item.get('quantity', 1)
specification = item.get('specification')
if not kd_code:
results.append({
'kd_code': kd_code,
'success': False,
'error': 'MISSING_KD_CODE'
})
failed_count += 1
continue
try:
result = session.quick_order(
kd_code=kd_code,
quantity=quantity,
spec=specification,
check_stock=check_stock
)
result['kd_code'] = kd_code
result['requested_qty'] = quantity
results.append(result)
if result.get('success'):
success_count += 1
else:
failed_count += 1
except Exception as e:
results.append({
'kd_code': kd_code,
'success': False,
'error': 'EXCEPTION',
'message': str(e)
})
failed_count += 1
return jsonify({
'success': True,
'total': len(items),
'success_count': success_count,
'failed_count': failed_count,
'results': results
})
# ========== 하위 호환성 ==========
# 기존 코드에서 직접 클래스 참조하는 경우를 위해
GeoyoungSession = GeoYoungSession

859
backend/order_db.py Normal file
View File

@ -0,0 +1,859 @@
# -*- coding: utf-8 -*-
"""
주문 관리 DB (SQLite)
- 다중 도매상 지원 (지오영, 수인, 백제 )
- 주문 상태 추적
- 품목별 결과 관리
- 자동화 ERP 확장 대비
"""
import sqlite3
import os
from datetime import datetime
from typing import Optional, List, Dict
import json
# DB 경로
DB_PATH = os.path.join(os.path.dirname(__file__), 'db', 'orders.db')
def get_connection():
"""DB 연결"""
os.makedirs(os.path.dirname(DB_PATH), exist_ok=True)
conn = sqlite3.connect(DB_PATH)
conn.row_factory = sqlite3.Row
return conn
def init_db():
"""DB 초기화 - 테이블 생성"""
conn = get_connection()
cursor = conn.cursor()
# ─────────────────────────────────────────────
# 도매상 마스터
# ─────────────────────────────────────────────
cursor.execute('''
CREATE TABLE IF NOT EXISTS wholesalers (
id TEXT PRIMARY KEY, -- 'geoyoung', 'sooin', 'baekje'
name TEXT NOT NULL, -- '지오영', '수인', '백제'
api_type TEXT, -- 'playwright', 'api', 'manual'
base_url TEXT,
is_active INTEGER DEFAULT 1,
config_json TEXT, -- 로그인 정보 (암호화 권장)
created_at TEXT DEFAULT CURRENT_TIMESTAMP
)
''')
# 기본 도매상 등록
cursor.execute('''
INSERT OR IGNORE INTO wholesalers (id, name, api_type, base_url)
VALUES
('geoyoung', '지오영', 'playwright', 'https://gwn.geoweb.kr'),
('sooin', '수인', 'manual', NULL),
('baekje', '백제', 'manual', NULL)
''')
# ─────────────────────────────────────────────
# 주문 헤더
# ─────────────────────────────────────────────
cursor.execute('''
CREATE TABLE IF NOT EXISTS orders (
id INTEGER PRIMARY KEY AUTOINCREMENT,
order_no TEXT UNIQUE, -- 주문번호 (ORD-20260306-001)
wholesaler_id TEXT NOT NULL, -- 도매상 ID
-- 주문 정보
order_date TEXT NOT NULL, -- 주문일 (YYYY-MM-DD)
order_time TEXT, -- 주문시간 (HH:MM:SS)
order_type TEXT DEFAULT 'manual', -- 'manual', 'auto', 'scheduled'
order_session TEXT, -- 'morning', 'afternoon', 'evening'
-- 상태
status TEXT DEFAULT 'draft', -- draft, pending, submitted, partial, completed, failed, cancelled
-- 집계
total_items INTEGER DEFAULT 0,
total_qty INTEGER DEFAULT 0,
success_items INTEGER DEFAULT 0,
failed_items INTEGER DEFAULT 0,
-- 참조
parent_order_id INTEGER, -- 재주문 원주문 참조
reference_period TEXT, -- 사용량 조회 기간 (2026-03-01~2026-03-06)
-- 메타
note TEXT,
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT DEFAULT CURRENT_TIMESTAMP,
submitted_at TEXT, -- 실제 제출 시간
completed_at TEXT,
FOREIGN KEY (wholesaler_id) REFERENCES wholesalers(id),
FOREIGN KEY (parent_order_id) REFERENCES orders(id)
)
''')
# ─────────────────────────────────────────────
# 주문 품목 상세
# ─────────────────────────────────────────────
cursor.execute('''
CREATE TABLE IF NOT EXISTS order_items (
id INTEGER PRIMARY KEY AUTOINCREMENT,
order_id INTEGER NOT NULL,
-- 약품 정보
drug_code TEXT NOT NULL, -- PIT3000 약품코드
kd_code TEXT, -- 보험코드 (지오영 검색용)
product_name TEXT NOT NULL,
manufacturer TEXT,
-- 규격/수량
specification TEXT, -- '30T', '300T', '500T'
unit_qty INTEGER, -- 규격당 수량 (30, 300, 500)
order_qty INTEGER NOT NULL, -- 주문 수량 (단위 개수)
total_dose INTEGER, -- 정제수 (order_qty * unit_qty)
-- 주문 근거
usage_qty INTEGER, -- 사용량 (조회 기간)
current_stock INTEGER, -- 주문 시점 재고
-- 가격 (선택)
unit_price INTEGER,
total_price INTEGER,
-- 상태
status TEXT DEFAULT 'pending', -- pending, submitted, success, failed, cancelled
-- 결과
result_code TEXT, -- 'OK', 'OUT_OF_STOCK', 'NOT_FOUND', 'ERROR'
result_message TEXT,
wholesaler_order_no TEXT, -- 도매상 주문번호
-- 메타
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (order_id) REFERENCES orders(id) ON DELETE CASCADE
)
''')
# ─────────────────────────────────────────────
# 주문 로그 (상태 변경 이력)
# ─────────────────────────────────────────────
cursor.execute('''
CREATE TABLE IF NOT EXISTS order_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
order_id INTEGER NOT NULL,
order_item_id INTEGER, -- NULL이면 주문 전체 로그
action TEXT NOT NULL, -- 'created', 'submitted', 'success', 'failed', 'cancelled'
old_status TEXT,
new_status TEXT,
message TEXT,
detail_json TEXT, -- API 응답 상세 정보
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (order_id) REFERENCES orders(id) ON DELETE CASCADE,
FOREIGN KEY (order_item_id) REFERENCES order_items(id) ON DELETE CASCADE
)
''')
# ─────────────────────────────────────────────
# 주문 컨텍스트 (AI 학습용 스냅샷)
# ─────────────────────────────────────────────
cursor.execute('''
CREATE TABLE IF NOT EXISTS order_context (
id INTEGER PRIMARY KEY AUTOINCREMENT,
order_item_id INTEGER NOT NULL,
-- 약품 정보
drug_code TEXT NOT NULL,
product_name TEXT,
-- 주문 시점 재고
stock_at_order INTEGER, -- 주문 시점 현재고
-- 사용량 분석
usage_1d INTEGER, -- 최근 1 사용량
usage_7d INTEGER, -- 최근 7 사용량
usage_30d INTEGER, -- 최근 30 사용량
avg_daily_usage REAL, -- 일평균 사용량 (30 기준)
-- 주문 패턴
ordered_spec TEXT, -- 주문한 규격 (30T, 300T)
ordered_qty INTEGER, -- 주문 수량 (단위 개수)
ordered_dose INTEGER, -- 주문 정제수
-- 규격 선택 이유 (AI 분석용)
available_specs TEXT, -- 가능한 규격들 JSON ["30T", "300T"]
spec_stocks TEXT, -- 규격별 도매상 재고 JSON {"30T": 50, "300T": 0}
selection_reason TEXT, -- 'stock_available', 'best_fit', 'only_option', 'user_choice'
-- 예측 vs 실제 (나중에 업데이트)
days_until_stockout REAL, -- 주문 시점 예상 재고 소진일
actual_reorder_days INTEGER, -- 실제 재주문까지 일수 (나중에 업데이트)
-- 메타
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (order_item_id) REFERENCES order_items(id) ON DELETE CASCADE
)
''')
# ─────────────────────────────────────────────
# 일별 사용량 추적 (시계열 데이터)
# ─────────────────────────────────────────────
cursor.execute('''
CREATE TABLE IF NOT EXISTS daily_usage (
id INTEGER PRIMARY KEY AUTOINCREMENT,
drug_code TEXT NOT NULL,
usage_date TEXT NOT NULL, -- YYYY-MM-DD
-- 처방 데이터
rx_count INTEGER DEFAULT 0, -- 처방 건수
rx_qty INTEGER DEFAULT 0, -- 처방 수량 (정제수)
-- POS 데이터 (일반약)
pos_count INTEGER DEFAULT 0,
pos_qty INTEGER DEFAULT 0,
-- 집계
total_qty INTEGER DEFAULT 0,
-- 재고 스냅샷
stock_start INTEGER, -- 시작 재고
stock_end INTEGER, -- 종료 재고
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
UNIQUE(drug_code, usage_date)
)
''')
# ─────────────────────────────────────────────
# AI 분석 결과/패턴
# ─────────────────────────────────────────────
cursor.execute('''
CREATE TABLE IF NOT EXISTS order_patterns (
id INTEGER PRIMARY KEY AUTOINCREMENT,
drug_code TEXT NOT NULL,
-- 분석 기간
analysis_date TEXT NOT NULL,
analysis_period_days INTEGER,
-- 사용 패턴
avg_daily_usage REAL,
usage_stddev REAL, -- 사용량 표준편차 (변동성)
peak_usage INTEGER, -- 최대 사용량
-- 주문 패턴
typical_order_spec TEXT, -- 주로 주문하는 규격
typical_order_qty INTEGER, -- 주로 주문하는 수량
order_frequency_days REAL, -- 평균 주문 주기 ()
-- AI 추천
recommended_spec TEXT, -- 추천 규격
recommended_qty INTEGER, -- 추천 수량
recommended_reorder_point INTEGER,-- 추천 재주문점 (재고가 이하면 주문)
confidence_score REAL, -- 추천 신뢰도 (0-1)
-- 모델 정보
model_version TEXT,
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
UNIQUE(drug_code, analysis_date)
)
''')
# ─────────────────────────────────────────────
# 인덱스
# ─────────────────────────────────────────────
cursor.execute('CREATE INDEX IF NOT EXISTS idx_orders_date ON orders(order_date)')
cursor.execute('CREATE INDEX IF NOT EXISTS idx_orders_status ON orders(status)')
cursor.execute('CREATE INDEX IF NOT EXISTS idx_orders_wholesaler ON orders(wholesaler_id)')
cursor.execute('CREATE INDEX IF NOT EXISTS idx_order_items_drug ON order_items(drug_code)')
cursor.execute('CREATE INDEX IF NOT EXISTS idx_order_items_status ON order_items(status)')
cursor.execute('CREATE INDEX IF NOT EXISTS idx_order_context_drug ON order_context(drug_code)')
cursor.execute('CREATE INDEX IF NOT EXISTS idx_daily_usage_drug ON daily_usage(drug_code)')
cursor.execute('CREATE INDEX IF NOT EXISTS idx_daily_usage_date ON daily_usage(usage_date)')
cursor.execute('CREATE INDEX IF NOT EXISTS idx_order_patterns_drug ON order_patterns(drug_code)')
conn.commit()
conn.close()
return True
def generate_order_no(wholesaler_id: str) -> str:
"""주문번호 생성 (ORD-GEO-20260306-001)"""
prefix_map = {
'geoyoung': 'GEO',
'sooin': 'SOO',
'baekje': 'BAK'
}
prefix = prefix_map.get(wholesaler_id, 'ORD')
date_str = datetime.now().strftime('%Y%m%d')
conn = get_connection()
cursor = conn.cursor()
# 오늘 해당 도매상 주문 수 카운트
cursor.execute('''
SELECT COUNT(*) FROM orders
WHERE wholesaler_id = ? AND order_date = ?
''', (wholesaler_id, datetime.now().strftime('%Y-%m-%d')))
count = cursor.fetchone()[0] + 1
conn.close()
return f"ORD-{prefix}-{date_str}-{count:03d}"
def create_order(wholesaler_id: str, items: List[Dict],
order_type: str = 'manual',
order_session: str = None,
reference_period: str = None,
note: str = None) -> Dict:
"""
주문 생성 (draft 상태)
items: [
{
'drug_code': '670400830',
'kd_code': '670400830',
'product_name': '레바미피드정 30T',
'manufacturer': '휴온스',
'specification': '30T',
'unit_qty': 30,
'order_qty': 10,
'usage_qty': 280,
'current_stock': 50
}
]
"""
conn = get_connection()
cursor = conn.cursor()
try:
order_no = generate_order_no(wholesaler_id)
now = datetime.now()
# 주문 헤더 생성
cursor.execute('''
INSERT INTO orders (
order_no, wholesaler_id, order_date, order_time,
order_type, order_session, reference_period, note,
total_items, total_qty, status
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'draft')
''', (
order_no,
wholesaler_id,
now.strftime('%Y-%m-%d'),
now.strftime('%H:%M:%S'),
order_type,
order_session,
reference_period,
note,
len(items),
sum(item.get('order_qty', 0) for item in items)
))
order_id = cursor.lastrowid
# 주문 품목 생성
for item in items:
unit_qty = item.get('unit_qty', 1)
order_qty = item.get('order_qty', 0)
cursor.execute('''
INSERT INTO order_items (
order_id, drug_code, kd_code, product_name, manufacturer,
specification, unit_qty, order_qty, total_dose,
usage_qty, current_stock, status
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'pending')
''', (
order_id,
item.get('drug_code'),
item.get('kd_code'),
item.get('product_name'),
item.get('manufacturer'),
item.get('specification'),
unit_qty,
order_qty,
order_qty * unit_qty,
item.get('usage_qty'),
item.get('current_stock')
))
# 로그
cursor.execute('''
INSERT INTO order_logs (order_id, action, new_status, message)
VALUES (?, 'created', 'draft', ?)
''', (order_id, f'{len(items)}개 품목 주문 생성'))
conn.commit()
return {
'success': True,
'order_id': order_id,
'order_no': order_no,
'total_items': len(items)
}
except Exception as e:
conn.rollback()
return {'success': False, 'error': str(e)}
finally:
conn.close()
def get_order(order_id: int) -> Optional[Dict]:
"""주문 조회 (품목 포함)"""
conn = get_connection()
cursor = conn.cursor()
cursor.execute('SELECT * FROM orders WHERE id = ?', (order_id,))
order = cursor.fetchone()
if not order:
conn.close()
return None
cursor.execute('SELECT * FROM order_items WHERE order_id = ?', (order_id,))
items = cursor.fetchall()
conn.close()
return {
**dict(order),
'items': [dict(item) for item in items]
}
def update_order_status(order_id: int, status: str, message: str = None) -> bool:
"""주문 상태 업데이트"""
conn = get_connection()
cursor = conn.cursor()
try:
# 현재 상태 조회
cursor.execute('SELECT status FROM orders WHERE id = ?', (order_id,))
row = cursor.fetchone()
if not row:
return False
old_status = row['status']
# 상태 업데이트
now = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
update_fields = ['status = ?', 'updated_at = ?']
params = [status, now]
if status == 'submitted':
update_fields.append('submitted_at = ?')
params.append(now)
elif status in ('completed', 'failed'):
update_fields.append('completed_at = ?')
params.append(now)
params.append(order_id)
cursor.execute(f'''
UPDATE orders SET {', '.join(update_fields)} WHERE id = ?
''', params)
# 로그
cursor.execute('''
INSERT INTO order_logs (order_id, action, old_status, new_status, message)
VALUES (?, ?, ?, ?, ?)
''', (order_id, status, old_status, status, message))
conn.commit()
return True
except Exception as e:
conn.rollback()
return False
finally:
conn.close()
def update_item_result(item_id: int, status: str, result_code: str = None,
result_message: str = None, wholesaler_order_no: str = None) -> bool:
"""품목 결과 업데이트"""
conn = get_connection()
cursor = conn.cursor()
try:
cursor.execute('''
UPDATE order_items SET
status = ?,
result_code = ?,
result_message = ?,
wholesaler_order_no = ?,
updated_at = CURRENT_TIMESTAMP
WHERE id = ?
''', (status, result_code, result_message, wholesaler_order_no, item_id))
# 주문 집계 업데이트
cursor.execute('SELECT order_id FROM order_items WHERE id = ?', (item_id,))
order_id = cursor.fetchone()['order_id']
cursor.execute('''
UPDATE orders SET
success_items = (SELECT COUNT(*) FROM order_items WHERE order_id = ? AND status = 'success'),
failed_items = (SELECT COUNT(*) FROM order_items WHERE order_id = ? AND status = 'failed'),
updated_at = CURRENT_TIMESTAMP
WHERE id = ?
''', (order_id, order_id, order_id))
conn.commit()
return True
except Exception as e:
conn.rollback()
return False
finally:
conn.close()
def get_order_history(wholesaler_id: str = None,
start_date: str = None,
end_date: str = None,
status: str = None,
limit: int = 50) -> List[Dict]:
"""주문 이력 조회"""
conn = get_connection()
cursor = conn.cursor()
query = 'SELECT * FROM orders WHERE 1=1'
params = []
if wholesaler_id:
query += ' AND wholesaler_id = ?'
params.append(wholesaler_id)
if start_date:
query += ' AND order_date >= ?'
params.append(start_date)
if end_date:
query += ' AND order_date <= ?'
params.append(end_date)
if status:
query += ' AND status = ?'
params.append(status)
query += ' ORDER BY created_at DESC LIMIT ?'
params.append(limit)
cursor.execute(query, params)
orders = [dict(row) for row in cursor.fetchall()]
conn.close()
return orders
# ─────────────────────────────────────────────
# AI 학습용 함수들
# ─────────────────────────────────────────────
def save_order_context(order_item_id: int, context: Dict) -> bool:
"""
주문 시점 컨텍스트 저장 (AI 학습용)
context: {
'drug_code': '670400830',
'product_name': '레바미피드정',
'stock_at_order': 50,
'usage_1d': 30,
'usage_7d': 180,
'usage_30d': 800,
'ordered_spec': '30T',
'ordered_qty': 10,
'available_specs': ['30T', '300T'],
'spec_stocks': {'30T': 50, '300T': 0},
'selection_reason': 'stock_available'
}
"""
conn = get_connection()
cursor = conn.cursor()
try:
# 일평균 사용량 계산
usage_30d = context.get('usage_30d', 0)
avg_daily = usage_30d / 30.0 if usage_30d else 0
# 재고 소진 예상일 계산
stock = context.get('stock_at_order', 0)
days_until_stockout = stock / avg_daily if avg_daily > 0 else None
# 주문 총 정제수
ordered_qty = context.get('ordered_qty', 0)
spec = context.get('ordered_spec', '')
unit_qty = int(''.join(filter(str.isdigit, spec))) if spec else 1
ordered_dose = ordered_qty * unit_qty
cursor.execute('''
INSERT INTO order_context (
order_item_id, drug_code, product_name,
stock_at_order, usage_1d, usage_7d, usage_30d, avg_daily_usage,
ordered_spec, ordered_qty, ordered_dose,
available_specs, spec_stocks, selection_reason,
days_until_stockout
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
''', (
order_item_id,
context.get('drug_code'),
context.get('product_name'),
context.get('stock_at_order'),
context.get('usage_1d'),
context.get('usage_7d'),
context.get('usage_30d'),
avg_daily,
context.get('ordered_spec'),
ordered_qty,
ordered_dose,
json.dumps(context.get('available_specs', []), ensure_ascii=False),
json.dumps(context.get('spec_stocks', {}), ensure_ascii=False),
context.get('selection_reason'),
days_until_stockout
))
conn.commit()
return True
except Exception as e:
conn.rollback()
return False
finally:
conn.close()
def update_daily_usage(drug_code: str, usage_date: str,
rx_count: int = 0, rx_qty: int = 0,
pos_count: int = 0, pos_qty: int = 0,
stock_end: int = None) -> bool:
"""일별 사용량 업데이트 (UPSERT)"""
conn = get_connection()
cursor = conn.cursor()
try:
total_qty = rx_qty + pos_qty
cursor.execute('''
INSERT INTO daily_usage (
drug_code, usage_date, rx_count, rx_qty, pos_count, pos_qty,
total_qty, stock_end
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(drug_code, usage_date) DO UPDATE SET
rx_count = rx_count + excluded.rx_count,
rx_qty = rx_qty + excluded.rx_qty,
pos_count = pos_count + excluded.pos_count,
pos_qty = pos_qty + excluded.pos_qty,
total_qty = total_qty + excluded.total_qty,
stock_end = COALESCE(excluded.stock_end, stock_end)
''', (drug_code, usage_date, rx_count, rx_qty, pos_count, pos_qty,
total_qty, stock_end))
conn.commit()
return True
except Exception as e:
conn.rollback()
return False
finally:
conn.close()
def get_usage_stats(drug_code: str, days: int = 30) -> Dict:
"""약품 사용량 통계 조회 (AI 분석용)"""
conn = get_connection()
cursor = conn.cursor()
from datetime import datetime, timedelta
end_date = datetime.now().strftime('%Y-%m-%d')
start_date = (datetime.now() - timedelta(days=days)).strftime('%Y-%m-%d')
cursor.execute('''
SELECT
COUNT(*) as days_with_data,
SUM(total_qty) as total_usage,
AVG(total_qty) as avg_daily,
MAX(total_qty) as max_daily,
MIN(total_qty) as min_daily
FROM daily_usage
WHERE drug_code = ? AND usage_date BETWEEN ? AND ?
''', (drug_code, start_date, end_date))
row = cursor.fetchone()
conn.close()
if row and row['total_usage']:
return {
'drug_code': drug_code,
'period_days': days,
'days_with_data': row['days_with_data'],
'total_usage': row['total_usage'],
'avg_daily': round(row['avg_daily'], 2) if row['avg_daily'] else 0,
'max_daily': row['max_daily'],
'min_daily': row['min_daily']
}
return {
'drug_code': drug_code,
'period_days': days,
'days_with_data': 0,
'total_usage': 0,
'avg_daily': 0,
'max_daily': 0,
'min_daily': 0
}
def get_order_pattern(drug_code: str) -> Optional[Dict]:
"""약품 주문 패턴 조회"""
conn = get_connection()
cursor = conn.cursor()
# 최근 주문 이력 분석
cursor.execute('''
SELECT
oi.specification,
oi.order_qty,
oi.total_dose,
o.order_date
FROM order_items oi
JOIN orders o ON oi.order_id = o.id
WHERE oi.drug_code = ? AND oi.status = 'success'
ORDER BY o.order_date DESC
LIMIT 10
''', (drug_code,))
orders = [dict(row) for row in cursor.fetchall()]
if not orders:
conn.close()
return None
# 가장 많이 사용된 규격
spec_counts = {}
for o in orders:
spec = o['specification']
spec_counts[spec] = spec_counts.get(spec, 0) + 1
typical_spec = max(spec_counts, key=spec_counts.get)
# 평균 주문 수량
typical_qty = sum(o['order_qty'] for o in orders) // len(orders)
# 주문 주기 계산
if len(orders) >= 2:
dates = [datetime.strptime(o['order_date'], '%Y-%m-%d') for o in orders]
intervals = [(dates[i] - dates[i+1]).days for i in range(len(dates)-1)]
avg_interval = sum(intervals) / len(intervals) if intervals else 0
else:
avg_interval = 0
conn.close()
return {
'drug_code': drug_code,
'order_count': len(orders),
'typical_spec': typical_spec,
'typical_qty': typical_qty,
'avg_order_interval_days': round(avg_interval, 1),
'recent_orders': orders[:5]
}
def get_ai_training_data(limit: int = 1000) -> List[Dict]:
"""AI 학습용 데이터 추출"""
conn = get_connection()
cursor = conn.cursor()
cursor.execute('''
SELECT
oc.*,
oi.status as order_status,
oi.result_code,
o.order_date,
o.wholesaler_id
FROM order_context oc
JOIN order_items oi ON oc.order_item_id = oi.id
JOIN orders o ON oi.order_id = o.id
ORDER BY oc.created_at DESC
LIMIT ?
''', (limit,))
data = []
for row in cursor.fetchall():
item = dict(row)
# JSON 필드 파싱
if item.get('available_specs'):
item['available_specs'] = json.loads(item['available_specs'])
if item.get('spec_stocks'):
item['spec_stocks'] = json.loads(item['spec_stocks'])
data.append(item)
conn.close()
return data
def save_ai_pattern(drug_code: str, pattern: Dict) -> bool:
"""AI 분석 결과 저장"""
conn = get_connection()
cursor = conn.cursor()
try:
today = datetime.now().strftime('%Y-%m-%d')
cursor.execute('''
INSERT INTO order_patterns (
drug_code, analysis_date, analysis_period_days,
avg_daily_usage, usage_stddev, peak_usage,
typical_order_spec, typical_order_qty, order_frequency_days,
recommended_spec, recommended_qty, recommended_reorder_point,
confidence_score, model_version
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(drug_code, analysis_date) DO UPDATE SET
avg_daily_usage = excluded.avg_daily_usage,
recommended_spec = excluded.recommended_spec,
recommended_qty = excluded.recommended_qty,
confidence_score = excluded.confidence_score
''', (
drug_code,
today,
pattern.get('period_days', 30),
pattern.get('avg_daily_usage'),
pattern.get('usage_stddev'),
pattern.get('peak_usage'),
pattern.get('typical_order_spec'),
pattern.get('typical_order_qty'),
pattern.get('order_frequency_days'),
pattern.get('recommended_spec'),
pattern.get('recommended_qty'),
pattern.get('recommended_reorder_point'),
pattern.get('confidence_score'),
pattern.get('model_version', 'v1')
))
conn.commit()
return True
except Exception as e:
conn.rollback()
return False
finally:
conn.close()
# 초기화 실행
init_db()

360
backend/sooin_api.py Normal file
View File

@ -0,0 +1,360 @@
# -*- coding: utf-8 -*-
"""
수인약품 도매상 API - Flask Blueprint
핵심 로직은 wholesale 패키지에서 가져옴
파일은 Flask API 연동만 담당
"""
import time
import logging
from flask import Blueprint, jsonify, request as flask_request
# wholesale 패키지 경로 설정
import wholesale_path
# wholesale 패키지에서 핵심 클래스 가져오기
from wholesale import SooinSession
logger = logging.getLogger(__name__)
# Blueprint 생성
sooin_bp = Blueprint('sooin', __name__, url_prefix='/api/sooin')
# ========== 세션 관리 ==========
_sooin_session = None
def get_sooin_session():
global _sooin_session
if _sooin_session is None:
_sooin_session = SooinSession()
return _sooin_session
def search_sooin_stock(keyword: str, search_type: str = 'kd_code'):
"""수인약품 재고 검색 (동기, 빠름)"""
try:
session = get_sooin_session()
result = session.search_products(keyword)
if result.get('success'):
return {
'success': True,
'keyword': keyword,
'search_type': search_type,
'count': result['total'],
'items': result['items']
}
else:
return result
except Exception as e:
logger.error(f"수인약품 검색 오류: {e}")
return {'success': False, 'error': 'SEARCH_ERROR', 'message': str(e)}
# ========== Flask API Routes ==========
@sooin_bp.route('/stock', methods=['GET'])
def api_sooin_stock():
"""
수인약품 재고 조회 API
GET /api/sooin/stock?kd_code=073100220
GET /api/sooin/stock?keyword=코자정&type=name
"""
kd_code = flask_request.args.get('kd_code', '').strip()
keyword = flask_request.args.get('keyword', '').strip()
search_type = flask_request.args.get('type', 'kd_code').strip()
search_term = kd_code or keyword
if kd_code:
search_type = 'kd_code'
if not search_term:
return jsonify({
'success': False,
'error': 'MISSING_PARAM',
'message': 'kd_code 또는 keyword 파라미터가 필요합니다'
}), 400
try:
result = search_sooin_stock(search_term, search_type)
return jsonify(result)
except Exception as e:
logger.error(f"수인약품 API 오류: {e}")
return jsonify({
'success': False,
'error': 'API_ERROR',
'message': str(e)
}), 500
@sooin_bp.route('/session-status', methods=['GET'])
def api_session_status():
"""세션 상태 확인"""
session = get_sooin_session()
return jsonify({
'logged_in': session._logged_in,
'last_login': session._last_login,
'session_age_sec': int(time.time() - session._last_login) if session._last_login else None
})
@sooin_bp.route('/cart', methods=['GET'])
def api_sooin_cart():
"""장바구니 조회 API"""
try:
session = get_sooin_session()
result = session.get_cart()
return jsonify(result)
except Exception as e:
return jsonify({'success': False, 'error': str(e), 'items': []}), 500
@sooin_bp.route('/cart/clear', methods=['POST'])
def api_sooin_cart_clear():
"""장바구니 비우기 API"""
try:
session = get_sooin_session()
result = session.clear_cart()
return jsonify(result)
except Exception as e:
return jsonify({'success': False, 'error': str(e)}), 500
@sooin_bp.route('/cart/cancel', methods=['POST'])
def api_sooin_cart_cancel():
"""
장바구니 항목 취소 API
POST /api/sooin/cart/cancel
{ "row_index": 0 }
또는
{ "internal_code": "32495" }
"""
data = flask_request.get_json() or {}
row_index = data.get('row_index')
internal_code = data.get('internal_code')
if row_index is None and not internal_code:
return jsonify({
'success': False,
'error': 'MISSING_PARAM',
'message': 'row_index 또는 internal_code가 필요합니다'
}), 400
try:
session = get_sooin_session()
result = session.cancel_item(row_index=row_index, product_code=internal_code)
return jsonify(result)
except Exception as e:
return jsonify({'success': False, 'error': str(e)}), 500
@sooin_bp.route('/cart/restore', methods=['POST'])
def api_sooin_cart_restore():
"""
취소된 항목 복원 API
POST /api/sooin/cart/restore
{ "row_index": 0 }
"""
data = flask_request.get_json() or {}
row_index = data.get('row_index')
internal_code = data.get('internal_code')
try:
session = get_sooin_session()
result = session.restore_item(row_index=row_index, product_code=internal_code)
return jsonify(result)
except Exception as e:
return jsonify({'success': False, 'error': str(e)}), 500
@sooin_bp.route('/order', methods=['POST'])
def api_sooin_order():
"""
수인약품 주문 API (장바구니 추가)
POST /api/sooin/order
{
"kd_code": "073100220",
"quantity": 1,
"specification": "30T",
"check_stock": true
}
"""
data = flask_request.get_json()
if not data:
return jsonify({'success': False, 'error': 'NO_DATA'}), 400
kd_code = data.get('kd_code', '').strip()
quantity = data.get('quantity', 1)
specification = data.get('specification')
check_stock = data.get('check_stock', True)
if not kd_code:
return jsonify({
'success': False,
'error': 'MISSING_PARAM',
'message': 'kd_code가 필요합니다'
}), 400
try:
session = get_sooin_session()
result = session.quick_order(
kd_code=kd_code,
quantity=quantity,
spec=specification,
check_stock=check_stock
)
return jsonify(result)
except Exception as e:
logger.error(f"수인약품 주문 오류: {e}")
return jsonify({
'success': False,
'error': 'ORDER_ERROR',
'message': str(e)
}), 500
@sooin_bp.route('/confirm', methods=['POST'])
def api_sooin_confirm():
"""주문 확정 API"""
data = flask_request.get_json() or {}
memo = data.get('memo', '')
try:
session = get_sooin_session()
result = session.submit_order(memo)
return jsonify(result)
except Exception as e:
return jsonify({'success': False, 'error': str(e)}), 500
@sooin_bp.route('/full-order', methods=['POST'])
def api_sooin_full_order():
"""
전체 주문 API (검색 장바구니 확정)
POST /api/sooin/full-order
{
"kd_code": "073100220",
"quantity": 1,
"specification": "30T",
"auto_confirm": true,
"memo": "자동주문"
}
"""
data = flask_request.get_json()
if not data or not data.get('kd_code'):
return jsonify({'success': False, 'error': 'kd_code required'}), 400
try:
session = get_sooin_session()
# 장바구니에 담기
cart_result = session.quick_order(
kd_code=data['kd_code'],
quantity=data.get('quantity', 1),
spec=data.get('specification'),
check_stock=data.get('check_stock', True)
)
if not cart_result.get('success'):
return jsonify(cart_result)
if not data.get('auto_confirm', True):
return jsonify(cart_result)
# 주문 확정
confirm_result = session.submit_order(data.get('memo', ''))
if confirm_result.get('success'):
return jsonify({
'success': True,
'message': f"{cart_result['product']['name']} {cart_result['quantity']}개 주문 완료",
'product': cart_result['product'],
'quantity': cart_result['quantity'],
'confirmed': True
})
else:
return jsonify({
'success': False,
'error': confirm_result.get('error', 'CONFIRM_FAILED'),
'message': f"장바구니 담기 성공, 주문 확정 실패",
'product': cart_result['product'],
'cart_added': True
})
except Exception as e:
return jsonify({'success': False, 'error': str(e)}), 500
@sooin_bp.route('/order-batch', methods=['POST'])
def api_sooin_order_batch():
"""수인약품 일괄 주문 API"""
data = flask_request.get_json()
if not data or not data.get('items'):
return jsonify({'success': False, 'error': 'NO_ITEMS'}), 400
items = data.get('items', [])
check_stock = data.get('check_stock', True)
session = get_sooin_session()
results = []
success_count = 0
failed_count = 0
for item in items:
kd_code = item.get('kd_code', '').strip()
quantity = item.get('quantity', 1)
specification = item.get('specification')
if not kd_code:
results.append({
'kd_code': kd_code,
'success': False,
'error': 'MISSING_KD_CODE'
})
failed_count += 1
continue
try:
result = session.quick_order(
kd_code=kd_code,
quantity=quantity,
spec=specification,
check_stock=check_stock
)
result['kd_code'] = kd_code
result['requested_qty'] = quantity
results.append(result)
if result.get('success'):
success_count += 1
else:
failed_count += 1
except Exception as e:
results.append({
'kd_code': kd_code,
'success': False,
'error': 'EXCEPTION',
'message': str(e)
})
failed_count += 1
return jsonify({
'success': True,
'total': len(items),
'success_count': success_count,
'failed_count': failed_count,
'results': results
})

File diff suppressed because it is too large Load Diff

View File

@ -1054,8 +1054,172 @@
// ──────────────── 주문 제출 ────────────────
function submitOrder() {
if (cart.length === 0) return;
// 지오영 품목만 필터
const geoItems = cart.filter(c => c.supplier === '지오영' || c.geoyoung_code);
if (geoItems.length === 0) {
// 지오영 품목 없으면 기존 방식 (클립보드)
submitOrderClipboard();
return;
}
// 지오영 주문 모달 열기
openOrderConfirmModal(geoItems);
}
function openOrderConfirmModal(items) {
const modal = document.getElementById('orderConfirmModal');
const tbody = document.getElementById('orderConfirmBody');
let html = '';
items.forEach((item, idx) => {
html += `
<tr>
<td>${escapeHtml(item.product_name)}</td>
<td class="mono">${item.specification || '-'}</td>
<td class="mono">${item.qty}</td>
</tr>`;
});
tbody.innerHTML = html;
document.getElementById('orderConfirmCount').textContent = items.length;
modal.classList.add('show');
}
function closeOrderConfirmModal() {
document.getElementById('orderConfirmModal').classList.remove('show');
}
async function executeOrder(dryRun = true) {
const geoItems = cart.filter(c => c.supplier === '지오영' || c.geoyoung_code);
if (geoItems.length === 0) {
showToast('지오영 품목이 없습니다', 'error');
return;
}
// 버튼 비활성화
const btnTest = document.getElementById('btnOrderTest');
const btnReal = document.getElementById('btnOrderReal');
btnTest.disabled = true;
btnReal.disabled = true;
btnTest.textContent = dryRun ? '처리 중...' : '🧪 테스트';
btnReal.textContent = !dryRun ? '처리 중...' : '🚀 실제 주문';
try {
const payload = {
wholesaler_id: 'geoyoung',
items: geoItems.map(item => ({
drug_code: item.drug_code,
kd_code: item.geoyoung_code || item.drug_code,
product_name: item.product_name,
manufacturer: item.supplier,
specification: item.specification || '',
order_qty: item.qty,
usage_qty: item.usage_qty || 0,
current_stock: item.current_stock || 0
})),
reference_period: `${document.getElementById('startDate').value}~${document.getElementById('endDate').value}`,
dry_run: dryRun
};
// 실제 주문은 시간이 오래 걸림 (Playwright 사용)
const timeoutMs = dryRun ? 60000 : 180000; // 테스트 1분, 실제 3분
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
const response = await fetch('/api/order/quick-submit', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(payload),
signal: controller.signal
});
clearTimeout(timeoutId);
const result = await response.json();
closeOrderConfirmModal();
if (result.success) {
showOrderResultModal(result);
} else {
showToast(`❌ 주문 실패: ${result.error}`, 'error');
}
} catch (err) {
showToast(`❌ 오류: ${err.message}`, 'error');
} finally {
btnTest.disabled = false;
btnReal.disabled = false;
btnTest.textContent = '🧪 테스트';
btnReal.textContent = '🚀 실제 주문';
}
}
function showOrderResultModal(result) {
const modal = document.getElementById('orderResultModal');
const content = document.getElementById('orderResultContent');
const isDryRun = result.dry_run;
const statusEmoji = result.failed_count === 0 ? '✅' : result.success_count === 0 ? '❌' : '⚠️';
let html = `
<div class="result-header ${result.failed_count === 0 ? 'success' : 'partial'}">
<span class="result-emoji">${statusEmoji}</span>
<span class="result-title">${isDryRun ? '[테스트]' : ''} 주문 ${result.failed_count === 0 ? '완료' : '처리됨'}</span>
</div>
<div class="result-summary">
<div class="result-stat">
<span class="stat-label">주문번호</span>
<span class="stat-value mono">${result.order_no}</span>
</div>
<div class="result-stat">
<span class="stat-label">성공</span>
<span class="stat-value success">${result.success_count}개</span>
</div>
<div class="result-stat">
<span class="stat-label">실패</span>
<span class="stat-value ${result.failed_count > 0 ? 'failed' : ''}">${result.failed_count}개</span>
</div>
</div>
<table class="result-table">
<thead><tr><th>품목</th><th>수량</th><th>결과</th></tr></thead>
<tbody>`;
(result.results || []).forEach(item => {
const isSuccess = item.status === 'success';
html += `
<tr class="${isSuccess ? '' : 'failed-row'}">
<td>${escapeHtml(item.product_name)}</td>
<td class="mono">${item.order_qty}</td>
<td class="${isSuccess ? 'result-ok' : 'result-fail'}">
${isSuccess ? '✓' : '✗'} ${item.result_code}
${item.result_message ? `<br><small>${escapeHtml(item.result_message)}</small>` : ''}
</td>
</tr>`;
});
html += '</tbody></table>';
if (isDryRun && result.success_count > 0) {
html += `<div class="result-note">💡 테스트 모드입니다. 실제 주문은 "실제 주문" 버튼을 누르세요.</div>`;
}
content.innerHTML = html;
modal.classList.add('show');
}
function closeOrderResultModal() {
document.getElementById('orderResultModal').classList.remove('show');
}
// 기존 클립보드 방식 (지오영 아닌 품목용)
function submitOrderClipboard() {
if (cart.length === 0) return;
// 제조사별 그룹화
const bySupplier = {};
cart.forEach(item => {
const sup = item.supplier || '미지정';
@ -1063,7 +1227,6 @@
bySupplier[sup].push(item);
});
// 주문서 텍스트 생성
let orderText = `💊 청춘약국 전문의약품 발주서\n`;
orderText += `━━━━━━━━━━━━━━━━━━━━━━━━\n`;
orderText += `📅 작성일: ${new Date().toLocaleDateString('ko-KR')}\n`;
@ -1081,7 +1244,6 @@
orderText += `\n━━━━━━━━━━━━━━━━━━━━━━━━\n`;
orderText += `총 ${cart.length}개 품목\n`;
// 클립보드 복사
navigator.clipboard.writeText(orderText).then(() => {
showToast('📋 주문서가 클립보드에 복사되었습니다!', 'success');
}).catch(() => {
@ -1116,6 +1278,573 @@
document.getElementById('searchInput').addEventListener('keypress', e => {
if (e.key === 'Enter') loadUsageData();
});
// ──────────────── 지오영 재고 조회 ────────────────
let currentGeoyoungItem = null;
function openGeoyoungModal(idx) {
const item = usageData[idx];
if (!item) return;
currentGeoyoungItem = item;
// 모달 열기
document.getElementById('geoModalProductName').textContent = item.product_name;
document.getElementById('geoModalDrugCode').textContent = item.drug_code;
document.getElementById('geoModalUsage').textContent = item.total_dose.toLocaleString() + '개';
document.getElementById('geoModalStock').textContent = item.current_stock.toLocaleString() + '개';
document.getElementById('geoyoungModal').classList.add('show');
// 로딩 표시
document.getElementById('geoResultBody').innerHTML = `
<div class="geo-loading">
<div class="loading-spinner"></div>
<div>지오영 재고 조회 중...</div>
</div>`;
// API 호출 (보험코드로 먼저 시도)
searchGeoyoung(item.drug_code, item.product_name);
}
function closeGeoyoungModal() {
document.getElementById('geoyoungModal').classList.remove('show');
currentGeoyoungItem = null;
}
async function searchGeoyoung(kdCode, productName) {
const resultBody = document.getElementById('geoResultBody');
try {
// 1차: 보험코드(KD코드)로 검색
let response = await fetch(`/api/geoyoung/stock?kd_code=${encodeURIComponent(kdCode)}`);
let data = await response.json();
// 결과 없으면 성분명으로 재검색
if (data.success && data.count === 0) {
document.getElementById('geoResultBody').innerHTML = `
<div class="geo-loading">
<div class="loading-spinner"></div>
<div>성분명으로 재검색 중...</div>
</div>`;
response = await fetch(`/api/geoyoung/stock-by-name?product_name=${encodeURIComponent(productName)}`);
data = await response.json();
}
if (!data.success) {
resultBody.innerHTML = `
<div class="geo-error">
<div>❌ ${data.message || '조회 실패'}</div>
</div>`;
return;
}
if (data.count === 0) {
resultBody.innerHTML = `
<div class="geo-empty">
<div>📭 지오영에 해당 제품이 없습니다</div>
</div>`;
return;
}
// 검색어 표시
if (data.extracted_ingredient) {
document.getElementById('geoSearchKeyword').textContent = `검색: "${data.extracted_ingredient}"`;
document.getElementById('geoSearchKeyword').style.display = 'block';
}
// 결과 렌더링
renderGeoyoungResults(data.items);
} catch (err) {
resultBody.innerHTML = `
<div class="geo-error">
<div>❌ 네트워크 오류: ${err.message}</div>
</div>`;
}
}
function renderGeoyoungResults(items) {
const resultBody = document.getElementById('geoResultBody');
// 재고 있는 것 먼저 정렬
items.sort((a, b) => (b.stock > 0 ? 1 : 0) - (a.stock > 0 ? 1 : 0) || b.stock - a.stock);
let html = `<table class="geo-table">
<thead>
<tr>
<th>제품명</th>
<th>규격</th>
<th>재고</th>
<th></th>
</tr>
</thead>
<tbody>`;
items.forEach((item, idx) => {
const hasStock = item.stock > 0;
html += `
<tr class="${hasStock ? '' : 'no-stock'}">
<td>
<div class="geo-product">
<span class="geo-name">${escapeHtml(item.product_name)}</span>
<span class="geo-code">${item.insurance_code}</span>
</div>
</td>
<td class="geo-spec">${item.specification}</td>
<td class="geo-stock ${hasStock ? 'in-stock' : 'out-stock'}">${item.stock}</td>
<td>
${hasStock ? `<button class="geo-add-btn" onclick="addGeoyoungToCart(${idx})">담기</button>` : ''}
</td>
</tr>`;
});
html += '</tbody></table>';
// 전역에 저장 (담기용)
window.geoyoungItems = items;
resultBody.innerHTML = html;
}
function addGeoyoungToCart(idx) {
const item = window.geoyoungItems[idx];
if (!item || !currentGeoyoungItem) return;
// 수량 계산 (규격에서 숫자 추출)
const specMatch = item.specification.match(/(\d+)/);
const specQty = specMatch ? parseInt(specMatch[1]) : 1;
// 필요 수량 계산
const needed = currentGeoyoungItem.total_dose;
const suggestedQty = Math.ceil(needed / specQty);
const qty = prompt(`주문 수량 (${item.specification} 기준)\n\n필요량: ${needed}개\n규격: ${specQty}개/단위\n추천: ${suggestedQty}단위 (${suggestedQty * specQty}개)`, suggestedQty);
if (!qty || isNaN(qty)) return;
// 장바구니에 추가 (지오영 정보 포함)
const cartItem = {
drug_code: currentGeoyoungItem.drug_code,
product_name: item.product_name,
supplier: '지오영',
qty: parseInt(qty),
specification: item.specification,
geoyoung_code: item.insurance_code
};
// 기존 항목 체크
const existing = cart.find(c => c.drug_code === currentGeoyoungItem.drug_code && c.specification === item.specification);
if (existing) {
existing.qty = parseInt(qty);
} else {
cart.push(cartItem);
}
updateCartUI();
closeGeoyoungModal();
showToast(`✅ ${item.product_name} (${item.specification}) ${qty}개 추가`, 'success');
}
// 테이블 행 더블클릭으로 지오영 모달 열기
document.addEventListener('dblclick', function(e) {
const row = e.target.closest('tr[data-idx]');
if (row) {
const idx = parseInt(row.dataset.idx);
openGeoyoungModal(idx);
}
});
</script>
<!-- 지오영 재고 조회 모달 -->
<div class="geo-modal" id="geoyoungModal">
<div class="geo-modal-content">
<div class="geo-modal-header">
<h3>🏭 지오영 재고 조회</h3>
<button class="geo-close" onclick="closeGeoyoungModal()"></button>
</div>
<div class="geo-modal-info">
<div class="geo-info-row">
<span class="geo-label">약품명</span>
<span class="geo-value" id="geoModalProductName">-</span>
</div>
<div class="geo-info-row">
<span class="geo-label">보험코드</span>
<span class="geo-value mono" id="geoModalDrugCode">-</span>
</div>
<div class="geo-info-row">
<span class="geo-label">사용량</span>
<span class="geo-value highlight" id="geoModalUsage">-</span>
</div>
<div class="geo-info-row">
<span class="geo-label">현재고</span>
<span class="geo-value" id="geoModalStock">-</span>
</div>
</div>
<div class="geo-search-info" id="geoSearchKeyword" style="display:none;"></div>
<div class="geo-result" id="geoResultBody">
<div class="geo-loading">
<div class="loading-spinner"></div>
<div>지오영 재고 조회 중...</div>
</div>
</div>
</div>
</div>
<style>
/* 지오영 모달 스타일 */
.geo-modal {
display: none;
position: fixed;
top: 0; left: 0; right: 0; bottom: 0;
background: rgba(0,0,0,0.7);
z-index: 500;
align-items: center;
justify-content: center;
backdrop-filter: blur(4px);
}
.geo-modal.show { display: flex; }
.geo-modal-content {
background: var(--bg-secondary);
border-radius: 16px;
width: 90%;
max-width: 700px;
max-height: 85vh;
overflow: hidden;
display: flex;
flex-direction: column;
border: 1px solid var(--accent-cyan);
box-shadow: 0 8px 32px rgba(6, 182, 212, 0.3);
}
.geo-modal-header {
padding: 16px 20px;
background: linear-gradient(135deg, #0891b2, var(--accent-cyan));
display: flex;
justify-content: space-between;
align-items: center;
}
.geo-modal-header h3 {
font-size: 16px;
font-weight: 700;
}
.geo-close {
background: rgba(255,255,255,0.2);
border: none;
width: 28px;
height: 28px;
border-radius: 6px;
color: #fff;
cursor: pointer;
}
.geo-modal-info {
padding: 16px 20px;
background: var(--bg-card);
border-bottom: 1px solid var(--border);
}
.geo-info-row {
display: flex;
justify-content: space-between;
padding: 6px 0;
}
.geo-label {
color: var(--text-muted);
font-size: 12px;
}
.geo-value {
font-weight: 600;
font-size: 13px;
}
.geo-value.mono {
font-family: 'JetBrains Mono', monospace;
}
.geo-value.highlight {
color: var(--accent-cyan);
}
.geo-search-info {
padding: 8px 20px;
background: rgba(6, 182, 212, 0.1);
font-size: 12px;
color: var(--accent-cyan);
}
.geo-result {
flex: 1;
overflow-y: auto;
padding: 16px;
}
.geo-loading, .geo-error, .geo-empty {
text-align: center;
padding: 40px 20px;
color: var(--text-muted);
}
.geo-error { color: var(--accent-rose); }
.geo-table {
width: 100%;
border-collapse: collapse;
}
.geo-table th {
padding: 10px 12px;
font-size: 11px;
font-weight: 600;
color: var(--text-muted);
text-align: left;
border-bottom: 1px solid var(--border);
}
.geo-table td {
padding: 12px;
font-size: 12px;
border-bottom: 1px solid rgba(255,255,255,0.05);
}
.geo-table tr:hover {
background: rgba(255,255,255,0.02);
}
.geo-table tr.no-stock {
opacity: 0.5;
}
.geo-product {
display: flex;
flex-direction: column;
gap: 2px;
}
.geo-name {
font-weight: 500;
}
.geo-code {
font-family: 'JetBrains Mono', monospace;
font-size: 10px;
color: var(--text-muted);
}
.geo-spec {
font-weight: 600;
color: var(--accent-amber);
}
.geo-stock {
font-family: 'JetBrains Mono', monospace;
font-weight: 600;
text-align: center;
}
.geo-stock.in-stock { color: var(--accent-emerald); }
.geo-stock.out-stock { color: var(--text-muted); }
.geo-add-btn {
padding: 6px 12px;
background: var(--accent-cyan);
border: none;
border-radius: 6px;
color: #fff;
font-size: 11px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
}
.geo-add-btn:hover {
background: #0891b2;
transform: scale(1.05);
}
/* 주문 확인 모달 */
.order-modal {
display: none;
position: fixed;
top: 0; left: 0; right: 0; bottom: 0;
background: rgba(0,0,0,0.8);
z-index: 600;
align-items: center;
justify-content: center;
backdrop-filter: blur(4px);
}
.order-modal.show { display: flex; }
.order-modal-content {
background: var(--bg-secondary);
border-radius: 16px;
width: 90%;
max-width: 500px;
max-height: 80vh;
overflow: hidden;
display: flex;
flex-direction: column;
border: 1px solid var(--accent-violet);
box-shadow: 0 8px 32px rgba(139, 92, 246, 0.3);
}
.order-modal-header {
padding: 16px 20px;
background: linear-gradient(135deg, #7c3aed, var(--accent-violet));
display: flex;
justify-content: space-between;
align-items: center;
}
.order-modal-header h3 { font-size: 16px; font-weight: 700; }
.order-close {
background: rgba(255,255,255,0.2);
border: none;
width: 28px; height: 28px;
border-radius: 6px;
color: #fff;
cursor: pointer;
}
.order-modal-body {
padding: 20px;
overflow-y: auto;
}
.order-confirm-table {
width: 100%;
border-collapse: collapse;
margin-bottom: 16px;
}
.order-confirm-table th {
padding: 8px;
font-size: 11px;
color: var(--text-muted);
text-align: left;
border-bottom: 1px solid var(--border);
}
.order-confirm-table td {
padding: 10px 8px;
font-size: 12px;
border-bottom: 1px solid rgba(255,255,255,0.05);
}
.order-confirm-table .mono {
font-family: 'JetBrains Mono', monospace;
}
.order-modal-footer {
padding: 16px 20px;
background: var(--bg-card);
display: flex;
gap: 12px;
justify-content: flex-end;
}
.btn-order-test {
padding: 10px 20px;
background: var(--accent-amber);
border: none;
border-radius: 8px;
color: #000;
font-weight: 600;
cursor: pointer;
}
.btn-order-real {
padding: 10px 20px;
background: var(--accent-emerald);
border: none;
border-radius: 8px;
color: #fff;
font-weight: 600;
cursor: pointer;
}
.btn-order-test:disabled, .btn-order-real:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* 주문 결과 모달 */
.result-header {
text-align: center;
padding: 20px;
border-radius: 12px;
margin-bottom: 16px;
}
.result-header.success { background: rgba(16, 185, 129, 0.1); }
.result-header.partial { background: rgba(245, 158, 11, 0.1); }
.result-emoji { font-size: 32px; display: block; margin-bottom: 8px; }
.result-title { font-size: 16px; font-weight: 600; }
.result-summary {
display: flex;
gap: 16px;
margin-bottom: 16px;
}
.result-stat {
flex: 1;
text-align: center;
padding: 12px;
background: var(--bg-card);
border-radius: 8px;
}
.stat-label { display: block; font-size: 11px; color: var(--text-muted); }
.stat-value { display: block; font-size: 16px; font-weight: 700; margin-top: 4px; }
.stat-value.success { color: var(--accent-emerald); }
.stat-value.failed { color: var(--accent-rose); }
.result-table {
width: 100%;
border-collapse: collapse;
}
.result-table th {
padding: 8px;
font-size: 11px;
color: var(--text-muted);
text-align: left;
border-bottom: 1px solid var(--border);
}
.result-table td {
padding: 10px 8px;
font-size: 12px;
border-bottom: 1px solid rgba(255,255,255,0.05);
}
.result-table .failed-row { background: rgba(244, 63, 94, 0.1); }
.result-ok { color: var(--accent-emerald); }
.result-fail { color: var(--accent-rose); }
.result-note {
margin-top: 16px;
padding: 12px;
background: rgba(6, 182, 212, 0.1);
border-radius: 8px;
font-size: 12px;
color: var(--accent-cyan);
}
</style>
<!-- 주문 확인 모달 -->
<div class="order-modal" id="orderConfirmModal">
<div class="order-modal-content">
<div class="order-modal-header">
<h3>🏭 지오영 주문 확인</h3>
<button class="order-close" onclick="closeOrderConfirmModal()"></button>
</div>
<div class="order-modal-body">
<p style="margin-bottom:12px;color:var(--text-secondary);">
<span id="orderConfirmCount">0</span>개 품목을 지오영에 주문합니다.
</p>
<table class="order-confirm-table">
<thead><tr><th>품목명</th><th>규격</th><th>수량</th></tr></thead>
<tbody id="orderConfirmBody"></tbody>
</table>
</div>
<div class="order-modal-footer">
<button class="btn-order-test" id="btnOrderTest" onclick="executeOrder(true)">🧪 테스트</button>
<button class="btn-order-real" id="btnOrderReal" onclick="executeOrder(false)">🚀 실제 주문</button>
</div>
</div>
</div>
<!-- 주문 결과 모달 -->
<div class="order-modal" id="orderResultModal">
<div class="order-modal-content" style="max-width:600px;">
<div class="order-modal-header" style="background:linear-gradient(135deg, #059669, var(--accent-emerald));">
<h3>📋 주문 결과</h3>
<button class="order-close" onclick="closeOrderResultModal()"></button>
</div>
<div class="order-modal-body" id="orderResultContent">
</div>
<div class="order-modal-footer">
<button class="btn-order-test" onclick="closeOrderResultModal()">닫기</button>
</div>
</div>
</div>
</body>
</html>

16
backend/test_bagjs.py Normal file
View File

@ -0,0 +1,16 @@
# -*- coding: utf-8 -*-
"""Bag.js 분석"""
from sooin_api import SooinSession
import re
session = SooinSession()
session.login()
resp = session.session.get('http://sooinpharm.co.kr/Common/Javascript/Bag.js?v=250228')
js = resp.text
# del 포함된 부분 찾기
lines = js.split('\n')
for i, line in enumerate(lines):
if 'del' in line.lower() and ('kind' in line.lower() or 'bagorder' in line.lower()):
print(f'{i}: {line.strip()[:100]}')

18
backend/test_bagjs2.py Normal file
View File

@ -0,0 +1,18 @@
# -*- coding: utf-8 -*-
"""Bag.js 전체에서 del 찾기"""
from sooin_api import SooinSession
import re
session = SooinSession()
session.login()
resp = session.session.get('http://sooinpharm.co.kr/Common/Javascript/Bag.js?v=250228')
js = resp.text
print(f'JS 길이: {len(js)}')
# del 포함된 줄 모두
for i, line in enumerate(js.split('\n')):
line = line.strip()
if 'del' in line.lower():
print(f'{line[:120]}')

16
backend/test_bagjs3.py Normal file
View File

@ -0,0 +1,16 @@
# -*- coding: utf-8 -*-
"""Bag.js 체크박스 관련 찾기"""
from sooin_api import SooinSession
import re
session = SooinSession()
session.login()
resp = session.session.get('http://sooinpharm.co.kr/Common/Javascript/Bag.js?v=250228')
js = resp.text
# chk, checkbox 관련 코드 찾기
lines = js.split('\n')
for i, line in enumerate(lines):
if 'chk' in line.lower() or 'check' in line.lower():
print(f'{i}: {line.strip()[:120]}')

19
backend/test_bagjs4.py Normal file
View File

@ -0,0 +1,19 @@
# -*- coding: utf-8 -*-
"""Bag.js AJAX URL 찾기"""
from sooin_api import SooinSession
import re
session = SooinSession()
session.login()
resp = session.session.get('http://sooinpharm.co.kr/Common/Javascript/Bag.js?v=250228')
js = resp.text
# AJAX 호출 찾기 ($.ajax, url:, type: 패턴)
ajax_blocks = re.findall(r'\$\.ajax\s*\(\s*\{[^}]{0,500}\}', js, re.DOTALL)
print(f'AJAX 호출 {len(ajax_blocks)}개 발견:\n')
for i, block in enumerate(ajax_blocks[:5]):
print(f'=== AJAX {i+1} ===')
print(block[:300])
print()

46
backend/test_cancel.py Normal file
View File

@ -0,0 +1,46 @@
# -*- coding: utf-8 -*-
"""항목 취소 테스트"""
from sooin_api import SooinSession
import json
session = SooinSession()
session.login()
print('=== 항목 취소 테스트 ===\n')
# 1. 장바구니 비우기
session.clear_cart()
print('1. 장바구니 비움')
# 2. 두 개 담기
session.order_product('073100220', 1, '30T') # 코자정
print('2. 코자정 담음')
session.order_product('652100640', 1) # 스틸녹스
print('3. 스틸녹스 담음')
# 3. 장바구니 확인
cart = session.get_cart()
print(f'\n현재 장바구니:')
print(f' 총 항목: {cart.get("all_items", 0)}')
print(f' 활성(주문포함): {cart.get("total_items", 0)}')
print(f' 취소됨: {cart.get("cancelled_items", 0)}')
for item in cart.get('items', []):
status = '❌ 취소' if item.get('checked') else '✅ 활성'
print(f' [{item.get("row_index")}] {item.get("product_name")} - {status}')
# 4. 첫 번째 항목 취소
print(f'\n4. 첫 번째 항목(idx=0) 취소 시도...')
result = session.cancel_item(row_index=0)
print(f' 결과: {result.get("success")} - {result.get("message", result.get("error", ""))}')
# 5. 다시 확인
cart = session.get_cart()
print(f'\n취소 후 장바구니:')
print(f' 활성: {cart.get("total_items", 0)}')
print(f' 취소됨: {cart.get("cancelled_items", 0)}')
for item in cart.get('items', []):
status = '❌ 취소' if item.get('checked') else '✅ 활성'
print(f' [{item.get("row_index")}] {item.get("product_name")} - {status}')
print('\n=== 완료 ===')

60
backend/test_cart.py Normal file
View File

@ -0,0 +1,60 @@
# -*- coding: utf-8 -*-
"""장바구니 추가 테스트 (실제 주문 X)"""
import json
import sys
sys.path.insert(0, r'c:\Users\청춘약국\source\pharmacy-pos-qr-system\backend')
from sooin_api import SooinSession
print("=" * 60)
print("수인약품 API 장바구니 테스트")
print("=" * 60)
session = SooinSession()
# 1. 로그인
print("\n1. 로그인...")
if not session.login():
print("❌ 로그인 실패")
sys.exit(1)
print("✅ 로그인 성공!")
# 2. 장바구니 비우기
print("\n2. 장바구니 비우기...")
result = session.clear_cart()
print(f" 결과: {'성공' if result['success'] else '실패'}")
# 3. 제품 검색
print("\n3. 제품 검색 (KD코드: 073100220 - 코자정)...")
products = session.search_products('073100220', 'kd_code')
print(f" 검색 결과: {len(products)}")
for p in products:
print(f" - {p['product_name']} ({p['specification']}) 재고: {p['stock']} 단가: {p['unit_price']:,}")
print(f" 내부코드: {p['internal_code']}")
# 4. 장바구니 추가
if products:
print("\n4. 장바구니 추가 (첫 번째 제품, 1개)...")
product = products[1] # 30T 선택
result = session.add_to_cart(
internal_code=product['internal_code'],
quantity=1,
stock=product['stock'],
price=product['unit_price']
)
print(f" 결과: {json.dumps(result, ensure_ascii=False, indent=2)}")
# 5. 장바구니 조회
print("\n5. 장바구니 조회...")
cart = session.get_cart()
print(f" 장바구니: {cart['total_items']}개 품목, {cart['total_amount']:,}")
for item in cart['items']:
print(f" - {item['product_name']}: {item['quantity']}개 ({item['amount']:,}원)")
# 6. 장바구니 비우기 (정리)
print("\n6. 장바구니 비우기 (정리)...")
result = session.clear_cart()
print(f" 결과: {'성공' if result['success'] else '실패'}")
print("\n" + "=" * 60)
print("테스트 완료! (실제 주문은 하지 않았습니다)")
print("=" * 60)

114
backend/test_cart_api.py Normal file
View File

@ -0,0 +1,114 @@
# -*- coding: utf-8 -*-
"""지오영 장바구니 API 직접 테스트 (requests)"""
import requests
from bs4 import BeautifulSoup
import asyncio
from playwright.async_api import async_playwright
async def get_cookies():
"""Playwright로 로그인 후 쿠키 획득"""
async with async_playwright() as p:
browser = await p.chromium.launch(headless=True)
page = await browser.new_page()
await page.goto('https://gwn.geoweb.kr/Member/Login')
await page.fill('input[type="text"]', '7390')
await page.fill('input[type="password"]', 'trajet6640')
await page.click('button, input[type="submit"]')
await page.wait_for_load_state('networkidle')
cookies = await page.context.cookies()
await browser.close()
return cookies
def test_cart_api():
# 1. 쿠키 획득
print("1. 로그인 중...")
cookies = asyncio.run(get_cookies())
# 2. requests 세션 설정
session = requests.Session()
for c in cookies:
session.cookies.set(c['name'], c['value'])
session.headers.update({
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8',
'X-Requested-With': 'XMLHttpRequest'
})
print(f" 쿠키: {[c['name'] for c in cookies]}")
# 3. 제품 검색
print("\n2. 제품 검색...")
search_resp = session.post('https://gwn.geoweb.kr/Home/PartialSearchProduct', data={
'srchText': '643104281',
'srchCate': '',
'prdtType': '',
'prdOrder': '',
'srchCompany': '',
'startdate': '',
'enddate': ''
})
print(f" 검색 응답: {search_resp.status_code}, 길이: {len(search_resp.text)}")
# 4. 장바구니 API 테스트 - 여러 엔드포인트 시도
print("\n3. 장바구니 API 테스트...")
endpoints = [
'/Home/PartialProductCart',
'/Home/AddCart',
'/Order/AddCart',
'/Home/AddToCart',
'/Order/AddToCart',
'/Home/InsertCart',
'/Order/InsertCart',
]
for endpoint in endpoints:
url = f'https://gwn.geoweb.kr{endpoint}'
# 다양한 파라미터 조합 시도
params_list = [
{'prdtCode': '643104281', 'qty': 1},
{'productCode': '643104281', 'quantity': 1},
{'code': '643104281', 'cnt': 1},
{'insCode': '643104281', 'orderQty': 1},
]
for params in params_list:
try:
resp = session.post(url, data=params, timeout=5)
if resp.status_code == 200:
text = resp.text[:200]
if 'error' not in text.lower() and '404' not in text:
print(f"{endpoint}")
print(f" Params: {params}")
print(f" Response: {text[:100]}...")
except Exception as e:
pass
# 5. 현재 장바구니 조회
print("\n4. 장바구니 조회...")
cart_resp = session.post('https://gwn.geoweb.kr/Home/PartialProductCart')
print(f" 응답: {cart_resp.status_code}")
soup = BeautifulSoup(cart_resp.text, 'html.parser')
# 장바구니 테이블에서 상품 찾기
rows = soup.find_all('tr')
print(f" 테이블 행: {len(rows)}")
# HTML에서 장바구니 추가 폼 찾기
forms = soup.find_all('form')
for form in forms:
action = form.get('action', '')
if 'cart' in action.lower() or 'order' in action.lower():
print(f" 폼 발견: {action}")
inputs = form.find_all('input')
for inp in inputs:
print(f" - {inp.get('name')}: {inp.get('value', '')}")
if __name__ == "__main__":
test_cart_api()

View File

@ -0,0 +1,44 @@
# -*- coding: utf-8 -*-
"""장바구니 디버깅"""
import sys
sys.path.insert(0, r'c:\Users\청춘약국\source\pharmacy-pos-qr-system\backend')
from sooin_api import SooinSession
session = SooinSession()
if not session.login():
print("로그인 실패")
sys.exit(1)
print("로그인 성공!")
# 1. 장바구니 추가 요청의 실제 응답 확인
print("\n=== 장바구니 추가 요청 ===")
data = {
'qty_0': '1',
'pc_0': '32495',
'stock_0': '238',
'saleqty_0': '0',
'price_0': '14220',
'soldout_0': 'N',
'ordunitqty_0': '1',
'bidqty_0': '0',
'outqty_0': '0',
'overqty_0': '0',
'manage_0': 'N',
'prodno_0': '',
'termdt_0': ''
}
resp = session.session.post(session.BAG_URL, data=data, timeout=15)
print(f"Status: {resp.status_code}")
print(f"URL: {resp.url}")
print(f"\n응답 (처음 2000자):\n{resp.text[:2000]}")
# 2. 장바구니 조회 응답 확인
print("\n\n=== 장바구니 조회 요청 ===")
params = {'currVenCd': session.VENDOR_CODE}
resp2 = session.session.get(session.BAG_URL, params=params, timeout=15)
print(f"Status: {resp2.status_code}")
print(f"URL: {resp2.url}")
print(f"\n응답 (처음 3000자):\n{resp2.text[:3000]}")

127
backend/test_cart_list.py Normal file
View File

@ -0,0 +1,127 @@
# -*- coding: utf-8 -*-
"""장바구니 조회 API 테스트"""
import requests
from bs4 import BeautifulSoup
import asyncio
from playwright.async_api import async_playwright
import json
async def get_cookies():
async with async_playwright() as p:
browser = await p.chromium.launch(headless=True)
page = await browser.new_page()
await page.goto('https://gwn.geoweb.kr/Member/Login')
await page.fill('input[type="text"]', '7390')
await page.fill('input[type="password"]', 'trajet6640')
await page.click('button, input[type="submit"]')
await page.wait_for_load_state('networkidle')
cookies = await page.context.cookies()
await browser.close()
return cookies
def test():
print("="*60)
print("장바구니 조회 API 테스트")
print("="*60)
cookies = asyncio.run(get_cookies())
session = requests.Session()
for c in cookies:
session.cookies.set(c['name'], c['value'])
session.headers.update({
'User-Agent': 'Mozilla/5.0',
'X-Requested-With': 'XMLHttpRequest'
})
# 1. 먼저 제품 하나 담기
print("\n1. 테스트용 제품 담기...")
search_resp = session.post('https://gwn.geoweb.kr/Home/PartialSearchProduct',
data={'srchText': '661700390'})
soup = BeautifulSoup(search_resp.text, 'html.parser')
product_div = soup.find('div', class_='div-product-detail')
if product_div:
lis = product_div.find_all('li')
internal_code = lis[0].get_text(strip=True)
cart_resp = session.post('https://gwn.geoweb.kr/Home/DataCart/add', data={
'productCode': internal_code,
'moveCode': '',
'orderQty': 3
})
print(f" 담기 결과: {cart_resp.json()}")
# 2. 장바구니 조회
print("\n2. 장바구니 조회 (PartialProductCart)...")
cart_resp = session.post('https://gwn.geoweb.kr/Home/PartialProductCart')
print(f" 상태: {cart_resp.status_code}")
print(f" 길이: {len(cart_resp.text)}")
# HTML 파싱
soup = BeautifulSoup(cart_resp.text, 'html.parser')
# 테이블 찾기
tables = soup.find_all('table')
print(f" 테이블 수: {len(tables)}")
# 장바구니 항목 파싱
cart_items = []
# div_cart_detail 클래스 찾기
cart_divs = soup.find_all('div', class_='div_cart_detail')
print(f" cart_detail divs: {len(cart_divs)}")
for div in cart_divs:
lis = div.find_all('li')
if len(lis) >= 5:
item = {
'product_code': lis[0].get_text(strip=True) if len(lis) > 0 else '',
'move_code': lis[1].get_text(strip=True) if len(lis) > 1 else '',
'quantity': lis[2].get_text(strip=True) if len(lis) > 2 else '',
'price': lis[3].get_text(strip=True) if len(lis) > 3 else '',
'total': lis[4].get_text(strip=True) if len(lis) > 4 else '',
}
cart_items.append(item)
print(f" - {item}")
# 테이블 행 분석
print("\n 테이블 행 분석:")
for table in tables:
rows = table.find_all('tr')
for row in rows[:5]:
cells = row.find_all(['td', 'th'])
if cells:
texts = [c.get_text(strip=True)[:20] for c in cells[:6]]
print(f" {texts}")
# 3. 다른 API 시도
print("\n3. 다른 장바구니 API 시도...")
endpoints = [
'/Home/GetCartList',
'/Home/CartList',
'/Order/GetCart',
'/Order/CartList',
'/Home/DataCart/list',
]
for ep in endpoints:
try:
resp = session.post(f'https://gwn.geoweb.kr{ep}', timeout=5)
if resp.status_code == 200 and len(resp.text) > 100:
print(f"{ep}: {len(resp.text)} bytes")
print(f" {resp.text[:100]}...")
except:
pass
# 4. 장바구니 비우기
print("\n4. 장바구니 비우기...")
session.post('https://gwn.geoweb.kr/Home/DataCart/delAll')
print(" 완료")
if __name__ == "__main__":
test()

74
backend/test_datacart.py Normal file
View File

@ -0,0 +1,74 @@
# -*- coding: utf-8 -*-
"""지오영 DataCart API 테스트"""
import requests
import asyncio
from playwright.async_api import async_playwright
import time
async def get_cookies():
async with async_playwright() as p:
browser = await p.chromium.launch(headless=True)
page = await browser.new_page()
await page.goto('https://gwn.geoweb.kr/Member/Login')
await page.fill('input[type="text"]', '7390')
await page.fill('input[type="password"]', 'trajet6640')
await page.click('button, input[type="submit"]')
await page.wait_for_load_state('networkidle')
cookies = await page.context.cookies()
await browser.close()
return cookies
def test_datacart():
print("1. 로그인 중...")
start = time.time()
cookies = asyncio.run(get_cookies())
print(f" 로그인 완료: {time.time()-start:.1f}")
session = requests.Session()
for c in cookies:
session.cookies.set(c['name'], c['value'])
session.headers.update({
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
'X-Requested-With': 'XMLHttpRequest',
'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8'
})
# 2. 장바구니 추가 테스트
print("\n2. 장바구니 추가 테스트...")
start = time.time()
resp = session.post('https://gwn.geoweb.kr/Home/DataCart/add', data={
'productCode': '643104281', # 하일렌플러스
'moveCode': '',
'orderQty': 1
})
print(f" 소요시간: {time.time()-start:.1f}")
print(f" 상태코드: {resp.status_code}")
print(f" 응답: {resp.text[:500]}")
# JSON 파싱
try:
result = resp.json()
print(f" result: {result.get('result')}")
print(f" msg: {result.get('msg')}")
except:
pass
# 3. 장바구니 조회
print("\n3. 장바구니 조회...")
cart_resp = session.post('https://gwn.geoweb.kr/Home/PartialProductCart')
print(f" 응답 길이: {len(cart_resp.text)}")
# 장바구니에 상품 있는지 확인
if '643104281' in cart_resp.text or '하일렌' in cart_resp.text:
print(" ✓ 장바구니에 상품 추가됨!")
else:
print(" ? 장바구니 확인 필요")
if __name__ == "__main__":
test_datacart()

83
backend/test_datacart2.py Normal file
View File

@ -0,0 +1,83 @@
# -*- coding: utf-8 -*-
"""지오영 검색 → 장바구니 추가 테스트"""
import requests
from bs4 import BeautifulSoup
import asyncio
from playwright.async_api import async_playwright
import time
import re
async def get_cookies():
async with async_playwright() as p:
browser = await p.chromium.launch(headless=True)
page = await browser.new_page()
await page.goto('https://gwn.geoweb.kr/Member/Login')
await page.fill('input[type="text"]', '7390')
await page.fill('input[type="password"]', 'trajet6640')
await page.click('button, input[type="submit"]')
await page.wait_for_load_state('networkidle')
cookies = await page.context.cookies()
await browser.close()
return cookies
def test():
print("1. 로그인...")
cookies = asyncio.run(get_cookies())
session = requests.Session()
for c in cookies:
session.cookies.set(c['name'], c['value'])
session.headers.update({
'User-Agent': 'Mozilla/5.0',
'X-Requested-With': 'XMLHttpRequest'
})
# 2. 검색
print("\n2. 제품 검색 (661700390 - 콩코르정)...")
search_resp = session.post('https://gwn.geoweb.kr/Home/PartialSearchProduct', data={
'srchText': '661700390'
})
soup = BeautifulSoup(search_resp.text, 'html.parser')
# 제품 코드 찾기 - data 속성이나 hidden input에서
rows = soup.find_all('tr')
print(f" 테이블 행: {len(rows)}")
# HTML 구조 분석
for row in rows[:2]:
tds = row.find_all('td')
if tds:
print(f" TD 개수: {len(tds)}")
for i, td in enumerate(tds[:8]):
text = td.get_text(strip=True)[:30]
onclick = td.get('onclick', '')[:50]
data_attrs = {k:v for k,v in td.attrs.items() if k.startswith('data')}
print(f" [{i}] {text} | onclick={onclick} | data={data_attrs}")
# onclick에서 제품 코드 추출
onclick_pattern = re.findall(r"onclick=['\"]([^'\"]+)['\"]", search_resp.text)
for oc in onclick_pattern[:3]:
print(f" onclick: {oc[:100]}")
# SelectProduct 함수 호출에서 인덱스 확인
select_pattern = re.findall(r'SelectProduct\s*\(\s*(\d+)', search_resp.text)
print(f" SelectProduct 인덱스: {select_pattern[:3]}")
# div-product-detail에서 제품 코드 찾기
product_divs = soup.find_all('div', class_='div-product-detail')
print(f" product-detail divs: {len(product_divs)}")
for div in product_divs[:2]:
lis = div.find_all('li')
if lis:
print(f" li 개수: {len(lis)}")
for i, li in enumerate(lis[:5]):
print(f" [{i}] {li.get_text(strip=True)[:50]}")
if __name__ == "__main__":
test()

105
backend/test_datacart3.py Normal file
View File

@ -0,0 +1,105 @@
# -*- coding: utf-8 -*-
"""지오영 장바구니 추가 - 정확한 productCode로 테스트"""
import requests
from bs4 import BeautifulSoup
import asyncio
from playwright.async_api import async_playwright
import time
async def get_cookies():
async with async_playwright() as p:
browser = await p.chromium.launch(headless=True)
page = await browser.new_page()
await page.goto('https://gwn.geoweb.kr/Member/Login')
await page.fill('input[type="text"]', '7390')
await page.fill('input[type="password"]', 'trajet6640')
await page.click('button, input[type="submit"]')
await page.wait_for_load_state('networkidle')
cookies = await page.context.cookies()
await browser.close()
return cookies
def test():
print("="*60)
print("지오영 API 직접 호출 테스트")
print("="*60)
# 1. 로그인
print("\n1. 로그인...")
start = time.time()
cookies = asyncio.run(get_cookies())
print(f" 완료: {time.time()-start:.1f}")
session = requests.Session()
for c in cookies:
session.cookies.set(c['name'], c['value'])
session.headers.update({
'User-Agent': 'Mozilla/5.0',
'X-Requested-With': 'XMLHttpRequest'
})
# 2. 검색해서 productCode 획득
print("\n2. 제품 검색...")
start = time.time()
search_resp = session.post('https://gwn.geoweb.kr/Home/PartialSearchProduct', data={
'srchText': '661700390' # 콩코르정
})
print(f" 완료: {time.time()-start:.1f}")
soup = BeautifulSoup(search_resp.text, 'html.parser')
product_div = soup.find('div', class_='div-product-detail')
if product_div:
lis = product_div.find_all('li')
product_code = lis[0].get_text(strip=True) if lis else None
print(f" productCode: {product_code}")
else:
print(" 제품 없음!")
return
# 3. 장바구니 추가
print("\n3. 장바구니 추가...")
start = time.time()
cart_resp = session.post('https://gwn.geoweb.kr/Home/DataCart/add', data={
'productCode': product_code,
'moveCode': '',
'orderQty': 2
})
print(f" 완료: {time.time()-start:.1f}")
print(f" 상태: {cart_resp.status_code}")
try:
result = cart_resp.json()
print(f" result: {result.get('result')}")
print(f" msg: {result.get('msg', 'OK')}")
if result.get('result') == 1:
print("\n ✅ 장바구니 추가 성공!")
else:
print(f"\n ❌ 실패: {result.get('msg')}")
except:
print(f" 응답: {cart_resp.text[:200]}")
# 4. 장바구니 확인
print("\n4. 장바구니 확인...")
cart_check = session.post('https://gwn.geoweb.kr/Home/PartialProductCart')
if '콩코르' in cart_check.text or product_code in cart_check.text:
print(" ✅ 장바구니에 상품 있음!")
else:
print(" 확인 필요")
# 전체 시간
print("\n" + "="*60)
print("총 API 호출 시간: 검색 + 장바구니 추가 = ~3초")
print("(Playwright 30초+ 대비 10배 이상 빠름!)")
print("="*60)
if __name__ == "__main__":
test()

101
backend/test_dataorder.py Normal file
View File

@ -0,0 +1,101 @@
# -*- coding: utf-8 -*-
"""지오영 주문 확정 API 테스트"""
import requests
from bs4 import BeautifulSoup
import asyncio
from playwright.async_api import async_playwright
import time
async def get_cookies():
async with async_playwright() as p:
browser = await p.chromium.launch(headless=True)
page = await browser.new_page()
await page.goto('https://gwn.geoweb.kr/Member/Login')
await page.fill('input[type="text"]', '7390')
await page.fill('input[type="password"]', 'trajet6640')
await page.click('button, input[type="submit"]')
await page.wait_for_load_state('networkidle')
cookies = await page.context.cookies()
await browser.close()
return cookies
def test():
print("="*60)
print("지오영 전체 주문 플로우 테스트")
print("="*60)
# 1. 로그인
print("\n1. 로그인...")
start = time.time()
cookies = asyncio.run(get_cookies())
print(f" 완료: {time.time()-start:.1f}")
session = requests.Session()
for c in cookies:
session.cookies.set(c['name'], c['value'])
session.headers.update({
'User-Agent': 'Mozilla/5.0',
'X-Requested-With': 'XMLHttpRequest'
})
# 2. 검색 → productCode 획득
print("\n2. 제품 검색...")
start = time.time()
search_resp = session.post('https://gwn.geoweb.kr/Home/PartialSearchProduct', data={
'srchText': '661700390'
})
soup = BeautifulSoup(search_resp.text, 'html.parser')
product_div = soup.find('div', class_='div-product-detail')
lis = product_div.find_all('li') if product_div else []
product_code = lis[0].get_text(strip=True) if lis else None
print(f" productCode: {product_code}")
print(f" 완료: {time.time()-start:.1f}")
# 3. 장바구니 추가
print("\n3. 장바구니 추가...")
start = time.time()
cart_resp = session.post('https://gwn.geoweb.kr/Home/DataCart/add', data={
'productCode': product_code,
'moveCode': '',
'orderQty': 1
})
result = cart_resp.json()
print(f" result: {result.get('result')}")
print(f" 완료: {time.time()-start:.1f}")
if result.get('result') != 1:
print(f" ❌ 장바구니 추가 실패: {result.get('msg')}")
return
# 4. 주문 확정 (실제 주문!) - 테스트이므로 실행 안함
print("\n4. 주문 확정 API 테스트...")
print(" ⚠️ 실제 주문이 들어가므로 테스트 중지!")
print(" API: POST /Home/DataOrder")
print(" params: { p_desc: '메모' }")
# 실제 주문 코드 (주석 처리)
# order_resp = session.post('https://gwn.geoweb.kr/Home/DataOrder', data={
# 'p_desc': '테스트 주문'
# })
# print(f" 응답: {order_resp.text[:200]}")
# 5. 장바구니 비우기 (테스트용)
print("\n5. 장바구니 비우기...")
# 장바구니에서 삭제
clear_resp = session.post('https://gwn.geoweb.kr/Home/DataCart/delAll')
print(f" 상태: {clear_resp.status_code}")
print("\n" + "="*60)
print("✅ 전체 API 플로우 확인 완료!")
print("")
print("1. 검색: POST /Home/PartialSearchProduct")
print("2. 장바구니: POST /Home/DataCart/add")
print("3. 주문확정: POST /Home/DataOrder")
print("="*60)
if __name__ == "__main__":
test()

32
backend/test_del.py Normal file
View File

@ -0,0 +1,32 @@
# -*- coding: utf-8 -*-
from sooin_api import SooinSession
import re
session = SooinSession()
session.login()
resp = session.session.get('http://sooinpharm.co.kr/Service/Order/Bag.asp?currVenCd=50911')
# 개별 삭제 관련 찾기
html = resp.text
# kind 파라미터 종류
kinds = re.findall(r'kind=(\w+)', html)
print('kind 파라미터들:', list(set(kinds)))
# 체크박스 관련 함수
if 'chk_' in html:
print('\n체크박스 있음 (chk_0, chk_1 등)')
# delOne 같은 개별 삭제
if 'delOne' in html or 'deleteOne' in html:
print('개별 삭제 함수 있음')
# 선택삭제 버튼
if '선택삭제' in html or '선택 삭제' in html:
print('선택삭제 버튼 있음')
# 전체 삭제 URL
del_url = re.search(r'BagOrder\.asp\?kind=del[^"\'>\s]*', html)
if del_url:
print(f'\n전체 삭제 URL: {del_url.group()}')

26
backend/test_del2.py Normal file
View File

@ -0,0 +1,26 @@
# -*- coding: utf-8 -*-
from sooin_api import SooinSession
import re
session = SooinSession()
session.login()
resp = session.session.get('http://sooinpharm.co.kr/Service/Order/Bag.asp?currVenCd=50911')
html = resp.text
# 모든 script 내용 출력
scripts = re.findall(r'<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]}')

25
backend/test_del3.py Normal file
View File

@ -0,0 +1,25 @@
# -*- coding: utf-8 -*-
from sooin_api import SooinSession
import re
session = SooinSession()
session.login()
resp = session.session.get('http://sooinpharm.co.kr/Service/Order/Bag.asp?currVenCd=50911')
html = resp.text
# 모든 <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}')

29
backend/test_del_chk.py Normal file
View File

@ -0,0 +1,29 @@
# -*- coding: utf-8 -*-
"""체크박스로 삭제 테스트"""
from sooin_api import SooinSession
import re
session = SooinSession()
session.login()
# Bag.asp의 JavaScript 전체 확인
resp = session.session.get('http://sooinpharm.co.kr/Service/Order/Bag.asp?currVenCd=50911')
# onclick 이벤트들 찾기
onclicks = re.findall(r'onclick="([^"]*)"', resp.text)
print('onclick handlers:')
for oc in onclicks[:10]:
if len(oc) < 200:
print(f' {oc}')
# form의 name과 action
forms = re.findall(r'<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]}...')

21
backend/test_del_html.py Normal file
View File

@ -0,0 +1,21 @@
# -*- coding: utf-8 -*-
"""HTML 전체 분석"""
from sooin_api import SooinSession
session = SooinSession()
session.login()
resp = session.session.get('http://sooinpharm.co.kr/Service/Order/Bag.asp?currVenCd=50911')
# 전체 저장해서 분석
with open('bag_page.html', 'w', encoding='utf-8') as f:
f.write(resp.text)
print('bag_page.html 저장됨')
print(f'길이: {len(resp.text)}')
# 현재 장바구니 상태
cart = session.get_cart()
print(f'장바구니: {cart.get("total_items", 0)}')
for item in cart.get('items', []):
print(f' - {item.get("product_name")}')

38
backend/test_del_one.py Normal file
View File

@ -0,0 +1,38 @@
# -*- coding: utf-8 -*-
"""개별 삭제 테스트"""
from sooin_api import SooinSession
session = SooinSession()
session.login()
# 1. 장바구니 비우기
session.clear_cart()
print('1. 장바구니 비움')
# 2. 두 개 담기
session.order_product('073100220', 1, '30T') # 코자정
print('2. 코자정 담음')
session.order_product('652100640', 1) # 스틸녹스
print('3. 스틸녹스 담음')
# 장바구니 확인
cart = session.get_cart()
count = cart.get('total_items', 0)
print(f' 현재 장바구니: {count}')
for item in cart.get('items', []):
print(f' - {item.get("product_name", "")}')
# 3. 첫 번째 항목만 삭제 (idx=0)
print('\n4. idx=0 (첫 번째) 삭제...')
resp = session.session.get(
'http://sooinpharm.co.kr/Service/Order/BagOrder.asp',
params={'kind': 'delOne', 'idx': '0', 'currVenCd': '50911'}
)
# 장바구니 다시 확인
cart = session.get_cart()
count = cart.get('total_items', 0)
print(f' 삭제 후: {count}')
for item in cart.get('items', []):
print(f' - {item.get("product_name", "")}')

33
backend/test_del_pc.py Normal file
View File

@ -0,0 +1,33 @@
# -*- coding: utf-8 -*-
"""pc 파라미터로 삭제 테스트"""
from sooin_api import SooinSession
session = SooinSession()
session.login()
# 장바구니 확인
resp = session.session.get('http://sooinpharm.co.kr/Service/Order/Bag.asp?currVenCd=50911')
# hidden input들 확인
import re
hiddens = re.findall(r'name="(pc_\d+|idx_\d+|bagIdx_\d+)"[^>]*value="([^"]*)"', resp.text)
print('Hidden fields:')
for name, val in hiddens[:10]:
print(f' {name}: {val}')
# 장바구니 iframe의 실제 삭제 로직 찾기
# del + pc 조합 시도
print('\ndel with pc 시도...')
resp = session.session.get(
'http://sooinpharm.co.kr/Service/Order/BagOrder.asp',
params={
'kind': 'delOne',
'idx': '0',
'pc': '31840', # 스틸녹스 코드
'currVenCd': '50911'
}
)
# 결과
cart = session.get_cart()
print(f'삭제 후: {cart.get("total_items", 0)}')

26
backend/test_del_post.py Normal file
View File

@ -0,0 +1,26 @@
# -*- coding: utf-8 -*-
"""개별 삭제 POST 테스트"""
from sooin_api import SooinSession
session = SooinSession()
session.login()
# 장바구니 확인
cart = session.get_cart()
print(f'현재: {cart.get("total_items", 0)}')
# POST로 삭제 시도
print('\nPOST로 delOne 시도...')
resp = session.session.post(
'http://sooinpharm.co.kr/Service/Order/BagOrder.asp',
data={
'kind': 'delOne',
'idx': '0',
'currVenCd': '50911'
}
)
print(f'응답: {resp.text[:300]}')
# 다시 확인
cart = session.get_cart()
print(f'\n삭제 후: {cart.get("total_items", 0)}')

39
backend/test_encoding.py Normal file
View File

@ -0,0 +1,39 @@
# -*- coding: utf-8 -*-
import sys
import re
sys.path.insert(0, r'c:\Users\청춘약국\source\pharmacy-pos-qr-system\backend')
from sooin_api import SooinSession
session = SooinSession()
if session.login():
# 직접 요청해서 인코딩 확인
params = {
'so': '0', 'so2': '0', 'so3': '2',
'tx_physic': '073100220',
'tx_ven': '50911',
'currVenNm': '청춘약국'
}
resp = session.session.get(session.ORDER_URL, params=params, timeout=15)
print('Content-Type:', resp.headers.get('Content-Type'))
print('Encoding:', resp.encoding)
print('Apparent Encoding:', resp.apparent_encoding)
# charset 확인
charset_match = re.search(r'charset=([^\s;"]+)', resp.text[:1000])
print('HTML charset:', charset_match.group(1) if charset_match else 'Not found')
# 직접 디코딩 테스트
print('\n--- 디코딩 테스트 ---')
test_encodings = ['euc-kr', 'cp949', 'utf-8', 'iso-8859-1']
for enc in test_encodings:
try:
decoded = resp.content.decode(enc, errors='replace')
# 코자정이 포함되어 있는지 확인
if '코자정' in decoded:
print(f'{enc}: 성공! (코자정 발견)')
elif '' in decoded or '' in decoded:
print(f'{enc}: 부분 실패 (깨진 문자 발견)')
else:
print(f'{enc}: 확인 불가')
except Exception as e:
print(f'{enc}: 오류 - {e}')

25
backend/test_flask_api.py Normal file
View File

@ -0,0 +1,25 @@
# -*- coding: utf-8 -*-
"""Flask Blueprint 테스트"""
import wholesale_path
from geoyoung_api import geoyoung_bp, get_geo_session
from sooin_api import sooin_bp, get_sooin_session
print('=== Flask Blueprint 테스트 ===\n')
# Blueprint 확인
print(f'지오영 Blueprint: {geoyoung_bp.name} ({geoyoung_bp.url_prefix})')
print(f'수인약품 Blueprint: {sooin_bp.name} ({sooin_bp.url_prefix})')
# 세션 함수 확인
geo_session = get_geo_session()
sooin_session = get_sooin_session()
print(f'\n지오영 세션: {geo_session}')
print(f'수인약품 세션: {sooin_session}')
# 라우트 확인
print('\n지오영 라우트:')
for rule in geoyoung_bp.deferred_functions:
print(f' - {rule}')
print('\n✅ Blueprint 로드 성공!')

View File

@ -0,0 +1,112 @@
# -*- coding: utf-8 -*-
"""지오영 API 직접 테스트"""
import asyncio
from playwright.async_api import async_playwright
import json
async def capture_cart_api():
async with async_playwright() as p:
browser = await p.chromium.launch(headless=True)
page = await browser.new_page()
# 요청/응답 캡처
cart_requests = []
async def handle_request(request):
if 'Cart' in request.url or 'Order' in request.url or 'Add' in request.url:
cart_requests.append({
'url': request.url,
'method': request.method,
'headers': dict(request.headers),
'data': request.post_data
})
page.on('request', handle_request)
# 로그인
await page.goto('https://gwn.geoweb.kr/Member/Login')
await page.fill('input[type="text"]', '7390')
await page.fill('input[type="password"]', 'trajet6640')
await page.click('button, input[type="submit"]')
await page.wait_for_load_state('networkidle')
print("로그인 완료")
# 쿠키 저장
cookies = await page.context.cookies()
print(f"쿠키: {[c['name'] for c in cookies]}")
# 검색 페이지
await page.goto('https://gwn.geoweb.kr/Home/Index')
await page.wait_for_timeout(2000)
# 검색 (AJAX)
await page.evaluate('''
$.ajax({
url: "/Home/PartialSearchProduct",
type: "POST",
data: {srchText: "643104281"},
success: function(data) {
console.log("검색 결과:", data.substring(0, 500));
}
});
''')
await page.wait_for_timeout(2000)
# 장바구니 추가 시도 (JavaScript로)
result = await page.evaluate('''
async function testCart() {
// 장바구니 추가 함수 찾기
if (typeof AddCart !== 'undefined') {
return "AddCart 함수 존재";
}
if (typeof fnAddCart !== 'undefined') {
return "fnAddCart 함수 존재";
}
// 전역 함수 목록
var funcs = [];
for (var key in window) {
if (typeof window[key] === 'function' &&
(key.toLowerCase().includes('cart') ||
key.toLowerCase().includes('order') ||
key.toLowerCase().includes('add'))) {
funcs.push(key);
}
}
return "발견된 함수: " + funcs.join(", ");
}
return testCart();
''')
print(f"JavaScript 분석: {result}")
# 페이지 소스에서 장바구니 관련 스크립트 찾기
scripts = await page.evaluate('''
var scripts = document.querySelectorAll('script');
var result = [];
scripts.forEach(function(s) {
var text = s.textContent || s.innerText || '';
if (text.includes('Cart') || text.includes('AddProduct')) {
result.push(text.substring(0, 1000));
}
});
return result;
''')
await browser.close()
print("\n" + "="*60)
print("캡처된 Cart/Order 요청:")
print("="*60)
for r in cart_requests:
print(json.dumps(r, indent=2, ensure_ascii=False))
print("\n" + "="*60)
print("장바구니 관련 스크립트:")
print("="*60)
for i, s in enumerate(scripts[:3]):
print(f"\n--- Script {i+1} ---")
print(s[:800])
if __name__ == "__main__":
asyncio.run(capture_cart_api())

49
backend/test_sooin.py Normal file
View File

@ -0,0 +1,49 @@
# -*- coding: utf-8 -*-
"""수인약품 API 테스트"""
import time
import sys
# 현재 디렉토리 추가
sys.path.insert(0, '.')
from sooin_api import SooinSession
print('수인약품 API 테스트')
print('='*50)
session = SooinSession()
# 1. 로그인 테스트
start = time.time()
print('1. 로그인 중...')
if session.login():
print(f' ✅ 로그인 성공! ({time.time()-start:.1f}초)')
else:
print(' ❌ 로그인 실패')
sys.exit(1)
# 2. 검색 테스트 (KD코드: 코자정)
start = time.time()
print('\n2. 검색 테스트 (KD코드: 073100220 - 코자정)...')
products = session.search_products('073100220', 'kd_code')
elapsed = time.time() - start
print(f' 검색 완료: {len(products)}개 ({elapsed:.2f}초)')
for p in products[:3]:
name = p.get('product_name', '')
spec = p.get('specification', '')
stock = p.get('stock', 0)
price = p.get('unit_price', 0)
code = p.get('internal_code', '')
print(f' - {name} ({spec})')
print(f' 재고: {stock}, 단가: {price:,}원, 내부코드: {code}')
# 3. 장바구니 조회
start = time.time()
print('\n3. 장바구니 조회...')
cart = session.get_cart()
elapsed = time.time() - start
print(f' 장바구니: {cart.get("total_items", 0)}개 품목 ({elapsed:.2f}초)')
print('\n' + '='*50)
print('✅ 테스트 완료!')

View File

@ -0,0 +1,40 @@
# -*- coding: utf-8 -*-
"""수인약품 API 전체 플로우 테스트"""
import time
from sooin_api import SooinSession
session = SooinSession()
print('=== 수인약품 API 전체 테스트 ===')
print()
# 로그인
start = time.time()
session.login()
print(f'1. 로그인: {time.time()-start:.1f}')
# 장바구니 비우기
start = time.time()
session.clear_cart()
print(f'2. 장바구니 비우기: {time.time()-start:.2f}')
# 검색 + 장바구니 추가
start = time.time()
result = session.order_product('073100220', 2, '30T')
elapsed = time.time() - start
success = result.get('success', False)
msg = result.get('message', '')
print(f'3. 검색+장바구니: {elapsed:.2f}')
print(f' 결과: {success} - {msg}')
# 장바구니 조회
start = time.time()
cart = session.get_cart()
elapsed = time.time() - start
items = cart.get('total_items', 0)
amount = cart.get('total_amount', 0)
print(f'4. 장바구니 조회: {elapsed:.2f}')
print(f' 품목: {items}개, 금액: {amount:,}')
print()
print('=== 완료! ===')

View File

@ -0,0 +1,32 @@
# -*- coding: utf-8 -*-
"""wholesale 통합 테스트"""
import wholesale_path
from wholesale import SooinSession, GeoYoungSession
print('=== 도매상 API 통합 테스트 ===\n')
# 수인약품 테스트
print('1. 수인약품 테스트')
sooin = SooinSession()
if sooin.login():
print(' ✅ 로그인 성공')
result = sooin.search_products('073100220')
print(f' ✅ 검색: {result["total"]}개 결과')
cart = sooin.get_cart()
print(f' ✅ 장바구니: {cart["total_items"]}')
else:
print(' ❌ 로그인 실패')
# 지오영 테스트
print('\n2. 지오영 테스트')
geo = GeoYoungSession()
if geo.login():
print(' ✅ 로그인 성공')
result = geo.search_products('레바미피드')
print(f' ✅ 검색: {result["total"]}개 결과')
cart = geo.get_cart()
print(f' ✅ 장바구니: {cart["total_items"]}')
else:
print(' ❌ 로그인 실패')
print('\n=== 테스트 완료 ===')

13
backend/wholesale_path.py Normal file
View File

@ -0,0 +1,13 @@
# -*- coding: utf-8 -*-
"""wholesale 패키지 경로 설정"""
import sys
import os
# wholesale 패키지 경로 추가
WHOLESALE_PATH = r"c:\Users\청춘약국\source\pharmacy-wholesale-api"
if WHOLESALE_PATH not in sys.path:
sys.path.insert(0, WHOLESALE_PATH)
# dotenv 로드
from dotenv import load_dotenv
load_dotenv(os.path.join(WHOLESALE_PATH, '.env'))

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,875 @@
# AI ERP 자동 주문 시스템 기획서
> 버전: 1.0
> 작성일: 2026-03-06
> 목표: 약국 재고 관리 및 주문을 AI가 학습하여 완전 자동화
---
## 📋 Executive Summary
### 비전
**"약사님이 주문에 신경 쓰지 않아도 되는 약국"**
AI가 사용량, 재고, 도매상 상황, 과거 주문 패턴을 학습하여:
- 언제 주문할지
- 어느 도매상에 주문할지
- 어떤 규격으로 주문할지
- 얼마나 주문할지
모든 것을 자동으로 결정하고 실행합니다.
### 핵심 가치
| AS-IS | TO-BE |
|-------|-------|
| 매일 재고 확인 | AI가 자동 모니터링 |
| 수동으로 도매상 선택 | AI가 최적 도매상 선택 |
| 경험에 의존한 주문량 | 데이터 기반 최적 주문량 |
| 주문 누락/지연 발생 | 선제적 자동 주문 |
---
## 🎯 시스템 목표
### 1차 목표 (자동화)
- [ ] 재고 부족 품목 자동 감지
- [ ] 도매상 자동 선택 및 주문
- [ ] 주문 결과 자동 피드백
### 2차 목표 (최적화)
- [ ] 비용 최소화 (가격, 배송비)
- [ ] 재고 최적화 (과잉/부족 방지)
- [ ] 주문 타이밍 최적화
### 3차 목표 (예측)
- [ ] 수요 예측 (계절, 요일, 이벤트)
- [ ] 공급 리스크 예측 (품절, 단종)
- [ ] 가격 변동 예측
---
## 🧠 AI 학습 요소
### 1. 주문 패턴 학습
#### 1.1 규격 선택 패턴 (Spec Selection)
```
학습 데이터:
- 약품별 과거 주문 규격 (30T, 100T, 300T, 500T)
- 각 규격 선택 시점의 재고/사용량
- 선택 결과 (남은 재고, 다음 주문까지 기간)
학습 목표:
- 사용량 대비 최적 규격 예측
- 낭비 최소화 (유통기한 고려)
- 단가 최적화 (대용량 할인 vs 소량 회전)
```
**예시 시나리오:**
| 사용량/월 | 학습된 최적 규격 | 이유 |
|-----------|-----------------|------|
| 50개 | 30T x 2 | 소량, 빠른 회전 |
| 200개 | 100T x 2 | 중간, 적정 재고 |
| 800개 | 300T x 3 | 대량, 단가 절감 |
#### 1.2 재고 전략 학습 (Inventory Strategy)
```
학습 데이터:
- 주문 시점의 재고 수준
- 재고 소진까지 남은 일수
- 주문 후 입고까지 리드타임
- 품절 발생 이력
학습 목표:
- 약사님의 재고 선호도 파악
- 타이트형: 최소 재고 유지 (현금 흐름 중시)
- 여유형: 안전 재고 확보 (품절 방지 중시)
```
**재고 전략 프로파일:**
```python
class InventoryStrategy:
TIGHT = {
'safety_days': 2, # 안전 재고 2일치
'reorder_point': 0.8, # 80% 소진 시 주문
'order_coverage': 7 # 7일치 주문
}
MODERATE = {
'safety_days': 5,
'reorder_point': 0.6,
'order_coverage': 14
}
CONSERVATIVE = {
'safety_days': 10,
'reorder_point': 0.5,
'order_coverage': 30
}
```
#### 1.3 주문량 전략 학습 (Order Quantity)
```
학습 데이터:
- 사용량 (일별, 주별, 월별)
- 주문량
- 주문 후 소진까지 기간
- 사용량 변동성 (표준편차)
학습 패턴:
1. 정확 매칭형: 사용량 = 주문량
2. 안전 마진형: 사용량 + α
3. 라운드업형: 규격 단위로 올림
4. 할인 최적형: MOQ(최소주문량) 충족
```
#### 1.4 도매상 선택 학습 (Wholesaler Selection)
```
학습 데이터:
- 도매상별 주문 빈도
- 도매상별 가격
- 도매상별 재고 상황
- 도매상별 배송 속도
- 분할 주문 패턴
학습 목표:
- 기본 도매상 선호도
- 상황별 대체 도매상
- 분할 주문 조건
```
**도매상 선택 로직:**
```python
def select_wholesaler(product, quantity, urgency):
"""
AI가 학습한 도매상 선택 로직
고려 요소:
1. 재고 (있는 곳 우선)
2. 가격 (저렴한 곳)
3. 선호도 (과거 패턴)
4. 긴급도 (배송 속도)
"""
candidates = []
for ws in wholesalers:
score = 0
# 재고 체크
if ws.has_stock(product, quantity):
score += 100
# 가격 (낮을수록 높은 점수)
score += (1 - ws.price_ratio) * 50
# 학습된 선호도
score += ai_model.preference_score(ws, product) * 30
# 긴급도 반영
if urgency == 'high':
score += ws.delivery_speed * 20
candidates.append((ws, score))
return max(candidates, key=lambda x: x[1])
```
---
## 📊 데이터 모델
### 주문 컨텍스트 (AI 학습용)
```sql
CREATE TABLE order_context (
id INTEGER PRIMARY KEY,
order_item_id INTEGER,
-- 약품 정보
drug_code TEXT,
product_name TEXT,
-- 주문 시점 상황
stock_at_order INTEGER, -- 주문 시점 재고
usage_1d INTEGER, -- 최근 1일 사용량
usage_7d INTEGER, -- 최근 7일 사용량
usage_30d INTEGER, -- 최근 30일 사용량
avg_daily_usage REAL, -- 일평균 사용량
usage_stddev REAL, -- 사용량 변동성
-- 주문 결정
ordered_spec TEXT, -- 선택한 규격 (30T, 300T)
ordered_qty INTEGER, -- 주문 수량
ordered_dose INTEGER, -- 총 정제수
wholesaler_id TEXT, -- 선택한 도매상
-- 선택지 정보
available_specs JSON, -- 가능했던 규격들
available_wholesalers JSON, -- 가능했던 도매상들
spec_stocks JSON, -- 규격별 재고
wholesaler_prices JSON, -- 도매상별 가격
-- 선택 이유 (AI 분석용)
selection_reason TEXT, -- 'price', 'stock', 'preference', 'urgency'
-- 예측 vs 실제
predicted_days_coverage REAL, -- 예상 커버 일수
actual_days_to_reorder INT, -- 실제 재주문까지 일수
-- 결과 평가
was_optimal BOOLEAN, -- 최적 선택이었나
waste_amount INTEGER, -- 낭비량 (폐기, 유통기한)
stockout_occurred BOOLEAN, -- 품절 발생했나
created_at TIMESTAMP
);
```
### 사용량 시계열
```sql
CREATE TABLE daily_usage (
id INTEGER PRIMARY KEY,
drug_code TEXT,
usage_date DATE,
-- 출처별 사용량
rx_qty INTEGER, -- 처방전 사용량
pos_qty INTEGER, -- POS 판매량
return_qty INTEGER, -- 반품량
-- 집계
net_usage INTEGER, -- 순 사용량
-- 재고 스냅샷
stock_start INTEGER,
stock_end INTEGER,
-- 특이사항
is_holiday BOOLEAN,
is_event BOOLEAN, -- 프로모션 등
weather TEXT, -- 날씨 (선택)
UNIQUE(drug_code, usage_date)
);
```
### AI 분석 결과
```sql
CREATE TABLE ai_recommendations (
id INTEGER PRIMARY KEY,
drug_code TEXT,
analysis_date DATE,
-- 현재 상황
current_stock INTEGER,
avg_daily_usage REAL,
days_of_stock REAL,
-- AI 추천
should_order BOOLEAN,
recommended_qty INTEGER,
recommended_spec TEXT,
recommended_wholesaler TEXT,
urgency_level TEXT, -- 'low', 'medium', 'high', 'critical'
-- 추천 근거
reasoning JSON,
confidence_score REAL,
-- 실행 상태
auto_executed BOOLEAN,
executed_at TIMESTAMP,
execution_result TEXT,
created_at TIMESTAMP
);
```
---
## 🔄 시스템 아키텍처
### 전체 흐름
```
┌─────────────────────────────────────────────────────────────────┐
│ AI ERP 자동 주문 시스템 │
└─────────────────────────────────────────────────────────────────┘
┌───────────────────────┼───────────────────────┐
▼ ▼ ▼
┌───────────────┐ ┌───────────────┐ ┌───────────────┐
│ 데이터 수집 │ │ AI 분석 │ │ 자동 실행 │
│ │ │ │ │ │
│ • POS 판매 │─────▶│ • 사용량 예측 │─────▶│ • 도매상 API │
│ • 처방전 조제 │ │ • 재고 분석 │ │ • 주문 실행 │
│ • 현재 재고 │ │ • 주문 추천 │ │ • 결과 피드백 │
│ • 도매상 재고 │ │ • 패턴 학습 │ │ │
└───────────────┘ └───────────────┘ └───────────────┘
│ │ │
└───────────────────────┼───────────────────────┘
┌───────────────────┐
│ 학습 루프 │
│ │
│ 주문 결과 평가 │
│ → 모델 업데이트 │
│ → 전략 조정 │
└───────────────────┘
```
### 컴포넌트 상세
```
┌──────────────────────────────────────────────────────────────────┐
│ 데이터 레이어 │
├──────────────────────────────────────────────────────────────────┤
│ │
│ ┌────────────┐ ┌────────────┐ ┌────────────┐ ┌────────────┐ │
│ │ PIT3000 │ │ SQLite │ │ 지오영 │ │ 수인 │ │
│ │ (MSSQL) │ │ Orders DB │ │ API │ │ API │ │
│ └─────┬──────┘ └─────┬──────┘ └─────┬──────┘ └─────┬──────┘ │
│ │ │ │ │ │
│ └───────────────┴───────────────┴───────────────┘ │
│ │ │
└────────────────────────────────┼─────────────────────────────────┘
┌──────────────────────────────────────────────────────────────────┐
│ 서비스 레이어 │
├──────────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ │
│ │ InventorySync │ │ UsageAnalyzer │ │ OrderExecutor │ │
│ │ │ │ │ │ │ │
│ │ • 재고 동기화 │ │ • 사용량 집계 │ │ • 주문 실행 │ │
│ │ • 실시간 추적 │ │ • 트렌드 분석 │ │ • 결과 처리 │ │
│ └─────────────────┘ └─────────────────┘ └─────────────────┘ │
│ │
│ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ │
│ │ AIPredictor │ │ AIOptimizer │ │ AILearner │ │
│ │ │ │ │ │ │ │
│ │ • 수요 예측 │ │ • 규격 최적화 │ │ • 패턴 학습 │ │
│ │ • 재고 예측 │ │ • 도매상 선택 │ │ • 모델 업데이트 │ │
│ └─────────────────┘ └─────────────────┘ └─────────────────┘ │
│ │
└──────────────────────────────────────────────────────────────────┘
┌──────────────────────────────────────────────────────────────────┐
│ 인터페이스 레이어 │
├──────────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ │
│ │ 웹 대시보드 │ │ 알림 시스템 │ │ 관리자 앱 │ │
│ │ │ │ │ │ │ │
│ │ • 재고 현황 │ │ • 주문 알림 │ │ • 수동 개입 │ │
│ │ • 주문 이력 │ │ • 이상 감지 │ │ • 설정 조정 │ │
│ │ • AI 추천 │ │ • 승인 요청 │ │ │ │
│ └─────────────────┘ └─────────────────┘ └─────────────────┘ │
│ │
└──────────────────────────────────────────────────────────────────┘
```
---
## 🤖 AI 모델 설계
### 1. 수요 예측 모델
```python
class DemandPredictor:
"""
약품별 일간 수요 예측
입력:
- 과거 30일 사용량
- 요일 (월~일)
- 계절/월
- 특수일 (공휴일, 이벤트)
출력:
- 향후 7일 예측 사용량
- 예측 신뢰구간
"""
def predict(self, drug_code: str, days: int = 7) -> dict:
features = self._extract_features(drug_code)
prediction = {
'daily_forecast': [], # 일별 예측
'total_forecast': 0, # 총 예측량
'confidence': 0.0, # 신뢰도
'lower_bound': 0, # 하한
'upper_bound': 0 # 상한
}
return prediction
```
### 2. 재고 최적화 모델
```python
class InventoryOptimizer:
"""
최적 재고 수준 및 재주문점 계산
입력:
- 예측 수요
- 리드타임 (주문~입고)
- 서비스 수준 (품절 허용률)
- 재고 유지 비용
출력:
- 재주문점 (Reorder Point)
- 안전 재고 (Safety Stock)
- 최적 주문량 (EOQ)
"""
def calculate_reorder_point(self, drug_code: str) -> dict:
demand = self.demand_predictor.predict(drug_code)
lead_time = self._get_lead_time(drug_code)
# 재주문점 = 리드타임 수요 + 안전재고
lead_time_demand = demand['daily_avg'] * lead_time
safety_stock = self._calculate_safety_stock(drug_code)
return {
'reorder_point': lead_time_demand + safety_stock,
'safety_stock': safety_stock,
'lead_time_days': lead_time
}
```
### 3. 규격 선택 모델
```python
class SpecSelector:
"""
최적 규격 선택
고려 요소:
- 예상 사용량
- 규격별 단가
- 유통기한
- 과거 선택 패턴
"""
def select_spec(self, drug_code: str, needed_qty: int,
available_specs: list) -> dict:
candidates = []
for spec in available_specs:
spec_qty = self._parse_spec_qty(spec) # "300T" → 300
# 필요 단위 수 계산
units_needed = math.ceil(needed_qty / spec_qty)
total_qty = units_needed * spec_qty
waste = total_qty - needed_qty
# 비용 계산
unit_price = self._get_unit_price(drug_code, spec)
total_cost = units_needed * unit_price
cost_per_dose = total_cost / total_qty
# 학습된 선호도
preference = self.ai_model.spec_preference(drug_code, spec)
# 점수 계산
score = self._calculate_score(
waste_ratio=waste / total_qty,
cost_efficiency=1 / cost_per_dose,
preference=preference
)
candidates.append({
'spec': spec,
'units': units_needed,
'total_qty': total_qty,
'waste': waste,
'cost': total_cost,
'score': score
})
return max(candidates, key=lambda x: x['score'])
```
### 4. 도매상 선택 모델
```python
class WholesalerSelector:
"""
최적 도매상 선택 (다중 도매상 지원)
고려 요소:
- 재고 유무
- 가격
- 배송 속도
- 과거 선호도
- 최소 주문 금액
"""
def select_wholesaler(self, drug_code: str, spec: str,
quantity: int, urgency: str) -> dict:
wholesalers = ['geoyoung', 'sooin', 'baekje']
candidates = []
for ws in wholesalers:
# 재고 확인
stock = self._check_stock(ws, drug_code, spec)
if stock < quantity:
continue
# 가격 조회
price = self._get_price(ws, drug_code, spec)
# 배송 속도
delivery_hours = self._get_delivery_time(ws)
# AI 학습 선호도
preference = self.ai_model.wholesaler_preference(
drug_code, ws
)
# 종합 점수
score = self._calculate_score(
has_stock=True,
price=price,
delivery=delivery_hours,
preference=preference,
urgency=urgency
)
candidates.append({
'wholesaler': ws,
'stock': stock,
'price': price,
'delivery_hours': delivery_hours,
'score': score
})
if not candidates:
return self._handle_no_stock(drug_code, spec, quantity)
return max(candidates, key=lambda x: x['score'])
def _handle_no_stock(self, drug_code, spec, quantity):
"""재고 없을 때: 분할 주문 또는 대체품"""
# 1. 다른 규격으로 분할
# 2. 다중 도매상 분할
# 3. 대체 약품 추천
pass
```
### 5. 주문 결정 엔진
```python
class OrderDecisionEngine:
"""
종합 주문 결정
매일 실행:
1. 모든 약품 재고 스캔
2. 재주문점 도달 품목 식별
3. 각 품목별 최적 주문 계획 수립
4. 자동 실행 또는 승인 요청
"""
def daily_analysis(self) -> list:
recommendations = []
for drug in self._get_all_drugs():
current_stock = self._get_stock(drug.code)
reorder_point = self.inventory_optimizer.calculate_reorder_point(drug.code)
if current_stock <= reorder_point['reorder_point']:
# 주문 필요
order_plan = self._create_order_plan(drug)
recommendations.append(order_plan)
return recommendations
def _create_order_plan(self, drug) -> dict:
# 1. 필요 수량 계산
needed_qty = self._calculate_needed_qty(drug)
# 2. 최적 규격 선택
spec = self.spec_selector.select_spec(
drug.code, needed_qty, drug.available_specs
)
# 3. 최적 도매상 선택
wholesaler = self.wholesaler_selector.select_wholesaler(
drug.code, spec['spec'], spec['units'],
urgency=self._determine_urgency(drug)
)
return {
'drug_code': drug.code,
'drug_name': drug.name,
'current_stock': self._get_stock(drug.code),
'needed_qty': needed_qty,
'recommended_spec': spec['spec'],
'recommended_units': spec['units'],
'recommended_wholesaler': wholesaler['wholesaler'],
'estimated_cost': wholesaler['price'] * spec['units'],
'urgency': self._determine_urgency(drug),
'confidence': self._calculate_confidence(),
'auto_execute': self._should_auto_execute(drug)
}
```
---
## 📈 학습 파이프라인
### 피드백 루프
```
주문 실행 → 결과 기록 → 평가 → 학습 → 모델 업데이트
│ │
└────────────────────────────────────────┘
```
### 평가 지표
```python
class OrderEvaluator:
"""주문 결과 평가"""
def evaluate(self, order_id: int) -> dict:
order = self._get_order(order_id)
# 1. 재고 효율성
days_covered = self._calculate_days_covered(order)
expected_days = order.expected_coverage
coverage_accuracy = days_covered / expected_days
# 2. 비용 효율성
actual_cost_per_dose = order.total_cost / order.total_dose
market_avg_cost = self._get_market_avg_cost(order.drug_code)
cost_efficiency = market_avg_cost / actual_cost_per_dose
# 3. 낭비율
waste = self._calculate_waste(order)
waste_ratio = waste / order.total_dose
# 4. 품절 발생 여부
stockout = self._check_stockout_before_next_order(order)
return {
'coverage_accuracy': coverage_accuracy,
'cost_efficiency': cost_efficiency,
'waste_ratio': waste_ratio,
'stockout_occurred': stockout,
'overall_score': self._calculate_overall_score(...)
}
```
### 모델 업데이트
```python
class AILearner:
"""주문 결과로부터 학습"""
def learn_from_order(self, order_id: int):
evaluation = self.evaluator.evaluate(order_id)
context = self._get_order_context(order_id)
# 1. 규격 선택 학습
self.spec_model.update(
drug_code=context.drug_code,
chosen_spec=context.ordered_spec,
was_optimal=evaluation['waste_ratio'] < 0.1
)
# 2. 재고 전략 학습
self.inventory_model.update(
drug_code=context.drug_code,
reorder_point=context.stock_at_order,
was_optimal=not evaluation['stockout_occurred']
)
# 3. 도매상 선호도 학습
self.wholesaler_model.update(
drug_code=context.drug_code,
chosen_wholesaler=context.wholesaler_id,
satisfaction=evaluation['cost_efficiency']
)
```
---
## ⚙️ 자동화 레벨
### Level 0: 수동
- AI 추천만 제공
- 모든 주문은 수동 실행
### Level 1: 반자동
- AI가 주문 계획 생성
- 약사님 승인 후 자동 실행
- 알림: 승인 요청
### Level 2: 조건부 자동
- 신뢰도 높은 주문은 자동 실행
- 신뢰도 낮은 주문만 승인 요청
- 조건 예시:
- 자주 주문하는 품목
- 금액 임계값 이하
- 긴급하지 않은 주문
### Level 3: 완전 자동
- 모든 주문 자동 실행
- 이상 상황만 알림
- 약사님은 대시보드로 모니터링
```python
class AutomationLevel:
def should_auto_execute(self, order_plan: dict) -> bool:
level = self.settings.automation_level
if level == 0:
return False
if level == 1:
return False # 항상 승인 필요
if level == 2:
# 조건부 자동
conditions = [
order_plan['confidence'] > 0.9,
order_plan['estimated_cost'] < 100000,
order_plan['drug_code'] in self.trusted_drugs,
order_plan['urgency'] != 'critical'
]
return all(conditions)
if level == 3:
# 완전 자동 (이상 상황만 제외)
return not self._is_anomaly(order_plan)
```
---
## 🔔 알림 시스템
### 알림 유형
| 유형 | 조건 | 채널 |
|------|------|------|
| 승인 요청 | Level 1-2에서 자동 실행 안 되는 주문 | 카톡, 앱 푸시 |
| 주문 완료 | 자동 주문 실행됨 | 앱 푸시 |
| 재고 경고 | 안전 재고 이하 | 카톡 |
| 품절 긴급 | 재고 0, 당일 필요 | 전화, 카톡 |
| 이상 감지 | 비정상 사용량, 가격 급등 | 앱 푸시 |
| 일간 리포트 | 매일 오전 | 이메일 |
### 알림 메시지 예시
```
📦 주문 승인 요청
약품: 콩코르정 2.5mg
현재고: 45개 (3일치)
추천 주문: 300T x 2박스
도매상: 지오영
예상 금액: 72,000원
[승인] [수정] [거절]
```
---
## 📅 개발 로드맵
### Phase 1: 기반 구축 (1-2주)
- [x] 지오영 API 연동
- [x] 주문 DB 스키마 설계
- [x] 주문 컨텍스트 로깅
- [ ] 수인 API 연동
- [ ] 일별 사용량 집계 자동화
### Phase 2: AI 기본 (2-3주)
- [ ] 수요 예측 모델 (단순 이동평균)
- [ ] 재주문점 계산
- [ ] 규격 선택 로직 (규칙 기반)
- [ ] 도매상 선택 로직 (규칙 기반)
- [ ] 주문 추천 대시보드
### Phase 3: 학습 시스템 (2-3주)
- [ ] 피드백 루프 구현
- [ ] 주문 평가 시스템
- [ ] 패턴 학습 (규격, 도매상)
- [ ] 재고 전략 프로파일링
### Phase 4: 자동화 (1-2주)
- [ ] Level 1 (승인 후 자동)
- [ ] 알림 시스템 연동
- [ ] Level 2 (조건부 자동)
- [ ] 모니터링 대시보드
### Phase 5: 고도화 (지속)
- [ ] ML 모델 적용 (XGBoost, LSTM)
- [ ] Level 3 (완전 자동)
- [ ] 다중 약국 지원
- [ ] 수요 예측 정교화
---
## 📊 성공 지표 (KPI)
| 지표 | 현재 | 목표 |
|------|------|------|
| 주문 소요 시간 | 30분/일 | 0분 (자동) |
| 품절 발생률 | 5% | <1% |
| 재고 회전율 | - | +20% |
| 주문 비용 절감 | - | 5-10% |
| 폐기 손실 | - | -30% |
---
## 🔐 보안 및 안전장치
### 자동 주문 제한
- 일일 자동 주문 금액 상한
- 단일 품목 최대 수량
- 신규 품목 자동 주문 제외
- 가격 급등 시 수동 전환
### 롤백 메커니즘
- 모든 주문 취소 가능 (확정 전)
- 자동화 레벨 즉시 변경
- 긴급 수동 모드 전환
### 감사 로그
- 모든 AI 결정 기록
- 자동 실행 이력
- 승인/거절 이력
---
## 💡 핵심 인사이트
> "AI는 약사님의 주문 습관을 학습합니다."
- 약사님이 항상 지오영에 먼저 주문하면 → AI도 지오영 우선
- 약사님이 300T보다 30T를 선호하면 → AI도 소량 주문
- 약사님이 여유 있게 주문하면 → AI도 안전 재고 확보
- 약사님이 가격에 민감하면 → AI도 최저가 추적
**AI는 대체하는 것이 아니라, 약사님의 방식을 자동화합니다.**
---
## 📚 참고 자료
- 지오영 API 문서: `docs/GEOYOUNG_API_REVERSE_ENGINEERING.md`
- 주문 DB 스키마: `backend/order_db.py`
- 사용량 조회 페이지: `docs/RX_USAGE_GEOYOUNG_GUIDE.md`

View File

@ -0,0 +1,375 @@
# 지오영 API 리버스 엔지니어링 가이드
> 작성일: 2026-03-06
> 목적: 지오영 도매상 웹사이트의 내부 API를 분석하여 Playwright 대신 requests로 빠른 주문 시스템 구축
---
## 📋 개요
### 문제점
- **Playwright 방식**: 30초+ 소요 (브라우저 실행 → 로그인 → 검색 → 클릭 → 장바구니)
- **경쟁사**: 훨씬 빠른 주문 처리
### 해결책
- 웹사이트의 **내부 AJAX API**를 분석
- **requests + 세션 쿠키**로 직접 호출
- 결과: **~1초** 주문 완료 (30배 빨라짐!)
---
## 🔍 분석 과정
### 1단계: 인증 쿠키 확인
Playwright로 로그인 후 쿠키 확인:
```python
cookies = await page.context.cookies()
print([c['name'] for c in cookies])
# 출력: ['GEORELAUTH']
```
**핵심 발견**: `GEORELAUTH` 쿠키가 인증 토큰
### 2단계: 네트워크 요청 캡처
```python
page.on('request', lambda req: print(req.url, req.method))
```
**발견된 POST 요청:**
- `/Member/Login` - 로그인
- `/Home/PartialSearchProduct` - 제품 검색
- `/Home/PartialProductCart` - 장바구니 조회
### 3단계: JavaScript 번들 분석
```
https://gwn.geoweb.kr/bundles/order?v=...
https://gwn.geoweb.kr/bundles/order_product_cart?v=...
```
정규식으로 함수/URL 추출:
```python
import re
# 함수 찾기
funcs = re.findall(r'function\s+(Add\w*|Process\w*)\s*\(', content)
# AJAX URL 찾기
urls = re.findall(r'url\s*:\s*["\']([^"\']+)["\']', content)
```
### 4단계: 핵심 함수 발견
**AddCart 함수:**
```javascript
function AddCart(n,t,i){
// ... 유효성 검사 ...
ProcessCart("add", e, i, r); // ← 핵심!
}
```
**ProcessCart 함수:**
```javascript
function ProcessCart(n,t,i,r){
var u = {};
u.productCode = t;
u.moveCode = i;
u.orderQty = r;
jsf_com_GetAjax("/Home/DataCart/" + n, u, "json", ...);
}
```
**발견!**
- 장바구니 API: `POST /Home/DataCart/add`
- 파라미터: `productCode`, `moveCode`, `orderQty`
### 5단계: 주문 확정 API 찾기
HTML에서 폼 분석:
```python
soup = BeautifulSoup(html, 'html.parser')
form = soup.find('form', id='frmSave')
print(form.get('action'))
# 출력: /Home/DataOrder
```
**발견!** 주문 확정 API: `POST /Home/DataOrder`
---
## 🔑 최종 API 명세
### 1. 로그인
```
POST https://gwn.geoweb.kr/Member/Login
Content-Type: application/x-www-form-urlencoded
LoginID=7390&Password=trajet6640
→ 쿠키 'GEORELAUTH' 반환
```
### 2. 제품 검색
```
POST https://gwn.geoweb.kr/Home/PartialSearchProduct
Content-Type: application/x-www-form-urlencoded
X-Requested-With: XMLHttpRequest
srchText=661700390
→ HTML 테이블 반환 (보험코드, 제품명, 재고 등)
```
### 3. 장바구니 추가 ⭐
```
POST https://gwn.geoweb.kr/Home/DataCart/add
Content-Type: application/x-www-form-urlencoded
X-Requested-With: XMLHttpRequest
productCode=008709 ← 내부 코드 (보험코드 아님!)
moveCode=
orderQty=2
→ {"result": 1, "msg": ""} (성공)
→ {"result": -100, "msg": "주문 등록을 할수없는 제품"} (실패)
```
### 4. 주문 확정 ⭐
```
POST https://gwn.geoweb.kr/Home/DataOrder
Content-Type: application/x-www-form-urlencoded
p_desc=메모
→ 리다이렉트 또는 성공 페이지
```
### 5. 장바구니 비우기
```
POST https://gwn.geoweb.kr/Home/DataCart/delAll
→ 성공 시 200
```
---
## ⚠️ 주의사항 (삽질 포인트)
### 1. productCode ≠ 보험코드
**실수:**
```python
# ❌ 보험코드로 장바구니 추가 시도
session.post('/Home/DataCart/add', data={
'productCode': '661700390', # 보험코드
'orderQty': 1
})
# 결과: {"result": -100, "msg": "주문 등록을 할수없는 제품"}
```
**해결:**
```python
# ✅ 검색 결과에서 내부 코드 추출
soup = BeautifulSoup(search_html, 'html.parser')
product_div = soup.find('div', class_='div-product-detail')
internal_code = product_div.find_all('li')[0].get_text() # 예: "008709"
session.post('/Home/DataCart/add', data={
'productCode': internal_code, # 내부 코드
'orderQty': 1
})
# 결과: {"result": 1} 성공!
```
### 2. X-Requested-With 헤더 필요
```python
session.headers.update({
'X-Requested-With': 'XMLHttpRequest' # AJAX 요청임을 명시
})
```
### 3. 세션 쿠키 유지
Playwright로 로그인 → requests 세션에 쿠키 복사:
```python
# Playwright에서 쿠키 획득
cookies = await page.context.cookies()
# requests 세션에 복사
session = requests.Session()
for c in cookies:
session.cookies.set(c['name'], c['value'])
```
### 4. 로그인 세션 만료
- 세션 유효시간: 약 30분
- 해결: 로그인 후 시간 체크, 만료 시 재로그인
```python
if time.time() - self.last_login > 1800: # 30분
self.login()
```
---
## 📊 성능 비교
| 방식 | 첫 요청 | 이후 요청 | 비고 |
|------|---------|----------|------|
| **Playwright** | ~12초 | ~30초 | 브라우저 실행 |
| **API 직접 호출** | **~5초** | **~1초** | requests 사용 |
**30배 속도 향상!**
---
## 🛠️ 구현 코드
### GeoyoungSession 클래스 (geoyoung_api.py)
```python
class GeoyoungSession:
"""지오영 세션 관리 (싱글톤, 세션 재사용)"""
BASE_URL = "https://gwn.geoweb.kr"
def login(self) -> bool:
"""Playwright로 로그인 → 쿠키 획득"""
# ... Playwright 로그인 ...
cookies = await page.context.cookies()
for c in cookies:
self.session.cookies.set(c['name'], c['value'])
self.logged_in = True
self.last_login = time.time()
def search_stock_with_code(self, keyword: str) -> list:
"""검색 + 내부 코드 추출"""
resp = self.session.post(f"{self.BASE_URL}/Home/PartialSearchProduct",
data={'srchText': keyword})
# HTML 파싱 → internal_code 추출
def add_to_cart(self, product_code: str, quantity: int) -> dict:
"""장바구니 추가"""
resp = self.session.post(f"{self.BASE_URL}/Home/DataCart/add", data={
'productCode': product_code,
'moveCode': '',
'orderQty': quantity
})
return resp.json()
def confirm_order(self, memo: str = '') -> dict:
"""주문 확정"""
resp = self.session.post(f"{self.BASE_URL}/Home/DataOrder",
data={'p_desc': memo})
return {'success': True}
def full_order(self, kd_code: str, quantity: int, ...) -> dict:
"""전체 주문 플로우"""
# 1. 검색 → internal_code
# 2. 장바구니 추가
# 3. 주문 확정
```
---
## 🔧 분석 도구/스크립트
분석에 사용한 스크립트들 (backend/ 폴더):
| 파일 | 용도 |
|------|------|
| `capture_geoyoung_api.py` | 네트워크 요청 캡처 |
| `analyze_geoyoung.py` | HTML/JS 분석 |
| `download_js.py` | JS 번들 다운로드 |
| `extract_addcart.py` | AddCart 함수 추출 |
| `extract_processcart.py` | ProcessCart 함수 추출 |
| `find_frmsave.py` | 주문 확정 폼 찾기 |
| `test_datacart.py` | 장바구니 API 테스트 |
| `test_dataorder.py` | 전체 플로우 테스트 |
---
## 📝 API 엔드포인트 (Flask)
```
GET /api/geoyoung/stock?kd_code=661700390 # 재고 조회
POST /api/geoyoung/order # 장바구니 추가
POST /api/geoyoung/confirm # 주문 확정
POST /api/geoyoung/full-order # 전체 주문 (추천!)
```
### full-order 요청 예시
```bash
curl -X POST http://localhost:7001/api/geoyoung/full-order \
-H "Content-Type: application/json" \
-d '{
"kd_code": "661700390",
"quantity": 2,
"specification": "30T",
"auto_confirm": true,
"memo": "자동주문"
}'
```
### 응답
```json
{
"success": true,
"message": "콩코르정2.5mg 30T 머크(대웅) 2개 주문 완료",
"product": {
"insurance_code": "661700390",
"internal_code": "008709",
"product_name": "콩코르정2.5mg 30T 머크(대웅)",
"specification": "30T",
"stock": 533
},
"quantity": 2,
"confirmed": true
}
```
---
## 🎯 핵심 교훈
1. **웹사이트 = API 서버**
모든 웹사이트는 내부적으로 API를 사용함. 브라우저 개발자도구로 분석 가능.
2. **JavaScript 번들 분석**
minified JS도 함수명, URL 패턴으로 핵심 로직 파악 가능.
3. **쿠키 = 인증**
대부분의 사이트는 쿠키로 세션 관리. 쿠키만 있으면 requests로 동일 동작.
4. **내부 코드 ≠ 외부 코드**
보험코드, 바코드 등 외부 식별자와 내부 DB 키가 다를 수 있음.
5. **AJAX 헤더**
`X-Requested-With: XMLHttpRequest` 헤더가 필요한 경우 많음.
---
## 🔮 향후 개선
- [ ] 로그인을 requests로 직접 (Playwright 없이)
- [ ] 다중 도매상 지원 (수인, 백제 등)
- [ ] 주문 실패 시 자동 재시도
- [ ] 주문 상태 조회 API
---
## 📚 참고
- 지오영 URL: https://gwn.geoweb.kr
- 관련 파일: `backend/geoyoung_api.py`
- 주문 DB: `backend/db/orders.db`

View File

@ -0,0 +1,316 @@
# 전문의약품 사용량 조회 + 지오영 주문 시스템
> 작성일: 2026-03-06
> 상태: 1단계 완료 (재고 조회), 2단계 진행 예정 (자동 주문)
---
## 📋 개요
약국의 전문의약품(처방전 조제) 사용량을 기간별로 조회하고, 지오영 도매상에서 재고를 확인하여 주문까지 연결하는 시스템.
### 핵심 기능
1. **사용량 조회**: 기간별 전문의약품 사용량 집계
2. **현재고 표시**: PIT3000 재고 데이터 연동
3. **지오영 재고 조회**: 도매상 재고 실시간 확인
4. **규격별 표시**: 30T, 100T, 300T 등 다양한 규격
5. **주문 장바구니**: 선택 품목 장바구니 담기
---
## 🗂️ 파일 구조
```
pharmacy-pos-qr-system/backend/
├── app.py # Flask 메인 (Blueprint 등록)
├── geoyoung_api.py # 지오영 API 모듈 ⭐ NEW
└── templates/
├── admin_rx_usage.html # 전문의약품 사용량 페이지 ⭐ NEW
└── admin_usage.html # OTC 사용량 페이지 ⭐ NEW
```
---
## 🔗 API 엔드포인트
### 1. 전문의약품 사용량 조회
```
GET /api/rx-usage?start_date=2026-03-01&end_date=2026-03-06&sort=qty_desc
```
**파라미터:**
| 파라미터 | 설명 | 예시 |
|---------|------|------|
| start_date | 시작일 (YYYY-MM-DD) | 2026-03-01 |
| end_date | 종료일 (YYYY-MM-DD) | 2026-03-06 |
| search | 검색어 (약품명, 코드) | 레바미피드 |
| sort | 정렬 (qty_desc, qty_asc, name_asc, amount_desc, rx_desc) | qty_desc |
**응답:**
```json
{
"success": true,
"items": [
{
"drug_code": "670400830",
"product_name": "휴니즈레바미피드정_(0.1g/1정)",
"supplier": "(주)휴온스메디텍",
"total_qty": 15,
"total_dose": 980,
"total_amount": 12500,
"prescription_count": 45,
"current_stock": 3809,
"barcode": "",
"thumbnail": null
}
],
"stats": {
"period_days": 6,
"product_count": 312,
"total_qty": 1500,
"total_dose": 15042,
"total_amount": 321837881
}
}
```
### 2. 지오영 재고 조회 (보험코드)
```
GET /api/geoyoung/stock?kd_code=670400830
```
**응답:**
```json
{
"success": true,
"keyword": "670400830",
"count": 2,
"items": [
{
"insurance_code": "670400830",
"manufacturer": "휴온스메디텍",
"product_name": "레바미피드정 300T 휴온스메디케어(구.휴니즈)",
"specification": "300T",
"stock": 0
},
{
"insurance_code": "670400830",
"manufacturer": "휴온스메디텍",
"product_name": "레바미피드정 30T 휴온스메디케어(구.휴니즈)",
"specification": "30T",
"stock": 0
}
]
}
```
### 3. 지오영 재고 조회 (제품명 → 성분 추출)
```
GET /api/geoyoung/stock-by-name?product_name=휴니즈레바미피드정_(0.1g/1정)
```
성분명 "레바미피드"를 추출하여 검색 → 여러 제약사 제품 반환
### 4. 지오영 세션 상태
```
GET /api/geoyoung/session-status
```
---
## 🗄️ 데이터베이스 구조
### MSSQL - PM_PRES (처방전)
**PS_sub_pharm** - 처방 상세
| 컬럼 | 설명 |
|------|------|
| PreSerial | 처방전 일련번호 |
| Indate | 조제일 (YYYYMMDD) |
| DrugCode | 약품코드 |
| QUAN | 수량 |
| Days | 투약일수 |
| DRUPRICE | 약가 |
### MSSQL - PM_DRUG (약품)
**CD_GOODS** - 약품 마스터
| 컬럼 | 설명 |
|------|------|
| DrugCode | 약품코드 (PK) |
| GoodsName | 약품명 |
| SplName | 제조사명 |
| BARCODE | 바코드 |
**IM_total** - 현재고 ⭐ 중요
| 컬럼 | 설명 |
|------|------|
| DrugCode | 약품코드 |
| **IM_QT_sale_debit** | **현재고 수량** |
### 현재고 조회 쿼리
```sql
SELECT
P.DrugCode,
G.GoodsName,
ISNULL(IT.IM_QT_sale_debit, 0) as current_stock
FROM PS_sub_pharm P
LEFT JOIN PM_DRUG.dbo.CD_GOODS G ON P.DrugCode = G.DrugCode
LEFT JOIN PM_DRUG.dbo.IM_total IT ON P.DrugCode = IT.DrugCode
```
---
## 🏭 지오영 API 연동
### 아키텍처
```
[브라우저] → [Flask API] → [GeoyoungSession] → [지오영 웹]
[Playwright 로그인] (최초 1회)
[requests 검색] (이후 빠름)
```
### 세션 관리 (geoyoung_api.py)
```python
class GeoyoungSession:
"""싱글톤 패턴, 세션 30분 유지"""
def login(self):
# Playwright로 로그인 → 쿠키 획득
# requests 세션에 쿠키 복사
def search_stock(self, keyword):
# requests로 빠른 검색
# POST /Home/PartialSearchProduct
```
### 성능
| 요청 | 소요시간 | 비고 |
|------|----------|------|
| 첫 요청 (로그인) | ~12초 | Playwright 브라우저 |
| 이후 요청 | **~2.5초** | requests 재사용 |
| 세션 유효기간 | 30분 | 자동 재로그인 |
### 지오영 로그인 정보
```
URL: https://gwn.geoweb.kr
ID: 7390
PW: trajet6640
```
---
## 💻 UI 사용법
### 페이지 접속
```
http://localhost:7001/admin/rx-usage
```
### 기능
1. **날짜 선택**: 시작일/종료일 지정
2. **검색**: 약품명, 코드로 필터
3. **정렬**: 투약량순, 처방건수순, 금액순
4. **지오영 조회**: 행 **더블클릭** → 모달
5. **장바구니**: 체크 후 "장바구니 추가"
6. **주문서**: "주문서 생성" → 클립보드 복사
### 색상 의미 (현재고)
- 🟢 초록: 재고 충분 (현재고 > 사용량)
- 🟡 노랑: 재고 부족 (현재고 < 사용량)
- 🔴 빨강: 재고 없음 (0)
---
## 🚀 향후 개발 계획
### 2단계: 자동 주문
- [ ] 지오영 장바구니 담기 API
- [ ] 주문 확정 API (dry_run 모드)
- [ ] 주문 내역 SQLite 저장
### 3단계: 다중 도매상
- [ ] 수인 API 연동
- [ ] 도매상 선택 UI
- [ ] 재고 비교 (A사 vs B사)
### 4단계: 스마트 주문
- [ ] 사용량 기반 최적 규격 추천
- 예: 220개 필요 → "30T x 8개" vs "300T x 1개"
- [ ] 분할 주문 (오전/오후)
- [ ] 주문 누적 관리
### 5단계: 주문 DB
```sql
-- SQLite: orders.db
CREATE TABLE orders (
id INTEGER PRIMARY KEY,
order_date TEXT,
wholesaler TEXT, -- 'geoyoung', 'sooin'
drug_code TEXT,
product_name TEXT,
specification TEXT, -- '30T', '300T'
quantity INTEGER,
status TEXT, -- 'pending', 'ordered', 'delivered'
created_at TEXT
);
```
---
## 🔧 트러블슈팅
### 문제: 지오영 로그인 실패
**원인**: requests만으로는 로그인 불가 (JavaScript 필요)
**해결**: Playwright 하이브리드 방식 (로그인만 Playwright)
### 문제: 검색 결과 0개
**원인**: 보험코드가 아닌 내부 코드로 검색
**해결**: 보험코드(KD코드) 사용, 또는 성분명으로 재검색
### 문제: 현재고가 0으로 표시
**원인**: IM_inventory 테이블이 비어있음
**해결**: `IM_total.IM_QT_sale_debit` 컬럼 사용
### 문제: Flask 서버 시작 안됨
**원인**: stdout 인코딩 문제 (Start-Process 사용 시)
**해결**: geoyoung_api.py에서 stdout 재설정 코드 제거
---
## 📝 관련 파일 참조
### 지오영 크롤러 원본
```
c:\Users\청춘약국\source\person-lookup-web-local\crawler\
├── gangwon_geoyoung_api.py # API 클라이언트
├── gangwon_geoyoung_order.py # 주문 자동화 (order_by_kd_code)
└── gangwon_geoyoung_crawler.py # 데이터 크롤링
```
### 주문 함수 사용 예시
```python
from gangwon_geoyoung_order import order_by_kd_code
# 테스트 (실제 주문 안함)
result = await order_by_kd_code("670400830", quantity=10, dry_run=True)
# 실제 주문
result = await order_by_kd_code("670400830", quantity=10, dry_run=False)
```
---
## ✅ 체크리스트
- [x] 전문의약품 사용량 조회 API
- [x] 현재고 표시 (IM_total)
- [x] 지오영 재고 조회 API
- [x] 지오영 세션 관리 (속도 개선)
- [x] UI 모달 (더블클릭)
- [x] 장바구니 기능
- [ ] 지오영 실제 주문 연동
- [ ] 주문 내역 DB 저장
- [ ] 다중 도매상 지원

View File

@ -0,0 +1,189 @@
# 도매상 API 통합 가이드
> 작성일: 2026-03-06
> 버전: 1.0
## 📦 패키지 구조
```
pharmacy-wholesale-api/ # 별도 리포지토리
├── wholesale/
│ ├── __init__.py # SooinSession, GeoYoungSession 노출
│ ├── base.py # WholesaleSession 공통 인터페이스
│ ├── sooin.py # 수인약품 API
│ └── geoyoung.py # 지오영 API
└── docs/
└── SOOIN.md # 수인약품 상세 문서
pharmacy-pos-qr-system/backend/ # 기존 프로젝트
├── wholesale_path.py # 패키지 경로 설정
├── sooin_api.py # Flask Blueprint (wholesale 사용)
└── geoyoung_api.py # Flask Blueprint (wholesale 사용)
```
---
## 🔌 도매상별 API 특성
| 항목 | 지오영 | 수인약품 |
|------|--------|----------|
| 웹사이트 | gwn.geoweb.kr | sooinpharm.co.kr |
| 인증 방식 | Playwright → requests | Playwright → requests |
| 세션 유효시간 | 30분 | 30분 |
| 검색 코드 | 보험코드 (KD) | KD코드 + 내부코드 (pc) |
| 장바구니 추가 | productCode 필요 | internal_code (pc) 필요 |
| **개별 삭제** | ❌ 없음 | ✅ 체크박스 soft delete |
| 장바구니 조회 | PartialProductCart | Bag.asp |
---
## 🔑 핵심 발견: 코드 체계
### 지오영
```
보험코드 (KD코드) → 검색 → productCode (내부) → 장바구니 추가
```
### 수인약품
```
KD코드 → 검색 → internal_code (pc) → 장바구니 추가
PhysicInfo.asp?pc=32495 에서 추출
```
**⚠️ 중요:** `internal_code`가 없으면 장바구니 추가 불가!
---
## 🛒 수인약품 개별 취소 (Soft Delete)
### 발견 과정
- `kind=delOne` API 존재하지만 작동 안 함
- 체크박스가 실제 "취소" 역할
- `ControlBag.asp` AJAX 엔드포인트 발견
### API 사용법
```python
from wholesale import SooinSession
session = SooinSession()
session.login()
# 장바구니 조회 (체크 상태 포함)
cart = session.get_cart()
# cart['items'][0]['checked'] = False (활성)
# cart['items'][0]['active'] = True
# 항목 취소 (체크)
session.cancel_item(row_index=0)
# 또는
session.cancel_item(product_code="32495")
# 취소 복원 (체크 해제)
session.restore_item(row_index=0)
```
### 내부 동작
```
POST /Service/Order/ControlBag.asp
Content-Type: application/x-www-form-urlencoded; charset=euc-kr
X-Requested-With: XMLHttpRequest
vc=50911 (거래처코드)
pc=32495 (내부 제품코드)
f=true (true=취소, false=복원)
pg= (제품구분, 빈값)
pdno= (제품번호, 빈값)
tmdt= (기한, 빈값)
```
---
## 📊 SQLite 스키마 연동
### order_context (AI 학습용)
```sql
-- 새로 추가된 필드 (2026-03-06)
wholesaler_id TEXT, -- 'geoyoung' 또는 'sooin'
wholesaler_price INTEGER, -- 도매상 가격
internal_code TEXT, -- 도매상 내부 코드
was_cancelled BOOLEAN, -- 취소 여부 (수인 soft delete)
```
### 도매상별 주문 시 기록할 데이터
```python
order_context = {
'drug_code': 'D12345',
'product_name': '아세탑정',
'wholesaler_id': 'sooin',
'internal_code': '32495', # 수인 내부코드
'ordered_spec': '30T',
'ordered_qty': 2,
'wholesaler_price': 4800,
'available_specs': '["30T", "500T"]',
'spec_stocks': '{"30T": 0, "500T": 0}', # 재고 상황
'selection_reason': 'only_option',
'was_cancelled': False
}
```
---
## 🔄 Flask API 엔드포인트
### 수인약품 (/api/sooin/*)
| 메서드 | 경로 | 설명 |
|--------|------|------|
| GET | /stock | 재고 검색 |
| GET | /cart | 장바구니 조회 |
| POST | /order | 장바구니 추가 |
| POST | /cart/clear | 장바구니 비우기 |
| POST | /cart/cancel | **항목 취소** (신규) |
| POST | /cart/restore | **항목 복원** (신규) |
| POST | /confirm | 주문 전송 |
### 지오영 (/api/geoyoung/*)
| 메서드 | 경로 | 설명 |
|--------|------|------|
| GET | /stock | 재고 검색 |
| GET | /cart | 장바구니 조회 |
| POST | /order | 장바구니 추가 |
| POST | /cart/clear | 장바구니 비우기 |
| POST | /confirm | 주문 전송 |
---
## 📁 관련 문서
| 문서 | 위치 | 내용 |
|------|------|------|
| AI ERP 자동주문 기획 | `docs/AI_ERP_AUTO_ORDER_SYSTEM.md` | 전체 시스템 설계 |
| 지오영 API 분석 | `docs/GEOYOUNG_API_REVERSE_ENGINEERING.md` | 지오영 리버스 엔지니어링 |
| 수인 API 분석 | `pharmacy-wholesale-api/docs/SOOIN.md` | 수인 리버스 엔지니어링 |
| 사용량 조회 가이드 | `docs/RX_USAGE_GEOYOUNG_GUIDE.md` | 처방 사용량 조회 |
---
## ✅ 체크리스트
### 완료
- [x] 지오영 API 연동
- [x] 수인약품 API 연동
- [x] 개별 취소 기능 (수인)
- [x] Flask Blueprint 통합
- [x] wholesale 패키지 분리
- [x] SQLite 스키마 업데이트
### 진행 예정
- [ ] daily_usage 자동 수집
- [ ] AI 규격 선택 모델
- [ ] AI 도매상 선택 모델
- [ ] 자동 주문 Level 1 (승인 후 실행)
---
*업데이트: 2026-03-06 by 용림 🐉*