Refine process cost metrics and project search dropdown behavior

This commit is contained in:
b17301
2026-04-28 12:22:20 +09:00
parent 21901fe8fb
commit 1d6470c770
4 changed files with 428 additions and 74 deletions
+179 -53
View File
@@ -5288,6 +5288,49 @@ def _safe_ratio(numerator: Any, denominator: Any) -> float:
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]:
normalized_source = normalize_text(source).lower()
if normalized_source == "wehago":
@@ -5324,13 +5367,32 @@ def _get_project_contract_meta() -> dict[str, dict[str, Any]]:
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(support_dept_code, '') AS support_dept_code,
COALESCE(support_dept_name, '') AS support_dept_name,
COALESCE(hanmac_contract_amount, 0) AS hanmac_contract_amount,
COALESCE(client_name, '') AS client_name
FROM project_contract_info
WHERE COALESCE(support_dept_code, '') <> ''
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
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()
@@ -5343,9 +5405,53 @@ def _get_project_contract_meta() -> dict[str, dict[str, Any]]:
return result
def get_process_cost_project_options(source: str, selected_year: int | None) -> list[dict[str, Any]]:
def _get_hanmac_process_cost_tx_by_code(selected_year: int | None) -> dict[str, dict[str, Any]]:
year_clause = ""
params: dict[str, Any] = {}
if selected_year:
year_clause = "AND year = :selected_year"
params["selected_year"] = selected_year
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 ('공통', '경영지원부', '기술개발센터', '임원실', '기술개발부', '총괄기획실')
{year_clause}
GROUP BY support_dept_code
"""
),
params,
).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, selected_year: int | None, include_related: bool = False) -> list[dict[str, Any]]:
normalized_source = normalize_text(source).lower()
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)
@@ -5391,34 +5497,26 @@ def get_process_cost_project_options(source: str, selected_year: int | None) ->
params,
).mappings().all()
else:
year_clause = ""
params = {}
if selected_year:
year_clause = "AND year = :selected_year"
params["selected_year"] = selected_year
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 COALESCE(voucher_number, '')) 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 ('공통', '경영지원부', '기술개발센터', '임원실', '기술개발부', '총괄기획실')
{year_clause}
GROUP BY support_dept_code
ORDER BY expense_amount DESC, support_dept_code
"""
),
params,
).mappings().all()
tx_by_code = _get_hanmac_process_cost_tx_by_code(selected_year)
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:
@@ -5426,6 +5524,7 @@ def get_process_cost_project_options(source: str, selected_year: int | None) ->
if not code:
continue
contract_row = contract_meta.get(code, {})
billing_row = billing_summary_map.get(code, {})
contract_amount = normalize_amount(contract_row.get("hanmac_contract_amount"))
revenue_amount = normalize_amount(row.get("revenue_amount"))
expense_amount = normalize_amount(row.get("expense_amount"))
@@ -5433,8 +5532,8 @@ def get_process_cost_project_options(source: str, selected_year: int | None) ->
result.append(
{
"support_dept_code": code,
"support_dept_name": normalize_text(contract_row.get("support_dept_name")) or normalize_text(row.get("support_dept_name")) or code,
"client_name": normalize_text(contract_row.get("client_name")),
"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,
@@ -5444,11 +5543,16 @@ def get_process_cost_project_options(source: str, selected_year: int | None) ->
"last_posting_date": normalize_text(row.get("last_posting_date")),
}
)
result.sort(key=lambda item: (-normalize_amount(item.get("expense_amount")), item.get("support_dept_code", "")))
result.sort(key=lambda item: (-normalize_amount(item.get("expense_amount")), -normalize_amount(item.get("revenue_amount")), item.get("support_dept_code", "")))
return result
def get_process_cost_project_detail(source: str, selected_year: int | None, support_dept_code: str) -> dict[str, Any]:
def get_process_cost_project_detail(
source: str,
selected_year: int | None,
support_dept_code: str,
include_related: bool = False,
) -> dict[str, Any]:
code = normalize_text(support_dept_code)
if not code:
return {
@@ -5460,11 +5564,15 @@ def get_process_cost_project_detail(source: str, selected_year: int | None, supp
}
normalized_source = normalize_text(source).lower()
contract_meta = _get_project_contract_meta().get(code, {})
contract_meta_map = _get_project_contract_meta()
contract_meta = contract_meta_map.get(code, {})
related_clusters = _build_process_cost_related_clusters() if include_related else {}
cluster_codes = related_clusters.get(code, [code]) if include_related else [code]
if normalized_source == "wehago":
init_wehago_compare_db(engine)
in_clause, code_params = build_in_clause("process_cost_wehago_code", cluster_codes)
year_clause = ""
params: dict[str, Any] = {"support_dept_code": code}
params: dict[str, Any] = dict(code_params)
if selected_year:
year_clause = "AND fiscal_year = :selected_year"
params["selected_year"] = selected_year
@@ -5490,7 +5598,7 @@ def get_process_cost_project_detail(source: str, selected_year: int | None, supp
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 = :support_dept_code
WHERE support_dept_code IN ({in_clause})
{year_clause}
"""
),
@@ -5506,7 +5614,7 @@ def get_process_cost_project_detail(source: str, selected_year: int | None, supp
COUNT(*) AS row_count,
MAX(COALESCE(proof_date, '')) AS last_posting_date
FROM wehago_voucher_rows
WHERE support_dept_code = :support_dept_code
WHERE support_dept_code IN ({in_clause})
AND (account_code LIKE '5%' OR account_code LIKE '6%')
{year_clause}
GROUP BY account_code, account_name
@@ -5524,7 +5632,7 @@ def get_process_cost_project_detail(source: str, selected_year: int | None, supp
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 = :support_dept_code
WHERE support_dept_code IN ({in_clause})
{year_clause}
AND LENGTH(COALESCE(proof_date, '')) >= 7
GROUP BY month_label
@@ -5535,8 +5643,9 @@ def get_process_cost_project_detail(source: str, selected_year: int | None, supp
params,
).mappings().all()
else:
in_clause, code_params = build_in_clause("process_cost_code", cluster_codes)
year_clause = ""
params = {"support_dept_code": code}
params = dict(code_params)
if selected_year:
year_clause = "AND year = :selected_year"
params["selected_year"] = selected_year
@@ -5555,7 +5664,7 @@ def get_process_cost_project_detail(source: str, selected_year: int | None, supp
COUNT(DISTINCT COALESCE(voucher_number, '')) AS voucher_count,
MAX(COALESCE(posting_date, '')) AS last_posting_date
FROM transactions
WHERE support_dept_code = :support_dept_code
WHERE support_dept_code IN ({in_clause})
{year_clause}
"""
),
@@ -5571,7 +5680,7 @@ def get_process_cost_project_detail(source: str, selected_year: int | None, supp
COUNT(*) AS row_count,
MAX(COALESCE(posting_date, '')) AS last_posting_date
FROM transactions
WHERE support_dept_code = :support_dept_code
WHERE support_dept_code IN ({in_clause})
AND (account_code LIKE '5%' OR account_code LIKE '6%')
{year_clause}
GROUP BY account_code, account_name
@@ -5589,7 +5698,7 @@ def get_process_cost_project_detail(source: str, selected_year: int | None, supp
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 = :support_dept_code
WHERE support_dept_code IN ({in_clause})
{year_clause}
AND year IS NOT NULL
AND month IS NOT NULL
@@ -5602,7 +5711,12 @@ def get_process_cost_project_detail(source: str, selected_year: int | None, supp
).mappings().all()
summary_row = dict(summary) if summary else {}
contract_amount = normalize_amount(contract_meta.get("hanmac_contract_amount"))
if include_related:
contract_amount = sum(normalize_amount(contract_meta_map.get(member, {}).get("hanmac_contract_amount")) for member in cluster_codes)
as_cost_amount = sum(normalize_amount(contract_meta_map.get(member, {}).get("expected_as_cost")) for member in cluster_codes)
else:
contract_amount = normalize_amount(contract_meta.get("hanmac_contract_amount"))
as_cost_amount = normalize_amount(contract_meta.get("expected_as_cost"))
revenue_amount = normalize_amount(summary_row.get("revenue_amount"))
expense_amount = normalize_amount(summary_row.get("expense_amount"))
labor_amount = normalize_amount(summary_row.get("labor_amount"))
@@ -5615,7 +5729,8 @@ def get_process_cost_project_detail(source: str, selected_year: int | None, supp
phase_rows = [
{"phase": "직접인건비", "target_amount": target_base * 0.30, "actual_amount": labor_amount},
{"phase": "외주비", "target_amount": target_base * 0.32, "actual_amount": outsourcing_amount},
{"phase": "설계경비", "target_amount": target_base * 0.23, "actual_amount": design_cost_amount},
{"phase": "경비", "target_amount": target_base * 0.23, "actual_amount": design_cost_amount},
{"phase": "A/S비", "target_amount": as_cost_amount, "actual_amount": as_cost_amount},
{"phase": "판관비", "target_amount": target_base * 0.15, "actual_amount": sga_amount},
]
for row in phase_rows:
@@ -5665,6 +5780,7 @@ def get_process_cost_project_detail(source: str, selected_year: int | None, supp
"last_posting_date": normalize_text(summary_row.get("last_posting_date")),
"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],
},
"phase_rows": phase_rows,
"account_rows": normalized_accounts,
@@ -5684,6 +5800,7 @@ def render_process_cost_page(
source: str | None = None,
year: int | None = None,
code: str | None = None,
include_related: bool = False,
message: str = "",
) -> HTMLResponse:
init_db()
@@ -5693,23 +5810,30 @@ def render_process_cost_page(
normalized_source = "hanmac"
years = get_process_cost_available_years(normalized_source)
selected_year = year if year in years else (max(years) if years else None)
project_options = get_process_cost_project_options(normalized_source, selected_year)
selected_year = year if year in years else None
project_options = get_process_cost_project_options(normalized_source, selected_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 = ""
if not selected_code and project_options:
selected_code = project_options[0]["support_dept_code"]
detail = get_process_cost_project_detail(normalized_source, selected_year, selected_code)
detail = get_process_cost_project_detail(
normalized_source,
selected_year,
selected_code,
include_related=include_related,
)
context = {
**base_context(request, message),
"process_cost_source": normalized_source,
"process_cost_years": years,
"process_cost_selected_year": selected_year,
"process_cost_selected_code": selected_code,
"process_cost_include_related": include_related,
"process_cost_projects": project_options,
"process_cost_detail": detail,
"process_cost_related_codes": get_process_cost_related_codes(selected_code),
}
return templates.TemplateResponse(request, "process_cost.html", context)
@@ -7092,6 +7216,7 @@ async def process_cost(
source: str | None = None,
year: str | None = None,
code: str | None = None,
include_related: str | None = None,
):
try:
return render_process_cost_page(
@@ -7099,6 +7224,7 @@ async def process_cost(
source=source,
year=parse_optional_year(year),
code=code,
include_related=normalize_text(include_related) in {"1", "true", "y", "yes", "on"},
)
except Exception as exc:
logger.exception("프로세스 원가 페이지 에러: %s", exc)