diff --git a/WORK_SUMMARY_20260408.md b/WORK_SUMMARY_20260408.md new file mode 100644 index 0000000..de2a715 --- /dev/null +++ b/WORK_SUMMARY_20260408.md @@ -0,0 +1,23 @@ +# 작업 요약 + +작업일: 2026-04-08 + +## 핵심 변경 + +- 프로젝트 검색 성능 저하 구간을 줄이기 위해 검색 목록 렌더 흐름을 정리하고 표시 개수를 제한했습니다. +- 프로젝트 정보 페이지의 상세 레이아웃을 재구성해 상단 정보 카드의 중첩 박스를 제거하고 주요 지표 배치를 정리했습니다. +- 계획 대비 실제 비교에서 인건비, 외주비, 제경비, A/S비, 판관비 세부 로직과 실제 집행 합산 기준을 여러 차례 보정했습니다. +- 실투입 관리, 실행예산계획, 과업수행계획 입력 UI와 저장 구조를 정리했습니다. +- 대시보드 상단을 재구성해 사업현황 요약과 수금/지출 구성 그래프를 다시 배치했습니다. +- 프로젝트 페이지 상태 저장은 버튼 클릭 시 DB에 저장되도록 연결했습니다. + +## 주요 파일 + +- `main.py` +- `templates/base.html` +- `templates/dashboard.html` +- `templates/projects.html` + +## 참고 + +- 템플릿 백업은 `template_backups/20260408_ko/`에 생성했습니다. diff --git a/data.db b/data.db index d131c95..8475417 100644 Binary files a/data.db and b/data.db differ diff --git a/main.py b/main.py index 9f8e175..82f50db 100644 --- a/main.py +++ b/main.py @@ -215,6 +215,20 @@ def init_db() -> None: """ ) ) + 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, + related_project_selections_json TEXT DEFAULT '{}', + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) + """ + ) + ) existing_columns = { row[1] for row in conn.execute(text("PRAGMA table_info(project_status)")).fetchall() @@ -247,6 +261,19 @@ def init_db() -> None: 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 = { + "selected_code": "TEXT DEFAULT ''", + "selected_year": "TEXT DEFAULT ''", + "analysis_open": "INTEGER DEFAULT 0", + "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}")) def count_transactions() -> int: @@ -1088,6 +1115,102 @@ def get_project_status_for_edit(support_dept_code: str | None) -> dict[str, Any] return result +def get_project_page_state() -> dict[str, Any]: + 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(related_project_selections_json, '{}') AS related_project_selections_json + FROM project_page_state + WHERE page_key = 'projects' + """ + ) + ).mappings().first() + if not row: + return { + "selected_code": "", + "selected_year": "", + "analysis_open": False, + "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"]), + "related_project_selections": related_project_selections, + } + + +def save_project_page_state(payload: dict[str, Any]) -> None: + 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 + 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, + selected_code, + selected_year, + analysis_open, + related_project_selections_json, + updated_at + ) VALUES ( + 'projects', + :selected_code, + :selected_year, + :analysis_open, + :related_project_selections_json, + CURRENT_TIMESTAMP + ) + ON CONFLICT(page_key) DO UPDATE SET + selected_code = excluded.selected_code, + selected_year = excluded.selected_year, + analysis_open = excluded.analysis_open, + related_project_selections_json = excluded.related_project_selections_json, + updated_at = CURRENT_TIMESTAMP + """ + ), + { + "selected_code": selected_code, + "selected_year": selected_year, + "analysis_open": analysis_open, + "related_project_selections_json": json.dumps(related_project_selections, ensure_ascii=False), + }, + ) + + def get_project_year_options() -> list[int]: return get_available_years() @@ -1240,6 +1363,40 @@ def get_project_revenue_mix(selected_year: int | None = None) -> list[dict[str, 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] = {} @@ -1723,19 +1880,85 @@ def build_project_status_payload(payload: dict[str, Any]) -> dict[str, Any]: row["group"] = "cost_plan" exec_budget_rows = exec_labor_rows + exec_outsource_rows + exec_cost_plan_rows - actual_input_rows = [] - actual_refs = payload.get("actual_input_ref[]", []) - actual_amounts = payload.get("actual_input_amount[]", []) - actual_notes = payload.get("actual_input_note[]", []) - for index, ref in enumerate(actual_refs): - actual_input_rows.append( + 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_rows: list[dict[str, Any]] = [] + actual_labor_max_length = max( + len(actual_labor_grades), + len(actual_labor_minutes), + len(actual_labor_amounts), + ) + 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 "", + } + 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: + normalized_row["amount"] = 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( { - "reference": ref, - "amount": actual_amounts[index] if index < len(actual_amounts) else "", - "note": actual_notes[index] if index < len(actual_notes) else "", + "group": "labor_adjustment", + "label": "인건비 조정", + "amount": actual_labor_adjustment_total, } ) - actual_input_rows = filter_amount_rows(actual_input_rows, amount_key="amount") + + 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") expected_as_rate = normalize_amount(payload.get("expected_as_rate")) expected_sga_rate = normalize_amount(payload.get("expected_sga_rate")) @@ -1979,6 +2202,9 @@ def render_home( **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, @@ -2004,6 +2230,7 @@ def render_projects_page( "project_account_breakdowns": get_project_account_breakdowns(selected_year), "project_status_rows": get_project_status_rows(), "project_edit": get_project_status_for_edit(edit_code), + "project_page_state": get_project_page_state(), "support_department_options": get_support_department_options(), "cost_department_options": get_cost_department_options(), "cost_account_options": get_cost_account_options(), @@ -2054,6 +2281,19 @@ async def project_edit_data(code: str | None = None): 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("/annual-summary") async def annual_summary(request: Request): try: diff --git a/template_backups/20260408_ko/annual_summary.html b/template_backups/20260408_ko/annual_summary.html new file mode 100644 index 0000000..35b7915 --- /dev/null +++ b/template_backups/20260408_ko/annual_summary.html @@ -0,0 +1,466 @@ +{% extends "base.html" %} + +{% block title %}연도별 수익 비용 정리{% endblock %} + +{% block head_extra %} + +{% endblock %} + +{% block content %} +
+
+

