9456 lines
404 KiB
Python
9456 lines
404 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 datasette.app import Datasette
|
|
from wehago_compare import (
|
|
enqueue_default_pair_recommend_precompute,
|
|
get_erp_filtered_rows,
|
|
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,
|
|
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_BACKUP_MIN_INTERVAL_SECONDS = 900.0
|
|
DB_BACKUP_KEEP_COUNT = 24
|
|
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:
|
|
for group_key, items in DEFAULT_APP_OPTION_ITEMS.items():
|
|
for sort_order, (item_key, label, value_text) in enumerate(items):
|
|
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,
|
|
},
|
|
)
|
|
|
|
for rule_group, items in DEFAULT_APP_KEYWORD_RULES.items():
|
|
for sort_order, (category_key, keyword) in enumerate(items):
|
|
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 init_db() -> None:
|
|
with engine.begin() as conn:
|
|
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)
|
|
conn.execute(text("ANALYZE"))
|
|
init_wehago_compare_db(engine)
|
|
|
|
|
|
@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)
|
|
|
|
|
|
@app.get("/health")
|
|
async def health() -> dict[str, str]:
|
|
return build_health_payload()
|
|
|
|
|
|
@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/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)])
|