diff --git a/data.db b/data.db index 4c3c4c8..8e18fb9 100644 Binary files a/data.db and b/data.db differ diff --git a/main.py b/main.py index 4c3e7af..27757e9 100644 --- a/main.py +++ b/main.py @@ -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) diff --git a/templates/process_cost.html b/templates/process_cost.html index 39b8713..457684a 100644 --- a/templates/process_cost.html +++ b/templates/process_cost.html @@ -48,6 +48,13 @@ color: #fff; } + .pc-tab-row { + display: inline-flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; + } + .pc-filter { display: inline-flex; align-items: center; @@ -144,6 +151,83 @@ line-height: 1.6; } + .pc-related-box { + border: 1px solid var(--line); + border-radius: 12px; + background: #fff; + padding: 12px; + display: grid; + gap: 10px; + } + + .pc-related-box h3 { + font-size: 14px; + } + + .pc-chip-row { + display: flex; + flex-wrap: wrap; + gap: 8px; + } + + .pc-chip { + display: inline-flex; + align-items: center; + gap: 8px; + border: 1px solid var(--line); + border-radius: 999px; + background: #f8fafc; + padding: 6px 10px; + font-size: 12px; + min-height: 32px; + } + + .pc-chip button { + border: none; + background: transparent; + color: var(--muted); + padding: 0; + min-width: 16px; + min-height: 16px; + line-height: 1; + cursor: pointer; + } + + .pc-related-form { + display: grid; + grid-template-columns: minmax(0, 1fr) auto auto; + gap: 8px; + align-items: center; + } + + .pc-related-list { + border: 1px solid var(--line); + border-radius: 10px; + background: #fff; + max-height: 200px; + overflow: auto; + display: none; + } + + .pc-related-list.active { + display: block; + } + + .pc-related-item { + width: 100%; + border: none; + background: transparent; + text-align: left; + padding: 9px 10px; + cursor: pointer; + font-size: 12px; + color: var(--ink); + } + + .pc-related-item:hover { + background: #f5f7fa; + } + .pc-block { border: 1px solid var(--line); border-radius: 14px; @@ -309,23 +393,28 @@ {% set overview = detail.overview or {} %} {% set diagnostics = detail.diagnostics or {} %}
-
-

프로세스 원가 관리

-

토목설계회사 기준 초안 (직접인건비/외주비/설계경비/판관비 중심)

-
-
-
- Hanmac ERP - WEHAGO +
+ {% if process_cost_include_related %} + + {% endif %} + + +
+ +

설계 프로세스별 원가 현황

@@ -474,7 +578,7 @@
-
설계경비 비중{{ "{:.1f}".format(diagnostics.design_cost_ratio|default(0)) }}%
+
제경비 비중{{ "{:.1f}".format(diagnostics.design_cost_ratio|default(0)) }}%
@@ -486,10 +590,11 @@

데이터 현황

선택 소스{% if process_cost_source == 'wehago' %}WEHAGO{% else %}Hanmac ERP{% endif %}
-
연도{{ process_cost_selected_year or '-' }}
+
연도{{ process_cost_selected_year or '전체 기간' }}
전표 건수{{ "{:,}".format(overview.voucher_count|default(0)) }}
행 건수{{ "{:,}".format(overview.row_count|default(0)) }}
실행률{{ "{:.1f}".format(overview.execution_rate|default(0)) }}%
+
연계 반영{% if process_cost_include_related %}반영{% else %}미반영{% endif %}
@@ -504,6 +609,9 @@ {% block script %}