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 @@ +프로젝트 세부 집계 데이터를 불러오는 중입니다...
+프로젝트 세부 집계 데이터를 준비한 뒤 표시합니다.
+프로젝트 세부 집계 데이터를 불러오지 못했습니다. 잠시 후 다시 시도해 주세요.
+