Feat: --port 옵션, VWorld 맵 타일, 구글 드라이브 업로드 추가

This commit is contained in:
user01
2026-05-16 03:12:42 +09:00
parent 43f23c52fc
commit e3fb8de5c5
2 changed files with 451 additions and 8 deletions

285
app.py
View File

@@ -1,4 +1,4 @@
import os, io, random, requests, openpyxl, concurrent.futures
import os, io, random, requests, openpyxl, concurrent.futures, argparse
from flask import Flask, render_template, request, jsonify, send_file
import logging
@@ -345,6 +345,41 @@ def get_active():
return t, None, None
@app.route('/api/vworld-api-status')
def vworld_api_status():
"""VWorld API Key의 유효성을 확인합니다."""
if not VWORLD_API_KEY:
return jsonify({'valid': False, 'key': None})
# 간단한 주소로 VWorld API 테스트
try:
test_address = '서울특별시 용산구'
url = 'https://api.vworld.kr/req/address'
params = {
'service': 'address',
'request': 'getcoord',
'version': '2.0',
'crs': 'EPSG:4326',
'address': test_address,
'format': 'json',
'type': 'PARCEL',
'key': VWORLD_API_KEY,
}
resp = requests.get(url, params=params, timeout=5)
if resp.ok:
data = resp.json()
response = data.get('response', {})
status = response.get('status')
# OK, SUCCESS, FAIL 등의 상태가 반환되면 유효한 API Key
if status in ['OK', 'SUCCESS', 'FAIL']:
return jsonify({'valid': True, 'key': VWORLD_API_KEY})
except Exception as e:
logger.debug(f'VWorld API 테스트 실패: {e}')
return jsonify({'valid': False, 'key': None})
@app.route('/api/data')
def get_data():
tid = request.args.get('tab') or store['active_tab']
@@ -381,6 +416,186 @@ def is_likely_worker_sheet(sheet_name, headers):
return worker_indicators >= 2
def download_from_google_drive(file_id, timeout=30):
"""구글 드라이브에서 파일을 다운로드합니다."""
url = f'https://drive.google.com/uc?id={file_id}&export=download'
try:
logger.info(f'구글 드라이브에서 파일 다운로드 중: {file_id}')
resp = requests.get(url, timeout=timeout, allow_redirects=True, stream=True)
if not resp.ok:
logger.error(f'구글 드라이브 다운로드 실패: 상태코드={resp.status_code}')
return None
# 파일 크기 확인
file_size = int(resp.headers.get('content-length', 0))
if file_size == 0:
logger.warning('다운로드한 파일의 크기가 0입니다')
return None
if file_size > 16 * 1024 * 1024: # 16MB 이상
logger.error(f'파일이 너무 큽니다: {file_size} bytes')
return None
# 파일을 바이너리로 읽기
file_content = io.BytesIO(resp.content)
logger.info(f'파일 다운로드 완료: {file_size} bytes')
return file_content
except requests.exceptions.Timeout:
logger.error(f'구글 드라이브 다운로드 타임아웃: {file_id}')
return None
except Exception as e:
logger.error(f'구글 드라이브 다운로드 중 오류: {e}')
return None
def process_excel_file(wb, filename):
"""엑셀 파일을 처리하고 근무자/근무처 데이터를 반환합니다."""
workers, workplaces, all_addrs = [], [], set()
for sheet_name in wb.sheetnames:
ws = wb[sheet_name]
headers = [str(c.value or '').strip() for c in ws[1]]
logger.info(f' 시트: {sheet_name}, 헤더: {headers}')
update_progress('엑셀 파싱', f'시트 처리 중: {sheet_name}')
hope_idx = find_header_idx(headers, ['희망사항', '희망', '배치희망'])
is_worker_sheet = hope_idx is not None
name_idx = find_header_idx(headers, ['이름', '성명', '명칭', '근무지명', '투표소명'])
addr_idx = find_header_idx(headers, ['주소', '위치', '소재지'])
logger.info(f' → 희망사항: {hope_idx}, 이름: {name_idx}, 주소: {addr_idx}, 근무자시트: {is_worker_sheet}')
if is_worker_sheet:
hope_idx = hope_idx if hope_idx is not None else 1
name_idx = name_idx if name_idx is not None else 0
dob_idx = find_header_idx(headers, ['생년월일', '생일']) or 3
phone_idx = find_header_idx(headers, ['연락처', '전화', '휴대폰']) or 4
if addr_idx is None:
addr_idx = 2
logger.warning(f' [주의] 주소 컬럼을 찾을 수 없어 기본 인덱스 {addr_idx} 사용')
row_count = 0
for row in ws.iter_rows(min_row=2, values_only=True):
if not any(row): continue
name = str(row[name_idx] or '').strip() if name_idx is not None else ''
if not name: continue
addr = str(row[addr_idx] or '').strip()
if addr:
all_addrs.add(addr)
row_count += 1
logger.info(f' → 근무자 행 수: {row_count}')
else:
name_idx = name_idx if name_idx is not None else 0
if addr_idx is None:
addr_idx = 1
logger.warning(f' [주의] 주소 컬럼을 찾을 수 없어 기본 인덱스 {addr_idx} 사용')
row_count = 0
for row in ws.iter_rows(min_row=2, values_only=True):
if not any(row): continue
name = str(row[name_idx] or '').strip() if name_idx is not None else ''
if not name: continue
addr = str(row[addr_idx] or '').strip()
if addr:
all_addrs.add(addr)
row_count += 1
logger.info(f' → 근무처 행 수: {row_count}')
# 지오코딩
total_addrs = len(all_addrs)
logger.info(f'수집된 고유 주소 수: {total_addrs}')
GEO_STATS['success'] = 0
GEO_STATS['failed'] = 0
GEO_STATS['failed_addrs'] = []
if VWORLD_API_KEY:
api_name = 'VWorld API'
delay = 0.1
elif KAKAO_API_KEY:
api_name = '카카오 API'
delay = 0.1
else:
api_name = 'Nominatim (API 키 권장)'
logger.info(' (참고: VWorld API 키 발급받으시면 정확도와 속도가 크게 향상됩니다)')
delay = 1.0
logger.info(f'=== 지오코딩 시작 ({api_name}) ===')
update_progress('지오코딩', f'주소를 좌표로 변환 중 ({api_name})', total=total_addrs, current=0)
geo_results = {}
for idx, addr in enumerate(all_addrs, 1):
msg = f'[{idx}/{total_addrs}] {addr}'
logger.info(f' {msg}')
geo_results[addr] = geocode_with_fallback(addr)
if idx % 5 == 0 or idx == total_addrs:
update_progress('지오코딩', f'좌표 변환 중... {idx}/{total_addrs}', total=total_addrs, current=idx)
if idx < total_addrs:
time.sleep(delay)
logger.info(f'=== 지오코딩 완료: 성공 {GEO_STATS["success"]}, 실패 {GEO_STATS["failed"]} ===')
if GEO_STATS['failed_addrs']:
logger.warning(f'실패한 주소 목록 (최대 20개): {GEO_STATS["failed_addrs"]}')
# 근무자/근무처 데이터 구성
for sheet_name in wb.sheetnames:
ws = wb[sheet_name]
headers = [str(c.value or '').strip() for c in ws[1]]
hope_idx = find_header_idx(headers, ['희망사항', '희망', '배치희망'])
is_worker_sheet = hope_idx is not None
name_idx = find_header_idx(headers, ['이름', '성명', '명칭', '근무지명', '투표소명'])
addr_idx = find_header_idx(headers, ['주소', '위치', '소재지'])
if is_worker_sheet:
hope_idx = hope_idx if hope_idx is not None else 1
name_idx = name_idx if name_idx is not None else 0
dob_idx = find_header_idx(headers, ['생년월일', '생일']) or 3
phone_idx = find_header_idx(headers, ['연락처', '전화', '휴대폰']) or 4
if addr_idx is None:
addr_idx = 2
for row in ws.iter_rows(min_row=2, values_only=True):
if not any(row): continue
name = str(row[name_idx] or '').strip() if name_idx is not None else ''
if not name: continue
addr = str(row[addr_idx] or '').strip()
lat, lng = geo_results.get(addr, (YONGSAN_CENTER[0] + random.uniform(-0.005, 0.005),
YONGSAN_CENTER[1] + random.uniform(-0.005, 0.005)))
workers.append({
'id': f'w{len(workers)}', 'name': name,
'hope': str(row[hope_idx] or '').strip(),
'address': addr,
'dob': str(row[dob_idx] or '').strip(),
'phone': str(row[phone_idx] or '').strip(),
'lat': lat, 'lng': lng,
})
else:
name_idx = name_idx if name_idx is not None else 0
if addr_idx is None:
addr_idx = 1
for row in ws.iter_rows(min_row=2, values_only=True):
if not any(row): continue
name = str(row[name_idx] or '').strip() if name_idx is not None else ''
if not name: continue
addr = str(row[addr_idx] or '').strip()
lat, lng = geo_results.get(addr, (YONGSAN_CENTER[0] + random.uniform(-0.005, 0.005),
YONGSAN_CENTER[1] + random.uniform(-0.005, 0.005)))
workplaces.append({
'id': f'p{len(workplaces)}', 'name': name,
'address': addr, 'lat': lat, 'lng': lng,
})
logger.info(f'=== 처리 완료: 근무자 {len(workers)}명, 근무처 {len(workplaces)}개 ===')
return workers, workplaces
@app.route('/api/upload', methods=['POST'])
def upload():
tid = request.form.get('tab') or store['active_tab']
@@ -564,6 +779,69 @@ def upload():
return jsonify({'workers': workers, 'workplaces': workplaces, 'assignments': {}})
@app.route('/api/google-drive-upload', methods=['POST'])
def google_drive_upload():
"""구글 드라이브에서 엑셀 파일을 다운로드하여 처리합니다."""
body = request.get_json() or {}
file_id = body.get('fileId', '').strip()
tid = body.get('tab') or store['active_tab']
if not file_id:
return jsonify({'error': '파일 ID가 없습니다.'}), 400
t = store['tabs'].get(tid)
if not t:
return jsonify({'error': '탭 없음'}), 400
upload_progress.update({
'active': True,
'stage': '시작',
'total': 0,
'current': 0,
'message': '',
'logs': [],
})
update_progress('파일 로딩', f'구글 드라이브에서 파일을 다운로드 중입니다...')
# 구글 드라이브에서 파일 다운로드
file_content = download_from_google_drive(file_id)
if not file_content:
upload_progress['active'] = False
return jsonify({'error': '파일을 다운로드할 수 없습니다. 파일 ID를 확인하고 파일이 공유되어 있는지 확인하세요.'}), 400
try:
# 파일을 엑셀로 읽기
update_progress('파일 파싱', '엑셀 파일을 읽는 중입니다...')
wb = openpyxl.load_workbook(file_content, data_only=True)
logger.info(f'=== 구글 드라이브 엑셀 업로드 시작: {file_id} ===')
logger.info(f'시트 목록: {wb.sheetnames}')
# 엑셀 파일 처리
workers, workplaces = process_excel_file(wb, f'GoogleDrive_{file_id}.xlsx')
# 탭에 데이터 저장
t['workers'] = workers
t['workplaces'] = workplaces
t['assignments'] = {}
store['filename'] = f'GoogleDrive_{file_id}'
update_progress('완료', f'처리 완료: 근무자 {len(workers)}명, 근무처 {len(workplaces)}')
upload_progress['active'] = False
logger.info(f'=== 구글 드라이브 업로드 완료: 근무자 {len(workers)}명, 근무처 {len(workplaces)}개 ===')
return jsonify({'workers': workers, 'workplaces': workplaces, 'assignments': {}})
except Exception as e:
logger.error(f'엑셀 파일 처리 중 오류: {e}')
import traceback
traceback.print_exc()
upload_progress['active'] = False
return jsonify({'error': f'파일 처리 중 오류: {str(e)}'}), 400
@app.route('/api/assign', methods=['POST'])
def assign():
body = request.get_json() or {}
@@ -643,4 +921,7 @@ def index():
if __name__ == '__main__':
app.run(debug=True)
parser = argparse.ArgumentParser()
parser.add_argument('--port', type=int, default=5000, help='Port to run the server on')
args = parser.parse_args()
app.run(debug=True, port=args.port)