feat: 동물약 안내서/제품 이미지 외부 API 추가 (CORS 지원)
- GET /api/animal-drug-print/{apc|barcode} - 바로 인쇄
- GET /api/product-image/{barcode} - 제품 이미지 반환
- GET /api/product-image-info/{barcode} - 이미지 메타데이터
- 바코드→APC 자동 변환 지원
This commit is contained in:
456
backend/app.py
456
backend/app.py
@@ -16,7 +16,7 @@ if sys.platform == 'win32':
|
||||
sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding='utf-8')
|
||||
os.environ.setdefault('PYTHONIOENCODING', 'utf-8')
|
||||
|
||||
from flask import Flask, request, render_template, render_template_string, jsonify, redirect, url_for, session
|
||||
from flask import Flask, request, render_template, render_template_string, jsonify, redirect, url_for, session, make_response
|
||||
import hashlib
|
||||
import base64
|
||||
import secrets
|
||||
@@ -8535,6 +8535,323 @@ def api_animal_drug_info_print():
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
# 동물약 정보 인쇄 API (GET - URL로 바로 인쇄) - 외부 CORS 지원
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
@app.route('/api/animal-drug-print/<code>')
|
||||
def api_animal_drug_print_by_code(code):
|
||||
"""
|
||||
동물약 안내서 바로 인쇄 (GET 방식)
|
||||
GET /api/animal-drug-print/0231093520106 (APC 코드)
|
||||
GET /api/animal-drug-print/8809720800035 (바코드)
|
||||
|
||||
- CORS 지원 (외부에서 호출 가능)
|
||||
- APC 또는 바코드로 조회 후 ESC/POS 프린터로 인쇄
|
||||
"""
|
||||
try:
|
||||
import re
|
||||
from html import unescape
|
||||
|
||||
apc = None
|
||||
product_name = None
|
||||
|
||||
# 코드 타입 판별 (APC vs 바코드)
|
||||
if code.startswith('023'):
|
||||
# APC 코드
|
||||
apc = code
|
||||
else:
|
||||
# 바코드로 가정 → APC 조회
|
||||
try:
|
||||
conn_str = (
|
||||
'DRIVER={ODBC Driver 17 for SQL Server};'
|
||||
'SERVER=192.168.0.4\\PM2014;'
|
||||
'DATABASE=PM_DRUG;'
|
||||
'UID=sa;'
|
||||
'PWD=tmddls214!%(;'
|
||||
'TrustServerCertificate=yes'
|
||||
)
|
||||
import pyodbc
|
||||
conn = pyodbc.connect(conn_str, timeout=10)
|
||||
cursor = conn.cursor()
|
||||
|
||||
# 바코드 → APC 조회
|
||||
cursor.execute("""
|
||||
SELECT TOP 1
|
||||
U.CD_CD_BARCODE as apc,
|
||||
G.GoodsName as product_name
|
||||
FROM CD_ITEM_UNIT_MEMBER U
|
||||
JOIN CD_GOODS G ON U.DRUGCODE = G.DrugCode
|
||||
WHERE U.CD_CD_BARCODE = ? OR G.BARCODE = ?
|
||||
""", (code, code))
|
||||
row = cursor.fetchone()
|
||||
conn.close()
|
||||
|
||||
if row and row.apc:
|
||||
apc = row.apc
|
||||
product_name = row.product_name
|
||||
else:
|
||||
response = jsonify({'success': False, 'error': f'바코드 {code}에 해당하는 APC를 찾을 수 없습니다'})
|
||||
response.headers['Access-Control-Allow-Origin'] = '*'
|
||||
return response, 404
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"바코드→APC 조회 오류: {e}")
|
||||
response = jsonify({'success': False, 'error': f'바코드 조회 오류: {str(e)}'})
|
||||
response.headers['Access-Control-Allow-Origin'] = '*'
|
||||
return response, 500
|
||||
|
||||
if not apc:
|
||||
response = jsonify({'success': False, 'error': 'APC 코드가 필요합니다'})
|
||||
response.headers['Access-Control-Allow-Origin'] = '*'
|
||||
return response, 400
|
||||
|
||||
# PostgreSQL에서 약품 정보 조회
|
||||
try:
|
||||
from sqlalchemy import create_engine
|
||||
pg_engine = create_engine(
|
||||
'postgresql://admin:trajet6640@192.168.0.87:5432/apdb_master',
|
||||
connect_args={'connect_timeout': 5}
|
||||
)
|
||||
|
||||
with pg_engine.connect() as conn:
|
||||
result = conn.execute(text("""
|
||||
SELECT
|
||||
a.product_name,
|
||||
a.company_name,
|
||||
a.main_ingredient,
|
||||
a.efficacy_effect,
|
||||
a.dosage_instructions,
|
||||
a.precautions,
|
||||
a.weight_min_kg,
|
||||
a.weight_max_kg,
|
||||
a.pet_size_label,
|
||||
a.component_code,
|
||||
g.component_name_ko,
|
||||
g.dosing_interval_adult,
|
||||
g.dosing_interval_high_risk,
|
||||
g.dosing_interval_puppy,
|
||||
g.companion_drugs
|
||||
FROM apc a
|
||||
LEFT JOIN component_guide g ON a.component_code = g.component_code
|
||||
WHERE a.apc = :apc
|
||||
LIMIT 1
|
||||
"""), {'apc': apc})
|
||||
row = result.fetchone()
|
||||
|
||||
# 포장단위 APC → 대표 APC 폴백
|
||||
if not row and len(apc) == 13 and apc.startswith('023'):
|
||||
item_prefix = apc[:8]
|
||||
result = conn.execute(text("""
|
||||
SELECT
|
||||
a.product_name, a.company_name, a.main_ingredient,
|
||||
a.efficacy_effect, a.dosage_instructions, a.precautions,
|
||||
a.weight_min_kg, a.weight_max_kg, a.pet_size_label,
|
||||
a.component_code,
|
||||
g.component_name_ko, g.dosing_interval_adult,
|
||||
g.dosing_interval_high_risk, g.dosing_interval_puppy,
|
||||
g.companion_drugs
|
||||
FROM apc a
|
||||
LEFT JOIN component_guide g ON a.component_code = g.component_code
|
||||
WHERE a.apc LIKE :prefix
|
||||
ORDER BY LENGTH(a.apc)
|
||||
LIMIT 1
|
||||
"""), {'prefix': f'{item_prefix}%'})
|
||||
row = result.fetchone()
|
||||
|
||||
if not row:
|
||||
response = jsonify({'success': False, 'error': f'APC {apc} 정보를 찾을 수 없습니다'})
|
||||
response.headers['Access-Control-Allow-Origin'] = '*'
|
||||
return response, 404
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"PostgreSQL 조회 오류: {e}")
|
||||
response = jsonify({'success': False, 'error': f'DB 조회 오류: {str(e)}'})
|
||||
response.headers['Access-Control-Allow-Origin'] = '*'
|
||||
return response, 500
|
||||
|
||||
# HTML 태그 제거 함수
|
||||
def strip_html(html_text):
|
||||
if not html_text:
|
||||
return ''
|
||||
text = re.sub(r'</p>|<br\s*/?>|</div>', '\n', html_text)
|
||||
text = re.sub(r'<[^>]+>', '', text)
|
||||
text = unescape(text)
|
||||
if '─' in text or '━' in text or ('======' in text and '------' in text):
|
||||
lines = text.split('\n')
|
||||
cleaned = [line.strip() for line in lines if line.strip()]
|
||||
return '\n'.join(cleaned)
|
||||
else:
|
||||
text = re.sub(r'\s+', ' ', text).strip()
|
||||
return text
|
||||
|
||||
# 항목별 줄바꿈 처리
|
||||
def format_for_print(text):
|
||||
if not text:
|
||||
return ''
|
||||
text = re.sub(r'\s*(가|나|다|라|마|바|사|아|자)\.\s*', r'\n\1. ', text)
|
||||
text = re.sub(r'\s+(\d+)\)\s*', r'\n \1) ', text)
|
||||
return text.strip()
|
||||
|
||||
# 텍스트 줄바꿈
|
||||
def wrap_text(text, width=40):
|
||||
lines = []
|
||||
words = text.split()
|
||||
current_line = ""
|
||||
for word in words:
|
||||
if len(current_line) + len(word) + 1 <= width:
|
||||
current_line += (" " if current_line else "") + word
|
||||
else:
|
||||
if current_line:
|
||||
lines.append(current_line)
|
||||
current_line = word
|
||||
if current_line:
|
||||
lines.append(current_line)
|
||||
return lines
|
||||
|
||||
# 데이터 파싱
|
||||
pg_product_name = row.product_name or product_name or ''
|
||||
company = row.company_name or ''
|
||||
ingredient = row.main_ingredient or ''
|
||||
efficacy = strip_html(row.efficacy_effect)
|
||||
dosage = strip_html(row.dosage_instructions)
|
||||
precautions = strip_html(row.precautions)
|
||||
|
||||
# 80mm 프린터 = 48자 기준
|
||||
LINE = "=" * 48
|
||||
THIN = "-" * 48
|
||||
|
||||
message = f"""
|
||||
{LINE}
|
||||
[ 애니팜 투약지도서 ]
|
||||
{LINE}
|
||||
|
||||
{pg_product_name}
|
||||
"""
|
||||
if company:
|
||||
message += f"제조: {company}\n"
|
||||
|
||||
if ingredient and ingredient != 'NaN':
|
||||
message += f"""
|
||||
{THIN}
|
||||
▶ 주성분
|
||||
"""
|
||||
for line in wrap_text(ingredient, 46):
|
||||
message += f" {line}\n"
|
||||
|
||||
if efficacy:
|
||||
message += f"""
|
||||
{THIN}
|
||||
▶ 효능효과
|
||||
"""
|
||||
formatted_efficacy = format_for_print(efficacy)
|
||||
for para in formatted_efficacy.split('\n'):
|
||||
for line in wrap_text(para.strip(), 44):
|
||||
message += f" {line}\n"
|
||||
|
||||
if dosage:
|
||||
message += f"""
|
||||
{THIN}
|
||||
▶ 용법용량
|
||||
"""
|
||||
has_box_table = '─' in dosage or '━' in dosage
|
||||
has_ascii_table = '======' in dosage and '------' in dosage
|
||||
if has_box_table:
|
||||
for line in dosage.split('\n'):
|
||||
line = line.strip()
|
||||
if line:
|
||||
message += f"{line}\n"
|
||||
elif has_ascii_table:
|
||||
for line in dosage.split('\n'):
|
||||
stripped = line.strip()
|
||||
if not stripped:
|
||||
continue
|
||||
if stripped.startswith('===') or stripped.startswith('---'):
|
||||
message += f" {'─' * 44}\n"
|
||||
else:
|
||||
message += f" {stripped}\n"
|
||||
else:
|
||||
formatted_dosage = format_for_print(dosage)
|
||||
for para in formatted_dosage.split('\n'):
|
||||
for line in wrap_text(para.strip(), 44):
|
||||
message += f" {line}\n"
|
||||
|
||||
# 투약 주기
|
||||
if row.dosing_interval_adult:
|
||||
message += f"""
|
||||
{THIN}
|
||||
★ 투약 주기 ★
|
||||
"""
|
||||
message += f" 일반: {row.dosing_interval_adult}\n"
|
||||
if row.dosing_interval_high_risk:
|
||||
message += f" 고위험: {row.dosing_interval_high_risk}\n"
|
||||
if row.dosing_interval_puppy:
|
||||
message += f" 새끼: {row.dosing_interval_puppy}\n"
|
||||
|
||||
# 병용약 권장
|
||||
if row.companion_drugs:
|
||||
message += f"""
|
||||
{THIN}
|
||||
★ 함께 투약 권장 ★
|
||||
"""
|
||||
for line in wrap_text(row.companion_drugs, 44):
|
||||
message += f" {line}\n"
|
||||
|
||||
# 주의사항
|
||||
if precautions:
|
||||
message += f"""
|
||||
{THIN}
|
||||
▶ 주의사항
|
||||
"""
|
||||
formatted_precautions = format_for_print(precautions)
|
||||
for para in formatted_precautions.split('\n'):
|
||||
for line in wrap_text(para.strip(), 44):
|
||||
message += f" {line}\n"
|
||||
|
||||
message += f"""
|
||||
{LINE}
|
||||
청 춘 약 국
|
||||
Tel: 033-481-5222
|
||||
"""
|
||||
|
||||
# 네트워크 프린터로 인쇄
|
||||
from pos_printer import print_text
|
||||
|
||||
result = print_text(message, cut=True)
|
||||
|
||||
if result:
|
||||
response = jsonify({
|
||||
'success': True,
|
||||
'message': '동물약 안내서 인쇄 완료',
|
||||
'apc': apc,
|
||||
'product_name': pg_product_name
|
||||
})
|
||||
response.headers['Access-Control-Allow-Origin'] = '*'
|
||||
return response
|
||||
else:
|
||||
response = jsonify({'success': False, 'error': '프린터 출력 실패'})
|
||||
response.headers['Access-Control-Allow-Origin'] = '*'
|
||||
return response, 500
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"동물약 GET 인쇄 API 오류: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
response = jsonify({'success': False, 'error': str(e)})
|
||||
response.headers['Access-Control-Allow-Origin'] = '*'
|
||||
return response, 500
|
||||
|
||||
|
||||
@app.route('/api/animal-drug-print/<code>', methods=['OPTIONS'])
|
||||
def api_animal_drug_print_options(code):
|
||||
"""CORS preflight 요청 처리"""
|
||||
response = make_response()
|
||||
response.headers['Access-Control-Allow-Origin'] = '*'
|
||||
response.headers['Access-Control-Allow-Methods'] = 'GET, OPTIONS'
|
||||
response.headers['Access-Control-Allow-Headers'] = 'Content-Type'
|
||||
return response
|
||||
|
||||
|
||||
@app.route('/api/animal-drug-info/preview', methods=['POST'])
|
||||
def api_animal_drug_info_preview():
|
||||
"""동물약 정보 미리보기 (텍스트 반환)"""
|
||||
@@ -10386,6 +10703,143 @@ def api_stock_forecast():
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════════════════════════════
|
||||
# 바코드로 제품 이미지 조회 API (외부 CORS 지원)
|
||||
# ══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
@app.route('/api/product-image/<barcode>')
|
||||
def api_product_image_by_barcode(barcode):
|
||||
"""
|
||||
바코드로 제품 이미지 반환 API
|
||||
GET /api/product-image/8806265001309
|
||||
GET /api/product-image/8806265001309?type=thumbnail
|
||||
|
||||
- CORS 지원 (외부에서 호출 가능)
|
||||
- type=thumbnail: 썸네일 반환
|
||||
- 이미지가 없으면 404
|
||||
"""
|
||||
try:
|
||||
img_type = request.args.get('type', 'full') # full 또는 thumbnail
|
||||
|
||||
img_db_path = os.path.join(os.path.dirname(__file__), 'db', 'product_images.db')
|
||||
conn = sqlite3.connect(img_db_path)
|
||||
conn.row_factory = sqlite3.Row
|
||||
cursor = conn.cursor()
|
||||
|
||||
if img_type == 'thumbnail':
|
||||
cursor.execute("""
|
||||
SELECT thumbnail_base64, product_name
|
||||
FROM product_images
|
||||
WHERE barcode = ? AND thumbnail_base64 IS NOT NULL
|
||||
""", (barcode,))
|
||||
col_name = 'thumbnail_base64'
|
||||
else:
|
||||
cursor.execute("""
|
||||
SELECT image_base64, product_name
|
||||
FROM product_images
|
||||
WHERE barcode = ? AND image_base64 IS NOT NULL
|
||||
""", (barcode,))
|
||||
col_name = 'image_base64'
|
||||
|
||||
row = cursor.fetchone()
|
||||
conn.close()
|
||||
|
||||
if not row or not row[col_name]:
|
||||
response = jsonify({'success': False, 'error': 'Image not found'})
|
||||
response.status_code = 404
|
||||
response.headers['Access-Control-Allow-Origin'] = '*'
|
||||
return response
|
||||
|
||||
# base64 디코딩
|
||||
import base64
|
||||
image_data = base64.b64decode(row[col_name])
|
||||
|
||||
# 이미지 응답 생성
|
||||
response = make_response(image_data)
|
||||
response.headers['Content-Type'] = 'image/jpeg'
|
||||
response.headers['Access-Control-Allow-Origin'] = '*'
|
||||
response.headers['Access-Control-Allow-Methods'] = 'GET, OPTIONS'
|
||||
response.headers['Cache-Control'] = 'public, max-age=86400' # 24시간 캐시
|
||||
response.headers['X-Product-Name'] = row['product_name'].encode('utf-8').decode('latin-1') if row['product_name'] else ''
|
||||
|
||||
return response
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"product-image API 오류: {e}")
|
||||
response = jsonify({'success': False, 'error': str(e)})
|
||||
response.status_code = 500
|
||||
response.headers['Access-Control-Allow-Origin'] = '*'
|
||||
return response
|
||||
|
||||
|
||||
@app.route('/api/product-image/<barcode>', methods=['OPTIONS'])
|
||||
def api_product_image_options(barcode):
|
||||
"""CORS preflight 요청 처리"""
|
||||
response = make_response()
|
||||
response.headers['Access-Control-Allow-Origin'] = '*'
|
||||
response.headers['Access-Control-Allow-Methods'] = 'GET, OPTIONS'
|
||||
response.headers['Access-Control-Allow-Headers'] = 'Content-Type'
|
||||
return response
|
||||
|
||||
|
||||
@app.route('/api/product-image-info/<barcode>')
|
||||
def api_product_image_info(barcode):
|
||||
"""
|
||||
바코드로 제품 이미지 정보 조회 (JSON)
|
||||
GET /api/product-image-info/8806265001309
|
||||
|
||||
이미지 존재 여부, 제품명, URL 등 메타데이터 반환
|
||||
"""
|
||||
try:
|
||||
img_db_path = os.path.join(os.path.dirname(__file__), 'db', 'product_images.db')
|
||||
conn = sqlite3.connect(img_db_path)
|
||||
conn.row_factory = sqlite3.Row
|
||||
cursor = conn.cursor()
|
||||
|
||||
cursor.execute("""
|
||||
SELECT barcode, drug_code, product_name, image_url, source, status,
|
||||
CASE WHEN image_base64 IS NOT NULL THEN 1 ELSE 0 END as has_image,
|
||||
CASE WHEN thumbnail_base64 IS NOT NULL THEN 1 ELSE 0 END as has_thumbnail,
|
||||
created_at, updated_at
|
||||
FROM product_images
|
||||
WHERE barcode = ?
|
||||
""", (barcode,))
|
||||
|
||||
row = cursor.fetchone()
|
||||
conn.close()
|
||||
|
||||
if not row:
|
||||
response = jsonify({'success': False, 'error': 'Product not found'})
|
||||
response.headers['Access-Control-Allow-Origin'] = '*'
|
||||
return response, 404
|
||||
|
||||
response = jsonify({
|
||||
'success': True,
|
||||
'data': {
|
||||
'barcode': row['barcode'],
|
||||
'drug_code': row['drug_code'],
|
||||
'product_name': row['product_name'],
|
||||
'image_url': f"/api/product-image/{row['barcode']}",
|
||||
'thumbnail_url': f"/api/product-image/{row['barcode']}?type=thumbnail",
|
||||
'original_url': row['image_url'],
|
||||
'source': row['source'],
|
||||
'status': row['status'],
|
||||
'has_image': bool(row['has_image']),
|
||||
'has_thumbnail': bool(row['has_thumbnail']),
|
||||
'created_at': row['created_at'],
|
||||
'updated_at': row['updated_at']
|
||||
}
|
||||
})
|
||||
response.headers['Access-Control-Allow-Origin'] = '*'
|
||||
return response
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"product-image-info API 오류: {e}")
|
||||
response = jsonify({'success': False, 'error': str(e)})
|
||||
response.headers['Access-Control-Allow-Origin'] = '*'
|
||||
return response, 500
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
import os
|
||||
|
||||
|
||||
Reference in New Issue
Block a user