diff --git a/main.py b/main.py index 4f06858..20c1061 100644 --- a/main.py +++ b/main.py @@ -18,19 +18,22 @@ from urllib.parse import parse_qs, quote_plus import uvicorn from fastapi import FastAPI, File, Request, UploadFile from fastapi.encoders import jsonable_encoder -from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse +from fastapi.responses import FileResponse, HTMLResponse, JSONResponse, RedirectResponse, Response from fastapi.staticfiles import StaticFiles from fastapi.templating import Jinja2Templates from openpyxl import load_workbook from sqlalchemy import bindparam, create_engine, event, text +from sqlalchemy.engine import URL from sqlalchemy.exc import OperationalError from datasette.app import Datasette from wehago_compare import ( enqueue_default_pair_recommend_precompute, + export_wehago_status_rows_xlsx, get_erp_filtered_rows, get_compare_snapshot_status, get_individual_pair_recommendations, get_last_action_summary, + get_status_export_job, get_status_field_suggestions, get_status_detail_rows, get_wehago_compare_dashboard, @@ -38,8 +41,11 @@ from wehago_compare import ( get_wehago_filtered_rows, import_uploaded_erp_voucher_file, init_wehago_compare_db, + load_bridge_review_settings, request_compare_snapshot_rebuild, + request_status_export_xlsx, recommend_pair_matches, + save_bridge_review_settings, save_recommended_pair_matches, save_manual_pair_matches, save_recheck_review_rows, @@ -2857,6 +2863,121 @@ async def db_browser(request: Request, target: str | None = None): return templates.TemplateResponse(request, "db_browser.html", context) +def build_hanmac_browser_plan() -> dict[str, Any]: + return { + "server_host": "172.16.42.111", + "status": { + "label": "연결 준비", + "description": "지금은 읽기 전용 조회 화면을 먼저 구성하고, 이후 MySQL 연결 정보를 붙여 바로 조회할 수 있도록 준비한 상태입니다.", + }, + "strategy": [ + { + "title": "1단계: 바로 보기", + "description": "hanmac / hanmac_manhour에서 자주 보는 테이블을 읽기 전용으로 조회합니다.", + }, + { + "title": "2단계: 기준키 정리", + "description": "프로젝트코드, 사번, 부서코드, 일자를 현재 앱 DB와 연결할 공통 키로 정리합니다.", + }, + { + "title": "3단계: 통합 화면", + "description": "프로젝트 정보, 원가/전표 데이터, manhour 데이터를 한 화면에서 함께 보여줍니다.", + }, + ], + "schemas": [ + { + "name": "hanmac", + "role": "기본 업무/REST API 성격의 운영 데이터 확인", + "tables": ["restapi"], + }, + { + "name": "hanmac_manhour", + "role": "일일업무, 투입시간, 사원/프로젝트 기준 데이터 확인", + "tables": [ + "dallyproject_tbl", + "dallyproject_addwork_tbl", + "dallyproject_2020_tbl", + "member_tbl", + "project_tbl", + ], + }, + ], + "recommended_views": [ + { + "title": "일일업무 조회", + "summary": "사번, 이름, 프로젝트코드, 기간으로 가장 자주 볼 가능성이 높은 기본 화면입니다.", + "fields": ["입력일", "사번", "이름", "프로젝트코드", "업무내용", "시간"], + }, + { + "title": "사원별 투입시간", + "summary": "특정 직원이 어느 프로젝트에 얼마나 투입되었는지 월/기간 기준으로 확인합니다.", + "fields": ["사번", "이름", "기간", "프로젝트수", "총 시간", "최근 입력일"], + }, + { + "title": "프로젝트별 투입시간", + "summary": "프로젝트 기준으로 참여 인원과 누적 manhour를 보며 현재 DB의 프로젝트 정보와 붙이기 좋습니다.", + "fields": ["프로젝트코드", "프로젝트명", "참여인원", "총 시간", "최근 입력일"], + }, + { + "title": "마스터 조회", + "summary": "member_tbl, project_tbl을 기준으로 사원/프로젝트 마스터를 점검합니다.", + "fields": ["사번", "이름", "부서", "직급", "프로젝트코드", "프로젝트명"], + }, + ], + "join_keys": [ + { + "key": "project_code", + "reason": "현재 프로젝트 정보 화면과 연결하는 핵심 기준값입니다.", + }, + { + "key": "member_no", + "reason": "사원별 투입시간과 사용자/담당자 기준 분석의 핵심 키입니다.", + }, + { + "key": "dept_code", + "reason": "지원부서/원가부서 또는 조직 기준 요약과 연결하기 좋습니다.", + }, + { + "key": "work_date", + "reason": "전표일자, 증빙일자, 프로젝트 기간 분석과 함께 볼 때 필요합니다.", + }, + ], + "sample_queries": [ + { + "title": "일일업무 최근 입력 조회", + "sql": ( + "SELECT EntryTime, MemberNo, project_code, contents, work_hour, work_min " + "FROM dallyproject_addwork_tbl ORDER BY EntryTime DESC LIMIT 100;" + ), + }, + { + "title": "사원별 누적 투입시간", + "sql": ( + "SELECT MemberNo, SUM(COALESCE(work_hour, 0) * 60 + COALESCE(work_min, 0)) AS total_minutes " + "FROM dallyproject_addwork_tbl GROUP BY MemberNo ORDER BY total_minutes DESC LIMIT 50;" + ), + }, + { + "title": "프로젝트별 투입시간", + "sql": ( + "SELECT project_code, SUM(COALESCE(work_hour, 0) * 60 + COALESCE(work_min, 0)) AS total_minutes " + "FROM dallyproject_addwork_tbl GROUP BY project_code ORDER BY total_minutes DESC LIMIT 50;" + ), + }, + ], + "integration_notes": [ + "우선은 외부 MySQL을 읽기 전용으로 직접 조회합니다.", + "조회 패턴이 안정되면 필요한 테이블만 로컬 SQLite로 동기화합니다.", + "동기화 후에는 현재 프로젝트 정보/원가/전표 화면에 manhour 요약 칼럼을 추가합니다.", + ], + "connection_requirements": [ + "MySQL 접속 정보(host, port, user, password)", + "허용할 스키마 목록(hanmac, hanmac_manhour)", + "읽기 전용 계정 여부 확인", + ], + } + + def normalize_text(value: Any) -> str: if value is None: return "" @@ -8532,6 +8653,898 @@ def render_wehago_compare_page( return templates.TemplateResponse(request, "wehago_compare.html", context) +def render_hanmac_browser_page( + request: Request, + message: str = "", +) -> HTMLResponse: + context = { + **base_context(request, message), + } + return templates.TemplateResponse(request, "hanmac_browser.html", context) + + +def _build_hanmac_mysql_engine(payload: dict[str, Any]): + host = normalize_text(payload.get("host")) + port_raw = normalize_text(payload.get("port")) or "3306" + user = normalize_text(payload.get("user")) + password = payload.get("password") + database = normalize_text(payload.get("database")) + + if not host: + raise ValueError("서버 IP를 입력해주세요.") + if not user: + raise ValueError("아이디를 입력해주세요.") + if password in (None, ""): + raise ValueError("비밀번호를 입력해주세요.") + if not database: + raise ValueError("DB를 선택해주세요.") + + try: + port = int(port_raw) + except ValueError as exc: + raise ValueError("포트 번호를 숫자로 입력해주세요.") from exc + + return create_engine( + URL.create( + "mysql+pymysql", + username=user, + password=str(password), + host=host, + port=port, + database=database, + query={"charset": "utf8"}, + ), + pool_pre_ping=True, + pool_recycle=300, + connect_args={"connect_timeout": 5}, + ) + + +def test_hanmac_mysql_connection(payload: dict[str, Any]) -> dict[str, Any]: + database = normalize_text(payload.get("database")) + test_engine = _build_hanmac_mysql_engine(payload) + + try: + with test_engine.connect() as connection: + current_database = connection.execute(text("SELECT DATABASE()")).scalar() + current_user = connection.execute(text("SELECT CURRENT_USER()")).scalar() + server_version = connection.execute(text("SELECT VERSION()")).scalar() + return { + "status": "ok", + "message": f"{database} 연결에 성공했습니다.", + "database": current_database or database, + "user": current_user or user, + "server_version": server_version or "", + } + finally: + test_engine.dispose() + + +def _validate_hanmac_table_name(table_name: Any) -> str: + normalized = normalize_text(table_name) + if not normalized: + raise ValueError("테이블 이름이 필요합니다.") + if not re.fullmatch(r"[A-Za-z0-9_]+", normalized): + raise ValueError("테이블 이름 형식이 올바르지 않습니다.") + return normalized + + +def _validate_hanmac_schema_name(schema_name: Any) -> str: + normalized = normalize_text(schema_name) + if normalized not in {"hanmac", "hanmac_manhour"}: + raise ValueError("스키마 이름이 올바르지 않습니다.") + return normalized + + +def get_hanmac_table_list(payload: dict[str, Any]) -> dict[str, Any]: + connect_payload = dict(payload) + connect_payload["database"] = normalize_text(payload.get("database")) or "hanmac" + test_engine = _build_hanmac_mysql_engine(connect_payload) + try: + with test_engine.connect() as connection: + table_rows = connection.execute( + text( + """ + SELECT table_schema, table_name + FROM information_schema.tables + WHERE table_schema IN ('hanmac', 'hanmac_manhour') + AND table_type = 'BASE TABLE' + ORDER BY table_schema, table_name + """ + ) + ).mappings().all() + tables: list[dict[str, Any]] = [] + for row in table_rows: + schema_name = str(row["table_schema"]) + table_name = str(row["table_name"]) + try: + row_count = connection.execute(text(f"SELECT COUNT(*) FROM `{schema_name}`.`{table_name}`")).scalar() + except Exception: + row_count = None + tables.append( + { + "schema": schema_name, + "name": table_name, + "row_count": int(row_count) if row_count is not None else None, + } + ) + preferred_tables = { + "dallyproject_tbl": 0, + "dallyproject_addwork_tbl": 1, + "member_tbl": 2, + "project_tbl": 3, + "worker_tardy_tbl": 4, + } + tables.sort( + key=lambda item: ( + item.get("schema") != "hanmac_manhour", + preferred_tables.get(item.get("name", ""), 99), + (item.get("row_count") is None), + -(item.get("row_count") or 0), + item["name"], + ) + ) + return { + "status": "ok", + "database": "hanmac / hanmac_manhour", + "tables": tables, + } + finally: + test_engine.dispose() + + +def get_hanmac_table_preview(payload: dict[str, Any]) -> dict[str, Any]: + schema_name = _validate_hanmac_schema_name(payload.get("schema")) + table_name = _validate_hanmac_table_name(payload.get("table")) + limit_raw = payload.get("limit") + try: + limit = int(limit_raw or 100) + except ValueError as exc: + raise ValueError("조회 건수는 숫자여야 합니다.") from exc + safe_limit = max(1, min(limit, 300)) + + connect_payload = dict(payload) + connect_payload["database"] = schema_name + test_engine = _build_hanmac_mysql_engine(connect_payload) + try: + with test_engine.connect() as connection: + available_tables = { + (str(row["table_schema"]), str(row["table_name"])) + for row in connection.execute( + text( + """ + SELECT table_schema, table_name + FROM information_schema.tables + WHERE table_schema IN ('hanmac', 'hanmac_manhour') + AND table_type = 'BASE TABLE' + """ + ) + ).mappings().all() + } + if (schema_name, table_name) not in available_tables: + raise ValueError("선택한 테이블을 찾을 수 없습니다.") + + column_rows = connection.execute(text(f"SHOW COLUMNS FROM `{schema_name}`.`{table_name}`")).mappings().all() + columns = [str(row.get("Field") or "") for row in column_rows] + data_rows = connection.execute(text(f"SELECT * FROM `{schema_name}`.`{table_name}` LIMIT {safe_limit}")).mappings().all() + rows = [ + { + column: ( + value.isoformat(sep=" ") + if isinstance(value, datetime) + else str(value) if value is not None and not isinstance(value, (int, float, str)) + else value + ) + for column, value in dict(row).items() + } + for row in data_rows + ] + return { + "status": "ok", + "schema": schema_name, + "table": table_name, + "columns": columns, + "rows": rows, + "shown_count": len(rows), + "limit": safe_limit, + } + finally: + test_engine.dispose() + + +def _hanmac_find_column(columns: list[str], candidates: list[str]) -> str | None: + lowered_map = {str(column).lower(): str(column) for column in columns} + for candidate in candidates: + matched = lowered_map.get(str(candidate).lower()) + if matched: + return matched + return None + + +def _hanmac_parse_date_value(value: Any) -> date | None: + if value is None: + return None + if isinstance(value, datetime): + return value.date() + if isinstance(value, date): + return value + text_value = str(value).strip() + if not text_value or text_value in {"0000-00-00", "0000-00-00 00:00:00"}: + return None + for fmt in ("%Y-%m-%d", "%Y-%m-%d %H:%M:%S", "%Y/%m/%d", "%Y/%m/%d %H:%M:%S", "%Y%m%d"): + try: + return datetime.strptime(text_value, fmt).date() + except ValueError: + continue + return None + + +def _hanmac_parse_datetime_value(value: Any) -> datetime | None: + if value is None: + return None + if isinstance(value, datetime): + return value + if isinstance(value, date): + return datetime.combine(value, datetime.min.time()) + text_value = str(value).strip() + if not text_value or text_value in {"0000-00-00", "0000-00-00 00:00:00"}: + return None + for fmt in ("%Y-%m-%d %H:%M:%S", "%Y/%m/%d %H:%M:%S", "%Y-%m-%d", "%Y/%m/%d", "%Y%m%d"): + try: + parsed = datetime.strptime(text_value, fmt) + return parsed + except ValueError: + continue + return None + + +def _hanmac_parse_float_value(value: Any) -> float: + if value is None: + return 0.0 + if isinstance(value, (int, float)): + return float(value) + text_value = str(value).strip() + if not text_value: + return 0.0 + try: + return float(text_value) + except ValueError: + return 0.0 + + +def _hanmac_parse_duration_hours(value: Any) -> float: + if value is None: + return 0.0 + if isinstance(value, (int, float)): + return max(float(value), 0.0) + text_value = str(value).strip() + if not text_value or text_value in {"0000-00-00 00:00:00", "00:00:00"}: + return 0.0 + if " " in text_value and len(text_value.split(" ")[-1].split(":")) == 3: + text_value = text_value.split(" ")[-1] + match = re.fullmatch(r"(\d{1,3}):(\d{2})(?::(\d{2}))?", text_value) + if match: + hours = int(match.group(1)) + minutes = int(match.group(2)) + seconds = int(match.group(3) or 0) + return max(hours + (minutes / 60.0) + (seconds / 3600.0), 0.0) + return max(_hanmac_parse_float_value(text_value), 0.0) + + +def _hanmac_calculate_regular_hours(entry_time: Any, leave_time: Any) -> float: + started_at = _hanmac_parse_datetime_value(entry_time) + ended_at = _hanmac_parse_datetime_value(leave_time) + if not started_at or not ended_at: + return 0.0 + hours = (ended_at - started_at).total_seconds() / 3600.0 + if hours < 0 or hours > 24: + return 0.0 + return round(hours, 2) + + +def _hanmac_resolve_period(payload: dict[str, Any]) -> tuple[date, date]: + today = date.today() + start_date = _hanmac_parse_date_value(payload.get("start_date")) or date(today.year, 1, 1) + end_date = _hanmac_parse_date_value(payload.get("end_date")) or today + if end_date < start_date: + start_date, end_date = end_date, start_date + return start_date, end_date + + +def _hanmac_build_select_alias(column_name: str | None, alias: str) -> str: + return f"`{column_name}` AS `{alias}`" if column_name else f"NULL AS `{alias}`" + + +def _hanmac_fetch_table_columns(connection: Any, schema_name: str) -> dict[str, list[str]]: + table_rows = connection.execute( + text( + """ + SELECT table_name + FROM information_schema.tables + WHERE table_schema = :schema_name + AND table_type = 'BASE TABLE' + ORDER BY table_name + """ + ), + {"schema_name": schema_name}, + ).mappings().all() + metadata: dict[str, list[str]] = {} + for row in table_rows: + table_name = str(row["table_name"]) + column_rows = connection.execute(text(f"SHOW COLUMNS FROM `{schema_name}`.`{table_name}`")).mappings().all() + metadata[table_name] = [str(column_row.get("Field") or "") for column_row in column_rows] + return metadata + + +def _hanmac_member_status_label(entry_date: date | None, leave_date: date | None, today: date) -> str: + if leave_date and leave_date < today: + return "퇴사" + if entry_date and entry_date > today: + return "입사전" + return "재직" + + +def _hanmac_member_matches_filter( + member_record: dict[str, Any], + employment_filter: str, + start_date: date, + end_date: date, + today: date, +) -> bool: + if employment_filter == "all": + return True + entry_date = _hanmac_parse_date_value(member_record.get("entry_date")) + leave_date = _hanmac_parse_date_value(member_record.get("leave_date")) + if employment_filter == "current": + return (entry_date is None or entry_date <= today) and (leave_date is None or leave_date >= today) + if employment_filter == "retired": + return leave_date is not None and leave_date < today + if employment_filter == "period": + entry_ok = entry_date is None or entry_date <= end_date + leave_ok = leave_date is None or leave_date >= start_date + return entry_ok and leave_ok + return True + + +def _hanmac_member_is_active_on(member_record: dict[str, Any], work_date: date | None) -> bool: + if work_date is None: + return True + entry_date = _hanmac_parse_date_value(member_record.get("entry_date")) + leave_date = _hanmac_parse_date_value(member_record.get("leave_date")) + if entry_date and work_date < entry_date: + return False + if leave_date and work_date > leave_date: + return False + return True + + +def get_hanmac_aggregate_summary(payload: dict[str, Any]) -> dict[str, Any]: + schema_name = "hanmac_manhour" + start_date, end_date = _hanmac_resolve_period(payload) + employment_filter = normalize_text(payload.get("employment")) or "all" + view_mode = normalize_text(payload.get("view")) or "member" + connect_payload = dict(payload) + connect_payload["database"] = schema_name + test_engine = _build_hanmac_mysql_engine(connect_payload) + + try: + with test_engine.connect() as connection: + metadata = _hanmac_fetch_table_columns(connection, schema_name) + + member_info: dict[str, dict[str, Any]] = {} + member_columns = metadata.get("member_tbl") or [] + member_no_col = _hanmac_find_column(member_columns, ["MemberNo", "member_no"]) + if member_no_col: + member_name_col = _hanmac_find_column(member_columns, ["Name", "MemberName", "member_name", "UserName", "KorName"]) + entry_date_col = _hanmac_find_column(member_columns, ["EntryDate", "entry_date", "HireDate", "JoinDate", "InDate"]) + leave_date_col = _hanmac_find_column(member_columns, ["LeaveDate", "leave_date", "RetireDate", "OutDate"]) + dept_name_col = _hanmac_find_column(member_columns, ["DeptName", "Department", "PartName", "TeamName", "Dept"]) + member_rows = connection.execute( + text( + f""" + SELECT + {_hanmac_build_select_alias(member_no_col, "member_no")}, + {_hanmac_build_select_alias(member_name_col, "member_name")}, + {_hanmac_build_select_alias(entry_date_col, "entry_date")}, + {_hanmac_build_select_alias(leave_date_col, "leave_date")}, + {_hanmac_build_select_alias(dept_name_col, "dept_name")} + FROM `{schema_name}`.`member_tbl` + """ + ) + ).mappings().all() + for row in member_rows: + member_no = normalize_text(row.get("member_no")) + if not member_no: + continue + member_info[member_no] = { + "member_no": member_no, + "member_name": normalize_text(row.get("member_name")) or member_no, + "entry_date": _hanmac_parse_date_value(row.get("entry_date")), + "leave_date": _hanmac_parse_date_value(row.get("leave_date")), + "dept_name": normalize_text(row.get("dept_name")) or "", + } + + project_map: dict[str, str] = {} + project_columns = metadata.get("project_tbl") or [] + project_code_col = _hanmac_find_column(project_columns, ["project_code", "ProjectCode", "ProjectKey", "PCode"]) + if project_code_col: + project_name_col = _hanmac_find_column(project_columns, ["project_name", "ProjectName", "Name", "project_nm"]) + project_rows = connection.execute( + text( + f""" + SELECT + {_hanmac_build_select_alias(project_code_col, "project_code")}, + {_hanmac_build_select_alias(project_name_col, "project_name")} + FROM `{schema_name}`.`project_tbl` + """ + ) + ).mappings().all() + for row in project_rows: + project_code = normalize_text(row.get("project_code")) + if project_code: + project_map[project_code] = normalize_text(row.get("project_name")) or project_code + + regular_tables = [ + table_name + for table_name, columns in metadata.items() + if table_name.startswith("dallyproject") + and "addwork" not in table_name.lower() + and _hanmac_find_column(columns, ["MemberNo", "member_no"]) + and _hanmac_find_column(columns, ["EntryTime", "entry_time"]) + ] + if "dallyproject_tbl" in regular_tables: + regular_tables = ["dallyproject_tbl"] + + regular_records: list[dict[str, Any]] = [] + for table_name in regular_tables: + columns = metadata.get(table_name) or [] + member_col = _hanmac_find_column(columns, ["MemberNo", "member_no"]) + project_col = _hanmac_find_column(columns, ["project_code", "new_project_code", "ProjectCode", "ProjectKey", "EntryPCode", "PCode"]) + entry_col = _hanmac_find_column(columns, ["EntryTime", "entry_time"]) + leave_col = _hanmac_find_column(columns, ["LeaveTime", "leave_time"]) + if not member_col or not entry_col: + continue + where_clauses = [f"`{member_col}` IS NOT NULL"] + params: dict[str, Any] = {} + if start_date: + where_clauses.append(f"LEFT(CAST(`{entry_col}` AS CHAR), 10) >= :start_date") + params["start_date"] = start_date.isoformat() + if end_date: + where_clauses.append(f"LEFT(CAST(`{entry_col}` AS CHAR), 10) <= :end_date") + params["end_date"] = end_date.isoformat() + regular_rows = connection.execute( + text( + f""" + SELECT + {_hanmac_build_select_alias(member_col, "member_no")}, + {_hanmac_build_select_alias(project_col, "project_code")}, + {_hanmac_build_select_alias(entry_col, "entry_time")}, + {_hanmac_build_select_alias(leave_col, "leave_time")} + FROM `{schema_name}`.`{table_name}` + WHERE {' AND '.join(where_clauses)} + """ + ), + params, + ).mappings().all() + for row in regular_rows: + member_no = normalize_text(row.get("member_no")) + if not member_no: + continue + regular_records.append( + { + "member_no": member_no, + "project_code": normalize_text(row.get("project_code")) or "", + "work_date": _hanmac_parse_date_value(row.get("entry_time")), + "regular_hours": _hanmac_calculate_regular_hours(row.get("entry_time"), row.get("leave_time")), + } + ) + + addwork_columns = metadata.get("dallyproject_addwork_tbl") or [] + overtime_records: list[dict[str, Any]] = [] + addwork_member_col = _hanmac_find_column(addwork_columns, ["MemberNo", "member_no"]) + addwork_date_col = _hanmac_find_column(addwork_columns, ["EntryTime", "entry_time", "work_date"]) + if addwork_member_col: + addwork_project_col = _hanmac_find_column(addwork_columns, ["new_project_code", "project_code", "ProjectCode", "ProjectKey", "PCode"]) + addwork_hour_col = _hanmac_find_column(addwork_columns, ["work_hour", "WorkHour"]) + addwork_min_col = _hanmac_find_column(addwork_columns, ["work_min", "WorkMin"]) + where_clauses = [f"`{addwork_member_col}` IS NOT NULL"] + params = {} + if addwork_date_col and start_date: + where_clauses.append(f"LEFT(CAST(`{addwork_date_col}` AS CHAR), 10) >= :start_date") + params["start_date"] = start_date.isoformat() + if addwork_date_col and end_date: + where_clauses.append(f"LEFT(CAST(`{addwork_date_col}` AS CHAR), 10) <= :end_date") + params["end_date"] = end_date.isoformat() + addwork_rows = connection.execute( + text( + f""" + SELECT + {_hanmac_build_select_alias(addwork_member_col, "member_no")}, + {_hanmac_build_select_alias(addwork_project_col, "project_code")}, + {_hanmac_build_select_alias(addwork_date_col, "work_date")}, + {_hanmac_build_select_alias(addwork_hour_col, "work_hour")}, + {_hanmac_build_select_alias(addwork_min_col, "work_min")} + FROM `{schema_name}`.`dallyproject_addwork_tbl` + WHERE {' AND '.join(where_clauses)} + """ + ), + params, + ).mappings().all() + for row in addwork_rows: + member_no = normalize_text(row.get("member_no")) + if not member_no: + continue + overtime_hours = round(_hanmac_parse_float_value(row.get("work_hour")) + (_hanmac_parse_float_value(row.get("work_min")) / 60.0), 2) + overtime_records.append( + { + "member_no": member_no, + "project_code": normalize_text(row.get("project_code")) or "", + "work_date": _hanmac_parse_date_value(row.get("work_date")), + "overtime_hours": overtime_hours, + } + ) + + leave_records: list[dict[str, Any]] = [] + tardy_columns = metadata.get("worker_tardy_tbl") or [] + tardy_member_col = _hanmac_find_column(tardy_columns, ["MemberNo", "member_no", "EmpNo", "UserID"]) + if tardy_member_col: + tardy_date_col = _hanmac_find_column(tardy_columns, ["work_date", "WorkDate", "EntryDate", "Date", "TardyDate"]) + tardy_type_col = _hanmac_find_column(tardy_columns, ["reason", "Reason", "state", "State", "gubun", "Gubun", "type", "Type", "kind", "Kind"]) + tardy_value_col = _hanmac_find_column(tardy_columns, ["day_count", "DayCount", "days", "Days", "use_day", "UseDay", "work_day", "WorkDay", "hours", "Hours", "time", "Time"]) + where_clauses = [f"`{tardy_member_col}` IS NOT NULL"] + params = {} + if tardy_date_col and start_date: + where_clauses.append(f"LEFT(CAST(`{tardy_date_col}` AS CHAR), 10) >= :start_date") + params["start_date"] = start_date.isoformat() + if tardy_date_col and end_date: + where_clauses.append(f"LEFT(CAST(`{tardy_date_col}` AS CHAR), 10) <= :end_date") + params["end_date"] = end_date.isoformat() + tardy_rows = connection.execute( + text( + f""" + SELECT + {_hanmac_build_select_alias(tardy_member_col, "member_no")}, + {_hanmac_build_select_alias(tardy_date_col, "work_date")}, + {_hanmac_build_select_alias(tardy_type_col, "leave_type")}, + {_hanmac_build_select_alias(tardy_value_col, "leave_value")} + FROM `{schema_name}`.`worker_tardy_tbl` + WHERE {' AND '.join(where_clauses)} + """ + ), + params, + ).mappings().all() + leave_keywords = ("연차", "휴가", "휴직", "반차", "공가", "병가", "출산", "육아", "대체", "leave", "vacation", "holiday") + for row in tardy_rows: + member_no = normalize_text(row.get("member_no")) + leave_type = normalize_text(row.get("leave_type")) + if not member_no or not leave_type: + continue + if not any(keyword in leave_type.lower() for keyword in leave_keywords): + continue + leave_amount = _hanmac_parse_float_value(row.get("leave_value")) or 1.0 + leave_records.append( + { + "member_no": member_no, + "work_date": _hanmac_parse_date_value(row.get("work_date")), + "leave_days": round(leave_amount, 2), + "leave_type": leave_type, + } + ) + + today = date.today() + member_aggregates: dict[str, dict[str, Any]] = {} + project_aggregates: dict[str, dict[str, Any]] = {} + leave_days_by_member_date: dict[tuple[str, date], float] = {} + + def include_member(member_no: str) -> bool: + member_record = member_info.get(member_no) or { + "member_no": member_no, + "member_name": member_no, + "entry_date": None, + "leave_date": None, + "dept_name": "", + } + return _hanmac_member_matches_filter(member_record, employment_filter, start_date, end_date, today) + + for row in leave_records: + member_no = row["member_no"] + work_date = row.get("work_date") + if not include_member(member_no): + continue + member_record = member_info.get(member_no) or { + "member_no": member_no, + "member_name": member_no, + "entry_date": None, + "leave_date": None, + "dept_name": "", + } + if not _hanmac_member_is_active_on(member_record, work_date): + continue + if work_date: + key = (member_no, work_date) + leave_days_by_member_date[key] = round( + leave_days_by_member_date.get(key, 0.0) + row["leave_days"], + 2, + ) + + def ensure_member_bucket(member_no: str) -> dict[str, Any]: + member_record = member_info.get(member_no) or { + "member_no": member_no, + "member_name": member_no, + "entry_date": None, + "leave_date": None, + "dept_name": "", + } + return member_aggregates.setdefault( + member_no, + { + "member_no": member_no, + "member_name": member_record["member_name"], + "status": _hanmac_member_status_label(member_record["entry_date"], member_record["leave_date"], today), + "entry_date": member_record["entry_date"].isoformat() if member_record["entry_date"] else "", + "leave_date": member_record["leave_date"].isoformat() if member_record["leave_date"] else "", + "dept_name": member_record["dept_name"], + "regular_hours": 0.0, + "overtime_hours": 0.0, + "holiday_hours": 0.0, + "legal_leave_days": 0.0, + "project_codes": set(), + "multi_entry_days": 0, + "multi_entry_details": [], + }, + ) + + def ensure_project_bucket(project_code: str) -> dict[str, Any]: + project_key = project_code or "(미지정)" + return project_aggregates.setdefault( + project_key, + { + "project_code": project_key, + "project_name": project_map.get(project_code or "", project_key), + "regular_hours": 0.0, + "overtime_hours": 0.0, + "holiday_hours": 0.0, + "legal_leave_days": 0.0, + "member_nos": set(), + }, + ) + + regular_day_groups: dict[tuple[str, date], dict[str, Any]] = {} + for row in regular_records: + member_no = row["member_no"] + if not include_member(member_no): + continue + member_record = member_info.get(member_no) or { + "member_no": member_no, + "member_name": member_no, + "entry_date": None, + "leave_date": None, + "dept_name": "", + } + work_date = row.get("work_date") + if not work_date or not _hanmac_member_is_active_on(member_record, work_date): + continue + key = (member_no, work_date) + day_group = regular_day_groups.setdefault( + key, + { + "member_no": member_no, + "work_date": work_date, + "project_hours": {}, + "entries": [], + }, + ) + project_code = row["project_code"] + raw_hours = max(0.0, _hanmac_parse_float_value(row["regular_hours"])) + day_group["project_hours"][project_code] = round( + day_group["project_hours"].get(project_code, 0.0) + raw_hours, + 4, + ) + day_group["entries"].append( + { + "project_code": project_code or "(미지정)", + "project_name": project_map.get(project_code or "", project_code or "(미지정)"), + "regular_hours": round(raw_hours, 2), + } + ) + + for (member_no, work_date), day_group in regular_day_groups.items(): + member_bucket = ensure_member_bucket(member_no) + raw_total_hours = round(sum(day_group["project_hours"].values()), 2) + leave_days = min(1.0, max(0.0, leave_days_by_member_date.get((member_no, work_date), 0.0))) + weekday_cap = 8.0 if work_date.weekday() < 5 else 0.0 + capped_regular_hours = round(min(raw_total_hours, max(0.0, weekday_cap * (1.0 - leave_days))), 2) + holiday_hours = round(raw_total_hours if work_date.weekday() >= 5 else 0.0, 2) + + member_bucket["regular_hours"] += capped_regular_hours + member_bucket["holiday_hours"] += holiday_hours + + for project_code in day_group["project_hours"].keys(): + if project_code: + member_bucket["project_codes"].add(project_code) + + if len(day_group["entries"]) > 1: + member_bucket["multi_entry_days"] += 1 + member_bucket["multi_entry_details"].append( + { + "work_date": work_date.isoformat(), + "row_count": len(day_group["entries"]), + "raw_total_hours": raw_total_hours, + "capped_regular_hours": capped_regular_hours, + "leave_days": round(leave_days, 2), + "entries": sorted( + day_group["entries"], + key=lambda item: (-item["regular_hours"], item["project_code"]), + ), + } + ) + + if raw_total_hours <= 0: + continue + + if work_date.weekday() >= 5: + for project_code, project_hours in day_group["project_hours"].items(): + project_bucket = ensure_project_bucket(project_code) + project_bucket["holiday_hours"] += round(project_hours, 2) + project_bucket["member_nos"].add(member_no) + continue + + for project_code, project_hours in day_group["project_hours"].items(): + project_bucket = ensure_project_bucket(project_code) + share_ratio = project_hours / raw_total_hours if raw_total_hours else 0.0 + project_bucket["regular_hours"] += round(capped_regular_hours * share_ratio, 4) + project_bucket["member_nos"].add(member_no) + + for row in overtime_records: + member_no = row["member_no"] + if not include_member(member_no): + continue + project_code = row["project_code"] + member_record = member_info.get(member_no) or { + "member_no": member_no, + "member_name": member_no, + "entry_date": None, + "leave_date": None, + "dept_name": "", + } + if not _hanmac_member_is_active_on(member_record, row["work_date"]): + continue + member_bucket = ensure_member_bucket(member_no) + member_bucket["overtime_hours"] += row["overtime_hours"] + if project_code: + member_bucket["project_codes"].add(project_code) + + project_bucket = ensure_project_bucket(project_code) + project_bucket["overtime_hours"] += row["overtime_hours"] + project_bucket["member_nos"].add(member_no) + + for row in leave_records: + member_no = row["member_no"] + if not include_member(member_no): + continue + member_record = member_info.get(member_no) or { + "member_no": member_no, + "member_name": member_no, + "entry_date": None, + "leave_date": None, + "dept_name": "", + } + if not _hanmac_member_is_active_on(member_record, row["work_date"]): + continue + member_bucket = ensure_member_bucket(member_no) + member_bucket["legal_leave_days"] += row["leave_days"] + + if view_mode == "project": + rows = [ + { + "project_code": bucket["project_code"], + "project_name": bucket["project_name"], + "member_count": len(bucket["member_nos"]), + "regular_hours": round(bucket["regular_hours"], 2), + "overtime_hours": round(bucket["overtime_hours"], 2), + "holiday_hours": round(bucket["holiday_hours"], 2), + "total_hours": round(bucket["regular_hours"] + bucket["overtime_hours"] + bucket["holiday_hours"], 2), + "legal_leave_days": round(bucket["legal_leave_days"], 2), + } + for bucket in project_aggregates.values() + ] + rows.sort(key=lambda item: (-item["total_hours"], item["project_code"])) + columns = [ + {"key": "project_code", "label": "프로젝트코드"}, + {"key": "project_name", "label": "프로젝트명"}, + {"key": "member_count", "label": "참여인원"}, + {"key": "regular_hours", "label": "정규근로"}, + {"key": "overtime_hours", "label": "연장근로"}, + {"key": "total_hours", "label": "총근로"}, + {"key": "legal_leave_days", "label": "법정휴가"}, + ] + else: + rows = [ + { + "member_no": bucket["member_no"], + "member_name": bucket["member_name"], + "status": bucket["status"], + "entry_date": bucket["entry_date"], + "leave_date": bucket["leave_date"], + "dept_name": bucket["dept_name"], + "regular_hours": round(bucket["regular_hours"], 2), + "overtime_hours": round(bucket["overtime_hours"], 2), + "holiday_hours": round(bucket["holiday_hours"], 2), + "total_hours": round(bucket["regular_hours"] + bucket["overtime_hours"] + bucket["holiday_hours"], 2), + "legal_leave_days": round(bucket["legal_leave_days"], 2), + "project_count": len(bucket["project_codes"]), + "multi_entry_days": bucket["multi_entry_days"], + "multi_entry_details": sorted( + bucket["multi_entry_details"], + key=lambda item: item["work_date"], + ), + } + for bucket in member_aggregates.values() + ] + rows.sort(key=lambda item: (-item["total_hours"], item["member_no"])) + columns = [ + {"key": "member_no", "label": "사번"}, + {"key": "member_name", "label": "이름"}, + {"key": "status", "label": "구분"}, + {"key": "entry_date", "label": "입사일"}, + {"key": "leave_date", "label": "퇴사일"}, + {"key": "dept_name", "label": "부서"}, + {"key": "regular_hours", "label": "정규근로"}, + {"key": "overtime_hours", "label": "연장근로"}, + {"key": "total_hours", "label": "총근로"}, + {"key": "legal_leave_days", "label": "법정휴가"}, + {"key": "project_count", "label": "프로젝트수"}, + ] + + summary = { + "member_count": len(member_aggregates), + "project_count": len(project_aggregates), + "regular_hours": round(sum(item.get("regular_hours", 0.0) for item in rows), 2), + "overtime_hours": round(sum(item.get("overtime_hours", 0.0) for item in rows), 2), + "legal_leave_days": round(sum(item.get("legal_leave_days", 0.0) for item in rows), 2), + } + summary["holiday_hours"] = round(sum(item.get("holiday_hours", 0.0) for item in rows), 2) + summary["total_hours"] = round(summary["regular_hours"] + summary["overtime_hours"] + summary["holiday_hours"], 2) + + return { + "status": "ok", + "view": view_mode, + "start_date": start_date.isoformat(), + "end_date": end_date.isoformat(), + "employment": employment_filter, + "columns": columns, + "rows": rows, + "summary": summary, + } + finally: + test_engine.dispose() + + +def build_hanmac_mysql_error_message(exc: OperationalError) -> str: + raw_message = str(exc.orig or exc) + lowered = raw_message.lower() + + if "access denied for user" in lowered: + return ( + "MySQL 로그인은 시도됐지만 권한이 거부되었습니다. 비밀번호가 다르거나, " + "현재 앱 서버의 접속 출발지 IP(172.31.18.135)에 대해 root 계정이 허용되지 않았을 가능성이 큽니다." + ) + if "no route to host" in lowered or "network is unreachable" in lowered: + return ( + "현재 앱 서버가 172.16.42.111까지 네트워크 경로를 찾지 못하고 있습니다. " + "입력값 문제보다는 내부망 경로, VPN, 라우팅 또는 방화벽 상태를 먼저 확인해주세요." + ) + if "unknown character set" in lowered: + return "MySQL 서버 문자셋 호환성 문제였습니다. 앱 쪽 설정을 조정했으니 다시 연결 확인을 시도해주세요." + if "unknown database" in lowered: + return "선택한 DB 이름을 찾지 못했습니다. hanmac 또는 hanmac_manhour 선택을 다시 확인해주세요." + if "can't connect" in lowered or "connection refused" in lowered or "timed out" in lowered: + return "MySQL 서버 포트에는 접근했지만 최종 연결에 실패했습니다. 서버 상태 또는 방화벽 설정을 확인해주세요." + if "authentication plugin" in lowered: + return "MySQL 인증 방식이 현재 앱과 맞지 않습니다. 서버 계정의 인증 플러그인 설정을 확인해주세요." + return f"MySQL 연결에 실패했습니다. 상세 원인: {raw_message}" + + def build_wehago_compare_health_payload() -> dict[str, Any]: init_db() payload = get_wehago_compare_dashboard( @@ -9092,6 +10105,87 @@ async def process_cost( return HTMLResponse("
로그를 확인해주세요.
", status_code=500) +@app.get("/hanmac-browser") +async def hanmac_browser(request: Request): + try: + return render_hanmac_browser_page(request) + except Exception as exc: + logger.exception("hanmac DB_external 페이지 에러: %s", exc) + return HTMLResponse("로그를 확인해주세요.
", status_code=500) + + +@app.post("/hanmac-browser/api/test-connection") +async def hanmac_browser_test_connection(request: Request): + try: + payload = await request.json() + if not isinstance(payload, dict): + raise ValueError("잘못된 요청 형식입니다.") + return JSONResponse(content=test_hanmac_mysql_connection(payload)) + except OperationalError as exc: + logger.exception("hanmac DB_external MySQL 연결 실패: %s", exc) + return JSONResponse( + content={ + "status": "error", + "message": build_hanmac_mysql_error_message(exc), + }, + status_code=400, + ) + except Exception as exc: + logger.exception("hanmac DB_external 연결 확인 에러: %s", exc) + return JSONResponse( + content={ + "status": "error", + "message": str(exc), + }, + status_code=400, + ) + + +@app.post("/hanmac-browser/api/tables") +async def hanmac_browser_tables(request: Request): + try: + payload = await request.json() + if not isinstance(payload, dict): + raise ValueError("잘못된 요청 형식입니다.") + return JSONResponse(content=get_hanmac_table_list(payload)) + except OperationalError as exc: + logger.exception("hanmac DB_external 테이블 목록 조회 실패: %s", exc) + return JSONResponse(content={"status": "error", "message": build_hanmac_mysql_error_message(exc)}, status_code=400) + except Exception as exc: + logger.exception("hanmac DB_external 테이블 목록 에러: %s", exc) + return JSONResponse(content={"status": "error", "message": str(exc)}, status_code=400) + + +@app.post("/hanmac-browser/api/preview") +async def hanmac_browser_preview(request: Request): + try: + payload = await request.json() + if not isinstance(payload, dict): + raise ValueError("잘못된 요청 형식입니다.") + return JSONResponse(content=get_hanmac_table_preview(payload)) + except OperationalError as exc: + logger.exception("hanmac DB_external 테이블 미리보기 실패: %s", exc) + return JSONResponse(content={"status": "error", "message": build_hanmac_mysql_error_message(exc)}, status_code=400) + except Exception as exc: + logger.exception("hanmac DB_external 테이블 미리보기 에러: %s", exc) + return JSONResponse(content={"status": "error", "message": str(exc)}, status_code=400) + + +@app.post("/hanmac-browser/api/aggregate") +async def hanmac_browser_aggregate(request: Request): + try: + payload = await request.json() + if not isinstance(payload, dict): + raise ValueError("잘못된 요청 형식입니다.") + return JSONResponse(content=get_hanmac_aggregate_summary(payload)) + except OperationalError as exc: + logger.exception("hanmac DB_external 집계 조회 실패: %s", exc) + return JSONResponse(content={"status": "error", "message": build_hanmac_mysql_error_message(exc)}, status_code=400) + except Exception as exc: + logger.exception("hanmac DB_external 집계 에러: %s", exc) + return JSONResponse(content={"status": "error", "message": str(exc)}, status_code=400) + + @app.get("/wehago-compare") async def wehago_compare(request: Request, start_year: str | None = None, end_year: str | None = None): try: @@ -9195,6 +10289,104 @@ async def wehago_compare_status_rows( return JSONResponse(content={"error": str(exc)}, status_code=500) + + + + +@app.post("/wehago-compare/api/status-export-jobs") +async def wehago_compare_status_export_jobs(request: Request): + try: + payload = await request.json() + if not isinstance(payload, dict): + payload = {} + response = request_status_export_xlsx( + engine, + start_year=int(payload.get("start_year")) if payload.get("start_year") is not None else None, + end_year=int(payload.get("end_year")) if payload.get("end_year") is not None else None, + status=str(payload.get("status") or ""), + voucher_no=str(payload.get("voucher_no") or ""), + draft_no=str(payload.get("draft_no") or ""), + wehago_account=str(payload.get("wehago_account") or ""), + erp_account=str(payload.get("erp_account") or ""), + wehago_amount=str(payload.get("wehago_amount") or ""), + erp_amount=str(payload.get("erp_amount") or ""), + wehago_vendor=str(payload.get("wehago_vendor") or ""), + erp_vendor=str(payload.get("erp_vendor") or ""), + desc_keyword=str(payload.get("desc_keyword") or ""), + ) + return JSONResponse(content=jsonable_encoder({"ok": True, **response})) + except Exception as exc: + logger.exception("전표비교 엑셀 준비 요청 에러: %s", exc) + return JSONResponse(content={"error": str(exc)}, status_code=500) + + +@app.get("/wehago-compare/api/status-export-jobs/{job_key}") +async def wehago_compare_status_export_job(job_key: str): + try: + payload = get_status_export_job(engine, job_key) + return JSONResponse(content=jsonable_encoder(payload)) + except Exception as exc: + logger.exception("전표비교 엑셀 상태 조회 에러: %s", exc) + return JSONResponse(content={"error": str(exc)}, status_code=500) + + +@app.get("/wehago-compare/api/status-export-download/{job_key}") +async def wehago_compare_status_export_download(job_key: str): + try: + payload = get_status_export_job(engine, job_key) + file_path = payload.get("file_path") or "" + file_name = payload.get("file_name") or f"{job_key}.xlsx" + if payload.get("state") != "ready" or not file_path or not Path(file_path).exists(): + return JSONResponse(content={"error": "엑셀 파일을 아직 준비 중입니다."}, status_code=409) + return FileResponse( + path=file_path, + media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + filename=file_name, + ) + except Exception as exc: + logger.exception("전표비교 엑셀 다운로드 전달 에러: %s", exc) + return JSONResponse(content={"error": str(exc)}, status_code=500) + +@app.get("/wehago-compare/api/status-export") +async def wehago_compare_status_export( + start_year: int | None = None, + end_year: int | None = None, + status: str = "", + voucher_no: str = "", + draft_no: str = "", + wehago_account: str = "", + erp_account: str = "", + wehago_amount: str = "", + erp_amount: str = "", + wehago_vendor: str = "", + erp_vendor: str = "", + desc_keyword: str = "", +): + try: + file_name, file_bytes, _row_count = export_wehago_status_rows_xlsx( + engine, + start_year=start_year, + end_year=end_year, + status=status, + voucher_no=voucher_no, + draft_no=draft_no, + wehago_account=wehago_account, + erp_account=erp_account, + wehago_amount=wehago_amount, + erp_amount=erp_amount, + wehago_vendor=wehago_vendor, + erp_vendor=erp_vendor, + desc_keyword=desc_keyword, + ) + return Response( + content=file_bytes, + media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + headers={"Content-Disposition": f'attachment; filename="{file_name}"'}, + ) + except Exception as exc: + logger.exception("전표비교 엑셀 다운로드 에러: %s", exc) + return JSONResponse(content={"error": str(exc)}, status_code=500) + @app.get("/wehago-compare/api/summary") async def wehago_compare_summary( start_year: int | None = None, @@ -9216,12 +10408,14 @@ async def wehago_compare_summary( async def wehago_compare_snapshot_status( start_year: int | None = None, end_year: int | None = None, + force: int = 0, ): try: payload = get_compare_snapshot_status( engine, start_year=start_year, end_year=end_year, + force=bool(force), ) return JSONResponse(content=jsonable_encoder(payload)) except Exception as exc: @@ -9250,6 +10444,29 @@ async def wehago_compare_snapshot_rebuild(request: Request): return JSONResponse(content={"error": str(exc)}, status_code=500) +@app.get("/wehago-compare/api/bridge-review-settings") +async def wehago_compare_bridge_review_settings(): + try: + payload = load_bridge_review_settings(engine) + return JSONResponse(content=jsonable_encoder(payload)) + except Exception as exc: + logger.exception("2단계 비교 설정 조회 에러: %s", exc) + return JSONResponse(content={"error": str(exc)}, status_code=500) + + +@app.post("/wehago-compare/api/bridge-review-settings") +async def wehago_compare_bridge_review_settings_save(request: Request): + try: + payload = await request.json() + if not isinstance(payload, dict): + payload = {} + saved = save_bridge_review_settings(engine, payload) + return JSONResponse(content=jsonable_encoder({"ok": True, "settings": saved})) + except Exception as exc: + logger.exception("2단계 비교 설정 저장 에러: %s", exc) + return JSONResponse(content={"error": str(exc)}, status_code=500) + + @app.get("/wehago-compare/api/status-suggestions") async def wehago_compare_status_suggestions( start_year: int | None = None, diff --git a/requirements.txt b/requirements.txt index 6e3c43a..1760573 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,7 @@ fastapi jinja2 openpyxl +pymysql python-multipart sqlalchemy uvicorn diff --git a/templates/base.html b/templates/base.html index 0aadcf3..f750e8b 100644 --- a/templates/base.html +++ b/templates/base.html @@ -687,6 +687,7 @@ 프로젝트 원가 프로젝트 정보 전표비교 + hanmac DB_external DB 조회