diff --git a/data.db b/data.db index 1adcb31..bd731a6 100644 Binary files a/data.db and b/data.db differ diff --git a/main.py b/main.py index 20c1061..f5808c1 100644 --- a/main.py +++ b/main.py @@ -8,7 +8,7 @@ import threading import time import tempfile import zipfile -from datetime import date, datetime +from datetime import date, datetime, timedelta from decimal import Decimal, InvalidOperation, ROUND_HALF_UP from functools import lru_cache from pathlib import Path @@ -25,8 +25,10 @@ from openpyxl import load_workbook from sqlalchemy import bindparam, create_engine, event, text from sqlalchemy.engine import URL from sqlalchemy.exc import OperationalError +from starlette.concurrency import run_in_threadpool from datasette.app import Datasette from wehago_compare import ( + cleanup_compare_runtime_artifacts, enqueue_default_pair_recommend_precompute, export_wehago_status_rows_xlsx, get_erp_filtered_rows, @@ -67,13 +69,39 @@ _DB_INIT_LOCK = threading.Lock() _DB_INIT_DONE = False _DB_ANALYZE_LOCK = threading.Lock() _DB_ANALYZE_LAST_ATTEMPT_AT = 0.0 +_HANMAC_LAST_AGGREGATE_DIAGNOSTICS: dict[str, Any] = {} +_HANMAC_AGGREGATE_CACHE_TTL_SEC = 300.0 +_HANMAC_AGGREGATE_REFRESHING: set[str] = set() +_HANMAC_AGGREGATE_REFRESHING_LOCK = threading.Lock() +_HANMAC_PREVIEW_CACHE_TTL_SEC = 180.0 +_HANMAC_PREVIEW_REFRESHING: set[str] = set() +_HANMAC_PREVIEW_REFRESHING_LOCK = threading.Lock() +_HANMAC_EXPORT_JOB_EVENT = threading.Event() +_HANMAC_EXPORT_WORKER_LOCK = threading.Lock() +_HANMAC_EXPORT_WORKER_STARTED = False +_HANMAC_EXPORT_WORKER_THREAD: threading.Thread | None = None +_APP_MAINTENANCE_WORKER_LOCK = threading.Lock() +_APP_MAINTENANCE_WORKER_STARTED = False +_APP_MAINTENANCE_WORKER_THREAD: threading.Thread | None = None DB_BACKUP_MIN_INTERVAL_SECONDS = 900.0 DB_BACKUP_KEEP_COUNT = 24 DB_ANALYZE_MIN_INTERVAL_SECONDS = 6 * 60 * 60 +APP_MAINTENANCE_INTERVAL_SECONDS = 15 * 60.0 +EXPORT_RETENTION_SECONDS = 24 * 60 * 60 +QUERY_CACHE_RETENTION_SECONDS = 7 * 24 * 60 * 60 PROCESS_COST_CACHE_TTL_SECONDS = 120.0 _PROCESS_COST_RUNTIME_CACHE_LOCK = threading.Lock() _PROCESS_COST_PROJECT_OPTIONS_CACHE: dict[tuple[Any, ...], dict[str, Any]] = {} _PROCESS_COST_PROJECT_DETAIL_CACHE: dict[tuple[Any, ...], dict[str, Any]] = {} +_ANNUAL_SUMMARY_BOOTSTRAP_CACHE_LOCK = threading.Lock() +_ANNUAL_SUMMARY_BOOTSTRAP_CACHE: dict[str, Any] = { + "stored_at": 0.0, + "payload": None, +} +ANNUAL_SUMMARY_BOOTSTRAP_CACHE_TTL_SECONDS = 300.0 +_DASHBOARD_BOOTSTRAP_CACHE_LOCK = threading.Lock() +_DASHBOARD_BOOTSTRAP_CACHE: dict[tuple[Any, ...], dict[str, Any]] = {} +DASHBOARD_BOOTSTRAP_CACHE_TTL_SECONDS = 300.0 app = FastAPI() @@ -85,12 +113,13 @@ BACKUP_DIR = BASE_DIR / "backups" STATIC_DIR.mkdir(exist_ok=True) BACKUP_DIR.mkdir(exist_ok=True) +HANMAC_EXPORT_DIR = Path(tempfile.gettempdir()) / "my_intranet_hanmac_exports" templates = Jinja2Templates(directory=str(TEMPLATES_DIR)) app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static") engine = create_engine( f"sqlite:///{DB_PATH}", - connect_args={"check_same_thread": False}, + connect_args={"check_same_thread": False, "timeout": 30}, ) @@ -712,6 +741,119 @@ def init_db() -> None: """ ) ) + conn.execute( + text( + """ + CREATE TABLE IF NOT EXISTS hanmac_aggregate_query_cache ( + cache_key TEXT PRIMARY KEY, + payload_signature TEXT NOT NULL DEFAULT '', + payload_json TEXT NOT NULL DEFAULT '{}', + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) + """ + ) + ) + conn.execute( + text( + """ + CREATE TABLE IF NOT EXISTS hanmac_aggregate_query_metrics ( + cache_key TEXT PRIMARY KEY, + payload_signature TEXT NOT NULL DEFAULT '', + view_mode TEXT NOT NULL DEFAULT '', + employment_filter TEXT NOT NULL DEFAULT '', + start_date TEXT NOT NULL DEFAULT '', + end_date TEXT NOT NULL DEFAULT '', + summary_json TEXT NOT NULL DEFAULT '{}', + columns_json TEXT NOT NULL DEFAULT '[]', + row_count INTEGER NOT NULL DEFAULT 0, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) + """ + ) + ) + conn.execute( + text( + """ + CREATE INDEX IF NOT EXISTS idx_hanmac_aggregate_query_metrics_updated_at + ON hanmac_aggregate_query_metrics (updated_at) + """ + ) + ) + conn.execute( + text( + """ + CREATE TABLE IF NOT EXISTS hanmac_aggregate_query_rows ( + cache_key TEXT NOT NULL, + row_index INTEGER NOT NULL, + row_json TEXT NOT NULL DEFAULT '{}', + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (cache_key, row_index) + ) + """ + ) + ) + conn.execute( + text( + """ + CREATE INDEX IF NOT EXISTS idx_hanmac_aggregate_query_rows_updated_at + ON hanmac_aggregate_query_rows (updated_at) + """ + ) + ) + conn.execute( + text( + """ + CREATE INDEX IF NOT EXISTS idx_hanmac_aggregate_query_cache_updated_at + ON hanmac_aggregate_query_cache (updated_at) + """ + ) + ) + conn.execute( + text( + """ + CREATE TABLE IF NOT EXISTS hanmac_preview_query_cache ( + cache_key TEXT PRIMARY KEY, + payload_signature TEXT NOT NULL DEFAULT '', + payload_json TEXT NOT NULL DEFAULT '{}', + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) + """ + ) + ) + conn.execute( + text( + """ + CREATE INDEX IF NOT EXISTS idx_hanmac_preview_query_cache_updated_at + ON hanmac_preview_query_cache (updated_at) + """ + ) + ) + conn.execute( + text( + """ + CREATE TABLE IF NOT EXISTS hanmac_export_jobs ( + job_key TEXT PRIMARY KEY, + job_type TEXT NOT NULL DEFAULT 'preview_export_csv', + payload_json TEXT NOT NULL DEFAULT '{}', + state TEXT NOT NULL DEFAULT 'queued', + file_path TEXT NOT NULL DEFAULT '', + file_name TEXT NOT NULL DEFAULT '', + row_count INTEGER NOT NULL DEFAULT 0, + error_message TEXT NOT NULL DEFAULT '', + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) + """ + ) + ) + conn.execute( + text( + """ + CREATE INDEX IF NOT EXISTS idx_hanmac_export_jobs_state_created + ON hanmac_export_jobs (state, created_at) + """ + ) + ) conn.execute( text( """ @@ -2804,6 +2946,7 @@ def sync_auto_project_related_links() -> None: def on_startup() -> None: init_db() init_wehago_compare_db(engine) + _ensure_app_maintenance_worker() with engine.begin() as conn: corrected = sanitize_project_labor_amount_rows(conn) if corrected: @@ -6175,9 +6318,14 @@ def get_project_cost_by_year(selected_year: int | None) -> list[dict[str, Any]]: return result -def get_project_account_breakdowns(selected_year: int | None) -> dict[str, dict[str, list[dict[str, Any]]]]: +def get_project_account_breakdowns( + selected_year: int | None, + codes: list[str] | None = None, +) -> dict[str, dict[str, list[dict[str, Any]]]]: year_clause = "" params: dict[str, Any] = {} + code_clause = "" + normalized_codes = [normalize_text(code) for code in (codes or []) if normalize_text(code)] if selected_year: year_clause = "AND year = :selected_year" params["selected_year"] = selected_year @@ -6186,56 +6334,61 @@ def get_project_account_breakdowns(selected_year: int | None) -> dict[str, dict[ if recent_10_start_year is not None: year_clause = "AND year >= :recent_10_start_year" params["recent_10_start_year"] = recent_10_start_year + if normalized_codes: + code_clause = "AND support_dept_code IN :selected_codes" + params["selected_codes"] = normalized_codes + + grouped_breakdown_query = text( + f""" + SELECT support_dept_code, + CASE + WHEN {REVENUE_SQL} THEN 'revenue' + WHEN accounting_category = '원가' THEN 'cost' + WHEN accounting_category = '판관비' THEN 'sga' + ELSE 'other' + END AS breakdown_kind, + COALESCE(account_code, '') AS account_code, + COALESCE(account_name, '') AS account_name, + SUM(COALESCE(amount, 0)) AS total_amount + FROM transactions + WHERE COALESCE(support_dept_code, '') <> '' + AND support_dept_code NOT IN ('ZZZZZZ') + AND COALESCE(support_dept_name, '') <> '' + AND support_dept_name NOT IN ('공통', '경영지원부', '기술개발센터', '임원실', '기술개발부', '총괄기획실') + AND ({REVENUE_SQL} OR accounting_category IN ('원가', '판관비')) + {year_clause} + {code_clause} + GROUP BY support_dept_code, breakdown_kind, account_code, account_name + """ + ) + cost_detail_query = text( + f""" + SELECT + COALESCE(support_dept_code, '') AS support_dept_code, + COALESCE(voucher_number, '') AS voucher_number, + COALESCE(posting_date, '') AS posting_date, + COALESCE(partner_name, '') AS partner_name, + COALESCE(partner_code, '') AS partner_code, + COALESCE(account_code, '') AS account_code, + COALESCE(account_name, '') AS account_name, + COALESCE(amount, 0) AS amount + FROM transactions + WHERE COALESCE(support_dept_code, '') <> '' + AND support_dept_code NOT IN ('ZZZZZZ') + AND COALESCE(support_dept_name, '') <> '' + AND support_dept_name NOT IN ('공통', '경영지원부', '기술개발센터', '임원실', '기술개발부', '총괄기획실') + AND accounting_category = '원가' + {year_clause} + {code_clause} + """ + ) + if normalized_codes: + grouped_breakdown_query = grouped_breakdown_query.bindparams(bindparam("selected_codes", expanding=True)) + cost_detail_query = cost_detail_query.bindparams(bindparam("selected_codes", expanding=True)) with engine.begin() as conn: - rows = conn.execute( - text( - f""" - SELECT support_dept_code, - CASE - WHEN {REVENUE_SQL} THEN 'revenue' - WHEN accounting_category = '원가' THEN 'cost' - WHEN accounting_category = '판관비' THEN 'sga' - ELSE 'other' - END AS breakdown_kind, - COALESCE(account_code, '') AS account_code, - COALESCE(account_name, '') AS account_name, - SUM(COALESCE(amount, 0)) AS total_amount - FROM transactions - WHERE COALESCE(support_dept_code, '') <> '' - AND support_dept_code NOT IN ('ZZZZZZ') - AND COALESCE(support_dept_name, '') <> '' - AND support_dept_name NOT IN ('공통', '경영지원부', '기술개발센터', '임원실', '기술개발부', '총괄기획실') - AND ({REVENUE_SQL} OR accounting_category IN ('원가', '판관비')) - {year_clause} - GROUP BY support_dept_code, breakdown_kind, account_code, account_name - """ - ), - params, - ).mappings().all() - cost_detail_rows = conn.execute( - text( - f""" - SELECT - COALESCE(support_dept_code, '') AS support_dept_code, - COALESCE(voucher_number, '') AS voucher_number, - COALESCE(posting_date, '') AS posting_date, - COALESCE(partner_name, '') AS partner_name, - COALESCE(partner_code, '') AS partner_code, - COALESCE(account_code, '') AS account_code, - COALESCE(account_name, '') AS account_name, - COALESCE(amount, 0) AS amount - FROM transactions - WHERE COALESCE(support_dept_code, '') <> '' - AND support_dept_code NOT IN ('ZZZZZZ') - AND COALESCE(support_dept_name, '') <> '' - AND support_dept_name NOT IN ('공통', '경영지원부', '기술개발센터', '임원실', '기술개발부', '총괄기획실') - AND accounting_category = '원가' - {year_clause} - """ - ), - params, - ).mappings().all() + rows = conn.execute(grouped_breakdown_query, params).mappings().all() + cost_detail_rows = conn.execute(cost_detail_query, params).mappings().all() result: dict[str, dict[str, Any]] = {} for row in rows: @@ -7467,6 +7620,90 @@ def render_process_cost_page( return templates.TemplateResponse(request, "process_cost.html", context) +def get_process_cost_bootstrap_payload( + source: str | None = None, + start_year: int | None = None, + end_year: int | None = None, + code: str | None = None, + include_related: bool = False, + active_related: str | None = None, +) -> dict[str, Any]: + normalized_source = normalize_text(source).lower() + if normalized_source not in {"hanmac", "wehago"}: + normalized_source = "hanmac" + + years = get_process_cost_available_years(normalized_source) + selected_start_year = start_year if start_year in years else None + selected_end_year = end_year if end_year in years else None + if not selected_start_year and not selected_end_year and years: + selected_start_year = years[0] + selected_end_year = years[-1] + elif selected_start_year and selected_end_year and selected_start_year > selected_end_year: + selected_end_year = selected_start_year + elif selected_end_year and not selected_start_year: + selected_start_year = years[0] if years else None + if selected_start_year and selected_start_year > selected_end_year: + selected_start_year = selected_end_year + elif selected_start_year and not selected_end_year: + selected_end_year = years[-1] if years else None + if selected_end_year and selected_end_year < selected_start_year: + selected_end_year = selected_start_year + + project_options = get_process_cost_project_options( + normalized_source, + selected_start_year, + selected_end_year, + include_related=include_related, + ) + selected_code = normalize_text(code) + if selected_code and not any(item["support_dept_code"] == selected_code for item in project_options): + selected_code = "" + selected_project = next( + (item for item in project_options if normalize_text(item.get("support_dept_code")) == selected_code), + None, + ) + related_codes = get_process_cost_related_codes(selected_code) + active_related_codes: list[str] = [] + if include_related and selected_code: + normalized_active_related = normalize_text(active_related) + if normalized_active_related in {"-", "__none__"}: + active_related_codes = [] + else: + requested_active_codes = [ + normalize_text(value) + for value in normalized_active_related.split(",") + if normalize_text(value) + ] + if requested_active_codes: + active_related_codes = [ + value for value in requested_active_codes + if value != selected_code and value in related_codes + ] + else: + active_related_codes = list(related_codes) + + detail = get_process_cost_project_detail( + normalized_source, + None, + selected_code, + include_related=include_related, + active_related_codes=active_related_codes, + ) + return { + "projects": project_options, + "selectedCode": selected_code, + "selectedProject": selected_project or {}, + "relatedCodes": related_codes, + "activeRelatedCodes": active_related_codes, + "quickLinkCodes": get_process_cost_quick_links(), + "source": normalized_source, + "selectedStartYear": selected_start_year, + "selectedEndYear": selected_end_year, + "includeRelatedEnabled": include_related, + "monthlyRows": (detail.get("monthly_rows") or []), + } + + def get_financial_series(granularity: str) -> list[dict[str, Any]]: group_fields = "year" if granularity == "yearly" else "year, month" order_fields = "year" if granularity == "yearly" else "year, month" @@ -8567,10 +8804,6 @@ def render_home( "overview": get_overview_stats(overview_year), "overview_selected_year": overview_year, "project_dashboard": get_project_dashboard_summary(overview_year), - "project_revenue_mix_yearly": get_project_revenue_mix(), - "project_revenue_mix_monthly": get_project_revenue_mix_monthly(), - "yearly_summary": get_yearly_summary(), - "monthly_summary": get_monthly_summary(), "available_years": available_years, "dashboard_revenue_metric_options": get_option_items("dashboard_revenue_metrics"), "dashboard_expense_metric_options": get_option_items("dashboard_expense_metrics"), @@ -8578,6 +8811,30 @@ def render_home( return templates.TemplateResponse(request, "dashboard.html", context) +def get_dashboard_bootstrap_payload(overview_year: int | None = None) -> dict[str, Any]: + cache_key = (overview_year or 0,) + now = time.time() + with _DASHBOARD_BOOTSTRAP_CACHE_LOCK: + cached = _DASHBOARD_BOOTSTRAP_CACHE.get(cache_key) + if cached and now - float(cached.get("stored_at") or 0.0) <= DASHBOARD_BOOTSTRAP_CACHE_TTL_SECONDS: + return copy.deepcopy(cached.get("payload") or {}) + + payload = { + "yearly_summary": get_yearly_summary(), + "monthly_summary": get_monthly_summary(), + "project_revenue_mix_yearly": get_project_revenue_mix(), + "project_revenue_mix_monthly": get_project_revenue_mix_monthly(), + "overview_selected_year": overview_year, + } + + with _DASHBOARD_BOOTSTRAP_CACHE_LOCK: + _DASHBOARD_BOOTSTRAP_CACHE[cache_key] = { + "stored_at": now, + "payload": copy.deepcopy(payload), + } + return payload + + def render_projects_page( request: Request, edit_code: str | None = None, @@ -8592,11 +8849,7 @@ def render_projects_page( "project_year_options": get_project_year_options(), "selected_year": selected_year, "project_dashboard": get_project_dashboard_summary(selected_year), - "project_revenue_mix": get_project_revenue_mix(selected_year), - "project_cost_by_year": get_project_cost_by_year(selected_year), "project_monthly_cost_rows": get_business_monthly_summary(), - "project_account_breakdowns": get_project_account_breakdowns(selected_year), - "project_status_rows": get_project_status_rows(), "project_comparison_notes": get_project_comparison_notes_map(), "project_analysis_settings": get_project_analysis_settings_map(), "project_edit": get_project_status_for_edit(edit_code), @@ -8624,8 +8877,6 @@ def render_annual_summary_page(request: Request, message: str = "") -> HTMLRespo context = { **base_context(request, message), "available_years": get_available_years(), - "yearly_financial_series": get_financial_series("yearly"), - "monthly_financial_series": get_financial_series("monthly"), "annual_metric_cards": get_option_items("annual_metric_cards"), "annual_expense_chart_metrics": get_option_items("annual_expense_chart_metrics"), "annual_balance_chart_metrics": get_option_items("annual_balance_chart_metrics"), @@ -8633,6 +8884,25 @@ def render_annual_summary_page(request: Request, message: str = "") -> HTMLRespo return templates.TemplateResponse(request, "annual_summary.html", context) +def get_annual_summary_bootstrap_payload() -> dict[str, Any]: + now = time.time() + with _ANNUAL_SUMMARY_BOOTSTRAP_CACHE_LOCK: + cached_payload = _ANNUAL_SUMMARY_BOOTSTRAP_CACHE.get("payload") + cached_at = float(_ANNUAL_SUMMARY_BOOTSTRAP_CACHE.get("stored_at") or 0.0) + if cached_payload and now - cached_at <= ANNUAL_SUMMARY_BOOTSTRAP_CACHE_TTL_SECONDS: + return copy.deepcopy(cached_payload) + + payload = { + "yearly_financial_series": get_financial_series("yearly"), + "monthly_financial_series": get_financial_series("monthly"), + } + + with _ANNUAL_SUMMARY_BOOTSTRAP_CACHE_LOCK: + _ANNUAL_SUMMARY_BOOTSTRAP_CACHE["stored_at"] = now + _ANNUAL_SUMMARY_BOOTSTRAP_CACHE["payload"] = copy.deepcopy(payload) + return payload + + def render_wehago_compare_page( request: Request, start_year: int | None = None, @@ -8793,6 +9063,150 @@ def get_hanmac_table_list(payload: dict[str, Any]) -> dict[str, Any]: test_engine.dispose() +def _hanmac_decode_preview_cursor(cursor: Any) -> int: + normalized = normalize_text(cursor) + if not normalized: + return 0 + try: + offset = int(normalized) + except ValueError as exc: + raise ValueError("미리보기 커서 형식이 올바르지 않습니다.") from exc + return max(0, offset) + + +def _hanmac_build_preview_order_by(column_rows: list[dict[str, Any]]) -> str: + primary_columns = [str(row.get("Field") or "") for row in column_rows if str(row.get("Key") or "").upper() == "PRI"] + if primary_columns: + order_columns = [column for column in primary_columns if column] + elif column_rows: + order_columns = [str(column_rows[0].get("Field") or "")] + else: + order_columns = [] + if not order_columns: + return "" + return ", ".join(f"`{column}`" for column in order_columns) + + +def _hanmac_preview_cache_key(payload: dict[str, Any]) -> str: + normalized = { + "host": normalize_text(payload.get("host")), + "port": normalize_text(payload.get("port")), + "user": normalize_text(payload.get("user")), + "schema": _validate_hanmac_schema_name(payload.get("schema")), + "table": _validate_hanmac_table_name(payload.get("table")), + "limit": int(payload.get("limit") or 100), + "cursor": normalize_text(payload.get("cursor")), + } + return hashlib.sha1(json.dumps(normalized, ensure_ascii=False, sort_keys=True).encode("utf-8")).hexdigest() + + +def _load_hanmac_preview_cache(cache_key: str) -> tuple[dict[str, Any] | None, bool]: + if not cache_key: + return None, False + init_db() + with engine.begin() as conn: + row = conn.execute( + text( + """ + SELECT payload_json, updated_at + FROM hanmac_preview_query_cache + WHERE cache_key = :cache_key + LIMIT 1 + """ + ), + {"cache_key": cache_key}, + ).first() + if not row: + return None, False + try: + payload = json.loads(str(row[0] or "{}")) + except Exception: + payload = {} + cached_at = None + try: + cached_at = datetime.fromisoformat(str(row[1])) + except Exception: + cached_at = None + fresh = False + if cached_at is not None: + fresh = (datetime.now() - cached_at).total_seconds() <= _HANMAC_PREVIEW_CACHE_TTL_SEC + if isinstance(payload, dict): + payload["cache_meta"] = { + "cached_at": str(row[1] or ""), + "fresh": fresh, + } + return payload, fresh + return None, fresh + + +def _store_hanmac_preview_cache(cache_key: str, payload: dict[str, Any]) -> None: + if not cache_key or not isinstance(payload, dict): + return + init_db() + with engine.begin() as conn: + conn.execute( + text( + """ + INSERT INTO hanmac_preview_query_cache ( + cache_key, payload_signature, payload_json, updated_at + ) VALUES ( + :cache_key, :payload_signature, :payload_json, CURRENT_TIMESTAMP + ) + ON CONFLICT(cache_key) DO UPDATE SET + payload_signature = excluded.payload_signature, + payload_json = excluded.payload_json, + updated_at = CURRENT_TIMESTAMP + """ + ), + { + "cache_key": cache_key, + "payload_signature": cache_key, + "payload_json": json.dumps(payload, ensure_ascii=False), + }, + ) + + +def _refresh_hanmac_preview_cache_background(payload: dict[str, Any], cache_key: str) -> None: + try: + result = get_hanmac_table_preview(payload) + _store_hanmac_preview_cache(cache_key, result) + except Exception: + logger.exception("hanmac preview cache background refresh failed") + finally: + with _HANMAC_PREVIEW_REFRESHING_LOCK: + _HANMAC_PREVIEW_REFRESHING.discard(cache_key) + + +def get_hanmac_table_preview_cached(payload: dict[str, Any]) -> dict[str, Any]: + cache_key = _hanmac_preview_cache_key(payload) + cached_payload, fresh = _load_hanmac_preview_cache(cache_key) + if cached_payload and fresh: + cached_payload.setdefault("cache_meta", {}) + cached_payload["cache_meta"]["pending_refresh"] = False + return cached_payload + if cached_payload and not fresh: + with _HANMAC_PREVIEW_REFRESHING_LOCK: + should_start = cache_key not in _HANMAC_PREVIEW_REFRESHING + if should_start: + _HANMAC_PREVIEW_REFRESHING.add(cache_key) + if should_start: + worker = threading.Thread( + target=_refresh_hanmac_preview_cache_background, + args=(dict(payload), cache_key), + daemon=True, + name=f"hanmac-preview-refresh-{cache_key[:8]}", + ) + worker.start() + cached_payload.setdefault("cache_meta", {}) + cached_payload["cache_meta"]["pending_refresh"] = True + return cached_payload + result = get_hanmac_table_preview(payload) + _store_hanmac_preview_cache(cache_key, result) + result.setdefault("cache_meta", {}) + result["cache_meta"]["pending_refresh"] = False + return result + + 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")) @@ -8801,7 +9215,8 @@ def get_hanmac_table_preview(payload: dict[str, Any]) -> dict[str, Any]: limit = int(limit_raw or 100) except ValueError as exc: raise ValueError("조회 건수는 숫자여야 합니다.") from exc - safe_limit = max(1, min(limit, 300)) + safe_limit = max(1, min(limit, 200)) + offset = _hanmac_decode_preview_cursor(payload.get("cursor")) connect_payload = dict(payload) connect_payload["database"] = schema_name @@ -8826,7 +9241,14 @@ def get_hanmac_table_preview(payload: dict[str, Any]) -> dict[str, Any]: 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() + order_by_clause = _hanmac_build_preview_order_by(column_rows) + query_sql = f"SELECT * FROM `{schema_name}`.`{table_name}`" + if order_by_clause: + query_sql += f" ORDER BY {order_by_clause}" + query_sql += f" LIMIT {safe_limit + 1} OFFSET {offset}" + data_rows = connection.execute(text(query_sql)).mappings().all() + has_more = len(data_rows) > safe_limit + data_rows = data_rows[:safe_limit] rows = [ { column: ( @@ -8839,6 +9261,7 @@ def get_hanmac_table_preview(payload: dict[str, Any]) -> dict[str, Any]: } for row in data_rows ] + next_cursor = str(offset + safe_limit) if has_more else "" return { "status": "ok", "schema": schema_name, @@ -8847,6 +9270,9 @@ def get_hanmac_table_preview(payload: dict[str, Any]) -> dict[str, Any]: "rows": rows, "shown_count": len(rows), "limit": safe_limit, + "cursor": str(offset), + "next_cursor": next_cursor, + "has_more": bool(next_cursor), } finally: test_engine.dispose() @@ -8861,6 +9287,69 @@ def _hanmac_find_column(columns: list[str], candidates: list[str]) -> str | None return None +def _hanmac_build_project_code_relation_maps( + project_code_alias_groups: list[dict[str, Any]], +) -> dict[str, Any]: + canonical_map: dict[str, str] = {} + equivalent_code_map: dict[str, set[str]] = {} + relation_groups: dict[str, list[str]] = {} + base_codes = { + normalize_text(group.get("project_code")) + for group in project_code_alias_groups + if normalize_text(group.get("project_code")) + } + new_codes = { + normalize_text(group.get("new_project_code")) + for group in project_code_alias_groups + if normalize_text(group.get("new_project_code")) + } + adjacency: dict[str, set[str]] = {} + for group in project_code_alias_groups: + project_code = normalize_text(group.get("project_code")) + if not project_code: + continue + equivalent_codes = { + normalize_text(group.get("project_view_code")), + normalize_text(group.get("old_project_code")), + normalize_text(group.get("new_project_code")), + } + equivalent_codes = {code for code in equivalent_codes if code and code != project_code} + adjacency.setdefault(project_code, set()) + for code in equivalent_codes: + adjacency.setdefault(project_code, set()).add(code) + adjacency.setdefault(code, set()).add(project_code) + + visited_codes: set[str] = set() + for start_code in sorted(adjacency): + if start_code in visited_codes: + continue + stack = [start_code] + cluster: set[str] = set() + while stack: + current_code = stack.pop() + if current_code in cluster: + continue + cluster.add(current_code) + stack.extend(sorted(adjacency.get(current_code, set()) - cluster)) + visited_codes.update(cluster) + representative_candidates = sorted((cluster & base_codes) - new_codes) + if not representative_candidates: + representative_candidates = sorted(cluster & base_codes) + representative = representative_candidates[0] if representative_candidates else sorted(cluster)[0] + for code in cluster: + canonical_map[code] = representative + related_codes = sorted(code for code in cluster if code != representative) + if related_codes: + equivalent_code_map[representative] = set(related_codes) + relation_groups[representative] = related_codes + + return { + "canonical_map": canonical_map, + "equivalent_code_map": equivalent_code_map, + "relation_groups": relation_groups, + } + + def _hanmac_parse_date_value(value: Any) -> date | None: if value is None: return None @@ -8912,6 +9401,15 @@ def _hanmac_parse_float_value(value: Any) -> float: return 0.0 +def _hanmac_iter_dates(start_value: date, end_value: date) -> list[date]: + if end_value < start_value: + start_value, end_value = end_value, start_value + return [ + start_value + timedelta(days=offset) + for offset in range((end_value - start_value).days + 1) + ] + + def _hanmac_parse_duration_hours(value: Any) -> float: if value is None: return 0.0 @@ -9018,7 +9516,750 @@ def _hanmac_member_is_active_on(member_record: dict[str, Any], work_date: date | return True +def _hanmac_aggregate_cache_key(payload: dict[str, Any]) -> str: + normalized = { + "host": normalize_text(payload.get("host")), + "port": normalize_text(payload.get("port")), + "user": normalize_text(payload.get("user")), + "database": normalize_text(payload.get("database") or "hanmac_manhour"), + "view": normalize_text(payload.get("view") or "member"), + "employment": normalize_text(payload.get("employment") or "all"), + "start_date": normalize_text(payload.get("start_date")), + "end_date": normalize_text(payload.get("end_date")), + } + return hashlib.sha1(json.dumps(normalized, ensure_ascii=False, sort_keys=True).encode("utf-8")).hexdigest() + + +def _load_hanmac_aggregate_cache(cache_key: str) -> tuple[dict[str, Any] | None, bool]: + if not cache_key: + return None, False + init_db() + with engine.begin() as conn: + row = conn.execute( + text( + """ + SELECT payload_json, updated_at + FROM hanmac_aggregate_query_cache + WHERE cache_key = :cache_key + LIMIT 1 + """ + ), + {"cache_key": cache_key}, + ).first() + if not row: + return None, False + payload_json = str(row[0] or "{}") + updated_at = row[1] + try: + payload = json.loads(payload_json) + except Exception: + payload = {} + cached_at = None + try: + cached_at = datetime.fromisoformat(str(updated_at)) + except Exception: + cached_at = None + fresh = False + if cached_at is not None: + fresh = (datetime.now() - cached_at).total_seconds() <= _HANMAC_AGGREGATE_CACHE_TTL_SEC + if isinstance(payload, dict): + payload["cache_meta"] = { + "cached_at": str(updated_at or ""), + "fresh": fresh, + } + return payload, fresh + return None, fresh + + +def _load_hanmac_aggregate_projection(cache_key: str) -> tuple[dict[str, Any] | None, bool]: + if not cache_key: + return None, False + init_db() + with engine.begin() as conn: + metric_row = conn.execute( + text( + """ + SELECT view_mode, employment_filter, start_date, end_date, + summary_json, columns_json, row_count, updated_at + FROM hanmac_aggregate_query_metrics + WHERE cache_key = :cache_key + LIMIT 1 + """ + ), + {"cache_key": cache_key}, + ).mappings().first() + if not metric_row: + return None, False + row_items = conn.execute( + text( + """ + SELECT row_json + FROM hanmac_aggregate_query_rows + WHERE cache_key = :cache_key + ORDER BY row_index + """ + ), + {"cache_key": cache_key}, + ).scalars().all() + try: + summary = json.loads(str(metric_row.get("summary_json") or "{}")) + except Exception: + summary = {} + try: + columns = json.loads(str(metric_row.get("columns_json") or "[]")) + except Exception: + columns = [] + rows: list[dict[str, Any]] = [] + for item in row_items: + try: + parsed = json.loads(str(item or "{}")) + except Exception: + parsed = {} + if isinstance(parsed, dict): + rows.append(parsed) + updated_at = metric_row.get("updated_at") + cached_at = None + try: + cached_at = datetime.fromisoformat(str(updated_at)) + except Exception: + cached_at = None + fresh = False + if cached_at is not None: + fresh = (datetime.now() - cached_at).total_seconds() <= _HANMAC_AGGREGATE_CACHE_TTL_SEC + payload = { + "status": "ok", + "view": str(metric_row.get("view_mode") or ""), + "employment": str(metric_row.get("employment_filter") or ""), + "start_date": str(metric_row.get("start_date") or ""), + "end_date": str(metric_row.get("end_date") or ""), + "summary": summary if isinstance(summary, dict) else {}, + "columns": columns if isinstance(columns, list) else [], + "rows": rows, + "cache_meta": { + "cached_at": str(updated_at or ""), + "fresh": fresh, + "source": "projection", + }, + } + return payload, fresh + + +def _store_hanmac_aggregate_cache(cache_key: str, payload: dict[str, Any]) -> None: + if not cache_key or not isinstance(payload, dict): + return + init_db() + with engine.begin() as conn: + conn.execute( + text( + """ + INSERT INTO hanmac_aggregate_query_cache ( + cache_key, payload_signature, payload_json, updated_at + ) VALUES ( + :cache_key, :payload_signature, :payload_json, CURRENT_TIMESTAMP + ) + ON CONFLICT(cache_key) DO UPDATE SET + payload_signature = excluded.payload_signature, + payload_json = excluded.payload_json, + updated_at = CURRENT_TIMESTAMP + """ + ), + { + "cache_key": cache_key, + "payload_signature": cache_key, + "payload_json": json.dumps(payload, ensure_ascii=False), + }, + ) + conn.execute( + text( + """ + INSERT INTO hanmac_aggregate_query_metrics ( + cache_key, payload_signature, view_mode, employment_filter, start_date, end_date, + summary_json, columns_json, row_count, updated_at + ) VALUES ( + :cache_key, :payload_signature, :view_mode, :employment_filter, :start_date, :end_date, + :summary_json, :columns_json, :row_count, CURRENT_TIMESTAMP + ) + ON CONFLICT(cache_key) DO UPDATE SET + payload_signature = excluded.payload_signature, + view_mode = excluded.view_mode, + employment_filter = excluded.employment_filter, + start_date = excluded.start_date, + end_date = excluded.end_date, + summary_json = excluded.summary_json, + columns_json = excluded.columns_json, + row_count = excluded.row_count, + updated_at = CURRENT_TIMESTAMP + """ + ), + { + "cache_key": cache_key, + "payload_signature": cache_key, + "view_mode": normalize_text(payload.get("view")), + "employment_filter": normalize_text(payload.get("employment")), + "start_date": str(payload.get("start_date") or ""), + "end_date": str(payload.get("end_date") or ""), + "summary_json": json.dumps(payload.get("summary") or {}, ensure_ascii=False), + "columns_json": json.dumps(payload.get("columns") or [], ensure_ascii=False), + "row_count": len(list(payload.get("rows") or [])), + }, + ) + conn.execute( + text( + """ + DELETE FROM hanmac_aggregate_query_rows + WHERE cache_key = :cache_key + """ + ), + {"cache_key": cache_key}, + ) + rows = list(payload.get("rows") or []) + for row_index, row in enumerate(rows): + conn.execute( + text( + """ + INSERT INTO hanmac_aggregate_query_rows ( + cache_key, row_index, row_json, updated_at + ) VALUES ( + :cache_key, :row_index, :row_json, CURRENT_TIMESTAMP + ) + """ + ), + { + "cache_key": cache_key, + "row_index": row_index, + "row_json": json.dumps(row or {}, ensure_ascii=False), + }, + ) + + +def _refresh_hanmac_aggregate_cache_background(payload: dict[str, Any], cache_key: str) -> None: + try: + result = get_hanmac_aggregate_summary(payload) + _store_hanmac_aggregate_cache(cache_key, result) + except Exception: + logger.exception("hanmac aggregate cache background refresh failed") + finally: + with _HANMAC_AGGREGATE_REFRESHING_LOCK: + _HANMAC_AGGREGATE_REFRESHING.discard(cache_key) + + +def get_hanmac_aggregate_summary_cached(payload: dict[str, Any]) -> dict[str, Any]: + cache_key = _hanmac_aggregate_cache_key(payload) + projection_payload, projection_fresh = _load_hanmac_aggregate_projection(cache_key) + if projection_payload and projection_fresh: + projection_payload.setdefault("cache_meta", {}) + projection_payload["cache_meta"]["pending_refresh"] = False + return projection_payload + if projection_payload and not projection_fresh: + with _HANMAC_AGGREGATE_REFRESHING_LOCK: + should_start = cache_key not in _HANMAC_AGGREGATE_REFRESHING + if should_start: + _HANMAC_AGGREGATE_REFRESHING.add(cache_key) + if should_start: + worker = threading.Thread( + target=_refresh_hanmac_aggregate_cache_background, + args=(dict(payload), cache_key), + daemon=True, + name=f"hanmac-aggregate-refresh-{cache_key[:8]}", + ) + worker.start() + projection_payload.setdefault("cache_meta", {}) + projection_payload["cache_meta"]["pending_refresh"] = True + return projection_payload + cached_payload, fresh = _load_hanmac_aggregate_cache(cache_key) + if cached_payload and fresh: + cached_payload.setdefault("cache_meta", {}) + cached_payload["cache_meta"]["pending_refresh"] = False + return cached_payload + if cached_payload and not fresh: + with _HANMAC_AGGREGATE_REFRESHING_LOCK: + should_start = cache_key not in _HANMAC_AGGREGATE_REFRESHING + if should_start: + _HANMAC_AGGREGATE_REFRESHING.add(cache_key) + if should_start: + worker = threading.Thread( + target=_refresh_hanmac_aggregate_cache_background, + args=(dict(payload), cache_key), + daemon=True, + name=f"hanmac-aggregate-refresh-{cache_key[:8]}", + ) + worker.start() + cached_payload.setdefault("cache_meta", {}) + cached_payload["cache_meta"]["pending_refresh"] = True + return cached_payload + result = get_hanmac_aggregate_summary(payload) + _store_hanmac_aggregate_cache(cache_key, result) + result.setdefault("cache_meta", {}) + result["cache_meta"]["pending_refresh"] = False + return result + + +def _hanmac_csv_escape(value: Any) -> str: + text_value = "" if value is None else str(value) + return '"' + text_value.replace('"', '""') + '"' + + +def _hanmac_export_payload_signature(payload: dict[str, Any]) -> str: + normalized = { + "schema": _validate_hanmac_schema_name(payload.get("schema")), + "table": _validate_hanmac_table_name(payload.get("table")), + "limit": int(payload.get("limit") or 100), + "cursor": normalize_text(payload.get("cursor")), + "value_column": normalize_text(payload.get("value_column")), + "value_search": normalize_text(payload.get("value_search")), + } + return hashlib.sha1(json.dumps(normalized, ensure_ascii=False, sort_keys=True).encode("utf-8")).hexdigest() + + +def _hanmac_aggregate_export_payload_signature(payload: dict[str, Any]) -> str: + normalized = { + "host": normalize_text(payload.get("host")), + "port": normalize_text(payload.get("port")), + "user": normalize_text(payload.get("user")), + "database": normalize_text(payload.get("database") or "hanmac_manhour"), + "view": normalize_text(payload.get("view") or "member"), + "employment": normalize_text(payload.get("employment") or "all"), + "start_date": normalize_text(payload.get("start_date")), + "end_date": normalize_text(payload.get("end_date")), + "value_column": normalize_text(payload.get("value_column")), + "value_search": normalize_text(payload.get("value_search")), + "sort_key": normalize_text(payload.get("sort_key")), + "sort_direction": normalize_text(payload.get("sort_direction") or "desc"), + } + return hashlib.sha1(json.dumps(normalized, ensure_ascii=False, sort_keys=True).encode("utf-8")).hexdigest() + + +def _claim_next_hanmac_export_job() -> dict[str, Any] | None: + init_db() + with engine.begin() as conn: + row = conn.execute( + text( + """ + SELECT job_key, job_type, payload_json + FROM hanmac_export_jobs + WHERE state = 'queued' + ORDER BY created_at ASC + LIMIT 1 + """ + ) + ).mappings().first() + if not row: + return None + conn.execute( + text( + """ + UPDATE hanmac_export_jobs + SET state = 'running', + error_message = '', + updated_at = CURRENT_TIMESTAMP + WHERE job_key = :job_key + """ + ), + {"job_key": row["job_key"]}, + ) + try: + payload = json.loads(str(row.get("payload_json") or "{}")) + except Exception: + payload = {} + return { + "job_key": str(row.get("job_key") or ""), + "job_type": str(row.get("job_type") or ""), + "payload": payload, + } + + +def _update_hanmac_export_job(job_key: str, **fields: Any) -> None: + if not job_key: + return + init_db() + assignments = [] + params: dict[str, Any] = {"job_key": job_key} + for key, value in fields.items(): + assignments.append(f"{key} = :{key}") + params[key] = value + assignments.append("updated_at = CURRENT_TIMESTAMP") + with engine.begin() as conn: + conn.execute( + text(f"UPDATE hanmac_export_jobs SET {', '.join(assignments)} WHERE job_key = :job_key"), + params, + ) + + +def _build_hanmac_preview_export_csv(payload: dict[str, Any]) -> tuple[str, bytes, int]: + preview_payload = { + **payload, + "schema": payload.get("schema"), + "table": payload.get("table"), + "limit": payload.get("limit"), + "cursor": payload.get("cursor"), + } + preview_result = get_hanmac_table_preview(preview_payload) + columns = list(preview_result.get("columns") or []) + rows = list(preview_result.get("rows") or []) + value_column = normalize_text(payload.get("value_column")) + value_search = normalize_text(payload.get("value_search")).lower() + if value_search: + target_columns = [value_column] if value_column and value_column in columns else columns + rows = [ + row for row in rows + if any(value_search in str((row or {}).get(column) or "").lower() for column in target_columns) + ] + header = ",".join(_hanmac_csv_escape(column) for column in columns) + body = [ + ",".join(_hanmac_csv_escape((row or {}).get(column)) for column in columns) + for row in rows + ] + content = ("\uFEFF" + header + ("\r\n" + "\r\n".join(body) if body else "")).encode("utf-8") + today = datetime.now().strftime("%Y-%m-%d") + file_name = f"{preview_result.get('schema') or 'hanmac'}_{preview_result.get('table') or 'preview'}_{today}.csv" + return file_name, content, len(rows) + + +def _build_hanmac_aggregate_export_csv(payload: dict[str, Any]) -> tuple[str, bytes, int]: + aggregate_payload = { + **payload, + "view": payload.get("view") or "member", + "employment": payload.get("employment") or "all", + "start_date": payload.get("start_date"), + "end_date": payload.get("end_date"), + } + aggregate_result = get_hanmac_aggregate_summary_cached(aggregate_payload) + columns = list(aggregate_result.get("columns") or []) + rows = list(aggregate_result.get("rows") or []) + value_column = normalize_text(payload.get("value_column")) + value_search = normalize_text(payload.get("value_search")).lower() + sort_key = normalize_text(payload.get("sort_key")) + sort_direction = normalize_text(payload.get("sort_direction") or "desc") + if value_search: + target_keys = [value_column] if value_column else [str(column.get("key") or "") for column in columns] + rows = [ + row for row in rows + if any(value_search in str((row or {}).get(key) or "").lower() for key in target_keys if key) + ] + if sort_key: + direction = 1 if sort_direction == "asc" else -1 + def _sort_value(item: dict[str, Any]) -> Any: + value = item.get(sort_key) + number = None + try: + number = float(value) + except Exception: + number = None + if number is not None: + return (0, number) + return (1, str(value or "").lower()) + rows = sorted(rows, key=_sort_value, reverse=(direction == -1)) + export_columns = [str(column.get("key") or "") for column in columns if str(column.get("key") or "").strip()] + export_labels = [str(column.get("label") or column.get("key") or "") for column in columns if str(column.get("key") or "").strip()] + header = ",".join(_hanmac_csv_escape(label) for label in export_labels) + body = [ + ",".join(_hanmac_csv_escape((row or {}).get(column_key)) for column_key in export_columns) + for row in rows + ] + content = ("\uFEFF" + header + ("\r\n" + "\r\n".join(body) if body else "")).encode("utf-8") + today = datetime.now().strftime("%Y-%m-%d") + view_name = normalize_text(aggregate_result.get("view") or payload.get("view") or "member") or "member" + file_name = f"hanmac_{view_name}_aggregate_{today}.csv" + return file_name, content, len(rows) + + +def _run_hanmac_export_job(job: dict[str, Any]) -> None: + job_key = str(job.get("job_key") or "") + job_type = str(job.get("job_type") or "") + payload = dict(job.get("payload") or {}) + try: + if job_type == "aggregate_export_csv": + file_name, content, row_count = _build_hanmac_aggregate_export_csv(payload) + else: + file_name, content, row_count = _build_hanmac_preview_export_csv(payload) + HANMAC_EXPORT_DIR.mkdir(parents=True, exist_ok=True) + file_path = HANMAC_EXPORT_DIR / f"{job_key}.csv" + file_path.write_bytes(content) + _update_hanmac_export_job( + job_key, + state="ready", + file_path=str(file_path), + file_name=file_name, + row_count=row_count, + error_message="", + ) + except Exception as exc: + logger.exception("hanmac export job failed: %s", exc) + _update_hanmac_export_job( + job_key, + state="failed", + error_message=str(exc), + ) + + +def _hanmac_export_worker_loop() -> None: + while True: + job = _claim_next_hanmac_export_job() + if not job: + _HANMAC_EXPORT_JOB_EVENT.wait(2.0) + _HANMAC_EXPORT_JOB_EVENT.clear() + continue + _run_hanmac_export_job(job) + + +def _ensure_hanmac_export_worker() -> None: + global _HANMAC_EXPORT_WORKER_STARTED, _HANMAC_EXPORT_WORKER_THREAD + with _HANMAC_EXPORT_WORKER_LOCK: + if _HANMAC_EXPORT_WORKER_STARTED and _HANMAC_EXPORT_WORKER_THREAD and _HANMAC_EXPORT_WORKER_THREAD.is_alive(): + return + worker = threading.Thread( + target=_hanmac_export_worker_loop, + daemon=True, + name="hanmac-export-worker", + ) + worker.start() + _HANMAC_EXPORT_WORKER_THREAD = worker + _HANMAC_EXPORT_WORKER_STARTED = True + + +def request_hanmac_preview_export_job(payload: dict[str, Any]) -> dict[str, Any]: + job_key = f"hanmac-preview-export:{_hanmac_export_payload_signature(payload)}" + init_db() + with engine.begin() as conn: + row = conn.execute( + text( + """ + SELECT job_key, state, file_path, file_name, row_count, error_message + FROM hanmac_export_jobs + WHERE job_key = :job_key + LIMIT 1 + """ + ), + {"job_key": job_key}, + ).mappings().first() + if row: + state = str(row.get("state") or "") + file_path = str(row.get("file_path") or "") + if state == "ready" and file_path and Path(file_path).exists(): + return { + "job_key": job_key, + "state": "ready", + "download_url": f"/hanmac-browser/api/preview-export-download/{quote_plus(job_key)}", + "row_count": int(row.get("row_count") or 0), + "file_name": str(row.get("file_name") or ""), + } + if state in {"queued", "running"}: + _ensure_hanmac_export_worker() + _HANMAC_EXPORT_JOB_EVENT.set() + return {"job_key": job_key, "state": state, "download_url": "", "row_count": 0, "file_name": ""} + conn.execute( + text( + """ + INSERT OR REPLACE INTO hanmac_export_jobs ( + job_key, job_type, payload_json, state, file_path, file_name, row_count, error_message, created_at, updated_at + ) VALUES ( + :job_key, 'preview_export_csv', :payload_json, 'queued', '', '', 0, '', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP + ) + """ + ), + {"job_key": job_key, "payload_json": json.dumps(payload, ensure_ascii=False)}, + ) + _ensure_hanmac_export_worker() + _HANMAC_EXPORT_JOB_EVENT.set() + return {"job_key": job_key, "state": "queued", "download_url": "", "row_count": 0, "file_name": ""} + + +def request_hanmac_aggregate_export_job(payload: dict[str, Any]) -> dict[str, Any]: + job_key = f"hanmac-aggregate-export:{_hanmac_aggregate_export_payload_signature(payload)}" + init_db() + with engine.begin() as conn: + row = conn.execute( + text( + """ + SELECT job_key, state, file_path, file_name, row_count, error_message + FROM hanmac_export_jobs + WHERE job_key = :job_key + LIMIT 1 + """ + ), + {"job_key": job_key}, + ).mappings().first() + if row: + state = str(row.get("state") or "") + file_path = str(row.get("file_path") or "") + if state == "ready" and file_path and Path(file_path).exists(): + return { + "job_key": job_key, + "state": "ready", + "download_url": f"/hanmac-browser/api/preview-export-download/{quote_plus(job_key)}", + "row_count": int(row.get("row_count") or 0), + "file_name": str(row.get("file_name") or ""), + } + if state in {"queued", "running"}: + _ensure_hanmac_export_worker() + _HANMAC_EXPORT_JOB_EVENT.set() + return {"job_key": job_key, "state": state, "download_url": "", "row_count": 0, "file_name": ""} + conn.execute( + text( + """ + INSERT OR REPLACE INTO hanmac_export_jobs ( + job_key, job_type, payload_json, state, file_path, file_name, row_count, error_message, created_at, updated_at + ) VALUES ( + :job_key, 'aggregate_export_csv', :payload_json, 'queued', '', '', 0, '', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP + ) + """ + ), + {"job_key": job_key, "payload_json": json.dumps(payload, ensure_ascii=False)}, + ) + _ensure_hanmac_export_worker() + _HANMAC_EXPORT_JOB_EVENT.set() + return {"job_key": job_key, "state": "queued", "download_url": "", "row_count": 0, "file_name": ""} + + +def get_hanmac_export_job(job_key: str) -> dict[str, Any]: + init_db() + with engine.begin() as conn: + row = conn.execute( + text( + """ + SELECT job_key, state, file_path, file_name, row_count, error_message, updated_at + FROM hanmac_export_jobs + WHERE job_key = :job_key + LIMIT 1 + """ + ), + {"job_key": job_key}, + ).mappings().first() + if not row: + return {"job_key": job_key, "state": "missing", "download_url": "", "row_count": 0, "file_name": "", "error_message": "작업을 찾을 수 없습니다."} + file_path = str(row.get("file_path") or "") + ready = str(row.get("state") or "") == "ready" and file_path and Path(file_path).exists() + return { + "job_key": str(row.get("job_key") or job_key), + "state": str(row.get("state") or ""), + "download_url": f"/hanmac-browser/api/preview-export-download/{quote_plus(job_key)}" if ready else "", + "row_count": int(row.get("row_count") or 0), + "file_name": str(row.get("file_name") or ""), + "file_path": file_path, + "error_message": str(row.get("error_message") or ""), + "updated_at": str(row.get("updated_at") or ""), + } + + +def _cleanup_old_hanmac_export_files(keep_paths: set[str] | None = None, older_than_sec: int = EXPORT_RETENTION_SECONDS) -> None: + keep = {str(Path(item)) for item in (keep_paths or set())} + if not HANMAC_EXPORT_DIR.exists(): + return + threshold = time.time() - max(int(older_than_sec), 60) + for path in HANMAC_EXPORT_DIR.glob("*.csv"): + try: + if str(path) in keep: + continue + if path.stat().st_mtime >= threshold: + continue + path.unlink(missing_ok=True) + except Exception: + continue + + +def _cleanup_hanmac_runtime_artifacts() -> None: + init_db() + export_threshold = datetime.now() - timedelta(seconds=EXPORT_RETENTION_SECONDS) + cache_threshold = datetime.now() - timedelta(seconds=QUERY_CACHE_RETENTION_SECONDS) + keep_paths: set[str] = set() + with engine.begin() as conn: + ready_paths = conn.execute( + text( + """ + SELECT file_path + FROM hanmac_export_jobs + WHERE state = 'ready' + AND COALESCE(file_path, '') <> '' + AND updated_at >= :export_threshold + """ + ), + {"export_threshold": export_threshold.strftime("%Y-%m-%d %H:%M:%S")}, + ).scalars().all() + keep_paths = {str(Path(path)) for path in ready_paths if str(path or "").strip()} + conn.execute( + text( + """ + DELETE FROM hanmac_export_jobs + WHERE state IN ('ready', 'failed') + AND updated_at < :export_threshold + """ + ), + {"export_threshold": export_threshold.strftime("%Y-%m-%d %H:%M:%S")}, + ) + conn.execute( + text( + """ + DELETE FROM hanmac_aggregate_query_cache + WHERE updated_at < :cache_threshold + """ + ), + {"cache_threshold": cache_threshold.strftime("%Y-%m-%d %H:%M:%S")}, + ) + conn.execute( + text( + """ + DELETE FROM hanmac_preview_query_cache + WHERE updated_at < :cache_threshold + """ + ), + {"cache_threshold": cache_threshold.strftime("%Y-%m-%d %H:%M:%S")}, + ) + conn.execute( + text( + """ + DELETE FROM hanmac_aggregate_query_metrics + WHERE updated_at < :cache_threshold + """ + ), + {"cache_threshold": cache_threshold.strftime("%Y-%m-%d %H:%M:%S")}, + ) + conn.execute( + text( + """ + DELETE FROM hanmac_aggregate_query_rows + WHERE updated_at < :cache_threshold + """ + ), + {"cache_threshold": cache_threshold.strftime("%Y-%m-%d %H:%M:%S")}, + ) + _cleanup_old_hanmac_export_files(keep_paths=keep_paths, older_than_sec=EXPORT_RETENTION_SECONDS) + + +def _run_app_maintenance_once() -> None: + _cleanup_hanmac_runtime_artifacts() + cleanup_compare_runtime_artifacts( + engine, + export_retention_sec=EXPORT_RETENTION_SECONDS, + cache_retention_sec=QUERY_CACHE_RETENTION_SECONDS, + ) + + +def _app_maintenance_worker_loop() -> None: + while True: + try: + _run_app_maintenance_once() + except Exception as exc: + logger.warning("app maintenance skipped due to error: %s", exc) + time.sleep(APP_MAINTENANCE_INTERVAL_SECONDS) + + +def _ensure_app_maintenance_worker() -> None: + global _APP_MAINTENANCE_WORKER_STARTED, _APP_MAINTENANCE_WORKER_THREAD + with _APP_MAINTENANCE_WORKER_LOCK: + if _APP_MAINTENANCE_WORKER_STARTED and _APP_MAINTENANCE_WORKER_THREAD and _APP_MAINTENANCE_WORKER_THREAD.is_alive(): + return + worker = threading.Thread( + target=_app_maintenance_worker_loop, + daemon=True, + name="app-maintenance-worker", + ) + worker.start() + _APP_MAINTENANCE_WORKER_THREAD = worker + _APP_MAINTENANCE_WORKER_STARTED = True + + def get_hanmac_aggregate_summary(payload: dict[str, Any]) -> dict[str, Any]: + global _HANMAC_LAST_AGGREGATE_DIAGNOSTICS schema_name = "hanmac_manhour" start_date, end_date = _hanmac_resolve_period(payload) employment_filter = normalize_text(payload.get("employment")) or "all" @@ -9030,12 +10271,26 @@ def get_hanmac_aggregate_summary(payload: dict[str, Any]) -> dict[str, Any]: try: with test_engine.connect() as connection: metadata = _hanmac_fetch_table_columns(connection, schema_name) + source_diagnostics: dict[str, Any] = { + "regular_tables": [], + "holiday_time_rows": 0, + "holiday_time_hours": 0.0, + "tardy_columns": [], + "tardy_candidate_rows": 0, + "leave_matched_rows": 0, + "leave_types": [], + "member_columns": [], + "member_name_col": "", + "member_name_fallback_rows": 0, + } member_info: dict[str, dict[str, Any]] = {} member_columns = metadata.get("member_tbl") or [] + source_diagnostics["member_columns"] = member_columns 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"]) + member_name_col = _hanmac_find_column(member_columns, ["Name", "MemberName", "member_name", "UserName", "KorName", "MemberNm", "member_nm", "EmpName", "emp_name", "UserNM", "user_nm", "KorNm", "kor_nm", "KoreanName", "DisplayName"]) + source_diagnostics["member_name_col"] = member_name_col or "" 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"]) @@ -9056,25 +10311,35 @@ def get_hanmac_aggregate_summary(payload: dict[str, Any]) -> dict[str, Any]: member_no = normalize_text(row.get("member_no")) if not member_no: continue + member_name = normalize_text(row.get("member_name")) or member_no + if member_name == member_no: + source_diagnostics["member_name_fallback_rows"] += 1 member_info[member_no] = { "member_no": member_no, - "member_name": normalize_text(row.get("member_name")) or member_no, + "member_name": member_name, "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_code_alias_groups: list[dict[str, Any]] = [] project_columns = metadata.get("project_tbl") or [] - project_code_col = _hanmac_find_column(project_columns, ["project_code", "ProjectCode", "ProjectKey", "PCode"]) + project_code_col = _hanmac_find_column(project_columns, ["projectcode", "project_code", "ProjectCode", "ProjectKey", "PCode"]) if project_code_col: project_name_col = _hanmac_find_column(project_columns, ["project_name", "ProjectName", "Name", "project_nm"]) + project_view_code_col = _hanmac_find_column(project_columns, ["projectviewcode", "project_view_code", "ProjectViewCode"]) + old_project_code_col = _hanmac_find_column(project_columns, ["oldprojectcode", "old_project_code", "OldProjectCode"]) + new_project_code_col = _hanmac_find_column(project_columns, ["newprojectcode", "new_project_code", "NewProjectCode"]) 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")} + {_hanmac_build_select_alias(project_name_col, "project_name")}, + {_hanmac_build_select_alias(project_view_code_col, "project_view_code")}, + {_hanmac_build_select_alias(old_project_code_col, "old_project_code")}, + {_hanmac_build_select_alias(new_project_code_col, "new_project_code")} FROM `{schema_name}`.`project_tbl` """ ) @@ -9083,6 +10348,14 @@ def get_hanmac_aggregate_summary(payload: dict[str, Any]) -> dict[str, Any]: project_code = normalize_text(row.get("project_code")) if project_code: project_map[project_code] = normalize_text(row.get("project_name")) or project_code + project_code_alias_groups.append( + { + "project_code": project_code, + "project_view_code": normalize_text(row.get("project_view_code")), + "old_project_code": normalize_text(row.get("old_project_code")), + "new_project_code": normalize_text(row.get("new_project_code")), + } + ) regular_tables = [ table_name @@ -9102,6 +10375,15 @@ def get_hanmac_aggregate_summary(payload: dict[str, Any]) -> dict[str, Any]: 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"]) + work_time_col = _hanmac_find_column(columns, ["WorkTime", "work_time", "RegularTime", "regular_time"]) + holiday_time_col = _hanmac_find_column(columns, ["HolidayTime", "holiday_time", "HolidayWorkTime", "holiday_work_time"]) + source_diagnostics["regular_tables"].append( + { + "table": table_name, + "work_time_col": work_time_col or "", + "holiday_time_col": holiday_time_col or "", + } + ) if not member_col or not entry_col: continue where_clauses = [f"`{member_col}` IS NOT NULL"] @@ -9119,7 +10401,9 @@ def get_hanmac_aggregate_summary(payload: dict[str, Any]) -> dict[str, Any]: {_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")} + {_hanmac_build_select_alias(leave_col, "leave_time")}, + {_hanmac_build_select_alias(work_time_col, "work_time")}, + {_hanmac_build_select_alias(holiday_time_col, "holiday_time")} FROM `{schema_name}`.`{table_name}` WHERE {' AND '.join(where_clauses)} """ @@ -9130,12 +10414,18 @@ def get_hanmac_aggregate_summary(payload: dict[str, Any]) -> dict[str, Any]: member_no = normalize_text(row.get("member_no")) if not member_no: continue + work_time_hours = _hanmac_parse_duration_hours(row.get("work_time")) + holiday_time_hours = _hanmac_parse_duration_hours(row.get("holiday_time")) + if holiday_time_hours > 0: + source_diagnostics["holiday_time_rows"] += 1 + source_diagnostics["holiday_time_hours"] = round(source_diagnostics["holiday_time_hours"] + holiday_time_hours, 2) 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")), + "regular_hours": work_time_hours if work_time_hours > 0 else (0.0 if holiday_time_hours > 0 else _hanmac_calculate_regular_hours(row.get("entry_time"), row.get("leave_time"))), + "holiday_hours": holiday_time_hours, } ) @@ -9143,7 +10433,7 @@ def get_hanmac_aggregate_summary(payload: dict[str, Any]) -> dict[str, Any]: 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: + if addwork_member_col and addwork_date_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"]) @@ -9186,15 +10476,22 @@ def get_hanmac_aggregate_summary(payload: dict[str, Any]) -> dict[str, Any]: leave_records: list[dict[str, Any]] = [] tardy_columns = metadata.get("worker_tardy_tbl") or [] + source_diagnostics["tardy_columns"] = tardy_columns 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"]) + tardy_date_col = _hanmac_find_column(tardy_columns, ["work_date", "WorkDate", "EntryDate", "Date", "TardyDate", "s_date", "SDate", "StartDate", "start_date"]) + tardy_end_date_col = _hanmac_find_column(tardy_columns, ["e_date", "EDate", "EndDate", "end_date"]) + tardy_type_col = _hanmac_find_column(tardy_columns, ["reason", "Reason", "ReasonName", "TardyReason", "state", "State", "WorkState", "gubun", "Gubun", "TardyGubun", "type", "Type", "TardyType", "kind", "Kind", "TardyKind", "TardyCode", "TardyCD", "HolidayType", "VacationType", "AbsenceType", "contents", "Contents", "info", "Info", "memo", "Memo", "remark", "Remark"]) + tardy_value_col = _hanmac_find_column(tardy_columns, ["day_count", "DayCount", "days", "Days", "day", "Day", "DayCnt", "use_day", "UseDay", "use_days", "UseDays", "used_days", "UsedDays", "work_day", "WorkDay", "tardy_day", "TardyDay", "tardy_days", "TardyDays", "hours", "Hours", "hour", "Hour", "time", "Time", "TardyTime", "TardyHour", "TardyHours"]) + tardy_hour_col = _hanmac_find_column(tardy_columns, ["tardy_h", "TardyH", "tardy_hour", "TardyHour"]) + tardy_min_col = _hanmac_find_column(tardy_columns, ["tardy_m", "TardyM", "tardy_min", "TardyMin"]) 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") + if tardy_end_date_col: + where_clauses.append(f"LEFT(CAST(`{tardy_end_date_col}` AS CHAR), 10) >= :start_date") + else: + 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") @@ -9205,15 +10502,20 @@ def get_hanmac_aggregate_summary(payload: dict[str, Any]) -> dict[str, Any]: SELECT {_hanmac_build_select_alias(tardy_member_col, "member_no")}, {_hanmac_build_select_alias(tardy_date_col, "work_date")}, + {_hanmac_build_select_alias(tardy_end_date_col, "end_date")}, {_hanmac_build_select_alias(tardy_type_col, "leave_type")}, - {_hanmac_build_select_alias(tardy_value_col, "leave_value")} + {_hanmac_build_select_alias(tardy_value_col, "leave_value")}, + {_hanmac_build_select_alias(tardy_hour_col, "leave_hour")}, + {_hanmac_build_select_alias(tardy_min_col, "leave_min")} FROM `{schema_name}`.`worker_tardy_tbl` WHERE {' AND '.join(where_clauses)} """ ), params, ).mappings().all() - leave_keywords = ("연차", "휴가", "휴직", "반차", "공가", "병가", "출산", "육아", "대체", "leave", "vacation", "holiday") + source_diagnostics["tardy_candidate_rows"] = len(tardy_rows) + leave_keywords = ("연차", "휴가", "휴직", "반차", "공가", "병가", "출산", "육아", "대체", "대휴", "보상", "휴일", "leave", "vacation", "holiday") + leave_type_values: set[str] = set() for row in tardy_rows: member_no = normalize_text(row.get("member_no")) leave_type = normalize_text(row.get("leave_type")) @@ -9221,15 +10523,68 @@ def get_hanmac_aggregate_summary(payload: dict[str, Any]) -> dict[str, Any]: 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, - } - ) + source_diagnostics["leave_matched_rows"] += 1 + leave_type_values.add(leave_type) + leave_amount = _hanmac_parse_float_value(row.get("leave_value")) + leave_hours = _hanmac_parse_float_value(row.get("leave_hour")) + (_hanmac_parse_float_value(row.get("leave_min")) / 60.0) + if leave_amount <= 0 and leave_hours > 0: + leave_amount = round(leave_hours / 8.0, 2) + if leave_amount <= 0: + leave_amount = 0.0 + record_start = _hanmac_parse_date_value(row.get("work_date")) + record_end = _hanmac_parse_date_value(row.get("end_date")) or record_start + if not record_start: + continue + if record_end and record_end < record_start: + record_start, record_end = record_end, record_start + full_dates = _hanmac_iter_dates(record_start, record_end or record_start) + clipped_start = max(record_start, start_date) + clipped_end = min(record_end or record_start, end_date) + if clipped_end < clipped_start: + continue + clipped_dates = _hanmac_iter_dates(clipped_start, clipped_end) + if leave_amount <= 0: + leave_amount = float(len(full_dates)) + daily_leave_amount = leave_amount / max(len(full_dates), 1) + daily_leave_hours = leave_hours / max(len(full_dates), 1) if leave_hours > 0 else daily_leave_amount * 8.0 + for leave_date in clipped_dates: + leave_records.append( + { + "member_no": member_no, + "work_date": leave_date, + "end_date": leave_date, + "leave_days": round(daily_leave_amount, 4), + "leave_hours": round(daily_leave_hours, 4), + "leave_type": leave_type, + } + ) + source_diagnostics["leave_types"] = sorted(leave_type_values) + + project_relation_maps = _hanmac_build_project_code_relation_maps(project_code_alias_groups) + project_canonical_map: dict[str, str] = project_relation_maps["canonical_map"] + project_equivalent_code_map: dict[str, set[str]] = project_relation_maps["equivalent_code_map"] + + def canonical_project_code(project_code: Any) -> str: + normalized_code = normalize_text(project_code) + return project_canonical_map.get(normalized_code, normalized_code) + + def project_display_name(project_code: Any) -> str: + normalized_code = normalize_text(project_code) + canonical_code = canonical_project_code(normalized_code) + return ( + project_map.get(canonical_code) + or project_map.get(normalized_code) + or canonical_code + or "(미지정)" + ) + + def equivalent_project_codes(project_code: Any) -> list[str]: + canonical_code = canonical_project_code(project_code) + return sorted( + code + for code in project_equivalent_code_map.get(canonical_code, set()) + if code and code != canonical_code + ) today = date.today() member_aggregates: dict[str, dict[str, Any]] = {} @@ -9287,25 +10642,40 @@ def get_hanmac_aggregate_summary(payload: dict[str, Any]) -> dict[str, Any]: "regular_hours": 0.0, "overtime_hours": 0.0, "holiday_hours": 0.0, + "regular_work_days": 0, + "overtime_work_day_keys": set(), "legal_leave_days": 0.0, + "legal_leave_hours": 0.0, "project_codes": set(), + "regular_details": [], + "holiday_details": [], + "overtime_details": [], + "leave_details": [], "multi_entry_days": 0, "multi_entry_details": [], }, ) def ensure_project_bucket(project_code: str) -> dict[str, Any]: - project_key = project_code or "(미지정)" + project_key = canonical_project_code(project_code) or "(미지정)" return project_aggregates.setdefault( project_key, { "project_code": project_key, - "project_name": project_map.get(project_code or "", project_key), + "project_name": project_display_name(project_key), + "equivalent_project_codes": equivalent_project_codes(project_key), "regular_hours": 0.0, "overtime_hours": 0.0, "holiday_hours": 0.0, + "regular_work_days": 0, + "overtime_work_day_keys": set(), "legal_leave_days": 0.0, + "legal_leave_hours": 0.0, "member_nos": set(), + "regular_details": [], + "holiday_details": [], + "overtime_details": [], + "leave_details": [], }, ) @@ -9331,33 +10701,89 @@ def get_hanmac_aggregate_summary(payload: dict[str, Any]) -> dict[str, Any]: "member_no": member_no, "work_date": work_date, "project_hours": {}, + "holiday_project_hours": {}, "entries": [], }, ) - project_code = row["project_code"] + raw_project_code = row["project_code"] + project_code = canonical_project_code(raw_project_code) raw_hours = max(0.0, _hanmac_parse_float_value(row["regular_hours"])) + raw_holiday_hours = max(0.0, _hanmac_parse_float_value(row.get("holiday_hours"))) day_group["project_hours"][project_code] = round( day_group["project_hours"].get(project_code, 0.0) + raw_hours, 4, ) + day_group["holiday_project_hours"][project_code] = round( + day_group["holiday_project_hours"].get(project_code, 0.0) + raw_holiday_hours, + 4, + ) day_group["entries"].append( { "project_code": project_code or "(미지정)", - "project_name": project_map.get(project_code or "", project_code or "(미지정)"), + "project_name": project_display_name(project_code), + "raw_project_code": raw_project_code, + "equivalent_project_codes": equivalent_project_codes(project_code), "regular_hours": round(raw_hours, 2), + "holiday_hours": round(raw_holiday_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) + raw_holiday_total_hours = round(sum(day_group["holiday_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) + holiday_hours = round(raw_holiday_total_hours if raw_holiday_total_hours > 0 else (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 + if capped_regular_hours > 0: + member_bucket["regular_work_days"] += 1 + member_bucket["regular_details"].append( + { + "work_date": work_date.isoformat(), + "regular_hours": capped_regular_hours, + "raw_total_hours": raw_total_hours, + "leave_days": round(leave_days, 2), + "projects": sorted( + [ + { + "project_code": project_code or "(미지정)", + "project_name": project_display_name(project_code), + "equivalent_project_codes": equivalent_project_codes(project_code), + "hours": round(project_hours, 2), + } + for project_code, project_hours in day_group["project_hours"].items() + ], + key=lambda item: (-item["hours"], item["project_code"]), + ), + } + ) + if holiday_hours > 0: + member_bucket["holiday_details"].append( + { + "work_date": work_date.isoformat(), + "holiday_hours": holiday_hours, + "projects": sorted( + [ + { + "project_code": project_code or "(미지정)", + "project_name": project_display_name(project_code), + "equivalent_project_codes": equivalent_project_codes(project_code), + "hours": round(project_hours, 2), + } + for project_code, project_hours in ( + day_group["holiday_project_hours"].items() + if raw_holiday_total_hours > 0 + else day_group["project_hours"].items() + ) + ], + key=lambda item: (-item["hours"], item["project_code"]), + ), + } + ) for project_code in day_group["project_hours"].keys(): if project_code: @@ -9382,24 +10808,50 @@ def get_hanmac_aggregate_summary(payload: dict[str, Any]) -> dict[str, Any]: if raw_total_hours <= 0: continue - if work_date.weekday() >= 5: - for project_code, project_hours in day_group["project_hours"].items(): + if holiday_hours > 0: + holiday_source_hours = day_group["holiday_project_hours"] if raw_holiday_total_hours > 0 else day_group["project_hours"] + for project_code, project_hours in holiday_source_hours.items(): project_bucket = ensure_project_bucket(project_code) - project_bucket["holiday_hours"] += round(project_hours, 2) + project_hours = round(project_hours, 2) + project_bucket["holiday_hours"] += project_hours + if project_hours > 0: + project_bucket["holiday_details"].append( + { + "work_date": work_date.isoformat(), + "member_no": member_no, + "member_name": member_record["member_name"], + "holiday_hours": project_hours, + } + ) project_bucket["member_nos"].add(member_no) - continue + if work_date.weekday() >= 5 and raw_holiday_total_hours <= 0: + 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_regular_hours = round(capped_regular_hours * share_ratio, 4) + project_bucket["regular_hours"] += project_regular_hours + if capped_regular_hours > 0 and project_hours > 0: + project_bucket["regular_work_days"] += 1 + project_bucket["regular_details"].append( + { + "work_date": work_date.isoformat(), + "member_no": member_no, + "member_name": member_record["member_name"], + "regular_hours": round(project_regular_hours, 2), + "raw_project_hours": round(project_hours, 2), + "leave_days": round(leave_days, 2), + } + ) 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"] + raw_project_code = row["project_code"] + project_code = canonical_project_code(raw_project_code) member_record = member_info.get(member_no) or { "member_no": member_no, "member_name": member_no, @@ -9411,11 +10863,33 @@ def get_hanmac_aggregate_summary(payload: dict[str, Any]) -> dict[str, Any]: continue member_bucket = ensure_member_bucket(member_no) member_bucket["overtime_hours"] += row["overtime_hours"] + if row["overtime_hours"] > 0: + member_bucket["overtime_work_day_keys"].add(row["work_date"]) + member_bucket["overtime_details"].append( + { + "work_date": row["work_date"].isoformat() if row.get("work_date") else "", + "project_code": project_code or "(미지정)", + "project_name": project_display_name(project_code), + "raw_project_code": raw_project_code, + "equivalent_project_codes": equivalent_project_codes(project_code), + "overtime_hours": round(row["overtime_hours"], 2), + } + ) if project_code: member_bucket["project_codes"].add(project_code) project_bucket = ensure_project_bucket(project_code) project_bucket["overtime_hours"] += row["overtime_hours"] + if row["overtime_hours"] > 0: + project_bucket["overtime_work_day_keys"].add((member_no, row["work_date"])) + project_bucket["overtime_details"].append( + { + "work_date": row["work_date"].isoformat() if row.get("work_date") else "", + "member_no": member_no, + "member_name": member_record["member_name"], + "overtime_hours": round(row["overtime_hours"], 2), + } + ) project_bucket["member_nos"].add(member_no) for row in leave_records: @@ -9433,18 +10907,54 @@ def get_hanmac_aggregate_summary(payload: dict[str, Any]) -> dict[str, Any]: continue member_bucket = ensure_member_bucket(member_no) member_bucket["legal_leave_days"] += row["leave_days"] + member_bucket["legal_leave_hours"] += row.get("leave_hours", row["leave_days"] * 8.0) + member_bucket["leave_details"].append( + { + "work_date": row["work_date"].isoformat() if row.get("work_date") else "", + "leave_type": row.get("leave_type") or "법정휴가", + "leave_days": round(row["leave_days"], 2), + "leave_hours": round(row.get("leave_hours", row["leave_days"] * 8.0), 2), + } + ) if view_mode == "project": rows = [ { "project_code": bucket["project_code"], "project_name": bucket["project_name"], + "equivalent_project_codes": bucket.get("equivalent_project_codes", []), "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), + "regular_work_days": bucket["regular_work_days"], + "overtime_work_days": len(bucket["overtime_work_day_keys"]), "total_hours": round(bucket["regular_hours"] + bucket["overtime_hours"] + bucket["holiday_hours"], 2), "legal_leave_days": round(bucket["legal_leave_days"], 2), + "legal_leave_hours": round(bucket["legal_leave_hours"], 2), + "aggregate_details": { + "regular_hours": sorted(bucket["regular_details"], key=lambda item: (item.get("work_date") or "", item.get("member_no") or "")), + "overtime_hours": sorted(bucket["overtime_details"], key=lambda item: (item.get("work_date") or "", item.get("member_no") or "")), + "holiday_hours": sorted(bucket["holiday_details"], key=lambda item: (item.get("work_date") or "", item.get("member_no") or "")), + "total_hours": sorted( + [ + *[ + {"detail_type": "정규근로", **item} + for item in bucket["regular_details"] + ], + *[ + {"detail_type": "휴일근로", **item} + for item in bucket["holiday_details"] + ], + *[ + {"detail_type": "연장근로", **item} + for item in bucket["overtime_details"] + ], + ], + key=lambda item: (item.get("work_date") or "", item.get("member_no") or "", item.get("detail_type") or ""), + ), + "legal_leave_days": sorted(bucket["leave_details"], key=lambda item: (item.get("work_date") or "", item.get("member_no") or "")), + }, } for bucket in project_aggregates.values() ] @@ -9470,9 +10980,46 @@ def get_hanmac_aggregate_summary(payload: dict[str, Any]) -> dict[str, Any]: "regular_hours": round(bucket["regular_hours"], 2), "overtime_hours": round(bucket["overtime_hours"], 2), "holiday_hours": round(bucket["holiday_hours"], 2), + "regular_work_days": bucket["regular_work_days"], + "overtime_work_days": len(bucket["overtime_work_day_keys"]), "total_hours": round(bucket["regular_hours"] + bucket["overtime_hours"] + bucket["holiday_hours"], 2), "legal_leave_days": round(bucket["legal_leave_days"], 2), + "legal_leave_hours": round(bucket["legal_leave_hours"], 2), "project_count": len(bucket["project_codes"]), + "aggregate_details": { + "regular_hours": sorted(bucket["regular_details"], key=lambda item: item.get("work_date") or ""), + "overtime_hours": sorted(bucket["overtime_details"], key=lambda item: item.get("work_date") or ""), + "holiday_hours": sorted(bucket["holiday_details"], key=lambda item: item.get("work_date") or ""), + "total_hours": sorted( + [ + *[ + {"detail_type": "정규근로", **item} + for item in bucket["regular_details"] + ], + *[ + {"detail_type": "휴일근로", **item} + for item in bucket["holiday_details"] + ], + *[ + {"detail_type": "연장근로", **item} + for item in bucket["overtime_details"] + ], + ], + key=lambda item: (item.get("work_date") or "", item.get("detail_type") or ""), + ), + "legal_leave_days": sorted(bucket["leave_details"], key=lambda item: item.get("work_date") or ""), + "project_count": sorted( + [ + { + "project_code": project_code, + "project_name": project_display_name(project_code), + "equivalent_project_codes": equivalent_project_codes(project_code), + } + for project_code in bucket["project_codes"] + ], + key=lambda item: item["project_code"], + ), + }, "multi_entry_days": bucket["multi_entry_days"], "multi_entry_details": sorted( bucket["multi_entry_details"], @@ -9502,11 +11049,12 @@ def get_hanmac_aggregate_summary(payload: dict[str, Any]) -> dict[str, Any]: "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), + "legal_leave_hours": round(sum(item.get("legal_leave_hours", 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 { + response_payload = { "status": "ok", "view": view_mode, "start_date": start_date.isoformat(), @@ -9515,7 +11063,28 @@ def get_hanmac_aggregate_summary(payload: dict[str, Any]) -> dict[str, Any]: "columns": columns, "rows": rows, "summary": summary, + "source_diagnostics": source_diagnostics, } + _HANMAC_LAST_AGGREGATE_DIAGNOSTICS = { + "updated_at": datetime.now().isoformat(timespec="seconds"), + "start_date": response_payload["start_date"], + "end_date": response_payload["end_date"], + "employment": employment_filter, + "view": view_mode, + "summary": summary, + "source_diagnostics": source_diagnostics, + } + logger.info( + "hanmac aggregate diagnostics: period=%s~%s HolidayTime rows=%s hours=%s leave=%s/%s types=%s", + response_payload["start_date"], + response_payload["end_date"], + source_diagnostics.get("holiday_time_rows"), + source_diagnostics.get("holiday_time_hours"), + source_diagnostics.get("leave_matched_rows"), + source_diagnostics.get("tardy_candidate_rows"), + source_diagnostics.get("leave_types"), + ) + return response_payload finally: test_engine.dispose() @@ -9584,6 +11153,16 @@ async def home(request: Request, edit_id: int | None = None, overview_year: int return HTMLResponse("

서버 오류

로그를 확인해주세요.

", status_code=500) +@app.get("/bootstrap-data") +async def dashboard_bootstrap_data_api(overview_year: str | None = None): + try: + payload = await run_in_threadpool(get_dashboard_bootstrap_payload, parse_optional_year(overview_year)) + 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("/projects") async def projects( request: Request, @@ -9612,6 +11191,35 @@ async def project_edit_data(code: str | None = None): return JSONResponse(content={"error": str(exc)}, status_code=500) +@app.get("/projects/account-breakdowns") +async def project_account_breakdowns_api(year: str | None = None, codes: str | None = None): + try: + selected_year = parse_optional_year(year) + selected_codes = parse_support_dept_codes_param(codes) + payload = await run_in_threadpool(get_project_account_breakdowns, selected_year, selected_codes) + 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("/projects/bootstrap-data") +async def project_bootstrap_data_api(year: str | None = None): + try: + selected_year = parse_optional_year(year) + payload = await run_in_threadpool( + lambda: { + "revenue_mix": get_project_revenue_mix(selected_year), + "project_cost_by_year": get_project_cost_by_year(selected_year), + "project_status_rows": get_project_status_rows(), + } + ) + return JSONResponse(content=jsonable_encoder(payload)) + except Exception as exc: + logger.exception("프로젝트 부트스트랩 데이터 조회 에러: %s", exc) + return JSONResponse(content={"error": str(exc)}, status_code=500) + + @app.post("/projects/page-state") async def project_page_state_save(request: Request): try: @@ -10080,6 +11688,16 @@ async def annual_summary(request: Request): return HTMLResponse("

서버 오류

로그를 확인해주세요.

", status_code=500) +@app.get("/annual-summary/bootstrap-data") +async def annual_summary_bootstrap_data_api(): + try: + payload = await run_in_threadpool(get_annual_summary_bootstrap_payload) + 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("/process-cost") async def process_cost( request: Request, @@ -10105,6 +11723,31 @@ async def process_cost( return HTMLResponse("

서버 오류

로그를 확인해주세요.

", status_code=500) +@app.get("/process-cost/bootstrap-data") +async def process_cost_bootstrap_data( + source: str | None = None, + start_year: str | None = None, + end_year: str | None = None, + code: str | None = None, + include_related: bool = False, + active_related: str | None = None, +): + try: + payload = await run_in_threadpool( + get_process_cost_bootstrap_payload, + source, + parse_optional_year(start_year), + parse_optional_year(end_year), + code, + include_related, + active_related, + ) + 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("/hanmac-browser") async def hanmac_browser(request: Request): try: @@ -10162,7 +11805,7 @@ async def hanmac_browser_preview(request: Request): payload = await request.json() if not isinstance(payload, dict): raise ValueError("잘못된 요청 형식입니다.") - return JSONResponse(content=get_hanmac_table_preview(payload)) + return JSONResponse(content=await run_in_threadpool(get_hanmac_table_preview_cached, 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) @@ -10171,13 +11814,69 @@ async def hanmac_browser_preview(request: Request): return JSONResponse(content={"status": "error", "message": str(exc)}, status_code=400) +@app.post("/hanmac-browser/api/preview-export-jobs") +async def hanmac_browser_preview_export_jobs(request: Request): + try: + payload = await request.json() + if not isinstance(payload, dict): + payload = {} + response = await run_in_threadpool(request_hanmac_preview_export_job, payload) + return JSONResponse(content=jsonable_encoder({"ok": True, **response})) + except OperationalError as exc: + logger.exception("hanmac DB_external 미리보기 엑셀 준비 실패: %s", exc) + return JSONResponse(content={"error": build_hanmac_mysql_error_message(exc)}, status_code=400) + except Exception as exc: + logger.exception("hanmac DB_external 미리보기 엑셀 준비 에러: %s", exc) + return JSONResponse(content={"error": str(exc)}, status_code=500) + + +@app.post("/hanmac-browser/api/aggregate-export-jobs") +async def hanmac_browser_aggregate_export_jobs(request: Request): + try: + payload = await request.json() + if not isinstance(payload, dict): + payload = {} + response = await run_in_threadpool(request_hanmac_aggregate_export_job, payload) + return JSONResponse(content=jsonable_encoder({"ok": True, **response})) + except OperationalError as exc: + logger.exception("hanmac DB_external 집계 엑셀 준비 실패: %s", exc) + return JSONResponse(content={"error": build_hanmac_mysql_error_message(exc)}, status_code=400) + except Exception as exc: + logger.exception("hanmac DB_external 집계 엑셀 준비 에러: %s", exc) + return JSONResponse(content={"error": str(exc)}, status_code=500) + + +@app.get("/hanmac-browser/api/preview-export-jobs/{job_key}") +async def hanmac_browser_preview_export_job(job_key: str): + try: + payload = get_hanmac_export_job(job_key) + return JSONResponse(content=jsonable_encoder(payload)) + except Exception as exc: + logger.exception("hanmac DB_external 미리보기 엑셀 상태 조회 에러: %s", exc) + return JSONResponse(content={"error": str(exc)}, status_code=500) + + +@app.get("/hanmac-browser/api/preview-export-download/{job_key}") +async def hanmac_browser_preview_export_download(job_key: str): + try: + payload = get_hanmac_export_job(job_key) + file_path = payload.get("file_path") or "" + file_name = payload.get("file_name") or f"{job_key}.csv" + 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="text/csv; charset=utf-8", filename=file_name) + except Exception as exc: + logger.exception("hanmac DB_external 미리보기 엑셀 다운로드 전달 에러: %s", exc) + return JSONResponse(content={"error": str(exc)}, status_code=500) + + @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)) + return JSONResponse(content=await run_in_threadpool(get_hanmac_aggregate_summary_cached, 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) @@ -10186,6 +11885,11 @@ async def hanmac_browser_aggregate(request: Request): return JSONResponse(content={"status": "error", "message": str(exc)}, status_code=400) +@app.get("/hanmac-browser/api/aggregate-diagnostics") +async def hanmac_browser_aggregate_diagnostics(): + return JSONResponse(content=jsonable_encoder(_HANMAC_LAST_AGGREGATE_DIAGNOSTICS or {"status": "empty"})) + + @app.get("/wehago-compare") async def wehago_compare(request: Request, start_year: str | None = None, end_year: str | None = None): try: @@ -10262,9 +11966,11 @@ async def wehago_compare_status_rows( boundary_excluded: str = "", offset: int = 0, limit: int = 200, + cursor: str = "", ): try: - payload = get_status_detail_rows( + payload = await run_in_threadpool( + get_status_detail_rows, engine, start_year=start_year, end_year=end_year, @@ -10282,8 +11988,12 @@ async def wehago_compare_status_rows( boundary_excluded=boundary_excluded, offset=offset, limit=limit, + cursor=cursor, ) return JSONResponse(content=jsonable_encoder(payload)) + except ValueError as exc: + logger.warning("전표비교 상태 상세 조회 검증 오류: %s", exc) + return JSONResponse(content={"error": str(exc)}, status_code=400) except Exception as exc: logger.exception("전표비교 상태 상세 조회 에러: %s", exc) return JSONResponse(content={"error": str(exc)}, status_code=500) diff --git a/scripts/run_server.sh b/scripts/run_server.sh index 0dadbbb..1d1e24a 100755 --- a/scripts/run_server.sh +++ b/scripts/run_server.sh @@ -35,7 +35,17 @@ if ss -ltn "( sport = :${PORT} )" | tail -n +2 | grep -q .; then exit 1 fi -export INTRANET_AUTO_RELOAD="${INTRANET_AUTO_RELOAD:-0}" +export INTRANET_AUTO_RELOAD="${INTRANET_AUTO_RELOAD:-1}" export INTRANET_PORT="$PORT" +echo "서버를 시작합니다: http://127.0.0.1:${PORT} (auto_reload=${INTRANET_AUTO_RELOAD})" +echo "예기치 않게 종료되면 2초 후 자동 재시작합니다." -exec ./.venv/bin/python main.py +while true; do + ./.venv/bin/python main.py + exit_code=$? + if [ "$exit_code" -eq 0 ]; then + exit 0 + fi + echo "서버가 종료되었습니다 (exit=${exit_code}). 2초 후 다시 시작합니다." + sleep 2 +done diff --git a/scripts/setup_windows_all_portproxies.ps1 b/scripts/setup_windows_all_portproxies.ps1 index 5433673..48d5a69 100644 --- a/scripts/setup_windows_all_portproxies.ps1 +++ b/scripts/setup_windows_all_portproxies.ps1 @@ -14,7 +14,7 @@ $knownPorts = @{ function Get-WslListeningPorts { $raw = wsl.exe bash -lc "ss -ltnH 2>/dev/null" if (-not $raw) { - throw "WSL listening TCP 포트를 확인하지 못했습니다." + return @() } $portMap = @{} @@ -110,8 +110,27 @@ $listenAddresses = $listenAddresses | Where-Object { -not [string]::IsNullOrWhit Write-Host "Setting Windows portproxy for all active WSL TCP ports..." -ForegroundColor Cyan Write-Host "WSL IP: $WslIp" $portEntries = @(Get-WslListeningPorts) + +$knownPortEntries = @() +foreach ($knownPort in ($knownPorts.Keys | Sort-Object)) { + $existingEntry = @($portEntries | Where-Object { [int]$_.Port -eq [int]$knownPort } | Select-Object -First 1) + if ($existingEntry -and $existingEntry.Count -gt 0) { + continue + } + + $hosts = New-Object System.Collections.Generic.List[string] + $hosts.Add("not-listening-yet") + $knownPortEntries += [ordered]@{ + Port = [int]$knownPort + Hosts = $hosts + ExternallyReachable = $true + Assumed = $true + } +} + +$portEntries = @($portEntries + $knownPortEntries | Sort-Object Port) if (-not $portEntries -or $portEntries.Count -eq 0) { - throw "외부에서 접근 가능한 WSL TCP 포트를 찾지 못했습니다." + throw "등록할 WSL TCP 포트를 찾지 못했습니다." } Write-Host "Detected ports: $((@($portEntries | ForEach-Object { $_.Port })) -join ', ')" @@ -130,9 +149,16 @@ foreach ($entry in $reachableEntries) { $appInfo = $knownPorts[$port] $displayName = if ($appInfo) { [string]$appInfo.Name } else { "wsl-port-$port" } $ruleName = if ($appInfo) { [string]$appInfo.RuleName } else { "WSL-PORT-$port" } + $assumed = $false + if ($entry.Contains("Assumed")) { + $assumed = [bool]$entry.Assumed + } Write-Host "" Write-Host "$displayName : $port ($($entry.Hosts -join ', '))" -ForegroundColor Cyan + if ($assumed) { + Write-Host " Known service port is not listening yet; portproxy will still be refreshed to the current WSL IP." -ForegroundColor Yellow + } foreach ($address in $listenAddresses) { netsh interface portproxy delete v4tov4 listenport=$port listenaddress=$address | Out-Null diff --git a/templates/annual_summary.html b/templates/annual_summary.html index 48c654f..f9863c2 100644 --- a/templates/annual_summary.html +++ b/templates/annual_summary.html @@ -250,9 +250,9 @@ {% block script %} {% endblock %} diff --git a/templates/dashboard.html b/templates/dashboard.html index cb54773..8998480 100644 --- a/templates/dashboard.html +++ b/templates/dashboard.html @@ -377,11 +377,11 @@ {% block script %} {% endblock %} diff --git a/templates/hanmac_browser.html b/templates/hanmac_browser.html index da14e5f..4d23827 100644 --- a/templates/hanmac_browser.html +++ b/templates/hanmac_browser.html @@ -367,12 +367,12 @@ .hanmac-aggregate-table th, .hanmac-aggregate-table td { - padding: 10px 10px; + padding: 7px 10px; border-bottom: 1px solid #eceff3; text-align: left; - vertical-align: top; + vertical-align: middle; font-size: 12px; - line-height: 1.5; + line-height: 1.35; white-space: nowrap; } @@ -383,6 +383,69 @@ background: #f7f9fc; color: var(--ink); font-weight: 800; + text-align: center; + box-shadow: none; + } + + .hanmac-aggregate-sort-button { + all: unset; + box-sizing: border-box; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 6px; + width: 100%; + min-height: 20px; + padding: 0; + border: 0; + border-radius: 0; + background: transparent; + color: inherit; + font: inherit; + font-weight: 800; + line-height: 1.2; + text-align: center; + cursor: pointer; + box-shadow: none; + transform: none; + transition: none; + } + + .hanmac-aggregate-sort-button:hover, + .hanmac-aggregate-sort-button:focus { + background: transparent; + box-shadow: none; + transform: none; + } + + .hanmac-aggregate-sort-button::after { + content: "↕"; + color: #8a96a8; + font-size: 11px; + line-height: 1; + } + + .hanmac-aggregate-sort-button.is-sorted-asc::after { + content: "↑"; + color: #111111; + } + + .hanmac-aggregate-sort-button.is-sorted-desc::after { + content: "↓"; + color: #111111; + } + + .hanmac-aggregate-table td.is-numeric { + text-align: right; + } + + .hanmac-aggregate-table th.is-numeric { + text-align: center; + } + + .hanmac-aggregate-table th.is-numeric .hanmac-aggregate-sort-button { + justify-content: center; + text-align: center; } .hanmac-aggregate-table tbody tr:nth-child(even) td { @@ -395,23 +458,107 @@ gap: 8px; } + .hanmac-aggregate-metric-cell { + display: inline-flex; + align-items: center; + justify-content: flex-end; + gap: 6px; + width: 100%; + min-height: 18px; + font-variant-numeric: tabular-nums; + } + + .hanmac-aggregate-metric-value { + display: inline-flex; + justify-content: flex-end; + min-width: 64px; + color: inherit; + font: inherit; + font-weight: 700; + line-height: 18px; + text-align: right; + font-variant-numeric: tabular-nums; + } + + .hanmac-aggregate-metric-button { + all: unset; + box-sizing: border-box; + display: inline-flex; + justify-content: flex-end; + min-width: 64px; + color: inherit; + font: inherit; + font-weight: 700; + line-height: 18px; + text-align: right; + font-variant-numeric: tabular-nums; + border: 0; + border-bottom: 1px solid transparent; + border-radius: 0; + padding: 0; + background: transparent; + cursor: pointer; + box-shadow: none; + min-height: 0; + transform: none; + transition: none; + } + + .hanmac-aggregate-metric-button:hover { + background: transparent; + border-bottom-color: #1f4d8f; + color: #1f4d8f; + box-shadow: none; + transform: none; + } + + .hanmac-aggregate-day-badge { + display: inline-flex; + align-items: center; + justify-content: flex-end; + width: 42px; + min-height: 0; + padding: 0; + border: 0; + border-radius: 0; + background: transparent; + color: #4a5565; + font-size: 11px; + font-weight: 800; + line-height: 18px; + text-align: right; + font-variant-numeric: tabular-nums; + white-space: nowrap; + box-shadow: none; + } + .hanmac-aggregate-detail-link { + all: unset; + box-sizing: border-box; display: inline-flex; align-items: center; justify-content: center; - min-height: 24px; - padding: 0 8px; - border: 1px solid #d8dfeb; - border-radius: 999px; - background: #f5f8fc; + min-height: 0; + padding: 0; + border: 0; + border-bottom: 1px solid transparent; + border-radius: 0; + background: transparent; color: #1f4d8f; font-size: 11px; font-weight: 700; + line-height: 18px; cursor: pointer; + box-shadow: none; + transform: none; + transition: none; } .hanmac-aggregate-detail-link:hover { - background: #ebf2fb; + background: transparent; + border-bottom-color: #1f4d8f; + box-shadow: none; + transform: none; } .hanmac-modal-copy { @@ -481,6 +628,12 @@ font-weight: 800; } + .hanmac-detail-table th.is-numeric, + .hanmac-detail-table td.is-numeric { + text-align: right; + font-variant-numeric: tabular-nums; + } + .hanmac-preview-hero { display: flex; align-items: center; @@ -909,6 +1062,7 @@ +
@@ -985,6 +1139,8 @@
+ + @@ -1098,6 +1254,7 @@ const aggregateValueSearch = document.getElementById("hanmacAggregateValueSearch"); const aggregateValueOptions = document.getElementById("hanmacAggregateValueOptions"); const aggregateValueReset = document.getElementById("hanmacAggregateValueReset"); + const exportAggregateButton = document.getElementById("hanmacExportAggregateButton"); const multiEntryModal = document.getElementById("hanmacMultiEntryModal"); const closeMultiEntryButton = document.getElementById("hanmacCloseMultiEntryButton"); const multiEntryTitle = document.getElementById("hanmacMultiEntryTitle"); @@ -1109,6 +1266,8 @@ const previewValueReset = document.getElementById("hanmacPreviewValueReset"); const previewLimit = document.getElementById("hanmacPreviewLimit"); const refreshPreviewButton = document.getElementById("hanmacRefreshPreviewButton"); + const previewPrevButton = document.getElementById("hanmacPreviewPrevButton"); + const previewNextButton = document.getElementById("hanmacPreviewNextButton"); const exportPreviewButton = document.getElementById("hanmacExportPreviewButton"); const previewMeta = document.getElementById("hanmacPreviewMeta"); const previewEmpty = document.getElementById("hanmacPreviewEmpty"); @@ -1124,7 +1283,7 @@ const summaryKey = document.getElementById("hanmacSummaryKey"); const schemaTabs = Array.from(document.querySelectorAll("[data-schema-filter]")); const focusButtons = Array.from(document.querySelectorAll("[data-focus]")); - if (!form || !modal || !openConfigButton || !closeConfigButton || !statusBox || !testButton || !loadTablesButton || !tableListMeta || !tableList || !tableSearch || !aggregateStartDate || !aggregateEndDate || !aggregateEmployment || !aggregateLoadButton || !aggregateMeta || !aggregateMemberCount || !aggregateRegularHours || !aggregateOvertimeHours || !aggregateTotalHours || !aggregateLeaveDays || !aggregateProjectCount || !aggregateEmpty || !aggregateTable || !aggregateHead || !aggregateBody || !aggregateValueColumn || !aggregateValueSearch || !aggregateValueOptions || !aggregateValueReset || !multiEntryModal || !closeMultiEntryButton || !multiEntryTitle || !multiEntryMeta || !multiEntryBody || !previewValueColumn || !previewValueSearch || !previewValueOptions || !previewValueReset || !previewLimit || !refreshPreviewButton || !exportPreviewButton || !previewMeta || !previewEmpty || !previewControls || !previewTable || !previewHead || !previewBody || !previewToggleButton || !selectedTitle || !summarySchema || !summaryRows || !summaryColumns || !summaryKey) return; + if (!form || !modal || !openConfigButton || !closeConfigButton || !statusBox || !testButton || !loadTablesButton || !tableListMeta || !tableList || !tableSearch || !aggregateStartDate || !aggregateEndDate || !aggregateEmployment || !aggregateLoadButton || !aggregateMeta || !aggregateMemberCount || !aggregateRegularHours || !aggregateOvertimeHours || !aggregateTotalHours || !aggregateLeaveDays || !aggregateProjectCount || !aggregateEmpty || !aggregateTable || !aggregateHead || !aggregateBody || !aggregateValueColumn || !aggregateValueSearch || !aggregateValueOptions || !aggregateValueReset || !exportAggregateButton || !multiEntryModal || !closeMultiEntryButton || !multiEntryTitle || !multiEntryMeta || !multiEntryBody || !previewValueColumn || !previewValueSearch || !previewValueOptions || !previewValueReset || !previewLimit || !refreshPreviewButton || !previewPrevButton || !previewNextButton || !exportPreviewButton || !previewMeta || !previewEmpty || !previewControls || !previewTable || !previewHead || !previewBody || !previewToggleButton || !selectedTitle || !summarySchema || !summaryRows || !summaryColumns || !summaryKey) return; let allTables = []; let visibleTables = []; @@ -1135,10 +1294,16 @@ let currentPreviewColumns = []; let currentPreviewRows = []; let currentPreviewAllRows = []; + let currentPreviewCursor = ""; + let currentPreviewNextCursor = ""; + let currentPreviewCursorHistory = []; + let currentPreviewExportJobKey = ""; + let currentAggregateExportJobKey = ""; let currentAggregateRows = []; let currentAggregateAllRows = []; let currentAggregateColumns = []; let currentAggregateView = "member"; + let currentAggregateSort = { key: "", direction: "desc" }; const credentialStorageKey = "hanmac-db-external-credentials-v1"; const priorityTableNames = ["dallyproject_tbl", "dallyproject_addwork_tbl", "member_tbl", "project_tbl", "worker_tardy_tbl"]; @@ -1262,28 +1427,137 @@ return columnLabelMap[String(column)] || String(column); }; - const csvEscape = (value) => { - const textValue = value == null ? "" : String(value); - return `"${textValue.replace(/"/g, '""')}"`; + const setPreviewExportBusy = (busy) => { + exportPreviewButton.disabled = busy; + exportPreviewButton.dataset.loading = busy ? "true" : "false"; + exportPreviewButton.title = busy ? "파일 준비 중입니다..." : "엑셀 다운로드"; + exportPreviewButton.setAttribute("aria-label", busy ? "엑셀 준비 중" : "엑셀 다운로드"); }; - const exportPreviewToExcel = () => { - if (!currentPreviewColumns.length || !currentPreviewRows.length) return; - const header = currentPreviewColumns.map((column) => csvEscape(getColumnLabel(column))).join(","); - const body = currentPreviewRows.map((row) => ( - currentPreviewColumns.map((column) => csvEscape(row[column])).join(",") - )); - const csvContent = ["\uFEFF" + header, ...body].join("\r\n"); - const blob = new Blob([csvContent], { type: "text/csv;charset=utf-8;" }); - const url = URL.createObjectURL(blob); - const anchor = document.createElement("a"); - const today = new Date().toISOString().slice(0, 10); - anchor.href = url; - anchor.download = `${selectedSchema || "hanmac"}_${selectedTable || "preview"}_${today}.csv`; - document.body.appendChild(anchor); - anchor.click(); - anchor.remove(); - URL.revokeObjectURL(url); + const setAggregateExportBusy = (busy) => { + exportAggregateButton.disabled = busy; + exportAggregateButton.dataset.loading = busy ? "true" : "false"; + exportAggregateButton.title = busy ? "집계 파일 준비 중입니다..." : "집계 엑셀 다운로드"; + exportAggregateButton.setAttribute("aria-label", busy ? "집계 엑셀 준비 중" : "집계 엑셀 다운로드"); + }; + + const pollPreviewExportJob = async (jobKey) => { + currentPreviewExportJobKey = jobKey; + for (let attempt = 0; attempt < 240; attempt += 1) { + const response = await fetch(`/hanmac-browser/api/preview-export-jobs/${encodeURIComponent(jobKey)}`); + const payload = await response.json(); + if (!response.ok) { + throw new Error(payload.error || "파일 준비 상태를 확인하지 못했습니다."); + } + if (payload.state === "ready" && payload.download_url) { + setPreviewExportBusy(false); + currentPreviewExportJobKey = ""; + window.location.assign(payload.download_url); + return; + } + if (payload.state === "failed") { + setPreviewExportBusy(false); + currentPreviewExportJobKey = ""; + throw new Error(payload.error_message || "파일 준비 중 오류가 발생했습니다."); + } + await new Promise((resolve) => window.setTimeout(resolve, 1500)); + } + setPreviewExportBusy(false); + currentPreviewExportJobKey = ""; + throw new Error("파일 준비 시간이 길어지고 있습니다. 잠시 후 다시 시도해주세요."); + }; + + const exportPreviewToExcel = async () => { + if (!selectedTable || !selectedSchema || !currentPreviewColumns.length) return; + setPreviewExportBusy(true); + try { + const response = await fetch("/hanmac-browser/api/preview-export-jobs", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + ...getPayload(), + schema: selectedSchema, + table: selectedTable, + limit: previewLimit.value, + cursor: currentPreviewCursor, + value_column: previewValueColumn.value, + value_search: previewValueSearch.value, + }), + }); + const payload = await response.json(); + if (!response.ok) { + throw new Error(payload.error || "파일 준비 요청에 실패했습니다."); + } + if (payload.state === "ready" && payload.download_url) { + setPreviewExportBusy(false); + window.location.assign(payload.download_url); + return; + } + await pollPreviewExportJob(payload.job_key || ""); + } catch (error) { + setPreviewExportBusy(false); + alert(error.message || "엑셀 다운로드 준비 중 오류가 발생했습니다."); + } + }; + + const pollAggregateExportJob = async (jobKey) => { + currentAggregateExportJobKey = jobKey; + for (let attempt = 0; attempt < 240; attempt += 1) { + const response = await fetch(`/hanmac-browser/api/preview-export-jobs/${encodeURIComponent(jobKey)}`); + const payload = await response.json(); + if (!response.ok) { + throw new Error(payload.error || "집계 파일 준비 상태를 확인하지 못했습니다."); + } + if (payload.state === "ready" && payload.download_url) { + setAggregateExportBusy(false); + currentAggregateExportJobKey = ""; + window.location.assign(payload.download_url); + return; + } + if (payload.state === "failed") { + setAggregateExportBusy(false); + currentAggregateExportJobKey = ""; + throw new Error(payload.error_message || "집계 파일 준비 중 오류가 발생했습니다."); + } + await new Promise((resolve) => window.setTimeout(resolve, 1500)); + } + setAggregateExportBusy(false); + currentAggregateExportJobKey = ""; + throw new Error("집계 파일 준비 시간이 길어지고 있습니다. 잠시 후 다시 시도해주세요."); + }; + + const exportAggregateToExcel = async () => { + setAggregateExportBusy(true); + try { + const response = await fetch("/hanmac-browser/api/aggregate-export-jobs", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + ...getPayload(), + start_date: aggregateStartDate.value, + end_date: aggregateEndDate.value, + employment: aggregateEmployment.value, + view: currentAggregateView, + value_column: aggregateValueColumn.value, + value_search: aggregateValueSearch.value, + sort_key: currentAggregateSort.key, + sort_direction: currentAggregateSort.direction, + }), + }); + const payload = await response.json(); + if (!response.ok) { + throw new Error(payload.error || "집계 파일 준비 요청에 실패했습니다."); + } + if (payload.state === "ready" && payload.download_url) { + setAggregateExportBusy(false); + window.location.assign(payload.download_url); + return; + } + await pollAggregateExportJob(payload.job_key || ""); + } catch (error) { + setAggregateExportBusy(false); + alert(error.message || "집계 엑셀 다운로드 준비 중 오류가 발생했습니다."); + } }; const getFocusTitle = () => { @@ -1368,11 +1642,21 @@ currentPreviewColumns = []; currentPreviewRows = []; currentPreviewAllRows = []; + currentPreviewCursor = ""; + currentPreviewNextCursor = ""; + currentPreviewCursorHistory = []; previewValueColumn.innerHTML = ``; previewValueSearch.value = ""; previewValueOptions.innerHTML = ""; summaryColumns.textContent = "0"; summaryKey.textContent = "-"; + previewPrevButton.disabled = true; + previewNextButton.disabled = true; + }; + + const updatePreviewPager = () => { + previewPrevButton.disabled = !currentPreviewCursorHistory.length; + previewNextButton.disabled = !currentPreviewNextCursor; }; const setAggregateEmpty = (message) => { @@ -1403,6 +1687,37 @@ return number.toLocaleString("ko-KR", { maximumFractionDigits: 2 }); }; + const aggregateDayColumnMap = { + regular_hours: "regular_work_days", + overtime_hours: "overtime_work_days", + legal_leave_days: "legal_leave_days", + }; + const aggregateValueColumnMap = { + legal_leave_days: "legal_leave_hours", + }; + const aggregateNumericColumns = new Set([ + "member_count", + "regular_hours", + "overtime_hours", + "total_hours", + "legal_leave_days", + "project_count", + ]); + const aggregateDetailColumns = new Set([ + "regular_hours", + "overtime_hours", + "total_hours", + "legal_leave_days", + "project_count", + ]); + const aggregateDetailLabels = { + regular_hours: "정규근로", + overtime_hours: "연장근로", + total_hours: "총근로", + legal_leave_days: "법정휴가", + project_count: "프로젝트수", + }; + const escapeHtml = (value) => String(value ?? "") .replaceAll("&", "&") .replaceAll("<", "<") @@ -1458,9 +1773,13 @@ return columnsToRead.some((column) => getRowValueText(row, column).toLowerCase().includes(searchValue)); }); renderPreviewTableRows(currentPreviewColumns, filteredRows); - previewMeta.textContent = searchValue - ? `최근 ${Number(filteredRows.length).toLocaleString("ko-KR")}건 표시` - : `최근 ${Number(currentPreviewAllRows.length).toLocaleString("ko-KR")}건 표시`; + const countText = searchValue + ? `현재 ${Number(filteredRows.length).toLocaleString("ko-KR")}건 표시` + : `현재 ${Number(currentPreviewAllRows.length).toLocaleString("ko-KR")}건 표시`; + const pageStart = Number(currentPreviewCursor || 0) + 1; + const pageEnd = Number(currentPreviewCursor || 0) + Number(currentPreviewAllRows.length || 0); + const pageText = currentPreviewAllRows.length ? `${pageStart.toLocaleString("ko-KR")}~${pageEnd.toLocaleString("ko-KR")}행` : ""; + previewMeta.textContent = [countText, pageText].filter(Boolean).join(" / "); }; const renderAggregateValueOptions = () => { @@ -1481,17 +1800,86 @@ )).join(""); }; + const getAggregateSortValue = (row, key) => { + const valueKey = aggregateValueColumnMap[key] || key; + const value = row?.[valueKey]; + const numeric = Number(value); + if (value !== null && value !== "" && Number.isFinite(numeric)) return numeric; + return String(value ?? "").toLowerCase(); + }; + + const getSortedAggregateRows = (rows) => { + if (!currentAggregateSort.key) return rows; + const direction = currentAggregateSort.direction === "asc" ? 1 : -1; + return [...rows].sort((left, right) => { + const leftValue = getAggregateSortValue(left, currentAggregateSort.key); + const rightValue = getAggregateSortValue(right, currentAggregateSort.key); + if (typeof leftValue === "number" && typeof rightValue === "number") { + return (leftValue - rightValue) * direction; + } + return String(leftValue).localeCompare(String(rightValue), "ko", { numeric: true }) * direction; + }); + }; + + const getEquivalentProjectTitle = (row) => { + const equivalentCodes = Array.isArray(row?.equivalent_project_codes) ? row.equivalent_project_codes.filter(Boolean) : []; + return equivalentCodes.length ? equivalentCodes.join(", ") : ""; + }; + + const renderProjectCodeText = (row, fallbackKey = "project_code") => { + const text = row?.[fallbackKey] || "(미지정)"; + const title = getEquivalentProjectTitle(row); + return `${escapeHtml(text)}`; + }; + + const canOpenAggregateDetail = (row, columnKey) => { + if (!aggregateDetailColumns.has(columnKey)) return false; + const details = row?.aggregate_details?.[columnKey]; + return Array.isArray(details) && details.length > 0; + }; + + const renderAggregateMetricCell = (row, column, rowIndex, view) => { + const isNumeric = aggregateNumericColumns.has(column.key); + const valueKey = aggregateValueColumnMap[column.key] || column.key; + const valueText = formatNumberText(row[valueKey]); + const valueHtml = canOpenAggregateDetail(row, column.key) + ? `` + : `${valueText}`; + const dayKey = aggregateDayColumnMap[column.key]; + const dayValue = Number(row[dayKey] || 0); + const dayBadge = dayKey ? `${formatNumberText(dayValue)}일` : ""; + if (view === "member" && column.key === "regular_hours") { + const duplicateDays = Number(row.multi_entry_days || 0); + const detailButton = duplicateDays > 0 + ? `` + : ""; + return `
${valueHtml}${dayBadge}${detailButton}
`; + } + if (dayKey) { + return `
${valueHtml}${dayBadge}
`; + } + if (isNumeric) { + return `
${valueHtml}
`; + } + if (column.key === "project_code") { + return `${renderProjectCodeText(row)}`; + } + return `${row[column.key] == null ? "" : escapeHtml(String(row[column.key]))}`; + }; + const renderAggregateTableRows = (columns, rows, view) => { currentAggregateColumns = columns; currentAggregateRows = rows; - aggregateHead.innerHTML = `${columns.map((column) => `${column.label}`).join("")}`; + aggregateHead.innerHTML = `${columns.map((column) => { + const sortedClass = currentAggregateSort.key === column.key + ? ` is-sorted-${currentAggregateSort.direction}` + : ""; + const numericClass = aggregateNumericColumns.has(column.key) ? " class=\"is-numeric\"" : ""; + return ``; + }).join("")}`; aggregateBody.innerHTML = rows.map((row, rowIndex) => ( `${columns.map((column) => { - if (view === "member" && column.key === "regular_hours") { - const duplicateDays = Number(row.multi_entry_days || 0); - return `
${formatNumberText(row[column.key])}${duplicateDays > 0 ? `` : ""}
`; - } - return `${row[column.key] == null ? "" : escapeHtml(String(row[column.key]))}`; + return renderAggregateMetricCell(row, column, rowIndex, view); }).join("")}` )).join(""); aggregateEmpty.hidden = true; @@ -1509,7 +1897,7 @@ const columnsToRead = selectedColumn ? [selectedColumn] : currentAggregateColumns.map((column) => column.key); return columnsToRead.some((columnKey) => getRowValueText(row, columnKey).toLowerCase().includes(searchValue)); }); - renderAggregateTableRows(currentAggregateColumns, filteredRows, currentAggregateView); + renderAggregateTableRows(currentAggregateColumns, getSortedAggregateRows(filteredRows), currentAggregateView); }; const openMultiEntryModal = (row) => { @@ -1538,9 +1926,9 @@ ${(Array.isArray(detail.entries) ? detail.entries : []).map((entry) => ` - ${escapeHtml(entry.project_code || "(미지정)")} - ${escapeHtml(entry.project_name || entry.project_code || "(미지정)")} - ${formatNumberText(entry.regular_hours)} + ${renderProjectCodeText(entry)} + ${escapeHtml(entry.project_name || entry.project_code || "(미지정)")} + ${formatNumberText(entry.regular_hours)} `).join("")} @@ -1551,6 +1939,130 @@ multiEntryModal.hidden = false; }; + const renderDetailProjectList = (projects) => { + const list = Array.isArray(projects) ? projects : []; + if (!list.length) return ""; + return ` + + + + + + + + + + ${list.map((project) => ` + + + + + + `).join("")} + +
프로젝트코드프로젝트명시간
${renderProjectCodeText(project)}${escapeHtml(project.project_name || project.project_code || "(미지정)")}${formatNumberText(project.hours)}
+ `; + }; + + const renderAggregateDetailRows = (detailKey, details) => { + if (detailKey === "project_count") { + return ` + + + + + + + + + ${details.map((detail) => ` + + + + + `).join("")} + +
프로젝트코드프로젝트명
${renderProjectCodeText(detail)}${escapeHtml(detail.project_name || detail.project_code || "(미지정)")}
+ `; + } + if (detailKey === "legal_leave_days") { + return ` + + + + + + + + + + + ${details.map((detail) => ` + + + + + + + `).join("")} + +
일자구분일수시간
${escapeHtml(detail.work_date || "-")}${escapeHtml(detail.leave_type || "법정휴가")}${formatNumberText(detail.leave_days)}${formatNumberText(detail.leave_hours)}
+ `; + } + if (detailKey === "overtime_hours") { + return ` + + + + + + + + + + ${details.map((detail) => ` + + + + + + `).join("")} + +
일자${currentAggregateView === "project" ? "사원" : "프로젝트"}시간
${escapeHtml(detail.work_date || "-")}${escapeHtml(currentAggregateView === "project" ? `${detail.member_name || detail.member_no || ""} ${detail.member_no ? `(${detail.member_no})` : ""}`.trim() : `${detail.project_name || detail.project_code || "(미지정)"} ${detail.project_code ? `(${detail.project_code})` : ""}`.trim())}${formatNumberText(detail.overtime_hours)}
+ `; + } + return details.map((detail) => ` +
+
+ ${escapeHtml(detail.work_date || "-")} + ${detail.detail_type ? `${escapeHtml(detail.detail_type)}` : ""} + ${detail.member_name || detail.member_no ? `${escapeHtml(`${detail.member_name || detail.member_no || ""} ${detail.member_no ? `(${detail.member_no})` : ""}`.trim())}` : ""} + ${detail.regular_hours != null ? `정규 ${formatNumberText(detail.regular_hours)}시간` : ""} + ${detail.holiday_hours != null ? `휴일 ${formatNumberText(detail.holiday_hours)}시간` : ""} + ${detail.overtime_hours != null ? `연장 ${formatNumberText(detail.overtime_hours)}시간` : ""} + ${detail.raw_total_hours != null ? `원본 ${formatNumberText(detail.raw_total_hours)}시간` : ""} + ${detail.leave_days ? `휴가 ${formatNumberText(detail.leave_days)}일` : ""} + ${detail.leave_hours ? `휴가 ${formatNumberText(detail.leave_hours)}시간` : ""} +
+ ${renderDetailProjectList(detail.projects)} +
+ `).join(""); + }; + + const openAggregateDetailModal = (row, detailKey) => { + const details = Array.isArray(row?.aggregate_details?.[detailKey]) ? row.aggregate_details[detailKey] : []; + const label = aggregateDetailLabels[detailKey] || detailKey; + const owner = currentAggregateView === "project" + ? `${row?.project_name || row?.project_code || "프로젝트"}` + : `${row?.member_name || row?.member_no || "사원"}`; + multiEntryTitle.textContent = `${owner} ${label} 상세`; + multiEntryMeta.textContent = `${Number(details.length).toLocaleString("ko-KR")}건 · ${label} ${formatNumberText(row?.[detailKey])}`; + multiEntryBody.innerHTML = details.length + ? renderAggregateDetailRows(detailKey, details) + : `
상세 내역이 없습니다.
`; + multiEntryModal.hidden = false; + }; + const updateSelectedSummary = (tableItem, columns = []) => { if (!tableItem) { selectedTitle.textContent = `${getFocusTitle()} · 선택 대기`; @@ -1590,6 +2102,9 @@ button.addEventListener("click", () => { selectedSchema = item.schema; selectedTable = item.name; + currentPreviewCursor = ""; + currentPreviewNextCursor = ""; + currentPreviewCursorHistory = []; renderTableList(); loadPreview(); }); @@ -1609,12 +2124,20 @@ return; } currentPreviewAllRows = rows; + currentPreviewCursor = String(payload.cursor || ""); + currentPreviewNextCursor = String(payload.next_cursor || ""); previewValueColumn.innerHTML = `${columns.map((column) => ``).join("")}`; previewValueSearch.value = ""; renderPreviewValueOptions(); renderPreviewTableRows(columns, rows); - previewMeta.textContent = `최근 ${Number(payload.shown_count || 0).toLocaleString("ko-KR")}건 표시`; + const shownText = `현재 ${Number(payload.shown_count || 0).toLocaleString("ko-KR")}건 표시`; + const pageStart = Number(payload.cursor || 0) + 1; + const pageEnd = Number(payload.cursor || 0) + Number(payload.shown_count || 0); + const pageText = Number(payload.shown_count || 0) > 0 ? `${pageStart.toLocaleString("ko-KR")}~${pageEnd.toLocaleString("ko-KR")}행` : ""; + const pendingText = payload.cache_meta?.pending_refresh ? " · 캐시 갱신 중" : ""; + previewMeta.textContent = [shownText, pageText].filter(Boolean).join(" / ") + pendingText; updateSelectedSummary(activeItem, columns); + updatePreviewPager(); }; const renderAggregate = (payload) => { @@ -1627,26 +2150,35 @@ aggregateRegularHours.textContent = Number(summary.regular_hours || 0).toLocaleString("ko-KR"); aggregateOvertimeHours.textContent = Number(summary.overtime_hours || 0).toLocaleString("ko-KR"); aggregateTotalHours.textContent = Number(summary.total_hours || 0).toLocaleString("ko-KR"); - aggregateLeaveDays.textContent = Number(summary.legal_leave_days || 0).toLocaleString("ko-KR"); + aggregateLeaveDays.textContent = Number(summary.legal_leave_hours || 0).toLocaleString("ko-KR"); aggregateProjectCount.textContent = Number(summary.project_count || 0).toLocaleString("ko-KR"); - aggregateMeta.textContent = `${payload.start_date || ""} ~ ${payload.end_date || ""}`; + const diagnostics = payload.source_diagnostics || {}; + const diagnosticText = diagnostics + ? ` · HolidayTime ${Number(diagnostics.holiday_time_rows || 0).toLocaleString("ko-KR")}행 · 휴가 ${Number(diagnostics.leave_matched_rows || 0).toLocaleString("ko-KR")}/${Number(diagnostics.tardy_candidate_rows || 0).toLocaleString("ko-KR")}행` + : ""; + aggregateMeta.textContent = `${payload.start_date || ""} ~ ${payload.end_date || ""}${diagnosticText}`; if (!columns.length || !rows.length) { setAggregateEmpty(""); return; } + if (currentAggregateSort.key && !columns.some((column) => column.key === currentAggregateSort.key)) { + currentAggregateSort = { key: "", direction: "desc" }; + } aggregateValueColumn.innerHTML = `${columns.map((column) => ``).join("")}`; aggregateValueSearch.value = ""; renderAggregateValueOptions(); - renderAggregateTableRows(columns, rows, payload.view || "member"); + renderAggregateTableRows(columns, getSortedAggregateRows(rows), payload.view || "member"); }; - const loadPreview = async () => { + const loadPreview = async (cursor = "", preserveHistory = false) => { if (!selectedTable || !selectedSchema) { setPreviewEmpty(""); previewMeta.textContent = ""; return; } refreshPreviewButton.disabled = true; + previewPrevButton.disabled = true; + previewNextButton.disabled = true; previewMeta.textContent = "조회 중..."; try { const response = await fetch("/hanmac-browser/api/preview", { @@ -1657,18 +2189,23 @@ schema: selectedSchema, table: selectedTable, limit: previewLimit.value, + cursor, }), }); const payload = await response.json(); if (!response.ok || payload.status !== "ok") { throw new Error(payload.message || "테이블 미리보기에 실패했습니다."); } + if (!preserveHistory) { + currentPreviewCursorHistory = cursor ? [...currentPreviewCursorHistory, currentPreviewCursor] : []; + } renderPreview(payload); } catch (error) { setPreviewEmpty(error.message || "테이블 미리보기 중 오류가 발생했습니다."); previewMeta.textContent = "조회 실패"; } finally { refreshPreviewButton.disabled = false; + updatePreviewPager(); } }; @@ -1749,12 +2286,31 @@ tableSearch.addEventListener("input", renderTableList); aggregateBody.addEventListener("click", (event) => { + const detailButton = event.target.closest("[data-aggregate-detail-index]"); + if (detailButton) { + const rowIndex = Number(detailButton.dataset.aggregateDetailIndex || -1); + const detailKey = detailButton.dataset.aggregateDetailKey || ""; + if (rowIndex >= 0 && rowIndex < currentAggregateRows.length && detailKey) { + openAggregateDetailModal(currentAggregateRows[rowIndex], detailKey); + } + return; + } const button = event.target.closest("[data-aggregate-duplicate-index]"); if (!button || currentAggregateView !== "member") return; const rowIndex = Number(button.dataset.aggregateDuplicateIndex || -1); if (rowIndex < 0 || rowIndex >= currentAggregateRows.length) return; openMultiEntryModal(currentAggregateRows[rowIndex]); }); + aggregateHead.addEventListener("click", (event) => { + const button = event.target.closest("[data-aggregate-sort]"); + if (!button) return; + const key = button.dataset.aggregateSort || ""; + if (!key) return; + currentAggregateSort = currentAggregateSort.key === key + ? { key, direction: currentAggregateSort.direction === "asc" ? "desc" : "asc" } + : { key, direction: "desc" }; + applyAggregateValueFilter(); + }); schemaTabs.forEach((button) => { button.addEventListener("click", () => { @@ -1769,9 +2325,6 @@ focusFilter = button.dataset.focus || "all"; focusButtons.forEach((item) => item.classList.toggle("is-active", item === button)); renderTableList(); - if (getPayload().password) { - loadAggregate().catch(() => {}); - } }); }); @@ -1799,8 +2352,19 @@ loadTablesButton.addEventListener("click", loadTables); aggregateLoadButton.addEventListener("click", loadAggregate); - refreshPreviewButton.addEventListener("click", loadPreview); + refreshPreviewButton.addEventListener("click", () => loadPreview(currentPreviewCursor, true)); + previewPrevButton.addEventListener("click", async () => { + if (!currentPreviewCursorHistory.length) return; + const previousCursor = currentPreviewCursorHistory[currentPreviewCursorHistory.length - 1] || ""; + currentPreviewCursorHistory = currentPreviewCursorHistory.slice(0, -1); + await loadPreview(previousCursor, true); + }); + previewNextButton.addEventListener("click", async () => { + if (!currentPreviewNextCursor) return; + await loadPreview(currentPreviewNextCursor); + }); exportPreviewButton.addEventListener("click", exportPreviewToExcel); + exportAggregateButton.addEventListener("click", exportAggregateToExcel); aggregateValueColumn.addEventListener("change", () => { renderAggregateValueOptions(); applyAggregateValueFilter(); @@ -1849,11 +2413,7 @@ aggregateStartDate.value = startOfYear.toISOString().slice(0, 10); aggregateEndDate.value = today.toISOString().slice(0, 10); - const hasSavedPassword = restoreCredentials(); - if (hasSavedPassword) { - loadTables().catch(() => {}); - loadAggregate().catch(() => {}); - } + restoreCredentials(); })(); {% endblock %} diff --git a/templates/process_cost.html b/templates/process_cost.html index 1068955..7b55f1c 100644 --- a/templates/process_cost.html +++ b/templates/process_cost.html @@ -1048,47 +1048,27 @@
- {% endblock %} {% block script %} {% endblock %} diff --git a/templates/projects.html b/templates/projects.html index 2bc351f..4ae0ea4 100644 --- a/templates/projects.html +++ b/templates/projects.html @@ -3666,20 +3666,23 @@ const serverFocusCode = {{ project_focus_code | tojson }}; const projectEditCache = new Map(); let modalOriginalEdit = null; - const revenueMix = {{ project_revenue_mix | tojson }}; + let revenueMix = []; const revenueMixMode = {{ ("monthly" if selected_year else "yearly") | tojson }}; - const projectCostRows = {{ project_cost_by_year | tojson }}; + let projectCostRows = []; const projectMonthlyCostRows = {{ project_monthly_cost_rows | tojson }}; - const projectStatusRows = {{ project_status_rows | tojson }}; + let projectStatusRows = []; const projectComparisonNotes = {{ project_comparison_notes | tojson }}; const projectAnalysisSettings = {{ project_analysis_settings | tojson }}; const persistedProjectPageState = {{ project_page_state | tojson }}; const persistedProjectRelatedLinks = {{ project_related_links | tojson }}; const persistedUncontractedCategoryOverrides = {{ project_uncontracted_classifications | tojson }}; const projectPageSessionId = window.clientSessionId || ""; - const projectStatusMap = Object.fromEntries(projectStatusRows.map((item) => [item.support_dept_code, item])); - const projectAccountBreakdowns = {{ project_account_breakdowns | tojson }}; - const projectCostMap = Object.fromEntries(projectCostRows.map((item) => [item.support_dept_code, item])); + let projectStatusMap = {}; + let projectAccountBreakdowns = {}; + let projectAccountBreakdownsLoaded = false; + let projectAccountBreakdownsLoadedCodes = new Set(); + let projectAccountBreakdownsPromise = null; + let projectCostMap = {}; const currencyFormatter = new Intl.NumberFormat("ko-KR", { maximumFractionDigits: 0 }); window.__projectRelatedSelections = new Map(); const persistedUncontractedFilterState = { @@ -4586,6 +4589,7 @@ } else { projectStatusRows.push(projectRow); } + refreshProjectDerivedCaches(); } function createCollectionRow(row = {}) { @@ -6126,6 +6130,126 @@ return ""; } + function buildProjectBreakdownRequestCodes(baseCode = "") { + const normalizedBaseCode = String(baseCode || selectedCode || "").trim(); + if (!normalizedBaseCode) { + return []; + } + const codeSet = new Set([normalizedBaseCode]); + getRelatedCodes(normalizedBaseCode).forEach((code) => { + const normalizedCode = String(code || "").trim(); + if (normalizedCode) { + codeSet.add(normalizedCode); + } + }); + return [...codeSet]; + } + + function hasProjectBreakdownDataForCodes(codes = []) { + const requestedCodes = Array.isArray(codes) + ? codes.map((code) => String(code || "").trim()).filter(Boolean) + : []; + if (!requestedCodes.length) { + return projectAccountBreakdownsLoaded; + } + return requestedCodes.every((code) => projectAccountBreakdownsLoadedCodes.has(code)); + } + + async function loadProjectAccountBreakdowns(force = false, codes = []) { + const requestedCodes = Array.isArray(codes) + ? codes.map((code) => String(code || "").trim()).filter(Boolean) + : []; + const needsFullLoad = !requestedCodes.length; + if (!force) { + if (needsFullLoad && projectAccountBreakdownsLoaded) { + return projectAccountBreakdowns; + } + if (!needsFullLoad && requestedCodes.every((code) => projectAccountBreakdownsLoadedCodes.has(code))) { + return projectAccountBreakdowns; + } + if (projectAccountBreakdownsPromise) { + await projectAccountBreakdownsPromise; + if (needsFullLoad && projectAccountBreakdownsLoaded) { + return projectAccountBreakdowns; + } + if (!needsFullLoad && requestedCodes.every((code) => projectAccountBreakdownsLoadedCodes.has(code))) { + return projectAccountBreakdowns; + } + } + } + const codesToLoad = force || needsFullLoad + ? requestedCodes + : requestedCodes.filter((code) => !projectAccountBreakdownsLoadedCodes.has(code)); + const queryParams = new URLSearchParams(); + if (selectedYear) { + queryParams.set("year", String(selectedYear)); + } + if (codesToLoad.length) { + queryParams.set("codes", codesToLoad.join(",")); + } + const query = queryParams.toString() ? `?${queryParams.toString()}` : ""; + projectAccountBreakdownsPromise = fetch(`/projects/account-breakdowns${query}`, { + method: "GET", + credentials: "same-origin", + headers: { + "Accept": "application/json", + }, + }).then(async (response) => { + const payload = await response.json().catch(() => ({})); + if (!response.ok || payload?.error) { + throw new Error(payload?.error || `HTTP ${response.status}`); + } + const nextPayload = payload || {}; + if (needsFullLoad) { + projectAccountBreakdowns = nextPayload; + projectAccountBreakdownsLoaded = true; + projectAccountBreakdownsLoadedCodes = new Set(Object.keys(nextPayload || {})); + } else { + projectAccountBreakdowns = { + ...projectAccountBreakdowns, + ...nextPayload, + }; + codesToLoad.forEach((code) => { + projectAccountBreakdownsLoadedCodes.add(code); + }); + } + return projectAccountBreakdowns; + }).catch((error) => { + console.error("프로젝트 계정 분해 조회 에러", error); + if (needsFullLoad) { + projectAccountBreakdowns = {}; + projectAccountBreakdownsLoaded = false; + projectAccountBreakdownsLoadedCodes = new Set(); + } + throw error; + }).finally(() => { + projectAccountBreakdownsPromise = null; + }); + return projectAccountBreakdownsPromise; + } + + async function loadProjectBootstrapData(force = false) { + if (!force && projectStatusRows.length && projectCostRows.length) { + return; + } + const query = selectedYear ? `?year=${encodeURIComponent(String(selectedYear))}` : ""; + const response = await fetch(`/projects/bootstrap-data${query}`, { + method: "GET", + credentials: "same-origin", + headers: { + "Accept": "application/json", + }, + }); + const payload = await response.json().catch(() => ({})); + if (!response.ok || payload?.error) { + throw new Error(payload?.error || `HTTP ${response.status}`); + } + revenueMix = Array.isArray(payload?.revenue_mix) ? payload.revenue_mix : []; + projectCostRows = Array.isArray(payload?.project_cost_by_year) ? payload.project_cost_by_year : []; + projectStatusRows = Array.isArray(payload?.project_status_rows) ? payload.project_status_rows : []; + refreshProjectDerivedCaches(); + } + function getBreakdownList(code, kind) { const item = projectAccountBreakdowns?.[code] || {}; return Array.isArray(item?.[kind]) ? item[kind] : []; @@ -7934,10 +8058,18 @@ } const aggregatedProjectCostDatasetCache = new Map(); - const aggregatedAllProjectCostRows = mergeProjectExplorerRows(projectCostRows, projectStatusRows); - const aggregatedAllProjectCostMap = new Map( - aggregatedAllProjectCostRows.map((item) => [item.support_dept_code, item]), - ); + let aggregatedAllProjectCostRows = []; + let aggregatedAllProjectCostMap = new Map(); + + function refreshProjectDerivedCaches() { + projectStatusMap = Object.fromEntries(projectStatusRows.map((item) => [item.support_dept_code, item])); + projectCostMap = Object.fromEntries(projectCostRows.map((item) => [item.support_dept_code, item])); + aggregatedProjectCostDatasetCache.clear(); + aggregatedAllProjectCostRows = mergeProjectExplorerRows(projectCostRows, projectStatusRows); + aggregatedAllProjectCostMap = new Map( + aggregatedAllProjectCostRows.map((item) => [item.support_dept_code, item]), + ); + } function getAggregatedProjectCostDataset(yearFilterValue) { const cacheKey = String(yearFilterValue || ""); @@ -8950,6 +9082,36 @@ scheduleStandaloneUncontractedDashboardRender(); return; } + const requestedBreakdownCodes = buildProjectBreakdownRequestCodes(item.support_dept_code); + if (!hasProjectBreakdownDataForCodes(requestedBreakdownCodes)) { + relatedBarBox.innerHTML = ""; + heroBox.innerHTML = renderAnalysisHero(item); + metricsBox.innerHTML = renderAnalysisMetrics(item); + comparisonBox.innerHTML = ` +
+

프로젝트 세부 집계 데이터를 불러오는 중입니다...

+
+ `; + notesBox.innerHTML = ` +
+

프로젝트 세부 집계 데이터를 준비한 뒤 표시합니다.

+
+ `; + loadProjectAccountBreakdowns(false, requestedBreakdownCodes).then(() => { + const latestItem = getAnalysisItem(item.support_dept_code); + if (latestItem && String(currentSelectedProjectCode || "") === String(item.support_dept_code || "")) { + renderAnalysis(latestItem); + } + }).catch(() => { + comparisonBox.innerHTML = ` +
+

프로젝트 세부 집계 데이터를 불러오지 못했습니다. 잠시 후 다시 시도해 주세요.

+
+ `; + }); + scheduleStandaloneUncontractedDashboardRender(); + return; + } const baseCode = String(item.support_dept_code || "").trim(); const allRelatedItems = getRelatedItems(item.support_dept_code, { includeInactive: true }); const relatedItems = getRelatedItems(item.support_dept_code); @@ -9502,6 +9664,11 @@ (async () => { await loadProjectPageStateFromServer(); await loadProjectQuickLinksFromServer(); + try { + await loadProjectBootstrapData(); + } catch (error) { + console.error("프로젝트 부트스트랩 데이터 조회 에러", error); + } if (serverEditCode) { selectedCode = serverEditCode; currentSelectedProjectCode = serverEditCode; diff --git a/templates/wehago_compare.html b/templates/wehago_compare.html index 2e2a175..2be8a02 100644 --- a/templates/wehago_compare.html +++ b/templates/wehago_compare.html @@ -16,7 +16,7 @@ gap: 12px; flex-wrap: wrap; padding-bottom: 14px; - border-bottom: 1px solid var(--line); + border-bottom: 0; } .voucher-title-wrap h2 { @@ -947,6 +947,38 @@ align-items: start; } + .voucher-detail-wrap { + max-height: 65vh; + overflow: auto; + border: 1px solid var(--line); + border-radius: 12px; + background: #fff; + } + + .voucher-detail-wrap table { + width: 100%; + border-collapse: collapse; + font-size: 13px; + } + + .voucher-detail-wrap th, + .voucher-detail-wrap td { + padding: 10px 12px; + border-bottom: 1px solid #eef2f7; + text-align: left; + vertical-align: top; + white-space: nowrap; + } + + .voucher-detail-wrap td.cell-desc, + .voucher-detail-wrap th.cell-desc { + white-space: normal; + } + + .voucher-summary-action { + min-width: 64px; + } + .bridge-card { border: 1px solid var(--line); border-radius: 10px; @@ -1439,10 +1471,7 @@
-

현황

-
- 기간 {{ wehago_compare.selected_start_year or '-' }} ~ {{ wehago_compare.selected_end_year or '-' }} -
+

기간 {{ wehago_compare.selected_start_year or '-' }} ~ {{ wehago_compare.selected_end_year or '-' }}

@@ -1613,6 +1642,21 @@
+ +