Compare commits

...

5 Commits

Author SHA1 Message Date
thug0bin
acf8e44aa5 fix: 이미지 상태 필터와 통계 일관성 수정
- '실패' 필터 선택 시 failed + no_result 둘 다 검색되도록 수정
- 통계 라벨: '실패' → '실패/없음'
- 필터 옵션: '실패' → '실패/검색없음', 별도 'no_result' 옵션 제거
- 상단 통계와 필터 결과가 일치하도록 UX 개선
2026-03-04 10:25:53 +09:00
thug0bin
546a5e7ae6 feat: 제품 이미지 카메라 촬영 기능 추가
- HTML5 getUserMedia로 카메라 촬영 지원 (모바일 후면 카메라 기본)
- 1:1 가이드 박스 UI로 정사각형 크롭 안내
- 백엔드: PIL로 800x800 리사이즈 + 썸네일 생성
- 기존 URL 교체 기능과 탭 방식으로 통합

버그 수정:
- closeReplaceModal() 호출 전 변수 복사로 null 전송 문제 해결
- None 값 방어 코드 추가

Docs: TROUBLESHOOTING-CAMERA-UPLOAD.md 추가
2026-03-04 10:08:40 +09:00
thug0bin
30d95c8579 feat: 제품 이미지 크롤링에 날짜 선택 기능 추가 - 달력으로 날짜 선택 가능 - 해당 날짜 판매 제품 크롤링 2026-03-04 00:55:02 +09:00
thug0bin
51216c582f fix: 알림톡 실패 시 상세 에러 메시지 저장 - header.resultMessage 대신 sendResults[0].resultMessage 우선 저장 - 원인 파악이 가능하도록 개선 2026-03-04 00:36:25 +09:00
thug0bin
9ba2846820 fix: 알림톡 14자 제한 수정 - 특수문자 제거 로직 삭제 (불필요했음) - 14자 제한으로 자르기 (카카오 API 제한) 2026-03-04 00:33:25 +09:00
5 changed files with 515 additions and 64 deletions

View File

