fix: 날짜 표시 UTC → KST 변환 (admin 페이지들)

This commit is contained in:
thug0bin 2026-03-07 11:38:37 +09:00
parent c1fae04344
commit d842c776c9
9 changed files with 642 additions and 11 deletions

View File

@ -0,0 +1,40 @@
import sqlite3
import os
db_path = 'db/paai_logs.db'
print(f"DB 경로: {os.path.abspath(db_path)}")
print(f"파일 존재: {os.path.exists(db_path)}")
conn = sqlite3.connect(db_path)
cursor = conn.cursor()
# 모든 테이블 확인
cursor.execute("SELECT name FROM sqlite_master WHERE type='table'")
all_tables = [t[0] for t in cursor.fetchall()]
print(f"전체 테이블: {all_tables}")
for table in all_tables:
cursor.execute(f"SELECT COUNT(*) FROM {table}")
count = cursor.fetchone()[0]
print(f"\n=== {table}: {count}건 ===")
# 컬럼 정보
cursor.execute(f"PRAGMA table_info({table})")
cols = [c[1] for c in cursor.fetchall()]
print(f"컬럼: {cols}")
# status 컬럼이 있으면 상태별 카운트
if 'status' in cols:
cursor.execute(f"SELECT status, COUNT(*) FROM {table} GROUP BY status")
for row in cursor.fetchall():
print(f" {row[0]}: {row[1]}")
# 최근 5건
cursor.execute(f"SELECT * FROM {table} ORDER BY rowid DESC LIMIT 3")
rows = cursor.fetchall()
if rows:
print(f"최근 3건:")
for row in rows:
print(f" {row}")
conn.close()

6
backend/check_schema.py Normal file
View File

@ -0,0 +1,6 @@
import sqlite3
conn = sqlite3.connect('db/paai_logs.db')
cursor = conn.cursor()
cursor.execute("SELECT sql FROM sqlite_master WHERE type='table' AND name='paai_logs'")
print(cursor.fetchone()[0])
conn.close()

View File

@ -437,3 +437,236 @@ def api_sooin_order_batch():
'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

View File

@ -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'
}) : '';
@ -1075,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'
}) : '';

View File

