Compare commits
186 Commits
main
...
a7bcf46aaa
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a7bcf46aaa | ||
|
|
e82f4be4af | ||
|
|
eda0429a85 | ||
|
|
71d1916efb | ||
|
|
c71c9ad678 | ||
|
|
91f8dea5b4 | ||
|
|
d6cf4c2cc1 | ||
|
|
09948c234f | ||
|
|
a23e4bad43 | ||
|
|
1088720081 | ||
|
|
497aeee75f | ||
|
|
0ae4ae66f0 | ||
|
|
232a77006a | ||
|
|
20fc528c2b | ||
|
|
0f69b50c49 | ||
|
|
dc2a992c12 | ||
|
|
21c8124811 | ||
|
|
33c6cd2d5c | ||
|
|
e5744e4f0f | ||
|
|
1720c108b5 | ||
|
|
d842c776c9 | ||
|
|
c1fae04344 | ||
|
|
b6d0fadb3c | ||
|
|
ee300f80ca | ||
|
|
846883cbfa | ||
|
|
29597d55fa | ||
|
|
442815b65e | ||
|
|
a672c7a2a0 | ||
|
|
f48e657e12 | ||
|
|
268f5bce8f | ||
|
|
ad58cde952 | ||
|
|
760aea6f89 | ||
|
|
be95f8b3d1 | ||
|
|
5519f5ae62 | ||
|
|
4b2d934839 | ||
|
|
06c975ce34 | ||
|
|
ad0b55ee2d | ||
|
|
2d09f139ca | ||
|
|
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 | ||
|
|
cb90d4a7a6 | ||
|
|
f3b6496c91 | ||
|
|
16c3881661 | ||
|
|
59a55d6b22 | ||
|
|
4275689c29 | ||
|
|
1b33f82fd4 | ||
|
|
141b211f07 | ||
|
|
088d88878a | ||
|
|
ebf2e8a016 | ||
|
|
41428646ab | ||
|
|
4fc667b844 | ||
|
|
7928bbd55c | ||
|
|
d8aa073564 | ||
|
|
6192f635ca | ||
|
|
fc2db78816 | ||
|
|
c21aa956da | ||
|
|
8d025457c0 | ||
|
|
75448ffdc5 | ||
|
|
1054a9ed17 | ||
|
|
71c35433fc | ||
|
|
836be958db | ||
|
|
f829276431 | ||
|
|
9ff25dcbce | ||
|
|
4352a8b9a8 | ||
|
|
5a2ab044ba | ||
|
|
a89dc9b354 | ||
|
|
27da568a13 | ||
|
|
abb8ad1325 | ||
|
|
f374ca4fd1 | ||
|
|
e2d3ea032f | ||
|
|
097bc4c84f | ||
|
|
321fd0de1e | ||
|
|
77c667e1f6 | ||
|
|
1c2bfd473b | ||
|
|
6bb86f8780 | ||
|
|
e95c08ef59 | ||
|
|
27bb0b7b86 | ||
|
|
96a3df8470 | ||
|
|
e7096f7bed | ||
|
|
01f0df9294 | ||
|
|
2859dc43cc | ||
|
|
a0cbb984e5 | ||
|
|
5dd3489385 | ||
|
|
b660f324ac | ||
|
|
fa4e87b461 | ||
|
|
9ce7e884d7 | ||
|
|
5074adce20 | ||
|
|
50825c597e | ||
|
|
acf8e44aa5 | ||
|
|
546a5e7ae6 | ||
|
|
30d95c8579 | ||
|
|
51216c582f | ||
|
|
9ba2846820 | ||
|
|
0aebdaea0c | ||
|
|
467c0e91aa | ||
|
|
0676c4f466 | ||
|
|
79259d004b | ||
|
|
8aa43221d2 | ||
|
|
95fdd23817 | ||
|
|
65754f594b | ||
|
|
4a3ec38ba7 | ||
|
|
4a06e60e29 | ||
|
|
ee28f97c11 | ||
|
|
29648e3a7d | ||
|
|
4713395557 | ||
|
|
007b37e6c6 | ||
|
|
0e954ac749 | ||
|
|
887aba3a03 | ||
|
|
c154537c87 | ||
|
|
b71d511c7a | ||
|
|
ac0e1ced0e | ||
|
|
76a4280ebd | ||
|
|
c525632246 | ||
|
|
a7b3d5b7e0 | ||
|
|
695c1f707f | ||
|
|
f1e609ba9f | ||
|
|
e10b50e0c3 | ||
|
|
c279e53c3e | ||
|
|
e37659dc04 | ||
|
|
52a4f69abc | ||
|
|
1cebb02ec6 | ||
|
|
f102f6b42e | ||
|
|
16adca3646 | ||
|
|
fbe7dde4ce | ||
|
|
8c20c8b8db | ||
|
|
67e576736d | ||
|
|
4c0cd68267 | ||
|
|
68dcb919e4 | ||
|
|
6a786ff042 | ||
|
|
4c93ee038a | ||
|
|
a42af23038 | ||
|
|
180393700b | ||
|
|
21e07bcca9 | ||
|
|
95d7ebab71 | ||
|
|
c1c38c68ac | ||
|
|
fd77dcbef9 | ||
|
|
912679b137 | ||
|
|
f438f42d15 | ||
|
|
b1d5bcfc98 | ||
|
|
8b58ab0d3a | ||
|
|
c022ee21d0 | ||
|
|
d612563580 | ||
|
|
dfbc6e4761 | ||
|
|
8ee148abe4 | ||
|
|
3c9739a92e | ||
|
|
73b8c8ec88 | ||
|
|
4254a0f7a2 | ||
|
|
e12328ec17 | ||
|
|
009d133aef | ||
|
|
9019347d48 | ||
|
|
b95e14419e | ||
|
|
dd28958a59 |
7
.gitignore
vendored
@@ -87,5 +87,12 @@ tmp/
|
||||
*.tmp
|
||||
.claude/
|
||||
|
||||
# Test/Debug scripts (일회성 분석용)
|
||||
backend/scripts/check_*.py
|
||||
backend/scripts/find_*.py
|
||||
backend/scripts/search_*.py
|
||||
backend/scripts/compare_*.py
|
||||
backend/scripts/analyze_*.py
|
||||
|
||||
# GUI settings (user-specific)
|
||||
gui_settings.json
|
||||
|
||||
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
|
||||
|
||||
86
backend/analyze_baekje_ledger.py
Normal file
@@ -0,0 +1,86 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""백제약품 주문 원장 페이지 분석"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
from dotenv import load_dotenv
|
||||
|
||||
load_dotenv()
|
||||
|
||||
async def analyze_order_ledger():
|
||||
from playwright.async_api import async_playwright
|
||||
|
||||
username = os.getenv('BAEKJE_USER_ID')
|
||||
password = os.getenv('BAEKJE_PASSWORD')
|
||||
|
||||
print(f'Username: {username}')
|
||||
|
||||
async with async_playwright() as p:
|
||||
browser = await p.chromium.launch(headless=False)
|
||||
context = await browser.new_context()
|
||||
page = await context.new_page()
|
||||
|
||||
# 로그인 페이지
|
||||
await page.goto('https://ibjp.co.kr/dist/login', timeout=15000)
|
||||
await page.wait_for_load_state('networkidle', timeout=10000)
|
||||
|
||||
# 로그인 폼 입력
|
||||
inputs = await page.locator('input[type="text"], input[type="password"]').all()
|
||||
if len(inputs) >= 2:
|
||||
await inputs[0].fill(username)
|
||||
await inputs[1].fill(password)
|
||||
|
||||
# 로그인 버튼 클릭
|
||||
buttons = await page.locator('button').all()
|
||||
for btn in buttons:
|
||||
text = await btn.text_content()
|
||||
if '로그인' in (text or ''):
|
||||
await btn.click()
|
||||
break
|
||||
|
||||
# 로그인 완료 대기
|
||||
try:
|
||||
await page.wait_for_url('**/comOrd**', timeout=15000)
|
||||
print('Login successful, redirected to comOrd')
|
||||
except Exception as e:
|
||||
print(f'URL wait failed: {e}')
|
||||
await asyncio.sleep(3)
|
||||
|
||||
print(f'Current URL: {page.url}')
|
||||
|
||||
# 주문 원장 페이지로 이동
|
||||
await page.goto('https://ibjp.co.kr/dist/ordLedger', timeout=15000)
|
||||
await page.wait_for_load_state('networkidle', timeout=15000)
|
||||
|
||||
print(f'Order Ledger URL: {page.url}')
|
||||
|
||||
# 페이지 HTML 저장
|
||||
html = await page.content()
|
||||
with open('ordLedger_page.html', 'w', encoding='utf-8') as f:
|
||||
f.write(html)
|
||||
print('Page HTML saved to ordLedger_page.html')
|
||||
|
||||
# 스크린샷 저장
|
||||
await page.screenshot(path='ordLedger_screenshot.png', full_page=True)
|
||||
print('Screenshot saved')
|
||||
|
||||
# 테이블 데이터 분석
|
||||
tables = await page.locator('table').all()
|
||||
print(f'Found {len(tables)} tables')
|
||||
|
||||
for i, table in enumerate(tables):
|
||||
headers = await table.locator('th').all()
|
||||
header_texts = [await h.text_content() for h in headers]
|
||||
print(f'Table {i} headers: {header_texts}')
|
||||
|
||||
# 페이지 텍스트 출력 (분석용)
|
||||
body_text = await page.locator('body').text_content()
|
||||
print('\n=== Page Text Preview ===')
|
||||
print(body_text[:3000] if body_text else 'No text')
|
||||
|
||||
await asyncio.sleep(30) # 페이지 확인 시간
|
||||
await browser.close()
|
||||
|
||||
if __name__ == '__main__':
|
||||
asyncio.run(analyze_order_ledger())
|
||||
16
backend/analyze_bag.py
Normal file
@@ -0,0 +1,16 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
import sys; sys.path.insert(0, '.'); import wholesale_path
|
||||
from wholesale import SooinSession
|
||||
|
||||
s = SooinSession()
|
||||
s.login()
|
||||
|
||||
# Bag.asp HTML 가져오기
|
||||
resp = s.session.get(f'{s.BAG_VIEW_URL}?currVenCd={s.vendor_code}', timeout=15)
|
||||
|
||||
# 파일로 저장
|
||||
with open('bag_page.html', 'w', encoding='utf-8') as f:
|
||||
f.write(resp.text)
|
||||
|
||||
print('bag_page.html 저장됨')
|
||||
print(f'응답 길이: {len(resp.text)}')
|
||||
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())
|
||||
4104
backend/app.py
447
backend/baekje_api.py
Normal file
@@ -0,0 +1,447 @@
|
||||
# -*- 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)
|
||||
|
||||
|
||||
@baekje_bp.route('/orders/summary-by-kd', methods=['GET'])
|
||||
def api_baekje_orders_by_kd():
|
||||
"""
|
||||
백제약품 주문량 KD코드별 집계 API
|
||||
|
||||
GET /api/baekje/orders/summary-by-kd?start_date=2026-03-01&end_date=2026-03-07
|
||||
|
||||
Returns:
|
||||
{
|
||||
"success": true,
|
||||
"order_count": 4,
|
||||
"by_kd_code": {
|
||||
"670400830": {
|
||||
"product_name": "레바미피드정",
|
||||
"spec": "100T",
|
||||
"boxes": 2,
|
||||
"units": 200
|
||||
}
|
||||
},
|
||||
"total_products": 15
|
||||
}
|
||||
"""
|
||||
import re
|
||||
from datetime import datetime
|
||||
|
||||
today = datetime.now().strftime("%Y-%m-%d")
|
||||
start_date = flask_request.args.get('start_date', today).strip()
|
||||
end_date = flask_request.args.get('end_date', today).strip()
|
||||
|
||||
def parse_spec(spec: str, product_name: str = '') -> int:
|
||||
"""
|
||||
규격에서 수량 추출 (30T → 30, 100C → 100)
|
||||
"""
|
||||
combined = f"{spec} {product_name}"
|
||||
|
||||
# D(도즈) 단위는 박스 단위로 계산 (140D → 1)
|
||||
if re.search(r'\d+\s*D\b', combined, re.IGNORECASE):
|
||||
return 1
|
||||
|
||||
# T/C/P 단위가 붙은 숫자 추출 (예: 14T, 100C, 30P)
|
||||
qty_match = re.search(r'(\d+)\s*[TCP]\b', combined, re.IGNORECASE)
|
||||
if qty_match:
|
||||
return int(qty_match.group(1))
|
||||
|
||||
# 없으면 spec의 첫 번째 숫자
|
||||
if spec:
|
||||
num_match = re.search(r'(\d+)', spec)
|
||||
if num_match:
|
||||
val = int(num_match.group(1))
|
||||
# mg, ml 같은 용량 단위면 수량 1로 처리
|
||||
if re.search(r'\d+\s*(mg|ml|g)\b', spec, re.IGNORECASE):
|
||||
return 1
|
||||
return val
|
||||
|
||||
return 1
|
||||
|
||||
try:
|
||||
session = get_baekje_session()
|
||||
|
||||
# 주문 목록 + 상세를 한 번에 조회 (include_details=True)
|
||||
# 접수 상태(확정 전)도 포함됨!
|
||||
orders_result = session.get_order_list(start_date, end_date, include_details=True)
|
||||
|
||||
if not orders_result.get('success'):
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': orders_result.get('error', 'ORDERS_FETCH_FAILED'),
|
||||
'by_kd_code': {},
|
||||
'order_count': 0
|
||||
})
|
||||
|
||||
orders = orders_result.get('orders', [])
|
||||
|
||||
if not orders:
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'order_count': 0,
|
||||
'period': {'start': start_date, 'end': end_date},
|
||||
'by_kd_code': {},
|
||||
'total_products': 0,
|
||||
'pending_count': 0,
|
||||
'approved_count': 0
|
||||
})
|
||||
|
||||
# KD코드별 집계 (items가 이미 각 order에 포함됨)
|
||||
kd_summary = {}
|
||||
|
||||
for order in orders:
|
||||
for item in order.get('items', []):
|
||||
# 취소 상태 제외
|
||||
status = item.get('status', '').strip()
|
||||
if '취소' in status or '삭제' in status:
|
||||
continue
|
||||
|
||||
# 백제는 kd_code가 insurance_code(BOHUM_CD)에 있음
|
||||
kd_code = item.get('kd_code', '') or item.get('insurance_code', '')
|
||||
if not kd_code:
|
||||
continue
|
||||
|
||||
product_name = item.get('product_name', '')
|
||||
spec = item.get('spec', '')
|
||||
quantity = item.get('quantity', 0) or item.get('order_qty', 0)
|
||||
per_unit = parse_spec(spec, product_name)
|
||||
total_units = quantity * per_unit
|
||||
|
||||
if kd_code not in kd_summary:
|
||||
kd_summary[kd_code] = {
|
||||
'product_name': product_name,
|
||||
'spec': spec,
|
||||
'boxes': 0,
|
||||
'units': 0
|
||||
}
|
||||
|
||||
kd_summary[kd_code]['boxes'] += quantity
|
||||
kd_summary[kd_code]['units'] += total_units
|
||||
|
||||
pending_count = orders_result.get('pending_count', 0)
|
||||
approved_count = orders_result.get('approved_count', 0)
|
||||
|
||||
logger.info(f"백제 주문량 집계: {start_date}~{end_date}, {len(orders)}건 (접수:{pending_count}, 승인:{approved_count}), {len(kd_summary)}개 품목")
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'order_count': len(orders),
|
||||
'pending_count': pending_count, # 접수 상태 (확정 전)
|
||||
'approved_count': approved_count, # 승인 상태 (확정됨)
|
||||
'period': {'start': start_date, 'end': end_date},
|
||||
'by_kd_code': kd_summary,
|
||||
'total_products': len(kd_summary)
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"백제 주문량 집계 오류: {e}")
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': 'API_ERROR',
|
||||
'message': str(e),
|
||||
'by_kd_code': {},
|
||||
'order_count': 0
|
||||
}), 500
|
||||
|
||||
|
||||
@baekje_bp.route('/monthly-sales', methods=['GET'])
|
||||
def api_get_monthly_sales():
|
||||
"""
|
||||
월간 매출(주문) 합계 조회
|
||||
|
||||
GET /api/baekje/monthly-sales?year=2026&month=3
|
||||
|
||||
Returns:
|
||||
{
|
||||
"success": true,
|
||||
"total_amount": 7305877, // 월간 매출 합계
|
||||
"total_returns": 0, // 월간 반품 합계
|
||||
"net_amount": 7305877, // 순매출 (매출 - 반품)
|
||||
"total_paid": 0, // 월간 입금 합계
|
||||
"ending_balance": 14563978, // 월말 잔액
|
||||
"prev_balance": 14565453, // 전월이월금
|
||||
"from_date": "2026-03-01",
|
||||
"to_date": "2026-03-31",
|
||||
"rotate_days": 58.4 // 회전일수
|
||||
}
|
||||
"""
|
||||
from datetime import datetime
|
||||
|
||||
year = flask_request.args.get('year', '').strip()
|
||||
month = flask_request.args.get('month', '').strip()
|
||||
|
||||
# 기본값: 현재 연월
|
||||
now = datetime.now()
|
||||
if not year:
|
||||
year = now.year
|
||||
else:
|
||||
year = int(year)
|
||||
|
||||
if not month:
|
||||
month = now.month
|
||||
else:
|
||||
month = int(month)
|
||||
|
||||
session = get_baekje_session()
|
||||
result = session.get_monthly_sales(year, month)
|
||||
return jsonify(result)
|
||||
150
backend/bag_page.html
Normal file
@@ -0,0 +1,150 @@
|
||||
|
||||
|
||||
<!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>
|
||||
|
||||
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())
|
||||
82
backend/capture_order.py
Normal file
@@ -0,0 +1,82 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
네트워크 캡처용 - 약사님이 직접 주문 버튼 클릭
|
||||
"""
|
||||
import sys; sys.path.insert(0, '.'); import wholesale_path
|
||||
from wholesale import SooinSession
|
||||
from playwright.sync_api import sync_playwright
|
||||
import time
|
||||
|
||||
s = SooinSession()
|
||||
print('로그인...')
|
||||
s.login()
|
||||
|
||||
# 장바구니에 코자정 담기
|
||||
print('\n코자정 검색...')
|
||||
result = s.search_products('코자정 50mg PTP')
|
||||
product = None
|
||||
for item in result.get('items', []):
|
||||
if 'PTP' in item['name']:
|
||||
product = item
|
||||
break
|
||||
|
||||
if product:
|
||||
print(f"제품: {product['name']} - {product['price']:,}원")
|
||||
s.add_to_cart(product['internal_code'], qty=1,
|
||||
price=product['price'], stock=product['stock'])
|
||||
print('장바구니에 담음!')
|
||||
else:
|
||||
print('제품 못 찾음')
|
||||
|
||||
# 장바구니 확인
|
||||
cart = s.get_cart()
|
||||
print(f"\n장바구니: {cart['total_items']}개, {cart['total_amount']:,}원")
|
||||
|
||||
print('\n' + '='*50)
|
||||
print('브라우저 열기 + 네트워크 캡처 시작')
|
||||
print('='*50)
|
||||
|
||||
with sync_playwright() as p:
|
||||
browser = p.chromium.launch(headless=False) # 브라우저 보임
|
||||
context = browser.new_context()
|
||||
|
||||
# 세션 쿠키 복사
|
||||
for c in s.session.cookies:
|
||||
context.add_cookies([{
|
||||
'name': c.name,
|
||||
'value': c.value,
|
||||
'domain': c.domain or 'sooinpharm.co.kr',
|
||||
'path': c.path or '/'
|
||||
}])
|
||||
|
||||
page = context.new_page()
|
||||
|
||||
# 네트워크 요청 캡처
|
||||
def on_request(request):
|
||||
if 'BagOrder' in request.url and request.method == 'POST':
|
||||
print('\n' + '='*50)
|
||||
print('🎯 POST 요청 캡처!')
|
||||
print('='*50)
|
||||
print(f'URL: {request.url}')
|
||||
print(f'\nPOST 데이터:')
|
||||
data = request.post_data or ''
|
||||
# 파라미터별로 출력
|
||||
for param in data.split('&'):
|
||||
if '=' in param:
|
||||
key, val = param.split('=', 1)
|
||||
print(f' {key}: {val[:50]}')
|
||||
print('='*50)
|
||||
|
||||
page.on('request', on_request)
|
||||
|
||||
# 주문 페이지로 이동
|
||||
page.goto('http://sooinpharm.co.kr/Service/Order/Order.asp')
|
||||
|
||||
print('\n✅ 브라우저 준비 완료!')
|
||||
print('👆 주문전송 버튼을 클릭해주세요!')
|
||||
print('\n(Enter 누르면 브라우저 닫힘)')
|
||||
input()
|
||||
|
||||
browser.close()
|
||||
|
||||
print('\n완료!')
|
||||
79
backend/capture_order2.py
Normal file
@@ -0,0 +1,79 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
네트워크 캡처 v2 - 새로고침 후에도 캡처
|
||||
"""
|
||||
import sys; sys.path.insert(0, '.'); import wholesale_path
|
||||
from wholesale import SooinSession
|
||||
from playwright.sync_api import sync_playwright
|
||||
import time
|
||||
|
||||
s = SooinSession()
|
||||
print('로그인...')
|
||||
s.login()
|
||||
|
||||
# 먼저 장바구니 비우기
|
||||
s.clear_cart()
|
||||
|
||||
# 코자정 담기
|
||||
print('코자정 검색...')
|
||||
result = s.search_products('코자정')
|
||||
product = result['items'][0] if result.get('items') else None
|
||||
|
||||
if product:
|
||||
print(f"제품: {product['name']} - {product['price']:,}원")
|
||||
s.add_to_cart(product['internal_code'], qty=1,
|
||||
price=product['price'], stock=product['stock'])
|
||||
print('장바구니에 담음!')
|
||||
|
||||
cart = s.get_cart()
|
||||
print(f"장바구니: {cart['total_items']}개")
|
||||
|
||||
print('\n브라우저 열기...')
|
||||
|
||||
with sync_playwright() as p:
|
||||
browser = p.chromium.launch(headless=False)
|
||||
context = browser.new_context()
|
||||
|
||||
# 쿠키 복사
|
||||
for c in s.session.cookies:
|
||||
context.add_cookies([{
|
||||
'name': c.name,
|
||||
'value': c.value,
|
||||
'domain': c.domain or 'sooinpharm.co.kr',
|
||||
'path': c.path or '/'
|
||||
}])
|
||||
|
||||
page = context.new_page()
|
||||
|
||||
# 모든 요청 캡처 (지속적)
|
||||
captured = []
|
||||
def capture(request):
|
||||
if 'BagOrder' in request.url and request.method == 'POST':
|
||||
data = request.post_data or ''
|
||||
captured.append(data)
|
||||
print('\n' + '='*60)
|
||||
print('🎯 POST 캡처!')
|
||||
print('='*60)
|
||||
for param in data.split('&')[:30]: # 주요 파라미터만
|
||||
if '=' in param:
|
||||
k, v = param.split('=', 1)
|
||||
if v: # 값이 있는 것만
|
||||
print(f' {k}: {v[:60]}')
|
||||
print('='*60)
|
||||
|
||||
context.on('request', capture) # context 레벨에서 캡처
|
||||
|
||||
page.goto('http://sooinpharm.co.kr/Service/Order/Order.asp')
|
||||
|
||||
print('\n✅ 준비 완료!')
|
||||
print('👆 F5로 새로고침 후 주문전송 버튼 클릭!')
|
||||
print('\n(Enter 누르면 종료)')
|
||||
input()
|
||||
|
||||
# 캡처된 데이터 파일로 저장
|
||||
if captured:
|
||||
with open('captured_post.txt', 'w', encoding='utf-8') as f:
|
||||
f.write(captured[0])
|
||||
print('\n📁 captured_post.txt 저장됨')
|
||||
|
||||
browser.close()
|
||||
@@ -1,70 +0,0 @@
|
||||
"""
|
||||
바코드가 있는 제품 샘플 조회
|
||||
"""
|
||||
|
||||
import sys
|
||||
import os
|
||||
sys.path.insert(0, os.path.dirname(__file__))
|
||||
|
||||
from db.dbsetup import DatabaseManager
|
||||
from sqlalchemy import text
|
||||
|
||||
def check_barcode_samples():
|
||||
"""바코드가 있는 제품 샘플 조회"""
|
||||
db_manager = DatabaseManager()
|
||||
|
||||
try:
|
||||
session = db_manager.get_session('PM_PRES')
|
||||
|
||||
# 바코드가 있는 제품 샘플 조회
|
||||
query = text("""
|
||||
SELECT TOP 10
|
||||
S.DrugCode,
|
||||
S.BARCODE,
|
||||
G.GoodsName,
|
||||
S.SL_NM_cost_a as price
|
||||
FROM SALE_SUB S
|
||||
LEFT JOIN PM_DRUG.dbo.CD_GOODS G ON S.DrugCode = G.DrugCode
|
||||
WHERE S.BARCODE IS NOT NULL AND S.BARCODE != ''
|
||||
ORDER BY S.SL_NO_order DESC
|
||||
""")
|
||||
|
||||
results = session.execute(query).fetchall()
|
||||
|
||||
print('=' * 100)
|
||||
print('바코드가 있는 제품 샘플 (최근 10개)')
|
||||
print('=' * 100)
|
||||
for r in results:
|
||||
barcode = r.BARCODE if r.BARCODE else '(없음)'
|
||||
goods_name = r.GoodsName if r.GoodsName else '(약품명 없음)'
|
||||
print(f'DrugCode: {r.DrugCode:20} | BARCODE: {barcode:20} | 제품명: {goods_name}')
|
||||
print('=' * 100)
|
||||
|
||||
# 바코드 통계
|
||||
stats_query = text("""
|
||||
SELECT
|
||||
COUNT(DISTINCT DrugCode) as total_drugs,
|
||||
COUNT(DISTINCT BARCODE) as total_barcodes,
|
||||
SUM(CASE WHEN BARCODE IS NOT NULL AND BARCODE != '' THEN 1 ELSE 0 END) as with_barcode,
|
||||
COUNT(*) as total_sales
|
||||
FROM SALE_SUB
|
||||
""")
|
||||
|
||||
stats = session.execute(stats_query).fetchone()
|
||||
|
||||
print('\n바코드 통계')
|
||||
print('=' * 100)
|
||||
print(f'전체 제품 수 (DrugCode): {stats.total_drugs:,}')
|
||||
print(f'바코드 종류 수: {stats.total_barcodes:,}')
|
||||
print(f'바코드가 있는 판매 건수: {stats.with_barcode:,}')
|
||||
print(f'전체 판매 건수: {stats.total_sales:,}')
|
||||
print(f'바코드 보유율: {stats.with_barcode / stats.total_sales * 100:.2f}%')
|
||||
print('=' * 100)
|
||||
|
||||
except Exception as e:
|
||||
print(f"오류 발생: {e}")
|
||||
finally:
|
||||
db_manager.close_all()
|
||||
|
||||
if __name__ == '__main__':
|
||||
check_barcode_samples()
|
||||
@@ -1,83 +0,0 @@
|
||||
"""
|
||||
특정 거래의 SALE_SUB 데이터 확인
|
||||
"""
|
||||
|
||||
import sys
|
||||
import os
|
||||
sys.path.insert(0, os.path.dirname(__file__))
|
||||
|
||||
from db.dbsetup import DatabaseManager
|
||||
from sqlalchemy import text
|
||||
|
||||
def check_sale_sub_data(transaction_id):
|
||||
"""특정 거래의 판매 상세 데이터 확인"""
|
||||
db_manager = DatabaseManager()
|
||||
|
||||
try:
|
||||
session = db_manager.get_session('PM_PRES')
|
||||
|
||||
# SALE_SUB 모든 컬럼 조회
|
||||
query = text("""
|
||||
SELECT *
|
||||
FROM SALE_SUB
|
||||
WHERE SL_NO_order = :transaction_id
|
||||
""")
|
||||
|
||||
result = session.execute(query, {'transaction_id': transaction_id}).fetchone()
|
||||
|
||||
if result:
|
||||
print("=" * 80)
|
||||
print(f"거래번호 {transaction_id}의 SALE_SUB 데이터")
|
||||
print("=" * 80)
|
||||
|
||||
# 모든 컬럼 출력
|
||||
for key in result._mapping.keys():
|
||||
value = result._mapping[key]
|
||||
print(f"{key:30} = {value}")
|
||||
|
||||
print("=" * 80)
|
||||
else:
|
||||
print(f"거래번호 {transaction_id}를 찾을 수 없습니다.")
|
||||
|
||||
except Exception as e:
|
||||
print(f"오류 발생: {e}")
|
||||
finally:
|
||||
db_manager.close_all()
|
||||
|
||||
def check_sale_main_data(transaction_id):
|
||||
"""특정 거래의 SALE_MAIN 데이터 확인"""
|
||||
db_manager = DatabaseManager()
|
||||
|
||||
try:
|
||||
session = db_manager.get_session('PM_PRES')
|
||||
|
||||
query = text("""
|
||||
SELECT *
|
||||
FROM SALE_MAIN
|
||||
WHERE SL_NO_order = :transaction_id
|
||||
""")
|
||||
|
||||
result = session.execute(query, {'transaction_id': transaction_id}).fetchone()
|
||||
|
||||
if result:
|
||||
print("\n" + "=" * 80)
|
||||
print(f"거래번호 {transaction_id}의 SALE_MAIN 데이터")
|
||||
print("=" * 80)
|
||||
|
||||
for key in result._mapping.keys():
|
||||
value = result._mapping[key]
|
||||
print(f"{key:30} = {value}")
|
||||
|
||||
print("=" * 80)
|
||||
else:
|
||||
print(f"거래번호 {transaction_id}를 찾을 수 없습니다.")
|
||||
|
||||
except Exception as e:
|
||||
print(f"오류 발생: {e}")
|
||||
finally:
|
||||
db_manager.close_all()
|
||||
|
||||
if __name__ == '__main__':
|
||||
# 스크린샷의 거래번호
|
||||
check_sale_sub_data('20260123000261')
|
||||
check_sale_main_data('20260123000261')
|
||||
@@ -1,54 +0,0 @@
|
||||
"""
|
||||
SALE_MAIN 테이블 컬럼 확인 스크립트
|
||||
"""
|
||||
|
||||
import sys
|
||||
import os
|
||||
sys.path.insert(0, os.path.dirname(__file__))
|
||||
|
||||
from db.dbsetup import DatabaseManager
|
||||
from sqlalchemy import text
|
||||
|
||||
def check_sale_table_columns(table_name):
|
||||
"""테이블의 모든 컬럼 확인"""
|
||||
db_manager = DatabaseManager()
|
||||
|
||||
try:
|
||||
session = db_manager.get_session('PM_PRES')
|
||||
|
||||
# SQL Server에서 테이블 컬럼 정보 조회
|
||||
query = text(f"""
|
||||
SELECT
|
||||
COLUMN_NAME,
|
||||
DATA_TYPE,
|
||||
CHARACTER_MAXIMUM_LENGTH,
|
||||
IS_NULLABLE
|
||||
FROM INFORMATION_SCHEMA.COLUMNS
|
||||
WHERE TABLE_NAME = '{table_name}'
|
||||
ORDER BY ORDINAL_POSITION
|
||||
""")
|
||||
|
||||
columns = session.execute(query).fetchall()
|
||||
|
||||
print("=" * 80)
|
||||
print(f"{table_name} 테이블 컬럼 목록")
|
||||
print("=" * 80)
|
||||
|
||||
for col in columns:
|
||||
nullable = "NULL" if col.IS_NULLABLE == 'YES' else "NOT NULL"
|
||||
max_len = f"({col.CHARACTER_MAXIMUM_LENGTH})" if col.CHARACTER_MAXIMUM_LENGTH else ""
|
||||
print(f"{col.COLUMN_NAME:30} {col.DATA_TYPE}{max_len:20} {nullable}")
|
||||
|
||||
print("=" * 80)
|
||||
print(f"총 {len(columns)}개 컬럼")
|
||||
print("=" * 80)
|
||||
|
||||
except Exception as e:
|
||||
print(f"오류 발생: {e}")
|
||||
finally:
|
||||
db_manager.close_all()
|
||||
|
||||
if __name__ == '__main__':
|
||||
check_sale_table_columns('SALE_MAIN')
|
||||
print("\n\n")
|
||||
check_sale_table_columns('SALE_SUB')
|
||||
54
backend/config/__init__.py
Normal file
@@ -0,0 +1,54 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
도매상 설정 중앙 관리
|
||||
|
||||
사용법:
|
||||
from config import get_wholesalers, get_wholesaler
|
||||
|
||||
# 전체 도매상 목록
|
||||
wholesalers = get_wholesalers()
|
||||
|
||||
# 특정 도매상 정보
|
||||
geo = get_wholesaler('geoyoung')
|
||||
print(geo['name']) # 지오영
|
||||
print(geo['logo']) # /static/img/logo_geoyoung.ico
|
||||
"""
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
_config = None
|
||||
_config_path = Path(__file__).parent / 'wholesalers.json'
|
||||
|
||||
|
||||
def _load_config():
|
||||
global _config
|
||||
if _config is None:
|
||||
with open(_config_path, 'r', encoding='utf-8') as f:
|
||||
_config = json.load(f)
|
||||
return _config
|
||||
|
||||
|
||||
def get_wholesalers():
|
||||
"""전체 도매상 목록 반환 (순서대로)"""
|
||||
config = _load_config()
|
||||
order = config.get('order', [])
|
||||
wholesalers = config.get('wholesalers', {})
|
||||
return [wholesalers[key] for key in order if key in wholesalers]
|
||||
|
||||
|
||||
def get_wholesaler(wholesaler_id: str):
|
||||
"""특정 도매상 정보 반환"""
|
||||
config = _load_config()
|
||||
return config.get('wholesalers', {}).get(wholesaler_id)
|
||||
|
||||
|
||||
def get_all_wholesalers_dict():
|
||||
"""전체 도매상 딕셔너리 반환"""
|
||||
config = _load_config()
|
||||
return config.get('wholesalers', {})
|
||||
|
||||
|
||||
def get_config():
|
||||
"""전체 설정 반환"""
|
||||
return _load_config()
|
||||
65
backend/config/wholesalers.json
Normal file
@@ -0,0 +1,65 @@
|
||||
{
|
||||
"wholesalers": {
|
||||
"geoyoung": {
|
||||
"id": "geoyoung",
|
||||
"name": "지오영",
|
||||
"shortName": "지오영",
|
||||
"icon": "🏭",
|
||||
"logo": "/static/img/logo_geoyoung.ico",
|
||||
"color": "#06b6d4",
|
||||
"gradient": "linear-gradient(135deg, #0891b2, #06b6d4)",
|
||||
"bgColor": "rgba(6, 182, 212, 0.1)",
|
||||
"api": {
|
||||
"balance": "/api/geoyoung/balance",
|
||||
"stock": "/api/geoyoung/stock",
|
||||
"order": "/api/geoyoung/order"
|
||||
},
|
||||
"env": {
|
||||
"userId": "GEOYOUNG_USER_ID",
|
||||
"password": "GEOYOUNG_PASSWORD"
|
||||
}
|
||||
},
|
||||
"sooin": {
|
||||
"id": "sooin",
|
||||
"name": "수인약품",
|
||||
"shortName": "수인",
|
||||
"icon": "💊",
|
||||
"logo": "/static/img/logo_sooin.svg",
|
||||
"color": "#a855f7",
|
||||
"gradient": "linear-gradient(135deg, #7c3aed, #a855f7)",
|
||||
"bgColor": "rgba(168, 85, 247, 0.1)",
|
||||
"api": {
|
||||
"balance": "/api/sooin/balance",
|
||||
"stock": "/api/sooin/stock",
|
||||
"order": "/api/sooin/order"
|
||||
},
|
||||
"env": {
|
||||
"userId": "SOOIN_USER_ID",
|
||||
"password": "SOOIN_PASSWORD",
|
||||
"vendorCode": "SOOIN_VENDOR_CODE"
|
||||
}
|
||||
},
|
||||
"baekje": {
|
||||
"id": "baekje",
|
||||
"name": "백제약품",
|
||||
"shortName": "백제",
|
||||
"icon": "💉",
|
||||
"logo": "/static/img/logo_baekje.svg",
|
||||
"color": "#f59e0b",
|
||||
"gradient": "linear-gradient(135deg, #d97706, #f59e0b)",
|
||||
"bgColor": "rgba(245, 158, 11, 0.1)",
|
||||
"api": {
|
||||
"balance": "/api/baekje/balance",
|
||||
"stock": "/api/baekje/stock",
|
||||
"order": "/api/baekje/order"
|
||||
},
|
||||
"env": {
|
||||
"userId": "BAEKJE_USER_ID",
|
||||
"password": "BAEKJE_PASSWORD"
|
||||
}
|
||||
}
|
||||
},
|
||||
"order": ["baekje", "geoyoung", "sooin"],
|
||||
"version": "1.0.0",
|
||||
"lastUpdated": "2026-03-06"
|
||||
}
|
||||
50
backend/create_limits_table.py
Normal file
@@ -0,0 +1,50 @@
|
||||
import sqlite3
|
||||
|
||||
conn = sqlite3.connect('db/orders.db')
|
||||
cur = conn.cursor()
|
||||
|
||||
# wholesaler_limits 테이블 생성
|
||||
cur.execute('''
|
||||
CREATE TABLE IF NOT EXISTS wholesaler_limits (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
wholesaler_id TEXT NOT NULL UNIQUE,
|
||||
|
||||
-- 한도 설정
|
||||
monthly_limit INTEGER DEFAULT 0, -- 월 한도 (원)
|
||||
warning_threshold REAL DEFAULT 0.9, -- 경고 임계값 (90%)
|
||||
|
||||
-- 우선순위
|
||||
priority INTEGER DEFAULT 1, -- 1이 최우선
|
||||
|
||||
-- 상태
|
||||
is_active INTEGER DEFAULT 1,
|
||||
|
||||
-- 메타
|
||||
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
''')
|
||||
|
||||
# 기본 데이터 삽입 (각 2000만원)
|
||||
wholesalers = [
|
||||
('geoyoung', 20000000, 0.9, 1),
|
||||
('sooin', 20000000, 0.9, 2),
|
||||
('baekje', 20000000, 0.9, 3),
|
||||
]
|
||||
|
||||
for ws_id, limit, threshold, priority in wholesalers:
|
||||
cur.execute('''
|
||||
INSERT OR REPLACE INTO wholesaler_limits
|
||||
(wholesaler_id, monthly_limit, warning_threshold, priority)
|
||||
VALUES (?, ?, ?, ?)
|
||||
''', (ws_id, limit, threshold, priority))
|
||||
|
||||
conn.commit()
|
||||
|
||||
# 확인
|
||||
cur.execute('SELECT * FROM wholesaler_limits')
|
||||
print('=== wholesaler_limits 생성 완료 ===')
|
||||
for row in cur.fetchall():
|
||||
print(row)
|
||||
|
||||
conn.close()
|
||||
@@ -154,11 +154,46 @@ class DatabaseManager:
|
||||
return self.engines[database]
|
||||
|
||||
def get_session(self, database='PM_BASE'):
|
||||
"""특정 데이터베이스 세션 반환"""
|
||||
"""특정 데이터베이스 세션 반환 (자동 복구 포함)"""
|
||||
if database not in self.sessions:
|
||||
engine = self.get_engine(database)
|
||||
Session = sessionmaker(bind=engine)
|
||||
self.sessions[database] = Session()
|
||||
else:
|
||||
# 🔥 기존 세션 상태 체크 및 자동 복구
|
||||
session = self.sessions[database]
|
||||
try:
|
||||
# 세션이 유효한지 간단한 쿼리로 테스트
|
||||
session.execute(text("SELECT 1"))
|
||||
except Exception as e:
|
||||
error_msg = str(e).lower()
|
||||
# 연결 끊김 또는 트랜잭션 에러 감지
|
||||
if any(keyword in error_msg for keyword in [
|
||||
'invalid transaction', 'rollback', 'connection',
|
||||
'closed', 'lost', 'timeout', 'network', 'disconnect'
|
||||
]):
|
||||
print(f"[DB Manager] {database} 세션 복구 시도: {e}")
|
||||
try:
|
||||
session.rollback()
|
||||
print(f"[DB Manager] {database} 롤백 성공, 세션 재사용")
|
||||
except Exception as rollback_err:
|
||||
print(f"[DB Manager] {database} 롤백 실패, 세션 재생성: {rollback_err}")
|
||||
try:
|
||||
session.close()
|
||||
except:
|
||||
pass
|
||||
del self.sessions[database]
|
||||
# 새 세션 생성
|
||||
engine = self.get_engine(database)
|
||||
Session = sessionmaker(bind=engine)
|
||||
self.sessions[database] = Session()
|
||||
print(f"[DB Manager] {database} 새 세션 생성 완료")
|
||||
else:
|
||||
# 다른 종류의 에러면 롤백만 시도
|
||||
try:
|
||||
session.rollback()
|
||||
except:
|
||||
pass
|
||||
return self.sessions[database]
|
||||
|
||||
def rollback_session(self, database='PM_BASE'):
|
||||
@@ -237,7 +272,13 @@ class DatabaseManager:
|
||||
self.init_sqlite_schema()
|
||||
self.sqlite_conn = old_conn
|
||||
print(f"[DB Manager] SQLite 신규 DB 생성 완료: {self.sqlite_db_path}")
|
||||
|
||||
else:
|
||||
# 기존 DB: 마이그레이션 실행
|
||||
old_conn = self.sqlite_conn
|
||||
self.sqlite_conn = conn
|
||||
self._migrate_sqlite()
|
||||
self.sqlite_conn = old_conn
|
||||
|
||||
return conn
|
||||
|
||||
def init_sqlite_schema(self):
|
||||
@@ -319,6 +360,67 @@ class DatabaseManager:
|
||||
self.sqlite_conn.commit()
|
||||
print("[DB Manager] SQLite 마이그레이션: ai_recommendations 테이블 생성")
|
||||
|
||||
# customer_identities 토큰 저장 컬럼 추가
|
||||
cursor.execute("PRAGMA table_info(customer_identities)")
|
||||
ci_columns = [row[1] for row in cursor.fetchall()]
|
||||
if 'access_token' not in ci_columns:
|
||||
cursor.execute("ALTER TABLE customer_identities ADD COLUMN access_token TEXT")
|
||||
cursor.execute("ALTER TABLE customer_identities ADD COLUMN refresh_token TEXT")
|
||||
cursor.execute("ALTER TABLE customer_identities ADD COLUMN token_expires_at DATETIME")
|
||||
self.sqlite_conn.commit()
|
||||
print("[DB Manager] SQLite 마이그레이션: customer_identities 토큰 컬럼 추가")
|
||||
|
||||
# pets 테이블 생성 (반려동물)
|
||||
cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='pets'")
|
||||
if not cursor.fetchone():
|
||||
cursor.executescript("""
|
||||
CREATE TABLE IF NOT EXISTS pets (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
name VARCHAR(50) NOT NULL,
|
||||
species VARCHAR(20) NOT NULL,
|
||||
breed VARCHAR(50),
|
||||
gender VARCHAR(10),
|
||||
birth_date DATE,
|
||||
age_months INTEGER,
|
||||
weight DECIMAL(5,2),
|
||||
photo_url TEXT,
|
||||
notes TEXT,
|
||||
is_active BOOLEAN DEFAULT TRUE,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_pets_user ON pets(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_pets_species ON pets(species);
|
||||
""")
|
||||
self.sqlite_conn.commit()
|
||||
print("[DB Manager] SQLite 마이그레이션: pets 테이블 생성")
|
||||
|
||||
# otc_label_presets 테이블 생성 (OTC 용법 라벨)
|
||||
cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='otc_label_presets'")
|
||||
if not cursor.fetchone():
|
||||
cursor.executescript("""
|
||||
CREATE TABLE IF NOT EXISTS otc_label_presets (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
barcode VARCHAR(20) NOT NULL UNIQUE,
|
||||
drug_code VARCHAR(20),
|
||||
display_name VARCHAR(100),
|
||||
effect VARCHAR(100),
|
||||
dosage_instruction TEXT,
|
||||
usage_tip TEXT,
|
||||
use_wide_format BOOLEAN DEFAULT TRUE,
|
||||
print_count INTEGER DEFAULT 0,
|
||||
last_printed_at DATETIME,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_otc_label_barcode ON otc_label_presets(barcode);
|
||||
CREATE INDEX IF NOT EXISTS idx_otc_label_drug_code ON otc_label_presets(drug_code);
|
||||
""")
|
||||
self.sqlite_conn.commit()
|
||||
print("[DB Manager] SQLite 마이그레이션: otc_label_presets 테이블 생성")
|
||||
|
||||
def test_connection(self, database='PM_BASE'):
|
||||
"""연결 테스트"""
|
||||
try:
|
||||
|
||||
220
backend/db/kims_logger.py
Normal file
@@ -0,0 +1,220 @@
|
||||
"""
|
||||
KIMS API 로깅 모듈
|
||||
- API 호출/응답 SQLite 저장
|
||||
- AI 학습용 데이터 수집
|
||||
"""
|
||||
import sqlite3
|
||||
import json
|
||||
import os
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
# DB 파일 경로
|
||||
DB_PATH = Path(__file__).parent / 'kims_logs.db'
|
||||
|
||||
def init_db():
|
||||
"""DB 초기화 (테이블 생성)"""
|
||||
schema_path = Path(__file__).parent / 'kims_logs_schema.sql'
|
||||
|
||||
conn = sqlite3.connect(str(DB_PATH))
|
||||
cursor = conn.cursor()
|
||||
|
||||
with open(schema_path, 'r', encoding='utf-8') as f:
|
||||
schema = f.read()
|
||||
cursor.executescript(schema)
|
||||
|
||||
conn.commit()
|
||||
conn.close()
|
||||
print(f"KIMS 로그 DB 초기화 완료: {DB_PATH}")
|
||||
|
||||
def log_kims_call(
|
||||
pre_serial: str = None,
|
||||
user_id: int = None,
|
||||
source: str = 'admin',
|
||||
drug_codes: list = None,
|
||||
drug_names: list = None,
|
||||
api_status: str = 'SUCCESS',
|
||||
http_status: int = 200,
|
||||
response_time_ms: int = 0,
|
||||
interactions: list = None,
|
||||
response_raw: dict = None,
|
||||
error_message: str = None
|
||||
) -> int:
|
||||
"""
|
||||
KIMS API 호출 로그 저장
|
||||
|
||||
Returns:
|
||||
log_id: 생성된 로그 ID
|
||||
"""
|
||||
# DB 없으면 초기화
|
||||
if not DB_PATH.exists():
|
||||
init_db()
|
||||
|
||||
conn = sqlite3.connect(str(DB_PATH))
|
||||
cursor = conn.cursor()
|
||||
|
||||
interactions = interactions or []
|
||||
drug_codes = drug_codes or []
|
||||
drug_names = drug_names or []
|
||||
|
||||
# 심각한 상호작용 여부 (severity 1 또는 2)
|
||||
has_severe = any(
|
||||
str(i.get('severity', '5')) in ['1', '2']
|
||||
for i in interactions
|
||||
)
|
||||
|
||||
# 메인 로그 삽입
|
||||
cursor.execute("""
|
||||
INSERT INTO kims_api_logs (
|
||||
pre_serial, user_id, source,
|
||||
request_drug_codes, request_drug_names, request_drug_count,
|
||||
api_status, http_status, response_time_ms,
|
||||
interaction_count, has_severe_interaction,
|
||||
interactions_json, response_raw, error_message
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""", (
|
||||
pre_serial,
|
||||
user_id,
|
||||
source,
|
||||
json.dumps(drug_codes, ensure_ascii=False),
|
||||
json.dumps(drug_names, ensure_ascii=False),
|
||||
len(drug_codes),
|
||||
api_status,
|
||||
http_status,
|
||||
response_time_ms,
|
||||
len(interactions),
|
||||
1 if has_severe else 0,
|
||||
json.dumps(interactions, ensure_ascii=False),
|
||||
json.dumps(response_raw, ensure_ascii=False) if response_raw else None,
|
||||
error_message
|
||||
))
|
||||
|
||||
log_id = cursor.lastrowid
|
||||
|
||||
# 상호작용 상세 삽입 (정규화)
|
||||
for inter in interactions:
|
||||
cursor.execute("""
|
||||
INSERT INTO kims_interactions (
|
||||
log_id,
|
||||
drug1_code, drug1_name, drug1_generic,
|
||||
drug2_code, drug2_name, drug2_generic,
|
||||
severity_level, severity_desc,
|
||||
likelihood_level, likelihood_desc,
|
||||
observation, observation_generic,
|
||||
clinical_management, action_to_take, reference
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""", (
|
||||
log_id,
|
||||
inter.get('drug1_code'),
|
||||
inter.get('drug1_name'),
|
||||
inter.get('generic1'),
|
||||
inter.get('drug2_code'),
|
||||
inter.get('drug2_name'),
|
||||
inter.get('generic2'),
|
||||
int(inter.get('severity', 5)) if str(inter.get('severity', '')).isdigit() else None,
|
||||
inter.get('severity_text'),
|
||||
None, # likelihood_level
|
||||
inter.get('likelihood'),
|
||||
inter.get('description'),
|
||||
None, # observation_generic
|
||||
inter.get('management'),
|
||||
inter.get('action'),
|
||||
None # reference
|
||||
))
|
||||
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
return log_id
|
||||
|
||||
def get_recent_logs(limit: int = 50):
|
||||
"""최근 로그 조회"""
|
||||
if not DB_PATH.exists():
|
||||
return []
|
||||
|
||||
conn = sqlite3.connect(str(DB_PATH))
|
||||
conn.row_factory = sqlite3.Row
|
||||
cursor = conn.cursor()
|
||||
|
||||
cursor.execute("""
|
||||
SELECT * FROM kims_api_logs
|
||||
ORDER BY created_at DESC
|
||||
LIMIT ?
|
||||
""", (limit,))
|
||||
|
||||
rows = cursor.fetchall()
|
||||
conn.close()
|
||||
|
||||
return [dict(row) for row in rows]
|
||||
|
||||
def get_log_detail(log_id: int):
|
||||
"""로그 상세 조회 (상호작용 포함)"""
|
||||
if not DB_PATH.exists():
|
||||
return None
|
||||
|
||||
conn = sqlite3.connect(str(DB_PATH))
|
||||
conn.row_factory = sqlite3.Row
|
||||
cursor = conn.cursor()
|
||||
|
||||
# 메인 로그
|
||||
cursor.execute("SELECT * FROM kims_api_logs WHERE id = ?", (log_id,))
|
||||
log = cursor.fetchone()
|
||||
|
||||
if not log:
|
||||
conn.close()
|
||||
return None
|
||||
|
||||
# 상호작용 상세
|
||||
cursor.execute("""
|
||||
SELECT * FROM kims_interactions
|
||||
WHERE log_id = ?
|
||||
ORDER BY severity_level ASC
|
||||
""", (log_id,))
|
||||
interactions = cursor.fetchall()
|
||||
|
||||
conn.close()
|
||||
|
||||
result = dict(log)
|
||||
result['interactions_detail'] = [dict(i) for i in interactions]
|
||||
|
||||
return result
|
||||
|
||||
def get_stats():
|
||||
"""통계 조회"""
|
||||
if not DB_PATH.exists():
|
||||
return {}
|
||||
|
||||
conn = sqlite3.connect(str(DB_PATH))
|
||||
conn.row_factory = sqlite3.Row
|
||||
cursor = conn.cursor()
|
||||
|
||||
# 전체 통계
|
||||
cursor.execute("""
|
||||
SELECT
|
||||
COUNT(*) as total_calls,
|
||||
SUM(CASE WHEN api_status = 'SUCCESS' THEN 1 ELSE 0 END) as success_count,
|
||||
SUM(CASE WHEN interaction_count > 0 THEN 1 ELSE 0 END) as with_interaction,
|
||||
SUM(CASE WHEN has_severe_interaction = 1 THEN 1 ELSE 0 END) as with_severe,
|
||||
AVG(response_time_ms) as avg_response_ms
|
||||
FROM kims_api_logs
|
||||
""")
|
||||
stats = dict(cursor.fetchone())
|
||||
|
||||
# 최근 7일 일별 통계
|
||||
cursor.execute("""
|
||||
SELECT * FROM kims_stats
|
||||
ORDER BY date DESC
|
||||
LIMIT 7
|
||||
""")
|
||||
daily = [dict(row) for row in cursor.fetchall()]
|
||||
|
||||
conn.close()
|
||||
|
||||
stats['daily'] = daily
|
||||
return stats
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
# DB 초기화 테스트
|
||||
init_db()
|
||||
print("KIMS 로그 DB 초기화 완료!")
|
||||
86
backend/db/kims_logs_schema.sql
Normal file
@@ -0,0 +1,86 @@
|
||||
-- KIMS API 로그 테이블 스키마
|
||||
-- AI 학습 데이터로 활용 예정
|
||||
|
||||
-- 1. API 호출 로그 (메인)
|
||||
CREATE TABLE IF NOT EXISTS kims_api_logs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
-- 호출 컨텍스트
|
||||
pre_serial TEXT, -- 처방번호
|
||||
user_id INTEGER, -- 마일리지 회원 ID (있으면)
|
||||
source TEXT DEFAULT 'admin', -- 호출 소스 (admin, api, batch 등)
|
||||
|
||||
-- 요청 데이터
|
||||
request_drug_codes TEXT NOT NULL, -- JSON: ["055101150", "622801610"]
|
||||
request_drug_names TEXT, -- JSON: ["오메프투캡슐", "락소펜엠정"]
|
||||
request_drug_count INTEGER, -- 요청 약품 수
|
||||
|
||||
-- 응답 데이터
|
||||
api_status TEXT NOT NULL, -- SUCCESS, ERROR, TIMEOUT
|
||||
http_status INTEGER, -- HTTP 상태 코드
|
||||
response_time_ms INTEGER, -- 응답 시간 (밀리초)
|
||||
|
||||
-- 상호작용 결과
|
||||
interaction_count INTEGER DEFAULT 0, -- 발견된 상호작용 수
|
||||
has_severe_interaction INTEGER DEFAULT 0, -- 심각한 상호작용 여부 (1/2 등급)
|
||||
|
||||
-- 상세 데이터 (JSON)
|
||||
interactions_json TEXT, -- 상호작용 상세 정보 JSON
|
||||
response_raw TEXT, -- 전체 API 응답 (디버깅/학습용)
|
||||
|
||||
-- 에러 정보
|
||||
error_message TEXT
|
||||
);
|
||||
|
||||
-- 2. 상호작용 상세 (정규화, AI 학습용)
|
||||
CREATE TABLE IF NOT EXISTS kims_interactions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
log_id INTEGER NOT NULL, -- kims_api_logs.id FK
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
-- 약품 1
|
||||
drug1_code TEXT NOT NULL,
|
||||
drug1_name TEXT,
|
||||
drug1_generic TEXT, -- 성분명 (영문)
|
||||
|
||||
-- 약품 2
|
||||
drug2_code TEXT NOT NULL,
|
||||
drug2_name TEXT,
|
||||
drug2_generic TEXT, -- 성분명 (영문)
|
||||
|
||||
-- 상호작용 정보
|
||||
severity_level INTEGER, -- 1=심각, 2=중등도, 3=경미, 4=참고
|
||||
severity_desc TEXT, -- 심각도 설명 (중증, 경미 등)
|
||||
likelihood_level INTEGER, -- 발생 가능성
|
||||
likelihood_desc TEXT,
|
||||
|
||||
-- 상세 설명 (AI 학습 핵심 데이터)
|
||||
observation TEXT, -- 상호작용 설명 (한글)
|
||||
observation_generic TEXT, -- 일반적 설명
|
||||
clinical_management TEXT, -- 임상적 관리 방법
|
||||
action_to_take TEXT, -- 권장 조치
|
||||
reference TEXT, -- 참고문헌
|
||||
|
||||
FOREIGN KEY (log_id) REFERENCES kims_api_logs(id)
|
||||
);
|
||||
|
||||
-- 인덱스
|
||||
CREATE INDEX IF NOT EXISTS idx_kims_logs_created ON kims_api_logs(created_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_kims_logs_pre_serial ON kims_api_logs(pre_serial);
|
||||
CREATE INDEX IF NOT EXISTS idx_kims_logs_status ON kims_api_logs(api_status);
|
||||
CREATE INDEX IF NOT EXISTS idx_kims_interactions_log ON kims_interactions(log_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_kims_interactions_drugs ON kims_interactions(drug1_code, drug2_code);
|
||||
CREATE INDEX IF NOT EXISTS idx_kims_interactions_severity ON kims_interactions(severity_level);
|
||||
|
||||
-- 통계 뷰
|
||||
CREATE VIEW IF NOT EXISTS kims_stats AS
|
||||
SELECT
|
||||
DATE(created_at) as date,
|
||||
COUNT(*) as total_calls,
|
||||
SUM(CASE WHEN api_status = 'SUCCESS' THEN 1 ELSE 0 END) as success_count,
|
||||
SUM(CASE WHEN interaction_count > 0 THEN 1 ELSE 0 END) as with_interaction,
|
||||
SUM(CASE WHEN has_severe_interaction = 1 THEN 1 ELSE 0 END) as with_severe,
|
||||
AVG(response_time_ms) as avg_response_ms
|
||||
FROM kims_api_logs
|
||||
GROUP BY DATE(created_at);
|
||||
@@ -22,6 +22,9 @@ CREATE TABLE IF NOT EXISTS customer_identities (
|
||||
provider VARCHAR(20) NOT NULL,
|
||||
provider_user_id VARCHAR(100) NOT NULL,
|
||||
provider_data TEXT,
|
||||
access_token TEXT,
|
||||
refresh_token TEXT,
|
||||
token_expires_at DATETIME,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id),
|
||||
UNIQUE(provider, provider_user_id)
|
||||
@@ -120,3 +123,44 @@ CREATE TABLE IF NOT EXISTS ai_recommendations (
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_rec_user_status ON ai_recommendations(user_id, status);
|
||||
CREATE INDEX IF NOT EXISTS idx_rec_expires ON ai_recommendations(expires_at);
|
||||
|
||||
-- 8. 반려동물 테이블
|
||||
CREATE TABLE IF NOT EXISTS pets (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
name VARCHAR(50) NOT NULL, -- 이름 (예: 뽀삐, 나비)
|
||||
species VARCHAR(20) NOT NULL, -- 종류: dog, cat, other
|
||||
breed VARCHAR(50), -- 품종 (말티즈, 페르시안 등)
|
||||
gender VARCHAR(10), -- male, female, unknown
|
||||
birth_date DATE, -- 생년월일 (나중에 사용)
|
||||
age_months INTEGER, -- 월령 (나중에 사용)
|
||||
weight DECIMAL(5,2), -- 체중 kg (나중에 사용)
|
||||
photo_url TEXT, -- 사진 URL
|
||||
notes TEXT, -- 특이사항/메모
|
||||
is_active BOOLEAN DEFAULT TRUE, -- 활성 상태
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_pets_user ON pets(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_pets_species ON pets(species);
|
||||
|
||||
-- 9. OTC 용법 라벨 테이블 (바코드 기준 오버라이드 데이터)
|
||||
CREATE TABLE IF NOT EXISTS otc_label_presets (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
barcode VARCHAR(20) NOT NULL UNIQUE, -- 바코드 (PK 역할)
|
||||
drug_code VARCHAR(20), -- MSSQL DrugCode (참조용)
|
||||
display_name VARCHAR(100), -- 표시 이름 (오버라이드, NULL이면 MSSQL 이름 사용)
|
||||
effect VARCHAR(100), -- 효능 (예: "치통, 두통")
|
||||
dosage_instruction TEXT, -- 용법 (예: "1일 3회, 1회 1정, 식후 30분")
|
||||
usage_tip TEXT, -- 부가 설명 (예: "[통증 시에만 복용]")
|
||||
use_wide_format BOOLEAN DEFAULT TRUE, -- 와이드 포맷 사용 여부
|
||||
print_count INTEGER DEFAULT 0, -- 인쇄 횟수 (통계용)
|
||||
last_printed_at DATETIME, -- 마지막 인쇄 시간
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_otc_label_barcode ON otc_label_presets(barcode);
|
||||
CREATE INDEX IF NOT EXISTS idx_otc_label_drug_code ON otc_label_presets(drug_code);
|
||||
|
||||
351
backend/db/paai_logger.py
Normal file
@@ -0,0 +1,351 @@
|
||||
"""
|
||||
PAAI (Pharmacist Assistant AI) 로깅 모듈
|
||||
- API 호출/응답 SQLite 저장
|
||||
- 분석 결과 및 피드백 관리
|
||||
"""
|
||||
import sqlite3
|
||||
import json
|
||||
import os
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
|
||||
# DB 파일 경로
|
||||
DB_PATH = Path(__file__).parent / 'paai_logs.db'
|
||||
|
||||
|
||||
def init_db():
|
||||
"""DB 초기화 (테이블 생성)"""
|
||||
schema_path = Path(__file__).parent / 'paai_logs_schema.sql'
|
||||
|
||||
conn = sqlite3.connect(str(DB_PATH))
|
||||
cursor = conn.cursor()
|
||||
|
||||
with open(schema_path, 'r', encoding='utf-8') as f:
|
||||
schema = f.read()
|
||||
cursor.executescript(schema)
|
||||
|
||||
conn.commit()
|
||||
conn.close()
|
||||
print(f"PAAI 로그 DB 초기화 완료: {DB_PATH}")
|
||||
|
||||
|
||||
def create_log(
|
||||
pre_serial: str = None,
|
||||
patient_code: str = None,
|
||||
patient_name: str = None,
|
||||
disease_code_1: str = None,
|
||||
disease_name_1: str = None,
|
||||
disease_code_2: str = None,
|
||||
disease_name_2: str = None,
|
||||
current_medications: list = None,
|
||||
previous_serial: str = None,
|
||||
previous_medications: list = None,
|
||||
prescription_changes: dict = None,
|
||||
otc_history: dict = None
|
||||
) -> int:
|
||||
"""
|
||||
PAAI 분석 로그 생성 (초기 상태)
|
||||
|
||||
Returns:
|
||||
log_id: 생성된 로그 ID
|
||||
"""
|
||||
if not DB_PATH.exists():
|
||||
init_db()
|
||||
|
||||
conn = sqlite3.connect(str(DB_PATH))
|
||||
cursor = conn.cursor()
|
||||
|
||||
current_medications = current_medications or []
|
||||
previous_medications = previous_medications or []
|
||||
otc_history = otc_history or {}
|
||||
|
||||
# 환자명 마스킹
|
||||
masked_name = None
|
||||
if patient_name:
|
||||
masked_name = patient_name[0] + '*' * (len(patient_name) - 1) if len(patient_name) > 1 else patient_name
|
||||
|
||||
cursor.execute("""
|
||||
INSERT INTO paai_logs (
|
||||
pre_serial, patient_code, patient_name,
|
||||
disease_code_1, disease_name_1, disease_code_2, disease_name_2,
|
||||
current_medications, current_med_count,
|
||||
previous_serial, previous_medications, prescription_changes,
|
||||
otc_history, otc_visit_count,
|
||||
status
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'pending')
|
||||
""", (
|
||||
pre_serial,
|
||||
patient_code,
|
||||
masked_name,
|
||||
disease_code_1,
|
||||
disease_name_1,
|
||||
disease_code_2,
|
||||
disease_name_2,
|
||||
json.dumps(current_medications, ensure_ascii=False),
|
||||
len(current_medications),
|
||||
previous_serial,
|
||||
json.dumps(previous_medications, ensure_ascii=False),
|
||||
json.dumps(prescription_changes, ensure_ascii=False) if prescription_changes else None,
|
||||
json.dumps(otc_history, ensure_ascii=False),
|
||||
otc_history.get('visit_count', 0)
|
||||
))
|
||||
|
||||
log_id = cursor.lastrowid
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
return log_id
|
||||
|
||||
|
||||
def update_kims_result(
|
||||
log_id: int,
|
||||
kims_drug_codes: list = None,
|
||||
kims_interactions: list = None,
|
||||
kims_response_time_ms: int = 0
|
||||
):
|
||||
"""KIMS 상호작용 결과 업데이트"""
|
||||
conn = sqlite3.connect(str(DB_PATH))
|
||||
cursor = conn.cursor()
|
||||
|
||||
kims_drug_codes = kims_drug_codes or []
|
||||
kims_interactions = kims_interactions or []
|
||||
|
||||
# 심각한 상호작용 여부 (severity 1 또는 2)
|
||||
has_severe = any(
|
||||
str(i.get('severity', '5')) in ['1', '2']
|
||||
for i in kims_interactions
|
||||
)
|
||||
|
||||
cursor.execute("""
|
||||
UPDATE paai_logs SET
|
||||
kims_drug_codes = ?,
|
||||
kims_drug_count = ?,
|
||||
kims_interactions = ?,
|
||||
kims_interaction_count = ?,
|
||||
kims_has_severe = ?,
|
||||
kims_response_time_ms = ?,
|
||||
status = 'kims_done'
|
||||
WHERE id = ?
|
||||
""", (
|
||||
json.dumps(kims_drug_codes, ensure_ascii=False),
|
||||
len(kims_drug_codes),
|
||||
json.dumps(kims_interactions, ensure_ascii=False),
|
||||
len(kims_interactions),
|
||||
1 if has_severe else 0,
|
||||
kims_response_time_ms,
|
||||
log_id
|
||||
))
|
||||
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
|
||||
def update_ai_result(
|
||||
log_id: int,
|
||||
ai_prompt: str = None,
|
||||
ai_model: str = None,
|
||||
ai_response: dict = None,
|
||||
ai_response_time_ms: int = 0,
|
||||
ai_token_count: int = None
|
||||
):
|
||||
"""AI 분석 결과 업데이트"""
|
||||
conn = sqlite3.connect(str(DB_PATH))
|
||||
cursor = conn.cursor()
|
||||
|
||||
cursor.execute("""
|
||||
UPDATE paai_logs SET
|
||||
ai_prompt = ?,
|
||||
ai_model = ?,
|
||||
ai_response = ?,
|
||||
ai_response_time_ms = ?,
|
||||
ai_token_count = ?,
|
||||
status = 'success'
|
||||
WHERE id = ?
|
||||
""", (
|
||||
ai_prompt,
|
||||
ai_model,
|
||||
json.dumps(ai_response, ensure_ascii=False) if ai_response else None,
|
||||
ai_response_time_ms,
|
||||
ai_token_count,
|
||||
log_id
|
||||
))
|
||||
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
|
||||
def update_error(log_id: int, error_message: str):
|
||||
"""에러 상태 업데이트"""
|
||||
conn = sqlite3.connect(str(DB_PATH))
|
||||
cursor = conn.cursor()
|
||||
|
||||
cursor.execute("""
|
||||
UPDATE paai_logs SET
|
||||
status = 'error',
|
||||
error_message = ?
|
||||
WHERE id = ?
|
||||
""", (error_message, log_id))
|
||||
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
|
||||
def update_feedback(log_id: int, useful: bool, comment: str = None):
|
||||
"""피드백 업데이트"""
|
||||
conn = sqlite3.connect(str(DB_PATH))
|
||||
cursor = conn.cursor()
|
||||
|
||||
cursor.execute("""
|
||||
UPDATE paai_logs SET
|
||||
feedback_useful = ?,
|
||||
feedback_comment = ?
|
||||
WHERE id = ?
|
||||
""", (1 if useful else 0, comment, log_id))
|
||||
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
|
||||
def get_recent_logs(
|
||||
limit: int = 100,
|
||||
status: str = None,
|
||||
has_severe: bool = None,
|
||||
date: str = None
|
||||
) -> list:
|
||||
"""최근 로그 조회"""
|
||||
if not DB_PATH.exists():
|
||||
return []
|
||||
|
||||
conn = sqlite3.connect(str(DB_PATH))
|
||||
conn.row_factory = sqlite3.Row
|
||||
cursor = conn.cursor()
|
||||
|
||||
query = "SELECT * FROM paai_logs WHERE 1=1"
|
||||
params = []
|
||||
|
||||
if status:
|
||||
query += " AND status = ?"
|
||||
params.append(status)
|
||||
|
||||
if has_severe is not None:
|
||||
query += " AND kims_has_severe = ?"
|
||||
params.append(1 if has_severe else 0)
|
||||
|
||||
if date:
|
||||
query += " AND DATE(created_at) = ?"
|
||||
params.append(date)
|
||||
|
||||
query += " ORDER BY created_at DESC LIMIT ?"
|
||||
params.append(limit)
|
||||
|
||||
cursor.execute(query, params)
|
||||
rows = cursor.fetchall()
|
||||
|
||||
result = []
|
||||
for row in rows:
|
||||
log = dict(row)
|
||||
# JSON 필드 파싱
|
||||
for field in ['current_medications', 'previous_medications', 'prescription_changes',
|
||||
'otc_history', 'kims_drug_codes', 'kims_interactions', 'ai_response']:
|
||||
if log.get(field):
|
||||
try:
|
||||
log[field] = json.loads(log[field])
|
||||
except:
|
||||
pass
|
||||
result.append(log)
|
||||
|
||||
conn.close()
|
||||
return result
|
||||
|
||||
|
||||
def get_log_detail(log_id: int) -> dict:
|
||||
"""로그 상세 조회"""
|
||||
if not DB_PATH.exists():
|
||||
return None
|
||||
|
||||
conn = sqlite3.connect(str(DB_PATH))
|
||||
conn.row_factory = sqlite3.Row
|
||||
cursor = conn.cursor()
|
||||
|
||||
cursor.execute("SELECT * FROM paai_logs WHERE id = ?", (log_id,))
|
||||
row = cursor.fetchone()
|
||||
|
||||
if not row:
|
||||
conn.close()
|
||||
return None
|
||||
|
||||
log = dict(row)
|
||||
|
||||
# JSON 필드 파싱
|
||||
for field in ['current_medications', 'previous_medications', 'prescription_changes',
|
||||
'otc_history', 'kims_drug_codes', 'kims_interactions', 'ai_response']:
|
||||
if log.get(field):
|
||||
try:
|
||||
log[field] = json.loads(log[field])
|
||||
except:
|
||||
pass
|
||||
|
||||
conn.close()
|
||||
return log
|
||||
|
||||
|
||||
def get_stats() -> dict:
|
||||
"""통계 조회"""
|
||||
if not DB_PATH.exists():
|
||||
return {
|
||||
'total': 0,
|
||||
'today': 0,
|
||||
'success_rate': 0,
|
||||
'avg_response_time': 0,
|
||||
'severe_count': 0
|
||||
}
|
||||
|
||||
conn = sqlite3.connect(str(DB_PATH))
|
||||
cursor = conn.cursor()
|
||||
|
||||
today = datetime.now().strftime('%Y-%m-%d')
|
||||
|
||||
# 전체 건수
|
||||
cursor.execute("SELECT COUNT(*) FROM paai_logs")
|
||||
total = cursor.fetchone()[0]
|
||||
|
||||
# 오늘 건수
|
||||
cursor.execute("SELECT COUNT(*) FROM paai_logs WHERE DATE(created_at) = ?", (today,))
|
||||
today_count = cursor.fetchone()[0]
|
||||
|
||||
# 성공률
|
||||
cursor.execute("SELECT COUNT(*) FROM paai_logs WHERE status = 'success'")
|
||||
success_count = cursor.fetchone()[0]
|
||||
success_rate = (success_count / total * 100) if total > 0 else 0
|
||||
|
||||
# 평균 응답시간
|
||||
cursor.execute("SELECT AVG(ai_response_time_ms) FROM paai_logs WHERE ai_response_time_ms > 0")
|
||||
avg_time = cursor.fetchone()[0] or 0
|
||||
|
||||
# 심각한 상호작용 건수 (오늘)
|
||||
cursor.execute("""
|
||||
SELECT COUNT(*) FROM paai_logs
|
||||
WHERE DATE(created_at) = ? AND kims_has_severe = 1
|
||||
""", (today,))
|
||||
severe_count = cursor.fetchone()[0]
|
||||
|
||||
# 피드백 통계
|
||||
cursor.execute("SELECT COUNT(*) FROM paai_logs WHERE feedback_useful = 1")
|
||||
useful_count = cursor.fetchone()[0]
|
||||
cursor.execute("SELECT COUNT(*) FROM paai_logs WHERE feedback_useful IS NOT NULL")
|
||||
feedback_total = cursor.fetchone()[0]
|
||||
|
||||
conn.close()
|
||||
|
||||
return {
|
||||
'total': total,
|
||||
'today': today_count,
|
||||
'success_rate': round(success_rate, 1),
|
||||
'avg_response_time': int(avg_time),
|
||||
'severe_count': severe_count,
|
||||
'feedback': {
|
||||
'useful': useful_count,
|
||||
'total': feedback_total,
|
||||
'rate': round(useful_count / feedback_total * 100, 1) if feedback_total > 0 else 0
|
||||
}
|
||||
}
|
||||
59
backend/db/paai_logs_schema.sql
Normal file
@@ -0,0 +1,59 @@
|
||||
-- PAAI (Pharmacist Assistant AI) 로그 스키마
|
||||
-- 생성일: 2026-03-04
|
||||
|
||||
CREATE TABLE IF NOT EXISTS paai_logs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
-- 요청 정보
|
||||
pre_serial TEXT, -- 처방번호
|
||||
patient_code TEXT, -- 환자코드 (CusCode)
|
||||
patient_name TEXT, -- 환자명 (마스킹: 김**)
|
||||
|
||||
-- 질병 정보
|
||||
disease_code_1 TEXT, -- St1 (상병코드1)
|
||||
disease_name_1 TEXT, -- 상병명1
|
||||
disease_code_2 TEXT, -- St2 (상병코드2)
|
||||
disease_name_2 TEXT, -- 상병명2
|
||||
|
||||
-- 처방 정보
|
||||
current_medications TEXT, -- JSON: 현재 처방 [{code, name, dosage, ...}]
|
||||
current_med_count INTEGER, -- 현재 처방 약품 수
|
||||
previous_serial TEXT, -- 이전 처방번호
|
||||
previous_medications TEXT, -- JSON: 이전 처방
|
||||
prescription_changes TEXT, -- JSON: {added, removed, changed}
|
||||
|
||||
-- OTC 이력
|
||||
otc_history TEXT, -- JSON: {purchases, frequent_items}
|
||||
otc_visit_count INTEGER, -- OTC 구매 횟수
|
||||
|
||||
-- KIMS 상호작용
|
||||
kims_drug_codes TEXT, -- JSON: 검사한 KD코드 배열
|
||||
kims_drug_count INTEGER, -- 검사한 약품 수
|
||||
kims_interactions TEXT, -- JSON: 상호작용 결과
|
||||
kims_interaction_count INTEGER, -- 상호작용 건수
|
||||
kims_has_severe BOOLEAN DEFAULT 0, -- 심각한 상호작용 (severity 1,2)
|
||||
kims_response_time_ms INTEGER, -- KIMS API 응답시간
|
||||
|
||||
-- AI 분석
|
||||
ai_prompt TEXT, -- AI에 전달한 프롬프트
|
||||
ai_model TEXT, -- 사용된 모델
|
||||
ai_response TEXT, -- JSON: AI 분석 결과
|
||||
ai_response_time_ms INTEGER, -- AI 응답 시간
|
||||
ai_token_count INTEGER, -- 토큰 사용량
|
||||
|
||||
-- 상태
|
||||
status TEXT DEFAULT 'pending', -- pending, kims_done, success, error
|
||||
error_message TEXT,
|
||||
|
||||
-- 피드백
|
||||
feedback_useful INTEGER, -- 1=유용, 0=아님, NULL=미응답
|
||||
feedback_comment TEXT -- 약사 코멘트
|
||||
);
|
||||
|
||||
-- 인덱스
|
||||
CREATE INDEX IF NOT EXISTS idx_paai_created ON paai_logs(created_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_paai_patient ON paai_logs(patient_code);
|
||||
CREATE INDEX IF NOT EXISTS idx_paai_status ON paai_logs(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_paai_serial ON paai_logs(pre_serial);
|
||||
CREATE INDEX IF NOT EXISTS idx_paai_severe ON paai_logs(kims_has_severe);
|
||||
38
backend/db/product_images_schema.sql
Normal file
@@ -0,0 +1,38 @@
|
||||
-- product_images.db 스키마
|
||||
-- yakkok.com에서 크롤링한 제품 이미지 저장
|
||||
|
||||
CREATE TABLE IF NOT EXISTS product_images (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
barcode TEXT UNIQUE NOT NULL, -- 바코드 (고유키)
|
||||
drug_code TEXT, -- PIT3000 DrugCode
|
||||
product_name TEXT NOT NULL, -- 제품명
|
||||
search_name TEXT, -- 검색에 사용한 이름
|
||||
image_base64 TEXT, -- 이미지 (base64)
|
||||
image_url TEXT, -- 원본 URL
|
||||
thumbnail_base64 TEXT, -- 썸네일 (base64, 작은 사이즈)
|
||||
source TEXT DEFAULT 'yakkok', -- 출처
|
||||
status TEXT DEFAULT 'pending', -- pending/success/failed/manual/no_result
|
||||
error_message TEXT, -- 실패 시 에러 메시지
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- 인덱스
|
||||
CREATE INDEX IF NOT EXISTS idx_barcode ON product_images(barcode);
|
||||
CREATE INDEX IF NOT EXISTS idx_status ON product_images(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_drug_code ON product_images(drug_code);
|
||||
CREATE INDEX IF NOT EXISTS idx_created_at ON product_images(created_at);
|
||||
|
||||
-- 크롤링 로그 테이블
|
||||
CREATE TABLE IF NOT EXISTS crawl_logs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
batch_id TEXT, -- 배치 ID
|
||||
total_count INTEGER DEFAULT 0, -- 전체 개수
|
||||
success_count INTEGER DEFAULT 0, -- 성공 개수
|
||||
failed_count INTEGER DEFAULT 0, -- 실패 개수
|
||||
skipped_count INTEGER DEFAULT 0, -- 스킵 개수 (이미 있음)
|
||||
started_at DATETIME,
|
||||
finished_at DATETIME,
|
||||
status TEXT DEFAULT 'running', -- running/completed/failed
|
||||
error_message TEXT
|
||||
);
|
||||
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())
|
||||
18
backend/ecosystem.config.js
Normal file
@@ -0,0 +1,18 @@
|
||||
module.exports = {
|
||||
apps: [
|
||||
{
|
||||
name: 'pharmacy-flask',
|
||||
script: 'python',
|
||||
args: 'app.py',
|
||||
cwd: 'c:\\Users\\청춘약국\\source\\pharmacy-pos-qr-system\\backend',
|
||||
interpreter: 'none',
|
||||
watch: false,
|
||||
autorestart: true,
|
||||
max_restarts: 10,
|
||||
env: {
|
||||
FLASK_ENV: 'production',
|
||||
PYTHONIOENCODING: 'utf-8'
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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())
|
||||
BIN
backend/geo_cart_before.png
Normal file
|
After Width: | Height: | Size: 56 KiB |
742
backend/geoyoung_api.py
Normal file
@@ -0,0 +1,742 @@
|
||||
# -*- 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
|
||||
|
||||
|
||||
@geoyoung_bp.route('/monthly-sales', methods=['GET'])
|
||||
def api_get_monthly_sales():
|
||||
"""
|
||||
월간 매출 조회
|
||||
|
||||
GET /api/geoyoung/monthly-sales?year=2026&month=3
|
||||
"""
|
||||
from datetime import datetime
|
||||
|
||||
year = request.args.get('year', type=int)
|
||||
month = request.args.get('month', type=int)
|
||||
|
||||
# 기본값: 현재 월
|
||||
if not year or not month:
|
||||
now = datetime.now()
|
||||
year = year or now.year
|
||||
month = month or now.month
|
||||
|
||||
session = get_geo_session()
|
||||
|
||||
if hasattr(session, 'get_monthly_sales'):
|
||||
result = session.get_monthly_sales(year, month)
|
||||
return jsonify(result)
|
||||
else:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': 'NOT_IMPLEMENTED',
|
||||
'message': '지오영 월간 매출 조회 미구현'
|
||||
}), 501
|
||||
|
||||
|
||||
# ========== 주문 조회 API ==========
|
||||
|
||||
@geoyoung_bp.route('/order-list', methods=['GET'])
|
||||
def api_geoyoung_order_list():
|
||||
"""
|
||||
지오영 주문 목록 조회 API
|
||||
|
||||
GET /api/geoyoung/order-list?start_date=2026-03-01&end_date=2026-03-07
|
||||
|
||||
Query Parameters:
|
||||
start_date: 시작일 (YYYY-MM-DD), 기본값 30일 전
|
||||
end_date: 종료일 (YYYY-MM-DD), 기본값 오늘
|
||||
|
||||
Returns:
|
||||
{
|
||||
"success": true,
|
||||
"orders": [{
|
||||
"order_num": "DA2603-0006409",
|
||||
"order_date": "2026-03-07",
|
||||
"order_time": "09:08:55",
|
||||
"total_amount": 132020,
|
||||
"item_count": 3,
|
||||
"status": "출고확정"
|
||||
}, ...],
|
||||
"total_count": 5,
|
||||
"start_date": "2026-03-01",
|
||||
"end_date": "2026-03-07"
|
||||
}
|
||||
"""
|
||||
start_date = request.args.get('start_date', '').strip()
|
||||
end_date = request.args.get('end_date', '').strip()
|
||||
|
||||
try:
|
||||
session = get_geo_session()
|
||||
result = session.get_order_list(start_date or None, end_date or None)
|
||||
return jsonify(result)
|
||||
except Exception as e:
|
||||
logger.error(f"지오영 주문 목록 조회 오류: {e}")
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': 'API_ERROR',
|
||||
'message': str(e),
|
||||
'orders': [],
|
||||
'total_count': 0
|
||||
}), 500
|
||||
|
||||
|
||||
@geoyoung_bp.route('/order-detail/<order_num>', methods=['GET'])
|
||||
def api_geoyoung_order_detail(order_num):
|
||||
"""
|
||||
지오영 주문 상세 조회 API
|
||||
|
||||
GET /api/geoyoung/order-detail/DA2603-0006409
|
||||
|
||||
Returns:
|
||||
{
|
||||
"success": true,
|
||||
"order_num": "DA2603-0006409",
|
||||
"order_date": "2026-03-07",
|
||||
"order_time": "09:08:55",
|
||||
"items": [{
|
||||
"product_code": "008709",
|
||||
"kd_code": "670400830",
|
||||
"product_name": "레바미피드정100mg",
|
||||
"spec": "100mg",
|
||||
"quantity": 10,
|
||||
"unit_price": 500,
|
||||
"amount": 5000
|
||||
}, ...],
|
||||
"total_amount": 132020,
|
||||
"item_count": 3
|
||||
}
|
||||
"""
|
||||
try:
|
||||
session = get_geo_session()
|
||||
result = session.get_order_detail(order_num)
|
||||
return jsonify(result)
|
||||
except Exception as e:
|
||||
logger.error(f"지오영 주문 상세 조회 오류: {e}")
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': 'API_ERROR',
|
||||
'message': str(e),
|
||||
'order_num': order_num,
|
||||
'items': [],
|
||||
'total_amount': 0
|
||||
}), 500
|
||||
|
||||
|
||||
@geoyoung_bp.route('/orders/summary-by-kd', methods=['GET'])
|
||||
def api_geoyoung_orders_by_kd():
|
||||
"""
|
||||
지오영 주문량 KD코드별 집계 API
|
||||
|
||||
GET /api/geoyoung/orders/summary-by-kd?start_date=2026-03-01&end_date=2026-03-07
|
||||
|
||||
Returns:
|
||||
{
|
||||
"success": true,
|
||||
"order_count": 4,
|
||||
"by_kd_code": {
|
||||
"670400830": {
|
||||
"product_name": "레바미피드정",
|
||||
"spec": "100T",
|
||||
"boxes": 2,
|
||||
"units": 200
|
||||
}
|
||||
},
|
||||
"total_products": 15
|
||||
}
|
||||
"""
|
||||
import re
|
||||
from datetime import datetime
|
||||
|
||||
today = datetime.now().strftime("%Y-%m-%d")
|
||||
start_date = request.args.get('start_date', today).strip()
|
||||
end_date = request.args.get('end_date', today).strip()
|
||||
|
||||
def parse_spec(spec: str, product_name: str = '') -> int:
|
||||
"""
|
||||
규격에서 수량 추출 (30T → 30, 100C → 100)
|
||||
|
||||
단위 처리:
|
||||
- T/C/P: 정/캡슐/포 → 숫자 그대로 (30T → 30)
|
||||
- D: 도즈/분사 → 1로 처리 (140D → 1, 박스 단위)
|
||||
- mg/ml/g: 용량 → 1로 처리
|
||||
"""
|
||||
combined = f"{spec} {product_name}"
|
||||
|
||||
# D(도즈) 단위는 박스 단위로 계산 (140D → 1)
|
||||
if re.search(r'\d+\s*D\b', combined, re.IGNORECASE):
|
||||
return 1
|
||||
|
||||
# T/C/P 단위가 붙은 숫자 추출 (예: 14T, 100C, 30P)
|
||||
qty_match = re.search(r'(\d+)\s*[TCP]\b', combined, re.IGNORECASE)
|
||||
if qty_match:
|
||||
return int(qty_match.group(1))
|
||||
|
||||
# 없으면 spec의 첫 번째 숫자 (mg, ml 등 용량일 수 있음 - 기본값 1)
|
||||
if spec:
|
||||
num_match = re.search(r'(\d+)', spec)
|
||||
if num_match:
|
||||
val = int(num_match.group(1))
|
||||
# mg, ml 같은 용량 단위면 수량 1로 처리
|
||||
if re.search(r'\d+\s*(mg|ml|g)\b', spec, re.IGNORECASE):
|
||||
return 1
|
||||
return val
|
||||
|
||||
return 1
|
||||
|
||||
try:
|
||||
session = get_geo_session()
|
||||
|
||||
# 주문 목록 조회 (items 포함)
|
||||
orders_result = session.get_order_list(start_date, end_date)
|
||||
|
||||
if not orders_result.get('success'):
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': orders_result.get('error', 'ORDERS_FETCH_FAILED'),
|
||||
'by_kd_code': {},
|
||||
'order_count': 0
|
||||
})
|
||||
|
||||
orders = orders_result.get('orders', [])
|
||||
|
||||
# 각 주문의 items에 KD코드 추가 (enrich)
|
||||
for order in orders:
|
||||
items = order.get('items', [])
|
||||
if items:
|
||||
session._enrich_kd_codes(items)
|
||||
|
||||
# KD코드별 집계
|
||||
kd_summary = {}
|
||||
|
||||
for order in orders:
|
||||
# 지오영은 get_order_list에서 items도 같이 반환
|
||||
for item in order.get('items', []):
|
||||
# 취소/삭제 상태 제외
|
||||
status = item.get('status', '').strip()
|
||||
if '취소' in status or '삭제' in status:
|
||||
continue
|
||||
|
||||
kd_code = item.get('kd_code', '')
|
||||
if not kd_code:
|
||||
continue
|
||||
|
||||
product_name = item.get('product_name', '')
|
||||
spec = item.get('spec', '')
|
||||
quantity = item.get('quantity', 0) or item.get('order_qty', 0)
|
||||
per_unit = parse_spec(spec, product_name)
|
||||
total_units = quantity * per_unit
|
||||
|
||||
if kd_code not in kd_summary:
|
||||
kd_summary[kd_code] = {
|
||||
'product_name': product_name,
|
||||
'spec': spec,
|
||||
'boxes': 0,
|
||||
'units': 0
|
||||
}
|
||||
|
||||
kd_summary[kd_code]['boxes'] += quantity
|
||||
kd_summary[kd_code]['units'] += total_units
|
||||
|
||||
logger.info(f"지오영 주문량 집계: {start_date}~{end_date}, {len(orders)}건 주문, {len(kd_summary)}개 품목")
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'order_count': len(orders),
|
||||
'period': {'start': start_date, 'end': end_date},
|
||||
'by_kd_code': kd_summary,
|
||||
'total_products': len(kd_summary)
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"지오영 주문량 집계 오류: {e}")
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': 'API_ERROR',
|
||||
'message': str(e),
|
||||
'by_kd_code': {},
|
||||
'order_count': 0
|
||||
}), 500
|
||||
|
||||
|
||||
@geoyoung_bp.route('/order-today', methods=['GET'])
|
||||
def api_geoyoung_order_today():
|
||||
"""
|
||||
지오영 오늘 주문 요약 API
|
||||
|
||||
GET /api/geoyoung/order-today
|
||||
|
||||
Returns:
|
||||
{
|
||||
"success": true,
|
||||
"date": "2026-03-07",
|
||||
"order_count": 3,
|
||||
"total_amount": 450000,
|
||||
"item_count": 15,
|
||||
"orders": [...]
|
||||
}
|
||||
"""
|
||||
try:
|
||||
session = get_geo_session()
|
||||
result = session.get_today_order_summary()
|
||||
return jsonify(result)
|
||||
except Exception as e:
|
||||
logger.error(f"지오영 오늘 주문 조회 오류: {e}")
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': 'API_ERROR',
|
||||
'message': str(e),
|
||||
'date': '',
|
||||
'order_count': 0,
|
||||
'total_amount': 0
|
||||
}), 500
|
||||
|
||||
|
||||
# ========== 하위 호환성 ==========
|
||||
|
||||
# 기존 코드에서 직접 클래스 참조하는 경우를 위해
|
||||
GeoyoungSession = GeoYoungSession
|
||||
10
backend/ordLedger_page.html
Normal file
BIN
backend/ordLedger_screenshot.png
Normal file
|
After Width: | Height: | Size: 302 KiB |
1389
backend/order_api.py
Normal file
861
backend/order_db.py
Normal file
@@ -0,0 +1,861 @@
|
||||
# -*- 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, -- 보험코드 (지오영 검색용)
|
||||
internal_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, internal_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('internal_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
@@ -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
@@ -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
@@ -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()
|
||||
1820
backend/pmr_api.py
Normal file
169
backend/pos_printer.py
Normal file
@@ -0,0 +1,169 @@
|
||||
# pos_printer.py - ESC/POS 영수증 프린터 유틸리티
|
||||
# 0bin-label-app/src/pos_settings_dialog.py 기반
|
||||
|
||||
import socket
|
||||
import logging
|
||||
from datetime import datetime
|
||||
|
||||
# 프린터 설정 (config에서 불러올 수도 있음)
|
||||
POS_PRINTER_IP = "192.168.0.174"
|
||||
POS_PRINTER_PORT = 9100
|
||||
POS_PRINTER_NAME = "올댓포스 오른쪽"
|
||||
|
||||
# ESC/POS 명령어
|
||||
ESC = b'\x1b'
|
||||
GS = b'\x1d'
|
||||
|
||||
# 기본 명령
|
||||
INIT = ESC + b'@' # 프린터 초기화
|
||||
CUT = ESC + b'd\x03' # 피드 + 커트 (원본 방식)
|
||||
FEED = b'\n\n\n' # 줄바꿈
|
||||
|
||||
# 정렬
|
||||
ALIGN_LEFT = ESC + b'a\x00'
|
||||
ALIGN_CENTER = ESC + b'a\x01'
|
||||
ALIGN_RIGHT = ESC + b'a\x02'
|
||||
|
||||
# 폰트 스타일
|
||||
BOLD_ON = ESC + b'E\x01'
|
||||
BOLD_OFF = ESC + b'E\x00'
|
||||
DOUBLE_HEIGHT = ESC + b'!\x10'
|
||||
DOUBLE_WIDTH = ESC + b'!\x20'
|
||||
DOUBLE_SIZE = ESC + b'!\x30' # 가로세로 2배
|
||||
NORMAL_SIZE = ESC + b'!\x00'
|
||||
|
||||
# 로깅
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
|
||||
|
||||
def print_raw(data: bytes, ip: str = None, port: int = None) -> bool:
|
||||
"""
|
||||
ESC/POS 바이트 데이터를 프린터로 전송
|
||||
|
||||
Args:
|
||||
data: ESC/POS 명령어 + 텍스트 바이트
|
||||
ip: 프린터 IP (기본값: POS_PRINTER_IP)
|
||||
port: 프린터 포트 (기본값: POS_PRINTER_PORT)
|
||||
|
||||
Returns:
|
||||
bool: 성공 여부
|
||||
"""
|
||||
ip = ip or POS_PRINTER_IP
|
||||
port = port or POS_PRINTER_PORT
|
||||
|
||||
try:
|
||||
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
sock.settimeout(5)
|
||||
sock.connect((ip, port))
|
||||
sock.sendall(data)
|
||||
sock.close()
|
||||
logging.info(f"[POS Printer] 전송 성공: {ip}:{port}")
|
||||
return True
|
||||
except socket.timeout:
|
||||
logging.error(f"[POS Printer] 연결 시간 초과: {ip}:{port}")
|
||||
return False
|
||||
except ConnectionRefusedError:
|
||||
logging.error(f"[POS Printer] 연결 거부됨: {ip}:{port}")
|
||||
return False
|
||||
except Exception as e:
|
||||
logging.error(f"[POS Printer] 전송 실패: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def print_text(text: str, cut: bool = True) -> bool:
|
||||
"""
|
||||
텍스트를 영수증 프린터로 출력
|
||||
|
||||
Args:
|
||||
text: 출력할 텍스트 (한글 지원)
|
||||
cut: 출력 후 용지 커트 여부
|
||||
|
||||
Returns:
|
||||
bool: 성공 여부
|
||||
"""
|
||||
try:
|
||||
# EUC-KR 인코딩 (한글 지원)
|
||||
text_bytes = text.encode('euc-kr', errors='replace')
|
||||
|
||||
# 명령어 조합
|
||||
command = INIT + text_bytes + b'\n\n\n'
|
||||
if cut:
|
||||
command += CUT
|
||||
|
||||
return print_raw(command)
|
||||
except Exception as e:
|
||||
logging.error(f"[POS Printer] 텍스트 인쇄 실패: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def print_cusetc(customer_name: str, cusetc: str, phone: str = None) -> bool:
|
||||
"""
|
||||
특이(참고)사항 영수증 출력 (단순 텍스트 방식)
|
||||
|
||||
Args:
|
||||
customer_name: 고객 이름
|
||||
cusetc: 특이사항 내용
|
||||
phone: 전화번호 (선택)
|
||||
|
||||
Returns:
|
||||
bool: 성공 여부
|
||||
"""
|
||||
now = datetime.now().strftime('%Y-%m-%d %H:%M')
|
||||
|
||||
# 전화번호 포맷팅
|
||||
phone_display = ""
|
||||
if phone:
|
||||
phone_clean = phone.replace("-", "").replace(" ", "")
|
||||
if len(phone_clean) == 11:
|
||||
phone_display = f"{phone_clean[:3]}-{phone_clean[3:7]}-{phone_clean[7:]}"
|
||||
else:
|
||||
phone_display = phone
|
||||
|
||||
# 80mm 프린터 = 48자 기준
|
||||
LINE = "=" * 48
|
||||
THIN = "-" * 48
|
||||
|
||||
message = f"""
|
||||
{LINE}
|
||||
[ 특이사항 ]
|
||||
{LINE}
|
||||
고객: {customer_name}
|
||||
"""
|
||||
if phone_display:
|
||||
message += f"연락처: {phone_display}\n"
|
||||
|
||||
message += f"""출력: {now}
|
||||
{THIN}
|
||||
{cusetc}
|
||||
{LINE}
|
||||
청춘약국
|
||||
"""
|
||||
|
||||
return print_text(message, cut=True)
|
||||
|
||||
|
||||
def test_print() -> bool:
|
||||
"""테스트 인쇄"""
|
||||
now = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
|
||||
|
||||
test_message = f"""
|
||||
================================
|
||||
POS 프린터 테스트
|
||||
================================
|
||||
|
||||
IP: {POS_PRINTER_IP}
|
||||
Port: {POS_PRINTER_PORT}
|
||||
Time: {now}
|
||||
|
||||
청춘약국 마일리지 시스템
|
||||
ESC/POS 정상 작동!
|
||||
================================
|
||||
"""
|
||||
return print_text(test_message, cut=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# 테스트
|
||||
print("POS 프린터 테스트 인쇄...")
|
||||
result = test_print()
|
||||
print(f"결과: {'성공' if result else '실패'}")
|
||||
82
backend/scripts/batch_apc_matching.py
Normal file
@@ -0,0 +1,82 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
동물약 일괄 APC 매칭 - 후보 찾기
|
||||
"""
|
||||
import sys, io
|
||||
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8')
|
||||
sys.path.insert(0, 'c:\\Users\\청춘약국\\source\\pharmacy-pos-qr-system\\backend')
|
||||
|
||||
from db.dbsetup import get_db_session
|
||||
from sqlalchemy import text, create_engine
|
||||
|
||||
# 1. MSSQL 동물약 (APC 없는 것만)
|
||||
session = get_db_session('PM_DRUG')
|
||||
result = session.execute(text("""
|
||||
SELECT
|
||||
G.DrugCode,
|
||||
G.GoodsName,
|
||||
G.Saleprice,
|
||||
(
|
||||
SELECT TOP 1 U.CD_CD_BARCODE
|
||||
FROM CD_ITEM_UNIT_MEMBER U
|
||||
WHERE U.DRUGCODE = G.DrugCode
|
||||
AND U.CD_CD_BARCODE LIKE '023%'
|
||||
) AS APC_CODE
|
||||
FROM CD_GOODS G
|
||||
WHERE G.POS_BOON = '010103'
|
||||
AND G.GoodsSelCode = 'B'
|
||||
ORDER BY G.GoodsName
|
||||
"""))
|
||||
|
||||
no_apc = []
|
||||
for row in result:
|
||||
if not row.APC_CODE:
|
||||
no_apc.append({
|
||||
'code': row.DrugCode,
|
||||
'name': row.GoodsName,
|
||||
'price': row.Saleprice
|
||||
})
|
||||
|
||||
session.close()
|
||||
|
||||
print(f'=== APC 없는 동물약: {len(no_apc)}개 ===\n')
|
||||
|
||||
# 2. PostgreSQL에서 매칭 후보 찾기
|
||||
pg = create_engine('postgresql://admin:trajet6640@192.168.0.87:5432/apdb_master').connect()
|
||||
|
||||
matches = []
|
||||
for drug in no_apc:
|
||||
name = drug['name']
|
||||
# 제품명에서 검색 키워드 추출
|
||||
# (판) 제거, 괄호 내용 제거
|
||||
search_name = name.replace('(판)', '').split('(')[0].strip()
|
||||
|
||||
# PostgreSQL 검색
|
||||
result = pg.execute(text("""
|
||||
SELECT apc, product_name,
|
||||
llm_pharm->>'사용가능 동물' as target,
|
||||
llm_pharm->>'분류' as category
|
||||
FROM apc
|
||||
WHERE product_name ILIKE :pattern
|
||||
ORDER BY LENGTH(product_name)
|
||||
LIMIT 5
|
||||
"""), {'pattern': f'%{search_name}%'})
|
||||
|
||||
candidates = list(result)
|
||||
if candidates:
|
||||
matches.append({
|
||||
'mssql': drug,
|
||||
'candidates': candidates
|
||||
})
|
||||
print(f'✅ {name}')
|
||||
for c in candidates[:2]:
|
||||
print(f' → {c.apc}: {c.product_name[:40]}... [{c.target or "?"}]')
|
||||
else:
|
||||
print(f'❌ {name} - 매칭 없음')
|
||||
|
||||
pg.close()
|
||||
|
||||
print(f'\n=== 요약 ===')
|
||||
print(f'APC 없는 제품: {len(no_apc)}개')
|
||||
print(f'매칭 후보 있음: {len(matches)}개')
|
||||
print(f'매칭 없음: {len(no_apc) - len(matches)}개')
|
||||
75
backend/scripts/batch_insert_apc.py
Normal file
@@ -0,0 +1,75 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
확실한 매칭만 일괄 등록
|
||||
"""
|
||||
import sys, io
|
||||
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8')
|
||||
sys.path.insert(0, 'c:\\Users\\청춘약국\\source\\pharmacy-pos-qr-system\\backend')
|
||||
|
||||
from db.dbsetup import get_db_session
|
||||
from sqlalchemy import text
|
||||
from datetime import datetime
|
||||
|
||||
# 확실한 매칭 목록 (MSSQL 제품명, DrugCode, APC)
|
||||
MAPPINGS = [
|
||||
# 파라캅
|
||||
('파라캅L(5kg이상)', 'LB000003159', '0230338510101'), # 파라캅 L 정 10정
|
||||
('파라캅S(5kg이하)', 'LB000003160', '0230347110106'), # 파라캅 에스 정 10정
|
||||
# 세레니아
|
||||
('세레니아정16mg(개멀미약)', 'LB000003353', '0231884610109'), # 세레니아 정 16mg / 4정
|
||||
('세레니아정24mg(개멀미약)', 'LB000003354', '0231884620107'), # 세레니아 정 24mg / 4정
|
||||
]
|
||||
|
||||
session = get_db_session('PM_DRUG')
|
||||
today = datetime.now().strftime('%Y%m%d')
|
||||
|
||||
print('=== 일괄 APC 매핑 ===\n')
|
||||
|
||||
for name, drugcode, apc in MAPPINGS:
|
||||
# 기존 가격 조회
|
||||
existing = session.execute(text("""
|
||||
SELECT TOP 1 CD_MY_UNIT, CD_IN_UNIT
|
||||
FROM CD_ITEM_UNIT_MEMBER
|
||||
WHERE DRUGCODE = :dc
|
||||
ORDER BY SN DESC
|
||||
"""), {'dc': drugcode}).fetchone()
|
||||
|
||||
if not existing:
|
||||
print(f'❌ {name}: 기존 레코드 없음')
|
||||
continue
|
||||
|
||||
# 이미 APC 있는지 확인
|
||||
check = session.execute(text("""
|
||||
SELECT 1 FROM CD_ITEM_UNIT_MEMBER
|
||||
WHERE DRUGCODE = :dc AND CD_CD_BARCODE = :apc
|
||||
"""), {'dc': drugcode, 'apc': apc}).fetchone()
|
||||
|
||||
if check:
|
||||
print(f'⏭️ {name}: 이미 등록됨')
|
||||
continue
|
||||
|
||||
# INSERT
|
||||
try:
|
||||
session.execute(text("""
|
||||
INSERT INTO CD_ITEM_UNIT_MEMBER (
|
||||
DRUGCODE, CD_CD_UNIT, CD_NM_UNIT, CD_MY_UNIT, CD_IN_UNIT,
|
||||
CD_CD_BARCODE, CD_CD_POS, CHANGE_DATE
|
||||
) VALUES (
|
||||
:drugcode, '015', 1.0, :my_unit, :in_unit,
|
||||
:barcode, '', :change_date
|
||||
)
|
||||
"""), {
|
||||
'drugcode': drugcode,
|
||||
'my_unit': existing.CD_MY_UNIT,
|
||||
'in_unit': existing.CD_IN_UNIT,
|
||||
'barcode': apc,
|
||||
'change_date': today
|
||||
})
|
||||
session.commit()
|
||||
print(f'✅ {name} → {apc}')
|
||||
except Exception as e:
|
||||
session.rollback()
|
||||
print(f'❌ {name}: {e}')
|
||||
|
||||
session.close()
|
||||
print('\n완료!')
|
||||
34
backend/scripts/debug_gesidin_match.py
Normal file
@@ -0,0 +1,34 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
import sys, io
|
||||
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8')
|
||||
|
||||
# 테스트 AI 응답
|
||||
ai_response = "개시딘은 피부염 치료에 사용하는 겔 형태의 외용약입니다."
|
||||
|
||||
drug_name = "(판)복합개시딘"
|
||||
|
||||
# 현재 매칭 로직
|
||||
base_name = drug_name.split('(')[0].split('/')[0].strip()
|
||||
print(f'제품명: {drug_name}')
|
||||
print(f'괄호 앞: "{base_name}"')
|
||||
|
||||
# suffix 제거
|
||||
for suffix in ['정', '액', 'L', 'M', 'S', 'XL', 'XS', 'SS', 'mini']:
|
||||
if base_name.endswith(suffix):
|
||||
base_name = base_name[:-len(suffix)]
|
||||
base_name = base_name.strip()
|
||||
print(f'suffix 제거 후: "{base_name}"')
|
||||
|
||||
# 매칭 테스트
|
||||
ai_lower = ai_response.lower()
|
||||
ai_nospace = ai_lower.replace(' ', '')
|
||||
base_lower = base_name.lower()
|
||||
base_nospace = base_lower.replace(' ', '')
|
||||
|
||||
print(f'\n매칭 테스트:')
|
||||
print(f' "{base_lower}" in ai_response? {base_lower in ai_lower}')
|
||||
print(f' "{base_nospace}" in ai_nospace? {base_nospace in ai_nospace}')
|
||||
|
||||
# 문제: (판)이 먼저 잘려서 빈 문자열이 됨!
|
||||
print(f'\n문제: split("(")[0] = "{drug_name.split("(")[0]}"')
|
||||
print('→ "(판)"에서 "("로 시작하니까 빈 문자열!')
|
||||
51
backend/scripts/debug_matching.py
Normal file
@@ -0,0 +1,51 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
import sys
|
||||
import io
|
||||
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8', errors='replace')
|
||||
|
||||
# 테스트 AI 응답 (실제 응답 시뮬레이션)
|
||||
ai_response = """
|
||||
네, 안텔민은 개와 고양이 모두 사용 가능합니다!
|
||||
|
||||
**안텔민 킹** - 체중 5kg 이상 반려동물용
|
||||
**안텔민 뽀삐** - 체중 5kg 이하 소형 반려동물용
|
||||
|
||||
두 제품 모두 개와 고양이의 내부 기생충 구제에 효과적입니다.
|
||||
"""
|
||||
|
||||
animal_drugs = [
|
||||
{'name': '안텔민킹(5kg이상)', 'code': 'LB000003157'},
|
||||
{'name': '안텔민뽀삐(5kg이하)', 'code': 'LB000003158'},
|
||||
{'name': '다이로하트정M(12~22kg)', 'code': 'LB000003151'},
|
||||
]
|
||||
|
||||
print('=== 현재 매칭 로직 테스트 ===\n')
|
||||
print(f'AI 응답:\n{ai_response}\n')
|
||||
print('=' * 50)
|
||||
|
||||
ai_response_lower = ai_response.lower()
|
||||
|
||||
for drug in animal_drugs:
|
||||
drug_name = drug['name']
|
||||
base_name = drug_name.split('(')[0].split('/')[0].strip()
|
||||
|
||||
# suffix 제거
|
||||
original_base = base_name
|
||||
for suffix in ['정', '액', 'L', 'M', 'S', 'XL', 'XS', 'SS', 'mini']:
|
||||
if base_name.endswith(suffix):
|
||||
base_name = base_name[:-len(suffix)]
|
||||
base_name = base_name.strip()
|
||||
|
||||
matched = base_name.lower() in ai_response_lower
|
||||
|
||||
print(f'\n제품: {drug_name}')
|
||||
print(f' 괄호 앞: {original_base}')
|
||||
print(f' suffix 제거 후: {base_name}')
|
||||
print(f' 매칭 결과: {"✅ 매칭됨" if matched else "❌ 매칭 안됨"}')
|
||||
|
||||
if not matched:
|
||||
# 왜 안 됐는지 확인
|
||||
print(f' → "{base_name.lower()}" in 응답? {base_name.lower() in ai_response_lower}')
|
||||
# 띄어쓰기 변형 체크
|
||||
spaced = base_name.replace('킹', ' 킹').replace('뽀삐', ' 뽀삐')
|
||||
print(f' → 띄어쓰기 변형 "{spaced.lower()}" in 응답? {spaced.lower() in ai_response_lower}')
|
||||
48
backend/scripts/debug_matching2.py
Normal file
@@ -0,0 +1,48 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
import sys
|
||||
import io
|
||||
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8', errors='replace')
|
||||
|
||||
# 실제 AI 응답
|
||||
ai_response = """안텔민은 개와 고양이 모두에게 사용할 수 있습니다만, 체중에 따라 복용할 용량이 다릅니다. 🐾
|
||||
|
||||
- **안텔민**: 5kg 이상 개와 고양이에게 복용 가능.
|
||||
- **안텔민 뽀삐**: 5kg 미만 소형 반려동물에게 복용 가능.
|
||||
|
||||
따라서, 반려동물의 체중에 맞는 적절한 제품을 선택해야 해요! 🐶 체중을 알려주시면 더 구체적으로 안내해 드릴 수 있어요."""
|
||||
|
||||
animal_drugs = [
|
||||
{'name': '안텔민', 'code': 'S0000001', 'apc': None},
|
||||
{'name': '안텔민킹(5kg이상)', 'code': 'LB000003157', 'apc': '0230237810109'},
|
||||
{'name': '안텔민뽀삐(5kg이하)', 'code': 'LB000003158', 'apc': '0230237010107'},
|
||||
]
|
||||
|
||||
print('=== 매칭 테스트 ===\n')
|
||||
print(f'AI 응답:\n{ai_response}\n')
|
||||
print('=' * 50)
|
||||
|
||||
ai_response_lower = ai_response.lower()
|
||||
ai_response_nospace = ai_response_lower.replace(' ', '')
|
||||
|
||||
for drug in animal_drugs:
|
||||
drug_name = drug['name']
|
||||
base_name = drug_name.split('(')[0].split('/')[0].strip()
|
||||
|
||||
for suffix in ['정', '액', 'L', 'M', 'S', 'XL', 'XS', 'SS', 'mini']:
|
||||
if base_name.endswith(suffix):
|
||||
base_name = base_name[:-len(suffix)]
|
||||
base_name = base_name.strip()
|
||||
|
||||
base_lower = base_name.lower()
|
||||
base_nospace = base_lower.replace(' ', '')
|
||||
|
||||
in_normal = base_lower in ai_response_lower
|
||||
in_nospace = base_nospace in ai_response_nospace
|
||||
matched = len(base_name) >= 2 and (in_normal or in_nospace)
|
||||
|
||||
print(f'\n제품: {drug_name}')
|
||||
print(f' base_name: "{base_name}"')
|
||||
print(f' base_nospace: "{base_nospace}"')
|
||||
print(f' 일반매칭: {in_normal}')
|
||||
print(f' 공백제거매칭: {in_nospace}')
|
||||
print(f' 최종: {"✅" if matched else "❌"}')
|
||||
43
backend/scripts/debug_prompt.py
Normal file
@@ -0,0 +1,43 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
import sys
|
||||
import io
|
||||
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8', errors='replace')
|
||||
sys.path.insert(0, 'c:\\Users\\청춘약국\\source\\pharmacy-pos-qr-system\\backend')
|
||||
|
||||
from db.dbsetup import get_db_session
|
||||
from sqlalchemy import text
|
||||
|
||||
# _get_animal_drugs 로직 복제
|
||||
drug_session = get_db_session('PM_DRUG')
|
||||
query = text("""
|
||||
SELECT
|
||||
G.DrugCode,
|
||||
G.GoodsName,
|
||||
G.Saleprice,
|
||||
(
|
||||
SELECT TOP 1 U.CD_CD_BARCODE
|
||||
FROM CD_ITEM_UNIT_MEMBER U
|
||||
WHERE U.DRUGCODE = G.DrugCode
|
||||
AND U.CD_CD_BARCODE LIKE '023%'
|
||||
ORDER BY U.CHANGE_DATE DESC
|
||||
) AS APC_CODE
|
||||
FROM CD_GOODS G
|
||||
WHERE G.POS_BOON = '010103'
|
||||
AND G.GoodsSelCode = 'B'
|
||||
ORDER BY G.GoodsName
|
||||
""")
|
||||
rows = drug_session.execute(query).fetchall()
|
||||
|
||||
print('=== AI에 전달되는 보유 제품 목록 ===\n')
|
||||
for r in rows:
|
||||
apc = r.APC_CODE
|
||||
rag_info = ""
|
||||
if apc:
|
||||
rag_info = f" [대상: 개, 고양이]" # RAG 정보 시뮬레이션
|
||||
|
||||
print(f"- {r.GoodsName} ({r.Saleprice:,.0f}원){rag_info}")
|
||||
|
||||
print('\n=== 안텔민 관련 제품만 ===')
|
||||
for r in rows:
|
||||
if '안텔민' in r.GoodsName:
|
||||
print(f" {r.GoodsName} - APC: {r.APC_CODE}")
|
||||
42
backend/scripts/debug_rag.py
Normal file
@@ -0,0 +1,42 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
import sys
|
||||
import io
|
||||
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8', errors='replace')
|
||||
|
||||
from sqlalchemy import create_engine, text
|
||||
|
||||
pg = create_engine('postgresql://admin:trajet6640@192.168.0.87:5432/apdb_master').connect()
|
||||
|
||||
# 안텔민킹 RAG 정보
|
||||
apc = '0230237810109'
|
||||
print(f'=== 안텔민킹 ({apc}) RAG 정보 ===\n')
|
||||
|
||||
result = pg.execute(text(f"""
|
||||
SELECT
|
||||
product_name,
|
||||
llm_pharm->>'사용가능 동물' as target_animals,
|
||||
llm_pharm->>'분류' as category,
|
||||
llm_pharm->>'체중/부위' as dosage_weight,
|
||||
llm_pharm->>'월령금기' as age_restriction
|
||||
FROM apc
|
||||
WHERE apc = '{apc}'
|
||||
"""))
|
||||
|
||||
row = result.fetchone()
|
||||
if row:
|
||||
print(f'제품명: {row.product_name}')
|
||||
print(f'사용가능 동물: {row.target_animals}')
|
||||
print(f'분류: {row.category}')
|
||||
print(f'체중/용량: {row.dosage_weight}')
|
||||
print(f'월령금기: {row.age_restriction}')
|
||||
|
||||
# efficacy_effect도 확인
|
||||
result2 = pg.execute(text(f"""
|
||||
SELECT efficacy_effect FROM apc WHERE apc = '{apc}'
|
||||
"""))
|
||||
row2 = result2.fetchone()
|
||||
if row2 and row2.efficacy_effect:
|
||||
print(f'\n효능/효과 (원문 일부):')
|
||||
print(row2.efficacy_effect[:500])
|
||||
|
||||
pg.close()
|
||||
86
backend/scripts/debug_rag_prompt.py
Normal file
@@ -0,0 +1,86 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
import sys
|
||||
import io
|
||||
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8', errors='replace')
|
||||
sys.path.insert(0, 'c:\\Users\\청춘약국\\source\\pharmacy-pos-qr-system\\backend')
|
||||
|
||||
from db.dbsetup import get_db_session
|
||||
from sqlalchemy import text, create_engine
|
||||
|
||||
# 1. _get_animal_drugs 시뮬레이션
|
||||
drug_session = get_db_session('PM_DRUG')
|
||||
query = text("""
|
||||
SELECT
|
||||
G.DrugCode,
|
||||
G.GoodsName,
|
||||
G.Saleprice,
|
||||
(
|
||||
SELECT TOP 1 U.CD_CD_BARCODE
|
||||
FROM CD_ITEM_UNIT_MEMBER U
|
||||
WHERE U.DRUGCODE = G.DrugCode
|
||||
AND U.CD_CD_BARCODE LIKE '023%'
|
||||
ORDER BY U.CHANGE_DATE DESC
|
||||
) AS APC_CODE
|
||||
FROM CD_GOODS G
|
||||
WHERE G.POS_BOON = '010103'
|
||||
AND G.GoodsSelCode = 'B'
|
||||
ORDER BY G.GoodsName
|
||||
""")
|
||||
rows = drug_session.execute(query).fetchall()
|
||||
|
||||
animal_drugs = []
|
||||
for r in rows:
|
||||
animal_drugs.append({
|
||||
'code': r.DrugCode,
|
||||
'name': r.GoodsName,
|
||||
'price': float(r.Saleprice) if r.Saleprice else 0,
|
||||
'apc': r.APC_CODE
|
||||
})
|
||||
|
||||
# 2. _get_animal_drug_rag 시뮬레이션
|
||||
apc_codes = [d['apc'] for d in animal_drugs if d.get('apc')]
|
||||
print(f'APC 코드 목록: {apc_codes}\n')
|
||||
|
||||
rag_data = {}
|
||||
if apc_codes:
|
||||
pg = create_engine('postgresql://admin:trajet6640@192.168.0.87:5432/apdb_master').connect()
|
||||
placeholders = ','.join([f"'{apc}'" for apc in apc_codes])
|
||||
result = pg.execute(text(f"""
|
||||
SELECT apc, product_name,
|
||||
llm_pharm->>'사용가능 동물' as target_animals,
|
||||
llm_pharm->>'분류' as category,
|
||||
llm_pharm->>'체중/부위' as dosage_weight,
|
||||
llm_pharm->>'기간/용법' as usage_period,
|
||||
llm_pharm->>'월령금기' as age_restriction
|
||||
FROM apc
|
||||
WHERE apc IN ({placeholders})
|
||||
"""))
|
||||
for row in result:
|
||||
rag_data[row.apc] = {
|
||||
'target_animals': row.target_animals or '정보 없음',
|
||||
'category': row.category or '',
|
||||
'dosage_weight': row.dosage_weight or '',
|
||||
'usage_period': row.usage_period or '',
|
||||
'age_restriction': row.age_restriction or ''
|
||||
}
|
||||
pg.close()
|
||||
|
||||
print(f'RAG 데이터: {rag_data}\n')
|
||||
|
||||
# 3. available_products_text 생성
|
||||
print('=== AI에 전달되는 제품 목록 (RAG 포함) ===\n')
|
||||
for d in animal_drugs:
|
||||
if '안텔민' in d['name']:
|
||||
line = f"- {d['name']} ({d['price']:,.0f}원)"
|
||||
if d.get('apc') and d['apc'] in rag_data:
|
||||
info = rag_data[d['apc']]
|
||||
details = []
|
||||
if info.get('target_animals'):
|
||||
details.append(f"대상: {info['target_animals']}")
|
||||
if info.get('dosage_weight'):
|
||||
details.append(f"용량: {info['dosage_weight']}")
|
||||
if info.get('age_restriction'):
|
||||
details.append(f"금기: {info['age_restriction']}")
|
||||
if details:
|
||||
line += f" [{', '.join(details)}]"
|
||||
print(line)
|
||||
45
backend/scripts/detailed_search.py
Normal file
@@ -0,0 +1,45 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
import sys, io
|
||||
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8')
|
||||
from sqlalchemy import create_engine, text
|
||||
|
||||
pg = create_engine('postgresql://admin:trajet6640@192.168.0.87:5432/apdb_master').connect()
|
||||
|
||||
# 약국 제품 → PostgreSQL 매칭 (체중/용량 포함)
|
||||
mappings = [
|
||||
# (약국제품명, 검색키워드)
|
||||
('제스타제(10정)', '제스타제', '10'),
|
||||
('파라캅L(5kg이상)', '파라캅', 'L'),
|
||||
('파라캅S(5kg이하)', '파라캅', 'S'),
|
||||
('하트캅츄어블(11kg이하)', '하트캅', '11'),
|
||||
('넥스가드L(15~30kg)', '넥스가드', '15'),
|
||||
('넥스가드xs(2~3.5kg)', '넥스가드', '2'),
|
||||
('다이로하트정M(12~22kg)', '다이로하트', '12'),
|
||||
('다이로하트정S(5.6~11kg)', '다이로하트', '5.6'),
|
||||
('다이로하트정SS(5.6kg이하)', '다이로하트', 'SS'),
|
||||
('세레니아정16mg(개멀미약)', '세레니아', '16'),
|
||||
('세레니아정24mg(개멀미약)', '세레니아', '24'),
|
||||
('하트세이버츄어블M(12~22kg)', '하트세이버', '12'),
|
||||
('하트세이버츄어블S(5.6~11kg)', '하트세이버', '5.6'),
|
||||
('하트웜솔루션츄어블M(12~22kg)', '하트웜', '12'),
|
||||
('하트웜솔루션츄어블S(11kg이하)', '하트웜', '11'),
|
||||
]
|
||||
|
||||
print('=== 상세 매칭 검색 ===\n')
|
||||
|
||||
for pharm_name, keyword, size in mappings:
|
||||
result = pg.execute(text("""
|
||||
SELECT apc, product_name, packaging,
|
||||
llm_pharm->>'사용가능 동물' as target
|
||||
FROM apc
|
||||
WHERE product_name ILIKE :kw
|
||||
ORDER BY product_name
|
||||
LIMIT 10
|
||||
"""), {'kw': f'%{keyword}%'})
|
||||
|
||||
print(f'\n📦 {pharm_name} (검색: {keyword}, 사이즈: {size})')
|
||||
for r in result:
|
||||
mark = '⭐' if size.lower() in r.product_name.lower() else ' '
|
||||
print(f'{mark} {r.apc}: {r.product_name[:50]}')
|
||||
|
||||
pg.close()
|
||||
23
backend/scripts/fix_gesidin_boon.py
Normal file
@@ -0,0 +1,23 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
import sys, io
|
||||
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8')
|
||||
sys.path.insert(0, 'c:\\Users\\청춘약국\\source\\pharmacy-pos-qr-system\\backend')
|
||||
|
||||
from db.dbsetup import get_db_session
|
||||
from sqlalchemy import text
|
||||
|
||||
session = get_db_session('PM_DRUG')
|
||||
|
||||
print('업데이트 전:')
|
||||
r = session.execute(text("SELECT GoodsName, POS_BOON FROM CD_GOODS WHERE DrugCode = 'LB000003140'")).fetchone()
|
||||
print(f' {r.GoodsName}: POS_BOON = {r.POS_BOON}')
|
||||
|
||||
session.execute(text("UPDATE CD_GOODS SET POS_BOON = '010103' WHERE DrugCode = 'LB000003140'"))
|
||||
session.commit()
|
||||
|
||||
print('\n업데이트 후:')
|
||||
r2 = session.execute(text("SELECT GoodsName, POS_BOON FROM CD_GOODS WHERE DrugCode = 'LB000003140'")).fetchone()
|
||||
print(f' {r2.GoodsName}: POS_BOON = {r2.POS_BOON}')
|
||||
print(' ✅ 완료!')
|
||||
|
||||
session.close()
|
||||
75
backend/scripts/insert_apc_gesidin.py
Normal file
@@ -0,0 +1,75 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
import sys, io
|
||||
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8')
|
||||
sys.path.insert(0, 'c:\\Users\\청춘약국\\source\\pharmacy-pos-qr-system\\backend')
|
||||
|
||||
from db.dbsetup import get_db_session
|
||||
from sqlalchemy import text
|
||||
from datetime import datetime
|
||||
|
||||
session = get_db_session('PM_DRUG')
|
||||
|
||||
# 1. 기존 데이터에서 가격 정보 가져오기
|
||||
print('1. 기존 레코드에서 가격 정보 조회...')
|
||||
existing = session.execute(text("""
|
||||
SELECT TOP 1 CD_MY_UNIT, CD_IN_UNIT
|
||||
FROM CD_ITEM_UNIT_MEMBER
|
||||
WHERE DRUGCODE = 'LB000003140'
|
||||
ORDER BY SN DESC
|
||||
""")).fetchone()
|
||||
|
||||
sale_price = existing.CD_MY_UNIT
|
||||
purchase_price = existing.CD_IN_UNIT
|
||||
print(f' 판매가: {sale_price:,.0f}원')
|
||||
print(f' 입고가: {purchase_price:,.0f}원')
|
||||
|
||||
# 2. 오늘 날짜
|
||||
today = datetime.now().strftime('%Y%m%d')
|
||||
print(f'\n2. 날짜: {today}')
|
||||
|
||||
# 3. INSERT 실행
|
||||
print('\n3. INSERT 실행...')
|
||||
apc_code = '0231093520106' # 복합개시딘 10g
|
||||
|
||||
try:
|
||||
session.execute(text("""
|
||||
INSERT INTO CD_ITEM_UNIT_MEMBER (
|
||||
DRUGCODE, CD_CD_UNIT, CD_NM_UNIT, CD_MY_UNIT, CD_IN_UNIT,
|
||||
CD_CD_BARCODE, CD_CD_POS, CHANGE_DATE
|
||||
) VALUES (
|
||||
:drugcode, :unit, :nm_unit, :my_unit, :in_unit,
|
||||
:barcode, :pos, :change_date
|
||||
)
|
||||
"""), {
|
||||
'drugcode': 'LB000003140',
|
||||
'unit': '015',
|
||||
'nm_unit': 1.0,
|
||||
'my_unit': sale_price,
|
||||
'in_unit': purchase_price,
|
||||
'barcode': apc_code,
|
||||
'pos': '',
|
||||
'change_date': today
|
||||
})
|
||||
|
||||
session.commit()
|
||||
print(f' ✅ 성공! APC {apc_code} 추가됨')
|
||||
|
||||
# 4. 확인
|
||||
print('\n4. 결과 확인...')
|
||||
result = session.execute(text("""
|
||||
SELECT DRUGCODE, CD_CD_BARCODE, CD_MY_UNIT, SN
|
||||
FROM CD_ITEM_UNIT_MEMBER
|
||||
WHERE DRUGCODE = 'LB000003140' AND CD_CD_BARCODE = :apc
|
||||
"""), {'apc': apc_code})
|
||||
|
||||
row = result.fetchone()
|
||||
if row:
|
||||
print(f' DRUGCODE: {row.DRUGCODE}')
|
||||
print(f' BARCODE: {row.CD_CD_BARCODE}')
|
||||
print(f' SN: {row.SN}')
|
||||
|
||||
except Exception as e:
|
||||
session.rollback()
|
||||
print(f' ❌ 실패: {e}')
|
||||
|
||||
session.close()
|
||||
90
backend/scripts/insert_apc_poppy.py
Normal file
@@ -0,0 +1,90 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
안텔민뽀삐 APC 추가 실행 (SN 자동 생성)
|
||||
"""
|
||||
import sys
|
||||
import io
|
||||
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8', errors='replace')
|
||||
sys.path.insert(0, 'c:\\Users\\청춘약국\\source\\pharmacy-pos-qr-system\\backend')
|
||||
|
||||
from db.dbsetup import get_db_session
|
||||
from sqlalchemy import text
|
||||
from datetime import datetime
|
||||
|
||||
session = get_db_session('PM_DRUG')
|
||||
|
||||
# 1. 기존 데이터에서 가격 정보 가져오기
|
||||
print('1. 기존 레코드에서 가격 정보 조회...')
|
||||
existing = session.execute(text("""
|
||||
SELECT TOP 1 CD_MY_UNIT, CD_IN_UNIT
|
||||
FROM CD_ITEM_UNIT_MEMBER
|
||||
WHERE DRUGCODE = 'LB000003158'
|
||||
ORDER BY SN DESC
|
||||
""")).fetchone()
|
||||
|
||||
sale_price = existing.CD_MY_UNIT
|
||||
purchase_price = existing.CD_IN_UNIT
|
||||
print(f' 판매가: {sale_price:,.0f}원')
|
||||
print(f' 입고가: {purchase_price:,.0f}원')
|
||||
|
||||
# 2. 오늘 날짜
|
||||
today = datetime.now().strftime('%Y%m%d')
|
||||
print(f'\n2. 날짜: {today}')
|
||||
|
||||
# 3. INSERT 실행 (SN은 IDENTITY 자동 생성)
|
||||
print('\n3. INSERT 실행...')
|
||||
apc_code = '0230237010107' # 안텔민뽀삐 10정
|
||||
|
||||
try:
|
||||
session.execute(text("""
|
||||
INSERT INTO CD_ITEM_UNIT_MEMBER (
|
||||
DRUGCODE,
|
||||
CD_CD_UNIT,
|
||||
CD_NM_UNIT,
|
||||
CD_MY_UNIT,
|
||||
CD_IN_UNIT,
|
||||
CD_CD_BARCODE,
|
||||
CD_CD_POS,
|
||||
CHANGE_DATE
|
||||
) VALUES (
|
||||
:drugcode,
|
||||
:unit,
|
||||
:nm_unit,
|
||||
:my_unit,
|
||||
:in_unit,
|
||||
:barcode,
|
||||
:pos,
|
||||
:change_date
|
||||
)
|
||||
"""), {
|
||||
'drugcode': 'LB000003158',
|
||||
'unit': '015',
|
||||
'nm_unit': 1.0,
|
||||
'my_unit': sale_price,
|
||||
'in_unit': purchase_price,
|
||||
'barcode': apc_code,
|
||||
'pos': '',
|
||||
'change_date': today
|
||||
})
|
||||
|
||||
session.commit()
|
||||
print(f' ✅ 성공! APC {apc_code} 추가됨')
|
||||
|
||||
# 4. 확인
|
||||
print('\n4. 결과 확인...')
|
||||
result = session.execute(text("""
|
||||
SELECT * FROM CD_ITEM_UNIT_MEMBER
|
||||
WHERE DRUGCODE = 'LB000003158' AND CD_CD_BARCODE = :apc
|
||||
"""), {'apc': apc_code})
|
||||
|
||||
row = result.fetchone()
|
||||
if row:
|
||||
print(' --- 추가된 레코드 ---')
|
||||
for col in result.keys():
|
||||
print(f' {col}: {getattr(row, col)}')
|
||||
|
||||
except Exception as e:
|
||||
session.rollback()
|
||||
print(f' ❌ 실패: {e}')
|
||||
|
||||
session.close()
|
||||
36
backend/scripts/list_petfarm.py
Normal file
@@ -0,0 +1,36 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
import sys, io
|
||||
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8')
|
||||
sys.path.insert(0, 'c:\\Users\\청춘약국\\source\\pharmacy-pos-qr-system\\backend')
|
||||
|
||||
from db.dbsetup import get_db_session
|
||||
from sqlalchemy import text
|
||||
|
||||
session = get_db_session('PM_DRUG')
|
||||
|
||||
print('=== 펫팜 공급 동물약 ===\n')
|
||||
result = session.execute(text("""
|
||||
SELECT
|
||||
G.DrugCode,
|
||||
G.GoodsName,
|
||||
G.POS_BOON,
|
||||
S.SplName,
|
||||
(
|
||||
SELECT TOP 1 U.CD_CD_BARCODE
|
||||
FROM CD_ITEM_UNIT_MEMBER U
|
||||
WHERE U.DRUGCODE = G.DrugCode
|
||||
AND U.CD_CD_BARCODE LIKE '023%'
|
||||
) AS APC_CODE
|
||||
FROM CD_GOODS G
|
||||
LEFT JOIN CD_SALEGOODS S ON G.DrugCode = S.DrugCode
|
||||
WHERE S.SplName LIKE N'%펫팜%'
|
||||
ORDER BY G.GoodsName
|
||||
"""))
|
||||
|
||||
for row in result:
|
||||
apc_status = f'✅ {row.APC_CODE}' if row.APC_CODE else '❌ 없음'
|
||||
boon_status = '🐾' if row.POS_BOON == '010103' else ' '
|
||||
print(f'{boon_status} {row.GoodsName}')
|
||||
print(f' APC: {apc_status}')
|
||||
|
||||
session.close()
|
||||
68
backend/scripts/perf_test.py
Normal file
@@ -0,0 +1,68 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""APC 매칭 성능 측정"""
|
||||
import sys, io, time
|
||||
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8')
|
||||
sys.path.insert(0, 'c:\\Users\\청춘약국\\source\\pharmacy-pos-qr-system\\backend')
|
||||
|
||||
from db.dbsetup import get_db_session
|
||||
from sqlalchemy import text, create_engine
|
||||
|
||||
print('=== APC 매칭 성능 측정 ===\n')
|
||||
|
||||
# 1. MSSQL: 동물약 + APC 조회
|
||||
start = time.time()
|
||||
session = get_db_session('PM_DRUG')
|
||||
result = session.execute(text("""
|
||||
SELECT G.DrugCode, G.GoodsName,
|
||||
(SELECT TOP 1 U.CD_CD_BARCODE FROM CD_ITEM_UNIT_MEMBER U
|
||||
WHERE U.DRUGCODE = G.DrugCode AND U.CD_CD_BARCODE LIKE '023%') AS APC_CODE
|
||||
FROM CD_GOODS G
|
||||
WHERE G.POS_BOON = '010103' AND G.GoodsSelCode = 'B'
|
||||
"""))
|
||||
mssql_rows = list(result)
|
||||
no_apc = [r for r in mssql_rows if not r.APC_CODE]
|
||||
has_apc = [r for r in mssql_rows if r.APC_CODE]
|
||||
mssql_time = time.time() - start
|
||||
print(f'1. MSSQL 동물약 조회: {mssql_time:.3f}초')
|
||||
print(f' - 총 제품: {len(mssql_rows)}개')
|
||||
print(f' - APC 있음: {len(has_apc)}개 ✅')
|
||||
print(f' - APC 없음: {len(no_apc)}개 ❌')
|
||||
|
||||
# 2. PostgreSQL 연결 + 매칭 검색
|
||||
pg = create_engine('postgresql://admin:trajet6640@192.168.0.87:5432/apdb_master').connect()
|
||||
|
||||
# 샘플 매칭 테스트
|
||||
sample_count = min(5, len(no_apc))
|
||||
start = time.time()
|
||||
match_count = 0
|
||||
for drug in no_apc[:sample_count]:
|
||||
search_name = drug.GoodsName.replace('(판)', '').split('(')[0].strip()
|
||||
res = pg.execute(text("""
|
||||
SELECT apc, product_name FROM apc
|
||||
WHERE product_name ILIKE :p LIMIT 5
|
||||
"""), {'p': f'%{search_name}%'})
|
||||
if list(res):
|
||||
match_count += 1
|
||||
pg_search_time = time.time() - start
|
||||
per_search = pg_search_time / sample_count if sample_count > 0 else 0
|
||||
print(f'\n2. PostgreSQL 매칭 검색: {pg_search_time:.3f}초 ({sample_count}개 샘플)')
|
||||
print(f' - 건당 소요: {per_search*1000:.1f}ms')
|
||||
print(f' - 매칭 성공: {match_count}/{sample_count}')
|
||||
print(f' - 예상 전체: {per_search * len(no_apc):.1f}초 ({len(no_apc)}개)')
|
||||
|
||||
# 3. APC 테이블 통계
|
||||
start = time.time()
|
||||
total_apc = pg.execute(text("SELECT COUNT(*) FROM apc")).scalar()
|
||||
with_image = pg.execute(text("SELECT COUNT(*) FROM apc WHERE image_url1 IS NOT NULL AND image_url1 != ''")).scalar()
|
||||
pg.close()
|
||||
print(f'\n3. APDB 통계:')
|
||||
print(f' - 전체 APC: {total_apc:,}개')
|
||||
print(f' - 이미지 있음: {with_image:,}개 ({with_image/total_apc*100:.1f}%)')
|
||||
|
||||
# 4. CD_ITEM_UNIT_MEMBER 구조 확인
|
||||
print(f'\n4. 현재 APC 매핑 상태:')
|
||||
for r in has_apc[:5]:
|
||||
print(f' ✅ {r.GoodsName[:25]:<25} → {r.APC_CODE}')
|
||||
|
||||
session.close()
|
||||
print('\n=== 측정 완료 ===')
|
||||
89
backend/scripts/prepare_apc_insert.py
Normal file
@@ -0,0 +1,89 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
안텔민뽀삐 APC 추가 준비 스크립트
|
||||
- CD_ITEM_UNIT_MEMBER 구조 확인
|
||||
- 안텔민킹 레코드 참고
|
||||
- INSERT 쿼리 생성 (실행 안 함)
|
||||
"""
|
||||
import sys
|
||||
import io
|
||||
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8', errors='replace')
|
||||
sys.path.insert(0, 'c:\\Users\\청춘약국\\source\\pharmacy-pos-qr-system\\backend')
|
||||
|
||||
from db.dbsetup import get_db_session
|
||||
from sqlalchemy import text
|
||||
|
||||
session = get_db_session('PM_DRUG')
|
||||
|
||||
print('=' * 60)
|
||||
print('1. CD_ITEM_UNIT_MEMBER 테이블 구조')
|
||||
print('=' * 60)
|
||||
result = session.execute(text("""
|
||||
SELECT COLUMN_NAME, DATA_TYPE, IS_NULLABLE, CHARACTER_MAXIMUM_LENGTH
|
||||
FROM INFORMATION_SCHEMA.COLUMNS
|
||||
WHERE TABLE_NAME = 'CD_ITEM_UNIT_MEMBER'
|
||||
ORDER BY ORDINAL_POSITION
|
||||
"""))
|
||||
for r in result:
|
||||
nullable = 'NULL' if r.IS_NULLABLE == 'YES' else 'NOT NULL'
|
||||
length = f'({r.CHARACTER_MAXIMUM_LENGTH})' if r.CHARACTER_MAXIMUM_LENGTH else ''
|
||||
print(f' {r.COLUMN_NAME}: {r.DATA_TYPE}{length} {nullable}')
|
||||
|
||||
print('\n' + '=' * 60)
|
||||
print('2. 안텔민킹 APC 레코드 (참고용)')
|
||||
print('=' * 60)
|
||||
result = session.execute(text("""
|
||||
SELECT * FROM CD_ITEM_UNIT_MEMBER
|
||||
WHERE DRUGCODE = 'LB000003157'
|
||||
AND CD_CD_BARCODE LIKE '023%'
|
||||
"""))
|
||||
row = result.fetchone()
|
||||
if row:
|
||||
cols = result.keys()
|
||||
for col in cols:
|
||||
val = getattr(row, col)
|
||||
print(f' {col}: {val}')
|
||||
|
||||
print('\n' + '=' * 60)
|
||||
print('3. 안텔민뽀삐 현재 레코드')
|
||||
print('=' * 60)
|
||||
result2 = session.execute(text("""
|
||||
SELECT * FROM CD_ITEM_UNIT_MEMBER
|
||||
WHERE DRUGCODE = 'LB000003158'
|
||||
ORDER BY SN DESC
|
||||
"""))
|
||||
rows = list(result2)
|
||||
print(f' 총 {len(rows)}개 레코드')
|
||||
for row in rows[:3]:
|
||||
print(f'\n --- SN: {row.SN} ---')
|
||||
cols = result2.keys()
|
||||
for col in cols:
|
||||
val = getattr(row, col)
|
||||
print(f' {col}: {val}')
|
||||
|
||||
print('\n' + '=' * 60)
|
||||
print('4. 다음 SN 값 확인')
|
||||
print('=' * 60)
|
||||
result3 = session.execute(text("SELECT MAX(SN) as max_sn FROM CD_ITEM_UNIT_MEMBER"))
|
||||
max_sn = result3.fetchone().max_sn
|
||||
print(f' 현재 MAX(SN): {max_sn}')
|
||||
print(f' 다음 SN: {max_sn + 1}')
|
||||
|
||||
session.close()
|
||||
|
||||
print('\n' + '=' * 60)
|
||||
print('5. PostgreSQL에서 안텔민뽀삐 APC 확인')
|
||||
print('=' * 60)
|
||||
from sqlalchemy import create_engine
|
||||
pg = create_engine('postgresql://admin:trajet6640@192.168.0.87:5432/apdb_master').connect()
|
||||
result4 = pg.execute(text("""
|
||||
SELECT apc, product_name
|
||||
FROM apc
|
||||
WHERE product_name ILIKE '%안텔민%뽀삐%' OR product_name ILIKE '%안텔민%5kg%이하%'
|
||||
ORDER BY apc
|
||||
"""))
|
||||
for r in result4:
|
||||
print(f' APC: {r.apc}')
|
||||
print(f' 제품명: {r.product_name}')
|
||||
print()
|
||||
pg.close()
|
||||
168
backend/scripts/query_aniparm.py
Normal file
@@ -0,0 +1,168 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
애니팜 PostgreSQL 조회 스크립트
|
||||
Usage:
|
||||
python scripts/query_aniparm.py schema # 테이블 구조 확인
|
||||
python scripts/query_aniparm.py search <제품명> # 제품 검색
|
||||
python scripts/query_aniparm.py barcode <바코드> # 바코드로 검색
|
||||
python scripts/query_aniparm.py sample # 샘플 데이터
|
||||
python scripts/query_aniparm.py stats # 통계
|
||||
"""
|
||||
import sys
|
||||
import io
|
||||
import json
|
||||
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
# 인코딩 설정 (Windows CP949 문제 방지)
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8', errors='replace')
|
||||
sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding='utf-8', errors='replace')
|
||||
from sqlalchemy import create_engine, text
|
||||
|
||||
# PostgreSQL 연결
|
||||
DATABASE_URI = 'postgresql://admin:trajet6640@192.168.0.87:5432/apdb_master'
|
||||
|
||||
def get_connection():
|
||||
engine = create_engine(DATABASE_URI)
|
||||
return engine.connect()
|
||||
|
||||
def cmd_schema():
|
||||
"""apc 테이블 구조 확인"""
|
||||
conn = get_connection()
|
||||
result = conn.execute(text("""
|
||||
SELECT column_name, data_type, character_maximum_length
|
||||
FROM information_schema.columns
|
||||
WHERE table_name = 'apc'
|
||||
ORDER BY ordinal_position
|
||||
"""))
|
||||
|
||||
print('=== apc 테이블 컬럼 ===')
|
||||
for row in result:
|
||||
length = f'({row.character_maximum_length})' if row.character_maximum_length else ''
|
||||
print(f' {row.column_name}: {row.data_type}{length}')
|
||||
|
||||
conn.close()
|
||||
|
||||
def cmd_search(keyword):
|
||||
"""제품명으로 검색"""
|
||||
conn = get_connection()
|
||||
result = conn.execute(text("""
|
||||
SELECT idx, apc, product_name, company_name,
|
||||
image_url1, godoimage_url_f, for_pets
|
||||
FROM apc
|
||||
WHERE product_name ILIKE :keyword
|
||||
LIMIT 20
|
||||
"""), {'keyword': f'%{keyword}%'})
|
||||
|
||||
print(f'=== "{keyword}" 검색 결과 ===')
|
||||
count = 0
|
||||
for row in result:
|
||||
count += 1
|
||||
print(f'\n[{count}] {row.product_name}')
|
||||
print(f' APC: {row.apc}')
|
||||
print(f' 제조사: {row.company_name}')
|
||||
print(f' 동물용: {row.for_pets}')
|
||||
if row.image_url1:
|
||||
print(f' 이미지1: {row.image_url1[:50]}...')
|
||||
if row.godoimage_url_f:
|
||||
print(f' 고도몰F: {row.godoimage_url_f[:50]}...')
|
||||
|
||||
if count == 0:
|
||||
print('(결과 없음)')
|
||||
|
||||
conn.close()
|
||||
|
||||
def cmd_barcode(barcode):
|
||||
"""바코드로 검색 - 바코드 컬럼이 있는지 먼저 확인"""
|
||||
conn = get_connection()
|
||||
|
||||
# 바코드 관련 컬럼 찾기
|
||||
result = conn.execute(text("""
|
||||
SELECT column_name FROM information_schema.columns
|
||||
WHERE table_name = 'apc'
|
||||
AND column_name ILIKE '%barcode%'
|
||||
"""))
|
||||
|
||||
barcode_cols = [row.column_name for row in result]
|
||||
|
||||
if not barcode_cols:
|
||||
print('apc 테이블에 barcode 관련 컬럼이 없습니다.')
|
||||
print('다른 컬럼으로 검색해야 합니다.')
|
||||
else:
|
||||
print(f'바코드 컬럼 발견: {barcode_cols}')
|
||||
# TODO: 바코드로 검색 구현
|
||||
|
||||
conn.close()
|
||||
|
||||
def cmd_sample():
|
||||
"""샘플 데이터 (동물용 제품)"""
|
||||
conn = get_connection()
|
||||
result = conn.execute(text("""
|
||||
SELECT idx, apc, product_name, company_name,
|
||||
image_url1, godoimage_url_f, for_pets
|
||||
FROM apc
|
||||
WHERE for_pets = true
|
||||
LIMIT 10
|
||||
"""))
|
||||
|
||||
print('=== 동물용 제품 샘플 ===')
|
||||
count = 0
|
||||
for row in result:
|
||||
count += 1
|
||||
print(f'\n[{count}] {row.product_name}')
|
||||
print(f' APC: {row.apc}')
|
||||
print(f' 제조사: {row.company_name}')
|
||||
img = row.image_url1 or row.godoimage_url_f or '(없음)'
|
||||
if len(img) > 50:
|
||||
img = img[:50] + '...'
|
||||
print(f' 이미지: {img}')
|
||||
|
||||
if count == 0:
|
||||
print('(동물용 제품 없음 - for_pets 필터 확인 필요)')
|
||||
|
||||
conn.close()
|
||||
|
||||
def cmd_stats():
|
||||
"""통계"""
|
||||
conn = get_connection()
|
||||
|
||||
result = conn.execute(text("""
|
||||
SELECT
|
||||
COUNT(*) as total,
|
||||
SUM(CASE WHEN for_pets = true THEN 1 ELSE 0 END) as pet_count,
|
||||
SUM(CASE WHEN image_url1 IS NOT NULL AND image_url1 != '' THEN 1 ELSE 0 END) as has_img1,
|
||||
SUM(CASE WHEN godoimage_url_f IS NOT NULL AND godoimage_url_f != '' THEN 1 ELSE 0 END) as has_godo_f
|
||||
FROM apc
|
||||
"""))
|
||||
|
||||
row = result.fetchone()
|
||||
print('=== apc 테이블 통계 ===')
|
||||
print(f'전체 제품: {row.total:,}개')
|
||||
print(f'동물용(for_pets=true): {row.pet_count:,}개')
|
||||
print(f'image_url1 있음: {row.has_img1:,}개')
|
||||
print(f'godoimage_url_f 있음: {row.has_godo_f:,}개')
|
||||
|
||||
conn.close()
|
||||
|
||||
def main():
|
||||
if len(sys.argv) < 2:
|
||||
print(__doc__)
|
||||
return
|
||||
|
||||
cmd = sys.argv[1]
|
||||
|
||||
if cmd == 'schema':
|
||||
cmd_schema()
|
||||
elif cmd == 'search' and len(sys.argv) > 2:
|
||||
cmd_search(sys.argv[2])
|
||||
elif cmd == 'barcode' and len(sys.argv) > 2:
|
||||
cmd_barcode(sys.argv[2])
|
||||
elif cmd == 'sample':
|
||||
cmd_sample()
|
||||
elif cmd == 'stats':
|
||||
cmd_stats()
|
||||
else:
|
||||
print(__doc__)
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
27
backend/scripts/show_llm_pharm.py
Normal file
@@ -0,0 +1,27 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
import sys, io, json
|
||||
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8')
|
||||
from sqlalchemy import create_engine, text
|
||||
|
||||
pg = create_engine('postgresql://admin:trajet6640@192.168.0.87:5432/apdb_master').connect()
|
||||
|
||||
# 안텔민킹 llm_pharm 전체 확인
|
||||
result = pg.execute(text("""
|
||||
SELECT product_name, llm_pharm FROM apc WHERE apc = '0230237810109'
|
||||
"""))
|
||||
row = result.fetchone()
|
||||
|
||||
print('=== 안텔민킹 llm_pharm 전체 키 ===\n')
|
||||
data = row.llm_pharm
|
||||
for k in sorted(data.keys()):
|
||||
val = str(data[k])
|
||||
if len(val) > 60:
|
||||
val = val[:60] + '...'
|
||||
print(f' {k}: {val}')
|
||||
|
||||
# 동물약 전체 개수
|
||||
print('\n=== PostgreSQL 동물약 전체 개수 ===')
|
||||
result2 = pg.execute(text("SELECT COUNT(*) FROM apc"))
|
||||
print(f' 전체: {result2.fetchone()[0]}개')
|
||||
|
||||
pg.close()
|
||||
16
backend/scripts/test_gestage_api.py
Normal file
@@ -0,0 +1,16 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
import sys, io
|
||||
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8')
|
||||
sys.path.insert(0, 'c:\\Users\\청춘약국\\source\\pharmacy-pos-qr-system\\backend')
|
||||
|
||||
from app import _get_animal_drugs
|
||||
|
||||
drugs = _get_animal_drugs()
|
||||
gestage = [d for d in drugs if '제스타제' in d['name']]
|
||||
|
||||
print('=== 제스타제 API 결과 ===\n')
|
||||
for d in gestage:
|
||||
print(f"name: {d['name']}")
|
||||
print(f"barcode: {d['barcode']}")
|
||||
print(f"apc: {d['apc']}")
|
||||
print(f"image_url: {d['image_url']}")
|
||||
23
backend/scripts/test_pets_migration.py
Normal file
@@ -0,0 +1,23 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""pets 테이블 마이그레이션 테스트"""
|
||||
import sys, io
|
||||
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8')
|
||||
sys.path.insert(0, 'c:\\Users\\청춘약국\\source\\pharmacy-pos-qr-system\\backend')
|
||||
|
||||
from db.dbsetup import db_manager
|
||||
|
||||
# SQLite 연결 (마이그레이션 자동 실행)
|
||||
conn = db_manager.get_sqlite_connection()
|
||||
cursor = conn.cursor()
|
||||
|
||||
# pets 테이블 확인
|
||||
cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='pets'")
|
||||
if cursor.fetchone():
|
||||
print('✅ pets 테이블 생성 완료')
|
||||
cursor.execute('PRAGMA table_info(pets)')
|
||||
columns = cursor.fetchall()
|
||||
print('\n컬럼 목록:')
|
||||
for col in columns:
|
||||
print(f' - {col[1]} ({col[2]})')
|
||||
else:
|
||||
print('❌ pets 테이블 없음')
|
||||
43
backend/scripts/update_gesidin_category.py
Normal file
@@ -0,0 +1,43 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
import sys, io
|
||||
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8')
|
||||
sys.path.insert(0, 'c:\\Users\\청춘약국\\source\\pharmacy-pos-qr-system\\backend')
|
||||
|
||||
from db.dbsetup import get_db_session
|
||||
from sqlalchemy import text
|
||||
|
||||
session = get_db_session('PM_DRUG')
|
||||
|
||||
print('1. 현재 상태 확인...')
|
||||
result = session.execute(text("""
|
||||
SELECT DrugCode, GoodsName, POS_BOON
|
||||
FROM CD_GOODS
|
||||
WHERE DrugCode = 'LB000003140'
|
||||
"""))
|
||||
row = result.fetchone()
|
||||
print(f' {row.GoodsName}: POS_BOON = {row.POS_BOON}')
|
||||
|
||||
print('\n2. POS_BOON을 동물약(010103)으로 업데이트...')
|
||||
try:
|
||||
session.execute(text("""
|
||||
UPDATE CD_GOODS
|
||||
SET POS_BOON = '010103'
|
||||
WHERE DrugCode = 'LB000003140'
|
||||
"""))
|
||||
session.commit()
|
||||
print(' ✅ 성공!')
|
||||
|
||||
# 확인
|
||||
result2 = session.execute(text("""
|
||||
SELECT DrugCode, GoodsName, POS_BOON
|
||||
FROM CD_GOODS
|
||||
WHERE DrugCode = 'LB000003140'
|
||||
"""))
|
||||
row2 = result2.fetchone()
|
||||
print(f' {row2.GoodsName}: POS_BOON = {row2.POS_BOON}')
|
||||
|
||||
except Exception as e:
|
||||
session.rollback()
|
||||
print(f' ❌ 실패: {e}')
|
||||
|
||||
session.close()
|
||||
@@ -98,6 +98,89 @@ class KakaoAPIClient:
|
||||
'error_description': f'Invalid JSON response: {e}'
|
||||
}
|
||||
|
||||
def refresh_access_token(self, refresh_token: str) -> Tuple[bool, Dict[str, Any]]:
|
||||
"""Refresh Token으로 Access Token 갱신"""
|
||||
url = f"{self.auth_base_url}/oauth/token"
|
||||
|
||||
data = {
|
||||
'grant_type': 'refresh_token',
|
||||
'client_id': self.client_id,
|
||||
'refresh_token': refresh_token,
|
||||
}
|
||||
if self.client_secret:
|
||||
data['client_secret'] = self.client_secret
|
||||
|
||||
try:
|
||||
headers = {'Content-Type': 'application/x-www-form-urlencoded'}
|
||||
response = self.session.post(url, data=data, headers=headers)
|
||||
|
||||
logger.info(f"카카오 토큰 갱신 응답 상태: {response.status_code}")
|
||||
response.raise_for_status()
|
||||
|
||||
token_data = response.json()
|
||||
if 'expires_in' in token_data:
|
||||
expires_at = datetime.now() + timedelta(seconds=token_data['expires_in'])
|
||||
token_data['expires_at'] = expires_at.isoformat()
|
||||
|
||||
return True, token_data
|
||||
|
||||
except requests.exceptions.RequestException as e:
|
||||
logger.error(f"카카오 토큰 갱신 실패: {e}")
|
||||
error_details = {
|
||||
'error': 'token_refresh_failed',
|
||||
'error_description': f'Failed to refresh access token: {e}'
|
||||
}
|
||||
try:
|
||||
if hasattr(e, 'response') and e.response is not None:
|
||||
kakao_error = e.response.json()
|
||||
logger.error(f"카카오 API 오류: {kakao_error}")
|
||||
error_details.update(kakao_error)
|
||||
except Exception:
|
||||
pass
|
||||
return False, error_details
|
||||
|
||||
def get_user_info_with_refresh(
|
||||
self,
|
||||
access_token: str,
|
||||
refresh_token: str,
|
||||
token_expires_at: str = None
|
||||
) -> Tuple[bool, Dict[str, Any], Dict[str, Any]]:
|
||||
"""저장된 토큰으로 사용자 정보 조회 (만료 시 자동 갱신)
|
||||
|
||||
Returns:
|
||||
(성공여부, 사용자정보/에러, 갱신된 토큰 데이터 또는 빈 dict)
|
||||
"""
|
||||
new_token_data = {}
|
||||
|
||||
# 만료 확인: 5분 이내면 미리 갱신
|
||||
if token_expires_at:
|
||||
try:
|
||||
expires = datetime.fromisoformat(token_expires_at)
|
||||
if datetime.now() >= expires - timedelta(minutes=5):
|
||||
logger.info("Access token 만료 임박, 갱신 시도")
|
||||
success, refreshed = self.refresh_access_token(refresh_token)
|
||||
if success:
|
||||
access_token = refreshed['access_token']
|
||||
new_token_data = refreshed
|
||||
else:
|
||||
return False, refreshed, {}
|
||||
except (ValueError, TypeError) as e:
|
||||
logger.warning(f"token_expires_at 파싱 실패, 기존 토큰으로 시도: {e}")
|
||||
|
||||
# 사용자 정보 조회
|
||||
success, user_info = self.get_user_info(access_token)
|
||||
|
||||
if not success and refresh_token:
|
||||
# 실패 시 갱신 후 재시도
|
||||
logger.info("사용자 정보 조회 실패, 토큰 갱신 후 재시도")
|
||||
refresh_ok, refreshed = self.refresh_access_token(refresh_token)
|
||||
if refresh_ok:
|
||||
access_token = refreshed['access_token']
|
||||
new_token_data = refreshed
|
||||
success, user_info = self.get_user_info(access_token)
|
||||
|
||||
return success, user_info, new_token_data
|
||||
|
||||
def get_user_info(self, access_token: str) -> Tuple[bool, Dict[str, Any]]:
|
||||
"""Access Token으로 사용자 정보 조회"""
|
||||
url = f"{self.api_base_url}/v2/user/me"
|
||||
|
||||
@@ -87,7 +87,16 @@ def _send_alimtalk(template_code, recipient_no, template_params):
|
||||
logger.info(f"알림톡 발송 성공: {template_code} → {recipient_no}")
|
||||
return (True, "발송 성공")
|
||||
else:
|
||||
error_msg = result.get('header', {}).get('resultMessage', str(result))
|
||||
# 상세 에러 추출: sendResults[0].resultMessage 우선, 없으면 header.resultMessage
|
||||
header_msg = result.get('header', {}).get('resultMessage', '')
|
||||
send_results = result.get('message', {}).get('sendResults', [])
|
||||
detail_msg = send_results[0].get('resultMessage', '') if send_results else ''
|
||||
|
||||
# 상세 에러가 있으면 그걸 사용, 없으면 header 에러
|
||||
error_msg = detail_msg if detail_msg and detail_msg != 'SUCCESS' else header_msg
|
||||
if not error_msg:
|
||||
error_msg = str(result)
|
||||
|
||||
logger.warning(f"알림톡 발송 실패: {template_code} → {recipient_no}: {error_msg}")
|
||||
return (False, error_msg)
|
||||
|
||||
@@ -100,15 +109,25 @@ def _send_alimtalk(template_code, recipient_no, template_params):
|
||||
|
||||
|
||||
def build_item_summary(items):
|
||||
"""구매 품목 요약 문자열 생성 (예: '타이레놀 외 3건')"""
|
||||
"""구매 품목 요약 문자열 생성 (예: '타이레놀 외 3건')
|
||||
|
||||
Note: 카카오 알림톡 템플릿 변수는 14자 제한
|
||||
(에러: "Blacklist can't use more than 14 characters in template value.")
|
||||
특수문자(%, 괄호 등)는 문제없이 발송 가능!
|
||||
"""
|
||||
if not items:
|
||||
return "약국 구매"
|
||||
first = items[0]['name']
|
||||
if len(first) > 20:
|
||||
first = first[:18] + '..'
|
||||
first = first.strip()
|
||||
|
||||
if len(items) == 1:
|
||||
return first
|
||||
return f"{first} 외 {len(items) - 1}건"
|
||||
# 단일 품목: 14자 제한 (그냥 자름)
|
||||
return first[:14]
|
||||
|
||||
# 복수 품목: "외 N건" 붙으므로 전체 14자 맞춤
|
||||
suffix = f" 외 {len(items) - 1}건"
|
||||
max_first = 14 - len(suffix)
|
||||
return f"{first[:max_first]}{suffix}"
|
||||
|
||||
|
||||
def send_mileage_claim_alimtalk(phone, name, points, balance, items=None,
|
||||
@@ -146,24 +165,7 @@ def send_mileage_claim_alimtalk(phone, name, points, balance, items=None,
|
||||
|
||||
success, msg = _send_alimtalk(template_code, phone, params)
|
||||
|
||||
if not success:
|
||||
# V3 실패 로그
|
||||
_log_to_db(template_code, phone, False, msg,
|
||||
template_params=params, user_id=user_id,
|
||||
trigger_source=trigger_source, transaction_id=transaction_id)
|
||||
|
||||
# V2 폴백
|
||||
template_code = 'MILEAGE_CLAIM_V2'
|
||||
params = {
|
||||
'고객명': name,
|
||||
'적립포인트': f'{points:,}',
|
||||
'총잔액': f'{balance:,}',
|
||||
'적립일시': now_kst,
|
||||
'전화번호': phone
|
||||
}
|
||||
success, msg = _send_alimtalk(template_code, phone, params)
|
||||
|
||||
# 최종 결과 로그
|
||||
# 결과 로그 (V3만 사용, V2 폴백 제거 - V2 반려 상태)
|
||||
_log_to_db(template_code, phone, success, msg,
|
||||
template_params=params, user_id=user_id,
|
||||
trigger_source=trigger_source, transaction_id=transaction_id)
|
||||
|
||||
672
backend/sooin_api.py
Normal file
@@ -0,0 +1,672 @@
|
||||
# -*- 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('/monthly-sales', methods=['GET'])
|
||||
def api_sooin_monthly_sales():
|
||||
"""
|
||||
수인약품 월간 매출 조회 API
|
||||
|
||||
GET /api/sooin/monthly-sales?year=2026&month=3
|
||||
|
||||
Returns:
|
||||
{
|
||||
"success": true,
|
||||
"total_amount": 3700239, // 월간 매출 합계
|
||||
"total_paid": 0, // 월간 입금 합계
|
||||
"ending_balance": 14293001, // 월말 잔액
|
||||
"opening_balance": 10592762, // 전일(기초) 잔액
|
||||
"from_date": "2026-03-01",
|
||||
"to_date": "2026-03-31"
|
||||
}
|
||||
"""
|
||||
from datetime import datetime
|
||||
|
||||
year = flask_request.args.get('year', type=int)
|
||||
month = flask_request.args.get('month', type=int)
|
||||
|
||||
# 기본값: 현재 월
|
||||
if not year or not month:
|
||||
now = datetime.now()
|
||||
year = year or now.year
|
||||
month = month or now.month
|
||||
|
||||
try:
|
||||
session = get_sooin_session()
|
||||
if hasattr(session, 'get_monthly_sales'):
|
||||
result = session.get_monthly_sales(year, month)
|
||||
return jsonify(result)
|
||||
else:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': 'NOT_IMPLEMENTED',
|
||||
'message': '수인약품 월간 매출 조회 미구현'
|
||||
}), 501
|
||||
except Exception as e:
|
||||
logger.error(f"수인약품 월간 매출 조회 오류: {e}")
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': 'MONTHLY_SALES_ERROR',
|
||||
'message': str(e)
|
||||
}), 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
|
||||
})
|
||||
|
||||
|
||||
# ========== 주문 조회 API ==========
|
||||
|
||||
@sooin_bp.route('/orders', methods=['GET'])
|
||||
def api_sooin_orders():
|
||||
"""
|
||||
수인약품 주문 목록 조회 API
|
||||
|
||||
GET /api/sooin/orders?start_date=2026-03-01&end_date=2026-03-07
|
||||
|
||||
파라미터:
|
||||
start_date: 시작일 (YYYY-MM-DD), 기본값: 오늘
|
||||
end_date: 종료일 (YYYY-MM-DD), 기본값: 오늘
|
||||
|
||||
Returns:
|
||||
{
|
||||
"success": true,
|
||||
"orders": [
|
||||
{
|
||||
"order_num": "202603095091177",
|
||||
"order_date": "2026-03-09",
|
||||
"order_time": "14:30:25",
|
||||
"total_amount": 125000,
|
||||
"item_count": 5,
|
||||
"status": "완료"
|
||||
}
|
||||
],
|
||||
"total_count": 10
|
||||
}
|
||||
"""
|
||||
from datetime import datetime
|
||||
|
||||
today = datetime.now().strftime("%Y-%m-%d")
|
||||
start_date = flask_request.args.get('start_date', today).strip()
|
||||
end_date = flask_request.args.get('end_date', today).strip()
|
||||
|
||||
try:
|
||||
session = get_sooin_session()
|
||||
result = session.get_order_list(start_date, end_date)
|
||||
return jsonify(result)
|
||||
except Exception as e:
|
||||
logger.error(f"수인약품 주문 목록 조회 오류: {e}")
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': 'ORDERS_ERROR',
|
||||
'message': str(e),
|
||||
'orders': [],
|
||||
'total_count': 0
|
||||
}), 500
|
||||
|
||||
|
||||
@sooin_bp.route('/orders/today-summary', methods=['GET'])
|
||||
def api_sooin_today_summary():
|
||||
"""
|
||||
수인약품 오늘 주문 집계 API
|
||||
|
||||
GET /api/sooin/orders/today-summary
|
||||
|
||||
Returns:
|
||||
{
|
||||
"success": true,
|
||||
"date": "2026-03-09",
|
||||
"summary": [
|
||||
{
|
||||
"kd_code": "073100220",
|
||||
"product_name": "코자정50mg",
|
||||
"total_quantity": 10,
|
||||
"total_amount": 150000,
|
||||
"order_count": 3
|
||||
}
|
||||
],
|
||||
"grand_total_amount": 500000,
|
||||
"grand_total_items": 25,
|
||||
"order_count": 5
|
||||
}
|
||||
"""
|
||||
try:
|
||||
session = get_sooin_session()
|
||||
result = session.get_today_order_summary()
|
||||
return jsonify(result)
|
||||
except Exception as e:
|
||||
logger.error(f"수인약품 오늘 주문 집계 오류: {e}")
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': 'TODAY_SUMMARY_ERROR',
|
||||
'message': str(e),
|
||||
'summary': [],
|
||||
'grand_total_amount': 0,
|
||||
'grand_total_items': 0,
|
||||
'order_count': 0
|
||||
}), 500
|
||||
|
||||
|
||||
@sooin_bp.route('/orders/<order_num>', methods=['GET'])
|
||||
def api_sooin_order_detail(order_num):
|
||||
"""
|
||||
수인약품 주문 상세 조회 API
|
||||
|
||||
GET /api/sooin/orders/202603095091177
|
||||
|
||||
Returns:
|
||||
{
|
||||
"success": true,
|
||||
"order_num": "202603095091177",
|
||||
"order_date": "2026-03-09",
|
||||
"items": [
|
||||
{
|
||||
"product_code": "32495",
|
||||
"kd_code": "073100220",
|
||||
"product_name": "코자정50mg",
|
||||
"spec": "30T",
|
||||
"quantity": 2,
|
||||
"unit_price": 15000,
|
||||
"amount": 30000
|
||||
}
|
||||
],
|
||||
"total_amount": 125000,
|
||||
"item_count": 5
|
||||
}
|
||||
"""
|
||||
if not order_num or not order_num.isdigit():
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': 'INVALID_ORDER_NUM',
|
||||
'message': '유효한 주문번호를 입력하세요'
|
||||
}), 400
|
||||
|
||||
try:
|
||||
session = get_sooin_session()
|
||||
result = session.get_order_detail(order_num)
|
||||
return jsonify(result)
|
||||
except Exception as e:
|
||||
logger.error(f"수인약품 주문 상세 조회 오류: {e}")
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': 'ORDER_DETAIL_ERROR',
|
||||
'message': str(e),
|
||||
'order_num': order_num,
|
||||
'items': [],
|
||||
'total_amount': 0
|
||||
}), 500
|
||||
|
||||
|
||||
@sooin_bp.route('/orders/summary-by-kd', methods=['GET'])
|
||||
def api_sooin_orders_by_kd():
|
||||
"""
|
||||
수인약품 주문량 KD코드별 집계 API (병렬 처리)
|
||||
|
||||
GET /api/sooin/orders/summary-by-kd?start_date=2026-03-01&end_date=2026-03-07
|
||||
"""
|
||||
import re
|
||||
from datetime import datetime
|
||||
|
||||
today = datetime.now().strftime("%Y-%m-%d")
|
||||
start_date = flask_request.args.get('start_date', today).strip()
|
||||
end_date = flask_request.args.get('end_date', today).strip()
|
||||
|
||||
def parse_spec(spec: str) -> int:
|
||||
if not spec:
|
||||
return 1
|
||||
match = re.search(r'(\d+)', spec)
|
||||
return int(match.group(1)) if match else 1
|
||||
|
||||
try:
|
||||
session = get_sooin_session()
|
||||
|
||||
# 주문 목록 조회
|
||||
orders_result = session.get_order_list(start_date, end_date)
|
||||
|
||||
if not orders_result.get('success'):
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': orders_result.get('error', 'ORDERS_FETCH_FAILED'),
|
||||
'by_kd_code': {}
|
||||
})
|
||||
|
||||
orders = orders_result.get('orders', [])
|
||||
order_nums = [o.get('order_num') for o in orders if o.get('order_num')]
|
||||
|
||||
# 순차 처리 + 캐시 (캐시 효과 극대화)
|
||||
all_details = []
|
||||
|
||||
for order_num in order_nums:
|
||||
try:
|
||||
detail = session.get_order_detail(order_num)
|
||||
if detail.get('success'):
|
||||
all_details.append(detail)
|
||||
except Exception as e:
|
||||
logger.warning(f"주문 상세 조회 실패: {e}")
|
||||
|
||||
# KD코드별 집계
|
||||
kd_summary = {}
|
||||
|
||||
for detail in all_details:
|
||||
for item in detail.get('items', []):
|
||||
kd_code = item.get('kd_code', '')
|
||||
if not kd_code:
|
||||
continue
|
||||
|
||||
product_name = item.get('product_name', '')
|
||||
spec = item.get('spec', '')
|
||||
quantity = item.get('quantity', 0)
|
||||
per_unit = parse_spec(spec)
|
||||
total_units = quantity * per_unit
|
||||
|
||||
if kd_code not in kd_summary:
|
||||
kd_summary[kd_code] = {
|
||||
'product_name': product_name,
|
||||
'spec': spec,
|
||||
'boxes': 0,
|
||||
'units': 0
|
||||
}
|
||||
|
||||
kd_summary[kd_code]['boxes'] += quantity
|
||||
kd_summary[kd_code]['units'] += total_units
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'order_count': len(order_nums),
|
||||
'period': {'start': start_date, 'end': end_date},
|
||||
'by_kd_code': kd_summary,
|
||||
'total_products': len(kd_summary)
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"수인약품 KD코드별 집계 오류: {e}")
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': 'SUMMARY_ERROR',
|
||||
'message': str(e),
|
||||
'by_kd_code': {}
|
||||
}), 500
|
||||
1072
backend/static/docs/AI_ERP.html
Normal file
BIN
backend/static/img/logo_baekje.ico
Normal file
|
After Width: | Height: | Size: 1.1 KiB |
16
backend/static/img/logo_baekje.svg
Normal file
@@ -0,0 +1,16 @@
|
||||
<svg id="logo_foot" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="64.36" height="32" viewBox="0 0 64.36 32">
|
||||
<defs>
|
||||
<clipPath id="clip-path">
|
||||
<rect id="Rectangle_4823" data-name="Rectangle 4823" width="64.36" height="32" fill="#999"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
<g id="Group_14242" data-name="Group 14242" clip-path="url(#clip-path)">
|
||||
<path id="Path_14020" data-name="Path 14020" d="M29.966,33.717c.348,0,.524.154.524.463v2.732h1.539V34.207a.437.437,0,0,1,.5-.49.449.449,0,0,1,.511.5v5.058a.566.566,0,0,1-.169.413.5.5,0,0,1-.676.017.634.634,0,0,1-.166-.43V37.842H30.49v1.437a.566.566,0,0,1-.169.413.474.474,0,0,1-.355.141.464.464,0,0,1-.335-.124.631.631,0,0,1-.162-.43v-5.1q0-.463.5-.463m13.23,0a.465.465,0,0,1,.345.114.4.4,0,0,1,.169.349V44.189a.476.476,0,0,1-.149.369.511.511,0,0,1-.375.161.484.484,0,0,1-.361-.141.532.532,0,0,1-.149-.389V39.1H41.088a.477.477,0,0,1-.375-.144.412.412,0,0,1-.149-.322.419.419,0,0,1,.146-.329.492.492,0,0,1,.365-.138h1.6V34.207a.434.434,0,0,1,.149-.362.533.533,0,0,1,.371-.128m2.384,0a.5.5,0,0,1,.342.128.4.4,0,0,1,.169.349v10a.467.467,0,0,1-.153.369.481.481,0,0,1-.358.161.469.469,0,0,1-.348-.141.535.535,0,0,1-.153-.389V34.207a.476.476,0,0,1,.159-.369.5.5,0,0,1,.342-.121m-21.463.235a.5.5,0,0,1,.348.124.527.527,0,0,1,.176.4V35.64h2.484v-1.2a.446.446,0,0,1,.169-.376.475.475,0,0,1,.332-.114.53.53,0,0,1,.355.128.416.416,0,0,1,.169.362v4.152a.975.975,0,0,1-.421.806,1.489,1.489,0,0,1-.942.289H24.83a1.219,1.219,0,0,1-.849-.279,1,1,0,0,1-.361-.816v-4.1a.545.545,0,0,1,.149-.4.48.48,0,0,1,.348-.141m13.041.158h4.214c.3,0,.444.131.444.4-.013.346-.159.52-.444.52H39.765v2.115a9.559,9.559,0,0,0,.623,3.581,7.858,7.858,0,0,0,1.641,2.2.452.452,0,0,1,.179.393.5.5,0,0,1-.172.332.616.616,0,0,1-.351.161.534.534,0,0,1-.4-.151,7.155,7.155,0,0,1-1.316-1.792,5.441,5.441,0,0,1-.713-2.256,5.5,5.5,0,0,1-.726,2.259A6.6,6.6,0,0,1,37.1,43.686a.658.658,0,0,1-.4.138.477.477,0,0,1-.315-.158.47.47,0,0,1-.119-.356.572.572,0,0,1,.225-.386,6.677,6.677,0,0,0,1.476-1.99,9.345,9.345,0,0,0,.773-3.779V35.026H37.145c-.325,0-.481-.158-.471-.47s.159-.446.484-.446M24.641,36.57v1.906a.324.324,0,0,0,.073.248.456.456,0,0,0,.305.074h1.628A.592.592,0,0,0,27,38.718a.286.286,0,0,0,.126-.242V36.57Zm-.2,4.323h7.255a1.425,1.425,0,0,1,.965.316,1.112,1.112,0,0,1,.381.869V44.2a.517.517,0,0,1-.176.393.477.477,0,0,1-.335.124.489.489,0,0,1-.342-.121.54.54,0,0,1-.159-.4V42.118a.305.305,0,0,0-.06-.218.494.494,0,0,0-.3-.077H24.439q-.507,0-.507-.463c0-.312.169-.467.507-.467" transform="translate(-15.788 -22.399)" fill="#999"/>
|
||||
<path id="Path_14021" data-name="Path 14021" d="M45.059,44.463a.73.73,0,0,1-.531-.217.8.8,0,0,1-.228-.575V33.689a.735.735,0,0,1,.244-.563.786.786,0,0,1,1.026,0,.647.647,0,0,1,.258.546v10a.731.731,0,0,1-.241.567.723.723,0,0,1-.528.225m0-11a.246.246,0,0,0-.173.058.211.211,0,0,0-.069.171v9.982a.273.273,0,0,0,.077.2.215.215,0,0,0,.165.064.224.224,0,0,0,.172-.082.218.218,0,0,0,.08-.187v-10a.138.138,0,0,0-.06-.135.268.268,0,0,0-.192-.08m-2.394,11a.737.737,0,0,1-.544-.217.8.8,0,0,1-.225-.575v-.642a.8.8,0,0,1-.208.3.876.876,0,0,1-.5.227.8.8,0,0,1-.6-.216,7.454,7.454,0,0,1-1.371-1.862,6.958,6.958,0,0,1-.489-1.107,6.8,6.8,0,0,1-.5,1.113,6.894,6.894,0,0,1-1.484,1.887.925.925,0,0,1-.566.2.743.743,0,0,1-.5-.242.728.728,0,0,1-.188-.546.835.835,0,0,1,.318-.575,6.445,6.445,0,0,0,1.41-1.906,9.118,9.118,0,0,0,.745-3.662V34.77H36.624a.732.732,0,0,1-.552-.2.715.715,0,0,1-.178-.538.665.665,0,0,1,.743-.7h4.214a.626.626,0,0,1,.7.658c-.027.707-.5.782-.7.782H39.5v1.853a9.335,9.335,0,0,0,.6,3.483,7.625,7.625,0,0,0,1.574,2.106.731.731,0,0,1,.215.288V38.844h-1.33A.722.722,0,0,1,40,38.613a.649.649,0,0,1-.215-.5.68.68,0,0,1,.236-.527.735.735,0,0,1,.534-.2H41.9v-3.7a.681.681,0,0,1,.251-.57.765.765,0,0,1,.519-.182.724.724,0,0,1,.52.175.65.65,0,0,1,.262.55V43.671a.738.738,0,0,1-.235.564.758.758,0,0,1-.547.228m-2.112-6.552a.229.229,0,0,0-.182.061.172.172,0,0,0-.07.144.149.149,0,0,0,.06.125.263.263,0,0,0,.205.08h1.588a.26.26,0,0,1,.259.262v5.088a.267.267,0,0,0,.074.2.226.226,0,0,0,.178.064.257.257,0,0,0,.192-.085.218.218,0,0,0,.074-.184V33.662a.141.141,0,0,0-.059-.135.23.23,0,0,0-.176-.067.363.363,0,0,0-.218.061.2.2,0,0,0-.063.167V37.65a.26.26,0,0,1-.259.262Zm-3.916-4.058c-.225,0-.225.074-.225.185a.251.251,0,0,0,.032.165.279.279,0,0,0,.18.043h1.595a.26.26,0,0,1,.259.262v2.128a9.648,9.648,0,0,1-.795,3.885A6.98,6.98,0,0,1,36.144,42.6a.324.324,0,0,0-.142.217.194.194,0,0,0,.052.153.22.22,0,0,0,.152.076.412.412,0,0,0,.219-.089,6.378,6.378,0,0,0,1.353-1.733,5.267,5.267,0,0,0,.694-2.149.26.26,0,0,1,.258-.243h0a.26.26,0,0,1,.256.246,5.206,5.206,0,0,0,.681,2.145,6.934,6.934,0,0,0,1.268,1.728.269.269,0,0,0,.209.08.357.357,0,0,0,.2-.1.226.226,0,0,0,.085-.156.188.188,0,0,0-.085-.17,8.153,8.153,0,0,1-1.706-2.282,9.855,9.855,0,0,1-.654-3.7V34.508a.26.26,0,0,1,.259-.262h1.608c.086,0,.176,0,.186-.269,0-.077,0-.124-.186-.124Zm-4.629,10.61a.744.744,0,0,1-.513-.187.808.808,0,0,1-.246-.592V41.6c0-.009,0-.017,0-.025a.477.477,0,0,0-.1-.009H23.918a.728.728,0,1,1,0-1.453h7.255a1.679,1.679,0,0,1,1.133.378,1.374,1.374,0,0,1,.472,1.068v2.125a.783.783,0,0,1-.26.586.737.737,0,0,1-.51.193m-8.091-3.826c-.237,0-.249.078-.249.2s.012.2.249.2h7.228a.738.738,0,0,1,.457.13.185.185,0,0,1,.027.024.545.545,0,0,1,.136.4v2.084a.279.279,0,0,0,.084.211.235.235,0,0,0,.159.044.223.223,0,0,0,.163-.058.252.252,0,0,0,.089-.2V41.56a.855.855,0,0,0-.294-.673,1.169,1.169,0,0,0-.794-.25Zm8.091-1.061a.72.72,0,0,1-.514-.2.914.914,0,0,1-.245-.619V37.586H30.228v1.175a.835.835,0,0,1-.245.6.729.729,0,0,1-.538.217.72.72,0,0,1-.514-.2.9.9,0,0,1-.242-.619v-5.1a.681.681,0,0,1,.756-.725.694.694,0,0,1,.783.725v2.47h1.021V33.689a.694.694,0,0,1,.759-.752.7.7,0,0,1,.769.765v5.058a.835.835,0,0,1-.245.6.713.713,0,0,1-.525.217m-.142-.56a.226.226,0,0,0,.142.037.2.2,0,0,0,.159-.065.3.3,0,0,0,.093-.228V33.7c0-.171-.035-.242-.252-.242s-.242.057-.242.228v2.705a.26.26,0,0,1-.259.262H29.969a.26.26,0,0,1-.259-.262V33.662c0-.108,0-.2-.265-.2-.227,0-.239.077-.239.2v5.1a.367.367,0,0,0,.1.255.226.226,0,0,0,.142.037.217.217,0,0,0,.172-.065.3.3,0,0,0,.093-.228V37.324a.26.26,0,0,1,.259-.262h1.539a.26.26,0,0,1,.259.262v1.437A.374.374,0,0,0,31.866,39.016Zm-5.6.416H24.309a1.473,1.473,0,0,1-1.021-.345,1.255,1.255,0,0,1-.448-1.011v-4.1a.811.811,0,0,1,.225-.584.784.784,0,0,1,1.051-.027.794.794,0,0,1,.262.6v.9h1.966v-.936a.705.705,0,0,1,.271-.584.715.715,0,0,1,.489-.168.786.786,0,0,1,.52.188.662.662,0,0,1,.262.564v4.152a1.243,1.243,0,0,1-.525,1.015,1.74,1.74,0,0,1-1.1.341M23.6,33.7a.225.225,0,0,0-.168.068.277.277,0,0,0-.07.211v4.1a.74.74,0,0,0,.267.614.975.975,0,0,0,.685.219h1.956a1.244,1.244,0,0,0,.784-.234.72.72,0,0,0,.32-.6V33.924c0-.106-.039-.134-.06-.149a.3.3,0,0,0-.206-.08.216.216,0,0,0-.16.049.193.193,0,0,0-.082.18v1.2a.26.26,0,0,1-.259.262H24.12a.26.26,0,0,1-.259-.262V33.961a.265.265,0,0,0-.092-.209A.242.242,0,0,0,23.6,33.7m2.53,4.847H24.5a.668.668,0,0,1-.482-.144.563.563,0,0,1-.155-.44V36.052a.26.26,0,0,1,.259-.262H26.6a.26.26,0,0,1,.259.262v1.906a.549.549,0,0,1-.234.454.811.811,0,0,1-.5.13m-1.745-.531a.733.733,0,0,0,.117.008h1.628a.423.423,0,0,0,.2-.028c.023-.016.023-.02.023-.032V36.314H24.379v1.645a.5.5,0,0,0,0,.053" transform="translate(-15.267 -21.881)" fill="#999"/>
|
||||
<path id="Path_14022" data-name="Path 14022" d="M110.236,33.72c.348,0,.524.158.524.473v1.413h1.283a.4.4,0,0,1,.451.45c0,.312-.149.467-.451.467H110.76v1.494h1.283a.4.4,0,0,1,.451.45c0,.312-.149.467-.451.467H110.76v.4a.459.459,0,0,1-.166.373.487.487,0,0,1-.358.141.5.5,0,0,1-.361-.151.476.476,0,0,1-.136-.366V34.2c0-.319.166-.477.5-.477m-5.279.222a2.7,2.7,0,0,1,2.135.809,2.62,2.62,0,0,1,.885,1.98,2.545,2.545,0,0,1-.892,1.943,2.665,2.665,0,0,1-2.129.809,2.99,2.99,0,0,1-2.324-.977,2.249,2.249,0,0,1-.723-1.776,2.675,2.675,0,0,1,.759-1.94,3.09,3.09,0,0,1,2.288-.849m0,.93a2.038,2.038,0,0,0-1.535.564,1.978,1.978,0,0,0,0,2.581,2.006,2.006,0,0,0,1.532.574,2.123,2.123,0,0,0,1.479-.463,2.1,2.1,0,0,0,.534-1.4,1.848,1.848,0,0,0-.4-1.255,2.258,2.258,0,0,0-1.611-.6m-2.122,5.79h6.529a1.553,1.553,0,0,1,1.081.379,1.145,1.145,0,0,1,.315.879v2.286a.517.517,0,0,1-.176.393.488.488,0,0,1-.348.124.5.5,0,0,1-.342-.121.551.551,0,0,1-.156-.4V41.923a.316.316,0,0,0-.073-.248.438.438,0,0,0-.305-.084h-6.526a.411.411,0,0,1-.471-.46c-.01-.312.146-.47.471-.47" transform="translate(-68.119 -22.402)" fill="#999"/>
|
||||
<path id="Path_14023" data-name="Path 14023" d="M109.715,44.466a.753.753,0,0,1-.51-.184.818.818,0,0,1-.246-.595V41.4a.463.463,0,0,0,0-.057.387.387,0,0,0-.116-.013h-6.525a.667.667,0,0,1-.73-.722.708.708,0,0,1,.178-.529.731.731,0,0,1,.552-.2h6.529a1.8,1.8,0,0,1,1.253.446,1.4,1.4,0,0,1,.4,1.074v2.286a.781.781,0,0,1-.26.586.739.739,0,0,1-.523.193m-7.4-4.061a.277.277,0,0,0-.18.043.238.238,0,0,0-.032.157c0,.159.03.206.213.206h6.525a.668.668,0,0,1,.483.155.567.567,0,0,1,.154.439v2.282a.287.287,0,0,0,.083.214.252.252,0,0,0,.156.042.231.231,0,0,0,.173-.055.255.255,0,0,0,.093-.2V41.4a.892.892,0,0,0-.232-.687,1.29,1.29,0,0,0-.906-.31Zm6.666.954,0,0,0,0m.734-1.766a.751.751,0,0,1-.541-.225.729.729,0,0,1-.215-.554V33.678a.687.687,0,0,1,.756-.738.7.7,0,0,1,.783.735v1.151h1.025a.653.653,0,0,1,.71.712.661.661,0,0,1-.71.728H110.5v.97h1.025a.653.653,0,0,1,.71.711.661.661,0,0,1-.71.728H110.5v.141a.784.784,0,0,1-.783.775m0-6.129c-.215,0-.239.069-.239.215v5.135c0,.124.04.161.053.174a.247.247,0,0,0,.364.014.21.21,0,0,0,.087-.184v-.4a.26.26,0,0,1,.259-.261h1.283c.149,0,.192-.027.192-.205,0-.145-.025-.188-.192-.188h-1.283a.26.26,0,0,1-.259-.262V36a.26.26,0,0,1,.259-.262h1.283c.149,0,.192-.027.192-.2,0-.148-.024-.188-.192-.188h-1.283a.26.26,0,0,1-.259-.262V33.675c0-.113,0-.211-.265-.211m-5.279,5.763a3.249,3.249,0,0,1-2.515-1.061,2.494,2.494,0,0,1-.791-1.953,2.945,2.945,0,0,1,.834-2.123,3.352,3.352,0,0,1,2.472-.928,2.955,2.955,0,0,1,2.328.9,2.868,2.868,0,0,1,.952,2.155,2.814,2.814,0,0,1-.978,2.137,2.9,2.9,0,0,1-2.3.877m0-5.541a2.822,2.822,0,0,0-2.106.774,2.4,2.4,0,0,0-.682,1.754,1.983,1.983,0,0,0,.634,1.579,2.74,2.74,0,0,0,2.154.912,2.433,2.433,0,0,0,1.934-.72,2.307,2.307,0,0,0,.828-1.771,2.35,2.35,0,0,0-.8-1.788,2.45,2.45,0,0,0-1.96-.739m0,4.649a2.262,2.262,0,0,1-1.719-.654,2.241,2.241,0,0,1,0-2.945,2.3,2.3,0,0,1,1.721-.643,2.518,2.518,0,0,1,1.788.674,2.112,2.112,0,0,1,.484,1.447,2,2,0,0,1-2.272,2.121m0-3.719a1.781,1.781,0,0,0-1.351.486,1.716,1.716,0,0,0,0,2.216,1.749,1.749,0,0,0,1.346.494,1.889,1.889,0,0,0,1.306-.4,1.816,1.816,0,0,0,.448-1.2,1.6,1.6,0,0,0-.337-1.082,1.988,1.988,0,0,0-1.417-.515" transform="translate(-67.597 -21.884)" fill="#999"/>
|
||||
<path id="Path_14024" data-name="Path 14024" d="M141.333,34.3h8.14a.538.538,0,0,1,.365.128.459.459,0,0,1,.146.326.5.5,0,0,1-.109.312.49.49,0,0,1-.365.151h-1.273l.076.047a.317.317,0,0,1,.07.064.465.465,0,0,1,.1.4l-.385,1.628h1.416a.414.414,0,0,1,.471.44c.017.319-.116.48-.408.48H141.28c-.288,0-.428-.158-.418-.47a.4.4,0,0,1,.458-.45h1.429l-.371-1.668a.492.492,0,0,1,.093-.409l.06-.057h-1.2a.41.41,0,0,1-.471-.46c-.01-.3.146-.456.471-.456m1.854.916.063.057a.527.527,0,0,1,.133.3l.365,1.776H147.1l.365-1.755a.428.428,0,0,1,.249-.366l.023-.013Zm-2.7,3.877h9.888a.443.443,0,0,1,.484.49q0,.4-.458.4h-4.473v.91l0,.06h2.656a1.362,1.362,0,0,1,.905.3.884.884,0,0,1,.351.738v1.678a1.056,1.056,0,0,1-.318.8,1.125,1.125,0,0,1-.839.322h-6.5a1.183,1.183,0,0,1-.859-.285A1.041,1.041,0,0,1,141,43.7V42.1a1.119,1.119,0,0,1,.325-.842,1.226,1.226,0,0,1,.872-.3h2.729l0-.034v-.936h-4.447q-.5,0-.5-.453c0-.292.169-.44.511-.44m1.936,2.779a.551.551,0,0,0-.312.057.418.418,0,0,0-.076.285v1.332c0,.134.017.218.056.252a.623.623,0,0,0,.371.077h5.922a.645.645,0,0,0,.332-.064.254.254,0,0,0,.086-.215V42.174c0-.111-.023-.181-.063-.208a.631.631,0,0,0-.378-.094Z" transform="translate(-93.565 -22.788)" fill="#999"/>
|
||||
<path id="Path_14025" data-name="Path 14025" d="M148.163,44.535h-6.5a1.431,1.431,0,0,1-1.039-.358,1.294,1.294,0,0,1-.4-.994v-1.6a1.374,1.374,0,0,1,.405-1.032,1.48,1.48,0,0,1,1.05-.371h2.468v-.447h-4.188a.71.71,0,1,1,.013-1.416h9.888a.7.7,0,0,1,.742.741.638.638,0,0,1-.716.675h-4.214v.447h2.394a1.612,1.612,0,0,1,1.071.362,1.143,1.143,0,0,1,.444.94v1.678a1.315,1.315,0,0,1-.4.985,1.375,1.375,0,0,1-1.017.394M141.677,40.7a.973.973,0,0,0-.7.229.855.855,0,0,0-.242.65v1.6a.782.782,0,0,0,.24.612.944.944,0,0,0,.685.217h6.5a.872.872,0,0,0,.656-.246.8.8,0,0,0,.243-.61V41.478a.625.625,0,0,0-.249-.53,1.122,1.122,0,0,0-.749-.249h-2.656a.257.257,0,0,1-.188-.082.264.264,0,0,1-.071-.194l0-.061v-.9a.26.26,0,0,1,.259-.261h4.473c.2,0,.2-.053.2-.141-.008-.194-.076-.228-.226-.228h-9.888c-.252,0-.252.083-.252.178s0,.191.239.191H144.4a.26.26,0,0,1,.259.261l0,.945a.263.263,0,0,1-.066.2.257.257,0,0,1-.192.086Zm6.191,2.92h-5.922a.813.813,0,0,1-.536-.138.565.565,0,0,1-.15-.453V41.7a.654.654,0,0,1,.147-.465.7.7,0,0,1,.5-.139h5.939a.866.866,0,0,1,.531.144.477.477,0,0,1,.169.419v1.423a.505.505,0,0,1-.187.422.862.862,0,0,1-.489.118m-6.09-.535a1.09,1.09,0,0,0,.168.011h5.922a.766.766,0,0,0,.157-.013l0-1.427c0-.006,0-.012,0-.017a.571.571,0,0,0-.182-.023h-5.939a1.052,1.052,0,0,0-.125.006.574.574,0,0,0,0,.075v1.332c0,.022,0,.041,0,.056m6.3-1.423,0,0,0,0m.978-3.648h-8.293a.66.66,0,0,1-.5-.189.732.732,0,0,1-.177-.551.653.653,0,0,1,.716-.7H141.9l-.3-1.349a.971.971,0,0,1-.01-.262h-.783a.667.667,0,0,1-.729-.722.686.686,0,0,1,.174-.515.735.735,0,0,1,.555-.2h8.14a.8.8,0,0,1,.534.191.73.73,0,0,1,.236.524.77.77,0,0,1-.164.474.758.758,0,0,1-.569.251h-.777a.8.8,0,0,1,0,.291l-.311,1.32h1.088a.672.672,0,0,1,.729.689.739.739,0,0,1-.169.564.651.651,0,0,1-.5.19m-8.253-.919c-.172,0-.2.045-.2.188a.28.28,0,0,0,.032.178.2.2,0,0,0,.127.03h8.293a.186.186,0,0,0,.121-.027.283.283,0,0,0,.028-.178c-.007-.144-.038-.191-.213-.191h-1.416a.258.258,0,0,1-.2-.1.264.264,0,0,1-.048-.223l.384-1.628a.191.191,0,0,0-.042-.166l-.083-.056a.127.127,0,1,0-.24,0l-.031.018c-.1.053-.109.1-.112.151a.242.242,0,0,1,0,.037l-.365,1.755a.26.26,0,0,1-.253.208h-3.349a.259.259,0,0,1-.253-.209l-.365-1.776a.3.3,0,0,0-.067-.175l-.048-.043a.264.264,0,0,1-.069-.288.259.259,0,0,1,.242-.168h4.546a.259.259,0,0,1,.25.194l0,.009s0-.008,0-.012a.259.259,0,0,1,.249-.191h1.273a.235.235,0,0,0,.182-.075.253.253,0,0,0,.033-.127.191.191,0,0,0-.07-.141.289.289,0,0,0-.182-.051h-8.14a.284.284,0,0,0-.183.043.217.217,0,0,0-.03.143c0,.162.028.207.213.207h1.2a.258.258,0,0,1,.24.165.264.264,0,0,1-.063.287l-.059.057a.264.264,0,0,0-.016.177l.368,1.653a.265.265,0,0,1-.05.221.258.258,0,0,1-.2.1Zm2.638-.524h2.929l.318-1.531a.745.745,0,0,1,.012-.08h-3.588c0,.02.008.041.01.062Z" transform="translate(-93.044 -22.269)" fill="#999"/>
|
||||
<path id="Path_14026" data-name="Path 14026" d="M32.18,32c-8.505,0-16.514-1.612-22.55-4.54C3.42,24.448,0,20.378,0,16S3.42,7.552,9.63,4.54C15.666,1.612,23.674,0,32.18,0S48.694,1.612,54.73,4.54c6.21,3.012,9.63,7.082,9.63,11.46s-3.42,8.448-9.63,11.46C48.694,30.388,40.685,32,32.18,32m0-30.49C23.893,1.51,16.114,3.07,10.275,5.9,4.611,8.649,1.492,12.235,1.492,16s3.119,7.351,8.783,10.1C16.114,28.93,23.893,30.49,32.18,30.49S48.246,28.93,54.085,26.1c5.664-2.747,8.783-6.333,8.783-10.1S59.749,8.649,54.085,5.9C48.246,3.07,40.467,1.51,32.18,1.51" transform="translate(0 0)" fill="#999"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 15 KiB |
BIN
backend/static/img/logo_geoyoung.ico
Normal file
|
After Width: | Height: | Size: 1.1 KiB |
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
@@ -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_1_d4ffe983.png
Normal file
|
After Width: | Height: | Size: 55 KiB |
BIN
backend/static/uploads/pets/pet_2_9919f990.jpg
Normal file
|
After Width: | Height: | Size: 1.3 MiB |
BIN
backend/static/uploads/pets/pet_3_53b73509.png
Normal file
|
After Width: | Height: | Size: 7.3 MiB |
BIN
backend/static/uploads/pets/pet_4_98a97580.jpg
Normal file
|
After Width: | Height: | Size: 4.3 MiB |
BIN
backend/static/uploads/pets/pet_5_f89b9542.jpg
Normal file
|
After Width: | Height: | Size: 1.2 MiB |
BIN
backend/static/uploads/pets/pet_6_0f0409cd.jpeg
Normal file
|
After Width: | Height: | Size: 2.0 MiB |
BIN
backend/static/uploads/pets/pet_7_fc95b8e7.jpeg
Normal file
|
After Width: | Height: | Size: 1.1 MiB |
BIN
backend/static/uploads/pets/pet_8_48666e98.jpeg
Normal file
|
After Width: | Height: | Size: 1.9 MiB |
@@ -20,6 +20,41 @@
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
/* 토스트 알림 */
|
||||
.toast-container {
|
||||
position: fixed;
|
||||
top: 20px;
|
||||
right: 20px;
|
||||
z-index: 10000;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
.toast {
|
||||
padding: 14px 20px;
|
||||
border-radius: 10px;
|
||||
color: white;
|
||||
font-weight: 500;
|
||||
font-size: 14px;
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
|
||||
animation: toastIn 0.3s ease, toastOut 0.3s ease 2.7s forwards;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
.toast.success { background: linear-gradient(135deg, #10b981 0%, #059669 100%); }
|
||||
.toast.error { background: linear-gradient(135deg, #ef4444 0%, #dc2626 100%); }
|
||||
.toast.info { background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%); }
|
||||
.toast.printing { background: linear-gradient(135deg, #f59e0b 0%, #d97706 100%); }
|
||||
@keyframes toastIn {
|
||||
from { opacity: 0; transform: translateX(100px); }
|
||||
to { opacity: 1; transform: translateX(0); }
|
||||
}
|
||||
@keyframes toastOut {
|
||||
from { opacity: 1; transform: translateX(0); }
|
||||
to { opacity: 0; transform: translateX(100px); }
|
||||
}
|
||||
|
||||
.header {
|
||||
background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%);
|
||||
padding: 32px 24px;
|
||||
@@ -457,8 +492,44 @@
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-card" style="background: linear-gradient(135deg, #fef3c7, #fde68a);">
|
||||
<div class="stat-label" style="color: #92400e;">🐾 등록 반려동물</div>
|
||||
<div class="stat-value" style="color: #92400e;">
|
||||
{{ pet_stats.total_pets or 0 }}마리
|
||||
<span style="font-size: 14px; font-weight: 500; margin-left: 8px;">
|
||||
(🐕 {{ pet_stats.dog_count or 0 }} / 🐈 {{ pet_stats.cat_count or 0 }})
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 최근 등록 반려동물 -->
|
||||
{% if recent_pets %}
|
||||
<div class="section">
|
||||
<div class="section-title">🐾 최근 등록 반려동물 (10마리)</div>
|
||||
<div style="display: flex; flex-wrap: wrap; gap: 12px;">
|
||||
{% for pet in recent_pets %}
|
||||
<div style="display: flex; align-items: center; gap: 12px; padding: 12px 16px; background: linear-gradient(135deg, #fef3c7, #fde68a); border-radius: 14px; min-width: 220px;">
|
||||
{% if pet.photo_url %}
|
||||
<img src="{{ pet.photo_url }}" style="width: 48px; height: 48px; border-radius: 50%; object-fit: cover; border: 2px solid #fff; box-shadow: 0 2px 6px rgba(0,0,0,0.15);">
|
||||
{% else %}
|
||||
<div style="width: 48px; height: 48px; border-radius: 50%; background: #fff; display: flex; align-items: center; justify-content: center; font-size: 24px; box-shadow: 0 2px 6px rgba(0,0,0,0.1);">
|
||||
{% if pet.species == 'dog' %}🐕{% elif pet.species == 'cat' %}🐈{% else %}🐾{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
<div>
|
||||
<div style="font-weight: 700; font-size: 15px; color: #92400e;">
|
||||
{% if pet.species == 'dog' %}🐕{% elif pet.species == 'cat' %}🐈{% else %}🐾{% endif %} {{ pet.name }}
|
||||
</div>
|
||||
<div style="font-size: 12px; color: #a16207;">{{ pet.breed or '품종 미등록' }}</div>
|
||||
<div style="font-size: 11px; color: #b45309; margin-top: 2px;">{{ pet.owner_name }} ({{ pet.owner_phone[:3] }}-****-{{ pet.owner_phone[-4:] }})</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- 최근 가입 사용자 -->
|
||||
<div class="section">
|
||||
<div class="section-title">최근 가입 사용자 (20명)</div>
|
||||
@@ -850,6 +921,113 @@
|
||||
function closeUserModal() {
|
||||
document.getElementById('userDetailModal').style.display = 'none';
|
||||
}
|
||||
|
||||
// 특이사항 펼치기/접기 (클릭 시)
|
||||
function toggleCusetc(el) {
|
||||
if (el.style.maxHeight === 'none' || el.style.maxHeight === '') {
|
||||
el.style.maxHeight = '40px';
|
||||
el.style.overflow = 'hidden';
|
||||
} else {
|
||||
el.style.maxHeight = 'none';
|
||||
el.style.overflow = 'visible';
|
||||
}
|
||||
}
|
||||
|
||||
// 특이사항 수정 모드
|
||||
function editCusetc(cuscode, btn) {
|
||||
document.getElementById('cusetc-view').style.display = 'none';
|
||||
document.getElementById('cusetc-edit').style.display = 'block';
|
||||
document.getElementById('cusetc-textarea').focus();
|
||||
btn.style.display = 'none';
|
||||
}
|
||||
|
||||
// 특이사항 저장
|
||||
async function saveCusetc(cuscode) {
|
||||
const textarea = document.getElementById('cusetc-textarea');
|
||||
const newValue = textarea.value.trim();
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/members/${cuscode}/cusetc`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ cusetc: newValue })
|
||||
});
|
||||
const data = await res.json();
|
||||
|
||||
if (data.success) {
|
||||
// 뷰 업데이트
|
||||
const viewEl = document.getElementById('cusetc-view');
|
||||
viewEl.innerHTML = newValue || '<span style="color: #9ca3af; font-weight: normal;">없음</span>';
|
||||
viewEl.style.maxHeight = newValue.length > 30 ? '40px' : 'none';
|
||||
|
||||
cancelCusetc();
|
||||
alert('✅ 저장되었습니다.');
|
||||
} else {
|
||||
alert('❌ ' + (data.error || '저장 실패'));
|
||||
}
|
||||
} catch (err) {
|
||||
alert('❌ 오류: ' + err.message);
|
||||
}
|
||||
}
|
||||
|
||||
// 특이사항 수정 취소
|
||||
function cancelCusetc() {
|
||||
document.getElementById('cusetc-view').style.display = 'block';
|
||||
document.getElementById('cusetc-edit').style.display = 'none';
|
||||
// 수정 버튼 다시 표시
|
||||
const editBtn = document.querySelector('#cusetc-view').parentElement.querySelector('button');
|
||||
if (editBtn) editBtn.style.display = 'inline-block';
|
||||
}
|
||||
|
||||
// 토스트 알림 함수
|
||||
function showToast(message, type = 'info') {
|
||||
let container = document.querySelector('.toast-container');
|
||||
if (!container) {
|
||||
container = document.createElement('div');
|
||||
container.className = 'toast-container';
|
||||
document.body.appendChild(container);
|
||||
}
|
||||
|
||||
const toast = document.createElement('div');
|
||||
toast.className = `toast ${type}`;
|
||||
|
||||
const icons = { success: '✅', error: '❌', info: 'ℹ️', printing: '🖨️' };
|
||||
toast.innerHTML = `<span>${icons[type] || ''}</span><span>${message}</span>`;
|
||||
|
||||
container.appendChild(toast);
|
||||
|
||||
setTimeout(() => toast.remove(), 3000);
|
||||
}
|
||||
|
||||
// 인쇄용 전역 변수
|
||||
let printData = { name: '', cusetc: '', phone: '' };
|
||||
|
||||
// 특이사항 인쇄 실행
|
||||
async function doPrintCusetc() {
|
||||
// 즉시 피드백
|
||||
showToast(`${printData.name}님 특이사항 인쇄 중...`, 'printing');
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/print/cusetc', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
customer_name: printData.name,
|
||||
cusetc: printData.cusetc,
|
||||
phone: printData.phone
|
||||
})
|
||||
});
|
||||
const data = await res.json();
|
||||
|
||||
if (data.success) {
|
||||
showToast(data.message, 'success');
|
||||
} else {
|
||||
showToast('인쇄 실패: ' + (data.error || '알 수 없는 오류'), 'error');
|
||||
}
|
||||
} catch (err) {
|
||||
showToast('오류: ' + err.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
function renderUserDetail(data) {
|
||||
// 전역 변수에 데이터 저장
|
||||
@@ -888,6 +1066,26 @@
|
||||
<div style="color: #ec4899; font-size: 16px; font-weight: 600;">${user.birthday.includes('-') ? user.birthday.split('-')[0] + '월 ' + user.birthday.split('-')[1] + '일' : user.birthday.slice(0,2) + '월 ' + user.birthday.slice(2,4) + '일'}</div>
|
||||
</div>
|
||||
` : ''}
|
||||
<!-- 특이(참고)사항 - 생일 옆 칸 -->
|
||||
${data.pos_customer ? `
|
||||
<div>
|
||||
<div style="display: flex; align-items: center; gap: 8px; margin-bottom: 6px;">
|
||||
<span style="color: #d97706; font-size: 13px;">⚠️ 특이사항</span>
|
||||
<button onclick="editCusetc('${data.pos_customer.cuscode}', this)" style="background: none; border: 1px solid #d97706; color: #d97706; font-size: 11px; padding: 2px 8px; border-radius: 4px; cursor: pointer;">✏️ 수정</button>
|
||||
${data.pos_customer.cusetc ? `<button onclick="printData={name:'${data.pos_customer.name}',cusetc:decodeURIComponent('${encodeURIComponent(data.pos_customer.cusetc)}'),phone:'${user.phone||''}'};doPrintCusetc()" style="background: none; border: 1px solid #6b7280; color: #6b7280; font-size: 11px; padding: 2px 8px; border-radius: 4px; cursor: pointer;">🖨️ 인쇄</button>` : ''}
|
||||
</div>
|
||||
<div id="cusetc-view" onclick="toggleCusetc(this)" style="color: #92400e; font-size: 14px; font-weight: 500; cursor: ${(data.pos_customer.cusetc || '').length > 30 ? 'pointer' : 'default'}; ${(data.pos_customer.cusetc || '').length > 30 ? 'max-height: 40px; overflow: hidden;' : ''}" title="${(data.pos_customer.cusetc || '').length > 30 ? '클릭하여 펼치기' : ''}">
|
||||
${data.pos_customer.cusetc || '<span style="color: #9ca3af; font-weight: normal;">없음</span>'}
|
||||
</div>
|
||||
<div id="cusetc-edit" style="display: none;">
|
||||
<textarea id="cusetc-textarea" style="width: 100%; min-height: 60px; padding: 8px; border: 1px solid #d97706; border-radius: 6px; font-size: 13px; resize: vertical;">${data.pos_customer.cusetc || ''}</textarea>
|
||||
<div style="display: flex; gap: 6px; margin-top: 6px;">
|
||||
<button onclick="saveCusetc('${data.pos_customer.cuscode}')" style="background: #d97706; color: white; border: none; padding: 4px 12px; border-radius: 4px; font-size: 12px; cursor: pointer;">저장</button>
|
||||
<button onclick="cancelCusetc()" style="background: #e5e7eb; color: #374151; border: none; padding: 4px 12px; border-radius: 4px; font-size: 12px; cursor: pointer;">취소</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
` : ''}
|
||||
</div>
|
||||
<div style="text-align: right; display: flex; gap: 8px; justify-content: flex-end;">
|
||||
<button onclick="showAIAnalysisModal(${user.id})" style="padding: 10px 24px; background: linear-gradient(135deg, #8b5cf6 0%, #6366f1 100%); color: white; border: none; border-radius: 10px; font-size: 14px; font-weight: 600; cursor: pointer; transition: all 0.2s;">
|
||||
@@ -913,6 +1111,9 @@
|
||||
<button onclick="switchTab('interests')" id="tab-interests" class="tab-btn" style="padding: 12px 20px; border: none; background: none; font-size: 15px; font-weight: 600; cursor: pointer; border-bottom: 3px solid transparent; color: #868e96;">
|
||||
💝 관심 (${data.interests ? data.interests.length : 0})
|
||||
</button>
|
||||
<button onclick="switchTab('pets')" id="tab-pets" class="tab-btn" style="padding: 12px 20px; border: none; background: none; font-size: 15px; font-weight: 600; cursor: pointer; border-bottom: 3px solid transparent; color: #868e96;">
|
||||
🐾 반려동물 (${data.pets ? data.pets.length : 0})
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 정렬 버튼 (구매 이력용) -->
|
||||
@@ -999,6 +1200,10 @@
|
||||
`;
|
||||
}).join('');
|
||||
|
||||
// 약품 코드 배열 (상호작용 체크용)
|
||||
const drugCodes = (rx.items || []).map(item => item.drug_code).filter(c => c);
|
||||
const drugCodesJson = JSON.stringify(drugCodes).replace(/"/g, '"');
|
||||
|
||||
html += `
|
||||
<div style="border: 1px solid #e9ecef; border-radius: 12px; margin-bottom: 12px; padding: 16px; border-left: 4px solid #6366f1;">
|
||||
<div style="display: flex; justify-content: space-between; margin-bottom: 8px;">
|
||||
@@ -1009,6 +1214,14 @@
|
||||
🏥 ${rx.hospital || ''} · ${rx.doctor || ''}
|
||||
</div>
|
||||
${rx.items && rx.items.length > 0 ? `<div style="background: #f8f9fa; border-radius: 8px; padding: 12px;">${itemsHtml}</div>` : ''}
|
||||
${drugCodes.length >= 2 ? `
|
||||
<div style="margin-top: 12px; text-align: right;">
|
||||
<button onclick='checkDrugInteraction(${drugCodesJson}, "${rx.pre_serial || ""}")'
|
||||
style="background: linear-gradient(135deg, #8b5cf6, #6366f1); color: #fff; border: none; padding: 8px 14px; border-radius: 8px; font-size: 12px; cursor: pointer; display: inline-flex; align-items: center; gap: 6px;">
|
||||
🔬 AI 상호작용 체크
|
||||
</button>
|
||||
</div>
|
||||
` : ''}
|
||||
</div>
|
||||
`;
|
||||
});
|
||||
@@ -1058,6 +1271,53 @@
|
||||
html += '<p style="text-align: center; padding: 40px; color: #868e96;">💝 관심 상품이 없습니다<br><small>마일리지 적립 시 AI 추천에서 "관심있어요"를 누르면 여기에 표시됩니다</small></p>';
|
||||
}
|
||||
|
||||
html += `
|
||||
</div>
|
||||
|
||||
<!-- 반려동물 탭 -->
|
||||
<div id="tab-content-pets" class="tab-content" style="display: none;">
|
||||
`;
|
||||
|
||||
// 반려동물 렌더링
|
||||
const pets = data.pets || [];
|
||||
if (pets.length > 0) {
|
||||
html += '<div style="display: grid; gap: 16px;">';
|
||||
pets.forEach(pet => {
|
||||
const photoHtml = pet.photo_url
|
||||
? `<img src="${pet.photo_url}" alt="${pet.name}" style="width: 80px; height: 80px; border-radius: 50%; object-fit: cover;">`
|
||||
: `<div style="width: 80px; height: 80px; border-radius: 50%; background: linear-gradient(135deg, #fbbf24, #f59e0b); display: flex; align-items: center; justify-content: center; font-size: 36px;">${pet.species === 'dog' ? '🐕' : (pet.species === 'cat' ? '🐈' : '🐾')}</div>`;
|
||||
|
||||
html += `
|
||||
<div style="border: 1px solid #e9ecef; border-radius: 16px; padding: 20px; display: flex; gap: 20px; align-items: center; border-left: 4px solid #f59e0b;">
|
||||
<div style="flex-shrink: 0;">
|
||||
${photoHtml}
|
||||
</div>
|
||||
<div style="flex: 1;">
|
||||
<div style="display: flex; align-items: center; gap: 10px; margin-bottom: 8px;">
|
||||
<span style="font-size: 20px; font-weight: 700; color: #212529;">${pet.name}</span>
|
||||
<span style="background: ${pet.species === 'dog' ? '#dbeafe' : '#fce7f3'}; color: ${pet.species === 'dog' ? '#1e40af' : '#9d174d'}; font-size: 12px; font-weight: 600; padding: 4px 10px; border-radius: 20px;">
|
||||
${pet.species_label}
|
||||
</span>
|
||||
</div>
|
||||
<div style="display: flex; flex-wrap: wrap; gap: 12px; font-size: 14px; color: #6b7280;">
|
||||
${pet.breed ? `<span>🏷️ ${pet.breed}</span>` : ''}
|
||||
${pet.gender_label ? `<span>${pet.gender_label}</span>` : ''}
|
||||
${pet.weight ? `<span>⚖️ ${pet.weight}kg</span>` : ''}
|
||||
${pet.age_months ? `<span>🎂 ${pet.age_months}개월</span>` : ''}
|
||||
</div>
|
||||
${pet.notes ? `<div style="margin-top: 8px; font-size: 13px; color: #9ca3af; background: #f9fafb; padding: 8px 12px; border-radius: 8px;">📝 ${pet.notes}</div>` : ''}
|
||||
<div style="margin-top: 10px; font-size: 12px; color: #d1d5db;">
|
||||
등록일: ${pet.created_at}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
});
|
||||
html += '</div>';
|
||||
} else {
|
||||
html += '<p style="text-align: center; padding: 40px; color: #868e96;">🐾 등록된 반려동물이 없습니다<br><small>고객이 마이페이지에서 반려동물을 등록하면 여기에 표시됩니다</small></p>';
|
||||
}
|
||||
|
||||
html += `
|
||||
</div>
|
||||
`;
|
||||
@@ -1710,6 +1970,169 @@
|
||||
closeAIAnalysisModal();
|
||||
}
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════
|
||||
// KIMS 약물 상호작용 체크
|
||||
// ═══════════════════════════════════════════════════
|
||||
|
||||
function escapeHtml(text) {
|
||||
if (!text) return '';
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
async function checkDrugInteraction(drugCodes, preSerial) {
|
||||
// drugCodes가 문자열로 넘어올 수 있음
|
||||
if (typeof drugCodes === 'string') {
|
||||
try { drugCodes = JSON.parse(drugCodes); } catch(e) { return; }
|
||||
}
|
||||
|
||||
// 로딩 모달 표시
|
||||
showInteractionModal('loading');
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/kims/interaction-check', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({
|
||||
drug_codes: drugCodes,
|
||||
pre_serial: preSerial
|
||||
})
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
showInteractionModal('result', data);
|
||||
} else {
|
||||
showInteractionModal('error', data.error || '알 수 없는 오류');
|
||||
}
|
||||
} catch (err) {
|
||||
showInteractionModal('error', '서버 연결 실패: ' + err.message);
|
||||
}
|
||||
}
|
||||
|
||||
function showInteractionModal(type, data) {
|
||||
let modal = document.getElementById('interactionModal');
|
||||
if (!modal) {
|
||||
modal = document.createElement('div');
|
||||
modal.id = 'interactionModal';
|
||||
modal.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.5);display:flex;align-items:center;justify-content:center;z-index:9999;';
|
||||
modal.onclick = (e) => { if (e.target === modal) modal.remove(); };
|
||||
document.body.appendChild(modal);
|
||||
}
|
||||
|
||||
let content = '';
|
||||
|
||||
if (type === 'loading') {
|
||||
content = `
|
||||
<div style="background:#fff;border-radius:16px;padding:40px;text-align:center;max-width:400px;">
|
||||
<div style="font-size:48px;margin-bottom:16px;">🔬</div>
|
||||
<div style="font-size:18px;font-weight:600;color:#334155;">상호작용 분석 중...</div>
|
||||
<div style="font-size:14px;color:#64748b;margin-top:8px;">KIMS 데이터베이스 조회 중</div>
|
||||
</div>
|
||||
`;
|
||||
} else if (type === 'error') {
|
||||
content = `
|
||||
<div style="background:#fff;border-radius:16px;padding:30px;max-width:400px;">
|
||||
<div style="font-size:40px;text-align:center;margin-bottom:16px;">⚠️</div>
|
||||
<div style="font-size:16px;font-weight:600;color:#dc2626;text-align:center;">분석 실패</div>
|
||||
<div style="font-size:14px;color:#64748b;margin-top:12px;text-align:center;">${escapeHtml(data)}</div>
|
||||
<div style="text-align:center;margin-top:20px;">
|
||||
<button onclick="document.getElementById('interactionModal').remove()"
|
||||
style="background:#6366f1;color:#fff;border:none;padding:10px 24px;border-radius:8px;cursor:pointer;">
|
||||
닫기
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
} else if (type === 'result') {
|
||||
const interactions = data.interactions || [];
|
||||
const drugsChecked = data.drugs_checked || [];
|
||||
|
||||
// 약품 목록 (상호작용 여부에 따른 색상)
|
||||
const drugsHtml = drugsChecked.map(d => {
|
||||
const hasInteraction = d.has_interaction;
|
||||
const bgColor = hasInteraction ? '#fef2f2' : '#f1f5f9';
|
||||
const borderColor = hasInteraction ? '#fca5a5' : '#e2e8f0';
|
||||
const textColor = hasInteraction ? '#dc2626' : '#334155';
|
||||
const icon = hasInteraction ? '⚠️ ' : '';
|
||||
return `<span style="display:inline-block;background:${bgColor};border:1px solid ${borderColor};color:${textColor};padding:4px 8px;border-radius:4px;margin:2px;font-size:12px;">${icon}${escapeHtml(d.name.slice(0,20))}</span>`;
|
||||
}).join('');
|
||||
|
||||
// 상호작용 목록
|
||||
let interactionsHtml = '';
|
||||
if (interactions.length === 0) {
|
||||
interactionsHtml = `
|
||||
<div style="text-align:center;padding:30px;">
|
||||
<div style="font-size:48px;margin-bottom:12px;">✅</div>
|
||||
<div style="font-size:16px;font-weight:600;color:#10b981;">상호작용 없음</div>
|
||||
<div style="font-size:13px;color:#64748b;margin-top:8px;">
|
||||
${data.total_pairs}개 약품 조합을 검사했습니다.<br>
|
||||
주의가 필요한 상호작용이 발견되지 않았습니다.
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
} else {
|
||||
interactionsHtml = interactions.map(item => `
|
||||
<div style="background:#fff;border:1px solid ${item.severity_color};border-radius:12px;padding:16px;margin-bottom:12px;">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:10px;">
|
||||
<span style="font-weight:600;color:#334155;">
|
||||
${escapeHtml(item.drug1_name?.slice(0,20) || '')} ↔ ${escapeHtml(item.drug2_name?.slice(0,20) || '')}
|
||||
</span>
|
||||
<span style="background:${item.severity_color};color:#fff;padding:4px 10px;border-radius:12px;font-size:12px;font-weight:500;">
|
||||
${item.severity_text}
|
||||
</span>
|
||||
</div>
|
||||
${item.description ? `
|
||||
<div style="font-size:13px;color:#475569;margin-bottom:8px;line-height:1.5;">
|
||||
📋 ${escapeHtml(item.description)}
|
||||
</div>
|
||||
` : ''}
|
||||
${item.management ? `
|
||||
<div style="font-size:12px;color:#059669;background:#ecfdf5;padding:8px 12px;border-radius:6px;">
|
||||
💡 ${escapeHtml(item.management.slice(0, 150))}...
|
||||
</div>
|
||||
` : ''}
|
||||
</div>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
content = `
|
||||
<div style="background:#f8fafc;border-radius:20px;max-width:500px;max-height:80vh;overflow:hidden;display:flex;flex-direction:column;">
|
||||
<div style="background:linear-gradient(135deg,#8b5cf6,#6366f1);padding:20px 24px;color:#fff;">
|
||||
<div style="font-size:18px;font-weight:700;display:flex;align-items:center;gap:10px;">
|
||||
🔬 약물 상호작용 분석
|
||||
</div>
|
||||
<div style="font-size:13px;opacity:0.9;margin-top:6px;">
|
||||
${drugsChecked.length}개 약품 · ${data.total_pairs}개 조합 검사
|
||||
</div>
|
||||
</div>
|
||||
<div style="padding:16px 20px;border-bottom:1px solid #e2e8f0;">
|
||||
<div style="font-size:12px;color:#64748b;margin-bottom:6px;">분석 약품</div>
|
||||
${drugsHtml}
|
||||
</div>
|
||||
<div style="flex:1;overflow-y:auto;padding:16px 20px;">
|
||||
${interactions.length > 0 ? `
|
||||
<div style="font-size:13px;color:#dc2626;font-weight:600;margin-bottom:12px;">
|
||||
⚠️ ${interactions.length}건의 상호작용 발견
|
||||
</div>
|
||||
` : ''}
|
||||
${interactionsHtml}
|
||||
</div>
|
||||
<div style="padding:16px 20px;border-top:1px solid #e2e8f0;text-align:center;">
|
||||
<button onclick="document.getElementById('interactionModal').remove()"
|
||||
style="background:#6366f1;color:#fff;border:none;padding:12px 32px;border-radius:10px;font-size:14px;font-weight:600;cursor:pointer;">
|
||||
닫기
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
modal.innerHTML = content;
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Lottie 애니메이션 라이브러리 (로컬) -->
|
||||
|
||||
563
backend/templates/admin_kims_logs.html
Normal file
@@ -0,0 +1,563 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>KIMS 상호작용 로그 - 청춘약국</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body {
|
||||
font-family: 'Noto Sans KR', -apple-system, BlinkMacSystemFont, sans-serif;
|
||||
background: #f8fafc;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
color: #1e293b;
|
||||
}
|
||||
|
||||
/* ── 헤더 ── */
|
||||
.header {
|
||||
background: linear-gradient(135deg, #dc2626 0%, #f59e0b 50%, #16a34a 100%);
|
||||
padding: 28px 32px 24px;
|
||||
color: #fff;
|
||||
}
|
||||
.header-nav {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.header-nav a {
|
||||
color: rgba(255,255,255,0.8);
|
||||
text-decoration: none;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
}
|
||||
.header-nav a:hover { color: #fff; }
|
||||
.header h1 {
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.5px;
|
||||
margin-bottom: 6px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
.header p {
|
||||
font-size: 14px;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
/* ── 컨텐츠 ── */
|
||||
.content {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 24px 20px 60px;
|
||||
}
|
||||
|
||||
/* ── 통계 카드 ── */
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, 1fr);
|
||||
gap: 14px;
|
||||
margin-bottom: 28px;
|
||||
}
|
||||
.stat-card {
|
||||
background: #fff;
|
||||
border-radius: 14px;
|
||||
padding: 20px;
|
||||
border: 1px solid #e2e8f0;
|
||||
}
|
||||
.stat-label {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #94a3b8;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.stat-value {
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
letter-spacing: -1px;
|
||||
}
|
||||
.stat-value.default { color: #1e293b; }
|
||||
.stat-value.green { color: #16a34a; }
|
||||
.stat-value.orange { color: #f59e0b; }
|
||||
.stat-value.red { color: #dc2626; }
|
||||
.stat-value.blue { color: #3b82f6; }
|
||||
.stat-sub {
|
||||
font-size: 11px;
|
||||
color: #94a3b8;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
/* ── 필터 ── */
|
||||
.filter-bar {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
margin-bottom: 20px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.filter-bar select, .filter-bar input {
|
||||
padding: 10px 14px;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 10px;
|
||||
font-size: 13px;
|
||||
font-family: inherit;
|
||||
background: #fff;
|
||||
}
|
||||
.filter-bar button {
|
||||
padding: 10px 20px;
|
||||
background: linear-gradient(135deg, #6366f1, #8b5cf6);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* ── 테이블 ── */
|
||||
.table-wrap {
|
||||
background: #fff;
|
||||
border-radius: 14px;
|
||||
border: 1px solid #e2e8f0;
|
||||
overflow: hidden;
|
||||
}
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
thead th {
|
||||
background: #f8fafc;
|
||||
padding: 12px 14px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #64748b;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
tbody td {
|
||||
padding: 14px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: #334155;
|
||||
border-bottom: 1px solid #f1f5f9;
|
||||
vertical-align: middle;
|
||||
}
|
||||
tbody tr { cursor: pointer; transition: background .15s; }
|
||||
tbody tr:hover { background: #f8fafc; }
|
||||
tbody tr:last-child td { border-bottom: none; }
|
||||
|
||||
/* ── 배지 ── */
|
||||
.badge {
|
||||
display: inline-block;
|
||||
padding: 4px 10px;
|
||||
border-radius: 100px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.badge-success { background: #dcfce7; color: #16a34a; }
|
||||
.badge-error { background: #fee2e2; color: #dc2626; }
|
||||
.badge-timeout { background: #fef3c7; color: #d97706; }
|
||||
.badge-severe { background: #dc2626; color: #fff; }
|
||||
.badge-moderate { background: #f59e0b; color: #fff; }
|
||||
.badge-mild { background: #3b82f6; color: #fff; }
|
||||
.badge-drug {
|
||||
background: #f1f5f9;
|
||||
color: #475569;
|
||||
margin: 2px;
|
||||
font-size: 10px;
|
||||
padding: 3px 8px;
|
||||
}
|
||||
.badge-drug.warning {
|
||||
background: #fef2f2;
|
||||
border: 1px solid #fca5a5;
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
/* ── 상호작용 카운트 ── */
|
||||
.interaction-count {
|
||||
font-weight: 700;
|
||||
font-size: 16px;
|
||||
}
|
||||
.interaction-count.zero { color: #16a34a; }
|
||||
.interaction-count.has { color: #dc2626; }
|
||||
.interaction-count.severe {
|
||||
color: #fff;
|
||||
background: #dc2626;
|
||||
padding: 4px 10px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
/* ── 아코디언 상세 ── */
|
||||
.detail-row { display: none; }
|
||||
.detail-row.open { display: table-row; }
|
||||
.detail-row td {
|
||||
padding: 0;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
background: #fafbfd;
|
||||
}
|
||||
.detail-content {
|
||||
padding: 20px 24px;
|
||||
}
|
||||
.detail-section {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.detail-section-title {
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: #64748b;
|
||||
margin-bottom: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
.drug-pills {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
/* ── 상호작용 카드 ── */
|
||||
.interaction-card {
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
padding: 16px;
|
||||
margin-bottom: 12px;
|
||||
border-left: 4px solid #e2e8f0;
|
||||
}
|
||||
.interaction-card.severe { border-left-color: #dc2626; background: #fef2f2; }
|
||||
.interaction-card.moderate { border-left-color: #f59e0b; background: #fffbeb; }
|
||||
.interaction-card.mild { border-left-color: #3b82f6; background: #eff6ff; }
|
||||
.interaction-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.interaction-drugs {
|
||||
font-weight: 600;
|
||||
color: #1e293b;
|
||||
font-size: 14px;
|
||||
}
|
||||
.interaction-desc {
|
||||
font-size: 13px;
|
||||
color: #475569;
|
||||
line-height: 1.6;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.interaction-mgmt {
|
||||
font-size: 12px;
|
||||
color: #059669;
|
||||
background: #ecfdf5;
|
||||
padding: 10px 12px;
|
||||
border-radius: 8px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* ── 빈 상태 ── */
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 60px 20px;
|
||||
color: #94a3b8;
|
||||
}
|
||||
.empty-icon { font-size: 48px; margin-bottom: 12px; opacity: 0.5; }
|
||||
|
||||
/* ── 로딩 ── */
|
||||
.loading {
|
||||
text-align: center;
|
||||
padding: 40px;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
/* ── 반응형 ── */
|
||||
@media (max-width: 900px) {
|
||||
.stats-grid { grid-template-columns: repeat(2, 1fr); }
|
||||
}
|
||||
@media (max-width: 600px) {
|
||||
.stats-grid { grid-template-columns: 1fr; }
|
||||
.filter-bar { flex-direction: column; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="header">
|
||||
<div class="header-nav">
|
||||
<a href="/admin">← 관리자 홈</a>
|
||||
<a href="/admin/members">회원 관리</a>
|
||||
</div>
|
||||
<h1>🔬 KIMS 상호작용 로그</h1>
|
||||
<p>약물 상호작용 체크 API 호출 기록 · AI 학습용 데이터</p>
|
||||
</div>
|
||||
|
||||
<div class="content">
|
||||
<!-- 통계 -->
|
||||
<div class="stats-grid" id="statsGrid">
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">총 호출</div>
|
||||
<div class="stat-value default" id="statTotal">-</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">성공</div>
|
||||
<div class="stat-value green" id="statSuccess">-</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">상호작용 발견</div>
|
||||
<div class="stat-value orange" id="statInteraction">-</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">심각 경고</div>
|
||||
<div class="stat-value red" id="statSevere">-</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">평균 응답</div>
|
||||
<div class="stat-value blue" id="statAvgMs">-</div>
|
||||
<div class="stat-sub">밀리초</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 필터 -->
|
||||
<div class="filter-bar">
|
||||
<select id="filterStatus">
|
||||
<option value="">모든 상태</option>
|
||||
<option value="SUCCESS">성공</option>
|
||||
<option value="ERROR">에러</option>
|
||||
<option value="TIMEOUT">타임아웃</option>
|
||||
</select>
|
||||
<select id="filterInteraction">
|
||||
<option value="">모든 결과</option>
|
||||
<option value="has">상호작용 있음</option>
|
||||
<option value="severe">심각 상호작용</option>
|
||||
<option value="none">상호작용 없음</option>
|
||||
</select>
|
||||
<input type="date" id="filterDate" />
|
||||
<button onclick="loadLogs()">🔍 조회</button>
|
||||
</div>
|
||||
|
||||
<!-- 테이블 -->
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>시간</th>
|
||||
<th>처방번호</th>
|
||||
<th>약품</th>
|
||||
<th>상호작용</th>
|
||||
<th>상태</th>
|
||||
<th>응답</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="logsBody">
|
||||
<tr>
|
||||
<td colspan="6" class="loading">로딩 중...</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
let logsData = [];
|
||||
let openRowId = null;
|
||||
|
||||
function escapeHtml(text) {
|
||||
if (!text) return '';
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
function formatDateTime(dt) {
|
||||
if (!dt) return '-';
|
||||
const d = new Date(dt);
|
||||
return `${d.getMonth()+1}/${d.getDate()} ${d.getHours().toString().padStart(2,'0')}:${d.getMinutes().toString().padStart(2,'0')}`;
|
||||
}
|
||||
|
||||
async function loadStats() {
|
||||
try {
|
||||
const res = await fetch('/api/kims/logs/stats');
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
document.getElementById('statTotal').textContent = data.stats.total_calls || 0;
|
||||
document.getElementById('statSuccess').textContent = data.stats.success_count || 0;
|
||||
document.getElementById('statInteraction').textContent = data.stats.with_interaction || 0;
|
||||
document.getElementById('statSevere').textContent = data.stats.with_severe || 0;
|
||||
document.getElementById('statAvgMs').textContent = Math.round(data.stats.avg_response_ms || 0);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('통계 로드 실패:', e);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadLogs() {
|
||||
const tbody = document.getElementById('logsBody');
|
||||
tbody.innerHTML = '<tr><td colspan="6" class="loading">로딩 중...</td></tr>';
|
||||
|
||||
const status = document.getElementById('filterStatus').value;
|
||||
const interaction = document.getElementById('filterInteraction').value;
|
||||
const date = document.getElementById('filterDate').value;
|
||||
|
||||
try {
|
||||
let url = '/api/kims/logs?limit=100';
|
||||
if (status) url += `&status=${status}`;
|
||||
if (interaction) url += `&interaction=${interaction}`;
|
||||
if (date) url += `&date=${date}`;
|
||||
|
||||
const res = await fetch(url);
|
||||
const data = await res.json();
|
||||
|
||||
if (!data.success || !data.logs || data.logs.length === 0) {
|
||||
tbody.innerHTML = '<tr><td colspan="6" class="empty-state"><div class="empty-icon">📭</div><div>로그가 없습니다</div></td></tr>';
|
||||
return;
|
||||
}
|
||||
|
||||
logsData = data.logs;
|
||||
renderLogs();
|
||||
} catch (e) {
|
||||
tbody.innerHTML = '<tr><td colspan="6" class="empty-state">로드 실패</td></tr>';
|
||||
}
|
||||
}
|
||||
|
||||
function renderLogs() {
|
||||
const tbody = document.getElementById('logsBody');
|
||||
let html = '';
|
||||
|
||||
logsData.forEach((log, idx) => {
|
||||
// 약품 배지
|
||||
let drugs = [];
|
||||
try { drugs = JSON.parse(log.request_drug_names || '[]'); } catch(e) {}
|
||||
const drugBadges = drugs.slice(0, 3).map(d =>
|
||||
`<span class="badge badge-drug">${escapeHtml(d.slice(0, 12))}</span>`
|
||||
).join('') + (drugs.length > 3 ? `<span class="badge badge-drug">+${drugs.length - 3}</span>` : '');
|
||||
|
||||
// 상호작용 표시
|
||||
let interactionHtml = '';
|
||||
if (log.interaction_count > 0) {
|
||||
if (log.has_severe_interaction) {
|
||||
interactionHtml = `<span class="interaction-count severe">⚠️ ${log.interaction_count}</span>`;
|
||||
} else {
|
||||
interactionHtml = `<span class="interaction-count has">${log.interaction_count}건</span>`;
|
||||
}
|
||||
} else {
|
||||
interactionHtml = `<span class="interaction-count zero">✓ 없음</span>`;
|
||||
}
|
||||
|
||||
// 상태 배지
|
||||
let statusBadge = '';
|
||||
if (log.api_status === 'SUCCESS') {
|
||||
statusBadge = '<span class="badge badge-success">성공</span>';
|
||||
} else if (log.api_status === 'TIMEOUT') {
|
||||
statusBadge = '<span class="badge badge-timeout">타임아웃</span>';
|
||||
} else {
|
||||
statusBadge = '<span class="badge badge-error">에러</span>';
|
||||
}
|
||||
|
||||
html += `
|
||||
<tr onclick="toggleDetail(${log.id}, ${idx})">
|
||||
<td>${formatDateTime(log.created_at)}</td>
|
||||
<td>${escapeHtml(log.pre_serial) || '-'}</td>
|
||||
<td>${drugBadges}</td>
|
||||
<td>${interactionHtml}</td>
|
||||
<td>${statusBadge}</td>
|
||||
<td>${log.response_time_ms || 0}ms</td>
|
||||
</tr>
|
||||
<tr class="detail-row" id="detail-${log.id}">
|
||||
<td colspan="6">
|
||||
<div class="detail-content" id="detail-content-${log.id}">
|
||||
로딩 중...
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
`;
|
||||
});
|
||||
|
||||
tbody.innerHTML = html;
|
||||
}
|
||||
|
||||
async function toggleDetail(logId, idx) {
|
||||
const detailRow = document.getElementById(`detail-${logId}`);
|
||||
|
||||
if (openRowId === logId) {
|
||||
detailRow.classList.remove('open');
|
||||
openRowId = null;
|
||||
return;
|
||||
}
|
||||
|
||||
// 기존 열린 행 닫기
|
||||
if (openRowId) {
|
||||
document.getElementById(`detail-${openRowId}`)?.classList.remove('open');
|
||||
}
|
||||
|
||||
openRowId = logId;
|
||||
detailRow.classList.add('open');
|
||||
|
||||
// 상세 데이터 로드
|
||||
const contentDiv = document.getElementById(`detail-content-${logId}`);
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/kims/logs/${logId}`);
|
||||
const data = await res.json();
|
||||
|
||||
if (!data.success) {
|
||||
contentDiv.innerHTML = '<p>상세 정보 로드 실패</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
const log = data.log;
|
||||
let drugs = [];
|
||||
try { drugs = JSON.parse(log.request_drug_names || '[]'); } catch(e) {}
|
||||
|
||||
// 상호작용 카드
|
||||
let interactionsHtml = '';
|
||||
const interactions = log.interactions_detail || [];
|
||||
|
||||
if (interactions.length === 0) {
|
||||
interactionsHtml = '<p style="color:#16a34a;font-weight:600;">✅ 상호작용 없음</p>';
|
||||
} else {
|
||||
interactions.forEach(inter => {
|
||||
const sevLevel = inter.severity_level || 5;
|
||||
const sevClass = sevLevel == 1 ? 'severe' : sevLevel == 2 ? 'moderate' : 'mild';
|
||||
|
||||
interactionsHtml += `
|
||||
<div class="interaction-card ${sevClass}">
|
||||
<div class="interaction-header">
|
||||
<span class="interaction-drugs">${escapeHtml(inter.drug1_name)} ↔ ${escapeHtml(inter.drug2_name)}</span>
|
||||
<span class="badge badge-${sevClass}">${escapeHtml(inter.severity_desc) || '알 수 없음'}</span>
|
||||
</div>
|
||||
${inter.observation ? `<div class="interaction-desc">${escapeHtml(inter.observation)}</div>` : ''}
|
||||
${inter.clinical_management ? `<div class="interaction-mgmt">💡 ${escapeHtml(inter.clinical_management).slice(0, 200)}...</div>` : ''}
|
||||
</div>
|
||||
`;
|
||||
});
|
||||
}
|
||||
|
||||
contentDiv.innerHTML = `
|
||||
<div class="detail-section">
|
||||
<div class="detail-section-title">💊 분석 약품 (${drugs.length}개)</div>
|
||||
<div class="drug-pills">
|
||||
${drugs.map(d => `<span class="badge badge-drug">${escapeHtml(d)}</span>`).join('')}
|
||||
</div>
|
||||
</div>
|
||||
<div class="detail-section">
|
||||
<div class="detail-section-title">⚠️ 상호작용 (${interactions.length}건)</div>
|
||||
${interactionsHtml}
|
||||
</div>
|
||||
<div class="detail-section" style="font-size:12px;color:#94a3b8;">
|
||||
응답시간: ${log.response_time_ms}ms ·
|
||||
호출시간: ${log.created_at} ·
|
||||
처방번호: ${escapeHtml(log.pre_serial) || '-'}
|
||||
</div>
|
||||
`;
|
||||
} catch (e) {
|
||||
contentDiv.innerHTML = '<p>로드 실패</p>';
|
||||
}
|
||||
}
|
||||
|
||||
// 초기 로드
|
||||
loadStats();
|
||||
loadLogs();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -924,7 +924,8 @@
|
||||
const txs = detailData.mileage.transactions;
|
||||
container.innerHTML = txs.map(tx => {
|
||||
const isPositive = tx.points > 0;
|
||||
const date = tx.created_at ? new Date(tx.created_at).toLocaleString('ko-KR', {
|
||||
// DB는 UTC로 저장 → 'Z' 붙여서 UTC로 해석 → KST로 표시
|
||||
const date = tx.created_at ? new Date(tx.created_at + 'Z').toLocaleString('ko-KR', {
|
||||
month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit'
|
||||
}) : '';
|
||||
|
||||
@@ -1038,6 +1039,10 @@
|
||||
`;
|
||||
}).join('');
|
||||
|
||||
// 약품 코드 배열 (상호작용 체크용)
|
||||
const drugCodes = (rx.items || []).map(item => item.drug_code).filter(c => c);
|
||||
const drugCodesJson = JSON.stringify(drugCodes);
|
||||
|
||||
return `
|
||||
<div class="purchase-card" style="border-left: 3px solid #6366f1;">
|
||||
<div class="purchase-header">
|
||||
@@ -1050,6 +1055,14 @@
|
||||
${rx.items && rx.items.length > 0 ? `
|
||||
<div class="purchase-items">${itemsHtml}</div>
|
||||
` : ''}
|
||||
${drugCodes.length >= 2 ? `
|
||||
<div style="margin-top:10px;text-align:right;">
|
||||
<button onclick='checkDrugInteraction(${drugCodesJson}, "${rx.pre_serial || ""}")'
|
||||
style="background:linear-gradient(135deg,#8b5cf6,#6366f1);color:#fff;border:none;padding:8px 14px;border-radius:8px;font-size:12px;cursor:pointer;display:inline-flex;align-items:center;gap:6px;">
|
||||
🔬 AI 상호작용 체크
|
||||
</button>
|
||||
</div>
|
||||
` : ''}
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
@@ -1063,8 +1076,8 @@
|
||||
}
|
||||
|
||||
container.innerHTML = detailData.interests.map(item => {
|
||||
// 날짜 포맷
|
||||
const date = item.created_at ? new Date(item.created_at).toLocaleString('ko-KR', {
|
||||
// 날짜 포맷 (DB는 UTC → KST 변환)
|
||||
const date = item.created_at ? new Date(item.created_at + 'Z').toLocaleString('ko-KR', {
|
||||
month: 'short', day: 'numeric'
|
||||
}) : '';
|
||||
|
||||
@@ -1111,6 +1124,158 @@
|
||||
|
||||
// 페이지 로드 시 검색창 포커스
|
||||
document.getElementById('searchInput').focus();
|
||||
|
||||
// ═══════════════════════════════════════════════════
|
||||
// KIMS 약물 상호작용 체크
|
||||
// ═══════════════════════════════════════════════════
|
||||
|
||||
async function checkDrugInteraction(drugCodes, preSerial) {
|
||||
// 로딩 모달 표시
|
||||
showInteractionModal('loading');
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/kims/interaction-check', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({
|
||||
drug_codes: drugCodes,
|
||||
pre_serial: preSerial
|
||||
})
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
showInteractionModal('result', data);
|
||||
} else {
|
||||
showInteractionModal('error', data.error || '알 수 없는 오류');
|
||||
}
|
||||
} catch (err) {
|
||||
showInteractionModal('error', '서버 연결 실패: ' + err.message);
|
||||
}
|
||||
}
|
||||
|
||||
function showInteractionModal(type, data) {
|
||||
let modal = document.getElementById('interactionModal');
|
||||
if (!modal) {
|
||||
// 모달 생성
|
||||
modal = document.createElement('div');
|
||||
modal.id = 'interactionModal';
|
||||
modal.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.5);display:flex;align-items:center;justify-content:center;z-index:9999;';
|
||||
modal.onclick = (e) => { if (e.target === modal) modal.remove(); };
|
||||
document.body.appendChild(modal);
|
||||
}
|
||||
|
||||
let content = '';
|
||||
|
||||
if (type === 'loading') {
|
||||
content = `
|
||||
<div style="background:#fff;border-radius:16px;padding:40px;text-align:center;max-width:400px;">
|
||||
<div style="font-size:48px;margin-bottom:16px;">🔬</div>
|
||||
<div style="font-size:18px;font-weight:600;color:#334155;">상호작용 분석 중...</div>
|
||||
<div style="font-size:14px;color:#64748b;margin-top:8px;">KIMS 데이터베이스 조회 중</div>
|
||||
</div>
|
||||
`;
|
||||
} else if (type === 'error') {
|
||||
content = `
|
||||
<div style="background:#fff;border-radius:16px;padding:30px;max-width:400px;">
|
||||
<div style="font-size:40px;text-align:center;margin-bottom:16px;">⚠️</div>
|
||||
<div style="font-size:16px;font-weight:600;color:#dc2626;text-align:center;">분석 실패</div>
|
||||
<div style="font-size:14px;color:#64748b;margin-top:12px;text-align:center;">${escapeHtml(data)}</div>
|
||||
<div style="text-align:center;margin-top:20px;">
|
||||
<button onclick="document.getElementById('interactionModal').remove()"
|
||||
style="background:#6366f1;color:#fff;border:none;padding:10px 24px;border-radius:8px;cursor:pointer;">
|
||||
닫기
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
} else if (type === 'result') {
|
||||
const interactions = data.interactions || [];
|
||||
const drugsChecked = data.drugs_checked || [];
|
||||
|
||||
// 약품 목록 (상호작용 있는 약품은 빨간색/주황색 배경)
|
||||
const drugsHtml = drugsChecked.map(d => {
|
||||
const hasInteraction = d.has_interaction;
|
||||
const bgColor = hasInteraction ? '#fef2f2' : '#f1f5f9'; // 연한 빨강 vs 회색
|
||||
const borderColor = hasInteraction ? '#fca5a5' : '#e2e8f0';
|
||||
const textColor = hasInteraction ? '#dc2626' : '#334155';
|
||||
const icon = hasInteraction ? '⚠️ ' : '';
|
||||
return `<span style="display:inline-block;background:${bgColor};border:1px solid ${borderColor};color:${textColor};padding:4px 8px;border-radius:4px;margin:2px;font-size:12px;">${icon}${escapeHtml(d.name.slice(0,20))}</span>`;
|
||||
}).join('');
|
||||
|
||||
// 상호작용 목록
|
||||
let interactionsHtml = '';
|
||||
if (interactions.length === 0) {
|
||||
interactionsHtml = `
|
||||
<div style="text-align:center;padding:30px;">
|
||||
<div style="font-size:48px;margin-bottom:12px;">✅</div>
|
||||
<div style="font-size:16px;font-weight:600;color:#10b981;">상호작용 없음</div>
|
||||
<div style="font-size:13px;color:#64748b;margin-top:8px;">
|
||||
${data.total_pairs}개 약품 조합을 검사했습니다.<br>
|
||||
주의가 필요한 상호작용이 발견되지 않았습니다.
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
} else {
|
||||
interactionsHtml = interactions.map(item => `
|
||||
<div style="background:#fff;border:1px solid ${item.severity_color};border-radius:12px;padding:16px;margin-bottom:12px;">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:10px;">
|
||||
<span style="font-weight:600;color:#334155;">
|
||||
${escapeHtml(item.drug1_name?.slice(0,20) || '')} ↔ ${escapeHtml(item.drug2_name?.slice(0,20) || '')}
|
||||
</span>
|
||||
<span style="background:${item.severity_color};color:#fff;padding:4px 10px;border-radius:12px;font-size:12px;font-weight:500;">
|
||||
${item.severity_text}
|
||||
</span>
|
||||
</div>
|
||||
${item.description ? `
|
||||
<div style="font-size:13px;color:#475569;margin-bottom:8px;line-height:1.5;">
|
||||
📋 ${escapeHtml(item.description)}
|
||||
</div>
|
||||
` : ''}
|
||||
${item.management ? `
|
||||
<div style="font-size:12px;color:#059669;background:#ecfdf5;padding:8px 12px;border-radius:6px;">
|
||||
💡 ${escapeHtml(item.management)}
|
||||
</div>
|
||||
` : ''}
|
||||
</div>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
content = `
|
||||
<div style="background:#f8fafc;border-radius:20px;max-width:500px;max-height:80vh;overflow:hidden;display:flex;flex-direction:column;">
|
||||
<div style="background:linear-gradient(135deg,#8b5cf6,#6366f1);padding:20px 24px;color:#fff;">
|
||||
<div style="font-size:18px;font-weight:700;display:flex;align-items:center;gap:10px;">
|
||||
🔬 약물 상호작용 분석
|
||||
</div>
|
||||
<div style="font-size:13px;opacity:0.9;margin-top:6px;">
|
||||
${drugsChecked.length}개 약품 · ${data.total_pairs}개 조합 검사
|
||||
</div>
|
||||
</div>
|
||||
<div style="padding:16px 20px;border-bottom:1px solid #e2e8f0;">
|
||||
<div style="font-size:12px;color:#64748b;margin-bottom:6px;">분석 약품</div>
|
||||
${drugsHtml}
|
||||
</div>
|
||||
<div style="flex:1;overflow-y:auto;padding:16px 20px;">
|
||||
${interactions.length > 0 ? `
|
||||
<div style="font-size:13px;color:#dc2626;font-weight:600;margin-bottom:12px;">
|
||||
⚠️ ${interactions.length}건의 상호작용 발견
|
||||
</div>
|
||||
` : ''}
|
||||
${interactionsHtml}
|
||||
</div>
|
||||
<div style="padding:16px 20px;border-top:1px solid #e2e8f0;text-align:center;">
|
||||
<button onclick="document.getElementById('interactionModal').remove()"
|
||||
style="background:#6366f1;color:#fff;border:none;padding:12px 32px;border-radius:10px;font-size:14px;font-weight:600;cursor:pointer;">
|
||||
닫기
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
modal.innerHTML = content;
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
704
backend/templates/admin_otc_labels.html
Normal file
@@ -0,0 +1,704 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>OTC 용법 라벨 관리 - 청춘약국</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body {
|
||||
font-family: 'Noto Sans KR', sans-serif;
|
||||
background: #f5f7fa;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
/* 헤더 */
|
||||
.header {
|
||||
background: linear-gradient(135deg, #f59e0b, #d97706);
|
||||
color: white;
|
||||
padding: 20px 24px;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
|
||||
}
|
||||
.header-content {
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
.header-title {
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.header-nav a {
|
||||
color: white;
|
||||
text-decoration: none;
|
||||
margin-left: 16px;
|
||||
opacity: 0.9;
|
||||
}
|
||||
.header-nav a:hover { opacity: 1; }
|
||||
|
||||
/* 컨테이너 */
|
||||
.container {
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
padding: 24px;
|
||||
display: grid;
|
||||
grid-template-columns: 400px 1fr;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
/* 패널 */
|
||||
.panel {
|
||||
background: white;
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.06);
|
||||
overflow: hidden;
|
||||
}
|
||||
.panel-header {
|
||||
padding: 16px 20px;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
font-weight: 700;
|
||||
font-size: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.panel-body {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
/* 검색 */
|
||||
.search-box {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.search-input {
|
||||
flex: 1;
|
||||
padding: 12px 16px;
|
||||
border: 2px solid #e2e8f0;
|
||||
border-radius: 10px;
|
||||
font-size: 15px;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
.search-input:focus {
|
||||
outline: none;
|
||||
border-color: #f59e0b;
|
||||
}
|
||||
.search-btn {
|
||||
padding: 12px 20px;
|
||||
background: linear-gradient(135deg, #f59e0b, #d97706);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: transform 0.1s;
|
||||
}
|
||||
.search-btn:hover { transform: scale(1.02); }
|
||||
|
||||
/* 검색 결과 */
|
||||
.search-results {
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 10px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.search-result-item {
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid #f1f5f9;
|
||||
cursor: pointer;
|
||||
transition: background 0.1s;
|
||||
}
|
||||
.search-result-item:hover { background: #fef3c7; }
|
||||
.search-result-item:last-child { border-bottom: none; }
|
||||
.search-result-name {
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.search-result-barcode {
|
||||
font-size: 12px;
|
||||
color: #64748b;
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
/* 폼 */
|
||||
.form-group {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.form-label {
|
||||
display: block;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #64748b;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.form-input, .form-textarea {
|
||||
width: 100%;
|
||||
padding: 12px 14px;
|
||||
border: 2px solid #e2e8f0;
|
||||
border-radius: 10px;
|
||||
font-size: 15px;
|
||||
font-family: inherit;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
.form-input:focus, .form-textarea:focus {
|
||||
outline: none;
|
||||
border-color: #f59e0b;
|
||||
}
|
||||
.form-textarea {
|
||||
resize: vertical;
|
||||
min-height: 80px;
|
||||
}
|
||||
.form-input[readonly] {
|
||||
background: #f8fafc;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
/* 버튼 */
|
||||
.btn-group {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-top: 20px;
|
||||
}
|
||||
.btn {
|
||||
flex: 1;
|
||||
padding: 14px 20px;
|
||||
border: none;
|
||||
border-radius: 12px;
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.btn-primary {
|
||||
background: linear-gradient(135deg, #f59e0b, #d97706);
|
||||
color: white;
|
||||
}
|
||||
.btn-primary:hover { transform: translateY(-1px); box-shadow: 0 4px 12px rgba(245,158,11,0.3); }
|
||||
.btn-secondary {
|
||||
background: #e2e8f0;
|
||||
color: #475569;
|
||||
}
|
||||
.btn-secondary:hover { background: #cbd5e1; }
|
||||
.btn-print {
|
||||
background: linear-gradient(135deg, #6366f1, #4f46e5);
|
||||
color: white;
|
||||
}
|
||||
.btn-print:hover { transform: translateY(-1px); box-shadow: 0 4px 12px rgba(99,102,241,0.3); }
|
||||
.btn-delete {
|
||||
background: #fee2e2;
|
||||
color: #dc2626;
|
||||
}
|
||||
.btn-delete:hover { background: #fecaca; }
|
||||
|
||||
/* 미리보기 */
|
||||
.preview-container {
|
||||
text-align: center;
|
||||
padding: 20px;
|
||||
background: #f8fafc;
|
||||
border-radius: 12px;
|
||||
min-height: 200px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.preview-image {
|
||||
max-width: 100%;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
|
||||
}
|
||||
.preview-placeholder {
|
||||
color: #94a3b8;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* 목록 테이블 */
|
||||
.label-list {
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 14px;
|
||||
}
|
||||
th {
|
||||
background: #f8fafc;
|
||||
padding: 12px 16px;
|
||||
text-align: left;
|
||||
font-weight: 600;
|
||||
color: #64748b;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
}
|
||||
td {
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid #f1f5f9;
|
||||
vertical-align: middle;
|
||||
}
|
||||
tr:hover { background: #fef3c7; cursor: pointer; }
|
||||
.td-name {
|
||||
font-weight: 600;
|
||||
color: #1e293b;
|
||||
}
|
||||
.td-effect {
|
||||
color: #d97706;
|
||||
font-weight: 500;
|
||||
}
|
||||
.td-count {
|
||||
font-family: monospace;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
/* 토스트 */
|
||||
.toast {
|
||||
position: fixed;
|
||||
bottom: 24px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
padding: 14px 28px;
|
||||
border-radius: 12px;
|
||||
font-weight: 600;
|
||||
z-index: 9999;
|
||||
animation: toastIn 0.3s ease;
|
||||
}
|
||||
.toast.success { background: #10b981; color: white; }
|
||||
.toast.error { background: #ef4444; color: white; }
|
||||
@keyframes toastIn {
|
||||
from { opacity: 0; transform: translateX(-50%) translateY(20px); }
|
||||
to { opacity: 1; transform: translateX(-50%) translateY(0); }
|
||||
}
|
||||
|
||||
/* 반응형 */
|
||||
@media (max-width: 900px) {
|
||||
.container {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header class="header">
|
||||
<div class="header-content">
|
||||
<div class="header-title">💊 OTC 용법 라벨 관리</div>
|
||||
<nav class="header-nav">
|
||||
<a href="/admin">📊 대시보드</a>
|
||||
<a href="/admin/pos-live">📋 실시간 POS</a>
|
||||
<a href="/admin/members">👥 회원</a>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="container">
|
||||
<!-- 왼쪽: 편집 패널 -->
|
||||
<div class="panel">
|
||||
<div class="panel-header">✏️ 라벨 편집</div>
|
||||
<div class="panel-body">
|
||||
<!-- 약품 검색 -->
|
||||
<div class="search-box">
|
||||
<input type="text" class="search-input" id="searchInput" placeholder="바코드 또는 약품명 검색...">
|
||||
<button class="search-btn" onclick="searchDrug()">검색</button>
|
||||
</div>
|
||||
|
||||
<!-- 검색 결과 -->
|
||||
<div class="search-results" id="searchResults" style="display:none;"></div>
|
||||
|
||||
<!-- 편집 폼 -->
|
||||
<form id="labelForm">
|
||||
<div class="form-group">
|
||||
<label class="form-label">바코드</label>
|
||||
<input type="text" class="form-input" id="barcode" readonly placeholder="약품을 검색하세요">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">약품명 (표시용)</label>
|
||||
<input type="text" class="form-input" id="displayName" placeholder="오버라이드 이름 (비우면 원본 사용)">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">효능 ⭐</label>
|
||||
<input type="text" class="form-input" id="effect" placeholder="예: 치통, 두통">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">용법</label>
|
||||
<textarea class="form-textarea" id="dosageInstruction" placeholder="예: 1일 3회, 1회 1정, 식후 30분"></textarea>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">부가 설명</label>
|
||||
<input type="text" class="form-input" id="usageTip" placeholder="예: [통증 시에만 복용]">
|
||||
</div>
|
||||
|
||||
<div class="btn-group">
|
||||
<button type="button" class="btn btn-secondary" onclick="previewLabel()">👁️ 미리보기</button>
|
||||
<button type="button" class="btn btn-primary" onclick="saveLabel()">💾 저장</button>
|
||||
</div>
|
||||
<div class="btn-group">
|
||||
<button type="button" class="btn btn-print" onclick="printLabel()">🖨️ 인쇄</button>
|
||||
<button type="button" class="btn btn-delete" onclick="deleteLabel()">🗑️ 삭제</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 오른쪽: 미리보기 + 목록 -->
|
||||
<div style="display: flex; flex-direction: column; gap: 24px;">
|
||||
<!-- 미리보기 -->
|
||||
<div class="panel">
|
||||
<div class="panel-header">👁️ 라벨 미리보기</div>
|
||||
<div class="panel-body">
|
||||
<div class="preview-container" id="previewContainer">
|
||||
<div class="preview-placeholder">미리보기를 클릭하면 라벨이 표시됩니다</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 저장된 목록 -->
|
||||
<div class="panel">
|
||||
<div class="panel-header">📋 저장된 라벨 프리셋</div>
|
||||
<div class="panel-body">
|
||||
<div class="label-list" id="labelList">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>약품명</th>
|
||||
<th>효능</th>
|
||||
<th>인쇄</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="labelListBody">
|
||||
<tr><td colspan="3" style="text-align:center; color:#94a3b8;">로딩 중...</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
let currentBarcode = '';
|
||||
let currentDrugName = '';
|
||||
|
||||
// 초기화
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
loadLabelList();
|
||||
|
||||
// Enter 키로 검색
|
||||
document.getElementById('searchInput').addEventListener('keypress', (e) => {
|
||||
if (e.key === 'Enter') searchDrug();
|
||||
});
|
||||
|
||||
// 입력 시 자동 미리보기 (디바운스)
|
||||
let debounceTimer;
|
||||
['effect', 'dosageInstruction', 'usageTip', 'displayName'].forEach(id => {
|
||||
document.getElementById(id).addEventListener('input', () => {
|
||||
clearTimeout(debounceTimer);
|
||||
debounceTimer = setTimeout(previewLabel, 500);
|
||||
});
|
||||
});
|
||||
|
||||
// URL 파라미터로 바코드/이름 전달 시 자동 로드
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const urlBarcode = params.get('barcode');
|
||||
const urlName = params.get('name');
|
||||
if (urlBarcode) {
|
||||
currentBarcode = urlBarcode;
|
||||
currentDrugName = urlName || urlBarcode;
|
||||
document.getElementById('barcode').value = urlBarcode;
|
||||
document.getElementById('searchInput').value = urlName || urlBarcode;
|
||||
|
||||
// 기존 프리셋 확인
|
||||
fetch(`/api/admin/otc-labels/${urlBarcode}`)
|
||||
.then(res => res.json())
|
||||
.then(data => {
|
||||
if (data.exists) {
|
||||
document.getElementById('displayName').value = data.label.display_name || '';
|
||||
document.getElementById('effect').value = data.label.effect || '';
|
||||
document.getElementById('dosageInstruction').value = data.label.dosage_instruction || '';
|
||||
document.getElementById('usageTip').value = data.label.usage_tip || '';
|
||||
}
|
||||
previewLabel();
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// 약품 검색 (MSSQL)
|
||||
async function searchDrug() {
|
||||
const query = document.getElementById('searchInput').value.trim();
|
||||
if (!query) return;
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/admin/otc-labels/search-mssql?q=${encodeURIComponent(query)}`);
|
||||
const data = await res.json();
|
||||
|
||||
const resultsDiv = document.getElementById('searchResults');
|
||||
|
||||
if (data.success && data.drugs.length > 0) {
|
||||
resultsDiv.innerHTML = data.drugs.map(drug => `
|
||||
<div class="search-result-item" onclick="selectDrug('${drug.barcode}', '${escapeHtml(drug.goods_name)}', '${drug.drug_code}')">
|
||||
<div class="search-result-name">${drug.goods_name}</div>
|
||||
<div class="search-result-barcode">${drug.barcode}</div>
|
||||
</div>
|
||||
`).join('');
|
||||
resultsDiv.style.display = 'block';
|
||||
} else {
|
||||
resultsDiv.innerHTML = '<div class="search-result-item" style="color:#94a3b8;">검색 결과 없음</div>';
|
||||
resultsDiv.style.display = 'block';
|
||||
}
|
||||
} catch (err) {
|
||||
showToast('검색 오류: ' + err.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// 약품 선택
|
||||
async function selectDrug(barcode, goodsName, drugCode) {
|
||||
document.getElementById('searchResults').style.display = 'none';
|
||||
document.getElementById('searchInput').value = goodsName;
|
||||
|
||||
currentBarcode = barcode;
|
||||
currentDrugName = goodsName;
|
||||
|
||||
document.getElementById('barcode').value = barcode;
|
||||
|
||||
// 기존 프리셋 확인
|
||||
try {
|
||||
const res = await fetch(`/api/admin/otc-labels/${barcode}`);
|
||||
const data = await res.json();
|
||||
|
||||
if (data.exists) {
|
||||
// 기존 데이터 로드
|
||||
document.getElementById('displayName').value = data.label.display_name || '';
|
||||
document.getElementById('effect').value = data.label.effect || '';
|
||||
document.getElementById('dosageInstruction').value = data.label.dosage_instruction || '';
|
||||
document.getElementById('usageTip').value = data.label.usage_tip || '';
|
||||
showToast('기존 프리셋 로드됨', 'success');
|
||||
} else {
|
||||
// 새 프리셋 (MSSQL 이름 사용)
|
||||
document.getElementById('displayName').value = '';
|
||||
document.getElementById('effect').value = '';
|
||||
document.getElementById('dosageInstruction').value = '';
|
||||
document.getElementById('usageTip').value = '';
|
||||
}
|
||||
|
||||
previewLabel();
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
}
|
||||
|
||||
// 미리보기
|
||||
async function previewLabel() {
|
||||
const drugName = document.getElementById('displayName').value || currentDrugName || '약품명';
|
||||
const effect = document.getElementById('effect').value;
|
||||
const dosageInstruction = document.getElementById('dosageInstruction').value;
|
||||
const usageTip = document.getElementById('usageTip').value;
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/admin/otc-labels/preview', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ drug_name: drugName, effect, dosage_instruction: dosageInstruction, usage_tip: usageTip })
|
||||
});
|
||||
const data = await res.json();
|
||||
|
||||
if (data.success) {
|
||||
document.getElementById('previewContainer').innerHTML =
|
||||
`<img src="${data.preview_url}" class="preview-image" alt="라벨 미리보기">`;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('미리보기 오류:', err);
|
||||
}
|
||||
}
|
||||
|
||||
// 저장
|
||||
async function saveLabel() {
|
||||
if (!currentBarcode) {
|
||||
showToast('먼저 약품을 검색하세요', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
// display_name이 비어있으면 원본 약품명 사용
|
||||
const displayName = document.getElementById('displayName').value || currentDrugName;
|
||||
|
||||
const payload = {
|
||||
barcode: currentBarcode,
|
||||
display_name: displayName,
|
||||
effect: document.getElementById('effect').value,
|
||||
dosage_instruction: document.getElementById('dosageInstruction').value,
|
||||
usage_tip: document.getElementById('usageTip').value
|
||||
};
|
||||
|
||||
console.log('저장 payload:', payload);
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/admin/otc-labels', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
|
||||
console.log('저장 응답 status:', res.status);
|
||||
const data = await res.json();
|
||||
console.log('저장 응답 data:', data);
|
||||
|
||||
if (data.success) {
|
||||
showToast('저장 완료!', 'success');
|
||||
loadLabelList();
|
||||
} else {
|
||||
showToast(data.error || '알 수 없는 오류', 'error');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('저장 오류:', err);
|
||||
showToast('저장 오류: ' + err.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// 인쇄
|
||||
async function printLabel() {
|
||||
const drugName = document.getElementById('displayName').value || currentDrugName || '약품명';
|
||||
const effect = document.getElementById('effect').value;
|
||||
const dosageInstruction = document.getElementById('dosageInstruction').value;
|
||||
const usageTip = document.getElementById('usageTip').value;
|
||||
|
||||
if (!effect && !dosageInstruction) {
|
||||
showToast('효능 또는 용법을 입력하세요', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/admin/otc-labels/print', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
barcode: currentBarcode,
|
||||
drug_name: drugName,
|
||||
effect,
|
||||
dosage_instruction: dosageInstruction,
|
||||
usage_tip: usageTip
|
||||
})
|
||||
});
|
||||
const data = await res.json();
|
||||
|
||||
if (data.success) {
|
||||
showToast('🖨️ 인쇄 완료!', 'success');
|
||||
loadLabelList();
|
||||
} else {
|
||||
showToast(data.error, 'error');
|
||||
}
|
||||
} catch (err) {
|
||||
showToast('인쇄 오류: ' + err.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// 삭제
|
||||
async function deleteLabel() {
|
||||
if (!currentBarcode) {
|
||||
showToast('삭제할 프리셋이 없습니다', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!confirm(`"${currentDrugName}" 프리셋을 삭제하시겠습니까?`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/admin/otc-labels/${currentBarcode}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
const data = await res.json();
|
||||
|
||||
if (data.success) {
|
||||
showToast('삭제 완료!', 'success');
|
||||
// 폼 초기화
|
||||
currentBarcode = '';
|
||||
currentDrugName = '';
|
||||
document.getElementById('barcode').value = '';
|
||||
document.getElementById('displayName').value = '';
|
||||
document.getElementById('effect').value = '';
|
||||
document.getElementById('dosageInstruction').value = '';
|
||||
document.getElementById('usageTip').value = '';
|
||||
document.getElementById('previewContainer').innerHTML = '<div class="preview-placeholder">미리보기를 클릭하면 라벨이 표시됩니다</div>';
|
||||
loadLabelList();
|
||||
} else {
|
||||
showToast(data.error || '삭제 실패', 'error');
|
||||
}
|
||||
} catch (err) {
|
||||
showToast('삭제 오류: ' + err.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// 목록 로드
|
||||
async function loadLabelList() {
|
||||
try {
|
||||
const res = await fetch('/api/admin/otc-labels');
|
||||
const data = await res.json();
|
||||
|
||||
if (data.success) {
|
||||
const tbody = document.getElementById('labelListBody');
|
||||
|
||||
if (data.labels.length === 0) {
|
||||
tbody.innerHTML = '<tr><td colspan="3" style="text-align:center; color:#94a3b8;">저장된 프리셋이 없습니다</td></tr>';
|
||||
return;
|
||||
}
|
||||
|
||||
tbody.innerHTML = data.labels.map(label => `
|
||||
<tr onclick="loadLabel('${label.barcode}')">
|
||||
<td class="td-name">${label.display_name || label.barcode}</td>
|
||||
<td class="td-effect">${label.effect || '-'}</td>
|
||||
<td class="td-count">${label.print_count || 0}회</td>
|
||||
</tr>
|
||||
`).join('');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('목록 로드 오류:', err);
|
||||
}
|
||||
}
|
||||
|
||||
// 목록에서 로드
|
||||
async function loadLabel(barcode) {
|
||||
try {
|
||||
const res = await fetch(`/api/admin/otc-labels/${barcode}`);
|
||||
const data = await res.json();
|
||||
|
||||
if (data.exists) {
|
||||
currentBarcode = barcode;
|
||||
currentDrugName = data.label.display_name || barcode;
|
||||
|
||||
document.getElementById('barcode').value = barcode;
|
||||
document.getElementById('displayName').value = data.label.display_name || '';
|
||||
document.getElementById('effect').value = data.label.effect || '';
|
||||
document.getElementById('dosageInstruction').value = data.label.dosage_instruction || '';
|
||||
document.getElementById('usageTip').value = data.label.usage_tip || '';
|
||||
|
||||
previewLabel();
|
||||
showToast('프리셋 로드됨', 'success');
|
||||
}
|
||||
} catch (err) {
|
||||
showToast('로드 오류: ' + err.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// 유틸
|
||||
function escapeHtml(str) {
|
||||
return str.replace(/[&<>"']/g, m => ({'&':'&','<':'<','>':'>','"':'"',"'":'''})[m]);
|
||||
}
|
||||
|
||||
function showToast(message, type = 'success') {
|
||||
const existing = document.querySelector('.toast');
|
||||
if (existing) existing.remove();
|
||||
|
||||
const toast = document.createElement('div');
|
||||
toast.className = `toast ${type}`;
|
||||
toast.textContent = message;
|
||||
document.body.appendChild(toast);
|
||||
|
||||
setTimeout(() => toast.remove(), 3000);
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
495
backend/templates/admin_paai.html
Normal file
@@ -0,0 +1,495 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>PAAI 분석 로그 - 관리자</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body {
|
||||
font-family: 'Segoe UI', 'Malgun Gothic', sans-serif;
|
||||
background: #f3f4f6;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.header {
|
||||
background: linear-gradient(135deg, #10b981, #059669);
|
||||
color: #fff;
|
||||
padding: 20px 30px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
.header h1 { font-size: 1.5rem; }
|
||||
.header a { color: #fff; text-decoration: none; opacity: 0.8; }
|
||||
.header a:hover { opacity: 1; }
|
||||
|
||||
.container {
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
/* 통계 카드 */
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||
gap: 15px;
|
||||
margin-bottom: 25px;
|
||||
}
|
||||
.stat-card {
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
padding: 20px;
|
||||
text-align: center;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.08);
|
||||
}
|
||||
.stat-card .num { font-size: 2rem; font-weight: 700; color: #10b981; }
|
||||
.stat-card .label { font-size: 0.85rem; color: #6b7280; margin-top: 5px; }
|
||||
.stat-card.severe .num { color: #ef4444; }
|
||||
|
||||
/* 필터 */
|
||||
.filters {
|
||||
background: #fff;
|
||||
padding: 15px 20px;
|
||||
border-radius: 12px;
|
||||
margin-bottom: 20px;
|
||||
display: flex;
|
||||
gap: 15px;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.08);
|
||||
}
|
||||
.filters input, .filters select {
|
||||
padding: 8px 12px;
|
||||
border: 2px solid #e5e7eb;
|
||||
border-radius: 8px;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.filters input:focus, .filters select:focus {
|
||||
outline: none;
|
||||
border-color: #10b981;
|
||||
}
|
||||
.filters button {
|
||||
padding: 8px 20px;
|
||||
background: #10b981;
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
}
|
||||
.filters button:hover { background: #059669; }
|
||||
|
||||
/* 로그 테이블 */
|
||||
.log-table {
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.08);
|
||||
}
|
||||
.log-table table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
.log-table th {
|
||||
background: #f9fafb;
|
||||
padding: 12px 15px;
|
||||
text-align: left;
|
||||
font-weight: 600;
|
||||
color: #374151;
|
||||
border-bottom: 2px solid #e5e7eb;
|
||||
}
|
||||
.log-table td {
|
||||
padding: 12px 15px;
|
||||
border-bottom: 1px solid #f3f4f6;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.log-table tr:hover { background: #f9fafb; cursor: pointer; }
|
||||
|
||||
.badge {
|
||||
display: inline-block;
|
||||
padding: 3px 10px;
|
||||
border-radius: 20px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
.badge-success { background: #d1fae5; color: #065f46; }
|
||||
.badge-error { background: #fee2e2; color: #991b1b; }
|
||||
.badge-pending { background: #fef3c7; color: #92400e; }
|
||||
.badge-severe { background: #fee2e2; color: #dc2626; }
|
||||
.badge-caution { background: #fef3c7; color: #d97706; }
|
||||
|
||||
.feedback-icon { font-size: 1.1rem; }
|
||||
|
||||
/* 상세 모달 */
|
||||
.modal {
|
||||
display: none;
|
||||
position: fixed;
|
||||
top: 0; left: 0; right: 0; bottom: 0;
|
||||
background: rgba(0,0,0,0.6);
|
||||
z-index: 1000;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 20px;
|
||||
}
|
||||
.modal.show { display: flex; }
|
||||
.modal-content {
|
||||
background: #fff;
|
||||
border-radius: 16px;
|
||||
width: 100%;
|
||||
max-width: 900px;
|
||||
max-height: 90vh;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.modal-header {
|
||||
background: linear-gradient(135deg, #10b981, #059669);
|
||||
color: #fff;
|
||||
padding: 20px 25px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
.modal-close {
|
||||
background: rgba(255,255,255,0.2);
|
||||
border: none;
|
||||
color: #fff;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 50%;
|
||||
font-size: 1.5rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
.modal-body {
|
||||
padding: 25px;
|
||||
overflow-y: auto;
|
||||
flex: 1;
|
||||
}
|
||||
.detail-section {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.detail-section h4 {
|
||||
font-size: 0.95rem;
|
||||
color: #374151;
|
||||
margin-bottom: 10px;
|
||||
border-bottom: 2px solid #10b981;
|
||||
padding-bottom: 5px;
|
||||
}
|
||||
.detail-section pre {
|
||||
background: #f9fafb;
|
||||
padding: 15px;
|
||||
border-radius: 8px;
|
||||
font-size: 0.85rem;
|
||||
overflow-x: auto;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
}
|
||||
.detail-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 10px;
|
||||
}
|
||||
.detail-item {
|
||||
background: #f9fafb;
|
||||
padding: 10px 15px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
.detail-item .label { font-size: 0.8rem; color: #6b7280; }
|
||||
.detail-item .value { font-weight: 600; color: #111827; }
|
||||
|
||||
.loading {
|
||||
text-align: center;
|
||||
padding: 60px;
|
||||
color: #9ca3af;
|
||||
}
|
||||
.spinner {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: 4px solid #e5e7eb;
|
||||
border-top-color: #10b981;
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
margin: 0 auto 15px;
|
||||
}
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="header">
|
||||
<h1>🤖 PAAI 분석 로그</h1>
|
||||
<a href="/admin">← 관리자 홈</a>
|
||||
</div>
|
||||
|
||||
<div class="container">
|
||||
<!-- 통계 -->
|
||||
<div class="stats-grid" id="statsGrid">
|
||||
<div class="stat-card">
|
||||
<div class="num" id="statTotal">-</div>
|
||||
<div class="label">전체</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="num" id="statToday">-</div>
|
||||
<div class="label">오늘</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="num" id="statSuccessRate">-</div>
|
||||
<div class="label">성공률</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="num" id="statAvgTime">-</div>
|
||||
<div class="label">평균 응답(ms)</div>
|
||||
</div>
|
||||
<div class="stat-card severe">
|
||||
<div class="num" id="statSevere">-</div>
|
||||
<div class="label">심각 상호작용</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="num" id="statFeedback">-</div>
|
||||
<div class="label">유용 피드백</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 필터 -->
|
||||
<div class="filters">
|
||||
<input type="date" id="filterDate" placeholder="날짜">
|
||||
<select id="filterStatus">
|
||||
<option value="">상태: 전체</option>
|
||||
<option value="success">성공</option>
|
||||
<option value="error">오류</option>
|
||||
<option value="pending">대기중</option>
|
||||
</select>
|
||||
<select id="filterSevere">
|
||||
<option value="">상호작용: 전체</option>
|
||||
<option value="true">심각 있음</option>
|
||||
<option value="false">심각 없음</option>
|
||||
</select>
|
||||
<button onclick="loadLogs()">🔍 조회</button>
|
||||
<button onclick="loadLogs()" style="background:#6b7280;">🔄 새로고침</button>
|
||||
</div>
|
||||
|
||||
<!-- 로그 테이블 -->
|
||||
<div class="log-table">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>시간</th>
|
||||
<th>환자</th>
|
||||
<th>처방번호</th>
|
||||
<th>약품수</th>
|
||||
<th>KIMS</th>
|
||||
<th>상태</th>
|
||||
<th>응답시간</th>
|
||||
<th>피드백</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="logTableBody">
|
||||
<tr><td colspan="8" class="loading"><div class="spinner"></div>로딩 중...</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 상세 모달 -->
|
||||
<div class="modal" id="detailModal">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h3>📋 분석 상세</h3>
|
||||
<button class="modal-close" onclick="closeModal()">×</button>
|
||||
</div>
|
||||
<div class="modal-body" id="modalBody">
|
||||
<div class="loading"><div class="spinner"></div></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// 페이지 로드
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
loadStats();
|
||||
loadLogs();
|
||||
});
|
||||
|
||||
// 통계 로드
|
||||
async function loadStats() {
|
||||
try {
|
||||
const res = await fetch('/api/paai/logs/stats');
|
||||
const data = await res.json();
|
||||
|
||||
if (data.success) {
|
||||
const s = data.stats;
|
||||
document.getElementById('statTotal').textContent = s.total;
|
||||
document.getElementById('statToday').textContent = s.today;
|
||||
document.getElementById('statSuccessRate').textContent = s.success_rate + '%';
|
||||
document.getElementById('statAvgTime').textContent = s.avg_response_time;
|
||||
document.getElementById('statSevere').textContent = s.severe_count;
|
||||
document.getElementById('statFeedback').textContent =
|
||||
s.feedback ? `${s.feedback.useful}/${s.feedback.total}` : '0/0';
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Stats error:', err);
|
||||
}
|
||||
}
|
||||
|
||||
// 로그 로드
|
||||
async function loadLogs() {
|
||||
const tbody = document.getElementById('logTableBody');
|
||||
tbody.innerHTML = '<tr><td colspan="8" class="loading"><div class="spinner"></div>로딩 중...</td></tr>';
|
||||
|
||||
try {
|
||||
const date = document.getElementById('filterDate').value;
|
||||
const status = document.getElementById('filterStatus').value;
|
||||
const severe = document.getElementById('filterSevere').value;
|
||||
|
||||
const params = new URLSearchParams();
|
||||
if (date) params.append('date', date);
|
||||
if (status) params.append('status', status);
|
||||
if (severe) params.append('has_severe', severe);
|
||||
params.append('limit', '100');
|
||||
|
||||
const res = await fetch(`/api/paai/logs?${params}`);
|
||||
const data = await res.json();
|
||||
|
||||
if (data.success) {
|
||||
if (data.logs.length === 0) {
|
||||
tbody.innerHTML = '<tr><td colspan="8" style="text-align:center;color:#9ca3af;padding:40px;">로그가 없습니다.</td></tr>';
|
||||
return;
|
||||
}
|
||||
|
||||
tbody.innerHTML = data.logs.map(log => {
|
||||
// DB는 UTC로 저장 → 'Z' 붙여서 UTC로 해석 → KST로 표시
|
||||
const time = new Date(log.created_at + 'Z').toLocaleString('ko-KR', {
|
||||
month: '2-digit', day: '2-digit',
|
||||
hour: '2-digit', minute: '2-digit'
|
||||
});
|
||||
|
||||
const statusBadge = {
|
||||
'success': '<span class="badge badge-success">성공</span>',
|
||||
'error': '<span class="badge badge-error">오류</span>',
|
||||
'pending': '<span class="badge badge-pending">대기</span>',
|
||||
'kims_done': '<span class="badge badge-pending">AI대기</span>'
|
||||
}[log.status] || log.status;
|
||||
|
||||
const kimsBadge = log.kims_has_severe
|
||||
? `<span class="badge badge-severe">🔴 ${log.kims_interaction_count}건</span>`
|
||||
: log.kims_interaction_count > 0
|
||||
? `<span class="badge badge-caution">⚠️ ${log.kims_interaction_count}건</span>`
|
||||
: '<span style="color:#9ca3af;">-</span>';
|
||||
|
||||
const feedback = log.feedback_useful === 1 ? '👍'
|
||||
: log.feedback_useful === 0 ? '👎' : '-';
|
||||
|
||||
return `
|
||||
<tr onclick="showDetail(${log.id})">
|
||||
<td>${time}</td>
|
||||
<td>${log.patient_name || '-'}</td>
|
||||
<td>${log.pre_serial || '-'}</td>
|
||||
<td>${log.current_med_count || 0}종</td>
|
||||
<td>${kimsBadge}</td>
|
||||
<td>${statusBadge}</td>
|
||||
<td>${log.ai_response_time_ms || '-'}ms</td>
|
||||
<td class="feedback-icon">${feedback}</td>
|
||||
</tr>
|
||||
`;
|
||||
}).join('');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Logs error:', err);
|
||||
tbody.innerHTML = '<tr><td colspan="8" style="text-align:center;color:#ef4444;">로드 실패</td></tr>';
|
||||
}
|
||||
}
|
||||
|
||||
// 상세 보기
|
||||
async function showDetail(logId) {
|
||||
const modal = document.getElementById('detailModal');
|
||||
const body = document.getElementById('modalBody');
|
||||
|
||||
body.innerHTML = '<div class="loading"><div class="spinner"></div></div>';
|
||||
modal.classList.add('show');
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/paai/logs/${logId}`);
|
||||
const data = await res.json();
|
||||
|
||||
if (data.success) {
|
||||
const log = data.log;
|
||||
|
||||
body.innerHTML = `
|
||||
<div class="detail-section">
|
||||
<h4>📌 기본 정보</h4>
|
||||
<div class="detail-grid">
|
||||
<div class="detail-item">
|
||||
<div class="label">환자</div>
|
||||
<div class="value">${log.patient_name || '-'} (${log.patient_code || '-'})</div>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<div class="label">처방번호</div>
|
||||
<div class="value">${log.pre_serial || '-'}</div>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<div class="label">질병1</div>
|
||||
<div class="value">[${log.disease_code_1 || '-'}] ${log.disease_name_1 || '-'}</div>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<div class="label">질병2</div>
|
||||
<div class="value">[${log.disease_code_2 || '-'}] ${log.disease_name_2 || '-'}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="detail-section">
|
||||
<h4>💊 현재 처방 (${log.current_med_count || 0}종)</h4>
|
||||
<pre>${JSON.stringify(log.current_medications, null, 2)}</pre>
|
||||
</div>
|
||||
|
||||
<div class="detail-section">
|
||||
<h4>⚠️ KIMS 상호작용 (${log.kims_interaction_count || 0}건)</h4>
|
||||
<pre>${JSON.stringify(log.kims_interactions, null, 2)}</pre>
|
||||
</div>
|
||||
|
||||
<div class="detail-section">
|
||||
<h4>🤖 AI 분석 결과</h4>
|
||||
<pre>${JSON.stringify(log.ai_response, null, 2)}</pre>
|
||||
</div>
|
||||
|
||||
<div class="detail-section">
|
||||
<h4>📊 성능</h4>
|
||||
<div class="detail-grid">
|
||||
<div class="detail-item">
|
||||
<div class="label">KIMS 응답</div>
|
||||
<div class="value">${log.kims_response_time_ms || '-'}ms</div>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<div class="label">AI 응답</div>
|
||||
<div class="value">${log.ai_response_time_ms || '-'}ms</div>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<div class="label">상태</div>
|
||||
<div class="value">${log.status}</div>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<div class="label">피드백</div>
|
||||
<div class="value">${log.feedback_useful === 1 ? '👍 유용' : log.feedback_useful === 0 ? '👎 아님' : '미응답'}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Detail error:', err);
|
||||
body.innerHTML = '<div style="text-align:center;color:#ef4444;">로드 실패</div>';
|
||||
}
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
document.getElementById('detailModal').classList.remove('show');
|
||||
}
|
||||
|
||||
// ESC로 모달 닫기
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape') closeModal();
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
1486
backend/templates/admin_pos_live.html
Normal file
1083
backend/templates/admin_product_images.html
Normal file
1437
backend/templates/admin_return_management.html
Normal file
3175
backend/templates/admin_rx_usage.html
Normal file
@@ -369,13 +369,170 @@
|
||||
|
||||
/* 제품 셀 */
|
||||
.product-cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
.product-thumb {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
object-fit: cover;
|
||||
border-radius: 6px;
|
||||
background: var(--bg-secondary);
|
||||
flex-shrink: 0;
|
||||
cursor: pointer;
|
||||
transition: transform 0.15s, box-shadow 0.15s;
|
||||
}
|
||||
.product-thumb:hover {
|
||||
transform: scale(1.1);
|
||||
box-shadow: 0 4px 12px rgba(139,92,246,0.3);
|
||||
}
|
||||
.product-thumb-placeholder {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: linear-gradient(135deg, #2a2a3e 0%, #1e1e2e 100%);
|
||||
border-radius: 6px;
|
||||
flex-shrink: 0;
|
||||
border: 1px solid rgba(255,255,255,0.05);
|
||||
cursor: pointer;
|
||||
transition: transform 0.15s, border-color 0.15s;
|
||||
}
|
||||
.product-thumb-placeholder:hover {
|
||||
transform: scale(1.1);
|
||||
border-color: var(--accent-purple);
|
||||
}
|
||||
.product-thumb-placeholder svg {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
opacity: 0.3;
|
||||
fill: #888;
|
||||
}
|
||||
|
||||
/* 이미지 교체 모달 */
|
||||
.image-modal {
|
||||
display: none;
|
||||
position: fixed;
|
||||
top: 0; left: 0; right: 0; bottom: 0;
|
||||
background: rgba(0,0,0,0.8);
|
||||
z-index: 1000;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
.image-modal.show { display: flex; }
|
||||
|
||||
.image-modal-content {
|
||||
background: #1a1a3e;
|
||||
border-radius: 16px;
|
||||
padding: 24px;
|
||||
max-width: 450px;
|
||||
width: 90%;
|
||||
max-height: 90vh;
|
||||
overflow-y: auto;
|
||||
border: 1px solid rgba(139,92,246,0.3);
|
||||
}
|
||||
.image-modal-content h3 {
|
||||
margin: 0 0 16px 0;
|
||||
color: var(--accent-purple);
|
||||
font-size: 18px;
|
||||
}
|
||||
.image-modal-tabs {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.tab-btn {
|
||||
flex: 1;
|
||||
padding: 10px;
|
||||
border: 1px solid rgba(255,255,255,0.1);
|
||||
background: transparent;
|
||||
color: var(--text-secondary);
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.tab-btn.active {
|
||||
background: var(--accent-purple);
|
||||
color: white;
|
||||
border-color: var(--accent-purple);
|
||||
}
|
||||
.tab-content { display: none; }
|
||||
.tab-content.active { display: block; }
|
||||
|
||||
.image-input {
|
||||
width: 100%;
|
||||
padding: 12px;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid rgba(255,255,255,0.1);
|
||||
border-radius: 8px;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.image-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent-purple);
|
||||
}
|
||||
|
||||
.camera-container {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
aspect-ratio: 1;
|
||||
background: #000;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.camera-container video,
|
||||
.camera-container canvas {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
.camera-guide {
|
||||
position: absolute;
|
||||
top: 0; left: 0; right: 0; bottom: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.modal-btns {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
.btn-modal {
|
||||
padding: 10px 20px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
font-weight: 500;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.btn-modal.secondary {
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.btn-modal.primary {
|
||||
background: var(--accent-purple);
|
||||
color: white;
|
||||
}
|
||||
.btn-modal:hover {
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
.product-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
}
|
||||
.product-name {
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.product-supplier {
|
||||
font-size: 11px;
|
||||
@@ -796,8 +953,14 @@
|
||||
<tr>
|
||||
<td>
|
||||
<div class="product-cell">
|
||||
<span class="product-name">${escapeHtml(item.product_name)}</span>
|
||||
${item.supplier ? `<span class="product-supplier">${escapeHtml(item.supplier)}</span>` : ''}
|
||||
${item.thumbnail
|
||||
? `<img src="data:image/jpeg;base64,${item.thumbnail}" class="product-thumb" onclick="openImageModal('${item.barcode || ''}', '${item.drug_code || ''}', '${escapeHtml(item.product_name)}')" alt="">`
|
||||
: `<div class="product-thumb-placeholder" onclick="openImageModal('${item.barcode || ''}', '${item.drug_code || ''}', '${escapeHtml(item.product_name)}')"><svg viewBox="0 0 24 24"><path d="M19 3H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm0 16H5V5h14v14zm-5-7l-3 3.72L9 13l-3 4h12l-4-5z"/></svg></div>`
|
||||
}
|
||||
<div class="product-info">
|
||||
<span class="product-name">${escapeHtml(item.product_name)}</span>
|
||||
${item.supplier ? `<span class="product-supplier">${escapeHtml(item.supplier)}</span>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td>${renderCode(item)}</td>
|
||||
@@ -826,8 +989,14 @@
|
||||
<td style="color:var(--text-secondary);font-size:12px;">${item.sale_date}</td>
|
||||
<td>
|
||||
<div class="product-cell">
|
||||
<span class="product-name">${escapeHtml(item.product_name)}</span>
|
||||
${item.supplier ? `<span class="product-supplier">${escapeHtml(item.supplier)}</span>` : ''}
|
||||
${item.thumbnail
|
||||
? `<img src="data:image/jpeg;base64,${item.thumbnail}" class="product-thumb" onclick="openImageModal('${item.barcode || ''}', '${item.drug_code || ''}', '${escapeHtml(item.product_name)}')" alt="">`
|
||||
: `<div class="product-thumb-placeholder" onclick="openImageModal('${item.barcode || ''}', '${item.drug_code || ''}', '${escapeHtml(item.product_name)}')"><svg viewBox="0 0 24 24"><path d="M19 3H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm0 16H5V5h14v14zm-5-7l-3 3.72L9 13l-3 4h12l-4-5z"/></svg></div>`
|
||||
}
|
||||
<div class="product-info">
|
||||
<span class="product-name">${escapeHtml(item.product_name)}</span>
|
||||
${item.supplier ? `<span class="product-supplier">${escapeHtml(item.supplier)}</span>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td>${renderCode(item)}</td>
|
||||
@@ -897,6 +1066,288 @@
|
||||
|
||||
// 초기 로드
|
||||
loadSalesData();
|
||||
|
||||
// ──────────────── 이미지 교체 모달 ────────────────
|
||||
let imgModalBarcode = null;
|
||||
let imgModalDrugCode = null;
|
||||
let imgModalName = null;
|
||||
let cameraStream = null;
|
||||
let capturedImageData = null;
|
||||
|
||||
function openImageModal(barcode, drugCode, productName) {
|
||||
// 바코드나 drug_code 중 하나는 있어야 함
|
||||
if (!barcode && !drugCode) {
|
||||
showToast('제품 코드 정보가 없습니다', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
imgModalBarcode = barcode || null;
|
||||
imgModalDrugCode = drugCode || null;
|
||||
imgModalName = productName || (barcode || drugCode);
|
||||
|
||||
document.getElementById('imgModalProductName').textContent = imgModalName;
|
||||
document.getElementById('imgModalCode').textContent = barcode || drugCode;
|
||||
document.getElementById('imgUrlInput').value = '';
|
||||
|
||||
// URL 탭으로 초기화
|
||||
switchImageTab('url');
|
||||
|
||||
document.getElementById('imageModal').classList.add('show');
|
||||
document.getElementById('imgUrlInput').focus();
|
||||
}
|
||||
|
||||
function closeImageModal() {
|
||||
stopCamera();
|
||||
document.getElementById('imageModal').classList.remove('show');
|
||||
imgModalBarcode = null;
|
||||
imgModalDrugCode = null;
|
||||
imgModalName = null;
|
||||
capturedImageData = null;
|
||||
}
|
||||
|
||||
function switchImageTab(tab) {
|
||||
document.querySelectorAll('.image-modal .tab-btn').forEach(btn => {
|
||||
btn.classList.toggle('active', btn.dataset.tab === tab);
|
||||
});
|
||||
document.querySelectorAll('.image-modal .tab-content').forEach(content => {
|
||||
content.classList.toggle('active', content.id === 'tab' + tab.charAt(0).toUpperCase() + tab.slice(1));
|
||||
});
|
||||
|
||||
if (tab === 'camera') {
|
||||
startCamera();
|
||||
} else {
|
||||
stopCamera();
|
||||
}
|
||||
}
|
||||
|
||||
async function startCamera() {
|
||||
try {
|
||||
stopCamera();
|
||||
|
||||
const constraints = {
|
||||
video: {
|
||||
facingMode: { ideal: 'environment' },
|
||||
width: { ideal: 1920 },
|
||||
height: { ideal: 1920 }
|
||||
},
|
||||
audio: false
|
||||
};
|
||||
|
||||
cameraStream = await navigator.mediaDevices.getUserMedia(constraints);
|
||||
const video = document.getElementById('cameraVideo');
|
||||
video.srcObject = cameraStream;
|
||||
video.style.display = 'block';
|
||||
|
||||
document.getElementById('captureCanvas').style.display = 'none';
|
||||
document.getElementById('cameraGuide').style.display = 'block';
|
||||
document.getElementById('captureBtn').style.display = 'block';
|
||||
document.getElementById('previewBtns').style.display = 'none';
|
||||
capturedImageData = null;
|
||||
|
||||
} catch (err) {
|
||||
console.error('카메라 오류:', err);
|
||||
showToast('카메라에 접근할 수 없습니다', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
function stopCamera() {
|
||||
if (cameraStream) {
|
||||
cameraStream.getTracks().forEach(track => track.stop());
|
||||
cameraStream = null;
|
||||
}
|
||||
const video = document.getElementById('cameraVideo');
|
||||
if (video) video.srcObject = null;
|
||||
}
|
||||
|
||||
function capturePhoto() {
|
||||
const video = document.getElementById('cameraVideo');
|
||||
const canvas = document.getElementById('captureCanvas');
|
||||
const ctx = canvas.getContext('2d');
|
||||
|
||||
const vw = video.videoWidth;
|
||||
const vh = video.videoHeight;
|
||||
const minDim = Math.min(vw, vh);
|
||||
const cropSize = minDim * 0.8;
|
||||
const sx = (vw - cropSize) / 2;
|
||||
const sy = (vh - cropSize) / 2;
|
||||
|
||||
canvas.width = 800;
|
||||
canvas.height = 800;
|
||||
ctx.drawImage(video, sx, sy, cropSize, cropSize, 0, 0, 800, 800);
|
||||
|
||||
capturedImageData = canvas.toDataURL('image/jpeg', 0.92);
|
||||
|
||||
video.style.display = 'none';
|
||||
canvas.style.display = 'block';
|
||||
document.getElementById('cameraGuide').style.display = 'none';
|
||||
document.getElementById('captureBtn').style.display = 'none';
|
||||
document.getElementById('previewBtns').style.display = 'flex';
|
||||
}
|
||||
|
||||
function retakePhoto() {
|
||||
const video = document.getElementById('cameraVideo');
|
||||
const canvas = document.getElementById('captureCanvas');
|
||||
|
||||
video.style.display = 'block';
|
||||
canvas.style.display = 'none';
|
||||
document.getElementById('cameraGuide').style.display = 'block';
|
||||
document.getElementById('captureBtn').style.display = 'block';
|
||||
document.getElementById('previewBtns').style.display = 'none';
|
||||
capturedImageData = null;
|
||||
}
|
||||
|
||||
async function submitCapturedImage() {
|
||||
if (!capturedImageData) {
|
||||
showToast('촬영된 이미지가 없습니다', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const code = imgModalBarcode || imgModalDrugCode;
|
||||
const name = imgModalName;
|
||||
const imageData = capturedImageData;
|
||||
|
||||
closeImageModal();
|
||||
showToast(`"${name}" 이미지 저장 중...`, 'info');
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/admin/product-images/${code}/upload`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
image_data: imageData,
|
||||
product_name: name,
|
||||
drug_code: imgModalDrugCode
|
||||
})
|
||||
});
|
||||
const data = await res.json();
|
||||
|
||||
if (data.success) {
|
||||
showToast('✅ 이미지 저장 완료!', 'success');
|
||||
loadSalesData(); // 새로고침
|
||||
} else {
|
||||
showToast(data.error || '저장 실패', 'error');
|
||||
}
|
||||
} catch (err) {
|
||||
showToast('오류: ' + err.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function submitImageUrl() {
|
||||
const imageUrl = document.getElementById('imgUrlInput').value.trim();
|
||||
|
||||
if (!imageUrl) {
|
||||
showToast('이미지 URL을 입력하세요', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!imageUrl.startsWith('http')) {
|
||||
showToast('올바른 URL을 입력하세요', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const code = imgModalBarcode || imgModalDrugCode;
|
||||
const name = imgModalName;
|
||||
|
||||
closeImageModal();
|
||||
showToast(`"${name}" 이미지 다운로드 중...`, 'info');
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/admin/product-images/${code}/replace`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
image_url: imageUrl,
|
||||
product_name: name,
|
||||
drug_code: imgModalDrugCode
|
||||
})
|
||||
});
|
||||
const data = await res.json();
|
||||
|
||||
if (data.success) {
|
||||
showToast('✅ 이미지 등록 완료!', 'success');
|
||||
loadSalesData(); // 새로고침
|
||||
} else {
|
||||
showToast(data.error || '등록 실패', 'error');
|
||||
}
|
||||
} catch (err) {
|
||||
showToast('오류: ' + err.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
function showToast(message, type = 'info') {
|
||||
const toast = document.createElement('div');
|
||||
toast.style.cssText = `
|
||||
position: fixed;
|
||||
bottom: 24px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
padding: 12px 24px;
|
||||
background: ${type === 'success' ? '#10b981' : type === 'error' ? '#ef4444' : '#6366f1'};
|
||||
color: white;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
z-index: 2000;
|
||||
animation: fadeIn 0.3s;
|
||||
`;
|
||||
toast.textContent = message;
|
||||
document.body.appendChild(toast);
|
||||
setTimeout(() => toast.remove(), 3000);
|
||||
}
|
||||
|
||||
// 모달 외부 클릭시 닫기
|
||||
document.getElementById('imageModal').addEventListener('click', e => {
|
||||
if (e.target.id === 'imageModal') closeImageModal();
|
||||
});
|
||||
</script>
|
||||
|
||||
<!-- 이미지 교체 모달 -->
|
||||
<div class="image-modal" id="imageModal">
|
||||
<div class="image-modal-content">
|
||||
<h3>📷 제품 이미지 등록</h3>
|
||||
<div style="background: rgba(139,92,246,0.1); border-radius: 8px; padding: 12px; margin-bottom: 16px;">
|
||||
<div style="font-weight: 600;" id="imgModalProductName">제품명</div>
|
||||
<div style="font-size: 12px; color: var(--text-muted); font-family: monospace;" id="imgModalCode">코드</div>
|
||||
</div>
|
||||
|
||||
<div class="image-modal-tabs">
|
||||
<button class="tab-btn active" data-tab="url" onclick="switchImageTab('url')">🔗 URL 입력</button>
|
||||
<button class="tab-btn" data-tab="camera" onclick="switchImageTab('camera')">📸 촬영</button>
|
||||
</div>
|
||||
|
||||
<!-- URL 탭 -->
|
||||
<div class="tab-content active" id="tabUrl">
|
||||
<input type="text" class="image-input" id="imgUrlInput" placeholder="이미지 URL을 입력하세요...">
|
||||
<div class="modal-btns">
|
||||
<button class="btn-modal secondary" onclick="closeImageModal()">취소</button>
|
||||
<button class="btn-modal primary" onclick="submitImageUrl()">등록하기</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 카메라 탭 -->
|
||||
<div class="tab-content" id="tabCamera">
|
||||
<div class="camera-container">
|
||||
<video id="cameraVideo" autoplay playsinline></video>
|
||||
<canvas id="captureCanvas" style="display:none;"></canvas>
|
||||
<div class="camera-guide" id="cameraGuide">
|
||||
<svg width="100%" height="100%" viewBox="0 0 100 100" preserveAspectRatio="none">
|
||||
<rect x="10" y="10" width="80" height="80" fill="none" stroke="rgba(139,92,246,0.5)" stroke-width="0.5" stroke-dasharray="2,2"/>
|
||||
<path d="M10,10 L20,10 M10,10 L10,20" fill="none" stroke="#a855f7" stroke-width="1"/>
|
||||
<path d="M90,10 L80,10 M90,10 L90,20" fill="none" stroke="#a855f7" stroke-width="1"/>
|
||||
<path d="M10,90 L20,90 M10,90 L10,80" fill="none" stroke="#a855f7" stroke-width="1"/>
|
||||
<path d="M90,90 L80,90 M90,90 L90,80" fill="none" stroke="#a855f7" stroke-width="1"/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-btns" id="captureBtn">
|
||||
<button class="btn-modal secondary" onclick="closeImageModal()">취소</button>
|
||||
<button class="btn-modal primary" onclick="capturePhoto()">📸 촬영</button>
|
||||
</div>
|
||||
<div class="modal-btns" id="previewBtns" style="display:none;">
|
||||
<button class="btn-modal secondary" onclick="retakePhoto()">다시 촬영</button>
|
||||
<button class="btn-modal primary" onclick="submitCapturedImage()">저장하기</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
1080
backend/templates/admin_usage.html
Normal file
@@ -119,6 +119,49 @@
|
||||
letter-spacing: -0.2px;
|
||||
}
|
||||
|
||||
/* 퀵 메뉴 */
|
||||
.quick-menu {
|
||||
display: flex;
|
||||
justify-content: space-around;
|
||||
padding: 20px 16px;
|
||||
background: #fff;
|
||||
margin: 0 16px 16px;
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
.quick-menu-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
text-decoration: none;
|
||||
padding: 8px 12px;
|
||||
border-radius: 12px;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.quick-menu-item:active {
|
||||
background: #f5f5f5;
|
||||
}
|
||||
|
||||
.quick-menu-icon {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 14px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
.quick-menu-item span {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #495057;
|
||||
letter-spacing: -0.3px;
|
||||
}
|
||||
|
||||
.section {
|
||||
padding: 24px;
|
||||
}
|
||||
@@ -301,6 +344,26 @@
|
||||
<div class="balance-desc">약국에서 1P = 1원으로 사용 가능</div>
|
||||
</div>
|
||||
|
||||
<!-- 퀵 메뉴 -->
|
||||
<div class="quick-menu">
|
||||
<a href="/mypage" class="quick-menu-item">
|
||||
<div class="quick-menu-icon" style="background: #fef3c7;">🐾</div>
|
||||
<span>반려동물</span>
|
||||
</a>
|
||||
<a href="#" class="quick-menu-item" onclick="alert('준비중입니다'); return false;">
|
||||
<div class="quick-menu-icon" style="background: #dbeafe;">🎟️</div>
|
||||
<span>쿠폰함</span>
|
||||
</a>
|
||||
<a href="#" class="quick-menu-item" onclick="alert('준비중입니다'); return false;">
|
||||
<div class="quick-menu-icon" style="background: #fce7f3;">📦</div>
|
||||
<span>구매내역</span>
|
||||
</a>
|
||||
<a href="/mypage" class="quick-menu-item">
|
||||
<div class="quick-menu-icon" style="background: #ede9fe;">⚙️</div>
|
||||
<span>내정보</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<div class="section-title">적립 내역</div>
|
||||
|
||||
@@ -403,7 +466,10 @@
|
||||
</div>
|
||||
<div style="padding:0 24px 32px;">
|
||||
<div style="text-align:center;padding:8px 0 20px;">
|
||||
<div style="font-size:48px;margin-bottom:16px;">💊</div>
|
||||
<div id="rec-image-container" style="margin-bottom:20px;width:100%;display:flex;justify-content:center;">
|
||||
<img id="rec-image" style="width:160px;height:auto;border:none;outline:none;display:none;" alt="추천 제품">
|
||||
<div id="rec-emoji" style="font-size:56px;">💊</div>
|
||||
</div>
|
||||
<div id="rec-message" style="color:#343a40;font-size:16px;font-weight:500;line-height:1.6;letter-spacing:-0.3px;margin-bottom:16px;"></div>
|
||||
<div id="rec-product" style="display:inline-block;background:linear-gradient(135deg,#6366f1,#8b5cf6);color:#fff;font-size:14px;font-weight:600;padding:8px 20px;border-radius:20px;letter-spacing:-0.2px;"></div>
|
||||
</div>
|
||||
@@ -512,6 +578,17 @@
|
||||
_recId = data.recommendation.id;
|
||||
document.getElementById('rec-message').textContent = data.recommendation.message;
|
||||
document.getElementById('rec-product').textContent = data.recommendation.product;
|
||||
|
||||
// 제품 이미지 표시
|
||||
if (data.recommendation.image) {
|
||||
document.getElementById('rec-image').src = 'data:image/jpeg;base64,' + data.recommendation.image;
|
||||
document.getElementById('rec-image').style.display = 'block';
|
||||
document.getElementById('rec-emoji').style.display = 'none';
|
||||
} else {
|
||||
document.getElementById('rec-image').style.display = 'none';
|
||||
document.getElementById('rec-emoji').style.display = 'block';
|
||||
}
|
||||
|
||||
document.getElementById('rec-sheet').style.display = 'block';
|
||||
document.getElementById('rec-backdrop').onclick = dismissRec;
|
||||
}
|
||||
|
||||
891
backend/templates/mypage_v2.html
Normal file
@@ -0,0 +1,891 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
|
||||
<meta name="theme-color" content="#6366f1">
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||||
<meta name="apple-mobile-web-app-title" content="청춘약국">
|
||||
<link rel="manifest" href="/static/manifest.json">
|
||||
<link rel="apple-touch-icon" href="/static/icons/icon-192.png">
|
||||
<title>마이페이지 - 청춘약국</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Noto Sans KR', -apple-system, BlinkMacSystemFont, sans-serif;
|
||||
background: #f5f7fa;
|
||||
min-height: 100vh;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
.app-container {
|
||||
background: #ffffff;
|
||||
min-height: 100vh;
|
||||
max-width: 420px;
|
||||
margin: 0 auto;
|
||||
box-shadow: 0 0 20px rgba(0,0,0,0.05);
|
||||
}
|
||||
|
||||
/* 헤더 */
|
||||
.header {
|
||||
background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%);
|
||||
padding: 20px 24px 100px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.header-top {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.header-title {
|
||||
color: white;
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.btn-logout {
|
||||
color: rgba(255,255,255,0.8);
|
||||
font-size: 14px;
|
||||
text-decoration: none;
|
||||
padding: 8px 12px;
|
||||
border-radius: 8px;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.btn-logout:hover {
|
||||
background: rgba(255,255,255,0.1);
|
||||
}
|
||||
|
||||
/* 프로필 카드 */
|
||||
.profile-card {
|
||||
background: white;
|
||||
border-radius: 20px;
|
||||
margin: -80px 16px 16px;
|
||||
padding: 24px;
|
||||
box-shadow: 0 4px 20px rgba(0,0,0,0.08);
|
||||
position: relative;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.profile-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.profile-avatar {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(135deg, #6366f1, #8b5cf6);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 28px;
|
||||
color: white;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.profile-avatar img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.profile-details h2 {
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
color: #1f2937;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.profile-details p {
|
||||
color: #6b7280;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* 통계 그리드 */
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 12px;
|
||||
padding-top: 20px;
|
||||
border-top: 1px solid #f3f4f6;
|
||||
}
|
||||
|
||||
.stat-item {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.stat-icon {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin: 0 auto 8px;
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.stat-icon.purple { background: #ede9fe; }
|
||||
.stat-icon.blue { background: #dbeafe; }
|
||||
.stat-icon.pink { background: #fce7f3; }
|
||||
|
||||
.stat-value {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 12px;
|
||||
color: #9ca3af;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
/* 섹션 */
|
||||
.section {
|
||||
background: white;
|
||||
margin: 16px;
|
||||
border-radius: 16px;
|
||||
padding: 20px;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.04);
|
||||
}
|
||||
|
||||
.section-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
color: #1f2937;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.section-action {
|
||||
color: #6366f1;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
/* 반려동물 카드 */
|
||||
.pet-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 16px;
|
||||
background: #f9fafb;
|
||||
border-radius: 12px;
|
||||
margin-bottom: 12px;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.pet-card:hover {
|
||||
background: #f3f4f6;
|
||||
}
|
||||
|
||||
.pet-photo {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(135deg, #fbbf24, #f59e0b);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 24px;
|
||||
overflow: hidden;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.pet-photo img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.pet-info {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.pet-name {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #1f2937;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.pet-details {
|
||||
font-size: 13px;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.pet-arrow {
|
||||
color: #d1d5db;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
/* 반려동물 추가 버튼 */
|
||||
.add-pet-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
padding: 16px;
|
||||
background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 12px;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: transform 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
|
||||
.add-pet-btn:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(99, 102, 241, 0.4);
|
||||
}
|
||||
|
||||
/* 메뉴 리스트 */
|
||||
.menu-list {
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.menu-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 16px 0;
|
||||
border-bottom: 1px solid #f3f4f6;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.menu-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.menu-item:hover {
|
||||
background: #f9fafb;
|
||||
margin: 0 -20px;
|
||||
padding: 16px 20px;
|
||||
}
|
||||
|
||||
.menu-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.menu-icon {
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.menu-text {
|
||||
font-size: 15px;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.menu-badge {
|
||||
background: #fef3c7;
|
||||
color: #92400e;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
padding: 4px 8px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.menu-arrow {
|
||||
color: #d1d5db;
|
||||
}
|
||||
|
||||
/* 모달 */
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0,0,0,0.5);
|
||||
display: none;
|
||||
align-items: flex-end;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.modal-overlay.active {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
background: white;
|
||||
border-radius: 24px 24px 0 0;
|
||||
width: 100%;
|
||||
max-width: 420px;
|
||||
max-height: 90vh;
|
||||
overflow-y: auto;
|
||||
padding: 24px;
|
||||
animation: slideUp 0.3s ease;
|
||||
}
|
||||
|
||||
@keyframes slideUp {
|
||||
from { transform: translateY(100%); }
|
||||
to { transform: translateY(0); }
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.modal-title {
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
.modal-close {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
background: #f3f4f6;
|
||||
border: none;
|
||||
font-size: 18px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/* 폼 스타일 */
|
||||
.form-group {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.form-label {
|
||||
display: block;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #374151;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.form-input {
|
||||
width: 100%;
|
||||
padding: 14px 16px;
|
||||
border: 2px solid #e5e7eb;
|
||||
border-radius: 12px;
|
||||
font-size: 15px;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
|
||||
.form-input:focus {
|
||||
outline: none;
|
||||
border-color: #6366f1;
|
||||
}
|
||||
|
||||
/* 종류 선택 */
|
||||
.species-options {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.species-option {
|
||||
flex: 1;
|
||||
padding: 20px;
|
||||
border: 2px solid #e5e7eb;
|
||||
border-radius: 16px;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.species-option:hover {
|
||||
border-color: #c7d2fe;
|
||||
}
|
||||
|
||||
.species-option.selected {
|
||||
border-color: #6366f1;
|
||||
background: #eef2ff;
|
||||
}
|
||||
|
||||
.species-option .icon {
|
||||
font-size: 40px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.species-option .label {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
/* 사진 업로드 */
|
||||
.photo-upload {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.photo-preview {
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
border-radius: 50%;
|
||||
background: #f3f4f6;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 48px;
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.photo-preview:hover {
|
||||
background: #e5e7eb;
|
||||
}
|
||||
|
||||
.photo-preview img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.photo-hint {
|
||||
font-size: 13px;
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
/* 제출 버튼 */
|
||||
.submit-btn {
|
||||
width: 100%;
|
||||
padding: 16px;
|
||||
background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 12px;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
margin-top: 24px;
|
||||
transition: transform 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
|
||||
.submit-btn:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(99, 102, 241, 0.4);
|
||||
}
|
||||
|
||||
.submit-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
transform: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
/* 빈 상태 */
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 32px 16px;
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.empty-state .icon {
|
||||
font-size: 48px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.empty-state p {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* 로딩 */
|
||||
.loading {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 40px;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: 3px solid #e5e7eb;
|
||||
border-top-color: #6366f1;
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="app-container">
|
||||
<!-- 헤더 -->
|
||||
<div class="header">
|
||||
<div class="header-top">
|
||||
<h1 class="header-title">마이페이지</h1>
|
||||
<a href="/logout" class="btn-logout">로그아웃</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 프로필 카드 -->
|
||||
<div class="profile-card">
|
||||
<div class="profile-info">
|
||||
<div class="profile-avatar">
|
||||
{% if user.profile_image_url %}
|
||||
<img src="{{ user.profile_image_url }}" alt="프로필">
|
||||
{% else %}
|
||||
😊
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="profile-details">
|
||||
<h2>{{ user.nickname or '회원' }}님</h2>
|
||||
<p>{{ user.phone or '전화번호 미등록' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stats-grid">
|
||||
<div class="stat-item">
|
||||
<div class="stat-icon purple">🎁</div>
|
||||
<div class="stat-value">{{ '{:,}'.format(user.mileage_balance or 0) }}</div>
|
||||
<div class="stat-label">포인트</div>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<div class="stat-icon blue">📦</div>
|
||||
<div class="stat-value">{{ purchase_count or 0 }}</div>
|
||||
<div class="stat-label">구매</div>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<div class="stat-icon pink">🐾</div>
|
||||
<div class="stat-value" id="pet-count">{{ pets|length }}</div>
|
||||
<div class="stat-label">반려동물</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 반려동물 섹션 -->
|
||||
<div class="section">
|
||||
<div class="section-header">
|
||||
<h3 class="section-title">🐾 내 반려동물</h3>
|
||||
</div>
|
||||
|
||||
<div id="pet-list">
|
||||
{% if pets %}
|
||||
{% for pet in pets %}
|
||||
<div class="pet-card" onclick="editPet({{ pet.id }})">
|
||||
<div class="pet-photo">
|
||||
{% if pet.photo_url %}
|
||||
<img src="{{ pet.photo_url }}" alt="{{ pet.name }}">
|
||||
{% else %}
|
||||
{{ '🐕' if pet.species == 'dog' else ('🐈' if pet.species == 'cat' else '🐾') }}
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="pet-info">
|
||||
<div class="pet-name">{{ pet.name }}</div>
|
||||
<div class="pet-details">
|
||||
{{ pet.species_label }}
|
||||
{% if pet.breed %}· {{ pet.breed }}{% endif %}
|
||||
{% if pet.gender %}· {{ '♂' if pet.gender == 'male' else ('♀' if pet.gender == 'female' else '') }}{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<span class="pet-arrow">›</span>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<div class="empty-state">
|
||||
<div class="icon">🐾</div>
|
||||
<p>등록된 반려동물이 없습니다</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<button class="add-pet-btn" onclick="openAddPetModal()">
|
||||
<span>+</span> 반려동물 추가하기
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 메뉴 섹션 -->
|
||||
<div class="section">
|
||||
<ul class="menu-list">
|
||||
<li class="menu-item" onclick="location.href='/my-page?phone={{ user.phone }}'">
|
||||
<div class="menu-left">
|
||||
<span class="menu-icon">📋</span>
|
||||
<span class="menu-text">적립 내역</span>
|
||||
</div>
|
||||
<span class="menu-arrow">›</span>
|
||||
</li>
|
||||
<li class="menu-item">
|
||||
<div class="menu-left">
|
||||
<span class="menu-icon">📦</span>
|
||||
<span class="menu-text">구매 내역</span>
|
||||
<span class="menu-badge">준비중</span>
|
||||
</div>
|
||||
<span class="menu-arrow">›</span>
|
||||
</li>
|
||||
<li class="menu-item">
|
||||
<div class="menu-left">
|
||||
<span class="menu-icon">🎟️</span>
|
||||
<span class="menu-text">쿠폰함</span>
|
||||
<span class="menu-badge">준비중</span>
|
||||
</div>
|
||||
<span class="menu-arrow">›</span>
|
||||
</li>
|
||||
<li class="menu-item">
|
||||
<div class="menu-left">
|
||||
<span class="menu-icon">⚙️</span>
|
||||
<span class="menu-text">내 정보 수정</span>
|
||||
</div>
|
||||
<span class="menu-arrow">›</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 반려동물 추가/수정 모달 -->
|
||||
<div class="modal-overlay" id="petModal">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h2 class="modal-title" id="modalTitle">반려동물 등록</h2>
|
||||
<button class="modal-close" onclick="closeModal()">✕</button>
|
||||
</div>
|
||||
|
||||
<form id="petForm" onsubmit="submitPet(event)">
|
||||
<input type="hidden" id="petId" value="">
|
||||
|
||||
<!-- 종류 선택 -->
|
||||
<div class="form-group">
|
||||
<label class="form-label">종류 *</label>
|
||||
<div class="species-options">
|
||||
<div class="species-option" data-species="dog" onclick="selectSpecies('dog')">
|
||||
<div class="icon">🐕</div>
|
||||
<div class="label">강아지</div>
|
||||
</div>
|
||||
<div class="species-option" data-species="cat" onclick="selectSpecies('cat')">
|
||||
<div class="icon">🐈</div>
|
||||
<div class="label">고양이</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 이름 -->
|
||||
<div class="form-group">
|
||||
<label class="form-label">이름 *</label>
|
||||
<input type="text" class="form-input" id="petName" placeholder="예: 뽀삐" required>
|
||||
</div>
|
||||
|
||||
<!-- 품종 -->
|
||||
<div class="form-group">
|
||||
<label class="form-label">품종</label>
|
||||
<select class="form-input" id="petBreed">
|
||||
<option value="">선택해주세요</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- 성별 -->
|
||||
<div class="form-group">
|
||||
<label class="form-label">성별</label>
|
||||
<select class="form-input" id="petGender">
|
||||
<option value="">선택해주세요</option>
|
||||
<option value="male">남아 ♂</option>
|
||||
<option value="female">여아 ♀</option>
|
||||
<option value="unknown">모름</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- 사진 -->
|
||||
<div class="form-group">
|
||||
<label class="form-label">사진</label>
|
||||
<div class="photo-upload">
|
||||
<div class="photo-preview" id="photoPreview" onclick="document.getElementById('photoInput').click()">
|
||||
📷
|
||||
</div>
|
||||
<input type="file" id="photoInput" accept="image/*" style="display:none" onchange="previewPhoto(event)">
|
||||
<span class="photo-hint">탭하여 사진 추가</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="submit-btn" id="submitBtn">등록하기</button>
|
||||
<button type="button" class="submit-btn" style="background:#ef4444; margin-top:12px; display:none;" id="deleteBtn" onclick="deletePet()">삭제하기</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
let selectedSpecies = '';
|
||||
let currentPetId = null;
|
||||
|
||||
const DOG_BREEDS = ['말티즈', '푸들', '포메라니안', '치와와', '시츄', '요크셔테리어', '비숑프리제', '골든리트리버', '래브라도리트리버', '진돗개', '시바견', '웰시코기', '닥스훈트', '비글', '보더콜리', '프렌치불독', '불독', '슈나우저', '사모예드', '허스키', '믹스견', '기타'];
|
||||
const CAT_BREEDS = ['코리안숏헤어', '페르시안', '러시안블루', '샴', '먼치킨', '랙돌', '브리티쉬숏헤어', '아메리칸숏헤어', '스코티쉬폴드', '노르웨이숲', '메인쿤', '뱅갈', '아비시니안', '터키쉬앙고라', '믹스묘', '기타'];
|
||||
|
||||
function selectSpecies(species) {
|
||||
selectedSpecies = species;
|
||||
document.querySelectorAll('.species-option').forEach(el => {
|
||||
el.classList.toggle('selected', el.dataset.species === species);
|
||||
});
|
||||
|
||||
// 품종 옵션 업데이트
|
||||
const breedSelect = document.getElementById('petBreed');
|
||||
const breeds = species === 'dog' ? DOG_BREEDS : CAT_BREEDS;
|
||||
breedSelect.innerHTML = '<option value="">선택해주세요</option>' +
|
||||
breeds.map(b => `<option value="${b}">${b}</option>`).join('');
|
||||
}
|
||||
|
||||
function openAddPetModal() {
|
||||
currentPetId = null;
|
||||
document.getElementById('modalTitle').textContent = '반려동물 등록';
|
||||
document.getElementById('petId').value = '';
|
||||
document.getElementById('petForm').reset();
|
||||
document.getElementById('photoPreview').innerHTML = '📷';
|
||||
document.getElementById('submitBtn').textContent = '등록하기';
|
||||
document.getElementById('deleteBtn').style.display = 'none';
|
||||
selectedSpecies = '';
|
||||
document.querySelectorAll('.species-option').forEach(el => el.classList.remove('selected'));
|
||||
document.getElementById('petModal').classList.add('active');
|
||||
}
|
||||
|
||||
function editPet(petId) {
|
||||
// TODO: API에서 pet 정보 가져와서 폼에 채우기
|
||||
currentPetId = petId;
|
||||
document.getElementById('modalTitle').textContent = '반려동물 수정';
|
||||
document.getElementById('submitBtn').textContent = '수정하기';
|
||||
document.getElementById('deleteBtn').style.display = 'block';
|
||||
document.getElementById('petModal').classList.add('active');
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
document.getElementById('petModal').classList.remove('active');
|
||||
}
|
||||
|
||||
function previewPhoto(event) {
|
||||
const file = event.target.files[0];
|
||||
if (file) {
|
||||
const reader = new FileReader();
|
||||
reader.onload = function(e) {
|
||||
document.getElementById('photoPreview').innerHTML =
|
||||
`<img src="${e.target.result}" alt="미리보기">`;
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
}
|
||||
|
||||
async function submitPet(event) {
|
||||
event.preventDefault();
|
||||
|
||||
if (!selectedSpecies) {
|
||||
alert('종류를 선택해주세요.');
|
||||
return;
|
||||
}
|
||||
|
||||
const name = document.getElementById('petName').value.trim();
|
||||
if (!name) {
|
||||
alert('이름을 입력해주세요.');
|
||||
return;
|
||||
}
|
||||
|
||||
const btn = document.getElementById('submitBtn');
|
||||
btn.disabled = true;
|
||||
btn.textContent = '처리중...';
|
||||
|
||||
try {
|
||||
const data = {
|
||||
name: name,
|
||||
species: selectedSpecies,
|
||||
breed: document.getElementById('petBreed').value,
|
||||
gender: document.getElementById('petGender').value
|
||||
};
|
||||
|
||||
const url = currentPetId ? `/api/pets/${currentPetId}` : '/api/pets';
|
||||
const method = currentPetId ? 'PUT' : 'POST';
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: method,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
// 사진 업로드
|
||||
const photoInput = document.getElementById('photoInput');
|
||||
if (photoInput.files.length > 0) {
|
||||
const petId = result.pet_id || currentPetId;
|
||||
const formData = new FormData();
|
||||
formData.append('photo', photoInput.files[0]);
|
||||
|
||||
await fetch(`/api/pets/${petId}/photo`, {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
}
|
||||
|
||||
alert(result.message || '저장되었습니다!');
|
||||
location.reload();
|
||||
} else {
|
||||
alert(result.error || '오류가 발생했습니다.');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
alert('서버 오류가 발생했습니다.');
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
btn.textContent = currentPetId ? '수정하기' : '등록하기';
|
||||
}
|
||||
}
|
||||
|
||||
async function deletePet() {
|
||||
if (!currentPetId) return;
|
||||
if (!confirm('정말 삭제하시겠습니까?')) return;
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/pets/${currentPetId}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
alert('삭제되었습니다.');
|
||||
location.reload();
|
||||
} else {
|
||||
alert(result.error || '삭제 실패');
|
||||
}
|
||||
} catch (error) {
|
||||
alert('서버 오류가 발생했습니다.');
|
||||
}
|
||||
}
|
||||
|
||||
// 모달 외부 클릭 시 닫기
|
||||
document.getElementById('petModal').addEventListener('click', function(e) {
|
||||
if (e.target === this) closeModal();
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
3073
backend/templates/pmr.html
Normal file
870
backend/templates/pmr_admin.html
Normal file
@@ -0,0 +1,870 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>PAAI 어드민 - 청춘약국</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body {
|
||||
font-family: 'Segoe UI', 'Malgun Gothic', sans-serif;
|
||||
background: #f3f4f6;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
/* 헤더 */
|
||||
.header {
|
||||
background: linear-gradient(135deg, #10b981, #059669);
|
||||
color: #fff;
|
||||
padding: 20px 30px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
box-shadow: 0 4px 15px rgba(16, 185, 129, 0.3);
|
||||
}
|
||||
.header h1 {
|
||||
font-size: 1.5rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
.header .nav-links {
|
||||
display: flex;
|
||||
gap: 15px;
|
||||
}
|
||||
.header .nav-links a {
|
||||
color: rgba(255,255,255,0.9);
|
||||
text-decoration: none;
|
||||
padding: 8px 16px;
|
||||
border-radius: 8px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.header .nav-links a:hover,
|
||||
.header .nav-links a.active {
|
||||
background: rgba(255,255,255,0.2);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* 메인 컨테이너 */
|
||||
.container {
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
/* 통계 카드 */
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 20px;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
.stat-card {
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
padding: 20px;
|
||||
box-shadow: 0 2px 10px rgba(0,0,0,0.05);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 15px;
|
||||
}
|
||||
.stat-card .icon {
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
border-radius: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
.stat-card .icon.blue { background: #dbeafe; }
|
||||
.stat-card .icon.green { background: #d1fae5; }
|
||||
.stat-card .icon.yellow { background: #fef3c7; }
|
||||
.stat-card .icon.red { background: #fee2e2; }
|
||||
.stat-card .icon.purple { background: #ede9fe; }
|
||||
.stat-card .info { flex: 1; }
|
||||
.stat-card .value { font-size: 1.8rem; font-weight: 700; color: #1f2937; }
|
||||
.stat-card .label { font-size: 0.85rem; color: #6b7280; }
|
||||
|
||||
/* 섹션 */
|
||||
.section {
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 2px 10px rgba(0,0,0,0.05);
|
||||
margin-bottom: 20px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.section-header {
|
||||
background: #f9fafb;
|
||||
padding: 15px 20px;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
.section-header h2 {
|
||||
font-size: 1.1rem;
|
||||
color: #374151;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.section-body {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
/* 필터 */
|
||||
.filters {
|
||||
display: flex;
|
||||
gap: 15px;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.filter-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.filter-group label {
|
||||
font-size: 0.85rem;
|
||||
color: #6b7280;
|
||||
}
|
||||
.filter-group input,
|
||||
.filter-group select {
|
||||
padding: 8px 12px;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 8px;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.filter-group input:focus,
|
||||
.filter-group select:focus {
|
||||
outline: none;
|
||||
border-color: #10b981;
|
||||
box-shadow: 0 0 0 3px rgba(16, 185, 129, 0.1);
|
||||
}
|
||||
.btn {
|
||||
padding: 8px 16px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-size: 0.9rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.btn-primary {
|
||||
background: #10b981;
|
||||
color: #fff;
|
||||
}
|
||||
.btn-primary:hover { background: #059669; }
|
||||
.btn-secondary {
|
||||
background: #e5e7eb;
|
||||
color: #374151;
|
||||
}
|
||||
.btn-secondary:hover { background: #d1d5db; }
|
||||
|
||||
/* 로그 테이블 */
|
||||
.log-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
.log-table th {
|
||||
background: #f9fafb;
|
||||
padding: 12px 15px;
|
||||
text-align: left;
|
||||
font-weight: 600;
|
||||
color: #374151;
|
||||
border-bottom: 2px solid #e5e7eb;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.log-table td {
|
||||
padding: 12px 15px;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
font-size: 0.9rem;
|
||||
color: #4b5563;
|
||||
}
|
||||
.log-table tr:hover { background: #f9fafb; }
|
||||
.log-table .badge {
|
||||
padding: 4px 10px;
|
||||
border-radius: 20px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
.badge-success { background: #d1fae5; color: #065f46; }
|
||||
.badge-error { background: #fee2e2; color: #991b1b; }
|
||||
.badge-pending { background: #fef3c7; color: #92400e; }
|
||||
.badge-severe { background: #fee2e2; color: #dc2626; }
|
||||
.badge-useful { background: #d1fae5; color: #065f46; }
|
||||
.badge-not-useful { background: #fee2e2; color: #991b1b; }
|
||||
.badge-no-feedback { background: #e5e7eb; color: #6b7280; }
|
||||
|
||||
.log-table .actions button {
|
||||
padding: 6px 12px;
|
||||
background: #ede9fe;
|
||||
color: #7c3aed;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
font-size: 0.8rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
.log-table .actions button:hover {
|
||||
background: #ddd6fe;
|
||||
}
|
||||
|
||||
/* 모달 */
|
||||
.modal {
|
||||
display: none;
|
||||
position: fixed;
|
||||
top: 0; left: 0; right: 0; bottom: 0;
|
||||
background: rgba(0,0,0,0.5);
|
||||
z-index: 1000;
|
||||
justify-content: center;
|
||||
align-items: flex-start;
|
||||
padding: 40px 20px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.modal.show { display: flex; }
|
||||
.modal-content {
|
||||
background: #fff;
|
||||
border-radius: 16px;
|
||||
width: 100%;
|
||||
max-width: 900px;
|
||||
box-shadow: 0 25px 50px rgba(0,0,0,0.2);
|
||||
}
|
||||
.modal-header {
|
||||
background: linear-gradient(135deg, #7c3aed, #a855f7);
|
||||
color: #fff;
|
||||
padding: 20px 25px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
border-radius: 16px 16px 0 0;
|
||||
}
|
||||
.modal-header h3 { font-size: 1.2rem; }
|
||||
.modal-close {
|
||||
background: rgba(255,255,255,0.2);
|
||||
border: none;
|
||||
color: #fff;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 50%;
|
||||
font-size: 1.5rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
.modal-close:hover { background: rgba(255,255,255,0.3); }
|
||||
.modal-body {
|
||||
padding: 25px;
|
||||
max-height: 70vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/* 상세 로그 섹션 */
|
||||
.detail-section {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.detail-section-title {
|
||||
font-size: 0.9rem;
|
||||
font-weight: 700;
|
||||
color: #374151;
|
||||
margin-bottom: 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.detail-section-title:hover { color: #10b981; }
|
||||
.detail-section-content {
|
||||
background: #f9fafb;
|
||||
border-radius: 8px;
|
||||
padding: 15px;
|
||||
font-size: 0.85rem;
|
||||
line-height: 1.6;
|
||||
}
|
||||
.detail-section-content.collapsed {
|
||||
display: none;
|
||||
}
|
||||
.detail-section-content pre {
|
||||
background: #1f2937;
|
||||
color: #e5e7eb;
|
||||
padding: 15px;
|
||||
border-radius: 8px;
|
||||
overflow-x: auto;
|
||||
font-family: 'Consolas', 'Monaco', monospace;
|
||||
font-size: 0.8rem;
|
||||
line-height: 1.5;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
.detail-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 120px 1fr;
|
||||
gap: 8px 15px;
|
||||
}
|
||||
.detail-grid dt {
|
||||
color: #6b7280;
|
||||
font-weight: 500;
|
||||
}
|
||||
.detail-grid dd {
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
/* 차트 영역 */
|
||||
.chart-container {
|
||||
height: 200px;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 8px;
|
||||
padding: 20px 0;
|
||||
}
|
||||
.chart-bar {
|
||||
flex: 1;
|
||||
background: linear-gradient(to top, #10b981, #34d399);
|
||||
border-radius: 4px 4px 0 0;
|
||||
min-height: 10px;
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.chart-bar:hover {
|
||||
transform: scaleY(1.05);
|
||||
transform-origin: bottom;
|
||||
}
|
||||
.chart-bar .tooltip {
|
||||
position: absolute;
|
||||
bottom: 100%;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: #1f2937;
|
||||
color: #fff;
|
||||
padding: 8px 12px;
|
||||
border-radius: 6px;
|
||||
font-size: 0.75rem;
|
||||
white-space: nowrap;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
.chart-bar:hover .tooltip { opacity: 1; }
|
||||
.chart-labels {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
.chart-labels span {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
font-size: 0.7rem;
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
/* 로딩 */
|
||||
.loading {
|
||||
text-align: center;
|
||||
padding: 40px;
|
||||
color: #9ca3af;
|
||||
}
|
||||
.spinner {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: 4px solid #e5e7eb;
|
||||
border-top-color: #10b981;
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
margin: 0 auto 15px;
|
||||
}
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
|
||||
/* 빈 상태 */
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 60px 20px;
|
||||
color: #9ca3af;
|
||||
}
|
||||
.empty-state .icon { font-size: 3rem; margin-bottom: 15px; }
|
||||
|
||||
/* 반응형 */
|
||||
@media (max-width: 768px) {
|
||||
.stats-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
.filters {
|
||||
flex-direction: column;
|
||||
}
|
||||
.log-table {
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
.log-table th, .log-table td {
|
||||
padding: 8px 10px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<!-- 헤더 -->
|
||||
<header class="header">
|
||||
<h1>🤖 PAAI 어드민</h1>
|
||||
<nav class="nav-links">
|
||||
<a href="/pmr" class="active">← 조제관리</a>
|
||||
<a href="#" onclick="refreshData()">🔄 새로고침</a>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<!-- 메인 -->
|
||||
<div class="container">
|
||||
<!-- 통계 카드 -->
|
||||
<div class="stats-grid" id="statsGrid">
|
||||
<div class="stat-card">
|
||||
<div class="icon blue">📊</div>
|
||||
<div class="info">
|
||||
<div class="value" id="statTotal">-</div>
|
||||
<div class="label">총 분석</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="icon green">📅</div>
|
||||
<div class="info">
|
||||
<div class="value" id="statToday">-</div>
|
||||
<div class="label">오늘</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="icon purple">👍</div>
|
||||
<div class="info">
|
||||
<div class="value" id="statUseful">-</div>
|
||||
<div class="label">유용 평가율</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="icon yellow">⚠️</div>
|
||||
<div class="info">
|
||||
<div class="value" id="statSevere">-</div>
|
||||
<div class="label">KIMS 경고 (오늘)</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="icon blue">⏱️</div>
|
||||
<div class="info">
|
||||
<div class="value" id="statAvgTime">-</div>
|
||||
<div class="label">평균 응답시간</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 일별 통계 차트 -->
|
||||
<div class="section">
|
||||
<div class="section-header">
|
||||
<h2>📈 일별 분석 추이 (최근 14일)</h2>
|
||||
</div>
|
||||
<div class="section-body">
|
||||
<div class="chart-container" id="dailyChart"></div>
|
||||
<div class="chart-labels" id="chartLabels"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 분석 이력 -->
|
||||
<div class="section">
|
||||
<div class="section-header">
|
||||
<h2>📋 분석 이력</h2>
|
||||
</div>
|
||||
<div class="section-body">
|
||||
<!-- 필터 -->
|
||||
<div class="filters">
|
||||
<div class="filter-group">
|
||||
<label>날짜:</label>
|
||||
<input type="date" id="filterDate">
|
||||
</div>
|
||||
<div class="filter-group">
|
||||
<label>환자명:</label>
|
||||
<input type="text" id="filterPatient" placeholder="검색...">
|
||||
</div>
|
||||
<div class="filter-group">
|
||||
<label>상태:</label>
|
||||
<select id="filterStatus">
|
||||
<option value="">전체</option>
|
||||
<option value="success">성공</option>
|
||||
<option value="error">에러</option>
|
||||
<option value="pending">대기중</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="filter-group">
|
||||
<label>KIMS 경고:</label>
|
||||
<select id="filterSevere">
|
||||
<option value="">전체</option>
|
||||
<option value="true">있음</option>
|
||||
<option value="false">없음</option>
|
||||
</select>
|
||||
</div>
|
||||
<button class="btn btn-primary" onclick="loadLogs()">검색</button>
|
||||
<button class="btn btn-secondary" onclick="clearFilters()">초기화</button>
|
||||
</div>
|
||||
|
||||
<!-- 테이블 -->
|
||||
<div id="logsContainer">
|
||||
<div class="loading">
|
||||
<div class="spinner"></div>
|
||||
<div>로딩 중...</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 상세 모달 -->
|
||||
<div class="modal" id="detailModal">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h3 id="modalTitle">📋 분석 상세</h3>
|
||||
<button class="modal-close" onclick="closeModal()">×</button>
|
||||
</div>
|
||||
<div class="modal-body" id="modalBody">
|
||||
<div class="loading">
|
||||
<div class="spinner"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// 초기화
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
loadStats();
|
||||
loadDailyStats();
|
||||
loadLogs();
|
||||
});
|
||||
|
||||
// 통계 로드
|
||||
async function loadStats() {
|
||||
try {
|
||||
const res = await fetch('/pmr/api/admin/stats');
|
||||
const data = await res.json();
|
||||
|
||||
if (data.success) {
|
||||
const s = data.stats;
|
||||
document.getElementById('statTotal').textContent = s.total.toLocaleString();
|
||||
document.getElementById('statToday').textContent = s.today;
|
||||
document.getElementById('statSevere').textContent = s.severe_count;
|
||||
document.getElementById('statAvgTime').textContent = (s.avg_response_time / 1000).toFixed(1) + '초';
|
||||
|
||||
if (s.feedback && s.feedback.total > 0) {
|
||||
document.getElementById('statUseful').textContent = s.feedback.rate + '%';
|
||||
} else {
|
||||
document.getElementById('statUseful').textContent = '-';
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Stats error:', err);
|
||||
}
|
||||
}
|
||||
|
||||
// 일별 통계 로드
|
||||
async function loadDailyStats() {
|
||||
try {
|
||||
const res = await fetch('/pmr/api/admin/feedback-stats');
|
||||
const data = await res.json();
|
||||
|
||||
if (data.success && data.stats.length > 0) {
|
||||
renderChart(data.stats.slice(0, 14).reverse());
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Daily stats error:', err);
|
||||
}
|
||||
}
|
||||
|
||||
// 차트 렌더링
|
||||
function renderChart(stats) {
|
||||
const container = document.getElementById('dailyChart');
|
||||
const labels = document.getElementById('chartLabels');
|
||||
|
||||
const maxTotal = Math.max(...stats.map(s => s.total), 1);
|
||||
|
||||
container.innerHTML = stats.map(s => {
|
||||
const height = Math.max((s.total / maxTotal) * 100, 5);
|
||||
const usefulPct = s.total > 0 ? Math.round((s.useful / s.total) * 100) : 0;
|
||||
return `
|
||||
<div class="chart-bar" style="height: ${height}%">
|
||||
<div class="tooltip">
|
||||
${s.date.slice(5)}<br>
|
||||
분석: ${s.total}건<br>
|
||||
유용: ${usefulPct}%<br>
|
||||
경고: ${s.severe}건
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
|
||||
labels.innerHTML = stats.map(s => `<span>${s.date.slice(5)}</span>`).join('');
|
||||
}
|
||||
|
||||
// 로그 로드
|
||||
async function loadLogs() {
|
||||
const container = document.getElementById('logsContainer');
|
||||
container.innerHTML = '<div class="loading"><div class="spinner"></div><div>로딩 중...</div></div>';
|
||||
|
||||
try {
|
||||
const params = new URLSearchParams();
|
||||
|
||||
const date = document.getElementById('filterDate').value;
|
||||
const patient = document.getElementById('filterPatient').value;
|
||||
const status = document.getElementById('filterStatus').value;
|
||||
const severe = document.getElementById('filterSevere').value;
|
||||
|
||||
if (date) params.append('date', date);
|
||||
if (patient) params.append('patient_name', patient);
|
||||
if (status) params.append('status', status);
|
||||
if (severe) params.append('has_severe', severe);
|
||||
params.append('limit', '100');
|
||||
|
||||
const res = await fetch('/pmr/api/admin/logs?' + params.toString());
|
||||
const data = await res.json();
|
||||
|
||||
if (data.success) {
|
||||
renderLogs(data.logs);
|
||||
} else {
|
||||
container.innerHTML = '<div class="empty-state"><div class="icon">⚠️</div><div>로드 실패</div></div>';
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Logs error:', err);
|
||||
container.innerHTML = '<div class="empty-state"><div class="icon">⚠️</div><div>오류 발생</div></div>';
|
||||
}
|
||||
}
|
||||
|
||||
// 로그 테이블 렌더링
|
||||
function renderLogs(logs) {
|
||||
const container = document.getElementById('logsContainer');
|
||||
|
||||
if (logs.length === 0) {
|
||||
container.innerHTML = '<div class="empty-state"><div class="icon">📭</div><div>분석 이력이 없습니다</div></div>';
|
||||
return;
|
||||
}
|
||||
|
||||
container.innerHTML = `
|
||||
<table class="log-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>#</th>
|
||||
<th>일시</th>
|
||||
<th>환자</th>
|
||||
<th>약품수</th>
|
||||
<th>KIMS</th>
|
||||
<th>상태</th>
|
||||
<th>피드백</th>
|
||||
<th>응답시간</th>
|
||||
<th>상세</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${logs.map(log => {
|
||||
// DB는 UTC로 저장 → 'Z' 붙여서 UTC로 해석 → KST로 표시
|
||||
const date = new Date(log.created_at + 'Z');
|
||||
const dateStr = date.toLocaleString('ko-KR', {
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
});
|
||||
|
||||
const statusBadge = {
|
||||
'success': '<span class="badge badge-success">성공</span>',
|
||||
'error': '<span class="badge badge-error">에러</span>',
|
||||
'pending': '<span class="badge badge-pending">대기</span>',
|
||||
'kims_done': '<span class="badge badge-pending">AI 대기</span>'
|
||||
}[log.status] || log.status;
|
||||
|
||||
let feedbackBadge = '<span class="badge badge-no-feedback">-</span>';
|
||||
if (log.feedback_useful === 1) {
|
||||
feedbackBadge = '<span class="badge badge-useful">👍</span>';
|
||||
} else if (log.feedback_useful === 0) {
|
||||
feedbackBadge = '<span class="badge badge-not-useful">👎</span>';
|
||||
}
|
||||
|
||||
const kimsInfo = log.kims_has_severe
|
||||
? `<span class="badge badge-severe">⚠️ ${log.kims_interaction_count}건</span>`
|
||||
: (log.kims_interaction_count > 0 ? `${log.kims_interaction_count}건` : '-');
|
||||
|
||||
const responseTime = log.ai_response_time_ms
|
||||
? (log.ai_response_time_ms / 1000).toFixed(1) + '초'
|
||||
: '-';
|
||||
|
||||
return `
|
||||
<tr>
|
||||
<td>${log.id}</td>
|
||||
<td>${dateStr}</td>
|
||||
<td>${log.patient_name || '-'}</td>
|
||||
<td>${log.current_med_count || 0}</td>
|
||||
<td>${kimsInfo}</td>
|
||||
<td>${statusBadge}</td>
|
||||
<td>${feedbackBadge}</td>
|
||||
<td>${responseTime}</td>
|
||||
<td class="actions">
|
||||
<button onclick="showDetail(${log.id})">상세</button>
|
||||
</td>
|
||||
</tr>
|
||||
`;
|
||||
}).join('')}
|
||||
</tbody>
|
||||
</table>
|
||||
`;
|
||||
}
|
||||
|
||||
// 필터 초기화
|
||||
function clearFilters() {
|
||||
document.getElementById('filterDate').value = '';
|
||||
document.getElementById('filterPatient').value = '';
|
||||
document.getElementById('filterStatus').value = '';
|
||||
document.getElementById('filterSevere').value = '';
|
||||
loadLogs();
|
||||
}
|
||||
|
||||
// 상세 보기
|
||||
async function showDetail(logId) {
|
||||
const modal = document.getElementById('detailModal');
|
||||
const body = document.getElementById('modalBody');
|
||||
const title = document.getElementById('modalTitle');
|
||||
|
||||
modal.classList.add('show');
|
||||
body.innerHTML = '<div class="loading"><div class="spinner"></div></div>';
|
||||
|
||||
try {
|
||||
const res = await fetch(`/pmr/api/admin/log/${logId}`);
|
||||
const data = await res.json();
|
||||
|
||||
if (data.success) {
|
||||
renderDetail(data.log);
|
||||
} else {
|
||||
body.innerHTML = '<div class="empty-state">로드 실패</div>';
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Detail error:', err);
|
||||
body.innerHTML = '<div class="empty-state">오류 발생</div>';
|
||||
}
|
||||
}
|
||||
|
||||
// 상세 렌더링
|
||||
function renderDetail(log) {
|
||||
const body = document.getElementById('modalBody');
|
||||
const title = document.getElementById('modalTitle');
|
||||
|
||||
title.textContent = `📋 분석 상세 - ${log.patient_name || '환자'}님 (#${log.id})`;
|
||||
|
||||
// 약품 목록 포맷
|
||||
let medsHtml = '-';
|
||||
if (log.current_medications && log.current_medications.length > 0) {
|
||||
medsHtml = log.current_medications.map(m =>
|
||||
`${m.name || m.code} (${m.dosage || '-'} × ${m.frequency || '-'}회 × ${m.days || '-'}일)`
|
||||
).join('<br>');
|
||||
}
|
||||
|
||||
// 피드백 상태
|
||||
let feedbackHtml = '<span class="badge badge-no-feedback">없음</span>';
|
||||
if (log.feedback_useful === 1) {
|
||||
feedbackHtml = '<span class="badge badge-useful">👍 유용해요</span>';
|
||||
} else if (log.feedback_useful === 0) {
|
||||
feedbackHtml = '<span class="badge badge-not-useful">👎 아니요</span>';
|
||||
}
|
||||
|
||||
body.innerHTML = `
|
||||
<!-- 기본 정보 -->
|
||||
<div class="detail-section">
|
||||
<div class="detail-section-title" onclick="toggleSection(this)">
|
||||
▼ 환자/처방 정보
|
||||
</div>
|
||||
<div class="detail-section-content">
|
||||
<dl class="detail-grid">
|
||||
<dt>처방번호</dt><dd>${log.pre_serial || '-'}</dd>
|
||||
<dt>환자코드</dt><dd>${log.patient_code || '-'}</dd>
|
||||
<dt>환자명</dt><dd>${log.patient_name || '-'}</dd>
|
||||
<dt>질병 1</dt><dd>[${log.disease_code_1 || '-'}] ${log.disease_name_1 || '-'}</dd>
|
||||
<dt>질병 2</dt><dd>[${log.disease_code_2 || '-'}] ${log.disease_name_2 || '-'}</dd>
|
||||
<dt>약품</dt><dd>${medsHtml}</dd>
|
||||
<dt>분석일시</dt><dd>${new Date(log.created_at + 'Z').toLocaleString('ko-KR')}</dd>
|
||||
<dt>상태</dt><dd>${log.status}</dd>
|
||||
<dt>피드백</dt><dd>${feedbackHtml}</dd>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- KIMS 결과 -->
|
||||
<div class="detail-section">
|
||||
<div class="detail-section-title" onclick="toggleSection(this)">
|
||||
▼ KIMS 상호작용 (${log.kims_response_time_ms || 0}ms)
|
||||
</div>
|
||||
<div class="detail-section-content">
|
||||
<p><strong>조회 약품:</strong> ${(log.kims_drug_codes || []).join(', ') || '-'}</p>
|
||||
<p><strong>상호작용:</strong> ${log.kims_interaction_count || 0}건 ${log.kims_has_severe ? '⚠️ 중증 포함' : ''}</p>
|
||||
${log.kims_interactions && log.kims_interactions.length > 0 ? `
|
||||
<pre>${JSON.stringify(log.kims_interactions, null, 2)}</pre>
|
||||
` : ''}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- AI 프롬프트 -->
|
||||
<div class="detail-section">
|
||||
<div class="detail-section-title" onclick="toggleSection(this)">
|
||||
▶ AI 프롬프트 (클릭하여 펼치기)
|
||||
</div>
|
||||
<div class="detail-section-content collapsed">
|
||||
<pre>${escapeHtml(log.ai_prompt || '없음')}</pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- AI 응답 -->
|
||||
<div class="detail-section">
|
||||
<div class="detail-section-title" onclick="toggleSection(this)">
|
||||
▼ AI 응답 (${log.ai_response_time_ms || 0}ms, ${log.ai_model || '-'})
|
||||
</div>
|
||||
<div class="detail-section-content">
|
||||
<pre>${JSON.stringify(log.ai_response, null, 2) || '없음'}</pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
${log.error_message ? `
|
||||
<div class="detail-section">
|
||||
<div class="detail-section-title" style="color: #dc2626;">
|
||||
⚠️ 에러 메시지
|
||||
</div>
|
||||
<div class="detail-section-content" style="background: #fee2e2;">
|
||||
${escapeHtml(log.error_message)}
|
||||
</div>
|
||||
</div>
|
||||
` : ''}
|
||||
`;
|
||||
}
|
||||
|
||||
// 섹션 토글
|
||||
function toggleSection(titleEl) {
|
||||
const content = titleEl.nextElementSibling;
|
||||
const isCollapsed = content.classList.contains('collapsed');
|
||||
|
||||
content.classList.toggle('collapsed');
|
||||
titleEl.textContent = titleEl.textContent.replace(/^[▼▶]/, isCollapsed ? '▼' : '▶');
|
||||
}
|
||||
|
||||
// 모달 닫기
|
||||
function closeModal() {
|
||||
document.getElementById('detailModal').classList.remove('show');
|
||||
}
|
||||
|
||||
// 데이터 새로고침
|
||||
function refreshData() {
|
||||
loadStats();
|
||||
loadDailyStats();
|
||||
loadLogs();
|
||||
}
|
||||
|
||||
// HTML 이스케이프
|
||||
function escapeHtml(text) {
|
||||
if (!text) return '';
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
// 모달 외부 클릭 시 닫기
|
||||
document.getElementById('detailModal').addEventListener('click', (e) => {
|
||||
if (e.target === e.currentTarget) closeModal();
|
||||
});
|
||||
|
||||
// ESC 키로 모달 닫기
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape') closeModal();
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
26
backend/test_all_orders.py
Normal file
@@ -0,0 +1,26 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
import requests
|
||||
|
||||
print('=== 주문량 API 테스트 (지오영 + 수인 + 백제) ===')
|
||||
|
||||
date = '2026-03-07'
|
||||
|
||||
# 지오영
|
||||
geo = requests.get(f'http://localhost:7001/api/geoyoung/orders/summary-by-kd?start_date={date}&end_date={date}', timeout=60).json()
|
||||
geo_count = len(geo.get('by_kd_code', {}))
|
||||
print(f'지오영: {"OK" if geo.get("success") else "FAIL"} - {geo_count}개 품목')
|
||||
|
||||
# 수인
|
||||
sooin = requests.get(f'http://localhost:7001/api/sooin/orders/summary-by-kd?start_date={date}&end_date={date}', timeout=60).json()
|
||||
sooin_count = len(sooin.get('by_kd_code', {}))
|
||||
print(f'수인: {"OK" if sooin.get("success") else "FAIL"} - {sooin_count}개 품목')
|
||||
|
||||
# 백제
|
||||
baekje = requests.get(f'http://localhost:7001/api/baekje/orders/summary-by-kd?start_date={date}&end_date={date}', timeout=60).json()
|
||||
baekje_count = len(baekje.get('by_kd_code', {}))
|
||||
print(f'백제: {"OK" if baekje.get("success") else "FAIL"} - {baekje_count}개 품목')
|
||||
if baekje.get('message'):
|
||||
print(f' 메시지: {baekje.get("message")}')
|
||||
|
||||
print()
|
||||
print(f'총 품목: {geo_count + sooin_count + baekje_count}개')
|
||||
60
backend/test_api_debug.py
Normal file
@@ -0,0 +1,60 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""API 직접 테스트 - 디버그용"""
|
||||
import requests
|
||||
import json
|
||||
|
||||
# 지오영에서 실제 품목 검색해서 internal_code 얻기
|
||||
import sys; sys.path.insert(0, '.'); import wholesale_path
|
||||
from wholesale import GeoYoungSession
|
||||
|
||||
g = GeoYoungSession()
|
||||
g.login()
|
||||
|
||||
# 재고 있는 품목 검색
|
||||
r = g.search_products('라식스')
|
||||
if r.get('items'):
|
||||
item = r['items'][0]
|
||||
print("="*60)
|
||||
print("검색된 품목:")
|
||||
print(f" name: {item['name']}")
|
||||
print(f" internal_code: {item['internal_code']}")
|
||||
print(f" stock: {item['stock']}")
|
||||
print(f" price: {item['price']}")
|
||||
print("="*60)
|
||||
|
||||
# API 호출
|
||||
payload = {
|
||||
"wholesaler_id": "geoyoung",
|
||||
"items": [{
|
||||
"drug_code": "652100200",
|
||||
"kd_code": "라식스",
|
||||
"internal_code": item['internal_code'], # 검색된 internal_code 사용
|
||||
"product_name": item['name'],
|
||||
"manufacturer": "한독",
|
||||
"specification": item.get('spec', ''),
|
||||
"order_qty": 1,
|
||||
"usage_qty": 100,
|
||||
"current_stock": 0
|
||||
}],
|
||||
"reference_period": "2026-02-01~2026-03-07",
|
||||
"dry_run": False,
|
||||
"cart_only": False
|
||||
}
|
||||
|
||||
print("\n" + "="*60)
|
||||
print("API 요청:")
|
||||
print(json.dumps(payload, ensure_ascii=False, indent=2))
|
||||
print("="*60)
|
||||
|
||||
response = requests.post(
|
||||
'http://localhost:7001/api/order/quick-submit',
|
||||
json=payload,
|
||||
timeout=60
|
||||
)
|
||||
|
||||
print("\n" + "="*60)
|
||||
print(f"응답 (status: {response.status_code}):")
|
||||
print(json.dumps(response.json(), ensure_ascii=False, indent=2))
|
||||
print("="*60)
|
||||
else:
|
||||
print("품목을 찾을 수 없습니다")
|
||||
52
backend/test_checkbox.py
Normal file
@@ -0,0 +1,52 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
import sys; sys.path.insert(0, '.'); import wholesale_path
|
||||
from wholesale import SooinSession
|
||||
from bs4 import BeautifulSoup
|
||||
import re
|
||||
|
||||
s = SooinSession()
|
||||
s.login()
|
||||
s.clear_cart()
|
||||
|
||||
result = s.search_products('코자정')
|
||||
product = result['items'][0]
|
||||
s.add_to_cart(product['internal_code'], qty=1, price=product['price'], stock=product['stock'])
|
||||
|
||||
resp = s.session.get(f'{s.BAG_VIEW_URL}?currVenCd={s.vendor_code}', timeout=15)
|
||||
soup = BeautifulSoup(resp.content, 'html.parser')
|
||||
form = soup.find('form', {'id': 'frmBag'})
|
||||
|
||||
form_data = {}
|
||||
for inp in form.find_all('input'):
|
||||
name = inp.get('name', '')
|
||||
if not name: continue
|
||||
inp_type = inp.get('type', '').lower()
|
||||
if inp_type == 'checkbox':
|
||||
# 체크박스는 'on' 값으로 전송!
|
||||
form_data[name] = 'on'
|
||||
else:
|
||||
form_data[name] = inp.get('value', '')
|
||||
|
||||
form_data['kind'] = 'order'
|
||||
form_data['x'] = '10'
|
||||
form_data['y'] = '10'
|
||||
|
||||
print('체크박스 포함된 form_data:')
|
||||
print(f" chk_0: {form_data.get('chk_0')}")
|
||||
|
||||
resp = s.session.post(s.BAG_URL, data=form_data, timeout=30)
|
||||
alert_match = re.search(r'alert\("([^"]*)"\)', resp.text)
|
||||
alert_msg = alert_match.group(1) if alert_match else 'N/A'
|
||||
print(f'alert 메시지: {alert_msg}')
|
||||
|
||||
# 장바구니 확인
|
||||
resp2 = s.session.get(f'{s.BAG_VIEW_URL}?currVenCd={s.vendor_code}', timeout=15)
|
||||
soup2 = BeautifulSoup(resp2.content, 'html.parser')
|
||||
int_array = soup2.find('input', {'name': 'intArray'})
|
||||
val = int_array.get('value') if int_array else '없음'
|
||||
print(f'주문 후 intArray: {val}')
|
||||
|
||||
if val == '-1':
|
||||
print('\n🎉 주문 성공!')
|
||||
else:
|
||||
print('\n❌ 주문 실패')
|
||||
42
backend/test_checkbox_html.py
Normal file
@@ -0,0 +1,42 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""체크박스 HTML 상태 확인"""
|
||||
import sys; sys.path.insert(0, '.'); import wholesale_path
|
||||
from wholesale import SooinSession
|
||||
from bs4 import BeautifulSoup
|
||||
import re
|
||||
|
||||
s = SooinSession()
|
||||
s.login()
|
||||
s.clear_cart()
|
||||
|
||||
# 품목 담기
|
||||
r1 = s.search_products('코자정')
|
||||
p1 = r1['items'][0]
|
||||
s.add_to_cart(p1['internal_code'], qty=1, price=p1['price'], stock=p1['stock'])
|
||||
|
||||
# 취소하기 전 HTML
|
||||
print('=== 취소 전 HTML ===')
|
||||
resp = s.session.get(f'{s.BAG_VIEW_URL}?currVenCd={s.vendor_code}', timeout=15)
|
||||
soup = BeautifulSoup(resp.content, 'html.parser')
|
||||
for cb in soup.find_all('input', {'type': 'checkbox'}):
|
||||
name = cb.get('name', '')
|
||||
checked = cb.get('checked')
|
||||
print(f"체크박스 {name}: checked={checked}")
|
||||
|
||||
# 취소
|
||||
print('\n=== 취소 실행 ===')
|
||||
s.cancel_item(row_index=0)
|
||||
|
||||
# 취소 후 HTML
|
||||
print('\n=== 취소 후 HTML ===')
|
||||
resp2 = s.session.get(f'{s.BAG_VIEW_URL}?currVenCd={s.vendor_code}', timeout=15)
|
||||
soup2 = BeautifulSoup(resp2.content, 'html.parser')
|
||||
for cb in soup2.find_all('input', {'type': 'checkbox'}):
|
||||
name = cb.get('name', '')
|
||||
checked = cb.get('checked')
|
||||
print(f"체크박스 {name}: checked={checked}")
|
||||
|
||||
# 체크박스 HTML 전체 출력
|
||||
cb = soup2.find('input', {'type': 'checkbox'})
|
||||
if cb:
|
||||
print(f"\n전체 HTML: {cb}")
|
||||