feat: 반려동물 등록 기능 및 확장 마이페이지 추가
- pets 테이블 추가 (이름, 종류, 품종, 사진 등) - 반려동물 CRUD API (/api/pets) - 확장 마이페이지 (/mypage) - 카카오 로그인 기반 - 기존 마이페이지에 퀵 메뉴 추가 (반려동물/쿠폰/구매내역/내정보) - 카카오 로그인 시 세션에 user_id 저장 - 동물약 APC 매핑 가이드 문서 추가
This commit is contained in:
parent
f102f6b42e
commit
1cebb02ec6
435
backend/app.py
435
backend/app.py
@ -492,8 +492,8 @@ def normalize_kakao_phone(kakao_phone):
|
||||
return None
|
||||
|
||||
|
||||
def link_kakao_identity(user_id, kakao_id, kakao_data):
|
||||
"""카카오 계정을 customer_identities에 연결"""
|
||||
def link_kakao_identity(user_id, kakao_id, kakao_data, token_data=None):
|
||||
"""카카오 계정을 customer_identities에 연결 (토큰 포함)"""
|
||||
conn = db_manager.get_sqlite_connection()
|
||||
cursor = conn.cursor()
|
||||
|
||||
@ -504,13 +504,33 @@ def link_kakao_identity(user_id, kakao_id, kakao_data):
|
||||
|
||||
is_new_link = cursor.fetchone() is None
|
||||
|
||||
store_data = {k: v for k, v in kakao_data.items() if k != 'raw_data'}
|
||||
|
||||
if is_new_link:
|
||||
# raw_data 제외 (세션 크기 절약)
|
||||
store_data = {k: v for k, v in kakao_data.items() if k != 'raw_data'}
|
||||
cursor.execute("""
|
||||
INSERT INTO customer_identities (user_id, provider, provider_user_id, provider_data)
|
||||
VALUES (?, 'kakao', ?, ?)
|
||||
""", (user_id, kakao_id, json.dumps(store_data, ensure_ascii=False)))
|
||||
INSERT INTO customer_identities
|
||||
(user_id, provider, provider_user_id, provider_data, access_token, refresh_token, token_expires_at)
|
||||
VALUES (?, 'kakao', ?, ?, ?, ?, ?)
|
||||
""", (
|
||||
user_id, kakao_id,
|
||||
json.dumps(store_data, ensure_ascii=False),
|
||||
token_data.get('access_token') if token_data else None,
|
||||
token_data.get('refresh_token') if token_data else None,
|
||||
token_data.get('expires_at') if token_data else None,
|
||||
))
|
||||
elif token_data:
|
||||
# 기존 레코드: 토큰 + 프로필 데이터 업데이트
|
||||
cursor.execute("""
|
||||
UPDATE customer_identities
|
||||
SET access_token = ?, refresh_token = ?, token_expires_at = ?, provider_data = ?
|
||||
WHERE provider = 'kakao' AND provider_user_id = ?
|
||||
""", (
|
||||
token_data.get('access_token'),
|
||||
token_data.get('refresh_token'),
|
||||
token_data.get('expires_at'),
|
||||
json.dumps(store_data, ensure_ascii=False),
|
||||
kakao_id,
|
||||
))
|
||||
|
||||
# 프로필 이미지, 이메일 업데이트
|
||||
updates = []
|
||||
@ -545,6 +565,52 @@ def find_user_by_kakao_id(kakao_id):
|
||||
return row['user_id'] if row else None
|
||||
|
||||
|
||||
def get_kakao_tokens(user_id):
|
||||
"""사용자의 저장된 카카오 토큰 조회"""
|
||||
conn = db_manager.get_sqlite_connection()
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("""
|
||||
SELECT provider_user_id, access_token, refresh_token, token_expires_at
|
||||
FROM customer_identities
|
||||
WHERE provider = 'kakao' AND user_id = ?
|
||||
""", (user_id,))
|
||||
row = cursor.fetchone()
|
||||
if row and row['access_token']:
|
||||
return {
|
||||
'kakao_id': row['provider_user_id'],
|
||||
'access_token': row['access_token'],
|
||||
'refresh_token': row['refresh_token'],
|
||||
'token_expires_at': row['token_expires_at'],
|
||||
}
|
||||
return None
|
||||
|
||||
|
||||
def update_kakao_tokens(kakao_id, token_data):
|
||||
"""카카오 토큰 업데이트 (갱신 후 저장용)"""
|
||||
conn = db_manager.get_sqlite_connection()
|
||||
cursor = conn.cursor()
|
||||
|
||||
updates = ["access_token = ?"]
|
||||
params = [token_data['access_token']]
|
||||
|
||||
if 'expires_at' in token_data:
|
||||
updates.append("token_expires_at = ?")
|
||||
params.append(token_data['expires_at'])
|
||||
|
||||
# refresh_token은 갱신 응답에 포함된 경우에만 업데이트
|
||||
if 'refresh_token' in token_data:
|
||||
updates.append("refresh_token = ?")
|
||||
params.append(token_data['refresh_token'])
|
||||
|
||||
params.append(kakao_id)
|
||||
cursor.execute(f"""
|
||||
UPDATE customer_identities
|
||||
SET {', '.join(updates)}
|
||||
WHERE provider = 'kakao' AND provider_user_id = ?
|
||||
""", params)
|
||||
conn.commit()
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# 라우트
|
||||
# ============================================================================
|
||||
@ -807,7 +873,7 @@ def claim_kakao_start():
|
||||
return redirect(auth_url)
|
||||
|
||||
|
||||
def _handle_mypage_kakao_callback(code, kakao_client):
|
||||
def _handle_mypage_kakao_callback(code, kakao_client, redirect_to=None):
|
||||
"""
|
||||
마이페이지 카카오 콜백 처리 - 카카오 연동(머지) + 마이페이지 이동
|
||||
|
||||
@ -816,6 +882,9 @@ def _handle_mypage_kakao_callback(code, kakao_client):
|
||||
B) 미연결 + 카카오 전화번호로 기존 유저 발견 → 카카오 연동 후 이동
|
||||
C) 미연결 + 기존 유저 없음 → 신규 생성 + 카카오 연동
|
||||
D) 전화번호 없음 → 에러 안내
|
||||
|
||||
Args:
|
||||
redirect_to: 리다이렉트할 URL (None이면 기본 /my-page?phone=xxx)
|
||||
"""
|
||||
success, token_data = kakao_client.get_access_token(code)
|
||||
if not success:
|
||||
@ -843,9 +912,15 @@ def _handle_mypage_kakao_callback(code, kakao_client):
|
||||
"UPDATE users SET nickname = ? WHERE id = ? AND nickname = '고객'",
|
||||
(kakao_name, existing_user_id))
|
||||
conn.commit()
|
||||
cursor.execute("SELECT phone FROM users WHERE id = ?", (existing_user_id,))
|
||||
cursor.execute("SELECT phone, nickname FROM users WHERE id = ?", (existing_user_id,))
|
||||
row = cursor.fetchone()
|
||||
if row and row['phone']:
|
||||
# 세션에 로그인 정보 저장
|
||||
session['logged_in_user_id'] = existing_user_id
|
||||
session['logged_in_phone'] = row['phone']
|
||||
session['logged_in_name'] = row['nickname'] or kakao_name
|
||||
if redirect_to:
|
||||
return redirect(redirect_to)
|
||||
return redirect(f"/my-page?phone={row['phone']}")
|
||||
|
||||
# 전화번호 없으면 연동 불가
|
||||
@ -860,7 +935,7 @@ def _handle_mypage_kakao_callback(code, kakao_client):
|
||||
if phone_user:
|
||||
# Case B: 기존 전화번호 유저 → 카카오 연동 (머지)
|
||||
user_id = phone_user['id']
|
||||
link_kakao_identity(user_id, kakao_id, user_info)
|
||||
link_kakao_identity(user_id, kakao_id, user_info, token_data)
|
||||
# "고객" 이름이면 카카오 실명으로 업데이트
|
||||
if phone_user['nickname'] == '고객' and kakao_name and kakao_name != '고객':
|
||||
cursor.execute("UPDATE users SET nickname = ? WHERE id = ?",
|
||||
@ -870,9 +945,17 @@ def _handle_mypage_kakao_callback(code, kakao_client):
|
||||
else:
|
||||
# Case C: 신규 유저 생성 + 카카오 연동
|
||||
user_id, _ = get_or_create_user(kakao_phone, kakao_name)
|
||||
link_kakao_identity(user_id, kakao_id, user_info)
|
||||
link_kakao_identity(user_id, kakao_id, user_info, token_data)
|
||||
logging.info(f"마이페이지 카카오 신규: user_id={user_id}, kakao_id={kakao_id}")
|
||||
|
||||
# 세션에 로그인 정보 저장 (mypage_v2용)
|
||||
session['logged_in_user_id'] = user_id
|
||||
session['logged_in_phone'] = kakao_phone
|
||||
session['logged_in_name'] = kakao_name
|
||||
|
||||
# 지정된 리다이렉트 URL이 있으면 그쪽으로
|
||||
if redirect_to:
|
||||
return redirect(redirect_to)
|
||||
return redirect(f"/my-page?phone={kakao_phone}")
|
||||
|
||||
|
||||
@ -904,8 +987,9 @@ def claim_kakao_callback():
|
||||
return render_template('error.html', message="보안 검증에 실패했습니다. 다시 시도해주세요.")
|
||||
|
||||
# 2.5 마이페이지 조회 목적이면 별도 처리
|
||||
if state_data.get('purpose') == 'mypage':
|
||||
return _handle_mypage_kakao_callback(code, get_kakao_client())
|
||||
if state_data.get('purpose') in ('mypage', 'mypage_v2'):
|
||||
redirect_to = '/mypage' if state_data.get('purpose') == 'mypage_v2' else None
|
||||
return _handle_mypage_kakao_callback(code, get_kakao_client(), redirect_to=redirect_to)
|
||||
|
||||
# 3. claim 컨텍스트 복원
|
||||
token_param = state_data.get('t', '')
|
||||
@ -942,6 +1026,8 @@ def claim_kakao_callback():
|
||||
# 카카오에서 받은 생년월일 조합
|
||||
kakao_birthday = None
|
||||
kakao_bday = user_info.get('birthday') # MMDD 형식
|
||||
print(f"[KAKAO DEBUG] user_info keys: {list(user_info.keys())}")
|
||||
print(f"[KAKAO DEBUG] birthday={kakao_bday}, birthyear={user_info.get('birthyear')}")
|
||||
if kakao_bday and len(kakao_bday) == 4:
|
||||
if user_info.get('birthyear'):
|
||||
kakao_birthday = f"{user_info['birthyear']}-{kakao_bday[:2]}-{kakao_bday[2:]}" # YYYY-MM-DD
|
||||
@ -963,7 +1049,7 @@ def claim_kakao_callback():
|
||||
else:
|
||||
user_id, is_new = get_or_create_user(kakao_phone, kakao_name, birthday=kakao_birthday)
|
||||
|
||||
link_kakao_identity(user_id, kakao_id, user_info)
|
||||
link_kakao_identity(user_id, kakao_id, user_info, token_data)
|
||||
|
||||
success, msg, new_balance = claim_mileage(user_id, token_info)
|
||||
if not success:
|
||||
@ -1077,6 +1163,67 @@ def mypage_kakao_start():
|
||||
return redirect(auth_url)
|
||||
|
||||
|
||||
@app.route('/mypage')
|
||||
def mypage_v2():
|
||||
"""확장 마이페이지 (카카오 로그인 필수)"""
|
||||
user_id = session.get('logged_in_user_id')
|
||||
|
||||
if not user_id:
|
||||
# 로그인 필요 - 카카오 로그인으로 리다이렉트
|
||||
csrf_token = secrets.token_hex(16)
|
||||
state_data = {'purpose': 'mypage_v2', 'csrf': csrf_token}
|
||||
kakao_state = base64.urlsafe_b64encode(
|
||||
json.dumps(state_data).encode()
|
||||
).decode()
|
||||
session['kakao_csrf'] = csrf_token
|
||||
return render_template('my_page_login.html', kakao_state=kakao_state, redirect_to='/mypage')
|
||||
|
||||
# 사용자 정보 조회
|
||||
conn = db_manager.get_sqlite_connection()
|
||||
cursor = conn.cursor()
|
||||
|
||||
cursor.execute("""
|
||||
SELECT id, nickname, phone, profile_image_url, mileage_balance, created_at
|
||||
FROM users WHERE id = ?
|
||||
""", (user_id,))
|
||||
user_raw = cursor.fetchone()
|
||||
|
||||
if not user_raw:
|
||||
session.pop('logged_in_user_id', None)
|
||||
return redirect('/mypage')
|
||||
|
||||
user = dict(user_raw)
|
||||
|
||||
# 반려동물 목록 조회
|
||||
cursor.execute("""
|
||||
SELECT id, name, species, breed, gender, photo_url, created_at
|
||||
FROM pets WHERE user_id = ? AND is_active = TRUE
|
||||
ORDER BY created_at DESC
|
||||
""", (user_id,))
|
||||
|
||||
pets = []
|
||||
for row in cursor.fetchall():
|
||||
species_label = '강아지 🐕' if row['species'] == 'dog' else ('고양이 🐈' if row['species'] == 'cat' else '기타')
|
||||
pets.append({
|
||||
'id': row['id'],
|
||||
'name': row['name'],
|
||||
'species': row['species'],
|
||||
'species_label': species_label,
|
||||
'breed': row['breed'],
|
||||
'gender': row['gender'],
|
||||
'photo_url': row['photo_url']
|
||||
})
|
||||
|
||||
# 구매 횟수 (적립 내역 수)
|
||||
cursor.execute("SELECT COUNT(*) FROM mileage_ledger WHERE user_id = ?", (user_id,))
|
||||
purchase_count = cursor.fetchone()[0]
|
||||
|
||||
return render_template('mypage_v2.html',
|
||||
user=user,
|
||||
pets=pets,
|
||||
purchase_count=purchase_count)
|
||||
|
||||
|
||||
@app.route('/my-page')
|
||||
def my_page():
|
||||
"""마이페이지 (전화번호로 조회)"""
|
||||
@ -4412,6 +4559,266 @@ def api_kims_interaction_check():
|
||||
}), 500
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
# 반려동물 API
|
||||
# ==============================================================================
|
||||
|
||||
# 견종/묘종 데이터
|
||||
DOG_BREEDS = [
|
||||
'말티즈', '푸들', '포메라니안', '치와와', '시츄', '요크셔테리어',
|
||||
'비숑프리제', '골든리트리버', '래브라도리트리버', '진돗개', '시바견',
|
||||
'웰시코기', '닥스훈트', '비글', '보더콜리', '프렌치불독', '불독',
|
||||
'슈나우저', '사모예드', '허스키', '믹스견', '기타'
|
||||
]
|
||||
|
||||
CAT_BREEDS = [
|
||||
'코리안숏헤어', '페르시안', '러시안블루', '샴', '먼치킨', '랙돌',
|
||||
'브리티쉬숏헤어', '아메리칸숏헤어', '스코티쉬폴드', '노르웨이숲',
|
||||
'메인쿤', '뱅갈', '아비시니안', '터키쉬앙고라', '믹스묘', '기타'
|
||||
]
|
||||
|
||||
@app.route('/api/pets', methods=['GET'])
|
||||
def get_pets():
|
||||
"""사용자의 반려동물 목록 조회"""
|
||||
# 세션에서 로그인 유저 확인
|
||||
user_id = session.get('logged_in_user_id')
|
||||
if not user_id:
|
||||
return jsonify({'success': False, 'error': '로그인이 필요합니다.'}), 401
|
||||
|
||||
try:
|
||||
conn = db_manager.get_sqlite_connection()
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("""
|
||||
SELECT id, name, species, breed, gender, birth_date,
|
||||
age_months, weight, photo_url, notes, created_at
|
||||
FROM pets
|
||||
WHERE user_id = ? AND is_active = TRUE
|
||||
ORDER BY created_at DESC
|
||||
""", (user_id,))
|
||||
|
||||
pets = []
|
||||
for row in cursor.fetchall():
|
||||
pets.append({
|
||||
'id': row['id'],
|
||||
'name': row['name'],
|
||||
'species': row['species'],
|
||||
'species_label': '강아지 🐕' if row['species'] == 'dog' else ('고양이 🐈' if row['species'] == 'cat' else '기타'),
|
||||
'breed': row['breed'],
|
||||
'gender': row['gender'],
|
||||
'birth_date': row['birth_date'],
|
||||
'age_months': row['age_months'],
|
||||
'weight': float(row['weight']) if row['weight'] else None,
|
||||
'photo_url': row['photo_url'],
|
||||
'notes': row['notes'],
|
||||
'created_at': utc_to_kst_str(row['created_at'])
|
||||
})
|
||||
|
||||
return jsonify({'success': True, 'pets': pets, 'count': len(pets)})
|
||||
except Exception as e:
|
||||
logging.error(f"반려동물 조회 오류: {e}")
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
|
||||
@app.route('/api/pets', methods=['POST'])
|
||||
def create_pet():
|
||||
"""반려동물 등록"""
|
||||
user_id = session.get('logged_in_user_id')
|
||||
if not user_id:
|
||||
return jsonify({'success': False, 'error': '로그인이 필요합니다.'}), 401
|
||||
|
||||
try:
|
||||
data = request.get_json()
|
||||
name = data.get('name', '').strip()
|
||||
species = data.get('species', '').strip() # dog, cat, other
|
||||
breed = data.get('breed', '').strip()
|
||||
gender = data.get('gender') # male, female, unknown
|
||||
|
||||
if not name:
|
||||
return jsonify({'success': False, 'error': '이름을 입력해주세요.'}), 400
|
||||
if species not in ['dog', 'cat', 'other']:
|
||||
return jsonify({'success': False, 'error': '종류를 선택해주세요.'}), 400
|
||||
|
||||
conn = db_manager.get_sqlite_connection()
|
||||
cursor = conn.cursor()
|
||||
|
||||
cursor.execute("""
|
||||
INSERT INTO pets (user_id, name, species, breed, gender)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
""", (user_id, name, species, breed, gender))
|
||||
|
||||
pet_id = cursor.lastrowid
|
||||
conn.commit()
|
||||
|
||||
logging.info(f"반려동물 등록: user_id={user_id}, pet_id={pet_id}, name={name}, species={species}")
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'pet_id': pet_id,
|
||||
'message': f'{name}이(가) 등록되었습니다!'
|
||||
})
|
||||
except Exception as e:
|
||||
logging.error(f"반려동물 등록 오류: {e}")
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
|
||||
@app.route('/api/pets/<int:pet_id>', methods=['PUT'])
|
||||
def update_pet(pet_id):
|
||||
"""반려동물 정보 수정"""
|
||||
user_id = session.get('logged_in_user_id')
|
||||
if not user_id:
|
||||
return jsonify({'success': False, 'error': '로그인이 필요합니다.'}), 401
|
||||
|
||||
try:
|
||||
data = request.get_json()
|
||||
|
||||
conn = db_manager.get_sqlite_connection()
|
||||
cursor = conn.cursor()
|
||||
|
||||
# 소유권 확인
|
||||
cursor.execute("SELECT id FROM pets WHERE id = ? AND user_id = ?", (pet_id, user_id))
|
||||
if not cursor.fetchone():
|
||||
return jsonify({'success': False, 'error': '반려동물을 찾을 수 없습니다.'}), 404
|
||||
|
||||
# 업데이트 필드 구성
|
||||
updates = []
|
||||
params = []
|
||||
|
||||
if 'name' in data:
|
||||
updates.append("name = ?")
|
||||
params.append(data['name'].strip())
|
||||
if 'species' in data:
|
||||
updates.append("species = ?")
|
||||
params.append(data['species'])
|
||||
if 'breed' in data:
|
||||
updates.append("breed = ?")
|
||||
params.append(data['breed'])
|
||||
if 'gender' in data:
|
||||
updates.append("gender = ?")
|
||||
params.append(data['gender'])
|
||||
if 'birth_date' in data:
|
||||
updates.append("birth_date = ?")
|
||||
params.append(data['birth_date'])
|
||||
if 'age_months' in data:
|
||||
updates.append("age_months = ?")
|
||||
params.append(data['age_months'])
|
||||
if 'weight' in data:
|
||||
updates.append("weight = ?")
|
||||
params.append(data['weight'])
|
||||
if 'notes' in data:
|
||||
updates.append("notes = ?")
|
||||
params.append(data['notes'])
|
||||
|
||||
if updates:
|
||||
updates.append("updated_at = CURRENT_TIMESTAMP")
|
||||
params.append(pet_id)
|
||||
|
||||
cursor.execute(f"""
|
||||
UPDATE pets SET {', '.join(updates)} WHERE id = ?
|
||||
""", params)
|
||||
conn.commit()
|
||||
|
||||
return jsonify({'success': True, 'message': '수정되었습니다.'})
|
||||
except Exception as e:
|
||||
logging.error(f"반려동물 수정 오류: {e}")
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
|
||||
@app.route('/api/pets/<int:pet_id>', methods=['DELETE'])
|
||||
def delete_pet(pet_id):
|
||||
"""반려동물 삭제 (soft delete)"""
|
||||
user_id = session.get('logged_in_user_id')
|
||||
if not user_id:
|
||||
return jsonify({'success': False, 'error': '로그인이 필요합니다.'}), 401
|
||||
|
||||
try:
|
||||
conn = db_manager.get_sqlite_connection()
|
||||
cursor = conn.cursor()
|
||||
|
||||
# 소유권 확인 및 삭제
|
||||
cursor.execute("""
|
||||
UPDATE pets SET is_active = FALSE, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ? AND user_id = ?
|
||||
""", (pet_id, user_id))
|
||||
|
||||
if cursor.rowcount == 0:
|
||||
return jsonify({'success': False, 'error': '반려동물을 찾을 수 없습니다.'}), 404
|
||||
|
||||
conn.commit()
|
||||
return jsonify({'success': True, 'message': '삭제되었습니다.'})
|
||||
except Exception as e:
|
||||
logging.error(f"반려동물 삭제 오류: {e}")
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
|
||||
@app.route('/api/pets/<int:pet_id>/photo', methods=['POST'])
|
||||
def upload_pet_photo(pet_id):
|
||||
"""반려동물 사진 업로드"""
|
||||
user_id = session.get('logged_in_user_id')
|
||||
if not user_id:
|
||||
return jsonify({'success': False, 'error': '로그인이 필요합니다.'}), 401
|
||||
|
||||
try:
|
||||
conn = db_manager.get_sqlite_connection()
|
||||
cursor = conn.cursor()
|
||||
|
||||
# 소유권 확인
|
||||
cursor.execute("SELECT id, name FROM pets WHERE id = ? AND user_id = ?", (pet_id, user_id))
|
||||
pet = cursor.fetchone()
|
||||
if not pet:
|
||||
return jsonify({'success': False, 'error': '반려동물을 찾을 수 없습니다.'}), 404
|
||||
|
||||
if 'photo' not in request.files:
|
||||
return jsonify({'success': False, 'error': '사진 파일이 없습니다.'}), 400
|
||||
|
||||
file = request.files['photo']
|
||||
if file.filename == '':
|
||||
return jsonify({'success': False, 'error': '파일을 선택해주세요.'}), 400
|
||||
|
||||
# 파일 확장자 체크
|
||||
allowed = {'png', 'jpg', 'jpeg', 'gif', 'webp'}
|
||||
ext = file.filename.rsplit('.', 1)[-1].lower() if '.' in file.filename else ''
|
||||
if ext not in allowed:
|
||||
return jsonify({'success': False, 'error': '지원하지 않는 이미지 형식입니다.'}), 400
|
||||
|
||||
# 파일 저장
|
||||
import uuid
|
||||
filename = f"pet_{pet_id}_{uuid.uuid4().hex[:8]}.{ext}"
|
||||
upload_dir = Path(app.root_path) / 'static' / 'uploads' / 'pets'
|
||||
upload_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
filepath = upload_dir / filename
|
||||
file.save(str(filepath))
|
||||
|
||||
# DB 업데이트
|
||||
photo_url = f"/static/uploads/pets/{filename}"
|
||||
cursor.execute("""
|
||||
UPDATE pets SET photo_url = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?
|
||||
""", (photo_url, pet_id))
|
||||
conn.commit()
|
||||
|
||||
logging.info(f"반려동물 사진 업로드: pet_id={pet_id}, filename={filename}")
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'photo_url': photo_url,
|
||||
'message': f'{pet["name"]} 사진이 등록되었습니다!'
|
||||
})
|
||||
except Exception as e:
|
||||
logging.error(f"반려동물 사진 업로드 오류: {e}")
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
|
||||
@app.route('/api/pets/breeds/<species>')
|
||||
def get_breeds(species):
|
||||
"""종류별 품종 목록 조회"""
|
||||
if species == 'dog':
|
||||
return jsonify({'success': True, 'breeds': DOG_BREEDS})
|
||||
elif species == 'cat':
|
||||
return jsonify({'success': True, 'breeds': CAT_BREEDS})
|
||||
else:
|
||||
return jsonify({'success': True, 'breeds': ['기타']})
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
import os
|
||||
|
||||
|
||||
@ -154,11 +154,46 @@ class DatabaseManager:
|
||||
return self.engines[database]
|
||||
|
||||
def get_session(self, database='PM_BASE'):
|
||||
"""특정 데이터베이스 세션 반환"""
|
||||
"""특정 데이터베이스 세션 반환 (자동 복구 포함)"""
|
||||
if database not in self.sessions:
|
||||
engine = self.get_engine(database)
|
||||
Session = sessionmaker(bind=engine)
|
||||
self.sessions[database] = Session()
|
||||
else:
|
||||
# 🔥 기존 세션 상태 체크 및 자동 복구
|
||||
session = self.sessions[database]
|
||||
try:
|
||||
# 세션이 유효한지 간단한 쿼리로 테스트
|
||||
session.execute(text("SELECT 1"))
|
||||
except Exception as e:
|
||||
error_msg = str(e).lower()
|
||||
# 연결 끊김 또는 트랜잭션 에러 감지
|
||||
if any(keyword in error_msg for keyword in [
|
||||
'invalid transaction', 'rollback', 'connection',
|
||||
'closed', 'lost', 'timeout', 'network', 'disconnect'
|
||||
]):
|
||||
print(f"[DB Manager] {database} 세션 복구 시도: {e}")
|
||||
try:
|
||||
session.rollback()
|
||||
print(f"[DB Manager] {database} 롤백 성공, 세션 재사용")
|
||||
except Exception as rollback_err:
|
||||
print(f"[DB Manager] {database} 롤백 실패, 세션 재생성: {rollback_err}")
|
||||
try:
|
||||
session.close()
|
||||
except:
|
||||
pass
|
||||
del self.sessions[database]
|
||||
# 새 세션 생성
|
||||
engine = self.get_engine(database)
|
||||
Session = sessionmaker(bind=engine)
|
||||
self.sessions[database] = Session()
|
||||
print(f"[DB Manager] {database} 새 세션 생성 완료")
|
||||
else:
|
||||
# 다른 종류의 에러면 롤백만 시도
|
||||
try:
|
||||
session.rollback()
|
||||
except:
|
||||
pass
|
||||
return self.sessions[database]
|
||||
|
||||
def rollback_session(self, database='PM_BASE'):
|
||||
@ -237,7 +272,13 @@ class DatabaseManager:
|
||||
self.init_sqlite_schema()
|
||||
self.sqlite_conn = old_conn
|
||||
print(f"[DB Manager] SQLite 신규 DB 생성 완료: {self.sqlite_db_path}")
|
||||
|
||||
else:
|
||||
# 기존 DB: 마이그레이션 실행
|
||||
old_conn = self.sqlite_conn
|
||||
self.sqlite_conn = conn
|
||||
self._migrate_sqlite()
|
||||
self.sqlite_conn = old_conn
|
||||
|
||||
return conn
|
||||
|
||||
def init_sqlite_schema(self):
|
||||
@ -319,6 +360,43 @@ class DatabaseManager:
|
||||
self.sqlite_conn.commit()
|
||||
print("[DB Manager] SQLite 마이그레이션: ai_recommendations 테이블 생성")
|
||||
|
||||
# customer_identities 토큰 저장 컬럼 추가
|
||||
cursor.execute("PRAGMA table_info(customer_identities)")
|
||||
ci_columns = [row[1] for row in cursor.fetchall()]
|
||||
if 'access_token' not in ci_columns:
|
||||
cursor.execute("ALTER TABLE customer_identities ADD COLUMN access_token TEXT")
|
||||
cursor.execute("ALTER TABLE customer_identities ADD COLUMN refresh_token TEXT")
|
||||
cursor.execute("ALTER TABLE customer_identities ADD COLUMN token_expires_at DATETIME")
|
||||
self.sqlite_conn.commit()
|
||||
print("[DB Manager] SQLite 마이그레이션: customer_identities 토큰 컬럼 추가")
|
||||
|
||||
# pets 테이블 생성 (반려동물)
|
||||
cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='pets'")
|
||||
if not cursor.fetchone():
|
||||
cursor.executescript("""
|
||||
CREATE TABLE IF NOT EXISTS pets (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
name VARCHAR(50) NOT NULL,
|
||||
species VARCHAR(20) NOT NULL,
|
||||
breed VARCHAR(50),
|
||||
gender VARCHAR(10),
|
||||
birth_date DATE,
|
||||
age_months INTEGER,
|
||||
weight DECIMAL(5,2),
|
||||
photo_url TEXT,
|
||||
notes TEXT,
|
||||
is_active BOOLEAN DEFAULT TRUE,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_pets_user ON pets(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_pets_species ON pets(species);
|
||||
""")
|
||||
self.sqlite_conn.commit()
|
||||
print("[DB Manager] SQLite 마이그레이션: pets 테이블 생성")
|
||||
|
||||
def test_connection(self, database='PM_BASE'):
|
||||
"""연결 테스트"""
|
||||
try:
|
||||
|
||||
@ -22,6 +22,9 @@ CREATE TABLE IF NOT EXISTS customer_identities (
|
||||
provider VARCHAR(20) NOT NULL,
|
||||
provider_user_id VARCHAR(100) NOT NULL,
|
||||
provider_data TEXT,
|
||||
access_token TEXT,
|
||||
refresh_token TEXT,
|
||||
token_expires_at DATETIME,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id),
|
||||
UNIQUE(provider, provider_user_id)
|
||||
@ -120,3 +123,25 @@ CREATE TABLE IF NOT EXISTS ai_recommendations (
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_rec_user_status ON ai_recommendations(user_id, status);
|
||||
CREATE INDEX IF NOT EXISTS idx_rec_expires ON ai_recommendations(expires_at);
|
||||
|
||||
-- 8. 반려동물 테이블
|
||||
CREATE TABLE IF NOT EXISTS pets (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
name VARCHAR(50) NOT NULL, -- 이름 (예: 뽀삐, 나비)
|
||||
species VARCHAR(20) NOT NULL, -- 종류: dog, cat, other
|
||||
breed VARCHAR(50), -- 품종 (말티즈, 페르시안 등)
|
||||
gender VARCHAR(10), -- male, female, unknown
|
||||
birth_date DATE, -- 생년월일 (나중에 사용)
|
||||
age_months INTEGER, -- 월령 (나중에 사용)
|
||||
weight DECIMAL(5,2), -- 체중 kg (나중에 사용)
|
||||
photo_url TEXT, -- 사진 URL
|
||||
notes TEXT, -- 특이사항/메모
|
||||
is_active BOOLEAN DEFAULT TRUE, -- 활성 상태
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_pets_user ON pets(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_pets_species ON pets(species);
|
||||
|
||||
BIN
backend/static/uploads/pets/pet_1_d4ffe983.png
Normal file
BIN
backend/static/uploads/pets/pet_1_d4ffe983.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 55 KiB |
@ -119,6 +119,49 @@
|
||||
letter-spacing: -0.2px;
|
||||
}
|
||||
|
||||
/* 퀵 메뉴 */
|
||||
.quick-menu {
|
||||
display: flex;
|
||||
justify-content: space-around;
|
||||
padding: 20px 16px;
|
||||
background: #fff;
|
||||
margin: 0 16px 16px;
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
.quick-menu-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
text-decoration: none;
|
||||
padding: 8px 12px;
|
||||
border-radius: 12px;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.quick-menu-item:active {
|
||||
background: #f5f5f5;
|
||||
}
|
||||
|
||||
.quick-menu-icon {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 14px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
.quick-menu-item span {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #495057;
|
||||
letter-spacing: -0.3px;
|
||||
}
|
||||
|
||||
.section {
|
||||
padding: 24px;
|
||||
}
|
||||
@ -301,6 +344,26 @@
|
||||
<div class="balance-desc">약국에서 1P = 1원으로 사용 가능</div>
|
||||
</div>
|
||||
|
||||
<!-- 퀵 메뉴 -->
|
||||
<div class="quick-menu">
|
||||
<a href="/mypage" class="quick-menu-item">
|
||||
<div class="quick-menu-icon" style="background: #fef3c7;">🐾</div>
|
||||
<span>반려동물</span>
|
||||
</a>
|
||||
<a href="#" class="quick-menu-item" onclick="alert('준비중입니다'); return false;">
|
||||
<div class="quick-menu-icon" style="background: #dbeafe;">🎟️</div>
|
||||
<span>쿠폰함</span>
|
||||
</a>
|
||||
<a href="#" class="quick-menu-item" onclick="alert('준비중입니다'); return false;">
|
||||
<div class="quick-menu-icon" style="background: #fce7f3;">📦</div>
|
||||
<span>구매내역</span>
|
||||
</a>
|
||||
<a href="/mypage" class="quick-menu-item">
|
||||
<div class="quick-menu-icon" style="background: #ede9fe;">⚙️</div>
|
||||
<span>내정보</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<div class="section-title">적립 내역</div>
|
||||
|
||||
|
||||
891
backend/templates/mypage_v2.html
Normal file
891
backend/templates/mypage_v2.html
Normal file
@ -0,0 +1,891 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
|
||||
<meta name="theme-color" content="#6366f1">
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||||
<meta name="apple-mobile-web-app-title" content="청춘약국">
|
||||
<link rel="manifest" href="/static/manifest.json">
|
||||
<link rel="apple-touch-icon" href="/static/icons/icon-192.png">
|
||||
<title>마이페이지 - 청춘약국</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Noto Sans KR', -apple-system, BlinkMacSystemFont, sans-serif;
|
||||
background: #f5f7fa;
|
||||
min-height: 100vh;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
.app-container {
|
||||
background: #ffffff;
|
||||
min-height: 100vh;
|
||||
max-width: 420px;
|
||||
margin: 0 auto;
|
||||
box-shadow: 0 0 20px rgba(0,0,0,0.05);
|
||||
}
|
||||
|
||||
/* 헤더 */
|
||||
.header {
|
||||
background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%);
|
||||
padding: 20px 24px 100px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.header-top {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.header-title {
|
||||
color: white;
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.btn-logout {
|
||||
color: rgba(255,255,255,0.8);
|
||||
font-size: 14px;
|
||||
text-decoration: none;
|
||||
padding: 8px 12px;
|
||||
border-radius: 8px;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.btn-logout:hover {
|
||||
background: rgba(255,255,255,0.1);
|
||||
}
|
||||
|
||||
/* 프로필 카드 */
|
||||
.profile-card {
|
||||
background: white;
|
||||
border-radius: 20px;
|
||||
margin: -80px 16px 16px;
|
||||
padding: 24px;
|
||||
box-shadow: 0 4px 20px rgba(0,0,0,0.08);
|
||||
position: relative;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.profile-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.profile-avatar {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(135deg, #6366f1, #8b5cf6);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 28px;
|
||||
color: white;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.profile-avatar img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.profile-details h2 {
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
color: #1f2937;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.profile-details p {
|
||||
color: #6b7280;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* 통계 그리드 */
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 12px;
|
||||
padding-top: 20px;
|
||||
border-top: 1px solid #f3f4f6;
|
||||
}
|
||||
|
||||
.stat-item {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.stat-icon {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin: 0 auto 8px;
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.stat-icon.purple { background: #ede9fe; }
|
||||
.stat-icon.blue { background: #dbeafe; }
|
||||
.stat-icon.pink { background: #fce7f3; }
|
||||
|
||||
.stat-value {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 12px;
|
||||
color: #9ca3af;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
/* 섹션 */
|
||||
.section {
|
||||
background: white;
|
||||
margin: 16px;
|
||||
border-radius: 16px;
|
||||
padding: 20px;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.04);
|
||||
}
|
||||
|
||||
.section-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
color: #1f2937;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.section-action {
|
||||
color: #6366f1;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
/* 반려동물 카드 */
|
||||
.pet-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 16px;
|
||||
background: #f9fafb;
|
||||
border-radius: 12px;
|
||||
margin-bottom: 12px;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.pet-card:hover {
|
||||
background: #f3f4f6;
|
||||
}
|
||||
|
||||
.pet-photo {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(135deg, #fbbf24, #f59e0b);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 24px;
|
||||
overflow: hidden;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.pet-photo img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.pet-info {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.pet-name {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #1f2937;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.pet-details {
|
||||
font-size: 13px;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.pet-arrow {
|
||||
color: #d1d5db;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
/* 반려동물 추가 버튼 */
|
||||
.add-pet-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
padding: 16px;
|
||||
background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 12px;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: transform 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
|
||||
.add-pet-btn:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(99, 102, 241, 0.4);
|
||||
}
|
||||
|
||||
/* 메뉴 리스트 */
|
||||
.menu-list {
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.menu-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 16px 0;
|
||||
border-bottom: 1px solid #f3f4f6;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.menu-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.menu-item:hover {
|
||||
background: #f9fafb;
|
||||
margin: 0 -20px;
|
||||
padding: 16px 20px;
|
||||
}
|
||||
|
||||
.menu-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.menu-icon {
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.menu-text {
|
||||
font-size: 15px;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.menu-badge {
|
||||
background: #fef3c7;
|
||||
color: #92400e;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
padding: 4px 8px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.menu-arrow {
|
||||
color: #d1d5db;
|
||||
}
|
||||
|
||||
/* 모달 */
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0,0,0,0.5);
|
||||
display: none;
|
||||
align-items: flex-end;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.modal-overlay.active {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
background: white;
|
||||
border-radius: 24px 24px 0 0;
|
||||
width: 100%;
|
||||
max-width: 420px;
|
||||
max-height: 90vh;
|
||||
overflow-y: auto;
|
||||
padding: 24px;
|
||||
animation: slideUp 0.3s ease;
|
||||
}
|
||||
|
||||
@keyframes slideUp {
|
||||
from { transform: translateY(100%); }
|
||||
to { transform: translateY(0); }
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.modal-title {
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
.modal-close {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
background: #f3f4f6;
|
||||
border: none;
|
||||
font-size: 18px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/* 폼 스타일 */
|
||||
.form-group {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.form-label {
|
||||
display: block;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #374151;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.form-input {
|
||||
width: 100%;
|
||||
padding: 14px 16px;
|
||||
border: 2px solid #e5e7eb;
|
||||
border-radius: 12px;
|
||||
font-size: 15px;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
|
||||
.form-input:focus {
|
||||
outline: none;
|
||||
border-color: #6366f1;
|
||||
}
|
||||
|
||||
/* 종류 선택 */
|
||||
.species-options {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.species-option {
|
||||
flex: 1;
|
||||
padding: 20px;
|
||||
border: 2px solid #e5e7eb;
|
||||
border-radius: 16px;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.species-option:hover {
|
||||
border-color: #c7d2fe;
|
||||
}
|
||||
|
||||
.species-option.selected {
|
||||
border-color: #6366f1;
|
||||
background: #eef2ff;
|
||||
}
|
||||
|
||||
.species-option .icon {
|
||||
font-size: 40px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.species-option .label {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
/* 사진 업로드 */
|
||||
.photo-upload {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.photo-preview {
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
border-radius: 50%;
|
||||
background: #f3f4f6;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 48px;
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.photo-preview:hover {
|
||||
background: #e5e7eb;
|
||||
}
|
||||
|
||||
.photo-preview img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.photo-hint {
|
||||
font-size: 13px;
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
/* 제출 버튼 */
|
||||
.submit-btn {
|
||||
width: 100%;
|
||||
padding: 16px;
|
||||
background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 12px;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
margin-top: 24px;
|
||||
transition: transform 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
|
||||
.submit-btn:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(99, 102, 241, 0.4);
|
||||
}
|
||||
|
||||
.submit-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
transform: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
/* 빈 상태 */
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 32px 16px;
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.empty-state .icon {
|
||||
font-size: 48px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.empty-state p {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* 로딩 */
|
||||
.loading {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 40px;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: 3px solid #e5e7eb;
|
||||
border-top-color: #6366f1;
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="app-container">
|
||||
<!-- 헤더 -->
|
||||
<div class="header">
|
||||
<div class="header-top">
|
||||
<h1 class="header-title">마이페이지</h1>
|
||||
<a href="/logout" class="btn-logout">로그아웃</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 프로필 카드 -->
|
||||
<div class="profile-card">
|
||||
<div class="profile-info">
|
||||
<div class="profile-avatar">
|
||||
{% if user.profile_image_url %}
|
||||
<img src="{{ user.profile_image_url }}" alt="프로필">
|
||||
{% else %}
|
||||
😊
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="profile-details">
|
||||
<h2>{{ user.nickname or '회원' }}님</h2>
|
||||
<p>{{ user.phone or '전화번호 미등록' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stats-grid">
|
||||
<div class="stat-item">
|
||||
<div class="stat-icon purple">🎁</div>
|
||||
<div class="stat-value">{{ '{:,}'.format(user.mileage_balance or 0) }}</div>
|
||||
<div class="stat-label">포인트</div>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<div class="stat-icon blue">📦</div>
|
||||
<div class="stat-value">{{ purchase_count or 0 }}</div>
|
||||
<div class="stat-label">구매</div>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<div class="stat-icon pink">🐾</div>
|
||||
<div class="stat-value" id="pet-count">{{ pets|length }}</div>
|
||||
<div class="stat-label">반려동물</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 반려동물 섹션 -->
|
||||
<div class="section">
|
||||
<div class="section-header">
|
||||
<h3 class="section-title">🐾 내 반려동물</h3>
|
||||
</div>
|
||||
|
||||
<div id="pet-list">
|
||||
{% if pets %}
|
||||
{% for pet in pets %}
|
||||
<div class="pet-card" onclick="editPet({{ pet.id }})">
|
||||
<div class="pet-photo">
|
||||
{% if pet.photo_url %}
|
||||
<img src="{{ pet.photo_url }}" alt="{{ pet.name }}">
|
||||
{% else %}
|
||||
{{ '🐕' if pet.species == 'dog' else ('🐈' if pet.species == 'cat' else '🐾') }}
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="pet-info">
|
||||
<div class="pet-name">{{ pet.name }}</div>
|
||||
<div class="pet-details">
|
||||
{{ pet.species_label }}
|
||||
{% if pet.breed %}· {{ pet.breed }}{% endif %}
|
||||
{% if pet.gender %}· {{ '♂' if pet.gender == 'male' else ('♀' if pet.gender == 'female' else '') }}{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<span class="pet-arrow">›</span>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<div class="empty-state">
|
||||
<div class="icon">🐾</div>
|
||||
<p>등록된 반려동물이 없습니다</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<button class="add-pet-btn" onclick="openAddPetModal()">
|
||||
<span>+</span> 반려동물 추가하기
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 메뉴 섹션 -->
|
||||
<div class="section">
|
||||
<ul class="menu-list">
|
||||
<li class="menu-item" onclick="location.href='/my-page?phone={{ user.phone }}'">
|
||||
<div class="menu-left">
|
||||
<span class="menu-icon">📋</span>
|
||||
<span class="menu-text">적립 내역</span>
|
||||
</div>
|
||||
<span class="menu-arrow">›</span>
|
||||
</li>
|
||||
<li class="menu-item">
|
||||
<div class="menu-left">
|
||||
<span class="menu-icon">📦</span>
|
||||
<span class="menu-text">구매 내역</span>
|
||||
<span class="menu-badge">준비중</span>
|
||||
</div>
|
||||
<span class="menu-arrow">›</span>
|
||||
</li>
|
||||
<li class="menu-item">
|
||||
<div class="menu-left">
|
||||
<span class="menu-icon">🎟️</span>
|
||||
<span class="menu-text">쿠폰함</span>
|
||||
<span class="menu-badge">준비중</span>
|
||||
</div>
|
||||
<span class="menu-arrow">›</span>
|
||||
</li>
|
||||
<li class="menu-item">
|
||||
<div class="menu-left">
|
||||
<span class="menu-icon">⚙️</span>
|
||||
<span class="menu-text">내 정보 수정</span>
|
||||
</div>
|
||||
<span class="menu-arrow">›</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 반려동물 추가/수정 모달 -->
|
||||
<div class="modal-overlay" id="petModal">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h2 class="modal-title" id="modalTitle">반려동물 등록</h2>
|
||||
<button class="modal-close" onclick="closeModal()">✕</button>
|
||||
</div>
|
||||
|
||||
<form id="petForm" onsubmit="submitPet(event)">
|
||||
<input type="hidden" id="petId" value="">
|
||||
|
||||
<!-- 종류 선택 -->
|
||||
<div class="form-group">
|
||||
<label class="form-label">종류 *</label>
|
||||
<div class="species-options">
|
||||
<div class="species-option" data-species="dog" onclick="selectSpecies('dog')">
|
||||
<div class="icon">🐕</div>
|
||||
<div class="label">강아지</div>
|
||||
</div>
|
||||
<div class="species-option" data-species="cat" onclick="selectSpecies('cat')">
|
||||
<div class="icon">🐈</div>
|
||||
<div class="label">고양이</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 이름 -->
|
||||
<div class="form-group">
|
||||
<label class="form-label">이름 *</label>
|
||||
<input type="text" class="form-input" id="petName" placeholder="예: 뽀삐" required>
|
||||
</div>
|
||||
|
||||
<!-- 품종 -->
|
||||
<div class="form-group">
|
||||
<label class="form-label">품종</label>
|
||||
<select class="form-input" id="petBreed">
|
||||
<option value="">선택해주세요</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- 성별 -->
|
||||
<div class="form-group">
|
||||
<label class="form-label">성별</label>
|
||||
<select class="form-input" id="petGender">
|
||||
<option value="">선택해주세요</option>
|
||||
<option value="male">남아 ♂</option>
|
||||
<option value="female">여아 ♀</option>
|
||||
<option value="unknown">모름</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- 사진 -->
|
||||
<div class="form-group">
|
||||
<label class="form-label">사진</label>
|
||||
<div class="photo-upload">
|
||||
<div class="photo-preview" id="photoPreview" onclick="document.getElementById('photoInput').click()">
|
||||
📷
|
||||
</div>
|
||||
<input type="file" id="photoInput" accept="image/*" style="display:none" onchange="previewPhoto(event)">
|
||||
<span class="photo-hint">탭하여 사진 추가</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="submit-btn" id="submitBtn">등록하기</button>
|
||||
<button type="button" class="submit-btn" style="background:#ef4444; margin-top:12px; display:none;" id="deleteBtn" onclick="deletePet()">삭제하기</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
let selectedSpecies = '';
|
||||
let currentPetId = null;
|
||||
|
||||
const DOG_BREEDS = ['말티즈', '푸들', '포메라니안', '치와와', '시츄', '요크셔테리어', '비숑프리제', '골든리트리버', '래브라도리트리버', '진돗개', '시바견', '웰시코기', '닥스훈트', '비글', '보더콜리', '프렌치불독', '불독', '슈나우저', '사모예드', '허스키', '믹스견', '기타'];
|
||||
const CAT_BREEDS = ['코리안숏헤어', '페르시안', '러시안블루', '샴', '먼치킨', '랙돌', '브리티쉬숏헤어', '아메리칸숏헤어', '스코티쉬폴드', '노르웨이숲', '메인쿤', '뱅갈', '아비시니안', '터키쉬앙고라', '믹스묘', '기타'];
|
||||
|
||||
function selectSpecies(species) {
|
||||
selectedSpecies = species;
|
||||
document.querySelectorAll('.species-option').forEach(el => {
|
||||
el.classList.toggle('selected', el.dataset.species === species);
|
||||
});
|
||||
|
||||
// 품종 옵션 업데이트
|
||||
const breedSelect = document.getElementById('petBreed');
|
||||
const breeds = species === 'dog' ? DOG_BREEDS : CAT_BREEDS;
|
||||
breedSelect.innerHTML = '<option value="">선택해주세요</option>' +
|
||||
breeds.map(b => `<option value="${b}">${b}</option>`).join('');
|
||||
}
|
||||
|
||||
function openAddPetModal() {
|
||||
currentPetId = null;
|
||||
document.getElementById('modalTitle').textContent = '반려동물 등록';
|
||||
document.getElementById('petId').value = '';
|
||||
document.getElementById('petForm').reset();
|
||||
document.getElementById('photoPreview').innerHTML = '📷';
|
||||
document.getElementById('submitBtn').textContent = '등록하기';
|
||||
document.getElementById('deleteBtn').style.display = 'none';
|
||||
selectedSpecies = '';
|
||||
document.querySelectorAll('.species-option').forEach(el => el.classList.remove('selected'));
|
||||
document.getElementById('petModal').classList.add('active');
|
||||
}
|
||||
|
||||
function editPet(petId) {
|
||||
// TODO: API에서 pet 정보 가져와서 폼에 채우기
|
||||
currentPetId = petId;
|
||||
document.getElementById('modalTitle').textContent = '반려동물 수정';
|
||||
document.getElementById('submitBtn').textContent = '수정하기';
|
||||
document.getElementById('deleteBtn').style.display = 'block';
|
||||
document.getElementById('petModal').classList.add('active');
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
document.getElementById('petModal').classList.remove('active');
|
||||
}
|
||||
|
||||
function previewPhoto(event) {
|
||||
const file = event.target.files[0];
|
||||
if (file) {
|
||||
const reader = new FileReader();
|
||||
reader.onload = function(e) {
|
||||
document.getElementById('photoPreview').innerHTML =
|
||||
`<img src="${e.target.result}" alt="미리보기">`;
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
}
|
||||
|
||||
async function submitPet(event) {
|
||||
event.preventDefault();
|
||||
|
||||
if (!selectedSpecies) {
|
||||
alert('종류를 선택해주세요.');
|
||||
return;
|
||||
}
|
||||
|
||||
const name = document.getElementById('petName').value.trim();
|
||||
if (!name) {
|
||||
alert('이름을 입력해주세요.');
|
||||
return;
|
||||
}
|
||||
|
||||
const btn = document.getElementById('submitBtn');
|
||||
btn.disabled = true;
|
||||
btn.textContent = '처리중...';
|
||||
|
||||
try {
|
||||
const data = {
|
||||
name: name,
|
||||
species: selectedSpecies,
|
||||
breed: document.getElementById('petBreed').value,
|
||||
gender: document.getElementById('petGender').value
|
||||
};
|
||||
|
||||
const url = currentPetId ? `/api/pets/${currentPetId}` : '/api/pets';
|
||||
const method = currentPetId ? 'PUT' : 'POST';
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: method,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
// 사진 업로드
|
||||
const photoInput = document.getElementById('photoInput');
|
||||
if (photoInput.files.length > 0) {
|
||||
const petId = result.pet_id || currentPetId;
|
||||
const formData = new FormData();
|
||||
formData.append('photo', photoInput.files[0]);
|
||||
|
||||
await fetch(`/api/pets/${petId}/photo`, {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
}
|
||||
|
||||
alert(result.message || '저장되었습니다!');
|
||||
location.reload();
|
||||
} else {
|
||||
alert(result.error || '오류가 발생했습니다.');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
alert('서버 오류가 발생했습니다.');
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
btn.textContent = currentPetId ? '수정하기' : '등록하기';
|
||||
}
|
||||
}
|
||||
|
||||
async function deletePet() {
|
||||
if (!currentPetId) return;
|
||||
if (!confirm('정말 삭제하시겠습니까?')) return;
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/pets/${currentPetId}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
alert('삭제되었습니다.');
|
||||
location.reload();
|
||||
} else {
|
||||
alert(result.error || '삭제 실패');
|
||||
}
|
||||
} catch (error) {
|
||||
alert('서버 오류가 발생했습니다.');
|
||||
}
|
||||
}
|
||||
|
||||
// 모달 외부 클릭 시 닫기
|
||||
document.getElementById('petModal').addEventListener('click', function(e) {
|
||||
if (e.target === this) closeModal();
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
176
docs/ANIMAL_DRUG_APC_MAPPING.md
Normal file
176
docs/ANIMAL_DRUG_APC_MAPPING.md
Normal file
@ -0,0 +1,176 @@
|
||||
# 동물약 APC 매핑 가이드
|
||||
|
||||
> 최종 업데이트: 2026-03-02
|
||||
|
||||
## 개요
|
||||
|
||||
POS(PIT3000)의 동물약 제품을 APDB의 APC 코드와 매핑하여 제품 정보(용법, 용량, 주의사항) 및 이미지를 표시하기 위한 작업 가이드.
|
||||
|
||||
---
|
||||
|
||||
## 현재 상태
|
||||
|
||||
### 매핑 현황
|
||||
|
||||
| 구분 | 개수 | 비율 |
|
||||
|------|------|------|
|
||||
| 동물약 총 | 39개 | 100% |
|
||||
| APC 매핑됨 | 7개 | 18% |
|
||||
| **APC 미매핑** | **32개** | **82%** |
|
||||
|
||||
### 매핑 완료 제품
|
||||
|
||||
| POS 제품명 | DrugCode | APC |
|
||||
|------------|----------|-----|
|
||||
| (판)복합개시딘 | LB000003140 | 0231093520106 |
|
||||
| 안텔민킹(5kg이상) | LB000003158 | 0230237810109 |
|
||||
| 안텔민뽀삐(5kg이하) | LB000003157 | 0230237010107 |
|
||||
| 파라캅L(5kg이상) | LB000003159 | 0230338510101 |
|
||||
| 파라캅S(5kg이하) | LB000003160 | 0230347110106 |
|
||||
| 세레니아정16mg(개멀미약) | LB000003353 | 0231884610109 |
|
||||
| 세레니아정24mg(개멀미약) | LB000003354 | 0231884620107 |
|
||||
|
||||
---
|
||||
|
||||
## 매핑 구조
|
||||
|
||||
### 데이터베이스 연결
|
||||
|
||||
```
|
||||
MSSQL (192.168.0.4\PM2014) PostgreSQL (192.168.0.87:5432)
|
||||
┌─────────────────────────┐ ┌─────────────────────────┐
|
||||
│ PM_DRUG.CD_GOODS │ │ apdb_master.apc │
|
||||
│ - DrugCode │ │ - apc (PK) │
|
||||
│ - GoodsName │ │ - product_name │
|
||||
│ - BARCODE │ │ - image_url1 │
|
||||
│ │ │ - llm_pharm (JSONB) │
|
||||
├─────────────────────────┤ └─────────────────────────┘
|
||||
│ PM_DRUG.CD_ITEM_UNIT_ │
|
||||
│ MEMBER │
|
||||
│ - DRUGCODE (FK) │
|
||||
│ - CD_CD_BARCODE ◀───────┼── APC 코드 저장 (023%로 시작)
|
||||
│ - CHANGE_DATE │
|
||||
└─────────────────────────┘
|
||||
```
|
||||
|
||||
### APC 매핑 방식
|
||||
|
||||
1. `CD_ITEM_UNIT_MEMBER` 테이블에 **추가 바코드**로 APC 등록
|
||||
2. 기존 바코드는 유지, APC를 별도 레코드로 INSERT
|
||||
3. APC 코드는 `023%`로 시작 (식별자)
|
||||
|
||||
---
|
||||
|
||||
## 1:1 매핑 가능 후보
|
||||
|
||||
### ✅ 확실한 매핑 (1개)
|
||||
|
||||
| POS 제품명 | DrugCode | APC | APDB 제품명 | 이미지 |
|
||||
|------------|----------|-----|-------------|--------|
|
||||
| **제스타제(10정)** | LB000003146 | 8809720800455 | 제스타제 | ✅ 있음 |
|
||||
|
||||
### ⚠️ 검토 필요 (1개)
|
||||
|
||||
| POS 제품명 | DrugCode | APC 후보 | 비고 |
|
||||
|------------|----------|----------|------|
|
||||
| 안텔민 | S0000001 | 0230237800003 | "안텔민킹"과 "안텔민뽀삐"는 이미 별도 매핑됨. 이 제품이 무엇인지 확인 필요 |
|
||||
|
||||
### ❌ APDB에 없음 (3개)
|
||||
|
||||
| POS 제품명 | 사유 |
|
||||
|------------|------|
|
||||
| (판)클라펫정50(100정) | APDB엔 "클라펫 정"만 있음 (함량 불일치) |
|
||||
| 넥스가드xs(2~3.5kg) | 사이즈별 APC 없음 |
|
||||
| 캐치원캣(2.5~7.5kg)/고양이 | APDB에 캐치원 자체가 없음 |
|
||||
|
||||
---
|
||||
|
||||
## 1:N 매핑 필요 제품 (27개)
|
||||
|
||||
사이즈별로 세분화된 제품들. 하나의 APDB APC에 여러 POS 제품을 매핑해야 함.
|
||||
|
||||
### 브랜드별 현황
|
||||
|
||||
| 브랜드 | POS 제품 수 | APDB 존재 | 비고 |
|
||||
|--------|-------------|-----------|------|
|
||||
| 다이로하트 | 3개 (SS/S/M) | ✅ | 다이로하트 츄어블 정 |
|
||||
| 하트세이버 | 4개 (mini/S/M/L) | ✅ | 하트세이버 플러스 츄어블 |
|
||||
| 하트웜솔루션 | 2개 (S/M) | ❌ | APDB에 없음 |
|
||||
| 리펠로 | 2개 (S/M) | ✅ | 리펠로액 (이미지 있음!) |
|
||||
| 캐치원 | 5개 (SS/S/M/L/캣) | ❌ | APDB에 없음 |
|
||||
| 셀라이트 | 5개 (SS/S/M/L/XL) | ✅ | 셀라이트 액 |
|
||||
| 넥스가드 | 2개 (xs/L) | ✅ | 넥스가드 스펙트라 |
|
||||
| 가드닐 | 3개 (S/M/L) | ✅ | 가드닐 액 |
|
||||
| 심피드 | 2개 (M/L) | ❌ | APDB에 없음 |
|
||||
| 하트캅 | 1개 | ✅ | 하트캅-츄어블 정 |
|
||||
|
||||
---
|
||||
|
||||
## APDB 통계
|
||||
|
||||
| 항목 | 수치 |
|
||||
|------|------|
|
||||
| 전체 APC | 16,326개 |
|
||||
| 이미지 있음 | 73개 (0.4%) |
|
||||
| LLM 정보 있음 | 81개 (0.5%) |
|
||||
| 동물 관련 키워드 | ~200개 |
|
||||
|
||||
⚠️ **주의:** APDB에 이미지가 거의 없음. 이미지 표시가 목적이라면 다른 소스 필요.
|
||||
|
||||
---
|
||||
|
||||
## 매핑 스크립트
|
||||
|
||||
### 매핑 후보 찾기
|
||||
```bash
|
||||
python backend/scripts/batch_apc_matching.py
|
||||
```
|
||||
|
||||
### 1:1 매핑 가능 후보 추출
|
||||
```bash
|
||||
python backend/scripts/find_1to1_candidates.py
|
||||
```
|
||||
|
||||
### 매핑 실행 (수동)
|
||||
```python
|
||||
# backend/scripts/batch_insert_apc.py 참고
|
||||
MAPPINGS = [
|
||||
('제스타제(10정)', 'LB000003146', '8809720800455'),
|
||||
]
|
||||
```
|
||||
|
||||
### INSERT 쿼리 예시
|
||||
```sql
|
||||
INSERT INTO CD_ITEM_UNIT_MEMBER (
|
||||
DRUGCODE, CD_CD_UNIT, CD_NM_UNIT, CD_MY_UNIT, CD_IN_UNIT,
|
||||
CD_CD_BARCODE, CD_CD_POS, CHANGE_DATE
|
||||
) VALUES (
|
||||
'LB000003146', -- DrugCode
|
||||
'015', -- 단위코드
|
||||
1.0, -- 단위명
|
||||
<기존값>, -- CD_MY_UNIT (기존 레코드에서 복사)
|
||||
<기존값>, -- CD_IN_UNIT (기존 레코드에서 복사)
|
||||
'8809720800455', -- APC 바코드
|
||||
'',
|
||||
'20260302' -- 변경일자
|
||||
)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 다음 단계
|
||||
|
||||
1. **제스타제** 1:1 매핑 실행
|
||||
2. **안텔민(S0000001)** 제품 확인 후 결정
|
||||
3. 1:N 매핑 정책 결정 (사이즈별 제품 → 동일 APC?)
|
||||
4. 이미지 소스 대안 검토 (필요시)
|
||||
|
||||
---
|
||||
|
||||
## 관련 파일
|
||||
|
||||
- `backend/db/dbsetup.py` - DB 연결 설정
|
||||
- `backend/scripts/batch_apc_matching.py` - 매칭 후보 찾기
|
||||
- `backend/scripts/batch_insert_apc.py` - 매핑 실행
|
||||
- `backend/scripts/find_1to1_candidates.py` - 1:1 후보 추출
|
||||
- `backend/app.py` - `_get_animal_drugs()`, `_get_animal_drug_rag()`
|
||||
Loading…
Reference in New Issue
Block a user