@ -358,7 +358,8 @@
}
tbody.innerHTML = data.logs.map(log => {
const time = new Date(log.created_at).toLocaleString('ko-KR', {
// 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'
});

View File

@ -37,6 +37,17 @@
min-height: 100vh;
}
/* ══════════════════ 주문량 로딩 ══════════════════ */
.order-loading {
display: inline-block;
color: var(--accent-cyan);
animation: pulse 1s infinite;
}
@keyframes pulse {
0%, 100% { opacity: 0.4; }
50% { opacity: 1; }
}
/* ══════════════════ 헤더 ══════════════════ */
.header {
background: linear-gradient(135deg, #0891b2 0%, #06b6d4 50%, #22d3ee 100%);
@ -825,17 +836,18 @@
<thead>
<tr>
<th class="check-col"><input type="checkbox" class="custom-check" id="checkAll" onchange="toggleCheckAll()"></th>
<th style="width:32%">약품</th>
<th style="width:28%">약품</th>
<th class="center">현재고</th>
<th class="center">처방횟수</th>
<th class="center">투약량</th>
<th class="center" style="color:var(--accent-cyan);">주문량</th>
<th class="right">매출액</th>
<th class="center" style="width:90px">주문수량</th>
</tr>
</thead>
<tbody id="usageTableBody">
<tr>
<td colspan="7">
<td colspan="8">
<div class="loading-state">
<div class="loading-spinner"></div>
<div>데이터 로딩 중...</div>
@ -882,6 +894,7 @@
<script>
let usageData = [];
let cart = [];
let orderDataByKd = {}; // 수인약품 주문량 (KD코드별)
// 초기화
document.addEventListener('DOMContentLoaded', function() {
@ -891,8 +904,42 @@
document.getElementById('endDate').value = todayStr;
loadUsageData();
loadOrderData(); // 수인약품 주문량 로드
});
// ──────────────── 수인약품 주문량 조회 ────────────────
async function loadOrderData() {
const startDate = document.getElementById('startDate').value;
const endDate = document.getElementById('endDate').value;
orderDataLoading = true;
try {
const res = await fetch(`/api/sooin/orders/summary-by-kd?start_date=${startDate}&end_date=${endDate}`);
const data = await res.json();
if (data.success) {
orderDataByKd = data.by_kd_code || {};
console.log('📦 수인약품 주문량:', Object.keys(orderDataByKd).length, '품목,', data.order_count, '건 주문');
}
} catch(err) {
console.warn('주문량 조회 실패:', err);
orderDataByKd = {};
} finally {
orderDataLoading = false;
renderTable(); // 로딩 완료 후 테이블 갱신
}
}
// KD코드로 주문량 조회
let orderDataLoading = true; // 로딩 상태
function getOrderedQty(kdCode) {
if (orderDataLoading) return '<span class="order-loading">···</span>';
const order = orderDataByKd[kdCode];
if (!order) return '-';
return order.units.toLocaleString();
}
// ──────────────── 데이터 로드 ────────────────
function loadUsageData() {
const startDate = document.getElementById('startDate').value;
@ -901,7 +948,7 @@
const sort = document.getElementById('sortSelect').value;
document.getElementById('usageTableBody').innerHTML = `
<tr><td colspan="7">
<tr><td colspan="8">
<div class="loading-state">
<div class="loading-spinner"></div>
<div>데이터 로딩 중...</div>
@ -928,7 +975,7 @@
renderTable();
} else {
document.getElementById('usageTableBody').innerHTML = `
<tr><td colspan="7">
<tr><td colspan="8">
<div class="empty-state">
<div class="empty-icon">⚠️</div>
<div>오류: ${data.error}</div>
@ -938,7 +985,7 @@
})
.catch(err => {
document.getElementById('usageTableBody').innerHTML = `
<tr><td colspan="7">
<tr><td colspan="8">
<div class="empty-state">
<div class="empty-icon"></div>
<div>데이터 로드 실패</div>
@ -953,7 +1000,7 @@
if (usageData.length === 0) {
tbody.innerHTML = `
<tr><td colspan="7">
<tr><td colspan="8">
<div class="empty-state">
<div class="empty-icon">💊</div>
<div>해당 기간 처방 내역이 없습니다</div>
@ -988,6 +1035,7 @@
</td>
<td class="qty-cell" style="color:var(--text-secondary);">${item.prescription_count}건</td>
<td class="qty-cell ${qtyClass}">${item.total_dose}</td>
<td class="qty-cell" style="color:var(--accent-cyan);">${getOrderedQty(item.drug_code)}</td>
<td style="text-align:right;font-family:'JetBrains Mono',monospace;font-size:12px;">
${formatPrice(item.total_amount)}원
</td>

View File

@ -651,7 +651,8 @@
</thead>
<tbody>
${logs.map(log => {
const date = new Date(log.created_at);
// 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',

View File

@ -0,0 +1,60 @@
# -*- coding: utf-8 -*-
import pyodbc
import time
conn_str = (
'DRIVER={ODBC Driver 17 for SQL Server};'
'SERVER=192.168.0.4\\PM2014;'
'DATABASE=PM_PRES;'
'UID=sa;'
'PWD=tmddls214!%(;'
'TrustServerCertificate=yes;'
'Connection Timeout=10'
)
conn = pyodbc.connect(conn_str, timeout=10)
cur = conn.cursor()
# 현재 쿼리 성능 측정
start = time.time()
query = """
WITH PatientUsage AS (
SELECT DISTINCT
P.DrugCode,
M.Paname,
MAX(CASE WHEN M.Indate >= '20260306' AND M.Indate <= '20260306' THEN 1 ELSE 0 END) as used_in_period
FROM PS_sub_pharm P
JOIN PS_main M ON P.PreSerial = M.PreSerial
WHERE M.Indate >= CONVERT(VARCHAR, DATEADD(YEAR, -1, GETDATE()), 112)
GROUP BY P.DrugCode, M.Paname
)
SELECT
PU.DrugCode as drug_code,
COUNT(*) as patient_count,
STUFF((
SELECT ', ' + PU2.Paname
FROM PatientUsage PU2
WHERE PU2.DrugCode = PU.DrugCode
ORDER BY PU2.Paname
FOR XML PATH(''), TYPE
).value('.', 'NVARCHAR(MAX)'), 1, 2, '') as patient_names,
STUFF((
SELECT ', ' + PU3.Paname
FROM PatientUsage PU3
WHERE PU3.DrugCode = PU.DrugCode AND PU3.used_in_period = 1
ORDER BY PU3.Paname
FOR XML PATH(''), TYPE
).value('.', 'NVARCHAR(MAX)'), 1, 2, '') as today_patients
FROM PatientUsage PU
GROUP BY PU.DrugCode
HAVING COUNT(*) <= 3
"""
cur.execute(query)
rows = cur.fetchall()
elapsed = time.time() - start
print(f"결과: {len(rows)}개 약품")
print(f"실행 시간: {elapsed:.2f}")
print(f"약품당: {elapsed/len(rows)*1000:.2f}ms" if rows else "")

241
docs/SOOIN_API.md Normal file
View File

@ -0,0 +1,241 @@
# 수인약품 (Sooin) API 문서
## 개요
- **회사명**: 수인약품
- **웹사이트**: http://sooinpharm.co.kr
- **인증방식**: Playwright 로그인 → requests 쿠키 재사용 (세션 30분 유효)
## 인증 정보 (환경변수)
```
SOOIN_USER_ID=thug0bin
SOOIN_PASSWORD=@Trajet6640
SOOIN_VENDOR_CODE=50911
SOOIN_VENDOR_NAME=청춘약국
```
## 핵심 URL 구조
### 기존 구현된 URL
| URL | 용도 |
|-----|------|
| `/Homepage/intro.asp` | 로그인 페이지 |
| `/Service/Order/Order.asp` | 제품 검색 |
| `/Service/Order/BagOrder.asp` | 장바구니 추가 |
| `/Service/Order/Bag.asp` | 장바구니 조회 |
| `/Service/Order/ControlBag.asp` | 장바구니 항목 제어 (취소/복원) |
| `/Service/Order/OrderEnd.asp` | 주문 전송 (확정) |
| `/Service/Order/PhysicInfo.asp` | 제품 상세 정보 |
| `/Service/SalesLedger/SalesLedger.asp` | 잔고/매출 조회 |
### 🆕 신규 구현 필요 URL (주문 조회)
| URL | 용도 |
|-----|------|
| `/Service/Report/Report.asp` | **주문 내역 목록 조회** |
| `/Service/Report/Report.asp?f=view&orderNum=...` | **주문 상세 조회** |
## 주문 조회 URL 분석
### 주문 목록 URL 예시
```
http://sooinpharm.co.kr/Service/Report/Report.asp?
sDate=2026-03-01 # 시작일
&eDate=2026-03-07 # 종료일
&tx_ven=50911 # 거래처 코드 (SOOIN_VENDOR_CODE)
&currVenNm=청춘약국 # 거래처명 (URL 인코딩됨)
&orderStatus=0 # 주문상태 (0=전체?)
&ListOrder=0 # 정렬 (0=기본)
&PhysicNm= # 제품명 필터 (선택)
&sg= # 미확인
&page=1 # 페이지 번호
```
### 주문 상세 URL 예시
```
http://sooinpharm.co.kr/Service/Report/Report.asp?
f=view # view 모드 (상세 조회)
&orderNum=202603095091177 # 주문번호 (YYYYMMDD + 거래처코드 + 순번?)
&Ifflag= # 미확인
&sDate=2026-03-01
&eDate=2026-03-07
&PhysicNm=
&ListOrder=0
&tx_ven=50911
&currVenNm=청춘약국
&orderStatus=0
&sg=
&page=1
```
### 주문번호 구조 분석
- `202603095091177``20260309` (날짜) + `50911` (거래처코드) + `77` (순번?)
- 형식: `YYYYMMDD` + `VENDOR_CODE` + `SEQ`
## API 엔드포인트
### Flask Blueprint: `/api/sooin/*`
| Method | Endpoint | 설명 |
|--------|----------|------|
| GET | `/api/sooin/stock` | 재고 조회 |
| GET | `/api/sooin/session-status` | 세션 상태 확인 |
| GET | `/api/sooin/balance` | 잔고(미수금) 조회 |
| GET | `/api/sooin/monthly-sales` | 월간 매출 조회 |
| GET | `/api/sooin/cart` | 장바구니 조회 |
| POST | `/api/sooin/cart/clear` | 장바구니 비우기 |
| POST | `/api/sooin/cart/cancel` | 장바구니 항목 취소 |
| POST | `/api/sooin/cart/restore` | 취소 항목 복원 |
| POST | `/api/sooin/order` | 주문 (장바구니 추가) |
| POST | `/api/sooin/confirm` | 주문 확정 |
| POST | `/api/sooin/full-order` | 전체 주문 (검색→담기→확정) |
| POST | `/api/sooin/order-batch` | 일괄 주문 |
| **GET** | **`/api/sooin/orders`** | **✅ 주문 목록 조회** |
| **GET** | **`/api/sooin/orders/<order_num>`** | **✅ 주문 상세 조회** |
| **GET** | **`/api/sooin/orders/today-summary`** | **✅ 오늘 주문 집계** |
## 🆕 신규 구현 목표: 주문 조회 API
### 목적
1. **오늘 주문한 약 목록 조회** - 주문이 정상적으로 들어갔는지 확인
2. **제품별 주문량 집계** - 오늘의 총 주문량을 제품별로 파악
### 구현할 API 엔드포인트
#### 1. 주문 목록 조회
```
GET /api/sooin/orders?start_date=2026-03-01&end_date=2026-03-07
```
**응답 예시:**
```json
{
"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
}
```
#### 2. 주문 상세 조회
```
GET /api/sooin/orders/<order_num>
```
**응답 예시:**
```json
{
"success": true,
"order_num": "202603095091177",
"order_date": "2026-03-09",
"items": [
{
"product_code": "32495", // 수인 내부코드
"kd_code": "073100220", // KD코드 (있으면)
"product_name": "코자정50mg",
"spec": "30T",
"quantity": 2,
"unit_price": 15000,
"amount": 30000
}
],
"total_amount": 125000
}
```
#### 3. 오늘 주문 집계 (제품별)
```
GET /api/sooin/orders/today-summary
```
**응답 예시:**
```json
{
"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
}
```
## 파일 구조
```
pharmacy-wholesale-api/
├── wholesale/
│ ├── __init__.py
│ ├── base.py # 공통 베이스 클래스
│ ├── sooin.py # 수인약품 핵심 로직 ← 여기에 주문 조회 메서드 추가
│ ├── geoyoung.py
│ └── baekje.py
└── .env # 인증 정보
pharmacy-pos-qr-system/
├── backend/
│ ├── app.py # 메인 Flask 앱
│ ├── sooin_api.py # 수인약품 Flask Blueprint ← 여기에 API 엔드포인트 추가
│ ├── wholesale_path.py # wholesale 패키지 경로 설정
│ └── templates/
│ └── admin_rx_usage.html # 프론트엔드 (주문 조회 UI 추가 가능)
└── docs/
└── SOOIN_API.md # 이 문서
```
## 구현 가이드
### 1단계: SooinSession에 메서드 추가 (`wholesale/sooin.py`)
```python
def get_order_list(self, start_date: str, end_date: str) -> dict:
"""주문 목록 조회"""
# /Service/Report/Report.asp 파싱
pass
def get_order_detail(self, order_num: str) -> dict:
"""주문 상세 조회"""
# /Service/Report/Report.asp?f=view&orderNum=... 파싱
pass
def get_today_order_summary(self) -> dict:
"""오늘 주문 제품별 집계"""
# get_order_list + get_order_detail 조합
pass
```
### 2단계: Flask API 엔드포인트 추가 (`sooin_api.py`)
```python
@sooin_bp.route('/orders', methods=['GET'])
def api_sooin_orders():
"""주문 목록 조회"""
pass
@sooin_bp.route('/orders/<order_num>', methods=['GET'])
def api_sooin_order_detail(order_num):
"""주문 상세 조회"""
pass
@sooin_bp.route('/orders/today-summary', methods=['GET'])
def api_sooin_today_summary():
"""오늘 주문 집계"""
pass
```
## 주의사항
1. **인코딩**: 수인약품 사이트는 `euc-kr` 인코딩 사용
2. **세션 유지**: 30분 세션 타임아웃, 자동 재로그인 필요
3. **HTML 파싱**: BeautifulSoup으로 테이블 구조 파싱
4. **에러 처리**: 로그인 실패, 네트워크 오류 등 처리 필요