commit 27a29e8e4cde552cb4ea92ebf2df536fbc234445 Author: b17301 Date: Tue Apr 7 15:15:15 2026 +0900 Initial intranet dashboard app diff --git a/.codex b/.codex new file mode 100644 index 0000000..e69de29 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..77ac754 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +.venv/ +__pycache__/ +*.pyc diff --git a/README.md b/README.md new file mode 100644 index 0000000..d490ad9 --- /dev/null +++ b/README.md @@ -0,0 +1,205 @@ +# 인트라넷 게시판 초보자 가이드 + +이 프로젝트는 `WSL2 Ubuntu + VS Code` 환경에서 실행하는 사내 게시판입니다. +중요한 점은, `WSL2`에서는 앱을 `0.0.0.0:8010`으로 띄워도 다른 PC가 곧바로 WSL 주소로 접속되지 않을 수 있다는 점입니다. + +즉, 내부망 공유를 하려면 아래 2가지가 모두 필요합니다. + +1. WSL 안에서 FastAPI 서버 실행 +2. Windows가 `8010` 요청을 받아 WSL의 `8010`으로 전달하도록 설정 + +## 1. 이번에 정리된 내용 + +- 앱은 [main.py](/home/b17301/my-intranet-app/main.py) 에서 `0.0.0.0:8010`으로 실행됨 +- 데이터는 `data.db`에 저장됨 +- WSL2 내부망 공유용 보조 스크립트를 `scripts/` 폴더에 추가함 +- Windows `portproxy`와 방화벽 규칙 설정용 PowerShell 스크립트를 추가함 + +## 2. 폴더 구조 + +```text +my-intranet-app/ +├── main.py +├── requirements.txt +├── data.db +├── static/ +├── templates/ +│ └── index.html +└── scripts/ + ├── run_server.sh + ├── show_access_info.sh + ├── setup_windows_portproxy.ps1 + └── remove_windows_portproxy.ps1 +``` + +## 3. 왜 다른 PC에서 접속이 안 되었는가 + +당신의 현재 환경은 `WSL2`입니다. + +WSL2는 Windows 안에 있는 별도 가상 네트워크를 사용합니다. +그래서 아래처럼 동작할 수 있습니다. + +- 내 PC 브라우저에서 `http://127.0.0.1:8010` 접속: 됨 +- 다른 PC에서 `http://WSL-IP:8010` 접속: 안 될 수 있음 + +이럴 때는 다른 PC가 `Windows 호스트 IP:8010` 으로 접속하고, +Windows가 그 요청을 WSL 앱으로 넘겨줘야 합니다. + +## 4. 처음 한 번만 할 설정 + +프로젝트 폴더로 이동: + +```bash +cd /home/b17301/my-intranet-app +``` + +가상환경 생성: + +```bash +python3 -m venv .venv +``` + +가상환경 활성화: + +```bash +source .venv/bin/activate +``` + +라이브러리 설치: + +```bash +pip install -r requirements.txt +``` + +## 5. WSL에서 서버 실행 + +아래 명령으로 실행: + +```bash +./scripts/run_server.sh +``` + +또는 기존처럼: + +```bash +source .venv/bin/activate +python main.py +``` + +내 PC 테스트 주소: + +```text +http://127.0.0.1:8010 +``` + +중요: + +- 이제 서버는 `reload=False`로 실행되므로, 예전처럼 자동 재시작 로그가 반복해서 나오지 않습니다. +- 코드를 수정한 뒤 반영하려면 서버를 `Ctrl + C`로 끄고 다시 `./scripts/run_server.sh`를 실행하면 됩니다. + +## 6. 현재 WSL 쪽 주소 확인 + +아래 명령 실행: + +```bash +./scripts/show_access_info.sh +``` + +현재 값은 실행 시점마다 달라질 수 있습니다. + +- WSL 내부 IP: `./scripts/show_access_info.sh` 실행 결과를 확인 +- Windows와 연결된 주소 추정값: `./scripts/show_access_info.sh` 실행 결과를 확인 + +하지만 다른 PC가 접속할 때는 보통 `WSL 내부 IP`가 아니라 `Windows 호스트 IP`를 사용해야 합니다. + +## 7. 가장 중요한 설정: Windows 포트 전달 + +다음 작업은 `Windows PowerShell을 관리자 권한`으로 열어서 실행해야 합니다. + +가장 쉬운 방법은 `PowerShell`에서 아래 명령을 실행하는 것입니다. + +```powershell +& "\\wsl$\Ubuntu\home\b17301\my-intranet-app\scripts\setup_windows_portproxy.ps1" -Port 8010 +``` + +만약 `CMD` 창만 사용하고 있다면 아래 명령을 그대로 실행하면 됩니다. + +```cmd +"\\wsl$\Ubuntu\home\b17301\my-intranet-app\scripts\setup_windows_portproxy.cmd" +``` + +이 스크립트가 하는 일: + +- Windows의 `0.0.0.0:8010` 수신 +- 들어온 요청을 `현재 WSL IP:8010` 으로 전달 +- Windows 방화벽에서 TCP `8010` 허용 + +## 8. 다른 PC에서 접속할 주소 + +다른 PC 브라우저에서는 아래처럼 접속합니다. + +```text +http://Windows호스트IP:8010 +``` + +예를 들어 Windows PC의 실제 사내 IP가 `192.168.0.25` 라면: + +```text +http://192.168.0.25:8010 +``` + +여기서 중요한 점: + +- WSL 주소를 다른 PC에 알려주면 안 될 수 있음 +- 다른 PC에는 반드시 `Windows IP`를 알려주는 쪽이 안전함 + +## 9. WSL IP가 바뀌면 어떻게 하나 + +WSL을 재시작하면 내부 IP가 바뀔 수 있습니다. +그럴 때는 아래 순서로 다시 맞추면 됩니다. + +1. WSL에서 `./scripts/show_access_info.sh` 실행 +2. 새 WSL IP 확인 +3. 관리자 PowerShell에서 다시 실행 + +```powershell +.\setup_windows_portproxy.ps1 -Port 8010 +``` + +## 10. 접속 확인 순서 + +1. WSL에서 `./scripts/run_server.sh` 실행 +2. 내 PC 브라우저에서 `http://127.0.0.1:8010` 확인 +3. Windows 관리자 PowerShell에서 `setup_windows_portproxy.ps1` 실행 +4. 다른 PC에서 `http://Windows호스트IP:8010` 접속 + +## 11. 안 되면 확인할 것 + +1. 서버가 실제로 실행 중인지 확인 + +```bash +lsof -i :8010 +``` + +2. 다른 프로그램이 `8010`을 이미 사용 중인지 확인 +3. Windows 방화벽 규칙이 생겼는지 확인 +4. 회사 네트워크에서 사설 포트 접근이 막히지 않았는지 확인 +5. WSL IP가 바뀌었는지 확인 + +## 12. 제거가 필요할 때 + +관리자 PowerShell에서: + +```powershell +& "\\wsl$\Ubuntu\home\b17301\my-intranet-app\scripts\remove_windows_portproxy.ps1" -Port 8010 +``` + +또는 `CMD`에서는: + +```cmd +"\\wsl$\Ubuntu\home\b17301\my-intranet-app\scripts\remove_windows_portproxy.cmd" 8010 +``` + +## 13. 초보자용 한 줄 요약 + +이 프로젝트는 이제 코드 문제는 해결되어 있고, 내부망 공유의 핵심은 `Windows IP:8010 -> WSL 8010` 연결을 Windows에서 열어주는 것입니다. diff --git a/data.db b/data.db new file mode 100644 index 0000000..4196149 Binary files /dev/null and b/data.db differ diff --git a/main.py b/main.py new file mode 100644 index 0000000..d6eb047 --- /dev/null +++ b/main.py @@ -0,0 +1,1956 @@ +import logging +import json +import re +from datetime import date, datetime +from pathlib import Path +from typing import Any +from urllib.parse import parse_qs, quote_plus + +import uvicorn +from fastapi import FastAPI, File, Request, UploadFile +from fastapi.responses import HTMLResponse, RedirectResponse +from fastapi.staticfiles import StaticFiles +from fastapi.templating import Jinja2Templates +from openpyxl import load_workbook +from sqlalchemy import create_engine, text + +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + +app = FastAPI() + +BASE_DIR = Path(__file__).resolve().parent +STATIC_DIR = BASE_DIR / "static" +TEMPLATES_DIR = BASE_DIR / "templates" +DB_PATH = BASE_DIR / "data.db" + +STATIC_DIR.mkdir(exist_ok=True) +templates = Jinja2Templates(directory=str(TEMPLATES_DIR)) +app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static") + +engine = create_engine( + f"sqlite:///{DB_PATH}", + connect_args={"check_same_thread": False}, +) + +EXCLUDED_SUPPORT_CODES = {"ZZZZZZ"} +EXCLUDED_SUPPORT_NAMES = {"공통", "경영지원부", "기술개발센터", "임원실", "기술개발부", "총괄기획실"} +SUPPORT_DEPARTMENT_NAMES = ("경영지원부", "임원실", "총괄기획실", "기술개발센터", "기술개발부", "공통") +VOUCHER_PATTERN = re.compile(r"^11-(\d{4})(\d{2})(\d{2})-[^-]+-[^-]+-[^-]+$") + +REVENUE_SQL = "(account_code LIKE '401101%' OR account_code LIKE '401102%')" +SUPPORT_COST_DEPT_SQL = ( + "cost_dept_name IN ('경영지원부', '임원실', '총괄기획실', '기술개발센터', '기술개발부', '공통')" +) +FIELD_COST_DEPT_SQL = ( + "COALESCE(cost_dept_name, '') NOT IN ('경영지원부', '임원실', '총괄기획실', '기술개발센터', '기술개발부', '공통')" +) + +FIELD_LABELS = { + "approval_status": "결재상태", + "voucher_number": "가전표번호", + "account_code": "계정코드", + "account_name": "계정명칭", + "debit_supply": "차변공급가", + "debit_vat": "차변부가세", + "credit_supply": "대변공급가", + "credit_vat": "대변부가세", + "issuing_dept_code": "발의부서코드", + "issuing_dept_name": "발의부서명", + "confirmed_voucher_number": "확정전표번호", + "support_dept_code": "지원부서코드", + "support_dept_name": "지원부서명", + "cost_dept_code": "원가부서코드", + "cost_dept_name": "원가부서명", + "memo1": "적요1", + "memo2": "적요2", + "partner_code": "거래처코드", + "partner_name": "거래처명칭", + "tax_code": "세무코드", + "posting_date": "증빙일자", + "voucher_type": "전표종류", + "management_item": "관리항목", +} + +FORM_FIELDS = list(FIELD_LABELS.keys()) + +DIRECT_HEADER_MAP = { + "결재상태": "approval_status", + "가전표번호": "voucher_number", + "계정코드": "account_code", + "계정명칭": "account_name", + "차변공급가": "debit_supply", + "차변부가세": "debit_vat", + "대변공급가": "credit_supply", + "대변부가세": "credit_vat", + "발의부서코드": "issuing_dept_code", + "발의부서명": "issuing_dept_name", + "발의부서명칭": "issuing_dept_name", + "지원부서코드": "support_dept_code", + "지원부서명": "support_dept_name", + "지원부서명칭": "support_dept_name", + "원가부서코드": "cost_dept_code", + "원가부서명": "cost_dept_name", + "원가부서명칭": "cost_dept_name", + "적요1": "memo1", + "적요2": "memo2", + "거래처코드": "partner_code", + "거래처명칭": "partner_name", + "세무코드": "tax_code", + "증빙일자": "posting_date", + "전표종류": "voucher_type", + "관리항목": "management_item", +} + + +def init_db() -> None: + with engine.begin() as conn: + conn.execute( + text( + """ + CREATE TABLE IF NOT EXISTS transactions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + approval_status TEXT, + voucher_number TEXT, + account_code TEXT, + account_name TEXT, + debit_supply REAL DEFAULT 0, + debit_vat REAL DEFAULT 0, + credit_supply REAL DEFAULT 0, + credit_vat REAL DEFAULT 0, + issuing_dept_code TEXT, + issuing_dept_name TEXT, + confirmed_voucher_number TEXT, + support_dept_code TEXT, + support_dept_name TEXT, + cost_dept_code TEXT, + cost_dept_name TEXT, + memo1 TEXT, + memo2 TEXT, + partner_code TEXT, + partner_name TEXT, + tax_code TEXT, + posting_date TEXT, + voucher_type TEXT, + management_item TEXT, + accounting_category TEXT, + amount REAL DEFAULT 0, + year INTEGER, + month INTEGER, + day INTEGER, + source_file TEXT, + last_editor_session_id TEXT DEFAULT '', + last_client_submitted_at TEXT DEFAULT '', + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) + """ + ) + ) + conn.execute( + text( + """ + CREATE INDEX IF NOT EXISTS idx_transactions_year_month + ON transactions (year, month) + """ + ) + ) + conn.execute( + text( + """ + CREATE INDEX IF NOT EXISTS idx_transactions_support + ON transactions (support_dept_code, support_dept_name) + """ + ) + ) + transaction_columns = { + row[1] + for row in conn.execute(text("PRAGMA table_info(transactions)")).fetchall() + } + required_transaction_columns = { + "last_editor_session_id": "TEXT DEFAULT ''", + "last_client_submitted_at": "TEXT DEFAULT ''", + } + for column_name, column_type in required_transaction_columns.items(): + if column_name not in transaction_columns: + conn.execute(text(f"ALTER TABLE transactions ADD COLUMN {column_name} {column_type}")) + conn.execute( + text( + """ + CREATE TABLE IF NOT EXISTS project_status ( + support_dept_code TEXT PRIMARY KEY, + support_dept_name TEXT NOT NULL, + progress_rate REAL DEFAULT 0, + contract_amount REAL DEFAULT 0, + collection_amount REAL DEFAULT 0, + collection_entries_json TEXT DEFAULT '[]', + change_round TEXT DEFAULT '', + item_investment REAL DEFAULT 0, + task_plan_department_budget REAL DEFAULT 0, + task_plan_outsource_budget REAL DEFAULT 0, + task_plan_outsource_detail TEXT DEFAULT '', + task_plan_joint_operating_cost REAL DEFAULT 0, + task_plan_entries_json TEXT DEFAULT '[]', + exec_budget_labor_by_grade REAL DEFAULT 0, + exec_budget_outsource REAL DEFAULT 0, + exec_budget_cost_plan REAL DEFAULT 0, + exec_budget_entries_json TEXT DEFAULT '[]', + actual_input_entries_json TEXT DEFAULT '[]', + project_type TEXT DEFAULT '', + expected_as_rate REAL DEFAULT 0, + expected_sga_rate REAL DEFAULT 0, + expected_as_cost REAL DEFAULT 0, + expected_sga_budget REAL DEFAULT 0, + last_editor_session_id TEXT DEFAULT '', + last_client_submitted_at TEXT DEFAULT '', + project_start_date TEXT DEFAULT '', + project_end_date TEXT DEFAULT '', + completion_status TEXT DEFAULT '', + notes TEXT DEFAULT '', + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) + """ + ) + ) + existing_columns = { + row[1] + for row in conn.execute(text("PRAGMA table_info(project_status)")).fetchall() + } + required_columns = { + "last_editor_session_id": "TEXT DEFAULT ''", + "last_client_submitted_at": "TEXT DEFAULT ''", + "contract_amount": "REAL DEFAULT 0", + "collection_entries_json": "TEXT DEFAULT '[]'", + "task_plan_department_budget": "REAL DEFAULT 0", + "task_plan_outsource_budget": "REAL DEFAULT 0", + "task_plan_outsource_detail": "TEXT DEFAULT ''", + "task_plan_joint_operating_cost": "REAL DEFAULT 0", + "task_plan_entries_json": "TEXT DEFAULT '[]'", + "exec_budget_labor_by_grade": "REAL DEFAULT 0", + "exec_budget_outsource": "REAL DEFAULT 0", + "exec_budget_cost_plan": "REAL DEFAULT 0", + "exec_budget_entries_json": "TEXT DEFAULT '[]'", + "actual_input_entries_json": "TEXT DEFAULT '[]'", + "project_type": "TEXT DEFAULT ''", + "expected_as_rate": "REAL DEFAULT 0", + "expected_sga_rate": "REAL DEFAULT 0", + "expected_as_cost": "REAL DEFAULT 0", + "expected_sga_budget": "REAL DEFAULT 0", + "project_start_date": "TEXT DEFAULT ''", + "project_end_date": "TEXT DEFAULT ''", + "completion_status": "TEXT DEFAULT ''", + } + 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}")) + + +def count_transactions() -> int: + with engine.begin() as conn: + return conn.execute(text("SELECT COUNT(*) FROM transactions")).scalar_one() + + +def existing_source_files() -> set[str]: + with engine.begin() as conn: + rows = conn.execute( + text("SELECT DISTINCT source_file FROM transactions WHERE COALESCE(source_file, '') <> ''") + ).fetchall() + return {normalize_text(row[0]) for row in rows if normalize_text(row[0])} + + +@app.on_event("startup") +def on_startup() -> None: + init_db() + auto_import_project_excels() + logger.info("DB ready at %s", DB_PATH) + + +def normalize_text(value: Any) -> str: + if value is None: + return "" + if isinstance(value, str): + return value.strip() + return str(value).strip() + + +def normalize_amount(value: Any) -> float: + if value in (None, ""): + return 0.0 + if isinstance(value, (int, float)): + return float(value) + cleaned = ( + str(value) + .strip() + .replace(",", "") + .replace("원", "") + .replace("(", "-") + .replace(")", "") + ) + if not cleaned: + return 0.0 + try: + return float(cleaned) + except ValueError: + return 0.0 + + +def normalize_date_text(value: Any) -> str: + if value in (None, ""): + return "" + if isinstance(value, datetime): + return value.date().isoformat() + if isinstance(value, date): + return value.isoformat() + text_value = normalize_text(value) + for fmt in ("%Y-%m-%d", "%Y/%m/%d", "%Y.%m.%d", "%Y%m%d"): + try: + return datetime.strptime(text_value, fmt).date().isoformat() + except ValueError: + continue + return text_value + + +def decode_json_rows(value: Any) -> list[dict[str, Any]]: + text_value = normalize_text(value) + if not text_value: + return [] + try: + rows = json.loads(text_value) + except json.JSONDecodeError: + return [] + return [row for row in rows if isinstance(row, dict)] + + +def encode_json_rows(rows: list[dict[str, Any]]) -> str: + return json.dumps(rows, ensure_ascii=False) + + +def clean_row_text(value: Any) -> str: + return normalize_text(value) + + +def filter_amount_rows(rows: list[dict[str, Any]], amount_key: str = "amount") -> list[dict[str, Any]]: + cleaned_rows: list[dict[str, Any]] = [] + for row in rows: + normalized_row = {key: clean_row_text(value) for key, value in row.items()} + amount = normalize_amount(normalized_row.get(amount_key)) + if amount or any(value for key, value in normalized_row.items() if key != amount_key): + normalized_row[amount_key] = amount + cleaned_rows.append(normalized_row) + return cleaned_rows + + +def sum_row_amounts(rows: list[dict[str, Any]], amount_key: str = "amount") -> float: + return sum(normalize_amount(row.get(amount_key)) for row in rows) + + +def get_support_department_options() -> list[dict[str, str]]: + with engine.begin() as conn: + rows = conn.execute( + text( + """ + SELECT DISTINCT support_dept_code, support_dept_name + FROM transactions + WHERE COALESCE(support_dept_code, '') <> '' + AND COALESCE(support_dept_name, '') <> '' + AND support_dept_code NOT IN ('ZZZZZZ') + AND support_dept_name NOT IN ('공통', '경영지원부', '기술개발센터', '임원실', '기술개발부', '총괄기획실') + ORDER BY support_dept_code, support_dept_name + """ + ) + ).mappings().all() + return [ + { + "support_dept_code": normalize_text(row["support_dept_code"]), + "support_dept_name": normalize_text(row["support_dept_name"]), + } + for row in rows + ] + + +def get_data_version() -> str: + with engine.begin() as conn: + transaction_updated = conn.execute(text("SELECT MAX(updated_at) FROM transactions")).scalar() + project_updated = conn.execute(text("SELECT MAX(updated_at) FROM project_status")).scalar() + versions = [normalize_text(transaction_updated), normalize_text(project_updated)] + return max((version for version in versions if version), default="") + + +def build_health_payload() -> dict[str, str]: + return { + "status": "ok", + "server_time": datetime.now().isoformat(timespec="seconds"), + "data_version": get_data_version(), + } + + +def check_record_revision(conn: Any, table_name: str, key_column: str, key_value: Any, edit_revision: str) -> None: + if not key_value or not edit_revision: + return + current_revision = conn.execute( + text(f"SELECT updated_at FROM {table_name} WHERE {key_column} = :key_value"), + {"key_value": key_value}, + ).scalar() + current_revision_text = normalize_text(current_revision) + if current_revision_text and current_revision_text != normalize_text(edit_revision): + raise ValueError("다른 사용자가 먼저 수정했습니다. 최신 화면으로 다시 확인한 뒤 저장해주세요.") + + +def date_diff_days(start_date: str, end_date: str) -> int | None: + if not start_date or not end_date: + return None + try: + start = datetime.strptime(start_date, "%Y-%m-%d").date() + end = datetime.strptime(end_date, "%Y-%m-%d").date() + return (end - start).days + except ValueError: + return None + + +def detect_category(account_code: str) -> str: + if account_code.startswith("5"): + return "원가" + if account_code.startswith("4"): + return "수입/매출액" + if account_code.startswith("6"): + return "판관비" + return "기타" + + +def extract_period(voucher_number: str, posting_date: str) -> tuple[int | None, int | None, int | None]: + voucher_match = VOUCHER_PATTERN.match(voucher_number) + if voucher_match: + year_text, month_text, day_text = voucher_match.groups() + return int(year_text), int(month_text), int(day_text) + + parsed_date = normalize_date_text(posting_date) + if re.match(r"^\d{4}-\d{2}-\d{2}$", parsed_date): + parsed = datetime.strptime(parsed_date, "%Y-%m-%d") + return parsed.year, parsed.month, parsed.day + return None, None, None + + +def choose_amount(debit_supply: float, credit_supply: float) -> float: + if debit_supply: + return abs(debit_supply) + if credit_supply: + return abs(credit_supply) + return 0.0 + + +def canonical_header_name(value: Any) -> str | None: + normalized = normalize_text(value).replace(" ", "") + if not normalized: + return None + if normalized in DIRECT_HEADER_MAP: + return DIRECT_HEADER_MAP[normalized] + if "확정전표" in normalized: + return "confirmed_voucher_number" + return None + + +def empty_record() -> dict[str, str]: + record = {field: "" for field in FORM_FIELDS} + record["id"] = "" + return record + + +def build_transaction_payload(raw: dict[str, Any], source_file: str = "") -> dict[str, Any]: + payload: dict[str, Any] = {} + for field in FORM_FIELDS: + if field in {"debit_supply", "debit_vat", "credit_supply", "credit_vat"}: + payload[field] = normalize_amount(raw.get(field)) + elif field == "posting_date": + payload[field] = normalize_date_text(raw.get(field)) + else: + payload[field] = normalize_text(raw.get(field)) + + payload["accounting_category"] = detect_category(payload["account_code"]) + payload["amount"] = choose_amount(payload["debit_supply"], payload["credit_supply"]) + year, month, day = extract_period(payload["voucher_number"], payload["posting_date"]) + payload["year"] = year + payload["month"] = month + payload["day"] = day + payload["source_file"] = source_file + return payload + + +def save_transaction(payload: dict[str, Any], record_id: int | None = None) -> None: + init_db() + params = { + **payload, + "record_id": record_id, + "last_editor_session_id": normalize_text(payload.get("client_session_id")), + "last_client_submitted_at": normalize_text(payload.get("client_submitted_at")), + } + with engine.begin() as conn: + if record_id: + check_record_revision(conn, "transactions", "id", record_id, normalize_text(payload.get("edit_revision"))) + conn.execute( + text( + """ + UPDATE transactions + SET approval_status = :approval_status, + voucher_number = :voucher_number, + account_code = :account_code, + account_name = :account_name, + debit_supply = :debit_supply, + debit_vat = :debit_vat, + credit_supply = :credit_supply, + credit_vat = :credit_vat, + issuing_dept_code = :issuing_dept_code, + issuing_dept_name = :issuing_dept_name, + confirmed_voucher_number = :confirmed_voucher_number, + support_dept_code = :support_dept_code, + support_dept_name = :support_dept_name, + cost_dept_code = :cost_dept_code, + cost_dept_name = :cost_dept_name, + memo1 = :memo1, + memo2 = :memo2, + partner_code = :partner_code, + partner_name = :partner_name, + tax_code = :tax_code, + posting_date = :posting_date, + voucher_type = :voucher_type, + management_item = :management_item, + accounting_category = :accounting_category, + amount = :amount, + year = :year, + month = :month, + day = :day, + source_file = COALESCE(NULLIF(:source_file, ''), source_file), + last_editor_session_id = :last_editor_session_id, + last_client_submitted_at = :last_client_submitted_at, + updated_at = CURRENT_TIMESTAMP + WHERE id = :record_id + """ + ), + params, + ) + return + + conn.execute( + text( + """ + INSERT INTO transactions ( + approval_status, + voucher_number, + account_code, + account_name, + debit_supply, + debit_vat, + credit_supply, + credit_vat, + issuing_dept_code, + issuing_dept_name, + confirmed_voucher_number, + support_dept_code, + support_dept_name, + cost_dept_code, + cost_dept_name, + memo1, + memo2, + partner_code, + partner_name, + tax_code, + posting_date, + voucher_type, + management_item, + accounting_category, + amount, + year, + month, + day, + source_file, + last_editor_session_id, + last_client_submitted_at + ) VALUES ( + :approval_status, + :voucher_number, + :account_code, + :account_name, + :debit_supply, + :debit_vat, + :credit_supply, + :credit_vat, + :issuing_dept_code, + :issuing_dept_name, + :confirmed_voucher_number, + :support_dept_code, + :support_dept_name, + :cost_dept_code, + :cost_dept_name, + :memo1, + :memo2, + :partner_code, + :partner_name, + :tax_code, + :posting_date, + :voucher_type, + :management_item, + :accounting_category, + :amount, + :year, + :month, + :day, + :source_file, + :last_editor_session_id, + :last_client_submitted_at + ) + """ + ), + params, + ) + + +def get_record_for_edit(record_id: int | None) -> dict[str, Any]: + if not record_id: + return empty_record() + with engine.begin() as conn: + row = conn.execute( + text("SELECT * FROM transactions WHERE id = :record_id"), + {"record_id": record_id}, + ).mappings().first() + if not row: + return empty_record() + data = dict(row) + for key, value in list(data.items()): + if value is None: + data[key] = "" + return data + + +def get_support_businesses() -> list[dict[str, Any]]: + with engine.begin() as conn: + rows = conn.execute( + text( + """ + SELECT support_dept_code, support_dept_name, COUNT(*) AS row_count + 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 ( + '공통', '경영지원부', '기술개발센터', '임원실', '기술개발부', '총괄기획실' + ) + GROUP BY support_dept_code, support_dept_name + ORDER BY support_dept_code, support_dept_name + """ + ) + ).mappings().all() + result = [dict(row) for row in rows] + for item in result: + item["project_duration_days"] = date_diff_days(item.get("project_start_date", ""), item.get("project_end_date", "")) + item["planned_total"] = ( + (item.get("task_plan_department_budget") or 0) + + (item.get("task_plan_outsource_budget") or 0) + + (item.get("task_plan_joint_operating_cost") or 0) + + (item.get("exec_budget_labor_by_grade") or 0) + + (item.get("exec_budget_outsource") or 0) + + (item.get("exec_budget_cost_plan") or 0) + + (item.get("expected_as_cost") or 0) + + (item.get("expected_sga_budget") or 0) + ) + item["actual_total_expense"] = ( + (item.get("total_cost") or 0) + + (item.get("total_sga") or 0) + ) + return result + + +def get_monthly_summary() -> list[dict[str, Any]]: + with engine.begin() as conn: + rows = conn.execute( + text( + """ + SELECT year, + month, + SUM( + CASE + WHEN accounting_category = '원가' + AND account_code NOT LIKE '5012%' + AND account_code NOT LIKE '5017%' + THEN amount + ELSE 0 + END + ) AS cost_sum, + SUM(CASE WHEN accounting_category = '판관비' THEN amount ELSE 0 END) AS sga_sum, + SUM(CASE WHEN account_code LIKE '5012%' THEN amount ELSE 0 END) AS labor_sum, + SUM(CASE WHEN account_code LIKE '5017%' THEN amount ELSE 0 END) AS outsourcing_sum + FROM transactions + WHERE year IS NOT NULL + AND month IS NOT NULL + GROUP BY year, month + ORDER BY year, month + """ + ) + ).mappings().all() + return [dict(row) for row in rows] + + +def get_yearly_summary() -> list[dict[str, Any]]: + with engine.begin() as conn: + rows = conn.execute( + text( + """ + SELECT year, + SUM( + CASE + WHEN accounting_category = '원가' + AND account_code NOT LIKE '5012%' + AND account_code NOT LIKE '5017%' + THEN amount + ELSE 0 + END + ) AS cost_sum, + SUM(CASE WHEN accounting_category = '판관비' THEN amount ELSE 0 END) AS sga_sum, + SUM(CASE WHEN account_code LIKE '5012%' THEN amount ELSE 0 END) AS labor_sum, + SUM(CASE WHEN account_code LIKE '5017%' THEN amount ELSE 0 END) AS outsourcing_sum + FROM transactions + WHERE year IS NOT NULL + GROUP BY year + ORDER BY year + """ + ) + ).mappings().all() + return [dict(row) for row in rows] + + +def get_business_monthly_summary() -> list[dict[str, Any]]: + with engine.begin() as conn: + rows = conn.execute( + text( + """ + SELECT year, + month, + support_dept_code, + support_dept_name, + SUM(CASE WHEN accounting_category = '원가' THEN amount ELSE 0 END) AS cost_sum, + SUM(CASE WHEN accounting_category = '판관비' THEN amount ELSE 0 END) AS sga_sum + FROM transactions + WHERE year IS NOT NULL + AND month IS NOT NULL + AND COALESCE(support_dept_code, '') <> '' + AND support_dept_code NOT IN ('ZZZZZZ') + AND COALESCE(support_dept_name, '') <> '' + AND support_dept_name NOT IN ( + '공통', '경영지원부', '기술개발센터', '임원실', '기술개발부', '총괄기획실' + ) + GROUP BY year, month, support_dept_code, support_dept_name + ORDER BY year, month, support_dept_code, support_dept_name + """ + ) + ).mappings().all() + return [dict(row) for row in rows] + + +def get_project_status_rows() -> list[dict[str, Any]]: + with engine.begin() as conn: + rows = conn.execute( + text( + f""" + SELECT b.support_dept_code, + b.support_dept_name, + b.row_count, + COALESCE(ps.progress_rate, 0) AS progress_rate, + COALESCE(ps.collection_amount, 0) AS collection_amount, + COALESCE(ps.change_round, '') AS change_round, + COALESCE(ps.item_investment, 0) AS item_investment, + COALESCE(ps.task_plan_department_budget, 0) AS task_plan_department_budget, + COALESCE(ps.task_plan_outsource_budget, 0) AS task_plan_outsource_budget, + COALESCE(ps.task_plan_outsource_detail, '') AS task_plan_outsource_detail, + COALESCE(ps.task_plan_joint_operating_cost, 0) AS task_plan_joint_operating_cost, + COALESCE(ps.exec_budget_labor_by_grade, 0) AS exec_budget_labor_by_grade, + COALESCE(ps.exec_budget_outsource, 0) AS exec_budget_outsource, + COALESCE(ps.exec_budget_cost_plan, 0) AS exec_budget_cost_plan, + COALESCE(ps.expected_as_cost, 0) AS expected_as_cost, + COALESCE(ps.expected_sga_budget, 0) AS expected_sga_budget, + COALESCE(ps.project_start_date, '') AS project_start_date, + COALESCE(ps.project_end_date, '') AS project_end_date, + COALESCE(ps.completion_status, '') AS completion_status, + COALESCE(ps.notes, '') AS notes, + COALESCE(agg.total_cost, 0) AS total_cost, + COALESCE(agg.total_sga, 0) AS total_sga, + COALESCE(agg.total_revenue, 0) AS total_revenue, + COALESCE(agg.actual_labor, 0) AS actual_labor, + COALESCE(agg.actual_outsource, 0) AS actual_outsource, + COALESCE(agg.latest_year, 0) AS latest_year, + COALESCE(agg.latest_month, 0) AS latest_month + FROM ( + SELECT support_dept_code, support_dept_name, COUNT(*) AS row_count + 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 ( + '공통', '경영지원부', '기술개발센터', '임원실', '기술개발부', '총괄기획실' + ) + GROUP BY support_dept_code, support_dept_name + ) AS b + LEFT JOIN project_status AS ps + ON ps.support_dept_code = b.support_dept_code + LEFT JOIN ( + SELECT support_dept_code, + SUM(CASE WHEN accounting_category = '원가' THEN amount ELSE 0 END) AS total_cost, + SUM(CASE WHEN accounting_category = '판관비' THEN amount ELSE 0 END) AS total_sga, + SUM(CASE WHEN {REVENUE_SQL} THEN amount ELSE 0 END) AS total_revenue, + SUM(CASE WHEN account_code LIKE '5012%' THEN amount ELSE 0 END) AS actual_labor, + SUM(CASE WHEN account_code LIKE '5017%' THEN amount ELSE 0 END) AS actual_outsource, + MAX(year) AS latest_year, + MAX(month) AS latest_month + FROM transactions + WHERE COALESCE(support_dept_code, '') <> '' + GROUP BY support_dept_code + ) AS agg + ON agg.support_dept_code = b.support_dept_code + ORDER BY b.support_dept_code, b.support_dept_name + """ + ) + ).mappings().all() + return [dict(row) for row in rows] + + +def get_project_status_for_edit(support_dept_code: str | None) -> dict[str, Any]: + if not support_dept_code: + return { + "support_dept_code": "", + "support_dept_name": "", + "progress_rate": "", + "contract_amount": "", + "collection_amount": "", + "collection_entries": [], + "change_round": "", + "item_investment": "", + "task_plan_department_budget": "", + "task_plan_outsource_budget": "", + "task_plan_outsource_detail": "", + "task_plan_joint_operating_cost": "", + "task_plan_entries": [], + "exec_budget_labor_by_grade": "", + "exec_budget_outsource": "", + "exec_budget_cost_plan": "", + "exec_budget_entries": [], + "actual_input_entries": [], + "project_type": "", + "expected_as_rate": "", + "expected_sga_rate": "", + "expected_as_cost": "", + "expected_sga_budget": "", + "project_start_date": "", + "project_end_date": "", + "completion_status": "", + "notes": "", + "updated_at": "", + } + + with engine.begin() as conn: + row = conn.execute( + text( + """ + SELECT b.support_dept_code, + b.support_dept_name, + COALESCE(ps.progress_rate, '') AS progress_rate, + COALESCE(ps.contract_amount, '') AS contract_amount, + COALESCE(ps.collection_amount, '') AS collection_amount, + COALESCE(ps.collection_entries_json, '[]') AS collection_entries_json, + COALESCE(ps.change_round, '') AS change_round, + COALESCE(ps.item_investment, '') AS item_investment, + COALESCE(ps.task_plan_department_budget, '') AS task_plan_department_budget, + COALESCE(ps.task_plan_outsource_budget, '') AS task_plan_outsource_budget, + COALESCE(ps.task_plan_outsource_detail, '') AS task_plan_outsource_detail, + COALESCE(ps.task_plan_joint_operating_cost, '') AS task_plan_joint_operating_cost, + COALESCE(ps.task_plan_entries_json, '[]') AS task_plan_entries_json, + COALESCE(ps.exec_budget_labor_by_grade, '') AS exec_budget_labor_by_grade, + COALESCE(ps.exec_budget_outsource, '') AS exec_budget_outsource, + COALESCE(ps.exec_budget_cost_plan, '') AS exec_budget_cost_plan, + COALESCE(ps.exec_budget_entries_json, '[]') AS exec_budget_entries_json, + COALESCE(ps.actual_input_entries_json, '[]') AS actual_input_entries_json, + COALESCE(ps.project_type, '') AS project_type, + COALESCE(ps.expected_as_rate, '') AS expected_as_rate, + COALESCE(ps.expected_sga_rate, '') AS expected_sga_rate, + COALESCE(ps.expected_as_cost, '') AS expected_as_cost, + COALESCE(ps.expected_sga_budget, '') AS expected_sga_budget, + COALESCE(ps.last_editor_session_id, '') AS last_editor_session_id, + COALESCE(ps.last_client_submitted_at, '') AS last_client_submitted_at, + COALESCE(ps.project_start_date, '') AS project_start_date, + COALESCE(ps.project_end_date, '') AS project_end_date, + COALESCE(ps.completion_status, '') AS completion_status, + COALESCE(ps.notes, '') AS notes, + COALESCE(ps.updated_at, '') AS updated_at + FROM ( + SELECT DISTINCT support_dept_code, support_dept_name + FROM transactions + WHERE support_dept_code = :support_dept_code + ) AS b + LEFT JOIN project_status AS ps + ON ps.support_dept_code = b.support_dept_code + """ + ), + {"support_dept_code": support_dept_code}, + ).mappings().first() + if not row: + return { + "support_dept_code": support_dept_code, + "support_dept_name": "", + "progress_rate": "", + "contract_amount": "", + "collection_amount": "", + "collection_entries": [], + "change_round": "", + "item_investment": "", + "task_plan_department_budget": "", + "task_plan_outsource_budget": "", + "task_plan_outsource_detail": "", + "task_plan_joint_operating_cost": "", + "task_plan_entries": [], + "exec_budget_labor_by_grade": "", + "exec_budget_outsource": "", + "exec_budget_cost_plan": "", + "exec_budget_entries": [], + "actual_input_entries": [], + "project_type": "", + "expected_as_rate": "", + "expected_sga_rate": "", + "expected_as_cost": "", + "expected_sga_budget": "", + "project_start_date": "", + "project_end_date": "", + "completion_status": "", + "notes": "", + "updated_at": "", + } + result = dict(row) + result["collection_entries"] = decode_json_rows(result.pop("collection_entries_json", "[]")) + result["task_plan_entries"] = decode_json_rows(result.pop("task_plan_entries_json", "[]")) + result["exec_budget_entries"] = decode_json_rows(result.pop("exec_budget_entries_json", "[]")) + result["actual_input_entries"] = decode_json_rows(result.pop("actual_input_entries_json", "[]")) + if not result["collection_entries"] and normalize_amount(result.get("collection_amount")): + result["collection_entries"] = [ + { + "vendor": "", + "round": "", + "amount": result.get("collection_amount", ""), + "date": "", + "due_date": "", + "note": "기존 수기 입력값", + } + ] + if not result["task_plan_entries"]: + fallback_task_rows = [] + if normalize_amount(result.get("task_plan_department_budget")): + fallback_task_rows.append({"group": "department", "label": "기존 부서별 배분액", "amount": result.get("task_plan_department_budget", "")}) + if normalize_amount(result.get("task_plan_outsource_budget")): + fallback_task_rows.append({"group": "outsource", "label": "기존 외주비", "amount": result.get("task_plan_outsource_budget", ""), "note": result.get("task_plan_outsource_detail", "")}) + if normalize_amount(result.get("task_plan_joint_operating_cost")): + fallback_task_rows.append({"group": "joint", "label": "기존 합사운영비", "amount": result.get("task_plan_joint_operating_cost", "")}) + result["task_plan_entries"] = fallback_task_rows + if not result["exec_budget_entries"]: + fallback_exec_rows = [] + if normalize_amount(result.get("exec_budget_labor_by_grade")): + fallback_exec_rows.append({"group": "labor", "label": "기존 직급별 인건비", "amount": result.get("exec_budget_labor_by_grade", "")}) + if normalize_amount(result.get("exec_budget_outsource")): + fallback_exec_rows.append({"group": "outsource", "label": "기존 외주비", "amount": result.get("exec_budget_outsource", "")}) + if normalize_amount(result.get("exec_budget_cost_plan")): + fallback_exec_rows.append({"group": "cost_plan", "label": "기존 비용계획", "amount": result.get("exec_budget_cost_plan", "")}) + result["exec_budget_entries"] = fallback_exec_rows + if not result["actual_input_entries"] and normalize_amount(result.get("item_investment")): + result["actual_input_entries"] = [ + { + "reference": "", + "amount": result.get("item_investment", ""), + "note": "기존 항목별투입액", + } + ] + return result + + +def get_project_year_options() -> list[int]: + return get_available_years() + + +def resolve_selected_year(selected_year: int | None) -> int | None: + return selected_year + + +def parse_optional_year(value: Any) -> int | None: + text = normalize_text(value) + if not text: + return None + return int(text) if text.isdigit() else None + + +def get_recent_10_start_year() -> int | None: + available_years = get_available_years() + if not available_years: + return None + return max(available_years) - 9 + + +def get_project_dashboard_summary(selected_year: int | None) -> dict[str, Any]: + selected_year = resolve_selected_year(selected_year) + project_year_clause = "" + collection_year_clause = "" + params: dict[str, Any] = {} + if selected_year: + project_year_clause = "AND p.year = :selected_year" + collection_year_clause = "AND year = :selected_year" + params["selected_year"] = selected_year + else: + recent_10_start_year = get_recent_10_start_year() + if recent_10_start_year is not None: + project_year_clause = "AND p.year >= :recent_10_start_year" + collection_year_clause = "AND year >= :recent_10_start_year" + params["recent_10_start_year"] = recent_10_start_year + + with engine.begin() as conn: + project_row = conn.execute( + text( + f""" + SELECT COUNT(*) AS related_projects, + SUM(COALESCE(p.expense_amount, 0)) AS expense_amount, + COUNT(CASE WHEN COALESCE(ps.completion_status, '') IN ('종료', '완료', 'Y', 'YES') THEN 1 END) AS completed_projects + FROM ( + SELECT year, + support_dept_code, + support_dept_name, + SUM(CASE WHEN account_code LIKE '5%' OR account_code LIKE '6%' THEN amount ELSE 0 END) AS expense_amount + 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 ('공통', '경영지원부', '기술개발센터', '임원실', '기술개발부', '총괄기획실') + GROUP BY year, support_dept_code, support_dept_name + ) AS p + LEFT JOIN project_status AS ps + ON ps.support_dept_code = p.support_dept_code + WHERE 1=1 + {project_year_clause} + """ + ), + params, + ).mappings().first() + + collection_row = conn.execute( + text( + f""" + SELECT COUNT(*) AS collection_transaction_count, + COUNT(DISTINCT support_dept_code) AS collection_project_count, + SUM(COALESCE(credit_supply, 0)) AS collection_amount + FROM transactions + WHERE COALESCE(credit_supply, 0) <> 0 + AND ({REVENUE_SQL}) + AND COALESCE(support_dept_code, '') <> '' + AND support_dept_code NOT IN ('ZZZZZZ') + AND COALESCE(support_dept_name, '') <> '' + {collection_year_clause} + """ + ), + params, + ).mappings().first() + + input_row = conn.execute( + text( + """ + SELECT COUNT(CASE WHEN COALESCE(collection_amount, 0) <> 0 THEN 1 END) AS collection_input_projects, + COUNT(CASE WHEN COALESCE(completion_status, '') <> '' THEN 1 END) AS completion_input_projects + FROM project_status + """ + ) + ).mappings().first() + + return { + **(dict(project_row) if project_row else {}), + **(dict(collection_row) if collection_row else {}), + **(dict(input_row) if input_row else {}), + } + + +def get_project_revenue_mix(selected_year: int | None = None) -> list[dict[str, Any]]: + params: dict[str, Any] = {} + if selected_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 year = :selected_year + AND month IS NOT NULL + GROUP BY year, month + ORDER BY year, month + """ + ), + {"selected_year": selected_year}, + ).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 + + with engine.begin() as conn: + rows = conn.execute( + text( + f""" + SELECT + year, + 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 year IS NOT NULL + GROUP BY year + ORDER BY year + """ + ) + ).mappings().all() + result = [dict(row) for row in rows] + for item in result: + item["label"] = str(item.get("year", "")) + return result[-10:] + + +def get_project_cost_by_year(selected_year: int | None) -> list[dict[str, Any]]: + year_clause = "" + params: dict[str, Any] = {} + if selected_year: + year_clause = "AND year = :selected_year" + params["selected_year"] = selected_year + else: + recent_10_start_year = get_recent_10_start_year() + 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, + support_dept_code, + support_dept_name, + SUM(CASE WHEN account_code LIKE '5%' OR account_code LIKE '6%' THEN amount ELSE 0 END) AS expense_amount, + SUM(CASE WHEN {REVENUE_SQL} THEN amount ELSE 0 END) AS revenue_amount + 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 year, support_dept_code, support_dept_name + ORDER BY year DESC, expense_amount DESC, support_dept_code + """ + ), + params, + ).mappings().all() + return [dict(row) for row in rows] + + +def get_project_account_breakdowns(selected_year: int | None) -> dict[str, dict[str, list[dict[str, Any]]]]: + year_clause = "" + params: dict[str, Any] = {} + if selected_year: + year_clause = "AND year = :selected_year" + params["selected_year"] = selected_year + else: + recent_10_start_year = get_recent_10_start_year() + 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 support_dept_code, + CASE + WHEN {REVENUE_SQL} THEN 'revenue' + WHEN accounting_category = '원가' THEN 'cost' + WHEN accounting_category = '판관비' THEN 'sga' + ELSE 'other' + END AS breakdown_kind, + COALESCE(account_name, account_code, '미분류') AS account_label, + SUM(COALESCE(amount, 0)) AS total_amount + 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 ('공통', '경영지원부', '기술개발센터', '임원실', '기술개발부', '총괄기획실') + AND ({REVENUE_SQL} OR accounting_category IN ('원가', '판관비')) + {year_clause} + GROUP BY support_dept_code, breakdown_kind, account_label + ORDER BY support_dept_code, breakdown_kind, total_amount DESC, account_label + """ + ), + params, + ).mappings().all() + + result: dict[str, dict[str, list[dict[str, Any]]]] = {} + for row in rows: + code = row["support_dept_code"] + kind = row["breakdown_kind"] + if kind == "other": + continue + result.setdefault(code, {"revenue": [], "cost": [], "sga": []}) + result[code][kind].append( + { + "label": row["account_label"], + "amount": row["total_amount"] or 0, + } + ) + return result + + +def get_recent_transactions(limit: int = 50) -> list[dict[str, Any]]: + with engine.begin() as conn: + rows = conn.execute( + text( + """ + SELECT id, + year, + month, + voucher_number, + account_code, + account_name, + support_dept_code, + support_dept_name, + cost_dept_code, + cost_dept_name, + accounting_category, + amount, + memo1, + management_item, + source_file, + updated_at + FROM transactions + ORDER BY COALESCE(year, 0) DESC, + COALESCE(month, 0) DESC, + id DESC + LIMIT :limit_count + """ + ), + {"limit_count": limit}, + ).mappings().all() + return [dict(row) for row in rows] + + +def get_overview_stats(selected_year: int | None = None) -> dict[str, Any]: + year_clause = "" + params: dict[str, Any] = {} + if selected_year: + year_clause = "WHERE year = :selected_year" + params["selected_year"] = selected_year + else: + recent_10_start_year = get_recent_10_start_year() + if recent_10_start_year is not None: + year_clause = "WHERE year >= :recent_10_start_year" + params["recent_10_start_year"] = recent_10_start_year + + with engine.begin() as conn: + row = conn.execute( + text( + f""" + SELECT COUNT(*) AS total_rows, + COUNT(DISTINCT source_file) AS source_files, + COUNT(DISTINCT CASE + WHEN COALESCE(support_dept_code, '') <> '' + AND support_dept_code NOT IN ('ZZZZZZ') + AND COALESCE(support_dept_name, '') <> '' + AND support_dept_name NOT IN ( + '공통', '경영지원부', '기술개발센터', '임원실', '기술개발부', '총괄기획실' + ) + THEN support_dept_code || '|' || support_dept_name + END) AS business_count, + SUM(CASE WHEN accounting_category = '원가' THEN amount ELSE 0 END) AS total_cost, + SUM(CASE WHEN accounting_category = '판관비' THEN amount ELSE 0 END) AS total_sga, + SUM(CASE WHEN {REVENUE_SQL} THEN amount ELSE 0 END) AS total_revenue + FROM transactions + {year_clause} + """ + ), + params, + ).mappings().first() + return dict(row) if row else {} + + +def get_available_years() -> list[int]: + 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[0] is not None] + + +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" + month_where = "" if granularity == "yearly" else "AND month IS NOT NULL" + project_cost_sql = ( + "accounting_category = '원가' " + "AND account_code NOT LIKE '5012%' " + "AND account_code NOT LIKE '5017%' " + "AND support_dept_code <> 'ZZZZZZ' " + f"AND {FIELD_COST_DEPT_SQL}" + ) + support_cost_sql = ( + "accounting_category = '원가' " + "AND account_code NOT LIKE '5012%' " + "AND account_code NOT LIKE '5017%' " + "AND support_dept_code = 'ZZZZZZ' " + f"AND {FIELD_COST_DEPT_SQL}" + ) + support_sga_sql = f"accounting_category = '판관비' AND {SUPPORT_COST_DEPT_SQL}" + field_sga_sql = f"accounting_category = '판관비' AND {FIELD_COST_DEPT_SQL}" + + with engine.begin() as conn: + rows = conn.execute( + text( + f""" + SELECT {group_fields}, + SUM(CASE WHEN {REVENUE_SQL} THEN amount ELSE 0 END) AS revenue_sum, + SUM(CASE WHEN {project_cost_sql} THEN amount ELSE 0 END) AS project_cost_sum, + SUM(CASE WHEN {support_cost_sql} THEN amount ELSE 0 END) AS support_cost_sum, + SUM(CASE WHEN {support_sga_sql} THEN amount ELSE 0 END) AS support_sga_sum, + SUM(CASE WHEN {field_sga_sql} THEN amount ELSE 0 END) AS field_sga_sum, + SUM(CASE WHEN account_code LIKE '5012%' THEN amount ELSE 0 END) AS labor_sum, + SUM(CASE WHEN account_code LIKE '5017%' THEN amount ELSE 0 END) AS outsourcing_sum + FROM transactions + WHERE year IS NOT NULL + {month_where} + GROUP BY {group_fields} + ORDER BY {order_fields} + """ + ) + ).mappings().all() + + result: list[dict[str, Any]] = [] + for row in rows: + item = dict(row) + item["total_expense"] = ( + (item.get("project_cost_sum") or 0) + + (item.get("support_cost_sum") or 0) + + (item.get("support_sga_sum") or 0) + + (item.get("field_sga_sum") or 0) + + (item.get("labor_sum") or 0) + + (item.get("outsourcing_sum") or 0) + ) + item["operating_balance"] = (item.get("revenue_sum") or 0) - item["total_expense"] + item["label"] = str(item["year"]) if granularity == "yearly" else f"{item['year']}-{int(item['month']):02d}" + result.append(item) + return result + + +def get_source_files_summary() -> list[dict[str, Any]]: + with engine.begin() as conn: + rows = conn.execute( + text( + """ + SELECT source_file, COUNT(*) AS row_count + FROM transactions + WHERE COALESCE(source_file, '') <> '' + GROUP BY source_file + ORDER BY row_count DESC, source_file + """ + ) + ).mappings().all() + return [dict(row) for row in rows] + + +def parse_excel_upload(upload_file: UploadFile) -> int: + init_db() + workbook = load_workbook(upload_file.file, data_only=True) + sheet = workbook.active + + headers = [canonical_header_name(cell.value) for cell in next(sheet.iter_rows(min_row=1, max_row=1))] + inserted = 0 + + for row in sheet.iter_rows(min_row=2, values_only=True): + raw: dict[str, Any] = {} + has_value = False + for index, value in enumerate(row): + field_name = headers[index] if index < len(headers) else None + if field_name: + raw[field_name] = value + if normalize_text(value): + has_value = True + + if not has_value: + continue + + payload = build_transaction_payload(raw, source_file=upload_file.filename or "") + if not payload["voucher_number"] and not payload["account_code"] and not payload["account_name"]: + continue + save_transaction(payload) + inserted += 1 + + return inserted + + +def import_excel_path(path: Path) -> int: + with path.open("rb") as excel_file: + upload = UploadFile(filename=path.name, file=excel_file) + return parse_excel_upload(upload) + + +def auto_import_project_excels() -> None: + init_db() + excel_files = sorted(BASE_DIR.glob("*.xlsx")) + if not excel_files: + return + + known_files = existing_source_files() + if count_transactions() > 0 and all(file.name in known_files for file in excel_files): + return + + for excel_path in excel_files: + if excel_path.name in known_files: + continue + inserted = import_excel_path(excel_path) + logger.info("Auto-imported %s rows from %s", inserted, excel_path.name) + + +def parse_manual_form(raw_body: bytes) -> dict[str, Any]: + parsed = parse_qs(raw_body.decode("utf-8")) + payload = {key: values[0] if values else "" for key, values in parsed.items()} + return payload + + +def parse_project_form(raw_body: bytes) -> dict[str, Any]: + parsed = parse_qs(raw_body.decode("utf-8")) + payload: dict[str, Any] = {} + for key, values in parsed.items(): + payload[key] = values if key.endswith("[]") else (values[0] if values else "") + return payload + + +def build_named_amount_rows( + labels: list[Any], + amounts: list[Any], + *, + label_key: str = "label", + amount_key: str = "amount", +) -> list[dict[str, Any]]: + rows = [] + for index, label in enumerate(labels): + rows.append( + { + label_key: label, + amount_key: amounts[index] if index < len(amounts) else "", + } + ) + return filter_amount_rows(rows, amount_key=amount_key) + + +def build_collection_rows(payload: dict[str, Any]) -> list[dict[str, Any]]: + progress_types = payload.get("collection_progress_type[]", []) + billing_rounds = payload.get("collection_billing_round[]", []) + billing_types = payload.get("collection_billing_type[]", []) + billing_dates = payload.get("collection_billing_date[]", []) + billed_amounts = payload.get("collection_billed_amount[]", []) + collection_rounds = payload.get("collection_round[]", []) + collection_dates = payload.get("collection_date[]", []) + collection_amounts = payload.get("collection_amount_row[]", []) + rows = [] + total_rows = max( + len(progress_types), + len(billing_rounds), + len(billing_types), + len(billing_dates), + len(billed_amounts), + len(collection_rounds), + len(collection_dates), + len(collection_amounts), + ) + for index in range(total_rows): + rows.append( + { + "progress_type": progress_types[index] if index < len(progress_types) else "", + "billing_round": billing_rounds[index] if index < len(billing_rounds) else "", + "billing_type": billing_types[index] if index < len(billing_types) else "", + "billing_date": billing_dates[index] if index < len(billing_dates) else "", + "billed_amount": billed_amounts[index] if index < len(billed_amounts) else "", + "round": collection_rounds[index] if index < len(collection_rounds) else "", + "date": collection_dates[index] if index < len(collection_dates) else "", + "amount": collection_amounts[index] if index < len(collection_amounts) else "", + } + ) + filtered_rows: list[dict[str, Any]] = [] + for row in rows: + normalized_row = {key: clean_row_text(value) for key, value in row.items()} + amount = normalize_amount(normalized_row.get("amount")) + billed_amount = normalize_amount(normalized_row.get("billed_amount")) + has_other_value = any( + value for key, value in normalized_row.items() + if key not in {"amount", "billed_amount"} + ) + if amount or billed_amount or has_other_value: + normalized_row["amount"] = amount + normalized_row["billed_amount"] = billed_amount + filtered_rows.append(normalized_row) + for row in filtered_rows: + row["billing_date"] = normalize_date_text(row.get("billing_date")) + row["date"] = normalize_date_text(row.get("date")) + row["billed_amount"] = normalize_amount(row.get("billed_amount")) + return filtered_rows + + +def build_project_status_payload(payload: dict[str, Any]) -> dict[str, Any]: + contract_amount = normalize_amount(payload.get("contract_amount")) + collection_rows = build_collection_rows(payload) + collection_amount = sum_row_amounts(collection_rows) + progress_rate = (collection_amount / contract_amount * 100) if contract_amount else 0.0 + + task_plan_department_rows = build_named_amount_rows( + payload.get("task_plan_department_label[]", []), + payload.get("task_plan_department_amount[]", []), + ) + task_plan_outsource_rows = [] + outsource_names = payload.get("task_plan_outsource_vendor[]", []) + outsource_amounts = payload.get("task_plan_outsource_amount[]", []) + outsource_notes = payload.get("task_plan_outsource_note[]", []) + for index, vendor in enumerate(outsource_names): + task_plan_outsource_rows.append( + { + "label": vendor, + "amount": outsource_amounts[index] if index < len(outsource_amounts) else "", + "note": outsource_notes[index] if index < len(outsource_notes) else "", + "group": "outsource", + } + ) + task_plan_outsource_rows = filter_amount_rows(task_plan_outsource_rows, amount_key="amount") + task_plan_joint_rows = build_named_amount_rows( + payload.get("task_plan_joint_label[]", []), + payload.get("task_plan_joint_amount[]", []), + ) + for row in task_plan_department_rows: + row["group"] = "department" + for row in task_plan_joint_rows: + row["group"] = "joint" + task_plan_rows = task_plan_department_rows + task_plan_outsource_rows + task_plan_joint_rows + + exec_labor_rows = build_named_amount_rows( + payload.get("exec_labor_role[]", []), + payload.get("exec_labor_amount[]", []), + ) + for row in exec_labor_rows: + row["group"] = "labor" + exec_outsource_rows = [] + exec_outsource_names = payload.get("exec_outsource_vendor[]", []) + exec_outsource_amounts = payload.get("exec_outsource_amount[]", []) + exec_outsource_notes = payload.get("exec_outsource_note[]", []) + for index, vendor in enumerate(exec_outsource_names): + exec_outsource_rows.append( + { + "label": vendor, + "amount": exec_outsource_amounts[index] if index < len(exec_outsource_amounts) else "", + "note": exec_outsource_notes[index] if index < len(exec_outsource_notes) else "", + "group": "outsource", + } + ) + exec_outsource_rows = filter_amount_rows(exec_outsource_rows, amount_key="amount") + exec_cost_plan_rows = build_named_amount_rows( + payload.get("exec_cost_plan_label[]", []), + payload.get("exec_cost_plan_amount[]", []), + ) + for row in exec_cost_plan_rows: + 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( + { + "reference": ref, + "amount": actual_amounts[index] if index < len(actual_amounts) else "", + "note": actual_notes[index] if index < len(actual_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")) + expected_as_cost = contract_amount * expected_as_rate / 100 if contract_amount else 0.0 + expected_sga_budget = contract_amount * expected_sga_rate / 100 if contract_amount else 0.0 + + return { + "support_dept_code": normalize_text(payload.get("support_dept_code")), + "support_dept_name": normalize_text(payload.get("support_dept_name")), + "progress_rate": progress_rate, + "contract_amount": contract_amount, + "collection_amount": collection_amount, + "collection_entries_json": encode_json_rows(collection_rows), + "change_round": normalize_text(payload.get("change_round")), + "item_investment": sum_row_amounts(actual_input_rows), + "task_plan_department_budget": sum_row_amounts(task_plan_department_rows), + "task_plan_outsource_budget": sum_row_amounts(task_plan_outsource_rows), + "task_plan_outsource_detail": "\n".join( + f"{row.get('label', '')}: {format_amount_for_text(row.get('amount'))}{' / ' + row.get('note', '') if row.get('note') else ''}" + for row in task_plan_outsource_rows + ), + "task_plan_joint_operating_cost": sum_row_amounts(task_plan_joint_rows), + "task_plan_entries_json": encode_json_rows(task_plan_rows), + "exec_budget_labor_by_grade": sum_row_amounts(exec_labor_rows), + "exec_budget_outsource": sum_row_amounts(exec_outsource_rows), + "exec_budget_cost_plan": sum_row_amounts(exec_cost_plan_rows), + "exec_budget_entries_json": encode_json_rows(exec_budget_rows), + "actual_input_entries_json": encode_json_rows(actual_input_rows), + "project_type": normalize_text(payload.get("project_type")), + "expected_as_rate": expected_as_rate, + "expected_sga_rate": expected_sga_rate, + "expected_as_cost": expected_as_cost, + "expected_sga_budget": expected_sga_budget, + "last_editor_session_id": normalize_text(payload.get("client_session_id")), + "last_client_submitted_at": normalize_text(payload.get("client_submitted_at")), + "project_start_date": normalize_date_text(payload.get("project_start_date")), + "project_end_date": normalize_date_text(payload.get("project_end_date")), + "completion_status": normalize_text(payload.get("completion_status")), + "notes": normalize_text(payload.get("notes")), + } + + +def project_status_payload_has_meaningful_data(payload: dict[str, Any]) -> bool: + if normalize_amount(payload.get("contract_amount")): + return True + if normalize_amount(payload.get("collection_amount")): + return True + if normalize_amount(payload.get("task_plan_department_budget")): + return True + if normalize_amount(payload.get("task_plan_outsource_budget")): + return True + if normalize_amount(payload.get("task_plan_joint_operating_cost")): + return True + if normalize_amount(payload.get("exec_budget_labor_by_grade")): + return True + if normalize_amount(payload.get("exec_budget_outsource")): + return True + if normalize_amount(payload.get("exec_budget_cost_plan")): + return True + if normalize_amount(payload.get("item_investment")): + return True + for key in ( + "project_type", + "project_start_date", + "project_end_date", + "completion_status", + "notes", + "change_round", + "support_dept_name", + "task_plan_outsource_detail", + ): + if normalize_text(payload.get(key)): + return True + for key in ( + "collection_entries_json", + "task_plan_entries_json", + "exec_budget_entries_json", + "actual_input_entries_json", + ): + if decode_json_rows(payload.get(key)): + return True + return False + + +def format_amount_for_text(value: Any) -> str: + amount = normalize_amount(value) + return f"{amount:,.0f}" + + +def save_project_status(payload: dict[str, Any]) -> None: + normalized_payload = build_project_status_payload(payload) + support_dept_code = normalize_text(normalized_payload.get("support_dept_code")) + if not support_dept_code: + return + + with engine.begin() as conn: + existing_row = conn.execute( + text("SELECT * FROM project_status WHERE support_dept_code = :support_dept_code"), + {"support_dept_code": support_dept_code}, + ).mappings().first() + if existing_row: + existing_payload = dict(existing_row) + if ( + project_status_payload_has_meaningful_data(existing_payload) + and not project_status_payload_has_meaningful_data(normalized_payload) + ): + raise ValueError("기존 입력값을 불러오지 않은 빈 상태로는 저장할 수 없습니다.") + check_record_revision( + conn, + "project_status", + "support_dept_code", + support_dept_code, + normalize_text(payload.get("edit_revision")), + ) + conn.execute( + text( + """ + INSERT INTO project_status ( + support_dept_code, + support_dept_name, + progress_rate, + contract_amount, + collection_amount, + collection_entries_json, + change_round, + item_investment, + task_plan_department_budget, + task_plan_outsource_budget, + task_plan_outsource_detail, + task_plan_joint_operating_cost, + task_plan_entries_json, + exec_budget_labor_by_grade, + exec_budget_outsource, + exec_budget_cost_plan, + exec_budget_entries_json, + actual_input_entries_json, + project_type, + expected_as_rate, + expected_sga_rate, + expected_as_cost, + expected_sga_budget, + last_editor_session_id, + last_client_submitted_at, + project_start_date, + project_end_date, + completion_status, + notes, + updated_at + ) VALUES ( + :support_dept_code, + :support_dept_name, + :progress_rate, + :contract_amount, + :collection_amount, + :collection_entries_json, + :change_round, + :item_investment, + :task_plan_department_budget, + :task_plan_outsource_budget, + :task_plan_outsource_detail, + :task_plan_joint_operating_cost, + :task_plan_entries_json, + :exec_budget_labor_by_grade, + :exec_budget_outsource, + :exec_budget_cost_plan, + :exec_budget_entries_json, + :actual_input_entries_json, + :project_type, + :expected_as_rate, + :expected_sga_rate, + :expected_as_cost, + :expected_sga_budget, + :last_editor_session_id, + :last_client_submitted_at, + :project_start_date, + :project_end_date, + :completion_status, + :notes, + CURRENT_TIMESTAMP + ) + ON CONFLICT(support_dept_code) DO UPDATE SET + support_dept_name = excluded.support_dept_name, + progress_rate = excluded.progress_rate, + contract_amount = excluded.contract_amount, + collection_amount = excluded.collection_amount, + collection_entries_json = excluded.collection_entries_json, + change_round = excluded.change_round, + item_investment = excluded.item_investment, + task_plan_department_budget = excluded.task_plan_department_budget, + task_plan_outsource_budget = excluded.task_plan_outsource_budget, + task_plan_outsource_detail = excluded.task_plan_outsource_detail, + task_plan_joint_operating_cost = excluded.task_plan_joint_operating_cost, + task_plan_entries_json = excluded.task_plan_entries_json, + exec_budget_labor_by_grade = excluded.exec_budget_labor_by_grade, + exec_budget_outsource = excluded.exec_budget_outsource, + exec_budget_cost_plan = excluded.exec_budget_cost_plan, + exec_budget_entries_json = excluded.exec_budget_entries_json, + actual_input_entries_json = excluded.actual_input_entries_json, + project_type = excluded.project_type, + expected_as_rate = excluded.expected_as_rate, + expected_sga_rate = excluded.expected_sga_rate, + expected_as_cost = excluded.expected_as_cost, + expected_sga_budget = excluded.expected_sga_budget, + last_editor_session_id = excluded.last_editor_session_id, + last_client_submitted_at = excluded.last_client_submitted_at, + project_start_date = excluded.project_start_date, + project_end_date = excluded.project_end_date, + completion_status = excluded.completion_status, + notes = excluded.notes, + updated_at = CURRENT_TIMESTAMP + """ + ), + normalized_payload, + ) + + +def base_context(request: Request, message: str = "") -> dict[str, Any]: + health_payload = build_health_payload() + return { + "request": request, + "message": message, + "data_version": health_payload["data_version"], + "server_time": health_payload["server_time"], + } + + +def render_home( + request: Request, + edit_id: int | None = None, + message: str = "", + overview_year: int | None = None, +) -> HTMLResponse: + init_db() + available_years = get_available_years() + context = { + **base_context(request, message), + "overview": get_overview_stats(overview_year), + "overview_selected_year": overview_year, + "yearly_summary": get_yearly_summary(), + "monthly_summary": get_monthly_summary(), + "available_years": available_years, + } + return templates.TemplateResponse(request, "dashboard.html", context) + + +def render_projects_page( + request: Request, + edit_code: str | None = None, + selected_year: int | None = None, + message: str = "", +) -> HTMLResponse: + init_db() + selected_year = resolve_selected_year(selected_year) + context = { + **base_context(request, message), + "project_year_options": get_project_year_options(), + "selected_year": selected_year, + "project_dashboard": get_project_dashboard_summary(selected_year), + "project_revenue_mix": get_project_revenue_mix(selected_year), + "project_cost_by_year": get_project_cost_by_year(selected_year), + "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), + "support_department_options": get_support_department_options(), + } + return templates.TemplateResponse(request, "projects.html", context) + + +def render_annual_summary_page(request: Request, message: str = "") -> HTMLResponse: + init_db() + context = { + **base_context(request, message), + "available_years": get_available_years(), + "yearly_financial_series": get_financial_series("yearly"), + "monthly_financial_series": get_financial_series("monthly"), + } + return templates.TemplateResponse(request, "annual_summary.html", context) + + +@app.get("/health") +async def health() -> dict[str, str]: + return build_health_payload() + + +@app.get("/") +async def home(request: Request, edit_id: int | None = None, overview_year: int | None = None): + try: + return render_home(request, edit_id=edit_id, overview_year=overview_year) + except Exception as exc: + logger.exception("홈페이지 에러: %s", exc) + return HTMLResponse("

