Compare commits

...

26 Commits

Author SHA1 Message Date
thug0bin
a7bcf46aaa feat(반품관리): 위치 지정 기능 추가
- 위치 뱃지 클릭 시 위치 수정 모달 표시
- '미지정' 뱃지 스타일 (점선 테두리, 클릭 유도)
- 기존 위치 선택 드롭다운 + 직접 입력 가능
- 위치 삭제 기능
- products 페이지와 동일한 API 재활용 (/api/locations, /api/drugs/.../location)
- 다크 테마에 맞는 모달 스타일
- Edit 툴로 부분 수정하여 인코딩 유지
2026-03-08 12:45:06 +09:00
thug0bin
e82f4be4af feat(반품관리): 위치 컬럼 추가
- CSS: .location-badge (노란 배지 스타일)
- 테이블 헤더에 위치 컬럼 추가
- API의 location 필드 활용 (CD_item_position 조인)
- Edit으로 부분 수정하여 인코딩 유지
2026-03-08 11:16:13 +09:00
thug0bin
eda0429a85 fix(반품관리): 인코딩 수정 (UTF-8)
- admin_return_management.html 한글 깨짐 수정
- Python으로 UTF-8 인코딩으로 전체 파일 재작성
- 모든 기능 유지 (입고이력, 위치 컬럼 등)
2026-03-08 11:08:02 +09:00
thug0bin
71d1916efb feat(반품관리): 약품 위치 컬럼 추가
- API: return-candidates에서 CD_item_position 조인하여 위치 정보 반환
- 테이블에 '위치' 컬럼 추가 (노란색 뱃지 스타일)
- 위치 미지정 약품은 '-' 표시
2026-03-08 10:46:43 +09:00
thug0bin
c71c9ad678 feat(반품관리): 약품 더블클릭 시 입고이력 모달 추가
- admin_return_management.html 업데이트:
  - 입고이력 모달 스타일/HTML 추가 (다크테마 적용)
  - tr ondblclick → openPurchaseModal()
  - 도매상 전화번호 클릭 시 복사 기능
  - 테이블 위에 더블클릭 힌트 추가
  - 상태변경 버튼에 event.stopPropagation() 추가
2026-03-08 10:35:48 +09:00
thug0bin
91f8dea5b4 feat(재고): 약품 더블클릭 시 입고이력 모달 추가
- 새 API: GET /api/drugs/<drug_code>/purchase-history
  - WH_sub + WH_main + PM_BASE.CD_custom 조인
  - 도매상명, 입고일, 수량, 단가, 전화번호 반환
- admin_products.html 업데이트:
  - tr ondblclick → openPurchaseModal()
  - 입고이력 모달 UI/스타일 추가
  - 도매상 전화번호 클릭 시 복사 기능
  - 결과 카운트 옆에 더블클릭 힌트 추가
- 기타 onclick에 event.stopPropagation() 추가 (충돌 방지)
2026-03-08 10:33:21 +09:00
thug0bin
d6cf4c2cc1 feat: 반품관리 페이지 추가 2026-03-08 10:03:42 +09:00
thug0bin
09948c234f feat(rx-usage): 선호 도매상 컬럼 추가
- 테이블에 '선호도매상' 컬럼 추가
- 입고장 기반 최다/최근 주문 도매상 표시
- API: /api/order/drugs/preferred-vendors 연동
- Python 스크립트로 안전하게 수정
2026-03-07 23:12:42 +09:00
thug0bin
a23e4bad43 feat: 약품별 선호 도매상 API + delivery_schedules 테이블
- GET /api/order/drug/{code}/preferred-vendor: 약품별 선호 도매상 조회
- POST /api/order/drugs/preferred-vendors: 일괄 조회
- MSSQL 입고장 데이터 활용 (WH_main, WH_sub)
- 최근 주문 도매상 + 최다 주문 도매상 반환

DB:
- delivery_schedules 테이블 생성 (orders.db)
- 도매상별 주문 마감시간/배송 도착시간 관리
2026-03-07 22:49:12 +09:00
thug0bin
1088720081 fix(baekje): /orders/summary-by-kd에서 새 API 사용
- get_order_list(include_details=True)로 한 번에 조회
- 접수 상태(확정 전)도 집계에 포함
- pending_count, approved_count 응답에 추가
2026-03-07 22:20:15 +09:00
thug0bin
497aeee75f feat(baekje): order_api에서 선별 주문 사용
- submit_order_selective로 기존 장바구니 보존
- 지오영/수인과 동일한 방식 적용
2026-03-07 21:42:15 +09:00
thug0bin
0ae4ae66f0 fix(baekje): 장바구니 담기 시 internal_code 사용하도록 수정
- kd_code 대신 internal_code로 장바구니 추가
- internal_code 없으면 검색 후 규격 매칭으로 찾기
- 백제 장바구니 담기 정상 작동 확인
2026-03-07 21:29:00 +09:00
thug0bin
232a77006a fix: 지오영 주문량 집계 시 취소/삭제 상태 제외
- status에 '취소' 또는 '삭제' 포함 시 집계 제외
- 예: '취소(삭제)' 상태
2026-03-07 18:14:00 +09:00
thug0bin
20fc528c2b fix: 조회 버튼 클릭 시 주문량도 갱신 2026-03-07 18:10:11 +09:00
thug0bin
0f69b50c49 fix: D(도즈) 단위는 boxes=units로 처리 (나잘스프레이 등) 2026-03-07 17:49:03 +09:00
thug0bin
dc2a992c12 fix: 규격 파싱 - T/C/P/D 수량 단위 우선, mg/ml 용량 단위 무시
- parse_spec 함수 개선: product_name에서도 수량 단위 추출
- 예: '스틸녹스정10mg(PTP) 14T' → spec='10mg'이어도 14T에서 14 추출
2026-03-07 17:31:18 +09:00
thug0bin
21c8124811 fix: 지오영 summary-by-kd에 KD코드 enrich 추가 2026-03-07 17:08:18 +09:00
thug0bin
33c6cd2d5c feat: 처방약품 사용량 페이지 - 주문량 지오영+수인 합산
- GET /api/geoyoung/orders/summary-by-kd 추가
- admin_rx_usage.html: 두 도매상 병렬 조회 후 합산 표시
- 콘솔에 도매상별 주문량 로깅
2026-03-07 17:07:25 +09:00
thug0bin
e5744e4f0f feat(geoyoung): 주문 조회 API 엔드포인트 추가
- GET /api/geoyoung/order-list: 기간별 주문 목록
- GET /api/geoyoung/order-detail/<order_num>: 주문 상세
- GET /api/geoyoung/order-today: 오늘 주문 요약

수인약품 API와 동일한 엔드포인트 구조
2026-03-07 17:01:22 +09:00
thug0bin
1720c108b5 fix: 상세 모달 날짜도 UTC → KST 변환 2026-03-07 11:40:05 +09:00
thug0bin
d842c776c9 fix: 날짜 표시 UTC → KST 변환 (admin 페이지들) 2026-03-07 11:38:37 +09:00
thug0bin
c1fae04344 docs: PAAI 트러블슈팅 기록 (2026-03-07) 2026-03-07 11:06:07 +09:00
thug0bin
b6d0fadb3c fix: OTC 라벨 모듈 import 경로 수정 (PM2 환경 호환) 2026-03-07 10:44:44 +09:00
thug0bin
ee300f80ca feat: 소수 환자 약품 뱃지 표시
- 1년간 3명 이하 환자만 사용하는 약품에 환자 이름 뱃지 표시
- 조회 기간 내 사용한 환자는 핑크색으로 강조
- 매출액 컬럼명 변경 (약가 → 매출액)
- SUM(DRUPRICE)로 매출액 계산
2026-03-07 00:43:02 +09:00
thug0bin
846883cbfa feat: 주문 모달 한도/매출 표시 및 UI 개선
- 도매상 한도 API 추가 (wholesaler_limits 테이블)
- 다중 도매상 모달: 월 한도 + 실제 월 매출 표시
- 주문 후 예상 사용량 계산 및 경고 표시
- 이모지 대신 로고 이미지 사용
- 약가 → 매출액 헤더 변경
- 매출액 계산: SUM(DRUPRICE)
2026-03-07 00:24:32 +09:00
thug0bin
29597d55fa feat: 주문 모달에 금액 표시 추가 2026-03-07 00:01:02 +09:00
32 changed files with 4210 additions and 364 deletions

View File

