From e62c10e66f63a84b8b133e5ca00c0daab522a2c9 Mon Sep 17 00:00:00 2001 From: user01 Date: Sun, 24 May 2026 23:56:26 +0900 Subject: [PATCH] =?UTF-8?q?Feat:=20=EA=B7=BC=EB=AC=B4=EC=B2=98=20=EC=A3=BC?= =?UTF-8?q?=EC=86=8C=20=EC=A4=91=EB=B3=B5=20=EC=8B=9C=20=ED=91=9C=EC=8B=9C?= =?UTF-8?q?/=EB=82=B4=EB=B3=B4=EB=82=B4=EA=B8=B0=20=ED=86=B5=ED=95=A9,=20?= =?UTF-8?q?=EA=B7=BC=EB=AC=B4=EC=9E=90=20=ED=81=B4=EB=A6=AD=20=EC=8B=9C=20?= =?UTF-8?q?=EC=A7=80=EB=8F=84=20=EC=9D=B4=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app.py | 275 ++++++++++++++++--------------------------- sample_용산구.xlsx | Bin 6101 -> 6183 bytes templates/index.html | 189 ++++++++++++++++++++++------- 3 files changed, 247 insertions(+), 217 deletions(-) diff --git a/app.py b/app.py index f6e9f8c..dfc46dc 100644 --- a/app.py +++ b/app.py @@ -450,8 +450,12 @@ def download_from_google_drive(file_id, timeout=30): return None -def process_excel_file(wb, filename): - """엑셀 파일을 처리하고 근무자/근무처 데이터를 반환합니다.""" +def process_excel_file(wb, filename, force_type=None): + """엑셀 파일을 처리하고 근무자/근무처 데이터를 반환합니다. + force_type: 'worker' - 모든 시트를 근무자로 처리 + 'workplace' - 모든 시트를 근무처로 처리 + None - 자동 감지 + """ workers, workplaces, all_addrs = [], [], set() for sheet_name in wb.sheetnames: @@ -461,7 +465,12 @@ def process_excel_file(wb, filename): update_progress('엑셀 파싱', f'시트 처리 중: {sheet_name}') hope_idx = find_header_idx(headers, ['희망사항', '희망', '배치희망']) - is_worker_sheet = is_likely_worker_sheet(sheet_name, headers) + if force_type == 'worker': + is_worker_sheet = True + elif force_type == 'workplace': + is_worker_sheet = False + else: + is_worker_sheet = is_likely_worker_sheet(sheet_name, headers) name_idx = find_header_idx(headers, ['이름', '성명', '명칭', '근무지명', '투표소명']) addr_idx = find_header_idx(headers, ['주소', '위치', '소재지']) @@ -547,7 +556,12 @@ def process_excel_file(wb, filename): headers = [str(c.value or '').strip() for c in ws[1]] hope_idx = find_header_idx(headers, ['희망사항', '희망', '배치희망']) - is_worker_sheet = is_likely_worker_sheet(sheet_name, headers) + if force_type == 'worker': + is_worker_sheet = True + elif force_type == 'workplace': + is_worker_sheet = False + else: + is_worker_sheet = is_likely_worker_sheet(sheet_name, headers) name_idx = find_header_idx(headers, ['이름', '성명', '명칭', '근무지명', '투표소명']) addr_idx = find_header_idx(headers, ['주소', '위치', '소재지']) @@ -596,187 +610,81 @@ def process_excel_file(wb, filename): return workers, workplaces -@app.route('/api/upload', methods=['POST']) -def upload(): - tid = request.form.get('tab') or store['active_tab'] +def _handle_upload(tid, file, force_type): + """공통 업로드 처리 로직""" t = store['tabs'].get(tid) if not t: - return jsonify({'error': '탭 없음'}), 400 - - file = request.files.get('file') - if not file: - return jsonify({'error': '파일이 없습니다.'}), 400 + return None, jsonify({'error': '탭 없음'}), 400 upload_progress.update({ - 'active': True, - 'stage': '시작', - 'total': 0, - 'current': 0, - 'message': '', - 'logs': [], + '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'=== {"근무자" if force_type == "worker" else "근무처"} 엑셀 업로드 시작: {file.filename} ===') logger.info(f'시트 목록: {wb.sheetnames}') - workers, workplaces, all_addrs = [], [], set() + workers, workplaces = process_excel_file(wb, file.filename, force_type=force_type) - 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 = is_likely_worker_sheet(sheet_name, headers) - - 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"]}') - - 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 = is_likely_worker_sheet(sheet_name, headers) - - 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)}개 ===') - - t['workers'] = workers - t['workplaces'] = workplaces - t['assignments'] = {} store['filename'] = file.filename - update_progress('완료', f'처리 완료: 근무자 {len(workers)}명, 근무처 {len(workplaces)}개') + return (workers, workplaces), None, None + + +@app.route('/api/upload-workers', methods=['POST']) +def upload_workers(): + tid = request.form.get('tab') or store['active_tab'] + file = request.files.get('file') + if not file: + return jsonify({'error': '파일이 없습니다.'}), 400 + + result, err, status = _handle_upload(tid, file, force_type='worker') + if err: + return err, status + + workers, _ = result + t = store['tabs'].get(tid) + t['workers'] = workers + t['assignments'] = {} + + update_progress('완료', f'근무자 업로드 완료: {len(workers)}명') upload_progress['active'] = False - return jsonify({'workers': workers, 'workplaces': workplaces, 'assignments': {}}) + return jsonify({'workers': workers, 'workplaces': t['workplaces'], 'assignments': {}}) + + +@app.route('/api/upload-workplaces', methods=['POST']) +def upload_workplaces(): + tid = request.form.get('tab') or store['active_tab'] + file = request.files.get('file') + if not file: + return jsonify({'error': '파일이 없습니다.'}), 400 + + result, err, status = _handle_upload(tid, file, force_type='workplace') + if err: + return err, status + + _, new_workplaces = result + t = store['tabs'].get(tid) + + # 기존 근무처에 추가 (append) — ID 중복 방지 + existing = t['workplaces'] + offset = len(existing) + for wp in new_workplaces: + wp['id'] = f'p{offset}' + offset += 1 + t['workplaces'] = existing + new_workplaces + + total = len(t['workplaces']) + update_progress('완료', f'근무처 업로드 완료 (총 {total}개, 이번 {len(new_workplaces)}개)') + upload_progress['active'] = False + + return jsonify({'workers': t['workers'], 'workplaces': t['workplaces'], 'assignments': {}}) @app.route('/api/google-drive-upload', methods=['POST']) @@ -784,6 +692,7 @@ def google_drive_upload(): """구글 드라이브에서 엑셀 파일을 다운로드하여 처리합니다.""" body = request.get_json() or {} file_id = body.get('fileId', '').strip() + upload_type = body.get('type', 'auto') # 'worker', 'workplace', 'auto' tid = body.get('tab') or store['active_tab'] if not file_id: @@ -815,24 +724,31 @@ def google_drive_upload(): update_progress('파일 파싱', '엑셀 파일을 읽는 중입니다...') wb = openpyxl.load_workbook(file_content, data_only=True) - logger.info(f'=== 구글 드라이브 엑셀 업로드 시작: {file_id} ===') + force_type = upload_type if upload_type != 'auto' else None + type_label = {'worker': '근무자', 'workplace': '근무처', 'auto': '자동감지'}.get(upload_type, '자동감지') + logger.info(f'=== 구글 드라이브 엑셀 업로드 시작: {file_id} (유형: {type_label}) ===') logger.info(f'시트 목록: {wb.sheetnames}') # 엑셀 파일 처리 - workers, workplaces = process_excel_file(wb, f'GoogleDrive_{file_id}.xlsx') + workers, workplaces = process_excel_file(wb, f'GoogleDrive_{file_id}.xlsx', force_type=force_type) # 탭에 데이터 저장 - t['workers'] = workers - t['workplaces'] = workplaces + if upload_type == 'worker': + t['workers'] = workers + elif upload_type == 'workplace': + t['workplaces'] = workplaces + else: + t['workers'] = workers + t['workplaces'] = workplaces t['assignments'] = {} store['filename'] = f'GoogleDrive_{file_id}' - update_progress('완료', f'처리 완료: 근무자 {len(workers)}명, 근무처 {len(workplaces)}개') + update_progress('완료', f'처리 완료: 근무자 {len(t["workers"])}명, 근무처 {len(t["workplaces"])}개') upload_progress['active'] = False - logger.info(f'=== 구글 드라이브 업로드 완료: 근무자 {len(workers)}명, 근무처 {len(workplaces)}개 ===') + logger.info(f'=== 구글 드라이브 업로드 완료: 근무자 {len(t["workers"])}명, 근무처 {len(t["workplaces"])}개 ===') - return jsonify({'workers': workers, 'workplaces': workplaces, 'assignments': {}}) + return jsonify({'workers': t['workers'], 'workplaces': t['workplaces'], 'assignments': {}}) except Exception as e: logger.error(f'엑셀 파일 처리 중 오류: {e}') @@ -899,9 +815,18 @@ def export(): ws2 = wb.create_sheet('근무지별 배치') ws2.append(['근무지명', '주소', '배치인원', '배치된근무자']) + # 같은 주소의 근무처는 통합하여 표시 + addr_groups = {} for wp in t['workplaces']: - aw = [w['name'] for w in t['workers'] if t['assignments'].get(w['id']) == wp['id']] - ws2.append([wp['name'], wp['address'], len(aw), ', '.join(aw)]) + key = wp['address'] + if key not in addr_groups: + addr_groups[key] = {'names': [], 'workplace_ids': []} + addr_groups[key]['names'].append(wp['name']) + addr_groups[key]['workplace_ids'].append(wp['id']) + for addr, g in addr_groups.items(): + combined_name = ', '.join(g['names']) + aw = [w['name'] for w in t['workers'] if t['assignments'].get(w['id']) in g['workplace_ids']] + ws2.append([combined_name, addr, len(aw), ', '.join(aw)]) buf = io.BytesIO() wb.save(buf) diff --git a/sample_용산구.xlsx b/sample_용산구.xlsx index 85f4be67c115bbdd727c8bdec81d0885014f7964..530e981c6e06a337775708d7d06f0baae7573504 100644 GIT binary patch delta 4069 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 X{c4v#6Y&@D9tcr(orfTf_R94iedRq& delta 3932 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 diff --git a/templates/index.html b/templates/index.html index eab006d..0ab4507 100644 --- a/templates/index.html +++ b/templates/index.html @@ -15,9 +15,13 @@ 서울특별시 용산구 근무 배치 시스템
-
- - + + + +
+
+ +
@@ -81,6 +85,11 @@
`; + card.addEventListener('click', () => { + const pos = getWorkerPos(w); + map.setView(pos, 16); + if (markers.workers[w.id]) markers.workers[w.id].openPopup(); + }); card.addEventListener('dragstart', e => { e.dataTransfer.setData('text/plain', w.id); e.dataTransfer.effectAllowed = 'move'; card.classList.add('dragging'); }); card.addEventListener('dragend', () => card.classList.remove('dragging')); list.appendChild(card); @@ -693,23 +792,27 @@ function renderWorkerList() { } } -function updateWorkplaceMarker(wpId) { +function updateWorkplaceByAddress(addr) { const d = currentData(); - const marker = markers.workplaces[wpId]; - const wp = d.workplaces.find(p => p.id === wpId); - if (!marker || !wp) return; - const cnt = getWorkplaceAssigneeCount(wpId); + const marker = markers.workplaces[addr]; + if (!marker) return; + const group = getWorkplaceGroups().find(g => g.address === addr); + if (!group) return; + const cnt = getGroupAssigneeCount(group.ids); + const namesHtml = escHtml(group.names.join(', ')); marker.setIcon(makeWorkplaceIcon(cnt)); marker.setPopupContent(` `); } function updateWorkplacePopup(wpId) { - updateWorkplaceMarker(wpId); + const addr = getWorkplaceAddress(wpId); + if (addr) updateWorkplaceByAddress(addr); } let currentSidebarTab = 'workers'; @@ -725,9 +828,11 @@ function renderWorkplaceList() { const d = currentData(); const list = document.getElementById('workplaceList'); list.innerHTML = ''; - for (const wp of d.workplaces) { - const cnt = getWorkplaceAssigneeCount(wp.id); - const assigned = d.workers.filter(w => d.assignments[w.id] === wp.id); + const groups = getWorkplaceGroups(); + for (const g of groups) { + const cnt = getGroupAssigneeCount(g.ids); + const assigned = d.workers.filter(w => g.ids.includes(d.assignments[w.id])); + const namesHtml = escHtml(g.names.join(', ')); const card = document.createElement('div'); card.className = 'workplace-card'; card.innerHTML = ` @@ -735,20 +840,20 @@ function renderWorkplaceList() {
-
${escHtml(wp.name)}
-
${escHtml(wp.address)}
+
${namesHtml}
+
${escHtml(g.address)}
${cnt}명 배치 ${assigned.length > 0 ? `${assigned.slice(0, 4).map(w => escHtml(w.name)).join(', ')}${assigned.length > 4 ? ' 외 ' + (assigned.length - 4) + '명' : ''}` : ''}
`; card.addEventListener('click', () => { - map.setView([wp.lat, wp.lng], 16); - if (markers.workplaces[wp.id]) markers.workplaces[wp.id].openPopup(); + map.setView([g.lat, g.lng], 16); + if (markers.workplaces[g.address]) markers.workplaces[g.address].openPopup(); }); list.appendChild(card); } - if (d.workplaces.length === 0) { + if (groups.length === 0) { list.innerHTML = '
엑셀 파일을 업로드하세요
'; } } @@ -758,7 +863,7 @@ function updateBadges() { const assigned = Object.keys(d.assignments).length; const total = d.workers.length; document.getElementById('workerCount').textContent = `${assigned}/${total}`; - document.getElementById('workplaceCount').textContent = d.workplaces.length; + document.getElementById('workplaceCount').textContent = getWorkplaceGroups().length; const badge = document.getElementById('statusBadge'); badge.textContent = assigned > 0 ? `배치 ${assigned}/${total}` : ''; badge.className = `status-badge ${!total ? '' : assigned === total ? 'complete' : assigned > 0 ? 'partial' : ''}`;