#!/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
from datetime import datetime, timedelta, time
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from urllib.parse import parse_qs, 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):
    conn.execute('PRAGMA journal_mode=WAL')
    conn.execute('PRAGMA synchronous=NORMAL')
    conn.execute('PRAGMA busy_timeout=30000')
    return conn


def open_db_connection(timeout=30):
    conn = sqlite3.connect(DB_PATH, timeout=timeout)
    return configure_sqlite_connection(conn)


def 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 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 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_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.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 _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_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] = nick
    return sorted(out.items())


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'<table[^>]*>(.*?)</table>', html or '', re.I | re.S):
        rows = []
        for tr_html in re.findall(r'<tr[^>]*>(.*?)</tr>', table_html, re.I | re.S):
            cells = [
                _clean_html_text(cell_html)
                for cell_html in re.findall(r'<t[dh][^>]*>(.*?)</t[dh]>', 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'<li[^>]*onclick="redrawPage2\((\d+)\)"[^>]*>(.*?)</li>', 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('계약종류', ''),
        '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 [],
    }

    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 []
    else:
        contract_fields = raw_contract if isinstance(raw_contract, dict) else {}
        scale_rows = []
    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 '',
        '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 _parse_site_worker_rows(html):
    rows = []
    for table in re.findall(r'<table[^>]*>(.*?)</table>', 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'<tr[^>]*>(.*?)</tr>', table, re.I | re.S):
            cells = [_clean_html_text(td) for td in re.findall(r'<t[dh][^>]*>(.*?)</t[dh]>', 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'<tr[^>]*>(.*?)</tr>', html or '', re.I | re.S):
        cells = [_clean_html_text(td) for td in re.findall(r'<t[dh][^>]*>(.*?)</t[dh]>', 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'] = '달력 테이블 정리'
    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('&nbsp;', ' ').replace('&#160;', ' ')
    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'<tr[^>]*>(.*?)</tr>', html, re.I | re.S):
        tds = re.findall(r'<td[^>]*>(.*?)</td>', 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 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 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):
    q = 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
    '''
    cur = conn.execute(q, (start_date, end_date, 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 == '/':
            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)
                    run_db_write(lambda conn: replace_erp_contract_detail_cache(conn, detail))
                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/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:
                    alias_rows = load_project_alias_from_erp()
                    replace_project_alias(conn, alias_rows)
                return self._json(200, {'ok': True, 'project_alias_source': 'erp', 'project_alias_loaded': len(alias_rows)})
            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)
                sync_info = run_db_write(lambda conn: replace_erp_project_code_cache(conn, result['page'], result['rows']))
                return self._json(
                    200,
                    {
                        'ok': True,
                        'count': sync_info['count'],
                        'page': sync_info['sourcePage'],
                        'syncedAt': sync_info['syncedAt'],
                    },
                )
            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)
                sync_info = run_db_write(lambda conn: replace_erp_contract_detail_cache(conn, detail))
                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:
                    alias_rows = load_project_alias_from_erp()
                    used['project_alias_source'] = 'erp'
                    replace_project_alias(conn, alias_rows)
                    used['project_alias_loaded'] = len(alias_rows)
                except Exception as e:
                    used['project_alias_source'] = 'erp_failed_keep_existing'
                    used['project_alias_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)
        try:
            alias_rows = load_project_alias_from_erp()
            print(f'Loaded project aliases from ERP: {len(alias_rows)}')
            replace_project_alias(conn, alias_rows)
        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

    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()
