diff --git a/DB_HEALTH_CHECK_20260409.md b/DB_HEALTH_CHECK_20260409.md new file mode 100644 index 0000000..1bc99af --- /dev/null +++ b/DB_HEALTH_CHECK_20260409.md @@ -0,0 +1,169 @@ +# DB Health Check 2026-04-09 + +## Summary + +- Current SQLite size and row counts are still within a manageable range for this app. +- Main medium-term risks were: + - concurrent writes causing `database is locked` + - all users sharing one `project_page_state` row + - growing scan cost around billing-link and project-status entry access +- These were addressed without data loss. + +## Current Scale + +- `transactions`: about 49k rows +- `project_contract_info`: 760 rows +- `project_billing_entries`: 1,774 rows +- `project_related_links`: 1,276 rows +- `project_status`: few rows now, but each row can contain many logical sub-entries + +## Improvements Applied + +### 1. SQLite concurrency and durability tuning + +Applied on every DB connection in [main.py](/home/b17301/my-intranet-app/main.py#L39): + +- `PRAGMA journal_mode=WAL` +- `PRAGMA synchronous=NORMAL` +- `PRAGMA foreign_keys=ON` +- `PRAGMA busy_timeout=5000` +- `PRAGMA temp_store=MEMORY` + +Effect: + +- better concurrent read/write behavior +- lower chance of write collisions during multi-user editing +- safer long-running usage than SQLite defaults + +### 2. Added indexes for growth hotspots + +Added in [main.py](/home/b17301/my-intranet-app/main.py#L119): + +- `transactions` + - `idx_transactions_support_category` + - `idx_transactions_support_account` + - `idx_transactions_source_file` + - `idx_transactions_updated_at` +- `project_billing_entries` + - `idx_project_billing_entries_raw_code` + - `idx_project_billing_entries_round_code` + - `idx_project_billing_entries_source_file` + - `idx_project_billing_entries_updated_at` +- `project_related_links` + - `idx_project_related_links_related` +- `project_page_state` + - `idx_project_page_state_page_session` + +Effect: + +- faster changed-round grouping +- better support/account based project aggregations +- faster source-file based reimport paths +- safer scaling as billing and transaction history grows + +### 3. Page state changed from shared row to session-scoped rows + +The old structure used one shared record: + +- `project_page_state(page_key PRIMARY KEY, ...)` + +This meant different users could overwrite each other's selected project, year, and open/closed detail state. + +It now migrates to: + +- `project_page_state(page_key, session_id, ...)` +- primary key: `(page_key, session_id)` + +Relevant code: + +- schema migration: [main.py](/home/b17301/my-intranet-app/main.py#L264) +- load/save logic: [main.py](/home/b17301/my-intranet-app/main.py#L1970) +- API: [main.py](/home/b17301/my-intranet-app/main.py#L3473) +- browser session id: [templates/base.html](/home/b17301/my-intranet-app/templates/base.html#L538) +- client page-state save/load: [templates/projects.html](/home/b17301/my-intranet-app/templates/projects.html#L4880) + +Effect: + +- browser A and browser B no longer fight over one shared project-search state + +### 4. Project status JSON blobs normalized into child tables + +Previously, logical row collections were stored only inside JSON columns in `project_status`: + +- `collection_entries_json` +- `task_plan_entries_json` +- `exec_budget_entries_json` +- `actual_input_entries_json` + +This has now been normalized into child tables: + +- `project_collection_entries` +- `project_task_plan_entries` +- `project_exec_budget_entries` +- `project_actual_input_entries` + +Relevant code: + +- row extraction and migration helpers: [main.py](/home/b17301/my-intranet-app/main.py#L987) +- child-table schema: [main.py](/home/b17301/my-intranet-app/main.py#L273) +- migration from legacy JSON: [main.py](/home/b17301/my-intranet-app/main.py#L1201) +- project status read paths: [main.py](/home/b17301/my-intranet-app/main.py#L2143) +- project status save path: [main.py](/home/b17301/my-intranet-app/main.py#L3323) + +Effect: + +- less dependence on large JSON blobs for active reads +- cleaner future path for per-section editing +- safer long-term maintainability + +## Backward Compatibility + +Legacy JSON columns are still kept in `project_status` for compatibility and rollback safety. + +Current behavior: + +- reads prefer normalized child rows +- if child rows do not exist, legacy JSON/scalar fallback still works +- writes update both: + - scalar summary fields + - legacy JSON cache + - normalized child rows + +This avoids data loss during migration. + +## Remaining Structural Risk + +The biggest remaining architectural limitation is: + +- `project_status` still acts as one large parent record for many independently editable sections + +So while row collections are normalized now, parent-level fields such as: + +- project type +- expected rates +- contract amount +- dates +- notes + +still live together in one row and one update flow. + +This is acceptable for now, but if many users edit the same project simultaneously, the next best improvement would be: + +1. split edit APIs by section +2. add per-section revision tracking +3. optionally move more parent fields into section-specific tables + +## Recommendation + +Current DB can continue operating efficiently with the applied changes. + +Recommended next step if the app keeps expanding: + +- introduce section-level save endpoints for + - collection + - task plan + - exec budget + - actual input + - project metadata + +That would reduce cross-section write conflicts even more. diff --git a/WORK_SUMMARY_20260409.md b/WORK_SUMMARY_20260409.md new file mode 100644 index 0000000..ba9cb58 --- /dev/null +++ b/WORK_SUMMARY_20260409.md @@ -0,0 +1,50 @@ +# 작업 요약 2026-04-09 + +## 이번 반영 범위 + +- 프로젝트 정보 페이지 안정화 + - 저장 후 프로젝트 정보 화면이 비거나 모달이 갑자기 닫히는 문제 구조 개선 + - 프로젝트 검색/상세/미계약 비용 발생 현황의 데이터 흐름 및 자동 최신화 충돌 완화 + - 바로가기, 연관 프로젝트, 세부 내역 정렬/표시 개선 + +- 사업현황 추가/수정 저장 구조 보강 + - 프로젝트 저장을 DB 기준으로 즉시 반영되도록 보강 + - 수금정보 분류값 정리 + - 기성구분: `선급금 / 기성금 / 준공금` + - 청구구분: `계약분 / 기타` + - 실행예산/실투입/예상 배분 설정 연계 보강 + +- 계약/청구/변경계약 데이터 반영 + - 계약현황, 기성청구현황, 변경계약금액현황(총괄/차수) 파일을 DB에 반영 + - 변경차수/보완/연계 프로젝트 자동 연결 로직 보강 + - 본계약과 연결 가능한 건은 상세 페이지와 연관 프로젝트 태그/집행내역에 합산 반영 + +- DB 구조 및 안정성 개선 + - 설정성 하드코딩 일부를 DB 설정 테이블로 이동 + - 프로젝트 입력 데이터의 섹션 분리 구조 확장 + - 건강 점검 문서 추가: `DB_HEALTH_CHECK_20260409.md` + +- 대시보드 / 연도별 수익·비용 UI 개선 + - 대시보드 카드/그래프 구조 정리 + - 연도별 수익/비용 그래프 크기, 라벨, 축, 카드 활용도 개선 + - 페이지 공통 여백 구조 정리 + +## 주요 수정 파일 + +- `main.py` +- `templates/projects.html` +- `templates/annual_summary.html` +- `templates/base.html` +- `templates/dashboard.html` +- `templates/index.html` +- `data.db` + +## 참고 데이터 파일 + +- `변경계약금액현황(회계)_총괄_20210101_20260409_260409.xlsx` +- `변경계약금액현황(회계)_차수_20210101_20260409_260409.xlsx` + +## 비고 + +- SQLite 기반 운영은 현재 데이터 규모에서는 가능하지만, 동시 작업과 화면 상태 저장은 계속 점검이 필요함 +- `data.db-wal`, `data.db-shm` 같은 런타임 임시 파일은 커밋 대상에서 제외함 diff --git a/data.db b/data.db index f464890..2205d0e 100644 Binary files a/data.db and b/data.db differ diff --git a/main.py b/main.py index 0a58bd1..9fadeda 100644 --- a/main.py +++ b/main.py @@ -2,7 +2,9 @@ import os import logging import json import re +import zipfile from datetime import date, datetime +from functools import lru_cache from pathlib import Path from typing import Any from urllib.parse import parse_qs, quote_plus @@ -14,7 +16,7 @@ from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse from fastapi.staticfiles import StaticFiles from fastapi.templating import Jinja2Templates from openpyxl import load_workbook -from sqlalchemy import create_engine, text +from sqlalchemy import create_engine, event, text logging.basicConfig(level=logging.INFO) logger = logging.getLogger(__name__) @@ -35,6 +37,17 @@ engine = create_engine( connect_args={"check_same_thread": False}, ) + +@event.listens_for(engine, "connect") +def configure_sqlite_connection(dbapi_connection: Any, _: Any) -> None: + cursor = dbapi_connection.cursor() + cursor.execute("PRAGMA journal_mode=WAL") + cursor.execute("PRAGMA synchronous=NORMAL") + cursor.execute("PRAGMA foreign_keys=ON") + cursor.execute("PRAGMA busy_timeout=5000") + cursor.execute("PRAGMA temp_store=MEMORY") + cursor.close() + EXCLUDED_SUPPORT_CODES = {"ZZZZZZ"} EXCLUDED_SUPPORT_NAMES = {"공통", "경영지원부", "기술개발센터", "임원실", "기술개발부", "총괄기획실"} SUPPORT_DEPARTMENT_NAMES = ("경영지원부", "임원실", "총괄기획실", "기술개발센터", "기술개발부", "공통") @@ -104,6 +117,150 @@ DIRECT_HEADER_MAP = { "관리항목": "management_item", } +DEFAULT_APP_OPTION_ITEMS = { + "labor_grades": [ + ("president", "사장", "사장"), + ("vice_president", "부사장", "부사장"), + ("executive_vice_president", "전무", "전무"), + ("managing_director", "상무", "상무"), + ("director", "이사", "이사"), + ("general_manager", "부장", "부장"), + ("deputy_general_manager", "차장", "차장"), + ("manager", "과장", "과장"), + ("assistant_manager", "대리", "대리"), + ("staff", "사원", "사원"), + ("principal", "수석", "수석"), + ("senior_manager", "책임", "책임"), + ("senior", "선임", "선임"), + ("researcher", "연구원", "연구원"), + ], + "expected_as_rates": [ + ("as_0", "0%", "0"), + ("as_2", "2%", "2"), + ("as_5", "5%", "5"), + ("as_10", "10%", "10"), + ], + "expected_sga_rates": [ + ("sga_13", "13%", "13"), + ("sga_15", "15%", "15"), + ("sga_20", "20%", "20"), + ("sga_25", "25%", "25"), + ], + "uncontracted_categories": [ + ("general", "일반 미계약", "general"), + ("precontract", "사전 사업 코드", "precontract"), + ("corporate_rnd", "기업 연구개발", "corporate_rnd"), + ("external_research", "외부 연구과제", "external_research"), + ], + "project_rules": [ + ("legacy_variant_cutoff_year", "이전 연도 변경/차수 제외 기준", "23"), + ("detail_visible_min_year", "세부내역 반영 시작 연도", "2023"), + ], + "dashboard_revenue_metrics": [ + ("design_revenue", "설계", "#4f7cff"), + ("design_other_revenue", "설계 외", "#67c7c9"), + ("supervision_revenue", "감리", "#233a5a"), + ("inspection_revenue", "점검", "#ffb54a"), + ], + "dashboard_expense_metrics": [ + ("cost_sum", "원가", "#4f7cff"), + ("sga_sum", "판관비", "#67c7c9"), + ("labor_sum", "원가인건비", "#233a5a"), + ("outsourcing_sum", "원가외주비", "#ffb54a"), + ], + "annual_metric_cards": [ + ("revenue_sum", "수금", "수금"), + ("project_cost_sum", "원가(프로젝트)", "원가(프로젝트)"), + ("support_cost_sum", "원가(지원부서)", "원가(지원부서)"), + ("support_sga_sum", "판관비(지원부서)", "판관비(지원부서)"), + ("field_sga_sum", "판관비(현업부서)", "판관비(현업부서)"), + ("labor_sum", "원가인건비", "원가인건비"), + ("outsourcing_sum", "원가외주비", "원가외주비"), + ("total_expense", "비용합계", "비용합계"), + ("operating_balance", "영업수지", "영업수지"), + ], + "annual_expense_chart_metrics": [ + ("labor_sum", "원가인건비", "#8b5cf6"), + ("outsourcing_sum", "원가외주비", "#ec4899"), + ("project_cost_sum", "원가(프로젝트)", "#0ea5a4"), + ("support_cost_sum", "원가(지원부서)", "#67b7dc"), + ("support_sga_sum", "판관비(지원부서)", "#f59e0b"), + ("field_sga_sum", "판관비(현업부서)", "#f97316"), + ], + "annual_balance_chart_metrics": [ + ("revenue_sum", "수금", "#0f766e"), + ("total_expense", "비용합계", "#1d4ed8"), + ("operating_balance", "영업수지", "#dc2626"), + ], +} + +DEFAULT_APP_KEYWORD_RULES = { + "special_x_classification": [ + ("external_research", "과제"), + ("external_research", "연구과제"), + ("external_research", "연구소"), + ("external_research", "연구용역"), + ("external_research", "연구"), + ("corporate_rnd", "신규노선개발"), + ("corporate_rnd", "프로그램 개발"), + ("corporate_rnd", "프로그램개발"), + ("corporate_rnd", "BIM"), + ("corporate_rnd", "시스템"), + ("corporate_rnd", "혁신"), + ] +} + + +def ensure_default_app_config(conn: Any) -> None: + for group_key, items in DEFAULT_APP_OPTION_ITEMS.items(): + for sort_order, (item_key, label, value_text) in enumerate(items): + conn.execute( + text( + """ + INSERT INTO app_option_items ( + group_key, item_key, label, value_text, sort_order, is_active, meta_json + ) VALUES ( + :group_key, :item_key, :label, :value_text, :sort_order, 1, '{}' + ) + ON CONFLICT(group_key, item_key) DO UPDATE SET + label = excluded.label, + value_text = excluded.value_text, + sort_order = excluded.sort_order + """ + ), + { + "group_key": group_key, + "item_key": item_key, + "label": label, + "value_text": value_text, + "sort_order": sort_order, + }, + ) + + for rule_group, items in DEFAULT_APP_KEYWORD_RULES.items(): + for sort_order, (category_key, keyword) in enumerate(items): + conn.execute( + text( + """ + INSERT INTO app_keyword_rules ( + rule_group, category_key, keyword, sort_order, is_active + ) VALUES ( + :rule_group, :category_key, :keyword, :sort_order, 1 + ) + ON CONFLICT(rule_group, category_key, keyword) DO UPDATE SET + sort_order = excluded.sort_order + """ + ), + { + "rule_group": rule_group, + "category_key": category_key, + "keyword": keyword, + "sort_order": sort_order, + }, + ) + + load_app_config.cache_clear() + def init_db() -> None: with engine.begin() as conn: @@ -165,6 +322,38 @@ def init_db() -> None: """ ) ) + conn.execute( + text( + """ + CREATE INDEX IF NOT EXISTS idx_transactions_support_category + ON transactions (support_dept_code, accounting_category) + """ + ) + ) + conn.execute( + text( + """ + CREATE INDEX IF NOT EXISTS idx_transactions_support_account + ON transactions (support_dept_code, account_code) + """ + ) + ) + conn.execute( + text( + """ + CREATE INDEX IF NOT EXISTS idx_transactions_source_file + ON transactions (source_file) + """ + ) + ) + conn.execute( + text( + """ + CREATE INDEX IF NOT EXISTS idx_transactions_updated_at + ON transactions (updated_at) + """ + ) + ) transaction_columns = { row[1] for row in conn.execute(text("PRAGMA table_info(transactions)")).fetchall() @@ -215,6 +404,39 @@ def init_db() -> None: """ ) ) + conn.execute( + text( + """ + CREATE TABLE IF NOT EXISTS project_basic_info ( + support_dept_code TEXT PRIMARY KEY, + support_dept_name TEXT NOT NULL DEFAULT '', + contract_amount REAL DEFAULT 0, + 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, + exec_labor_rates_json TEXT DEFAULT '{}', + change_round TEXT DEFAULT '', + project_start_date TEXT DEFAULT '', + project_end_date TEXT DEFAULT '', + completion_status TEXT DEFAULT '', + notes TEXT DEFAULT '', + last_editor_session_id TEXT DEFAULT '', + last_client_submitted_at TEXT DEFAULT '', + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) + """ + ) + ) + conn.execute( + text( + """ + CREATE INDEX IF NOT EXISTS idx_project_basic_info_updated_at + ON project_basic_info (updated_at) + """ + ) + ) conn.execute( text( """ @@ -223,12 +445,255 @@ def init_db() -> None: selected_code TEXT DEFAULT '', selected_year TEXT DEFAULT '', analysis_open INTEGER DEFAULT 0, + uncontracted_year_start TEXT DEFAULT '', + uncontracted_year_end TEXT DEFAULT '', related_project_selections_json TEXT DEFAULT '{}', updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ) """ ) ) + conn.execute( + text( + """ + CREATE TABLE IF NOT EXISTS app_option_items ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + group_key TEXT NOT NULL, + item_key TEXT NOT NULL, + label TEXT NOT NULL, + value_text TEXT NOT NULL DEFAULT '', + sort_order INTEGER NOT NULL DEFAULT 0, + is_active INTEGER NOT NULL DEFAULT 1, + meta_json TEXT DEFAULT '{}', + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + UNIQUE(group_key, item_key) + ) + """ + ) + ) + conn.execute( + text( + """ + CREATE INDEX IF NOT EXISTS idx_app_option_items_group + ON app_option_items (group_key, sort_order, id) + """ + ) + ) + conn.execute( + text( + """ + CREATE TABLE IF NOT EXISTS app_keyword_rules ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + rule_group TEXT NOT NULL, + category_key TEXT NOT NULL, + keyword TEXT NOT NULL, + sort_order INTEGER NOT NULL DEFAULT 0, + is_active INTEGER NOT NULL DEFAULT 1, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + UNIQUE(rule_group, category_key, keyword) + ) + """ + ) + ) + conn.execute( + text( + """ + CREATE INDEX IF NOT EXISTS idx_app_keyword_rules_group + ON app_keyword_rules (rule_group, category_key, sort_order, id) + """ + ) + ) + conn.execute( + text( + """ + CREATE TABLE IF NOT EXISTS project_collection_entries ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + support_dept_code TEXT NOT NULL, + position INTEGER NOT NULL DEFAULT 0, + vendor TEXT DEFAULT '', + progress_type TEXT DEFAULT '', + billing_round TEXT DEFAULT '', + billing_type TEXT DEFAULT '', + billing_date TEXT DEFAULT '', + billed_amount REAL DEFAULT 0, + round TEXT DEFAULT '', + date TEXT DEFAULT '', + due_date TEXT DEFAULT '', + amount REAL DEFAULT 0, + balance_amount REAL DEFAULT 0, + collection_rate REAL DEFAULT 0, + note TEXT DEFAULT '', + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) + """ + ) + ) + conn.execute( + text( + """ + CREATE INDEX IF NOT EXISTS idx_project_collection_entries_code_pos + ON project_collection_entries (support_dept_code, position) + """ + ) + ) + conn.execute( + text( + """ + CREATE TABLE IF NOT EXISTS project_task_plan_entries ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + support_dept_code TEXT NOT NULL, + position INTEGER NOT NULL DEFAULT 0, + group_name TEXT DEFAULT '', + dept_name TEXT DEFAULT '', + work_name TEXT DEFAULT '', + amount REAL DEFAULT 0, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) + """ + ) + ) + conn.execute( + text( + """ + CREATE INDEX IF NOT EXISTS idx_project_task_plan_entries_code_pos + ON project_task_plan_entries (support_dept_code, position) + """ + ) + ) + conn.execute( + text( + """ + CREATE TABLE IF NOT EXISTS project_exec_budget_entries ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + support_dept_code TEXT NOT NULL, + position INTEGER NOT NULL DEFAULT 0, + group_name TEXT DEFAULT '', + grade TEXT DEFAULT '', + hours TEXT DEFAULT '', + dept_name TEXT DEFAULT '', + work_name TEXT DEFAULT '', + account_code TEXT DEFAULT '', + account_name TEXT DEFAULT '', + amount REAL DEFAULT 0, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) + """ + ) + ) + conn.execute( + text( + """ + CREATE INDEX IF NOT EXISTS idx_project_exec_budget_entries_code_pos + ON project_exec_budget_entries (support_dept_code, position) + """ + ) + ) + conn.execute( + text( + """ + CREATE TABLE IF NOT EXISTS project_actual_input_entries ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + support_dept_code TEXT NOT NULL, + position INTEGER NOT NULL DEFAULT 0, + group_name TEXT DEFAULT '', + grade TEXT DEFAULT '', + minutes TEXT DEFAULT '', + label TEXT DEFAULT '', + reference TEXT DEFAULT '', + note TEXT DEFAULT '', + amount REAL DEFAULT 0, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) + """ + ) + ) + conn.execute( + text( + """ + CREATE INDEX IF NOT EXISTS idx_project_actual_input_entries_code_pos + ON project_actual_input_entries (support_dept_code, position) + """ + ) + ) + conn.execute( + text( + """ + CREATE TABLE IF NOT EXISTS project_comparison_notes ( + support_dept_code TEXT NOT NULL, + item_key TEXT NOT NULL, + note TEXT DEFAULT '', + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (support_dept_code, item_key) + ) + """ + ) + ) + conn.execute( + text( + """ + CREATE INDEX IF NOT EXISTS idx_project_comparison_notes_code + ON project_comparison_notes (support_dept_code, item_key) + """ + ) + ) + page_state_columns_before = { + row[1] + for row in conn.execute(text("PRAGMA table_info(project_page_state)")).fetchall() + } + if "session_id" not in page_state_columns_before: + legacy_rows = conn.execute( + text( + """ + SELECT page_key, selected_code, selected_year, analysis_open, + related_project_selections_json, updated_at + FROM project_page_state + """ + ) + ).mappings().all() + conn.execute(text("ALTER TABLE project_page_state RENAME TO project_page_state_legacy")) + conn.execute( + text( + """ + CREATE TABLE project_page_state ( + page_key TEXT NOT NULL, + session_id TEXT NOT NULL DEFAULT '', + selected_code TEXT DEFAULT '', + selected_year TEXT DEFAULT '', + analysis_open INTEGER DEFAULT 0, + uncontracted_year_start TEXT DEFAULT '', + uncontracted_year_end TEXT DEFAULT '', + related_project_selections_json TEXT DEFAULT '{}', + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (page_key, session_id) + ) + """ + ) + ) + for row in legacy_rows: + conn.execute( + text( + """ + INSERT INTO project_page_state ( + page_key, session_id, selected_code, selected_year, + analysis_open, uncontracted_year_start, uncontracted_year_end, + related_project_selections_json, updated_at + ) VALUES ( + :page_key, '', :selected_code, :selected_year, + :analysis_open, '', '', :related_project_selections_json, :updated_at + ) + """ + ), + dict(row), + ) + conn.execute(text("DROP TABLE project_page_state_legacy")) + conn.execute( + text( + """ + CREATE INDEX IF NOT EXISTS idx_project_page_state_page_session + ON project_page_state (page_key, session_id) + """ + ) + ) conn.execute( text( """ @@ -242,6 +707,89 @@ def init_db() -> None: """ ) ) + conn.execute( + text( + """ + CREATE TABLE IF NOT EXISTS project_uncontracted_classification ( + support_dept_code TEXT PRIMARY KEY, + category TEXT DEFAULT '', + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) + """ + ) + ) + conn.execute( + text( + """ + CREATE INDEX IF NOT EXISTS idx_project_uncontracted_classification_category + ON project_uncontracted_classification (category) + """ + ) + ) + conn.execute( + text( + """ + CREATE TABLE IF NOT EXISTS project_quick_links ( + page_key TEXT NOT NULL, + support_dept_code TEXT NOT NULL, + sort_order INTEGER NOT NULL DEFAULT 0, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (page_key, support_dept_code) + ) + """ + ) + ) + quick_link_columns_before = { + row[1] + for row in conn.execute(text("PRAGMA table_info(project_quick_links)")).fetchall() + } + if "session_id" in quick_link_columns_before: + legacy_rows = conn.execute( + text( + """ + SELECT page_key, support_dept_code, MIN(sort_order) AS sort_order, MAX(updated_at) AS updated_at + FROM project_quick_links + GROUP BY page_key, support_dept_code + """ + ) + ).mappings().all() + conn.execute(text("ALTER TABLE project_quick_links RENAME TO project_quick_links_legacy")) + conn.execute( + text( + """ + CREATE TABLE project_quick_links ( + page_key TEXT NOT NULL, + support_dept_code TEXT NOT NULL, + sort_order INTEGER NOT NULL DEFAULT 0, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (page_key, support_dept_code) + ) + """ + ) + ) + for row in legacy_rows: + conn.execute( + text( + """ + INSERT INTO project_quick_links ( + page_key, support_dept_code, sort_order, updated_at + ) VALUES ( + :page_key, :support_dept_code, :sort_order, :updated_at + ) + """ + ), + dict(row), + ) + conn.execute(text("DROP TABLE project_quick_links_legacy")) + conn.execute( + text( + """ + CREATE INDEX IF NOT EXISTS idx_project_quick_links_page_sort + ON project_quick_links (page_key, sort_order, updated_at) + """ + ) + ) + ensure_default_app_config(conn) conn.execute( text( """ @@ -250,6 +798,14 @@ def init_db() -> None: """ ) ) + conn.execute( + text( + """ + CREATE INDEX IF NOT EXISTS idx_project_related_links_related + ON project_related_links (related_support_dept_code) + """ + ) + ) conn.execute( text( """ @@ -280,6 +836,94 @@ def init_db() -> None: """ ) ) + conn.execute( + text( + """ + CREATE INDEX IF NOT EXISTS idx_project_contract_info_updated_at + ON project_contract_info (updated_at) + """ + ) + ) + conn.execute( + text( + """ + CREATE TABLE IF NOT EXISTS project_contract_change_summary ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + raw_summary_code TEXT DEFAULT '', + normalized_title TEXT DEFAULT '', + owner_department TEXT DEFAULT '', + business_division TEXT DEFAULT '', + support_dept_name TEXT DEFAULT '', + change_date TEXT DEFAULT '', + client_name TEXT DEFAULT '', + original_contract_period TEXT DEFAULT '', + changed_project_end_date TEXT DEFAULT '', + initial_contract_amount REAL DEFAULT 0, + previous_contract_amount REAL DEFAULT 0, + changed_contract_amount REAL DEFAULT 0, + delta_amount REAL DEFAULT 0, + source_file TEXT DEFAULT '', + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) + """ + ) + ) + conn.execute( + text( + """ + CREATE INDEX IF NOT EXISTS idx_project_contract_change_summary_code + ON project_contract_change_summary (raw_summary_code, change_date) + """ + ) + ) + conn.execute( + text( + """ + CREATE INDEX IF NOT EXISTS idx_project_contract_change_summary_title + ON project_contract_change_summary (normalized_title) + """ + ) + ) + conn.execute( + text( + """ + CREATE TABLE IF NOT EXISTS project_contract_change_round ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + support_dept_code TEXT DEFAULT '', + raw_round_code TEXT DEFAULT '', + normalized_title TEXT DEFAULT '', + owner_department TEXT DEFAULT '', + business_division TEXT DEFAULT '', + support_dept_name TEXT DEFAULT '', + change_date TEXT DEFAULT '', + client_name TEXT DEFAULT '', + original_contract_period TEXT DEFAULT '', + changed_project_end_date TEXT DEFAULT '', + initial_contract_amount REAL DEFAULT 0, + changed_contract_amount REAL DEFAULT 0, + delta_amount REAL DEFAULT 0, + source_file TEXT DEFAULT '', + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) + """ + ) + ) + conn.execute( + text( + """ + CREATE INDEX IF NOT EXISTS idx_project_contract_change_round_code + ON project_contract_change_round (support_dept_code, change_date) + """ + ) + ) + conn.execute( + text( + """ + CREATE INDEX IF NOT EXISTS idx_project_contract_change_round_title + ON project_contract_change_round (normalized_title) + """ + ) + ) conn.execute( text( """ @@ -317,6 +961,38 @@ def init_db() -> None: """ ) ) + conn.execute( + text( + """ + CREATE INDEX IF NOT EXISTS idx_project_billing_entries_raw_code + ON project_billing_entries (raw_project_code) + """ + ) + ) + conn.execute( + text( + """ + CREATE INDEX IF NOT EXISTS idx_project_billing_entries_round_code + ON project_billing_entries (round_code) + """ + ) + ) + conn.execute( + text( + """ + CREATE INDEX IF NOT EXISTS idx_project_billing_entries_source_file + ON project_billing_entries (source_file) + """ + ) + ) + conn.execute( + text( + """ + CREATE INDEX IF NOT EXISTS idx_project_billing_entries_updated_at + ON project_billing_entries (updated_at) + """ + ) + ) existing_columns = { row[1] for row in conn.execute(text("PRAGMA table_info(project_status)")).fetchall() @@ -354,9 +1030,12 @@ def init_db() -> None: for row in conn.execute(text("PRAGMA table_info(project_page_state)")).fetchall() } required_page_state_columns = { + "session_id": "TEXT DEFAULT ''", "selected_code": "TEXT DEFAULT ''", "selected_year": "TEXT DEFAULT ''", "analysis_open": "INTEGER DEFAULT 0", + "uncontracted_year_start": "TEXT DEFAULT ''", + "uncontracted_year_end": "TEXT DEFAULT ''", "related_project_selections_json": "TEXT DEFAULT '{}'", } for column_name, column_type in required_page_state_columns.items(): @@ -368,6 +1047,163 @@ def init_db() -> None: } if "link_source" not in related_link_columns: conn.execute(text("ALTER TABLE project_related_links ADD COLUMN link_source TEXT DEFAULT 'manual'")) + migrate_project_status_entries(conn) + migrate_project_basic_info(conn) + ensure_default_app_config(conn) + conn.execute(text("ANALYZE")) + + +@lru_cache(maxsize=1) +def load_app_config() -> dict[str, Any]: + with engine.connect() as conn: + option_rows = conn.execute( + text( + """ + SELECT group_key, item_key, label, value_text, sort_order + FROM app_option_items + WHERE is_active = 1 + ORDER BY group_key, sort_order, id + """ + ) + ).mappings().all() + keyword_rows = conn.execute( + text( + """ + SELECT rule_group, category_key, keyword, sort_order + FROM app_keyword_rules + WHERE is_active = 1 + ORDER BY rule_group, category_key, sort_order, id + """ + ) + ).mappings().all() + options: dict[str, list[dict[str, Any]]] = {} + for row in option_rows: + options.setdefault(row["group_key"], []).append( + { + "item_key": row["item_key"], + "label": row["label"], + "value": row["value_text"], + "sort_order": row["sort_order"], + } + ) + keyword_rules: dict[str, dict[str, list[str]]] = {} + for row in keyword_rows: + keyword_rules.setdefault(row["rule_group"], {}).setdefault(row["category_key"], []).append(row["keyword"]) + return {"options": options, "keyword_rules": keyword_rules} + + +def get_option_items(group_key: str) -> list[dict[str, Any]]: + return list(load_app_config().get("options", {}).get(group_key, [])) + + +def get_keyword_rule_groups(rule_group: str) -> dict[str, list[str]]: + return dict(load_app_config().get("keyword_rules", {}).get(rule_group, {})) + + +def get_labor_grade_options() -> list[str]: + return [item["label"] for item in get_option_items("labor_grades")] + + +def get_expected_as_rate_options() -> list[dict[str, Any]]: + return get_option_items("expected_as_rates") + + +def get_expected_sga_rate_options() -> list[dict[str, Any]]: + return get_option_items("expected_sga_rates") + + +def get_collection_progress_type_options() -> list[str]: + preferred_order = {"선급금": 0, "기성금": 1, "준공금": 2} + with engine.begin() as conn: + rows = conn.execute( + text( + """ + SELECT DISTINCT billing_type + FROM project_billing_entries + WHERE COALESCE(billing_type, '') <> '' + ORDER BY billing_type + """ + ) + ).fetchall() + values = { + normalized + for (value,) in rows + for normalized in [normalize_collection_progress_type(value)] + if normalized + } + if not values: + values = {"선급금", "기성금", "준공금"} + return sorted(values, key=lambda item: (preferred_order.get(item, 999), item)) + + +def get_collection_billing_type_options() -> list[str]: + with engine.begin() as conn: + rows = conn.execute( + text( + """ + SELECT DISTINCT billing_type + FROM project_collection_entries + WHERE COALESCE(billing_type, '') <> '' + ORDER BY billing_type + """ + ) + ).fetchall() + values = { + normalized + for (value,) in rows + for normalized in [normalize_collection_billing_type(value)] + if normalized + } + values.add("기타") + if not values: + values = {"계약분", "기타"} + return list(sorted(values)) + + +def get_uncontracted_category_options() -> list[dict[str, Any]]: + return get_option_items("uncontracted_categories") + + +def get_project_runtime_settings() -> dict[str, str]: + return {item["item_key"]: str(item.get("value", "")) for item in get_option_items("project_rules")} + + +def get_special_x_classification_rules() -> dict[str, list[str]]: + return get_keyword_rule_groups("special_x_classification") + + +def save_project_runtime_setting(item_key: Any, value_text: Any) -> None: + normalized_item_key = normalize_text(item_key) + if not normalized_item_key: + raise ValueError("설정 키가 올바르지 않습니다.") + with engine.begin() as conn: + existing = conn.execute( + text( + """ + SELECT group_key, item_key + FROM app_option_items + WHERE group_key = 'project_rules' AND item_key = :item_key + """ + ), + {"item_key": normalized_item_key}, + ).mappings().first() + if not existing: + raise ValueError("존재하지 않는 런타임 설정입니다.") + conn.execute( + text( + """ + UPDATE app_option_items + SET value_text = :value_text, + updated_at = CURRENT_TIMESTAMP + WHERE group_key = 'project_rules' AND item_key = :item_key + """ + ), + { + "item_key": normalized_item_key, + "value_text": normalize_text(value_text), + }, + ) + load_app_config.cache_clear() def count_transactions() -> int: @@ -399,6 +1235,22 @@ def existing_billing_source_files() -> set[str]: return {normalize_text(row[0]) for row in rows if normalize_text(row[0])} +def existing_change_contract_summary_source_files() -> set[str]: + with engine.begin() as conn: + rows = conn.execute( + text("SELECT DISTINCT source_file FROM project_contract_change_summary WHERE COALESCE(source_file, '') <> ''") + ).fetchall() + return {normalize_text(row[0]) for row in rows if normalize_text(row[0])} + + +def existing_change_contract_round_source_files() -> set[str]: + with engine.begin() as conn: + rows = conn.execute( + text("SELECT DISTINCT source_file FROM project_contract_change_round WHERE COALESCE(source_file, '') <> ''") + ).fetchall() + return {normalize_text(row[0]) for row in rows if normalize_text(row[0])} + + def workbook_row_values(sheet: Any, row_number: int) -> list[str]: return [normalize_text(sheet.cell(row_number, column).value) for column in range(1, sheet.max_column + 1)] @@ -410,10 +1262,18 @@ def detect_excel_import_kind(workbook: Any, filename: str = "") -> str: filename = normalize_text(filename) if {"총괄코드", "총 계약금액", "한맥계약금액"}.issubset(set(row1)): return "contract_status" + if {"총괄코드", "최초계약금액", "이전계약금액", "변경계약금액", "증감액"}.issubset(set(row1)): + return "change_contract_summary" + if {"차수코드", "당초계약금액", "변경계약금액", "증감액"}.issubset(set(row1)): + return "change_contract_round" if {"차수코드", "차수사업명", "청구금액", "수금금액"}.issubset(set(row5)): return "billing_status" if "계약현황" in filename: return "contract_status" + if "변경계약금액현황" in filename and "총괄" in filename: + return "change_contract_summary" + if "변경계약금액현황" in filename and "차수" in filename: + return "change_contract_round" if "기성청구현황" in filename: return "billing_status" return "transactions" @@ -590,7 +1450,242 @@ def import_billing_status_workbook(workbook: Any, source_file: str) -> int: return inserted +def import_change_contract_summary_workbook(workbook: Any, source_file: str) -> int: + sheet = workbook.active + with engine.begin() as conn: + conn.execute( + text("DELETE FROM project_contract_change_summary WHERE source_file = :source_file"), + {"source_file": source_file}, + ) + inserted = 0 + current_department = "" + for row in sheet.iter_rows(min_row=2, values_only=True): + values = list(row) + if not values or all(value in (None, "") for value in values): + continue + first_value = normalize_text(values[0] if len(values) > 0 else "") + if "소 계" in first_value or first_value.startswith("<"): + continue + if first_value: + current_department = first_value + raw_summary_code = normalize_text(values[2] if len(values) > 2 else "").replace("\xa0", "") + support_dept_name = normalize_text(values[3] if len(values) > 3 else "") + if not raw_summary_code or not support_dept_name: + continue + payload = { + "raw_summary_code": "".join(character for character in raw_summary_code if character.isdigit()), + "normalized_title": normalize_project_title_for_linking(support_dept_name), + "owner_department": current_department, + "business_division": normalize_text(values[1] if len(values) > 1 else ""), + "support_dept_name": support_dept_name, + "change_date": normalize_date_text(values[4] if len(values) > 4 else ""), + "client_name": normalize_text(values[5] if len(values) > 5 else ""), + "original_contract_period": normalize_text(values[6] if len(values) > 6 else ""), + "changed_project_end_date": normalize_date_text(values[7] if len(values) > 7 else ""), + "initial_contract_amount": normalize_amount(values[8] if len(values) > 8 else 0), + "previous_contract_amount": normalize_amount(values[9] if len(values) > 9 else 0), + "changed_contract_amount": normalize_amount(values[10] if len(values) > 10 else 0), + "delta_amount": normalize_amount(values[11] if len(values) > 11 else 0), + "source_file": source_file, + } + conn.execute( + text( + """ + INSERT INTO project_contract_change_summary ( + raw_summary_code, normalized_title, owner_department, business_division, + support_dept_name, change_date, client_name, original_contract_period, + changed_project_end_date, initial_contract_amount, previous_contract_amount, + changed_contract_amount, delta_amount, source_file, updated_at + ) VALUES ( + :raw_summary_code, :normalized_title, :owner_department, :business_division, + :support_dept_name, :change_date, :client_name, :original_contract_period, + :changed_project_end_date, :initial_contract_amount, :previous_contract_amount, + :changed_contract_amount, :delta_amount, :source_file, CURRENT_TIMESTAMP + ) + """ + ), + payload, + ) + inserted += 1 + sync_auto_project_related_links() + return inserted + + +def import_change_contract_round_workbook(workbook: Any, source_file: str) -> int: + sheet = workbook.active + with engine.begin() as conn: + conn.execute( + text("DELETE FROM project_contract_change_round WHERE source_file = :source_file"), + {"source_file": source_file}, + ) + inserted = 0 + current_department = "" + for row in sheet.iter_rows(min_row=2, values_only=True): + values = list(row) + if not values or all(value in (None, "") for value in values): + continue + first_value = normalize_text(values[0] if len(values) > 0 else "") + if "소 계" in first_value or first_value.startswith("<"): + continue + if first_value: + current_department = first_value + raw_round_code = normalize_text(values[2] if len(values) > 2 else "").replace("\xa0", "") + support_dept_name = normalize_text(values[3] if len(values) > 3 else "") + support_dept_code = normalize_project_code(raw_round_code) + if not support_dept_code or not support_dept_name: + continue + payload = { + "support_dept_code": support_dept_code, + "raw_round_code": raw_round_code, + "normalized_title": normalize_project_title_for_linking(support_dept_name), + "owner_department": current_department, + "business_division": normalize_text(values[1] if len(values) > 1 else ""), + "support_dept_name": support_dept_name, + "change_date": normalize_date_text(values[4] if len(values) > 4 else ""), + "client_name": normalize_text(values[5] if len(values) > 5 else ""), + "original_contract_period": normalize_text(values[6] if len(values) > 6 else ""), + "changed_project_end_date": normalize_date_text(values[7] if len(values) > 7 else ""), + "initial_contract_amount": normalize_amount(values[8] if len(values) > 8 else 0), + "changed_contract_amount": normalize_amount(values[9] if len(values) > 9 else 0), + "delta_amount": normalize_amount(values[10] if len(values) > 10 else 0), + "source_file": source_file, + } + conn.execute( + text( + """ + INSERT INTO project_contract_change_round ( + support_dept_code, raw_round_code, normalized_title, owner_department, + business_division, support_dept_name, change_date, client_name, + original_contract_period, changed_project_end_date, initial_contract_amount, + changed_contract_amount, delta_amount, source_file, updated_at + ) VALUES ( + :support_dept_code, :raw_round_code, :normalized_title, :owner_department, + :business_division, :support_dept_name, :change_date, :client_name, + :original_contract_period, :changed_project_end_date, :initial_contract_amount, + :changed_contract_amount, :delta_amount, :source_file, CURRENT_TIMESTAMP + ) + """ + ), + payload, + ) + inserted += 1 + sync_auto_project_related_links() + return inserted + + +def select_latest_contract_change_entry(rows: list[dict[str, Any]]) -> dict[str, Any]: + def sort_key(row: dict[str, Any]) -> tuple[str, int, float, float]: + return ( + normalize_text(row.get("change_date")), + 1 if normalize_amount(row.get("changed_contract_amount")) > 0 else 0, + abs(normalize_amount(row.get("delta_amount"))), + normalize_amount(row.get("changed_contract_amount")), + ) + + return max(rows, key=sort_key) if rows else {} + + +def get_project_contract_change_maps() -> tuple[dict[str, dict[str, Any]], dict[str, dict[str, Any]], dict[str, str], dict[str, str]]: + with engine.begin() as conn: + summary_rows = conn.execute( + text("SELECT * FROM project_contract_change_summary ORDER BY raw_summary_code, change_date, id") + ).mappings().all() + round_rows = conn.execute( + text("SELECT * FROM project_contract_change_round ORDER BY support_dept_code, change_date, id") + ).mappings().all() + existing_codes = { + normalize_text(row[0]) + for row in conn.execute( + text( + """ + SELECT DISTINCT support_dept_code FROM transactions WHERE COALESCE(support_dept_code, '') <> '' + UNION + SELECT DISTINCT support_dept_code FROM project_status WHERE COALESCE(support_dept_code, '') <> '' + UNION + SELECT DISTINCT support_dept_code FROM project_contract_info WHERE COALESCE(support_dept_code, '') <> '' + UNION + SELECT DISTINCT support_dept_code FROM project_billing_entries WHERE COALESCE(support_dept_code, '') <> '' + UNION + SELECT DISTINCT support_dept_code FROM project_contract_change_round WHERE COALESCE(support_dept_code, '') <> '' + """ + ) + ).fetchall() + if normalize_text(row[0]) + } + contracted_codes = { + normalize_text(row[0]) + for row in conn.execute( + text( + """ + SELECT support_dept_code + FROM project_contract_info + WHERE COALESCE(hanmac_contract_amount, 0) > 0 + UNION + SELECT support_dept_code + FROM project_billing_entries + GROUP BY support_dept_code + HAVING SUM(COALESCE(collected_amount, 0)) > 0 OR MAX(COALESCE(contract_amount, 0)) > 0 + """ + ) + ).fetchall() + if normalize_text(row[0]) + } + + summary_by_title: dict[str, list[dict[str, Any]]] = {} + for row in summary_rows: + title_key = normalize_text(row["normalized_title"]) + if title_key: + summary_by_title.setdefault(title_key, []).append(dict(row)) + latest_summary_by_title = { + title_key: select_latest_contract_change_entry(rows) + for title_key, rows in summary_by_title.items() + if rows + } + + round_by_code: dict[str, list[dict[str, Any]]] = {} + round_codes_by_title: dict[str, set[str]] = {} + for row in round_rows: + code = normalize_text(row["support_dept_code"]) + title_key = normalize_text(row["normalized_title"]) + if code: + round_by_code.setdefault(code, []).append(dict(row)) + if title_key and code: + round_codes_by_title.setdefault(title_key, set()).add(code) + latest_round_by_code = { + code: select_latest_contract_change_entry(rows) + for code, rows in round_by_code.items() + if rows + } + + representative_by_title: dict[str, str] = {} + for title_key, codes in round_codes_by_title.items(): + sorted_codes = sorted(codes) + if not sorted_codes: + continue + + def representative_rank(code: str) -> tuple[int, str]: + if code in contracted_codes: + return (0, code) + if code.startswith("Y") and code in existing_codes: + return (1, code) + if code.startswith("Z"): + return (2, code) + if code.startswith("X"): + return (3, code) + return (4, code) + + representative_by_title[title_key] = min(sorted_codes, key=representative_rank) + + title_by_code = { + code: title_key + for title_key, codes in round_codes_by_title.items() + for code in codes + } + return latest_summary_by_title, latest_round_by_code, representative_by_title, title_by_code + + def refresh_contract_review_tags() -> None: + latest_summary_by_title, latest_round_by_code, _, title_by_code = get_project_contract_change_maps() with engine.begin() as conn: billing_rows = conn.execute( text( @@ -607,20 +1702,32 @@ def refresh_contract_review_tags() -> None: if normalize_text(row["support_dept_code"]) } contract_rows = conn.execute( - text("SELECT support_dept_code, hanmac_contract_amount FROM project_contract_info") + text("SELECT support_dept_code, support_dept_name, hanmac_contract_amount FROM project_contract_info") ).mappings().all() for row in contract_rows: support_dept_code = normalize_text(row["support_dept_code"]) hanmac_contract_amount = normalize_amount(row["hanmac_contract_amount"]) billing_contract_amount = normalize_amount(billing_map.get(support_dept_code)) + title_key = title_by_code.get(support_dept_code) or normalize_project_title_for_linking(row.get("support_dept_name")) + latest_changed_contract_amount = ( + normalize_amount((latest_summary_by_title.get(title_key) or {}).get("changed_contract_amount")) + or normalize_amount((latest_round_by_code.get(support_dept_code) or {}).get("changed_contract_amount")) + ) + comparison_contract_amount = latest_changed_contract_amount or hanmac_contract_amount review_tag = "" review_note = "" - if billing_contract_amount and abs(hanmac_contract_amount - billing_contract_amount) > 0.5: + if billing_contract_amount and abs(comparison_contract_amount - billing_contract_amount) > 0.5: review_tag = "변경계약 검토 필요" - review_note = ( - f"계약현황 한맥계약금액 {hanmac_contract_amount:,.0f}원 / " - f"기성청구현황 계약금액 {billing_contract_amount:,.0f}원" - ) + if latest_changed_contract_amount: + review_note = ( + f"변경계약금액 {latest_changed_contract_amount:,.0f}원 / " + f"기성청구현황 계약금액 {billing_contract_amount:,.0f}원" + ) + else: + review_note = ( + f"계약현황 한맥계약금액 {hanmac_contract_amount:,.0f}원 / " + f"기성청구현황 계약금액 {billing_contract_amount:,.0f}원" + ) conn.execute( text( """ @@ -650,6 +1757,15 @@ def sync_auto_project_related_links() -> None: """ ) ).mappings().all() + change_round_rows = conn.execute( + text( + """ + SELECT support_dept_code, normalized_title, support_dept_name + FROM project_contract_change_round + WHERE COALESCE(support_dept_code, '') <> '' + """ + ) + ).mappings().all() existing_codes = { normalize_text(row[0]) for row in conn.execute( @@ -666,6 +1782,58 @@ def sync_auto_project_related_links() -> None: UNION SELECT DISTINCT support_dept_code FROM project_billing_entries WHERE COALESCE(support_dept_code, '') <> '' + UNION + SELECT DISTINCT support_dept_code FROM project_contract_change_round + WHERE COALESCE(support_dept_code, '') <> '' + """ + ) + ).fetchall() + if normalize_text(row[0]) + } + project_names = { + normalize_text(row[0]): normalize_text(row[1]) + for row in conn.execute( + text( + """ + WITH project_names AS ( + SELECT support_dept_code, MAX(support_dept_name) AS support_dept_name + FROM transactions + WHERE COALESCE(support_dept_code, '') <> '' + GROUP BY support_dept_code + UNION + SELECT support_dept_code, support_dept_name + FROM project_contract_info + WHERE COALESCE(support_dept_code, '') <> '' + UNION + SELECT support_dept_code, support_dept_name + FROM project_billing_entries + WHERE COALESCE(support_dept_code, '') <> '' + UNION + SELECT support_dept_code, support_dept_name + FROM project_contract_change_round + WHERE COALESCE(support_dept_code, '') <> '' + ) + SELECT support_dept_code, support_dept_name + FROM project_names + WHERE COALESCE(support_dept_code, '') <> '' + """ + ) + ).fetchall() + if normalize_text(row[0]) + } + contracted_codes = { + normalize_text(row[0]) + for row in conn.execute( + text( + """ + SELECT support_dept_code + FROM project_contract_info + WHERE COALESCE(hanmac_contract_amount, 0) > 0 + UNION + SELECT support_dept_code + FROM project_billing_entries + GROUP BY support_dept_code + HAVING SUM(COALESCE(collected_amount, 0)) > 0 OR MAX(COALESCE(contract_amount, 0)) > 0 """ ) ).fetchall() @@ -686,11 +1854,116 @@ def sync_auto_project_related_links() -> None: if round_code and round_code in existing_codes: cluster.add(round_code) - conn.execute(text("DELETE FROM project_related_links WHERE COALESCE(link_source, 'manual') = 'auto'")) - for cluster_codes in cluster_map.values(): + change_round_title_groups: dict[str, set[str]] = {} + change_contract_codes: set[str] = set() + for row in change_round_rows: + code = normalize_text(row["support_dept_code"]) + title_key = normalize_text(row["normalized_title"]) or normalize_project_title_for_linking(row["support_dept_name"]) + if not code or not title_key: + continue + change_contract_codes.add(code) + change_round_title_groups.setdefault(title_key, set()).add(code) + + project_codes_by_title: dict[str, set[str]] = {} + for code, name in project_names.items(): + title_key = normalize_project_title_for_linking(name) + if title_key: + project_codes_by_title.setdefault(title_key, set()).add(code) + + for title_key, round_codes in change_round_title_groups.items(): + cluster_codes = set(round_codes) + cluster_codes.update(project_codes_by_title.get(title_key, set())) + cluster_map.setdefault(f"change_round::{title_key}", set()).update( + code for code in cluster_codes if code in existing_codes + ) + + title_groups: dict[str, set[str]] = {} + for code, name in project_names.items(): + normalized_title = normalize_project_title_for_linking(name) + if len(normalized_title) < 8: + continue + title_groups.setdefault(normalized_title, set()).add(code) + + def choose_representative(codes: set[str]) -> str: + sorted_codes = sorted(codes) + contracted_non_special = [ + code + for code in sorted_codes + if code in contracted_codes and not code.startswith(("X", "Z")) + ] + if contracted_non_special: + return contracted_non_special[0] + plain_non_special = [ + code + for code in sorted_codes + if not code.startswith(("X", "Z")) + and not has_project_variant_marker(project_names.get(code, "")) + ] + if plain_non_special: + return plain_non_special[0] + contracted_any = [code for code in sorted_codes if code in contracted_codes] + if contracted_any: + return contracted_any[0] + return sorted_codes[0] if sorted_codes else "" + + for normalized_title, codes in title_groups.items(): + if len(codes) < 2: + continue + representative_code = choose_representative(codes) + if not representative_code: + continue + variant_codes = { + code + for code in codes + if code != representative_code and ( + code.startswith(("X", "Z")) + or has_project_variant_marker(project_names.get(code, "")) + ) + } + if not variant_codes: + continue + cluster_map.setdefault(f"title::{normalized_title}", set()).update({representative_code, *variant_codes}) + + title_items = sorted( + ((title_key, set(codes)) for title_key, codes in title_groups.items() if len(title_key) >= 8), + key=lambda item: len(item[0]), + ) + for index, (base_title, base_codes) in enumerate(title_items): + for related_title, related_codes in title_items[index + 1:]: + if base_title not in related_title and related_title not in base_title: + continue + merged_codes = set(base_codes) | set(related_codes) + representative_code = choose_representative(merged_codes) + if not representative_code: + continue + variant_codes = { + code + for code in merged_codes + if code != representative_code and ( + code.startswith(("X", "Z")) + or has_project_variant_marker(project_names.get(code, "")) + ) + } + if not variant_codes: + continue + cluster_map.setdefault( + f"title_fuzzy::{base_title if len(base_title) <= len(related_title) else related_title}", + set(), + ).update({representative_code, *variant_codes}) + + conn.execute(text("DELETE FROM project_related_links WHERE COALESCE(link_source, 'manual') LIKE 'auto%'")) + for cluster_key, cluster_codes in cluster_map.items(): normalized_cluster = sorted(cluster_codes) if len(normalized_cluster) < 2: continue + if str(cluster_key).startswith("change_round::"): + link_source = "auto_change_contract" + elif str(cluster_key).startswith("title_fuzzy::"): + link_source = "auto_title_fuzzy" + elif str(cluster_key).startswith("title::"): + link_source = "auto_title" + else: + link_source = "auto_round" for base_code in normalized_cluster: for related_code in normalized_cluster: if base_code == related_code: @@ -706,7 +1979,7 @@ def sync_auto_project_related_links() -> None: ) VALUES ( :base_support_dept_code, :related_support_dept_code, - 'auto', + :link_source, CURRENT_TIMESTAMP ) ON CONFLICT(base_support_dept_code, related_support_dept_code) DO UPDATE SET @@ -717,15 +1990,107 @@ def sync_auto_project_related_links() -> None: { "base_support_dept_code": base_code, "related_support_dept_code": related_code, + "link_source": link_source, }, ) + for base_code, related_code, link_source in conn.execute( + text( + """ + SELECT base_support_dept_code, related_support_dept_code, COALESCE(link_source, 'manual') AS link_source + FROM project_related_links + WHERE COALESCE(link_source, 'manual') LIKE 'auto_title%' + """ + ) + ).fetchall(): + normalized_base = normalize_text(base_code) + normalized_related = normalize_text(related_code) + if ( + normalized_base in change_contract_codes + and normalized_related not in change_contract_codes + and not normalized_related.startswith("X") + ) or ( + normalized_related in change_contract_codes + and normalized_base not in change_contract_codes + and not normalized_base.startswith("X") + ): + conn.execute( + text( + """ + DELETE FROM project_related_links + WHERE base_support_dept_code = :base_support_dept_code + AND related_support_dept_code = :related_support_dept_code + """ + ), + { + "base_support_dept_code": normalized_base, + "related_support_dept_code": normalized_related, + }, + ) + + x_codes_linked_to_change: set[str] = set() + for base_code, related_code in conn.execute( + text( + """ + SELECT base_support_dept_code, related_support_dept_code + FROM project_related_links + WHERE ( + base_support_dept_code LIKE 'X%' AND related_support_dept_code <> '' + ) OR ( + related_support_dept_code LIKE 'X%' AND base_support_dept_code <> '' + ) + """ + ) + ).fetchall(): + normalized_base = normalize_text(base_code) + normalized_related = normalize_text(related_code) + if normalized_base.startswith("X") and normalized_related in change_contract_codes: + x_codes_linked_to_change.add(normalized_base) + if normalized_related.startswith("X") and normalized_base in change_contract_codes: + x_codes_linked_to_change.add(normalized_related) + + for base_code, related_code in conn.execute( + text( + """ + SELECT base_support_dept_code, related_support_dept_code + FROM project_related_links + WHERE COALESCE(link_source, 'manual') LIKE 'auto_title%' + """ + ) + ).fetchall(): + normalized_base = normalize_text(base_code) + normalized_related = normalize_text(related_code) + should_delete = ( + normalized_base in x_codes_linked_to_change + and normalized_related not in change_contract_codes + and not normalized_related.startswith("X") + ) or ( + normalized_related in x_codes_linked_to_change + and normalized_base not in change_contract_codes + and not normalized_base.startswith("X") + ) + if should_delete: + conn.execute( + text( + """ + DELETE FROM project_related_links + WHERE base_support_dept_code = :base_support_dept_code + AND related_support_dept_code = :related_support_dept_code + """ + ), + { + "base_support_dept_code": normalized_base, + "related_support_dept_code": normalized_related, + }, + ) + @app.on_event("startup") def on_startup() -> None: init_db() auto_import_project_excels() sync_auto_project_related_links() + normalize_all_collection_entry_storage() logger.info("DB ready at %s", DB_PATH) @@ -774,6 +2139,42 @@ def normalize_date_text(value: Any) -> str: return text_value +def normalize_collection_progress_type(value: Any) -> str: + text_value = normalize_text(value) + if not text_value: + return "" + if "준공" in text_value: + return "준공금" + if any(keyword in text_value for keyword in ("선수", "선급")): + return "선급금" + if "기성" in text_value: + return "기성금" + return "" + + +def normalize_collection_billing_type(value: Any) -> str: + text_value = normalize_text(value) + if not text_value: + return "" + if "계약" in text_value: + return "계약분" + if "기타" in text_value: + return "기타" + return "" + + +def normalize_collection_entry_fields(row: dict[str, Any]) -> dict[str, Any]: + normalized = dict(row) + progress_type = normalize_collection_progress_type(normalized.get("progress_type")) + raw_billing_type = normalize_text(normalized.get("billing_type")) + billing_type = normalize_collection_billing_type(raw_billing_type) + if not progress_type and raw_billing_type in {"선수금", "선급금", "기성금", "준공금"}: + progress_type = normalize_collection_progress_type(raw_billing_type) + normalized["progress_type"] = progress_type + normalized["billing_type"] = billing_type or ("계약분" if progress_type else "") + return normalized + + def normalize_project_code(value: Any, default_prefix: str = "Y") -> str: text_value = normalize_text(value).replace("\u3164", "").replace("\xa0", "") prefix = "" @@ -797,6 +2198,56 @@ def normalize_round_value(value: Any) -> str: return text_value +PROJECT_TITLE_LINK_STRIP_PATTERNS = ( + r"\((?:\d+\s*차|[가-힣A-Za-z0-9\s]*변경[가-힣A-Za-z0-9\s]*|[가-힣A-Za-z0-9\s]*보완[가-힣A-Za-z0-9\s]*|[가-힣A-Za-z0-9\s]*입찰[가-힣A-Za-z0-9\s]*|가칭)\)", + r"\d+\s*차", + r"변경", + r"보완", + r"입찰", + r"기술제안", + r"실시설계", + r"조사[·ㆍ]설계", + r"조사설계", + r"기본\s*및\s*실시설계", + r"기본및실시설계", + r"기본설계", + r"설계", + r"용역", + r"시공단계", + r"건설사업관리", + r"가칭", +) + + +def normalize_project_title_for_linking(value: Any) -> str: + text_value = normalize_text(value) + if not text_value: + return "" + normalized = text_value + for pattern in PROJECT_TITLE_LINK_STRIP_PATTERNS: + normalized = re.sub(pattern, "", normalized, flags=re.IGNORECASE) + normalized = re.sub(r"[^가-힣A-Za-z0-9]", "", normalized) + return normalized + + +def has_project_variant_marker(value: Any) -> bool: + text_value = normalize_text(value) + if not text_value: + return False + return bool(re.search(r"(\d+\s*차|[nN]\s*차|변경|보완|지연보상금|가칭|연차|년분)", text_value)) + + +def classify_special_x_project(name: Any) -> str: + normalized_name = normalize_text(name) + if not normalized_name: + return "precontract" + rules = get_special_x_classification_rules() + for category_key, keywords in rules.items(): + if any(keyword in normalized_name for keyword in keywords): + return category_key + return "precontract" + + def decode_json_rows(value: Any) -> list[dict[str, Any]]: text_value = normalize_text(value) if not text_value: @@ -812,6 +2263,786 @@ def encode_json_rows(rows: list[dict[str, Any]]) -> str: return json.dumps(rows, ensure_ascii=False) +def normalize_collection_entry_row(row: dict[str, Any]) -> dict[str, Any]: + normalized = { + "vendor": clean_row_text(row.get("vendor")), + "progress_type": clean_row_text(row.get("progress_type")), + "billing_round": normalize_round_value(row.get("billing_round")), + "billing_type": clean_row_text(row.get("billing_type")), + "billing_date": normalize_date_text(row.get("billing_date")), + "billed_amount": normalize_amount(row.get("billed_amount")), + "round": normalize_round_value(row.get("round")), + "date": normalize_date_text(row.get("date")), + "due_date": normalize_date_text(row.get("due_date")), + "amount": normalize_amount(row.get("amount")), + "balance_amount": normalize_amount(row.get("balance_amount")), + "collection_rate": normalize_amount(row.get("collection_rate")), + "note": clean_row_text(row.get("note")), + } + return normalize_collection_entry_fields(normalized) + + +def normalize_task_plan_entry_row(row: dict[str, Any]) -> dict[str, Any]: + return { + "group": clean_row_text(row.get("group")), + "dept_name": clean_row_text(row.get("dept_name")), + "work_name": clean_row_text(row.get("work_name")), + "amount": normalize_amount(row.get("amount")), + } + + +def normalize_exec_budget_entry_row(row: dict[str, Any]) -> dict[str, Any]: + return { + "group": clean_row_text(row.get("group")), + "grade": clean_row_text(row.get("grade")), + "hours": clean_row_text(row.get("hours")), + "dept_name": clean_row_text(row.get("dept_name")), + "work_name": clean_row_text(row.get("work_name")), + "account_code": clean_row_text(row.get("account_code")), + "account_name": clean_row_text(row.get("account_name")), + "amount": normalize_amount(row.get("amount")), + } + + +def normalize_actual_input_entry_row(row: dict[str, Any]) -> dict[str, Any]: + return { + "group": clean_row_text(row.get("group")), + "grade": clean_row_text(row.get("grade")), + "minutes": clean_row_text(row.get("minutes")), + "label": clean_row_text(row.get("label")), + "reference": clean_row_text(row.get("reference")), + "note": clean_row_text(row.get("note")), + "amount": normalize_amount(row.get("amount")), + } + + +def extract_project_status_entry_sets(row: dict[str, Any]) -> dict[str, list[dict[str, Any]]]: + collection_entries = [normalize_collection_entry_row(item) for item in decode_json_rows(row.get("collection_entries_json"))] + task_plan_entries = [normalize_task_plan_entry_row(item) for item in decode_json_rows(row.get("task_plan_entries_json"))] + exec_budget_entries = [normalize_exec_budget_entry_row(item) for item in decode_json_rows(row.get("exec_budget_entries_json"))] + actual_input_entries = [normalize_actual_input_entry_row(item) for item in decode_json_rows(row.get("actual_input_entries_json"))] + + if not collection_entries and normalize_amount(row.get("collection_amount")): + collection_entries = [ + normalize_collection_entry_row( + { + "vendor": "", + "round": "", + "amount": row.get("collection_amount", ""), + "date": "", + "due_date": "", + "note": "기존 수기 입력값", + } + ) + ] + + if not task_plan_entries: + fallback_task_rows = [] + if normalize_amount(row.get("task_plan_department_budget")): + fallback_task_rows.append( + { + "group": "department", + "dept_name": "기존 부서별 배분", + "work_name": "", + "amount": row.get("task_plan_department_budget", ""), + } + ) + if normalize_amount(row.get("task_plan_outsource_budget")): + fallback_task_rows.append( + { + "group": "outsource", + "dept_name": "기존 외주비", + "work_name": row.get("task_plan_outsource_detail", ""), + "amount": row.get("task_plan_outsource_budget", ""), + } + ) + if normalize_amount(row.get("task_plan_joint_operating_cost")): + fallback_task_rows.append( + { + "group": "joint", + "dept_name": "기존 합사운영비", + "work_name": "", + "amount": row.get("task_plan_joint_operating_cost", ""), + } + ) + task_plan_entries = [normalize_task_plan_entry_row(item) for item in fallback_task_rows] + + if not exec_budget_entries: + fallback_exec_rows = [] + if normalize_amount(row.get("exec_budget_labor_by_grade")): + fallback_exec_rows.append( + { + "group": "labor", + "grade": "기존 인건비", + "hours": "", + "amount": row.get("exec_budget_labor_by_grade", ""), + } + ) + if normalize_amount(row.get("exec_budget_outsource")): + fallback_exec_rows.append( + { + "group": "outsource", + "dept_name": "기존 외주비", + "work_name": "", + "amount": row.get("exec_budget_outsource", ""), + } + ) + if normalize_amount(row.get("exec_budget_cost_plan")): + fallback_exec_rows.append( + { + "group": "cost_plan", + "account_code": "기존", + "account_name": "비용계획", + "amount": row.get("exec_budget_cost_plan", ""), + } + ) + exec_budget_entries = [normalize_exec_budget_entry_row(item) for item in fallback_exec_rows] + + if not actual_input_entries and normalize_amount(row.get("item_investment")): + actual_input_entries = [ + normalize_actual_input_entry_row( + { + "reference": "", + "amount": row.get("item_investment", ""), + "note": "기존 항목별투입액", + } + ) + ] + + return { + "collection_entries": collection_entries, + "task_plan_entries": task_plan_entries, + "exec_budget_entries": exec_budget_entries, + "actual_input_entries": actual_input_entries, + } + + +def replace_project_status_child_entries( + conn: Any, + support_dept_code: str, + collection_entries: list[dict[str, Any]], + task_plan_entries: list[dict[str, Any]], + exec_budget_entries: list[dict[str, Any]], + actual_input_entries: list[dict[str, Any]], +) -> None: + conn.execute( + text("DELETE FROM project_collection_entries WHERE support_dept_code = :support_dept_code"), + {"support_dept_code": support_dept_code}, + ) + conn.execute( + text("DELETE FROM project_task_plan_entries WHERE support_dept_code = :support_dept_code"), + {"support_dept_code": support_dept_code}, + ) + conn.execute( + text("DELETE FROM project_exec_budget_entries WHERE support_dept_code = :support_dept_code"), + {"support_dept_code": support_dept_code}, + ) + conn.execute( + text("DELETE FROM project_actual_input_entries WHERE support_dept_code = :support_dept_code"), + {"support_dept_code": support_dept_code}, + ) + + for position, row in enumerate(collection_entries): + normalized = normalize_collection_entry_row(row) + conn.execute( + text( + """ + INSERT INTO project_collection_entries ( + support_dept_code, position, vendor, progress_type, billing_round, + billing_type, billing_date, billed_amount, round, date, due_date, + amount, balance_amount, collection_rate, note, updated_at + ) VALUES ( + :support_dept_code, :position, :vendor, :progress_type, :billing_round, + :billing_type, :billing_date, :billed_amount, :round, :date, :due_date, + :amount, :balance_amount, :collection_rate, :note, CURRENT_TIMESTAMP + ) + """ + ), + {"support_dept_code": support_dept_code, "position": position, **normalized}, + ) + + for position, row in enumerate(task_plan_entries): + normalized = normalize_task_plan_entry_row(row) + conn.execute( + text( + """ + INSERT INTO project_task_plan_entries ( + support_dept_code, position, group_name, dept_name, work_name, amount, updated_at + ) VALUES ( + :support_dept_code, :position, :group_name, :dept_name, :work_name, :amount, CURRENT_TIMESTAMP + ) + """ + ), + { + "support_dept_code": support_dept_code, + "position": position, + "group_name": normalized["group"], + "dept_name": normalized["dept_name"], + "work_name": normalized["work_name"], + "amount": normalized["amount"], + }, + ) + + for position, row in enumerate(exec_budget_entries): + normalized = normalize_exec_budget_entry_row(row) + conn.execute( + text( + """ + INSERT INTO project_exec_budget_entries ( + support_dept_code, position, group_name, grade, hours, dept_name, + work_name, account_code, account_name, amount, updated_at + ) VALUES ( + :support_dept_code, :position, :group_name, :grade, :hours, :dept_name, + :work_name, :account_code, :account_name, :amount, CURRENT_TIMESTAMP + ) + """ + ), + { + "support_dept_code": support_dept_code, + "position": position, + "group_name": normalized["group"], + "grade": normalized["grade"], + "hours": normalized["hours"], + "dept_name": normalized["dept_name"], + "work_name": normalized["work_name"], + "account_code": normalized["account_code"], + "account_name": normalized["account_name"], + "amount": normalized["amount"], + }, + ) + + for position, row in enumerate(actual_input_entries): + normalized = normalize_actual_input_entry_row(row) + conn.execute( + text( + """ + INSERT INTO project_actual_input_entries ( + support_dept_code, position, group_name, grade, minutes, label, + reference, note, amount, updated_at + ) VALUES ( + :support_dept_code, :position, :group_name, :grade, :minutes, :label, + :reference, :note, :amount, CURRENT_TIMESTAMP + ) + """ + ), + { + "support_dept_code": support_dept_code, + "position": position, + "group_name": normalized["group"], + "grade": normalized["grade"], + "minutes": normalized["minutes"], + "label": normalized["label"], + "reference": normalized["reference"], + "note": normalized["note"], + "amount": normalized["amount"], + }, + ) + + +def load_project_status_entry_maps(conn: Any) -> dict[str, dict[str, list[dict[str, Any]]]]: + result: dict[str, dict[str, list[dict[str, Any]]]] = {} + + collection_rows = conn.execute( + text( + """ + SELECT support_dept_code, position, vendor, progress_type, billing_round, + billing_type, billing_date, billed_amount, round, date, due_date, + amount, balance_amount, collection_rate, note + FROM project_collection_entries + ORDER BY support_dept_code, position, id + """ + ) + ).mappings().all() + for row in collection_rows: + code = normalize_text(row["support_dept_code"]) + result.setdefault(code, {})["collection_entries"] = result.setdefault(code, {}).get("collection_entries", []) + result[code]["collection_entries"].append( + normalize_collection_entry_row({key: row[key] for key in row.keys() if key not in {"support_dept_code", "position"}}) + ) + + task_rows = conn.execute( + text( + """ + SELECT support_dept_code, position, group_name, dept_name, work_name, amount + FROM project_task_plan_entries + ORDER BY support_dept_code, position, id + """ + ) + ).mappings().all() + for row in task_rows: + code = normalize_text(row["support_dept_code"]) + result.setdefault(code, {})["task_plan_entries"] = result.setdefault(code, {}).get("task_plan_entries", []) + result[code]["task_plan_entries"].append( + normalize_task_plan_entry_row( + { + "group": row["group_name"], + "dept_name": row["dept_name"], + "work_name": row["work_name"], + "amount": row["amount"], + } + ) + ) + + exec_rows = conn.execute( + text( + """ + SELECT support_dept_code, position, group_name, grade, hours, dept_name, + work_name, account_code, account_name, amount + FROM project_exec_budget_entries + ORDER BY support_dept_code, position, id + """ + ) + ).mappings().all() + for row in exec_rows: + code = normalize_text(row["support_dept_code"]) + result.setdefault(code, {})["exec_budget_entries"] = result.setdefault(code, {}).get("exec_budget_entries", []) + result[code]["exec_budget_entries"].append( + normalize_exec_budget_entry_row( + { + "group": row["group_name"], + "grade": row["grade"], + "hours": row["hours"], + "dept_name": row["dept_name"], + "work_name": row["work_name"], + "account_code": row["account_code"], + "account_name": row["account_name"], + "amount": row["amount"], + } + ) + ) + + actual_rows = conn.execute( + text( + """ + SELECT support_dept_code, position, group_name, grade, minutes, label, + reference, note, amount + FROM project_actual_input_entries + ORDER BY support_dept_code, position, id + """ + ) + ).mappings().all() + for row in actual_rows: + code = normalize_text(row["support_dept_code"]) + result.setdefault(code, {})["actual_input_entries"] = result.setdefault(code, {}).get("actual_input_entries", []) + result[code]["actual_input_entries"].append( + normalize_actual_input_entry_row( + { + "group": row["group_name"], + "grade": row["grade"], + "minutes": row["minutes"], + "label": row["label"], + "reference": row["reference"], + "note": row["note"], + "amount": row["amount"], + } + ) + ) + + return result + + +def ensure_project_entry_set(entry_set: dict[str, list[dict[str, Any]]] | None) -> dict[str, list[dict[str, Any]]]: + source = entry_set or {} + return { + "collection_entries": list(source.get("collection_entries", [])), + "task_plan_entries": list(source.get("task_plan_entries", [])), + "exec_budget_entries": list(source.get("exec_budget_entries", [])), + "actual_input_entries": list(source.get("actual_input_entries", [])), + } + + +def migrate_project_status_entries(conn: Any) -> None: + migrated_codes = { + normalize_text(row[0]) + for row in conn.execute( + text( + """ + SELECT DISTINCT support_dept_code + FROM ( + SELECT support_dept_code FROM project_collection_entries + UNION ALL + SELECT support_dept_code FROM project_task_plan_entries + UNION ALL + SELECT support_dept_code FROM project_exec_budget_entries + UNION ALL + SELECT support_dept_code FROM project_actual_input_entries + ) + WHERE COALESCE(support_dept_code, '') <> '' + """ + ) + ).fetchall() + if normalize_text(row[0]) + } + source_rows = conn.execute( + text( + """ + SELECT support_dept_code, + collection_entries_json, + task_plan_entries_json, + exec_budget_entries_json, + actual_input_entries_json, + collection_amount, + task_plan_department_budget, + task_plan_outsource_budget, + task_plan_outsource_detail, + task_plan_joint_operating_cost, + exec_budget_labor_by_grade, + exec_budget_outsource, + exec_budget_cost_plan, + item_investment + FROM project_status + WHERE COALESCE(support_dept_code, '') <> '' + """ + ) + ).mappings().all() + for row in source_rows: + support_dept_code = normalize_text(row["support_dept_code"]) + if not support_dept_code or support_dept_code in migrated_codes: + continue + entry_sets = extract_project_status_entry_sets(dict(row)) + replace_project_status_child_entries( + conn, + support_dept_code, + entry_sets["collection_entries"], + entry_sets["task_plan_entries"], + entry_sets["exec_budget_entries"], + entry_sets["actual_input_entries"], + ) + + +def migrate_project_basic_info(conn: Any) -> None: + existing_codes = { + normalize_text(row[0]) + for row in conn.execute( + text("SELECT support_dept_code FROM project_basic_info WHERE COALESCE(support_dept_code, '') <> ''") + ).fetchall() + if normalize_text(row[0]) + } + source_rows = conn.execute( + text( + """ + SELECT support_dept_code, support_dept_name, contract_amount, project_type, + expected_as_rate, expected_sga_rate, expected_as_cost, expected_sga_budget, + exec_labor_rates_json, change_round, project_start_date, project_end_date, + completion_status, notes, last_editor_session_id, last_client_submitted_at + FROM project_status + WHERE COALESCE(support_dept_code, '') <> '' + """ + ) + ).mappings().all() + for row in source_rows: + support_dept_code = normalize_text(row["support_dept_code"]) + if not support_dept_code or support_dept_code in existing_codes: + continue + conn.execute( + text( + """ + INSERT INTO project_basic_info ( + support_dept_code, support_dept_name, contract_amount, project_type, + expected_as_rate, expected_sga_rate, expected_as_cost, expected_sga_budget, + exec_labor_rates_json, change_round, project_start_date, project_end_date, + completion_status, notes, last_editor_session_id, last_client_submitted_at, + updated_at + ) VALUES ( + :support_dept_code, :support_dept_name, :contract_amount, :project_type, + :expected_as_rate, :expected_sga_rate, :expected_as_cost, :expected_sga_budget, + :exec_labor_rates_json, :change_round, :project_start_date, :project_end_date, + :completion_status, :notes, :last_editor_session_id, :last_client_submitted_at, + CURRENT_TIMESTAMP + ) + """ + ), + dict(row), + ) + + +def save_project_basic_info_section(conn: Any, payload: dict[str, Any]) -> None: + conn.execute( + text( + """ + INSERT INTO project_basic_info ( + support_dept_code, support_dept_name, contract_amount, project_type, + expected_as_rate, expected_sga_rate, expected_as_cost, expected_sga_budget, + exec_labor_rates_json, change_round, project_start_date, project_end_date, + completion_status, notes, last_editor_session_id, last_client_submitted_at, + updated_at + ) VALUES ( + :support_dept_code, :support_dept_name, :contract_amount, :project_type, + :expected_as_rate, :expected_sga_rate, :expected_as_cost, :expected_sga_budget, + :exec_labor_rates_json, :change_round, :project_start_date, :project_end_date, + :completion_status, :notes, :last_editor_session_id, :last_client_submitted_at, + CURRENT_TIMESTAMP + ) + ON CONFLICT(support_dept_code) DO UPDATE SET + support_dept_name = excluded.support_dept_name, + contract_amount = excluded.contract_amount, + 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, + exec_labor_rates_json = excluded.exec_labor_rates_json, + change_round = excluded.change_round, + project_start_date = excluded.project_start_date, + project_end_date = excluded.project_end_date, + completion_status = excluded.completion_status, + notes = excluded.notes, + last_editor_session_id = excluded.last_editor_session_id, + last_client_submitted_at = excluded.last_client_submitted_at, + updated_at = CURRENT_TIMESTAMP + """ + ), + payload, + ) + + +def load_project_status_entries_for_code(conn: Any, support_dept_code: str) -> dict[str, list[dict[str, Any]]]: + code = normalize_text(support_dept_code) + entry_map = ensure_project_entry_set(None) + if not code: + return entry_map + + collection_rows = conn.execute( + text( + """ + SELECT vendor, progress_type, billing_round, billing_type, billing_date, + billed_amount, round, date, due_date, amount, balance_amount, + collection_rate, note + FROM project_collection_entries + WHERE support_dept_code = :support_dept_code + ORDER BY position, id + """ + ), + {"support_dept_code": code}, + ).mappings().all() + entry_map["collection_entries"] = [ + normalize_collection_entry_row(dict(row)) + for row in collection_rows + ] + + task_rows = conn.execute( + text( + """ + SELECT group_name, dept_name, work_name, amount + FROM project_task_plan_entries + WHERE support_dept_code = :support_dept_code + ORDER BY position, id + """ + ), + {"support_dept_code": code}, + ).mappings().all() + entry_map["task_plan_entries"] = [ + normalize_task_plan_entry_row( + { + "group": row["group_name"], + "dept_name": row["dept_name"], + "work_name": row["work_name"], + "amount": row["amount"], + } + ) + for row in task_rows + ] + + exec_rows = conn.execute( + text( + """ + SELECT group_name, grade, hours, dept_name, work_name, account_code, account_name, amount + FROM project_exec_budget_entries + WHERE support_dept_code = :support_dept_code + ORDER BY position, id + """ + ), + {"support_dept_code": code}, + ).mappings().all() + entry_map["exec_budget_entries"] = [ + normalize_exec_budget_entry_row( + { + "group": row["group_name"], + "grade": row["grade"], + "hours": row["hours"], + "dept_name": row["dept_name"], + "work_name": row["work_name"], + "account_code": row["account_code"], + "account_name": row["account_name"], + "amount": row["amount"], + } + ) + for row in exec_rows + ] + + actual_rows = conn.execute( + text( + """ + SELECT group_name, grade, minutes, label, reference, note, amount + FROM project_actual_input_entries + WHERE support_dept_code = :support_dept_code + ORDER BY position, id + """ + ), + {"support_dept_code": code}, + ).mappings().all() + entry_map["actual_input_entries"] = [ + normalize_actual_input_entry_row( + { + "group": row["group_name"], + "grade": row["grade"], + "minutes": row["minutes"], + "label": row["label"], + "reference": row["reference"], + "note": row["note"], + "amount": row["amount"], + } + ) + for row in actual_rows + ] + return entry_map + + +def sync_project_status_cache_row(conn: Any, support_dept_code: str) -> None: + code = normalize_text(support_dept_code) + if not code: + return + basic_info = conn.execute( + text("SELECT * FROM project_basic_info WHERE support_dept_code = :support_dept_code"), + {"support_dept_code": code}, + ).mappings().first() + entry_set = load_project_status_entries_for_code(conn, code) + collection_entries = entry_set["collection_entries"] + task_plan_entries = entry_set["task_plan_entries"] + exec_budget_entries = entry_set["exec_budget_entries"] + actual_input_entries = entry_set["actual_input_entries"] + + contract_amount = normalize_amount((basic_info or {}).get("contract_amount")) + collection_amount = sum_row_amounts(collection_entries) + progress_rate = (collection_amount / contract_amount * 100) if contract_amount else 0.0 + + task_plan_department_rows = [row for row in task_plan_entries if normalize_text(row.get("group")) == "department"] + task_plan_outsource_rows = [row for row in task_plan_entries if normalize_text(row.get("group")) == "outsource"] + task_plan_joint_rows = [row for row in task_plan_entries if normalize_text(row.get("group")) == "joint"] + exec_labor_rows = [row for row in exec_budget_entries if normalize_text(row.get("group")) == "labor"] + exec_outsource_rows = [row for row in exec_budget_entries if normalize_text(row.get("group")) == "outsource"] + exec_cost_plan_rows = [row for row in exec_budget_entries if normalize_text(row.get("group")) == "cost_plan"] + item_investment = sum_row_amounts(actual_input_entries) + + support_name = normalize_text((basic_info or {}).get("support_dept_name")) + if not support_name: + support_name = normalize_text( + conn.execute( + text( + """ + SELECT support_dept_name + FROM ( + SELECT support_dept_name, 1 AS priority FROM project_contract_info WHERE support_dept_code = :support_dept_code + UNION ALL + SELECT support_dept_name, 2 AS priority FROM project_billing_entries WHERE support_dept_code = :support_dept_code + UNION ALL + SELECT support_dept_name, 3 AS priority FROM transactions WHERE support_dept_code = :support_dept_code + ) + WHERE COALESCE(support_dept_name, '') <> '' + ORDER BY priority + LIMIT 1 + """ + ), + {"support_dept_code": code}, + ).scalar() + ) + + payload = { + "support_dept_code": code, + "support_dept_name": support_name, + "progress_rate": progress_rate, + "contract_amount": contract_amount, + "collection_amount": collection_amount, + "collection_entries_json": encode_json_rows(collection_entries), + "change_round": normalize_text((basic_info or {}).get("change_round")), + "item_investment": item_investment, + "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"{normalize_text(row.get('dept_name'))} / {normalize_text(row.get('work_name'))}: {format_amount_for_text(row.get('amount'))}".strip(" /:") + 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_entries), + "exec_budget_labor_by_grade": sum_row_amounts(exec_labor_rows), + "exec_labor_rates_json": normalize_text((basic_info or {}).get("exec_labor_rates_json")) or "{}", + "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_entries), + "actual_input_entries_json": encode_json_rows(actual_input_entries), + "project_type": normalize_text((basic_info or {}).get("project_type")), + "expected_as_rate": normalize_amount((basic_info or {}).get("expected_as_rate")), + "expected_sga_rate": normalize_amount((basic_info or {}).get("expected_sga_rate")), + "expected_as_cost": normalize_amount((basic_info or {}).get("expected_as_cost")), + "expected_sga_budget": normalize_amount((basic_info or {}).get("expected_sga_budget")), + "last_editor_session_id": normalize_text((basic_info or {}).get("last_editor_session_id")), + "last_client_submitted_at": normalize_text((basic_info or {}).get("last_client_submitted_at")), + "project_start_date": normalize_text((basic_info or {}).get("project_start_date")), + "project_end_date": normalize_text((basic_info or {}).get("project_end_date")), + "completion_status": normalize_text((basic_info or {}).get("completion_status")), + "notes": normalize_text((basic_info or {}).get("notes")), + } + 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_labor_rates_json, 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_labor_rates_json, :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_labor_rates_json = excluded.exec_labor_rates_json, + 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 + """ + ), + payload, + ) + + def clean_row_text(value: Any) -> str: return normalize_text(value) @@ -988,7 +3219,8 @@ def get_project_billing_summary_map() -> dict[str, dict[str, Any]]: if support_dept_code not in result: continue result[support_dept_code]["entries"].append( - { + normalize_collection_entry_row( + { "progress_type": "", "billing_round": normalize_round_value(row["progress_round"]), "billing_type": normalize_text(row["billing_type"]), @@ -1000,7 +3232,8 @@ def get_project_billing_summary_map() -> dict[str, dict[str, Any]]: "balance_amount": normalize_amount(row["balance_amount"]), "collection_rate": normalize_amount(row["collection_rate"]), "note": normalize_text(row["note"]), - } + } + ) ) return result @@ -1009,13 +3242,31 @@ def merge_project_external_fields( item: dict[str, Any], contract_info: dict[str, Any] | None, billing_summary: dict[str, Any] | None, + latest_summary_change: dict[str, Any] | None = None, + latest_round_change: dict[str, Any] | None = None, + change_representative_code: str = "", + change_title_key: str = "", ) -> dict[str, Any]: contract_info = contract_info or {} billing_summary = billing_summary or {} support_dept_name = normalize_text(item.get("support_dept_name")) or normalize_text(contract_info.get("support_dept_name")) or normalize_text(billing_summary.get("support_dept_name")) contract_amount = normalize_amount(item.get("contract_amount")) - if not contract_amount: - contract_amount = normalize_amount(contract_info.get("hanmac_contract_amount")) or normalize_amount(billing_summary.get("contract_amount")) + latest_summary_change = latest_summary_change or {} + latest_round_change = latest_round_change or {} + latest_changed_contract_amount = ( + normalize_amount(latest_summary_change.get("changed_contract_amount")) + or normalize_amount(latest_round_change.get("changed_contract_amount")) + ) + current_code = normalize_text(item.get("support_dept_code")) + if change_representative_code and current_code == change_representative_code and latest_changed_contract_amount: + contract_amount = latest_changed_contract_amount + elif not contract_amount: + contract_amount = ( + normalize_amount(contract_info.get("hanmac_contract_amount")) + or normalize_amount(billing_summary.get("contract_amount")) + ) + if not contract_amount and latest_changed_contract_amount and (not change_representative_code or current_code == change_representative_code): + contract_amount = latest_changed_contract_amount collection_amount = normalize_amount(item.get("collection_amount")) if not collection_amount: collection_amount = normalize_amount(billing_summary.get("collected_amount")) @@ -1023,9 +3274,20 @@ def merge_project_external_fields( if not collection_entries: collection_entries = billing_summary.get("entries", []) project_start_date = normalize_text(item.get("project_start_date")) or normalize_text(contract_info.get("project_start_date")) - project_end_date = normalize_text(item.get("project_end_date")) or normalize_text(contract_info.get("project_end_date")) + project_end_date = ( + normalize_text(item.get("project_end_date")) + or normalize_text(contract_info.get("project_end_date")) + or normalize_text(latest_summary_change.get("changed_project_end_date")) + or normalize_text(latest_round_change.get("changed_project_end_date")) + ) completion_status = normalize_text(item.get("completion_status")) or normalize_text(contract_info.get("progress_status")) - project_type = normalize_text(item.get("project_type")) or normalize_text(contract_info.get("business_division")) or normalize_text(billing_summary.get("business_division")) + project_type = ( + normalize_text(item.get("project_type")) + or normalize_text(contract_info.get("business_division")) + or normalize_text(billing_summary.get("business_division")) + or normalize_text(latest_summary_change.get("business_division")) + or normalize_text(latest_round_change.get("business_division")) + ) progress_rate = normalize_amount(item.get("progress_rate")) if not progress_rate and contract_amount: progress_rate = (collection_amount / contract_amount * 100) if contract_amount else 0.0 @@ -1039,7 +3301,12 @@ def merge_project_external_fields( item["completion_status"] = completion_status item["project_type"] = project_type item["progress_rate"] = progress_rate - item["client_name"] = normalize_text(contract_info.get("client_name")) or normalize_text(billing_summary.get("client_name")) + item["client_name"] = ( + normalize_text(contract_info.get("client_name")) + or normalize_text(billing_summary.get("client_name")) + or normalize_text(latest_summary_change.get("client_name")) + or normalize_text(latest_round_change.get("client_name")) + ) item["order_method"] = normalize_text(contract_info.get("order_method")) item["joint_contract"] = normalize_text(contract_info.get("joint_contract")) item["pm_name"] = normalize_text(contract_info.get("pm_name")) @@ -1054,6 +3321,11 @@ def merge_project_external_fields( item["billed_amount"] = normalize_amount(billing_summary.get("billed_amount")) item["collection_balance_amount"] = normalize_amount(billing_summary.get("balance_amount")) item["latest_billing_date"] = normalize_text(billing_summary.get("latest_billing_date")) + item["changed_contract_amount"] = latest_changed_contract_amount + item["changed_contract_date"] = normalize_text(latest_summary_change.get("change_date")) or normalize_text(latest_round_change.get("change_date")) + item["changed_project_end_date"] = normalize_text(latest_summary_change.get("changed_project_end_date")) or normalize_text(latest_round_change.get("changed_project_end_date")) + item["change_contract_representative_code"] = change_representative_code + item["change_contract_title_key"] = change_title_key return item @@ -1073,11 +3345,15 @@ def get_data_version() -> str: project_updated = conn.execute(text("SELECT MAX(updated_at) FROM project_status")).scalar() contract_updated = conn.execute(text("SELECT MAX(updated_at) FROM project_contract_info")).scalar() billing_updated = conn.execute(text("SELECT MAX(updated_at) FROM project_billing_entries")).scalar() + change_summary_updated = conn.execute(text("SELECT MAX(updated_at) FROM project_contract_change_summary")).scalar() + change_round_updated = conn.execute(text("SELECT MAX(updated_at) FROM project_contract_change_round")).scalar() versions = [ normalize_text(transaction_updated), normalize_text(project_updated), normalize_text(contract_updated), normalize_text(billing_updated), + normalize_text(change_summary_updated), + normalize_text(change_round_updated), ] return max((version for version in versions if version), default="") @@ -1453,7 +3729,9 @@ def get_business_monthly_summary() -> list[dict[str, Any]]: def get_project_status_rows() -> list[dict[str, Any]]: contract_info_map = get_project_contract_info_map() billing_summary_map = get_project_billing_summary_map() + latest_summary_by_title, latest_round_by_code, representative_by_title, title_by_code = get_project_contract_change_maps() with engine.begin() as conn: + entry_maps = load_project_status_entry_maps(conn) rows = conn.execute( text( f""" @@ -1524,18 +3802,35 @@ def get_project_status_rows() -> list[dict[str, Any]]: seen_codes: set[str] = set() for row in rows: item = dict(row) - item["collection_entries"] = decode_json_rows(item.pop("collection_entries_json", "[]")) - item["task_plan_entries"] = decode_json_rows(item.pop("task_plan_entries_json", "[]")) - item["exec_budget_entries"] = decode_json_rows(item.pop("exec_budget_entries_json", "[]")) - item["actual_input_entries"] = decode_json_rows(item.pop("actual_input_entries_json", "[]")) + support_dept_code = normalize_text(item.get("support_dept_code")) + entry_set = ensure_project_entry_set(entry_maps.get(support_dept_code)) if support_dept_code in entry_maps else extract_project_status_entry_sets(item) + item["collection_entries"] = entry_set["collection_entries"] + item["task_plan_entries"] = entry_set["task_plan_entries"] + item["exec_budget_entries"] = entry_set["exec_budget_entries"] + item["actual_input_entries"] = entry_set["actual_input_entries"] + item.pop("collection_entries_json", None) + item.pop("task_plan_entries_json", None) + item.pop("exec_budget_entries_json", None) + item.pop("actual_input_entries_json", None) item = merge_project_external_fields( item, contract_info_map.get(normalize_text(item.get("support_dept_code"))), billing_summary_map.get(normalize_text(item.get("support_dept_code"))), + latest_summary_by_title.get(title_by_code.get(normalize_text(item.get("support_dept_code"))) or normalize_project_title_for_linking(item.get("support_dept_name"))), + latest_round_by_code.get(normalize_text(item.get("support_dept_code"))), + representative_by_title.get(title_by_code.get(normalize_text(item.get("support_dept_code"))) or normalize_project_title_for_linking(item.get("support_dept_name")), ""), + title_by_code.get(normalize_text(item.get("support_dept_code"))) or normalize_project_title_for_linking(item.get("support_dept_name")), ) seen_codes.add(normalize_text(item.get("support_dept_code"))) result.append(item) for support_dept_code in sorted((set(contract_info_map) | set(billing_summary_map)) - seen_codes): + fallback_title_key = ( + title_by_code.get(support_dept_code) + or normalize_project_title_for_linking( + contract_info_map.get(support_dept_code, {}).get("support_dept_name") + or billing_summary_map.get(support_dept_code, {}).get("support_dept_name") + ) + ) result.append( merge_project_external_fields( { @@ -1575,14 +3870,93 @@ def get_project_status_rows() -> list[dict[str, Any]]: }, contract_info_map.get(support_dept_code), billing_summary_map.get(support_dept_code), + latest_summary_by_title.get(fallback_title_key), + latest_round_by_code.get(support_dept_code), + representative_by_title.get(fallback_title_key, ""), + fallback_title_key, ) ) return result +def get_project_status_row_for_code(support_dept_code: str | None) -> dict[str, Any] | None: + normalized_code = normalize_text(support_dept_code) + if not normalized_code: + return None + for item in get_project_status_rows(): + if normalize_text(item.get("support_dept_code")) == normalized_code: + return item + return None + + +def get_project_comparison_notes_map() -> dict[str, dict[str, str]]: + with engine.begin() as conn: + rows = conn.execute( + text( + """ + SELECT support_dept_code, item_key, COALESCE(note, '') AS note + FROM project_comparison_notes + WHERE COALESCE(support_dept_code, '') <> '' + """ + ) + ).mappings().all() + result: dict[str, dict[str, str]] = {} + for row in rows: + code = normalize_text(row.get("support_dept_code")) + item_key = normalize_text(row.get("item_key")) + if not code or not item_key: + continue + result.setdefault(code, {})[item_key] = normalize_text(row.get("note")) + return result + + +def save_project_comparison_note(support_dept_code: str | None, item_key: str | None, note: str | None) -> None: + code = normalize_text(support_dept_code) + normalized_item_key = normalize_text(item_key) + if not code or not normalized_item_key: + return + normalized_note = normalize_text(note) + with engine.begin() as conn: + if normalized_note: + conn.execute( + text( + """ + INSERT INTO project_comparison_notes ( + support_dept_code, item_key, note, updated_at + ) VALUES ( + :support_dept_code, :item_key, :note, CURRENT_TIMESTAMP + ) + ON CONFLICT(support_dept_code, item_key) DO UPDATE SET + note = excluded.note, + updated_at = CURRENT_TIMESTAMP + """ + ), + { + "support_dept_code": code, + "item_key": normalized_item_key, + "note": normalized_note, + }, + ) + else: + conn.execute( + text( + """ + DELETE FROM project_comparison_notes + WHERE support_dept_code = :support_dept_code + AND item_key = :item_key + """ + ), + { + "support_dept_code": code, + "item_key": normalized_item_key, + }, + ) + + def get_project_status_for_edit(support_dept_code: str | None) -> dict[str, Any]: contract_info_map = get_project_contract_info_map() billing_summary_map = get_project_billing_summary_map() + latest_summary_by_title, latest_round_by_code, representative_by_title, title_by_code = get_project_contract_change_maps() if not support_dept_code: return { "support_dept_code": "", @@ -1632,6 +4006,7 @@ def get_project_status_for_edit(support_dept_code: str | None) -> dict[str, Any] } with engine.begin() as conn: + entry_maps = load_project_status_entry_maps(conn) row = conn.execute( text( """ @@ -1725,101 +4100,33 @@ def get_project_status_for_edit(support_dept_code: str | None) -> dict[str, Any] "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", "[]")) + support_dept_code = normalize_text(result.get("support_dept_code")) + entry_set = ensure_project_entry_set(entry_maps.get(support_dept_code)) if support_dept_code in entry_maps else extract_project_status_entry_sets(result) + result["collection_entries"] = entry_set["collection_entries"] + result["task_plan_entries"] = entry_set["task_plan_entries"] + result["exec_budget_entries"] = entry_set["exec_budget_entries"] + result["actual_input_entries"] = entry_set["actual_input_entries"] + result.pop("collection_entries_json", None) + result.pop("task_plan_entries_json", None) + result.pop("exec_budget_entries_json", None) + result.pop("actual_input_entries_json", None) try: result["exec_labor_rates"] = json.loads(normalize_text(result.pop("exec_labor_rates_json", "{}")) or "{}") except json.JSONDecodeError: result["exec_labor_rates"] = {} - 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", - "dept_name": "기존 부서별 배분", - "work_name": "", - "amount": result.get("task_plan_department_budget", ""), - } - ) - if normalize_amount(result.get("task_plan_outsource_budget")): - fallback_task_rows.append( - { - "group": "outsource", - "dept_name": "기존 외주비", - "work_name": result.get("task_plan_outsource_detail", ""), - "amount": result.get("task_plan_outsource_budget", ""), - } - ) - if normalize_amount(result.get("task_plan_joint_operating_cost")): - fallback_task_rows.append( - { - "group": "joint", - "dept_name": "기존 합사운영비", - "work_name": "", - "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", - "grade": "기존 인건비", - "hours": "", - "amount": result.get("exec_budget_labor_by_grade", ""), - } - ) - if normalize_amount(result.get("exec_budget_outsource")): - fallback_exec_rows.append( - { - "group": "outsource", - "dept_name": "기존 외주비", - "work_name": "", - "amount": result.get("exec_budget_outsource", ""), - } - ) - if normalize_amount(result.get("exec_budget_cost_plan")): - fallback_exec_rows.append( - { - "group": "cost_plan", - "account_code": "기존", - "account_name": "비용계획", - "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 merge_project_external_fields( result, contract_info_map.get(normalize_text(result.get("support_dept_code"))), billing_summary_map.get(normalize_text(result.get("support_dept_code"))), + latest_summary_by_title.get(title_by_code.get(normalize_text(result.get("support_dept_code"))) or normalize_project_title_for_linking(result.get("support_dept_name"))), + latest_round_by_code.get(normalize_text(result.get("support_dept_code"))), + representative_by_title.get(title_by_code.get(normalize_text(result.get("support_dept_code"))) or normalize_project_title_for_linking(result.get("support_dept_name")), ""), + title_by_code.get(normalize_text(result.get("support_dept_code"))) or normalize_project_title_for_linking(result.get("support_dept_name")), ) -def get_project_page_state() -> dict[str, Any]: +def get_project_page_state(session_id: str | None = None) -> dict[str, Any]: + normalized_session_id = normalize_text(session_id) with engine.begin() as conn: row = conn.execute( text( @@ -1827,17 +4134,40 @@ def get_project_page_state() -> dict[str, Any]: SELECT COALESCE(selected_code, '') AS selected_code, COALESCE(selected_year, '') AS selected_year, COALESCE(analysis_open, 0) AS analysis_open, + COALESCE(uncontracted_year_start, '') AS uncontracted_year_start, + COALESCE(uncontracted_year_end, '') AS uncontracted_year_end, COALESCE(related_project_selections_json, '{}') AS related_project_selections_json FROM project_page_state WHERE page_key = 'projects' + AND session_id = :session_id """ - ) + ), + {"session_id": normalized_session_id}, ).mappings().first() + if not row and normalized_session_id: + 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(uncontracted_year_start, '') AS uncontracted_year_start, + COALESCE(uncontracted_year_end, '') AS uncontracted_year_end, + COALESCE(related_project_selections_json, '{}') AS related_project_selections_json + FROM project_page_state + WHERE page_key = 'projects' + AND session_id = '' + """ + ) + ).mappings().first() if not row: return { "selected_code": "", "selected_year": "", "analysis_open": False, + "uncontracted_year_start": "", + "uncontracted_year_end": "", "related_project_selections": {}, } try: @@ -1859,14 +4189,19 @@ def get_project_page_state() -> dict[str, Any]: "selected_code": normalize_text(row["selected_code"]), "selected_year": normalize_text(row["selected_year"]), "analysis_open": bool(row["analysis_open"]), + "uncontracted_year_start": normalize_text(row["uncontracted_year_start"]), + "uncontracted_year_end": normalize_text(row["uncontracted_year_end"]), "related_project_selections": related_project_selections, } def save_project_page_state(payload: dict[str, Any]) -> None: + session_id = normalize_text(payload.get("session_id")) 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 + uncontracted_year_start = normalize_text(payload.get("uncontracted_year_start")) + uncontracted_year_end = normalize_text(payload.get("uncontracted_year_end")) raw_related = payload.get("related_project_selections") or {} related_project_selections = {} if isinstance(raw_related, dict): @@ -1885,31 +4220,42 @@ def save_project_page_state(payload: dict[str, Any]) -> None: """ INSERT INTO project_page_state ( page_key, + session_id, selected_code, selected_year, analysis_open, + uncontracted_year_start, + uncontracted_year_end, related_project_selections_json, updated_at ) VALUES ( 'projects', + :session_id, :selected_code, :selected_year, :analysis_open, + :uncontracted_year_start, + :uncontracted_year_end, :related_project_selections_json, CURRENT_TIMESTAMP ) - ON CONFLICT(page_key) DO UPDATE SET + ON CONFLICT(page_key, session_id) DO UPDATE SET selected_code = excluded.selected_code, selected_year = excluded.selected_year, analysis_open = excluded.analysis_open, + uncontracted_year_start = excluded.uncontracted_year_start, + uncontracted_year_end = excluded.uncontracted_year_end, related_project_selections_json = excluded.related_project_selections_json, updated_at = CURRENT_TIMESTAMP """ ), { + "session_id": session_id, "selected_code": selected_code, "selected_year": selected_year, "analysis_open": analysis_open, + "uncontracted_year_start": uncontracted_year_start, + "uncontracted_year_end": uncontracted_year_end, "related_project_selections_json": json.dumps(related_project_selections, ensure_ascii=False), }, ) @@ -1938,6 +4284,105 @@ def get_project_related_links_map() -> dict[str, list[str]]: return related_map +def get_project_quick_links(session_id: str | None = None) -> list[str]: + with engine.begin() as conn: + rows = conn.execute( + text( + """ + SELECT support_dept_code + FROM project_quick_links + WHERE page_key = 'projects' + ORDER BY sort_order, updated_at DESC, support_dept_code + """ + ) + ).mappings().all() + return [normalize_text(row["support_dept_code"]) for row in rows if normalize_text(row["support_dept_code"])] + + +def save_project_quick_links(session_id: str | None, codes: list[str]) -> None: + normalized_codes: list[str] = [] + for code in codes: + normalized_code = normalize_text(code) + if normalized_code and normalized_code not in normalized_codes: + normalized_codes.append(normalized_code) + with engine.begin() as conn: + conn.execute( + text( + """ + DELETE FROM project_quick_links + WHERE page_key = 'projects' + """ + ) + ) + for sort_order, support_dept_code in enumerate(normalized_codes): + conn.execute( + text( + """ + INSERT INTO project_quick_links ( + page_key, support_dept_code, sort_order, updated_at + ) VALUES ( + 'projects', :support_dept_code, :sort_order, CURRENT_TIMESTAMP + ) + """ + ), + { + "support_dept_code": support_dept_code, + "sort_order": sort_order, + }, + ) + + +def get_project_uncontracted_classification_map() -> dict[str, str]: + with engine.begin() as conn: + rows = conn.execute( + text( + """ + SELECT support_dept_code, category + FROM project_uncontracted_classification + WHERE COALESCE(support_dept_code, '') <> '' + """ + ) + ).fetchall() + return { + normalize_text(row[0]): normalize_text(row[1]) + for row in rows + if normalize_text(row[0]) + } + + +def save_project_uncontracted_classification(support_dept_code: Any, category: Any) -> None: + normalized_code = normalize_text(support_dept_code) + normalized_category = normalize_text(category) + allowed_categories = {"general", "precontract", "corporate_rnd", "external_research"} + if not normalized_code: + raise ValueError("프로젝트 코드가 필요합니다.") + if normalized_category not in allowed_categories: + raise ValueError("허용되지 않는 미계약 분류입니다.") + with engine.begin() as conn: + conn.execute( + text( + """ + INSERT INTO project_uncontracted_classification ( + support_dept_code, + category, + updated_at + ) VALUES ( + :support_dept_code, + :category, + CURRENT_TIMESTAMP + ) + ON CONFLICT(support_dept_code) DO UPDATE SET + category = excluded.category, + updated_at = CURRENT_TIMESTAMP + """ + ), + { + "support_dept_code": normalized_code, + "category": normalized_category, + }, + ) + + def save_project_related_links(base_support_dept_code: str, related_codes: list[Any]) -> None: base_code = normalize_text(base_support_dept_code) if not base_code: @@ -2660,6 +5105,10 @@ def parse_excel_upload(upload_file: UploadFile) -> int: import_kind = detect_excel_import_kind(workbook, upload_file.filename or "") if import_kind == "contract_status": return import_contract_status_workbook(workbook, upload_file.filename or "") + if import_kind == "change_contract_summary": + return import_change_contract_summary_workbook(workbook, upload_file.filename or "") + if import_kind == "change_contract_round": + return import_change_contract_round_workbook(workbook, upload_file.filename or "") if import_kind == "billing_status": return import_billing_status_workbook(workbook, upload_file.filename or "") @@ -2697,30 +5146,131 @@ def import_excel_path(path: Path) -> int: def auto_import_project_excels() -> None: init_db() - excel_files = sorted(BASE_DIR.glob("*.xlsx")) + excel_files = [ + path + for path in sorted(BASE_DIR.glob("*.xlsx")) + if not path.name.startswith("~$") + ] if not excel_files: return known_files = existing_source_files() known_contract_files = existing_contract_source_files() known_billing_files = existing_billing_source_files() + known_change_summary_files = existing_change_contract_summary_source_files() + known_change_round_files = existing_change_contract_round_source_files() if count_transactions() > 0 and all(file.name in known_files for file in excel_files): - if all(file.name in known_contract_files or file.name in known_billing_files for file in excel_files): + if all( + file.name in known_contract_files + or file.name in known_billing_files + or file.name in known_change_summary_files + or file.name in known_change_round_files + for file in excel_files + ): return for excel_path in excel_files: - workbook = load_workbook(excel_path, data_only=True) + if not zipfile.is_zipfile(excel_path): + logger.warning("Skipping non-Excel or temporary workbook during auto-import: %s", excel_path.name) + continue + try: + workbook = load_workbook(excel_path, data_only=True) + except zipfile.BadZipFile: + logger.warning("Skipping invalid workbook during auto-import: %s", excel_path.name) + continue + except Exception: + logger.exception("Failed to inspect workbook during auto-import: %s", excel_path.name) + continue import_kind = detect_excel_import_kind(workbook, excel_path.name) if import_kind == "contract_status" and excel_path.name in known_contract_files: continue + if import_kind == "change_contract_summary" and excel_path.name in known_change_summary_files: + continue + if import_kind == "change_contract_round" and excel_path.name in known_change_round_files: + continue if import_kind == "billing_status" and excel_path.name in known_billing_files: continue if import_kind == "transactions" and excel_path.name in known_files: continue - with excel_path.open("rb") as excel_file: - upload = UploadFile(filename=excel_path.name, file=excel_file) - inserted = parse_excel_upload(upload) - logger.info("Auto-imported %s rows from %s", inserted, excel_path.name) + try: + with excel_path.open("rb") as excel_file: + upload = UploadFile(filename=excel_path.name, file=excel_file) + inserted = parse_excel_upload(upload) + logger.info("Auto-imported %s rows from %s", inserted, excel_path.name) + except Exception: + logger.exception("Failed to auto-import workbook: %s", excel_path.name) + + +def normalize_all_collection_entry_storage() -> None: + with engine.begin() as conn: + entry_rows = conn.execute( + text( + """ + SELECT id, support_dept_code, vendor, progress_type, billing_round, billing_type, + billing_date, billed_amount, round, date, due_date, amount, + balance_amount, collection_rate, note + FROM project_collection_entries + ORDER BY support_dept_code, position, id + """ + ) + ).mappings().all() + grouped_entries: dict[str, list[dict[str, Any]]] = {} + for row in entry_rows: + row_dict = dict(row) + entry_id = row_dict.pop("id", None) + support_dept_code = normalize_text(row_dict.pop("support_dept_code", "")) + normalized = normalize_collection_entry_row(row_dict) + grouped_entries.setdefault(support_dept_code, []).append(normalized) + if ( + normalize_text(row.get("progress_type")) != normalized["progress_type"] + or normalize_text(row.get("billing_type")) != normalized["billing_type"] + ): + conn.execute( + text( + """ + UPDATE project_collection_entries + SET progress_type = :progress_type, + billing_type = :billing_type, + updated_at = CURRENT_TIMESTAMP + WHERE id = :id + """ + ), + { + "id": entry_id, + "progress_type": normalized["progress_type"], + "billing_type": normalized["billing_type"], + }, + ) + + cached_rows = conn.execute( + text( + """ + SELECT support_dept_code, collection_entries_json + FROM project_status + WHERE collection_entries_json IS NOT NULL AND collection_entries_json <> '' + """ + ) + ).mappings().all() + for row in cached_rows: + support_dept_code = normalize_text(row["support_dept_code"]) + normalized_entries = grouped_entries.get(support_dept_code) + if normalized_entries is None: + raw_entries = decode_json_rows(row["collection_entries_json"]) + normalized_entries = [normalize_collection_entry_row(item) for item in raw_entries] + conn.execute( + text( + """ + UPDATE project_status + SET collection_entries_json = :collection_entries_json, + updated_at = CURRENT_TIMESTAMP + WHERE support_dept_code = :support_dept_code + """ + ), + { + "support_dept_code": support_dept_code, + "collection_entries_json": encode_json_rows(normalized_entries), + }, + ) def parse_manual_form(raw_body: bytes) -> dict[str, Any]: @@ -2824,6 +5374,9 @@ def build_collection_rows(payload: dict[str, Any]) -> list[dict[str, Any]]: normalized_row["billed_amount"] = billed_amount filtered_rows.append(normalized_row) for row in filtered_rows: + normalized_fields = normalize_collection_entry_fields(row) + row["progress_type"] = normalized_fields["progress_type"] + row["billing_type"] = normalized_fields["billing_type"] row["billing_round"] = normalize_round_value(row.get("billing_round")) row["round"] = normalize_round_value(row.get("round")) row["billing_date"] = normalize_date_text(row.get("billing_date")) @@ -2978,8 +5531,12 @@ def build_project_status_payload(payload: dict[str, Any]) -> dict[str, Any]: 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 + expected_as_cost = normalize_amount(payload.get("expected_as_cost")) + expected_sga_budget = normalize_amount(payload.get("expected_sga_budget")) + if not expected_as_cost and contract_amount and expected_as_rate: + expected_as_cost = contract_amount * expected_as_rate / 100 + if not expected_sga_budget and contract_amount and expected_sga_rate: + expected_sga_budget = contract_amount * expected_sga_rate / 100 exec_labor_rates = normalize_text(payload.get("exec_labor_rates_json")) or "{}" return { @@ -3016,6 +5573,10 @@ def build_project_status_payload(payload: dict[str, Any]) -> dict[str, Any]: "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")), + "_collection_rows": collection_rows, + "_task_plan_rows": task_plan_rows, + "_exec_budget_rows": exec_budget_rows, + "_actual_input_rows": actual_input_rows, } @@ -3071,6 +5632,75 @@ def save_project_status(payload: dict[str, Any]) -> None: support_dept_code = normalize_text(normalized_payload.get("support_dept_code")) if not support_dept_code: return + collection_rows = normalized_payload.pop("_collection_rows", []) + task_plan_rows = normalized_payload.pop("_task_plan_rows", []) + exec_budget_rows = normalized_payload.pop("_exec_budget_rows", []) + actual_input_rows = normalized_payload.pop("_actual_input_rows", []) + + basic_info_field_keys = ( + "support_dept_name", + "contract_amount", + "project_type", + "expected_as_rate", + "expected_sga_rate", + "expected_as_cost", + "expected_sga_budget", + "exec_labor_rates_json", + "change_round", + "project_start_date", + "project_end_date", + "completion_status", + "notes", + "last_editor_session_id", + "last_client_submitted_at", + ) + collection_field_keys = ( + "collection_progress_type[]", + "collection_billing_round[]", + "collection_billing_type[]", + "collection_billing_date[]", + "collection_billed_amount[]", + "collection_round[]", + "collection_date[]", + "collection_amount_row[]", + ) + task_plan_field_keys = ( + "task_plan_department_dept[]", + "task_plan_department_work[]", + "task_plan_department_amount[]", + "task_plan_outsource_dept[]", + "task_plan_outsource_work[]", + "task_plan_outsource_amount[]", + "task_plan_joint_dept[]", + "task_plan_joint_work[]", + "task_plan_joint_amount[]", + ) + exec_budget_field_keys = ( + "exec_labor_grade[]", + "exec_labor_hours[]", + "exec_labor_amount[]", + "exec_outsource_dept[]", + "exec_outsource_work[]", + "exec_outsource_amount[]", + "exec_cost_plan_code[]", + "exec_cost_plan_name[]", + "exec_cost_plan_amount[]", + ) + actual_input_field_keys = ( + "actual_labor_grade[]", + "actual_labor_minutes[]", + "actual_labor_amount[]", + "actual_labor_adjustment_total", + "actual_labor_joint_label[]", + "actual_labor_joint_amount[]", + "actual_as_label[]", + "actual_as_amount[]", + "actual_sga_label[]", + "actual_sga_amount[]", + ) + + def payload_has_any(keys: tuple[str, ...]) -> bool: + return any(key in payload for key in keys) with engine.begin() as conn: existing_row = conn.execute( @@ -3084,6 +5714,80 @@ def save_project_status(payload: dict[str, Any]) -> None: and not project_status_payload_has_meaningful_data(normalized_payload) ): raise ValueError("기존 입력값을 불러오지 않은 빈 상태로는 저장할 수 없습니다.") + existing_basic_info = conn.execute( + text("SELECT * FROM project_basic_info WHERE support_dept_code = :support_dept_code"), + {"support_dept_code": support_dept_code}, + ).mappings().first() + existing_entry_set = load_project_status_entries_for_code(conn, support_dept_code) + + if existing_basic_info: + for key in basic_info_field_keys: + if key not in payload: + normalized_payload[key] = existing_basic_info.get(key) + if not payload_has_any(collection_field_keys): + collection_rows = existing_entry_set["collection_entries"] + if not payload_has_any(task_plan_field_keys): + task_plan_rows = existing_entry_set["task_plan_entries"] + else: + existing_task_rows = existing_entry_set["task_plan_entries"] + if not payload_has_any(("task_plan_department_dept[]", "task_plan_department_work[]", "task_plan_department_amount[]")): + task_plan_rows.extend(row for row in existing_task_rows if normalize_text(row.get("group")) == "department") + if not payload_has_any(("task_plan_outsource_dept[]", "task_plan_outsource_work[]", "task_plan_outsource_amount[]")): + task_plan_rows.extend(row for row in existing_task_rows if normalize_text(row.get("group")) == "outsource") + if not payload_has_any(("task_plan_joint_dept[]", "task_plan_joint_work[]", "task_plan_joint_amount[]")): + task_plan_rows.extend(row for row in existing_task_rows if normalize_text(row.get("group")) == "joint") + if not payload_has_any(exec_budget_field_keys): + exec_budget_rows = existing_entry_set["exec_budget_entries"] + else: + existing_exec_rows = existing_entry_set["exec_budget_entries"] + if not payload_has_any(("exec_labor_grade[]", "exec_labor_hours[]", "exec_labor_amount[]")): + exec_budget_rows.extend(row for row in existing_exec_rows if normalize_text(row.get("group")) == "labor") + if not payload_has_any(("exec_outsource_dept[]", "exec_outsource_work[]", "exec_outsource_amount[]")): + exec_budget_rows.extend(row for row in existing_exec_rows if normalize_text(row.get("group")) == "outsource") + if not payload_has_any(("exec_cost_plan_code[]", "exec_cost_plan_name[]", "exec_cost_plan_amount[]")): + exec_budget_rows.extend(row for row in existing_exec_rows if normalize_text(row.get("group")) == "cost_plan") + if not payload_has_any(actual_input_field_keys): + actual_input_rows = existing_entry_set["actual_input_entries"] + else: + existing_actual_rows = existing_entry_set["actual_input_entries"] + if not payload_has_any(("actual_labor_grade[]", "actual_labor_minutes[]", "actual_labor_amount[]")): + actual_input_rows.extend(row for row in existing_actual_rows if normalize_text(row.get("group")) == "labor") + if "actual_labor_adjustment_total" not in payload: + actual_input_rows.extend(row for row in existing_actual_rows if normalize_text(row.get("group")) == "labor_adjustment") + if not payload_has_any(("actual_labor_joint_label[]", "actual_labor_joint_amount[]")): + actual_input_rows.extend(row for row in existing_actual_rows if normalize_text(row.get("group")) == "labor_joint") + if not payload_has_any(("actual_as_label[]", "actual_as_amount[]")): + actual_input_rows.extend(row for row in existing_actual_rows if normalize_text(row.get("group")) == "as") + if not payload_has_any(("actual_sga_label[]", "actual_sga_amount[]")): + actual_input_rows.extend(row for row in existing_actual_rows if normalize_text(row.get("group")) == "sga") + + normalized_payload["collection_amount"] = sum_row_amounts(collection_rows) + normalized_payload["progress_rate"] = ( + normalized_payload["collection_amount"] / normalize_amount(normalized_payload.get("contract_amount")) * 100 + if normalize_amount(normalized_payload.get("contract_amount")) + else 0.0 + ) + task_plan_department_rows = [row for row in task_plan_rows if normalize_text(row.get("group")) == "department"] + task_plan_outsource_rows = [row for row in task_plan_rows if normalize_text(row.get("group")) == "outsource"] + task_plan_joint_rows = [row for row in task_plan_rows if normalize_text(row.get("group")) == "joint"] + exec_labor_rows = [row for row in exec_budget_rows if normalize_text(row.get("group")) == "labor"] + exec_outsource_rows = [row for row in exec_budget_rows if normalize_text(row.get("group")) == "outsource"] + exec_cost_plan_rows = [row for row in exec_budget_rows if normalize_text(row.get("group")) == "cost_plan"] + normalized_payload["collection_entries_json"] = encode_json_rows(collection_rows) + normalized_payload["task_plan_department_budget"] = sum_row_amounts(task_plan_department_rows) + normalized_payload["task_plan_outsource_budget"] = sum_row_amounts(task_plan_outsource_rows) + normalized_payload["task_plan_outsource_detail"] = "\n".join( + f"{normalize_text(row.get('dept_name'))} / {normalize_text(row.get('work_name'))}: {format_amount_for_text(row.get('amount'))}".strip(" /:") + for row in task_plan_outsource_rows + ) + normalized_payload["task_plan_joint_operating_cost"] = sum_row_amounts(task_plan_joint_rows) + normalized_payload["task_plan_entries_json"] = encode_json_rows(task_plan_rows) + normalized_payload["exec_budget_labor_by_grade"] = sum_row_amounts(exec_labor_rows) + normalized_payload["exec_budget_outsource"] = sum_row_amounts(exec_outsource_rows) + normalized_payload["exec_budget_cost_plan"] = sum_row_amounts(exec_cost_plan_rows) + normalized_payload["exec_budget_entries_json"] = encode_json_rows(exec_budget_rows) + normalized_payload["item_investment"] = sum_row_amounts(actual_input_rows) + normalized_payload["actual_input_entries_json"] = encode_json_rows(actual_input_rows) check_record_revision( conn, "project_status", @@ -3091,109 +5795,16 @@ def save_project_status(payload: dict[str, Any]) -> None: 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_labor_rates_json, - 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_labor_rates_json, - :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_labor_rates_json = excluded.exec_labor_rates_json, - 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, + save_project_basic_info_section(conn, normalized_payload) + replace_project_status_child_entries( + conn, + support_dept_code, + collection_rows, + task_plan_rows, + exec_budget_rows, + actual_input_rows, ) + sync_project_status_cache_row(conn, support_dept_code) def base_context(request: Request, message: str = "") -> dict[str, Any]: @@ -3225,6 +5836,8 @@ def render_home( "yearly_summary": get_yearly_summary(), "monthly_summary": get_monthly_summary(), "available_years": available_years, + "dashboard_revenue_metric_options": get_option_items("dashboard_revenue_metrics"), + "dashboard_expense_metric_options": get_option_items("dashboard_expense_metrics"), } return templates.TemplateResponse(request, "dashboard.html", context) @@ -3232,6 +5845,7 @@ def render_home( def render_projects_page( request: Request, edit_code: str | None = None, + focus_code: str | None = None, selected_year: int | None = None, message: str = "", ) -> HTMLResponse: @@ -3247,12 +5861,23 @@ def render_projects_page( "project_monthly_cost_rows": get_business_monthly_summary(), "project_account_breakdowns": get_project_account_breakdowns(selected_year), "project_status_rows": get_project_status_rows(), + "project_comparison_notes": get_project_comparison_notes_map(), "project_edit": get_project_status_for_edit(edit_code), + "project_focus_code": normalize_text(focus_code), "project_page_state": get_project_page_state(), "project_related_links": get_project_related_links_map(), + "project_uncontracted_classifications": get_project_uncontracted_classification_map(), "support_department_options": get_support_department_options(), "cost_department_options": get_cost_department_options(), "cost_account_options": get_cost_account_options(), + "labor_grade_options": get_labor_grade_options(), + "expected_as_rate_options": get_expected_as_rate_options(), + "expected_sga_rate_options": get_expected_sga_rate_options(), + "collection_progress_type_options": get_collection_progress_type_options(), + "collection_billing_type_options": get_collection_billing_type_options(), + "uncontracted_category_options": get_uncontracted_category_options(), + "special_x_classification_rules": get_special_x_classification_rules(), + "project_runtime_settings": get_project_runtime_settings(), } return templates.TemplateResponse(request, "projects.html", context) @@ -3264,6 +5889,9 @@ def render_annual_summary_page(request: Request, message: str = "") -> HTMLRespo "available_years": get_available_years(), "yearly_financial_series": get_financial_series("yearly"), "monthly_financial_series": get_financial_series("monthly"), + "annual_metric_cards": get_option_items("annual_metric_cards"), + "annual_expense_chart_metrics": get_option_items("annual_expense_chart_metrics"), + "annual_balance_chart_metrics": get_option_items("annual_balance_chart_metrics"), } return templates.TemplateResponse(request, "annual_summary.html", context) @@ -3283,9 +5911,19 @@ async def home(request: Request, edit_id: int | None = None, overview_year: int @app.get("/projects") -async def projects(request: Request, edit_code: str | None = None, year: str | None = None): +async def projects( + request: Request, + edit_code: str | None = None, + focus_code: str | None = None, + year: str | None = None, +): try: - return render_projects_page(request, edit_code=edit_code, selected_year=parse_optional_year(year)) + return render_projects_page( + request, + edit_code=edit_code, + focus_code=focus_code, + selected_year=parse_optional_year(year), + ) except Exception as exc: logger.exception("사업현황 페이지 에러: %s", exc) return HTMLResponse("