@ -5707,8 +5707,12 @@ def api_product_images_list():
params = []
if status_filter:
where_clauses.append("status = ?")
params.append(status_filter)
# "failed" 필터는 failed + no_result 둘 다 포함 (통계와 일치시키기 위해)
if status_filter == 'failed':
where_clauses.append("status IN ('failed', 'no_result')")
else:
where_clauses.append("status = ?")
params.append(status_filter)
if search:
where_clauses.append("(product_name LIKE ? OR barcode LIKE ?)")
@ -5767,11 +5771,13 @@ def api_product_image_detail(barcode):
@app.route('/api/admin/product-images/crawl-today', methods=['POST'])
def api_crawl_today():
"""오늘 판매 제품 크롤링"""
"""특정 날짜 판매 제품 크롤링 (date 파라미터 없으면 오늘)"""
try:
from utils.yakkok_crawler import crawl_today_sales
result = crawl_today_sales(headless=True)
return jsonify({'success': True, 'result': result})
from utils.yakkok_crawler import crawl_sales_by_date
data = request.get_json() or {}
date_str = data.get('date') # YYYY-MM-DD 형식
result = crawl_sales_by_date(date_str, headless=True)
return jsonify({'success': True, 'result': result, 'date': date_str or 'today'})
except Exception as e:
logging.error(f"크롤링 오류: {e}")
return jsonify({'success': False, 'error': str(e)}), 500
@ -5925,6 +5931,101 @@ def api_replace_product_image(barcode):
return jsonify({'success': False, 'error': str(e)}), 500
@app.route('/api/admin/product-images/<barcode>/upload', methods=['POST'])
def api_upload_product_image(barcode):
"""카메라 촬영 이미지 업로드 (base64 -> 1:1 크롭 -> 800x800 리사이즈)"""
import sqlite3
import base64
from PIL import Image
from io import BytesIO
try:
data = request.get_json() or {}
image_data = (data.get('image_data') or '').strip()
product_name = data.get('product_name') or barcode
if not image_data:
return jsonify({'success': False, 'error': '이미지 데이터 필요'}), 400
# data:image/...;base64, 접두사 제거
if ',' in image_data:
image_data = image_data.split(',', 1)[1]
try:
# base64 디코딩
image_bytes = base64.b64decode(image_data)
img = Image.open(BytesIO(image_bytes))
# RGBA -> RGB 변환 (PNG 등 투명 배경 처리)
if img.mode == 'RGBA':
bg = Image.new('RGB', img.size, (255, 255, 255))
bg.paste(img, mask=img.split()[3])
img = bg
elif img.mode != 'RGB':
img = img.convert('RGB')
# 1:1 중앙 크롭 (정사각형)
width, height = img.size
min_dim = min(width, height)
left = (width - min_dim) // 2
top = (height - min_dim) // 2
right = left + min_dim
bottom = top + min_dim
img = img.crop((left, top, right, bottom))
# 800x800 리사이즈
target_size = 800
img = img.resize((target_size, target_size), Image.LANCZOS)
# base64 변환 (원본)
buffer = BytesIO()
img.save(buffer, format='JPEG', quality=90)
image_base64 = base64.b64encode(buffer.getvalue()).decode('utf-8')
# 썸네일 생성 (200x200)
thumb_size = 200
thumb_img = img.resize((thumb_size, thumb_size), Image.LANCZOS)
thumb_buffer = BytesIO()
thumb_img.save(thumb_buffer, format='JPEG', quality=85)
thumbnail_base64 = base64.b64encode(thumb_buffer.getvalue()).decode('utf-8')
except Exception as e:
return jsonify({'success': False, 'error': f'이미지 처리 실패: {str(e)}'}), 400
# SQLite 저장
db_path = os.path.join(os.path.dirname(__file__), 'db', 'product_images.db')
conn = sqlite3.connect(db_path)
cursor = conn.cursor()
# 기존 레코드 확인
cursor.execute("SELECT product_name, drug_code FROM product_images WHERE barcode = ?", (barcode,))
existing = cursor.fetchone()
if existing:
# 기존 레코드 있으면 이미지만 업데이트
cursor.execute("""
UPDATE product_images
SET image_base64 = ?, thumbnail_base64 = ?, image_url = NULL,
status = 'manual', error_message = NULL, updated_at = datetime('now')
WHERE barcode = ?
""", (image_base64, thumbnail_base64, barcode))
else:
# 새 레코드 생성
cursor.execute("""
INSERT INTO product_images (barcode, product_name, image_base64, thumbnail_base64, status)
VALUES (?, ?, ?, ?, 'manual')
""", (barcode, product_name, image_base64, thumbnail_base64))
conn.commit()
conn.close()
return jsonify({'success': True, 'message': '촬영 이미지 저장 완료'})
except Exception as e:
logging.error(f"이미지 업로드 오류: {e}")
return jsonify({'success': False, 'error': str(e)}), 500
@app.route('/api/admin/product-images/stats')
def api_product_images_stats():
"""이미지 통계"""

View File

