Improve 8092 cache loading and SQLite stability

This commit is contained in:
2026-06-09 10:12:15 +09:00
parent 6ac0964618
commit 54def57e99
3 changed files with 161 additions and 60 deletions

View File

@@ -48,10 +48,26 @@ HOLIDAY_DATES = {
SITE_SYNC_JOBS = {} SITE_SYNC_JOBS = {}
SITE_SYNC_LOCK = threading.Lock() SITE_SYNC_LOCK = threading.Lock()
DB_INIT_LOCK = threading.Lock()
DB_SCHEMA_READY = False
def configure_sqlite_connection(conn):
conn.execute('PRAGMA journal_mode=WAL')
conn.execute('PRAGMA synchronous=NORMAL')
conn.execute('PRAGMA busy_timeout=30000')
return conn
def open_db_connection(timeout=30):
conn = sqlite3.connect(DB_PATH, timeout=timeout)
return configure_sqlite_connection(conn)
def init_db(conn): def init_db(conn):
global DB_SCHEMA_READY
configure_sqlite_connection(conn)
conn.executescript( conn.executescript(
''' '''
CREATE TABLE IF NOT EXISTS member ( CREATE TABLE IF NOT EXISTS member (
@@ -151,6 +167,13 @@ def init_db(conn):
PRIMARY KEY (projectCode, workDate, selMenu) PRIMARY KEY (projectCode, workDate, selMenu)
); );
CREATE TABLE IF NOT EXISTS member_site_identity (
memberNo TEXT PRIMARY KEY,
korName TEXT DEFAULT '',
juminno TEXT DEFAULT '',
updatedAt TEXT DEFAULT ''
);
CREATE TABLE IF NOT EXISTS work_calendar_day ( CREATE TABLE IF NOT EXISTS work_calendar_day (
memberNo TEXT, memberNo TEXT,
workDate TEXT, workDate TEXT,
@@ -289,17 +312,25 @@ def init_db(conn):
conn.execute("CREATE INDEX IF NOT EXISTS idx_erp_bridge_overview_cache_page ON erp_bridge_overview_cache(sourcePage, projectCode, bridgeNo)") conn.execute("CREATE INDEX IF NOT EXISTS idx_erp_bridge_overview_cache_page ON erp_bridge_overview_cache(sourcePage, projectCode, bridgeNo)")
conn.execute("CREATE INDEX IF NOT EXISTS idx_erp_budget_plan_cache_page ON erp_budget_plan_cache(sourcePage, projectCode)") conn.execute("CREATE INDEX IF NOT EXISTS idx_erp_budget_plan_cache_page ON erp_budget_plan_cache(sourcePage, projectCode)")
conn.commit() conn.commit()
DB_SCHEMA_READY = True
def ensure_db_schema(conn): def ensure_db_schema(conn):
try: global DB_SCHEMA_READY
init_db(conn) if DB_SCHEMA_READY:
cleanup_site_records_after_retire(conn) return True
except sqlite3.OperationalError as error: with DB_INIT_LOCK:
if 'locked' in str(error).lower(): if DB_SCHEMA_READY:
return False return True
raise try:
return True configure_sqlite_connection(conn)
init_db(conn)
cleanup_site_records_after_retire(conn)
except sqlite3.OperationalError as error:
if 'locked' in str(error).lower():
return False
raise
return True
def normalize_member_no(v): def normalize_member_no(v):
@@ -351,8 +382,8 @@ def _member_rows_for_site_work_day(name_to_members, kor_name, work_date):
def cleanup_site_records_after_retire(conn): def cleanup_site_records_after_retire(conn):
# 이미 저장된 사업관리 기록 중 같은 이름의 현재 사번이 존재하는데 # 사업관리 원본은 이름 중심이라 퇴사자와 동명이인/재입사자를 잘못 매칭할 수 있다.
# 퇴사 사번의 퇴사일 이후로 붙은 기록은 중복 집계 원인이므로 제거한다. # member 퇴사일 이후의 사업관리 기록은 해당 퇴사 사번에서 무조건 제거한다.
conn.execute( conn.execute(
''' '''
DELETE FROM site_worksheet_record DELETE FROM site_worksheet_record
@@ -362,16 +393,6 @@ def cleanup_site_records_after_retire(conn):
JOIN member oldm ON oldm.MemberNo = s.memberNo JOIN member oldm ON oldm.MemberNo = s.memberNo
WHERE IFNULL(oldm.retireFlag, '') NOT IN ('', '0000-00-00', '0000-00-00 00:00:00') WHERE IFNULL(oldm.retireFlag, '') NOT IN ('', '0000-00-00', '0000-00-00 00:00:00')
AND date(s.workDate) > date(substr(oldm.retireFlag, 1, 10)) AND date(s.workDate) > date(substr(oldm.retireFlag, 1, 10))
AND EXISTS (
SELECT 1
FROM member newm
WHERE newm.korName = oldm.korName
AND newm.MemberNo <> oldm.MemberNo
AND (
IFNULL(newm.retireFlag, '') IN ('', '0000-00-00', '0000-00-00 00:00:00')
OR date(s.workDate) <= date(substr(newm.retireFlag, 1, 10))
)
)
) )
''' '''
) )
@@ -1583,6 +1604,66 @@ def _load_construct_paymonth_rows(session, member_name, juminno, write_day):
return _parse_construct_paymonth_rows(html, write_day) return _parse_construct_paymonth_rows(html, write_day)
def _project_code_for_bridge_name(conn, bridge_name, fallback_project_code=''):
bridge_name = _as_text(bridge_name).strip()
if not bridge_name:
return fallback_project_code or ''
row = conn.execute(
'''
SELECT projectCode
FROM project_alias
WHERE shortName = ?
ORDER BY projectCode DESC
LIMIT 1
''',
(bridge_name,)
).fetchone()
if row:
return row[0] or fallback_project_code or ''
row = conn.execute(
'''
SELECT projectCode
FROM project_alias
WHERE shortName LIKE ?
ORDER BY LENGTH(shortName) ASC, projectCode DESC
LIMIT 1
''',
(f'%{bridge_name}%',)
).fetchone()
return (row[0] if row else '') or fallback_project_code or ''
def insert_construct_paymonth_records(conn, session, member_no, member_name, juminno, write_day, start_date, end_date, fallback_project_code=''):
rows = _load_construct_paymonth_rows(session, member_name, juminno, write_day)
inserted = 0
for row in rows:
work_date = row.get('workDate') or ''
if work_date < start_date or work_date > end_date:
continue
project_code = _project_code_for_bridge_name(conn, row.get('bridgeName', ''), fallback_project_code)
if not project_code:
continue
conn.execute(
'''
INSERT OR REPLACE INTO site_worksheet_record
(projectCode, workDate, memberNo, korName, jobType, workText, note, personCount)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
''',
(
project_code,
work_date,
member_no,
member_name,
row.get('jobType', '') or '',
row.get('bridgeName', '') or row.get('workText', '') or '',
row.get('workText', '') or '',
_float_or_zero(row.get('personCount')),
)
)
inserted += 1
return inserted
def _site_record_rows_from_cache(conn, start_date, end_date, member_no): def _site_record_rows_from_cache(conn, start_date, end_date, member_no):
cur = conn.execute( cur = conn.execute(
''' '''
@@ -2078,7 +2159,24 @@ def get_member_site_worksheet_records(conn, start_date, end_date, member_no, ref
'cachedRows': len(cached), 'cachedRows': len(cached),
}) })
if not targets: if not targets:
return {'rows': cached, 'source': 'cache'} ident = conn.execute(
'''
SELECT IFNULL(juminno, '')
FROM member_site_identity
WHERE memberNo = ?
''',
(member_no,)
).fetchone()
if not ident or not (ident[0] or '').strip():
return {'rows': cached, 'source': 'cache'}
if progress is not None:
progress['phase'] = '월별 근무현황 보강'
s = erp_login_session()
insert_construct_paymonth_records(conn, s, member_no, member_name, ident[0].strip(), end_date, start_date, end_date)
conn.commit()
cleanup_site_records_after_retire(conn)
rebuild_work_calendar_tables(conn)
return {'rows': _site_record_rows_from_cache(conn, start_date, end_date, member_no), 'source': 'erp_paymonth', 'added': 0}
if progress is not None: if progress is not None:
progress['phase'] = '사업관리 로그인' progress['phase'] = '사업관리 로그인'
@@ -2205,6 +2303,25 @@ def get_member_site_worksheet_records(conn, start_date, end_date, member_no, ref
'processedTargets': int(progress.get('processedTargets') or 0) + 1, 'processedTargets': int(progress.get('processedTargets') or 0) + 1,
'added': len(out), 'added': len(out),
}) })
ident = conn.execute(
'''
SELECT IFNULL(juminno, '')
FROM member_site_identity
WHERE memberNo = ?
''',
(member_no,)
).fetchone()
if ident and (ident[0] or '').strip():
if progress is not None:
progress['phase'] = '월별 근무현황 보강'
try:
inserted = insert_construct_paymonth_records(
conn, s, member_no, member_name, ident[0].strip(), end_date, start_date, end_date
)
if inserted and progress is not None:
progress['added'] = int(progress.get('added') or 0) + inserted
except Exception:
pass
if progress is not None: if progress is not None:
progress['phase'] = '달력 테이블 정리' progress['phase'] = '달력 테이블 정리'
conn.commit() conn.commit()
@@ -3838,7 +3955,7 @@ class Handler(BaseHTTPRequestHandler):
q = parse_qs(parsed.query) q = parse_qs(parsed.query)
page_name = q.get('page', ['const'])[0] page_name = q.get('page', ['const'])[0]
refresh = q.get('refresh', ['0'])[0] in ('1', 'true', 'yes') refresh = q.get('refresh', ['0'])[0] in ('1', 'true', 'yes')
with sqlite3.connect(DB_PATH) as conn: with open_db_connection() as conn:
ensure_db_schema(conn) ensure_db_schema(conn)
if refresh: if refresh:
search_text = q.get('searchText', [''])[0] search_text = q.get('searchText', [''])[0]
@@ -3874,7 +3991,7 @@ class Handler(BaseHTTPRequestHandler):
project_code = q.get('projectCode', [''])[0] project_code = q.get('projectCode', [''])[0]
project_name = q.get('projectName', [''])[0] project_name = q.get('projectName', [''])[0]
refresh = q.get('refresh', ['0'])[0] in ('1', 'true', 'yes') refresh = q.get('refresh', ['0'])[0] in ('1', 'true', 'yes')
with sqlite3.connect(DB_PATH) as conn: with open_db_connection() as conn:
ensure_db_schema(conn) ensure_db_schema(conn)
if refresh: if refresh:
detail = fetch_erp_contract_detail(page_name, project_code, project_name) detail = fetch_erp_contract_detail(page_name, project_code, project_name)
@@ -3896,7 +4013,7 @@ class Handler(BaseHTTPRequestHandler):
project_code = q.get('projectCode', [''])[0] project_code = q.get('projectCode', [''])[0]
project_name = q.get('projectName', [''])[0] project_name = q.get('projectName', [''])[0]
refresh = q.get('refresh', ['0'])[0] in ('1', 'true', 'yes') refresh = q.get('refresh', ['0'])[0] in ('1', 'true', 'yes')
with sqlite3.connect(DB_PATH) as conn: with open_db_connection() as conn:
ensure_db_schema(conn) ensure_db_schema(conn)
if refresh: if refresh:
result = fetch_erp_bridge_overviews(page_name, project_code, project_name) result = fetch_erp_bridge_overviews(page_name, project_code, project_name)
@@ -3917,7 +4034,7 @@ class Handler(BaseHTTPRequestHandler):
project_code = q.get('projectCode', [''])[0] project_code = q.get('projectCode', [''])[0]
project_name = q.get('projectName', [''])[0] project_name = q.get('projectName', [''])[0]
refresh = q.get('refresh', ['0'])[0] in ('1', 'true', 'yes') refresh = q.get('refresh', ['0'])[0] in ('1', 'true', 'yes')
with sqlite3.connect(DB_PATH) as conn: with open_db_connection() as conn:
ensure_db_schema(conn) ensure_db_schema(conn)
if refresh: if refresh:
result = fetch_erp_budget_plan(page_name, project_code, project_name) result = fetch_erp_budget_plan(page_name, project_code, project_name)
@@ -3928,7 +4045,7 @@ class Handler(BaseHTTPRequestHandler):
return self._json(200, {'ok': True, 'plan': cached}) return self._json(200, {'ok': True, 'plan': cached})
if parsed.path == '/api/stats': if parsed.path == '/api/stats':
with sqlite3.connect(DB_PATH) as conn: with open_db_connection() as conn:
return self._json(200, get_stats(conn)) return self._json(200, get_stats(conn))
if parsed.path == '/api/rebuild-work-calendar': if parsed.path == '/api/rebuild-work-calendar':
@@ -4105,7 +4222,7 @@ class Handler(BaseHTTPRequestHandler):
q = parse_qs(parsed.query) q = parse_qs(parsed.query)
page_name = q.get('page', ['const'])[0] page_name = q.get('page', ['const'])[0]
result = fetch_erp_project_codes(page_name) result = fetch_erp_project_codes(page_name)
with sqlite3.connect(DB_PATH) as conn: with open_db_connection() as conn:
ensure_db_schema(conn) ensure_db_schema(conn)
sync_info = replace_erp_project_code_cache(conn, result['page'], result['rows']) sync_info = replace_erp_project_code_cache(conn, result['page'], result['rows'])
return self._json( return self._json(
@@ -4128,7 +4245,7 @@ class Handler(BaseHTTPRequestHandler):
project_code = q.get('projectCode', [''])[0] project_code = q.get('projectCode', [''])[0]
project_name = q.get('projectName', [''])[0] project_name = q.get('projectName', [''])[0]
detail = fetch_erp_contract_detail(page_name, project_code, project_name) detail = fetch_erp_contract_detail(page_name, project_code, project_name)
with sqlite3.connect(DB_PATH) as conn: with open_db_connection() as conn:
ensure_db_schema(conn) ensure_db_schema(conn)
sync_info = replace_erp_contract_detail_cache(conn, detail) sync_info = replace_erp_contract_detail_cache(conn, detail)
cached = get_erp_contract_detail_cache(conn, page_name, project_code) cached = get_erp_contract_detail_cache(conn, page_name, project_code)
@@ -4151,7 +4268,7 @@ class Handler(BaseHTTPRequestHandler):
project_code = q.get('projectCode', [''])[0] project_code = q.get('projectCode', [''])[0]
project_name = q.get('projectName', [''])[0] project_name = q.get('projectName', [''])[0]
result = fetch_erp_bridge_overviews(page_name, project_code, project_name) result = fetch_erp_bridge_overviews(page_name, project_code, project_name)
with sqlite3.connect(DB_PATH) as conn: with open_db_connection() as conn:
ensure_db_schema(conn) ensure_db_schema(conn)
sync_info = replace_erp_bridge_overview_cache(conn, result) sync_info = replace_erp_bridge_overview_cache(conn, result)
cached = get_erp_bridge_overview_cache(conn, page_name, project_code) cached = get_erp_bridge_overview_cache(conn, page_name, project_code)
@@ -4175,7 +4292,7 @@ class Handler(BaseHTTPRequestHandler):
project_code = q.get('projectCode', [''])[0] project_code = q.get('projectCode', [''])[0]
project_name = q.get('projectName', [''])[0] project_name = q.get('projectName', [''])[0]
result = fetch_erp_budget_plan(page_name, project_code, project_name) result = fetch_erp_budget_plan(page_name, project_code, project_name)
with sqlite3.connect(DB_PATH) as conn: with open_db_connection() as conn:
ensure_db_schema(conn) ensure_db_schema(conn)
sync_info = replace_erp_budget_plan_cache(conn, result) sync_info = replace_erp_budget_plan_cache(conn, result)
cached = get_erp_budget_plan_cache(conn, page_name, project_code) cached = get_erp_budget_plan_cache(conn, page_name, project_code)
@@ -4188,7 +4305,7 @@ class Handler(BaseHTTPRequestHandler):
return self._json(404, {'error': 'Not found'}) return self._json(404, {'error': 'Not found'})
try: try:
with sqlite3.connect(DB_PATH) as conn: with open_db_connection() as conn:
ensure_db_schema(conn) ensure_db_schema(conn)
used = load_from_mysql_into_sqlite(conn) used = load_from_mysql_into_sqlite(conn)
try: try:
@@ -4233,7 +4350,7 @@ def local_ip():
if __name__ == '__main__': if __name__ == '__main__':
os.makedirs(BASE_DIR, exist_ok=True) os.makedirs(BASE_DIR, exist_ok=True)
with sqlite3.connect(DB_PATH) as conn: with open_db_connection() as conn:
init_db(conn) init_db(conn)
try: try:
alias_rows = load_project_alias_from_erp() alias_rows = load_project_alias_from_erp()

View File

@@ -824,7 +824,7 @@
function renderDetail(detail) { function renderDetail(detail) {
if (!detail) { if (!detail) {
detailTitle.textContent = '계약정보 표'; detailTitle.textContent = '계약정보 표';
detailMeta.textContent = '시공코드나 약칭을 클릭하면 DB에 저장된 계약정보를 먼저 보여줍니다.'; detailMeta.textContent = '시공코드나 약칭을 클릭하면 DB 캐시를 먼저 보여줍니다. 최신 정보가 필요하면 새로 가져오기를 누르세요.';
detailBody.innerHTML = '<tr><td colspan="2" class="empty">선택된 시공코드가 없습니다.</td></tr>'; detailBody.innerHTML = '<tr><td colspan="2" class="empty">선택된 시공코드가 없습니다.</td></tr>';
bridgeMeta.textContent = '공사규모와 공사개요를 교량명 기준으로 매칭해서 보여줍니다.'; bridgeMeta.textContent = '공사규모와 공사개요를 교량명 기준으로 매칭해서 보여줍니다.';
bridgeBody.innerHTML = '<tr><td colspan="19" class="empty">선택된 시공코드가 없습니다.</td></tr>'; bridgeBody.innerHTML = '<tr><td colspan="19" class="empty">선택된 시공코드가 없습니다.</td></tr>';
@@ -1148,10 +1148,10 @@
} }
detailMeta.textContent = refresh detailMeta.textContent = refresh
? 'ERP 계약정보를 DB로 동기화하는 중입니다.' ? 'ERP 계약정보를 DB로 동기화하는 중입니다.'
: 'DB에 저장된 계약정보를 불러오는 중입니다.'; : 'DB 캐시에 저장된 계약정보를 불러오는 중입니다.';
bridgeMeta.textContent = refresh bridgeMeta.textContent = refresh
? 'ERP 공사규모와 공사개요를 동기화하는 중입니다.' ? 'ERP 공사규모와 공사개요를 동기화하는 중입니다.'
: 'DB에 저장된 공사규모와 공사개요를 불러오는 중입니다.'; : 'DB 캐시에 저장된 공사규모와 공사개요를 불러오는 중입니다.';
detailSyncButton.disabled = true; detailSyncButton.disabled = true;
try { try {
const detailUrl = `/api/erp-contract-detail?page=const&projectCode=${encodeURIComponent(projectCode)}&projectName=${encodeURIComponent(projectName || '')}&refresh=${refresh ? '1' : '0'}`; const detailUrl = `/api/erp-contract-detail?page=const&projectCode=${encodeURIComponent(projectCode)}&projectName=${encodeURIComponent(projectName || '')}&refresh=${refresh ? '1' : '0'}`;
@@ -1168,37 +1168,21 @@
if (!detailResponse.ok) { if (!detailResponse.ok) {
if (!refresh && detailResponse.status === 404) { if (!refresh && detailResponse.status === 404) {
return loadDetail(projectCode, projectName, true); state.detail = null;
state.bridgeOverviews = overviewResponse.ok ? (Array.isArray(overviewData.overviews) ? overviewData.overviews : []) : [];
state.budgetPlan = budgetPlanResponse.ok ? (budgetPlanData.plan || null) : null;
detailTitle.textContent = `${projectName || '-'} [${projectCode}]`;
detailMeta.textContent = 'DB 캐시에 계약정보가 없습니다. 새 정보 다시 가져오기를 누르면 ERP에서 수집합니다.';
detailBody.innerHTML = '<tr><td colspan="2" class="empty">저장된 계약정보가 없습니다. 최신 정보가 필요하면 오른쪽 상단 버튼을 눌러주세요.</td></tr>';
renderMergedBridgeRows([], state.bridgeOverviews || [], state.budgetPlan);
return;
} }
throw new Error(data.error || '계약정보 조회에 실패했습니다.'); throw new Error(data.error || '계약정보 조회에 실패했습니다.');
} }
state.detail = data.detail || null; state.detail = data.detail || null;
if (!refresh && (!state.detail || !Array.isArray(state.detail.scaleRows) || !state.detail.scaleRows.length)) {
return loadDetail(projectCode, projectName, true);
}
state.bridgeOverviews = Array.isArray(overviewData.overviews) ? overviewData.overviews : []; state.bridgeOverviews = Array.isArray(overviewData.overviews) ? overviewData.overviews : [];
state.budgetPlan = budgetPlanResponse.ok ? (budgetPlanData.plan || null) : null; state.budgetPlan = budgetPlanResponse.ok ? (budgetPlanData.plan || null) : null;
if (!refresh && !state.bridgeOverviews.length) {
const freshOverviewResponse = await fetch(
`/api/erp-bridge-overviews?page=const&projectCode=${encodeURIComponent(projectCode)}&projectName=${encodeURIComponent(projectName || '')}&refresh=1`,
{ cache: 'no-store' }
);
const freshOverviewData = await freshOverviewResponse.json();
if (freshOverviewResponse.ok) {
state.bridgeOverviews = Array.isArray(freshOverviewData.overviews) ? freshOverviewData.overviews : [];
}
}
if (!refresh && !state.budgetPlan) {
const freshPlanResponse = await fetch(
`/api/erp-budget-plan?page=const&projectCode=${encodeURIComponent(projectCode)}&projectName=${encodeURIComponent(projectName || '')}&refresh=1`,
{ cache: 'no-store' }
);
const freshPlanData = await freshPlanResponse.json();
if (freshPlanResponse.ok) {
state.budgetPlan = freshPlanData.plan || null;
}
}
if (state.selectedRow && state.detail) { if (state.selectedRow && state.detail) {
state.selectedRow.contractType = state.detail.contractType || state.selectedRow.contractType || ''; state.selectedRow.contractType = state.detail.contractType || state.selectedRow.contractType || '';
state.selectedRow.applicationType = Array.from(new Set( state.selectedRow.applicationType = Array.from(new Set(

View File

@@ -18,7 +18,7 @@ class ProjectCodeViewerHandler(base.Handler):
if __name__ == '__main__': if __name__ == '__main__':
os.makedirs(base.BASE_DIR, exist_ok=True) os.makedirs(base.BASE_DIR, exist_ok=True)
try: try:
with sqlite3.connect(base.DB_PATH, timeout=30) as conn: with base.open_db_connection() as conn:
base.init_db(conn) base.init_db(conn)
except sqlite3.OperationalError as error: except sqlite3.OperationalError as error:
if 'locked' in str(error).lower(): if 'locked' in str(error).lower():