서버 오류

로그를 확인해주세요.

", status_code=500) + + +@app.get("/projects") +async def projects(request: Request, edit_code: str | None = None, year: str | None = None): + try: + return render_projects_page(request, edit_code=edit_code, selected_year=parse_optional_year(year)) + except Exception as exc: + logger.exception("사업현황 페이지 에러: %s", exc) + return HTMLResponse("

서버 오류

로그를 확인해주세요.

", status_code=500) + + +@app.get("/annual-summary") +async def annual_summary(request: Request): + try: + return render_annual_summary_page(request) + except Exception as exc: + logger.exception("연도별 수익 비용 정리 페이지 에러: %s", exc) + return HTMLResponse("

서버 오류

로그를 확인해주세요.

", status_code=500) + + +@app.post("/upload") +async def upload_excel(request: Request, excel_file: UploadFile = File(...)): + try: + inserted = parse_excel_upload(excel_file) + return render_home(request, message=f"{inserted}건의 엑셀 데이터를 DB에 저장했습니다.") + except Exception as exc: + logger.exception("엑셀 업로드 에러: %s", exc) + return render_home(request, message=f"엑셀 업로드 중 오류가 발생했습니다: {exc}") + + +@app.post("/records/save") +async def save_record(request: Request): + form_data: dict[str, Any] = {} + try: + form_data = parse_manual_form(await request.body()) + record_id = normalize_text(form_data.get("id")) + payload = build_transaction_payload(form_data) + save_transaction(payload, int(record_id) if record_id else None) + return RedirectResponse("/", status_code=303) + except Exception as exc: + logger.exception("데이터 저장 에러: %s", exc) + record_id = normalize_text(form_data.get("id")) + return render_home( + request, + edit_id=int(record_id) if record_id else None, + message=f"데이터 저장 중 오류가 발생했습니다: {exc}", + ) + + +@app.post("/projects/save") +async def save_project(request: Request): + form_data: dict[str, Any] = {} + try: + form_data = parse_project_form(await request.body()) + save_project_status(form_data) + code = normalize_text(form_data.get("support_dept_code")) + selected_year = normalize_text(form_data.get("selected_year")) + redirect_url = "/projects" + query_parts = [] + if code: + query_parts.append(f"edit_code={quote_plus(code)}") + if selected_year: + query_parts.append(f"year={quote_plus(selected_year)}") + if query_parts: + redirect_url += "?" + "&".join(query_parts) + return RedirectResponse(redirect_url, status_code=303) + except Exception as exc: + logger.exception("사업현황 저장 에러: %s", exc) + code = normalize_text(form_data.get("support_dept_code")) + selected_year_text = normalize_text(form_data.get("selected_year")) + selected_year = int(selected_year_text) if selected_year_text.isdigit() else None + return render_projects_page( + request, + edit_code=code or None, + selected_year=selected_year, + message=f"사업현황 저장 중 오류가 발생했습니다: {exc}", + ) + + +if __name__ == "__main__": + uvicorn.run(app, host="0.0.0.0", port=8010, reload=False) diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..6e3c43a --- /dev/null +++ b/requirements.txt @@ -0,0 +1,6 @@ +fastapi +jinja2 +openpyxl +python-multipart +sqlalchemy +uvicorn diff --git a/scripts/remove_windows_portproxy.cmd b/scripts/remove_windows_portproxy.cmd new file mode 100644 index 0000000..9d28a87 --- /dev/null +++ b/scripts/remove_windows_portproxy.cmd @@ -0,0 +1,11 @@ +@echo off +setlocal + +set SCRIPT_DIR=%~dp0 +set PORT=%~1 + +if "%PORT%"=="" set PORT=8010 + +powershell -NoProfile -ExecutionPolicy Bypass -File "%SCRIPT_DIR%remove_windows_portproxy.ps1" -Port %PORT% + +endlocal diff --git a/scripts/remove_windows_portproxy.ps1 b/scripts/remove_windows_portproxy.ps1 new file mode 100644 index 0000000..6145c96 --- /dev/null +++ b/scripts/remove_windows_portproxy.ps1 @@ -0,0 +1,16 @@ +param( + [string]$ListenAddress = "0.0.0.0", + [int]$Port = 8010, + [string]$RuleName = "MyIntranetApp-8010" +) + +$ErrorActionPreference = "Stop" + +netsh interface portproxy delete v4tov4 listenport=$Port listenaddress=$ListenAddress | Out-Null + +$existingRule = Get-NetFirewallRule -DisplayName $RuleName -ErrorAction SilentlyContinue +if ($existingRule) { + Remove-NetFirewallRule -DisplayName $RuleName | Out-Null +} + +Write-Host "Removed portproxy and firewall rule for port $Port" -ForegroundColor Yellow diff --git a/scripts/run_server.sh b/scripts/run_server.sh new file mode 100755 index 0000000..81a893f --- /dev/null +++ b/scripts/run_server.sh @@ -0,0 +1,14 @@ +#!/usr/bin/env bash +set -euo pipefail + +cd "$(dirname "$0")/.." + +if [ ! -x ".venv/bin/python" ]; then + echo "가상환경이 없습니다. 먼저 아래를 실행하세요." + echo "python3 -m venv .venv" + echo "source .venv/bin/activate" + echo "pip install -r requirements.txt" + exit 1 +fi + +exec ./.venv/bin/python main.py diff --git a/scripts/setup_windows_portproxy.cmd b/scripts/setup_windows_portproxy.cmd new file mode 100644 index 0000000..3736dfb --- /dev/null +++ b/scripts/setup_windows_portproxy.cmd @@ -0,0 +1,24 @@ +@echo off +setlocal + +set SCRIPT_DIR=%~dp0 +set WSL_IP=%~1 +set PORT=%~2 + +if not "%WSL_IP%"=="" ( + echo %WSL_IP% | findstr /R "^[0-9][0-9]*$" >nul + if not errorlevel 1 ( + set PORT=%WSL_IP% + set WSL_IP= + ) +) + +if "%PORT%"=="" set PORT=8010 + +if "%WSL_IP%"=="" ( + powershell -NoProfile -ExecutionPolicy Bypass -File "%SCRIPT_DIR%setup_windows_portproxy.ps1" -Port %PORT% +) else ( + powershell -NoProfile -ExecutionPolicy Bypass -File "%SCRIPT_DIR%setup_windows_portproxy.ps1" -WslIp %WSL_IP% -Port %PORT% +) + +endlocal diff --git a/scripts/setup_windows_portproxy.ps1 b/scripts/setup_windows_portproxy.ps1 new file mode 100644 index 0000000..39cd7b5 --- /dev/null +++ b/scripts/setup_windows_portproxy.ps1 @@ -0,0 +1,54 @@ +param( + [string]$ListenAddress = "0.0.0.0", + [int]$Port = 8010, + [string]$WslIp = "", + [string]$RuleName = "MyIntranetApp-8010" +) + +$ErrorActionPreference = "Stop" + +if ([string]::IsNullOrWhiteSpace($WslIp)) { + $detected = wsl.exe hostname -I 2>$null + if (-not $detected) { + throw "WSL IP를 자동으로 찾지 못했습니다. -WslIp 옵션으로 직접 지정해 주세요." + } + + $WslIp = (($detected -split "\s+") | Where-Object { $_ -match '^\d+\.\d+\.\d+\.\d+$' } | Select-Object -First 1) + if (-not $WslIp) { + throw "WSL IP를 자동으로 파싱하지 못했습니다. -WslIp 옵션으로 직접 지정해 주세요." + } +} + +Write-Host "Setting Windows portproxy for WSL app..." -ForegroundColor Cyan +Write-Host "ListenAddress: $ListenAddress" +Write-Host "Port: $Port" +Write-Host "WSL IP: $WslIp" + +netsh interface portproxy delete v4tov4 listenport=$Port listenaddress=$ListenAddress | Out-Null +netsh interface portproxy add v4tov4 listenport=$Port listenaddress=$ListenAddress connectport=$Port connectaddress=$WslIp + +$existingRule = Get-NetFirewallRule -DisplayName $RuleName -ErrorAction SilentlyContinue +if ($existingRule) { + Remove-NetFirewallRule -DisplayName $RuleName | Out-Null +} + +New-NetFirewallRule ` + -DisplayName $RuleName ` + -Direction Inbound ` + -Action Allow ` + -Protocol TCP ` + -LocalPort $Port | Out-Null + +$windowsIps = Get-NetIPAddress -AddressFamily IPv4 | + Where-Object { $_.IPAddress -notlike '127.*' -and $_.IPAddress -notlike '169.254*' -and $_.IPAddress -notlike '172.*' } | + Select-Object -ExpandProperty IPAddress + +Write-Host "" +Write-Host "Completed." -ForegroundColor Green +Write-Host "Now open this from another PC on the intranet:" +Write-Host "http://:$Port" +if ($windowsIps) { + Write-Host "Windows IPv4 candidates: $($windowsIps -join ', ')" +} +Write-Host "" +Write-Host "Tip: if WSL restarts and its IP changes, run this script again. If you omit -WslIp, the script will detect it automatically." diff --git a/scripts/show_access_info.sh b/scripts/show_access_info.sh new file mode 100755 index 0000000..ada10cd --- /dev/null +++ b/scripts/show_access_info.sh @@ -0,0 +1,27 @@ +#!/usr/bin/env bash +set -euo pipefail + +WSL_IP="$(hostname -I | awk '{print $1}')" +WSL_HOST_IP="$(awk '/nameserver/ {print $2; exit}' /etc/resolv.conf)" +WINDOWS_IPS="$( + powershell.exe -NoProfile -Command "(Get-NetIPAddress -AddressFamily IPv4 | Where-Object { \$_.IPAddress -notlike '127.*' -and \$_.IPAddress -notlike '169.254*' } | Select-Object -ExpandProperty IPAddress) -join ','" \ + 2>/dev/null | tr -d '\r' +)" + +cat < + .filter-grid { + display: grid; + grid-template-columns: 220px 220px 1fr; + gap: 12px; + align-items: end; + } + + .legend { + display: flex; + flex-wrap: wrap; + gap: 10px; + margin-top: 10px; + } + + .legend-item { + display: inline-flex; + align-items: center; + gap: 8px; + color: #363b44; + font-size: 13px; + font-weight: 700; + background: rgba(255,255,255,0.92); + border: 1px solid var(--line); + border-radius: 10px; + padding: 6px 10px; + } + + .legend-swatch { + width: 10px; + height: 10px; + border-radius: 999px; + } + + .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: 16px; + box-shadow: inset 0 1px 0 rgba(255,255,255,0.92); + } + + .chart-legend { + margin-bottom: 10px; + } + + .chart-title { + font-size: 18px; + font-weight: 700; + margin-bottom: 12px; + } + + .chart-svg { + width: 100%; + height: auto; + aspect-ratio: 1200 / 420; + min-height: 320px; + display: block; + } + + .expense-chart-svg { + aspect-ratio: 1600 / 420; + } + + .chart-note { + margin-top: 10px; + color: var(--muted); + font-size: 13px; + line-height: 1.6; + } + + .metric-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 12px; + } + + @media (max-width: 1000px) { + .filter-grid, + .metric-grid { + grid-template-columns: 1fr; + } + } + +{% endblock %} + +{% block content %} +
+
+

