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:
parent
e84eda928a
commit
c1596a6d35
277
backend/SOOIN_API_REVERSE_ENGINEERING.md
Normal file
277
backend/SOOIN_API_REVERSE_ENGINEERING.md
Normal 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
|
||||
|
||||
77
backend/analyze_geoyoung.py
Normal file
77
backend/analyze_geoyoung.py
Normal 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())
|
||||
@ -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
206
backend/bag_page.html
Normal 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&currVenCd=50911&currMkind=&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>
|
||||
79
backend/capture_geoyoung_api.py
Normal file
79
backend/capture_geoyoung_api.py
Normal 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
11
backend/check_db.py
Normal 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
13
backend/check_order_db.py
Normal 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
28
backend/check_paai_db.py
Normal 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
85
backend/download_js.py
Normal 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())
|
||||
86
backend/extract_addcart.py
Normal file
86
backend/extract_addcart.py
Normal 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())
|
||||
71
backend/extract_processcart.py
Normal file
71
backend/extract_processcart.py
Normal 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
90
backend/find_cart_js.py
Normal 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
82
backend/find_frmsave.py
Normal 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
70
backend/find_order_api.py
Normal 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())
|
||||
76
backend/find_order_api2.py
Normal file
76
backend/find_order_api2.py
Normal 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
316
backend/geoyoung_api.py
Normal 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
859
backend/order_db.py
Normal 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
360
backend/sooin_api.py
Normal 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
|
||||
})
|
||||
1072
backend/static/docs/AI_ERP.html
Normal file
1072
backend/static/docs/AI_ERP.html
Normal file
File diff suppressed because it is too large
Load Diff
@ -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
16
backend/test_bagjs.py
Normal 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
18
backend/test_bagjs2.py
Normal 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
16
backend/test_bagjs3.py
Normal 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
19
backend/test_bagjs4.py
Normal 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
46
backend/test_cancel.py
Normal 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
60
backend/test_cart.py
Normal 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
114
backend/test_cart_api.py
Normal 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()
|
||||
44
backend/test_cart_debug.py
Normal file
44
backend/test_cart_debug.py
Normal 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
127
backend/test_cart_list.py
Normal 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
74
backend/test_datacart.py
Normal 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
83
backend/test_datacart2.py
Normal 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
105
backend/test_datacart3.py
Normal 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
101
backend/test_dataorder.py
Normal 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
32
backend/test_del.py
Normal 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
26
backend/test_del2.py
Normal 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
25
backend/test_del3.py
Normal 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
29
backend/test_del_chk.py
Normal 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
21
backend/test_del_html.py
Normal 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
38
backend/test_del_one.py
Normal 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
33
backend/test_del_pc.py
Normal 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
26
backend/test_del_post.py
Normal 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
39
backend/test_encoding.py
Normal 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
25
backend/test_flask_api.py
Normal 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 로드 성공!')
|
||||
112
backend/test_geoyoung_api.py
Normal file
112
backend/test_geoyoung_api.py
Normal 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
49
backend/test_sooin.py
Normal 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('✅ 테스트 완료!')
|
||||
40
backend/test_sooin_full.py
Normal file
40
backend/test_sooin_full.py
Normal 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('=== 완료! ===')
|
||||
32
backend/test_wholesale_integration.py
Normal file
32
backend/test_wholesale_integration.py
Normal 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
13
backend/wholesale_path.py
Normal 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'))
|
||||
1072
docs/AI_ERP_AUTO_ORDER_SYSTEM.html
Normal file
1072
docs/AI_ERP_AUTO_ORDER_SYSTEM.html
Normal file
File diff suppressed because it is too large
Load Diff
875
docs/AI_ERP_AUTO_ORDER_SYSTEM.md
Normal file
875
docs/AI_ERP_AUTO_ORDER_SYSTEM.md
Normal 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`
|
||||
375
docs/GEOYOUNG_API_REVERSE_ENGINEERING.md
Normal file
375
docs/GEOYOUNG_API_REVERSE_ENGINEERING.md
Normal 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`
|
||||
316
docs/RX_USAGE_GEOYOUNG_GUIDE.md
Normal file
316
docs/RX_USAGE_GEOYOUNG_GUIDE.md
Normal 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 저장
|
||||
- [ ] 다중 도매상 지원
|
||||
189
docs/WHOLESALE_API_INTEGRATION.md
Normal file
189
docs/WHOLESALE_API_INTEGRATION.md
Normal 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 용림 🐉*
|
||||
Loading…
Reference in New Issue
Block a user