연도별 수익 비용 정리

+
+
+
+ +
+
+ +
+
+
+
+ +
+
+

선택 구간 요약

+
+
+
+ +
+
+

비용 구조

+
+
+
+ +
+
+ +
+
+

수금/비용/영업수지 그래프

+
+
+
+ +
+
+{% endblock %} + +{% block script %} + +{% endblock %} diff --git a/template_backups/20260408_ko/base.html b/template_backups/20260408_ko/base.html new file mode 100644 index 0000000..c4cd4fd --- /dev/null +++ b/template_backups/20260408_ko/base.html @@ -0,0 +1,665 @@ + + + + + + {% block title %}인트라넷 회계 시스템{% endblock %} + + {% block head_extra %}{% endblock %} + + +
+ + + {% if message %} +
{{ message }}
+ {% endif %} + + {% block content %}{% endblock %} +
+{% block script %}{% endblock %} + + + diff --git a/template_backups/20260408_ko/dashboard.html b/template_backups/20260408_ko/dashboard.html new file mode 100644 index 0000000..ee349c2 --- /dev/null +++ b/template_backups/20260408_ko/dashboard.html @@ -0,0 +1,595 @@ +{% extends "base.html" %} + +{% block title %}대시보드{% endblock %} + +{% block head_extra %} + +{% endblock %} + +{% block content %} +
+
+
+

사업현황

+
+
+
+ +
+
+
+ + +
+
엑셀 파일을 선택하면 회계 데이터를 DB에 바로 저장합니다. 프로젝트 폴더에 둔 파일 외에 추가 파일을 수동 반영할 때 사용하세요.
+
+
+
+ +
+
+
+
+
수행 프로젝트
+
{{ ((project_dashboard.related_projects or 0) - (project_dashboard.completed_projects or 0)) if ((project_dashboard.related_projects or 0) - (project_dashboard.completed_projects or 0)) > 0 else 0 }}
+
+
+
종료 프로젝트
+
{{ project_dashboard.completed_projects or 0 }}
+
+
+
수금액
+
{{ "{:,.0f}".format(project_dashboard.collection_amount or 0) }}
+
+
+
비용
+
{{ "{:,.0f}".format((overview.total_cost or 0) + (overview.total_sga or 0)) }}
+
+
+
원가
+
{{ "{:,.0f}".format(overview.total_cost or 0) }}
+
+
+
판관비
+
{{ "{:,.0f}".format(overview.total_sga or 0) }}
+
+
+
+ +
+
+
+

수금 구성

+
+ + + +
+
+
+
+ +
+
+ +
+
+

지출 구성

+
+ + + +
+
+
+
+ +
+
+
+
+
+{% endblock %} + +{% block script %} + +{% endblock %} diff --git a/template_backups/20260408_ko/index.html b/template_backups/20260408_ko/index.html new file mode 100644 index 0000000..ff14b31 --- /dev/null +++ b/template_backups/20260408_ko/index.html @@ -0,0 +1,591 @@ + + + + + + 회계 데이터 인트라넷 대시보드 + + + +
+ {% if message %} +
{{ message }}
+ {% endif %} + +
+
+

현황 요약

+

DB에 저장된 전체 자료 기준