서버 오류

로그를 확인해주세요.

", status_code=500) @@ -3313,6 +5951,15 @@ async def project_page_state_save(request: Request): return JSONResponse(content={"error": str(exc)}, status_code=500) +@app.get("/projects/page-state") +async def project_page_state_load(session_id: str = ""): + try: + return JSONResponse(content=jsonable_encoder(get_project_page_state(session_id))) + except Exception as exc: + logger.exception("사업현황 페이지 상태 조회 에러: %s", exc) + return JSONResponse(content={"error": str(exc)}, status_code=500) + + @app.post("/projects/related-links") async def project_related_links_save(request: Request): try: @@ -3330,6 +5977,78 @@ async def project_related_links_save(request: Request): return JSONResponse(content={"error": str(exc)}, status_code=500) +@app.get("/projects/quick-links") +async def project_quick_links_load(session_id: str = ""): + try: + return JSONResponse(content={"codes": get_project_quick_links(session_id)}) + except Exception as exc: + logger.exception("프로젝트 바로가기 조회 에러: %s", exc) + return JSONResponse(content={"error": str(exc)}, status_code=500) + + +@app.post("/projects/quick-links") +async def project_quick_links_save(request: Request): + try: + payload = await request.json() + if not isinstance(payload, dict): + raise ValueError("잘못된 바로가기 형식입니다.") + codes = payload.get("codes") or [] + if not isinstance(codes, list): + raise ValueError("바로가기 목록 형식이 잘못되었습니다.") + session_id = normalize_text(payload.get("session_id")) + save_project_quick_links(session_id, [normalize_text(code) for code in codes if isinstance(code, str)]) + return JSONResponse(content={"status": "ok", "codes": get_project_quick_links(session_id)}) + except Exception as exc: + logger.exception("프로젝트 바로가기 저장 에러: %s", exc) + return JSONResponse(content={"error": str(exc)}, status_code=500) + + +@app.post("/projects/uncontracted-category") +async def project_uncontracted_category_save(request: Request): + try: + payload = await request.json() + if not isinstance(payload, dict): + raise ValueError("잘못된 미계약 분류 형식입니다.") + save_project_uncontracted_classification( + payload.get("support_dept_code"), + payload.get("category"), + ) + return JSONResponse(content={"status": "ok"}) + except Exception as exc: + logger.exception("미계약 분류 저장 에러: %s", exc) + return JSONResponse(content={"error": str(exc)}, status_code=500) + + +@app.post("/projects/runtime-setting") +async def project_runtime_setting_save(request: Request): + try: + payload = await request.json() + if not isinstance(payload, dict): + raise ValueError("잘못된 설정 형식입니다.") + save_project_runtime_setting(payload.get("item_key"), payload.get("value")) + return JSONResponse(content={"status": "ok", "settings": get_project_runtime_settings()}) + except Exception as exc: + logger.exception("프로젝트 런타임 설정 저장 에러: %s", exc) + return JSONResponse(content={"error": str(exc)}, status_code=500) + + +@app.post("/projects/comparison-note") +async def project_comparison_note_save(request: Request): + try: + payload = await request.json() + if not isinstance(payload, dict): + raise ValueError("잘못된 비고 형식입니다.") + save_project_comparison_note( + payload.get("support_dept_code"), + payload.get("item_key"), + payload.get("note"), + ) + 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: @@ -3379,7 +6098,7 @@ async def save_project(request: Request): redirect_url = "/projects" query_parts = [] if code: - query_parts.append(f"edit_code={quote_plus(code)}") + query_parts.append(f"focus_code={quote_plus(code)}") if selected_year: query_parts.append(f"year={quote_plus(selected_year)}") if query_parts: @@ -3393,11 +6112,43 @@ async def save_project(request: Request): return render_projects_page( request, edit_code=code or None, + focus_code=code or None, selected_year=selected_year, message=f"사업현황 저장 중 오류가 발생했습니다: {exc}", ) +@app.post("/projects/save-json") +async def save_project_json(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")) + health_payload = build_health_payload() + return JSONResponse( + content=jsonable_encoder( + { + "ok": True, + "support_dept_code": code, + "project_edit": get_project_status_for_edit(code), + "project_row": get_project_status_row_for_code(code), + "data_version": health_payload.get("data_version", ""), + "server_time": health_payload.get("server_time", ""), + } + ) + ) + except Exception as exc: + logger.exception("사업현황 JSON 저장 에러: %s", exc) + return JSONResponse( + status_code=500, + content={ + "ok": False, + "error": str(exc) or "사업현황 저장 중 오류가 발생했습니다.", + }, + ) + + if __name__ == "__main__": auto_reload = os.getenv("INTRANET_AUTO_RELOAD", "1").lower() not in {"0", "false", "no"} uvicorn.run("main:app", host="0.0.0.0", port=8010, reload=auto_reload, reload_dirs=[str(BASE_DIR)]) diff --git a/templates/annual_summary.html b/templates/annual_summary.html index 713e331..90ed07b 100644 --- a/templates/annual_summary.html +++ b/templates/annual_summary.html @@ -8,12 +8,14 @@ display: grid; grid-template-columns: 320px minmax(0, 1fr); gap: 16px; - align-items: start; + align-items: stretch; } - .summary-stack { + .summary-panel { display: grid; + grid-template-columns: 320px minmax(0, 1fr); gap: 16px; + align-items: stretch; } .filter-grid { @@ -26,7 +28,7 @@ .legend { display: flex; flex-wrap: wrap; - gap: 10px; + gap: 12px; margin-top: 0; justify-content: center; } @@ -35,13 +37,14 @@ display: inline-flex; align-items: center; gap: 8px; - color: #363b44; + color: #2f3c49; font-size: 13px; - font-weight: 700; + font-weight: 800; + letter-spacing: -0.01em; background: rgba(255,255,255,0.92); border: 1px solid var(--line); - border-radius: 10px; - padding: 6px 10px; + border-radius: 12px; + padding: 8px 12px; } .legend-swatch { @@ -56,24 +59,25 @@ radial-gradient(circle at top left, rgba(17, 17, 17, 0.045), transparent 36%); border: 1px solid var(--line); border-radius: 16px; - padding: 14px; + padding: 18px 20px 20px; box-shadow: inset 0 1px 0 rgba(255,255,255,0.92); } .chart-legend { - margin-bottom: 10px; + margin-bottom: 12px; } .chart-svg { width: 100%; height: auto; - aspect-ratio: 1200 / 420; - min-height: 320px; + aspect-ratio: 1560 / 620; + min-height: 460px; display: block; } .expense-chart-svg { - aspect-ratio: 1600 / 420; + aspect-ratio: 1560 / 660; + min-height: 510px; } .chart-note { @@ -83,49 +87,60 @@ line-height: 1.6; } + .summary-overview { + display: grid; + grid-template-rows: auto auto 1fr; + gap: 14px; + min-height: 100%; + } + .metric-grid { display: grid; grid-template-columns: 1fr; - gap: 8px; + gap: 10px; + align-content: stretch; } .chart-stack { display: grid; gap: 16px; + min-height: 100%; } - .summary-stack .panel { - padding: 14px; - gap: 12px; + .summary-overview .stat-card, + .chart-stack .stat-card { + min-height: 112px; } - .summary-stack .section-title h2, + .summary-overview .section-title h2, .chart-stack .section-title h2 { font-size: 17px; } - .summary-stack .field select { + .summary-overview .field select { min-width: 0; padding: 10px 12px; font-size: 13px; } - .summary-stack .stat-card { - padding: 10px 12px; + .summary-overview .stat-card { + padding: 14px 14px 15px; border-radius: 12px; - gap: 2px; + gap: 4px; } - .summary-stack .stat-card .label { - font-size: 11px; + .summary-overview .stat-card .label { + font-size: 12px; } - .summary-stack .stat-card .value { - font-size: 20px; + .summary-overview .stat-card .value { + font-size: 22px; + line-height: 1.18; } @media (max-width: 1000px) { .summary-layout, + .summary-panel, .filter-grid, .metric-grid { grid-template-columns: 1fr; @@ -136,50 +151,44 @@ {% block content %}
-
-
-
-
-

수익/비용 현황

+
+

수익/비용 현황

+
+
+
+
+
+
-
-
- -
-
- -
+
+
-
-
+
+
-
-
+
+

비용 구조

-
-
- -
-
-
-
+
+ +
+
+

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

-
-
- -
-
+
+ +
@@ -190,30 +199,20 @@ const yearlySeries = {{ yearly_financial_series | tojson }}; const monthlySeries = {{ monthly_financial_series | tojson }}; const availableYears = [...new Set(yearlySeries.map((item) => item.year).filter((year) => year !== null && year !== undefined))]; + const annualMetricCards = {{ annual_metric_cards | tojson }}; + const annualExpenseChartMetrics = {{ annual_expense_chart_metrics | tojson }}; + const annualBalanceChartMetrics = {{ annual_balance_chart_metrics | tojson }}; - const palette = { - revenue_sum: "#0f766e", - project_cost_sum: "#0ea5a4", - support_cost_sum: "#67b7dc", - support_sga_sum: "#f59e0b", - field_sga_sum: "#f97316", - labor_sum: "#8b5cf6", - outsourcing_sum: "#ec4899", - total_expense: "#1d4ed8", - operating_balance: "#dc2626", - }; + const palette = Object.fromEntries( + [...annualExpenseChartMetrics, ...annualBalanceChartMetrics].map((option) => [ + option.item_key, + option.value, + ]), + ); - const labels = { - revenue_sum: "수금", - project_cost_sum: "원가(프로젝트)", - support_cost_sum: "원가(지원부서)", - support_sga_sum: "판관비(지원부서)", - field_sga_sum: "판관비(현업부서)", - labor_sum: "원가인건비", - outsourcing_sum: "원가외주비", - total_expense: "비용합계", - operating_balance: "영업수지", - }; + const labels = Object.fromEntries( + annualMetricCards.map((option) => [option.item_key, option.label]), + ); function formatNumber(value) { return new Intl.NumberFormat("ko-KR", { maximumFractionDigits: 0 }).format(value || 0); @@ -251,6 +250,43 @@ return { tickStep, tickMax }; } + function buildDynamicPositiveAxis(maxValue, width, height, margin) { + const safeMax = Math.max(Number(maxValue || 0), 1); + const paddedMax = safeMax * 1.08; + const tickStep = pickTickStep(paddedMax / 5); + const tickMax = Math.max(tickStep, Math.ceil(paddedMax / tickStep) * tickStep); + let axis = ""; + for (let value = 0; value <= tickMax; value += tickStep) { + const y = height - margin.bottom - ((height - margin.top - margin.bottom) * value) / tickMax; + axis += ``; + axis += `${formatAxisLabel(value)}`; + } + axis += ``; + return { axis, tickStep, tickMax }; + } + + function buildDynamicBalanceAxis(maxPositiveValue, minNegativeValue, width, height, margin) { + const safePositive = Math.max(Number(maxPositiveValue || 0), 0); + const safeNegative = Math.min(Number(minNegativeValue || 0), 0); + const paddedPositive = safePositive > 0 ? safePositive * 1.08 : 1000; + const paddedNegative = safeNegative < 0 ? safeNegative * 1.08 : 0; + const rangeAbs = Math.max(Math.abs(paddedPositive), Math.abs(paddedNegative), 1); + const tickStep = pickTickStep(rangeAbs / 4); + const positiveMax = Math.max(tickStep, Math.ceil(paddedPositive / tickStep) * tickStep); + const negativeMin = safeNegative < 0 ? Math.min(-tickStep, Math.floor(paddedNegative / tickStep) * tickStep) : 0; + const plotHeight = height - margin.top - margin.bottom; + const totalRange = positiveMax - negativeMin; + const zeroY = margin.top + (plotHeight * positiveMax) / totalRange; + let axis = ""; + for (let value = negativeMin; value <= positiveMax; value += tickStep) { + const y = margin.top + ((positiveMax - value) / totalRange) * plotHeight; + axis += ``; + axis += `${formatAxisLabel(value)}`; + } + axis += ``; + return { axis, tickStep, positiveMax, negativeMin, zeroY, totalRange }; + } + function renderLegend(targetId, keys) { const target = document.getElementById(targetId); if (!target) return; @@ -298,17 +334,7 @@ } function renderMetrics(series) { - const keys = [ - "revenue_sum", - "project_cost_sum", - "support_cost_sum", - "support_sga_sum", - "field_sga_sum", - "labor_sum", - "outsourcing_sum", - "total_expense", - "operating_balance", - ]; + const keys = annualMetricCards.map((option) => option.item_key); const totals = {}; keys.forEach((key) => { totals[key] = series.reduce((sum, item) => sum + (item[key] || 0), 0); @@ -334,35 +360,21 @@ return { axis, tickMax }; } - function buildSignedAxis(maxPositiveValue, minNegativeValue, width, height, margin) { - const forcedNegativeFloor = -20000000000; - const normalizedNegative = Math.min(Number(minNegativeValue || 0), forcedNegativeFloor); - const rangeMax = Math.max(Math.abs(maxPositiveValue || 0), Math.abs(normalizedNegative || 0), 1); - const { tickStep, tickMax } = buildPositiveAxisScale(rangeMax, 4); - const plotHeight = height - margin.top - margin.bottom; - const zeroY = margin.top + (plotHeight * tickMax) / (tickMax * 2); - let axis = ""; - for (let value = -tickMax; value <= tickMax; value += tickStep) { - const y = zeroY - (plotHeight * value) / (tickMax * 2); - axis += ``; - axis += `${formatAxisLabel(value)}`; - } - axis += ``; - return { axis, tickMax, zeroY }; - } - function renderEmptyChart(svgId, message) { const svg = document.getElementById(svgId); if (!svg) return; + const viewBox = (svg.getAttribute("viewBox") || "0 0 1400 560").split(/\s+/).map(Number); + const width = viewBox[2] || 1400; + const height = viewBox[3] || 560; svg.innerHTML = ` - - ${message} + + ${message} `; } function renderExpenseChart(series) { const svg = document.getElementById("expenseChart"); - const keys = ["labor_sum", "outsourcing_sum", "project_cost_sum", "support_cost_sum", "support_sga_sum", "field_sga_sum"]; + const keys = annualExpenseChartMetrics.map((option) => option.item_key); const isMonthlyView = series.some((item) => String(item.label || "").includes("월")); renderLegend("expenseLegend", keys); if (!series.length) { @@ -370,77 +382,62 @@ return; } - const width = 1600; - const height = 420; - const margin = { top: 30, right: isMonthlyView ? 72 : 36, bottom: 74, left: 98 }; - const barWidth = (width - margin.left - margin.right) / series.length * (isMonthlyView ? 0.18 : 0.29); + const width = 1560; + const height = 660; + svg.setAttribute("viewBox", `0 0 ${width} ${height}`); + const margin = { top: 54, right: 36, bottom: 96, left: 124 }; + const plotWidth = width - margin.left - margin.right; + const plotHeight = height - margin.top - margin.bottom; + const barWidth = (plotWidth / series.length) * (isMonthlyView ? 0.36 : 0.42); const step = (width - margin.left - margin.right) / series.length; const maxValue = Math.max(...series.map((item) => keys.reduce((sum, key) => sum + (item[key] || 0), 0)), 1); - const { axis, tickMax } = buildAxis(maxValue, width, height, margin); + const { axis, tickMax } = buildDynamicPositiveAxis(maxValue, width, height, margin); let markup = ` - + ${axis} `; series.forEach((item, index) => { let cumulative = 0; const total = keys.reduce((sum, key) => sum + (item[key] || 0), 0); const x = margin.left + index * step + (step - barWidth) / 2; - const detailX = x + barWidth + 8; - const labelEntries = []; keys.forEach((key) => { const value = item[key] || 0; - const barHeight = ((height - margin.top - margin.bottom) * value) / tickMax; - const y = height - margin.bottom - barHeight - ((height - margin.top - margin.bottom) * cumulative) / tickMax; + const barHeight = (plotHeight * value) / tickMax; + const y = height - margin.bottom - barHeight - (plotHeight * cumulative) / tickMax; cumulative += value; markup += ``; - if (value > 0) { - const percent = total ? ((value / total) * 100).toFixed(1) : "0.0"; - labelEntries.push({ - key, - value, - percent, - desiredY: y + (barHeight / 2), - }); + if (value > 0 && barHeight >= 34) { + const percent = total ? `${((value / total) * 100).toFixed(1)}%` : "0.0%"; + markup += `${percent}`; } }); - labelEntries.sort((a, b) => a.desiredY - b.desiredY); - const minY = margin.top + 12; - const maxY = height - margin.bottom - 12; - const gap = isMonthlyView ? 16 : 18; - let lastY = minY - gap; - labelEntries.forEach((entry) => { - const lineY = Math.max(entry.desiredY, lastY + gap, minY); - const finalY = Math.min(lineY, maxY); - lastY = finalY; - markup += ``; - markup += `${entry.percent}%`; - }); if (total > 0) { - const topY = height - margin.bottom - ((height - margin.top - margin.bottom) * total) / tickMax; - markup += `${formatAxisLabel(total)}`; + const topY = height - margin.bottom - (plotHeight * total) / tickMax; + markup += `${formatAxisLabel(total)}`; } - markup += `${item.label}`; + markup += `${item.label}`; }); svg.innerHTML = markup; } function renderBalanceChart(series) { const svg = document.getElementById("balanceChart"); - const metrics = ["revenue_sum", "total_expense", "operating_balance"]; + const metrics = annualBalanceChartMetrics.map((option) => option.item_key); renderLegend("balanceLegend", metrics); if (!series.length) { renderEmptyChart("balanceChart", "표시할 수익/비용 데이터가 없습니다."); return; } - const width = 1200; - const height = 420; - const margin = { top: 30, right: 24, bottom: 74, left: 98 }; + const width = 1560; + const height = 620; + svg.setAttribute("viewBox", `0 0 ${width} ${height}`); + const margin = { top: 56, right: 34, bottom: 100, left: 124 }; const plotWidth = width - margin.left - margin.right; const plotHeight = height - margin.top - margin.bottom; const maxPositiveValue = Math.max(...series.flatMap((item) => [ @@ -449,11 +446,11 @@ Math.max(item.operating_balance || 0, 0), ]), 1); const minNegativeValue = Math.min(...series.map((item) => Math.min(item.operating_balance || 0, 0)), 0); - const { axis, tickMax, zeroY } = buildSignedAxis(maxPositiveValue, minNegativeValue, width, height, margin); + const { axis, positiveMax, negativeMin, zeroY, totalRange } = buildDynamicBalanceAxis(maxPositiveValue, minNegativeValue, width, height, margin); const groupWidth = plotWidth / Math.max(series.length, 1); const groupGap = groupWidth * 0.22; const innerGap = 0; - const barWidth = Math.min((groupWidth - groupGap * 2) / metrics.length, 44); + const barWidth = Math.min((groupWidth - groupGap * 2) / metrics.length, 72); const actualGroupWidth = barWidth * metrics.length + innerGap * (metrics.length - 1); const groupStartOffset = (groupWidth - actualGroupWidth) / 2; let markup = ` @@ -467,21 +464,46 @@ `; series.forEach((item, index) => { const baseX = margin.left + index * groupWidth; + const positiveLabels = []; + const negativeLabels = []; metrics.forEach((key, metricIndex) => { const rawValue = Number(item[key] || 0); const value = key === "operating_balance" ? rawValue : Math.max(rawValue, 0); - const barHeight = (plotHeight * Math.abs(value)) / (tickMax * 2); + const clampedValue = Math.max(negativeMin, Math.min(positiveMax, value)); + const barHeight = (plotHeight * Math.abs(clampedValue)) / totalRange; const x = baseX + groupStartOffset + metricIndex * (barWidth + innerGap); - const y = value < 0 ? zeroY : zeroY - barHeight; + const y = clampedValue < 0 ? zeroY : zeroY - barHeight; markup += ``; - if (value !== 0) { - const labelY = value < 0 - ? Math.min(y + barHeight + 14, height - margin.bottom + 6) - : Math.max(y - 8, margin.top + 12); - markup += `${formatAxisLabel(value)}`; + if (clampedValue !== 0) { + const label = { + x: x + barWidth / 2, + desiredY: clampedValue < 0 + ? Math.min(y + barHeight + 22, height - margin.bottom + 12) + : Math.max(y - 14, margin.top + 18), + text: formatAxisLabel(value), + }; + if (clampedValue < 0) { + negativeLabels.push(label); + } else { + positiveLabels.push(label); + } } }); - markup += `${item.label}`; + positiveLabels.sort((a, b) => a.desiredY - b.desiredY); + let lastPositiveY = margin.top - 24; + positiveLabels.forEach((label) => { + const y = Math.max(label.desiredY, lastPositiveY + 18); + lastPositiveY = y; + markup += `${label.text}`; + }); + negativeLabels.sort((a, b) => a.desiredY - b.desiredY); + let lastNegativeY = zeroY + 18; + negativeLabels.forEach((label) => { + const y = Math.max(label.desiredY, lastNegativeY + 18); + lastNegativeY = y; + markup += `${label.text}`; + }); + markup += `${item.label}`; }); svg.innerHTML = markup; diff --git a/templates/base.html b/templates/base.html index 3691704..6451db4 100644 --- a/templates/base.html +++ b/templates/base.html @@ -17,6 +17,9 @@ --warn: #fff2cb; --table-alt: #f5f6f8; --white: #ffffff; + --page-gutter: clamp(14px, 1.8vw, 24px); + --panel-pad: clamp(16px, 1.4vw, 20px); + --page-frame-width: min(1520px, calc(100vw - (var(--page-gutter) * 2))); } * { @@ -32,14 +35,18 @@ radial-gradient(circle at top left, rgba(255, 255, 255, 0.92), transparent 24%), linear-gradient(180deg, var(--bg-a), var(--bg-b)); min-height: 100vh; - padding: 14px; + padding: var(--page-gutter); } .page { - max-width: 1520px; + width: min(100%, var(--page-frame-width)); margin: 0 auto; display: grid; - gap: 14px; + gap: var(--page-gutter); + } + + .page > * { + width: 100%; } .nav { @@ -82,7 +89,7 @@ background: var(--panel); border: 1px solid var(--line); border-radius: 18px; - padding: 18px; + padding: var(--panel-pad); box-shadow: 0 10px 24px rgba(21, 24, 29, 0.045); backdrop-filter: blur(6px); } @@ -450,10 +457,6 @@ } @media (max-width: 720px) { - body { - padding: 14px; - } - .stats, .form-grid { grid-template-columns: 1fr; @@ -484,6 +487,7 @@ id="syncStatusWidget" data-data-version="{{ data_version or '' }}" data-refresh-url="{{ request.url.path }}{% if request.url.query %}?{{ request.url.query }}{% endif %}" + data-refresh-mode="{{ 'disabled' if request.url.path == '/projects' else 'auto' }}" >
@@ -521,6 +525,7 @@ const dataVersion = document.getElementById("syncDataVersion"); let pageVersion = widget.dataset.dataVersion || ""; const refreshUrl = widget.dataset.refreshUrl || window.location.href; + const refreshMode = widget.dataset.refreshMode || "auto"; let refreshInFlight = false; let pendingVersion = ""; let isFormDirty = false; @@ -536,6 +541,7 @@ } const clientSessionId = getSessionId(); + window.clientSessionId = clientSessionId; sessionPill.textContent = clientSessionId; function updateWidgetTitle() { @@ -591,17 +597,36 @@ updateWidgetTitle(); } + function setPageDataVersion(nextVersion) { + pageVersion = nextVersion || ""; + pendingVersion = ""; + widget.dataset.dataVersion = pageVersion; + dataVersion.textContent = pageVersion || "-"; + updateWidgetTitle(); + } + + window.__setPageDataVersion = setPageDataVersion; + function hasActiveEditor() { const active = document.activeElement; return Boolean(active && active.closest && active.closest("form[data-collab-form]")); } + function hasOpenModal() { + return Boolean(document.querySelector(".modal-backdrop.open")); + } + function shouldDelayRefresh() { - return isFormDirty || hasActiveEditor(); + return isFormDirty || hasActiveEditor() || hasOpenModal() || window.__suspendAutoRefresh === true; } async function refreshPageWhenSafe(nextVersion) { if (refreshInFlight) return; + if (refreshMode === "disabled") { + setPageDataVersion(nextVersion || pageVersion); + setStatus("online", "서버 정상 연결"); + return; + } if (shouldDelayRefresh()) { pendingVersion = nextVersion || pendingVersion || pageVersion; setStatus("online", "새 데이터 대기 중"); @@ -613,16 +638,7 @@ setStatus("online", "새 데이터 반영 중"); try { - const response = await fetch(refreshUrl, { - cache: "no-store", - credentials: "same-origin", - headers: { "X-Requested-With": "XMLHttpRequest" }, - }); - if (!response.ok) throw new Error(`HTTP ${response.status}`); - const html = await response.text(); - document.open(); - document.write(html); - document.close(); + window.location.replace(refreshUrl); } catch (error) { refreshInFlight = false; setStatus("error", "업데이트 재시도 중"); diff --git a/templates/dashboard.html b/templates/dashboard.html index 1b69c39..0e91590 100644 --- a/templates/dashboard.html +++ b/templates/dashboard.html @@ -228,16 +228,6 @@
-
-
- 계약 프로젝트 {{ import_sync_summary.contract_project_count or 0 }}건 - 청구 프로젝트 {{ import_sync_summary.billing_project_count or 0 }}건 - 변경계약 검토 {{ import_sync_summary.review_needed_count or 0 }}건 - 한맥 계약합계 {{ "{:,.0f}".format(import_sync_summary.total_hanmac_contract_amount or 0) }} - 청구 수금합계 {{ "{:,.0f}".format(import_sync_summary.total_collected_amount or 0) }} -
-
-
@@ -285,10 +275,9 @@
@@ -314,10 +303,9 @@ @@ -339,35 +327,31 @@ const revenueYearly = {{ project_revenue_mix_yearly | tojson }}; const revenueMonthly = {{ project_revenue_mix_monthly | tojson }}; const pageSelectedYear = {{ overview_selected_year | tojson }}; + const dashboardRevenueMetricOptions = {{ dashboard_revenue_metric_options | tojson }}; + const dashboardExpenseMetricOptions = {{ dashboard_expense_metric_options | tojson }}; - const revenuePalette = { - design_revenue: { label: "설계", color: "#4f7cff" }, - design_other_revenue: { label: "설계 외", color: "#67c7c9" }, - supervision_revenue: { label: "감리", color: "#233a5a" }, - inspection_revenue: { label: "점검", color: "#ffb54a" }, - }; + const revenuePalette = Object.fromEntries( + dashboardRevenueMetricOptions.map((option) => [ + option.item_key, + { label: option.label, color: option.value }, + ]), + ); - const expensePalette = { - cost_sum: { label: "원가", color: "#4f7cff" }, - sga_sum: { label: "판관비", color: "#67c7c9" }, - labor_sum: { label: "원가인건비", color: "#233a5a" }, - outsourcing_sum: { label: "원가외주비", color: "#ffb54a" }, - }; + const expensePalette = Object.fromEntries( + dashboardExpenseMetricOptions.map((option) => [ + option.item_key, + { label: option.label, color: option.value }, + ]), + ); const revenueMetricMap = { - all: ["design_revenue", "design_other_revenue", "supervision_revenue", "inspection_revenue"], - design_revenue: ["design_revenue"], - design_other_revenue: ["design_other_revenue"], - supervision_revenue: ["supervision_revenue"], - inspection_revenue: ["inspection_revenue"], + all: dashboardRevenueMetricOptions.map((option) => option.item_key), + ...Object.fromEntries(dashboardRevenueMetricOptions.map((option) => [option.item_key, [option.item_key]])), }; const expenseMetricMap = { - all: ["cost_sum", "sga_sum", "labor_sum", "outsourcing_sum"], - cost_sum: ["cost_sum"], - sga_sum: ["sga_sum"], - labor_sum: ["labor_sum"], - outsourcing_sum: ["outsourcing_sum"], + all: dashboardExpenseMetricOptions.map((option) => option.item_key), + ...Object.fromEntries(dashboardExpenseMetricOptions.map((option) => [option.item_key, [option.item_key]])), }; function formatNumber(value) { diff --git a/templates/index.html b/templates/index.html index ef0d35a..e0c976c 100644 --- a/templates/index.html +++ b/templates/index.html @@ -18,6 +18,9 @@ --warn: #fff0c9; --table-alt: #f9fbfc; --white: #ffffff; + --page-gutter: clamp(14px, 1.8vw, 24px); + --panel-pad: clamp(16px, 1.4vw, 20px); + --page-frame-width: min(1520px, calc(100vw - (var(--page-gutter) * 2))); } * { @@ -33,21 +36,25 @@ radial-gradient(circle at top left, rgba(255, 255, 255, 0.9), transparent 28%), linear-gradient(155deg, var(--bg-a), var(--bg-b)); min-height: 100vh; - padding: 24px; + padding: var(--page-gutter); } .page { - max-width: 1480px; + width: min(100%, var(--page-frame-width)); margin: 0 auto; display: grid; - gap: 20px; + gap: var(--page-gutter); + } + + .page > * { + width: 100%; } .panel { background: var(--panel); border: 1px solid rgba(255, 255, 255, 0.6); border-radius: 24px; - padding: 24px; + padding: var(--panel-pad); box-shadow: 0 20px 45px rgba(51, 76, 92, 0.12); backdrop-filter: blur(10px); } diff --git a/templates/projects.html b/templates/projects.html index d61588c..783feeb 100644 --- a/templates/projects.html +++ b/templates/projects.html @@ -160,17 +160,21 @@ .analysis-shell { display: grid; gap: 16px; + overflow: visible; } .analysis-toolbar { display: grid; gap: 12px; align-items: start; + position: relative; + z-index: 120; } .analysis-main { display: grid; gap: 16px; + overflow: visible; } .analysis-summary-list { @@ -182,6 +186,9 @@ border: 1px solid var(--line); border-radius: 16px; padding: 14px; + position: relative; + overflow: visible; + z-index: 2; } .toolbar-head { @@ -192,6 +199,15 @@ margin-bottom: 10px; } + .toolbar-title-group { + display: flex; + align-items: center; + gap: 10px; + flex-wrap: wrap; + min-width: 0; + flex: 1; + } + .toolbar-field h3 { font-size: 16px; margin-bottom: 0; @@ -203,6 +219,67 @@ gap: 8px; } + .project-quick-link-bar { + display: flex; + flex-wrap: wrap; + gap: 8px; + align-items: center; + min-width: 0; + } + + .project-quick-link { + display: inline-flex; + align-items: center; + justify-content: space-between; + gap: 8px; + min-width: 0; + max-width: 190px; + padding: 8px 12px; + border: 1px solid var(--line); + border-radius: 999px; + background: rgba(255, 255, 255, 0.96); + color: var(--ink); + font-size: 12px; + font-weight: 700; + line-height: 1.3; + cursor: pointer; + transition: border-color 0.16s ease, box-shadow 0.16s ease, transform 0.16s ease; + } + + .project-quick-link:hover { + border-color: #c8d7e5; + box-shadow: 0 10px 18px rgba(22, 33, 50, 0.08); + transform: translateY(-1px); + } + + .project-quick-link span { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .project-quick-link-remove { + border: none; + background: transparent; + padding: 0; + margin: 0; + color: var(--muted); + display: inline-flex; + align-items: center; + justify-content: center; + cursor: pointer; + flex: 0 0 auto; + } + + .project-quick-link-remove svg { + width: 14px; + height: 14px; + stroke: currentColor; + stroke-width: 2; + fill: none; + } + .toggle-analysis-button svg { transition: transform 0.16s ease; } @@ -222,6 +299,7 @@ position: relative; display: grid; gap: 8px; + z-index: 180; } .project-search-dropdown { @@ -230,7 +308,7 @@ top: calc(100% + 8px); left: 0; right: 0; - z-index: 12; + z-index: 4000; border: 1px solid var(--line); border-radius: 14px; background: rgba(255, 255, 255, 0.98); @@ -609,6 +687,9 @@ border-top: 1px solid #eef2f5; font-size: 12px; text-align: left; + align-items: center; + min-height: 38px; + box-sizing: border-box; } .comparison-detail-item:first-child { @@ -618,6 +699,9 @@ .comparison-detail-item span:first-child { color: var(--muted); line-height: 1.45; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; } .comparison-detail-item strong { @@ -625,6 +709,58 @@ text-align: right; } + .comparison-detail-item.is-subtotal, + .comparison-detail-item.is-total { + background: #f8fafc; + font-weight: 800; + } + + .comparison-detail-item.is-total { + background: #f3f6f9; + } + + .comparison-detail-item.is-placeholder { + visibility: hidden; + } + + .comparison-detail-item.is-warning { + display: flex; + justify-content: center; + align-items: center; + padding: 8px 10px; + min-height: 38px; + } + + .comparison-detail-warning-box { + border: 2px solid #dc2626; + border-radius: 12px; + padding: 10px 14px; + display: inline-flex; + flex-direction: column; + align-items: center; + justify-content: center; + text-align: center; + gap: 4px; + max-width: 100%; + box-sizing: border-box; + background: #fff; + } + + .comparison-detail-warning-box span:first-child { + color: #dc2626; + font-size: 15px; + font-weight: 900; + line-height: 1.25; + } + + .comparison-detail-warning-box strong { + color: #dc2626; + font-size: 12px; + font-weight: 800; + line-height: 1.35; + text-align: center; + } + .analysis-kpis { display: grid; grid-template-columns: repeat(6, minmax(0, 1fr)); @@ -675,6 +811,22 @@ background: #f5f8fb; } + .comparison-note-input { + width: 100%; + min-height: 24px; + padding: 6px 8px; + border: 1px solid #d7dfe7; + border-radius: 10px; + background: #fff; + font: inherit; + color: var(--ink); + resize: vertical; + } + + .comparison-note-input::placeholder { + color: #9aa8b5; + } + .delta-positive { color: #15803d; font-weight: 800; @@ -737,18 +889,31 @@ #projectUncontractedDashboard { padding: 20px; + position: relative; + z-index: 1; + } + + .analysis-root-panel { + position: relative; + z-index: 40; + overflow: visible; + } + + .project-uncontracted-panel { + position: relative; + z-index: 1; } .uncontracted-toolbar { display: grid; - grid-template-columns: repeat(2, 180px) 1fr; + grid-template-columns: repeat(2, 180px) auto; gap: 12px; align-items: end; } .uncontracted-summary-grid { display: grid; - grid-template-columns: repeat(5, minmax(0, 1fr)); + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 10px; } @@ -759,17 +924,71 @@ background: #fbfcfd; display: grid; gap: 4px; + text-align: left; + align-content: start; + justify-items: start; + min-width: 0; + min-height: 96px; + box-sizing: border-box; + box-shadow: none; + } + + .uncontracted-stat.is-clickable { + cursor: pointer; + transition: transform 0.16s ease, box-shadow 0.16s ease, border-color 0.16s ease; + } + + .uncontracted-stat.is-clickable:hover, + .uncontracted-stat.is-clickable.is-active { + border-color: #c8d7e5; + box-shadow: 0 12px 22px rgba(22, 33, 50, 0.08); + transform: translateY(-1px); } .uncontracted-stat span { color: var(--muted); font-size: 12px; + display: block; + line-height: 1.35; + white-space: normal; + word-break: keep-all; } .uncontracted-stat strong { + color: var(--ink); font-size: 20px; line-height: 1.2; word-break: break-word; + display: block; + } + + .uncontracted-stat em { + color: var(--muted); + font-size: 12px; + font-style: normal; + line-height: 1.4; + font-weight: 700; + text-align: left; + display: block; + white-space: normal; + word-break: keep-all; + } + + .uncontracted-stat-copy { + display: grid; + gap: 4px; + min-width: 0; + width: 100%; + text-align: left; + align-content: start; + justify-items: start; + } + + .uncontracted-detail-summary { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 10px; + margin-bottom: 10px; } .uncontracted-dashboard-grid { @@ -809,6 +1028,19 @@ text-align: right; } + .compact-table .classification-cell, + .compact-table th.classification-cell { + text-align: center; + width: 180px; + } + + .detail-classification-select { + width: 100%; + min-width: 0; + padding: 8px 10px; + font-size: 12px; + } + .compact-table .interactive-row { cursor: pointer; } @@ -1150,7 +1382,7 @@ top: calc(100% + 6px); left: 0; right: 0; - z-index: 5; + z-index: 2800; border: 1px solid var(--line); border-radius: 10px; background: #ffffff; @@ -1415,6 +1647,21 @@ padding: 8px 10px; display: grid; gap: 3px; + text-align: left; + align-content: start; + justify-items: start; + box-sizing: border-box; + } + + .summary-chip.is-clickable { + cursor: pointer; + transition: transform 0.16s ease, box-shadow 0.16s ease, border-color 0.16s ease; + } + + .summary-chip.is-clickable:hover { + border-color: #c8d7e5; + box-shadow: 0 12px 22px rgba(22, 33, 50, 0.08); + transform: translateY(-1px); } .summary-chip span { @@ -1712,13 +1959,16 @@ {% endblock %} {% block content %} -
+
-

프로젝트 검색

+
+

프로젝트 검색

+ +
-
+
@@ -2389,6 +2647,71 @@ + + + + {% endblock %} {% block script %} @@ -2398,8 +2721,12 @@ const supportDepartmentOptions = {{ support_department_options | tojson }}; const costDepartmentOptions = {{ cost_department_options | tojson }}; const costAccountOptions = {{ cost_account_options | tojson }}; - const laborGradeOptions = ["사장", "부사장", "전무", "상무", "이사", "부장", "차장", "과장", "대리", "사원", "수석", "책임", "선임", "연구원"]; + const laborGradeOptions = {{ labor_grade_options | tojson }}; + const uncontractedCategoryOptions = {{ uncontracted_category_options | tojson }}; + const specialXClassificationRules = {{ special_x_classification_rules | tojson }}; + const projectRuntimeSettings = {{ project_runtime_settings | tojson }}; let projectEdit = {{ project_edit | tojson }}; + const serverFocusCode = {{ project_focus_code | tojson }}; const projectEditCache = new Map(); let modalOriginalEdit = null; const revenueMix = {{ project_revenue_mix | tojson }}; @@ -2407,13 +2734,26 @@ const projectCostRows = {{ project_cost_by_year | tojson }}; const projectMonthlyCostRows = {{ project_monthly_cost_rows | tojson }}; const projectStatusRows = {{ project_status_rows | tojson }}; + const projectComparisonNotes = {{ project_comparison_notes | tojson }}; const persistedProjectPageState = {{ project_page_state | tojson }}; const persistedProjectRelatedLinks = {{ project_related_links | tojson }}; + const persistedUncontractedCategoryOverrides = {{ project_uncontracted_classifications | tojson }}; + const projectPageSessionId = window.clientSessionId || ""; const projectStatusMap = Object.fromEntries(projectStatusRows.map((item) => [item.support_dept_code, item])); const projectAccountBreakdowns = {{ project_account_breakdowns | tojson }}; const projectCostMap = Object.fromEntries(projectCostRows.map((item) => [item.support_dept_code, item])); const currencyFormatter = new Intl.NumberFormat("ko-KR", { maximumFractionDigits: 0 }); + window.__projectRelatedSelections = new Map(); + const persistedUncontractedFilterState = { + start_year: String(persistedProjectPageState?.uncontracted_year_start || ""), + end_year: String(persistedProjectPageState?.uncontracted_year_end || ""), + }; let currentSelectedProjectCode = projectEdit.support_dept_code || ""; + const projectStatusForm = document.getElementById("projectStatusForm"); + const modal = document.getElementById("projectModal"); + const openModalButton = document.getElementById("openProjectModal"); + const closeModalButton = document.getElementById("closeProjectModal"); + const saveProjectModalButton = document.getElementById("saveProjectModal"); const supportDeptCodeLookup = document.getElementById("supportDeptCodeLookup"); const supportDeptCodeInput = document.getElementById("support_dept_code"); const supportDeptNameInput = document.getElementById("support_dept_name"); @@ -2431,7 +2771,6 @@ const contractAmountInput = document.getElementById("contract_amount"); const expectedAsRateInput = document.getElementById("expected_as_rate"); const expectedSgaRateInput = document.getElementById("expected_sga_rate"); - const projectStatusForm = document.getElementById("projectStatusForm"); const editRevisionInput = projectStatusForm?.querySelector('input[name="edit_revision"]'); const collectionRows = document.getElementById("collectionRows"); const taskPlanDepartmentRows = document.getElementById("taskPlanDepartmentRows"); @@ -2450,7 +2789,16 @@ const laborRateRows = document.getElementById("laborRateRows"); const openLaborRateModalButton = document.getElementById("openLaborRateModal"); const closeLaborRateModalButton = document.getElementById("closeLaborRateModal"); + const detailYearSettingModal = document.getElementById("detailYearSettingModal"); + const detailVisibleMinYearInput = document.getElementById("detailVisibleMinYearInput"); + const closeDetailYearSettingModalButton = document.getElementById("closeDetailYearSettingModal"); + const saveDetailYearSettingButton = document.getElementById("saveDetailYearSetting"); + const excludedDetailItemsModal = document.getElementById("excludedDetailItemsModal"); + const closeExcludedDetailItemsModalButton = document.getElementById("closeExcludedDetailItemsModal"); + const excludedDetailItemsSummary = document.getElementById("excludedDetailItemsSummary"); + const excludedDetailItemsRows = document.getElementById("excludedDetailItemsRows"); let currentLaborRates = {}; + let latestExcludedDetailRows = []; yearFilter?.addEventListener("change", (event) => { const nextYear = event.target.value; @@ -2565,6 +2913,16 @@ return String(Number(numeric.toFixed(2))); } + const collectionProgressTypeOptions = {{ collection_progress_type_options | tojson }}; + const collectionBillingTypeOptions = {{ collection_billing_type_options | tojson }}; + + function renderSelectOptions(options, selectedValue) { + const currentValue = String(selectedValue || ""); + return options.map((option) => ( + `` + )).join(""); + } + function parseDurationMinutes(value) { const raw = String(value ?? "").trim().toLowerCase(); if (!raw) return 0; @@ -2716,7 +3074,21 @@ document.getElementById("collectionBilledTotalDisplay").textContent = `${formatNumber(billedTotal)}원`; document.getElementById("collectionTotalDisplay").textContent = `${formatNumber(collectionTotal)}원`; updateCollectionContractCells(contractAmount, collectionTotal); - document.getElementById("progress_rate_display").textContent = `${progressRate.toFixed(2)}%`; + document.getElementById("progress_rate_display").textContent = `${progressRate.toFixed(1)}%`; + + const expectedAsCost = contractAmount * parseAmount(expectedAsRateInput?.value) / 100; + const expectedSgaBudget = contractAmount * parseAmount(expectedSgaRateInput?.value) / 100; + const expectedAsCostField = document.getElementById("expected_as_cost_display"); + const expectedSgaBudgetField = document.getElementById("expected_sga_budget_display"); + if (expectedAsCostField && document.activeElement !== expectedAsCostField) { + const keepManual = parseAmount(expectedAsCostField.value); + expectedAsCostField.value = keepManual ? formatAmountInputValue(keepManual) : (expectedAsCost ? formatAmountInputValue(expectedAsCost) : ""); + } + if (expectedSgaBudgetField && document.activeElement !== expectedSgaBudgetField) { + const keepManual = parseAmount(expectedSgaBudgetField.value); + expectedSgaBudgetField.value = keepManual ? formatAmountInputValue(keepManual) : (expectedSgaBudget ? formatAmountInputValue(expectedSgaBudget) : ""); + } + syncExpectedAllocationsToActualInputs(); const taskPlanTotal = getAllTaskPlanAmountInputs() .reduce((sum, input) => sum + parseAmount(input.value), 0); @@ -2727,11 +3099,48 @@ document.getElementById("taskPlanTotalDisplay").textContent = formatNumber(taskPlanTotal); document.getElementById("execBudgetTotalDisplay").textContent = formatNumber(execBudgetTotal); document.getElementById("actualInputTotalDisplay").textContent = formatNumber(actualInputTotal); + } - const expectedAsCost = contractAmount * parseAmount(expectedAsRateInput?.value) / 100; - const expectedSgaBudget = contractAmount * parseAmount(expectedSgaRateInput?.value) / 100; - document.getElementById("expected_as_cost_display").textContent = `${formatNumber(expectedAsCost)}원`; - document.getElementById("expected_sga_budget_display").textContent = `${formatNumber(expectedSgaBudget)}원`; + function syncExpectedAllocationsToActualInputs() { + const expectedAsAmount = parseAmount(document.getElementById("expected_as_cost_display")?.value); + const expectedSgaAmount = parseAmount(document.getElementById("expected_sga_budget_display")?.value); + const ensureAutoSyncTarget = (container, labelText, expectedAmount) => { + let syncInputs = [...container.querySelectorAll('[data-auto-sync="true"]')]; + if (syncInputs.length) return syncInputs; + const fallbackRow = [...container.querySelectorAll("tr")].find((row) => { + const labelInput = row.querySelector('input[type="text"][name$="_label[]"], input[type="text"]'); + const amountInput = row.querySelector('[data-amount-input]'); + const normalizedLabel = String(labelInput?.value || "").trim(); + const amountValue = parseAmount(amountInput?.value); + return normalizedLabel === labelText && (!amountValue || amountValue === expectedAmount); + }); + const fallbackInput = fallbackRow?.querySelector('[data-amount-input]'); + if (fallbackInput) { + fallbackInput.dataset.autoSync = "true"; + syncInputs = [fallbackInput]; + } + return syncInputs; + }; + const asSyncInputs = ensureAutoSyncTarget(actualAsRows, "A/S비", expectedAsAmount); + const sgaSyncInputs = ensureAutoSyncTarget(actualSgaRows, "판관비", expectedSgaAmount); + asSyncInputs.forEach((input) => { + input.value = expectedAsAmount ? formatAmountInputValue(expectedAsAmount) : ""; + }); + sgaSyncInputs.forEach((input) => { + input.value = expectedSgaAmount ? formatAmountInputValue(expectedSgaAmount) : ""; + }); + } + + function upsertProjectStatusRow(projectRow) { + if (!projectRow?.support_dept_code) return; + const code = String(projectRow.support_dept_code); + projectStatusMap[code] = projectRow; + const index = projectStatusRows.findIndex((item) => item.support_dept_code === code); + if (index >= 0) { + projectStatusRows[index] = projectRow; + } else { + projectStatusRows.push(projectRow); + } } function createCollectionRow(row = {}) { @@ -2742,7 +3151,9 @@
- +
@@ -2750,7 +3161,9 @@
- +
@@ -2921,10 +3334,11 @@ function createActualCostRow(group, row = {}) { const fieldPrefix = group === "as" ? "actual_as" : (group === "sga" ? "actual_sga" : "actual_labor_joint"); const labelValue = row.label || row.note || ""; + const autoSync = row.auto_sync === true; return ` - +
+
진행율 - ${Number(item.progress_rate || 0).toFixed(2)}% + ${Number(item.progress_rate || 0).toFixed(1)}%
종료여부 @@ -4104,15 +4978,57 @@ return `${Number(month || 0)}월`; } + function getUncontractedCategoryLabel(category) { + const matched = (uncontractedCategoryOptions || []).find((item) => item.value === category); + return matched?.label || "일반 미계약"; + } + + function getProjectCodeYear(code) { + const match = String(code || "").match(/^[A-Z](\d{2})/i); + return match ? Number(match[1]) : 0; + } + + function getProjectCodeFullYear(code) { + const shortYear = getProjectCodeYear(code); + if (!shortYear) return 0; + return 2000 + shortYear; + } + + function hasProjectVariantMarker(name) { + return /(\d+\s*차|[nN]\s*차|변경|보완|지연보상금|가칭|연차|년분)/.test(String(name || "")); + } + + function getCurrentRelatedCodes(baseCode) { + const selectionMap = window.__projectRelatedSelections; + if (!(selectionMap instanceof Map)) return []; + return [...(selectionMap.get(baseCode) || new Set())]; + } + let uncontractedDetailSelection = { type: "", year: null, month: null }; let uncontractedDetailVisible = false; function getUncontractedYearRange() { - const years = [...new Set(projectCostRows.map((item) => Number(item.year || 0)).filter(Boolean))].sort((a, b) => a - b); + const currentYear = new Date().getFullYear(); + const dataYears = [...new Set(projectCostRows.map((item) => Number(item.year || 0)).filter(Boolean))].sort((a, b) => a - b); + const minYear = 1994; + const maxYear = Math.max(currentYear, dataYears[dataYears.length - 1] || currentYear); + const years = Array.from({ length: Math.max(0, maxYear - minYear + 1) }, (_, index) => minYear + index); + const fallbackStart = Number(persistedUncontractedFilterState.start_year || years[0] || minYear); + const fallbackEnd = Number(persistedUncontractedFilterState.end_year || years[years.length - 1] || maxYear); return { allYears: years, - startYear: Number(document.getElementById("uncontractedYearStart")?.value || years[0] || 0), - endYear: Number(document.getElementById("uncontractedYearEnd")?.value || years[years.length - 1] || 0), + startYear: Number( + document.getElementById("uncontractedYearStart")?.value + || persistedUncontractedFilterState.start_year + || fallbackStart + || 0 + ), + endYear: Number( + document.getElementById("uncontractedYearEnd")?.value + || persistedUncontractedFilterState.end_year + || fallbackEnd + || 0 + ), }; } @@ -4125,17 +5041,41 @@ const filteredAggregate = aggregateProjectCostRows(filteredRows).filter((item) => { const merged = getAnalysisItem(item.support_dept_code) || item; return getProjectContractState(merged) !== "contracted"; + }).filter((item) => Number(item.expense_amount || 0) > 0); + const filteredAggregateMap = new Map(filteredAggregate.map((item) => [item.support_dept_code, item])); + const filteredExpenseRows = filteredRows.filter((row) => { + if (!filteredAggregateMap.has(row.support_dept_code)) return false; + return Number(row.expense_amount || 0) > 0; }); const summary = { uncontracted_projects: filteredAggregate.length, - cost_incurred_projects: filteredAggregate.filter((item) => Number(item.expense_amount || 0) > 0).length, + cost_incurred_projects: filteredAggregate.length, expense_amount: filteredAggregate.reduce((sum, item) => sum + Number(item.expense_amount || 0), 0), revenue_amount: filteredAggregate.reduce((sum, item) => sum + Number(item.revenue_amount || 0), 0), review_needed_projects: filteredAggregate.filter((item) => Boolean(projectStatusMap[item.support_dept_code]?.review_tag)).length, + precontract_projects: 0, + precontract_expense_amount: 0, + corporate_rnd_projects: 0, + corporate_rnd_expense_amount: 0, + external_research_projects: 0, + external_research_expense_amount: 0, }; + filteredAggregate.forEach((item) => { + const type = classifyUncontractedSpecialType(item); + if (type === "corporate_rnd") { + summary.corporate_rnd_projects += 1; + summary.corporate_rnd_expense_amount += Number(item.expense_amount || 0); + } else if (type === "external_research") { + summary.external_research_projects += 1; + summary.external_research_expense_amount += Number(item.expense_amount || 0); + } else if (type === "precontract") { + summary.precontract_projects += 1; + summary.precontract_expense_amount += Number(item.expense_amount || 0); + } + }); const yearlyMap = new Map(); - filteredRows.forEach((row) => { + filteredExpenseRows.forEach((row) => { const merged = getAnalysisItem(row.support_dept_code) || row; if (getProjectContractState(merged) === "contracted") return; const year = Number(row.year || 0); @@ -4146,16 +5086,20 @@ expense_amount: 0, }; current.uncontracted_projects.add(row.support_dept_code); - if (Number(row.expense_amount || 0) > 0) current.cost_incurred_projects.add(row.support_dept_code); + current.cost_incurred_projects.add(row.support_dept_code); current.expense_amount += Number(row.expense_amount || 0); yearlyMap.set(year, current); }); - const yearlyRows = [...yearlyMap.values()].sort((a, b) => a.year - b.year).map((row) => ({ - year: row.year, - uncontracted_projects: row.uncontracted_projects.size, - cost_incurred_projects: row.cost_incurred_projects.size, - expense_amount: row.expense_amount, - })); + const yearlyRows = []; + for (let year = startYear; year <= endYear; year += 1) { + const row = yearlyMap.get(year); + yearlyRows.push({ + year, + uncontracted_projects: row?.uncontracted_projects.size || 0, + cost_incurred_projects: row?.cost_incurred_projects.size || 0, + expense_amount: row?.expense_amount || 0, + }); + } const focusYear = Number(uncontractedDetailSelection.year || yearlyRows[yearlyRows.length - 1]?.year || endYear || 0); const monthlyMap = new Map(); @@ -4171,6 +5115,7 @@ expense_amount: 0, }; const expenseAmount = Number(row.cost_sum || 0) + Number(row.sga_sum || 0); + if (expenseAmount <= 0) return; if (expenseAmount > 0) current.cost_incurred_projects.add(row.support_dept_code); current.expense_amount += expenseAmount; monthlyMap.set(month, current); @@ -4182,6 +5127,7 @@ })); let detailRows = []; + let excludedDetailRows = []; const detailTitle = "세부 내역"; if (uncontractedDetailSelection.type === "month" && uncontractedDetailSelection.year && uncontractedDetailSelection.month) { const map = new Map(); @@ -4201,10 +5147,25 @@ detailRows = [...map.values()].sort((a, b) => Number(b.expense_amount || 0) - Number(a.expense_amount || 0)); } else if (uncontractedDetailSelection.type === "year" && uncontractedDetailSelection.year) { const map = new Map(); + const excludedMap = new Map(); filteredRows.forEach((row) => { if (Number(row.year || 0) !== Number(uncontractedDetailSelection.year)) return; const merged = getAnalysisItem(row.support_dept_code) || row; if (getProjectContractState(merged) === "contracted") return; + if (Number(row.expense_amount || 0) <= 0) return; + const projectCreatedYear = getProjectCodeFullYear(row.support_dept_code || ""); + if (!projectCreatedYear || projectCreatedYear < startYear || projectCreatedYear > endYear) { + const excludedCurrent = excludedMap.get(row.support_dept_code) || { + support_dept_code: row.support_dept_code, + support_dept_name: row.support_dept_name, + expense_amount: 0, + created_year: projectCreatedYear || null, + reason: "현재 연도 필터 범위 밖에서 생성된 프로젝트", + }; + excludedCurrent.expense_amount += Number(row.expense_amount || 0); + excludedMap.set(row.support_dept_code, excludedCurrent); + return; + } const current = map.get(row.support_dept_code) || { support_dept_code: row.support_dept_code, support_dept_name: row.support_dept_name, @@ -4216,9 +5177,40 @@ map.set(row.support_dept_code, current); }); detailRows = [...map.values()].sort((a, b) => Number(b.expense_amount || 0) - Number(a.expense_amount || 0)); + excludedDetailRows = [...excludedMap.values()].sort((a, b) => Number(b.expense_amount || 0) - Number(a.expense_amount || 0)); + } else if (uncontractedDetailSelection.type === "category" && uncontractedDetailSelection.category) { + const map = new Map(); + filteredAggregate.forEach((item) => { + if (classifyUncontractedSpecialType(item) !== uncontractedDetailSelection.category) return; + map.set(item.support_dept_code, { + support_dept_code: item.support_dept_code, + support_dept_name: item.support_dept_name, + expense_amount: Number(item.expense_amount || 0), + revenue_amount: Number(item.revenue_amount || 0), + category: classifyUncontractedSpecialType(item), + }); + }); + detailRows = [...map.values()].sort((a, b) => Number(b.expense_amount || 0) - Number(a.expense_amount || 0)); } - return { startYear, endYear, summary, yearlyRows, monthlyRows, focusYear, detailRows, detailTitle }; + const selectedYearlyRow = uncontractedDetailSelection.type === "year" + ? yearlyRows.find((row) => Number(row.year) === Number(uncontractedDetailSelection.year)) + : null; + const detailExpenseTotal = detailRows.reduce((sum, row) => sum + Number(row.expense_amount || 0), 0); + const detailExpenseGap = Math.max(0, Number(selectedYearlyRow?.expense_amount || 0) - detailExpenseTotal); + + return { + startYear, + endYear, + summary, + yearlyRows, + monthlyRows, + focusYear, + detailRows, + excludedDetailRows, + detailExpenseGap, + detailTitle, + }; } function renderUncontractedDashboard() { @@ -4226,6 +5218,9 @@ const allYears = getUncontractedYearRange().allYears; const startOptions = allYears.map((year) => ``).join(""); const endOptions = allYears.map((year) => ``).join(""); + const detailExpenseTotal = (data.detailRows || []).reduce((sum, row) => sum + Number(row.expense_amount || 0), 0); + const detailRevenueTotal = (data.detailRows || []).reduce((sum, row) => sum + Number(row.revenue_amount || 0), 0); + latestExcludedDetailRows = data.excludedDetailRows || []; return `
@@ -4237,6 +5232,14 @@
+
@@ -4245,6 +5248,21 @@
발생 비용${formatDisplayAmount(data.summary.expense_amount || 0)}
관련 수금${formatDisplayAmount(data.summary.revenue_amount || 0)}
검토 필요${formatNumber(data.summary.review_needed_projects || 0)}
+
+ 사전 사업 코드 + ${formatNumber(data.summary.precontract_projects || 0)} + ${formatDisplayAmount(data.summary.precontract_expense_amount || 0)} +
+
+ 기업 연구개발 + ${formatNumber(data.summary.corporate_rnd_projects || 0)} + ${formatDisplayAmount(data.summary.corporate_rnd_expense_amount || 0)} +
+
+ 외부 연구과제 + ${formatNumber(data.summary.external_research_projects || 0)} + ${formatDisplayAmount(data.summary.external_research_expense_amount || 0)} +
@@ -4290,12 +5308,29 @@
${uncontractedDetailVisible && uncontractedDetailSelection.type ? `
+
+
+ 세부 내역 합계 + ${formatNumber(detailExpenseTotal)} +
+ ${uncontractedDetailSelection.type === "year" ? ` +
+ 연도별 발생비용 차액 + ${formatNumber(data.detailExpenseGap || 0)} +
+ ` : `
연도별 발생비용 차액0
`} +
+ 관련수금 합계 + ${formatNumber(detailRevenueTotal)} +
+
${uncontractedDetailSelection.type === "month" ? "" : ""} + @@ -4304,8 +5339,15 @@ ${uncontractedDetailSelection.type === "month" ? "" : ``} + - `).join("") : ``} + `).join("") : ``}
프로젝트 발생 비용관련 수금섹터 수정
${escapeHtml(row.support_dept_code)} · ${escapeHtml(row.support_dept_name)} ${formatNumber(row.expense_amount || 0)}${formatNumber(row.revenue_amount || 0)} + +
표시할 세부 내역이 없습니다.
표시할 세부 내역이 없습니다.
@@ -4376,6 +5418,7 @@ function renderAnalysisComparison(item) { const details = buildComparisonDetails(item); + const comparisonNotes = projectComparisonNotes?.[item.support_dept_code] || {}; const rows = [ ["수금", item.collection_amount || 0, item.total_revenue || 0, "collection"], ["인건비", sumEntryAmounts(details.labor.planned), sumEntryAmounts(details.labor.actual) + sumEntryAmounts(details.labor.actual_joint), "labor"], @@ -4393,12 +5436,15 @@ 계획/입력값 실제 집행값 차이 + 비고 ${rows.map(([label, planned, actual, key]) => { const detail = key ? details[key] : null; const hasDetail = Boolean(detail); + const plannedEntries = detail?.planned || []; + const actualEntries = detail?.actual || []; return ` @@ -4414,27 +5460,32 @@ ${formatComparisonCell(planned)} ${formatComparisonCell(actual)} ${formatComparisonDiff(planned, actual)} + + + ${hasDetail ? ` - +
-
-
-
계획/입력 세부
-
- ${renderComparisonDetailList(detail.planned, "등록된 계획 세부 항목이 없습니다.")} -
-
-
-
실제 집행 세부
- ${key === "labor" - ? renderLaborActualDetail(detail) - : `
- ${renderComparisonDetailList(detail.actual, "연결된 실제 계정이 없습니다.")} -
`} -
-
+ ${key === "labor" + ? ` + ${renderLaborComparisonDetails( + plannedEntries, + detail?.actual || [], + detail?.actual_joint || [], + )} + ` + : renderPairedComparisonDetails( + plannedEntries, + actualEntries, + "", + "", + )}
` : ""} @@ -4675,7 +5726,7 @@
${item.support_dept_name} - ${item.support_dept_code} · 진행율 ${Number(item.progress_rate || 0).toFixed(2)}% · 수금 ${formatDisplayAmount(item.collection_amount)} + ${item.support_dept_code} · 진행율 ${Number(item.progress_rate || 0).toFixed(1)}% · 수금 ${formatDisplayAmount(item.collection_amount)}
+
+ `).join(""); + } + + async function loadProjectQuickLinksFromServer() { + try { + const response = await fetch("/projects/quick-links", { + method: "GET", + credentials: "same-origin", + headers: { + "Accept": "application/json", + }, + }); + if (!response.ok) { + throw new Error(`HTTP ${response.status}`); + } + const payload = await response.json(); + if (payload?.error) { + throw new Error(payload.error); + } + projectQuickLinks = Array.isArray(payload?.codes) ? payload.codes.filter(Boolean) : []; + } catch (error) { + console.warn("Failed to load project quick links", error); + } + renderProjectQuickLinks(); + } + + async function saveProjectQuickLinksToServer(showFeedback = false) { + try { + const response = await fetch("/projects/quick-links", { + method: "POST", + credentials: "same-origin", + headers: { + "Content-Type": "application/json", + "Accept": "application/json", + }, + body: JSON.stringify({ + session_id: projectPageSessionId, + codes: projectQuickLinks, + }), + }); + if (!response.ok) { + throw new Error(`HTTP ${response.status}`); + } + const payload = await response.json(); + if (payload?.error) { + throw new Error(payload.error); + } + projectQuickLinks = Array.isArray(payload?.codes) ? payload.codes.filter(Boolean) : projectQuickLinks; + renderProjectQuickLinks(); + if (showFeedback) { + window.alert("프로젝트 바로가기를 저장했습니다."); + } + } catch (error) { + console.error(error); + window.alert("프로젝트 바로가기를 저장하지 못했습니다."); + } + } + + async function addProjectQuickLink(code) { + const normalizedCode = String(code || "").trim(); + if (!normalizedCode) return; + projectQuickLinks = [normalizedCode, ...projectQuickLinks.filter((item) => item !== normalizedCode)].slice(0, 6); + renderProjectQuickLinks(); + await saveProjectQuickLinksToServer(true); + } + + async function removeProjectQuickLink(code) { + const normalizedCode = String(code || "").trim(); + if (!normalizedCode) return; + projectQuickLinks = projectQuickLinks.filter((item) => item !== normalizedCode); + renderProjectQuickLinks(); + await saveProjectQuickLinksToServer(false); + } + + async function saveComparisonNoteToServer(code, itemKey, note) { + const response = await fetch("/projects/comparison-note", { + method: "POST", + credentials: "same-origin", + headers: { + "Content-Type": "application/json", + "Accept": "application/json", + }, + body: JSON.stringify({ + support_dept_code: code, + item_key: itemKey, + note, + }), + }); + const payload = await response.json().catch(() => ({})); + if (!response.ok || payload?.error) { + throw new Error(payload?.error || "비고를 저장하지 못했습니다."); + } + projectComparisonNotes[code] = projectComparisonNotes[code] || {}; + projectComparisonNotes[code][itemKey] = note; + } + function renderAnalysis(item) { if (!item) { relatedBarBox.innerHTML = ""; @@ -5086,7 +6376,7 @@ metricsBox.innerHTML = ""; comparisonBox.innerHTML = ""; notesBox.innerHTML = ""; - uncontractedBox.innerHTML = renderUncontractedDashboard(yearSelect?.value || ""); + scheduleStandaloneUncontractedDashboardRender(); return; } const relatedItems = getRelatedItems(item.support_dept_code); @@ -5096,6 +6386,11 @@ metricsBox.innerHTML = renderAnalysisMetrics(aggregateItem); comparisonBox.innerHTML = renderAnalysisComparison(aggregateItem); notesBox.innerHTML = renderAnalysisNotes(aggregateItem); + heroBox.querySelectorAll("[data-set-quick-link]").forEach((button) => { + button.addEventListener("click", async () => { + await addProjectQuickLink(button.dataset.setQuickLink || ""); + }); + }); relatedBarBox.querySelectorAll("[data-remove-related]").forEach((button) => { button.addEventListener("click", () => removeRelatedProject(button.dataset.removeRelated || "")); @@ -5111,6 +6406,24 @@ }); } + window.__projectPageHelpers = { + upsertProjectStatusRow, + refreshAnalysisForCode(code) { + const normalizedCode = String(code || "").trim(); + if (!normalizedCode) return; + selectedCode = normalizedCode; + currentSelectedProjectCode = normalizedCode; + const nextItem = getAnalysisItem(normalizedCode); + if (nextItem) { + if (input) { + input.value = nextItem.support_dept_name || ""; + } + setAnalysisVisibility(true); + renderAnalysis(nextItem); + } + }, + }; + function refresh(options = {}) { const { renderDetail = false } = options; const keyword = input?.value || ""; @@ -5133,12 +6446,82 @@ contractFilterSelect?.addEventListener("change", () => refresh({ renderDetail: true })); input?.addEventListener("focus", () => refresh({ renderDetail: false })); function renderStandaloneUncontractedDashboard() { - uncontractedBox.innerHTML = renderUncontractedDashboard(); + if (!uncontractedBox) return; + try { + uncontractedBox.innerHTML = renderUncontractedDashboard(); + } catch (error) { + console.error("미계약 비용 발생 현황 렌더 에러", error); + uncontractedBox.innerHTML = ` +
+
+

미계약 비용 발생 현황

+
+
+

미계약 비용 발생 현황을 불러오는 중 문제가 발생했습니다. 페이지를 새로고침한 뒤 다시 확인해 주세요.

+
+
+ `; + } } - uncontractedBox?.addEventListener("change", (event) => { + + let uncontractedDashboardRenderToken = null; + function scheduleStandaloneUncontractedDashboardRender(options = {}) { + const { urgent = false } = options; + if (uncontractedDashboardRenderToken) { + if (typeof uncontractedDashboardRenderToken === "number") { + window.clearTimeout(uncontractedDashboardRenderToken); + } else if (typeof window.cancelIdleCallback === "function") { + window.cancelIdleCallback(uncontractedDashboardRenderToken); + } + uncontractedDashboardRenderToken = null; + } + const runner = () => { + uncontractedDashboardRenderToken = null; + renderStandaloneUncontractedDashboard(); + }; + if (urgent) { + uncontractedDashboardRenderToken = window.setTimeout(runner, 0); + return; + } + if (typeof window.requestIdleCallback === "function") { + uncontractedDashboardRenderToken = window.requestIdleCallback(runner, { timeout: 600 }); + return; + } + uncontractedDashboardRenderToken = window.setTimeout(runner, 120); + } + uncontractedBox?.addEventListener("change", async (event) => { const startSelect = event.target.closest("#uncontractedYearStart"); const endSelect = event.target.closest("#uncontractedYearEnd"); - if (!startSelect && !endSelect) return; + const classificationSelect = event.target.closest("[data-uncontracted-classification]"); + if (!startSelect && !endSelect && !classificationSelect) return; + if (classificationSelect) { + const supportDeptCode = classificationSelect.dataset.uncontractedClassification || ""; + const category = classificationSelect.value || "general"; + try { + const response = await fetch("/projects/uncontracted-category", { + method: "POST", + credentials: "same-origin", + headers: { + "Content-Type": "application/json", + "Accept": "application/json", + }, + body: JSON.stringify({ + support_dept_code: supportDeptCode, + category, + }), + }); + const payload = await response.json(); + if (!response.ok || payload?.error) { + throw new Error(payload?.error || `HTTP ${response.status}`); + } + persistedUncontractedCategoryOverrides[supportDeptCode] = category; + scheduleStandaloneUncontractedDashboardRender({ urgent: true }); + } catch (error) { + console.error(error); + window.alert("미계약 섹터를 저장하지 못했습니다."); + } + return; + } const startYear = Number(document.getElementById("uncontractedYearStart")?.value || 0); const endYear = Number(document.getElementById("uncontractedYearEnd")?.value || 0); if (startYear && endYear && startYear > endYear) { @@ -5150,16 +6533,38 @@ } uncontractedDetailSelection = { type: "", year: null, month: null }; uncontractedDetailVisible = false; - renderStandaloneUncontractedDashboard(); + scheduleStandaloneUncontractedDashboardRender({ urgent: true }); }); uncontractedBox?.addEventListener("click", (event) => { const yearRow = event.target.closest("[data-uncontracted-year]"); const monthRow = event.target.closest("[data-uncontracted-month]"); + const categoryButton = event.target.closest("[data-uncontracted-category]"); const toggleButton = event.target.closest("#toggleUncontractedDetail"); + const saveFilterButton = event.target.closest("#saveUncontractedFilterState"); + const excludedItemsButton = event.target.closest("#openExcludedDetailItems"); const openProjectRow = event.target.closest("[data-open-project-code]"); if (toggleButton) { uncontractedDetailVisible = !uncontractedDetailVisible; - renderStandaloneUncontractedDashboard(); + scheduleStandaloneUncontractedDashboardRender({ urgent: true }); + return; + } + if (saveFilterButton) { + saveUncontractedFilterState(); + return; + } + if (excludedItemsButton) { + openExcludedDetailItemsModal(); + return; + } + if (categoryButton) { + uncontractedDetailSelection = { + type: "category", + category: categoryButton.dataset.uncontractedCategory || "general", + year: null, + month: null, + }; + uncontractedDetailVisible = true; + scheduleStandaloneUncontractedDashboardRender({ urgent: true }); return; } if (yearRow) { @@ -5169,7 +6574,7 @@ month: null, }; uncontractedDetailVisible = true; - renderStandaloneUncontractedDashboard(); + scheduleStandaloneUncontractedDashboardRender({ urgent: true }); return; } if (monthRow) { @@ -5179,16 +6584,31 @@ month: Number(monthRow.dataset.uncontractedMonth || 0), }; uncontractedDetailVisible = true; - renderStandaloneUncontractedDashboard(); + scheduleStandaloneUncontractedDashboardRender({ urgent: true }); return; } if (openProjectRow) { + if (event.target.closest(".detail-classification-select")) return; selectedCode = openProjectRow.dataset.openProjectCode || ""; currentSelectedProjectCode = selectedCode; setAnalysisVisibility(true); renderAnalysis(getAnalysisItem(selectedCode)); } }); + uncontractedBox?.addEventListener("keydown", (event) => { + const categoryButton = event.target.closest("[data-uncontracted-category]"); + const excludedItemsButton = event.target.closest("#openExcludedDetailItems"); + if (categoryButton) { + if (event.key !== "Enter" && event.key !== " ") return; + event.preventDefault(); + categoryButton.click(); + return; + } + if (!excludedItemsButton) return; + if (event.key !== "Enter" && event.key !== " ") return; + event.preventDefault(); + excludedItemsButton.click(); + }); listBox?.addEventListener("click", (event) => { const itemEl = event.target.closest(".explorer-item"); if (!itemEl) return; @@ -5200,6 +6620,28 @@ renderSuggestionList([], ""); renderAnalysis(getAnalysisItem(selectedCode)); }); + quickLinkBar?.addEventListener("click", (event) => { + const removeButton = event.target.closest("[data-remove-quick-link]"); + if (removeButton) { + event.stopPropagation(); + removeProjectQuickLink(removeButton.dataset.removeQuickLink || ""); + return; + } + const quickLinkButton = event.target.closest("[data-open-quick-link]"); + if (!quickLinkButton) return; + selectedCode = quickLinkButton.dataset.openQuickLink || ""; + currentSelectedProjectCode = selectedCode; + setAnalysisVisibility(true); + renderAnalysis(getAnalysisItem(selectedCode)); + }); + quickLinkBar?.addEventListener("keydown", (event) => { + const quickLinkButton = event.target.closest("[data-open-quick-link]"); + if (!quickLinkButton) return; + if (event.key !== "Enter" && event.key !== " ") return; + if (event.target.closest("[data-remove-quick-link]")) return; + event.preventDefault(); + quickLinkButton.click(); + }); relatedSearchInput?.addEventListener("input", refreshRelatedProjectResults); relatedModalCloseButton?.addEventListener("click", closeRelatedProjectPicker); relatedModal?.addEventListener("click", (event) => { @@ -5207,6 +6649,19 @@ closeRelatedProjectPicker(); } }); + closeDetailYearSettingModalButton?.addEventListener("click", closeDetailYearSettingModal); + saveDetailYearSettingButton?.addEventListener("click", saveDetailYearSetting); + detailYearSettingModal?.addEventListener("click", (event) => { + if (event.target === detailYearSettingModal) { + closeDetailYearSettingModal(); + } + }); + closeExcludedDetailItemsModalButton?.addEventListener("click", closeExcludedDetailItemsModal); + excludedDetailItemsModal?.addEventListener("click", (event) => { + if (event.target === excludedDetailItemsModal) { + closeExcludedDetailItemsModal(); + } + }); analysisToggle?.addEventListener("click", () => { const nextOpen = !analysisOpen; setAnalysisVisibility(nextOpen); @@ -5229,58 +6684,96 @@ }); uncontractedDetailSelection = { type: "", year: null, month: null }; uncontractedDetailVisible = false; - renderStandaloneUncontractedDashboard(); - // Always start from a clean first view: keep the latest project as the - // internal default selection, but hide the detail workspace until the - // user explicitly opens it or selects a search result. - setAnalysisVisibility(false); - refresh({ renderDetail: false }); + (async () => { + await loadProjectPageStateFromServer(); + await loadProjectQuickLinksFromServer(); + if (serverEditCode) { + selectedCode = serverEditCode; + currentSelectedProjectCode = serverEditCode; + analysisOpen = true; + } else if (initialFocusCode) { + selectedCode = initialFocusCode; + currentSelectedProjectCode = initialFocusCode; + analysisOpen = true; + } + if (!analysisOpen) { + // Keep the first view lightweight unless the current browser + // session explicitly saved an open detail state. + setAnalysisVisibility(false); + } else { + setAnalysisVisibility(true); + } + refresh({ renderDetail: false }); + const startupFocusCode = serverEditCode || initialFocusCode; + if (startupFocusCode) { + const initialItem = getAnalysisItem(startupFocusCode); + if (initialItem) { + if (input) { + input.value = initialItem.support_dept_name || ""; + } + setAnalysisVisibility(true); + renderAnalysis(initialItem); + } + } + scheduleStandaloneUncontractedDashboardRender(); + })(); + if (openModalButton) { + openModalButton.addEventListener("click", async () => { + openProjectModalForFreshEntry(""); + }); + } + + document.addEventListener("click", async (event) => { + const editButton = event.target.closest("[data-edit-code]"); + if (!editButton) return; + event.preventDefault(); + const fallbackHref = editButton.getAttribute("href") || ""; + const targetCode = editButton.dataset.editCode || ""; + try { + await openProjectModalForCode(targetCode); + } catch (error) { + console.error(error); + } + if (!modal?.classList.contains("open") && fallbackHref) { + window.location.href = fallbackHref; + } + }); + + document.addEventListener("focusout", async (event) => { + const noteInput = event.target.closest(".comparison-note-input"); + if (!noteInput) return; + const code = noteInput.dataset.comparisonNoteCode || ""; + const itemKey = noteInput.dataset.comparisonNoteKey || ""; + const note = noteInput.value.trim(); + const previous = (projectComparisonNotes?.[code] || {})[itemKey] || ""; + if (note === previous) return; + try { + await saveComparisonNoteToServer(code, itemKey, note); + } catch (error) { + console.error(error); + alert(error.message || "비고를 저장하지 못했습니다."); + noteInput.value = previous; + } + }); + + if (modal?.classList.contains("open") && projectEdit.support_dept_code) { + modalOriginalEdit = cloneProjectEditData(projectEdit); + applyProjectEditData(projectEdit); + } + + if (closeModalButton) { + closeModalButton.addEventListener("click", () => { + closeProjectModal(); + }); + } + + modal?.addEventListener("click", (event) => { + if (event.target === modal) { + closeProjectModal(); + } + }); + + renderRevenueChart(); })(); - - const modal = document.getElementById("projectModal"); - const openModalButton = document.getElementById("openProjectModal"); - const closeModalButton = document.getElementById("closeProjectModal"); - - if (openModalButton) { - openModalButton.addEventListener("click", async () => { - openProjectModalForFreshEntry(""); - }); - } - - document.addEventListener("click", async (event) => { - const editButton = event.target.closest("[data-edit-code]"); - if (!editButton) return; - event.preventDefault(); - await openProjectModalForCode(editButton.dataset.editCode || ""); - }); - - if (modal?.classList.contains("open") && projectEdit.support_dept_code) { - modalOriginalEdit = cloneProjectEditData(projectEdit); - applyProjectEditData(projectEdit); - } - - function closeProjectModal() { - if (modalOriginalEdit) { - applyProjectEditData(cloneProjectEditData(modalOriginalEdit)); - } - modal?.classList.remove("open"); - supportDeptCodeResults.classList.remove("open"); - supportDeptNameResults.classList.remove("open"); - document.activeElement?.blur?.(); - } - - if (closeModalButton) { - closeModalButton.addEventListener("click", () => { - closeProjectModal(); - }); - } - - modal?.addEventListener("click", (event) => { - if (event.target === modal) { - closeProjectModal(); - } - }); - - renderRevenueChart(); {% endblock %} diff --git a/변경계약금액현황(회계)_차수_20210101_20260409_260409.xlsx b/변경계약금액현황(회계)_차수_20210101_20260409_260409.xlsx new file mode 100644 index 0000000..eff0396 Binary files /dev/null and b/변경계약금액현황(회계)_차수_20210101_20260409_260409.xlsx differ diff --git a/변경계약금액현황(회계)_총괄_20210101_20260409_260409.xlsx b/변경계약금액현황(회계)_총괄_20210101_20260409_260409.xlsx new file mode 100644 index 0000000..1f6be6b Binary files /dev/null and b/변경계약금액현황(회계)_총괄_20210101_20260409_260409.xlsx differ