From 3bf48c42db2664eb28e9740cc75003d0fe662955 Mon Sep 17 00:00:00 2001 From: b17301 Date: Tue, 28 Apr 2026 18:11:14 +0900 Subject: [PATCH] Refine process cost navigation and unify section layouts --- data.db | Bin 236146688 -> 236146688 bytes main.py | 416 +++++++++++++--- templates/annual_summary.html | 28 +- templates/base.html | 37 +- templates/dashboard.html | 29 +- templates/process_cost.html | 873 +++++++++++++++++++++++++++++----- templates/projects.html | 144 ++++-- templates/wehago_compare.html | 54 ++- 8 files changed, 1302 insertions(+), 279 deletions(-) diff --git a/data.db b/data.db index 8e18fb9586c39e0c9c187d58af619581d54d3bc6..24cb51dd59c2e0cd251c761096dffc289f084832 100644 GIT binary patch delta 42762 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 delta 42592 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 None: ) +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, + }, + ) + + def get_project_uncontracted_classification_map() -> dict[str, str]: with engine.begin() as conn: rows = conn.execute( @@ -5332,34 +5380,53 @@ def get_process_cost_related_codes(support_dept_code: str | None) -> list[str]: 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] + 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)) - 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 _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]]: @@ -5385,7 +5452,9 @@ def _get_project_contract_meta() -> dict[str, dict[str, Any]]: 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_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 @@ -5405,12 +5474,117 @@ def _get_project_contract_meta() -> dict[str, dict[str, Any]]: return result +def _get_project_actual_sga_summary(codes: list[str]) -> dict[str, dict[str, Any]]: + normalized_codes = [normalize_text(code) for code in codes if normalize_text(code)] + if not normalized_codes: + return {} + in_clause, params = build_in_clause("actual_sga_code", normalized_codes) + with engine.begin() as conn: + rows = conn.execute( + text( + f""" + SELECT + support_dept_code, + 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 + FROM project_actual_input_entries + WHERE support_dept_code IN ({in_clause}) + AND COALESCE(group_name, '') = 'sga' + 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, + } + 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]]: - 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( @@ -5427,11 +5601,10 @@ def _get_hanmac_process_cost_tx_by_code(selected_year: int | None) -> dict[str, 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: @@ -5448,18 +5621,18 @@ def _get_hanmac_process_cost_tx_by_code(selected_year: int | None) -> dict[str, return result -def get_process_cost_project_options(source: str, selected_year: int | None, include_related: bool = False) -> list[dict[str, Any]]: +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() 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) - 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( @@ -5480,7 +5653,6 @@ def get_process_cost_project_options(source: str, selected_year: int | None, inc FROM wehago_voucher_rows WHERE COALESCE(support_dept_code, '') <> '' AND support_dept_code NOT IN ('ZZZZZZ') - {year_clause} ) SELECT support_dept_code, @@ -5494,10 +5666,10 @@ def get_process_cost_project_options(source: str, selected_year: int | None, inc ORDER BY expense_amount DESC, support_dept_code """ ), - params, + {}, ).mappings().all() else: - tx_by_code = _get_hanmac_process_cost_tx_by_code(selected_year) + 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: @@ -5525,6 +5697,14 @@ def get_process_cost_project_options(source: str, selected_year: int | None, inc 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 = normalize_amount(contract_row.get("hanmac_contract_amount")) revenue_amount = normalize_amount(row.get("revenue_amount")) expense_amount = normalize_amount(row.get("expense_amount")) @@ -5541,10 +5721,12 @@ def get_process_cost_project_options(source: str, selected_year: int | None, inc "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], } ) - result.sort(key=lambda item: (-normalize_amount(item.get("expense_amount")), -normalize_amount(item.get("revenue_amount")), item.get("support_dept_code", ""))) - return result + return _sort_process_cost_project_options(result) def get_process_cost_project_detail( @@ -5568,14 +5750,12 @@ def get_process_cost_project_detail( 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] + actual_sga_summary_map = _get_project_actual_sga_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) - year_clause = "" params: dict[str, Any] = dict(code_params) - 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)) " @@ -5599,7 +5779,6 @@ def get_process_cost_project_detail( MAX(COALESCE(proof_date, '')) AS last_posting_date FROM wehago_voucher_rows WHERE support_dept_code IN ({in_clause}) - {year_clause} """ ), params, @@ -5616,7 +5795,6 @@ def get_process_cost_project_detail( FROM wehago_voucher_rows 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 ORDER BY amount DESC, account_code LIMIT 14 @@ -5633,7 +5811,6 @@ def get_process_cost_project_detail( 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}) - {year_clause} AND LENGTH(COALESCE(proof_date, '')) >= 7 GROUP BY month_label ORDER BY month_label DESC @@ -5644,11 +5821,7 @@ def get_process_cost_project_detail( ).mappings().all() else: in_clause, code_params = build_in_clause("process_cost_code", cluster_codes) - year_clause = "" params = dict(code_params) - if selected_year: - year_clause = "AND year = :selected_year" - params["selected_year"] = selected_year with engine.begin() as conn: summary = conn.execute( text( @@ -5665,7 +5838,6 @@ def get_process_cost_project_detail( MAX(COALESCE(posting_date, '')) AS last_posting_date FROM transactions WHERE support_dept_code IN ({in_clause}) - {year_clause} """ ), params, @@ -5682,7 +5854,6 @@ def get_process_cost_project_detail( FROM transactions 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 ORDER BY amount DESC, account_code LIMIT 14 @@ -5699,7 +5870,6 @@ def get_process_cost_project_detail( 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}) - {year_clause} AND year IS NOT NULL AND month IS NOT NULL GROUP BY year, month @@ -5714,24 +5884,46 @@ def get_process_cost_project_detail( 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) + expected_sga_budget = sum(normalize_amount(contract_meta_map.get(member, {}).get("expected_sga_budget")) for member in cluster_codes) + planned_labor_amount = sum(normalize_amount((exec_budget_summary_map.get(member) or {}).get("labor")) for member in cluster_codes) + planned_outsource_amount = sum(normalize_amount((exec_budget_summary_map.get(member) or {}).get("outsource")) for member in cluster_codes) + planned_cost_plan_amount = sum(normalize_amount((exec_budget_summary_map.get(member) or {}).get("cost_plan")) 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")) + expected_sga_budget = normalize_amount(contract_meta.get("expected_sga_budget")) + planned_labor_amount = normalize_amount((exec_budget_summary_map.get(code) or {}).get("labor")) + planned_outsource_amount = normalize_amount((exec_budget_summary_map.get(code) or {}).get("outsource")) + planned_cost_plan_amount = normalize_amount((exec_budget_summary_map.get(code) or {}).get("cost_plan")) revenue_amount = normalize_amount(summary_row.get("revenue_amount")) - expense_amount = normalize_amount(summary_row.get("expense_amount")) + ledger_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) + ledger_sga_amount = normalize_amount(summary_row.get("sga_amount")) + real_project_actual_sga_amount = 0.0 + 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 _is_real_project_actual_sga(actual_summary, member_expected_sga_budget): + real_project_actual_sga_amount += normalize_amount((actual_summary or {}).get("amount")) + sga_amount = ledger_sga_amount + real_project_actual_sga_amount + expense_amount = ledger_expense_amount + real_project_actual_sga_amount + design_cost_amount = max(ledger_expense_amount - labor_amount - outsourcing_amount - ledger_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) + target_base = ( + planned_labor_amount + + planned_outsource_amount + + planned_cost_plan_amount + + as_cost_amount + + expected_sga_budget + ) 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": 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_cost_amount}, - {"phase": "판관비", "target_amount": target_base * 0.15, "actual_amount": sga_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"] @@ -5750,6 +5942,19 @@ def get_process_cost_project_detail( "last_posting_date": normalize_text(row.get("last_posting_date")), } ) + if real_project_actual_sga_amount > 0: + normalized_accounts.append( + { + "account_code": "PROJECT-SGA", + "account_name": "판관비(프로젝트 정보)", + "amount": real_project_actual_sga_amount, + "row_count": sum(len((actual_sga_summary_map.get(member) or {}).get("rows") or []) for member in cluster_codes), + "share_rate": _safe_ratio(real_project_actual_sga_amount, expense_amount), + "last_posting_date": "", + } + ) + 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: @@ -5781,6 +5986,9 @@ def get_process_cost_project_detail( "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, }, "phase_rows": phase_rows, "account_rows": normalized_accounts, @@ -5798,7 +6006,8 @@ def get_process_cost_project_detail( def render_process_cost_page( request: Request, source: str | None = None, - year: int | None = None, + start_year: int | None = None, + end_year: int | None = None, code: str | None = None, include_related: bool = False, message: str = "", @@ -5810,17 +6019,39 @@ def render_process_cost_page( normalized_source = "hanmac" years = get_process_cost_available_years(normalized_source) - 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_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 = "" - if not selected_code and project_options: - selected_code = project_options[0]["support_dept_code"] + selected_project = next( + (item for item in project_options if normalize_text(item.get("support_dept_code")) == selected_code), + None, + ) detail = get_process_cost_project_detail( normalized_source, - selected_year, + None, selected_code, include_related=include_related, ) @@ -5828,12 +6059,16 @@ def render_process_cost_page( **base_context(request, message), "process_cost_source": normalized_source, "process_cost_years": years, - "process_cost_selected_year": selected_year, + "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": get_process_cost_related_codes(selected_code), + "process_cost_quick_link_codes": get_process_cost_quick_links(), } return templates.TemplateResponse(request, "process_cost.html", context) @@ -6832,6 +7067,31 @@ async def project_quick_links_save(request: Request): 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: @@ -7214,7 +7474,8 @@ async def annual_summary(request: Request): async def process_cost( request: Request, source: str | None = None, - year: str | None = None, + start_year: str | None = None, + end_year: str | None = None, code: str | None = None, include_related: str | None = None, ): @@ -7222,7 +7483,8 @@ async def process_cost( return render_process_cost_page( request, source=source, - year=parse_optional_year(year), + 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"}, ) diff --git a/templates/annual_summary.html b/templates/annual_summary.html index dced093..48c654f 100644 --- a/templates/annual_summary.html +++ b/templates/annual_summary.html @@ -54,13 +54,12 @@ } .chart-box { - background: - linear-gradient(180deg, rgba(255,255,255,0.98), rgba(246,247,249,0.98)), - radial-gradient(circle at top left, rgba(17, 17, 17, 0.045), transparent 36%); - border: 1px solid var(--line); - border-radius: 16px; - padding: 18px 20px 20px; - box-shadow: inset 0 1px 0 rgba(255,255,255,0.92); + background: transparent; + border: 0; + border-top: 1px solid var(--line); + border-radius: 0; + padding: 18px 0 0; + box-shadow: none; content-visibility: auto; contain-intrinsic-size: 620px; } @@ -96,6 +95,8 @@ min-height: 100%; content-visibility: auto; contain-intrinsic-size: 760px; + padding-right: 14px; + border-right: 1px solid rgba(217, 221, 227, 0.9); } .metric-grid { @@ -128,8 +129,8 @@ } .summary-overview .stat-card { - padding: 14px 14px 15px; - border-radius: 12px; + padding: 0 8px 12px 0; + border-radius: 0; gap: 4px; } @@ -174,7 +175,7 @@ } body[data-view-mode="dual"] .chart-box { - padding: 12px 12px 14px; + padding: 12px 0 0; min-height: 100%; } @@ -191,6 +192,13 @@ .metric-grid { grid-template-columns: 1fr; } + + .summary-overview { + padding-right: 0; + padding-bottom: 12px; + border-right: 0; + border-bottom: 1px solid rgba(217, 221, 227, 0.9); + } } {% endblock %} diff --git a/templates/base.html b/templates/base.html index 213da8d..41cbefb 100644 --- a/templates/base.html +++ b/templates/base.html @@ -23,6 +23,7 @@ --page-frame-width-dual: min(2560px, calc(100vw - (var(--page-gutter) * 2))); --page-frame-width-single: min(1520px, calc(100vw - (var(--page-gutter) * 2))); --status-widget-height: 34px; + --toolbar-row-height: 44px; } * { @@ -116,12 +117,12 @@ } .panel { - background: var(--panel); - border: 1px solid var(--line); - border-radius: 18px; - padding: var(--panel-pad); - box-shadow: 0 10px 24px rgba(21, 24, 29, 0.045); - backdrop-filter: blur(6px); + background: transparent; + border: 0; + border-radius: 0; + padding: 0; + box-shadow: none; + backdrop-filter: none; } .section-title { @@ -165,11 +166,12 @@ } .stat-card { - background: var(--white); - border: 1px solid var(--line); - border-radius: 14px; - padding: 14px 15px 13px; - box-shadow: inset 0 1px 0 rgba(255,255,255,0.7); + background: transparent; + border: 0; + border-radius: 0; + padding: 0 0 14px; + box-shadow: none; + border-bottom: 1px solid rgba(217, 221, 227, 0.9); } .stat-card .label { @@ -402,9 +404,10 @@ .table-wrap { overflow: auto; - border: 1px solid var(--line); - border-radius: 14px; - background: var(--white); + border: 0; + border-top: 1px solid var(--line); + border-radius: 0; + background: transparent; } table { @@ -431,7 +434,7 @@ } tbody tr:nth-child(even) td { - background: var(--table-alt); + background: rgba(245, 246, 248, 0.65); } .empty { @@ -614,9 +617,9 @@
+
+ +
+ + +
+
+

프로젝트 선택

+
+
+
+
+
-
{{ item.support_dept_name }}
- {% if item.client_name %} -
{{ item.client_name }}
+
+ {% if selected_project %} +
+
+
{{ selected_project.support_dept_code }}
+ +
+
{{ selected_project.support_dept_name }}
+ {% if selected_project.client_name %} +
{{ selected_project.client_name }}
+ {% endif %} +
{% endif %} - - {% endfor %} - {% if not process_cost_projects %} -
해당 조건의 프로젝트 데이터가 없습니다.
- {% endif %} -
-
+ + + + +
+
+
+ + + + + +
+
+
+
@@ -476,25 +841,34 @@
- + + {% endblock %} {% block script %}