@ -87,7 +87,16 @@ def _send_alimtalk(template_code, recipient_no, template_params):
logger.info(f"알림톡 발송 성공: {template_code}{recipient_no}")
return (True, "발송 성공")
else:
error_msg = result.get('header', {}).get('resultMessage', str(result))
# 상세 에러 추출: sendResults[0].resultMessage 우선, 없으면 header.resultMessage
header_msg = result.get('header', {}).get('resultMessage', '')
send_results = result.get('message', {}).get('sendResults', [])
detail_msg = send_results[0].get('resultMessage', '') if send_results else ''
# 상세 에러가 있으면 그걸 사용, 없으면 header 에러
error_msg = detail_msg if detail_msg and detail_msg != 'SUCCESS' else header_msg
if not error_msg:
error_msg = str(result)
logger.warning(f"알림톡 발송 실패: {template_code}{recipient_no}: {error_msg}")
return (False, error_msg)
@ -100,15 +109,25 @@ def _send_alimtalk(template_code, recipient_no, template_params):
def build_item_summary(items):
"""구매 품목 요약 문자열 생성 (예: '타이레놀 외 3건')"""
"""구매 품목 요약 문자열 생성 (예: '타이레놀 외 3건')
Note: 카카오 알림톡 템플릿 변수는 14 제한
(에러: "Blacklist can't use more than 14 characters in template value.")
특수문자(%, 괄호 ) 문제없이 발송 가능!
"""
if not items:
return "약국 구매"
first = items[0]['name']
if len(first) > 20:
first = first[:18] + '..'
first = first.strip()
if len(items) == 1:
return first
return f"{first}{len(items) - 1}"
# 단일 품목: 14자 제한 (그냥 자름)
return first[:14]
# 복수 품목: "외 N건" 붙으므로 전체 14자 맞춤
suffix = f"{len(items) - 1}"
max_first = 14 - len(suffix)
return f"{first[:max_first]}{suffix}"
def send_mileage_claim_alimtalk(phone, name, points, balance, items=None,
@ -146,24 +165,7 @@ def send_mileage_claim_alimtalk(phone, name, points, balance, items=None,
success, msg = _send_alimtalk(template_code, phone, params)
if not success:
# V3 실패 로그
_log_to_db(template_code, phone, False, msg,
template_params=params, user_id=user_id,
trigger_source=trigger_source, transaction_id=transaction_id)
# V2 폴백
template_code = 'MILEAGE_CLAIM_V2'
params = {
'고객명': name,
'적립포인트': f'{points:,}',
'총잔액': f'{balance:,}',
'적립일시': now_kst,
'전화번호': phone
}
success, msg = _send_alimtalk(template_code, phone, params)
# 최종 결과 로그
# 결과 로그 (V3만 사용, V2 폴백 제거 - V2 반려 상태)
_log_to_db(template_code, phone, success, msg,
template_params=params, user_id=user_id,
trigger_source=trigger_source, transaction_id=transaction_id)

View File

@ -308,6 +308,30 @@
to { transform: translateX(0); opacity: 1; }
}
/* 탭 스타일 */
.tab-btn {
flex: 1;
padding: 10px 16px;
background: rgba(255,255,255,0.05);
border: 1px solid rgba(255,255,255,0.1);
border-radius: 8px;
color: #9ca3af;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.3s;
}
.tab-btn:hover {
background: rgba(255,255,255,0.1);
}
.tab-btn.active {
background: linear-gradient(135deg, #8b5cf6, #6366f1);
color: white;
border-color: transparent;
}
.empty-state {
text-align: center;
padding: 60px 20px;
@ -325,8 +349,9 @@
<header>
<h1>🖼️ 제품 이미지 관리</h1>
<div class="actions">
<button class="btn btn-primary" onclick="crawlToday()">
🔄 오늘 판매 제품 크롤링
<input type="date" id="crawlDate" class="filter-select" style="padding: 8px 12px;">
<button class="btn btn-primary" onclick="crawlByDate()">
🔄 해당일 판매 제품 크롤링
</button>
<button class="btn btn-secondary" onclick="openManualCrawl()">
수동 크롤링
@ -346,7 +371,7 @@
</div>
<div class="stat-card failed">
<div class="value" id="statFailed">-</div>
<div class="label">실패</div>
<div class="label">실패/없음</div>
</div>
<div class="stat-card pending">
<div class="value" id="statPending">-</div>
@ -361,8 +386,7 @@
<select class="filter-select" id="statusFilter" onchange="loadImages()">
<option value="">전체 상태</option>
<option value="success">성공</option>
<option value="failed">실패</option>
<option value="no_result">검색결과없음</option>
<option value="failed">실패/검색없음</option>
<option value="pending">대기</option>
<option value="manual">수동등록</option>
</select>
@ -410,28 +434,80 @@
</div>
</div>
<!-- 이미지 교체 모달 -->
<!-- 이미지 교체 모달 (탭 방식: URL / 촬영) -->
<div class="modal" id="replaceModal">
<div class="modal-content" style="max-width: 500px;">
<h3>🔄 이미지 교체</h3>
<p style="color: #9ca3af; margin-bottom: 8px; font-size: 13px;">
구글 이미지 등에서 찾은 URL을 붙여넣으세요
</p>
<div id="replaceProductInfo" style="background: rgba(139,92,246,0.1); border-radius: 8px; padding: 12px; margin-bottom: 16px;">
<div style="font-weight: 600;" id="replaceProductName"></div>
<div style="font-size: 12px; color: #a855f7; font-family: monospace;" id="replaceBarcode"></div>
</div>
<div style="margin-bottom: 16px;">
<label style="display: block; margin-bottom: 4px; font-size: 13px;">이미지 URL *</label>
<input type="text" id="replaceImageUrl" class="search-box" style="width: 100%;"
placeholder="https://example.com/image.jpg">
<div style="font-size: 11px; color: #6b7280; margin-top: 4px;">
💡 이미지 우클릭 → "이미지 주소 복사"로 URL을 가져오세요
<!-- 탭 버튼 -->
<div class="replace-tabs" style="display: flex; gap: 8px; margin-bottom: 16px;">
<button class="tab-btn active" onclick="switchReplaceTab('url')" id="tabBtnUrl">
🔗 URL 입력
</button>
<button class="tab-btn" onclick="switchReplaceTab('camera')" id="tabBtnCamera">
📷 촬영
</button>
</div>
<!-- URL 입력 탭 -->
<div id="tabUrl" class="tab-content">
<p style="color: #9ca3af; margin-bottom: 8px; font-size: 13px;">
구글 이미지 등에서 찾은 URL을 붙여넣으세요
</p>
<div style="margin-bottom: 16px;">
<label style="display: block; margin-bottom: 4px; font-size: 13px;">이미지 URL *</label>
<input type="text" id="replaceImageUrl" class="search-box" style="width: 100%;"
placeholder="https://example.com/image.jpg">
<div style="font-size: 11px; color: #6b7280; margin-top: 4px;">
💡 이미지 우클릭 → "이미지 주소 복사"로 URL을 가져오세요
</div>
</div>
<div style="text-align: right;">
<button class="btn btn-secondary" onclick="closeReplaceModal()">취소</button>
<button class="btn btn-primary" onclick="submitReplace()">교체하기</button>
</div>
</div>
<div style="text-align: right;">
<button class="btn btn-secondary" onclick="closeReplaceModal()">취소</button>
<button class="btn btn-primary" onclick="submitReplace()">교체하기</button>
<!-- 카메라 촬영 탭 -->
<div id="tabCamera" class="tab-content" style="display: none;">
<!-- 카메라 뷰 -->
<div id="cameraContainer" style="position: relative; width: 100%; aspect-ratio: 1; background: #000; border-radius: 8px; overflow: hidden; margin-bottom: 12px;">
<video id="cameraVideo" autoplay playsinline style="width: 100%; height: 100%; object-fit: cover;"></video>
<!-- 1:1 가이드 오버레이 -->
<div id="cameraGuide" style="position: absolute; top: 0; left: 0; right: 0; bottom: 0; pointer-events: none;">
<svg width="100%" height="100%" viewBox="0 0 100 100" preserveAspectRatio="none">
<!-- 반투명 외곽 -->
<rect x="0" y="0" width="100" height="100" fill="rgba(0,0,0,0.4)"/>
<!-- 중앙 투명 영역 -->
<rect x="10" y="10" width="80" height="80" fill="transparent" stroke="#a855f7" stroke-width="0.5" stroke-dasharray="2,2"/>
<!-- 모서리 강조 -->
<path d="M10,20 L10,10 L20,10" fill="none" stroke="#a855f7" stroke-width="1"/>
<path d="M80,10 L90,10 L90,20" fill="none" stroke="#a855f7" stroke-width="1"/>
<path d="M90,80 L90,90 L80,90" fill="none" stroke="#a855f7" stroke-width="1"/>
<path d="M20,90 L10,90 L10,80" fill="none" stroke="#a855f7" stroke-width="1"/>
</svg>
</div>
<!-- 촬영된 이미지 미리보기 -->
<canvas id="captureCanvas" style="display: none; position: absolute; top: 0; left: 0; width: 100%; height: 100%;"></canvas>
</div>
<p style="color: #9ca3af; font-size: 12px; text-align: center; margin-bottom: 12px;">
📦 제품을 보라색 가이드 안에 맞춰주세요
</p>
<!-- 버튼들 -->
<div id="cameraButtons" style="display: flex; gap: 8px; justify-content: center;">
<button class="btn btn-secondary" onclick="closeReplaceModal()">취소</button>
<button class="btn btn-primary" id="captureBtn" onclick="capturePhoto()">📸 촬영</button>
</div>
<div id="previewButtons" style="display: none; gap: 8px; justify-content: center;">
<button class="btn btn-secondary" onclick="retakePhoto()">↩️ 다시 촬영</button>
<button class="btn btn-primary" onclick="submitCapture()">✅ 저장</button>
</div>
</div>
</div>
</div>
@ -453,6 +529,10 @@
// 초기 로드
document.addEventListener('DOMContentLoaded', () => {
// 오늘 날짜로 기본값 설정
const today = new Date().toISOString().split('T')[0];
document.getElementById('crawlDate').value = today;
loadStats();
loadImages();
});
@ -542,14 +622,25 @@
debounceTimer = setTimeout(loadImages, 300);
}
async function crawlToday() {
if (!confirm('오늘 판매된 제품 이미지를 크롤링합니다. 진행할까요?')) return;
async function crawlByDate() {
const dateInput = document.getElementById('crawlDate').value;
if (!dateInput) {
showToast('날짜를 선택하세요', 'error');
return;
}
showToast('크롤링 시작... 잠시 기다려주세요', 'info');
const dateStr = dateInput.replace(/-/g, '');
const displayDate = `${dateStr.slice(4,6)}/${dateStr.slice(6,8)}`;
if (!confirm(`${displayDate} 판매 제품 이미지를 크롤링합니다. 진행할까요?`)) return;
showToast(`${displayDate} 크롤링 시작... 잠시 기다려주세요`, 'info');
try {
const res = await fetch('/api/admin/product-images/crawl-today', {
method: 'POST'
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ date: dateInput })
});
const data = await res.json();
@ -741,6 +832,9 @@
// 이미지 교체
let replaceTargetBarcode = null;
let replaceTargetName = null;
let cameraStream = null;
let capturedImageData = null;
function openReplaceModal(barcode, productName) {
console.log('openReplaceModal called with:', barcode, productName);
@ -751,16 +845,173 @@
}
replaceTargetBarcode = barcode;
document.getElementById('replaceProductName').textContent = productName || barcode;
replaceTargetName = productName || barcode;
document.getElementById('replaceProductName').textContent = replaceTargetName;
document.getElementById('replaceBarcode').textContent = barcode;
document.getElementById('replaceImageUrl').value = '';
// 탭 초기화 (URL 탭으로)
switchReplaceTab('url');
document.getElementById('replaceModal').classList.add('show');
document.getElementById('replaceImageUrl').focus();
}
function closeReplaceModal() {
stopCamera();
document.getElementById('replaceModal').classList.remove('show');
replaceTargetBarcode = null;
replaceTargetName = null;
capturedImageData = null;
}
function switchReplaceTab(tab) {
// 탭 버튼 활성화
document.getElementById('tabBtnUrl').classList.toggle('active', tab === 'url');
document.getElementById('tabBtnCamera').classList.toggle('active', tab === 'camera');
// 탭 콘텐츠 표시
document.getElementById('tabUrl').style.display = tab === 'url' ? 'block' : 'none';
document.getElementById('tabCamera').style.display = tab === 'camera' ? 'block' : 'none';
// 카메라 탭이면 카메라 시작
if (tab === 'camera') {
startCamera();
} else {
stopCamera();
}
}
async function startCamera() {
try {
// 이전 스트림 정리
stopCamera();
// 후면 카메라 우선 (모바일)
const constraints = {
video: {
facingMode: { ideal: 'environment' }, // 후면 카메라
width: { ideal: 1920 },
height: { ideal: 1920 }
},
audio: false
};
cameraStream = await navigator.mediaDevices.getUserMedia(constraints);
const video = document.getElementById('cameraVideo');
video.srcObject = cameraStream;
video.style.display = 'block';
// 캡처 상태 초기화
document.getElementById('captureCanvas').style.display = 'none';
document.getElementById('cameraGuide').style.display = 'block';
document.getElementById('cameraButtons').style.display = 'flex';
document.getElementById('previewButtons').style.display = 'none';
capturedImageData = null;
} catch (err) {
console.error('카메라 접근 오류:', err);
showToast('카메라에 접근할 수 없습니다: ' + err.message, 'error');
}
}
function stopCamera() {
if (cameraStream) {
cameraStream.getTracks().forEach(track => track.stop());
cameraStream = null;
}
const video = document.getElementById('cameraVideo');
video.srcObject = null;
}
function capturePhoto() {
const video = document.getElementById('cameraVideo');
const canvas = document.getElementById('captureCanvas');
const ctx = canvas.getContext('2d');
// 비디오 크기
const vw = video.videoWidth;
const vh = video.videoHeight;
// 1:1 영역 계산 (가이드 박스: 80% 영역)
const minDim = Math.min(vw, vh);
const cropSize = minDim * 0.8;
const sx = (vw - cropSize) / 2;
const sy = (vh - cropSize) / 2;
// 캔버스를 800x800으로 설정 (최종 해상도)
canvas.width = 800;
canvas.height = 800;
// 크롭하여 그리기
ctx.drawImage(video, sx, sy, cropSize, cropSize, 0, 0, 800, 800);
// base64 저장
capturedImageData = canvas.toDataURL('image/jpeg', 0.92);
// UI 전환 (미리보기 모드)
video.style.display = 'none';
canvas.style.display = 'block';
document.getElementById('cameraGuide').style.display = 'none';
document.getElementById('cameraButtons').style.display = 'none';
document.getElementById('previewButtons').style.display = 'flex';
// 카메라 스트림은 유지 (다시 촬영 위해)
}
function retakePhoto() {
const video = document.getElementById('cameraVideo');
const canvas = document.getElementById('captureCanvas');
// UI 전환 (카메라 모드)
video.style.display = 'block';
canvas.style.display = 'none';
document.getElementById('cameraGuide').style.display = 'block';
document.getElementById('cameraButtons').style.display = 'flex';
document.getElementById('previewButtons').style.display = 'none';
capturedImageData = null;
}
async function submitCapture() {
if (!capturedImageData) {
showToast('촬영된 이미지가 없습니다', 'error');
return;
}
if (!replaceTargetBarcode) {
showToast('바코드 정보가 없습니다', 'error');
return;
}
// 모달 닫기 전에 값 복사 (closeReplaceModal에서 null로 리셋되므로)
const barcode = replaceTargetBarcode;
const productName = replaceTargetName;
const imageData = capturedImageData;
closeReplaceModal();
showToast(`"${productName}" 이미지 저장 중...`, 'info');
try {
const res = await fetch(`/api/admin/product-images/${barcode}/upload`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
image_data: imageData,
product_name: productName
})
});
const data = await res.json();
if (data.success) {
showToast('✅ 촬영 이미지 저장 완료!', 'success');
loadStats();
loadImages();
} else {
showToast(data.error || '저장 실패', 'error');
}
} catch (err) {
showToast('오류: ' + err.message, 'error');
}
}
async function submitReplace() {

View File

@ -272,8 +272,12 @@ def crawl_products(products, headless=True):
return results
def get_today_sales_products():
"""오늘 판매된 제품 목록 조회 (MSSQL)"""
def get_sales_products(date_str=None):
"""특정 날짜 판매 제품 목록 조회 (MSSQL)
Args:
date_str: 날짜 문자열 (YYYYMMDD 또는 YYYY-MM-DD), None이면 오늘
"""
try:
# 상위 폴더의 db 모듈 import
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..'))
@ -282,9 +286,14 @@ def get_today_sales_products():
session = db_manager.get_session('PM_PRES')
today = datetime.now().strftime('%Y%m%d')
# 날짜 처리
if date_str:
# YYYY-MM-DD -> YYYYMMDD 변환
target_date = date_str.replace('-', '')
else:
target_date = datetime.now().strftime('%Y%m%d')
# 오늘 판매된 품목 조회 (중복 제거)
# 해당 날짜 판매된 품목 조회 (중복 제거)
query = text("""
SELECT DISTINCT
COALESCE(NULLIF(G.Barcode, ''),
@ -294,11 +303,11 @@ def get_today_sales_products():
ISNULL(G.GoodsName, '알수없음') AS product_name
FROM SALE_SUB S
LEFT JOIN PM_DRUG.dbo.CD_GOODS G ON S.DrugCode = G.DrugCode
WHERE S.SL_NO_order LIKE :today_pattern
WHERE S.SL_NO_order LIKE :date_pattern
AND S.DrugCode IS NOT NULL
""")
result = session.execute(query, {'today_pattern': f'{today}%'}).fetchall()
result = session.execute(query, {'date_pattern': f'{target_date}%'}).fetchall()
products = []
for row in result:
@ -306,7 +315,7 @@ def get_today_sales_products():
if barcode: # 바코드 있는 것만
products.append((barcode, row[1], row[2]))
logger.info(f"[MSSQL] 오늘 판매 품목: {len(products)}")
logger.info(f"[MSSQL] {target_date} 판매 품목: {len(products)}")
return products
except Exception as e:
@ -314,15 +323,29 @@ def get_today_sales_products():
return []
def crawl_today_sales(headless=True):
"""오늘 판매된 제품 이미지 크롤링"""
products = get_today_sales_products()
def get_today_sales_products():
"""오늘 판매된 제품 목록 조회 (하위호환)"""
return get_sales_products(None)
def crawl_sales_by_date(date_str=None, headless=True):
"""특정 날짜 판매 제품 이미지 크롤링
Args:
date_str: 날짜 문자열 (YYYYMMDD 또는 YYYY-MM-DD), None이면 오늘
"""
products = get_sales_products(date_str)
if not products:
return {'total': 0, 'success': 0, 'failed': 0, 'skipped': 0, 'message': '오늘 판매 내역 없음'}
return {'total': 0, 'success': 0, 'failed': 0, 'skipped': 0, 'message': '해당일 판매 내역 없음'}
return crawl_products(products, headless=headless)
def crawl_today_sales(headless=True):
"""오늘 판매된 제품 이미지 크롤링 (하위호환)"""
return crawl_sales_by_date(None, headless=headless)
# CLI 실행
if __name__ == '__main__':
import argparse

View File

@ -0,0 +1,74 @@
# 트러블슈팅: 카메라 촬영 이미지 업로드 실패
## 📅 발생일: 2026-03-04
## 🔴 증상
- 제품 이미지 관리 페이지에서 "촬영" 기능으로 이미지 교체 시 저장 실패
- 에러 메시지: `NoneType object has no attribute 'strip'`
- API 호출은 성공하나 이미지 데이터가 `null`로 전송됨
## 🔍 원인 분석
### 1차 원인: None 값 처리 누락 (백엔드)
```python
# 문제 코드
image_data = data.get('image_data', '').strip() # None이면 에러
# 수정 코드
image_data = (data.get('image_data') or '').strip()
```
### 2차 원인: 변수 리셋 타이밍 문제 (프론트엔드) ⭐ 핵심
```javascript
// 문제 코드
async function submitCapture() {
const barcode = replaceTargetBarcode;
const productName = replaceTargetName;
closeReplaceModal(); // ← 여기서 capturedImageData = null 로 리셋됨!
// API 호출 시 capturedImageData가 이미 null
body: JSON.stringify({
image_data: capturedImageData, // null!
...
})
}
// 수정 코드
async function submitCapture() {
const barcode = replaceTargetBarcode;
const productName = replaceTargetName;
const imageData = capturedImageData; // ← 미리 복사!
closeReplaceModal();
body: JSON.stringify({
image_data: imageData, // 복사된 값 사용
...
})
}
```
### 3차 원인: 브라우저 캐시
- 코드 수정 후에도 브라우저가 이전 JS를 캐시
- **Ctrl+Shift+R** (강력 새로고침) 필요
## ✅ 해결 방법
1. **백엔드**: `None` 값에 대한 방어 코드 추가
2. **프론트엔드**: `closeReplaceModal()` 호출 전에 필요한 변수들을 로컬 변수로 복사
3. **테스트 시**: 강력 새로고침 (Ctrl+Shift+R) 또는 시크릿 모드 사용
## 📝 교훈
1. **모달 닫기 함수에서 상태 리셋 주의**
- 모달을 닫으면서 관련 변수를 초기화하는 경우, async 함수에서 순서에 주의
2. **프론트엔드 디버깅 시 브라우저 캐시 확인**
- 코드 수정 후에도 동작이 같다면 캐시 문제 의심
3. **API 직접 테스트로 문제 범위 좁히기**
- `requests` 또는 `curl`로 API만 테스트하면 프론트/백엔드 문제 구분 가능
## 🔧 관련 파일
- `backend/app.py` - `/api/admin/product-images/<barcode>/upload` 엔드포인트
- `backend/templates/admin_product_images.html` - 카메라 촬영 UI 및 JS