Update project dashboard routing and map directions
This commit is contained in:
BIN
matching.db
BIN
matching.db
Binary file not shown.
@@ -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))
|
||||
|
||||
@@ -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 = '<tr><td colspan="2" class="empty">조건에 맞는 프로젝트가 없습니다.</td></tr>';
|
||||
@@ -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]) => `
|
||||
<tr>
|
||||
<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>
|
||||
`).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) {
|
||||
@@ -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('<!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) {
|
||||
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 @@
|
||||
<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.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>
|
||||
<button
|
||||
class="remark-button"
|
||||
@@ -1133,6 +1265,7 @@
|
||||
if (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()}건`;
|
||||
statusText.textContent = keyword
|
||||
|
||||
Reference in New Issue
Block a user