Compare commits
36 Commits
cb90d4a7a6
...
1829c3efa7
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1829c3efa7 | ||
|
|
241e65aaf1 | ||
|
|
ddba17ae08 | ||
|
|
055fad574d | ||
|
|
857a058691 | ||
|
|
78f6f21228 | ||
|
|
f625a08091 | ||
|
|
50455e63c7 | ||
|
|
7dda385b7f | ||
|
|
101dda2e41 | ||
|
|
19c70e42fb | ||
|
|
90d993156e | ||
|
|
ba38c05b93 | ||
|
|
c1596a6d35 | ||
|
|
e84eda928a | ||
|
|
0460085791 | ||
|
|
0d9f4c9a23 | ||
|
|
3527cc9777 | ||
|
|
636fd66f9e | ||
|
|
69b75d6724 | ||
|
|
3ce44019bf | ||
|
|
d820d13af9 | ||
|
|
771d247163 | ||
|
|
daa697fff9 | ||
|
|
8a86a120d8 | ||
|
|
513c082cc6 | ||
|
|
4968735a80 | ||
|
|
a144a091b9 | ||
|
|
ebd4669d24 | ||
|
|
c7169e6679 | ||
|
|
2eb92daf3e | ||
|
|
b4e4a44981 | ||
|
|
e33204f265 | ||
|
|
0bbc8a56f7 | ||
|
|
0b17139daa | ||
|
|
7ac3f7a8b4 |
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())
|
||||
348
backend/app.py
348
backend/app.py
@ -56,6 +56,21 @@ app.config['PERMANENT_SESSION_LIFETIME'] = timedelta(days=90) # 3개월 유지
|
||||
from pmr_api import pmr_bp
|
||||
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 baekje_api import baekje_bp
|
||||
app.register_blueprint(baekje_bp)
|
||||
|
||||
from order_api import order_bp
|
||||
app.register_blueprint(order_bp)
|
||||
|
||||
# 데이터베이스 매니저
|
||||
db_manager = DatabaseManager()
|
||||
|
||||
@ -3874,6 +3889,339 @@ def api_sales_detail():
|
||||
}), 500
|
||||
|
||||
|
||||
# ===== 사용량 조회 페이지 및 API =====
|
||||
|
||||
@app.route('/admin/usage')
|
||||
def admin_usage():
|
||||
"""OTC 사용량 조회 · 주문 페이지"""
|
||||
return render_template('admin_usage.html')
|
||||
|
||||
|
||||
@app.route('/admin/rx-usage')
|
||||
def admin_rx_usage():
|
||||
"""전문의약품 사용량 조회 · 주문 페이지"""
|
||||
return render_template('admin_rx_usage.html')
|
||||
|
||||
|
||||
@app.route('/api/usage')
|
||||
def api_usage():
|
||||
"""
|
||||
기간별 품목 사용량 조회 API
|
||||
GET /api/usage?start_date=2026-01-01&end_date=2026-01-31&search=타이레놀&sort=qty_desc
|
||||
"""
|
||||
try:
|
||||
start_date = request.args.get('start_date', '')
|
||||
end_date = request.args.get('end_date', '')
|
||||
search = request.args.get('search', '').strip()
|
||||
sort = request.args.get('sort', 'qty_desc') # qty_desc, qty_asc, name_asc, amount_desc
|
||||
|
||||
# 날짜 형식 변환 (YYYY-MM-DD -> YYYYMMDD)
|
||||
start_date_fmt = start_date.replace('-', '') if start_date else datetime.now().strftime('%Y%m%d')
|
||||
end_date_fmt = end_date.replace('-', '') if end_date else datetime.now().strftime('%Y%m%d')
|
||||
|
||||
mssql_session = db_manager.get_session('PM_PRES')
|
||||
|
||||
# 품목별 사용량 집계 쿼리
|
||||
usage_query = text("""
|
||||
SELECT
|
||||
S.DrugCode as drug_code,
|
||||
ISNULL(G.GoodsName, '알 수 없음') as product_name,
|
||||
CASE
|
||||
WHEN G.SplName IS NOT NULL AND G.SplName != '' THEN G.SplName
|
||||
WHEN SET_CHK.is_set = 1 THEN '세트상품'
|
||||
ELSE ''
|
||||
END as supplier,
|
||||
SUM(ISNULL(S.QUAN, 1)) as total_qty,
|
||||
SUM(ISNULL(S.SL_TOTAL_PRICE, 0)) as total_amount,
|
||||
COALESCE(NULLIF(G.BARCODE, ''), U.CD_CD_BARCODE, '') as barcode
|
||||
FROM SALE_SUB S
|
||||
LEFT JOIN PM_DRUG.dbo.CD_GOODS G ON S.DrugCode = G.DrugCode
|
||||
OUTER APPLY (
|
||||
SELECT TOP 1 CD_CD_BARCODE
|
||||
FROM PM_DRUG.dbo.CD_ITEM_UNIT_MEMBER
|
||||
WHERE DRUGCODE = S.DrugCode AND CD_CD_BARCODE IS NOT NULL AND CD_CD_BARCODE != ''
|
||||
) U
|
||||
OUTER APPLY (
|
||||
SELECT TOP 1 1 as is_set
|
||||
FROM PM_DRUG.dbo.CD_item_set
|
||||
WHERE SetCode = S.DrugCode AND DrugCode = 'SET0000'
|
||||
) SET_CHK
|
||||
WHERE S.SL_DT_appl >= :start_date
|
||||
AND S.SL_DT_appl <= :end_date
|
||||
GROUP BY S.DrugCode, G.GoodsName, G.SplName, SET_CHK.is_set, G.BARCODE, U.CD_CD_BARCODE
|
||||
ORDER BY SUM(ISNULL(S.QUAN, 1)) DESC
|
||||
""")
|
||||
|
||||
rows = mssql_session.execute(usage_query, {
|
||||
'start_date': start_date_fmt,
|
||||
'end_date': end_date_fmt
|
||||
}).fetchall()
|
||||
|
||||
items = []
|
||||
total_qty = 0
|
||||
total_amount = 0
|
||||
|
||||
for row in rows:
|
||||
drug_code = row.drug_code or ''
|
||||
product_name = row.product_name or ''
|
||||
|
||||
# 검색 필터
|
||||
if search:
|
||||
search_lower = search.lower()
|
||||
if (search_lower not in product_name.lower() and
|
||||
search_lower not in drug_code.lower()):
|
||||
continue
|
||||
|
||||
qty = int(row.total_qty or 0)
|
||||
amount = float(row.total_amount or 0)
|
||||
|
||||
items.append({
|
||||
'drug_code': drug_code,
|
||||
'product_name': product_name,
|
||||
'supplier': row.supplier or '',
|
||||
'barcode': row.barcode or '',
|
||||
'total_qty': qty,
|
||||
'total_amount': int(amount),
|
||||
'thumbnail': None
|
||||
})
|
||||
|
||||
total_qty += qty
|
||||
total_amount += amount
|
||||
|
||||
# 정렬
|
||||
if sort == 'qty_asc':
|
||||
items.sort(key=lambda x: x['total_qty'])
|
||||
elif sort == 'qty_desc':
|
||||
items.sort(key=lambda x: x['total_qty'], reverse=True)
|
||||
elif sort == 'name_asc':
|
||||
items.sort(key=lambda x: x['product_name'])
|
||||
elif sort == 'amount_desc':
|
||||
items.sort(key=lambda x: x['total_amount'], reverse=True)
|
||||
|
||||
# 제품 이미지 조회
|
||||
try:
|
||||
images_db_path = Path(__file__).parent / 'db' / 'product_images.db'
|
||||
if images_db_path.exists():
|
||||
img_conn = sqlite3.connect(str(images_db_path))
|
||||
img_cursor = img_conn.cursor()
|
||||
|
||||
barcodes = [item['barcode'] for item in items if item['barcode']]
|
||||
drug_codes = [item['drug_code'] for item in items]
|
||||
|
||||
image_map = {}
|
||||
if barcodes:
|
||||
placeholders = ','.join(['?' for _ in barcodes])
|
||||
img_cursor.execute(f'''
|
||||
SELECT barcode, thumbnail_base64
|
||||
FROM product_images
|
||||
WHERE barcode IN ({placeholders}) AND thumbnail_base64 IS NOT NULL
|
||||
''', barcodes)
|
||||
for r in img_cursor.fetchall():
|
||||
image_map[f'bc:{r[0]}'] = r[1]
|
||||
|
||||
if drug_codes:
|
||||
placeholders = ','.join(['?' for _ in drug_codes])
|
||||
img_cursor.execute(f'''
|
||||
SELECT drug_code, thumbnail_base64
|
||||
FROM product_images
|
||||
WHERE drug_code IN ({placeholders}) AND thumbnail_base64 IS NOT NULL
|
||||
''', drug_codes)
|
||||
for r in img_cursor.fetchall():
|
||||
if f'dc:{r[0]}' not in image_map:
|
||||
image_map[f'dc:{r[0]}'] = r[1]
|
||||
|
||||
img_conn.close()
|
||||
|
||||
for item in items:
|
||||
thumb = image_map.get(f'bc:{item["barcode"]}') or image_map.get(f'dc:{item["drug_code"]}')
|
||||
if thumb:
|
||||
item['thumbnail'] = thumb
|
||||
except Exception as img_err:
|
||||
logging.warning(f"제품 이미지 조회 오류: {img_err}")
|
||||
|
||||
# 기간 일수 계산
|
||||
try:
|
||||
from datetime import datetime as dt
|
||||
start_dt = dt.strptime(start_date_fmt, '%Y%m%d')
|
||||
end_dt = dt.strptime(end_date_fmt, '%Y%m%d')
|
||||
period_days = (end_dt - start_dt).days + 1
|
||||
except:
|
||||
period_days = 1
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'items': items[:500], # 최대 500건
|
||||
'stats': {
|
||||
'period_days': period_days,
|
||||
'product_count': len(items),
|
||||
'total_qty': total_qty,
|
||||
'total_amount': int(total_amount)
|
||||
}
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"사용량 조회 오류: {e}")
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': str(e)
|
||||
}), 500
|
||||
|
||||
|
||||
@app.route('/api/rx-usage')
|
||||
def api_rx_usage():
|
||||
"""
|
||||
전문의약품(처방전) 기간별 사용량 조회 API
|
||||
GET /api/rx-usage?start_date=2026-01-01&end_date=2026-01-31&search=타이레놀&sort=qty_desc
|
||||
"""
|
||||
try:
|
||||
start_date = request.args.get('start_date', '')
|
||||
end_date = request.args.get('end_date', '')
|
||||
search = request.args.get('search', '').strip()
|
||||
sort = request.args.get('sort', 'qty_desc')
|
||||
|
||||
# 날짜 형식 변환 (YYYY-MM-DD -> YYYYMMDD)
|
||||
start_date_fmt = start_date.replace('-', '') if start_date else datetime.now().strftime('%Y%m%d')
|
||||
end_date_fmt = end_date.replace('-', '') if end_date else datetime.now().strftime('%Y%m%d')
|
||||
|
||||
mssql_session = db_manager.get_session('PM_PRES')
|
||||
|
||||
# 전문의약품 품목별 사용량 집계 쿼리 (현재고: IM_total.IM_QT_sale_debit, 위치: CD_item_position.CD_NM_sale)
|
||||
rx_query = text("""
|
||||
SELECT
|
||||
P.DrugCode as drug_code,
|
||||
ISNULL(G.GoodsName, '알 수 없음') as product_name,
|
||||
ISNULL(G.SplName, '') as supplier,
|
||||
SUM(ISNULL(P.QUAN, 1)) as total_qty,
|
||||
SUM(ISNULL(P.QUAN, 1) * ISNULL(P.Days, 1)) as total_dose,
|
||||
SUM(ISNULL(P.DRUPRICE, 0) * ISNULL(P.QUAN, 1) * ISNULL(P.Days, 1)) as total_amount,
|
||||
COUNT(DISTINCT P.PreSerial) as prescription_count,
|
||||
COALESCE(NULLIF(G.BARCODE, ''), '') as barcode,
|
||||
ISNULL(IT.IM_QT_sale_debit, 0) as current_stock,
|
||||
ISNULL(POS.CD_NM_sale, '') as location
|
||||
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
|
||||
LEFT JOIN PM_DRUG.dbo.CD_item_position POS ON P.DrugCode = POS.DrugCode
|
||||
WHERE P.Indate >= :start_date
|
||||
AND P.Indate <= :end_date
|
||||
GROUP BY P.DrugCode, G.GoodsName, G.SplName, G.BARCODE, IT.IM_QT_sale_debit, POS.CD_NM_sale
|
||||
ORDER BY SUM(ISNULL(P.QUAN, 1) * ISNULL(P.Days, 1)) DESC
|
||||
""")
|
||||
|
||||
rows = mssql_session.execute(rx_query, {
|
||||
'start_date': start_date_fmt,
|
||||
'end_date': end_date_fmt
|
||||
}).fetchall()
|
||||
|
||||
items = []
|
||||
total_qty = 0
|
||||
total_dose = 0
|
||||
total_amount = 0
|
||||
total_prescriptions = set()
|
||||
|
||||
for row in rows:
|
||||
drug_code = row.drug_code or ''
|
||||
product_name = row.product_name or ''
|
||||
|
||||
# 검색 필터
|
||||
if search:
|
||||
search_lower = search.lower()
|
||||
if (search_lower not in product_name.lower() and
|
||||
search_lower not in drug_code.lower()):
|
||||
continue
|
||||
|
||||
qty = int(row.total_qty or 0)
|
||||
dose = int(row.total_dose or 0)
|
||||
amount = float(row.total_amount or 0)
|
||||
rx_count = int(row.prescription_count or 0)
|
||||
|
||||
items.append({
|
||||
'drug_code': drug_code,
|
||||
'product_name': product_name,
|
||||
'supplier': row.supplier or '',
|
||||
'barcode': row.barcode or '',
|
||||
'total_qty': qty,
|
||||
'total_dose': dose, # 총 투약량 (수량 x 일수)
|
||||
'total_amount': int(amount),
|
||||
'prescription_count': rx_count,
|
||||
'current_stock': int(row.current_stock or 0), # 현재고
|
||||
'location': row.location or '', # 약국 내 위치
|
||||
'thumbnail': None
|
||||
})
|
||||
|
||||
total_qty += qty
|
||||
total_dose += dose
|
||||
total_amount += amount
|
||||
|
||||
# 정렬
|
||||
if sort == 'qty_asc':
|
||||
items.sort(key=lambda x: x['total_dose'])
|
||||
elif sort == 'qty_desc':
|
||||
items.sort(key=lambda x: x['total_dose'], reverse=True)
|
||||
elif sort == 'name_asc':
|
||||
items.sort(key=lambda x: x['product_name'])
|
||||
elif sort == 'amount_desc':
|
||||
items.sort(key=lambda x: x['total_amount'], reverse=True)
|
||||
elif sort == 'rx_desc':
|
||||
items.sort(key=lambda x: x['prescription_count'], reverse=True)
|
||||
|
||||
# 제품 이미지 조회
|
||||
try:
|
||||
images_db_path = Path(__file__).parent / 'db' / 'product_images.db'
|
||||
if images_db_path.exists():
|
||||
img_conn = sqlite3.connect(str(images_db_path))
|
||||
img_cursor = img_conn.cursor()
|
||||
|
||||
drug_codes = [item['drug_code'] for item in items]
|
||||
|
||||
image_map = {}
|
||||
if drug_codes:
|
||||
placeholders = ','.join(['?' for _ in drug_codes])
|
||||
img_cursor.execute(f'''
|
||||
SELECT drug_code, thumbnail_base64
|
||||
FROM product_images
|
||||
WHERE drug_code IN ({placeholders}) AND thumbnail_base64 IS NOT NULL
|
||||
''', drug_codes)
|
||||
for r in img_cursor.fetchall():
|
||||
image_map[r[0]] = r[1]
|
||||
|
||||
img_conn.close()
|
||||
|
||||
for item in items:
|
||||
if item['drug_code'] in image_map:
|
||||
item['thumbnail'] = image_map[item['drug_code']]
|
||||
except Exception as img_err:
|
||||
logging.warning(f"제품 이미지 조회 오류: {img_err}")
|
||||
|
||||
# 기간 일수 계산
|
||||
try:
|
||||
from datetime import datetime as dt
|
||||
start_dt = dt.strptime(start_date_fmt, '%Y%m%d')
|
||||
end_dt = dt.strptime(end_date_fmt, '%Y%m%d')
|
||||
period_days = (end_dt - start_dt).days + 1
|
||||
except:
|
||||
period_days = 1
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'items': items[:500],
|
||||
'stats': {
|
||||
'period_days': period_days,
|
||||
'product_count': len(items),
|
||||
'total_qty': total_qty,
|
||||
'total_dose': total_dose,
|
||||
'total_amount': int(total_amount)
|
||||
}
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"전문의약품 사용량 조회 오류: {e}")
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': str(e)
|
||||
}), 500
|
||||
|
||||
|
||||
# ===== Claude 상태 API =====
|
||||
|
||||
@app.route('/api/claude-status')
|
||||
|
||||
262
backend/baekje_api.py
Normal file
262
backend/baekje_api.py
Normal file
@ -0,0 +1,262 @@
|
||||
# -*- 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 BaekjeSession
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Blueprint 생성
|
||||
baekje_bp = Blueprint('baekje', __name__, url_prefix='/api/baekje')
|
||||
|
||||
|
||||
# ========== 세션 관리 ==========
|
||||
|
||||
_baekje_session = None
|
||||
_init_started = False
|
||||
|
||||
def get_baekje_session():
|
||||
global _baekje_session
|
||||
if _baekje_session is None:
|
||||
_baekje_session = BaekjeSession()
|
||||
return _baekje_session
|
||||
|
||||
|
||||
def init_baekje_session():
|
||||
"""앱 시작 시 백그라운드에서 로그인 시작"""
|
||||
global _init_started
|
||||
if _init_started:
|
||||
return
|
||||
_init_started = True
|
||||
|
||||
session = get_baekje_session()
|
||||
|
||||
# 저장된 토큰이 있으면 즉시 사용 가능
|
||||
if session._logged_in:
|
||||
logger.info(f"백제약품: 저장된 토큰 사용 중")
|
||||
return
|
||||
|
||||
# 백그라운드 로그인 시작
|
||||
session.start_background_login()
|
||||
logger.info(f"백제약품: 백그라운드 로그인 시작됨")
|
||||
|
||||
|
||||
# 모듈 로드 시 자동 시작
|
||||
try:
|
||||
init_baekje_session()
|
||||
except Exception as e:
|
||||
logger.warning(f"백제약품 초기화 오류: {e}")
|
||||
|
||||
|
||||
def search_baekje_stock(keyword: str):
|
||||
"""백제약품 재고 검색"""
|
||||
try:
|
||||
session = get_baekje_session()
|
||||
result = session.search_products(keyword)
|
||||
|
||||
if result.get('success'):
|
||||
return {
|
||||
'success': True,
|
||||
'keyword': keyword,
|
||||
'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 ==========
|
||||
|
||||
@baekje_bp.route('/stock', methods=['GET'])
|
||||
def api_baekje_stock():
|
||||
"""
|
||||
백제약품 재고 조회 API
|
||||
|
||||
GET /api/baekje/stock?kd_code=672300240
|
||||
GET /api/baekje/stock?keyword=타이레놀
|
||||
"""
|
||||
kd_code = flask_request.args.get('kd_code', '').strip()
|
||||
keyword = flask_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_baekje_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
|
||||
|
||||
|
||||
@baekje_bp.route('/session-status', methods=['GET'])
|
||||
def api_session_status():
|
||||
"""세션 상태 확인"""
|
||||
session = get_baekje_session()
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'wholesaler': 'baekje',
|
||||
'name': '백제약품',
|
||||
'logged_in': session._logged_in,
|
||||
'last_login': session._last_login,
|
||||
'session_timeout': session.SESSION_TIMEOUT
|
||||
})
|
||||
|
||||
|
||||
@baekje_bp.route('/login', methods=['POST'])
|
||||
def api_login():
|
||||
"""수동 로그인"""
|
||||
session = get_baekje_session()
|
||||
success = session.login()
|
||||
return jsonify({
|
||||
'success': success,
|
||||
'message': '로그인 성공' if success else '로그인 실패'
|
||||
})
|
||||
|
||||
|
||||
@baekje_bp.route('/cart', methods=['GET'])
|
||||
def api_get_cart():
|
||||
"""장바구니 조회"""
|
||||
session = get_baekje_session()
|
||||
result = session.get_cart()
|
||||
return jsonify(result)
|
||||
|
||||
|
||||
@baekje_bp.route('/cart', methods=['POST'])
|
||||
def api_add_to_cart():
|
||||
"""
|
||||
장바구니 추가
|
||||
|
||||
POST /api/baekje/cart
|
||||
{
|
||||
"product_code": "672300240",
|
||||
"quantity": 2
|
||||
}
|
||||
"""
|
||||
data = flask_request.get_json() or {}
|
||||
product_code = data.get('product_code', '').strip()
|
||||
quantity = int(data.get('quantity', 1))
|
||||
|
||||
if not product_code:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': 'MISSING_PARAM',
|
||||
'message': 'product_code 필요'
|
||||
}), 400
|
||||
|
||||
session = get_baekje_session()
|
||||
result = session.add_to_cart(product_code, quantity)
|
||||
return jsonify(result)
|
||||
|
||||
|
||||
@baekje_bp.route('/order', methods=['POST'])
|
||||
def api_submit_order():
|
||||
"""
|
||||
주문 등록
|
||||
|
||||
POST /api/baekje/order
|
||||
{
|
||||
"memo": "긴급 요청"
|
||||
}
|
||||
"""
|
||||
data = flask_request.get_json() or {}
|
||||
memo = data.get('memo', '')
|
||||
|
||||
session = get_baekje_session()
|
||||
result = session.submit_order(memo)
|
||||
return jsonify(result)
|
||||
|
||||
|
||||
# ========== 프론트엔드 통합용 ==========
|
||||
|
||||
@baekje_bp.route('/search-for-order', methods=['POST'])
|
||||
def api_search_for_order():
|
||||
"""
|
||||
발주용 재고 검색 (프론트엔드 통합용)
|
||||
|
||||
POST /api/baekje/search-for-order
|
||||
{
|
||||
"kd_code": "672300240",
|
||||
"product_name": "타이레놀",
|
||||
"specification": "500T"
|
||||
}
|
||||
"""
|
||||
data = flask_request.get_json() or {}
|
||||
kd_code = data.get('kd_code', '').strip()
|
||||
product_name = data.get('product_name', '').strip()
|
||||
specification = data.get('specification', '').strip()
|
||||
|
||||
search_term = kd_code or product_name
|
||||
|
||||
if not search_term:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': 'MISSING_PARAM'
|
||||
}), 400
|
||||
|
||||
result = search_baekje_stock(search_term)
|
||||
|
||||
if result.get('success') and specification:
|
||||
# 규격 필터링
|
||||
filtered = [
|
||||
item for item in result.get('items', [])
|
||||
if specification.lower() in item.get('spec', '').lower()
|
||||
]
|
||||
result['items'] = filtered
|
||||
result['count'] = len(filtered)
|
||||
|
||||
return jsonify(result)
|
||||
|
||||
|
||||
# ========== 잔고 조회 ==========
|
||||
|
||||
@baekje_bp.route('/balance', methods=['GET'])
|
||||
def api_get_balance():
|
||||
"""
|
||||
잔고액 조회
|
||||
|
||||
GET /api/baekje/balance
|
||||
GET /api/baekje/balance?year=2026
|
||||
|
||||
Returns:
|
||||
{
|
||||
"success": true,
|
||||
"balance": 14193234,
|
||||
"monthly": [
|
||||
{"month": "2026-03", "sales": 6935133, "balance": 14193234, ...},
|
||||
{"month": "2026-02", "sales": 18600692, "balance": 7258101, ...}
|
||||
]
|
||||
}
|
||||
"""
|
||||
year = flask_request.args.get('year', '').strip()
|
||||
|
||||
session = get_baekje_session()
|
||||
result = session.get_balance(year if year else None)
|
||||
return jsonify(result)
|
||||
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())
|
||||
453
backend/geoyoung_api.py
Normal file
453
backend/geoyoung_api.py
Normal file
@ -0,0 +1,453 @@
|
||||
# -*- 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, include_price: bool = True):
|
||||
"""지오영 재고 검색 (동기, 단가 포함)"""
|
||||
try:
|
||||
session = get_geo_session()
|
||||
|
||||
# 새 API 사용 (단가 포함)
|
||||
result = session.search_products(keyword, include_price=include_price)
|
||||
|
||||
if result.get('success'):
|
||||
# 기존 형식으로 변환
|
||||
items = [{
|
||||
'insurance_code': item['code'],
|
||||
'internal_code': item.get('internal_code'),
|
||||
'manufacturer': item['manufacturer'],
|
||||
'product_name': item['name'],
|
||||
'specification': item['spec'],
|
||||
'stock': item['stock'],
|
||||
'price': item.get('price', 0), # 단가 추가!
|
||||
'box_qty': item.get('box_qty'),
|
||||
'case_qty': item.get('case_qty')
|
||||
} for item in result['items']]
|
||||
|
||||
return {
|
||||
'success': True,
|
||||
'keyword': keyword,
|
||||
'count': len(items),
|
||||
'items': items
|
||||
}
|
||||
else:
|
||||
return {'success': False, 'error': result.get('error'), 'message': '검색 실패'}
|
||||
|
||||
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('/cart/cancel', methods=['POST'])
|
||||
def api_geoyoung_cart_cancel():
|
||||
"""
|
||||
장바구니 개별 항목 삭제 API (Hard delete)
|
||||
|
||||
POST /api/geoyoung/cart/cancel
|
||||
{
|
||||
"row_index": 0, // 또는
|
||||
"product_code": "008709"
|
||||
}
|
||||
|
||||
⚠️ 지오영은 완전 삭제됨 (복원 불가, 다시 추가해야 함)
|
||||
"""
|
||||
data = request.get_json() or {}
|
||||
row_index = data.get('row_index')
|
||||
product_code = data.get('product_code')
|
||||
|
||||
if row_index is None and not product_code:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': 'MISSING_PARAM',
|
||||
'message': 'row_index 또는 product_code 필요'
|
||||
}), 400
|
||||
|
||||
try:
|
||||
session = get_geo_session()
|
||||
result = session.cancel_item(row_index=row_index, product_code=product_code)
|
||||
return jsonify(result)
|
||||
except Exception as e:
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
|
||||
@geoyoung_bp.route('/cart/restore', methods=['POST'])
|
||||
def api_geoyoung_cart_restore():
|
||||
"""
|
||||
삭제된 항목 복원 API - 지오영은 Hard delete이므로 지원 안 함
|
||||
|
||||
Returns:
|
||||
항상 {'success': False, 'error': 'NOT_SUPPORTED'}
|
||||
"""
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': 'NOT_SUPPORTED',
|
||||
'message': '지오영은 삭제 후 복원 불가 (다시 추가 필요)'
|
||||
}), 400
|
||||
|
||||
|
||||
@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
|
||||
})
|
||||
|
||||
|
||||
# ========== 잔고 탐색 (임시) ==========
|
||||
|
||||
@geoyoung_bp.route('/explore-balance', methods=['GET'])
|
||||
def api_explore_balance():
|
||||
"""잔고 페이지 탐색 (임시 디버그용)"""
|
||||
from bs4 import BeautifulSoup
|
||||
|
||||
session = get_geo_session()
|
||||
if not session._logged_in:
|
||||
session.login()
|
||||
|
||||
results = {
|
||||
'logged_in': session._logged_in,
|
||||
'cookies': len(session.session.cookies),
|
||||
'pages_found': [],
|
||||
'balance_pages': []
|
||||
}
|
||||
|
||||
# Order 페이지에서 메뉴 링크 수집
|
||||
try:
|
||||
# 먼저 Order 페이지 접근
|
||||
resp = session.session.get(f"{session.BASE_URL}/Home/Order", timeout=10)
|
||||
results['order_page'] = {
|
||||
'status': resp.status_code,
|
||||
'url': resp.url,
|
||||
'is_error': 'Error' in resp.url
|
||||
}
|
||||
|
||||
if resp.status_code == 200 and 'Error' not in resp.url:
|
||||
soup = BeautifulSoup(resp.text, 'html.parser')
|
||||
|
||||
# 모든 링크 추출
|
||||
for link in soup.find_all('a', href=True):
|
||||
href = link.get('href', '')
|
||||
text = link.get_text(strip=True)[:50]
|
||||
|
||||
if href.startswith('/') and href not in [l['href'] for l in results['pages_found']]:
|
||||
entry = {'href': href, 'text': text}
|
||||
results['pages_found'].append(entry)
|
||||
|
||||
# 잔고 관련 키워드
|
||||
keywords = ['account', 'balance', 'trans', 'state', 'history', 'ledger', '잔고', '잔액', '거래', '명세', '내역']
|
||||
if any(kw in href.lower() or kw in text for kw in keywords):
|
||||
results['balance_pages'].append(entry)
|
||||
|
||||
except Exception as e:
|
||||
results['error'] = str(e)
|
||||
|
||||
return jsonify(results)
|
||||
|
||||
|
||||
@geoyoung_bp.route('/balance', methods=['GET'])
|
||||
def api_get_balance():
|
||||
"""
|
||||
잔고액 조회
|
||||
|
||||
GET /api/geoyoung/balance
|
||||
"""
|
||||
session = get_geo_session()
|
||||
|
||||
# get_balance 메서드가 있으면 호출
|
||||
if hasattr(session, 'get_balance'):
|
||||
result = session.get_balance()
|
||||
return jsonify(result)
|
||||
else:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': 'NOT_IMPLEMENTED',
|
||||
'message': '지오영 잔고 조회 미구현'
|
||||
}), 501
|
||||
|
||||
|
||||
# ========== 하위 호환성 ==========
|
||||
|
||||
# 기존 코드에서 직접 클래스 참조하는 경우를 위해
|
||||
GeoyoungSession = GeoYoungSession
|
||||
915
backend/order_api.py
Normal file
915
backend/order_api.py
Normal file
@ -0,0 +1,915 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
주문 API 모듈
|
||||
- 주문 생성/조회
|
||||
- 지오영 실제 주문 연동
|
||||
- dry_run 테스트 모드
|
||||
"""
|
||||
|
||||
import sys
|
||||
import os
|
||||
import asyncio
|
||||
import re
|
||||
from flask import Blueprint, jsonify, request
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Blueprint 생성
|
||||
order_bp = Blueprint('order', __name__, url_prefix='/api/order')
|
||||
|
||||
# 지오영 크롤러 경로
|
||||
CRAWLER_PATH = r'c:\Users\청춘약국\source\person-lookup-web-local\crawler'
|
||||
if CRAWLER_PATH not in sys.path:
|
||||
sys.path.insert(0, CRAWLER_PATH)
|
||||
|
||||
# 주문 DB
|
||||
from order_db import (
|
||||
create_order, get_order, update_order_status,
|
||||
update_item_result, get_order_history,
|
||||
save_order_context, get_usage_stats, get_order_pattern,
|
||||
get_ai_training_data
|
||||
)
|
||||
|
||||
|
||||
def run_async(coro):
|
||||
"""동기 컨텍스트에서 비동기 함수 실행"""
|
||||
try:
|
||||
loop = asyncio.get_event_loop()
|
||||
except RuntimeError:
|
||||
loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(loop)
|
||||
return loop.run_until_complete(coro)
|
||||
|
||||
|
||||
def parse_specification(spec: str) -> int:
|
||||
"""규격에서 숫자 추출 (30T -> 30)"""
|
||||
if not spec:
|
||||
return 1
|
||||
match = re.search(r'(\d+)', spec)
|
||||
return int(match.group(1)) if match else 1
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────
|
||||
# API 엔드포인트
|
||||
# ─────────────────────────────────────────────
|
||||
|
||||
@order_bp.route('/create', methods=['POST'])
|
||||
def api_create_order():
|
||||
"""
|
||||
주문 생성 (draft 상태)
|
||||
|
||||
POST /api/order/create
|
||||
{
|
||||
"wholesaler_id": "geoyoung",
|
||||
"items": [
|
||||
{
|
||||
"drug_code": "670400830",
|
||||
"kd_code": "670400830",
|
||||
"product_name": "레바미피드정 30T",
|
||||
"specification": "30T",
|
||||
"order_qty": 10,
|
||||
"usage_qty": 280,
|
||||
"current_stock": 50
|
||||
}
|
||||
],
|
||||
"reference_period": "2026-03-01~2026-03-06",
|
||||
"note": "오전 주문"
|
||||
}
|
||||
"""
|
||||
data = request.get_json()
|
||||
|
||||
if not data:
|
||||
return jsonify({'success': False, 'error': 'No data'}), 400
|
||||
|
||||
wholesaler_id = data.get('wholesaler_id', 'geoyoung')
|
||||
items = data.get('items', [])
|
||||
|
||||
if not items:
|
||||
return jsonify({'success': False, 'error': 'No items'}), 400
|
||||
|
||||
# unit_qty 계산
|
||||
for item in items:
|
||||
if 'unit_qty' not in item:
|
||||
item['unit_qty'] = parse_specification(item.get('specification'))
|
||||
|
||||
result = create_order(
|
||||
wholesaler_id=wholesaler_id,
|
||||
items=items,
|
||||
order_type=data.get('order_type', 'manual'),
|
||||
order_session=data.get('order_session'),
|
||||
reference_period=data.get('reference_period'),
|
||||
note=data.get('note')
|
||||
)
|
||||
|
||||
return jsonify(result)
|
||||
|
||||
|
||||
@order_bp.route('/submit', methods=['POST'])
|
||||
def api_submit_order():
|
||||
"""
|
||||
주문 제출 (실제 도매상 주문)
|
||||
|
||||
POST /api/order/submit
|
||||
{
|
||||
"order_id": 1,
|
||||
"dry_run": true
|
||||
}
|
||||
|
||||
dry_run=true: 시뮬레이션 (실제 주문 X)
|
||||
dry_run=false: 실제 주문
|
||||
"""
|
||||
data = request.get_json()
|
||||
|
||||
order_id = data.get('order_id')
|
||||
dry_run = data.get('dry_run', True) # 기본은 테스트 모드
|
||||
|
||||
if not order_id:
|
||||
return jsonify({'success': False, 'error': 'order_id required'}), 400
|
||||
|
||||
# 주문 조회
|
||||
order = get_order(order_id)
|
||||
if not order:
|
||||
return jsonify({'success': False, 'error': 'Order not found'}), 404
|
||||
|
||||
if order['status'] not in ('draft', 'pending', 'failed'):
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': f"Cannot submit order with status: {order['status']}"
|
||||
}), 400
|
||||
|
||||
wholesaler_id = order['wholesaler_id']
|
||||
|
||||
# 도매상별 주문 처리
|
||||
if wholesaler_id == 'geoyoung':
|
||||
result = submit_geoyoung_order(order, dry_run)
|
||||
else:
|
||||
result = {
|
||||
'success': False,
|
||||
'error': f'Wholesaler {wholesaler_id} not supported yet'
|
||||
}
|
||||
|
||||
return jsonify(result)
|
||||
|
||||
|
||||
def submit_geoyoung_order(order: dict, dry_run: bool) -> dict:
|
||||
"""지오영 주문 제출"""
|
||||
order_id = order['id']
|
||||
items = order['items']
|
||||
|
||||
# 상태 업데이트
|
||||
update_order_status(order_id, 'pending',
|
||||
f'주문 제출 시작 (dry_run={dry_run})')
|
||||
|
||||
results = []
|
||||
success_count = 0
|
||||
failed_count = 0
|
||||
|
||||
try:
|
||||
if dry_run:
|
||||
# ─────────────────────────────────────────
|
||||
# DRY RUN: 시뮬레이션
|
||||
# ─────────────────────────────────────────
|
||||
for item in items:
|
||||
# 재고 확인만 (실제 주문 X)
|
||||
from geoyoung_api import search_geoyoung_stock
|
||||
|
||||
kd_code = item.get('kd_code') or item.get('drug_code')
|
||||
stock_result = search_geoyoung_stock(kd_code)
|
||||
|
||||
# 규격 매칭 (재고 있는 것 우선!)
|
||||
spec = item.get('specification', '')
|
||||
matched = None
|
||||
matched_with_stock = None
|
||||
matched_any = None
|
||||
|
||||
if stock_result.get('success'):
|
||||
for geo_item in stock_result.get('items', []):
|
||||
if spec in geo_item.get('specification', ''):
|
||||
# 첫 번째 규격 매칭 저장
|
||||
if matched_any is None:
|
||||
matched_any = geo_item
|
||||
# 재고 있는 제품 우선
|
||||
if geo_item.get('stock', 0) > 0:
|
||||
matched_with_stock = geo_item
|
||||
break
|
||||
|
||||
# 재고 있는 것 우선, 없으면 첫 번째 매칭
|
||||
matched = matched_with_stock or matched_any
|
||||
|
||||
# 모든 규격과 재고 수집 (AI 학습용)
|
||||
available_specs = []
|
||||
spec_stocks = {}
|
||||
if stock_result.get('success'):
|
||||
for geo_item in stock_result.get('items', []):
|
||||
s = geo_item.get('specification', '')
|
||||
available_specs.append(s)
|
||||
spec_stocks[s] = geo_item.get('stock', 0)
|
||||
|
||||
if matched:
|
||||
if matched['stock'] >= item['order_qty']:
|
||||
# 주문 가능
|
||||
status = 'success'
|
||||
result_code = 'OK'
|
||||
result_message = f"[DRY RUN] 주문 가능: 재고 {matched['stock']}"
|
||||
success_count += 1
|
||||
selection_reason = 'stock_available'
|
||||
else:
|
||||
# 재고 부족
|
||||
status = 'failed'
|
||||
selection_reason = 'low_stock'
|
||||
result_code = 'LOW_STOCK'
|
||||
result_message = f"[DRY RUN] 재고 부족: {matched['stock']}개 (요청: {item['order_qty']})"
|
||||
failed_count += 1
|
||||
else:
|
||||
# 제품 없음
|
||||
status = 'failed'
|
||||
result_code = 'NOT_FOUND'
|
||||
result_message = f"[DRY RUN] 지오영에서 규격 {spec} 미발견"
|
||||
failed_count += 1
|
||||
selection_reason = 'not_found'
|
||||
|
||||
update_item_result(item['id'], status, result_code, result_message)
|
||||
|
||||
# ─────────────────────────────────────────
|
||||
# AI 학습용 컨텍스트 저장
|
||||
# ─────────────────────────────────────────
|
||||
save_order_context(item['id'], {
|
||||
'drug_code': item['drug_code'],
|
||||
'product_name': item['product_name'],
|
||||
'stock_at_order': item.get('current_stock', 0),
|
||||
'usage_1d': item.get('usage_qty', 0) // 7 if item.get('usage_qty') else 0, # 추정
|
||||
'usage_7d': item.get('usage_qty', 0), # 조회 기간 사용량
|
||||
'usage_30d': (item.get('usage_qty', 0) * 30) // 7 if item.get('usage_qty') else 0, # 추정
|
||||
'ordered_spec': spec,
|
||||
'ordered_qty': item['order_qty'],
|
||||
'available_specs': available_specs,
|
||||
'spec_stocks': spec_stocks,
|
||||
'selection_reason': selection_reason if 'selection_reason' in dir() else 'unknown'
|
||||
})
|
||||
|
||||
results.append({
|
||||
'item_id': item['id'],
|
||||
'drug_code': item['drug_code'],
|
||||
'product_name': item['product_name'],
|
||||
'specification': spec,
|
||||
'order_qty': item['order_qty'],
|
||||
'status': status,
|
||||
'result_code': result_code,
|
||||
'result_message': result_message,
|
||||
'available_specs': available_specs,
|
||||
'spec_stocks': spec_stocks
|
||||
})
|
||||
|
||||
# 주문 상태 업데이트
|
||||
if failed_count == 0:
|
||||
update_order_status(order_id, 'completed',
|
||||
f'[DRY RUN] 시뮬레이션 완료: {success_count}개 성공')
|
||||
elif success_count == 0:
|
||||
update_order_status(order_id, 'failed',
|
||||
f'[DRY RUN] 시뮬레이션 완료: {failed_count}개 실패')
|
||||
else:
|
||||
update_order_status(order_id, 'partial',
|
||||
f'[DRY RUN] 부분 성공: {success_count}개 성공, {failed_count}개 실패')
|
||||
|
||||
else:
|
||||
# ─────────────────────────────────────────
|
||||
# 실제 주문 (빠른 API - ~1초/품목)
|
||||
# ─────────────────────────────────────────
|
||||
from geoyoung_api import get_geo_session
|
||||
|
||||
geo_session = get_geo_session()
|
||||
|
||||
for item in items:
|
||||
kd_code = item.get('kd_code') or item.get('drug_code')
|
||||
order_qty = item['order_qty']
|
||||
spec = item.get('specification', '')
|
||||
|
||||
try:
|
||||
# 지오영 주문 실행 (빠른 API - 장바구니+확정)
|
||||
result = geo_session.full_order(
|
||||
kd_code=kd_code,
|
||||
quantity=order_qty,
|
||||
specification=spec if spec else None,
|
||||
check_stock=True,
|
||||
auto_confirm=True,
|
||||
memo=f"자동주문 - {item.get('product_name', '')}"
|
||||
)
|
||||
|
||||
if result.get('success'):
|
||||
status = 'success'
|
||||
result_code = 'OK'
|
||||
result_message = result.get('message', '주문 완료')
|
||||
success_count += 1
|
||||
else:
|
||||
status = 'failed'
|
||||
result_code = result.get('error', 'UNKNOWN')
|
||||
result_message = result.get('message', '주문 실패')
|
||||
failed_count += 1
|
||||
|
||||
except Exception as e:
|
||||
status = 'failed'
|
||||
result_code = 'ERROR'
|
||||
result_message = str(e)
|
||||
failed_count += 1
|
||||
|
||||
update_item_result(item['id'], status, result_code, result_message)
|
||||
|
||||
# AI 학습용 컨텍스트 저장 (실제 주문)
|
||||
save_order_context(item['id'], {
|
||||
'drug_code': item['drug_code'],
|
||||
'product_name': item['product_name'],
|
||||
'stock_at_order': item.get('current_stock', 0),
|
||||
'usage_7d': item.get('usage_qty', 0),
|
||||
'ordered_spec': spec,
|
||||
'ordered_qty': order_qty,
|
||||
'selection_reason': 'user_order'
|
||||
})
|
||||
|
||||
results.append({
|
||||
'item_id': item['id'],
|
||||
'drug_code': item['drug_code'],
|
||||
'product_name': item['product_name'],
|
||||
'specification': spec,
|
||||
'order_qty': order_qty,
|
||||
'status': status,
|
||||
'result_code': result_code,
|
||||
'result_message': result_message
|
||||
})
|
||||
|
||||
# 주문 상태 업데이트
|
||||
if failed_count == 0:
|
||||
update_order_status(order_id, 'submitted',
|
||||
f'주문 제출 완료: {success_count}개 품목')
|
||||
elif success_count == 0:
|
||||
update_order_status(order_id, 'failed',
|
||||
f'주문 실패: {failed_count}개 품목')
|
||||
else:
|
||||
update_order_status(order_id, 'partial',
|
||||
f'부분 주문: {success_count}개 성공, {failed_count}개 실패')
|
||||
|
||||
return {
|
||||
'success': True,
|
||||
'dry_run': dry_run,
|
||||
'order_id': order_id,
|
||||
'order_no': order['order_no'],
|
||||
'total_items': len(items),
|
||||
'success_count': success_count,
|
||||
'failed_count': failed_count,
|
||||
'results': results
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"지오영 주문 오류: {e}")
|
||||
update_order_status(order_id, 'failed', str(e))
|
||||
return {
|
||||
'success': False,
|
||||
'order_id': order_id,
|
||||
'error': str(e)
|
||||
}
|
||||
|
||||
|
||||
@order_bp.route('/<int:order_id>', methods=['GET'])
|
||||
def api_get_order(order_id):
|
||||
"""주문 상세 조회"""
|
||||
order = get_order(order_id)
|
||||
|
||||
if not order:
|
||||
return jsonify({'success': False, 'error': 'Order not found'}), 404
|
||||
|
||||
return jsonify({'success': True, 'order': order})
|
||||
|
||||
|
||||
@order_bp.route('/history', methods=['GET'])
|
||||
def api_order_history():
|
||||
"""
|
||||
주문 이력 조회
|
||||
|
||||
GET /api/order/history?wholesaler_id=geoyoung&start_date=2026-03-01&limit=20
|
||||
"""
|
||||
orders = get_order_history(
|
||||
wholesaler_id=request.args.get('wholesaler_id'),
|
||||
start_date=request.args.get('start_date'),
|
||||
end_date=request.args.get('end_date'),
|
||||
status=request.args.get('status'),
|
||||
limit=int(request.args.get('limit', 50))
|
||||
)
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'count': len(orders),
|
||||
'orders': orders
|
||||
})
|
||||
|
||||
|
||||
@order_bp.route('/quick-submit', methods=['POST'])
|
||||
def api_quick_submit():
|
||||
"""
|
||||
빠른 주문 (생성 + 제출 한번에)
|
||||
|
||||
POST /api/order/quick-submit
|
||||
{
|
||||
"wholesaler_id": "geoyoung" | "sooin",
|
||||
"items": [...],
|
||||
"dry_run": true
|
||||
}
|
||||
"""
|
||||
data = request.get_json()
|
||||
|
||||
if not data or not data.get('items'):
|
||||
return jsonify({'success': False, 'error': 'No items'}), 400
|
||||
|
||||
# 1. 주문 생성
|
||||
create_result = create_order(
|
||||
wholesaler_id=data.get('wholesaler_id', 'geoyoung'),
|
||||
items=data['items'],
|
||||
order_type='manual',
|
||||
reference_period=data.get('reference_period'),
|
||||
note=data.get('note')
|
||||
)
|
||||
|
||||
if not create_result.get('success'):
|
||||
return jsonify(create_result), 400
|
||||
|
||||
order_id = create_result['order_id']
|
||||
|
||||
# 2. 주문 조회
|
||||
order = get_order(order_id)
|
||||
|
||||
# 3. 주문 제출
|
||||
dry_run = data.get('dry_run', True)
|
||||
|
||||
if order['wholesaler_id'] == 'geoyoung':
|
||||
submit_result = submit_geoyoung_order(order, dry_run)
|
||||
elif order['wholesaler_id'] == 'sooin':
|
||||
submit_result = submit_sooin_order(order, dry_run)
|
||||
elif order['wholesaler_id'] == 'baekje':
|
||||
submit_result = submit_baekje_order(order, dry_run)
|
||||
else:
|
||||
submit_result = {'success': False, 'error': f"Wholesaler {order['wholesaler_id']} not supported"}
|
||||
|
||||
submit_result['order_no'] = create_result['order_no']
|
||||
|
||||
return jsonify(submit_result)
|
||||
|
||||
|
||||
def submit_sooin_order(order: dict, dry_run: bool) -> dict:
|
||||
"""수인약품 주문 제출"""
|
||||
order_id = order['id']
|
||||
items = order['items']
|
||||
|
||||
# 상태 업데이트
|
||||
update_order_status(order_id, 'pending',
|
||||
f'수인 주문 시작 (dry_run={dry_run})')
|
||||
|
||||
results = []
|
||||
success_count = 0
|
||||
failed_count = 0
|
||||
|
||||
try:
|
||||
from sooin_api import get_sooin_session
|
||||
sooin_session = get_sooin_session()
|
||||
|
||||
if dry_run:
|
||||
# ─────────────────────────────────────────
|
||||
# DRY RUN: 재고 확인만
|
||||
# ─────────────────────────────────────────
|
||||
for item in items:
|
||||
kd_code = item.get('kd_code') or item.get('drug_code')
|
||||
spec = item.get('specification', '')
|
||||
|
||||
# 재고 검색
|
||||
search_result = sooin_session.search_products(kd_code)
|
||||
|
||||
matched = None
|
||||
available_specs = []
|
||||
spec_stocks = {}
|
||||
|
||||
if search_result.get('success'):
|
||||
for sooin_item in search_result.get('items', []):
|
||||
s = sooin_item.get('spec', '')
|
||||
available_specs.append(s)
|
||||
spec_stocks[s] = sooin_item.get('stock', 0)
|
||||
|
||||
# 규격 매칭
|
||||
if spec in s or s in spec:
|
||||
if matched is None or sooin_item.get('stock', 0) > matched.get('stock', 0):
|
||||
matched = sooin_item
|
||||
|
||||
if matched:
|
||||
stock = matched.get('stock', 0)
|
||||
if stock >= item['order_qty']:
|
||||
status = 'success'
|
||||
result_code = 'OK'
|
||||
result_message = f"[DRY RUN] 주문 가능: 재고 {stock}, 단가 {matched.get('price', 0):,}원"
|
||||
success_count += 1
|
||||
selection_reason = 'stock_available'
|
||||
elif stock > 0:
|
||||
status = 'failed'
|
||||
result_code = 'LOW_STOCK'
|
||||
result_message = f"[DRY RUN] 재고 부족: {stock}개 (요청: {item['order_qty']})"
|
||||
failed_count += 1
|
||||
selection_reason = 'low_stock'
|
||||
else:
|
||||
status = 'failed'
|
||||
result_code = 'OUT_OF_STOCK'
|
||||
result_message = f"[DRY RUN] 재고 없음"
|
||||
failed_count += 1
|
||||
selection_reason = 'out_of_stock'
|
||||
else:
|
||||
status = 'failed'
|
||||
result_code = 'NOT_FOUND'
|
||||
result_message = f"[DRY RUN] 수인에서 규격 {spec} 미발견"
|
||||
failed_count += 1
|
||||
selection_reason = 'not_found'
|
||||
|
||||
update_item_result(item['id'], status, result_code, result_message)
|
||||
|
||||
# AI 학습용 컨텍스트 저장
|
||||
save_order_context(item['id'], {
|
||||
'drug_code': item['drug_code'],
|
||||
'product_name': item['product_name'],
|
||||
'stock_at_order': item.get('current_stock', 0),
|
||||
'usage_7d': item.get('usage_qty', 0),
|
||||
'ordered_spec': spec,
|
||||
'ordered_qty': item['order_qty'],
|
||||
'available_specs': available_specs,
|
||||
'spec_stocks': spec_stocks,
|
||||
'selection_reason': selection_reason,
|
||||
'wholesaler_id': 'sooin'
|
||||
})
|
||||
|
||||
results.append({
|
||||
'item_id': item['id'],
|
||||
'drug_code': item['drug_code'],
|
||||
'product_name': item['product_name'],
|
||||
'specification': spec,
|
||||
'order_qty': item['order_qty'],
|
||||
'status': status,
|
||||
'result_code': result_code,
|
||||
'result_message': result_message,
|
||||
'available_specs': available_specs,
|
||||
'spec_stocks': spec_stocks,
|
||||
'price': matched.get('price') if matched else None
|
||||
})
|
||||
|
||||
# 상태 업데이트
|
||||
if failed_count == 0:
|
||||
update_order_status(order_id, 'completed',
|
||||
f'[DRY RUN] 수인 시뮬레이션 완료: {success_count}개 성공')
|
||||
elif success_count == 0:
|
||||
update_order_status(order_id, 'failed',
|
||||
f'[DRY RUN] 수인 시뮬레이션 완료: {failed_count}개 실패')
|
||||
else:
|
||||
update_order_status(order_id, 'partial',
|
||||
f'[DRY RUN] 수인 부분 성공: {success_count}개 성공, {failed_count}개 실패')
|
||||
|
||||
else:
|
||||
# ─────────────────────────────────────────
|
||||
# 실제 주문
|
||||
# ─────────────────────────────────────────
|
||||
for item in items:
|
||||
kd_code = item.get('kd_code') or item.get('drug_code')
|
||||
order_qty = item['order_qty']
|
||||
spec = item.get('specification', '')
|
||||
internal_code = item.get('internal_code')
|
||||
|
||||
try:
|
||||
# internal_code가 없으면 검색해서 찾기
|
||||
if not internal_code:
|
||||
search_result = sooin_session.search_products(kd_code)
|
||||
if search_result.get('success'):
|
||||
for sooin_item in search_result.get('items', []):
|
||||
if spec in sooin_item.get('spec', '') or sooin_item.get('spec', '') in spec:
|
||||
internal_code = sooin_item.get('internal_code')
|
||||
break
|
||||
|
||||
if not internal_code:
|
||||
raise ValueError(f"내부 코드를 찾을 수 없음: {kd_code} {spec}")
|
||||
|
||||
# 장바구니 추가
|
||||
cart_result = sooin_session.add_to_cart(internal_code, order_qty)
|
||||
|
||||
if cart_result.get('success'):
|
||||
status = 'success'
|
||||
result_code = 'CART_ADDED'
|
||||
result_message = f"장바구니 추가 완료 (확정 필요)"
|
||||
success_count += 1
|
||||
else:
|
||||
status = 'failed'
|
||||
result_code = cart_result.get('error', 'CART_FAILED')
|
||||
result_message = cart_result.get('message', '장바구니 추가 실패')
|
||||
failed_count += 1
|
||||
|
||||
except Exception as e:
|
||||
status = 'failed'
|
||||
result_code = 'ERROR'
|
||||
result_message = str(e)
|
||||
failed_count += 1
|
||||
|
||||
update_item_result(item['id'], status, result_code, result_message)
|
||||
|
||||
save_order_context(item['id'], {
|
||||
'drug_code': item['drug_code'],
|
||||
'product_name': item['product_name'],
|
||||
'stock_at_order': item.get('current_stock', 0),
|
||||
'usage_7d': item.get('usage_qty', 0),
|
||||
'ordered_spec': spec,
|
||||
'ordered_qty': order_qty,
|
||||
'selection_reason': 'user_order',
|
||||
'wholesaler_id': 'sooin',
|
||||
'internal_code': internal_code
|
||||
})
|
||||
|
||||
results.append({
|
||||
'item_id': item['id'],
|
||||
'drug_code': item['drug_code'],
|
||||
'product_name': item['product_name'],
|
||||
'specification': spec,
|
||||
'order_qty': order_qty,
|
||||
'status': status,
|
||||
'result_code': result_code,
|
||||
'result_message': result_message
|
||||
})
|
||||
|
||||
# 주문 확정은 별도로 (장바구니에 담기만 한 상태)
|
||||
if success_count > 0:
|
||||
update_order_status(order_id, 'pending',
|
||||
f'수인 장바구니 추가 완료: {success_count}개 (확정 필요)')
|
||||
else:
|
||||
update_order_status(order_id, 'failed',
|
||||
f'수인 주문 실패: {failed_count}개')
|
||||
|
||||
return {
|
||||
'success': True,
|
||||
'dry_run': dry_run,
|
||||
'order_id': order_id,
|
||||
'order_no': order['order_no'],
|
||||
'wholesaler': 'sooin',
|
||||
'total_items': len(items),
|
||||
'success_count': success_count,
|
||||
'failed_count': failed_count,
|
||||
'results': results,
|
||||
'note': '실제 주문 시 장바구니에 담김. 수인약품 사이트에서 최종 확정 필요.' if not dry_run else None
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"수인 주문 오류: {e}")
|
||||
update_order_status(order_id, 'failed', str(e))
|
||||
return {
|
||||
'success': False,
|
||||
'order_id': order_id,
|
||||
'error': str(e)
|
||||
}
|
||||
|
||||
|
||||
def submit_baekje_order(order: dict, dry_run: bool) -> dict:
|
||||
"""백제약품 주문 제출"""
|
||||
order_id = order['id']
|
||||
items = order['items']
|
||||
|
||||
# 상태 업데이트
|
||||
update_order_status(order_id, 'pending',
|
||||
f'백제약품 주문 시작 (dry_run={dry_run})')
|
||||
|
||||
results = []
|
||||
success_count = 0
|
||||
failed_count = 0
|
||||
|
||||
try:
|
||||
from baekje_api import get_baekje_session
|
||||
baekje_session = get_baekje_session()
|
||||
|
||||
if dry_run:
|
||||
# ─────────────────────────────────────────
|
||||
# DRY RUN: 재고 확인만
|
||||
# ─────────────────────────────────────────
|
||||
for item in items:
|
||||
kd_code = item.get('kd_code') or item.get('drug_code')
|
||||
spec = item.get('specification', '')
|
||||
|
||||
# 재고 검색
|
||||
search_result = baekje_session.search_products(kd_code)
|
||||
|
||||
matched = None
|
||||
available_specs = []
|
||||
spec_stocks = {}
|
||||
|
||||
if search_result.get('success'):
|
||||
for baekje_item in search_result.get('items', []):
|
||||
s = baekje_item.get('spec', '')
|
||||
available_specs.append(s)
|
||||
spec_stocks[s] = baekje_item.get('stock', 0)
|
||||
|
||||
# 규격 매칭
|
||||
if spec in s or s in spec:
|
||||
if matched is None or baekje_item.get('stock', 0) > matched.get('stock', 0):
|
||||
matched = baekje_item
|
||||
|
||||
if matched:
|
||||
stock = matched.get('stock', 0)
|
||||
if stock >= item['order_qty']:
|
||||
status = 'success'
|
||||
result_code = 'OK'
|
||||
result_message = f"[DRY RUN] 주문 가능: 재고 {stock}, 단가 {matched.get('price', 0):,}원"
|
||||
success_count += 1
|
||||
selection_reason = 'stock_available'
|
||||
elif stock > 0:
|
||||
status = 'failed'
|
||||
result_code = 'LOW_STOCK'
|
||||
result_message = f"[DRY RUN] 재고 부족: {stock}개 (요청: {item['order_qty']})"
|
||||
failed_count += 1
|
||||
selection_reason = 'low_stock'
|
||||
else:
|
||||
status = 'failed'
|
||||
result_code = 'OUT_OF_STOCK'
|
||||
result_message = f"[DRY RUN] 재고 없음"
|
||||
failed_count += 1
|
||||
selection_reason = 'out_of_stock'
|
||||
else:
|
||||
status = 'failed'
|
||||
result_code = 'NOT_FOUND'
|
||||
result_message = f"[DRY RUN] 백제에서 규격 {spec} 미발견"
|
||||
failed_count += 1
|
||||
selection_reason = 'not_found'
|
||||
|
||||
update_item_result(item['id'], status, result_code, result_message)
|
||||
|
||||
# AI 학습용 컨텍스트 저장
|
||||
save_order_context(item['id'], {
|
||||
'drug_code': item['drug_code'],
|
||||
'product_name': item['product_name'],
|
||||
'stock_at_order': item.get('current_stock', 0),
|
||||
'usage_7d': item.get('usage_qty', 0),
|
||||
'ordered_spec': spec,
|
||||
'ordered_qty': item['order_qty'],
|
||||
'available_specs': available_specs,
|
||||
'spec_stocks': spec_stocks,
|
||||
'selection_reason': selection_reason,
|
||||
'wholesaler_id': 'baekje'
|
||||
})
|
||||
|
||||
results.append({
|
||||
'item_id': item['id'],
|
||||
'drug_code': item['drug_code'],
|
||||
'product_name': item['product_name'],
|
||||
'specification': spec,
|
||||
'order_qty': item['order_qty'],
|
||||
'status': status,
|
||||
'result_code': result_code,
|
||||
'result_message': result_message,
|
||||
'available_specs': available_specs,
|
||||
'spec_stocks': spec_stocks,
|
||||
'price': matched.get('price') if matched else None
|
||||
})
|
||||
|
||||
# 상태 업데이트
|
||||
if failed_count == 0:
|
||||
update_order_status(order_id, 'completed',
|
||||
f'[DRY RUN] 백제 시뮬레이션 완료: {success_count}개 성공')
|
||||
elif success_count == 0:
|
||||
update_order_status(order_id, 'failed',
|
||||
f'[DRY RUN] 백제 시뮬레이션 완료: {failed_count}개 실패')
|
||||
else:
|
||||
update_order_status(order_id, 'partial',
|
||||
f'[DRY RUN] 백제 부분 성공: {success_count}개 성공, {failed_count}개 실패')
|
||||
|
||||
else:
|
||||
# ─────────────────────────────────────────
|
||||
# 실제 주문 (장바구니 추가)
|
||||
# ─────────────────────────────────────────
|
||||
for item in items:
|
||||
kd_code = item.get('kd_code') or item.get('drug_code')
|
||||
order_qty = item['order_qty']
|
||||
spec = item.get('specification', '')
|
||||
|
||||
try:
|
||||
# 장바구니 추가
|
||||
cart_result = baekje_session.add_to_cart(kd_code, order_qty)
|
||||
|
||||
if cart_result.get('success'):
|
||||
status = 'success'
|
||||
result_code = 'CART_ADDED'
|
||||
result_message = f"장바구니 추가 완료 (백제몰에서 확정 필요)"
|
||||
success_count += 1
|
||||
else:
|
||||
status = 'failed'
|
||||
result_code = cart_result.get('error', 'CART_FAILED')
|
||||
result_message = cart_result.get('message', '장바구니 추가 실패')
|
||||
failed_count += 1
|
||||
|
||||
except Exception as e:
|
||||
status = 'failed'
|
||||
result_code = 'ERROR'
|
||||
result_message = str(e)
|
||||
failed_count += 1
|
||||
|
||||
update_item_result(item['id'], status, result_code, result_message)
|
||||
|
||||
save_order_context(item['id'], {
|
||||
'drug_code': item['drug_code'],
|
||||
'product_name': item['product_name'],
|
||||
'stock_at_order': item.get('current_stock', 0),
|
||||
'usage_7d': item.get('usage_qty', 0),
|
||||
'ordered_spec': spec,
|
||||
'ordered_qty': order_qty,
|
||||
'selection_reason': 'user_order',
|
||||
'wholesaler_id': 'baekje'
|
||||
})
|
||||
|
||||
results.append({
|
||||
'item_id': item['id'],
|
||||
'drug_code': item['drug_code'],
|
||||
'product_name': item['product_name'],
|
||||
'specification': spec,
|
||||
'order_qty': order_qty,
|
||||
'status': status,
|
||||
'result_code': result_code,
|
||||
'result_message': result_message
|
||||
})
|
||||
|
||||
# 상태 업데이트
|
||||
if success_count > 0:
|
||||
update_order_status(order_id, 'pending',
|
||||
f'백제 장바구니 추가 완료: {success_count}개 (확정 필요)')
|
||||
else:
|
||||
update_order_status(order_id, 'failed',
|
||||
f'백제 주문 실패: {failed_count}개')
|
||||
|
||||
return {
|
||||
'success': True,
|
||||
'dry_run': dry_run,
|
||||
'order_id': order_id,
|
||||
'order_no': order['order_no'],
|
||||
'wholesaler': 'baekje',
|
||||
'total_items': len(items),
|
||||
'success_count': success_count,
|
||||
'failed_count': failed_count,
|
||||
'results': results,
|
||||
'note': '실제 주문 시 장바구니에 담김. 백제몰(ibjp.co.kr)에서 최종 확정 필요.' if not dry_run else None
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"백제 주문 오류: {e}")
|
||||
update_order_status(order_id, 'failed', str(e))
|
||||
return {
|
||||
'success': False,
|
||||
'order_id': order_id,
|
||||
'error': str(e)
|
||||
}
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────
|
||||
# AI 학습용 API
|
||||
# ─────────────────────────────────────────────
|
||||
|
||||
@order_bp.route('/ai/training-data', methods=['GET'])
|
||||
def api_ai_training_data():
|
||||
"""
|
||||
AI 학습용 데이터 추출
|
||||
|
||||
GET /api/order/ai/training-data?limit=1000
|
||||
"""
|
||||
limit = int(request.args.get('limit', 1000))
|
||||
data = get_ai_training_data(limit)
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'count': len(data),
|
||||
'data': data
|
||||
})
|
||||
|
||||
|
||||
@order_bp.route('/ai/usage-stats/<drug_code>', methods=['GET'])
|
||||
def api_ai_usage_stats(drug_code):
|
||||
"""
|
||||
약품 사용량 통계 (AI 분석용)
|
||||
|
||||
GET /api/order/ai/usage-stats/670400830?days=30
|
||||
"""
|
||||
days = int(request.args.get('days', 30))
|
||||
stats = get_usage_stats(drug_code, days)
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'stats': stats
|
||||
})
|
||||
|
||||
|
||||
@order_bp.route('/ai/order-pattern/<drug_code>', methods=['GET'])
|
||||
def api_ai_order_pattern(drug_code):
|
||||
"""
|
||||
약품 주문 패턴 조회
|
||||
|
||||
GET /api/order/ai/order-pattern/670400830
|
||||
"""
|
||||
pattern = get_order_pattern(drug_code)
|
||||
|
||||
if pattern:
|
||||
return jsonify({'success': True, 'pattern': pattern})
|
||||
else:
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'pattern': None,
|
||||
'message': '주문 이력이 없습니다'
|
||||
})
|
||||
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()
|
||||
225
backend/paai_feedback.py
Normal file
225
backend/paai_feedback.py
Normal file
@ -0,0 +1,225 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
PAAI 피드백 루프 시스템
|
||||
- 피드백 수집, AI 정제, 프롬프트 인젝션
|
||||
"""
|
||||
|
||||
import sqlite3
|
||||
import os
|
||||
import json
|
||||
from datetime import datetime
|
||||
from flask import Blueprint, request, jsonify
|
||||
|
||||
paai_feedback_bp = Blueprint('paai_feedback', __name__)
|
||||
|
||||
# DB 경로
|
||||
DB_PATH = os.path.join(os.path.dirname(__file__), 'db', 'paai_feedback.db')
|
||||
|
||||
def get_db():
|
||||
"""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():
|
||||
"""테이블 초기화"""
|
||||
conn = get_db()
|
||||
conn.execute('''
|
||||
CREATE TABLE IF NOT EXISTS paai_feedback (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
-- 컨텍스트
|
||||
prescription_id TEXT,
|
||||
patient_name TEXT,
|
||||
patient_context TEXT,
|
||||
|
||||
-- PAAI 응답
|
||||
paai_request TEXT,
|
||||
paai_response TEXT,
|
||||
|
||||
-- 피드백
|
||||
rating TEXT,
|
||||
category TEXT,
|
||||
pharmacist_comment TEXT,
|
||||
|
||||
-- AI 정제 결과
|
||||
refined_rule TEXT,
|
||||
confidence REAL,
|
||||
|
||||
-- 적용 상태
|
||||
applied_to_prompt INTEGER DEFAULT 0,
|
||||
applied_to_training INTEGER DEFAULT 0
|
||||
)
|
||||
''')
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
# 앱 시작 시 테이블 생성
|
||||
init_db()
|
||||
|
||||
|
||||
@paai_feedback_bp.route('/api/paai/feedback', methods=['POST'])
|
||||
def submit_feedback():
|
||||
"""피드백 제출"""
|
||||
try:
|
||||
data = request.json
|
||||
|
||||
conn = get_db()
|
||||
cursor = conn.cursor()
|
||||
|
||||
cursor.execute('''
|
||||
INSERT INTO paai_feedback (
|
||||
prescription_id, patient_name, patient_context,
|
||||
paai_request, paai_response,
|
||||
rating, category, pharmacist_comment
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
''', (
|
||||
data.get('prescription_id'),
|
||||
data.get('patient_name'),
|
||||
json.dumps(data.get('patient_context', {}), ensure_ascii=False),
|
||||
data.get('paai_request'),
|
||||
data.get('paai_response'),
|
||||
data.get('rating'), # 'good' or 'bad'
|
||||
data.get('category'), # 'interaction', 'indication', 'dosage', 'other'
|
||||
data.get('pharmacist_comment')
|
||||
))
|
||||
|
||||
feedback_id = cursor.lastrowid
|
||||
conn.commit()
|
||||
|
||||
# bad 피드백이고 코멘트가 있으면 AI 정제 시도
|
||||
if data.get('rating') == 'bad' and data.get('pharmacist_comment'):
|
||||
refined = refine_feedback_async(feedback_id, data)
|
||||
if refined:
|
||||
cursor.execute('''
|
||||
UPDATE paai_feedback
|
||||
SET refined_rule = ?, confidence = ?
|
||||
WHERE id = ?
|
||||
''', (refined['rule'], refined['confidence'], feedback_id))
|
||||
conn.commit()
|
||||
|
||||
conn.close()
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'feedback_id': feedback_id,
|
||||
'message': '피드백이 저장되었습니다.'
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
|
||||
def refine_feedback_async(feedback_id, data):
|
||||
"""피드백을 규칙으로 정제 (동기 버전 - 나중에 비동기로 변경 가능)"""
|
||||
try:
|
||||
# TODO: AI 호출로 정제
|
||||
# 지금은 간단히 코멘트를 규칙 형태로 저장
|
||||
comment = data.get('pharmacist_comment', '')
|
||||
if not comment:
|
||||
return None
|
||||
|
||||
# 간단한 규칙 형태로 변환
|
||||
category = data.get('category', 'other')
|
||||
rule = f"[{category}] {comment}"
|
||||
|
||||
return {
|
||||
'rule': rule,
|
||||
'confidence': 0.8 # 기본 신뢰도
|
||||
}
|
||||
except:
|
||||
return None
|
||||
|
||||
|
||||
@paai_feedback_bp.route('/api/paai/feedback/rules', methods=['GET'])
|
||||
def get_feedback_rules():
|
||||
"""축적된 피드백 규칙 조회 (프롬프트 인젝션용)"""
|
||||
try:
|
||||
conn = get_db()
|
||||
cursor = conn.cursor()
|
||||
|
||||
# bad 피드백 중 정제된 규칙만
|
||||
cursor.execute('''
|
||||
SELECT refined_rule, category, created_at
|
||||
FROM paai_feedback
|
||||
WHERE rating = 'bad'
|
||||
AND refined_rule IS NOT NULL
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 20
|
||||
''')
|
||||
|
||||
rules = []
|
||||
for row in cursor.fetchall():
|
||||
rules.append({
|
||||
'rule': row['refined_rule'],
|
||||
'category': row['category'],
|
||||
'created_at': row['created_at']
|
||||
})
|
||||
|
||||
conn.close()
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'rules': rules,
|
||||
'count': len(rules)
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
|
||||
@paai_feedback_bp.route('/api/paai/feedback/stats', methods=['GET'])
|
||||
def get_feedback_stats():
|
||||
"""피드백 통계"""
|
||||
try:
|
||||
conn = get_db()
|
||||
cursor = conn.cursor()
|
||||
|
||||
cursor.execute('''
|
||||
SELECT
|
||||
COUNT(*) as total,
|
||||
SUM(CASE WHEN rating = 'good' THEN 1 ELSE 0 END) as good,
|
||||
SUM(CASE WHEN rating = 'bad' THEN 1 ELSE 0 END) as bad
|
||||
FROM paai_feedback
|
||||
''')
|
||||
row = cursor.fetchone()
|
||||
|
||||
conn.close()
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'stats': {
|
||||
'total': row['total'] or 0,
|
||||
'good': row['good'] or 0,
|
||||
'bad': row['bad'] or 0
|
||||
}
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
|
||||
def get_rules_for_prompt(patient_context=None):
|
||||
"""프롬프트에 주입할 규칙 목록 반환"""
|
||||
try:
|
||||
conn = get_db()
|
||||
cursor = conn.cursor()
|
||||
|
||||
# 최근 규칙 20개
|
||||
cursor.execute('''
|
||||
SELECT refined_rule
|
||||
FROM paai_feedback
|
||||
WHERE rating = 'bad'
|
||||
AND refined_rule IS NOT NULL
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 20
|
||||
''')
|
||||
|
||||
rules = [row['refined_rule'] for row in cursor.fetchall()]
|
||||
conn.close()
|
||||
|
||||
return rules
|
||||
except:
|
||||
return []
|
||||
159
backend/paai_printer.py
Normal file
159
backend/paai_printer.py
Normal file
@ -0,0 +1,159 @@
|
||||
"""PAAI ESC/POS 프린터 모듈"""
|
||||
import json
|
||||
from datetime import datetime
|
||||
from escpos.printer import Network
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
|
||||
# 프린터 설정
|
||||
PRINTER_IP = "192.168.0.174"
|
||||
PRINTER_PORT = 9100
|
||||
THERMAL_WIDTH = 576
|
||||
|
||||
|
||||
def print_paai_result(pre_serial: str, patient_name: str, analysis: dict, kims_summary: dict) -> dict:
|
||||
"""PAAI 분석 결과 인쇄"""
|
||||
try:
|
||||
# 이미지 생성
|
||||
img = create_receipt_image(pre_serial, patient_name, analysis, kims_summary)
|
||||
|
||||
# 프린터 연결 및 출력
|
||||
p = Network(PRINTER_IP, port=PRINTER_PORT, timeout=15)
|
||||
p.image(img)
|
||||
p.text('\n\n\n')
|
||||
p.cut()
|
||||
|
||||
return {'success': True, 'message': '인쇄 완료'}
|
||||
except Exception as e:
|
||||
return {'success': False, 'error': str(e)}
|
||||
|
||||
|
||||
def create_receipt_image(pre_serial: str, patient_name: str, analysis: dict, kims_summary: dict) -> Image:
|
||||
"""영수증 이미지 생성"""
|
||||
# 폰트
|
||||
try:
|
||||
font_title = ImageFont.truetype('malgun.ttf', 28)
|
||||
font_section = ImageFont.truetype('malgunbd.ttf', 20)
|
||||
font_normal = ImageFont.truetype('malgun.ttf', 18)
|
||||
font_small = ImageFont.truetype('malgun.ttf', 15)
|
||||
except:
|
||||
font_title = ImageFont.load_default()
|
||||
font_section = font_title
|
||||
font_normal = font_title
|
||||
font_small = font_title
|
||||
|
||||
width = THERMAL_WIDTH
|
||||
padding = 20
|
||||
y = padding
|
||||
|
||||
# 이미지 생성
|
||||
img = Image.new('RGB', (width, 1000), 'white')
|
||||
draw = ImageDraw.Draw(img)
|
||||
|
||||
# 헤더
|
||||
draw.text((width//2, y), 'PAAI 복약안내', font=font_title, fill='black', anchor='mt')
|
||||
y += 40
|
||||
draw.line([(padding, y), (width-padding, y)], fill='black', width=1)
|
||||
y += 15
|
||||
|
||||
# 환자 정보
|
||||
draw.text((padding, y), f'환자: {patient_name}', font=font_normal, fill='black')
|
||||
y += 25
|
||||
draw.text((padding, y), f'처방번호: {pre_serial}', font=font_small, fill='black')
|
||||
y += 20
|
||||
now_str = datetime.now().strftime("%Y-%m-%d %H:%M")
|
||||
draw.text((padding, y), f'출력: {now_str}', font=font_small, fill='black')
|
||||
y += 25
|
||||
draw.line([(padding, y), (width-padding, y)], fill='black', width=1)
|
||||
y += 15
|
||||
|
||||
# 상호작용
|
||||
interaction_count = kims_summary.get('interaction_count', 0)
|
||||
has_severe = kims_summary.get('has_severe', False)
|
||||
|
||||
if has_severe:
|
||||
draw.text((padding, y), '[주의] 중증 상호작용 있음!', font=font_section, fill='black')
|
||||
elif interaction_count > 0:
|
||||
draw.text((padding, y), f'약물 상호작용: {interaction_count}건', font=font_normal, fill='black')
|
||||
else:
|
||||
draw.text((padding, y), '상호작용 없음', font=font_normal, fill='black')
|
||||
y += 30
|
||||
|
||||
# 처방 해석
|
||||
insight = analysis.get('prescription_insight', '')
|
||||
if insight:
|
||||
draw.text((padding, y), '[처방 해석]', font=font_section, fill='black')
|
||||
y += 28
|
||||
for line in wrap_text(insight, 40)[:3]:
|
||||
draw.text((padding, y), line, font=font_small, fill='black')
|
||||
y += 20
|
||||
y += 10
|
||||
|
||||
# 주의사항
|
||||
cautions = analysis.get('cautions', [])
|
||||
if cautions:
|
||||
draw.text((padding, y), '[복용 주의사항]', font=font_section, fill='black')
|
||||
y += 28
|
||||
for i, c in enumerate(cautions[:3], 1):
|
||||
for line in wrap_text(f'{i}. {c}', 40)[:2]:
|
||||
draw.text((padding, y), line, font=font_small, fill='black')
|
||||
y += 20
|
||||
y += 10
|
||||
|
||||
# 상담 포인트
|
||||
counseling = analysis.get('counseling_points', [])
|
||||
if counseling:
|
||||
draw.text((padding, y), '[상담 포인트]', font=font_section, fill='black')
|
||||
y += 28
|
||||
for i, c in enumerate(counseling[:2], 1):
|
||||
for line in wrap_text(f'{i}. {c}', 40)[:2]:
|
||||
draw.text((padding, y), line, font=font_small, fill='black')
|
||||
y += 20
|
||||
y += 10
|
||||
|
||||
# 푸터
|
||||
y += 10
|
||||
draw.line([(padding, y), (width-padding, y)], fill='black', width=1)
|
||||
y += 15
|
||||
draw.text((width//2, y), '양구청춘약국 PAAI', font=font_small, fill='black', anchor='mt')
|
||||
|
||||
return img.crop((0, 0, width, y + 30))
|
||||
|
||||
|
||||
def wrap_text(text: str, max_chars: int = 40) -> list:
|
||||
"""텍스트 줄바꿈"""
|
||||
lines = []
|
||||
words = text.split()
|
||||
current = ""
|
||||
|
||||
for word in words:
|
||||
if len(current) + len(word) + 1 <= max_chars:
|
||||
current = current + " " + word if current else word
|
||||
else:
|
||||
if current:
|
||||
lines.append(current)
|
||||
current = word
|
||||
|
||||
if current:
|
||||
lines.append(current)
|
||||
|
||||
return lines if lines else [text[:max_chars]]
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
# CLI 테스트
|
||||
import sys
|
||||
if len(sys.argv) > 1:
|
||||
pre_serial = sys.argv[1]
|
||||
else:
|
||||
pre_serial = '20260305000075'
|
||||
|
||||
# 테스트 데이터
|
||||
analysis = {
|
||||
'prescription_insight': '테스트 처방입니다.',
|
||||
'cautions': ['주의사항 1', '주의사항 2'],
|
||||
'counseling_points': ['상담 포인트 1']
|
||||
}
|
||||
kims_summary = {'interaction_count': 0, 'has_severe': False}
|
||||
|
||||
result = print_paai_result(pre_serial, '테스트환자', analysis, kims_summary)
|
||||
print(result)
|
||||
201
backend/paai_printer_cli.py
Normal file
201
backend/paai_printer_cli.py
Normal file
@ -0,0 +1,201 @@
|
||||
"""PAAI ESC/POS 프린터 CLI - EUC-KR 텍스트 방식"""
|
||||
import sys
|
||||
import json
|
||||
import socket
|
||||
from datetime import datetime
|
||||
|
||||
# 프린터 설정
|
||||
PRINTER_IP = "192.168.0.174"
|
||||
PRINTER_PORT = 9100
|
||||
|
||||
# ESC/POS 명령어
|
||||
ESC = b'\x1b'
|
||||
INIT = ESC + b'@' # 프린터 초기화
|
||||
CUT = ESC + b'd\x03' # 피드 + 커트
|
||||
|
||||
|
||||
def print_raw(data: bytes) -> bool:
|
||||
"""바이트 데이터를 프린터로 전송"""
|
||||
try:
|
||||
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
sock.settimeout(10)
|
||||
sock.connect((PRINTER_IP, PRINTER_PORT))
|
||||
sock.sendall(data)
|
||||
sock.close()
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"프린터 오류: {e}", file=sys.stderr)
|
||||
return False
|
||||
|
||||
|
||||
def wrap_text(text: str, width: int = 44) -> list:
|
||||
"""텍스트 줄바꿈 (44자 기준, 들여쓰기 고려)"""
|
||||
if not text:
|
||||
return []
|
||||
|
||||
lines = []
|
||||
words = text.split()
|
||||
current = ""
|
||||
|
||||
for word in words:
|
||||
if len(current) + len(word) + 1 <= width:
|
||||
current = current + " " + word if current else word
|
||||
else:
|
||||
if current:
|
||||
lines.append(current)
|
||||
current = word
|
||||
|
||||
if current:
|
||||
lines.append(current)
|
||||
|
||||
return lines if lines else [text[:width]]
|
||||
|
||||
|
||||
def center_text(text: str, width: int = 48) -> str:
|
||||
"""중앙 정렬"""
|
||||
text_len = len(text)
|
||||
if text_len >= width:
|
||||
return text
|
||||
spaces = (width - text_len) // 2
|
||||
return " " * spaces + text
|
||||
|
||||
|
||||
def format_paai_receipt(pre_serial: str, patient_name: str,
|
||||
analysis: dict, kims_summary: dict) -> str:
|
||||
"""PAAI 복약안내 영수증 텍스트 생성 (48자 기준)"""
|
||||
|
||||
LINE = "=" * 48
|
||||
THIN = "-" * 48
|
||||
now = datetime.now().strftime("%Y-%m-%d %H:%M")
|
||||
|
||||
# 헤더
|
||||
msg = f"\n{LINE}\n"
|
||||
msg += center_text("[ PAAI 복약안내 ]") + "\n"
|
||||
msg += f"{LINE}\n"
|
||||
|
||||
# 환자 정보
|
||||
msg += f"환자: {patient_name}\n"
|
||||
msg += f"처방번호: {pre_serial}\n"
|
||||
msg += f"출력: {now}\n"
|
||||
msg += f"{THIN}\n"
|
||||
|
||||
# 상호작용 요약
|
||||
interaction_count = kims_summary.get('interaction_count', 0)
|
||||
has_severe = kims_summary.get('has_severe', False)
|
||||
|
||||
if has_severe:
|
||||
msg += "[!!] 중증 상호작용 있음!\n"
|
||||
elif interaction_count > 0:
|
||||
msg += f"[!] 약물 상호작용: {interaction_count}건\n"
|
||||
else:
|
||||
msg += "[V] 상호작용 없음\n"
|
||||
msg += "\n"
|
||||
|
||||
# 처방 해석
|
||||
insight = analysis.get('prescription_insight', '')
|
||||
if insight:
|
||||
msg += f"{THIN}\n"
|
||||
msg += ">> 처방 해석\n"
|
||||
for line in wrap_text(insight, 44):
|
||||
msg += f" {line}\n"
|
||||
msg += "\n"
|
||||
|
||||
# 복용 주의사항
|
||||
cautions = analysis.get('cautions', [])
|
||||
if cautions:
|
||||
msg += f"{THIN}\n"
|
||||
msg += ">> 복용 주의사항\n"
|
||||
for i, caution in enumerate(cautions[:4], 1):
|
||||
# 첫 줄
|
||||
first_line = True
|
||||
for line in wrap_text(f"{i}. {caution}", 44):
|
||||
if first_line:
|
||||
msg += f" {line}\n"
|
||||
first_line = False
|
||||
else:
|
||||
msg += f" {line}\n"
|
||||
msg += "\n"
|
||||
|
||||
# 상담 포인트
|
||||
counseling = analysis.get('counseling_points', [])
|
||||
if counseling:
|
||||
msg += f"{THIN}\n"
|
||||
msg += ">> 상담 포인트\n"
|
||||
for i, point in enumerate(counseling[:3], 1):
|
||||
first_line = True
|
||||
for line in wrap_text(f"{i}. {point}", 44):
|
||||
if first_line:
|
||||
msg += f" {line}\n"
|
||||
first_line = False
|
||||
else:
|
||||
msg += f" {line}\n"
|
||||
msg += "\n"
|
||||
|
||||
# OTC 추천
|
||||
otc_recs = analysis.get('otc_recommendations', [])
|
||||
if otc_recs:
|
||||
msg += f"{THIN}\n"
|
||||
msg += ">> OTC 추천\n"
|
||||
for rec in otc_recs[:2]:
|
||||
product = rec.get('product', '')
|
||||
reason = rec.get('reason', '')
|
||||
msg += f" - {product}\n"
|
||||
for line in wrap_text(reason, 42):
|
||||
msg += f" {line}\n"
|
||||
msg += "\n"
|
||||
|
||||
# 푸터
|
||||
msg += f"{LINE}\n"
|
||||
msg += center_text("양구청춘약국 PAAI") + "\n"
|
||||
msg += center_text("Tel: 033-481-5222") + "\n"
|
||||
msg += "\n"
|
||||
|
||||
return msg
|
||||
|
||||
|
||||
def print_paai_receipt(data: dict) -> bool:
|
||||
"""PAAI 영수증 인쇄"""
|
||||
try:
|
||||
pre_serial = data.get('pre_serial', '')
|
||||
patient_name = data.get('patient_name', '')
|
||||
analysis = data.get('analysis', {})
|
||||
kims_summary = data.get('kims_summary', {})
|
||||
|
||||
# 텍스트 생성
|
||||
message = format_paai_receipt(pre_serial, patient_name, analysis, kims_summary)
|
||||
|
||||
# EUC-KR 인코딩 (한글 지원)
|
||||
text_bytes = message.encode('euc-kr', errors='replace')
|
||||
|
||||
# 명령어 조합
|
||||
command = INIT + text_bytes + b'\n\n\n' + CUT
|
||||
|
||||
return print_raw(command)
|
||||
except Exception as e:
|
||||
print(f"인쇄 오류: {e}", file=sys.stderr)
|
||||
return False
|
||||
|
||||
|
||||
def main():
|
||||
if len(sys.argv) < 2:
|
||||
print("사용법: python paai_printer_cli.py <json_file>", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
json_path = sys.argv[1]
|
||||
|
||||
try:
|
||||
with open(json_path, 'r', encoding='utf-8') as f:
|
||||
data = json.load(f)
|
||||
|
||||
if print_paai_receipt(data):
|
||||
print("인쇄 완료")
|
||||
sys.exit(0)
|
||||
else:
|
||||
sys.exit(1)
|
||||
except Exception as e:
|
||||
print(f"오류: {e}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@ -38,6 +38,119 @@ def get_mssql_connection(database='PM_PRES'):
|
||||
return pyodbc.connect(conn_str, timeout=10)
|
||||
|
||||
|
||||
def warmup_db_connection():
|
||||
"""앱 시작 시 DB 연결 미리 생성 (첫 요청 속도 개선)"""
|
||||
try:
|
||||
conn = get_mssql_connection('PM_PRES')
|
||||
conn.cursor().execute("SELECT 1")
|
||||
conn.close()
|
||||
logging.info("[PMR] DB 연결 warmup 완료")
|
||||
except Exception as e:
|
||||
logging.warning(f"[PMR] DB warmup 실패: {e}")
|
||||
|
||||
|
||||
# 앱 로드 시 warmup 실행
|
||||
warmup_db_connection()
|
||||
|
||||
|
||||
def enrich_medications(medications: list) -> list:
|
||||
"""
|
||||
약품 목록에 성분/분류/상호작용/금기 정보 추가 (PAAI용)
|
||||
|
||||
CD_SUNG: 성분 정보
|
||||
CD_MC: 분류(PRINT_TYPE), 상호작용(INTERACTION), 금기(CONTRA)
|
||||
"""
|
||||
if not medications:
|
||||
return medications
|
||||
|
||||
try:
|
||||
conn = get_mssql_connection('PM_DRUG')
|
||||
cursor = conn.cursor()
|
||||
|
||||
# DrugCode 목록 추출
|
||||
drug_codes = [m.get('code') or m.get('medication_code') for m in medications if m.get('code') or m.get('medication_code')]
|
||||
|
||||
if not drug_codes:
|
||||
conn.close()
|
||||
return medications
|
||||
|
||||
# 1. CD_MC에서 분류/상호작용/금기 조회
|
||||
placeholders = ','.join(['?' for _ in drug_codes])
|
||||
cursor.execute(f"""
|
||||
SELECT
|
||||
DRUGCODE,
|
||||
PRINT_TYPE,
|
||||
INTERACTION,
|
||||
CONTRA
|
||||
FROM CD_MC
|
||||
WHERE DRUGCODE IN ({placeholders})
|
||||
""", drug_codes)
|
||||
|
||||
mc_info = {}
|
||||
for row in cursor.fetchall():
|
||||
mc_info[row.DRUGCODE] = {
|
||||
'print_type': row.PRINT_TYPE or '',
|
||||
'interaction': (row.INTERACTION or '')[:500], # 너무 길면 자르기
|
||||
'contra': (row.CONTRA or '')[:300]
|
||||
}
|
||||
|
||||
# 2. CD_GOODS에서 SUNG_CODE 조회
|
||||
cursor.execute(f"""
|
||||
SELECT DrugCode, SUNG_CODE
|
||||
FROM CD_GOODS
|
||||
WHERE DrugCode IN ({placeholders}) AND SUNG_CODE IS NOT NULL
|
||||
""", drug_codes)
|
||||
|
||||
sung_codes = {}
|
||||
for row in cursor.fetchall():
|
||||
if row.SUNG_CODE:
|
||||
sung_codes[row.DrugCode] = row.SUNG_CODE
|
||||
|
||||
# 3. CD_SUNG에서 성분 정보 조회
|
||||
components_by_drug = {}
|
||||
if sung_codes:
|
||||
unique_sung_codes = list(set(sung_codes.values()))
|
||||
placeholders2 = ','.join(['?' for _ in unique_sung_codes])
|
||||
cursor.execute(f"""
|
||||
SELECT SUNG_CODE, SUNG_HNM
|
||||
FROM CD_SUNG
|
||||
WHERE SUNG_CODE IN ({placeholders2})
|
||||
""", unique_sung_codes)
|
||||
|
||||
# SUNG_CODE별 성분 목록
|
||||
sung_components = {}
|
||||
for row in cursor.fetchall():
|
||||
if row.SUNG_CODE not in sung_components:
|
||||
sung_components[row.SUNG_CODE] = []
|
||||
sung_components[row.SUNG_CODE].append(row.SUNG_HNM)
|
||||
|
||||
# DrugCode별로 매핑
|
||||
for drug_code, sung_code in sung_codes.items():
|
||||
components_by_drug[drug_code] = sung_components.get(sung_code, [])
|
||||
|
||||
conn.close()
|
||||
|
||||
# 4. medications에 정보 추가
|
||||
for med in medications:
|
||||
code = med.get('code') or med.get('medication_code')
|
||||
if code:
|
||||
# MC 정보
|
||||
if code in mc_info:
|
||||
med['print_type'] = mc_info[code]['print_type']
|
||||
med['interaction_info'] = mc_info[code]['interaction']
|
||||
med['contra_info'] = mc_info[code]['contra']
|
||||
|
||||
# 성분 정보
|
||||
if code in components_by_drug:
|
||||
med['components'] = components_by_drug[code]
|
||||
|
||||
return medications
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"[PAAI] Medication enrichment 오류: {e}")
|
||||
return medications
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# 조제관리 페이지
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
@ -188,7 +301,10 @@ def get_prescription_detail(prescription_id):
|
||||
return jsonify({'success': False, 'error': '처방전을 찾을 수 없습니다'}), 404
|
||||
|
||||
# 처방 약품 목록 (PS_sub_pharm + CD_GOODS + CD_MC JOIN)
|
||||
# PS_Type: 0,1=일반, 4=대체조제(실제), 9=대체조제(원본)
|
||||
medications = []
|
||||
original_prescriptions = {} # PS_Type=9인 원본 처방 저장
|
||||
|
||||
cursor.execute("""
|
||||
SELECT
|
||||
s.DrugCode,
|
||||
@ -197,6 +313,8 @@ def get_prescription_detail(prescription_id):
|
||||
s.QUAN_TIME,
|
||||
s.PS_Type,
|
||||
s.INV_QUAN,
|
||||
s.SUB_SERIAL,
|
||||
s.UnitCode,
|
||||
g.GoodsName,
|
||||
g.SUNG_CODE,
|
||||
m.PRINT_TYPE,
|
||||
@ -208,10 +326,33 @@ def get_prescription_detail(prescription_id):
|
||||
ORDER BY s.SUB_SERIAL
|
||||
""", (prescription_id,))
|
||||
|
||||
for row in cursor.fetchall():
|
||||
all_rows = cursor.fetchall()
|
||||
|
||||
# 1차: PS_Type=9 (원본 처방) 수집 - 인덱스로 저장
|
||||
for i, row in enumerate(all_rows):
|
||||
if row.PS_Type == '9':
|
||||
original_prescriptions[i] = {
|
||||
'drug_code': row.DrugCode or '',
|
||||
'drug_name': row.GoodsName or row.DrugCode or '',
|
||||
'add_info': row.PRINT_TYPE or row.SIM_EFFECT or ''
|
||||
}
|
||||
|
||||
# 2차: 실제 조제약만 추가 (PS_Type != 9)
|
||||
for i, row in enumerate(all_rows):
|
||||
if row.PS_Type == '9':
|
||||
continue # 원본 처방은 스킵
|
||||
|
||||
# 효능: PRINT_TYPE > SIM_EFFECT > 없음
|
||||
add_info = row.PRINT_TYPE or row.SIM_EFFECT or ''
|
||||
|
||||
# 대체조제 여부 확인: PS_Type=4이고 바로 다음이 PS_Type=9
|
||||
# 순서: 4(대체) → 9(원본)
|
||||
is_substituted = row.PS_Type == '4' and (i + 1) in original_prescriptions
|
||||
original_drug = original_prescriptions.get(i + 1) if is_substituted else None
|
||||
|
||||
# UnitCode: 1=보험, 2=비보험, 3=100/100, 4~7=급여(본인부담률)
|
||||
unit_code = int(row.UnitCode) if row.UnitCode else 1
|
||||
|
||||
medications.append({
|
||||
'medication_code': row.DrugCode or '',
|
||||
'med_name': row.GoodsName or row.DrugCode or '',
|
||||
@ -221,7 +362,11 @@ def get_prescription_detail(prescription_id):
|
||||
'duration': row.Days or 0,
|
||||
'total_qty': float(row.INV_QUAN) if row.INV_QUAN else 0,
|
||||
'type': '급여' if row.PS_Type in ['0', '4'] else '비급여' if row.PS_Type == '1' else row.PS_Type,
|
||||
'sung_code': row.SUNG_CODE or ''
|
||||
'sung_code': row.SUNG_CODE or '',
|
||||
'ps_type': row.PS_Type or '0',
|
||||
'unit_code': unit_code,
|
||||
'is_substituted': is_substituted,
|
||||
'original_drug': original_drug
|
||||
})
|
||||
|
||||
# 나이/성별 계산
|
||||
@ -615,18 +760,21 @@ def get_patient_history(cus_code):
|
||||
pre_serial = row.PreSerial
|
||||
|
||||
# 해당 처방의 약품 목록 조회
|
||||
# PS_Type=9 (대체조제 원처방)는 제외
|
||||
cursor.execute("""
|
||||
SELECT
|
||||
s.DrugCode,
|
||||
s.Days,
|
||||
s.QUAN,
|
||||
s.QUAN_TIME,
|
||||
s.PS_Type,
|
||||
g.GoodsName,
|
||||
m.PRINT_TYPE
|
||||
FROM PS_sub_pharm s
|
||||
LEFT JOIN PM_DRUG.dbo.CD_GOODS g ON s.DrugCode = g.DrugCode
|
||||
LEFT JOIN PM_DRUG.dbo.CD_MC m ON s.DrugCode = m.DRUGCODE
|
||||
WHERE s.PreSerial = ?
|
||||
AND (s.PS_Type IS NULL OR s.PS_Type != '9')
|
||||
ORDER BY s.SUB_SERIAL
|
||||
""", (pre_serial,))
|
||||
|
||||
@ -638,7 +786,8 @@ def get_patient_history(cus_code):
|
||||
'add_info': med_row.PRINT_TYPE or '',
|
||||
'dosage': float(med_row.QUAN) if med_row.QUAN else 0,
|
||||
'frequency': med_row.QUAN_TIME or 0,
|
||||
'duration': med_row.Days or 0
|
||||
'duration': med_row.Days or 0,
|
||||
'ps_type': med_row.PS_Type or '0'
|
||||
})
|
||||
|
||||
# 날짜 포맷
|
||||
@ -694,36 +843,69 @@ def get_patient_otc_history(cus_code):
|
||||
conn = get_mssql_connection('PM_PRES')
|
||||
cursor = conn.cursor()
|
||||
|
||||
# OTC 거래 목록 조회 (PRESERIAL = 'V' = OTC 판매)
|
||||
# ✅ 최적화: 한번의 쿼리로 거래 + 품목 모두 조회 (JOIN)
|
||||
cursor.execute("""
|
||||
SELECT
|
||||
m.SL_NO_order,
|
||||
m.SL_DT_appl,
|
||||
m.InsertTime,
|
||||
m.SL_MY_sale,
|
||||
m.SL_NM_custom
|
||||
FROM SALE_MAIN m
|
||||
WHERE m.SL_CD_custom = ?
|
||||
AND m.PRESERIAL = 'V'
|
||||
ORDER BY m.InsertTime DESC
|
||||
""", (cus_code,))
|
||||
m.SL_NM_custom,
|
||||
s.DrugCode,
|
||||
g.GoodsName,
|
||||
s.SL_NM_item,
|
||||
s.SL_TOTAL_PRICE,
|
||||
mc.PRINT_TYPE
|
||||
FROM (
|
||||
SELECT TOP (?) *
|
||||
FROM SALE_MAIN
|
||||
WHERE SL_CD_custom = ? AND PRESERIAL = 'V'
|
||||
ORDER BY InsertTime DESC
|
||||
) m
|
||||
LEFT JOIN SALE_SUB s ON m.SL_NO_order = s.SL_NO_order
|
||||
LEFT JOIN PM_DRUG.dbo.CD_GOODS g ON s.DrugCode = g.DrugCode
|
||||
LEFT JOIN PM_DRUG.dbo.CD_MC mc ON s.DrugCode = mc.DRUGCODE
|
||||
ORDER BY m.InsertTime DESC, s.DrugCode
|
||||
""", (limit, cus_code))
|
||||
|
||||
# 결과를 order_no별로 그룹핑
|
||||
orders_dict = {}
|
||||
all_drug_codes = []
|
||||
|
||||
# 먼저 거래 목록 수집
|
||||
orders = []
|
||||
for row in cursor.fetchall():
|
||||
orders.append({
|
||||
'order_no': row.SL_NO_order,
|
||||
'date': row.SL_DT_appl,
|
||||
'datetime': row.InsertTime.strftime('%Y-%m-%d %H:%M') if row.InsertTime else '',
|
||||
'amount': int(row.SL_MY_sale or 0),
|
||||
'customer_name': row.SL_NM_custom or ''
|
||||
})
|
||||
order_no = row.SL_NO_order
|
||||
|
||||
if order_no not in orders_dict:
|
||||
orders_dict[order_no] = {
|
||||
'order_no': order_no,
|
||||
'date': row.SL_DT_appl,
|
||||
'datetime': row.InsertTime.strftime('%Y-%m-%d %H:%M') if row.InsertTime else '',
|
||||
'amount': int(row.SL_MY_sale or 0),
|
||||
'customer_name': row.SL_NM_custom or '',
|
||||
'items': []
|
||||
}
|
||||
|
||||
# 품목 추가 (DrugCode가 있는 경우만)
|
||||
if row.DrugCode:
|
||||
drug_code = row.DrugCode
|
||||
all_drug_codes.append(drug_code)
|
||||
orders_dict[order_no]['items'].append({
|
||||
'drug_code': drug_code,
|
||||
'name': row.GoodsName or drug_code,
|
||||
'quantity': int(row.SL_NM_item or 0),
|
||||
'price': int(row.SL_TOTAL_PRICE or 0),
|
||||
'category': row.PRINT_TYPE or '',
|
||||
'image': None
|
||||
})
|
||||
|
||||
# 최근 limit개만
|
||||
orders = orders[:limit]
|
||||
conn.close()
|
||||
|
||||
if not orders:
|
||||
conn.close()
|
||||
# dict → list 변환
|
||||
purchases = list(orders_dict.values())
|
||||
for p in purchases:
|
||||
p['item_count'] = len(p['items'])
|
||||
|
||||
if not purchases:
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'cus_code': cus_code,
|
||||
@ -731,46 +913,6 @@ def get_patient_otc_history(cus_code):
|
||||
'purchases': []
|
||||
})
|
||||
|
||||
# 각 거래의 품목 조회
|
||||
purchases = []
|
||||
all_drug_codes = []
|
||||
|
||||
for order in orders:
|
||||
cursor.execute("""
|
||||
SELECT
|
||||
s.DrugCode,
|
||||
g.GoodsName,
|
||||
s.SL_NM_item,
|
||||
s.SL_TOTAL_PRICE,
|
||||
mc.PRINT_TYPE
|
||||
FROM SALE_SUB s
|
||||
LEFT JOIN PM_DRUG.dbo.CD_GOODS g ON s.DrugCode = g.DrugCode
|
||||
LEFT JOIN PM_DRUG.dbo.CD_MC mc ON s.DrugCode = mc.DRUGCODE
|
||||
WHERE s.SL_NO_order = ?
|
||||
ORDER BY s.DrugCode
|
||||
""", (order['order_no'],))
|
||||
|
||||
items = []
|
||||
for item_row in cursor.fetchall():
|
||||
drug_code = item_row.DrugCode or ''
|
||||
all_drug_codes.append(drug_code)
|
||||
items.append({
|
||||
'drug_code': drug_code,
|
||||
'name': item_row.GoodsName or drug_code,
|
||||
'quantity': int(item_row.SL_NM_item or 0),
|
||||
'price': int(item_row.SL_TOTAL_PRICE or 0),
|
||||
'category': item_row.PRINT_TYPE or '',
|
||||
'image': None
|
||||
})
|
||||
|
||||
purchases.append({
|
||||
**order,
|
||||
'items': items,
|
||||
'item_count': len(items)
|
||||
})
|
||||
|
||||
conn.close()
|
||||
|
||||
# 제품 이미지 조회 (product_images.db)
|
||||
image_map = {}
|
||||
try:
|
||||
@ -975,12 +1117,16 @@ def paai_analyze():
|
||||
pre_serial = data.get('pre_serial')
|
||||
cus_code = data.get('cus_code')
|
||||
patient_name = data.get('patient_name')
|
||||
patient_note = data.get('patient_note', '') # 환자 특이사항 (알러지, 기저질환 등)
|
||||
disease_info = data.get('disease_info', {})
|
||||
current_medications = data.get('current_medications', [])
|
||||
previous_serial = data.get('previous_serial')
|
||||
previous_medications = data.get('previous_medications', [])
|
||||
otc_history = data.get('otc_history', {})
|
||||
|
||||
# ✅ 약품 정보 Enrichment (성분/분류/상호작용/금기)
|
||||
current_medications = enrich_medications(current_medications)
|
||||
|
||||
# 처방 변화 분석
|
||||
prescription_changes = analyze_prescription_changes(
|
||||
current_medications, previous_medications
|
||||
@ -1056,7 +1202,8 @@ def paai_analyze():
|
||||
current_medications=current_medications,
|
||||
prescription_changes=prescription_changes,
|
||||
kims_interactions=kims_interactions,
|
||||
otc_history=otc_history
|
||||
otc_history=otc_history,
|
||||
patient_note=patient_note
|
||||
)
|
||||
|
||||
# 5. Clawdbot AI 호출 (WebSocket)
|
||||
@ -1146,7 +1293,8 @@ def build_paai_prompt(
|
||||
current_medications: list,
|
||||
prescription_changes: dict,
|
||||
kims_interactions: list,
|
||||
otc_history: dict
|
||||
otc_history: dict,
|
||||
patient_note: str = ''
|
||||
) -> str:
|
||||
"""AI 프롬프트 생성"""
|
||||
|
||||
@ -1157,10 +1305,27 @@ def build_paai_prompt(
|
||||
if disease_info.get('code_2'):
|
||||
diseases.append(f"[{disease_info['code_2']}] {disease_info.get('name_2', '')}")
|
||||
|
||||
# 현재 처방
|
||||
# 현재 처방 (성분 정보 포함)
|
||||
med_lines = []
|
||||
for med in current_medications:
|
||||
line = f"- {med.get('name', '?')}: {med.get('dosage', 0)}정 × {med.get('frequency', 0)}회 × {med.get('days', 0)}일"
|
||||
name = med.get('name', '?')
|
||||
dosage = med.get('dosage', 0)
|
||||
freq = med.get('frequency', 0)
|
||||
days = med.get('days', 0)
|
||||
|
||||
line = f"- {name}: {dosage}정 × {freq}회 × {days}일"
|
||||
|
||||
# 분류 정보
|
||||
if med.get('print_type'):
|
||||
line += f"\n └ 분류: {med['print_type']}"
|
||||
|
||||
# 성분 정보
|
||||
if med.get('components'):
|
||||
components_str = ', '.join(med['components'][:3]) # 최대 3개
|
||||
if len(med['components']) > 3:
|
||||
components_str += f" 외 {len(med['components'])-3}개"
|
||||
line += f"\n └ 성분: {components_str}"
|
||||
|
||||
med_lines.append(line)
|
||||
|
||||
# 처방 변화
|
||||
@ -1194,11 +1359,17 @@ def build_paai_prompt(
|
||||
for item in otc_history['frequent_items'][:5]:
|
||||
otc_lines.append(f"- {item.get('name', '?')} ({item.get('count', 0)}회 구매)")
|
||||
|
||||
# 환자 특이사항 (알러지, 기저질환 등)
|
||||
note_text = patient_note.strip() if patient_note else ''
|
||||
|
||||
prompt = f"""당신은 약사를 보조하는 AI입니다. 환자 정보와 KIMS 상호작용 데이터를 바탕으로 분석해주세요.
|
||||
|
||||
## 환자 질병
|
||||
{chr(10).join(diseases) if diseases else '- 정보 없음'}
|
||||
|
||||
## 환자 특이사항 (알러지/기저질환/주의사항)
|
||||
{note_text if note_text else '- 없음'}
|
||||
|
||||
## 현재 처방
|
||||
{chr(10).join(med_lines) if med_lines else '- 정보 없음'}
|
||||
|
||||
@ -1434,3 +1605,216 @@ def paai_admin_feedback_stats():
|
||||
except Exception as e:
|
||||
logging.error(f"피드백 통계 조회 오류: {e}")
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# ESC/POS 자동인쇄 API (EUC-KR 텍스트 방식)
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
|
||||
import socket
|
||||
|
||||
# 프린터 설정
|
||||
ESCPOS_PRINTER_IP = "192.168.0.174"
|
||||
ESCPOS_PRINTER_PORT = 9100
|
||||
|
||||
# ESC/POS 명령어
|
||||
_ESC = b'\x1b'
|
||||
_INIT = _ESC + b'@' # 프린터 초기화
|
||||
_CUT = _ESC + b'd\x03' # 피드 + 커트
|
||||
|
||||
|
||||
def _log_print_history(pre_serial, patient_name, success, error=None):
|
||||
"""인쇄 이력을 파일에 기록"""
|
||||
try:
|
||||
log_dir = Path(__file__).parent / 'logs'
|
||||
log_dir.mkdir(exist_ok=True)
|
||||
log_file = log_dir / 'print_history.log'
|
||||
|
||||
timestamp = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
|
||||
status = '✅ 성공' if success else f'❌ 실패: {error}'
|
||||
line = f"[{timestamp}] {pre_serial} | {patient_name} | {status}\n"
|
||||
|
||||
with open(log_file, 'a', encoding='utf-8') as f:
|
||||
f.write(line)
|
||||
except Exception as e:
|
||||
logging.warning(f"인쇄 로그 기록 실패: {e}")
|
||||
|
||||
|
||||
@pmr_bp.route('/api/paai/print', methods=['POST'])
|
||||
def paai_print():
|
||||
"""PAAI 분석 결과 ESC/POS 인쇄"""
|
||||
try:
|
||||
data = request.get_json()
|
||||
pre_serial = data.get('pre_serial', '')
|
||||
patient_name = data.get('patient_name', '')
|
||||
result = data.get('result', {})
|
||||
|
||||
analysis = result.get('analysis', {})
|
||||
kims_summary = result.get('kims_summary', {})
|
||||
|
||||
logging.info(f"[PRINT] 요청 수신: {pre_serial} ({patient_name})")
|
||||
|
||||
# 영수증 텍스트 생성
|
||||
message = _format_paai_receipt(pre_serial, patient_name, analysis, kims_summary)
|
||||
|
||||
# 인쇄
|
||||
success = _print_escpos_text(message)
|
||||
|
||||
if success:
|
||||
logging.info(f"[PRINT] ✅ 완료: {pre_serial} ({patient_name})")
|
||||
_log_print_history(pre_serial, patient_name, True)
|
||||
return jsonify({'success': True, 'message': '인쇄 완료'})
|
||||
else:
|
||||
logging.error(f"[PRINT] ❌ 프린터 연결 실패: {pre_serial}")
|
||||
_log_print_history(pre_serial, patient_name, False, '프린터 연결 실패')
|
||||
return jsonify({'success': False, 'error': '프린터 연결 실패'}), 500
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"[PRINT] ❌ 오류: {pre_serial} - {e}")
|
||||
_log_print_history(pre_serial, patient_name, False, str(e))
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
|
||||
def _print_escpos_text(message: str) -> bool:
|
||||
"""ESC/POS 프린터로 텍스트 전송 (EUC-KR)"""
|
||||
try:
|
||||
# EUC-KR 인코딩
|
||||
text_bytes = message.encode('euc-kr', errors='replace')
|
||||
|
||||
# 명령어 조합
|
||||
command = _INIT + text_bytes + b'\n\n\n' + _CUT
|
||||
|
||||
# 소켓 전송
|
||||
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
sock.settimeout(10)
|
||||
sock.connect((ESCPOS_PRINTER_IP, ESCPOS_PRINTER_PORT))
|
||||
sock.sendall(command)
|
||||
sock.close()
|
||||
return True
|
||||
except Exception as e:
|
||||
logging.error(f"ESC/POS 전송 실패: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def _format_paai_receipt(pre_serial: str, patient_name: str,
|
||||
analysis: dict, kims_summary: dict) -> str:
|
||||
"""PAAI 복약안내 영수증 텍스트 생성 (48자 기준)"""
|
||||
|
||||
LINE = "=" * 48
|
||||
THIN = "-" * 48
|
||||
now = datetime.now().strftime("%Y-%m-%d %H:%M")
|
||||
|
||||
# 헤더
|
||||
msg = f"\n{LINE}\n"
|
||||
msg += _center_text("[ PAAI 복약안내 ]") + "\n"
|
||||
msg += f"{LINE}\n"
|
||||
|
||||
# 환자 정보
|
||||
msg += f"환자: {patient_name}\n"
|
||||
msg += f"처방번호: {pre_serial}\n"
|
||||
msg += f"출력: {now}\n"
|
||||
msg += f"{THIN}\n"
|
||||
|
||||
# 상호작용 요약
|
||||
interaction_count = kims_summary.get('interaction_count', 0)
|
||||
has_severe = kims_summary.get('has_severe', False)
|
||||
|
||||
if has_severe:
|
||||
msg += "[!!] 중증 상호작용 있음!\n"
|
||||
elif interaction_count > 0:
|
||||
msg += f"[!] 약물 상호작용: {interaction_count}건\n"
|
||||
else:
|
||||
msg += "[V] 상호작용 없음\n"
|
||||
msg += "\n"
|
||||
|
||||
# 처방 해석
|
||||
insight = analysis.get('prescription_insight', '')
|
||||
if insight:
|
||||
msg += f"{THIN}\n"
|
||||
msg += ">> 처방 해석\n"
|
||||
for line in _wrap_text(insight, 44):
|
||||
msg += f" {line}\n"
|
||||
msg += "\n"
|
||||
|
||||
# 복용 주의사항
|
||||
cautions = analysis.get('cautions', [])
|
||||
if cautions:
|
||||
msg += f"{THIN}\n"
|
||||
msg += ">> 복용 주의사항\n"
|
||||
for i, caution in enumerate(cautions[:4], 1):
|
||||
first_line = True
|
||||
for line in _wrap_text(f"{i}. {caution}", 44):
|
||||
if first_line:
|
||||
msg += f" {line}\n"
|
||||
first_line = False
|
||||
else:
|
||||
msg += f" {line}\n"
|
||||
msg += "\n"
|
||||
|
||||
# 상담 포인트
|
||||
counseling = analysis.get('counseling_points', [])
|
||||
if counseling:
|
||||
msg += f"{THIN}\n"
|
||||
msg += ">> 상담 포인트\n"
|
||||
for i, point in enumerate(counseling[:3], 1):
|
||||
first_line = True
|
||||
for line in _wrap_text(f"{i}. {point}", 44):
|
||||
if first_line:
|
||||
msg += f" {line}\n"
|
||||
first_line = False
|
||||
else:
|
||||
msg += f" {line}\n"
|
||||
msg += "\n"
|
||||
|
||||
# OTC 추천
|
||||
otc_recs = analysis.get('otc_recommendations', [])
|
||||
if otc_recs:
|
||||
msg += f"{THIN}\n"
|
||||
msg += ">> OTC 추천\n"
|
||||
for rec in otc_recs[:2]:
|
||||
product = rec.get('product', '')
|
||||
reason = rec.get('reason', '')
|
||||
msg += f" - {product}\n"
|
||||
for line in _wrap_text(reason, 42):
|
||||
msg += f" {line}\n"
|
||||
msg += "\n"
|
||||
|
||||
# 푸터
|
||||
msg += f"{LINE}\n"
|
||||
msg += _center_text("양구청춘약국 PAAI") + "\n"
|
||||
msg += _center_text("Tel: 033-481-5222") + "\n"
|
||||
msg += "\n"
|
||||
|
||||
return msg
|
||||
|
||||
|
||||
def _center_text(text: str, width: int = 48) -> str:
|
||||
"""중앙 정렬"""
|
||||
text_len = len(text)
|
||||
if text_len >= width:
|
||||
return text
|
||||
spaces = (width - text_len) // 2
|
||||
return " " * spaces + text
|
||||
|
||||
|
||||
def _wrap_text(text: str, width: int = 44) -> list:
|
||||
"""텍스트 줄바꿈"""
|
||||
if not text:
|
||||
return []
|
||||
|
||||
lines = []
|
||||
words = text.split()
|
||||
current = ""
|
||||
|
||||
for word in words:
|
||||
if len(current) + len(word) + 1 <= width:
|
||||
current = current + " " + word if current else word
|
||||
else:
|
||||
if current:
|
||||
lines.append(current)
|
||||
current = word
|
||||
|
||||
if current:
|
||||
lines.append(current)
|
||||
|
||||
return lines if lines else [text[:width]]
|
||||
|
||||
390
backend/sooin_api.py
Normal file
390
backend/sooin_api.py
Normal file
@ -0,0 +1,390 @@
|
||||
# -*- 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('/balance', methods=['GET'])
|
||||
def api_sooin_balance():
|
||||
"""
|
||||
수인약품 잔고(미수금) 조회 API
|
||||
|
||||
GET /api/sooin/balance
|
||||
|
||||
Returns:
|
||||
{
|
||||
"success": true,
|
||||
"balance": 14293001, // 현재 잔고 (누계합)
|
||||
"prev_balance": 10592762, // 전일잔액
|
||||
"monthly_sales": 3700239, // 월 매출
|
||||
"yearly_sales": 34380314 // 연 누계 매출
|
||||
}
|
||||
"""
|
||||
try:
|
||||
session = get_sooin_session()
|
||||
result = session.get_balance()
|
||||
return jsonify(result)
|
||||
except Exception as e:
|
||||
logger.error(f"수인약품 잔고 조회 오류: {e}")
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': 'BALANCE_ERROR',
|
||||
'message': str(e),
|
||||
'balance': 0
|
||||
}), 500
|
||||
|
||||
|
||||
@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
BIN
backend/static/img/logo_geoyoung.ico
Normal file
BIN
backend/static/img/logo_geoyoung.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.1 KiB |
3
backend/static/img/logo_sooin.ico
Normal file
3
backend/static/img/logo_sooin.ico
Normal file
@ -0,0 +1,3 @@
|
||||
<script type="text/javascript">
|
||||
location.href = "./homepage/intro.asp";
|
||||
</script>
|
||||
4
backend/static/img/logo_sooin.svg
Normal file
4
backend/static/img/logo_sooin.svg
Normal file
@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
||||
<rect width="32" height="32" rx="6" fill="#7c3aed"/>
|
||||
<text x="16" y="22" text-anchor="middle" fill="white" font-family="Arial" font-weight="bold" font-size="16">수</text>
|
||||
</svg>
|
||||
BIN
backend/static/uploads/pets/pet_4_98a97580.jpg
Normal file
BIN
backend/static/uploads/pets/pet_4_98a97580.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.3 MiB |
2155
backend/templates/admin_rx_usage.html
Normal file
2155
backend/templates/admin_rx_usage.html
Normal file
File diff suppressed because it is too large
Load Diff
1080
backend/templates/admin_usage.html
Normal file
1080
backend/templates/admin_usage.html
Normal file
File diff suppressed because it is too large
Load Diff
@ -33,6 +33,59 @@
|
||||
align-items: center;
|
||||
gap: 15px;
|
||||
}
|
||||
.auto-controls {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
.status-badge {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 12px;
|
||||
border-radius: 20px;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.status-badge .status-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
.status-badge.disconnected {
|
||||
background: #fef2f2;
|
||||
color: #dc2626;
|
||||
}
|
||||
.status-badge.disconnected .status-dot {
|
||||
background: #ef4444;
|
||||
}
|
||||
.status-badge.connected {
|
||||
background: #ecfdf5;
|
||||
color: #059669;
|
||||
}
|
||||
.status-badge.connected .status-dot {
|
||||
background: #10b981;
|
||||
}
|
||||
.status-badge.auto-print-off {
|
||||
background: #f3f4f6;
|
||||
color: #6b7280;
|
||||
}
|
||||
.status-badge.auto-print-off .status-dot {
|
||||
background: #9ca3af;
|
||||
}
|
||||
.status-badge.auto-print-on {
|
||||
background: #dbeafe;
|
||||
color: #2563eb;
|
||||
}
|
||||
.status-badge.auto-print-on .status-dot {
|
||||
background: #3b82f6;
|
||||
animation: pulse 1.5s infinite;
|
||||
}
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.5; }
|
||||
}
|
||||
.date-picker {
|
||||
padding: 8px 15px;
|
||||
border: 2px solid #8b5cf6;
|
||||
@ -64,7 +117,7 @@
|
||||
|
||||
/* 왼쪽: 환자 목록 */
|
||||
.patient-list {
|
||||
width: 380px;
|
||||
width: clamp(250px, 22vw, 380px);
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 4px 20px rgba(0,0,0,0.15);
|
||||
@ -72,6 +125,12 @@
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
/* 세로 모니터 최적화 */
|
||||
@media (orientation: portrait) {
|
||||
.patient-list {
|
||||
width: clamp(220px, 18vw, 300px);
|
||||
}
|
||||
}
|
||||
.patient-list-header {
|
||||
background: #4c1d95;
|
||||
color: #fff;
|
||||
@ -362,11 +421,99 @@
|
||||
}
|
||||
.paai-feedback button:hover { border-color: #10b981; }
|
||||
.paai-feedback button.selected { background: #d1fae5; border-color: #10b981; }
|
||||
.paai-feedback button.selected-bad { background: #fee2e2; border-color: #ef4444; }
|
||||
.paai-timing {
|
||||
font-size: 0.8rem;
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
/* 피드백 코멘트 모달 */
|
||||
.feedback-modal {
|
||||
display: none;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0,0,0,0.5);
|
||||
z-index: 1200;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
.feedback-modal.show { display: flex; }
|
||||
.feedback-modal-content {
|
||||
background: #fff;
|
||||
border-radius: 16px;
|
||||
width: 90%;
|
||||
max-width: 500px;
|
||||
box-shadow: 0 20px 40px rgba(0,0,0,0.3);
|
||||
}
|
||||
.feedback-modal-header {
|
||||
background: linear-gradient(135deg, #ef4444, #dc2626);
|
||||
color: #fff;
|
||||
padding: 18px 24px;
|
||||
border-radius: 16px 16px 0 0;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
.feedback-modal-header h3 { font-size: 1.1rem; margin: 0; }
|
||||
.feedback-modal-close {
|
||||
background: rgba(255,255,255,0.2);
|
||||
border: none;
|
||||
color: #fff;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
font-size: 1.3rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
.feedback-modal-body { padding: 24px; }
|
||||
.feedback-categories {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.feedback-category {
|
||||
padding: 6px 14px;
|
||||
border: 2px solid #e5e7eb;
|
||||
border-radius: 20px;
|
||||
background: #fff;
|
||||
cursor: pointer;
|
||||
font-size: 0.85rem;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.feedback-category:hover { border-color: #ef4444; }
|
||||
.feedback-category.selected { background: #fee2e2; border-color: #ef4444; color: #dc2626; }
|
||||
.feedback-textarea {
|
||||
width: 100%;
|
||||
min-height: 100px;
|
||||
padding: 12px;
|
||||
border: 2px solid #e5e7eb;
|
||||
border-radius: 10px;
|
||||
font-size: 0.95rem;
|
||||
resize: vertical;
|
||||
font-family: inherit;
|
||||
}
|
||||
.feedback-textarea:focus { outline: none; border-color: #ef4444; }
|
||||
.feedback-modal-footer {
|
||||
padding: 16px 24px;
|
||||
border-top: 1px solid #e5e7eb;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
}
|
||||
.feedback-btn {
|
||||
padding: 10px 20px;
|
||||
border-radius: 8px;
|
||||
font-size: 0.95rem;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
}
|
||||
.feedback-btn-cancel { background: #f3f4f6; color: #374151; }
|
||||
.feedback-btn-submit { background: #ef4444; color: #fff; font-weight: 600; }
|
||||
|
||||
/* PAAI 토스트 알림 */
|
||||
.paai-toast-container {
|
||||
position: fixed;
|
||||
@ -889,6 +1036,83 @@
|
||||
tr.row-removed { background: #fef2f2 !important; opacity: 0.7; }
|
||||
tr.row-changed { background: #fffbeb !important; }
|
||||
|
||||
/* 일반 대체조제 표시 */
|
||||
.subst-badge {
|
||||
display: inline-block;
|
||||
background: linear-gradient(135deg, #f59e0b, #d97706);
|
||||
color: white;
|
||||
padding: 1px 5px;
|
||||
border-radius: 4px;
|
||||
font-size: 0.65rem;
|
||||
font-weight: 700;
|
||||
margin-right: 4px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
/* 저가대체 인센티브 표시 */
|
||||
.lowcost-badge {
|
||||
display: inline-block;
|
||||
background: linear-gradient(135deg, #10b981, #059669);
|
||||
color: white;
|
||||
padding: 1px 5px;
|
||||
border-radius: 4px;
|
||||
font-size: 0.65rem;
|
||||
font-weight: 700;
|
||||
margin-right: 4px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
/* 비급여 표시 */
|
||||
.noncov-badge {
|
||||
display: inline-block;
|
||||
background: linear-gradient(135deg, #ef4444, #dc2626);
|
||||
color: white;
|
||||
padding: 1px 5px;
|
||||
border-radius: 4px;
|
||||
font-size: 0.65rem;
|
||||
font-weight: 700;
|
||||
margin-right: 4px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
/* 본인부담률 표시 (30/50/80/90/100) */
|
||||
.copay-badge {
|
||||
display: inline-block;
|
||||
background: linear-gradient(135deg, #8b5cf6, #7c3aed);
|
||||
color: white;
|
||||
padding: 1px 5px;
|
||||
border-radius: 4px;
|
||||
font-size: 0.65rem;
|
||||
font-weight: 700;
|
||||
margin-right: 4px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
.original-rx {
|
||||
margin-top: 4px;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
.original-rx summary {
|
||||
cursor: pointer;
|
||||
color: #6b7280;
|
||||
user-select: none;
|
||||
}
|
||||
.original-rx summary:hover {
|
||||
color: #4b5563;
|
||||
}
|
||||
.original-drug-info {
|
||||
margin-top: 4px;
|
||||
padding: 6px 10px;
|
||||
background: #fef3c7;
|
||||
border-radius: 4px;
|
||||
border-left: 3px solid #f59e0b;
|
||||
}
|
||||
.orig-name {
|
||||
display: block;
|
||||
font-weight: 500;
|
||||
color: #92400e;
|
||||
}
|
||||
.orig-code {
|
||||
font-size: 0.7rem;
|
||||
color: #b45309;
|
||||
}
|
||||
|
||||
.change-arrow {
|
||||
color: #94a3b8;
|
||||
margin: 0 4px;
|
||||
@ -940,6 +1164,17 @@
|
||||
<header class="header">
|
||||
<h1>💊 조제관리 <span>청춘라벨 v2</span></h1>
|
||||
<div class="controls">
|
||||
<!-- 자동감지/자동인쇄 상태 -->
|
||||
<div class="auto-controls">
|
||||
<div id="triggerIndicator" class="status-badge disconnected">
|
||||
<span class="status-dot"></span>
|
||||
자동감지 OFF
|
||||
</div>
|
||||
<div id="autoPrintToggle" class="status-badge auto-print-off">
|
||||
<span class="status-dot"></span>
|
||||
자동인쇄 OFF
|
||||
</div>
|
||||
</div>
|
||||
<input type="date" id="dateSelect" class="date-picker">
|
||||
<div class="stats-box">
|
||||
<div class="stat-item">
|
||||
@ -1028,6 +1263,29 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 피드백 코멘트 모달 -->
|
||||
<div class="feedback-modal" id="feedbackModal">
|
||||
<div class="feedback-modal-content">
|
||||
<div class="feedback-modal-header">
|
||||
<h3>📝 어떤 점이 문제였나요?</h3>
|
||||
<button class="feedback-modal-close" onclick="closeFeedbackModal()">×</button>
|
||||
</div>
|
||||
<div class="feedback-modal-body">
|
||||
<div class="feedback-categories">
|
||||
<button class="feedback-category" data-cat="interaction">💊 약물 상호작용</button>
|
||||
<button class="feedback-category" data-cat="indication">🎯 적응증/용도</button>
|
||||
<button class="feedback-category" data-cat="dosage">📏 용법용량</button>
|
||||
<button class="feedback-category" data-cat="other">📋 기타</button>
|
||||
</div>
|
||||
<textarea class="feedback-textarea" id="feedbackComment" placeholder="잘못된 점이나 개선할 내용을 적어주세요... 예: NSAID와 아세트아미노펜은 병용 가능합니다."></textarea>
|
||||
</div>
|
||||
<div class="feedback-modal-footer">
|
||||
<button class="feedback-btn feedback-btn-cancel" onclick="closeFeedbackModal()">취소</button>
|
||||
<button class="feedback-btn feedback-btn-submit" onclick="submitFeedbackComment()">제출</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 특이사항 모달 -->
|
||||
<div class="cusetc-modal" id="cusetcModal">
|
||||
<div class="cusetc-modal-content">
|
||||
@ -1298,12 +1556,23 @@
|
||||
</thead>
|
||||
<tbody>
|
||||
${data.medications.map(m => `
|
||||
<tr data-add-info="${escapeHtml(m.add_info || '')}">
|
||||
<tr data-add-info="${escapeHtml(m.add_info || '')}" ${m.is_substituted ? 'class="substituted-row"' : ''}>
|
||||
<td><input type="checkbox" class="med-check" data-code="${m.medication_code}" ${m.is_auto_print ? 'checked' : ''}></td>
|
||||
<td>
|
||||
<div class="med-name">${m.med_name || m.medication_code}</div>
|
||||
<div class="med-name">
|
||||
${m.unit_code === 2 ? '<span class="noncov-badge" title="비급여">비)</span> ' : ''}${[3,4,5,6,7].includes(m.unit_code) ? '<span class="copay-badge" title="본인부담률">' + {3:'100',4:'50',5:'80',6:'30',7:'90'}[m.unit_code] + ')</span> ' : ''}${m.ps_type === '1' ? '<span class="subst-badge" title="일반 대체조제">대)</span> ' : ''}${m.is_substituted ? '<span class="lowcost-badge" title="저가대체 인센티브">저)</span> ' : ''}${m.med_name || m.medication_code}
|
||||
</div>
|
||||
<div class="med-code">${m.medication_code}</div>
|
||||
${m.add_info ? `<div style="font-size:0.75rem;color:#6b7280;">${escapeHtml(m.add_info)}</div>` : ''}
|
||||
${m.is_substituted && m.original_drug ? `
|
||||
<details class="original-rx">
|
||||
<summary>원처방 보기</summary>
|
||||
<div class="original-drug-info">
|
||||
<span class="orig-name">${escapeHtml(m.original_drug.drug_name)}</span>
|
||||
<span class="orig-code">${m.original_drug.drug_code}</span>
|
||||
</div>
|
||||
</details>
|
||||
` : ''}
|
||||
</td>
|
||||
<td>${m.formulation ? `<span class="med-form">${m.formulation}</span>` : '-'}</td>
|
||||
<td><span class="med-dosage">${m.dosage || '-'}</span></td>
|
||||
@ -1611,10 +1880,15 @@
|
||||
|
||||
if (data.success && data.count > 0) {
|
||||
otcData = data;
|
||||
// OTC 뱃지 추가 (질병 뱃지 앞에)
|
||||
// OTC 뱃지 추가 (맨 앞에)
|
||||
const rxInfo = document.getElementById('rxInfo');
|
||||
const otcBadge = `<span class="otc-badge" onclick="showOtcModal()">💊 OTC ${data.count}건</span>`;
|
||||
rxInfo.innerHTML = otcBadge + rxInfo.innerHTML;
|
||||
if (rxInfo && !rxInfo.querySelector('.otc-badge')) {
|
||||
const otcBadge = document.createElement('span');
|
||||
otcBadge.className = 'otc-badge';
|
||||
otcBadge.onclick = showOtcModal;
|
||||
otcBadge.innerHTML = `💊 OTC ${data.count}건`;
|
||||
rxInfo.insertBefore(otcBadge, rxInfo.firstChild);
|
||||
}
|
||||
} else {
|
||||
otcData = null;
|
||||
}
|
||||
@ -1893,6 +2167,7 @@
|
||||
pre_serial: preSerial,
|
||||
cus_code: currentPrescriptionData.cus_code,
|
||||
patient_name: patientName,
|
||||
patient_note: currentPrescriptionData.cusetc || '', // 환자 특이사항 (알러지, 기저질환 등)
|
||||
disease_info: {
|
||||
code_1: currentPrescriptionData.st1 || '',
|
||||
name_1: currentPrescriptionData.st1_name || '',
|
||||
@ -1946,6 +2221,9 @@
|
||||
|
||||
// 토스트 알림
|
||||
showPaaiToast(patientName, 'PAAI 분석 완료! 클릭하여 확인', 'success', preSerial);
|
||||
|
||||
// 자동인쇄 (활성화된 경우)
|
||||
printPaaiResult(preSerial, patientName, result);
|
||||
} else {
|
||||
showPaaiToast(patientName, '분석 실패: ' + (result.error || '알 수 없는 오류'), 'error', preSerial);
|
||||
}
|
||||
@ -2045,6 +2323,7 @@
|
||||
}
|
||||
|
||||
currentPaaiLogId = cached.result.log_id;
|
||||
currentPaaiResponse = JSON.stringify(cached.result.analysis || {});
|
||||
displayPaaiResult(cached.result);
|
||||
modal.classList.add('show');
|
||||
}
|
||||
@ -2169,28 +2448,93 @@
|
||||
document.getElementById('paaiModal').classList.remove('show');
|
||||
}
|
||||
|
||||
// 피드백 관련 변수
|
||||
let currentPaaiResponse = '';
|
||||
let currentFeedbackCategory = 'other';
|
||||
|
||||
async function sendPaaiFeedback(useful) {
|
||||
if (!currentPaaiLogId) return;
|
||||
|
||||
// 버튼 즉시 반영
|
||||
document.getElementById('paaiUseful').classList.toggle('selected', useful);
|
||||
document.getElementById('paaiNotUseful').classList.toggle('selected', !useful);
|
||||
document.getElementById('paaiNotUseful').classList.toggle('selected-bad', !useful);
|
||||
document.getElementById('paaiNotUseful').classList.remove('selected');
|
||||
|
||||
if (useful) {
|
||||
// 👍 좋아요: 바로 저장하고 닫기
|
||||
try {
|
||||
await fetch('/api/paai/feedback', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({
|
||||
prescription_id: currentPrescriptionId,
|
||||
patient_name: document.querySelector('.patient-name')?.textContent || '',
|
||||
paai_response: currentPaaiResponse,
|
||||
rating: 'good'
|
||||
})
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Feedback error:', err);
|
||||
}
|
||||
setTimeout(() => closePaaiModal(), 500);
|
||||
} else {
|
||||
// 👎 아니요: 코멘트 모달 열기
|
||||
openFeedbackModal();
|
||||
}
|
||||
}
|
||||
|
||||
function openFeedbackModal() {
|
||||
document.getElementById('feedbackModal').classList.add('show');
|
||||
document.getElementById('feedbackComment').value = '';
|
||||
document.getElementById('feedbackComment').focus();
|
||||
|
||||
// 카테고리 버튼 이벤트
|
||||
document.querySelectorAll('.feedback-category').forEach(btn => {
|
||||
btn.classList.remove('selected');
|
||||
btn.onclick = () => {
|
||||
document.querySelectorAll('.feedback-category').forEach(b => b.classList.remove('selected'));
|
||||
btn.classList.add('selected');
|
||||
currentFeedbackCategory = btn.dataset.cat;
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function closeFeedbackModal() {
|
||||
document.getElementById('feedbackModal').classList.remove('show');
|
||||
}
|
||||
|
||||
async function submitFeedbackComment() {
|
||||
const comment = document.getElementById('feedbackComment').value.trim();
|
||||
|
||||
if (!comment) {
|
||||
alert('코멘트를 입력해주세요.');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await fetch('/pmr/api/paai/feedback', {
|
||||
const res = await fetch('/api/paai/feedback', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({
|
||||
log_id: currentPaaiLogId,
|
||||
useful: useful
|
||||
prescription_id: currentPrescriptionId,
|
||||
patient_name: document.querySelector('.patient-name')?.textContent || '',
|
||||
paai_response: currentPaaiResponse,
|
||||
rating: 'bad',
|
||||
category: currentFeedbackCategory,
|
||||
pharmacist_comment: comment
|
||||
})
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
closeFeedbackModal();
|
||||
closePaaiModal();
|
||||
showPaaiToast('피드백이 저장되었습니다. 감사합니다! 🙏', 'success');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Feedback error:', err);
|
||||
alert('피드백 저장 실패');
|
||||
}
|
||||
|
||||
// 0.5초 후 모달 닫기
|
||||
setTimeout(() => closePaaiModal(), 500);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
@ -2434,6 +2778,13 @@
|
||||
true // clickable
|
||||
);
|
||||
playTriggerSound();
|
||||
|
||||
// 자동인쇄 (활성화된 경우)
|
||||
printPaaiResult(data.pre_serial, data.patient_name, {
|
||||
success: true,
|
||||
analysis: data.analysis,
|
||||
kims_summary: data.kims_summary
|
||||
});
|
||||
break;
|
||||
case 'analysis_failed':
|
||||
showTriggerToast(data.pre_serial, data.patient_name, 'failed', data.error || '분석 실패', '❌');
|
||||
@ -2561,39 +2912,121 @@
|
||||
|
||||
// 연결 상태 표시
|
||||
function updateTriggerIndicator(isConnected) {
|
||||
let indicator = document.getElementById('triggerIndicator');
|
||||
if (!indicator) {
|
||||
const controls = document.querySelector('.header .controls');
|
||||
if (controls) {
|
||||
indicator = document.createElement('div');
|
||||
indicator.id = 'triggerIndicator';
|
||||
indicator.style.cssText = `
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 12px;
|
||||
background: #f1f5f9;
|
||||
border-radius: 8px;
|
||||
font-size: 0.8rem;
|
||||
color: #64748b;
|
||||
`;
|
||||
controls.insertBefore(indicator, controls.firstChild);
|
||||
}
|
||||
}
|
||||
|
||||
const indicator = document.getElementById('triggerIndicator');
|
||||
if (indicator) {
|
||||
indicator.className = `status-badge ${isConnected ? 'connected' : 'disconnected'}`;
|
||||
indicator.innerHTML = `
|
||||
<span style="
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: ${isConnected ? '#10b981' : '#ef4444'};
|
||||
"></span>
|
||||
${isConnected ? '자동감지 ON' : '자동감지 OFF'}
|
||||
<span class="status-dot"></span>
|
||||
자동감지 ${isConnected ? 'ON' : 'OFF'}
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// 자동인쇄 기능 (모두 window 전역)
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
window.autoPrintEnabled = localStorage.getItem('pmr_auto_print') === 'true';
|
||||
window.printedSerials = new Set(); // 중복 인쇄 방지용
|
||||
|
||||
// 간단한 토스트 알림
|
||||
window.showToast = function(message, type) {
|
||||
var container = document.getElementById('paaiToastContainer');
|
||||
if (!container) return;
|
||||
|
||||
var toast = document.createElement('div');
|
||||
toast.className = 'paai-toast';
|
||||
toast.style.background = type === 'success' ? 'linear-gradient(135deg, #10b981, #059669)' :
|
||||
type === 'error' ? 'linear-gradient(135deg, #ef4444, #dc2626)' :
|
||||
'linear-gradient(135deg, #6b7280, #4b5563)';
|
||||
|
||||
toast.innerHTML = '<div class="content"><div class="title">' + message + '</div></div>';
|
||||
toast.onclick = function() { toast.remove(); };
|
||||
|
||||
container.appendChild(toast);
|
||||
setTimeout(function() { toast.remove(); }, 3000);
|
||||
};
|
||||
|
||||
// 표시 업데이트
|
||||
window.updateAutoPrintIndicator = function() {
|
||||
var toggle = document.getElementById('autoPrintToggle');
|
||||
if (toggle) {
|
||||
toggle.className = 'status-badge ' + (window.autoPrintEnabled ? 'auto-print-on' : 'auto-print-off');
|
||||
toggle.innerHTML = '<span class="status-dot"></span>자동인쇄 ' + (window.autoPrintEnabled ? 'ON' : 'OFF');
|
||||
}
|
||||
};
|
||||
|
||||
// PAAI 결과 인쇄 (중복 방지 포함)
|
||||
window.printPaaiResult = async function(preSerial, patientName, result) {
|
||||
// 1. 자동인쇄 비활성화 체크
|
||||
if (!window.autoPrintEnabled) {
|
||||
console.log('[AutoPrint] 비활성화됨, 스킵:', preSerial);
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. 중복 인쇄 방지 (요청 전에 먼저 체크 & 추가!)
|
||||
if (window.printedSerials.has(preSerial)) {
|
||||
console.log('[AutoPrint] 이미 인쇄됨, 스킵:', preSerial);
|
||||
return;
|
||||
}
|
||||
// 즉시 Set에 추가 (비동기 race condition 방지)
|
||||
window.printedSerials.add(preSerial);
|
||||
|
||||
// 3. 인쇄 진행
|
||||
try {
|
||||
var now = new Date().toLocaleTimeString('ko-KR');
|
||||
console.log('[AutoPrint] ' + now + ' 인쇄 요청:', preSerial, patientName);
|
||||
|
||||
var response = await fetch('/pmr/api/paai/print', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({
|
||||
pre_serial: preSerial,
|
||||
patient_name: patientName,
|
||||
result: result
|
||||
})
|
||||
});
|
||||
|
||||
var data = await response.json();
|
||||
if (data.success) {
|
||||
console.log('[AutoPrint] ' + now + ' ✅ 인쇄 완료:', preSerial, patientName);
|
||||
window.showToast('🖨️ ' + patientName, 'success');
|
||||
} else {
|
||||
// 실패 시 Set에서 제거 (재시도 가능하도록)
|
||||
window.printedSerials.delete(preSerial);
|
||||
console.error('[AutoPrint] ' + now + ' ❌ 실패:', preSerial, data.error);
|
||||
window.showToast('인쇄 실패: ' + patientName, 'error');
|
||||
}
|
||||
|
||||
// 5분 후 Set에서 제거 (메모리 관리)
|
||||
setTimeout(function() {
|
||||
window.printedSerials.delete(preSerial);
|
||||
}, 5 * 60 * 1000);
|
||||
} catch (err) {
|
||||
// 오류 시 Set에서 제거
|
||||
window.printedSerials.delete(preSerial);
|
||||
console.error('[AutoPrint] 오류:', preSerial, err);
|
||||
window.showToast('인쇄 오류', 'error');
|
||||
}
|
||||
};
|
||||
|
||||
// 즉시 실행: 토글 이벤트 바인딩
|
||||
(function() {
|
||||
var toggle = document.getElementById('autoPrintToggle');
|
||||
if (toggle) {
|
||||
toggle.style.cursor = 'pointer';
|
||||
toggle.onclick = function() {
|
||||
window.autoPrintEnabled = !window.autoPrintEnabled;
|
||||
localStorage.setItem('pmr_auto_print', window.autoPrintEnabled ? 'true' : 'false');
|
||||
window.updateAutoPrintIndicator();
|
||||
window.showToast(window.autoPrintEnabled ? '자동인쇄 ON' : '자동인쇄 OFF', window.autoPrintEnabled ? 'success' : 'info');
|
||||
console.log('[AutoPrint] 토글:', window.autoPrintEnabled);
|
||||
};
|
||||
// 초기 상태 표시
|
||||
window.updateAutoPrintIndicator();
|
||||
console.log('[AutoPrint] 초기화 완료, 상태:', window.autoPrintEnabled);
|
||||
}
|
||||
})();
|
||||
|
||||
// 알림 소리
|
||||
function playTriggerSound() {
|
||||
try {
|
||||
|
||||
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`
|
||||
@ -119,6 +119,53 @@ LIMIT 10;
|
||||
| `PHONE` | 전화번호 |
|
||||
| `PANUM` | 주민번호 |
|
||||
|
||||
**PM_PRES.dbo.PS_sub_pharm** - 조제 약품 상세 ⭐
|
||||
| 컬럼 | 설명 |
|
||||
|------|------|
|
||||
| `PreSerial` | 처방번호 (FK) |
|
||||
| `SUB_SERIAL` | 약품 순번 |
|
||||
| `DrugCode` | 제품 코드 |
|
||||
| `Days` | 복용일수 |
|
||||
| `QUAN` | 1회 복용량 |
|
||||
| `QUAN_TIME` | 1일 복용횟수 |
|
||||
| `INV_QUAN` | 총 투약량 |
|
||||
| `PS_Type` | **조제 유형** (아래 참고) |
|
||||
|
||||
#### PS_Type 값 (대체조제 구분) ⭐
|
||||
|
||||
| PS_Type | 의미 | 표시 |
|
||||
|---------|------|------|
|
||||
| **0** | 일반 처방 | ✅ 표시 |
|
||||
| **1** | 일반 대체조제 | ✅ 표시 + `대)` 배지 (주황색) |
|
||||
| **4** | 저가대체 인센티브 - **실제 조제약** | ✅ 표시 + `저)` 배지 (초록색) |
|
||||
| **9** | 저가대체 인센티브 - **원본 처방약** | ❌ 숨김 (약가 계산용)
|
||||
|
||||
**대체조제 데이터 패턴:**
|
||||
```
|
||||
SUB_SERIAL 순서로 4(실제) → 9(원본) 쌍으로 저장됨
|
||||
|
||||
예시 (김현지 처방):
|
||||
PS_Type=4 | 사이톱신정 ← 실제 조제 (표시)
|
||||
PS_Type=9 | 씨프러스정 ← 원본 처방 (숨김, 사이톱신의 원처방)
|
||||
PS_Type=4 | 티로파정 ← 실제 조제 (표시)
|
||||
PS_Type=9 | 티램정 ← 원본 처방 (숨김, 티로파의 원처방)
|
||||
```
|
||||
|
||||
**쿼리 예시:**
|
||||
```sql
|
||||
-- 실제 조제약만 조회 (대체조제 원본 제외)
|
||||
SELECT * FROM PS_sub_pharm WHERE PreSerial = '처방번호' AND PS_Type != '9'
|
||||
|
||||
-- 대체조제 쌍 확인
|
||||
SELECT
|
||||
s1.DrugCode AS 실제조제,
|
||||
s2.DrugCode AS 원본처방
|
||||
FROM PS_sub_pharm s1
|
||||
JOIN PS_sub_pharm s2 ON s1.PreSerial = s2.PreSerial
|
||||
AND s1.SUB_SERIAL + 1 = s2.SUB_SERIAL
|
||||
WHERE s1.PS_Type = '4' AND s2.PS_Type = '9'
|
||||
```
|
||||
|
||||
```sql
|
||||
-- 예시: 오늘 판매 내역 + 제품명 조회
|
||||
SELECT
|
||||
@ -420,6 +467,128 @@ OPENAI_MODEL=gpt-4o-mini
|
||||
|
||||
---
|
||||
|
||||
---
|
||||
|
||||
## 🤖 PAAI 시스템 (처방 AI 분석)
|
||||
|
||||
### 개요
|
||||
|
||||
**PAAI (Prescription AI Analysis)**는 처방 접수 시 자동으로 AI 분석을 수행하고,
|
||||
분석 결과를 영수증 프린터로 출력하는 시스템입니다.
|
||||
|
||||
### 아키텍처
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────────────────┐
|
||||
│ PAAI 시스템 흐름 │
|
||||
└─────────────────────────────────────────────────────────────────────────────┘
|
||||
|
||||
┌─────────────┐ ┌─────────────────┐ ┌─────────────────┐
|
||||
│ POS 접수 │────►│ PM_PRES_LOG │────►│ Trigger Module │
|
||||
│ (처방입력) │ │ (MSSQL) │ │ (폴링 감지) │
|
||||
└─────────────┘ └─────────────────┘ └─────────────────┘
|
||||
│
|
||||
┌───────────────────────────────┤
|
||||
│ │
|
||||
▼ ▼
|
||||
┌─────────────────┐ ┌─────────────────┐
|
||||
│ WebSocket 알림 │ │ PAAI 분석 요청 │
|
||||
│ (ws://8765) │ │ Flask API │
|
||||
└─────────────────┘ └─────────────────┘
|
||||
│ │
|
||||
▼ ▼
|
||||
┌─────────────────┐ ┌─────────────────┐
|
||||
│ 프론트엔드 │ │ Claude API │
|
||||
│ pmr.html │ │ (분석 수행) │
|
||||
└─────────────────┘ └─────────────────┘
|
||||
│ │
|
||||
│◄──────────────────────────────┤
|
||||
│ analysis_completed 이벤트
|
||||
▼
|
||||
┌─────────────────┐
|
||||
│ 자동 인쇄 │
|
||||
│ ESC/POS 프린터 │
|
||||
└─────────────────┘
|
||||
```
|
||||
|
||||
### 구성 요소
|
||||
|
||||
| 모듈 | 위치 | 역할 |
|
||||
|------|------|------|
|
||||
| **Trigger Module** | `prescription-trigger/prescription_trigger.py` | PM_PRES_LOG 폴링, 처방 감지, 분석 요청 |
|
||||
| **WebSocket Server** | Trigger 내장 (port 8765) | 프론트엔드에 실시간 이벤트 전송 |
|
||||
| **PAAI API** | `backend/pmr_api.py` | 분석 요청 처리, Claude API 호출, 결과 저장 |
|
||||
| **프론트엔드** | `backend/templates/pmr.html` | 조제관리 UI, 자동인쇄 토글 |
|
||||
| **프린터 모듈** | `backend/paai_printer.py` | ESC/POS 영수증 프린터 출력 |
|
||||
|
||||
### WebSocket 이벤트
|
||||
|
||||
| 이벤트 | 방향 | 설명 |
|
||||
|--------|------|------|
|
||||
| `prescription_detected` | Server → Client | 새 처방 감지됨 |
|
||||
| `analysis_started` | Server → Client | AI 분석 시작 |
|
||||
| `analysis_completed` | Server → Client | 분석 완료 (결과 포함) |
|
||||
| `analysis_failed` | Server → Client | 분석 실패 (에러 포함) |
|
||||
|
||||
### 자동 인쇄 흐름
|
||||
|
||||
```javascript
|
||||
// 1. WebSocket으로 analysis_completed 수신
|
||||
ws.onmessage = (event) => {
|
||||
const data = JSON.parse(event.data);
|
||||
if (data.event === 'analysis_completed') {
|
||||
// 2. 자동인쇄 ON 상태면 인쇄
|
||||
if (window.autoPrintEnabled) {
|
||||
printPaaiResult(data.pre_serial, data.patient_name, data);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 3. 인쇄 API 호출
|
||||
POST /pmr/api/paai/print
|
||||
{
|
||||
"pre_serial": "20260305000099",
|
||||
"patient_name": "홍길동",
|
||||
"result": { "analysis": {...}, "kims_summary": {...} }
|
||||
}
|
||||
|
||||
// 4. ESC/POS 프린터로 출력
|
||||
```
|
||||
|
||||
### 중복 방지
|
||||
|
||||
```javascript
|
||||
// window.printedSerials (Set) 으로 중복 인쇄 방지
|
||||
if (window.printedSerials.has(preSerial)) {
|
||||
console.log('[AutoPrint] 이미 인쇄됨, 스킵:', preSerial);
|
||||
return;
|
||||
}
|
||||
window.printedSerials.add(preSerial); // 요청 전에 추가 (race condition 방지)
|
||||
```
|
||||
|
||||
### 자동 재시도
|
||||
|
||||
| 시도 | 대기 시간 | 상태 |
|
||||
|------|----------|------|
|
||||
| 1회차 | - | 최초 시도 |
|
||||
| 2회차 | 2초 | 첫 번째 재시도 |
|
||||
| 3회차 | 4초 | 두 번째 재시도 |
|
||||
| 실패 | - | `analysis_failed` 이벤트 발송 |
|
||||
|
||||
### 로그 파일
|
||||
|
||||
| 파일 | 위치 | 내용 |
|
||||
|------|------|------|
|
||||
| `print_history.log` | `backend/logs/` | 인쇄 성공/실패 기록 |
|
||||
| `analysis_failures.log` | `prescription-trigger/logs/` | 분석 실패 상세 기록 |
|
||||
| `paai_logs.db` | `backend/db/` | 분석 결과 SQLite 저장 |
|
||||
|
||||
### 관련 문서
|
||||
|
||||
- `docs/PAAI_AUTO_PRINT_TROUBLESHOOTING.md` - 자동인쇄 트러블슈팅 가이드
|
||||
|
||||
---
|
||||
|
||||
## 📝 버전 이력
|
||||
|
||||
| 날짜 | 버전 | 변경 내용 |
|
||||
@ -427,6 +596,8 @@ OPENAI_MODEL=gpt-4o-mini
|
||||
| 2026-02-28 | 1.0 | 초기 아키텍처 문서 작성 |
|
||||
| | | 동물약 AI 챗봇 추가 |
|
||||
| | | 플로팅 챗봇 UI 구현 |
|
||||
| 2026-03-05 | 1.1 | PAAI 시스템 아키텍처 추가 |
|
||||
| | | 자동인쇄, WebSocket, 재시도 로직 |
|
||||
|
||||
---
|
||||
|
||||
|
||||
252
docs/ESCPOS_TROUBLESHOOTING.md
Normal file
252
docs/ESCPOS_TROUBLESHOOTING.md
Normal file
@ -0,0 +1,252 @@
|
||||
# ESC/POS 영수증 프린터 트러블슈팅 가이드
|
||||
|
||||
> 작성일: 2026-03-05
|
||||
> 프린터: 192.168.0.174:9100 (올댓포스 오른쪽)
|
||||
|
||||
---
|
||||
|
||||
## 핵심 요약
|
||||
|
||||
| 항목 | 올바른 방식 | 잘못된 방식 |
|
||||
|------|------------|------------|
|
||||
| **인코딩** | EUC-KR | UTF-8 |
|
||||
| **전송 방식** | socket 직접 전송 | python-escpos 라이브러리 |
|
||||
| **이모지** | 사용 금지 (`>>`, `[V]`) | ❌ 🖨️ ✅ |
|
||||
| **이미지** | 사용 금지 | PIL Image |
|
||||
| **용지 폭** | 48자 기준 | 글자수 무제한 |
|
||||
| **용지 길이** | 무제한 (롤 용지) | 제한 없음 |
|
||||
|
||||
---
|
||||
|
||||
## 증상별 해결책
|
||||
|
||||
### 1. 아무것도 안 나옴
|
||||
```
|
||||
원인: 프린터 연결 실패
|
||||
해결:
|
||||
1. ping 192.168.0.174 확인
|
||||
2. 포트 9100 확인 (Test-NetConnection -ComputerName 192.168.0.174 -Port 9100)
|
||||
3. 프린터 전원 확인
|
||||
```
|
||||
|
||||
### 2. "EAT" 또는 깨진 문자만 나옴
|
||||
```
|
||||
원인: 이미지 인쇄 방식 사용
|
||||
해결: 이미지 방식 사용 금지! 텍스트 + EUC-KR 인코딩 사용
|
||||
|
||||
❌ 잘못된 코드:
|
||||
from escpos.printer import Network
|
||||
p = Network(...)
|
||||
p.image(img) # 이미지 인쇄 - 안 됨!
|
||||
|
||||
✅ 올바른 코드:
|
||||
sock = socket.socket(...)
|
||||
text_bytes = message.encode('euc-kr', errors='replace')
|
||||
sock.sendall(INIT + text_bytes + CUT)
|
||||
```
|
||||
|
||||
### 3. 한글이 ???? 로 나옴
|
||||
```
|
||||
원인: UTF-8 인코딩 사용
|
||||
해결: EUC-KR 인코딩 사용
|
||||
|
||||
❌ 잘못된 코드:
|
||||
text.encode('utf-8')
|
||||
|
||||
✅ 올바른 코드:
|
||||
text.encode('euc-kr', errors='replace')
|
||||
```
|
||||
|
||||
### 4. 이모지가 ? 로 나옴
|
||||
```
|
||||
원인: ESC/POS 프린터는 이모지 미지원
|
||||
해결: 텍스트로 대체
|
||||
|
||||
❌ ✅ 상호작용 없음
|
||||
✅ [V] 상호작용 없음
|
||||
|
||||
❌ ⚠️ 주의 필요
|
||||
✅ [!] 주의 필요
|
||||
|
||||
❌ 📋 처방 해석
|
||||
✅ >> 처방 해석
|
||||
```
|
||||
|
||||
### 5. 첫 줄만 나오고 잘림
|
||||
```
|
||||
원인: python-escpos 라이브러리의 set() 함수 문제
|
||||
해결: socket 직접 전송 방식 사용
|
||||
|
||||
❌ 잘못된 코드:
|
||||
from escpos.printer import Network
|
||||
p = Network(...)
|
||||
p.set(align='center', bold=True) # 이 명령이 문제!
|
||||
p.text("내용")
|
||||
|
||||
✅ 올바른 코드:
|
||||
sock = socket.socket(...)
|
||||
sock.sendall(INIT + text.encode('euc-kr') + CUT)
|
||||
```
|
||||
|
||||
### 6. 연결은 되는데 인쇄 안 됨
|
||||
```
|
||||
원인: 프린터가 이전 작업에서 hang 상태
|
||||
해결:
|
||||
1. 프린터 전원 껐다 켜기
|
||||
2. 또는 INIT 명령 먼저 전송: ESC + b'@'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 올바른 코드 템플릿
|
||||
|
||||
### 기본 텍스트 인쇄
|
||||
```python
|
||||
import socket
|
||||
|
||||
# 프린터 설정
|
||||
PRINTER_IP = "192.168.0.174"
|
||||
PRINTER_PORT = 9100
|
||||
|
||||
# ESC/POS 명령어
|
||||
ESC = b'\x1b'
|
||||
INIT = ESC + b'@' # 프린터 초기화
|
||||
CUT = ESC + b'd\x03' # 피드 + 커트
|
||||
|
||||
def print_text(message: str) -> bool:
|
||||
try:
|
||||
# EUC-KR 인코딩 (한글 지원)
|
||||
text_bytes = message.encode('euc-kr', errors='replace')
|
||||
|
||||
# 명령어 조합
|
||||
command = INIT + text_bytes + b'\n\n\n' + CUT
|
||||
|
||||
# 소켓 전송
|
||||
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
sock.settimeout(10)
|
||||
sock.connect((PRINTER_IP, PRINTER_PORT))
|
||||
sock.sendall(command)
|
||||
sock.close()
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"인쇄 오류: {e}")
|
||||
return False
|
||||
|
||||
# 사용 예시
|
||||
message = """
|
||||
================================================
|
||||
[ 테스트 출력 ]
|
||||
================================================
|
||||
환자: 홍길동
|
||||
처방번호: 20260305000001
|
||||
|
||||
[V] 상호작용 없음
|
||||
|
||||
>> 처방 해석
|
||||
감기 증상 완화를 위한 처방입니다.
|
||||
|
||||
================================================
|
||||
청춘약국
|
||||
================================================
|
||||
"""
|
||||
print_text(message)
|
||||
```
|
||||
|
||||
### 중앙 정렬 헬퍼
|
||||
```python
|
||||
def center_text(text: str, width: int = 48) -> str:
|
||||
"""48자 기준 중앙 정렬"""
|
||||
text_len = len(text)
|
||||
if text_len >= width:
|
||||
return text
|
||||
spaces = (width - text_len) // 2
|
||||
return " " * spaces + text
|
||||
|
||||
# 사용
|
||||
print(center_text("[ PAAI 복약안내 ]"))
|
||||
# 출력: " [ PAAI 복약안내 ]"
|
||||
```
|
||||
|
||||
### 줄바꿈 헬퍼
|
||||
```python
|
||||
def wrap_text(text: str, width: int = 44) -> list:
|
||||
"""44자 기준 줄바꿈 (들여쓰기 여유)"""
|
||||
lines = []
|
||||
words = text.split()
|
||||
current = ""
|
||||
|
||||
for word in words:
|
||||
if len(current) + len(word) + 1 <= width:
|
||||
current = current + " " + word if current else word
|
||||
else:
|
||||
if current:
|
||||
lines.append(current)
|
||||
current = word
|
||||
|
||||
if current:
|
||||
lines.append(current)
|
||||
|
||||
return lines if lines else [text[:width]]
|
||||
|
||||
# 사용
|
||||
long_text = "경골 하단 및 중족골 골절로 인한 통증과 부종 관리를 위해 NSAIDs를 처방합니다."
|
||||
for line in wrap_text(long_text, 44):
|
||||
print(f" {line}")
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 프린터 사양
|
||||
|
||||
| 항목 | 값 |
|
||||
|------|-----|
|
||||
| IP | 192.168.0.174 |
|
||||
| Port | 9100 |
|
||||
| 용지 폭 | 80mm (48자) |
|
||||
| 인코딩 | EUC-KR (CP949) |
|
||||
| 한글 | 지원 |
|
||||
| 이모지 | 미지원 |
|
||||
| 이미지 | 미지원 (이 프린터) |
|
||||
|
||||
---
|
||||
|
||||
## 참고 파일
|
||||
|
||||
| 파일 | 설명 |
|
||||
|------|------|
|
||||
| `backend/pos_printer.py` | ESC/POS 기본 유틸리티 |
|
||||
| `backend/paai_printer_cli.py` | PAAI 인쇄 전용 CLI |
|
||||
| `clawd/memory/3월4일 동물약_복약지도서.md` | 동물약 인쇄 가이드 |
|
||||
|
||||
---
|
||||
|
||||
## 테스트 명령어
|
||||
|
||||
```powershell
|
||||
# 연결 테스트
|
||||
Test-NetConnection -ComputerName 192.168.0.174 -Port 9100
|
||||
|
||||
# 간단 인쇄 테스트
|
||||
python -c "
|
||||
import socket
|
||||
sock = socket.socket()
|
||||
sock.connect(('192.168.0.174', 9100))
|
||||
sock.sendall(b'\x1b@Test OK\n\n\n\x1bd\x03')
|
||||
sock.close()
|
||||
print('OK')
|
||||
"
|
||||
|
||||
# pos_printer.py 테스트
|
||||
cd C:\Users\청춘약국\source\pharmacy-pos-qr-system\backend
|
||||
python pos_printer.py
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 히스토리
|
||||
|
||||
| 날짜 | 문제 | 해결 |
|
||||
|------|------|------|
|
||||
| 2026-03-04 | 동물약 투약지도서 이모지 깨짐 | 이모지 제거, 텍스트로 대체 |
|
||||
| 2026-03-05 | PAAI 인쇄 "EAT"만 출력 | 이미지 방식 → 텍스트 방식 변경 |
|
||||
| 2026-03-05 | python-escpos 라이브러리 문제 | socket 직접 전송으로 변경 |
|
||||
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`
|
||||
232
docs/PAAI_AUTO_PRINT_TROUBLESHOOTING.md
Normal file
232
docs/PAAI_AUTO_PRINT_TROUBLESHOOTING.md
Normal file
@ -0,0 +1,232 @@
|
||||
# PAAI 자동인쇄 트러블슈팅 가이드
|
||||
|
||||
## 개요
|
||||
|
||||
PAAI(처방 AI 분석) 결과를 영수증 프린터로 자동 출력하는 기능의 트러블슈팅 가이드입니다.
|
||||
|
||||
---
|
||||
|
||||
## 시스템 구성
|
||||
|
||||
```
|
||||
[처방 접수] → [WebSocket 감지] → [PAAI 분석] → [자동 인쇄]
|
||||
↓ ↓ ↓ ↓
|
||||
POS 입력 ws://8765 Claude API ESC/POS 프린터
|
||||
```
|
||||
|
||||
### 관련 파일
|
||||
- `backend/templates/pmr.html` - 프론트엔드 (토글, WebSocket 클라이언트)
|
||||
- `backend/pmr_api.py` - API 엔드포인트 (`/pmr/api/paai/print`)
|
||||
- `backend/paai_printer.py` - ESC/POS 프린터 모듈
|
||||
|
||||
---
|
||||
|
||||
## 문제 1: 자동인쇄 토글이 작동하지 않음
|
||||
|
||||
### 증상
|
||||
- "자동인쇄" 버튼 클릭해도 ON/OFF 전환 안 됨
|
||||
- 콘솔에 `showToast is not defined` 에러
|
||||
|
||||
### 원인
|
||||
JavaScript 함수가 전역 스코프에 등록되지 않음
|
||||
|
||||
### 해결
|
||||
모든 변수/함수를 `window.` 접두사로 전역 등록:
|
||||
|
||||
```javascript
|
||||
// ❌ 잘못된 방식
|
||||
var autoPrintEnabled = true;
|
||||
function showToast() { ... }
|
||||
|
||||
// ✅ 올바른 방식
|
||||
window.autoPrintEnabled = true;
|
||||
window.showToast = function() { ... };
|
||||
```
|
||||
|
||||
### 체크리스트
|
||||
- [ ] `window.autoPrintEnabled` - boolean
|
||||
- [ ] `window.showToast` - function
|
||||
- [ ] `window.updateAutoPrintIndicator` - function
|
||||
- [ ] `window.printPaaiResult` - function
|
||||
|
||||
브라우저 콘솔에서 확인:
|
||||
```javascript
|
||||
typeof window.showToast // "function" 이어야 함
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 문제 2: 새로고침 후 변경사항 미반영
|
||||
|
||||
### 증상
|
||||
- 코드 수정 후 새로고침해도 이전 버전 실행
|
||||
- `showToast is not defined` 에러 지속
|
||||
|
||||
### 원인
|
||||
1. Flask 서버가 템플릿을 캐시함 (개발 모드 아닐 때)
|
||||
2. 브라우저 캐시
|
||||
|
||||
### 해결
|
||||
|
||||
**Flask 재시작:**
|
||||
```powershell
|
||||
# app.py 프로세스 종료 후 재시작
|
||||
Get-Process -Name python | Where-Object {
|
||||
(Get-WmiObject Win32_Process -Filter "ProcessId=$($_.Id)").CommandLine -match "app.py"
|
||||
} | Stop-Process -Force
|
||||
|
||||
cd C:\Users\청춘약국\source\pharmacy-pos-qr-system\backend
|
||||
python app.py
|
||||
```
|
||||
|
||||
**브라우저 강제 새로고침:**
|
||||
- `Ctrl + F5` (캐시 무시)
|
||||
- 또는 URL에 쿼리 파라미터 추가: `?v=2`
|
||||
|
||||
---
|
||||
|
||||
## 문제 3: 프린터 출력 안 됨
|
||||
|
||||
### 증상
|
||||
- 콘솔에 `[AutoPrint] 인쇄 요청:` 로그는 보임
|
||||
- 프린터에서 출력 없음
|
||||
|
||||
### 원인 및 해결
|
||||
|
||||
**1. 프린터 연결 확인:**
|
||||
```powershell
|
||||
# USB 프린터 확인
|
||||
Get-WmiObject Win32_Printer | Select Name, PortName
|
||||
```
|
||||
|
||||
**2. API 응답 확인:**
|
||||
```powershell
|
||||
# 직접 API 테스트
|
||||
curl -X POST http://localhost:7001/pmr/api/paai/print `
|
||||
-H "Content-Type: application/json" `
|
||||
-d '{"pre_serial":"test","patient_name":"테스트","result":{"analysis":{}}}'
|
||||
```
|
||||
|
||||
**3. paai_printer.py 단독 테스트:**
|
||||
```powershell
|
||||
cd C:\Users\청춘약국\source\pharmacy-pos-qr-system\backend
|
||||
python paai_printer_cli.py test
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 문제 4: 한글 깨짐
|
||||
|
||||
### 증상
|
||||
- 프린터 출력에서 한글이 `????` 또는 깨진 문자로 출력
|
||||
|
||||
### 원인
|
||||
프린터가 EUC-KR 인코딩 필요 (CP949)
|
||||
|
||||
### 해결
|
||||
`paai_printer.py`에서 EUC-KR 인코딩 사용:
|
||||
|
||||
```python
|
||||
def encode_korean(text):
|
||||
"""한글을 EUC-KR로 인코딩"""
|
||||
try:
|
||||
return text.encode('euc-kr', errors='replace')
|
||||
except:
|
||||
return text.encode('ascii', errors='replace')
|
||||
```
|
||||
|
||||
ESC/POS 명령어:
|
||||
```python
|
||||
# 한글 모드 설정 (Code Page 949)
|
||||
printer.write(b'\x1b\x40') # 초기화
|
||||
printer.write(b'\x1c\x43\x01') # 한글 모드
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 문제 5: WebSocket 연결 실패
|
||||
|
||||
### 증상
|
||||
- "자동감지 OFF" 표시
|
||||
- 콘솔에 `WebSocket connection failed` 에러
|
||||
|
||||
### 원인
|
||||
처방감지 서버 (`trigger_server.py`)가 실행되지 않음
|
||||
|
||||
### 해결
|
||||
```powershell
|
||||
cd C:\Users\청춘약국\source\pharmacy-pos-qr-system\backend
|
||||
python trigger_server.py
|
||||
```
|
||||
|
||||
정상 연결 시 콘솔 로그:
|
||||
```
|
||||
[Trigger] 연결 시도: ws://localhost:8765
|
||||
[Trigger] ✅ 연결됨
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 디버깅 체크리스트
|
||||
|
||||
### 브라우저 콘솔 확인
|
||||
```javascript
|
||||
// 전역 함수 확인
|
||||
typeof window.autoPrintEnabled // boolean
|
||||
typeof window.showToast // "function"
|
||||
typeof window.printPaaiResult // "function"
|
||||
|
||||
// 수동 인쇄 테스트
|
||||
window.printPaaiResult('test123', '테스트환자', {
|
||||
analysis: { prescription_insight: '테스트' }
|
||||
});
|
||||
```
|
||||
|
||||
### 서버 로그 확인
|
||||
```powershell
|
||||
# Flask 로그에서 인쇄 API 호출 확인
|
||||
# POST /pmr/api/paai/print 200
|
||||
```
|
||||
|
||||
### localStorage 확인
|
||||
```javascript
|
||||
localStorage.getItem('pmr_auto_print') // "true" 또는 "false"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 관련 커밋 히스토리
|
||||
|
||||
| 커밋 | 설명 |
|
||||
|------|------|
|
||||
| `b4e4a44` | 자동인쇄 전역 변수/함수 완전 수정 |
|
||||
| `e33204f` | printPaaiResult 전역 함수로 변경 |
|
||||
| `0bbc8a5` | showToast 함수 추가 |
|
||||
| `0b17139` | PAAI 자동인쇄 기능 완성 (EUC-KR) |
|
||||
|
||||
---
|
||||
|
||||
## 전체 플로우 요약
|
||||
|
||||
```
|
||||
1. 처방 접수 (POS)
|
||||
↓
|
||||
2. trigger_server.py가 감지 → WebSocket 브로드캐스트
|
||||
↓
|
||||
3. pmr.html이 WebSocket 메시지 수신
|
||||
↓
|
||||
4. 자동감지 ON이면 → PAAI 분석 요청 (/pmr/api/paai/analyze)
|
||||
↓
|
||||
5. 분석 완료 → analysis_completed 이벤트
|
||||
↓
|
||||
6. 자동인쇄 ON이면 → printPaaiResult() 호출
|
||||
↓
|
||||
7. /pmr/api/paai/print API → paai_printer.py
|
||||
↓
|
||||
8. ESC/POS 명령어로 영수증 프린터 출력 🖨️
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
*문서 작성일: 2026-03-05*
|
||||
*작성자: 용림 🐉*
|
||||
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 저장
|
||||
- [ ] 다중 도매상 지원
|
||||
199
docs/WHOLESALE_API_INTEGRATION.md
Normal file
199
docs/WHOLESALE_API_INTEGRATION.md
Normal file
@ -0,0 +1,199 @@
|
||||
# 도매상 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 | **항목 취소 (soft delete)** |
|
||||
| POST | /cart/restore | **항목 복원** |
|
||||
| POST | /confirm | 주문 전송 |
|
||||
|
||||
### 지오영 (/api/geoyoung/*)
|
||||
|
||||
| 메서드 | 경로 | 설명 |
|
||||
|--------|------|------|
|
||||
| GET | /stock | 재고 검색 |
|
||||
| GET | /cart | 장바구니 조회 |
|
||||
| POST | /order | 장바구니 추가 |
|
||||
| POST | /cart/clear | 장바구니 비우기 |
|
||||
| POST | /cart/cancel | **항목 삭제 (hard delete)** |
|
||||
| POST | /cart/restore | ❌ NOT_SUPPORTED |
|
||||
| POST | /confirm | 주문 전송 |
|
||||
|
||||
### 개별 삭제 API 차이
|
||||
|
||||
| 도매상 | cancel 동작 | restore 가능 | 내부 API |
|
||||
|--------|-------------|-------------|----------|
|
||||
| 수인 | 체크박스 soft delete | ✅ 가능 | ControlBag.asp |
|
||||
| 지오영 | 완전 삭제 | ❌ 불가 | DataCart/del |
|
||||
|
||||
---
|
||||
|
||||
## 📁 관련 문서
|
||||
|
||||
| 문서 | 위치 | 내용 |
|
||||
|------|------|------|
|
||||
| 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] 개별 취소 기능 (수인) - soft delete
|
||||
- [x] 개별 삭제 기능 (지오영) - hard delete
|
||||
- [x] Flask Blueprint 통합
|
||||
- [x] wholesale 패키지 분리
|
||||
- [x] SQLite 스키마 업데이트
|
||||
|
||||
### 진행 예정
|
||||
- [ ] daily_usage 자동 수집
|
||||
- [ ] AI 규격 선택 모델
|
||||
- [ ] AI 도매상 선택 모델
|
||||
- [ ] 자동 주문 Level 1 (승인 후 실행)
|
||||
|
||||
---
|
||||
|
||||
*업데이트: 2026-03-06 by 용림 🐉*
|
||||
170
docs/세로모니터_레이아웃_개선_계획.md
Normal file
170
docs/세로모니터_레이아웃_개선_계획.md
Normal file
@ -0,0 +1,170 @@
|
||||
# PMR 세로 모니터 레이아웃 개선 계획
|
||||
|
||||
## 현재 상황
|
||||
|
||||
### 환경
|
||||
- 모니터: 2.5K (2560x1440) 세로 모드 → 1440x2560
|
||||
- 문제: 환자목록과 처방전 내용이 **거의 절반씩** 차지
|
||||
- 환자목록은 그렇게 넓을 필요 없음
|
||||
|
||||
### 현재 CSS 구조
|
||||
```css
|
||||
.main-content {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
}
|
||||
.patient-list {
|
||||
width: 380px; /* 고정 너비 */
|
||||
}
|
||||
.prescription-panel {
|
||||
flex: 1; /* 나머지 공간 */
|
||||
}
|
||||
```
|
||||
|
||||
### 세로 모니터에서의 문제
|
||||
- 화면 너비: 1440px
|
||||
- 환자목록: 380px (26%)
|
||||
- 처방전: ~1040px (72%)
|
||||
- **실제로는 환자목록이 26%인데 "절반처럼" 느껴짐** → 세로가 길어서 상대적으로 넓어 보임
|
||||
|
||||
---
|
||||
|
||||
## 해결 방안 비교
|
||||
|
||||
### 방안 1: 미디어쿼리 (aspect-ratio)
|
||||
```css
|
||||
/* 세로 모니터 감지 (높이 > 너비) */
|
||||
@media (orientation: portrait) {
|
||||
.patient-list {
|
||||
width: 280px; /* 더 좁게 */
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**장점:**
|
||||
- 간단, 직관적
|
||||
- 기존 코드 영향 최소화
|
||||
|
||||
**단점:**
|
||||
- 세로 모니터 전용 스타일 분기 필요
|
||||
|
||||
---
|
||||
|
||||
### 방안 2: CSS Container Queries
|
||||
```css
|
||||
.main-content {
|
||||
container-type: inline-size;
|
||||
}
|
||||
@container (max-width: 1500px) {
|
||||
.patient-list {
|
||||
width: 280px;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**장점:**
|
||||
- 모던한 접근
|
||||
- 컨테이너 기준으로 반응
|
||||
|
||||
**단점:**
|
||||
- 브라우저 지원 확인 필요 (Chrome 105+)
|
||||
|
||||
---
|
||||
|
||||
### 방안 3: 환자목록 비율 기반 (추천 ⭐)
|
||||
```css
|
||||
.patient-list {
|
||||
width: 280px;
|
||||
min-width: 250px;
|
||||
max-width: 380px;
|
||||
}
|
||||
```
|
||||
|
||||
또는:
|
||||
```css
|
||||
.patient-list {
|
||||
width: clamp(250px, 20vw, 380px);
|
||||
}
|
||||
```
|
||||
|
||||
**장점:**
|
||||
- 미디어쿼리 없이 자동 조절
|
||||
- 모든 화면에서 적절한 비율 유지
|
||||
- **가장 단순함**
|
||||
|
||||
**단점:**
|
||||
- 특정 breakpoint 세밀 조정 어려움
|
||||
|
||||
---
|
||||
|
||||
### 방안 4: 세로 모니터 전용 레이아웃
|
||||
```css
|
||||
@media (orientation: portrait) and (min-height: 1800px) {
|
||||
.main-content {
|
||||
flex-direction: column;
|
||||
}
|
||||
.patient-list {
|
||||
width: 100%;
|
||||
height: 200px; /* 상단 고정 */
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**장점:**
|
||||
- 세로 모니터 최적화
|
||||
|
||||
**단점:**
|
||||
- 레이아웃 완전 변경 → 복잡
|
||||
- UX 변화 큼
|
||||
|
||||
---
|
||||
|
||||
## 추천 방안: **방안 3 + 방안 1 조합**
|
||||
|
||||
### 구현
|
||||
```css
|
||||
/* 기본: 비율 기반 너비 */
|
||||
.patient-list {
|
||||
width: clamp(250px, 22vw, 380px);
|
||||
}
|
||||
|
||||
/* 세로 모니터에서 더 좁게 */
|
||||
@media (orientation: portrait) {
|
||||
.patient-list {
|
||||
width: clamp(220px, 18vw, 300px);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 이유
|
||||
1. **clamp()**: 최소/최대 범위 내에서 자동 조절
|
||||
2. **portrait 미디어쿼리**: 세로 모니터 추가 최적화
|
||||
3. **코드 2줄 추가**로 해결 가능
|
||||
|
||||
---
|
||||
|
||||
## 작업 범위
|
||||
|
||||
### 변경 파일
|
||||
- `pmr.html` - CSS 수정 (약 5줄)
|
||||
|
||||
### 테스트
|
||||
- 가로 모니터 (기존 동작 유지)
|
||||
- 세로 모니터 (환자목록 좁아짐)
|
||||
- 반응형 resize
|
||||
|
||||
---
|
||||
|
||||
## 예상 결과
|
||||
|
||||
| 모드 | 환자목록 너비 | 비율 |
|
||||
|------|--------------|------|
|
||||
| 가로 (1920px) | ~380px | 20% |
|
||||
| 가로 (1440px) | ~320px | 22% |
|
||||
| **세로 (1440px)** | **~260px** | **18%** |
|
||||
|
||||
---
|
||||
|
||||
## 승인 시 진행
|
||||
|
||||
약사님 확인 후 바로 구현 가능합니다.
|
||||
Loading…
Reference in New Issue
Block a user