import copy import base64 import os import logging import hashlib import hmac import json import math import re import secrets import shutil import sqlite3 import subprocess import sys import threading import time import tempfile import uuid import zipfile from io import BytesIO from datetime import date, datetime, timedelta, timezone from decimal import Decimal, InvalidOperation, ROUND_HALF_UP from functools import lru_cache from pathlib import Path from typing import Any, Mapping, Sequence from urllib.parse import parse_qs, quote_plus, unquote_plus import uvicorn from fastapi import FastAPI, File, Request, UploadFile from fastapi.encoders import jsonable_encoder from fastapi.responses import FileResponse, HTMLResponse, JSONResponse, RedirectResponse, Response from fastapi.staticfiles import StaticFiles from fastapi.templating import Jinja2Templates from openpyxl import Workbook, load_workbook from openpyxl.styles import Alignment, Font, PatternFill from openpyxl.utils import get_column_letter 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 starlette.middleware.gzip import GZipMiddleware from datasette.app import Datasette from runtime_config import ( BACKUP_DIR, DB_PATH, HANMAC_EXPORT_DIR, WAL_BLOCK_HEAVY_BYTES, ensure_runtime_directories, validate_sqlite_runtime, ) from wehago_compare import ( QUERY_PROJECTION_VERSION, _clear_compare_runtime_caches, _group_has_offset_tax_invoice_structure, _group_has_tax_invoice_cancel_signal, _load_hanmac_unconnected_source_groups, _offset_group_vector, _offset_vectors_cancel_each_other, _voucher_groups_within_days, apply_wehago_final_status_counts_to_metric_sections, cleanup_compare_runtime_artifacts, enqueue_default_pair_recommend_precompute, export_wehago_status_rows_xlsx, get_erp_filtered_rows, get_compare_snapshot_status, get_individual_pair_recommendations, get_last_action_summary, get_status_export_job, get_status_field_suggestions, get_status_detail_rows, get_dashboard_metric_counts_nonblocking, get_wehago_compare_dashboard, get_wehago_compare_summary, get_wehago_final_status_summary_from_conn, get_wehago_filtered_rows, import_uploaded_erp_voucher_file, init_wehago_compare_db, load_bridge_review_settings, request_compare_snapshot_rebuild, request_status_export_xlsx, recommend_pair_matches, save_bridge_review_settings, save_recommended_pair_matches, save_manual_pair_matches, save_recheck_change_rows, save_recheck_review_rows, undo_last_action, ) logging.basicConfig(level=logging.INFO) logger = logging.getLogger(__name__) _HEALTH_PAYLOAD_CACHE: dict[str, Any] = { "expires_at": 0.0, "payload": None, } _DB_BACKUP_STATE: dict[str, Any] = { "last_run_at": 0.0, } _DB_BACKUP_LOCK = threading.Lock() _DB_INIT_LOCK = threading.Lock() _DB_INIT_DONE = False _DB_ANALYZE_LOCK = threading.Lock() _DB_ANALYZE_LAST_ATTEMPT_AT = 0.0 _DB_VACUUM_LOCK = threading.Lock() _DB_VACUUM_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 _SYSTEM_JOB_EVENT = threading.Event() _SYSTEM_JOB_WORKER_LOCK = threading.Lock() _SYSTEM_JOB_WORKER_STARTED = False _SYSTEM_JOB_WORKER_THREAD: threading.Thread | None = None _SYSTEM_JOB_LAST_STALE_CLEANUP_AT = 0.0 _APP_POST_STARTUP_WARMUP_LOCK = threading.Lock() _APP_POST_STARTUP_WARMUP_STARTED = False SYSTEM_JOB_STALE_RUNNING_SECONDS = 2 * 60 * 60 AUTH_COOKIE_NAME = "hm_session" AUTH_SESSION_SECONDS = int(os.getenv("HM_AUTH_SESSION_SECONDS", "28800") or "28800") AUTH_PBKDF2_ITERATIONS = 260000 AUTH_LAST_SEEN_UPDATE_SECONDS = int(os.getenv("HM_AUTH_LAST_SEEN_UPDATE_SECONDS", "300") or "300") AUTH_SECRET = os.getenv("HM_AUTH_SECRET", "") if not AUTH_SECRET: AUTH_SECRET = hashlib.sha256(str(DB_PATH).encode("utf-8")).hexdigest() AUTH_PUBLIC_PATHS = {"/login", "/logout", "/health", "/healthz"} AUTH_PERMISSION_BY_PREFIX = { "/annual-summary": "annual_summary", "/static/hm-biz-process": "biz_process", "/biz-process-viewer": "biz_process", "/biz-process": "biz_process", "/process-cost": "process_cost", "/cost-analysis": "cost_analysis", "/projects": "projects", "/wehago-benefit-entertainment": "wehago_compare", "/wehago-compare": "wehago_compare", "/hanmac-browser": "hanmac_browser", "/db-browser": "db_browser", "/db": "db_browser", "/admin": "admin", } AUTH_NAV_ITEMS = [ {"href": "/", "label": "대시보드", "permission": "dashboard", "active": "exact"}, {"href": "/annual-summary", "label": "연도별 수익/비용", "permission": "annual_summary", "active": "exact"}, {"href": "/biz-process", "label": "HM-BIZ-PROCESS", "permission": "biz_process", "active": "exact"}, {"href": "/process-cost", "label": "프로젝트 원가", "permission": "process_cost", "active": "exact"}, {"href": "/cost-analysis", "label": "프로젝트 손익분석", "permission": "cost_analysis", "active": "exact"}, {"href": "/projects", "label": "프로젝트 정보", "permission": "projects", "active": "exact"}, {"href": "/wehago-compare", "label": "전표비교", "permission": "wehago_compare", "active": "exact"}, {"href": "/wehago-benefit-entertainment", "label": "복리/접대비", "permission": "wehago_compare", "active": "exact"}, {"href": "/hanmac-browser", "label": "hanmac DB_external", "permission": "hanmac_browser", "active": "exact"}, {"href": "/db-browser", "label": "DB 조회", "permission": "db_browser", "active": "db"}, {"href": "/admin/users", "label": "사용자 관리", "permission": "admin", "active": "admin"}, ] DB_BACKUP_MIN_INTERVAL_SECONDS = 900.0 DB_BACKUP_KEEP_COUNT = 24 DB_ANALYZE_MIN_INTERVAL_SECONDS = 6 * 60 * 60 DB_VACUUM_MIN_INTERVAL_SECONDS = 24 * 60 * 60 DB_VACUUM_FREELIST_MIN_BYTES = int(os.getenv("HM_AUTO_VACUUM_FREELIST_MIN_BYTES", str(2 * 1024 * 1024 * 1024))) DB_VACUUM_WINDOW_START_HOUR = int(os.getenv("HM_AUTO_VACUUM_WINDOW_START_HOUR", "2") or "2") DB_VACUUM_WINDOW_END_HOUR = int(os.getenv("HM_AUTO_VACUUM_WINDOW_END_HOUR", "5") or "5") 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 _PROJECT_BOOTSTRAP_CACHE_LOCK = threading.Lock() _PROJECT_BOOTSTRAP_CACHE: dict[tuple[Any, ...], dict[str, Any]] = {} PROJECT_BOOTSTRAP_CACHE_TTL_SECONDS = 300.0 _PROJECT_ACCOUNT_BREAKDOWN_CACHE_LOCK = threading.Lock() _PROJECT_ACCOUNT_BREAKDOWN_CACHE: dict[tuple[Any, ...], dict[str, Any]] = {} PROJECT_ACCOUNT_BREAKDOWN_CACHE_TTL_SECONDS = 300.0 _COST_ANALYSIS_PAYLOAD_CACHE_LOCK = threading.Lock() _COST_ANALYSIS_PAYLOAD_CACHE: dict[tuple[Any, ...], dict[str, Any]] = {} COST_ANALYSIS_PAYLOAD_CACHE_TTL_SECONDS = 90.0 COST_ANALYSIS_HANMAC_AGGREGATE_SCHEMA = "cost-analysis-period-v13-cumulative-profit-rate" app = FastAPI() app.add_middleware(GZipMiddleware, minimum_size=1200, compresslevel=5) BASE_DIR = Path(__file__).resolve().parent STATIC_DIR = BASE_DIR / "static" TEMPLATES_DIR = BASE_DIR / "templates" HMBIZ_PROCESS_STATIC_DIR = STATIC_DIR / "hm-biz-process" HMBIZ_PROCESS_SEED_DB_PATH = BASE_DIR / "storage" / "hm-biz-process" / "flow.db" HMBIZ_PROCESS_DB_PATH = Path(os.getenv("HMBIZ_PROCESS_DB_PATH", str(HMBIZ_PROCESS_SEED_DB_PATH))) STATIC_DIR.mkdir(exist_ok=True) ensure_runtime_directories() 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, "timeout": 30}, ) _DEFAULT_HANMAC_HOLIDAY_LINES = """ 2018-01-01|새해 2018-02-15|설날 2018-02-16|설날 2018-02-17|설날 2018-03-01|삼일절 2018-05-05|어린이날 2018-05-07|대체휴일 2018-05-22|부처님오신날 2018-06-06|현충일 2018-06-13|지방선거 2018-08-15|광복절 2018-09-23|추석 2018-09-24|추석 2018-09-25|추석 2018-09-26|대체휴일 2018-10-03|개천절 2018-10-09|한글날 2018-12-25|크리스마스 2019-01-01|새해 2019-02-04|설날 2019-02-05|설날 2019-02-06|설날 2019-03-01|삼일절 2019-05-05|어린이날 2019-05-06|대체휴일 2019-05-12|부처님오신날 2019-06-06|현충일 2019-08-15|광복절 2019-09-12|추석 2019-09-13|추석 2019-09-14|추석 2019-10-03|개천절 2019-10-09|한글날 2019-12-25|크리스마스 2020-01-01|새해 2020-01-24|설날 2020-01-25|설날 2020-01-26|설날 2020-01-27|설날대체휴일 2020-03-01|삼일절 2020-04-15|국회의원선거 2020-04-30|부처님오신날 2020-05-05|어린이날 2020-06-06|현충일 2020-08-15|광복절 2020-08-17|임시공휴일 2020-09-30|추석 2020-10-01|추석 2020-10-02|추석 2020-10-03|개천절 2020-10-09|한글날 2020-12-25|크리스마스 2021-01-01|새해 2021-02-11|설날 2021-02-12|설날 2021-02-13|설날 2021-03-01|삼일절 2021-05-05|어린이날 2021-05-19|부처님오신날 2021-06-06|현충일 2021-08-15|광복절 2021-08-16|대체휴일 2021-09-20|추석 2021-09-21|추석 2021-09-22|추석 2021-10-03|개천절 2021-10-04|대체휴일 2021-10-09|한글날 2021-10-11|대체휴일 2021-12-25|크리스마스 2022-01-01|1월1일 2022-01-31|설날 2022-02-01|설날 2022-02-02|설날 2022-03-01|삼일절 2022-03-09|대통령선거일 2022-05-05|어린이날 2022-05-08|부처님오신날 2022-06-01|전국동시지방선거 2022-06-06|현충일 2022-08-15|광복절 2022-09-09|추석 2022-09-10|추석 2022-09-11|추석 2022-09-12|대체공휴일 2022-10-03|개천절 2022-10-09|한글날 2022-10-10|대체공휴일 2022-12-25|기독탄신일 2023-01-01|1월1일 2023-01-21|설날 2023-01-22|설날 2023-01-23|설날 2023-01-24|대체공휴일 2023-03-01|삼일절 2023-05-05|어린이날 2023-05-27|부처님오신날 2023-05-29|대체공휴일 2023-06-06|현충일 2023-08-15|광복절 2023-09-28|추석 2023-09-29|추석 2023-09-30|추석 2023-10-02|임시공휴일 2023-10-03|개천절 2023-10-09|한글날 2023-12-25|기독탄신일 2024-01-01|1월1일 2024-02-09|설날 2024-02-10|설날 2024-02-11|설날 2024-02-12|대체공휴일(설날) 2024-03-01|삼일절 2024-04-10|국회의원선거 2024-05-05|어린이날 2024-05-06|대체공휴일(어린이날) 2024-05-15|부처님오신날 2024-06-06|현충일 2024-08-15|광복절 2024-09-16|추석 2024-09-17|추석 2024-09-18|추석 2024-10-01|임시공휴일 2024-10-03|개천절 2024-10-09|한글날 2024-12-25|기독탄신일 2025-01-01|1월1일 2025-01-27|임시공휴일 2025-01-28|설날 2025-01-29|설날 2025-01-30|설날 2025-03-01|삼일절 2025-03-03|대체공휴일 2025-05-05|어린이날 / 부처님오신날 2025-05-06|대체공휴일 2025-06-06|현충일 2025-08-15|광복절 2025-10-03|개천절 2025-10-05|추석 2025-10-06|추석 2025-10-07|추석 2025-10-08|대체공휴일 2025-10-09|한글날 2025-12-25|기독탄신일 2026-01-01|1월1일 2026-02-16|설날 2026-02-17|설날 2026-02-18|설날 2026-03-01|삼일절 2026-03-02|대체공휴일(삼일절) 2026-05-05|어린이날 2026-05-24|부처님오신날 2026-05-25|대체공휴일(부처님오신날) 2026-06-03|전국동시지방선거 2026-06-06|현충일 2026-08-15|광복절 2026-08-17|대체공휴일(광복절) 2026-09-24|추석 2026-09-25|추석 2026-09-26|추석 2026-10-03|개천절 2026-10-05|대체공휴일(개천절) 2026-10-09|한글날 2026-12-25|기독탄신일 """.strip() def _hanmac_default_holiday_type(holiday_name: str) -> str: if "대체" in holiday_name: return "substitute" if "임시" in holiday_name or "선거" in holiday_name: return "company" return "legal" def _seed_default_hanmac_holidays(conn: Any) -> None: existing_count = conn.execute(text("SELECT COUNT(*) FROM hanmac_holidays")).scalar() or 0 if existing_count: return rows: list[dict[str, str]] = [] for line in _DEFAULT_HANMAC_HOLIDAY_LINES.splitlines(): holiday_date, holiday_name = [part.strip() for part in line.split("|", 1)] rows.append( { "holiday_date": holiday_date, "holiday_name": holiday_name, "holiday_type": _hanmac_default_holiday_type(holiday_name), "memo": "기본 휴무일 기준(사용자 제공 2018년 이후)", } ) conn.execute( text( """ INSERT OR IGNORE INTO hanmac_holidays ( holiday_date, holiday_name, holiday_type, memo, created_at, updated_at ) VALUES ( :holiday_date, :holiday_name, :holiday_type, :memo, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP ) """ ), rows, ) DEFAULT_HANMAC_LEAVE_RULES: tuple[dict[str, Any], ...] = ( { "keyword": "시차", "leave_label": "시차", "rule_type": "explicit_hours", "default_hours": 0.0, "priority": 10, "memo": "개별 입력 시간/분 또는 값 컬럼을 시간으로 반영", }, { "keyword": "반차", "leave_label": "반차", "rule_type": "fixed_hours", "default_hours": 4.0, "priority": 20, "memo": "명시 시간이 없으면 4시간", }, { "keyword": "연차", "leave_label": "연차", "rule_type": "full_day", "default_hours": 8.0, "priority": 30, "memo": "명시 시간이 없으면 1일 8시간", }, { "keyword": "포상", "leave_label": "포상휴가", "rule_type": "full_day", "default_hours": 8.0, "priority": 40, "memo": "명시 시간이 없으면 1일 8시간", }, { "keyword": "대휴", "leave_label": "대체휴가", "rule_type": "full_day", "default_hours": 8.0, "priority": 50, "memo": "명시 시간이 없으면 1일 8시간", }, { "keyword": "대체", "leave_label": "대체휴가", "rule_type": "full_day", "default_hours": 8.0, "priority": 60, "memo": "명시 시간이 없으면 1일 8시간", }, { "keyword": "보상", "leave_label": "보상휴가", "rule_type": "full_day", "default_hours": 8.0, "priority": 70, "memo": "명시 시간이 없으면 1일 8시간", }, { "keyword": "휴가", "leave_label": "휴가", "rule_type": "full_day", "default_hours": 8.0, "priority": 80, "memo": "명시 시간이 없으면 1일 8시간", }, { "keyword": "공가", "leave_label": "공가", "rule_type": "full_day", "default_hours": 8.0, "priority": 90, "memo": "명시 시간이 없으면 1일 8시간", }, { "keyword": "병가", "leave_label": "병가", "rule_type": "full_day", "default_hours": 8.0, "priority": 100, "memo": "명시 시간이 없으면 1일 8시간", }, { "keyword": "휴직", "leave_label": "휴직", "rule_type": "full_day", "default_hours": 8.0, "priority": 110, "memo": "명시 시간이 없으면 1일 8시간", }, { "keyword": "출산", "leave_label": "출산휴가", "rule_type": "full_day", "default_hours": 8.0, "priority": 120, "memo": "명시 시간이 없으면 1일 8시간", }, ) def _seed_default_hanmac_leave_rules(conn: Any) -> None: existing_count = conn.execute(text("SELECT COUNT(*) FROM hanmac_leave_rules")).scalar() or 0 if existing_count: return conn.execute( text( """ INSERT OR IGNORE INTO hanmac_leave_rules ( keyword, leave_label, rule_type, default_hours, enabled, priority, memo, created_at, updated_at ) VALUES ( :keyword, :leave_label, :rule_type, :default_hours, 1, :priority, :memo, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP ) """ ), list(DEFAULT_HANMAC_LEAVE_RULES), ) conn.execute( text( """ DELETE FROM hanmac_leave_rules WHERE keyword = '육아' AND leave_label = '육아휴직/휴가' AND memo = '명시 시간이 없으면 1일 8시간' """ ) ) def ensure_auth_schema(conn: Any) -> None: conn.execute( text( """ CREATE TABLE IF NOT EXISTS app_users ( id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT NOT NULL UNIQUE, password_hash TEXT NOT NULL, display_name TEXT DEFAULT '', is_active INTEGER NOT NULL DEFAULT 1, is_admin INTEGER NOT NULL DEFAULT 0, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ) """ ) ) conn.execute( text( """ CREATE TABLE IF NOT EXISTS app_roles ( id INTEGER PRIMARY KEY AUTOINCREMENT, role_key TEXT NOT NULL UNIQUE, role_name TEXT NOT NULL, description TEXT DEFAULT '', created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ) """ ) ) conn.execute( text( """ CREATE TABLE IF NOT EXISTS app_permissions ( id INTEGER PRIMARY KEY AUTOINCREMENT, permission_key TEXT NOT NULL UNIQUE, permission_name TEXT NOT NULL, description TEXT DEFAULT '', created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ) """ ) ) conn.execute( text( """ CREATE TABLE IF NOT EXISTS app_user_roles ( user_id INTEGER NOT NULL, role_id INTEGER NOT NULL, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (user_id, role_id) ) """ ) ) conn.execute( text( """ CREATE TABLE IF NOT EXISTS app_role_permissions ( role_id INTEGER NOT NULL, permission_id INTEGER NOT NULL, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (role_id, permission_id) ) """ ) ) conn.execute( text( """ CREATE TABLE IF NOT EXISTS app_user_permissions ( user_id INTEGER NOT NULL, permission_id INTEGER NOT NULL, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (user_id, permission_id) ) """ ) ) conn.execute( text( """ CREATE TABLE IF NOT EXISTS app_sessions ( session_id TEXT PRIMARY KEY, user_id INTEGER NOT NULL, expires_at TEXT NOT NULL, revoked_at TEXT DEFAULT '', ip_address TEXT DEFAULT '', user_agent TEXT DEFAULT '', created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, last_seen_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ) """ ) ) conn.execute( text( """ CREATE TABLE IF NOT EXISTS app_login_events ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER, username TEXT DEFAULT '', success INTEGER NOT NULL DEFAULT 0, failure_reason TEXT DEFAULT '', ip_address TEXT DEFAULT '', user_agent TEXT DEFAULT '', created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ) """ ) ) permissions = [ ("dashboard", "대시보드"), ("annual_summary", "연도별 수익/비용"), ("biz_process", "HM-BIZ-PROCESS"), ("process_cost", "프로젝트 원가"), ("cost_analysis", "프로젝트 손익분석"), ("projects", "프로젝트 정보"), ("wehago_compare", "전표비교"), ("hanmac_browser", "hanmac DB_external"), ("db_browser", "DB 조회"), ("admin", "사용자 관리"), ] for permission_key, permission_name in permissions: conn.execute( text( f""" INSERT INTO app_permissions (permission_key, permission_name, description) VALUES (:permission_key, :permission_name, '') ON CONFLICT(permission_key) DO UPDATE SET permission_name = excluded.permission_name, updated_at = CURRENT_TIMESTAMP """ ), {"permission_key": permission_key, "permission_name": permission_name}, ) roles = [ ("admin", "관리자", "모든 메뉴와 사용자 관리 권한"), ("viewer", "조회자", "기본 조회 메뉴 권한"), ] for role_key, role_name, description in roles: conn.execute( text( """ INSERT INTO app_roles (role_key, role_name, description) VALUES (:role_key, :role_name, :description) ON CONFLICT(role_key) DO UPDATE SET role_name = excluded.role_name, description = excluded.description, updated_at = CURRENT_TIMESTAMP """ ), {"role_key": role_key, "role_name": role_name, "description": description}, ) conn.execute( text( """ INSERT OR IGNORE INTO app_role_permissions (role_id, permission_id) SELECT r.id, p.id FROM app_roles AS r JOIN app_permissions AS p WHERE r.role_key = 'admin' """ ) ) conn.execute( text( """ DELETE FROM app_role_permissions WHERE role_id IN (SELECT id FROM app_roles WHERE role_key = 'viewer') """ ) ) def _get_runtime_cache_entry( cache: dict[tuple[Any, ...], dict[str, Any]], key: tuple[Any, ...], ttl_seconds: float = PROCESS_COST_CACHE_TTL_SECONDS, ) -> Any | None: now = time.time() with _PROCESS_COST_RUNTIME_CACHE_LOCK: cached = cache.get(key) if not cached: return None if now - float(cached.get("stored_at") or 0.0) > ttl_seconds: cache.pop(key, None) return None return copy.deepcopy(cached.get("value")) def _set_runtime_cache_entry( cache: dict[tuple[Any, ...], dict[str, Any]], key: tuple[Any, ...], value: Any, ) -> Any: with _PROCESS_COST_RUNTIME_CACHE_LOCK: cache[key] = { "stored_at": time.time(), "value": copy.deepcopy(value), } return copy.deepcopy(value) def _get_deepcopy_ttl_cache_entry( cache: dict[tuple[Any, ...], dict[str, Any]], lock: threading.Lock, key: tuple[Any, ...], ttl_seconds: float, ) -> Any | None: now = time.time() with lock: cached = cache.get(key) if not cached: return None if now - float(cached.get("stored_at") or 0.0) > ttl_seconds: cache.pop(key, None) return None return copy.deepcopy(cached.get("payload")) def _set_deepcopy_ttl_cache_entry( cache: dict[tuple[Any, ...], dict[str, Any]], lock: threading.Lock, key: tuple[Any, ...], value: Any, ) -> Any: with lock: cache[key] = { "stored_at": time.time(), "payload": copy.deepcopy(value), } return copy.deepcopy(value) def _auth_now_ts() -> int: return int(time.time()) def _auth_b64encode(value: bytes) -> str: return base64.urlsafe_b64encode(value).decode("ascii").rstrip("=") def _auth_b64decode(value: str) -> bytes: padding = "=" * (-len(value) % 4) return base64.urlsafe_b64decode((value + padding).encode("ascii")) def hash_password(password: str) -> str: salt = secrets.token_bytes(16) digest = hashlib.pbkdf2_hmac("sha256", password.encode("utf-8"), salt, AUTH_PBKDF2_ITERATIONS) return f"pbkdf2_sha256${AUTH_PBKDF2_ITERATIONS}${_auth_b64encode(salt)}${_auth_b64encode(digest)}" def verify_password(password: str, password_hash: str) -> bool: parts = str(password_hash or "").split("$") if len(parts) != 4 or parts[0] != "pbkdf2_sha256": return False try: iterations = int(parts[1]) salt = _auth_b64decode(parts[2]) expected = _auth_b64decode(parts[3]) except Exception: return False digest = hashlib.pbkdf2_hmac("sha256", password.encode("utf-8"), salt, iterations) return hmac.compare_digest(digest, expected) def _auth_sign_payload(payload: dict[str, Any]) -> str: raw_payload = _auth_b64encode(json.dumps(payload, ensure_ascii=False, separators=(",", ":")).encode("utf-8")) signature = hmac.new(AUTH_SECRET.encode("utf-8"), raw_payload.encode("ascii"), hashlib.sha256).digest() return f"{raw_payload}.{_auth_b64encode(signature)}" def _auth_unsign_payload(token: str) -> dict[str, Any] | None: try: raw_payload, signature = str(token or "").split(".", 1) expected = hmac.new(AUTH_SECRET.encode("utf-8"), raw_payload.encode("ascii"), hashlib.sha256).digest() if not hmac.compare_digest(_auth_b64decode(signature), expected): return None payload = json.loads(_auth_b64decode(raw_payload).decode("utf-8")) if not isinstance(payload, dict): return None if int(payload.get("exp") or 0) < _auth_now_ts(): return None return payload except Exception: return None def _auth_cookie_response(response: Response, session_token: str = "") -> Response: if session_token: response.set_cookie( AUTH_COOKIE_NAME, session_token, max_age=AUTH_SESSION_SECONDS, httponly=True, samesite="lax", ) else: response.delete_cookie(AUTH_COOKIE_NAME) return response def _auth_fetch_user(user_id: int) -> dict[str, Any] | None: init_db() with engine.begin() as conn: row = conn.execute( text( """ SELECT id, username, display_name, is_active, is_admin FROM app_users WHERE id = :user_id LIMIT 1 """ ), {"user_id": int(user_id or 0)}, ).mappings().first() if not row or not int(row["is_active"] or 0): return None permissions = conn.execute( text( """ SELECT DISTINCT p.permission_key FROM ( SELECT p.permission_key FROM app_permissions AS p JOIN app_role_permissions AS rp ON rp.permission_id = p.id JOIN app_user_roles AS ur ON ur.role_id = rp.role_id WHERE ur.user_id = :user_id UNION SELECT p.permission_key FROM app_permissions AS p JOIN app_user_permissions AS up ON up.permission_id = p.id WHERE up.user_id = :user_id ) AS p """ ), {"user_id": int(row["id"])}, ).scalars().all() permission_set = set(str(item) for item in permissions) if int(row["is_admin"] or 0): permission_set.add("admin") permission_set.update(item["permission"] for item in AUTH_NAV_ITEMS) return { "id": int(row["id"]), "username": row["username"], "display_name": row["display_name"] or row["username"], "is_admin": bool(row["is_admin"]), "permissions": sorted(permission_set), } def _auth_get_request_user(request: Request) -> dict[str, Any] | None: payload = _auth_unsign_payload(request.cookies.get(AUTH_COOKIE_NAME, "")) if not payload: return None session_id = normalize_text(payload.get("sid")) user_id = int(payload.get("uid") or 0) if not session_id or not user_id: return None init_db() with engine.begin() as conn: row = conn.execute( text( """ SELECT user_id, expires_at, last_seen_at FROM app_sessions WHERE session_id = :session_id AND COALESCE(revoked_at, '') = '' LIMIT 1 """ ), {"session_id": session_id}, ).mappings().first() if not row or int(row["user_id"] or 0) != user_id: return None try: expires_at = datetime.fromisoformat(str(row["expires_at"])) except Exception: return None if expires_at < datetime.now(): return None should_touch_session = True try: last_seen_at = datetime.fromisoformat(str(row["last_seen_at"] or "")) should_touch_session = (datetime.now() - last_seen_at).total_seconds() >= AUTH_LAST_SEEN_UPDATE_SECONDS except Exception: should_touch_session = True if should_touch_session: conn.execute( text("UPDATE app_sessions SET last_seen_at = CURRENT_TIMESTAMP WHERE session_id = :session_id"), {"session_id": session_id}, ) user = _auth_fetch_user(user_id) if user: user["session_id"] = session_id return user def _auth_user_can(user: dict[str, Any] | None, permission: str) -> bool: if not user: return False if user.get("is_admin"): return True return permission in set(user.get("permissions") or []) def _auth_default_landing_for_user(user: dict[str, Any] | None) -> str: if not user: return "/" for item in AUTH_NAV_ITEMS: if _auth_user_can(user, str(item.get("permission") or "")): return str(item.get("href") or "/") return "/" def _auth_path_permission(path: str) -> str: if path == "/": return "dashboard" for prefix, permission in sorted(AUTH_PERMISSION_BY_PREFIX.items(), key=lambda item: len(item[0]), reverse=True): if path == prefix or path.startswith(f"{prefix}/"): return permission return "dashboard" def _auth_is_api_request(request: Request) -> bool: accept = request.headers.get("accept", "") return request.url.path.endswith("/api") or "/api/" in request.url.path or "application/json" in accept def _auth_redirect_target(request: Request) -> str: path = request.url.path query = request.url.query target = path + (f"?{query}" if query else "") return quote_plus(target) def _auth_log_event( username: str, success: bool, request: Request, failure_reason: str = "", user_id: int | None = None, ) -> None: try: init_db() with engine.begin() as conn: conn.execute( text( """ INSERT INTO app_login_events ( user_id, username, success, failure_reason, ip_address, user_agent, created_at ) VALUES ( :user_id, :username, :success, :failure_reason, :ip_address, :user_agent, CURRENT_TIMESTAMP ) """ ), { "user_id": user_id, "username": username, "success": 1 if success else 0, "failure_reason": failure_reason, "ip_address": request.client.host if request.client else "", "user_agent": request.headers.get("user-agent", ""), }, ) except Exception as exc: logger.warning("login event logging skipped: %s", exc) def create_login_session(user_id: int, request: Request) -> str: session_id = secrets.token_urlsafe(32) expires_at = datetime.now() + timedelta(seconds=AUTH_SESSION_SECONDS) token = _auth_sign_payload({"sid": session_id, "uid": int(user_id), "exp": int(expires_at.timestamp())}) init_db() with engine.begin() as conn: conn.execute( text( """ INSERT INTO app_sessions ( session_id, user_id, expires_at, ip_address, user_agent, created_at, last_seen_at ) VALUES ( :session_id, :user_id, :expires_at, :ip_address, :user_agent, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP ) """ ), { "session_id": session_id, "user_id": int(user_id), "expires_at": expires_at.isoformat(timespec="seconds"), "ip_address": request.client.host if request.client else "", "user_agent": request.headers.get("user-agent", ""), }, ) return token def authenticate_user(username: str, password: str) -> dict[str, Any] | None: init_db() with engine.begin() as conn: row = conn.execute( text( """ SELECT id, username, password_hash, display_name, is_active, is_admin FROM app_users WHERE username = :username LIMIT 1 """ ), {"username": username}, ).mappings().first() if not row or not int(row["is_active"] or 0): return None if not verify_password(password, row["password_hash"]): return None return _auth_fetch_user(int(row["id"])) def _format_kst_display_from_utc(value: Any) -> str: text_value = normalize_text(value) if not text_value: return "" try: if text_value.endswith("Z"): parsed = datetime.fromisoformat(text_value[:-1] + "+00:00") else: parsed = datetime.fromisoformat(text_value) if parsed.tzinfo is None: parsed = parsed.replace(tzinfo=timezone.utc) return parsed.astimezone(timezone(timedelta(hours=9))).strftime("%Y-%m-%d %H:%M:%S") except Exception: return text_value def list_admin_users() -> list[dict[str, Any]]: init_db() with engine.begin() as conn: rows = conn.execute( text( """ SELECT u.id, u.username, u.display_name, u.is_active, u.is_admin, u.created_at, u.updated_at, last_success.created_at AS last_login_at, COALESCE(GROUP_CONCAT(DISTINCT r.role_key), '') AS roles, COALESCE(GROUP_CONCAT(DISTINCT p.permission_key), '') AS direct_permissions FROM app_users AS u LEFT JOIN ( SELECT user_id, MAX(created_at) AS created_at FROM app_login_events WHERE success = 1 AND user_id IS NOT NULL GROUP BY user_id ) AS last_success ON last_success.user_id = u.id LEFT JOIN app_user_roles AS ur ON ur.user_id = u.id LEFT JOIN app_roles AS r ON r.id = ur.role_id LEFT JOIN app_user_permissions AS up ON up.user_id = u.id LEFT JOIN app_permissions AS p ON p.id = up.permission_id GROUP BY u.id ORDER BY u.username """ ) ).mappings().all() return [ { "id": int(row["id"]), "username": row["username"], "display_name": row["display_name"] or row["username"], "is_active": bool(row["is_active"]), "is_admin": bool(row["is_admin"]), "roles": [item for item in str(row["roles"] or "").split(",") if item], "direct_permissions": [item for item in str(row["direct_permissions"] or "").split(",") if item], "created_at": str(row["created_at"] or ""), "updated_at": str(row["updated_at"] or ""), "last_login_at": str(row["last_login_at"] or ""), "last_login_at_kst": _format_kst_display_from_utc(row["last_login_at"]), } for row in rows ] def list_admin_user_login_events(user_id: int, limit: int = 100) -> dict[str, Any]: init_db() limit = max(1, min(int(limit or 100), 300)) with engine.begin() as conn: user = conn.execute( text( """ SELECT id, username, display_name FROM app_users WHERE id = :user_id LIMIT 1 """ ), {"user_id": int(user_id)}, ).mappings().first() if not user: raise ValueError("사용자를 찾을 수 없습니다.") rows = conn.execute( text( """ SELECT id, user_id, username, success, failure_reason, ip_address, user_agent, created_at FROM app_login_events WHERE user_id = :user_id OR username = :username ORDER BY created_at DESC, id DESC LIMIT :limit """ ), {"user_id": int(user["id"]), "username": user["username"], "limit": limit}, ).mappings().all() return { "user": { "id": int(user["id"]), "username": user["username"], "display_name": user["display_name"] or user["username"], }, "events": [ { "id": int(row["id"]), "username": row["username"] or "", "success": bool(row["success"]), "failure_reason": row["failure_reason"] or "", "ip_address": row["ip_address"] or "", "user_agent": row["user_agent"] or "", "created_at": str(row["created_at"] or ""), "created_at_kst": _format_kst_display_from_utc(row["created_at"]), } for row in rows ], } def list_admin_permission_options() -> list[dict[str, str]]: init_db() with engine.begin() as conn: rows = conn.execute( text( """ SELECT permission_key, permission_name FROM app_permissions ORDER BY CASE permission_key WHEN 'dashboard' THEN 0 WHEN 'annual_summary' THEN 1 WHEN 'biz_process' THEN 2 WHEN 'process_cost' THEN 3 WHEN 'cost_analysis' THEN 4 WHEN 'projects' THEN 5 WHEN 'wehago_compare' THEN 6 WHEN 'hanmac_browser' THEN 7 WHEN 'db_browser' THEN 8 WHEN 'admin' THEN 9 ELSE 99 END, permission_name """ ) ).mappings().all() return [{"key": row["permission_key"], "label": row["permission_name"]} for row in rows] def upsert_admin_user(payload: dict[str, Any]) -> None: username = normalize_text(payload.get("username")) if not username: raise ValueError("아이디가 필요합니다.") password = str(payload.get("password") or "") display_name = normalize_text(payload.get("display_name")) or username role_key = normalize_text(payload.get("role")) or "viewer" if role_key not in {"admin", "viewer"}: role_key = "viewer" raw_permissions = payload.get("permissions") if raw_permissions is None: raw_permissions = payload.get("permissions[]") if isinstance(raw_permissions, str): selected_permissions = {raw_permissions} if raw_permissions else set() elif isinstance(raw_permissions, (list, tuple, set)): selected_permissions = {normalize_text(item) for item in raw_permissions if normalize_text(item)} else: selected_permissions = set() is_active = 1 if normalize_text(payload.get("is_active")) in {"1", "true", "on", "yes", "활성"} else 0 is_admin = 1 if role_key == "admin" else 0 if is_admin: selected_permissions = set() init_db() with engine.begin() as conn: existing = conn.execute( text("SELECT id FROM app_users WHERE username = :username"), {"username": username}, ).mappings().first() if existing: params = { "username": username, "display_name": display_name, "is_active": is_active, "is_admin": is_admin, } password_sql = "" if password: params["password_hash"] = hash_password(password) password_sql = "password_hash = :password_hash," conn.execute( text( f""" UPDATE app_users SET display_name = :display_name, {password_sql} is_active = :is_active, is_admin = :is_admin, updated_at = CURRENT_TIMESTAMP WHERE username = :username """ ), params, ) user_id = int(existing["id"]) else: if not password: raise ValueError("새 사용자 비밀번호가 필요합니다.") user_id = conn.execute( text( """ INSERT INTO app_users ( username, password_hash, display_name, is_active, is_admin, created_at, updated_at ) VALUES ( :username, :password_hash, :display_name, :is_active, :is_admin, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP ) RETURNING id """ ), { "username": username, "password_hash": hash_password(password), "display_name": display_name, "is_active": is_active, "is_admin": is_admin, }, ).scalar_one() role_id = conn.execute( text("SELECT id FROM app_roles WHERE role_key = :role_key"), {"role_key": role_key}, ).scalar_one() conn.execute(text("DELETE FROM app_user_roles WHERE user_id = :user_id"), {"user_id": user_id}) conn.execute( text( """ INSERT OR IGNORE INTO app_user_roles (user_id, role_id, created_at) VALUES (:user_id, :role_id, CURRENT_TIMESTAMP) """ ), {"user_id": user_id, "role_id": role_id}, ) conn.execute(text("DELETE FROM app_user_permissions WHERE user_id = :user_id"), {"user_id": user_id}) if selected_permissions: conn.execute( text( """ INSERT OR IGNORE INTO app_user_permissions (user_id, permission_id, created_at) SELECT :user_id, id, CURRENT_TIMESTAMP FROM app_permissions WHERE permission_key IN :permission_keys """ ).bindparams(bindparam("permission_keys", expanding=True)), {"user_id": user_id, "permission_keys": sorted(selected_permissions)}, ) if not is_active: conn.execute( text("UPDATE app_sessions SET revoked_at = CURRENT_TIMESTAMP WHERE user_id = :user_id AND revoked_at IS NULL"), {"user_id": user_id}, ) @app.middleware("http") async def auth_middleware(request: Request, call_next): path = request.url.path if path in AUTH_PUBLIC_PATHS or path.startswith("/login") or path.startswith("/health/"): return await call_next(request) user = _auth_get_request_user(request) if not user: if _auth_is_api_request(request): return JSONResponse({"error": "login_required"}, status_code=401) return RedirectResponse(url=f"/login?next={_auth_redirect_target(request)}", status_code=303) permission = _auth_path_permission(path) if not _auth_user_can(user, permission): if path == "/" and not _auth_is_api_request(request): return RedirectResponse(url=_auth_default_landing_for_user(user), status_code=303) if _auth_is_api_request(request): return JSONResponse({"error": "forbidden"}, status_code=403) return HTMLResponse("

403

접근 권한이 없습니다.

", status_code=403) request.state.current_user = user return await call_next(request) def _json_hash(value: dict[str, Any]) -> str: return hashlib.sha1( json.dumps(value, ensure_ascii=False, sort_keys=True, default=str).encode("utf-8") ).hexdigest() def _load_system_page_cache(page_key: str, cache_key: str) -> dict[str, Any] | None: init_db() with engine.begin() as conn: row = conn.execute( text( """ SELECT payload_json, updated_at, params_json, row_count, signature FROM system_page_cache WHERE page_key = :page_key AND cache_key = :cache_key LIMIT 1 """ ), {"page_key": page_key, "cache_key": cache_key}, ).mappings().first() if not row: return None try: payload = json.loads(str(row["payload_json"] or "{}")) except Exception: payload = {} if not isinstance(payload, dict): return None payload.setdefault("cacheMeta", {}) payload["cacheMeta"].update( { "source": "system_page_cache", "pageKey": page_key, "cacheKey": cache_key, "updatedAt": str(row["updated_at"] or ""), "rowCount": int(row["row_count"] or 0), "signature": str(row["signature"] or ""), } ) return payload def _store_system_page_cache( page_key: str, cache_key: str, *, params: dict[str, Any], payload: dict[str, Any], row_count: int = 0, signature: str = "", ) -> None: init_db() with engine.begin() as conn: conn.execute( text( """ INSERT INTO system_page_cache ( page_key, cache_key, params_json, payload_json, row_count, signature, created_at, updated_at ) VALUES ( :page_key, :cache_key, :params_json, :payload_json, :row_count, :signature, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP ) ON CONFLICT(page_key, cache_key) DO UPDATE SET params_json = excluded.params_json, payload_json = excluded.payload_json, row_count = excluded.row_count, signature = excluded.signature, updated_at = CURRENT_TIMESTAMP """ ), { "page_key": page_key, "cache_key": cache_key, "params_json": json.dumps(params, ensure_ascii=False, default=str), "payload_json": json.dumps(payload, ensure_ascii=False, default=str), "row_count": int(row_count or 0), "signature": signature, }, ) def build_datasette_metadata() -> dict[str, Any]: return { "title": "한맥 인트라넷 DB 조회", "databases": { "data": { "title": "운영 DB", "queries": { "transaction_source_files": { "title": "거래 원본 파일 현황", "sql": """ SELECT source_file, COUNT(*) AS row_count, MAX(updated_at) AS last_updated_at FROM transactions WHERE COALESCE(source_file, '') <> '' GROUP BY source_file ORDER BY row_count DESC, source_file """, }, "project_related_links_overview": { "title": "연계 프로젝트 링크 현황", "sql": """ SELECT base_support_dept_code, related_support_dept_code, COALESCE(link_source, 'manual') AS link_source, updated_at FROM project_related_links ORDER BY base_support_dept_code, related_support_dept_code """, }, "project_collection_summary": { "title": "프로젝트 수금 요약", "sql": """ SELECT support_dept_code, MAX(support_dept_name) AS support_dept_name, COUNT(*) AS row_count, SUM(COALESCE(amount, 0)) AS collected_amount, MIN(date) AS first_collection_date, MAX(date) AS last_collection_date FROM project_collection_entries GROUP BY support_dept_code ORDER BY collected_amount DESC, support_dept_code """, }, "billing_vs_collection_gap": { "title": "청구/수금 차이 점검", "sql": """ WITH billing AS ( SELECT support_dept_code, SUM(COALESCE(billed_amount, 0)) AS billed_amount, SUM(COALESCE(collected_amount, 0)) AS billing_collected_amount FROM project_billing_entries GROUP BY support_dept_code ), collection AS ( SELECT support_dept_code, SUM(COALESCE(amount, 0)) AS collection_amount FROM project_collection_entries GROUP BY support_dept_code ), codes AS ( SELECT support_dept_code FROM billing UNION SELECT support_dept_code FROM collection ) SELECT codes.support_dept_code, COALESCE(billing.billed_amount, 0) AS billed_amount, COALESCE(collection.collection_amount, 0) AS collection_amount, COALESCE(billing.billed_amount, 0) - COALESCE(collection.collection_amount, 0) AS gap_amount FROM codes LEFT JOIN billing ON billing.support_dept_code = codes.support_dept_code LEFT JOIN collection ON collection.support_dept_code = codes.support_dept_code ORDER BY ABS(COALESCE(billing.billed_amount, 0) - COALESCE(collection.collection_amount, 0)) DESC, codes.support_dept_code """, }, }, "tables": { "transactions": {"title": "거래전표"}, "project_status": {"title": "프로젝트 상태"}, "project_related_links": {"title": "연계 프로젝트"}, "project_billing_entries": {"title": "청구 내역"}, "project_collection_entries": {"title": "수금 내역"}, "project_contract_info": {"title": "계약 현황"}, "project_analysis_settings": {"title": "분석 설정"}, }, } }, } def _empty_compare_metric_counts() -> dict[str, int]: return { "matched": 0, "ledger_only": 0, "voucher_only": 0, "amount_mismatch": 0, "voucher_matched": 0, "erp_voucher_matched": 0, "bridge_expense_review": 0, "voucher_unmatched": 0, "erp_voucher_unmatched": 0, "voucher_recheck": 0, "voucher_excepted": 0, "hanmac_unconnected": 0, } def _load_projection_group_counts( conn: sqlite3.Connection, start_year: int, end_year: int, *, signature_like: str | None = None, ) -> tuple[dict[str, int], int]: active_signature = "" if signature_like and signature_like.startswith(f"{QUERY_PROJECTION_VERSION}|"): try: active_row = conn.execute( """ SELECT setting_json FROM wehago_compare_settings WHERE setting_key = ? LIMIT 1 """, (f"wehago_active_query_projection:{int(start_year)}:{int(end_year)}",), ).fetchone() if active_row and active_row[0]: active_payload = json.loads(str(active_row[0] or "{}")) if isinstance(active_payload, dict): active_signature = normalize_text(active_payload.get("signature")) except Exception: active_signature = "" if not active_signature.startswith(f"{QUERY_PROJECTION_VERSION}|"): return {}, 0 where = [ "start_year <= ?", "end_year >= ?", ] params: list[Any] = [int(start_year), int(end_year)] if active_signature: where = ["start_year = ?", "end_year = ?", "signature = ?"] params = [int(start_year), int(end_year), active_signature] elif signature_like: where.append("signature LIKE ?") params.append(signature_like) scope_row = conn.execute( f""" SELECT start_year, end_year, signature, COUNT(DISTINCT status_key) AS status_count, MAX(updated_at) AS max_updated_at FROM wehago_compare_query_groups WHERE {' AND '.join(where)} GROUP BY start_year, end_year, signature ORDER BY CASE WHEN start_year = ? AND end_year = ? THEN 0 ELSE 1 END ASC, status_count DESC, (end_year - start_year) ASC, max_updated_at DESC LIMIT 1 """, (*params, int(start_year), int(end_year)), ).fetchone() if not scope_row: return {}, 0 proj_start = int(scope_row["start_year"] or 0) proj_end = int(scope_row["end_year"] or 0) signature = str(scope_row["signature"] or "") counts: dict[str, int] = {} for row in conn.execute( """ SELECT status_key, COUNT(*) AS row_count FROM wehago_compare_query_groups WHERE start_year = ? AND end_year = ? AND signature = ? AND fiscal_year BETWEEN ? AND ? GROUP BY status_key """, (proj_start, proj_end, signature, int(start_year), int(end_year)), ).fetchall(): counts[str(row["status_key"] or "")] = int(row["row_count"] or 0) return counts, int(scope_row["status_count"] or 0) def _fast_wehago_compare_summary_payload( start_year: int | None = None, end_year: int | None = None, ) -> dict[str, Any]: conn = sqlite3.connect(DB_PATH) conn.row_factory = sqlite3.Row try: available_years = sorted( { int(row[0]) for row in conn.execute( """ SELECT fiscal_year FROM wehago_snapshot_status UNION SELECT DISTINCT fiscal_year FROM wehago_compare_query_groups """ ).fetchall() if int(row[0] or 0) > 0 }, reverse=True, ) current_year = date.today().year default_start_year = min(available_years) if available_years else current_year default_end_year = max(available_years) if available_years else current_year if start_year is None and end_year is None: start_year = default_start_year end_year = default_end_year elif start_year is None: start_year = end_year elif end_year is None: end_year = start_year valid_years = set(available_years) if valid_years: if start_year not in valid_years: start_year = default_start_year if end_year not in valid_years: end_year = default_end_year if start_year == default_start_year else (start_year or default_end_year) if start_year and end_year and start_year > end_year: start_year, end_year = end_year, start_year counts = _empty_compare_metric_counts() current_group_counts, projection_status_count = _load_projection_group_counts( conn, int(start_year or 0), int(end_year or 0), signature_like=f"{QUERY_PROJECTION_VERSION}|%", ) has_current_projection_counts = bool(current_group_counts) if not current_group_counts: current_group_counts, projection_status_count = _load_projection_group_counts( conn, int(start_year or 0), int(end_year or 0), ) for status_key, value in current_group_counts.items(): if status_key in counts: counts[status_key] = int(value or 0) with engine.begin() as sqlalchemy_conn: counts["hanmac_unconnected"] = len( _load_hanmac_unconnected_source_groups( sqlalchemy_conn, int(start_year or 0), int(end_year or 0), ) ) if current_group_counts: scope_row = conn.execute( """ SELECT start_year, end_year, signature FROM wehago_compare_query_groups WHERE start_year <= ? AND end_year >= ? AND status_key = ? GROUP BY start_year, end_year, signature ORDER BY CASE WHEN signature LIKE ? THEN 0 ELSE 1 END ASC, CASE WHEN start_year = ? AND end_year = ? THEN 0 ELSE 1 END ASC, (end_year - start_year) ASC, MAX(updated_at) DESC LIMIT 1 """, ( int(start_year or 0), int(end_year or 0), "voucher_matched", f"{QUERY_PROJECTION_VERSION}|%", int(start_year or 0), int(end_year or 0), ), ).fetchone() else: scope_row = None if scope_row: proj_start = int(scope_row["start_year"] or 0) proj_end = int(scope_row["end_year"] or 0) signature = str(scope_row["signature"] or "") for row in conn.execute( """ SELECT status_key, COUNT(*) FROM wehago_compare_query_rows WHERE start_year = ? AND end_year = ? AND signature = ? AND fiscal_year BETWEEN ? AND ? AND status_key IN ('matched', 'ledger_only', 'amount_mismatch', 'voucher_only') GROUP BY status_key """, (proj_start, proj_end, signature, int(start_year or 0), int(end_year or 0)), ).fetchall(): status_key = str(row["status_key"] or "") if status_key in counts: counts[status_key] = int(row[1] or 0) if projection_status_count < 5: cached_summary = conn.execute( """ SELECT payload_json FROM wehago_summary_range_cache WHERE start_year = ? AND end_year = ? ORDER BY updated_at DESC, created_at DESC LIMIT 1 """, (int(start_year or 0), int(end_year or 0)), ).fetchone() if cached_summary and cached_summary["payload_json"]: try: cached_payload = json.loads(str(cached_summary["payload_json"])) cached_counts = cached_payload.get("counts") if isinstance(cached_payload, dict) else None if isinstance(cached_counts, dict): for status_key in counts: counts[status_key] = int(cached_counts.get(status_key, counts[status_key]) or 0) except Exception: pass cached_summary = conn.execute( """ SELECT payload_json FROM wehago_summary_range_cache WHERE start_year = ? AND end_year = ? ORDER BY updated_at DESC, created_at DESC LIMIT 1 """, (int(start_year or 0), int(end_year or 0)), ).fetchone() if cached_summary and cached_summary["payload_json"]: try: cached_payload = json.loads(str(cached_summary["payload_json"])) cached_counts = cached_payload.get("counts") if isinstance(cached_payload, dict) else None if isinstance(cached_counts, dict): for status_key in ("matched", "ledger_only", "voucher_only", "amount_mismatch", "bridge_expense_review"): if int(counts.get(status_key, 0) or 0) == 0: counts[status_key] = int(cached_counts.get(status_key, 0) or 0) except Exception: pass if any(int(counts.get(status_key, 0) or 0) for status_key in ("voucher_matched", "erp_voucher_matched", "voucher_unmatched", "erp_voucher_unmatched", "voucher_recheck", "hanmac_unconnected")): for status_key in ("matched", "ledger_only", "voucher_only", "amount_mismatch"): if int(counts.get(status_key, 0) or 0) != 0: continue row = conn.execute( """ SELECT COUNT(*) FROM wehago_comparison_results WHERE fiscal_year BETWEEN ? AND ? AND status = ? """, (int(start_year or 0), int(end_year or 0), status_key), ).fetchone() counts[status_key] = int((row or [0])[0] or 0) try: resolved_counts, count_pending = get_dashboard_metric_counts_nonblocking( engine, int(start_year or 0), int(end_year or 0), ) if any(int(resolved_counts.get(status_key, 0) or 0) for status_key in counts): for status_key in counts: counts[status_key] = int(resolved_counts.get(status_key, counts[status_key]) or 0) except Exception: count_pending = False snapshot_state = {"ready": [], "stale": [], "missing": [], "queued": [], "running": [], "failed": []} status_rows = { int(row["fiscal_year"]): str(row["state"] or "") for row in conn.execute( """ SELECT fiscal_year, state FROM wehago_snapshot_status WHERE fiscal_year BETWEEN ? AND ? """, (int(start_year or 0), int(end_year or 0)), ).fetchall() if int(row["fiscal_year"] or 0) > 0 } for year in range(int(start_year or 0), int(end_year or 0) + 1): state = status_rows.get(year, "missing") snapshot_state.setdefault(state, []) snapshot_state[state].append(year) pending = bool(snapshot_state["missing"] or snapshot_state["stale"] or snapshot_state["queued"] or snapshot_state["running"]) pending = pending or bool(count_pending) last_action = None last_action_row = conn.execute( """ SELECT id, action_type, payload_json, created_at FROM wehago_action_history ORDER BY id DESC LIMIT 1 """ ).fetchone() if last_action_row: try: action_payload = json.loads(last_action_row["payload_json"] or "{}") except Exception: action_payload = {} last_action = { "id": int(last_action_row["id"] or 0), "action_type": str(last_action_row["action_type"] or ""), "count": int(action_payload.get("count") or 0), "created_at": str(last_action_row["created_at"] or ""), } metric_sections = [ { "key": status_key, "label": label, "description": description, "count": int(counts.get(status_key, 0) or 0), "columns": [], "rows": [], } for status_key, label, description in ( ("matched", "Matched", ""), ("ledger_only", "Unmatched", ""), ("voucher_only", "ERP Unmatched", ""), ("amount_mismatch", "Recheck", ""), ("voucher_matched", "WEHAGO Voucher", ""), ("voucher_unmatched", "WEHAGO Unmatched", ""), ("voucher_recheck", "WEHAGO Recheck", ""), ("voucher_excepted", "WEHAGO Excepted", ""), ("hanmac_unconnected", "Hanmac unconnected", ""), ("erp_voucher_matched", "HANMAC Voucher", ""), ("erp_voucher_unmatched", "HANMAC Unmatched", ""), ("bridge_expense_review", "2단계 비교", ""), ) ] with engine.begin() as sqlalchemy_conn: final_status_summary = get_wehago_final_status_summary_from_conn( sqlalchemy_conn, start_year, end_year, counts, ) metric_sections = apply_wehago_final_status_counts_to_metric_sections(metric_sections, final_status_summary) return { "selected_start_year": start_year, "selected_end_year": end_year, "metric_sections": metric_sections, "wehago_final_status_summary": final_status_summary, "last_action": last_action, "pending": pending, "snapshot_state": snapshot_state, "snapshot_policy": {"available_years": available_years}, "snapshot_aggregate": { "ready_count": len(snapshot_state["ready"]), "stale_count": len(snapshot_state["stale"]), "missing_count": len(snapshot_state["missing"]), "queued_count": len(snapshot_state["queued"]), "running_count": len(snapshot_state["running"]), "failed_count": len(snapshot_state["failed"]), }, "snapshot_status_payload": None, } finally: conn.close() datasette_app = Datasette( files=[str(DB_PATH)], metadata=build_datasette_metadata(), settings={ "base_url": "/db/", "default_page_size": 50, "max_returned_rows": 2000, "sql_time_limit_ms": 20000, "allow_facet": True, "default_allow_sql": True, "allow_download": True, }, ).app() app.mount("/db", datasette_app, name="datasette") @event.listens_for(engine, "connect") def configure_sqlite_connection(dbapi_connection: Any, _: Any) -> None: cursor = dbapi_connection.cursor() cursor.execute("PRAGMA journal_mode=WAL") cursor.execute("PRAGMA synchronous=NORMAL") cursor.execute("PRAGMA foreign_keys=ON") cursor.execute("PRAGMA busy_timeout=5000") cursor.execute("PRAGMA temp_store=MEMORY") cursor.close() EXCLUDED_SUPPORT_CODES = {"ZZZZZZ"} EXCLUDED_SUPPORT_NAMES = {"공통", "경영지원부", "기술개발센터", "임원실", "기술개발부", "총괄기획실"} SUPPORT_DEPARTMENT_NAMES = ("경영지원부", "임원실", "총괄기획실", "기술개발센터", "기술개발부", "공통") VOUCHER_PATTERN = re.compile(r"^11-(\d{4})(\d{2})(\d{2})-[^-]+-[^-]+-[^-]+$") REVENUE_SQL = "(account_code LIKE '401101%' OR account_code LIKE '401102%')" EXEC_COST_PLAN_OTHER_CODE = "501999" EXEC_COST_PLAN_OTHER_NAME = "기타" SUPPORT_COST_DEPT_SQL = ( "cost_dept_name IN ('경영지원부', '임원실', '총괄기획실', '기술개발센터', '기술개발부', '공통')" ) FIELD_COST_DEPT_SQL = ( "COALESCE(cost_dept_name, '') NOT IN ('경영지원부', '임원실', '총괄기획실', '기술개발센터', '기술개발부', '공통')" ) FIELD_LABELS = { "approval_status": "결재상태", "voucher_number": "가전표번호", "account_code": "계정코드", "account_name": "계정명칭", "debit_supply": "차변공급가", "debit_vat": "차변부가세", "credit_supply": "대변공급가", "credit_vat": "대변부가세", "issuing_dept_code": "발의부서코드", "issuing_dept_name": "발의부서명", "confirmed_voucher_number": "확정전표번호", "support_dept_code": "지원부서코드", "support_dept_name": "지원부서명", "cost_dept_code": "원가부서코드", "cost_dept_name": "원가부서명", "memo1": "적요1", "memo2": "적요2", "partner_code": "거래처코드", "partner_name": "거래처명칭", "tax_code": "세무코드", "posting_date": "증빙일자", "voucher_type": "전표종류", "management_item": "관리항목", } FORM_FIELDS = list(FIELD_LABELS.keys()) DIRECT_HEADER_MAP = { "결재상태": "approval_status", "가전표번호": "voucher_number", "계정코드": "account_code", "계정명칭": "account_name", "차변공급가": "debit_supply", "차변부가세": "debit_vat", "대변공급가": "credit_supply", "대변부가세": "credit_vat", "발의부서코드": "issuing_dept_code", "발의부서명": "issuing_dept_name", "발의부서명칭": "issuing_dept_name", "지원부서코드": "support_dept_code", "지원부서명": "support_dept_name", "지원부서명칭": "support_dept_name", "원가부서코드": "cost_dept_code", "원가부서명": "cost_dept_name", "원가부서명칭": "cost_dept_name", "적요1": "memo1", "적요2": "memo2", "거래처코드": "partner_code", "거래처명칭": "partner_name", "세무코드": "tax_code", "증빙일자": "posting_date", "전표종류": "voucher_type", "관리항목": "management_item", } DEFAULT_EXEC_LABOR_RATES = { "2025": { "설계": {"부사장": 56100, "전무": 56100, "상무": 49400, "이사": 46600, "부장": 41400, "차장": 38000, "과장": 34600, "대리": 31600, "사원": 27100}, "감리": {"부사장": 51800, "전무": 49000, "상무": 46000, "이사": 43900, "부장": 41000, "차장": 38200, "과장": 38200, "대리": 36600, "사원": 28700}, "지원": {"부사장": 51800, "전무": 49000, "상무": 46000, "이사": 43900, "부장": 41000, "차장": 38200, "과장": 38200, "대리": 36600, "사원": 28700}, }, "2024": { "설계": {"부사장": 56100, "전무": 56100, "상무": 47700, "이사": 45200, "부장": 40900, "차장": 37600, "과장": 34200, "대리": 30800, "사원": 26300}, "감리": {"부사장": 51800, "전무": 49000, "상무": 46000, "이사": 43100, "부장": 40200, "차장": 38200, "과장": 38200, "대리": 30500, "사원": 26500}, "지원": {"부사장": 51800, "전무": 49000, "상무": 46000, "이사": 43100, "부장": 40200, "차장": 38200, "과장": 38200, "대리": 30500, "사원": 26500}, }, "2023": { "설계": {"부사장": 56100, "전무": 56100, "상무": 46900, "이사": 44700, "부장": 40200, "차장": 36800, "과장": 33500, "대리": 30000, "사원": 25600}, "감리": {"부사장": 51800, "전무": 51800, "상무": 46000, "이사": 43100, "부장": 40200, "차장": 38200, "과장": 38200, "대리": 28600, "사원": 25600}, "지원": {"부사장": 51800, "전무": 51800, "상무": 46000, "이사": 43100, "부장": 40200, "차장": 38200, "과장": 38200, "대리": 28600, "사원": 25600}, }, "2022": { "설계": {"부사장": 56100, "전무": 56100, "상무": 46900, "이사": 44700, "부장": 40200, "차장": 36800, "과장": 33400, "대리": 30000, "사원": 25600}, "감리": {"부사장": 51800, "전무": 51800, "상무": 46000, "이사": 42100, "부장": 39300, "차장": 38200, "과장": 38200, "대리": 28000, "사원": 25500}, "지원": {"부사장": 51800, "전무": 51800, "상무": 46000, "이사": 42100, "부장": 39300, "차장": 38200, "과장": 38200, "대리": 28000, "사원": 25500}, }, "2021": { "설계": {"부사장": 54700, "전무": 54700, "상무": 45700, "이사": 45700, "부장": 38300, "차장": 35200, "과장": 31500, "대리": 28100, "사원": 24300}, "감리": {"부사장": 47000, "전무": 47000, "상무": 44500, "이사": 39000, "부장": 36800, "차장": 33400, "과장": 33400, "대리": 24200, "사원": 23200}, "지원": {"부사장": 47000, "전무": 47000, "상무": 44500, "이사": 39000, "부장": 36800, "차장": 33400, "과장": 33400, "대리": 24200, "사원": 23200}, }, "2020": { "설계": {"부사장": 52300, "전무": 52300, "상무": 52300, "이사": 52300, "부장": 35000, "차장": 31700, "과장": 28800, "대리": 25900, "사원": 23000}, "감리": {"부사장": 45200, "전무": 45200, "상무": 41900, "이사": 41900, "부장": 34500, "차장": 32700, "과장": 32700, "대리": 22600, "사원": 22600}, "지원": {"부사장": 45200, "전무": 45200, "상무": 41900, "이사": 41900, "부장": 34500, "차장": 32700, "과장": 32700, "대리": 22600, "사원": 22600}, }, } DEFAULT_APP_OPTION_ITEMS = { "labor_grades": [ ("president", "사장", "사장"), ("vice_president", "부사장", "부사장"), ("executive_vice_president", "전무", "전무"), ("managing_director", "상무", "상무"), ("director", "이사", "이사"), ("general_manager", "부장", "부장"), ("deputy_general_manager", "차장", "차장"), ("manager", "과장", "과장"), ("assistant_manager", "대리", "대리"), ("staff", "사원", "사원"), ("principal", "수석", "수석"), ("senior_manager", "책임", "책임"), ("senior", "선임", "선임"), ("researcher", "연구원", "연구원"), ], "expected_as_rates": [ ("as_0", "0%", "0"), ("as_2", "2%", "2"), ("as_5", "5%", "5"), ("as_10", "10%", "10"), ], "expected_sga_rates": [ ("sga_13", "13%", "13"), ("sga_15", "15%", "15"), ("sga_20", "20%", "20"), ("sga_25", "25%", "25"), ], "uncontracted_categories": [ ("general", "일반 미계약", "general"), ("precontract", "사전 사업 코드", "precontract"), ("corporate_rnd", "기업 연구개발", "corporate_rnd"), ("external_research", "외부 연구과제", "external_research"), ], "project_rules": [ ("legacy_variant_cutoff_year", "이전 연도 변경/차수 제외 기준", "23"), ("detail_visible_min_year", "세부내역 반영 시작 연도", "2023"), ], "project_shared": [ ("exec_labor_rates_json", "공통 기준인건비", json.dumps(DEFAULT_EXEC_LABOR_RATES, ensure_ascii=False, separators=(",", ":"))), ], "dashboard_revenue_metrics": [ ("design_revenue", "설계", "#4f7cff"), ("design_other_revenue", "설계 외", "#67c7c9"), ("supervision_revenue", "감리", "#233a5a"), ("inspection_revenue", "점검", "#ffb54a"), ], "dashboard_expense_metrics": [ ("cost_sum", "원가", "#4f7cff"), ("sga_sum", "판관비", "#67c7c9"), ("labor_sum", "원가인건비", "#233a5a"), ("outsourcing_sum", "원가외주비", "#ffb54a"), ], "annual_metric_cards": [ ("revenue_sum", "수금", "수금"), ("project_cost_sum", "원가(프로젝트)", "원가(프로젝트)"), ("support_cost_sum", "원가(지원부서)", "원가(지원부서)"), ("support_sga_sum", "판관비(지원부서)", "판관비(지원부서)"), ("field_sga_sum", "판관비(현업부서)", "판관비(현업부서)"), ("labor_sum", "원가인건비", "원가인건비"), ("outsourcing_sum", "원가외주비", "원가외주비"), ("total_expense", "비용합계", "비용합계"), ("operating_balance", "영업수지", "영업수지"), ], "annual_expense_chart_metrics": [ ("labor_sum", "원가인건비", "#8b5cf6"), ("outsourcing_sum", "원가외주비", "#ec4899"), ("project_cost_sum", "원가(프로젝트)", "#0ea5a4"), ("support_cost_sum", "원가(지원부서)", "#67b7dc"), ("support_sga_sum", "판관비(지원부서)", "#f59e0b"), ("field_sga_sum", "판관비(현업부서)", "#f97316"), ], "annual_balance_chart_metrics": [ ("revenue_sum", "수금", "#0f766e"), ("total_expense", "비용합계", "#1d4ed8"), ("operating_balance", "영업수지", "#dc2626"), ], } DEFAULT_APP_KEYWORD_RULES = { "special_x_classification": [ ("external_research", "과제"), ("external_research", "연구과제"), ("external_research", "연구소"), ("external_research", "연구용역"), ("external_research", "연구"), ("corporate_rnd", "신규노선개발"), ("corporate_rnd", "프로그램 개발"), ("corporate_rnd", "프로그램개발"), ("corporate_rnd", "BIM"), ("corporate_rnd", "시스템"), ("corporate_rnd", "혁신"), ] } def ensure_default_app_config(conn: Any) -> None: def _clean_text(value: Any) -> str: return "" if value is None else str(value).strip() existing_option_rows = conn.execute( text( """ SELECT group_key, item_key, label, value_text, sort_order FROM app_option_items """ ) ).mappings().all() existing_options = { (_clean_text(row.get("group_key")), _clean_text(row.get("item_key"))): ( _clean_text(row.get("label")), _clean_text(row.get("value_text")), int(row.get("sort_order") or 0), ) for row in existing_option_rows } for group_key, items in DEFAULT_APP_OPTION_ITEMS.items(): for sort_order, (item_key, label, value_text) in enumerate(items): existing_value = existing_options.get((group_key, item_key)) if existing_value == (label, value_text, sort_order): continue conn.execute( text( """ INSERT INTO app_option_items ( group_key, item_key, label, value_text, sort_order, is_active, meta_json ) VALUES ( :group_key, :item_key, :label, :value_text, :sort_order, 1, '{}' ) ON CONFLICT(group_key, item_key) DO UPDATE SET label = excluded.label, value_text = excluded.value_text, sort_order = excluded.sort_order """ ), { "group_key": group_key, "item_key": item_key, "label": label, "value_text": value_text, "sort_order": sort_order, }, ) existing_rule_rows = conn.execute( text( """ SELECT rule_group, category_key, keyword, sort_order FROM app_keyword_rules """ ) ).mappings().all() existing_rules = { (_clean_text(row.get("rule_group")), _clean_text(row.get("category_key")), _clean_text(row.get("keyword"))): int(row.get("sort_order") or 0) for row in existing_rule_rows } for rule_group, items in DEFAULT_APP_KEYWORD_RULES.items(): for sort_order, (category_key, keyword) in enumerate(items): if existing_rules.get((rule_group, category_key, keyword)) == sort_order: continue conn.execute( text( """ INSERT INTO app_keyword_rules ( rule_group, category_key, keyword, sort_order, is_active ) VALUES ( :rule_group, :category_key, :keyword, :sort_order, 1 ) ON CONFLICT(rule_group, category_key, keyword) DO UPDATE SET sort_order = excluded.sort_order """ ), { "rule_group": rule_group, "category_key": category_key, "keyword": keyword, "sort_order": sort_order, }, ) load_app_config.cache_clear() def _maybe_run_db_analyze() -> None: global _DB_ANALYZE_LAST_ATTEMPT_AT now = time.time() if now - _DB_ANALYZE_LAST_ATTEMPT_AT < DB_ANALYZE_MIN_INTERVAL_SECONDS: return with _DB_ANALYZE_LOCK: now = time.time() if now - _DB_ANALYZE_LAST_ATTEMPT_AT < DB_ANALYZE_MIN_INTERVAL_SECONDS: return _DB_ANALYZE_LAST_ATTEMPT_AT = now try: with engine.begin() as conn: conn.execute(text("ANALYZE")) except OperationalError as exc: logger.warning("ANALYZE skipped due to database lock: %s", exc) except Exception as exc: logger.warning("ANALYZE skipped due to unexpected error: %s", exc) def init_db() -> None: global _DB_INIT_DONE if _DB_INIT_DONE: return with _DB_INIT_LOCK: if _DB_INIT_DONE: return conn = engine.connect() trans = conn.begin() conn.execute( text( """ CREATE TABLE IF NOT EXISTS transactions ( id INTEGER PRIMARY KEY AUTOINCREMENT, approval_status TEXT, voucher_number TEXT, account_code TEXT, account_name TEXT, debit_supply REAL DEFAULT 0, debit_vat REAL DEFAULT 0, credit_supply REAL DEFAULT 0, credit_vat REAL DEFAULT 0, issuing_dept_code TEXT, issuing_dept_name TEXT, confirmed_voucher_number TEXT, support_dept_code TEXT, support_dept_name TEXT, cost_dept_code TEXT, cost_dept_name TEXT, memo1 TEXT, memo2 TEXT, partner_code TEXT, partner_name TEXT, tax_code TEXT, posting_date TEXT, voucher_type TEXT, management_item TEXT, accounting_category TEXT, amount REAL DEFAULT 0, year INTEGER, month INTEGER, day INTEGER, source_file TEXT, last_editor_session_id TEXT DEFAULT '', last_client_submitted_at TEXT DEFAULT '', created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ) """ ) ) conn.execute( text( """ CREATE INDEX IF NOT EXISTS idx_transactions_year_month ON transactions (year, month) """ ) ) conn.execute( text( """ CREATE INDEX IF NOT EXISTS idx_transactions_support ON transactions (support_dept_code, support_dept_name) """ ) ) conn.execute( text( """ CREATE INDEX IF NOT EXISTS idx_transactions_support_category ON transactions (support_dept_code, accounting_category) """ ) ) conn.execute( text( """ CREATE INDEX IF NOT EXISTS idx_transactions_support_account ON transactions (support_dept_code, account_code) """ ) ) conn.execute( text( """ CREATE INDEX IF NOT EXISTS idx_transactions_source_file ON transactions (source_file) """ ) ) conn.execute( text( """ CREATE INDEX IF NOT EXISTS idx_transactions_updated_at ON transactions (updated_at) """ ) ) conn.execute( text( f""" CREATE INDEX IF NOT EXISTS idx_transactions_cost_analysis_date_account_support ON transactions ( ({COST_ANALYSIS_TX_DATE_SQL}), account_code, support_dept_code ) """ ) ) transaction_columns = { row[1] for row in conn.execute(text("PRAGMA table_info(transactions)")).fetchall() } required_transaction_columns = { "last_editor_session_id": "TEXT DEFAULT ''", "last_client_submitted_at": "TEXT DEFAULT ''", } for column_name, column_type in required_transaction_columns.items(): if column_name not in transaction_columns: conn.execute(text(f"ALTER TABLE transactions ADD COLUMN {column_name} {column_type}")) conn.execute( text( """ CREATE TABLE IF NOT EXISTS project_status ( support_dept_code TEXT PRIMARY KEY, support_dept_name TEXT NOT NULL, progress_rate REAL DEFAULT 0, contract_amount REAL DEFAULT 0, collection_amount REAL DEFAULT 0, collection_entries_json TEXT DEFAULT '[]', change_round TEXT DEFAULT '', item_investment REAL DEFAULT 0, task_plan_department_budget REAL DEFAULT 0, task_plan_outsource_budget REAL DEFAULT 0, task_plan_outsource_detail TEXT DEFAULT '', task_plan_joint_operating_cost REAL DEFAULT 0, task_plan_entries_json TEXT DEFAULT '[]', exec_budget_labor_by_grade REAL DEFAULT 0, exec_labor_rates_json TEXT DEFAULT '{}', exec_budget_outsource REAL DEFAULT 0, exec_budget_cost_plan REAL DEFAULT 0, exec_budget_entries_json TEXT DEFAULT '[]', actual_input_entries_json TEXT DEFAULT '[]', project_type TEXT DEFAULT '', expected_as_rate REAL DEFAULT 0, expected_sga_rate REAL DEFAULT 0, expected_as_cost REAL DEFAULT 0, expected_sga_budget REAL DEFAULT 0, last_editor_session_id TEXT DEFAULT '', last_client_submitted_at TEXT DEFAULT '', project_start_date TEXT DEFAULT '', project_end_date TEXT DEFAULT '', completion_status TEXT DEFAULT '', notes TEXT DEFAULT '', updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ) """ ) ) conn.execute( text( """ CREATE TABLE IF NOT EXISTS project_basic_info ( support_dept_code TEXT PRIMARY KEY, support_dept_name TEXT NOT NULL DEFAULT '', contract_amount REAL DEFAULT 0, project_type TEXT DEFAULT '', expected_as_rate REAL DEFAULT 0, expected_sga_rate REAL DEFAULT 0, expected_as_cost REAL DEFAULT 0, expected_sga_budget REAL DEFAULT 0, exec_labor_rates_json TEXT DEFAULT '{}', change_round TEXT DEFAULT '', project_start_date TEXT DEFAULT '', project_end_date TEXT DEFAULT '', completion_status TEXT DEFAULT '', notes TEXT DEFAULT '', last_editor_session_id TEXT DEFAULT '', last_client_submitted_at TEXT DEFAULT '', updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ) """ ) ) conn.execute( text( """ CREATE INDEX IF NOT EXISTS idx_project_basic_info_updated_at ON project_basic_info (updated_at) """ ) ) conn.execute( text( """ CREATE TABLE IF NOT EXISTS project_page_state ( page_key TEXT PRIMARY KEY, selected_code TEXT DEFAULT '', selected_year TEXT DEFAULT '', analysis_open INTEGER DEFAULT 0, uncontracted_year_start TEXT DEFAULT '', uncontracted_year_end TEXT DEFAULT '', related_project_selections_json TEXT DEFAULT '{}', updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ) """ ) ) 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_holidays ( holiday_date TEXT PRIMARY KEY, holiday_name TEXT NOT NULL DEFAULT '', holiday_type TEXT NOT NULL DEFAULT 'company', memo 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_holidays_type_date ON hanmac_holidays (holiday_type, holiday_date) """ ) ) _seed_default_hanmac_holidays(conn) conn.execute( text( """ CREATE TABLE IF NOT EXISTS hanmac_leave_rules ( keyword TEXT PRIMARY KEY, leave_label TEXT NOT NULL DEFAULT '', rule_type TEXT NOT NULL DEFAULT 'full_day', default_hours REAL NOT NULL DEFAULT 8, enabled INTEGER NOT NULL DEFAULT 1, priority INTEGER NOT NULL DEFAULT 100, memo 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_leave_rules_enabled_priority ON hanmac_leave_rules (enabled, priority) """ ) ) _seed_default_hanmac_leave_rules(conn) conn.execute( text( """ CREATE TABLE IF NOT EXISTS system_jobs ( id TEXT PRIMARY KEY, page_key TEXT NOT NULL DEFAULT '', job_type TEXT NOT NULL DEFAULT '', status TEXT NOT NULL DEFAULT 'queued', start_year INTEGER, end_year INTEGER, params_json TEXT NOT NULL DEFAULT '{}', progress_current INTEGER NOT NULL DEFAULT 0, progress_total INTEGER NOT NULL DEFAULT 0, message TEXT NOT NULL DEFAULT '', result_json TEXT NOT NULL DEFAULT '{}', error_message TEXT NOT NULL DEFAULT '', cancel_requested INTEGER NOT NULL DEFAULT 0, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, started_at TIMESTAMP, finished_at TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ) """ ) ) conn.execute( text( """ CREATE INDEX IF NOT EXISTS idx_system_jobs_page_status ON system_jobs (page_key, status, created_at) """ ) ) conn.execute( text( """ CREATE INDEX IF NOT EXISTS idx_system_jobs_type_period ON system_jobs (job_type, start_year, end_year, created_at) """ ) ) conn.execute( text( """ CREATE TABLE IF NOT EXISTS system_page_cache ( page_key TEXT NOT NULL, cache_key TEXT NOT NULL, params_json TEXT NOT NULL DEFAULT '{}', payload_json TEXT NOT NULL DEFAULT '{}', row_count INTEGER NOT NULL DEFAULT 0, signature TEXT NOT NULL DEFAULT '', created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (page_key, cache_key) ) """ ) ) conn.execute( text( """ CREATE INDEX IF NOT EXISTS idx_system_page_cache_updated_at ON system_page_cache (page_key, updated_at) """ ) ) 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 wehago_benefit_category_overrides ( ledger_row_id INTEGER PRIMARY KEY, category TEXT NOT NULL DEFAULT '', memo 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_wehago_benefit_category_overrides_updated ON wehago_benefit_category_overrides (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( """ CREATE TABLE IF NOT EXISTS app_option_items ( id INTEGER PRIMARY KEY AUTOINCREMENT, group_key TEXT NOT NULL, item_key TEXT NOT NULL, label TEXT NOT NULL, value_text TEXT NOT NULL DEFAULT '', sort_order INTEGER NOT NULL DEFAULT 0, is_active INTEGER NOT NULL DEFAULT 1, meta_json TEXT DEFAULT '{}', updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, UNIQUE(group_key, item_key) ) """ ) ) conn.execute( text( """ CREATE INDEX IF NOT EXISTS idx_app_option_items_group ON app_option_items (group_key, sort_order, id) """ ) ) conn.execute( text( """ CREATE TABLE IF NOT EXISTS app_keyword_rules ( id INTEGER PRIMARY KEY AUTOINCREMENT, rule_group TEXT NOT NULL, category_key TEXT NOT NULL, keyword TEXT NOT NULL, sort_order INTEGER NOT NULL DEFAULT 0, is_active INTEGER NOT NULL DEFAULT 1, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, UNIQUE(rule_group, category_key, keyword) ) """ ) ) conn.execute( text( """ CREATE INDEX IF NOT EXISTS idx_app_keyword_rules_group ON app_keyword_rules (rule_group, category_key, sort_order, id) """ ) ) conn.execute( text( """ CREATE TABLE IF NOT EXISTS project_collection_entries ( id INTEGER PRIMARY KEY AUTOINCREMENT, support_dept_code TEXT NOT NULL, position INTEGER NOT NULL DEFAULT 0, vendor TEXT DEFAULT '', progress_type TEXT DEFAULT '', billing_round TEXT DEFAULT '', billing_type TEXT DEFAULT '', billing_date TEXT DEFAULT '', billed_amount REAL DEFAULT 0, round TEXT DEFAULT '', date TEXT DEFAULT '', due_date TEXT DEFAULT '', amount REAL DEFAULT 0, balance_amount REAL DEFAULT 0, collection_rate REAL DEFAULT 0, note TEXT DEFAULT '', updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ) """ ) ) conn.execute( text( """ CREATE INDEX IF NOT EXISTS idx_project_collection_entries_code_pos ON project_collection_entries (support_dept_code, position) """ ) ) conn.execute( text( """ CREATE INDEX IF NOT EXISTS idx_project_collection_entries_date_code ON project_collection_entries (date, support_dept_code) """ ) ) conn.execute( text( """ CREATE TABLE IF NOT EXISTS project_task_plan_entries ( id INTEGER PRIMARY KEY AUTOINCREMENT, support_dept_code TEXT NOT NULL, position INTEGER NOT NULL DEFAULT 0, group_name TEXT DEFAULT '', dept_name TEXT DEFAULT '', work_name TEXT DEFAULT '', amount REAL DEFAULT 0, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ) """ ) ) conn.execute( text( """ CREATE INDEX IF NOT EXISTS idx_project_task_plan_entries_code_pos ON project_task_plan_entries (support_dept_code, position) """ ) ) conn.execute( text( """ CREATE TABLE IF NOT EXISTS project_exec_budget_entries ( id INTEGER PRIMARY KEY AUTOINCREMENT, support_dept_code TEXT NOT NULL, position INTEGER NOT NULL DEFAULT 0, group_name TEXT DEFAULT '', grade TEXT DEFAULT '', hours TEXT DEFAULT '', rate_year TEXT DEFAULT '', dept_name TEXT DEFAULT '', work_name TEXT DEFAULT '', account_code TEXT DEFAULT '', account_name TEXT DEFAULT '', amount REAL DEFAULT 0, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ) """ ) ) conn.execute( text( """ CREATE INDEX IF NOT EXISTS idx_project_exec_budget_entries_code_pos ON project_exec_budget_entries (support_dept_code, position) """ ) ) conn.execute( text( """ CREATE TABLE IF NOT EXISTS project_actual_input_entries ( id INTEGER PRIMARY KEY AUTOINCREMENT, support_dept_code TEXT NOT NULL, position INTEGER NOT NULL DEFAULT 0, group_name TEXT DEFAULT '', grade TEXT DEFAULT '', minutes TEXT DEFAULT '', rate_year TEXT DEFAULT '', label TEXT DEFAULT '', reference TEXT DEFAULT '', note TEXT DEFAULT '', amount REAL DEFAULT 0, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ) """ ) ) conn.execute( text( """ CREATE INDEX IF NOT EXISTS idx_project_actual_input_entries_code_pos ON project_actual_input_entries (support_dept_code, position) """ ) ) conn.execute( text( """ CREATE TABLE IF NOT EXISTS app_save_events ( id INTEGER PRIMARY KEY AUTOINCREMENT, action_key TEXT NOT NULL, entity_type TEXT NOT NULL, entity_key TEXT DEFAULT '', session_id TEXT DEFAULT '', status TEXT NOT NULL DEFAULT 'ok', duration_ms INTEGER NOT NULL DEFAULT 0, payload_json TEXT DEFAULT '{}', error_message TEXT DEFAULT '', created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ) """ ) ) conn.execute( text( """ CREATE INDEX IF NOT EXISTS idx_app_save_events_lookup ON app_save_events (entity_type, entity_key, created_at DESC) """ ) ) conn.execute( text( """ CREATE TABLE IF NOT EXISTS project_status_snapshots ( id INTEGER PRIMARY KEY AUTOINCREMENT, support_dept_code TEXT NOT NULL, action_key TEXT NOT NULL DEFAULT 'project_status_save', session_id TEXT DEFAULT '', previous_revision TEXT DEFAULT '', revision TEXT DEFAULT '', previous_snapshot_json TEXT DEFAULT '{}', snapshot_json TEXT DEFAULT '{}', created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ) """ ) ) conn.execute( text( """ CREATE INDEX IF NOT EXISTS idx_project_status_snapshots_code_created ON project_status_snapshots (support_dept_code, created_at DESC) """ ) ) conn.execute( text( """ CREATE TABLE IF NOT EXISTS db_backup_history ( id INTEGER PRIMARY KEY AUTOINCREMENT, backup_file TEXT NOT NULL, file_size INTEGER NOT NULL DEFAULT 0, trigger_action TEXT DEFAULT '', session_id TEXT DEFAULT '', created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ) """ ) ) conn.execute( text( """ CREATE TABLE IF NOT EXISTS project_comparison_notes ( support_dept_code TEXT NOT NULL, item_key TEXT NOT NULL, note TEXT DEFAULT '', updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (support_dept_code, item_key) ) """ ) ) conn.execute( text( """ CREATE INDEX IF NOT EXISTS idx_project_comparison_notes_code ON project_comparison_notes (support_dept_code, item_key) """ ) ) conn.execute( text( """ CREATE TABLE IF NOT EXISTS project_analysis_settings ( support_dept_code TEXT NOT NULL PRIMARY KEY, detail_note TEXT DEFAULT '', inactive_related_codes_json TEXT DEFAULT '[]', labor_joint_exempt INTEGER DEFAULT 0, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ) """ ) ) conn.execute( text( """ CREATE INDEX IF NOT EXISTS idx_project_analysis_settings_code ON project_analysis_settings (support_dept_code) """ ) ) page_state_columns_before = { row[1] for row in conn.execute(text("PRAGMA table_info(project_page_state)")).fetchall() } if "session_id" not in page_state_columns_before: legacy_rows = conn.execute( text( """ SELECT page_key, selected_code, selected_year, analysis_open, related_project_selections_json, updated_at FROM project_page_state """ ) ).mappings().all() conn.execute(text("ALTER TABLE project_page_state RENAME TO project_page_state_legacy")) conn.execute( text( """ CREATE TABLE project_page_state ( page_key TEXT NOT NULL, session_id TEXT NOT NULL DEFAULT '', selected_code TEXT DEFAULT '', selected_year TEXT DEFAULT '', analysis_open INTEGER DEFAULT 0, uncontracted_year_start TEXT DEFAULT '', uncontracted_year_end TEXT DEFAULT '', related_project_selections_json TEXT DEFAULT '{}', updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (page_key, session_id) ) """ ) ) for row in legacy_rows: conn.execute( text( """ INSERT INTO project_page_state ( page_key, session_id, selected_code, selected_year, analysis_open, uncontracted_year_start, uncontracted_year_end, related_project_selections_json, updated_at ) VALUES ( :page_key, '', :selected_code, :selected_year, :analysis_open, '', '', :related_project_selections_json, :updated_at ) """ ), dict(row), ) conn.execute(text("DROP TABLE project_page_state_legacy")) conn.execute( text( """ CREATE INDEX IF NOT EXISTS idx_project_page_state_page_session ON project_page_state (page_key, session_id) """ ) ) conn.execute( text( """ CREATE TABLE IF NOT EXISTS project_related_links ( base_support_dept_code TEXT NOT NULL, related_support_dept_code TEXT NOT NULL, link_source TEXT DEFAULT 'manual', updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (base_support_dept_code, related_support_dept_code) ) """ ) ) conn.execute( text( """ CREATE TABLE IF NOT EXISTS project_uncontracted_classification ( support_dept_code TEXT PRIMARY KEY, category TEXT DEFAULT '', updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ) """ ) ) conn.execute( text( """ CREATE INDEX IF NOT EXISTS idx_project_uncontracted_classification_category ON project_uncontracted_classification (category) """ ) ) conn.execute( text( """ CREATE TABLE IF NOT EXISTS project_quick_links ( page_key TEXT NOT NULL, support_dept_code TEXT NOT NULL, sort_order INTEGER NOT NULL DEFAULT 0, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (page_key, support_dept_code) ) """ ) ) quick_link_columns_before = { row[1] for row in conn.execute(text("PRAGMA table_info(project_quick_links)")).fetchall() } if "session_id" in quick_link_columns_before: legacy_rows = conn.execute( text( """ SELECT page_key, support_dept_code, MIN(sort_order) AS sort_order, MAX(updated_at) AS updated_at FROM project_quick_links GROUP BY page_key, support_dept_code """ ) ).mappings().all() conn.execute(text("ALTER TABLE project_quick_links RENAME TO project_quick_links_legacy")) conn.execute( text( """ CREATE TABLE project_quick_links ( page_key TEXT NOT NULL, support_dept_code TEXT NOT NULL, sort_order INTEGER NOT NULL DEFAULT 0, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (page_key, support_dept_code) ) """ ) ) for row in legacy_rows: conn.execute( text( """ INSERT INTO project_quick_links ( page_key, support_dept_code, sort_order, updated_at ) VALUES ( :page_key, :support_dept_code, :sort_order, :updated_at ) """ ), dict(row), ) conn.execute(text("DROP TABLE project_quick_links_legacy")) conn.execute( text( """ CREATE INDEX IF NOT EXISTS idx_project_quick_links_page_sort ON project_quick_links (page_key, sort_order, updated_at) """ ) ) ensure_default_app_config(conn) conn.execute( text( """ CREATE INDEX IF NOT EXISTS idx_project_related_links_base ON project_related_links (base_support_dept_code) """ ) ) conn.execute( text( """ CREATE INDEX IF NOT EXISTS idx_project_related_links_related ON project_related_links (related_support_dept_code) """ ) ) conn.execute( text( """ CREATE TABLE IF NOT EXISTS project_contract_info ( support_dept_code TEXT PRIMARY KEY, raw_contract_code TEXT DEFAULT '', business_division TEXT DEFAULT '', order_method TEXT DEFAULT '', owner_department TEXT DEFAULT '', client_name TEXT DEFAULT '', support_dept_name TEXT DEFAULT '', work_category TEXT DEFAULT '', order_date TEXT DEFAULT '', contract_date TEXT DEFAULT '', project_start_date TEXT DEFAULT '', project_end_date TEXT DEFAULT '', contract_status TEXT DEFAULT '', joint_contract TEXT DEFAULT '', pm_name TEXT DEFAULT '', progress_status TEXT DEFAULT '', total_contract_amount REAL DEFAULT 0, hanmac_contract_amount REAL DEFAULT 0, review_tag TEXT DEFAULT '', review_note TEXT DEFAULT '', source_file TEXT DEFAULT '', updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ) """ ) ) conn.execute( text( """ CREATE INDEX IF NOT EXISTS idx_project_contract_info_updated_at ON project_contract_info (updated_at) """ ) ) conn.execute( text( """ CREATE TABLE IF NOT EXISTS project_contract_change_summary ( id INTEGER PRIMARY KEY AUTOINCREMENT, raw_summary_code TEXT DEFAULT '', normalized_title TEXT DEFAULT '', owner_department TEXT DEFAULT '', business_division TEXT DEFAULT '', support_dept_name TEXT DEFAULT '', change_date TEXT DEFAULT '', client_name TEXT DEFAULT '', original_contract_period TEXT DEFAULT '', changed_project_end_date TEXT DEFAULT '', initial_contract_amount REAL DEFAULT 0, previous_contract_amount REAL DEFAULT 0, changed_contract_amount REAL DEFAULT 0, delta_amount REAL DEFAULT 0, source_file TEXT DEFAULT '', updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ) """ ) ) conn.execute( text( """ CREATE INDEX IF NOT EXISTS idx_project_contract_change_summary_code ON project_contract_change_summary (raw_summary_code, change_date) """ ) ) conn.execute( text( """ CREATE INDEX IF NOT EXISTS idx_project_contract_change_summary_title ON project_contract_change_summary (normalized_title) """ ) ) conn.execute( text( """ CREATE TABLE IF NOT EXISTS project_contract_change_round ( id INTEGER PRIMARY KEY AUTOINCREMENT, support_dept_code TEXT DEFAULT '', raw_round_code TEXT DEFAULT '', normalized_title TEXT DEFAULT '', owner_department TEXT DEFAULT '', business_division TEXT DEFAULT '', support_dept_name TEXT DEFAULT '', change_date TEXT DEFAULT '', client_name TEXT DEFAULT '', original_contract_period TEXT DEFAULT '', changed_project_end_date TEXT DEFAULT '', initial_contract_amount REAL DEFAULT 0, changed_contract_amount REAL DEFAULT 0, delta_amount REAL DEFAULT 0, source_file TEXT DEFAULT '', updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ) """ ) ) conn.execute( text( """ CREATE INDEX IF NOT EXISTS idx_project_contract_change_round_code ON project_contract_change_round (support_dept_code, change_date) """ ) ) conn.execute( text( """ CREATE INDEX IF NOT EXISTS idx_project_contract_change_round_title ON project_contract_change_round (normalized_title) """ ) ) conn.execute( text( """ CREATE TABLE IF NOT EXISTS project_billing_entries ( id INTEGER PRIMARY KEY AUTOINCREMENT, support_dept_code TEXT, raw_project_code TEXT DEFAULT '', round_code TEXT DEFAULT '', support_department TEXT DEFAULT '', business_division TEXT DEFAULT '', support_dept_name TEXT DEFAULT '', contract_amount REAL DEFAULT 0, client_name TEXT DEFAULT '', billing_type TEXT DEFAULT '', progress_round TEXT DEFAULT '', billing_date TEXT DEFAULT '', tax_invoice_date TEXT DEFAULT '', expected_collection_date TEXT DEFAULT '', billed_amount REAL DEFAULT 0, collected_amount REAL DEFAULT 0, balance_amount REAL DEFAULT 0, collection_rate REAL DEFAULT 0, note TEXT DEFAULT '', source_file TEXT DEFAULT '', updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ) """ ) ) conn.execute( text( """ CREATE INDEX IF NOT EXISTS idx_project_billing_entries_code ON project_billing_entries (support_dept_code, billing_date) """ ) ) conn.execute( text( """ CREATE INDEX IF NOT EXISTS idx_project_billing_entries_raw_code ON project_billing_entries (raw_project_code) """ ) ) conn.execute( text( """ CREATE INDEX IF NOT EXISTS idx_project_billing_entries_round_code ON project_billing_entries (round_code) """ ) ) conn.execute( text( """ CREATE INDEX IF NOT EXISTS idx_project_billing_entries_source_file ON project_billing_entries (source_file) """ ) ) conn.execute( text( """ CREATE INDEX IF NOT EXISTS idx_project_billing_entries_updated_at ON project_billing_entries (updated_at) """ ) ) conn.execute( text( """ CREATE INDEX IF NOT EXISTS idx_project_billing_entries_effective_date_code ON project_billing_entries ( COALESCE(COALESCE(tax_invoice_date, billing_date), ''), support_dept_code ) """ ) ) existing_columns = { row[1] for row in conn.execute(text("PRAGMA table_info(project_status)")).fetchall() } required_columns = { "last_editor_session_id": "TEXT DEFAULT ''", "last_client_submitted_at": "TEXT DEFAULT ''", "contract_amount": "REAL DEFAULT 0", "collection_entries_json": "TEXT DEFAULT '[]'", "task_plan_department_budget": "REAL DEFAULT 0", "task_plan_outsource_budget": "REAL DEFAULT 0", "task_plan_outsource_detail": "TEXT DEFAULT ''", "task_plan_joint_operating_cost": "REAL DEFAULT 0", "task_plan_entries_json": "TEXT DEFAULT '[]'", "exec_budget_labor_by_grade": "REAL DEFAULT 0", "exec_labor_rates_json": "TEXT DEFAULT '{}'", "exec_budget_outsource": "REAL DEFAULT 0", "exec_budget_cost_plan": "REAL DEFAULT 0", "exec_budget_entries_json": "TEXT DEFAULT '[]'", "actual_input_entries_json": "TEXT DEFAULT '[]'", "project_type": "TEXT DEFAULT ''", "expected_as_rate": "REAL DEFAULT 0", "expected_sga_rate": "REAL DEFAULT 0", "expected_as_cost": "REAL DEFAULT 0", "expected_sga_budget": "REAL DEFAULT 0", "project_start_date": "TEXT DEFAULT ''", "project_end_date": "TEXT DEFAULT ''", "completion_status": "TEXT DEFAULT ''", } for column_name, column_type in required_columns.items(): if column_name not in existing_columns: conn.execute(text(f"ALTER TABLE project_status ADD COLUMN {column_name} {column_type}")) page_state_columns = { row[1] for row in conn.execute(text("PRAGMA table_info(project_page_state)")).fetchall() } required_page_state_columns = { "session_id": "TEXT DEFAULT ''", "selected_code": "TEXT DEFAULT ''", "selected_year": "TEXT DEFAULT ''", "analysis_open": "INTEGER DEFAULT 0", "uncontracted_year_start": "TEXT DEFAULT ''", "uncontracted_year_end": "TEXT DEFAULT ''", "related_project_selections_json": "TEXT DEFAULT '{}'", } for column_name, column_type in required_page_state_columns.items(): if column_name not in page_state_columns: conn.execute(text(f"ALTER TABLE project_page_state ADD COLUMN {column_name} {column_type}")) related_link_columns = { row[1] for row in conn.execute(text("PRAGMA table_info(project_related_links)")).fetchall() } if "link_source" not in related_link_columns: conn.execute(text("ALTER TABLE project_related_links ADD COLUMN link_source TEXT DEFAULT 'manual'")) exec_budget_entry_columns = { row[1] for row in conn.execute(text("PRAGMA table_info(project_exec_budget_entries)")).fetchall() } if "rate_year" not in exec_budget_entry_columns: conn.execute(text("ALTER TABLE project_exec_budget_entries ADD COLUMN rate_year TEXT DEFAULT ''")) actual_input_entry_columns = { row[1] for row in conn.execute(text("PRAGMA table_info(project_actual_input_entries)")).fetchall() } if "rate_year" not in actual_input_entry_columns: conn.execute(text("ALTER TABLE project_actual_input_entries ADD COLUMN rate_year TEXT DEFAULT ''")) migrate_project_status_entries(conn) migrate_project_basic_info(conn) ensure_auth_schema(conn) ensure_default_app_config(conn) trans.commit() conn.close() _DB_INIT_DONE = True run_startup_analyze = normalize_text(os.getenv("HM_RUN_STARTUP_ANALYZE", "0")) in {"1", "true", "yes", "on"} if run_startup_analyze: _maybe_run_db_analyze() else: logger.info("Skipping startup ANALYZE; set HM_RUN_STARTUP_ANALYZE=1 to run it before serving") @lru_cache(maxsize=1) def load_app_config() -> dict[str, Any]: with engine.connect() as conn: option_rows = conn.execute( text( """ SELECT group_key, item_key, label, value_text, sort_order FROM app_option_items WHERE is_active = 1 ORDER BY group_key, sort_order, id """ ) ).mappings().all() keyword_rows = conn.execute( text( """ SELECT rule_group, category_key, keyword, sort_order FROM app_keyword_rules WHERE is_active = 1 ORDER BY rule_group, category_key, sort_order, id """ ) ).mappings().all() options: dict[str, list[dict[str, Any]]] = {} for row in option_rows: options.setdefault(row["group_key"], []).append( { "item_key": row["item_key"], "label": row["label"], "value": row["value_text"], "sort_order": row["sort_order"], } ) keyword_rules: dict[str, dict[str, list[str]]] = {} for row in keyword_rows: keyword_rules.setdefault(row["rule_group"], {}).setdefault(row["category_key"], []).append(row["keyword"]) return {"options": options, "keyword_rules": keyword_rules} def get_option_items(group_key: str) -> list[dict[str, Any]]: return list(load_app_config().get("options", {}).get(group_key, [])) def get_keyword_rule_groups(rule_group: str) -> dict[str, list[str]]: return dict(load_app_config().get("keyword_rules", {}).get(rule_group, {})) def get_labor_grade_options() -> list[str]: return [item["label"] for item in get_option_items("labor_grades")] def round_percentage_rate(value: Any) -> int: text_value = normalize_text(value) if not text_value: return 0 try: return int(Decimal(text_value).quantize(Decimal("1"), rounding=ROUND_HALF_UP)) except (InvalidOperation, ValueError): return 0 def format_rounded_percentage_options(group_key: str) -> list[dict[str, Any]]: default_values_by_group = { "expected_as_rates": {"0", "2", "5", "10"}, "expected_sga_rates": {"13", "15", "20", "25"}, } default_values = default_values_by_group.get(group_key, set()) default_options: list[dict[str, Any]] = [] existing_options: list[dict[str, Any]] = [] seen_values: set[str] = set() for option in get_option_items(group_key): rounded_rate = round_percentage_rate(option.get("value")) value = str(rounded_rate) if value in seen_values: continue is_default = value in default_values or int(option.get("sort_order") or 0) < 999 if not is_default and rounded_rate < 0: continue option_payload = { **option, "label": f"{rounded_rate}%", "value": value, "rounded_rate": rounded_rate, "is_existing_value": not is_default, } seen_values.add(value) if is_default: default_options.append(option_payload) else: existing_options.append(option_payload) existing_options.sort(key=lambda item: (item["rounded_rate"], item.get("label", ""))) return default_options + existing_options def get_expected_as_rate_options() -> list[dict[str, Any]]: return format_rounded_percentage_options("expected_as_rates") def get_expected_sga_rate_options() -> list[dict[str, Any]]: return format_rounded_percentage_options("expected_sga_rates") def get_collection_progress_type_options() -> list[str]: preferred_order = {"선급금": 0, "기성금": 1, "준공금": 2} with engine.begin() as conn: rows = conn.execute( text( """ SELECT DISTINCT billing_type FROM project_billing_entries WHERE COALESCE(billing_type, '') <> '' ORDER BY billing_type """ ) ).fetchall() values = { normalized for (value,) in rows for normalized in [normalize_collection_progress_type(value)] if normalized } if not values: values = {"선급금", "기성금", "준공금"} return sorted(values, key=lambda item: (preferred_order.get(item, 999), item)) def get_collection_billing_type_options() -> list[str]: with engine.begin() as conn: rows = conn.execute( text( """ SELECT DISTINCT billing_type FROM project_collection_entries WHERE COALESCE(billing_type, '') <> '' ORDER BY billing_type """ ) ).fetchall() values = { normalized for (value,) in rows for normalized in [normalize_collection_billing_type(value)] if normalized } values.add("기타") if not values: values = {"계약분", "기타"} return list(sorted(values)) def get_uncontracted_category_options() -> list[dict[str, Any]]: return get_option_items("uncontracted_categories") def get_project_runtime_settings() -> dict[str, str]: return {item["item_key"]: str(item.get("value", "")) for item in get_option_items("project_rules")} def get_shared_exec_labor_rates_json() -> str: shared_items = {item["item_key"]: item for item in get_option_items("project_shared")} value = normalize_text((shared_items.get("exec_labor_rates_json") or {}).get("value")) if value and value != "{}": return value with engine.begin() as conn: fallback = normalize_text( conn.execute( text( """ SELECT COALESCE(exec_labor_rates_json, '{}') FROM project_basic_info WHERE COALESCE(exec_labor_rates_json, '{}') <> '{}' ORDER BY updated_at DESC LIMIT 1 """ ) ).scalar() ) return fallback or "{}" def get_shared_exec_labor_rates() -> dict[str, Any]: try: parsed = json.loads(get_shared_exec_labor_rates_json()) if not isinstance(parsed, dict): return copy.deepcopy(DEFAULT_EXEC_LABOR_RATES) return merge_exec_labor_rates_with_defaults(parsed) except json.JSONDecodeError: return copy.deepcopy(DEFAULT_EXEC_LABOR_RATES) def merge_exec_labor_rates_with_defaults(raw_rates: Any) -> dict[str, Any]: """Keep the Hanmac default labor table complete while preserving saved overrides.""" merged = copy.deepcopy(DEFAULT_EXEC_LABOR_RATES) if not isinstance(raw_rates, dict): return merged for year_key, year_bucket in raw_rates.items(): year_text = normalize_text(year_key) if not year_text or not isinstance(year_bucket, dict): continue merged.setdefault(year_text, {"설계": {}, "감리": {}, "지원": {}}) has_category_bucket = any(isinstance(value, dict) for value in year_bucket.values()) if has_category_bucket: for category_key, category_bucket in year_bucket.items(): if not isinstance(category_bucket, dict): continue category = _normalize_labor_rate_category(category_key) merged[year_text].setdefault(category, {}) for grade_key, amount_value in category_bucket.items(): grade_text = _normalize_labor_grade_name(grade_key) if not grade_text: continue amount = normalize_amount(amount_value) if amount: merged[year_text][category][grade_text] = int(amount) else: merged[year_text].setdefault("설계", {}) for grade_key, amount_value in year_bucket.items(): grade_text = _normalize_labor_grade_name(grade_key) if not grade_text: continue amount = normalize_amount(amount_value) if amount: merged[year_text]["설계"][grade_text] = int(amount) if not merged[year_text].get("지원"): merged[year_text]["지원"] = dict(merged[year_text].get("감리") or {}) return merged def save_shared_exec_labor_rates(conn: Any, exec_labor_rates_json: str) -> None: normalized_json = normalize_text(exec_labor_rates_json) or "{}" conn.execute( text( """ INSERT INTO app_option_items ( group_key, item_key, label, value_text, sort_order, is_active, meta_json ) VALUES ( 'project_shared', 'exec_labor_rates_json', '공통 기준인건비', :value_text, 0, 1, '{}' ) ON CONFLICT(group_key, item_key) DO UPDATE SET value_text = excluded.value_text, is_active = 1 """ ), {"value_text": normalized_json}, ) load_app_config.cache_clear() def get_special_x_classification_rules() -> dict[str, list[str]]: return get_keyword_rule_groups("special_x_classification") def _safe_json_dumps(value: Any) -> str: return json.dumps(value, ensure_ascii=False, separators=(",", ":"), default=str) def log_save_event( action_key: str, entity_type: str, entity_key: Any = "", *, session_id: Any = "", status: str = "ok", duration_ms: int = 0, payload: Any = None, error_message: Any = "", ) -> None: try: with engine.begin() as conn: conn.execute( text( """ INSERT INTO app_save_events ( action_key, entity_type, entity_key, session_id, status, duration_ms, payload_json, error_message ) VALUES ( :action_key, :entity_type, :entity_key, :session_id, :status, :duration_ms, :payload_json, :error_message ) """ ), { "action_key": normalize_text(action_key), "entity_type": normalize_text(entity_type), "entity_key": normalize_text(entity_key), "session_id": normalize_text(session_id), "status": normalize_text(status) or "ok", "duration_ms": max(int(duration_ms or 0), 0), "payload_json": _safe_json_dumps(payload or {}), "error_message": normalize_text(error_message), }, ) except Exception as exc: logger.warning("저장 이벤트 로그 기록 실패: %s", exc) def prune_old_backups() -> None: backup_files = sorted( ( path for path in BACKUP_DIR.glob("data-*.sqlite3") if path.is_file() ), key=lambda path: path.stat().st_mtime, reverse=True, ) for stale_path in backup_files[DB_BACKUP_KEEP_COUNT:]: try: stale_path.unlink(missing_ok=True) except Exception as exc: logger.warning("오래된 백업 파일 정리 실패(%s): %s", stale_path.name, exc) def maybe_create_database_backup(trigger_action: str, session_id: Any = "", force: bool = False) -> str: now = time.monotonic() if not force and now - float(_DB_BACKUP_STATE.get("last_run_at") or 0.0) < DB_BACKUP_MIN_INTERVAL_SECONDS: return "" if not _DB_BACKUP_LOCK.acquire(blocking=False): return "" try: now = time.monotonic() if not force and now - float(_DB_BACKUP_STATE.get("last_run_at") or 0.0) < DB_BACKUP_MIN_INTERVAL_SECONDS: return "" stamp = datetime.now().strftime("%Y%m%d-%H%M%S") temp_path = BACKUP_DIR / f".data-{stamp}.tmp" final_path = BACKUP_DIR / f"data-{stamp}.sqlite3" source_conn = sqlite3.connect(DB_PATH) backup_conn = sqlite3.connect(temp_path) try: source_conn.backup(backup_conn) finally: backup_conn.close() source_conn.close() temp_path.replace(final_path) file_size = final_path.stat().st_size if final_path.exists() else 0 _DB_BACKUP_STATE["last_run_at"] = time.monotonic() prune_old_backups() try: with engine.begin() as conn: conn.execute( text( """ INSERT INTO db_backup_history ( backup_file, file_size, trigger_action, session_id ) VALUES ( :backup_file, :file_size, :trigger_action, :session_id ) """ ), { "backup_file": final_path.name, "file_size": int(file_size or 0), "trigger_action": normalize_text(trigger_action), "session_id": normalize_text(session_id), }, ) log_save_event( "db_backup", "system", final_path.name, session_id=session_id, payload={"trigger_action": trigger_action, "file_size": int(file_size or 0)}, ) except Exception as exc: logger.warning("DB 백업 이력 저장 실패: %s", exc) return final_path.name except Exception as exc: logger.warning("DB 백업 생성 실패: %s", exc) return "" finally: _DB_BACKUP_LOCK.release() def _hour_is_in_window(hour: int, start_hour: int, end_hour: int) -> bool: if start_hour == end_hour: return True if start_hour < end_hour: return start_hour <= hour < end_hour return hour >= start_hour or hour < end_hour def _maybe_run_db_vacuum() -> None: global _DB_VACUUM_LAST_ATTEMPT_AT enabled = normalize_text(os.getenv("HM_AUTO_VACUUM_ENABLED", "0")).lower() in {"1", "true", "yes", "on"} if not enabled: return now = time.time() if now - _DB_VACUUM_LAST_ATTEMPT_AT < DB_VACUUM_MIN_INTERVAL_SECONDS: return current_hour = datetime.now().hour if not _hour_is_in_window(current_hour, DB_VACUUM_WINDOW_START_HOUR, DB_VACUUM_WINDOW_END_HOUR): return if not _DB_VACUUM_LOCK.acquire(blocking=False): return try: now = time.time() if now - _DB_VACUUM_LAST_ATTEMPT_AT < DB_VACUUM_MIN_INTERVAL_SECONDS: return _DB_VACUUM_LAST_ATTEMPT_AT = now with engine.begin() as conn: page_size = int(conn.execute(text("PRAGMA page_size")).scalar() or 0) freelist_count = int(conn.execute(text("PRAGMA freelist_count")).scalar() or 0) free_bytes = page_size * freelist_count if free_bytes < DB_VACUUM_FREELIST_MIN_BYTES: return backup_name = maybe_create_database_backup("auto_vacuum", force=True) if not backup_name: logger.warning("Auto VACUUM skipped because pre-vacuum backup was not created") return logger.warning( "Auto VACUUM starting after backup %s; freelist bytes=%s", backup_name, free_bytes, ) vacuum_conn = sqlite3.connect(DB_PATH, timeout=30) try: vacuum_conn.execute("PRAGMA busy_timeout=30000") vacuum_conn.execute("VACUUM") finally: vacuum_conn.close() logger.warning("Auto VACUUM completed") except OperationalError as exc: logger.warning("Auto VACUUM skipped due to database lock: %s", exc) except Exception as exc: logger.warning("Auto VACUUM skipped due to unexpected error: %s", exc) finally: _DB_VACUUM_LOCK.release() def load_project_status_snapshot_payload(conn: Any, support_dept_code: str) -> dict[str, Any]: normalized_code = normalize_text(support_dept_code) if not normalized_code: return {} row = conn.execute( text("SELECT * FROM project_status WHERE support_dept_code = :support_dept_code"), {"support_dept_code": normalized_code}, ).mappings().first() entry_set = load_project_status_entries_for_code(conn, normalized_code) has_entries = any(entry_set.get(key) for key in entry_set) if not row and not has_entries: return {} base = dict(row) if row else {"support_dept_code": normalized_code} return { "support_dept_code": normalized_code, "support_dept_name": normalize_text(base.get("support_dept_name")), "contract_amount": normalize_amount(base.get("contract_amount")), "collection_amount": normalize_amount(base.get("collection_amount")), "progress_rate": normalize_amount(base.get("progress_rate")), "project_type": normalize_text(base.get("project_type")), "expected_as_rate": normalize_amount(base.get("expected_as_rate")), "expected_sga_rate": normalize_amount(base.get("expected_sga_rate")), "expected_as_cost": normalize_amount(base.get("expected_as_cost")), "expected_sga_budget": normalize_amount(base.get("expected_sga_budget")), "change_round": normalize_text(base.get("change_round")), "project_start_date": normalize_text(base.get("project_start_date")), "project_end_date": normalize_text(base.get("project_end_date")), "completion_status": normalize_text(base.get("completion_status")), "notes": normalize_text(base.get("notes")), "updated_at": normalize_text(base.get("updated_at")), "collection_entries": entry_set.get("collection_entries", []), "task_plan_entries": entry_set.get("task_plan_entries", []), "exec_budget_entries": entry_set.get("exec_budget_entries", []), "actual_input_entries": entry_set.get("actual_input_entries", []), } def record_project_status_snapshot( support_dept_code: str, session_id: Any, previous_snapshot: dict[str, Any], next_snapshot: dict[str, Any], ) -> None: normalized_code = normalize_text(support_dept_code) if not normalized_code: return try: with engine.begin() as conn: conn.execute( text( """ INSERT INTO project_status_snapshots ( support_dept_code, action_key, session_id, previous_revision, revision, previous_snapshot_json, snapshot_json ) VALUES ( :support_dept_code, 'project_status_save', :session_id, :previous_revision, :revision, :previous_snapshot_json, :snapshot_json ) """ ), { "support_dept_code": normalized_code, "session_id": normalize_text(session_id), "previous_revision": normalize_text((previous_snapshot or {}).get("updated_at")), "revision": normalize_text((next_snapshot or {}).get("updated_at")), "previous_snapshot_json": _safe_json_dumps(previous_snapshot or {}), "snapshot_json": _safe_json_dumps(next_snapshot or {}), }, ) except Exception as exc: logger.warning("프로젝트 스냅샷 기록 실패(%s): %s", normalized_code, exc) def save_project_runtime_setting(item_key: Any, value_text: Any) -> None: normalized_item_key = normalize_text(item_key) if not normalized_item_key: raise ValueError("설정 키가 올바르지 않습니다.") with engine.begin() as conn: existing = conn.execute( text( """ SELECT group_key, item_key FROM app_option_items WHERE group_key = 'project_rules' AND item_key = :item_key """ ), {"item_key": normalized_item_key}, ).mappings().first() if not existing: raise ValueError("존재하지 않는 런타임 설정입니다.") conn.execute( text( """ UPDATE app_option_items SET value_text = :value_text, updated_at = CURRENT_TIMESTAMP WHERE group_key = 'project_rules' AND item_key = :item_key """ ), { "item_key": normalized_item_key, "value_text": normalize_text(value_text), }, ) load_app_config.cache_clear() log_save_event( "project_runtime_setting_save", "project_runtime_setting", normalized_item_key, payload={"value_text": normalize_text(value_text)}, ) def count_transactions() -> int: with engine.begin() as conn: return conn.execute(text("SELECT COUNT(*) FROM transactions")).scalar_one() def existing_source_files() -> set[str]: with engine.begin() as conn: rows = conn.execute( text("SELECT DISTINCT source_file FROM transactions WHERE COALESCE(source_file, '') <> ''") ).fetchall() return {normalize_text(row[0]) for row in rows if normalize_text(row[0])} def existing_contract_source_files() -> set[str]: with engine.begin() as conn: rows = conn.execute( text("SELECT DISTINCT source_file FROM project_contract_info WHERE COALESCE(source_file, '') <> ''") ).fetchall() return {normalize_text(row[0]) for row in rows if normalize_text(row[0])} def existing_billing_source_files() -> set[str]: with engine.begin() as conn: rows = conn.execute( text("SELECT DISTINCT source_file FROM project_billing_entries WHERE COALESCE(source_file, '') <> ''") ).fetchall() return {normalize_text(row[0]) for row in rows if normalize_text(row[0])} def existing_change_contract_summary_source_files() -> set[str]: with engine.begin() as conn: rows = conn.execute( text("SELECT DISTINCT source_file FROM project_contract_change_summary WHERE COALESCE(source_file, '') <> ''") ).fetchall() return {normalize_text(row[0]) for row in rows if normalize_text(row[0])} def existing_change_contract_round_source_files() -> set[str]: with engine.begin() as conn: rows = conn.execute( text("SELECT DISTINCT source_file FROM project_contract_change_round WHERE COALESCE(source_file, '') <> ''") ).fetchall() return {normalize_text(row[0]) for row in rows if normalize_text(row[0])} def workbook_row_values(sheet: Any, row_number: int) -> list[str]: return [normalize_text(sheet.cell(row_number, column).value) for column in range(1, sheet.max_column + 1)] def detect_excel_import_kind(workbook: Any, filename: str = "") -> str: sheet = workbook.active row1 = workbook_row_values(sheet, 1) row5 = workbook_row_values(sheet, 5) if sheet.max_row >= 5 else [] filename = normalize_text(filename) if {"총괄코드", "총 계약금액", "한맥계약금액"}.issubset(set(row1)): return "contract_status" if {"총괄코드", "최초계약금액", "이전계약금액", "변경계약금액", "증감액"}.issubset(set(row1)): return "change_contract_summary" if {"차수코드", "당초계약금액", "변경계약금액", "증감액"}.issubset(set(row1)): return "change_contract_round" if {"차수코드", "차수사업명", "청구금액", "수금금액"}.issubset(set(row5)): return "billing_status" if "계약현황" in filename: return "contract_status" if "변경계약금액현황" in filename and "총괄" in filename: return "change_contract_summary" if "변경계약금액현황" in filename and "차수" in filename: return "change_contract_round" if "기성청구현황" in filename: return "billing_status" return "transactions" def import_contract_status_workbook(workbook: Any, source_file: str) -> int: sheet = workbook.active with engine.begin() as conn: conn.execute( text("DELETE FROM project_contract_info WHERE source_file = :source_file"), {"source_file": source_file}, ) inserted = 0 for row in sheet.iter_rows(min_row=2, values_only=True): business_division = normalize_text(row[0] if len(row) > 0 else "") support_dept_code = normalize_actual_project_code(row[1] if len(row) > 1 else "") if not support_dept_code: continue payload = { "support_dept_code": support_dept_code, "raw_contract_code": normalize_text(row[1] if len(row) > 1 else ""), "business_division": business_division, "order_method": normalize_text(row[2] if len(row) > 2 else ""), "owner_department": normalize_text(row[3] if len(row) > 3 else ""), "client_name": normalize_text(row[4] if len(row) > 4 else ""), "support_dept_name": normalize_text(row[5] if len(row) > 5 else ""), "work_category": normalize_text(row[6] if len(row) > 6 else ""), "order_date": normalize_date_text(row[7] if len(row) > 7 else ""), "contract_date": normalize_date_text(row[8] if len(row) > 8 else ""), "project_start_date": normalize_date_text(row[9] if len(row) > 9 else ""), "project_end_date": normalize_date_text(row[10] if len(row) > 10 else ""), "contract_status": normalize_text(row[11] if len(row) > 11 else ""), "joint_contract": normalize_text(row[12] if len(row) > 12 else ""), "pm_name": normalize_text(row[13] if len(row) > 13 else ""), "progress_status": normalize_text(row[14] if len(row) > 14 else ""), "total_contract_amount": normalize_amount(row[15] if len(row) > 15 else 0), "hanmac_contract_amount": normalize_amount(row[16] if len(row) > 16 else 0), "review_tag": "", "review_note": "", "source_file": source_file, } conn.execute( text( """ INSERT INTO project_contract_info ( support_dept_code, raw_contract_code, business_division, order_method, owner_department, client_name, support_dept_name, work_category, order_date, contract_date, project_start_date, project_end_date, contract_status, joint_contract, pm_name, progress_status, total_contract_amount, hanmac_contract_amount, review_tag, review_note, source_file, updated_at ) VALUES ( :support_dept_code, :raw_contract_code, :business_division, :order_method, :owner_department, :client_name, :support_dept_name, :work_category, :order_date, :contract_date, :project_start_date, :project_end_date, :contract_status, :joint_contract, :pm_name, :progress_status, :total_contract_amount, :hanmac_contract_amount, :review_tag, :review_note, :source_file, CURRENT_TIMESTAMP ) ON CONFLICT(support_dept_code) DO UPDATE SET raw_contract_code = excluded.raw_contract_code, business_division = excluded.business_division, order_method = excluded.order_method, owner_department = excluded.owner_department, client_name = excluded.client_name, support_dept_name = excluded.support_dept_name, work_category = excluded.work_category, order_date = excluded.order_date, contract_date = excluded.contract_date, project_start_date = excluded.project_start_date, project_end_date = excluded.project_end_date, contract_status = excluded.contract_status, joint_contract = excluded.joint_contract, pm_name = excluded.pm_name, progress_status = excluded.progress_status, total_contract_amount = excluded.total_contract_amount, hanmac_contract_amount = excluded.hanmac_contract_amount, source_file = excluded.source_file, updated_at = CURRENT_TIMESTAMP """ ), payload, ) inserted += 1 refresh_contract_review_tags() sync_auto_project_related_links() return inserted def import_billing_status_workbook(workbook: Any, source_file: str) -> int: sheet = workbook.active with engine.begin() as conn: conn.execute( text("DELETE FROM project_billing_entries WHERE source_file = :source_file"), {"source_file": source_file}, ) inserted = 0 current: dict[str, Any] = {} for row in sheet.iter_rows(min_row=6, values_only=True): values = list(row) if values and all(value in (None, "") for value in values): continue if values[0] is not None: current["support_department"] = normalize_text(values[0]) if len(values) > 1 and values[1] is not None: current["business_division"] = normalize_text(values[1]) if len(values) > 2 and values[2] is not None: current["raw_project_code"] = normalize_text(values[2]) if len(values) > 3 and values[3] is not None: current["round_code"] = normalize_text(values[3]) if len(values) > 4 and values[4] is not None: current["support_dept_name"] = normalize_text(values[4]) if len(values) > 5 and values[5] is not None: current["contract_amount"] = normalize_amount(values[5]) if len(values) > 6 and values[6] is not None: current["client_name"] = normalize_text(values[6]) round_code_text = normalize_text(current.get("round_code")) round_prefix = next((character.upper() for character in round_code_text if character.isalpha()), "Y") normalized_round_code = normalize_project_code( current.get("round_code"), default_prefix=round_prefix, ) normalized_raw_project_code = normalize_project_code( current.get("raw_project_code"), default_prefix=round_prefix, ) # Billing workbook stores the parent contract code in raw_project_code # and the actual charge/collection project code in round_code. # Prefer round_code when present so each sub-project keeps its own billing history. support_dept_code = normalized_round_code or normalized_raw_project_code if not support_dept_code: continue summary_row = normalize_text(values[11] if len(values) > 11 else "") == "합계" or normalize_text(values[10] if len(values) > 10 else "").startswith("수금 :") department_summary = "합계" in normalize_text(values[4] if len(values) > 4 else "") if summary_row or department_summary: continue payload = { "support_dept_code": support_dept_code, "raw_project_code": normalize_text(current.get("raw_project_code")), "round_code": normalize_text(current.get("round_code")), "support_department": normalize_text(current.get("support_department")), "business_division": normalize_text(current.get("business_division")), "support_dept_name": normalize_text(current.get("support_dept_name")), "contract_amount": normalize_amount(current.get("contract_amount")), "client_name": normalize_text(current.get("client_name")), "billing_type": normalize_text(values[7] if len(values) > 7 else ""), "progress_round": normalize_round_value(values[8] if len(values) > 8 else ""), "billing_date": normalize_date_text(values[9] if len(values) > 9 else ""), "tax_invoice_date": normalize_date_text(values[10] if len(values) > 10 else ""), "expected_collection_date": normalize_date_text(values[11] if len(values) > 11 else ""), "billed_amount": normalize_amount(values[12] if len(values) > 12 else 0), "collected_amount": normalize_amount(values[13] if len(values) > 13 else 0), "balance_amount": normalize_amount(values[14] if len(values) > 14 else 0), "collection_rate": normalize_amount(values[15] if len(values) > 15 else 0), "note": normalize_text(values[16] if len(values) > 16 else ""), "source_file": source_file, } conn.execute( text( """ INSERT INTO project_billing_entries ( support_dept_code, raw_project_code, round_code, support_department, business_division, support_dept_name, contract_amount, client_name, billing_type, progress_round, billing_date, tax_invoice_date, expected_collection_date, billed_amount, collected_amount, balance_amount, collection_rate, note, source_file, updated_at ) VALUES ( :support_dept_code, :raw_project_code, :round_code, :support_department, :business_division, :support_dept_name, :contract_amount, :client_name, :billing_type, :progress_round, :billing_date, :tax_invoice_date, :expected_collection_date, :billed_amount, :collected_amount, :balance_amount, :collection_rate, :note, :source_file, CURRENT_TIMESTAMP ) """ ), payload, ) inserted += 1 refresh_contract_review_tags() return inserted def import_change_contract_summary_workbook(workbook: Any, source_file: str) -> int: sheet = workbook.active with engine.begin() as conn: conn.execute( text("DELETE FROM project_contract_change_summary WHERE source_file = :source_file"), {"source_file": source_file}, ) inserted = 0 current_department = "" for row in sheet.iter_rows(min_row=2, values_only=True): values = list(row) if not values or all(value in (None, "") for value in values): continue first_value = normalize_text(values[0] if len(values) > 0 else "") if "소 계" in first_value or first_value.startswith("<"): continue if first_value: current_department = first_value raw_summary_code = normalize_text(values[2] if len(values) > 2 else "").replace("\xa0", "") support_dept_name = normalize_text(values[3] if len(values) > 3 else "") if not raw_summary_code or not support_dept_name: continue business_division = normalize_text(values[1] if len(values) > 1 else "") payload = { "raw_summary_code": "".join(character for character in raw_summary_code if character.isdigit()), "normalized_title": normalize_project_title_for_linking(support_dept_name), "owner_department": current_department, "business_division": business_division, "support_dept_name": support_dept_name, "change_date": normalize_date_text(values[4] if len(values) > 4 else ""), "client_name": normalize_text(values[5] if len(values) > 5 else ""), "original_contract_period": normalize_text(values[6] if len(values) > 6 else ""), "changed_project_end_date": normalize_date_text(values[7] if len(values) > 7 else ""), "initial_contract_amount": normalize_amount(values[8] if len(values) > 8 else 0), "previous_contract_amount": normalize_amount(values[9] if len(values) > 9 else 0), "changed_contract_amount": normalize_amount(values[10] if len(values) > 10 else 0), "delta_amount": normalize_amount(values[11] if len(values) > 11 else 0), "source_file": source_file, } conn.execute( text( """ INSERT INTO project_contract_change_summary ( raw_summary_code, normalized_title, owner_department, business_division, support_dept_name, change_date, client_name, original_contract_period, changed_project_end_date, initial_contract_amount, previous_contract_amount, changed_contract_amount, delta_amount, source_file, updated_at ) VALUES ( :raw_summary_code, :normalized_title, :owner_department, :business_division, :support_dept_name, :change_date, :client_name, :original_contract_period, :changed_project_end_date, :initial_contract_amount, :previous_contract_amount, :changed_contract_amount, :delta_amount, :source_file, CURRENT_TIMESTAMP ) """ ), payload, ) inserted += 1 sync_auto_project_related_links() return inserted def import_change_contract_round_workbook(workbook: Any, source_file: str) -> int: sheet = workbook.active with engine.begin() as conn: conn.execute( text("DELETE FROM project_contract_change_round WHERE source_file = :source_file"), {"source_file": source_file}, ) inserted = 0 current_department = "" for row in sheet.iter_rows(min_row=2, values_only=True): values = list(row) if not values or all(value in (None, "") for value in values): continue first_value = normalize_text(values[0] if len(values) > 0 else "") if "소 계" in first_value or first_value.startswith("<"): continue if first_value: current_department = first_value raw_round_code = normalize_text(values[2] if len(values) > 2 else "").replace("\xa0", "") support_dept_name = normalize_text(values[3] if len(values) > 3 else "") business_division = normalize_text(values[1] if len(values) > 1 else "") support_dept_code = normalize_actual_project_code(raw_round_code) if not support_dept_code or not support_dept_name: continue payload = { "support_dept_code": support_dept_code, "raw_round_code": raw_round_code, "normalized_title": normalize_project_title_for_linking(support_dept_name), "owner_department": current_department, "business_division": business_division, "support_dept_name": support_dept_name, "change_date": normalize_date_text(values[4] if len(values) > 4 else ""), "client_name": normalize_text(values[5] if len(values) > 5 else ""), "original_contract_period": normalize_text(values[6] if len(values) > 6 else ""), "changed_project_end_date": normalize_date_text(values[7] if len(values) > 7 else ""), "initial_contract_amount": normalize_amount(values[8] if len(values) > 8 else 0), "changed_contract_amount": normalize_amount(values[9] if len(values) > 9 else 0), "delta_amount": normalize_amount(values[10] if len(values) > 10 else 0), "source_file": source_file, } conn.execute( text( """ INSERT INTO project_contract_change_round ( support_dept_code, raw_round_code, normalized_title, owner_department, business_division, support_dept_name, change_date, client_name, original_contract_period, changed_project_end_date, initial_contract_amount, changed_contract_amount, delta_amount, source_file, updated_at ) VALUES ( :support_dept_code, :raw_round_code, :normalized_title, :owner_department, :business_division, :support_dept_name, :change_date, :client_name, :original_contract_period, :changed_project_end_date, :initial_contract_amount, :changed_contract_amount, :delta_amount, :source_file, CURRENT_TIMESTAMP ) """ ), payload, ) inserted += 1 sync_auto_project_related_links() return inserted def select_latest_contract_change_entry(rows: list[dict[str, Any]]) -> dict[str, Any]: def sort_key(row: dict[str, Any]) -> tuple[str, int, float, float]: return ( normalize_text(row.get("change_date")), 1 if normalize_amount(row.get("changed_contract_amount")) > 0 else 0, abs(normalize_amount(row.get("delta_amount"))), normalize_amount(row.get("changed_contract_amount")), ) return max(rows, key=sort_key) if rows else {} def get_project_contract_change_maps() -> tuple[dict[str, dict[str, Any]], dict[str, dict[str, Any]], dict[str, str], dict[str, str]]: with engine.begin() as conn: summary_rows = conn.execute( text("SELECT * FROM project_contract_change_summary ORDER BY raw_summary_code, change_date, id") ).mappings().all() round_rows = conn.execute( text("SELECT * FROM project_contract_change_round ORDER BY support_dept_code, change_date, id") ).mappings().all() existing_codes = { normalize_text(row[0]) for row in conn.execute( text( """ SELECT DISTINCT support_dept_code FROM transactions WHERE COALESCE(support_dept_code, '') <> '' UNION SELECT DISTINCT support_dept_code FROM project_status WHERE COALESCE(support_dept_code, '') <> '' UNION SELECT DISTINCT support_dept_code FROM project_contract_info WHERE COALESCE(support_dept_code, '') <> '' UNION SELECT DISTINCT support_dept_code FROM project_billing_entries WHERE COALESCE(support_dept_code, '') <> '' UNION SELECT DISTINCT support_dept_code FROM project_contract_change_round WHERE COALESCE(support_dept_code, '') <> '' """ ) ).fetchall() if normalize_text(row[0]) } contracted_codes = { normalize_text(row[0]) for row in conn.execute( text( """ SELECT support_dept_code FROM project_contract_info WHERE COALESCE(hanmac_contract_amount, 0) > 0 UNION SELECT support_dept_code FROM project_billing_entries GROUP BY support_dept_code HAVING SUM(COALESCE(collected_amount, 0)) > 0 OR MAX(COALESCE(contract_amount, 0)) > 0 """ ) ).fetchall() if normalize_text(row[0]) } summary_by_title: dict[str, list[dict[str, Any]]] = {} summary_codes_by_title: dict[str, set[str]] = {} for row in summary_rows: title_key = normalize_text(row["normalized_title"]) if title_key: summary_by_title.setdefault(title_key, []).append(dict(row)) summary_code = normalize_actual_project_code(row.get("raw_summary_code")) if summary_code: summary_codes_by_title.setdefault(title_key, set()).add(summary_code) latest_summary_by_title = { title_key: select_latest_contract_change_entry(rows) for title_key, rows in summary_by_title.items() if rows } round_by_code: dict[str, list[dict[str, Any]]] = {} round_codes_by_title: dict[str, set[str]] = {} for row in round_rows: code = normalize_text(row["support_dept_code"]) title_key = normalize_text(row["normalized_title"]) if code: round_by_code.setdefault(code, []).append(dict(row)) if title_key and code: round_codes_by_title.setdefault(title_key, set()).add(code) latest_round_by_code = { code: select_latest_contract_change_entry(rows) for code, rows in round_by_code.items() if rows } representative_by_title: dict[str, str] = {} all_title_keys = set(summary_codes_by_title) | set(round_codes_by_title) for title_key in all_title_keys: codes = set(round_codes_by_title.get(title_key, set())) | set(summary_codes_by_title.get(title_key, set())) sorted_codes = sorted(code for code in codes if code) if not sorted_codes: continue def representative_rank(code: str) -> tuple[int, str]: if code in contracted_codes: return (0, code) if code.startswith("Y") and code in existing_codes: return (1, code) if code.startswith("Z"): return (2, code) if code.startswith("X"): return (3, code) return (4, code) representative_by_title[title_key] = min(sorted_codes, key=representative_rank) title_by_code: dict[str, str] = {} for title_key, codes in round_codes_by_title.items(): for code in codes: if code: title_by_code[code] = title_key for title_key, codes in summary_codes_by_title.items(): for code in codes: if code: title_by_code[code] = title_key return latest_summary_by_title, latest_round_by_code, representative_by_title, title_by_code def refresh_contract_review_tags() -> None: latest_summary_by_title, latest_round_by_code, _, title_by_code = get_project_contract_change_maps() with engine.begin() as conn: billing_rows = conn.execute( text( """ SELECT support_dept_code, MAX(contract_amount) AS billing_contract_amount FROM project_billing_entries GROUP BY support_dept_code """ ) ).mappings().all() billing_map = { normalize_text(row["support_dept_code"]): normalize_amount(row["billing_contract_amount"]) for row in billing_rows if normalize_text(row["support_dept_code"]) } contract_rows = conn.execute( text("SELECT support_dept_code, support_dept_name, hanmac_contract_amount FROM project_contract_info") ).mappings().all() for row in contract_rows: support_dept_code = normalize_text(row["support_dept_code"]) hanmac_contract_amount = normalize_amount(row["hanmac_contract_amount"]) billing_contract_amount = normalize_amount(billing_map.get(support_dept_code)) title_key = title_by_code.get(support_dept_code) or normalize_project_title_for_linking(row.get("support_dept_name")) latest_changed_contract_amount = ( normalize_amount((latest_summary_by_title.get(title_key) or {}).get("changed_contract_amount")) or normalize_amount((latest_round_by_code.get(support_dept_code) or {}).get("changed_contract_amount")) ) comparison_contract_amount = latest_changed_contract_amount or hanmac_contract_amount review_tag = "" review_note = "" if billing_contract_amount and abs(comparison_contract_amount - billing_contract_amount) > 0.5: review_tag = "변경계약 검토 필요" if latest_changed_contract_amount: review_note = ( f"변경계약금액 {latest_changed_contract_amount:,.0f}원 / " f"기성청구현황 계약금액 {billing_contract_amount:,.0f}원" ) else: review_note = ( f"계약현황 한맥계약금액 {hanmac_contract_amount:,.0f}원 / " f"기성청구현황 계약금액 {billing_contract_amount:,.0f}원" ) conn.execute( text( """ UPDATE project_contract_info SET review_tag = :review_tag, review_note = :review_note, updated_at = CURRENT_TIMESTAMP WHERE support_dept_code = :support_dept_code """ ), { "support_dept_code": support_dept_code, "review_tag": review_tag, "review_note": review_note, }, ) def sync_auto_project_related_links() -> None: with engine.begin() as conn: billing_rows = conn.execute( text( """ SELECT support_dept_code, raw_project_code, round_code FROM project_billing_entries WHERE COALESCE(support_dept_code, '') <> '' """ ) ).mappings().all() change_round_rows = conn.execute( text( """ SELECT support_dept_code, normalized_title, support_dept_name FROM project_contract_change_round WHERE COALESCE(support_dept_code, '') <> '' """ ) ).mappings().all() change_summary_rows = conn.execute( text( """ SELECT raw_summary_code, normalized_title, support_dept_name, business_division FROM project_contract_change_summary WHERE COALESCE(raw_summary_code, '') <> '' """ ) ).mappings().all() existing_codes = { normalize_text(row[0]) for row in conn.execute( text( """ SELECT DISTINCT support_dept_code FROM transactions WHERE COALESCE(support_dept_code, '') <> '' UNION SELECT DISTINCT support_dept_code FROM project_status WHERE COALESCE(support_dept_code, '') <> '' UNION SELECT DISTINCT support_dept_code FROM project_contract_info WHERE COALESCE(support_dept_code, '') <> '' UNION SELECT DISTINCT support_dept_code FROM project_billing_entries WHERE COALESCE(support_dept_code, '') <> '' UNION SELECT DISTINCT support_dept_code FROM project_contract_change_round WHERE COALESCE(support_dept_code, '') <> '' """ ) ).fetchall() if normalize_text(row[0]) } project_names = { normalize_text(row[0]): normalize_text(row[1]) for row in conn.execute( text( """ WITH project_names AS ( SELECT support_dept_code, MAX(support_dept_name) AS support_dept_name FROM transactions WHERE COALESCE(support_dept_code, '') <> '' GROUP BY support_dept_code UNION SELECT support_dept_code, support_dept_name FROM project_contract_info WHERE COALESCE(support_dept_code, '') <> '' UNION SELECT support_dept_code, support_dept_name FROM project_billing_entries WHERE COALESCE(support_dept_code, '') <> '' UNION SELECT support_dept_code, support_dept_name FROM project_contract_change_round WHERE COALESCE(support_dept_code, '') <> '' ) SELECT support_dept_code, support_dept_name FROM project_names WHERE COALESCE(support_dept_code, '') <> '' """ ) ).fetchall() if normalize_text(row[0]) } contracted_codes = { normalize_text(row[0]) for row in conn.execute( text( """ SELECT support_dept_code FROM project_contract_info WHERE COALESCE(hanmac_contract_amount, 0) > 0 UNION SELECT support_dept_code FROM project_billing_entries GROUP BY support_dept_code HAVING SUM(COALESCE(collected_amount, 0)) > 0 OR MAX(COALESCE(contract_amount, 0)) > 0 """ ) ).fetchall() if normalize_text(row[0]) } round_cluster_map: dict[str, set[str]] = {} cluster_map: dict[str, set[str]] = {} for row in billing_rows: base_code = normalize_text(row["support_dept_code"]) raw_project_code = normalize_project_code( row["raw_project_code"], default_prefix=base_code[:1] or "Y", ) or normalize_text(row["raw_project_code"]) round_code = normalize_project_code(row["round_code"], default_prefix=base_code[:1] or "Y") if not base_code: continue cluster_key = raw_project_code or base_code round_cluster = round_cluster_map.setdefault(cluster_key, set()) cluster = cluster_map.setdefault(cluster_key, set()) if base_code in existing_codes: round_cluster.add(base_code) cluster.add(base_code) if round_code and round_code in existing_codes: round_cluster.add(round_code) cluster.add(round_code) change_round_title_groups: dict[str, set[str]] = {} change_contract_codes: set[str] = set() for row in change_round_rows: code = normalize_text(row["support_dept_code"]) title_key = normalize_text(row["normalized_title"]) or normalize_project_title_for_linking(row["support_dept_name"]) if not code or not title_key: continue change_contract_codes.add(code) change_round_title_groups.setdefault(title_key, set()).add(code) for row in change_summary_rows: code = normalize_actual_project_code(row["raw_summary_code"]) title_key = normalize_text(row["normalized_title"]) or normalize_project_title_for_linking(row["support_dept_name"]) if not code or not title_key: continue existing_codes.add(code) change_contract_codes.add(code) project_names.setdefault(code, normalize_text(row["support_dept_name"])) change_round_title_groups.setdefault(title_key, set()).add(code) project_codes_by_title: dict[str, set[str]] = {} for code, name in project_names.items(): title_key = normalize_project_title_for_linking(name) if title_key: project_codes_by_title.setdefault(title_key, set()).add(code) for title_key, round_codes in change_round_title_groups.items(): cluster_codes = set(round_codes) cluster_codes.update(project_codes_by_title.get(title_key, set())) cluster_map.setdefault(f"change_round::{title_key}", set()).update( code for code in cluster_codes if code in existing_codes ) code_family_groups: dict[str, set[str]] = {} for code in existing_codes: normalized_code = normalize_text(code).upper() if len(normalized_code) < 2: continue suffix = contract_family_code_suffix(normalized_code) if not suffix: continue code_family_groups.setdefault(str(int(suffix)), set()).add(normalized_code) for suffix, codes in code_family_groups.items(): x_code = f"X{suffix}" raw_x_code = f"9{suffix.zfill(5)}" if raw_x_code in codes and x_code in codes: cluster_map.setdefault(f"code_family::{suffix}::X", set()).update({raw_x_code, x_code}) raw_total_code = f"0{suffix.zfill(5)}" if raw_total_code not in codes: continue for prefix in ("Y", "Z"): related_code = f"{prefix}{suffix}" if related_code in codes: cluster_map.setdefault(f"code_family::{suffix}::{prefix}", set()).update( {raw_total_code, related_code} ) title_groups: dict[str, set[str]] = {} for code, name in project_names.items(): normalized_title = normalize_project_title_for_linking(name) if len(normalized_title) < 8: continue title_groups.setdefault(normalized_title, set()).add(code) def choose_representative(codes: set[str]) -> str: sorted_codes = sorted(codes) contracted_non_special = [ code for code in sorted_codes if code in contracted_codes and not code.startswith(("X", "Z")) ] if contracted_non_special: return contracted_non_special[0] plain_non_special = [ code for code in sorted_codes if not code.startswith(("X", "Z")) and not has_project_variant_marker(project_names.get(code, "")) ] if plain_non_special: return plain_non_special[0] contracted_any = [code for code in sorted_codes if code in contracted_codes] if contracted_any: return contracted_any[0] return sorted_codes[0] if sorted_codes else "" for normalized_title, codes in title_groups.items(): if len(codes) < 2: continue representative_code = choose_representative(codes) if not representative_code: continue variant_codes = { code for code in codes if code != representative_code and ( code.startswith(("X", "Z")) or has_project_variant_marker(project_names.get(code, "")) ) } if not variant_codes: continue cluster_map.setdefault(f"title::{normalized_title}", set()).update({representative_code, *variant_codes}) title_items = sorted( ((title_key, set(codes)) for title_key, codes in title_groups.items() if len(title_key) >= 8), key=lambda item: len(item[0]), ) for index, (base_title, base_codes) in enumerate(title_items): for related_title, related_codes in title_items[index + 1:]: if base_title not in related_title and related_title not in base_title: continue merged_codes = set(base_codes) | set(related_codes) representative_code = choose_representative(merged_codes) if not representative_code: continue variant_codes = { code for code in merged_codes if code != representative_code and ( code.startswith(("X", "Z")) or has_project_variant_marker(project_names.get(code, "")) ) } if not variant_codes: continue cluster_map.setdefault( f"title_fuzzy::{base_title if len(base_title) <= len(related_title) else related_title}", set(), ).update({representative_code, *variant_codes}) conn.execute(text("DELETE FROM project_related_links WHERE COALESCE(link_source, 'manual') LIKE 'auto%'")) for cluster_key, cluster_codes in cluster_map.items(): normalized_cluster = sorted(cluster_codes) if len(normalized_cluster) < 2: continue if str(cluster_key).startswith("change_round::"): link_source = "auto_change_contract" elif str(cluster_key).startswith("code_family::"): link_source = "auto_code_family" elif str(cluster_key).startswith("title_fuzzy::"): link_source = "auto_title_fuzzy" elif str(cluster_key).startswith("title::"): link_source = "auto_title" else: link_source = "auto_round" for base_code in normalized_cluster: for related_code in normalized_cluster: if base_code == related_code: continue conn.execute( text( """ INSERT INTO project_related_links ( base_support_dept_code, related_support_dept_code, link_source, updated_at ) VALUES ( :base_support_dept_code, :related_support_dept_code, :link_source, CURRENT_TIMESTAMP ) ON CONFLICT(base_support_dept_code, related_support_dept_code) DO UPDATE SET link_source = CASE WHEN project_related_links.link_source = 'auto_code_family' THEN project_related_links.link_source WHEN excluded.link_source = 'auto_code_family' THEN excluded.link_source ELSE excluded.link_source END, updated_at = CURRENT_TIMESTAMP """ ), { "base_support_dept_code": base_code, "related_support_dept_code": related_code, "link_source": link_source, }, ) for base_code, related_code, link_source in conn.execute( text( """ SELECT base_support_dept_code, related_support_dept_code, COALESCE(link_source, 'manual') AS link_source FROM project_related_links WHERE COALESCE(link_source, 'manual') LIKE 'auto_title%' """ ) ).fetchall(): normalized_base = normalize_text(base_code) normalized_related = normalize_text(related_code) if ( normalized_base in change_contract_codes and normalized_related not in change_contract_codes and not normalized_related.startswith("X") ) or ( normalized_related in change_contract_codes and normalized_base not in change_contract_codes and not normalized_base.startswith("X") ): conn.execute( text( """ DELETE FROM project_related_links WHERE base_support_dept_code = :base_support_dept_code AND related_support_dept_code = :related_support_dept_code """ ), { "base_support_dept_code": normalized_base, "related_support_dept_code": normalized_related, }, ) x_codes_linked_to_change: set[str] = set() for base_code, related_code in conn.execute( text( """ SELECT base_support_dept_code, related_support_dept_code FROM project_related_links WHERE ( base_support_dept_code LIKE 'X%' AND related_support_dept_code <> '' ) OR ( related_support_dept_code LIKE 'X%' AND base_support_dept_code <> '' ) """ ) ).fetchall(): normalized_base = normalize_text(base_code) normalized_related = normalize_text(related_code) if normalized_base.startswith("X") and normalized_related in change_contract_codes: x_codes_linked_to_change.add(normalized_base) if normalized_related.startswith("X") and normalized_base in change_contract_codes: x_codes_linked_to_change.add(normalized_related) for base_code, related_code in conn.execute( text( """ SELECT base_support_dept_code, related_support_dept_code FROM project_related_links WHERE COALESCE(link_source, 'manual') LIKE 'auto_title%' """ ) ).fetchall(): normalized_base = normalize_text(base_code) normalized_related = normalize_text(related_code) should_delete = ( normalized_base in x_codes_linked_to_change and normalized_related not in change_contract_codes and not normalized_related.startswith("X") ) or ( normalized_related in x_codes_linked_to_change and normalized_base not in change_contract_codes and not normalized_base.startswith("X") ) if should_delete: conn.execute( text( """ DELETE FROM project_related_links WHERE base_support_dept_code = :base_support_dept_code AND related_support_dept_code = :related_support_dept_code """ ), { "base_support_dept_code": normalized_base, "related_support_dept_code": normalized_related, }, ) for cluster_codes in round_cluster_map.values(): normalized_cluster = sorted(code for code in cluster_codes if code in existing_codes) if len(normalized_cluster) < 2: continue for base_code in normalized_cluster: for related_code in normalized_cluster: if base_code == related_code: continue conn.execute( text( """ INSERT INTO project_related_links ( base_support_dept_code, related_support_dept_code, link_source, updated_at ) VALUES ( :base_support_dept_code, :related_support_dept_code, 'auto_round', CURRENT_TIMESTAMP ) ON CONFLICT(base_support_dept_code, related_support_dept_code) DO UPDATE SET link_source = CASE WHEN project_related_links.link_source = 'auto_code_family' THEN project_related_links.link_source WHEN excluded.link_source = 'auto_code_family' THEN excluded.link_source ELSE excluded.link_source END, updated_at = CURRENT_TIMESTAMP """ ), { "base_support_dept_code": base_code, "related_support_dept_code": related_code, }, ) @app.on_event("startup") def on_startup() -> None: sqlite_status = validate_sqlite_runtime() try: init_db() except OperationalError as exc: if "readonly database" not in str(exc).lower(): raise logger.warning("Startup DB initialization skipped because runtime DB is readonly: %s", exc) _ensure_app_post_startup_warmup() logger.info("DB ready at %s (SQLite %s)", DB_PATH, sqlite_status["sqlite_version"]) def _run_app_post_startup_warmup() -> None: time.sleep(1.0) cleaned_jobs = _cleanup_stale_system_jobs("startup", all_running=True) if cleaned_jobs: logger.warning("Cleaned %s stale running system job(s) during startup", cleaned_jobs) try: init_wehago_compare_db(engine) except Exception as exc: logger.warning("Post-startup compare DB init skipped due to error: %s", exc) try: _ensure_app_maintenance_worker() except Exception as exc: logger.warning("Post-startup maintenance worker init skipped due to error: %s", exc) try: _ensure_system_job_worker() except Exception as exc: logger.warning("Post-startup system job worker init skipped due to error: %s", exc) try: with engine.begin() as conn: corrected = sanitize_project_labor_amount_rows(conn) if corrected: logger.info("Sanitized project labor amounts for %s project(s)", corrected) except Exception as exc: logger.warning("Post-startup labor sanitize skipped due to error: %s", exc) run_heavy_startup = normalize_text(os.getenv("HM_RUN_HEAVY_STARTUP", "0")) in {"1", "true", "yes", "on"} if not run_heavy_startup: logger.info("Skipping heavy startup refresh tasks; existing DB state will be used as-is") return try: auto_import_project_excels() sync_auto_project_related_links() normalize_all_collection_entry_storage() except Exception as exc: logger.warning("Post-startup heavy refresh skipped due to error: %s", exc) def _ensure_app_post_startup_warmup() -> None: global _APP_POST_STARTUP_WARMUP_STARTED with _APP_POST_STARTUP_WARMUP_LOCK: if _APP_POST_STARTUP_WARMUP_STARTED: return worker = threading.Thread( target=_run_app_post_startup_warmup, daemon=True, name="app-post-startup-warmup", ) worker.start() _APP_POST_STARTUP_WARMUP_STARTED = True def _safe_next_url(value: Any) -> str: next_url = unquote_plus(normalize_text(value)) if not next_url.startswith("/") or next_url.startswith("//"): return "/" if next_url.startswith("/login") or next_url.startswith("/logout"): return "/" return next_url @app.get("/login") async def login_page(request: Request, next: str = ""): init_db() current_user = _auth_get_request_user(request) if current_user: next_url = _safe_next_url(next) if next_url == "/" and not _auth_user_can(current_user, "dashboard"): next_url = _auth_default_landing_for_user(current_user) return RedirectResponse(url=next_url, status_code=303) return templates.TemplateResponse( request, "login.html", {"request": request, "next_url": _safe_next_url(next), "error": ""}, ) @app.post("/login") async def login_submit(request: Request): form = await request.form() username = normalize_text(form.get("username")) password = str(form.get("password") or "") next_url = _safe_next_url(form.get("next")) user = authenticate_user(username, password) if not user: _auth_log_event(username, False, request, "invalid_credentials") return templates.TemplateResponse( request, "login.html", {"request": request, "next_url": next_url, "error": "아이디 또는 비밀번호가 올바르지 않습니다."}, status_code=401, ) if next_url == "/" and not _auth_user_can(user, "dashboard"): next_url = _auth_default_landing_for_user(user) session_token = create_login_session(int(user["id"]), request) _auth_log_event(username, True, request, user_id=int(user["id"])) response = RedirectResponse(url=next_url, status_code=303) return _auth_cookie_response(response, session_token) @app.get("/logout") async def logout(request: Request): payload = _auth_unsign_payload(request.cookies.get(AUTH_COOKIE_NAME, "")) session_id = normalize_text((payload or {}).get("sid")) if session_id: with engine.begin() as conn: conn.execute( text("UPDATE app_sessions SET revoked_at = CURRENT_TIMESTAMP WHERE session_id = :session_id"), {"session_id": session_id}, ) response = RedirectResponse(url="/login", status_code=303) return _auth_cookie_response(response, "") @app.get("/admin/users") async def admin_users(request: Request, message: str = ""): context = { **base_context(request, message), "users": list_admin_users(), "permission_options": list_admin_permission_options(), } return templates.TemplateResponse(request, "admin_users.html", context) @app.get("/admin/users/{user_id}/login-events") async def admin_user_login_events(user_id: int, limit: int = 100): try: return JSONResponse(list_admin_user_login_events(user_id, limit=limit)) except ValueError as exc: return JSONResponse({"error": str(exc)}, status_code=404) @app.post("/admin/users") async def admin_users_save(request: Request): form = await request.form() try: payload = dict(form) payload["permissions"] = form.getlist("permissions") upsert_admin_user(payload) return RedirectResponse(url="/admin/users?message=%EC%A0%80%EC%9E%A5%EB%90%98%EC%97%88%EC%8A%B5%EB%8B%88%EB%8B%A4", status_code=303) except Exception as exc: context = { **base_context(request, str(exc)), "users": list_admin_users(), "permission_options": list_admin_permission_options(), } return templates.TemplateResponse(request, "admin_users.html", context, status_code=400) @app.get("/db", include_in_schema=False) async def database_browser_redirect() -> RedirectResponse: return RedirectResponse(url="/db/") @app.get("/db-browser") async def db_browser(request: Request, target: str | None = None): allowed_targets = { "home": "/db/data", "transactions": "/db/data/transactions", "project_status": "/db/data/project_status", "project_related_links": "/db/data/project_related_links", "project_billing_entries": "/db/data/project_billing_entries", "project_collection_entries": "/db/data/project_collection_entries", "project_contract_info": "/db/data/project_contract_info", "project_analysis_settings": "/db/data/project_analysis_settings", "transaction_source_files": "/db/data/transaction_source_files", "project_related_links_overview": "/db/data/project_related_links_overview", "project_collection_summary": "/db/data/project_collection_summary", "billing_vs_collection_gap": "/db/data/billing_vs_collection_gap", } normalized_target = normalize_text(target) or "home" current_target = allowed_targets.get(normalized_target, allowed_targets["home"]) context = { **base_context(request), "db_browser_target_key": normalized_target if normalized_target in allowed_targets else "home", "db_browser_target_url": current_target, "db_browser_links": [ {"key": "home", "label": "DB 홈", "url": allowed_targets["home"]}, {"key": "transactions", "label": "거래전표", "url": allowed_targets["transactions"]}, {"key": "project_status", "label": "프로젝트 상태", "url": allowed_targets["project_status"]}, {"key": "project_related_links", "label": "연계 프로젝트", "url": allowed_targets["project_related_links"]}, {"key": "project_billing_entries", "label": "청구 내역", "url": allowed_targets["project_billing_entries"]}, {"key": "project_collection_entries", "label": "수금 내역", "url": allowed_targets["project_collection_entries"]}, {"key": "project_contract_info", "label": "계약 현황", "url": allowed_targets["project_contract_info"]}, {"key": "project_analysis_settings", "label": "분석 설정", "url": allowed_targets["project_analysis_settings"]}, {"key": "transaction_source_files", "label": "거래 원본 파일 현황", "url": allowed_targets["transaction_source_files"]}, {"key": "project_related_links_overview", "label": "연계 링크 현황", "url": allowed_targets["project_related_links_overview"]}, {"key": "project_collection_summary", "label": "프로젝트 수금 요약", "url": allowed_targets["project_collection_summary"]}, {"key": "billing_vs_collection_gap", "label": "청구/수금 차이 점검", "url": allowed_targets["billing_vs_collection_gap"]}, ], } return templates.TemplateResponse(request, "db_browser.html", context) def build_hanmac_browser_plan() -> dict[str, Any]: return { "server_host": "172.16.42.111", "status": { "label": "연결 준비", "description": "지금은 읽기 전용 조회 화면을 먼저 구성하고, 이후 MySQL 연결 정보를 붙여 바로 조회할 수 있도록 준비한 상태입니다.", }, "strategy": [ { "title": "1단계: 바로 보기", "description": "hanmac / hanmac_manhour / baron_manhour에서 자주 보는 테이블을 읽기 전용으로 조회합니다.", }, { "title": "2단계: 기준키 정리", "description": "프로젝트코드, 사번, 부서코드, 일자를 현재 앱 DB와 연결할 공통 키로 정리합니다.", }, { "title": "3단계: 통합 화면", "description": "프로젝트 정보, 원가/전표 데이터, manhour 데이터를 한 화면에서 함께 보여줍니다.", }, ], "schemas": [ { "name": "hanmac", "role": "기본 업무/REST API 성격의 운영 데이터 확인", "tables": ["restapi"], }, { "name": "hanmac_manhour", "role": "일일업무, 투입시간, 사원/프로젝트 기준 데이터 확인", "tables": [ "dallyproject_tbl", "dallyproject_addwork_tbl", "dallyproject_2020_tbl", "member_tbl", "project_tbl", ], }, { "name": "baron_manhour", "role": "센터/총괄 인원 기준 및 중복 투입시간 제외 여부 확인", "tables": ["member_tbl"], }, ], "recommended_views": [ { "title": "일일업무 조회", "summary": "사번, 이름, 프로젝트코드, 기간으로 가장 자주 볼 가능성이 높은 기본 화면입니다.", "fields": ["입력일", "사번", "이름", "프로젝트코드", "업무내용", "시간"], }, { "title": "사원별 투입시간", "summary": "특정 직원이 어느 프로젝트에 얼마나 투입되었는지 월/기간 기준으로 확인합니다.", "fields": ["사번", "이름", "기간", "프로젝트수", "총 시간", "최근 입력일"], }, { "title": "프로젝트별 투입시간", "summary": "프로젝트 기준으로 참여 인원과 누적 manhour를 보며 현재 DB의 프로젝트 정보와 붙이기 좋습니다.", "fields": ["프로젝트코드", "프로젝트명", "참여인원", "총 시간", "최근 입력일"], }, { "title": "마스터 조회", "summary": "member_tbl, project_tbl을 기준으로 사원/프로젝트 마스터를 점검합니다.", "fields": ["사번", "이름", "부서", "직급", "프로젝트코드", "프로젝트명"], }, ], "join_keys": [ { "key": "project_code", "reason": "현재 프로젝트 정보 화면과 연결하는 핵심 기준값입니다.", }, { "key": "member_no", "reason": "사원별 투입시간과 사용자/담당자 기준 분석의 핵심 키입니다.", }, { "key": "dept_code", "reason": "지원부서/원가부서 또는 조직 기준 요약과 연결하기 좋습니다.", }, { "key": "work_date", "reason": "전표일자, 증빙일자, 프로젝트 기간 분석과 함께 볼 때 필요합니다.", }, ], "sample_queries": [ { "title": "일일업무 최근 입력 조회", "sql": ( "SELECT EntryTime, MemberNo, project_code, contents, work_hour, work_min " "FROM dallyproject_addwork_tbl ORDER BY EntryTime DESC LIMIT 100;" ), }, { "title": "사원별 누적 투입시간", "sql": ( "SELECT MemberNo, SUM(COALESCE(work_hour, 0) * 60 + COALESCE(work_min, 0)) AS total_minutes " "FROM dallyproject_addwork_tbl GROUP BY MemberNo ORDER BY total_minutes DESC LIMIT 50;" ), }, { "title": "프로젝트별 투입시간", "sql": ( "SELECT project_code, SUM(COALESCE(work_hour, 0) * 60 + COALESCE(work_min, 0)) AS total_minutes " "FROM dallyproject_addwork_tbl GROUP BY project_code ORDER BY total_minutes DESC LIMIT 50;" ), }, ], "integration_notes": [ "우선은 외부 MySQL을 읽기 전용으로 직접 조회합니다.", "조회 패턴이 안정되면 필요한 테이블만 로컬 SQLite로 동기화합니다.", "동기화 후에는 현재 프로젝트 정보/원가/전표 화면에 manhour 요약 칼럼을 추가합니다.", ], "connection_requirements": [ "MySQL 접속 정보(host, port, user, password)", "허용할 스키마 목록(hanmac, hanmac_manhour, baron_manhour)", "읽기 전용 계정 여부 확인", ], } def normalize_text(value: Any) -> str: if value is None: return "" if isinstance(value, str): return value.strip() return str(value).strip() def normalize_amount(value: Any) -> float: if value in (None, ""): return 0.0 if isinstance(value, (int, float)): return float(value) cleaned = ( str(value) .strip() .replace(",", "") .replace("원", "") .replace("(", "-") .replace(")", "") ) if not cleaned: return 0.0 try: return float(cleaned) except ValueError: return 0.0 def normalize_date_text(value: Any) -> str: if value in (None, ""): return "" if isinstance(value, datetime): return value.date().isoformat() if isinstance(value, date): return value.isoformat() text_value = normalize_text(value) for fmt in ("%Y-%m-%d", "%Y/%m/%d", "%Y.%m.%d", "%Y%m%d"): try: return datetime.strptime(text_value, fmt).date().isoformat() except ValueError: continue return text_value def normalize_collection_progress_type(value: Any) -> str: text_value = normalize_text(value) if not text_value: return "" if "준공" in text_value: return "준공금" if any(keyword in text_value for keyword in ("선수", "선급")): return "선급금" if "기성" in text_value: return "기성금" return "" def normalize_collection_billing_type(value: Any) -> str: text_value = normalize_text(value) if not text_value: return "" if "계약" in text_value: return "계약분" if "기타" in text_value: return "기타" return "" def normalize_collection_entry_fields(row: dict[str, Any]) -> dict[str, Any]: normalized = dict(row) progress_type = normalize_collection_progress_type(normalized.get("progress_type")) raw_billing_type = normalize_text(normalized.get("billing_type")) billing_type = normalize_collection_billing_type(raw_billing_type) if not progress_type and raw_billing_type in {"선수금", "선급금", "기성금", "준공금"}: progress_type = normalize_collection_progress_type(raw_billing_type) normalized["progress_type"] = progress_type normalized["billing_type"] = billing_type or ("계약분" if progress_type else "") return normalized def normalize_project_code(value: Any, default_prefix: str = "Y") -> str: text_value = normalize_text(value).replace("\u3164", "").replace("\xa0", "") prefix = "" for character in text_value: if character.isalpha(): prefix = character.upper() break digits = "".join(character for character in text_value if character.isdigit()) if not digits: return "" return f"{prefix or default_prefix}{int(digits)}" def normalize_actual_project_code(value: Any) -> str: text_value = normalize_text(value).replace("\u3164", "").replace("\xa0", "").upper() prefix = next((character for character in text_value if character.isalpha()), "") digits = "".join(character for character in text_value if character.isdigit()) if not digits: return "" if prefix: return f"{prefix}{int(digits)}" if len(digits) <= 5: return digits.zfill(6) return digits def normalize_contract_family_code( value: Any, business_division: Any = "", default_prefix: str = "Y", ) -> str: text_value = normalize_text(value).replace("\u3164", "").replace("\xa0", "").upper() digits = "".join(character for character in text_value if character.isdigit()) if not digits: return "" explicit_prefix = next((character for character in text_value if character.isalpha()), "") if explicit_prefix in {"X", "Y", "Z"}: return f"{explicit_prefix}{int(digits)}" business_text = normalize_text(business_division) if len(digits) >= 6 and digits[0] == "9": return f"X{int(digits[1:])}" if len(digits) >= 6 and digits[0] == "0": prefix = "Z" if "감리" in business_text else "Y" return f"{prefix}{int(digits[1:])}" return f"{default_prefix}{int(digits)}" def contract_family_code_suffix(value: Any) -> str: text_value = normalize_text(value).upper() digits = "".join(character for character in text_value if character.isdigit()) if not digits: return "" has_alpha_prefix = any(character.isalpha() for character in text_value) if not has_alpha_prefix and len(digits) >= 6 and digits[0] in {"0", "9"}: digits = digits[1:] return str(int(digits)) if digits else "" def resolve_contract_family_code_from_contract_info( conn: Any, value: Any, business_division: Any = "", support_dept_name: Any = "", default_prefix: str = "Y", ) -> str: fallback_code = normalize_contract_family_code( value, business_division=business_division, default_prefix=default_prefix, ) suffix = contract_family_code_suffix(value) if not suffix: return fallback_code candidate_codes = [f"{prefix}{suffix}" for prefix in ("X", "Y", "Z")] rows = conn.execute( text( """ SELECT support_dept_code, business_division, support_dept_name FROM project_contract_info WHERE support_dept_code IN :candidate_codes """ ).bindparams(bindparam("candidate_codes", expanding=True)), {"candidate_codes": candidate_codes}, ).mappings().all() if not rows: return fallback_code target_title = normalize_project_title_for_linking(support_dept_name) target_business = normalize_text(business_division) matching_title_rows = [ row for row in rows if target_title and normalize_project_title_for_linking(row.get("support_dept_name")) == target_title ] if matching_title_rows: matching_business_rows = [ row for row in matching_title_rows if not target_business or normalize_text(row.get("business_division")) == target_business ] chosen_row = (matching_business_rows or matching_title_rows)[0] return normalize_text(chosen_row.get("support_dept_code")) or fallback_code return fallback_code def normalize_round_value(value: Any) -> str: text_value = normalize_text(value) if not text_value: return "" digits = "".join(character for character in text_value if character.isdigit()) if digits: return str(int(digits)) return text_value PROJECT_TITLE_LINK_STRIP_PATTERNS = ( r"\((?:\d+\s*차|[가-힣A-Za-z0-9\s]*변경[가-힣A-Za-z0-9\s]*|[가-힣A-Za-z0-9\s]*보완[가-힣A-Za-z0-9\s]*|[가-힣A-Za-z0-9\s]*입찰[가-힣A-Za-z0-9\s]*|가칭)\)", r"\d+\s*차", r"변경", r"보완", r"입찰", r"기술제안", r"실시설계", r"조사[·ㆍ]설계", r"조사설계", r"기본\s*및\s*실시설계", r"기본및실시설계", r"기본설계", r"설계", r"용역", r"시공단계", r"건설사업관리", r"가칭", ) def normalize_project_title_for_linking(value: Any) -> str: text_value = normalize_text(value) if not text_value: return "" normalized = text_value for pattern in PROJECT_TITLE_LINK_STRIP_PATTERNS: normalized = re.sub(pattern, "", normalized, flags=re.IGNORECASE) normalized = re.sub(r"[^가-힣A-Za-z0-9]", "", normalized) return normalized def has_project_variant_marker(value: Any) -> bool: text_value = normalize_text(value) if not text_value: return False return bool(re.search(r"(\d+\s*차|[nN]\s*차|변경|보완|지연보상금|가칭|연차|년분)", text_value)) def classify_special_x_project(name: Any) -> str: normalized_name = normalize_text(name) if not normalized_name: return "precontract" rules = get_special_x_classification_rules() for category_key, keywords in rules.items(): if any(keyword in normalized_name for keyword in keywords): return category_key return "precontract" def decode_json_rows(value: Any) -> list[dict[str, Any]]: text_value = normalize_text(value) if not text_value: return [] try: rows = json.loads(text_value) except json.JSONDecodeError: return [] return [row for row in rows if isinstance(row, dict)] def encode_json_rows(rows: list[dict[str, Any]]) -> str: return json.dumps(rows, ensure_ascii=False) def normalize_collection_entry_row(row: dict[str, Any]) -> dict[str, Any]: normalized = { "vendor": clean_row_text(row.get("vendor")), "progress_type": clean_row_text(row.get("progress_type")), "billing_round": normalize_round_value(row.get("billing_round")), "billing_type": clean_row_text(row.get("billing_type")), "billing_date": normalize_date_text(row.get("billing_date")), "billed_amount": normalize_amount(row.get("billed_amount")), "round": normalize_round_value(row.get("round")), "date": normalize_date_text(row.get("date")), "due_date": normalize_date_text(row.get("due_date")), "amount": normalize_amount(row.get("amount")), "balance_amount": normalize_amount(row.get("balance_amount")), "collection_rate": normalize_amount(row.get("collection_rate")), "note": clean_row_text(row.get("note")), } return normalize_collection_entry_fields(normalized) def normalize_task_plan_entry_row(row: dict[str, Any]) -> dict[str, Any]: return { "group": clean_row_text(row.get("group")), "dept_name": clean_row_text(row.get("dept_name")), "work_name": clean_row_text(row.get("work_name")), "amount": normalize_amount(row.get("amount")), } def normalize_exec_budget_entry_row(row: dict[str, Any]) -> dict[str, Any]: return { "group": clean_row_text(row.get("group")), "grade": clean_row_text(row.get("grade")), "hours": clean_row_text(row.get("hours")), "rate_year": clean_row_text(row.get("rate_year")), "dept_name": clean_row_text(row.get("dept_name")), "work_name": clean_row_text(row.get("work_name")), "account_code": clean_row_text(row.get("account_code")), "account_name": clean_row_text(row.get("account_name")), "amount": normalize_amount(row.get("amount")), } def normalize_actual_input_entry_row(row: dict[str, Any]) -> dict[str, Any]: return { "group": clean_row_text(row.get("group")), "grade": clean_row_text(row.get("grade")), "minutes": clean_row_text(row.get("minutes")), "rate_year": clean_row_text(row.get("rate_year")), "label": clean_row_text(row.get("label")), "reference": clean_row_text(row.get("reference")), "note": clean_row_text(row.get("note")), "amount": normalize_amount(row.get("amount")), } def extract_project_status_entry_sets(row: dict[str, Any]) -> dict[str, list[dict[str, Any]]]: collection_entries = [normalize_collection_entry_row(item) for item in decode_json_rows(row.get("collection_entries_json"))] task_plan_entries = [normalize_task_plan_entry_row(item) for item in decode_json_rows(row.get("task_plan_entries_json"))] exec_budget_entries = [normalize_exec_budget_entry_row(item) for item in decode_json_rows(row.get("exec_budget_entries_json"))] actual_input_entries = [normalize_actual_input_entry_row(item) for item in decode_json_rows(row.get("actual_input_entries_json"))] if not collection_entries and normalize_amount(row.get("collection_amount")): collection_entries = [ normalize_collection_entry_row( { "vendor": "", "round": "", "amount": row.get("collection_amount", ""), "date": "", "due_date": "", "note": "기존 수기 입력값", } ) ] if not task_plan_entries: fallback_task_rows = [] if normalize_amount(row.get("task_plan_department_budget")): fallback_task_rows.append( { "group": "department", "dept_name": "기존 부서별 배분", "work_name": "", "amount": row.get("task_plan_department_budget", ""), } ) if normalize_amount(row.get("task_plan_outsource_budget")): fallback_task_rows.append( { "group": "outsource", "dept_name": "기존 외주비", "work_name": row.get("task_plan_outsource_detail", ""), "amount": row.get("task_plan_outsource_budget", ""), } ) if normalize_amount(row.get("task_plan_joint_operating_cost")): fallback_task_rows.append( { "group": "joint", "dept_name": "기존 합사운영비", "work_name": "", "amount": row.get("task_plan_joint_operating_cost", ""), } ) task_plan_entries = [normalize_task_plan_entry_row(item) for item in fallback_task_rows] if not exec_budget_entries: fallback_exec_rows = [] if normalize_amount(row.get("exec_budget_labor_by_grade")): fallback_exec_rows.append( { "group": "labor", "grade": "기존 인건비", "hours": "", "amount": row.get("exec_budget_labor_by_grade", ""), } ) if normalize_amount(row.get("exec_budget_outsource")): fallback_exec_rows.append( { "group": "outsource", "dept_name": "기존 외주비", "work_name": "", "amount": row.get("exec_budget_outsource", ""), } ) if normalize_amount(row.get("exec_budget_cost_plan")): fallback_exec_rows.append( { "group": "cost_plan", "account_code": "기존", "account_name": "비용계획", "amount": row.get("exec_budget_cost_plan", ""), } ) exec_budget_entries = [normalize_exec_budget_entry_row(item) for item in fallback_exec_rows] if not actual_input_entries and normalize_amount(row.get("item_investment")): actual_input_entries = [ normalize_actual_input_entry_row( { "reference": "", "amount": row.get("item_investment", ""), "note": "기존 항목별투입액", } ) ] return { "collection_entries": collection_entries, "task_plan_entries": task_plan_entries, "exec_budget_entries": exec_budget_entries, "actual_input_entries": actual_input_entries, } def replace_project_status_child_entries( conn: Any, support_dept_code: str, collection_entries: list[dict[str, Any]], task_plan_entries: list[dict[str, Any]], exec_budget_entries: list[dict[str, Any]], actual_input_entries: list[dict[str, Any]], ) -> None: conn.execute( text("DELETE FROM project_collection_entries WHERE support_dept_code = :support_dept_code"), {"support_dept_code": support_dept_code}, ) conn.execute( text("DELETE FROM project_task_plan_entries WHERE support_dept_code = :support_dept_code"), {"support_dept_code": support_dept_code}, ) conn.execute( text("DELETE FROM project_exec_budget_entries WHERE support_dept_code = :support_dept_code"), {"support_dept_code": support_dept_code}, ) conn.execute( text("DELETE FROM project_actual_input_entries WHERE support_dept_code = :support_dept_code"), {"support_dept_code": support_dept_code}, ) for position, row in enumerate(collection_entries): normalized = normalize_collection_entry_row(row) conn.execute( text( """ INSERT INTO project_collection_entries ( support_dept_code, position, vendor, progress_type, billing_round, billing_type, billing_date, billed_amount, round, date, due_date, amount, balance_amount, collection_rate, note, updated_at ) VALUES ( :support_dept_code, :position, :vendor, :progress_type, :billing_round, :billing_type, :billing_date, :billed_amount, :round, :date, :due_date, :amount, :balance_amount, :collection_rate, :note, CURRENT_TIMESTAMP ) """ ), {"support_dept_code": support_dept_code, "position": position, **normalized}, ) for position, row in enumerate(task_plan_entries): normalized = normalize_task_plan_entry_row(row) conn.execute( text( """ INSERT INTO project_task_plan_entries ( support_dept_code, position, group_name, dept_name, work_name, amount, updated_at ) VALUES ( :support_dept_code, :position, :group_name, :dept_name, :work_name, :amount, CURRENT_TIMESTAMP ) """ ), { "support_dept_code": support_dept_code, "position": position, "group_name": normalized["group"], "dept_name": normalized["dept_name"], "work_name": normalized["work_name"], "amount": normalized["amount"], }, ) for position, row in enumerate(exec_budget_entries): normalized = normalize_exec_budget_entry_row(row) conn.execute( text( """ INSERT INTO project_exec_budget_entries ( support_dept_code, position, group_name, grade, hours, rate_year, dept_name, work_name, account_code, account_name, amount, updated_at ) VALUES ( :support_dept_code, :position, :group_name, :grade, :hours, :rate_year, :dept_name, :work_name, :account_code, :account_name, :amount, CURRENT_TIMESTAMP ) """ ), { "support_dept_code": support_dept_code, "position": position, "group_name": normalized["group"], "grade": normalized["grade"], "hours": normalized["hours"], "rate_year": normalized["rate_year"], "dept_name": normalized["dept_name"], "work_name": normalized["work_name"], "account_code": normalized["account_code"], "account_name": normalized["account_name"], "amount": normalized["amount"], }, ) for position, row in enumerate(actual_input_entries): normalized = normalize_actual_input_entry_row(row) conn.execute( text( """ INSERT INTO project_actual_input_entries ( support_dept_code, position, group_name, grade, minutes, rate_year, label, reference, note, amount, updated_at ) VALUES ( :support_dept_code, :position, :group_name, :grade, :minutes, :rate_year, :label, :reference, :note, :amount, CURRENT_TIMESTAMP ) """ ), { "support_dept_code": support_dept_code, "position": position, "group_name": normalized["group"], "grade": normalized["grade"], "minutes": normalized["minutes"], "rate_year": normalized["rate_year"], "label": normalized["label"], "reference": normalized["reference"], "note": normalized["note"], "amount": normalized["amount"], }, ) def load_project_status_entry_maps(conn: Any) -> dict[str, dict[str, list[dict[str, Any]]]]: result: dict[str, dict[str, list[dict[str, Any]]]] = {} collection_rows = conn.execute( text( """ SELECT support_dept_code, position, vendor, progress_type, billing_round, billing_type, billing_date, billed_amount, round, date, due_date, amount, balance_amount, collection_rate, note FROM project_collection_entries ORDER BY support_dept_code, position, id """ ) ).mappings().all() for row in collection_rows: code = normalize_text(row["support_dept_code"]) result.setdefault(code, {})["collection_entries"] = result.setdefault(code, {}).get("collection_entries", []) result[code]["collection_entries"].append( normalize_collection_entry_row({key: row[key] for key in row.keys() if key not in {"support_dept_code", "position"}}) ) task_rows = conn.execute( text( """ SELECT support_dept_code, position, group_name, dept_name, work_name, amount FROM project_task_plan_entries ORDER BY support_dept_code, position, id """ ) ).mappings().all() for row in task_rows: code = normalize_text(row["support_dept_code"]) result.setdefault(code, {})["task_plan_entries"] = result.setdefault(code, {}).get("task_plan_entries", []) result[code]["task_plan_entries"].append( normalize_task_plan_entry_row( { "group": row["group_name"], "dept_name": row["dept_name"], "work_name": row["work_name"], "amount": row["amount"], } ) ) exec_rows = conn.execute( text( """ SELECT support_dept_code, position, group_name, grade, hours, dept_name, rate_year, work_name, account_code, account_name, amount FROM project_exec_budget_entries ORDER BY support_dept_code, position, id """ ) ).mappings().all() for row in exec_rows: code = normalize_text(row["support_dept_code"]) result.setdefault(code, {})["exec_budget_entries"] = result.setdefault(code, {}).get("exec_budget_entries", []) result[code]["exec_budget_entries"].append( normalize_exec_budget_entry_row( { "group": row["group_name"], "grade": row["grade"], "hours": row["hours"], "rate_year": row["rate_year"], "dept_name": row["dept_name"], "work_name": row["work_name"], "account_code": row["account_code"], "account_name": row["account_name"], "amount": row["amount"], } ) ) actual_rows = conn.execute( text( """ SELECT support_dept_code, position, group_name, grade, minutes, rate_year, label, reference, note, amount FROM project_actual_input_entries ORDER BY support_dept_code, position, id """ ) ).mappings().all() for row in actual_rows: code = normalize_text(row["support_dept_code"]) result.setdefault(code, {})["actual_input_entries"] = result.setdefault(code, {}).get("actual_input_entries", []) result[code]["actual_input_entries"].append( normalize_actual_input_entry_row( { "group": row["group_name"], "grade": row["grade"], "minutes": row["minutes"], "rate_year": row["rate_year"], "label": row["label"], "reference": row["reference"], "note": row["note"], "amount": row["amount"], } ) ) return result def ensure_project_entry_set(entry_set: dict[str, list[dict[str, Any]]] | None) -> dict[str, list[dict[str, Any]]]: source = entry_set or {} return { "collection_entries": list(source.get("collection_entries", [])), "task_plan_entries": list(source.get("task_plan_entries", [])), "exec_budget_entries": list(source.get("exec_budget_entries", [])), "actual_input_entries": list(source.get("actual_input_entries", [])), } def migrate_project_status_entries(conn: Any) -> None: migrated_codes = { normalize_text(row[0]) for row in conn.execute( text( """ SELECT DISTINCT support_dept_code FROM ( SELECT support_dept_code FROM project_collection_entries UNION ALL SELECT support_dept_code FROM project_task_plan_entries UNION ALL SELECT support_dept_code FROM project_exec_budget_entries UNION ALL SELECT support_dept_code FROM project_actual_input_entries ) WHERE COALESCE(support_dept_code, '') <> '' """ ) ).fetchall() if normalize_text(row[0]) } source_rows = conn.execute( text( """ SELECT support_dept_code, collection_entries_json, task_plan_entries_json, exec_budget_entries_json, actual_input_entries_json, collection_amount, task_plan_department_budget, task_plan_outsource_budget, task_plan_outsource_detail, task_plan_joint_operating_cost, exec_budget_labor_by_grade, exec_budget_outsource, exec_budget_cost_plan, item_investment FROM project_status WHERE COALESCE(support_dept_code, '') <> '' """ ) ).mappings().all() for row in source_rows: support_dept_code = normalize_text(row["support_dept_code"]) if not support_dept_code or support_dept_code in migrated_codes: continue entry_sets = extract_project_status_entry_sets(dict(row)) replace_project_status_child_entries( conn, support_dept_code, entry_sets["collection_entries"], entry_sets["task_plan_entries"], entry_sets["exec_budget_entries"], entry_sets["actual_input_entries"], ) def migrate_project_basic_info(conn: Any) -> None: existing_codes = { normalize_text(row[0]) for row in conn.execute( text("SELECT support_dept_code FROM project_basic_info WHERE COALESCE(support_dept_code, '') <> ''") ).fetchall() if normalize_text(row[0]) } source_rows = conn.execute( text( """ SELECT support_dept_code, support_dept_name, contract_amount, project_type, expected_as_rate, expected_sga_rate, expected_as_cost, expected_sga_budget, exec_labor_rates_json, change_round, project_start_date, project_end_date, completion_status, notes, last_editor_session_id, last_client_submitted_at FROM project_status WHERE COALESCE(support_dept_code, '') <> '' """ ) ).mappings().all() for row in source_rows: support_dept_code = normalize_text(row["support_dept_code"]) if not support_dept_code or support_dept_code in existing_codes: continue conn.execute( text( """ INSERT INTO project_basic_info ( support_dept_code, support_dept_name, contract_amount, project_type, expected_as_rate, expected_sga_rate, expected_as_cost, expected_sga_budget, exec_labor_rates_json, change_round, project_start_date, project_end_date, completion_status, notes, last_editor_session_id, last_client_submitted_at, updated_at ) VALUES ( :support_dept_code, :support_dept_name, :contract_amount, :project_type, :expected_as_rate, :expected_sga_rate, :expected_as_cost, :expected_sga_budget, :exec_labor_rates_json, :change_round, :project_start_date, :project_end_date, :completion_status, :notes, :last_editor_session_id, :last_client_submitted_at, CURRENT_TIMESTAMP ) """ ), dict(row), ) def save_project_basic_info_section(conn: Any, payload: dict[str, Any]) -> None: save_shared_exec_labor_rates(conn, normalize_text(payload.get("exec_labor_rates_json")) or "{}") conn.execute( text( """ INSERT INTO project_basic_info ( support_dept_code, support_dept_name, contract_amount, project_type, expected_as_rate, expected_sga_rate, expected_as_cost, expected_sga_budget, exec_labor_rates_json, change_round, project_start_date, project_end_date, completion_status, notes, last_editor_session_id, last_client_submitted_at, updated_at ) VALUES ( :support_dept_code, :support_dept_name, :contract_amount, :project_type, :expected_as_rate, :expected_sga_rate, :expected_as_cost, :expected_sga_budget, :exec_labor_rates_json, :change_round, :project_start_date, :project_end_date, :completion_status, :notes, :last_editor_session_id, :last_client_submitted_at, CURRENT_TIMESTAMP ) ON CONFLICT(support_dept_code) DO UPDATE SET support_dept_name = excluded.support_dept_name, contract_amount = excluded.contract_amount, project_type = excluded.project_type, expected_as_rate = excluded.expected_as_rate, expected_sga_rate = excluded.expected_sga_rate, expected_as_cost = excluded.expected_as_cost, expected_sga_budget = excluded.expected_sga_budget, exec_labor_rates_json = excluded.exec_labor_rates_json, change_round = excluded.change_round, project_start_date = excluded.project_start_date, project_end_date = excluded.project_end_date, completion_status = excluded.completion_status, notes = excluded.notes, last_editor_session_id = excluded.last_editor_session_id, last_client_submitted_at = excluded.last_client_submitted_at, updated_at = CURRENT_TIMESTAMP """ ), payload, ) def load_project_status_entries_for_code(conn: Any, support_dept_code: str) -> dict[str, list[dict[str, Any]]]: code = normalize_text(support_dept_code) entry_map = ensure_project_entry_set(None) if not code: return entry_map collection_rows = conn.execute( text( """ SELECT vendor, progress_type, billing_round, billing_type, billing_date, billed_amount, round, date, due_date, amount, balance_amount, collection_rate, note FROM project_collection_entries WHERE support_dept_code = :support_dept_code ORDER BY position, id """ ), {"support_dept_code": code}, ).mappings().all() entry_map["collection_entries"] = [ normalize_collection_entry_row(dict(row)) for row in collection_rows ] task_rows = conn.execute( text( """ SELECT group_name, dept_name, work_name, amount FROM project_task_plan_entries WHERE support_dept_code = :support_dept_code ORDER BY position, id """ ), {"support_dept_code": code}, ).mappings().all() entry_map["task_plan_entries"] = [ normalize_task_plan_entry_row( { "group": row["group_name"], "dept_name": row["dept_name"], "work_name": row["work_name"], "amount": row["amount"], } ) for row in task_rows ] exec_rows = conn.execute( text( """ SELECT group_name, grade, hours, rate_year, dept_name, work_name, account_code, account_name, amount FROM project_exec_budget_entries WHERE support_dept_code = :support_dept_code ORDER BY position, id """ ), {"support_dept_code": code}, ).mappings().all() entry_map["exec_budget_entries"] = [ normalize_exec_budget_entry_row( { "group": row["group_name"], "grade": row["grade"], "hours": row["hours"], "rate_year": row["rate_year"], "dept_name": row["dept_name"], "work_name": row["work_name"], "account_code": row["account_code"], "account_name": row["account_name"], "amount": row["amount"], } ) for row in exec_rows ] actual_rows = conn.execute( text( """ SELECT group_name, grade, minutes, rate_year, label, reference, note, amount FROM project_actual_input_entries WHERE support_dept_code = :support_dept_code ORDER BY position, id """ ), {"support_dept_code": code}, ).mappings().all() entry_map["actual_input_entries"] = [ normalize_actual_input_entry_row( { "group": row["group_name"], "grade": row["grade"], "minutes": row["minutes"], "rate_year": row["rate_year"], "label": row["label"], "reference": row["reference"], "note": row["note"], "amount": row["amount"], } ) for row in actual_rows ] return entry_map def sync_project_status_cache_row(conn: Any, support_dept_code: str) -> None: code = normalize_text(support_dept_code) if not code: return basic_info = conn.execute( text("SELECT * FROM project_basic_info WHERE support_dept_code = :support_dept_code"), {"support_dept_code": code}, ).mappings().first() entry_set = load_project_status_entries_for_code(conn, code) collection_entries = entry_set["collection_entries"] task_plan_entries = entry_set["task_plan_entries"] exec_budget_entries = entry_set["exec_budget_entries"] actual_input_entries = entry_set["actual_input_entries"] contract_amount = normalize_amount((basic_info or {}).get("contract_amount")) collection_amount = sum_row_amounts(collection_entries) progress_rate = (collection_amount / contract_amount * 100) if contract_amount else 0.0 task_plan_department_rows = [row for row in task_plan_entries if normalize_text(row.get("group")) == "department"] task_plan_outsource_rows = [row for row in task_plan_entries if normalize_text(row.get("group")) == "outsource"] task_plan_joint_rows = [row for row in task_plan_entries if normalize_text(row.get("group")) == "joint"] exec_labor_rows = [row for row in exec_budget_entries if normalize_text(row.get("group")) == "labor"] exec_outsource_rows = [row for row in exec_budget_entries if normalize_text(row.get("group")) == "outsource"] exec_cost_plan_rows = [row for row in exec_budget_entries if normalize_text(row.get("group")) == "cost_plan"] item_investment = sum_row_amounts(actual_input_entries) support_name = normalize_text((basic_info or {}).get("support_dept_name")) if not support_name: support_name = normalize_text( conn.execute( text( """ SELECT support_dept_name FROM ( SELECT support_dept_name, 1 AS priority FROM project_contract_info WHERE support_dept_code = :support_dept_code UNION ALL SELECT support_dept_name, 2 AS priority FROM project_billing_entries WHERE support_dept_code = :support_dept_code UNION ALL SELECT support_dept_name, 3 AS priority FROM transactions WHERE support_dept_code = :support_dept_code ) WHERE COALESCE(support_dept_name, '') <> '' ORDER BY priority LIMIT 1 """ ), {"support_dept_code": code}, ).scalar() ) payload = { "support_dept_code": code, "support_dept_name": support_name, "progress_rate": progress_rate, "contract_amount": contract_amount, "collection_amount": collection_amount, "collection_entries_json": encode_json_rows(collection_entries), "change_round": normalize_text((basic_info or {}).get("change_round")), "item_investment": item_investment, "task_plan_department_budget": sum_row_amounts(task_plan_department_rows), "task_plan_outsource_budget": sum_row_amounts(task_plan_outsource_rows), "task_plan_outsource_detail": "\n".join( f"{normalize_text(row.get('dept_name'))} / {normalize_text(row.get('work_name'))}: {format_amount_for_text(row.get('amount'))}".strip(" /:") for row in task_plan_outsource_rows ), "task_plan_joint_operating_cost": sum_row_amounts(task_plan_joint_rows), "task_plan_entries_json": encode_json_rows(task_plan_entries), "exec_budget_labor_by_grade": sum_row_amounts(exec_labor_rows), "exec_labor_rates_json": normalize_text((basic_info or {}).get("exec_labor_rates_json")) or "{}", "exec_budget_outsource": sum_row_amounts(exec_outsource_rows), "exec_budget_cost_plan": sum_row_amounts(exec_cost_plan_rows), "exec_budget_entries_json": encode_json_rows(exec_budget_entries), "actual_input_entries_json": encode_json_rows(actual_input_entries), "project_type": normalize_text((basic_info or {}).get("project_type")), "expected_as_rate": normalize_amount((basic_info or {}).get("expected_as_rate")), "expected_sga_rate": normalize_amount((basic_info or {}).get("expected_sga_rate")), "expected_as_cost": normalize_amount((basic_info or {}).get("expected_as_cost")), "expected_sga_budget": normalize_amount((basic_info or {}).get("expected_sga_budget")), "last_editor_session_id": normalize_text((basic_info or {}).get("last_editor_session_id")), "last_client_submitted_at": normalize_text((basic_info or {}).get("last_client_submitted_at")), "project_start_date": normalize_text((basic_info or {}).get("project_start_date")), "project_end_date": normalize_text((basic_info or {}).get("project_end_date")), "completion_status": normalize_text((basic_info or {}).get("completion_status")), "notes": normalize_text((basic_info or {}).get("notes")), } conn.execute( text( """ INSERT INTO project_status ( support_dept_code, support_dept_name, progress_rate, contract_amount, collection_amount, collection_entries_json, change_round, item_investment, task_plan_department_budget, task_plan_outsource_budget, task_plan_outsource_detail, task_plan_joint_operating_cost, task_plan_entries_json, exec_budget_labor_by_grade, exec_labor_rates_json, exec_budget_outsource, exec_budget_cost_plan, exec_budget_entries_json, actual_input_entries_json, project_type, expected_as_rate, expected_sga_rate, expected_as_cost, expected_sga_budget, last_editor_session_id, last_client_submitted_at, project_start_date, project_end_date, completion_status, notes, updated_at ) VALUES ( :support_dept_code, :support_dept_name, :progress_rate, :contract_amount, :collection_amount, :collection_entries_json, :change_round, :item_investment, :task_plan_department_budget, :task_plan_outsource_budget, :task_plan_outsource_detail, :task_plan_joint_operating_cost, :task_plan_entries_json, :exec_budget_labor_by_grade, :exec_labor_rates_json, :exec_budget_outsource, :exec_budget_cost_plan, :exec_budget_entries_json, :actual_input_entries_json, :project_type, :expected_as_rate, :expected_sga_rate, :expected_as_cost, :expected_sga_budget, :last_editor_session_id, :last_client_submitted_at, :project_start_date, :project_end_date, :completion_status, :notes, CURRENT_TIMESTAMP ) ON CONFLICT(support_dept_code) DO UPDATE SET support_dept_name = excluded.support_dept_name, progress_rate = excluded.progress_rate, contract_amount = excluded.contract_amount, collection_amount = excluded.collection_amount, collection_entries_json = excluded.collection_entries_json, change_round = excluded.change_round, item_investment = excluded.item_investment, task_plan_department_budget = excluded.task_plan_department_budget, task_plan_outsource_budget = excluded.task_plan_outsource_budget, task_plan_outsource_detail = excluded.task_plan_outsource_detail, task_plan_joint_operating_cost = excluded.task_plan_joint_operating_cost, task_plan_entries_json = excluded.task_plan_entries_json, exec_budget_labor_by_grade = excluded.exec_budget_labor_by_grade, exec_labor_rates_json = excluded.exec_labor_rates_json, exec_budget_outsource = excluded.exec_budget_outsource, exec_budget_cost_plan = excluded.exec_budget_cost_plan, exec_budget_entries_json = excluded.exec_budget_entries_json, actual_input_entries_json = excluded.actual_input_entries_json, project_type = excluded.project_type, expected_as_rate = excluded.expected_as_rate, expected_sga_rate = excluded.expected_sga_rate, expected_as_cost = excluded.expected_as_cost, expected_sga_budget = excluded.expected_sga_budget, last_editor_session_id = excluded.last_editor_session_id, last_client_submitted_at = excluded.last_client_submitted_at, project_start_date = excluded.project_start_date, project_end_date = excluded.project_end_date, completion_status = excluded.completion_status, notes = excluded.notes, updated_at = CURRENT_TIMESTAMP """ ), payload, ) def clean_row_text(value: Any) -> str: return normalize_text(value) def filter_amount_rows(rows: list[dict[str, Any]], amount_key: str = "amount") -> list[dict[str, Any]]: cleaned_rows: list[dict[str, Any]] = [] for row in rows: normalized_row = {key: clean_row_text(value) for key, value in row.items()} amount = normalize_amount(normalized_row.get(amount_key)) if amount or any(value for key, value in normalized_row.items() if key != amount_key): normalized_row[amount_key] = amount cleaned_rows.append(normalized_row) return cleaned_rows def sum_row_amounts(rows: list[dict[str, Any]], amount_key: str = "amount") -> float: return sum(normalize_amount(row.get(amount_key)) for row in rows) LABOR_RATE_CATEGORY_ALIASES = { "design": "설계", "supervision": "감리", "support": "지원", "설계": "설계", "감리": "감리", "지원": "지원", } LABOR_RATE_BASE_GRADES = {"사장", "부사장", "전무", "전무이사", "상무", "상무이사", "이사", "부장", "차장", "과장", "대리", "사원"} LABOR_RATE_DERIVED_GRADES = {"수석", "책임", "선임", "연구원"} def _normalize_labor_grade_name(value: Any) -> str: grade_text = normalize_text(value).replace(" ", "") aliases = { "전무이사": "전무", "상무이사": "상무", "409091": "이사", "272727": "부장", "250000": "차장", "227273": "과장", "204545": "대리", "181818": "사원", } return aliases.get(grade_text, grade_text) def _normalize_labor_rate_category(value: Any) -> str: text_value = normalize_text(value) if "감리" in text_value: return "감리" if "지원" in text_value: return "지원" if "설계" in text_value: return "설계" return LABOR_RATE_CATEGORY_ALIASES.get(text_value, "설계") def _labor_rate_lookup_category(value: Any) -> str: category = _normalize_labor_rate_category(value) return "감리" if category == "지원" else category def _ceil_to_hundreds(value: float) -> float: amount = float(value or 0) if amount <= 0: return 0.0 return float(math.ceil(amount / 100.0) * 100) def _get_numeric_labor_rate(bucket: dict[str, float], grade: str) -> float: return normalize_amount(bucket.get(_normalize_labor_grade_name(grade))) def _get_derived_labor_rate(design_bucket: dict[str, float], grade: str) -> float: grade_text = _normalize_labor_grade_name(grade) if grade_text == "수석": return _get_numeric_labor_rate(design_bucket, "이사") if grade_text == "책임": manager_rate = _get_numeric_labor_rate(design_bucket, "부장") deputy_rate = _get_numeric_labor_rate(design_bucket, "차장") if not manager_rate or not deputy_rate: return 0.0 return _ceil_to_hundreds(((manager_rate * 5) + (deputy_rate * 2)) / 7) if grade_text == "선임": deputy_rate = _get_numeric_labor_rate(design_bucket, "차장") section_rate = _get_numeric_labor_rate(design_bucket, "과장") assistant_rate = _get_numeric_labor_rate(design_bucket, "대리") if not deputy_rate or not section_rate or not assistant_rate: return 0.0 return _ceil_to_hundreds(((deputy_rate * 2) + (section_rate * 3) + assistant_rate) / 6) if grade_text == "연구원": assistant_rate = _get_numeric_labor_rate(design_bucket, "대리") staff_rate = _get_numeric_labor_rate(design_bucket, "사원") if not assistant_rate or not staff_rate: return 0.0 return _ceil_to_hundreds(((assistant_rate * 2) + (staff_rate * 3)) / 5) return 0.0 def _parse_labor_rates_json(raw_json: Any) -> dict[str, dict[str, dict[str, float]]]: try: parsed = json.loads(normalize_text(raw_json) or "{}") except json.JSONDecodeError: return {} if not isinstance(parsed, dict): return {} normalized: dict[str, dict[str, dict[str, float]]] = {} for year_key, bucket in parsed.items(): year_text = normalize_text(year_key) if not year_text or not isinstance(bucket, dict): continue normalized[year_text] = {} has_category_buckets = any( _normalize_labor_rate_category(key) in {"설계", "감리", "지원"} and isinstance(value, dict) for key, value in bucket.items() ) if has_category_buckets: for category_key, category_bucket in bucket.items(): if not isinstance(category_bucket, dict): continue category = _normalize_labor_rate_category(category_key) normalized[year_text].setdefault(category, {}) for grade_key, amount_value in category_bucket.items(): grade_text = _normalize_labor_grade_name(grade_key) if not grade_text: continue normalized[year_text][category][grade_text] = normalize_amount(amount_value) else: normalized[year_text].setdefault("설계", {}) for grade_key, amount_value in bucket.items(): grade_text = _normalize_labor_grade_name(grade_key) if not grade_text: continue normalized[year_text]["설계"][grade_text] = normalize_amount(amount_value) return normalized def _resolve_labor_rate( rates_by_year: dict[str, dict[str, dict[str, float]]], grade: Any, rate_year: Any, fallback_year: Any = "", project_type: Any = "", ) -> float: grade_text = _normalize_labor_grade_name(grade) if not grade_text: return 0.0 category = _labor_rate_lookup_category(project_type) year_candidates: list[str] = [] for value in (rate_year, fallback_year): text_value = normalize_text(value) if text_value and text_value not in year_candidates: year_candidates.append(text_value) if not year_candidates: year_candidates.append(str(datetime.now().year)) for year_text in year_candidates: year_bucket = rates_by_year.get(year_text) or {} design_bucket = year_bucket.get("설계") or {} if grade_text in LABOR_RATE_DERIVED_GRADES: amount = _get_derived_labor_rate(design_bucket, grade_text) if amount: return amount category_bucket = year_bucket.get(category) or {} if not category_bucket and category == "감리": category_bucket = year_bucket.get("지원") or {} if not category_bucket: category_bucket = design_bucket amount = normalize_amount(category_bucket.get(grade_text)) if amount: return amount return 0.0 def _parse_exec_hours_value(value: Any) -> float: digits = "".join(character for character in normalize_text(value) if character.isdigit()) if not digits: return 0.0 return float(int(digits[:5])) def _parse_minutes_value(value: Any) -> float: digits = "".join(character for character in normalize_text(value) if character.isdigit()) if not digits: return 0.0 return float(int(digits)) def sanitize_project_labor_amount_rows(conn: Any) -> int: shared_rates = _parse_labor_rates_json(get_shared_exec_labor_rates_json()) rows = conn.execute( text( """ SELECT support_dept_code, COALESCE(exec_labor_rates_json, '{}') AS exec_labor_rates_json FROM project_status WHERE COALESCE(support_dept_code, '') <> '' """ ) ).mappings().all() updated_count = 0 for row in rows: code = normalize_text(row.get("support_dept_code")) if not code: continue entry_set = load_project_status_entries_for_code(conn, code) exec_entries = list(entry_set.get("exec_budget_entries", [])) actual_entries = list(entry_set.get("actual_input_entries", [])) rates = _parse_labor_rates_json(row.get("exec_labor_rates_json")) or shared_rates changed = False for entry in exec_entries: if normalize_text(entry.get("group")) != "labor": continue hours_value = _parse_exec_hours_value(entry.get("hours")) next_amount = _resolve_labor_rate(rates, entry.get("grade"), entry.get("rate_year")) * hours_value if abs(normalize_amount(entry.get("amount")) - next_amount) > 0.5: entry["amount"] = next_amount changed = True for entry in actual_entries: if normalize_text(entry.get("group")) != "labor": continue minutes_value = _parse_minutes_value(entry.get("minutes")) next_amount = _resolve_labor_rate(rates, entry.get("grade"), entry.get("rate_year")) * (minutes_value / 60.0 if minutes_value else 0.0) if abs(normalize_amount(entry.get("amount")) - next_amount) > 0.5: entry["amount"] = next_amount changed = True if not changed: continue replace_project_status_child_entries( conn, code, entry_set.get("collection_entries", []), entry_set.get("task_plan_entries", []), exec_entries, actual_entries, ) sync_project_status_cache_row(conn, code) updated_count += 1 return updated_count def get_support_department_options() -> list[dict[str, str]]: with engine.begin() as conn: rows = conn.execute( text( """ SELECT DISTINCT support_dept_code, support_dept_name FROM ( SELECT support_dept_code, support_dept_name FROM transactions UNION ALL SELECT support_dept_code, support_dept_name FROM project_contract_info UNION ALL SELECT support_dept_code, support_dept_name FROM project_billing_entries ) AS merged WHERE COALESCE(support_dept_code, '') <> '' AND COALESCE(support_dept_name, '') <> '' AND support_dept_code NOT IN ('ZZZZZZ') AND support_dept_name NOT IN ('공통', '경영지원부', '기술개발센터', '임원실', '기술개발부', '총괄기획실') ORDER BY support_dept_code, support_dept_name """ ) ).mappings().all() return [ { "support_dept_code": normalize_text(row["support_dept_code"]), "support_dept_name": normalize_text(row["support_dept_name"]), } for row in rows ] def get_cost_department_options() -> list[dict[str, str]]: with engine.begin() as conn: rows = conn.execute( text( """ SELECT DISTINCT cost_dept_name FROM transactions WHERE COALESCE(cost_dept_name, '') <> '' ORDER BY cost_dept_name """ ) ).mappings().all() return [ {"cost_dept_name": normalize_text(row["cost_dept_name"])} for row in rows if normalize_text(row["cost_dept_name"]) ] def get_cost_account_options() -> list[dict[str, str]]: with engine.begin() as conn: rows = conn.execute( text( """ SELECT DISTINCT account_code, account_name FROM transactions WHERE accounting_category = '원가' AND COALESCE(account_code, '') <> '' AND COALESCE(account_name, '') <> '' ORDER BY account_code, account_name """ ) ).mappings().all() deduped: dict[tuple[str, str], dict[str, str]] = {} for row in rows: account_code = normalize_text(row["account_code"]) account_name = normalize_text(row["account_name"]) if not account_code or not account_name: continue normalized_code, normalized_name, _ = normalize_account_display(account_code, account_name) key = (normalized_code, normalized_name) deduped[key] = { "account_code": normalized_code, "account_name": normalized_name, } deduped[(EXEC_COST_PLAN_OTHER_CODE, EXEC_COST_PLAN_OTHER_NAME)] = { "account_code": EXEC_COST_PLAN_OTHER_CODE, "account_name": EXEC_COST_PLAN_OTHER_NAME, } return sorted(deduped.values(), key=lambda item: (item["account_code"], item["account_name"])) def get_import_sync_summary() -> dict[str, Any]: with engine.begin() as conn: row = conn.execute( text( """ SELECT (SELECT COUNT(*) FROM project_contract_info) AS contract_project_count, (SELECT COUNT(*) FROM project_billing_entries) AS billing_entry_count, (SELECT COUNT(DISTINCT support_dept_code) FROM project_billing_entries) AS billing_project_count, (SELECT COUNT(*) FROM project_contract_info WHERE COALESCE(review_tag, '') <> '') AS review_needed_count, (SELECT SUM(hanmac_contract_amount) FROM project_contract_info) AS total_hanmac_contract_amount, (SELECT SUM(collected_amount) FROM project_billing_entries) AS total_collected_amount, (SELECT MAX(updated_at) FROM project_contract_info) AS latest_contract_sync, (SELECT MAX(updated_at) FROM project_billing_entries) AS latest_billing_sync """ ) ).mappings().first() return dict(row) if row else {} def get_project_contract_info_map() -> dict[str, dict[str, Any]]: with engine.begin() as conn: rows = conn.execute( text("SELECT * FROM project_contract_info ORDER BY support_dept_code") ).mappings().all() return {normalize_text(row["support_dept_code"]): dict(row) for row in rows} def get_project_billing_summary_map() -> dict[str, dict[str, Any]]: with engine.begin() as conn: rows = conn.execute( text( """ SELECT support_dept_code, MAX(support_dept_name) AS support_dept_name, MAX(contract_amount) AS contract_amount, MAX(client_name) AS client_name, MAX(support_department) AS support_department, MAX(business_division) AS business_division, SUM(billed_amount) AS billed_amount, SUM(collected_amount) AS collected_amount, SUM(balance_amount) AS balance_amount, MAX(billing_date) AS latest_billing_date FROM project_billing_entries GROUP BY support_dept_code ORDER BY support_dept_code """ ) ).mappings().all() entry_rows = conn.execute( text( """ SELECT support_dept_code, billing_type, progress_round, billing_date, tax_invoice_date, expected_collection_date, billed_amount, collected_amount, balance_amount, collection_rate, note FROM project_billing_entries ORDER BY support_dept_code, billing_date, progress_round, id """ ) ).mappings().all() result = {normalize_text(row["support_dept_code"]): dict(row) for row in rows} for item in result.values(): item["entries"] = [] for row in entry_rows: support_dept_code = normalize_text(row["support_dept_code"]) if support_dept_code not in result: continue result[support_dept_code]["entries"].append( normalize_collection_entry_row( { "progress_type": "", "billing_round": normalize_round_value(row["progress_round"]), "billing_type": normalize_text(row["billing_type"]), "billing_date": normalize_date_text(row["billing_date"]), "billed_amount": normalize_amount(row["billed_amount"]), "round": normalize_round_value(row["progress_round"]), "date": normalize_date_text(row["tax_invoice_date"]) or normalize_date_text(row["expected_collection_date"]), "amount": normalize_amount(row["collected_amount"]), "balance_amount": normalize_amount(row["balance_amount"]), "collection_rate": normalize_amount(row["collection_rate"]), "note": normalize_text(row["note"]), } ) ) return result def merge_project_external_fields( item: dict[str, Any], contract_info: dict[str, Any] | None, billing_summary: dict[str, Any] | None, latest_summary_change: dict[str, Any] | None = None, latest_round_change: dict[str, Any] | None = None, change_representative_code: str = "", change_title_key: str = "", ) -> dict[str, Any]: contract_info = contract_info or {} billing_summary = billing_summary or {} support_dept_name = normalize_text(item.get("support_dept_name")) or normalize_text(contract_info.get("support_dept_name")) or normalize_text(billing_summary.get("support_dept_name")) contract_amount = normalize_amount(item.get("contract_amount")) latest_summary_change = latest_summary_change or {} latest_round_change = latest_round_change or {} direct_contract_amount = ( normalize_amount(billing_summary.get("contract_amount")) or normalize_amount(contract_info.get("hanmac_contract_amount")) or contract_amount ) latest_round_contract_amount = normalize_amount(latest_round_change.get("changed_contract_amount")) latest_changed_contract_amount = ( normalize_amount(latest_summary_change.get("changed_contract_amount")) or latest_round_contract_amount ) current_code = normalize_text(item.get("support_dept_code")) if direct_contract_amount: contract_amount = direct_contract_amount elif latest_round_contract_amount: contract_amount = latest_round_contract_amount elif latest_changed_contract_amount and (not change_representative_code or current_code == change_representative_code): contract_amount = latest_changed_contract_amount collection_amount = normalize_amount(item.get("collection_amount")) if not collection_amount: collection_amount = normalize_amount(billing_summary.get("collected_amount")) collection_entries = item.get("collection_entries") if not collection_entries: collection_entries = billing_summary.get("entries", []) project_start_date = normalize_text(item.get("project_start_date")) or normalize_text(contract_info.get("project_start_date")) project_end_date = ( normalize_text(item.get("project_end_date")) or normalize_text(contract_info.get("project_end_date")) or normalize_text(latest_summary_change.get("changed_project_end_date")) or normalize_text(latest_round_change.get("changed_project_end_date")) ) completion_status = normalize_text(item.get("completion_status")) or normalize_text(contract_info.get("progress_status")) project_type = ( normalize_text(item.get("project_type")) or normalize_text(contract_info.get("business_division")) or normalize_text(billing_summary.get("business_division")) or normalize_text(latest_summary_change.get("business_division")) or normalize_text(latest_round_change.get("business_division")) or ("설계" if current_code.upper().startswith("Y") else "") or ("감리" if current_code.upper().startswith("Z") else "") ) progress_rate = normalize_amount(item.get("progress_rate")) if not progress_rate and contract_amount: progress_rate = (collection_amount / contract_amount * 100) if contract_amount else 0.0 item["support_dept_name"] = support_dept_name item["contract_amount"] = contract_amount item["collection_amount"] = collection_amount item["collection_entries"] = collection_entries or [] item["project_start_date"] = project_start_date item["project_end_date"] = project_end_date item["completion_status"] = completion_status item["project_type"] = project_type item["progress_rate"] = progress_rate item["client_name"] = ( normalize_text(contract_info.get("client_name")) or normalize_text(billing_summary.get("client_name")) or normalize_text(latest_summary_change.get("client_name")) or normalize_text(latest_round_change.get("client_name")) ) item["order_method"] = normalize_text(contract_info.get("order_method")) item["joint_contract"] = normalize_text(contract_info.get("joint_contract")) item["pm_name"] = normalize_text(contract_info.get("pm_name")) item["contract_status"] = normalize_text(contract_info.get("contract_status")) item["progress_status"] = normalize_text(contract_info.get("progress_status")) item["work_category"] = normalize_text(contract_info.get("work_category")) item["review_tag"] = normalize_text(contract_info.get("review_tag")) item["review_note"] = normalize_text(contract_info.get("review_note")) item["total_contract_amount"] = normalize_amount(contract_info.get("total_contract_amount")) item["hanmac_contract_amount"] = normalize_amount(contract_info.get("hanmac_contract_amount")) item["billing_contract_amount"] = normalize_amount(billing_summary.get("contract_amount")) item["billed_amount"] = normalize_amount(billing_summary.get("billed_amount")) source_collection_balance = normalize_amount(billing_summary.get("balance_amount")) if billing_summary: item["collection_balance_amount"] = source_collection_balance else: item["collection_balance_amount"] = contract_amount - collection_amount item["latest_billing_date"] = normalize_text(billing_summary.get("latest_billing_date")) item["changed_contract_amount"] = latest_changed_contract_amount item["changed_contract_date"] = normalize_text(latest_summary_change.get("change_date")) or normalize_text(latest_round_change.get("change_date")) item["changed_project_end_date"] = normalize_text(latest_summary_change.get("changed_project_end_date")) or normalize_text(latest_round_change.get("changed_project_end_date")) item["change_contract_representative_code"] = change_representative_code item["change_contract_title_key"] = change_title_key return item def normalize_account_display(account_code: Any, account_name: Any) -> tuple[str, str, str]: normalized_code = normalize_text(account_code)[:6] normalized_name = re.sub(r"\s*\(.*$", "", normalize_text(account_name)).strip() if normalized_code and normalized_name: label = f"{normalized_code} · {normalized_name}" else: label = normalized_name or normalized_code or "미분류" return normalized_code, normalized_name, label def build_transaction_posting_display(voucher_number: Any, posting_date: Any) -> str: year, month, day = extract_period(normalize_text(voucher_number), normalize_text(posting_date)) if year and month and day: return f"{year:04d}-{month:02d}-{day:02d}" parsed_date = normalize_date_text(posting_date) if re.match(r"^\d{4}-\d{2}-\d{2}$", parsed_date): return parsed_date return "-" def get_data_version() -> str: version_parts: list[str] = [] for path in (DB_PATH, DB_PATH.with_name(f"{DB_PATH.name}-wal")): try: stat = path.stat() except FileNotFoundError: continue version_parts.append(f"{stat.st_mtime_ns}:{stat.st_size}") return "|".join(version_parts) BUSINESS_DATA_INCLUDE_TABLES = { "app_keyword_rules", "app_option_items", "hanmac_holidays", "project_actual_input_entries", "project_analysis_settings", "project_basic_info", "project_billing_entries", "project_collection_entries", "project_comparison_notes", "project_contract_change_round", "project_contract_change_summary", "project_contract_info", "project_exec_budget_entries", "project_quick_links", "project_related_links", "project_status", "project_task_plan_entries", "project_uncontracted_classification", "transactions", "wehago_compare_settings", "wehago_benefit_category_overrides", "wehago_comparison_results", "wehago_ledger_rows", "wehago_manual_pair_matches", "wehago_recheck_reviews", "wehago_recheck_row_changes", "wehago_source_files", "wehago_voucher_rows", } BUSINESS_DATA_EXCLUDE_PREFIXES = ( "app_login", "app_role", "app_session", "app_user", "db_backup", "system_", ) BUSINESS_DATA_EXCLUDE_TABLES = { "app_save_events", "hanmac_aggregate_query_cache", "hanmac_aggregate_query_metrics", "hanmac_aggregate_query_rows", "hanmac_export_jobs", "hanmac_preview_query_cache", "project_page_state", "project_status_snapshots", "wehago_action_history", "wehago_background_jobs", "wehago_compare_export_jobs", "wehago_compare_export_row_cache", "wehago_compare_query_groups", "wehago_compare_query_metrics", "wehago_compare_query_page_cache", "wehago_compare_query_rows", "wehago_metric_count_cache", "wehago_pair_recommend_cache", "wehago_raw_erp_trace_candidate_cache", "wehago_result_row_cache", "wehago_snapshot_status", "wehago_summary_range_cache", } def _quote_sqlite_identifier(identifier: str) -> str: return '"' + identifier.replace('"', '""') + '"' def _business_version_table_names(conn: Any) -> list[str]: rows = conn.execute( text( """ SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%' ORDER BY name """ ) ).fetchall() names = [str(row[0]) for row in rows] return [ name for name in names if name in BUSINESS_DATA_INCLUDE_TABLES and name not in BUSINESS_DATA_EXCLUDE_TABLES and not any(name.startswith(prefix) for prefix in BUSINESS_DATA_EXCLUDE_PREFIXES) ] def get_business_data_version() -> str: init_db() version_parts: list[str] = [] try: with engine.connect() as conn: for table_name in _business_version_table_names(conn): quoted_table = _quote_sqlite_identifier(table_name) columns = [ str(row[1]) for row in conn.exec_driver_sql(f"PRAGMA table_info({quoted_table})").fetchall() ] probes = ["COUNT(*) AS row_count"] if "updated_at" in columns: probes.append("COALESCE(MAX(updated_at), '') AS updated_at") else: probes.append("'' AS updated_at") if "created_at" in columns: probes.append("COALESCE(MAX(created_at), '') AS created_at") else: probes.append("'' AS created_at") if "id" in columns: probes.append("COALESCE(MAX(id), 0) AS max_id") else: probes.append("COALESCE(MAX(rowid), 0) AS max_id") row = conn.exec_driver_sql(f"SELECT {', '.join(probes)} FROM {quoted_table}").mappings().first() if row: version_parts.append( "|".join( [ table_name, str(row["row_count"] or 0), str(row["updated_at"] or ""), str(row["created_at"] or ""), str(row["max_id"] or 0), ] ) ) except Exception as exc: logger.warning("business data version fallback to file version: %s", exc) return get_data_version() raw_version = "\n".join(version_parts) return hashlib.sha1(raw_version.encode("utf-8")).hexdigest() if raw_version else "" def build_health_payload(force: bool = False) -> dict[str, str]: now = time.monotonic() cached_payload = _HEALTH_PAYLOAD_CACHE.get("payload") if not force and cached_payload and now < float(_HEALTH_PAYLOAD_CACHE.get("expires_at") or 0.0): return dict(cached_payload) business_data_version = get_business_data_version() system_data_version = get_data_version() payload = { "status": "ok", "server_time": datetime.now().isoformat(timespec="seconds"), "business_data_version": business_data_version, "system_data_version": system_data_version, "data_version": business_data_version, } _HEALTH_PAYLOAD_CACHE["payload"] = payload _HEALTH_PAYLOAD_CACHE["expires_at"] = now + 2.0 return dict(payload) def check_record_revision(conn: Any, table_name: str, key_column: str, key_value: Any, edit_revision: str) -> None: if not key_value or not edit_revision: return current_revision = conn.execute( text(f"SELECT updated_at FROM {table_name} WHERE {key_column} = :key_value"), {"key_value": key_value}, ).scalar() current_revision_text = normalize_text(current_revision) if current_revision_text and current_revision_text != normalize_text(edit_revision): raise ValueError("다른 사용자가 먼저 수정했습니다. 최신 화면으로 다시 확인한 뒤 저장해주세요.") def date_diff_days(start_date: str, end_date: str) -> int | None: if not start_date or not end_date: return None try: start = datetime.strptime(start_date, "%Y-%m-%d").date() end = datetime.strptime(end_date, "%Y-%m-%d").date() return (end - start).days except ValueError: return None def detect_category(account_code: str) -> str: if account_code.startswith("5"): return "원가" if account_code.startswith("4"): return "수입/매출액" if account_code.startswith("6"): return "판관비" return "기타" def extract_period(voucher_number: str, posting_date: str) -> tuple[int | None, int | None, int | None]: voucher_match = VOUCHER_PATTERN.match(voucher_number) if voucher_match: year_text, month_text, day_text = voucher_match.groups() return int(year_text), int(month_text), int(day_text) parsed_date = normalize_date_text(posting_date) if re.match(r"^\d{4}-\d{2}-\d{2}$", parsed_date): parsed = datetime.strptime(parsed_date, "%Y-%m-%d") return parsed.year, parsed.month, parsed.day return None, None, None def choose_amount(debit_supply: float, credit_supply: float) -> float: if debit_supply: return abs(debit_supply) if credit_supply: return abs(credit_supply) return 0.0 def canonical_header_name(value: Any) -> str | None: normalized = normalize_text(value).replace(" ", "") if not normalized: return None if normalized in DIRECT_HEADER_MAP: return DIRECT_HEADER_MAP[normalized] if "확정전표" in normalized: return "confirmed_voucher_number" return None def empty_record() -> dict[str, str]: record = {field: "" for field in FORM_FIELDS} record["id"] = "" return record def build_transaction_payload(raw: dict[str, Any], source_file: str = "") -> dict[str, Any]: payload: dict[str, Any] = {} for field in FORM_FIELDS: if field in {"debit_supply", "debit_vat", "credit_supply", "credit_vat"}: payload[field] = normalize_amount(raw.get(field)) elif field == "posting_date": payload[field] = normalize_date_text(raw.get(field)) else: payload[field] = normalize_text(raw.get(field)) payload["accounting_category"] = detect_category(payload["account_code"]) payload["amount"] = choose_amount(payload["debit_supply"], payload["credit_supply"]) year, month, day = extract_period(payload["voucher_number"], payload["posting_date"]) payload["year"] = year payload["month"] = month payload["day"] = day payload["source_file"] = source_file return payload def save_transaction(payload: dict[str, Any], record_id: int | None = None) -> None: init_db() started_at = time.perf_counter() normalized_record_id = str(record_id) if record_id is not None else "" params = { **payload, "record_id": record_id, "last_editor_session_id": normalize_text(payload.get("client_session_id")), "last_client_submitted_at": normalize_text(payload.get("client_submitted_at")), } with engine.begin() as conn: if record_id: check_record_revision(conn, "transactions", "id", record_id, normalize_text(payload.get("edit_revision"))) conn.execute( text( """ UPDATE transactions SET approval_status = :approval_status, voucher_number = :voucher_number, account_code = :account_code, account_name = :account_name, debit_supply = :debit_supply, debit_vat = :debit_vat, credit_supply = :credit_supply, credit_vat = :credit_vat, issuing_dept_code = :issuing_dept_code, issuing_dept_name = :issuing_dept_name, confirmed_voucher_number = :confirmed_voucher_number, support_dept_code = :support_dept_code, support_dept_name = :support_dept_name, cost_dept_code = :cost_dept_code, cost_dept_name = :cost_dept_name, memo1 = :memo1, memo2 = :memo2, partner_code = :partner_code, partner_name = :partner_name, tax_code = :tax_code, posting_date = :posting_date, voucher_type = :voucher_type, management_item = :management_item, accounting_category = :accounting_category, amount = :amount, year = :year, month = :month, day = :day, source_file = COALESCE(NULLIF(:source_file, ''), source_file), last_editor_session_id = :last_editor_session_id, last_client_submitted_at = :last_client_submitted_at, updated_at = CURRENT_TIMESTAMP WHERE id = :record_id """ ), params, ) return conn.execute( text( """ INSERT INTO transactions ( approval_status, voucher_number, account_code, account_name, debit_supply, debit_vat, credit_supply, credit_vat, issuing_dept_code, issuing_dept_name, confirmed_voucher_number, support_dept_code, support_dept_name, cost_dept_code, cost_dept_name, memo1, memo2, partner_code, partner_name, tax_code, posting_date, voucher_type, management_item, accounting_category, amount, year, month, day, source_file, last_editor_session_id, last_client_submitted_at ) VALUES ( :approval_status, :voucher_number, :account_code, :account_name, :debit_supply, :debit_vat, :credit_supply, :credit_vat, :issuing_dept_code, :issuing_dept_name, :confirmed_voucher_number, :support_dept_code, :support_dept_name, :cost_dept_code, :cost_dept_name, :memo1, :memo2, :partner_code, :partner_name, :tax_code, :posting_date, :voucher_type, :management_item, :accounting_category, :amount, :year, :month, :day, :source_file, :last_editor_session_id, :last_client_submitted_at ) """ ), params, ) duration_ms = int((time.perf_counter() - started_at) * 1000) log_save_event( "transaction_save", "transaction", normalized_record_id or normalize_text(payload.get("voucher_number")), session_id=payload.get("client_session_id"), duration_ms=duration_ms, payload={ "record_id": normalized_record_id, "voucher_number": normalize_text(payload.get("voucher_number")), "support_dept_code": normalize_text(payload.get("support_dept_code")), "account_code": normalize_text(payload.get("account_code")), }, ) maybe_create_database_backup("transaction_save", payload.get("client_session_id")) def get_record_for_edit(record_id: int | None) -> dict[str, Any]: if not record_id: return empty_record() with engine.begin() as conn: row = conn.execute( text("SELECT * FROM transactions WHERE id = :record_id"), {"record_id": record_id}, ).mappings().first() if not row: return empty_record() data = dict(row) for key, value in list(data.items()): if value is None: data[key] = "" return data def get_support_businesses() -> list[dict[str, Any]]: with engine.begin() as conn: rows = conn.execute( text( """ SELECT support_dept_code, support_dept_name, COUNT(*) AS row_count 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 ( '공통', '경영지원부', '기술개발센터', '임원실', '기술개발부', '총괄기획실' ) GROUP BY support_dept_code, support_dept_name ORDER BY support_dept_code, support_dept_name """ ) ).mappings().all() result = [dict(row) for row in rows] for item in result: item["project_duration_days"] = date_diff_days(item.get("project_start_date", ""), item.get("project_end_date", "")) item["planned_total"] = ( (item.get("task_plan_department_budget") or 0) + (item.get("task_plan_outsource_budget") or 0) + (item.get("task_plan_joint_operating_cost") or 0) + (item.get("exec_budget_labor_by_grade") or 0) + (item.get("exec_budget_outsource") or 0) + (item.get("exec_budget_cost_plan") or 0) + (item.get("expected_as_cost") or 0) + (item.get("expected_sga_budget") or 0) ) item["actual_total_expense"] = ( (item.get("total_cost") or 0) + (item.get("total_sga") or 0) ) return result def get_monthly_summary() -> list[dict[str, Any]]: with engine.begin() as conn: rows = conn.execute( text( """ SELECT year, month, SUM( CASE WHEN accounting_category = '원가' AND account_code NOT LIKE '5012%' AND account_code NOT LIKE '5017%' THEN amount ELSE 0 END ) AS cost_sum, SUM(CASE WHEN accounting_category = '판관비' THEN amount ELSE 0 END) AS sga_sum, SUM(CASE WHEN account_code LIKE '5012%' THEN amount ELSE 0 END) AS labor_sum, SUM(CASE WHEN account_code LIKE '5017%' THEN amount ELSE 0 END) AS outsourcing_sum FROM transactions WHERE year IS NOT NULL AND month IS NOT NULL GROUP BY year, month ORDER BY year, month """ ) ).mappings().all() return [dict(row) for row in rows] def get_yearly_summary() -> list[dict[str, Any]]: with engine.begin() as conn: rows = conn.execute( text( """ SELECT year, SUM( CASE WHEN accounting_category = '원가' AND account_code NOT LIKE '5012%' AND account_code NOT LIKE '5017%' THEN amount ELSE 0 END ) AS cost_sum, SUM(CASE WHEN accounting_category = '판관비' THEN amount ELSE 0 END) AS sga_sum, SUM(CASE WHEN account_code LIKE '5012%' THEN amount ELSE 0 END) AS labor_sum, SUM(CASE WHEN account_code LIKE '5017%' THEN amount ELSE 0 END) AS outsourcing_sum FROM transactions WHERE year IS NOT NULL GROUP BY year ORDER BY year """ ) ).mappings().all() return [dict(row) for row in rows] def get_business_monthly_summary() -> list[dict[str, Any]]: with engine.begin() as conn: rows = conn.execute( text( """ SELECT year, month, support_dept_code, support_dept_name, SUM(CASE WHEN accounting_category = '원가' THEN amount ELSE 0 END) AS cost_sum, SUM(CASE WHEN accounting_category = '판관비' THEN amount ELSE 0 END) AS sga_sum FROM transactions WHERE year IS NOT NULL AND month IS NOT NULL AND COALESCE(support_dept_code, '') <> '' AND support_dept_code NOT IN ('ZZZZZZ') AND COALESCE(support_dept_name, '') <> '' AND support_dept_name NOT IN ( '공통', '경영지원부', '기술개발센터', '임원실', '기술개발부', '총괄기획실' ) GROUP BY year, month, support_dept_code, support_dept_name ORDER BY year, month, support_dept_code, support_dept_name """ ) ).mappings().all() return [dict(row) for row in rows] def get_project_status_rows() -> list[dict[str, Any]]: contract_info_map = get_project_contract_info_map() billing_summary_map = get_project_billing_summary_map() latest_summary_by_title, latest_round_by_code, representative_by_title, title_by_code = get_project_contract_change_maps() with engine.begin() as conn: entry_maps = load_project_status_entry_maps(conn) rows = conn.execute( text( f""" SELECT b.support_dept_code, b.support_dept_name, b.row_count, COALESCE(ps.progress_rate, 0) AS progress_rate, COALESCE(ps.contract_amount, 0) AS contract_amount, COALESCE(ps.collection_amount, 0) AS collection_amount, COALESCE(ps.collection_entries_json, '[]') AS collection_entries_json, COALESCE(ps.change_round, '') AS change_round, COALESCE(ps.item_investment, 0) AS item_investment, COALESCE(ps.task_plan_department_budget, 0) AS task_plan_department_budget, COALESCE(ps.task_plan_outsource_budget, 0) AS task_plan_outsource_budget, COALESCE(ps.task_plan_outsource_detail, '') AS task_plan_outsource_detail, COALESCE(ps.task_plan_joint_operating_cost, 0) AS task_plan_joint_operating_cost, COALESCE(ps.task_plan_entries_json, '[]') AS task_plan_entries_json, COALESCE(ps.exec_budget_labor_by_grade, 0) AS exec_budget_labor_by_grade, COALESCE(ps.exec_budget_outsource, 0) AS exec_budget_outsource, COALESCE(ps.exec_budget_cost_plan, 0) AS exec_budget_cost_plan, COALESCE(ps.exec_budget_entries_json, '[]') AS exec_budget_entries_json, COALESCE(ps.actual_input_entries_json, '[]') AS actual_input_entries_json, COALESCE(ps.expected_as_cost, 0) AS expected_as_cost, COALESCE(ps.expected_sga_budget, 0) AS expected_sga_budget, COALESCE(ps.project_start_date, '') AS project_start_date, COALESCE(ps.project_end_date, '') AS project_end_date, COALESCE(ps.completion_status, '') AS completion_status, COALESCE(ps.notes, '') AS notes, COALESCE(agg.total_cost, 0) AS total_cost, COALESCE(agg.total_sga, 0) AS total_sga, COALESCE(agg.total_revenue, 0) AS total_revenue, COALESCE(agg.actual_labor, 0) AS actual_labor, COALESCE(agg.actual_outsource, 0) AS actual_outsource, COALESCE(agg.latest_year, 0) AS latest_year, COALESCE(agg.latest_month, 0) AS latest_month FROM ( SELECT support_dept_code, support_dept_name, COUNT(*) AS row_count 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 ( '공통', '경영지원부', '기술개발센터', '임원실', '기술개발부', '총괄기획실' ) GROUP BY support_dept_code, support_dept_name ) AS b LEFT JOIN project_status AS ps ON ps.support_dept_code = b.support_dept_code LEFT JOIN ( SELECT support_dept_code, SUM(CASE WHEN accounting_category = '원가' THEN amount ELSE 0 END) AS total_cost, SUM(CASE WHEN accounting_category = '판관비' THEN amount ELSE 0 END) AS total_sga, SUM(CASE WHEN {REVENUE_SQL} THEN amount ELSE 0 END) AS total_revenue, SUM(CASE WHEN account_code LIKE '5012%' THEN amount ELSE 0 END) AS actual_labor, SUM(CASE WHEN account_code LIKE '5017%' THEN amount ELSE 0 END) AS actual_outsource, MAX(year) AS latest_year, MAX(month) AS latest_month FROM transactions WHERE COALESCE(support_dept_code, '') <> '' GROUP BY support_dept_code ) AS agg ON agg.support_dept_code = b.support_dept_code ORDER BY b.support_dept_code, b.support_dept_name """ ) ).mappings().all() result = [] seen_codes: set[str] = set() shared_input_map = _get_shared_cluster_input_map() for row in rows: item = dict(row) support_dept_code = normalize_text(item.get("support_dept_code")) entry_set = ensure_project_entry_set(entry_maps.get(support_dept_code)) if support_dept_code in entry_maps else extract_project_status_entry_sets(item) item["collection_entries"] = entry_set["collection_entries"] item["task_plan_entries"] = entry_set["task_plan_entries"] item["exec_budget_entries"] = entry_set["exec_budget_entries"] item["actual_input_entries"] = entry_set["actual_input_entries"] item.pop("collection_entries_json", None) item.pop("task_plan_entries_json", None) item.pop("exec_budget_entries_json", None) item.pop("actual_input_entries_json", None) item = merge_project_external_fields( item, contract_info_map.get(normalize_text(item.get("support_dept_code"))), billing_summary_map.get(normalize_text(item.get("support_dept_code"))), latest_summary_by_title.get(title_by_code.get(normalize_text(item.get("support_dept_code"))) or normalize_project_title_for_linking(item.get("support_dept_name"))), latest_round_by_code.get(normalize_text(item.get("support_dept_code"))), representative_by_title.get(title_by_code.get(normalize_text(item.get("support_dept_code"))) or normalize_project_title_for_linking(item.get("support_dept_name")), ""), title_by_code.get(normalize_text(item.get("support_dept_code"))) or normalize_project_title_for_linking(item.get("support_dept_name")), ) shared_meta = shared_input_map.get(support_dept_code, {}) item["shared_input_owner_code"] = normalize_text(shared_meta.get("owner_code")) item["shared_input_cluster_codes"] = list(shared_meta.get("cluster_codes") or []) seen_codes.add(normalize_text(item.get("support_dept_code"))) result.append(item) for support_dept_code in sorted((set(contract_info_map) | set(billing_summary_map)) - seen_codes): fallback_title_key = ( title_by_code.get(support_dept_code) or normalize_project_title_for_linking( contract_info_map.get(support_dept_code, {}).get("support_dept_name") or billing_summary_map.get(support_dept_code, {}).get("support_dept_name") ) ) result.append( merge_project_external_fields( { "support_dept_code": support_dept_code, "support_dept_name": "", "row_count": 0, "progress_rate": 0, "contract_amount": 0, "collection_amount": 0, "collection_entries": [], "change_round": "", "item_investment": 0, "task_plan_department_budget": 0, "task_plan_outsource_budget": 0, "task_plan_outsource_detail": "", "task_plan_joint_operating_cost": 0, "task_plan_entries": [], "exec_budget_labor_by_grade": 0, "exec_budget_outsource": 0, "exec_budget_cost_plan": 0, "exec_budget_entries": [], "actual_input_entries": [], "expected_as_cost": 0, "expected_sga_budget": 0, "project_start_date": "", "project_end_date": "", "completion_status": "", "notes": "", "total_cost": 0, "total_sga": 0, "total_revenue": 0, "actual_labor": 0, "actual_outsource": 0, "latest_year": 0, "latest_month": 0, "project_type": "", "shared_input_owner_code": normalize_text((shared_input_map.get(support_dept_code) or {}).get("owner_code")), "shared_input_cluster_codes": list((shared_input_map.get(support_dept_code) or {}).get("cluster_codes") or []), }, contract_info_map.get(support_dept_code), billing_summary_map.get(support_dept_code), latest_summary_by_title.get(fallback_title_key), latest_round_by_code.get(support_dept_code), representative_by_title.get(fallback_title_key, ""), fallback_title_key, ) ) return result def get_project_status_row_for_code(support_dept_code: str | None) -> dict[str, Any] | None: normalized_code = normalize_text(support_dept_code) if not normalized_code: return None for item in get_project_status_rows(): if normalize_text(item.get("support_dept_code")) == normalized_code: return item return None def slim_project_status_row(item: dict[str, Any]) -> dict[str, Any]: keys = ( "support_dept_code", "support_dept_name", "row_count", "progress_rate", "contract_amount", "collection_amount", "change_round", "item_investment", "task_plan_department_budget", "task_plan_outsource_budget", "task_plan_joint_operating_cost", "exec_budget_labor_by_grade", "exec_budget_outsource", "exec_budget_cost_plan", "expected_as_cost", "expected_sga_budget", "project_start_date", "project_end_date", "completion_status", "total_cost", "total_sga", "total_revenue", "actual_labor", "actual_outsource", "latest_year", "latest_month", "project_type", "shared_input_owner_code", "client_name", "order_method", "joint_contract", "pm_name", "contract_status", "progress_status", "work_category", "review_tag", "review_note", "total_contract_amount", "hanmac_contract_amount", "billing_contract_amount", "billed_amount", "collection_balance_amount", "latest_billing_date", "changed_contract_amount", "changed_contract_date", "changed_project_end_date", "change_contract_representative_code", "change_contract_title_key", ) result = {key: item.get(key) for key in keys if key in item} result["shared_input_cluster_codes"] = list(item.get("shared_input_cluster_codes") or []) result["_detail_loaded"] = False return result def get_project_status_search_rows() -> list[dict[str, Any]]: return [slim_project_status_row(item) for item in get_project_status_rows()] def get_project_comparison_notes_map() -> dict[str, dict[str, str]]: with engine.begin() as conn: rows = conn.execute( text( """ SELECT support_dept_code, item_key, COALESCE(note, '') AS note FROM project_comparison_notes WHERE COALESCE(support_dept_code, '') <> '' """ ) ).mappings().all() result: dict[str, dict[str, str]] = {} for row in rows: code = normalize_text(row.get("support_dept_code")) item_key = normalize_text(row.get("item_key")) if not code or not item_key: continue result.setdefault(code, {})[item_key] = normalize_text(row.get("note")) return result def save_project_comparison_note(support_dept_code: str | None, item_key: str | None, note: str | None) -> None: code = normalize_text(support_dept_code) normalized_item_key = normalize_text(item_key) if not code or not normalized_item_key: return normalized_note = normalize_text(note) with engine.begin() as conn: if normalized_note: conn.execute( text( """ INSERT INTO project_comparison_notes ( support_dept_code, item_key, note, updated_at ) VALUES ( :support_dept_code, :item_key, :note, CURRENT_TIMESTAMP ) ON CONFLICT(support_dept_code, item_key) DO UPDATE SET note = excluded.note, updated_at = CURRENT_TIMESTAMP """ ), { "support_dept_code": code, "item_key": normalized_item_key, "note": normalized_note, }, ) else: conn.execute( text( """ DELETE FROM project_comparison_notes WHERE support_dept_code = :support_dept_code AND item_key = :item_key """ ), { "support_dept_code": code, "item_key": normalized_item_key, }, ) log_save_event( "project_comparison_note_save", "project_comparison_note", f"{code}:{normalized_item_key}", payload={"has_note": bool(normalized_note)}, ) def get_project_analysis_settings_map() -> dict[str, dict[str, object]]: with engine.begin() as conn: rows = conn.execute( text( """ SELECT support_dept_code, COALESCE(detail_note, '') AS detail_note, COALESCE(inactive_related_codes_json, '[]') AS inactive_related_codes_json, COALESCE(labor_joint_exempt, 0) AS labor_joint_exempt FROM project_analysis_settings WHERE COALESCE(support_dept_code, '') <> '' """ ) ).mappings().all() result: dict[str, dict[str, object]] = {} for row in rows: code = normalize_text(row.get("support_dept_code")) if not code: continue try: inactive_codes = json.loads(row.get("inactive_related_codes_json") or "[]") except Exception: inactive_codes = [] result[code] = { "detail_note": normalize_text(row.get("detail_note")), "inactive_related_codes": [ normalize_text(value) for value in (inactive_codes or []) if normalize_text(value) ], "labor_joint_exempt": bool(row.get("labor_joint_exempt")), } return result def save_project_analysis_settings( support_dept_code: str | None, detail_note: str | None = None, inactive_related_codes: list[str] | None = None, labor_joint_exempt: bool | None = None, ) -> None: code = normalize_text(support_dept_code) if not code: return current = get_project_analysis_settings_map().get(code, {}) next_detail_note = normalize_text(detail_note) if detail_note is not None else normalize_text(current.get("detail_note")) current_inactive = current.get("inactive_related_codes", []) next_inactive_related_codes = [ normalize_text(value) for value in (inactive_related_codes if inactive_related_codes is not None else current_inactive) if normalize_text(value) ] next_labor_joint_exempt = bool(labor_joint_exempt) if labor_joint_exempt is not None else bool(current.get("labor_joint_exempt")) with engine.begin() as conn: conn.execute( text( """ INSERT INTO project_analysis_settings ( support_dept_code, detail_note, inactive_related_codes_json, labor_joint_exempt, updated_at ) VALUES ( :support_dept_code, :detail_note, :inactive_related_codes_json, :labor_joint_exempt, CURRENT_TIMESTAMP ) ON CONFLICT(support_dept_code) DO UPDATE SET detail_note = excluded.detail_note, inactive_related_codes_json = excluded.inactive_related_codes_json, labor_joint_exempt = excluded.labor_joint_exempt, updated_at = CURRENT_TIMESTAMP """ ), { "support_dept_code": code, "detail_note": next_detail_note, "inactive_related_codes_json": json.dumps(next_inactive_related_codes, ensure_ascii=False), "labor_joint_exempt": 1 if next_labor_joint_exempt else 0, }, ) log_save_event( "project_analysis_settings_save", "project_analysis_settings", code, payload={ "inactive_related_count": len(next_inactive_related_codes), "labor_joint_exempt": next_labor_joint_exempt, "has_detail_note": bool(next_detail_note), }, ) def get_project_status_for_edit(support_dept_code: str | None) -> dict[str, Any]: contract_info_map = get_project_contract_info_map() billing_summary_map = get_project_billing_summary_map() latest_summary_by_title, latest_round_by_code, representative_by_title, title_by_code = get_project_contract_change_maps() if not support_dept_code: return { "support_dept_code": "", "support_dept_name": "", "progress_rate": "", "contract_amount": "", "collection_amount": "", "collection_entries": [], "change_round": "", "item_investment": "", "task_plan_department_budget": "", "task_plan_outsource_budget": "", "task_plan_outsource_detail": "", "task_plan_joint_operating_cost": "", "task_plan_entries": [], "exec_budget_labor_by_grade": "", "exec_labor_rates": get_shared_exec_labor_rates(), "exec_budget_outsource": "", "exec_budget_cost_plan": "", "exec_budget_entries": [], "actual_input_entries": [], "project_type": "", "expected_as_rate": "", "expected_sga_rate": "", "expected_as_cost": "", "expected_sga_budget": "", "project_start_date": "", "project_end_date": "", "completion_status": "", "notes": "", "client_name": "", "order_method": "", "joint_contract": "", "pm_name": "", "contract_status": "", "progress_status": "", "work_category": "", "review_tag": "", "review_note": "", "total_contract_amount": 0, "hanmac_contract_amount": 0, "billing_contract_amount": 0, "billed_amount": 0, "collection_balance_amount": 0, "latest_billing_date": "", "updated_at": "", } with engine.begin() as conn: entry_maps = load_project_status_entry_maps(conn) row = conn.execute( text( """ SELECT b.support_dept_code, b.support_dept_name, COALESCE(ps.progress_rate, '') AS progress_rate, COALESCE(ps.contract_amount, '') AS contract_amount, COALESCE(ps.collection_amount, '') AS collection_amount, COALESCE(ps.collection_entries_json, '[]') AS collection_entries_json, COALESCE(ps.change_round, '') AS change_round, COALESCE(ps.item_investment, '') AS item_investment, COALESCE(ps.task_plan_department_budget, '') AS task_plan_department_budget, COALESCE(ps.task_plan_outsource_budget, '') AS task_plan_outsource_budget, COALESCE(ps.task_plan_outsource_detail, '') AS task_plan_outsource_detail, COALESCE(ps.task_plan_joint_operating_cost, '') AS task_plan_joint_operating_cost, COALESCE(ps.task_plan_entries_json, '[]') AS task_plan_entries_json, COALESCE(ps.exec_budget_labor_by_grade, '') AS exec_budget_labor_by_grade, COALESCE(ps.exec_labor_rates_json, '{}') AS exec_labor_rates_json, COALESCE(ps.exec_budget_outsource, '') AS exec_budget_outsource, COALESCE(ps.exec_budget_cost_plan, '') AS exec_budget_cost_plan, COALESCE(ps.exec_budget_entries_json, '[]') AS exec_budget_entries_json, COALESCE(ps.actual_input_entries_json, '[]') AS actual_input_entries_json, COALESCE(ps.project_type, '') AS project_type, COALESCE(ps.expected_as_rate, '') AS expected_as_rate, COALESCE(ps.expected_sga_rate, '') AS expected_sga_rate, COALESCE(ps.expected_as_cost, '') AS expected_as_cost, COALESCE(ps.expected_sga_budget, '') AS expected_sga_budget, COALESCE(ps.last_editor_session_id, '') AS last_editor_session_id, COALESCE(ps.last_client_submitted_at, '') AS last_client_submitted_at, COALESCE(ps.project_start_date, '') AS project_start_date, COALESCE(ps.project_end_date, '') AS project_end_date, COALESCE(ps.completion_status, '') AS completion_status, COALESCE(ps.notes, '') AS notes, COALESCE(ps.updated_at, '') AS updated_at FROM ( SELECT DISTINCT support_dept_code, support_dept_name FROM transactions WHERE support_dept_code = :support_dept_code ) AS b LEFT JOIN project_status AS ps ON ps.support_dept_code = b.support_dept_code """ ), {"support_dept_code": support_dept_code}, ).mappings().first() if not row: result = { "support_dept_code": support_dept_code, "support_dept_name": "", "progress_rate": "", "contract_amount": "", "collection_amount": "", "collection_entries": [], "change_round": "", "item_investment": "", "task_plan_department_budget": "", "task_plan_outsource_budget": "", "task_plan_outsource_detail": "", "task_plan_joint_operating_cost": "", "task_plan_entries": [], "exec_budget_labor_by_grade": "", "exec_labor_rates": {}, "exec_budget_outsource": "", "exec_budget_cost_plan": "", "exec_budget_entries": [], "actual_input_entries": [], "project_type": "", "expected_as_rate": "", "expected_sga_rate": "", "expected_as_cost": "", "expected_sga_budget": "", "project_start_date": "", "project_end_date": "", "completion_status": "", "notes": "", "client_name": "", "order_method": "", "joint_contract": "", "pm_name": "", "contract_status": "", "progress_status": "", "work_category": "", "review_tag": "", "review_note": "", "total_contract_amount": 0, "hanmac_contract_amount": 0, "billing_contract_amount": 0, "billed_amount": 0, "collection_balance_amount": 0, "latest_billing_date": "", "updated_at": "", } result["exec_labor_rates"] = get_shared_exec_labor_rates() title_key = title_by_code.get(normalize_text(support_dept_code)) or normalize_project_title_for_linking(result.get("support_dept_name")) result = merge_project_external_fields( result, contract_info_map.get(normalize_text(support_dept_code)), billing_summary_map.get(normalize_text(support_dept_code)), latest_summary_by_title.get(title_key), latest_round_by_code.get(normalize_text(support_dept_code)), representative_by_title.get(title_key, ""), title_key, ) return result result = dict(row) support_dept_code = normalize_text(result.get("support_dept_code")) entry_set = ensure_project_entry_set(entry_maps.get(support_dept_code)) if support_dept_code in entry_maps else extract_project_status_entry_sets(result) result["collection_entries"] = entry_set["collection_entries"] result["task_plan_entries"] = entry_set["task_plan_entries"] result["exec_budget_entries"] = entry_set["exec_budget_entries"] result["actual_input_entries"] = entry_set["actual_input_entries"] result.pop("collection_entries_json", None) result.pop("task_plan_entries_json", None) result.pop("exec_budget_entries_json", None) result.pop("actual_input_entries_json", None) shared_labor_rates = get_shared_exec_labor_rates() try: project_labor_rates = json.loads(normalize_text(result.pop("exec_labor_rates_json", "{}")) or "{}") except json.JSONDecodeError: project_labor_rates = {} # 기준인건비는 전 프로젝트 공통값을 우선 사용한다. if isinstance(shared_labor_rates, dict) and shared_labor_rates: result["exec_labor_rates"] = shared_labor_rates else: result["exec_labor_rates"] = project_labor_rates if isinstance(project_labor_rates, dict) else {} result = merge_project_external_fields( result, contract_info_map.get(normalize_text(result.get("support_dept_code"))), billing_summary_map.get(normalize_text(result.get("support_dept_code"))), latest_summary_by_title.get(title_by_code.get(normalize_text(result.get("support_dept_code"))) or normalize_project_title_for_linking(result.get("support_dept_name"))), latest_round_by_code.get(normalize_text(result.get("support_dept_code"))), representative_by_title.get(title_by_code.get(normalize_text(result.get("support_dept_code"))) or normalize_project_title_for_linking(result.get("support_dept_name")), ""), title_by_code.get(normalize_text(result.get("support_dept_code"))) or normalize_project_title_for_linking(result.get("support_dept_name")), ) shared_meta = _get_shared_cluster_input_map().get(support_dept_code, {}) result["shared_input_owner_code"] = normalize_text(shared_meta.get("owner_code")) result["shared_input_cluster_codes"] = list(shared_meta.get("cluster_codes") or []) result["expected_as_rate"] = round_percentage_rate(result.get("expected_as_rate")) if normalize_text(result.get("expected_as_rate")) else "" result["expected_sga_rate"] = round_percentage_rate(result.get("expected_sga_rate")) if normalize_text(result.get("expected_sga_rate")) else "" return result def get_project_page_state(session_id: str | None = None) -> dict[str, Any]: normalized_session_id = normalize_text(session_id) with engine.begin() as conn: row = conn.execute( text( """ SELECT COALESCE(selected_code, '') AS selected_code, COALESCE(selected_year, '') AS selected_year, COALESCE(analysis_open, 0) AS analysis_open, COALESCE(uncontracted_year_start, '') AS uncontracted_year_start, COALESCE(uncontracted_year_end, '') AS uncontracted_year_end, COALESCE(related_project_selections_json, '{}') AS related_project_selections_json FROM project_page_state WHERE page_key = 'projects' AND session_id = :session_id """ ), {"session_id": normalized_session_id}, ).mappings().first() if not row and normalized_session_id: with engine.begin() as conn: row = conn.execute( text( """ SELECT COALESCE(selected_code, '') AS selected_code, COALESCE(selected_year, '') AS selected_year, COALESCE(analysis_open, 0) AS analysis_open, COALESCE(uncontracted_year_start, '') AS uncontracted_year_start, COALESCE(uncontracted_year_end, '') AS uncontracted_year_end, COALESCE(related_project_selections_json, '{}') AS related_project_selections_json FROM project_page_state WHERE page_key = 'projects' AND session_id = '' """ ) ).mappings().first() if not row: return { "selected_code": "", "selected_year": "", "analysis_open": False, "uncontracted_year_start": "", "uncontracted_year_end": "", "related_project_selections": {}, } try: related_project_selections_raw = json.loads(normalize_text(row["related_project_selections_json"]) or "{}") except json.JSONDecodeError: related_project_selections_raw = {} related_project_selections = {} if isinstance(related_project_selections_raw, dict): related_project_selections = { normalize_text(key): [ normalize_text(value) for value in values if normalize_text(value) ] for key, values in related_project_selections_raw.items() if normalize_text(key) and isinstance(values, list) } return { "selected_code": normalize_text(row["selected_code"]), "selected_year": normalize_text(row["selected_year"]), "analysis_open": bool(row["analysis_open"]), "uncontracted_year_start": normalize_text(row["uncontracted_year_start"]), "uncontracted_year_end": normalize_text(row["uncontracted_year_end"]), "related_project_selections": related_project_selections, } def save_project_page_state(payload: dict[str, Any]) -> None: session_id = normalize_text(payload.get("session_id")) selected_code = normalize_text(payload.get("selected_code")) selected_year = normalize_text(payload.get("selected_year")) analysis_open = 1 if payload.get("analysis_open") else 0 uncontracted_year_start = normalize_text(payload.get("uncontracted_year_start")) uncontracted_year_end = normalize_text(payload.get("uncontracted_year_end")) raw_related = payload.get("related_project_selections") or {} related_project_selections = {} if isinstance(raw_related, dict): related_project_selections = { normalize_text(key): [ normalize_text(value) for value in values if normalize_text(value) ] for key, values in raw_related.items() if normalize_text(key) and isinstance(values, list) } with engine.begin() as conn: conn.execute( text( """ INSERT INTO project_page_state ( page_key, session_id, selected_code, selected_year, analysis_open, uncontracted_year_start, uncontracted_year_end, related_project_selections_json, updated_at ) VALUES ( 'projects', :session_id, :selected_code, :selected_year, :analysis_open, :uncontracted_year_start, :uncontracted_year_end, :related_project_selections_json, CURRENT_TIMESTAMP ) ON CONFLICT(page_key, session_id) DO UPDATE SET selected_code = excluded.selected_code, selected_year = excluded.selected_year, analysis_open = excluded.analysis_open, uncontracted_year_start = excluded.uncontracted_year_start, uncontracted_year_end = excluded.uncontracted_year_end, related_project_selections_json = excluded.related_project_selections_json, updated_at = CURRENT_TIMESTAMP """ ), { "session_id": session_id, "selected_code": selected_code, "selected_year": selected_year, "analysis_open": analysis_open, "uncontracted_year_start": uncontracted_year_start, "uncontracted_year_end": uncontracted_year_end, "related_project_selections_json": json.dumps(related_project_selections, ensure_ascii=False), }, ) log_save_event( "project_page_state_save", "project_page_state", session_id, session_id=session_id, payload={ "selected_code": selected_code, "selected_year": selected_year, "analysis_open": bool(analysis_open), "related_selection_count": len(related_project_selections), }, ) def get_project_related_links_map() -> dict[str, list[str]]: with engine.begin() as conn: rows = conn.execute( text( """ SELECT base_support_dept_code, related_support_dept_code FROM project_related_links ORDER BY base_support_dept_code, related_support_dept_code """ ) ).mappings().all() related_map: dict[str, set[str]] = {} for row in rows: base_code = normalize_text(row["base_support_dept_code"]) related_code = normalize_text(row["related_support_dept_code"]) if not base_code or not related_code: continue related_map.setdefault(base_code, set()).add(related_code) related_map.setdefault(related_code, set()).add(base_code) return { base_code: sorted(related_codes) for base_code, related_codes in sorted(related_map.items()) } def get_project_quick_links(session_id: str | None = None) -> list[str]: with engine.begin() as conn: rows = conn.execute( text( """ SELECT support_dept_code FROM project_quick_links WHERE page_key = 'projects' ORDER BY sort_order, updated_at DESC, support_dept_code """ ) ).mappings().all() return [normalize_text(row["support_dept_code"]) for row in rows if normalize_text(row["support_dept_code"])] def save_project_quick_links(session_id: str | None, codes: list[str]) -> None: normalized_codes: list[str] = [] for code in codes: normalized_code = normalize_text(code) if normalized_code and normalized_code not in normalized_codes: normalized_codes.append(normalized_code) with engine.begin() as conn: conn.execute( text( """ DELETE FROM project_quick_links WHERE page_key = 'projects' """ ) ) for sort_order, support_dept_code in enumerate(normalized_codes): conn.execute( text( """ INSERT INTO project_quick_links ( page_key, support_dept_code, sort_order, updated_at ) VALUES ( 'projects', :support_dept_code, :sort_order, CURRENT_TIMESTAMP ) """ ), { "support_dept_code": support_dept_code, "sort_order": sort_order, }, ) log_save_event( "project_quick_links_save", "project_quick_links", "projects", session_id=session_id, payload={"codes": normalized_codes}, ) def get_process_cost_quick_links() -> list[str]: with engine.begin() as conn: rows = conn.execute( text( """ SELECT support_dept_code FROM project_quick_links WHERE page_key = 'process_cost' ORDER BY sort_order, updated_at DESC, support_dept_code """ ) ).mappings().all() return [normalize_text(row["support_dept_code"]) for row in rows if normalize_text(row["support_dept_code"])] def save_process_cost_quick_links(codes: list[str]) -> None: normalized_codes: list[str] = [] for code in codes: normalized_code = normalize_text(code) if normalized_code and normalized_code not in normalized_codes: normalized_codes.append(normalized_code) with engine.begin() as conn: conn.execute( text( """ DELETE FROM project_quick_links WHERE page_key = 'process_cost' """ ) ) for sort_order, support_dept_code in enumerate(normalized_codes): conn.execute( text( """ INSERT INTO project_quick_links ( page_key, support_dept_code, sort_order, updated_at ) VALUES ( 'process_cost', :support_dept_code, :sort_order, CURRENT_TIMESTAMP ) """ ), { "support_dept_code": support_dept_code, "sort_order": sort_order, }, ) log_save_event( "process_cost_quick_links_save", "project_quick_links", "process_cost", payload={"codes": normalized_codes}, ) def get_project_uncontracted_classification_map() -> dict[str, str]: with engine.begin() as conn: rows = conn.execute( text( """ SELECT support_dept_code, category FROM project_uncontracted_classification WHERE COALESCE(support_dept_code, '') <> '' """ ) ).fetchall() return { normalize_text(row[0]): normalize_text(row[1]) for row in rows if normalize_text(row[0]) } def save_project_uncontracted_classification(support_dept_code: Any, category: Any) -> None: normalized_code = normalize_text(support_dept_code) normalized_category = normalize_text(category) allowed_categories = {"general", "precontract", "corporate_rnd", "external_research"} if not normalized_code: raise ValueError("프로젝트 코드가 필요합니다.") if normalized_category not in allowed_categories: raise ValueError("허용되지 않는 미계약 분류입니다.") with engine.begin() as conn: conn.execute( text( """ INSERT INTO project_uncontracted_classification ( support_dept_code, category, updated_at ) VALUES ( :support_dept_code, :category, CURRENT_TIMESTAMP ) ON CONFLICT(support_dept_code) DO UPDATE SET category = excluded.category, updated_at = CURRENT_TIMESTAMP """ ), { "support_dept_code": normalized_code, "category": normalized_category, }, ) log_save_event( "project_uncontracted_classification_save", "project_uncontracted_classification", normalized_code, payload={"category": normalized_category}, ) def save_project_related_links(base_support_dept_code: str, related_codes: list[Any]) -> None: base_code = normalize_text(base_support_dept_code) if not base_code: return requested_codes = sorted( { normalize_text(code) for code in related_codes if normalize_text(code) and normalize_text(code) != base_code } ) with engine.begin() as conn: manual_rows = conn.execute( text( """ SELECT base_support_dept_code, related_support_dept_code FROM project_related_links WHERE COALESCE(link_source, 'manual') = 'manual' """ ) ).fetchall() manual_adjacency: dict[str, set[str]] = {} for row_base, row_related in manual_rows: normalized_base = normalize_text(row_base) normalized_related = normalize_text(row_related) if not normalized_base or not normalized_related: continue manual_adjacency.setdefault(normalized_base, set()).add(normalized_related) manual_adjacency.setdefault(normalized_related, set()).add(normalized_base) previous_cluster: set[str] = set() stack = [base_code] while stack: current = stack.pop() if current in previous_cluster: continue previous_cluster.add(current) stack.extend(sorted(manual_adjacency.get(current, set()) - previous_cluster)) next_cluster = {base_code, *requested_codes} impacted_codes = previous_cluster | next_cluster auto_pairs = { (normalize_text(row[0]), normalize_text(row[1])) for row in conn.execute( text( """ SELECT base_support_dept_code, related_support_dept_code FROM project_related_links WHERE COALESCE(link_source, 'manual') LIKE 'auto%' """ ) ).fetchall() if normalize_text(row[0]) and normalize_text(row[1]) } conn.execute( text( """ DELETE FROM project_related_links WHERE COALESCE(link_source, 'manual') = 'manual' AND ( base_support_dept_code IN :impacted_codes OR related_support_dept_code IN :impacted_codes ) """ ).bindparams(bindparam("impacted_codes", expanding=True)), {"impacted_codes": sorted(impacted_codes) or [""]}, ) for cluster_base in sorted(next_cluster): for related_code in sorted(next_cluster): if cluster_base == related_code or (cluster_base, related_code) in auto_pairs: continue conn.execute( text( """ INSERT INTO project_related_links ( base_support_dept_code, related_support_dept_code, link_source, updated_at ) VALUES ( :base_support_dept_code, :related_support_dept_code, 'manual', CURRENT_TIMESTAMP ) ON CONFLICT(base_support_dept_code, related_support_dept_code) DO UPDATE SET link_source = excluded.link_source, updated_at = CURRENT_TIMESTAMP """ ), { "base_support_dept_code": cluster_base, "related_support_dept_code": related_code, }, ) log_save_event( "project_related_links_save", "project_related_links", base_code, payload={"related_codes": requested_codes, "cluster_codes": sorted(next_cluster)}, ) def get_project_year_options() -> list[int]: return get_available_years() def get_latest_completed_year(reference_date: date | None = None) -> int | None: available_years = get_available_years() if not available_years: return None today = reference_date or date.today() cutoff = date(today.year, 1, 10) while cutoff.weekday() >= 5: cutoff += timedelta(days=1) completed_boundary_year = today.year - 1 if today >= cutoff else today.year - 2 completed_years = [year for year in available_years if int(year) <= completed_boundary_year] return max(completed_years or available_years) def resolve_selected_year(selected_year: int | None) -> int | None: if selected_year is not None: return selected_year return get_latest_completed_year() def parse_optional_year(value: Any) -> int | None: text = normalize_text(value) if not text: return None return int(text) if text.isdigit() else None def get_recent_10_start_year() -> int | None: available_years = get_available_years() if not available_years: return None return max(available_years) - 9 def get_project_dashboard_summary(selected_year: int | None) -> dict[str, Any]: selected_year = resolve_selected_year(selected_year) project_year_clause = "" collection_year_clause = "" params: dict[str, Any] = {} if selected_year: project_year_clause = "AND p.year = :selected_year" collection_year_clause = "AND year = :selected_year" params["selected_year"] = selected_year else: recent_10_start_year = get_recent_10_start_year() if recent_10_start_year is not None: project_year_clause = "AND p.year >= :recent_10_start_year" collection_year_clause = "AND year >= :recent_10_start_year" params["recent_10_start_year"] = recent_10_start_year with engine.begin() as conn: project_row = conn.execute( text( f""" SELECT COUNT(*) AS related_projects, SUM(COALESCE(p.expense_amount, 0)) AS expense_amount, COUNT(CASE WHEN COALESCE(ps.completion_status, '') IN ('종료', '완료', 'Y', 'YES') THEN 1 END) AS completed_projects FROM ( SELECT year, support_dept_code, support_dept_name, SUM(CASE WHEN account_code LIKE '5%' OR account_code LIKE '6%' THEN amount ELSE 0 END) AS expense_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 ('공통', '경영지원부', '기술개발센터', '임원실', '기술개발부', '총괄기획실') GROUP BY year, support_dept_code, support_dept_name ) AS p LEFT JOIN project_status AS ps ON ps.support_dept_code = p.support_dept_code WHERE 1=1 {project_year_clause} """ ), params, ).mappings().first() collection_row = conn.execute( text( f""" SELECT COUNT(*) AS collection_transaction_count, COUNT(DISTINCT support_dept_code) AS collection_project_count, SUM(COALESCE(credit_supply, 0)) AS collection_amount FROM transactions WHERE COALESCE(credit_supply, 0) <> 0 AND ({REVENUE_SQL}) AND COALESCE(support_dept_code, '') <> '' AND support_dept_code NOT IN ('ZZZZZZ') AND COALESCE(support_dept_name, '') <> '' {collection_year_clause} """ ), params, ).mappings().first() input_row = conn.execute( text( """ SELECT COUNT(CASE WHEN COALESCE(collection_amount, 0) <> 0 THEN 1 END) AS collection_input_projects, COUNT(CASE WHEN COALESCE(completion_status, '') <> '' THEN 1 END) AS completion_input_projects FROM project_status """ ) ).mappings().first() return { **(dict(project_row) if project_row else {}), **(dict(collection_row) if collection_row else {}), **(dict(input_row) if input_row else {}), } def get_uncontracted_project_dashboard(selected_year: int | None) -> dict[str, Any]: selected_year = resolve_selected_year(selected_year) transaction_year_clause = "" params: dict[str, Any] = {} if selected_year: transaction_year_clause = "AND t.year = :selected_year" params["selected_year"] = selected_year else: recent_10_start_year = get_recent_10_start_year() if recent_10_start_year is not None: transaction_year_clause = "AND t.year >= :recent_10_start_year" params["recent_10_start_year"] = recent_10_start_year with engine.begin() as conn: summary = conn.execute( text( f""" WITH project_universe AS ( SELECT DISTINCT support_dept_code, support_dept_name 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 ('공통', '경영지원부', '기술개발센터', '임원실', '기술개발부', '총괄기획실') UNION SELECT support_dept_code, support_dept_name FROM project_contract_info WHERE COALESCE(support_dept_code, '') <> '' UNION SELECT support_dept_code, support_dept_name FROM project_billing_entries WHERE COALESCE(support_dept_code, '') <> '' ), contract_flags AS ( SELECT support_dept_code, COALESCE(hanmac_contract_amount, 0) AS hanmac_contract_amount, COALESCE(review_tag, '') AS review_tag FROM project_contract_info ), project_amounts AS ( SELECT t.support_dept_code, SUM(CASE WHEN t.account_code LIKE '5%' OR t.account_code LIKE '6%' THEN t.amount ELSE 0 END) AS expense_amount, SUM(CASE WHEN {REVENUE_SQL.replace('account_code', 't.account_code')} THEN t.amount ELSE 0 END) AS revenue_amount FROM transactions AS t WHERE COALESCE(t.support_dept_code, '') <> '' AND t.support_dept_code NOT IN ('ZZZZZZ') {transaction_year_clause} GROUP BY t.support_dept_code ) SELECT SUM(CASE WHEN COALESCE(contract_flags.hanmac_contract_amount, 0) <= 0 THEN 1 ELSE 0 END) AS uncontracted_projects, SUM(CASE WHEN COALESCE(contract_flags.hanmac_contract_amount, 0) <= 0 AND COALESCE(project_amounts.expense_amount, 0) > 0 THEN 1 ELSE 0 END) AS cost_incurred_projects, SUM(CASE WHEN COALESCE(contract_flags.hanmac_contract_amount, 0) <= 0 THEN COALESCE(project_amounts.expense_amount, 0) ELSE 0 END) AS expense_amount, SUM(CASE WHEN COALESCE(contract_flags.hanmac_contract_amount, 0) <= 0 THEN COALESCE(project_amounts.revenue_amount, 0) ELSE 0 END) AS revenue_amount, SUM(CASE WHEN COALESCE(contract_flags.review_tag, '') <> '' THEN 1 ELSE 0 END) AS review_needed_projects FROM project_universe LEFT JOIN contract_flags ON contract_flags.support_dept_code = project_universe.support_dept_code LEFT JOIN project_amounts ON project_amounts.support_dept_code = project_universe.support_dept_code """ ), params, ).mappings().first() yearly_rows = conn.execute( text( f""" WITH yearly_costs AS ( SELECT t.year, t.support_dept_code, SUM(CASE WHEN t.account_code LIKE '5%' OR t.account_code LIKE '6%' THEN t.amount ELSE 0 END) AS expense_amount, SUM(CASE WHEN {REVENUE_SQL.replace('account_code', 't.account_code')} THEN t.amount ELSE 0 END) AS revenue_amount FROM transactions AS t WHERE COALESCE(t.support_dept_code, '') <> '' AND t.support_dept_code NOT IN ('ZZZZZZ') AND t.year IS NOT NULL {transaction_year_clause} GROUP BY t.year, t.support_dept_code ) SELECT yearly_costs.year, COUNT(DISTINCT CASE WHEN COALESCE(c.hanmac_contract_amount, 0) <= 0 THEN yearly_costs.support_dept_code END) AS uncontracted_projects, COUNT(DISTINCT CASE WHEN COALESCE(c.hanmac_contract_amount, 0) <= 0 AND COALESCE(yearly_costs.expense_amount, 0) > 0 THEN yearly_costs.support_dept_code END) AS cost_incurred_projects, SUM(CASE WHEN COALESCE(c.hanmac_contract_amount, 0) <= 0 THEN COALESCE(yearly_costs.expense_amount, 0) ELSE 0 END) AS expense_amount, SUM(CASE WHEN COALESCE(c.hanmac_contract_amount, 0) <= 0 THEN COALESCE(yearly_costs.revenue_amount, 0) ELSE 0 END) AS revenue_amount FROM yearly_costs LEFT JOIN project_contract_info AS c ON c.support_dept_code = yearly_costs.support_dept_code GROUP BY yearly_costs.year ORDER BY yearly_costs.year """ ), params, ).mappings().all() monthly_focus_year = selected_year if monthly_focus_year is None: monthly_focus_year = conn.execute( text( """ WITH monthly_candidates AS ( SELECT MAX(t.year) AS latest_year FROM transactions AS t LEFT JOIN project_contract_info AS c ON c.support_dept_code = t.support_dept_code WHERE COALESCE(t.support_dept_code, '') <> '' AND t.support_dept_code NOT IN ('ZZZZZZ') AND COALESCE(c.hanmac_contract_amount, 0) <= 0 AND (t.account_code LIKE '5%' OR t.account_code LIKE '6%') ) SELECT latest_year FROM monthly_candidates """ ) ).scalar() monthly_rows: list[dict[str, Any]] = [] if monthly_focus_year: monthly_rows = conn.execute( text( f""" WITH monthly_costs AS ( SELECT t.month, t.support_dept_code, SUM(CASE WHEN t.account_code LIKE '5%' OR t.account_code LIKE '6%' THEN t.amount ELSE 0 END) AS expense_amount, SUM(CASE WHEN {REVENUE_SQL.replace('account_code', 't.account_code')} THEN t.amount ELSE 0 END) AS revenue_amount FROM transactions AS t WHERE COALESCE(t.support_dept_code, '') <> '' AND t.support_dept_code NOT IN ('ZZZZZZ') AND t.year = :monthly_focus_year AND t.month IS NOT NULL GROUP BY t.month, t.support_dept_code ) SELECT monthly_costs.month, COUNT(DISTINCT CASE WHEN COALESCE(c.hanmac_contract_amount, 0) <= 0 THEN monthly_costs.support_dept_code END) AS uncontracted_projects, COUNT(DISTINCT CASE WHEN COALESCE(c.hanmac_contract_amount, 0) <= 0 AND COALESCE(monthly_costs.expense_amount, 0) > 0 THEN monthly_costs.support_dept_code END) AS cost_incurred_projects, SUM(CASE WHEN COALESCE(c.hanmac_contract_amount, 0) <= 0 THEN COALESCE(monthly_costs.expense_amount, 0) ELSE 0 END) AS expense_amount, SUM(CASE WHEN COALESCE(c.hanmac_contract_amount, 0) <= 0 THEN COALESCE(monthly_costs.revenue_amount, 0) ELSE 0 END) AS revenue_amount FROM monthly_costs LEFT JOIN project_contract_info AS c ON c.support_dept_code = monthly_costs.support_dept_code GROUP BY monthly_costs.month ORDER BY monthly_costs.month """ ), {"monthly_focus_year": monthly_focus_year}, ).mappings().all() top_rows = conn.execute( text( f""" WITH project_costs AS ( SELECT t.support_dept_code, MAX(t.support_dept_name) AS support_dept_name, SUM(CASE WHEN t.account_code LIKE '5%' OR t.account_code LIKE '6%' THEN t.amount ELSE 0 END) AS expense_amount, SUM(CASE WHEN {REVENUE_SQL.replace('account_code', 't.account_code')} THEN t.amount ELSE 0 END) AS revenue_amount, MAX(t.year) AS latest_year, MAX(t.month) AS latest_month FROM transactions AS t WHERE COALESCE(t.support_dept_code, '') <> '' AND t.support_dept_code NOT IN ('ZZZZZZ') {transaction_year_clause} GROUP BY t.support_dept_code ) SELECT project_costs.support_dept_code, project_costs.support_dept_name, project_costs.expense_amount, project_costs.revenue_amount, project_costs.latest_year, project_costs.latest_month, COALESCE(c.review_tag, '') AS review_tag FROM project_costs LEFT JOIN project_contract_info AS c ON c.support_dept_code = project_costs.support_dept_code WHERE COALESCE(c.hanmac_contract_amount, 0) <= 0 AND COALESCE(project_costs.expense_amount, 0) > 0 ORDER BY project_costs.expense_amount DESC, project_costs.support_dept_code LIMIT 12 """ ), params, ).mappings().all() return { "summary": dict(summary) if summary else {}, "yearly_rows": [dict(row) for row in yearly_rows], "monthly_rows": [dict(row) for row in monthly_rows], "monthly_focus_year": int(monthly_focus_year) if monthly_focus_year else None, "top_rows": [dict(row) for row in top_rows], } def get_project_revenue_mix(selected_year: int | None = None) -> list[dict[str, Any]]: params: dict[str, Any] = {} if selected_year: with engine.begin() as conn: rows = conn.execute( text( f""" SELECT year, month, SUM(CASE WHEN account_code LIKE '40110101%' AND memo1 LIKE '%설계%' THEN amount ELSE 0 END) AS design_revenue, SUM(CASE WHEN account_code LIKE '40110101%' AND (memo1 NOT LIKE '%설계%' OR COALESCE(memo1, '') = '') THEN amount ELSE 0 END) AS design_other_revenue, SUM(CASE WHEN account_code LIKE '40110102%' THEN amount ELSE 0 END) AS supervision_revenue, SUM(CASE WHEN account_code LIKE '40110103%' THEN amount ELSE 0 END) AS inspection_revenue FROM transactions WHERE year = :selected_year AND month IS NOT NULL GROUP BY year, month ORDER BY year, month """ ), {"selected_year": selected_year}, ).mappings().all() result = [dict(row) for row in rows] for item in result: item["label"] = f"{int(item['month'])}월" if item.get("month") is not None else str(item.get("year", "")) return result with engine.begin() as conn: rows = conn.execute( text( f""" SELECT year, SUM(CASE WHEN account_code LIKE '40110101%' AND memo1 LIKE '%설계%' THEN amount ELSE 0 END) AS design_revenue, SUM(CASE WHEN account_code LIKE '40110101%' AND (memo1 NOT LIKE '%설계%' OR COALESCE(memo1, '') = '') THEN amount ELSE 0 END) AS design_other_revenue, SUM(CASE WHEN account_code LIKE '40110102%' THEN amount ELSE 0 END) AS supervision_revenue, SUM(CASE WHEN account_code LIKE '40110103%' THEN amount ELSE 0 END) AS inspection_revenue FROM transactions WHERE year IS NOT NULL GROUP BY year ORDER BY year """ ) ).mappings().all() result = [dict(row) for row in rows] for item in result: item["label"] = str(item.get("year", "")) return result[-10:] def get_project_revenue_mix_monthly() -> list[dict[str, Any]]: recent_10_start_year = get_recent_10_start_year() params: dict[str, Any] = {} year_clause = "" 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 with engine.begin() as conn: rows = conn.execute( text( f""" SELECT year, month, SUM(CASE WHEN account_code LIKE '40110101%' AND memo1 LIKE '%설계%' THEN amount ELSE 0 END) AS design_revenue, SUM(CASE WHEN account_code LIKE '40110101%' AND (memo1 NOT LIKE '%설계%' OR COALESCE(memo1, '') = '') THEN amount ELSE 0 END) AS design_other_revenue, SUM(CASE WHEN account_code LIKE '40110102%' THEN amount ELSE 0 END) AS supervision_revenue, SUM(CASE WHEN account_code LIKE '40110103%' THEN amount ELSE 0 END) AS inspection_revenue FROM transactions WHERE month IS NOT NULL {year_clause} GROUP BY year, month ORDER BY year, month """ ), params, ).mappings().all() result = [dict(row) for row in rows] for item in result: item["label"] = f"{int(item['month'])}월" if item.get("month") is not None else str(item.get("year", "")) return result def get_project_cost_by_year(selected_year: int | None = None, *, all_years: bool = False) -> list[dict[str, Any]]: year_clause = "" params: dict[str, Any] = {} if selected_year: year_clause = "AND year = :selected_year" params["selected_year"] = selected_year elif not all_years: recent_10_start_year = get_recent_10_start_year() 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 contract_info_map = get_project_contract_info_map() billing_summary_map = get_project_billing_summary_map() with engine.begin() as conn: rows = conn.execute( text( f""" SELECT year, support_dept_code, support_dept_name, SUM(CASE WHEN account_code LIKE '5%' OR account_code LIKE '6%' THEN amount ELSE 0 END) AS expense_amount, SUM(CASE WHEN {REVENUE_SQL} THEN amount ELSE 0 END) AS revenue_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 ('공통', '경영지원부', '기술개발센터', '임원실', '기술개발부', '총괄기획실') {year_clause} GROUP BY year, support_dept_code, support_dept_name ORDER BY year DESC, expense_amount DESC, support_dept_code """ ), params, ).mappings().all() result = [dict(row) for row in rows] existing_codes = {normalize_text(row["support_dept_code"]) for row in result} candidate_codes = sorted((set(contract_info_map) | set(billing_summary_map)) - existing_codes) recent_10_start_year = get_recent_10_start_year() for support_dept_code in candidate_codes: contract_info = contract_info_map.get(support_dept_code, {}) billing_summary = billing_summary_map.get(support_dept_code, {}) fallback_date = ( normalize_text(contract_info.get("project_start_date")) or normalize_text(contract_info.get("contract_date")) or normalize_text(billing_summary.get("latest_billing_date")) ) fallback_year = 0 if re.match(r"^\d{4}-\d{2}-\d{2}$", fallback_date): fallback_year = int(fallback_date[:4]) if selected_year and fallback_year and fallback_year != selected_year: continue if not selected_year and recent_10_start_year is not None and fallback_year and fallback_year < recent_10_start_year: continue result.append( { "year": fallback_year, "support_dept_code": support_dept_code, "support_dept_name": normalize_text(contract_info.get("support_dept_name")) or normalize_text(billing_summary.get("support_dept_name")), "expense_amount": 0, "revenue_amount": normalize_amount(billing_summary.get("collected_amount")), } ) result.sort(key=lambda item: (-(int(item.get("year") or 0)), -normalize_amount(item.get("expense_amount")), normalize_text(item.get("support_dept_code")))) return result def get_project_account_breakdowns( selected_year: int | None, codes: list[str] | None = None, ) -> dict[str, dict[str, list[dict[str, Any]]]]: cache_key = ( selected_year or 0, tuple(sorted(normalize_text(code) for code in (codes or []) if normalize_text(code))), ) cached = _get_deepcopy_ttl_cache_entry( _PROJECT_ACCOUNT_BREAKDOWN_CACHE, _PROJECT_ACCOUNT_BREAKDOWN_CACHE_LOCK, cache_key, PROJECT_ACCOUNT_BREAKDOWN_CACHE_TTL_SECONDS, ) if cached is not None: return cached 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 else: recent_10_start_year = get_recent_10_start_year() 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(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: code = row["support_dept_code"] kind = row["breakdown_kind"] if kind == "other": continue _, _, label = normalize_account_display(row["account_code"], row["account_name"]) result.setdefault(code, {"revenue": {}, "cost": {}, "sga": {}, "cost_detail": []}) result[code][kind][label] = result[code][kind].get(label, 0.0) + float(row["total_amount"] or 0) for row in cost_detail_rows: code = normalize_text(row["support_dept_code"]) if not code: continue account_code, account_name, label = normalize_account_display(row["account_code"], row["account_name"]) result.setdefault(code, {"revenue": {}, "cost": {}, "sga": {}, "cost_detail": []}) result[code]["cost_detail"].append( { "posting_date": build_transaction_posting_display(row["voucher_number"], row["posting_date"]), "partner_name": normalize_text(row["partner_name"]), "partner_code": normalize_text(row["partner_code"]), "account_code": account_code, "account_name": account_name, "label": label, "amount": float(row["amount"] or 0), } ) normalized_result: dict[str, dict[str, list[dict[str, Any]]]] = {} for code, buckets in result.items(): normalized_result[code] = {} for kind, entries in buckets.items(): if kind == "cost_detail": normalized_result[code][kind] = list(entries) continue normalized_result[code][kind] = [ { "label": label, "amount": amount, "account_code": label.split(" · ", 1)[0] if " · " in label else "", "account_name": label.split(" · ", 1)[1] if " · " in label else label, } for label, amount in sorted(entries.items(), key=lambda item: item[1], reverse=True) ] return _set_deepcopy_ttl_cache_entry( _PROJECT_ACCOUNT_BREAKDOWN_CACHE, _PROJECT_ACCOUNT_BREAKDOWN_CACHE_LOCK, cache_key, normalized_result, ) def parse_support_dept_codes_param(raw_codes: Any, fallback_code: Any = "") -> list[str]: values: list[str] = [] for chunk in re.split(r"[\s,]+", normalize_text(raw_codes)): normalized = normalize_text(chunk) if normalized and normalized not in values: values.append(normalized) normalized_fallback = normalize_text(fallback_code) if normalized_fallback and normalized_fallback not in values: values.insert(0, normalized_fallback) return values def build_in_clause(prefix: str, values: list[str]) -> tuple[str, dict[str, Any]]: params: dict[str, Any] = {} placeholders: list[str] = [] for index, value in enumerate(values): key = f"{prefix}_{index}" placeholders.append(f":{key}") params[key] = value return ", ".join(placeholders), params def fetch_project_expense_transaction_rows( codes: list[str], expense_group: str = "", account_label: str = "", ) -> list[dict[str, Any]]: normalized_codes = [normalize_text(code) for code in codes if normalize_text(code)] if not normalized_codes: return [] in_clause, code_params = build_in_clause("project_code", normalized_codes) query = text( f""" SELECT 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(cost_dept_name, '') AS cost_dept_name, COALESCE(support_dept_code, '') AS support_dept_code, COALESCE(support_dept_name, '') AS support_dept_name, COALESCE(account_code, '') AS account_code, COALESCE(account_name, '') AS account_name, amount FROM transactions WHERE support_dept_code IN ({in_clause}) AND accounting_category = '원가' ORDER BY posting_date DESC, voucher_number DESC, partner_name, cost_dept_name, account_code """ ) with engine.begin() as conn: rows = conn.execute(query, code_params).mappings().all() normalized_group = normalize_text(expense_group).lower() normalized_account_label = normalize_text(account_label) result: list[dict[str, Any]] = [] for row in rows: normalized_code, normalized_name, normalized_label = normalize_account_display( row["account_code"], row["account_name"], ) is_design_outsource = "기술협력비" in normalized_label or "기술협력비" in normalized_name if normalized_group == "outsource" and not is_design_outsource: continue if normalized_group == "overhead" and is_design_outsource: continue if normalized_account_label and normalized_label != normalized_account_label: continue result.append( { "posting_date": build_transaction_posting_display(row["voucher_number"], row["posting_date"]), "voucher_number": normalize_text(row["voucher_number"]), "partner_name": normalize_text(row["partner_name"]), "partner_code": normalize_text(row["partner_code"]), "cost_dept_name": normalize_text(row["cost_dept_name"]), "support_dept_code": normalize_text(row["support_dept_code"]), "support_dept_name": normalize_text(row["support_dept_name"]), "account_code": normalized_code, "account_name": normalized_name, "amount": int(round(float(row["amount"] or 0))), } ) return result def get_project_expense_date_range(codes: list[str]) -> tuple[str, str]: normalized_codes = [normalize_text(code) for code in (codes or []) if normalize_text(code)] if not normalized_codes: return "", "" in_clause, code_params = build_in_clause("project_code", normalized_codes) query = text( f""" SELECT COALESCE(voucher_number, '') AS voucher_number, COALESCE(posting_date, '') AS posting_date FROM transactions WHERE support_dept_code IN ({in_clause}) AND accounting_category = '원가' """ ) dates: list[str] = [] with engine.begin() as conn: rows = conn.execute(query, code_params).mappings().all() for row in rows: display = build_transaction_posting_display(row["voucher_number"], row["posting_date"]) if re.match(r"^\d{4}-\d{2}-\d{2}$", display): dates.append(display) if not dates: return "", "" return min(dates), max(dates) def get_recent_transactions(limit: int = 50) -> list[dict[str, Any]]: with engine.begin() as conn: rows = conn.execute( text( """ SELECT id, year, month, voucher_number, account_code, account_name, support_dept_code, support_dept_name, cost_dept_code, cost_dept_name, accounting_category, amount, memo1, management_item, source_file, updated_at FROM transactions ORDER BY COALESCE(year, 0) DESC, COALESCE(month, 0) DESC, id DESC LIMIT :limit_count """ ), {"limit_count": limit}, ).mappings().all() return [dict(row) for row in rows] def get_overview_stats(selected_year: int | None = None) -> dict[str, Any]: year_clause = "" params: dict[str, Any] = {} if selected_year: year_clause = "WHERE year = :selected_year" params["selected_year"] = selected_year else: recent_10_start_year = get_recent_10_start_year() if recent_10_start_year is not None: year_clause = "WHERE year >= :recent_10_start_year" params["recent_10_start_year"] = recent_10_start_year with engine.begin() as conn: row = conn.execute( text( f""" SELECT COUNT(*) AS total_rows, COUNT(DISTINCT source_file) AS source_files, COUNT(DISTINCT CASE WHEN COALESCE(support_dept_code, '') <> '' AND support_dept_code NOT IN ('ZZZZZZ') AND COALESCE(support_dept_name, '') <> '' AND support_dept_name NOT IN ( '공통', '경영지원부', '기술개발센터', '임원실', '기술개발부', '총괄기획실' ) THEN support_dept_code || '|' || support_dept_name END) AS business_count, SUM(CASE WHEN accounting_category = '원가' THEN amount ELSE 0 END) AS total_cost, SUM(CASE WHEN accounting_category = '판관비' THEN amount ELSE 0 END) AS total_sga, SUM(CASE WHEN {REVENUE_SQL} THEN amount ELSE 0 END) AS total_revenue FROM transactions {year_clause} """ ), params, ).mappings().first() return dict(row) if row else {} def get_available_years() -> list[int]: with engine.begin() as conn: rows = conn.execute( text( """ SELECT DISTINCT year FROM transactions WHERE year IS NOT NULL ORDER BY year """ ) ).fetchall() return [int(row[0]) for row in rows if row[0] is not None] def _safe_ratio(numerator: Any, denominator: Any) -> float: denom = normalize_amount(denominator) if abs(denom) < 1e-9: return 0.0 return (normalize_amount(numerator) / denom) * 100.0 def _build_process_cost_related_clusters() -> dict[str, list[str]]: related_map = get_project_related_links_map() adjacency: dict[str, set[str]] = {} for base_code, related_codes in related_map.items(): normalized_base = normalize_text(base_code) if not normalized_base: continue adjacency.setdefault(normalized_base, set()) for related_code in related_codes: normalized_related = normalize_text(related_code) if not normalized_related: continue adjacency.setdefault(normalized_base, set()).add(normalized_related) adjacency.setdefault(normalized_related, set()).add(normalized_base) cluster_map: dict[str, list[str]] = {} visited: set[str] = set() for code in sorted(adjacency): if code in visited: continue stack = [code] component: set[str] = set() while stack: current = stack.pop() if current in component: continue component.add(current) visited.add(current) stack.extend(adjacency.get(current, set()) - component) members = sorted(component) for member in members: cluster_map[member] = members return cluster_map def get_process_cost_related_codes(support_dept_code: str | None) -> list[str]: code = normalize_text(support_dept_code) if not code: return [] related_map = get_project_related_links_map() return list(related_map.get(code, [])) def get_process_cost_available_years(source: str) -> list[int]: contract_meta = _get_project_contract_meta() detected_years = { year for code in contract_meta.keys() for year in [_extract_year_from_project_code(code)] if year is not None } max_year = max(detected_years) if detected_years else datetime.now().year min_year = 1994 if max_year < min_year: max_year = min_year return list(range(min_year, max_year + 1)) def _extract_year_from_project_code(value: Any) -> int | None: code = normalize_text(value).upper() if len(code) < 3: return None digits = "".join(ch for ch in code if ch.isdigit()) if len(digits) < 2: return None year_2d = digits[:2] if not year_2d.isdigit(): return None year_value = int(year_2d) if year_value >= 94: return 1900 + year_value return 2000 + year_value def _sort_process_cost_project_options(items: list[dict[str, Any]]) -> list[dict[str, Any]]: def sort_key(item: dict[str, Any]) -> str: return normalize_text(item.get("support_dept_code")).upper() return sorted(items, key=sort_key) def _get_process_cost_project_kind(code: Any) -> tuple[str, str]: normalized = normalize_text(code).upper() prefix = normalized[:1] if prefix == "X": return "X", "사업전" if prefix == "Y": return "Y", "설계" if prefix == "Z": return "Z", "감리" return "", "기타" def _get_project_contract_meta() -> dict[str, dict[str, Any]]: with engine.begin() as conn: rows = conn.execute( text( """ WITH code_universe AS ( SELECT DISTINCT support_dept_code FROM project_contract_info WHERE COALESCE(support_dept_code, '') <> '' UNION SELECT DISTINCT support_dept_code FROM project_status WHERE COALESCE(support_dept_code, '') <> '' UNION SELECT DISTINCT support_dept_code FROM project_basic_info WHERE COALESCE(support_dept_code, '') <> '' ) SELECT COALESCE(u.support_dept_code, '') AS support_dept_code, COALESCE(c.support_dept_name, p.support_dept_name, b.support_dept_name, '') AS support_dept_name, COALESCE(c.hanmac_contract_amount, 0) AS hanmac_contract_amount, COALESCE(c.client_name, '') AS client_name, COALESCE(p.expected_as_cost, b.expected_as_cost, 0) AS expected_as_cost, COALESCE(p.expected_sga_budget, b.expected_sga_budget, 0) AS expected_sga_budget, COALESCE(p.project_start_date, b.project_start_date, '') AS project_start_date FROM code_universe AS u LEFT JOIN project_contract_info AS c ON c.support_dept_code = u.support_dept_code LEFT JOIN project_status AS p ON p.support_dept_code = u.support_dept_code LEFT JOIN project_basic_info AS b ON b.support_dept_code = u.support_dept_code """ ) ).mappings().all() result: dict[str, dict[str, Any]] = {} for row in rows: code = normalize_text(row.get("support_dept_code")) if not code: continue result[code] = dict(row) return result def _get_direct_project_contract_amount( code: str, contract_meta_map: dict[str, dict[str, Any]], billing_summary_map: dict[str, dict[str, Any]], latest_summary_by_title: dict[str, dict[str, Any]] | None = None, latest_round_by_code: dict[str, dict[str, Any]] | None = None, representative_by_title: dict[str, str] | None = None, title_by_code: dict[str, str] | None = None, ) -> float: normalized_code = normalize_text(code) contract_meta = contract_meta_map.get(normalized_code, {}) billing_summary = billing_summary_map.get(normalized_code, {}) direct_amount = ( normalize_amount(billing_summary.get("contract_amount")) or normalize_amount(contract_meta.get("hanmac_contract_amount")) ) if direct_amount: return direct_amount latest_round_change = (latest_round_by_code or {}).get(normalized_code) or {} round_amount = normalize_amount(latest_round_change.get("changed_contract_amount")) if round_amount: return round_amount return 0.0 def _get_aggregated_project_contract_amount( codes: list[str], contract_meta_map: dict[str, dict[str, Any]], billing_summary_map: dict[str, dict[str, Any]], ) -> float: latest_summary_by_title, latest_round_by_code, representative_by_title, title_by_code = get_project_contract_change_maps() return sum( _get_direct_project_contract_amount( code, contract_meta_map, billing_summary_map, latest_summary_by_title, latest_round_by_code, representative_by_title, title_by_code, ) for code in codes if normalize_text(code) ) def _get_project_actual_input_group_summary( codes: list[str], group_names: list[str], ) -> dict[str, dict[str, Any]]: normalized_codes = [normalize_text(code) for code in codes if normalize_text(code)] normalized_groups = [normalize_text(group) for group in group_names if normalize_text(group)] if not normalized_codes or not normalized_groups: return {} in_clause, params = build_in_clause("actual_input_code", normalized_codes) group_clause, group_params = build_in_clause("actual_input_group", normalized_groups) params.update(group_params) with engine.begin() as conn: rows = conn.execute( text( f""" SELECT support_dept_code, COALESCE(group_name, '') AS group_name, COALESCE(label, '') AS label, COALESCE(reference, '') AS reference, COALESCE(note, '') AS note, COALESCE(grade, '') AS grade, COALESCE(minutes, '') AS minutes, COALESCE(amount, 0) AS amount, COALESCE(updated_at, '') AS updated_at FROM project_actual_input_entries WHERE support_dept_code IN ({in_clause}) AND COALESCE(group_name, '') IN ({group_clause}) ORDER BY position, id """ ), params, ).mappings().all() grouped: dict[str, list[dict[str, Any]]] = {} for row in rows: code = normalize_text(row.get("support_dept_code")) if not code: continue grouped.setdefault(code, []).append(dict(row)) result: dict[str, dict[str, Any]] = {} for code, code_rows in grouped.items(): total_amount = sum(normalize_amount(item.get("amount")) for item in code_rows) has_detail_trace = any( normalize_text(item.get("reference")) or normalize_text(item.get("note")) or normalize_text(item.get("grade")) or normalize_text(item.get("minutes")) for item in code_rows ) distinct_labels = { normalize_text(item.get("label")) for item in code_rows if normalize_text(item.get("label")) } result[code] = { "rows": code_rows, "amount": total_amount, "has_detail_trace": has_detail_trace, "distinct_labels": distinct_labels, "last_updated_at": max((normalize_text(item.get("updated_at")) for item in code_rows), default=""), } return result def _get_project_actual_sga_summary(codes: list[str]) -> dict[str, dict[str, Any]]: return _get_project_actual_input_group_summary(codes, ["sga"]) def _get_project_actual_labor_summary(codes: list[str]) -> dict[str, dict[str, Any]]: return _get_project_actual_input_group_summary(codes, ["labor", "labor_adjustment", "labor_joint"]) def _get_project_actual_as_summary(codes: list[str]) -> dict[str, dict[str, Any]]: return _get_project_actual_input_group_summary(codes, ["as"]) def _get_shared_cluster_input_map() -> dict[str, dict[str, Any]]: related_clusters = _build_process_cost_related_clusters() if not related_clusters: return {} billing_summary_map = get_project_billing_summary_map() contract_meta_map = _get_project_contract_meta() with engine.begin() as conn: input_codes = { normalize_text(row[0]) for row in conn.execute( text( """ SELECT support_dept_code FROM project_status WHERE COALESCE(task_plan_department_budget, 0) <> 0 OR COALESCE(task_plan_outsource_budget, 0) <> 0 OR COALESCE(task_plan_joint_operating_cost, 0) <> 0 OR COALESCE(exec_budget_labor_by_grade, 0) <> 0 OR COALESCE(exec_budget_outsource, 0) <> 0 OR COALESCE(exec_budget_cost_plan, 0) <> 0 OR COALESCE(expected_as_cost, 0) <> 0 OR COALESCE(expected_sga_budget, 0) <> 0 OR COALESCE(item_investment, 0) <> 0 UNION SELECT support_dept_code FROM project_task_plan_entries UNION SELECT support_dept_code FROM project_exec_budget_entries UNION SELECT support_dept_code FROM project_actual_input_entries """ ) ).fetchall() if normalize_text(row[0]) } result: dict[str, dict[str, Any]] = {} visited_clusters: set[tuple[str, ...]] = set() for code, cluster_codes in related_clusters.items(): cluster_key = tuple(cluster_codes) if cluster_key in visited_clusters: continue visited_clusters.add(cluster_key) normalized_cluster = [normalize_text(item) for item in cluster_codes if normalize_text(item)] input_members = [item for item in normalized_cluster if item in input_codes] material_members = [ item for item in normalized_cluster if ( normalize_amount((billing_summary_map.get(item) or {}).get("contract_amount")) > 0 or normalize_amount((billing_summary_map.get(item) or {}).get("collected_amount")) > 0 or normalize_amount((contract_meta_map.get(item) or {}).get("hanmac_contract_amount")) > 0 ) ] if len(normalized_cluster) < 2 or len(input_members) != 1 or len(material_members) < 2: continue owner_code = input_members[0] meta = { "owner_code": owner_code, "cluster_codes": normalized_cluster, } for member in normalized_cluster: result[member] = meta return result def _get_project_exec_budget_summary(codes: list[str]) -> dict[str, dict[str, float]]: normalized_codes = [normalize_text(code) for code in codes if normalize_text(code)] if not normalized_codes: return {} in_clause, params = build_in_clause("exec_budget_code", normalized_codes) with engine.begin() as conn: rows = conn.execute( text( f""" SELECT support_dept_code, COALESCE(group_name, '') AS group_name, SUM(COALESCE(amount, 0)) AS amount FROM project_exec_budget_entries WHERE support_dept_code IN ({in_clause}) GROUP BY support_dept_code, group_name """ ), params, ).mappings().all() result: dict[str, dict[str, float]] = {} for row in rows: code = normalize_text(row.get("support_dept_code")) group_name = normalize_text(row.get("group_name")) if not code: continue current = result.setdefault(code, {"labor": 0.0, "outsource": 0.0, "cost_plan": 0.0}) current[group_name or "labor"] = normalize_amount(row.get("amount")) return result def _is_real_project_actual_sga( actual_sga_summary: dict[str, Any] | None, expected_sga_budget: float, ) -> bool: if not actual_sga_summary: return False amount = normalize_amount(actual_sga_summary.get("amount")) if amount <= 0: return False if actual_sga_summary.get("has_detail_trace"): return True distinct_labels = set(actual_sga_summary.get("distinct_labels") or []) if len(distinct_labels) > 1: return True if distinct_labels and distinct_labels != {"판관비"}: return True if expected_sga_budget > 0 and abs(amount - expected_sga_budget) <= 0.5: return False return True def _get_hanmac_process_cost_tx_by_code(selected_year: int | None) -> dict[str, dict[str, Any]]: with engine.begin() as conn: rows = conn.execute( text( f""" SELECT COALESCE(support_dept_code, '') AS support_dept_code, MAX(COALESCE(support_dept_name, '')) AS support_dept_name, SUM(CASE WHEN {REVENUE_SQL} THEN amount ELSE 0 END) AS revenue_amount, SUM(CASE WHEN account_code LIKE '5%' OR account_code LIKE '6%' THEN amount ELSE 0 END) AS expense_amount, COUNT(DISTINCT CASE WHEN COALESCE(voucher_number, '') <> '' THEN voucher_number END) AS voucher_count, MAX(COALESCE(posting_date, '')) AS last_posting_date 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 ('공통', '경영지원부', '기술개발센터', '임원실', '기술개발부', '총괄기획실') GROUP BY support_dept_code """ ), {}, ).mappings().all() result: dict[str, dict[str, Any]] = {} for row in rows: code = normalize_text(row.get("support_dept_code")) if not code: continue result[code] = { "support_dept_name": normalize_text(row.get("support_dept_name")), "revenue_amount": normalize_amount(row.get("revenue_amount")), "expense_amount": normalize_amount(row.get("expense_amount")), "voucher_count": int(row.get("voucher_count") or 0), "last_posting_date": normalize_text(row.get("last_posting_date")), } return result def get_process_cost_project_options( source: str, start_year: int | None, end_year: int | None, include_related: bool = False, ) -> list[dict[str, Any]]: normalized_source = normalize_text(source).lower() cache_key = ("project_options", normalized_source, start_year, end_year, bool(include_related)) cached = _get_runtime_cache_entry(_PROCESS_COST_PROJECT_OPTIONS_CACHE, cache_key) if cached is not None: return cached contract_meta = _get_project_contract_meta() billing_summary_map = get_project_billing_summary_map() if normalized_source != "wehago" else {} if normalized_source == "wehago": init_wehago_compare_db(engine) with engine.begin() as conn: rows = conn.execute( text( f""" WITH base AS ( SELECT COALESCE(support_dept_code, '') AS support_dept_code, COALESCE(support_dept_name, '') AS support_dept_name, COALESCE(proof_date, '') AS proof_date, COALESCE(account_code, '') AS account_code, CASE WHEN ABS(COALESCE(compare_amount, 0)) > 0 THEN ABS(COALESCE(compare_amount, 0)) WHEN ABS(COALESCE(debit_supply, 0)) >= ABS(COALESCE(credit_supply, 0)) THEN ABS(COALESCE(debit_supply, 0)) ELSE ABS(COALESCE(credit_supply, 0)) END AS amount, COALESCE(confirmed_no, '') AS confirmed_no, COALESCE(draft_no, '') AS draft_no FROM wehago_voucher_rows WHERE COALESCE(support_dept_code, '') <> '' AND support_dept_code NOT IN ('ZZZZZZ') ) SELECT support_dept_code, MAX(support_dept_name) AS support_dept_name, SUM(CASE WHEN account_code LIKE '401101%' OR account_code LIKE '401102%' THEN amount ELSE 0 END) AS revenue_amount, SUM(CASE WHEN account_code LIKE '5%' OR account_code LIKE '6%' THEN amount ELSE 0 END) AS expense_amount, COUNT(DISTINCT CASE WHEN COALESCE(confirmed_no, '') <> '' THEN confirmed_no ELSE draft_no END) AS voucher_count, MAX(proof_date) AS last_posting_date FROM base GROUP BY support_dept_code ORDER BY expense_amount DESC, support_dept_code """ ), {}, ).mappings().all() else: tx_by_code = _get_hanmac_process_cost_tx_by_code(None) universe_codes = sorted(set(contract_meta) | set(billing_summary_map) | set(tx_by_code)) rows = [] for code in universe_codes: current_tx = tx_by_code.get(code, {}) expense_amount = normalize_amount(current_tx.get("expense_amount")) revenue_amount = normalize_amount(current_tx.get("revenue_amount")) voucher_count = int(current_tx.get("voucher_count", 0) or 0) last_posting_date = normalize_text(current_tx.get("last_posting_date")) tx_name = normalize_text(current_tx.get("support_dept_name")) rows.append( { "support_dept_code": code, "support_dept_name": tx_name, "revenue_amount": revenue_amount, "expense_amount": expense_amount, "voucher_count": voucher_count, "last_posting_date": last_posting_date, } ) result: list[dict[str, Any]] = [] for row in rows: code = normalize_text(row.get("support_dept_code")) if not code: continue contract_row = contract_meta.get(code, {}) billing_row = billing_summary_map.get(code, {}) project_start_date = normalize_text(contract_row.get("project_start_date")) project_start_year = _extract_year_from_project_code(code) if start_year and project_start_year and project_start_year < start_year: continue if end_year and project_start_year and project_start_year > end_year: continue if (start_year or end_year) and not project_start_year: continue contract_amount = _get_direct_project_contract_amount(code, contract_meta, billing_summary_map) revenue_amount = normalize_amount(row.get("revenue_amount")) expense_amount = normalize_amount(row.get("expense_amount")) profit_amount = revenue_amount - expense_amount result.append( { "support_dept_code": code, "support_dept_name": normalize_text(contract_row.get("support_dept_name")) or normalize_text(billing_row.get("support_dept_name")) or normalize_text(row.get("support_dept_name")) or code, "client_name": normalize_text(contract_row.get("client_name")) or normalize_text(billing_row.get("client_name")), "contract_amount": contract_amount, "revenue_amount": revenue_amount, "expense_amount": expense_amount, "profit_amount": profit_amount, "profit_rate": _safe_ratio(profit_amount, revenue_amount), "voucher_count": int(row.get("voucher_count") or 0), "last_posting_date": normalize_text(row.get("last_posting_date")), "project_start_date": project_start_date, "project_kind_code": _get_process_cost_project_kind(code)[0], "project_kind_label": _get_process_cost_project_kind(code)[1], } ) return _set_runtime_cache_entry( _PROCESS_COST_PROJECT_OPTIONS_CACHE, cache_key, _sort_process_cost_project_options(result), ) def get_process_cost_project_detail( source: str, selected_year: int | None, support_dept_code: str, include_related: bool = False, active_related_codes: list[str] | None = None, ) -> dict[str, Any]: code = normalize_text(support_dept_code) if not code: return { "overview": {}, "phase_rows": [], "account_rows": [], "monthly_rows": [], "diagnostics": {}, } normalized_source = normalize_text(source).lower() normalized_active_related_codes = tuple( sorted( normalize_text(item) for item in (active_related_codes or []) if normalize_text(item) ) ) cache_key = ( "project_detail", normalized_source, selected_year, code, bool(include_related), normalized_active_related_codes, ) cached = _get_runtime_cache_entry(_PROCESS_COST_PROJECT_DETAIL_CACHE, cache_key) if cached is not None: return cached contract_meta_map = _get_project_contract_meta() billing_summary_map = get_project_billing_summary_map() if normalized_source != "wehago" else {} shared_input_map = _get_shared_cluster_input_map() contract_meta = contract_meta_map.get(code, {}) saved_related_codes = get_process_cost_related_codes(code) active_related_codes = [ normalize_text(item) for item in normalized_active_related_codes if normalize_text(item) ] active_related_codes = [ item for item in active_related_codes if item != code and item in saved_related_codes ] cluster_codes = [code, *active_related_codes] if include_related else [code] cluster_code_set = {normalize_text(item) for item in cluster_codes if normalize_text(item)} suppressed_shared_owner_codes = { normalize_text(meta.get("owner_code")) for member_code in cluster_code_set for meta in [shared_input_map.get(member_code) or {}] if meta and not set(meta.get("cluster_codes") or []).issubset(cluster_code_set) } actual_sga_summary_map = _get_project_actual_sga_summary(cluster_codes) actual_labor_summary_map = _get_project_actual_labor_summary(cluster_codes) actual_as_summary_map = _get_project_actual_as_summary(cluster_codes) exec_budget_summary_map = _get_project_exec_budget_summary(cluster_codes) if normalized_source == "wehago": init_wehago_compare_db(engine) in_clause, code_params = build_in_clause("process_cost_wehago_code", cluster_codes) params: dict[str, Any] = dict(code_params) amount_expr = ( "CASE " "WHEN ABS(COALESCE(compare_amount, 0)) > 0 THEN ABS(COALESCE(compare_amount, 0)) " "WHEN ABS(COALESCE(debit_supply, 0)) >= ABS(COALESCE(credit_supply, 0)) THEN ABS(COALESCE(debit_supply, 0)) " "ELSE ABS(COALESCE(credit_supply, 0)) " "END" ) with engine.begin() as conn: summary = conn.execute( text( f""" SELECT MAX(COALESCE(support_dept_name, '')) AS support_dept_name, SUM(CASE WHEN account_code LIKE '401101%' OR account_code LIKE '401102%' THEN {amount_expr} ELSE 0 END) AS revenue_amount, SUM(CASE WHEN account_code LIKE '5%' OR account_code LIKE '6%' THEN {amount_expr} ELSE 0 END) AS expense_amount, SUM(CASE WHEN account_code LIKE '5012%' THEN {amount_expr} ELSE 0 END) AS labor_amount, SUM(CASE WHEN account_code LIKE '5017%' THEN {amount_expr} ELSE 0 END) AS outsourcing_amount, SUM(CASE WHEN account_code LIKE '6%' THEN {amount_expr} ELSE 0 END) AS sga_amount, COUNT(*) AS row_count, COUNT(DISTINCT CASE WHEN COALESCE(confirmed_no, '') <> '' THEN confirmed_no ELSE draft_no END) AS voucher_count, MAX(COALESCE(proof_date, '')) AS last_posting_date FROM wehago_voucher_rows WHERE support_dept_code IN ({in_clause}) """ ), params, ).mappings().first() account_rows = conn.execute( text( f""" SELECT COALESCE(account_code, '') AS account_code, COALESCE(account_name, '') AS account_name, SUM({amount_expr}) AS amount, COUNT(*) AS row_count, MAX(COALESCE(proof_date, '')) AS last_posting_date FROM wehago_voucher_rows WHERE support_dept_code IN ({in_clause}) AND (account_code LIKE '5%' OR account_code LIKE '6%') GROUP BY account_code, account_name ORDER BY amount DESC, account_code LIMIT 14 """ ), params, ).mappings().all() monthly_rows = conn.execute( text( f""" SELECT SUBSTR(COALESCE(proof_date, ''), 1, 7) AS month_label, SUM(CASE WHEN account_code LIKE '401101%' OR account_code LIKE '401102%' THEN {amount_expr} ELSE 0 END) AS revenue_amount, SUM(CASE WHEN account_code LIKE '5%' OR account_code LIKE '6%' THEN {amount_expr} ELSE 0 END) AS expense_amount FROM wehago_voucher_rows WHERE support_dept_code IN ({in_clause}) AND LENGTH(COALESCE(proof_date, '')) >= 7 GROUP BY month_label ORDER BY month_label DESC LIMIT 8 """ ), params, ).mappings().all() else: in_clause, code_params = build_in_clause("process_cost_code", cluster_codes) params = dict(code_params) with engine.begin() as conn: summary = conn.execute( text( f""" SELECT MAX(COALESCE(support_dept_name, '')) AS support_dept_name, SUM(CASE WHEN {REVENUE_SQL} THEN amount ELSE 0 END) AS revenue_amount, SUM(CASE WHEN account_code LIKE '5%' OR account_code LIKE '6%' THEN amount ELSE 0 END) AS expense_amount, SUM(CASE WHEN account_code LIKE '5012%' THEN amount ELSE 0 END) AS labor_amount, SUM(CASE WHEN account_code LIKE '5017%' THEN amount ELSE 0 END) AS outsourcing_amount, SUM(CASE WHEN accounting_category = '판관비' THEN amount ELSE 0 END) AS sga_amount, COUNT(*) AS row_count, COUNT(DISTINCT COALESCE(voucher_number, '')) AS voucher_count, MAX(COALESCE(posting_date, '')) AS last_posting_date FROM transactions WHERE support_dept_code IN ({in_clause}) """ ), params, ).mappings().first() account_rows = conn.execute( text( f""" SELECT COALESCE(account_code, '') AS account_code, COALESCE(account_name, '') AS account_name, SUM(COALESCE(amount, 0)) AS amount, COUNT(*) AS row_count, MAX(COALESCE(posting_date, '')) AS last_posting_date FROM transactions WHERE support_dept_code IN ({in_clause}) AND (account_code LIKE '5%' OR account_code LIKE '6%') GROUP BY account_code, account_name ORDER BY amount DESC, account_code LIMIT 14 """ ), params, ).mappings().all() monthly_rows = conn.execute( text( f""" SELECT printf('%04d-%02d', year, month) AS month_label, SUM(CASE WHEN {REVENUE_SQL} THEN amount ELSE 0 END) AS revenue_amount, SUM(CASE WHEN account_code LIKE '5%' OR account_code LIKE '6%' THEN amount ELSE 0 END) AS expense_amount FROM transactions WHERE support_dept_code IN ({in_clause}) AND year IS NOT NULL AND month IS NOT NULL GROUP BY year, month ORDER BY year DESC, month DESC LIMIT 8 """ ), params, ).mappings().all() summary_row = dict(summary) if summary else {} if include_related: contract_amount = _get_aggregated_project_contract_amount(cluster_codes, contract_meta_map, billing_summary_map) as_cost_amount = sum(normalize_amount(contract_meta_map.get(member, {}).get("expected_as_cost")) for member in cluster_codes if member not in suppressed_shared_owner_codes) expected_sga_budget = sum(normalize_amount(contract_meta_map.get(member, {}).get("expected_sga_budget")) for member in cluster_codes if member not in suppressed_shared_owner_codes) planned_labor_amount = sum(normalize_amount((exec_budget_summary_map.get(member) or {}).get("labor")) for member in cluster_codes if member not in suppressed_shared_owner_codes) planned_outsource_amount = sum(normalize_amount((exec_budget_summary_map.get(member) or {}).get("outsource")) for member in cluster_codes if member not in suppressed_shared_owner_codes) planned_cost_plan_amount = sum(normalize_amount((exec_budget_summary_map.get(member) or {}).get("cost_plan")) for member in cluster_codes if member not in suppressed_shared_owner_codes) else: contract_amount = _get_direct_project_contract_amount(code, contract_meta_map, billing_summary_map) as_cost_amount = 0.0 if code in suppressed_shared_owner_codes else normalize_amount(contract_meta.get("expected_as_cost")) expected_sga_budget = 0.0 if code in suppressed_shared_owner_codes else normalize_amount(contract_meta.get("expected_sga_budget")) planned_labor_amount = 0.0 if code in suppressed_shared_owner_codes else normalize_amount((exec_budget_summary_map.get(code) or {}).get("labor")) planned_outsource_amount = 0.0 if code in suppressed_shared_owner_codes else normalize_amount((exec_budget_summary_map.get(code) or {}).get("outsource")) planned_cost_plan_amount = 0.0 if code in suppressed_shared_owner_codes else normalize_amount((exec_budget_summary_map.get(code) or {}).get("cost_plan")) revenue_amount = normalize_amount(summary_row.get("revenue_amount")) ledger_expense_amount = normalize_amount(summary_row.get("expense_amount")) ledger_labor_amount = normalize_amount(summary_row.get("labor_amount")) outsourcing_amount = normalize_amount(summary_row.get("outsourcing_amount")) ledger_sga_amount = normalize_amount(summary_row.get("sga_amount")) actual_labor_amount = sum( normalize_amount((actual_labor_summary_map.get(member) or {}).get("amount")) for member in cluster_codes if member not in suppressed_shared_owner_codes ) real_project_actual_sga_amount = 0.0 actual_input_last_updated_at = "" for member in cluster_codes: updated_at = normalize_text((actual_labor_summary_map.get(member) or {}).get("last_updated_at")) if updated_at and updated_at > actual_input_last_updated_at: actual_input_last_updated_at = updated_at for member in cluster_codes: member_expected_sga_budget = normalize_amount(contract_meta_map.get(member, {}).get("expected_sga_budget")) actual_summary = actual_sga_summary_map.get(member) if member in suppressed_shared_owner_codes: actual_summary = None member_expected_sga_budget = 0.0 if _is_real_project_actual_sga(actual_summary, member_expected_sga_budget): real_project_actual_sga_amount += normalize_amount((actual_summary or {}).get("amount")) updated_at = normalize_text((actual_summary or {}).get("last_updated_at")) if updated_at and updated_at > actual_input_last_updated_at: actual_input_last_updated_at = updated_at actual_as_amount = sum( normalize_amount((actual_as_summary_map.get(member) or {}).get("amount")) for member in cluster_codes if member not in suppressed_shared_owner_codes ) actual_sga_amount = sum( normalize_amount((actual_sga_summary_map.get(member) or {}).get("amount")) for member in cluster_codes if member not in suppressed_shared_owner_codes ) labor_amount = actual_labor_amount if actual_labor_amount > 0 else ledger_labor_amount as_amount = actual_as_amount if actual_as_amount > 0 else as_cost_amount sga_amount = actual_sga_amount if actual_sga_amount > 0 else ledger_sga_amount design_cost_amount = max(ledger_expense_amount - ledger_labor_amount - outsourcing_amount - ledger_sga_amount, 0.0) expense_amount = labor_amount + outsourcing_amount + design_cost_amount + as_amount + sga_amount profit_amount = revenue_amount - expense_amount target_base = ( planned_labor_amount + planned_outsource_amount + planned_cost_plan_amount + as_cost_amount + expected_sga_budget ) phase_rows = [ {"phase": "직접인건비", "target_amount": planned_labor_amount, "actual_amount": labor_amount}, {"phase": "외주비", "target_amount": planned_outsource_amount, "actual_amount": outsourcing_amount}, {"phase": "제경비", "target_amount": planned_cost_plan_amount, "actual_amount": design_cost_amount}, {"phase": "A/S비", "target_amount": as_cost_amount, "actual_amount": as_amount}, {"phase": "판관비", "target_amount": expected_sga_budget, "actual_amount": sga_amount}, ] for row in phase_rows: row["gap_amount"] = row["target_amount"] - row["actual_amount"] row["progress_rate"] = _safe_ratio(row["actual_amount"], row["target_amount"]) normalized_accounts = [] for row in account_rows: amount = normalize_amount(row.get("amount")) last_posting_date = normalize_text(row.get("last_posting_date")) or normalize_text(summary_row.get("last_posting_date")) normalized_accounts.append( { "account_code": normalize_text(row.get("account_code")), "account_name": normalize_text(row.get("account_name")), "amount": amount, "row_count": int(row.get("row_count") or 0), "share_rate": _safe_ratio(amount, expense_amount), "last_posting_date": last_posting_date[:10] if last_posting_date else "", } ) if actual_labor_amount > 0: normalized_accounts.append( { "account_code": "PROJECT-LABOR", "account_name": "직접인건비(프로젝트 정보)", "amount": actual_labor_amount, "row_count": sum(len((actual_labor_summary_map.get(member) or {}).get("rows") or []) for member in cluster_codes), "share_rate": _safe_ratio(actual_labor_amount, expense_amount), "last_posting_date": "", } ) normalized_accounts = [ row for row in normalized_accounts if normalize_text(row.get("account_code")) != "PROJECT-SGA" and "판관비" not in normalize_text(row.get("account_name")) ] normalized_accounts.sort(key=lambda item: (-normalize_amount(item.get("amount")), normalize_text(item.get("account_code")))) normalized_accounts = normalized_accounts[:14] normalized_monthly = [] for row in monthly_rows: revenue = normalize_amount(row.get("revenue_amount")) expense = normalize_amount(row.get("expense_amount")) normalized_monthly.append( { "month_label": normalize_text(row.get("month_label")), "revenue_amount": revenue, "expense_amount": expense, "profit_amount": revenue - expense, } ) normalized_monthly.reverse() return _set_runtime_cache_entry( _PROCESS_COST_PROJECT_DETAIL_CACHE, cache_key, { "overview": { "support_dept_code": code, "support_dept_name": normalize_text(contract_meta.get("support_dept_name")) or normalize_text(summary_row.get("support_dept_name")) or code, "client_name": normalize_text(contract_meta.get("client_name")), "contract_amount": contract_amount, "revenue_amount": revenue_amount, "expense_amount": expense_amount, "profit_amount": profit_amount, "profit_rate": _safe_ratio(profit_amount, revenue_amount), "target_cost_amount": target_base, "execution_rate": _safe_ratio(expense_amount, target_base), "last_posting_date": normalize_text(summary_row.get("last_posting_date"))[:10], "voucher_count": int(summary_row.get("voucher_count") or 0), "row_count": int(summary_row.get("row_count") or 0), "included_codes": cluster_codes if include_related else [code], "expected_sga_budget": expected_sga_budget, "ledger_sga_amount": ledger_sga_amount, "project_actual_sga_amount": real_project_actual_sga_amount, "project_actual_labor_amount": actual_labor_amount, }, "phase_rows": phase_rows, "account_rows": normalized_accounts, "monthly_rows": normalized_monthly, "diagnostics": { "labor_ratio": _safe_ratio(labor_amount, expense_amount), "outsourcing_ratio": _safe_ratio(outsourcing_amount, expense_amount), "design_cost_ratio": _safe_ratio(design_cost_amount, expense_amount), "sga_ratio": _safe_ratio(sga_amount, expense_amount), "cost_to_revenue_ratio": _safe_ratio(expense_amount, revenue_amount), }, }, ) def render_process_cost_page( request: Request, 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, message: str = "", ) -> HTMLResponse: init_db() init_wehago_compare_db(engine) 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, ) context = { **base_context(request, message), "process_cost_source": normalized_source, "process_cost_years": years, "process_cost_years_desc": sorted(years, reverse=True), "process_cost_selected_start_year": selected_start_year, "process_cost_selected_end_year": selected_end_year, "process_cost_selected_code": selected_code, "process_cost_selected_project": selected_project, "process_cost_include_related": include_related, "process_cost_projects": project_options, "process_cost_detail": detail, "process_cost_related_codes": related_codes, "process_cost_active_related_codes": active_related_codes, "process_cost_quick_link_codes": get_process_cost_quick_links(), } return templates.TemplateResponse(request, "process_cost.html", context) def _process_cost_bootstrap_cache_params( 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() or "hanmac" if normalized_source not in {"hanmac", "wehago"}: normalized_source = "hanmac" return { "source": normalized_source, "start_year": int(start_year or 0), "end_year": int(end_year or 0), "code": normalize_text(code), "include_related": bool(include_related), "active_related": normalize_text(active_related), } def _process_cost_bootstrap_cache_key( 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, ) -> str: return _json_hash( _process_cost_bootstrap_cache_params( source, start_year, end_year, code, include_related, active_related, ) ) def _build_process_cost_bootstrap_payload_uncached( 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_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]: cache_key = ( normalize_text(source).lower() or "hanmac", start_year or 0, end_year or 0, normalize_text(code), bool(include_related), normalize_text(active_related), ) cached = _get_runtime_cache_entry( _PROCESS_COST_PROJECT_DETAIL_CACHE, cache_key, ttl_seconds=PROCESS_COST_CACHE_TTL_SECONDS, ) if cached is not None: return cached persistent_cache_key = _process_cost_bootstrap_cache_key( source, start_year, end_year, code, include_related, active_related, ) persistent = _load_system_page_cache("process_cost_bootstrap", persistent_cache_key) if persistent is not None: return _set_runtime_cache_entry(_PROCESS_COST_PROJECT_DETAIL_CACHE, cache_key, persistent) payload = _build_process_cost_bootstrap_payload_uncached( source, start_year, end_year, code, include_related, active_related, ) return _set_runtime_cache_entry(_PROCESS_COST_PROJECT_DETAIL_CACHE, cache_key, payload) 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" month_where = "" if granularity == "yearly" else "AND month IS NOT NULL" project_cost_sql = ( "accounting_category = '원가' " "AND account_code NOT LIKE '5012%' " "AND account_code NOT LIKE '5017%' " "AND support_dept_code <> 'ZZZZZZ' " f"AND {FIELD_COST_DEPT_SQL}" ) support_cost_sql = ( "accounting_category = '원가' " "AND account_code NOT LIKE '5012%' " "AND account_code NOT LIKE '5017%' " "AND support_dept_code = 'ZZZZZZ' " f"AND {FIELD_COST_DEPT_SQL}" ) support_sga_sql = f"accounting_category = '판관비' AND {SUPPORT_COST_DEPT_SQL}" field_sga_sql = f"accounting_category = '판관비' AND {FIELD_COST_DEPT_SQL}" with engine.begin() as conn: rows = conn.execute( text( f""" SELECT {group_fields}, SUM(CASE WHEN {REVENUE_SQL} THEN amount ELSE 0 END) AS revenue_sum, SUM(CASE WHEN {project_cost_sql} THEN amount ELSE 0 END) AS project_cost_sum, SUM(CASE WHEN {support_cost_sql} THEN amount ELSE 0 END) AS support_cost_sum, SUM(CASE WHEN {support_sga_sql} THEN amount ELSE 0 END) AS support_sga_sum, SUM(CASE WHEN {field_sga_sql} THEN amount ELSE 0 END) AS field_sga_sum, SUM(CASE WHEN account_code LIKE '5012%' THEN amount ELSE 0 END) AS labor_sum, SUM(CASE WHEN account_code LIKE '5017%' THEN amount ELSE 0 END) AS outsourcing_sum FROM transactions WHERE year IS NOT NULL {month_where} GROUP BY {group_fields} ORDER BY {order_fields} """ ) ).mappings().all() result: list[dict[str, Any]] = [] for row in rows: item = dict(row) item["total_expense"] = ( (item.get("project_cost_sum") or 0) + (item.get("support_cost_sum") or 0) + (item.get("support_sga_sum") or 0) + (item.get("field_sga_sum") or 0) + (item.get("labor_sum") or 0) + (item.get("outsourcing_sum") or 0) ) item["operating_balance"] = (item.get("revenue_sum") or 0) - item["total_expense"] item["label"] = str(item["year"]) if granularity == "yearly" else f"{item['year']}-{int(item['month']):02d}" result.append(item) return result def get_source_files_summary() -> list[dict[str, Any]]: with engine.begin() as conn: rows = conn.execute( text( """ SELECT source_file, COUNT(*) AS row_count FROM transactions WHERE COALESCE(source_file, '') <> '' GROUP BY source_file ORDER BY row_count DESC, source_file """ ) ).mappings().all() return [dict(row) for row in rows] def parse_excel_upload(upload_file: UploadFile) -> int: init_db() workbook = load_workbook(upload_file.file, data_only=True) import_kind = detect_excel_import_kind(workbook, upload_file.filename or "") if import_kind == "contract_status": return import_contract_status_workbook(workbook, upload_file.filename or "") if import_kind == "change_contract_summary": return import_change_contract_summary_workbook(workbook, upload_file.filename or "") if import_kind == "change_contract_round": return import_change_contract_round_workbook(workbook, upload_file.filename or "") if import_kind == "billing_status": return import_billing_status_workbook(workbook, upload_file.filename or "") sheet = workbook.active headers = [canonical_header_name(cell.value) for cell in next(sheet.iter_rows(min_row=1, max_row=1))] if import_kind == "transactions": rows_to_insert: list[dict[str, Any]] = [] inserted = 0 insert_sql = text( """ INSERT INTO transactions ( approval_status, voucher_number, account_code, account_name, debit_supply, debit_vat, credit_supply, credit_vat, issuing_dept_code, issuing_dept_name, confirmed_voucher_number, support_dept_code, support_dept_name, cost_dept_code, cost_dept_name, memo1, memo2, partner_code, partner_name, tax_code, posting_date, voucher_type, management_item, accounting_category, amount, year, month, day, source_file, last_editor_session_id, last_client_submitted_at ) VALUES ( :approval_status, :voucher_number, :account_code, :account_name, :debit_supply, :debit_vat, :credit_supply, :credit_vat, :issuing_dept_code, :issuing_dept_name, :confirmed_voucher_number, :support_dept_code, :support_dept_name, :cost_dept_code, :cost_dept_name, :memo1, :memo2, :partner_code, :partner_name, :tax_code, :posting_date, :voucher_type, :management_item, :accounting_category, :amount, :year, :month, :day, :source_file, '', '' ) """ ) for row in sheet.iter_rows(min_row=2, values_only=True): raw: dict[str, Any] = {} has_value = False for index, value in enumerate(row): field_name = headers[index] if index < len(headers) else None if field_name: raw[field_name] = value if normalize_text(value): has_value = True if not has_value: continue payload = build_transaction_payload(raw, source_file=upload_file.filename or "") if not payload["voucher_number"] and not payload["account_code"] and not payload["account_name"]: continue rows_to_insert.append(payload) inserted += 1 with engine.begin() as conn: conn.execute( text("DELETE FROM transactions WHERE COALESCE(source_file, '') = :source_file") , {"source_file": upload_file.filename or ""}, ) if rows_to_insert: conn.execute(insert_sql, rows_to_insert) return inserted inserted = 0 for row in sheet.iter_rows(min_row=2, values_only=True): raw: dict[str, Any] = {} has_value = False for index, value in enumerate(row): field_name = headers[index] if index < len(headers) else None if field_name: raw[field_name] = value if normalize_text(value): has_value = True if not has_value: continue payload = build_transaction_payload(raw, source_file=upload_file.filename or "") if not payload["voucher_number"] and not payload["account_code"] and not payload["account_name"]: continue save_transaction(payload) inserted += 1 return inserted def import_excel_path(path: Path) -> int: with path.open("rb") as excel_file: upload = UploadFile(filename=path.name, file=excel_file) return parse_excel_upload(upload) def _extract_filename_date_score(filename: str) -> int: text_name = normalize_text(filename) if not text_name: return 0 tokens = re.findall(r"(\d{6,8})", text_name) if not tokens: return 0 best = 0 for token in tokens: try: if len(token) == 8: score = int(token) elif len(token) == 6: score = int(f"20{token}") else: continue except ValueError: continue if score > best: best = score return best def _transaction_file_priority(path: Path) -> tuple[int, int, float]: name = normalize_text(path.name).lower() voucher_sort_bonus = 1 if "voucher_sort" in name else 0 date_score = _extract_filename_date_score(path.name) mtime = 0.0 try: mtime = path.stat().st_mtime except OSError: mtime = 0.0 return (voucher_sort_bonus, date_score, mtime) def _collect_auto_import_excel_files() -> list[Path]: scan_dirs = [BASE_DIR] extra_roots = [normalize_text(os.getenv("PROJECT_AUTO_IMPORT_DIRS")), normalize_text(os.getenv("WEHAGO_SOURCE_ROOT"))] fallback_wehago_dir = BASE_DIR.parent / "WEHAGO_DB" if fallback_wehago_dir.exists(): extra_roots.append(str(fallback_wehago_dir)) seen_dirs: set[str] = set() for root in extra_roots: if not root: continue for part in root.split(os.pathsep): normalized_part = normalize_text(part) if not normalized_part: continue if normalized_part in seen_dirs: continue seen_dirs.add(normalized_part) candidate = Path(normalized_part) if candidate.exists() and candidate.is_dir(): scan_dirs.append(candidate) file_map: dict[str, Path] = {} for directory in scan_dirs: for path in sorted(directory.glob("*.xlsx")): if path.name.startswith("~$"): continue if path.name not in file_map: file_map[path.name] = path continue existing = file_map[path.name] if _transaction_file_priority(path) > _transaction_file_priority(existing): file_map[path.name] = path return sorted(file_map.values(), key=lambda item: item.name) def _get_transaction_source_last_updated(source_file: str) -> datetime | None: with engine.begin() as conn: row = conn.execute( text( """ SELECT MAX(updated_at) AS last_updated_at FROM transactions WHERE source_file = :source_file """ ), {"source_file": normalize_text(source_file)}, ).mappings().first() raw_value = normalize_text((row or {}).get("last_updated_at")) if not raw_value: return None for fmt in ("%Y-%m-%d %H:%M:%S", "%Y-%m-%dT%H:%M:%S"): try: return datetime.strptime(raw_value[:19], fmt) except ValueError: continue return None def _should_reimport_transaction_file(path: Path, known_files: set[str]) -> bool: if path.name not in known_files: return True if count_transactions() <= 0: return True db_last_updated = _get_transaction_source_last_updated(path.name) if not db_last_updated: return True try: file_mtime = datetime.fromtimestamp(path.stat().st_mtime) except OSError: return False return file_mtime > db_last_updated def auto_import_project_excels() -> None: init_db() excel_files = _collect_auto_import_excel_files() if not excel_files: return known_files = existing_source_files() known_contract_files = existing_contract_source_files() known_billing_files = existing_billing_source_files() known_change_summary_files = existing_change_contract_summary_source_files() known_change_round_files = existing_change_contract_round_source_files() if count_transactions() > 0 and all(file.name in known_files for file in excel_files): if all( file.name in known_contract_files or file.name in known_billing_files or file.name in known_change_summary_files or file.name in known_change_round_files for file in excel_files ): return transaction_candidates: list[Path] = [] for excel_path in excel_files: if not zipfile.is_zipfile(excel_path): logger.warning("Skipping non-Excel or temporary workbook during auto-import: %s", excel_path.name) continue try: workbook = load_workbook(excel_path, data_only=True) except zipfile.BadZipFile: logger.warning("Skipping invalid workbook during auto-import: %s", excel_path.name) continue except Exception: logger.exception("Failed to inspect workbook during auto-import: %s", excel_path.name) continue import_kind = detect_excel_import_kind(workbook, excel_path.name) if import_kind == "transactions": transaction_candidates.append(excel_path) continue if import_kind == "contract_status" and excel_path.name in known_contract_files: continue if import_kind == "change_contract_summary" and excel_path.name in known_change_summary_files: continue if import_kind == "change_contract_round" and excel_path.name in known_change_round_files: continue if import_kind == "billing_status" and excel_path.name in known_billing_files: continue if import_kind == "transactions" and excel_path.name in known_files: continue try: with excel_path.open("rb") as excel_file: upload = UploadFile(filename=excel_path.name, file=excel_file) inserted = parse_excel_upload(upload) logger.info("Auto-imported %s rows from %s", inserted, excel_path.name) except Exception: logger.exception("Failed to auto-import workbook: %s", excel_path.name) if not transaction_candidates: return selected_transaction_file = max(transaction_candidates, key=_transaction_file_priority) if not _should_reimport_transaction_file(selected_transaction_file, known_files): return try: with selected_transaction_file.open("rb") as excel_file: upload = UploadFile(filename=selected_transaction_file.name, file=excel_file) inserted = parse_excel_upload(upload) logger.info( "Auto-imported %s transaction rows from %s (selected from %s candidates)", inserted, selected_transaction_file, len(transaction_candidates), ) except Exception: logger.exception("Failed to auto-import transaction workbook: %s", selected_transaction_file.name) def normalize_all_collection_entry_storage() -> None: with engine.begin() as conn: entry_rows = conn.execute( text( """ SELECT id, support_dept_code, vendor, progress_type, billing_round, billing_type, billing_date, billed_amount, round, date, due_date, amount, balance_amount, collection_rate, note FROM project_collection_entries ORDER BY support_dept_code, position, id """ ) ).mappings().all() grouped_entries: dict[str, list[dict[str, Any]]] = {} for row in entry_rows: row_dict = dict(row) entry_id = row_dict.pop("id", None) support_dept_code = normalize_text(row_dict.pop("support_dept_code", "")) normalized = normalize_collection_entry_row(row_dict) grouped_entries.setdefault(support_dept_code, []).append(normalized) if ( normalize_text(row.get("progress_type")) != normalized["progress_type"] or normalize_text(row.get("billing_type")) != normalized["billing_type"] ): conn.execute( text( """ UPDATE project_collection_entries SET progress_type = :progress_type, billing_type = :billing_type, updated_at = CURRENT_TIMESTAMP WHERE id = :id """ ), { "id": entry_id, "progress_type": normalized["progress_type"], "billing_type": normalized["billing_type"], }, ) cached_rows = conn.execute( text( """ SELECT support_dept_code, collection_entries_json FROM project_status WHERE collection_entries_json IS NOT NULL AND collection_entries_json <> '' """ ) ).mappings().all() for row in cached_rows: support_dept_code = normalize_text(row["support_dept_code"]) normalized_entries = grouped_entries.get(support_dept_code) if normalized_entries is None: raw_entries = decode_json_rows(row["collection_entries_json"]) normalized_entries = [normalize_collection_entry_row(item) for item in raw_entries] conn.execute( text( """ UPDATE project_status SET collection_entries_json = :collection_entries_json, updated_at = CURRENT_TIMESTAMP WHERE support_dept_code = :support_dept_code """ ), { "support_dept_code": support_dept_code, "collection_entries_json": encode_json_rows(normalized_entries), }, ) def parse_manual_form(raw_body: bytes) -> dict[str, Any]: parsed = parse_qs(raw_body.decode("utf-8")) payload = {key: values[0] if values else "" for key, values in parsed.items()} return payload def parse_project_form(raw_body: bytes) -> dict[str, Any]: parsed = parse_qs(raw_body.decode("utf-8")) payload: dict[str, Any] = {} for key, values in parsed.items(): payload[key] = values if key.endswith("[]") else (values[0] if values else "") return payload def build_named_amount_rows( labels: list[Any], amounts: list[Any], *, label_key: str = "label", amount_key: str = "amount", ) -> list[dict[str, Any]]: rows = [] for index, label in enumerate(labels): rows.append( { label_key: label, amount_key: amounts[index] if index < len(amounts) else "", } ) return filter_amount_rows(rows, amount_key=amount_key) def build_triplet_amount_rows( first_values: list[Any], second_values: list[Any], amounts: list[Any], *, first_key: str, second_key: str, amount_key: str = "amount", ) -> list[dict[str, Any]]: max_length = max(len(first_values), len(second_values), len(amounts)) rows = [] for index in range(max_length): rows.append( { first_key: first_values[index] if index < len(first_values) else "", second_key: second_values[index] if index < len(second_values) else "", amount_key: amounts[index] if index < len(amounts) else "", } ) return filter_amount_rows(rows, amount_key=amount_key) def build_collection_rows(payload: dict[str, Any]) -> list[dict[str, Any]]: progress_types = payload.get("collection_progress_type[]", []) billing_rounds = payload.get("collection_billing_round[]", []) billing_types = payload.get("collection_billing_type[]", []) billing_dates = payload.get("collection_billing_date[]", []) billed_amounts = payload.get("collection_billed_amount[]", []) collection_rounds = payload.get("collection_round[]", []) collection_dates = payload.get("collection_date[]", []) collection_amounts = payload.get("collection_amount_row[]", []) rows = [] total_rows = max( len(progress_types), len(billing_rounds), len(billing_types), len(billing_dates), len(billed_amounts), len(collection_rounds), len(collection_dates), len(collection_amounts), ) for index in range(total_rows): rows.append( { "progress_type": progress_types[index] if index < len(progress_types) else "", "billing_round": billing_rounds[index] if index < len(billing_rounds) else "", "billing_type": billing_types[index] if index < len(billing_types) else "", "billing_date": billing_dates[index] if index < len(billing_dates) else "", "billed_amount": billed_amounts[index] if index < len(billed_amounts) else "", "round": collection_rounds[index] if index < len(collection_rounds) else "", "date": collection_dates[index] if index < len(collection_dates) else "", "amount": collection_amounts[index] if index < len(collection_amounts) else "", } ) filtered_rows: list[dict[str, Any]] = [] for row in rows: normalized_row = {key: clean_row_text(value) for key, value in row.items()} amount = normalize_amount(normalized_row.get("amount")) billed_amount = normalize_amount(normalized_row.get("billed_amount")) has_other_value = any( value for key, value in normalized_row.items() if key not in {"amount", "billed_amount"} ) if amount or billed_amount or has_other_value: normalized_row["amount"] = amount normalized_row["billed_amount"] = billed_amount filtered_rows.append(normalized_row) for row in filtered_rows: normalized_fields = normalize_collection_entry_fields(row) row["progress_type"] = normalized_fields["progress_type"] row["billing_type"] = normalized_fields["billing_type"] row["billing_round"] = normalize_round_value(row.get("billing_round")) row["round"] = normalize_round_value(row.get("round")) row["billing_date"] = normalize_date_text(row.get("billing_date")) row["date"] = normalize_date_text(row.get("date")) row["billed_amount"] = normalize_amount(row.get("billed_amount")) return filtered_rows def build_project_status_payload(payload: dict[str, Any]) -> dict[str, Any]: contract_amount = normalize_amount(payload.get("contract_amount")) collection_rows = build_collection_rows(payload) collection_amount = sum_row_amounts(collection_rows) progress_rate = (collection_amount / contract_amount * 100) if contract_amount else 0.0 task_plan_department_rows = build_triplet_amount_rows( payload.get("task_plan_department_dept[]", []), payload.get("task_plan_department_work[]", []), payload.get("task_plan_department_amount[]", []), first_key="dept_name", second_key="work_name", ) for row in task_plan_department_rows: row["group"] = "department" task_plan_outsource_rows = build_triplet_amount_rows( payload.get("task_plan_outsource_dept[]", []), payload.get("task_plan_outsource_work[]", []), payload.get("task_plan_outsource_amount[]", []), first_key="dept_name", second_key="work_name", ) for row in task_plan_outsource_rows: row["group"] = "outsource" task_plan_joint_rows = build_triplet_amount_rows( payload.get("task_plan_joint_dept[]", []), payload.get("task_plan_joint_work[]", []), payload.get("task_plan_joint_amount[]", []), first_key="dept_name", second_key="work_name", ) for row in task_plan_joint_rows: row["group"] = "joint" task_plan_rows = task_plan_department_rows + task_plan_outsource_rows + task_plan_joint_rows exec_labor_grades = payload.get("exec_labor_grade[]", []) exec_labor_hours = payload.get("exec_labor_hours[]", []) exec_labor_amounts = payload.get("exec_labor_amount[]", []) exec_labor_rate_years = payload.get("exec_labor_rate_year[]", []) labor_rates_by_year = _parse_labor_rates_json(payload.get("exec_labor_rates_json")) if not labor_rates_by_year: labor_rates_by_year = _parse_labor_rates_json(get_shared_exec_labor_rates_json()) fallback_rate_year = normalize_text(payload.get("year")) or str(datetime.now().year) exec_labor_rows: list[dict[str, Any]] = [] exec_labor_max_length = max( len(exec_labor_grades), len(exec_labor_hours), len(exec_labor_amounts), len(exec_labor_rate_years), ) for index in range(exec_labor_max_length): row = { "grade": exec_labor_grades[index] if index < len(exec_labor_grades) else "", "hours": exec_labor_hours[index] if index < len(exec_labor_hours) else "", "rate_year": exec_labor_rate_years[index] if index < len(exec_labor_rate_years) else "", "amount": exec_labor_amounts[index] if index < len(exec_labor_amounts) else "", } normalized_row = {key: clean_row_text(value) for key, value in row.items()} amount = normalize_amount(normalized_row.get("amount")) has_other_value = any(value for key, value in normalized_row.items() if key != "amount") if amount or has_other_value: hours_value = _parse_exec_hours_value(normalized_row.get("hours")) normalized_row["hours"] = str(int(hours_value)) if hours_value else "" computed_amount = _resolve_labor_rate( labor_rates_by_year, normalized_row.get("grade"), normalized_row.get("rate_year"), fallback_rate_year, payload.get("project_type"), ) * hours_value normalized_row["amount"] = computed_amount if computed_amount else amount normalized_row["group"] = "labor" exec_labor_rows.append(normalized_row) exec_outsource_rows = build_triplet_amount_rows( payload.get("exec_outsource_dept[]", []), payload.get("exec_outsource_work[]", []), payload.get("exec_outsource_amount[]", []), first_key="dept_name", second_key="work_name", ) for row in exec_outsource_rows: row["group"] = "outsource" exec_cost_plan_rows = build_triplet_amount_rows( payload.get("exec_cost_plan_code[]", []), payload.get("exec_cost_plan_name[]", []), payload.get("exec_cost_plan_amount[]", []), first_key="account_code", second_key="account_name", ) for row in exec_cost_plan_rows: row["group"] = "cost_plan" exec_budget_rows = exec_labor_rows + exec_outsource_rows + exec_cost_plan_rows actual_labor_grades = payload.get("actual_labor_grade[]", []) actual_labor_minutes = payload.get("actual_labor_minutes[]", []) actual_labor_amounts = payload.get("actual_labor_amount[]", []) actual_labor_rate_years = payload.get("actual_labor_rate_year[]", []) actual_labor_rows: list[dict[str, Any]] = [] actual_labor_max_length = max( len(actual_labor_grades), len(actual_labor_minutes), len(actual_labor_amounts), len(actual_labor_rate_years), ) for index in range(actual_labor_max_length): row = { "grade": actual_labor_grades[index] if index < len(actual_labor_grades) else "", "minutes": actual_labor_minutes[index] if index < len(actual_labor_minutes) else "", "amount": actual_labor_amounts[index] if index < len(actual_labor_amounts) else "", "rate_year": actual_labor_rate_years[index] if index < len(actual_labor_rate_years) else "", } normalized_row = {key: clean_row_text(value) for key, value in row.items()} amount = normalize_amount(normalized_row.get("amount")) has_other_value = any( value for key, value in normalized_row.items() if key != "amount" ) if amount or has_other_value: minutes_value = _parse_minutes_value(normalized_row.get("minutes")) normalized_row["minutes"] = str(int(minutes_value)) if minutes_value else "" computed_amount = _resolve_labor_rate( labor_rates_by_year, normalized_row.get("grade"), normalized_row.get("rate_year"), fallback_rate_year, payload.get("project_type"), ) * (minutes_value / 60.0 if minutes_value else 0.0) normalized_row["amount"] = computed_amount if computed_amount else amount actual_labor_rows.append(normalized_row) for row in actual_labor_rows: row["group"] = "labor" actual_labor_adjustment_total = normalize_amount(payload.get("actual_labor_adjustment_total")) actual_labor_adjustment_rows = [] if actual_labor_adjustment_total: actual_labor_adjustment_rows.append( { "group": "labor_adjustment", "label": "인건비 조정", "amount": actual_labor_adjustment_total, } ) actual_as_rows = build_named_amount_rows( payload.get("actual_as_label[]", []), payload.get("actual_as_amount[]", []), label_key="label", amount_key="amount", ) for row in actual_as_rows: row["group"] = "as" actual_labor_joint_rows = build_named_amount_rows( payload.get("actual_labor_joint_label[]", []), payload.get("actual_labor_joint_amount[]", []), label_key="label", amount_key="amount", ) for row in actual_labor_joint_rows: row["group"] = "labor_joint" actual_sga_rows = build_named_amount_rows( payload.get("actual_sga_label[]", []), payload.get("actual_sga_amount[]", []), label_key="label", amount_key="amount", ) for row in actual_sga_rows: row["group"] = "sga" actual_input_rows = actual_labor_rows + actual_labor_adjustment_rows + actual_labor_joint_rows + actual_as_rows + actual_sga_rows if not actual_input_rows: legacy_refs = payload.get("actual_input_ref[]", []) legacy_amounts = payload.get("actual_input_amount[]", []) legacy_notes = payload.get("actual_input_note[]", []) for index, ref in enumerate(legacy_refs): actual_input_rows.append( { "reference": ref, "amount": legacy_amounts[index] if index < len(legacy_amounts) else "", "note": legacy_notes[index] if index < len(legacy_notes) else "", } ) actual_input_rows = filter_amount_rows(actual_input_rows, amount_key="amount") has_expected_as_rate = normalize_text(payload.get("expected_as_rate")) != "" has_expected_sga_rate = normalize_text(payload.get("expected_sga_rate")) != "" expected_as_rate = round_percentage_rate(payload.get("expected_as_rate")) if has_expected_as_rate else 0 expected_sga_rate = round_percentage_rate(payload.get("expected_sga_rate")) if has_expected_sga_rate else 0 expected_as_cost = normalize_amount(payload.get("expected_as_cost")) expected_sga_budget = normalize_amount(payload.get("expected_sga_budget")) if has_expected_as_rate and contract_amount: expected_as_cost = contract_amount * expected_as_rate / 100 if has_expected_sga_rate and contract_amount: expected_sga_budget = contract_amount * expected_sga_rate / 100 exec_labor_rates = normalize_text(payload.get("exec_labor_rates_json")) or "{}" return { "support_dept_code": normalize_text(payload.get("support_dept_code")), "support_dept_name": normalize_text(payload.get("support_dept_name")), "progress_rate": progress_rate, "contract_amount": contract_amount, "collection_amount": collection_amount, "collection_entries_json": encode_json_rows(collection_rows), "change_round": normalize_text(payload.get("change_round")), "item_investment": sum_row_amounts(actual_input_rows), "task_plan_department_budget": sum_row_amounts(task_plan_department_rows), "task_plan_outsource_budget": sum_row_amounts(task_plan_outsource_rows), "task_plan_outsource_detail": "\n".join( f"{normalize_text(row.get('dept_name'))} / {normalize_text(row.get('work_name'))}: {format_amount_for_text(row.get('amount'))}".strip(" /:") for row in task_plan_outsource_rows ), "task_plan_joint_operating_cost": sum_row_amounts(task_plan_joint_rows), "task_plan_entries_json": encode_json_rows(task_plan_rows), "exec_budget_labor_by_grade": sum_row_amounts(exec_labor_rows), "exec_labor_rates_json": exec_labor_rates, "exec_budget_outsource": sum_row_amounts(exec_outsource_rows), "exec_budget_cost_plan": sum_row_amounts(exec_cost_plan_rows), "exec_budget_entries_json": encode_json_rows(exec_budget_rows), "actual_input_entries_json": encode_json_rows(actual_input_rows), "project_type": normalize_text(payload.get("project_type")), "expected_as_rate": expected_as_rate, "expected_sga_rate": expected_sga_rate, "expected_as_cost": expected_as_cost, "expected_sga_budget": expected_sga_budget, "last_editor_session_id": normalize_text(payload.get("client_session_id")), "last_client_submitted_at": normalize_text(payload.get("client_submitted_at")), "project_start_date": normalize_date_text(payload.get("project_start_date")), "project_end_date": normalize_date_text(payload.get("project_end_date")), "completion_status": normalize_text(payload.get("completion_status")), "notes": normalize_text(payload.get("notes")), "_collection_rows": collection_rows, "_task_plan_rows": task_plan_rows, "_exec_budget_rows": exec_budget_rows, "_actual_input_rows": actual_input_rows, } def project_status_payload_has_meaningful_data(payload: dict[str, Any]) -> bool: if normalize_amount(payload.get("contract_amount")): return True if normalize_amount(payload.get("collection_amount")): return True if normalize_amount(payload.get("task_plan_department_budget")): return True if normalize_amount(payload.get("task_plan_outsource_budget")): return True if normalize_amount(payload.get("task_plan_joint_operating_cost")): return True if normalize_amount(payload.get("exec_budget_labor_by_grade")): return True if normalize_amount(payload.get("exec_budget_outsource")): return True if normalize_amount(payload.get("exec_budget_cost_plan")): return True if normalize_amount(payload.get("item_investment")): return True for key in ( "project_type", "project_start_date", "project_end_date", "completion_status", "notes", "change_round", "support_dept_name", "task_plan_outsource_detail", ): if normalize_text(payload.get(key)): return True for key in ( "collection_entries_json", "task_plan_entries_json", "exec_budget_entries_json", "actual_input_entries_json", ): if decode_json_rows(payload.get(key)): return True return False def format_amount_for_text(value: Any) -> str: amount = normalize_amount(value) return f"{amount:,.0f}" def save_project_status(payload: dict[str, Any]) -> None: normalized_payload = build_project_status_payload(payload) support_dept_code = normalize_text(normalized_payload.get("support_dept_code")) if not support_dept_code: return started_at = time.perf_counter() session_id = normalize_text(payload.get("client_session_id")) collection_rows = normalized_payload.pop("_collection_rows", []) task_plan_rows = normalized_payload.pop("_task_plan_rows", []) exec_budget_rows = normalized_payload.pop("_exec_budget_rows", []) actual_input_rows = normalized_payload.pop("_actual_input_rows", []) previous_snapshot: dict[str, Any] = {} basic_info_field_keys = ( "support_dept_name", "contract_amount", "project_type", "expected_as_rate", "expected_sga_rate", "expected_as_cost", "expected_sga_budget", "exec_labor_rates_json", "change_round", "project_start_date", "project_end_date", "completion_status", "notes", "last_editor_session_id", "last_client_submitted_at", ) collection_field_keys = ( "collection_progress_type[]", "collection_billing_round[]", "collection_billing_type[]", "collection_billing_date[]", "collection_billed_amount[]", "collection_round[]", "collection_date[]", "collection_amount_row[]", ) task_plan_field_keys = ( "task_plan_department_dept[]", "task_plan_department_work[]", "task_plan_department_amount[]", "task_plan_outsource_dept[]", "task_plan_outsource_work[]", "task_plan_outsource_amount[]", "task_plan_joint_dept[]", "task_plan_joint_work[]", "task_plan_joint_amount[]", ) exec_budget_field_keys = ( "exec_labor_grade[]", "exec_labor_hours[]", "exec_labor_rate_year[]", "exec_labor_amount[]", "exec_outsource_dept[]", "exec_outsource_work[]", "exec_outsource_amount[]", "exec_cost_plan_code[]", "exec_cost_plan_name[]", "exec_cost_plan_amount[]", ) actual_input_field_keys = ( "actual_labor_grade[]", "actual_labor_minutes[]", "actual_labor_amount[]", "actual_labor_rate_year[]", "actual_labor_adjustment_total", "actual_labor_joint_label[]", "actual_labor_joint_amount[]", "actual_as_label[]", "actual_as_amount[]", "actual_sga_label[]", "actual_sga_amount[]", ) def payload_has_any(keys: tuple[str, ...]) -> bool: return any(key in payload for key in keys) with engine.begin() as conn: previous_snapshot = load_project_status_snapshot_payload(conn, support_dept_code) existing_row = conn.execute( text("SELECT * FROM project_status WHERE support_dept_code = :support_dept_code"), {"support_dept_code": support_dept_code}, ).mappings().first() if existing_row: existing_payload = dict(existing_row) if ( project_status_payload_has_meaningful_data(existing_payload) and not project_status_payload_has_meaningful_data(normalized_payload) ): raise ValueError("기존 입력값을 불러오지 않은 빈 상태로는 저장할 수 없습니다.") existing_basic_info = conn.execute( text("SELECT * FROM project_basic_info WHERE support_dept_code = :support_dept_code"), {"support_dept_code": support_dept_code}, ).mappings().first() existing_entry_set = load_project_status_entries_for_code(conn, support_dept_code) if existing_basic_info: for key in basic_info_field_keys: if key not in payload: normalized_payload[key] = existing_basic_info.get(key) if not payload_has_any(collection_field_keys): collection_rows = existing_entry_set["collection_entries"] if not payload_has_any(task_plan_field_keys): task_plan_rows = existing_entry_set["task_plan_entries"] else: existing_task_rows = existing_entry_set["task_plan_entries"] if not payload_has_any(("task_plan_department_dept[]", "task_plan_department_work[]", "task_plan_department_amount[]")): task_plan_rows.extend(row for row in existing_task_rows if normalize_text(row.get("group")) == "department") if not payload_has_any(("task_plan_outsource_dept[]", "task_plan_outsource_work[]", "task_plan_outsource_amount[]")): task_plan_rows.extend(row for row in existing_task_rows if normalize_text(row.get("group")) == "outsource") if not payload_has_any(("task_plan_joint_dept[]", "task_plan_joint_work[]", "task_plan_joint_amount[]")): task_plan_rows.extend(row for row in existing_task_rows if normalize_text(row.get("group")) == "joint") if not payload_has_any(exec_budget_field_keys): exec_budget_rows = existing_entry_set["exec_budget_entries"] else: existing_exec_rows = existing_entry_set["exec_budget_entries"] if not payload_has_any(("exec_labor_grade[]", "exec_labor_hours[]", "exec_labor_rate_year[]", "exec_labor_amount[]")): exec_budget_rows.extend(row for row in existing_exec_rows if normalize_text(row.get("group")) == "labor") if not payload_has_any(("exec_outsource_dept[]", "exec_outsource_work[]", "exec_outsource_amount[]")): exec_budget_rows.extend(row for row in existing_exec_rows if normalize_text(row.get("group")) == "outsource") if not payload_has_any(("exec_cost_plan_code[]", "exec_cost_plan_name[]", "exec_cost_plan_amount[]")): exec_budget_rows.extend(row for row in existing_exec_rows if normalize_text(row.get("group")) == "cost_plan") if not payload_has_any(actual_input_field_keys): actual_input_rows = existing_entry_set["actual_input_entries"] else: existing_actual_rows = existing_entry_set["actual_input_entries"] if not payload_has_any(("actual_labor_grade[]", "actual_labor_minutes[]", "actual_labor_amount[]", "actual_labor_rate_year[]")): actual_input_rows.extend(row for row in existing_actual_rows if normalize_text(row.get("group")) == "labor") if "actual_labor_adjustment_total" not in payload: actual_input_rows.extend(row for row in existing_actual_rows if normalize_text(row.get("group")) == "labor_adjustment") if not payload_has_any(("actual_labor_joint_label[]", "actual_labor_joint_amount[]")): actual_input_rows.extend(row for row in existing_actual_rows if normalize_text(row.get("group")) == "labor_joint") if not payload_has_any(("actual_as_label[]", "actual_as_amount[]")): actual_input_rows.extend(row for row in existing_actual_rows if normalize_text(row.get("group")) == "as") if not payload_has_any(("actual_sga_label[]", "actual_sga_amount[]")): actual_input_rows.extend(row for row in existing_actual_rows if normalize_text(row.get("group")) == "sga") normalized_payload["collection_amount"] = sum_row_amounts(collection_rows) normalized_payload["progress_rate"] = ( normalized_payload["collection_amount"] / normalize_amount(normalized_payload.get("contract_amount")) * 100 if normalize_amount(normalized_payload.get("contract_amount")) else 0.0 ) task_plan_department_rows = [row for row in task_plan_rows if normalize_text(row.get("group")) == "department"] task_plan_outsource_rows = [row for row in task_plan_rows if normalize_text(row.get("group")) == "outsource"] task_plan_joint_rows = [row for row in task_plan_rows if normalize_text(row.get("group")) == "joint"] exec_labor_rows = [row for row in exec_budget_rows if normalize_text(row.get("group")) == "labor"] exec_outsource_rows = [row for row in exec_budget_rows if normalize_text(row.get("group")) == "outsource"] exec_cost_plan_rows = [row for row in exec_budget_rows if normalize_text(row.get("group")) == "cost_plan"] normalized_payload["collection_entries_json"] = encode_json_rows(collection_rows) normalized_payload["task_plan_department_budget"] = sum_row_amounts(task_plan_department_rows) normalized_payload["task_plan_outsource_budget"] = sum_row_amounts(task_plan_outsource_rows) normalized_payload["task_plan_outsource_detail"] = "\n".join( f"{normalize_text(row.get('dept_name'))} / {normalize_text(row.get('work_name'))}: {format_amount_for_text(row.get('amount'))}".strip(" /:") for row in task_plan_outsource_rows ) normalized_payload["task_plan_joint_operating_cost"] = sum_row_amounts(task_plan_joint_rows) normalized_payload["task_plan_entries_json"] = encode_json_rows(task_plan_rows) normalized_payload["exec_budget_labor_by_grade"] = sum_row_amounts(exec_labor_rows) normalized_payload["exec_budget_outsource"] = sum_row_amounts(exec_outsource_rows) normalized_payload["exec_budget_cost_plan"] = sum_row_amounts(exec_cost_plan_rows) normalized_payload["exec_budget_entries_json"] = encode_json_rows(exec_budget_rows) normalized_payload["item_investment"] = sum_row_amounts(actual_input_rows) normalized_payload["actual_input_entries_json"] = encode_json_rows(actual_input_rows) check_record_revision( conn, "project_status", "support_dept_code", support_dept_code, normalize_text(payload.get("edit_revision")), ) save_project_basic_info_section(conn, normalized_payload) replace_project_status_child_entries( conn, support_dept_code, collection_rows, task_plan_rows, exec_budget_rows, actual_input_rows, ) sync_project_status_cache_row(conn, support_dept_code) next_snapshot = get_project_status_for_edit(support_dept_code) duration_ms = int((time.perf_counter() - started_at) * 1000) record_project_status_snapshot(support_dept_code, session_id, previous_snapshot, next_snapshot) log_save_event( "project_status_save", "project_status", support_dept_code, session_id=session_id, duration_ms=duration_ms, payload={ "support_dept_name": normalize_text(next_snapshot.get("support_dept_name")), "selected_revision": normalize_text(payload.get("edit_revision")), "saved_revision": normalize_text(next_snapshot.get("updated_at")), "collection_count": len(collection_rows), "task_plan_count": len(task_plan_rows), "exec_budget_count": len(exec_budget_rows), "actual_input_count": len(actual_input_rows), "save_scope": normalize_text(payload.get("save_scope")) or "all", }, ) maybe_create_database_backup("project_status_save", session_id) def base_context(request: Request, message: str = "") -> dict[str, Any]: health_payload = build_health_payload() current_user = getattr(request.state, "current_user", None) permission_set = set((current_user or {}).get("permissions") or []) if current_user and current_user.get("is_admin"): permission_set.update(item["permission"] for item in AUTH_NAV_ITEMS) nav_items = [ item for item in AUTH_NAV_ITEMS if current_user and (current_user.get("is_admin") or item["permission"] in permission_set) ] return { "request": request, "message": message, "current_user": current_user, "nav_items": nav_items, "data_version": health_payload["data_version"], "server_time": health_payload["server_time"], "import_sync_summary": get_import_sync_summary(), } WEHAGO_BENEFIT_DEFAULT_START_YEAR = 2023 WEHAGO_BENEFIT_DEFAULT_END_YEAR = 2025 WEHAGO_BENEFIT_CATEGORIES = ["급여성비용", "공통복지비", "부서/현장 운영비", "임원 개인성 비용", "경조사", "식대", "운동비", "기타"] WEHAGO_BENEFIT_EXPORT_COLUMNS = [ ("year", "연도"), ("ledger_date", "일자"), ("voucher_no", "전표번호"), ("account_group", "구분"), ("category", "분류"), ("person_name", "개인/귀속"), ("hanmac_member_grade", "직급"), ("account_name", "계정"), ("vendor_name", "거래처"), ("debit", "차변금액"), ("credit", "대변금액"), ("net_amount", "순금액"), ("amount", "금액"), ("description", "적요"), ("counterpart_details", "상대계정"), ("basis", "분류근거"), ("classification_review", "직급검토"), ] WEHAGO_EXECUTIVE_GRADES = {"대표", "회장", "부회장", "사장", "부사장", "전무", "상무", "이사"} WEHAGO_CONGRAT_CONDOLENCE_KEYWORDS = ( "경조", "조의", "부의", "부고", "근조", "화환", "축의", "결혼", "장례", "상조", "부친상", "모친상", "빙부", "빙부상", "빙모", "빙모상", "시부상", "시모상", "배우자상", "자녀출산", "아기출산", "출산", "칠순", "팔순", "회갑", "환갑", "돌잔치", ) def _normalize_report_year(value: Any, fallback: int) -> int: try: year = int(value) except Exception: return fallback if year < 2000 or year > 2100: return fallback return year def _normalize_report_bool(value: Any) -> bool: return normalize_text(value).lower() in {"1", "true", "y", "yes", "on", "포함"} def _contains_any_keyword(text_value: str, keywords: tuple[str, ...]) -> str: normalized = normalize_text(text_value).lower() for keyword in keywords: if keyword.lower() in normalized: return keyword return "" def _normalize_wehago_vendor_name(value: Any) -> str: vendor = normalize_text(value) corrections = { "임원/한형과": "임원/한형관", "한형과": "한형관", } return corrections.get(vendor, vendor) def _is_wehago_adjustment_or_reclass_row(row: Mapping[str, Any]) -> bool: text_value = " ".join( [ normalize_text(row.get("account_code")), normalize_text(row.get("account_name")), normalize_text(row.get("vendor_name")), normalize_text(row.get("description")), ] ) return bool(_contains_any_keyword(text_value, ("대체", "원가", "손익"))) def _infer_wehago_person_name(vendor_name: Any, description: Any) -> str: vendor = _normalize_wehago_vendor_name(vendor_name) if "/" in vendor: tail = normalize_text(vendor.rsplit("/", 1)[-1]) if tail: return tail if re.fullmatch(r"[가-힣]{2,5}[A-Za-z]?", vendor): return vendor desc = normalize_text(description) match = re.search(r"(?:대표|사장|부사장|전무|상무|이사|임원)?\s*([가-힣]{2,4}[A-Za-z]?)", desc) if match and any(title in desc for title in ("대표", "사장", "부사장", "전무", "상무", "이사", "임원")): return match.group(1) return vendor or "미지정" def _hanmac_member_grade_lookup_signature() -> str: init_db() with engine.begin() as conn: db_signature = conn.execute( text( """ SELECT COUNT(*) || ':' || COALESCE(MAX(updated_at), '') FROM hanmac_aggregate_query_metrics WHERE view_mode = 'member' """ ) ).scalar() joint_cache_path = BASE_DIR / "static" / "hanmac-joint-members-cache.json" try: stat = joint_cache_path.stat() file_signature = f"{stat.st_mtime_ns}:{stat.st_size}" except Exception: file_signature = "" return hashlib.sha1(f"{db_signature or ''}|{file_signature}".encode("utf-8")).hexdigest() def _load_hanmac_member_grade_lookup() -> dict[str, dict[str, Any]]: return dict(_load_hanmac_member_grade_lookup_cached(_hanmac_member_grade_lookup_signature())) @lru_cache(maxsize=4) def _load_hanmac_member_grade_lookup_cached(_signature: str) -> dict[str, dict[str, Any]]: init_db() with engine.begin() as conn: cache_keys = [ normalize_text(row[0]) for row in conn.execute( text( """ SELECT cache_key FROM hanmac_aggregate_query_metrics WHERE view_mode = 'member' ORDER BY updated_at DESC LIMIT 8 """ ) ).fetchall() if normalize_text(row[0]) ] row_items: list[str] = [] for cache_key in cache_keys: row_items.extend( str(item or "") for item in conn.execute( text( """ SELECT row_json FROM hanmac_aggregate_query_rows WHERE cache_key = :cache_key AND ( row_json LIKE '%member_grade%' OR row_json LIKE '%"grade"%' OR row_json LIKE '%"position"%' OR row_json LIKE '%"rank"%' ) ORDER BY row_index """ ), {"cache_key": cache_key}, ).scalars().all() ) lookup: dict[str, dict[str, Any]] = {} def add_member_row(row: dict[str, Any]) -> None: member_name = normalize_text(row.get("member_name") or row.get("name")) if not member_name: return member_grade = _normalize_labor_grade_name( row.get("member_grade") or row.get("grade") or row.get("position") or row.get("rank") ) member_key = _hanmac_normalize_person_name(member_name) if not member_key: return existing = lookup.get(member_key) if existing and existing.get("member_grade") and not member_grade: return lookup[member_key] = { "member_name": member_name, "member_no": normalize_text(row.get("member_no")), "member_grade": member_grade, "dept_name": normalize_text(row.get("dept_name")), "status": normalize_text(row.get("status")), } for item in row_items: try: row = json.loads(item or "{}") except Exception: continue if isinstance(row, dict): add_member_row(row) joint_cache_path = BASE_DIR / "static" / "hanmac-joint-members-cache.json" try: joint_payload = json.loads(joint_cache_path.read_text(encoding="utf-8")) except Exception: joint_payload = {} if isinstance(joint_payload, dict): payload_items = [] by_key = joint_payload.get("by_key") if isinstance(by_key, dict): payload_items.extend(value for value in by_key.values() if isinstance(value, dict)) latest = joint_payload.get("latest") if isinstance(latest, dict): payload_items.append(latest) for payload_item in payload_items: for row in payload_item.get("joint_members") or []: if isinstance(row, dict): add_member_row(row) return lookup def _lookup_hanmac_member_grade(person_name: Any, member_lookup: dict[str, dict[str, Any]]) -> dict[str, Any]: person_key = _hanmac_normalize_person_name(person_name) if not person_key: return {} return member_lookup.get(person_key) or {} def _is_wehago_executive_grade(member_grade: Any) -> bool: grade = _normalize_labor_grade_name(member_grade) return bool(grade and (grade in WEHAGO_EXECUTIVE_GRADES or any(item in grade for item in WEHAGO_EXECUTIVE_GRADES))) def save_wehago_benefit_category_override(ledger_row_id: Any, category: Any) -> dict[str, Any]: row_id = int(ledger_row_id or 0) normalized_category = normalize_text(category) if row_id <= 0: raise ValueError("전표 행 ID가 올바르지 않습니다.") if normalized_category not in set(WEHAGO_BENEFIT_CATEGORIES): raise ValueError("분류 값이 올바르지 않습니다.") init_db() init_wehago_compare_db(engine) with engine.begin() as conn: exists = conn.execute( text("SELECT 1 FROM wehago_ledger_rows WHERE id = :row_id LIMIT 1"), {"row_id": row_id}, ).scalar() if not exists: raise ValueError("저장할 전표 행을 찾을 수 없습니다.") conn.execute( text( """ INSERT INTO wehago_benefit_category_overrides ( ledger_row_id, category, updated_at ) VALUES ( :ledger_row_id, :category, CURRENT_TIMESTAMP ) ON CONFLICT(ledger_row_id) DO UPDATE SET category = excluded.category, updated_at = CURRENT_TIMESTAMP """ ), {"ledger_row_id": row_id, "category": normalized_category}, ) return {"ok": True, "ledger_row_id": row_id, "category": normalized_category} def _classify_wehago_benefit_row(row: dict[str, Any], member_grade: str = "") -> tuple[str, str, str]: account_code = normalize_text(row.get("account_code")) account_name = normalize_text(row.get("account_name")) vendor_name = _normalize_wehago_vendor_name(row.get("vendor_name")) description = normalize_text(row.get("description")) text_value = " ".join([account_code, account_name, vendor_name, description]) congrat_keyword = _contains_any_keyword(text_value, WEHAGO_CONGRAT_CONDOLENCE_KEYWORDS) if "접대" in account_name or account_code.startswith("813"): account_group = "접대비" rules = ( ("경조사", WEHAGO_CONGRAT_CONDOLENCE_KEYWORDS), ("운동비", ("골프", "운동", "체력", "연습장", "스포츠", "피트니스")), ("식대", ("식대", "식사", "오찬", "만찬", "점심", "저녁", "회식", "음식", "식당", "카페", "커피", "주점")), ) for category, keywords in rules: matched = _contains_any_keyword(text_value, keywords) if matched: return account_group, category, matched return account_group, "기타", "" account_group = "복리후생비" payroll_keyword = _contains_any_keyword(text_value, ("건강보험료", "장기요양보험료", "고용보험료", "산재보험료")) if payroll_keyword: return account_group, "급여성비용", payroll_keyword executive_keyword = _contains_any_keyword(text_value, ("체력", "운동", "헬스", "골프", "골프연습", "연습장", "피트니스")) grade_is_executive = _is_wehago_executive_grade(member_grade) vendor_is_executive = grade_is_executive or vendor_name.startswith("임원/") or _contains_any_keyword(text_value, ("대표", "사장", "부사장", "전무", "상무", "이사")) if congrat_keyword: return account_group, "경조사", congrat_keyword meal_keyword = _contains_any_keyword(text_value, ("식대", "식사", "오찬", "만찬", "점심", "저녁")) if executive_keyword or (vendor_is_executive and meal_keyword): return account_group, "임원 개인성 비용", executive_keyword or meal_keyword or member_grade or "임원" common_keyword = _contains_any_keyword( text_value, ( "전직원", "전 직원", "임직원", "전체", "명절", "선물", "건강보험", "고용보험", "산재", "장기요양", "국민연금", "보험료", "복지", "건강검진", "단체", "창립", ), ) if common_keyword: return account_group, "공통복지비", common_keyword operation_keyword = _contains_any_keyword( text_value, ( "부서", "현장", "회식", "간식", "식대", "식사", "점심", "저녁", "야근", "야식", "합사", "사무실", "회의", "워크샵", "워크숍", "송년회", ), ) if operation_keyword: return account_group, "부서/현장 운영비", operation_keyword return account_group, "기타", "" def _wehago_voucher_key(row: Mapping[str, Any]) -> tuple[int, str, str]: return ( int(row.get("year") or row.get("fiscal_year") or 0), normalize_text(row.get("ledger_date")), normalize_text(row.get("voucher_no")), ) def _format_wehago_counterpart_details(row: Mapping[str, Any], voucher_lines: Sequence[Mapping[str, Any]]) -> str: row_id = int(row.get("ledger_row_id") or 0) lines: list[str] = [] for line in voucher_lines: line_id = int(line.get("ledger_row_id") or 0) if row_id and line_id == row_id: continue debit = normalize_amount(line.get("debit")) credit = normalize_amount(line.get("credit")) side = "차" if debit else "대" amount = debit if debit else credit pieces = [ f"[{side}]", normalize_text(line.get("account_name")) or normalize_text(line.get("account_code")), ] vendor_name = normalize_text(line.get("vendor_name")) if vendor_name: pieces.append(vendor_name) if amount: pieces.append(f"{amount:,.0f}") description = normalize_text(line.get("description")) if description: pieces.append(description) lines.append(" ".join(piece for piece in pieces if piece)) return " / ".join(lines) def _format_wehago_counterpart_tooltip(counterpart_details: Any) -> str: return normalize_text(counterpart_details).replace(" / [", "\n[") def get_wehago_benefit_entertainment_report( start_year: int = WEHAGO_BENEFIT_DEFAULT_START_YEAR, end_year: int = WEHAGO_BENEFIT_DEFAULT_END_YEAR, account_group: str = "all", category: str = "all", person_keyword: str = "", desc_keyword: str = "", include_adjustments: bool = False, limit: int | None = 300, ) -> dict[str, Any]: init_db() init_wehago_compare_db(engine) start_year = _normalize_report_year(start_year, WEHAGO_BENEFIT_DEFAULT_START_YEAR) end_year = _normalize_report_year(end_year, WEHAGO_BENEFIT_DEFAULT_END_YEAR) if start_year > end_year: start_year, end_year = end_year, start_year include_adjustments = bool(include_adjustments) adjustment_sql = "" if not include_adjustments: adjustment_sql = """ AND COALESCE(account_name, '') NOT LIKE '%대체%' AND COALESCE(account_name, '') NOT LIKE '%원가%' AND COALESCE(account_name, '') NOT LIKE '%손익%' AND COALESCE(vendor_name, '') NOT LIKE '%대체%' AND COALESCE(vendor_name, '') NOT LIKE '%원가%' AND COALESCE(vendor_name, '') NOT LIKE '%손익%' AND COALESCE(description, '') NOT LIKE '%대체%' AND COALESCE(description, '') NOT LIKE '%원가%' AND COALESCE(description, '') NOT LIKE '%손익%' """ params = {"start_year": start_year, "end_year": end_year} with engine.begin() as conn: source_rows = conn.execute( text( f""" SELECT id AS ledger_row_id, fiscal_year AS year, COALESCE(ledger_date, '') AS ledger_date, COALESCE(voucher_no, '') AS voucher_no, COALESCE(account_code, '') AS account_code, COALESCE(account_name, '') AS account_name, COALESCE(vendor_name, '') AS vendor_name, COALESCE(debit, 0) AS debit, COALESCE(credit, 0) AS credit, COALESCE(description, '') AS description FROM wehago_ledger_rows WHERE fiscal_year BETWEEN :start_year AND :end_year AND COALESCE(debit, 0) > 0 AND ( COALESCE(account_name, '') LIKE '%복리후생%' OR COALESCE(account_name, '') LIKE '%접대%' OR COALESCE(account_code, '') IN ('611', '811', '813') ) {adjustment_sql} ORDER BY fiscal_year, ledger_date, voucher_no, id """ ), params, ).mappings().all() override_rows = conn.execute( text( """ SELECT ledger_row_id, category FROM wehago_benefit_category_overrides """ ) ).mappings().all() voucher_line_rows: list[Mapping[str, Any]] = [] voucher_keys = sorted({_wehago_voucher_key(row) for row in source_rows if normalize_text(row.get("voucher_no"))}) with engine.begin() as conn: for chunk_start in range(0, len(voucher_keys), 250): chunk = voucher_keys[chunk_start : chunk_start + 250] if not chunk: continue chunk_params: dict[str, Any] = {} values_sql: list[str] = [] for index, (year, ledger_date, voucher_no) in enumerate(chunk): year_key = f"year_{index}" date_key = f"ledger_date_{index}" voucher_key = f"voucher_no_{index}" values_sql.append(f"(:{year_key}, :{date_key}, :{voucher_key})") chunk_params[year_key] = year chunk_params[date_key] = ledger_date chunk_params[voucher_key] = voucher_no voucher_line_rows.extend( conn.execute( text( f""" WITH target_keys(year, ledger_date, voucher_no) AS ( VALUES {", ".join(values_sql)} ) SELECT line.id AS ledger_row_id, line.fiscal_year AS year, COALESCE(line.ledger_date, '') AS ledger_date, COALESCE(line.voucher_no, '') AS voucher_no, COALESCE(line.account_code, '') AS account_code, COALESCE(line.account_name, '') AS account_name, COALESCE(line.vendor_name, '') AS vendor_name, COALESCE(line.debit, 0) AS debit, COALESCE(line.credit, 0) AS credit, COALESCE(line.description, '') AS description FROM wehago_ledger_rows AS line JOIN target_keys AS target ON target.year = line.fiscal_year AND target.ledger_date = COALESCE(line.ledger_date, '') AND target.voucher_no = COALESCE(line.voucher_no, '') ORDER BY line.fiscal_year, line.ledger_date, line.voucher_no, line.id """ ), chunk_params, ).mappings().all() ) voucher_lines_by_key: dict[tuple[int, str, str], list[dict[str, Any]]] = {} for voucher_line in voucher_line_rows: line_item = dict(voucher_line) voucher_lines_by_key.setdefault(_wehago_voucher_key(line_item), []).append(line_item) category_overrides = { int(row["ledger_row_id"]): normalize_text(row["category"]) for row in override_rows if row.get("ledger_row_id") is not None } account_group_filter = normalize_text(account_group) category_filter = normalize_text(category) person_filter = normalize_text(person_keyword).lower() desc_filter = normalize_text(desc_keyword).lower() member_grade_lookup = _load_hanmac_member_grade_lookup() detail_rows: list[dict[str, Any]] = [] for row in source_rows: item = dict(row) item["vendor_name"] = _normalize_wehago_vendor_name(item.get("vendor_name")) person_name = _infer_wehago_person_name(item.get("vendor_name"), item.get("description")) member_record = _lookup_hanmac_member_grade(person_name, member_grade_lookup) member_grade = normalize_text(member_record.get("member_grade")) row_group, row_category, basis = _classify_wehago_benefit_row(item, member_grade=member_grade) ledger_row_id = int(item.get("ledger_row_id") or 0) manual_category = category_overrides.get(ledger_row_id, "") if manual_category: row_category = manual_category basis = "수동저장" if account_group_filter not in {"", "all", "전체"} and row_group != account_group_filter: continue if category_filter not in {"", "all", "전체"} and row_category != category_filter: continue if person_filter and person_filter not in normalize_text(person_name).lower() and person_filter not in normalize_text(item.get("vendor_name")).lower(): continue searchable_desc = " ".join([normalize_text(item.get("description")), normalize_text(item.get("vendor_name")), normalize_text(item.get("account_name"))]).lower() if desc_filter and desc_filter not in searchable_desc: continue debit = normalize_amount(item.get("debit")) credit = normalize_amount(item.get("credit")) if debit <= 0: continue if not include_adjustments and _is_wehago_adjustment_or_reclass_row(item): continue net_amount = debit - credit amount = debit counterpart_details = _format_wehago_counterpart_details(item, voucher_lines_by_key.get(_wehago_voucher_key(item), [])) counterpart_tooltip = _format_wehago_counterpart_tooltip(counterpart_details) classification_review = "" if member_grade: classification_review = f"hanmac DB_external 직급 확인: {member_grade}" if _is_wehago_executive_grade(member_grade) and row_group == "복리후생비": classification_review += " / 임원급 기준 검토" detail_rows.append( { **item, "ledger_row_id": ledger_row_id, "account_group": row_group, "category": row_category, "manual_category": manual_category, "is_manual_category": bool(manual_category), "person_name": person_name, "hanmac_member_no": normalize_text(member_record.get("member_no")), "hanmac_member_grade": member_grade, "hanmac_dept_name": normalize_text(member_record.get("dept_name")), "debit": debit, "credit": credit, "net_amount": net_amount, "amount": amount, "counterpart_details": counterpart_details, "counterpart_tooltip": counterpart_tooltip, "basis": basis or "기타", "classification_review": classification_review, } ) summary_map: dict[tuple[str, str, str], dict[str, Any]] = {} category_map: dict[tuple[str, str], dict[str, Any]] = {} yearly_map: dict[tuple[int, str], dict[str, Any]] = {} category_year_map: dict[tuple[str, str], dict[str, Any]] = {} report_years = list(range(start_year, end_year + 1)) for item in detail_rows: vendor_name = normalize_text(item.get("vendor_name")) or "미지정" person_name = normalize_text(item.get("person_name")) summary_key = (item["account_group"], item["category"], vendor_name) summary = summary_map.setdefault( summary_key, { "account_group": item["account_group"], "category": item["category"], "vendor_name": vendor_name, "person_name": person_name, "person_names": set(), "hanmac_member_grade": item.get("hanmac_member_grade", ""), "amount": 0.0, "debit": 0.0, "credit": 0.0, "row_count": 0, "last_date": "", }, ) if person_name: summary["person_names"].add(person_name) if not summary.get("hanmac_member_grade") and item.get("hanmac_member_grade"): summary["hanmac_member_grade"] = item.get("hanmac_member_grade", "") summary["amount"] += item["amount"] summary["debit"] += item["debit"] summary["credit"] += item["credit"] summary["row_count"] += 1 summary["last_date"] = max(summary["last_date"], normalize_text(item.get("ledger_date"))) category_key = (item["account_group"], item["category"]) category_summary = category_map.setdefault(category_key, {"account_group": item["account_group"], "category": item["category"], "amount": 0.0, "row_count": 0}) category_summary["amount"] += item["amount"] category_summary["row_count"] += 1 category_year_summary = category_year_map.setdefault( category_key, { "account_group": item["account_group"], "category": item["category"], "year_amounts": {year: 0.0 for year in report_years}, "total_amount": 0.0, "row_count": 0, }, ) item_year = int(item.get("year") or 0) if item_year not in category_year_summary["year_amounts"]: category_year_summary["year_amounts"][item_year] = 0.0 category_year_summary["year_amounts"][item_year] += item["amount"] category_year_summary["total_amount"] += item["amount"] category_year_summary["row_count"] += 1 yearly_key = (int(item.get("year") or 0), item["account_group"]) yearly_summary = yearly_map.setdefault(yearly_key, {"year": int(item.get("year") or 0), "account_group": item["account_group"], "amount": 0.0, "row_count": 0}) yearly_summary["amount"] += item["amount"] yearly_summary["row_count"] += 1 summary_rows: list[dict[str, Any]] = [] for row in summary_map.values(): person_names = sorted(name for name in row.pop("person_names", set()) if name) row["person_name"] = ", ".join(person_names[:4]) + (" 외" if len(person_names) > 4 else "") summary_rows.append(row) vendor_total_amounts: dict[str, float] = {} for row in summary_rows: vendor_name = normalize_text(row.get("vendor_name")) or "미지정" vendor_total_amounts[vendor_name] = vendor_total_amounts.get(vendor_name, 0.0) + float(row.get("amount") or 0.0) def summary_sort_key(row: Mapping[str, Any]) -> tuple[float, str, float, str, str]: vendor_name = normalize_text(row.get("vendor_name")) or "미지정" return ( -vendor_total_amounts.get(vendor_name, 0.0), vendor_name, -float(row.get("amount") or 0.0), normalize_text(row.get("account_group")), normalize_text(row.get("category")), ) sorted_summary_rows = sorted(summary_rows, key=summary_sort_key) category_year_summary_rows = sorted( category_year_map.values(), key=lambda row: (normalize_text(row.get("account_group")), -float(row.get("total_amount") or 0), normalize_text(row.get("category"))), ) all_detail_rows = sorted(detail_rows, key=lambda row: (row.get("year") or 0, row.get("ledger_date") or "", row.get("voucher_no") or "")) shown_detail_rows = all_detail_rows if limit is None else all_detail_rows[: max(0, int(limit))] total_amount = sum(row["amount"] for row in all_detail_rows) return { "filters": { "start_year": start_year, "end_year": end_year, "account_group": account_group_filter or "all", "category": category_filter or "all", "person_keyword": normalize_text(person_keyword), "desc_keyword": normalize_text(desc_keyword), "include_adjustments": include_adjustments, }, "total_amount": total_amount, "row_count": len(all_detail_rows), "shown_count": len(shown_detail_rows), "detail_rows": shown_detail_rows, "all_detail_rows": all_detail_rows, "vendor_summary_rows": sorted_summary_rows, "person_summary_rows": sorted_summary_rows, "category_summary_rows": sorted(category_map.values(), key=lambda row: (row["account_group"], -row["amount"], row["category"])), "yearly_summary_rows": sorted(yearly_map.values(), key=lambda row: (row["year"], row["account_group"])), "category_year_summary_rows": category_year_summary_rows, "summary_years": report_years, "category_options": WEHAGO_BENEFIT_CATEGORIES, "account_group_options": ["복리후생비", "접대비"], "hanmac_grade_match_count": sum(1 for row in all_detail_rows if row.get("hanmac_member_grade")), "hanmac_grade_lookup_count": len(member_grade_lookup), } def export_wehago_benefit_entertainment_xlsx(report: dict[str, Any]) -> tuple[str, bytes]: workbook = Workbook() worksheet = workbook.active worksheet.title = "상세" header_fill = PatternFill("solid", fgColor="1F2937") header_font = Font(color="FFFFFF", bold=True) worksheet.append([label for _, label in WEHAGO_BENEFIT_EXPORT_COLUMNS]) for cell in worksheet[1]: cell.fill = header_fill cell.font = header_font cell.alignment = Alignment(horizontal="center") for row in report["all_detail_rows"]: worksheet.append([row.get(field, "") for field, _ in WEHAGO_BENEFIT_EXPORT_COLUMNS]) worksheet.freeze_panes = "A2" worksheet.auto_filter.ref = f"A1:Q{max(len(report['all_detail_rows']) + 1, 1)}" widths = [8, 12, 12, 14, 18, 16, 10, 18, 24, 14, 14, 14, 14, 46, 54, 14, 28] for index, width in enumerate(widths, start=1): worksheet.column_dimensions[get_column_letter(index)].width = width for row in worksheet.iter_rows(min_row=2, min_col=10, max_col=13): for cell in row: cell.number_format = '#,##0' summary_sheet = workbook.create_sheet(title="거래처별 요약") summary_sheet.append(["구분", "분류", "거래처", "개인/귀속", "직급", "금액", "차변금액", "대변금액", "건수", "최근일자"]) for cell in summary_sheet[1]: cell.fill = header_fill cell.font = header_font cell.alignment = Alignment(horizontal="center") for row in report["vendor_summary_rows"]: summary_sheet.append([row.get("account_group"), row.get("category"), row.get("vendor_name"), row.get("person_name"), row.get("hanmac_member_grade"), row.get("amount"), row.get("debit"), row.get("credit"), row.get("row_count"), row.get("last_date")]) summary_sheet.freeze_panes = "A2" for column, width in zip("ABCDEFGHIJ", [14, 18, 24, 18, 10, 14, 14, 14, 10, 12], strict=False): summary_sheet.column_dimensions[column].width = width for row in summary_sheet.iter_rows(min_row=2, min_col=6, max_col=8): for cell in row: cell.number_format = '#,##0' buffer = BytesIO() workbook.save(buffer) filters = report["filters"] file_name = f"wehago_benefit_entertainment_{filters['start_year']}_{filters['end_year']}_{datetime.now().strftime('%Y%m%d_%H%M%S')}.xlsx" return file_name, buffer.getvalue() def ensure_hmbiz_process_db() -> None: if HMBIZ_PROCESS_DB_PATH.exists(): return if not HMBIZ_PROCESS_SEED_DB_PATH.exists(): raise FileNotFoundError("HM-BIZ-PROCESS 초기 DB를 찾을 수 없습니다.") HMBIZ_PROCESS_DB_PATH.parent.mkdir(parents=True, exist_ok=True) shutil.copy2(HMBIZ_PROCESS_SEED_DB_PATH, HMBIZ_PROCESS_DB_PATH) def load_hmbiz_process_flow_data() -> dict[str, Any]: ensure_hmbiz_process_db() with sqlite3.connect(HMBIZ_PROCESS_DB_PATH) as conn: row = conn.execute("SELECT payload FROM app_state WHERE key = 'flow-data'").fetchone() if not row: raise FileNotFoundError("HM-BIZ-PROCESS 진행도 데이터를 찾을 수 없습니다.") payload = json.loads(row[0]) if not isinstance(payload, dict): raise ValueError("HM-BIZ-PROCESS 진행도 데이터 형식이 올바르지 않습니다.") return payload def save_hmbiz_process_flow_data(payload: dict[str, Any]) -> dict[str, Any]: if not isinstance(payload, dict) or not isinstance(payload.get("flowModel"), dict): raise ValueError("저장할 HM-BIZ-PROCESS 진행도 데이터 형식이 올바르지 않습니다.") ensure_hmbiz_process_db() stored_payload = copy.deepcopy(payload) stored_payload["exportedAt"] = datetime.now().astimezone().isoformat() with sqlite3.connect(HMBIZ_PROCESS_DB_PATH) as conn: conn.execute( """ INSERT INTO app_state (key, payload, updated_at) VALUES ('flow-data', :payload, :updated_at) ON CONFLICT(key) DO UPDATE SET payload = excluded.payload, updated_at = excluded.updated_at """, { "payload": json.dumps(stored_payload, ensure_ascii=False, separators=(",", ":")), "updated_at": stored_payload["exportedAt"], }, ) return stored_payload def render_home( request: Request, edit_id: int | None = None, message: str = "", overview_year: int | None = None, ) -> HTMLResponse: init_db() available_years = get_available_years() overview_year = resolve_selected_year(overview_year) context = { **base_context(request, message), "overview": get_overview_stats(overview_year), "overview_selected_year": overview_year, "project_dashboard": get_project_dashboard_summary(overview_year), "available_years": available_years, "dashboard_revenue_metric_options": get_option_items("dashboard_revenue_metrics"), "dashboard_expense_metric_options": get_option_items("dashboard_expense_metrics"), } 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,) cached = _get_deepcopy_ttl_cache_entry( _DASHBOARD_BOOTSTRAP_CACHE, _DASHBOARD_BOOTSTRAP_CACHE_LOCK, cache_key, DASHBOARD_BOOTSTRAP_CACHE_TTL_SECONDS, ) if cached is not None: return cached persistent_cache_key = _json_hash({"overview_year": overview_year or 0}) persistent = _load_system_page_cache("dashboard_bootstrap", persistent_cache_key) if persistent is not None: return _set_deepcopy_ttl_cache_entry( _DASHBOARD_BOOTSTRAP_CACHE, _DASHBOARD_BOOTSTRAP_CACHE_LOCK, cache_key, persistent, ) 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, } return _set_deepcopy_ttl_cache_entry( _DASHBOARD_BOOTSTRAP_CACHE, _DASHBOARD_BOOTSTRAP_CACHE_LOCK, cache_key, payload, ) def render_projects_page( request: Request, edit_code: str | None = None, focus_code: str | None = None, selected_year: int | None = None, message: str = "", ) -> HTMLResponse: init_db() selected_year = resolve_selected_year(selected_year) context = { **base_context(request, message), "project_year_options": get_project_year_options(), "selected_year": selected_year, "project_dashboard": get_project_dashboard_summary(selected_year), "project_monthly_cost_rows": get_business_monthly_summary(), "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), "project_focus_code": normalize_text(focus_code), "project_page_state": get_project_page_state(), "project_related_links": get_project_related_links_map(), "project_uncontracted_classifications": get_project_uncontracted_classification_map(), "support_department_options": get_support_department_options(), "cost_department_options": get_cost_department_options(), "cost_account_options": get_cost_account_options(), "labor_grade_options": get_labor_grade_options(), "expected_as_rate_options": get_expected_as_rate_options(), "expected_sga_rate_options": get_expected_sga_rate_options(), "collection_progress_type_options": get_collection_progress_type_options(), "collection_billing_type_options": get_collection_billing_type_options(), "uncontracted_category_options": get_uncontracted_category_options(), "special_x_classification_rules": get_special_x_classification_rules(), "project_runtime_settings": get_project_runtime_settings(), "default_exec_labor_rates": copy.deepcopy(DEFAULT_EXEC_LABOR_RATES), } return templates.TemplateResponse(request, "projects.html", context) def get_projects_bootstrap_payload(selected_year: int | None = None) -> dict[str, Any]: cache_scope = "all-project-cost-v3-slim-status" cache_key = (selected_year or 0, cache_scope) cached = _get_deepcopy_ttl_cache_entry( _PROJECT_BOOTSTRAP_CACHE, _PROJECT_BOOTSTRAP_CACHE_LOCK, cache_key, PROJECT_BOOTSTRAP_CACHE_TTL_SECONDS, ) if cached is not None: return cached persistent_cache_key = _json_hash({"selected_year": selected_year or 0, "project_cost_scope": cache_scope}) persistent = _load_system_page_cache("projects_bootstrap", persistent_cache_key) if persistent is not None: return _set_deepcopy_ttl_cache_entry( _PROJECT_BOOTSTRAP_CACHE, _PROJECT_BOOTSTRAP_CACHE_LOCK, cache_key, persistent, ) payload = { "revenue_mix": get_project_revenue_mix(selected_year), "project_cost_by_year": get_project_cost_by_year(None, all_years=True), "project_status_rows": get_project_status_search_rows(), } return _set_deepcopy_ttl_cache_entry( _PROJECT_BOOTSTRAP_CACHE, _PROJECT_BOOTSTRAP_CACHE_LOCK, cache_key, payload, ) COST_ANALYSIS_LABOR_KEYWORDS = ("급여", "상여", "퇴직급여", "건강보험료", "고용보험료", "산재보험료") COST_ANALYSIS_OUTSOURCE_KEYWORDS = ("기술협력비", "설계외주비", "외주비") COST_ANALYSIS_COMMON_CODES = {"", "ZZZZZZ"} COST_ANALYSIS_TX_DATE_SQL = ( "CASE " "WHEN COALESCE(posting_date, '') GLOB '[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]' THEN posting_date " "WHEN COALESCE(voucher_number, '') GLOB '11-[0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9]-*' " "THEN substr(voucher_number, 4, 4) || '-' || substr(voucher_number, 8, 2) || '-' || substr(voucher_number, 10, 2) " "ELSE '' END" ) def _parse_iso_date(value: Any) -> date | None: text_value = normalize_date_text(value) if not re.match(r"^\d{4}-\d{2}-\d{2}$", text_value): return None try: return datetime.strptime(text_value, "%Y-%m-%d").date() except ValueError: return None def _date_text(value: Any) -> str: parsed = _parse_iso_date(value) return parsed.isoformat() if parsed else "" def _iter_year_slices(start_date: date, end_date: date) -> list[dict[str, Any]]: slices: list[dict[str, Any]] = [] current = start_date while current <= end_date: year_start = date(current.year, 1, 1) year_end = date(current.year, 12, 31) slice_start = max(current, year_start) slice_end = min(end_date, year_end) slices.append( { "year": current.year, "start": slice_start, "end": slice_end, "days": (slice_end - slice_start).days + 1, "year_days": (year_end - year_start).days + 1, } ) current = slice_end + timedelta(days=1) return slices @lru_cache(maxsize=64) def _cost_analysis_get_accumulation_start(end_date_text: str) -> date: fallback = date(2021, 1, 1) with engine.begin() as conn: row = conn.execute( text( f""" SELECT MIN(source_date) AS first_date FROM ( SELECT {COST_ANALYSIS_TX_DATE_SQL} AS source_date FROM transactions WHERE {COST_ANALYSIS_TX_DATE_SQL} <> '' AND {COST_ANALYSIS_TX_DATE_SQL} <= :end_date UNION ALL SELECT COALESCE(COALESCE(tax_invoice_date, billing_date), '') AS source_date FROM project_billing_entries WHERE COALESCE(COALESCE(tax_invoice_date, billing_date), '') <> '' AND COALESCE(COALESCE(tax_invoice_date, billing_date), '') <= :end_date UNION ALL SELECT COALESCE(date, '') AS source_date FROM project_collection_entries WHERE COALESCE(date, '') <> '' AND COALESCE(date, '') <= :end_date ) """ ), {"end_date": end_date_text}, ).mappings().first() first_date = _parse_iso_date((row or {}).get("first_date")) return first_date or fallback def _cost_analysis_project_type(code: str, fallback: Any = "") -> str: normalized_fallback = normalize_text(fallback) if normalized_fallback: return normalized_fallback normalized_code = normalize_text(code).upper() if normalized_code.startswith("Y"): return "설계" if normalized_code.startswith("Z"): return "감리" if normalized_code.startswith("X"): return "사업전" return "기타" def _cost_analysis_financial_bucket(account_code: Any) -> str: code = normalize_text(account_code) if code.startswith("4"): return "revenue" if code.startswith("5"): return "cost" if code.startswith("6"): return "sga" return "other" def _cost_analysis_expense_item(account_code: Any, account_name: Any, is_sales_cost: bool) -> str: if is_sales_cost: return "sales" bucket = _cost_analysis_financial_bucket(account_code) if bucket == "sga": return "sga" name = normalize_text(account_name) if any(keyword in name for keyword in COST_ANALYSIS_LABOR_KEYWORDS): return "labor" if any(keyword in name for keyword in COST_ANALYSIS_OUTSOURCE_KEYWORDS): return "outsource" return "overhead" def _cost_analysis_is_sales_cost(row: dict[str, Any]) -> bool: for code_key, name_key in ( ("issuing_dept_code", "issuing_dept_name"), ("support_dept_code", "support_dept_name"), ("cost_dept_code", "cost_dept_name"), ): if normalize_text(row.get(code_key)).upper() == "A0100": return True if "임원실" in normalize_text(row.get(name_key)): return True return False def _cost_analysis_empty_phase_totals() -> dict[str, dict[str, float]]: return { "pre": {"labor": 0.0, "outsource": 0.0, "overhead": 0.0, "sga": 0.0, "sales": 0.0}, "during": {"labor": 0.0, "outsource": 0.0, "overhead": 0.0, "sga": 0.0, "sales": 0.0}, "post": {"labor": 0.0, "outsource": 0.0, "overhead": 0.0, "sga": 0.0, "sales": 0.0}, } def _cost_analysis_get_project_meta() -> dict[str, dict[str, Any]]: billing_summary = get_project_billing_summary_map() latest_summary_by_title, latest_round_by_code, representative_by_title, title_by_code = get_project_contract_change_maps() with engine.begin() as conn: rows = conn.execute( text( """ WITH code_universe AS ( SELECT DISTINCT support_dept_code FROM transactions WHERE COALESCE(support_dept_code, '') <> '' UNION SELECT DISTINCT support_dept_code FROM project_status WHERE COALESCE(support_dept_code, '') <> '' UNION SELECT DISTINCT support_dept_code FROM project_basic_info WHERE COALESCE(support_dept_code, '') <> '' UNION SELECT DISTINCT support_dept_code FROM project_contract_info WHERE COALESCE(support_dept_code, '') <> '' UNION SELECT DISTINCT support_dept_code FROM project_billing_entries WHERE COALESCE(support_dept_code, '') <> '' UNION SELECT DISTINCT support_dept_code FROM project_contract_change_round WHERE COALESCE(support_dept_code, '') <> '' ) SELECT COALESCE(u.support_dept_code, '') AS support_dept_code, COALESCE(c.support_dept_name, p.support_dept_name, b.support_dept_name, tx.support_dept_name, '') AS support_dept_name, COALESCE(c.owner_department, '') AS pm_department, COALESCE(p.project_type, b.project_type, '') AS project_type, COALESCE(p.completion_status, b.completion_status, '') AS completion_status, COALESCE(p.project_start_date, b.project_start_date, '') AS project_start_date, COALESCE(p.project_end_date, b.project_end_date, '') AS project_end_date, COALESCE(p.contract_amount, b.contract_amount, c.hanmac_contract_amount, 0) AS status_contract_amount, COALESCE(c.hanmac_contract_amount, 0) AS hanmac_contract_amount, COALESCE(tx.first_posting_date, '') AS first_posting_date, COALESCE(bill.first_billing_date, '') AS first_billing_date, COALESCE(chg.first_change_date, '') AS first_change_date FROM code_universe AS u LEFT JOIN project_contract_info AS c ON c.support_dept_code = u.support_dept_code LEFT JOIN project_status AS p ON p.support_dept_code = u.support_dept_code LEFT JOIN project_basic_info AS b ON b.support_dept_code = u.support_dept_code LEFT JOIN ( SELECT support_dept_code, MAX(COALESCE(support_dept_name, '')) AS support_dept_name, MIN(NULLIF(posting_date, '')) AS first_posting_date FROM transactions GROUP BY support_dept_code ) AS tx ON tx.support_dept_code = u.support_dept_code LEFT JOIN ( SELECT support_dept_code, MIN(NULLIF(COALESCE(tax_invoice_date, billing_date), '')) AS first_billing_date FROM project_billing_entries GROUP BY support_dept_code ) AS bill ON bill.support_dept_code = u.support_dept_code LEFT JOIN ( SELECT support_dept_code, MIN(NULLIF(change_date, '')) AS first_change_date FROM project_contract_change_round GROUP BY support_dept_code ) AS chg ON chg.support_dept_code = u.support_dept_code """ ) ).mappings().all() result: dict[str, dict[str, Any]] = {} for row in rows: code = normalize_text(row.get("support_dept_code")) if not code: continue billing_row = billing_summary.get(code, {}) title_key = title_by_code.get(code) or normalize_project_title_for_linking(row.get("support_dept_name")) or normalize_project_title_for_linking(billing_row.get("support_dept_name")) latest_summary_change = latest_summary_by_title.get(title_key) or {} latest_round_change = latest_round_by_code.get(code) or {} representative_code = representative_by_title.get(title_key, "") latest_round_amount = normalize_amount(latest_round_change.get("changed_contract_amount")) latest_summary_amount = normalize_amount(latest_summary_change.get("changed_contract_amount")) contract_amount = ( normalize_amount(row.get("status_contract_amount")) or normalize_amount(billing_row.get("contract_amount")) or normalize_amount(row.get("hanmac_contract_amount")) or latest_round_amount or (latest_summary_amount if (not representative_code or representative_code == code) else 0) ) if latest_round_amount and latest_round_amount > contract_amount: contract_amount = latest_round_amount if latest_summary_amount and (not representative_code or representative_code == code) and latest_summary_amount > contract_amount: contract_amount = latest_summary_amount explicit_start_date = _date_text(row.get("project_start_date")) fallback_start_date = _date_text(row.get("first_change_date")) or _date_text(row.get("first_billing_date")) or _date_text(row.get("first_posting_date")) result[code] = { "support_dept_code": code, "support_dept_name": normalize_text(row.get("support_dept_name")) or normalize_text(billing_row.get("support_dept_name")) or normalize_text(latest_summary_change.get("support_dept_name")) or normalize_text(latest_round_change.get("support_dept_name")) or code, "pm_department": normalize_text(row.get("pm_department")) or normalize_text(billing_row.get("support_department")) or normalize_text(latest_summary_change.get("owner_department")) or normalize_text(latest_round_change.get("owner_department")), "project_type": _cost_analysis_project_type(code, row.get("project_type") or latest_summary_change.get("business_division") or latest_round_change.get("business_division")), "completion_status": normalize_text(row.get("completion_status")) or normalize_text(latest_summary_change.get("progress_status")), "project_start_date": explicit_start_date or fallback_start_date, "project_start_date_source": "기본정보" if explicit_start_date else ("계약변경/청구/전표" if fallback_start_date else ""), "project_end_date": _date_text(row.get("project_end_date")) or _date_text(latest_summary_change.get("changed_project_end_date")) or _date_text(latest_round_change.get("changed_project_end_date")), "contract_amount": contract_amount, "contract_source": "변경계약 차수" if latest_round_amount and abs(contract_amount - latest_round_amount) < 0.5 else ("변경계약 총괄" if latest_summary_amount and abs(contract_amount - latest_summary_amount) < 0.5 else "계약/청구"), } return result def _cost_analysis_get_x_links() -> dict[str, list[str]]: with engine.begin() as conn: rows = conn.execute( text( """ SELECT base_support_dept_code, related_support_dept_code FROM project_related_links WHERE COALESCE(base_support_dept_code, '') <> '' AND COALESCE(related_support_dept_code, '') <> '' """ ) ).mappings().all() result: dict[str, list[str]] = {} for row in rows: base = normalize_text(row.get("base_support_dept_code")).upper() related = normalize_text(row.get("related_support_dept_code")).upper() if base.startswith("X") and not related.startswith("X"): result.setdefault(related, []).append(base) if related.startswith("X") and not base.startswith("X"): result.setdefault(base, []).append(related) return {code: sorted(set(values)) for code, values in result.items()} def _cost_analysis_get_xyz_code_map() -> dict[str, str]: with engine.begin() as conn: rows = conn.execute( text( """ SELECT base_support_dept_code, related_support_dept_code FROM project_related_links WHERE COALESCE(base_support_dept_code, '') <> '' AND COALESCE(related_support_dept_code, '') <> '' """ ) ).mappings().all() candidates: dict[str, set[str]] = {} for row in rows: base = normalize_text(row.get("base_support_dept_code")).upper() related = normalize_text(row.get("related_support_dept_code")).upper() for source, target in ((base, related), (related, base)): if not source or source[:1] not in {"0", "9"}: continue if target[:1] in {"X", "Y", "Z"}: candidates.setdefault(source, set()).add(target) def representative_rank(code: str) -> tuple[int, int, str]: prefix = code[:1] if prefix in {"Y", "Z"}: priority = 0 elif prefix == "X": priority = 1 else: priority = 2 return (priority, -(_extract_year_from_project_code(code) or 0), code) return { source: sorted(targets, key=representative_rank)[0] for source, targets in candidates.items() if targets } def _cost_analysis_get_completion_billing_dates() -> dict[str, str]: result: dict[str, str] = {} with engine.begin() as conn: rows = conn.execute( text( """ SELECT support_dept_code, progress_type, billing_type, billing_date FROM project_collection_entries WHERE COALESCE(support_dept_code, '') <> '' UNION ALL SELECT support_dept_code, '' AS progress_type, billing_type, billing_date FROM project_billing_entries WHERE COALESCE(support_dept_code, '') <> '' """ ) ).mappings().all() for row in rows: code = normalize_text(row.get("support_dept_code")) if not code: continue progress_type = normalize_collection_progress_type(row.get("progress_type")) or normalize_collection_progress_type(row.get("billing_type")) billing_type = normalize_collection_billing_type(row.get("billing_type")) if progress_type != "준공금" and billing_type != "준공금": continue billing_date = _date_text(row.get("billing_date")) if not billing_date: continue if code not in result or billing_date < result[code]: result[code] = billing_date return result def _clear_cost_analysis_payload_caches() -> None: with _COST_ANALYSIS_PAYLOAD_CACHE_LOCK: _COST_ANALYSIS_PAYLOAD_CACHE.clear() _cost_analysis_load_hanmac_member_rows_for_cache.cache_clear() with engine.begin() as conn: conn.execute(text("DELETE FROM system_page_cache WHERE page_key = 'cost_analysis_payload'")) def _cost_analysis_hanmac_cache_version() -> str: try: with engine.begin() as conn: row = conn.execute( text( """ SELECT COUNT(*) AS row_count, COALESCE(MAX(updated_at), '') AS updated_at, COALESCE(MAX(rowid), 0) AS max_rowid FROM hanmac_aggregate_query_metrics WHERE view_mode = 'member' """ ) ).mappings().first() except Exception: return "" if not row: return "" raw = "|".join([str(row.get("row_count") or 0), str(row.get("updated_at") or ""), str(row.get("max_rowid") or 0)]) return hashlib.sha1(raw.encode("utf-8")).hexdigest() def _cost_analysis_metric_has_member_grade(cache_key: Any) -> bool: normalized_key = normalize_text(cache_key) if not normalized_key: return False with engine.begin() as conn: row_json = conn.execute( text( """ SELECT row_json FROM hanmac_aggregate_query_rows WHERE cache_key = :cache_key AND row_json LIKE '%member_grade%' LIMIT 1 """ ), {"cache_key": normalized_key}, ).scalar() if not row_json: return False try: row = json.loads(str(row_json or "{}")) except Exception: return False return bool(_normalize_labor_grade_name(row.get("member_grade") or row.get("grade") or row.get("position") or row.get("rank"))) def _cost_analysis_select_hanmac_member_metric( start_date: date, end_date: date, prefer_member_grade: bool = False, ) -> dict[str, Any] | None: with engine.begin() as conn: candidates = conn.execute( text( """ SELECT cache_key, start_date, end_date, updated_at FROM hanmac_aggregate_query_metrics WHERE view_mode = 'member' AND COALESCE(start_date, '') <= :start_date AND COALESCE(end_date, '') >= :end_date ORDER BY CASE WHEN start_date = :start_date AND end_date = :end_date THEN 0 ELSE 1 END, updated_at DESC LIMIT 10 """ ), {"start_date": start_date.isoformat(), "end_date": end_date.isoformat()}, ).mappings().all() if not candidates: candidates = conn.execute( text( """ SELECT cache_key, start_date, end_date, updated_at FROM hanmac_aggregate_query_metrics WHERE view_mode = 'member' AND COALESCE(start_date, '') <= :end_date AND COALESCE(end_date, '') >= :start_date ORDER BY CASE WHEN COALESCE(start_date, '') <= :start_date AND COALESCE(end_date, '') >= :end_date THEN 0 ELSE 1 END, start_date ASC, updated_at DESC LIMIT 10 """ ), {"start_date": start_date.isoformat(), "end_date": end_date.isoformat()}, ).mappings().all() if not candidates: return None candidate_dicts = [dict(candidate) for candidate in candidates] if prefer_member_grade: for candidate in candidate_dicts: if _cost_analysis_metric_has_member_grade(candidate.get("cache_key")): return candidate return candidate_dicts[0] @lru_cache(maxsize=8) def _cost_analysis_load_hanmac_member_rows_for_cache(cache_key: str) -> tuple[dict[str, Any], ...]: normalized_key = normalize_text(cache_key) if not normalized_key: return tuple() with engine.begin() as conn: row_items = conn.execute( text( """ SELECT row_json FROM hanmac_aggregate_query_rows WHERE cache_key = :cache_key ORDER BY row_index """ ), {"cache_key": normalized_key}, ).scalars().all() rows: list[dict[str, Any]] = [] for item in row_items: try: row = json.loads(str(item or "{}")) except Exception: continue if isinstance(row, dict): rows.append(row) return tuple(rows) def _cost_analysis_load_hanmac_member_rows( start_date: date, end_date: date, prefer_member_grade: bool = False, ) -> tuple[dict[str, Any] | None, tuple[dict[str, Any], ...]]: metric = _cost_analysis_select_hanmac_member_metric(start_date, end_date, prefer_member_grade) if not metric: return None, tuple() return metric, _cost_analysis_load_hanmac_member_rows_for_cache(normalize_text(metric.get("cache_key"))) def _cost_analysis_metric_covers_range(metric: dict[str, Any] | None, start_date: date, end_date: date) -> bool: if not metric: return False metric_start = _parse_iso_date(metric.get("start_date")) metric_end = _parse_iso_date(metric.get("end_date")) return bool(metric_start and metric_end and metric_start <= start_date and metric_end >= end_date) def _cost_analysis_select_hanmac_prefix_metric( start_date: date, end_date: date, prefer_member_grade: bool = False, ) -> dict[str, Any] | None: with engine.begin() as conn: candidates = conn.execute( text( """ SELECT cache_key, start_date, end_date, updated_at FROM hanmac_aggregate_query_metrics WHERE view_mode = 'member' AND COALESCE(start_date, '') <= :start_date AND COALESCE(end_date, '') >= :start_date AND COALESCE(end_date, '') <= :end_date ORDER BY end_date DESC, updated_at DESC LIMIT 10 """ ), {"start_date": start_date.isoformat(), "end_date": end_date.isoformat()}, ).mappings().all() candidate_dicts = [dict(candidate) for candidate in candidates] if prefer_member_grade: for candidate in candidate_dicts: if _cost_analysis_metric_has_member_grade(candidate.get("cache_key")): return candidate return candidate_dicts[0] if candidate_dicts else None def _cost_analysis_load_hanmac_member_row_items( start_date: date, end_date: date, prefer_member_grade: bool = False, ) -> tuple[dict[str, Any] | None, list[str]]: metric = _cost_analysis_select_hanmac_member_metric(start_date, end_date, prefer_member_grade) if not metric: return None, [] with engine.begin() as conn: row_items = conn.execute( text( """ SELECT row_json FROM hanmac_aggregate_query_rows WHERE cache_key = :cache_key ORDER BY row_index """ ), {"cache_key": metric["cache_key"]}, ).scalars().all() return metric, list(row_items) def _cost_analysis_load_hanmac_labor_map( start_date: date, end_date: date, project_meta: dict[str, dict[str, Any]], allowed_codes: set[str] | None = None, ) -> dict[str, dict[str, float]]: by_year = _cost_analysis_load_hanmac_labor_map_by_year(start_date, end_date, project_meta, allowed_codes) result: dict[str, dict[str, float]] = {} for code_map in by_year.values(): for code, phase_amounts in code_map.items(): target = result.setdefault(code, {"pre": 0.0, "during": 0.0, "post": 0.0}) for phase, amount in phase_amounts.items(): if phase in target: target[phase] += normalize_amount(amount) return result def _cost_analysis_load_hanmac_labor_map_by_year( start_date: date, end_date: date, project_meta: dict[str, dict[str, Any]], allowed_codes: set[str] | None = None, ) -> dict[int, dict[str, dict[str, float]]]: alias_to_code, title_to_codes = _cost_analysis_build_hanmac_matchers(project_meta) metric, row_items = _cost_analysis_load_hanmac_member_rows(start_date, end_date, prefer_member_grade=True) if not metric: return {} rates_by_year = _parse_labor_rates_json(get_shared_exec_labor_rates_json()) if not rates_by_year: rates_by_year = _parse_labor_rates_json(json.dumps(DEFAULT_EXEC_LABOR_RATES, ensure_ascii=False)) result: dict[int, dict[str, dict[str, float]]] = {} completion_dates = _cost_analysis_get_completion_billing_dates() resolve_cache: dict[tuple[str, str, str], list[str]] = {} def add_amount(project: dict[str, Any], work_date_text: Any, member_grade: str, hours: float) -> None: if hours <= 0: return work_date = _parse_iso_date(work_date_text) if work_date and (work_date < start_date or work_date > end_date): return resolve_key = ( normalize_text(project.get("project_code")).upper(), "|".join(normalize_text(value).upper() for value in (project.get("equivalent_project_codes") or [])), f"{normalize_project_title_for_linking(project.get('project_name'))}|{(work_date or start_date).isoformat()}", ) if resolve_key in resolve_cache: codes = resolve_cache[resolve_key] else: codes = _cost_analysis_resolve_hanmac_project_codes(project, work_date, alias_to_code, title_to_codes, project_meta) resolve_cache[resolve_key] = codes if not codes: return year_text = str((work_date or start_date).year) split_hours = hours / len(codes) for code in codes: normalized_code = normalize_text(code).upper() if allowed_codes is not None and normalized_code not in allowed_codes: continue rate = _resolve_labor_rate( rates_by_year, member_grade, year_text, year_text, (project_meta.get(normalized_code) or {}).get("project_type"), ) if rate <= 0: continue phase = _cost_analysis_phase_for_transaction( normalized_code, (work_date or start_date).isoformat(), completion_dates, ) result.setdefault((work_date or start_date).year, {}).setdefault( normalized_code, {"pre": 0.0, "during": 0.0, "post": 0.0}, )[phase] += rate * split_hours for row in row_items: member_grade = _normalize_labor_grade_name( row.get("member_grade") or row.get("grade") or row.get("position") or row.get("rank") ) if not member_grade: continue details = row.get("aggregate_details") if isinstance(row.get("aggregate_details"), dict) else {} for detail in details.get("regular_hours") or []: projects = detail.get("projects") if isinstance(detail.get("projects"), list) else [] if not projects: continue raw_total = sum(normalize_amount(project.get("hours")) for project in projects) recognized_total = normalize_amount(detail.get("regular_hours")) for project in projects: raw_hours = normalize_amount(project.get("hours")) hours = recognized_total * raw_hours / raw_total if raw_total > 0 and recognized_total > 0 else raw_hours add_amount(project, detail.get("work_date"), member_grade, hours) for detail in details.get("overtime_hours") or []: add_amount(detail, detail.get("work_date"), member_grade, normalize_amount(detail.get("overtime_hours"))) for detail in details.get("holiday_hours") or []: projects = detail.get("projects") if isinstance(detail.get("projects"), list) else [] if projects: raw_total = sum(normalize_amount(project.get("hours")) for project in projects) recognized_total = normalize_amount(detail.get("holiday_hours")) for project in projects: raw_hours = normalize_amount(project.get("hours")) hours = recognized_total * raw_hours / raw_total if raw_total > 0 and recognized_total > 0 else raw_hours add_amount(project, detail.get("work_date"), member_grade, hours) else: add_amount(detail, detail.get("work_date"), member_grade, normalize_amount(detail.get("holiday_hours"))) return result def _cost_analysis_load_hanmac_labor_map_yearly( start_date: date, end_date: date, project_meta: dict[str, dict[str, Any]], allowed_codes: set[str] | None = None, ) -> dict[str, dict[str, float]]: result: dict[str, dict[str, float]] = {} prefix_metric = _cost_analysis_select_hanmac_prefix_metric(start_date, end_date, prefer_member_grade=True) prefix_end = _parse_iso_date((prefix_metric or {}).get("end_date")) if prefix_metric and prefix_end and prefix_end >= start_date: by_year = _cost_analysis_load_hanmac_labor_map_by_year(start_date, min(prefix_end, end_date), project_meta, allowed_codes) for slice_map in by_year.values(): for code, phase_amounts in slice_map.items(): target = result.setdefault(code, {"pre": 0.0, "during": 0.0, "post": 0.0}) for phase, amount in phase_amounts.items(): if phase in target: target[phase] += normalize_amount(amount) start_date = min(prefix_end, end_date) + timedelta(days=1) if start_date > end_date: return result for year_slice in _iter_year_slices(start_date, end_date): slice_map = _cost_analysis_load_hanmac_labor_map( year_slice["start"], year_slice["end"], project_meta, allowed_codes, ) for code, phase_amounts in slice_map.items(): target = result.setdefault(code, {"pre": 0.0, "during": 0.0, "post": 0.0}) for phase, amount in phase_amounts.items(): if phase in target: target[phase] += normalize_amount(amount) return result def _cost_analysis_load_hanmac_labor_detail_rows( start_date: date, end_date: date, requested_codes: list[str], requested_phase: str, project_meta: dict[str, dict[str, Any]], ) -> list[dict[str, Any]]: normalized_requested_codes = {normalize_text(code).upper() for code in requested_codes if normalize_text(code)} normalized_requested_codes -= COST_ANALYSIS_COMMON_CODES if not normalized_requested_codes: return [] alias_to_code, title_to_codes = _cost_analysis_build_hanmac_matchers(project_meta) metric, row_items = _cost_analysis_load_hanmac_member_row_items(start_date, end_date, prefer_member_grade=True) if not metric: return [] rates_by_year = _parse_labor_rates_json(get_shared_exec_labor_rates_json()) if not rates_by_year: rates_by_year = _parse_labor_rates_json(json.dumps(DEFAULT_EXEC_LABOR_RATES, ensure_ascii=False)) completion_dates = _cost_analysis_get_completion_billing_dates() normalized_phase = normalize_text(requested_phase).lower() detail_by_member: dict[tuple[str, str, str], dict[str, Any]] = {} grade_order = { grade: index for index, grade in enumerate(("회장", "부회장", "사장", "부사장", "전무", "상무", "이사", "부장", "차장", "과장", "대리", "사원")) } def add_hours( source_row: dict[str, Any], project: dict[str, Any], work_date_text: Any, member_grade: str, hours: float, hour_kind: str, ) -> None: if hours <= 0: return work_date = _parse_iso_date(work_date_text) if work_date and (work_date < start_date or work_date > end_date): return codes = _cost_analysis_resolve_hanmac_project_codes(project, work_date, alias_to_code, title_to_codes, project_meta) if not codes: return year_text = str((work_date or start_date).year) split_hours = hours / len(codes) for code in codes: normalized_code = normalize_text(code).upper() if normalized_code not in normalized_requested_codes: continue phase = _cost_analysis_phase_for_transaction( normalized_code, (work_date or start_date).isoformat(), completion_dates, ) if normalized_phase and normalized_phase != "all" and phase != normalized_phase: continue rate = _resolve_labor_rate( rates_by_year, member_grade, year_text, year_text, (project_meta.get(normalized_code) or {}).get("project_type"), ) if rate <= 0: continue member_no = normalize_text(source_row.get("member_no")) member_name = normalize_text(source_row.get("member_name")) key = (member_no, member_name, member_grade) detail = detail_by_member.setdefault( key, { "member_no": member_no, "dept_name": normalize_text(source_row.get("dept_name")), "member_name": member_name, "member_grade": member_grade, "regular_hours": 0.0, "overtime_hours": 0.0, "holiday_hours": 0.0, "amount": 0.0, }, ) detail[f"{hour_kind}_hours"] += split_hours detail["amount"] += rate * split_hours for item in row_items: try: row = json.loads(str(item or "{}")) except Exception: continue if not isinstance(row, dict): continue member_grade = _normalize_labor_grade_name( row.get("member_grade") or row.get("grade") or row.get("position") or row.get("rank") ) if not member_grade: continue details = row.get("aggregate_details") if isinstance(row.get("aggregate_details"), dict) else {} for detail in details.get("regular_hours") or []: projects = detail.get("projects") if isinstance(detail.get("projects"), list) else [] if not projects: continue raw_total = sum(normalize_amount(project.get("hours")) for project in projects) recognized_total = normalize_amount(detail.get("regular_hours")) for project in projects: raw_hours = normalize_amount(project.get("hours")) hours = recognized_total * raw_hours / raw_total if raw_total > 0 and recognized_total > 0 else raw_hours add_hours(row, project, detail.get("work_date"), member_grade, hours, "regular") for detail in details.get("overtime_hours") or []: add_hours(row, detail, detail.get("work_date"), member_grade, normalize_amount(detail.get("overtime_hours")), "overtime") for detail in details.get("holiday_hours") or []: projects = detail.get("projects") if isinstance(detail.get("projects"), list) else [] if projects: raw_total = sum(normalize_amount(project.get("hours")) for project in projects) recognized_total = normalize_amount(detail.get("holiday_hours")) for project in projects: raw_hours = normalize_amount(project.get("hours")) hours = recognized_total * raw_hours / raw_total if raw_total > 0 and recognized_total > 0 else raw_hours add_hours(row, project, detail.get("work_date"), member_grade, hours, "holiday") else: add_hours(row, detail, detail.get("work_date"), member_grade, normalize_amount(detail.get("holiday_hours")), "holiday") rows = [] for detail in detail_by_member.values(): regular_hours = normalize_amount(detail.get("regular_hours")) overtime_hours = normalize_amount(detail.get("overtime_hours")) holiday_hours = normalize_amount(detail.get("holiday_hours")) detail["total_hours"] = regular_hours + overtime_hours + holiday_hours detail["extra_hours"] = overtime_hours + holiday_hours detail["amount"] = int(round(normalize_amount(detail.get("amount")))) rows.append(detail) return sorted( rows, key=lambda item: ( normalize_text(item.get("dept_name")), grade_order.get(normalize_text(item.get("member_grade")), len(grade_order)), normalize_text(item.get("member_no")), ), ) def _cost_analysis_load_hanmac_labor_detail_rows_yearly( start_date: date, end_date: date, requested_codes: list[str], requested_phase: str, project_meta: dict[str, dict[str, Any]], ) -> list[dict[str, Any]]: merged: dict[tuple[str, str, str], dict[str, Any]] = {} for year_slice in _iter_year_slices(start_date, end_date): for row in _cost_analysis_load_hanmac_labor_detail_rows( year_slice["start"], year_slice["end"], requested_codes, requested_phase, project_meta, ): key = ( normalize_text(row.get("member_no")), normalize_text(row.get("member_name")), normalize_text(row.get("member_grade")), ) detail = merged.setdefault( key, { "member_no": normalize_text(row.get("member_no")), "dept_name": normalize_text(row.get("dept_name")), "member_name": normalize_text(row.get("member_name")), "member_grade": normalize_text(row.get("member_grade")), "regular_hours": 0.0, "overtime_hours": 0.0, "holiday_hours": 0.0, "amount": 0.0, }, ) for field in ("regular_hours", "overtime_hours", "holiday_hours", "amount"): detail[field] += normalize_amount(row.get(field)) rows = [] for detail in merged.values(): regular_hours = normalize_amount(detail.get("regular_hours")) overtime_hours = normalize_amount(detail.get("overtime_hours")) holiday_hours = normalize_amount(detail.get("holiday_hours")) detail["total_hours"] = regular_hours + overtime_hours + holiday_hours detail["extra_hours"] = overtime_hours + holiday_hours detail["amount"] = int(round(normalize_amount(detail.get("amount")))) rows.append(detail) return sorted( rows, key=lambda item: ( normalize_text(item.get("dept_name")), normalize_text(item.get("member_grade")), normalize_text(item.get("member_no")), ), ) def _cost_analysis_collect_missing_hanmac_grade_rows( start_date: date, end_date: date, project_meta: dict[str, dict[str, Any]], allowed_codes: set[str] | None = None, ) -> list[dict[str, Any]]: alias_to_code, title_to_codes = _cost_analysis_build_hanmac_matchers(project_meta) metric, row_items = _cost_analysis_load_hanmac_member_row_items(start_date, end_date, prefer_member_grade=True) if not metric: return [] completion_dates = _cost_analysis_get_completion_billing_dates() result_rows: list[dict[str, Any]] = [] metric_range = f"{metric['start_date']}~{metric['end_date']}" def add_missing_grade_row( source_row: dict[str, Any], project: dict[str, Any], work_date_text: Any, hours: float, hour_kind: str, ) -> None: if hours <= 0: return work_date = _parse_iso_date(work_date_text) if work_date and (work_date < start_date or work_date > end_date): return raw_grade = ( source_row.get("member_grade") or source_row.get("grade") or source_row.get("position") or source_row.get("rank") ) if _normalize_labor_grade_name(raw_grade): return codes = _cost_analysis_resolve_hanmac_project_codes(project, work_date, alias_to_code, title_to_codes, project_meta) if not codes: return split_hours = hours / len(codes) effective_date = work_date or start_date for code in codes: normalized_code = normalize_text(code).upper() if normalized_code in COST_ANALYSIS_COMMON_CODES: continue if allowed_codes is not None and normalized_code not in allowed_codes: continue meta = project_meta.get(normalized_code) or {} phase = _cost_analysis_phase_for_transaction(normalized_code, effective_date.isoformat(), completion_dates) result_rows.append( { "work_date": effective_date.isoformat(), "phase": {"pre": "사업전", "during": "사업중", "post": "사업후"}.get(phase, phase), "support_dept_code": normalized_code, "project_name": normalize_text(meta.get("support_dept_name")) or normalized_code, "member_no": normalize_text(source_row.get("member_no")), "member_name": normalize_text(source_row.get("member_name")), "dept_name": normalize_text(source_row.get("dept_name")), "raw_grade": normalize_text(raw_grade), "hour_kind": {"regular": "정규", "overtime": "연장", "holiday": "휴일"}.get(hour_kind, hour_kind), "hours": round(split_hours, 4), "source_project_code": normalize_text(project.get("project_code")), "source_project_name": normalize_text(project.get("project_name")), "metric_range": metric_range, "metric_cache_key": normalize_text(metric.get("cache_key")), } ) for item in row_items: try: row = json.loads(str(item or "{}")) except Exception: continue if not isinstance(row, dict): continue details = row.get("aggregate_details") if isinstance(row.get("aggregate_details"), dict) else {} for detail in details.get("regular_hours") or []: projects = detail.get("projects") if isinstance(detail.get("projects"), list) else [] if not projects: continue raw_total = sum(normalize_amount(project.get("hours")) for project in projects) recognized_total = normalize_amount(detail.get("regular_hours")) for project in projects: raw_hours = normalize_amount(project.get("hours")) hours = recognized_total * raw_hours / raw_total if raw_total > 0 and recognized_total > 0 else raw_hours add_missing_grade_row(row, project, detail.get("work_date"), hours, "regular") for detail in details.get("overtime_hours") or []: add_missing_grade_row(row, detail, detail.get("work_date"), normalize_amount(detail.get("overtime_hours")), "overtime") for detail in details.get("holiday_hours") or []: projects = detail.get("projects") if isinstance(detail.get("projects"), list) else [] if projects: raw_total = sum(normalize_amount(project.get("hours")) for project in projects) recognized_total = normalize_amount(detail.get("holiday_hours")) for project in projects: raw_hours = normalize_amount(project.get("hours")) hours = recognized_total * raw_hours / raw_total if raw_total > 0 and recognized_total > 0 else raw_hours add_missing_grade_row(row, project, detail.get("work_date"), hours, "holiday") else: add_missing_grade_row(row, detail, detail.get("work_date"), normalize_amount(detail.get("holiday_hours")), "holiday") return sorted( result_rows, key=lambda item: ( normalize_text(item.get("support_dept_code")), normalize_text(item.get("work_date")), normalize_text(item.get("member_name")), normalize_text(item.get("hour_kind")), ), ) def _cost_analysis_collect_missing_hanmac_grade_rows_yearly( start_date: date, end_date: date, project_meta: dict[str, dict[str, Any]], allowed_codes: set[str] | None = None, ) -> list[dict[str, Any]]: rows: list[dict[str, Any]] = [] for year_slice in _iter_year_slices(start_date, end_date): rows.extend( _cost_analysis_collect_missing_hanmac_grade_rows( year_slice["start"], year_slice["end"], project_meta, allowed_codes, ) ) return sorted( rows, key=lambda item: ( normalize_text(item.get("support_dept_code")), normalize_text(item.get("work_date")), normalize_text(item.get("member_name")), normalize_text(item.get("hour_kind")), ), ) def _cost_analysis_build_hanmac_matchers(project_meta: dict[str, dict[str, Any]]) -> tuple[dict[str, str], dict[str, list[str]]]: title_to_codes: dict[str, list[str]] = {} alias_to_code: dict[str, str] = {} xyz_code_map = _cost_analysis_get_xyz_code_map() def alias_rank(code: str) -> tuple[int, int, str]: normalized_code = normalize_text(code).upper() prefix = normalized_code[:1] if prefix in {"Y", "Z"}: priority = 0 elif prefix == "X": priority = 1 elif prefix in {"0", "9"}: priority = 3 else: priority = 2 return (priority, -(_extract_year_from_project_code(normalized_code) or 0), normalized_code) def add_alias(alias: str, code: str) -> None: normalized_alias = normalize_text(alias).upper() normalized_code = normalize_text(code).upper() if not normalized_alias or not normalized_code: return current = alias_to_code.get(normalized_alias) if not current or alias_rank(normalized_code) < alias_rank(current): alias_to_code[normalized_alias] = normalized_code for code, meta in project_meta.items(): normalized_code = normalize_text(code).upper() if not normalized_code or normalized_code in COST_ANALYSIS_COMMON_CODES: continue add_alias(normalized_code, normalized_code) if len(normalized_code) > 1 and normalized_code[0] in {"X", "Y", "Z"}: numeric_alias = normalized_code[1:] if numeric_alias: add_alias(numeric_alias, normalized_code) add_alias(f"0{numeric_alias}", normalized_code) title_key = normalize_project_title_for_linking(meta.get("support_dept_name")) if title_key: title_to_codes.setdefault(title_key, []).append(normalized_code) for source_code, target_code in xyz_code_map.items(): add_alias(source_code, target_code) for title_key, codes in list(title_to_codes.items()): title_to_codes[title_key] = sorted(set(codes)) return alias_to_code, title_to_codes def _cost_analysis_resolve_hanmac_project_codes( project: dict[str, Any], work_date: date | None, alias_to_code: dict[str, str], title_to_codes: dict[str, list[str]], project_meta: dict[str, dict[str, Any]], ) -> list[str]: title_key = normalize_project_title_for_linking(project.get("project_name")) title_candidates = title_to_codes.get(title_key, []) for value in [project.get("project_code"), *(project.get("equivalent_project_codes") or [])]: alias = normalize_text(value).upper() if alias in COST_ANALYSIS_COMMON_CODES: return [] if alias in alias_to_code: resolved_code = alias_to_code[alias] if resolved_code in COST_ANALYSIS_COMMON_CODES: return [] if not title_candidates or _cost_analysis_active_on_date(project_meta.get(resolved_code) or {}, work_date): return [resolved_code] break candidates = title_candidates if not candidates: return [] xyz_candidates = [ code for code in candidates if normalize_text(code).upper().startswith(("X", "Y", "Z")) ] if xyz_candidates: candidates = xyz_candidates if not work_date: return [candidates[-1]] work_year = work_date.year def code_year(code: str) -> int | None: return _extract_year_from_project_code(code) active_on_date_candidates = [ code for code in candidates if _cost_analysis_active_on_date(project_meta.get(code) or {}, work_date) ] if active_on_date_candidates: return [ sorted( active_on_date_candidates, key=lambda code: ( _parse_iso_date((project_meta.get(code) or {}).get("project_start_date")) or date.min, code, ), )[-1] ] year_window_candidates = [ code for code in candidates if (code_year(code) is not None and code_year(code) <= work_year <= code_year(code) + 1) ] if year_window_candidates: return year_window_candidates active_candidates = [ code for code in candidates if _cost_analysis_active_in_year(project_meta.get(code) or {}, work_year) ] if active_candidates: return active_candidates past_candidates = [code for code in candidates if (code_year(code) or 0) <= work_year] return [past_candidates[-1] if past_candidates else candidates[-1]] def _cost_analysis_load_hanmac_project_hours( start_date: date, end_date: date, project_meta: dict[str, dict[str, Any]], allowed_codes: set[str] | None = None, ) -> dict[str, dict[str, float]]: result: dict[str, dict[str, float]] = {} by_year = _cost_analysis_load_hanmac_project_hours_by_year(start_date, end_date, project_meta, allowed_codes) for code_map in by_year.values(): for code, phase_hours in code_map.items(): target = result.setdefault(code, {"pre": 0.0, "during": 0.0, "post": 0.0}) for phase, hours in phase_hours.items(): if phase in target: target[phase] += normalize_amount(hours) return result def _cost_analysis_load_hanmac_project_hours_by_year( start_date: date, end_date: date, project_meta: dict[str, dict[str, Any]], allowed_codes: set[str] | None = None, ) -> dict[int, dict[str, dict[str, float]]]: alias_to_code, title_to_codes = _cost_analysis_build_hanmac_matchers(project_meta) metric, row_items = _cost_analysis_load_hanmac_member_rows(start_date, end_date, prefer_member_grade=True) if not metric: return {} completion_dates = _cost_analysis_get_completion_billing_dates() result: dict[int, dict[str, dict[str, float]]] = {} resolve_cache: dict[tuple[str, str, str], list[str]] = {} def add_hours(project: dict[str, Any], work_date_text: Any, hours: float) -> None: if hours <= 0: return work_date = _parse_iso_date(work_date_text) if work_date and (work_date < start_date or work_date > end_date): return effective_date = work_date or start_date resolve_key = ( normalize_text(project.get("project_code")).upper(), "|".join(normalize_text(value).upper() for value in (project.get("equivalent_project_codes") or [])), f"{normalize_project_title_for_linking(project.get('project_name'))}|{effective_date.isoformat()}", ) if resolve_key in resolve_cache: codes = resolve_cache[resolve_key] else: codes = _cost_analysis_resolve_hanmac_project_codes(project, effective_date, alias_to_code, title_to_codes, project_meta) resolve_cache[resolve_key] = codes if not codes: return split_hours = hours / len(codes) for code in codes: normalized_code = normalize_text(code).upper() if allowed_codes is not None and normalized_code not in allowed_codes: continue phase = _cost_analysis_phase_for_transaction(normalized_code, effective_date.isoformat(), completion_dates) result.setdefault(effective_date.year, {}).setdefault( normalized_code, {"pre": 0.0, "during": 0.0, "post": 0.0}, )[phase] += split_hours for row in row_items: details = row.get("aggregate_details") if isinstance(row.get("aggregate_details"), dict) else {} for detail in details.get("regular_hours") or []: projects = detail.get("projects") if isinstance(detail.get("projects"), list) else [] raw_total = sum(normalize_amount(project.get("hours")) for project in projects) recognized_total = normalize_amount(detail.get("regular_hours")) for project in projects: raw_hours = normalize_amount(project.get("hours")) hours = recognized_total * raw_hours / raw_total if raw_total > 0 and recognized_total > 0 else raw_hours add_hours(project, detail.get("work_date"), hours) for detail in details.get("overtime_hours") or []: add_hours(detail, detail.get("work_date"), normalize_amount(detail.get("overtime_hours"))) for detail in details.get("holiday_hours") or []: projects = detail.get("projects") if isinstance(detail.get("projects"), list) else [] if projects: raw_total = sum(normalize_amount(project.get("hours")) for project in projects) recognized_total = normalize_amount(detail.get("holiday_hours")) for project in projects: raw_hours = normalize_amount(project.get("hours")) hours = recognized_total * raw_hours / raw_total if raw_total > 0 and recognized_total > 0 else raw_hours add_hours(project, detail.get("work_date"), hours) else: add_hours(detail, detail.get("work_date"), normalize_amount(detail.get("holiday_hours"))) return result def _cost_analysis_load_hanmac_hours_and_labor_by_year( start_date: date, end_date: date, project_meta: dict[str, dict[str, Any]], allowed_codes: set[str] | None = None, ) -> tuple[dict[int, dict[str, dict[str, float]]], dict[int, dict[str, dict[str, float]]]]: alias_to_code, title_to_codes = _cost_analysis_build_hanmac_matchers(project_meta) metric, row_items = _cost_analysis_load_hanmac_member_rows(start_date, end_date, prefer_member_grade=True) if not metric: return {}, {} rates_by_year = _parse_labor_rates_json(get_shared_exec_labor_rates_json()) if not rates_by_year: rates_by_year = _parse_labor_rates_json(json.dumps(DEFAULT_EXEC_LABOR_RATES, ensure_ascii=False)) completion_dates = _cost_analysis_get_completion_billing_dates() hours_result: dict[int, dict[str, dict[str, float]]] = {} labor_result: dict[int, dict[str, dict[str, float]]] = {} resolve_cache: dict[tuple[str, str, str], list[str]] = {} def add_project(project: dict[str, Any], work_date_text: Any, hours: float, member_grade: str = "") -> None: if hours <= 0: return work_date = _parse_iso_date(work_date_text) if work_date and (work_date < start_date or work_date > end_date): return effective_date = work_date or start_date resolve_key = ( normalize_text(project.get("project_code")).upper(), "|".join(normalize_text(value).upper() for value in (project.get("equivalent_project_codes") or [])), f"{normalize_project_title_for_linking(project.get('project_name'))}|{effective_date.isoformat()}", ) if resolve_key in resolve_cache: codes = resolve_cache[resolve_key] else: codes = _cost_analysis_resolve_hanmac_project_codes(project, effective_date, alias_to_code, title_to_codes, project_meta) resolve_cache[resolve_key] = codes if not codes: return split_hours = hours / len(codes) year = effective_date.year year_text = str(year) for code in codes: normalized_code = normalize_text(code).upper() if allowed_codes is not None and normalized_code not in allowed_codes: continue phase = _cost_analysis_phase_for_transaction(normalized_code, effective_date.isoformat(), completion_dates) hours_result.setdefault(year, {}).setdefault( normalized_code, {"pre": 0.0, "during": 0.0, "post": 0.0}, )[phase] += split_hours if not member_grade: continue rate = _resolve_labor_rate( rates_by_year, member_grade, year_text, year_text, (project_meta.get(normalized_code) or {}).get("project_type"), ) if rate <= 0: continue labor_result.setdefault(year, {}).setdefault( normalized_code, {"pre": 0.0, "during": 0.0, "post": 0.0}, )[phase] += rate * split_hours for row in row_items: member_grade = _normalize_labor_grade_name( row.get("member_grade") or row.get("grade") or row.get("position") or row.get("rank") ) details = row.get("aggregate_details") if isinstance(row.get("aggregate_details"), dict) else {} for detail in details.get("regular_hours") or []: projects = detail.get("projects") if isinstance(detail.get("projects"), list) else [] raw_total = sum(normalize_amount(project.get("hours")) for project in projects) recognized_total = normalize_amount(detail.get("regular_hours")) for project in projects: raw_hours = normalize_amount(project.get("hours")) hours = recognized_total * raw_hours / raw_total if raw_total > 0 and recognized_total > 0 else raw_hours add_project(project, detail.get("work_date"), hours, member_grade) for detail in details.get("overtime_hours") or []: add_project(detail, detail.get("work_date"), normalize_amount(detail.get("overtime_hours")), member_grade) for detail in details.get("holiday_hours") or []: projects = detail.get("projects") if isinstance(detail.get("projects"), list) else [] if projects: raw_total = sum(normalize_amount(project.get("hours")) for project in projects) recognized_total = normalize_amount(detail.get("holiday_hours")) for project in projects: raw_hours = normalize_amount(project.get("hours")) hours = recognized_total * raw_hours / raw_total if raw_total > 0 and recognized_total > 0 else raw_hours add_project(project, detail.get("work_date"), hours, member_grade) else: add_project(detail, detail.get("work_date"), normalize_amount(detail.get("holiday_hours")), member_grade) return hours_result, labor_result def _cost_analysis_load_hanmac_hours_and_labor_yearly( start_date: date, end_date: date, project_meta: dict[str, dict[str, Any]], allowed_codes: set[str] | None = None, ) -> tuple[dict[int, dict[str, dict[str, float]]], dict[int, dict[str, dict[str, float]]]]: hours_result: dict[int, dict[str, dict[str, float]]] = {} labor_result: dict[int, dict[str, dict[str, float]]] = {} def merge( hours_by_year: dict[int, dict[str, dict[str, float]]], labor_by_year: dict[int, dict[str, dict[str, float]]], ) -> None: for year, code_map in hours_by_year.items(): for code, phase_hours in code_map.items(): target = hours_result.setdefault(year, {}).setdefault(code, {"pre": 0.0, "during": 0.0, "post": 0.0}) for phase, hours in phase_hours.items(): if phase in target: target[phase] += normalize_amount(hours) for year, code_map in labor_by_year.items(): for code, phase_amounts in code_map.items(): target = labor_result.setdefault(year, {}).setdefault(code, {"pre": 0.0, "during": 0.0, "post": 0.0}) for phase, amount in phase_amounts.items(): if phase in target: target[phase] += normalize_amount(amount) prefix_metric = _cost_analysis_select_hanmac_prefix_metric(start_date, end_date, prefer_member_grade=True) prefix_end = _parse_iso_date((prefix_metric or {}).get("end_date")) if prefix_metric and prefix_end and prefix_end >= start_date: merge(*_cost_analysis_load_hanmac_hours_and_labor_by_year( start_date, min(prefix_end, end_date), project_meta, allowed_codes, )) start_date = min(prefix_end, end_date) + timedelta(days=1) if start_date > end_date: return hours_result, labor_result for year_slice in _iter_year_slices(start_date, end_date): merge(*_cost_analysis_load_hanmac_hours_and_labor_by_year( year_slice["start"], year_slice["end"], project_meta, allowed_codes, )) return hours_result, labor_result def _cost_analysis_get_annual_hanmac_total_hours(year: int) -> float: year_start = date(year, 1, 1).isoformat() year_end = date(year, 12, 31).isoformat() with engine.begin() as conn: metric = conn.execute( text( """ SELECT cache_key, summary_json FROM hanmac_aggregate_query_metrics WHERE view_mode = 'member' AND COALESCE(start_date, '') <= :year_start AND COALESCE(end_date, '') >= :year_end ORDER BY CASE WHEN start_date = :year_start AND end_date = :year_end THEN 0 ELSE 1 END, updated_at DESC LIMIT 1 """ ), {"year_start": year_start, "year_end": year_end}, ).mappings().first() if not metric: return 0.0 try: summary = json.loads(metric["summary_json"] or "{}") except Exception: summary = {} total_hours = normalize_amount(summary.get("total_hours")) if total_hours > 0: return total_hours rows = conn.execute( text( """ SELECT row_json FROM hanmac_aggregate_query_rows WHERE cache_key = :cache_key """ ), {"cache_key": metric["cache_key"]}, ).scalars().all() return sum(normalize_amount((json.loads(row) if row else {}).get("total_hours")) for row in rows) def _cost_analysis_phase_for_transaction(code: str, posting_date: str, completion_dates: dict[str, str]) -> str: normalized_code = normalize_text(code).upper() if normalized_code.startswith("X"): return "pre" if normalized_code.startswith(("Y", "Z")): completion_date = completion_dates.get(normalized_code, "") if completion_date and posting_date and posting_date >= completion_date: return "post" return "during" return "during" def _cost_analysis_row_template(code: str, meta: dict[str, Any], selected_year: int | None) -> dict[str, Any]: contract_amount = normalize_amount(meta.get("contract_amount")) return { "support_dept_code": code, "pm_department": normalize_text(meta.get("pm_department")), "year": selected_year or "", "project_name": normalize_text(meta.get("support_dept_name")) or code, "project_type": _cost_analysis_project_type(code, meta.get("project_type")), "completion_status": normalize_text(meta.get("completion_status")), "project_start_date": normalize_text(meta.get("project_start_date")), "project_end_date": normalize_text(meta.get("project_end_date")), "contract_amount": contract_amount, "billing_amount": 0.0, "collection_amount": 0.0, "period_billing_amount": 0.0, "period_collection_amount": 0.0, "period_revenue_amount": 0.0, "period_cost_total": 0.0, "period_sga_total": 0.0, "period_sales_total": 0.0, "period_total_cost": 0.0, "period_profit_amount": 0.0, "period_revenue_profit_rate": 0.0, "period_collection_profit_rate": 0.0, "cumulative_profit_rate": 0.0, "contract_balance_amount": contract_amount, "collection_rate": 0.0, "revenue_amount": 0.0, "phases": _cost_analysis_empty_phase_totals(), "allocated": _cost_analysis_empty_phase_totals(), "direct_codes": [code], "pre_codes": [], "profit_amount": 0.0, "contract_profit_rate": 0.0, "revenue_profit_rate": 0.0, "collection_profit_rate": 0.0, "total_cost": 0.0, "cost_total": 0.0, "sga_total": 0.0, "sales_total": 0.0, } def _cost_analysis_finalize_row(row: dict[str, Any]) -> None: cost_total = 0.0 sga_total = 0.0 sales_total = 0.0 for phase_name, buckets in row["phases"].items(): for item_key, amount in buckets.items(): amount = normalize_amount(amount) if item_key in {"labor", "outsource", "overhead"}: cost_total += amount elif item_key == "sales": sales_total += amount else: sga_total += amount row["cost_total"] = cost_total row["sga_total"] = sga_total row["sales_total"] = sales_total row["total_cost"] = cost_total + sga_total + sales_total row["profit_amount"] = normalize_amount(row.get("revenue_amount")) - row["total_cost"] row["period_revenue_amount"] = normalize_amount(row.get("period_revenue_amount")) row["period_cost_total"] = cost_total row["period_sga_total"] = sga_total row["period_sales_total"] = sales_total row["period_total_cost"] = row["period_cost_total"] + row["period_sga_total"] + row["period_sales_total"] row["period_profit_amount"] = row["period_revenue_amount"] - row["period_total_cost"] row["contract_balance_amount"] = max( normalize_amount(row.get("contract_amount")) - normalize_amount(row.get("collection_amount")), 0.0, ) row["collection_rate"] = _safe_ratio(row.get("collection_amount"), row.get("contract_amount")) row["contract_profit_rate"] = _safe_ratio(row.get("profit_amount"), row.get("contract_amount")) row["revenue_profit_rate"] = _safe_ratio(row.get("profit_amount"), row.get("revenue_amount")) row["collection_profit_rate"] = _safe_ratio(row.get("profit_amount"), row.get("collection_amount")) row["period_revenue_profit_rate"] = _safe_ratio(row.get("period_profit_amount"), row.get("period_revenue_amount")) row["period_collection_profit_rate"] = _safe_ratio(row.get("period_profit_amount"), row.get("period_collection_amount")) row["cumulative_profit_rate"] = _safe_ratio( normalize_amount(row.get("collection_amount")) - row["total_cost"], row.get("collection_amount"), ) def _cost_analysis_active_in_year(meta: dict[str, Any], year: int) -> bool: start_date = _parse_iso_date(meta.get("project_start_date")) end_date = _parse_iso_date(meta.get("project_end_date")) if start_date and start_date.year > year: return False if end_date and end_date.year < year: return False return True def _cost_analysis_active_on_date(meta: dict[str, Any], work_date: date | None) -> bool: if not work_date: return True start_date = _parse_iso_date(meta.get("project_start_date")) end_date = _parse_iso_date(meta.get("project_end_date")) if start_date and start_date > work_date: return False if end_date and end_date < work_date: return False return True def _cost_analysis_project_group_key(code: str, row: dict[str, Any]) -> str: normalized_code = normalize_text(code).upper() project_name_key = normalize_project_title_for_linking(row.get("project_name")) if project_name_key: return project_name_key return normalized_code def _cost_analysis_get_link_representative_map() -> dict[str, str]: with engine.begin() as conn: rows = conn.execute( text( """ SELECT base_support_dept_code, related_support_dept_code FROM project_related_links WHERE COALESCE(base_support_dept_code, '') <> '' AND COALESCE(related_support_dept_code, '') <> '' """ ) ).mappings().all() graph: dict[str, set[str]] = {} for row in rows: base = normalize_text(row.get("base_support_dept_code")).upper() related = normalize_text(row.get("related_support_dept_code")).upper() if not base or not related: continue graph.setdefault(base, set()).add(related) graph.setdefault(related, set()).add(base) representative_map: dict[str, str] = {} seen: set[str] = set() for start_code in sorted(graph): if start_code in seen: continue stack = [start_code] component: set[str] = set() while stack: code = stack.pop() if code in component: continue component.add(code) stack.extend(sorted(graph.get(code, set()) - component)) seen.update(component) total_codes = sorted(code for code in component if code[:1] in {"0", "9"}) if not total_codes: continue representative = total_codes[0] for code in component: representative_map[code] = representative representative_map.update(_cost_analysis_infer_yz_link_representatives(representative_map)) return representative_map def _cost_analysis_infer_yz_link_representatives(existing_representative_map: dict[str, str]) -> dict[str, str]: project_meta = _cost_analysis_get_project_meta() representative_titles: dict[str, set[str]] = {} for code, representative in existing_representative_map.items(): normalized_code = normalize_text(code).upper() normalized_representative = normalize_text(representative).upper() if not normalized_code or not normalized_representative or normalized_code == normalized_representative: continue title = normalize_project_title_for_linking((project_meta.get(normalized_code) or {}).get("support_dept_name")) if title: representative_titles.setdefault(normalized_representative, set()).add(title) for representative, meta in project_meta.items(): normalized_representative = normalize_text(representative).upper() if normalized_representative[:1] not in {"0", "9"}: continue title = normalize_project_title_for_linking(meta.get("support_dept_name")) if title: representative_titles.setdefault(normalized_representative, set()).add(title) inferred: dict[str, str] = {} for code, meta in project_meta.items(): normalized_code = normalize_text(code).upper() if ( not normalized_code or normalized_code in existing_representative_map or normalized_code[:1] not in {"Y", "Z"} ): continue title = normalize_project_title_for_linking(meta.get("support_dept_name")) if not title: continue matches: list[tuple[int, str]] = [] for representative, titles in representative_titles.items(): best_score = 0 for candidate_title in titles: if not candidate_title: continue if title == candidate_title: best_score = max(best_score, 100) elif len(title) >= 8 and len(candidate_title) >= 8 and (title in candidate_title or candidate_title in title): best_score = max(best_score, min(len(title), len(candidate_title))) if best_score >= 8: matches.append((best_score, representative)) if not matches: continue matches.sort(reverse=True) if len(matches) > 1 and matches[0][0] == matches[1][0]: continue inferred[normalized_code] = matches[0][1] return inferred def _cost_analysis_latest_row(rows: list[dict[str, Any]]) -> dict[str, Any]: def sort_key(row: dict[str, Any]) -> tuple[str, str]: code = normalize_text(row.get("support_dept_code")).upper() return (normalize_text(row.get("project_end_date")), code) yz_rows = [row for row in rows if normalize_text(row.get("support_dept_code")).upper().startswith(("Y", "Z"))] return sorted(yz_rows or rows, key=sort_key)[-1] def _cost_analysis_linked_group_key(row: dict[str, Any], representative_map: dict[str, str]) -> str: codes = [ normalize_text(row.get("support_dept_code")).upper(), *[normalize_text(code).upper() for code in (row.get("direct_codes") or [])], *[normalize_text(code).upper() for code in (row.get("aggregate_codes") or [])], ] for code in codes: representative = representative_map.get(code) if representative: return representative return _cost_analysis_project_group_key(row.get("support_dept_code", ""), row) def _cost_analysis_aggregate_rows( rows: list[dict[str, Any]], project_meta: dict[str, dict[str, Any]], representative_map: dict[str, str] | None = None, ) -> list[dict[str, Any]]: representative_map = representative_map or _cost_analysis_get_link_representative_map() grouped: dict[str, list[dict[str, Any]]] = {} for row in rows: grouped.setdefault(_cost_analysis_linked_group_key(row, representative_map), []).append(row) result: list[dict[str, Any]] = [] for group_key, group_rows in grouped.items(): representative_code = normalize_text(group_key).upper() representative_meta = project_meta.get(representative_code) if representative_code[:1] in {"0", "9"} else None latest = _cost_analysis_latest_row(group_rows) aggregate = copy.deepcopy(latest) aggregate["view_mode"] = "aggregate" aggregate["aggregate_project_count"] = len(group_rows) aggregate["aggregate_codes"] = sorted({normalize_text(row.get("support_dept_code")).upper() for row in group_rows if normalize_text(row.get("support_dept_code"))}) aggregate["aggregate_members"] = sorted( [ { "support_dept_code": normalize_text(row.get("support_dept_code")), "project_name": normalize_text(row.get("project_name")), "project_start_date": normalize_text(row.get("project_start_date")), "project_end_date": normalize_text(row.get("project_end_date")), "contract_amount": normalize_amount(row.get("contract_amount")), "billing_amount": normalize_amount(row.get("billing_amount")), "collection_amount": normalize_amount(row.get("collection_amount")), "total_cost": normalize_amount(row.get("total_cost")), } for row in group_rows ], key=lambda item: ( normalize_text(item.get("project_start_date")) or "9999-12-31", normalize_text(item.get("project_end_date")) or "9999-12-31", normalize_text(item.get("support_dept_code")), ), ) if representative_meta: aggregate["support_dept_code"] = representative_code aggregate["project_name"] = normalize_text(representative_meta.get("support_dept_name")) or representative_code aggregate["pm_department"] = normalize_text(representative_meta.get("pm_department")) or normalize_text(aggregate.get("pm_department")) aggregate["project_type"] = _cost_analysis_project_type(representative_code, representative_meta.get("project_type")) aggregate["completion_status"] = normalize_text(representative_meta.get("completion_status")) or normalize_text(aggregate.get("completion_status")) aggregate["direct_codes"] = sorted({ code for row in group_rows for code in [row.get("support_dept_code"), *(row.get("direct_codes") or [])] if normalize_text(code) }) start_dates = [_parse_iso_date(row.get("project_start_date")) for row in group_rows] end_dates = [_parse_iso_date(row.get("project_end_date")) for row in group_rows] aggregate["project_start_date"] = min([value for value in start_dates if value], default=None) aggregate["project_start_date"] = aggregate["project_start_date"].isoformat() if aggregate["project_start_date"] else "" latest_status = normalize_text(latest.get("completion_status")) if "진행" in latest_status: aggregate["project_end_date"] = "" else: latest_end = max([value for value in end_dates if value], default=None) aggregate["project_end_date"] = latest_end.isoformat() if latest_end else "" aggregate["contract_amount"] = sum(normalize_amount(row.get("contract_amount")) for row in group_rows) aggregate["billing_amount"] = sum(normalize_amount(row.get("billing_amount")) for row in group_rows) aggregate["collection_amount"] = sum(normalize_amount(row.get("collection_amount")) for row in group_rows) aggregate["period_billing_amount"] = sum(normalize_amount(row.get("period_billing_amount")) for row in group_rows) aggregate["period_collection_amount"] = sum(normalize_amount(row.get("period_collection_amount")) for row in group_rows) aggregate["period_revenue_amount"] = sum(normalize_amount(row.get("period_revenue_amount") or row.get("revenue_amount")) for row in group_rows) aggregate["contract_balance_amount"] = 0.0 aggregate["revenue_amount"] = sum(normalize_amount(row.get("revenue_amount")) for row in group_rows) aggregate["phases"] = _cost_analysis_empty_phase_totals() aggregate["allocated"] = _cost_analysis_empty_phase_totals() for row in group_rows: for phase, buckets in (row.get("phases") or {}).items(): if phase not in aggregate["phases"]: continue for item_key, amount in buckets.items(): if item_key in aggregate["phases"][phase]: aggregate["phases"][phase][item_key] += normalize_amount(amount) for phase, buckets in (row.get("allocated") or {}).items(): if phase not in aggregate["allocated"]: continue for item_key, amount in buckets.items(): if item_key in aggregate["allocated"][phase]: aggregate["allocated"][phase][item_key] += normalize_amount(amount) aggregate["period_cost_total"] = sum(normalize_amount(row.get("period_cost_total") or row.get("cost_total")) for row in group_rows) aggregate["period_sga_total"] = sum(normalize_amount(row.get("period_sga_total") or row.get("sga_total")) for row in group_rows) aggregate["period_sales_total"] = sum(normalize_amount(row.get("period_sales_total") or row.get("sales_total")) for row in group_rows) aggregate["period_total_cost"] = sum(normalize_amount(row.get("period_total_cost") or row.get("total_cost")) for row in group_rows) aggregate["period_profit_amount"] = aggregate["period_revenue_amount"] - aggregate["period_total_cost"] _cost_analysis_finalize_row(aggregate) result.append(aggregate) return result def _cost_analysis_build_payload(start_date_text: str, end_date_text: str, mode: str = "individual") -> dict[str, Any]: start_date = _parse_iso_date(start_date_text) or date(date.today().year, 1, 1) end_date = _parse_iso_date(end_date_text) or date.today() if end_date < start_date: start_date, end_date = end_date, start_date normalized_mode = normalize_text(mode).lower() cache_key = ( start_date.isoformat(), end_date.isoformat(), "aggregate" if normalized_mode in {"aggregate", "sum", "합산", "연계", "linked", "link"} else "individual", get_business_data_version(), _cost_analysis_hanmac_cache_version(), ) cached_payload = _get_deepcopy_ttl_cache_entry( _COST_ANALYSIS_PAYLOAD_CACHE, _COST_ANALYSIS_PAYLOAD_CACHE_LOCK, cache_key, COST_ANALYSIS_PAYLOAD_CACHE_TTL_SECONDS, ) if cached_payload is not None: return cached_payload persistent_cache_key = _json_hash( { "start_date": start_date.isoformat(), "end_date": end_date.isoformat(), "mode": cache_key[2], "data_version": cache_key[3], "hanmac_cache_version": cache_key[4], "schema": COST_ANALYSIS_HANMAC_AGGREGATE_SCHEMA, } ) persistent_payload = _load_system_page_cache("cost_analysis_payload", persistent_cache_key) if persistent_payload is not None: return _set_deepcopy_ttl_cache_entry( _COST_ANALYSIS_PAYLOAD_CACHE, _COST_ANALYSIS_PAYLOAD_CACHE_LOCK, cache_key, persistent_payload, ) selected_year = start_date.year if start_date.year == end_date.year else None project_meta = _cost_analysis_get_project_meta() x_links = _cost_analysis_get_x_links() xyz_code_map = _cost_analysis_get_xyz_code_map() completion_dates = _cost_analysis_get_completion_billing_dates() rows_by_code: dict[str, dict[str, Any]] = {} x_owner_map: dict[str, str] = {} for owner_code, x_codes in x_links.items(): normalized_owner_code = normalize_text(owner_code).upper() display_owner_code = xyz_code_map.get(normalized_owner_code, normalized_owner_code) for x_code in x_codes: x_owner_map.setdefault(x_code, display_owner_code) with engine.begin() as conn: period_code_rows = conn.execute( text( f""" SELECT DISTINCT support_dept_code FROM transactions WHERE {COST_ANALYSIS_TX_DATE_SQL} >= :start_date AND {COST_ANALYSIS_TX_DATE_SQL} <= :end_date AND (account_code LIKE '4%' OR account_code LIKE '5%' OR account_code LIKE '6%') AND COALESCE(support_dept_code, '') <> '' UNION SELECT DISTINCT support_dept_code FROM project_collection_entries WHERE COALESCE(date, '') >= :start_date AND COALESCE(date, '') <= :end_date AND COALESCE(support_dept_code, '') <> '' UNION SELECT DISTINCT support_dept_code FROM project_billing_entries WHERE COALESCE(COALESCE(tax_invoice_date, billing_date), '') >= :start_date AND COALESCE(COALESCE(tax_invoice_date, billing_date), '') <= :end_date AND COALESCE(support_dept_code, '') <> '' """ ), {"start_date": start_date.isoformat(), "end_date": end_date.isoformat()}, ).mappings().all() visible_candidate_codes: set[str] = set() period_source_codes = {normalize_text(row.get("support_dept_code")).upper() for row in period_code_rows} for source_code in period_source_codes: if not source_code or source_code in COST_ANALYSIS_COMMON_CODES: continue visible_candidate_codes.add(x_owner_map.get(source_code, xyz_code_map.get(source_code, source_code))) annual_hanmac_project_hours_by_year, annual_hanmac_labor_by_year = _cost_analysis_load_hanmac_hours_and_labor_yearly( start_date, end_date, project_meta, None, ) visible_candidate_codes.update( code for code_map in annual_hanmac_project_hours_by_year.values() for code in code_map ) source_candidate_codes: set[str] = set(visible_candidate_codes) for target_code in list(visible_candidate_codes): source_candidate_codes.update(x_links.get(target_code, [])) for source_code, target_code in xyz_code_map.items(): if target_code in visible_candidate_codes: source_candidate_codes.add(source_code) for source_code, target_code in x_owner_map.items(): if target_code in visible_candidate_codes: source_candidate_codes.add(source_code) hanmac_labor_map: dict[str, dict[str, float]] = {} for code_map in annual_hanmac_labor_by_year.values(): for code, phase_amounts in code_map.items(): if visible_candidate_codes and code not in visible_candidate_codes: continue target = hanmac_labor_map.setdefault(code, {"pre": 0.0, "during": 0.0, "post": 0.0}) for phase, amount in phase_amounts.items(): if phase in target: target[phase] += normalize_amount(amount) def ensure_row(code: str) -> dict[str, Any]: normalized_code = normalize_text(code).upper() if normalized_code in COST_ANALYSIS_COMMON_CODES: raise ValueError("공통 코드는 프로젝트 행으로 생성할 수 없습니다.") meta = project_meta.get(normalized_code, {"support_dept_code": normalized_code, "support_dept_name": normalized_code}) if normalized_code not in rows_by_code: rows_by_code[normalized_code] = _cost_analysis_row_template(normalized_code, meta, selected_year) rows_by_code[normalized_code]["pre_codes"] = x_links.get(normalized_code, []) rows_by_code[normalized_code]["direct_codes"] = [normalized_code, *x_links.get(normalized_code, [])] return rows_by_code[normalized_code] with engine.begin() as conn: if source_candidate_codes: candidate_in_clause, candidate_params = build_in_clause("cost_analysis_source_code", sorted(source_candidate_codes)) tx_code_filter = f"AND (UPPER(COALESCE(support_dept_code, '')) IN ({candidate_in_clause}) OR UPPER(COALESCE(support_dept_code, '')) IN ('', 'ZZZZZZ'))" else: candidate_params = {} tx_code_filter = "" tx_rows = conn.execute( text( f""" SELECT COALESCE(voucher_number, '') AS voucher_number, {COST_ANALYSIS_TX_DATE_SQL} AS posting_date, COALESCE(account_code, '') AS account_code, COALESCE(account_name, '') AS account_name, COALESCE(support_dept_code, '') AS support_dept_code, COALESCE(support_dept_name, '') AS support_dept_name, COALESCE(issuing_dept_code, '') AS issuing_dept_code, COALESCE(issuing_dept_name, '') AS issuing_dept_name, COALESCE(cost_dept_code, '') AS cost_dept_code, COALESCE(cost_dept_name, '') AS cost_dept_name, COALESCE(partner_name, '') AS partner_name, COALESCE(memo1, '') AS memo1, COALESCE(amount, 0) AS amount FROM transactions WHERE {COST_ANALYSIS_TX_DATE_SQL} >= :start_date AND {COST_ANALYSIS_TX_DATE_SQL} <= :end_date AND (account_code LIKE '4%' OR account_code LIKE '5%' OR account_code LIKE '6%') {tx_code_filter} """ ), {**candidate_params, "start_date": start_date.isoformat(), "end_date": end_date.isoformat()}, ).mappings().all() collection_rows = conn.execute( text( """ SELECT support_dept_code, SUM(COALESCE(amount, 0)) AS collection_amount, SUM( CASE WHEN COALESCE(date, '') >= :start_date AND COALESCE(date, '') <= :end_date THEN COALESCE(amount, 0) ELSE 0 END ) AS period_collection_amount FROM project_collection_entries WHERE COALESCE(date, '') <= :end_date GROUP BY support_dept_code """ ), {"start_date": start_date.isoformat(), "end_date": end_date.isoformat()}, ).mappings().all() billing_collection_rows = conn.execute( text( """ SELECT support_dept_code, SUM(COALESCE(collected_amount, 0)) AS collection_amount, SUM( CASE WHEN COALESCE(COALESCE(tax_invoice_date, billing_date), '') >= :start_date AND COALESCE(COALESCE(tax_invoice_date, billing_date), '') <= :end_date THEN COALESCE(collected_amount, 0) ELSE 0 END ) AS period_collection_amount FROM project_billing_entries WHERE COALESCE(COALESCE(tax_invoice_date, billing_date), '') <= :end_date GROUP BY support_dept_code """ ), {"start_date": start_date.isoformat(), "end_date": end_date.isoformat()}, ).mappings().all() billing_amount_rows = conn.execute( text( """ SELECT support_dept_code, SUM(COALESCE(billed_amount, 0)) AS billing_amount, SUM( CASE WHEN COALESCE(COALESCE(tax_invoice_date, billing_date), '') >= :start_date AND COALESCE(COALESCE(tax_invoice_date, billing_date), '') <= :end_date THEN COALESCE(billed_amount, 0) ELSE 0 END ) AS period_billing_amount FROM project_billing_entries WHERE COALESCE(COALESCE(tax_invoice_date, billing_date), '') <= :end_date GROUP BY support_dept_code """ ), {"start_date": start_date.isoformat(), "end_date": end_date.isoformat()}, ).mappings().all() annual_sga_rows = conn.execute( text( f""" SELECT CAST(strftime('%Y', {COST_ANALYSIS_TX_DATE_SQL}) AS INTEGER) AS posting_year, SUM(COALESCE(amount, 0)) AS amount FROM transactions WHERE ( ( UPPER(COALESCE(support_dept_code, '')) IN ('', 'ZZZZZZ') AND account_code LIKE '6%' ) OR ( UPPER(COALESCE(support_dept_code, '')) IN ('ZZZZZZ') AND account_code LIKE '5%' ) ) AND {COST_ANALYSIS_TX_DATE_SQL} >= :start_date AND {COST_ANALYSIS_TX_DATE_SQL} <= :end_date GROUP BY CAST(strftime('%Y', {COST_ANALYSIS_TX_DATE_SQL}) AS INTEGER) """ ), { "start_date": start_date.isoformat(), "end_date": end_date.isoformat(), }, ).mappings().all() visible_activity_codes: set[str] = set() common_rows: list[dict[str, Any]] = [] for raw_row in tx_rows: row = dict(raw_row) code = normalize_text(row.get("support_dept_code")).upper() bucket = _cost_analysis_financial_bucket(row.get("account_code")) if code in COST_ANALYSIS_COMMON_CODES: if bucket in {"sga", "cost"}: common_rows.append(row) continue target_code = x_owner_map.get(code, xyz_code_map.get(code, code)) report_row = ensure_row(target_code) if code != target_code and code not in report_row["direct_codes"]: report_row["direct_codes"].append(code) posting_date = _date_text(row.get("posting_date")) if posting_date and start_date.isoformat() <= posting_date <= end_date.isoformat(): visible_activity_codes.add(target_code) amount = normalize_amount(row.get("amount")) if bucket == "revenue": report_row["revenue_amount"] += amount report_row["period_revenue_amount"] += amount continue if bucket not in {"cost", "sga"}: continue phase = "pre" if code.startswith("X") else _cost_analysis_phase_for_transaction(target_code, posting_date, completion_dates) item_key = _cost_analysis_expense_item(row.get("account_code"), row.get("account_name"), _cost_analysis_is_sales_cost(row)) if item_key == "labor" and target_code in hanmac_labor_map: continue if item_key == "outsource" and phase == "pre": item_key = "overhead" report_row["phases"][phase][item_key] += amount for code, phase_amounts in hanmac_labor_map.items(): if normalize_text(code).upper() in COST_ANALYSIS_COMMON_CODES: continue report_row = ensure_row(code) for phase, amount in phase_amounts.items(): if phase in report_row["phases"] and amount: report_row["phases"][phase]["labor"] += amount for collection_row in collection_rows: code = normalize_text(collection_row.get("support_dept_code")).upper() if not code or code in COST_ANALYSIS_COMMON_CODES: continue target_code = xyz_code_map.get(code, code) report_row = ensure_row(target_code) if code != target_code and code not in report_row["direct_codes"]: report_row["direct_codes"].append(code) report_row["collection_amount"] += normalize_amount(collection_row.get("collection_amount")) report_row["period_collection_amount"] += normalize_amount(collection_row.get("period_collection_amount")) if normalize_amount(collection_row.get("period_collection_amount")): visible_activity_codes.add(target_code) collected_codes = {normalize_text(row.get("support_dept_code")).upper() for row in collection_rows} for collection_row in billing_collection_rows: code = normalize_text(collection_row.get("support_dept_code")).upper() if not code or code in COST_ANALYSIS_COMMON_CODES or code in collected_codes: continue amount = normalize_amount(collection_row.get("collection_amount")) if not amount: continue target_code = xyz_code_map.get(code, code) report_row = ensure_row(target_code) if code != target_code and code not in report_row["direct_codes"]: report_row["direct_codes"].append(code) report_row["collection_amount"] += amount report_row["period_collection_amount"] += normalize_amount(collection_row.get("period_collection_amount")) if normalize_amount(collection_row.get("period_collection_amount")): visible_activity_codes.add(target_code) for billing_row in billing_amount_rows: code = normalize_text(billing_row.get("support_dept_code")).upper() if not code or code in COST_ANALYSIS_COMMON_CODES: continue amount = normalize_amount(billing_row.get("billing_amount")) if not amount: continue target_code = xyz_code_map.get(code, code) report_row = ensure_row(target_code) if code != target_code and code not in report_row["direct_codes"]: report_row["direct_codes"].append(code) report_row["billing_amount"] += amount report_row["period_billing_amount"] += normalize_amount(billing_row.get("period_billing_amount")) if normalize_amount(billing_row.get("period_billing_amount")): visible_activity_codes.add(target_code) annual_sga_totals = {int(row["posting_year"]): normalize_amount(row.get("amount")) for row in annual_sga_rows if row.get("posting_year")} for year_slice in _iter_year_slices(start_date, end_date): year = int(year_slice["year"]) annual_sga_total = annual_sga_totals.get(year, 0.0) hanmac_project_hours = annual_hanmac_project_hours_by_year.get(year, {}) period_total_hours = sum( normalize_amount(hours) for phase_hours in hanmac_project_hours.values() for hours in phase_hours.values() ) if annual_sga_total <= 0 or period_total_hours <= 0: continue hourly_sga = annual_sga_total / period_total_hours for code, phase_hours in hanmac_project_hours.items(): normalized_code = normalize_text(code).upper() if normalized_code in COST_ANALYSIS_COMMON_CODES: continue report_row = ensure_row(code) for phase, hours in phase_hours.items(): amount = hourly_sga * normalize_amount(hours) if phase in report_row["phases"] and amount: report_row["phases"][phase]["sga"] += amount report_row["allocated"][phase]["sga"] += amount all_finalized_rows = [] period_finalized_rows = [] for code, row in rows_by_code.items(): _cost_analysis_finalize_row(row) all_finalized_rows.append(row) has_direct = code in visible_activity_codes if has_direct: period_finalized_rows.append(row) linked_mode = normalized_mode in {"aggregate", "sum", "합산", "연계", "linked", "link"} if linked_mode: representative_map = _cost_analysis_get_link_representative_map() visible_link_keys = { _cost_analysis_linked_group_key(row, representative_map) for row in period_finalized_rows } for code, representative in representative_map.items(): if representative not in visible_link_keys: continue if code in rows_by_code or code in COST_ANALYSIS_COMMON_CODES or code not in project_meta: continue row = ensure_row(code) _cost_analysis_finalize_row(row) all_finalized_rows.append(row) final_rows = [ row for row in all_finalized_rows if _cost_analysis_linked_group_key(row, representative_map) in visible_link_keys ] final_rows = _cost_analysis_aggregate_rows(final_rows, project_meta, representative_map) for row in final_rows: _cost_analysis_finalize_row(row) else: final_rows = period_finalized_rows final_rows.sort(key=lambda item: (normalize_text(item.get("pm_department")), normalize_text(item.get("project_type")), normalize_text(item.get("project_name")))) summary = { "contract_amount": sum(normalize_amount(row.get("contract_amount")) for row in final_rows), "billing_amount": sum(normalize_amount(row.get("billing_amount")) for row in final_rows), "collection_amount": sum(normalize_amount(row.get("collection_amount")) for row in final_rows), "period_billing_amount": sum(normalize_amount(row.get("period_billing_amount")) for row in final_rows), "period_collection_amount": sum(normalize_amount(row.get("period_collection_amount")) for row in final_rows), "period_revenue_amount": sum(normalize_amount(row.get("period_revenue_amount") or row.get("revenue_amount")) for row in final_rows), "period_cost_total": sum(normalize_amount(row.get("period_cost_total") or row.get("cost_total")) for row in final_rows), "period_sga_total": sum(normalize_amount(row.get("period_sga_total") or row.get("sga_total")) for row in final_rows), "period_sales_total": sum(normalize_amount(row.get("period_sales_total") or row.get("sales_total")) for row in final_rows), "period_total_cost": sum(normalize_amount(row.get("period_total_cost") or row.get("total_cost")) for row in final_rows), "period_profit_amount": sum(normalize_amount(row.get("period_profit_amount") or row.get("profit_amount")) for row in final_rows), "contract_balance_amount": sum(normalize_amount(row.get("contract_balance_amount")) for row in final_rows), "revenue_amount": sum(normalize_amount(row.get("revenue_amount")) for row in final_rows), "cost_total": sum(normalize_amount(row.get("cost_total")) for row in final_rows), "sga_total": sum(normalize_amount(row.get("sga_total")) for row in final_rows), "sales_total": sum(normalize_amount(row.get("sales_total")) for row in final_rows), "total_cost": sum(normalize_amount(row.get("total_cost")) for row in final_rows), "profit_amount": sum(normalize_amount(row.get("profit_amount")) for row in final_rows), "project_count": len(final_rows), } summary["collection_rate"] = _safe_ratio(summary["collection_amount"], summary["contract_amount"]) summary["contract_profit_rate"] = _safe_ratio(summary["profit_amount"], summary["contract_amount"]) summary["revenue_profit_rate"] = _safe_ratio(summary["profit_amount"], summary["revenue_amount"]) summary["collection_profit_rate"] = _safe_ratio(summary["profit_amount"], summary["collection_amount"]) summary["period_revenue_profit_rate"] = _safe_ratio(summary["period_profit_amount"], summary["period_revenue_amount"]) summary["period_collection_profit_rate"] = _safe_ratio(summary["period_profit_amount"], summary["period_collection_amount"]) summary["cumulative_profit_rate"] = _safe_ratio(summary["collection_amount"] - summary["total_cost"], summary["collection_amount"]) payload = { "start_date": start_date.isoformat(), "end_date": end_date.isoformat(), "mode": "aggregate" if linked_mode else "individual", "rows": final_rows, "summary": summary, } _store_system_page_cache( "cost_analysis_payload", persistent_cache_key, params={ "start_date": start_date.isoformat(), "end_date": end_date.isoformat(), "mode": payload["mode"], "data_version": cache_key[3], "hanmac_cache_version": cache_key[4], }, payload=payload, row_count=len(final_rows), signature=str(cache_key[3]), ) return _set_deepcopy_ttl_cache_entry( _COST_ANALYSIS_PAYLOAD_CACHE, _COST_ANALYSIS_PAYLOAD_CACHE_LOCK, cache_key, payload, ) def render_cost_analysis_page(request: Request, message: str = "") -> HTMLResponse: init_db() today = date.today() context = { **base_context(request, message), "default_start_date": date(today.year, 1, 1).isoformat(), "default_end_date": today.isoformat(), } return templates.TemplateResponse(request, "cost_analysis.html", context) def render_annual_summary_page(request: Request, message: str = "") -> HTMLResponse: init_db() context = { **base_context(request, message), "available_years": get_available_years(), "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"), } return templates.TemplateResponse(request, "annual_summary.html", context) def get_annual_summary_bootstrap_payload() -> dict[str, Any]: cached = _get_deepcopy_ttl_cache_entry( _ANNUAL_SUMMARY_BOOTSTRAP_CACHE, _ANNUAL_SUMMARY_BOOTSTRAP_CACHE_LOCK, ("annual-summary",), ANNUAL_SUMMARY_BOOTSTRAP_CACHE_TTL_SECONDS, ) if cached is not None: return cached persistent_cache_key = _json_hash({"scope": "annual-summary"}) persistent = _load_system_page_cache("annual_summary_bootstrap", persistent_cache_key) if persistent is not None: return _set_deepcopy_ttl_cache_entry( _ANNUAL_SUMMARY_BOOTSTRAP_CACHE, _ANNUAL_SUMMARY_BOOTSTRAP_CACHE_LOCK, ("annual-summary",), persistent, ) payload = { "yearly_financial_series": get_financial_series("yearly"), "monthly_financial_series": get_financial_series("monthly"), } return _set_deepcopy_ttl_cache_entry( _ANNUAL_SUMMARY_BOOTSTRAP_CACHE, _ANNUAL_SUMMARY_BOOTSTRAP_CACHE_LOCK, ("annual-summary",), payload, ) def render_wehago_compare_page( request: Request, start_year: int | None = None, end_year: int | None = None, message: str = "", ) -> HTMLResponse: init_db() context = { **base_context(request, message), "wehago_compare": get_wehago_compare_dashboard( engine, start_year=start_year, end_year=end_year, include_metric_counts=True, warm_caches=False, ), } return templates.TemplateResponse(request, "wehago_compare.html", context) def render_wehago_benefit_entertainment_page( request: Request, start_year: int = WEHAGO_BENEFIT_DEFAULT_START_YEAR, end_year: int = WEHAGO_BENEFIT_DEFAULT_END_YEAR, account_group: str = "all", category: str = "all", person_keyword: str = "", desc_keyword: str = "", include_adjustments: bool = False, message: str = "", ) -> HTMLResponse: init_db() report = get_wehago_benefit_entertainment_report( start_year=start_year, end_year=end_year, account_group=account_group, category=category, person_keyword=person_keyword, desc_keyword=desc_keyword, include_adjustments=include_adjustments, limit=300, ) context = { **base_context(request, message), "benefit_report": report, "benefit_report_json": jsonable_encoder( { "detail_rows": report.get("all_detail_rows", []), "vendor_summary_rows": report.get("vendor_summary_rows", []), "category_options": report.get("category_options", []), } ), } return templates.TemplateResponse(request, "wehago_benefit_entertainment.html", context) def render_hanmac_browser_page( request: Request, message: str = "", ) -> HTMLResponse: context = { **base_context(request, message), } return templates.TemplateResponse(request, "hanmac_browser.html", context) def _build_hanmac_mysql_engine(payload: dict[str, Any]): host = normalize_text(payload.get("host")) port_raw = normalize_text(payload.get("port")) or "3306" user = normalize_text(payload.get("user")) password = payload.get("password") database = normalize_text(payload.get("database")) if not host: raise ValueError("서버 IP를 입력해주세요.") if not user: raise ValueError("아이디를 입력해주세요.") if password in (None, ""): raise ValueError("비밀번호를 입력해주세요.") if not database: raise ValueError("DB를 선택해주세요.") try: port = int(port_raw) except ValueError as exc: raise ValueError("포트 번호를 숫자로 입력해주세요.") from exc return create_engine( URL.create( "mysql+pymysql", username=user, password=str(password), host=host, port=port, database=database, query={"charset": "utf8"}, ), pool_pre_ping=True, pool_recycle=300, connect_args={"connect_timeout": 5}, ) def test_hanmac_mysql_connection(payload: dict[str, Any]) -> dict[str, Any]: database = normalize_text(payload.get("database")) test_engine = _build_hanmac_mysql_engine(payload) try: with test_engine.connect() as connection: current_database = connection.execute(text("SELECT DATABASE()")).scalar() current_user = connection.execute(text("SELECT CURRENT_USER()")).scalar() server_version = connection.execute(text("SELECT VERSION()")).scalar() return { "status": "ok", "message": f"{database} 연결에 성공했습니다.", "database": current_database or database, "user": current_user or user, "server_version": server_version or "", } finally: test_engine.dispose() def _validate_hanmac_table_name(table_name: Any) -> str: normalized = normalize_text(table_name) if not normalized: raise ValueError("테이블 이름이 필요합니다.") if not re.fullmatch(r"[A-Za-z0-9_]+", normalized): raise ValueError("테이블 이름 형식이 올바르지 않습니다.") return normalized HANMAC_EXTERNAL_SCHEMAS = ("hanmac", "hanmac_manhour", "baron_manhour") HANMAC_PRIMARY_MANHOUR_SCHEMA = "hanmac_manhour" HANMAC_CENTER_MANHOUR_SCHEMA = "baron_manhour" HANMAC_EXTERNAL_SCHEMA_SQL = ", ".join(f"'{schema}'" for schema in HANMAC_EXTERNAL_SCHEMAS) HANMAC_EXTERNAL_SCHEMA_LABEL = " / ".join(HANMAC_EXTERNAL_SCHEMAS) def _validate_hanmac_schema_name(schema_name: Any) -> str: normalized = normalize_text(schema_name) if normalized not in set(HANMAC_EXTERNAL_SCHEMAS): raise ValueError("스키마 이름이 올바르지 않습니다.") return normalized def get_hanmac_table_list(payload: dict[str, Any]) -> dict[str, Any]: connect_payload = dict(payload) connect_payload["database"] = normalize_text(payload.get("database")) or "hanmac" test_engine = _build_hanmac_mysql_engine(connect_payload) try: with test_engine.connect() as connection: table_rows = connection.execute( text( f""" SELECT table_schema, table_name FROM information_schema.tables WHERE table_schema IN ({HANMAC_EXTERNAL_SCHEMA_SQL}) AND table_type = 'BASE TABLE' ORDER BY table_schema, table_name """ ) ).mappings().all() tables: list[dict[str, Any]] = [] for row in table_rows: schema_name = str(row["table_schema"]) table_name = str(row["table_name"]) try: row_count = connection.execute(text(f"SELECT COUNT(*) FROM `{schema_name}`.`{table_name}`")).scalar() except Exception: row_count = None tables.append( { "schema": schema_name, "name": table_name, "row_count": int(row_count) if row_count is not None else None, } ) preferred_tables = { "dallyproject_tbl": 0, "dallyproject_addwork_tbl": 1, "member_tbl": 2, "project_tbl": 3, "worker_tardy_tbl": 4, } tables.sort( key=lambda item: ( {HANMAC_PRIMARY_MANHOUR_SCHEMA: 0, HANMAC_CENTER_MANHOUR_SCHEMA: 1, "hanmac": 2}.get(item.get("schema"), 9), preferred_tables.get(item.get("name", ""), 99), (item.get("row_count") is None), -(item.get("row_count") or 0), item["name"], ) ) return { "status": "ok", "database": HANMAC_EXTERNAL_SCHEMA_LABEL, "tables": tables, } finally: 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")) limit_raw = payload.get("limit") try: limit = int(limit_raw or 100) except ValueError as exc: raise ValueError("조회 건수는 숫자여야 합니다.") from exc safe_limit = max(1, min(limit, 200)) offset = _hanmac_decode_preview_cursor(payload.get("cursor")) connect_payload = dict(payload) connect_payload["database"] = schema_name test_engine = _build_hanmac_mysql_engine(connect_payload) try: with test_engine.connect() as connection: available_tables = { (str(row["table_schema"]), str(row["table_name"])) for row in connection.execute( text( f""" SELECT table_schema, table_name FROM information_schema.tables WHERE table_schema IN ({HANMAC_EXTERNAL_SCHEMA_SQL}) AND table_type = 'BASE TABLE' """ ) ).mappings().all() } if (schema_name, table_name) not in available_tables: raise ValueError("선택한 테이블을 찾을 수 없습니다.") column_rows = connection.execute(text(f"SHOW COLUMNS FROM `{schema_name}`.`{table_name}`")).mappings().all() columns = [str(row.get("Field") or "") for row in column_rows] 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: ( value.isoformat(sep=" ") if isinstance(value, datetime) else str(value) if value is not None and not isinstance(value, (int, float, str)) else value ) for column, value in dict(row).items() } for row in data_rows ] next_cursor = str(offset + safe_limit) if has_more else "" return { "status": "ok", "schema": schema_name, "table": table_name, "columns": columns, "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() def _hanmac_find_column(columns: list[str], candidates: list[str]) -> str | None: lowered_map = {str(column).lower(): str(column) for column in columns} for candidate in candidates: matched = lowered_map.get(str(candidate).lower()) if matched: return matched return None def _hanmac_normalize_person_name(value: Any) -> str: return re.sub(r"\s+", "", normalize_text(value)).lower() def _hanmac_normalize_member_token(value: Any) -> str: return normalize_text(value).lower() def _hanmac_normalize_member_restore_keys(payload: dict[str, Any]) -> set[str]: raw_items = payload.get("include_center_member_nos") if raw_items is None: raw_items = payload.get("restore_center_member_nos") if isinstance(raw_items, str): items = [item.strip() for item in raw_items.split(",")] elif isinstance(raw_items, (list, tuple, set)): items = list(raw_items) else: items = [] return {_hanmac_normalize_member_token(item) for item in items if _hanmac_normalize_member_token(item)} def _hanmac_load_member_info( connection: Any, schema_name: str, metadata: dict[str, list[str]], ) -> tuple[dict[str, dict[str, Any]], dict[str, Any]]: diagnostics: dict[str, Any] = { "schema": schema_name, "member_columns": [], "member_name_col": "", "member_name_fallback_rows": 0, "member_group_col": "", "dept_source_table": "", "dept_code_col": "", "dept_name_col": "", "dept_mapped_rows": 0, "available": False, } member_info: dict[str, dict[str, Any]] = {} member_columns = metadata.get("member_tbl") or [] diagnostics["member_columns"] = member_columns member_no_col = _hanmac_find_column(member_columns, ["MemberNo", "member_no", "EmpNo", "UserID"]) if not member_no_col: return member_info, diagnostics 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"]) 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"]) grade_col = _hanmac_find_column(member_columns, ["Grade", "grade", "Position", "position", "Rank", "rank", "Duty", "duty", "JobGrade", "job_grade", "RankCode", "rank_code", "WorkPosition", "work_position", "직급"]) member_group_col = _hanmac_find_column(member_columns, ["GroupCode", "group_code", "DeptCode", "dept_code", "DepartmentCode", "TeamCode"]) diagnostics["member_grade_col"] = grade_col or "" diagnostics["member_group_col"] = member_group_col or "" dept_name_by_code: dict[str, str] = {} if not dept_name_col and member_group_col: preferred_dept_tables = sorted( ( table_name for table_name in metadata if table_name != "member_tbl" and any(token in table_name.lower() for token in ("group", "dept", "department", "team", "part")) ), key=lambda table_name: ( not any(token in table_name.lower() for token in ("group", "dept")), table_name.lower(), ), ) for table_name in preferred_dept_tables: table_columns = metadata.get(table_name) or [] code_col = _hanmac_find_column( table_columns, ["GroupCode", "group_code", "DeptCode", "dept_code", "DepartmentCode", "TeamCode", "Code", "code"], ) name_col = _hanmac_find_column( table_columns, ["GroupName", "group_name", "DeptName", "dept_name", "Department", "DepartmentName", "PartName", "TeamName", "Name"], ) if not code_col or not name_col: continue dept_rows = connection.execute( text( f""" SELECT {_hanmac_build_select_alias(code_col, "dept_code")}, {_hanmac_build_select_alias(name_col, "dept_name")} FROM `{schema_name}`.`{table_name}` """ ) ).mappings().all() dept_name_by_code = { normalize_text(row.get("dept_code")): normalize_text(row.get("dept_name")) for row in dept_rows if normalize_text(row.get("dept_code")) and normalize_text(row.get("dept_name")) } if dept_name_by_code: diagnostics["dept_source_table"] = table_name diagnostics["dept_code_col"] = code_col diagnostics["dept_name_col"] = name_col break rank_code_names: dict[str, str] = {} system_columns = metadata.get("systemconfig_tbl") or [] if system_columns: sys_key_col = _hanmac_find_column(system_columns, ["SysKey", "sys_key", "syskey"]) code_col = _hanmac_find_column(system_columns, ["Code", "code"]) name_col = _hanmac_find_column(system_columns, ["Name", "name", "CodeORName", "Description"]) if sys_key_col and code_col and name_col: try: rank_rows = connection.execute( text( f""" SELECT {_hanmac_build_select_alias(code_col, "code")}, {_hanmac_build_select_alias(name_col, "name")} FROM `{schema_name}`.`systemconfig_tbl` WHERE LOWER(CAST(`{sys_key_col}` AS CHAR)) LIKE '%rank%' OR LOWER(CAST(`{sys_key_col}` AS CHAR)) LIKE '%position%' OR LOWER(CAST(`{sys_key_col}` AS CHAR)) LIKE '%duty%' OR CAST(`{sys_key_col}` AS CHAR) LIKE '%직급%' """ ) ).mappings().all() rank_code_names = { normalize_text(row.get("code")): normalize_text(row.get("name")) for row in rank_rows if normalize_text(row.get("code")) and normalize_text(row.get("name")) } except Exception: rank_code_names = {} diagnostics["member_grade_code_map_rows"] = len(rank_code_names) member_rows = connection.execute( text( f""" SELECT {_hanmac_build_select_alias(member_no_col, "member_no")}, {_hanmac_build_select_alias(member_name_col, "member_name")}, {_hanmac_build_select_alias(entry_date_col, "entry_date")}, {_hanmac_build_select_alias(leave_date_col, "leave_date")}, {_hanmac_build_select_alias(dept_name_col, "dept_name")}, {_hanmac_build_select_alias(grade_col, "member_grade")}, {_hanmac_build_select_alias(member_group_col, "group_code")} FROM `{schema_name}`.`member_tbl` """ ) ).mappings().all() diagnostics["available"] = True for row in member_rows: 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: diagnostics["member_name_fallback_rows"] += 1 dept_name = normalize_text(row.get("dept_name")) or dept_name_by_code.get(normalize_text(row.get("group_code")), "") if dept_name and not normalize_text(row.get("dept_name")): diagnostics["dept_mapped_rows"] += 1 raw_grade = normalize_text(row.get("member_grade")) member_grade = _normalize_labor_grade_name(rank_code_names.get(raw_grade) or raw_grade) member_info[member_no] = { "member_no": 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": dept_name, "member_grade": member_grade, "source_schema": schema_name, } return member_info, diagnostics 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 if isinstance(value, datetime): return value.date() if isinstance(value, date): return value text_value = str(value).strip() if not text_value or text_value in {"0000-00-00", "0000-00-00 00:00:00"}: return None for fmt in ("%Y-%m-%d", "%Y-%m-%d %H:%M:%S", "%Y/%m/%d", "%Y/%m/%d %H:%M:%S", "%Y%m%d"): try: return datetime.strptime(text_value, fmt).date() except ValueError: continue return None def _hanmac_parse_datetime_value(value: Any) -> datetime | None: if value is None: return None if isinstance(value, datetime): return value if isinstance(value, date): return datetime.combine(value, datetime.min.time()) text_value = str(value).strip() if not text_value or text_value in {"0000-00-00", "0000-00-00 00:00:00"}: return None for fmt in ("%Y-%m-%d %H:%M:%S", "%Y/%m/%d %H:%M:%S", "%Y-%m-%d", "%Y/%m/%d", "%Y%m%d"): try: parsed = datetime.strptime(text_value, fmt) return parsed except ValueError: continue return None def _hanmac_parse_float_value(value: Any) -> float: if value is None: return 0.0 if isinstance(value, (int, float)): return float(value) text_value = str(value).strip() if not text_value: return 0.0 try: return float(text_value) except ValueError: return 0.0 def _hanmac_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 if isinstance(value, (int, float)): return max(float(value), 0.0) text_value = str(value).strip() if not text_value or text_value in {"0000-00-00 00:00:00", "00:00:00"}: return 0.0 if " " in text_value and len(text_value.split(" ")[-1].split(":")) == 3: text_value = text_value.split(" ")[-1] match = re.fullmatch(r"(\d{1,3}):(\d{2})(?::(\d{2}))?", text_value) if match: hours = int(match.group(1)) minutes = int(match.group(2)) seconds = int(match.group(3) or 0) return max(hours + (minutes / 60.0) + (seconds / 3600.0), 0.0) return max(_hanmac_parse_float_value(text_value), 0.0) def _hanmac_parse_hour_minute_fields(hour_value: Any, minute_value: Any = None) -> float: hours = _hanmac_parse_duration_hours(hour_value) minutes = _hanmac_parse_float_value(minute_value) if minutes > 0: hours += minutes / 60.0 return round(max(hours, 0.0), 4) def _hanmac_calculate_regular_hours(entry_time: Any, leave_time: Any) -> float: started_at = _hanmac_parse_datetime_value(entry_time) ended_at = _hanmac_parse_datetime_value(leave_time) if not started_at or not ended_at: return 0.0 hours = (ended_at - started_at).total_seconds() / 3600.0 if hours < 0 or hours > 24: return 0.0 if ended_at.time() > datetime.strptime("12:30", "%H:%M").time(): hours = max(0.0, hours - 1.0) return round(hours, 2) def _hanmac_floor_regular_hours(hours: Any, cap: float = 8.0) -> float: return float(min(max(math.floor(max(_hanmac_parse_float_value(hours), 0.0)), 0), int(cap))) def _hanmac_round_recognized_hours(hours: Any) -> float: raw_hours = max(_hanmac_parse_float_value(hours), 0.0) return float(Decimal(str(raw_hours)).quantize(Decimal("1"), rounding=ROUND_HALF_UP)) def _hanmac_cap_weekday_overtime(hours: Any) -> float: raw_hours = max(_hanmac_parse_float_value(hours), 0.0) return _hanmac_round_recognized_hours(min(raw_hours, 3.0)) if raw_hours >= 2.0 else 0.0 def _hanmac_cap_holiday_hours(hours: Any) -> float: raw_hours = max(_hanmac_parse_float_value(hours), 0.0) return _hanmac_round_recognized_hours(min(raw_hours, 5.0)) if raw_hours >= 3.0 else 0.0 def _hanmac_allocate_recognized_hours(total_hours: Any, source_hours: dict[Any, float]) -> dict[Any, float]: target_hours = int(_hanmac_round_recognized_hours(total_hours)) positive_items = [ (key, max(_hanmac_parse_float_value(hours), 0.0)) for key, hours in source_hours.items() if _hanmac_parse_float_value(hours) > 0 ] allocations = {key: 0.0 for key in source_hours} source_total = sum(hours for _, hours in positive_items) if target_hours <= 0 or source_total <= 0: return allocations remainders: list[tuple[float, str, Any]] = [] allocated_total = 0 for key, hours in positive_items: quota = target_hours * (hours / source_total) allocated = int(math.floor(quota)) allocations[key] = float(allocated) allocated_total += allocated remainders.append((quota - allocated, str(key), key)) for _, _, key in sorted(remainders, key=lambda item: (-item[0], item[1]))[: target_hours - allocated_total]: allocations[key] += 1.0 return allocations def _hanmac_extract_leave_hours_from_text(value: Any) -> float: text_value = normalize_text(value).replace("/", "/") time_range = re.search( r"(\d{1,2})(?:\s*시|:)(?:\s*(\d{1,2})\s*분?)?\s*[~~\-]\s*(\d{1,2})(?:\s*시|:)(?:\s*(\d{1,2})\s*분?)?", text_value, ) if not time_range: return 0.0 start_hours = int(time_range.group(1)) + (int(time_range.group(2) or 0) / 60.0) end_hours = int(time_range.group(3)) + (int(time_range.group(4) or 0) / 60.0) if end_hours <= start_hours: end_hours += 24.0 return round(min(max(end_hours - start_hours, 0.0), 8.0), 4) def _load_hanmac_holiday_dates(start_date: date, end_date: date) -> set[date]: init_db() with engine.begin() as conn: rows = conn.execute( text( """ SELECT holiday_date FROM hanmac_holidays WHERE holiday_date >= :start_date AND holiday_date <= :end_date """ ), {"start_date": start_date.isoformat(), "end_date": end_date.isoformat()}, ).fetchall() holiday_dates: set[date] = set() for row in rows: parsed = _hanmac_parse_date_value(row[0]) if parsed: holiday_dates.add(parsed) return holiday_dates def get_hanmac_holidays(start_date: date | None = None, end_date: date | None = None) -> list[dict[str, Any]]: init_db() filters: list[str] = [] params: dict[str, Any] = {} if start_date: filters.append("holiday_date >= :start_date") params["start_date"] = start_date.isoformat() if end_date: filters.append("holiday_date <= :end_date") params["end_date"] = end_date.isoformat() where_sql = f"WHERE {' AND '.join(filters)}" if filters else "" with engine.begin() as conn: rows = conn.execute( text( f""" SELECT holiday_date, holiday_name, holiday_type, memo, updated_at FROM hanmac_holidays {where_sql} ORDER BY holiday_date """ ), params, ).mappings().all() return [dict(row) for row in rows] def _clear_hanmac_aggregate_caches() -> None: init_db() with engine.begin() as conn: conn.execute(text("DELETE FROM hanmac_aggregate_query_rows")) conn.execute(text("DELETE FROM hanmac_aggregate_query_metrics")) conn.execute(text("DELETE FROM hanmac_aggregate_query_cache")) def get_hanmac_leave_rules() -> list[dict[str, Any]]: init_db() with engine.begin() as conn: rows = conn.execute( text( """ SELECT keyword, leave_label, rule_type, default_hours, enabled, priority, memo FROM hanmac_leave_rules WHERE enabled = 1 ORDER BY priority, keyword """ ) ).mappings().all() return [dict(row) for row in rows] def save_hanmac_leave_rule(payload: dict[str, Any]) -> dict[str, Any]: keyword = normalize_text(payload.get("keyword")) if not keyword: raise ValueError("휴가 구분 키워드를 입력해주세요.") leave_label = normalize_text(payload.get("leave_label")) or keyword rule_type = normalize_text(payload.get("rule_type")) or "full_day" if rule_type not in {"full_day", "fixed_hours", "explicit_hours"}: raise ValueError("휴가 계산 방식은 full_day, fixed_hours, explicit_hours 중 하나여야 합니다.") default_hours = _hanmac_parse_float_value(payload.get("default_hours")) if default_hours <= 0 and rule_type != "explicit_hours": default_hours = 8.0 enabled = 1 if normalize_text(payload.get("enabled", "1")).lower() not in {"0", "false", "no", "off"} else 0 try: priority = int(payload.get("priority") or 100) except Exception: priority = 100 memo = normalize_text(payload.get("memo")) init_db() with engine.begin() as conn: conn.execute( text( """ INSERT INTO hanmac_leave_rules ( keyword, leave_label, rule_type, default_hours, enabled, priority, memo, created_at, updated_at ) VALUES ( :keyword, :leave_label, :rule_type, :default_hours, :enabled, :priority, :memo, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP ) ON CONFLICT(keyword) DO UPDATE SET leave_label = excluded.leave_label, rule_type = excluded.rule_type, default_hours = excluded.default_hours, enabled = excluded.enabled, priority = excluded.priority, memo = excluded.memo, updated_at = CURRENT_TIMESTAMP """ ), { "keyword": keyword, "leave_label": leave_label, "rule_type": rule_type, "default_hours": default_hours, "enabled": enabled, "priority": priority, "memo": memo, }, ) _clear_hanmac_aggregate_caches() return {"ok": True, "rules": get_hanmac_leave_rules()} def delete_hanmac_leave_rule(keyword_value: Any) -> dict[str, Any]: keyword = normalize_text(keyword_value) if not keyword: raise ValueError("삭제할 휴가 구분 키워드가 올바르지 않습니다.") init_db() with engine.begin() as conn: conn.execute(text("DELETE FROM hanmac_leave_rules WHERE keyword = :keyword"), {"keyword": keyword}) _clear_hanmac_aggregate_caches() return {"ok": True, "keyword": keyword, "rules": get_hanmac_leave_rules()} def _hanmac_match_leave_rule(leave_type: Any, rules: list[dict[str, Any]]) -> dict[str, Any] | None: normalized_leave_type = normalize_text(leave_type).lower() if not normalized_leave_type: return None flexible_work_keywords = ("탄력", "단축근무", "근무시간조정", "출근시간조정", "유연근무") if any(keyword in normalized_leave_type for keyword in flexible_work_keywords): return None for rule in rules: keyword = normalize_text(rule.get("keyword")).lower() if keyword and keyword in normalized_leave_type: return rule english_keywords = ("leave", "vacation", "holiday") if any(keyword in normalized_leave_type for keyword in english_keywords): return { "keyword": "leave", "leave_label": "휴가", "rule_type": "full_day", "default_hours": 8.0, } return None def _hanmac_calculate_leave_amounts( *, leave_type: Any, leave_value: Any, leave_hour: Any, leave_min: Any, rule: dict[str, Any], full_date_count: int, ) -> tuple[float, float, str]: rule_type = normalize_text(rule.get("rule_type")) or "full_day" default_hours = _hanmac_parse_float_value(rule.get("default_hours")) or 8.0 explicit_hours = _hanmac_parse_float_value(leave_hour) + (_hanmac_parse_float_value(leave_min) / 60.0) numeric_value = _hanmac_parse_float_value(leave_value) date_count = max(int(full_date_count or 1), 1) if rule_type == "explicit_hours": total_hours = explicit_hours if explicit_hours > 0 else numeric_value total_hours = max(total_hours, 0.0) return round(total_hours / 8.0, 4), round(total_hours, 4), "explicit_hours" if explicit_hours > 0: return round(explicit_hours / 8.0, 4), round(explicit_hours, 4), "explicit_hours" if numeric_value > 0: if rule_type == "fixed_hours": total_hours = numeric_value if numeric_value > default_hours else numeric_value * default_hours return round(total_hours / 8.0, 4), round(total_hours, 4), "numeric_fixed_hours" total_days = numeric_value return round(total_days, 4), round(total_days * default_hours, 4), "numeric_days" total_hours = default_hours * date_count return round(total_hours / 8.0, 4), round(total_hours, 4), "default_rule" def _hanmac_find_columns(columns: list[str], candidates: list[str]) -> list[str]: candidate_keys = {str(candidate).lower() for candidate in candidates} return [str(column) for column in columns if str(column).lower() in candidate_keys] def _hanmac_build_text_concat_alias(columns: list[str], alias: str) -> str: if not columns: return f"NULL AS `{alias}`" expressions = ", ".join(f"COALESCE(CAST(`{column}` AS CHAR), '')" for column in columns) return f"CONCAT_WS(' ', {expressions}) AS `{alias}`" def _hanmac_leave_source_profile(table_name: str, columns: list[str]) -> dict[str, Any] | None: member_col = _hanmac_find_column(columns, ["MemberNo", "member_no", "EmpNo", "UserID", "MemberID", "member_id"]) date_col = _hanmac_find_column(columns, ["work_date", "WorkDate", "EntryDate", "Date", "TardyDate", "s_date", "SDate", "StartDate", "start_date", "start_time", "StartTime", "UseDate", "use_date"]) type_cols = _hanmac_find_columns(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", "note", "Note", "memo", "Memo", "remark", "Remark", "Name", "Description"]) lower_name = table_name.lower() name_hint = any(keyword in lower_name for keyword in ("tardy", "leave", "vac", "holiday", "absence", "annual", "dayoff")) supported_state_table = lower_name == "userstate_tbl" if not member_col or not date_col or not type_cols or (not name_hint and table_name != "worker_tardy_tbl" and not supported_state_table): return None return { "table": table_name, "member_col": member_col, "date_col": date_col, "end_date_col": _hanmac_find_column(columns, ["e_date", "EDate", "EndDate", "end_date", "end_time", "EndTime"]), "project_col": _hanmac_find_column(columns, ["project_code", "ProjectCode", "new_project_code", "NewProjectCode", "ProjectKey", "PCode"]), "type_cols": type_cols, "value_col": _hanmac_find_column(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", "UseHour", "use_hour"]), "hour_col": _hanmac_find_column(columns, ["tardy_h", "TardyH", "tardy_hour", "TardyHour", "UseHour", "use_hour"]), "min_col": _hanmac_find_column(columns, ["tardy_m", "TardyM", "tardy_min", "TardyMin", "UseMin", "use_min"]), } def save_hanmac_holiday(payload: dict[str, Any]) -> dict[str, Any]: holiday_date = _hanmac_parse_date_value(payload.get("holiday_date")) if not holiday_date: raise ValueError("휴일 날짜를 입력해주세요.") holiday_type = normalize_text(payload.get("holiday_type")) or "company" if holiday_type not in {"legal", "substitute", "company"}: holiday_type = "company" holiday_name = normalize_text(payload.get("holiday_name")) or "휴일" memo = normalize_text(payload.get("memo")) init_db() with engine.begin() as conn: conn.execute( text( """ INSERT INTO hanmac_holidays ( holiday_date, holiday_name, holiday_type, memo, created_at, updated_at ) VALUES ( :holiday_date, :holiday_name, :holiday_type, :memo, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP ) ON CONFLICT(holiday_date) DO UPDATE SET holiday_name = excluded.holiday_name, holiday_type = excluded.holiday_type, memo = excluded.memo, updated_at = CURRENT_TIMESTAMP """ ), { "holiday_date": holiday_date.isoformat(), "holiday_name": holiday_name, "holiday_type": holiday_type, "memo": memo, }, ) _clear_hanmac_aggregate_caches() return {"ok": True, "holiday": get_hanmac_holidays(holiday_date, holiday_date)[0]} def delete_hanmac_holiday(holiday_date_value: Any) -> dict[str, Any]: holiday_date = _hanmac_parse_date_value(holiday_date_value) if not holiday_date: raise ValueError("삭제할 휴일 날짜가 올바르지 않습니다.") init_db() with engine.begin() as conn: conn.execute( text("DELETE FROM hanmac_holidays WHERE holiday_date = :holiday_date"), {"holiday_date": holiday_date.isoformat()}, ) _clear_hanmac_aggregate_caches() return {"ok": True, "holiday_date": holiday_date.isoformat()} def _hanmac_resolve_period(payload: dict[str, Any]) -> tuple[date, date]: today = date.today() start_date = _hanmac_parse_date_value(payload.get("start_date")) or date(today.year, 1, 1) end_date = _hanmac_parse_date_value(payload.get("end_date")) or today if end_date < start_date: start_date, end_date = end_date, start_date return start_date, end_date def _hanmac_build_select_alias(column_name: str | None, alias: str) -> str: return f"`{column_name}` AS `{alias}`" if column_name else f"NULL AS `{alias}`" def _hanmac_fetch_table_columns(connection: Any, schema_name: str) -> dict[str, list[str]]: table_rows = connection.execute( text( """ SELECT table_name FROM information_schema.tables WHERE table_schema = :schema_name AND table_type = 'BASE TABLE' ORDER BY table_name """ ), {"schema_name": schema_name}, ).mappings().all() metadata: dict[str, list[str]] = {} for row in table_rows: table_name = str(row["table_name"]) column_rows = connection.execute(text(f"SHOW COLUMNS FROM `{schema_name}`.`{table_name}`")).mappings().all() metadata[table_name] = [str(column_row.get("Field") or "") for column_row in column_rows] return metadata def _hanmac_load_joint_assignment_absent_codes(connection: Any, schema_name: str, metadata: dict[str, list[str]]) -> dict[str, str]: fallback_codes = {"20": "경쟁합사", "21": "일반합사", "C1": "합사", "C3": "합사"} columns = metadata.get("systemconfig_tbl") or [] if not columns: return fallback_codes sys_key_col = _hanmac_find_column(columns, ["SysKey", "sys_key", "syskey"]) code_col = _hanmac_find_column(columns, ["Code", "code"]) name_col = _hanmac_find_column(columns, ["Name", "name", "CodeORName", "Description"]) if not code_col: return fallback_codes where = [] params: dict[str, Any] = {} if sys_key_col: where.append(f"`{sys_key_col}` = :sys_key") params["sys_key"] = "AbsentCode" query = f""" SELECT {_hanmac_build_select_alias(code_col, "code")}, {_hanmac_build_select_alias(name_col, "name")} FROM `{schema_name}`.`systemconfig_tbl` {f"WHERE {' AND '.join(where)}" if where else ""} """ codes: dict[str, str] = {} try: rows = connection.execute(text(query), params).mappings().all() except Exception: return fallback_codes for row in rows: code = normalize_text(row.get("code")) name = normalize_text(row.get("name")) if code and "합사" in name: codes[code] = name return {**fallback_codes, **codes} def _hanmac_load_rank_code_names(connection: Any, schema_name: str, metadata: dict[str, list[str]]) -> dict[str, str]: system_columns = metadata.get("systemconfig_tbl") or [] if not system_columns: return {} sys_key_col = _hanmac_find_column(system_columns, ["SysKey", "sys_key", "syskey"]) code_col = _hanmac_find_column(system_columns, ["Code", "code"]) name_col = _hanmac_find_column(system_columns, ["Name", "name", "CodeORName", "Description"]) if not sys_key_col or not code_col or not name_col: return {} try: rows = connection.execute( text( f""" SELECT {_hanmac_build_select_alias(code_col, "code")}, {_hanmac_build_select_alias(name_col, "name")} FROM `{schema_name}`.`systemconfig_tbl` WHERE LOWER(CAST(`{sys_key_col}` AS CHAR)) LIKE '%rank%' OR LOWER(CAST(`{sys_key_col}` AS CHAR)) LIKE '%position%' OR LOWER(CAST(`{sys_key_col}` AS CHAR)) LIKE '%duty%' OR CAST(`{sys_key_col}` AS CHAR) LIKE '%직급%' """ ) ).mappings().all() except Exception: return {} return { normalize_text(row.get("code")): normalize_text(row.get("name")) for row in rows if normalize_text(row.get("code")) and normalize_text(row.get("name")) } def get_hanmac_grade_code_summary(payload: dict[str, Any]) -> dict[str, Any]: connect_payload = dict(payload) connect_payload["database"] = normalize_text(payload.get("database")) or HANMAC_PRIMARY_MANHOUR_SCHEMA test_engine = _build_hanmac_mysql_engine(connect_payload) schema_summaries: list[dict[str, Any]] = [] try: with test_engine.connect() as connection: for schema_name in (HANMAC_PRIMARY_MANHOUR_SCHEMA, HANMAC_CENTER_MANHOUR_SCHEMA): metadata = _hanmac_fetch_table_columns(connection, schema_name) member_columns = metadata.get("member_tbl") or [] member_no_col = _hanmac_find_column(member_columns, ["MemberNo", "member_no", "EmpNo", "UserID"]) 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"]) grade_col = _hanmac_find_column(member_columns, ["Grade", "grade", "Position", "position", "Rank", "rank", "Duty", "duty", "JobGrade", "job_grade", "RankCode", "rank_code", "WorkPosition", "work_position", "직급"]) 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"]) diagnostics = { "schema": schema_name, "member_table": "member_tbl" if member_columns else "", "member_no_col": member_no_col or "", "member_name_col": member_name_col or "", "grade_col": grade_col or "", "rank_code_map_rows": 0, } if not member_columns or not grade_col: schema_summaries.append({**diagnostics, "rows": []}) continue rank_code_names = _hanmac_load_rank_code_names(connection, schema_name, metadata) diagnostics["rank_code_map_rows"] = len(rank_code_names) member_rows = connection.execute( text( f""" SELECT {_hanmac_build_select_alias(member_no_col, "member_no")}, {_hanmac_build_select_alias(member_name_col, "member_name")}, {_hanmac_build_select_alias(grade_col, "grade_code")}, {_hanmac_build_select_alias(entry_date_col, "entry_date")}, {_hanmac_build_select_alias(leave_date_col, "leave_date")} FROM `{schema_name}`.`member_tbl` """ ) ).mappings().all() today = date.today() buckets: dict[str, dict[str, Any]] = {} for row in member_rows: grade_code = normalize_text(row.get("grade_code")) or "(빈값)" entry_date = _hanmac_parse_date_value(row.get("entry_date")) leave_date = _hanmac_parse_date_value(row.get("leave_date")) is_active = (entry_date is None or entry_date <= today) and (leave_date is None or leave_date >= today) bucket = buckets.setdefault( grade_code, { "grade_code": grade_code, "mapped_name": rank_code_names.get(grade_code, ""), "normalized_name": _normalize_labor_grade_name(rank_code_names.get(grade_code) or grade_code), "member_count": 0, "active_member_count": 0, "examples": [], }, ) bucket["member_count"] += 1 if is_active: bucket["active_member_count"] += 1 example_name = normalize_text(row.get("member_name")) or normalize_text(row.get("member_no")) if example_name and len(bucket["examples"]) < 5 and example_name not in bucket["examples"]: bucket["examples"].append(example_name) rows = sorted( buckets.values(), key=lambda item: ( item["grade_code"] == "(빈값)", str(item["grade_code"]), ), ) schema_summaries.append({**diagnostics, "rows": rows}) return {"status": "ok", "schemas": schema_summaries} finally: test_engine.dispose() def _hanmac_load_joint_assignment_records( connection: Any, schema_name: str, metadata: dict[str, list[str]], start_date: date, end_date: date, ) -> tuple[list[dict[str, Any]], dict[str, Any]]: diagnostics = { "joint_absent_codes": {}, "joint_assignment_source_rows": 0, "joint_assignment_records": 0, "joint_assignment_code_matched_rows": 0, "joint_assignment_text_matched_rows": 0, "joint_assignment_table": "", } columns = metadata.get("userstate_tbl") or [] if not columns: return [], diagnostics member_col = _hanmac_find_column(columns, ["MemberNo", "member_no", "EmpNo", "UserID", "MemberID", "member_id"]) start_col = _hanmac_find_column(columns, ["start_time", "StartTime", "s_date", "SDate", "start_date", "StartDate"]) end_col = _hanmac_find_column(columns, ["end_time", "EndTime", "e_date", "EDate", "end_date", "EndDate"]) project_col = _hanmac_find_column(columns, ["NewProjectCode", "new_project_code", "ProjectCode", "project_code", "ProjectKey", "PCode"]) fallback_project_col = _hanmac_find_column(columns, ["ProjectCode", "project_code"]) note_col = _hanmac_find_column(columns, ["note", "Note", "memo", "Memo", "remark", "Remark"]) sub_code_col = _hanmac_find_column(columns, ["sub_code", "SubCode", "AbsentCode", "absent_code"]) active_code_col = _hanmac_find_column(columns, ["active_code", "ActiveCode"]) if not member_col or not start_col: return [], diagnostics joint_codes = _hanmac_load_joint_assignment_absent_codes(connection, schema_name, metadata) diagnostics["joint_absent_codes"] = joint_codes code_values = sorted(joint_codes) code_conditions: list[str] = [] params: dict[str, Any] = { "start_date": start_date.isoformat(), "end_date": end_date.isoformat(), } for index, code in enumerate(code_values): key = f"joint_code_{index}" params[key] = code if sub_code_col: code_conditions.append(f"CAST(`{sub_code_col}` AS CHAR) = :{key}") if active_code_col: code_conditions.append(f"CAST(`{active_code_col}` AS CHAR) = :{key}") text_conditions = [] for column in (note_col,): if column: text_conditions.append(f"CAST(`{column}` AS CHAR) LIKE :joint_text") params["joint_text"] = "%합사%" filters = code_conditions + text_conditions if not filters: return [], diagnostics date_end_expr = f"LEFT(CAST(`{end_col}` AS CHAR), 10)" if end_col else f"LEFT(CAST(`{start_col}` AS CHAR), 10)" query = f""" SELECT {_hanmac_build_select_alias(member_col, "member_no")}, {_hanmac_build_select_alias(start_col, "start_date")}, {_hanmac_build_select_alias(end_col, "end_date")}, {_hanmac_build_select_alias(project_col, "project_code")}, {_hanmac_build_select_alias(fallback_project_col, "fallback_project_code")}, {_hanmac_build_select_alias(note_col, "note")}, {_hanmac_build_select_alias(sub_code_col, "sub_code")}, {_hanmac_build_select_alias(active_code_col, "active_code")} FROM `{schema_name}`.`userstate_tbl` WHERE `{member_col}` IS NOT NULL AND LEFT(CAST(`{start_col}` AS CHAR), 10) <= :end_date AND {date_end_expr} >= :start_date AND ({' OR '.join(filters)}) """ rows = connection.execute(text(query), params).mappings().all() diagnostics["joint_assignment_source_rows"] = len(rows) diagnostics["joint_assignment_table"] = "userstate_tbl" records: list[dict[str, Any]] = [] for row in rows: member_no = normalize_text(row.get("member_no")) record_start = _hanmac_parse_date_value(row.get("start_date")) record_end = _hanmac_parse_date_value(row.get("end_date")) or record_start if not member_no or not record_start: continue if record_end and record_end < record_start: record_start, record_end = record_end, record_start sub_code = normalize_text(row.get("sub_code")) active_code = normalize_text(row.get("active_code")) matched_code = sub_code if sub_code in joint_codes else active_code if active_code in joint_codes else "" note = normalize_text(row.get("note")) if matched_code: diagnostics["joint_assignment_code_matched_rows"] += 1 elif "합사" in note: diagnostics["joint_assignment_text_matched_rows"] += 1 records.append( { "member_no": member_no, "start_date": max(record_start, start_date), "end_date": min(record_end or record_start, end_date), "project_code": normalize_text(row.get("project_code")) or normalize_text(row.get("fallback_project_code")), "joint_code": matched_code, "joint_label": joint_codes.get(matched_code) or "합사", "note": note, "source": "userstate_tbl", } ) diagnostics["joint_assignment_records"] = len(records) return records, diagnostics def _hanmac_member_status_label(entry_date: date | None, leave_date: date | None, today: date) -> str: if leave_date and leave_date < today: return "퇴사" if entry_date and entry_date > today: return "입사전" return "재직" def _hanmac_member_matches_filter( member_record: dict[str, Any], employment_filter: str, start_date: date, end_date: date, today: date, ) -> bool: if employment_filter == "all": return True entry_date = _hanmac_parse_date_value(member_record.get("entry_date")) leave_date = _hanmac_parse_date_value(member_record.get("leave_date")) if employment_filter in {"current", "active"}: return (entry_date is None or entry_date <= today) and (leave_date is None or leave_date >= today) if employment_filter == "retired": return leave_date is not None and leave_date < today if employment_filter == "period": entry_ok = entry_date is None or entry_date <= end_date leave_ok = leave_date is None or leave_date >= start_date return entry_ok and leave_ok return True def _hanmac_member_is_active_on(member_record: dict[str, Any], work_date: date | None) -> bool: if work_date is None: return True entry_date = _hanmac_parse_date_value(member_record.get("entry_date")) leave_date = _hanmac_parse_date_value(member_record.get("leave_date")) if entry_date and work_date < entry_date: return False if leave_date and work_date > leave_date: return False return True def _hanmac_expected_regular_hours_for_period( member_record: dict[str, Any], start_date: date, end_date: date, holiday_dates: set[date], ) -> float: entry_date = _hanmac_parse_date_value(member_record.get("entry_date")) leave_date = _hanmac_parse_date_value(member_record.get("leave_date")) effective_start = max(start_date, entry_date) if entry_date else start_date effective_end = min(end_date, leave_date) if leave_date else end_date if effective_end < effective_start: return 0.0 work_days = 0 for work_date in _hanmac_iter_dates(effective_start, effective_end): if work_date.weekday() >= 5 or work_date in holiday_dates: continue work_days += 1 return round(work_days * 8.0, 2) def _hanmac_aggregate_cache_key(payload: dict[str, Any]) -> str: employment_value = normalize_text(payload.get("employment") or "all") if employment_value == "current": employment_value = "active" include_center_member_nos = sorted(_hanmac_normalize_member_restore_keys(payload)) normalized = { "logic_version": "hanmac-aggregate-v19-regular-gap-remarks", "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_PRIMARY_MANHOUR_SCHEMA), "view": normalize_text(payload.get("view") or "member"), "employment": employment_value, "start_date": normalize_text(payload.get("start_date")), "end_date": normalize_text(payload.get("end_date")), "include_center_member_nos": include_center_member_nos, } 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: if ( "center_members" not in projection_payload or "joint_members" not in projection_payload or "source_diagnostics" not in projection_payload ): cached_payload, cached_fresh = _load_hanmac_aggregate_cache(cache_key) if cached_payload and cached_fresh: cached_payload.setdefault("cache_meta", {}) cached_payload["cache_meta"]["pending_refresh"] = False return cached_payload projection_payload.setdefault("cache_meta", {}) projection_payload["cache_meta"]["pending_refresh"] = False return projection_payload if projection_payload and not projection_fresh: if ( "center_members" not in projection_payload or "joint_members" not in projection_payload or "source_diagnostics" not in projection_payload ): cached_payload, cached_fresh = _load_hanmac_aggregate_cache(cache_key) if cached_payload: cached_payload.setdefault("cache_meta", {}) cached_payload["cache_meta"]["pending_refresh"] = not cached_fresh return cached_payload 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 = { "logic_version": "hanmac-aggregate-v16-grade-code-map", "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_PRIMARY_MANHOUR_SCHEMA), "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")), "include_center_member_nos": sorted(_hanmac_normalize_member_restore_keys(payload)), "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, ) _maybe_run_db_vacuum() 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 maintenance_enabled = normalize_text(os.getenv("HM_APP_MAINTENANCE_ENABLED", "1")).lower() not in { "0", "false", "no", "off", } if not maintenance_enabled: logger.info("Skipping automatic cache maintenance; HM_APP_MAINTENANCE_ENABLED=0") return 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 _system_job_to_payload(row: Any | None) -> dict[str, Any] | None: if row is None: return None item = dict(row) for key in ("params_json", "result_json"): try: item[key.replace("_json", "")] = json.loads(str(item.get(key) or "{}")) except Exception: item[key.replace("_json", "")] = {} return item def _cleanup_stale_system_jobs(reason: str = "startup", *, all_running: bool = False) -> int: global _SYSTEM_JOB_LAST_STALE_CLEANUP_AT if not all_running: now = time.time() if now - _SYSTEM_JOB_LAST_STALE_CLEANUP_AT < 60.0: return 0 _SYSTEM_JOB_LAST_STALE_CLEANUP_AT = now stale_seconds = max(60, int(os.getenv("HM_SYSTEM_JOB_STALE_SECONDS", str(SYSTEM_JOB_STALE_RUNNING_SECONDS)) or SYSTEM_JOB_STALE_RUNNING_SECONDS)) message = ( "자동 정리: 서버 재시작으로 중단된 실행 중 작업을 실패 처리했습니다." if all_running else f"자동 정리: {stale_seconds // 60}분 이상 갱신되지 않은 실행 중 작업을 실패 처리했습니다." ) error_message = f"stale running system job cleaned during {reason}" where_clause = "status = 'running'" if all_running else "status = 'running' AND updated_at < datetime('now', :threshold)" params = { "message": message, "error_message": error_message, "threshold": f"-{stale_seconds} seconds", } try: with engine.begin() as conn: result = conn.execute( text( f""" UPDATE system_jobs SET status = 'failed', message = :message, error_message = :error_message, finished_at = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP WHERE {where_clause} """ ), params, ) return int(result.rowcount or 0) except Exception as exc: logger.warning("stale system job cleanup skipped: %s", exc) return 0 def _fetch_system_job(job_id: str) -> dict[str, Any] | None: init_db() with engine.begin() as conn: row = conn.execute( text( """ SELECT * FROM system_jobs WHERE id = :id """ ), {"id": job_id}, ).mappings().first() return _system_job_to_payload(row) def _fetch_latest_system_job( page_key: str = "", job_type: str = "", start_year: int | None = None, end_year: int | None = None, ) -> dict[str, Any] | None: init_db() filters = ["1 = 1"] params: dict[str, Any] = {} if page_key: filters.append("page_key = :page_key") params["page_key"] = page_key if job_type: filters.append("job_type = :job_type") params["job_type"] = job_type if start_year is not None: filters.append("COALESCE(start_year, -1) = COALESCE(:start_year, -1)") params["start_year"] = start_year if end_year is not None: filters.append("COALESCE(end_year, -1) = COALESCE(:end_year, -1)") params["end_year"] = end_year with engine.begin() as conn: row = conn.execute( text( f""" SELECT * FROM system_jobs WHERE {' AND '.join(filters)} ORDER BY created_at DESC, id DESC LIMIT 1 """ ), params, ).mappings().first() return _system_job_to_payload(row) def _create_system_job( *, page_key: str, job_type: str, start_year: int | None = None, end_year: int | None = None, params: dict[str, Any] | None = None, ) -> dict[str, Any]: init_db() normalized_page = normalize_text(page_key) normalized_type = normalize_text(job_type) if not normalized_page or not normalized_type: raise ValueError("작업 페이지와 작업 종류가 필요합니다.") params = dict(params or {}) _cleanup_stale_system_jobs("job creation") job_id = "" for attempt in range(12): try: with engine.begin() as conn: existing = conn.execute( text( """ SELECT * FROM system_jobs WHERE page_key = :page_key AND job_type = :job_type AND COALESCE(start_year, -1) = COALESCE(:start_year, -1) AND COALESCE(end_year, -1) = COALESCE(:end_year, -1) AND status IN ('queued', 'running') ORDER BY created_at DESC LIMIT 1 """ ), { "page_key": normalized_page, "job_type": normalized_type, "start_year": start_year, "end_year": end_year, }, ).mappings().first() if existing: _SYSTEM_JOB_EVENT.set() payload = _system_job_to_payload(existing) or {} payload["reused"] = True return payload job_id = uuid.uuid4().hex conn.execute( text( """ INSERT INTO system_jobs ( id, page_key, job_type, status, start_year, end_year, params_json, progress_current, progress_total, message, result_json, error_message, created_at, updated_at ) VALUES ( :id, :page_key, :job_type, 'queued', :start_year, :end_year, :params_json, 0, 0, :message, '{}', '', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP ) """ ), { "id": job_id, "page_key": normalized_page, "job_type": normalized_type, "start_year": start_year, "end_year": end_year, "params_json": json.dumps(params, ensure_ascii=False), "message": "작업 대기 중입니다.", }, ) break except OperationalError as exc: if "database is locked" not in str(exc).lower() or attempt >= 11: raise time.sleep(1.0) _ensure_system_job_worker() _SYSTEM_JOB_EVENT.set() return _fetch_system_job(job_id) or {"id": job_id, "status": "queued"} def _update_system_job( job_id: str, *, status: str | None = None, message: str | None = None, progress_current: int | None = None, progress_total: int | None = None, result: dict[str, Any] | None = None, error_message: str | None = None, started: bool = False, finished: bool = False, ) -> None: assignments = ["updated_at = CURRENT_TIMESTAMP"] params: dict[str, Any] = {"id": job_id} if status is not None: assignments.append("status = :status") params["status"] = status if message is not None: assignments.append("message = :message") params["message"] = message if progress_current is not None: assignments.append("progress_current = :progress_current") params["progress_current"] = int(progress_current) if progress_total is not None: assignments.append("progress_total = :progress_total") params["progress_total"] = int(progress_total) if result is not None: assignments.append("result_json = :result_json") params["result_json"] = json.dumps(result, ensure_ascii=False, default=str) if error_message is not None: assignments.append("error_message = :error_message") params["error_message"] = error_message if started: assignments.append("started_at = CURRENT_TIMESTAMP") if finished: assignments.append("finished_at = CURRENT_TIMESTAMP") with engine.begin() as conn: conn.execute( text(f"UPDATE system_jobs SET {', '.join(assignments)} WHERE id = :id"), params, ) def _claim_next_system_job() -> dict[str, Any] | None: init_db() _cleanup_stale_system_jobs("worker claim") with engine.begin() as conn: row = conn.execute( text( """ SELECT * FROM system_jobs WHERE status = 'queued' ORDER BY created_at ASC, id ASC LIMIT 1 """ ) ).mappings().first() if not row: return None conn.execute( text( """ UPDATE system_jobs SET status = 'running', started_at = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP, message = '작업을 시작했습니다.' WHERE id = :id AND status = 'queued' """ ), {"id": row["id"]}, ) return _fetch_system_job(str(row["id"])) _WEHAGO_COMPARE_VOUCHER_STATUSES = ( "voucher_matched", "voucher_unmatched", "voucher_recheck", "voucher_excepted", "hanmac_unconnected", "erp_voucher_matched", "erp_voucher_unmatched", ) def _latest_year_query_source(conn: sqlite3.Connection, year: int) -> tuple[int, int, str]: row = conn.execute( f""" SELECT start_year, end_year, signature, COUNT(DISTINCT status_key) AS status_count, MAX(updated_at) AS max_updated_at FROM wehago_compare_query_groups WHERE ? BETWEEN start_year AND end_year AND status_key IN ({','.join('?' for _ in _WEHAGO_COMPARE_VOUCHER_STATUSES)}) AND signature LIKE ? GROUP BY start_year, end_year, signature ORDER BY CASE WHEN start_year = ? AND end_year = ? THEN 0 ELSE 1 END ASC, CASE WHEN signature LIKE '%db-reconciled-v1%' THEN 0 ELSE 1 END ASC, status_count DESC, (end_year - start_year) ASC, max_updated_at DESC LIMIT 1 """, ( year, *_WEHAGO_COMPARE_VOUCHER_STATUSES, f"{QUERY_PROJECTION_VERSION}|%", year, year, ), ).fetchone() if row is None: raise RuntimeError(f"{year}년 조회 projection이 없습니다. 먼저 해당 연도 계산을 실행해주세요.") return int(row["start_year"] or year), int(row["end_year"] or year), str(row["signature"] or "") def _project_current_year_query_range(start_year: int, end_year: int) -> dict[str, int]: group_columns = [ "start_year", "end_year", "status_key", "signature", "group_index", "fiscal_year", "ledger_date", "proof_date", "voucher_no", "draft_no", "ledger_row_count", "voucher_row_count", "ledger_debit", "ledger_credit", "voucher_debit", "voucher_credit", "ledger_accounts", "voucher_accounts", "ledger_vendors", "voucher_vendors", "review_reason", "search_text", ] row_columns = [ "start_year", "end_year", "status_key", "signature", "group_index", "row_index", "fiscal_year", "status_label", "ledger_date", "proof_date", "voucher_no", "draft_no", "ledger_account_name", "voucher_account_name", "ledger_vendor", "voucher_vendor", "ledger_debit", "ledger_credit", "voucher_debit", "voucher_credit", "ledger_desc", "voucher_desc", "review_reason", "matched_case", "ledger_row_key", "voucher_row_key", "match_identity_key", ] conn = sqlite3.connect(DB_PATH) conn.row_factory = sqlite3.Row try: source_projections = { year: _latest_year_query_source(conn, year) for year in range(start_year, end_year + 1) } raw_signature = "|".join( f"{year}:{source_projections[year][0]}-{source_projections[year][1]}:{source_projections[year][2]}" for year in sorted(source_projections) ) signature = f"{QUERY_PROJECTION_VERSION}|year-current-projection-v1|{start_year}-{end_year}|{hashlib.sha1(raw_signature.encode('utf-8')).hexdigest()}" counters = {status: 0 for status in _WEHAGO_COMPARE_VOUCHER_STATUSES} conn.execute("BEGIN") try: conn.execute( "DELETE FROM wehago_compare_query_rows WHERE start_year = ? AND end_year = ? AND signature = ?", (start_year, end_year, signature), ) conn.execute( "DELETE FROM wehago_compare_query_groups WHERE start_year = ? AND end_year = ? AND signature = ?", (start_year, end_year, signature), ) for year in range(start_year, end_year + 1): source_start_year, source_end_year, source_signature = source_projections[year] source_groups = conn.execute( f""" SELECT * FROM wehago_compare_query_groups WHERE start_year = ? AND end_year = ? AND signature = ? AND fiscal_year = ? AND status_key IN ({','.join('?' for _ in _WEHAGO_COMPARE_VOUCHER_STATUSES)}) ORDER BY status_key, group_index """, (source_start_year, source_end_year, source_signature, year, *_WEHAGO_COMPARE_VOUCHER_STATUSES), ).fetchall() for group in source_groups: status_key = str(group["status_key"] or "") counters[status_key] += 1 target_group_index = counters[status_key] values = {column: group[column] for column in group_columns} values.update( { "start_year": start_year, "end_year": end_year, "signature": signature, "group_index": target_group_index, } ) conn.execute( f""" INSERT INTO wehago_compare_query_groups ( {', '.join(group_columns)}, created_at, updated_at ) VALUES ( {', '.join('?' for _ in group_columns)}, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP ) """, [values[column] for column in group_columns], ) source_rows = conn.execute( """ SELECT * FROM wehago_compare_query_rows WHERE start_year = ? AND end_year = ? AND signature = ? AND status_key = ? AND group_index = ? AND fiscal_year = ? ORDER BY row_index """, ( source_start_year, source_end_year, source_signature, status_key, int(group["group_index"] or 0), year, ), ).fetchall() for source_row in source_rows: row_values = {column: source_row[column] for column in row_columns} row_values.update( { "start_year": start_year, "end_year": end_year, "signature": signature, "group_index": target_group_index, } ) conn.execute( f""" INSERT INTO wehago_compare_query_rows ( {', '.join(row_columns)}, created_at, updated_at ) VALUES ( {', '.join('?' for _ in row_columns)}, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP ) """, [row_values[column] for column in row_columns], ) conn.execute( """ DELETE FROM wehago_summary_range_cache WHERE start_year = ? AND end_year = ? """, (start_year, end_year), ) conn.commit() except Exception: conn.rollback() raise return counters finally: conn.close() def _rebuild_wehago_compare_year_query_projections_for_range( job_id: str, start_year: int, end_year: int, ) -> None: from wehago_compare import _rebuild_compare_query_projection total = max(1, end_year - start_year + 1) with engine.connect().execution_options(isolation_level="AUTOCOMMIT") as conn: for index, year in enumerate(range(start_year, end_year + 1), start=1): _update_system_job( job_id, message=f"{year}년 전표비교 조회 projection을 생성 중입니다.", progress_current=index - 1, progress_total=total, ) _rebuild_compare_query_projection(engine, conn, year, year) _update_system_job( job_id, message=f"{year}년 전표비교 조회 projection 준비 완료.", progress_current=index, progress_total=total, ) def _rebuild_wehago_compare_year_snapshots_for_range( job_id: str, start_year: int, end_year: int, ) -> None: from scripts.project_export_cache_ranges import current_ready_export_signature from wehago_compare import _refresh_year_resolved_sections total = max(1, end_year - start_year + 1) with engine.connect().execution_options(isolation_level="AUTOCOMMIT") as conn: for index, year in enumerate(range(start_year, end_year + 1), start=1): try: sqlite_conn = sqlite3.connect(DB_PATH) sqlite_conn.row_factory = sqlite3.Row try: current_ready_export_signature(sqlite_conn, year) _update_system_job( job_id, message=f"{year}년 현재 로직 전표 행 캐시가 이미 준비되어 있습니다.", progress_current=index, progress_total=total, ) continue finally: sqlite_conn.close() except Exception: pass _update_system_job( job_id, message=f"{year}년 현재 로직 전표 스냅샷을 재생성 중입니다.", progress_current=index - 1, progress_total=total, ) _refresh_year_resolved_sections(conn, year) _update_system_job( job_id, message=f"{year}년 현재 로직 전표 스냅샷 준비 완료.", progress_current=index, progress_total=total, ) def _run_wehago_compare_project_range_job(job: dict[str, Any]) -> dict[str, Any]: from scripts.project_export_cache_ranges import project_range _assert_wal_allows_heavy_cache_write() job_id = str(job.get("id") or "") start_year = int(job.get("start_year") or 0) end_year = int(job.get("end_year") or 0) params = job.get("params") if isinstance(job.get("params"), dict) else {} reuse_existing_projection = bool(params.get("reuse_existing_projection")) if not start_year or not end_year: raise ValueError("기간 정보가 없어 전표비교 조회 캐시를 만들 수 없습니다.") if start_year > end_year: start_year, end_year = end_year, start_year _update_system_job( job_id, message=f"{start_year}~{end_year} 전표비교 조회 캐시를 생성 중입니다.", progress_current=0, progress_total=1, ) if reuse_existing_projection: _update_system_job( job_id, message=f"{start_year}~{end_year} 현재 로직 전표 행 캐시로 빠른 조회 캐시를 생성 중입니다.", ) try: conn = sqlite3.connect(DB_PATH) conn.row_factory = sqlite3.Row try: counts = project_range(conn, start_year, end_year) finally: conn.close() _clear_compare_runtime_caches() return { "start_year": start_year, "end_year": end_year, "counts": counts, "source": "current_export_row_cache", } except Exception as exc: _update_system_job( job_id, message=f"현재 로직 전표 행 캐시가 없어 조회 캐시만 빠르게 재생성할 수 없습니다: {exc}", ) raise RuntimeError( "현재 로직 전표 행 캐시가 아직 준비되지 않아 조회 캐시를 갱신하지 않았습니다. " "기존 projection을 재사용하면 새 로직이 반영되지 않으므로, 연도별 스냅샷을 별도 증분 작업으로 먼저 만들어야 합니다." ) from exc _update_system_job( job_id, message=f"{start_year}~{end_year} 현재 로직 전표 스냅샷을 준비 중입니다.", ) _rebuild_wehago_compare_year_snapshots_for_range(job_id, start_year, end_year) _update_system_job( job_id, message=f"{start_year}~{end_year} 현재 로직 projection을 합산 중입니다.", ) conn = sqlite3.connect(DB_PATH) conn.row_factory = sqlite3.Row try: counts = project_range(conn, start_year, end_year) finally: conn.close() _clear_compare_runtime_caches() return { "start_year": start_year, "end_year": end_year, "counts": counts, } def _assert_wal_allows_heavy_cache_write() -> None: wal_path = DB_PATH.with_name(f"{DB_PATH.name}-wal") wal_bytes = wal_path.stat().st_size if wal_path.exists() else 0 if wal_bytes < WAL_BLOCK_HEAVY_BYTES: return raise RuntimeError( "현재 DB WAL 파일이 " f"{wal_bytes / (1024 * 1024):,.0f}MB로 대량 캐시 작업 중단 기준 " f"{WAL_BLOCK_HEAVY_BYTES / (1024 * 1024):,.0f}MB를 초과했습니다. " "신규 캐시 생성을 시작하지 않고, 서버 중단 후 백업/checkpoint 및 캐시 분리 작업을 먼저 수행해야 합니다." ) def _load_existing_wehago_compare_export_projection_job(start_year: int, end_year: int) -> dict[str, Any] | None: from scripts.project_export_cache_ranges import current_ready_export_signature, projection_signature conn = sqlite3.connect(DB_PATH) conn.row_factory = sqlite3.Row try: try: signatures = { year: current_ready_export_signature(conn, year) for year in range(start_year, end_year + 1) } except Exception: return None expected_signature = projection_signature(signatures, start_year, end_year) row = conn.execute( """ SELECT signature, MAX(updated_at) AS updated_at FROM wehago_compare_query_groups WHERE start_year = ? AND end_year = ? AND signature = ? GROUP BY signature ORDER BY updated_at DESC LIMIT 1 """, (start_year, end_year, expected_signature), ).fetchone() if row is None: return None counts = _empty_compare_metric_counts() for status_key, row_count in conn.execute( """ SELECT status_key, COUNT(*) FROM wehago_compare_query_groups WHERE start_year = ? AND end_year = ? AND signature = ? GROUP BY status_key """, (start_year, end_year, str(row["signature"] or "")), ).fetchall(): if str(status_key or "") in counts: counts[str(status_key or "")] = int(row_count or 0) for status_key, row_count in conn.execute( """ SELECT status, COUNT(*) FROM wehago_comparison_results WHERE fiscal_year BETWEEN ? AND ? AND status IN ('matched', 'ledger_only', 'voucher_only', 'amount_mismatch') GROUP BY status """, (start_year, end_year), ).fetchall(): if str(status_key or "") in counts: counts[str(status_key or "")] = int(row_count or 0) return { "id": f"cached-{start_year}-{end_year}", "page_key": "wehago_compare", "job_type": "wehago_compare_project_range", "status": "done", "start_year": start_year, "end_year": end_year, "params": {"source": "existing_current_export_row_cache"}, "progress_current": 1, "progress_total": 1, "message": "기존 조회 캐시가 이미 준비되어 있습니다.", "result": { "start_year": start_year, "end_year": end_year, "counts": counts, "source": "existing_current_export_row_cache", }, "error_message": "", "updated_at": str(row["updated_at"] or ""), } finally: conn.close() def _assert_wehago_compare_current_export_rows_ready(start_year: int, end_year: int) -> None: from scripts.project_export_cache_ranges import current_ready_export_signature conn = sqlite3.connect(DB_PATH) conn.row_factory = sqlite3.Row try: missing: list[str] = [] for year in range(start_year, end_year + 1): try: current_ready_export_signature(conn, year) except Exception as exc: missing.append(f"{year}: {exc}") if missing: raise RuntimeError( "현재 로직 전표 행 캐시가 아직 준비되지 않아 조회 캐시 작업을 등록하지 않았습니다. " "기존 projection을 재사용하면 새 로직이 반영되지 않습니다. " + " / ".join(missing) ) finally: conn.close() def _run_process_cost_bootstrap_job(job: dict[str, Any]) -> dict[str, Any]: job_id = str(job.get("id") or "") params = job.get("params") if isinstance(job.get("params"), dict) else {} source = normalize_text(params.get("source")) or "hanmac" start_year = parse_optional_year(params.get("start_year") or job.get("start_year")) end_year = parse_optional_year(params.get("end_year") or job.get("end_year")) code = normalize_text(params.get("code")) include_related = bool(params.get("include_related")) active_related = normalize_text(params.get("active_related")) _update_system_job( job_id, message="프로젝트 원가 데이터를 서버에서 계산 중입니다.", progress_current=0, progress_total=1, ) payload = _build_process_cost_bootstrap_payload_uncached( source, start_year, end_year, code, include_related, active_related, ) cache_params = _process_cost_bootstrap_cache_params( source, start_year, end_year, code, include_related, active_related, ) cache_key = _process_cost_bootstrap_cache_key( source, start_year, end_year, code, include_related, active_related, ) row_count = len(payload.get("projects") or []) _store_system_page_cache( "process_cost_bootstrap", cache_key, params=cache_params, payload=payload, row_count=row_count, signature=f"process-cost-bootstrap-v1|{cache_key}", ) memory_key = ( cache_params["source"], cache_params["start_year"], cache_params["end_year"], cache_params["code"], cache_params["include_related"], cache_params["active_related"], ) _set_runtime_cache_entry(_PROCESS_COST_PROJECT_DETAIL_CACHE, memory_key, payload) return { "source": cache_params["source"], "start_year": payload.get("selectedStartYear"), "end_year": payload.get("selectedEndYear"), "code": payload.get("selectedCode"), "project_count": row_count, "cache_key": cache_key, } def _run_dashboard_bootstrap_job(job: dict[str, Any]) -> dict[str, Any]: job_id = str(job.get("id") or "") params = job.get("params") if isinstance(job.get("params"), dict) else {} overview_year = parse_optional_year(params.get("overview_year") or job.get("start_year")) _update_system_job( job_id, message="대시보드 데이터를 서버에서 계산 중입니다.", progress_current=0, progress_total=1, ) 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, } cache_params = {"overview_year": overview_year or 0} cache_key = _json_hash(cache_params) row_count = sum( len(payload.get(key) or []) for key in ( "yearly_summary", "monthly_summary", "project_revenue_mix_yearly", "project_revenue_mix_monthly", ) ) _store_system_page_cache( "dashboard_bootstrap", cache_key, params=cache_params, payload=payload, row_count=row_count, signature=f"dashboard-bootstrap-v1|{cache_key}", ) _set_deepcopy_ttl_cache_entry( _DASHBOARD_BOOTSTRAP_CACHE, _DASHBOARD_BOOTSTRAP_CACHE_LOCK, (overview_year or 0,), payload, ) return { "overview_year": overview_year, "row_count": row_count, "cache_key": cache_key, } def _run_annual_summary_bootstrap_job(job: dict[str, Any]) -> dict[str, Any]: job_id = str(job.get("id") or "") _update_system_job( job_id, message="연도별 수익/비용 데이터를 서버에서 계산 중입니다.", progress_current=0, progress_total=1, ) payload = { "yearly_financial_series": get_financial_series("yearly"), "monthly_financial_series": get_financial_series("monthly"), } cache_params = {"scope": "annual-summary"} cache_key = _json_hash(cache_params) row_count = len(payload.get("yearly_financial_series") or []) + len(payload.get("monthly_financial_series") or []) _store_system_page_cache( "annual_summary_bootstrap", cache_key, params=cache_params, payload=payload, row_count=row_count, signature=f"annual-summary-bootstrap-v1|{cache_key}", ) _set_deepcopy_ttl_cache_entry( _ANNUAL_SUMMARY_BOOTSTRAP_CACHE, _ANNUAL_SUMMARY_BOOTSTRAP_CACHE_LOCK, ("annual-summary",), payload, ) return { "row_count": row_count, "cache_key": cache_key, } def _run_projects_bootstrap_job(job: dict[str, Any]) -> dict[str, Any]: job_id = str(job.get("id") or "") params = job.get("params") if isinstance(job.get("params"), dict) else {} selected_year = parse_optional_year(params.get("selected_year") or job.get("start_year")) _update_system_job( job_id, message="프로젝트 정보 데이터를 서버에서 계산 중입니다.", progress_current=0, progress_total=1, ) payload = { "revenue_mix": get_project_revenue_mix(selected_year), "project_cost_by_year": get_project_cost_by_year(None, all_years=True), "project_status_rows": get_project_status_search_rows(), } cache_scope = "all-project-cost-v3-slim-status" cache_params = {"selected_year": selected_year or 0, "project_cost_scope": cache_scope} cache_key = _json_hash(cache_params) row_count = sum( len(payload.get(key) or []) for key in ("revenue_mix", "project_cost_by_year", "project_status_rows") ) _store_system_page_cache( "projects_bootstrap", cache_key, params=cache_params, payload=payload, row_count=row_count, signature=f"projects-bootstrap-v1|{cache_key}", ) _set_deepcopy_ttl_cache_entry( _PROJECT_BOOTSTRAP_CACHE, _PROJECT_BOOTSTRAP_CACHE_LOCK, (selected_year or 0, cache_scope), payload, ) return { "selected_year": selected_year, "row_count": row_count, "cache_key": cache_key, } def _run_hanmac_preview_cache_job(job: dict[str, Any]) -> dict[str, Any]: job_id = str(job.get("id") or "") params = job.get("params") if isinstance(job.get("params"), dict) else {} cache_key = _hanmac_preview_cache_key(params) _update_system_job( job_id, message="hanmac 원본 테이블 미리보기 캐시를 서버에서 계산 중입니다.", progress_current=0, progress_total=1, ) payload = get_hanmac_table_preview(params) _store_hanmac_preview_cache(cache_key, payload) return { "schema": payload.get("schema"), "table": payload.get("table"), "row_count": len(payload.get("rows") or []), "cache_key": cache_key, } def _run_hanmac_aggregate_cache_job(job: dict[str, Any]) -> dict[str, Any]: job_id = str(job.get("id") or "") params = job.get("params") if isinstance(job.get("params"), dict) else {} cache_key = _hanmac_aggregate_cache_key(params) _update_system_job( job_id, message="hanmac 근태 집계 캐시를 서버에서 계산 중입니다.", progress_current=0, progress_total=1, ) payload = get_hanmac_aggregate_summary(params) _store_hanmac_aggregate_cache(cache_key, payload) return { "view": payload.get("view"), "start_date": payload.get("start_date"), "end_date": payload.get("end_date"), "row_count": len(payload.get("rows") or []), "cache_key": cache_key, } def _run_system_job(job: dict[str, Any]) -> None: job_id = str(job.get("id") or "") job_type = normalize_text(job.get("job_type")) try: if job_type == "wehago_compare_project_range": result = _run_wehago_compare_project_range_job(job) elif job_type == "process_cost_bootstrap": result = _run_process_cost_bootstrap_job(job) elif job_type == "dashboard_bootstrap": result = _run_dashboard_bootstrap_job(job) elif job_type == "annual_summary_bootstrap": result = _run_annual_summary_bootstrap_job(job) elif job_type == "projects_bootstrap": result = _run_projects_bootstrap_job(job) elif job_type == "hanmac_preview_cache": result = _run_hanmac_preview_cache_job(job) elif job_type == "hanmac_aggregate_cache": result = _run_hanmac_aggregate_cache_job(job) else: raise ValueError(f"지원하지 않는 작업 종류입니다: {job_type}") _update_system_job( job_id, status="done", message="작업이 완료되었습니다.", progress_current=1, progress_total=1, result=result, error_message="", finished=True, ) except Exception as exc: logger.exception("system job failed(%s): %s", job_id, exc) _update_system_job( job_id, status="failed", message="작업이 실패했습니다.", error_message=str(exc), finished=True, ) def _system_job_worker_loop() -> None: while True: try: job = _claim_next_system_job() if job: _run_system_job(job) continue except Exception as exc: logger.warning("system job worker skipped due to error: %s", exc) _SYSTEM_JOB_EVENT.wait(2.0) _SYSTEM_JOB_EVENT.clear() def _ensure_system_job_worker() -> None: global _SYSTEM_JOB_WORKER_STARTED, _SYSTEM_JOB_WORKER_THREAD with _SYSTEM_JOB_WORKER_LOCK: if _SYSTEM_JOB_WORKER_STARTED and _SYSTEM_JOB_WORKER_THREAD and _SYSTEM_JOB_WORKER_THREAD.is_alive(): return worker = threading.Thread( target=_system_job_worker_loop, daemon=True, name="system-job-worker", ) worker.start() _SYSTEM_JOB_WORKER_THREAD = worker _SYSTEM_JOB_WORKER_STARTED = True def get_hanmac_aggregate_summary(payload: dict[str, Any]) -> dict[str, Any]: global _HANMAC_LAST_AGGREGATE_DIAGNOSTICS schema_name = HANMAC_PRIMARY_MANHOUR_SCHEMA start_date, end_date = _hanmac_resolve_period(payload) employment_filter = normalize_text(payload.get("employment")) or "all" if employment_filter == "current": employment_filter = "active" if employment_filter == "period": employment_filter = "all" view_mode = normalize_text(payload.get("view")) or "member" restored_center_member_keys = _hanmac_normalize_member_restore_keys(payload) configured_holiday_dates = _load_hanmac_holiday_dates(start_date, end_date) connect_payload = dict(payload) connect_payload["database"] = schema_name test_engine = _build_hanmac_mysql_engine(connect_payload) try: with test_engine.connect() as connection: metadata = _hanmac_fetch_table_columns(connection, schema_name) 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_flexible_work_excluded_rows": 0, "leave_types": [], "leave_candidate_tables": [], "leave_source_stats": [], "leave_rules": [], "addwork_columns": [], "addwork_member_col": "", "addwork_date_col": "", "addwork_project_col": "", "addwork_hour_col": "", "addwork_min_col": "", "addwork_source_rows": 0, "addwork_parsed_rows": 0, "addwork_zero_hour_rows": 0, "addwork_weekday_threshold_filtered_rows": 0, "addwork_holiday_threshold_filtered_rows": 0, "addwork_holiday_rows": 0, "addwork_inactive_filtered_rows": 0, "addwork_member_filtered_rows": 0, "official_overtime_rows": 0, "official_overtime_hours": 0.0, "official_overtime_tables": [], "addwork_overridden_by_official_rows": 0, "member_columns": [], "member_name_col": "", "member_name_fallback_rows": 0, "canonical_member_no_rows": 0, "member_group_col": "", "dept_source_table": "", "dept_code_col": "", "dept_name_col": "", "dept_mapped_rows": 0, "center_schema_available": False, "center_member_count": 0, "center_duplicate_member_count": 0, "center_same_member_hidden_count": 0, "center_excluded_member_count": 0, "center_restored_member_count": 0, "configured_holiday_count": len(configured_holiday_dates), "joint_absent_codes": {}, "joint_assignment_source_rows": 0, "joint_assignment_records": 0, "joint_assignment_code_matched_rows": 0, "joint_assignment_text_matched_rows": 0, "joint_assignment_regular_rows": 0, "joint_assignment_overtime_rows": 0, "joint_assignment_leave_skipped_days": 0, } member_info, member_diagnostics = _hanmac_load_member_info(connection, schema_name, metadata) member_info_by_key = { _hanmac_normalize_member_token(member_no): record for member_no, record in member_info.items() } source_diagnostics["member_columns"] = member_diagnostics.get("member_columns", []) source_diagnostics["member_name_col"] = member_diagnostics.get("member_name_col", "") source_diagnostics["member_name_fallback_rows"] = member_diagnostics.get("member_name_fallback_rows", 0) for diagnostics_key in ("member_group_col", "member_grade_col", "member_grade_code_map_rows", "dept_source_table", "dept_code_col", "dept_name_col", "dept_mapped_rows"): source_diagnostics[diagnostics_key] = member_diagnostics.get(diagnostics_key, "") center_metadata: dict[str, list[str]] = {} center_member_info: dict[str, dict[str, Any]] = {} center_member_rows_by_key: dict[str, dict[str, Any]] = {} center_excluded_member_nos: set[str] = set() try: center_metadata = _hanmac_fetch_table_columns(connection, HANMAC_CENTER_MANHOUR_SCHEMA) center_member_info, _center_diagnostics = _hanmac_load_member_info(connection, HANMAC_CENTER_MANHOUR_SCHEMA, center_metadata) source_diagnostics["center_schema_available"] = bool(center_metadata) except Exception as exc: logger.info("baron_manhour center member lookup skipped: %s", exc) center_metadata = {} center_member_info = {} primary_member_nos = set(member_info_by_key) primary_member_no_by_key = { _hanmac_normalize_member_token(member_no): member_no for member_no in member_info } primary_member_names = { _hanmac_normalize_person_name(record.get("member_name")): member_no for member_no, record in member_info.items() if _hanmac_normalize_person_name(record.get("member_name")) } for center_no, center_record in sorted(center_member_info.items(), key=lambda item: (item[1].get("member_name") or "", item[0])): center_no_key = _hanmac_normalize_member_token(center_no) center_name_key = _hanmac_normalize_person_name(center_record.get("member_name")) matched_member_no = "" matched_by = "" if center_no_key and center_no_key in primary_member_nos: matched_member_no = primary_member_no_by_key[center_no_key] matched_by = "사번" elif center_name_key and center_name_key in primary_member_names: matched_member_no = primary_member_names[center_name_key] matched_by = "이름" if not matched_member_no: display_key = f"center:{center_no_key}" center_member_rows_by_key[display_key] = { "member_no": "", "center_member_no": center_no, "center_member_nos": [center_no], "member_name": center_record.get("member_name") or center_no, "dept_name": center_record.get("dept_name") or "", "entry_date": center_record["entry_date"].isoformat() if center_record.get("entry_date") else "", "leave_date": center_record["leave_date"].isoformat() if center_record.get("leave_date") else "", "status": "센터/총괄 전용", "matched_by": "", "source_schema": HANMAC_CENTER_MANHOUR_SCHEMA, "table_label": "센터/총괄", "restored": False, "can_restore": False, } continue member_key = _hanmac_normalize_member_token(matched_member_no) restored = center_no_key in restored_center_member_keys or member_key in restored_center_member_keys if center_no_key == member_key: source_diagnostics["center_same_member_hidden_count"] += 1 if not restored: center_excluded_member_nos.add(member_key) display_key = f"member:{member_key}" existing_center_row = center_member_rows_by_key.get(display_key) if existing_center_row: if center_no not in existing_center_row["center_member_nos"]: existing_center_row["center_member_nos"].append(center_no) existing_center_row["center_member_no"] = ", ".join(existing_center_row["center_member_nos"]) existing_center_row["restored"] = bool(existing_center_row["restored"] or restored) existing_center_row["status"] = "복구" if existing_center_row["restored"] else "기본 제외" if existing_center_row["matched_by"] != matched_by: existing_center_row["matched_by"] = "사번/이름" continue center_member_rows_by_key[display_key] = { "member_no": matched_member_no, "center_member_no": center_no, "center_member_nos": [center_no], "member_name": center_record.get("member_name") or matched_member_no, "dept_name": center_record.get("dept_name") or "", "entry_date": center_record["entry_date"].isoformat() if center_record.get("entry_date") else "", "leave_date": center_record["leave_date"].isoformat() if center_record.get("leave_date") else "", "status": "복구" if restored else "기본 제외", "matched_by": matched_by, "source_schema": HANMAC_CENTER_MANHOUR_SCHEMA, "table_label": "센터/총괄", "restored": restored, "can_restore": True, } center_member_rows = sorted( center_member_rows_by_key.values(), key=lambda item: (item.get("member_name") or "", item.get("member_no") or item.get("center_member_no") or ""), ) source_diagnostics["center_member_count"] = len(center_member_info) source_diagnostics["center_duplicate_member_count"] = len(center_member_rows) source_diagnostics["center_excluded_member_count"] = len(center_excluded_member_nos) source_diagnostics["center_restored_member_count"] = sum(1 for item in center_member_rows if item.get("restored")) 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, ["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_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` """ ) ).mappings().all() for row in project_rows: project_code = normalize_text(row.get("project_code")) if project_code: project_map[project_code] = normalize_text(row.get("project_name")) or project_code 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")), } ) joint_assignment_records, joint_assignment_diagnostics = _hanmac_load_joint_assignment_records( connection, schema_name, metadata, start_date, end_date, ) source_diagnostics.update(joint_assignment_diagnostics) regular_tables = [ table_name for table_name, columns in metadata.items() if table_name.startswith("dallyproject") and "addwork" not in table_name.lower() and _hanmac_find_column(columns, ["MemberNo", "member_no"]) and _hanmac_find_column(columns, ["EntryTime", "entry_time"]) ] if "dallyproject_tbl" in regular_tables: regular_tables = ["dallyproject_tbl"] regular_records: list[dict[str, Any]] = [] official_overtime_records: list[dict[str, Any]] = [] for table_name in regular_tables: columns = metadata.get(table_name) or [] member_col = _hanmac_find_column(columns, ["MemberNo", "member_no"]) project_col = _hanmac_find_column(columns, ["project_code", "new_project_code", "ProjectCode", "ProjectKey", "EntryPCode", "PCode"]) entry_col = _hanmac_find_column(columns, ["EntryTime", "entry_time"]) leave_col = _hanmac_find_column(columns, ["LeaveTime", "leave_time"]) 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"]) overtime_time_col = _hanmac_find_column(columns, ["OverTime", "over_time", "OverWorkTime", "overtime", "overtime_hour", "OverHour"]) source_diagnostics["regular_tables"].append( { "table": table_name, "work_time_col": work_time_col or "", "holiday_time_col": holiday_time_col or "", "overtime_time_col": overtime_time_col or "", } ) if overtime_time_col: source_diagnostics["official_overtime_tables"].append( {"table": table_name, "overtime_time_col": overtime_time_col} ) if not member_col or not entry_col: continue where_clauses = [f"`{member_col}` IS NOT NULL"] params: dict[str, Any] = {} if start_date: where_clauses.append(f"LEFT(CAST(`{entry_col}` AS CHAR), 10) >= :start_date") params["start_date"] = start_date.isoformat() if end_date: where_clauses.append(f"LEFT(CAST(`{entry_col}` AS CHAR), 10) <= :end_date") params["end_date"] = end_date.isoformat() regular_rows = connection.execute( text( f""" SELECT {_hanmac_build_select_alias(member_col, "member_no")}, {_hanmac_build_select_alias(project_col, "project_code")}, {_hanmac_build_select_alias(entry_col, "entry_time")}, {_hanmac_build_select_alias(leave_col, "leave_time")}, {_hanmac_build_select_alias(work_time_col, "work_time")}, {_hanmac_build_select_alias(holiday_time_col, "holiday_time")}, {_hanmac_build_select_alias(overtime_time_col, "overtime_time")} FROM `{schema_name}`.`{table_name}` WHERE {' AND '.join(where_clauses)} """ ), params, ).mappings().all() for row in regular_rows: member_no = normalize_text(row.get("member_no")) if not member_no: continue work_time_hours = _hanmac_parse_duration_hours(row.get("work_time")) holiday_time_hours = _hanmac_parse_duration_hours(row.get("holiday_time")) calculated_regular_hours = _hanmac_calculate_regular_hours(row.get("entry_time"), row.get("leave_time")) regular_hours = calculated_regular_hours if calculated_regular_hours > 0 else work_time_hours official_overtime_hours = _hanmac_parse_duration_hours(row.get("overtime_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")), "entry_time": _hanmac_parse_datetime_value(row.get("entry_time")), "leave_time": _hanmac_parse_datetime_value(row.get("leave_time")), "regular_hours": 0.0 if holiday_time_hours > 0 else regular_hours, "holiday_hours": holiday_time_hours, "source_label": "", } ) if official_overtime_hours > 0: official_overtime_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")), "overtime_hours": round(official_overtime_hours, 2), "source": f"{table_name}.{overtime_time_col}", } ) source_diagnostics["official_overtime_rows"] += 1 source_diagnostics["official_overtime_hours"] = round( source_diagnostics["official_overtime_hours"] + official_overtime_hours, 2, ) addwork_columns = metadata.get("dallyproject_addwork_tbl") or [] overtime_records: list[dict[str, Any]] = [] source_diagnostics["addwork_columns"] = addwork_columns addwork_member_col = _hanmac_find_column(addwork_columns, ["MemberNo", "member_no", "EmpNo", "UserID", "MemberID", "member_id"]) addwork_date_col = _hanmac_find_column(addwork_columns, ["EntryTime", "entry_time", "WorkDate", "work_date", "EntryDate", "entry_date", "Date", "date", "AddWorkDate", "addwork_date", "RegDate", "reg_date", "s_date", "SDate"]) if addwork_member_col and addwork_date_col: addwork_project_col = _hanmac_find_column(addwork_columns, ["new_project_code", "project_code", "ProjectCode", "ProjectKey", "PCode", "EntryPCode"]) addwork_hour_col = _hanmac_find_column(addwork_columns, ["work_hour", "WorkHour", "OverTime", "OverWorkTime", "overtime", "overtime_hour", "OverHour", "AddWorkHour", "addwork_hour", "WorkTime", "work_time"]) addwork_min_col = _hanmac_find_column(addwork_columns, ["work_min", "WorkMin", "OverMin", "OverMinute", "overtime_min", "AddWorkMin", "addwork_min"]) source_diagnostics["addwork_member_col"] = addwork_member_col or "" source_diagnostics["addwork_date_col"] = addwork_date_col or "" source_diagnostics["addwork_project_col"] = addwork_project_col or "" source_diagnostics["addwork_hour_col"] = addwork_hour_col or "" source_diagnostics["addwork_min_col"] = addwork_min_col or "" where_clauses = [f"`{addwork_member_col}` IS NOT NULL"] params = {} if addwork_date_col and start_date: where_clauses.append(f"LEFT(CAST(`{addwork_date_col}` AS CHAR), 10) >= :start_date") params["start_date"] = start_date.isoformat() if addwork_date_col and end_date: where_clauses.append(f"LEFT(CAST(`{addwork_date_col}` AS CHAR), 10) <= :end_date") params["end_date"] = end_date.isoformat() addwork_rows = connection.execute( text( f""" SELECT {_hanmac_build_select_alias(addwork_member_col, "member_no")}, {_hanmac_build_select_alias(addwork_project_col, "project_code")}, {_hanmac_build_select_alias(addwork_date_col, "work_date")}, {_hanmac_build_select_alias(addwork_hour_col, "work_hour")}, {_hanmac_build_select_alias(addwork_min_col, "work_min")} FROM `{schema_name}`.`dallyproject_addwork_tbl` WHERE {' AND '.join(where_clauses)} """ ), params, ).mappings().all() source_diagnostics["addwork_source_rows"] = len(addwork_rows) for row in addwork_rows: member_no = normalize_text(row.get("member_no")) if not member_no: continue overtime_hours = round(_hanmac_parse_hour_minute_fields(row.get("work_hour"), row.get("work_min")), 2) if overtime_hours <= 0: source_diagnostics["addwork_zero_hour_rows"] += 1 source_diagnostics["addwork_parsed_rows"] += 1 overtime_records.append( { "member_no": member_no, "project_code": normalize_text(row.get("project_code")) or "", "work_date": _hanmac_parse_date_value(row.get("work_date")), "overtime_hours": overtime_hours, "source": "dallyproject_addwork_tbl", } ) official_overtime_keys = { (_hanmac_normalize_member_token(row["member_no"]), row.get("work_date")) for row in official_overtime_records if row.get("work_date") } fallback_overtime_records = [] for row in overtime_records: row_key = (_hanmac_normalize_member_token(row["member_no"]), row.get("work_date")) if row_key in official_overtime_keys: source_diagnostics["addwork_overridden_by_official_rows"] += 1 continue fallback_overtime_records.append(row) overtime_records = [*official_overtime_records, *fallback_overtime_records] leave_records: list[dict[str, Any]] = [] leave_rules = get_hanmac_leave_rules() source_diagnostics["leave_rules"] = [ { "keyword": rule.get("keyword"), "label": rule.get("leave_label"), "rule_type": rule.get("rule_type"), "default_hours": rule.get("default_hours"), } for rule in leave_rules ] tardy_columns = metadata.get("worker_tardy_tbl") or [] source_diagnostics["tardy_columns"] = tardy_columns leave_source_profiles = [ profile for table_name, table_columns in metadata.items() if (profile := _hanmac_leave_source_profile(table_name, table_columns)) ] source_diagnostics["leave_candidate_tables"] = [ { "table": profile["table"], "member_col": profile["member_col"], "date_col": profile["date_col"], "project_col": profile["project_col"], "type_cols": profile["type_cols"], } for profile in leave_source_profiles ] leave_type_values: set[str] = set() flexible_work_keywords = ("탄력", "단축근무", "근무시간조정", "출근시간조정", "유연근무") for leave_profile in leave_source_profiles: leave_table = leave_profile["table"] leave_source_stat = { "table": leave_table, "candidate_rows": 0, "matched_rows": 0, "flexible_work_excluded_rows": 0, } tardy_member_col = leave_profile["member_col"] tardy_date_col = leave_profile["date_col"] tardy_end_date_col = leave_profile["end_date_col"] tardy_project_col = leave_profile["project_col"] tardy_value_col = leave_profile["value_col"] tardy_hour_col = leave_profile["hour_col"] tardy_min_col = leave_profile["min_col"] where_clauses = [f"`{tardy_member_col}` IS NOT NULL"] params = {} if tardy_date_col and 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") params["end_date"] = end_date.isoformat() tardy_rows = connection.execute( text( f""" SELECT {_hanmac_build_select_alias(tardy_member_col, "member_no")}, {_hanmac_build_select_alias(tardy_date_col, "work_date")}, {_hanmac_build_select_alias(tardy_end_date_col, "end_date")}, {_hanmac_build_select_alias(tardy_project_col, "project_code")}, {_hanmac_build_text_concat_alias(leave_profile["type_cols"], "leave_type")}, {_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}`.`{leave_table}` WHERE {' AND '.join(where_clauses)} """ ), params, ).mappings().all() source_diagnostics["tardy_candidate_rows"] += len(tardy_rows) leave_source_stat["candidate_rows"] = len(tardy_rows) for row in tardy_rows: member_no = normalize_text(row.get("member_no")) leave_type = normalize_text(row.get("leave_type")) if not member_no or not leave_type: continue if any(keyword in leave_type.lower() for keyword in flexible_work_keywords): source_diagnostics["leave_flexible_work_excluded_rows"] += 1 leave_source_stat["flexible_work_excluded_rows"] += 1 continue leave_rule = _hanmac_match_leave_rule(leave_type, leave_rules) if not leave_rule: continue source_diagnostics["leave_matched_rows"] += 1 leave_source_stat["matched_rows"] += 1 leave_type_values.add(leave_type) 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) embedded_leave_hours = _hanmac_extract_leave_hours_from_text(leave_type) explicit_leave_hours = row.get("leave_hour") if _hanmac_parse_float_value(explicit_leave_hours) <= 0 and embedded_leave_hours > 0: explicit_leave_hours = embedded_leave_hours leave_amount, leave_hours, leave_hours_source = _hanmac_calculate_leave_amounts( leave_type=leave_type, leave_value=row.get("leave_value"), leave_hour=explicit_leave_hours, leave_min=row.get("leave_min"), rule=leave_rule, full_date_count=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, "project_code": normalize_text(row.get("project_code")) or "", "leave_days": round(daily_leave_amount, 4), "leave_hours": round(daily_leave_hours, 4), "leave_type": leave_type, "leave_rule": leave_rule.get("leave_label") or leave_rule.get("keyword") or "", "leave_hours_source": leave_hours_source, "leave_source_table": leave_table, } ) source_diagnostics["leave_source_stats"].append(leave_source_stat) 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]] = {} project_aggregates: dict[str, dict[str, Any]] = {} leave_days_by_member_date: dict[tuple[str, date], float] = {} def get_member_record(member_no: str) -> dict[str, Any]: return member_info.get(member_no) or member_info_by_key.get(_hanmac_normalize_member_token(member_no)) or { "member_no": member_no, "member_name": member_no, "entry_date": None, "leave_date": None, "dept_name": "", } def canonical_member_no(member_no: Any) -> str: raw_member_no = normalize_text(member_no) member_record = member_info_by_key.get(_hanmac_normalize_member_token(raw_member_no)) canonical_no = normalize_text((member_record or {}).get("member_no")) or raw_member_no if raw_member_no and canonical_no and raw_member_no != canonical_no: source_diagnostics["canonical_member_no_rows"] += 1 return canonical_no def include_member(member_no: str) -> bool: member_record = get_member_record(member_no) if _hanmac_normalize_member_token(member_no) in center_excluded_member_nos: return False return _hanmac_member_matches_filter(member_record, employment_filter, start_date, end_date, today) joint_member_map: dict[str, dict[str, Any]] = {} for record in joint_assignment_records: member_no = canonical_member_no(record["member_no"]) if not include_member(member_no): continue member_record = get_member_record(member_no) record_start = record.get("start_date") if not _hanmac_member_is_active_on(member_record, record_start): continue project_code = canonical_project_code(record.get("project_code")) content_parts = [ f"{record_start.isoformat() if record_start else ''}" + ( f"~{record.get('end_date').isoformat()}" if record.get("end_date") and record.get("end_date") != record_start else "" ), project_display_name(project_code), project_code, normalize_text(record.get("note")), ] content = " / ".join(part for part in content_parts if part) bucket = joint_member_map.setdefault( member_no, { "member_no": member_no, "member_name": member_record.get("member_name") or member_no, "member_grade": member_record.get("member_grade") or "", "entry_date": member_record["entry_date"].isoformat() if member_record.get("entry_date") else "", "leave_date": member_record["leave_date"].isoformat() if member_record.get("leave_date") else "", "contents": [], "info_count": 0, }, ) if content and content not in bucket["contents"]: bucket["contents"].append(content) bucket["info_count"] += 1 joint_members = sorted( ( { **item, "content": "\n".join(item.pop("contents", [])), } for item in joint_member_map.values() ), key=lambda item: (-int(item.get("info_count") or 0), item.get("member_no") or ""), ) for row in leave_records: member_no = canonical_member_no(row["member_no"]) work_date = row.get("work_date") if not include_member(member_no): continue member_record = get_member_record(member_no) if not _hanmac_member_is_active_on(member_record, work_date): continue if work_date: key = (member_no, work_date) leave_days_by_member_date[key] = round( leave_days_by_member_date.get(key, 0.0) + row["leave_days"], 2, ) for record in joint_assignment_records: member_no = canonical_member_no(record["member_no"]) if not include_member(member_no): continue member_record = get_member_record(member_no) record_start = record.get("start_date") record_end = record.get("end_date") or record_start if not record_start or not record_end: continue if record_end < record_start: record_start, record_end = record_end, record_start for work_date in _hanmac_iter_dates(record_start, record_end): if work_date.weekday() >= 5 or work_date in configured_holiday_dates: continue if not _hanmac_member_is_active_on(member_record, work_date): continue leave_days = min(1.0, max(0.0, leave_days_by_member_date.get((member_no, work_date), 0.0))) regular_hours = round(max(0.0, 8.0 * (1.0 - leave_days)), 4) if regular_hours <= 0: source_diagnostics["joint_assignment_leave_skipped_days"] += 1 continue regular_records.append( { "member_no": member_no, "project_code": normalize_text(record.get("project_code")) or "", "work_date": work_date, "entry_time": None, "leave_time": None, "regular_hours": regular_hours, "holiday_hours": 0.0, "source_label": "합사", "joint_label": record.get("joint_label") or "합사", "joint_code": record.get("joint_code") or "", "note": record.get("note") or "", } ) source_diagnostics["joint_assignment_regular_rows"] += 1 if leave_days <= 0: overtime_records.append( { "member_no": member_no, "project_code": normalize_text(record.get("project_code")) or "", "work_date": work_date, "overtime_hours": 3.0, "source": "합사", "raw_overtime_hours": 3.0, "joint_label": record.get("joint_label") or "합사", "joint_code": record.get("joint_code") or "", "note": record.get("note") or "", } ) source_diagnostics["joint_assignment_overtime_rows"] += 1 def ensure_member_bucket(member_no: str) -> dict[str, Any]: member_record = get_member_record(member_no) return member_aggregates.setdefault( member_no, { "member_no": member_no, "member_name": member_record["member_name"], "status": _hanmac_member_status_label(member_record["entry_date"], member_record["leave_date"], today), "entry_date": member_record["entry_date"].isoformat() if member_record["entry_date"] else "", "leave_date": member_record["leave_date"].isoformat() if member_record["leave_date"] else "", "dept_name": member_record["dept_name"], "member_grade": member_record.get("member_grade", ""), "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 = canonical_project_code(project_code) or "(미지정)" return project_aggregates.setdefault( project_key, { "project_code": 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": [], }, ) regular_day_groups: dict[tuple[str, date], dict[str, Any]] = {} for row in regular_records: member_no = canonical_member_no(row["member_no"]) if not include_member(member_no): continue member_record = get_member_record(member_no) work_date = row.get("work_date") if not work_date or not _hanmac_member_is_active_on(member_record, work_date): continue key = (member_no, work_date) day_group = regular_day_groups.setdefault( key, { "member_no": member_no, "work_date": work_date, "project_hours": {}, "holiday_project_hours": {}, "project_source_labels": {}, "entries": [], "ordered_entries": [], }, ) 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"))) source_label = normalize_text(row.get("source_label")) 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, ) if source_label: day_group["project_source_labels"].setdefault(project_code, set()).add(source_label) day_group["entries"].append( { "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), "regular_hours": round(raw_hours, 2), "holiday_hours": round(raw_holiday_hours, 2), "source_label": source_label, } ) day_group["ordered_entries"].append( { "project_code": project_code, "entry_time": row.get("entry_time"), "leave_time": row.get("leave_time"), "regular_hours": raw_hours, "holiday_hours": raw_holiday_hours, "source_label": source_label, } ) 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))) is_configured_holiday = work_date in configured_holiday_dates is_weekend_or_holiday = work_date.weekday() >= 5 or is_configured_holiday or raw_holiday_total_hours > 0 weekday_cap = 0.0 if is_weekend_or_holiday else max(0.0, 8.0 * (1.0 - leave_days)) capped_regular_hours = _hanmac_floor_regular_hours(min(raw_total_hours, weekday_cap)) holiday_source_total = raw_holiday_total_hours if raw_holiday_total_hours > 0 else (raw_total_hours if is_weekend_or_holiday else 0.0) holiday_hours = _hanmac_cap_holiday_hours(holiday_source_total) allocated_project_regular_hours = _hanmac_allocate_recognized_hours( capped_regular_hours, day_group["project_hours"], ) holiday_source_hours = ( day_group["holiday_project_hours"] if raw_holiday_total_hours > 0 else day_group["project_hours"] ) allocated_project_holiday_hours = _hanmac_allocate_recognized_hours( holiday_hours, holiday_source_hours, ) 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), "recognized_hours": round(allocated_project_regular_hours.get(project_code, 0.0), 2), "source_label": ", ".join(sorted(day_group["project_source_labels"].get(project_code, set()))), } 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, "holiday_reason": "주말" if work_date.weekday() >= 5 else ("휴일표" if is_configured_holiday else "HolidayTime"), "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), "recognized_hours": allocated_project_holiday_hours.get(project_code, 0.0), } 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: member_bucket["project_codes"].add(project_code) if len(day_group["entries"]) > 1: collapsed_entries: dict[tuple[str, str, str], dict[str, Any]] = {} for entry in day_group["entries"]: collapse_key = ( canonical_project_code(entry.get("project_code")), normalize_text(entry.get("source_label")), canonical_project_code(entry.get("raw_project_code") or entry.get("project_code")), ) collapsed_entry = collapsed_entries.setdefault( collapse_key, { **entry, "regular_hours": 0.0, "holiday_hours": 0.0, "row_count": 0, }, ) collapsed_entry["regular_hours"] = round( collapsed_entry["regular_hours"] + float(entry.get("regular_hours") or 0.0), 2, ) collapsed_entry["holiday_hours"] = round( collapsed_entry["holiday_hours"] + float(entry.get("holiday_hours") or 0.0), 2, ) collapsed_entry["row_count"] += 1 member_bucket["multi_entry_days"] += 1 member_bucket["multi_entry_details"].append( { "work_date": work_date.isoformat(), "row_count": len(day_group["entries"]), "display_row_count": len(collapsed_entries), "raw_total_hours": raw_total_hours, "capped_regular_hours": capped_regular_hours, "leave_days": round(leave_days, 2), "entries": sorted( collapsed_entries.values(), key=lambda item: (-item["regular_hours"], item["project_code"]), ), } ) if raw_total_hours <= 0: continue if holiday_hours > 0: for project_code, project_hours in holiday_source_hours.items(): project_bucket = ensure_project_bucket(project_code) project_hours = round(project_hours, 2) project_holiday_hours = allocated_project_holiday_hours.get(project_code, 0.0) project_bucket["holiday_hours"] += project_holiday_hours if project_holiday_hours > 0: project_bucket["holiday_details"].append( { "work_date": work_date.isoformat(), "member_no": member_no, "member_name": member_record["member_name"], "holiday_hours": round(project_holiday_hours, 2), "raw_project_hours": project_hours, } ) project_bucket["member_nos"].add(member_no) if is_weekend_or_holiday 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) project_regular_hours = round(allocated_project_regular_hours.get(project_code, 0.0), 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), "source_label": ", ".join(sorted(day_group["project_source_labels"].get(project_code, set()))), } ) project_bucket["member_nos"].add(member_no) overtime_day_groups: dict[tuple[str, date | None], dict[str, Any]] = {} for row in overtime_records: member_no = canonical_member_no(row["member_no"]) if not include_member(member_no): source_diagnostics["addwork_member_filtered_rows"] += 1 continue member_record = get_member_record(member_no) if not _hanmac_member_is_active_on(member_record, row["work_date"]): source_diagnostics["addwork_inactive_filtered_rows"] += 1 continue group_key = (_hanmac_normalize_member_token(member_no), row.get("work_date")) day_group = overtime_day_groups.setdefault( group_key, {"member_no": member_no, "member_record": member_record, "work_date": row.get("work_date"), "rows": []}, ) day_group["rows"].append(row) for day_group in overtime_day_groups.values(): member_no = day_group["member_no"] member_record = day_group["member_record"] overtime_work_date = day_group["work_date"] raw_day_overtime_hours = round( sum(_hanmac_parse_float_value(row.get("overtime_hours")) for row in day_group["rows"]), 4, ) is_overtime_holiday = bool( overtime_work_date and (overtime_work_date.weekday() >= 5 or overtime_work_date in configured_holiday_dates) ) recognized_day_overtime_hours = 0.0 if is_overtime_holiday else _hanmac_cap_weekday_overtime(raw_day_overtime_hours) recognized_day_holiday_hours = _hanmac_cap_holiday_hours(raw_day_overtime_hours) if is_overtime_holiday else 0.0 if is_overtime_holiday: source_diagnostics["addwork_holiday_rows"] += 1 if raw_day_overtime_hours > 0 and recognized_day_holiday_hours <= 0: source_diagnostics["addwork_holiday_threshold_filtered_rows"] += 1 elif raw_day_overtime_hours > 0 and recognized_day_overtime_hours <= 0: source_diagnostics["addwork_weekday_threshold_filtered_rows"] += 1 overtime_allocation_source = { row_index: _hanmac_parse_float_value(row.get("overtime_hours")) for row_index, row in enumerate(day_group["rows"]) } allocated_overtime_hours = _hanmac_allocate_recognized_hours( recognized_day_overtime_hours, overtime_allocation_source, ) allocated_holiday_overtime_hours = _hanmac_allocate_recognized_hours( recognized_day_holiday_hours, overtime_allocation_source, ) collapsed_overtime_rows: dict[tuple[str, str, str], dict[str, Any]] = {} for row_index, row in enumerate(day_group["rows"]): raw_project_code = row["project_code"] project_code = canonical_project_code(raw_project_code) raw_overtime_hours = _hanmac_parse_float_value(row.get("raw_overtime_hours", row["overtime_hours"])) overtime_hours = allocated_overtime_hours.get(row_index, 0.0) holiday_overtime_hours = allocated_holiday_overtime_hours.get(row_index, 0.0) collapsed_key = ( project_code or "(미지정)", normalize_text(row.get("source")) or "", normalize_text(row.get("joint_code")) or "", ) collapsed_row = collapsed_overtime_rows.setdefault( collapsed_key, { **row, "project_code": raw_project_code, "canonical_project_code": project_code, "overtime_hours": 0.0, "holiday_overtime_hours": 0.0, "raw_overtime_hours": 0.0, }, ) collapsed_row["overtime_hours"] += overtime_hours collapsed_row["holiday_overtime_hours"] += holiday_overtime_hours collapsed_row["raw_overtime_hours"] += raw_overtime_hours for row in collapsed_overtime_rows.values(): raw_project_code = row["project_code"] project_code = row.get("canonical_project_code") or canonical_project_code(raw_project_code) raw_overtime_hours = _hanmac_parse_float_value(row.get("raw_overtime_hours")) overtime_hours = _hanmac_parse_float_value(row.get("overtime_hours")) holiday_overtime_hours = _hanmac_parse_float_value(row.get("holiday_overtime_hours")) member_bucket = ensure_member_bucket(member_no) member_bucket["overtime_hours"] += overtime_hours member_bucket["holiday_hours"] += holiday_overtime_hours if overtime_hours > 0: member_bucket["overtime_work_day_keys"].add(overtime_work_date) member_bucket["overtime_details"].append( { "work_date": overtime_work_date.isoformat() if overtime_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(overtime_hours, 2), "raw_overtime_hours": round(raw_overtime_hours, 2), "raw_day_overtime_hours": round(raw_day_overtime_hours, 2), "source": row.get("source") or "", } ) if holiday_overtime_hours > 0: member_bucket["holiday_details"].append( { "work_date": overtime_work_date.isoformat() if overtime_work_date else "", "holiday_hours": round(holiday_overtime_hours, 2), "raw_holiday_hours": round(raw_overtime_hours, 2), "projects": [ { "project_code": project_code or "(미지정)", "project_name": project_display_name(project_code), "hours": round(holiday_overtime_hours, 2), } ], } ) if project_code: member_bucket["project_codes"].add(project_code) project_bucket = ensure_project_bucket(project_code) project_bucket["overtime_hours"] += overtime_hours project_bucket["holiday_hours"] += holiday_overtime_hours if overtime_hours > 0: project_bucket["overtime_work_day_keys"].add((member_no, overtime_work_date)) project_bucket["overtime_details"].append( { "work_date": overtime_work_date.isoformat() if overtime_work_date else "", "member_no": member_no, "member_name": member_record["member_name"], "overtime_hours": round(overtime_hours, 2), "raw_overtime_hours": round(raw_overtime_hours, 2), "raw_day_overtime_hours": round(raw_day_overtime_hours, 2), "source": row.get("source") or "", } ) if holiday_overtime_hours > 0: project_bucket["holiday_details"].append( { "work_date": overtime_work_date.isoformat() if overtime_work_date else "", "member_no": member_no, "member_name": member_record["member_name"], "holiday_hours": round(holiday_overtime_hours, 2), "raw_holiday_hours": round(raw_overtime_hours, 2), } ) project_bucket["member_nos"].add(member_no) for row in leave_records: member_no = canonical_member_no(row["member_no"]) if not include_member(member_no): continue member_record = get_member_record(member_no) if not _hanmac_member_is_active_on(member_record, row["work_date"]): continue member_bucket = ensure_member_bucket(member_no) member_bucket["legal_leave_days"] += row["leave_days"] member_bucket["legal_leave_hours"] += row.get("leave_hours", row["leave_days"] * 8.0) leave_project_code = canonical_project_code(row.get("project_code")) 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_rule": row.get("leave_rule") or "", "leave_hours_source": row.get("leave_hours_source") or "", "leave_source_table": row.get("leave_source_table") or "", "project_code": leave_project_code or "", "project_name": project_display_name(leave_project_code) if leave_project_code else "법정휴가", "equivalent_project_codes": equivalent_project_codes(leave_project_code) if leave_project_code else [], "leave_days": round(row["leave_days"], 2), "leave_hours": round(row.get("leave_hours", row["leave_days"] * 8.0), 2), } ) if leave_project_code: project_bucket = ensure_project_bucket(leave_project_code) project_bucket["legal_leave_days"] += row["leave_days"] project_bucket["legal_leave_hours"] += row.get("leave_hours", row["leave_days"] * 8.0) project_bucket["member_nos"].add(member_no) project_bucket["leave_details"].append( { "work_date": row["work_date"].isoformat() if row.get("work_date") else "", "member_no": member_no, "member_name": member_record["member_name"], "leave_type": row.get("leave_type") or "법정휴가", "leave_rule": row.get("leave_rule") or "", "leave_hours_source": row.get("leave_hours_source") or "", "leave_source_table": row.get("leave_source_table") or "", "project_code": leave_project_code, "project_name": project_display_name(leave_project_code), "equivalent_project_codes": equivalent_project_codes(leave_project_code), "leave_days": round(row["leave_days"], 2), "leave_hours": round(row.get("leave_hours", row["leave_days"] * 8.0), 2), } ) for member_no, bucket in member_aggregates.items(): member_record = get_member_record(member_no) expected_regular_hours = _hanmac_expected_regular_hours_for_period( member_record, start_date, end_date, configured_holiday_dates, ) expected_after_leave_hours = round(max(0.0, expected_regular_hours - bucket["legal_leave_hours"]), 2) regular_hour_gap = round(bucket["regular_hours"] - expected_after_leave_hours, 2) bucket["expected_regular_hours"] = expected_regular_hours bucket["expected_regular_after_leave_hours"] = expected_after_leave_hours bucket["regular_hour_gap"] = regular_hour_gap 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() ] rows.sort(key=lambda item: (-item["total_hours"], item["project_code"])) columns = [ {"key": "project_code", "label": "프로젝트코드"}, {"key": "project_name", "label": "프로젝트명"}, {"key": "member_count", "label": "참여인원"}, {"key": "regular_hours", "label": "정규근로"}, {"key": "overtime_hours", "label": "연장근로"}, {"key": "total_hours", "label": "총근로"}, {"key": "legal_leave_days", "label": "법정휴가"}, ] else: rows = [ { "member_no": bucket["member_no"], "member_name": bucket["member_name"], "status": bucket["status"], "entry_date": bucket["entry_date"], "leave_date": bucket["leave_date"], "dept_name": bucket["dept_name"], "member_grade": bucket.get("member_grade", ""), "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"], "expected_regular_hours": round(bucket.get("expected_regular_hours", 0.0), 2), "expected_regular_after_leave_hours": round(bucket.get("expected_regular_after_leave_hours", 0.0), 2), "regular_hour_gap": round(bucket.get("regular_hour_gap", 0.0), 2), "remarks": " · ".join( part for part in ( f"중복 {bucket['multi_entry_days']}일" if bucket["multi_entry_days"] else "", ( f"근무시간 {bucket.get('regular_hour_gap', 0.0):+,.2f}시간" if abs(bucket.get("regular_hour_gap", 0.0)) >= 0.01 else "" ), ) if part ), "multi_entry_details": sorted( bucket["multi_entry_details"], key=lambda item: item["work_date"], ), } for bucket in member_aggregates.values() ] rows.sort(key=lambda item: (-item["total_hours"], item["member_no"])) columns = [ {"key": "member_no", "label": "사번"}, {"key": "member_name", "label": "이름"}, {"key": "member_grade", "label": "직급"}, {"key": "status", "label": "구분"}, {"key": "entry_date", "label": "입사일"}, {"key": "leave_date", "label": "퇴사일"}, {"key": "dept_name", "label": "부서"}, {"key": "regular_hours", "label": "정규근로"}, {"key": "overtime_hours", "label": "연장근로"}, {"key": "total_hours", "label": "총근로"}, {"key": "legal_leave_days", "label": "법정휴가"}, {"key": "project_count", "label": "프로젝트수"}, {"key": "remarks", "label": "비고"}, ] summary = { "member_count": len(member_aggregates), "project_count": len(project_aggregates), "regular_hours": round(sum(item.get("regular_hours", 0.0) for item in rows), 2), "overtime_hours": round(sum(item.get("overtime_hours", 0.0) for item in rows), 2), "legal_leave_days": round(sum(item.get("legal_leave_days", 0.0) for item in rows), 2), "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) response_payload = { "status": "ok", "view": view_mode, "start_date": start_date.isoformat(), "end_date": end_date.isoformat(), "employment": employment_filter, "columns": columns, "rows": rows, "summary": summary, "center_members": center_member_rows, "joint_members": joint_members, "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() def build_hanmac_mysql_error_message(exc: OperationalError) -> str: raw_message = str(exc.orig or exc) lowered = raw_message.lower() if "access denied for user" in lowered: return ( "MySQL 로그인은 시도됐지만 권한이 거부되었습니다. 비밀번호가 다르거나, " "현재 앱 서버의 접속 출발지 IP(172.31.18.135)에 대해 root 계정이 허용되지 않았을 가능성이 큽니다." ) if "no route to host" in lowered or "network is unreachable" in lowered: return ( "현재 앱 서버가 172.16.42.111까지 네트워크 경로를 찾지 못하고 있습니다. " "입력값 문제보다는 내부망 경로, VPN, 라우팅 또는 방화벽 상태를 먼저 확인해주세요." ) if "unknown character set" in lowered: return "MySQL 서버 문자셋 호환성 문제였습니다. 앱 쪽 설정을 조정했으니 다시 연결 확인을 시도해주세요." if "unknown database" in lowered: return f"선택한 DB 이름을 찾지 못했습니다. {HANMAC_EXTERNAL_SCHEMA_LABEL} 선택을 다시 확인해주세요." if "can't connect" in lowered or "connection refused" in lowered or "timed out" in lowered: return "MySQL 서버 포트에는 접근했지만 최종 연결에 실패했습니다. 서버 상태 또는 방화벽 설정을 확인해주세요." if "authentication plugin" in lowered: return "MySQL 인증 방식이 현재 앱과 맞지 않습니다. 서버 계정의 인증 플러그인 설정을 확인해주세요." return f"MySQL 연결에 실패했습니다. 상세 원인: {raw_message}" def build_wehago_compare_health_payload() -> dict[str, Any]: init_db() payload = get_wehago_compare_dashboard( engine, include_metric_counts=False, warm_caches=False, ) return { "status": "ok", "selected_start_year": payload.get("selected_start_year"), "selected_end_year": payload.get("selected_end_year"), "available_year_count": len(payload.get("available_years") or []), "metric_section_count": len(payload.get("metric_sections") or []), } @app.get("/health") async def health() -> dict[str, str]: return build_health_payload() @app.get("/health/wehago-compare") async def health_wehago_compare() -> JSONResponse: try: return JSONResponse(content=jsonable_encoder(build_wehago_compare_health_payload())) except Exception as exc: logger.exception("전표비교 readiness 에러: %s", exc) return JSONResponse(content={"status": "error", "error": str(exc)}, status_code=500) @app.get("/") async def home(request: Request, edit_id: int | None = None, overview_year: int | None = None): try: return render_home(request, edit_id=edit_id, overview_year=overview_year) except Exception as exc: logger.exception("홈페이지 에러: %s", exc) 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), headers={"Cache-Control": "no-store, max-age=0"}, ) except Exception as exc: logger.exception("대시보드 부트스트랩 데이터 조회 에러: %s", exc) return JSONResponse(content={"error": str(exc)}, status_code=500) @app.post("/dashboard/api/rebuild-cache") async def dashboard_rebuild_cache(request: Request): try: payload = await request.json() if not isinstance(payload, dict): payload = {} overview_year = parse_optional_year(payload.get("overview_year")) job = await run_in_threadpool( _create_system_job, page_key="dashboard", job_type="dashboard_bootstrap", start_year=overview_year, end_year=overview_year, params={"overview_year": overview_year}, ) return JSONResponse(content=jsonable_encoder({"ok": True, "job": job})) except Exception as exc: logger.exception("대시보드 캐시 작업 등록 에러: %s", exc) return JSONResponse(content={"ok": False, "error": str(exc)}, status_code=500) @app.get("/projects") async def projects( request: Request, edit_code: str | None = None, focus_code: str | None = None, year: str | None = None, ): try: return render_projects_page( request, edit_code=edit_code, focus_code=focus_code, selected_year=parse_optional_year(year), ) except Exception as exc: logger.exception("사업현황 페이지 에러: %s", exc) return HTMLResponse("

서버 오류

로그를 확인해주세요.

", status_code=500) @app.get("/cost-analysis") async def cost_analysis(request: Request): try: return render_cost_analysis_page(request) except Exception as exc: logger.exception("프로젝트 손익분석 페이지 에러: %s", exc) return HTMLResponse("

서버 오류

로그를 확인해주세요.

", status_code=500) @app.get("/cost-analysis/data") async def cost_analysis_data(start_date: str = "", end_date: str = "", mode: str = "individual"): try: payload = await run_in_threadpool(_cost_analysis_build_payload, start_date, end_date, mode) return JSONResponse( content=jsonable_encoder(payload), headers={"Cache-Control": "no-store, max-age=0"}, ) except Exception as exc: logger.exception("프로젝트 손익분석 데이터 조회 에러: %s", exc) return JSONResponse(content={"error": str(exc)}, status_code=500) @app.get("/cost-analysis/missing-grade-export") async def cost_analysis_missing_grade_export(start_date: str = "", end_date: str = "", codes: str = ""): try: start = _parse_iso_date(start_date) or date(date.today().year, 1, 1) end = _parse_iso_date(end_date) or date.today() if end < start: start, end = end, start requested_codes = {code.upper() for code in parse_support_dept_codes_param(codes)} project_meta = _cost_analysis_get_project_meta() rows = await run_in_threadpool( _cost_analysis_collect_missing_hanmac_grade_rows_yearly, start, end, project_meta, requested_codes or None, ) columns = [ ("work_date", "일자"), ("phase", "구분"), ("support_dept_code", "프로젝트코드"), ("project_name", "사업명"), ("member_no", "사번"), ("member_name", "성명"), ("dept_name", "부서"), ("raw_grade", "원천직급"), ("hour_kind", "투입구분"), ("hours", "투입시간"), ("source_project_code", "원천프로젝트코드"), ("source_project_name", "원천프로젝트명"), ("metric_range", "한맥집계기간"), ("metric_cache_key", "한맥캐시키"), ] csv_lines = [",".join(_hanmac_csv_escape(label) for _, label in columns)] for row in rows: csv_lines.append(",".join(_hanmac_csv_escape(row.get(key)) for key, _ in columns)) csv_bytes = ("\ufeff" + "\n".join(csv_lines) + "\n").encode("utf-8") file_name = f"cost_analysis_missing_grade_{end.isoformat()}.csv" return Response( content=csv_bytes, media_type="text/csv; charset=utf-8", headers={"Content-Disposition": f'attachment; filename="{file_name}"'}, ) except Exception as exc: logger.exception("프로젝트 손익분석 직급 누락 다운로드 에러: %s", exc) return JSONResponse(content={"error": str(exc)}, status_code=500) @app.get("/cost-analysis/missing-grade") async def cost_analysis_missing_grade(start_date: str = "", end_date: str = "", codes: str = ""): try: start = _parse_iso_date(start_date) or date(date.today().year, 1, 1) end = _parse_iso_date(end_date) or date.today() if end < start: start, end = end, start requested_codes = {code.upper() for code in parse_support_dept_codes_param(codes)} project_meta = _cost_analysis_get_project_meta() rows = await run_in_threadpool( _cost_analysis_collect_missing_hanmac_grade_rows_yearly, start, end, project_meta, requested_codes or None, ) summary_by_project: dict[str, dict[str, Any]] = {} for row in rows: code = normalize_text(row.get("support_dept_code")).upper() phase = normalize_text(row.get("phase")) summary = summary_by_project.setdefault( code, { "support_dept_code": code, "project_name": normalize_text(row.get("project_name")), "row_count": 0, "hours": 0.0, "pre_hours": 0.0, "during_hours": 0.0, "post_hours": 0.0, }, ) hours = normalize_amount(row.get("hours")) summary["row_count"] += 1 summary["hours"] += hours if phase == "사업전": summary["pre_hours"] += hours elif phase == "사업후": summary["post_hours"] += hours else: summary["during_hours"] += hours return JSONResponse( content=jsonable_encoder( { "rows": rows[:5000], "row_count": len(rows), "summary": sorted(summary_by_project.values(), key=lambda item: (-normalize_amount(item.get("hours")), normalize_text(item.get("support_dept_code")))), "truncated": len(rows) > 5000, } ) ) except Exception as exc: logger.exception("프로젝트 손익분석 직급 누락 조회 에러: %s", exc) return JSONResponse(content={"error": str(exc)}, status_code=500) @app.post("/cost-analysis/hanmac-cache-rebuild") async def cost_analysis_hanmac_cache_rebuild(request: Request): try: payload = await request.json() if not isinstance(payload, dict): payload = {} start = _parse_iso_date(payload.get("start_date")) or date(date.today().year, 1, 1) end = _parse_iso_date(payload.get("end_date")) or date.today() if end < start: start, end = end, start base_payload = { "host": normalize_text(payload.get("host")), "port": normalize_text(payload.get("port")) or "3306", "user": normalize_text(payload.get("user")), "password": payload.get("password") or "", "database": normalize_text(payload.get("database") or HANMAC_PRIMARY_MANHOUR_SCHEMA), "view": "member", "employment": normalize_text(payload.get("employment") or "all"), "include_center_member_nos": payload.get("include_center_member_nos") or [], } if not base_payload["host"] or not base_payload["user"] or not base_payload["password"]: raise ValueError("한맥 DB_external 접속 정보가 필요합니다. 한맥 DB_external 페이지에서 접속 정보를 저장한 뒤 다시 시도해주세요.") jobs = [] for year_slice in _iter_year_slices(start, end): job_payload = { **base_payload, "start_date": year_slice["start"].isoformat(), "end_date": year_slice["end"].isoformat(), } job = await run_in_threadpool( _create_system_job, page_key="hanmac_browser", job_type="hanmac_aggregate_cache", start_year=int(year_slice["year"]), end_year=int(year_slice["year"]), params=job_payload, ) jobs.append(job) await run_in_threadpool(_clear_cost_analysis_payload_caches) return JSONResponse(content=jsonable_encoder({"ok": True, "jobs": jobs, "job_count": len(jobs)})) except Exception as exc: logger.exception("프로젝트 손익분석 한맥 캐시 재생성 등록 에러: %s", exc) return JSONResponse(content={"error": str(exc)}, status_code=500) @app.get("/cost-analysis/detail") async def cost_analysis_detail( start_date: str = "", end_date: str = "", codes: str = "", phase: str = "", item: str = "", ): try: start = _parse_iso_date(start_date) or date(date.today().year, 1, 1) end = _parse_iso_date(end_date) or date.today() if end < start: start, end = end, start normalized_codes = [code.upper() for code in parse_support_dept_codes_param(codes)] if not normalized_codes: raise ValueError("조회할 프로젝트 코드가 필요합니다.") normalized_phase = normalize_text(phase).lower() normalized_item = normalize_text(item).lower() completion_dates = _cost_analysis_get_completion_billing_dates() if normalized_item == "labor": project_meta = _cost_analysis_get_project_meta() rows = _cost_analysis_load_hanmac_labor_detail_rows_yearly( start, end, normalized_codes, normalized_phase, project_meta, ) if rows: return JSONResponse( content=jsonable_encoder( { "detail_type": "labor", "rows": rows, "total_amount": sum(normalize_amount(row.get("amount")) for row in rows), } ) ) if normalized_item == "collection": in_clause, code_params = build_in_clause("cost_analysis_collection_code", normalized_codes) query = text( f""" SELECT COALESCE(date, '') AS posting_date, '수금' AS account_name, COALESCE(vendor, '') AS partner_name, COALESCE(note, '') AS memo1, COALESCE(amount, 0) AS amount FROM project_collection_entries WHERE support_dept_code IN ({in_clause}) AND COALESCE(date, '') >= :start_date AND COALESCE(date, '') <= :end_date ORDER BY date DESC, vendor """ ) params = {**code_params, "start_date": start.isoformat(), "end_date": end.isoformat()} with engine.begin() as conn: rows = [ { "posting_date": _date_text(row["posting_date"]), "account_name": normalize_text(row["account_name"]), "partner_name": normalize_text(row["partner_name"]), "memo1": normalize_text(row["memo1"]), "amount": int(round(normalize_amount(row["amount"]))), } for row in conn.execute(query, params).mappings() ] if not rows: billing_query = text( f""" SELECT COALESCE(COALESCE(tax_invoice_date, billing_date), '') AS posting_date, '수금' AS account_name, COALESCE(client_name, '') AS partner_name, COALESCE(note, '') AS memo1, COALESCE(collected_amount, 0) AS amount FROM project_billing_entries WHERE support_dept_code IN ({in_clause}) AND COALESCE(COALESCE(tax_invoice_date, billing_date), '') >= :start_date AND COALESCE(COALESCE(tax_invoice_date, billing_date), '') <= :end_date AND COALESCE(collected_amount, 0) <> 0 ORDER BY posting_date DESC, client_name """ ) rows = [ { "posting_date": _date_text(row["posting_date"]), "account_name": normalize_text(row["account_name"]), "partner_name": normalize_text(row["partner_name"]), "memo1": normalize_text(row["memo1"]), "amount": int(round(normalize_amount(row["amount"]))), } for row in conn.execute(billing_query, params).mappings() ] return JSONResponse(content=jsonable_encoder({"rows": rows, "total_amount": sum(row["amount"] for row in rows)})) if normalized_item == "billing": in_clause, code_params = build_in_clause("cost_analysis_billing_code", normalized_codes) query = text( f""" SELECT COALESCE(COALESCE(tax_invoice_date, billing_date), '') AS posting_date, '청구금액' AS account_name, COALESCE(client_name, '') AS partner_name, COALESCE(note, '') AS memo1, COALESCE(billed_amount, 0) AS amount FROM project_billing_entries WHERE support_dept_code IN ({in_clause}) AND COALESCE(COALESCE(tax_invoice_date, billing_date), '') >= :start_date AND COALESCE(COALESCE(tax_invoice_date, billing_date), '') <= :end_date AND COALESCE(billed_amount, 0) <> 0 ORDER BY posting_date DESC, client_name """ ) params = {**code_params, "start_date": start.isoformat(), "end_date": end.isoformat()} with engine.begin() as conn: rows = [ { "posting_date": _date_text(row["posting_date"]), "account_name": normalize_text(row["account_name"]), "partner_name": normalize_text(row["partner_name"]), "memo1": normalize_text(row["memo1"]), "amount": int(round(normalize_amount(row["amount"]))), } for row in conn.execute(query, params).mappings() ] return JSONResponse(content=jsonable_encoder({"rows": rows, "total_amount": sum(row["amount"] for row in rows)})) in_clause, code_params = build_in_clause("cost_analysis_detail_code", normalized_codes) query = text( f""" SELECT COALESCE(voucher_number, '') AS voucher_number, {COST_ANALYSIS_TX_DATE_SQL} AS posting_date, COALESCE(account_code, '') AS account_code, COALESCE(account_name, '') AS account_name, COALESCE(support_dept_code, '') AS support_dept_code, COALESCE(support_dept_name, '') AS support_dept_name, COALESCE(issuing_dept_code, '') AS issuing_dept_code, COALESCE(issuing_dept_name, '') AS issuing_dept_name, COALESCE(cost_dept_code, '') AS cost_dept_code, COALESCE(cost_dept_name, '') AS cost_dept_name, COALESCE(partner_name, '') AS partner_name, COALESCE(memo1, '') AS memo1, COALESCE(amount, 0) AS amount FROM transactions WHERE support_dept_code IN ({in_clause}) AND {COST_ANALYSIS_TX_DATE_SQL} >= :start_date AND {COST_ANALYSIS_TX_DATE_SQL} <= :end_date AND (account_code LIKE '4%' OR account_code LIKE '5%' OR account_code LIKE '6%') ORDER BY posting_date DESC, voucher_number DESC, partner_name, account_code """ ) params = {**code_params, "start_date": start.isoformat(), "end_date": end.isoformat()} result_rows: list[dict[str, Any]] = [] with engine.begin() as conn: for raw_row in conn.execute(query, params).mappings(): row = dict(raw_row) code = normalize_text(row.get("support_dept_code")).upper() bucket = _cost_analysis_financial_bucket(row.get("account_code")) posting_date = _date_text(row.get("posting_date")) row_phase = _cost_analysis_phase_for_transaction(code, posting_date, completion_dates) row_item = _cost_analysis_expense_item(row.get("account_code"), row.get("account_name"), _cost_analysis_is_sales_cost(row)) if row_item == "outsource" and row_phase == "pre": row_item = "overhead" if normalized_phase and normalized_phase != "all" and row_phase != normalized_phase: continue if normalized_item == "revenue" and bucket != "revenue": continue elif normalized_item == "cost_total" and row_item not in {"labor", "outsource", "overhead"}: continue elif normalized_item == "sga_total" and row_item != "sga": continue elif normalized_item == "sales_total" and row_item != "sales": continue elif normalized_item == "total_cost" and row_item not in {"labor", "outsource", "overhead", "sga", "sales"}: continue elif normalized_item in {"labor", "outsource", "overhead", "sga", "sales"} and row_item != normalized_item: continue elif normalized_item not in {"revenue", "cost_total", "sga_total", "sales_total", "total_cost", "labor", "outsource", "overhead", "sga", "sales"}: continue result_rows.append( { "posting_date": build_transaction_posting_display(row["voucher_number"], row["posting_date"]), "account_name": normalize_text(row["account_name"]), "partner_name": normalize_text(row["partner_name"]), "memo1": normalize_text(row["memo1"]), "amount": int(round(normalize_amount(row["amount"]))), } ) return JSONResponse(content=jsonable_encoder({"rows": result_rows, "total_amount": sum(row["amount"] for row in result_rows)})) except Exception as exc: logger.exception("프로젝트 손익분석 상세 조회 에러: %s", exc) return JSONResponse(content={"error": str(exc)}, status_code=500) @app.get("/projects/edit-data") async def project_edit_data(code: str | None = None): try: return JSONResponse(content=jsonable_encoder(get_project_status_for_edit(code))) except Exception as exc: logger.exception("사업현황 편집 데이터 조회 에러: %s", exc) return JSONResponse(content={"error": str(exc)}, status_code=500) @app.get("/projects/status-detail") async def project_status_detail(code: str | None = None): try: item = await run_in_threadpool(get_project_status_row_for_code, code) return JSONResponse(content=jsonable_encoder({"ok": True, "item": item or None})) except Exception as exc: logger.exception("사업현황 상세 데이터 조회 에러: %s", exc) return JSONResponse(content={"ok": False, "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), headers={"Cache-Control": "no-store, max-age=0"}, ) 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(get_projects_bootstrap_payload, selected_year) return JSONResponse( content=jsonable_encoder(payload), headers={"Cache-Control": "no-store, max-age=0"}, ) except Exception as exc: logger.exception("프로젝트 부트스트랩 데이터 조회 에러: %s", exc) return JSONResponse(content={"error": str(exc)}, status_code=500) @app.post("/projects/api/rebuild-cache") async def projects_rebuild_cache(request: Request): try: payload = await request.json() if not isinstance(payload, dict): payload = {} selected_year = parse_optional_year(payload.get("year")) job = await run_in_threadpool( _create_system_job, page_key="projects", job_type="projects_bootstrap", start_year=selected_year, end_year=selected_year, params={"selected_year": selected_year}, ) return JSONResponse(content=jsonable_encoder({"ok": True, "job": job})) except Exception as exc: logger.exception("프로젝트 정보 캐시 작업 등록 에러: %s", exc) return JSONResponse(content={"ok": False, "error": str(exc)}, status_code=500) @app.post("/projects/page-state") async def project_page_state_save(request: Request): try: payload = await request.json() if not isinstance(payload, dict): raise ValueError("잘못된 페이지 상태 형식입니다.") save_project_page_state(payload) return JSONResponse(content={"status": "ok"}) except Exception as exc: logger.exception("사업현황 페이지 상태 저장 에러: %s", exc) return JSONResponse(content={"error": str(exc)}, status_code=500) @app.get("/projects/page-state") async def project_page_state_load(session_id: str = ""): try: return JSONResponse(content=jsonable_encoder(get_project_page_state(session_id))) except Exception as exc: logger.exception("사업현황 페이지 상태 조회 에러: %s", exc) return JSONResponse(content={"error": str(exc)}, status_code=500) @app.post("/projects/related-links") async def project_related_links_save(request: Request): try: payload = await request.json() if not isinstance(payload, dict): raise ValueError("잘못된 연관 프로젝트 형식입니다.") base_code = normalize_text(payload.get("base_code")) related_codes = payload.get("related_codes") or [] if not isinstance(related_codes, list): raise ValueError("연관 프로젝트 목록 형식이 올바르지 않습니다.") save_project_related_links(base_code, related_codes) return JSONResponse(content={"status": "ok", "related_project_links": get_project_related_links_map()}) except Exception as exc: logger.exception("연관 프로젝트 저장 에러: %s", exc) return JSONResponse(content={"error": str(exc)}, status_code=500) @app.get("/projects/quick-links") async def project_quick_links_load(session_id: str = ""): try: return JSONResponse(content={"codes": get_project_quick_links(session_id)}) except Exception as exc: logger.exception("프로젝트 바로가기 조회 에러: %s", exc) return JSONResponse(content={"error": str(exc)}, status_code=500) @app.post("/projects/quick-links") async def project_quick_links_save(request: Request): try: payload = await request.json() if not isinstance(payload, dict): raise ValueError("잘못된 바로가기 형식입니다.") codes = payload.get("codes") or [] if not isinstance(codes, list): raise ValueError("바로가기 목록 형식이 잘못되었습니다.") session_id = normalize_text(payload.get("session_id")) save_project_quick_links(session_id, [normalize_text(code) for code in codes if isinstance(code, str)]) return JSONResponse(content={"status": "ok", "codes": get_project_quick_links(session_id)}) except Exception as exc: logger.exception("프로젝트 바로가기 저장 에러: %s", exc) return JSONResponse(content={"error": str(exc)}, status_code=500) @app.get("/process-cost/quick-links") async def process_cost_quick_links_load(): try: return JSONResponse(content={"codes": get_process_cost_quick_links()}) except Exception as exc: logger.exception("프로세스 원가 바로가기 조회 에러: %s", exc) return JSONResponse(content={"error": str(exc)}, status_code=500) @app.post("/process-cost/quick-links") async def process_cost_quick_links_save(request: Request): try: payload = await request.json() if not isinstance(payload, dict): raise ValueError("잘못된 바로가기 형식입니다.") codes = payload.get("codes") or [] if not isinstance(codes, list): raise ValueError("바로가기 목록 형식이 잘못되었습니다.") save_process_cost_quick_links([normalize_text(code) for code in codes if isinstance(code, str)]) return JSONResponse(content={"status": "ok", "codes": get_process_cost_quick_links()}) except Exception as exc: logger.exception("프로세스 원가 바로가기 저장 에러: %s", exc) return JSONResponse(content={"error": str(exc)}, status_code=500) @app.post("/projects/uncontracted-category") async def project_uncontracted_category_save(request: Request): try: payload = await request.json() if not isinstance(payload, dict): raise ValueError("잘못된 미계약 분류 형식입니다.") save_project_uncontracted_classification( payload.get("support_dept_code"), payload.get("category"), ) return JSONResponse(content={"status": "ok"}) except Exception as exc: logger.exception("미계약 분류 저장 에러: %s", exc) return JSONResponse(content={"error": str(exc)}, status_code=500) @app.post("/projects/runtime-setting") async def project_runtime_setting_save(request: Request): try: payload = await request.json() if not isinstance(payload, dict): raise ValueError("잘못된 설정 형식입니다.") save_project_runtime_setting(payload.get("item_key"), payload.get("value")) return JSONResponse(content={"status": "ok", "settings": get_project_runtime_settings()}) except Exception as exc: logger.exception("프로젝트 런타임 설정 저장 에러: %s", exc) return JSONResponse(content={"error": str(exc)}, status_code=500) @app.post("/projects/comparison-note") async def project_comparison_note_save(request: Request): try: payload = await request.json() if not isinstance(payload, dict): raise ValueError("잘못된 비고 형식입니다.") save_project_comparison_note( payload.get("support_dept_code"), payload.get("item_key"), payload.get("note"), ) return JSONResponse(content={"status": "ok"}) except Exception as exc: logger.exception("계획 대비 실제 비교 비고 저장 에러: %s", exc) return JSONResponse(content={"error": str(exc)}, status_code=500) @app.post("/projects/analysis-settings") async def project_analysis_settings_save(request: Request): try: payload = await request.json() if not isinstance(payload, dict): raise ValueError("잘못된 프로젝트 상세 설정 형식입니다.") inactive_related_codes = payload.get("inactive_related_codes") if inactive_related_codes is not None and not isinstance(inactive_related_codes, list): raise ValueError("제외 연관 프로젝트 형식이 올바르지 않습니다.") save_project_analysis_settings( payload.get("support_dept_code"), detail_note=payload.get("detail_note") if "detail_note" in payload else None, inactive_related_codes=inactive_related_codes if isinstance(inactive_related_codes, list) else None, labor_joint_exempt=payload.get("labor_joint_exempt") if "labor_joint_exempt" in payload else None, ) return JSONResponse(content={"status": "ok"}) except Exception as exc: logger.exception("프로젝트 상세 설정 저장 에러: %s", exc) return JSONResponse(content={"error": str(exc)}, status_code=500) @app.get("/projects/uncontracted-detail-transactions") async def project_uncontracted_detail_transactions( support_dept_code: str = "", kind: str = "expense", detail_type: str = "", year: int | None = None, month: int | None = None, category: str | None = None, start_year: int | None = None, end_year: int | None = None, ): try: normalized_code = normalize_text(support_dept_code) if not normalized_code: raise ValueError("프로젝트 코드가 필요합니다.") normalized_kind = normalize_text(kind).lower() if normalized_kind not in {"expense", "revenue"}: raise ValueError("조회 종류가 올바르지 않습니다.") normalized_detail_type = normalize_text(detail_type).lower() if normalized_detail_type not in {"year", "month", "category"}: raise ValueError("세부 조회 형식이 올바르지 않습니다.") filters = ["support_dept_code = :support_dept_code"] params: dict[str, Any] = {"support_dept_code": normalized_code} if normalized_kind == "expense": filters.append("accounting_category IN ('원가', '판관비')") else: filters.append(REVENUE_SQL) if normalized_detail_type == "month": if not year or not month: raise ValueError("월별 세부 조회에는 연도와 월이 필요합니다.") filters.append("year = :year") filters.append("month = :month") params["year"] = int(year) params["month"] = int(month) elif normalized_detail_type == "year": if not year: raise ValueError("연도별 세부 조회에는 연도가 필요합니다.") filters.append("year = :year") params["year"] = int(year) else: # category 상세는 프로젝트 생성 시기와 무관하게 선택된 연도 구간 안의 발생 전표를 모두 보여준다. if start_year: filters.append("year >= :start_year") params["start_year"] = int(start_year) if end_year: filters.append("year <= :end_year") params["end_year"] = int(end_year) if category: params["category"] = normalize_text(category) query = text( f""" SELECT 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(cost_dept_name, '') AS cost_dept_name, COALESCE(support_dept_code, '') AS support_dept_code, COALESCE(support_dept_name, '') AS support_dept_name, COALESCE(account_code, '') AS account_code, COALESCE(account_name, '') AS account_name, amount FROM transactions WHERE {' AND '.join(filters)} ORDER BY posting_date DESC, voucher_number DESC, partner_name, cost_dept_name, account_code """ ) with engine.begin() as conn: rows = [ { "posting_date": build_transaction_posting_display(row["voucher_number"], row["posting_date"]), "voucher_number": normalize_text(row["voucher_number"]), "partner_name": normalize_text(row["partner_name"]), "partner_code": normalize_text(row["partner_code"]), "cost_dept_name": normalize_text(row["cost_dept_name"]), "support_dept_code": normalize_text(row["support_dept_code"]), "support_dept_name": normalize_text(row["support_dept_name"]), "account_code": normalize_text(row["account_code"]), "account_name": normalize_text(row["account_name"]), "amount": int(round(float(row["amount"] or 0))), } for row in conn.execute(query, params).mappings() ] return JSONResponse( content={ "rows": rows, "total_amount": sum(int(row["amount"] or 0) for row in rows), "support_dept_code": normalized_code, "kind": normalized_kind, "detail_type": normalized_detail_type, "category": normalize_text(category), } ) except Exception as exc: logger.exception("미계약 세부 거래내역 조회 에러: %s", exc) return JSONResponse(content={"error": str(exc)}, status_code=500) @app.get("/projects/uncontracted-vendor-transactions") async def project_uncontracted_vendor_transactions( partner_code: str = "", partner_name: str = "", start_year: int | None = None, end_year: int | None = None, ): try: normalized_partner_code = normalize_text(partner_code) normalized_partner_name = normalize_text(partner_name) if not normalized_partner_code and not normalized_partner_name: raise ValueError("거래처 정보가 필요합니다.") filters = ["accounting_category IN ('원가', '판관비')"] params: dict[str, Any] = {} if start_year: filters.append("year >= :start_year") params["start_year"] = int(start_year) if end_year: filters.append("year <= :end_year") params["end_year"] = int(end_year) if normalized_partner_code: filters.append("COALESCE(partner_code, '') = :partner_code") params["partner_code"] = normalized_partner_code else: filters.append("COALESCE(partner_name, '') = :partner_name") params["partner_name"] = normalized_partner_name query = text( f""" SELECT 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(cost_dept_name, '') AS cost_dept_name, COALESCE(support_dept_code, '') AS support_dept_code, COALESCE(support_dept_name, '') AS support_dept_name, COALESCE(account_code, '') AS account_code, COALESCE(account_name, '') AS account_name, amount FROM transactions WHERE {' AND '.join(filters)} ORDER BY posting_date DESC, voucher_number DESC, cost_dept_name, account_code """ ) with engine.begin() as conn: rows = [ { "posting_date": build_transaction_posting_display(row["voucher_number"], row["posting_date"]), "voucher_number": normalize_text(row["voucher_number"]), "partner_name": normalize_text(row["partner_name"]), "partner_code": normalize_text(row["partner_code"]), "cost_dept_name": normalize_text(row["cost_dept_name"]), "support_dept_code": normalize_text(row["support_dept_code"]), "support_dept_name": normalize_text(row["support_dept_name"]), "account_code": normalize_text(row["account_code"]), "account_name": normalize_text(row["account_name"]), "amount": int(round(float(row["amount"] or 0))), } for row in conn.execute(query, params).mappings() ] return JSONResponse( content={ "rows": rows, "total_amount": sum(int(row["amount"] or 0) for row in rows), "partner_code": normalized_partner_code, "partner_name": normalized_partner_name, } ) except Exception as exc: logger.exception("거래처별 미계약 비용 상세 조회 에러: %s", exc) return JSONResponse(content={"error": str(exc)}, status_code=500) @app.get("/projects/comparison-actual-transactions") async def project_comparison_actual_transactions( support_dept_code: str = "", codes: str = "", group: str = "", account_label: str = "", ): try: normalized_codes = parse_support_dept_codes_param(codes, support_dept_code) if not normalized_codes: raise ValueError("프로젝트 코드가 필요합니다.") rows = fetch_project_expense_transaction_rows( normalized_codes, expense_group=group, account_label=account_label, ) return JSONResponse( content={ "rows": rows, "total_amount": sum(int(row["amount"] or 0) for row in rows), "support_dept_code": normalize_text(support_dept_code), "codes": normalized_codes, "group": normalize_text(group).lower(), "account_label": normalize_text(account_label), } ) except Exception as exc: logger.exception("비교 실제 집행 세부 거래내역 조회 에러: %s", exc) return JSONResponse(content={"error": str(exc)}, status_code=500) @app.get("/projects/comparison-vendor-transactions") async def project_comparison_vendor_transactions( support_dept_code: str = "", codes: str = "", partner_code: str = "", partner_name: str = "", start_date: str = "", end_date: str = "", ): try: normalized_codes = parse_support_dept_codes_param(codes, support_dept_code) if not normalized_codes: raise ValueError("프로젝트 코드가 필요합니다.") normalized_partner_code = normalize_text(partner_code) normalized_partner_name = normalize_text(partner_name) if not normalized_partner_code and not normalized_partner_name: raise ValueError("거래처 정보가 필요합니다.") project_start_date, project_end_date = get_project_expense_date_range(normalized_codes) effective_start_date = normalize_text(start_date) or project_start_date effective_end_date = normalize_text(end_date) or project_end_date filters = ["accounting_category IN ('원가', '판관비')"] params: dict[str, Any] = {} if effective_start_date: filters.append("COALESCE(posting_date, '') >= :start_date") params["start_date"] = effective_start_date if effective_end_date: filters.append("COALESCE(posting_date, '') <= :end_date") params["end_date"] = effective_end_date if normalized_partner_code: filters.append("COALESCE(partner_code, '') = :partner_code") params["partner_code"] = normalized_partner_code else: filters.append("COALESCE(partner_name, '') = :partner_name") params["partner_name"] = normalized_partner_name query = text( f""" SELECT 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(cost_dept_name, '') AS cost_dept_name, COALESCE(support_dept_code, '') AS support_dept_code, COALESCE(support_dept_name, '') AS support_dept_name, COALESCE(account_code, '') AS account_code, COALESCE(account_name, '') AS account_name, amount FROM transactions WHERE {' AND '.join(filters)} ORDER BY posting_date DESC, voucher_number DESC, cost_dept_name, account_code """ ) with engine.begin() as conn: filtered_rows = [ { "posting_date": build_transaction_posting_display(row["voucher_number"], row["posting_date"]), "voucher_number": normalize_text(row["voucher_number"]), "partner_name": normalize_text(row["partner_name"]), "partner_code": normalize_text(row["partner_code"]), "cost_dept_name": normalize_text(row["cost_dept_name"]), "support_dept_code": normalize_text(row["support_dept_code"]), "support_dept_name": normalize_text(row["support_dept_name"]), "account_code": normalize_text(row["account_code"]), "account_name": normalize_text(row["account_name"]), "amount": int(round(float(row["amount"] or 0))), } for row in conn.execute(query, params).mappings() ] return JSONResponse( content={ "rows": filtered_rows, "total_amount": sum(int(row["amount"] or 0) for row in filtered_rows), "support_dept_code": normalize_text(support_dept_code), "codes": normalized_codes, "partner_code": normalized_partner_code, "partner_name": normalized_partner_name, "start_date": effective_start_date, "end_date": effective_end_date, "project_start_date": project_start_date, "project_end_date": project_end_date, } ) except Exception as exc: logger.exception("비교 거래처 세부 조회 에러: %s", exc) return JSONResponse(content={"error": str(exc)}, status_code=500) @app.get("/annual-summary") async def annual_summary(request: Request): try: return render_annual_summary_page(request) except Exception as exc: logger.exception("연도별 수익 비용 정리 페이지 에러: %s", exc) return HTMLResponse("

서버 오류

로그를 확인해주세요.

", status_code=500) @app.get("/biz-process") async def biz_process(request: Request): context = base_context(request) context.update({"biz_process_src": "/static/hm-biz-process/flow_260320.html"}) return templates.TemplateResponse(request, "biz_process.html", context) @app.get("/biz-process-viewer") async def biz_process_viewer_root(): return RedirectResponse("/static/hm-biz-process/flow_260320.html") @app.get("/biz-process-viewer/process-map") async def biz_process_viewer_process_map(): return RedirectResponse("/static/hm-biz-process/process_map.html") @app.get("/biz-process-viewer/api/health") async def biz_process_viewer_health(): return {"ok": "true"} @app.get("/biz-process-viewer/api/flow-data") async def biz_process_viewer_flow_data(): try: payload = await run_in_threadpool(load_hmbiz_process_flow_data) return JSONResponse(content=jsonable_encoder(payload)) except Exception as exc: logger.exception("HM-BIZ-PROCESS 데이터 조회 에러: %s", exc) return JSONResponse(content={"error": str(exc)}, status_code=500) @app.put("/biz-process-viewer/api/flow-data") async def biz_process_viewer_save_flow_data(request: Request): try: payload = await request.json() result = await run_in_threadpool(save_hmbiz_process_flow_data, payload) return JSONResponse(content=jsonable_encoder(result)) except Exception as exc: logger.exception("HM-BIZ-PROCESS 데이터 저장 에러: %s", exc) return JSONResponse(content={"error": str(exc)}, 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), headers={"Cache-Control": "no-store, max-age=0"}, ) except Exception as exc: logger.exception("연도별 수익 비용 부트스트랩 데이터 조회 에러: %s", exc) return JSONResponse(content={"error": str(exc)}, status_code=500) @app.post("/annual-summary/api/rebuild-cache") async def annual_summary_rebuild_cache(): try: job = await run_in_threadpool( _create_system_job, page_key="annual_summary", job_type="annual_summary_bootstrap", start_year=None, end_year=None, params={"scope": "annual-summary"}, ) return JSONResponse(content=jsonable_encoder({"ok": True, "job": job})) except Exception as exc: logger.exception("연도별 수익 비용 캐시 작업 등록 에러: %s", exc) return JSONResponse(content={"ok": False, "error": str(exc)}, status_code=500) @app.get("/process-cost") async def process_cost( request: Request, source: str | None = None, start_year: str | None = None, end_year: str | None = None, code: str | None = None, include_related: str | None = None, active_related: str | None = None, ): try: return render_process_cost_page( request, source=source, start_year=parse_optional_year(start_year), end_year=parse_optional_year(end_year), code=code, include_related=normalize_text(include_related) in {"1", "true", "y", "yes", "on"}, active_related=active_related, ) except Exception as exc: logger.exception("프로세스 원가 페이지 에러: %s", exc) 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.post("/process-cost/api/rebuild-cache") async def process_cost_rebuild_cache(request: Request): try: payload = await request.json() if not isinstance(payload, dict): payload = {} source = normalize_text(payload.get("source")) or "hanmac" start_year = parse_optional_year(payload.get("start_year")) end_year = parse_optional_year(payload.get("end_year")) code = normalize_text(payload.get("code")) include_related = normalize_text(payload.get("include_related")) in {"1", "true", "y", "yes", "on"} active_related = normalize_text(payload.get("active_related")) job = await run_in_threadpool( _create_system_job, page_key="process_cost", job_type="process_cost_bootstrap", start_year=start_year, end_year=end_year, params={ "source": source, "start_year": start_year, "end_year": end_year, "code": code, "include_related": include_related, "active_related": active_related, }, ) return JSONResponse(content=jsonable_encoder({"ok": True, "job": job})) except Exception as exc: logger.exception("프로젝트 원가 캐시 작업 등록 에러: %s", exc) return JSONResponse(content={"ok": False, "error": str(exc)}, status_code=500) @app.get("/hanmac-browser") async def hanmac_browser(request: Request): try: return render_hanmac_browser_page(request) except Exception as exc: logger.exception("hanmac DB_external 페이지 에러: %s", exc) return HTMLResponse("

서버 오류

로그를 확인해주세요.

", status_code=500) @app.post("/hanmac-browser/api/test-connection") async def hanmac_browser_test_connection(request: Request): try: payload = await request.json() if not isinstance(payload, dict): raise ValueError("잘못된 요청 형식입니다.") return JSONResponse(content=test_hanmac_mysql_connection(payload)) except OperationalError as exc: logger.exception("hanmac DB_external MySQL 연결 실패: %s", exc) return JSONResponse( content={ "status": "error", "message": build_hanmac_mysql_error_message(exc), }, status_code=400, ) except Exception as exc: logger.exception("hanmac DB_external 연결 확인 에러: %s", exc) return JSONResponse( content={ "status": "error", "message": str(exc), }, status_code=400, ) @app.post("/hanmac-browser/api/tables") async def hanmac_browser_tables(request: Request): try: payload = await request.json() if not isinstance(payload, dict): raise ValueError("잘못된 요청 형식입니다.") return JSONResponse(content=get_hanmac_table_list(payload)) except OperationalError as exc: logger.exception("hanmac DB_external 테이블 목록 조회 실패: %s", exc) return JSONResponse(content={"status": "error", "message": build_hanmac_mysql_error_message(exc)}, status_code=400) except Exception as exc: logger.exception("hanmac DB_external 테이블 목록 에러: %s", exc) return JSONResponse(content={"status": "error", "message": str(exc)}, status_code=400) @app.post("/hanmac-browser/api/grade-codes") async def hanmac_browser_grade_codes(request: Request): try: payload = await request.json() if not isinstance(payload, dict): raise ValueError("잘못된 요청 형식입니다.") result = await run_in_threadpool(get_hanmac_grade_code_summary, payload) return JSONResponse(content=jsonable_encoder(result)) except OperationalError as exc: logger.exception("hanmac DB_external 직급 코드 조회 실패: %s", exc) return JSONResponse(content={"status": "error", "message": build_hanmac_mysql_error_message(exc)}, status_code=400) except Exception as exc: logger.exception("hanmac DB_external 직급 코드 조회 에러: %s", exc) return JSONResponse(content={"status": "error", "message": str(exc)}, status_code=400) @app.post("/hanmac-browser/api/preview") async def hanmac_browser_preview(request: Request): try: payload = await request.json() if not isinstance(payload, dict): raise ValueError("잘못된 요청 형식입니다.") return JSONResponse(content=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) except Exception as exc: logger.exception("hanmac DB_external 테이블 미리보기 에러: %s", exc) return JSONResponse(content={"status": "error", "message": str(exc)}, status_code=400) @app.post("/hanmac-browser/api/preview/rebuild-cache") async def hanmac_browser_preview_rebuild_cache(request: Request): try: payload = await request.json() if not isinstance(payload, dict): payload = {} job = await run_in_threadpool( _create_system_job, page_key="hanmac_browser", job_type="hanmac_preview_cache", start_year=None, end_year=None, params=payload, ) return JSONResponse(content=jsonable_encoder({"ok": True, "job": job})) except Exception as exc: logger.exception("hanmac DB_external 미리보기 캐시 작업 등록 에러: %s", exc) return JSONResponse(content={"ok": False, "error": str(exc)}, status_code=500) @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=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) except Exception as exc: logger.exception("hanmac DB_external 집계 에러: %s", exc) return JSONResponse(content={"status": "error", "message": str(exc)}, status_code=400) @app.post("/hanmac-browser/api/aggregate/rebuild-cache") async def hanmac_browser_aggregate_rebuild_cache(request: Request): try: payload = await request.json() if not isinstance(payload, dict): payload = {} job = await run_in_threadpool( _create_system_job, page_key="hanmac_browser", job_type="hanmac_aggregate_cache", start_year=None, end_year=None, params=payload, ) return JSONResponse(content=jsonable_encoder({"ok": True, "job": job})) except Exception as exc: logger.exception("hanmac DB_external 집계 캐시 작업 등록 에러: %s", exc) return JSONResponse(content={"ok": False, "error": str(exc)}, status_code=500) @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("/hanmac-browser/api/holidays") async def hanmac_browser_holidays(start_date: str | None = None, end_date: str | None = None): try: rows = await run_in_threadpool( get_hanmac_holidays, _hanmac_parse_date_value(start_date), _hanmac_parse_date_value(end_date), ) return JSONResponse(content=jsonable_encoder({"ok": True, "rows": rows})) except Exception as exc: logger.exception("hanmac 휴일 기준 조회 에러: %s", exc) return JSONResponse(content={"ok": False, "error": str(exc)}, status_code=500) @app.post("/hanmac-browser/api/holidays") async def hanmac_browser_save_holiday(request: Request): try: payload = await request.json() if not isinstance(payload, dict): payload = {} result = await run_in_threadpool(save_hanmac_holiday, payload) return JSONResponse(content=jsonable_encoder(result)) except Exception as exc: logger.exception("hanmac 휴일 기준 저장 에러: %s", exc) return JSONResponse(content={"ok": False, "error": str(exc)}, status_code=400) @app.delete("/hanmac-browser/api/holidays/{holiday_date}") async def hanmac_browser_delete_holiday(holiday_date: str): try: result = await run_in_threadpool(delete_hanmac_holiday, holiday_date) return JSONResponse(content=jsonable_encoder(result)) except Exception as exc: logger.exception("hanmac 휴일 기준 삭제 에러: %s", exc) return JSONResponse(content={"ok": False, "error": str(exc)}, status_code=400) @app.get("/hanmac-browser/api/leave-rules") async def hanmac_browser_leave_rules(): try: rows = await run_in_threadpool(get_hanmac_leave_rules) return JSONResponse(content=jsonable_encoder({"ok": True, "rows": rows})) except Exception as exc: logger.exception("hanmac 휴가 계산 규칙 조회 에러: %s", exc) return JSONResponse(content={"ok": False, "error": str(exc)}, status_code=500) @app.post("/hanmac-browser/api/leave-rules") async def hanmac_browser_save_leave_rule(request: Request): try: payload = await request.json() if not isinstance(payload, dict): payload = {} result = await run_in_threadpool(save_hanmac_leave_rule, payload) return JSONResponse(content=jsonable_encoder(result)) except Exception as exc: logger.exception("hanmac 휴가 계산 규칙 저장 에러: %s", exc) return JSONResponse(content={"ok": False, "error": str(exc)}, status_code=400) @app.delete("/hanmac-browser/api/leave-rules/{keyword}") async def hanmac_browser_delete_leave_rule(keyword: str): try: result = await run_in_threadpool(delete_hanmac_leave_rule, keyword) return JSONResponse(content=jsonable_encoder(result)) except Exception as exc: logger.exception("hanmac 휴가 계산 규칙 삭제 에러: %s", exc) return JSONResponse(content={"ok": False, "error": str(exc)}, status_code=400) @app.post("/api/system-jobs") async def system_jobs_create(request: Request): try: payload = await request.json() if not isinstance(payload, dict): payload = {} page_key = normalize_text(payload.get("page_key")) job_type = normalize_text(payload.get("job_type")) start_year = parse_optional_year(payload.get("start_year")) end_year = parse_optional_year(payload.get("end_year")) params = payload.get("params") if isinstance(payload.get("params"), dict) else {} job = await run_in_threadpool( _create_system_job, page_key=page_key, job_type=job_type, start_year=start_year, end_year=end_year, params=params, ) return JSONResponse(content=jsonable_encoder({"ok": True, "job": job})) except Exception as exc: logger.exception("시스템 작업 등록 에러: %s", exc) return JSONResponse(content={"ok": False, "error": str(exc)}, status_code=500) @app.get("/api/system-jobs/latest") async def system_jobs_latest( page_key: str = "", job_type: str = "", start_year: str | None = None, end_year: str | None = None, ): try: job = await run_in_threadpool( _fetch_latest_system_job, normalize_text(page_key), normalize_text(job_type), parse_optional_year(start_year), parse_optional_year(end_year), ) return JSONResponse(content=jsonable_encoder({"ok": True, "job": job})) except Exception as exc: logger.exception("최근 시스템 작업 조회 에러: %s", exc) return JSONResponse(content={"ok": False, "error": str(exc)}, status_code=500) @app.get("/api/system-jobs/{job_id}") async def system_jobs_detail(job_id: str): try: job = await run_in_threadpool(_fetch_system_job, normalize_text(job_id)) if not job: return JSONResponse(content={"ok": False, "error": "작업을 찾을 수 없습니다."}, status_code=404) return JSONResponse(content=jsonable_encoder({"ok": True, "job": job})) except Exception as exc: logger.exception("시스템 작업 상세 조회 에러: %s", exc) return JSONResponse(content={"ok": False, "error": str(exc)}, status_code=500) @app.post("/wehago-compare/api/rebuild-query-cache") async def wehago_compare_rebuild_query_cache(request: Request): try: payload = await request.json() if not isinstance(payload, dict): payload = {} start_year = parse_optional_year(payload.get("start_year")) end_year = parse_optional_year(payload.get("end_year")) if start_year is None or end_year is None: raise ValueError("조회 캐시를 생성할 기간을 선택해주세요.") if start_year > end_year: start_year, end_year = end_year, start_year existing_job = await run_in_threadpool( _load_existing_wehago_compare_export_projection_job, start_year, end_year, ) if existing_job: return JSONResponse(content=jsonable_encoder({"ok": True, "job": existing_job})) try: await run_in_threadpool( _assert_wehago_compare_current_export_rows_ready, start_year, end_year, ) except Exception as exc: return JSONResponse(content={"ok": False, "error": str(exc)}, status_code=409) try: await run_in_threadpool(_assert_wal_allows_heavy_cache_write) except Exception as exc: return JSONResponse(content={"ok": False, "error": str(exc)}, status_code=409) job = await run_in_threadpool( _create_system_job, page_key="wehago_compare", job_type="wehago_compare_project_range", start_year=start_year, end_year=end_year, params={"source": "wehago_compare_page", "reuse_existing_projection": True}, ) return JSONResponse(content=jsonable_encoder({"ok": True, "job": job})) except Exception as exc: logger.exception("전표비교 조회 캐시 작업 등록 에러: %s", exc) return JSONResponse(content={"ok": False, "error": str(exc)}, status_code=500) @app.get("/wehago-compare") async def wehago_compare(request: Request, start_year: str | None = None, end_year: str | None = None): try: return render_wehago_compare_page( request, start_year=parse_optional_year(start_year), end_year=parse_optional_year(end_year), ) except Exception as exc: logger.exception("전표비교 페이지 에러: %s", exc) return HTMLResponse("

서버 오류

로그를 확인해주세요.

", status_code=500) @app.get("/wehago-benefit-entertainment") async def wehago_benefit_entertainment( request: Request, start_year: str | None = None, end_year: str | None = None, account_group: str = "all", category: str = "all", person_keyword: str = "", desc_keyword: str = "", include_adjustments: str | None = None, ): try: return await run_in_threadpool( render_wehago_benefit_entertainment_page, request, start_year=_normalize_report_year(start_year, WEHAGO_BENEFIT_DEFAULT_START_YEAR), end_year=_normalize_report_year(end_year, WEHAGO_BENEFIT_DEFAULT_END_YEAR), account_group=account_group, category=category, person_keyword=person_keyword, desc_keyword=desc_keyword, include_adjustments=_normalize_report_bool(include_adjustments), ) except Exception as exc: logger.exception("복리/접대비 보고서 페이지 에러: %s", exc) return HTMLResponse("

서버 오류

로그를 확인해주세요.

", status_code=500) @app.get("/wehago-benefit-entertainment/export") async def wehago_benefit_entertainment_export( start_year: str | None = None, end_year: str | None = None, account_group: str = "all", category: str = "all", person_keyword: str = "", desc_keyword: str = "", include_adjustments: str | None = None, ): try: report = await run_in_threadpool( get_wehago_benefit_entertainment_report, start_year=_normalize_report_year(start_year, WEHAGO_BENEFIT_DEFAULT_START_YEAR), end_year=_normalize_report_year(end_year, WEHAGO_BENEFIT_DEFAULT_END_YEAR), account_group=account_group, category=category, person_keyword=person_keyword, desc_keyword=desc_keyword, include_adjustments=_normalize_report_bool(include_adjustments), limit=None, ) file_name, content = await run_in_threadpool(export_wehago_benefit_entertainment_xlsx, report) return Response( content=content, media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", headers={"Content-Disposition": f'attachment; filename="{file_name}"'}, ) except Exception as exc: logger.exception("복리/접대비 엑셀 다운로드 에러: %s", exc) return JSONResponse(content={"error": str(exc)}, status_code=500) @app.post("/wehago-benefit-entertainment/api/category") async def wehago_benefit_entertainment_save_category(request: Request): try: payload = await request.json() if not isinstance(payload, dict): payload = {} result = await run_in_threadpool( save_wehago_benefit_category_override, payload.get("ledger_row_id"), payload.get("category"), ) return JSONResponse(content=jsonable_encoder(result)) except ValueError as exc: return JSONResponse(content={"ok": False, "error": str(exc)}, status_code=400) except Exception as exc: logger.exception("복리/접대비 분류 저장 에러: %s", exc) return JSONResponse(content={"ok": False, "error": str(exc)}, status_code=500) @app.post("/wehago-compare/upload-erp") async def upload_wehago_erp_file( request: Request, start_year: str | None = None, end_year: str | None = None, erp_file: UploadFile = File(...), ): temp_path: Path | None = None try: if not erp_file.filename: raise ValueError("업로드할 ERP 파일을 선택해주세요.") suffix = Path(erp_file.filename).suffix or ".xlsx" with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as handle: temp_path = Path(handle.name) while True: chunk = await erp_file.read(1024 * 1024) if not chunk: break handle.write(chunk) summary = import_uploaded_erp_voucher_file(engine, temp_path, erp_file.filename) message = ( f"ERP 파일 반영 완료: {summary['file_name']} " f"(추가 {summary['inserted_rows']}건, 중복 제외 {summary['duplicate_rows']}건)" ) return render_wehago_compare_page( request, start_year=parse_optional_year(start_year), end_year=parse_optional_year(end_year), message=message, ) except Exception as exc: logger.exception("전표비교 ERP 업로드 에러: %s", exc) return render_wehago_compare_page( request, start_year=parse_optional_year(start_year), end_year=parse_optional_year(end_year), message=f"ERP 파일 업로드 중 오류가 발생했습니다: {exc}", ) finally: await erp_file.close() if temp_path and temp_path.exists(): temp_path.unlink(missing_ok=True) @app.get("/wehago-compare/api/status-rows") async def wehago_compare_status_rows( start_year: int | None = None, end_year: int | None = None, status: str = "", voucher_no: str = "", draft_no: str = "", wehago_account: str = "", erp_account: str = "", wehago_amount: str = "", erp_amount: str = "", wehago_vendor: str = "", erp_vendor: str = "", desc_keyword: str = "", review_reason: str = "", boundary_excluded: str = "", offset: int = 0, limit: int = 200, cursor: str = "", ): try: payload = await run_in_threadpool( get_status_detail_rows, engine, start_year=start_year, end_year=end_year, status=status, voucher_no=voucher_no, draft_no=draft_no, wehago_account=wehago_account, erp_account=erp_account, wehago_amount=wehago_amount, erp_amount=erp_amount, wehago_vendor=wehago_vendor, erp_vendor=erp_vendor, desc_keyword=desc_keyword, review_reason=review_reason, boundary_excluded=boundary_excluded, offset=offset, limit=limit, cursor=cursor, ) return JSONResponse( content=jsonable_encoder(payload), headers={"Cache-Control": "no-store, max-age=0"}, ) 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) @app.post("/wehago-compare/api/status-export-jobs") async def wehago_compare_status_export_jobs(request: Request): try: payload = await request.json() if not isinstance(payload, dict): payload = {} response = request_status_export_xlsx( engine, start_year=int(payload.get("start_year")) if payload.get("start_year") is not None else None, end_year=int(payload.get("end_year")) if payload.get("end_year") is not None else None, status=str(payload.get("status") or ""), voucher_no=str(payload.get("voucher_no") or ""), draft_no=str(payload.get("draft_no") or ""), wehago_account=str(payload.get("wehago_account") or ""), erp_account=str(payload.get("erp_account") or ""), wehago_amount=str(payload.get("wehago_amount") or ""), erp_amount=str(payload.get("erp_amount") or ""), wehago_vendor=str(payload.get("wehago_vendor") or ""), erp_vendor=str(payload.get("erp_vendor") or ""), desc_keyword=str(payload.get("desc_keyword") or ""), ) return JSONResponse(content=jsonable_encoder({"ok": True, **response})) except Exception as exc: logger.exception("전표비교 엑셀 준비 요청 에러: %s", exc) return JSONResponse(content={"error": str(exc)}, status_code=500) @app.get("/wehago-compare/api/status-export-jobs/{job_key}") async def wehago_compare_status_export_job(job_key: str): try: payload = get_status_export_job(engine, job_key) return JSONResponse(content=jsonable_encoder(payload)) except Exception as exc: logger.exception("전표비교 엑셀 상태 조회 에러: %s", exc) return JSONResponse(content={"error": str(exc)}, status_code=500) @app.get("/wehago-compare/api/status-export-download/{job_key}") async def wehago_compare_status_export_download(job_key: str): try: payload = get_status_export_job(engine, job_key) file_path = payload.get("file_path") or "" file_name = payload.get("file_name") or f"{job_key}.xlsx" if payload.get("state") != "ready" or not file_path or not Path(file_path).exists(): return JSONResponse(content={"error": "엑셀 파일을 아직 준비 중입니다."}, status_code=409) return FileResponse( path=file_path, media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", filename=file_name, ) except Exception as exc: logger.exception("전표비교 엑셀 다운로드 전달 에러: %s", exc) return JSONResponse(content={"error": str(exc)}, status_code=500) @app.get("/wehago-compare/api/status-export") async def wehago_compare_status_export( start_year: int | None = None, end_year: int | None = None, status: str = "", voucher_no: str = "", draft_no: str = "", wehago_account: str = "", erp_account: str = "", wehago_amount: str = "", erp_amount: str = "", wehago_vendor: str = "", erp_vendor: str = "", desc_keyword: str = "", ): try: file_name, file_bytes, _row_count = export_wehago_status_rows_xlsx( engine, start_year=start_year, end_year=end_year, status=status, voucher_no=voucher_no, draft_no=draft_no, wehago_account=wehago_account, erp_account=erp_account, wehago_amount=wehago_amount, erp_amount=erp_amount, wehago_vendor=wehago_vendor, erp_vendor=erp_vendor, desc_keyword=desc_keyword, ) return Response( content=file_bytes, media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", headers={"Content-Disposition": f'attachment; filename="{file_name}"'}, ) except Exception as exc: logger.exception("전표비교 엑셀 다운로드 에러: %s", exc) return JSONResponse(content={"error": str(exc)}, status_code=500) @app.get("/wehago-compare/api/summary") async def wehago_compare_summary( start_year: int | None = None, end_year: int | None = None, ): try: payload = await run_in_threadpool( _fast_wehago_compare_summary_payload, start_year, end_year, ) return JSONResponse( content=jsonable_encoder(payload), headers={"Cache-Control": "no-store, max-age=0"}, ) except Exception as exc: logger.exception("전표비교 현황 조회 에러: %s", exc) return JSONResponse(content={"error": str(exc)}, status_code=500) @app.get("/wehago-compare/api/snapshot-status") async def wehago_compare_snapshot_status( start_year: int | None = None, end_year: int | None = None, force: int = 0, ): try: payload = get_compare_snapshot_status( engine, start_year=start_year, end_year=end_year, force=bool(force), ) return JSONResponse(content=jsonable_encoder(payload)) except Exception as exc: logger.exception("전표비교 스냅샷 상태 조회 에러: %s", exc) return JSONResponse(content={"error": str(exc)}, status_code=500) @app.post("/wehago-compare/api/snapshot-rebuild") async def wehago_compare_snapshot_rebuild(request: Request): try: payload = await request.json() if not isinstance(payload, dict): payload = {} start_year = payload.get("start_year") end_year = payload.get("end_year") include_metric_counts_value = payload.get("include_metric_counts", True) if isinstance(include_metric_counts_value, bool): include_metric_counts = include_metric_counts_value else: include_metric_counts = normalize_text(include_metric_counts_value).lower() not in { "0", "false", "n", "no", "off", } response = request_compare_snapshot_rebuild( engine, start_year=int(start_year) if start_year is not None else None, end_year=int(end_year) if end_year is not None else None, include_metric_counts=include_metric_counts, ) return JSONResponse(content=jsonable_encoder({"ok": True, **response})) except Exception as exc: logger.exception("전표비교 스냅샷 재생성 요청 에러: %s", exc) return JSONResponse(content={"error": str(exc)}, status_code=500) @app.get("/wehago-compare/api/bridge-review-settings") async def wehago_compare_bridge_review_settings(): try: payload = load_bridge_review_settings(engine) return JSONResponse(content=jsonable_encoder(payload)) except Exception as exc: logger.exception("2단계 비교 설정 조회 에러: %s", exc) return JSONResponse(content={"error": str(exc)}, status_code=500) @app.post("/wehago-compare/api/bridge-review-settings") async def wehago_compare_bridge_review_settings_save(request: Request): try: payload = await request.json() if not isinstance(payload, dict): payload = {} saved = save_bridge_review_settings(engine, payload) return JSONResponse(content=jsonable_encoder({"ok": True, "settings": saved})) except Exception as exc: logger.exception("2단계 비교 설정 저장 에러: %s", exc) return JSONResponse(content={"error": str(exc)}, status_code=500) @app.get("/wehago-compare/api/status-suggestions") async def wehago_compare_status_suggestions( start_year: int | None = None, end_year: int | None = None, status: str = "", field: str = "voucher_no", keyword: str = "", offset: int = 0, limit: int = 10, ): try: payload = get_status_field_suggestions( engine, start_year=start_year, end_year=end_year, status=status, field=field, keyword=keyword, offset=offset, limit=limit, ) return JSONResponse(content=jsonable_encoder(payload)) except Exception as exc: logger.exception("전표비교 자동완성 조회 에러: %s", exc) return JSONResponse(content={"error": str(exc)}, status_code=500) def _refresh_wehago_recheck_projection_after_change() -> dict[str, Any]: scripts = [ Path("scripts/promote_wehago_recheck_projection.py"), Path("scripts/reconcile_wehago_projection_to_db.py"), ] outputs: list[str] = [] for script in scripts: completed = subprocess.run( [sys.executable, str(script)], cwd=Path(__file__).resolve().parent, text=True, capture_output=True, timeout=180, check=False, ) output = "\n".join(part for part in [completed.stdout, completed.stderr] if part).strip() if output: outputs.append(output) if completed.returncode != 0: raise RuntimeError(output or f"{script.name} 실행에 실패했습니다.") _clear_compare_runtime_caches() reconcile_payload: dict[str, Any] = {} if outputs: last_line = outputs[-1].splitlines()[-1].strip() try: parsed = json.loads(last_line) if isinstance(parsed, dict): reconcile_payload = parsed except Exception: reconcile_payload = {"output": last_line} return reconcile_payload def _ensure_wehago_manual_offset_excepted_table(conn: sqlite3.Connection) -> None: conn.execute( """ CREATE TABLE IF NOT EXISTS wehago_manual_offset_excepted ( pair_key TEXT PRIMARY KEY, left_identity TEXT NOT NULL, right_identity TEXT NOT NULL, start_year INTEGER NOT NULL, end_year INTEGER NOT NULL, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP ) """ ) def _wehago_projection_scope_for_range(conn: sqlite3.Connection, start_year: int, end_year: int) -> tuple[int, int, str]: if start_year == end_year: return _latest_year_query_source(conn, start_year) row = conn.execute( f""" SELECT start_year, end_year, signature, COUNT(DISTINCT status_key) AS status_count, MAX(updated_at) AS max_updated_at FROM wehago_compare_query_groups WHERE start_year = ? AND end_year = ? AND status_key IN ({','.join('?' for _ in _WEHAGO_COMPARE_VOUCHER_STATUSES)}) AND signature LIKE ? GROUP BY start_year, end_year, signature ORDER BY CASE WHEN signature LIKE '%db-reconciled-v1%' THEN 0 ELSE 1 END ASC, status_count DESC, max_updated_at DESC LIMIT 1 """, (start_year, end_year, *_WEHAGO_COMPARE_VOUCHER_STATUSES, f"{QUERY_PROJECTION_VERSION}|%"), ).fetchone() if row is None: raise RuntimeError(f"{start_year}~{end_year}년 조회 projection이 없습니다. 먼저 해당 기간 계산을 실행해주세요.") return int(row["start_year"]), int(row["end_year"]), str(row["signature"] or "") def _wehago_offset_projection_context( conn: sqlite3.Connection, start_year: int, end_year: int, ) -> tuple[tuple[int, int, str], list[tuple[tuple[int, int, str], int | None]], list[int]]: primary = _wehago_projection_scope_for_range(conn, start_year, end_year) scopes: list[tuple[tuple[int, int, str], int | None]] = [(primary, None)] legacy_context_years: list[int] = [] for context_year in sorted({start_year - 1, end_year + 1}): try: context_scope = _latest_year_query_source(conn, context_year) except RuntimeError: row = conn.execute( f""" SELECT start_year, end_year, signature, COUNT(DISTINCT status_key) AS status_count, MAX(updated_at) AS max_updated_at FROM wehago_compare_query_groups WHERE ? BETWEEN start_year AND end_year AND status_key IN ({','.join('?' for _ in _WEHAGO_COMPARE_VOUCHER_STATUSES)}) GROUP BY start_year, end_year, signature ORDER BY CASE WHEN start_year = ? AND end_year = ? THEN 0 ELSE 1 END ASC, status_count DESC, (end_year - start_year) ASC, max_updated_at DESC LIMIT 1 """, (context_year, *_WEHAGO_COMPARE_VOUCHER_STATUSES, context_year, context_year), ).fetchone() if row is None: continue context_scope = ( int(row["start_year"] or context_year), int(row["end_year"] or context_year), str(row["signature"] or ""), ) legacy_context_years.append(context_year) if context_scope != primary and (context_scope, context_year) not in scopes: scopes.append((context_scope, context_year)) return primary, scopes, legacy_context_years def _wehago_group_identity(group: dict[str, Any]) -> str: summary = group.get("summary") or {} fiscal_year = int(summary.get("fiscal_year") or 0) date_digits = re.findall(r"\d+", str(summary.get("ledger_date") or "")) if len(date_digits) >= 3: year, month, day = int(date_digits[-3]), int(date_digits[-2]), int(date_digits[-1]) elif len(date_digits) >= 2 and fiscal_year: year, month, day = fiscal_year, int(date_digits[-2]), int(date_digits[-1]) else: return "" voucher_digits = re.sub(r"\D", "", str(summary.get("voucher_no") or "")) if not voucher_digits: return "" return f"{year:04d}{month:02d}{day:02d}-{int(voucher_digits):05d}" def _load_wehago_offset_projection_groups( conn: sqlite3.Connection, scope: tuple[int, int, str], fiscal_year: int | None = None, ) -> dict[str, list[dict[str, Any]]]: start_year, end_year, signature = scope status_keys = ("voucher_matched", "voucher_unmatched", "voucher_recheck") groups_by_key: dict[tuple[str, int], dict[str, Any]] = {} placeholders = ",".join("?" for _ in status_keys) fiscal_sql = " AND fiscal_year = ?" if fiscal_year is not None else "" fiscal_params: tuple[int, ...] = (fiscal_year,) if fiscal_year is not None else () for row in conn.execute( f""" SELECT * FROM wehago_compare_query_groups WHERE start_year = ? AND end_year = ? AND signature = ? AND status_key IN ({placeholders}) {fiscal_sql} ORDER BY status_key, group_index """, (start_year, end_year, signature, *status_keys, *fiscal_params), ).fetchall(): summary = dict(row) groups_by_key[(str(row["status_key"]), int(row["group_index"]))] = {"summary": summary, "rows": []} for row in conn.execute( f""" SELECT * FROM wehago_compare_query_rows WHERE start_year = ? AND end_year = ? AND signature = ? AND status_key IN ({placeholders}) {fiscal_sql} ORDER BY status_key, group_index, row_index """, (start_year, end_year, signature, *status_keys, *fiscal_params), ).fetchall(): key = (str(row["status_key"]), int(row["group_index"])) group = groups_by_key.get(key) if group is not None: group["rows"].append(dict(row)) sections = {status_key: [] for status_key in status_keys} for (status_key, _group_index), group in groups_by_key.items(): sections[status_key].append(group) return sections def _wehago_group_draft_keys(group: dict[str, Any]) -> set[str]: values = [str((group.get("summary") or {}).get("draft_no") or "")] values.extend(str(row.get("draft_no") or "") for row in list(group.get("rows") or [])) return { item.strip() for value in values for item in value.split(",") if item.strip() } def _load_wehago_offset_management_items( conn: sqlite3.Connection, start_year: int, end_year: int, ) -> dict[str, list[str]]: by_draft: dict[str, list[str]] = {} for row in conn.execute( """ SELECT draft_no, confirmed_no, management_item FROM wehago_voucher_rows WHERE fiscal_year BETWEEN ? AND ? AND COALESCE(management_item, '') <> '' """, (start_year - 1, end_year + 1), ).fetchall(): item = str(row["management_item"] or "").strip() if not item: continue for key in (str(row["draft_no"] or "").strip(), str(row["confirmed_no"] or "").strip()): if key and item not in by_draft.setdefault(key, []): by_draft[key].append(item) return by_draft def _offset_group_display( group: dict[str, Any], management_items_by_draft: dict[str, list[str]] | None = None, ) -> dict[str, Any]: summary = group.get("summary") or {} vector = _offset_group_vector(group) net_amount = sum(vector.values()) management_items: list[str] = [] for draft_key in sorted(_wehago_group_draft_keys(group)): for item in (management_items_by_draft or {}).get(draft_key, []): if item not in management_items: management_items.append(item) return { "identity": _wehago_group_identity(group), "status_key": str(summary.get("status_key") or ""), "group_index": int(summary.get("group_index") or 0), "fiscal_year": int(summary.get("fiscal_year") or 0), "ledger_date": str(summary.get("ledger_date") or ""), "voucher_no": str(summary.get("voucher_no") or ""), "ledger_accounts": str(summary.get("ledger_accounts") or ""), "ledger_vendors": str(summary.get("ledger_vendors") or ""), "draft_no": str(summary.get("draft_no") or ""), "voucher_accounts": str(summary.get("voucher_accounts") or ""), "voucher_vendors": str(summary.get("voucher_vendors") or ""), "ledger_debit": float(summary.get("ledger_debit") or 0), "ledger_credit": float(summary.get("ledger_credit") or 0), "net_amount": float(net_amount), "review_reason": str(summary.get("review_reason") or ""), "erp_management_items": " / ".join(management_items), } def _load_wehago_offset_context_groups( conn: sqlite3.Connection, scopes: list[tuple[tuple[int, int, str], int | None]], ) -> dict[str, list[dict[str, Any]]]: sections = {status_key: [] for status_key in ("voucher_matched", "voucher_unmatched", "voucher_recheck")} seen: set[str] = set() for scope, fiscal_year in scopes: scoped_sections = _load_wehago_offset_projection_groups(conn, scope, fiscal_year) for status_key, groups in scoped_sections.items(): for group in groups: identity = _wehago_group_identity(group) if not identity or identity in seen: continue seen.add(identity) sections[status_key].append(group) return sections def _get_wehago_offset_candidates(start_year: int, end_year: int) -> dict[str, Any]: conn = sqlite3.connect(DB_PATH) conn.row_factory = sqlite3.Row try: scope, context_scopes, legacy_context_years = _wehago_offset_projection_context(conn, start_year, end_year) primary_sections = _load_wehago_offset_projection_groups(conn, scope) sections = _load_wehago_offset_context_groups(conn, context_scopes) management_items_by_draft = _load_wehago_offset_management_items(conn, start_year, end_year) indexed_groups: dict[ tuple[tuple[tuple[str, str, str], float], ...], list[tuple[str, dict[str, Any], dict[tuple[str, str, str], float]]], ] = {} for status_key, groups in sections.items(): for group in groups: vector = _offset_group_vector(group) if vector: vector_key = tuple(sorted((key, round(value, 4)) for key, value in vector.items())) indexed_groups.setdefault(vector_key, []).append((status_key, group, vector)) candidate_groups: list[dict[str, Any]] = [] seen_pair_keys: set[tuple[str, str]] = set() for source in primary_sections["voucher_recheck"]: source_year = int((source.get("summary") or {}).get("fiscal_year") or 0) if source_year < start_year or source_year > end_year: continue source_vector = _offset_group_vector(source) if not source_vector or not any(value < -0.5 for value in source_vector.values()): continue if not (_group_has_tax_invoice_cancel_signal(source) or _group_has_offset_tax_invoice_structure(source)): continue source_identity = _wehago_group_identity(source) partners: list[dict[str, Any]] = [] opposite_key = tuple(sorted((key, round(-value, 4)) for key, value in source_vector.items())) for status_key, partner, partner_vector in indexed_groups.get(opposite_key, []): partner_identity = _wehago_group_identity(partner) if not partner_identity or partner_identity == source_identity: continue if not _offset_vectors_cancel_each_other(source_vector, partner_vector): continue if not _voucher_groups_within_days(source, partner, 93): continue pair_key = tuple(sorted((source_identity, partner_identity))) if pair_key in seen_pair_keys: continue payload = _offset_group_display(partner, management_items_by_draft) payload["status_key"] = status_key partners.append(payload) if not partners: continue seen_pair_keys.update( tuple(sorted((source_identity, str(partner["identity"])))) for partner in partners ) partners.sort(key=lambda row: (row["ledger_date"], row["voucher_no"], row["status_key"])) candidate_groups.append( { "source": _offset_group_display(source, management_items_by_draft), "partners": partners, "candidate_count": len(partners), "recommendation": "복수 반전 후보 확인 필요" if len(partners) > 1 else "반전쌍 확인 후 이동", } ) candidate_groups.sort(key=lambda item: (item["source"]["ledger_date"], item["source"]["voucher_no"])) required_context_years = sorted({start_year - 1, end_year + 1}) available_context_years = [ year for year in required_context_years if any(scope_start <= year <= scope_end for (scope_start, scope_end, _signature), _fiscal_year in context_scopes) ] return { "groups": candidate_groups, "count": len(candidate_groups), "pair_count": sum(len(group["partners"]) for group in candidate_groups), "start_year": scope[0], "end_year": scope[1], "signature": scope[2], "context_years": available_context_years, "missing_context_years": sorted(set(required_context_years) - set(available_context_years)), "legacy_context_years": legacy_context_years, } finally: conn.close() def _save_wehago_manual_offset_pairs(start_year: int, end_year: int, pairs: list[dict[str, Any]]) -> int: conn = sqlite3.connect(DB_PATH) conn.row_factory = sqlite3.Row try: scope, context_scopes, _legacy_context_years = _wehago_offset_projection_context(conn, start_year, end_year) primary_sections = _load_wehago_offset_projection_groups(conn, scope) sections = _load_wehago_offset_context_groups(conn, context_scopes) current: dict[str, tuple[str, dict[str, Any]]] = {} for status_key, groups in sections.items(): for group in groups: identity = _wehago_group_identity(group) if identity: current[identity] = (status_key, group) valid_source_identities = { _wehago_group_identity(group) for group in primary_sections["voucher_recheck"] if start_year <= int((group.get("summary") or {}).get("fiscal_year") or 0) <= end_year } _ensure_wehago_manual_offset_excepted_table(conn) saved = 0 selected_identities: set[str] = set() for pair in pairs: if not isinstance(pair, dict): continue source_identity = str(pair.get("source_identity") or "") partner_identity = str(pair.get("partner_identity") or "") source_entry = current.get(source_identity) partner_entry = current.get(partner_identity) if not source_entry or not partner_entry or source_identity == partner_identity: raise ValueError("선택한 상계 후보가 현재 조회 결과와 일치하지 않습니다. 목록을 다시 열어 선택해주세요.") if source_identity not in valid_source_identities: raise ValueError("조회 기간의 Recheck 취소 전표만 이동 대상으로 선택할 수 있습니다.") if source_identity in selected_identities or partner_identity in selected_identities: raise ValueError("같은 전표를 둘 이상의 상계쌍에 동시에 사용할 수 없습니다.") selected_identities.update((source_identity, partner_identity)) source_status, source = source_entry partner_status, partner = partner_entry if source_status != "voucher_recheck": raise ValueError("상계 이동 대상은 현재 Recheck에 있는 취소 전표여야 합니다.") if not (_group_has_tax_invoice_cancel_signal(source) or _group_has_offset_tax_invoice_structure(source)): raise ValueError("선택한 취소 전표는 상계 검토 조건을 충족하지 않습니다.") if not _offset_vectors_cancel_each_other(_offset_group_vector(source), _offset_group_vector(partner)): raise ValueError("선택한 두 전표의 금액·계정 구조가 서로 반전되지 않습니다.") if not _voucher_groups_within_days(source, partner, 93): raise ValueError("선택한 두 전표는 검토 기간(전후 3개월)을 벗어납니다.") canonical = sorted((source_identity, partner_identity)) pair_key = hashlib.sha256("|".join(canonical).encode("utf-8")).hexdigest() result = conn.execute( """ INSERT OR IGNORE INTO wehago_manual_offset_excepted ( pair_key, left_identity, right_identity, start_year, end_year ) VALUES (?, ?, ?, ?, ?) """, (pair_key, canonical[0], canonical[1], start_year, end_year), ) saved += max(int(result.rowcount or 0), 0) conn.commit() return saved finally: conn.close() def _refresh_wehago_manual_offset_years(years: set[int]) -> dict[str, Any]: output_by_year: dict[str, Any] = {} script = Path("scripts/reconcile_wehago_projection_to_db.py") for year in sorted(year for year in years if year > 0): completed = subprocess.run( [sys.executable, str(script), "--year", str(year)], cwd=Path(__file__).resolve().parent, text=True, capture_output=True, timeout=180, check=False, ) output = "\n".join(part for part in [completed.stdout, completed.stderr] if part).strip() if completed.returncode != 0: raise RuntimeError(output or f"{year}년 상계 분류 반영에 실패했습니다.") last_line = output.splitlines()[-1].strip() if output else "" try: output_by_year[str(year)] = json.loads(last_line) if last_line else {"ok": True} except Exception: output_by_year[str(year)] = {"output": last_line} _clear_compare_runtime_caches() return {"years": output_by_year} @app.get("/wehago-compare/api/offset-candidates") async def wehago_compare_offset_candidates(start_year: int, end_year: int): try: payload = await run_in_threadpool(_get_wehago_offset_candidates, start_year, end_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.post("/wehago-compare/api/offset-except-save") async def wehago_compare_offset_except_save(request: Request): try: payload = await request.json() if not isinstance(payload, dict): raise ValueError("저장할 상계 항목 형식이 올바르지 않습니다.") pairs = payload.get("pairs") if not isinstance(pairs, list) or not pairs: raise ValueError("Excepted로 이동할 상계쌍을 선택해주세요.") start_year = int(payload.get("start_year") or 0) end_year = int(payload.get("end_year") or start_year) saved = await run_in_threadpool(_save_wehago_manual_offset_pairs, start_year, end_year, pairs) if not saved: raise ValueError("새로 저장할 상계쌍이 없습니다. 이미 반영되었거나 목록을 다시 조회해주세요.") affected_years = { int(str(identity)[:4]) for pair in pairs for identity in (pair.get("source_identity"), pair.get("partner_identity")) if str(identity or "")[:4].isdigit() } projection_result = await run_in_threadpool(_refresh_wehago_manual_offset_years, affected_years) return JSONResponse(content={"saved_count": saved, "projection": projection_result}) except Exception as exc: logger.exception("전표비교 상계 Excepted 이동 에러: %s", exc) return JSONResponse(content={"error": str(exc)}, status_code=500) @app.post("/wehago-compare/api/recheck-review-save") async def wehago_compare_recheck_review_save(request: Request): try: payload = await request.json() rows = payload.get("rows") if isinstance(payload, dict) else None match_rows = payload.get("match_rows") if isinstance(payload, dict) else None split_rows = payload.get("split_rows") if isinstance(payload, dict) else None if not isinstance(rows, list): raise ValueError("저장할 검토 항목 형식이 올바르지 않습니다.") if isinstance(match_rows, list) or isinstance(split_rows, list): result = save_recheck_change_rows( engine, match_rows if isinstance(match_rows, list) else rows, split_rows if isinstance(split_rows, list) else [], ) saved = int(result.get("count") or 0) else: saved = save_recheck_review_rows(engine, rows) result = {"match_count": saved, "split_count": 0, "count": saved} projection_result = await run_in_threadpool(_refresh_wehago_recheck_projection_after_change) enqueue_default_pair_recommend_precompute(engine) return JSONResponse(content={"saved_count": saved, **result, "projection": projection_result}) except Exception as exc: logger.exception("전표비교 검토 저장 에러: %s", exc) return JSONResponse(content={"error": str(exc)}, status_code=500) @app.post("/wehago-compare/api/pair-match-save") async def wehago_compare_pair_match_save(request: Request): try: payload = await request.json() ledger_rows = payload.get("ledger_rows") if isinstance(payload, dict) else None voucher_rows = payload.get("voucher_rows") if isinstance(payload, dict) else None if not isinstance(ledger_rows, list) or not isinstance(voucher_rows, list): raise ValueError("저장할 쌍비교 항목 형식이 올바르지 않습니다.") saved = save_manual_pair_matches(engine, ledger_rows, voucher_rows) enqueue_default_pair_recommend_precompute(engine) return JSONResponse(content={"saved_count": saved}) except Exception as exc: logger.exception("전표비교 쌍매칭 저장 에러: %s", exc) return JSONResponse(content={"error": str(exc)}, status_code=500) @app.get("/wehago-compare/api/pair-recommendations") async def wehago_compare_pair_recommendations( start_year: int | None = None, end_year: int | None = None, ledger_voucher_no: str = "", ledger_review_reason: str = "", voucher_voucher_no: str = "", voucher_review_reason: str = "", limit: int = 300, ): try: payload = recommend_pair_matches( engine, start_year=start_year, end_year=end_year, ledger_voucher_no=ledger_voucher_no, ledger_review_reason=ledger_review_reason, voucher_voucher_no=voucher_voucher_no, voucher_review_reason=voucher_review_reason, limit=limit, ) 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("/wehago-compare/api/pair-recommendations/apply") async def wehago_compare_pair_recommendations_apply(request: Request): try: payload = await request.json() pair_keys = payload.get("pair_keys") if isinstance(payload, dict) else None auto_only = bool(payload.get("auto_only")) if isinstance(payload, dict) else False saved = save_recommended_pair_matches( engine, start_year=parse_optional_year(payload.get("start_year")) if isinstance(payload, dict) else None, end_year=parse_optional_year(payload.get("end_year")) if isinstance(payload, dict) else None, pair_keys=pair_keys if isinstance(pair_keys, list) else None, auto_only=auto_only, ledger_voucher_no=payload.get("ledger_voucher_no", "") if isinstance(payload, dict) else "", ledger_review_reason=payload.get("ledger_review_reason", "") if isinstance(payload, dict) else "", voucher_voucher_no=payload.get("voucher_voucher_no", "") if isinstance(payload, dict) else "", voucher_review_reason=payload.get("voucher_review_reason", "") if isinstance(payload, dict) else "", ) return JSONResponse(content=jsonable_encoder(saved)) except Exception as exc: logger.exception("전표비교 추천 매칭 저장 에러: %s", exc) return JSONResponse(content={"error": str(exc)}, status_code=500) @app.get("/wehago-compare/api/last-action") async def wehago_compare_last_action(): try: payload = get_last_action_summary(engine=engine) return JSONResponse(content=jsonable_encoder(payload or {})) except Exception as exc: logger.exception("전표비교 최근 작업 조회 에러: %s", exc) return JSONResponse(content={"error": str(exc)}, status_code=500) @app.post("/wehago-compare/api/undo-last-action") async def wehago_compare_undo_last_action(): try: payload = undo_last_action(engine) enqueue_default_pair_recommend_precompute(engine) return JSONResponse(content=jsonable_encoder(payload)) except Exception as exc: logger.exception("전표비교 되돌리기 에러: %s", exc) return JSONResponse(content={"error": str(exc)}, status_code=500) @app.get("/wehago-compare/api/pair-individual-recommendations") async def wehago_compare_pair_individual_recommendations( start_year: int | None = None, end_year: int | None = None, source_status: str = "", source_row_key: str = "", offset: int = 0, limit: int = 10, ): try: payload = get_individual_pair_recommendations( engine, start_year=start_year, end_year=end_year, source_status=source_status, source_row_key=source_row_key, offset=offset, limit=limit, ) return JSONResponse(content=jsonable_encoder(payload)) except Exception as exc: logger.exception("전표비교 개별 추천 조회 에러: %s", exc) return JSONResponse(content={"error": str(exc)}, status_code=500) @app.get("/wehago-compare/api/wehago-rows") async def wehago_compare_wehago_rows( start_year: int | None = None, end_year: int | None = None, voucher_no: str = "", account_code: str = "", vendor_name: str = "", ): try: payload = get_wehago_filtered_rows( engine, start_year=start_year, end_year=end_year, voucher_no=voucher_no, account_code=account_code, vendor_name=vendor_name, ) return JSONResponse(content=jsonable_encoder(payload)) except Exception as exc: logger.exception("전표비교 WEHAGO 상세 조회 에러: %s", exc) return JSONResponse(content={"error": str(exc)}, status_code=500) @app.get("/wehago-compare/api/erp-rows") async def wehago_compare_erp_rows( start_year: int | None = None, end_year: int | None = None, voucher_no: str = "", account_code: str = "", vendor_name: str = "", ): try: payload = get_erp_filtered_rows( engine, start_year=start_year, end_year=end_year, voucher_no=voucher_no, account_code=account_code, vendor_name=vendor_name, ) return JSONResponse(content=jsonable_encoder(payload)) except Exception as exc: logger.exception("전표비교 ERP 상세 조회 에러: %s", exc) return JSONResponse(content={"error": str(exc)}, status_code=500) @app.post("/upload") async def upload_excel(request: Request, excel_file: UploadFile = File(...)): try: inserted = parse_excel_upload(excel_file) return render_home(request, message=f"{inserted}건의 엑셀 데이터를 DB에 저장했습니다.") except Exception as exc: logger.exception("엑셀 업로드 에러: %s", exc) return render_home(request, message=f"엑셀 업로드 중 오류가 발생했습니다: {exc}") @app.post("/records/save") async def save_record(request: Request): form_data: dict[str, Any] = {} try: form_data = parse_manual_form(await request.body()) record_id = normalize_text(form_data.get("id")) payload = build_transaction_payload(form_data) save_transaction(payload, int(record_id) if record_id else None) return RedirectResponse("/", status_code=303) except Exception as exc: logger.exception("데이터 저장 에러: %s", exc) record_id = normalize_text(form_data.get("id")) return render_home( request, edit_id=int(record_id) if record_id else None, message=f"데이터 저장 중 오류가 발생했습니다: {exc}", ) @app.post("/projects/save") async def save_project(request: Request): form_data: dict[str, Any] = {} try: form_data = parse_project_form(await request.body()) save_project_status(form_data) code = normalize_text(form_data.get("support_dept_code")) selected_year = normalize_text(form_data.get("selected_year")) redirect_url = "/projects" query_parts = [] if code: query_parts.append(f"focus_code={quote_plus(code)}") if selected_year: query_parts.append(f"year={quote_plus(selected_year)}") if query_parts: redirect_url += "?" + "&".join(query_parts) return RedirectResponse(redirect_url, status_code=303) except Exception as exc: logger.exception("사업현황 저장 에러: %s", exc) code = normalize_text(form_data.get("support_dept_code")) selected_year_text = normalize_text(form_data.get("selected_year")) selected_year = int(selected_year_text) if selected_year_text.isdigit() else None return render_projects_page( request, edit_code=code or None, focus_code=code or None, selected_year=selected_year, message=f"사업현황 저장 중 오류가 발생했습니다: {exc}", ) @app.post("/projects/save-json") async def save_project_json(request: Request): form_data: dict[str, Any] = {} try: form_data = parse_project_form(await request.body()) save_project_status(form_data) code = normalize_text(form_data.get("support_dept_code")) health_payload = build_health_payload(force=True) return JSONResponse( content=jsonable_encoder( { "ok": True, "support_dept_code": code, "project_edit": get_project_status_for_edit(code), "project_row": get_project_status_row_for_code(code), "data_version": health_payload.get("data_version", ""), "server_time": health_payload.get("server_time", ""), } ) ) except ValueError as exc: code = normalize_text(form_data.get("support_dept_code")) logger.warning("사업현황 JSON 저장 충돌/검증 오류(%s): %s", code, exc) log_save_event( "project_status_save", "project_status", code, session_id=form_data.get("client_session_id"), status="error", error_message=str(exc), payload={ "selected_revision": normalize_text(form_data.get("edit_revision")), "support_dept_code": code, "save_scope": normalize_text(form_data.get("save_scope")) or "all", }, ) status_code = 409 if "먼저 수정" in str(exc) else 400 return JSONResponse( status_code=status_code, content=jsonable_encoder( { "ok": False, "error": str(exc) or "사업현황 저장 중 오류가 발생했습니다.", "conflict": status_code == 409, "support_dept_code": code, "project_edit": get_project_status_for_edit(code), "project_row": get_project_status_row_for_code(code), } ), ) except Exception as exc: logger.exception("사업현황 JSON 저장 에러: %s", exc) code = normalize_text(form_data.get("support_dept_code")) log_save_event( "project_status_save", "project_status", code, session_id=form_data.get("client_session_id"), status="error", error_message=str(exc), payload={ "selected_revision": normalize_text(form_data.get("edit_revision")), "support_dept_code": code, "save_scope": normalize_text(form_data.get("save_scope")) or "all", }, ) return JSONResponse( status_code=500, content={ "ok": False, "error": str(exc) or "사업현황 저장 중 오류가 발생했습니다.", }, ) if __name__ == "__main__": auto_reload = os.getenv("INTRANET_AUTO_RELOAD", "0").lower() not in {"0", "false", "no"} port = int(os.getenv("INTRANET_PORT", "8010")) uvicorn.run("main:app", host="0.0.0.0", port=port, reload=auto_reload, reload_dirs=[str(BASE_DIR)])