Files
HM_project_Viewer_Board/main.py
T

12519 lines
537 KiB
Python

import copy
import os
import logging
import json
import re
import sqlite3
import threading
import time
import tempfile
import zipfile
from datetime import date, datetime, timedelta
from decimal import Decimal, InvalidOperation, ROUND_HALF_UP
from functools import lru_cache
from pathlib import Path
from typing import Any
from urllib.parse import parse_qs, quote_plus
import uvicorn
from fastapi import FastAPI, File, Request, UploadFile
from fastapi.encoders import jsonable_encoder
from fastapi.responses import FileResponse, HTMLResponse, JSONResponse, RedirectResponse, Response
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
from openpyxl import load_workbook
from sqlalchemy import bindparam, create_engine, event, text
from sqlalchemy.engine import URL
from sqlalchemy.exc import OperationalError
from starlette.concurrency import run_in_threadpool
from datasette.app import Datasette
from wehago_compare import (
cleanup_compare_runtime_artifacts,
enqueue_default_pair_recommend_precompute,
export_wehago_status_rows_xlsx,
get_erp_filtered_rows,
get_compare_snapshot_status,
get_individual_pair_recommendations,
get_last_action_summary,
get_status_export_job,
get_status_field_suggestions,
get_status_detail_rows,
get_wehago_compare_dashboard,
get_wehago_compare_summary,
get_wehago_filtered_rows,
import_uploaded_erp_voucher_file,
init_wehago_compare_db,
load_bridge_review_settings,
request_compare_snapshot_rebuild,
request_status_export_xlsx,
recommend_pair_matches,
save_bridge_review_settings,
save_recommended_pair_matches,
save_manual_pair_matches,
save_recheck_review_rows,
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
_HANMAC_LAST_AGGREGATE_DIAGNOSTICS: dict[str, Any] = {}
_HANMAC_AGGREGATE_CACHE_TTL_SEC = 300.0
_HANMAC_AGGREGATE_REFRESHING: set[str] = set()
_HANMAC_AGGREGATE_REFRESHING_LOCK = threading.Lock()
_HANMAC_PREVIEW_CACHE_TTL_SEC = 180.0
_HANMAC_PREVIEW_REFRESHING: set[str] = set()
_HANMAC_PREVIEW_REFRESHING_LOCK = threading.Lock()
_HANMAC_EXPORT_JOB_EVENT = threading.Event()
_HANMAC_EXPORT_WORKER_LOCK = threading.Lock()
_HANMAC_EXPORT_WORKER_STARTED = False
_HANMAC_EXPORT_WORKER_THREAD: threading.Thread | None = None
_APP_MAINTENANCE_WORKER_LOCK = threading.Lock()
_APP_MAINTENANCE_WORKER_STARTED = False
_APP_MAINTENANCE_WORKER_THREAD: threading.Thread | None = None
DB_BACKUP_MIN_INTERVAL_SECONDS = 900.0
DB_BACKUP_KEEP_COUNT = 24
DB_ANALYZE_MIN_INTERVAL_SECONDS = 6 * 60 * 60
APP_MAINTENANCE_INTERVAL_SECONDS = 15 * 60.0
EXPORT_RETENTION_SECONDS = 24 * 60 * 60
QUERY_CACHE_RETENTION_SECONDS = 7 * 24 * 60 * 60
PROCESS_COST_CACHE_TTL_SECONDS = 120.0
_PROCESS_COST_RUNTIME_CACHE_LOCK = threading.Lock()
_PROCESS_COST_PROJECT_OPTIONS_CACHE: dict[tuple[Any, ...], dict[str, Any]] = {}
_PROCESS_COST_PROJECT_DETAIL_CACHE: dict[tuple[Any, ...], dict[str, Any]] = {}
_ANNUAL_SUMMARY_BOOTSTRAP_CACHE_LOCK = threading.Lock()
_ANNUAL_SUMMARY_BOOTSTRAP_CACHE: dict[str, Any] = {
"stored_at": 0.0,
"payload": None,
}
ANNUAL_SUMMARY_BOOTSTRAP_CACHE_TTL_SECONDS = 300.0
_DASHBOARD_BOOTSTRAP_CACHE_LOCK = threading.Lock()
_DASHBOARD_BOOTSTRAP_CACHE: dict[tuple[Any, ...], dict[str, Any]] = {}
DASHBOARD_BOOTSTRAP_CACHE_TTL_SECONDS = 300.0
app = FastAPI()
BASE_DIR = Path(__file__).resolve().parent
STATIC_DIR = BASE_DIR / "static"
TEMPLATES_DIR = BASE_DIR / "templates"
DB_PATH = BASE_DIR / "data.db"
BACKUP_DIR = BASE_DIR / "backups"
STATIC_DIR.mkdir(exist_ok=True)
BACKUP_DIR.mkdir(exist_ok=True)
HANMAC_EXPORT_DIR = Path(tempfile.gettempdir()) / "my_intranet_hanmac_exports"
templates = Jinja2Templates(directory=str(TEMPLATES_DIR))
app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")
engine = create_engine(
f"sqlite:///{DB_PATH}",
connect_args={"check_same_thread": False, "timeout": 30},
)
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 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": "분석 설정"},
},
}
},
}
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_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", "공통 기준인건비", "{}"),
],
"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)
"""
)
)
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_aggregate_query_metrics (
cache_key TEXT PRIMARY KEY,
payload_signature TEXT NOT NULL DEFAULT '',
view_mode TEXT NOT NULL DEFAULT '',
employment_filter TEXT NOT NULL DEFAULT '',
start_date TEXT NOT NULL DEFAULT '',
end_date TEXT NOT NULL DEFAULT '',
summary_json TEXT NOT NULL DEFAULT '{}',
columns_json TEXT NOT NULL DEFAULT '[]',
row_count INTEGER NOT NULL DEFAULT 0,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
"""
)
)
conn.execute(
text(
"""
CREATE INDEX IF NOT EXISTS idx_hanmac_aggregate_query_metrics_updated_at
ON hanmac_aggregate_query_metrics (updated_at)
"""
)
)
conn.execute(
text(
"""
CREATE TABLE IF NOT EXISTS hanmac_aggregate_query_rows (
cache_key TEXT NOT NULL,
row_index INTEGER NOT NULL,
row_json TEXT NOT NULL DEFAULT '{}',
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (cache_key, row_index)
)
"""
)
)
conn.execute(
text(
"""
CREATE INDEX IF NOT EXISTS idx_hanmac_aggregate_query_rows_updated_at
ON hanmac_aggregate_query_rows (updated_at)
"""
)
)
conn.execute(
text(
"""
CREATE INDEX IF NOT EXISTS idx_hanmac_aggregate_query_cache_updated_at
ON hanmac_aggregate_query_cache (updated_at)
"""
)
)
conn.execute(
text(
"""
CREATE TABLE IF NOT EXISTS hanmac_preview_query_cache (
cache_key TEXT PRIMARY KEY,
payload_signature TEXT NOT NULL DEFAULT '',
payload_json TEXT NOT NULL DEFAULT '{}',
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
"""
)
)
conn.execute(
text(
"""
CREATE INDEX IF NOT EXISTS idx_hanmac_preview_query_cache_updated_at
ON hanmac_preview_query_cache (updated_at)
"""
)
)
conn.execute(
text(
"""
CREATE TABLE IF NOT EXISTS hanmac_export_jobs (
job_key TEXT PRIMARY KEY,
job_type TEXT NOT NULL DEFAULT 'preview_export_csv',
payload_json TEXT NOT NULL DEFAULT '{}',
state TEXT NOT NULL DEFAULT 'queued',
file_path TEXT NOT NULL DEFAULT '',
file_name TEXT NOT NULL DEFAULT '',
row_count INTEGER NOT NULL DEFAULT 0,
error_message TEXT NOT NULL DEFAULT '',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
"""
)
)
conn.execute(
text(
"""
CREATE INDEX IF NOT EXISTS idx_hanmac_export_jobs_state_created
ON hanmac_export_jobs (state, created_at)
"""
)
)
conn.execute(
text(
"""
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 TABLE IF NOT EXISTS project_task_plan_entries (
id INTEGER PRIMARY KEY AUTOINCREMENT,
support_dept_code TEXT NOT NULL,
position INTEGER NOT NULL DEFAULT 0,
group_name TEXT DEFAULT '',
dept_name TEXT DEFAULT '',
work_name TEXT DEFAULT '',
amount REAL DEFAULT 0,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
"""
)
)
conn.execute(
text(
"""
CREATE INDEX IF NOT EXISTS idx_project_task_plan_entries_code_pos
ON project_task_plan_entries (support_dept_code, position)
"""
)
)
conn.execute(
text(
"""
CREATE TABLE IF NOT EXISTS project_exec_budget_entries (
id INTEGER PRIMARY KEY AUTOINCREMENT,
support_dept_code TEXT NOT NULL,
position INTEGER NOT NULL DEFAULT 0,
group_name TEXT DEFAULT '',
grade TEXT DEFAULT '',
hours TEXT DEFAULT '',
rate_year TEXT DEFAULT '',
dept_name TEXT DEFAULT '',
work_name TEXT DEFAULT '',
account_code TEXT DEFAULT '',
account_name TEXT DEFAULT '',
amount REAL DEFAULT 0,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
"""
)
)
conn.execute(
text(
"""
CREATE INDEX IF NOT EXISTS idx_project_exec_budget_entries_code_pos
ON project_exec_budget_entries (support_dept_code, position)
"""
)
)
conn.execute(
text(
"""
CREATE TABLE IF NOT EXISTS project_actual_input_entries (
id INTEGER PRIMARY KEY AUTOINCREMENT,
support_dept_code TEXT NOT NULL,
position INTEGER NOT NULL DEFAULT 0,
group_name TEXT DEFAULT '',
grade TEXT DEFAULT '',
minutes TEXT DEFAULT '',
rate_year TEXT DEFAULT '',
label TEXT DEFAULT '',
reference TEXT DEFAULT '',
note TEXT DEFAULT '',
amount REAL DEFAULT 0,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
"""
)
)
conn.execute(
text(
"""
CREATE INDEX IF NOT EXISTS idx_project_actual_input_entries_code_pos
ON project_actual_input_entries (support_dept_code, position)
"""
)
)
conn.execute(
text(
"""
CREATE TABLE IF NOT EXISTS app_save_events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
action_key TEXT NOT NULL,
entity_type TEXT NOT NULL,
entity_key TEXT DEFAULT '',
session_id TEXT DEFAULT '',
status TEXT NOT NULL DEFAULT 'ok',
duration_ms INTEGER NOT NULL DEFAULT 0,
payload_json TEXT DEFAULT '{}',
error_message TEXT DEFAULT '',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
"""
)
)
conn.execute(
text(
"""
CREATE INDEX IF NOT EXISTS idx_app_save_events_lookup
ON app_save_events (entity_type, entity_key, created_at DESC)
"""
)
)
conn.execute(
text(
"""
CREATE TABLE IF NOT EXISTS project_status_snapshots (
id INTEGER PRIMARY KEY AUTOINCREMENT,
support_dept_code TEXT NOT NULL,
action_key TEXT NOT NULL DEFAULT 'project_status_save',
session_id TEXT DEFAULT '',
previous_revision TEXT DEFAULT '',
revision TEXT DEFAULT '',
previous_snapshot_json TEXT DEFAULT '{}',
snapshot_json TEXT DEFAULT '{}',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
"""
)
)
conn.execute(
text(
"""
CREATE INDEX IF NOT EXISTS idx_project_status_snapshots_code_created
ON project_status_snapshots (support_dept_code, created_at DESC)
"""
)
)
conn.execute(
text(
"""
CREATE TABLE IF NOT EXISTS db_backup_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
backup_file TEXT NOT NULL,
file_size INTEGER NOT NULL DEFAULT 0,
trigger_action TEXT DEFAULT '',
session_id TEXT DEFAULT '',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
"""
)
)
conn.execute(
text(
"""
CREATE TABLE IF NOT EXISTS project_comparison_notes (
support_dept_code TEXT NOT NULL,
item_key TEXT NOT NULL,
note TEXT DEFAULT '',
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (support_dept_code, item_key)
)
"""
)
)
conn.execute(
text(
"""
CREATE INDEX IF NOT EXISTS idx_project_comparison_notes_code
ON project_comparison_notes (support_dept_code, item_key)
"""
)
)
conn.execute(
text(
"""
CREATE TABLE IF NOT EXISTS project_analysis_settings (
support_dept_code TEXT NOT NULL PRIMARY KEY,
detail_note TEXT DEFAULT '',
inactive_related_codes_json TEXT DEFAULT '[]',
labor_joint_exempt INTEGER DEFAULT 0,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
"""
)
)
conn.execute(
text(
"""
CREATE INDEX IF NOT EXISTS idx_project_analysis_settings_code
ON project_analysis_settings (support_dept_code)
"""
)
)
page_state_columns_before = {
row[1]
for row in conn.execute(text("PRAGMA table_info(project_page_state)")).fetchall()
}
if "session_id" not in page_state_columns_before:
legacy_rows = conn.execute(
text(
"""
SELECT page_key, selected_code, selected_year, analysis_open,
related_project_selections_json, updated_at
FROM project_page_state
"""
)
).mappings().all()
conn.execute(text("ALTER TABLE project_page_state RENAME TO project_page_state_legacy"))
conn.execute(
text(
"""
CREATE TABLE project_page_state (
page_key TEXT NOT NULL,
session_id TEXT NOT NULL DEFAULT '',
selected_code TEXT DEFAULT '',
selected_year TEXT DEFAULT '',
analysis_open INTEGER DEFAULT 0,
uncontracted_year_start TEXT DEFAULT '',
uncontracted_year_end TEXT DEFAULT '',
related_project_selections_json TEXT DEFAULT '{}',
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (page_key, session_id)
)
"""
)
)
for row in legacy_rows:
conn.execute(
text(
"""
INSERT INTO project_page_state (
page_key, session_id, selected_code, selected_year,
analysis_open, uncontracted_year_start, uncontracted_year_end,
related_project_selections_json, updated_at
) VALUES (
:page_key, '', :selected_code, :selected_year,
:analysis_open, '', '', :related_project_selections_json, :updated_at
)
"""
),
dict(row),
)
conn.execute(text("DROP TABLE project_page_state_legacy"))
conn.execute(
text(
"""
CREATE INDEX IF NOT EXISTS idx_project_page_state_page_session
ON project_page_state (page_key, session_id)
"""
)
)
conn.execute(
text(
"""
CREATE TABLE IF NOT EXISTS project_related_links (
base_support_dept_code TEXT NOT NULL,
related_support_dept_code TEXT NOT NULL,
link_source TEXT DEFAULT 'manual',
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (base_support_dept_code, related_support_dept_code)
)
"""
)
)
conn.execute(
text(
"""
CREATE TABLE IF NOT EXISTS project_uncontracted_classification (
support_dept_code TEXT PRIMARY KEY,
category TEXT DEFAULT '',
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
"""
)
)
conn.execute(
text(
"""
CREATE INDEX IF NOT EXISTS idx_project_uncontracted_classification_category
ON project_uncontracted_classification (category)
"""
)
)
conn.execute(
text(
"""
CREATE TABLE IF NOT EXISTS project_quick_links (
page_key TEXT NOT NULL,
support_dept_code TEXT NOT NULL,
sort_order INTEGER NOT NULL DEFAULT 0,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (page_key, support_dept_code)
)
"""
)
)
quick_link_columns_before = {
row[1]
for row in conn.execute(text("PRAGMA table_info(project_quick_links)")).fetchall()
}
if "session_id" in quick_link_columns_before:
legacy_rows = conn.execute(
text(
"""
SELECT page_key, support_dept_code, MIN(sort_order) AS sort_order, MAX(updated_at) AS updated_at
FROM project_quick_links
GROUP BY page_key, support_dept_code
"""
)
).mappings().all()
conn.execute(text("ALTER TABLE project_quick_links RENAME TO project_quick_links_legacy"))
conn.execute(
text(
"""
CREATE TABLE project_quick_links (
page_key TEXT NOT NULL,
support_dept_code TEXT NOT NULL,
sort_order INTEGER NOT NULL DEFAULT 0,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (page_key, support_dept_code)
)
"""
)
)
for row in legacy_rows:
conn.execute(
text(
"""
INSERT INTO project_quick_links (
page_key, support_dept_code, sort_order, updated_at
) VALUES (
:page_key, :support_dept_code, :sort_order, :updated_at
)
"""
),
dict(row),
)
conn.execute(text("DROP TABLE project_quick_links_legacy"))
conn.execute(
text(
"""
CREATE INDEX IF NOT EXISTS idx_project_quick_links_page_sort
ON project_quick_links (page_key, sort_order, updated_at)
"""
)
)
ensure_default_app_config(conn)
conn.execute(
text(
"""
CREATE INDEX IF NOT EXISTS idx_project_related_links_base
ON project_related_links (base_support_dept_code)
"""
)
)
conn.execute(
text(
"""
CREATE INDEX IF NOT EXISTS idx_project_related_links_related
ON project_related_links (related_support_dept_code)
"""
)
)
conn.execute(
text(
"""
CREATE TABLE IF NOT EXISTS project_contract_info (
support_dept_code TEXT PRIMARY KEY,
raw_contract_code TEXT DEFAULT '',
business_division TEXT DEFAULT '',
order_method TEXT DEFAULT '',
owner_department TEXT DEFAULT '',
client_name TEXT DEFAULT '',
support_dept_name TEXT DEFAULT '',
work_category TEXT DEFAULT '',
order_date TEXT DEFAULT '',
contract_date TEXT DEFAULT '',
project_start_date TEXT DEFAULT '',
project_end_date TEXT DEFAULT '',
contract_status TEXT DEFAULT '',
joint_contract TEXT DEFAULT '',
pm_name TEXT DEFAULT '',
progress_status TEXT DEFAULT '',
total_contract_amount REAL DEFAULT 0,
hanmac_contract_amount REAL DEFAULT 0,
review_tag TEXT DEFAULT '',
review_note TEXT DEFAULT '',
source_file TEXT DEFAULT '',
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
"""
)
)
conn.execute(
text(
"""
CREATE INDEX IF NOT EXISTS idx_project_contract_info_updated_at
ON project_contract_info (updated_at)
"""
)
)
conn.execute(
text(
"""
CREATE TABLE IF NOT EXISTS project_contract_change_summary (
id INTEGER PRIMARY KEY AUTOINCREMENT,
raw_summary_code TEXT DEFAULT '',
normalized_title TEXT DEFAULT '',
owner_department TEXT DEFAULT '',
business_division TEXT DEFAULT '',
support_dept_name TEXT DEFAULT '',
change_date TEXT DEFAULT '',
client_name TEXT DEFAULT '',
original_contract_period TEXT DEFAULT '',
changed_project_end_date TEXT DEFAULT '',
initial_contract_amount REAL DEFAULT 0,
previous_contract_amount REAL DEFAULT 0,
changed_contract_amount REAL DEFAULT 0,
delta_amount REAL DEFAULT 0,
source_file TEXT DEFAULT '',
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
"""
)
)
conn.execute(
text(
"""
CREATE INDEX IF NOT EXISTS idx_project_contract_change_summary_code
ON project_contract_change_summary (raw_summary_code, change_date)
"""
)
)
conn.execute(
text(
"""
CREATE INDEX IF NOT EXISTS idx_project_contract_change_summary_title
ON project_contract_change_summary (normalized_title)
"""
)
)
conn.execute(
text(
"""
CREATE TABLE IF NOT EXISTS project_contract_change_round (
id INTEGER PRIMARY KEY AUTOINCREMENT,
support_dept_code TEXT DEFAULT '',
raw_round_code TEXT DEFAULT '',
normalized_title TEXT DEFAULT '',
owner_department TEXT DEFAULT '',
business_division TEXT DEFAULT '',
support_dept_name TEXT DEFAULT '',
change_date TEXT DEFAULT '',
client_name TEXT DEFAULT '',
original_contract_period TEXT DEFAULT '',
changed_project_end_date TEXT DEFAULT '',
initial_contract_amount REAL DEFAULT 0,
changed_contract_amount REAL DEFAULT 0,
delta_amount REAL DEFAULT 0,
source_file TEXT DEFAULT '',
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
"""
)
)
conn.execute(
text(
"""
CREATE INDEX IF NOT EXISTS idx_project_contract_change_round_code
ON project_contract_change_round (support_dept_code, change_date)
"""
)
)
conn.execute(
text(
"""
CREATE INDEX IF NOT EXISTS idx_project_contract_change_round_title
ON project_contract_change_round (normalized_title)
"""
)
)
conn.execute(
text(
"""
CREATE TABLE IF NOT EXISTS project_billing_entries (
id INTEGER PRIMARY KEY AUTOINCREMENT,
support_dept_code TEXT,
raw_project_code TEXT DEFAULT '',
round_code TEXT DEFAULT '',
support_department TEXT DEFAULT '',
business_division TEXT DEFAULT '',
support_dept_name TEXT DEFAULT '',
contract_amount REAL DEFAULT 0,
client_name TEXT DEFAULT '',
billing_type TEXT DEFAULT '',
progress_round TEXT DEFAULT '',
billing_date TEXT DEFAULT '',
tax_invoice_date TEXT DEFAULT '',
expected_collection_date TEXT DEFAULT '',
billed_amount REAL DEFAULT 0,
collected_amount REAL DEFAULT 0,
balance_amount REAL DEFAULT 0,
collection_rate REAL DEFAULT 0,
note TEXT DEFAULT '',
source_file TEXT DEFAULT '',
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
"""
)
)
conn.execute(
text(
"""
CREATE INDEX IF NOT EXISTS idx_project_billing_entries_code
ON project_billing_entries (support_dept_code, billing_date)
"""
)
)
conn.execute(
text(
"""
CREATE INDEX IF NOT EXISTS idx_project_billing_entries_raw_code
ON project_billing_entries (raw_project_code)
"""
)
)
conn.execute(
text(
"""
CREATE INDEX IF NOT EXISTS idx_project_billing_entries_round_code
ON project_billing_entries (round_code)
"""
)
)
conn.execute(
text(
"""
CREATE INDEX IF NOT EXISTS idx_project_billing_entries_source_file
ON project_billing_entries (source_file)
"""
)
)
conn.execute(
text(
"""
CREATE INDEX IF NOT EXISTS idx_project_billing_entries_updated_at
ON project_billing_entries (updated_at)
"""
)
)
existing_columns = {
row[1]
for row in conn.execute(text("PRAGMA table_info(project_status)")).fetchall()
}
required_columns = {
"last_editor_session_id": "TEXT DEFAULT ''",
"last_client_submitted_at": "TEXT DEFAULT ''",
"contract_amount": "REAL DEFAULT 0",
"collection_entries_json": "TEXT DEFAULT '[]'",
"task_plan_department_budget": "REAL DEFAULT 0",
"task_plan_outsource_budget": "REAL DEFAULT 0",
"task_plan_outsource_detail": "TEXT DEFAULT ''",
"task_plan_joint_operating_cost": "REAL DEFAULT 0",
"task_plan_entries_json": "TEXT DEFAULT '[]'",
"exec_budget_labor_by_grade": "REAL DEFAULT 0",
"exec_labor_rates_json": "TEXT DEFAULT '{}'",
"exec_budget_outsource": "REAL DEFAULT 0",
"exec_budget_cost_plan": "REAL DEFAULT 0",
"exec_budget_entries_json": "TEXT DEFAULT '[]'",
"actual_input_entries_json": "TEXT DEFAULT '[]'",
"project_type": "TEXT DEFAULT ''",
"expected_as_rate": "REAL DEFAULT 0",
"expected_sga_rate": "REAL DEFAULT 0",
"expected_as_cost": "REAL DEFAULT 0",
"expected_sga_budget": "REAL DEFAULT 0",
"project_start_date": "TEXT DEFAULT ''",
"project_end_date": "TEXT DEFAULT ''",
"completion_status": "TEXT DEFAULT ''",
}
for column_name, column_type in required_columns.items():
if column_name not in existing_columns:
conn.execute(text(f"ALTER TABLE project_status ADD COLUMN {column_name} {column_type}"))
page_state_columns = {
row[1]
for row in conn.execute(text("PRAGMA table_info(project_page_state)")).fetchall()
}
required_page_state_columns = {
"session_id": "TEXT DEFAULT ''",
"selected_code": "TEXT DEFAULT ''",
"selected_year": "TEXT DEFAULT ''",
"analysis_open": "INTEGER DEFAULT 0",
"uncontracted_year_start": "TEXT DEFAULT ''",
"uncontracted_year_end": "TEXT DEFAULT ''",
"related_project_selections_json": "TEXT DEFAULT '{}'",
}
for column_name, column_type in required_page_state_columns.items():
if column_name not in page_state_columns:
conn.execute(text(f"ALTER TABLE project_page_state ADD COLUMN {column_name} {column_type}"))
related_link_columns = {
row[1]
for row in conn.execute(text("PRAGMA table_info(project_related_links)")).fetchall()
}
if "link_source" not in related_link_columns:
conn.execute(text("ALTER TABLE project_related_links ADD COLUMN link_source TEXT DEFAULT 'manual'"))
exec_budget_entry_columns = {
row[1]
for row in conn.execute(text("PRAGMA table_info(project_exec_budget_entries)")).fetchall()
}
if "rate_year" not in exec_budget_entry_columns:
conn.execute(text("ALTER TABLE project_exec_budget_entries ADD COLUMN rate_year TEXT DEFAULT ''"))
actual_input_entry_columns = {
row[1]
for row in conn.execute(text("PRAGMA table_info(project_actual_input_entries)")).fetchall()
}
if "rate_year" not in actual_input_entry_columns:
conn.execute(text("ALTER TABLE project_actual_input_entries ADD COLUMN rate_year TEXT DEFAULT ''"))
migrate_project_status_entries(conn)
migrate_project_basic_info(conn)
ensure_default_app_config(conn)
trans.commit()
conn.close()
init_wehago_compare_db(engine)
_DB_INIT_DONE = True
_maybe_run_db_analyze()
@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())
return parsed if isinstance(parsed, dict) else {}
except json.JSONDecodeError:
return {}
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 = "") -> str:
now = time.monotonic()
if 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 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 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):
support_dept_code = normalize_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": normalize_text(row[0] if len(row) > 0 else ""),
"order_method": normalize_text(row[2] if len(row) > 2 else ""),
"owner_department": normalize_text(row[3] if len(row) > 3 else ""),
"client_name": normalize_text(row[4] if len(row) > 4 else ""),
"support_dept_name": normalize_text(row[5] if len(row) > 5 else ""),
"work_category": normalize_text(row[6] if len(row) > 6 else ""),
"order_date": normalize_date_text(row[7] if len(row) > 7 else ""),
"contract_date": normalize_date_text(row[8] if len(row) > 8 else ""),
"project_start_date": normalize_date_text(row[9] if len(row) > 9 else ""),
"project_end_date": normalize_date_text(row[10] if len(row) > 10 else ""),
"contract_status": normalize_text(row[11] if len(row) > 11 else ""),
"joint_contract": normalize_text(row[12] if len(row) > 12 else ""),
"pm_name": normalize_text(row[13] if len(row) > 13 else ""),
"progress_status": normalize_text(row[14] if len(row) > 14 else ""),
"total_contract_amount": normalize_amount(row[15] if len(row) > 15 else 0),
"hanmac_contract_amount": normalize_amount(row[16] if len(row) > 16 else 0),
"review_tag": "",
"review_note": "",
"source_file": source_file,
}
conn.execute(
text(
"""
INSERT INTO project_contract_info (
support_dept_code, raw_contract_code, business_division, order_method,
owner_department, client_name, support_dept_name, work_category,
order_date, contract_date, project_start_date, project_end_date,
contract_status, joint_contract, pm_name, progress_status,
total_contract_amount, hanmac_contract_amount, review_tag, review_note,
source_file, updated_at
) VALUES (
:support_dept_code, :raw_contract_code, :business_division, :order_method,
:owner_department, :client_name, :support_dept_name, :work_category,
:order_date, :contract_date, :project_start_date, :project_end_date,
:contract_status, :joint_contract, :pm_name, :progress_status,
:total_contract_amount, :hanmac_contract_amount, :review_tag, :review_note,
:source_file, CURRENT_TIMESTAMP
)
ON CONFLICT(support_dept_code) DO UPDATE SET
raw_contract_code = excluded.raw_contract_code,
business_division = excluded.business_division,
order_method = excluded.order_method,
owner_department = excluded.owner_department,
client_name = excluded.client_name,
support_dept_name = excluded.support_dept_name,
work_category = excluded.work_category,
order_date = excluded.order_date,
contract_date = excluded.contract_date,
project_start_date = excluded.project_start_date,
project_end_date = excluded.project_end_date,
contract_status = excluded.contract_status,
joint_contract = excluded.joint_contract,
pm_name = excluded.pm_name,
progress_status = excluded.progress_status,
total_contract_amount = excluded.total_contract_amount,
hanmac_contract_amount = excluded.hanmac_contract_amount,
source_file = excluded.source_file,
updated_at = CURRENT_TIMESTAMP
"""
),
payload,
)
inserted += 1
refresh_contract_review_tags()
sync_auto_project_related_links()
return inserted
def import_billing_status_workbook(workbook: Any, source_file: str) -> int:
sheet = workbook.active
with engine.begin() as conn:
conn.execute(
text("DELETE FROM project_billing_entries WHERE source_file = :source_file"),
{"source_file": source_file},
)
inserted = 0
current: dict[str, Any] = {}
for row in sheet.iter_rows(min_row=6, values_only=True):
values = list(row)
if values and all(value in (None, "") for value in values):
continue
if values[0] is not None:
current["support_department"] = normalize_text(values[0])
if len(values) > 1 and values[1] is not None:
current["business_division"] = normalize_text(values[1])
if len(values) > 2 and values[2] is not None:
current["raw_project_code"] = normalize_text(values[2])
if len(values) > 3 and values[3] is not None:
current["round_code"] = normalize_text(values[3])
if len(values) > 4 and values[4] is not None:
current["support_dept_name"] = normalize_text(values[4])
if len(values) > 5 and values[5] is not None:
current["contract_amount"] = normalize_amount(values[5])
if len(values) > 6 and values[6] is not None:
current["client_name"] = normalize_text(values[6])
round_code_text = normalize_text(current.get("round_code"))
round_prefix = next((character.upper() for character in round_code_text if character.isalpha()), "Y")
normalized_round_code = normalize_project_code(
current.get("round_code"),
default_prefix=round_prefix,
)
normalized_raw_project_code = normalize_project_code(
current.get("raw_project_code"),
default_prefix=round_prefix,
)
# Billing workbook stores the parent contract code in raw_project_code
# and the actual charge/collection project code in round_code.
# Prefer round_code when present so each sub-project keeps its own billing history.
support_dept_code = normalized_round_code or normalized_raw_project_code
if not support_dept_code:
continue
summary_row = normalize_text(values[11] if len(values) > 11 else "") == "합계" or normalize_text(values[10] if len(values) > 10 else "").startswith("수금 :")
department_summary = "합계" in normalize_text(values[4] if len(values) > 4 else "")
if summary_row or department_summary:
continue
payload = {
"support_dept_code": support_dept_code,
"raw_project_code": normalize_text(current.get("raw_project_code")),
"round_code": normalize_text(current.get("round_code")),
"support_department": normalize_text(current.get("support_department")),
"business_division": normalize_text(current.get("business_division")),
"support_dept_name": normalize_text(current.get("support_dept_name")),
"contract_amount": normalize_amount(current.get("contract_amount")),
"client_name": normalize_text(current.get("client_name")),
"billing_type": normalize_text(values[7] if len(values) > 7 else ""),
"progress_round": normalize_round_value(values[8] if len(values) > 8 else ""),
"billing_date": normalize_date_text(values[9] if len(values) > 9 else ""),
"tax_invoice_date": normalize_date_text(values[10] if len(values) > 10 else ""),
"expected_collection_date": normalize_date_text(values[11] if len(values) > 11 else ""),
"billed_amount": normalize_amount(values[12] if len(values) > 12 else 0),
"collected_amount": normalize_amount(values[13] if len(values) > 13 else 0),
"balance_amount": normalize_amount(values[14] if len(values) > 14 else 0),
"collection_rate": normalize_amount(values[15] if len(values) > 15 else 0),
"note": normalize_text(values[16] if len(values) > 16 else ""),
"source_file": source_file,
}
conn.execute(
text(
"""
INSERT INTO project_billing_entries (
support_dept_code, raw_project_code, round_code, support_department,
business_division, support_dept_name, contract_amount, client_name,
billing_type, progress_round, billing_date, tax_invoice_date,
expected_collection_date, billed_amount, collected_amount,
balance_amount, collection_rate, note, source_file, updated_at
) VALUES (
:support_dept_code, :raw_project_code, :round_code, :support_department,
:business_division, :support_dept_name, :contract_amount, :client_name,
:billing_type, :progress_round, :billing_date, :tax_invoice_date,
:expected_collection_date, :billed_amount, :collected_amount,
:balance_amount, :collection_rate, :note, :source_file, CURRENT_TIMESTAMP
)
"""
),
payload,
)
inserted += 1
refresh_contract_review_tags()
return inserted
def import_change_contract_summary_workbook(workbook: Any, source_file: str) -> int:
sheet = workbook.active
with engine.begin() as conn:
conn.execute(
text("DELETE FROM project_contract_change_summary WHERE source_file = :source_file"),
{"source_file": source_file},
)
inserted = 0
current_department = ""
for row in sheet.iter_rows(min_row=2, values_only=True):
values = list(row)
if not values or all(value in (None, "") for value in values):
continue
first_value = normalize_text(values[0] if len(values) > 0 else "")
if "소 계" in first_value or first_value.startswith("<"):
continue
if first_value:
current_department = first_value
raw_summary_code = normalize_text(values[2] if len(values) > 2 else "").replace("\xa0", "")
support_dept_name = normalize_text(values[3] if len(values) > 3 else "")
if not raw_summary_code or not support_dept_name:
continue
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": normalize_text(values[1] if len(values) > 1 else ""),
"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 "")
support_dept_code = normalize_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": normalize_text(values[1] if len(values) > 1 else ""),
"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_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()
existing_codes = {
normalize_text(row[0])
for row in conn.execute(
text(
"""
SELECT DISTINCT support_dept_code FROM transactions
WHERE COALESCE(support_dept_code, '') <> ''
UNION
SELECT DISTINCT support_dept_code FROM project_status
WHERE COALESCE(support_dept_code, '') <> ''
UNION
SELECT DISTINCT support_dept_code FROM project_contract_info
WHERE COALESCE(support_dept_code, '') <> ''
UNION
SELECT DISTINCT support_dept_code FROM project_billing_entries
WHERE COALESCE(support_dept_code, '') <> ''
UNION
SELECT DISTINCT support_dept_code FROM project_contract_change_round
WHERE COALESCE(support_dept_code, '') <> ''
"""
)
).fetchall()
if normalize_text(row[0])
}
project_names = {
normalize_text(row[0]): normalize_text(row[1])
for row in conn.execute(
text(
"""
WITH project_names AS (
SELECT support_dept_code, MAX(support_dept_name) AS support_dept_name
FROM transactions
WHERE COALESCE(support_dept_code, '') <> ''
GROUP BY support_dept_code
UNION
SELECT support_dept_code, support_dept_name
FROM project_contract_info
WHERE COALESCE(support_dept_code, '') <> ''
UNION
SELECT support_dept_code, support_dept_name
FROM project_billing_entries
WHERE COALESCE(support_dept_code, '') <> ''
UNION
SELECT support_dept_code, support_dept_name
FROM project_contract_change_round
WHERE COALESCE(support_dept_code, '') <> ''
)
SELECT support_dept_code, support_dept_name
FROM project_names
WHERE COALESCE(support_dept_code, '') <> ''
"""
)
).fetchall()
if normalize_text(row[0])
}
contracted_codes = {
normalize_text(row[0])
for row in conn.execute(
text(
"""
SELECT support_dept_code
FROM project_contract_info
WHERE COALESCE(hanmac_contract_amount, 0) > 0
UNION
SELECT support_dept_code
FROM project_billing_entries
GROUP BY support_dept_code
HAVING SUM(COALESCE(collected_amount, 0)) > 0 OR MAX(COALESCE(contract_amount, 0)) > 0
"""
)
).fetchall()
if normalize_text(row[0])
}
round_cluster_map: dict[str, set[str]] = {}
cluster_map: dict[str, set[str]] = {}
for row in billing_rows:
base_code = normalize_text(row["support_dept_code"])
raw_project_code = normalize_project_code(
row["raw_project_code"],
default_prefix=base_code[:1] or "Y",
) or normalize_text(row["raw_project_code"])
round_code = normalize_project_code(row["round_code"], default_prefix=base_code[:1] or "Y")
if not base_code:
continue
cluster_key = raw_project_code or base_code
round_cluster = round_cluster_map.setdefault(cluster_key, set())
cluster = cluster_map.setdefault(cluster_key, set())
if base_code in existing_codes:
round_cluster.add(base_code)
cluster.add(base_code)
if round_code and round_code in existing_codes:
round_cluster.add(round_code)
cluster.add(round_code)
change_round_title_groups: dict[str, set[str]] = {}
change_contract_codes: set[str] = set()
for row in change_round_rows:
code = normalize_text(row["support_dept_code"])
title_key = normalize_text(row["normalized_title"]) or normalize_project_title_for_linking(row["support_dept_name"])
if not code or not title_key:
continue
change_contract_codes.add(code)
change_round_title_groups.setdefault(title_key, set()).add(code)
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
)
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("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 = excluded.link_source,
updated_at = CURRENT_TIMESTAMP
"""
),
{
"base_support_dept_code": base_code,
"related_support_dept_code": related_code,
"link_source": link_source,
},
)
for base_code, related_code, link_source in conn.execute(
text(
"""
SELECT base_support_dept_code, related_support_dept_code, COALESCE(link_source, 'manual') AS link_source
FROM project_related_links
WHERE COALESCE(link_source, 'manual') LIKE 'auto_title%'
"""
)
).fetchall():
normalized_base = normalize_text(base_code)
normalized_related = normalize_text(related_code)
if (
normalized_base in change_contract_codes
and normalized_related not in change_contract_codes
and not normalized_related.startswith("X")
) or (
normalized_related in change_contract_codes
and normalized_base not in change_contract_codes
and not normalized_base.startswith("X")
):
conn.execute(
text(
"""
DELETE FROM project_related_links
WHERE base_support_dept_code = :base_support_dept_code
AND related_support_dept_code = :related_support_dept_code
"""
),
{
"base_support_dept_code": normalized_base,
"related_support_dept_code": normalized_related,
},
)
x_codes_linked_to_change: set[str] = set()
for base_code, related_code in conn.execute(
text(
"""
SELECT base_support_dept_code, related_support_dept_code
FROM project_related_links
WHERE (
base_support_dept_code LIKE 'X%' AND related_support_dept_code <> ''
) OR (
related_support_dept_code LIKE 'X%' AND base_support_dept_code <> ''
)
"""
)
).fetchall():
normalized_base = normalize_text(base_code)
normalized_related = normalize_text(related_code)
if normalized_base.startswith("X") and normalized_related in change_contract_codes:
x_codes_linked_to_change.add(normalized_base)
if normalized_related.startswith("X") and normalized_base in change_contract_codes:
x_codes_linked_to_change.add(normalized_related)
for base_code, related_code in conn.execute(
text(
"""
SELECT base_support_dept_code, related_support_dept_code
FROM project_related_links
WHERE COALESCE(link_source, 'manual') LIKE 'auto_title%'
"""
)
).fetchall():
normalized_base = normalize_text(base_code)
normalized_related = normalize_text(related_code)
should_delete = (
normalized_base in x_codes_linked_to_change
and normalized_related not in change_contract_codes
and not normalized_related.startswith("X")
) or (
normalized_related in x_codes_linked_to_change
and normalized_base not in change_contract_codes
and not normalized_base.startswith("X")
)
if should_delete:
conn.execute(
text(
"""
DELETE FROM project_related_links
WHERE base_support_dept_code = :base_support_dept_code
AND related_support_dept_code = :related_support_dept_code
"""
),
{
"base_support_dept_code": normalized_base,
"related_support_dept_code": normalized_related,
},
)
for cluster_codes in round_cluster_map.values():
normalized_cluster = sorted(code for code in cluster_codes if code in existing_codes)
if len(normalized_cluster) < 2:
continue
for base_code in normalized_cluster:
for related_code in normalized_cluster:
if base_code == related_code:
continue
conn.execute(
text(
"""
INSERT INTO project_related_links (
base_support_dept_code,
related_support_dept_code,
link_source,
updated_at
) VALUES (
:base_support_dept_code,
:related_support_dept_code,
'auto_round',
CURRENT_TIMESTAMP
)
ON CONFLICT(base_support_dept_code, related_support_dept_code) DO UPDATE SET
link_source = excluded.link_source,
updated_at = CURRENT_TIMESTAMP
"""
),
{
"base_support_dept_code": base_code,
"related_support_dept_code": related_code,
},
)
@app.on_event("startup")
def on_startup() -> None:
init_db()
init_wehago_compare_db(engine)
_ensure_app_maintenance_worker()
with engine.begin() as conn:
corrected = sanitize_project_labor_amount_rows(conn)
if corrected:
logger.info("Sanitized project labor amounts for %s project(s)", corrected)
run_heavy_startup = normalize_text(os.getenv("HM_RUN_HEAVY_STARTUP", "0")) in {"1", "true", "yes", "on"}
if run_heavy_startup:
auto_import_project_excels()
sync_auto_project_related_links()
normalize_all_collection_entry_storage()
else:
logger.info("Skipping heavy startup refresh tasks; existing DB state will be used as-is")
logger.info("DB ready at %s", DB_PATH)
@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에서 자주 보는 테이블을 읽기 전용으로 조회합니다.",
},
{
"title": "2단계: 기준키 정리",
"description": "프로젝트코드, 사번, 부서코드, 일자를 현재 앱 DB와 연결할 공통 키로 정리합니다.",
},
{
"title": "3단계: 통합 화면",
"description": "프로젝트 정보, 원가/전표 데이터, manhour 데이터를 한 화면에서 함께 보여줍니다.",
},
],
"schemas": [
{
"name": "hanmac",
"role": "기본 업무/REST API 성격의 운영 데이터 확인",
"tables": ["restapi"],
},
{
"name": "hanmac_manhour",
"role": "일일업무, 투입시간, 사원/프로젝트 기준 데이터 확인",
"tables": [
"dallyproject_tbl",
"dallyproject_addwork_tbl",
"dallyproject_2020_tbl",
"member_tbl",
"project_tbl",
],
},
],
"recommended_views": [
{
"title": "일일업무 조회",
"summary": "사번, 이름, 프로젝트코드, 기간으로 가장 자주 볼 가능성이 높은 기본 화면입니다.",
"fields": ["입력일", "사번", "이름", "프로젝트코드", "업무내용", "시간"],
},
{
"title": "사원별 투입시간",
"summary": "특정 직원이 어느 프로젝트에 얼마나 투입되었는지 월/기간 기준으로 확인합니다.",
"fields": ["사번", "이름", "기간", "프로젝트수", "총 시간", "최근 입력일"],
},
{
"title": "프로젝트별 투입시간",
"summary": "프로젝트 기준으로 참여 인원과 누적 manhour를 보며 현재 DB의 프로젝트 정보와 붙이기 좋습니다.",
"fields": ["프로젝트코드", "프로젝트명", "참여인원", "총 시간", "최근 입력일"],
},
{
"title": "마스터 조회",
"summary": "member_tbl, project_tbl을 기준으로 사원/프로젝트 마스터를 점검합니다.",
"fields": ["사번", "이름", "부서", "직급", "프로젝트코드", "프로젝트명"],
},
],
"join_keys": [
{
"key": "project_code",
"reason": "현재 프로젝트 정보 화면과 연결하는 핵심 기준값입니다.",
},
{
"key": "member_no",
"reason": "사원별 투입시간과 사용자/담당자 기준 분석의 핵심 키입니다.",
},
{
"key": "dept_code",
"reason": "지원부서/원가부서 또는 조직 기준 요약과 연결하기 좋습니다.",
},
{
"key": "work_date",
"reason": "전표일자, 증빙일자, 프로젝트 기간 분석과 함께 볼 때 필요합니다.",
},
],
"sample_queries": [
{
"title": "일일업무 최근 입력 조회",
"sql": (
"SELECT EntryTime, MemberNo, project_code, contents, work_hour, work_min "
"FROM dallyproject_addwork_tbl ORDER BY EntryTime DESC LIMIT 100;"
),
},
{
"title": "사원별 누적 투입시간",
"sql": (
"SELECT MemberNo, SUM(COALESCE(work_hour, 0) * 60 + COALESCE(work_min, 0)) AS total_minutes "
"FROM dallyproject_addwork_tbl GROUP BY MemberNo ORDER BY total_minutes DESC LIMIT 50;"
),
},
{
"title": "프로젝트별 투입시간",
"sql": (
"SELECT project_code, SUM(COALESCE(work_hour, 0) * 60 + COALESCE(work_min, 0)) AS total_minutes "
"FROM dallyproject_addwork_tbl GROUP BY project_code ORDER BY total_minutes DESC LIMIT 50;"
),
},
],
"integration_notes": [
"우선은 외부 MySQL을 읽기 전용으로 직접 조회합니다.",
"조회 패턴이 안정되면 필요한 테이블만 로컬 SQLite로 동기화합니다.",
"동기화 후에는 현재 프로젝트 정보/원가/전표 화면에 manhour 요약 칼럼을 추가합니다.",
],
"connection_requirements": [
"MySQL 접속 정보(host, port, user, password)",
"허용할 스키마 목록(hanmac, hanmac_manhour)",
"읽기 전용 계정 여부 확인",
],
}
def normalize_text(value: Any) -> str:
if value is None:
return ""
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_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)
def _parse_labor_rates_json(raw_json: Any) -> 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, 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] = {}
for grade_key, amount_value in bucket.items():
grade_text = normalize_text(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, float]],
grade: Any,
rate_year: Any,
fallback_year: Any = "",
) -> float:
grade_text = normalize_text(grade)
if not grade_text:
return 0.0
year_candidates: list[str] = []
for value in (rate_year, fallback_year):
text_value = normalize_text(value)
if text_value and text_value not in year_candidates:
year_candidates.append(text_value)
if not year_candidates:
year_candidates.append(str(datetime.now().year))
for year_text in year_candidates:
year_bucket = rates_by_year.get(year_text) or {}
amount = normalize_amount(year_bucket.get(grade_text))
if amount:
return amount
return 0.0
def _parse_exec_hours_value(value: Any) -> float:
digits = "".join(character for character in normalize_text(value) if character.isdigit())
if not digits:
return 0.0
return float(int(digits[:5]))
def _parse_minutes_value(value: Any) -> float:
digits = "".join(character for character in normalize_text(value) if character.isdigit())
if not digits:
return 0.0
return float(int(digits))
def sanitize_project_labor_amount_rows(conn: Any) -> int:
shared_rates = _parse_labor_rates_json(get_shared_exec_labor_rates_json())
rows = conn.execute(
text(
"""
SELECT support_dept_code, COALESCE(exec_labor_rates_json, '{}') AS exec_labor_rates_json
FROM project_status
WHERE COALESCE(support_dept_code, '') <> ''
"""
)
).mappings().all()
updated_count = 0
for row in rows:
code = normalize_text(row.get("support_dept_code"))
if not code:
continue
entry_set = load_project_status_entries_for_code(conn, code)
exec_entries = list(entry_set.get("exec_budget_entries", []))
actual_entries = list(entry_set.get("actual_input_entries", []))
rates = _parse_labor_rates_json(row.get("exec_labor_rates_json")) or shared_rates
changed = False
for entry in exec_entries:
if normalize_text(entry.get("group")) != "labor":
continue
hours_value = _parse_exec_hours_value(entry.get("hours"))
next_amount = _resolve_labor_rate(rates, entry.get("grade"), entry.get("rate_year")) * hours_value
if abs(normalize_amount(entry.get("amount")) - next_amount) > 0.5:
entry["amount"] = next_amount
changed = True
for entry in actual_entries:
if normalize_text(entry.get("group")) != "labor":
continue
minutes_value = _parse_minutes_value(entry.get("minutes"))
next_amount = _resolve_labor_rate(rates, entry.get("grade"), entry.get("rate_year")) * (minutes_value / 60.0 if minutes_value else 0.0)
if abs(normalize_amount(entry.get("amount")) - next_amount) > 0.5:
entry["amount"] = next_amount
changed = True
if not changed:
continue
replace_project_status_child_entries(
conn,
code,
entry_set.get("collection_entries", []),
entry_set.get("task_plan_entries", []),
exec_entries,
actual_entries,
)
sync_project_status_cache_row(conn, code)
updated_count += 1
return updated_count
def get_support_department_options() -> list[dict[str, str]]:
with engine.begin() as conn:
rows = conn.execute(
text(
"""
SELECT DISTINCT support_dept_code, support_dept_name
FROM (
SELECT support_dept_code, support_dept_name
FROM transactions
UNION ALL
SELECT support_dept_code, support_dept_name
FROM project_contract_info
UNION ALL
SELECT support_dept_code, support_dept_name
FROM project_billing_entries
) AS merged
WHERE COALESCE(support_dept_code, '') <> ''
AND COALESCE(support_dept_name, '') <> ''
AND support_dept_code NOT IN ('ZZZZZZ')
AND support_dept_name NOT IN ('공통', '경영지원부', '기술개발센터', '임원실', '기술개발부', '총괄기획실')
ORDER BY support_dept_code, support_dept_name
"""
)
).mappings().all()
return [
{
"support_dept_code": normalize_text(row["support_dept_code"]),
"support_dept_name": normalize_text(row["support_dept_name"]),
}
for row in rows
]
def get_cost_department_options() -> list[dict[str, str]]:
with engine.begin() as conn:
rows = conn.execute(
text(
"""
SELECT DISTINCT cost_dept_name
FROM transactions
WHERE COALESCE(cost_dept_name, '') <> ''
ORDER BY cost_dept_name
"""
)
).mappings().all()
return [
{"cost_dept_name": normalize_text(row["cost_dept_name"])}
for row in rows
if normalize_text(row["cost_dept_name"])
]
def get_cost_account_options() -> list[dict[str, str]]:
with engine.begin() as conn:
rows = conn.execute(
text(
"""
SELECT DISTINCT account_code, account_name
FROM transactions
WHERE accounting_category = '원가'
AND COALESCE(account_code, '') <> ''
AND COALESCE(account_name, '') <> ''
ORDER BY account_code, account_name
"""
)
).mappings().all()
deduped: dict[tuple[str, str], dict[str, str]] = {}
for row in rows:
account_code = normalize_text(row["account_code"])
account_name = normalize_text(row["account_name"])
if not account_code or not account_name:
continue
normalized_code, normalized_name, _ = normalize_account_display(account_code, account_name)
key = (normalized_code, normalized_name)
deduped[key] = {
"account_code": normalized_code,
"account_name": normalized_name,
}
deduped[(EXEC_COST_PLAN_OTHER_CODE, EXEC_COST_PLAN_OTHER_NAME)] = {
"account_code": EXEC_COST_PLAN_OTHER_CODE,
"account_name": EXEC_COST_PLAN_OTHER_NAME,
}
return sorted(deduped.values(), key=lambda item: (item["account_code"], item["account_name"]))
def get_import_sync_summary() -> dict[str, Any]:
with engine.begin() as conn:
row = conn.execute(
text(
"""
SELECT
(SELECT COUNT(*) FROM project_contract_info) AS contract_project_count,
(SELECT COUNT(*) FROM project_billing_entries) AS billing_entry_count,
(SELECT COUNT(DISTINCT support_dept_code) FROM project_billing_entries) AS billing_project_count,
(SELECT COUNT(*) FROM project_contract_info WHERE COALESCE(review_tag, '') <> '') AS review_needed_count,
(SELECT SUM(hanmac_contract_amount) FROM project_contract_info) AS total_hanmac_contract_amount,
(SELECT SUM(collected_amount) FROM project_billing_entries) AS total_collected_amount,
(SELECT MAX(updated_at) FROM project_contract_info) AS latest_contract_sync,
(SELECT MAX(updated_at) FROM project_billing_entries) AS latest_billing_sync
"""
)
).mappings().first()
return dict(row) if row else {}
def get_project_contract_info_map() -> dict[str, dict[str, Any]]:
with engine.begin() as conn:
rows = conn.execute(
text("SELECT * FROM project_contract_info ORDER BY support_dept_code")
).mappings().all()
return {normalize_text(row["support_dept_code"]): dict(row) for row in rows}
def get_project_billing_summary_map() -> dict[str, dict[str, Any]]:
with engine.begin() as conn:
rows = conn.execute(
text(
"""
SELECT support_dept_code,
MAX(support_dept_name) AS support_dept_name,
MAX(contract_amount) AS contract_amount,
MAX(client_name) AS client_name,
MAX(support_department) AS support_department,
MAX(business_division) AS business_division,
SUM(billed_amount) AS billed_amount,
SUM(collected_amount) AS collected_amount,
SUM(balance_amount) AS balance_amount,
MAX(billing_date) AS latest_billing_date
FROM project_billing_entries
GROUP BY support_dept_code
ORDER BY support_dept_code
"""
)
).mappings().all()
entry_rows = conn.execute(
text(
"""
SELECT support_dept_code,
billing_type,
progress_round,
billing_date,
tax_invoice_date,
expected_collection_date,
billed_amount,
collected_amount,
balance_amount,
collection_rate,
note
FROM project_billing_entries
ORDER BY support_dept_code, billing_date, progress_round, id
"""
)
).mappings().all()
result = {normalize_text(row["support_dept_code"]): dict(row) for row in rows}
for item in result.values():
item["entries"] = []
for row in entry_rows:
support_dept_code = normalize_text(row["support_dept_code"])
if support_dept_code not in result:
continue
result[support_dept_code]["entries"].append(
normalize_collection_entry_row(
{
"progress_type": "",
"billing_round": normalize_round_value(row["progress_round"]),
"billing_type": normalize_text(row["billing_type"]),
"billing_date": normalize_date_text(row["billing_date"]),
"billed_amount": normalize_amount(row["billed_amount"]),
"round": normalize_round_value(row["progress_round"]),
"date": normalize_date_text(row["tax_invoice_date"]) or normalize_date_text(row["expected_collection_date"]),
"amount": normalize_amount(row["collected_amount"]),
"balance_amount": normalize_amount(row["balance_amount"]),
"collection_rate": normalize_amount(row["collection_rate"]),
"note": normalize_text(row["note"]),
}
)
)
return result
def merge_project_external_fields(
item: dict[str, Any],
contract_info: dict[str, Any] | None,
billing_summary: dict[str, Any] | None,
latest_summary_change: dict[str, Any] | None = None,
latest_round_change: dict[str, Any] | None = None,
change_representative_code: str = "",
change_title_key: str = "",
) -> dict[str, Any]:
contract_info = contract_info or {}
billing_summary = billing_summary or {}
support_dept_name = normalize_text(item.get("support_dept_name")) or normalize_text(contract_info.get("support_dept_name")) or normalize_text(billing_summary.get("support_dept_name"))
contract_amount = normalize_amount(item.get("contract_amount"))
latest_summary_change = latest_summary_change or {}
latest_round_change = latest_round_change or {}
direct_contract_amount = (
normalize_amount(billing_summary.get("contract_amount"))
or normalize_amount(contract_info.get("hanmac_contract_amount"))
or contract_amount
)
latest_round_contract_amount = normalize_amount(latest_round_change.get("changed_contract_amount"))
latest_changed_contract_amount = (
normalize_amount(latest_summary_change.get("changed_contract_amount"))
or latest_round_contract_amount
)
current_code = normalize_text(item.get("support_dept_code"))
if direct_contract_amount:
contract_amount = direct_contract_amount
elif latest_round_contract_amount:
contract_amount = latest_round_contract_amount
elif latest_changed_contract_amount and (not change_representative_code or current_code == change_representative_code):
contract_amount = latest_changed_contract_amount
collection_amount = normalize_amount(item.get("collection_amount"))
if not collection_amount:
collection_amount = normalize_amount(billing_summary.get("collected_amount"))
collection_entries = item.get("collection_entries")
if not collection_entries:
collection_entries = billing_summary.get("entries", [])
project_start_date = normalize_text(item.get("project_start_date")) or normalize_text(contract_info.get("project_start_date"))
project_end_date = (
normalize_text(item.get("project_end_date"))
or normalize_text(contract_info.get("project_end_date"))
or normalize_text(latest_summary_change.get("changed_project_end_date"))
or normalize_text(latest_round_change.get("changed_project_end_date"))
)
completion_status = normalize_text(item.get("completion_status")) or normalize_text(contract_info.get("progress_status"))
project_type = (
normalize_text(item.get("project_type"))
or normalize_text(contract_info.get("business_division"))
or normalize_text(billing_summary.get("business_division"))
or normalize_text(latest_summary_change.get("business_division"))
or normalize_text(latest_round_change.get("business_division"))
)
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:
with engine.begin() as conn:
transaction_updated = conn.execute(text("SELECT MAX(updated_at) FROM transactions")).scalar()
project_updated = conn.execute(text("SELECT MAX(updated_at) FROM project_status")).scalar()
contract_updated = conn.execute(text("SELECT MAX(updated_at) FROM project_contract_info")).scalar()
billing_updated = conn.execute(text("SELECT MAX(updated_at) FROM project_billing_entries")).scalar()
change_summary_updated = conn.execute(text("SELECT MAX(updated_at) FROM project_contract_change_summary")).scalar()
change_round_updated = conn.execute(text("SELECT MAX(updated_at) FROM project_contract_change_round")).scalar()
versions = [
normalize_text(transaction_updated),
normalize_text(project_updated),
normalize_text(contract_updated),
normalize_text(billing_updated),
normalize_text(change_summary_updated),
normalize_text(change_round_updated),
]
return max((version for version in versions if version), default="")
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)
payload = {
"status": "ok",
"server_time": datetime.now().isoformat(timespec="seconds"),
"data_version": get_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 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": {},
"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:
return {
"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 = 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 resolve_selected_year(selected_year: int | None) -> int | None:
return selected_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) -> list[dict[str, Any]]:
year_clause = ""
params: dict[str, Any] = {}
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
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]]]]:
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 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 get_process_cost_bootstrap_payload(
source: str | None = None,
start_year: int | None = None,
end_year: int | None = None,
code: str | None = None,
include_related: bool = False,
active_related: str | None = None,
) -> dict[str, Any]:
normalized_source = normalize_text(source).lower()
if normalized_source not in {"hanmac", "wehago"}:
normalized_source = "hanmac"
years = get_process_cost_available_years(normalized_source)
selected_start_year = start_year if start_year in years else None
selected_end_year = end_year if end_year in years else None
if not selected_start_year and not selected_end_year and years:
selected_start_year = years[0]
selected_end_year = years[-1]
elif selected_start_year and selected_end_year and selected_start_year > selected_end_year:
selected_end_year = selected_start_year
elif selected_end_year and not selected_start_year:
selected_start_year = years[0] if years else None
if selected_start_year and selected_start_year > selected_end_year:
selected_start_year = selected_end_year
elif selected_start_year and not selected_end_year:
selected_end_year = years[-1] if years else None
if selected_end_year and selected_end_year < selected_start_year:
selected_end_year = selected_start_year
project_options = get_process_cost_project_options(
normalized_source,
selected_start_year,
selected_end_year,
include_related=include_related,
)
selected_code = normalize_text(code)
if selected_code and not any(item["support_dept_code"] == selected_code for item in project_options):
selected_code = ""
selected_project = next(
(item for item in project_options if normalize_text(item.get("support_dept_code")) == selected_code),
None,
)
related_codes = get_process_cost_related_codes(selected_code)
active_related_codes: list[str] = []
if include_related and selected_code:
normalized_active_related = normalize_text(active_related)
if normalized_active_related in {"-", "__none__"}:
active_related_codes = []
else:
requested_active_codes = [
normalize_text(value)
for value in normalized_active_related.split(",")
if normalize_text(value)
]
if requested_active_codes:
active_related_codes = [
value for value in requested_active_codes
if value != selected_code and value in related_codes
]
else:
active_related_codes = list(related_codes)
detail = get_process_cost_project_detail(
normalized_source,
None,
selected_code,
include_related=include_related,
active_related_codes=active_related_codes,
)
return {
"projects": project_options,
"selectedCode": selected_code,
"selectedProject": selected_project or {},
"relatedCodes": related_codes,
"activeRelatedCodes": active_related_codes,
"quickLinkCodes": get_process_cost_quick_links(),
"source": normalized_source,
"selectedStartYear": selected_start_year,
"selectedEndYear": selected_end_year,
"includeRelatedEnabled": include_related,
"monthlyRows": (detail.get("monthly_rows") or []),
}
def get_financial_series(granularity: str) -> list[dict[str, Any]]:
group_fields = "year" if granularity == "yearly" else "year, month"
order_fields = "year" if granularity == "yearly" else "year, month"
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,
) * 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,
) * (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()
return {
"request": request,
"message": message,
"data_version": health_payload["data_version"],
"server_time": health_payload["server_time"],
"import_sync_summary": get_import_sync_summary(),
}
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()
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,)
now = time.time()
with _DASHBOARD_BOOTSTRAP_CACHE_LOCK:
cached = _DASHBOARD_BOOTSTRAP_CACHE.get(cache_key)
if cached and now - float(cached.get("stored_at") or 0.0) <= DASHBOARD_BOOTSTRAP_CACHE_TTL_SECONDS:
return copy.deepcopy(cached.get("payload") or {})
payload = {
"yearly_summary": get_yearly_summary(),
"monthly_summary": get_monthly_summary(),
"project_revenue_mix_yearly": get_project_revenue_mix(),
"project_revenue_mix_monthly": get_project_revenue_mix_monthly(),
"overview_selected_year": overview_year,
}
with _DASHBOARD_BOOTSTRAP_CACHE_LOCK:
_DASHBOARD_BOOTSTRAP_CACHE[cache_key] = {
"stored_at": now,
"payload": copy.deepcopy(payload),
}
return payload
def render_projects_page(
request: Request,
edit_code: str | None = None,
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(),
}
return templates.TemplateResponse(request, "projects.html", context)
def render_annual_summary_page(request: Request, message: str = "") -> HTMLResponse:
init_db()
context = {
**base_context(request, message),
"available_years": get_available_years(),
"annual_metric_cards": get_option_items("annual_metric_cards"),
"annual_expense_chart_metrics": get_option_items("annual_expense_chart_metrics"),
"annual_balance_chart_metrics": get_option_items("annual_balance_chart_metrics"),
}
return templates.TemplateResponse(request, "annual_summary.html", context)
def get_annual_summary_bootstrap_payload() -> dict[str, Any]:
now = time.time()
with _ANNUAL_SUMMARY_BOOTSTRAP_CACHE_LOCK:
cached_payload = _ANNUAL_SUMMARY_BOOTSTRAP_CACHE.get("payload")
cached_at = float(_ANNUAL_SUMMARY_BOOTSTRAP_CACHE.get("stored_at") or 0.0)
if cached_payload and now - cached_at <= ANNUAL_SUMMARY_BOOTSTRAP_CACHE_TTL_SECONDS:
return copy.deepcopy(cached_payload)
payload = {
"yearly_financial_series": get_financial_series("yearly"),
"monthly_financial_series": get_financial_series("monthly"),
}
with _ANNUAL_SUMMARY_BOOTSTRAP_CACHE_LOCK:
_ANNUAL_SUMMARY_BOOTSTRAP_CACHE["stored_at"] = now
_ANNUAL_SUMMARY_BOOTSTRAP_CACHE["payload"] = copy.deepcopy(payload)
return payload
def render_wehago_compare_page(
request: Request,
start_year: int | None = None,
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=False,
warm_caches=False,
),
}
return templates.TemplateResponse(request, "wehago_compare.html", context)
def render_hanmac_browser_page(
request: Request,
message: str = "",
) -> HTMLResponse:
context = {
**base_context(request, message),
}
return templates.TemplateResponse(request, "hanmac_browser.html", context)
def _build_hanmac_mysql_engine(payload: dict[str, Any]):
host = normalize_text(payload.get("host"))
port_raw = normalize_text(payload.get("port")) or "3306"
user = normalize_text(payload.get("user"))
password = payload.get("password")
database = normalize_text(payload.get("database"))
if not host:
raise ValueError("서버 IP를 입력해주세요.")
if not user:
raise ValueError("아이디를 입력해주세요.")
if password in (None, ""):
raise ValueError("비밀번호를 입력해주세요.")
if not database:
raise ValueError("DB를 선택해주세요.")
try:
port = int(port_raw)
except ValueError as exc:
raise ValueError("포트 번호를 숫자로 입력해주세요.") from exc
return create_engine(
URL.create(
"mysql+pymysql",
username=user,
password=str(password),
host=host,
port=port,
database=database,
query={"charset": "utf8"},
),
pool_pre_ping=True,
pool_recycle=300,
connect_args={"connect_timeout": 5},
)
def test_hanmac_mysql_connection(payload: dict[str, Any]) -> dict[str, Any]:
database = normalize_text(payload.get("database"))
test_engine = _build_hanmac_mysql_engine(payload)
try:
with test_engine.connect() as connection:
current_database = connection.execute(text("SELECT DATABASE()")).scalar()
current_user = connection.execute(text("SELECT CURRENT_USER()")).scalar()
server_version = connection.execute(text("SELECT VERSION()")).scalar()
return {
"status": "ok",
"message": f"{database} 연결에 성공했습니다.",
"database": current_database or database,
"user": current_user or user,
"server_version": server_version or "",
}
finally:
test_engine.dispose()
def _validate_hanmac_table_name(table_name: Any) -> str:
normalized = normalize_text(table_name)
if not normalized:
raise ValueError("테이블 이름이 필요합니다.")
if not re.fullmatch(r"[A-Za-z0-9_]+", normalized):
raise ValueError("테이블 이름 형식이 올바르지 않습니다.")
return normalized
def _validate_hanmac_schema_name(schema_name: Any) -> str:
normalized = normalize_text(schema_name)
if normalized not in {"hanmac", "hanmac_manhour"}:
raise ValueError("스키마 이름이 올바르지 않습니다.")
return normalized
def get_hanmac_table_list(payload: dict[str, Any]) -> dict[str, Any]:
connect_payload = dict(payload)
connect_payload["database"] = normalize_text(payload.get("database")) or "hanmac"
test_engine = _build_hanmac_mysql_engine(connect_payload)
try:
with test_engine.connect() as connection:
table_rows = connection.execute(
text(
"""
SELECT table_schema, table_name
FROM information_schema.tables
WHERE table_schema IN ('hanmac', 'hanmac_manhour')
AND table_type = 'BASE TABLE'
ORDER BY table_schema, table_name
"""
)
).mappings().all()
tables: list[dict[str, Any]] = []
for row in table_rows:
schema_name = str(row["table_schema"])
table_name = str(row["table_name"])
try:
row_count = connection.execute(text(f"SELECT COUNT(*) FROM `{schema_name}`.`{table_name}`")).scalar()
except Exception:
row_count = None
tables.append(
{
"schema": schema_name,
"name": table_name,
"row_count": int(row_count) if row_count is not None else None,
}
)
preferred_tables = {
"dallyproject_tbl": 0,
"dallyproject_addwork_tbl": 1,
"member_tbl": 2,
"project_tbl": 3,
"worker_tardy_tbl": 4,
}
tables.sort(
key=lambda item: (
item.get("schema") != "hanmac_manhour",
preferred_tables.get(item.get("name", ""), 99),
(item.get("row_count") is None),
-(item.get("row_count") or 0),
item["name"],
)
)
return {
"status": "ok",
"database": "hanmac / hanmac_manhour",
"tables": tables,
}
finally:
test_engine.dispose()
def _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(
"""
SELECT table_schema, table_name
FROM information_schema.tables
WHERE table_schema IN ('hanmac', 'hanmac_manhour')
AND table_type = 'BASE TABLE'
"""
)
).mappings().all()
}
if (schema_name, table_name) not in available_tables:
raise ValueError("선택한 테이블을 찾을 수 없습니다.")
column_rows = connection.execute(text(f"SHOW COLUMNS FROM `{schema_name}`.`{table_name}`")).mappings().all()
columns = [str(row.get("Field") or "") for row in column_rows]
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_build_project_code_relation_maps(
project_code_alias_groups: list[dict[str, Any]],
) -> dict[str, Any]:
canonical_map: dict[str, str] = {}
equivalent_code_map: dict[str, set[str]] = {}
relation_groups: dict[str, list[str]] = {}
base_codes = {
normalize_text(group.get("project_code"))
for group in project_code_alias_groups
if normalize_text(group.get("project_code"))
}
new_codes = {
normalize_text(group.get("new_project_code"))
for group in project_code_alias_groups
if normalize_text(group.get("new_project_code"))
}
adjacency: dict[str, set[str]] = {}
for group in project_code_alias_groups:
project_code = normalize_text(group.get("project_code"))
if not project_code:
continue
equivalent_codes = {
normalize_text(group.get("project_view_code")),
normalize_text(group.get("old_project_code")),
normalize_text(group.get("new_project_code")),
}
equivalent_codes = {code for code in equivalent_codes if code and code != project_code}
adjacency.setdefault(project_code, set())
for code in equivalent_codes:
adjacency.setdefault(project_code, set()).add(code)
adjacency.setdefault(code, set()).add(project_code)
visited_codes: set[str] = set()
for start_code in sorted(adjacency):
if start_code in visited_codes:
continue
stack = [start_code]
cluster: set[str] = set()
while stack:
current_code = stack.pop()
if current_code in cluster:
continue
cluster.add(current_code)
stack.extend(sorted(adjacency.get(current_code, set()) - cluster))
visited_codes.update(cluster)
representative_candidates = sorted((cluster & base_codes) - new_codes)
if not representative_candidates:
representative_candidates = sorted(cluster & base_codes)
representative = representative_candidates[0] if representative_candidates else sorted(cluster)[0]
for code in cluster:
canonical_map[code] = representative
related_codes = sorted(code for code in cluster if code != representative)
if related_codes:
equivalent_code_map[representative] = set(related_codes)
relation_groups[representative] = related_codes
return {
"canonical_map": canonical_map,
"equivalent_code_map": equivalent_code_map,
"relation_groups": relation_groups,
}
def _hanmac_parse_date_value(value: Any) -> date | None:
if value is None:
return None
if isinstance(value, datetime):
return value.date()
if isinstance(value, date):
return value
text_value = str(value).strip()
if not text_value or text_value in {"0000-00-00", "0000-00-00 00:00:00"}:
return None
for fmt in ("%Y-%m-%d", "%Y-%m-%d %H:%M:%S", "%Y/%m/%d", "%Y/%m/%d %H:%M:%S", "%Y%m%d"):
try:
return datetime.strptime(text_value, fmt).date()
except ValueError:
continue
return None
def _hanmac_parse_datetime_value(value: Any) -> datetime | None:
if value is None:
return None
if isinstance(value, datetime):
return value
if isinstance(value, date):
return datetime.combine(value, datetime.min.time())
text_value = str(value).strip()
if not text_value or text_value in {"0000-00-00", "0000-00-00 00:00:00"}:
return None
for fmt in ("%Y-%m-%d %H:%M:%S", "%Y/%m/%d %H:%M:%S", "%Y-%m-%d", "%Y/%m/%d", "%Y%m%d"):
try:
parsed = datetime.strptime(text_value, fmt)
return parsed
except ValueError:
continue
return None
def _hanmac_parse_float_value(value: Any) -> float:
if value is None:
return 0.0
if isinstance(value, (int, float)):
return float(value)
text_value = str(value).strip()
if not text_value:
return 0.0
try:
return float(text_value)
except ValueError:
return 0.0
def _hanmac_iter_dates(start_value: date, end_value: date) -> list[date]:
if end_value < start_value:
start_value, end_value = end_value, start_value
return [
start_value + timedelta(days=offset)
for offset in range((end_value - start_value).days + 1)
]
def _hanmac_parse_duration_hours(value: Any) -> float:
if value is None:
return 0.0
if isinstance(value, (int, float)):
return max(float(value), 0.0)
text_value = str(value).strip()
if not text_value or text_value in {"0000-00-00 00:00:00", "00:00:00"}:
return 0.0
if " " in text_value and len(text_value.split(" ")[-1].split(":")) == 3:
text_value = text_value.split(" ")[-1]
match = re.fullmatch(r"(\d{1,3}):(\d{2})(?::(\d{2}))?", text_value)
if match:
hours = int(match.group(1))
minutes = int(match.group(2))
seconds = int(match.group(3) or 0)
return max(hours + (minutes / 60.0) + (seconds / 3600.0), 0.0)
return max(_hanmac_parse_float_value(text_value), 0.0)
def _hanmac_calculate_regular_hours(entry_time: Any, leave_time: Any) -> float:
started_at = _hanmac_parse_datetime_value(entry_time)
ended_at = _hanmac_parse_datetime_value(leave_time)
if not started_at or not ended_at:
return 0.0
hours = (ended_at - started_at).total_seconds() / 3600.0
if hours < 0 or hours > 24:
return 0.0
return round(hours, 2)
def _hanmac_resolve_period(payload: dict[str, Any]) -> tuple[date, date]:
today = date.today()
start_date = _hanmac_parse_date_value(payload.get("start_date")) or date(today.year, 1, 1)
end_date = _hanmac_parse_date_value(payload.get("end_date")) or today
if end_date < start_date:
start_date, end_date = end_date, start_date
return start_date, end_date
def _hanmac_build_select_alias(column_name: str | None, alias: str) -> str:
return f"`{column_name}` AS `{alias}`" if column_name else f"NULL AS `{alias}`"
def _hanmac_fetch_table_columns(connection: Any, schema_name: str) -> dict[str, list[str]]:
table_rows = connection.execute(
text(
"""
SELECT table_name
FROM information_schema.tables
WHERE table_schema = :schema_name
AND table_type = 'BASE TABLE'
ORDER BY table_name
"""
),
{"schema_name": schema_name},
).mappings().all()
metadata: dict[str, list[str]] = {}
for row in table_rows:
table_name = str(row["table_name"])
column_rows = connection.execute(text(f"SHOW COLUMNS FROM `{schema_name}`.`{table_name}`")).mappings().all()
metadata[table_name] = [str(column_row.get("Field") or "") for column_row in column_rows]
return metadata
def _hanmac_member_status_label(entry_date: date | None, leave_date: date | None, today: date) -> str:
if leave_date and leave_date < today:
return "퇴사"
if entry_date and entry_date > today:
return "입사전"
return "재직"
def _hanmac_member_matches_filter(
member_record: dict[str, Any],
employment_filter: str,
start_date: date,
end_date: date,
today: date,
) -> bool:
if employment_filter == "all":
return True
entry_date = _hanmac_parse_date_value(member_record.get("entry_date"))
leave_date = _hanmac_parse_date_value(member_record.get("leave_date"))
if employment_filter == "current":
return (entry_date is None or entry_date <= today) and (leave_date is None or leave_date >= today)
if employment_filter == "retired":
return leave_date is not None and leave_date < today
if employment_filter == "period":
entry_ok = entry_date is None or entry_date <= end_date
leave_ok = leave_date is None or leave_date >= start_date
return entry_ok and leave_ok
return True
def _hanmac_member_is_active_on(member_record: dict[str, Any], work_date: date | None) -> bool:
if work_date is None:
return True
entry_date = _hanmac_parse_date_value(member_record.get("entry_date"))
leave_date = _hanmac_parse_date_value(member_record.get("leave_date"))
if entry_date and work_date < entry_date:
return False
if leave_date and work_date > leave_date:
return False
return True
def _hanmac_aggregate_cache_key(payload: dict[str, Any]) -> str:
normalized = {
"host": normalize_text(payload.get("host")),
"port": normalize_text(payload.get("port")),
"user": normalize_text(payload.get("user")),
"database": normalize_text(payload.get("database") or "hanmac_manhour"),
"view": normalize_text(payload.get("view") or "member"),
"employment": normalize_text(payload.get("employment") or "all"),
"start_date": normalize_text(payload.get("start_date")),
"end_date": normalize_text(payload.get("end_date")),
}
return hashlib.sha1(json.dumps(normalized, ensure_ascii=False, sort_keys=True).encode("utf-8")).hexdigest()
def _load_hanmac_aggregate_cache(cache_key: str) -> tuple[dict[str, Any] | None, bool]:
if not cache_key:
return None, False
init_db()
with engine.begin() as conn:
row = conn.execute(
text(
"""
SELECT payload_json, updated_at
FROM hanmac_aggregate_query_cache
WHERE cache_key = :cache_key
LIMIT 1
"""
),
{"cache_key": cache_key},
).first()
if not row:
return None, False
payload_json = str(row[0] or "{}")
updated_at = row[1]
try:
payload = json.loads(payload_json)
except Exception:
payload = {}
cached_at = None
try:
cached_at = datetime.fromisoformat(str(updated_at))
except Exception:
cached_at = None
fresh = False
if cached_at is not None:
fresh = (datetime.now() - cached_at).total_seconds() <= _HANMAC_AGGREGATE_CACHE_TTL_SEC
if isinstance(payload, dict):
payload["cache_meta"] = {
"cached_at": str(updated_at or ""),
"fresh": fresh,
}
return payload, fresh
return None, fresh
def _load_hanmac_aggregate_projection(cache_key: str) -> tuple[dict[str, Any] | None, bool]:
if not cache_key:
return None, False
init_db()
with engine.begin() as conn:
metric_row = conn.execute(
text(
"""
SELECT view_mode, employment_filter, start_date, end_date,
summary_json, columns_json, row_count, updated_at
FROM hanmac_aggregate_query_metrics
WHERE cache_key = :cache_key
LIMIT 1
"""
),
{"cache_key": cache_key},
).mappings().first()
if not metric_row:
return None, False
row_items = conn.execute(
text(
"""
SELECT row_json
FROM hanmac_aggregate_query_rows
WHERE cache_key = :cache_key
ORDER BY row_index
"""
),
{"cache_key": cache_key},
).scalars().all()
try:
summary = json.loads(str(metric_row.get("summary_json") or "{}"))
except Exception:
summary = {}
try:
columns = json.loads(str(metric_row.get("columns_json") or "[]"))
except Exception:
columns = []
rows: list[dict[str, Any]] = []
for item in row_items:
try:
parsed = json.loads(str(item or "{}"))
except Exception:
parsed = {}
if isinstance(parsed, dict):
rows.append(parsed)
updated_at = metric_row.get("updated_at")
cached_at = None
try:
cached_at = datetime.fromisoformat(str(updated_at))
except Exception:
cached_at = None
fresh = False
if cached_at is not None:
fresh = (datetime.now() - cached_at).total_seconds() <= _HANMAC_AGGREGATE_CACHE_TTL_SEC
payload = {
"status": "ok",
"view": str(metric_row.get("view_mode") or ""),
"employment": str(metric_row.get("employment_filter") or ""),
"start_date": str(metric_row.get("start_date") or ""),
"end_date": str(metric_row.get("end_date") or ""),
"summary": summary if isinstance(summary, dict) else {},
"columns": columns if isinstance(columns, list) else [],
"rows": rows,
"cache_meta": {
"cached_at": str(updated_at or ""),
"fresh": fresh,
"source": "projection",
},
}
return payload, fresh
def _store_hanmac_aggregate_cache(cache_key: str, payload: dict[str, Any]) -> None:
if not cache_key or not isinstance(payload, dict):
return
init_db()
with engine.begin() as conn:
conn.execute(
text(
"""
INSERT INTO hanmac_aggregate_query_cache (
cache_key, payload_signature, payload_json, updated_at
) VALUES (
:cache_key, :payload_signature, :payload_json, CURRENT_TIMESTAMP
)
ON CONFLICT(cache_key) DO UPDATE SET
payload_signature = excluded.payload_signature,
payload_json = excluded.payload_json,
updated_at = CURRENT_TIMESTAMP
"""
),
{
"cache_key": cache_key,
"payload_signature": cache_key,
"payload_json": json.dumps(payload, ensure_ascii=False),
},
)
conn.execute(
text(
"""
INSERT INTO hanmac_aggregate_query_metrics (
cache_key, payload_signature, view_mode, employment_filter, start_date, end_date,
summary_json, columns_json, row_count, updated_at
) VALUES (
:cache_key, :payload_signature, :view_mode, :employment_filter, :start_date, :end_date,
:summary_json, :columns_json, :row_count, CURRENT_TIMESTAMP
)
ON CONFLICT(cache_key) DO UPDATE SET
payload_signature = excluded.payload_signature,
view_mode = excluded.view_mode,
employment_filter = excluded.employment_filter,
start_date = excluded.start_date,
end_date = excluded.end_date,
summary_json = excluded.summary_json,
columns_json = excluded.columns_json,
row_count = excluded.row_count,
updated_at = CURRENT_TIMESTAMP
"""
),
{
"cache_key": cache_key,
"payload_signature": cache_key,
"view_mode": normalize_text(payload.get("view")),
"employment_filter": normalize_text(payload.get("employment")),
"start_date": str(payload.get("start_date") or ""),
"end_date": str(payload.get("end_date") or ""),
"summary_json": json.dumps(payload.get("summary") or {}, ensure_ascii=False),
"columns_json": json.dumps(payload.get("columns") or [], ensure_ascii=False),
"row_count": len(list(payload.get("rows") or [])),
},
)
conn.execute(
text(
"""
DELETE FROM hanmac_aggregate_query_rows
WHERE cache_key = :cache_key
"""
),
{"cache_key": cache_key},
)
rows = list(payload.get("rows") or [])
for row_index, row in enumerate(rows):
conn.execute(
text(
"""
INSERT INTO hanmac_aggregate_query_rows (
cache_key, row_index, row_json, updated_at
) VALUES (
:cache_key, :row_index, :row_json, CURRENT_TIMESTAMP
)
"""
),
{
"cache_key": cache_key,
"row_index": row_index,
"row_json": json.dumps(row or {}, ensure_ascii=False),
},
)
def _refresh_hanmac_aggregate_cache_background(payload: dict[str, Any], cache_key: str) -> None:
try:
result = get_hanmac_aggregate_summary(payload)
_store_hanmac_aggregate_cache(cache_key, result)
except Exception:
logger.exception("hanmac aggregate cache background refresh failed")
finally:
with _HANMAC_AGGREGATE_REFRESHING_LOCK:
_HANMAC_AGGREGATE_REFRESHING.discard(cache_key)
def get_hanmac_aggregate_summary_cached(payload: dict[str, Any]) -> dict[str, Any]:
cache_key = _hanmac_aggregate_cache_key(payload)
projection_payload, projection_fresh = _load_hanmac_aggregate_projection(cache_key)
if projection_payload and projection_fresh:
projection_payload.setdefault("cache_meta", {})
projection_payload["cache_meta"]["pending_refresh"] = False
return projection_payload
if projection_payload and not projection_fresh:
with _HANMAC_AGGREGATE_REFRESHING_LOCK:
should_start = cache_key not in _HANMAC_AGGREGATE_REFRESHING
if should_start:
_HANMAC_AGGREGATE_REFRESHING.add(cache_key)
if should_start:
worker = threading.Thread(
target=_refresh_hanmac_aggregate_cache_background,
args=(dict(payload), cache_key),
daemon=True,
name=f"hanmac-aggregate-refresh-{cache_key[:8]}",
)
worker.start()
projection_payload.setdefault("cache_meta", {})
projection_payload["cache_meta"]["pending_refresh"] = True
return projection_payload
cached_payload, fresh = _load_hanmac_aggregate_cache(cache_key)
if cached_payload and fresh:
cached_payload.setdefault("cache_meta", {})
cached_payload["cache_meta"]["pending_refresh"] = False
return cached_payload
if cached_payload and not fresh:
with _HANMAC_AGGREGATE_REFRESHING_LOCK:
should_start = cache_key not in _HANMAC_AGGREGATE_REFRESHING
if should_start:
_HANMAC_AGGREGATE_REFRESHING.add(cache_key)
if should_start:
worker = threading.Thread(
target=_refresh_hanmac_aggregate_cache_background,
args=(dict(payload), cache_key),
daemon=True,
name=f"hanmac-aggregate-refresh-{cache_key[:8]}",
)
worker.start()
cached_payload.setdefault("cache_meta", {})
cached_payload["cache_meta"]["pending_refresh"] = True
return cached_payload
result = get_hanmac_aggregate_summary(payload)
_store_hanmac_aggregate_cache(cache_key, result)
result.setdefault("cache_meta", {})
result["cache_meta"]["pending_refresh"] = False
return result
def _hanmac_csv_escape(value: Any) -> str:
text_value = "" if value is None else str(value)
return '"' + text_value.replace('"', '""') + '"'
def _hanmac_export_payload_signature(payload: dict[str, Any]) -> str:
normalized = {
"schema": _validate_hanmac_schema_name(payload.get("schema")),
"table": _validate_hanmac_table_name(payload.get("table")),
"limit": int(payload.get("limit") or 100),
"cursor": normalize_text(payload.get("cursor")),
"value_column": normalize_text(payload.get("value_column")),
"value_search": normalize_text(payload.get("value_search")),
}
return hashlib.sha1(json.dumps(normalized, ensure_ascii=False, sort_keys=True).encode("utf-8")).hexdigest()
def _hanmac_aggregate_export_payload_signature(payload: dict[str, Any]) -> str:
normalized = {
"host": normalize_text(payload.get("host")),
"port": normalize_text(payload.get("port")),
"user": normalize_text(payload.get("user")),
"database": normalize_text(payload.get("database") or "hanmac_manhour"),
"view": normalize_text(payload.get("view") or "member"),
"employment": normalize_text(payload.get("employment") or "all"),
"start_date": normalize_text(payload.get("start_date")),
"end_date": normalize_text(payload.get("end_date")),
"value_column": normalize_text(payload.get("value_column")),
"value_search": normalize_text(payload.get("value_search")),
"sort_key": normalize_text(payload.get("sort_key")),
"sort_direction": normalize_text(payload.get("sort_direction") or "desc"),
}
return hashlib.sha1(json.dumps(normalized, ensure_ascii=False, sort_keys=True).encode("utf-8")).hexdigest()
def _claim_next_hanmac_export_job() -> dict[str, Any] | None:
init_db()
with engine.begin() as conn:
row = conn.execute(
text(
"""
SELECT job_key, job_type, payload_json
FROM hanmac_export_jobs
WHERE state = 'queued'
ORDER BY created_at ASC
LIMIT 1
"""
)
).mappings().first()
if not row:
return None
conn.execute(
text(
"""
UPDATE hanmac_export_jobs
SET state = 'running',
error_message = '',
updated_at = CURRENT_TIMESTAMP
WHERE job_key = :job_key
"""
),
{"job_key": row["job_key"]},
)
try:
payload = json.loads(str(row.get("payload_json") or "{}"))
except Exception:
payload = {}
return {
"job_key": str(row.get("job_key") or ""),
"job_type": str(row.get("job_type") or ""),
"payload": payload,
}
def _update_hanmac_export_job(job_key: str, **fields: Any) -> None:
if not job_key:
return
init_db()
assignments = []
params: dict[str, Any] = {"job_key": job_key}
for key, value in fields.items():
assignments.append(f"{key} = :{key}")
params[key] = value
assignments.append("updated_at = CURRENT_TIMESTAMP")
with engine.begin() as conn:
conn.execute(
text(f"UPDATE hanmac_export_jobs SET {', '.join(assignments)} WHERE job_key = :job_key"),
params,
)
def _build_hanmac_preview_export_csv(payload: dict[str, Any]) -> tuple[str, bytes, int]:
preview_payload = {
**payload,
"schema": payload.get("schema"),
"table": payload.get("table"),
"limit": payload.get("limit"),
"cursor": payload.get("cursor"),
}
preview_result = get_hanmac_table_preview(preview_payload)
columns = list(preview_result.get("columns") or [])
rows = list(preview_result.get("rows") or [])
value_column = normalize_text(payload.get("value_column"))
value_search = normalize_text(payload.get("value_search")).lower()
if value_search:
target_columns = [value_column] if value_column and value_column in columns else columns
rows = [
row for row in rows
if any(value_search in str((row or {}).get(column) or "").lower() for column in target_columns)
]
header = ",".join(_hanmac_csv_escape(column) for column in columns)
body = [
",".join(_hanmac_csv_escape((row or {}).get(column)) for column in columns)
for row in rows
]
content = ("\uFEFF" + header + ("\r\n" + "\r\n".join(body) if body else "")).encode("utf-8")
today = datetime.now().strftime("%Y-%m-%d")
file_name = f"{preview_result.get('schema') or 'hanmac'}_{preview_result.get('table') or 'preview'}_{today}.csv"
return file_name, content, len(rows)
def _build_hanmac_aggregate_export_csv(payload: dict[str, Any]) -> tuple[str, bytes, int]:
aggregate_payload = {
**payload,
"view": payload.get("view") or "member",
"employment": payload.get("employment") or "all",
"start_date": payload.get("start_date"),
"end_date": payload.get("end_date"),
}
aggregate_result = get_hanmac_aggregate_summary_cached(aggregate_payload)
columns = list(aggregate_result.get("columns") or [])
rows = list(aggregate_result.get("rows") or [])
value_column = normalize_text(payload.get("value_column"))
value_search = normalize_text(payload.get("value_search")).lower()
sort_key = normalize_text(payload.get("sort_key"))
sort_direction = normalize_text(payload.get("sort_direction") or "desc")
if value_search:
target_keys = [value_column] if value_column else [str(column.get("key") or "") for column in columns]
rows = [
row for row in rows
if any(value_search in str((row or {}).get(key) or "").lower() for key in target_keys if key)
]
if sort_key:
direction = 1 if sort_direction == "asc" else -1
def _sort_value(item: dict[str, Any]) -> Any:
value = item.get(sort_key)
number = None
try:
number = float(value)
except Exception:
number = None
if number is not None:
return (0, number)
return (1, str(value or "").lower())
rows = sorted(rows, key=_sort_value, reverse=(direction == -1))
export_columns = [str(column.get("key") or "") for column in columns if str(column.get("key") or "").strip()]
export_labels = [str(column.get("label") or column.get("key") or "") for column in columns if str(column.get("key") or "").strip()]
header = ",".join(_hanmac_csv_escape(label) for label in export_labels)
body = [
",".join(_hanmac_csv_escape((row or {}).get(column_key)) for column_key in export_columns)
for row in rows
]
content = ("\uFEFF" + header + ("\r\n" + "\r\n".join(body) if body else "")).encode("utf-8")
today = datetime.now().strftime("%Y-%m-%d")
view_name = normalize_text(aggregate_result.get("view") or payload.get("view") or "member") or "member"
file_name = f"hanmac_{view_name}_aggregate_{today}.csv"
return file_name, content, len(rows)
def _run_hanmac_export_job(job: dict[str, Any]) -> None:
job_key = str(job.get("job_key") or "")
job_type = str(job.get("job_type") or "")
payload = dict(job.get("payload") or {})
try:
if job_type == "aggregate_export_csv":
file_name, content, row_count = _build_hanmac_aggregate_export_csv(payload)
else:
file_name, content, row_count = _build_hanmac_preview_export_csv(payload)
HANMAC_EXPORT_DIR.mkdir(parents=True, exist_ok=True)
file_path = HANMAC_EXPORT_DIR / f"{job_key}.csv"
file_path.write_bytes(content)
_update_hanmac_export_job(
job_key,
state="ready",
file_path=str(file_path),
file_name=file_name,
row_count=row_count,
error_message="",
)
except Exception as exc:
logger.exception("hanmac export job failed: %s", exc)
_update_hanmac_export_job(
job_key,
state="failed",
error_message=str(exc),
)
def _hanmac_export_worker_loop() -> None:
while True:
job = _claim_next_hanmac_export_job()
if not job:
_HANMAC_EXPORT_JOB_EVENT.wait(2.0)
_HANMAC_EXPORT_JOB_EVENT.clear()
continue
_run_hanmac_export_job(job)
def _ensure_hanmac_export_worker() -> None:
global _HANMAC_EXPORT_WORKER_STARTED, _HANMAC_EXPORT_WORKER_THREAD
with _HANMAC_EXPORT_WORKER_LOCK:
if _HANMAC_EXPORT_WORKER_STARTED and _HANMAC_EXPORT_WORKER_THREAD and _HANMAC_EXPORT_WORKER_THREAD.is_alive():
return
worker = threading.Thread(
target=_hanmac_export_worker_loop,
daemon=True,
name="hanmac-export-worker",
)
worker.start()
_HANMAC_EXPORT_WORKER_THREAD = worker
_HANMAC_EXPORT_WORKER_STARTED = True
def request_hanmac_preview_export_job(payload: dict[str, Any]) -> dict[str, Any]:
job_key = f"hanmac-preview-export:{_hanmac_export_payload_signature(payload)}"
init_db()
with engine.begin() as conn:
row = conn.execute(
text(
"""
SELECT job_key, state, file_path, file_name, row_count, error_message
FROM hanmac_export_jobs
WHERE job_key = :job_key
LIMIT 1
"""
),
{"job_key": job_key},
).mappings().first()
if row:
state = str(row.get("state") or "")
file_path = str(row.get("file_path") or "")
if state == "ready" and file_path and Path(file_path).exists():
return {
"job_key": job_key,
"state": "ready",
"download_url": f"/hanmac-browser/api/preview-export-download/{quote_plus(job_key)}",
"row_count": int(row.get("row_count") or 0),
"file_name": str(row.get("file_name") or ""),
}
if state in {"queued", "running"}:
_ensure_hanmac_export_worker()
_HANMAC_EXPORT_JOB_EVENT.set()
return {"job_key": job_key, "state": state, "download_url": "", "row_count": 0, "file_name": ""}
conn.execute(
text(
"""
INSERT OR REPLACE INTO hanmac_export_jobs (
job_key, job_type, payload_json, state, file_path, file_name, row_count, error_message, created_at, updated_at
) VALUES (
:job_key, 'preview_export_csv', :payload_json, 'queued', '', '', 0, '', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP
)
"""
),
{"job_key": job_key, "payload_json": json.dumps(payload, ensure_ascii=False)},
)
_ensure_hanmac_export_worker()
_HANMAC_EXPORT_JOB_EVENT.set()
return {"job_key": job_key, "state": "queued", "download_url": "", "row_count": 0, "file_name": ""}
def request_hanmac_aggregate_export_job(payload: dict[str, Any]) -> dict[str, Any]:
job_key = f"hanmac-aggregate-export:{_hanmac_aggregate_export_payload_signature(payload)}"
init_db()
with engine.begin() as conn:
row = conn.execute(
text(
"""
SELECT job_key, state, file_path, file_name, row_count, error_message
FROM hanmac_export_jobs
WHERE job_key = :job_key
LIMIT 1
"""
),
{"job_key": job_key},
).mappings().first()
if row:
state = str(row.get("state") or "")
file_path = str(row.get("file_path") or "")
if state == "ready" and file_path and Path(file_path).exists():
return {
"job_key": job_key,
"state": "ready",
"download_url": f"/hanmac-browser/api/preview-export-download/{quote_plus(job_key)}",
"row_count": int(row.get("row_count") or 0),
"file_name": str(row.get("file_name") or ""),
}
if state in {"queued", "running"}:
_ensure_hanmac_export_worker()
_HANMAC_EXPORT_JOB_EVENT.set()
return {"job_key": job_key, "state": state, "download_url": "", "row_count": 0, "file_name": ""}
conn.execute(
text(
"""
INSERT OR REPLACE INTO hanmac_export_jobs (
job_key, job_type, payload_json, state, file_path, file_name, row_count, error_message, created_at, updated_at
) VALUES (
:job_key, 'aggregate_export_csv', :payload_json, 'queued', '', '', 0, '', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP
)
"""
),
{"job_key": job_key, "payload_json": json.dumps(payload, ensure_ascii=False)},
)
_ensure_hanmac_export_worker()
_HANMAC_EXPORT_JOB_EVENT.set()
return {"job_key": job_key, "state": "queued", "download_url": "", "row_count": 0, "file_name": ""}
def get_hanmac_export_job(job_key: str) -> dict[str, Any]:
init_db()
with engine.begin() as conn:
row = conn.execute(
text(
"""
SELECT job_key, state, file_path, file_name, row_count, error_message, updated_at
FROM hanmac_export_jobs
WHERE job_key = :job_key
LIMIT 1
"""
),
{"job_key": job_key},
).mappings().first()
if not row:
return {"job_key": job_key, "state": "missing", "download_url": "", "row_count": 0, "file_name": "", "error_message": "작업을 찾을 수 없습니다."}
file_path = str(row.get("file_path") or "")
ready = str(row.get("state") or "") == "ready" and file_path and Path(file_path).exists()
return {
"job_key": str(row.get("job_key") or job_key),
"state": str(row.get("state") or ""),
"download_url": f"/hanmac-browser/api/preview-export-download/{quote_plus(job_key)}" if ready else "",
"row_count": int(row.get("row_count") or 0),
"file_name": str(row.get("file_name") or ""),
"file_path": file_path,
"error_message": str(row.get("error_message") or ""),
"updated_at": str(row.get("updated_at") or ""),
}
def _cleanup_old_hanmac_export_files(keep_paths: set[str] | None = None, older_than_sec: int = EXPORT_RETENTION_SECONDS) -> None:
keep = {str(Path(item)) for item in (keep_paths or set())}
if not HANMAC_EXPORT_DIR.exists():
return
threshold = time.time() - max(int(older_than_sec), 60)
for path in HANMAC_EXPORT_DIR.glob("*.csv"):
try:
if str(path) in keep:
continue
if path.stat().st_mtime >= threshold:
continue
path.unlink(missing_ok=True)
except Exception:
continue
def _cleanup_hanmac_runtime_artifacts() -> None:
init_db()
export_threshold = datetime.now() - timedelta(seconds=EXPORT_RETENTION_SECONDS)
cache_threshold = datetime.now() - timedelta(seconds=QUERY_CACHE_RETENTION_SECONDS)
keep_paths: set[str] = set()
with engine.begin() as conn:
ready_paths = conn.execute(
text(
"""
SELECT file_path
FROM hanmac_export_jobs
WHERE state = 'ready'
AND COALESCE(file_path, '') <> ''
AND updated_at >= :export_threshold
"""
),
{"export_threshold": export_threshold.strftime("%Y-%m-%d %H:%M:%S")},
).scalars().all()
keep_paths = {str(Path(path)) for path in ready_paths if str(path or "").strip()}
conn.execute(
text(
"""
DELETE FROM hanmac_export_jobs
WHERE state IN ('ready', 'failed')
AND updated_at < :export_threshold
"""
),
{"export_threshold": export_threshold.strftime("%Y-%m-%d %H:%M:%S")},
)
conn.execute(
text(
"""
DELETE FROM hanmac_aggregate_query_cache
WHERE updated_at < :cache_threshold
"""
),
{"cache_threshold": cache_threshold.strftime("%Y-%m-%d %H:%M:%S")},
)
conn.execute(
text(
"""
DELETE FROM hanmac_preview_query_cache
WHERE updated_at < :cache_threshold
"""
),
{"cache_threshold": cache_threshold.strftime("%Y-%m-%d %H:%M:%S")},
)
conn.execute(
text(
"""
DELETE FROM hanmac_aggregate_query_metrics
WHERE updated_at < :cache_threshold
"""
),
{"cache_threshold": cache_threshold.strftime("%Y-%m-%d %H:%M:%S")},
)
conn.execute(
text(
"""
DELETE FROM hanmac_aggregate_query_rows
WHERE updated_at < :cache_threshold
"""
),
{"cache_threshold": cache_threshold.strftime("%Y-%m-%d %H:%M:%S")},
)
_cleanup_old_hanmac_export_files(keep_paths=keep_paths, older_than_sec=EXPORT_RETENTION_SECONDS)
def _run_app_maintenance_once() -> None:
_cleanup_hanmac_runtime_artifacts()
cleanup_compare_runtime_artifacts(
engine,
export_retention_sec=EXPORT_RETENTION_SECONDS,
cache_retention_sec=QUERY_CACHE_RETENTION_SECONDS,
)
def _app_maintenance_worker_loop() -> None:
while True:
try:
_run_app_maintenance_once()
except Exception as exc:
logger.warning("app maintenance skipped due to error: %s", exc)
time.sleep(APP_MAINTENANCE_INTERVAL_SECONDS)
def _ensure_app_maintenance_worker() -> None:
global _APP_MAINTENANCE_WORKER_STARTED, _APP_MAINTENANCE_WORKER_THREAD
with _APP_MAINTENANCE_WORKER_LOCK:
if _APP_MAINTENANCE_WORKER_STARTED and _APP_MAINTENANCE_WORKER_THREAD and _APP_MAINTENANCE_WORKER_THREAD.is_alive():
return
worker = threading.Thread(
target=_app_maintenance_worker_loop,
daemon=True,
name="app-maintenance-worker",
)
worker.start()
_APP_MAINTENANCE_WORKER_THREAD = worker
_APP_MAINTENANCE_WORKER_STARTED = True
def get_hanmac_aggregate_summary(payload: dict[str, Any]) -> dict[str, Any]:
global _HANMAC_LAST_AGGREGATE_DIAGNOSTICS
schema_name = "hanmac_manhour"
start_date, end_date = _hanmac_resolve_period(payload)
employment_filter = normalize_text(payload.get("employment")) or "all"
view_mode = normalize_text(payload.get("view")) or "member"
connect_payload = dict(payload)
connect_payload["database"] = schema_name
test_engine = _build_hanmac_mysql_engine(connect_payload)
try:
with test_engine.connect() as connection:
metadata = _hanmac_fetch_table_columns(connection, schema_name)
source_diagnostics: dict[str, Any] = {
"regular_tables": [],
"holiday_time_rows": 0,
"holiday_time_hours": 0.0,
"tardy_columns": [],
"tardy_candidate_rows": 0,
"leave_matched_rows": 0,
"leave_types": [],
"member_columns": [],
"member_name_col": "",
"member_name_fallback_rows": 0,
}
member_info: dict[str, dict[str, Any]] = {}
member_columns = metadata.get("member_tbl") or []
source_diagnostics["member_columns"] = member_columns
member_no_col = _hanmac_find_column(member_columns, ["MemberNo", "member_no"])
if member_no_col:
member_name_col = _hanmac_find_column(member_columns, ["Name", "MemberName", "member_name", "UserName", "KorName", "MemberNm", "member_nm", "EmpName", "emp_name", "UserNM", "user_nm", "KorNm", "kor_nm", "KoreanName", "DisplayName"])
source_diagnostics["member_name_col"] = member_name_col or ""
entry_date_col = _hanmac_find_column(member_columns, ["EntryDate", "entry_date", "HireDate", "JoinDate", "InDate"])
leave_date_col = _hanmac_find_column(member_columns, ["LeaveDate", "leave_date", "RetireDate", "OutDate"])
dept_name_col = _hanmac_find_column(member_columns, ["DeptName", "Department", "PartName", "TeamName", "Dept"])
member_rows = connection.execute(
text(
f"""
SELECT
{_hanmac_build_select_alias(member_no_col, "member_no")},
{_hanmac_build_select_alias(member_name_col, "member_name")},
{_hanmac_build_select_alias(entry_date_col, "entry_date")},
{_hanmac_build_select_alias(leave_date_col, "leave_date")},
{_hanmac_build_select_alias(dept_name_col, "dept_name")}
FROM `{schema_name}`.`member_tbl`
"""
)
).mappings().all()
for row in member_rows:
member_no = normalize_text(row.get("member_no"))
if not member_no:
continue
member_name = normalize_text(row.get("member_name")) or member_no
if member_name == member_no:
source_diagnostics["member_name_fallback_rows"] += 1
member_info[member_no] = {
"member_no": member_no,
"member_name": member_name,
"entry_date": _hanmac_parse_date_value(row.get("entry_date")),
"leave_date": _hanmac_parse_date_value(row.get("leave_date")),
"dept_name": normalize_text(row.get("dept_name")) or "",
}
project_map: dict[str, str] = {}
project_code_alias_groups: list[dict[str, Any]] = []
project_columns = metadata.get("project_tbl") or []
project_code_col = _hanmac_find_column(project_columns, ["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")),
}
)
regular_tables = [
table_name
for table_name, columns in metadata.items()
if table_name.startswith("dallyproject")
and "addwork" not in table_name.lower()
and _hanmac_find_column(columns, ["MemberNo", "member_no"])
and _hanmac_find_column(columns, ["EntryTime", "entry_time"])
]
if "dallyproject_tbl" in regular_tables:
regular_tables = ["dallyproject_tbl"]
regular_records: list[dict[str, Any]] = []
for table_name in regular_tables:
columns = metadata.get(table_name) or []
member_col = _hanmac_find_column(columns, ["MemberNo", "member_no"])
project_col = _hanmac_find_column(columns, ["project_code", "new_project_code", "ProjectCode", "ProjectKey", "EntryPCode", "PCode"])
entry_col = _hanmac_find_column(columns, ["EntryTime", "entry_time"])
leave_col = _hanmac_find_column(columns, ["LeaveTime", "leave_time"])
work_time_col = _hanmac_find_column(columns, ["WorkTime", "work_time", "RegularTime", "regular_time"])
holiday_time_col = _hanmac_find_column(columns, ["HolidayTime", "holiday_time", "HolidayWorkTime", "holiday_work_time"])
source_diagnostics["regular_tables"].append(
{
"table": table_name,
"work_time_col": work_time_col or "",
"holiday_time_col": holiday_time_col or "",
}
)
if not member_col or not entry_col:
continue
where_clauses = [f"`{member_col}` IS NOT NULL"]
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")}
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"))
if holiday_time_hours > 0:
source_diagnostics["holiday_time_rows"] += 1
source_diagnostics["holiday_time_hours"] = round(source_diagnostics["holiday_time_hours"] + holiday_time_hours, 2)
regular_records.append(
{
"member_no": member_no,
"project_code": normalize_text(row.get("project_code")) or "",
"work_date": _hanmac_parse_date_value(row.get("entry_time")),
"regular_hours": work_time_hours if work_time_hours > 0 else (0.0 if holiday_time_hours > 0 else _hanmac_calculate_regular_hours(row.get("entry_time"), row.get("leave_time"))),
"holiday_hours": holiday_time_hours,
}
)
addwork_columns = metadata.get("dallyproject_addwork_tbl") or []
overtime_records: list[dict[str, Any]] = []
addwork_member_col = _hanmac_find_column(addwork_columns, ["MemberNo", "member_no"])
addwork_date_col = _hanmac_find_column(addwork_columns, ["EntryTime", "entry_time", "work_date"])
if addwork_member_col and addwork_date_col:
addwork_project_col = _hanmac_find_column(addwork_columns, ["new_project_code", "project_code", "ProjectCode", "ProjectKey", "PCode"])
addwork_hour_col = _hanmac_find_column(addwork_columns, ["work_hour", "WorkHour"])
addwork_min_col = _hanmac_find_column(addwork_columns, ["work_min", "WorkMin"])
where_clauses = [f"`{addwork_member_col}` IS NOT NULL"]
params = {}
if addwork_date_col and start_date:
where_clauses.append(f"LEFT(CAST(`{addwork_date_col}` AS CHAR), 10) >= :start_date")
params["start_date"] = start_date.isoformat()
if addwork_date_col and end_date:
where_clauses.append(f"LEFT(CAST(`{addwork_date_col}` AS CHAR), 10) <= :end_date")
params["end_date"] = end_date.isoformat()
addwork_rows = connection.execute(
text(
f"""
SELECT
{_hanmac_build_select_alias(addwork_member_col, "member_no")},
{_hanmac_build_select_alias(addwork_project_col, "project_code")},
{_hanmac_build_select_alias(addwork_date_col, "work_date")},
{_hanmac_build_select_alias(addwork_hour_col, "work_hour")},
{_hanmac_build_select_alias(addwork_min_col, "work_min")}
FROM `{schema_name}`.`dallyproject_addwork_tbl`
WHERE {' AND '.join(where_clauses)}
"""
),
params,
).mappings().all()
for row in addwork_rows:
member_no = normalize_text(row.get("member_no"))
if not member_no:
continue
overtime_hours = round(_hanmac_parse_float_value(row.get("work_hour")) + (_hanmac_parse_float_value(row.get("work_min")) / 60.0), 2)
overtime_records.append(
{
"member_no": member_no,
"project_code": normalize_text(row.get("project_code")) or "",
"work_date": _hanmac_parse_date_value(row.get("work_date")),
"overtime_hours": overtime_hours,
}
)
leave_records: list[dict[str, Any]] = []
tardy_columns = metadata.get("worker_tardy_tbl") or []
source_diagnostics["tardy_columns"] = tardy_columns
tardy_member_col = _hanmac_find_column(tardy_columns, ["MemberNo", "member_no", "EmpNo", "UserID"])
if tardy_member_col:
tardy_date_col = _hanmac_find_column(tardy_columns, ["work_date", "WorkDate", "EntryDate", "Date", "TardyDate", "s_date", "SDate", "StartDate", "start_date"])
tardy_end_date_col = _hanmac_find_column(tardy_columns, ["e_date", "EDate", "EndDate", "end_date"])
tardy_type_col = _hanmac_find_column(tardy_columns, ["reason", "Reason", "ReasonName", "TardyReason", "state", "State", "WorkState", "gubun", "Gubun", "TardyGubun", "type", "Type", "TardyType", "kind", "Kind", "TardyKind", "TardyCode", "TardyCD", "HolidayType", "VacationType", "AbsenceType", "contents", "Contents", "info", "Info", "memo", "Memo", "remark", "Remark"])
tardy_value_col = _hanmac_find_column(tardy_columns, ["day_count", "DayCount", "days", "Days", "day", "Day", "DayCnt", "use_day", "UseDay", "use_days", "UseDays", "used_days", "UsedDays", "work_day", "WorkDay", "tardy_day", "TardyDay", "tardy_days", "TardyDays", "hours", "Hours", "hour", "Hour", "time", "Time", "TardyTime", "TardyHour", "TardyHours"])
tardy_hour_col = _hanmac_find_column(tardy_columns, ["tardy_h", "TardyH", "tardy_hour", "TardyHour"])
tardy_min_col = _hanmac_find_column(tardy_columns, ["tardy_m", "TardyM", "tardy_min", "TardyMin"])
where_clauses = [f"`{tardy_member_col}` IS NOT NULL"]
params = {}
if tardy_date_col and start_date:
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_type_col, "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}`.`worker_tardy_tbl`
WHERE {' AND '.join(where_clauses)}
"""
),
params,
).mappings().all()
source_diagnostics["tardy_candidate_rows"] = len(tardy_rows)
leave_keywords = ("연차", "휴가", "휴직", "반차", "공가", "병가", "출산", "육아", "대체", "대휴", "보상", "휴일", "leave", "vacation", "holiday")
leave_type_values: set[str] = set()
for row in tardy_rows:
member_no = normalize_text(row.get("member_no"))
leave_type = normalize_text(row.get("leave_type"))
if not member_no or not leave_type:
continue
if not any(keyword in leave_type.lower() for keyword in leave_keywords):
continue
source_diagnostics["leave_matched_rows"] += 1
leave_type_values.add(leave_type)
leave_amount = _hanmac_parse_float_value(row.get("leave_value"))
leave_hours = _hanmac_parse_float_value(row.get("leave_hour")) + (_hanmac_parse_float_value(row.get("leave_min")) / 60.0)
if leave_amount <= 0 and leave_hours > 0:
leave_amount = round(leave_hours / 8.0, 2)
if leave_amount <= 0:
leave_amount = 0.0
record_start = _hanmac_parse_date_value(row.get("work_date"))
record_end = _hanmac_parse_date_value(row.get("end_date")) or record_start
if not record_start:
continue
if record_end and record_end < record_start:
record_start, record_end = record_end, record_start
full_dates = _hanmac_iter_dates(record_start, record_end or record_start)
clipped_start = max(record_start, start_date)
clipped_end = min(record_end or record_start, end_date)
if clipped_end < clipped_start:
continue
clipped_dates = _hanmac_iter_dates(clipped_start, clipped_end)
if leave_amount <= 0:
leave_amount = float(len(full_dates))
daily_leave_amount = leave_amount / max(len(full_dates), 1)
daily_leave_hours = leave_hours / max(len(full_dates), 1) if leave_hours > 0 else daily_leave_amount * 8.0
for leave_date in clipped_dates:
leave_records.append(
{
"member_no": member_no,
"work_date": leave_date,
"end_date": leave_date,
"leave_days": round(daily_leave_amount, 4),
"leave_hours": round(daily_leave_hours, 4),
"leave_type": leave_type,
}
)
source_diagnostics["leave_types"] = sorted(leave_type_values)
project_relation_maps = _hanmac_build_project_code_relation_maps(project_code_alias_groups)
project_canonical_map: dict[str, str] = project_relation_maps["canonical_map"]
project_equivalent_code_map: dict[str, set[str]] = project_relation_maps["equivalent_code_map"]
def canonical_project_code(project_code: Any) -> str:
normalized_code = normalize_text(project_code)
return project_canonical_map.get(normalized_code, normalized_code)
def project_display_name(project_code: Any) -> str:
normalized_code = normalize_text(project_code)
canonical_code = canonical_project_code(normalized_code)
return (
project_map.get(canonical_code)
or project_map.get(normalized_code)
or canonical_code
or "(미지정)"
)
def equivalent_project_codes(project_code: Any) -> list[str]:
canonical_code = canonical_project_code(project_code)
return sorted(
code
for code in project_equivalent_code_map.get(canonical_code, set())
if code and code != canonical_code
)
today = date.today()
member_aggregates: dict[str, dict[str, Any]] = {}
project_aggregates: dict[str, dict[str, Any]] = {}
leave_days_by_member_date: dict[tuple[str, date], float] = {}
def include_member(member_no: str) -> bool:
member_record = member_info.get(member_no) or {
"member_no": member_no,
"member_name": member_no,
"entry_date": None,
"leave_date": None,
"dept_name": "",
}
return _hanmac_member_matches_filter(member_record, employment_filter, start_date, end_date, today)
for row in leave_records:
member_no = row["member_no"]
work_date = row.get("work_date")
if not include_member(member_no):
continue
member_record = member_info.get(member_no) or {
"member_no": member_no,
"member_name": member_no,
"entry_date": None,
"leave_date": None,
"dept_name": "",
}
if not _hanmac_member_is_active_on(member_record, work_date):
continue
if work_date:
key = (member_no, work_date)
leave_days_by_member_date[key] = round(
leave_days_by_member_date.get(key, 0.0) + row["leave_days"],
2,
)
def ensure_member_bucket(member_no: str) -> dict[str, Any]:
member_record = member_info.get(member_no) or {
"member_no": member_no,
"member_name": member_no,
"entry_date": None,
"leave_date": None,
"dept_name": "",
}
return member_aggregates.setdefault(
member_no,
{
"member_no": member_no,
"member_name": member_record["member_name"],
"status": _hanmac_member_status_label(member_record["entry_date"], member_record["leave_date"], today),
"entry_date": member_record["entry_date"].isoformat() if member_record["entry_date"] else "",
"leave_date": member_record["leave_date"].isoformat() if member_record["leave_date"] else "",
"dept_name": member_record["dept_name"],
"regular_hours": 0.0,
"overtime_hours": 0.0,
"holiday_hours": 0.0,
"regular_work_days": 0,
"overtime_work_day_keys": set(),
"legal_leave_days": 0.0,
"legal_leave_hours": 0.0,
"project_codes": set(),
"regular_details": [],
"holiday_details": [],
"overtime_details": [],
"leave_details": [],
"multi_entry_days": 0,
"multi_entry_details": [],
},
)
def ensure_project_bucket(project_code: str) -> dict[str, Any]:
project_key = canonical_project_code(project_code) or "(미지정)"
return project_aggregates.setdefault(
project_key,
{
"project_code": project_key,
"project_name": project_display_name(project_key),
"equivalent_project_codes": equivalent_project_codes(project_key),
"regular_hours": 0.0,
"overtime_hours": 0.0,
"holiday_hours": 0.0,
"regular_work_days": 0,
"overtime_work_day_keys": set(),
"legal_leave_days": 0.0,
"legal_leave_hours": 0.0,
"member_nos": set(),
"regular_details": [],
"holiday_details": [],
"overtime_details": [],
"leave_details": [],
},
)
regular_day_groups: dict[tuple[str, date], dict[str, Any]] = {}
for row in regular_records:
member_no = row["member_no"]
if not include_member(member_no):
continue
member_record = member_info.get(member_no) or {
"member_no": member_no,
"member_name": member_no,
"entry_date": None,
"leave_date": None,
"dept_name": "",
}
work_date = row.get("work_date")
if not work_date or not _hanmac_member_is_active_on(member_record, work_date):
continue
key = (member_no, work_date)
day_group = regular_day_groups.setdefault(
key,
{
"member_no": member_no,
"work_date": work_date,
"project_hours": {},
"holiday_project_hours": {},
"entries": [],
},
)
raw_project_code = row["project_code"]
project_code = canonical_project_code(raw_project_code)
raw_hours = max(0.0, _hanmac_parse_float_value(row["regular_hours"]))
raw_holiday_hours = max(0.0, _hanmac_parse_float_value(row.get("holiday_hours")))
day_group["project_hours"][project_code] = round(
day_group["project_hours"].get(project_code, 0.0) + raw_hours,
4,
)
day_group["holiday_project_hours"][project_code] = round(
day_group["holiday_project_hours"].get(project_code, 0.0) + raw_holiday_hours,
4,
)
day_group["entries"].append(
{
"project_code": project_code or "(미지정)",
"project_name": project_display_name(project_code),
"raw_project_code": raw_project_code,
"equivalent_project_codes": equivalent_project_codes(project_code),
"regular_hours": round(raw_hours, 2),
"holiday_hours": round(raw_holiday_hours, 2),
}
)
for (member_no, work_date), day_group in regular_day_groups.items():
member_bucket = ensure_member_bucket(member_no)
raw_total_hours = round(sum(day_group["project_hours"].values()), 2)
raw_holiday_total_hours = round(sum(day_group["holiday_project_hours"].values()), 2)
leave_days = min(1.0, max(0.0, leave_days_by_member_date.get((member_no, work_date), 0.0)))
weekday_cap = 8.0 if work_date.weekday() < 5 else 0.0
capped_regular_hours = round(min(raw_total_hours, max(0.0, weekday_cap * (1.0 - leave_days))), 2)
holiday_hours = round(raw_holiday_total_hours if raw_holiday_total_hours > 0 else (raw_total_hours if work_date.weekday() >= 5 else 0.0), 2)
member_bucket["regular_hours"] += capped_regular_hours
member_bucket["holiday_hours"] += holiday_hours
if capped_regular_hours > 0:
member_bucket["regular_work_days"] += 1
member_bucket["regular_details"].append(
{
"work_date": work_date.isoformat(),
"regular_hours": capped_regular_hours,
"raw_total_hours": raw_total_hours,
"leave_days": round(leave_days, 2),
"projects": sorted(
[
{
"project_code": project_code or "(미지정)",
"project_name": project_display_name(project_code),
"equivalent_project_codes": equivalent_project_codes(project_code),
"hours": round(project_hours, 2),
}
for project_code, project_hours in day_group["project_hours"].items()
],
key=lambda item: (-item["hours"], item["project_code"]),
),
}
)
if holiday_hours > 0:
member_bucket["holiday_details"].append(
{
"work_date": work_date.isoformat(),
"holiday_hours": holiday_hours,
"projects": sorted(
[
{
"project_code": project_code or "(미지정)",
"project_name": project_display_name(project_code),
"equivalent_project_codes": equivalent_project_codes(project_code),
"hours": round(project_hours, 2),
}
for project_code, project_hours in (
day_group["holiday_project_hours"].items()
if raw_holiday_total_hours > 0
else day_group["project_hours"].items()
)
],
key=lambda item: (-item["hours"], item["project_code"]),
),
}
)
for project_code in day_group["project_hours"].keys():
if project_code:
member_bucket["project_codes"].add(project_code)
if len(day_group["entries"]) > 1:
member_bucket["multi_entry_days"] += 1
member_bucket["multi_entry_details"].append(
{
"work_date": work_date.isoformat(),
"row_count": len(day_group["entries"]),
"raw_total_hours": raw_total_hours,
"capped_regular_hours": capped_regular_hours,
"leave_days": round(leave_days, 2),
"entries": sorted(
day_group["entries"],
key=lambda item: (-item["regular_hours"], item["project_code"]),
),
}
)
if raw_total_hours <= 0:
continue
if holiday_hours > 0:
holiday_source_hours = day_group["holiday_project_hours"] if raw_holiday_total_hours > 0 else day_group["project_hours"]
for project_code, project_hours in holiday_source_hours.items():
project_bucket = ensure_project_bucket(project_code)
project_hours = round(project_hours, 2)
project_bucket["holiday_hours"] += project_hours
if project_hours > 0:
project_bucket["holiday_details"].append(
{
"work_date": work_date.isoformat(),
"member_no": member_no,
"member_name": member_record["member_name"],
"holiday_hours": project_hours,
}
)
project_bucket["member_nos"].add(member_no)
if work_date.weekday() >= 5 and raw_holiday_total_hours <= 0:
continue
for project_code, project_hours in day_group["project_hours"].items():
project_bucket = ensure_project_bucket(project_code)
share_ratio = project_hours / raw_total_hours if raw_total_hours else 0.0
project_regular_hours = round(capped_regular_hours * share_ratio, 4)
project_bucket["regular_hours"] += project_regular_hours
if capped_regular_hours > 0 and project_hours > 0:
project_bucket["regular_work_days"] += 1
project_bucket["regular_details"].append(
{
"work_date": work_date.isoformat(),
"member_no": member_no,
"member_name": member_record["member_name"],
"regular_hours": round(project_regular_hours, 2),
"raw_project_hours": round(project_hours, 2),
"leave_days": round(leave_days, 2),
}
)
project_bucket["member_nos"].add(member_no)
for row in overtime_records:
member_no = row["member_no"]
if not include_member(member_no):
continue
raw_project_code = row["project_code"]
project_code = canonical_project_code(raw_project_code)
member_record = member_info.get(member_no) or {
"member_no": member_no,
"member_name": member_no,
"entry_date": None,
"leave_date": None,
"dept_name": "",
}
if not _hanmac_member_is_active_on(member_record, row["work_date"]):
continue
member_bucket = ensure_member_bucket(member_no)
member_bucket["overtime_hours"] += row["overtime_hours"]
if row["overtime_hours"] > 0:
member_bucket["overtime_work_day_keys"].add(row["work_date"])
member_bucket["overtime_details"].append(
{
"work_date": row["work_date"].isoformat() if row.get("work_date") else "",
"project_code": project_code or "(미지정)",
"project_name": project_display_name(project_code),
"raw_project_code": raw_project_code,
"equivalent_project_codes": equivalent_project_codes(project_code),
"overtime_hours": round(row["overtime_hours"], 2),
}
)
if project_code:
member_bucket["project_codes"].add(project_code)
project_bucket = ensure_project_bucket(project_code)
project_bucket["overtime_hours"] += row["overtime_hours"]
if row["overtime_hours"] > 0:
project_bucket["overtime_work_day_keys"].add((member_no, row["work_date"]))
project_bucket["overtime_details"].append(
{
"work_date": row["work_date"].isoformat() if row.get("work_date") else "",
"member_no": member_no,
"member_name": member_record["member_name"],
"overtime_hours": round(row["overtime_hours"], 2),
}
)
project_bucket["member_nos"].add(member_no)
for row in leave_records:
member_no = row["member_no"]
if not include_member(member_no):
continue
member_record = member_info.get(member_no) or {
"member_no": member_no,
"member_name": member_no,
"entry_date": None,
"leave_date": None,
"dept_name": "",
}
if not _hanmac_member_is_active_on(member_record, row["work_date"]):
continue
member_bucket = ensure_member_bucket(member_no)
member_bucket["legal_leave_days"] += row["leave_days"]
member_bucket["legal_leave_hours"] += row.get("leave_hours", row["leave_days"] * 8.0)
member_bucket["leave_details"].append(
{
"work_date": row["work_date"].isoformat() if row.get("work_date") else "",
"leave_type": row.get("leave_type") or "법정휴가",
"leave_days": round(row["leave_days"], 2),
"leave_hours": round(row.get("leave_hours", row["leave_days"] * 8.0), 2),
}
)
if view_mode == "project":
rows = [
{
"project_code": bucket["project_code"],
"project_name": bucket["project_name"],
"equivalent_project_codes": bucket.get("equivalent_project_codes", []),
"member_count": len(bucket["member_nos"]),
"regular_hours": round(bucket["regular_hours"], 2),
"overtime_hours": round(bucket["overtime_hours"], 2),
"holiday_hours": round(bucket["holiday_hours"], 2),
"regular_work_days": bucket["regular_work_days"],
"overtime_work_days": len(bucket["overtime_work_day_keys"]),
"total_hours": round(bucket["regular_hours"] + bucket["overtime_hours"] + bucket["holiday_hours"], 2),
"legal_leave_days": round(bucket["legal_leave_days"], 2),
"legal_leave_hours": round(bucket["legal_leave_hours"], 2),
"aggregate_details": {
"regular_hours": sorted(bucket["regular_details"], key=lambda item: (item.get("work_date") or "", item.get("member_no") or "")),
"overtime_hours": sorted(bucket["overtime_details"], key=lambda item: (item.get("work_date") or "", item.get("member_no") or "")),
"holiday_hours": sorted(bucket["holiday_details"], key=lambda item: (item.get("work_date") or "", item.get("member_no") or "")),
"total_hours": sorted(
[
*[
{"detail_type": "정규근로", **item}
for item in bucket["regular_details"]
],
*[
{"detail_type": "휴일근로", **item}
for item in bucket["holiday_details"]
],
*[
{"detail_type": "연장근로", **item}
for item in bucket["overtime_details"]
],
],
key=lambda item: (item.get("work_date") or "", item.get("member_no") or "", item.get("detail_type") or ""),
),
"legal_leave_days": sorted(bucket["leave_details"], key=lambda item: (item.get("work_date") or "", item.get("member_no") or "")),
},
}
for bucket in project_aggregates.values()
]
rows.sort(key=lambda item: (-item["total_hours"], item["project_code"]))
columns = [
{"key": "project_code", "label": "프로젝트코드"},
{"key": "project_name", "label": "프로젝트명"},
{"key": "member_count", "label": "참여인원"},
{"key": "regular_hours", "label": "정규근로"},
{"key": "overtime_hours", "label": "연장근로"},
{"key": "total_hours", "label": "총근로"},
{"key": "legal_leave_days", "label": "법정휴가"},
]
else:
rows = [
{
"member_no": bucket["member_no"],
"member_name": bucket["member_name"],
"status": bucket["status"],
"entry_date": bucket["entry_date"],
"leave_date": bucket["leave_date"],
"dept_name": bucket["dept_name"],
"regular_hours": round(bucket["regular_hours"], 2),
"overtime_hours": round(bucket["overtime_hours"], 2),
"holiday_hours": round(bucket["holiday_hours"], 2),
"regular_work_days": bucket["regular_work_days"],
"overtime_work_days": len(bucket["overtime_work_day_keys"]),
"total_hours": round(bucket["regular_hours"] + bucket["overtime_hours"] + bucket["holiday_hours"], 2),
"legal_leave_days": round(bucket["legal_leave_days"], 2),
"legal_leave_hours": round(bucket["legal_leave_hours"], 2),
"project_count": len(bucket["project_codes"]),
"aggregate_details": {
"regular_hours": sorted(bucket["regular_details"], key=lambda item: item.get("work_date") or ""),
"overtime_hours": sorted(bucket["overtime_details"], key=lambda item: item.get("work_date") or ""),
"holiday_hours": sorted(bucket["holiday_details"], key=lambda item: item.get("work_date") or ""),
"total_hours": sorted(
[
*[
{"detail_type": "정규근로", **item}
for item in bucket["regular_details"]
],
*[
{"detail_type": "휴일근로", **item}
for item in bucket["holiday_details"]
],
*[
{"detail_type": "연장근로", **item}
for item in bucket["overtime_details"]
],
],
key=lambda item: (item.get("work_date") or "", item.get("detail_type") or ""),
),
"legal_leave_days": sorted(bucket["leave_details"], key=lambda item: item.get("work_date") or ""),
"project_count": sorted(
[
{
"project_code": project_code,
"project_name": project_display_name(project_code),
"equivalent_project_codes": equivalent_project_codes(project_code),
}
for project_code in bucket["project_codes"]
],
key=lambda item: item["project_code"],
),
},
"multi_entry_days": bucket["multi_entry_days"],
"multi_entry_details": sorted(
bucket["multi_entry_details"],
key=lambda item: item["work_date"],
),
}
for bucket in member_aggregates.values()
]
rows.sort(key=lambda item: (-item["total_hours"], item["member_no"]))
columns = [
{"key": "member_no", "label": "사번"},
{"key": "member_name", "label": "이름"},
{"key": "status", "label": "구분"},
{"key": "entry_date", "label": "입사일"},
{"key": "leave_date", "label": "퇴사일"},
{"key": "dept_name", "label": "부서"},
{"key": "regular_hours", "label": "정규근로"},
{"key": "overtime_hours", "label": "연장근로"},
{"key": "total_hours", "label": "총근로"},
{"key": "legal_leave_days", "label": "법정휴가"},
{"key": "project_count", "label": "프로젝트수"},
]
summary = {
"member_count": len(member_aggregates),
"project_count": len(project_aggregates),
"regular_hours": round(sum(item.get("regular_hours", 0.0) for item in rows), 2),
"overtime_hours": round(sum(item.get("overtime_hours", 0.0) for item in rows), 2),
"legal_leave_days": round(sum(item.get("legal_leave_days", 0.0) for item in rows), 2),
"legal_leave_hours": round(sum(item.get("legal_leave_hours", 0.0) for item in rows), 2),
}
summary["holiday_hours"] = round(sum(item.get("holiday_hours", 0.0) for item in rows), 2)
summary["total_hours"] = round(summary["regular_hours"] + summary["overtime_hours"] + summary["holiday_hours"], 2)
response_payload = {
"status": "ok",
"view": view_mode,
"start_date": start_date.isoformat(),
"end_date": end_date.isoformat(),
"employment": employment_filter,
"columns": columns,
"rows": rows,
"summary": summary,
"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 "선택한 DB 이름을 찾지 못했습니다. hanmac 또는 hanmac_manhour 선택을 다시 확인해주세요."
if "can't connect" in lowered or "connection refused" in lowered or "timed out" in lowered:
return "MySQL 서버 포트에는 접근했지만 최종 연결에 실패했습니다. 서버 상태 또는 방화벽 설정을 확인해주세요."
if "authentication plugin" in lowered:
return "MySQL 인증 방식이 현재 앱과 맞지 않습니다. 서버 계정의 인증 플러그인 설정을 확인해주세요."
return f"MySQL 연결에 실패했습니다. 상세 원인: {raw_message}"
def build_wehago_compare_health_payload() -> dict[str, Any]:
init_db()
payload = get_wehago_compare_dashboard(
engine,
include_metric_counts=False,
warm_caches=False,
)
return {
"status": "ok",
"selected_start_year": payload.get("selected_start_year"),
"selected_end_year": payload.get("selected_end_year"),
"available_year_count": len(payload.get("available_years") or []),
"metric_section_count": len(payload.get("metric_sections") or []),
}
@app.get("/health")
async def health() -> dict[str, str]:
return build_health_payload()
@app.get("/health/wehago-compare")
async def health_wehago_compare() -> JSONResponse:
try:
return JSONResponse(content=jsonable_encoder(build_wehago_compare_health_payload()))
except Exception as exc:
logger.exception("전표비교 readiness 에러: %s", exc)
return JSONResponse(content={"status": "error", "error": str(exc)}, status_code=500)
@app.get("/")
async def home(request: Request, edit_id: int | None = None, overview_year: int | None = None):
try:
return render_home(request, edit_id=edit_id, overview_year=overview_year)
except Exception as exc:
logger.exception("홈페이지 에러: %s", exc)
return HTMLResponse("<h1>서버 오류</h1><p>로그를 확인해주세요.</p>", status_code=500)
@app.get("/bootstrap-data")
async def dashboard_bootstrap_data_api(overview_year: str | None = None):
try:
payload = await run_in_threadpool(get_dashboard_bootstrap_payload, parse_optional_year(overview_year))
return JSONResponse(content=jsonable_encoder(payload))
except Exception as exc:
logger.exception("대시보드 부트스트랩 데이터 조회 에러: %s", exc)
return JSONResponse(content={"error": str(exc)}, status_code=500)
@app.get("/projects")
async def projects(
request: Request,
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("<h1>서버 오류</h1><p>로그를 확인해주세요.</p>", 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/account-breakdowns")
async def project_account_breakdowns_api(year: str | None = None, codes: str | None = None):
try:
selected_year = parse_optional_year(year)
selected_codes = parse_support_dept_codes_param(codes)
payload = await run_in_threadpool(get_project_account_breakdowns, selected_year, selected_codes)
return JSONResponse(content=jsonable_encoder(payload))
except Exception as exc:
logger.exception("프로젝트 계정 분해 조회 에러: %s", exc)
return JSONResponse(content={"error": str(exc)}, status_code=500)
@app.get("/projects/bootstrap-data")
async def project_bootstrap_data_api(year: str | None = None):
try:
selected_year = parse_optional_year(year)
payload = await run_in_threadpool(
lambda: {
"revenue_mix": get_project_revenue_mix(selected_year),
"project_cost_by_year": get_project_cost_by_year(selected_year),
"project_status_rows": get_project_status_rows(),
}
)
return JSONResponse(content=jsonable_encoder(payload))
except Exception as exc:
logger.exception("프로젝트 부트스트랩 데이터 조회 에러: %s", exc)
return JSONResponse(content={"error": str(exc)}, status_code=500)
@app.post("/projects/page-state")
async def project_page_state_save(request: Request):
try:
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("<h1>서버 오류</h1><p>로그를 확인해주세요.</p>", status_code=500)
@app.get("/annual-summary/bootstrap-data")
async def annual_summary_bootstrap_data_api():
try:
payload = await run_in_threadpool(get_annual_summary_bootstrap_payload)
return JSONResponse(content=jsonable_encoder(payload))
except Exception as exc:
logger.exception("연도별 수익 비용 부트스트랩 데이터 조회 에러: %s", exc)
return JSONResponse(content={"error": str(exc)}, status_code=500)
@app.get("/process-cost")
async def process_cost(
request: Request,
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("<h1>서버 오류</h1><p>로그를 확인해주세요.</p>", status_code=500)
@app.get("/process-cost/bootstrap-data")
async def process_cost_bootstrap_data(
source: str | None = None,
start_year: str | None = None,
end_year: str | None = None,
code: str | None = None,
include_related: bool = False,
active_related: str | None = None,
):
try:
payload = await run_in_threadpool(
get_process_cost_bootstrap_payload,
source,
parse_optional_year(start_year),
parse_optional_year(end_year),
code,
include_related,
active_related,
)
return JSONResponse(content=jsonable_encoder(payload))
except Exception as exc:
logger.exception("프로세스 원가 부트스트랩 데이터 조회 에러: %s", exc)
return JSONResponse(content={"error": str(exc)}, status_code=500)
@app.get("/hanmac-browser")
async def hanmac_browser(request: Request):
try:
return render_hanmac_browser_page(request)
except Exception as exc:
logger.exception("hanmac DB_external 페이지 에러: %s", exc)
return HTMLResponse("<h1>서버 오류</h1><p>로그를 확인해주세요.</p>", status_code=500)
@app.post("/hanmac-browser/api/test-connection")
async def hanmac_browser_test_connection(request: Request):
try:
payload = await request.json()
if not isinstance(payload, dict):
raise ValueError("잘못된 요청 형식입니다.")
return JSONResponse(content=test_hanmac_mysql_connection(payload))
except OperationalError as exc:
logger.exception("hanmac DB_external MySQL 연결 실패: %s", exc)
return JSONResponse(
content={
"status": "error",
"message": build_hanmac_mysql_error_message(exc),
},
status_code=400,
)
except Exception as exc:
logger.exception("hanmac DB_external 연결 확인 에러: %s", exc)
return JSONResponse(
content={
"status": "error",
"message": str(exc),
},
status_code=400,
)
@app.post("/hanmac-browser/api/tables")
async def hanmac_browser_tables(request: Request):
try:
payload = await request.json()
if not isinstance(payload, dict):
raise ValueError("잘못된 요청 형식입니다.")
return JSONResponse(content=get_hanmac_table_list(payload))
except OperationalError as exc:
logger.exception("hanmac DB_external 테이블 목록 조회 실패: %s", exc)
return JSONResponse(content={"status": "error", "message": build_hanmac_mysql_error_message(exc)}, status_code=400)
except Exception as exc:
logger.exception("hanmac DB_external 테이블 목록 에러: %s", exc)
return JSONResponse(content={"status": "error", "message": str(exc)}, status_code=400)
@app.post("/hanmac-browser/api/preview")
async def hanmac_browser_preview(request: Request):
try:
payload = await request.json()
if not isinstance(payload, dict):
raise ValueError("잘못된 요청 형식입니다.")
return JSONResponse(content=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-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.get("/hanmac-browser/api/aggregate-diagnostics")
async def hanmac_browser_aggregate_diagnostics():
return JSONResponse(content=jsonable_encoder(_HANMAC_LAST_AGGREGATE_DIAGNOSTICS or {"status": "empty"}))
@app.get("/wehago-compare")
async def wehago_compare(request: Request, start_year: str | None = None, end_year: str | None = None):
try:
return render_wehago_compare_page(
request,
start_year=parse_optional_year(start_year),
end_year=parse_optional_year(end_year),
)
except Exception as exc:
logger.exception("전표비교 페이지 에러: %s", exc)
return HTMLResponse("<h1>서버 오류</h1><p>로그를 확인해주세요.</p>", 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))
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 = get_wehago_compare_summary(
engine,
start_year=start_year,
end_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.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 = normalize_text(payload.get("include_metric_counts")) 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)
@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
if not isinstance(rows, list):
raise ValueError("저장할 검토 항목 형식이 올바르지 않습니다.")
saved = save_recheck_review_rows(engine, 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.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)])