Files
HM_project_Viewer_Board/main.py
T

9592 lines
409 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
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 HTMLResponse, JSONResponse, RedirectResponse
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.exc import OperationalError
from datasette.app import Datasette
from wehago_compare import (
enqueue_default_pair_recommend_precompute,
get_erp_filtered_rows,
get_compare_snapshot_status,
get_individual_pair_recommendations,
get_last_action_summary,
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,
request_compare_snapshot_rebuild,
recommend_pair_matches,
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
DB_BACKUP_MIN_INTERVAL_SECONDS = 900.0
DB_BACKUP_KEEP_COUNT = 24
DB_ANALYZE_MIN_INTERVAL_SECONDS = 6 * 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]] = {}
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)
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},
)
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 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)
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 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) -> dict[str, dict[str, 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
with engine.begin() as conn:
rows = conn.execute(
text(
f"""
SELECT support_dept_code,
CASE
WHEN {REVENUE_SQL} THEN 'revenue'
WHEN accounting_category = '원가' THEN 'cost'
WHEN accounting_category = '판관비' THEN 'sga'
ELSE 'other'
END AS breakdown_kind,
COALESCE(account_code, '') AS account_code,
COALESCE(account_name, '') AS account_name,
SUM(COALESCE(amount, 0)) AS total_amount
FROM transactions
WHERE COALESCE(support_dept_code, '') <> ''
AND support_dept_code NOT IN ('ZZZZZZ')
AND COALESCE(support_dept_name, '') <> ''
AND support_dept_name NOT IN ('공통', '경영지원부', '기술개발센터', '임원실', '기술개발부', '총괄기획실')
AND ({REVENUE_SQL} OR accounting_category IN ('원가', '판관비'))
{year_clause}
GROUP BY support_dept_code, breakdown_kind, account_code, account_name
"""
),
params,
).mappings().all()
cost_detail_rows = conn.execute(
text(
f"""
SELECT
COALESCE(support_dept_code, '') AS support_dept_code,
COALESCE(voucher_number, '') AS voucher_number,
COALESCE(posting_date, '') AS posting_date,
COALESCE(partner_name, '') AS partner_name,
COALESCE(partner_code, '') AS partner_code,
COALESCE(account_code, '') AS account_code,
COALESCE(account_name, '') AS account_name,
COALESCE(amount, 0) AS amount
FROM transactions
WHERE COALESCE(support_dept_code, '') <> ''
AND support_dept_code NOT IN ('ZZZZZZ')
AND COALESCE(support_dept_name, '') <> ''
AND support_dept_name NOT IN ('공통', '경영지원부', '기술개발센터', '임원실', '기술개발부', '총괄기획실')
AND accounting_category = '원가'
{year_clause}
"""
),
params,
).mappings().all()
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_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),
"project_revenue_mix_yearly": get_project_revenue_mix(),
"project_revenue_mix_monthly": get_project_revenue_mix_monthly(),
"yearly_summary": get_yearly_summary(),
"monthly_summary": get_monthly_summary(),
"available_years": available_years,
"dashboard_revenue_metric_options": get_option_items("dashboard_revenue_metrics"),
"dashboard_expense_metric_options": get_option_items("dashboard_expense_metrics"),
}
return templates.TemplateResponse(request, "dashboard.html", context)
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_revenue_mix": get_project_revenue_mix(selected_year),
"project_cost_by_year": get_project_cost_by_year(selected_year),
"project_monthly_cost_rows": get_business_monthly_summary(),
"project_account_breakdowns": get_project_account_breakdowns(selected_year),
"project_status_rows": get_project_status_rows(),
"project_comparison_notes": get_project_comparison_notes_map(),
"project_analysis_settings": get_project_analysis_settings_map(),
"project_edit": get_project_status_for_edit(edit_code),
"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(),
"yearly_financial_series": get_financial_series("yearly"),
"monthly_financial_series": get_financial_series("monthly"),
"annual_metric_cards": get_option_items("annual_metric_cards"),
"annual_expense_chart_metrics": get_option_items("annual_expense_chart_metrics"),
"annual_balance_chart_metrics": get_option_items("annual_balance_chart_metrics"),
}
return templates.TemplateResponse(request, "annual_summary.html", context)
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 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("/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.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("/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("/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,
):
try:
payload = 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,
)
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/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,
):
try:
payload = get_compare_snapshot_status(
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.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/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)])