diff --git a/matching.db b/matching.db index c0b08cb..ff7ac70 100644 Binary files a/matching.db and b/matching.db differ diff --git a/mysql_preview_server.py b/mysql_preview_server.py index a9079f3..e7f059c 100644 --- a/mysql_preview_server.py +++ b/mysql_preview_server.py @@ -11,9 +11,10 @@ import requests import html as html_lib import threading import uuid +import subprocess from datetime import datetime, timedelta, time from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer -from urllib.parse import parse_qs, urlparse +from urllib.parse import parse_qs, quote, urlparse BASE_DIR = os.path.dirname(os.path.abspath(__file__)) DB_PATH = os.path.join(BASE_DIR, 'matching.db') @@ -287,6 +288,15 @@ def init_db(conn): PRIMARY KEY (sourcePage, projectCode) ); + CREATE TABLE IF NOT EXISTS naver_address_token_cache ( + address TEXT PRIMARY KEY, + tokenX TEXT NOT NULL, + tokenY TEXT NOT NULL, + label TEXT DEFAULT '', + resolvedUrl TEXT DEFAULT '', + syncedAt TEXT NOT NULL + ); + CREATE INDEX IF NOT EXISTS idx_dally_memberno ON dallyproject(MemberNo); CREATE INDEX IF NOT EXISTS idx_tardy_member ON worker_tardy(MemberNo); ''' @@ -385,6 +395,144 @@ def _date_value(v): return None +def clean_route_address(value): + text = re.sub(r'\s+', ' ', _as_text(value).replace('\n', ' ')).strip() + text = re.sub(r'^\(주\)\s*장헌\s*', '', text) + text = re.sub(r'^\(주\)장헌\s*', '', text) + text = re.sub(r'^\[.*?\]\s*', '', text) + text = re.sub(r'\(.*?\)', ' ', text) + text = re.sub(r'\[.*?\]', ' ', text) + text = re.sub(r'\s+', ' ', text).strip() + address_start = re.search( + r'(서울특별시|서울시|부산광역시|부산시|대구광역시|대구시|인천광역시|인천시|광주광역시|광주시|대전광역시|대전시|울산광역시|울산시|세종특별자치시|세종시|경기도|강원특별자치도|강원도|충청북도|충북|충청남도|충남|전북특별자치도|전라북도|전북|전라남도|전남|경상북도|경북|경상남도|경남|제주특별자치도|제주도)', + text, + ) + if address_start: + text = text[address_start.start():].strip() + text = re.split(r'\s*~\s*', text, maxsplit=1)[0].strip() + text = re.sub(r'\s+일원$', '', text).strip() + return text + + +def resolve_naver_address_token(address): + cleaned = clean_route_address(address) + if not cleaned or cleaned == '-': + raise ValueError('주소가 비어 있습니다.') + + with open_db_connection() as conn: + ensure_db_schema(conn) + cached = conn.execute( + 'SELECT tokenX, tokenY, label, resolvedUrl FROM naver_address_token_cache WHERE address = ?', + (cleaned,), + ).fetchone() + if cached: + return { + 'address': cleaned, + 'tokenX': cached[0], + 'tokenY': cached[1], + 'label': cached[2] or cleaned, + 'resolvedUrl': cached[3] or '', + 'cached': True, + } + + node_script = r""" +const { chromium } = require('playwright-core'); +const address = process.argv[1]; +(async () => { + const browser = await chromium.launch({ + headless: true, + executablePath: '/usr/bin/google-chrome', + args: ['--no-sandbox', '--disable-dev-shm-usage'], + }); + const page = await browser.newPage(); + await page.goto(`https://map.naver.com/p/search/${encodeURIComponent(address)}`, { + waitUntil: 'domcontentloaded', + timeout: 20000, + }); + await page.waitForTimeout(3200); + const url = page.url(); + await browser.close(); + const match = url.match(/\/address\/([^/?#]+)/); + if (!match) { + throw new Error(`네이버 주소 토큰을 찾지 못했습니다: ${url}`); + } + const parts = decodeURIComponent(match[1]).split(','); + if (parts.length < 2) { + throw new Error(`네이버 주소 토큰 형식이 올바르지 않습니다: ${match[1]}`); + } + console.log(JSON.stringify({ + tokenX: parts[0], + tokenY: parts[1], + label: parts.slice(2).join(',') || address, + resolvedUrl: url, + })); +})().catch((error) => { + console.error(error && error.message ? error.message : String(error)); + process.exit(1); +}); +""" + env = os.environ.copy() + npx_node_path = '/home/hyein/.npm/_npx/9833c18b2d85bc59/node_modules' + env['NODE_PATH'] = f"{npx_node_path}:{env.get('NODE_PATH', '')}" if env.get('NODE_PATH') else npx_node_path + try: + completed = subprocess.run( + ['node', '-e', node_script, cleaned], + cwd=BASE_DIR, + env=env, + capture_output=True, + text=True, + timeout=30, + check=True, + ) + except subprocess.CalledProcessError as error: + detail = (error.stderr or error.stdout or str(error)).strip() + raise RuntimeError(f'네이버 주소 토큰 조회 실패: {cleaned}\n{detail}') from error + except subprocess.TimeoutExpired as error: + raise RuntimeError(f'네이버 주소 토큰 조회 시간 초과: {cleaned}') from error + token = json.loads(completed.stdout.strip().splitlines()[-1]) + token['address'] = cleaned + token['cached'] = False + + def write_cache(conn): + conn.execute( + ''' + INSERT OR REPLACE INTO naver_address_token_cache + (address, tokenX, tokenY, label, resolvedUrl, syncedAt) + VALUES (?, ?, ?, ?, ?, ?) + ''', + ( + cleaned, + token['tokenX'], + token['tokenY'], + token.get('label') or cleaned, + token.get('resolvedUrl') or '', + datetime.now().isoformat(timespec='seconds'), + ), + ) + return True + + run_db_write(write_cache) + return token + + +def build_naver_driving_route_url(origin_address, destination_address): + origin = resolve_naver_address_token(origin_address) + destination = resolve_naver_address_token(destination_address) + origin_label = quote(origin.get('label') or origin['address'], safe='') + destination_label = quote(destination.get('label') or destination['address'], safe='') + url = ( + 'https://map.naver.com/p/directions/' + f"{origin['tokenX']},{origin['tokenY']},{origin_label},ADDRESS_POI/" + f"{destination['tokenX']},{destination['tokenY']},{destination_label},ADDRESS_POI/" + '-/car' + ) + return { + 'url': url, + 'origin': origin, + 'destination': destination, + } + + def _member_rows_for_site_work_day(name_to_members, kor_name, work_date): # 사업관리 원본은 이름만 있어 재입사자가 같은 이름으로 중복 매칭될 수 있다. # 같은 이름의 사번이 여러 개이면 작업일 기준으로 퇴사 전 사번/현재 사번을 나눠 붙인다. @@ -3974,6 +4122,8 @@ class Handler(BaseHTTPRequestHandler): def do_GET(self): parsed = urlparse(self.path) if parsed.path == '/': + if os.environ.get('PORT') == '8092': + return self._html(PROJECT_CODES_HTML) return self._html(PEOPLE_UNIFIED_HTML) if parsed.path == '/project-codes.html': return self._html(PROJECT_CODES_HTML) @@ -4120,6 +4270,13 @@ class Handler(BaseHTTPRequestHandler): return self._json(404, {'ok': False, 'error': '공사시행계획서 캐시가 없습니다.'}) return self._json(200, {'ok': True, 'plan': cached}) + if parsed.path == '/api/naver-route-url': + q = parse_qs(parsed.query) + origin = q.get('origin', ['충청남도 당진시 고대면 성산로 464'])[0] + destination = q.get('destination', [''])[0] + result = build_naver_driving_route_url(origin, destination) + return self._json(200, {'ok': True, **result}) + if parsed.path == '/api/stats': with open_db_connection() as conn: return self._json(200, get_stats(conn)) diff --git a/project-codes.html b/project-codes.html index 916ef4e..a7dafbc 100644 --- a/project-codes.html +++ b/project-codes.html @@ -352,6 +352,31 @@ opacity: 0.45; cursor: default; } + .map-button { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 48px; + height: 28px; + margin-top: 6px; + padding: 0 10px; + border: 1px solid rgba(15, 118, 110, 0.24); + border-radius: 999px; + background: #e9f8f5; + color: var(--accent-strong); + font-size: 12px; + font-weight: 800; + cursor: pointer; + white-space: nowrap; + } + .map-button:hover { + filter: brightness(0.96); + } + .map-location { + display: grid; + gap: 4px; + justify-items: start; + } .modal-backdrop { position: fixed; inset: 0; @@ -620,6 +645,7 @@ sourceTable: '', sourceColumns: {}, }; + const FACTORY_ADDRESS = '충청남도 당진시 고대면 성산로 464'; const searchInput = document.getElementById('searchInput'); const contractTypeFilter = document.getElementById('contractTypeFilter'); @@ -788,6 +814,13 @@ .filter(Boolean); } + function compareProjectCodeDesc(a, b) { + return String(b.projectCode || '').localeCompare(String(a.projectCode || ''), 'ko', { + numeric: true, + sensitivity: 'base', + }); + } + function renderRows(rows) { if (!rows.length) { resultBody.innerHTML = '조건에 맞는 프로젝트가 없습니다.'; @@ -860,7 +893,7 @@ ['사업코드', detail.businessCode], ['약칭', detail.projectName], ['시공코드', detail.projectCode], - ['현장위치', detail.siteLocation], + ['현장위치', renderSiteLocationCell(detail.siteLocation, detail.projectName)], ['발주처', detail.clientName], ['최종계약금액', detail.finalContractAmountText], ['계약종류', detail.contractType], @@ -875,9 +908,32 @@ detailBody.innerHTML = rows.map(([label, value]) => ` ${escapeHtml(label)} - ${typeof value === 'string' && value.includes(' + ${typeof value === 'string' && (value.includes(' `).join(''); + Array.from(detailBody.querySelectorAll('.map-button')).forEach((button) => { + button.addEventListener('click', () => { + openBridgeMap(button.getAttribute('data-bridge') || '', button.getAttribute('data-location') || ''); + }); + }); + } + + function renderSiteLocationCell(siteLocation, projectName) { + const location = normalizeValue(siteLocation); + const disabled = !location || location === '-'; + return ` +
+
${toHtmlWithBreaks(location)}
+ +
+ `; } function normalizeBridgeName(value) { @@ -918,6 +974,75 @@ return `${text.slice(0, splitIndex).trim()}\n${text.slice(splitIndex + 1).trim()}`; } + function normalizeMapQuery(value) { + return String(value || '') + .replace(/\s+/g, ' ') + .replace(/\n+/g, ' ') + .trim(); + } + + function extractAddressForMap(value) { + let text = normalizeMapQuery(value) + .replace(/^\(주\)\s*장헌\s*/, '') + .replace(/^\(주\)장헌\s*/, '') + .replace(/^\[.*?\]\s*/, '') + .replace(/\(.*?\)/g, ' ') + .replace(/\[.*?\]/g, ' ') + .replace(/\s+/g, ' ') + .trim(); + const addressStart = text.search(/(서울특별시|서울시|부산광역시|부산시|대구광역시|대구시|인천광역시|인천시|광주광역시|광주시|대전광역시|대전시|울산광역시|울산시|세종특별자치시|세종시|경기도|강원특별자치도|강원도|충청북도|충북|충청남도|충남|전북특별자치도|전라북도|전북|전라남도|전남|경상북도|경북|경상남도|경남|제주특별자치도|제주도)/); + if (addressStart > 0) { + text = text.slice(addressStart).trim(); + } + text = text + .split(/\s*~\s*/)[0] + .replace(/\s+/g, ' ') + .trim(); + return text; + } + + async function openBridgeMap(bridgeName, bridgeLocation) { + const site = extractAddressForMap(bridgeLocation); + if (!site || site === '-') { + alert('교량 위치 정보가 없습니다.'); + return; + } + const popup = window.open( + '', + `jhBridgeRouteMap_${Date.now()}`, + 'popup=yes,width=1280,height=820,left=80,top=60,resizable=yes,scrollbars=yes' + ); + if (!popup) { + alert('팝업이 차단되었습니다. 브라우저 팝업 허용 후 다시 눌러주세요.'); + return; + } + popup.document.write('길찾기 준비 중네이버 자동차 길찾기를 준비하는 중입니다...'); + popup.focus(); + + try { + const params = new URLSearchParams({ + origin: FACTORY_ADDRESS, + destination: site, + }); + const response = await fetch(`/api/naver-route-url?${params.toString()}`, { cache: 'no-store' }); + const result = await response.json(); + if (!response.ok || !result.ok || !result.url) { + throw new Error(result.error || '네이버 길찾기 URL을 만들지 못했습니다.'); + } + popup.location.href = result.url; + } catch (error) { + const fallbackUrl = `https://map.naver.com/p/search/${encodeURIComponent(site)}`; + popup.document.body.innerHTML = ` +
+

네이버 자동차 길찾기를 바로 열지 못했습니다.

+

현장 주소: ${escapeHtml(site)}

+

${escapeHtml(error.message || String(error))}

+

네이버지도에서 현장 위치 먼저 열기

+
+ `; + } + } + function formatStatus(scaleRow, overviewRow) { const status = overviewRow?.constructionStatus || scaleRow?.constructionStatus || ''; return status || '-'; @@ -1033,6 +1158,7 @@ crossbeamCount: scaleRow.crossbeamCount || '-', panelCount: scaleRow.panelCount || '-', rebarCount: scaleRow.rebarCount || '-', + rawBridgeLocation: overviewRow.bridgeLocation || '', bridgeLocation: formatBridgeLocation(overviewRow.bridgeLocation), remarks: overviewRow.remarks || '-', }; @@ -1041,6 +1167,8 @@ function renderMergedBridgeRows(scaleRows, overviews, budgetPlan) { const mergedRows = mergeBridgeRows(scaleRows, overviews, budgetPlan); + const siteLocation = normalizeValue(state.detail?.siteLocation || state.selectedRow?.siteLocation || ''); + const hasSiteLocation = siteLocation && siteLocation !== '-'; bridgeMeta.textContent = mergedRows.length ? `공사규모 ${scaleRows.length}건 · 공사개요 ${overviews.length}건을 교량명 기준으로 매칭했습니다.` : '연결된 공사규모나 공사개요 데이터가 없습니다.'; @@ -1068,7 +1196,11 @@ ${toHtmlWithBreaks(formatCellValue(row.crossbeamCount))} ${toHtmlWithBreaks(formatCellValue(row.panelCount))} ${toHtmlWithBreaks(formatCellValue(row.rebarCount))} - ${toHtmlWithBreaks(row.bridgeLocation)} + +
+
${toHtmlWithBreaks(row.bridgeLocation)}
+
+