4380 lines
170 KiB
Python
4380 lines
170 KiB
Python
#!/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
|
|
|
|
|
|
def configure_sqlite_connection(conn):
|
|
conn.execute('PRAGMA journal_mode=WAL')
|
|
conn.execute('PRAGMA synchronous=NORMAL')
|
|
conn.execute('PRAGMA busy_timeout=30000')
|
|
return conn
|
|
|
|
|
|
def open_db_connection(timeout=30):
|
|
conn = sqlite3.connect(DB_PATH, timeout=timeout)
|
|
return configure_sqlite_connection(conn)
|
|
|
|
|
|
|
|
def init_db(conn):
|
|
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],
|
|
'contractType': row[2] or '',
|
|
'applicationType': row[3] or '',
|
|
}
|
|
for row in conn.execute(
|
|
'''
|
|
SELECT p.projectCode,
|
|
p.projectName,
|
|
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
|
|
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
|
|
for row in rows[1:]:
|
|
if not row:
|
|
continue
|
|
first = _as_text(row[0]).strip()
|
|
if not first or '조회된 내용이 없습니다' in first:
|
|
continue
|
|
padded = row + [''] * max(0, 7 - len(row))
|
|
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 _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 = {'height': '', 'length': '', 'quantity': '', 'remarks': ''}
|
|
if len(rows) < 2:
|
|
return result
|
|
values = rows[1] + [''] * max(0, 4 - len(rows[1]))
|
|
result.update({
|
|
'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 '{}')
|
|
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': payload.get('girderSpecs') or [],
|
|
'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(' ', ' ').replace(' ', ' ')
|
|
return s.strip()
|
|
|
|
|
|
def load_member_ranks_from_intranet():
|
|
login_url = INTRANET_BASE_URL.rstrip('/') + '/sys/popup/login_ok.php'
|
|
search_url = INTRANET_BASE_URL.rstrip('/') + '/sys/controller/codeSearch_controller.php?ActionMode=searchMember'
|
|
s = requests.Session()
|
|
# session init
|
|
s.get(INTRANET_BASE_URL, timeout=20)
|
|
payload = {
|
|
'user_id': INTRANET_LOGIN_ID,
|
|
'user_pw': INTRANET_LOGIN_PW,
|
|
'user_ip': '',
|
|
'user_contact_area': 'OUT',
|
|
'checksaveid': '',
|
|
'is_ajax': 1,
|
|
}
|
|
r = s.post(login_url, data=payload, timeout=20)
|
|
if str(r.text).strip() != '1':
|
|
raise RuntimeError(f'Intranet login failed: {r.text[:120]}')
|
|
|
|
rr = s.post(
|
|
search_url,
|
|
data={'ajax_insertStr': '', 'ajax_returnId01': 'a', 'ajax_returnId02': 'b'},
|
|
timeout=20
|
|
)
|
|
html = rr.content.decode('utf-8', errors='ignore')
|
|
rank_map = {}
|
|
for tr in re.findall(r'<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')
|
|
with open_db_connection() as conn:
|
|
ensure_db_schema(conn)
|
|
if refresh:
|
|
search_text = q.get('searchText', [''])[0]
|
|
result = fetch_erp_project_codes(page_name, search_text)
|
|
sync_info = replace_erp_project_code_cache(conn, result['page'], result['rows'])
|
|
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'],
|
|
},
|
|
)
|
|
|
|
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')
|
|
with open_db_connection() as conn:
|
|
ensure_db_schema(conn)
|
|
if refresh:
|
|
detail = fetch_erp_contract_detail(page_name, project_code, project_name)
|
|
replace_erp_contract_detail_cache(conn, detail)
|
|
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')
|
|
with open_db_connection() as conn:
|
|
ensure_db_schema(conn)
|
|
if refresh:
|
|
result = fetch_erp_bridge_overviews(page_name, project_code, project_name)
|
|
replace_erp_bridge_overview_cache(conn, result)
|
|
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')
|
|
with open_db_connection() as conn:
|
|
ensure_db_schema(conn)
|
|
if refresh:
|
|
result = fetch_erp_budget_plan(page_name, project_code, project_name)
|
|
replace_erp_budget_plan_cache(conn, result)
|
|
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)
|
|
with open_db_connection() as conn:
|
|
ensure_db_schema(conn)
|
|
sync_info = 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)
|
|
with open_db_connection() as conn:
|
|
ensure_db_schema(conn)
|
|
sync_info = replace_erp_contract_detail_cache(conn, detail)
|
|
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)
|
|
with open_db_connection() as conn:
|
|
ensure_db_schema(conn)
|
|
sync_info = replace_erp_bridge_overview_cache(conn, result)
|
|
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)
|
|
with open_db_connection() as conn:
|
|
ensure_db_schema(conn)
|
|
sync_info = replace_erp_budget_plan_cache(conn, result)
|
|
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()
|