diff --git a/main.py b/main.py index d101263..4c3e7af 100644 --- a/main.py +++ b/main.py @@ -5281,6 +5281,439 @@ def get_available_years() -> list[int]: 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 get_process_cost_available_years(source: str) -> list[int]: + normalized_source = normalize_text(source).lower() + if normalized_source == "wehago": + init_wehago_compare_db(engine) + with engine.begin() as conn: + rows = conn.execute( + text( + """ + SELECT DISTINCT fiscal_year + FROM wehago_voucher_rows + WHERE fiscal_year IS NOT NULL + ORDER BY fiscal_year + """ + ) + ).fetchall() + return [int(row[0]) for row in rows if row and row[0] is not None] + + 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 and row[0] is not None] + + +def _get_project_contract_meta() -> dict[str, dict[str, Any]]: + with engine.begin() as conn: + rows = conn.execute( + text( + """ + 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, '') <> '' + """ + ) + ).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_process_cost_project_options(source: str, selected_year: int | None) -> list[dict[str, Any]]: + normalized_source = normalize_text(source).lower() + contract_meta = _get_project_contract_meta() + + if normalized_source == "wehago": + init_wehago_compare_db(engine) + year_clause = "" + params: dict[str, Any] = {} + if selected_year: + year_clause = "AND fiscal_year = :selected_year" + params["selected_year"] = selected_year + 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') + {year_clause} + ) + 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 + """ + ), + 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() + + 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, {}) + 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")) + 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(row.get("support_dept_name")) or code, + "client_name": normalize_text(contract_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")), + } + ) + result.sort(key=lambda item: (-normalize_amount(item.get("expense_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]: + code = normalize_text(support_dept_code) + if not code: + return { + "overview": {}, + "phase_rows": [], + "account_rows": [], + "monthly_rows": [], + "diagnostics": {}, + } + + normalized_source = normalize_text(source).lower() + contract_meta = _get_project_contract_meta().get(code, {}) + if normalized_source == "wehago": + init_wehago_compare_db(engine) + year_clause = "" + params: dict[str, Any] = {"support_dept_code": code} + if selected_year: + year_clause = "AND fiscal_year = :selected_year" + params["selected_year"] = selected_year + 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 = :support_dept_code + {year_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 = :support_dept_code + AND (account_code LIKE '5%' OR account_code LIKE '6%') + {year_clause} + 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 = :support_dept_code + {year_clause} + AND LENGTH(COALESCE(proof_date, '')) >= 7 + GROUP BY month_label + ORDER BY month_label DESC + LIMIT 8 + """ + ), + params, + ).mappings().all() + else: + year_clause = "" + params = {"support_dept_code": code} + if selected_year: + year_clause = "AND year = :selected_year" + params["selected_year"] = selected_year + 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 = :support_dept_code + {year_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 = :support_dept_code + AND (account_code LIKE '5%' OR account_code LIKE '6%') + {year_clause} + 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 = :support_dept_code + {year_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 {} + contract_amount = normalize_amount(contract_meta.get("hanmac_contract_amount")) + 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")) + outsourcing_amount = normalize_amount(summary_row.get("outsourcing_amount")) + sga_amount = normalize_amount(summary_row.get("sga_amount")) + design_cost_amount = max(expense_amount - labor_amount - outsourcing_amount - sga_amount, 0.0) + profit_amount = revenue_amount - expense_amount + + target_base = contract_amount * 0.85 if contract_amount > 0 else max(revenue_amount * 0.85, expense_amount) + 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.15, "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")) + 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": normalize_text(row.get("last_posting_date")), + } + ) + + 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 { + "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")), + "voucher_count": int(summary_row.get("voucher_count") or 0), + "row_count": int(summary_row.get("row_count") or 0), + }, + "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, + year: int | None = None, + code: 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_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_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) + 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_projects": project_options, + "process_cost_detail": detail, + } + 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" @@ -6653,6 +7086,25 @@ async def annual_summary(request: Request): return HTMLResponse("
로그를 확인해주세요.
", status_code=500) +@app.get("/process-cost") +async def process_cost( + request: Request, + source: str | None = None, + year: str | None = None, + code: str | None = None, +): + try: + return render_process_cost_page( + request, + source=source, + year=parse_optional_year(year), + code=code, + ) + except Exception as exc: + logger.exception("프로세스 원가 페이지 에러: %s", exc) + return HTMLResponse("로그를 확인해주세요.
", status_code=500) + + @app.get("/wehago-compare") async def wehago_compare(request: Request, start_year: str | None = None, end_year: str | None = None): try: diff --git a/templates/base.html b/templates/base.html index dd84b87..213da8d 100644 --- a/templates/base.html +++ b/templates/base.html @@ -616,6 +616,7 @@ 대시보드 프로젝트 정보 연도별 수익/비용 + 프로세스 원가 전표비교