연도별 수익 비용 정리

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

선택 구간 요약

+
+
+
+ +
+
+

비용 구조

+
+
+
+ +
+
+ +
+
+

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

+
+
+
+ +
+
+{% endblock %} + +{% block script %} + +{% endblock %} diff --git a/templates/base.html b/templates/base.html new file mode 100644 index 0000000..e2201aa --- /dev/null +++ b/templates/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/templates/dashboard.html b/templates/dashboard.html new file mode 100644 index 0000000..d0dfd26 --- /dev/null +++ b/templates/dashboard.html @@ -0,0 +1,618 @@ +{% extends "base.html" %} + +{% block title %}대시보드{% endblock %} + +{% block head_extra %} + +{% endblock %} + +{% block content %} +
+
+
+
+

대시보드

+
+
+
+
+ + +
+
엑셀 파일을 선택하면 회계 데이터를 DB에 바로 저장합니다. 프로젝트 폴더에 둔 파일 외에 추가 파일을 수동 반영할 때 사용하세요.
+
+
+
+ +
+
+

현황 요약

+
+
+
+ +
+
+
+
+
집계 대상 사업 수
+
{{ overview.business_count or 0 }}
+
+
+
수입금액
+
{{ "{:,.0f}".format(overview.total_revenue or 0) }}
+
+
+
원가 총액
+
{{ "{:,.0f}".format(overview.total_cost or 0) }}
+
+
+
판관비 총액
+
{{ "{:,.0f}".format(overview.total_sga or 0) }}
+
+
+
+ +
+
+

