Update project dashboard routing and map directions

This commit is contained in:
2026-06-10 16:22:04 +09:00
parent 62b25b045b
commit 0c052abfa7
3 changed files with 294 additions and 4 deletions

Binary file not shown.

View File

@@ -11,9 +11,10 @@ import requests
import html as html_lib import html as html_lib
import threading import threading
import uuid import uuid
import subprocess
from datetime import datetime, timedelta, time from datetime import datetime, timedelta, time
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer 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__)) BASE_DIR = os.path.dirname(os.path.abspath(__file__))
DB_PATH = os.path.join(BASE_DIR, 'matching.db') DB_PATH = os.path.join(BASE_DIR, 'matching.db')
@@ -287,6 +288,15 @@ def init_db(conn):
PRIMARY KEY (sourcePage, projectCode) 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_dally_memberno ON dallyproject(MemberNo);
CREATE INDEX IF NOT EXISTS idx_tardy_member ON worker_tardy(MemberNo); CREATE INDEX IF NOT EXISTS idx_tardy_member ON worker_tardy(MemberNo);
''' '''
@@ -385,6 +395,144 @@ def _date_value(v):
return None 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): 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): def do_GET(self):
parsed = urlparse(self.path) parsed = urlparse(self.path)
if parsed.path == '/': if parsed.path == '/':
if os.environ.get('PORT') == '8092':
return self._html(PROJECT_CODES_HTML)
return self._html(PEOPLE_UNIFIED_HTML) return self._html(PEOPLE_UNIFIED_HTML)
if parsed.path == '/project-codes.html': if parsed.path == '/project-codes.html':
return self._html(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(404, {'ok': False, 'error': '공사시행계획서 캐시가 없습니다.'})
return self._json(200, {'ok': True, 'plan': cached}) 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': if parsed.path == '/api/stats':
with open_db_connection() as conn: with open_db_connection() as conn:
return self._json(200, get_stats(conn)) return self._json(200, get_stats(conn))

View File

@@ -352,6 +352,31 @@
opacity: 0.45; opacity: 0.45;
cursor: default; 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 { .modal-backdrop {
position: fixed; position: fixed;
inset: 0; inset: 0;
@@ -620,6 +645,7 @@
sourceTable: '', sourceTable: '',
sourceColumns: {}, sourceColumns: {},
}; };
const FACTORY_ADDRESS = '충청남도 당진시 고대면 성산로 464';
const searchInput = document.getElementById('searchInput'); const searchInput = document.getElementById('searchInput');
const contractTypeFilter = document.getElementById('contractTypeFilter'); const contractTypeFilter = document.getElementById('contractTypeFilter');
@@ -788,6 +814,13 @@
.filter(Boolean); .filter(Boolean);
} }
function compareProjectCodeDesc(a, b) {
return String(b.projectCode || '').localeCompare(String(a.projectCode || ''), 'ko', {
numeric: true,
sensitivity: 'base',
});
}
function renderRows(rows) { function renderRows(rows) {
if (!rows.length) { if (!rows.length) {
resultBody.innerHTML = '<tr><td colspan="2" class="empty">조건에 맞는 프로젝트가 없습니다.</td></tr>'; resultBody.innerHTML = '<tr><td colspan="2" class="empty">조건에 맞는 프로젝트가 없습니다.</td></tr>';
@@ -860,7 +893,7 @@
['사업코드', detail.businessCode], ['사업코드', detail.businessCode],
['약칭', detail.projectName], ['약칭', detail.projectName],
['시공코드', detail.projectCode], ['시공코드', detail.projectCode],
['현장위치', detail.siteLocation], ['현장위치', renderSiteLocationCell(detail.siteLocation, detail.projectName)],
['발주처', detail.clientName], ['발주처', detail.clientName],
['최종계약금액', detail.finalContractAmountText], ['최종계약금액', detail.finalContractAmountText],
['계약종류', detail.contractType], ['계약종류', detail.contractType],
@@ -875,9 +908,32 @@
detailBody.innerHTML = rows.map(([label, value]) => ` detailBody.innerHTML = rows.map(([label, value]) => `
<tr> <tr>
<th>${escapeHtml(label)}</th> <th>${escapeHtml(label)}</th>
<td>${typeof value === 'string' && value.includes('<table') ? value : escapeHtml(value || '-')}</td> <td>${typeof value === 'string' && (value.includes('<table') || value.includes('class="map-location"')) ? value : escapeHtml(value || '-')}</td>
</tr> </tr>
`).join(''); `).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 `
<div class="map-location">
<div>${toHtmlWithBreaks(location)}</div>
<button
class="map-button"
type="button"
data-bridge="${escapeAttr(projectName || '')}"
data-location="${escapeAttr(location)}"
${disabled ? 'disabled' : ''}
title="공장 위치와 현장 위치를 지도에서 보기"
>지도</button>
</div>
`;
} }
function normalizeBridgeName(value) { function normalizeBridgeName(value) {
@@ -918,6 +974,75 @@
return `${text.slice(0, splitIndex).trim()}\n${text.slice(splitIndex + 1).trim()}`; 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('<!doctype html><meta charset="utf-8"><title>길찾기 준비 중</title><body style="font-family:sans-serif;padding:24px;">네이버 자동차 길찾기를 준비하는 중입니다...</body>');
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 = `
<div style="font-family:sans-serif;padding:24px;line-height:1.6;">
<h2 style="margin:0 0 12px;color:#b91c1c;">네이버 자동차 길찾기를 바로 열지 못했습니다.</h2>
<p>현장 주소: <strong>${escapeHtml(site)}</strong></p>
<p style="white-space:pre-wrap;color:#7f1d1d;">${escapeHtml(error.message || String(error))}</p>
<p><a href="${escapeAttr(fallbackUrl)}" style="color:#0f766e;font-weight:700;">네이버지도에서 현장 위치 먼저 열기</a></p>
</div>
`;
}
}
function formatStatus(scaleRow, overviewRow) { function formatStatus(scaleRow, overviewRow) {
const status = overviewRow?.constructionStatus || scaleRow?.constructionStatus || ''; const status = overviewRow?.constructionStatus || scaleRow?.constructionStatus || '';
return status || '-'; return status || '-';
@@ -1033,6 +1158,7 @@
crossbeamCount: scaleRow.crossbeamCount || '-', crossbeamCount: scaleRow.crossbeamCount || '-',
panelCount: scaleRow.panelCount || '-', panelCount: scaleRow.panelCount || '-',
rebarCount: scaleRow.rebarCount || '-', rebarCount: scaleRow.rebarCount || '-',
rawBridgeLocation: overviewRow.bridgeLocation || '',
bridgeLocation: formatBridgeLocation(overviewRow.bridgeLocation), bridgeLocation: formatBridgeLocation(overviewRow.bridgeLocation),
remarks: overviewRow.remarks || '-', remarks: overviewRow.remarks || '-',
}; };
@@ -1041,6 +1167,8 @@
function renderMergedBridgeRows(scaleRows, overviews, budgetPlan) { function renderMergedBridgeRows(scaleRows, overviews, budgetPlan) {
const mergedRows = mergeBridgeRows(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 bridgeMeta.textContent = mergedRows.length
? `공사규모 ${scaleRows.length}건 · 공사개요 ${overviews.length}건을 교량명 기준으로 매칭했습니다.` ? `공사규모 ${scaleRows.length}건 · 공사개요 ${overviews.length}건을 교량명 기준으로 매칭했습니다.`
: '연결된 공사규모나 공사개요 데이터가 없습니다.'; : '연결된 공사규모나 공사개요 데이터가 없습니다.';
@@ -1068,7 +1196,11 @@
<td class="cell-num bridge-summary">${toHtmlWithBreaks(formatCellValue(row.crossbeamCount))}</td> <td class="cell-num bridge-summary">${toHtmlWithBreaks(formatCellValue(row.crossbeamCount))}</td>
<td class="cell-num bridge-summary">${toHtmlWithBreaks(formatCellValue(row.panelCount))}</td> <td class="cell-num bridge-summary">${toHtmlWithBreaks(formatCellValue(row.panelCount))}</td>
<td class="cell-num bridge-summary">${toHtmlWithBreaks(formatCellValue(row.rebarCount))}</td> <td class="cell-num bridge-summary">${toHtmlWithBreaks(formatCellValue(row.rebarCount))}</td>
<td class="bridge-summary cell-wide">${toHtmlWithBreaks(row.bridgeLocation)}</td> <td class="bridge-summary cell-wide">
<div class="map-location">
<div>${toHtmlWithBreaks(row.bridgeLocation)}</div>
</div>
</td>
<td> <td>
<button <button
class="remark-button" class="remark-button"
@@ -1133,6 +1265,7 @@
if (selectedApplicationType) { if (selectedApplicationType) {
state.filteredRows = state.filteredRows.filter((row) => splitApplicationTypes(row.applicationType).includes(selectedApplicationType)); state.filteredRows = state.filteredRows.filter((row) => splitApplicationTypes(row.applicationType).includes(selectedApplicationType));
} }
state.filteredRows.sort(compareProjectCodeDesc);
countChip.textContent = `${state.rows.length.toLocaleString()}건 / 표시 ${state.filteredRows.length.toLocaleString()}`; countChip.textContent = `${state.rows.length.toLocaleString()}건 / 표시 ${state.filteredRows.length.toLocaleString()}`;
statusText.textContent = keyword statusText.textContent = keyword