- add_tab: 새 탭을 서버에서 즉시 활성화하도록 수정 - addTab(): loadTabs() 대신 직접 render() 호출 (탭 전환 오버헤드 제거) - 지오코딩: concurrent.futures.ThreadPoolExecutor로 병렬 처리 (5 worker) - 지오코딩: 단일 시도 + 캐시로 중복 요청 제거, 30초→4초 단축 - upload: 먼저 모든 주소 수집 후 병렬 지오코딩, 이후 데이터 구성
290 lines
9.8 KiB
Python
290 lines
9.8 KiB
Python
import os, io, random, requests, openpyxl, concurrent.futures
|
|
from flask import Flask, render_template, request, jsonify, send_file
|
|
|
|
app = Flask(__name__)
|
|
app.config['UPLOAD_FOLDER'] = 'uploads'
|
|
app.config['MAX_CONTENT_LENGTH'] = 16 * 1024 * 1024
|
|
os.makedirs(app.config['UPLOAD_FOLDER'], exist_ok=True)
|
|
|
|
YONGSAN_CENTER = (37.5326, 126.9906)
|
|
|
|
store = {
|
|
'tabs': {},
|
|
'active_tab': None,
|
|
'next_id': 0,
|
|
'filename': None,
|
|
}
|
|
|
|
|
|
def tab_data(tab_id):
|
|
t = store['tabs'].get(tab_id)
|
|
if not t:
|
|
return None, 404
|
|
return t, None
|
|
|
|
|
|
GEO_CACHE = {}
|
|
|
|
def geocode_with_fallback(address):
|
|
if address in GEO_CACHE:
|
|
return GEO_CACHE[address]
|
|
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)
|
|
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
|
|
except Exception:
|
|
pass
|
|
jitter = random.uniform(-0.005, 0.005)
|
|
result = (YONGSAN_CENTER[0] + jitter, YONGSAN_CENTER[1] + jitter)
|
|
GEO_CACHE[address] = result
|
|
return result
|
|
|
|
|
|
def empty_tab(name):
|
|
return {'name': name, 'workers': [], 'workplaces': [], 'assignments': {}}
|
|
|
|
|
|
# ── Tab management ──
|
|
|
|
@app.route('/api/tabs')
|
|
def list_tabs():
|
|
tabs = [{'id': tid, 'name': t['name'],
|
|
'workerCount': len(t['workers']),
|
|
'assignCount': len(t['assignments'])}
|
|
for tid, t in store['tabs'].items()]
|
|
return jsonify({'tabs': tabs, 'activeTab': store['active_tab']})
|
|
|
|
|
|
@app.route('/api/tabs/add', methods=['POST'])
|
|
def add_tab():
|
|
body = request.get_json() or {}
|
|
name = (body.get('name') or '').strip() or f'탭 {store["next_id"] + 1}'
|
|
tid = f'tab_{store["next_id"]}'
|
|
store['next_id'] += 1
|
|
store['tabs'][tid] = empty_tab(name)
|
|
store['active_tab'] = tid
|
|
return jsonify({'id': tid, 'name': name, 'activeTab': tid})
|
|
|
|
|
|
@app.route('/api/tabs/rename', methods=['POST'])
|
|
def rename_tab():
|
|
body = request.get_json() or {}
|
|
tid = body.get('id')
|
|
name = (body.get('name') or '').strip()
|
|
if not tid or not name or tid not in store['tabs']:
|
|
return jsonify({'error': '잘못된 요청'}), 400
|
|
store['tabs'][tid]['name'] = name
|
|
return jsonify({'id': tid, 'name': name})
|
|
|
|
|
|
@app.route('/api/tabs/delete', methods=['POST'])
|
|
def delete_tab():
|
|
body = request.get_json() or {}
|
|
tid = body.get('id')
|
|
if not tid or tid not in store['tabs']:
|
|
return jsonify({'error': '잘못된 요청'}), 400
|
|
del store['tabs'][tid]
|
|
if store['active_tab'] == tid:
|
|
keys = list(store['tabs'].keys())
|
|
store['active_tab'] = keys[0] if keys else None
|
|
return jsonify({'deleted': tid})
|
|
|
|
|
|
@app.route('/api/tabs/activate', methods=['POST'])
|
|
def activate_tab():
|
|
body = request.get_json() or {}
|
|
tid = body.get('id')
|
|
if not tid or tid not in store['tabs']:
|
|
return jsonify({'error': '탭을 찾을 수 없음'}), 400
|
|
store['active_tab'] = tid
|
|
return jsonify({'activeTab': tid})
|
|
|
|
|
|
# ── Data ──
|
|
|
|
def get_active():
|
|
t = store['tabs'].get(store['active_tab'])
|
|
if not t:
|
|
return None, jsonify({'error': '탭 없음'}), 400
|
|
return t, None, None
|
|
|
|
|
|
@app.route('/api/data')
|
|
def get_data():
|
|
tid = request.args.get('tab') or store['active_tab']
|
|
t = store['tabs'].get(tid)
|
|
if not t:
|
|
return jsonify({'workers': [], 'workplaces': [], 'assignments': {}})
|
|
return jsonify({
|
|
'workers': t['workers'],
|
|
'workplaces': t['workplaces'],
|
|
'assignments': t['assignments'],
|
|
})
|
|
|
|
|
|
@app.route('/api/upload', methods=['POST'])
|
|
def upload():
|
|
tid = request.form.get('tab') or store['active_tab']
|
|
t = store['tabs'].get(tid)
|
|
if not t:
|
|
return jsonify({'error': '탭 없음'}), 400
|
|
|
|
file = request.files.get('file')
|
|
if not file:
|
|
return jsonify({'error': '파일이 없습니다.'}), 400
|
|
|
|
path = os.path.join(app.config['UPLOAD_FOLDER'], file.filename)
|
|
file.save(path)
|
|
wb = openpyxl.load_workbook(path, data_only=True)
|
|
|
|
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]]
|
|
|
|
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 not name: continue
|
|
addr = str(row[idx.get('주소', 2)] or '').strip()
|
|
lat, lng = geo_results[addr]
|
|
workers.append({
|
|
'id': f'w{len(workers)}', 'name': name,
|
|
'hope': str(row[idx.get('희망사항', 1)] or '').strip(),
|
|
'address': addr,
|
|
'dob': str(row[idx.get('생년월일', 3)] or '').strip(),
|
|
'phone': str(row[idx.get('연락처', 4)] or '').strip(),
|
|
'lat': lat, 'lng': lng,
|
|
})
|
|
|
|
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 not name: continue
|
|
addr = str(row[idx.get('주소', 1)] or '').strip()
|
|
lat, lng = geo_results[addr]
|
|
workplaces.append({
|
|
'id': f'p{len(workplaces)}', 'name': name,
|
|
'address': addr, 'lat': lat, 'lng': lng,
|
|
})
|
|
|
|
t['workers'] = workers
|
|
t['workplaces'] = workplaces
|
|
t['assignments'] = {}
|
|
store['filename'] = file.filename
|
|
|
|
return jsonify({'workers': workers, 'workplaces': workplaces, 'assignments': {}})
|
|
|
|
|
|
@app.route('/api/assign', methods=['POST'])
|
|
def assign():
|
|
body = request.get_json() or {}
|
|
tid = body.get('tab') or store['active_tab']
|
|
t = store['tabs'].get(tid)
|
|
if not t:
|
|
return jsonify({'error': '탭 없음'}), 400
|
|
worker_id, workplace_id = body.get('workerId'), body.get('workplaceId')
|
|
if not worker_id or not workplace_id:
|
|
return jsonify({'error': '잘못된 요청'}), 400
|
|
t['assignments'][worker_id] = workplace_id
|
|
return jsonify({'assignments': t['assignments']})
|
|
|
|
|
|
@app.route('/api/unassign', methods=['POST'])
|
|
def unassign():
|
|
body = request.get_json() or {}
|
|
tid = body.get('tab') or store['active_tab']
|
|
t = store['tabs'].get(tid)
|
|
if not t:
|
|
return jsonify({'error': '탭 없음'}), 400
|
|
worker_id = body.get('workerId')
|
|
t['assignments'].pop(worker_id, None)
|
|
return jsonify({'assignments': t['assignments']})
|
|
|
|
|
|
@app.route('/api/reset', methods=['POST'])
|
|
def reset():
|
|
body = request.get_json() or {}
|
|
tid = body.get('tab') or store['active_tab']
|
|
t = store['tabs'].get(tid)
|
|
if t:
|
|
t['assignments'] = {}
|
|
return jsonify({'assignments': t['assignments'] if t else {}})
|
|
|
|
|
|
@app.route('/api/export')
|
|
def export():
|
|
tid = request.args.get('tab') or store['active_tab']
|
|
t = store['tabs'].get(tid)
|
|
if not t:
|
|
return jsonify({'error': '탭 없음'}), 400
|
|
|
|
wb = openpyxl.Workbook()
|
|
|
|
ws1 = wb.active
|
|
ws1.title = '배치 현황'
|
|
ws1.append(['이름', '연락처', '생년월일', '희망사항', '주소', '배치근무지', '근무지주소'])
|
|
wp_lookup = {wp['id']: wp for wp in t['workplaces']}
|
|
for w in t['workers']:
|
|
a_id = t['assignments'].get(w['id'])
|
|
wn = wp_lookup[a_id]['name'] if a_id and a_id in wp_lookup else ''
|
|
wa = wp_lookup[a_id]['address'] if a_id and a_id in wp_lookup else ''
|
|
ws1.append([w['name'], w['phone'], w['dob'], w['hope'], w['address'], wn, wa])
|
|
|
|
ws2 = wb.create_sheet('근무지별 배치')
|
|
ws2.append(['근무지명', '주소', '배치인원', '배치된근무자'])
|
|
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)])
|
|
|
|
buf = io.BytesIO()
|
|
wb.save(buf)
|
|
buf.seek(0)
|
|
|
|
tab_name = t['name']
|
|
fname = store.get('filename', 'export.xlsx')
|
|
base, ext = os.path.splitext(fname)
|
|
dn = f'{base}_{tab_name}_배치결과{ext}'
|
|
return send_file(buf, as_attachment=True, download_name=dn,
|
|
mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet')
|
|
|
|
|
|
@app.route('/')
|
|
def index():
|
|
return render_template('index.html')
|
|
|
|
|
|
if __name__ == '__main__':
|
|
app.run(debug=True)
|