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)}
+
+ |
| | |