+
+
+
+
전체 데이터 건수
+
{{ overview.total_rows or 0 }}
+
+
+
업로드 파일 수
+
{{ overview.source_files or 0 }}
+
+
+
집계 대상 사업 수
+
{{ overview.business_count or 0 }}
+
+
+
원가 총액
+
{{ "{:,.0f}".format(overview.total_cost or 0) }}
+
+
+
판관비 총액
+
{{ "{:,.0f}".format(overview.total_sga or 0) }}
+
+
+
+
+ {% if yearly_summary %} + + + + + + + + + + + + {% for item in yearly_summary %} + + + + + + + + {% endfor %} + +
연도원가 합계판관비 합계원가인건비원가외주비
{{ item.year }}{{ "{:,.0f}".format(item.cost_sum or 0) }}{{ "{:,.0f}".format(item.sga_sum or 0) }}{{ "{:,.0f}".format(item.labor_sum or 0) }}{{ "{:,.0f}".format(item.outsourcing_sum or 0) }}
+ {% else %} +
연간 집계 데이터가 없습니다.
+ {% endif %} +
+
+ {% if monthly_summary %} + + + + + + + + + + + + + {% for item in monthly_summary %} + + + + + + + + + {% endfor %} + +
연도원가 합계판관비 합계원가인건비원가외주비
{{ item.year }}{{ item.month }}{{ "{:,.0f}".format(item.cost_sum or 0) }}{{ "{:,.0f}".format(item.sga_sum or 0) }}{{ "{:,.0f}".format(item.labor_sum or 0) }}{{ "{:,.0f}".format(item.outsourcing_sum or 0) }}
+ {% else %} +
월별 집계 데이터가 없습니다.
+ {% endif %} +
+
+
+ +
+
+
+
+

엑셀 업로드

+

업로드 즉시 DB 저장

+
+
+

+ 업로드 파일은 이미지에 보인 열 형식 기준으로 읽습니다. + 예: 결재상태, 가전표번호, 계정코드, 계정명칭, 차변공급가, 대변공급가, 지원부서코드, + 지원부서명, 원가부서코드, 원가부서명, 적요1, 관리항목 등 +

+

+ 현재 프로젝트 폴더에 있는 엑셀 파일은 서버 시작 시 DB가 비어 있으면 자동으로 적재됩니다. +

+
+
+ + +
+
+ +
+
+
+
+ +
+
+

{{ "데이터 수정" if edit_record.id else "DB 직접 입력" }}

+

엑셀 없이도 직접 등록/수정 가능

+
+
+ +
+ {% for field_name, field_label in field_labels.items() %} +
+ + {% if field_name in ['memo1', 'memo2', 'management_item'] %} + + {% elif field_name == 'posting_date' %} + + {% elif field_name in ['debit_supply', 'debit_vat', 'credit_supply', 'credit_vat'] %} + + {% else %} + + {% endif %} +
+ {% endfor %} +
+
+ + {% if edit_record.id %} + 수정 취소 + {% endif %} +
+
+
+
+ +
+
+ + 집계 대상 사업 + 검색해서 펼쳐보기 + + +
+ {% if support_businesses %} + + + + + + + + + + {% for item in support_businesses %} + + + + + + {% endfor %} + +
지원부서코드사업명행 수
{{ item.support_dept_code }}{{ item.support_dept_name }}{{ item.row_count }}
+ {% else %} +
아직 표시할 사업 데이터가 없습니다. 엑셀 업로드 또는 수동 입력을 먼저 진행해주세요.
+ {% endif %} +
+
+
+
+ +
+ + 사업별 연도/월 사용 비용 + 검색해서 펼쳐보기 + + +
+ {% if business_monthly_summary %} + + + + + + + + + + + + + {% for item in business_monthly_summary %} + + + + + + + + + {% endfor %} + +
연도지원부서코드사업명원가판관비
{{ item.year }}{{ item.month }}{{ item.support_dept_code }}{{ item.support_dept_name }}{{ "{:,.0f}".format(item.cost_sum or 0) }}{{ "{:,.0f}".format(item.sga_sum or 0) }}
+ {% else %} +
사업별 월 집계 데이터가 없습니다.
+ {% endif %} +
+
+
+ + + diff --git a/template_backups/20260408_ko/projects.html b/template_backups/20260408_ko/projects.html new file mode 100644 index 0000000..ce2478f --- /dev/null +++ b/template_backups/20260408_ko/projects.html @@ -0,0 +1,4724 @@ +{% extends "base.html" %} + +{% block title %}프로젝트 정보{% endblock %} + +{% block head_extra %} + +{% endblock %} + +{% block content %} +
+
+
+
+
+
+

프로젝트 검색

+
+ + + + +
+
+
+
+ +
+
+
+
+
+
+ +
+
+
+
+ +
+
+
+ + + + + + +{% endblock %} + +{% block script %} + +{% endblock %} diff --git a/templates/base.html b/templates/base.html index e2201aa..c4cd4fd 100644 --- a/templates/base.html +++ b/templates/base.html @@ -476,7 +476,7 @@