feat: 전문의약품(Rx) 사용량 조회 페이지 + OTC 사용량 페이지 추가
- /admin/rx-usage: 전문의약품 사용량 조회 (현재고 포함) - /admin/usage: OTC 일반약 사용량 조회 - /api/rx-usage: 처방전 데이터 기반 품목별 집계 API - /api/usage: POS 판매 데이터 기반 품목별 집계 API - 현재고: IM_total.IM_QT_sale_debit 사용 - 장바구니 + 주문서 생성 기능 - 세로모니터 대응 미디어쿼리 포함
This commit is contained in:
parent
0d9f4c9a23
commit
0460085791
330
backend/app.py
330
backend/app.py
@ -3877,6 +3877,336 @@ def api_sales_detail():
|
|||||||
}), 500
|
}), 500
|
||||||
|
|
||||||
|
|
||||||
|
# ===== 사용량 조회 페이지 및 API =====
|
||||||
|
|
||||||
|
@app.route('/admin/usage')
|
||||||
|
def admin_usage():
|
||||||
|
"""OTC 사용량 조회 · 주문 페이지"""
|
||||||
|
return render_template('admin_usage.html')
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/admin/rx-usage')
|
||||||
|
def admin_rx_usage():
|
||||||
|
"""전문의약품 사용량 조회 · 주문 페이지"""
|
||||||
|
return render_template('admin_rx_usage.html')
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/api/usage')
|
||||||
|
def api_usage():
|
||||||
|
"""
|
||||||
|
기간별 품목 사용량 조회 API
|
||||||
|
GET /api/usage?start_date=2026-01-01&end_date=2026-01-31&search=타이레놀&sort=qty_desc
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
start_date = request.args.get('start_date', '')
|
||||||
|
end_date = request.args.get('end_date', '')
|
||||||
|
search = request.args.get('search', '').strip()
|
||||||
|
sort = request.args.get('sort', 'qty_desc') # qty_desc, qty_asc, name_asc, amount_desc
|
||||||
|
|
||||||
|
# 날짜 형식 변환 (YYYY-MM-DD -> YYYYMMDD)
|
||||||
|
start_date_fmt = start_date.replace('-', '') if start_date else datetime.now().strftime('%Y%m%d')
|
||||||
|
end_date_fmt = end_date.replace('-', '') if end_date else datetime.now().strftime('%Y%m%d')
|
||||||
|
|
||||||
|
mssql_session = db_manager.get_session('PM_PRES')
|
||||||
|
|
||||||
|
# 품목별 사용량 집계 쿼리
|
||||||
|
usage_query = text("""
|
||||||
|
SELECT
|
||||||
|
S.DrugCode as drug_code,
|
||||||
|
ISNULL(G.GoodsName, '알 수 없음') as product_name,
|
||||||
|
CASE
|
||||||
|
WHEN G.SplName IS NOT NULL AND G.SplName != '' THEN G.SplName
|
||||||
|
WHEN SET_CHK.is_set = 1 THEN '세트상품'
|
||||||
|
ELSE ''
|
||||||
|
END as supplier,
|
||||||
|
SUM(ISNULL(S.QUAN, 1)) as total_qty,
|
||||||
|
SUM(ISNULL(S.SL_TOTAL_PRICE, 0)) as total_amount,
|
||||||
|
COALESCE(NULLIF(G.BARCODE, ''), U.CD_CD_BARCODE, '') as barcode
|
||||||
|
FROM SALE_SUB S
|
||||||
|
LEFT JOIN PM_DRUG.dbo.CD_GOODS G ON S.DrugCode = G.DrugCode
|
||||||
|
OUTER APPLY (
|
||||||
|
SELECT TOP 1 CD_CD_BARCODE
|
||||||
|
FROM PM_DRUG.dbo.CD_ITEM_UNIT_MEMBER
|
||||||
|
WHERE DRUGCODE = S.DrugCode AND CD_CD_BARCODE IS NOT NULL AND CD_CD_BARCODE != ''
|
||||||
|
) U
|
||||||
|
OUTER APPLY (
|
||||||
|
SELECT TOP 1 1 as is_set
|
||||||
|
FROM PM_DRUG.dbo.CD_item_set
|
||||||
|
WHERE SetCode = S.DrugCode AND DrugCode = 'SET0000'
|
||||||
|
) SET_CHK
|
||||||
|
WHERE S.SL_DT_appl >= :start_date
|
||||||
|
AND S.SL_DT_appl <= :end_date
|
||||||
|
GROUP BY S.DrugCode, G.GoodsName, G.SplName, SET_CHK.is_set, G.BARCODE, U.CD_CD_BARCODE
|
||||||
|
ORDER BY SUM(ISNULL(S.QUAN, 1)) DESC
|
||||||
|
""")
|
||||||
|
|
||||||
|
rows = mssql_session.execute(usage_query, {
|
||||||
|
'start_date': start_date_fmt,
|
||||||
|
'end_date': end_date_fmt
|
||||||
|
}).fetchall()
|
||||||
|
|
||||||
|
items = []
|
||||||
|
total_qty = 0
|
||||||
|
total_amount = 0
|
||||||
|
|
||||||
|
for row in rows:
|
||||||
|
drug_code = row.drug_code or ''
|
||||||
|
product_name = row.product_name or ''
|
||||||
|
|
||||||
|
# 검색 필터
|
||||||
|
if search:
|
||||||
|
search_lower = search.lower()
|
||||||
|
if (search_lower not in product_name.lower() and
|
||||||
|
search_lower not in drug_code.lower()):
|
||||||
|
continue
|
||||||
|
|
||||||
|
qty = int(row.total_qty or 0)
|
||||||
|
amount = float(row.total_amount or 0)
|
||||||
|
|
||||||
|
items.append({
|
||||||
|
'drug_code': drug_code,
|
||||||
|
'product_name': product_name,
|
||||||
|
'supplier': row.supplier or '',
|
||||||
|
'barcode': row.barcode or '',
|
||||||
|
'total_qty': qty,
|
||||||
|
'total_amount': int(amount),
|
||||||
|
'thumbnail': None
|
||||||
|
})
|
||||||
|
|
||||||
|
total_qty += qty
|
||||||
|
total_amount += amount
|
||||||
|
|
||||||
|
# 정렬
|
||||||
|
if sort == 'qty_asc':
|
||||||
|
items.sort(key=lambda x: x['total_qty'])
|
||||||
|
elif sort == 'qty_desc':
|
||||||
|
items.sort(key=lambda x: x['total_qty'], reverse=True)
|
||||||
|
elif sort == 'name_asc':
|
||||||
|
items.sort(key=lambda x: x['product_name'])
|
||||||
|
elif sort == 'amount_desc':
|
||||||
|
items.sort(key=lambda x: x['total_amount'], reverse=True)
|
||||||
|
|
||||||
|
# 제품 이미지 조회
|
||||||
|
try:
|
||||||
|
images_db_path = Path(__file__).parent / 'db' / 'product_images.db'
|
||||||
|
if images_db_path.exists():
|
||||||
|
img_conn = sqlite3.connect(str(images_db_path))
|
||||||
|
img_cursor = img_conn.cursor()
|
||||||
|
|
||||||
|
barcodes = [item['barcode'] for item in items if item['barcode']]
|
||||||
|
drug_codes = [item['drug_code'] for item in items]
|
||||||
|
|
||||||
|
image_map = {}
|
||||||
|
if barcodes:
|
||||||
|
placeholders = ','.join(['?' for _ in barcodes])
|
||||||
|
img_cursor.execute(f'''
|
||||||
|
SELECT barcode, thumbnail_base64
|
||||||
|
FROM product_images
|
||||||
|
WHERE barcode IN ({placeholders}) AND thumbnail_base64 IS NOT NULL
|
||||||
|
''', barcodes)
|
||||||
|
for r in img_cursor.fetchall():
|
||||||
|
image_map[f'bc:{r[0]}'] = r[1]
|
||||||
|
|
||||||
|
if drug_codes:
|
||||||
|
placeholders = ','.join(['?' for _ in drug_codes])
|
||||||
|
img_cursor.execute(f'''
|
||||||
|
SELECT drug_code, thumbnail_base64
|
||||||
|
FROM product_images
|
||||||
|
WHERE drug_code IN ({placeholders}) AND thumbnail_base64 IS NOT NULL
|
||||||
|
''', drug_codes)
|
||||||
|
for r in img_cursor.fetchall():
|
||||||
|
if f'dc:{r[0]}' not in image_map:
|
||||||
|
image_map[f'dc:{r[0]}'] = r[1]
|
||||||
|
|
||||||
|
img_conn.close()
|
||||||
|
|
||||||
|
for item in items:
|
||||||
|
thumb = image_map.get(f'bc:{item["barcode"]}') or image_map.get(f'dc:{item["drug_code"]}')
|
||||||
|
if thumb:
|
||||||
|
item['thumbnail'] = thumb
|
||||||
|
except Exception as img_err:
|
||||||
|
logging.warning(f"제품 이미지 조회 오류: {img_err}")
|
||||||
|
|
||||||
|
# 기간 일수 계산
|
||||||
|
try:
|
||||||
|
from datetime import datetime as dt
|
||||||
|
start_dt = dt.strptime(start_date_fmt, '%Y%m%d')
|
||||||
|
end_dt = dt.strptime(end_date_fmt, '%Y%m%d')
|
||||||
|
period_days = (end_dt - start_dt).days + 1
|
||||||
|
except:
|
||||||
|
period_days = 1
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'success': True,
|
||||||
|
'items': items[:500], # 최대 500건
|
||||||
|
'stats': {
|
||||||
|
'period_days': period_days,
|
||||||
|
'product_count': len(items),
|
||||||
|
'total_qty': total_qty,
|
||||||
|
'total_amount': int(total_amount)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logging.error(f"사용량 조회 오류: {e}")
|
||||||
|
return jsonify({
|
||||||
|
'success': False,
|
||||||
|
'error': str(e)
|
||||||
|
}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/api/rx-usage')
|
||||||
|
def api_rx_usage():
|
||||||
|
"""
|
||||||
|
전문의약품(처방전) 기간별 사용량 조회 API
|
||||||
|
GET /api/rx-usage?start_date=2026-01-01&end_date=2026-01-31&search=타이레놀&sort=qty_desc
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
start_date = request.args.get('start_date', '')
|
||||||
|
end_date = request.args.get('end_date', '')
|
||||||
|
search = request.args.get('search', '').strip()
|
||||||
|
sort = request.args.get('sort', 'qty_desc')
|
||||||
|
|
||||||
|
# 날짜 형식 변환 (YYYY-MM-DD -> YYYYMMDD)
|
||||||
|
start_date_fmt = start_date.replace('-', '') if start_date else datetime.now().strftime('%Y%m%d')
|
||||||
|
end_date_fmt = end_date.replace('-', '') if end_date else datetime.now().strftime('%Y%m%d')
|
||||||
|
|
||||||
|
mssql_session = db_manager.get_session('PM_PRES')
|
||||||
|
|
||||||
|
# 전문의약품 품목별 사용량 집계 쿼리 (현재고: IM_total.IM_QT_sale_debit)
|
||||||
|
rx_query = text("""
|
||||||
|
SELECT
|
||||||
|
P.DrugCode as drug_code,
|
||||||
|
ISNULL(G.GoodsName, '알 수 없음') as product_name,
|
||||||
|
ISNULL(G.SplName, '') as supplier,
|
||||||
|
SUM(ISNULL(P.QUAN, 1)) as total_qty,
|
||||||
|
SUM(ISNULL(P.QUAN, 1) * ISNULL(P.Days, 1)) as total_dose,
|
||||||
|
SUM(ISNULL(P.DRUPRICE, 0) * ISNULL(P.QUAN, 1) * ISNULL(P.Days, 1)) as total_amount,
|
||||||
|
COUNT(DISTINCT P.PreSerial) as prescription_count,
|
||||||
|
COALESCE(NULLIF(G.BARCODE, ''), '') as barcode,
|
||||||
|
ISNULL(IT.IM_QT_sale_debit, 0) as current_stock
|
||||||
|
FROM PS_sub_pharm P
|
||||||
|
LEFT JOIN PM_DRUG.dbo.CD_GOODS G ON P.DrugCode = G.DrugCode
|
||||||
|
LEFT JOIN PM_DRUG.dbo.IM_total IT ON P.DrugCode = IT.DrugCode
|
||||||
|
WHERE P.Indate >= :start_date
|
||||||
|
AND P.Indate <= :end_date
|
||||||
|
GROUP BY P.DrugCode, G.GoodsName, G.SplName, G.BARCODE, IT.IM_QT_sale_debit
|
||||||
|
ORDER BY SUM(ISNULL(P.QUAN, 1) * ISNULL(P.Days, 1)) DESC
|
||||||
|
""")
|
||||||
|
|
||||||
|
rows = mssql_session.execute(rx_query, {
|
||||||
|
'start_date': start_date_fmt,
|
||||||
|
'end_date': end_date_fmt
|
||||||
|
}).fetchall()
|
||||||
|
|
||||||
|
items = []
|
||||||
|
total_qty = 0
|
||||||
|
total_dose = 0
|
||||||
|
total_amount = 0
|
||||||
|
total_prescriptions = set()
|
||||||
|
|
||||||
|
for row in rows:
|
||||||
|
drug_code = row.drug_code or ''
|
||||||
|
product_name = row.product_name or ''
|
||||||
|
|
||||||
|
# 검색 필터
|
||||||
|
if search:
|
||||||
|
search_lower = search.lower()
|
||||||
|
if (search_lower not in product_name.lower() and
|
||||||
|
search_lower not in drug_code.lower()):
|
||||||
|
continue
|
||||||
|
|
||||||
|
qty = int(row.total_qty or 0)
|
||||||
|
dose = int(row.total_dose or 0)
|
||||||
|
amount = float(row.total_amount or 0)
|
||||||
|
rx_count = int(row.prescription_count or 0)
|
||||||
|
|
||||||
|
items.append({
|
||||||
|
'drug_code': drug_code,
|
||||||
|
'product_name': product_name,
|
||||||
|
'supplier': row.supplier or '',
|
||||||
|
'barcode': row.barcode or '',
|
||||||
|
'total_qty': qty,
|
||||||
|
'total_dose': dose, # 총 투약량 (수량 x 일수)
|
||||||
|
'total_amount': int(amount),
|
||||||
|
'prescription_count': rx_count,
|
||||||
|
'current_stock': int(row.current_stock or 0), # 현재고
|
||||||
|
'thumbnail': None
|
||||||
|
})
|
||||||
|
|
||||||
|
total_qty += qty
|
||||||
|
total_dose += dose
|
||||||
|
total_amount += amount
|
||||||
|
|
||||||
|
# 정렬
|
||||||
|
if sort == 'qty_asc':
|
||||||
|
items.sort(key=lambda x: x['total_dose'])
|
||||||
|
elif sort == 'qty_desc':
|
||||||
|
items.sort(key=lambda x: x['total_dose'], reverse=True)
|
||||||
|
elif sort == 'name_asc':
|
||||||
|
items.sort(key=lambda x: x['product_name'])
|
||||||
|
elif sort == 'amount_desc':
|
||||||
|
items.sort(key=lambda x: x['total_amount'], reverse=True)
|
||||||
|
elif sort == 'rx_desc':
|
||||||
|
items.sort(key=lambda x: x['prescription_count'], reverse=True)
|
||||||
|
|
||||||
|
# 제품 이미지 조회
|
||||||
|
try:
|
||||||
|
images_db_path = Path(__file__).parent / 'db' / 'product_images.db'
|
||||||
|
if images_db_path.exists():
|
||||||
|
img_conn = sqlite3.connect(str(images_db_path))
|
||||||
|
img_cursor = img_conn.cursor()
|
||||||
|
|
||||||
|
drug_codes = [item['drug_code'] for item in items]
|
||||||
|
|
||||||
|
image_map = {}
|
||||||
|
if drug_codes:
|
||||||
|
placeholders = ','.join(['?' for _ in drug_codes])
|
||||||
|
img_cursor.execute(f'''
|
||||||
|
SELECT drug_code, thumbnail_base64
|
||||||
|
FROM product_images
|
||||||
|
WHERE drug_code IN ({placeholders}) AND thumbnail_base64 IS NOT NULL
|
||||||
|
''', drug_codes)
|
||||||
|
for r in img_cursor.fetchall():
|
||||||
|
image_map[r[0]] = r[1]
|
||||||
|
|
||||||
|
img_conn.close()
|
||||||
|
|
||||||
|
for item in items:
|
||||||
|
if item['drug_code'] in image_map:
|
||||||
|
item['thumbnail'] = image_map[item['drug_code']]
|
||||||
|
except Exception as img_err:
|
||||||
|
logging.warning(f"제품 이미지 조회 오류: {img_err}")
|
||||||
|
|
||||||
|
# 기간 일수 계산
|
||||||
|
try:
|
||||||
|
from datetime import datetime as dt
|
||||||
|
start_dt = dt.strptime(start_date_fmt, '%Y%m%d')
|
||||||
|
end_dt = dt.strptime(end_date_fmt, '%Y%m%d')
|
||||||
|
period_days = (end_dt - start_dt).days + 1
|
||||||
|
except:
|
||||||
|
period_days = 1
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'success': True,
|
||||||
|
'items': items[:500],
|
||||||
|
'stats': {
|
||||||
|
'period_days': period_days,
|
||||||
|
'product_count': len(items),
|
||||||
|
'total_qty': total_qty,
|
||||||
|
'total_dose': total_dose,
|
||||||
|
'total_amount': int(total_amount)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logging.error(f"전문의약품 사용량 조회 오류: {e}")
|
||||||
|
return jsonify({
|
||||||
|
'success': False,
|
||||||
|
'error': str(e)
|
||||||
|
}), 500
|
||||||
|
|
||||||
|
|
||||||
# ===== Claude 상태 API =====
|
# ===== Claude 상태 API =====
|
||||||
|
|
||||||
@app.route('/api/claude-status')
|
@app.route('/api/claude-status')
|
||||||
|
|||||||
1121
backend/templates/admin_rx_usage.html
Normal file
1121
backend/templates/admin_rx_usage.html
Normal file
File diff suppressed because it is too large
Load Diff
1080
backend/templates/admin_usage.html
Normal file
1080
backend/templates/admin_usage.html
Normal file
File diff suppressed because it is too large
Load Diff
Loading…
Reference in New Issue
Block a user