통합 비용 집계

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

원가·판관비 추세

+
+
+
+ +
+
+ +
+
+
+
+
+ +
+
+{% endblock %} + +{% block script %} + +{% endblock %} diff --git a/templates/index.html b/templates/index.html new file mode 100644 index 0000000..ff14b31 --- /dev/null +++ b/templates/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/templates/projects.html b/templates/projects.html new file mode 100644 index 0000000..bcb03b9 --- /dev/null +++ b/templates/projects.html @@ -0,0 +1,3059 @@ +{% extends "base.html" %} + +{% block title %}사업현황{% endblock %} + +{% block head_extra %} + +{% endblock %} + +{% block content %} +
+
+
+
+
+
+

사업현황

+
+ +
+
+
+
+
+
연관 프로젝트 수
+
{{ project_dashboard.related_projects or 0 }}
+
+
+
수금액
+
{{ "{:,.0f}".format(project_dashboard.collection_amount or 0) }}
+
+
+
지출액
+
{{ "{:,.0f}".format(project_dashboard.expense_amount or 0) }}
+
+
+
종료 프로젝트 수
+
{{ project_dashboard.completed_projects or 0 }}
+
+
+
+ +
+
+
+
+
+

수금액 구성

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

프로젝트 검색

+
+
+ +
+
+
+
+
+
+ + +
+ + +
+
+
+ +
+
+
+ + +{% endblock %} + +{% block script %} + +{% endblock %} diff --git a/수입원가판관비_260406.xlsx b/수입원가판관비_260406.xlsx new file mode 100644 index 0000000..0ca03db Binary files /dev/null and b/수입원가판관비_260406.xlsx differ