@ -6,6 +6,9 @@ Flask 웹 서버 - QR 마일리지 적립
import sys
import os
# 현재 디렉토리를 Python path에 추가 (PM2 실행 시 utils 모듈 찾기 위함)
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
# Windows 콘솔 UTF-8 강제 (한글 깨짐 방지)
if sys.platform == 'win32':
import io
@ -39,9 +42,18 @@ except ImportError:
logging.warning("OpenAI 라이브러리가 설치되지 않았습니다. AI 분석 기능을 사용할 수 없습니다.")
# Path setup
sys.path.insert(0, os.path.dirname(__file__))
BACKEND_DIR = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, BACKEND_DIR)
from db.dbsetup import DatabaseManager
# OTC 라벨 프린터 import
try:
from utils.otc_label_printer import generate_preview_image, print_otc_label
OTC_LABEL_AVAILABLE = True
except ImportError as e:
OTC_LABEL_AVAILABLE = False
logging.warning(f"OTC 라벨 프린터 모듈 로드 실패: {e}")
app = Flask(__name__)
app.secret_key = 'pharmacy-qr-mileage-secret-key-2026'
@ -3627,6 +3639,69 @@ def api_products():
return jsonify({'success': False, 'error': str(e)}), 500
# ==================== 입고이력 API ====================
@app.route('/api/drugs/<drug_code>/purchase-history')
def api_drug_purchase_history(drug_code):
"""
약품 입고이력 조회 API
- WH_sub: 입고 상세 (약품코드, 수량, 단가)
- WH_main: 입고 마스터 (입고일, 도매상코드)
- PM_BASE.CD_custom: 도매상명
"""
try:
drug_session = db_manager.get_session('PM_DRUG')
# 입고이력 조회 (최근 100건)
result = drug_session.execute(text("""
SELECT TOP 100
m.WH_DT_appl as purchase_date,
COALESCE(c.CD_NM_custom, m.WH_BUSINAME, '미확인') as supplier_name,
CAST(COALESCE(s.WH_NM_item_a, 0) AS INT) as quantity,
CAST(COALESCE(s.WH_MY_unit_a, 0) AS INT) as unit_price,
c.CD_TEL_charge1 as supplier_tel
FROM WH_sub s
JOIN WH_main m ON m.WH_NO_stock = s.WH_SR_stock AND m.WH_DT_appl = s.WH_DT_appl
LEFT JOIN PM_BASE.dbo.CD_custom c ON m.WH_CD_cust_sale = c.CD_CD_custom
WHERE s.DrugCode = :drug_code
ORDER BY m.WH_DT_appl DESC
"""), {'drug_code': drug_code})
history = []
for row in result.fetchall():
# 날짜 포맷팅 (YYYYMMDD -> YYYY-MM-DD)
date_str = row.purchase_date or ''
if len(date_str) == 8:
date_str = f"{date_str[:4]}-{date_str[4:6]}-{date_str[6:8]}"
history.append({
'date': date_str,
'supplier': row.supplier_name or '미확인',
'quantity': row.quantity or 0,
'unit_price': row.unit_price or 0,
'supplier_tel': row.supplier_tel or ''
})
# 약품명 조회
name_result = drug_session.execute(text("""
SELECT GoodsName FROM CD_GOODS WHERE DrugCode = :drug_code
"""), {'drug_code': drug_code})
name_row = name_result.fetchone()
drug_name = name_row[0] if name_row else drug_code
return jsonify({
'success': True,
'drug_code': drug_code,
'drug_name': drug_name,
'history': history,
'count': len(history)
})
except Exception as e:
logging.error(f"입고이력 조회 오류: {e}")
return jsonify({'success': False, 'error': str(e)}), 500
# ==================== 위치 정보 API ====================
@app.route('/api/locations')
@ -4088,6 +4163,73 @@ def api_rx_usage():
mssql_session = db_manager.get_session('PM_PRES')
# 1년간 사용 환자 3명 이하 약품의 환자 목록 조회 + 조회 기간 내 사용 여부
patient_query = text("""
WITH PatientUsage AS (
SELECT DISTINCT
P.DrugCode,
M.Paname,
MAX(CASE WHEN M.Indate >= :start_date AND M.Indate <= :end_date 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
""")
patient_rows = mssql_session.execute(patient_query, {
'start_date': start_date_fmt,
'end_date': end_date_fmt
}).fetchall()
patient_map = {row.drug_code: {
'count': row.patient_count,
'names': row.patient_names,
'today': row.today_patients # 오늘 사용한 환자
} for row in patient_rows}
# 조회 기간 내 주문량 조회 (orders.db에서)
import sqlite3
orders_db_path = os.path.join(os.path.dirname(__file__), 'db', 'orders.db')
orders_conn = sqlite3.connect(orders_db_path)
orders_cur = orders_conn.cursor()
# 조회 기간 내 성공한 주문량 집계
orders_cur.execute('''
SELECT
oi.drug_code,
SUM(oi.order_qty) as ordered_qty,
SUM(oi.total_dose) as ordered_dose
FROM order_items oi
JOIN orders o ON oi.order_id = o.id
WHERE o.order_date >= ? AND o.order_date <= ?
AND o.status IN ('submitted', 'success', 'confirmed')
AND oi.status IN ('success', 'pending')
GROUP BY oi.drug_code
''', (start_date, end_date))
ordered_map = {row[0]: {'qty': row[1] or 0, 'dose': row[2] or 0} for row in orders_cur.fetchall()}
orders_conn.close()
# 전문의약품 품목별 사용량 집계 쿼리 (현재고: IM_total.IM_QT_sale_debit, 위치: CD_item_position.CD_NM_sale)
rx_query = text("""
SELECT
@ -4096,7 +4238,7 @@ def api_rx_usage():
ISNULL(G.SplName, '') as supplier,
SUM(ISNULL(P.QUAN, 1)) as total_qty,
SUM(ISNULL(P.QUAN, 1) * ISNULL(P.Days, 1)) as total_dose,
SUM(ISNULL(P.DRUPRICE, 0) * ISNULL(P.QUAN, 1) * ISNULL(P.Days, 1)) as total_amount,
SUM(ISNULL(P.DRUPRICE, 0)) as total_amount, -- DRUPRICE 합계
COUNT(DISTINCT P.PreSerial) as prescription_count,
COALESCE(NULLIF(G.BARCODE, ''), '') as barcode,
ISNULL(IT.IM_QT_sale_debit, 0) as current_stock,
@ -4138,6 +4280,16 @@ def api_rx_usage():
amount = float(row.total_amount or 0)
rx_count = int(row.prescription_count or 0)
# 소수 환자 약품인지 확인 (1년간 3명 이하)
patient_info = patient_map.get(drug_code)
# 조회 기간 내 주문량
order_info = ordered_map.get(drug_code, {'qty': 0, 'dose': 0})
ordered_dose = order_info['dose']
# 잔여 필요량 = 사용량 - 주문량 (음수면 0)
remaining_dose = max(0, dose - ordered_dose)
items.append({
'drug_code': drug_code,
'product_name': product_name,
@ -4149,7 +4301,12 @@ def api_rx_usage():
'prescription_count': rx_count,
'current_stock': int(row.current_stock or 0), # 현재고
'location': row.location or '', # 약국 내 위치
'thumbnail': None
'thumbnail': None,
'patient_count': patient_info['count'] if patient_info else None,
'patient_names': patient_info['names'] if patient_info else None,
'today_patients': patient_info['today'] if patient_info else None,
'ordered_dose': ordered_dose, # 조회 기간 내 주문량
'remaining_dose': remaining_dose # 잔여 필요량
})
total_qty += qty
@ -5140,6 +5297,237 @@ def api_kims_log_detail(log_id):
return jsonify({'success': False, 'error': str(e)})
# ─────────────────────────────────────────────────────────────
# 반품 후보 관리
# ─────────────────────────────────────────────────────────────
@app.route('/admin/return-management')
def admin_return_management():
"""반품 후보 관리 페이지"""
return render_template('admin_return_management.html')
@app.route('/api/return-candidates')
def api_return_candidates():
"""반품 후보 목록 조회 API (가격 정보 포함)"""
import pyodbc
status = request.args.get('status', '')
urgency = request.args.get('urgency', '')
search = request.args.get('search', '')
sort = request.args.get('sort', 'months_desc')
try:
db_path = os.path.join(BACKEND_DIR, 'db', 'orders.db')
conn = sqlite3.connect(db_path)
conn.row_factory = sqlite3.Row
cursor = conn.cursor()
# 기본 쿼리
query = "SELECT * FROM return_candidates WHERE 1=1"
params = []
# 상태 필터
if status:
query += " AND status = ?"
params.append(status)
# 검색어 필터
if search:
query += " AND (drug_name LIKE ? OR drug_code LIKE ?)"
params.extend([f'%{search}%', f'%{search}%'])
# 긴급도 필터
if urgency == 'critical':
query += " AND (months_since_use >= 36 OR months_since_purchase >= 36)"
elif urgency == 'warning':
query += " AND ((months_since_use >= 24 OR months_since_purchase >= 24) AND (COALESCE(months_since_use, 0) < 36 AND COALESCE(months_since_purchase, 0) < 36))"
elif urgency == 'normal':
query += " AND (COALESCE(months_since_use, 0) < 24 AND COALESCE(months_since_purchase, 0) < 24)"
# 정렬
sort_map = {
'months_desc': 'COALESCE(months_since_use, months_since_purchase, 0) DESC',
'months_asc': 'COALESCE(months_since_use, months_since_purchase, 0) ASC',
'stock_desc': 'current_stock DESC',
'name_asc': 'drug_name ASC',
'detected_desc': 'detected_at DESC'
}
query += f" ORDER BY {sort_map.get(sort, 'detected_at DESC')}"
cursor.execute(query, params)
rows = cursor.fetchall()
# 약품코드 목록 추출
drug_codes = [row['drug_code'] for row in rows]
# MSSQL에서 가격 + 위치 정보 조회 (한 번에)
price_map = {}
location_map = {}
if drug_codes:
try:
mssql_conn_str = (
'DRIVER={ODBC Driver 17 for SQL Server};'
'SERVER=192.168.0.4\\PM2014;'
'DATABASE=PM_DRUG;'
'UID=sa;'
'PWD=tmddls214!%(;'
'TrustServerCertificate=yes;'
'Connection Timeout=5'
)
mssql_conn = pyodbc.connect(mssql_conn_str, timeout=5)
mssql_cursor = mssql_conn.cursor()
# IN 절 생성 (SQL Injection 방지를 위해 파라미터화)
# Price가 없으면 Saleprice, Topprice 순으로 fallback
# CD_item_position JOIN으로 위치 정보도 함께 조회
placeholders = ','.join(['?' for _ in drug_codes])
mssql_cursor.execute(f"""
SELECT G.DrugCode,
COALESCE(NULLIF(G.Price, 0), NULLIF(G.Saleprice, 0), NULLIF(G.Topprice, 0), 0) as BestPrice,
ISNULL(POS.CD_NM_sale, '') as Location
FROM CD_GOODS G
LEFT JOIN CD_item_position POS ON G.DrugCode = POS.DrugCode
WHERE G.DrugCode IN ({placeholders})
""", drug_codes)
for row in mssql_cursor.fetchall():
price_map[row[0]] = float(row[1]) if row[1] else 0
location_map[row[0]] = row[2] or ''
mssql_conn.close()
except Exception as e:
logging.warning(f"MSSQL 가격/위치 조회 실패: {e}")
# 전체 데이터 조회 (통계용)
cursor.execute("SELECT drug_code, current_stock, months_since_use, months_since_purchase FROM return_candidates")
all_rows = cursor.fetchall()
# 긴급도별 금액 합계 계산
total_amount = 0
critical_amount = 0
warning_amount = 0
for row in all_rows:
code = row['drug_code']
stock = row['current_stock'] or 0
price = price_map.get(code, 0)
amount = stock * price
months_use = row['months_since_use'] or 0
months_purchase = row['months_since_purchase'] or 0
max_months = max(months_use, months_purchase)
total_amount += amount
if max_months >= 36:
critical_amount += amount
elif max_months >= 24:
warning_amount += amount
items = []
for row in rows:
code = row['drug_code']
stock = row['current_stock'] or 0
price = price_map.get(code, 0)
recoverable = stock * price
items.append({
'id': row['id'],
'drug_code': code,
'drug_name': row['drug_name'],
'current_stock': stock,
'unit_price': price,
'recoverable_amount': recoverable,
'location': location_map.get(code, ''),
'last_prescription_date': row['last_prescription_date'],
'months_since_use': row['months_since_use'],
'last_purchase_date': row['last_purchase_date'],
'months_since_purchase': row['months_since_purchase'],
'status': row['status'],
'decision_reason': row['decision_reason'],
'detected_at': row['detected_at'],
'updated_at': row['updated_at']
})
# 통계 계산
cursor.execute("SELECT COUNT(*) FROM return_candidates WHERE months_since_use >= 36 OR months_since_purchase >= 36")
critical = cursor.fetchone()[0]
cursor.execute("""SELECT COUNT(*) FROM return_candidates
WHERE (months_since_use >= 24 OR months_since_purchase >= 24)
AND (COALESCE(months_since_use, 0) < 36 AND COALESCE(months_since_purchase, 0) < 36)""")
warning = cursor.fetchone()[0]
cursor.execute("SELECT COUNT(*) FROM return_candidates WHERE status = 'pending'")
pending = cursor.fetchone()[0]
cursor.execute("SELECT COUNT(*) FROM return_candidates WHERE status IN ('returned', 'keep', 'disposed', 'resolved')")
processed = cursor.fetchone()[0]
cursor.execute("SELECT COUNT(*) FROM return_candidates")
total = cursor.fetchone()[0]
conn.close()
return jsonify({
'success': True,
'items': items,
'stats': {
'critical': critical,
'warning': warning,
'pending': pending,
'processed': processed,
'total': total,
'total_amount': total_amount,
'critical_amount': critical_amount,
'warning_amount': warning_amount
}
})
except Exception as e:
return jsonify({'success': False, 'error': str(e)}), 500
@app.route('/api/return-candidates/<int:item_id>', methods=['PUT'])
def api_update_return_candidate(item_id):
"""반품 후보 상태 변경 API"""
try:
data = request.get_json()
new_status = data.get('status')
reason = data.get('reason', '')
if not new_status:
return jsonify({'success': False, 'error': '상태가 지정되지 않았습니다'}), 400
valid_statuses = ['pending', 'reviewed', 'returned', 'keep', 'disposed', 'resolved']
if new_status not in valid_statuses:
return jsonify({'success': False, 'error': f'유효하지 않은 상태: {new_status}'}), 400
db_path = os.path.join(BACKEND_DIR, 'db', 'orders.db')
conn = sqlite3.connect(db_path)
cursor = conn.cursor()
cursor.execute("""
UPDATE return_candidates
SET status = ?,
decision_reason = ?,
reviewed_at = datetime('now', 'localtime'),
updated_at = datetime('now', 'localtime')
WHERE id = ?
""", (new_status, reason, item_id))
if cursor.rowcount == 0:
conn.close()
return jsonify({'success': False, 'error': '항목을 찾을 수 없습니다'}), 404
conn.commit()
conn.close()
return jsonify({'success': True, 'message': '상태가 변경되었습니다'})
except Exception as e:
return jsonify({'success': False, 'error': str(e)}), 500
@app.route('/api/kims/interaction-check', methods=['POST'])
def api_kims_interaction_check():
"""
@ -6282,7 +6670,8 @@ def api_preview_otc_label():
dosage_instruction = data.get('dosage_instruction', '')
usage_tip = data.get('usage_tip', '')
from utils.otc_label_printer import generate_preview_image
if not OTC_LABEL_AVAILABLE:
return jsonify({'success': False, 'error': 'OTC 라벨 모듈이 로드되지 않았습니다.'}), 500
preview_url = generate_preview_image(drug_name, effect, dosage_instruction, usage_tip)
@ -6314,7 +6703,8 @@ def api_print_otc_label():
dosage_instruction = data.get('dosage_instruction', '')
usage_tip = data.get('usage_tip', '')
from utils.otc_label_printer import print_otc_label
if not OTC_LABEL_AVAILABLE:
return jsonify({'success': False, 'error': 'OTC 라벨 모듈이 로드되지 않았습니다.'}), 500
success = print_otc_label(drug_name, effect, dosage_instruction, usage_tip)

View File

@ -262,6 +262,148 @@ def api_get_balance():
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():
"""

View File

@ -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()

View File

@ -1,8 +0,0 @@
# -*- coding: utf-8 -*-
import sys; sys.path.insert(0, '.'); import wholesale_path
from wholesale import SooinSession
s = SooinSession()
s.login()
cart = s.get_cart()
print(f"장바구니: {cart['total_items']}개, {cart['total_amount']:,}")

View File

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

View File

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

View File

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

View File

@ -1,23 +0,0 @@
import sqlite3
conn = sqlite3.connect('db/mileage.db')
c = conn.cursor()
# 테이블 구조
c.execute("SELECT sql FROM sqlite_master WHERE name='pets'")
print("=== PETS TABLE SCHEMA ===")
print(c.fetchone())
# 샘플 데이터
c.execute("SELECT * FROM pets LIMIT 5")
print("\n=== SAMPLE DATA ===")
for row in c.fetchall():
print(row)
# 컬럼명
c.execute("PRAGMA table_info(pets)")
print("\n=== COLUMNS ===")
for col in c.fetchall():
print(col)
conn.close()

View File

@ -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')

View File

@ -1,21 +0,0 @@
# -*- coding: utf-8 -*-
import sys; sys.path.insert(0, '.'); import wholesale_path
from wholesale import SooinSession
s = SooinSession()
s.login()
cart = s.get_cart()
print(f'성공: {cart["success"]}')
print(f'품목 수: {cart["total_items"]}')
print(f'총액: {cart["total_amount"]:,}')
print()
if cart['items']:
print('=== 장바구니 품목 ===')
for item in cart['items']:
status = '' if item.get('active') else '❌취소'
name = item['product_name'][:30]
print(f"{status} {name:30} x{item['quantity']} = {item['amount']:,}")
else:
print('🛒 장바구니 비어있음')

View File

@ -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')

View 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()

View File

@ -478,6 +478,264 @@ def api_get_monthly_sales():
}), 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
# ========== 하위 호환성 ==========
# 기존 코드에서 직접 클래스 참조하는 경우를 위해

View File

@ -898,12 +898,45 @@ def submit_baekje_order(order: dict, dry_run: bool, cart_only: bool = True) -> d
# ─────────────────────────────────────────
for item in items:
kd_code = item.get('kd_code') or item.get('drug_code')
internal_code = item.get('internal_code') # 프론트엔드에서 전달된 internal_code
order_qty = item['order_qty']
spec = item.get('specification', '')
cart_result = {}
# 🔍 디버그: 백제 주문 파라미터 확인
logger.info(f"[BAEKJE DEBUG] kd_code={kd_code}, internal_code={internal_code}, qty={order_qty}, spec={spec}")
logger.info(f"[BAEKJE DEBUG] full item: {item}")
try:
# 장바구니 추가
cart_result = baekje_session.add_to_cart(kd_code, order_qty)
if internal_code:
# internal_code가 있으면 바로 장바구니 추가!
logger.info(f"[BAEKJE DEBUG] Using internal_code directly: {internal_code}")
cart_result = baekje_session.add_to_cart(internal_code, order_qty)
logger.info(f"[BAEKJE DEBUG] add_to_cart result: {cart_result}")
else:
# internal_code가 없으면 검색 후 장바구니 추가
logger.info(f"[BAEKJE DEBUG] No internal_code, searching by kd_code={kd_code}")
search_result = baekje_session.search_products(kd_code)
if search_result.get('success') and search_result.get('items'):
# 규격 매칭 (재고 있는 것 우선)
matched_item = None
for baekje_item in search_result.get('items', []):
item_spec = baekje_item.get('spec', '')
# 규격이 지정되어 있으면 매칭, 없으면 첫번째 재고 있는 것
if not spec or spec in item_spec or item_spec in spec:
if matched_item is None or baekje_item.get('stock', 0) > matched_item.get('stock', 0):
matched_item = baekje_item
if matched_item:
found_internal_code = matched_item.get('internal_code')
logger.info(f"[BAEKJE DEBUG] Found internal_code via search: {found_internal_code}")
cart_result = baekje_session.add_to_cart(found_internal_code, order_qty)
internal_code = found_internal_code # 컨텍스트 저장용
else:
cart_result = {'success': False, 'error': 'NO_MATCHING_SPEC', 'message': f'규격 {spec} 미발견'}
else:
cart_result = {'success': False, 'error': 'PRODUCT_NOT_FOUND', 'message': '제품 검색 결과 없음'}
if cart_result.get('success'):
status = 'success'
@ -921,6 +954,7 @@ def submit_baekje_order(order: dict, dry_run: bool, cart_only: bool = True) -> d
result_code = 'ERROR'
result_message = str(e)
failed_count += 1
logger.error(f"[BAEKJE DEBUG] Exception: {e}")
update_item_result(item['id'], status, result_code, result_message)
@ -932,7 +966,8 @@ def submit_baekje_order(order: dict, dry_run: bool, cart_only: bool = True) -> d
'ordered_spec': spec,
'ordered_qty': order_qty,
'selection_reason': 'user_order',
'wholesaler_id': 'baekje'
'wholesaler_id': 'baekje',
'internal_code': internal_code
})
results.append({
@ -943,24 +978,38 @@ def submit_baekje_order(order: dict, dry_run: bool, cart_only: bool = True) -> d
'order_qty': order_qty,
'status': status,
'result_code': result_code,
'result_message': result_message
'result_message': result_message,
'internal_code': internal_code
})
# cart_only=False면 주문 확정까지 진행
# cart_only=False면 주문 확정까지 진행 (선별 주문!)
if not cart_only and success_count > 0:
try:
confirm_result = baekje_session.submit_order()
if confirm_result.get('success'):
update_order_status(order_id, 'submitted',
f'백제 주문 확정 완료: {success_count}')
# 결과 메시지 업데이트
for r in results:
if r['status'] == 'success':
r['result_code'] = 'OK'
r['result_message'] = '주문 확정 완료'
# 이번에 담은 품목의 internal_code만 수집
ordered_codes = [r['internal_code'] for r in results
if r['status'] == 'success' and r.get('internal_code')]
logger.info(f"[BAEKJE DEBUG] 선별 주문 시작, ordered_codes: {ordered_codes}")
if ordered_codes:
# 선별 주문: 기존 품목은 건드리지 않고, 이번에 담은 것만 주문
confirm_result = baekje_session.submit_order_selective(ordered_codes)
if confirm_result.get('success'):
restored_info = f", 기존 {confirm_result.get('restored_count', 0)}개 복원" if confirm_result.get('restored_count', 0) > 0 else ""
update_order_status(order_id, 'submitted',
f'백제 주문 확정 완료: {success_count}{restored_info}')
# 결과 메시지 업데이트
for r in results:
if r['status'] == 'success':
r['result_code'] = 'OK'
r['result_message'] = '주문 확정 완료'
else:
update_order_status(order_id, 'partial',
f'백제 장바구니 담김, 확정 실패: {confirm_result.get("error", "알 수 없는 오류")}')
else:
update_order_status(order_id, 'partial',
f'백제 장바구니 담김, 확정 실패: {confirm_result.get("error", "알 수 없는 오류")}')
f'백제 장바구니 담김, internal_code 없음')
except Exception as e:
logger.error(f"백제 주문 확정 오류: {e}")
update_order_status(order_id, 'partial',
@ -1056,3 +1105,285 @@ def api_ai_order_pattern(drug_code):
'pattern': None,
'message': '주문 이력이 없습니다'
})
# ─────────────────────────────────────────────
# 도매상 한도 관리 API
# ─────────────────────────────────────────────
@order_bp.route('/wholesaler/limits', methods=['GET'])
def api_wholesaler_limits():
"""
전체 도매상 한도 조회 (현재 사용량 포함)
GET /api/order/wholesaler/limits
"""
import sqlite3
from datetime import datetime
# 절대 경로 사용
db_path = r'c:\Users\청춘약국\source\pharmacy-pos-qr-system\backend\db\orders.db'
conn = sqlite3.connect(db_path)
conn.row_factory = sqlite3.Row
cur = conn.cursor()
# 현재 월
year_month = datetime.now().strftime('%Y-%m')
# 한도 정보 조회
cur.execute('SELECT * FROM wholesaler_limits WHERE is_active = 1 ORDER BY priority')
limits = cur.fetchall()
result = []
for row in limits:
ws_id = row['wholesaler_id']
monthly_limit = row['monthly_limit']
# 이번 달 실제 주문 금액 조회 (성공한 것만)
cur.execute('''
SELECT COALESCE(SUM(oi.unit_price * oi.order_qty), 0) as total_amount
FROM order_items oi
JOIN orders o ON oi.order_id = o.id
WHERE o.wholesaler_id = ?
AND strftime('%Y-%m', o.order_date) = ?
AND o.status IN ('submitted', 'success', 'confirmed')
''', (ws_id, year_month))
usage_row = cur.fetchone()
current_usage = usage_row['total_amount'] if usage_row else 0
usage_percent = (current_usage / monthly_limit * 100) if monthly_limit > 0 else 0
remaining = monthly_limit - current_usage
result.append({
'wholesaler_id': ws_id,
'monthly_limit': monthly_limit,
'current_usage': current_usage,
'remaining': remaining,
'usage_percent': round(usage_percent, 1),
'warning_threshold': row['warning_threshold'],
'is_warning': usage_percent >= (row['warning_threshold'] * 100),
'priority': row['priority']
})
conn.close()
return jsonify({
'success': True,
'year_month': year_month,
'limits': result
})
@order_bp.route('/wholesaler/limits/<wholesaler_id>', methods=['PUT'])
def api_update_wholesaler_limit(wholesaler_id):
"""
도매상 한도 수정
PUT /api/order/wholesaler/limits/geoyoung
{
"monthly_limit": 30000000,
"warning_threshold": 0.85
}
"""
import sqlite3
data = request.get_json()
db_path = r'c:\Users\청춘약국\source\pharmacy-pos-qr-system\backend\db\orders.db'
conn = sqlite3.connect(db_path)
cur = conn.cursor()
updates = []
params = []
if 'monthly_limit' in data:
updates.append('monthly_limit = ?')
params.append(data['monthly_limit'])
if 'warning_threshold' in data:
updates.append('warning_threshold = ?')
params.append(data['warning_threshold'])
if 'priority' in data:
updates.append('priority = ?')
params.append(data['priority'])
if updates:
updates.append("updated_at = datetime('now')")
params.append(wholesaler_id)
cur.execute(f'''
UPDATE wholesaler_limits
SET {', '.join(updates)}
WHERE wholesaler_id = ?
''', params)
conn.commit()
conn.close()
return jsonify({
'success': True,
'message': f'{wholesaler_id} 한도 업데이트 완료'
})
# ========== 약품별 선호 도매상 API ==========
def get_drug_preferred_vendor(drug_code: str, period_days: int = 365):
"""
약품코드 기준 선호 도매상 조회 (MSSQL 입고장 데이터)
"""
import pyodbc
CONN_STR = (
'DRIVER={ODBC Driver 17 for SQL Server};'
'SERVER=192.168.0.4\\PM2014;'
'DATABASE=PM_DRUG;'
'UID=sa;'
'PWD=tmddls214!%(;'
'TrustServerCertificate=yes;'
'Connection Timeout=10'
)
try:
conn = pyodbc.connect(CONN_STR, timeout=10)
cursor = conn.cursor()
# 약품명 조회
cursor.execute("SELECT GoodsName FROM CD_GOODS WHERE DrugCode = ?", drug_code)
row = cursor.fetchone()
drug_name = row[0] if row else ''
# 도매상별 입고 통계
query = """
SELECT
c.CD_NM_custom AS vendor_name,
c.CD_CD_custom AS vendor_code,
COUNT(*) AS order_count,
SUM(ws.WH_NM_item_a) AS total_qty,
SUM(ws.WH_MY_amount_a) AS total_amount,
AVG(ws.WH_MY_unit_a) AS avg_unit_price,
MAX(wm.WH_DT_appl) AS last_order_date
FROM WH_sub ws
JOIN WH_main wm ON ws.WH_SR_stock = wm.WH_NO_stock
LEFT JOIN PM_BASE.dbo.CD_custom c ON wm.WH_CD_cust_sale = c.CD_CD_custom
WHERE ws.DrugCode = ?
AND wm.WH_DT_appl >= CONVERT(varchar(8), DATEADD(day, ?, GETDATE()), 112)
GROUP BY c.CD_NM_custom, c.CD_CD_custom
ORDER BY COUNT(*) DESC
"""
cursor.execute(query, (drug_code, -period_days))
rows = cursor.fetchall()
if not rows:
conn.close()
return {
'success': True,
'drug_code': drug_code,
'drug_name': drug_name,
'recent_vendor': None,
'most_frequent_vendor': None,
'vendors': [],
'message': '입고 이력 없음'
}
vendors = []
for r in rows:
vendors.append({
'vendor_name': r[0] or '알수없음',
'vendor_code': r[1] or '',
'order_count': r[2],
'total_qty': float(r[3] or 0),
'total_amount': float(r[4] or 0),
'avg_unit_price': float(r[5] or 0),
'last_order_date': r[6]
})
most_frequent = vendors[0] if vendors else None
# 최근 주문 도매상
recent_query = """
SELECT TOP 1
c.CD_NM_custom AS vendor_name,
c.CD_CD_custom AS vendor_code,
wm.WH_DT_appl AS order_date,
ws.WH_NM_item_a AS qty,
ws.WH_MY_unit_a AS unit_price
FROM WH_sub ws
JOIN WH_main wm ON ws.WH_SR_stock = wm.WH_NO_stock
LEFT JOIN PM_BASE.dbo.CD_custom c ON wm.WH_CD_cust_sale = c.CD_CD_custom
WHERE ws.DrugCode = ?
ORDER BY wm.WH_DT_appl DESC
"""
cursor.execute(recent_query, drug_code)
recent_row = cursor.fetchone()
recent_vendor = None
if recent_row:
recent_vendor = {
'vendor_name': recent_row[0] or '알수없음',
'vendor_code': recent_row[1] or '',
'order_date': recent_row[2],
'qty': float(recent_row[3] or 0),
'unit_price': float(recent_row[4] or 0)
}
conn.close()
return {
'success': True,
'drug_code': drug_code,
'drug_name': drug_name,
'recent_vendor': recent_vendor,
'most_frequent_vendor': most_frequent,
'vendors': vendors
}
except Exception as e:
return {
'success': False,
'error': str(e),
'drug_code': drug_code
}
@order_bp.route('/drug/<drug_code>/preferred-vendor', methods=['GET'])
def api_drug_preferred_vendor(drug_code):
"""
약품별 선호 도매상 조회 API
GET /api/order/drug/670400830/preferred-vendor
GET /api/order/drug/670400830/preferred-vendor?period=180
"""
period = request.args.get('period', 365, type=int)
result = get_drug_preferred_vendor(drug_code, period)
return jsonify(result)
@order_bp.route('/drugs/preferred-vendors', methods=['POST'])
def api_drugs_preferred_vendors():
"""
여러 약품의 선호 도매상 일괄 조회
POST /api/order/drugs/preferred-vendors
{"drug_codes": ["670400830", "654301800"], "period": 365}
"""
data = request.get_json() or {}
drug_codes = data.get('drug_codes', [])
period = data.get('period', 365)
if not drug_codes:
return jsonify({'success': False, 'error': 'drug_codes required'})
results = {}
for code in drug_codes:
results[code] = get_drug_preferred_vendor(code, period)
return jsonify({
'success': True,
'count': len(results),
'results': results
})

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

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

@ -590,6 +590,135 @@
.location-modal-btn.primary { background: #f59e0b; color: #fff; }
.location-modal-btn.primary:hover { background: #d97706; }
/* ── 입고이력 모달 ── */
.purchase-modal {
display: none;
position: fixed;
top: 0; left: 0; right: 0; bottom: 0;
background: rgba(0,0,0,0.6);
z-index: 2000;
align-items: center;
justify-content: center;
backdrop-filter: blur(4px);
}
.purchase-modal.show { display: flex; }
.purchase-modal-content {
background: #fff;
border-radius: 16px;
padding: 0;
max-width: 600px;
width: 95%;
max-height: 80vh;
box-shadow: 0 20px 60px rgba(0,0,0,0.3);
animation: modalSlideIn 0.2s ease;
overflow: hidden;
display: flex;
flex-direction: column;
}
.purchase-modal-header {
padding: 20px 24px;
background: linear-gradient(135deg, #3b82f6 0%, #1d4ed8 100%);
color: #fff;
}
.purchase-modal-header h3 {
margin: 0 0 6px 0;
font-size: 18px;
display: flex;
align-items: center;
gap: 8px;
}
.purchase-modal-header .drug-name {
font-size: 14px;
opacity: 0.9;
}
.purchase-modal-body {
padding: 16px 24px 24px;
overflow-y: auto;
flex: 1;
}
.purchase-history-table {
width: 100%;
border-collapse: collapse;
}
.purchase-history-table th {
background: #f8fafc;
padding: 12px 10px;
font-size: 12px;
font-weight: 600;
color: #64748b;
text-align: left;
border-bottom: 2px solid #e2e8f0;
position: sticky;
top: 0;
}
.purchase-history-table td {
padding: 14px 10px;
font-size: 14px;
border-bottom: 1px solid #f1f5f9;
}
.purchase-history-table tr:hover td {
background: #f8fafc;
}
.supplier-name {
font-weight: 600;
color: #1e293b;
}
.supplier-tel {
font-size: 12px;
color: #3b82f6;
cursor: pointer;
}
.supplier-tel:hover {
text-decoration: underline;
}
.purchase-date {
color: #64748b;
font-family: 'JetBrains Mono', monospace;
font-size: 13px;
}
.purchase-qty {
font-weight: 600;
color: #10b981;
}
.purchase-price {
color: #6b7280;
}
.purchase-empty {
text-align: center;
padding: 40px 20px;
color: #94a3b8;
}
.purchase-empty .icon {
font-size: 40px;
margin-bottom: 12px;
}
.purchase-modal-footer {
padding: 16px 24px;
border-top: 1px solid #e2e8f0;
display: flex;
justify-content: flex-end;
}
.purchase-modal-btn {
padding: 10px 24px;
border: none;
border-radius: 8px;
cursor: pointer;
font-weight: 500;
font-size: 14px;
background: #f1f5f9;
color: #64748b;
transition: all 0.15s;
}
.purchase-modal-btn:hover {
background: #e2e8f0;
}
tbody tr {
cursor: pointer;
}
tbody tr:active {
background: #ede9fe;
}
/* ── 가격 ── */
.price {
font-weight: 600;
@ -916,6 +1045,7 @@
<!-- 결과 -->
<div class="result-count" id="resultCount" style="display:none;">
검색 결과: <strong id="resultNum">0</strong>
<span style="margin-left: 16px; color: #94a3b8; font-size: 12px;">💡 행 더블클릭 → 입고이력</span>
</div>
<div class="table-wrap">
@ -993,6 +1123,34 @@
</div>
</div>
<!-- 입고이력 모달 -->
<div class="purchase-modal" id="purchaseModal" onclick="if(event.target===this)closePurchaseModal()">
<div class="purchase-modal-content">
<div class="purchase-modal-header">
<h3>📦 입고 이력</h3>
<div class="drug-name" id="purchaseDrugName">-</div>
</div>
<div class="purchase-modal-body">
<table class="purchase-history-table">
<thead>
<tr>
<th>도매상</th>
<th>입고일</th>
<th>수량</th>
<th>단가</th>
</tr>
</thead>
<tbody id="purchaseHistoryBody">
<tr><td colspan="4" class="purchase-empty"><div class="icon">📭</div><p>로딩 중...</p></td></tr>
</tbody>
</table>
</div>
<div class="purchase-modal-footer">
<button class="purchase-modal-btn" onclick="closePurchaseModal()">닫기</button>
</div>
</div>
</div>
<script>
let productsData = [];
let selectedItem = null;
@ -1069,17 +1227,17 @@
: '';
return `
<tr>
<tr ondblclick="openPurchaseModal('${item.drug_code}', '${escapeHtml(item.product_name).replace(/'/g, "\\'")}')">
<td style="text-align:center;">
${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>`
? `<img src="data:image/jpeg;base64,${item.thumbnail}" class="product-thumb" onclick="event.stopPropagation();openImageModal('${item.barcode || ''}', '${item.drug_code || ''}', '${escapeHtml(item.product_name)}')" alt="">`
: `<div class="product-thumb-placeholder" onclick="event.stopPropagation();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>`
}
</td>
<td>
<div class="product-name">
${escapeHtml(item.product_name)}
${item.is_animal_drug ? `<span class="animal-badge ${item.apc ? 'clickable' : ''}" ${item.apc ? `onclick="printAnimalDrugInfo('${item.apc}', '${escapeHtml(item.product_name)}')" title="클릭하면 약품 안내서 인쇄"` : 'title="APC 없음"'}>🐾 동물약</span>` : ''}
${item.is_animal_drug ? `<span class="animal-badge ${item.apc ? 'clickable' : ''}" ${item.apc ? `onclick="event.stopPropagation();printAnimalDrugInfo('${item.apc}', '${escapeHtml(item.product_name)}')" title="클릭하면 약품 안내서 인쇄"` : 'title="APC 없음"'}>🐾 동물약</span>` : ''}
${categoryBadge}
</div>
<div class="product-supplier ${item.is_set ? 'set' : ''}">${escapeHtml(item.supplier) || ''}</div>
@ -1092,12 +1250,12 @@
<div style="margin-top:4px;">${item.apc ? `<span class="code code-apc">${item.apc}</span>` : `<span class="code code-apc-na">APC미지정</span>`}</div>`
: (item.barcode ? `<span class="code code-barcode">${item.barcode}</span>` : `<span class="code code-na">없음</span>`)}</td>
<td>${item.location
? `<span class="location-badge" onclick="openLocationModal('${item.drug_code}', '${escapeHtml(item.product_name)}', '${escapeHtml(item.location)}')">${escapeHtml(item.location)}</span>`
: `<span class="location-badge unset" onclick="openLocationModal('${item.drug_code}', '${escapeHtml(item.product_name)}', '')">미지정</span>`}</td>
? `<span class="location-badge" onclick="event.stopPropagation();openLocationModal('${item.drug_code}', '${escapeHtml(item.product_name)}', '${escapeHtml(item.location)}')">${escapeHtml(item.location)}</span>`
: `<span class="location-badge unset" onclick="event.stopPropagation();openLocationModal('${item.drug_code}', '${escapeHtml(item.product_name)}', '')">미지정</span>`}</td>
<td class="stock ${(item.stock || 0) > 0 ? 'in-stock' : 'out-stock'}">${item.stock || 0}${wsStock}</td>
<td class="price">${formatPrice(item.sale_price)}</td>
<td>
<button class="btn-qr" onclick="printQR(${idx})">🏷️ QR</button>
<button class="btn-qr" onclick="event.stopPropagation();printQR(${idx})">🏷️ QR</button>
</td>
</tr>
`}).join('');
@ -1833,6 +1991,65 @@
document.getElementById('locationModal')?.addEventListener('click', e => {
if (e.target.id === 'locationModal') closeLocationModal();
});
// ══════════════════════════════════════════════════════════════════
// 입고이력 모달
// ══════════════════════════════════════════════════════════════════
async function openPurchaseModal(drugCode, drugName) {
const modal = document.getElementById('purchaseModal');
const nameEl = document.getElementById('purchaseDrugName');
const tbody = document.getElementById('purchaseHistoryBody');
nameEl.textContent = drugName || drugCode;
tbody.innerHTML = '<tr><td colspan="4" class="purchase-empty"><div class="icon"></div><p>입고이력 조회 중...</p></td></tr>';
modal.classList.add('show');
try {
const res = await fetch(`/api/drugs/${drugCode}/purchase-history`);
const data = await res.json();
if (data.success) {
if (data.history.length === 0) {
tbody.innerHTML = '<tr><td colspan="4" class="purchase-empty"><div class="icon">📭</div><p>입고 이력이 없습니다</p></td></tr>';
} else {
tbody.innerHTML = data.history.map(h => `
<tr>
<td>
<div class="supplier-name">${escapeHtml(h.supplier)}</div>
${h.supplier_tel ? `<div class="supplier-tel" onclick="copyToClipboard('${h.supplier_tel}')" title="클릭하여 복사">📞 ${h.supplier_tel}</div>` : ''}
</td>
<td class="purchase-date">${h.date}</td>
<td class="purchase-qty">${h.quantity.toLocaleString()}</td>
<td class="purchase-price">${h.unit_price ? formatPrice(h.unit_price) : '-'}</td>
</tr>
`).join('');
}
} else {
tbody.innerHTML = `<tr><td colspan="4" class="purchase-empty"><div class="icon">⚠️</div><p>조회 실패: ${data.error}</p></td></tr>`;
}
} catch (err) {
tbody.innerHTML = `<tr><td colspan="4" class="purchase-empty"><div class="icon"></div><p>오류: ${err.message}</p></td></tr>`;
}
}
function closePurchaseModal() {
document.getElementById('purchaseModal').classList.remove('show');
}
function copyToClipboard(text) {
navigator.clipboard.writeText(text).then(() => {
showToast(`📋 ${text} 복사됨`, 'success');
}).catch(() => {
// fallback
const input = document.createElement('input');
input.value = text;
document.body.appendChild(input);
input.select();
document.execCommand('copy');
document.body.removeChild(input);
showToast(`📋 ${text} 복사됨`, 'success');
});
}
</script>
</body>
</html>

File diff suppressed because it is too large Load Diff

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%);
@ -391,6 +402,28 @@
font-family: inherit;
}
.patient-badge {
display: inline-block;
background: rgba(156, 163, 175, 0.15);
color: #9ca3af;
font-size: 10px;
font-weight: 500;
padding: 2px 6px;
border-radius: 4px;
margin-left: 4px;
font-family: inherit;
}
.patient-badge.has-today {
background: rgba(236, 72, 153, 0.2);
color: #ec4899;
}
.today-patient {
color: #ec4899;
font-weight: 700;
}
/* 수량 관련 */
.qty-cell {
text-align: center;
@ -745,7 +778,7 @@
<option value="amount_desc">금액 높은순</option>
</select>
</div>
<button class="search-btn" onclick="loadUsageData()">🔍 조회</button>
<button class="search-btn" onclick="loadUsageData(); loadOrderData();">🔍 조회</button>
<button class="search-btn" style="background: linear-gradient(135deg, #a855f7, #7c3aed);" onclick="openBalanceModal()">💰 도매상 잔고</button>
</div>
@ -774,7 +807,7 @@
<div class="stat-card emerald">
<div class="stat-icon">💰</div>
<div class="stat-value" id="statTotalAmount">-</div>
<div class="stat-label">약가</div>
<div class="stat-label">매출액</div>
</div>
<div class="stat-card orange">
<div class="stat-icon">🛒</div>
@ -803,17 +836,19 @@
<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="right">약가</th>
<th class="center" style="color:var(--accent-cyan);">주문량</th>
<th class="center" style="color:var(--accent-purple);font-size:11px;">선호도매상</th>
<th class="right">매출액</th>
<th class="center" style="width:90px">주문수량</th>
</tr>
</thead>
<tbody id="usageTableBody">
<tr>
<td colspan="7">
<td colspan="9">
<div class="loading-state">
<div class="loading-spinner"></div>
<div>데이터 로딩 중...</div>
@ -860,6 +895,38 @@
<script>
let usageData = [];
let cart = [];
let orderDataByKd = {};
let preferredVendors = {}; // 약품별 선호 도매상
// 선호 도매상 표시
function getPreferredVendor(drugCode) {
const v = preferredVendors[drugCode];
if (!v || !v.success) return '-';
const recent = v.recent_vendor;
const most = v.most_frequent_vendor;
if (!recent && !most) return '-';
const shorten = (n) => n ? n.replace('강원','').replace('(주)','').replace('지점','').replace('약품','').substring(0,3) : '';
const rn = recent ? shorten(recent.vendor_name) : '';
const mn = most ? shorten(most.vendor_name) : '';
if (rn === mn && rn) return `<span style="color:#10b981" title="${most?.vendor_name}">${rn}</span>`;
let h = '';
if (mn) h += `<span style="color:#a855f7" title="최다(${most.order_count}회)">★${mn}</span>`;
if (rn && rn !== mn) h += `<br><span style="color:#888" title="최근">▸${rn}</span>`;
return h || '-';
}
// 선호 도매상 로드
async function loadPreferredVendors(codes) {
if (!codes || !codes.length) return;
try {
const r = await fetch('/api/order/drugs/preferred-vendors', {
method: 'POST', headers: {'Content-Type': 'application/json'},
body: JSON.stringify({drug_codes: codes, period: 365})
});
const d = await r.json();
if (d.success) { preferredVendors = d.results; renderTable(); }
} catch(e) { console.error('선호도매상 로드 실패:', e); }
} // 도매상 주문량 합산 (KD코드별) - 지오영 + 수인
// 초기화
document.addEventListener('DOMContentLoaded', function() {
@ -869,8 +936,90 @@
document.getElementById('endDate').value = todayStr;
loadUsageData();
loadOrderData(); // 수인약품 주문량 로드
});
// ──────────────── 도매상 주문량 조회 (지오영 + 수인 + 백제 합산) ────────────────
async function loadOrderData() {
const startDate = document.getElementById('startDate').value;
const endDate = document.getElementById('endDate').value;
orderDataLoading = true;
orderDataByKd = {};
try {
// 지오영 + 수인 + 백제 병렬 조회
const [geoRes, sooinRes, baekjeRes] = await Promise.all([
fetch(`/api/geoyoung/orders/summary-by-kd?start_date=${startDate}&end_date=${endDate}`).then(r => r.json()).catch(() => ({ success: false })),
fetch(`/api/sooin/orders/summary-by-kd?start_date=${startDate}&end_date=${endDate}`).then(r => r.json()).catch(() => ({ success: false })),
fetch(`/api/baekje/orders/summary-by-kd?start_date=${startDate}&end_date=${endDate}`).then(r => r.json()).catch(() => ({ success: false }))
]);
let totalOrders = 0;
// 지오영 데이터 합산
if (geoRes.success && geoRes.by_kd_code) {
for (const [kd, data] of Object.entries(geoRes.by_kd_code)) {
if (!orderDataByKd[kd]) {
orderDataByKd[kd] = { product_name: data.product_name, spec: data.spec, boxes: 0, units: 0, sources: [] };
}
orderDataByKd[kd].boxes += data.boxes || 0;
orderDataByKd[kd].units += data.units || 0;
orderDataByKd[kd].sources.push('지오영');
}
totalOrders += geoRes.order_count || 0;
console.log('🏭 지오영 주문량:', Object.keys(geoRes.by_kd_code).length, '품목,', geoRes.order_count, '건');
}
// 수인 데이터 합산
if (sooinRes.success && sooinRes.by_kd_code) {
for (const [kd, data] of Object.entries(sooinRes.by_kd_code)) {
if (!orderDataByKd[kd]) {
orderDataByKd[kd] = { product_name: data.product_name, spec: data.spec, boxes: 0, units: 0, sources: [] };
}
orderDataByKd[kd].boxes += data.boxes || 0;
orderDataByKd[kd].units += data.units || 0;
orderDataByKd[kd].sources.push('수인');
}
totalOrders += sooinRes.order_count || 0;
console.log('💜 수인 주문량:', Object.keys(sooinRes.by_kd_code).length, '품목,', sooinRes.order_count, '건');
}
// 백제 데이터 합산
if (baekjeRes.success && baekjeRes.by_kd_code) {
for (const [kd, data] of Object.entries(baekjeRes.by_kd_code)) {
if (!orderDataByKd[kd]) {
orderDataByKd[kd] = { product_name: data.product_name, spec: data.spec, boxes: 0, units: 0, sources: [] };
}
orderDataByKd[kd].boxes += data.boxes || 0;
orderDataByKd[kd].units += data.units || 0;
orderDataByKd[kd].sources.push('백제');
}
totalOrders += baekjeRes.order_count || 0;
console.log('💉 백제 주문량:', Object.keys(baekjeRes.by_kd_code).length, '품목,', baekjeRes.order_count, '건');
}
console.log('📦 3사 합산 주문량:', Object.keys(orderDataByKd).length, '품목,', totalOrders, '건 주문');
} 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;
@ -879,7 +1028,7 @@
const sort = document.getElementById('sortSelect').value;
document.getElementById('usageTableBody').innerHTML = `
<tr><td colspan="7">
<tr><td colspan="9">
<div class="loading-state">
<div class="loading-spinner"></div>
<div>데이터 로딩 중...</div>
@ -904,9 +1053,11 @@
document.getElementById('resultCount').textContent = `(${data.items.length}개)`;
renderTable();
// 선호 도매상 로드 (백그라운드)
loadPreferredVendors(data.items.map(i => i.drug_code));
} else {
document.getElementById('usageTableBody').innerHTML = `
<tr><td colspan="7">
<tr><td colspan="9">
<div class="empty-state">
<div class="empty-icon">⚠️</div>
<div>오류: ${data.error}</div>
@ -916,7 +1067,7 @@
})
.catch(err => {
document.getElementById('usageTableBody').innerHTML = `
<tr><td colspan="7">
<tr><td colspan="9">
<div class="empty-state">
<div class="empty-icon"></div>
<div>데이터 로드 실패</div>
@ -931,7 +1082,7 @@
if (usageData.length === 0) {
tbody.innerHTML = `
<tr><td colspan="7">
<tr><td colspan="9">
<div class="empty-state">
<div class="empty-icon">💊</div>
<div>해당 기간 처방 내역이 없습니다</div>
@ -957,7 +1108,7 @@
}
<div class="product-info">
<span class="product-name">${escapeHtml(item.product_name)}</span>
<span class="product-code">${item.drug_code}${item.supplier ? ` · ${escapeHtml(item.supplier)}` : ''}${item.location ? ` <span class="location-badge">📍${escapeHtml(item.location)}</span>` : ''}</span>
<span class="product-code">${item.drug_code}${item.supplier ? ` · ${escapeHtml(item.supplier)}` : ''}${item.location ? ` <span class="location-badge">📍${escapeHtml(item.location)}</span>` : ''}${item.patient_names ? ` <span class="patient-badge ${item.today_patients ? 'has-today' : ''}" title="${item.patient_count}명 사용${item.today_patients ? ' (오늘: ' + item.today_patients + ')' : ''}">👤${formatPatientNames(item.patient_names, item.today_patients)}</span>` : ''}</span>
</div>
</div>
</td>
@ -966,6 +1117,8 @@
</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 class="qty-cell" style="font-size:10px;">${getPreferredVendor(item.drug_code)}</td>
<td style="text-align:right;font-family:'JetBrains Mono',monospace;font-size:12px;">
${formatPrice(item.total_amount)}원
</td>
@ -1156,44 +1309,135 @@
// 다중 도매상 선택을 위한 전역 변수
let pendingWholesalerItems = {};
let pendingOtherItems = [];
let wholesalerLimits = {}; // 도매상 한도 캐시
function openWholesalerSelectModal(itemsByWholesaler, otherItems) {
async function openWholesalerSelectModal(itemsByWholesaler, otherItems) {
pendingWholesalerItems = itemsByWholesaler;
pendingOtherItems = otherItems;
const modal = document.getElementById('multiWholesalerModal');
const body = document.getElementById('multiWholesalerBody');
// 로딩 표시
body.innerHTML = '<div style="text-align:center;padding:40px;color:var(--text-muted);">📊 한도 및 월 매출 조회 중...</div>';
modal.classList.add('show');
const now = new Date();
const year = now.getFullYear();
const month = now.getMonth() + 1;
// 1. 도매상 한도 정보 가져오기
try {
const res = await fetch('/api/order/wholesaler/limits');
const data = await res.json();
if (data.success) {
data.limits.forEach(l => {
wholesalerLimits[l.wholesaler_id] = l;
});
}
} catch (e) {
console.warn('한도 조회 실패:', e);
}
// 2. 실제 월 매출 가져오기 (도매상 API 호출)
const wholesalerConfigs = WHOLESALER_ORDER.map(id => WHOLESALER_CONFIG[id]);
await Promise.all(wholesalerConfigs.map(async ws => {
try {
const salesRes = await fetch(`${ws.salesApi}?year=${year}&month=${month}`);
const salesData = await salesRes.json();
if (salesData.success && wholesalerLimits[ws.id]) {
// 실제 월 매출로 current_usage 업데이트
wholesalerLimits[ws.id].current_usage = salesData.total_amount || 0;
wholesalerLimits[ws.id].usage_percent = wholesalerLimits[ws.id].monthly_limit > 0
? Math.round((salesData.total_amount || 0) / wholesalerLimits[ws.id].monthly_limit * 1000) / 10
: 0;
wholesalerLimits[ws.id].remaining = wholesalerLimits[ws.id].monthly_limit - (salesData.total_amount || 0);
}
} catch (e) {
console.warn(`${ws.id} 월매출 조회 실패:`, e);
}
}));
const wsIds = Object.keys(itemsByWholesaler);
// 전체 총액 계산
let grandTotal = 0;
wsIds.forEach(wsId => {
itemsByWholesaler[wsId].forEach(item => {
grandTotal += (item.unit_price || 0) * item.qty;
});
});
let html = `
<div class="multi-ws-summary">
<p style="margin-bottom:16px;color:var(--text-secondary);">
장바구니에 <b>${wsIds.length}개 도매상</b>의 품목이 있습니다.
</p>
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px;">
<p style="color:var(--text-secondary);margin:0;">
장바구니에 <b>${wsIds.length}개 도매상</b>의 품목이 있습니다.
</p>
<div style="font-size:18px;font-weight:700;color:var(--accent-emerald);font-family:'JetBrains Mono',monospace;">
${grandTotal > 0 ? '₩' + grandTotal.toLocaleString() : ''}
</div>
</div>
`;
// 각 도매상별 품목 표시
wsIds.forEach(wsId => {
const ws = WHOLESALERS[wsId];
const items = itemsByWholesaler[wsId];
const limit = wholesalerLimits[wsId];
// 도매상별 소계
const wsTotal = items.reduce((sum, item) => sum + (item.unit_price || 0) * item.qty, 0);
// 한도 정보 계산
let limitHtml = '';
if (limit) {
const afterOrder = limit.current_usage + wsTotal;
const afterPercent = (afterOrder / limit.monthly_limit * 100).toFixed(1);
const isOver = afterOrder > limit.monthly_limit;
const isWarning = afterPercent >= (limit.warning_threshold * 100);
limitHtml = `
<div style="margin-top:8px;padding:8px 12px;background:var(--bg-tertiary);border-radius:6px;font-size:12px;">
<div style="display:flex;justify-content:space-between;margin-bottom:4px;">
<span>월 한도</span>
<span style="font-family:'JetBrains Mono',monospace;">${(limit.monthly_limit/10000).toLocaleString()}만원</span>
</div>
<div style="display:flex;justify-content:space-between;margin-bottom:4px;">
<span>이번달 사용</span>
<span style="font-family:'JetBrains Mono',monospace;">${(limit.current_usage/10000).toLocaleString()}만원 (${limit.usage_percent}%)</span>
</div>
<div style="display:flex;justify-content:space-between;color:${isOver ? 'var(--accent-red)' : isWarning ? 'var(--accent-amber)' : 'var(--accent-emerald)'};">
<span>주문 후</span>
<span style="font-family:'JetBrains Mono',monospace;font-weight:600;">
${(afterOrder/10000).toLocaleString()}만원 (${afterPercent}%)
${isOver ? ' ⚠️ 초과!' : isWarning ? ' ⚠️' : ' ✓'}
</span>
</div>
</div>
`;
}
html += `
<div class="multi-ws-card ${wsId}">
<div class="multi-ws-header">
<span class="multi-ws-icon">${ws.icon}</span>
<img src="${ws.logo}" alt="${ws.name}" style="width:24px;height:24px;object-fit:contain;margin-right:8px;">
<span class="multi-ws-name">${ws.name}</span>
<span class="multi-ws-count">${items.length}개 품목</span>
${wsTotal > 0 ? `<span style="margin-left:auto;margin-right:12px;font-family:'JetBrains Mono',monospace;font-size:13px;color:var(--accent-cyan);">₩${wsTotal.toLocaleString()}</span>` : ''}
<label class="multi-ws-checkbox">
<input type="checkbox" id="ws_check_${wsId}" checked>
<span>포함</span>
</label>
</div>
<div class="multi-ws-items">
${items.slice(0, 3).map(item => `
<div class="multi-ws-item">· ${item.product_name} (${item.qty}개)</div>
`).join('')}
${items.slice(0, 3).map(item => {
const itemAmt = (item.unit_price || 0) * item.qty;
return `<div class="multi-ws-item">· ${item.product_name} (${item.qty}개)${itemAmt > 0 ? ` <span style="color:var(--text-muted);">${itemAmt.toLocaleString()}원</span>` : ''}</div>`;
}).join('')}
${items.length > 3 ? `<div class="multi-ws-item more">... 외 ${items.length - 3}개</div>` : ''}
</div>
${limitHtml}
</div>
`;
});
@ -1427,7 +1671,7 @@
const headerDiv = modal.querySelector('.order-modal-header');
// 도매상별 헤더 및 본문 텍스트 변경
document.getElementById('orderConfirmTitle').innerHTML = `${ws.icon} ${ws.name} 주문 확인`;
document.getElementById('orderConfirmTitle').innerHTML = `<img src="${ws.logo}" alt="${ws.name}" style="width:24px;height:24px;object-fit:contain;vertical-align:middle;margin-right:8px;">${ws.name} 주문 확인`;
document.getElementById('orderConfirmWholesaler').textContent = ws.name;
headerDiv.style.background = ws.gradient;
@ -1770,6 +2014,23 @@
return str.replace(/[&<>"']/g, m => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[m]));
}
// 환자 이름 포맷 (오늘 사용 환자 강조)
function formatPatientNames(allNames, todayNames) {
if (!allNames) return '';
if (!todayNames) return escapeHtml(allNames);
const todaySet = new Set(todayNames.split(', ').map(n => n.trim()));
const names = allNames.split(', ');
return names.map(name => {
const trimmed = name.trim();
if (todaySet.has(trimmed)) {
return `<strong class="today-patient">${escapeHtml(trimmed)}</strong>`;
}
return escapeHtml(trimmed);
}).join(', ');
}
function showToast(message, type = 'info') {
const toast = document.getElementById('toast');
toast.textContent = message;
@ -2025,6 +2286,7 @@
if (!qty || isNaN(qty)) return;
// 장바구니에 추가
const unitPrice = item.price || item.unit_price || 0;
const cartItem = {
drug_code: currentWholesaleItem.drug_code,
product_name: productName,
@ -2035,7 +2297,8 @@
internal_code: item.internal_code,
geoyoung_code: wholesaler === 'geoyoung' ? item.insurance_code : null,
sooin_code: wholesaler === 'sooin' ? item.code : null,
baekje_code: wholesaler === 'baekje' ? item.internal_code : null
baekje_code: wholesaler === 'baekje' ? item.internal_code : null,
unit_price: unitPrice // 💰 단가 추가
};
// 🔍 디버그: 장바구니 추가 시 internal_code 확인

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',
@ -772,7 +773,7 @@
<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>${log.created_at}</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>

View 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}')

View File

@ -0,0 +1,48 @@
# -*- coding: utf-8 -*-
import pyodbc
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()
# 최근 1년간 약품별 사용 환자 수 + 3명 이하면 이름 표시
query = """
WITH PatientUsage AS (
SELECT DISTINCT
P.DrugCode,
M.Paname
FROM PS_sub_pharm P
JOIN PS_main M ON P.PreSerial = M.PreSerial
WHERE M.Indate >= CONVERT(VARCHAR, DATEADD(YEAR, -1, GETDATE()), 112)
)
SELECT
PU.DrugCode,
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
FROM PatientUsage PU
GROUP BY PU.DrugCode
HAVING COUNT(*) <= 3
ORDER BY COUNT(*), PU.DrugCode
"""
cur.execute(query)
rows = cur.fetchall()
print(f"=== 최근 1년 사용 환자 3명 이하 약품 ({len(rows)}개) ===\n")
for row in rows[:20]: # 상위 20개만
print(f"[{row.DrugCode}] {row.patient_count}명: {row.patient_names}")

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 "")

View File

@ -0,0 +1,65 @@
# -*- coding: utf-8 -*-
import pyodbc
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()
# 오늘 날짜 확인
cur.execute('SELECT CONVERT(VARCHAR, GETDATE(), 112)')
today = cur.fetchone()[0]
print(f'오늘 날짜: {today}')
# 오늘 처방된 약품 중 3명 이하 환자 약품 테스트
query = """
WITH PatientUsage AS (
SELECT DISTINCT
P.DrugCode,
M.Paname,
MAX(CASE WHEN M.Indate = CONVERT(VARCHAR, GETDATE(), 112) THEN 1 ELSE 0 END) as used_today
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 TOP 20
PU.DrugCode,
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_today = 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
ORDER BY
CASE WHEN MAX(PU.used_today) = 1 THEN 0 ELSE 1 END, -- 오늘 사용한 먼저
PU.DrugCode
"""
cur.execute(query)
print('\n=== 3명 이하 환자 약품 (오늘 사용 우선) ===')
for row in cur.fetchall():
today_mark = ' ⭐오늘' if row.today_patients else ''
print(f'[{row.DrugCode}] {row.patient_count}명: {row.patient_names}{today_mark}')
if row.today_patients:
print(f' → 오늘 사용: {row.today_patients}')

40
backend/test_today_rx.py Normal file
View File

@ -0,0 +1,40 @@
# -*- coding: utf-8 -*-
import pyodbc
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()
# 오늘 처방 있는지 확인
cur.execute("""
SELECT COUNT(*) as cnt, MAX(Indate) as last_date
FROM PS_main
WHERE Indate = CONVERT(VARCHAR, GETDATE(), 112)
""")
row = cur.fetchone()
print(f'오늘(20260307) 처방 수: {row.cnt}')
# 최근 처방일
cur.execute("SELECT MAX(Indate) FROM PS_main")
print(f'최근 처방일: {cur.fetchone()[0]}')
# 어제 처방 약품 중 3명 이하 확인 (테스트용)
cur.execute("""
SELECT TOP 5 P.DrugCode, M.Paname, M.Indate
FROM PS_sub_pharm P
JOIN PS_main M ON P.PreSerial = M.PreSerial
WHERE M.Indate = '20260306'
ORDER BY P.DrugCode
""")
print('\n=== 3/6 처방 샘플 ===')
for row in cur.fetchall():
print(f'{row.DrugCode}: {row.Paname}')

View File

@ -0,0 +1,353 @@
# PAAI 시스템 트러블슈팅 기록
**날짜:** 2026-03-07
**작성자:** 용림 (AI 디지털 직원)
**상태:** ✅ 해결 완료
---
## 📋 목차
1. [시스템 아키텍처](#시스템-아키텍처)
2. [모델 전략 (Opus vs Sonnet)](#모델-전략-opus-vs-sonnet)
3. [발생한 문제들](#발생한-문제들)
4. [해결 과정](#해결-과정)
5. [수정된 코드](#수정된-코드)
6. [서브에이전트 활용](#서브에이전트-활용)
7. [교훈 및 권장사항](#교훈-및-권장사항)
---
## 시스템 아키텍처
### 전체 흐름
```
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ PIT3000 POS │────▶│ prescription │────▶│ Flask │
│ (MSSQL) │ │ _trigger.py │ │ (pmr_api) │
└─────────────────┘ └─────────────────┘ └─────────────────┘
│ WebSocket │ HTTP
▼ (ws://8765) ▼
┌─────────────────┐ ┌─────────────────┐
│ PMR 화면 │ │ KIMS API │
│ (실시간) │ │ (약물 상호작용) │
└─────────────────┘ └─────────────────┘
┌─────────────────┐
│ Clawdbot │
│ Gateway │
│ (ws://18789) │
└─────────────────┘
┌─────────────────┐
│ Claude API │
│ (Sonnet 4) │
└─────────────────┘
```
### PM2 서비스 구성
| 서비스 | PM2 이름 | 포트 | 역할 |
|--------|----------|------|------|
| Flask 웹서버 | `flask-pharmacy` | 7001 | API 엔드포인트, PMR 화면 |
| 처방 감지 | `websocket-rx` | 8765 | DB 폴링 → 실시간 알림 |
| Clawdbot | `clawdbot-gateway` | 18789 | AI 게이트웨이 |
---
## 모델 전략 (Opus vs Sonnet)
### Gateway 기본 설정
```json
// ~/.clawdbot/clawdbot.json
{
"agents": {
"defaults": {
"model": {
"primary": "anthropic/claude-opus-4-5" // 기본 모델
},
"models": {
"anthropic/claude-opus-4-5": { "alias": "opus" },
"github-copilot/gpt-5": {},
"openai-codex/gpt-5.2-codex": {}
}
}
}
}
```
### PAAI 전용 모델 (비용 절감)
PAAI 분석은 **빠른 응답**과 **비용 절감**이 중요하므로 **Sonnet**을 사용.
```python
# pmr_api.py - Line 1417
ai_text = ask_clawdbot(
message=prompt,
session_id='paai-analysis',
system_prompt=PAAI_SYSTEM_PROMPT,
timeout=60,
model='anthropic/claude-sonnet-4-5' # Sonnet 지정!
)
```
### sessions.patch 메커니즘
Gateway에서 모델이 allowlist에 없어도, `sessions.patch`로 세션별 모델 오버라이드 가능:
```python
# clawdbot_client.py - _ask_gateway()
# 4. 모델 오버라이드 (sessions.patch)
if model:
patch_frame = {
'type': 'req',
'method': 'sessions.patch',
'params': {
'key': session_id,
'model': model, # 'anthropic/claude-sonnet-4-5'
}
}
await ws.send(json.dumps(patch_frame))
# patch 실패해도 agent 요청은 계속 진행됨
```
### 모델 비용 비교
| 모델 | 입력 (1M) | 출력 (1M) | 용도 |
|------|-----------|-----------|------|
| Claude Opus 4.5 | $15 | $75 | 메인 세션, 복잡한 작업 |
| Claude Sonnet 4.5 | $3 | $15 | PAAI 분석, 빠른 응답 필요 |
**절감 효과:** PAAI를 Sonnet으로 돌리면 비용 **80% 절감**!
---
## 발생한 문제들
### 문제 1: 처방 분석이 안 됨 (임명옥 환자)
**증상:**
- 처방 감지는 되나 분석 결과가 안 나옴
- DB에 `generating` 상태로 멈춤
**원인:**
1. PM2 `watch` 모드로 인한 과도한 재시작 (30회 이상)
2. 재시작 과정에서 분석 요청이 중단됨
### 문제 2: Flask 연결 끊김
**증상:**
```
ConnectionResetError: [WinError 10054]
현재 연결은 원격 호스트에 의해 강제로 끊겼습니다
```
**원인:**
- watch 모드가 파일 변경 감지 → Flask 재시작
- 진행 중인 HTTP 요청이 끊김
### 문제 3: 모델 허용 오류 (경고)
**증상:**
```
WARNING: [Clawdbot] sessions.patch 실패: model not allowed: anthropic/claude-sonnet-4-5
```
**분석:**
- Gateway allowlist에 `claude-sonnet-4-5` 없음
- 하지만 **sessions.patch 실패해도 agent 요청은 진행됨**
- 세션의 기존 모델 또는 기본 모델로 폴백
- 실제 분석에는 영향 없음 (비용만 더 나갈 수 있음)
---
## 해결 과정
### Step 1: 상황 파악
```bash
# PM2 상태 확인
pm2 list
# → flask-pharmacy: ↺ 17, websocket-rx: ↺ 30 (과도한 재시작)
# 최근 성공한 분석 확인
sqlite3 db/paai_logs.db "SELECT * FROM paai_logs WHERE status='success' ORDER BY id DESC LIMIT 5"
# → 00:24:32까지 성공, 이후 실패
```
### Step 2: 원인 분석
```bash
# Flask 로그 확인
pm2 logs flask-pharmacy --lines 50
# watch 모드가 문제임을 확인
# uptime이 7초, 계속 재시작 중
```
### Step 3: watch 모드 비활성화
```bash
# 기존 서비스 삭제
pm2 stop flask-pharmacy websocket-rx
pm2 delete flask-pharmacy websocket-rx
# watch 없이 재등록
pm2 start app.py --name "flask-pharmacy" --interpreter python \
--cwd "c:\Users\청춘약국\source\pharmacy-pos-qr-system\backend"
pm2 start prescription_trigger.py --name "websocket-rx" --interpreter python \
--cwd "c:\Users\청춘약국\source\prescription-trigger"
# 저장
pm2 save
```
### Step 4: 밀린 처방 수동 분석
```bash
# 임명옥 처방 수동 분석 요청
curl -X POST http://localhost:7001/pmr/api/paai/analyze \
-H "Content-Type: application/json" \
-d '{"pre_serial": "20260307000059"}'
# → 성공!
```
---
## 수정된 코드
### ThreadPoolExecutor 적용 (동시성 제한)
**파일:** `prescription-trigger/prescription_trigger.py`
**Before (문제):**
```python
# 무제한 스레드 생성 → 세션 과부하
def _request_analysis(self, pre_serial, patient_name):
thread = threading.Thread(target=self._analyze_worker, args=(...))
thread.start()
self._worker_threads.append(thread)
```
**After (해결):**
```python
from concurrent.futures import ThreadPoolExecutor
class PrescriptionTrigger:
def __init__(self):
# 최대 3개 동시 처리
self._executor = ThreadPoolExecutor(max_workers=3)
def _request_analysis(self, pre_serial, patient_name):
self._executor.submit(self._analyze_worker, pre_serial, patient_name)
def stop(self):
self._executor.shutdown(wait=True) # graceful shutdown
```
**커밋:** `feat: ThreadPoolExecutor로 동시 처리 제한 (max_workers=3)`
### OTC 라벨 import 수정
**파일:** `backend/app.py`
**문제:** PM2 환경에서 상대 import 실패
```
ModuleNotFoundError: No module named 'utils'
```
**해결:**
```python
# app.py 상단에서 미리 import
from utils.otc_label_printer import generate_label_commands
```
---
## 서브에이전트 활용
오늘 트러블슈팅에 **3개의 서브에이전트**를 활용:
### 1. paai-investigation
- **목적:** PAAI 아키텍처 분석
- **결과:** Clawdbot Gateway WebSocket 사용 확인
### 2. paai-queue-analysis
- **목적:** 큐 처리 로직 분석
- **결과:** 날짜 필터로 어제 것은 무시, 오늘 것은 일괄 처리
### 3. paai-concurrency-design
- **목적:** 동시성 제어 방안 설계
- **결과:** ThreadPoolExecutor 방식 제안
```python
# 서브에이전트 호출 예시
sessions_spawn(
task="처방 분석 시스템의 동시성 제어 방안을 설계해주세요",
label="paai-concurrency-design"
)
```
---
## 교훈 및 권장사항
### ✅ PM2 watch 모드 사용 시 주의
```bash
# 개발 중에만 watch 사용
pm2 start app.py --watch # 개발용
# 운영 환경에서는 watch 끄기
pm2 start app.py # 운영용
# 코드 수정 후 수동 반영
pm2 restart flask-pharmacy
```
### ✅ 모델 비용 최적화
| 용도 | 권장 모델 | 이유 |
|------|-----------|------|
| 메인 대화 | Opus | 복잡한 작업, 높은 품질 |
| PAAI 분석 | Sonnet | 빠른 응답, 정형화된 출력 |
| 업셀링 추천 | Sonnet | 간단한 추천 |
### ✅ 동시성 제어
```python
# ThreadPoolExecutor 사용
executor = ThreadPoolExecutor(max_workers=3)
# 이점:
# 1. 리소스 제한 (CPU, 메모리)
# 2. Gateway 세션 과부하 방지
# 3. 순차적 처리보다 빠름
```
### ✅ 트러블슈팅 체크리스트
1. `pm2 list` - 재시작 횟수 확인 (↺)
2. `pm2 logs <name>` - 에러 로그 확인
3. `paai_logs.db` - 분석 상태 확인
4. `trigger_state.db` - 작업 큐 상태 확인
---
## 결론
| 항목 | Before | After |
|------|--------|-------|
| 안정성 | ❌ 30회 재시작 | ✅ 안정적 |
| 동시성 | 무제한 스레드 | 3개 제한 |
| 비용 | Opus (고비용) | Sonnet (저비용) |
| Watch 모드 | 활성화 (불안정) | 비활성화 (안정) |
**시스템 정상화 완료!** 🎉

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. **에러 처리**: 로그인 실패, 네트워크 오류 등 처리 필요