From 43f23c52fccdf29de004001ec14b723e1b951502 Mon Sep 17 00:00:00 2001 From: user01 Date: Wed, 13 May 2026 12:45:22 +0900 Subject: [PATCH] =?UTF-8?q?Feat:=20=EA=B7=BC=EB=AC=B4=EC=B2=98=20=EB=AA=A9?= =?UTF-8?q?=EB=A1=9D=20=EB=B0=8F=20=EB=B0=B0=EC=A7=80=20=ED=91=9C=EC=8B=9C?= =?UTF-8?q?,=20=EC=97=91=EC=85=80=20=EC=97=85=EB=A1=9C=EB=93=9C=20?= =?UTF-8?q?=EC=A7=84=ED=96=89=20=ED=8C=9D=EC=97=85=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 근무처 마커에 할당 인원 배지 표시 - 근무처 목록 패널 추가 (탭 전환 가능) - 탭 생성 후 F5 없이 즉시 표시되도록 버그 수정 - VWorld/카카오 지오코딩 API 지원 추가 (type 파라미터 포함) - 엑셀 업로드 중 진행 상황 팝업 모달 추가 - 근무처 목록 근무자 표시 3명 → 4명으로 변경 - 지오코딩 진행 상황 폴링 API 추가 --- app.py | 443 ++++++++++++++++++++++++++++++++++++++----- static/style.css | 95 ++++++++++ templates/index.html | 201 ++++++++++++++++++-- 3 files changed, 680 insertions(+), 59 deletions(-) diff --git a/app.py b/app.py index 0374782..f460b17 100644 --- a/app.py +++ b/app.py @@ -1,5 +1,9 @@ import os, io, random, requests, openpyxl, concurrent.futures from flask import Flask, render_template, request, jsonify, send_file +import logging + +logging.basicConfig(level=logging.DEBUG, format='%(asctime)s %(levelname)s: %(message)s') +logger = logging.getLogger(__name__) app = Flask(__name__) app.config['UPLOAD_FOLDER'] = 'uploads' @@ -15,6 +19,15 @@ store = { 'filename': None, } +upload_progress = { + 'active': False, + 'stage': '', + 'total': 0, + 'current': 0, + 'message': '', + 'logs': [], +} + def tab_data(tab_id): t = store['tabs'].get(tab_id) @@ -24,23 +37,217 @@ def tab_data(tab_id): GEO_CACHE = {} +GEO_STATS = {'success': 0, 'failed': 0, 'failed_addrs': []} -def geocode_with_fallback(address): - if address in GEO_CACHE: - return GEO_CACHE[address] +import time, re + +KAKAO_API_KEY = os.environ.get('KAKAO_API_KEY', '') +VWORLD_API_KEY = os.environ.get('VWORLD_API_KEY', 'E1FD0345-8021-3C9C-A397-CE0D88736D78') + +def geocode_kakao(address, timeout=10): + if not KAKAO_API_KEY: + return None try: - url = 'https://nominatim.openstreetmap.org/search' - params = {'q': address.strip(), 'format': 'json', 'limit': 1, 'countrycodes': 'kr'} - headers = {'User-Agent': 'MapinDrag/1.0'} - resp = requests.get(url, params=params, headers=headers, timeout=5) + url = 'https://dapi.kakao.com/v2/local/search/address.json' + headers = {'Authorization': f'KakaoAK {KAKAO_API_KEY}'} + params = {'query': address.strip(), 'size': 1} + resp = requests.get(url, params=params, headers=headers, timeout=timeout) if resp.ok: data = resp.json() - if data: - lat, lng = float(data[0]['lat']), float(data[0]['lon']) - GEO_CACHE[address] = (lat, lng) - return lat, lng + if data.get('documents'): + doc = data['documents'][0] + return float(doc['y']), float(doc['x']) except Exception: pass + return None + + +def looks_like_road_address(addr): + road_patterns = ['로', '길', '대로', '번길'] + for p in road_patterns: + if p in addr and any(c.isdigit() for c in addr[addr.find(p):]): + return True + return False + + +def geocode_vworld(address, timeout=15): + if not VWORLD_API_KEY: + return None + + addr = address.strip() + variants = [ + addr, + f'서울특별시 용산구 {addr}' if '용산구' not in addr and '서울' not in addr else None, + f'용산구 {addr}' if '용산구' not in addr else None, + ] + variants = [v for v in variants if v] + + if looks_like_road_address(addr): + types = ['ROAD', 'PARCEL'] + else: + types = ['PARCEL', 'ROAD'] + + for v in variants: + for addr_type in types: + try: + url = 'https://api.vworld.kr/req/address' + params = { + 'service': 'address', + 'request': 'getcoord', + 'version': '2.0', + 'crs': 'EPSG:4326', + 'address': v, + 'format': 'json', + 'type': addr_type, + 'key': VWORLD_API_KEY, + } + logger.debug(f' [VWorld 요청] type={addr_type}, address={v}') + resp = requests.get(url, params=params, timeout=timeout) + + if not resp.ok: + logger.warning(f' [VWorld HTTP 오류] 상태={resp.status_code}, 응답={resp.text[:200]}') + continue + + data = resp.json() + logger.debug(f' [VWorld 응답] {data}') + + response = data.get('response', {}) + status = response.get('status') + + if status == 'OK' or status == 'SUCCESS': + result = response.get('result') + if result: + point = result.get('point') + if point and point.get('x') and point.get('y'): + lat = float(point['y']) + lng = float(point['x']) + logger.info(f' [VWorld 성공] {addr} (type={addr_type}) -> ({lat}, {lng})') + return lat, lng + else: + err_msg = response.get('error', {}).get('message', str(data)) + logger.debug(f' [VWorld {addr_type} 실패] {v}: status={status}, 오류={err_msg}') + + except Exception as e: + logger.warning(f' [VWorld 예외] {v}: {e}') + import traceback + traceback.print_exc() + + return None + + +def normalize_korean_address(addr): + patterns = [ + (r'서울시', '서울특별시'), + (r'경기도', '경기'), + (r'(\d+)동', r'\1동'), + ] + for old, new in patterns: + addr = re.sub(old, new, addr) + return addr + + +def geocode_nominatim(address, timeout=10): + addr = normalize_korean_address(address.strip()) + variants = [ + addr, + f'{addr}, 서울특별시, 대한민국', + f'{addr}, 대한민국', + ] + + for v in variants: + try: + url = 'https://nominatim.openstreetmap.org/search' + params = { + 'q': v, + 'format': 'json', + 'limit': 3, + 'countrycodes': 'kr', + 'accept-language': 'ko,en', + } + headers = {'User-Agent': 'MapinDrag/1.0 (non-commercial research)'} + resp = requests.get(url, params=params, headers=headers, timeout=timeout) + if resp.ok: + results = resp.json() + if results: + for r in results: + lat = float(r['lat']) + lng = float(r['lon']) + if 37 < lat < 38 and 126.9 < lng < 127.1: + return lat, lng + except Exception: + pass + return None + + +def geocode_with_fallback(address): + address = str(address or '').strip() + + if not address: + jitter = random.uniform(-0.005, 0.005) + return (YONGSAN_CENTER[0] + jitter, YONGSAN_CENTER[1] + jitter) + + if address in GEO_CACHE: + return GEO_CACHE[address] + + addr_clean = address + prefixes = ['서울특별시 ', '서울시 ', '서울 ', '용산구 '] + for p in prefixes: + if addr_clean.startswith(p): + addr_clean = addr_clean[len(p):] + break + + variants = [] + + if '용산구' not in address and '서울' not in address: + variants.append(f'서울특별시 용산구 ' + addr_clean) + variants.append(f'용산구 ' + addr_clean) + variants.append(address) + + if '서울' in address and '용산구' not in address: + new_addr = address.replace('서울특별시', '서울특별시 용산구').replace('서울시', '서울시 용산구') + variants.append(new_addr) + + unique_vars = [] + seen = set() + for v in variants: + if v not in seen: + seen.add(v) + unique_vars.append(v) + + result = None + used_api = None + + api_order = [] + if KAKAO_API_KEY: + api_order.append(('카카오', geocode_kakao)) + if VWORLD_API_KEY: + api_order.append(('VWorld', geocode_vworld)) + api_order.append(('Nominatim', geocode_nominatim)) + + for api_name, geo_func in api_order: + if result: + break + for v in unique_vars: + try: + result = geo_func(v) + if result: + used_api = api_name + break + except Exception as e: + logger.debug(f'{api_name} 시도 실패 ({v}): {e}') + continue + + if result: + GEO_STATS['success'] += 1 + logger.info(f' [지오코딩 성공 ({used_api})] {address} -> {result}') + GEO_CACHE[address] = result + return result + + GEO_STATS['failed'] += 1 + if len(GEO_STATS['failed_addrs']) < 20: + GEO_STATS['failed_addrs'].append(address) + logger.warning(f' [지오코딩 실패 - 폴백 사용] {address}') + jitter = random.uniform(-0.005, 0.005) result = (YONGSAN_CENTER[0] + jitter, YONGSAN_CENTER[1] + jitter) GEO_CACHE[address] = result @@ -62,6 +269,28 @@ def list_tabs(): return jsonify({'tabs': tabs, 'activeTab': store['active_tab']}) +@app.route('/api/upload-progress') +def get_upload_progress(): + return jsonify({ + 'active': upload_progress['active'], + 'stage': upload_progress['stage'], + 'total': upload_progress['total'], + 'current': upload_progress['current'], + 'message': upload_progress['message'], + 'logs': upload_progress['logs'][-15:], + }) + + +def update_progress(stage, message='', total=0, current=0): + upload_progress['stage'] = stage + upload_progress['message'] = message + upload_progress['total'] = total + upload_progress['current'] = current + if message: + upload_progress['logs'].append(message) + logger.info(f'[진행: {stage}] {message}') + + @app.route('/api/tabs/add', methods=['POST']) def add_tab(): body = request.get_json() or {} @@ -129,6 +358,29 @@ def get_data(): }) +def find_header_idx(headers, keywords): + for kw in keywords: + for i, h in enumerate(headers): + if kw in h: + return i + return None + + +def is_likely_worker_sheet(sheet_name, headers): + sheet_lower = sheet_name.lower() + if any(kw in sheet_lower for kw in ['근무자', '참관인', '인력', '선거인', 'worker']): + return True + if any(kw in sheet_lower for kw in ['근무처', '투표소', '장소', '기관', 'workplace', 'poll']): + return False + + hope_idx = find_header_idx(headers, ['희망사항', '희망', '배치희망']) + dob_idx = find_header_idx(headers, ['생년월일', '생일', '주민번호']) + phone_idx = find_header_idx(headers, ['연락처', '전화', '휴대폰', '핸드폰']) + + worker_indicators = sum(1 for x in [hope_idx, dob_idx, phone_idx] if x is not None) + return worker_indicators >= 2 + + @app.route('/api/upload', methods=['POST']) def upload(): tid = request.form.get('tab') or store['active_tab'] @@ -140,70 +392,175 @@ def upload(): if not file: return jsonify({'error': '파일이 없습니다.'}), 400 + upload_progress.update({ + 'active': True, + 'stage': '시작', + 'total': 0, + 'current': 0, + 'message': '', + 'logs': [], + }) + + update_progress('파일 로딩', f'파일을 읽는 중: {file.filename}') + path = os.path.join(app.config['UPLOAD_FOLDER'], file.filename) file.save(path) wb = openpyxl.load_workbook(path, data_only=True) + logger.info(f'=== 엑셀 업로드 시작: {file.filename} ===') + logger.info(f'시트 목록: {wb.sheetnames}') + 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]] - if '희망사항' in headers: - idx = {h: i for i, h in enumerate(headers)} - for row in ws.iter_rows(min_row=2, values_only=True): - if not any(row): continue - name = str(row[idx.get('이름', 0)] or '').strip() - if name: all_addrs.add(str(row[idx.get('주소', 2)] or '').strip()) - if '희망사항' not in headers: - idx = {h: i for i, h in enumerate(headers)} - for row in ws.iter_rows(min_row=2, values_only=True): - if not any(row): continue - name = str(row[idx.get('이름', 0)] or '').strip() - if name: all_addrs.add(str(row[idx.get('주소', 1)] or '').strip()) - - with concurrent.futures.ThreadPoolExecutor(max_workers=5) as exc: - geo_results = {a: exc.submit(geocode_with_fallback, a) for a in all_addrs} - geo_results = {a: f.result() for a, f in geo_results.items()} 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}') - if '희망사항' in headers: - idx = {h: i for i, h in enumerate(headers)} + 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[idx.get('이름', 0)] or '').strip() + name = str(row[name_idx] or '').strip() if name_idx is not None else '' if not name: continue - addr = str(row[idx.get('주소', 2)] or '').strip() - lat, lng = geo_results[addr] + 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"]}') + + update_progress('완료', f'처리 완료: 근무자 {len(workers or [])}명, 근무처 {len(workplaces or [])}개') + upload_progress['active'] = False + + 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[idx.get('희망사항', 1)] or '').strip(), + 'hope': str(row[hope_idx] or '').strip(), 'address': addr, - 'dob': str(row[idx.get('생년월일', 3)] or '').strip(), - 'phone': str(row[idx.get('연락처', 4)] or '').strip(), + '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 - if '희망사항' not in headers: - idx = {h: i for i, h in enumerate(headers)} for row in ws.iter_rows(min_row=2, values_only=True): if not any(row): continue - name = str(row[idx.get('이름', 0)] or '').strip() + name = str(row[name_idx] or '').strip() if name_idx is not None else '' if not name: continue - addr = str(row[idx.get('주소', 1)] or '').strip() - lat, lng = geo_results[addr] + 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)}개 ===') + t['workers'] = workers t['workplaces'] = workplaces t['assignments'] = {} store['filename'] = file.filename + update_progress('완료', f'처리 완료: 근무자 {len(workers)}명, 근무처 {len(workplaces)}개') + upload_progress['active'] = False + return jsonify({'workers': workers, 'workplaces': workplaces, 'assignments': {}}) diff --git a/static/style.css b/static/style.css index 31008f0..3be2180 100644 --- a/static/style.css +++ b/static/style.css @@ -133,6 +133,47 @@ body { font-family: 'Segoe UI', 'Malgun Gothic', sans-serif; height: 100vh; over background: #1a73e8; color: #fff; font-size: 18px; border-radius: 8px; width: 42px; height: 42px; display: flex; align-items: center; justify-content: center; } +.marker-badge { + position: absolute; top: -8px; right: -8px; min-width: 20px; height: 20px; + background: #ef4444; color: #fff; border-radius: 10px; font-size: 11px; font-weight: 700; + display: flex; align-items: center; justify-content: center; padding: 0 4px; + border: 2px solid #fff; box-shadow: 0 1px 4px rgba(0,0,0,0.25); +} +.sidebar-tabs { + display: flex; background: #f0f2f5; border-bottom: 1px solid #e0e0e0; +} +.sidebar-tab { + flex: 1; padding: 10px 8px; text-align: center; cursor: pointer; font-size: 13px; + font-weight: 500; color: #6b7280; transition: all 0.2s; +} +.sidebar-tab:hover { color: #1a73e8; } +.sidebar-tab.active { + color: #1a73e8; background: #fff; border-bottom: 2px solid #1a73e8; +} +.sidebar-tab .count { margin-left: 4px; font-size: 11px; padding: 1px 6px; border-radius: 8px; background: #e5e7eb; } +.sidebar-tab.active .count { background: #e8f0fe; } +.workplace-list { + flex: 1; overflow-y: auto; padding: 8px; display: none; +} +.workplace-list.active { display: block; } +.worker-list.hidden { display: none; } +.workplace-card { + display: flex; gap: 10px; padding: 10px 12px; margin-bottom: 6px; + background: #f8f9fa; border: 1px solid #e5e7eb; border-radius: 8px; + transition: all 0.2s; cursor: pointer; +} +.workplace-card:hover { border-color: #1a73e8; box-shadow: 0 2px 8px rgba(26,115,232,0.12); } +.workplace-card-avatar { + width: 40px; height: 40px; border-radius: 8px; background: #dbeafe; + display: flex; align-items: center; justify-content: center; color: #1a73e8; + flex-shrink: 0; font-size: 16px; +} +.workplace-card-info { flex: 1; min-width: 0; } +.workplace-card-name { font-weight: 600; font-size: 14px; color: #111; } +.workplace-card-address { font-size: 11px; color: #9ca3af; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; margin-top: 2px; } +.workplace-card-assignment { + margin-top: 4px; font-size: 12px; color: #1a73e8; font-weight: 500; +} .popup-content { font-size: 13px; line-height: 1.5; min-width: 150px; } .wp-assignees { margin-top: 6px; padding-top: 6px; border-top: 1px solid #e5e7eb; font-size: 12px; color: #6b7280; } @@ -148,3 +189,57 @@ body { font-family: 'Segoe UI', 'Malgun Gothic', sans-serif; height: 100vh; over .toast.error { background: #ef4444; } .toast.info { background: #1a73e8; } .toast.warning { background: #f59e0b; color: #333; } + +.modal-overlay { + position: fixed; top: 0; left: 0; right: 0; bottom: 0; + background: rgba(0,0,0,0.5); display: flex; align-items: center; justify-content: center; + z-index: 9999; +} +.modal-overlay.hidden { display: none; } + +.modal-content { + background: #fff; border-radius: 12px; box-shadow: 0 10px 40px rgba(0,0,0,0.2); + min-width: 400px; max-width: 500px; +} + +.modal-header { + padding: 16px 20px; border-bottom: 1px solid #e5e7eb; +} +.modal-header h3 { + font-size: 16px; color: #1f2937; margin: 0; + display: flex; align-items: center; gap: 8px; +} + +.modal-body { + padding: 24px 20px; text-align: center; +} + +.loading-spinner { + font-size: 48px; color: #1a73e8; margin-bottom: 16px; +} + +.loading-message { + font-size: 14px; color: #374151; margin-bottom: 16px; font-weight: 500; +} + +.loading-progress { margin-bottom: 12px; } +.loading-progress.hidden { display: none; } + +.progress-bar { + width: 100%; height: 8px; background: #e5e7eb; border-radius: 4px; overflow: hidden; +} + +.progress-fill { + height: 100%; background: #1a73e8; transition: width 0.2s; +} + +.progress-text { + font-size: 12px; color: #6b7280; margin-top: 4px; +} + +.loading-log { + max-height: 150px; overflow-y: auto; text-align: left; + background: #f9fafb; border-radius: 6px; padding: 8px 12px; + font-size: 11px; color: #6b7280; line-height: 1.5; +} +.loading-log:empty { display: none; } diff --git a/templates/index.html b/templates/index.html index a261d61..ec096e4 100644 --- a/templates/index.html +++ b/templates/index.html @@ -32,11 +32,16 @@