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 contextlib import nullcontext from http.cookiejar import CookieJar from io import BytesIO from datetime import date, datetime, timedelta, timezone from decimal import Decimal, InvalidOperation, ROUND_HALF_UP from difflib import SequenceMatcher from functools import lru_cache from html.parser import HTMLParser from pathlib import Path from typing import Any, Mapping, Sequence from urllib.parse import parse_qs, quote_plus, unquote_plus, urlencode, urljoin, urlparse from urllib.request import HTTPCookieProcessor, Request as UrlRequest, build_opener 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, _active_status_projection_run_matches_current_sources, _clear_compare_runtime_caches, _discover_available_fiscal_years, _get_compare_snapshot_state, _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, ensure_wehago_canonical_projection_state, 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_LINK_MAP_CACHE_LOCK = threading.Lock() _COST_ANALYSIS_LINK_MAP_CACHE: dict[tuple[Any, ...], dict[str, Any]] = {} COST_ANALYSIS_LINK_MAP_CACHE_TTL_SECONDS = 6 * 60 * 60.0 _COST_ANALYSIS_DETAIL_CACHE_LOCK = threading.Lock() _COST_ANALYSIS_DETAIL_CACHE: dict[tuple[Any, ...], dict[str, Any]] = {} COST_ANALYSIS_DETAIL_CACHE_TTL_SECONDS = 10 * 60.0 COST_ANALYSIS_FINANCIAL_LOGIC_VERSION = "cost-analysis-financial-v44-benefit-rnd-display" COST_ANALYSIS_LINK_LOGIC_VERSION = "cost-analysis-link-v5-satis-display-names" COST_ANALYSIS_H_PROJECT_MAPPING_VERSION = "h-code-confirmed-project-map-v1" COST_ANALYSIS_HANMAC_AGGREGATE_SCHEMA = "cost-analysis-period-v28-benefit-rnd-display" 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 hmbiz_process_static_no_cache_middleware(request: Request, call_next): response = await call_next(request) if request.url.path.startswith("/static/hm-biz-process/"): response.headers["Cache-Control"] = "no-store, max-age=0" response.headers["Pragma"] = "no-cache" response.headers["Expires"] = "0" if request.url.path == "/hanmac-browser": response.headers["Cache-Control"] = "no-store, max-age=0" response.headers["Pragma"] = "no-cache" response.headers["Expires"] = "0" return response @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]: def is_derived_projection_signature(value: Any) -> bool: normalized = normalize_text(value) return ( "snapshot-recheck-promote" in normalized or "db-reconciled-" in normalized ) 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}|") or is_derived_projection_signature(active_signature): active_signature = "" 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)} AND signature NOT LIKE '%|snapshot-recheck-promote|%' AND signature NOT LIKE '%|db-reconciled-%' 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 _apply_active_status_projection_card_counts( conn: sqlite3.Connection, metric_sections: list[dict[str, Any]], start_year: int, end_year: int, ) -> list[dict[str, Any]]: status_keys = { "hanmac_unconnected", "erp_voucher_matched", "erp_voucher_unmatched", } setting_keys = { f"wehago_active_status_projection:{status_key}:{int(start_year)}:{int(end_year)}": status_key for status_key in status_keys } if not setting_keys: return metric_sections placeholders = ", ".join("?" for _key in setting_keys) rows = conn.execute( f""" SELECT setting_key, setting_json FROM wehago_compare_settings WHERE setting_key IN ({placeholders}) """, tuple(setting_keys.keys()), ).fetchall() active_by_status: dict[str, dict[str, Any]] = {} for row in rows: status_key = setting_keys.get(str(row["setting_key"] or "")) if not status_key: continue try: payload = json.loads(str(row["setting_json"] or "{}")) except Exception: continue if isinstance(payload, dict): active_by_status[status_key] = payload missing_statuses = {status_key for status_key in status_keys if status_key not in active_by_status} if missing_statuses: try: query_start, query_end, query_signature = ( _latest_year_query_source(conn, int(start_year)) if int(start_year) == int(end_year) else (int(start_year), int(end_year), "") ) placeholders = ", ".join("?" for _status in missing_statuses) params: tuple[Any, ...] if query_signature: where_scope = "start_year = ? AND end_year = ? AND signature = ?" params = ( int(query_start), int(query_end), query_signature, int(start_year), int(end_year), *tuple(sorted(missing_statuses)), ) else: where_scope = "fiscal_year BETWEEN ? AND ? AND signature LIKE ?" params = ( int(start_year), int(end_year), f"{QUERY_PROJECTION_VERSION}|%", int(start_year), int(end_year), *tuple(sorted(missing_statuses)), ) for row in conn.execute( f""" SELECT status_key, COUNT(*) AS total_count, MAX(signature) AS signature FROM wehago_compare_query_groups WHERE {where_scope} AND fiscal_year BETWEEN ? AND ? AND status_key IN ({placeholders}) GROUP BY status_key """, params, ).fetchall(): status_key = str(row["status_key"] or "") if status_key: active_by_status[status_key] = { "total_count": int(row["total_count"] or 0), "signature": str(row["signature"] or ""), } except Exception: pass if int((active_by_status.get("hanmac_unconnected") or {}).get("total_count") or 0) <= 0: try: active_by_status["hanmac_unconnected"] = { "total_count": len( _load_hanmac_unconnected_source_groups( conn, int(start_year), int(end_year), ) ), "signature": str((active_by_status.get("hanmac_unconnected") or {}).get("signature") or ""), } except Exception: pass for section in metric_sections: status_key = str(section.get("key") or "") payload = active_by_status.get(status_key) if not payload: continue section["count"] = int(payload.get("total_count") or section.get("count") or 0) section["group_count"] = int(payload.get("total_count") or section.get("group_count") or section["count"] or 0) section["projection_signature"] = str(payload.get("signature") or section.get("projection_signature") or "") section["count_unit"] = "ERP 전표" if status_key == "hanmac_unconnected" else "전표그룹" return metric_sections 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 active_run_summary = None if active_run_summary: 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) 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 ""), } counts = _empty_compare_metric_counts() 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단계 비교", ""), ) ] metric_sections = apply_wehago_final_status_counts_to_metric_sections(metric_sections, active_run_summary) metric_sections = _apply_active_status_projection_card_counts( conn, metric_sections, int(start_year or 0), int(end_year or 0), ) return { "selected_start_year": start_year, "selected_end_year": end_year, "metric_sections": metric_sections, "wehago_final_status_summary": active_run_summary, "last_action": last_action, "pending": False, "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, } 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) if int(counts.get("hanmac_unconnected", 0) or 0) <= 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 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) count_pending = False if not has_current_projection_counts: 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: resolved_value = int(resolved_counts.get(status_key, 0) or 0) if resolved_value or int(counts.get(status_key, 0) or 0) == 0: counts[status_key] = resolved_value except Exception: count_pending = False with engine.begin() as sqlalchemy_conn: snapshot_state = _get_compare_snapshot_state( sqlalchemy_conn, int(start_year or 0), int(end_year or 0), ) pending = bool(snapshot_state["missing"] or snapshot_state["stale"] or snapshot_state["queued"] or snapshot_state["running"]) pending = pending or (bool(count_pending) and not has_current_projection_counts) 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: projection_repair = ensure_wehago_canonical_projection_state( sqlalchemy_conn, start_year, end_year, repair=True, ) final_status_summary = get_wehago_final_status_summary_from_conn( sqlalchemy_conn, start_year, end_year, counts, ) if projection_repair.get("needs_query_projection_rebuild"): pending = True if str(final_status_summary.get("source") or "") == "active_status_projection_missing": pending = True if projection_repair.get("repaired"): final_status_summary["auto_repair"] = projection_repair 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, source_support_dept_code TEXT DEFAULT '', source_project_code TEXT DEFAULT '', source_revision_id INTEGER 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, source_support_dept_code TEXT DEFAULT '', source_project_code TEXT DEFAULT '', source_revision_id INTEGER 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 satis_project_mapping ( id INTEGER PRIMARY KEY AUTOINCREMENT, erp_project_code TEXT NOT NULL, erp_project_name TEXT DEFAULT '', support_dept_code TEXT DEFAULT '', mapping_status TEXT DEFAULT 'pending', mapping_basis TEXT DEFAULT '', manual_override INTEGER DEFAULT 0, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, UNIQUE (erp_project_code) ) """ ) ) conn.execute( text( """ CREATE INDEX IF NOT EXISTS idx_satis_project_mapping_support_code ON satis_project_mapping (support_dept_code) """ ) ) conn.execute( text( """ CREATE TABLE IF NOT EXISTS satis_project_code_links ( id INTEGER PRIMARY KEY AUTOINCREMENT, local_project_code TEXT NOT NULL, local_project_name TEXT DEFAULT '', project_kind TEXT DEFAULT '', own_master_project_code TEXT DEFAULT '', own_master_project_name TEXT DEFAULT '', linked_main_project_code TEXT DEFAULT '', linked_main_project_name TEXT DEFAULT '', cost_project_code TEXT DEFAULT '', cost_project_name TEXT DEFAULT '', cost_kind TEXT DEFAULT '', pm_department_name TEXT DEFAULT '', is_joint_project TEXT DEFAULT '', is_tax_exempt TEXT DEFAULT '', is_active INTEGER DEFAULT 1, mapping_status TEXT DEFAULT 'confirmed', mapping_source TEXT DEFAULT '', source_file TEXT DEFAULT '', raw_payload_json TEXT DEFAULT '{}', created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, UNIQUE (local_project_code) ) """ ) ) conn.execute( text( """ CREATE INDEX IF NOT EXISTS idx_satis_project_code_links_own_master ON satis_project_code_links (own_master_project_code) """ ) ) conn.execute( text( """ CREATE INDEX IF NOT EXISTS idx_satis_project_code_links_linked_main ON satis_project_code_links (linked_main_project_code) """ ) ) conn.execute( text( """ CREATE INDEX IF NOT EXISTS idx_satis_project_code_links_cost_code ON satis_project_code_links (cost_project_code) """ ) ) conn.execute( text( """ CREATE TABLE IF NOT EXISTS satis_project_budget_revisions ( id INTEGER PRIMARY KEY AUTOINCREMENT, source_system TEXT DEFAULT 'satis', project_code TEXT NOT NULL, support_dept_code TEXT DEFAULT '', project_name TEXT DEFAULT '', budget_type TEXT NOT NULL, revision_no TEXT DEFAULT '', revision_name TEXT DEFAULT '', approval_status TEXT DEFAULT '', is_approved INTEGER DEFAULT 0, is_latest INTEGER DEFAULT 0, written_at TEXT DEFAULT '', approved_at TEXT DEFAULT '', source_updated_at TEXT DEFAULT '', source_key TEXT NOT NULL, source_hash TEXT DEFAULT '', raw_payload_json TEXT DEFAULT '', synced_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, UNIQUE (source_system, budget_type, source_key) ) """ ) ) conn.execute( text( """ CREATE INDEX IF NOT EXISTS idx_satis_project_budget_revisions_project ON satis_project_budget_revisions (project_code, support_dept_code, budget_type, revision_no) """ ) ) conn.execute( text( """ CREATE TABLE IF NOT EXISTS satis_project_task_plan_budget_lines ( id INTEGER PRIMARY KEY AUTOINCREMENT, revision_id INTEGER NOT NULL, line_no INTEGER NOT NULL DEFAULT 0, group_name TEXT DEFAULT '', dept_code TEXT DEFAULT '', dept_name TEXT DEFAULT '', work_code TEXT DEFAULT '', work_name TEXT DEFAULT '', amount REAL DEFAULT 0, currency TEXT DEFAULT 'KRW', source_line_key TEXT DEFAULT NULL, raw_payload_json TEXT DEFAULT '', synced_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, UNIQUE (revision_id, source_line_key), FOREIGN KEY (revision_id) REFERENCES satis_project_budget_revisions(id) ON DELETE CASCADE ) """ ) ) conn.execute( text( """ CREATE INDEX IF NOT EXISTS idx_satis_task_plan_budget_lines_revision ON satis_project_task_plan_budget_lines (revision_id, line_no) """ ) ) conn.execute( text( """ CREATE TABLE IF NOT EXISTS satis_project_exec_budget_lines ( id INTEGER PRIMARY KEY AUTOINCREMENT, revision_id INTEGER NOT NULL, line_no INTEGER NOT NULL DEFAULT 0, group_name TEXT DEFAULT '', grade TEXT DEFAULT '', hours TEXT DEFAULT '', rate_year TEXT DEFAULT '', unit_rate REAL DEFAULT 0, dept_code TEXT DEFAULT '', dept_name TEXT DEFAULT '', work_code TEXT DEFAULT '', work_name TEXT DEFAULT '', account_code TEXT DEFAULT '', account_name TEXT DEFAULT '', amount REAL DEFAULT 0, currency TEXT DEFAULT 'KRW', source_line_key TEXT DEFAULT NULL, raw_payload_json TEXT DEFAULT '', synced_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, UNIQUE (revision_id, source_line_key), FOREIGN KEY (revision_id) REFERENCES satis_project_budget_revisions(id) ON DELETE CASCADE ) """ ) ) conn.execute( text( """ CREATE INDEX IF NOT EXISTS idx_satis_exec_budget_lines_revision ON satis_project_exec_budget_lines (revision_id, line_no) """ ) ) conn.execute( text( """ CREATE TABLE IF NOT EXISTS satis_project_budget_raw_rows ( id INTEGER PRIMARY KEY AUTOINCREMENT, source_system TEXT DEFAULT 'satis', source_database TEXT NOT NULL, source_table TEXT NOT NULL, source_row_index INTEGER NOT NULL DEFAULT 0, budget_type TEXT DEFAULT '', project_code TEXT DEFAULT '', project_name TEXT DEFAULT '', revision_no TEXT DEFAULT '', approval_status TEXT DEFAULT '', amount_total REAL DEFAULT 0, amount_values_json TEXT DEFAULT '', inferred_columns_json TEXT DEFAULT '', raw_payload_json TEXT DEFAULT '', source_hash TEXT NOT NULL, synced_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, UNIQUE (source_hash) ) """ ) ) conn.execute( text( """ CREATE INDEX IF NOT EXISTS idx_satis_project_budget_raw_rows_project ON satis_project_budget_raw_rows (project_code, budget_type, source_database, source_table) """ ) ) conn.execute( text( """ CREATE TABLE IF NOT EXISTS satis_project_budget_projection_status ( id INTEGER PRIMARY KEY AUTOINCREMENT, support_dept_code TEXT NOT NULL, project_code TEXT DEFAULT '', project_name TEXT DEFAULT '', budget_type TEXT NOT NULL, revision_id INTEGER NOT NULL DEFAULT 0, revision_no TEXT DEFAULT '', approval_status TEXT DEFAULT '', projection_mode TEXT NOT NULL DEFAULT 'approved_latest', is_provisional INTEGER DEFAULT 0, line_count INTEGER DEFAULT 0, amount_total REAL DEFAULT 0, note TEXT DEFAULT '', projected_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, UNIQUE (support_dept_code, budget_type) ) """ ) ) conn.execute( text( """ CREATE INDEX IF NOT EXISTS idx_satis_budget_projection_status_revision ON satis_project_budget_projection_status (revision_id, projection_mode) """ ) ) conn.execute( text( """ CREATE TABLE IF NOT EXISTS satis_project_budget_web_captures ( id INTEGER PRIMARY KEY AUTOINCREMENT, capture_key TEXT NOT NULL, source_url TEXT NOT NULL, request_method TEXT DEFAULT 'GET', http_status INTEGER DEFAULT 0, final_url TEXT DEFAULT '', page_title TEXT DEFAULT '', matched_keywords TEXT DEFAULT '', internal_links_json TEXT DEFAULT '[]', amount_candidates_json TEXT DEFAULT '[]', body_preview TEXT DEFAULT '', body_hash TEXT DEFAULT '', captured_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, UNIQUE (capture_key) ) """ ) ) conn.execute( text( """ CREATE INDEX IF NOT EXISTS idx_satis_budget_web_captures_hash ON satis_project_budget_web_captures (body_hash, captured_at) """ ) ) 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 ''")) for column_name, column_type in { "source_support_dept_code": "TEXT DEFAULT ''", "source_project_code": "TEXT DEFAULT ''", "source_revision_id": "INTEGER DEFAULT 0", }.items(): if column_name not in exec_budget_entry_columns: conn.execute(text(f"ALTER TABLE project_exec_budget_entries ADD COLUMN {column_name} {column_type}")) task_plan_entry_columns = { row[1] for row in conn.execute(text("PRAGMA table_info(project_task_plan_entries)")).fetchall() } for column_name, column_type in { "source_support_dept_code": "TEXT DEFAULT ''", "source_project_code": "TEXT DEFAULT ''", "source_revision_id": "INTEGER DEFAULT 0", }.items(): if column_name not in task_plan_entry_columns: conn.execute(text(f"ALTER TABLE project_task_plan_entries ADD COLUMN {column_name} {column_type}")) 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 ''")) sync_satis_project_code_register(conn) 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, ) raw_common_tokens = { normalize_text(current.get("round_code")).replace(" ", "").upper(), normalize_text(current.get("raw_project_code")).replace(" ", "").upper(), normalize_text(current.get("support_dept_name")).replace(" ", "").upper(), } is_common_billing = any( token == "ZZZZZZ" or token in {"공통", "공통매출", "공통청구"} or token.startswith("공통매출") for token in raw_common_tokens if token ) # 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 = "ZZZZZZ" if is_common_billing else 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() satis_link_rows = conn.execute( text( """ SELECT local_project_code, local_project_name, own_master_project_code, own_master_project_name, linked_main_project_code, linked_main_project_name, cost_project_code, cost_project_name, mapping_status, is_active FROM satis_project_code_links WHERE COALESCE(local_project_code, '') <> '' AND COALESCE(mapping_status, '') IN ('confirmed', 'exception') AND COALESCE(is_active, 1) = 1 """ ) ).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, '') <> '' UNION SELECT DISTINCT local_project_code FROM satis_project_code_links WHERE COALESCE(local_project_code, '') <> '' UNION SELECT DISTINCT own_master_project_code FROM satis_project_code_links WHERE COALESCE(own_master_project_code, '') <> '' UNION SELECT DISTINCT linked_main_project_code FROM satis_project_code_links WHERE COALESCE(linked_main_project_code, '') <> '' UNION SELECT DISTINCT cost_project_code FROM satis_project_code_links WHERE COALESCE(cost_project_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, '') <> '' UNION SELECT local_project_code AS support_dept_code, local_project_name AS support_dept_name FROM satis_project_code_links WHERE COALESCE(local_project_code, '') <> '' UNION SELECT own_master_project_code AS support_dept_code, own_master_project_name AS support_dept_name FROM satis_project_code_links WHERE COALESCE(own_master_project_code, '') <> '' UNION SELECT linked_main_project_code AS support_dept_code, linked_main_project_name AS support_dept_name FROM satis_project_code_links WHERE COALESCE(linked_main_project_code, '') <> '' UNION SELECT cost_project_code AS support_dept_code, cost_project_name AS support_dept_name FROM satis_project_code_links WHERE COALESCE(cost_project_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]) } cluster_map: dict[str, set[str]] = {} for row in satis_link_rows: local_code = normalize_text(row.get("local_project_code")).upper() if not local_code: continue master_candidates = [ normalize_text(row.get("linked_main_project_code")).upper(), normalize_text(row.get("own_master_project_code")).upper(), normalize_text(row.get("cost_project_code")).upper(), ] master_code = next( ( code for code in master_candidates if code and code != local_code and code[:1] in {"0", "9"} and code[1:].isdigit() ), "", ) if not master_code: continue existing_codes.update({local_code, master_code}) project_names.setdefault(local_code, normalize_text(row.get("local_project_name"))) project_names.setdefault( master_code, normalize_text(row.get("linked_main_project_name")) or normalize_text(row.get("own_master_project_name")) or normalize_text(row.get("cost_project_name")), ) cluster_map.setdefault(f"satis_code::{master_code}", set()).update({master_code, local_code}) for row in billing_rows: base_code = normalize_text(row["support_dept_code"]) raw_project_code = normalize_actual_project_code(row["raw_project_code"]) round_code = normalize_project_code(row["round_code"], default_prefix=base_code[:1] or "Y") if not base_code: continue is_total_code = raw_project_code[:1] in {"0", "9"} and raw_project_code.isdigit() cluster_key = f"billing::{raw_project_code or base_code}" cluster = cluster_map.setdefault(cluster_key, set()) if is_total_code: existing_codes.add(raw_project_code) cluster.add(raw_project_code) if base_code in existing_codes: cluster.add(base_code) if round_code and round_code in existing_codes: 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("billing::"): link_source = "auto_billing" elif str(cluster_key).startswith("satis_code::"): link_source = "auto_satis_code" 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 COALESCE(project_related_links.link_source, 'manual') = 'manual' THEN COALESCE(project_related_links.link_source, 'manual') WHEN excluded.link_source = 'manual' THEN excluded.link_source WHEN project_related_links.link_source = 'auto_satis_code' THEN project_related_links.link_source WHEN excluded.link_source = 'auto_satis_code' THEN excluded.link_source 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 WHEN project_related_links.link_source = 'auto_billing' THEN project_related_links.link_source WHEN excluded.link_source = 'auto_billing' 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, }, ) @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: repaired = _auto_repair_wehago_projection_active_ranges() if repaired: logger.info("Auto-repaired WEHAGO projection state for %s range(s)", repaired) except Exception as exc: logger.warning("Post-startup WEHAGO projection auto-repair 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 _auto_repair_wehago_projection_active_ranges(limit: int = 20) -> int: ranges: set[tuple[int, int]] = set() with engine.begin() as conn: rows = conn.execute( text( """ SELECT setting_key FROM wehago_compare_settings WHERE setting_key LIKE 'wehago_active_query_projection:%:%' ORDER BY updated_at DESC LIMIT :limit """ ), {"limit": int(limit)}, ).mappings().all() for row in rows: parts = normalize_text(row.get("setting_key")).split(":") if len(parts) < 3: continue try: start = int(parts[-2]) end = int(parts[-1]) except Exception: continue if start > 0 and end >= start: ranges.add((start, end)) if not ranges: available_years = sorted( { int(year) for year in _discover_available_fiscal_years(conn) if int(year or 0) > 0 } ) if available_years: ranges.add((available_years[-1], available_years[-1])) repaired_count = 0 for start, end in sorted(ranges): result = ensure_wehago_canonical_projection_state(conn, start, end, repair=True) if result.get("repaired"): repaired_count += 1 return repaired_count 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 build_hanmac_wehago_audit_sources() -> dict[str, Any]: years = [ {"year": 2018, "gisu": 23}, {"year": 2019, "gisu": 24}, {"year": 2020, "gisu": 25}, {"year": 2021, "gisu": 26}, {"year": 2022, "gisu": 27}, {"year": 2023, "gisu": 28}, {"year": 2024, "gisu": 29}, {"year": 2025, "gisu": 30}, ] ledger_urls = [ { **item, "url": ( "https://smarta.wehago.com/#/smarta/account/SABK0107?sao" f"&cno=1173867&cd_com=biz202103030006368&gisu={item['gisu']}&yminsa=2026" f"&searchData={item['year']}0101{item['year']}1231&color=#1C90FB" "&companyName=(%EC%A3%BC)%ED%95%9C%EB%A7%A5%EA%B8%B0%EC%88%A0&companyID=b21344" ), } for item in years ] return { "menus": [ { "name": "계정별원장", "program": "SABK0107", "basis": "현재 DB 적재 원천입니다.", "keyword": "계정별원장", }, { "name": "총계정원장", "program": "", "basis": "계정별원장 잔액과 보고서 잔액을 결산 기준으로 대조할 때 사용합니다.", "keyword": "총계정원장", }, { "name": "합계잔액시산표", "program": "", "basis": "기말 잔액과 손익계정 총액을 보고서 금액과 대조할 때 사용합니다.", "keyword": "합계잔액시산표", }, ], "ledger_urls": ledger_urls, "account_codes": ["114", "137", "179", "260", "290", "901", "931", "116", "136"], "smarta_home_url": "https://smarta.wehago.com/", } 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 = {"수석", "책임", "선임", "연구원"} LABOR_RATE_GRADE_ORDER_HIGH_TO_LOW = ( "사장", "부사장", "전무", "상무", "이사", "부장", "차장", "과장", "대리", "사원", ) 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 _hanmac_is_researcher_grade(value: Any) -> bool: return "연구원" in _normalize_labor_grade_name(value) def _hanmac_is_system_member_record(member_record: Mapping[str, Any] | None) -> bool: record = member_record or {} member_no = normalize_text(record.get("member_no")).lower() member_name = _hanmac_normalize_person_name(record.get("member_name") or record.get("name")) login_id = normalize_text(record.get("login_id") or record.get("user_id") or record.get("account_id")).lower() display_name = normalize_text(record.get("member_name") or record.get("name")).replace(" ", "") explicit_system_member_nos = {"g26001", "g26002", "b24062", "office1"} organization_account_names = {"센터_기술기획팀", "인재성장팀", "관리실"} if member_no in explicit_system_member_nos or display_name in organization_account_names: return True system_tokens = ("tadmin", "admin", "test", "tester", "system", "sys", "office") if member_no.startswith(system_tokens) or login_id.startswith(system_tokens): return True if member_name in {"관리자", "시스템관리자", "테스트"} or "관리자" in member_name or "테스트" in member_name: return True has_person_marker = any( normalize_text(record.get(key)) for key in ("member_grade", "dept_name", "entry_date", "leave_date") ) return bool(member_no and member_name == member_no and not has_person_marker) def _hanmac_counts_as_member(member_record: Mapping[str, Any] | None) -> bool: return not _hanmac_is_researcher_grade((member_record or {}).get("member_grade")) 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)) numeric_requested_years = [ int(year_text) for year_text in year_candidates if year_text.isdigit() ] available_years = sorted( { int(year_text) for year_text in rates_by_year if normalize_text(year_text).isdigit() } ) if available_years: requested_year = max(numeric_requested_years or [datetime.now().year]) previous_years = [year for year in available_years if year <= requested_year] fallback_rate_year = previous_years[-1] if previous_years else available_years[0] fallback_rate_year_text = str(fallback_rate_year) if fallback_rate_year_text not in year_candidates: year_candidates.append(fallback_rate_year_text) 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 # 해당 직급의 시급이 없으면 직급 서열상 바로 위 직급부터 # 순서대로 찾아 가장 가까운 차상위 직급 시급을 사용한다. if grade_text in LABOR_RATE_GRADE_ORDER_HIGH_TO_LOW: grade_index = LABOR_RATE_GRADE_ORDER_HIGH_TO_LOW.index(grade_text) for higher_grade in reversed(LABOR_RATE_GRADE_ORDER_HIGH_TO_LOW[:grade_index]): amount = normalize_amount(category_bucket.get(higher_grade)) 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 if normalize_text(entry.get("account_code")).startswith("SATIS_"): 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' AND payload_signature LIKE :compatible_signature_pattern """ ), _cost_analysis_hanmac_signature_params(), ).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' AND payload_signature LIKE :compatible_signature_pattern ORDER BY CASE WHEN payload_signature LIKE :current_signature_prefix THEN 0 ELSE 1 END, updated_at DESC LIMIT 8 """ ), _cost_analysis_hanmac_signature_params(), ).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_COMMON_ACTIVITY_PREFIX = "__COMMON_ACTIVITY__:" COST_ANALYSIS_COMMON_ACTIVITY_SPECIAL_NAMES = { "H00-합사-01": "공통/합사", "H00-대기-01": "공통/감리대기", "HXX-영업-01": "공통/영업", "HV009109": "공통/영업", "HXX-고문-02": "공통/고문", "HV009111": "공통/고문", "HXX-교휴-04": "공통/휴가", "HV009104": "공통/휴가", "HXX-교휴-06": "공통/기타", "HV009110": "공통/기타", "HXX-교휴-08": "공통/행사·학회·협회", "HV009102": "공통/행사·학회·협회", "HV009101": "공통/회의", "HV009106": "공통/기타업무", "HP241101": "송산그린시티 용수공급시설(2차) 실시설계 용역", } COST_ANALYSIS_CONFIRMED_H_PROJECT_CODE_MAP = { # H/HP/HV source codes confirmed against ERP project/billing/transaction data. "H21-고속-09": "Y22004", "H22-제안-33": "Y22239", "H22-지방-08": "Y22239", "H24-제안-01": "Y24061", "H24-제안-05": "X24005", "H24-제안-10": "Z24138", "H24-제안-22": "Y24170", "H20-제안-16": "Z25031", "H21-제안-17": "Y22004", "HP241101": "X24005", } COST_ANALYSIS_CONFIRMED_H_TITLE_CODE_MAP = { "경호안전교육원3단계사업지명설계공모": "Y22239", "경호안전교육원3단계사업설계용역": "Y22239", "동광주광산대안제시경쟁": "Y24061", "동부간선도로지하화민간투자사업감독권한대행등건설사업관리용역": "Z24138", "포항안동111공구건설사업관리종심": "Z25031", "현대자동차남양연구소고속주회로재포장기본및실시설계": "Y22004", } COST_ANALYSIS_COMMON_VISIBLE_EXCEPTION_TITLES = { "국지도98호선양근대교도로건설공사감독권한대행등", "대산당진건설공사제1공구", "아산시노후하수관로개량사업", "중랑처리구역하수관로기술진단1권역", } def _cost_analysis_is_visible_common_exception(value: Any) -> bool: return normalize_project_title_for_linking(value) in COST_ANALYSIS_COMMON_VISIBLE_EXCEPTION_TITLES COST_ANALYSIS_LABOR_ACCOUNT_SQL = " OR ".join( f"COALESCE(account_name, '') LIKE '%{keyword}%'" for keyword in COST_ANALYSIS_LABOR_KEYWORDS ) 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 _cost_analysis_voucher_stem(value: Any) -> str: voucher = normalize_text(value) return voucher.rsplit("-", 1)[0] if "-" in voucher else voucher def _cost_analysis_voucher_date(value: Any) -> str: voucher = normalize_text(value) match = re.match(r"^11-(\d{4})(\d{2})(\d{2})-", voucher) if not match: return "" return f"{match.group(1)}-{match.group(2)}-{match.group(3)}" def _cost_analysis_erp_collection_events(end_date: date) -> list[dict[str, Any]]: # Only receivables created by customer billing belong to project collection. # General receivables include payroll/tax/asset-sale settlements. receivable_codes = {"10111101", "10111501"} non_cash_receivable_codes = {*receivable_codes, "10111901"} with engine.begin() as conn: transaction_rows = conn.execute( text( """ SELECT COALESCE(voucher_number, '') AS voucher_number, COALESCE(confirmed_voucher_number, '') AS confirmed_voucher_number, COALESCE(account_code, '') AS account_code, COALESCE(account_name, '') AS account_name, UPPER(COALESCE(support_dept_code, '')) AS support_dept_code, COALESCE(partner_name, '') AS partner_name, COALESCE(memo1, '') AS memo1, COALESCE(debit_supply, 0) AS debit_supply, COALESCE(debit_vat, 0) AS debit_vat, COALESCE(credit_supply, 0) AS credit_supply, COALESCE(credit_vat, 0) AS credit_vat FROM transactions WHERE account_code LIKE '101%' OR account_code LIKE '4%' """ ) ).mappings().all() voucher_groups: dict[str, list[dict[str, Any]]] = {} for raw_row in transaction_rows: row = dict(raw_row) voucher_key = _cost_analysis_voucher_stem( row.get("confirmed_voucher_number") or row.get("voucher_number") ) if voucher_key: voucher_groups.setdefault(voucher_key, []).append(row) invoice_candidates: dict[tuple[str, str], list[dict[str, Any]]] = {} for voucher_key, rows in voucher_groups.items(): revenue_supply = sum( normalize_amount(row.get("credit_supply")) - normalize_amount(row.get("debit_supply")) for row in rows if normalize_text(row.get("account_code")).startswith("4") ) receivable_debits = [ row for row in rows if normalize_text(row.get("account_code")) in receivable_codes and ( normalize_amount(row.get("debit_supply")) + normalize_amount(row.get("debit_vat")) - normalize_amount(row.get("credit_supply")) - normalize_amount(row.get("credit_vat")) ) > 0.5 ] gross_total = sum( normalize_amount(row.get("debit_supply")) + normalize_amount(row.get("debit_vat")) - normalize_amount(row.get("credit_supply")) - normalize_amount(row.get("credit_vat")) for row in receivable_debits ) if not receivable_debits or gross_total <= 0.5 or revenue_supply <= 0.5: continue for row in receivable_debits: gross_amount = ( normalize_amount(row.get("debit_supply")) + normalize_amount(row.get("debit_vat")) - normalize_amount(row.get("credit_supply")) - normalize_amount(row.get("credit_vat")) ) code = normalize_text(row.get("support_dept_code")).upper() partner = normalize_text(row.get("partner_name")) invoice_candidates.setdefault((code, partner), []).append( { "gross_amount": gross_amount, "supply_amount": revenue_supply * gross_amount / gross_total, "voucher_number": normalize_text(row.get("voucher_number")), "confirmed_voucher_number": normalize_text(row.get("confirmed_voucher_number")), "invoice_date": _cost_analysis_voucher_date(row.get("voucher_number")), "memo1": normalize_text(row.get("memo1")), } ) events: list[dict[str, Any]] = [] for voucher_key, rows in voucher_groups.items(): receipt_date = _cost_analysis_voucher_date(rows[0].get("voucher_number")) if not receipt_date or receipt_date > end_date.isoformat(): continue receivable_credits = [ row for row in rows if normalize_text(row.get("account_code")) in receivable_codes and ( normalize_amount(row.get("credit_supply")) + normalize_amount(row.get("credit_vat")) - normalize_amount(row.get("debit_supply")) - normalize_amount(row.get("debit_vat")) ) > 0.5 ] cash_debit_total = sum( normalize_amount(row.get("debit_supply")) + normalize_amount(row.get("debit_vat")) - normalize_amount(row.get("credit_supply")) - normalize_amount(row.get("credit_vat")) for row in rows if normalize_text(row.get("account_code")).startswith("101") and normalize_text(row.get("account_code")) not in non_cash_receivable_codes ) if cash_debit_total <= 0.5: continue if not receivable_credits: revenue_rows = [ row for row in rows if normalize_text(row.get("account_code")).startswith("4") and ( normalize_amount(row.get("credit_supply")) - normalize_amount(row.get("debit_supply")) ) > 0.5 ] receivable_debit_total = sum( normalize_amount(row.get("debit_supply")) + normalize_amount(row.get("debit_vat")) - normalize_amount(row.get("credit_supply")) - normalize_amount(row.get("credit_vat")) for row in rows if normalize_text(row.get("account_code")) in receivable_codes ) revenue_supply_total = sum( normalize_amount(row.get("credit_supply")) - normalize_amount(row.get("debit_supply")) for row in revenue_rows ) if not revenue_rows or receivable_debit_total > 0.5 or revenue_supply_total <= 0.5: continue for row in revenue_rows: supply_amount = ( normalize_amount(row.get("credit_supply")) - normalize_amount(row.get("debit_supply")) ) events.append( { "support_dept_code": normalize_text(row.get("support_dept_code")).upper(), "posting_date": receipt_date, "voucher_number": normalize_text(row.get("voucher_number")), "confirmed_voucher_number": normalize_text(row.get("confirmed_voucher_number")), "partner_name": normalize_text(row.get("partner_name")), "memo1": normalize_text(row.get("memo1")), "receivable_account_code": "", "receivable_account_name": "즉시 현금·카드 매출", "gross_amount": cash_debit_total * supply_amount / revenue_supply_total, "amount": supply_amount, "conversion_status": "immediate-cash", "source_invoice_voucher_number": normalize_text(row.get("voucher_number")), "source_invoice_confirmed_voucher_number": normalize_text( row.get("confirmed_voucher_number") ), "cash_match_gap": cash_debit_total - sum( normalize_amount(item.get("credit_supply")) + normalize_amount(item.get("credit_vat")) - normalize_amount(item.get("debit_supply")) - normalize_amount(item.get("debit_vat")) for item in revenue_rows ), } ) continue receivable_credit_total = sum( normalize_amount(row.get("credit_supply")) + normalize_amount(row.get("credit_vat")) - normalize_amount(row.get("debit_supply")) - normalize_amount(row.get("debit_vat")) for row in receivable_credits ) matched_cash_total = min(receivable_credit_total, cash_debit_total) for row in receivable_credits: gross_credit = ( normalize_amount(row.get("credit_supply")) + normalize_amount(row.get("credit_vat")) - normalize_amount(row.get("debit_supply")) - normalize_amount(row.get("debit_vat")) ) matched_gross = gross_credit * matched_cash_total / receivable_credit_total code = normalize_text(row.get("support_dept_code")).upper() partner = normalize_text(row.get("partner_name")) candidates = invoice_candidates.get((code, partner), []) exact_candidates = [ candidate for candidate in candidates if abs(normalize_amount(candidate.get("gross_amount")) - gross_credit) < 0.5 and normalize_text(candidate.get("invoice_date")) <= receipt_date ] receipt_memo = normalize_project_title_for_linking(row.get("memo1")) exact_invoice = max( exact_candidates, key=lambda candidate: ( SequenceMatcher( None, receipt_memo, normalize_project_title_for_linking(candidate.get("memo1")), ).ratio(), normalize_text(candidate.get("invoice_date")), ), default=None, ) if exact_invoice: supply_ratio = ( normalize_amount(exact_invoice.get("supply_amount")) / normalize_amount(exact_invoice.get("gross_amount")) ) supply_amount = matched_gross * supply_ratio conversion_status = "invoice-matched" else: supply_amount = matched_gross / 1.1 conversion_status = "vat-estimated" events.append( { "support_dept_code": code, "posting_date": receipt_date, "voucher_number": normalize_text(row.get("voucher_number")), "confirmed_voucher_number": normalize_text(row.get("confirmed_voucher_number")), "partner_name": partner, "memo1": normalize_text(row.get("memo1")), "receivable_account_code": normalize_text(row.get("account_code")), "receivable_account_name": normalize_text(row.get("account_name")), "gross_amount": matched_gross, "amount": supply_amount, "conversion_status": conversion_status, "source_invoice_voucher_number": normalize_text( (exact_invoice or {}).get("voucher_number") ), "source_invoice_confirmed_voucher_number": normalize_text( (exact_invoice or {}).get("confirmed_voucher_number") ), "cash_match_gap": cash_debit_total - receivable_credit_total, } ) return events 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_project_source_codes(project: Mapping[str, Any]) -> list[str]: return sorted( { normalize_text(value).upper() for value in ( project.get("project_code"), project.get("raw_project_code"), *(project.get("equivalent_project_codes") or []), *(project.get("source_project_codes") or []), ) if normalize_text(value) } ) def _cost_analysis_common_activity_info(project: Mapping[str, Any]) -> dict[str, Any] | None: source_codes = _cost_analysis_project_source_codes(project) h_codes = [code for code in source_codes if code.startswith("H")] if not h_codes: return None canonical_code = normalize_text(project.get("project_code")).upper() preferred_codes = [canonical_code, *h_codes] label = next( ( COST_ANALYSIS_COMMON_ACTIVITY_SPECIAL_NAMES[code] for code in preferred_codes if code in COST_ANALYSIS_COMMON_ACTIVITY_SPECIAL_NAMES ), "", ) if not label: category_match = re.match(r"^H(?:XX|\d{2})-([^-]+)-\d+$", canonical_code) category = normalize_text(category_match.group(1) if category_match else "") if category in {"영업", "고문", "관리"}: label = f"공통/{category}" elif category == "교휴": label = f"공통/교휴 ({canonical_code})" project_name = normalize_text(project.get("project_name")) if not label and project_name and project_name.upper() not in set(h_codes): label = project_name if not label: label = f"공통/기타업무 ({canonical_code or h_codes[0]})" raw_code = normalize_text(project.get("raw_project_code")).upper() display_source_codes = [] for code in h_codes: mapped_label = COST_ANALYSIS_COMMON_ACTIVITY_SPECIAL_NAMES.get(code) if mapped_label and mapped_label != label: continue if code in {canonical_code, raw_code} or mapped_label == label or not mapped_label: display_source_codes.append(code) if not display_source_codes: display_source_codes = [canonical_code or h_codes[0]] label_key = normalize_project_title_for_linking(label) or re.sub(r"[^A-Z0-9가-힣]+", "", label.upper()) return { "key": f"{COST_ANALYSIS_COMMON_ACTIVITY_PREFIX}{label_key}", "label": label, "source_codes": sorted(set(display_source_codes)), } def _cost_analysis_is_common_activity_code(value: Any) -> bool: return normalize_text(value).upper().startswith(COST_ANALYSIS_COMMON_ACTIVITY_PREFIX) 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_detail_item_matches(bucket: str, item_key: str, requested_item: str) -> bool: if requested_item == "revenue": return bucket == "revenue" if bucket not in {"cost", "sga"}: return False if requested_item == "cost_total": return bucket == "cost" and item_key in {"labor", "outsource", "overhead"} if requested_item == "sga_total": return bucket == "sga" and item_key == "sga" if requested_item == "sales_total": return item_key == "sales" if requested_item == "total_cost": return item_key in {"labor", "outsource", "overhead", "sga", "sales"} if requested_item in {"labor", "outsource", "overhead"}: return bucket == "cost" and item_key == requested_item if requested_item == "sga": return bucket == "sga" and item_key == "sga" if requested_item == "sales": return item_key == "sales" return False 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, "labor_adjustment": 0.0, "outsource": 0.0, "overhead": 0.0, "sga_labor": 0.0, "sga_labor_adjustment": 0.0, "sga": 0.0, "sales": 0.0}, "during": {"labor": 0.0, "labor_adjustment": 0.0, "outsource": 0.0, "overhead": 0.0, "sga_labor": 0.0, "sga_labor_adjustment": 0.0, "sga": 0.0, "sales": 0.0}, "post": {"labor": 0.0, "labor_adjustment": 0.0, "outsource": 0.0, "overhead": 0.0, "sga_labor": 0.0, "sga_labor_adjustment": 0.0, "sga": 0.0, "sales": 0.0}, } _COST_ANALYSIS_ROUND_CODE_RE = re.compile(r"^[XYZ]\d{5}$") _COST_ANALYSIS_MASTER_CODE_RE = re.compile(r"^[09]\d{5}$") def _cost_analysis_name_needs_display_fix(name: Any, code: Any = "") -> bool: normalized_name = normalize_text(name) normalized_code = normalize_text(code).upper() if not normalized_name: return True if normalized_code and normalized_name.upper() == normalized_code: return True return bool(re.fullmatch(r"[09]?\d{5,6}", normalized_name)) def _cost_analysis_get_satis_display_maps() -> dict[str, dict[str, Any]]: """Return Satis-derived display names and preferred round-code aliases. 손익분석 계산은 총괄/차수 코드가 모두 필요하지만, 화면 표시는 사용자가 보는 차수 프로젝트(X/Y/Z)와 사업명으로 맞춰야 한다. 이 함수는 satis_project_code_links와 이미 반영된 Satis 예산 출처를 이용해 그 표시 기준만 별도로 만든다. """ names: dict[str, str] = {} link_candidates: dict[str, list[dict[str, Any]]] = {} preferred_round: dict[str, str] = {} def put_name(code: Any, name: Any, *, prefer: bool = False) -> None: normalized_code = normalize_text(code).upper() normalized_name = normalize_text(name) if not normalized_code or not normalized_name: return current = names.get(normalized_code, "") if prefer or _cost_analysis_name_needs_display_fix(current, normalized_code): names[normalized_code] = normalized_name try: with engine.begin() as conn: link_rows = conn.execute( text( """ SELECT COALESCE(local_project_code, '') AS local_project_code, COALESCE(local_project_name, '') AS local_project_name, COALESCE(own_master_project_code, '') AS own_master_project_code, COALESCE(own_master_project_name, '') AS own_master_project_name, COALESCE(linked_main_project_code, '') AS linked_main_project_code, COALESCE(linked_main_project_name, '') AS linked_main_project_name, COALESCE(cost_project_code, '') AS cost_project_code, COALESCE(cost_project_name, '') AS cost_project_name, COALESCE(mapping_status, '') AS mapping_status, COALESCE(project_kind, '') AS project_kind, COALESCE(is_active, 1) AS is_active FROM satis_project_code_links WHERE COALESCE(local_project_code, '') <> '' """ ) ).mappings().all() budget_source_rows = conn.execute( text( """ SELECT UPPER(COALESCE(support_dept_code, '')) AS master_code, UPPER(COALESCE(source_support_dept_code, '')) AS source_code, COUNT(*) AS line_count, SUM(COALESCE(amount, 0)) AS amount FROM project_exec_budget_entries WHERE COALESCE(source_support_dept_code, '') <> '' AND UPPER(COALESCE(support_dept_code, '')) <> UPPER(COALESCE(source_support_dept_code, '')) GROUP BY UPPER(COALESCE(support_dept_code, '')), UPPER(COALESCE(source_support_dept_code, '')) """ ) ).mappings().all() status_codes = { normalize_text(code).upper() for code in conn.execute( text( """ SELECT support_dept_code FROM project_status WHERE COALESCE(support_dept_code, '') <> '' """ ) ).scalars().all() if normalize_text(code) } except Exception: return {"names": names, "preferred_round": preferred_round} for row in link_rows: local_code = normalize_text(row.get("local_project_code")).upper() if not local_code: continue put_name(local_code, row.get("local_project_name"), prefer=True) put_name(row.get("own_master_project_code"), row.get("own_master_project_name")) put_name(row.get("linked_main_project_code"), row.get("linked_main_project_name")) put_name(row.get("cost_project_code"), row.get("cost_project_name")) for master_code in { normalize_text(row.get("own_master_project_code")).upper(), normalize_text(row.get("linked_main_project_code")).upper(), normalize_text(row.get("cost_project_code")).upper(), }: if not master_code or not _COST_ANALYSIS_MASTER_CODE_RE.fullmatch(master_code): continue link_candidates.setdefault(master_code, []).append( { "local_code": local_code, "is_active": int(normalize_amount(row.get("is_active")) or 0), "mapping_status": normalize_text(row.get("mapping_status")), "project_kind": normalize_text(row.get("project_kind")), "in_project_status": local_code in status_codes, } ) budget_candidates: dict[str, list[tuple[float, int, str]]] = {} for row in budget_source_rows: master_code = normalize_text(row.get("master_code")).upper() source_code = normalize_text(row.get("source_code")).upper() if not _COST_ANALYSIS_MASTER_CODE_RE.fullmatch(master_code): continue if not _COST_ANALYSIS_ROUND_CODE_RE.fullmatch(source_code): continue budget_candidates.setdefault(master_code, []).append( ( normalize_amount(row.get("amount")), int(normalize_amount(row.get("line_count")) or 0), source_code, ) ) for master_code, candidates in budget_candidates.items(): preferred_round[master_code] = sorted(candidates, key=lambda item: (-item[0], -item[1], item[2]))[0][2] def link_rank(item: dict[str, Any]) -> tuple[int, int, int, int, str]: local_code = normalize_text(item.get("local_code")).upper() prefix = local_code[:1] return ( 0 if item.get("in_project_status") else 1, 0 if item.get("is_active") else 1, 0 if normalize_text(item.get("mapping_status")) in {"confirmed", "exception"} else 1, 0 if prefix in {"Y", "Z"} else 1 if prefix == "X" else 2, local_code, ) for master_code, candidates in link_candidates.items(): if master_code in preferred_round: continue valid_candidates = [ item for item in candidates if _COST_ANALYSIS_ROUND_CODE_RE.fullmatch(normalize_text(item.get("local_code")).upper()) ] if valid_candidates: preferred_round[master_code] = sorted(valid_candidates, key=link_rank)[0]["local_code"] return {"names": names, "preferred_round": preferred_round} 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() satis_display = _cost_analysis_get_satis_display_maps() satis_names = satis_display.get("names") or {} 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, '') <> '' UNION SELECT DISTINCT local_project_code FROM satis_project_code_links WHERE COALESCE(local_project_code, '') <> '' UNION SELECT DISTINCT own_master_project_code FROM satis_project_code_links WHERE COALESCE(own_master_project_code, '') <> '' UNION SELECT DISTINCT linked_main_project_code FROM satis_project_code_links WHERE COALESCE(linked_main_project_code, '') <> '' UNION SELECT DISTINCT cost_project_code FROM satis_project_code_links WHERE COALESCE(cost_project_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")) collected_amount = normalize_amount(billing_row.get("collected_amount")) billing_balance_amount = normalize_amount(billing_row.get("balance_amount")) collection_rate = _safe_ratio(collected_amount, contract_amount) if not collection_rate: collection_rate = max( [ normalize_amount(entry.get("collection_rate")) for entry in (billing_row.get("entries") or []) ] or [0.0] ) display_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 normalize_text(satis_names.get(code)) or code ) if _cost_analysis_name_needs_display_fix(display_name, code): display_name = normalize_text(satis_names.get(code)) or display_name result[code] = { "support_dept_code": code, "support_dept_name": display_name, "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, "collected_amount": collected_amount, "collection_balance_amount": billing_balance_amount, "collection_rate": collection_rate, "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_x_owner_map( x_links: dict[str, list[str]] | None = None, xyz_code_map: dict[str, str] | None = None, ) -> dict[str, str]: resolved_x_links = x_links if x_links is not None else _cost_analysis_get_x_links() resolved_xyz_code_map = xyz_code_map if xyz_code_map is not None else _cost_analysis_get_xyz_code_map() result: dict[str, str] = {} for owner_code, x_codes in resolved_x_links.items(): normalized_owner_code = normalize_text(owner_code).upper() display_owner_code = resolved_xyz_code_map.get(normalized_owner_code, normalized_owner_code) for x_code in x_codes: normalized_x_code = normalize_text(x_code).upper() if normalized_x_code: result.setdefault(normalized_x_code, display_owner_code) return result 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() with _COST_ANALYSIS_DETAIL_CACHE_LOCK: _COST_ANALYSIS_DETAIL_CACHE.clear() with _COST_ANALYSIS_LINK_MAP_CACHE_LOCK: _COST_ANALYSIS_LINK_MAP_CACHE.clear() _cost_analysis_field_cost_dept_names.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'")) conn.execute(text("DELETE FROM system_page_cache WHERE page_key = 'cost_analysis_link_map'")) def _cost_analysis_hanmac_signature_params() -> dict[str, str]: return { "current_signature_prefix": f"{HANMAC_AGGREGATE_SIGNATURE_PREFIX}%", "compatible_signature_pattern": f"{HANMAC_AGGREGATE_SIGNATURE_PREFIX}%", } def _cost_analysis_latest_hanmac_connection_params() -> dict[str, Any]: with engine.begin() as conn: rows = conn.execute( text( """ SELECT params_json FROM system_jobs WHERE job_type = 'hanmac_aggregate_cache' AND status = 'done' ORDER BY finished_at DESC, created_at DESC LIMIT 20 """ ) ).scalars().all() for raw_params in rows: try: params = json.loads(str(raw_params or "{}")) except Exception: continue if not isinstance(params, dict): continue if normalize_text(params.get("host")) and normalize_text(params.get("user")) and params.get("password"): return { "host": normalize_text(params.get("host")), "port": normalize_text(params.get("port")) or "3306", "user": normalize_text(params.get("user")), "password": params.get("password") or "", "database": normalize_text(params.get("database") or HANMAC_PRIMARY_MANHOUR_SCHEMA), "view": "member", "employment": normalize_text(params.get("employment") or "all"), "include_center_member_nos": params.get("include_center_member_nos") or [], } return {} def _cost_analysis_ensure_current_hanmac_cache_jobs(start_date: date, end_date: date) -> list[dict[str, Any]]: missing_slices: list[dict[str, Any]] = [] with engine.begin() as conn: for year_slice in _iter_year_slices(start_date, end_date): row = conn.execute( text( """ SELECT cache_key, row_count, summary_json, updated_at FROM hanmac_aggregate_query_metrics WHERE view_mode = 'member' AND payload_signature LIKE :current_signature_prefix AND COALESCE(start_date, '') <= :start_date AND COALESCE(end_date, '') >= :end_date AND COALESCE(row_count, 0) > 0 ORDER BY CASE WHEN start_date = :start_date AND end_date = :end_date THEN 0 ELSE 1 END, updated_at DESC LIMIT 1 """ ), { "start_date": year_slice["start"].isoformat(), "end_date": year_slice["end"].isoformat(), **_cost_analysis_hanmac_signature_params(), }, ).mappings().first() summary: dict[str, Any] = {} if row: try: summary = json.loads(str(row.get("summary_json") or "{}")) except Exception: summary = {} if not row or normalize_amount(summary.get("total_hours")) <= 0: missing_slices.append(year_slice) if not missing_slices: return [] connection_params = _cost_analysis_latest_hanmac_connection_params() if not connection_params: raise ValueError("최신 한맥 근무 캐시를 자동 갱신할 DB_external 접속 설정을 찾지 못했습니다.") jobs: list[dict[str, Any]] = [] for year_slice in missing_slices: job = _create_system_job( page_key="cost_analysis", job_type="hanmac_aggregate_cache", start_year=int(year_slice["year"]), end_year=int(year_slice["year"]), params={ **connection_params, "start_date": year_slice["start"].isoformat(), "end_date": year_slice["end"].isoformat(), }, ) jobs.append(job) return jobs 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' AND payload_signature LIKE :compatible_signature_pattern """ ), _cost_analysis_hanmac_signature_params(), ).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 payload_signature LIKE :compatible_signature_pattern AND COALESCE(start_date, '') <= :start_date AND COALESCE(end_date, '') >= :end_date ORDER BY CASE WHEN payload_signature LIKE :current_signature_prefix THEN 0 ELSE 1 END, 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(), **_cost_analysis_hanmac_signature_params(), }, ).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 payload_signature LIKE :compatible_signature_pattern AND COALESCE(start_date, '') <= :end_date AND COALESCE(end_date, '') >= :start_date ORDER BY CASE WHEN payload_signature LIKE :current_signature_prefix THEN 0 ELSE 1 END, 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(), **_cost_analysis_hanmac_signature_params(), }, ).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 payload_signature LIKE :compatible_signature_pattern AND COALESCE(start_date, '') <= :start_date AND COALESCE(end_date, '') >= :start_date AND COALESCE(end_date, '') <= :end_date ORDER BY CASE WHEN payload_signature LIKE :current_signature_prefix THEN 0 ELSE 1 END, end_date DESC, updated_at DESC LIMIT 10 """ ), { "start_date": start_date.isoformat(), "end_date": end_date.isoformat(), **_cost_analysis_hanmac_signature_params(), }, ).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) cost_weight = normalize_amount(project.get("cost_weight")) or 1.0 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, project_meta, ) result.setdefault((work_date or start_date).year, {}).setdefault( normalized_code, {"pre": 0.0, "during": 0.0, "post": 0.0}, )[phase] += rate * split_hours * cost_weight 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)} 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() representative_map = _cost_analysis_get_link_representative_map() normalized_phase = normalize_text(requested_phase).lower() detail_by_member: dict[tuple[str, 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 source_codes = _cost_analysis_project_source_codes(project) year_text = str((work_date or start_date).year) split_hours = hours / len(codes) cost_weight = normalize_amount(project.get("cost_weight")) or 1.0 for code in codes: normalized_code = normalize_text(code).upper() is_common_activity = _cost_analysis_is_common_activity_code(normalized_code) if ( normalized_code not in normalized_requested_codes and not (set(source_codes) & normalized_requested_codes) and not (is_common_activity and "ZZZZZZ" in normalized_requested_codes) ): continue phase = _cost_analysis_phase_for_transaction( normalized_code, (work_date or start_date).isoformat(), completion_dates, project_meta, ) 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")) source_project_code = next( (source_code for source_code in source_codes if source_code.startswith("H")), normalized_code, ) total_project_code = ( "ZZZZZZ" if is_common_activity else normalize_text(representative_map.get(normalized_code, "")).upper() ) display_project_code = source_project_code if is_common_activity else normalized_code key = (member_no, member_name, member_grade, display_project_code) 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, "total_project_code": total_project_code, "project_code": display_project_code, "source_project_code": source_project_code, "project_name": ( (_cost_analysis_common_activity_info(project) or {}).get("label") if is_common_activity else normalize_text(project.get("project_name")) ), "regular_hours": 0.0, "overtime_hours": 0.0, "holiday_hours": 0.0, "regular_amount": 0.0, "overtime_base_amount": 0.0, "overtime_premium_amount": 0.0, "holiday_base_amount": 0.0, "holiday_premium_amount": 0.0, "amount": 0.0, }, ) detail[f"{hour_kind}_hours"] += split_hours base_amount = rate * split_hours * cost_weight premium_amount = base_amount * 0.5 if hour_kind in {"overtime", "holiday"} else 0.0 if hour_kind == "regular": detail["regular_amount"] += base_amount elif hour_kind == "overtime": detail["overtime_base_amount"] += base_amount detail["overtime_premium_amount"] += premium_amount else: detail["holiday_base_amount"] += base_amount detail["holiday_premium_amount"] += premium_amount detail["amount"] += base_amount + premium_amount 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 for field in ( "regular_amount", "overtime_base_amount", "overtime_premium_amount", "holiday_base_amount", "holiday_premium_amount", "amount", ): detail[field] = int(round(normalize_amount(detail.get(field)))) 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")), normalize_text(item.get("project_code")), ), ) 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, 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")), normalize_text(row.get("project_code")), ) 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")), "total_project_code": normalize_text(row.get("total_project_code")), "project_code": normalize_text(row.get("project_code")), "source_project_code": normalize_text(row.get("source_project_code")), "project_name": normalize_text(row.get("project_name")), "regular_hours": 0.0, "overtime_hours": 0.0, "holiday_hours": 0.0, "regular_amount": 0.0, "overtime_base_amount": 0.0, "overtime_premium_amount": 0.0, "holiday_base_amount": 0.0, "holiday_premium_amount": 0.0, "amount": 0.0, }, ) for field in ( "regular_hours", "overtime_hours", "holiday_hours", "regular_amount", "overtime_base_amount", "overtime_premium_amount", "holiday_base_amount", "holiday_premium_amount", "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 for field in ( "regular_amount", "overtime_base_amount", "overtime_premium_amount", "holiday_base_amount", "holiday_premium_amount", "amount", ): detail[field] = int(round(normalize_amount(detail.get(field)))) 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")), normalize_text(item.get("project_code")), ), ) def _is_hanmac_joint_detail(value: dict[str, Any]) -> bool: return bool( normalize_text(value.get("joint_code")) or "합사" in normalize_text(value.get("joint_label")) or "합사" in normalize_text(value.get("source_label")) or "합사" in normalize_text(value.get("source")) or "합사" in normalize_text(value.get("note")) ) def _clear_project_cost_related_caches() -> None: with _PROCESS_COST_RUNTIME_CACHE_LOCK: _PROCESS_COST_PROJECT_OPTIONS_CACHE.clear() _PROCESS_COST_PROJECT_DETAIL_CACHE.clear() with engine.begin() as conn: conn.execute(text("DELETE FROM system_page_cache WHERE page_key IN ('process_cost_bootstrap', 'cost_analysis_payload')")) _clear_cost_analysis_payload_caches() def _cost_analysis_build_post_labor_summary( start_date: date, end_date: date, codes: list[str], row: dict[str, Any], project_meta: dict[str, dict[str, Any]], completion_dates: dict[str, str], ) -> dict[str, Any]: normalized_codes = [normalize_text(code).upper() for code in codes if normalize_text(code)] normalized_codes = [code for code in normalized_codes if code and code not in COST_ANALYSIS_COMMON_CODES] if not normalized_codes: return {} completion_date_values = sorted({completion_dates.get(code, "") for code in normalized_codes if completion_dates.get(code, "")}) completion_label = completion_date_values[0] if len(completion_date_values) == 1 else "코드별 준공금 기준" detail_rows = _cost_analysis_load_hanmac_labor_detail_rows_yearly( start_date, end_date, normalized_codes, "post", project_meta, ) if not detail_rows: return { "completion_billing_date": completion_label, "member_count": 0, "total_hours": 0.0, "extra_hours": 0.0, "amount": 0, "display_amount": int(round(normalize_amount(((row.get("phases") or {}).get("post") or {}).get("labor")))), "difference": -int(round(normalize_amount(((row.get("phases") or {}).get("post") or {}).get("labor")))), "matches_display": False, "people": [], } total_hours = sum(normalize_amount(item.get("total_hours")) for item in detail_rows) extra_hours = sum(normalize_amount(item.get("extra_hours")) for item in detail_rows) amount = int(round(sum(normalize_amount(item.get("amount")) for item in detail_rows))) display_amount = int(round(normalize_amount(((row.get("phases") or {}).get("post") or {}).get("labor")))) unique_members = { ( normalize_text(item.get("member_no")), normalize_text(item.get("member_name")), normalize_text(item.get("member_grade")), ) for item in detail_rows } return { "completion_billing_date": completion_label, "member_count": len(unique_members), "total_hours": round(total_hours, 2), "extra_hours": round(extra_hours, 2), "amount": amount, "display_amount": display_amount, "difference": int(round(amount - display_amount)), "matches_display": abs(amount - display_amount) < 1, "people": [ { "member_no": normalize_text(item.get("member_no")), "member_name": normalize_text(item.get("member_name")), "member_grade": normalize_text(item.get("member_grade")), "total_project_code": normalize_text(item.get("total_project_code")), "project_code": normalize_text(item.get("project_code")), "regular_hours": round(normalize_amount(item.get("regular_hours")), 2), "extra_hours": round(normalize_amount(item.get("extra_hours")), 2), "total_hours": round(normalize_amount(item.get("total_hours")), 2), "amount": int(round(normalize_amount(item.get("amount")))), } for item in detail_rows ], } def _cost_analysis_build_allocated_common_detail_rows( start_date: date, end_date: date, requested_codes: list[str], requested_phase: str, project_meta: dict[str, dict[str, Any]], requested_item: str, ) -> list[dict[str, Any]]: normalized_codes = {normalize_text(code).upper() for code in requested_codes if normalize_text(code)} normalized_codes -= COST_ANALYSIS_COMMON_CODES if not normalized_codes: return [] with engine.begin() as conn: annual_common_rows = conn.execute( text( f""" SELECT CAST(strftime('%Y', {COST_ANALYSIS_TX_DATE_SQL}) AS INTEGER) AS posting_year, SUM( CASE WHEN account_code LIKE '6%' THEN COALESCE(amount, 0) ELSE 0 END ) AS sga_amount, SUM( CASE WHEN account_code LIKE '5%' AND NOT ({COST_ANALYSIS_LABOR_ACCOUNT_SQL}) THEN COALESCE(amount, 0) ELSE 0 END ) AS common_cost_amount FROM transactions WHERE UPPER(COALESCE(support_dept_code, '')) IN ('', 'ZZZZZZ') AND (account_code LIKE '5%' OR account_code LIKE '6%') 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() normalized_item = normalize_text(requested_item).lower() amount_field = "sga_amount" if normalized_item == "sga" else "common_cost_amount" account_label = "공통 판관비 배부" if normalized_item == "sga" else "공통 제경비 배부" annual_totals = { int(row["posting_year"]): normalize_amount(row.get(amount_field)) for row in annual_common_rows if row.get("posting_year") } annual_hanmac_project_hours_by_year, _, annual_hanmac_missing_sga_by_year = _cost_analysis_load_hanmac_hours_and_labor_yearly( start_date, end_date, project_meta, None, ) normalized_phase = normalize_text(requested_phase).lower() rows: list[dict[str, Any]] = [] for year_slice in _iter_year_slices(start_date, end_date): year = int(year_slice["year"]) annual_total = annual_totals.get(year, 0.0) if normalized_item == "sga": annual_total += normalize_amount(annual_hanmac_missing_sga_by_year.get(year)) 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_total <= 0 or period_total_hours <= 0: continue hourly_amount = annual_total / period_total_hours for code in sorted(normalized_codes): phase_hours = hanmac_project_hours.get(code) or {} for phase, hours_value in phase_hours.items(): if normalized_phase and normalized_phase != "all" and phase != normalized_phase: continue hours = normalize_amount(hours_value) amount = int(round(hourly_amount * hours)) if not amount: continue rows.append( { "posting_date": f"{year}", "account_name": account_label, "partner_name": code, "memo1": f"{phase} 한맥근무 {hours:,.1f}h 기준 배부", "amount": amount, } ) return rows 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, project_meta) 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")) source_codes = _cost_analysis_project_source_codes(project) for source_code in source_codes: confirmed_code = COST_ANALYSIS_CONFIRMED_H_PROJECT_CODE_MAP.get(source_code) if confirmed_code and confirmed_code in project_meta: return [confirmed_code] confirmed_title_code = COST_ANALYSIS_CONFIRMED_H_TITLE_CODE_MAP.get(title_key) if confirmed_title_code and confirmed_title_code in project_meta: return [confirmed_title_code] 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: common_activity = _cost_analysis_common_activity_info(project) return [common_activity["key"]] if common_activity else [] 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, project_meta) 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 _COST_ANALYSIS_ADMIN_DEPT_NAMES = { "경영지원부", "임원실", "총괄기획실", "기술개발센터", "기술개발부", "공통", "관리실", "인사총무", "사업관리", } def _cost_analysis_normalize_dept_name(value: Any) -> str: return re.sub(r"\s+", "", normalize_text(value)) def _cost_analysis_is_admin_dept_name(value: Any) -> bool: dept_name = _cost_analysis_normalize_dept_name(value) if not dept_name: return True admin_names = {_cost_analysis_normalize_dept_name(name) for name in _COST_ANALYSIS_ADMIN_DEPT_NAMES} if dept_name in admin_names: return True return any(token in dept_name for token in ("경영지원", "임원", "총괄", "관리실", "인사총무", "사업관리", "센터")) @lru_cache(maxsize=1) def _cost_analysis_field_cost_dept_names() -> set[str]: init_db() with engine.begin() as conn: rows = conn.execute( text( """ SELECT DISTINCT COALESCE(cost_dept_name, '') AS cost_dept_name FROM transactions WHERE UPPER(COALESCE(support_dept_code, '')) NOT IN ('', 'ZZZZZZ') AND COALESCE(cost_dept_name, '') <> '' """ ) ).mappings().all() return { _cost_analysis_normalize_dept_name(row.get("cost_dept_name")) for row in rows if not _cost_analysis_is_admin_dept_name(row.get("cost_dept_name")) } 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]]], dict[int, 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]]] = {} missing_sga_result: dict[int, float] = {} dept_project_phase_hours: dict[int, dict[str, dict[tuple[str, str], float]]] = {} resolve_cache: dict[tuple[str, str, str], list[str]] = {} field_dept_names = _cost_analysis_field_cost_dept_names() def add_project(project: dict[str, Any], work_date_text: Any, hours: float, member_grade: str = "", dept_name: 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) cost_weight = normalize_amount(project.get("cost_weight")) or 1.0 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, project_meta) normalized_dept_name = _cost_analysis_normalize_dept_name(dept_name) hours_result.setdefault(year, {}).setdefault( normalized_code, {"pre": 0.0, "during": 0.0, "post": 0.0}, )[phase] += split_hours if normalized_dept_name and normalized_dept_name in field_dept_names: split_key = (normalized_code, phase) dept_project_phase_hours.setdefault(year, {}).setdefault(normalized_dept_name, {}) dept_project_phase_hours[year][normalized_dept_name][split_key] = ( dept_project_phase_hours[year][normalized_dept_name].get(split_key, 0.0) + 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 * cost_weight 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") ) dept_name = normalize_text(row.get("dept_name")) 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")) joint_recognized_hours = normalize_amount(project.get("recognized_hours")) if _is_hanmac_joint_detail(project) else 0.0 hours = ( joint_recognized_hours if joint_recognized_hours > 0 else 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, dept_name) for detail in details.get("overtime_hours") or []: add_project(detail, detail.get("work_date"), normalize_amount(detail.get("overtime_hours")), member_grade, dept_name) 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, dept_name) else: add_project(detail, detail.get("work_date"), normalize_amount(detail.get("holiday_hours")), member_grade, dept_name) 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 normalized_dept_name = _cost_analysis_normalize_dept_name(row.get("dept_name")) details = row.get("aggregate_details") if isinstance(row.get("aggregate_details"), dict) else {} for detail in details.get("missing_regular_days") or []: missing_hours = normalize_amount(detail.get("missing_hours")) if missing_hours <= 0: continue work_date = _parse_iso_date(detail.get("work_date")) or start_date if work_date < start_date or work_date > end_date: continue year = work_date.year year_text = str(year) rate = _resolve_labor_rate(rates_by_year, member_grade, year_text, year_text, "") missing_amount = rate * missing_hours if missing_amount <= 0: continue dept_ratios = dept_project_phase_hours.get(year, {}).get(normalized_dept_name, {}) dept_total_hours = sum(max(0.0, normalize_amount(hours)) for hours in dept_ratios.values()) if normalized_dept_name in field_dept_names and dept_total_hours > 0: for (code, phase), hours in dept_ratios.items(): if allowed_codes is not None and code not in allowed_codes: continue amount = missing_amount * (normalize_amount(hours) / dept_total_hours) if amount <= 0: continue labor_result.setdefault(year, {}).setdefault( code, {"pre": 0.0, "during": 0.0, "post": 0.0}, )[phase] += amount else: missing_sga_result[year] = missing_sga_result.get(year, 0.0) + missing_amount return hours_result, labor_result, missing_sga_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]]], dict[int, float]]: hours_result: dict[int, dict[str, dict[str, float]]] = {} labor_result: dict[int, dict[str, dict[str, float]]] = {} missing_sga_result: dict[int, float] = {} def merge( hours_by_year: dict[int, dict[str, dict[str, float]]], labor_by_year: dict[int, dict[str, dict[str, float]]], missing_sga_by_year: dict[int, 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) for year, amount in missing_sga_by_year.items(): missing_sga_result[year] = missing_sga_result.get(year, 0.0) + 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, missing_sga_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, missing_sga_result def _cost_analysis_hanmac_labor_totals_by_year( labor_by_year: dict[int, dict[str, dict[str, float]]], missing_sga_by_year: dict[int, float], ) -> dict[int, float]: years = set(labor_by_year) | set(missing_sga_by_year) return { year: ( sum( normalize_amount(amount) for phase_amounts in labor_by_year.get(year, {}).values() for amount in phase_amounts.values() ) + normalize_amount(missing_sga_by_year.get(year)) ) for year in years } 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 payload_signature LIKE :compatible_signature_pattern AND COALESCE(start_date, '') <= :year_start AND COALESCE(end_date, '') >= :year_end ORDER BY CASE WHEN payload_signature LIKE :current_signature_prefix THEN 0 ELSE 1 END, 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, **_cost_analysis_hanmac_signature_params(), }, ).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], project_meta: dict[str, dict[str, Any]] | None = None, ) -> str: normalized_code = normalize_text(code).upper() if _cost_analysis_is_common_activity_code(normalized_code): return "during" meta = (project_meta or {}).get(normalized_code) or {} if normalized_code.startswith("X"): return "pre" if normalized_code.startswith(("Y", "Z")): has_start_date = bool(normalize_text(meta.get("project_start_date"))) completion_date = completion_dates.get(normalized_code, "") collection_complete = ( normalize_amount(meta.get("collection_rate")) >= 99.5 or ( normalize_amount(meta.get("contract_amount")) > 0 and normalize_amount(meta.get("collected_amount")) >= normalize_amount(meta.get("contract_amount")) - 1 ) or ( normalize_amount(meta.get("collected_amount")) > 0 and abs(normalize_amount(meta.get("collection_balance_amount"))) <= 1 ) ) if completion_date and posting_date and posting_date >= completion_date and (has_start_date or collection_complete): return "post" return "during" if meta and (normalize_text(meta.get("project_type")) == "사업전" or not normalize_text(meta.get("project_start_date"))): return "pre" 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 { "row_key": normalize_text(meta.get("row_key")) or code, "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_negative_billing_amount": 0.0, "period_collection_amount": 0.0, "period_revenue_amount": 0.0, "period_revenue_billing_gap": 0.0, "period_revenue_collection_gap": 0.0, "period_cost_total": 0.0, "period_cost_labor_total": 0.0, "period_sga_labor_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, "cumulative_revenue_amount": 0.0, "cumulative_cost_total": 0.0, "cumulative_sga_total": 0.0, "cumulative_sales_total": 0.0, "cumulative_total_cost": 0.0, "cumulative_profit_amount": 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, "cost_labor_total": 0.0, "sga_labor_total": 0.0, "sga_total": 0.0, "sales_total": 0.0, "is_common_revenue": False, "common_revenue_amount": 0.0, } def _cost_analysis_finalize_row(row: dict[str, Any]) -> None: cost_total = 0.0 cost_labor_total = 0.0 sga_labor_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", "labor_adjustment", "outsource", "overhead"}: cost_total += amount if item_key in {"labor", "labor_adjustment"}: cost_labor_total += amount elif item_key == "sales": sales_total += amount else: sga_total += amount if item_key in {"sga_labor", "sga_labor_adjustment"}: sga_labor_total += amount row["cost_total"] = cost_total row["cost_labor_total"] = cost_labor_total row["sga_labor_total"] = sga_labor_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_cost_labor_total"] = cost_labor_total row["period_sga_labor_total"] = sga_labor_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["period_revenue_billing_gap"] = row["period_revenue_amount"] - normalize_amount(row.get("period_billing_amount")) row["period_revenue_collection_gap"] = row["period_revenue_amount"] - normalize_amount(row.get("period_collection_amount")) row["contract_balance_amount"] = max( normalize_amount(row.get("contract_amount")) - normalize_amount(row.get("billing_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_revenue_amount"] = normalize_amount(row.get("cumulative_revenue_amount")) row["cumulative_cost_total"] = normalize_amount(row.get("cumulative_cost_total")) row["cumulative_sga_total"] = normalize_amount(row.get("cumulative_sga_total")) row["cumulative_sales_total"] = normalize_amount(row.get("cumulative_sales_total")) row["cumulative_total_cost"] = normalize_amount(row.get("cumulative_total_cost")) row["cumulative_profit_amount"] = ( row["cumulative_revenue_amount"] - row["cumulative_total_cost"] ) row["cumulative_profit_rate"] = _safe_ratio( row["cumulative_profit_amount"], row["cumulative_revenue_amount"], ) def _cost_analysis_clean_common_master_row(row: dict[str, Any]) -> None: if not row.get("is_common_master"): return preserved_period_revenue = normalize_amount(row.get("period_revenue_amount")) preserved_collection = normalize_amount(row.get("collection_amount")) preserved_period_collection = normalize_amount(row.get("period_collection_amount")) for key in ( "billing_amount", "collection_amount", "period_billing_amount", "period_negative_billing_amount", "period_collection_amount", "period_revenue_billing_gap", "period_revenue_collection_gap", "period_cost_total", "period_cost_labor_total", "period_sga_labor_total", "period_sga_total", "period_sales_total", "period_total_cost", "period_profit_amount", "period_revenue_profit_rate", "period_collection_profit_rate", "contract_amount", "contract_balance_amount", "collection_rate", "revenue_amount", "cost_total", "cost_labor_total", "sga_labor_total", "sga_total", "sales_total", "total_cost", "profit_amount", "contract_profit_rate", "revenue_profit_rate", "collection_profit_rate", "cumulative_revenue_amount", "cumulative_cost_total", "cumulative_sga_total", "cumulative_sales_total", "cumulative_total_cost", "cumulative_profit_amount", "cumulative_profit_rate", ): row[key] = 0.0 row["period_revenue_amount"] = preserved_period_revenue row["collection_amount"] = preserved_collection row["period_collection_amount"] = preserved_period_collection row["period_revenue_collection_gap"] = preserved_period_revenue - preserved_period_collection row["period_profit_amount"] = preserved_period_revenue row["profit_amount"] = preserved_period_revenue row["period_revenue_profit_rate"] = _safe_ratio(preserved_period_revenue, preserved_period_revenue) row["period_collection_profit_rate"] = _safe_ratio(preserved_period_revenue, preserved_period_collection) row["collection_profit_rate"] = _safe_ratio(preserved_period_revenue, preserved_collection) row["cumulative_revenue_amount"] = preserved_period_revenue row["cumulative_profit_amount"] = preserved_period_revenue row["cumulative_profit_rate"] = _safe_ratio(preserved_period_revenue, preserved_period_revenue) 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_link_map_source_version() -> str: with engine.begin() as conn: row = conn.execute( text( """ SELECT (SELECT COUNT(*) FROM project_related_links) AS link_count, (SELECT COALESCE(MAX(updated_at), '') FROM project_related_links) AS link_updated_at, (SELECT COUNT(*) FROM project_billing_entries) AS billing_count, (SELECT COALESCE(MAX(updated_at), '') FROM project_billing_entries) AS billing_updated_at, (SELECT COUNT(*) FROM project_basic_info) AS project_count, (SELECT COALESCE(MAX(updated_at), '') FROM project_basic_info) AS project_updated_at, (SELECT COUNT(*) FROM satis_project_code_links) AS satis_link_count, (SELECT COALESCE(MAX(updated_at), '') FROM satis_project_code_links) AS satis_link_updated_at """ ) ).mappings().first() or {} return _json_hash( { "logic": COST_ANALYSIS_LINK_LOGIC_VERSION, "link_count": int(row.get("link_count") or 0), "link_updated_at": normalize_text(row.get("link_updated_at")), "billing_count": int(row.get("billing_count") or 0), "billing_updated_at": normalize_text(row.get("billing_updated_at")), "project_count": int(row.get("project_count") or 0), "project_updated_at": normalize_text(row.get("project_updated_at")), "satis_link_count": int(row.get("satis_link_count") or 0), "satis_link_updated_at": normalize_text(row.get("satis_link_updated_at")), } ) def _cost_analysis_build_link_representative_map() -> dict[str, str]: with engine.begin() as conn: rows = conn.execute( text( """ SELECT base_support_dept_code, related_support_dept_code, link_source FROM project_related_links WHERE COALESCE(base_support_dept_code, '') <> '' AND COALESCE(related_support_dept_code, '') <> '' """ ) ).mappings().all() billing_rows = conn.execute( text( """ SELECT support_dept_code, raw_project_code, round_code FROM project_billing_entries WHERE COALESCE(raw_project_code, '') <> '' AND COALESCE(round_code, '') <> '' """ ) ).mappings().all() satis_link_rows = conn.execute( text( """ SELECT local_project_code, own_master_project_code, linked_main_project_code, cost_project_code, mapping_status FROM satis_project_code_links WHERE COALESCE(local_project_code, '') <> '' AND COALESCE(mapping_status, '') IN ('confirmed', 'exception') """ ) ).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() link_source = normalize_text(row.get("link_source")).lower() if ( not base or not related or link_source not in { "manual", "auto_billing", "auto_satis_code", "auto_code_family", "auto_round", "auto_change_contract", } ): 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 for code in component: preferred_prefix = "9" if code.startswith("X") else "0" if code.startswith(("Y", "Z")) else code[:1] matching_totals = [candidate for candidate in total_codes if candidate.startswith(preferred_prefix)] representative_map[code] = (matching_totals or total_codes)[0] representative_map.update(_cost_analysis_infer_yz_link_representatives(representative_map)) # The billing application explicitly stores its parent contract code and # charged round code. This direct relation is authoritative when a broader # graph component contains more than one possible total project. for row in billing_rows: base_code = normalize_text(row.get("support_dept_code")).upper() total_code = normalize_actual_project_code(row.get("raw_project_code")) round_code = normalize_project_code( row.get("round_code"), default_prefix=base_code[:1] or "Y", ) if total_code[:1] not in {"0", "9"} or not total_code.isdigit(): continue if round_code: representative_map[round_code] = total_code if base_code: representative_map[base_code] = total_code representative_map[total_code] = total_code # Satis 차수사업코드등록은 총괄/차수/사전사업 연결의 공식 원장이다. # 프로젝트 손익분석의 집계 대표코드도 동일한 기준을 사용해야 예산 # 합산 결과와 손익분석 화면의 프로젝트 묶음이 어긋나지 않는다. for row in satis_link_rows: local_code = normalize_text(row.get("local_project_code")).upper() if not local_code or local_code == "ZZZZZZ": continue master_code = ( normalize_text(row.get("linked_main_project_code")).upper() or normalize_text(row.get("own_master_project_code")).upper() or normalize_text(row.get("cost_project_code")).upper() ) if master_code[:1] not in {"0", "9"} or not master_code[1:].isdigit(): continue representative_map[local_code] = master_code representative_map[master_code] = master_code return representative_map def _cost_analysis_get_link_representative_map(force: bool = False) -> dict[str, str]: source_version = _cost_analysis_link_map_source_version() memory_key = (source_version,) if not force: cached = _get_deepcopy_ttl_cache_entry( _COST_ANALYSIS_LINK_MAP_CACHE, _COST_ANALYSIS_LINK_MAP_CACHE_LOCK, memory_key, COST_ANALYSIS_LINK_MAP_CACHE_TTL_SECONDS, ) if cached is not None: return cached persistent = _load_system_page_cache("cost_analysis_link_map", source_version) if persistent is not None and isinstance(persistent.get("representative_map"), dict): return _set_deepcopy_ttl_cache_entry( _COST_ANALYSIS_LINK_MAP_CACHE, _COST_ANALYSIS_LINK_MAP_CACHE_LOCK, memory_key, persistent["representative_map"], ) representative_map = _cost_analysis_build_link_representative_map() _store_system_page_cache( "cost_analysis_link_map", source_version, params={ "source_version": source_version, "logic_version": COST_ANALYSIS_LINK_LOGIC_VERSION, }, payload={"representative_map": representative_map}, row_count=len(representative_map), signature=source_version, ) return _set_deepcopy_ttl_cache_entry( _COST_ANALYSIS_LINK_MAP_CACHE, _COST_ANALYSIS_LINK_MAP_CACHE_LOCK, memory_key, 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() inferred: dict[str, str] = {} representative_titles: dict[str, str] = { normalize_text(code).upper(): normalize_project_title_for_linking(meta.get("support_dept_name")) for code, meta in project_meta.items() if normalize_text(code).upper().startswith(("0", "9")) } exact_title_index: dict[tuple[str, str], list[str]] = {} trigram_index: dict[tuple[str, str], set[str]] = {} for representative, title in representative_titles.items(): if len(title) < 8: continue prefix = representative[:1] exact_title_index.setdefault((prefix, title), []).append(representative) for index in range(max(1, len(title) - 2)): trigram = title[index:index + 3] if trigram: trigram_index.setdefault((prefix, trigram), set()).add(representative) 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 {"X", "Y", "Z"} ): continue expected_total_prefix = "9" if normalized_code.startswith("X") else "0" suffix = contract_family_code_suffix(normalized_code) expected_total_code = f"{expected_total_prefix}{suffix.zfill(5)}" if suffix else "" if expected_total_code in project_meta: inferred[normalized_code] = expected_total_code continue title = normalize_project_title_for_linking(meta.get("support_dept_name")) if len(title) < 8: continue exact_matches = exact_title_index.get((expected_total_prefix, title), []) if len(exact_matches) == 1: inferred[normalized_code] = exact_matches[0] continue candidate_overlap: dict[str, int] = {} for index in range(max(1, len(title) - 2)): trigram = title[index:index + 3] for representative in trigram_index.get((expected_total_prefix, trigram), set()): candidate_overlap[representative] = candidate_overlap.get(representative, 0) + 1 candidate_codes = [ representative for representative, _ in sorted(candidate_overlap.items(), key=lambda item: (-item[1], item[0]))[:24] ] matches: list[tuple[float, str]] = [] for representative in candidate_codes: candidate_title = representative_titles.get(representative, "") if len(candidate_title) < 8: continue score = SequenceMatcher(None, title, candidate_title).ratio() if title in candidate_title or candidate_title in title: score = max(score, min(len(title), len(candidate_title)) / max(len(title), len(candidate_title))) if score >= 0.94: matches.append((score, representative)) if not matches: continue matches.sort(reverse=True) if len(matches) > 1 and matches[0][0] - matches[1][0] < 0.12: 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: if row.get("is_common_activity"): return normalize_text(row.get("row_key")) or normalize_text(row.get("project_name")) 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 normalize_text(row.get("support_dept_code")).upper() 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() satis_display = _cost_analysis_get_satis_display_maps() satis_names = satis_display.get("names") or {} grouped: dict[str, list[dict[str, Any]]] = {} common_revenue_rows: list[dict[str, Any]] = [] for row in rows: if row.get("is_common_revenue"): common_revenue_rows.append(copy.deepcopy(row)) continue 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["row_key"] = representative_code 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 representative_name = normalize_text(representative_meta.get("support_dept_name")) or normalize_text(satis_names.get(representative_code)) or representative_code if _cost_analysis_name_needs_display_fix(representative_name, representative_code): representative_name = normalize_text(satis_names.get(representative_code)) or representative_name aggregate["project_name"] = representative_name 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) }) aggregate["allocation_details"] = [ copy.deepcopy(detail) for row in group_rows for detail in (row.get("allocation_details") or []) ] 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_negative_billing_amount"] = sum(normalize_amount(row.get("period_negative_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")) for row in group_rows) aggregate["period_revenue_billing_gap"] = sum(normalize_amount(row.get("period_revenue_billing_gap")) for row in group_rows) aggregate["period_revenue_collection_gap"] = sum(normalize_amount(row.get("period_revenue_collection_gap")) 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")) for row in group_rows) aggregate["period_cost_labor_total"] = sum(normalize_amount(row.get("period_cost_labor_total")) for row in group_rows) aggregate["period_sga_labor_total"] = sum(normalize_amount(row.get("period_sga_labor_total")) for row in group_rows) aggregate["period_sga_total"] = sum(normalize_amount(row.get("period_sga_total")) for row in group_rows) aggregate["period_sales_total"] = sum(normalize_amount(row.get("period_sales_total")) for row in group_rows) aggregate["period_total_cost"] = sum(normalize_amount(row.get("period_total_cost")) for row in group_rows) aggregate["period_profit_amount"] = aggregate["period_revenue_amount"] - aggregate["period_total_cost"] aggregate["common_revenue_amount"] = sum(normalize_amount(row.get("common_revenue_amount")) for row in group_rows) aggregate["cumulative_revenue_amount"] = sum(normalize_amount(row.get("cumulative_revenue_amount")) for row in group_rows) aggregate["cumulative_cost_total"] = sum(normalize_amount(row.get("cumulative_cost_total")) for row in group_rows) aggregate["cumulative_sga_total"] = sum(normalize_amount(row.get("cumulative_sga_total")) for row in group_rows) aggregate["cumulative_sales_total"] = sum(normalize_amount(row.get("cumulative_sales_total")) for row in group_rows) aggregate["cumulative_total_cost"] = sum(normalize_amount(row.get("cumulative_total_cost")) for row in group_rows) aggregate["cumulative_profit_amount"] = aggregate["cumulative_revenue_amount"] - aggregate["cumulative_total_cost"] _cost_analysis_finalize_row(aggregate) result.append(aggregate) result.extend(common_revenue_rows) return result def _cost_analysis_apply_individual_display_codes( rows: list[dict[str, Any]], project_meta: dict[str, dict[str, Any]], ) -> list[dict[str, Any]]: satis_display = _cost_analysis_get_satis_display_maps() preferred_round = satis_display.get("preferred_round") or {} satis_names = satis_display.get("names") or {} if not preferred_round: for row in rows: code = normalize_text(row.get("support_dept_code")).upper() if _cost_analysis_name_needs_display_fix(row.get("project_name"), code): row["project_name"] = normalize_text(satis_names.get(code)) or normalize_text(row.get("project_name")) or code return rows def display_code_for(code: Any) -> str: normalized = normalize_text(code).upper() if _COST_ANALYSIS_MASTER_CODE_RE.fullmatch(normalized): return normalize_text(preferred_round.get(normalized)).upper() or normalized return normalized def display_name_for(code: str, fallback: Any = "") -> str: meta = project_meta.get(code) or {} name = normalize_text(meta.get("support_dept_name")) or normalize_text(satis_names.get(code)) or normalize_text(fallback) or code if _cost_analysis_name_needs_display_fix(name, code): name = normalize_text(satis_names.get(code)) or name return name grouped: dict[str, list[dict[str, Any]]] = {} for source_row in rows: row = copy.deepcopy(source_row) original_code = normalize_text(row.get("support_dept_code")).upper() display_code = display_code_for(original_code) if display_code and display_code != original_code: row["support_dept_code"] = display_code row["row_key"] = display_code row["project_name"] = display_name_for(display_code, row.get("project_name")) display_meta = project_meta.get(display_code) or {} row["pm_department"] = normalize_text(display_meta.get("pm_department")) or normalize_text(row.get("pm_department")) row["project_type"] = _cost_analysis_project_type(display_code, display_meta.get("project_type") or row.get("project_type")) row["completion_status"] = normalize_text(display_meta.get("completion_status")) or normalize_text(row.get("completion_status")) row["project_start_date"] = normalize_text(display_meta.get("project_start_date")) or normalize_text(row.get("project_start_date")) row["project_end_date"] = normalize_text(display_meta.get("project_end_date")) or normalize_text(row.get("project_end_date")) row["direct_codes"] = sorted({ display_code, original_code, *[normalize_text(code).upper() for code in (row.get("direct_codes") or []) if normalize_text(code)], }) elif _cost_analysis_name_needs_display_fix(row.get("project_name"), original_code): row["project_name"] = display_name_for(original_code, row.get("project_name")) normalized_display_code = normalize_text(row.get("support_dept_code")).upper() if row.get("is_common_master"): group_key = f"{normalized_display_code}::common-master" elif row.get("is_common_activity") or row.get("is_hidden_common_activity"): group_key = f"{normalized_display_code}::common-activity::{normalize_text(row.get('row_key')) or normalize_text(row.get('project_name'))}" else: group_key = normalized_display_code grouped.setdefault(group_key, []).append(row) merged_rows: list[dict[str, Any]] = [] additive_fields = { "billing_amount", "collection_amount", "period_billing_amount", "period_negative_billing_amount", "period_collection_amount", "period_revenue_amount", "period_revenue_billing_gap", "period_revenue_collection_gap", "period_cost_total", "period_cost_labor_total", "period_sga_labor_total", "period_sga_total", "period_sales_total", "period_total_cost", "period_profit_amount", "cumulative_revenue_amount", "cumulative_cost_total", "cumulative_sga_total", "cumulative_sales_total", "cumulative_total_cost", "cumulative_profit_amount", "revenue_amount", "common_revenue_amount", } for group_key, group_rows in grouped.items(): display_code = normalize_text(group_rows[0].get("support_dept_code")).upper() if group_rows else normalize_text(group_key).split("::", 1)[0] if len(group_rows) == 1: merged_rows.append(group_rows[0]) continue base = copy.deepcopy(_cost_analysis_latest_row(group_rows)) base["support_dept_code"] = display_code base["row_key"] = display_code base["project_name"] = display_name_for(display_code, base.get("project_name")) base["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) }) base["aggregate_codes"] = sorted({ code for row in group_rows for code in (row.get("aggregate_codes") or []) if normalize_text(code) }) base["allocation_details"] = [ copy.deepcopy(detail) for row in group_rows for detail in (row.get("allocation_details") or []) ] for field in additive_fields: base[field] = sum(normalize_amount(row.get(field)) for row in group_rows) base["contract_amount"] = max(normalize_amount(row.get("contract_amount")) for row in group_rows) base["collected_amount"] = max(normalize_amount(row.get("collected_amount")) for row in group_rows) base["contract_balance_amount"] = max(normalize_amount(row.get("contract_balance_amount")) for row in group_rows) base["phases"] = _cost_analysis_empty_phase_totals() base["allocated"] = _cost_analysis_empty_phase_totals() for row in group_rows: for phase, buckets in (row.get("phases") or {}).items(): if phase not in base["phases"]: continue for item_key, amount in buckets.items(): if item_key in base["phases"][phase]: base["phases"][phase][item_key] += normalize_amount(amount) for phase, buckets in (row.get("allocated") or {}).items(): if phase not in base["allocated"]: continue for item_key, amount in buckets.items(): if item_key in base["allocated"][phase]: base["allocated"][phase][item_key] += normalize_amount(amount) _cost_analysis_finalize_row(base) base["cumulative_revenue_amount"] = normalize_amount(base.get("revenue_amount")) base["cumulative_cost_total"] = normalize_amount(base.get("cost_total")) base["cumulative_sga_total"] = normalize_amount(base.get("sga_total")) base["cumulative_sales_total"] = normalize_amount(base.get("sales_total")) base["cumulative_total_cost"] = normalize_amount(base.get("total_cost")) base["cumulative_profit_amount"] = base["cumulative_revenue_amount"] - base["cumulative_total_cost"] base["cumulative_profit_rate"] = _safe_ratio(base["cumulative_profit_amount"], base["cumulative_revenue_amount"]) merged_rows.append(base) return merged_rows def _cost_analysis_payload_cache_context(start_date_text: str, end_date_text: str) -> 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 return { "start_date": start_date, "end_date": end_date, "data_version": get_business_data_version(), "hanmac_cache_version": _cost_analysis_hanmac_cache_version(), } def _cost_analysis_cache_key(context: dict[str, Any], mode: str) -> tuple[Any, ...]: return ( context["start_date"].isoformat(), context["end_date"].isoformat(), mode, context["data_version"], context["hanmac_cache_version"], COST_ANALYSIS_HANMAC_AGGREGATE_SCHEMA, COST_ANALYSIS_FINANCIAL_LOGIC_VERSION, COST_ANALYSIS_H_PROJECT_MAPPING_VERSION, COST_ANALYSIS_LINK_LOGIC_VERSION if mode == "aggregate" else "", ) def _cost_analysis_persistent_cache_key(context: dict[str, Any], mode: str) -> str: return _json_hash( { "start_date": context["start_date"].isoformat(), "end_date": context["end_date"].isoformat(), "mode": mode, "data_version": context["data_version"], "hanmac_cache_version": context["hanmac_cache_version"], "financial_logic_version": COST_ANALYSIS_FINANCIAL_LOGIC_VERSION, "h_project_mapping_version": COST_ANALYSIS_H_PROJECT_MAPPING_VERSION, "link_logic_version": COST_ANALYSIS_LINK_LOGIC_VERSION if mode == "aggregate" else "", "schema": COST_ANALYSIS_HANMAC_AGGREGATE_SCHEMA, } ) def _cost_analysis_load_cached_payload(context: dict[str, Any], mode: str) -> dict[str, Any] | None: cache_key = _cost_analysis_cache_key(context, mode) 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: cached_payload["cache_info"] = {**(cached_payload.get("cache_info") or {}), "source": "memory", "ready": True} return cached_payload persistent_cache_key = _cost_analysis_persistent_cache_key(context, mode) persistent_payload = _load_system_page_cache("cost_analysis_payload", persistent_cache_key) if persistent_payload is None: return None persistent_payload["cache_info"] = {**(persistent_payload.get("cache_info") or {}), "source": "persistent", "ready": True} return _set_deepcopy_ttl_cache_entry( _COST_ANALYSIS_PAYLOAD_CACHE, _COST_ANALYSIS_PAYLOAD_CACHE_LOCK, cache_key, persistent_payload, ) def _cost_analysis_store_payload(context: dict[str, Any], mode: str, payload: dict[str, Any]) -> dict[str, Any]: cache_key = _cost_analysis_cache_key(context, mode) persistent_cache_key = _cost_analysis_persistent_cache_key(context, mode) payload["cache_info"] = { "source": "computed", "ready": True, "financial_logic_version": COST_ANALYSIS_FINANCIAL_LOGIC_VERSION, "h_project_mapping_version": COST_ANALYSIS_H_PROJECT_MAPPING_VERSION, "link_logic_version": COST_ANALYSIS_LINK_LOGIC_VERSION, "data_version": context["data_version"], "hanmac_cache_version": context["hanmac_cache_version"], "generated_at": datetime.now().isoformat(timespec="seconds"), } try: _store_system_page_cache( "cost_analysis_payload", persistent_cache_key, params={ "start_date": context["start_date"].isoformat(), "end_date": context["end_date"].isoformat(), "mode": mode, "data_version": context["data_version"], "hanmac_cache_version": context["hanmac_cache_version"], "financial_logic_version": COST_ANALYSIS_FINANCIAL_LOGIC_VERSION, "h_project_mapping_version": COST_ANALYSIS_H_PROJECT_MAPPING_VERSION, "link_logic_version": COST_ANALYSIS_LINK_LOGIC_VERSION, }, payload=payload, row_count=len(payload.get("rows") or []), signature=str(context["data_version"]), ) except OperationalError as exc: if "database is locked" not in str(exc).lower(): raise logger.warning("cost analysis persistent cache store skipped because database is locked") payload["cache_info"] = { **(payload.get("cache_info") or {}), "persistent_cache_skipped": True, "persistent_cache_error": "database is locked", } return _set_deepcopy_ttl_cache_entry( _COST_ANALYSIS_PAYLOAD_CACHE, _COST_ANALYSIS_PAYLOAD_CACHE_LOCK, cache_key, payload, ) def _cost_analysis_load_last_valid_payload(start_date_text: str, end_date_text: str, mode: str) -> dict[str, Any] | None: normalized_mode = "aggregate" if normalize_text(mode).lower() in {"aggregate", "sum", "합산", "연계", "linked", "link"} else "individual" start_date = (_parse_iso_date(start_date_text) or date(date.today().year, 1, 1)).isoformat() end_date = (_parse_iso_date(end_date_text) or date.today()).isoformat() with engine.begin() as conn: row = conn.execute( text( """ SELECT payload_json, updated_at FROM system_page_cache WHERE page_key = 'cost_analysis_payload' AND json_extract(params_json, '$.start_date') = :start_date AND json_extract(params_json, '$.end_date') = :end_date AND json_extract(params_json, '$.mode') = :mode ORDER BY updated_at DESC LIMIT 1 """ ), {"start_date": start_date, "end_date": end_date, "mode": normalized_mode}, ).mappings().first() if not row: return None try: payload = json.loads(str(row.get("payload_json") or "{}")) except Exception: return None if not isinstance(payload, dict) or not isinstance(payload.get("rows"), list): return None payload["cache_info"] = { **(payload.get("cache_info") or {}), "source": "last-valid", "ready": False, "stale": True, "updated_at": normalize_text(row.get("updated_at")), } return payload def _cost_analysis_build_individual_payload( context: dict[str, Any], force: bool = False, include_cumulative: bool = True, ) -> dict[str, Any]: start_date = context["start_date"] end_date = context["end_date"] cache_key = _cost_analysis_cache_key(context, "individual") if not force: cached_payload = _cost_analysis_load_cached_payload(context, "individual") if cached_payload is not None: return cached_payload linked_mode = False selected_year = start_date.year if start_date.year == end_date.year else None project_meta = _cost_analysis_get_project_meta() completion_dates = _cost_analysis_get_completion_billing_dates() rows_by_code: dict[str, dict[str, Any]] = {} def resolve_report_code(source_code: Any) -> str: normalized_code = normalize_text(source_code).upper() if normalized_code in COST_ANALYSIS_COMMON_CODES: return "" return normalized_code if normalized_code.startswith(("0", "9", "X", "Y", "Z")) else "" 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(billing_date, '') >= :start_date AND COALESCE(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 target_code = resolve_report_code(source_code) if target_code: visible_candidate_codes.add(target_code) annual_hanmac_project_hours_by_year, annual_hanmac_labor_by_year, annual_hanmac_missing_sga_by_year = _cost_analysis_load_hanmac_hours_and_labor_yearly( start_date, end_date, project_meta, None, ) annual_source_codes = { normalize_text(code).upper() for code_map in annual_hanmac_project_hours_by_year.values() for code in code_map } visible_candidate_codes.update( target_code for code in annual_source_codes if (target_code := resolve_report_code(code)) ) source_candidate_codes: set[str] = { code for code in [*period_source_codes, *annual_source_codes] if code and code not in COST_ANALYSIS_COMMON_CODES } if linked_mode: visible_link_keys = { representative_map.get(code, code) for code in visible_candidate_codes } source_candidate_codes.update( code for code, representative in representative_map.items() if representative in visible_link_keys ) 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(): target_code = resolve_report_code(code) if not target_code or (visible_candidate_codes and target_code not in visible_candidate_codes): continue target = hanmac_labor_map.setdefault(target_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"] = [] rows_by_code[normalized_code]["direct_codes"] = [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(billing_date, '') >= :start_date AND COALESCE(billing_date, '') <= :end_date THEN COALESCE(billed_amount, 0) ELSE 0 END ) AS period_billing_amount FROM project_billing_entries WHERE COALESCE(billing_date, '') <= :end_date GROUP BY support_dept_code """ ), {"start_date": start_date.isoformat(), "end_date": end_date.isoformat()}, ).mappings().all() negative_billing_rows = conn.execute( text( """ SELECT support_dept_code, SUM(COALESCE(billed_amount, 0)) AS period_negative_billing_amount FROM project_billing_entries WHERE COALESCE(billing_date, '') >= :start_date AND COALESCE(billing_date, '') <= :end_date AND COALESCE(billed_amount, 0) < 0 GROUP BY support_dept_code """ ), {"start_date": start_date.isoformat(), "end_date": end_date.isoformat()}, ).mappings().all() common_revenue_rows = conn.execute( text( f""" SELECT CAST(strftime('%Y', {COST_ANALYSIS_TX_DATE_SQL}) AS INTEGER) AS posting_year, COALESCE(account_code, '') AS account_code, COALESCE(account_name, '') AS account_name, SUM(COALESCE(amount, 0)) AS revenue_amount, COUNT(*) AS row_count FROM transactions WHERE UPPER(COALESCE(support_dept_code, '')) IN ('', 'ZZZZZZ') AND (accounting_category = '수입/매출액' OR account_code LIKE '4%') 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), COALESCE(account_code, ''), COALESCE(account_name, '') """ ), { "start_date": start_date.isoformat(), "end_date": end_date.isoformat(), }, ).mappings().all() annual_erp_revenue_rows = conn.execute( text( f""" SELECT CAST(strftime('%Y', {COST_ANALYSIS_TX_DATE_SQL}) AS INTEGER) AS posting_year, SUM(COALESCE(amount, 0)) AS revenue_amount FROM transactions WHERE (accounting_category = '수입/매출액' OR account_code LIKE '4%') 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() annual_common_rows = conn.execute( text( f""" SELECT CAST(strftime('%Y', {COST_ANALYSIS_TX_DATE_SQL}) AS INTEGER) AS posting_year, SUM( CASE WHEN account_code LIKE '6%' THEN COALESCE(amount, 0) ELSE 0 END ) AS sga_amount, SUM( CASE WHEN account_code LIKE '5%' AND NOT ({COST_ANALYSIS_LABOR_ACCOUNT_SQL}) THEN COALESCE(amount, 0) ELSE 0 END ) AS common_cost_amount, SUM( CASE WHEN account_code LIKE '5%' AND ({COST_ANALYSIS_LABOR_ACCOUNT_SQL}) THEN COALESCE(amount, 0) ELSE 0 END ) AS excluded_common_labor_amount FROM transactions WHERE UPPER(COALESCE(support_dept_code, '')) IN ('', 'ZZZZZZ') AND (account_code LIKE '5%' OR account_code LIKE '6%') 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 = resolve_report_code(code) if not target_code: continue 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")) amount = normalize_amount(row.get("amount")) if bucket == "revenue": report_row["revenue_amount"] += amount report_row["period_revenue_amount"] += amount if amount: visible_activity_codes.add(target_code) continue if bucket not in {"cost", "sga"}: continue if posting_date and start_date.isoformat() <= posting_date <= end_date.isoformat() and amount: visible_activity_codes.add(target_code) phase = "pre" if code.startswith("X") else _cost_analysis_phase_for_transaction(target_code, posting_date, completion_dates, project_meta) item_key = _cost_analysis_expense_item(row.get("account_code"), row.get("account_name"), _cost_analysis_is_sales_cost(row)) # Labor is calculated exclusively from external work records. ERP payroll # vouchers must never be used as a fallback when work records are absent. if item_key == "labor": continue 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 target_code = resolve_report_code(code) if not target_code: continue report_row = ensure_row(target_code) for phase, amount in phase_amounts.items(): if phase in report_row["phases"] and amount: report_row["phases"][phase]["labor"] += amount visible_activity_codes.add(target_code) common_collection_amount = 0.0 common_period_collection_amount = 0.0 for collection_event in _cost_analysis_erp_collection_events(end_date): code = normalize_text(collection_event.get("support_dept_code")).upper() amount = normalize_amount(collection_event.get("amount")) posting_date = _date_text(collection_event.get("posting_date")) is_period = bool(posting_date and start_date.isoformat() <= posting_date <= end_date.isoformat()) if code in COST_ANALYSIS_COMMON_CODES: common_collection_amount += amount if is_period: common_period_collection_amount += amount continue target_code = resolve_report_code(code) if not target_code or not amount: continue 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 if is_period: report_row["period_collection_amount"] += 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 = resolve_report_code(code) if not target_code: continue 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 period_billing_amount = normalize_amount(billing_row.get("period_billing_amount")) report_row["period_billing_amount"] += period_billing_amount if period_billing_amount: visible_activity_codes.add(target_code) for billing_row in negative_billing_rows: code = normalize_text(billing_row.get("support_dept_code")).upper() if not code or code in COST_ANALYSIS_COMMON_CODES: continue target_code = resolve_report_code(code) if not target_code: continue report_row = ensure_row(target_code) report_row["period_negative_billing_amount"] += normalize_amount(billing_row.get("period_negative_billing_amount")) annual_common_totals = { int(row["posting_year"]): { "sga": normalize_amount(row.get("sga_amount")), "overhead": normalize_amount(row.get("common_cost_amount")), "excluded_common_labor": normalize_amount(row.get("excluded_common_labor_amount")), } for row in annual_common_rows if row.get("posting_year") } common_revenue_totals_by_year: dict[int, float] = {} erp_revenue_totals_by_year = { int(row.get("posting_year") or 0): normalize_amount(row.get("revenue_amount")) for row in annual_erp_revenue_rows if int(row.get("posting_year") or 0) } common_sga_allocated_by_year: dict[int, float] = {} for row in common_revenue_rows: year = int(row.get("posting_year") or 0) if year: common_revenue_totals_by_year[year] = common_revenue_totals_by_year.get(year, 0.0) + normalize_amount(row.get("revenue_amount")) for row in common_revenue_rows: year = int(row.get("posting_year") or 0) revenue_amount = normalize_amount(row.get("revenue_amount")) account_code = normalize_text(row.get("account_code")) account_name = normalize_text(row.get("account_name")) or "공통매출" if not year or not revenue_amount: continue row_key = f"ZZZZZZ:{year}:{account_code or 'common'}:{normalize_project_title_for_linking(account_name) or 'common'}" common_meta = { "row_key": row_key, "support_dept_name": account_name, "pm_department": "공통", "project_type": "공통매출", "completion_status": "", "contract_amount": 0, } report_row = _cost_analysis_row_template("ZZZZZZ", common_meta, selected_year) report_row["row_key"] = row_key report_row["support_dept_code"] = "ZZZZZZ" report_row["direct_codes"] = ["ZZZZZZ"] report_row["project_name"] = account_name report_row["project_type"] = "공통매출" report_row["pm_department"] = "공통" report_row["is_common_revenue"] = True report_row["common_revenue_amount"] = revenue_amount report_row["revenue_amount"] = revenue_amount report_row["period_revenue_amount"] = revenue_amount annual_sga = normalize_amount(annual_common_totals.get(year, {}).get("sga")) erp_revenue_total = erp_revenue_totals_by_year.get(year) or 0.0 allocated_sga = annual_sga * revenue_amount / erp_revenue_total if erp_revenue_total else 0.0 common_sga_allocated_by_year[year] = common_sga_allocated_by_year.get(year, 0.0) + allocated_sga report_row["phases"]["during"]["sga"] += allocated_sga report_row["allocated"]["during"]["sga"] += allocated_sga rows_by_code[row_key] = report_row visible_activity_codes.add(row_key) common_billing_rows = [ row for row in billing_amount_rows if normalize_text(row.get("support_dept_code")).upper() in COST_ANALYSIS_COMMON_CODES ] common_negative_billing_rows = [ row for row in negative_billing_rows if normalize_text(row.get("support_dept_code")).upper() in COST_ANALYSIS_COMMON_CODES ] common_billing_amount = sum(normalize_amount(row.get("billing_amount")) for row in common_billing_rows) common_period_billing_amount = sum(normalize_amount(row.get("period_billing_amount")) for row in common_billing_rows) common_period_negative_billing_amount = sum( normalize_amount(row.get("period_negative_billing_amount")) for row in common_negative_billing_rows ) if not common_billing_rows: for common_revenue_row in rows_by_code.values(): if not common_revenue_row.get("is_common_revenue"): continue revenue_amount = normalize_amount(common_revenue_row.get("revenue_amount")) period_revenue_amount = normalize_amount(common_revenue_row.get("period_revenue_amount")) common_revenue_row["billing_amount"] = revenue_amount common_revenue_row["period_billing_amount"] = period_revenue_amount common_revenue_row["common_billing_fallback_from_erp"] = True elif common_billing_amount or common_period_billing_amount or common_period_negative_billing_amount: common_billing_key = f"ZZZZZZ:{start_date.isoformat()}:{end_date.isoformat()}:billing" common_billing_meta = { "row_key": common_billing_key, "support_dept_name": "공통 청구", "pm_department": "공통", "project_type": "공통매출", "completion_status": "", "contract_amount": 0, } common_billing_row = _cost_analysis_row_template("ZZZZZZ", common_billing_meta, selected_year) common_billing_row["row_key"] = common_billing_key common_billing_row["support_dept_code"] = "ZZZZZZ" common_billing_row["direct_codes"] = ["ZZZZZZ"] common_billing_row["project_name"] = "공통 청구" common_billing_row["project_type"] = "공통매출" common_billing_row["pm_department"] = "공통" common_billing_row["is_common_revenue"] = True common_billing_row["billing_amount"] = common_billing_amount common_billing_row["period_billing_amount"] = common_period_billing_amount common_billing_row["period_negative_billing_amount"] = common_period_negative_billing_amount rows_by_code[common_billing_key] = common_billing_row if common_period_billing_amount or common_period_negative_billing_amount: visible_activity_codes.add(common_billing_key) for year, common_sga_amount in common_sga_allocated_by_year.items(): if year in annual_common_totals: annual_common_totals[year]["sga"] = max(0.0, normalize_amount(annual_common_totals[year].get("sga")) - common_sga_amount) for year_slice in _iter_year_slices(start_date, end_date): year = int(year_slice["year"]) annual_totals = annual_common_totals.get(year, {}) missing_regular_sga_amount = normalize_amount(annual_hanmac_missing_sga_by_year.get(year)) 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 period_total_hours <= 0: continue hourly_allocations = { item: (normalize_amount(annual_totals.get(item)) + (missing_regular_sga_amount if item == "sga" else 0.0)) / period_total_hours for item in ("overhead", "sga") if normalize_amount(annual_totals.get(item)) + (missing_regular_sga_amount if item == "sga" else 0.0) > 0 } if not hourly_allocations: continue for code, phase_hours in hanmac_project_hours.items(): normalized_code = normalize_text(code).upper() if normalized_code in COST_ANALYSIS_COMMON_CODES: continue target_code = resolve_report_code(normalized_code) if not target_code: continue report_row = ensure_row(target_code) for phase, hours in phase_hours.items(): if phase not in report_row["phases"]: continue normalized_hours = normalize_amount(hours) for item, hourly_amount in hourly_allocations.items(): amount = hourly_amount * normalized_hours if not amount: continue report_row["phases"][phase][item] += amount report_row["allocated"][phase][item] += amount visible_activity_codes.add(target_code) 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) if linked_mode: visible_link_keys = { _cost_analysis_linked_group_key(row, representative_map) for row in period_finalized_rows } final_rows = [ row for row in period_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 = list(period_finalized_rows) post_labor_summary_codes = {"Y22216", "Y24090"} for row in final_rows: row_codes = { normalize_text(row.get("support_dept_code")).upper(), *[normalize_text(code).upper() for code in (row.get("direct_codes") or [])], } if not any(code in post_labor_summary_codes for code in row_codes): continue target_codes = sorted(code for code in row_codes if code.startswith(("Y", "Z"))) row["post_labor_after_completion"] = _cost_analysis_build_post_labor_summary( start_date, end_date, target_codes, row, project_meta, completion_dates, ) final_rows.sort(key=lambda item: (normalize_text(item.get("pm_department")), normalize_text(item.get("project_type")), normalize_text(item.get("project_name")))) accumulation_start = _cost_analysis_get_accumulation_start(end_date.isoformat()) if include_cumulative and accumulation_start < start_date: cumulative_context = _cost_analysis_payload_cache_context( accumulation_start.isoformat(), end_date.isoformat(), ) cumulative_payload = _cost_analysis_build_individual_payload( cumulative_context, force=False, include_cumulative=False, ) cumulative_rows_by_code = { normalize_text(item.get("row_key") or item.get("support_dept_code")).upper(): item for item in (cumulative_payload.get("rows") or []) } for row in final_rows: cumulative_row = cumulative_rows_by_code.get( normalize_text(row.get("row_key") or row.get("support_dept_code")).upper() ) or {} row["cumulative_revenue_amount"] = normalize_amount(cumulative_row.get("revenue_amount")) row["cumulative_cost_total"] = normalize_amount(cumulative_row.get("cost_total")) row["cumulative_sga_total"] = normalize_amount(cumulative_row.get("sga_total")) row["cumulative_sales_total"] = normalize_amount(cumulative_row.get("sales_total")) row["cumulative_total_cost"] = normalize_amount(cumulative_row.get("total_cost")) row["cumulative_profit_amount"] = ( row["cumulative_revenue_amount"] - row["cumulative_total_cost"] ) row["cumulative_profit_rate"] = _safe_ratio( row["cumulative_profit_amount"], row["cumulative_revenue_amount"], ) else: for row in final_rows: row["cumulative_revenue_amount"] = normalize_amount(row.get("revenue_amount")) row["cumulative_cost_total"] = normalize_amount(row.get("cost_total")) row["cumulative_sga_total"] = normalize_amount(row.get("sga_total")) row["cumulative_sales_total"] = normalize_amount(row.get("sales_total")) row["cumulative_total_cost"] = normalize_amount(row.get("total_cost")) row["cumulative_profit_amount"] = ( row["cumulative_revenue_amount"] - row["cumulative_total_cost"] ) row["cumulative_profit_rate"] = _safe_ratio( row["cumulative_profit_amount"], row["cumulative_revenue_amount"], ) 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_negative_billing_amount": sum(normalize_amount(row.get("period_negative_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")) for row in final_rows), "period_revenue_billing_gap": sum(normalize_amount(row.get("period_revenue_billing_gap")) for row in final_rows), "period_revenue_collection_gap": sum(normalize_amount(row.get("period_revenue_collection_gap")) for row in final_rows), "period_cost_total": sum(normalize_amount(row.get("period_cost_total")) for row in final_rows), "period_cost_labor_total": sum(normalize_amount(row.get("period_cost_labor_total")) for row in final_rows), "period_sga_labor_total": sum(normalize_amount(row.get("period_sga_labor_total")) for row in final_rows), "period_sga_total": sum(normalize_amount(row.get("period_sga_total")) for row in final_rows), "period_sales_total": sum(normalize_amount(row.get("period_sales_total")) for row in final_rows), "period_total_cost": sum(normalize_amount(row.get("period_total_cost")) for row in final_rows), "period_profit_amount": sum(normalize_amount(row.get("period_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), "cost_labor_total": sum(normalize_amount(row.get("cost_labor_total")) for row in final_rows), "sga_labor_total": sum(normalize_amount(row.get("sga_labor_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), "cumulative_revenue_amount": sum(normalize_amount(row.get("cumulative_revenue_amount")) for row in final_rows), "cumulative_cost_total": sum(normalize_amount(row.get("cumulative_cost_total")) for row in final_rows), "cumulative_sga_total": sum(normalize_amount(row.get("cumulative_sga_total")) for row in final_rows), "cumulative_sales_total": sum(normalize_amount(row.get("cumulative_sales_total")) for row in final_rows), "cumulative_total_cost": sum(normalize_amount(row.get("cumulative_total_cost")) for row in final_rows), "cumulative_profit_amount": sum(normalize_amount(row.get("cumulative_profit_amount")) for row in final_rows), "common_revenue_amount": sum(normalize_amount(row.get("common_revenue_amount")) for row in final_rows), "project_revenue_amount": sum(normalize_amount(row.get("period_revenue_amount")) for row in final_rows if not row.get("is_common_revenue")), "common_revenue_row_count": sum(1 for row in final_rows if row.get("is_common_revenue")), "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["cumulative_profit_amount"], summary["cumulative_revenue_amount"]) allocation_diagnostics = { "common_labor_excluded": sum( normalize_amount(row.get("excluded_common_labor_amount")) for row in annual_common_rows ), "common_cost_allocated": sum( normalize_amount(row.get("common_cost_amount")) for row in annual_common_rows ), "common_sga_allocated": sum( normalize_amount(row.get("sga_amount")) for row in annual_common_rows ), "hanmac_missing_regular_sga_allocated": sum( normalize_amount(amount) for amount in annual_hanmac_missing_sga_by_year.values() ), "hanmac_labor_total": sum( _cost_analysis_hanmac_labor_totals_by_year( annual_hanmac_labor_by_year, annual_hanmac_missing_sga_by_year, ).values() ), } payload = { "start_date": start_date.isoformat(), "end_date": end_date.isoformat(), "mode": "individual", "rows": final_rows, "summary": summary, "accumulation_start_date": accumulation_start.isoformat(), "allocation_diagnostics": allocation_diagnostics, "accounting_comparison": _cost_analysis_accounting_comparison( start_date, end_date, summary, allocation_diagnostics, ), } return _cost_analysis_store_payload(context, "individual", payload) def _cost_analysis_summary_for_rows(rows: list[dict[str, Any]]) -> dict[str, Any]: summary = { "contract_amount": sum(normalize_amount(row.get("contract_amount")) for row in rows), "billing_amount": sum(normalize_amount(row.get("billing_amount")) for row in rows), "collection_amount": sum(normalize_amount(row.get("collection_amount")) for row in rows), "period_billing_amount": sum(normalize_amount(row.get("period_billing_amount")) for row in rows), "period_negative_billing_amount": sum(normalize_amount(row.get("period_negative_billing_amount")) for row in rows), "period_collection_amount": sum(normalize_amount(row.get("period_collection_amount")) for row in rows), "period_revenue_amount": sum(normalize_amount(row.get("period_revenue_amount")) for row in rows), "period_revenue_billing_gap": sum(normalize_amount(row.get("period_revenue_billing_gap")) for row in rows), "period_revenue_collection_gap": sum(normalize_amount(row.get("period_revenue_collection_gap")) for row in rows), "period_cost_total": sum(normalize_amount(row.get("period_cost_total")) for row in rows), "period_cost_labor_total": sum(normalize_amount(row.get("period_cost_labor_total")) for row in rows), "period_sga_labor_total": sum(normalize_amount(row.get("period_sga_labor_total")) for row in rows), "period_sga_total": sum(normalize_amount(row.get("period_sga_total")) for row in rows), "period_sales_total": sum(normalize_amount(row.get("period_sales_total")) for row in rows), "period_total_cost": sum(normalize_amount(row.get("period_total_cost")) for row in rows), "period_profit_amount": sum(normalize_amount(row.get("period_profit_amount")) for row in rows), "contract_balance_amount": sum(normalize_amount(row.get("contract_balance_amount")) for row in rows), "revenue_amount": sum(normalize_amount(row.get("revenue_amount")) for row in rows), "cost_total": sum(normalize_amount(row.get("cost_total")) for row in rows), "cost_labor_total": sum(normalize_amount(row.get("cost_labor_total")) for row in rows), "sga_labor_total": sum(normalize_amount(row.get("sga_labor_total")) for row in rows), "sga_total": sum(normalize_amount(row.get("sga_total")) for row in rows), "sales_total": sum(normalize_amount(row.get("sales_total")) for row in rows), "total_cost": sum(normalize_amount(row.get("total_cost")) for row in rows), "profit_amount": sum(normalize_amount(row.get("profit_amount")) for row in rows), "cumulative_revenue_amount": sum(normalize_amount(row.get("cumulative_revenue_amount")) for row in rows), "cumulative_cost_total": sum(normalize_amount(row.get("cumulative_cost_total")) for row in rows), "cumulative_sga_total": sum(normalize_amount(row.get("cumulative_sga_total")) for row in rows), "cumulative_sales_total": sum(normalize_amount(row.get("cumulative_sales_total")) for row in rows), "cumulative_total_cost": sum(normalize_amount(row.get("cumulative_total_cost")) for row in rows), "cumulative_profit_amount": sum(normalize_amount(row.get("cumulative_profit_amount")) for row in rows), "common_revenue_amount": sum(normalize_amount(row.get("common_revenue_amount")) for row in rows), "project_revenue_amount": sum(normalize_amount(row.get("period_revenue_amount")) for row in rows if not row.get("is_common_revenue")), "common_revenue_row_count": sum(1 for row in rows if row.get("is_common_revenue")), "project_count": len(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["cumulative_profit_amount"], summary["cumulative_revenue_amount"]) return summary def _cost_analysis_accounting_comparison( start_date: date, end_date: date, allocated_summary: dict[str, Any], allocation_diagnostics: dict[str, Any], ) -> dict[str, Any]: is_full_year = ( start_date.year == end_date.year and start_date == date(start_date.year, 1, 1) and end_date == date(end_date.year, 12, 31) ) result = { "available": is_full_year, "scope_label": f"{start_date.isoformat()}~{end_date.isoformat()}", "allocated_cost": normalize_amount(allocated_summary.get("total_cost")), "allocated_profit": normalize_amount(allocated_summary.get("period_profit_amount")), "allocated_profit_rate": _safe_ratio( allocated_summary.get("period_profit_amount"), allocated_summary.get("period_revenue_amount"), ), **allocation_diagnostics, } if not is_full_year: result["unavailable_reason"] = "WEHAGO 감사 후 재무제표 비교는 전체 회계연도 조회에서 제공합니다." return result with engine.begin() as conn: erp = conn.execute( text( """ SELECT SUM(CASE WHEN accounting_category = '원가' THEN amount ELSE 0 END) AS cost, SUM(CASE WHEN accounting_category = '판관비' THEN amount ELSE 0 END) AS sga, SUM(CASE WHEN accounting_category = '수입/매출액' OR account_code LIKE '4%' THEN amount ELSE 0 END) AS revenue FROM transactions WHERE year = :year """ ), {"year": start_date.year}, ).mappings().first() wehago_rows = conn.execute( text( """ SELECT COALESCE(account_code, '') AS account_code, SUM(COALESCE(debit, 0)) AS debit, SUM(COALESCE(credit, 0)) AS credit FROM wehago_ledger_rows WHERE fiscal_year = :year GROUP BY account_code """ ), {"year": start_date.year}, ).mappings().all() wehago_revenue = 0.0 wehago_cost = 0.0 wehago_sga = 0.0 wehago_labor_total = 0.0 for row in wehago_rows: code = normalize_text(row.get("account_code")) amount = _financial_gap_statement_amount(row) if code in {"411", "412", "413", "414", "415", "416", "417"}: wehago_revenue += abs(normalize_amount(row.get("credit"))) elif code == "452": wehago_cost += amount elif code.startswith("8"): wehago_sga += amount elif code == "908": wehago_sga -= amount if code in {"604", "606", "609", "611", "802", "808", "811"}: wehago_labor_total += amount erp_cost = normalize_amount((erp or {}).get("cost")) erp_sga = normalize_amount((erp or {}).get("sga")) erp_revenue = normalize_amount((erp or {}).get("revenue")) wehago_operating_expense = wehago_cost + wehago_sga wehago_operating_profit = wehago_revenue - wehago_operating_expense result.update( { "year": start_date.year, "erp_cost": erp_cost, "erp_sga": erp_sga, "erp_operating_expense": erp_cost + erp_sga, "erp_revenue": erp_revenue, "wehago_cost": wehago_cost, "wehago_sga": wehago_sga, "wehago_operating_expense": wehago_operating_expense, "wehago_revenue": wehago_revenue, "wehago_operating_profit": wehago_operating_profit, "wehago_operating_margin": _safe_ratio(wehago_operating_profit, wehago_revenue), "allocated_to_wehago_expense_gap": normalize_amount(allocated_summary.get("total_cost")) - wehago_operating_expense, "wehago_labor_total": wehago_labor_total, "hanmac_to_wehago_labor_gap": normalize_amount(result.get("hanmac_labor_total")) - wehago_labor_total, } ) return result def _cost_analysis_filter_payload_codes(payload: dict[str, Any], codes: list[str] | set[str] | tuple[str, ...]) -> dict[str, Any]: requested_codes = {normalize_text(code).upper() for code in codes if normalize_text(code)} if not requested_codes: return payload filtered = copy.deepcopy(payload) filtered_rows = [] for row in payload.get("rows") or []: row_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 [])], } if row_codes & requested_codes: filtered_rows.append(copy.deepcopy(row)) filtered["rows"] = filtered_rows filtered["summary"] = _cost_analysis_summary_for_rows(filtered_rows) filtered["validation_codes"] = sorted(requested_codes) filtered["cache_info"] = { **(filtered.get("cache_info") or {}), "validation_mode": True, } return filtered PAGE2_LABOR_TOKENS = ( "급여", "임금", "상여", "제수당", "퇴직", "퇴직금", "퇴직급여", "잡급", "연차", "연월차", "국민연금", "건강보험", "고용보험", "산재보험", "장기요양", ) PAGE2_INSURANCE_LABOR_TOKENS = ("국민연금", "건강보험", "고용보험", "산재보험", "장기요양") PAGE2_WEHAGO_LABOR_CODES = {"604", "606", "609", "611", "802", "808", "811"} PAGE2_WEHAGO_RND_CODES = {"650", "823"} def _cost_analysis2_text_blob(row: Mapping[str, Any]) -> str: return " ".join( normalize_text(row.get(key)) for key in ( "account_code", "account_name", "memo1", "memo2", "description", "vendor_name", "partner_name", ) ) def _cost_analysis2_is_labor_like(row: Mapping[str, Any]) -> bool: text_blob = _cost_analysis2_text_blob(row) account_name = normalize_text(row.get("account_name")) if "복리후생" in account_name and not any(token in text_blob for token in PAGE2_INSURANCE_LABOR_TOKENS): return False return any(token in text_blob for token in PAGE2_LABOR_TOKENS) def _cost_analysis2_is_rnd_like(row: Mapping[str, Any]) -> bool: text_blob = _cost_analysis2_text_blob(row) return "경상시험연구" in text_blob or "연구개발" in text_blob or "연구원" in text_blob def _cost_analysis2_expense_item(row: Mapping[str, Any]) -> str: if _cost_analysis_is_sales_cost(dict(row)): return "sales" account_code = normalize_text(row.get("account_code")) account_name = normalize_text(row.get("account_name")) if account_code.startswith("6"): return "sga" if any(keyword in account_name for keyword in COST_ANALYSIS_OUTSOURCE_KEYWORDS): return "outsource" return "overhead" def _cost_analysis2_reset_costs(row: dict[str, Any]) -> None: row["phases"] = _cost_analysis_empty_phase_totals() row["allocated"] = _cost_analysis_empty_phase_totals() for key in ( "period_cost_total", "period_cost_labor_total", "period_sga_labor_total", "period_sga_total", "period_sales_total", "period_total_cost", "period_profit_amount", "cost_total", "cost_labor_total", "sga_labor_total", "sga_total", "sales_total", "total_cost", "profit_amount", ): row[key] = 0.0 def _cost_analysis2_wehago_adjusted_statement(year: int) -> dict[str, float]: with engine.begin() as conn: account_rows = conn.execute( text( """ SELECT COALESCE(account_code, '') AS account_code, COALESCE(account_name, '') AS account_name, SUM(COALESCE(debit, 0)) AS debit, SUM(COALESCE(credit, 0)) AS credit FROM wehago_ledger_rows WHERE fiscal_year = :year GROUP BY account_code, account_name """ ), {"year": year}, ).mappings().all() detail_rows = conn.execute( text( """ SELECT fiscal_year, COALESCE(ledger_date, '') AS ledger_date, COALESCE(account_code, '') AS account_code, COALESCE(account_name, '') AS account_name, COALESCE(description, '') AS description, COALESCE(vendor_name, '') AS vendor_name, COALESCE(compare_desc, '') AS compare_desc, COALESCE(voucher_no, '') AS voucher_no, COALESCE(debit, 0) AS debit, COALESCE(credit, 0) AS credit FROM wehago_ledger_rows WHERE fiscal_year = :year AND ( account_code IN ('411','412','413','414','415','416','417','452','908') OR account_code LIKE '6%' OR account_code LIKE '8%' ) """ ), {"year": year}, ).mappings().all() operating_expense = 0.0 labor = 0.0 rnd = 0.0 for row in account_rows: code = normalize_text(row.get("account_code")) amount = _financial_gap_statement_amount(row) if code == "452": operating_expense += amount elif code.startswith("8"): operating_expense += amount elif code == "908": operating_expense -= amount if code in PAGE2_WEHAGO_LABOR_CODES: labor += amount if code in PAGE2_WEHAGO_RND_CODES: rnd += amount adjustment_total = 0.0 adjustment_labor = 0.0 adjustment_rnd = 0.0 for row in detail_rows: code = normalize_text(row.get("account_code")) bucket_key = _financial_gap_bucket_for_account(code) if bucket_key not in {"cogs", "sga", "cost_detail"}: continue if _financial_gap_is_closing_transfer(row) or not _financial_gap_is_audit_adjustment(row): continue delta = _financial_gap_signed_statement_delta(code, row.get("debit"), row.get("credit")) if not delta: continue adjustment_total += delta if code in PAGE2_WEHAGO_LABOR_CODES or _cost_analysis2_is_labor_like(row): adjustment_labor += delta if code in PAGE2_WEHAGO_RND_CODES or _cost_analysis2_is_rnd_like(row): adjustment_rnd += delta adjusted_expense = operating_expense - adjustment_total adjusted_labor = labor - adjustment_labor adjusted_rnd = rnd - adjustment_rnd return { "wehago_operating_expense": operating_expense, "wehago_adjustment_total": adjustment_total, "wehago_adjusted_expense": adjusted_expense, "wehago_labor": labor, "wehago_adjusted_labor": adjusted_labor, "wehago_rnd": rnd, "wehago_adjusted_rnd": adjusted_rnd, "wehago_adjusted_nonlabor": adjusted_expense - adjusted_labor, "wehago_adjusted_nonlabor_without_rnd": adjusted_expense - adjusted_labor - adjusted_rnd, } def _cost_analysis_erp_basis_payload(start_date_text: str, end_date_text: str, mode: str = "individual") -> dict[str, Any]: context = _cost_analysis_payload_cache_context(start_date_text, end_date_text) start_date = context["start_date"] end_date = context["end_date"] normalized_mode = "aggregate" if normalize_text(mode).lower() in {"aggregate", "sum", "합산", "연계", "linked", "link"} else "individual" base_payload = copy.deepcopy(_cost_analysis_load_cached_payload(context, "individual") or {}) if not base_payload: base_payload = copy.deepcopy( _cost_analysis_build_individual_payload( context, force=False, include_cumulative=False, ) ) common_period_revenue_amount = sum( normalize_amount(row.get("period_revenue_amount")) for row in (base_payload.get("rows") or []) if row.get("is_common_revenue") ) rows_by_code: dict[str, dict[str, Any]] = {} for row in base_payload.get("rows") or []: if row.get("is_common_revenue"): continue copied = copy.deepcopy(row) _cost_analysis2_reset_costs(copied) rows_by_code[normalize_text(copied.get("support_dept_code")).upper()] = copied project_meta = _cost_analysis_get_project_meta() completion_dates = _cost_analysis_get_completion_billing_dates() def resolve_report_code(source_code: Any) -> str: normalized_code = normalize_text(source_code).upper() if normalized_code in COST_ANALYSIS_COMMON_CODES: return "" return normalized_code if normalized_code.startswith(("0", "9", "X", "Y", "Z")) else "" def ensure_row(code: str) -> dict[str, Any]: normalized_code = normalize_text(code).upper() if normalized_code not in rows_by_code: meta = project_meta.get(normalized_code, {"support_dept_code": normalized_code, "support_dept_name": normalized_code}) rows_by_code[normalized_code] = _cost_analysis_row_template( normalized_code, meta, start_date.year if start_date.year == end_date.year else None, ) return rows_by_code[normalized_code] hanmac_hours_by_year, _old_labor_by_year, _old_missing_sga = _cost_analysis_load_hanmac_hours_and_labor_yearly( start_date, end_date, project_meta, None, ) hour_weights: dict[int, dict[str, dict[str, float]]] = {} for year, code_map in hanmac_hours_by_year.items(): for code, phase_hours in code_map.items(): target_code = resolve_report_code(code) if not target_code: continue target = hour_weights.setdefault(year, {}).setdefault(target_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) ensure_row(target_code) with engine.begin() as conn: tx_rows = conn.execute( text( f""" SELECT COALESCE(voucher_number, '') AS voucher_number, {COST_ANALYSIS_TX_DATE_SQL} AS posting_date, CAST(strftime('%Y', {COST_ANALYSIS_TX_DATE_SQL}) AS INTEGER) AS posting_year, COALESCE(account_code, '') AS account_code, COALESCE(account_name, '') AS account_name, COALESCE(accounting_category, '') AS accounting_category, 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(memo2, '') AS memo2, 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 '5%' OR account_code LIKE '6%') """ ), {"start_date": start_date.isoformat(), "end_date": end_date.isoformat()}, ).mappings().all() pools: dict[int, dict[str, float]] = {} diagnostics = { "erp_total_expense": 0.0, "erp_labor_pool": 0.0, "erp_cost_labor_pool": 0.0, "erp_sga_labor_pool": 0.0, "erp_rnd_labor_pool": 0.0, "erp_rnd_nonlabor_pool": 0.0, "erp_direct_nonlabor": 0.0, "erp_common_nonlabor_cost_pool": 0.0, "erp_common_nonlabor_sga_pool": 0.0, "judgement_required_amount": 0.0, } for raw_row in tx_rows: row = dict(raw_row) year = int(row.get("posting_year") or 0) if not year: continue amount = normalize_amount(row.get("amount")) if not amount: continue bucket = _cost_analysis_financial_bucket(row.get("account_code")) if bucket not in {"cost", "sga"}: continue diagnostics["erp_total_expense"] += amount pool = pools.setdefault(year, {"labor": 0.0, "sga_labor": 0.0, "common_cost": 0.0, "common_sga": 0.0}) is_rnd = _cost_analysis2_is_rnd_like(row) is_labor = _cost_analysis2_is_labor_like(row) if is_labor: labor_key = "sga_labor" if normalize_text(row.get("accounting_category")) == "판관비" else "labor" pool[labor_key] += amount diagnostics["erp_labor_pool"] += amount diagnostics["erp_sga_labor_pool" if labor_key == "sga_labor" else "erp_cost_labor_pool"] += amount if is_rnd: diagnostics["erp_rnd_labor_pool"] += amount continue if is_rnd: diagnostics["erp_rnd_nonlabor_pool"] += amount if "기타" in _cost_analysis2_text_blob(row) or "복리후생" in normalize_text(row.get("account_name")): diagnostics["judgement_required_amount"] += amount source_code = normalize_text(row.get("support_dept_code")).upper() target_code = resolve_report_code(source_code) item_key = _cost_analysis2_expense_item(row) if not target_code: if item_key == "sga": pool["common_sga"] += amount diagnostics["erp_common_nonlabor_sga_pool"] += amount else: pool["common_cost"] += amount diagnostics["erp_common_nonlabor_cost_pool"] += amount continue report_row = ensure_row(target_code) phase = "pre" if target_code.startswith("X") else _cost_analysis_phase_for_transaction( target_code, _date_text(row.get("posting_date")), completion_dates, project_meta, ) if item_key not in report_row["phases"].get(phase, {}): item_key = "sga" if bucket == "sga" else "overhead" report_row["phases"][phase][item_key] += amount diagnostics["erp_direct_nonlabor"] += amount unresolved_labor = 0.0 unresolved_common = 0.0 for year, pool in pools.items(): weights = hour_weights.get(year, {}) total_hours = sum( normalize_amount(hours) for phase_hours in weights.values() for hours in phase_hours.values() ) if total_hours <= 0: unresolved_labor += pool.get("labor", 0.0) + pool.get("sga_labor", 0.0) unresolved_common += pool.get("common_cost", 0.0) + pool.get("common_sga", 0.0) continue for code, phase_hours in weights.items(): report_row = ensure_row(code) for phase, hours in phase_hours.items(): if phase not in report_row["phases"]: continue ratio = normalize_amount(hours) / total_hours labor_amount = pool.get("labor", 0.0) * ratio sga_labor_amount = pool.get("sga_labor", 0.0) * ratio common_cost_amount = pool.get("common_cost", 0.0) * ratio common_sga_amount = pool.get("common_sga", 0.0) * ratio report_row["phases"][phase]["labor"] += labor_amount report_row["phases"][phase]["sga_labor"] += sga_labor_amount report_row["phases"][phase]["overhead"] += common_cost_amount report_row["phases"][phase]["sga"] += common_sga_amount report_row["allocated"][phase]["labor"] += labor_amount report_row["allocated"][phase]["sga_labor"] += sga_labor_amount report_row["allocated"][phase]["overhead"] += common_cost_amount report_row["allocated"][phase]["sga"] += common_sga_amount final_rows = list(rows_by_code.values()) for row in final_rows: _cost_analysis_finalize_row(row) row["cumulative_revenue_amount"] = normalize_amount(row.get("revenue_amount")) row["cumulative_cost_total"] = normalize_amount(row.get("cost_total")) row["cumulative_sga_total"] = normalize_amount(row.get("sga_total")) row["cumulative_sales_total"] = normalize_amount(row.get("sales_total")) row["cumulative_total_cost"] = normalize_amount(row.get("total_cost")) row["cumulative_profit_amount"] = row["cumulative_revenue_amount"] - row["cumulative_total_cost"] row["cumulative_profit_rate"] = _safe_ratio(row["cumulative_profit_amount"], row["cumulative_revenue_amount"]) if normalized_mode == "aggregate": representative_map = _cost_analysis_get_link_representative_map() final_rows = _cost_analysis_aggregate_rows(final_rows, project_meta, representative_map) 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 = _cost_analysis_summary_for_rows(final_rows) year_comparisons: list[dict[str, Any]] = [] for year in sorted(pools): statement = _cost_analysis2_wehago_adjusted_statement(year) year_project_rows = [ row for row in final_rows if int(normalize_amount(row.get("year")) or year) == year or start_date.year == end_date.year ] if start_date.year == end_date.year else final_rows project_total = sum(normalize_amount(row.get("total_cost")) for row in year_project_rows) if start_date.year == end_date.year else summary["total_cost"] year_comparisons.append( { "year": year, **statement, "erp_basis_allocated_total": project_total, "erp_basis_to_wehago_adjusted_gap": project_total - statement["wehago_adjusted_expense"], } ) diagnostics["unresolved_labor_pool"] = unresolved_labor diagnostics["unresolved_common_pool"] = unresolved_common diagnostics["erp_basis_total_cost"] = summary["total_cost"] diagnostics["erp_basis_total_gap_to_erp"] = summary["total_cost"] - diagnostics["erp_total_expense"] payload = { **base_payload, "mode": normalized_mode, "rows": final_rows, "summary": summary, "allocation_diagnostics": { **(base_payload.get("allocation_diagnostics") or {}), **diagnostics, }, "accounting_comparison": { **(base_payload.get("accounting_comparison") or {}), "erp_basis_available": True, "erp_basis": "hanmac_erp_transactions", "erp_basis_year_comparisons": year_comparisons, **diagnostics, }, "common_period_revenue_amount": common_period_revenue_amount, "cache_info": { "source": "computed", "ready": True, "financial_logic_version": COST_ANALYSIS_FINANCIAL_LOGIC_VERSION, "h_project_mapping_version": COST_ANALYSIS_H_PROJECT_MAPPING_VERSION, "link_logic_version": COST_ANALYSIS_LINK_LOGIC_VERSION, "data_version": context["data_version"], "hanmac_cache_version": context["hanmac_cache_version"], "generated_at": datetime.now().isoformat(timespec="seconds"), }, } return payload def _cost_analysis1_effective_period(start_date: date, end_date: date) -> tuple[date, date]: minimum = date(2023, 1, 1) cutoff = date(2026, 3, 31) effective_start = max(start_date, minimum) effective_end = min(end_date, cutoff) if effective_end < effective_start: effective_start = effective_end return effective_start, effective_end def _cost_analysis1_labor_basis( start_date: date, end_date: date, project_meta: dict[str, dict[str, Any]], ) -> dict[str, Any]: alias_to_code, title_to_codes = _cost_analysis_build_hanmac_matchers(project_meta) completion_dates = _cost_analysis_get_completion_billing_dates() 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)) standard: dict[int, dict[str, dict[str, float]]] = {} hours: dict[int, dict[str, dict[str, float]]] = {} dept_hours: dict[int, dict[str, dict[tuple[str, str], float]]] = {} dept_standard: dict[int, dict[str, dict[tuple[str, str], float]]] = {} common_activity_meta: dict[str, dict[str, Any]] = {} unresolved_hours = 0.0 def labor_bucket(code: str) -> str: normalized = normalize_text(code).upper() return "cost" if normalized.startswith(("Y", "Z")) and normalized != "ZZZZZZ" else "sga" for year_slice in _iter_year_slices(start_date, end_date): metric, row_items = _cost_analysis_load_hanmac_member_rows( year_slice["start"], year_slice["end"], prefer_member_grade=True, ) if not metric: continue resolve_cache: dict[tuple[str, str, str], list[str]] = {} def add_project( project: dict[str, Any], work_date_text: Any, member_grade: str, dept_name: str, recognized_hours: float, multiplier: float, ) -> None: nonlocal unresolved_hours if recognized_hours <= 0: return work_date = _parse_iso_date(work_date_text) or year_slice["start"] if work_date < year_slice["start"] or work_date > year_slice["end"]: 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.isoformat()}", ) codes = resolve_cache.get(resolve_key) if codes is None: 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: fallback_code = normalize_text(project.get("project_code")).upper() if not fallback_code: fallback_code = next( ( normalize_text(value).upper() for value in (project.get("equivalent_project_codes") or []) if normalize_text(value) ), "", ) codes = [fallback_code] if fallback_code else [] if not codes: unresolved_hours += recognized_hours return common_activity = _cost_analysis_common_activity_info(project) if common_activity: meta = common_activity_meta.setdefault( common_activity["key"], { "label": common_activity["label"], "source_codes": set(), }, ) meta["source_codes"].update(common_activity["source_codes"]) split_hours = recognized_hours / len(codes) cost_weight = normalize_amount(project.get("cost_weight")) or 1.0 year = work_date.year for code in codes: normalized_code = normalize_text(code).upper() normalized_dept = _cost_analysis_normalize_dept_name( dept_name or (project_meta.get(normalized_code) or {}).get("pm_department") or (project_meta.get(normalized_code) or {}).get("department_name") ) phase = ( "pre" if normalized_code.startswith("X") else _cost_analysis_phase_for_transaction( normalized_code, work_date.isoformat(), completion_dates, project_meta, ) ) rate = _resolve_labor_rate( rates_by_year, member_grade, str(year), str(year), (project_meta.get(normalized_code) or {}).get("project_type"), ) amount = rate * split_hours * multiplier * cost_weight target_key = "labor" if labor_bucket(normalized_code) == "cost" else "sga_labor" standard.setdefault(year, {}).setdefault( normalized_code, {"pre": 0.0, "during": 0.0, "post": 0.0, "bucket": target_key}, )[phase] += amount hours.setdefault(year, {}).setdefault( normalized_code, {"pre": 0.0, "during": 0.0, "post": 0.0}, )[phase] += split_hours if normalized_dept: dept_key = (normalized_code, phase) dept_hours.setdefault(year, {}).setdefault(normalized_dept, {}) dept_hours[year][normalized_dept][dept_key] = ( dept_hours[year][normalized_dept].get(dept_key, 0.0) + split_hours ) dept_standard.setdefault(year, {}).setdefault(normalized_dept, {}) dept_standard[year][normalized_dept][dept_key] = ( dept_standard[year][normalized_dept].get(dept_key, 0.0) + amount ) 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 dept_name = normalize_text(row.get("dept_name")) 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")) joint_hours = normalize_amount(project.get("recognized_hours")) if _is_hanmac_joint_detail(project) else 0.0 next_hours = ( joint_hours if joint_hours > 0 else recognized_total * raw_hours / raw_total if raw_total > 0 and recognized_total > 0 else raw_hours ) add_project(project, detail.get("work_date"), member_grade, dept_name, next_hours, 1.0) for detail in details.get("overtime_hours") or []: add_project( detail, detail.get("work_date"), member_grade, dept_name, normalize_amount(detail.get("overtime_hours")), 1.5, ) for detail in details.get("holiday_hours") or []: projects = detail.get("projects") if isinstance(detail.get("projects"), list) else [] recognized_total = min(normalize_amount(detail.get("holiday_hours")), 5.0) if projects: raw_total = sum(normalize_amount(project.get("hours")) for project in projects) for project in projects: raw_hours = normalize_amount(project.get("hours")) next_hours = ( recognized_total * raw_hours / raw_total if raw_total > 0 and recognized_total > 0 else min(raw_hours, 5.0) ) add_project(project, detail.get("work_date"), member_grade, dept_name, next_hours, 1.5) else: add_project(detail, detail.get("work_date"), member_grade, dept_name, recognized_total, 1.5) # ZZZZZZ는 미배부가 아니라 공통 프로젝트의 유효 코드다. # 한맥 근무기록이 ZZZZZZ에 연결된 경우 해당 기준인건비를 그대로 보존한다. return { "standard": standard, "hours": hours, "dept_hours": dept_hours, "dept_standard": dept_standard, "common_activity_meta": { code: { "label": normalize_text(meta.get("label")), "source_codes": sorted(meta.get("source_codes") or []), } for code, meta in common_activity_meta.items() }, "unresolved_hours": unresolved_hours, } def _cost_analysis1_build_payload(start_date_text: str, end_date_text: str, mode: str = "individual") -> dict[str, Any]: context = _cost_analysis_payload_cache_context(start_date_text, end_date_text) start_date, end_date = _cost_analysis1_effective_period( context["start_date"], context["end_date"], ) normalized_mode = "aggregate" if normalize_text(mode).lower() in {"aggregate", "sum", "합산", "연계", "linked", "link"} else "individual" erp_basis = copy.deepcopy(_cost_analysis_erp_basis_payload(start_date.isoformat(), end_date.isoformat(), "individual")) rows_by_code: dict[str, dict[str, Any]] = {} common_period_revenue = normalize_amount(erp_basis.get("common_period_revenue_amount")) common_collection_amount = 0.0 common_period_collection_amount = 0.0 for collection_event in _cost_analysis_erp_collection_events(end_date): code = normalize_text(collection_event.get("support_dept_code")).upper() if code not in COST_ANALYSIS_COMMON_CODES: continue amount = normalize_amount(collection_event.get("amount")) common_collection_amount += amount posting_date = _date_text(collection_event.get("posting_date")) if posting_date and start_date.isoformat() <= posting_date <= end_date.isoformat(): common_period_collection_amount += amount for source in erp_basis.get("rows") or []: if source.get("is_common_revenue"): continue row = copy.deepcopy(source) for phase in ("pre", "during", "post"): bucket = row["phases"][phase] bucket["overhead"] -= normalize_amount(row["allocated"][phase].get("overhead")) bucket["sga"] -= normalize_amount(row["allocated"][phase].get("sga")) bucket["labor"] = 0.0 bucket["labor_adjustment"] = 0.0 bucket["sga_labor"] = 0.0 bucket["sga_labor_adjustment"] = 0.0 row["allocated"][phase]["labor"] = 0.0 row["allocated"][phase]["sga_labor"] = 0.0 row["allocated"][phase]["overhead"] = 0.0 row["allocated"][phase]["sga"] = 0.0 rows_by_code[normalize_text(row.get("support_dept_code")).upper()] = row project_meta = _cost_analysis_get_project_meta() common_activity_meta: dict[str, dict[str, Any]] = {} common_activity_code_map: dict[str, str] = {} def ensure_row(code: str) -> dict[str, Any]: normalized = normalize_text(code).upper() normalized = common_activity_code_map.get(normalized, normalized) if normalized not in rows_by_code: activity_meta = common_activity_meta.get(normalized) if activity_meta: meta = { "row_key": normalized, "support_dept_name": normalize_text(activity_meta.get("label")) or "공통업무", "pm_department": "공통", "project_type": "공통업무", } row = _cost_analysis_row_template( "ZZZZZZ", meta, start_date.year if start_date.year == end_date.year else None, ) row["row_key"] = normalized row["support_dept_code"] = "ZZZZZZ" row["project_name"] = meta["support_dept_name"] row["pm_department"] = "공통" row["project_type"] = "공통업무" row["direct_codes"] = list(activity_meta.get("source_codes") or []) row["detail_codes"] = [normalized] row["is_common_activity"] = True rows_by_code[normalized] = row else: meta = project_meta.get(normalized, {"support_dept_code": normalized, "support_dept_name": normalized}) rows_by_code[normalized] = _cost_analysis_row_template( normalized, meta, start_date.year if start_date.year == end_date.year else None, ) return rows_by_code[normalized] basis = _cost_analysis1_labor_basis(start_date, end_date, project_meta) common_activity_meta = basis.get("common_activity_meta") or {} common_activity_code_map = { normalize_text(source_code).upper(): activity_code for activity_code, activity_meta in common_activity_meta.items() for source_code in (activity_meta.get("source_codes") or []) if normalize_text(source_code) } generic_common_code = f"{COST_ANALYSIS_COMMON_ACTIVITY_PREFIX}ERP공통비" common_activity_meta.setdefault( generic_common_code, { "label": "공통/ERP 공통비", "source_codes": ["ZZZZZZ"], }, ) all_hour_weights: dict[int, dict[tuple[str, str], float]] = {} for year, code_map in basis["hours"].items(): for code, phase_map in code_map.items(): normalized_code = normalize_text(code).upper() if not normalized_code or normalized_code in COST_ANALYSIS_COMMON_CODES: continue for phase in ("pre", "during", "post"): amount = normalize_amount(phase_map.get(phase)) if amount > 0: all_hour_weights.setdefault(year, {})[(normalized_code, phase)] = ( all_hour_weights.setdefault(year, {}).get((normalized_code, phase), 0.0) + amount ) with engine.begin() as conn: common_rows = conn.execute( text( f""" SELECT CAST(strftime('%Y', {COST_ANALYSIS_TX_DATE_SQL}) AS INTEGER) AS posting_year, COALESCE(account_code, '') AS account_code, COALESCE(account_name, '') AS account_name, COALESCE(accounting_category, '') AS accounting_category, 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(memo2, '') AS memo2, 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 '5%' OR account_code LIKE '6%') AND UPPER(COALESCE(support_dept_code, '')) IN ('', 'ZZZZZZ') """ ), {"start_date": start_date.isoformat(), "end_date": end_date.isoformat()}, ).mappings().all() for raw_row in common_rows: tx_row = dict(raw_row) if _cost_analysis2_is_labor_like(tx_row): continue amount = normalize_amount(tx_row.get("amount")) if not amount: continue target = ensure_row(generic_common_code) phase = "during" item_key = _cost_analysis2_expense_item(tx_row) if item_key not in target["phases"][phase]: item_key = "sga" if normalize_text(tx_row.get("accounting_category")) == "판관비" else "overhead" target["phases"][phase][item_key] += amount # ERP 기초자료에서 접두어 필터로 빠진 기타 코드를 페이지1에 직접 보완한다. # ZZZZZZ/미지정 비용은 위 공통비 풀에서 전체 프로젝트 투입시간으로 배부한다. initial_basis_codes = set(rows_by_code) completion_dates = _cost_analysis_get_completion_billing_dates() with engine.begin() as conn: direct_rows = conn.execute( text( f""" SELECT {COST_ANALYSIS_TX_DATE_SQL} AS posting_date, COALESCE(account_code, '') AS account_code, COALESCE(account_name, '') AS account_name, COALESCE(accounting_category, '') AS accounting_category, UPPER(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(memo2, '') AS memo2, COALESCE(amount, 0) AS amount FROM transactions WHERE {COST_ANALYSIS_TX_DATE_SQL} >= :start_date AND {COST_ANALYSIS_TX_DATE_SQL} <= :end_date AND COALESCE(support_dept_code, '') <> '' AND ( account_code LIKE '4%' OR account_code LIKE '5%' OR account_code LIKE '6%' ) """ ), {"start_date": start_date.isoformat(), "end_date": end_date.isoformat()}, ).mappings().all() for raw_row in direct_rows: tx_row = dict(raw_row) code = normalize_text(tx_row.get("support_dept_code")).upper() if not code: continue amount = normalize_amount(tx_row.get("amount")) if not amount: continue row = ensure_row(code) if not normalize_text(row.get("project_name")) or row.get("project_name") == code: row["project_name"] = normalize_text(tx_row.get("support_dept_name")) or code bucket = _cost_analysis_financial_bucket(tx_row.get("account_code")) if bucket == "revenue": if code in COST_ANALYSIS_COMMON_CODES: continue if code not in initial_basis_codes or code == "ZZZZZZ": row["revenue_amount"] += amount row["period_revenue_amount"] += amount continue if bucket not in {"cost", "sga"} or _cost_analysis2_is_labor_like(tx_row): continue if code in COST_ANALYSIS_COMMON_CODES: continue if code in initial_basis_codes and code != "ZZZZZZ": continue phase = "pre" if code.startswith("X") else _cost_analysis_phase_for_transaction( code, _date_text(tx_row.get("posting_date")), completion_dates, project_meta, ) item_key = _cost_analysis2_expense_item(tx_row) if item_key not in row["phases"][phase]: item_key = "sga" if bucket == "sga" else "overhead" row["phases"][phase][item_key] += amount with engine.begin() as conn: labor_rows = conn.execute( text( f""" SELECT CAST(strftime('%Y', {COST_ANALYSIS_TX_DATE_SQL}) AS INTEGER) AS posting_year, COALESCE(account_code, '') AS account_code, COALESCE(account_name, '') AS account_name, COALESCE(accounting_category, '') AS accounting_category, 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(memo2, '') AS memo2, 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 '5%' OR account_code LIKE '6%') """ ), {"start_date": start_date.isoformat(), "end_date": end_date.isoformat()}, ).mappings().all() labor_pools: dict[int, dict[str, float]] = {} for raw_row in labor_rows: tx_row = dict(raw_row) if not _cost_analysis2_is_labor_like(tx_row): continue year = int(tx_row.get("posting_year") or 0) amount = normalize_amount(tx_row.get("amount")) if not year or not amount: continue bucket_key = "labor" if normalize_text(tx_row.get("account_code")).startswith("5") else "sga_labor" pool = labor_pools.setdefault(year, {"labor": 0.0, "sga_labor": 0.0}) pool[bucket_key] += amount yearly_labor_diagnostics: list[dict[str, Any]] = [] for year, code_map in basis["standard"].items(): standard_weights_by_bucket: dict[str, dict[tuple[str, str], float]] = { "labor": {}, "sga_labor": {}, } for code, phase_map in code_map.items(): normalized_code = normalize_text(code).upper() if normalized_code in COST_ANALYSIS_COMMON_CODES: continue item_key = normalize_text(phase_map.get("bucket")) or "sga_labor" if item_key not in standard_weights_by_bucket: item_key = "sga_labor" for phase in ("pre", "during", "post"): standard_amount = normalize_amount(phase_map.get(phase)) if standard_amount > 0: standard_weights_by_bucket[item_key][(normalized_code, phase)] = standard_amount year_pool = labor_pools.get(year) or {"labor": 0.0, "sga_labor": 0.0} year_diagnostic: dict[str, Any] = {"year": year, "unallocated_pool": 0.0} for item_key in ("labor", "sga_labor"): standard_weights = standard_weights_by_bucket[item_key] total_standard_weight = sum(standard_weights.values()) actual_labor = normalize_amount(year_pool.get(item_key)) adjustment_pool = actual_labor - total_standard_weight standard_scale = ( min(1.0, actual_labor / total_standard_weight) if total_standard_weight > 0 else 0.0 ) adjustment_pool = max(0.0, adjustment_pool) adjustment_key = "labor_adjustment" if item_key == "labor" else "sga_labor_adjustment" for (code, phase), standard_amount in standard_weights.items(): row = ensure_row(code) applied_standard_amount = standard_amount * standard_scale row["phases"][phase][item_key] += applied_standard_amount if total_standard_weight > 0 and adjustment_pool > 0: allocated_amount = adjustment_pool * standard_amount / total_standard_weight row["phases"][phase][adjustment_key] += allocated_amount row["allocated"][phase][adjustment_key] += allocated_amount row.setdefault("allocation_details", []).append( { "source_row_key": f"ERP:{year}:{item_key}", "source_project_code": "ZZZZZZ", "source_project_name": ( "ERP 공통 원가 인건비성 비용" if item_key == "labor" else "ERP 공통 판관비 인건비성 비용" ), "source_phase": "all", "source_item": adjustment_key, "target_phase": phase, "target_item": adjustment_key, "project_hours": 0.0, "project_phase_hours": 0.0, "eligible_total_hours": 0.0, "project_ratio": standard_amount / total_standard_weight, "phase_ratio": 1.0, "source_amount": adjustment_pool, "allocated_amount": allocated_amount, } ) year_diagnostic[f"{item_key}_standard"] = total_standard_weight year_diagnostic[f"{item_key}_standard_scale"] = standard_scale year_diagnostic[f"{item_key}_applied_standard"] = total_standard_weight * standard_scale year_diagnostic[f"{item_key}_actual"] = actual_labor year_diagnostic[f"{item_key}_adjustment"] = adjustment_pool year_diagnostic["standard_labor"] = ( normalize_amount(year_diagnostic.get("labor_standard")) + normalize_amount(year_diagnostic.get("sga_labor_standard")) ) year_diagnostic["actual_labor"] = ( normalize_amount(year_diagnostic.get("labor_actual")) + normalize_amount(year_diagnostic.get("sga_labor_actual")) ) year_diagnostic["adjustment"] = ( normalize_amount(year_diagnostic.get("labor_adjustment")) + normalize_amount(year_diagnostic.get("sga_labor_adjustment")) ) yearly_labor_diagnostics.append( year_diagnostic ) project_phase_hours: dict[str, dict[str, float]] = {} for code_map in basis["hours"].values(): for code, phase_map in code_map.items(): normalized_code = normalize_text(code).upper() if not normalized_code.startswith(("X", "Y", "Z")) or normalized_code == "ZZZZZZ": continue target = project_phase_hours.setdefault( normalized_code, {"pre": 0.0, "during": 0.0, "post": 0.0}, ) for phase in ("pre", "during", "post"): target[phase] += normalize_amount(phase_map.get(phase)) ensure_row(normalized_code) def common_allocation_item(source_item: str) -> str: normalized_item = normalize_text(source_item) if normalized_item in {"labor", "sga_labor"}: return "sga_labor" if normalized_item in {"labor_adjustment", "sga_labor_adjustment"}: return "sga_labor_adjustment" if normalized_item == "outsource": return "outsource" if normalized_item in {"overhead", "sga", "sales"}: return "sga" return "sga" common_allocation_total = 0.0 common_source_total = 0.0 common_unallocated_total = 0.0 for source_code, source_row in list(rows_by_code.items()): if not source_row.get("is_common_activity"): continue is_visible_exception = _cost_analysis_is_visible_common_exception(source_row.get("project_name")) source_row["is_common_exception"] = is_visible_exception source_row["is_hidden_common_activity"] = not is_visible_exception source_row["exclude_from_totals"] = not is_visible_exception source_row["allocation_details"] = [] if is_visible_exception: continue restrict_to_cm = "감리대기" in normalize_text(source_row.get("project_name")) eligible_hours: dict[str, dict[str, float]] = {} for project_code, phase_hours in project_phase_hours.items(): project_row = rows_by_code.get(project_code) or {} pm_department = normalize_text( project_row.get("pm_department") or (project_meta.get(project_code) or {}).get("pm_department") ) if restrict_to_cm and _cost_analysis_normalize_dept_name(pm_department) != _cost_analysis_normalize_dept_name("건설사업관리부"): continue project_total_hours = sum(normalize_amount(phase_hours.get(phase)) for phase in ("pre", "during", "post")) if project_total_hours > 0: eligible_hours[project_code] = phase_hours eligible_total_hours = sum( sum(normalize_amount(phase_hours.get(phase)) for phase in ("pre", "during", "post")) for phase_hours in eligible_hours.values() ) for source_phase in ("pre", "during", "post"): for source_item, source_amount_value in source_row["phases"][source_phase].items(): source_amount = normalize_amount(source_amount_value) if not source_amount: continue common_source_total += source_amount if eligible_total_hours <= 0: common_unallocated_total += source_amount continue target_item = common_allocation_item(source_item) for project_code, phase_hours in eligible_hours.items(): project_total_hours = sum( normalize_amount(phase_hours.get(phase)) for phase in ("pre", "during", "post") ) if project_total_hours <= 0: continue project_allocated_amount = source_amount * project_total_hours / eligible_total_hours target_row = ensure_row(project_code) for target_phase in ("pre", "during", "post"): phase_hours_value = normalize_amount(phase_hours.get(target_phase)) if phase_hours_value <= 0: continue allocated_amount = project_allocated_amount * phase_hours_value / project_total_hours target_row["phases"][target_phase][target_item] += allocated_amount target_row["allocated"][target_phase][target_item] += allocated_amount common_allocation_total += allocated_amount allocation_detail = { "source_row_key": source_row.get("row_key") or source_code, "source_project_code": ", ".join(source_row.get("direct_codes") or []) or "ZZZZZZ", "source_project_name": source_row.get("project_name") or "공통업무", "source_phase": source_phase, "source_item": source_item, "target_phase": target_phase, "target_item": target_item, "project_hours": project_total_hours, "project_phase_hours": phase_hours_value, "eligible_total_hours": eligible_total_hours, "project_ratio": project_total_hours / eligible_total_hours, "phase_ratio": phase_hours_value / project_total_hours, "source_amount": source_amount, "allocated_amount": allocated_amount, } target_row.setdefault("allocation_details", []).append(allocation_detail) source_row["allocation_details"].append( { **allocation_detail, "target_project_code": project_code, "target_project_name": target_row.get("project_name") or project_code, } ) representative_row = ensure_row("ZZZZZZ") representative_row["support_dept_code"] = "ZZZZZZ" representative_row["project_name"] = "공통" representative_row["pm_department"] = "공통" representative_row["project_type"] = "공통" representative_row["is_common_master"] = True representative_row["exclude_from_totals"] = False representative_row["period_revenue_amount"] = common_period_revenue representative_row["revenue_amount"] = 0.0 representative_row["billing_amount"] = 0.0 representative_row["collection_amount"] = common_collection_amount representative_row["period_billing_amount"] = 0.0 representative_row["period_negative_billing_amount"] = 0.0 representative_row["period_collection_amount"] = common_period_collection_amount representative_row["contract_amount"] = 0.0 representative_row["contract_balance_amount"] = 0.0 representative_row["phases"] = _cost_analysis_empty_phase_totals() representative_row["allocated"] = _cost_analysis_empty_phase_totals() total_standard = sum(normalize_amount(item["standard_labor"]) for item in yearly_labor_diagnostics) actual_labor_total = sum(normalize_amount(item["actual_labor"]) for item in yearly_labor_diagnostics) adjustment_total = actual_labor_total - total_standard actual_ratio = actual_labor_total / total_standard if total_standard > 0 else 0.0 final_rows = [ row for code, row in rows_by_code.items() if normalize_text(code).upper() ] for row in final_rows: _cost_analysis_finalize_row(row) row["cumulative_revenue_amount"] = normalize_amount(row.get("revenue_amount")) row["cumulative_cost_total"] = normalize_amount(row.get("cost_total")) row["cumulative_sga_total"] = normalize_amount(row.get("sga_total")) row["cumulative_sales_total"] = normalize_amount(row.get("sales_total")) row["cumulative_total_cost"] = normalize_amount(row.get("total_cost")) row["cumulative_profit_amount"] = row["cumulative_revenue_amount"] - row["cumulative_total_cost"] row["cumulative_profit_rate"] = _safe_ratio(row["cumulative_profit_amount"], row["cumulative_revenue_amount"]) _cost_analysis_clean_common_master_row(row) if normalized_mode == "individual": final_rows = _cost_analysis_apply_individual_display_codes(final_rows, project_meta) for row in final_rows: _cost_analysis_clean_common_master_row(row) if normalized_mode == "aggregate": final_rows = _cost_analysis_aggregate_rows( final_rows, project_meta, _cost_analysis_get_link_representative_map(), ) for row in final_rows: _cost_analysis_clean_common_master_row(row) final_rows.sort( key=lambda item: ( 0 if item.get("is_common_master") else 1 if item.get("is_hidden_common_activity") else 2, normalize_text(item.get("pm_department")), normalize_text(item.get("project_type")), normalize_text(item.get("project_name")), ) ) accounting_rows = [row for row in final_rows if not row.get("exclude_from_totals")] summary = _cost_analysis_summary_for_rows(accounting_rows) summary["phases"] = { phase: { item: sum(normalize_amount((row.get("phases") or {}).get(phase, {}).get(item)) for row in accounting_rows) for item in ("labor", "labor_adjustment", "outsource", "overhead", "sga_labor", "sga_labor_adjustment", "sga", "sales") } for phase in ("pre", "during", "post") } displayed_labor_total = sum( normalize_amount((row.get("phases") or {}).get(phase, {}).get(item)) for row in accounting_rows for phase in ("pre", "during", "post") for item in ("labor", "labor_adjustment", "sga_labor", "sga_labor_adjustment") ) displayed_nonlabor_total = summary["total_cost"] - displayed_labor_total erp_total_expense = normalize_amount((erp_basis.get("allocation_diagnostics") or {}).get("erp_total_expense")) expense_reconciliation_gap = summary["total_cost"] - erp_total_expense return { **erp_basis, "start_date": start_date.isoformat(), "end_date": end_date.isoformat(), "mode": normalized_mode, "rows": final_rows, "summary": summary, "allocation_diagnostics": { **(erp_basis.get("allocation_diagnostics") or {}), "standard_labor_total": total_standard, "actual_labor_total": actual_labor_total, "labor_adjustment_total": adjustment_total, "labor_actual_ratio": actual_ratio, "yearly_labor_allocation": yearly_labor_diagnostics, "unresolved_hours": basis["unresolved_hours"], "calculation_cutoff": end_date.isoformat(), "displayed_labor_total": displayed_labor_total, "displayed_nonlabor_total": displayed_nonlabor_total, "displayed_total_expense": summary["total_cost"], "erp_total_expense": erp_total_expense, "expense_reconciliation_gap": expense_reconciliation_gap, "expense_reconciled": abs(expense_reconciliation_gap) < 0.5, "common_source_total": common_source_total, "common_allocated_total": common_allocation_total, "common_unallocated_total": common_unallocated_total, }, "cache_info": { "source": "computed", "ready": True, "financial_logic_version": COST_ANALYSIS_FINANCIAL_LOGIC_VERSION, "h_project_mapping_version": COST_ANALYSIS_H_PROJECT_MAPPING_VERSION, "link_logic_version": COST_ANALYSIS_LINK_LOGIC_VERSION, "data_version": context["data_version"], "hanmac_cache_version": context["hanmac_cache_version"], "generated_at": datetime.now().isoformat(timespec="seconds"), }, } def _cost_analysis_build_payload(start_date_text: str, end_date_text: str, mode: str = "individual", force: bool = False) -> dict[str, Any]: return _cost_analysis1_build_payload(start_date_text, end_date_text, mode) def render_cost_analysis_page(request: Request, message: str = "") -> HTMLResponse: init_db() cutoff = date(2026, 3, 31) context = { **base_context(request, message), "default_start_date": date(2023, 1, 1).isoformat(), "default_end_date": cutoff.isoformat(), } response = templates.TemplateResponse(request, "cost_analysis.html", context) response.headers["Cache-Control"] = "no-store, max-age=0" response.headers["Pragma"] = "no-cache" response.headers["Expires"] = "0" return response 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 _financial_gap_statement_amount(row: Mapping[str, Any]) -> float: debit = normalize_amount(row.get("debit")) credit = normalize_amount(row.get("credit")) return max(abs(debit), abs(credit)) def _financial_gap_signed_gap(wehago_amount: Any, erp_amount: Any) -> float: return normalize_amount(wehago_amount) - normalize_amount(erp_amount) def _financial_gap_bucket_for_account(code: str) -> str: if code in {"411", "412", "413", "414", "415", "416", "417"}: return "revenue" if code == "452": return "cogs" if code.startswith("8") or code == "908": return "sga" if code.startswith("6"): return "cost_detail" if code in {"901", "902", "903", "904", "905", "906", "907", "914", "930"}: return "nonop_income" if code in {"931", "932", "933", "934", "935", "936", "937", "960"}: return "nonop_expense" if code == "998": return "tax" return "" def _financial_gap_signed_statement_delta(code: str, debit: Any, credit: Any) -> float: debit_amount = normalize_amount(debit) credit_amount = normalize_amount(credit) bucket = _financial_gap_bucket_for_account(code) if bucket in {"revenue", "nonop_income"}: return credit_amount - debit_amount if bucket in {"cogs", "sga", "cost_detail", "nonop_expense", "tax"}: return debit_amount - credit_amount return 0.0 FINANCIAL_GAP_ERP_COST_LABOR_CODES = {"50120301", "50120501", "50152521"} FINANCIAL_GAP_ERP_COST_BENEFIT_CODES = { "50152501", "50152503", "50152505", "50152507", "50152511", "50152519", } FINANCIAL_GAP_ERP_SGA_LABOR_CODES = {"60110501"} FINANCIAL_GAP_ERP_RND_DISPLAY_CODES = {"60114701", "60114741"} FINANCIAL_GAP_WEHAGO_COST_BENEFIT_CODES = {"611"} FINANCIAL_GAP_WEHAGO_SGA_BENEFIT_CODES = {"811"} FINANCIAL_GAP_WEHAGO_RND_DISPLAY_CODES = {"650"} def _financial_gap_is_closing_transfer(row: Mapping[str, Any]) -> bool: code = normalize_text(row.get("account_code")) text_blob = " ".join( [ normalize_text(row.get("description")), normalize_text(row.get("vendor_name")), normalize_text(row.get("compare_desc")), ] ) if code == "400": return True closing_signals = ( "손익계정에 대체", "수익에서 대체", "비용에서 대체", "당기순손익", "잉여금에 대체", ) return any(signal in text_blob for signal in closing_signals) def _financial_gap_is_audit_adjustment(row: Mapping[str, Any]) -> bool: if _financial_gap_is_closing_transfer(row): return False code = normalize_text(row.get("account_code")) text_blob = " ".join( [ normalize_text(row.get("description")), normalize_text(row.get("vendor_name")), normalize_text(row.get("compare_desc")), ] ) if code == "452": return False if code == "417" and "기초미완성공사" in text_blob: return False adjustment_signals = ( "감사", "결산", "수정분개", "수정신고", "환원분개", "결산분개", "진행율 매출액", "진행률 매출액", "계상분 대체", "기초미완성공사", ) return any(signal in text_blob for signal in adjustment_signals) def _financial_gap_ratio(gap: Any, base_amount: Any) -> float: base = abs(normalize_amount(base_amount)) if base <= 0: return 0.0 return (normalize_amount(gap) / base) * 100.0 def _financial_gap_substantive_group( source: str, code: str, name: str, category: str = "", ) -> tuple[str, str, str, str]: code_text = normalize_text(code) name_text = normalize_text(name) category_text = normalize_text(category) text_blob = f"{code_text} {name_text} {category_text}" insurance_labor_tokens = ("국민연금", "건강보험", "고용보험", "산재보험", "장기요양") payroll_labor_tokens = ("급여", "임금", "상여", "제수당", "퇴직", "퇴직금", "퇴직급여", "잡급", "연차", "연월차", *insurance_labor_tokens) if source == "wehago": revenue_map = { "411": ("수익", "revenue_design", "설계용역수입", "411 설계용역수입"), "412": ("수익", "revenue_supervision", "감리용역수입", "412 감리용역수입"), "415": ("수익", "revenue_safety", "안전점검수입", "415 안전점검수입"), "413": ("수익", "revenue_rent", "임대·관리수입", "413 임대료수입"), "414": ("수익", "revenue_parking", "주차수입", "414 주차료수입"), "417": ("수익", "revenue_research", "연구용역수입", "417 연구용역수입"), } if code_text in revenue_map: return revenue_map[code_text] if code_text == "452": return ("영업비용", "expense_cogs_total", "매출원가 총액", "452 도급공사매출원가") if code_text in FINANCIAL_GAP_WEHAGO_COST_BENEFIT_CODES: return ("영업비용", "expense_cost_benefit", "원가성 복리후생비", f"{code_text} {name_text}") if code_text in FINANCIAL_GAP_WEHAGO_SGA_BENEFIT_CODES: return ("영업비용", "expense_sga_benefit", "판관 복리후생비", f"{code_text} {name_text}") if code_text in FINANCIAL_GAP_WEHAGO_RND_DISPLAY_CODES: return ("영업비용", "expense_rnd_display", "연구개발비 별도 표시", f"{code_text} {name_text}") if code_text in {"604", "606", "609"}: return ("영업비용", "expense_cost_labor", "원가성 인건비", f"{code_text} {name_text}") if code_text == "602": return ("영업비용", "expense_cost_outsourcing", "외주비", f"{code_text} {name_text}") if code_text in {"631", "639"}: return ("영업비용", "expense_cost_fee", "원가 지급수수료·보증수수료", f"{code_text} {name_text}") if code_text in {"645", "644"}: return ("영업비용", "expense_cost_field_ops", "현장운영·행사비", f"{code_text} {name_text}") if code_text in {"612"}: return ("영업비용", "expense_cost_travel", "원가 여비교통비", f"{code_text} {name_text}") if code_text in {"619"}: return ("영업비용", "expense_cost_rent", "원가 지급임차료", f"{code_text} {name_text}") if code_text in {"626", "629", "630"}: return ("영업비용", "expense_cost_supplies_print", "원가 도서·사무·소모품", f"{code_text} {name_text}") if code_text in {"617", "618", "622", "614", "625", "634"}: return ("영업비용", "expense_cost_admin", "원가 기타 운영비", f"{code_text} {name_text}") if code_text in {"802", "808"}: return ("영업비용", "expense_sga_labor", "판관 인건비", f"{code_text} {name_text}") if code_text == "823": return ("영업비용", "expense_sga_rnd", "판관 연구개발비", f"{code_text} {name_text}") if code_text in {"831", "837"}: return ("영업비용", "expense_sga_fee", "판관 지급수수료·건물관리비", f"{code_text} {name_text}") if code_text.startswith("8") or code_text == "908": return ("영업비용", "expense_sga_other", "판관 기타비용", f"{code_text} {name_text}") if code_text.startswith("6"): return ("영업비용", "expense_cost_other", "원가 기타비용", f"{code_text} {name_text}") return ("", "", "", "") if category_text == "수입/매출액" or code_text.startswith("4"): if "설계" in text_blob: return ("수익", "revenue_design", "설계용역수입", f"{code_text} {name_text}") if "감리" in text_blob: return ("수익", "revenue_supervision", "감리용역수입", f"{code_text} {name_text}") if "안전" in text_blob: return ("수익", "revenue_safety", "안전점검수입", f"{code_text} {name_text}") if "임대" in text_blob or "관리비" in text_blob: return ("수익", "revenue_rent", "임대·관리수입", f"{code_text} {name_text}") if "주차" in text_blob: return ("수익", "revenue_parking", "주차수입", f"{code_text} {name_text}") if "연구" in text_blob: return ("수익", "revenue_research", "연구용역수입", f"{code_text} {name_text}") return ("수익", "revenue_other", "기타수입", f"{code_text} {name_text}") if category_text == "원가" or code_text.startswith("5"): if code_text in FINANCIAL_GAP_ERP_COST_LABOR_CODES: return ("영업비용", "expense_cost_labor", "원가성 인건비", f"{code_text} {name_text}") if code_text in FINANCIAL_GAP_ERP_COST_BENEFIT_CODES: return ("영업비용", "expense_cost_benefit", "원가성 복리후생비", f"{code_text} {name_text}") if any(token in text_blob for token in payroll_labor_tokens): return ("영업비용", "expense_cost_labor", "원가성 인건비", f"{code_text} {name_text}") if "복리후생" in text_blob: return ("영업비용", "expense_cost_benefit", "원가성 복리후생비", f"{code_text} {name_text}") if any(token in text_blob for token in ("기술협력", "외주")): return ("영업비용", "expense_cost_outsourcing", "외주비", f"{code_text} {name_text}") if any(token in text_blob for token in ("지급수수료", "보증수수료")): return ("영업비용", "expense_cost_fee", "원가 지급수수료·보증수수료", f"{code_text} {name_text}") if any(token in text_blob for token in ("감리현장운영", "합사경비", "부서비")): return ("영업비용", "expense_cost_field_ops", "현장운영·행사비", f"{code_text} {name_text}") if "여비교통" in text_blob: return ("영업비용", "expense_cost_travel", "원가 여비교통비", f"{code_text} {name_text}") if "지급임차료" in text_blob: return ("영업비용", "expense_cost_rent", "원가 지급임차료", f"{code_text} {name_text}") if any(token in text_blob for token in ("도서인쇄", "사무용품", "소모품")): return ("영업비용", "expense_cost_supplies_print", "원가 도서·사무·소모품", f"{code_text} {name_text}") if "연구" in text_blob: return ("영업비용", "expense_cost_rnd", "원가 연구개발비", f"{code_text} {name_text}") return ("영업비용", "expense_cost_admin", "원가 기타 운영비", f"{code_text} {name_text}") if category_text == "판관비" or code_text.startswith("6"): if code_text in FINANCIAL_GAP_ERP_SGA_LABOR_CODES: return ("영업비용", "expense_sga_labor", "판관 인건비", f"{code_text} {name_text}") if code_text in FINANCIAL_GAP_ERP_RND_DISPLAY_CODES: return ("영업비용", "expense_rnd_display", "연구개발비 별도 표시", f"{code_text} {name_text}") if any(token in text_blob for token in payroll_labor_tokens): return ("영업비용", "expense_sga_labor", "판관 인건비", f"{code_text} {name_text}") if "복리후생" in text_blob: return ("영업비용", "expense_sga_benefit", "판관 복리후생비", f"{code_text} {name_text}") if "경상시험연구" in text_blob or "연구" in text_blob: return ("영업비용", "expense_sga_rnd", "판관 연구개발비", f"{code_text} {name_text}") if any(token in text_blob for token in ("지급수수료", "건물관리")): return ("영업비용", "expense_sga_fee", "판관 지급수수료·건물관리비", f"{code_text} {name_text}") return ("영업비용", "expense_sga_other", "판관 기타비용", f"{code_text} {name_text}") return ("", "", "", "") def _financial_gap_item_interpretation( item_key: str, wehago_amount: Any, erp_amount: Any, adjustment_amount: Any, ) -> str: gap = _financial_gap_signed_gap(wehago_amount, erp_amount) residual = gap - normalize_amount(adjustment_amount) if item_key == "expense_cogs_total": return "WEHAGO의 452는 손익계산서 매출원가 총액입니다. 6xx 원가 상세와 합산하지 말고 ERP 원가 합계와 방향을 확인합니다." if item_key.startswith("revenue_"): if abs(normalize_amount(adjustment_amount)) >= abs(gap) * 0.6: return "진행률·결산 조정으로 상당 부분 설명되나, 남은 차이는 매출 계정 매핑 또는 ERP 원매출 누락 범위를 확인해야 합니다." return "조정 전표만으로 설명되지 않는 매출 차이입니다. 같은 용역 성격의 ERP 매출 계정과 WEHAGO 수익계정 매핑을 우선 확인합니다." if item_key.startswith("expense_cost_"): return "WEHAGO 원가 상세와 ERP 원가 계정의 성격별 차이입니다. 452 총액과 중복 합산하지 않고 비용 성격·부서·프로젝트 귀속 차이를 봅니다." if item_key.startswith("expense_sga_"): return "판관비 성격 비용의 차이입니다. ERP 판관비가 WEHAGO에서 원가성 6xx로 이동했는지, 또는 반대로 남았는지 확인합니다." if item_key == "expense_rnd_display": return "연구개발비를 별도 표시한 항목입니다. 원가/판관 재배분 없이 WEHAGO와 ERP의 표시 계정만 나누어 확인합니다." if abs(residual) > 0: return "동일 성격 항목으로 맞춘 뒤에도 잔차가 남아 원장 행 단위 확인이 필요합니다." return "동일 성격 항목 기준으로 큰 잔차는 제한적입니다." def _financial_gap_raw_account_note(item_key: str, default_note: str, erp_amount: Any = 0) -> str: if item_key == "expense_rnd_display": amount = normalize_amount(erp_amount) return f"연구개발비 별도 표시 항목입니다. 연구개발비 인건비성 ERP 금액: {amount:,.0f}원" if item_key == "expense_cost_benefit": return "건강보험료는 인건비성 비용으로 유지하고, 그 외 원가 복리후생비만 별도 표시합니다." if item_key == "expense_sga_benefit": return "판관 복리후생비를 인건비와 분리해 별도 표시합니다." return default_note def _financial_gap_classification_review_rows() -> list[dict[str, Any]]: checks = [ { "key": "baron_support", "label": "바론 경영/기술지원 수수료", "erp_where": """ accounting_category = '판관비' AND ( partner_name LIKE '%바론%' OR memo1 LIKE '%경영,기술지원%' OR memo1 LIKE '%인건비 정산%' OR memo1 LIKE '%정산분(인건비)%' ) """, "wehago_where": """ ( vendor_name LIKE '%바론%' OR description LIKE '%경영,기술지원%' OR description LIKE '%인건비 정산%' OR description LIKE '%정산분(인건비)%' ) """, "finding": "ERP 판관비 지급수수료로 잡힌 금액이 WEHAGO에서는 주로 602 외주비, 즉 원가성 비용으로 보입니다.", }, { "key": "social_insurance", "label": "4대보험 회사부담금", "erp_where": """ accounting_category = '판관비' AND ( account_name LIKE '%건강보험%' OR account_name LIKE '%고용보험%' OR account_name LIKE '%산재보험%' OR memo1 LIKE '%건강보험%' OR memo1 LIKE '%고용보험%' OR memo1 LIKE '%산재보험%' ) """, "wehago_where": """ ( description LIKE '%건강보험%' OR description LIKE '%고용보험%' OR description LIKE '%산재보험%' ) """, "finding": "ERP 판관비의 4대보험 회사부담금 일부가 WEHAGO에서는 611 복리후생비 또는 617 세금과공과금 등 원가성 계정으로 보입니다.", }, { "key": "public_property_rent", "label": "공유재산사용료/임차료", "erp_where": """ accounting_category = '판관비' AND ( memo1 LIKE '%공유재산%' OR memo1 LIKE '%시특별%' OR partner_name LIKE '%서울특별시%' ) """, "wehago_where": """ ( description LIKE '%공유재산%' OR description LIKE '%시특별%' OR vendor_name LIKE '%서울특별시%' ) """, "finding": "ERP에서는 판관비 월세로 보이나, WEHAGO에서는 619 지급임차료 원가성 계정으로 보이는 금액이 있습니다.", }, { "key": "building_management", "label": "건물관리용역비", "erp_where": """ accounting_category = '판관비' AND ( memo1 LIKE '%건물관리%' OR partner_name LIKE '%두레티엠에스%' OR partner_name LIKE '%만나%' ) """, "wehago_where": """ ( description LIKE '%건물관리%' OR vendor_name LIKE '%두레티엠에스%' OR vendor_name LIKE '%만나%' ) """, "finding": "건물관리용역비는 ERP에서는 지급수수료성 판관비, WEHAGO에서는 주로 837 건물관리비 판관비로 보여 구분은 유사하나 계정명이 다릅니다.", }, { "key": "rnd_salary", "label": "연구원 급여/경상연구개발비", "erp_where": """ accounting_category = '판관비' AND ( account_name LIKE '%경상시험연구%' OR memo1 LIKE '%연구원%' ) """, "wehago_where": """ ( account_code IN ('823','650') OR description LIKE '%연구원%' OR description LIKE '%스마트과제%' OR description LIKE '%XR과제%' OR description LIKE '%자율주행%' ) """, "finding": "연구개발성 비용은 ERP와 WEHAGO 모두 별도 연구개발성 항목으로 보이나, 650 원가성 연구개발비와 823 판관 연구개발비 포함 범위를 구분해야 합니다.", }, ] result: list[dict[str, Any]] = [] with engine.begin() as conn: for year in (2022, 2023, 2024, 2025): for check in checks: erp_row = conn.execute( text( f""" SELECT SUM(amount) AS amount, COUNT(*) AS row_count, GROUP_CONCAT(DISTINCT account_name) AS account_names FROM transactions WHERE year = :year AND ({check['erp_where']}) """ ), {"year": year}, ).mappings().first() wehago_row = conn.execute( text( f""" SELECT SUM(CASE WHEN account_code LIKE '6%' THEN ABS(COALESCE(debit, 0)) WHEN account_code LIKE '8%' THEN ABS(COALESCE(debit, 0)) ELSE ABS(COALESCE(debit, 0)) + ABS(COALESCE(credit, 0)) END) AS amount, SUM(CASE WHEN account_code LIKE '6%' THEN ABS(COALESCE(debit, 0)) ELSE 0 END) AS cost_amount, SUM(CASE WHEN account_code LIKE '8%' THEN ABS(COALESCE(debit, 0)) ELSE 0 END) AS sga_amount, COUNT(*) AS row_count, GROUP_CONCAT(DISTINCT account_code || ' ' || account_name) AS account_names FROM wehago_ledger_rows WHERE fiscal_year = :year AND ({check['wehago_where']}) AND (account_code LIKE '6%' OR account_code LIKE '8%') """ ), {"year": year}, ).mappings().first() result.append( { "year": year, "key": check["key"], "label": check["label"], "finding": check["finding"], "erp_sga_amount": normalize_amount((erp_row or {}).get("amount")), "erp_row_count": int(normalize_amount((erp_row or {}).get("row_count"))), "erp_account_names": normalize_text((erp_row or {}).get("account_names")), "wehago_amount": normalize_amount((wehago_row or {}).get("amount")), "wehago_cost_amount": normalize_amount((wehago_row or {}).get("cost_amount")), "wehago_sga_amount": normalize_amount((wehago_row or {}).get("sga_amount")), "wehago_row_count": int(normalize_amount((wehago_row or {}).get("row_count"))), "wehago_account_names": normalize_text((wehago_row or {}).get("account_names")), } ) return result def _financial_gap_get_hanmac_hours_summary(year: int) -> dict[str, Any]: 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, row_count, summary_json, updated_at FROM hanmac_aggregate_query_metrics WHERE view_mode = 'member' AND payload_signature LIKE :compatible_signature_pattern AND COALESCE(start_date, '') <= :year_start AND COALESCE(end_date, '') >= :year_end ORDER BY CASE WHEN payload_signature LIKE :current_signature_prefix THEN 0 ELSE 1 END, 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, **_cost_analysis_hanmac_signature_params(), }, ).mappings().first() if not metric: return { "total_hours": 0.0, "regular_hours": 0.0, "overtime_hours": 0.0, "holiday_hours": 0.0, "member_count": 0, "project_count": 0, "cache_updated_at": "", } try: summary = json.loads(metric.get("summary_json") or "{}") except Exception: summary = {} return { "total_hours": normalize_amount(summary.get("total_hours")), "regular_hours": normalize_amount(summary.get("regular_hours")), "overtime_hours": normalize_amount(summary.get("overtime_hours")), "holiday_hours": normalize_amount(summary.get("holiday_hours")), "member_count": int(normalize_amount(summary.get("member_count")) or normalize_amount(metric.get("row_count"))), "project_count": int(normalize_amount(summary.get("project_count"))), "cache_updated_at": normalize_text(metric.get("updated_at")), } def get_financial_gap_analysis_payload() -> dict[str, Any]: # WEHAGO 원장과 프로젝트 손익분석1을 실제로 대사할 수 있는 공통 연도. # 2026년 WEHAGO 손익 원장이 아직 없어 0원과 비교하는 오해를 막기 위해 제외한다. target_years = [2023, 2024, 2025] project_payloads_by_year: dict[int, dict[str, Any]] = {} for year in target_years: year_end = date(2026, 3, 31) if year == 2026 else date(year, 12, 31) project_payloads_by_year[year] = _cost_analysis1_build_payload( date(year, 1, 1).isoformat(), year_end.isoformat(), "individual", ) account_rows_by_year: dict[int, list[dict[str, Any]]] = {year: [] for year in target_years} ledger_detail_rows_by_year: dict[int, list[dict[str, Any]]] = {year: [] for year in target_years} erp_by_year: dict[int, dict[str, Any]] = {year: {} for year in target_years} erp_account_rows_by_year: dict[int, list[dict[str, Any]]] = {year: [] for year in target_years} classification_review_rows = _financial_gap_classification_review_rows() with engine.begin() as conn: wehago_rows = conn.execute( text( """ SELECT fiscal_year, COALESCE(account_code, '') AS account_code, COALESCE(account_name, '') AS account_name, SUM(COALESCE(debit, 0)) AS debit, SUM(COALESCE(credit, 0)) AS credit FROM wehago_ledger_rows WHERE fiscal_year BETWEEN 2023 AND 2026 AND (fiscal_year < 2026 OR COALESCE(ledger_date, '') <= '2026-03-31') GROUP BY fiscal_year, account_code, account_name ORDER BY fiscal_year, account_code """ ) ).mappings().all() ledger_detail_rows = conn.execute( text( """ SELECT fiscal_year, COALESCE(ledger_date, '') AS ledger_date, COALESCE(account_code, '') AS account_code, COALESCE(account_name, '') AS account_name, COALESCE(description, '') AS description, COALESCE(vendor_name, '') AS vendor_name, COALESCE(compare_desc, '') AS compare_desc, COALESCE(voucher_no, '') AS voucher_no, COALESCE(debit, 0) AS debit, COALESCE(credit, 0) AS credit FROM wehago_ledger_rows WHERE fiscal_year BETWEEN 2023 AND 2026 AND (fiscal_year < 2026 OR COALESCE(ledger_date, '') <= '2026-03-31') AND ( account_code IN ('411','412','413','414','415','416','417','452','908','901','902','903','904','905','906','907','914','930','931','932','933','934','935','936','937','960','998') OR account_code LIKE '6%' OR account_code LIKE '8%' ) ORDER BY fiscal_year, ledger_date, voucher_no, account_code """ ) ).mappings().all() erp_rows = conn.execute( text( f""" SELECT year, SUM(CASE WHEN accounting_category = '수입/매출액' OR account_code LIKE '4%' THEN amount ELSE 0 END) AS revenue, SUM(CASE WHEN accounting_category = '원가' THEN amount ELSE 0 END) AS cost, SUM(CASE WHEN accounting_category = '판관비' THEN amount ELSE 0 END) AS sga, SUM(CASE WHEN account_code LIKE '5012%' THEN amount ELSE 0 END) AS labor_direct, SUM(CASE WHEN account_code LIKE '5017%' THEN amount ELSE 0 END) AS outsourcing, SUM(CASE WHEN account_code LIKE '7%' THEN amount ELSE 0 END) AS nonop_income, SUM(CASE WHEN account_code LIKE '9%' THEN amount ELSE 0 END) AS nonop_expense, SUM(CASE WHEN accounting_category = '기타' THEN amount ELSE 0 END) AS other_amount FROM transactions WHERE {COST_ANALYSIS_TX_DATE_SQL} >= '2023-01-01' AND {COST_ANALYSIS_TX_DATE_SQL} <= '2026-03-31' GROUP BY year ORDER BY year """ ) ).mappings().all() erp_account_rows = conn.execute( text( f""" SELECT year, COALESCE(account_code, '') AS account_code, COALESCE(account_name, '') AS account_name, COALESCE(accounting_category, '') AS accounting_category, SUM(COALESCE(amount, 0)) AS amount, COUNT(*) AS row_count FROM transactions WHERE {COST_ANALYSIS_TX_DATE_SQL} >= '2023-01-01' AND {COST_ANALYSIS_TX_DATE_SQL} <= '2026-03-31' AND ( accounting_category IN ('수입/매출액','원가','판관비') OR account_code LIKE '4%' OR account_code LIKE '5%' OR account_code LIKE '6%' ) GROUP BY year, account_code, account_name, accounting_category ORDER BY year, account_code """ ) ).mappings().all() for row in wehago_rows: year = int(row.get("fiscal_year") or 0) if year in account_rows_by_year: account_rows_by_year[year].append(dict(row)) for row in ledger_detail_rows: year = int(row.get("fiscal_year") or 0) if year in ledger_detail_rows_by_year: ledger_detail_rows_by_year[year].append(dict(row)) for row in erp_rows: year = int(row.get("year") or 0) if year in erp_by_year: erp_by_year[year] = dict(row) for row in erp_account_rows: year = int(row.get("year") or 0) if year in erp_account_rows_by_year: erp_account_rows_by_year[year].append(dict(row)) yearly_rows: list[dict[str, Any]] = [] account_detail_rows: list[dict[str, Any]] = [] adjustment_rows: list[dict[str, Any]] = [] labor_rows: list[dict[str, Any]] = [] review_items: list[dict[str, Any]] = [] anomaly_cards: list[dict[str, Any]] = [] reason_items: list[dict[str, Any]] = [] substantive_gap_rows: list[dict[str, Any]] = [] raw_account_comparison_rows: list[dict[str, Any]] = [] for year in target_years: buckets = { "revenue": 0.0, "cogs": 0.0, "sga": 0.0, "nonop_income": 0.0, "nonop_expense": 0.0, "tax": 0.0, "cost_detail_6xx": 0.0, "direct_labor": 0.0, "sga_labor": 0.0, "rnd_like": 0.0, "outsourcing_6xx": 0.0, } adjustment_buckets = { "revenue": 0.0, "cogs": 0.0, "sga": 0.0, "cost_detail": 0.0, "nonop_income": 0.0, "nonop_expense": 0.0, "tax": 0.0, } substantive_wehago: dict[str, dict[str, Any]] = {} substantive_erp: dict[str, dict[str, Any]] = {} substantive_adjustments: dict[str, float] = {} closing_transfer_count = 0 audit_adjustment_count = 0 for ledger_row in ledger_detail_rows_by_year.get(year, []): code = normalize_text(ledger_row.get("account_code")) bucket_key = _financial_gap_bucket_for_account(code) if not bucket_key: continue if _financial_gap_is_closing_transfer(ledger_row): closing_transfer_count += 1 continue if not _financial_gap_is_audit_adjustment(ledger_row): continue delta = _financial_gap_signed_statement_delta( code, ledger_row.get("debit"), ledger_row.get("credit"), ) if abs(delta) <= 0: continue audit_adjustment_count += 1 target_bucket_key = "cogs" if bucket_key == "cost_detail" else bucket_key adjustment_buckets[target_bucket_key] += delta section, item_key, item_label, _ = _financial_gap_substantive_group( "wehago", code, ledger_row.get("account_name"), ) if item_key and item_key != "expense_cogs_total": substantive_adjustments[item_key] = substantive_adjustments.get(item_key, 0.0) + delta adjustment_rows.append( { "year": year, "ledger_date": normalize_text(ledger_row.get("ledger_date")), "voucher_no": normalize_text(ledger_row.get("voucher_no")), "bucket": target_bucket_key, "account_code": code, "account_name": normalize_text(ledger_row.get("account_name")), "description": normalize_text(ledger_row.get("description")), "vendor_name": normalize_text(ledger_row.get("vendor_name")), "debit": normalize_amount(ledger_row.get("debit")), "credit": normalize_amount(ledger_row.get("credit")), "delta": delta, } ) for account in account_rows_by_year.get(year, []): code = normalize_text(account.get("account_code")) name = normalize_text(account.get("account_name")) amount = _financial_gap_statement_amount(account) if code in {"411", "412", "413", "414", "415", "416", "417"}: amount = abs(normalize_amount(account.get("credit"))) if not amount: continue if code in {"411", "412", "413", "414", "415", "416", "417"}: buckets["revenue"] += amount elif code == "452": buckets["cogs"] += amount elif code.startswith("8"): buckets["sga"] += amount elif code == "908": buckets["sga"] -= amount elif code == "998": buckets["tax"] += amount elif code.startswith("9"): if code in {"901", "902", "903", "904", "905", "906", "907", "914", "930"}: buckets["nonop_income"] += amount elif code in {"931", "932", "933", "934", "935", "936", "937", "960"}: buckets["nonop_expense"] += amount if code.startswith("6"): buckets["cost_detail_6xx"] += amount if code in {"604", "606", "609"}: buckets["direct_labor"] += amount if code in {"802", "808"}: buckets["sga_labor"] += amount if code in {"650", "823"}: buckets["rnd_like"] += amount if code == "602": buckets["outsourcing_6xx"] += amount if code in {"411", "412", "413", "414", "415", "416", "417", "452"} or code.startswith(("6", "8")) or code in {"901", "902", "903", "904", "905", "906", "907", "908", "914", "930", "931", "932", "933", "934", "935", "936", "937", "960", "998"}: account_detail_rows.append( { "year": year, "account_code": code, "account_name": name, "wehago_amount": -amount if code == "908" else amount, "bucket": ( "매출" if code in {"411", "412", "413", "414", "415", "416", "417"} else "매출원가 총액" if code == "452" else "원가 상세" if code.startswith("6") else "판관비" if code.startswith("8") else "판관비 조정" if code == "908" else "법인세" if code == "998" else "영업외" ), } ) section, item_key, item_label, source_label = _financial_gap_substantive_group( "wehago", code, name, ) if item_key: group = substantive_wehago.setdefault( item_key, { "section": section, "item_label": item_label, "amount": 0.0, "accounts": [], "row_count": 0, }, ) group["amount"] += -amount if code == "908" else amount group["row_count"] += 1 if source_label and source_label not in group["accounts"]: group["accounts"].append(source_label) for erp_account in erp_account_rows_by_year.get(year, []): section, item_key, item_label, source_label = _financial_gap_substantive_group( "erp", erp_account.get("account_code"), erp_account.get("account_name"), erp_account.get("accounting_category"), ) if not item_key: continue group = substantive_erp.setdefault( item_key, { "section": section, "item_label": item_label, "amount": 0.0, "accounts": [], "row_count": 0, }, ) group["amount"] += normalize_amount(erp_account.get("amount")) group["row_count"] += int(normalize_amount(erp_account.get("row_count"))) if source_label and source_label not in group["accounts"]: group["accounts"].append(source_label) erp = erp_by_year.get(year, {}) project_payload = project_payloads_by_year.get(year) or {} project_summary = project_payload.get("summary") or {} project_diagnostics = project_payload.get("allocation_diagnostics") or {} # Hanmac ERP 비교값은 프로젝트 손익분석 페이지1과 동일한 최종 # 분류·배부 결과를 사용한다. 원천 전표 직접 합계는 상세 추적용으로만 남긴다. erp_revenue = normalize_amount(project_summary.get("period_revenue_amount")) erp_cost = normalize_amount(project_summary.get("period_cost_total")) erp_sga = ( normalize_amount(project_summary.get("period_sga_total")) + normalize_amount(project_summary.get("period_sales_total")) ) adjusted_erp_revenue = erp_revenue + adjustment_buckets["revenue"] adjusted_erp_cost = erp_cost + adjustment_buckets["cogs"] adjusted_erp_sga = erp_sga + adjustment_buckets["sga"] erp_operating_expense = erp_cost + erp_sga adjusted_erp_operating_expense = adjusted_erp_cost + adjusted_erp_sga wehago_operating_expense = buckets["cogs"] + buckets["sga"] cost_gap = _financial_gap_signed_gap(buckets["cogs"], erp_cost) sga_gap = _financial_gap_signed_gap(buckets["sga"], erp_sga) total_expense_gap = _financial_gap_signed_gap(wehago_operating_expense, erp_operating_expense) revenue_gap = _financial_gap_signed_gap(buckets["revenue"], erp_revenue) adjusted_revenue_gap = _financial_gap_signed_gap(buckets["revenue"], adjusted_erp_revenue) adjusted_cost_gap = _financial_gap_signed_gap(buckets["cogs"], adjusted_erp_cost) adjusted_sga_gap = _financial_gap_signed_gap(buckets["sga"], adjusted_erp_sga) adjusted_total_expense_gap = _financial_gap_signed_gap(wehago_operating_expense, adjusted_erp_operating_expense) expense_summary_comparisons = ( ( "expense_total", "영업비용 총액", wehago_operating_expense, erp_operating_expense, adjustment_buckets["cogs"] + adjustment_buckets["sga"], "WEHAGO 매출원가 총액+판관비와 Hanmac ERP 원가+판관비의 회사 전체 비교", ), ( "expense_cogs_total", "매출원가 총액", buckets["cogs"], erp_cost, adjustment_buckets["cogs"], "WEHAGO 452 총액과 Hanmac ERP 5xx 원가 합계 비교", ), ( "expense_sga_total", "판매비와관리비 총액", buckets["sga"], erp_sga, adjustment_buckets["sga"], "WEHAGO 8xx(908 환입 차감)와 Hanmac ERP 6xx 판관비 합계 비교", ), ) for item_key, item_label, wehago_amount, erp_amount, adjustment_amount, note in expense_summary_comparisons: pre_adjustment_wehago_amount = wehago_amount - adjustment_amount raw_account_comparison_rows.append( { "year": year, "section": "영업비용", "comparison_level": "총액", "item_key": item_key, "item_label": item_label, "wehago_current_amount": wehago_amount, "audit_adjustment_amount": adjustment_amount, "wehago_pre_adjustment_amount": pre_adjustment_wehago_amount, "erp_amount": erp_amount, "pre_adjustment_gap": pre_adjustment_wehago_amount - erp_amount, "wehago_accounts": ( "452 + 8xx - 908" if item_key == "expense_total" else "452 도급공사매출원가" if item_key == "expense_cogs_total" else "8xx 판관비 - 908 대손충당금환입" ), "erp_accounts": ( "5xx 원가 + 6xx 판관비" if item_key == "expense_total" else "5xx 원가" if item_key == "expense_cogs_total" else "6xx 판관비" ), "note": note, } ) substantive_erp.setdefault( "expense_cogs_total", { "section": "영업비용", "item_label": "매출원가 총액", "amount": 0.0, "accounts": [], "row_count": 0, }, ) substantive_erp["expense_cogs_total"]["amount"] = erp_cost substantive_erp["expense_cogs_total"]["accounts"] = ["ERP 원가 계정 합계"] substantive_erp["expense_cogs_total"]["row_count"] = sum( int(normalize_amount(row.get("row_count"))) for row in erp_account_rows_by_year.get(year, []) if normalize_text(row.get("accounting_category")) == "원가" ) substantive_adjustments["expense_cogs_total"] = adjustment_buckets["cogs"] for item_key in sorted(set(substantive_wehago) | set(substantive_erp)): wehago_item = substantive_wehago.get(item_key, {}) erp_item = substantive_erp.get(item_key, {}) wehago_amount = normalize_amount(wehago_item.get("amount")) erp_amount = normalize_amount(erp_item.get("amount")) adjustment_amount = normalize_amount(substantive_adjustments.get(item_key)) raw_gap = _financial_gap_signed_gap(wehago_amount, erp_amount) residual_gap = raw_gap - adjustment_amount if item_key != "expense_cogs_total": pre_adjustment_wehago_amount = wehago_amount - adjustment_amount raw_account_comparison_rows.append( { "year": year, "section": normalize_text(wehago_item.get("section") or erp_item.get("section")), "comparison_level": "유사 계정군", "item_key": item_key, "item_label": normalize_text(wehago_item.get("item_label") or erp_item.get("item_label")), "wehago_current_amount": wehago_amount, "audit_adjustment_amount": adjustment_amount, "wehago_pre_adjustment_amount": pre_adjustment_wehago_amount, "erp_amount": erp_amount, "pre_adjustment_gap": pre_adjustment_wehago_amount - erp_amount, "wehago_accounts": ", ".join((wehago_item.get("accounts") or [])[:12]), "erp_accounts": ", ".join((erp_item.get("accounts") or [])[:12]), "note": _financial_gap_raw_account_note( item_key, "감사·결산·대체 문구로 식별한 조정효과를 WEHAGO 현재액에서 제거한 뒤 ERP와 비교", erp_amount, ), } ) if ( max(abs(wehago_amount), abs(erp_amount), abs(raw_gap), abs(residual_gap)) < 50_000_000 and item_key != "expense_cogs_total" ): continue section = normalize_text(wehago_item.get("section") or erp_item.get("section")) item_label = normalize_text(wehago_item.get("item_label") or erp_item.get("item_label")) substantive_gap_rows.append( { "year": year, "section": section, "item_key": item_key, "item_label": item_label, "wehago_amount": wehago_amount, "erp_amount": erp_amount, "gap_amount": raw_gap, "adjustment_amount": adjustment_amount, "residual_gap": residual_gap, "gap_rate": _financial_gap_ratio(raw_gap, max(abs(wehago_amount), abs(erp_amount), 1.0)), "wehago_accounts": ", ".join((wehago_item.get("accounts") or [])[:8]), "erp_accounts": ", ".join((erp_item.get("accounts") or [])[:8]), "wehago_row_count": int(normalize_amount(wehago_item.get("row_count"))), "erp_row_count": int(normalize_amount(erp_item.get("row_count"))), "interpretation": _financial_gap_item_interpretation( item_key, wehago_amount, erp_amount, adjustment_amount, ), } ) gross_profit = buckets["revenue"] - buckets["cogs"] operating_profit = gross_profit - buckets["sga"] erp_operating_profit = erp_revenue - erp_operating_expense adjusted_erp_operating_profit = adjusted_erp_revenue - adjusted_erp_operating_expense adjusted_operating_profit_gap = _financial_gap_signed_gap( operating_profit, adjusted_erp_operating_profit, ) net_profit_proxy = ( operating_profit + buckets["nonop_income"] - buckets["nonop_expense"] - buckets["tax"] ) review_tags: list[str] = [] if abs(cost_gap) > 0 and abs(sga_gap) > 0 and cost_gap * sga_gap < 0: paired = min(abs(cost_gap), abs(sga_gap)) / max(abs(cost_gap), abs(sga_gap)) if paired >= 0.75: review_tags.append("원가/판관비 재분류") if buckets["cogs"] > 0 and buckets["cost_detail_6xx"] > 0: review_tags.append("452 총액/6xx 상세 구분") if abs(revenue_gap) / max(abs(buckets["revenue"]), 1.0) >= 0.03: review_tags.append("매출 인식/계정 매핑") if abs(adjustment_buckets["cogs"]) > 0 and abs(adjustment_buckets["sga"]) > 0: review_tags.append("6xx/8xx 조정흐름") if abs(total_expense_gap) / max(abs(wehago_operating_expense), 1.0) < 0.02 and ("원가/판관비 재분류" not in review_tags): review_tags.append("총비용 유사") if abs(adjusted_total_expense_gap) < abs(total_expense_gap): review_tags.append("감사조정 반영시 개선") elif abs(sum(adjustment_buckets.values())) > 0: review_tags.append("조정후 잔차 검토") def add_reason( tag: str, summary: str, basis_rows: list[dict[str, Any]], next_checks: list[str], outline_rows: list[dict[str, str]] | None = None, ) -> None: reason_items.append( { "year": year, "tag": tag, "summary": summary, "basis_rows": basis_rows, "next_checks": next_checks, "outline_rows": outline_rows or [], } ) if "원가/판관비 재분류" in review_tags: add_reason( "원가/판관비 재분류", "원가 차이와 판관비 차이가 서로 반대 방향으로 발생하고, 두 금액의 크기가 비슷해 총비용 자체가 틀렸다기보다 비용이 원가와 판관비 사이에서 다르게 분류되었을 가능성을 먼저 의심했습니다.", [ {"label": "조정 전 원가 차이", "value": cost_gap}, {"label": "조정 전 판관비 차이", "value": sga_gap}, {"label": "조정 후 원가 차이", "value": adjusted_cost_gap}, {"label": "조정 후 판관비 차이", "value": adjusted_sga_gap}, {"label": "조정 후 원가+판관비 차이", "value": adjusted_total_expense_gap}, ], [ "ERP transactions의 원가/판관비 분류 규칙이 WEHAGO 손익계산서 분류와 같은지 확인", "지원부서/현업부서 판관비가 원가 또는 판관비 중 어디로 들어가는지 확인", "원가성 인건비, 외주비, 관리현장운영비가 ERP와 WEHAGO에서 같은 항목으로 분류되는지 확인", ], [ {"title": "무엇을 뜻하나", "body": "WEHAGO에서는 매출원가로 본 비용을 ERP에서는 판관비로 보거나, 반대로 ERP에서는 원가로 본 비용을 WEHAGO에서는 판관비로 본 경우를 말합니다."}, {"title": "왜 의심하나", "body": "원가 차이는 플러스인데 판관비 차이는 마이너스처럼 서로 반대 방향이고, 원가+판관비 합계 차이는 상대적으로 작으면 총비용 누락보다 분류 위치 차이가 더 그럴듯합니다."}, {"title": "무엇이 아닌가", "body": "이 판단은 비용을 억지로 맞추자는 뜻도 아니고, 452와 6xx가 하나의 조정 전표라는 뜻도 아닙니다. 비용이 어느 칸에 들어갔는지를 확인하자는 신호입니다."}, {"title": "예시", "body": "예를 들어 ERP에서 본사/지원부서 비용을 판관비로 집계했는데 감사 후 WEHAGO에서는 프로젝트 수행과 관련된 원가성 비용으로 재분류했다면 원가는 늘고 판관비는 줄어듭니다."}, {"title": "볼 지점", "body": "6xx 원가 계정, 8xx 판관비 계정, 부서 기준, 프로젝트 코드 유무, 인건비/외주비/관리현장운영비의 분류 기준을 함께 확인해야 합니다."}, ], ) if "452 총액/6xx 상세 구분" in review_tags: add_reason( "452 총액/6xx 상세 구분", "452는 재무제표에 표시되는 매출원가 총액이고, 6xx는 그 안을 구성하는 상세 원가 성격으로 봅니다. 이 둘은 하나의 조정 사건이라기보다 서로 다른 층위의 정보입니다.", [ {"label": "452 도급공사매출원가", "value": buckets["cogs"]}, {"label": "6xx 원가상세 합계", "value": buckets["cost_detail_6xx"]}, {"label": "452와 6xx의 차이", "value": buckets["cogs"] - buckets["cost_detail_6xx"]}, {"label": "식별된 마감/대체 전표 수", "value": closing_transfer_count}, ], [ "손익계산서 총액을 볼 때는 452를 사용하고, 원가 구성 내역을 볼 때는 6xx를 사용합니다.", "452와 6xx를 동시에 더해 원가 총액을 만들고 있지 않은지 확인합니다.", "6xx와 8xx 사이의 조정 여부는 이 태그가 아니라 '6xx/8xx 조정흐름'에서 확인합니다.", ], [ {"title": "무엇을 뜻하나", "body": "452와 6xx를 같은 표에서 볼 수 있지만, 452는 총액이고 6xx는 상세입니다."}, {"title": "무엇이 아닌가", "body": "452와 6xx가 서로 조정되었다거나, 6xx와 8xx 조정을 이 태그 하나로 설명한다는 뜻은 아닙니다."}, {"title": "왜 표시하나", "body": "총액 비교 화면에서 452와 6xx를 함께 더하면 원가가 과대 표시될 수 있어 집계 기준을 환기하기 위한 표시입니다."}, ], ) if "매출 인식/계정 매핑" in review_tags: add_reason( "매출 인식/계정 매핑", "매출 쪽은 진행률 매출액 계상, 환원분개, 수정신고분 때문에 ERP 현재 매출과 WEHAGO 재무제표 매출이 달라질 수 있습니다.", [ {"label": "WEHAGO 매출", "value": buckets["revenue"]}, {"label": "ERP 매출", "value": erp_revenue}, {"label": "매출 조정효과", "value": adjustment_buckets["revenue"]}, {"label": "ERP+조정 매출", "value": adjusted_erp_revenue}, {"label": "조정 후 매출 차이", "value": adjusted_revenue_gap}, ], [ "진행률 매출액, 결산 환원분개, 수정신고분이 ERP 매출 집계에 반영되어 있는지 확인", "WEHAGO 매출 계정 411~417과 ERP 매출 계정 4xx의 매핑 기준 확인", "2022년처럼 특정 결산대체 행이 매출 계정에 들어간 경우 별도 제외/분류 기준 확인", ], [ {"title": "무엇을 뜻하나", "body": "매출 차이는 주로 진행률 매출 계상/환원 및 수정분개가 ERP와 WEHAGO에 같은 방식으로 반영되지 않을 때 발생합니다."}, {"title": "먼저 볼 자료", "body": "411~417 매출 계정, 진행율 매출액, 결산 환원분개, 수정신고분 전표를 봅니다."}, {"title": "비용 조정과의 관계", "body": "매출 진행률 조정은 6xx/8xx 비용 조정과 별도 흐름으로 보되, 같은 결산 과정에서 함께 발생할 수는 있습니다."}, ], ) if "6xx/8xx 조정흐름" in review_tags: add_reason( "6xx/8xx 조정흐름", "감사/결산 조정 전표 중 원가 상세인 6xx 계정과 판관비인 8xx 계정에 모두 영향이 있어, 비용 조정 흐름을 함께 확인해야 합니다.", [ {"label": "6xx 원가 조정효과", "value": adjustment_buckets["cogs"]}, {"label": "8xx 판관비 조정효과", "value": adjustment_buckets["sga"]}, {"label": "조정 후 원가 차이", "value": adjusted_cost_gap}, {"label": "조정 후 판관비 차이", "value": adjusted_sga_gap}, {"label": "조정 후 영업비용 차이", "value": adjusted_total_expense_gap}, ], [ "6xx 조정 전표와 8xx 조정 전표가 같은 결산 판단에서 나온 것인지 확인", "원가성 인건비/복리후생비/퇴직급여와 판관 인건비 계정이 각각 어디로 반영됐는지 확인", "ERP의 원가/판관비 기준이 WEHAGO 감사 후 분류 기준과 달라졌는지 확인", ], [ {"title": "무엇을 뜻하나", "body": "사용자께서 보신 것처럼 6xx와 8xx에 조정이 함께 있을 때 비용 조정을 하나의 흐름으로 보는 탭입니다."}, {"title": "452와의 차이", "body": "452는 매출원가 총액 표시 계정이고, 여기서는 6xx 원가상세와 8xx 판관비 조정 전표의 방향과 규모를 봅니다."}, {"title": "판단 포인트", "body": "6xx 조정과 8xx 조정이 서로 상쇄되는지, 아니면 둘 다 같은 방향으로 비용을 바꾸는지 확인합니다."}, ], ) if "감사조정 반영시 개선" in review_tags or "조정후 잔차 검토" in review_tags: add_reason( "감사조정 반영시 개선" if "감사조정 반영시 개선" in review_tags else "조정후 잔차 검토", "감사/결산 조정 전표를 ERP 현재값에 더해 본 뒤, 차이가 줄어드는지와 남는 잔차가 어디인지 비교했습니다.", [ {"label": "조정 전 매출 차이", "value": revenue_gap}, {"label": "조정 후 매출 차이", "value": adjusted_revenue_gap}, {"label": "조정 전 영업비용 차이", "value": total_expense_gap}, {"label": "조정 후 영업비용 차이", "value": adjusted_total_expense_gap}, {"label": "식별된 조정효과 합계", "value": sum(adjustment_buckets.values())}, ], [ "모달의 조정 전표 목록에서 결산/감사/수정 전표가 실제 감사 반영분인지 확인", "조정 후에도 남는 항목은 ERP 원천전표 누락, 계정 매핑, 시점 차이로 분류", "452 같은 마감 총액 전표는 중복 방지를 위해 조정효과에서 제외한 기준이 맞는지 확인", ], ) yearly_rows.append( { "year": year, "wehago_revenue": buckets["revenue"], "erp_revenue": erp_revenue, "adjusted_erp_revenue": adjusted_erp_revenue, "revenue_gap": revenue_gap, "adjusted_revenue_gap": adjusted_revenue_gap, "revenue_gap_rate": _financial_gap_ratio(revenue_gap, buckets["revenue"]), "wehago_cogs": buckets["cogs"], "erp_cost": erp_cost, "adjusted_erp_cost": adjusted_erp_cost, "cost_gap": cost_gap, "adjusted_cost_gap": adjusted_cost_gap, "cost_gap_rate": _financial_gap_ratio(cost_gap, buckets["cogs"]), "wehago_sga": buckets["sga"], "erp_sga": erp_sga, "adjusted_erp_sga": adjusted_erp_sga, "sga_gap": sga_gap, "adjusted_sga_gap": adjusted_sga_gap, "sga_gap_rate": _financial_gap_ratio(sga_gap, buckets["sga"]), "wehago_operating_expense": wehago_operating_expense, "erp_operating_expense": erp_operating_expense, "adjusted_erp_operating_expense": adjusted_erp_operating_expense, "operating_expense_gap": total_expense_gap, "adjusted_operating_expense_gap": adjusted_total_expense_gap, "operating_expense_gap_rate": _financial_gap_ratio(total_expense_gap, wehago_operating_expense), "wehago_gross_profit": gross_profit, "wehago_operating_profit": operating_profit, "erp_operating_profit": erp_operating_profit, "adjusted_erp_operating_profit": adjusted_erp_operating_profit, "operating_profit_gap": _financial_gap_signed_gap(operating_profit, erp_operating_profit), "adjusted_operating_profit_gap": adjusted_operating_profit_gap, "wehago_nonop_income": buckets["nonop_income"], "wehago_nonop_expense": buckets["nonop_expense"], "wehago_tax": buckets["tax"], "wehago_net_profit_proxy": net_profit_proxy, "cost_detail_6xx": buckets["cost_detail_6xx"], "adjustments": adjustment_buckets, "audit_adjustment_count": audit_adjustment_count, "closing_transfer_count": closing_transfer_count, "review_tags": review_tags, } ) hours = _financial_gap_get_hanmac_hours_summary(year) direct_labor = buckets["direct_labor"] sga_labor = buckets["sga_labor"] labor_pool_without_rnd = direct_labor + sga_labor labor_pool_with_rnd = labor_pool_without_rnd + buckets["rnd_like"] hanmac_labor_total = normalize_amount(project_diagnostics.get("displayed_labor_total")) hanmac_wehago_labor_gap = hanmac_labor_total - labor_pool_without_rnd total_hours = normalize_amount(hours.get("total_hours")) hourly_without_rnd = labor_pool_without_rnd / total_hours if total_hours > 0 else 0.0 hourly_with_rnd = labor_pool_with_rnd / total_hours if total_hours > 0 else 0.0 labor_tags: list[str] = [] if total_hours <= 0: labor_tags.append("근무시간 캐시 확인") if hourly_without_rnd >= 60000: labor_tags.append("시간/단가 누락 가능") if buckets["rnd_like"] / max(labor_pool_without_rnd, 1.0) >= 0.05: labor_tags.append("연구개발 포함여부 검토") if normalize_amount(erp.get("labor_direct")) > 0 and direct_labor > 0: direct_gap = _financial_gap_signed_gap(direct_labor, erp.get("labor_direct")) if abs(direct_gap) / max(direct_labor, 1.0) >= 0.1: labor_tags.append("ERP 원가인건비 매핑차") labor_rows.append( { "year": year, "direct_labor": direct_labor, "sga_labor": sga_labor, "rnd_like": buckets["rnd_like"], "labor_pool_without_rnd": labor_pool_without_rnd, "labor_pool_with_rnd": labor_pool_with_rnd, "wehago_labor_total": labor_pool_without_rnd, "hanmac_labor_total": hanmac_labor_total, "hanmac_wehago_labor_gap": hanmac_wehago_labor_gap, "erp_labor_direct": normalize_amount(erp.get("labor_direct")), "erp_outsourcing": normalize_amount(erp.get("outsourcing")), "wehago_outsourcing": buckets["outsourcing_6xx"], **hours, "implied_hourly_without_rnd": hourly_without_rnd, "implied_hourly_with_rnd": hourly_with_rnd, "tags": labor_tags, } ) for tag in review_tags: review_items.append( { "year": year, "area": "손익", "tag": tag, "basis": f"원가차이 {cost_gap:,.0f}, 판관비차이 {sga_gap:,.0f}, 총비용차이 {total_expense_gap:,.0f}", } ) for tag in labor_tags: review_items.append( { "year": year, "area": "인건비", "tag": tag, "basis": f"총근무 {total_hours:,.1f}h, 역산단가 {hourly_without_rnd:,.0f}원/h", } ) severity = "ok" headline = "큰 이상 없음" primary_gap = abs(adjusted_total_expense_gap) if abs(adjusted_revenue_gap) / max(abs(buckets["revenue"]), 1.0) >= 0.1: severity = "danger" headline = "매출 차이 큼" primary_gap = abs(adjusted_revenue_gap) elif abs(adjusted_total_expense_gap) / max(abs(wehago_operating_expense), 1.0) >= 0.05: severity = "danger" headline = "영업비용 차이 큼" elif abs(adjusted_cost_gap) > 0 and abs(adjusted_sga_gap) > 0 and adjusted_cost_gap * adjusted_sga_gap < 0: severity = "warning" headline = "원가/판관비 재분류 의심" primary_gap = min(abs(adjusted_cost_gap), abs(adjusted_sga_gap)) elif any(tag in review_tags for tag in ("452 총액/6xx 상세 구분", "조정후 잔차 검토", "6xx/8xx 조정흐름")): severity = "warning" headline = "집계 기준 확인" anomaly_cards.append( { "year": year, "severity": severity, "headline": headline, "primary_gap": primary_gap, "revenue_gap": adjusted_revenue_gap, "cost_gap": adjusted_cost_gap, "sga_gap": adjusted_sga_gap, "operating_expense_gap": adjusted_total_expense_gap, "audit_adjustment_total": sum(adjustment_buckets.values()), "audit_adjustment_count": audit_adjustment_count, "tags": review_tags[:4], } ) totals = { "wehago_revenue": sum(row["wehago_revenue"] for row in yearly_rows), "erp_revenue": sum(row["erp_revenue"] for row in yearly_rows), "adjusted_erp_revenue": sum(row["adjusted_erp_revenue"] for row in yearly_rows), "wehago_operating_expense": sum(row["wehago_operating_expense"] for row in yearly_rows), "erp_operating_expense": sum(row["erp_operating_expense"] for row in yearly_rows), "adjusted_erp_operating_expense": sum(row["adjusted_erp_operating_expense"] for row in yearly_rows), "labor_pool_without_rnd": sum(row["labor_pool_without_rnd"] for row in labor_rows), "hanmac_labor_total": sum(row["hanmac_labor_total"] for row in labor_rows), "hanmac_wehago_labor_gap": sum(row["hanmac_wehago_labor_gap"] for row in labor_rows), "total_hours": sum(row["total_hours"] for row in labor_rows), "audit_adjustment_total": sum(sum(row["adjustments"].values()) for row in yearly_rows), "audit_adjustment_count": sum(row["audit_adjustment_count"] for row in yearly_rows), } totals["revenue_gap"] = _financial_gap_signed_gap(totals["wehago_revenue"], totals["erp_revenue"]) totals["adjusted_revenue_gap"] = _financial_gap_signed_gap(totals["wehago_revenue"], totals["adjusted_erp_revenue"]) totals["operating_expense_gap"] = _financial_gap_signed_gap( totals["wehago_operating_expense"], totals["erp_operating_expense"], ) totals["adjusted_operating_expense_gap"] = _financial_gap_signed_gap( totals["wehago_operating_expense"], totals["adjusted_erp_operating_expense"], ) totals["implied_hourly_without_rnd"] = ( totals["labor_pool_without_rnd"] / totals["total_hours"] if totals["total_hours"] > 0 else 0.0 ) sorted_substantive_gap_rows = sorted( substantive_gap_rows, key=lambda item: ( item["year"], 0 if item["section"] == "수익" else 1, -abs(normalize_amount(item["residual_gap"])), -abs(normalize_amount(item["gap_amount"])), ), ) substantive_gap_preview_rows: list[dict[str, Any]] = [] for preview_year in sorted(target_years, reverse=True): year_rows = [ row for row in substantive_gap_rows if int(row.get("year") or 0) == preview_year ] for preview_section, section_limit in (("수익", 3), ("영업비용", 4)): section_rows = [ row for row in year_rows if row.get("section") == preview_section ] section_rows.sort( key=lambda item: ( -abs(normalize_amount(item["residual_gap"])), -abs(normalize_amount(item["gap_amount"])), ) ) substantive_gap_preview_rows.extend(section_rows[:section_limit]) project_profit_bridge_rows: list[dict[str, Any]] = [] for bridge_year in target_years: latest_financial = next((row for row in yearly_rows if row.get("year") == bridge_year), {}) try: project_payload = project_payloads_by_year.get(bridge_year) or {} project_summary = project_payload.get("summary") or {} diagnostics = project_payload.get("allocation_diagnostics") or {} project_cache_info = project_payload.get("cache_info") or {} project_rows = project_payload.get("rows") or [] common_rows = [ row for row in project_rows if normalize_text(row.get("support_dept_code")).upper() == "ZZZZZZ" ] project_revenue = normalize_amount(project_summary.get("period_revenue_amount")) project_expense = normalize_amount(project_summary.get("period_total_cost")) project_profit = normalize_amount(project_summary.get("period_profit_amount")) common_revenue = sum(normalize_amount(row.get("period_revenue_amount")) for row in common_rows) wehago_revenue = normalize_amount(latest_financial.get("wehago_revenue")) wehago_expense = normalize_amount(latest_financial.get("wehago_operating_expense")) wehago_profit = normalize_amount(latest_financial.get("wehago_operating_profit")) erp_revenue = normalize_amount(latest_financial.get("erp_revenue")) adjusted_erp_revenue = normalize_amount(latest_financial.get("adjusted_erp_revenue")) revenue_adjustment_total = normalize_amount((latest_financial.get("adjustments") or {}).get("revenue")) latest_revenue_adjustments = [ row for row in adjustment_rows if row.get("year") == bridge_year and row.get("bucket") == "revenue" ] revenue_adjustment_increase = sum( max(0.0, normalize_amount(row.get("delta"))) for row in latest_revenue_adjustments ) revenue_adjustment_decrease = sum( min(0.0, normalize_amount(row.get("delta"))) for row in latest_revenue_adjustments ) hanmac_labor_total = normalize_amount(diagnostics.get("displayed_labor_total")) common_cost_allocated = 0.0 common_sga_allocated = sum(normalize_amount(row.get("total_cost")) for row in common_rows) project_profit_bridge_rows.append( { "year": bridge_year, "project_revenue": project_revenue, "project_only_revenue": project_revenue - common_revenue, "common_revenue": common_revenue, "project_billing": normalize_amount(project_summary.get("period_billing_amount")), "project_negative_billing": normalize_amount(project_summary.get("period_negative_billing_amount")), "project_collection": normalize_amount(project_summary.get("period_collection_amount")), "project_revenue_billing_gap": normalize_amount(project_summary.get("period_revenue_billing_gap")), "project_revenue_collection_gap": normalize_amount(project_summary.get("period_revenue_collection_gap")), "common_revenue_row_count": len(common_rows), "wehago_revenue": wehago_revenue, "erp_revenue": erp_revenue, "adjusted_erp_revenue": adjusted_erp_revenue, "revenue_adjustment_total": revenue_adjustment_total, "revenue_adjustment_increase": revenue_adjustment_increase, "revenue_adjustment_decrease": revenue_adjustment_decrease, "revenue_adjustment_count": len(latest_revenue_adjustments), "project_revenue_gap": project_revenue - wehago_revenue, "wehago_to_erp_revenue_gap": wehago_revenue - erp_revenue, "erp_to_project_revenue_gap": erp_revenue - project_revenue, "adjusted_erp_to_project_revenue_gap": adjusted_erp_revenue - project_revenue, "project_unassigned_erp_revenue": erp_revenue - project_revenue, "project_expense": project_expense, "erp_total_expense": normalize_amount(diagnostics.get("erp_total_expense")), "expense_reconciliation_gap": normalize_amount(diagnostics.get("expense_reconciliation_gap")), "expense_reconciled": bool(diagnostics.get("expense_reconciled")), "project_profit_financial_logic_version": normalize_text(project_cache_info.get("financial_logic_version")), "project_profit_h_mapping_version": normalize_text(project_cache_info.get("h_project_mapping_version")), "project_profit_generated_at": normalize_text(project_cache_info.get("generated_at") or project_cache_info.get("updated_at")), "wehago_expense": wehago_expense, "project_expense_gap": project_expense - wehago_expense, "project_profit": project_profit, "wehago_profit": wehago_profit, "project_profit_gap": project_profit - wehago_profit, "hanmac_labor_total": hanmac_labor_total, "common_labor_excluded": 0.0, "hanmac_missing_regular_sga_allocated": 0.0, "common_cost_allocated": common_cost_allocated, "common_sga_allocated": common_sga_allocated, "common_source_total": normalize_amount(diagnostics.get("common_source_total")), "direct_project_nonlabor": ( project_expense - hanmac_labor_total - common_cost_allocated - common_sga_allocated ), "profit_bridge_validation_gap": ( (project_revenue - wehago_revenue) - (project_expense - wehago_expense) - (project_profit - wehago_profit) ), "revenue_bridge_validation_gap": ( (wehago_revenue - erp_revenue) + (erp_revenue - project_revenue) - (wehago_revenue - project_revenue) ), "adjusted_revenue_bridge_validation_gap": ( erp_revenue + revenue_adjustment_total - adjusted_erp_revenue ), "finding": ( "프로젝트손익 수익은 프로젝트 귀속 ERP 매출과 공통매출 행을 함께 표시합니다. " "청구·수금 금액은 참고값이며 매출-청구 차이를 별도 확인합니다." ), } ) except Exception as exc: logger.warning("프로젝트손익/WEHAGO 손익 브릿지 생성 실패(%s): %s", bridge_year, exc) return { "years": target_years, "yearly_rows": yearly_rows, "labor_rows": labor_rows, "account_detail_rows": account_detail_rows, "substantive_gap_rows": sorted_substantive_gap_rows, "substantive_gap_preview_rows": substantive_gap_preview_rows, "raw_account_comparison_rows": sorted( raw_account_comparison_rows, key=lambda item: ( item["year"], 0 if item["section"] == "수익" else 1, 0 if item["comparison_level"] == "총액" else 1, -abs(normalize_amount(item["pre_adjustment_gap"])), item["item_label"], ), ), "adjustment_rows": sorted(adjustment_rows, key=lambda item: (item["year"], item["ledger_date"], item["voucher_no"], item["account_code"])), "classification_review_rows": classification_review_rows, "review_items": review_items, "anomaly_cards": anomaly_cards, "reason_items": reason_items, "project_profit_bridge_rows": project_profit_bridge_rows, "totals": totals, "assumptions": [ "WEHAGO 손익계산서 금액은 계정별 원장의 차변/대변 합계 중 큰 금액을 표시 금액으로 사용했습니다.", "452 도급공사매출원가는 재무제표 매출원가 총액으로 보고, 6xx 계정은 매출원가 상세 구성으로만 표시했습니다.", "감사/결산 조정효과는 결산, 감사, 수정, 환원, 진행율 매출액, 계상분 대체 문맥의 원장 행만 별도 집계했습니다.", "손익계정 대체, 수익/비용에서 대체, 당기순손익 대체 같은 마감 전표는 최종 손익 금액 중복을 막기 위해 조정효과에서 제외했습니다.", "Hanmac ERP 비교 수익·원가·판관비·손익은 프로젝트 손익분석 페이지1과 동일한 연도별 최종 payload를 사용합니다.", "프로젝트 인건비는 한맥 근무시간에 연도·사업분류·직급별 시급과 연장·휴일 가산율을 적용한 뒤 ERP 실제 인건비성 비용과의 차액을 비례 배분한 값입니다.", "인건비 차이는 프로젝트 손익분석 페이지1의 최종 인건비와 WEHAGO 원가성·판관 인건비의 차이입니다.", "프로젝트손익은 프로젝트 코드로 연결된 ERP 4xx 매출을 수익으로 사용하므로, 회사 전체 WEHAGO 매출과 범위가 다를 수 있습니다.", "ERP+조정매출은 ERP 원매출에 WEHAGO 원장의 적요에서 감사·결산·진행률 매출 조정으로 식별한 전표 효과를 더한 분석용 비교값이며, ERP에 저장된 별도 확정 매출값이 아닙니다.", "식별 조정 전표는 적요 문구 기반이므로 전표 상세에서 실제 결산 조정 여부를 확인해야 하며, 프로젝트손익에는 프로젝트별 귀속 근거가 없는 조정액을 자동 배부하지 않습니다.", "ZZZZZZ 공통 프로젝트의 수익·비용·인건비는 프로젝트 손익분석 페이지1의 공통 행에 보존합니다.", "퇴직급여, 복리후생비, 상여/연차/4대보험 성격 비용은 인건비 풀에 포함될 수 있다는 전제로 검토합니다.", ], } def render_annual_gap_analysis_page(request: Request, message: str = "") -> HTMLResponse: init_db() context = { **base_context(request, message), "gap_analysis": get_financial_gap_analysis_payload(), } return templates.TemplateResponse(request, "annual_gap_analysis.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, ), } response = templates.TemplateResponse(request, "wehago_compare.html", context) response.headers["Cache-Control"] = "no-store, max-age=0" response.headers["Pragma"] = "no-cache" return response 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), "hanmac_wehago_audit_sources": build_hanmac_wehago_audit_sources(), } 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() HANMAC_MANAGEMENT_ERP_BASE_URL = "http://erp.hanmaceng.co.kr/planning_mng/" HANMAC_MANAGEMENT_ERP_LOGIN_URL = f"{HANMAC_MANAGEMENT_ERP_BASE_URL}LoginCheck.php" HANMAC_MANAGEMENT_ERP_MAIN_URL = f"{HANMAC_MANAGEMENT_ERP_BASE_URL}sys/controller/main_controller.php" HANMAC_SATIS_ERP_BASE_URL = "http://erp.hanmaceng.co.kr/satis/" HANMAC_SATIS_ERP_LOGIN_CONTROLLER_URL = f"{HANMAC_SATIS_ERP_BASE_URL}sys/controller/Login/Login_Controller.php" HANMAC_SATIS_ERP_LOGIN_PAGE_URL = ( f"{HANMAC_SATIS_ERP_BASE_URL}sys/controller/Login/Login_controller.php?ActionMode=GoLogin" ) HANMAC_ERP_DIRECT_DB_HOST = "erp.hanmaceng.co.kr" HANMAC_ERP_DIRECT_DB_PORT = 3306 HANMAC_SATIS_BUDGET_DISCOVERY_KEYWORDS = ( "satis", "project", "proj", "budget", "exec", "plan", "task", "cost", "amount", "approval", "approve", "revision", "rev", "round", "degree", "change", "dept", "work", "account", "acct", ) HANMAC_SATIS_NUMERIC_TYPES = {"bigint", "decimal", "double", "float", "int", "integer", "mediumint", "numeric", "real", "smallint", "tinyint"} HANMAC_SATIS_AMOUNT_COLUMN_TOKENS = ("amount", "amt", "budget", "cost", "price", "sum", "total", "money", "supply") HANMAC_SATIS_PROJECT_CODE_TOKENS = ("project", "proj", "pjt", "pj", "prj") HANMAC_SATIS_REVISION_TOKENS = ("revision", "rev", "round", "degree", "change", "seq", "cha", "turn") HANMAC_SATIS_STATUS_TOKENS = ("approval", "approve", "status", "state", "confirm", "app") HANMAC_SATIS_PROJECT_CODE_REGISTER_FILENAME = "차수사업코드등록_260622.xls" HANMAC_SATIS_LINKED_MAIN_PROJECT_OVERRIDES = { "X24016": "024240", "X24020": "024241", "X25011": "025227", } HANMAC_SATIS_COMMON_PROJECT_CODES = {"ZZZZZZ", "X24003"} HANMAC_SATIS_EXCEPTION_PROJECT_CODES = {"006061"} class _SatisProjectCodeRegisterHtmlParser(HTMLParser): def __init__(self) -> None: super().__init__() self._in_cell = False self._cell_parts: list[str] = [] self._row: list[str] = [] self.rows: list[list[str]] = [] def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None: if tag == "tr": self._row = [] if tag in {"td", "th"}: self._in_cell = True self._cell_parts = [] def handle_data(self, data: str) -> None: if self._in_cell: self._cell_parts.append(data) def handle_endtag(self, tag: str) -> None: if tag in {"td", "th"} and self._in_cell: self._row.append(" ".join("".join(self._cell_parts).split())) self._in_cell = False self._cell_parts = [] elif tag == "tr" and self._row: self.rows.append(self._row) def _parse_satis_project_code_register(path: Path) -> list[dict[str, str]]: if not path.exists(): return [] body = path.read_text(encoding="utf-8", errors="replace") parser = _SatisProjectCodeRegisterHtmlParser() parser.feed(body) header: list[str] = [] data_rows: list[list[str]] = [] for index, row in enumerate(parser.rows): if row and row[0] == "차수사업코드": header = row data_rows = parser.rows[index + 1 :] break if not header: return [] records: list[dict[str, str]] = [] for row in data_rows: if not row or row[0].startswith("총 "): continue if len(row) < len(header): row = [*row, *([""] * (len(header) - len(row)))] records.append({column: normalize_text(row[position]) for position, column in enumerate(header)}) return records def _default_satis_master_code_for_local_project(local_project_code: str, project_kind: str) -> str: local_project_code = normalize_text(local_project_code) project_kind = normalize_text(project_kind) if re.fullmatch(r"X\d{5}", local_project_code): return f"9{local_project_code[1:]}" if re.fullmatch(r"[YZ]\d{5}", local_project_code): return f"0{local_project_code[1:]}" if re.fullmatch(r"[09]\d{5}", local_project_code): return local_project_code if local_project_code == "ZZZZZZ": return "" return "" def sync_satis_project_code_register(conn: Any | None = None) -> dict[str, Any]: """Import the ERP round-project code register into the local mapping table.""" register_path = BASE_DIR / HANMAC_SATIS_PROJECT_CODE_REGISTER_FILENAME records = _parse_satis_project_code_register(register_path) if not records: return { "status": "skipped", "message": f"{HANMAC_SATIS_PROJECT_CODE_REGISTER_FILENAME} 파일을 찾지 못했거나 읽을 수 없습니다.", "source_file": str(register_path), "row_count": 0, } owns_connection = conn is None context = engine.begin() if owns_connection else nullcontext(conn) inserted_or_updated = 0 kind_counts: dict[str, int] = {} status_counts: dict[str, int] = {} with context as active_conn: for record in records: local_code = normalize_text(record.get("차수사업코드")) if not local_code: continue project_kind = normalize_text(record.get("사업종류")) own_master_code = normalize_text(record.get("총괄사업코드")) or _default_satis_master_code_for_local_project(local_code, project_kind) linked_main_code = own_master_code mapping_status = "confirmed" if local_code in HANMAC_SATIS_LINKED_MAIN_PROJECT_OVERRIDES: linked_main_code = HANMAC_SATIS_LINKED_MAIN_PROJECT_OVERRIDES[local_code] elif project_kind == "사전": linked_main_code = "" if local_code in HANMAC_SATIS_COMMON_PROJECT_CODES: mapping_status = "common" linked_main_code = "" if local_code in HANMAC_SATIS_EXCEPTION_PROJECT_CODES or own_master_code in HANMAC_SATIS_EXCEPTION_PROJECT_CODES: mapping_status = "exception" is_active = 1 if normalize_text(record.get("사용여부")) != "N" else 0 kind_counts[project_kind or "미지정"] = kind_counts.get(project_kind or "미지정", 0) + 1 status_counts[mapping_status] = status_counts.get(mapping_status, 0) + 1 active_conn.execute( text( """ INSERT INTO satis_project_code_links ( local_project_code, local_project_name, project_kind, own_master_project_code, own_master_project_name, linked_main_project_code, linked_main_project_name, cost_project_code, cost_project_name, cost_kind, pm_department_name, is_joint_project, is_tax_exempt, is_active, mapping_status, mapping_source, source_file, raw_payload_json, updated_at ) VALUES ( :local_project_code, :local_project_name, :project_kind, :own_master_project_code, :own_master_project_name, :linked_main_project_code, :linked_main_project_name, :cost_project_code, :cost_project_name, :cost_kind, :pm_department_name, :is_joint_project, :is_tax_exempt, :is_active, :mapping_status, 'project_code_register', :source_file, :raw_payload_json, CURRENT_TIMESTAMP ) ON CONFLICT(local_project_code) DO UPDATE SET local_project_name = excluded.local_project_name, project_kind = excluded.project_kind, own_master_project_code = excluded.own_master_project_code, own_master_project_name = excluded.own_master_project_name, linked_main_project_code = excluded.linked_main_project_code, linked_main_project_name = excluded.linked_main_project_name, cost_project_code = excluded.cost_project_code, cost_project_name = excluded.cost_project_name, cost_kind = excluded.cost_kind, pm_department_name = excluded.pm_department_name, is_joint_project = excluded.is_joint_project, is_tax_exempt = excluded.is_tax_exempt, is_active = excluded.is_active, mapping_status = excluded.mapping_status, mapping_source = excluded.mapping_source, source_file = excluded.source_file, raw_payload_json = excluded.raw_payload_json, updated_at = CURRENT_TIMESTAMP """ ), { "local_project_code": local_code, "local_project_name": normalize_text(record.get("차수사업명칭")), "project_kind": project_kind, "own_master_project_code": own_master_code, "own_master_project_name": normalize_text(record.get("총괄사업명칭")), "linked_main_project_code": linked_main_code, "linked_main_project_name": "", "cost_project_code": normalize_text(record.get("원가코드")), "cost_project_name": normalize_text(record.get("원가사업명칭")), "cost_kind": normalize_text(record.get("원가종류")), "pm_department_name": normalize_text(record.get("PM부서명칭")), "is_joint_project": normalize_text(record.get("공동공사여부")), "is_tax_exempt": normalize_text(record.get("면세여부")), "is_active": is_active, "mapping_status": mapping_status, "source_file": register_path.name, "raw_payload_json": json.dumps(record, ensure_ascii=False, sort_keys=True), }, ) inserted_or_updated += 1 return { "status": "ok", "message": f"Satis 차수사업코드 원장 {inserted_or_updated:,}건을 동기화했습니다.", "source_file": str(register_path), "row_count": inserted_or_updated, "kind_counts": kind_counts, "status_counts": status_counts, } def _hanmac_mysql_identifier(value: Any) -> str: normalized = normalize_text(value) if not re.fullmatch(r"[A-Za-z0-9_]+", normalized): raise ValueError(f"MySQL 식별자 형식이 올바르지 않습니다: {normalized}") return f"`{normalized}`" def _lower_identifier(value: Any) -> str: return normalize_text(value).lower().replace("-", "_") def _pick_satis_column(columns: list[dict[str, Any]], token_groups: Sequence[Sequence[str]]) -> str: scored: list[tuple[int, str]] = [] for column in columns: name = normalize_text(column.get("name")) lowered = _lower_identifier(name) score = 0 for group_index, tokens in enumerate(token_groups): if any(token in lowered for token in tokens): score += max(1, 10 - group_index) if score > 0: scored.append((score, name)) scored.sort(key=lambda item: (-item[0], len(item[1]), item[1])) return scored[0][1] if scored else "" def _infer_satis_budget_type(database: str, table: str, columns: list[dict[str, Any]]) -> str: haystack = " ".join([database, table, *[normalize_text(column.get("name")) for column in columns]]).lower() if any(token in haystack for token in ("exec", "execution", "실행")): return "exec_budget" if any(token in haystack for token in ("task", "plan", "과업", "수행")): return "task_plan" if any(token in haystack for token in ("overview", "summary", "개요")): return "project_overview" return "project_budget" def _infer_satis_amount_columns(columns: list[dict[str, Any]]) -> list[str]: scored: list[tuple[int, int, str]] = [] for position, column in enumerate(columns): name = normalize_text(column.get("name")) lowered = _lower_identifier(name) data_type = _lower_identifier(column.get("data_type")) score = 0 if data_type in HANMAC_SATIS_NUMERIC_TYPES: score += 4 if any(token in lowered for token in HANMAC_SATIS_AMOUNT_COLUMN_TOKENS): score += 8 if any(token in lowered for token in ("date", "year", "no", "code", "cd", "id", "seq", "rate", "percent")): score -= 4 if score >= 6: scored.append((score, position, name)) scored.sort(key=lambda item: (-item[0], item[1], item[2])) return [name for _score, _position, name in scored[:12]] def _safe_float(value: Any) -> float: if value in (None, ""): return 0.0 try: return float(str(value).replace(",", "")) except Exception: return 0.0 def _json_default(value: Any) -> Any: if isinstance(value, (date, datetime)): return value.isoformat() if isinstance(value, Decimal): return float(value) return str(value) def _hanmac_erp_web_credentials(payload: dict[str, Any]) -> tuple[str, str]: user = normalize_text(payload.get("erp_user")) password = str(payload.get("erp_password") or "") if not user: raise ValueError("Satis ERP 아이디를 입력해주세요.") if not password: raise ValueError("Satis ERP 비밀번호를 입력해주세요.") return user, password def _hanmac_erp_mysql_credentials(payload: dict[str, Any]) -> tuple[str, str, bool]: db_user = normalize_text(payload.get("erp_db_user")) db_password = str(payload.get("erp_db_password") or "") if db_user or db_password: if not db_user: raise ValueError("관리DB MySQL 아이디를 입력해주세요.") if not db_password: raise ValueError("관리DB MySQL 비밀번호를 입력해주세요.") return db_user, db_password, True user, password = _hanmac_erp_web_credentials(payload) return user, password, False def _build_hanmac_mysql_access_denied_message(user: str, explicit_db_credentials: bool) -> str: if explicit_db_credentials: return f"관리DB MySQL 계정 '{user}'로 직접 DB 접속이 거부되었습니다. MySQL 권한 또는 비밀번호를 확인해주세요." return ( f"ERP 웹 계정 '{user}'로 MySQL 직접 DB 접속이 거부되었습니다. " "G26001 같은 관리 ERP 웹 로그인 계정과 MySQL DB 계정은 별도일 수 있습니다. " "예산 원본 DB를 직접 조회하려면 관리DB MySQL 아이디/비밀번호 또는 읽기전용 View/API 정보가 필요합니다." ) def _sync_satis_budget_raw_rows(payload: dict[str, Any]) -> dict[str, Any]: _hanmac_erp_web_credentials(payload) db_user, db_password, explicit_db_credentials = _hanmac_erp_mysql_credentials(payload) try: max_tables = max(1, min(int(payload.get("max_tables") or 12), 40)) except Exception: max_tables = 12 try: row_limit = max(10, min(int(payload.get("row_limit") or 500), 5000)) except Exception: row_limit = 500 try: discovery = _discover_hanmac_erp_budget_tables(db_user, db_password) except OperationalError as exc: if "access denied" in str(exc).lower(): raise ValueError(_build_hanmac_mysql_access_denied_message(db_user, explicit_db_credentials)) from exc raise candidates = list(discovery.get("candidate_tables") or [])[:max_tables] if not candidates: return { "status": "ok", "message": "직접 DB 접속은 됐지만 예산 후보 테이블을 찾지 못했습니다.", "inserted_or_updated_rows": 0, "table_results": [], **discovery, } remote_engine = _build_hanmac_erp_direct_mysql_engine(db_user, db_password) table_results: list[dict[str, Any]] = [] inserted_or_updated = 0 skipped_tables = 0 try: with remote_engine.connect() as remote_conn, engine.begin() as local_conn: for candidate in candidates: database = normalize_text(candidate.get("database")) table = normalize_text(candidate.get("table")) if not database or not table: continue column_rows = remote_conn.execute( text( """ SELECT COLUMN_NAME, DATA_TYPE, ORDINAL_POSITION FROM information_schema.columns WHERE table_schema = :database AND table_name = :table ORDER BY ORDINAL_POSITION """ ), {"database": database, "table": table}, ).mappings().fetchall() columns = [ { "name": normalize_text(row.get("COLUMN_NAME")), "data_type": normalize_text(row.get("DATA_TYPE")), "position": int(row.get("ORDINAL_POSITION") or 0), } for row in column_rows if normalize_text(row.get("COLUMN_NAME")) ] amount_columns = _infer_satis_amount_columns(columns) if not amount_columns: skipped_tables += 1 table_results.append( { "database": database, "table": table, "status": "skipped", "reason": "금액성 컬럼을 추정하지 못했습니다.", "row_count": 0, } ) continue project_code_column = _pick_satis_column(columns, (HANMAC_SATIS_PROJECT_CODE_TOKENS, ("code", "cd", "no"))) project_name_column = _pick_satis_column(columns, (HANMAC_SATIS_PROJECT_CODE_TOKENS, ("name", "nm", "title"))) revision_column = _pick_satis_column(columns, (HANMAC_SATIS_REVISION_TOKENS,)) approval_status_column = _pick_satis_column(columns, (HANMAC_SATIS_STATUS_TOKENS,)) budget_type = _infer_satis_budget_type(database, table, columns) selected_columns: list[str] = [] for name in [ project_code_column, project_name_column, revision_column, approval_status_column, *amount_columns, *[normalize_text(column.get("name")) for column in columns[:30]], ]: if name and name not in selected_columns: selected_columns.append(name) selected_columns = selected_columns[:80] quoted_selected = ", ".join(_hanmac_mysql_identifier(name) for name in selected_columns) quoted_amounts = [_hanmac_mysql_identifier(name) for name in amount_columns] where_clause = " OR ".join(f"COALESCE({quoted}, 0) <> 0" for quoted in quoted_amounts) source_sql = text( f""" SELECT {quoted_selected} FROM {_hanmac_mysql_identifier(database)}.{_hanmac_mysql_identifier(table)} WHERE {where_clause} LIMIT {int(row_limit)} """ ) rows = remote_conn.execute(source_sql).mappings().fetchall() table_inserted = 0 inferred_columns = { "project_code": project_code_column, "project_name": project_name_column, "revision_no": revision_column, "approval_status": approval_status_column, "amount_columns": amount_columns, } for row_index, row in enumerate(rows): row_dict = {key: row.get(key) for key in selected_columns} amount_values = { column_name: _safe_float(row_dict.get(column_name)) for column_name in amount_columns } amount_total = sum(amount_values.values()) if amount_total == 0: continue raw_payload_json = json.dumps(row_dict, ensure_ascii=False, sort_keys=True, default=_json_default) source_hash = hashlib.sha256( json.dumps( { "database": database, "table": table, "row": row_dict, }, ensure_ascii=False, sort_keys=True, default=_json_default, ).encode("utf-8") ).hexdigest() local_conn.execute( text( """ INSERT OR REPLACE INTO satis_project_budget_raw_rows ( source_system, source_database, source_table, source_row_index, budget_type, project_code, project_name, revision_no, approval_status, amount_total, amount_values_json, inferred_columns_json, raw_payload_json, source_hash, synced_at ) VALUES ( 'satis', :source_database, :source_table, :source_row_index, :budget_type, :project_code, :project_name, :revision_no, :approval_status, :amount_total, :amount_values_json, :inferred_columns_json, :raw_payload_json, :source_hash, CURRENT_TIMESTAMP ) """ ), { "source_database": database, "source_table": table, "source_row_index": row_index, "budget_type": budget_type, "project_code": normalize_text(row_dict.get(project_code_column)), "project_name": normalize_text(row_dict.get(project_name_column)), "revision_no": normalize_text(row_dict.get(revision_column)), "approval_status": normalize_text(row_dict.get(approval_status_column)), "amount_total": amount_total, "amount_values_json": json.dumps(amount_values, ensure_ascii=False, sort_keys=True), "inferred_columns_json": json.dumps(inferred_columns, ensure_ascii=False, sort_keys=True), "raw_payload_json": raw_payload_json, "source_hash": source_hash, }, ) table_inserted += 1 inserted_or_updated += table_inserted table_results.append( { "database": database, "table": table, "status": "synced", "budget_type": budget_type, "row_count": table_inserted, "sampled_row_limit": row_limit, "inferred_columns": inferred_columns, } ) finally: remote_engine.dispose() return { "status": "ok", "message": f"Satis 후보 테이블 {len(candidates)}개에서 실제 금액 행 {inserted_or_updated:,}건을 원본 저장소에 반영했습니다.", "inserted_or_updated_rows": inserted_or_updated, "skipped_tables": skipped_tables, "table_results": table_results, "raw_table": "satis_project_budget_raw_rows", "candidate_table_count": discovery.get("candidate_table_count", 0), "direct_db_database_count": discovery.get("direct_db_database_count", 0), } def _parse_json_dict(value: Any) -> dict[str, Any]: if isinstance(value, dict): return value try: parsed = json.loads(str(value or "{}")) return parsed if isinstance(parsed, dict) else {} except Exception: return {} def _satis_raw_project_code(row: Mapping[str, Any]) -> str: project_code = normalize_text(row.get("project_code")) if project_code: return project_code payload = _parse_json_dict(row.get("raw_payload_json")) inferred = _parse_json_dict(row.get("inferred_columns_json")) inferred_project_column = normalize_text(inferred.get("project_code")) if inferred_project_column: return normalize_text(payload.get(inferred_project_column)) for key, value in payload.items(): lowered = _lower_identifier(key) if any(token in lowered for token in HANMAC_SATIS_PROJECT_CODE_TOKENS) and any(token in lowered for token in ("code", "cd", "no")): candidate = normalize_text(value) if candidate: return candidate return "UNKNOWN" def _satis_raw_project_name(row: Mapping[str, Any]) -> str: project_name = normalize_text(row.get("project_name")) if project_name: return project_name payload = _parse_json_dict(row.get("raw_payload_json")) inferred = _parse_json_dict(row.get("inferred_columns_json")) inferred_project_name_column = normalize_text(inferred.get("project_name")) if inferred_project_name_column: return normalize_text(payload.get(inferred_project_name_column)) for key, value in payload.items(): lowered = _lower_identifier(key) if any(token in lowered for token in HANMAC_SATIS_PROJECT_CODE_TOKENS) and any(token in lowered for token in ("name", "nm", "title")): candidate = normalize_text(value) if candidate: return candidate return "" def _lookup_support_dept_code_for_satis_project(conn: Any, project_code: str, project_name: str) -> str: project_code = normalize_text(project_code) project_name = normalize_text(project_name) if not project_code and not project_name: return "" row = None if project_code: row = conn.execute( text( """ SELECT support_dept_code FROM project_status WHERE support_dept_code = :project_code LIMIT 1 """ ), {"project_code": project_code}, ).mappings().first() if project_code: link_row = conn.execute( text( """ SELECT l.local_project_code AS support_dept_code FROM satis_project_code_links l INNER JOIN project_status p ON p.support_dept_code = l.local_project_code WHERE l.mapping_status IN ('confirmed', 'exception') AND l.local_project_code <> 'ZZZZZZ' AND ( l.local_project_code = :project_code OR l.own_master_project_code = :project_code OR l.linked_main_project_code = :project_code OR l.cost_project_code = :project_code ) ORDER BY CASE WHEN l.local_project_code = :project_code THEN 0 ELSE 1 END, CASE WHEN l.own_master_project_code = :project_code THEN 0 ELSE 1 END, CASE WHEN l.linked_main_project_code = :project_code THEN 0 ELSE 1 END, CASE WHEN l.is_active = 1 THEN 0 ELSE 1 END, l.local_project_code LIMIT 1 """ ), {"project_code": project_code}, ).mappings().first() if link_row: row = link_row if not row and project_code: row = conn.execute( text( """ SELECT support_dept_code FROM satis_project_mapping WHERE erp_project_code = :project_code AND mapping_status = 'matched' LIMIT 1 """ ), {"project_code": project_code}, ).mappings().first() if not row and project_name: row = conn.execute( text( """ SELECT support_dept_code FROM project_status WHERE support_dept_name = :project_name LIMIT 1 """ ), {"project_name": project_name}, ).mappings().first() return normalize_text(row.get("support_dept_code")) if row else "" def _normalize_satis_budget_raw_rows(_payload: dict[str, Any] | None = None) -> dict[str, Any]: init_db() normalized_revisions = 0 task_lines = 0 exec_lines = 0 skipped_rows = 0 source_table_counts: dict[str, int] = {} with engine.begin() as conn: conn.execute(text("DELETE FROM satis_project_budget_projection_status")) conn.execute(text("DELETE FROM satis_project_task_plan_budget_lines")) conn.execute(text("DELETE FROM satis_project_exec_budget_lines")) conn.execute(text("DELETE FROM satis_project_budget_revisions")) raw_rows = conn.execute( text( """ SELECT * FROM satis_project_budget_raw_rows WHERE source_database = 'satis_web' AND inferred_columns_json LIKE '%GET_DETAIL%' AND source_table IN ( 'SCREEN_03:Ajax_02', 'SCREEN_06:Ajax_00', 'SCREEN_02:Ajax_01', 'SCREEN_02:Ajax_03', 'SCREEN_02:Ajax_04' ) ORDER BY source_database, source_table, project_code, revision_no, id """ ) ).mappings().fetchall() deduplicated_raw_rows: list[Mapping[str, Any]] = [] seen_raw_signatures: set[tuple[str, str, str, str]] = set() for candidate_row in raw_rows: signature = ( normalize_text(candidate_row.get("source_table")), _satis_raw_project_code(candidate_row), normalize_text(candidate_row.get("revision_no")), normalize_text(candidate_row.get("raw_payload_json")), ) if signature in seen_raw_signatures: continue seen_raw_signatures.add(signature) deduplicated_raw_rows.append(candidate_row) raw_rows = deduplicated_raw_rows revision_metadata: dict[tuple[str, str], dict[str, str]] = {} for metadata_row in raw_rows: if normalize_text(metadata_row.get("source_table")) != "SCREEN_03:Ajax_02": continue metadata_project_code = _satis_raw_project_code(metadata_row) metadata_revision_no = normalize_text(metadata_row.get("revision_no")) or "00" revision_metadata[(metadata_project_code, metadata_revision_no)] = { "approval_status": normalize_text(metadata_row.get("approval_status")), "project_name": _satis_raw_project_name(metadata_row), } for raw_row in raw_rows: raw_id = int(raw_row.get("id") or 0) source_database = normalize_text(raw_row.get("source_database")) source_table = normalize_text(raw_row.get("source_table")) budget_type = normalize_text(raw_row.get("budget_type")) or "project_budget" project_code = _satis_raw_project_code(raw_row) project_name = _satis_raw_project_name(raw_row) revision_no = normalize_text(raw_row.get("revision_no")) or "0" approval_status = normalize_text(raw_row.get("approval_status")) metadata = revision_metadata.get((project_code, revision_no), {}) if metadata: approval_status = normalize_text(metadata.get("approval_status")) or approval_status project_name = normalize_text(metadata.get("project_name")) or project_name raw_payload_json = normalize_text(raw_row.get("raw_payload_json")) raw_payload = _parse_json_dict(raw_payload_json) amount_values = _parse_json_dict(raw_row.get("amount_values_json")) inferred_columns = _parse_json_dict(raw_row.get("inferred_columns_json")) if not amount_values: skipped_rows += 1 continue support_dept_code = _lookup_support_dept_code_for_satis_project(conn, project_code, project_name) source_key = f"{source_database}:{project_code}:{budget_type}:{revision_no}" source_hash = hashlib.sha256( json.dumps( { "source_key": source_key, "raw_hash": normalize_text(raw_row.get("source_hash")), "amount_values": amount_values, }, ensure_ascii=False, sort_keys=True, default=_json_default, ).encode("utf-8") ).hexdigest() approval_status_lower = re.sub(r"<[^>]+>", "", approval_status).strip().lower() is_approved = int( any( token in approval_status_lower for token in ("승인완료", "결재완료", "확정:y", "approved", "completed") ) or approval_status_lower in ("70", "y", "1") ) conn.execute( text( """ INSERT INTO satis_project_budget_revisions ( source_system, project_code, support_dept_code, project_name, budget_type, revision_no, revision_name, approval_status, is_approved, is_latest, source_key, source_hash, raw_payload_json, synced_at ) VALUES ( 'satis', :project_code, :support_dept_code, :project_name, :budget_type, :revision_no, :revision_name, :approval_status, :is_approved, 0, :source_key, :source_hash, :raw_payload_json, CURRENT_TIMESTAMP ) ON CONFLICT(source_system, budget_type, source_key) DO UPDATE SET support_dept_code = excluded.support_dept_code, project_name = excluded.project_name, revision_no = excluded.revision_no, revision_name = excluded.revision_name, approval_status = excluded.approval_status, is_approved = excluded.is_approved, source_hash = excluded.source_hash, raw_payload_json = excluded.raw_payload_json, synced_at = CURRENT_TIMESTAMP """ ), { "project_code": project_code, "support_dept_code": support_dept_code, "project_name": project_name, "budget_type": budget_type, "revision_no": revision_no, "revision_name": f"{source_database}.{source_table} #{raw_id}", "approval_status": approval_status, "is_approved": is_approved, "source_key": source_key, "source_hash": source_hash, "raw_payload_json": raw_payload_json, }, ) revision_row = conn.execute( text( """ SELECT id FROM satis_project_budget_revisions WHERE source_system = 'satis' AND budget_type = :budget_type AND source_key = :source_key LIMIT 1 """ ), {"budget_type": budget_type, "source_key": source_key}, ).mappings().first() if not revision_row: skipped_rows += 1 continue revision_id = int(revision_row.get("id") or 0) for line_no, (amount_column, amount_value) in enumerate(amount_values.items()): amount = _safe_float(amount_value) if amount == 0: continue source_line_key = f"{raw_id}:{amount_column}" group_name = source_table dept_name = "" work_name = amount_column grade = "" hours = "" account_code = amount_column account_name = amount_column if source_table == "SCREEN_06:Ajax_00": dept_name = normalize_text(raw_payload.get("item09") or raw_payload.get("item08")) work_name = normalize_text(raw_payload.get("item06")) or amount_column if amount_column == "item40": group_name = "outsource" work_name = f"{work_name} 외주예상" elif normalize_text(raw_payload.get("item14")) == "Y": group_name = "outsource" else: group_name = "department" elif source_table == "SCREEN_02:Ajax_01": group_name = "labor" grade = normalize_text(raw_payload.get("item07")) account_code = "SATIS_LABOR" account_name = grade or "인건비" elif source_table == "SCREEN_02:Ajax_03": group_name = "outsource" work_name = normalize_text(raw_payload.get("item06")) account_code = normalize_text(raw_payload.get("item05")) account_name = work_name or "외주비" elif source_table == "SCREEN_02:Ajax_04": group_name = "cost_plan" account_code = normalize_text(raw_payload.get("item06")) account_name = normalize_text(raw_payload.get("item07")) or "제경비" line_payload = { "source_database": source_database, "source_table": source_table, "raw_row_id": raw_id, "amount_column": amount_column, "inferred_columns": inferred_columns, "raw": raw_payload, } if budget_type == "exec_budget": conn.execute( text( """ INSERT INTO satis_project_exec_budget_lines ( revision_id, line_no, group_name, grade, hours, dept_name, work_name, account_code, account_name, amount, source_line_key, raw_payload_json, synced_at ) VALUES ( :revision_id, :line_no, :group_name, :grade, :hours, :dept_name, :work_name, :account_code, :account_name, :amount, :source_line_key, :raw_payload_json, CURRENT_TIMESTAMP ) """ ), { "revision_id": revision_id, "line_no": line_no, "group_name": group_name, "grade": grade, "hours": hours, "dept_name": dept_name, "work_name": work_name, "account_code": account_code, "account_name": account_name, "amount": amount, "source_line_key": source_line_key, "raw_payload_json": json.dumps(line_payload, ensure_ascii=False, sort_keys=True, default=_json_default), }, ) exec_lines += 1 else: conn.execute( text( """ INSERT INTO satis_project_task_plan_budget_lines ( revision_id, line_no, group_name, dept_name, work_name, amount, source_line_key, raw_payload_json, synced_at ) VALUES ( :revision_id, :line_no, :group_name, :dept_name, :work_name, :amount, :source_line_key, :raw_payload_json, CURRENT_TIMESTAMP ) """ ), { "revision_id": revision_id, "line_no": line_no, "group_name": group_name, "dept_name": dept_name, "work_name": work_name, "amount": amount, "source_line_key": source_line_key, "raw_payload_json": json.dumps(line_payload, ensure_ascii=False, sort_keys=True, default=_json_default), }, ) task_lines += 1 normalized_revisions += 1 source_table_key = f"{source_database}.{source_table}" source_table_counts[source_table_key] = source_table_counts.get(source_table_key, 0) + 1 summary_rows = conn.execute( text( """ SELECT r.budget_type, COUNT(DISTINCT r.id) AS revision_count, COALESCE(SUM(t.amount), 0) AS task_amount, COALESCE(SUM(e.amount), 0) AS exec_amount FROM satis_project_budget_revisions r LEFT JOIN satis_project_task_plan_budget_lines t ON t.revision_id = r.id LEFT JOIN satis_project_exec_budget_lines e ON e.revision_id = r.id GROUP BY r.budget_type ORDER BY r.budget_type """ ) ).mappings().fetchall() return { "status": "ok", "message": f"Satis 원본 금액 {normalized_revisions:,}건을 차수/상세 예산 테이블로 정규화했습니다.", "raw_row_count": len(raw_rows), "normalized_revisions": normalized_revisions, "task_lines": task_lines, "exec_lines": exec_lines, "skipped_rows": skipped_rows, "source_tables": [ {"table": key, "row_count": count} for key, count in sorted(source_table_counts.items(), key=lambda item: (-item[1], item[0]))[:20] ], "summary": [ { "budget_type": normalize_text(row.get("budget_type")), "revision_count": int(row.get("revision_count") or 0), "task_amount": float(row.get("task_amount") or 0), "exec_amount": float(row.get("exec_amount") or 0), } for row in summary_rows ], } def _is_satis_pending_approval_status(value: Any) -> bool: status = normalize_text(value).lower() if not status: return False pending_tokens = ("승인중", "승인 중", "결재중", "결재 중", "상신", "진행", "검토", "요청", "대기", "pending", "progress", "review") rejected_tokens = ("반려", "취소", "삭제", "reject", "cancel", "deleted") return any(token in status for token in pending_tokens) and not any(token in status for token in rejected_tokens) def _satis_revision_sort_key(row: Mapping[str, Any]) -> tuple[float, int]: revision_no_text = normalize_text(row.get("revision_no")) numbers = re.findall(r"-?\d+(?:\.\d+)?", revision_no_text) revision_value = float(numbers[-1]) if numbers else 0.0 return (revision_value, int(row.get("id") or 0)) def _select_satis_projection_revisions(conn: Any, include_pending: bool) -> list[dict[str, Any]]: rows = [ dict(row) for row in conn.execute( text( """ SELECT * FROM satis_project_budget_revisions WHERE support_dept_code <> '' AND budget_type IN ('task_plan', 'exec_budget') ORDER BY support_dept_code, budget_type, id """ ) ).mappings().fetchall() ] grouped: dict[tuple[str, str], list[dict[str, Any]]] = {} for row in rows: budget_type = normalize_text(row.get("budget_type")) target_type = "exec_budget" if budget_type == "exec_budget" else "task_plan" key = (normalize_text(row.get("support_dept_code")), target_type) grouped.setdefault(key, []).append(row) selected: list[dict[str, Any]] = [] for (_support_dept_code, target_type), candidates in grouped.items(): approved = [row for row in candidates if int(row.get("is_approved") or 0) == 1] pending = [row for row in candidates if _is_satis_pending_approval_status(row.get("approval_status"))] base = max(approved, key=_satis_revision_sort_key) if approved else None pending_latest = max(pending, key=_satis_revision_sort_key) if pending else None chosen = base projection_mode = "approved_latest" is_provisional = 0 note = "" if include_pending and pending_latest and ( not base or _satis_revision_sort_key(pending_latest) >= _satis_revision_sort_key(base) ): chosen = pending_latest projection_mode = "pending_provisional" is_provisional = 1 note = "승인 중인 최신 차수를 가반영했습니다. 승인 완료 후 재반영이 필요합니다." elif not chosen and pending_latest and include_pending: chosen = pending_latest projection_mode = "pending_provisional" is_provisional = 1 note = "승인 완료 차수가 없어 승인 중인 차수를 가반영했습니다." if not chosen: continue chosen["target_budget_type"] = target_type chosen["projection_mode"] = projection_mode chosen["is_provisional"] = is_provisional chosen["projection_note"] = note selected.append(chosen) return selected def _aggregate_satis_budget_entries_to_master_projects(conn: Any) -> dict[str, Any]: master_rows = [ dict(row) for row in conn.execute( text( """ SELECT DISTINCT COALESCE(NULLIF(linked_main_project_code, ''), own_master_project_code) AS master_code FROM satis_project_code_links WHERE mapping_status IN ('confirmed', 'exception') AND COALESCE(NULLIF(linked_main_project_code, ''), own_master_project_code) <> '' AND COALESCE(NULLIF(linked_main_project_code, ''), own_master_project_code) GLOB '[09][0-9][0-9][0-9][0-9][0-9]' UNION SELECT support_dept_code AS master_code FROM project_status WHERE support_dept_code GLOB '[09][0-9][0-9][0-9][0-9][0-9]' ORDER BY master_code """ ) ).mappings().fetchall() ] aggregated_project_count = 0 task_rows_inserted = 0 exec_rows_inserted = 0 status_rows: list[dict[str, Any]] = [] for master_row in master_rows: master_code = normalize_text(master_row.get("master_code")) if not master_code: continue source_codes = [ normalize_text(row.get("local_project_code")) for row in conn.execute( text( """ SELECT local_project_code FROM satis_project_code_links WHERE mapping_status IN ('confirmed', 'exception') AND local_project_code <> 'ZZZZZZ' AND ( own_master_project_code = :master_code OR linked_main_project_code = :master_code OR cost_project_code = :master_code ) ORDER BY CASE WHEN local_project_code = :master_code THEN 0 ELSE 1 END, local_project_code """ ), {"master_code": master_code}, ).mappings().fetchall() if normalize_text(row.get("local_project_code")) ] if master_code not in source_codes: source_codes.insert(0, master_code) source_codes = list(dict.fromkeys(source_codes)) child_codes = [code for code in source_codes if code != master_code] child_exec_count = int( conn.execute( text( """ SELECT COUNT(*) FROM project_exec_budget_entries WHERE support_dept_code IN :source_codes AND COALESCE(source_support_dept_code, support_dept_code) = support_dept_code """ ).bindparams(bindparam("source_codes", expanding=True)), {"source_codes": child_codes or ["__NO_SOURCE__"]}, ).scalar() or 0 ) child_task_count = int( conn.execute( text( """ SELECT COUNT(*) FROM project_task_plan_entries WHERE support_dept_code IN :source_codes AND COALESCE(source_support_dept_code, support_dept_code) = support_dept_code """ ).bindparams(bindparam("source_codes", expanding=True)), {"source_codes": child_codes or ["__NO_SOURCE__"]}, ).scalar() or 0 ) effective_exec_sources = child_codes if child_exec_count else [master_code] effective_task_sources = child_codes if child_task_count else [master_code] exec_source_rows = [ dict(row) for row in conn.execute( text( """ SELECT * FROM project_exec_budget_entries WHERE support_dept_code IN :source_codes AND COALESCE(source_support_dept_code, support_dept_code) = support_dept_code ORDER BY support_dept_code, position, id """ ).bindparams(bindparam("source_codes", expanding=True)), {"source_codes": effective_exec_sources or ["__NO_SOURCE__"]}, ).mappings().fetchall() ] task_source_rows = [ dict(row) for row in conn.execute( text( """ SELECT * FROM project_task_plan_entries WHERE support_dept_code IN :source_codes AND COALESCE(source_support_dept_code, support_dept_code) = support_dept_code ORDER BY support_dept_code, position, id """ ).bindparams(bindparam("source_codes", expanding=True)), {"source_codes": effective_task_sources or ["__NO_SOURCE__"]}, ).mappings().fetchall() ] if not exec_source_rows and not task_source_rows: continue conn.execute( text("DELETE FROM project_exec_budget_entries WHERE support_dept_code = :master_code"), {"master_code": master_code}, ) conn.execute( text("DELETE FROM project_task_plan_entries WHERE support_dept_code = :master_code"), {"master_code": master_code}, ) exec_amount_total = 0.0 for position, row in enumerate(exec_source_rows): source_support_dept_code = normalize_text(row.get("source_support_dept_code")) or normalize_text(row.get("support_dept_code")) amount = normalize_amount(row.get("amount")) 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, source_support_dept_code, source_project_code, source_revision_id, updated_at ) VALUES ( :support_dept_code, :position, :group_name, :grade, :hours, :rate_year, :dept_name, :work_name, :account_code, :account_name, :amount, :source_support_dept_code, :source_project_code, :source_revision_id, CURRENT_TIMESTAMP ) """ ), { "support_dept_code": master_code, "position": position, "group_name": normalize_text(row.get("group_name")), "grade": normalize_text(row.get("grade")), "hours": normalize_text(row.get("hours")), "rate_year": normalize_text(row.get("rate_year")), "dept_name": normalize_text(row.get("dept_name")), "work_name": normalize_text(row.get("work_name")), "account_code": normalize_text(row.get("account_code")), "account_name": normalize_text(row.get("account_name")), "amount": amount, "source_support_dept_code": source_support_dept_code, "source_project_code": normalize_text(row.get("source_project_code")), "source_revision_id": int(row.get("source_revision_id") or 0), }, ) exec_amount_total += amount exec_rows_inserted += 1 task_amount_total = 0.0 for position, row in enumerate(task_source_rows): source_support_dept_code = normalize_text(row.get("source_support_dept_code")) or normalize_text(row.get("support_dept_code")) amount = normalize_amount(row.get("amount")) conn.execute( text( """ INSERT INTO project_task_plan_entries ( support_dept_code, position, group_name, dept_name, work_name, amount, source_support_dept_code, source_project_code, source_revision_id, updated_at ) VALUES ( :support_dept_code, :position, :group_name, :dept_name, :work_name, :amount, :source_support_dept_code, :source_project_code, :source_revision_id, CURRENT_TIMESTAMP ) """ ), { "support_dept_code": master_code, "position": position, "group_name": normalize_text(row.get("group_name")), "dept_name": normalize_text(row.get("dept_name")), "work_name": normalize_text(row.get("work_name")), "amount": amount, "source_support_dept_code": source_support_dept_code, "source_project_code": normalize_text(row.get("source_project_code")), "source_revision_id": int(row.get("source_revision_id") or 0), }, ) task_amount_total += amount task_rows_inserted += 1 if exec_source_rows: actual_exec_source_codes = list( dict.fromkeys( normalize_text(row.get("source_support_dept_code")) or normalize_text(row.get("support_dept_code")) for row in exec_source_rows if normalize_text(row.get("source_support_dept_code")) or normalize_text(row.get("support_dept_code")) ) ) conn.execute( text( """ INSERT INTO satis_project_budget_projection_status ( support_dept_code, project_code, project_name, budget_type, revision_id, revision_no, approval_status, projection_mode, is_provisional, line_count, amount_total, note, projected_at ) VALUES ( :support_dept_code, :project_code, :project_name, 'exec_budget', 0, '', '', 'master_aggregate', 0, :line_count, :amount_total, :note, CURRENT_TIMESTAMP ) ON CONFLICT(support_dept_code, budget_type) DO UPDATE SET project_code = excluded.project_code, project_name = excluded.project_name, revision_id = excluded.revision_id, revision_no = excluded.revision_no, approval_status = excluded.approval_status, projection_mode = excluded.projection_mode, is_provisional = excluded.is_provisional, line_count = excluded.line_count, amount_total = excluded.amount_total, note = excluded.note, projected_at = CURRENT_TIMESTAMP """ ), { "support_dept_code": master_code, "project_code": master_code, "project_name": "", "line_count": len(exec_source_rows), "amount_total": exec_amount_total, "note": f"연계 차수 프로젝트 예산 합산: {', '.join(actual_exec_source_codes)}", }, ) status_rows.append( { "support_dept_code": master_code, "budget_type": "exec_budget", "line_count": len(exec_source_rows), "amount_total": exec_amount_total, "source_codes": actual_exec_source_codes, } ) if task_source_rows: actual_task_source_codes = list( dict.fromkeys( normalize_text(row.get("source_support_dept_code")) or normalize_text(row.get("support_dept_code")) for row in task_source_rows if normalize_text(row.get("source_support_dept_code")) or normalize_text(row.get("support_dept_code")) ) ) conn.execute( text( """ INSERT INTO satis_project_budget_projection_status ( support_dept_code, project_code, project_name, budget_type, revision_id, revision_no, approval_status, projection_mode, is_provisional, line_count, amount_total, note, projected_at ) VALUES ( :support_dept_code, :project_code, :project_name, 'task_plan', 0, '', '', 'master_aggregate', 0, :line_count, :amount_total, :note, CURRENT_TIMESTAMP ) ON CONFLICT(support_dept_code, budget_type) DO UPDATE SET project_code = excluded.project_code, project_name = excluded.project_name, revision_id = excluded.revision_id, revision_no = excluded.revision_no, approval_status = excluded.approval_status, projection_mode = excluded.projection_mode, is_provisional = excluded.is_provisional, line_count = excluded.line_count, amount_total = excluded.amount_total, note = excluded.note, projected_at = CURRENT_TIMESTAMP """ ), { "support_dept_code": master_code, "project_code": master_code, "project_name": "", "line_count": len(task_source_rows), "amount_total": task_amount_total, "note": f"연계 차수 프로젝트 예산 합산: {', '.join(actual_task_source_codes)}", }, ) status_rows.append( { "support_dept_code": master_code, "budget_type": "task_plan", "line_count": len(task_source_rows), "amount_total": task_amount_total, "source_codes": actual_task_source_codes, } ) aggregated_project_count += 1 sync_project_status_cache_row(conn, master_code) return { "aggregated_project_count": aggregated_project_count, "task_rows_inserted": task_rows_inserted, "exec_rows_inserted": exec_rows_inserted, "status_rows": status_rows[:50], } def _project_satis_budget_to_current_entries(payload: dict[str, Any] | None = None) -> dict[str, Any]: payload = payload or {} include_pending = bool(payload.get("include_pending", True)) init_db() selected_count = 0 projected_projects: set[str] = set() task_rows_inserted = 0 exec_rows_inserted = 0 provisional_count = 0 skipped: list[dict[str, Any]] = [] projected_status_rows: list[dict[str, Any]] = [] with engine.begin() as conn: selected_revisions = _select_satis_projection_revisions(conn, include_pending) target_keys = { ( normalize_text(row.get("support_dept_code")), normalize_text(row.get("target_budget_type")), ) for row in selected_revisions if normalize_text(row.get("support_dept_code")) } for support_dept_code, target_budget_type in sorted(target_keys): if target_budget_type == "exec_budget": conn.execute( text("DELETE FROM project_exec_budget_entries WHERE support_dept_code = :support_dept_code"), {"support_dept_code": support_dept_code}, ) elif target_budget_type == "task_plan": conn.execute( text("DELETE FROM project_task_plan_entries WHERE support_dept_code = :support_dept_code"), {"support_dept_code": support_dept_code}, ) for revision in selected_revisions: revision_id = int(revision.get("id") or 0) support_dept_code = normalize_text(revision.get("support_dept_code")) if not revision_id or not support_dept_code: skipped.append({"revision_id": revision_id, "reason": "프로젝트 매핑이 없습니다."}) continue target_budget_type = normalize_text(revision.get("target_budget_type")) selected_count += 1 projected_projects.add(support_dept_code) if int(revision.get("is_provisional") or 0): provisional_count += 1 line_count = 0 amount_total = 0.0 if target_budget_type == "exec_budget": lines = conn.execute( text( """ SELECT * FROM satis_project_exec_budget_lines WHERE revision_id = :revision_id ORDER BY line_no, id """ ), {"revision_id": revision_id}, ).mappings().fetchall() for position, line in enumerate(lines): amount = normalize_amount(line.get("amount")) 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, source_support_dept_code, source_project_code, source_revision_id, updated_at ) VALUES ( :support_dept_code, :position, :group_name, :grade, :hours, :rate_year, :dept_name, :work_name, :account_code, :account_name, :amount, :source_support_dept_code, :source_project_code, :source_revision_id, CURRENT_TIMESTAMP ) """ ), { "support_dept_code": support_dept_code, "position": position, "group_name": normalize_text(line.get("group_name")) or "satis_exec_budget", "grade": normalize_text(line.get("grade")), "hours": normalize_text(line.get("hours")), "rate_year": normalize_text(line.get("rate_year")), "dept_name": normalize_text(line.get("dept_name")), "work_name": normalize_text(line.get("work_name")), "account_code": normalize_text(line.get("account_code")), "account_name": normalize_text(line.get("account_name")), "amount": amount, "source_support_dept_code": support_dept_code, "source_project_code": normalize_text(revision.get("project_code")), "source_revision_id": revision_id, }, ) line_count += 1 amount_total += amount exec_rows_inserted += 1 else: lines = conn.execute( text( """ SELECT * FROM satis_project_task_plan_budget_lines WHERE revision_id = :revision_id ORDER BY line_no, id """ ), {"revision_id": revision_id}, ).mappings().fetchall() for position, line in enumerate(lines): amount = normalize_amount(line.get("amount")) conn.execute( text( """ INSERT INTO project_task_plan_entries ( support_dept_code, position, group_name, dept_name, work_name, amount, source_support_dept_code, source_project_code, source_revision_id, updated_at ) VALUES ( :support_dept_code, :position, :group_name, :dept_name, :work_name, :amount, :source_support_dept_code, :source_project_code, :source_revision_id, CURRENT_TIMESTAMP ) """ ), { "support_dept_code": support_dept_code, "position": position, "group_name": normalize_text(line.get("group_name")) or "satis_task_plan", "dept_name": normalize_text(line.get("dept_name")), "work_name": normalize_text(line.get("work_name")), "amount": amount, "source_support_dept_code": support_dept_code, "source_project_code": normalize_text(revision.get("project_code")), "source_revision_id": revision_id, }, ) line_count += 1 amount_total += amount task_rows_inserted += 1 conn.execute( text( """ INSERT INTO satis_project_budget_projection_status ( support_dept_code, project_code, project_name, budget_type, revision_id, revision_no, approval_status, projection_mode, is_provisional, line_count, amount_total, note, projected_at ) VALUES ( :support_dept_code, :project_code, :project_name, :budget_type, :revision_id, :revision_no, :approval_status, :projection_mode, :is_provisional, :line_count, :amount_total, :note, CURRENT_TIMESTAMP ) ON CONFLICT(support_dept_code, budget_type) DO UPDATE SET project_code = excluded.project_code, project_name = excluded.project_name, revision_id = excluded.revision_id, revision_no = excluded.revision_no, approval_status = excluded.approval_status, projection_mode = excluded.projection_mode, is_provisional = excluded.is_provisional, line_count = excluded.line_count, amount_total = excluded.amount_total, note = excluded.note, projected_at = CURRENT_TIMESTAMP """ ), { "support_dept_code": support_dept_code, "project_code": normalize_text(revision.get("project_code")), "project_name": normalize_text(revision.get("project_name")), "budget_type": target_budget_type, "revision_id": revision_id, "revision_no": normalize_text(revision.get("revision_no")), "approval_status": normalize_text(revision.get("approval_status")), "projection_mode": normalize_text(revision.get("projection_mode")), "is_provisional": int(revision.get("is_provisional") or 0), "line_count": line_count, "amount_total": amount_total, "note": normalize_text(revision.get("projection_note")), }, ) projected_status_rows.append( { "support_dept_code": support_dept_code, "project_code": normalize_text(revision.get("project_code")), "budget_type": target_budget_type, "revision_no": normalize_text(revision.get("revision_no")), "approval_status": normalize_text(revision.get("approval_status")), "projection_mode": normalize_text(revision.get("projection_mode")), "line_count": line_count, "amount_total": amount_total, } ) for support_dept_code in sorted(projected_projects): sync_project_status_cache_row(conn, support_dept_code) aggregate_result = _aggregate_satis_budget_entries_to_master_projects(conn) return { "status": "ok", "message": f"승인 최신 차수 기준으로 {len(projected_projects):,}개 프로젝트의 Satis 예산을 기존 입력 테이블에 반영했습니다.", "include_pending": include_pending, "selected_revisions": selected_count, "project_count": len(projected_projects), "task_rows_inserted": task_rows_inserted, "exec_rows_inserted": exec_rows_inserted, "provisional_count": provisional_count, "skipped": skipped[:20], "projected": projected_status_rows[:50], "master_aggregate": aggregate_result, "projection_status_table": "satis_project_budget_projection_status", } def _run_satis_budget_full_sync(payload: dict[str, Any]) -> dict[str, Any]: _hanmac_erp_web_credentials(payload) _hanmac_erp_mysql_credentials(payload) raw_result = _sync_satis_budget_raw_rows(payload) normalize_result = _normalize_satis_budget_raw_rows({}) projection_result = _project_satis_budget_to_current_entries({"include_pending": True}) return { "status": "ok", "message": "Satis 예산 원본 저장, 정규화, 승인 최신 차수 반영을 순차 실행했습니다.", "raw": raw_result, "normalize": normalize_result, "projection": projection_result, "inserted_or_updated_rows": int(raw_result.get("inserted_or_updated_rows") or 0), "normalized_revisions": int(normalize_result.get("normalized_revisions") or 0), "project_count": int(projection_result.get("project_count") or 0), "task_rows_inserted": int(projection_result.get("task_rows_inserted") or 0), "exec_rows_inserted": int(projection_result.get("exec_rows_inserted") or 0), "provisional_count": int(projection_result.get("provisional_count") or 0), } HANMAC_SATIS_WEB_BUDGET_KEYWORDS = ( "프로젝트개요관리", "과업수행계획서작성", "실행계획서작성", "과업수행계획서변경차수관리", "과업수행계획서", "실행계획서", "예산", "승인", "차수", ) def _extract_satis_web_links(base_url: str, body: str) -> list[str]: links: set[str] = set() static_ext_pattern = re.compile(r"\.(?:css|js|png|gif|jpg|jpeg|ico|bmp|svg|woff|ttf)(?:$|\?)", re.IGNORECASE) for match in re.findall(r"""(?:href|src|action)\s*=\s*["']([^"']+)["']""", body, flags=re.IGNORECASE): link = normalize_text(match) if not link or link.startswith(("#", "javascript:", "mailto:")): continue absolute = urljoin(base_url, link) if static_ext_pattern.search(urlparse(absolute).path): continue parsed = urlparse(absolute) if parsed.netloc and parsed.netloc != "erp.hanmaceng.co.kr": continue if "/satis/" in parsed.path.lower() or absolute.lower().startswith(HANMAC_SATIS_ERP_BASE_URL.lower()): links.add(absolute) for match in re.findall(r"""(?:open|go|url|href|src|action)[A-Za-z0-9_]*\s*\(\s*["']([^"']+)["']""", body, flags=re.IGNORECASE): link = normalize_text(match) if not link or link.startswith(("#", "javascript:", "mailto:")): continue absolute = urljoin(base_url, link) if static_ext_pattern.search(urlparse(absolute).path): continue parsed = urlparse(absolute) if parsed.netloc and parsed.netloc != "erp.hanmaceng.co.kr": continue if "/satis/" in parsed.path.lower() or "controller" in parsed.path.lower(): links.add(absolute) return sorted(links) def _extract_satis_amount_candidates(body: str, limit: int = 80) -> list[dict[str, Any]]: candidates: list[dict[str, Any]] = [] compact = re.sub(r"\s+", " ", body) for match in re.finditer(r"(?= limit: break return candidates def _extract_satis_menu_items(body: str) -> list[dict[str, Any]]: menu_items: list[dict[str, Any]] = [] for match in re.finditer(r"""var\s+(list_data\d*)\s*=\s*jQuery\.parseJSON\(\s*'(.+?)'\s*\);""", body, flags=re.IGNORECASE | re.DOTALL): variable_name = normalize_text(match.group(1)) raw_json = match.group(2) try: items = json.loads(raw_json) except Exception: try: items = json.loads(raw_json.encode("utf-8").decode("unicode_escape")) except Exception: continue if not isinstance(items, list): continue for item in items: if not isinstance(item, dict): continue name = normalize_text(item.get("item04")) primary_url = normalize_text(item.get("item05")) secondary_url = normalize_text(item.get("item07")) if not name and not primary_url and not secondary_url: continue menu_items.append( { "source_variable": variable_name, "category1": normalize_text(item.get("item01")), "category2": normalize_text(item.get("item02")), "category3": normalize_text(item.get("item03")), "name": name, "primary_url": primary_url, "secondary_url": secondary_url, "screen_id": normalize_text(item.get("item08")), "menu_code": normalize_text(item.get("item10")), } ) return menu_items def _extract_satis_form_defaults(body: str) -> dict[str, str]: defaults: dict[str, str] = {} for match in re.finditer(r"]+>", body, flags=re.IGNORECASE): tag = match.group(0) name_match = re.search(r"""name\s*=\s*["']?([^"'\s>]+)""", tag, flags=re.IGNORECASE) if not name_match: continue value_match = re.search(r"""value\s*=\s*["']([^"']*)["']""", tag, flags=re.IGNORECASE) if not value_match: value_match = re.search(r"""value\s*=\s*([^"'\s>]*)""", tag, flags=re.IGNORECASE) defaults[name_match.group(1)] = value_match.group(1) if value_match else "" for match in re.finditer(r"]*)>(.*?)", body, flags=re.IGNORECASE | re.DOTALL): attributes, options_html = match.groups() name_match = re.search(r"""name\s*=\s*["']?([^"'\s>]+)""", attributes, flags=re.IGNORECASE) if not name_match: continue option_matches = list( re.finditer(r"]*)>(.*?)", options_html, flags=re.IGNORECASE | re.DOTALL) ) if not option_matches: defaults[name_match.group(1)] = "" continue selected_option = next( (option for option in option_matches if re.search(r"\bselected\b", option.group(1), flags=re.IGNORECASE)), option_matches[0], ) option_attributes, option_label = selected_option.groups() value_match = re.search( r"""value\s*=\s*(?:"([^"]*)"|'([^']*)'|([^"'\s>]*))""", option_attributes, flags=re.IGNORECASE, ) if value_match: defaults[name_match.group(1)] = next( (value for value in value_match.groups() if value is not None), "", ) else: defaults[name_match.group(1)] = re.sub(r"<[^>]+>", "", option_label).strip() return defaults def _relax_satis_search_defaults(action_mode: str, main_action: str, defaults: Mapping[str, Any]) -> dict[str, str]: relaxed = {normalize_text(key): normalize_text(value) for key, value in defaults.items() if normalize_text(key)} if action_mode == "SCREEN_01" and main_action == "Ajax_01": # 프로젝트 개요 화면의 초기 선택값은 사용자의 부서/진행 사업으로 제한된다. # 예산 전체 수집에서는 ERP 화면이 제공하는 '%' 값을 사용해 과거·종료 사업도 조회한다. for key in ("input_select_01", "input_select_02", "input_select_03", "input_select_04", "input_select_05"): if key in relaxed: relaxed[key] = "%" for key in ("input_item_01", "input_item_02", "input_item_03"): if key in relaxed: relaxed[key] = "" return relaxed def _extract_satis_ajax_actions(body: str) -> list[str]: actions = set(re.findall(r"""["']((?:HTML_)?Ajax_\d+|HTML_Page_\d+|Plan_chg)["']""", body)) actions.update(re.findall(r"""MainAction\s*=\s*["']((?:HTML_)?Ajax_\d+|HTML_Page_\d+|Plan_chg)["']""", body)) actions.update(re.findall(r"""MainAction['"]?\s*:\s*["']((?:HTML_)?Ajax_\d+|HTML_Page_\d+|Plan_chg)["']""", body)) return sorted(actions) def _build_satis_capture( *, source_url: str, request_method: str, http_status: int, final_url: str, body: str, ) -> dict[str, Any]: title_match = re.search(r"]*>(.*?)", body, flags=re.IGNORECASE | re.DOTALL) title = re.sub(r"\s+", " ", title_match.group(1)).strip() if title_match else "" matched_keywords = [keyword for keyword in HANMAC_SATIS_WEB_BUDGET_KEYWORDS if keyword in body or keyword in title or keyword in final_url] links = _extract_satis_web_links(final_url or source_url, body) amount_candidates = _extract_satis_amount_candidates(body) body_hash = hashlib.sha256(body.encode("utf-8", "replace")).hexdigest() capture_key = hashlib.sha256(f"{request_method}|{source_url}|{final_url}|{body_hash}".encode("utf-8")).hexdigest() return { "capture_key": capture_key, "source_url": source_url, "request_method": request_method, "http_status": http_status, "final_url": final_url, "page_title": title[:300], "matched_keywords": ", ".join(matched_keywords), "internal_links": links[:80], "amount_candidates": amount_candidates[:80], "body_preview": body[:2_000_000], "body_hash": body_hash, } def _json_loads_loose(value: str) -> Any: text_value = normalize_text(value) if not text_value: return None try: return json.loads(text_value) except Exception: pass start_candidates = [idx for idx in (text_value.find("["), text_value.find("{")) if idx >= 0] if not start_candidates: return None start = min(start_candidates) end = max(text_value.rfind("]"), text_value.rfind("}")) if end <= start: return None try: return json.loads(text_value[start : end + 1]) except Exception: return None def _flatten_satis_json_rows(parsed: Any) -> list[dict[str, Any]]: if isinstance(parsed, list): return [item for item in parsed if isinstance(item, dict)] if not isinstance(parsed, dict): return [] for key in ("rows", "data", "list_data", "list", "records"): value = parsed.get(key) if isinstance(value, list): rows: list[dict[str, Any]] = [] for item in value: if isinstance(item, dict): if isinstance(item.get("cell"), list): rows.append({f"cell{idx + 1:02d}": cell for idx, cell in enumerate(item["cell"])}) else: rows.append(item) return rows return [parsed] if parsed else [] def _infer_satis_web_budget_type(action_mode: str, main_action: str, source_url: str, row: Mapping[str, Any]) -> str: haystack = " ".join( [ normalize_text(action_mode), normalize_text(main_action), normalize_text(source_url), " ".join(normalize_text(value) for value in row.values()), ] ) if "SCREEN_02" in haystack or "실행계획" in haystack or "exec" in haystack.lower(): return "exec_budget" if "SCREEN_01" in haystack or "프로젝트개요" in haystack: return "project_overview" if "과업수행" in haystack or "SCREEN_03" in haystack or "SCREEN_06" in haystack: return "task_plan" return "project_budget" def _pick_satis_web_value(row: Mapping[str, Any], keys: Sequence[str]) -> str: lowered = {normalize_text(key).lower(): value for key, value in row.items()} for key in keys: key_l = key.lower() if key_l in lowered: return normalize_text(lowered.get(key_l)) return "" def _promote_satis_web_captures_to_raw_rows(captures: Sequence[Mapping[str, Any]]) -> dict[str, Any]: inserted_or_updated = 0 parsed_capture_count = 0 skipped_capture_count = 0 with engine.begin() as conn: conn.execute(text("DELETE FROM satis_project_budget_raw_rows WHERE source_database = 'satis_web'")) for capture in captures: body = normalize_text(capture.get("body_preview")) parsed = _json_loads_loose(body) rows = _flatten_satis_json_rows(parsed) if not rows: skipped_capture_count += 1 continue parsed_capture_count += 1 source_url = normalize_text(capture.get("source_url")) query = parse_qs(urlparse(source_url).query) action_mode = normalize_text((query.get("ActionMode") or [""])[0]) main_action = normalize_text((query.get("MainAction") or [""])[0]) source_table = f"{action_mode or 'WEB'}:{main_action or normalize_text(capture.get('request_method')) or 'response'}" for row_index, row in enumerate(rows): normalized_row = {normalize_text(key): value for key, value in row.items() if normalize_text(key)} if action_mode == "SCREEN_01" and main_action in ("Ajax_01", "HTML_Ajax_01"): project_code = _pick_satis_web_value(normalized_row, ("view02", "view28", "item02", "item01")) project_name = _pick_satis_web_value(normalized_row, ("view04", "item04", "item02")) revision_no = _pick_satis_web_value(normalized_row, ("item17",)) approval_status = _pick_satis_web_value(normalized_row, ("item18",)) preferred_amount_keys = ("item10",) elif action_mode == "SCREEN_03" and main_action == "HTML_Ajax_01": project_code = _pick_satis_web_value(normalized_row, ("view02", "view28", "item03", "item02")) project_name = _pick_satis_web_value(normalized_row, ("view04", "item04")) revision_no = _pick_satis_web_value(normalized_row, ("item17",)) approval_status = _pick_satis_web_value(normalized_row, ("item18",)) preferred_amount_keys = ("item10",) elif action_mode == "SCREEN_03" and main_action in ("Ajax_02", "Ajax_03"): project_code = _pick_satis_web_value(normalized_row, ("project_code", "proj_code", "item02", "item01")) project_name = _pick_satis_web_value(normalized_row, ("project_name", "proj_name")) revision_no = _pick_satis_web_value(normalized_row, ("degree", "item03", "item04")) approval_status = " ".join( value for value in ( _pick_satis_web_value(normalized_row, ("item07",)), f"확정:{_pick_satis_web_value(normalized_row, ('item08',))}" if _pick_satis_web_value(normalized_row, ("item08",)) else "", ) if value ) preferred_amount_keys = ("item12", "item13", "item14") if main_action == "Ajax_03" else () elif action_mode == "SCREEN_06" and main_action == "Ajax_00": project_code = _pick_satis_web_value(normalized_row, ("item02",)) project_name = "" revision_no = _pick_satis_web_value(normalized_row, ("item03",)) approval_status = "" preferred_amount_keys = ("item10", "item40") elif action_mode == "SCREEN_06" and main_action == "Ajax_03": project_code = _pick_satis_web_value(normalized_row, ("item02",)) project_name = "" revision_no = _pick_satis_web_value(normalized_row, ("item03",)) approval_status = "" preferred_amount_keys = ("item08", "item09") elif action_mode == "SCREEN_02" and main_action in ("Ajax_01", "Ajax_02", "Ajax_03", "Ajax_04"): project_code = _pick_satis_web_value( normalized_row, ("item01",) if main_action == "Ajax_03" else ("item02",), ) project_name = "" revision_no = _pick_satis_web_value( normalized_row, ("item02",) if main_action == "Ajax_03" else ("item03",), ) approval_status = "" preferred_amount_keys = { "Ajax_01": ("item08",), "Ajax_02": ("item08",), "Ajax_03": ("item07",), "Ajax_04": ("item08",), }[main_action] else: project_code = _pick_satis_web_value( normalized_row, ("project_code", "proj_code", "proj_cd", "view02", "view28", "item02", "cell02"), ) project_name = _pick_satis_web_value( normalized_row, ("project_name", "proj_name", "proj_nm", "view04", "item03", "cell03"), ) revision_no = _pick_satis_web_value( normalized_row, ("revision_no", "degree", "cha", "item01", "item02", "cell01", "cell02"), ) approval_status = _pick_satis_web_value( normalized_row, ("approval_status", "status", "state", "item07", "item12", "cell07"), ) preferred_amount_keys = () project_code = project_code or normalize_text( (query.get("proj_code") or query.get("input_item_01") or [""])[0] ) project_name = project_name or normalize_text((query.get("input_item_02") or [""])[0]) revision_no = revision_no or normalize_text( (query.get("degree") or query.get("input_item_03") or [""])[0] ) approval_status = approval_status or normalize_text((query.get("input_item_04") or [""])[0]) amount_values = { key: _safe_float(value) for key, value in normalized_row.items() if _safe_float(value) != 0 and re.search(r"(amount|amt|price|cost|money|budget|sum|total|금액|예산|합계|계약|수금|잔액|item0[4-9]|item1[0-9]|view)", key, flags=re.IGNORECASE) } if preferred_amount_keys: amount_values = { key: _safe_float(normalized_row.get(key)) for key in preferred_amount_keys if _safe_float(normalized_row.get(key)) != 0 } if ( (action_mode == "SCREEN_03" and main_action == "Ajax_02") or (action_mode == "SCREEN_06" and main_action in ("Ajax_01", "Ajax_02", "Ajax_03", "Ajax_04", "Ajax_05")) or (action_mode == "SCREEN_02" and main_action in ("Ajax_02", "Ajax_05")) ): amount_values = {} amount_total = sum(amount_values.values()) is_revision_metadata = action_mode == "SCREEN_03" and main_action == "Ajax_02" and project_code if amount_total == 0 and not is_revision_metadata and not any( "합계" in normalize_text(value) or "계약" in normalize_text(value) for value in normalized_row.values() ): continue budget_type = _infer_satis_web_budget_type(action_mode, main_action, source_url, normalized_row) if action_mode == "SCREEN_03" and main_action == "Ajax_03": budget_type = "project_overview" inferred_columns = { "source_url": source_url, "request_method": normalize_text(capture.get("request_method")), "action_mode": action_mode, "main_action": main_action, "amount_columns": sorted(amount_values.keys()), "web_capture_id": capture.get("id"), } raw_payload_json = json.dumps(normalized_row, ensure_ascii=False, sort_keys=True, default=_json_default) source_hash = hashlib.sha256( json.dumps( { "source": "satis_web", "url": source_url, "method": normalize_text(capture.get("request_method")), "row": normalized_row, }, ensure_ascii=False, sort_keys=True, default=_json_default, ).encode("utf-8") ).hexdigest() conn.execute( text( """ INSERT OR REPLACE INTO satis_project_budget_raw_rows ( source_system, source_database, source_table, source_row_index, budget_type, project_code, project_name, revision_no, approval_status, amount_total, amount_values_json, inferred_columns_json, raw_payload_json, source_hash, synced_at ) VALUES ( 'satis', 'satis_web', :source_table, :source_row_index, :budget_type, :project_code, :project_name, :revision_no, :approval_status, :amount_total, :amount_values_json, :inferred_columns_json, :raw_payload_json, :source_hash, CURRENT_TIMESTAMP ) """ ), { "source_table": source_table, "source_row_index": row_index, "budget_type": budget_type, "project_code": project_code, "project_name": project_name, "revision_no": revision_no, "approval_status": approval_status, "amount_total": amount_total, "amount_values_json": json.dumps(amount_values, ensure_ascii=False, sort_keys=True, default=_json_default), "inferred_columns_json": json.dumps(inferred_columns, ensure_ascii=False, sort_keys=True, default=_json_default), "raw_payload_json": raw_payload_json, "source_hash": source_hash, }, ) inserted_or_updated += 1 return { "parsed_capture_count": parsed_capture_count, "skipped_capture_count": skipped_capture_count, "inserted_or_updated_rows": inserted_or_updated, } def _target_satis_budget_menu_items(menu_items: list[dict[str, Any]]) -> list[dict[str, Any]]: targets: list[dict[str, Any]] = [] target_names = ("프로젝트개요관리", "과업수행계획서작성", "실행계획서작성", "과업수행계획서변경차수관리") for item in menu_items: haystack = " ".join( normalize_text(item.get(key)) for key in ("category1", "category2", "category3", "name", "primary_url", "secondary_url", "screen_id") ) if any(target in haystack for target in target_names): targets.append(item) continue if "프로젝트" in haystack and any(token in haystack for token in ("과업수행", "실행계획", "예산", "변경차수")): targets.append(item) return targets def _satis_web_login_opener(user: str, password: str) -> tuple[Any, CookieJar, dict[str, Any]]: cookie_jar = CookieJar() opener = build_opener(HTTPCookieProcessor(cookie_jar)) login_info: dict[str, Any] = {} login_page_request = UrlRequest( HANMAC_SATIS_ERP_LOGIN_PAGE_URL, headers={"User-Agent": "my-intranet-app/satis-web-budget-collector"}, ) with opener.open(login_page_request, timeout=10) as response: login_info["login_page_status"] = int(getattr(response, "status", 200) or 200) response.read(1000) check_request = UrlRequest( f"{HANMAC_SATIS_ERP_LOGIN_CONTROLLER_URL}?ActionMode=SCREEN_10&MainAction=Ajax_01&SubAction=checktype04&item02=&item03=&userid={quote_plus(user)}&password={quote_plus(password)}", data=b"", headers={"User-Agent": "my-intranet-app/satis-web-budget-collector"}, ) try: with opener.open(check_request, timeout=10) as response: login_info["credential_check_status"] = int(getattr(response, "status", 200) or 200) login_info["credential_check_body"] = response.read(2000).decode("utf-8", "replace").strip()[:200] except Exception as exc: login_info["credential_check_error"] = str(exc) login_request = UrlRequest( HANMAC_SATIS_ERP_LOGIN_CONTROLLER_URL, data=urlencode( { "ActionMode": "SCREEN_10", "MainAction": "Ajax_02", "SubAction": "checktype05", "userid": user, "password": password, "item02": "", "item03": password, } ).encode("utf-8"), headers={ "Content-Type": "application/x-www-form-urlencoded", "User-Agent": "my-intranet-app/satis-web-budget-collector", }, ) with opener.open(login_request, timeout=10) as response: login_info["login_status"] = int(getattr(response, "status", 200) or 200) login_info["login_url"] = str(response.geturl() or "") login_info["login_body_preview"] = response.read(2_000_000).decode("utf-8", "replace")[:500_000] login_info["php_session"] = any(cookie.name == "PHPSESSID" for cookie in cookie_jar) return opener, cookie_jar, login_info def _collect_satis_budget_via_web(payload: dict[str, Any]) -> dict[str, Any]: user, password = _hanmac_erp_web_credentials(payload) try: max_pages = max(5, min(int(payload.get("max_pages") or 40), 120)) except Exception: max_pages = 40 opener, _cookie_jar, login_info = _satis_web_login_opener(user, password) seed_urls = [ HANMAC_SATIS_ERP_BASE_URL, HANMAC_SATIS_ERP_LOGIN_CONTROLLER_URL, f"{HANMAC_SATIS_ERP_BASE_URL}sys/controller/main_controller.php", f"{HANMAC_SATIS_ERP_BASE_URL}sys/controller/Main/Main_controller.php", ] login_body_preview = normalize_text(login_info.get("login_body_preview")) seed_urls.extend(_extract_satis_web_links(HANMAC_SATIS_ERP_LOGIN_CONTROLLER_URL, login_body_preview)[:20]) menu_items = _extract_satis_menu_items(login_body_preview) target_menu_items = _target_satis_budget_menu_items(menu_items) for item in target_menu_items: for key in ("primary_url", "secondary_url"): url = normalize_text(item.get(key)) if url: seed_urls.append(url) queue: list[str] = [] seen: set[str] = set() for url in seed_urls: if url not in seen: queue.append(url) seen.add(url) captures: list[dict[str, Any]] = [] matched_captures: list[dict[str, Any]] = [] errors: list[dict[str, Any]] = [] login_body = normalize_text(login_info.get("login_body_preview")) if login_body: login_capture = _build_satis_capture( source_url=HANMAC_SATIS_ERP_LOGIN_CONTROLLER_URL, request_method="POST", http_status=int(login_info.get("login_status") or 0), final_url=normalize_text(login_info.get("login_url")), body=login_body, ) captures.append(login_capture) if login_capture["matched_keywords"] or login_capture["amount_candidates"] or login_capture["internal_links"]: matched_captures.append(login_capture) for link in login_capture["internal_links"]: if link not in seen: queue.append(link) seen.add(link) while queue and len(captures) < max_pages: url = queue.pop(0) if re.search(r"\.(?:css|js|png|gif|jpg|jpeg|ico|bmp|svg|woff|ttf)(?:$|\?)", urlparse(url).path, flags=re.IGNORECASE): continue try: request = UrlRequest(url, headers={"User-Agent": "my-intranet-app/satis-web-budget-collector"}) with opener.open(request, timeout=10) as response: status = int(getattr(response, "status", 200) or 200) final_url = str(response.geturl() or "") raw = response.read(700_000) body = raw.decode("utf-8", "replace") except Exception as exc: errors.append({"url": url, "error": str(exc)[:300]}) continue capture = _build_satis_capture( source_url=url, request_method="GET", http_status=status, final_url=final_url, body=body, ) links = capture["internal_links"] for link in links: if link not in seen and len(seen) < max_pages * 4: if any(token in link.lower() for token in ("project", "plan", "exec", "budget", "task", "controller", "satis")) or capture["matched_keywords"]: queue.append(link) seen.add(link) captures.append(capture) if capture["matched_keywords"] or capture["amount_candidates"]: matched_captures.append(capture) ajax_seen: set[str] = set() ajax_captures: list[dict[str, Any]] = [] for screen_capture in list(captures): screen_body = normalize_text(screen_capture.get("body_preview")) final_url = normalize_text(screen_capture.get("final_url") or screen_capture.get("source_url")) if "Project_Controller.php" not in final_url and "ProjectMenHour_Controller.php" not in final_url: continue parsed = urlparse(final_url) query = parse_qs(parsed.query) action_mode = normalize_text((query.get("ActionMode") or [""])[0]) if not action_mode: action_match = re.search(r"""var\s+ActionMode\s*=\s*["']([^"']+)""", screen_body) action_mode = normalize_text(action_match.group(1) if action_match else "") if not action_mode: continue controller_url = final_url.split("?", 1)[0] form_defaults = _extract_satis_form_defaults(screen_body) ajax_actions = [ action for action in _extract_satis_ajax_actions(screen_body) if not action.startswith("HTML_Page_") ] for main_action in ajax_actions: params = _relax_satis_search_defaults(action_mode, main_action, form_defaults) params.update( { "ActionMode": action_mode, "MainAction": main_action, "SubAction": "select", "page": "1", "rows": "500", "_search": "false", "sidx": "", "sord": "asc", "nd": str(int(time.time() * 1000)), } ) flat_ajax_url = f"{controller_url}?{urlencode(params)}" query_ajax_url = f"{controller_url}?{urlencode({key: value for key, value in params.items() if key in {'ActionMode', 'MainAction', 'SubAction', 'page', 'rows', '_search', 'sidx', 'sord', 'nd'}})}" nested_jggrid_form = { f"jgGridData[{key}]": value for key, value in params.items() if key not in {"ActionMode", "MainAction", "SubAction", "page", "rows", "_search", "sidx", "sord", "nd"} } request_specs = [ ("GET", flat_ajax_url, None), ("POST", flat_ajax_url, params), ("POST_JGGRID", query_ajax_url, nested_jggrid_form), ] for request_method, ajax_url, post_params in request_specs: ajax_key = f"{request_method}|{ajax_url}|{json.dumps(post_params or {}, ensure_ascii=False, sort_keys=True)}" if ajax_key in ajax_seen: continue ajax_seen.add(ajax_key) try: data = urlencode(post_params).encode("utf-8") if post_params is not None else None headers = {"User-Agent": "my-intranet-app/satis-web-budget-collector"} if data is not None: headers["Content-Type"] = "application/x-www-form-urlencoded" request = UrlRequest(ajax_url, data=data, headers=headers) with opener.open(request, timeout=12) as response: status = int(getattr(response, "status", 200) or 200) response_final_url = str(response.geturl() or "") raw = response.read(2_000_000) ajax_body = raw.decode("utf-8", "replace") except Exception as exc: errors.append({"url": ajax_url, "method": request_method, "error": str(exc)[:300]}) continue ajax_capture = _build_satis_capture( source_url=ajax_url, request_method=request_method, http_status=status, final_url=response_final_url, body=ajax_body, ) ajax_captures.append(ajax_capture) captures.append(ajax_capture) if ajax_capture["matched_keywords"] or ajax_capture["amount_candidates"] or len(ajax_body) > 100: matched_captures.append(ajax_capture) # 현재 포트 DB에 존재하는 프로젝트만 대상으로 차수 및 예산 상세를 순회한다. # support_dept_code는 Satis 프로젝트 코드(Y24196 등)와 동일하게 관리되고 있다. try: max_detail_projects = max(1, min(int(payload.get("max_detail_projects") or 50), 200)) except Exception: max_detail_projects = 50 external_projects: dict[str, str] = {} for capture in captures: parsed_capture_url = urlparse(normalize_text(capture.get("source_url"))) capture_query = parse_qs(parsed_capture_url.query) capture_action_mode = normalize_text((capture_query.get("ActionMode") or [""])[0]) capture_main_action = normalize_text((capture_query.get("MainAction") or [""])[0]) if (capture_action_mode, capture_main_action) not in { ("SCREEN_01", "Ajax_01"), ("SCREEN_01", "HTML_Ajax_01"), ("SCREEN_03", "HTML_Ajax_01"), }: continue for external_row in _flatten_satis_json_rows( _json_loads_loose(normalize_text(capture.get("body_preview"))) ): if capture_action_mode == "SCREEN_03": external_code = _pick_satis_web_value(external_row, ("view02", "view28", "item03", "item02")) external_name = _pick_satis_web_value(external_row, ("view04", "item04")) else: external_code = _pick_satis_web_value(external_row, ("view02", "view28", "item02", "item01")) external_name = _pick_satis_web_value(external_row, ("view04", "item04", "item02")) if external_code and external_name: external_projects[external_code] = external_name def project_name_key(value: Any) -> str: return re.sub(r"[^0-9a-z가-힣]", "", normalize_text(value).lower()) with engine.begin() as conn: local_projects = [ dict(row) for row in conn.execute( text( """ SELECT support_dept_code, support_dept_name FROM project_status WHERE support_dept_code <> '' ORDER BY support_dept_code LIMIT :limit """ ), {"limit": max_detail_projects}, ).mappings().fetchall() ] detail_projects: list[dict[str, str]] = [] for local_project in local_projects: support_dept_code = normalize_text(local_project.get("support_dept_code")) support_dept_name = normalize_text(local_project.get("support_dept_name")) mapped_code = "" mapping_basis = "" register_row = conn.execute( text( """ SELECT * FROM satis_project_code_links WHERE local_project_code = :support_dept_code LIMIT 1 """ ), {"support_dept_code": support_dept_code}, ).mappings().first() if register_row: register_status = normalize_text(register_row.get("mapping_status")) if register_status not in {"common"}: linked_main_code = normalize_text(register_row.get("linked_main_project_code")) own_master_code = normalize_text(register_row.get("own_master_project_code")) if linked_main_code: mapped_code = linked_main_code mapping_basis = "project_code_register:linked_main" elif own_master_code: mapped_code = own_master_code mapping_basis = "project_code_register:own_master" candidates = [support_dept_code] if re.fullmatch(r"X\d{5}", support_dept_code): candidates.insert(0, f"9{support_dept_code[1:]}") if re.fullmatch(r"[YZ]\d{5}", support_dept_code): candidates.insert(0, f"0{support_dept_code[1:]}") if not mapped_code: for candidate in candidates: if candidate in external_projects: mapped_code = candidate mapping_basis = "code_pattern" if candidate != support_dept_code else "exact_code" break if not mapped_code and support_dept_name: local_name_key = project_name_key(support_dept_name) exact_name_matches = [ code for code, name in external_projects.items() if project_name_key(name) == local_name_key ] if len(exact_name_matches) == 1: mapped_code = exact_name_matches[0] mapping_basis = "exact_name" else: scored = sorted( ( SequenceMatcher(None, local_name_key, project_name_key(name)).ratio(), code, ) for code, name in external_projects.items() if project_name_key(name) ) if scored and scored[-1][0] >= 0.88 and ( len(scored) == 1 or scored[-1][0] - scored[-2][0] >= 0.04 ): mapped_code = scored[-1][1] mapping_basis = f"fuzzy_name:{scored[-1][0]:.3f}" if mapped_code: mapped_name = ( external_projects.get(mapped_code) or normalize_text((register_row or {}).get("linked_main_project_name")) or normalize_text((register_row or {}).get("own_master_project_name")) or normalize_text((register_row or {}).get("local_project_name")) or support_dept_name ) conn.execute( text( """ INSERT INTO satis_project_mapping ( erp_project_code, erp_project_name, support_dept_code, mapping_status, mapping_basis, manual_override, updated_at ) VALUES ( :erp_project_code, :erp_project_name, :support_dept_code, 'matched', :mapping_basis, 0, CURRENT_TIMESTAMP ) ON CONFLICT(erp_project_code) DO UPDATE SET erp_project_name = excluded.erp_project_name, support_dept_code = CASE WHEN satis_project_mapping.manual_override = 1 THEN satis_project_mapping.support_dept_code ELSE excluded.support_dept_code END, mapping_status = CASE WHEN satis_project_mapping.manual_override = 1 THEN satis_project_mapping.mapping_status ELSE excluded.mapping_status END, mapping_basis = CASE WHEN satis_project_mapping.manual_override = 1 THEN satis_project_mapping.mapping_basis ELSE excluded.mapping_basis END, updated_at = CURRENT_TIMESTAMP """ ), { "erp_project_code": mapped_code, "erp_project_name": mapped_name, "support_dept_code": support_dept_code, "mapping_basis": mapping_basis, }, ) detail_projects.append( { "project_code": mapped_code, "project_name": mapped_name, "support_dept_code": support_dept_code, } ) detail_capture_count = 0 detail_revision_count = 0 project_controller_url = f"{HANMAC_SATIS_ERP_BASE_URL}sys/controller/Project/Project_Controller.php" def fetch_detail(action_mode: str, main_action: str, params: Mapping[str, Any]) -> dict[str, Any] | None: nonlocal detail_capture_count request_params = { "ActionMode": action_mode, "MainAction": main_action, "SubAction": "select", "page": "1", "rows": "500", "_search": "false", "sidx": "", "sord": "asc", "nd": str(int(time.time() * 1000)), **{normalize_text(key): normalize_text(value) for key, value in params.items() if normalize_text(key)}, } detail_url = f"{project_controller_url}?{urlencode(request_params)}" try: request = UrlRequest(detail_url, headers={"User-Agent": "my-intranet-app/satis-web-budget-collector"}) with opener.open(request, timeout=15) as response: status = int(getattr(response, "status", 200) or 200) response_final_url = str(response.geturl() or "") raw = response.read(4_000_000) detail_body = raw.decode("utf-8", "replace") except Exception as exc: errors.append( { "url": detail_url, "method": "GET_DETAIL", "error": str(exc)[:300], } ) return None detail_capture = _build_satis_capture( source_url=detail_url, request_method="GET_DETAIL", http_status=status, final_url=response_final_url, body=detail_body, ) captures.append(detail_capture) detail_capture_count += 1 if detail_capture["matched_keywords"] or detail_capture["amount_candidates"] or len(detail_body) > 100: matched_captures.append(detail_capture) return detail_capture for project in detail_projects: project_code = normalize_text(project.get("project_code")) project_name = normalize_text(project.get("project_name")) if not project_code: continue revision_capture = fetch_detail( "SCREEN_03", "Ajax_02", { "proj_code": project_code, "input_item_01": project_code, "input_item_02": project_name, "degree_pre": "00", }, ) revision_rows = _flatten_satis_json_rows( _json_loads_loose(normalize_text((revision_capture or {}).get("body_preview"))) ) revisions: list[dict[str, str]] = [] for revision_row in revision_rows: revision_no = _pick_satis_web_value(revision_row, ("item03", "degree", "revision_no")) or "00" revision_status = " ".join( value for value in ( _pick_satis_web_value(revision_row, ("item07", "status")), f"확정:{_pick_satis_web_value(revision_row, ('item08',))}" if _pick_satis_web_value(revision_row, ("item08",)) else "", ) if value ) revision_key = (revision_no, revision_status) if not any((item["revision_no"], item["approval_status"]) == revision_key for item in revisions): revisions.append({"revision_no": revision_no, "approval_status": revision_status}) if not revisions: revisions = [{"revision_no": "00", "approval_status": ""}] detail_revision_count += len(revisions) for revision in revisions: revision_no = normalize_text(revision.get("revision_no")) or "00" approval_status = normalize_text(revision.get("approval_status")) common_params = { "proj_code": project_code, "degree": revision_no, "degree_pre": revision_no, "input_item_01": project_code, "input_item_02": project_name, "input_item_03": revision_no, "input_item_03_name": revision_no, "input_item_04": approval_status, } fetch_detail("SCREEN_03", "Ajax_03", common_params) fetch_detail("SCREEN_06", "Ajax_00", common_params) fetch_detail("SCREEN_06", "Ajax_03", common_params) dept_capture = fetch_detail( "SCREEN_02", "Info_dept", { "input_item_01": project_code, "input_item_03": revision_no, }, ) dept_rows = _flatten_satis_json_rows( _json_loads_loose(normalize_text((dept_capture or {}).get("body_preview"))) ) dept_codes = sorted( { _pick_satis_web_value(row, ("CODE", "code", "item01")) for row in dept_rows if _pick_satis_web_value(row, ("CODE", "code", "item01")) } ) if not dept_codes: dept_codes = ["%"] for dept_code in dept_codes: dept_params = {**common_params, "input_select_01": dept_code} fetch_detail("SCREEN_02", "Ajax_00", dept_params) for main_action in ("Ajax_01", "Ajax_02", "Ajax_03", "Ajax_04"): fetch_detail("SCREEN_02", main_action, dept_params) with engine.begin() as conn: for capture in captures: conn.execute( text( """ INSERT INTO satis_project_budget_web_captures ( capture_key, source_url, request_method, http_status, final_url, page_title, matched_keywords, internal_links_json, amount_candidates_json, body_preview, body_hash, captured_at ) VALUES ( :capture_key, :source_url, :request_method, :http_status, :final_url, :page_title, :matched_keywords, :internal_links_json, :amount_candidates_json, :body_preview, :body_hash, CURRENT_TIMESTAMP ) ON CONFLICT(capture_key) DO UPDATE SET http_status = excluded.http_status, final_url = excluded.final_url, page_title = excluded.page_title, matched_keywords = excluded.matched_keywords, internal_links_json = excluded.internal_links_json, amount_candidates_json = excluded.amount_candidates_json, body_preview = excluded.body_preview, body_hash = excluded.body_hash, captured_at = CURRENT_TIMESTAMP """ ), { **{key: capture[key] for key in ("capture_key", "source_url", "request_method", "http_status", "final_url", "page_title", "matched_keywords", "body_preview", "body_hash")}, "internal_links_json": json.dumps(capture["internal_links"], ensure_ascii=False), "amount_candidates_json": json.dumps(capture["amount_candidates"], ensure_ascii=False), }, ) promoted_result = _promote_satis_web_captures_to_raw_rows(captures) return { "status": "ok", "message": f"Satis 웹로그인 세션으로 {len(captures):,}개 페이지/컨트롤러 응답을 수집했습니다.", "login_info": {key: value for key, value in login_info.items() if "password" not in key.lower()}, "captured_count": len(captures), "ajax_captured_count": len(ajax_captures), "detail_project_count": len(detail_projects), "detail_revision_count": detail_revision_count, "detail_captured_count": detail_capture_count, "raw_inserted_or_updated_rows": int(promoted_result.get("inserted_or_updated_rows") or 0), "raw_parsed_capture_count": int(promoted_result.get("parsed_capture_count") or 0), "matched_count": len(matched_captures), "error_count": len(errors), "errors": errors[:10], "matched_examples": [ { "url": item["final_url"] or item["source_url"], "title": item["page_title"], "keywords": item["matched_keywords"], "amount_candidate_count": len(item["amount_candidates"]), "link_count": len(item["internal_links"]), } for item in matched_captures[:12] ], "menu_item_count": len(menu_items), "target_menu_items": target_menu_items[:30], "capture_table": "satis_project_budget_web_captures", "next_action": "matched_examples 또는 DB의 satis_project_budget_web_captures에서 실제 프로젝트 조회 컨트롤러와 파라미터를 확인해야 합니다.", } def _build_hanmac_erp_direct_mysql_engine(user: str, password: str): return create_engine( URL.create( "mysql+pymysql", username=user, password=password, host=HANMAC_ERP_DIRECT_DB_HOST, port=HANMAC_ERP_DIRECT_DB_PORT, query={"charset": "utf8"}, ), pool_pre_ping=True, pool_recycle=300, connect_args={"connect_timeout": 5}, ) def _discover_hanmac_erp_budget_tables(user: str, password: str) -> dict[str, Any]: direct_db_engine = _build_hanmac_erp_direct_mysql_engine(user, password) try: with direct_db_engine.connect() as connection: databases = [ normalize_text(row[0]) for row in connection.execute(text("SHOW DATABASES")).fetchall() if normalize_text(row[0]) ] visible_databases = [ database for database in databases if database.lower() not in {"information_schema", "mysql", "performance_schema"} ] like_clauses: list[str] = [] params: dict[str, Any] = {} for index, keyword in enumerate(HANMAC_SATIS_BUDGET_DISCOVERY_KEYWORDS): key = f"keyword_{index}" params[key] = f"%{keyword.lower()}%" like_clauses.append(f"LOWER(c.table_name) LIKE :{key}") like_clauses.append(f"LOWER(c.column_name) LIKE :{key}") rows = connection.execute( text( f""" SELECT c.table_schema, c.table_name, COUNT(*) AS matched_column_count, COUNT(DISTINCT c.column_name) AS distinct_column_count, GROUP_CONCAT(c.column_name ORDER BY c.ordinal_position SEPARATOR ', ') AS matched_columns FROM information_schema.columns c WHERE c.table_schema NOT IN ('information_schema', 'mysql', 'performance_schema') AND ({" OR ".join(like_clauses)}) GROUP BY c.table_schema, c.table_name ORDER BY matched_column_count DESC, c.table_schema, c.table_name LIMIT 80 """ ), params, ).mappings().fetchall() candidates = [] for row in rows: candidates.append( { "database": normalize_text(row.get("table_schema")), "table": normalize_text(row.get("table_name")), "matched_column_count": int(row.get("matched_column_count") or 0), "distinct_column_count": int(row.get("distinct_column_count") or 0), "matched_columns": normalize_text(row.get("matched_columns"))[:600], } ) return { "direct_db_access": True, "direct_db_message": f"MySQL 직접 접속에 성공했습니다. 조회 가능한 DB {len(visible_databases)}개, 예산 후보 테이블 {len(candidates)}개를 확인했습니다.", "direct_db_database_count": len(visible_databases), "direct_db_database_examples": visible_databases[:12], "candidate_table_count": len(candidates), "candidate_tables": candidates[:40], } finally: direct_db_engine.dispose() def test_hanmac_satis_budget_discovery(payload: dict[str, Any]) -> dict[str, Any]: user, password = _hanmac_erp_web_credentials(payload) db_user, db_password, explicit_db_credentials = _hanmac_erp_mysql_credentials(payload) result: dict[str, Any] = { "status": "ok", "message": "Satis 예산 연동 사전 탐색을 완료했습니다.", "web_access": False, "web_message": "", "direct_db_access": False, "direct_db_message": "", "candidate_table_count": 0, "candidate_tables": [], "prepared_local_tables": [ "satis_project_mapping", "satis_project_budget_revisions", "satis_project_task_plan_budget_lines", "satis_project_exec_budget_lines", ], "transport_warning": "Satis ERP 로그인 주소가 HTTP이므로 계정 전송 구간이 암호화되지 않습니다.", } cookie_jar = CookieJar() opener = build_opener(HTTPCookieProcessor(cookie_jar)) try: login_page_request = UrlRequest( HANMAC_SATIS_ERP_LOGIN_PAGE_URL, headers={"User-Agent": "my-intranet-app/satis-budget-discovery"}, ) with opener.open(login_page_request, timeout=10) as response: login_page_status = int(getattr(response, "status", 200) or 200) response.read(1000) login_request = UrlRequest( HANMAC_SATIS_ERP_LOGIN_CONTROLLER_URL, data=urlencode( { "ActionMode": "SCREEN_10", "MainAction": "Ajax_02", "SubAction": "checktype05", "userid": user, "password": password, "item02": "", "item03": "", } ).encode("utf-8"), headers={ "Content-Type": "application/x-www-form-urlencoded", "User-Agent": "my-intranet-app/satis-budget-discovery", }, ) with opener.open(login_request, timeout=10) as response: login_status = int(getattr(response, "status", 200) or 200) login_url = str(response.geturl() or "") login_body = response.read(300_000).decode("utf-8", "replace") links = { normalize_text(match) for match in re.findall(r"""(?:href|src|url)\s*=\s*["']?([^"' >]+)""", login_body, flags=re.IGNORECASE) if normalize_text(match) } internal_links = sorted( link for link in links if "satis" in link.lower() or link.startswith(("./", "../", "/")) ) result.update( { "web_access": True, "web_message": "Satis 로그인 컨트롤러 호출에 성공했습니다.", "login_page_status": login_page_status, "login_status": login_status, "login_url": login_url, "php_session": any(cookie.name == "PHPSESSID" for cookie in cookie_jar), "internal_link_count": len(internal_links), "internal_link_examples": internal_links[:8], } ) except Exception as exc: result["web_message"] = f"Satis 웹 로그인 확인은 실패했습니다: {exc}" try: result.update(_discover_hanmac_erp_budget_tables(db_user, db_password)) except OperationalError as exc: lowered = str(exc).lower() if "access denied" in lowered: result["direct_db_message"] = _build_hanmac_mysql_access_denied_message(db_user, explicit_db_credentials) else: result["direct_db_message"] = "MySQL 3306 포트는 열려 있지만 직접 DB 접속 또는 메타정보 조회에 실패했습니다." except Exception as exc: result["direct_db_message"] = f"직접 DB 탐색 중 오류가 발생했습니다: {exc}" if result.get("direct_db_access"): result["message"] = "Satis 후보 DB/테이블 탐색과 로컬 저장소 준비를 완료했습니다." elif result.get("web_access"): result["message"] = "Satis 웹 접근은 확인했지만 직접 DB 후보 테이블은 확인하지 못했습니다." else: result["message"] = "로컬 저장소는 준비했지만 Satis 웹/DB 접근은 확인하지 못했습니다." return result def test_hanmac_management_erp_access(payload: dict[str, Any]) -> dict[str, Any]: user = normalize_text(payload.get("erp_user")) password = str(payload.get("erp_password") or "") if not user: raise ValueError("관리 ERP 아이디를 입력해주세요.") if not password: raise ValueError("관리 ERP 비밀번호를 입력해주세요.") cookie_jar = CookieJar() opener = build_opener(HTTPCookieProcessor(cookie_jar)) login_request = UrlRequest( HANMAC_MANAGEMENT_ERP_LOGIN_URL, data=urlencode( { "memberID": user, "LoginID": user, "passwd": password, "CheckSave": "", "login": "1", } ).encode("utf-8"), headers={ "Content-Type": "application/x-www-form-urlencoded", "User-Agent": "my-intranet-app/hanmac-erp-access-check", }, ) with opener.open(login_request, timeout=10) as response: login_result = response.read(2000).decode("utf-8", "replace").strip().lower() if login_result != "success": if login_result == "auth": raise ValueError("관리 ERP 로그인은 확인됐지만 접근권한이 없습니다.") raise ValueError("관리 ERP 아이디 또는 비밀번호가 올바르지 않습니다.") main_request = UrlRequest( HANMAC_MANAGEMENT_ERP_MAIN_URL, headers={"User-Agent": "my-intranet-app/hanmac-erp-access-check"}, ) with opener.open(main_request, timeout=10) as response: main_status = int(getattr(response, "status", 200) or 200) main_url = str(response.geturl() or "") main_body = response.read(500_000).decode("utf-8", "replace") links = { normalize_text(match) for match in re.findall(r"""(?:href|src|url)\s*=\s*["']?([^"' >]+)""", main_body, flags=re.IGNORECASE) if normalize_text(match) } internal_links = sorted( link for link in links if "planning_mng" in link or link.startswith(("./", "../", "/")) ) db_markers = sorted( marker for marker in ("mysql", "mysqli", "pdo", "db_host", "db_name", "database", "3306") if marker in main_body.lower() ) direct_db_access = False direct_db_databases: list[str] = [] direct_db_message = "" direct_db_engine = create_engine( URL.create( "mysql+pymysql", username=user, password=password, host="erp.hanmaceng.co.kr", port=3306, query={"charset": "utf8"}, ), pool_pre_ping=True, connect_args={"connect_timeout": 5}, ) try: with direct_db_engine.connect() as connection: direct_db_databases = [ normalize_text(row[0]) for row in connection.execute(text("SHOW DATABASES")).fetchall() if normalize_text(row[0]) ] direct_db_access = True direct_db_message = f"동일 계정으로 MySQL 직접 접속에 성공했습니다. 조회 가능한 DB {len(direct_db_databases)}개를 확인했습니다." except OperationalError as exc: lowered = str(exc).lower() if "access denied" in lowered: direct_db_message = "MySQL 3306 포트는 열려 있지만 관리 ERP 웹 계정으로는 DB 직접 로그인이 거부되었습니다." else: direct_db_message = "MySQL 3306 포트는 열려 있지만 관리 ERP 웹 계정으로 DB 직접 접속하지 못했습니다." finally: direct_db_engine.dispose() return { "status": "ok", "message": "관리 ERP 로그인 및 내부 메인 페이지 접근에 성공했습니다.", "web_access": True, "main_status": main_status, "main_url": main_url, "php_session": any(cookie.name == "PHPSESSID" for cookie in cookie_jar), "internal_link_count": len(internal_links), "internal_link_examples": internal_links[:8], "direct_db_access": direct_db_access, "direct_db_database_count": len(direct_db_databases), "direct_db_database_examples": direct_db_databases[:8], "direct_db_info_found": bool(db_markers), "direct_db_markers": db_markers, "direct_db_message": direct_db_message, "transport_warning": "관리 ERP 로그인 주소가 HTTP이므로 계정 전송 구간이 암호화되지 않습니다.", } 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_company_code(value: Any) -> str: return normalize_text(value).upper() def _hanmac_company_is_hanmac(value: Any) -> bool: text_value = normalize_text(value).replace(" ", "").upper() return not text_value or text_value in {"HANMAC", "HM", "한맥", "한맥기술", "(주)한맥기술", "주식회사한맥기술"} def _hanmac_member_is_active_for_period(member_record: Mapping[str, Any], start_date: date, end_date: date) -> bool: entry_date = _hanmac_parse_date_value(member_record.get("entry_date")) leave_date = _hanmac_parse_date_value(member_record.get("leave_date")) return (entry_date is None or entry_date <= end_date) and (leave_date is None or leave_date >= start_date) def _hanmac_load_member_work_keys_for_period( connection: Any, schema_name: str, metadata: dict[str, list[str]], start_date: date, end_date: date, ) -> set[str]: columns = metadata.get("dallyproject_tbl") or [] member_col = _hanmac_find_column(columns, ["MemberNo", "member_no", "EmpNo", "UserID", "MemberID", "member_id"]) entry_col = _hanmac_find_column(columns, ["EntryTime", "entry_time", "WorkDate", "work_date", "EntryDate", "entry_date"]) if not member_col or not entry_col: return set() try: rows = connection.execute( text( f""" SELECT DISTINCT {_hanmac_build_select_alias(member_col, "member_no")} FROM `{schema_name}`.`dallyproject_tbl` WHERE `{member_col}` IS NOT NULL AND LEFT(CAST(`{entry_col}` AS CHAR), 10) >= :start_date AND LEFT(CAST(`{entry_col}` AS CHAR), 10) <= :end_date """ ), {"start_date": start_date.isoformat(), "end_date": end_date.isoformat()}, ).mappings().all() except Exception: return set() return { _hanmac_normalize_member_token(row.get("member_no")) for row in rows if _hanmac_normalize_member_token(row.get("member_no")) } def _hanmac_discover_affiliate_manhour_schemas(connection: Any, primary_schema: str) -> list[str]: try: rows = connection.execute( text( """ SELECT table_schema, SUM(CASE WHEN table_name = 'member_tbl' THEN 1 ELSE 0 END) AS has_member, SUM(CASE WHEN table_name = 'dallyproject_tbl' THEN 1 ELSE 0 END) AS has_work FROM information_schema.tables WHERE table_type = 'BASE TABLE' AND table_schema <> :primary_schema AND table_schema LIKE '%manhour%' GROUP BY table_schema HAVING has_member > 0 AND has_work > 0 ORDER BY table_schema """ ), {"primary_schema": primary_schema}, ).mappings().all() except Exception: return [HANMAC_CENTER_MANHOUR_SCHEMA] schemas = [normalize_text(row.get("table_schema")) for row in rows if normalize_text(row.get("table_schema"))] if HANMAC_CENTER_MANHOUR_SCHEMA not in schemas: schemas.append(HANMAC_CENTER_MANHOUR_SCHEMA) return [schema for schema in schemas if schema and schema != primary_schema] def _hanmac_affiliate_schema_label(schema_name: Any) -> str: text_value = normalize_text(schema_name) lowered = text_value.lower() aliases = { "baron": "바론컨설턴트", "saman": "삼안", "samahn": "삼안", "jangheon": "장헌산업", "ptc": "피티씨", } for token, label in aliases.items(): if token in lowered: return label return text_value 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": "", "member_company_col": "", "member_work_company_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", "직급"]) company_col = _hanmac_find_column(member_columns, ["Company", "company", "Corp", "corp"]) work_company_col = _hanmac_find_column(member_columns, ["WorkCompany", "work_company", "WorkCorp", "work_corp"]) 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_company_col"] = company_col or "" diagnostics["member_work_company_col"] = work_company_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(company_col, "company")}, {_hanmac_build_select_alias(work_company_col, "work_company")}, {_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, "company": _hanmac_company_code(row.get("company")), "work_company": _hanmac_company_code(row.get("work_company")), "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]: common_codes = {"0", "ZZZZZZ"} 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")) } alias_base_codes: dict[str, set[str]] = {} for group in project_code_alias_groups: project_code = normalize_text(group.get("project_code")) if not project_code: continue for alias_key in ("project_view_code", "old_project_code", "new_project_code"): alias_code = normalize_text(group.get(alias_key)) if alias_code and alias_code not in common_codes and alias_code != project_code: alias_base_codes.setdefault(alias_code, set()).add(project_code) shared_alias_codes = { alias_code for alias_code, linked_base_codes in alias_base_codes.items() if len(linked_base_codes) > 1 } adjacency: dict[str, set[str]] = {} for group in project_code_alias_groups: project_code = normalize_text(group.get("project_code")) if not project_code or project_code in common_codes: 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 and code not in common_codes and code not in shared_alias_codes } 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, "excluded_common_codes": sorted(common_codes), "excluded_shared_alias_codes": sorted(shared_alias_codes), } 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_calculate_official_overtime_hours(entry_time: Any, overtime_time: Any, leave_time: Any) -> tuple[float, str]: overtime_text = normalize_text(overtime_time) if not overtime_text or overtime_text in {"0000-00-00 00:00:00", "00:00:00"}: return 0.0, "" overtime_started_at = _hanmac_parse_datetime_value(overtime_time) ended_at = _hanmac_parse_datetime_value(leave_time) started_at = _hanmac_parse_datetime_value(entry_time) if overtime_started_at and ended_at: if started_at and overtime_started_at.date() == started_at.date() and ended_at < overtime_started_at: ended_at = ended_at + timedelta(days=1) hours = (ended_at - overtime_started_at).total_seconds() / 3600.0 if 0 < hours <= 24: return round(hours, 2), "time_range" return 0.0, "invalid_time_range" return _hanmac_parse_duration_hours(overtime_time), "duration" 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) for year in range(start_date.year, end_date.year + 1): labor_day = date(year, 5, 1) if start_date <= labor_day <= end_date: holiday_dates.add(labor_day) 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_userstate_leave_rule(state_code: Any, leave_type: Any) -> dict[str, Any] | None: code = normalize_text(state_code).zfill(2) if code == "01": return {"keyword": "state:1", "leave_label": "연차", "rule_type": "full_day", "default_hours": 8.0} if code == "30": return {"keyword": "state:30", "leave_label": "오전반차", "rule_type": "fixed_hours", "default_hours": 4.0} if code == "31": return {"keyword": "state:31", "leave_label": "오후반차", "rule_type": "fixed_hours", "default_hours": 4.0} if code == "18": return {"keyword": "state:18", "leave_label": "시차", "rule_type": "explicit_hours", "default_hours": 0.0} if code == "07": return {"keyword": "state:7", "leave_label": "경조휴가", "rule_type": "full_day", "default_hours": 8.0} if code == "08": leave_label = "출산휴가" if "출산" in normalize_text(leave_type) else "특별휴가" return {"keyword": "state:8", "leave_label": leave_label, "rule_type": "full_day", "default_hours": 8.0} if code == "10": return {"keyword": "state:10", "leave_label": "병가", "rule_type": "full_day", "default_hours": 8.0} if code == "16": return {"keyword": "state:16", "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"]) state_col = _hanmac_find_column(columns, ["state", "State", "WorkState"]) 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"]), "state_col": state_col, "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 "(빈값)" normalized_grade = _normalize_labor_grade_name(rank_code_names.get(grade_code) or grade_code) 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": normalized_grade, "member_count": 0, "active_member_count": 0, "examples": [], }, ) if not _hanmac_is_researcher_grade(normalized_grade): 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_state_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"]) state_col = _hanmac_find_column(columns, ["state", "State", "state_code", "StateCode"]) 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 state_col: code_conditions.append(f"CAST(`{state_col}` AS CHAR) = :{key}") 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(state_col, "state_code")}, {_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 state_code = normalize_text(row.get("state_code")) sub_code = normalize_text(row.get("sub_code")) active_code = normalize_text(row.get("active_code")) matched_code = ( state_code if state_code in joint_codes else 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 if state_code == matched_code: diagnostics["joint_assignment_state_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_status_work_rule(state_code: Any, note: Any, project_code: Any) -> dict[str, Any] | None: code = normalize_text(state_code).zfill(2) note_text = normalize_text(note) project_text = normalize_text(project_code) if code == "22": return {"label": "감리현장", "source_label": "감리현장", "cost_weight": 1.0} if code == "23": return {"label": "감리대기", "source_label": "감리대기", "cost_weight": 0.7} if code != "03": return None compact_note = re.sub(r"\s+", "", note_text).lower() personal_keywords = ( "개인", "개인사유", "개인업무", "개인용무", "개인일정", "병원", "치과", "한의원", "검진", "진료", "치료", "약처방", "은행", "부동산", "차량", "자동차", "가족", "자녀", "배우자", "모친", "부친", "장례", "조문", "결혼", "이사", "휴가", "연차", "반차", ) if any(keyword in compact_note for keyword in personal_keywords): return None business_keywords = ( "현장", "조사", "회의", "협의", "점검", "검사", "교육", "착수", "보고", "발표", "준공", "심의", "평가", "설계", "용역", "공사", "공단", "공사", "국토청", "사업", "관리", "열차감시", "발주처", ) if project_text or any(keyword in compact_note for keyword in business_keywords): return {"label": "업무회의", "source_label": "회의중", "cost_weight": 1.0} return None def _hanmac_load_status_work_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 = { "status_work_source_rows": 0, "status_work_records": 0, "status_work_duplicate_rows": 0, "status_work_meeting_records": 0, "status_work_supervision_site_records": 0, "status_work_supervision_wait_records": 0, "status_work_personal_meeting_skipped_rows": 0, "status_work_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"]) state_col = _hanmac_find_column(columns, ["state", "State", "state_code", "StateCode"]) 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"]) if not member_col or not state_col or not start_col: 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)" rows = connection.execute( text( f""" SELECT {_hanmac_build_select_alias(member_col, "member_no")}, {_hanmac_build_select_alias(state_col, "state_code")}, {_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")} FROM `{schema_name}`.`userstate_tbl` WHERE `{member_col}` IS NOT NULL AND CAST(`{state_col}` AS CHAR) IN ('3', '03', '22', '23') AND LEFT(CAST(`{start_col}` AS CHAR), 10) <= :end_date AND {date_end_expr} >= :start_date """ ), {"start_date": start_date.isoformat(), "end_date": end_date.isoformat()}, ).mappings().all() diagnostics["status_work_source_rows"] = len(rows) diagnostics["status_work_table"] = "userstate_tbl" records: list[dict[str, Any]] = [] seen_record_keys: set[tuple[str, str, date, date, str, str]] = set() 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 project_code = normalize_text(row.get("project_code")) or normalize_text(row.get("fallback_project_code")) note = normalize_text(row.get("note")) rule = _hanmac_status_work_rule(row.get("state_code"), note, project_code) if not rule: if normalize_text(row.get("state_code")).zfill(2) == "03": diagnostics["status_work_personal_meeting_skipped_rows"] += 1 continue 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 state_code = normalize_text(row.get("state_code")).zfill(2) record_key = ( _hanmac_normalize_member_token(member_no), state_code, record_start, record_end or record_start, project_code, note, ) if record_key in seen_record_keys: diagnostics["status_work_duplicate_rows"] += 1 continue seen_record_keys.add(record_key) if state_code == "03": diagnostics["status_work_meeting_records"] += 1 elif state_code == "22": diagnostics["status_work_supervision_site_records"] += 1 elif state_code == "23": diagnostics["status_work_supervision_wait_records"] += 1 records.append( { "member_no": member_no, "state_code": state_code, "start_date": max(record_start, start_date), "end_date": min(record_end or record_start, end_date), "project_code": project_code, "source_label": rule["source_label"], "status_label": rule["label"], "cost_weight": float(rule["cost_weight"]), "note": note, "source": "userstate_tbl", } ) diagnostics["status_work_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")) if not entry_date and not leave_date and not normalize_text(member_record.get("source_schema")): return 0.0 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) HANMAC_AGGREGATE_LOGIC_VERSION = "hanmac-aggregate-v40-joint-fallback-replacement" HANMAC_AGGREGATE_SIGNATURE_PREFIX = f"{HANMAC_AGGREGATE_LOGIC_VERSION}:" 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_LOGIC_VERSION, "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": f"{HANMAC_AGGREGATE_SIGNATURE_PREFIX}{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": f"{HANMAC_AGGREGATE_SIGNATURE_PREFIX}{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_LOGIC_VERSION, "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 {}) params_json = json.dumps(params, ensure_ascii=False, sort_keys=True) dedupe_by_params = normalized_page == "cost_analysis" _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 (:dedupe_by_params = 0 OR params_json = :params_json) 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, "dedupe_by_params": 1 if dedupe_by_params else 0, "params_json": params_json, }, ).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": params_json, "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_cost_analysis_payload_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 {} start_date = normalize_text(params.get("start_date")) end_date = normalize_text(params.get("end_date")) mode = normalize_text(params.get("mode")) or "individual" force = bool(params.get("force")) codes = sorted( { normalize_text(code).upper() for code in (params.get("codes") or []) if normalize_text(code) } ) _update_system_job( job_id, message="프로젝트 손익분석 데이터를 계산 중입니다.", progress_current=0, progress_total=1, ) payload = _cost_analysis_build_payload(start_date, end_date, mode, force=force) return { "start_date": payload.get("start_date"), "end_date": payload.get("end_date"), "mode": payload.get("mode"), "project_count": len(payload.get("rows") or []), "requested_codes": codes, "cache_info": payload.get("cache_info") or {}, } 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 == "cost_analysis_payload": result = _run_cost_analysis_payload_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, "affiliate_schema_count": 0, "affiliate_actual_work_excluded_count": 0, "center_duplicate_retained_primary_work_count": 0, "center_duplicate_excluded_no_primary_work_count": 0, "company_review_member_count": 0, "no_hanmac_work_review_member_count": 0, "researcher_member_filtered_rows": 0, "system_member_filtered_rows": 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_state_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, "status_work_source_rows": 0, "status_work_records": 0, "status_work_duplicate_rows": 0, "status_work_meeting_records": 0, "status_work_supervision_site_records": 0, "status_work_supervision_wait_records": 0, "status_work_regular_rows": 0, "status_work_activity_evidence_rows": 0, "status_work_activity_only_regular_rows": 0, "status_work_leave_skipped_days": 0, "status_work_personal_meeting_skipped_rows": 0, "supervision_priority_days": 0, "supervision_overridden_regular_rows": 0, "regular_exact_duplicate_rows_removed": 0, "generated_baseline_overlap_rows_removed": 0, "activity_evidence_duplicate_rows_removed": 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() } primary_regular_member_keys = _hanmac_load_member_work_keys_for_period( connection, schema_name, metadata, start_date, end_date, ) 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_company_col", "member_work_company_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() affiliate_work_excluded_member_keys: set[str] = set() affiliate_work_reasons_by_member_key: dict[str, list[str]] = {} affiliate_membership_reasons_by_member_key: dict[str, list[str]] = {} member_review_notes_by_key: dict[str, list[str]] = {} center_regular_member_keys: set[str] = set() affiliate_schema_names = _hanmac_discover_affiliate_manhour_schemas(connection, schema_name) source_diagnostics["affiliate_schema_count"] = len(affiliate_schema_names) 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 affiliate_schema in affiliate_schema_names: try: affiliate_metadata = _hanmac_fetch_table_columns(connection, affiliate_schema) affiliate_member_info, _affiliate_diagnostics = _hanmac_load_member_info(connection, affiliate_schema, affiliate_metadata) affiliate_regular_member_keys = _hanmac_load_member_work_keys_for_period( connection, affiliate_schema, affiliate_metadata, start_date, end_date, ) except Exception as exc: logger.info("affiliate manhour lookup skipped(%s): %s", affiliate_schema, exc) continue affiliate_label = _hanmac_affiliate_schema_label(affiliate_schema) if affiliate_schema == HANMAC_CENTER_MANHOUR_SCHEMA: center_metadata = affiliate_metadata center_member_info = affiliate_member_info center_regular_member_keys = affiliate_regular_member_keys source_diagnostics["center_schema_available"] = bool(center_metadata) for affiliate_no, affiliate_record in affiliate_member_info.items(): affiliate_key = _hanmac_normalize_member_token(affiliate_no) affiliate_name_key = _hanmac_normalize_person_name(affiliate_record.get("member_name")) matched_member_no = "" if affiliate_key and affiliate_key in primary_member_nos: matched_member_no = primary_member_no_by_key[affiliate_key] elif affiliate_name_key and affiliate_name_key in primary_member_names: matched_member_no = primary_member_names[affiliate_name_key] if not matched_member_no: continue member_key = _hanmac_normalize_member_token(matched_member_no) if member_key in restored_center_member_keys or affiliate_key in restored_center_member_keys: continue if _hanmac_member_is_active_for_period(affiliate_record, start_date, end_date): reason = f"{affiliate_label} 소속 등록 확인" if reason not in affiliate_membership_reasons_by_member_key.setdefault(member_key, []): affiliate_membership_reasons_by_member_key[member_key].append(reason) if affiliate_key not in affiliate_regular_member_keys: continue reason = f"{affiliate_label} 실제근무 확인" if reason not in affiliate_work_reasons_by_member_key.setdefault(member_key, []): affiliate_work_reasons_by_member_key[member_key].append(reason) if member_key in primary_regular_member_keys: member_review_notes_by_key.setdefault(member_key, []).append(f"{affiliate_label} 실제근무도 존재") continue affiliate_work_excluded_member_keys.add(member_key) try: if not center_metadata: 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) center_regular_member_keys = _hanmac_load_member_work_keys_for_period( connection, HANMAC_CENTER_MANHOUR_SCHEMA, center_metadata, start_date, end_date, ) 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 = {} center_regular_member_keys = set() 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 center_active = _hanmac_member_is_active_for_period(center_record, start_date, end_date) has_primary_regular_work = member_key in primary_regular_member_keys has_center_regular_work = center_no_key in center_regular_member_keys should_exclude_center_duplicate = member_key in affiliate_work_excluded_member_keys if should_exclude_center_duplicate: center_excluded_member_nos.add(member_key) source_diagnostics["center_duplicate_excluded_no_primary_work_count"] += 1 elif matched_member_no and center_active and not restored: source_diagnostics["center_duplicate_retained_primary_work_count"] += 1 center_status = ( "복구" if restored else " / ".join(affiliate_work_reasons_by_member_key.get(member_key, [])) + " 제외" if should_exclude_center_duplicate else "한맥근무 유지" ) 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 center_status 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": center_status, "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) center_excluded_member_nos.update(affiliate_work_excluded_member_keys) source_diagnostics["center_excluded_member_count"] = len(center_excluded_member_nos) source_diagnostics["affiliate_actual_work_excluded_count"] = len(affiliate_work_excluded_member_keys) 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) status_work_records, status_work_diagnostics = _hanmac_load_status_work_records( connection, schema_name, metadata, start_date, end_date, ) source_diagnostics.update(status_work_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, official_overtime_source = _hanmac_calculate_official_overtime_hours( row.get("entry_time"), row.get("overtime_time"), row.get("leave_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}:{official_overtime_source}", } ) 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_state_col = leave_profile.get("state_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_select_alias(tardy_state_col, "state_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 if leave_table.lower() == "userstate_tbl": leave_rule = _hanmac_userstate_leave_rule(row.get("state_code"), leave_type) else: 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"] source_diagnostics["project_alias_excluded_common_codes"] = project_relation_maps["excluded_common_codes"] source_diagnostics["project_alias_excluded_shared_codes"] = project_relation_maps["excluded_shared_alias_codes"] project_map.setdefault("H00-대기-01", "감리대기") def canonical_project_code(project_code: Any) -> str: normalized_code = normalize_text(project_code) if not normalized_code or normalized_code.upper() in {"0", "ZZZZZZ"}: return "ZZZZZZ" 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) if canonical_code == "ZZZZZZ": return "공통/미지정" if canonical_code == "H00-합사-01": return "합사" 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 ) def project_classification(project_code: Any, raw_project_code: Any = "", source_label: Any = "") -> dict[str, Any]: canonical_code = canonical_project_code(project_code) raw_code = normalize_text(raw_project_code) source_text = normalize_text(source_label) project_name = project_display_name(canonical_code) equivalent_codes = {code.upper() for code in equivalent_project_codes(canonical_code)} code_tokens = { normalize_text(canonical_code).upper(), raw_code.upper(), *equivalent_codes, } text_blob = f"{canonical_code} {raw_code} {project_name} {' '.join(sorted(equivalent_codes))}".upper() if canonical_code in {"", "ZZZZZZ"}: return {"category": "common", "label": "공통/미지정", "allocable": False, "compensation_candidate": False} if canonical_code == "H00-대기-01" or source_text == "감리대기": return {"category": "supervision_wait", "label": "감리대기", "allocable": True, "compensation_candidate": False} if canonical_code == "H00-합사-01": return {"category": "common", "label": "합사 발령", "allocable": False, "compensation_candidate": False} if ( "HV009111" in code_tokens or "HV-00-간접-11" in code_tokens or canonical_code == "HXX-고문-02" or "HV009109" in code_tokens or "HV-00-간접-09" in code_tokens or canonical_code == "HXX-영업-01" ): return {"category": "indirect_sales", "label": "영업/고문 간접", "allocable": False, "compensation_candidate": False} if "HV009110" in code_tokens or canonical_code == "HXX-교휴-06": return {"category": "common", "label": "기타/교휴 활동", "allocable": False, "compensation_candidate": False} if any(token in text_blob for token in ("제안", "PQ", "입찰", "수주", "검토프로젝트")): return {"category": "pre_sales", "label": "사전사업/제안", "allocable": False, "compensation_candidate": False} if "회의중" in source_text and not normalize_text(project_code): return {"category": "activity_only", "label": "회의/활동근거", "allocable": False, "compensation_candidate": False} return {"category": "actual_project", "label": "실제 수행 프로젝트", "allocable": True, "compensation_candidate": True} def normalize_joint_assignment_project_code(project_code: Any, raw_project_code: Any, source_label: Any) -> str: canonical_code = canonical_project_code(project_code) raw_code = normalize_text(raw_project_code).upper() source_text = normalize_text(source_label) code_tokens = { normalize_text(canonical_code).upper(), raw_code, *(code.upper() for code in equivalent_project_codes(canonical_code)), } if source_text == "합사" and ( canonical_code == "HXX-교휴-06" or "HV009110" in code_tokens or "HXX-교휴-06" in code_tokens ): return "H00-합사-01" return canonical_code today = date.today() member_aggregates: dict[str, dict[str, Any]] = {} project_aggregates: dict[str, dict[str, Any]] = {} leave_hours_by_member_date: dict[tuple[str, date], float] = {} recognized_regular_hours_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 affiliate_name_overrides = { _hanmac_normalize_person_name(name): label for name, label in { "신현우": "바론컨설턴트", "김소연": "바론컨설턴트", "문수혁": "삼안", "양규순": "삼안", "신동호": "삼안", "이용운": "삼안", "전미현": "한라산업개발", "김현지": "바론컨설턴트", "유지원": "바론컨설턴트", "장종찬": "바론컨설턴트", "정태원": "바론컨설턴트", "양병홍": "바론컨설턴트", "한형관": "명시 제외", }.items() } company_label_by_code = { "BARON": "바론컨설턴트", "SAMAN": "삼안", "SAMAHN": "삼안", "JANGHEON": "장헌산업", "PTC": "피티씨", "HALLA": "한라산업개발", "HALLASAN": "한라산업개발", "ETC": "기타/공용", } def population_detail(member_record: Mapping[str, Any], reason: str, decision: str = "소속검토필요") -> dict[str, Any]: return { "decision": decision, "reason": reason, "member_no": normalize_text(member_record.get("member_no")), "member_name": normalize_text(member_record.get("member_name")), "company": normalize_text(member_record.get("company")), "work_company": normalize_text(member_record.get("work_company")), "dept_name": normalize_text(member_record.get("dept_name")), } population_decision_cache: dict[str, dict[str, Any]] = {} population_excluded_member_keys: set[str] = set() def classify_population_member(member_no: str) -> dict[str, Any]: member_key = _hanmac_normalize_member_token(member_no) cached = population_decision_cache.get(member_key) if cached is not None: return cached member_record = get_member_record(member_no) company_code = _hanmac_company_code(member_record.get("company")) work_company_code = _hanmac_company_code(member_record.get("work_company")) company_codes = [code for code in (company_code, work_company_code) if code] non_hanmac_company_codes = [ code for code in company_codes if not _hanmac_company_is_hanmac(code) ] explicit_hanmac_company = any(_hanmac_company_is_hanmac(code) for code in company_codes) name_key = _hanmac_normalize_person_name(member_record.get("member_name")) explicit_affiliate_label = affiliate_name_overrides.get(name_key) affiliate_reasons = list(affiliate_work_reasons_by_member_key.get(member_key, [])) affiliate_membership_reasons = list(affiliate_membership_reasons_by_member_key.get(member_key, [])) details: list[dict[str, Any]] = [] if _hanmac_is_system_member_record(member_record): details.append(population_detail(member_record, "사람 이름이 아닌 조직/관리자/공용 계정", "관리자제외")) decision = {"include": False, "label": "관리자제외", "details": details} population_decision_cache[member_key] = decision return decision if explicit_affiliate_label: details.append(population_detail(member_record, f"{explicit_affiliate_label} 소속 명시 규칙", "계열사제외")) for code in non_hanmac_company_codes: label = company_label_by_code.get(code, code) details.append(population_detail(member_record, f"현재 소속 코드가 한맥이 아님({label})", "계열사제외")) for reason in affiliate_membership_reasons: details.append(population_detail(member_record, reason, "계열사소속확인")) for reason in affiliate_reasons: details.append(population_detail(member_record, reason, "계열사근무확인")) has_affiliate_evidence = bool(explicit_affiliate_label or non_hanmac_company_codes or affiliate_membership_reasons or affiliate_reasons) has_current_non_hanmac_company = bool(explicit_affiliate_label or non_hanmac_company_codes or affiliate_membership_reasons) if has_current_non_hanmac_company: decision = {"include": False, "label": "계열사제외", "details": details} elif affiliate_reasons and not explicit_hanmac_company: decision = {"include": False, "label": "계열사제외", "details": details} elif has_affiliate_evidence: decision = {"include": True, "label": "소속검토필요", "details": details} else: decision = {"include": True, "label": "", "details": []} population_decision_cache[member_key] = decision return decision 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 if _hanmac_is_researcher_grade(member_record.get("member_grade")): source_diagnostics["researcher_member_filtered_rows"] += 1 return False population_decision = classify_population_member(member_no) if population_decision.get("label") == "관리자제외": source_diagnostics["system_member_filtered_rows"] += 1 return False if not population_decision.get("include", True): population_excluded_member_keys.add(_hanmac_normalize_member_token(member_no)) 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_hours_by_member_date[key] = round( min( 8.0, leave_hours_by_member_date.get(key, 0.0) + max(0.0, _hanmac_parse_float_value(row.get("leave_hours"))), ), 4, ) 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_hours = min(8.0, max(0.0, leave_hours_by_member_date.get((member_no, work_date), 0.0))) regular_hours = round(max(0.0, 8.0 - leave_hours), 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 joint_overtime_hours = _hanmac_cap_weekday_overtime(3.0 if leave_hours <= 0 else 0.0) if joint_overtime_hours > 0: overtime_records.append( { "member_no": member_no, "project_code": normalize_text(record.get("project_code")) or "", "work_date": work_date, "overtime_hours": joint_overtime_hours, "source": "합사", "raw_overtime_hours": joint_overtime_hours, "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 baseline_regular_day_keys = { (canonical_member_no(row["member_no"]), row.get("work_date")) for row in regular_records if row.get("work_date") and ( _hanmac_parse_float_value(row.get("regular_hours")) > 0 or _hanmac_parse_float_value(row.get("holiday_hours")) > 0 ) } for record in status_work_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_hours = min(8.0, max(0.0, leave_hours_by_member_date.get((member_no, work_date), 0.0))) regular_hours = round(max(0.0, 8.0 - leave_hours), 4) if regular_hours <= 0: source_diagnostics["status_work_leave_skipped_days"] += 1 continue state_code = normalize_text(record.get("state_code")).zfill(2) is_activity_evidence = state_code == "03" and (member_no, work_date) in baseline_regular_day_keys applied_regular_hours = 0.0 if is_activity_evidence else regular_hours regular_records.append( { "member_no": member_no, "project_code": "H00-대기-01" if state_code == "23" else normalize_text(record.get("project_code")) or "", "raw_project_code": normalize_text(record.get("project_code")) or "", "work_date": work_date, "entry_time": None, "leave_time": None, "regular_hours": applied_regular_hours, "holiday_hours": 0.0, "source_label": record.get("source_label") or "상태근무", "status_label": record.get("status_label") or "상태근무", "state_code": state_code, "record_role": "activity_evidence" if is_activity_evidence else "baseline", "cost_weight": _hanmac_parse_float_value(record.get("cost_weight")) or 1.0, "note": record.get("note") or "", } ) if is_activity_evidence: source_diagnostics["status_work_activity_evidence_rows"] += 1 else: baseline_regular_day_keys.add((member_no, work_date)) source_diagnostics["status_work_regular_rows"] += 1 if state_code == "03": source_diagnostics["status_work_activity_only_regular_rows"] += 1 def ensure_member_bucket(member_no: str) -> dict[str, Any]: member_record = get_member_record(member_no) member_key = _hanmac_normalize_member_token(member_no) review_notes = member_review_notes_by_key.setdefault(member_key, []) population_decision = classify_population_member(member_no) population_details = list(population_decision.get("details") or []) for detail in population_details: note = normalize_text(detail.get("reason")) if note and note not in review_notes: review_notes.append(note) company_code = normalize_text(member_record.get("company")) work_company_code = normalize_text(member_record.get("work_company")) 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", ""), "population_review_notes": review_notes, "population_review_label": population_decision.get("label") or "", "population_review_details": population_details, "company": company_code, "work_company": work_company_code, "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": [], "missing_regular_details": [], "multi_entry_days": 0, "multi_entry_details": [], "overlap_type_counts": {}, }, ) def ensure_project_bucket(project_code: str) -> dict[str, Any]: project_key = canonical_project_code(project_code) 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": [], }, ) supervision_day_keys = { (canonical_member_no(row["member_no"]), row.get("work_date")) for row in regular_records if normalize_text(row.get("state_code")).zfill(2) == "22" and row.get("work_date") } source_diagnostics["supervision_priority_days"] = len(supervision_day_keys) 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) if key in supervision_day_keys and normalize_text(row.get("state_code")).zfill(2) != "22": source_diagnostics["supervision_overridden_regular_rows"] += 1 continue day_group = regular_day_groups.setdefault( key, { "member_no": member_no, "work_date": work_date, "project_hours": {}, "holiday_project_hours": {}, "project_source_labels": {}, "project_raw_codes": {}, "project_cost_weight_sums": {}, "entries": [], "activity_evidence": [], "baseline_signatures": set(), "generated_baseline_keys": set(), "activity_evidence_signatures": set(), "ordered_entries": [], }, ) raw_project_code = row.get("raw_project_code") or row["project_code"] source_label = normalize_text(row.get("source_label")) project_code = ( "H00-대기-01" if source_label == "감리대기" else normalize_joint_assignment_project_code(row["project_code"], raw_project_code, source_label) ) 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"))) record_role = normalize_text(row.get("record_role")) or "baseline" cost_weight = _hanmac_parse_float_value(row.get("cost_weight")) or 1.0 if record_role == "activity_evidence": evidence_signature = ( project_code, source_label, normalize_text(row.get("note")), ) if evidence_signature in day_group["activity_evidence_signatures"]: source_diagnostics["activity_evidence_duplicate_rows_removed"] += 1 continue day_group["activity_evidence_signatures"].add(evidence_signature) day_group["activity_evidence"].append( { "project_code": project_code or "ZZZZZZ", "project_name": project_display_name(project_code), "raw_project_code": raw_project_code, "equivalent_project_codes": equivalent_project_codes(project_code), "project_classification": project_classification(project_code, raw_project_code, source_label), "regular_hours": 0.0, "holiday_hours": 0.0, "source_label": source_label, "note": normalize_text(row.get("note")), "record_role": record_role, } ) if source_label: day_group["project_source_labels"].setdefault(project_code, set()).add(source_label) continue if source_label in {"감리현장", "감리대기", "합사"}: generated_baseline_key = ( project_code, source_label, normalize_text(row.get("state_code")).zfill(2), ) if generated_baseline_key in day_group["generated_baseline_keys"]: source_diagnostics["generated_baseline_overlap_rows_removed"] += 1 continue day_group["generated_baseline_keys"].add(generated_baseline_key) baseline_signature = ( project_code, source_label, normalize_text(row.get("state_code")).zfill(2), normalize_text(row.get("joint_code")), normalize_text(row.get("note")), row.get("entry_time"), row.get("leave_time"), round(raw_hours, 4), round(raw_holiday_hours, 4), ) if baseline_signature in day_group["baseline_signatures"]: source_diagnostics["regular_exact_duplicate_rows_removed"] += 1 continue day_group["baseline_signatures"].add(baseline_signature) day_group["project_hours"][project_code] = round( day_group["project_hours"].get(project_code, 0.0) + raw_hours, 4, ) if raw_hours > 0: day_group["project_cost_weight_sums"][project_code] = round( day_group["project_cost_weight_sums"].get(project_code, 0.0) + (raw_hours * cost_weight), 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) if raw_project_code and raw_project_code != project_code: day_group["project_raw_codes"].setdefault(project_code, set()).add(raw_project_code) 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, "record_role": record_role, "cost_weight": round(cost_weight, 4), } ) 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) leave_hours = min(8.0, max(0.0, leave_hours_by_member_date.get((member_no, work_date), 0.0))) leave_days = round(leave_hours / 8.0, 4) is_configured_holiday = work_date in configured_holiday_dates raw_holiday_total_hours_before_adjustment = round(sum(day_group["holiday_project_hours"].values()), 2) is_weekend_or_holiday = ( work_date.weekday() >= 5 or is_configured_holiday or raw_holiday_total_hours_before_adjustment > 0 ) weekday_cap = 0.0 if is_weekend_or_holiday else max(0.0, 8.0 - leave_hours) generated_source_labels = {"감리현장", "감리대기", "합사"} rebuilt_entries: list[dict[str, Any]] = [] generated_suppressed_evidence: list[dict[str, Any]] = [] project_real_hours: dict[str, float] = {} for entry in day_group["entries"]: entry_project_code = canonical_project_code(entry.get("project_code")) entry_source_label = normalize_text(entry.get("source_label")) entry_hours = max(0.0, _hanmac_parse_float_value(entry.get("regular_hours"))) if entry_source_label not in generated_source_labels and entry_hours > 0: project_real_hours[entry_project_code] = round( project_real_hours.get(entry_project_code, 0.0) + entry_hours, 4, ) generated_topup_used_by_project: dict[str, float] = {} for entry in day_group["entries"]: entry_project_code = canonical_project_code(entry.get("project_code")) entry_source_label = normalize_text(entry.get("source_label")) entry_hours = max(0.0, _hanmac_parse_float_value(entry.get("regular_hours"))) if entry_source_label not in generated_source_labels or entry_hours <= 0 or project_real_hours.get(entry_project_code, 0.0) <= 0: rebuilt_entries.append(entry) continue already_used = generated_topup_used_by_project.get(entry_project_code, 0.0) topup_capacity = max(0.0, weekday_cap - project_real_hours.get(entry_project_code, 0.0) - already_used) topup_hours = round(min(entry_hours, topup_capacity), 4) evidence_entry = { "project_code": entry_project_code or "ZZZZZZ", "project_name": project_display_name(entry_project_code), "raw_project_code": normalize_text(entry.get("raw_project_code")), "equivalent_project_codes": equivalent_project_codes(entry_project_code), "project_classification": project_classification( entry_project_code, entry.get("raw_project_code"), entry_source_label, ), "regular_hours": 0.0, "holiday_hours": 0.0, "source_label": entry_source_label, "note": "실제근무행 우선 적용으로 상태근무 생성행은 근거로만 보관", "record_role": "generated_evidence", } generated_suppressed_evidence.append(evidence_entry) if topup_hours > 0: topup_entry = { **entry, "regular_hours": round(topup_hours, 2), "source_label": f"{entry_source_label} 보정", "record_role": "generated_topup", } rebuilt_entries.append(topup_entry) generated_topup_used_by_project[entry_project_code] = round(already_used + topup_hours, 4) source_diagnostics["generated_status_topup_rows"] = ( int(source_diagnostics.get("generated_status_topup_rows") or 0) + 1 ) source_diagnostics["generated_status_topup_hours"] = round( float(source_diagnostics.get("generated_status_topup_hours") or 0.0) + topup_hours, 2, ) else: source_diagnostics["generated_status_suppressed_rows"] = ( int(source_diagnostics.get("generated_status_suppressed_rows") or 0) + 1 ) source_diagnostics["generated_status_suppressed_hours"] = round( float(source_diagnostics.get("generated_status_suppressed_hours") or 0.0) + entry_hours, 2, ) if len(rebuilt_entries) != len(day_group["entries"]) or generated_topup_used_by_project: day_group["entries"] = rebuilt_entries day_group["project_hours"] = {} day_group["holiday_project_hours"] = {} day_group["project_source_labels"] = {} day_group["project_raw_codes"] = {} day_group["project_cost_weight_sums"] = {} for entry in day_group["entries"]: entry_project_code = canonical_project_code(entry.get("project_code")) entry_source_label = normalize_text(entry.get("source_label")) entry_hours = max(0.0, _hanmac_parse_float_value(entry.get("regular_hours"))) entry_holiday_hours = max(0.0, _hanmac_parse_float_value(entry.get("holiday_hours"))) entry_cost_weight = _hanmac_parse_float_value(entry.get("cost_weight")) or 1.0 day_group["project_hours"][entry_project_code] = round( day_group["project_hours"].get(entry_project_code, 0.0) + entry_hours, 4, ) if entry_hours > 0: day_group["project_cost_weight_sums"][entry_project_code] = round( day_group["project_cost_weight_sums"].get(entry_project_code, 0.0) + (entry_hours * entry_cost_weight), 4, ) day_group["holiday_project_hours"][entry_project_code] = round( day_group["holiday_project_hours"].get(entry_project_code, 0.0) + entry_holiday_hours, 4, ) if entry_source_label: day_group["project_source_labels"].setdefault(entry_project_code, set()).add(entry_source_label) raw_entry_code = normalize_text(entry.get("raw_project_code")) if raw_entry_code and raw_entry_code != entry_project_code: day_group["project_raw_codes"].setdefault(entry_project_code, set()).add(raw_entry_code) day_group["activity_evidence"].extend(generated_suppressed_evidence) positive_project_codes_for_fallback = { project_code for project_code, project_hours in day_group["project_hours"].items() if project_hours > 0 } if len(positive_project_codes_for_fallback) == 1 and not is_weekend_or_holiday and leave_hours <= 0 and weekday_cap > 0: fallback_project_code = next(iter(positive_project_codes_for_fallback)) fallback_classification = project_classification(fallback_project_code) actual_activity_evidence = [] seen_activity_project_codes: set[str] = set() for evidence in day_group["activity_evidence"]: evidence_project_code = canonical_project_code(evidence.get("project_code")) evidence_classification = evidence.get("project_classification") or project_classification( evidence_project_code, evidence.get("raw_project_code"), evidence.get("source_label"), ) if ( evidence_project_code != "ZZZZZZ" and evidence_classification.get("category") == "actual_project" and evidence_project_code not in seen_activity_project_codes ): actual_activity_evidence.append(evidence) seen_activity_project_codes.add(evidence_project_code) if fallback_classification.get("category") == "common" and actual_activity_evidence: replaced_hours = round(day_group["project_hours"].get(fallback_project_code, 0.0), 4) replacement_hours = round(min(replaced_hours, weekday_cap), 4) retained_entries = [] fallback_entries = [] for entry in day_group["entries"]: if canonical_project_code(entry.get("project_code")) == fallback_project_code: fallback_entries.append(entry) else: retained_entries.append(entry) if replacement_hours > 0 and fallback_entries: for entry in fallback_entries: day_group["activity_evidence"].append( { "project_code": fallback_project_code or "ZZZZZZ", "project_name": project_display_name(fallback_project_code), "raw_project_code": normalize_text(entry.get("raw_project_code")), "equivalent_project_codes": equivalent_project_codes(fallback_project_code), "project_classification": project_classification( fallback_project_code, entry.get("raw_project_code"), entry.get("source_label"), ), "regular_hours": 0.0, "holiday_hours": 0.0, "source_label": normalize_text(entry.get("source_label")) or "상태근무", "note": "실제 프로젝트 활동근거가 있어 합사/기타 생성행은 근거로만 보관", "record_role": "nonallocable_fallback_evidence", } ) allocated_replacement = _hanmac_allocate_recognized_hours( replacement_hours, {index: 1.0 for index, _evidence in enumerate(actual_activity_evidence)}, ) for index, evidence in enumerate(actual_activity_evidence): evidence_project_code = canonical_project_code(evidence.get("project_code")) evidence_hours = round(allocated_replacement.get(index, 0.0), 4) if evidence_hours <= 0: continue raw_evidence_code = normalize_text(evidence.get("raw_project_code")) retained_entries.append( { "project_code": evidence_project_code, "project_name": project_display_name(evidence_project_code), "raw_project_code": raw_evidence_code, "equivalent_project_codes": equivalent_project_codes(evidence_project_code), "regular_hours": round(evidence_hours, 2), "holiday_hours": 0.0, "source_label": "활동근거 대체", "record_role": "activity_replacement", "cost_weight": 1.0, } ) day_group["entries"] = retained_entries day_group["project_hours"] = {} day_group["holiday_project_hours"] = {} day_group["project_source_labels"] = {} day_group["project_raw_codes"] = {} day_group["project_cost_weight_sums"] = {} for entry in day_group["entries"]: entry_project_code = canonical_project_code(entry.get("project_code")) entry_source_label = normalize_text(entry.get("source_label")) entry_hours = max(0.0, _hanmac_parse_float_value(entry.get("regular_hours"))) entry_holiday_hours = max(0.0, _hanmac_parse_float_value(entry.get("holiday_hours"))) entry_cost_weight = _hanmac_parse_float_value(entry.get("cost_weight")) or 1.0 day_group["project_hours"][entry_project_code] = round( day_group["project_hours"].get(entry_project_code, 0.0) + entry_hours, 4, ) if entry_hours > 0: day_group["project_cost_weight_sums"][entry_project_code] = round( day_group["project_cost_weight_sums"].get(entry_project_code, 0.0) + (entry_hours * entry_cost_weight), 4, ) day_group["holiday_project_hours"][entry_project_code] = round( day_group["holiday_project_hours"].get(entry_project_code, 0.0) + entry_holiday_hours, 4, ) if entry_source_label: day_group["project_source_labels"].setdefault(entry_project_code, set()).add(entry_source_label) raw_entry_code = normalize_text(entry.get("raw_project_code")) if raw_entry_code and raw_entry_code != entry_project_code: day_group["project_raw_codes"].setdefault(entry_project_code, set()).add(raw_entry_code) source_diagnostics["nonallocable_fallback_replaced_rows"] = ( int(source_diagnostics.get("nonallocable_fallback_replaced_rows") or 0) + len(fallback_entries) ) source_diagnostics["nonallocable_fallback_replaced_hours"] = round( float(source_diagnostics.get("nonallocable_fallback_replaced_hours") or 0.0) + replacement_hours, 2, ) positive_project_codes_before_priority = { project_code for project_code, project_hours in day_group["project_hours"].items() if project_hours > 0 } has_wait_project = "H00-대기-01" in positive_project_codes_before_priority actual_project_codes = [ project_code for project_code in positive_project_codes_before_priority if project_classification(project_code).get("category") == "actual_project" ] if has_wait_project and actual_project_codes: removed_hours = day_group["project_hours"].pop("H00-대기-01", 0.0) day_group["holiday_project_hours"].pop("H00-대기-01", None) day_group["project_cost_weight_sums"].pop("H00-대기-01", None) day_group["project_source_labels"].pop("H00-대기-01", None) day_group["project_raw_codes"].pop("H00-대기-01", None) day_group["entries"] = [ entry for entry in day_group["entries"] if canonical_project_code(entry.get("project_code")) != "H00-대기-01" ] source_diagnostics["supervision_wait_replaced_by_project_rows"] = ( int(source_diagnostics.get("supervision_wait_replaced_by_project_rows") or 0) + 1 ) source_diagnostics["supervision_wait_replaced_by_project_hours"] = round( float(source_diagnostics.get("supervision_wait_replaced_by_project_hours") or 0.0) + removed_hours, 2, ) elif has_wait_project: retained_entries = [] for entry in day_group["entries"]: entry_project_code = canonical_project_code(entry.get("project_code")) if entry_project_code == "H00-대기-01": retained_entries.append(entry) continue entry_classification = project_classification(entry_project_code, entry.get("raw_project_code"), entry.get("source_label")) if entry_classification.get("category") in {"pre_sales", "indirect_sales", "common", "activity_only"}: removed_hours = day_group["project_hours"].pop(entry_project_code, 0.0) day_group["holiday_project_hours"].pop(entry_project_code, None) day_group["project_cost_weight_sums"].pop(entry_project_code, None) day_group["project_source_labels"].pop(entry_project_code, None) day_group["project_raw_codes"].pop(entry_project_code, None) source_diagnostics["supervision_wait_retained_over_presales_rows"] = ( int(source_diagnostics.get("supervision_wait_retained_over_presales_rows") or 0) + 1 ) source_diagnostics["supervision_wait_retained_over_presales_hours"] = round( float(source_diagnostics.get("supervision_wait_retained_over_presales_hours") or 0.0) + removed_hours, 2, ) continue retained_entries.append(entry) day_group["entries"] = retained_entries positive_project_codes_after_wait = { project_code for project_code, project_hours in day_group["project_hours"].items() if project_hours > 0 } allocable_positive_project_codes = { project_code for project_code in positive_project_codes_after_wait if project_classification(project_code).get("allocable") } if allocable_positive_project_codes and len(positive_project_codes_after_wait) > len(allocable_positive_project_codes): retained_entries = [] for entry in day_group["entries"]: entry_project_code = canonical_project_code(entry.get("project_code")) entry_classification = project_classification(entry_project_code, entry.get("raw_project_code"), entry.get("source_label")) if entry_project_code not in allocable_positive_project_codes and not entry_classification.get("allocable"): removed_hours = day_group["project_hours"].pop(entry_project_code, 0.0) day_group["holiday_project_hours"].pop(entry_project_code, None) day_group["project_cost_weight_sums"].pop(entry_project_code, None) day_group["project_source_labels"].pop(entry_project_code, None) day_group["project_raw_codes"].pop(entry_project_code, None) source_diagnostics["nonallocable_project_suppressed_rows"] = ( int(source_diagnostics.get("nonallocable_project_suppressed_rows") or 0) + 1 ) source_diagnostics["nonallocable_project_suppressed_hours"] = round( float(source_diagnostics.get("nonallocable_project_suppressed_hours") or 0.0) + removed_hours, 2, ) continue retained_entries.append(entry) day_group["entries"] = retained_entries positive_project_codes_after_allocable = { project_code for project_code, project_hours in day_group["project_hours"].items() if project_hours > 0 } if len(positive_project_codes_after_allocable) > 1: retained_entries = [] for entry in day_group["entries"]: entry_project_code = canonical_project_code(entry.get("project_code")) entry_classification = project_classification(entry_project_code, entry.get("raw_project_code"), entry.get("source_label")) if entry_classification.get("category") in {"common", "activity_only", "indirect_sales"}: removed_hours = day_group["project_hours"].pop(entry_project_code, 0.0) day_group["holiday_project_hours"].pop(entry_project_code, None) day_group["project_cost_weight_sums"].pop(entry_project_code, None) day_group["project_source_labels"].pop(entry_project_code, None) day_group["project_raw_codes"].pop(entry_project_code, None) source_diagnostics["nonallocable_project_suppressed_rows"] = ( int(source_diagnostics.get("nonallocable_project_suppressed_rows") or 0) + 1 ) source_diagnostics["nonallocable_project_suppressed_hours"] = round( float(source_diagnostics.get("nonallocable_project_suppressed_hours") or 0.0) + removed_hours, 2, ) continue retained_entries.append(entry) day_group["entries"] = retained_entries raw_total_hours = round(sum(day_group["project_hours"].values()), 2) raw_holiday_total_hours = round(sum(day_group["holiday_project_hours"].values()), 2) 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 - leave_hours) if not is_weekend_or_holiday and leave_hours <= 0 and raw_total_hours < weekday_cap: compensation_candidates: list[dict[str, Any]] = [] for evidence in day_group["activity_evidence"]: evidence_project_code = canonical_project_code(evidence.get("project_code")) evidence_classification = evidence.get("project_classification") or project_classification( evidence_project_code, evidence.get("raw_project_code"), evidence.get("source_label"), ) if evidence_classification.get("compensation_candidate") and evidence_project_code != "ZZZZZZ": compensation_candidates.append(evidence) if compensation_candidates: missing_hours = round(max(0.0, weekday_cap - raw_total_hours), 4) candidate_weights = { index: 1.0 for index, _evidence in enumerate(compensation_candidates) } allocated_compensation = _hanmac_allocate_recognized_hours(missing_hours, candidate_weights) for index, evidence in enumerate(compensation_candidates): compensation_hours = round(allocated_compensation.get(index, 0.0), 4) if compensation_hours <= 0: continue evidence_project_code = canonical_project_code(evidence.get("project_code")) day_group["project_hours"][evidence_project_code] = round( day_group["project_hours"].get(evidence_project_code, 0.0) + compensation_hours, 4, ) day_group["project_cost_weight_sums"][evidence_project_code] = round( day_group["project_cost_weight_sums"].get(evidence_project_code, 0.0) + compensation_hours, 4, ) day_group["project_source_labels"].setdefault(evidence_project_code, set()).add("회의근거 보정") raw_evidence_code = normalize_text(evidence.get("raw_project_code")) if raw_evidence_code and raw_evidence_code != evidence_project_code: day_group["project_raw_codes"].setdefault(evidence_project_code, set()).add(raw_evidence_code) day_group["entries"].append( { "project_code": evidence_project_code, "project_name": project_display_name(evidence_project_code), "raw_project_code": raw_evidence_code, "equivalent_project_codes": equivalent_project_codes(evidence_project_code), "regular_hours": round(compensation_hours, 2), "holiday_hours": 0.0, "source_label": "회의근거 보정", "record_role": "compensation", "cost_weight": 1.0, } ) raw_total_hours = round(sum(day_group["project_hours"].values()), 2) source_diagnostics["meeting_evidence_compensation_days"] = ( int(source_diagnostics.get("meeting_evidence_compensation_days") or 0) + 1 ) source_diagnostics["meeting_evidence_compensation_hours"] = round( float(source_diagnostics.get("meeting_evidence_compensation_hours") or 0.0) + missing_hours, 2, ) if (member_no, work_date) in supervision_day_keys: capped_regular_hours = round(min(raw_total_hours, weekday_cap), 2) else: 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 recognized_regular_hours_by_member_date[(member_no, work_date)] = round(capped_regular_hours, 4) 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), "leave_hours": round(leave_hours, 2), "projects": sorted( [ { "project_code": project_code or "(미지정)", "project_name": project_display_name(project_code), "equivalent_project_codes": equivalent_project_codes(project_code), "source_project_codes": sorted(day_group["project_raw_codes"].get(project_code, set())), "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()))), "cost_weight": round( day_group["project_cost_weight_sums"].get(project_code, project_hours) / project_hours if project_hours > 0 else 1.0, 4, ), } for project_code, project_hours in day_group["project_hours"].items() ], key=lambda item: (-item["hours"], item["project_code"]), ), "activity_evidence": sorted( day_group["activity_evidence"], key=lambda item: (item["project_code"], item.get("source_label") or ""), ), } ) 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) positive_project_codes = { project_code for project_code, project_hours in day_group["project_hours"].items() if project_hours > 0 } evidence_project_codes = { evidence_project_code for entry in day_group["activity_evidence"] if ( (evidence_project_code := canonical_project_code(entry.get("project_code"))) != "ZZZZZZ" and (entry.get("project_classification") or project_classification( evidence_project_code, entry.get("raw_project_code"), entry.get("source_label"), )).get("category") == "actual_project" ) } conflicting_evidence_codes = evidence_project_codes - positive_project_codes overlap_type = "" if len(positive_project_codes) > 1: overlap_type = "복수 프로젝트 배부" elif conflicting_evidence_codes: overlap_type = "실제 프로젝트 활동근거 검토" else: positive_entries = [ entry for entry in day_group["entries"] if ( float(entry.get("regular_hours") or 0.0) > 0 or float(entry.get("holiday_hours") or 0.0) > 0 ) ] positive_entry_project_codes = { canonical_project_code(entry.get("project_code")) for entry in positive_entries } if ( len(positive_entry_project_codes) == 1 and any(normalize_text(entry.get("record_role")) == "compensation" for entry in positive_entries) ): positive_entries = [] if not overlap_type and len(positive_entries) > 1: entry_signatures = { ( canonical_project_code(entry.get("project_code")), normalize_text(entry.get("source_label")), round(float(entry.get("regular_hours") or 0.0), 2), ) for entry in positive_entries } overlap_type = "원천 데이터 중복" if len(entry_signatures) < len(positive_entries) else "동일 프로젝트 근무기록 중첩" if overlap_type: 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["overlap_type_counts"][overlap_type] = ( member_bucket["overlap_type_counts"].get(overlap_type, 0) + 1 ) member_bucket["multi_entry_details"].append( { "work_date": work_date.isoformat(), "overlap_type": overlap_type, "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), "activity_evidence": sorted( day_group["activity_evidence"], key=lambda item: (item["project_code"], item.get("source_label") or ""), ), "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, } ) if _hanmac_counts_as_member(member_record): 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()))), "cost_weight": round( day_group["project_cost_weight_sums"].get(project_code, project_hours) / project_hours if project_hours > 0 else 1.0, 4, ), } ) if _hanmac_counts_as_member(member_record): 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), } ) if _hanmac_counts_as_member(member_record): 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) if _hanmac_counts_as_member(member_record): 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, member_record in member_info.items(): if include_member(member_no) and _hanmac_member_is_active_for_period(member_record, start_date, end_date): ensure_member_bucket(member_no) 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, ) 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 expected_after_leave_hours = 0.0 missing_regular_details: list[dict[str, Any]] = [] if effective_end >= effective_start: for work_date in _hanmac_iter_dates(effective_start, effective_end): if work_date.weekday() >= 5 or work_date in configured_holiday_dates: continue leave_hours = min( 8.0, max(0.0, leave_hours_by_member_date.get((member_no, work_date), 0.0)), ) expected_day_hours = round(max(0.0, 8.0 - leave_hours), 2) recognized_day_hours = round( max(0.0, recognized_regular_hours_by_member_date.get((member_no, work_date), 0.0)), 2, ) expected_after_leave_hours += expected_day_hours missing_hours = round(max(0.0, expected_day_hours - recognized_day_hours), 2) if missing_hours <= 0: continue missing_regular_details.append( { "work_date": work_date.isoformat(), "expected_hours": expected_day_hours, "recognized_hours": recognized_day_hours, "leave_hours": round(leave_hours, 2), "missing_hours": missing_hours, "reason": "근무 근거 없음" if recognized_day_hours <= 0 else "부분 근무시간 부족", } ) expected_after_leave_hours = round(expected_after_leave_hours, 2) regular_hour_gap = round(bucket["regular_hours"] - expected_after_leave_hours, 2) missing_regular_hours = round(sum(item["missing_hours"] for item in missing_regular_details), 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 bucket["missing_regular_hours"] = missing_regular_hours bucket["missing_regular_days"] = round(missing_regular_hours / 8.0, 2) bucket["missing_regular_date_count"] = len(missing_regular_details) bucket["missing_regular_details"] = missing_regular_details has_actual_hanmac_work = ( bucket["regular_hours"] > 0 or bucket["overtime_hours"] > 0 or bucket["holiday_hours"] > 0 or bool(bucket["regular_details"]) or bool(bucket["overtime_details"]) or bool(bucket["holiday_details"]) ) if not has_actual_hanmac_work: note = "한맥 실제 근무기록 없음 · 소속 유지 가능성 검토" if note not in bucket["population_review_notes"]: bucket["population_review_notes"].append(note) bucket["population_review_label"] = bucket.get("population_review_label") or "소속검토필요" bucket.setdefault("population_review_details", []).append( { "decision": "소속검토필요", "reason": note, "member_no": bucket.get("member_no", ""), "member_name": bucket.get("member_name", ""), "company": bucket.get("company", ""), "work_company": bucket.get("work_company", ""), "dept_name": bucket.get("dept_name", ""), } ) source_diagnostics["population_owner_excluded_member_count"] = len(population_excluded_member_keys) source_diagnostics["company_review_member_count"] = sum( 1 for bucket in member_aggregates.values() if any("현재 소속 코드" in note for note in (bucket.get("population_review_notes") or [])) ) source_diagnostics["no_hanmac_work_review_member_count"] = sum( 1 for bucket in member_aggregates.values() if any("한맥 실제 근무기록 없음" in note for note in (bucket.get("population_review_notes") or [])) ) 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", ""), "population_review": bucket.get("population_review_label", ""), "population_review_notes": list(bucket.get("population_review_notes") or []), "company": bucket.get("company", ""), "work_company": bucket.get("work_company", ""), "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": { "population_review": sorted( bucket.get("population_review_details") or [], key=lambda item: (item.get("decision") or "", item.get("reason") or ""), ), "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 ""), "missing_regular_days": sorted( bucket["missing_regular_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"], "overlap_type_counts": dict(sorted(bucket["overlap_type_counts"].items())), "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), "missing_regular_hours": round(bucket.get("missing_regular_hours", 0.0), 2), "missing_regular_days": round(bucket.get("missing_regular_days", 0.0), 2), "missing_regular_date_count": int(bucket.get("missing_regular_date_count", 0)), "remarks": " · ".join( part for part in ( bucket.get("population_review_label", ""), ( " · ".join( f"{label} {count}일" for label, count in sorted(bucket["overlap_type_counts"].items()) ) if bucket["overlap_type_counts"] else "" ), ( f"정규근로 {bucket.get('missing_regular_days', 0.0):,.2f}일 부족" f" ({bucket.get('missing_regular_hours', 0.0):,.2f}시간)" if bucket.get("missing_regular_hours", 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": "population_review", "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": sum( 1 for member_no in member_aggregates if _hanmac_counts_as_member(get_member_record(member_no)) ), "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) source_diagnostics["headcount_excluded_researcher_count"] = sum( 1 for member_no in member_aggregates if not _hanmac_counts_as_member(get_member_record(member_no)) ) source_diagnostics["logic_version"] = HANMAC_AGGREGATE_LOGIC_VERSION 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() with engine.begin() as conn: years = sorted(_discover_available_fiscal_years(conn), reverse=True) active_row = conn.execute( text( """ SELECT setting_key FROM wehago_compare_settings WHERE setting_key LIKE 'wehago_active_query_projection:%:%' ORDER BY updated_at DESC LIMIT 1 """ ) ).mappings().first() selected_start = None selected_end = None if active_row: parts = normalize_text(active_row.get("setting_key")).split(":") if len(parts) >= 3: try: selected_start = int(parts[-2]) selected_end = int(parts[-1]) except Exception: selected_start = None selected_end = None if selected_start is None or selected_end is None: fallback_year = int(years[0]) if years else date.today().year selected_start = fallback_year selected_end = fallback_year projection_state = ensure_wehago_canonical_projection_state( conn, selected_start, selected_end, repair=True, ) return { "status": "ok", "logic_version": QUERY_PROJECTION_VERSION, "selected_start_year": selected_start, "selected_end_year": selected_end, "available_year_count": len(years), "projection_state": projection_state, } @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", background: bool = False, ): try: if background: requested_start = _parse_iso_date(start_date) or date(date.today().year, 1, 1) requested_end = _parse_iso_date(end_date) or date.today() if requested_end < requested_start: requested_start, requested_end = requested_end, requested_start requested_start, requested_end = _cost_analysis1_effective_period( requested_start, requested_end, ) cumulative_start = await run_in_threadpool( _cost_analysis_get_accumulation_start, requested_end.isoformat(), ) hanmac_refresh_jobs = await run_in_threadpool( _cost_analysis_ensure_current_hanmac_cache_jobs, cumulative_start, requested_end, ) if hanmac_refresh_jobs: normalized_mode = "aggregate" if normalize_text(mode).lower() in {"aggregate", "sum", "합산", "연계", "linked", "link"} else "individual" last_valid = await run_in_threadpool( _cost_analysis_load_last_valid_payload, requested_start.isoformat(), requested_end.isoformat(), normalized_mode, ) return JSONResponse( content=jsonable_encoder( { **(last_valid or {}), "pending": True, "job": hanmac_refresh_jobs[0], "hanmac_refresh_jobs": hanmac_refresh_jobs, "cache_info": { **((last_valid or {}).get("cache_info") or {}), "ready": False, "source": "hanmac-auto-refresh", }, } ), status_code=202, headers={"Cache-Control": "no-store, max-age=0"}, ) # 프로젝트 손익분석은 ERP 기초자료에 표준·조정인건비를 반영한 # 단일 최종 payload를 반환한다. 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"}, ) 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.post("/cost-analysis/refresh") async def cost_analysis_refresh(request: Request): try: payload = await request.json() if not isinstance(payload, dict): payload = {} context = await run_in_threadpool( _cost_analysis_payload_cache_context, normalize_text(payload.get("start_date")), normalize_text(payload.get("end_date")), ) normalized_mode = "aggregate" if normalize_text(payload.get("mode")).lower() in {"aggregate", "sum", "합산", "연계", "linked", "link"} else "individual" codes = [ normalize_text(code).upper() for code in (payload.get("codes") or []) if normalize_text(code) ] job = await run_in_threadpool( _create_system_job, page_key="cost_analysis", job_type="cost_analysis_payload", start_year=context["start_date"].year, end_year=context["end_date"].year, params={ "start_date": context["start_date"].isoformat(), "end_date": context["end_date"].isoformat(), "mode": normalized_mode, "force": bool(payload.get("force")), "codes": codes, }, ) 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("/cost-analysis/validate") async def cost_analysis_validate( start_date: str = "", end_date: str = "", mode: str = "individual", codes: str = "", ): try: requested_codes = parse_support_dept_codes_param(codes) if not requested_codes: raise ValueError("검증할 프로젝트 코드가 필요합니다.") payload = await run_in_threadpool(_cost_analysis_build_payload, start_date, end_date, mode) filtered = await run_in_threadpool(_cost_analysis_filter_payload_codes, payload, requested_codes) return JSONResponse( content=jsonable_encoder(filtered), 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/cache-diagnostics") async def cost_analysis_cache_diagnostics(start_date: str = "", end_date: str = "", mode: str = "individual"): try: context = await run_in_threadpool(_cost_analysis_payload_cache_context, start_date, end_date) normalized_mode = "aggregate" if normalize_text(mode).lower() in {"aggregate", "sum", "합산", "연계", "linked", "link"} else "individual" cached = await run_in_threadpool(_cost_analysis_load_cached_payload, context, normalized_mode) latest_job = await run_in_threadpool( _fetch_latest_system_job, page_key="cost_analysis", job_type="cost_analysis_payload", start_year=context["start_date"].year, end_year=context["end_date"].year, ) return JSONResponse( content=jsonable_encoder( { "ready": cached is not None, "mode": normalized_mode, "start_date": context["start_date"].isoformat(), "end_date": context["end_date"].isoformat(), "cache_info": (cached or {}).get("cache_info") or {}, "job": latest_job, "financial_logic_version": COST_ANALYSIS_FINANCIAL_LOGIC_VERSION, "h_project_mapping_version": COST_ANALYSIS_H_PROJECT_MAPPING_VERSION, "link_logic_version": COST_ANALYSIS_LINK_LOGIC_VERSION, } ) ) 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 = "", representative_code: 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() detail_cache_key = ( start.isoformat(), end.isoformat(), tuple(sorted(normalized_codes)), normalize_text(representative_code).upper(), normalized_phase, normalized_item, get_business_data_version(), _cost_analysis_hanmac_cache_version(), COST_ANALYSIS_FINANCIAL_LOGIC_VERSION, COST_ANALYSIS_LINK_LOGIC_VERSION, ) cached_detail = _get_deepcopy_ttl_cache_entry( _COST_ANALYSIS_DETAIL_CACHE, _COST_ANALYSIS_DETAIL_CACHE_LOCK, detail_cache_key, COST_ANALYSIS_DETAIL_CACHE_TTL_SECONDS, ) if cached_detail is not None: cached_detail["cache_info"] = {**(cached_detail.get("cache_info") or {}), "source": "memory"} return JSONResponse(content=jsonable_encoder(cached_detail)) def detail_response(payload: dict[str, Any]) -> JSONResponse: payload["cache_info"] = { "source": "computed", "generated_at": datetime.now().isoformat(timespec="seconds"), } cached = _set_deepcopy_ttl_cache_entry( _COST_ANALYSIS_DETAIL_CACHE, _COST_ANALYSIS_DETAIL_CACHE_LOCK, detail_cache_key, payload, ) return JSONResponse(content=jsonable_encoder(cached)) completion_dates = _cost_analysis_get_completion_billing_dates() representative_map = _cost_analysis_get_link_representative_map() requested_total_codes = [code for code in normalized_codes if normalize_text(code).upper()[:1] in {"0", "9"}] explicit_representative_code = normalize_text(representative_code).upper() if explicit_representative_code[:1] not in {"0", "9"}: explicit_representative_code = "" def detail_project_codes(source_code: Any) -> dict[str, str]: individual_code = normalize_text(source_code).upper() if not individual_code: return { "total_project_code": explicit_representative_code, "project_code": "", "source_project_code": "", "mapping_status": "explicit" if explicit_representative_code else "unresolved", } total_code = explicit_representative_code or representative_map.get(individual_code, "") mapping_status = "explicit" if explicit_representative_code else "linked" if not total_code and individual_code[:1] in {"0", "9"}: total_code = individual_code mapping_status = "self-total" if not total_code and requested_total_codes: total_code = requested_total_codes[0] mapping_status = "requested-total" if not total_code: mapping_status = "unresolved" return { "total_project_code": total_code, "project_code": individual_code, "source_project_code": individual_code, "mapping_status": mapping_status, } def enrich_detail_row(row: dict[str, Any]) -> dict[str, Any]: enriched = dict(row) code_fields = detail_project_codes(enriched.get("project_code") or enriched.get("source_project_code")) for key, value in code_fields.items(): if key in {"total_project_code", "mapping_status"}: if not normalize_text(enriched.get(key)): enriched[key] = value elif not normalize_text(enriched.get(key)): enriched[key] = value return enriched if normalized_item in {"labor", "sga_labor", "labor_combined", "sga_labor_combined"}: project_meta = _cost_analysis_get_project_meta() rows = _cost_analysis_load_hanmac_labor_detail_rows_yearly( start, end, normalized_codes, normalized_phase, project_meta, ) _, hanmac_labor_by_year, _ = _cost_analysis_load_hanmac_hours_and_labor_yearly( start, end, project_meta, set(normalized_codes), ) has_hanmac_labor = any( normalize_amount(phase_amounts.get(phase_key)) for code_map in hanmac_labor_by_year.values() for phase_amounts in code_map.values() for phase_key in ("pre", "during", "post") ) if rows or has_hanmac_labor: rows = [enrich_detail_row(row) for row in rows] hours_summary = { field: round(sum(normalize_amount(row.get(field)) for row in rows), 2) for field in ("regular_hours", "overtime_hours", "holiday_hours", "extra_hours", "total_hours") } return detail_response( { "detail_type": "labor", "rows": rows, "total_amount": sum(normalize_amount(row.get("amount")) for row in rows), "hours_summary": hours_summary, } ) if normalized_item == "collection": code_set = set(normalized_codes) rows = [] for event in _cost_analysis_erp_collection_events(end): code = normalize_text(event.get("support_dept_code")).upper() posting_date = _date_text(event.get("posting_date")) if code not in code_set or not (start.isoformat() <= posting_date <= end.isoformat()): continue conversion_status = normalize_text(event.get("conversion_status")) rows.append( { "posting_date": posting_date, "account_name": normalize_text(event.get("receivable_account_name")) or "ERP 수금", "partner_name": normalize_text(event.get("partner_name")), **detail_project_codes(code), "memo1": normalize_text(event.get("memo1")), "amount": int(round(normalize_amount(event.get("amount")))), "gross_amount": int(round(normalize_amount(event.get("gross_amount")))), "voucher_number": normalize_text(event.get("voucher_number")), "confirmed_voucher_number": normalize_text(event.get("confirmed_voucher_number")), "source_invoice_voucher_number": normalize_text(event.get("source_invoice_voucher_number")), "source_invoice_confirmed_voucher_number": normalize_text( event.get("source_invoice_confirmed_voucher_number") ), "match_status": ( "원 청구 전표 매칭" if conversion_status == "invoice-matched" else ( "즉시 현금·카드 매출" if conversion_status == "immediate-cash" else "부가세 10% 추정 환산" ) ), "included_in_total": True, } ) rows.sort(key=lambda row: (row.get("posting_date") or "", row.get("voucher_number") or ""), reverse=True) rows = [enrich_detail_row(row) for row in rows] return detail_response( { "detail_type": "collection", "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 id, COALESCE(support_dept_code, '') AS support_dept_code, COALESCE(billing_date, '') AS posting_date, COALESCE(tax_invoice_date, '') AS tax_invoice_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(billing_date, '') >= :start_date AND COALESCE(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: billing_rows = [dict(row) for row in conn.execute(query, params).mappings()] erp_rows = [ dict(row) for row in conn.execute( text( f""" SELECT COALESCE(voucher_number, '') AS voucher_number, COALESCE(confirmed_voucher_number, '') AS confirmed_voucher_number, {COST_ANALYSIS_TX_DATE_SQL} AS posting_date, COALESCE(account_code, '') AS account_code, COALESCE(account_name, '') AS account_name, UPPER(COALESCE(support_dept_code, '')) AS support_dept_code, COALESCE(partner_name, '') AS partner_name, COALESCE(memo1, '') AS memo1, COALESCE(amount, 0) AS amount FROM transactions WHERE UPPER(COALESCE(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%' AND COALESCE(amount, 0) <> 0 ORDER BY posting_date DESC, voucher_number DESC """ ), params, ).mappings() ] used_erp_indexes: set[int] = set() rows = [] for billing_row in billing_rows: code = normalize_text(billing_row.get("support_dept_code")).upper() amount = normalize_amount(billing_row.get("amount")) tax_invoice_date = _date_text(billing_row.get("tax_invoice_date")) exact_index = next( ( index for index, erp_row in enumerate(erp_rows) if index not in used_erp_indexes and normalize_text(erp_row.get("support_dept_code")).upper() == code and abs(normalize_amount(erp_row.get("amount")) - amount) < 0.5 and _date_text(erp_row.get("posting_date")) == tax_invoice_date ), None, ) amount_index = exact_index if amount_index is None: amount_index = next( ( index for index, erp_row in enumerate(erp_rows) if index not in used_erp_indexes and normalize_text(erp_row.get("support_dept_code")).upper() == code and abs(normalize_amount(erp_row.get("amount")) - amount) < 0.5 ), None, ) erp_row = erp_rows[amount_index] if amount_index is not None else {} if amount_index is not None: used_erp_indexes.add(amount_index) match_status = "ERP 전표 일치" if amount_index is None: match_status = "ERP 전표 미확인" elif exact_index is None: match_status = "ERP 증빙일자 불일치" rows.append( { "posting_date": _date_text(billing_row.get("posting_date")), "tax_invoice_date": tax_invoice_date, "account_name": normalize_text(billing_row.get("account_name")), "partner_name": normalize_text(billing_row.get("partner_name")), **detail_project_codes(code), "memo1": normalize_text(billing_row.get("memo1")), "amount": int(round(amount)), "erp_posting_date": _date_text(erp_row.get("posting_date")), "voucher_number": normalize_text(erp_row.get("voucher_number")), "confirmed_voucher_number": normalize_text(erp_row.get("confirmed_voucher_number")), "match_status": match_status, "included_in_total": True, } ) for index, erp_row in enumerate(erp_rows): if index in used_erp_indexes: continue code = normalize_text(erp_row.get("support_dept_code")).upper() rows.append( { "posting_date": "", "tax_invoice_date": "", "erp_posting_date": _date_text(erp_row.get("posting_date")), "account_name": normalize_text(erp_row.get("account_name")), "partner_name": normalize_text(erp_row.get("partner_name")), **detail_project_codes(code), "memo1": normalize_text(erp_row.get("memo1")), "amount": int(round(normalize_amount(erp_row.get("amount")))), "voucher_number": normalize_text(erp_row.get("voucher_number")), "confirmed_voucher_number": normalize_text(erp_row.get("confirmed_voucher_number")), "match_status": "DB 청구 미연결 ERP 전표", "included_in_total": False, } ) rows = [enrich_detail_row(row) for row in rows] return detail_response( { "detail_type": "billing", "rows": rows, "total_amount": sum( row["amount"] for row in rows if row.get("included_in_total") ), } ) project_meta = _cost_analysis_get_project_meta() synthetic_rows: list[dict[str, Any]] = [] hanmac_labor_detail_rows: list[dict[str, Any]] = [] if normalized_item in {"cost_total", "total_cost"}: hanmac_labor_detail_rows = _cost_analysis_load_hanmac_labor_detail_rows_yearly( start, end, normalized_codes, normalized_phase, project_meta, ) for labor_row in hanmac_labor_detail_rows: synthetic_rows.append( { "posting_date": "한맥", "account_name": "한맥 인건비", "partner_name": normalize_text(labor_row.get("member_name")), "total_project_code": normalize_text(labor_row.get("total_project_code")), "project_code": normalize_text(labor_row.get("project_code")), "memo1": ( f"{normalize_text(labor_row.get('member_grade'))} " f"총 {normalize_amount(labor_row.get('total_hours')):,.1f}h" f" / 초과 {normalize_amount(labor_row.get('extra_hours')):,.1f}h" ).strip(), "amount": int(round(normalize_amount(labor_row.get("amount")))), } ) if normalized_item in {"overhead", "cost_total", "total_cost"}: synthetic_rows.extend( _cost_analysis_build_allocated_common_detail_rows( start, end, normalized_codes, normalized_phase, project_meta, "overhead", ) ) if normalized_item in {"sga", "sga_total", "total_cost"}: synthetic_rows.extend( _cost_analysis_build_allocated_common_detail_rows( start, end, normalized_codes, normalized_phase, project_meta, "sga", ) ) 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 = "pre" if code.startswith("X") else _cost_analysis_phase_for_transaction(code, posting_date, completion_dates, project_meta) row_item = _cost_analysis_expense_item(row.get("account_code"), row.get("account_name"), _cost_analysis_is_sales_cost(row)) if normalized_phase and normalized_phase != "all" and row_phase != normalized_phase: continue if row_item == "labor": continue if not _cost_analysis_detail_item_matches(bucket, row_item, normalized_item): 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"]), **detail_project_codes(code), "memo1": normalize_text(row["memo1"]), "amount": int(round(normalize_amount(row["amount"]))), } ) rows = [enrich_detail_row(row) for row in [*synthetic_rows, *result_rows]] return detail_response({"rows": rows, "total_amount": sum(row["amount"] for row in 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("/annual-summary/gap-analysis") async def annual_summary_gap_analysis(request: Request): try: return render_annual_gap_analysis_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/test-management-erp") async def hanmac_browser_test_management_erp(request: Request): try: payload = await request.json() if not isinstance(payload, dict): raise ValueError("잘못된 요청 형식입니다.") result = await run_in_threadpool(test_hanmac_management_erp_access, payload) return JSONResponse(content=jsonable_encoder(result)) except Exception as exc: logger.exception("hanmac 관리 ERP 접근 확인 에러: %s", exc) return JSONResponse( content={"status": "error", "message": str(exc)}, status_code=400, ) @app.post("/hanmac-browser/api/discover-satis-budget") async def hanmac_browser_discover_satis_budget(request: Request): try: payload = await request.json() if not isinstance(payload, dict): raise ValueError("잘못된 요청 형식입니다.") init_db() result = await run_in_threadpool(test_hanmac_satis_budget_discovery, payload) return JSONResponse(content=jsonable_encoder(result)) except Exception as exc: logger.exception("hanmac Satis 예산 연동 탐색 에러: %s", exc) return JSONResponse( content={"status": "error", "message": str(exc)}, status_code=400, ) @app.post("/hanmac-browser/api/sync-satis-budget-raw") async def hanmac_browser_sync_satis_budget_raw(request: Request): try: payload = await request.json() if not isinstance(payload, dict): raise ValueError("잘못된 요청 형식입니다.") init_db() result = await run_in_threadpool(_sync_satis_budget_raw_rows, payload) return JSONResponse(content=jsonable_encoder(result)) except Exception as exc: logger.exception("hanmac Satis 예산 원본 금액 동기화 에러: %s", exc) return JSONResponse( content={"status": "error", "message": str(exc)}, status_code=400, ) @app.post("/hanmac-browser/api/normalize-satis-budget") async def hanmac_browser_normalize_satis_budget(request: Request): try: payload = await request.json() if not isinstance(payload, dict): raise ValueError("잘못된 요청 형식입니다.") result = await run_in_threadpool(_normalize_satis_budget_raw_rows, payload) return JSONResponse(content=jsonable_encoder(result)) except Exception as exc: logger.exception("hanmac Satis 예산 정규화 에러: %s", exc) return JSONResponse( content={"status": "error", "message": str(exc)}, status_code=400, ) @app.post("/hanmac-browser/api/project-satis-budget-current") async def hanmac_browser_project_satis_budget_current(request: Request): try: payload = await request.json() if not isinstance(payload, dict): raise ValueError("잘못된 요청 형식입니다.") result = await run_in_threadpool(_project_satis_budget_to_current_entries, payload) return JSONResponse(content=jsonable_encoder(result)) except Exception as exc: logger.exception("hanmac Satis 예산 기존 입력 테이블 반영 에러: %s", exc) return JSONResponse( content={"status": "error", "message": str(exc)}, status_code=400, ) @app.post("/hanmac-browser/api/run-satis-budget-full-sync") async def hanmac_browser_run_satis_budget_full_sync(request: Request): try: payload = await request.json() if not isinstance(payload, dict): raise ValueError("잘못된 요청 형식입니다.") init_db() result = await run_in_threadpool(_run_satis_budget_full_sync, payload) return JSONResponse(content=jsonable_encoder(result)) except Exception as exc: logger.exception("hanmac Satis 예산 전체 실행 에러: %s", exc) return JSONResponse( content={"status": "error", "message": str(exc)}, status_code=400, ) @app.post("/hanmac-browser/api/collect-satis-budget-web") async def hanmac_browser_collect_satis_budget_web(request: Request): try: payload = await request.json() if not isinstance(payload, dict): raise ValueError("잘못된 요청 형식입니다.") init_db() result = await run_in_threadpool(_collect_satis_budget_via_web, payload) return JSONResponse(content=jsonable_encoder(result)) except Exception as exc: logger.exception("hanmac Satis 웹로그인 예산 수집 에러: %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 await run_in_threadpool( 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]: _clear_compare_runtime_caches() return { "mode": "manual_overlay_only", "message": "선택한 recheck 변경만 저장했고, 전체 projection 재생성은 실행하지 않았습니다.", } 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)])