#!/usr/bin/env python3
import json
import math
import os
import sqlite3
import socket
import traceback
import re
import csv
import requests
import html as html_lib
import threading
import uuid
import subprocess
from pathlib import Path
from datetime import datetime, timedelta, time
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
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')
INDEX_HTML = os.path.join(BASE_DIR, 'index.html')
DETAIL_HTML = os.path.join(BASE_DIR, 'detail-view.html')
DETAIL_PROJECT_HTML = os.path.join(BASE_DIR, 'detail-view-project.html')
MYSQL_PREVIEW_HTML = os.path.join(BASE_DIR, 'mysql-preview.html')
PROJECT_CODES_HTML = os.path.join(BASE_DIR, 'project-codes.html')
PEOPLE_UNIFIED_HTML = os.path.join(BASE_DIR, 'people-unified.html')
SALARY_AVG_CSV_PATH = os.path.join(os.path.dirname(BASE_DIR), 'erp_salary_avg_by_title_year_2026-05-27.csv')
# MySQL direct source (user requested)
MYSQL_HOST = os.environ.get('MYSQL_HOST', '172.16.42.111')
MYSQL_USER = os.environ.get('MYSQL_USER', 'root')
MYSQL_PASSWORD = os.environ.get('MYSQL_PASSWORD', 'hanmacerp!')
MYSQL_DB = os.environ.get('MYSQL_DB', 'jangheon_manhour')
MYSQL_PORT = int(os.environ.get('MYSQL_PORT', '3306'))
ERP_BASE_URL = os.environ.get('ERP_BASE_URL', 'http://erp.jangheon.co.kr/projt_mng')
ERP_LOGIN_ID = os.environ.get('ERP_LOGIN_ID', 'g25001')
ERP_LOGIN_PW = os.environ.get('ERP_LOGIN_PW', '00000')
INTRANET_BASE_URL = os.environ.get('INTRANET_BASE_URL', 'http://erp.jangheon.co.kr/intranet/')
INTRANET_LOGIN_ID = os.environ.get('INTRANET_LOGIN_ID', 'G25001')
INTRANET_LOGIN_PW = os.environ.get('INTRANET_LOGIN_PW', '00000')
HOLIDAY_DATES = {
'2020-01-01', '2020-01-24', '2020-01-25', '2020-01-26', '2020-01-27',
'2020-03-01', '2020-04-15', '2020-04-30', '2020-05-05', '2020-06-06',
'2020-08-15', '2020-08-17', '2020-09-30', '2020-10-01', '2020-10-02',
'2020-10-03', '2020-10-09', '2020-12-25',
}
SITE_SYNC_JOBS = {}
SITE_SYNC_LOCK = threading.Lock()
DB_INIT_LOCK = threading.Lock()
DB_SCHEMA_READY = False
DB_WRITE_LOCK = threading.Lock()
def configure_sqlite_connection(conn):
# Docker에서 8091/8092가 같은 SQLite 파일을 bind mount로 공유한다.
# WAL/SHM 파일이 남은 상태에서 컨테이너 재시작/복구가 섞이면 malformed가 반복될 수 있어
# 운영 모드는 단일 DB 파일 중심의 DELETE journal로 둔다.
conn.execute('PRAGMA journal_mode=DELETE')
conn.execute('PRAGMA synchronous=FULL')
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 run_db_write(write_fn, retries=4, delay=0.2):
last_error = None
for attempt in range(retries):
try:
with DB_WRITE_LOCK:
with open_db_connection() as conn:
ensure_db_schema(conn)
return write_fn(conn)
except sqlite3.OperationalError as error:
last_error = error
if 'locked' not in str(error).lower() or attempt == retries - 1:
raise
time.sleep(delay * (attempt + 1))
if last_error:
raise last_error
raise RuntimeError('DB write failed')
def init_db(conn):
global DB_SCHEMA_READY
configure_sqlite_connection(conn)
conn.executescript(
'''
CREATE TABLE IF NOT EXISTS member (
MemberNo TEXT PRIMARY KEY,
korName TEXT,
rankCode TEXT DEFAULT '',
rankName TEXT DEFAULT '',
groupCode TEXT DEFAULT '',
teamName TEXT DEFAULT '',
retireFlag TEXT DEFAULT '',
isRetired INTEGER DEFAULT 0
);
CREATE TABLE IF NOT EXISTS dallyproject (
id INTEGER PRIMARY KEY AUTOINCREMENT,
MemberNo TEXT,
EntryTime TEXT,
EntryPCode TEXT,
EntryJobCode TEXT,
EntryJob TEXT,
LeaveTime TEXT,
LeavePCode TEXT,
LeaveJobCode TEXT,
LeaveJob TEXT,
OverTime TEXT,
ConnectIP TEXT,
OverWorkIP TEXT,
EndWorkIP TEXT,
SortKey TEXT,
Note TEXT,
modify TEXT,
ConfirmDate TEXT,
WorkDate TEXT,
TotalHours REAL DEFAULT 0,
RegularHours REAL DEFAULT 0,
OvertimeHours REAL DEFAULT 0,
BusinessTripHours REAL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS worker_tardy (
MemberNo TEXT,
s_date TEXT,
e_date TEXT,
tardy_h INTEGER,
tardy_m INTEGER
);
CREATE TABLE IF NOT EXISTS project_alias (
projectCode TEXT PRIMARY KEY,
shortName TEXT
);
CREATE TABLE IF NOT EXISTS erp_project_alias_cache (
sourcePage TEXT NOT NULL,
projectCode TEXT NOT NULL,
shortName TEXT NOT NULL DEFAULT '',
syncedAt TEXT NOT NULL,
PRIMARY KEY (sourcePage, projectCode)
);
CREATE TABLE IF NOT EXISTS site_worksheet_record (
projectCode TEXT,
workDate TEXT,
memberNo TEXT,
korName TEXT,
jobType TEXT DEFAULT '',
workText TEXT DEFAULT '',
note TEXT DEFAULT '',
personCount REAL DEFAULT 0,
PRIMARY KEY (projectCode, workDate, memberNo, korName)
);
CREATE TABLE IF NOT EXISTS site_worksheet_sync (
memberNo TEXT,
projectCode TEXT,
yearMonth TEXT,
syncedAt TEXT,
PRIMARY KEY (memberNo, projectCode, yearMonth)
);
CREATE TABLE IF NOT EXISTS site_worksheet_day_sync (
projectCode TEXT,
workDate TEXT,
syncedAt TEXT,
PRIMARY KEY (projectCode, workDate)
);
CREATE TABLE IF NOT EXISTS site_worksheet_worker_cache (
projectCode TEXT,
workDate TEXT,
korName TEXT,
jobType TEXT DEFAULT '',
workText TEXT DEFAULT '',
note TEXT DEFAULT '',
personCount REAL DEFAULT 0,
syncedAt TEXT,
PRIMARY KEY (projectCode, workDate, korName, jobType, workText, note)
);
CREATE TABLE IF NOT EXISTS site_worksheet_menu_sync (
projectCode TEXT,
workDate TEXT,
selMenu TEXT,
syncedAt TEXT,
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 (
memberNo TEXT,
workDate TEXT,
korName TEXT DEFAULT '',
teamName TEXT DEFAULT '',
rankName TEXT DEFAULT '',
sqlHours REAL DEFAULT 0,
sqlRegularHours REAL DEFAULT 0,
sqlOvertimeHours REAL DEFAULT 0,
sqlProjectCodes TEXT DEFAULT '',
siteCount REAL DEFAULT 0,
siteProjectCodes TEXT DEFAULT '',
siteWorkTexts TEXT DEFAULT '',
hasSql INTEGER DEFAULT 0,
hasSite INTEGER DEFAULT 0,
PRIMARY KEY (memberNo, workDate)
);
CREATE TABLE IF NOT EXISTS work_calendar_detail (
id INTEGER PRIMARY KEY AUTOINCREMENT,
source TEXT,
memberNo TEXT,
workDate TEXT,
projectCode TEXT DEFAULT '',
projectName TEXT DEFAULT '',
workText TEXT DEFAULT '',
jobType TEXT DEFAULT '',
hours REAL DEFAULT 0,
regularHours REAL DEFAULT 0,
overtimeHours REAL DEFAULT 0,
personCount REAL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS erp_project_code_cache (
sourcePage TEXT NOT NULL,
projectCode TEXT NOT NULL,
projectName TEXT NOT NULL,
syncedAt TEXT NOT NULL,
PRIMARY KEY (sourcePage, projectCode)
);
CREATE TABLE IF NOT EXISTS erp_contract_detail_cache (
sourcePage TEXT NOT NULL,
projectCode TEXT NOT NULL,
projectName TEXT NOT NULL DEFAULT '',
businessCode TEXT DEFAULT '',
contractName TEXT DEFAULT '',
siteLocation TEXT DEFAULT '',
clientName TEXT DEFAULT '',
finalContractAmountText TEXT DEFAULT '',
finalContractAmountValue INTEGER DEFAULT 0,
contractType TEXT DEFAULT '',
rawContractJson TEXT DEFAULT '',
syncedAt TEXT NOT NULL,
PRIMARY KEY (sourcePage, projectCode)
);
CREATE TABLE IF NOT EXISTS erp_bridge_overview_cache (
sourcePage TEXT NOT NULL,
projectCode TEXT NOT NULL,
bridgeNo INTEGER NOT NULL,
bridgeName TEXT DEFAULT '',
bridgeDisplayName TEXT DEFAULT '',
applicationType TEXT DEFAULT '',
constructionStatus TEXT DEFAULT '',
constructionPeriod TEXT DEFAULT '',
spanLengthUp TEXT DEFAULT '',
spanLengthDown TEXT DEFAULT '',
widthUp TEXT DEFAULT '',
widthDown TEXT DEFAULT '',
girderHeightCenter TEXT DEFAULT '',
girderHeightSupport TEXT DEFAULT '',
spanCompositionUp TEXT DEFAULT '',
spanCompositionDown TEXT DEFAULT '',
bridgeLocation TEXT DEFAULT '',
remarks TEXT DEFAULT '',
rawOverviewJson TEXT DEFAULT '',
syncedAt TEXT NOT NULL,
PRIMARY KEY (sourcePage, projectCode, bridgeNo)
);
CREATE TABLE IF NOT EXISTS erp_budget_plan_cache (
sourcePage TEXT NOT NULL,
projectCode TEXT NOT NULL,
projectName TEXT DEFAULT '',
integratedDocTitle TEXT DEFAULT '',
integratedDocIdx INTEGER DEFAULT 0,
totalInputDays TEXT DEFAULT '',
totalRebarTon TEXT DEFAULT '',
rawPlanJson TEXT DEFAULT '',
syncedAt TEXT NOT NULL,
PRIMARY KEY (sourcePage, projectCode)
);
CREATE TABLE IF NOT EXISTS erp_linked_code_cache (
sourcePage TEXT NOT NULL,
projectCode TEXT NOT NULL,
projectName TEXT NOT NULL DEFAULT '',
businessCode TEXT DEFAULT '',
salesCode TEXT DEFAULT '',
salesName TEXT DEFAULT '',
designCode TEXT DEFAULT '',
designName TEXT DEFAULT '',
matchedBy TEXT DEFAULT '',
syncedAt TEXT NOT NULL,
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);
'''
)
# Backward-compatible migration for existing DB files.
cols = {row[1] for row in conn.execute("PRAGMA table_info(dallyproject)").fetchall()}
mcols = {row[1] for row in conn.execute("PRAGMA table_info(member)").fetchall()}
if 'rankCode' not in mcols:
conn.execute("ALTER TABLE member ADD COLUMN rankCode TEXT DEFAULT ''")
if 'rankName' not in mcols:
conn.execute("ALTER TABLE member ADD COLUMN rankName TEXT DEFAULT ''")
if 'groupCode' not in mcols:
conn.execute("ALTER TABLE member ADD COLUMN groupCode TEXT DEFAULT ''")
if 'teamName' not in mcols:
conn.execute("ALTER TABLE member ADD COLUMN teamName TEXT DEFAULT ''")
if 'retireFlag' not in mcols:
conn.execute("ALTER TABLE member ADD COLUMN retireFlag TEXT DEFAULT ''")
if 'isRetired' not in mcols:
conn.execute("ALTER TABLE member ADD COLUMN isRetired INTEGER DEFAULT 0")
if 'WorkDate' not in cols:
conn.execute("ALTER TABLE dallyproject ADD COLUMN WorkDate TEXT")
if 'TotalHours' not in cols:
conn.execute("ALTER TABLE dallyproject ADD COLUMN TotalHours REAL DEFAULT 0")
if 'RegularHours' not in cols:
conn.execute("ALTER TABLE dallyproject ADD COLUMN RegularHours REAL DEFAULT 0")
if 'OvertimeHours' not in cols:
conn.execute("ALTER TABLE dallyproject ADD COLUMN OvertimeHours REAL DEFAULT 0")
if 'BusinessTripHours' not in cols:
conn.execute("ALTER TABLE dallyproject ADD COLUMN BusinessTripHours REAL DEFAULT 0")
conn.execute("CREATE INDEX IF NOT EXISTS idx_dally_workdate ON dallyproject(WorkDate)")
conn.execute("CREATE INDEX IF NOT EXISTS idx_project_alias_code ON project_alias(projectCode)")
conn.execute("CREATE INDEX IF NOT EXISTS idx_erp_project_alias_cache_page ON erp_project_alias_cache(sourcePage, projectCode)")
conn.execute("CREATE INDEX IF NOT EXISTS idx_site_worksheet_member ON site_worksheet_record(memberNo, workDate)")
conn.execute("CREATE INDEX IF NOT EXISTS idx_site_worksheet_name ON site_worksheet_record(korName, workDate)")
conn.execute("CREATE INDEX IF NOT EXISTS idx_site_worksheet_sync_member ON site_worksheet_sync(memberNo, yearMonth)")
conn.execute("CREATE INDEX IF NOT EXISTS idx_site_worksheet_day_sync ON site_worksheet_day_sync(projectCode, workDate)")
conn.execute("CREATE INDEX IF NOT EXISTS idx_site_worksheet_worker_cache ON site_worksheet_worker_cache(projectCode, workDate, korName)")
conn.execute("CREATE INDEX IF NOT EXISTS idx_site_worksheet_menu_sync ON site_worksheet_menu_sync(projectCode, workDate, selMenu)")
conn.execute("CREATE INDEX IF NOT EXISTS idx_work_calendar_day_member ON work_calendar_day(memberNo, workDate)")
conn.execute("CREATE INDEX IF NOT EXISTS idx_work_calendar_detail_member ON work_calendar_detail(memberNo, workDate)")
conn.execute("CREATE INDEX IF NOT EXISTS idx_erp_project_code_cache_page ON erp_project_code_cache(sourcePage, projectCode)")
conn.execute("CREATE INDEX IF NOT EXISTS idx_erp_contract_detail_cache_page ON erp_contract_detail_cache(sourcePage, projectCode)")
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_linked_code_cache_page ON erp_linked_code_cache(sourcePage, projectCode)")
conn.commit()
DB_SCHEMA_READY = True
def ensure_db_schema(conn):
global DB_SCHEMA_READY
if DB_SCHEMA_READY:
return True
with DB_INIT_LOCK:
if DB_SCHEMA_READY:
return True
try:
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):
return _as_text(v).strip().upper()
def _norm_key(k):
return re.sub(r'[^a-z0-9]+', '', str(k or '').lower())
def _as_text(v):
if v is None:
return ''
if isinstance(v, bytes):
for enc in ('utf-8', 'cp949', 'euc-kr', 'latin1'):
try:
return v.decode(enc, errors='ignore')
except Exception:
continue
return ''
return str(v)
def _date_value(v):
m = re.match(r'^(\d{4})-(\d{2})-(\d{2})', _as_text(v).strip())
if not m:
return None
if m.group(0) in ('0000-00-00',):
return None
try:
return datetime(int(m.group(1)), int(m.group(2)), int(m.group(3))).date()
except Exception:
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 executablePath = process.env.CHROME_PATH || '/usr/bin/google-chrome';
const browser = await chromium.launch({
headless: true,
executablePath,
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()
node_paths = [
os.path.join(BASE_DIR, 'node_modules'),
'/home/hyein/.npm/_npx/9833c18b2d85bc59/node_modules',
]
existing_node_paths = [path for path in node_paths if os.path.isdir(path)]
if existing_node_paths:
env['NODE_PATH'] = ':'.join(existing_node_paths + ([env['NODE_PATH']] if env.get('NODE_PATH') else []))
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):
# 사업관리 원본은 이름만 있어 재입사자가 같은 이름으로 중복 매칭될 수 있다.
# 같은 이름의 사번이 여러 개이면 작업일 기준으로 퇴사 전 사번/현재 사번을 나눠 붙인다.
members = name_to_members.get(_as_text(kor_name).strip()) or []
if len(members) <= 1:
return members
day = _date_value(work_date)
if not day:
return members
retired_valid = [m for m in members if m.get('retireDate') and day <= m['retireDate']]
if retired_valid:
return retired_valid
return [m for m in members if not m.get('retireDate') or day <= m['retireDate']]
def cleanup_site_records_after_retire(conn):
# 사업관리 원본은 이름 중심이라 퇴사자와 동명이인/재입사자를 잘못 매칭할 수 있다.
# member 퇴사일 이후의 사업관리 기록은 해당 퇴사 사번에서 무조건 제거한다.
conn.execute(
'''
DELETE FROM site_worksheet_record
WHERE rowid IN (
SELECT s.rowid
FROM site_worksheet_record s
JOIN member oldm ON oldm.MemberNo = s.memberNo
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))
)
'''
)
conn.commit()
def _find_col_key(cols, aliases):
if not cols:
return ''
alias_set = {_norm_key(a) for a in aliases}
for k in cols.keys():
if _norm_key(k) in alias_set:
return k
return ''
def _norm_code_token(v):
s = _as_text(v).strip()
if not s:
return ''
if re.fullmatch(r'\d+', s):
return str(int(s))
return s.upper()
def insert_member_rows(conn, rows):
conn.execute('DELETE FROM member')
conn.executemany(
'INSERT OR REPLACE INTO member (MemberNo, korName, rankCode, rankName, groupCode, teamName, retireFlag, isRetired) VALUES (?, ?, ?, ?, ?, ?, ?, ?)',
rows,
)
def parse_retired_flag(raw_value, col_key=''):
s = _as_text(raw_value).strip().lower()
if not s:
return 0
col_key = _norm_key(col_key)
# LeaveDate 계열: 유효 날짜가 오늘 이전/같으면 퇴사로 간주
if col_key in ('leavedate', 'retiredate', 'quitdate', 'resigndate'):
if s in ('0000-00-00', '0000-00-00 00:00:00'):
return 0
m = re.match(r'^(\d{4})-(\d{2})-(\d{2})', s)
if m:
try:
d = datetime(int(m.group(1)), int(m.group(2)), int(m.group(3))).date()
return 1 if d <= datetime.now().date() else 0
except Exception:
return 0
return 0
# useYn/workYn 계열은 N/0 이 퇴사 의미인 경우가 많음
if col_key in ('useyn', 'workyn', 'isuse', 'iswork'):
if s in ('n', '0', 'false', 'inactive'):
return 1
return 0
if s in ('재직', '근무', '정상', 'active'):
return 0
retired_tokens = ('y', '1', 'true', 'retire', 'resign', '퇴사', '퇴직', 'inactive', 'leave', 'quit')
return 1 if any(tok in s for tok in retired_tokens) else 0
def update_member_ranks(conn, rank_map):
if not rank_map:
return 0
rows = [(rank_map[k], k) for k in rank_map.keys()]
conn.executemany('UPDATE member SET rankName = ? WHERE MemberNo = ?', rows)
conn.commit()
return len(rows)
def update_member_ranks_by_rankcode(conn, code_to_name):
if not code_to_name:
return 0
cur = conn.execute('SELECT MemberNo, IFNULL(rankCode, "") FROM member')
updates = []
for mno, rc in cur.fetchall():
key = _as_text(rc).strip()
if not key:
continue
rn = _as_text(code_to_name.get(key) or code_to_name.get(_norm_code_token(key))).strip()
if rn:
updates.append((rn, mno))
if updates:
conn.executemany('UPDATE member SET rankName = ? WHERE MemberNo = ?', updates)
conn.commit()
return len(updates)
def insert_daily_rows(conn, rows):
conn.executemany(
'''
INSERT INTO dallyproject (
MemberNo, EntryTime, EntryPCode, EntryJobCode, EntryJob,
LeaveTime, LeavePCode, LeaveJobCode, LeaveJob, OverTime,
ConnectIP, OverWorkIP, EndWorkIP, SortKey, Note, modify, ConfirmDate,
WorkDate, TotalHours, RegularHours, OvertimeHours, BusinessTripHours
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
''',
rows,
)
def parse_dt(value):
s = _as_text(value).strip()
if not s:
return None
fmts = ('%Y-%m-%d %H:%M:%S', '%Y-%m-%d %H:%M')
for f in fmts:
try:
return datetime.strptime(s, f)
except Exception:
pass
return None
def parse_d(value):
s = _as_text(value).strip()
if not s:
return None
for f in ('%Y-%m-%d', '%Y/%m/%d'):
try:
return datetime.strptime(s, f).date()
except Exception:
pass
return None
def insert_worker_tardy(conn, rows):
conn.execute('DELETE FROM worker_tardy')
if rows:
conn.executemany(
'INSERT INTO worker_tardy (MemberNo, s_date, e_date, tardy_h, tardy_m) VALUES (?, ?, ?, ?, ?)',
rows
)
def build_tardy_map(conn):
cur = conn.execute('SELECT MemberNo, s_date, e_date, tardy_h, tardy_m FROM worker_tardy')
out = {}
for mno, sd, ed, th, tm in cur.fetchall():
sdd = parse_d(sd)
edd = parse_d(ed)
if not sdd or not edd:
continue
out.setdefault(mno, []).append((sdd, edd, int(th or 9), int(tm or 0)))
for k in out:
out[k].sort(key=lambda x: (x[0], x[1]))
return out
def tardy_start_for_day(tardy_map, member_no, day):
rules = tardy_map.get(member_no, [])
for sd, ed, th, tm in rules:
if sd <= day <= ed:
return th, tm
return 9, 0
def overlap_hours(a_start, a_end, b_start, b_end):
s = max(a_start, b_start)
e = min(a_end, b_end)
if e <= s:
return 0.0
return (e - s).total_seconds() / 3600.0
def _is_business_trip_text(*texts):
for t in texts:
s = _as_text(t).strip()
if '출장' in s:
return True
return False
def compute_hours(
entry_time, leave_time, overtime_time, member_no, tardy_map,
entry_job='', leave_job='', entry_job_code='', leave_job_code=''
):
def approved_by_10min(raw_hours, min_hours, max_hours):
approved = math.floor(max(0.0, raw_hours) * 6.0) / 6.0
if approved < min_hours:
return 0.0
return min(approved, max_hours)
ent = parse_dt(entry_time)
lea = parse_dt(leave_time)
is_trip = _is_business_trip_text(entry_job, leave_job, entry_job_code, leave_job_code)
if not ent:
return '', 0.0, 0.0, 0.0, 0.0
# 출장: LeaveTime 없어도 8시간 인정(별도 컬럼 분리)
if not lea and is_trip:
day = ent.date()
return day.isoformat(), 8.0, 0.0, 0.0, 8.0
if not lea:
return '', 0.0, 0.0, 0.0, 0.0
if lea < ent:
lea = lea + timedelta(days=1)
base_hours = max(0.0, (lea - ent).total_seconds() / 3600.0)
ot_raw = (overtime_time or '').strip()
extra_hours = 0.0
day = ent.date()
day_key = day.isoformat()
is_weekend = day.weekday() >= 5 # 5: Sat, 6: Sun
is_holiday = day_key in HOLIDAY_DATES
is_holiday_like = is_weekend or is_holiday
th, tm = tardy_start_for_day(tardy_map, member_no, day)
regular_start = datetime.combine(day, time(th, tm))
# 정규 8시간 + 점심 1시간을 포함한 기준 퇴근시각
regular_end = regular_start + timedelta(hours=9)
# 평일 OT는 LeaveTime - OverTime 을 우선 기준으로 본다.
if ot_raw and not ot_raw.startswith('0000-00-00'):
ot_dt = parse_dt(ot_raw)
if ot_dt:
extra_hours = max(0.0, (lea - ot_dt).total_seconds() / 3600.0)
elif not is_weekend and not is_holiday:
# 평일인데 OverTime 값이 비어 있어도, 실제 퇴근이 정규 종료 이후면
# legacy 데이터 보정을 위해 추가근무 후보로 본다.
extra_hours = max(0.0, (lea - regular_end).total_seconds() / 3600.0)
# Regular work policy:
# - Weekday only
# - Deduct lunch overlap (12:00~13:00)
# - Convert to whole hours only (no 0.5 etc., floor)
# - Cap at 8 hours/day
lunch_start = datetime.combine(day, time(12, 0))
lunch_end = datetime.combine(day, time(13, 0))
lunch_overlap = overlap_hours(ent, lea, lunch_start, lunch_end)
regular_raw = max(0.0, base_hours - lunch_overlap)
regular = 0.0 if is_holiday_like else float(min(8, int(regular_raw)))
# Overtime is counted only from OverTime field difference (OverTime - LeaveTime).
# Overtime policy by date:
# - Weekday: approve in 10-minute units, only when >= 2h, capped at 3h
# - Weekend/holiday: use worked hours, if >= 3h then cap at 5h (otherwise 0h)
if is_holiday_like:
holiday_hours = max(0.0, regular_raw)
overtime = approved_by_10min(holiday_hours, 3.0, 5.0)
else:
overtime = approved_by_10min(extra_hours, 2.0, 3.0)
business_trip = 0.0
# 출장 행은 근무시간(regular)을 출장시간으로 분리 표기
# 총시간(total)은 유지되도록 regular -> business_trip 이동.
if is_trip:
business_trip = regular
regular = 0.0
total = regular + overtime + business_trip
return day.isoformat(), round(total, 2), round(regular, 2), round(overtime, 2), round(business_trip, 2)
def backfill_daily_hours_if_needed(conn):
# Recalculate all workable rows to keep rules consistent across code updates.
need = conn.execute(
'''
SELECT COUNT(*)
FROM dallyproject
WHERE IFNULL(EntryTime, '') <> ''
AND IFNULL(LeaveTime, '') <> ''
'''
).fetchone()[0]
if need == 0:
return 0
tardy_map = build_tardy_map(conn)
cur = conn.execute('SELECT id, MemberNo, EntryTime, LeaveTime, OverTime FROM dallyproject')
updates = []
cur = conn.execute('SELECT id, MemberNo, EntryTime, LeaveTime, OverTime, EntryJob, LeaveJob, EntryJobCode, LeaveJobCode FROM dallyproject')
updates = []
for rid, mno, ent, lea, ot, entry_job, leave_job, entry_job_code, leave_job_code in cur.fetchall():
work_date, total_h, regular_h, overtime_h, business_trip_h = compute_hours(
ent, lea, ot, mno or '', tardy_map, entry_job or '', leave_job or '', entry_job_code or '', leave_job_code or ''
)
updates.append((work_date, total_h, regular_h, overtime_h, business_trip_h, rid))
if len(updates) >= 5000:
conn.executemany(
'UPDATE dallyproject SET WorkDate=?, TotalHours=?, RegularHours=?, OvertimeHours=?, BusinessTripHours=? WHERE id=?',
updates
)
updates = []
if updates:
conn.executemany(
'UPDATE dallyproject SET WorkDate=?, TotalHours=?, RegularHours=?, OvertimeHours=?, BusinessTripHours=? WHERE id=?',
updates
)
conn.commit()
return need
def load_project_alias_entries_from_erp():
login_url = ERP_BASE_URL.rstrip('/') + '/sys/controller/common/main_controller.php'
s = requests.Session()
login_payload = {
'ActionMode': 'LoginCheck',
'memberID': ERP_LOGIN_ID,
'LoginID': ERP_LOGIN_ID,
'passwd': ERP_LOGIN_PW,
'CheckSave': '',
'login': '1',
}
r = s.post(login_url, data=login_payload, timeout=20)
if r.text.strip().lower() != 'success':
raise RuntimeError(f'ERP login failed: {r.text[:120]}')
pages = ['sales', 'design', 'const', 'make', 'research']
out = {}
for page in pages:
payload = {
'ActionMode': 'getMain',
'SubAction': 'getProjectList',
'page': page,
'searchText': '',
}
rr = s.post(login_url + '?', data=payload, timeout=20)
body = rr.text.strip()
if not body:
continue
data = json.loads(body)
for item in data.get('list_data', []) or []:
code = _as_text(item.get('ProjectCode')).strip().upper()
nick = _as_text(item.get('ProjectNickname')).strip()
if code and nick:
out[code] = {'sourcePage': page, 'projectCode': code, 'shortName': nick}
return sorted(out.values(), key=lambda item: (item['projectCode'], item['sourcePage']))
def load_project_alias_from_erp():
return [(item['projectCode'], item['shortName']) for item in load_project_alias_entries_from_erp()]
def erp_login_session():
login_url = ERP_BASE_URL.rstrip('/') + '/sys/controller/common/main_controller.php'
s = requests.Session()
login_payload = {
'ActionMode': 'LoginCheck',
'memberID': ERP_LOGIN_ID,
'LoginID': ERP_LOGIN_ID,
'passwd': ERP_LOGIN_PW,
'CheckSave': '',
'login': '1',
}
r = s.post(login_url, data=login_payload, timeout=20)
if r.text.strip().lower() != 'success':
raise RuntimeError(f'ERP login failed: {r.text[:120]}')
return s
def fetch_erp_project_codes(page='const', search_text=''):
section = _as_text(page).strip().lower() or 'const'
allowed_pages = {'sales', 'design', 'const', 'make', 'research'}
if section not in allowed_pages:
raise ValueError('허용되지 않은 ERP 페이지입니다.')
login_url = ERP_BASE_URL.rstrip('/') + '/sys/controller/common/main_controller.php'
session = erp_login_session()
payload = {
'ActionMode': 'getMain',
'SubAction': 'getProjectList',
'page': section,
'searchText': _as_text(search_text).strip(),
}
response = session.post(login_url + '?', data=payload, timeout=20)
body = response.text.strip()
if not body:
return {'page': section, 'rows': []}
data = json.loads(body)
rows = []
for item in data.get('list_data', []) or []:
code = _as_text(item.get('ProjectCode')).strip()
name = _as_text(item.get('ProjectNickname') or item.get('ProjectName')).strip()
if code and name:
rows.append({'projectCode': code, 'projectName': name})
return {'page': section, 'rows': rows}
def replace_erp_project_code_cache(conn, source_page, rows):
page_name = _as_text(source_page).strip().lower() or 'const'
synced_at = datetime.now().isoformat(timespec='seconds')
conn.execute('DELETE FROM erp_project_code_cache WHERE sourcePage = ?', (page_name,))
if rows:
conn.executemany(
'''
INSERT OR REPLACE INTO erp_project_code_cache
(sourcePage, projectCode, projectName, syncedAt)
VALUES (?, ?, ?, ?)
''',
[(page_name, row['projectCode'], row['projectName'], synced_at) for row in rows],
)
conn.commit()
return {'sourcePage': page_name, 'count': len(rows), 'syncedAt': synced_at}
def get_erp_project_code_cache(conn, source_page='const'):
page_name = _as_text(source_page).strip().lower() or 'const'
rows = [
{
'projectCode': row[0],
'projectName': row[1],
'businessCode': row[2] or '',
'siteLocation': row[3] or '',
'clientName': row[4] or '',
'finalContractAmountText': row[5] or '',
'contractType': row[6] or '',
'applicationType': row[7] or '',
'syncedAt': row[8] or '',
}
for row in conn.execute(
'''
SELECT p.projectCode,
p.projectName,
COALESCE(d.businessCode, '') AS businessCode,
COALESCE(d.siteLocation, '') AS siteLocation,
COALESCE(d.clientName, '') AS clientName,
COALESCE(d.finalContractAmountText, '') AS finalContractAmountText,
COALESCE(d.contractType, '') AS contractType,
COALESCE((
SELECT GROUP_CONCAT(applicationType, '||')
FROM (
SELECT DISTINCT b.applicationType AS applicationType
FROM erp_bridge_overview_cache b
WHERE b.sourcePage = p.sourcePage
AND b.projectCode = p.projectCode
AND IFNULL(b.applicationType, '') <> ''
ORDER BY b.applicationType
)
), '') AS applicationType,
COALESCE(d.syncedAt, '') AS detailSyncedAt
FROM erp_project_code_cache p
LEFT JOIN erp_contract_detail_cache d
ON d.sourcePage = p.sourcePage
AND d.projectCode = p.projectCode
WHERE p.sourcePage = ?
ORDER BY p.projectCode ASC
''',
(page_name,),
).fetchall()
]
synced_at = conn.execute(
'SELECT MAX(syncedAt) FROM erp_project_code_cache WHERE sourcePage = ?',
(page_name,),
).fetchone()[0]
return {'sourcePage': page_name, 'rows': rows, 'syncedAt': synced_at or ''}
def _parse_amount_text(value):
text = _as_text(value).strip()
digits = re.sub(r'[^0-9-]', '', text)
if not digits:
return 0
try:
return int(digits)
except Exception:
return 0
def _extract_table_rows(html):
tables = []
for table_html in re.findall(r'
', html or '', re.I | re.S):
rows = []
for tr_html in re.findall(r']*>(.*?)
', table_html, re.I | re.S):
cells = [
_clean_html_text(cell_html)
for cell_html in re.findall(r']*>(.*?)', tr_html, re.I | re.S)
]
if cells:
rows.append(cells)
if rows:
tables.append(rows)
return tables
def _find_table_rows_by_labels(tables, required_labels):
required = [_norm_key(label) for label in required_labels]
for rows in tables:
flat = ' '.join(' '.join(row) for row in rows)
norm_flat = _norm_key(flat)
if all(label in norm_flat for label in required):
return rows
return []
def _find_contract_scale_table_rows(tables):
required_headers = [_norm_key(label) for label in ('No', '교량명', '시공상태', 'GIRDER')]
for rows in tables:
if not rows:
continue
header_line = ' '.join(rows[0])
norm_header = _norm_key(header_line)
if all(label in norm_header for label in required_headers):
return rows
return []
def _label_value_map_from_rows(rows):
out = {}
for row in rows:
index = 0
while index + 1 < len(row):
label = _as_text(row[index]).strip()
value = _as_text(row[index + 1]).strip()
if label:
out[re.sub(r'\s+', '', label)] = value
index += 2
return out
def _parse_contract_scale_rows(rows):
if not rows:
return []
data_rows = []
for row in rows[1:]:
if not row:
continue
first = _as_text(row[0]).strip()
if first in ('No', '조회된내용이없습니다.', '조회된 내용이 없습니다.'):
continue
padded = row + [''] * max(0, 11 - len(row))
data_rows.append({
'rowNo': int(first) if first.isdigit() else len(data_rows) + 1,
'bridgeName': _as_text(padded[1]).strip(),
'constructionStatus': _as_text(padded[2]).strip(),
'statusChange': _as_text(padded[3]).strip(),
'spanLength': _as_text(padded[4]).strip(),
'width': _as_text(padded[5]).strip(),
'girderHeight': _as_text(padded[6]).strip(),
'girderCount': _as_text(padded[7]).strip(),
'crossbeamCount': _as_text(padded[8]).strip(),
'panelCount': _as_text(padded[9]).strip(),
'rebarCount': _as_text(padded[10]).strip(),
})
return data_rows
def _extract_bridge_numbers(html):
numbers = []
for match in re.finditer(r"redrawPage\((\d+)\)", html or '', re.I):
value = int(match.group(1))
if value not in numbers:
numbers.append(value)
return numbers or [1]
def _find_section_rows_by_title(tables, title):
title_key = _norm_key(title)
for rows in tables:
if not rows:
continue
first_cell = _norm_key(rows[0][0] if rows[0] else '')
if title_key in first_cell:
return rows
return []
def _parse_bridge_overview(rows):
overview = {
'bridgeDisplayName': '',
'bridgeName': '',
'applicationType': '',
'constructionStatus': '',
'constructionPeriod': '',
'spanLengthUp': '',
'spanLengthDown': '',
'widthUp': '',
'widthDown': '',
'girderHeightCenter': '',
'girderHeightSupport': '',
'spanCompositionUp': '',
'spanCompositionDown': '',
'bridgeLocation': '',
'remarks': '',
}
if not rows:
return overview
for row in rows:
values = [value.strip() for value in row]
if not values:
continue
label = re.sub(r'\s+', '', values[0])
if label == '교량명':
overview['bridgeDisplayName'] = values[1] if len(values) > 1 else ''
overview['applicationType'] = values[3] if len(values) > 3 else ''
display = overview['bridgeDisplayName']
overview['bridgeName'] = re.sub(r'\s*\([^)]*\)\s*\[[^\]]*\]\s*$', '', display).strip()
elif label == '시공상태':
overview['constructionStatus'] = values[2] if len(values) > 2 else ''
overview['constructionPeriod'] = values[4] if len(values) > 4 else ''
elif label == '연장(m)':
overview['spanLengthUp'] = values[2] if len(values) > 2 else ''
overview['widthUp'] = values[5] if len(values) > 5 else ''
elif label == '하행' and len(values) >= 4 and not overview['spanLengthDown'] and not overview['widthDown']:
overview['spanLengthDown'] = values[1] if len(values) > 1 else ''
overview['widthDown'] = values[3] if len(values) > 3 else ''
elif label == '형고(m)':
overview['girderHeightCenter'] = values[2] if len(values) > 2 else ''
overview['spanCompositionUp'] = values[5] if len(values) > 5 else ''
elif label == '지점부':
overview['girderHeightSupport'] = values[1] if len(values) > 1 else ''
overview['spanCompositionDown'] = values[3] if len(values) > 3 else ''
elif label == '교량위치':
overview['bridgeLocation'] = values[1] if len(values) > 1 else ''
elif label == '특이사항':
overview['remarks'] = values[1] if len(values) > 1 else ''
return overview
def _extract_budget_plan_doc_info(html):
docs = []
for match in re.finditer(r']*onclick="redrawPage2\((\d+)\)"[^>]*>(.*?)', html or '', re.I | re.S):
doc_idx = int(match.group(1))
raw_text = re.sub(r'<[^>]+>', ' ', match.group(2))
title = ' '.join(_as_text(raw_text).split()).strip()
if title:
docs.append({'docIdx': doc_idx, 'title': title})
return docs
def _find_budget_plan_table(rows, title):
title_key = re.sub(r'\s+', '', _as_text(title))
for table in rows:
if not table or not table[0]:
continue
first = re.sub(r'\s+', '', _as_text(table[0][0]))
if first == title_key:
return table
return []
def _parse_budget_plan_summary(rows):
summary = {
'projectNo': '',
'bridgeNames': '',
'clientSales': '',
'contractDate': '',
'contractPeriod': '',
'bridgeDesignAmount': '',
'bridgeContractAmount': '',
'extraAmount': '',
}
if not rows:
return summary
mapping = _label_value_map_from_rows(rows)
summary.update({
'projectNo': mapping.get('ProjectNo', ''),
'bridgeNames': mapping.get('교량명', ''),
'clientSales': mapping.get('발주처/매출처', ''),
'contractDate': mapping.get('계약일', ''),
'contractPeriod': mapping.get('계약기간', ''),
'bridgeDesignAmount': mapping.get('교량설계가', ''),
'bridgeContractAmount': mapping.get('교량계약가', ''),
'extraAmount': mapping.get('ES/추가금액', ''),
})
return summary
def _parse_budget_input_days(rows):
result = {'fabrication': '', 'tensioning': '', 'erection': '', 'panel': '', 'total': ''}
if len(rows) < 3:
return result
header = [re.sub(r'\s+', '', _as_text(cell)) for cell in rows[1]]
values = [_as_text(cell).strip() for cell in rows[2]]
lookup = dict(zip(header, values))
result.update({
'fabrication': lookup.get('제작', ''),
'tensioning': lookup.get('인장', ''),
'erection': lookup.get('거치', ''),
'panel': lookup.get('판넬', ''),
'total': lookup.get('총투입일', ''),
})
return result
def _parse_budget_rebar(rows):
result = {'factory': '', 'site': '', 'total': ''}
if len(rows) < 3:
return result
header = [re.sub(r'\s+', '', _as_text(cell)) for cell in rows[1]]
values = [_as_text(cell).strip() for cell in rows[2]]
lookup = dict(zip(header, values))
result.update({
'factory': lookup.get('공장', ''),
'site': lookup.get('현장', ''),
'total': lookup.get('합계', ''),
})
return result
def _parse_budget_girder_specs(rows):
specs = []
if len(rows) < 2:
return specs
expected_cells = max(len(rows[0]) - 1, 0)
for row in rows[1:]:
if not row:
continue
first = _as_text(row[0]).strip()
if not first or '조회된 내용이 없습니다' in first:
continue
if expected_cells and len(row) < expected_cells:
padded = ([''] * (expected_cells - len(row))) + row
else:
padded = row[:]
padded += [''] * max(0, 7 - len(padded))
specs.append({
'extension': _as_text(padded[0]).strip(),
'width': _as_text(padded[1]).strip(),
'length': _as_text(padded[2]).strip(),
'height': _as_text(padded[3]).strip(),
'quantity': _as_text(padded[4]).strip(),
'formCount': _as_text(padded[5]).strip(),
'remarks': _as_text(padded[6]).strip(),
})
return specs
def _normalize_budget_girder_specs(specs):
normalized = []
previous_extension = ''
previous_width = ''
for row in specs or []:
current = dict(row or {})
if (
current.get('extension')
and current.get('width')
and current.get('length')
and current.get('height')
and not current.get('quantity')
and not current.get('formCount')
):
current = {
**current,
'extension': '',
'width': '',
'length': current.get('extension', ''),
'height': current.get('width', ''),
'quantity': current.get('length', ''),
'formCount': current.get('height', ''),
}
if not _as_text(current.get('extension')).strip() and previous_extension:
current['extension'] = previous_extension
if not _as_text(current.get('width')).strip() and previous_width:
current['width'] = previous_width
previous_extension = _as_text(current.get('extension')).strip() or previous_extension
previous_width = _as_text(current.get('width')).strip() or previous_width
normalized.append(current)
return normalized
def _parse_budget_predeck(rows):
result = {'generalArea': '', 'medianArea': '', 'barrierArea': '', 'remarks': ''}
if len(rows) < 2:
return result
values = rows[1] + [''] * max(0, 4 - len(rows[1]))
result.update({
'generalArea': _as_text(values[0]).strip(),
'medianArea': _as_text(values[1]).strip(),
'barrierArea': _as_text(values[2]).strip(),
'remarks': _as_text(values[3]).strip(),
})
return result
def _parse_budget_crossbeam(rows):
result = []
if len(rows) < 2:
return result
for row in rows[1:]:
if not row:
continue
first = _as_text(row[0]).strip()
if not first or '조회된 내용이 없습니다' in first:
continue
values = row + [''] * max(0, 4 - len(row))
result.append({
'height': _as_text(values[0]).strip(),
'length': _as_text(values[1]).strip(),
'quantity': _as_text(values[2]).strip(),
'remarks': _as_text(values[3]).strip(),
})
return result
def _parse_budget_plan_tables(tables):
summary_rows = _find_budget_plan_table(tables, 'ProjectNo')
input_days_rows = _find_budget_plan_table(tables, '투입일수(일)')
input_rebar_rows = _find_budget_plan_table(tables, '투입철근(Ton)')
girder_rows = _find_budget_plan_table(tables, '거더')
predeck_rows = _find_budget_plan_table(tables, '프리덱')
crossbeam_rows = _find_budget_plan_table(tables, '가로보')
return {
'summary': _parse_budget_plan_summary(summary_rows),
'inputDays': _parse_budget_input_days(input_days_rows),
'inputRebar': _parse_budget_rebar(input_rebar_rows),
'girderSpecs': _parse_budget_girder_specs(girder_rows),
'predeck': _parse_budget_predeck(predeck_rows),
'crossbeam': _parse_budget_crossbeam(crossbeam_rows),
}
def fetch_erp_budget_plan(page='const', project_code='', project_name=''):
section = _as_text(page).strip().lower() or 'const'
code = _as_text(project_code).strip()
short_name = _as_text(project_name).strip()
if not code:
raise ValueError('project_code가 필요합니다.')
session = erp_login_session()
detail_url = ERP_BASE_URL.rstrip('/') + '/sys/controller/const/budget_info_controller.php'
base_params = {
'page': section,
'ProjectCode': code,
'ProjectDvsNm': '시공',
'ProjectNm': short_name,
}
first_response = session.get(detail_url, params={**base_params, 'ActionMode': 'REPORT_1', 'bridgeno': '1'}, timeout=20)
first_html = first_response.content.decode('utf-8', errors='ignore')
bridge_numbers = _extract_bridge_numbers(first_html)
docs = _extract_budget_plan_doc_info(first_html)
integrated_doc = next((doc for doc in docs if doc.get('title', '').startswith('통합')), None)
if len(bridge_numbers) <= 1 or not integrated_doc:
parsed = _parse_budget_plan_tables(_extract_table_rows(first_html))
return {
'sourcePage': section,
'projectCode': code,
'projectName': short_name,
'integratedDocIdx': 0,
'integratedDocTitle': '교량별' if len(bridge_numbers) <= 1 else '',
'summary': parsed['summary'],
'inputDays': parsed['inputDays'],
'inputRebar': parsed['inputRebar'],
'girderSpecs': parsed['girderSpecs'],
'predeck': parsed['predeck'],
'crossbeam': parsed['crossbeam'],
}
integrated_response = session.get(
detail_url,
params={**base_params, 'ActionMode': 'REPORT_2', 'doc_idx': str(integrated_doc['docIdx'])},
timeout=20,
)
html = integrated_response.content.decode('utf-8', errors='ignore')
parsed = _parse_budget_plan_tables(_extract_table_rows(html))
return {
'sourcePage': section,
'projectCode': code,
'projectName': short_name,
'integratedDocIdx': int(integrated_doc['docIdx']),
'integratedDocTitle': integrated_doc['title'],
'summary': parsed['summary'],
'inputDays': parsed['inputDays'],
'inputRebar': parsed['inputRebar'],
'girderSpecs': parsed['girderSpecs'],
'predeck': parsed['predeck'],
'crossbeam': parsed['crossbeam'],
}
def replace_erp_budget_plan_cache(conn, result):
source_page = _as_text(result.get('sourcePage')).strip().lower() or 'const'
project_code = _as_text(result.get('projectCode')).strip()
synced_at = datetime.now().isoformat(timespec='seconds')
payload = {
'summary': result.get('summary') or {},
'inputDays': result.get('inputDays') or {},
'inputRebar': result.get('inputRebar') or {},
'girderSpecs': result.get('girderSpecs') or [],
'predeck': result.get('predeck') or {},
'crossbeam': result.get('crossbeam') or {},
}
conn.execute(
'''
INSERT OR REPLACE INTO erp_budget_plan_cache
(sourcePage, projectCode, projectName, integratedDocTitle, integratedDocIdx, totalInputDays, totalRebarTon, rawPlanJson, syncedAt)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
''',
(
source_page,
project_code,
_as_text(result.get('projectName')).strip(),
_as_text(result.get('integratedDocTitle')).strip(),
int(result.get('integratedDocIdx') or 0),
_as_text((result.get('inputDays') or {}).get('total')).strip(),
_as_text((result.get('inputRebar') or {}).get('total')).strip(),
json.dumps(payload, ensure_ascii=False),
synced_at,
),
)
conn.commit()
return {'sourcePage': source_page, 'projectCode': project_code, 'syncedAt': synced_at}
def get_erp_budget_plan_cache(conn, source_page='const', project_code=''):
source_page = _as_text(source_page).strip().lower() or 'const'
project_code = _as_text(project_code).strip()
row = conn.execute(
'''
SELECT projectName, integratedDocTitle, integratedDocIdx, totalInputDays, totalRebarTon, rawPlanJson, syncedAt
FROM erp_budget_plan_cache
WHERE sourcePage = ? AND projectCode = ?
''',
(source_page, project_code),
).fetchone()
if not row:
return None
payload = json.loads(row[5] or '{}')
girder_specs = _normalize_budget_girder_specs(payload.get('girderSpecs') or [])
return {
'sourcePage': source_page,
'projectCode': project_code,
'projectName': row[0] or '',
'integratedDocTitle': row[1] or '',
'integratedDocIdx': row[2] or 0,
'totalInputDays': row[3] or '',
'totalRebarTon': row[4] or '',
'summary': payload.get('summary') or {},
'inputDays': payload.get('inputDays') or {},
'inputRebar': payload.get('inputRebar') or {},
'girderSpecs': girder_specs,
'predeck': payload.get('predeck') or {},
'crossbeam': payload.get('crossbeam') or {},
'syncedAt': row[6] or '',
}
def fetch_erp_bridge_overviews(page='const', project_code='', project_name=''):
section = _as_text(page).strip().lower() or 'const'
code = _as_text(project_code).strip()
short_name = _as_text(project_name).strip()
if not code:
raise ValueError('project_code가 필요합니다.')
session = erp_login_session()
controller = 'bridge_manage_controller.php'
detail_url = ERP_BASE_URL.rstrip('/') + f'/sys/controller/{section}/{controller}'
def load_bridge_html(bridge_no):
params = {
'ActionMode': 'REPORT_1',
'page': section,
'bridgeno': bridge_no,
'ProjectCode': code,
'ProjectDvsNm': '시공',
'ProjectNm': short_name,
}
response = session.get(detail_url, params=params, timeout=20)
return response.content.decode('utf-8', errors='ignore')
first_html = load_bridge_html(1)
bridge_numbers = _extract_bridge_numbers(first_html)
overviews = []
for bridge_no in bridge_numbers:
html = first_html if bridge_no == 1 else load_bridge_html(bridge_no)
tables = _extract_table_rows(html)
overview_rows = _find_section_rows_by_title(tables, '교량명')
overview = _parse_bridge_overview(overview_rows)
overview['bridgeNo'] = bridge_no
overview['sourcePage'] = section
overview['projectCode'] = code
overview['projectName'] = short_name
overviews.append(overview)
return {
'sourcePage': section,
'projectCode': code,
'projectName': short_name,
'bridgeNumbers': bridge_numbers,
'rows': overviews,
}
def replace_erp_bridge_overview_cache(conn, result):
source_page = _as_text(result.get('sourcePage')).strip().lower() or 'const'
project_code = _as_text(result.get('projectCode')).strip()
synced_at = datetime.now().isoformat(timespec='seconds')
conn.execute(
'DELETE FROM erp_bridge_overview_cache WHERE sourcePage = ? AND projectCode = ?',
(source_page, project_code),
)
rows = result.get('rows') or []
if rows:
conn.executemany(
'''
INSERT OR REPLACE INTO erp_bridge_overview_cache
(sourcePage, projectCode, bridgeNo, bridgeName, bridgeDisplayName, applicationType,
constructionStatus, constructionPeriod, spanLengthUp, spanLengthDown, widthUp, widthDown,
girderHeightCenter, girderHeightSupport, spanCompositionUp, spanCompositionDown,
bridgeLocation, remarks, rawOverviewJson, syncedAt)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
''',
[
(
source_page,
project_code,
int(row.get('bridgeNo') or 0),
_as_text(row.get('bridgeName')).strip(),
_as_text(row.get('bridgeDisplayName')).strip(),
_as_text(row.get('applicationType')).strip(),
_as_text(row.get('constructionStatus')).strip(),
_as_text(row.get('constructionPeriod')).strip(),
_as_text(row.get('spanLengthUp')).strip(),
_as_text(row.get('spanLengthDown')).strip(),
_as_text(row.get('widthUp')).strip(),
_as_text(row.get('widthDown')).strip(),
_as_text(row.get('girderHeightCenter')).strip(),
_as_text(row.get('girderHeightSupport')).strip(),
_as_text(row.get('spanCompositionUp')).strip(),
_as_text(row.get('spanCompositionDown')).strip(),
_as_text(row.get('bridgeLocation')).strip(),
_as_text(row.get('remarks')).strip(),
json.dumps(row, ensure_ascii=False),
synced_at,
)
for row in rows
],
)
conn.commit()
return {'sourcePage': source_page, 'projectCode': project_code, 'count': len(rows), 'syncedAt': synced_at}
def get_erp_bridge_overview_cache(conn, source_page='const', project_code=''):
source_page = _as_text(source_page).strip().lower() or 'const'
project_code = _as_text(project_code).strip()
rows = [
{
'bridgeNo': row[0],
'bridgeName': row[1],
'bridgeDisplayName': row[2],
'applicationType': row[3],
'constructionStatus': row[4],
'constructionPeriod': row[5],
'spanLengthUp': row[6],
'spanLengthDown': row[7],
'widthUp': row[8],
'widthDown': row[9],
'girderHeightCenter': row[10],
'girderHeightSupport': row[11],
'spanCompositionUp': row[12],
'spanCompositionDown': row[13],
'bridgeLocation': row[14],
'remarks': row[15],
}
for row in conn.execute(
'''
SELECT bridgeNo, bridgeName, bridgeDisplayName, applicationType, constructionStatus,
constructionPeriod, spanLengthUp, spanLengthDown, widthUp, widthDown,
girderHeightCenter, girderHeightSupport, spanCompositionUp, spanCompositionDown,
bridgeLocation, remarks
FROM erp_bridge_overview_cache
WHERE sourcePage = ? AND projectCode = ?
ORDER BY bridgeNo ASC
''',
(source_page, project_code),
).fetchall()
]
synced_at = conn.execute(
'SELECT MAX(syncedAt) FROM erp_bridge_overview_cache WHERE sourcePage = ? AND projectCode = ?',
(source_page, project_code),
).fetchone()[0]
return {'sourcePage': source_page, 'projectCode': project_code, 'rows': rows, 'syncedAt': synced_at or ''}
def fetch_erp_contract_detail(page='const', project_code='', project_name=''):
section = _as_text(page).strip().lower() or 'const'
code = _as_text(project_code).strip()
short_name = _as_text(project_name).strip()
if not code:
raise ValueError('project_code가 필요합니다.')
controller_map = {
'const': 'const_contents_controller.php',
'design': 'common_contents_controller.php',
'make': 'make_contents_controller.php',
'research': 'research_contents_controller.php',
'sales': 'sales_contents_controller.php',
}
page_name_map = {
'const': '시공',
'design': '설계',
'make': '제조',
'research': '연구',
'sales': '영업',
}
controller = controller_map.get(section)
if not controller:
raise ValueError('허용되지 않은 ERP 페이지입니다.')
session = erp_login_session()
detail_url = ERP_BASE_URL.rstrip('/') + f'/sys/controller/{section}/{controller}'
params = {
'ActionMode': 'REPORT_1',
'page': section,
'ProjectCode': code,
'ProjectDvsNm': page_name_map[section],
'ProjectNm': short_name,
}
response = session.get(detail_url, params=params, timeout=20)
html = response.content.decode('utf-8', errors='ignore')
tables = _extract_table_rows(html)
contract_rows = _find_table_rows_by_labels(tables, ('사업코드', '약칭', '최종계약금액', '시공코드'))
contract_map = _label_value_map_from_rows(contract_rows)
scale_rows_raw = _find_contract_scale_table_rows(tables)
scale_rows = _parse_contract_scale_rows(scale_rows_raw)
detail = {
'sourcePage': section,
'projectCode': code,
'projectName': short_name or contract_map.get('약칭', ''),
'businessCode': contract_map.get('사업코드', ''),
'contractName': contract_map.get('계약공사명', ''),
'siteLocation': contract_map.get('현장위치', ''),
'clientName': contract_map.get('발주처', ''),
'finalContractAmountText': contract_map.get('최종계약금액', ''),
'finalContractAmountValue': _parse_amount_text(contract_map.get('최종계약금액', '')),
'contractType': contract_map.get('계약종류', ''),
'linkedCodes': find_erp_linked_codes(code, contract_map.get('사업코드', ''), short_name or contract_map.get('약칭', '')),
'contractFields': contract_map,
'scaleRows': scale_rows,
}
return detail
def replace_erp_contract_detail_cache(conn, detail):
source_page = _as_text(detail.get('sourcePage')).strip().lower() or 'const'
project_code = _as_text(detail.get('projectCode')).strip()
if not project_code:
raise ValueError('projectCode가 비어 있습니다.')
synced_at = datetime.now().isoformat(timespec='seconds')
contract_fields = detail.get('contractFields') or {}
raw_payload = {
'contractFields': contract_fields,
'scaleRows': detail.get('scaleRows') or [],
'linkedCodes': detail.get('linkedCodes') or {},
}
conn.execute(
'''
INSERT OR REPLACE INTO erp_contract_detail_cache
(sourcePage, projectCode, projectName, businessCode, contractName, siteLocation,
clientName, finalContractAmountText, finalContractAmountValue, contractType,
rawContractJson, syncedAt)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
''',
(
source_page,
project_code,
_as_text(detail.get('projectName')).strip(),
_as_text(detail.get('businessCode')).strip(),
_as_text(detail.get('contractName')).strip(),
_as_text(detail.get('siteLocation')).strip(),
_as_text(detail.get('clientName')).strip(),
_as_text(detail.get('finalContractAmountText')).strip(),
int(detail.get('finalContractAmountValue') or 0),
_as_text(detail.get('contractType')).strip(),
json.dumps(raw_payload, ensure_ascii=False),
synced_at,
),
)
conn.commit()
return {'sourcePage': source_page, 'projectCode': project_code, 'syncedAt': synced_at}
def get_erp_contract_detail_cache(conn, source_page='const', project_code=''):
source_page = _as_text(source_page).strip().lower() or 'const'
project_code = _as_text(project_code).strip()
row = conn.execute(
'''
SELECT projectName, businessCode, contractName, siteLocation, clientName,
finalContractAmountText, finalContractAmountValue, contractType, rawContractJson, syncedAt
FROM erp_contract_detail_cache
WHERE sourcePage = ? AND projectCode = ?
''',
(source_page, project_code),
).fetchone()
if not row:
return None
raw_contract = json.loads(row[8] or '{}')
if isinstance(raw_contract, dict) and ('contractFields' in raw_contract or 'scaleRows' in raw_contract):
contract_fields = raw_contract.get('contractFields') or {}
scale_rows = raw_contract.get('scaleRows') or []
linked_codes = _decorate_linked_code_names(raw_contract.get('linkedCodes') or {}, conn)
else:
contract_fields = raw_contract if isinstance(raw_contract, dict) else {}
scale_rows = []
linked_codes = {}
return {
'sourcePage': source_page,
'projectCode': project_code,
'projectName': row[0] or '',
'businessCode': row[1] or '',
'contractName': row[2] or '',
'siteLocation': row[3] or '',
'clientName': row[4] or '',
'finalContractAmountText': row[5] or '',
'finalContractAmountValue': row[6] or 0,
'contractType': row[7] or '',
'linkedCodes': linked_codes,
'contractFields': contract_fields,
'scaleRows': scale_rows,
'syncedAt': row[9] or '',
}
def _clean_html_text(value):
text = html_lib.unescape(_strip_tags(value or ''))
return re.sub(r'\s+', ' ', text).strip()
def _normalize_text_key(value):
return re.sub(r'\s+', '', _as_text(value)).strip()
def _extract_business_code_key(value):
text = _as_text(value).strip()
match = re.search(r'\d{2}-\d{3}', text)
return match.group(0) if match else text
def _load_linked_code_map_from_html():
html_path = Path(__file__).with_name('project-codes.html')
if not html_path.exists():
return {}
try:
html_text = html_path.read_text(encoding='utf-8')
except OSError:
return {}
match = re.search(r'const\s+LINKED_CODE_BY_BUSINESS\s*=\s*(\{.*?\});', html_text, re.S)
if not match:
return {}
try:
parsed = json.loads(match.group(1))
except Exception:
return {}
return parsed if isinstance(parsed, dict) else {}
def _decorate_linked_code_names(linked_codes, conn=None):
payload = dict(linked_codes or {})
close_conn = False
if conn is None:
conn = open_db_connection()
close_conn = True
try:
sales_code = _as_text(payload.get('salesCode')).strip()
design_code = _as_text(payload.get('designCode')).strip()
if sales_code and not _as_text(payload.get('salesName')).strip():
payload['salesName'] = get_project_alias_name(conn, sales_code, 'sales')
if design_code and not _as_text(payload.get('designName')).strip():
payload['designName'] = get_project_alias_name(conn, design_code, 'design')
return payload
finally:
if close_conn:
conn.close()
def fetch_erp_linked_code_rows():
session = erp_login_session()
report_url = ERP_BASE_URL.rstrip('/') + '/sys/controller/report/design_step_controller.php'
response = session.get(report_url, params={'ActionMode': 'REPORT_5'}, timeout=20)
html = response.content.decode('utf-8', errors='ignore')
tables = _extract_table_rows(html)
target_rows = []
for rows in tables:
if not rows:
continue
header = rows[0]
if '사업코드' in header and '영업코드' in header and '설계코드' in header and '시공코드' in header:
target_rows = rows
break
if not target_rows:
return []
header = target_rows[0]
header_index = {label: index for index, label in enumerate(header)}
business_index = header_index.get('사업코드')
bridge_name_index = header_index.get('교량명')
sales_index = header_index.get('영업코드')
design_index = header_index.get('설계코드')
const_index = header_index.get('시공코드')
status_index = header_index.get('진행상태')
out = []
for row in target_rows[1:]:
if not row:
continue
if business_index is None or business_index >= len(row):
continue
business_code = _as_text(row[business_index]).strip()
if not business_code or not re.match(r'^\d{2}-\d{3}', business_code):
continue
out.append({
'businessCode': business_code,
'bridgeName': _as_text(row[bridge_name_index]).strip() if bridge_name_index is not None and bridge_name_index < len(row) else '',
'status': _as_text(row[status_index]).strip() if status_index is not None and status_index < len(row) else '',
'salesCode': _as_text(row[sales_index]).strip() if sales_index is not None and sales_index < len(row) else '',
'designCode': _as_text(row[design_index]).strip() if design_index is not None and design_index < len(row) else '',
'constCode': _as_text(row[const_index]).strip() if const_index is not None and const_index < len(row) else '',
})
return out
def find_erp_linked_codes(project_code='', business_code='', project_name=''):
project_code = _as_text(project_code).strip()
business_code = _extract_business_code_key(business_code)
project_name_key = _normalize_text_key(project_name)
html_map = _load_linked_code_map_from_html()
def html_map_match():
if project_code:
for key, value in html_map.items():
if _as_text((value or {}).get('constCode')).strip() == project_code:
return _decorate_linked_code_names({
'businessCode': key,
'bridgeName': '',
'status': '',
'salesCode': _as_text((value or {}).get('salesCode')).strip(),
'designCode': _as_text((value or {}).get('designCode')).strip(),
'constCode': _as_text((value or {}).get('constCode')).strip(),
'matchedBy': 'htmlMap.constCode',
})
if business_code and business_code in html_map:
value = html_map.get(business_code) or {}
return _decorate_linked_code_names({
'businessCode': business_code,
'bridgeName': '',
'status': '',
'salesCode': _as_text(value.get('salesCode')).strip(),
'designCode': _as_text(value.get('designCode')).strip(),
'constCode': _as_text(value.get('constCode')).strip(),
'matchedBy': 'htmlMap.businessCode',
})
return None
html_hit = html_map_match()
if html_hit:
return html_hit
rows = fetch_erp_linked_code_rows()
if not rows:
return {'salesCode': '', 'salesName': '', 'designCode': '', 'designName': '', 'constCode': '', 'matchedBy': ''}
for row in rows:
if project_code and _as_text(row.get('constCode')).strip() == project_code:
return _decorate_linked_code_names({**row, 'matchedBy': 'constCode'})
candidates = [row for row in rows if business_code and _as_text(row.get('businessCode')).strip() == business_code]
if len(candidates) == 1:
return _decorate_linked_code_names({**candidates[0], 'matchedBy': 'businessCode'})
if len(candidates) > 1 and project_name_key:
for row in candidates:
bridge_name_key = _normalize_text_key(row.get('bridgeName'))
if project_name_key and project_name_key in bridge_name_key:
return _decorate_linked_code_names({**row, 'matchedBy': 'businessCode+projectName'})
return _decorate_linked_code_names({**candidates[0], 'matchedBy': 'businessCode'})
return {'salesCode': '', 'salesName': '', 'designCode': '', 'designName': '', 'constCode': '', 'matchedBy': ''}
def _parse_site_worker_rows(html):
rows = []
for table in re.findall(r'', html or '', re.I | re.S):
plain = _clean_html_text(table)
if '직종' not in plain or '성명' not in plain or '작업내용' not in plain:
continue
for tr in re.findall(r']*>(.*?)
', table, re.I | re.S):
cells = [_clean_html_text(td) for td in re.findall(r']*>(.*?)', tr, re.I | re.S)]
if len(cells) < 8:
continue
if cells[0] in ('No.', '합 계') or cells[1] in ('직종', ''):
continue
detail_match = re.search(r"cs_person_detail\('([^']*)','([^']*)','([^']*)'\)", tr)
rows.append({
'jobType': cells[1],
'korName': cells[2],
'juminno': detail_match.group(2) if detail_match else (cells[3] if len(cells) > 3 else ''),
'morning': cells[4] if len(cells) > 4 else '',
'afternoon': cells[5] if len(cells) > 5 else '',
'night': cells[6] if len(cells) > 6 else '',
'personCount': cells[7] if len(cells) > 7 else '',
'workText': cells[8] if len(cells) > 8 else '',
'note': cells[9] if len(cells) > 9 else '',
})
return rows
def _parse_construct_paymonth_rows(html, write_day):
rows = []
base_year = (write_day or '')[:4]
for tr in re.findall(r']*>(.*?)
', html or '', re.I | re.S):
cells = [_clean_html_text(td) for td in re.findall(r']*>(.*?)', tr, re.I | re.S)]
if len(cells) < 9 or not cells[0].isdigit():
continue
mmdd = cells[3]
if not re.match(r'^\d{2}/\d{2}$', mmdd or ''):
continue
work_date = f'{base_year}-{mmdd[:2]}-{mmdd[3:5]}'
rows.append({
'workDate': work_date,
'bridgeName': cells[1],
'jobType': cells[2],
'morning': cells[4],
'afternoon': cells[5],
'night': cells[6],
'personCount': cells[7],
'workText': cells[9] if len(cells) > 9 else '',
})
return rows
def _load_construct_paymonth_rows(session, member_name, juminno, write_day):
if not member_name or not juminno or not write_day:
return []
url = ERP_BASE_URL.rstrip('/') + '/sys/etc/Construct_PayMonth_Detail_Print.php'
r = session.get(
url,
params={'name': member_name, 'juminno': juminno, 'WriteDay': write_day},
timeout=10
)
html = r.content.decode('utf-8', errors='ignore')
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):
cur = conn.execute(
'''
SELECT projectCode, workDate, memberNo, korName, jobType, workText, note, personCount
FROM site_worksheet_record
WHERE memberNo = ?
AND date(workDate) >= date(?)
AND date(workDate) <= date(?)
ORDER BY workDate, projectCode
''',
(member_no, start_date, end_date)
)
cols = [d[0] for d in cur.description]
return [dict(zip(cols, row)) for row in cur.fetchall()]
def _site_record_rows_from_cache_for_members(conn, start_date, end_date, member_nos):
member_nos = [normalize_member_no(v) for v in member_nos if normalize_member_no(v)]
if not member_nos:
return []
ph = ','.join(['?'] * len(member_nos))
cur = conn.execute(
f'''
SELECT projectCode, workDate, memberNo, korName, jobType, workText, note, personCount
FROM site_worksheet_record
WHERE memberNo IN ({ph})
AND date(workDate) >= date(?)
AND date(workDate) <= date(?)
ORDER BY workDate, projectCode, memberNo
''',
[*member_nos, start_date, end_date]
)
cols = [d[0] for d in cur.description]
return [dict(zip(cols, row)) for row in cur.fetchall()]
def _float_or_zero(value):
try:
return float(_as_text(value).replace(',', '').strip() or 0)
except Exception:
return 0.0
def get_site_worksheet_records_by_days(conn, start_date, end_date, member_nos, refresh=False, progress=None):
member_nos = [normalize_member_no(v) for v in member_nos if normalize_member_no(v)]
if progress is not None:
progress.update({
'phase': '직원 목록 확인',
'totalMembers': len(member_nos),
'processedMembers': 0,
'totalTargets': 0,
'processedTargets': 0,
'totalDays': 0,
'processedDays': 0,
'currentProjectCode': '',
'currentYearMonth': '',
'currentWorkDate': '',
'added': 0,
'foundWorkers': 0,
'matchedWorkers': 0,
})
if not member_nos:
return {'rows': [], 'source': 'empty'}
ph = ','.join(['?'] * len(member_nos))
people_cur = conn.execute(
f'''
SELECT MemberNo, IFNULL(korName, '') AS korName, IFNULL(retireFlag, '') AS retireFlag
FROM member
WHERE MemberNo IN ({ph})
AND IFNULL(korName, '') <> ''
''',
member_nos
)
name_to_members = {}
for member_no, kor_name, retire_flag in people_cur.fetchall():
name_to_members.setdefault(_as_text(kor_name).strip(), []).append({
'memberNo': normalize_member_no(member_no),
'retireDate': _date_value(retire_flag),
})
if not name_to_members:
return {'rows': [], 'source': 'empty'}
cached = _site_record_rows_from_cache_for_members(conn, start_date, end_date, member_nos)
if refresh:
conn.execute(
f'''
DELETE FROM site_worksheet_record
WHERE memberNo IN ({ph})
AND date(workDate) >= date(?)
AND date(workDate) <= date(?)
''',
[*member_nos, start_date, end_date]
)
conn.execute(
'''
DELETE FROM site_worksheet_day_sync
WHERE date(workDate) >= date(?)
AND date(workDate) <= date(?)
''',
(start_date, end_date)
)
conn.execute(
'''
DELETE FROM site_worksheet_worker_cache
WHERE date(workDate) >= date(?)
AND date(workDate) <= date(?)
''',
(start_date, end_date)
)
conn.execute(
'''
DELETE FROM site_worksheet_menu_sync
WHERE date(workDate) >= date(?)
AND date(workDate) <= date(?)
''',
(start_date, end_date)
)
conn.commit()
cached = []
cur = conn.execute(
f'''
SELECT DISTINCT IFNULL(EntryPCode, '') AS projectCode,
substr(IFNULL(WorkDate, substr(EntryTime, 1, 10)), 1, 7) AS yearMonth
FROM dallyproject
WHERE MemberNo IN ({ph})
AND EntryPCode LIKE '%시공%'
AND date(IFNULL(WorkDate, substr(EntryTime, 1, 10))) >= date(?)
AND date(IFNULL(WorkDate, substr(EntryTime, 1, 10))) <= date(?)
AND IFNULL(EntryPCode, '') <> ''
ORDER BY yearMonth DESC, projectCode
''',
[*member_nos, start_date, end_date]
)
month_targets = [(r[0], r[1]) for r in cur.fetchall() if r[0] and r[1]]
# 사업관리 원본에는 SQL(dallyproject)보다 최신 월 기록이 먼저 올라오는 경우가 있다.
# 그래서 최근 SQL 기록이 있는 시공 프로젝트는 조회 종료월까지 사업관리 월을 추가 확인한다.
# 예: 24-시공-03은 SQL은 2026-04까지지만 사업관리에는 2026-05/06 기록이 존재.
def ym_to_int(ym):
try:
y, m = str(ym).split('-', 1)
return int(y) * 12 + int(m)
except Exception:
return 0
def int_to_ym(v):
y = (v - 1) // 12
m = (v - 1) % 12 + 1
return f'{y:04d}-{m:02d}'
target_set = set(month_targets)
start_ym_i = ym_to_int(_as_text(start_date)[:7])
end_ym_i = ym_to_int(_as_text(end_date)[:7])
if start_ym_i and end_ym_i and start_ym_i <= end_ym_i:
recent_cur = conn.execute(
f'''
SELECT IFNULL(EntryPCode, '') AS projectCode,
MAX(substr(IFNULL(WorkDate, substr(EntryTime, 1, 10)), 1, 7)) AS lastYearMonth
FROM dallyproject
WHERE MemberNo IN ({ph})
AND EntryPCode LIKE '%시공%'
AND date(IFNULL(WorkDate, substr(EntryTime, 1, 10))) <= date(?)
AND IFNULL(EntryPCode, '') <> ''
GROUP BY IFNULL(EntryPCode, '')
''',
[*member_nos, end_date]
)
for project_code, last_ym in recent_cur.fetchall():
last_i = ym_to_int(last_ym)
if not project_code or not last_i:
continue
# 너무 오래 끝난 프로젝트까지 매번 확인하지 않도록 종료월 기준 최근 3개월 프로젝트만 확장한다.
if end_ym_i - last_i > 3:
continue
for ym_i in range(max(start_ym_i, last_i + 1), end_ym_i + 1):
target_set.add((project_code, int_to_ym(ym_i)))
month_targets = sorted(target_set, key=lambda x: (x[1], x[0]), reverse=True)
if progress is not None:
progress.update({
'phase': '대상 월 계산 완료',
'totalTargets': len(month_targets),
'processedTargets': 0,
'cachedRows': len(cached),
})
if not month_targets:
return {'rows': cached, 'source': 'cache'}
synced_days = set()
if not refresh:
synced_days = {
(r[0], r[1])
for r in conn.execute(
'''
SELECT s.projectCode, s.workDate
FROM site_worksheet_day_sync s
WHERE date(s.workDate) >= date(?)
AND date(s.workDate) <= date(?)
AND EXISTS (
SELECT 1
FROM site_worksheet_worker_cache w
WHERE w.projectCode = s.projectCode
AND w.workDate = s.workDate
)
''',
(start_date, end_date)
).fetchall()
}
if progress is not None:
progress['phase'] = '사업관리 로그인'
s = erp_login_session()
workdate_url = ERP_BASE_URL.rstrip('/') + '/sys/controller/const/site_worksheet_controller.php'
synced_menus = set()
if not refresh:
synced_menus = {
(r[0], r[1], r[2])
for r in conn.execute(
'''
SELECT projectCode, workDate, selMenu
FROM site_worksheet_menu_sync
WHERE date(workDate) >= date(?)
AND date(workDate) <= date(?)
''',
(start_date, end_date)
).fetchall()
}
added = 0
found_workers = 0
matched_workers = 0
total_days = 0
processed_days = 0
def insert_matched_records_from_cache(project_code, work_date):
inserted = 0
cur = conn.execute(
'''
SELECT korName, jobType, workText, note, personCount
FROM site_worksheet_worker_cache
WHERE projectCode = ?
AND workDate = ?
''',
(project_code, work_date)
)
found = 0
for kor_name, job_type, work_text, note, person_count in cur.fetchall():
found += 1
matched_members = _member_rows_for_site_work_day(name_to_members, kor_name, work_date)
for matched_member in matched_members:
matched_member_no = matched_member.get('memberNo') if isinstance(matched_member, dict) else matched_member
conn.execute(
'''
INSERT OR REPLACE INTO site_worksheet_record
(projectCode, workDate, memberNo, korName, jobType, workText, note, personCount)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
''',
(
project_code,
work_date,
matched_member_no,
kor_name,
job_type or '',
work_text or '',
note or '',
_float_or_zero(person_count),
)
)
inserted += 1
return found, inserted
for month_idx, (project_code, year_month) in enumerate(month_targets, start=1):
if progress is not None:
progress.update({
'phase': '작업일보 날짜 확인',
'processedTargets': month_idx - 1,
'currentProjectCode': project_code,
'currentYearMonth': year_month,
'currentWorkDate': '',
})
year, month = year_month.split('-', 1)
try:
r = s.post(
workdate_url,
data={
'ActionMode': 'getWorkDate',
'ProjectCode': project_code,
'bridgeno': '1',
'year': year,
'month': str(int(month)),
},
timeout=20
)
days = json.loads(r.text)
except Exception:
if progress is not None:
progress['processedTargets'] = month_idx
continue
work_dates = []
for day in days or []:
work_date = _as_text(day.get('WorkDate')).strip()
if not work_date or work_date < start_date or work_date > end_date:
continue
if _float_or_zero(day.get('PERSON_SUM')) <= 0:
continue
work_dates.append(work_date)
total_days += len(work_dates)
if progress is not None:
progress.update({
'totalDays': total_days,
'processedDays': processed_days,
})
for work_date in work_dates:
if progress is not None:
progress.update({
'phase': '작업일보 직원 매칭',
'currentProjectCode': project_code,
'currentYearMonth': year_month,
'currentWorkDate': work_date,
})
if (project_code, work_date) not in synced_days:
conn.execute(
'''
INSERT OR REPLACE INTO site_worksheet_day_sync (projectCode, workDate, syncedAt)
VALUES (?, ?, ?)
''',
(project_code, work_date, datetime.now().isoformat(timespec='seconds'))
)
synced_days.add((project_code, work_date))
for sel_menu in ('2', '3'):
if not refresh and (project_code, work_date, sel_menu) in synced_menus:
continue
params = {
'ActionMode': 'REPORT_1',
'MainAction': 'info',
'bridgeno': '1',
'ProjectCode': project_code,
'WriteDay': work_date,
'SelMenu': sel_menu,
}
worker_rows = []
try:
detail = s.get(workdate_url, params=params, timeout=10)
html = detail.content.decode('utf-8', errors='ignore')
worker_rows = _parse_site_worker_rows(html)
except Exception:
worker_rows = []
synced_at = datetime.now().isoformat(timespec='seconds')
for row in worker_rows:
kor_name = _as_text(row.get('korName')).strip()
if not kor_name:
continue
work_text = row.get('workText', '') or ''
if sel_menu == '3' and work_text:
work_text = '인장/가설 ' + work_text
conn.execute(
'''
INSERT OR REPLACE INTO site_worksheet_worker_cache
(projectCode, workDate, korName, jobType, workText, note, personCount, syncedAt)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
''',
(
project_code,
work_date,
kor_name,
row.get('jobType', ''),
work_text,
row.get('note', '') or '',
_float_or_zero(row.get('personCount')),
synced_at,
)
)
conn.execute(
'''
INSERT OR REPLACE INTO site_worksheet_menu_sync (projectCode, workDate, selMenu, syncedAt)
VALUES (?, ?, ?, ?)
''',
(project_code, work_date, sel_menu, synced_at)
)
synced_menus.add((project_code, work_date, sel_menu))
if progress is not None and sel_menu == '3' and worker_rows:
progress['phase'] = '인장/가설 직원 매칭'
day_found, day_matched = insert_matched_records_from_cache(project_code, work_date)
found_workers += day_found
matched_workers += day_matched
added += day_matched
processed_days += 1
conn.commit()
if progress is not None:
progress.update({
'processedDays': processed_days,
'added': added,
'foundWorkers': found_workers,
'matchedWorkers': matched_workers,
})
if progress is not None:
progress['processedTargets'] = month_idx
if progress is not None:
progress['phase'] = '월별 근무현황 보강'
identity_rows = conn.execute(
f'''
SELECT m.MemberNo, IFNULL(m.korName, '') AS korName, IFNULL(i.juminno, '') AS juminno
FROM member m
JOIN member_site_identity i ON i.memberNo = m.MemberNo
WHERE m.MemberNo IN ({ph})
AND IFNULL(m.korName, '') <> ''
AND IFNULL(i.juminno, '') <> ''
''',
member_nos
).fetchall()
for idx, (identity_member_no, identity_name, juminno) in enumerate(identity_rows, start=1):
if progress is not None:
progress.update({
'phase': '월별 근무현황 보강',
'processedMembers': idx - 1,
'totalMembers': len(identity_rows),
'currentProjectCode': '',
'currentYearMonth': _as_text(end_date)[:7],
'currentWorkDate': end_date,
})
try:
added += insert_construct_paymonth_records(
conn,
s,
identity_member_no,
identity_name,
juminno,
end_date,
start_date,
end_date,
)
conn.commit()
except Exception:
# Project/day crawl is the primary source; paymonth is a best-effort gap filler.
pass
if progress is not None:
progress.update({
'phase': '달력 테이블 정리',
'processedMembers': len(identity_rows),
'added': added,
})
conn.commit()
cleanup_site_records_after_retire(conn)
rebuild_work_calendar_tables(conn)
return {
'rows': _site_record_rows_from_cache_for_members(conn, start_date, end_date, member_nos),
'source': 'erp_day_incremental',
'added': added,
}
def get_member_site_worksheet_records(conn, start_date, end_date, member_no, refresh=False, progress=None):
member_no = normalize_member_no(member_no)
if progress is not None:
progress.update({
'phase': '사람 확인',
'processedTargets': 0,
'totalTargets': 0,
'currentProjectCode': '',
'currentYearMonth': '',
'added': 0,
})
if not member_no:
return {'rows': []}
mrow = conn.execute('SELECT IFNULL(korName, "") FROM member WHERE MemberNo = ?', (member_no,)).fetchone()
member_name = (mrow[0] if mrow else '').strip()
if not member_name:
return {'rows': []}
cached = _site_record_rows_from_cache(conn, start_date, end_date, member_no)
if refresh and cached:
conn.execute(
'''
DELETE FROM site_worksheet_record
WHERE memberNo = ?
AND date(workDate) >= date(?)
AND date(workDate) <= date(?)
''',
(member_no, start_date, end_date)
)
conn.execute(
'''
DELETE FROM site_worksheet_sync
WHERE memberNo = ?
AND yearMonth >= substr(?, 1, 7)
AND yearMonth <= substr(?, 1, 7)
''',
(member_no, start_date, end_date)
)
conn.commit()
cached = []
if not refresh:
synced_targets = {
(r[0], r[1])
for r in conn.execute(
'''
SELECT projectCode, yearMonth
FROM site_worksheet_sync
WHERE memberNo = ?
AND yearMonth >= substr(?, 1, 7)
AND yearMonth <= substr(?, 1, 7)
''',
(member_no, start_date, end_date)
).fetchall()
}
else:
synced_targets = set()
cur = conn.execute(
'''
SELECT DISTINCT IFNULL(EntryPCode, '') AS projectCode,
substr(IFNULL(WorkDate, substr(EntryTime, 1, 10)), 1, 7) AS yearMonth
FROM dallyproject
WHERE MemberNo = ?
AND EntryPCode LIKE '%시공%'
AND date(IFNULL(WorkDate, substr(EntryTime, 1, 10))) >= date(?)
AND date(IFNULL(WorkDate, substr(EntryTime, 1, 10))) <= date(?)
AND IFNULL(EntryPCode, '') <> ''
ORDER BY yearMonth DESC, projectCode
''',
(member_no, start_date, end_date)
)
targets = [(r[0], r[1]) for r in cur.fetchall() if r[0] and r[1]]
if not refresh and synced_targets:
targets = [t for t in targets if t not in synced_targets]
if progress is not None:
progress.update({
'phase': '대상 계산 완료',
'totalTargets': len(targets),
'processedTargets': 0,
'memberName': member_name,
'cachedRows': len(cached),
})
if not targets:
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:
progress['phase'] = '사업관리 로그인'
s = erp_login_session()
workdate_url = ERP_BASE_URL.rstrip('/') + '/sys/controller/const/site_worksheet_controller.php'
out = []
for project_code, year_month in targets:
if progress is not None:
progress.update({
'phase': '월별 작업일 확인',
'currentProjectCode': project_code,
'currentYearMonth': year_month,
})
year, month = year_month.split('-', 1)
payload = {
'ActionMode': 'getWorkDate',
'ProjectCode': project_code,
'bridgeno': '1',
'year': year,
'month': str(int(month)),
}
try:
r = s.post(workdate_url, data=payload, timeout=20)
days = json.loads(r.text)
except Exception:
continue
found_month_record = False
for day in days or []:
if found_month_record:
break
work_date = _as_text(day.get('WorkDate')).strip()
if not work_date or work_date < start_date or work_date > end_date:
continue
if float(day.get('PERSON_SUM') or 0) <= 0:
continue
if progress is not None:
progress.update({
'phase': '근무현황 파싱',
'currentProjectCode': project_code,
'currentYearMonth': year_month,
'currentWorkDate': work_date,
})
detail_param_sets = [
{
'ActionMode': 'REPORT_1',
'page': 'const',
'ProjectCode': project_code,
'bridgeno': '1',
'isPop': 'true',
'WriteDay': work_date,
},
{
'ActionMode': 'REPORT_1',
'MainAction': 'info',
'bridgeno': '1',
'ProjectCode': project_code,
'WriteDay': work_date,
'SelMenu': '2',
},
]
html = ''
for params in detail_param_sets:
try:
detail = s.get(workdate_url, params=params, timeout=8)
html = detail.content.decode('utf-8', errors='ignore')
if member_name in _clean_html_text(html):
break
except Exception:
continue
if not html:
continue
worker_rows = _parse_site_worker_rows(html)
for row in worker_rows:
if member_name not in row.get('korName', ''):
continue
month_rows = _load_construct_paymonth_rows(s, member_name, row.get('juminno', ''), work_date)
if not month_rows:
month_rows = [{
'workDate': work_date,
'bridgeName': '',
'jobType': row.get('jobType', ''),
'workText': row.get('workText', ''),
'personCount': row.get('personCount') or 1,
}]
for month_row in month_rows:
if month_row['workDate'] < start_date or month_row['workDate'] > end_date:
continue
record = {
'projectCode': project_code,
'workDate': month_row['workDate'],
'memberNo': member_no,
'korName': member_name,
'jobType': month_row.get('jobType', ''),
'workText': month_row.get('bridgeName', '') or month_row.get('workText', ''),
'note': month_row.get('workText', ''),
'personCount': float(month_row.get('personCount') or 0),
}
out.append(record)
if progress is not None:
progress['added'] = len(out)
conn.execute(
'''
INSERT OR REPLACE INTO site_worksheet_record
(projectCode, workDate, memberNo, korName, jobType, workText, note, personCount)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
''',
(
record['projectCode'], record['workDate'], record['memberNo'], record['korName'],
record['jobType'], record['workText'], record['note'], record['personCount']
)
)
found_month_record = True
break
conn.execute(
'''
INSERT OR REPLACE INTO site_worksheet_sync (memberNo, projectCode, yearMonth, syncedAt)
VALUES (?, ?, ?, ?)
''',
(member_no, project_code, year_month, datetime.now().isoformat(timespec='seconds'))
)
if progress is not None:
progress.update({
'processedTargets': int(progress.get('processedTargets') or 0) + 1,
'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:
progress['phase'] = '달력 테이블 정리'
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_incremental', 'added': len(out)}
def _public_site_sync_job(job):
data = dict(job)
if data.get('status') != 'done':
data.pop('rows', None)
return data
def start_site_worksheet_sync_job(start_date, end_date, member_no, refresh=False, member_nos=None):
member_list = [normalize_member_no(v) for v in (member_nos or []) if normalize_member_no(v)]
if not member_list:
one = normalize_member_no(member_no)
if one:
member_list = [one]
with SITE_SYNC_LOCK:
for existing in SITE_SYNC_JOBS.values():
if existing.get('status') == 'running':
existing['reused'] = True
return _public_site_sync_job(existing)
job_id = uuid.uuid4().hex
job = {
'jobId': job_id,
'status': 'running',
'phase': '준비',
'start': start_date,
'end': end_date,
'memberNo': normalize_member_no(member_no),
'memberName': '',
'totalMembers': len(member_list),
'processedMembers': 0,
'totalTargets': 0,
'processedTargets': 0,
'currentProjectCode': '',
'currentYearMonth': '',
'currentWorkDate': '',
'cachedRows': 0,
'added': 0,
'rows': [],
'startedAt': datetime.now().isoformat(timespec='seconds'),
}
with SITE_SYNC_LOCK:
SITE_SYNC_JOBS[job_id] = job
def run():
try:
with sqlite3.connect(DB_PATH, timeout=30) as conn:
init_db(conn)
result = get_site_worksheet_records_by_days(
conn,
start_date,
end_date,
member_list,
refresh=refresh,
progress=job,
)
job.update({
'status': 'done',
'phase': '완료',
'rows': result.get('rows', []),
'source': result.get('source', ''),
'added': result.get('added', job.get('added', 0)),
'finishedAt': datetime.now().isoformat(timespec='seconds'),
})
except Exception as e:
job.update({
'status': 'error',
'phase': '오류',
'error': str(e),
'finishedAt': datetime.now().isoformat(timespec='seconds'),
})
threading.Thread(target=run, daemon=True).start()
return _public_site_sync_job(job)
def _strip_tags(s):
s = re.sub(r'<[^>]+>', '', s or '')
s = s.replace(' ', ' ').replace(' ', ' ')
return s.strip()
def load_member_ranks_from_intranet():
login_url = INTRANET_BASE_URL.rstrip('/') + '/sys/popup/login_ok.php'
search_url = INTRANET_BASE_URL.rstrip('/') + '/sys/controller/codeSearch_controller.php?ActionMode=searchMember'
s = requests.Session()
# session init
s.get(INTRANET_BASE_URL, timeout=20)
payload = {
'user_id': INTRANET_LOGIN_ID,
'user_pw': INTRANET_LOGIN_PW,
'user_ip': '',
'user_contact_area': 'OUT',
'checksaveid': '',
'is_ajax': 1,
}
r = s.post(login_url, data=payload, timeout=20)
if str(r.text).strip() != '1':
raise RuntimeError(f'Intranet login failed: {r.text[:120]}')
rr = s.post(
search_url,
data={'ajax_insertStr': '', 'ajax_returnId01': 'a', 'ajax_returnId02': 'b'},
timeout=20
)
html = rr.content.decode('utf-8', errors='ignore')
rank_map = {}
for tr in re.findall(r']*>(.*?)
', html, re.I | re.S):
tds = re.findall(r']*>(.*?) | ', tr, re.I | re.S)
if len(tds) < 5:
continue
member_no = normalize_member_no(_strip_tags(tds[3]))
rank_name = _strip_tags(tds[4])
if member_no and rank_name:
rank_map[member_no] = rank_name
return rank_map
def replace_project_alias(conn, rows):
conn.execute('DELETE FROM project_alias')
if rows:
conn.executemany(
'INSERT OR REPLACE INTO project_alias (projectCode, shortName) VALUES (?, ?)',
rows
)
conn.commit()
def replace_erp_project_alias_cache(conn, rows):
synced_at = datetime.now().isoformat(timespec='seconds')
conn.execute('DELETE FROM erp_project_alias_cache')
if rows:
conn.executemany(
'''
INSERT OR REPLACE INTO erp_project_alias_cache (sourcePage, projectCode, shortName, syncedAt)
VALUES (?, ?, ?, ?)
''',
[
(
_as_text(row.get('sourcePage')).strip().lower(),
_as_text(row.get('projectCode')).strip().upper(),
_as_text(row.get('shortName')).strip(),
synced_at,
)
for row in rows
if _as_text(row.get('sourcePage')).strip() and _as_text(row.get('projectCode')).strip()
],
)
conn.commit()
return {'count': len(rows), 'syncedAt': synced_at}
def get_project_alias_map(conn):
cur = conn.execute('SELECT projectCode, shortName FROM project_alias')
return {code: short for code, short in cur.fetchall() if code}
def infer_source_page_from_code(project_code=''):
code = _as_text(project_code).strip().upper()
if '-교영-' in code:
return 'sales'
if '-설계-' in code:
return 'design'
if '-시공-' in code:
return 'const'
if '-제조-' in code:
return 'make'
if '-연구-' in code:
return 'research'
return ''
def get_project_alias_name(conn, project_code='', source_page=''):
code = _as_text(project_code).strip().upper()
page_name = _as_text(source_page).strip().lower() or infer_source_page_from_code(code)
if not code:
return ''
if page_name:
row = conn.execute(
'SELECT shortName FROM erp_project_alias_cache WHERE sourcePage = ? AND projectCode = ?',
(page_name, code),
).fetchone()
if row and row[0]:
return row[0]
row = conn.execute('SELECT shortName FROM project_alias WHERE projectCode = ?', (code,)).fetchone()
return row[0] if row and row[0] else ''
def replace_erp_linked_code_cache(conn, source_page, project_code, project_name, business_code, linked_codes):
source_page = _as_text(source_page).strip().lower() or 'const'
project_code = _as_text(project_code).strip()
if not project_code:
return None
payload = linked_codes or {}
synced_at = datetime.now().isoformat(timespec='seconds')
conn.execute(
'''
INSERT OR REPLACE INTO erp_linked_code_cache
(sourcePage, projectCode, projectName, businessCode, salesCode, salesName, designCode, designName, matchedBy, syncedAt)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
''',
(
source_page,
project_code,
_as_text(project_name).strip(),
_extract_business_code_key(business_code),
_as_text(payload.get('salesCode')).strip(),
_as_text(payload.get('salesName')).strip(),
_as_text(payload.get('designCode')).strip(),
_as_text(payload.get('designName')).strip(),
_as_text(payload.get('matchedBy')).strip(),
synced_at,
),
)
conn.commit()
return {'sourcePage': source_page, 'projectCode': project_code, 'syncedAt': synced_at}
def sync_project_alias_caches(conn):
alias_entries = load_project_alias_entries_from_erp()
replace_project_alias(conn, [(item['projectCode'], item['shortName']) for item in alias_entries])
cache_info = replace_erp_project_alias_cache(conn, alias_entries)
return {'entries': alias_entries, 'cacheInfo': cache_info}
def rebuild_erp_linked_code_cache(conn, source_page='const'):
source_page = _as_text(source_page).strip().lower() or 'const'
rows = conn.execute(
'''
SELECT p.projectCode,
p.projectName,
COALESCE(d.businessCode, '')
FROM erp_project_code_cache p
LEFT JOIN erp_contract_detail_cache d
ON d.sourcePage = p.sourcePage
AND d.projectCode = p.projectCode
WHERE p.sourcePage = ?
ORDER BY p.projectCode ASC
''',
(source_page,),
).fetchall()
synced_at = datetime.now().isoformat(timespec='seconds')
payload_rows = []
for project_code, project_name, business_code in rows:
linked_codes = find_erp_linked_codes(project_code, business_code, project_name)
if linked_codes.get('salesCode') or linked_codes.get('designCode'):
payload_rows.append(
(
source_page,
_as_text(project_code).strip(),
_as_text(project_name).strip(),
_extract_business_code_key(business_code),
_as_text(linked_codes.get('salesCode')).strip(),
_as_text(linked_codes.get('salesName')).strip(),
_as_text(linked_codes.get('designCode')).strip(),
_as_text(linked_codes.get('designName')).strip(),
_as_text(linked_codes.get('matchedBy')).strip(),
synced_at,
)
)
conn.execute('DELETE FROM erp_linked_code_cache WHERE sourcePage = ?', (source_page,))
if payload_rows:
conn.executemany(
'''
INSERT OR REPLACE INTO erp_linked_code_cache
(sourcePage, projectCode, projectName, businessCode, salesCode, salesName, designCode, designName, matchedBy, syncedAt)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
''',
payload_rows,
)
conn.commit()
return len(payload_rows)
def rebuild_work_calendar_tables(conn):
conn.execute('DELETE FROM work_calendar_detail')
conn.execute('DELETE FROM work_calendar_day')
conn.execute(
'''
INSERT INTO work_calendar_detail
(source, memberNo, workDate, projectCode, projectName, workText, jobType, hours, regularHours, overtimeHours, personCount)
SELECT
'sql',
d.MemberNo,
IFNULL(d.WorkDate, substr(d.EntryTime, 1, 10)),
IFNULL(d.EntryPCode, ''),
IFNULL(pa.shortName, ''),
IFNULL(d.EntryJob, ''),
IFNULL(d.EntryJobCode, ''),
ROUND(IFNULL(d.TotalHours, 0), 2),
ROUND(IFNULL(d.RegularHours, 0) + IFNULL(d.BusinessTripHours, 0), 2),
ROUND(IFNULL(d.OvertimeHours, 0), 2),
0
FROM dallyproject d
LEFT JOIN project_alias pa ON pa.projectCode = d.EntryPCode
WHERE IFNULL(d.MemberNo, '') <> ''
AND IFNULL(IFNULL(d.WorkDate, substr(d.EntryTime, 1, 10)), '') <> ''
'''
)
conn.execute(
'''
INSERT INTO work_calendar_detail
(source, memberNo, workDate, projectCode, projectName, workText, jobType, hours, regularHours, overtimeHours, personCount)
SELECT
'site',
s.memberNo,
s.workDate,
IFNULL(s.projectCode, ''),
IFNULL(pa.shortName, ''),
IFNULL(s.workText, ''),
IFNULL(s.jobType, ''),
0,
0,
0,
ROUND(IFNULL(s.personCount, 0), 2)
FROM site_worksheet_record s
LEFT JOIN project_alias pa ON pa.projectCode = s.projectCode
WHERE IFNULL(s.memberNo, '') <> ''
AND IFNULL(s.workDate, '') <> ''
'''
)
conn.execute(
'''
INSERT INTO work_calendar_day
(memberNo, workDate, korName, teamName, rankName, sqlHours, sqlRegularHours, sqlOvertimeHours,
sqlProjectCodes, siteCount, siteProjectCodes, siteWorkTexts, hasSql, hasSite)
SELECT
d.memberNo,
d.workDate,
IFNULL(m.korName, ''),
IFNULL(m.teamName, ''),
IFNULL(m.rankName, ''),
ROUND(SUM(CASE WHEN d.source = 'sql' THEN d.hours ELSE 0 END), 2),
ROUND(SUM(CASE WHEN d.source = 'sql' THEN d.regularHours ELSE 0 END), 2),
ROUND(SUM(CASE WHEN d.source = 'sql' THEN d.overtimeHours ELSE 0 END), 2),
GROUP_CONCAT(DISTINCT CASE WHEN d.source = 'sql' AND d.projectCode <> '' THEN d.projectCode END),
ROUND(SUM(CASE WHEN d.source = 'site' THEN d.personCount ELSE 0 END), 2),
GROUP_CONCAT(DISTINCT CASE WHEN d.source = 'site' AND d.projectCode <> '' THEN d.projectCode END),
GROUP_CONCAT(DISTINCT CASE WHEN d.source = 'site' AND d.workText <> '' THEN d.workText END),
MAX(CASE WHEN d.source = 'sql' THEN 1 ELSE 0 END),
MAX(CASE WHEN d.source = 'site' THEN 1 ELSE 0 END)
FROM work_calendar_detail d
LEFT JOIN member m ON m.MemberNo = d.memberNo
GROUP BY d.memberNo, d.workDate, IFNULL(m.korName, ''), IFNULL(m.teamName, ''), IFNULL(m.rankName, '')
'''
)
conn.commit()
return {
'days': conn.execute('SELECT COUNT(*) FROM work_calendar_day').fetchone()[0],
'details': conn.execute('SELECT COUNT(*) FROM work_calendar_detail').fetchone()[0],
}
def _mysql_connect():
try:
import pymysql
except Exception as e:
raise RuntimeError('pymysql 모듈이 필요합니다. `pip install pymysql` 실행 후 다시 시도하세요.') from e
last_err = None
for cs in ('utf8mb4', 'utf8'):
try:
return pymysql.connect(
host=MYSQL_HOST,
user=MYSQL_USER,
password=MYSQL_PASSWORD,
database=MYSQL_DB,
port=MYSQL_PORT,
charset=cs,
cursorclass=pymysql.cursors.DictCursor,
autocommit=True,
)
except Exception as e:
last_err = e
raise last_err
def fetch_mysql_tables():
mysql_conn = _mysql_connect()
try:
with mysql_conn.cursor() as cur:
cur.execute("SHOW TABLES")
rows = cur.fetchall() or []
out = []
for row in rows:
if isinstance(row, dict):
out.extend([_as_text(v).strip() for v in row.values() if _as_text(v).strip()])
else:
out.extend([_as_text(v).strip() for v in row if _as_text(v).strip()])
return sorted(set(out))
finally:
try:
mysql_conn.close()
except Exception:
pass
def fetch_mysql_table_preview(table_name, limit=100):
tn = _as_text(table_name).strip()
if not re.fullmatch(r'[A-Za-z0-9_]+', tn):
raise ValueError('table 파라미터가 올바르지 않습니다.')
row_limit = max(1, min(int(limit or 100), 500))
mysql_conn = _mysql_connect()
try:
with mysql_conn.cursor() as cur:
cur.execute(f"SELECT * FROM `{tn}` LIMIT {row_limit}")
rows = cur.fetchall() or []
return rows
finally:
try:
mysql_conn.close()
except Exception:
pass
def _best_project_table_candidate(table_cols):
code_aliases = (
'projectcode', 'project_code', 'pcode', 'projcode', 'jobcode', 'workcode', 'code'
)
name_aliases = (
'projectnickname', 'project_name', 'projectname', 'codename', 'code_name', 'name', 'title'
)
excluded_terms = ('member', 'systemconfig', 'config', 'tardy', 'worksheet', 'calendar')
candidates = []
for table_name, cols in table_cols.items():
code_key = _find_col_key(cols, code_aliases)
name_key = _find_col_key(cols, name_aliases)
if not code_key or not name_key:
continue
table_l = table_name.lower()
code_col = cols[code_key]
name_col = cols[name_key]
score = 0
if any(term in table_l for term in ('project', 'projt', 'proj')):
score += 80
if any(term in table_l for term in ('list', 'info', 'master', 'base')):
score += 20
if code_key in ('projectcode', 'project_code', 'pcode', 'projcode'):
score += 25
if name_key in ('projectnickname', 'projectname', 'project_name', 'codename', 'code_name'):
score += 25
if code_key == 'code':
score -= 10
if name_key == 'name':
score -= 5
if any(term in table_l for term in excluded_terms):
score -= 60
if code_col == name_col:
score -= 100
candidates.append((score, table_name, code_col, name_col))
candidates.sort(reverse=True)
return candidates[0] if candidates else None
def fetch_mysql_project_codes(limit=2000):
row_limit = max(1, min(int(limit or 2000), 10000))
mysql_conn = _mysql_connect()
try:
_, _, _, table_cols = discover_mysql_tables(mysql_conn)
candidate = _best_project_table_candidate(table_cols)
if not candidate:
raise RuntimeError('프로젝트 코드/코드명 컬럼이 있는 MySQL 테이블을 찾지 못했습니다.')
_, table_name, code_col, name_col = candidate
query = f'''
SELECT DISTINCT
TRIM({_quote_ident(code_col)}) AS projectCode,
TRIM({_quote_ident(name_col)}) AS projectName
FROM {_quote_ident(table_name)}
WHERE TRIM(IFNULL({_quote_ident(code_col)}, '')) <> ''
AND TRIM(IFNULL({_quote_ident(name_col)}, '')) <> ''
ORDER BY TRIM({_quote_ident(code_col)}) ASC
LIMIT {row_limit}
'''
with mysql_conn.cursor() as cur:
cur.execute(query)
rows = cur.fetchall() or []
items = []
seen = set()
for row in rows:
code = _as_text(row.get('projectCode')).strip()
name = _as_text(row.get('projectName')).strip()
key = (code, name)
if not code or not name or key in seen:
continue
seen.add(key)
items.append({'projectCode': code, 'projectName': name})
return {
'sourceTable': table_name,
'sourceColumns': {'projectCode': code_col, 'projectName': name_col},
'rows': items,
}
finally:
try:
mysql_conn.close()
except Exception:
pass
def fetch_people_active_in_2020():
mysql_conn = _mysql_connect()
try:
def _norm_code(v):
s = _as_text(v).strip()
if not s:
return ''
if s.isdigit():
return str(int(s))
return s
with mysql_conn.cursor() as cur:
cur.execute(
'''
SELECT Code, Name
FROM systemconfig_tbl
WHERE SysKey = 'WorkPositionCode'
'''
)
wp_map = {}
for r in cur.fetchall() or []:
code = _as_text(r.get('Code')).strip()
name = _as_text(r.get('Name')).strip()
if code:
wp_map[code] = name
cur.execute(
'''
SELECT Code, Name
FROM systemconfig_tbl
WHERE SysKey IN ('GroupCode', 'GroupCode_del')
'''
)
team_map = {}
for r in cur.fetchall() or []:
code = _norm_code(r.get('Code'))
name = _as_text(r.get('Name')).strip()
if code and code not in team_map:
team_map[code] = name
cur.execute(
'''
SELECT Code, Name
FROM systemconfig_tbl
WHERE SysKey = 'PositionCode'
'''
)
rank_map = {}
for r in cur.fetchall() or []:
code = _norm_code(r.get('Code'))
name = _as_text(r.get('Name')).strip()
if code and code not in rank_map:
rank_map[code] = name
cur.execute(
'''
SELECT
MemberNo,
korName,
RankCode,
GroupCode,
WorkPosition,
EntryDate,
LeaveDate,
eMail
FROM member_tbl
WHERE EntryDate <= '2020-12-31'
AND (
LeaveDate IS NULL
OR LeaveDate = '0000-00-00'
OR LeaveDate > '2020-12-31'
)
ORDER BY korName ASC, MemberNo ASC
'''
)
rows = []
for r in cur.fetchall() or []:
wp_code = _as_text(r.get('WorkPosition')).strip()
group_code_raw = _as_text(r.get('GroupCode')).strip()
group_code = _norm_code(group_code_raw)
rank_code_raw = _as_text(r.get('RankCode')).strip()
rank_code = _norm_code(rank_code_raw)
rows.append(
{
'MemberNo': _as_text(r.get('MemberNo')).strip(),
'korName': _as_text(r.get('korName')).strip(),
'RankCode': rank_code_raw,
'RankName': _as_text(rank_map.get(rank_code, '')).strip(),
'GroupCode': group_code_raw,
'TeamName': _as_text(team_map.get(group_code, '')).strip(),
'WorkPosition': wp_code,
'WorkPositionName': _as_text(wp_map.get(wp_code, '')).strip(),
'EntryDate': _as_text(r.get('EntryDate')).strip(),
'LeaveDate': _as_text(r.get('LeaveDate')).strip(),
'eMail': _as_text(r.get('eMail')).strip(),
}
)
return rows
finally:
try:
mysql_conn.close()
except Exception:
pass
def _quote_ident(name):
return '`' + str(name).replace('`', '``') + '`'
def discover_mysql_tables(mysql_conn):
with mysql_conn.cursor() as cur:
cur.execute(
'''
SELECT TABLE_NAME, COLUMN_NAME
FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = %s
''',
(MYSQL_DB,),
)
rows = cur.fetchall()
table_cols = {}
for r in rows:
t = r['TABLE_NAME']
c = r['COLUMN_NAME']
table_cols.setdefault(t, {})[c.lower()] = c
member_table = None
daily_tables = []
for t, cols in table_cols.items():
if 'memberno' in cols and 'korname' in cols and member_table is None:
member_table = t
for t, cols in table_cols.items():
if 'memberno' in cols and 'entrytime' in cols and 'entrypcode' in cols:
daily_tables.append(t)
# avoid loading member table as daily table
if member_table in daily_tables:
daily_tables = [t for t in daily_tables if t != member_table]
tardy_tables = []
for t, cols in table_cols.items():
if 'memberno' in cols and 's_date' in cols and 'e_date' in cols and 'tardy_h' in cols and 'tardy_m' in cols:
tardy_tables.append(t)
return member_table, sorted(daily_tables), sorted(tardy_tables), table_cols
def load_rank_map_from_systemconfig(mysql_conn, table_cols):
# rankCode는 systemconfig의 PositionCode 기준으로 매핑
mapped = load_syskey_code_name_map(mysql_conn, table_cols, 'PositionCode')
if mapped:
return mapped
# fallback
return load_syskey_code_name_map(mysql_conn, table_cols, 'PositionRank')
def load_syskey_code_name_map(mysql_conn, table_cols, target_syskey):
target = str(target_syskey or '').strip().lower()
if not target:
return {}
candidates = []
for t, cols in table_cols.items():
keys = {k.lower(): v for k, v in cols.items()}
syskey_k = _find_col_key(keys, ('syskey', 'sys_key'))
code_k = _find_col_key(keys, ('code',))
name_k = _find_col_key(keys, ('name', 'codename', 'rankname', 'value'))
if syskey_k and code_k and name_k:
score = 10 + (40 if 'systemconfig' in t.lower() else 0)
candidates.append((score, t, keys[syskey_k], keys[code_k], keys[name_k]))
if not candidates:
return {}
candidates.sort(reverse=True)
_, table_name, syskey_col, code_col, name_col = candidates[0]
q = f'''
SELECT {_quote_ident(code_col)} AS code,
{_quote_ident(name_col)} AS name
FROM {_quote_ident(table_name)}
WHERE LOWER(TRIM({_quote_ident(syskey_col)})) = %s
'''
out = {}
with mysql_conn.cursor() as cur:
cur.execute(q, (target,))
for r in cur.fetchall():
code = _as_text(r.get('code')).strip()
name = _as_text(r.get('name')).strip()
if code:
out[code] = name
nk = _norm_code_token(code)
if nk and nk not in out:
out[nk] = name
return out
def load_from_mysql_into_sqlite(sqlite_conn):
mysql_conn = _mysql_connect()
try:
member_table, daily_tables, tardy_tables, table_cols = discover_mysql_tables(mysql_conn)
if not member_table:
raise RuntimeError('MySQL에서 member 테이블(컬럼: MemberNo, korName)을 찾지 못했습니다.')
if not daily_tables:
raise RuntimeError('MySQL에서 일지 테이블(컬럼: MemberNo, EntryTime, EntryPCode)을 찾지 못했습니다.')
# load member
mcols = table_cols[member_table]
m_memberno = _quote_ident(mcols['memberno'])
m_korname = _quote_ident(mcols['korname'])
rank_col_key = _find_col_key(mcols, ('rankcode', 'rank_code', 'rkcode', 'jobgrade', 'gradecode'))
m_rankcode = _quote_ident(mcols[rank_col_key]) if rank_col_key else "''"
group_col_key = _find_col_key(mcols, ('groupcode', 'group_code', 'teamcode', 'team_code', 'groupid', 'group_id'))
m_groupcode = _quote_ident(mcols[group_col_key]) if group_col_key else "''"
workpos_col_key = _find_col_key(mcols, ('workposition', 'work_position', 'workstate', 'work_state', 'statecode', 'state_code'))
m_workpos = _quote_ident(mcols[workpos_col_key]) if workpos_col_key else "''"
retire_col_key = _find_col_key(
mcols,
(
'isretired', 'retired', 'retireyn', 'retire_yn', 'quityn', 'quit_yn', 'resignyn', 'resign_yn',
'outyn', 'out_yn', 'useyn', 'use_yn', 'workyn', 'work_yn', 'status', 'state', 'empstate',
'emp_state', 'retireflag', 'retire_flag', 'quitdate', 'leave_date', 'leavedate', 'retiredate'
),
)
m_retire = _quote_ident(mcols[retire_col_key]) if retire_col_key else "''"
mt = _quote_ident(member_table)
workpos_map = load_syskey_code_name_map(mysql_conn, table_cols, 'WorkPositionCode')
group_map = load_syskey_code_name_map(mysql_conn, table_cols, 'GroupCode')
group_map_del = load_syskey_code_name_map(mysql_conn, table_cols, 'GroupCode_del')
if group_map_del:
for k, v in group_map_del.items():
if k not in group_map:
group_map[k] = v
member_rows = []
with mysql_conn.cursor() as cur:
cur.execute(
f"SELECT {m_memberno} AS MemberNo, {m_korname} AS korName, {m_rankcode} AS rankCode, {m_groupcode} AS groupCode, {m_workpos} AS workPosition, {m_retire} AS retireFlag FROM {mt}"
)
for r in cur.fetchall():
mno = normalize_member_no(r.get('MemberNo'))
if not mno:
continue
retire_raw = _as_text(r.get('retireFlag')).strip()
workpos_code = _as_text(r.get('workPosition')).strip()
workpos_name = _as_text(workpos_map.get(workpos_code) or workpos_map.get(_norm_code_token(workpos_code))).strip()
group_code = _as_text(r.get('groupCode')).strip()
team_name = _as_text(group_map.get(group_code) or group_map.get(_norm_code_token(group_code))).strip()
is_ret = 0
is_ret = max(is_ret, parse_retired_flag(retire_raw, retire_col_key))
if workpos_code == '9' or ('퇴사' in workpos_name):
is_ret = 1
# retireFlag에 원본 퇴사값이 없으면 WorkPosition 기반 상태명을 사용
retire_flag = retire_raw or workpos_name or workpos_code
member_rows.append(
(
mno,
_as_text(r.get('korName')).strip(),
_as_text(r.get('rankCode')).strip(),
'',
group_code,
team_name,
retire_flag,
is_ret,
)
)
insert_member_rows(sqlite_conn, member_rows)
rank_map = load_rank_map_from_systemconfig(mysql_conn, table_cols)
rank_loaded = update_member_ranks_by_rankcode(sqlite_conn, rank_map)
# load tardy rules
tardy_rows = []
for t in tardy_tables:
cols = table_cols[t]
q = f'''
SELECT {_quote_ident(cols['memberno'])} AS memberno,
{_quote_ident(cols['s_date'])} AS s_date,
{_quote_ident(cols['e_date'])} AS e_date,
{_quote_ident(cols['tardy_h'])} AS tardy_h,
{_quote_ident(cols['tardy_m'])} AS tardy_m
FROM {_quote_ident(t)}
'''
with mysql_conn.cursor() as cur:
cur.execute(q)
for r in cur.fetchall():
mno = normalize_member_no(r.get('memberno'))
sd = (r.get('s_date') or '').__str__()[:10]
ed = (r.get('e_date') or '').__str__()[:10]
try:
th = int(float(r.get('tardy_h') or 9))
tm = int(float(r.get('tardy_m') or 0))
except Exception:
th, tm = 9, 0
if mno and sd and ed:
tardy_rows.append((mno, sd, ed, th, tm))
insert_worker_tardy(sqlite_conn, tardy_rows)
tardy_map = build_tardy_map(sqlite_conn)
# load daily tables with dedupe
sqlite_conn.execute('DELETE FROM dallyproject')
seen_rows = set()
loaded_files = []
total_rows = 0
total_dups = 0
aliases = [
'MemberNo', 'EntryTime', 'EntryPCode', 'EntryJobCode', 'EntryJob',
'LeaveTime', 'LeavePCode', 'LeaveJobCode', 'LeaveJob', 'OverTime',
'ConnectIP', 'OverWorkIP', 'EndWorkIP', 'SortKey', 'Note', 'modify', 'ConfirmDate'
]
for t in daily_tables:
cols = table_cols[t]
select_parts = []
for a in aliases:
key = a.lower()
if key in cols:
select_parts.append(f"COALESCE({_quote_ident(cols[key])}, '') AS {a}")
else:
select_parts.append(f"'' AS {a}")
query = f"SELECT {', '.join(select_parts)} FROM {_quote_ident(t)}"
inserted = 0
dups = 0
batch = []
with mysql_conn.cursor() as cur:
cur.execute(query)
while True:
chunk = cur.fetchmany(5000)
if not chunk:
break
for r in chunk:
row = (
normalize_member_no(r.get('MemberNo')),
_as_text(r.get('EntryTime')).strip(),
_as_text(r.get('EntryPCode')).strip(),
_as_text(r.get('EntryJobCode')).strip(),
_as_text(r.get('EntryJob')).strip(),
_as_text(r.get('LeaveTime')).strip(),
_as_text(r.get('LeavePCode')).strip(),
_as_text(r.get('LeaveJobCode')).strip(),
_as_text(r.get('LeaveJob')).strip(),
_as_text(r.get('OverTime')).strip(),
_as_text(r.get('ConnectIP')).strip(),
_as_text(r.get('OverWorkIP')).strip(),
_as_text(r.get('EndWorkIP')).strip(),
_as_text(r.get('SortKey')).strip(),
_as_text(r.get('Note')).strip(),
_as_text(r.get('modify')).strip(),
_as_text(r.get('ConfirmDate')).strip(),
)
if row in seen_rows:
dups += 1
continue
seen_rows.add(row)
work_date, total_h, regular_h, overtime_h, business_trip_h = compute_hours(
row[1], row[5], row[9], row[0], tardy_map, row[4], row[8], row[3], row[7]
)
batch.append(row + (work_date, total_h, regular_h, overtime_h, business_trip_h))
if batch:
insert_daily_rows(sqlite_conn, batch)
inserted += len(batch)
batch = []
loaded_files.append({'file': t, 'rows': inserted, 'duplicatesSkipped': dups})
total_rows += inserted
total_dups += dups
sqlite_conn.commit()
return {
'source': 'mysql',
'member_loaded': len(member_rows),
'member_rank_source': 'mysql_systemconfig' if rank_map else 'mysql_systemconfig_not_found',
'member_rank_loaded': rank_loaded,
'dally_loaded': total_rows,
'duplicates_skipped': total_dups,
'daily_files': loaded_files,
}
finally:
try:
mysql_conn.close()
except Exception:
pass
def get_stats(conn):
total = conn.execute('SELECT COUNT(*) FROM dallyproject').fetchone()[0]
matched = conn.execute(
'''
SELECT COUNT(*)
FROM dallyproject d
JOIN member m ON m.MemberNo = d.MemberNo
WHERE IFNULL(m.korName, '') <> ''
'''
).fetchone()[0]
return {'total': total, 'matched': matched, 'unmatched': total - matched}
def _duration_sql(alias='d'):
return f'''
CASE
WHEN {alias}.EntryTime IS NULL OR {alias}.LeaveTime IS NULL OR {alias}.EntryTime = '' OR {alias}.LeaveTime = '' THEN 0
ELSE (
(
strftime('%s', CASE WHEN length({alias}.LeaveTime) = 16 THEN {alias}.LeaveTime || ':00' ELSE {alias}.LeaveTime END) -
strftime('%s', CASE WHEN length({alias}.EntryTime) = 16 THEN {alias}.EntryTime || ':00' ELSE {alias}.EntryTime END) +
CASE
WHEN strftime('%s', CASE WHEN length({alias}.LeaveTime) = 16 THEN {alias}.LeaveTime || ':00' ELSE {alias}.LeaveTime END) <
strftime('%s', CASE WHEN length({alias}.EntryTime) = 16 THEN {alias}.EntryTime || ':00' ELSE {alias}.EntryTime END)
THEN 86400
ELSE 0
END
) / 3600.0
)
END
'''
def get_people_summary(conn, start_date, end_date):
params = (start_date, end_date, start_date, end_date)
q_with_site = f'''
WITH d AS (
SELECT
MemberNo,
ROUND(SUM(TotalHours), 2) AS totalHours,
COUNT(*) AS totalRows
FROM dallyproject
WHERE date(substr(EntryTime,1,10)) BETWEEN date(?) AND date(?)
GROUP BY MemberNo
),
s AS (
SELECT
memberNo AS MemberNo,
ROUND(SUM(IFNULL(personCount, 0) * 8), 2) AS siteHours,
COUNT(*) AS siteRows
FROM site_worksheet_record
WHERE date(workDate) BETWEEN date(?) AND date(?)
GROUP BY memberNo
)
SELECT
m.MemberNo,
IFNULL(m.korName, '') AS korName,
IFNULL(m.rankName, '') AS rankName,
IFNULL(m.teamName, '') AS teamName,
IFNULL(m.retireFlag, '') AS retireFlag,
IFNULL(m.isRetired, 0) AS isRetired,
IFNULL(d.totalHours, 0) AS totalHours,
IFNULL(d.totalRows, 0) AS totalRows,
IFNULL(s.siteHours, 0) AS siteHours,
IFNULL(s.siteRows, 0) AS siteRows
FROM member m
LEFT JOIN d ON d.MemberNo = m.MemberNo
LEFT JOIN s ON s.MemberNo = m.MemberNo
ORDER BY CASE WHEN IFNULL(m.korName,'')='' THEN 1 ELSE 0 END, korName ASC, m.MemberNo ASC
'''
try:
cur = conn.execute(q_with_site, params)
except sqlite3.DatabaseError as error:
if 'malformed' not in str(error).lower():
raise
q_without_site = f'''
WITH d AS (
SELECT
MemberNo,
ROUND(SUM(TotalHours), 2) AS totalHours,
COUNT(*) AS totalRows
FROM dallyproject
WHERE date(substr(EntryTime,1,10)) BETWEEN date(?) AND date(?)
GROUP BY MemberNo
)
SELECT
m.MemberNo,
IFNULL(m.korName, '') AS korName,
IFNULL(m.rankName, '') AS rankName,
IFNULL(m.teamName, '') AS teamName,
IFNULL(m.retireFlag, '') AS retireFlag,
IFNULL(m.isRetired, 0) AS isRetired,
IFNULL(d.totalHours, 0) AS totalHours,
IFNULL(d.totalRows, 0) AS totalRows,
0 AS siteHours,
0 AS siteRows
FROM member m
LEFT JOIN d ON d.MemberNo = m.MemberNo
ORDER BY CASE WHEN IFNULL(m.korName,'')='' THEN 1 ELSE 0 END, korName ASC, m.MemberNo ASC
'''
cur = conn.execute(q_without_site, (start_date, end_date))
cols = [d[0] for d in cur.description]
return [dict(zip(cols, row)) for row in cur.fetchall()]
def get_people_unified(conn, start_date, end_date, include_retired=False):
q = '''
SELECT
m.MemberNo,
IFNULL(m.korName, '') AS korName,
IFNULL(m.rankName, '') AS rankName,
IFNULL(m.teamName, '') AS teamName,
IFNULL(m.retireFlag, '') AS retireFlag,
IFNULL(m.isRetired, 0) AS isRetired,
ROUND(IFNULL(SUM(CASE WHEN date(substr(d.EntryTime,1,10)) BETWEEN date(?) AND date(?) THEN d.TotalHours ELSE 0 END), 0), 2) AS totalHours,
ROUND(IFNULL(SUM(CASE WHEN date(substr(d.EntryTime,1,10)) BETWEEN date(?) AND date(?) THEN d.RegularHours + IFNULL(d.BusinessTripHours, 0) ELSE 0 END), 0), 2) AS regularHours,
ROUND(IFNULL(SUM(CASE WHEN date(substr(d.EntryTime,1,10)) BETWEEN date(?) AND date(?) THEN d.OvertimeHours ELSE 0 END), 0), 2) AS overtimeHours,
IFNULL(SUM(CASE WHEN date(substr(d.EntryTime,1,10)) BETWEEN date(?) AND date(?) THEN 1 ELSE 0 END), 0) AS totalRows,
MAX(CASE WHEN date(substr(d.EntryTime,1,10)) BETWEEN date(?) AND date(?) THEN substr(d.EntryTime,1,10) ELSE '' END) AS lastWorkDate
FROM member m
LEFT JOIN dallyproject d ON d.MemberNo = m.MemberNo
GROUP BY m.MemberNo, IFNULL(m.korName, ''), IFNULL(m.rankName, ''), IFNULL(m.teamName, ''), IFNULL(m.retireFlag, ''), IFNULL(m.isRetired, 0)
'''
params = [
start_date, end_date, start_date, end_date, start_date, end_date,
start_date, end_date, start_date, end_date
]
cur = conn.execute(q, params)
cols = [d[0] for d in cur.description]
rows = [dict(zip(cols, row)) for row in cur.fetchall()]
proj_q = '''
SELECT EntryPCode, ROUND(SUM(TotalHours), 2) AS hours
FROM dallyproject
WHERE MemberNo = ?
AND date(substr(EntryTime,1,10)) BETWEEN date(?) AND date(?)
AND IFNULL(EntryPCode,'') <> ''
GROUP BY EntryPCode
ORDER BY hours DESC, EntryPCode ASC
LIMIT 1
'''
for r in rows:
p = conn.execute(proj_q, (r['MemberNo'], start_date, end_date)).fetchone()
r['topProjectCode'] = p[0] if p else ''
r['topProjectHours'] = p[1] if p else 0
if not include_retired:
rows = [r for r in rows if int(r.get('isRetired') or 0) == 0]
rows.sort(key=lambda x: (-float(x.get('totalHours') or 0), x.get('korName') or '', x.get('MemberNo') or ''))
return rows
def get_project_summary(conn, start_date, end_date):
q = f'''
SELECT d.EntryPCode AS projectCode,
ROUND(SUM(d.TotalHours), 2) AS totalHours,
ROUND(SUM(d.RegularHours + IFNULL(d.BusinessTripHours, 0)), 2) AS regularHours,
ROUND(SUM(d.OvertimeHours), 2) AS overtimeHours,
COUNT(*) AS totalRows,
COUNT(DISTINCT d.MemberNo) AS peopleCount
FROM dallyproject d
WHERE date(substr(d.EntryTime, 1, 10)) >= date(?)
AND date(substr(d.EntryTime, 1, 10)) <= date(?)
AND IFNULL(d.EntryPCode, '') <> ''
GROUP BY d.EntryPCode
HAVING totalHours > 0
ORDER BY totalHours DESC, totalRows DESC
'''
cur = conn.execute(q, (start_date, end_date))
cols = [d[0] for d in cur.description]
return [dict(zip(cols, row)) for row in cur.fetchall()]
def get_member_dashboard(conn, start_date, end_date, member_no):
where = "date(substr(d.EntryTime,1,10)) >= date(?) AND date(substr(d.EntryTime,1,10)) <= date(?)"
params = [start_date, end_date]
if member_no:
where += " AND d.MemberNo = ?"
params.append(member_no)
summary_q = f'''
SELECT ROUND(SUM(d.TotalHours), 2) AS totalHours,
ROUND(SUM(d.RegularHours + IFNULL(d.BusinessTripHours, 0)), 2) AS regularHours,
ROUND(SUM(d.OvertimeHours), 2) AS overtimeHours,
ROUND(SUM(d.BusinessTripHours), 2) AS businessTripHours,
COUNT(*) AS totalRows,
COUNT(DISTINCT CASE WHEN d.TotalHours > 0 THEN d.MemberNo END) AS peopleCount,
COUNT(DISTINCT CASE WHEN d.TotalHours > 0 AND IFNULL(d.EntryPCode,'') <> '' THEN d.EntryPCode END) AS projectCount
FROM dallyproject d
WHERE {where}
'''
total_hours, regular_hours, overtime_hours, business_trip_hours, total_rows, people_count, project_count = conn.execute(summary_q, params).fetchone()
project_q = f'''
SELECT d.EntryPCode AS projectCode,
ROUND(SUM(d.TotalHours), 2) AS hours,
ROUND(SUM(d.RegularHours + IFNULL(d.BusinessTripHours, 0)), 2) AS regularHours,
ROUND(SUM(d.OvertimeHours), 2) AS overtimeHours,
ROUND(SUM(d.BusinessTripHours), 2) AS businessTripHours,
COUNT(*) AS rowsCount
FROM dallyproject d
WHERE {where}
GROUP BY d.EntryPCode
HAVING hours > 0
ORDER BY hours DESC
'''
cur = conn.execute(project_q, params)
cols = [d[0] for d in cur.description]
projects = [dict(zip(cols, row)) for row in cur.fetchall()]
yearly_q = f'''
SELECT substr(d.EntryTime, 1, 7) AS yearMonth,
substr(d.EntryTime, 1, 4) AS yearCode,
d.EntryPCode AS projectCode,
CASE
WHEN instr(d.EntryPCode, '-') = 0 THEN '기타'
ELSE
CASE
WHEN instr(substr(d.EntryPCode, instr(d.EntryPCode, '-') + 1), '-') = 0 THEN '기타'
ELSE substr(d.EntryPCode, instr(d.EntryPCode, '-') + 1, instr(substr(d.EntryPCode, instr(d.EntryPCode, '-') + 1), '-') - 1)
END
END AS typeCode,
ROUND(SUM(d.TotalHours), 2) AS hours,
ROUND(SUM(d.RegularHours + IFNULL(d.BusinessTripHours, 0)), 2) AS regularHours,
ROUND(SUM(d.OvertimeHours), 2) AS overtimeHours,
ROUND(SUM(d.BusinessTripHours), 2) AS businessTripHours,
COUNT(*) AS rowsCount
FROM dallyproject d
WHERE {where}
GROUP BY yearMonth, yearCode, projectCode, typeCode
HAVING hours > 0
ORDER BY yearMonth ASC, hours DESC
'''
cur2 = conn.execute(yearly_q, params)
cols2 = [d[0] for d in cur2.description]
project_yearly = [dict(zip(cols2, row)) for row in cur2.fetchall()]
member_name = ''
member_rank = ''
member_team = ''
if member_no:
r = conn.execute(
'SELECT IFNULL(korName, ""), IFNULL(rankName, ""), IFNULL(teamName, "") FROM member WHERE MemberNo = ?',
(member_no,),
).fetchone()
if r:
member_name, member_rank, member_team = r[0], r[1], r[2]
return {
'start': start_date,
'end': end_date,
'memberNo': member_no,
'korName': member_name,
'rankName': member_rank,
'teamName': member_team,
'totalHours': total_hours or 0,
'regularHours': regular_hours or 0,
'overtimeHours': overtime_hours or 0,
'businessTripHours': business_trip_hours or 0,
'totalRows': total_rows or 0,
'peopleCount': people_count or 0,
'projectCount': project_count or 0,
'projects': projects,
'projectYearly': project_yearly,
}
def get_member_daily_calendar(conn, start_date, end_date, member_no):
if not member_no:
return {'rows': [], 'dayStates': []}
q = '''
SELECT
IFNULL(d.WorkDate, substr(d.EntryTime, 1, 10)) AS rawWorkDate,
IFNULL(d.EntryPCode, '') AS projectCode,
IFNULL(d.MemberNo, '') AS memberNo,
IFNULL(d.EntryTime, '') AS entryTime,
IFNULL(d.LeaveTime, '') AS leaveTime,
IFNULL(d.OverTime, '') AS overTime,
IFNULL(d.EntryJob, '') AS entryJob,
IFNULL(d.LeaveJob, '') AS leaveJob,
IFNULL(d.EntryJobCode, '') AS entryJobCode,
IFNULL(d.LeaveJobCode, '') AS leaveJobCode
FROM dallyproject d
WHERE d.MemberNo = ?
AND date(IFNULL(d.WorkDate, substr(d.EntryTime, 1, 10))) >= date(?)
AND date(IFNULL(d.WorkDate, substr(d.EntryTime, 1, 10))) <= date(?)
AND IFNULL(IFNULL(d.WorkDate, substr(d.EntryTime, 1, 10)), '') <> ''
'''
cur = conn.execute(q, (member_no, start_date, end_date))
tardy_map = build_tardy_map(conn)
agg = {}
for raw_work_date, project_code, row_member_no, entry_time, leave_time, over_time, entry_job, leave_job, entry_job_code, leave_job_code in cur.fetchall():
work_date, total_hours, regular_hours, overtime_hours, _biz_trip_hours = compute_hours(
entry_time,
leave_time,
over_time,
row_member_no or member_no,
tardy_map,
entry_job or '',
leave_job or '',
entry_job_code or '',
leave_job_code or '',
)
wd = _as_text(work_date or raw_work_date).strip()
pc = _as_text(project_code).strip()
if not wd:
continue
key = (wd, pc)
if key not in agg:
agg[key] = {'hours': 0.0, 'regularHours': 0.0, 'overtimeHours': 0.0, 'rowsCount': 0}
agg[key]['hours'] += float(total_hours or 0)
agg[key]['regularHours'] += float(regular_hours or 0)
agg[key]['overtimeHours'] += float(overtime_hours or 0)
agg[key]['rowsCount'] += 1
rows = []
for (wd, pc), v in agg.items():
rows.append(
{
'workDate': wd,
'projectCode': pc,
'hours': round(v['hours'], 2),
'regularHours': round(v['regularHours'], 2),
'overtimeHours': round(v['overtimeHours'], 2),
'rowsCount': v['rowsCount'],
}
)
rows.sort(key=lambda x: (x['workDate'], -(x['hours'] or 0), x['projectCode']))
# userstate 반영: 시차/반차/연차/출장 (MySQL 원본 테이블 조회)
day_states = {}
day_shift_hours = {}
try:
mysql_conn = _mysql_connect()
try:
state_name_by_code = {}
with mysql_conn.cursor() as cur:
cur.execute(
"SELECT Code, Name FROM systemconfig_tbl WHERE SysKey='UserStateCode'"
)
for r in cur.fetchall() or []:
k = _as_text(r.get('Code')).strip()
if k.isdigit():
k = str(int(k))
state_name_by_code[k] = _as_text(r.get('Name')).strip()
cur.execute(
'''
SELECT state, start_time, end_time, sub_code, note, ProjectCode
FROM userstate_tbl
WHERE UPPER(MemberNo) = UPPER(%s)
AND start_time <= %s
AND end_time >= %s
''',
(member_no, end_date, start_date),
)
state_rows = cur.fetchall() or []
rs = parse_d(start_date)
re = parse_d(end_date)
for r in state_rows:
s = parse_d(r.get('start_time'))
e = parse_d(r.get('end_time'))
if not s or not e or not rs or not re:
continue
if e < s:
s, e = e, s
s = max(s, rs)
e = min(e, re)
code = _as_text(r.get('state')).strip()
if code.isdigit():
code = str(int(code))
name = state_name_by_code.get(code, '')
note = _as_text(r.get('note')).strip()
shift_hours = 0.0
if name == '시차':
sc = _as_text(r.get('sub_code')).strip()
try:
shift_hours = float(sc) if sc else 0.0
except Exception:
shift_hours = 0.0
extra_labels = []
if '지각' in note:
extra_labels.append('지각')
if '반차' in note:
label = '반차'
elif name == '휴가':
label = '연차'
elif name in ('오전반차', '오후반차'):
label = '반차'
elif name in ('시차', '출장'):
label = name
else:
continue
d = s
while d <= e:
key = d.isoformat()
day_states.setdefault(key, set()).add(label)
for extra_label in extra_labels:
day_states.setdefault(key, set()).add(extra_label)
if shift_hours > 0:
day_shift_hours[key] = day_shift_hours.get(key, 0.0) + shift_hours
d = d + timedelta(days=1)
finally:
try:
mysql_conn.close()
except Exception:
pass
except Exception:
# userstate 조회 실패 시에도 달력 본문은 동작 유지
day_states = {}
day_state_rows = []
for k in sorted(day_states.keys()):
day_state_rows.append({'workDate': k, 'states': sorted(day_states[k])})
day_shift_rows = []
for k in sorted(day_shift_hours.keys()):
day_shift_rows.append({'workDate': k, 'shiftHours': round(day_shift_hours[k], 2)})
return {'rows': rows, 'dayStates': day_state_rows, 'dayShiftHours': day_shift_rows}
def get_project_dashboard(conn, start_date, end_date, project_code):
where = "date(substr(d.EntryTime,1,10)) >= date(?) AND date(substr(d.EntryTime,1,10)) <= date(?)"
params = [start_date, end_date]
if project_code:
where += " AND d.EntryPCode = ?"
params.append(project_code)
summary_q = f'''
SELECT ROUND(SUM(d.TotalHours), 2) AS totalHours,
ROUND(SUM(d.RegularHours + IFNULL(d.BusinessTripHours, 0)), 2) AS regularHours,
ROUND(SUM(d.OvertimeHours), 2) AS overtimeHours,
ROUND(SUM(d.BusinessTripHours), 2) AS businessTripHours,
COUNT(*) AS totalRows,
COUNT(DISTINCT CASE WHEN d.TotalHours > 0 THEN d.MemberNo END) AS peopleCount,
COUNT(DISTINCT CASE WHEN d.TotalHours > 0 AND IFNULL(d.EntryPCode,'') <> '' THEN d.EntryPCode END) AS projectCount
FROM dallyproject d
WHERE {where}
'''
total_hours, regular_hours, overtime_hours, business_trip_hours, total_rows, people_count, project_count = conn.execute(summary_q, params).fetchone()
people_q = f'''
SELECT d.MemberNo,
IFNULL(m.korName, '') AS korName,
IFNULL(m.rankName, '') AS rankName,
IFNULL(m.teamName, '') AS teamName,
ROUND(SUM(d.TotalHours), 2) AS hours,
ROUND(SUM(d.RegularHours + IFNULL(d.BusinessTripHours, 0)), 2) AS regularHours,
ROUND(SUM(d.OvertimeHours), 2) AS overtimeHours,
ROUND(SUM(d.BusinessTripHours), 2) AS businessTripHours,
COUNT(*) AS rowsCount
FROM dallyproject d
LEFT JOIN member m ON m.MemberNo = d.MemberNo
WHERE {where}
GROUP BY d.MemberNo, IFNULL(m.korName, ''), IFNULL(m.rankName, ''), IFNULL(m.teamName, '')
HAVING hours > 0
ORDER BY hours DESC
'''
cur = conn.execute(people_q, params)
cols = [d[0] for d in cur.description]
people = [dict(zip(cols, row)) for row in cur.fetchall()]
people_year_q = f'''
SELECT substr(d.EntryTime, 1, 4) AS yearCode,
d.MemberNo,
IFNULL(m.korName, '') AS korName,
IFNULL(m.rankName, '') AS rankName,
IFNULL(m.teamName, '') AS teamName,
ROUND(SUM(d.TotalHours), 2) AS hours,
ROUND(SUM(d.RegularHours + IFNULL(d.BusinessTripHours, 0)), 2) AS regularHours,
ROUND(SUM(d.OvertimeHours), 2) AS overtimeHours,
ROUND(SUM(d.BusinessTripHours), 2) AS businessTripHours,
COUNT(*) AS rowsCount
FROM dallyproject d
LEFT JOIN member m ON m.MemberNo = d.MemberNo
WHERE {where}
GROUP BY yearCode, d.MemberNo, IFNULL(m.korName, ''), IFNULL(m.rankName, ''), IFNULL(m.teamName, '')
HAVING hours > 0
ORDER BY yearCode ASC, hours DESC
'''
cur2 = conn.execute(people_year_q, params)
cols2 = [d[0] for d in cur2.description]
people_yearly = [dict(zip(cols2, row)) for row in cur2.fetchall()]
return {
'start': start_date,
'end': end_date,
'projectCode': project_code,
'totalHours': total_hours or 0,
'regularHours': regular_hours or 0,
'overtimeHours': overtime_hours or 0,
'businessTripHours': business_trip_hours or 0,
'totalRows': total_rows or 0,
'peopleCount': people_count or 0,
'projectCount': project_count or 0,
'people': people,
'peopleYearly': people_yearly,
}
def get_project_daily_calendar(conn, start_date, end_date, project_code):
if not project_code:
return {'rows': []}
q = '''
SELECT
IFNULL(d.WorkDate, substr(d.EntryTime, 1, 10)) AS rawWorkDate,
IFNULL(d.MemberNo, '') AS memberNo,
IFNULL(m.korName, '') AS korName,
ROUND(IFNULL(d.TotalHours, 0), 2) AS totalHours,
ROUND(IFNULL(d.RegularHours, 0), 2) AS regularHours,
ROUND(IFNULL(d.OvertimeHours, 0), 2) AS overtimeHours
FROM dallyproject d
LEFT JOIN member m ON m.MemberNo = d.MemberNo
WHERE d.EntryPCode = ?
AND date(IFNULL(d.WorkDate, substr(d.EntryTime, 1, 10))) >= date(?)
AND date(IFNULL(d.WorkDate, substr(d.EntryTime, 1, 10))) <= date(?)
AND IFNULL(IFNULL(d.WorkDate, substr(d.EntryTime, 1, 10)), '') <> ''
'''
cur = conn.execute(q, (project_code, start_date, end_date))
agg = {}
for raw_work_date, member_no, kor_name, total_hours, regular_hours, overtime_hours in cur.fetchall():
wd = _as_text(raw_work_date).strip()
if not wd:
continue
if wd not in agg:
agg[wd] = {
'hours': 0.0,
'regularHours': 0.0,
'overtimeHours': 0.0,
'peopleCount': 0,
'members': {},
}
item = agg[wd]
item['hours'] += float(total_hours or 0)
item['regularHours'] += float(regular_hours or 0)
item['overtimeHours'] += float(overtime_hours or 0)
key = (_as_text(member_no).strip(), _as_text(kor_name).strip())
if key not in item['members']:
item['members'][key] = 0.0
item['peopleCount'] += 1
item['members'][key] += float(total_hours or 0)
rows = []
for wd, v in sorted(agg.items()):
top_member_no = ''
top_member_name = ''
top_member_hours = -1.0
for (member_no, kor_name), hours in v['members'].items():
if hours > top_member_hours:
top_member_no = member_no
top_member_name = kor_name
top_member_hours = hours
rows.append({
'workDate': wd,
'hours': round(v['hours'], 2),
'regularHours': round(v['regularHours'], 2),
'overtimeHours': round(v['overtimeHours'], 2),
'peopleCount': v['peopleCount'],
'topMemberNo': top_member_no,
'topMemberName': top_member_name,
'topMemberHours': round(max(0.0, top_member_hours), 2),
})
return {'rows': rows}
def get_project_monthly_detail(conn, start_date, end_date, project_code):
where = "date(substr(d.EntryTime,1,10)) >= date(?) AND date(substr(d.EntryTime,1,10)) <= date(?)"
params = [start_date, end_date]
if project_code:
where += " AND d.EntryPCode = ?"
params.append(project_code)
q = f'''
SELECT substr(d.EntryTime, 1, 7) AS yearMonth,
substr(d.EntryTime, 1, 4) AS yearCode,
d.MemberNo,
IFNULL(m.korName, '') AS korName,
IFNULL(m.rankName, '') AS rankName,
IFNULL(m.teamName, '') AS teamName,
ROUND(SUM(d.TotalHours), 2) AS hours,
ROUND(SUM(d.RegularHours + IFNULL(d.BusinessTripHours, 0)), 2) AS regularHours,
ROUND(SUM(d.OvertimeHours), 2) AS overtimeHours
FROM dallyproject d
LEFT JOIN member m ON m.MemberNo = d.MemberNo
WHERE {where}
GROUP BY yearMonth, yearCode, d.MemberNo, IFNULL(m.korName, ''), IFNULL(m.rankName, ''), IFNULL(m.teamName, '')
HAVING hours > 0
ORDER BY yearMonth ASC, hours DESC
'''
cur = conn.execute(q, params)
cols = [d[0] for d in cur.description]
return {'rows': [dict(zip(cols, row)) for row in cur.fetchall()]}
def get_project_site_monthly_detail(conn, start_date, end_date, project_code):
where = "date(s.workDate) >= date(?) AND date(s.workDate) <= date(?)"
params = [start_date, end_date]
if project_code:
where += " AND s.projectCode = ?"
params.append(project_code)
q = f'''
SELECT substr(s.workDate, 1, 7) AS yearMonth,
substr(s.workDate, 1, 4) AS yearCode,
s.memberNo AS MemberNo,
IFNULL(m.korName, s.korName) AS korName,
IFNULL(m.rankName, '') AS rankName,
IFNULL(m.teamName, '') AS teamName,
ROUND(SUM(IFNULL(s.personCount, 0) * 8), 2) AS hours,
ROUND(SUM(IFNULL(s.personCount, 0)), 2) AS personCount,
COUNT(DISTINCT s.workDate) AS workDays,
COUNT(*) AS rowsCount
FROM site_worksheet_record s
LEFT JOIN member m ON m.MemberNo = s.memberNo
WHERE {where}
AND IFNULL(s.memberNo, '') <> ''
GROUP BY yearMonth, yearCode, s.memberNo, IFNULL(m.korName, s.korName), IFNULL(m.rankName, ''), IFNULL(m.teamName, '')
HAVING hours > 0
ORDER BY yearMonth ASC, hours DESC
'''
cur = conn.execute(q, params)
cols = [d[0] for d in cur.description]
return {'rows': [dict(zip(cols, row)) for row in cur.fetchall()]}
def get_member_yearly_type_breakdown(conn, start_date, end_date, member_no):
where = "date(substr(d.EntryTime,1,10)) >= date(?) AND date(substr(d.EntryTime,1,10)) <= date(?)"
params = [start_date, end_date]
if member_no:
where += " AND d.MemberNo = ?"
params.append(member_no)
q = f'''
SELECT substr(d.EntryPCode, 1, 2) AS yearCode,
CASE
WHEN instr(d.EntryPCode, '-') = 0 THEN '기타'
ELSE
CASE
WHEN instr(substr(d.EntryPCode, instr(d.EntryPCode, '-') + 1), '-') = 0 THEN '기타'
ELSE substr(d.EntryPCode, instr(d.EntryPCode, '-') + 1, instr(substr(d.EntryPCode, instr(d.EntryPCode, '-') + 1), '-') - 1)
END
END AS typeCode,
ROUND(SUM(d.TotalHours), 2) AS hours,
ROUND(SUM(d.RegularHours + IFNULL(d.BusinessTripHours, 0)), 2) AS regularHours,
ROUND(SUM(d.OvertimeHours), 2) AS overtimeHours,
COUNT(*) AS rowsCount
FROM dallyproject d
WHERE {where}
GROUP BY yearCode, typeCode
HAVING hours > 0
ORDER BY yearCode ASC, hours DESC
'''
cur = conn.execute(q, params)
rows = cur.fetchall()
by_year = {}
for year_code, type_code, hours, regular_hours, overtime_hours, rows_count in rows:
y = year_code or '기타'
by_year.setdefault(y, {'yearCode': y, 'totalHours': 0, 'types': []})
by_year[y]['totalHours'] += float(hours or 0)
by_year[y]['types'].append({
'typeCode': type_code or '기타',
'hours': hours or 0,
'regularHours': regular_hours or 0,
'overtimeHours': overtime_hours or 0,
'rowsCount': rows_count or 0
})
result = []
for y in sorted(by_year.keys()):
item = by_year[y]
total = item['totalHours']
for t in item['types']:
t['sharePct'] = round((float(t['hours']) / total * 100), 1) if total > 0 else 0
item['types'].sort(key=lambda x: float(x['hours']), reverse=True)
item['totalHours'] = round(total, 2)
result.append(item)
return {'years': result}
def get_date_range(conn):
q = '''
SELECT MIN(date(substr(EntryTime, 1, 10))) AS minDate,
MAX(date(substr(EntryTime, 1, 10))) AS maxDate
FROM dallyproject
WHERE IFNULL(EntryTime, '') <> ''
AND date(substr(EntryTime, 1, 10)) IS NOT NULL
'''
min_date, max_date = conn.execute(q).fetchone()
return {'minDate': min_date or '', 'maxDate': max_date or ''}
def get_monthly_person_project(conn, start_date, end_date):
q = '''
SELECT substr(d.EntryTime, 1, 7) AS yearMonth,
d.MemberNo,
IFNULL(m.korName, '') AS korName,
IFNULL(m.rankName, '') AS rankName,
IFNULL(m.teamName, '') AS teamName,
d.EntryPCode AS projectCode,
ROUND(SUM(d.TotalHours), 2) AS hours,
ROUND(SUM(d.RegularHours + IFNULL(d.BusinessTripHours, 0)), 2) AS regularHours,
ROUND(SUM(d.BusinessTripHours), 2) AS businessTripHours,
ROUND(SUM(d.OvertimeHours), 2) AS overtimeHours
FROM dallyproject d
LEFT JOIN member m ON m.MemberNo = d.MemberNo
WHERE date(substr(d.EntryTime, 1, 10)) >= date(?)
AND date(substr(d.EntryTime, 1, 10)) <= date(?)
AND IFNULL(d.MemberNo, '') <> ''
AND IFNULL(d.EntryPCode, '') <> ''
GROUP BY yearMonth, d.MemberNo, IFNULL(m.korName, ''), IFNULL(m.rankName, ''), IFNULL(m.teamName, ''), d.EntryPCode
HAVING hours > 0
ORDER BY yearMonth ASC, d.MemberNo ASC, hours DESC
'''
cur = conn.execute(q, (start_date, end_date))
cols = [d[0] for d in cur.description]
return {'rows': [dict(zip(cols, row)) for row in cur.fetchall()]}
def _norm_title(s):
v = _as_text(s).strip()
if not v:
return ''
v = re.sub(r'\s+', '', v)
return v
def load_salary_avg_by_title_year():
if not os.path.exists(SALARY_AVG_CSV_PATH):
return {'rows': [], 'byTitle': {}}
enc = 'cp949'
rows = []
with open(SALARY_AVG_CSV_PATH, 'r', encoding=enc, newline='') as f:
r = csv.DictReader(f)
for x in r:
try:
year = int(_as_text(x.get('year')).strip())
title = _as_text(x.get('title')).strip()
avg = int(float(_as_text(x.get('avg_monthly_salary')).strip() or '0'))
sample = int(float(_as_text(x.get('sample_count')).strip() or '0'))
except Exception:
continue
if not title or avg <= 0:
continue
rows.append({
'year': year,
'title': title,
'titleNorm': _norm_title(title),
'avgMonthlySalary': avg,
'sampleCount': sample,
})
# same (title, year) may appear multiple times; merge with weighted average by sample_count.
by_title_year = {}
for row in rows:
k = (row['titleNorm'], row['year'])
prev = by_title_year.get(k, {'title': row['title'], 'sumPaid': 0, 'sumSample': 0})
prev['sumPaid'] += int(row['avgMonthlySalary'] or 0) * int(max(1, row['sampleCount'] or 0))
prev['sumSample'] += int(max(1, row['sampleCount'] or 0))
by_title_year[k] = prev
merged_rows = []
for (tnorm, year), v in by_title_year.items():
avg = int(round(v['sumPaid'] / max(1, v['sumSample'])))
merged_rows.append({
'year': year,
'title': v['title'],
'titleNorm': tnorm,
'avgMonthlySalary': avg,
'sampleCount': v['sumSample'],
})
by_title = {}
for row in merged_rows:
by_title.setdefault(row['titleNorm'], []).append(row)
for k in by_title.keys():
by_title[k].sort(key=lambda z: z['year'])
out = {}
for k, arr in by_title.items():
years = [a['year'] for a in arr]
vals = [a['avgMonthlySalary'] for a in arr]
min_y, max_y = min(years), max(years)
min_v = vals[years.index(min_y)]
max_v = vals[years.index(max_y)]
growth = 0.03
if max_y > min_y and min_v > 0 and max_v > 0:
try:
growth = (max_v / min_v) ** (1.0 / (max_y - min_y)) - 1.0
except Exception:
growth = 0.03
# 최근 5개 연도 값 기준, 최대/최소 제외 평균(직급 고정 월급)
arr_desc = sorted(arr, key=lambda z: z['year'], reverse=True)
last5_vals = [int(x['avgMonthlySalary']) for x in arr_desc[:5] if int(x['avgMonthlySalary'] or 0) > 0]
trimmed_vals = last5_vals[:]
if len(trimmed_vals) >= 3:
mn = min(trimmed_vals)
mx = max(trimmed_vals)
mn_removed = False
mx_removed = False
kept = []
for v in trimmed_vals:
if (not mn_removed) and v == mn:
mn_removed = True
continue
if (not mx_removed) and v == mx:
mx_removed = True
continue
kept.append(v)
trimmed_vals = kept if kept else trimmed_vals
fixed_salary = int(round(sum(trimmed_vals) / len(trimmed_vals))) if trimmed_vals else 0
out[k] = {
'title': arr[0]['title'],
'growthRate': growth,
'minYear': min_y,
'maxYear': max_y,
'yearToSalary': {str(a['year']): a['avgMonthlySalary'] for a in arr},
'fixedSalary': fixed_salary,
'fixedBaseYears': [int(x['year']) for x in arr_desc[:5]],
'rows': arr,
}
return {'rows': rows, 'byTitle': out}
class Handler(BaseHTTPRequestHandler):
def _json(self, status, payload):
body = json.dumps(payload, ensure_ascii=False).encode('utf-8')
self.send_response(status)
self.send_header('Content-Type', 'application/json; charset=utf-8')
self.send_header('Cache-Control', 'no-store, no-cache, must-revalidate, max-age=0')
self.send_header('Pragma', 'no-cache')
self.send_header('Expires', '0')
self.send_header('Content-Length', str(len(body)))
self.end_headers()
self.wfile.write(body)
def _html(self, path):
with open(path, 'rb') as f:
body = f.read()
self.send_response(200)
self.send_header('Content-Type', 'text/html; charset=utf-8')
self.send_header('Cache-Control', 'no-store, no-cache, must-revalidate, max-age=0')
self.send_header('Pragma', 'no-cache')
self.send_header('Expires', '0')
self.send_header('Content-Length', str(len(body)))
self.end_headers()
self.wfile.write(body)
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)
if parsed.path == '/detail-view.html':
return self._html(DETAIL_HTML)
if parsed.path == '/detail-view-project.html':
return self._html(DETAIL_PROJECT_HTML)
if parsed.path == '/mysql-preview.html':
return self._html(MYSQL_PREVIEW_HTML)
if parsed.path == '/people-unified.html':
return self._html(PEOPLE_UNIFIED_HTML)
try:
if parsed.path == '/api/people-active-2020':
rows = fetch_people_active_in_2020()
return self._json(200, {'year': 2020, 'count': len(rows), 'people': rows})
if parsed.path == '/api/people-unified':
q = parse_qs(parsed.query)
start_date = q.get('start', ['2016-01-01'])[0]
end_date = q.get('end', ['2026-12-31'])[0]
include_retired = (q.get('includeRetired', ['0'])[0] == '1')
with sqlite3.connect(DB_PATH) as conn:
people = get_people_unified(conn, start_date, end_date, include_retired)
return self._json(200, {'start': start_date, 'end': end_date, 'count': len(people), 'people': people})
if parsed.path == '/api/mysql-tables':
tables = fetch_mysql_tables()
return self._json(200, {'tables': tables, 'count': len(tables)})
if parsed.path == '/api/mysql-table-preview':
q = parse_qs(parsed.query)
table_name = q.get('table', [''])[0]
row_limit = q.get('limit', ['100'])[0]
rows = fetch_mysql_table_preview(table_name, row_limit)
return self._json(200, {'table': table_name, 'rows': rows, 'count': len(rows)})
if parsed.path == '/api/mysql-project-codes':
q = parse_qs(parsed.query)
row_limit = q.get('limit', ['2000'])[0]
result = fetch_mysql_project_codes(row_limit)
return self._json(
200,
{
'count': len(result['rows']),
'rows': result['rows'],
'sourceTable': result['sourceTable'],
'sourceColumns': result['sourceColumns'],
},
)
if parsed.path == '/api/erp-project-codes':
q = parse_qs(parsed.query)
page_name = q.get('page', ['const'])[0]
refresh = q.get('refresh', ['0'])[0] in ('1', 'true', 'yes')
if refresh:
search_text = q.get('searchText', [''])[0]
result = fetch_erp_project_codes(page_name, search_text)
sync_info = run_db_write(lambda conn: replace_erp_project_code_cache(conn, result['page'], result['rows']))
with open_db_connection() as conn:
ensure_db_schema(conn)
cached = get_erp_project_code_cache(conn, result['page'])
return self._json(
200,
{
'count': len(cached['rows']),
'rows': cached['rows'],
'source': 'erp_cache',
'page': cached['sourcePage'],
'syncedAt': sync_info['syncedAt'],
},
)
with open_db_connection() as conn:
ensure_db_schema(conn)
cached = get_erp_project_code_cache(conn, page_name)
return self._json(
200,
{
'count': len(cached['rows']),
'rows': cached['rows'],
'source': 'erp_cache',
'page': cached['sourcePage'],
'syncedAt': cached['syncedAt'],
},
)
if parsed.path == '/api/erp-contract-detail':
q = parse_qs(parsed.query)
page_name = q.get('page', ['const'])[0]
project_code = q.get('projectCode', [''])[0]
project_name = q.get('projectName', [''])[0]
refresh = q.get('refresh', ['0'])[0] in ('1', 'true', 'yes')
if refresh:
detail = fetch_erp_contract_detail(page_name, project_code, project_name)
def _write_refresh(conn):
replace_erp_contract_detail_cache(conn, detail)
replace_erp_linked_code_cache(
conn,
page_name,
project_code,
detail.get('projectName', '') or project_name,
detail.get('businessCode', ''),
detail.get('linkedCodes') or {},
)
run_db_write(_write_refresh)
with open_db_connection() as conn:
ensure_db_schema(conn)
cached = get_erp_contract_detail_cache(conn, page_name, project_code)
if cached and (
(
not (cached.get('linkedCodes') or {}).get('salesCode')
and not (cached.get('linkedCodes') or {}).get('designCode')
) or (
(cached.get('linkedCodes') or {}).get('salesCode')
and not (cached.get('linkedCodes') or {}).get('salesName')
) or (
(cached.get('linkedCodes') or {}).get('designCode')
and not (cached.get('linkedCodes') or {}).get('designName')
)
):
linked_codes = find_erp_linked_codes(project_code, cached.get('businessCode', ''), cached.get('projectName', '') or project_name)
if linked_codes.get('salesCode') or linked_codes.get('designCode'):
cached['linkedCodes'] = linked_codes
def _write_backfill(conn):
replace_erp_contract_detail_cache(conn, cached)
replace_erp_linked_code_cache(
conn,
page_name,
project_code,
cached.get('projectName', '') or project_name,
cached.get('businessCode', ''),
linked_codes,
)
run_db_write(_write_backfill)
with open_db_connection() as conn:
ensure_db_schema(conn)
cached = get_erp_contract_detail_cache(conn, page_name, project_code)
if not cached:
return self._json(404, {'ok': False, 'error': '계약정보 캐시가 없습니다.'})
return self._json(
200,
{
'ok': True,
'detail': cached,
},
)
if parsed.path == '/api/erp-bridge-overviews':
q = parse_qs(parsed.query)
page_name = q.get('page', ['const'])[0]
project_code = q.get('projectCode', [''])[0]
project_name = q.get('projectName', [''])[0]
refresh = q.get('refresh', ['0'])[0] in ('1', 'true', 'yes')
if refresh:
result = fetch_erp_bridge_overviews(page_name, project_code, project_name)
run_db_write(lambda conn: replace_erp_bridge_overview_cache(conn, result))
with open_db_connection() as conn:
ensure_db_schema(conn)
cached = get_erp_bridge_overview_cache(conn, page_name, project_code)
return self._json(
200,
{
'ok': True,
'overviews': cached['rows'],
'syncedAt': cached['syncedAt'],
},
)
if parsed.path == '/api/erp-budget-plan':
q = parse_qs(parsed.query)
page_name = q.get('page', ['const'])[0]
project_code = q.get('projectCode', [''])[0]
project_name = q.get('projectName', [''])[0]
refresh = q.get('refresh', ['0'])[0] in ('1', 'true', 'yes')
if refresh:
result = fetch_erp_budget_plan(page_name, project_code, project_name)
run_db_write(lambda conn: replace_erp_budget_plan_cache(conn, result))
with open_db_connection() as conn:
ensure_db_schema(conn)
cached = get_erp_budget_plan_cache(conn, page_name, project_code)
if not cached:
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))
if parsed.path == '/api/rebuild-work-calendar':
with sqlite3.connect(DB_PATH) as conn:
ensure_db_schema(conn)
return self._json(200, rebuild_work_calendar_tables(conn))
if parsed.path == '/api/work-calendar-day':
q = parse_qs(parsed.query)
start_date = q.get('start', ['2020-01-01'])[0]
end_date = q.get('end', ['2026-12-31'])[0]
member_no = normalize_member_no(q.get('memberNo', [''])[0])
where = 'date(workDate) >= date(?) AND date(workDate) <= date(?)'
params = [start_date, end_date]
if member_no:
where += ' AND memberNo = ?'
params.append(member_no)
with sqlite3.connect(DB_PATH) as conn:
conn.row_factory = sqlite3.Row
rows = [dict(r) for r in conn.execute(f'SELECT * FROM work_calendar_day WHERE {where} ORDER BY workDate, memberNo', params)]
return self._json(200, {'rows': rows, 'count': len(rows)})
if parsed.path == '/api/people-summary':
q = parse_qs(parsed.query)
start_date = q.get('start', ['2016-01-01'])[0]
end_date = q.get('end', ['2026-12-31'])[0]
with sqlite3.connect(DB_PATH) as conn:
return self._json(200, {'people': get_people_summary(conn, start_date, end_date)})
if parsed.path == '/api/project-summary':
q = parse_qs(parsed.query)
start_date = q.get('start', ['2016-01-01'])[0]
end_date = q.get('end', ['2026-12-31'])[0]
with sqlite3.connect(DB_PATH) as conn:
return self._json(200, {'projects': get_project_summary(conn, start_date, end_date)})
if parsed.path == '/api/project-aliases':
with sqlite3.connect(DB_PATH) as conn:
return self._json(200, {'aliases': get_project_alias_map(conn)})
if parsed.path == '/api/member-dashboard':
q = parse_qs(parsed.query)
start_date = q.get('start', ['2016-01-01'])[0]
end_date = q.get('end', ['2026-12-31'])[0]
member_no = normalize_member_no(q.get('memberNo', [''])[0])
with sqlite3.connect(DB_PATH) as conn:
return self._json(200, get_member_dashboard(conn, start_date, end_date, member_no))
if parsed.path == '/api/member-daily-calendar':
q = parse_qs(parsed.query)
start_date = q.get('start', ['2016-01-01'])[0]
end_date = q.get('end', ['2026-12-31'])[0]
member_no = normalize_member_no(q.get('memberNo', [''])[0])
with sqlite3.connect(DB_PATH) as conn:
return self._json(200, get_member_daily_calendar(conn, start_date, end_date, member_no))
if parsed.path == '/api/start-site-worksheet-sync':
q = parse_qs(parsed.query)
start_date = q.get('start', ['2016-01-01'])[0]
end_date = q.get('end', ['2026-12-31'])[0]
member_no = normalize_member_no(q.get('memberNo', [''])[0])
member_nos = []
for raw in q.get('members', []):
member_nos.extend(raw.split(','))
refresh = q.get('refresh', ['0'])[0] in ('1', 'true', 'yes')
return self._json(200, start_site_worksheet_sync_job(start_date, end_date, member_no, refresh=refresh, member_nos=member_nos))
if parsed.path == '/api/site-worksheet-sync-status':
q = parse_qs(parsed.query)
job_id = q.get('jobId', [''])[0]
with SITE_SYNC_LOCK:
job = SITE_SYNC_JOBS.get(job_id)
if not job:
return self._json(404, {'status': 'error', 'error': '작업을 찾을 수 없습니다.'})
return self._json(200, _public_site_sync_job(job))
if parsed.path == '/api/member-site-worksheet-records':
q = parse_qs(parsed.query)
start_date = q.get('start', ['2016-01-01'])[0]
end_date = q.get('end', ['2026-12-31'])[0]
member_no = normalize_member_no(q.get('memberNo', [''])[0])
refresh = q.get('refresh', ['0'])[0] in ('1', 'true', 'yes')
cache_only = q.get('cacheOnly', ['0'])[0] in ('1', 'true', 'yes')
with sqlite3.connect(DB_PATH) as conn:
ensure_db_schema(conn)
if cache_only:
return self._json(200, {'rows': _site_record_rows_from_cache(conn, start_date, end_date, member_no), 'source': 'cache'})
return self._json(200, get_member_site_worksheet_records(conn, start_date, end_date, member_no, refresh=refresh))
if parsed.path == '/api/project-dashboard':
q = parse_qs(parsed.query)
start_date = q.get('start', ['2016-01-01'])[0]
end_date = q.get('end', ['2026-12-31'])[0]
project_code = (q.get('projectCode', [''])[0] or '').strip()
with sqlite3.connect(DB_PATH) as conn:
return self._json(200, get_project_dashboard(conn, start_date, end_date, project_code))
if parsed.path == '/api/project-daily-calendar':
q = parse_qs(parsed.query)
start_date = q.get('start', ['2016-01-01'])[0]
end_date = q.get('end', ['2026-12-31'])[0]
project_code = (q.get('projectCode', [''])[0] or '').strip()
with sqlite3.connect(DB_PATH) as conn:
return self._json(200, get_project_daily_calendar(conn, start_date, end_date, project_code))
if parsed.path == '/api/project-monthly-detail':
q = parse_qs(parsed.query)
start_date = q.get('start', ['2016-01-01'])[0]
end_date = q.get('end', ['2026-12-31'])[0]
project_code = (q.get('projectCode', [''])[0] or '').strip()
with sqlite3.connect(DB_PATH) as conn:
return self._json(200, get_project_monthly_detail(conn, start_date, end_date, project_code))
if parsed.path == '/api/project-site-monthly-detail':
q = parse_qs(parsed.query)
start_date = q.get('start', ['2016-01-01'])[0]
end_date = q.get('end', ['2026-12-31'])[0]
project_code = (q.get('projectCode', [''])[0] or '').strip()
with sqlite3.connect(DB_PATH) as conn:
return self._json(200, get_project_site_monthly_detail(conn, start_date, end_date, project_code))
if parsed.path == '/api/member-yearly-breakdown':
q = parse_qs(parsed.query)
start_date = q.get('start', ['2016-01-01'])[0]
end_date = q.get('end', ['2026-12-31'])[0]
member_no = normalize_member_no(q.get('memberNo', [''])[0])
with sqlite3.connect(DB_PATH) as conn:
return self._json(200, get_member_yearly_type_breakdown(conn, start_date, end_date, member_no))
if parsed.path == '/api/date-range':
with sqlite3.connect(DB_PATH) as conn:
return self._json(200, get_date_range(conn))
if parsed.path == '/api/monthly-person-project':
q = parse_qs(parsed.query)
start_date = q.get('start', ['2016-01-01'])[0]
end_date = q.get('end', ['2026-12-31'])[0]
with sqlite3.connect(DB_PATH) as conn:
return self._json(200, get_monthly_person_project(conn, start_date, end_date))
if parsed.path == '/api/salary-avg-by-title':
return self._json(200, load_salary_avg_by_title_year())
self._json(404, {'error': 'Not found'})
except Exception as e:
traceback.print_exc()
self._json(500, {'ok': False, 'error': str(e)})
def do_POST(self):
parsed = urlparse(self.path)
if parsed.path == '/api/sync-project-aliases':
try:
with sqlite3.connect(DB_PATH) as conn:
ensure_db_schema(conn)
sync_info = sync_project_alias_caches(conn)
linked_count = rebuild_erp_linked_code_cache(conn, 'const')
return self._json(200, {
'ok': True,
'project_alias_source': 'erp',
'project_alias_loaded': len(sync_info['entries']),
'linked_code_cache_loaded': linked_count,
'syncedAt': sync_info['cacheInfo']['syncedAt'],
})
except Exception as e:
traceback.print_exc()
return self._json(500, {'ok': False, 'error': str(e)})
if parsed.path == '/api/sync-ranks':
try:
with sqlite3.connect(DB_PATH) as conn:
ensure_db_schema(conn)
rank_map = load_member_ranks_from_intranet()
updated = update_member_ranks(conn, rank_map)
return self._json(200, {'ok': True, 'member_rank_source': 'intranet', 'member_rank_loaded': updated})
except Exception as e:
traceback.print_exc()
return self._json(500, {'ok': False, 'error': str(e)})
if parsed.path == '/api/sync-erp-project-codes':
try:
q = parse_qs(parsed.query)
page_name = q.get('page', ['const'])[0]
result = fetch_erp_project_codes(page_name)
def _write_project_codes(conn):
sync_info = replace_erp_project_code_cache(conn, result['page'], result['rows'])
linked_count = rebuild_erp_linked_code_cache(conn, result['page']) if result['page'] == 'const' else 0
return sync_info, linked_count
sync_info, linked_count = run_db_write(_write_project_codes)
return self._json(
200,
{
'ok': True,
'count': sync_info['count'],
'page': sync_info['sourcePage'],
'syncedAt': sync_info['syncedAt'],
'linkedCodeCacheLoaded': linked_count,
},
)
except Exception as e:
traceback.print_exc()
return self._json(500, {'ok': False, 'error': str(e)})
if parsed.path == '/api/sync-erp-contract-detail':
try:
q = parse_qs(parsed.query)
page_name = q.get('page', ['const'])[0]
project_code = q.get('projectCode', [''])[0]
project_name = q.get('projectName', [''])[0]
detail = fetch_erp_contract_detail(page_name, project_code, project_name)
def _write_contract(conn):
sync_info = replace_erp_contract_detail_cache(conn, detail)
replace_erp_linked_code_cache(
conn,
page_name,
project_code,
detail.get('projectName', '') or project_name,
detail.get('businessCode', ''),
detail.get('linkedCodes') or {},
)
return sync_info
sync_info = run_db_write(_write_contract)
with open_db_connection() as conn:
ensure_db_schema(conn)
cached = get_erp_contract_detail_cache(conn, page_name, project_code)
return self._json(
200,
{
'ok': True,
'syncedAt': sync_info['syncedAt'],
'detail': cached,
},
)
except Exception as e:
traceback.print_exc()
return self._json(500, {'ok': False, 'error': str(e)})
if parsed.path == '/api/sync-erp-bridge-overviews':
try:
q = parse_qs(parsed.query)
page_name = q.get('page', ['const'])[0]
project_code = q.get('projectCode', [''])[0]
project_name = q.get('projectName', [''])[0]
result = fetch_erp_bridge_overviews(page_name, project_code, project_name)
sync_info = run_db_write(lambda conn: replace_erp_bridge_overview_cache(conn, result))
with open_db_connection() as conn:
ensure_db_schema(conn)
cached = get_erp_bridge_overview_cache(conn, page_name, project_code)
return self._json(
200,
{
'ok': True,
'count': sync_info['count'],
'syncedAt': sync_info['syncedAt'],
'overviews': cached['rows'],
},
)
except Exception as e:
traceback.print_exc()
return self._json(500, {'ok': False, 'error': str(e)})
if parsed.path == '/api/sync-erp-budget-plan':
try:
q = parse_qs(parsed.query)
page_name = q.get('page', ['const'])[0]
project_code = q.get('projectCode', [''])[0]
project_name = q.get('projectName', [''])[0]
result = fetch_erp_budget_plan(page_name, project_code, project_name)
sync_info = run_db_write(lambda conn: replace_erp_budget_plan_cache(conn, result))
with open_db_connection() as conn:
ensure_db_schema(conn)
cached = get_erp_budget_plan_cache(conn, page_name, project_code)
return self._json(200, {'ok': True, 'syncedAt': sync_info['syncedAt'], 'plan': cached})
except Exception as e:
traceback.print_exc()
return self._json(500, {'ok': False, 'error': str(e)})
if parsed.path != '/api/load':
return self._json(404, {'error': 'Not found'})
try:
with open_db_connection() as conn:
ensure_db_schema(conn)
used = load_from_mysql_into_sqlite(conn)
try:
sync_info = sync_project_alias_caches(conn)
used['project_alias_source'] = 'erp'
used['project_alias_loaded'] = len(sync_info['entries'])
used['linked_code_cache_loaded'] = rebuild_erp_linked_code_cache(conn, 'const')
except Exception as e:
used['project_alias_source'] = 'erp_failed_keep_existing'
used['project_alias_loaded'] = 0
used['linked_code_cache_loaded'] = 0
used['project_alias_error'] = str(e)
# member rank는 MySQL member.rankCode -> systemconfig 매핑을 우선 사용
# (used['member_rank_source'], used['member_rank_loaded']는 load_from_mysql_into_sqlite에서 설정)
used['work_calendar'] = rebuild_work_calendar_tables(conn)
stats = get_stats(conn)
payload = {
'ok': True,
**used,
'stats': stats,
}
self._json(200, payload)
except Exception as e:
traceback.print_exc()
self._json(500, {'ok': False, 'error': str(e)})
def local_ip():
try:
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
s.connect(('8.8.8.8', 80))
return s.getsockname()[0]
except Exception:
return '127.0.0.1'
finally:
try:
s.close()
except Exception:
pass
if __name__ == '__main__':
os.makedirs(BASE_DIR, exist_ok=True)
with open_db_connection() as conn:
init_db(conn)
if os.environ.get('STARTUP_MAINTENANCE', '1') not in ('0', 'false', 'False', 'no'):
try:
sync_info = sync_project_alias_caches(conn)
print(f"Loaded project aliases from ERP: {len(sync_info['entries'])}")
linked_count = rebuild_erp_linked_code_cache(conn, 'const')
print(f'Loaded linked code cache: {linked_count}')
except Exception as e:
print(f'Load project aliases from ERP failed, keep existing aliases: {e}')
# 직급은 MySQL member.rankCode -> systemconfig 매핑 기반으로 /api/load 시 반영
try:
fixed = backfill_daily_hours_if_needed(conn)
if fixed:
print(f'Backfilled hour columns for {fixed} rows.')
rebuilt = rebuild_work_calendar_tables(conn)
print(f"Rebuilt work calendar tables: days={rebuilt['days']}, details={rebuilt['details']}")
except sqlite3.OperationalError as e:
if 'locked' in str(e).lower():
print(f'Skip startup rebuild because database is locked: {e}')
else:
raise
else:
print('Skip startup maintenance by STARTUP_MAINTENANCE=0')
port = int(os.environ.get('PORT', '8091'))
host = '0.0.0.0'
print(f'Server running: http://{local_ip()}:{port}')
print(f'Local access: http://127.0.0.1:{port}')
print('Tip: open /mysql-preview.html for direct MySQL table preview page.')
ThreadingHTTPServer((host, port), Handler).serve_forever()