diff --git a/data.db b/data.db index 24cb51d..41c0d04 100644 Binary files a/data.db and b/data.db differ diff --git a/main.py b/main.py index 6c12ebd..70b5246 100644 --- a/main.py +++ b/main.py @@ -2,6 +2,8 @@ import os import logging import json import re +import sqlite3 +import threading import time import tempfile import zipfile @@ -44,6 +46,12 @@ _HEALTH_PAYLOAD_CACHE: dict[str, Any] = { "expires_at": 0.0, "payload": None, } +_DB_BACKUP_STATE: dict[str, Any] = { + "last_run_at": 0.0, +} +_DB_BACKUP_LOCK = threading.Lock() +DB_BACKUP_MIN_INTERVAL_SECONDS = 900.0 +DB_BACKUP_KEEP_COUNT = 24 app = FastAPI() @@ -51,8 +59,10 @@ BASE_DIR = Path(__file__).resolve().parent STATIC_DIR = BASE_DIR / "static" TEMPLATES_DIR = BASE_DIR / "templates" DB_PATH = BASE_DIR / "data.db" +BACKUP_DIR = BASE_DIR / "backups" STATIC_DIR.mkdir(exist_ok=True) +BACKUP_DIR.mkdir(exist_ok=True) templates = Jinja2Templates(directory=str(TEMPLATES_DIR)) app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static") @@ -180,6 +190,9 @@ DEFAULT_APP_OPTION_ITEMS = { ("legacy_variant_cutoff_year", "이전 연도 변경/차수 제외 기준", "23"), ("detail_visible_min_year", "세부내역 반영 시작 연도", "2023"), ], + "project_shared": [ + ("exec_labor_rates_json", "공통 기준인건비", "{}"), + ], "dashboard_revenue_metrics": [ ("design_revenue", "설계", "#4f7cff"), ("design_other_revenue", "설계 외", "#67c7c9"), @@ -594,6 +607,7 @@ def init_db() -> None: group_name TEXT DEFAULT '', grade TEXT DEFAULT '', hours TEXT DEFAULT '', + rate_year TEXT DEFAULT '', dept_name TEXT DEFAULT '', work_name TEXT DEFAULT '', account_code TEXT DEFAULT '', @@ -622,6 +636,7 @@ def init_db() -> None: group_name TEXT DEFAULT '', grade TEXT DEFAULT '', minutes TEXT DEFAULT '', + rate_year TEXT DEFAULT '', label TEXT DEFAULT '', reference TEXT DEFAULT '', note TEXT DEFAULT '', @@ -639,6 +654,71 @@ def init_db() -> None: """ ) ) + conn.execute( + text( + """ + CREATE TABLE IF NOT EXISTS app_save_events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + action_key TEXT NOT NULL, + entity_type TEXT NOT NULL, + entity_key TEXT DEFAULT '', + session_id TEXT DEFAULT '', + status TEXT NOT NULL DEFAULT 'ok', + duration_ms INTEGER NOT NULL DEFAULT 0, + payload_json TEXT DEFAULT '{}', + error_message TEXT DEFAULT '', + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) + """ + ) + ) + conn.execute( + text( + """ + CREATE INDEX IF NOT EXISTS idx_app_save_events_lookup + ON app_save_events (entity_type, entity_key, created_at DESC) + """ + ) + ) + conn.execute( + text( + """ + CREATE TABLE IF NOT EXISTS project_status_snapshots ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + support_dept_code TEXT NOT NULL, + action_key TEXT NOT NULL DEFAULT 'project_status_save', + session_id TEXT DEFAULT '', + previous_revision TEXT DEFAULT '', + revision TEXT DEFAULT '', + previous_snapshot_json TEXT DEFAULT '{}', + snapshot_json TEXT DEFAULT '{}', + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) + """ + ) + ) + conn.execute( + text( + """ + CREATE INDEX IF NOT EXISTS idx_project_status_snapshots_code_created + ON project_status_snapshots (support_dept_code, created_at DESC) + """ + ) + ) + conn.execute( + text( + """ + CREATE TABLE IF NOT EXISTS db_backup_history ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + backup_file TEXT NOT NULL, + file_size INTEGER NOT NULL DEFAULT 0, + trigger_action TEXT DEFAULT '', + session_id TEXT DEFAULT '', + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) + """ + ) + ) conn.execute( text( """ @@ -1092,6 +1172,18 @@ 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'")) + exec_budget_entry_columns = { + row[1] + for row in conn.execute(text("PRAGMA table_info(project_exec_budget_entries)")).fetchall() + } + if "rate_year" not in exec_budget_entry_columns: + conn.execute(text("ALTER TABLE project_exec_budget_entries ADD COLUMN rate_year TEXT DEFAULT ''")) + actual_input_entry_columns = { + row[1] + for row in conn.execute(text("PRAGMA table_info(project_actual_input_entries)")).fetchall() + } + if "rate_year" not in actual_input_entry_columns: + conn.execute(text("ALTER TABLE project_actual_input_entries ADD COLUMN rate_year TEXT DEFAULT ''")) migrate_project_status_entries(conn) migrate_project_basic_info(conn) ensure_default_app_config(conn) @@ -1214,10 +1306,255 @@ 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_shared_exec_labor_rates_json() -> str: + shared_items = {item["item_key"]: item for item in get_option_items("project_shared")} + value = normalize_text((shared_items.get("exec_labor_rates_json") or {}).get("value")) + if value and value != "{}": + return value + with engine.begin() as conn: + fallback = normalize_text( + conn.execute( + text( + """ + SELECT COALESCE(exec_labor_rates_json, '{}') + FROM project_basic_info + WHERE COALESCE(exec_labor_rates_json, '{}') <> '{}' + ORDER BY updated_at DESC + LIMIT 1 + """ + ) + ).scalar() + ) + return fallback or "{}" + + +def get_shared_exec_labor_rates() -> dict[str, Any]: + try: + parsed = json.loads(get_shared_exec_labor_rates_json()) + return parsed if isinstance(parsed, dict) else {} + except json.JSONDecodeError: + return {} + + +def save_shared_exec_labor_rates(conn: Any, exec_labor_rates_json: str) -> None: + normalized_json = normalize_text(exec_labor_rates_json) or "{}" + conn.execute( + text( + """ + INSERT INTO app_option_items ( + group_key, item_key, label, value_text, sort_order, is_active, meta_json + ) VALUES ( + 'project_shared', 'exec_labor_rates_json', '공통 기준인건비', :value_text, 0, 1, '{}' + ) + ON CONFLICT(group_key, item_key) DO UPDATE SET + value_text = excluded.value_text, + is_active = 1 + """ + ), + {"value_text": normalized_json}, + ) + load_app_config.cache_clear() + + def get_special_x_classification_rules() -> dict[str, list[str]]: return get_keyword_rule_groups("special_x_classification") +def _safe_json_dumps(value: Any) -> str: + return json.dumps(value, ensure_ascii=False, separators=(",", ":"), default=str) + + +def log_save_event( + action_key: str, + entity_type: str, + entity_key: Any = "", + *, + session_id: Any = "", + status: str = "ok", + duration_ms: int = 0, + payload: Any = None, + error_message: Any = "", +) -> None: + try: + with engine.begin() as conn: + conn.execute( + text( + """ + INSERT INTO app_save_events ( + action_key, entity_type, entity_key, session_id, + status, duration_ms, payload_json, error_message + ) VALUES ( + :action_key, :entity_type, :entity_key, :session_id, + :status, :duration_ms, :payload_json, :error_message + ) + """ + ), + { + "action_key": normalize_text(action_key), + "entity_type": normalize_text(entity_type), + "entity_key": normalize_text(entity_key), + "session_id": normalize_text(session_id), + "status": normalize_text(status) or "ok", + "duration_ms": max(int(duration_ms or 0), 0), + "payload_json": _safe_json_dumps(payload or {}), + "error_message": normalize_text(error_message), + }, + ) + except Exception as exc: + logger.warning("저장 이벤트 로그 기록 실패: %s", exc) + + +def prune_old_backups() -> None: + backup_files = sorted( + ( + path for path in BACKUP_DIR.glob("data-*.sqlite3") + if path.is_file() + ), + key=lambda path: path.stat().st_mtime, + reverse=True, + ) + for stale_path in backup_files[DB_BACKUP_KEEP_COUNT:]: + try: + stale_path.unlink(missing_ok=True) + except Exception as exc: + logger.warning("오래된 백업 파일 정리 실패(%s): %s", stale_path.name, exc) + + +def maybe_create_database_backup(trigger_action: str, session_id: Any = "") -> str: + now = time.monotonic() + if now - float(_DB_BACKUP_STATE.get("last_run_at") or 0.0) < DB_BACKUP_MIN_INTERVAL_SECONDS: + return "" + if not _DB_BACKUP_LOCK.acquire(blocking=False): + return "" + try: + now = time.monotonic() + if now - float(_DB_BACKUP_STATE.get("last_run_at") or 0.0) < DB_BACKUP_MIN_INTERVAL_SECONDS: + return "" + stamp = datetime.now().strftime("%Y%m%d-%H%M%S") + temp_path = BACKUP_DIR / f".data-{stamp}.tmp" + final_path = BACKUP_DIR / f"data-{stamp}.sqlite3" + source_conn = sqlite3.connect(DB_PATH) + backup_conn = sqlite3.connect(temp_path) + try: + source_conn.backup(backup_conn) + finally: + backup_conn.close() + source_conn.close() + temp_path.replace(final_path) + file_size = final_path.stat().st_size if final_path.exists() else 0 + _DB_BACKUP_STATE["last_run_at"] = time.monotonic() + prune_old_backups() + try: + with engine.begin() as conn: + conn.execute( + text( + """ + INSERT INTO db_backup_history ( + backup_file, file_size, trigger_action, session_id + ) VALUES ( + :backup_file, :file_size, :trigger_action, :session_id + ) + """ + ), + { + "backup_file": final_path.name, + "file_size": int(file_size or 0), + "trigger_action": normalize_text(trigger_action), + "session_id": normalize_text(session_id), + }, + ) + log_save_event( + "db_backup", + "system", + final_path.name, + session_id=session_id, + payload={"trigger_action": trigger_action, "file_size": int(file_size or 0)}, + ) + except Exception as exc: + logger.warning("DB 백업 이력 저장 실패: %s", exc) + return final_path.name + except Exception as exc: + logger.warning("DB 백업 생성 실패: %s", exc) + return "" + finally: + _DB_BACKUP_LOCK.release() + + +def load_project_status_snapshot_payload(conn: Any, support_dept_code: str) -> dict[str, Any]: + normalized_code = normalize_text(support_dept_code) + if not normalized_code: + return {} + row = conn.execute( + text("SELECT * FROM project_status WHERE support_dept_code = :support_dept_code"), + {"support_dept_code": normalized_code}, + ).mappings().first() + entry_set = load_project_status_entries_for_code(conn, normalized_code) + has_entries = any(entry_set.get(key) for key in entry_set) + if not row and not has_entries: + return {} + base = dict(row) if row else {"support_dept_code": normalized_code} + return { + "support_dept_code": normalized_code, + "support_dept_name": normalize_text(base.get("support_dept_name")), + "contract_amount": normalize_amount(base.get("contract_amount")), + "collection_amount": normalize_amount(base.get("collection_amount")), + "progress_rate": normalize_amount(base.get("progress_rate")), + "project_type": normalize_text(base.get("project_type")), + "expected_as_rate": normalize_amount(base.get("expected_as_rate")), + "expected_sga_rate": normalize_amount(base.get("expected_sga_rate")), + "expected_as_cost": normalize_amount(base.get("expected_as_cost")), + "expected_sga_budget": normalize_amount(base.get("expected_sga_budget")), + "change_round": normalize_text(base.get("change_round")), + "project_start_date": normalize_text(base.get("project_start_date")), + "project_end_date": normalize_text(base.get("project_end_date")), + "completion_status": normalize_text(base.get("completion_status")), + "notes": normalize_text(base.get("notes")), + "updated_at": normalize_text(base.get("updated_at")), + "collection_entries": entry_set.get("collection_entries", []), + "task_plan_entries": entry_set.get("task_plan_entries", []), + "exec_budget_entries": entry_set.get("exec_budget_entries", []), + "actual_input_entries": entry_set.get("actual_input_entries", []), + } + + +def record_project_status_snapshot( + support_dept_code: str, + session_id: Any, + previous_snapshot: dict[str, Any], + next_snapshot: dict[str, Any], +) -> None: + normalized_code = normalize_text(support_dept_code) + if not normalized_code: + return + try: + with engine.begin() as conn: + conn.execute( + text( + """ + INSERT INTO project_status_snapshots ( + support_dept_code, action_key, session_id, + previous_revision, revision, + previous_snapshot_json, snapshot_json + ) VALUES ( + :support_dept_code, 'project_status_save', :session_id, + :previous_revision, :revision, + :previous_snapshot_json, :snapshot_json + ) + """ + ), + { + "support_dept_code": normalized_code, + "session_id": normalize_text(session_id), + "previous_revision": normalize_text((previous_snapshot or {}).get("updated_at")), + "revision": normalize_text((next_snapshot or {}).get("updated_at")), + "previous_snapshot_json": _safe_json_dumps(previous_snapshot or {}), + "snapshot_json": _safe_json_dumps(next_snapshot or {}), + }, + ) + except Exception as exc: + logger.warning("프로젝트 스냅샷 기록 실패(%s): %s", normalized_code, exc) + + def save_project_runtime_setting(item_key: Any, value_text: Any) -> None: normalized_item_key = normalize_text(item_key) if not normalized_item_key: @@ -1250,6 +1587,12 @@ def save_project_runtime_setting(item_key: Any, value_text: Any) -> None: }, ) load_app_config.cache_clear() + log_save_event( + "project_runtime_setting_save", + "project_runtime_setting", + normalized_item_key, + payload={"value_text": normalize_text(value_text)}, + ) def count_transactions() -> int: @@ -2134,6 +2477,10 @@ def sync_auto_project_related_links() -> None: @app.on_event("startup") def on_startup() -> None: init_db() + with engine.begin() as conn: + corrected = sanitize_project_labor_amount_rows(conn) + if corrected: + logger.info("Sanitized project labor amounts for %s project(s)", corrected) auto_import_project_excels() sync_auto_project_related_links() normalize_all_collection_entry_storage() @@ -2342,6 +2689,7 @@ def normalize_exec_budget_entry_row(row: dict[str, Any]) -> dict[str, Any]: "group": clean_row_text(row.get("group")), "grade": clean_row_text(row.get("grade")), "hours": clean_row_text(row.get("hours")), + "rate_year": clean_row_text(row.get("rate_year")), "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")), @@ -2355,6 +2703,7 @@ def normalize_actual_input_entry_row(row: dict[str, Any]) -> dict[str, Any]: "group": clean_row_text(row.get("group")), "grade": clean_row_text(row.get("grade")), "minutes": clean_row_text(row.get("minutes")), + "rate_year": clean_row_text(row.get("rate_year")), "label": clean_row_text(row.get("label")), "reference": clean_row_text(row.get("reference")), "note": clean_row_text(row.get("note")), @@ -2535,10 +2884,10 @@ def replace_project_status_child_entries( text( """ INSERT INTO project_exec_budget_entries ( - support_dept_code, position, group_name, grade, hours, dept_name, + support_dept_code, position, group_name, grade, hours, rate_year, dept_name, work_name, account_code, account_name, amount, updated_at ) VALUES ( - :support_dept_code, :position, :group_name, :grade, :hours, :dept_name, + :support_dept_code, :position, :group_name, :grade, :hours, :rate_year, :dept_name, :work_name, :account_code, :account_name, :amount, CURRENT_TIMESTAMP ) """ @@ -2549,6 +2898,7 @@ def replace_project_status_child_entries( "group_name": normalized["group"], "grade": normalized["grade"], "hours": normalized["hours"], + "rate_year": normalized["rate_year"], "dept_name": normalized["dept_name"], "work_name": normalized["work_name"], "account_code": normalized["account_code"], @@ -2563,10 +2913,10 @@ def replace_project_status_child_entries( text( """ INSERT INTO project_actual_input_entries ( - support_dept_code, position, group_name, grade, minutes, label, + support_dept_code, position, group_name, grade, minutes, rate_year, label, reference, note, amount, updated_at ) VALUES ( - :support_dept_code, :position, :group_name, :grade, :minutes, :label, + :support_dept_code, :position, :group_name, :grade, :minutes, :rate_year, :label, :reference, :note, :amount, CURRENT_TIMESTAMP ) """ @@ -2577,6 +2927,7 @@ def replace_project_status_child_entries( "group_name": normalized["group"], "grade": normalized["grade"], "minutes": normalized["minutes"], + "rate_year": normalized["rate_year"], "label": normalized["label"], "reference": normalized["reference"], "note": normalized["note"], @@ -2633,7 +2984,7 @@ def load_project_status_entry_maps(conn: Any) -> dict[str, dict[str, list[dict[s text( """ SELECT support_dept_code, position, group_name, grade, hours, dept_name, - work_name, account_code, account_name, amount + rate_year, work_name, account_code, account_name, amount FROM project_exec_budget_entries ORDER BY support_dept_code, position, id """ @@ -2648,6 +2999,7 @@ def load_project_status_entry_maps(conn: Any) -> dict[str, dict[str, list[dict[s "group": row["group_name"], "grade": row["grade"], "hours": row["hours"], + "rate_year": row["rate_year"], "dept_name": row["dept_name"], "work_name": row["work_name"], "account_code": row["account_code"], @@ -2660,7 +3012,7 @@ def load_project_status_entry_maps(conn: Any) -> dict[str, dict[str, list[dict[s actual_rows = conn.execute( text( """ - SELECT support_dept_code, position, group_name, grade, minutes, label, + SELECT support_dept_code, position, group_name, grade, minutes, rate_year, label, reference, note, amount FROM project_actual_input_entries ORDER BY support_dept_code, position, id @@ -2676,6 +3028,7 @@ def load_project_status_entry_maps(conn: Any) -> dict[str, dict[str, list[dict[s "group": row["group_name"], "grade": row["grade"], "minutes": row["minutes"], + "rate_year": row["rate_year"], "label": row["label"], "reference": row["reference"], "note": row["note"], @@ -2803,6 +3156,7 @@ def migrate_project_basic_info(conn: Any) -> None: def save_project_basic_info_section(conn: Any, payload: dict[str, Any]) -> None: + save_shared_exec_labor_rates(conn, normalize_text(payload.get("exec_labor_rates_json")) or "{}") conn.execute( text( """ @@ -2892,7 +3246,7 @@ def load_project_status_entries_for_code(conn: Any, support_dept_code: str) -> d exec_rows = conn.execute( text( """ - SELECT group_name, grade, hours, dept_name, work_name, account_code, account_name, amount + SELECT group_name, grade, hours, rate_year, 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 @@ -2906,6 +3260,7 @@ def load_project_status_entries_for_code(conn: Any, support_dept_code: str) -> d "group": row["group_name"], "grade": row["grade"], "hours": row["hours"], + "rate_year": row["rate_year"], "dept_name": row["dept_name"], "work_name": row["work_name"], "account_code": row["account_code"], @@ -2919,7 +3274,7 @@ def load_project_status_entries_for_code(conn: Any, support_dept_code: str) -> d actual_rows = conn.execute( text( """ - SELECT group_name, grade, minutes, label, reference, note, amount + SELECT group_name, grade, minutes, rate_year, label, reference, note, amount FROM project_actual_input_entries WHERE support_dept_code = :support_dept_code ORDER BY position, id @@ -2933,6 +3288,7 @@ def load_project_status_entries_for_code(conn: Any, support_dept_code: str) -> d "group": row["group_name"], "grade": row["grade"], "minutes": row["minutes"], + "rate_year": row["rate_year"], "label": row["label"], "reference": row["reference"], "note": row["note"], @@ -3108,6 +3464,121 @@ def sum_row_amounts(rows: list[dict[str, Any]], amount_key: str = "amount") -> f return sum(normalize_amount(row.get(amount_key)) for row in rows) +def _parse_labor_rates_json(raw_json: Any) -> dict[str, dict[str, float]]: + try: + parsed = json.loads(normalize_text(raw_json) or "{}") + except json.JSONDecodeError: + return {} + if not isinstance(parsed, dict): + return {} + normalized: dict[str, dict[str, float]] = {} + for year_key, bucket in parsed.items(): + year_text = normalize_text(year_key) + if not year_text or not isinstance(bucket, dict): + continue + normalized[year_text] = {} + for grade_key, amount_value in bucket.items(): + grade_text = normalize_text(grade_key) + if not grade_text: + continue + normalized[year_text][grade_text] = normalize_amount(amount_value) + return normalized + + +def _resolve_labor_rate( + rates_by_year: dict[str, dict[str, float]], + grade: Any, + rate_year: Any, + fallback_year: Any = "", +) -> float: + grade_text = normalize_text(grade) + if not grade_text: + return 0.0 + year_candidates: list[str] = [] + for value in (rate_year, fallback_year): + text_value = normalize_text(value) + if text_value and text_value not in year_candidates: + year_candidates.append(text_value) + if not year_candidates: + year_candidates.append(str(datetime.now().year)) + for year_text in year_candidates: + year_bucket = rates_by_year.get(year_text) or {} + amount = normalize_amount(year_bucket.get(grade_text)) + if amount: + return amount + return 0.0 + + +def _parse_exec_hours_value(value: Any) -> float: + digits = "".join(character for character in normalize_text(value) if character.isdigit()) + if not digits: + return 0.0 + return float(int(digits[:5])) + + +def _parse_minutes_value(value: Any) -> float: + digits = "".join(character for character in normalize_text(value) if character.isdigit()) + if not digits: + return 0.0 + return float(int(digits)) + + +def sanitize_project_labor_amount_rows(conn: Any) -> int: + shared_rates = _parse_labor_rates_json(get_shared_exec_labor_rates_json()) + rows = conn.execute( + text( + """ + SELECT support_dept_code, COALESCE(exec_labor_rates_json, '{}') AS exec_labor_rates_json + FROM project_status + WHERE COALESCE(support_dept_code, '') <> '' + """ + ) + ).mappings().all() + updated_count = 0 + for row in rows: + code = normalize_text(row.get("support_dept_code")) + if not code: + continue + entry_set = load_project_status_entries_for_code(conn, code) + exec_entries = list(entry_set.get("exec_budget_entries", [])) + actual_entries = list(entry_set.get("actual_input_entries", [])) + rates = _parse_labor_rates_json(row.get("exec_labor_rates_json")) or shared_rates + changed = False + + for entry in exec_entries: + if normalize_text(entry.get("group")) != "labor": + continue + hours_value = _parse_exec_hours_value(entry.get("hours")) + next_amount = _resolve_labor_rate(rates, entry.get("grade"), entry.get("rate_year")) * hours_value + if abs(normalize_amount(entry.get("amount")) - next_amount) > 0.5: + entry["amount"] = next_amount + changed = True + + for entry in actual_entries: + if normalize_text(entry.get("group")) != "labor": + continue + minutes_value = _parse_minutes_value(entry.get("minutes")) + next_amount = _resolve_labor_rate(rates, entry.get("grade"), entry.get("rate_year")) * (minutes_value / 60.0 if minutes_value else 0.0) + if abs(normalize_amount(entry.get("amount")) - next_amount) > 0.5: + entry["amount"] = next_amount + changed = True + + if not changed: + continue + + replace_project_status_child_entries( + conn, + code, + entry_set.get("collection_entries", []), + entry_set.get("task_plan_entries", []), + exec_entries, + actual_entries, + ) + sync_project_status_cache_row(conn, code) + updated_count += 1 + return updated_count + + def get_support_department_options() -> list[dict[str, str]]: with engine.begin() as conn: rows = conn.execute( @@ -3523,6 +3994,8 @@ def build_transaction_payload(raw: dict[str, Any], source_file: str = "") -> dic def save_transaction(payload: dict[str, Any], record_id: int | None = None) -> None: init_db() + started_at = time.perf_counter() + normalized_record_id = str(record_id) if record_id is not None else "" params = { **payload, "record_id": record_id, @@ -3647,6 +4120,21 @@ def save_transaction(payload: dict[str, Any], record_id: int | None = None) -> N ), params, ) + duration_ms = int((time.perf_counter() - started_at) * 1000) + log_save_event( + "transaction_save", + "transaction", + normalized_record_id or normalize_text(payload.get("voucher_number")), + session_id=payload.get("client_session_id"), + duration_ms=duration_ms, + payload={ + "record_id": normalized_record_id, + "voucher_number": normalize_text(payload.get("voucher_number")), + "support_dept_code": normalize_text(payload.get("support_dept_code")), + "account_code": normalize_text(payload.get("account_code")), + }, + ) + maybe_create_database_backup("transaction_save", payload.get("client_session_id")) def get_record_for_edit(record_id: int | None) -> dict[str, Any]: @@ -4015,6 +4503,12 @@ def save_project_comparison_note(support_dept_code: str | None, item_key: str | "item_key": normalized_item_key, }, ) + log_save_event( + "project_comparison_note_save", + "project_comparison_note", + f"{code}:{normalized_item_key}", + payload={"has_note": bool(normalized_note)}, + ) def get_project_analysis_settings_map() -> dict[str, dict[str, object]]: @@ -4092,6 +4586,16 @@ def save_project_analysis_settings( "labor_joint_exempt": 1 if next_labor_joint_exempt else 0, }, ) + log_save_event( + "project_analysis_settings_save", + "project_analysis_settings", + code, + payload={ + "inactive_related_count": len(next_inactive_related_codes), + "labor_joint_exempt": next_labor_joint_exempt, + "has_detail_note": bool(next_detail_note), + }, + ) def get_project_status_for_edit(support_dept_code: str | None) -> dict[str, Any]: @@ -4251,10 +4755,16 @@ def get_project_status_for_edit(support_dept_code: str | None) -> dict[str, Any] result.pop("task_plan_entries_json", None) result.pop("exec_budget_entries_json", None) result.pop("actual_input_entries_json", None) + shared_labor_rates = get_shared_exec_labor_rates() try: - result["exec_labor_rates"] = json.loads(normalize_text(result.pop("exec_labor_rates_json", "{}")) or "{}") + project_labor_rates = json.loads(normalize_text(result.pop("exec_labor_rates_json", "{}")) or "{}") except json.JSONDecodeError: - result["exec_labor_rates"] = {} + project_labor_rates = {} + # 기준인건비는 전 프로젝트 공통값을 우선 사용한다. + if isinstance(shared_labor_rates, dict) and shared_labor_rates: + result["exec_labor_rates"] = shared_labor_rates + else: + result["exec_labor_rates"] = project_labor_rates if isinstance(project_labor_rates, dict) else {} return merge_project_external_fields( result, contract_info_map.get(normalize_text(result.get("support_dept_code"))), @@ -4402,6 +4912,18 @@ def save_project_page_state(payload: dict[str, Any]) -> None: ) for base_code, related_codes in related_project_selections.items(): save_project_related_links(base_code, related_codes) + log_save_event( + "project_page_state_save", + "project_page_state", + session_id, + session_id=session_id, + payload={ + "selected_code": selected_code, + "selected_year": selected_year, + "analysis_open": bool(analysis_open), + "related_selection_count": len(related_project_selections), + }, + ) def get_project_related_links_map() -> dict[str, list[str]]: @@ -4471,6 +4993,13 @@ def save_project_quick_links(session_id: str | None, codes: list[str]) -> None: "sort_order": sort_order, }, ) + log_save_event( + "project_quick_links_save", + "project_quick_links", + "projects", + session_id=session_id, + payload={"codes": normalized_codes}, + ) def get_process_cost_quick_links() -> list[str]: @@ -4519,6 +5048,12 @@ def save_process_cost_quick_links(codes: list[str]) -> None: "sort_order": sort_order, }, ) + log_save_event( + "process_cost_quick_links_save", + "project_quick_links", + "process_cost", + payload={"codes": normalized_codes}, + ) def get_project_uncontracted_classification_map() -> dict[str, str]: @@ -4570,6 +5105,12 @@ def save_project_uncontracted_classification(support_dept_code: Any, category: A "category": normalized_category, }, ) + log_save_event( + "project_uncontracted_classification_save", + "project_uncontracted_classification", + normalized_code, + payload={"category": normalized_category}, + ) def save_project_related_links(base_support_dept_code: str, related_codes: list[Any]) -> None: @@ -4619,6 +5160,12 @@ def save_project_related_links(base_support_dept_code: str, related_codes: list[ "related_support_dept_code": related_code, }, ) + log_save_event( + "project_related_links_save", + "project_related_links", + base_code, + payload={"related_codes": normalized_codes}, + ) def get_project_year_options() -> list[int]: @@ -5099,26 +5646,69 @@ def get_project_account_breakdowns(selected_year: int | None) -> dict[str, dict[ AND ({REVENUE_SQL} OR accounting_category IN ('원가', '판관비')) {year_clause} GROUP BY support_dept_code, breakdown_kind, account_code, account_name - ORDER BY support_dept_code, breakdown_kind, total_amount DESC, account_code, account_name + """ + ), + params, + ).mappings().all() + cost_detail_rows = conn.execute( + text( + f""" + SELECT + COALESCE(support_dept_code, '') AS support_dept_code, + COALESCE(voucher_number, '') AS voucher_number, + COALESCE(posting_date, '') AS posting_date, + COALESCE(partner_name, '') AS partner_name, + COALESCE(partner_code, '') AS partner_code, + COALESCE(account_code, '') AS account_code, + COALESCE(account_name, '') AS account_name, + COALESCE(amount, 0) AS amount + FROM transactions + WHERE COALESCE(support_dept_code, '') <> '' + AND support_dept_code NOT IN ('ZZZZZZ') + AND COALESCE(support_dept_name, '') <> '' + AND support_dept_name NOT IN ('공통', '경영지원부', '기술개발센터', '임원실', '기술개발부', '총괄기획실') + AND accounting_category = '원가' + {year_clause} """ ), params, ).mappings().all() - result: dict[str, dict[str, dict[str, float]]] = {} + result: dict[str, dict[str, Any]] = {} for row in rows: code = row["support_dept_code"] kind = row["breakdown_kind"] if kind == "other": continue _, _, label = normalize_account_display(row["account_code"], row["account_name"]) - result.setdefault(code, {"revenue": {}, "cost": {}, "sga": {}}) + result.setdefault(code, {"revenue": {}, "cost": {}, "sga": {}, "cost_detail": []}) result[code][kind][label] = result[code][kind].get(label, 0.0) + float(row["total_amount"] or 0) + for row in cost_detail_rows: + code = normalize_text(row["support_dept_code"]) + if not code: + continue + account_code, account_name, label = normalize_account_display(row["account_code"], row["account_name"]) + result.setdefault(code, {"revenue": {}, "cost": {}, "sga": {}, "cost_detail": []}) + result[code]["cost_detail"].append( + { + "posting_date": build_transaction_posting_display(row["voucher_number"], row["posting_date"]), + "partner_name": normalize_text(row["partner_name"]), + "partner_code": normalize_text(row["partner_code"]), + "account_code": account_code, + "account_name": account_name, + "label": label, + "amount": float(row["amount"] or 0), + } + ) + normalized_result: dict[str, dict[str, list[dict[str, Any]]]] = {} for code, buckets in result.items(): normalized_result[code] = {} for kind, entries in buckets.items(): + if kind == "cost_detail": + normalized_result[code][kind] = list(entries) + continue normalized_result[code][kind] = [ { "label": label, @@ -5474,26 +6064,34 @@ def _get_project_contract_meta() -> dict[str, dict[str, Any]]: return result -def _get_project_actual_sga_summary(codes: list[str]) -> dict[str, dict[str, Any]]: +def _get_project_actual_input_group_summary( + codes: list[str], + group_names: list[str], +) -> dict[str, dict[str, Any]]: normalized_codes = [normalize_text(code) for code in codes if normalize_text(code)] - if not normalized_codes: + normalized_groups = [normalize_text(group) for group in group_names if normalize_text(group)] + if not normalized_codes or not normalized_groups: return {} - in_clause, params = build_in_clause("actual_sga_code", normalized_codes) + in_clause, params = build_in_clause("actual_input_code", normalized_codes) + group_clause, group_params = build_in_clause("actual_input_group", normalized_groups) + params.update(group_params) with engine.begin() as conn: rows = conn.execute( text( f""" SELECT support_dept_code, + COALESCE(group_name, '') AS group_name, COALESCE(label, '') AS label, COALESCE(reference, '') AS reference, COALESCE(note, '') AS note, COALESCE(grade, '') AS grade, COALESCE(minutes, '') AS minutes, - COALESCE(amount, 0) AS amount + COALESCE(amount, 0) AS amount, + COALESCE(updated_at, '') AS updated_at FROM project_actual_input_entries WHERE support_dept_code IN ({in_clause}) - AND COALESCE(group_name, '') = 'sga' + AND COALESCE(group_name, '') IN ({group_clause}) ORDER BY position, id """ ), @@ -5527,10 +6125,23 @@ def _get_project_actual_sga_summary(codes: list[str]) -> dict[str, dict[str, Any "amount": total_amount, "has_detail_trace": has_detail_trace, "distinct_labels": distinct_labels, + "last_updated_at": max((normalize_text(item.get("updated_at")) for item in code_rows), default=""), } return result +def _get_project_actual_sga_summary(codes: list[str]) -> dict[str, dict[str, Any]]: + return _get_project_actual_input_group_summary(codes, ["sga"]) + + +def _get_project_actual_labor_summary(codes: list[str]) -> dict[str, dict[str, Any]]: + return _get_project_actual_input_group_summary(codes, ["labor", "labor_adjustment", "labor_joint"]) + + +def _get_project_actual_as_summary(codes: list[str]) -> dict[str, dict[str, Any]]: + return _get_project_actual_input_group_summary(codes, ["as"]) + + def _get_project_exec_budget_summary(codes: list[str]) -> dict[str, dict[str, float]]: normalized_codes = [normalize_text(code) for code in codes if normalize_text(code)] if not normalized_codes: @@ -5734,6 +6345,7 @@ def get_process_cost_project_detail( selected_year: int | None, support_dept_code: str, include_related: bool = False, + active_related_codes: list[str] | None = None, ) -> dict[str, Any]: code = normalize_text(support_dept_code) if not code: @@ -5748,9 +6360,20 @@ def get_process_cost_project_detail( normalized_source = normalize_text(source).lower() contract_meta_map = _get_project_contract_meta() contract_meta = contract_meta_map.get(code, {}) - related_clusters = _build_process_cost_related_clusters() if include_related else {} - cluster_codes = related_clusters.get(code, [code]) if include_related else [code] + saved_related_codes = get_process_cost_related_codes(code) + active_related_codes = [ + normalize_text(item) + for item in (active_related_codes or []) + if normalize_text(item) + ] + active_related_codes = [ + item for item in active_related_codes + if item != code and item in saved_related_codes + ] + cluster_codes = [code, *active_related_codes] if include_related else [code] actual_sga_summary_map = _get_project_actual_sga_summary(cluster_codes) + actual_labor_summary_map = _get_project_actual_labor_summary(cluster_codes) + actual_as_summary_map = _get_project_actual_as_summary(cluster_codes) exec_budget_summary_map = _get_project_exec_budget_summary(cluster_codes) if normalized_source == "wehago": init_wehago_compare_db(engine) @@ -5897,18 +6520,40 @@ def get_process_cost_project_detail( planned_cost_plan_amount = normalize_amount((exec_budget_summary_map.get(code) or {}).get("cost_plan")) revenue_amount = normalize_amount(summary_row.get("revenue_amount")) ledger_expense_amount = normalize_amount(summary_row.get("expense_amount")) - labor_amount = normalize_amount(summary_row.get("labor_amount")) + ledger_labor_amount = normalize_amount(summary_row.get("labor_amount")) outsourcing_amount = normalize_amount(summary_row.get("outsourcing_amount")) ledger_sga_amount = normalize_amount(summary_row.get("sga_amount")) + actual_labor_amount = sum( + normalize_amount((actual_labor_summary_map.get(member) or {}).get("amount")) + for member in cluster_codes + ) real_project_actual_sga_amount = 0.0 + actual_input_last_updated_at = "" + for member in cluster_codes: + updated_at = normalize_text((actual_labor_summary_map.get(member) or {}).get("last_updated_at")) + if updated_at and updated_at > actual_input_last_updated_at: + actual_input_last_updated_at = updated_at for member in cluster_codes: member_expected_sga_budget = normalize_amount(contract_meta_map.get(member, {}).get("expected_sga_budget")) actual_summary = actual_sga_summary_map.get(member) if _is_real_project_actual_sga(actual_summary, member_expected_sga_budget): real_project_actual_sga_amount += normalize_amount((actual_summary or {}).get("amount")) - sga_amount = ledger_sga_amount + real_project_actual_sga_amount - expense_amount = ledger_expense_amount + real_project_actual_sga_amount - design_cost_amount = max(ledger_expense_amount - labor_amount - outsourcing_amount - ledger_sga_amount, 0.0) + updated_at = normalize_text((actual_summary or {}).get("last_updated_at")) + if updated_at and updated_at > actual_input_last_updated_at: + actual_input_last_updated_at = updated_at + actual_as_amount = sum( + normalize_amount((actual_as_summary_map.get(member) or {}).get("amount")) + for member in cluster_codes + ) + actual_sga_amount = sum( + normalize_amount((actual_sga_summary_map.get(member) or {}).get("amount")) + for member in cluster_codes + ) + labor_amount = actual_labor_amount if actual_labor_amount > 0 else ledger_labor_amount + as_amount = actual_as_amount if actual_as_amount > 0 else as_cost_amount + sga_amount = actual_sga_amount if actual_sga_amount > 0 else ledger_sga_amount + design_cost_amount = max(ledger_expense_amount - ledger_labor_amount - outsourcing_amount - ledger_sga_amount, 0.0) + expense_amount = labor_amount + outsourcing_amount + design_cost_amount + as_amount + sga_amount profit_amount = revenue_amount - expense_amount target_base = ( @@ -5922,7 +6567,7 @@ def get_process_cost_project_detail( {"phase": "직접인건비", "target_amount": planned_labor_amount, "actual_amount": labor_amount}, {"phase": "외주비", "target_amount": planned_outsource_amount, "actual_amount": outsourcing_amount}, {"phase": "제경비", "target_amount": planned_cost_plan_amount, "actual_amount": design_cost_amount}, - {"phase": "A/S비", "target_amount": as_cost_amount, "actual_amount": as_cost_amount}, + {"phase": "A/S비", "target_amount": as_cost_amount, "actual_amount": as_amount}, {"phase": "판관비", "target_amount": expected_sga_budget, "actual_amount": sga_amount}, ] for row in phase_rows: @@ -5932,6 +6577,7 @@ def get_process_cost_project_detail( normalized_accounts = [] for row in account_rows: amount = normalize_amount(row.get("amount")) + last_posting_date = normalize_text(row.get("last_posting_date")) or normalize_text(summary_row.get("last_posting_date")) normalized_accounts.append( { "account_code": normalize_text(row.get("account_code")), @@ -5939,22 +6585,28 @@ def get_process_cost_project_detail( "amount": amount, "row_count": int(row.get("row_count") or 0), "share_rate": _safe_ratio(amount, expense_amount), - "last_posting_date": normalize_text(row.get("last_posting_date")), + "last_posting_date": last_posting_date[:10] if last_posting_date else "", } ) - if real_project_actual_sga_amount > 0: + if actual_labor_amount > 0: normalized_accounts.append( { - "account_code": "PROJECT-SGA", - "account_name": "판관비(프로젝트 정보)", - "amount": real_project_actual_sga_amount, - "row_count": sum(len((actual_sga_summary_map.get(member) or {}).get("rows") or []) for member in cluster_codes), - "share_rate": _safe_ratio(real_project_actual_sga_amount, expense_amount), + "account_code": "PROJECT-LABOR", + "account_name": "직접인건비(프로젝트 정보)", + "amount": actual_labor_amount, + "row_count": sum(len((actual_labor_summary_map.get(member) or {}).get("rows") or []) for member in cluster_codes), + "share_rate": _safe_ratio(actual_labor_amount, expense_amount), "last_posting_date": "", } ) - normalized_accounts.sort(key=lambda item: (-normalize_amount(item.get("amount")), normalize_text(item.get("account_code")))) - normalized_accounts = normalized_accounts[:14] + normalized_accounts = [ + row + for row in normalized_accounts + if normalize_text(row.get("account_code")) != "PROJECT-SGA" + and "판관비" not in normalize_text(row.get("account_name")) + ] + normalized_accounts.sort(key=lambda item: (-normalize_amount(item.get("amount")), normalize_text(item.get("account_code")))) + normalized_accounts = normalized_accounts[:14] normalized_monthly = [] for row in monthly_rows: @@ -5982,13 +6634,14 @@ def get_process_cost_project_detail( "profit_rate": _safe_ratio(profit_amount, revenue_amount), "target_cost_amount": target_base, "execution_rate": _safe_ratio(expense_amount, target_base), - "last_posting_date": normalize_text(summary_row.get("last_posting_date")), + "last_posting_date": normalize_text(summary_row.get("last_posting_date"))[:10], "voucher_count": int(summary_row.get("voucher_count") or 0), "row_count": int(summary_row.get("row_count") or 0), "included_codes": cluster_codes if include_related else [code], "expected_sga_budget": expected_sga_budget, "ledger_sga_amount": ledger_sga_amount, "project_actual_sga_amount": real_project_actual_sga_amount, + "project_actual_labor_amount": actual_labor_amount, }, "phase_rows": phase_rows, "account_rows": normalized_accounts, @@ -6010,6 +6663,7 @@ def render_process_cost_page( end_year: int | None = None, code: str | None = None, include_related: bool = False, + active_related: str | None = None, message: str = "", ) -> HTMLResponse: init_db() @@ -6048,12 +6702,32 @@ def render_process_cost_page( (item for item in project_options if normalize_text(item.get("support_dept_code")) == selected_code), None, ) + related_codes = get_process_cost_related_codes(selected_code) + active_related_codes: list[str] = [] + if include_related and selected_code: + normalized_active_related = normalize_text(active_related) + if normalized_active_related in {"-", "__none__"}: + active_related_codes = [] + else: + requested_active_codes = [ + normalize_text(value) + for value in normalized_active_related.split(",") + if normalize_text(value) + ] + if requested_active_codes: + active_related_codes = [ + value for value in requested_active_codes + if value != selected_code and value in related_codes + ] + else: + active_related_codes = list(related_codes) detail = get_process_cost_project_detail( normalized_source, None, selected_code, include_related=include_related, + active_related_codes=active_related_codes, ) context = { **base_context(request, message), @@ -6067,7 +6741,8 @@ def render_process_cost_page( "process_cost_include_related": include_related, "process_cost_projects": project_options, "process_cost_detail": detail, - "process_cost_related_codes": get_process_cost_related_codes(selected_code), + "process_cost_related_codes": related_codes, + "process_cost_active_related_codes": active_related_codes, "process_cost_quick_link_codes": get_process_cost_quick_links(), } return templates.TemplateResponse(request, "process_cost.html", context) @@ -6163,8 +6838,105 @@ def parse_excel_upload(upload_file: UploadFile) -> int: sheet = workbook.active headers = [canonical_header_name(cell.value) for cell in next(sheet.iter_rows(min_row=1, max_row=1))] - inserted = 0 + if import_kind == "transactions": + rows_to_insert: list[dict[str, Any]] = [] + inserted = 0 + insert_sql = text( + """ + INSERT INTO transactions ( + approval_status, + voucher_number, + account_code, + account_name, + debit_supply, + debit_vat, + credit_supply, + credit_vat, + issuing_dept_code, + issuing_dept_name, + confirmed_voucher_number, + support_dept_code, + support_dept_name, + cost_dept_code, + cost_dept_name, + memo1, + memo2, + partner_code, + partner_name, + tax_code, + posting_date, + voucher_type, + management_item, + accounting_category, + amount, + year, + month, + day, + source_file, + last_editor_session_id, + last_client_submitted_at + ) VALUES ( + :approval_status, + :voucher_number, + :account_code, + :account_name, + :debit_supply, + :debit_vat, + :credit_supply, + :credit_vat, + :issuing_dept_code, + :issuing_dept_name, + :confirmed_voucher_number, + :support_dept_code, + :support_dept_name, + :cost_dept_code, + :cost_dept_name, + :memo1, + :memo2, + :partner_code, + :partner_name, + :tax_code, + :posting_date, + :voucher_type, + :management_item, + :accounting_category, + :amount, + :year, + :month, + :day, + :source_file, + '', + '' + ) + """ + ) + for row in sheet.iter_rows(min_row=2, values_only=True): + raw: dict[str, Any] = {} + has_value = False + for index, value in enumerate(row): + field_name = headers[index] if index < len(headers) else None + if field_name: + raw[field_name] = value + if normalize_text(value): + has_value = True + if not has_value: + continue + payload = build_transaction_payload(raw, source_file=upload_file.filename or "") + if not payload["voucher_number"] and not payload["account_code"] and not payload["account_name"]: + continue + rows_to_insert.append(payload) + inserted += 1 + + with engine.begin() as conn: + conn.execute( + text("DELETE FROM transactions WHERE COALESCE(source_file, '') <> ''") + ) + if rows_to_insert: + conn.execute(insert_sql, rows_to_insert) + return inserted + + inserted = 0 for row in sheet.iter_rows(min_row=2, values_only=True): raw: dict[str, Any] = {} has_value = False @@ -6174,16 +6946,13 @@ def parse_excel_upload(upload_file: UploadFile) -> int: raw[field_name] = value if normalize_text(value): has_value = True - if not has_value: continue - payload = build_transaction_payload(raw, source_file=upload_file.filename or "") if not payload["voucher_number"] and not payload["account_code"] and not payload["account_name"]: continue save_transaction(payload) inserted += 1 - return inserted @@ -6193,13 +6962,118 @@ def import_excel_path(path: Path) -> int: return parse_excel_upload(upload) +def _extract_filename_date_score(filename: str) -> int: + text_name = normalize_text(filename) + if not text_name: + return 0 + tokens = re.findall(r"(\d{6,8})", text_name) + if not tokens: + return 0 + best = 0 + for token in tokens: + try: + if len(token) == 8: + score = int(token) + elif len(token) == 6: + score = int(f"20{token}") + else: + continue + except ValueError: + continue + if score > best: + best = score + return best + + +def _transaction_file_priority(path: Path) -> tuple[int, int, float]: + name = normalize_text(path.name).lower() + voucher_sort_bonus = 1 if "voucher_sort" in name else 0 + date_score = _extract_filename_date_score(path.name) + mtime = 0.0 + try: + mtime = path.stat().st_mtime + except OSError: + mtime = 0.0 + return (voucher_sort_bonus, date_score, mtime) + + +def _collect_auto_import_excel_files() -> list[Path]: + scan_dirs = [BASE_DIR] + extra_roots = [normalize_text(os.getenv("PROJECT_AUTO_IMPORT_DIRS")), normalize_text(os.getenv("WEHAGO_SOURCE_ROOT"))] + fallback_wehago_dir = BASE_DIR.parent / "WEHAGO_DB" + if fallback_wehago_dir.exists(): + extra_roots.append(str(fallback_wehago_dir)) + + seen_dirs: set[str] = set() + for root in extra_roots: + if not root: + continue + for part in root.split(os.pathsep): + normalized_part = normalize_text(part) + if not normalized_part: + continue + if normalized_part in seen_dirs: + continue + seen_dirs.add(normalized_part) + candidate = Path(normalized_part) + if candidate.exists() and candidate.is_dir(): + scan_dirs.append(candidate) + + file_map: dict[str, Path] = {} + for directory in scan_dirs: + for path in sorted(directory.glob("*.xlsx")): + if path.name.startswith("~$"): + continue + if path.name not in file_map: + file_map[path.name] = path + continue + existing = file_map[path.name] + if _transaction_file_priority(path) > _transaction_file_priority(existing): + file_map[path.name] = path + return sorted(file_map.values(), key=lambda item: item.name) + + +def _get_transaction_source_last_updated(source_file: str) -> datetime | None: + with engine.begin() as conn: + row = conn.execute( + text( + """ + SELECT MAX(updated_at) AS last_updated_at + FROM transactions + WHERE source_file = :source_file + """ + ), + {"source_file": normalize_text(source_file)}, + ).mappings().first() + raw_value = normalize_text((row or {}).get("last_updated_at")) + if not raw_value: + return None + for fmt in ("%Y-%m-%d %H:%M:%S", "%Y-%m-%dT%H:%M:%S"): + try: + return datetime.strptime(raw_value[:19], fmt) + except ValueError: + continue + return None + + +def _should_reimport_transaction_file(path: Path, known_files: set[str]) -> bool: + if path.name not in known_files: + return True + if count_transactions() <= 0: + return True + db_last_updated = _get_transaction_source_last_updated(path.name) + if not db_last_updated: + return True + try: + file_mtime = datetime.fromtimestamp(path.stat().st_mtime) + except OSError: + return False + return file_mtime > db_last_updated + + def auto_import_project_excels() -> None: init_db() - excel_files = [ - path - for path in sorted(BASE_DIR.glob("*.xlsx")) - if not path.name.startswith("~$") - ] + excel_files = _collect_auto_import_excel_files() if not excel_files: return @@ -6218,6 +7092,7 @@ def auto_import_project_excels() -> None: ): return + transaction_candidates: list[Path] = [] for excel_path in excel_files: if not zipfile.is_zipfile(excel_path): logger.warning("Skipping non-Excel or temporary workbook during auto-import: %s", excel_path.name) @@ -6231,6 +7106,9 @@ def auto_import_project_excels() -> None: 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 == "transactions": + transaction_candidates.append(excel_path) + continue 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: @@ -6249,6 +7127,25 @@ def auto_import_project_excels() -> None: except Exception: logger.exception("Failed to auto-import workbook: %s", excel_path.name) + if not transaction_candidates: + return + + selected_transaction_file = max(transaction_candidates, key=_transaction_file_priority) + if not _should_reimport_transaction_file(selected_transaction_file, known_files): + return + try: + with selected_transaction_file.open("rb") as excel_file: + upload = UploadFile(filename=selected_transaction_file.name, file=excel_file) + inserted = parse_excel_upload(upload) + logger.info( + "Auto-imported %s transaction rows from %s (selected from %s candidates)", + inserted, + selected_transaction_file, + len(transaction_candidates), + ) + except Exception: + logger.exception("Failed to auto-import transaction workbook: %s", selected_transaction_file.name) + def normalize_all_collection_entry_storage() -> None: with engine.begin() as conn: @@ -6469,15 +7366,44 @@ def build_project_status_payload(payload: dict[str, Any]) -> dict[str, Any]: row["group"] = "joint" task_plan_rows = task_plan_department_rows + task_plan_outsource_rows + task_plan_joint_rows - exec_labor_rows = build_triplet_amount_rows( - payload.get("exec_labor_grade[]", []), - payload.get("exec_labor_hours[]", []), - payload.get("exec_labor_amount[]", []), - first_key="grade", - second_key="hours", + exec_labor_grades = payload.get("exec_labor_grade[]", []) + exec_labor_hours = payload.get("exec_labor_hours[]", []) + exec_labor_amounts = payload.get("exec_labor_amount[]", []) + exec_labor_rate_years = payload.get("exec_labor_rate_year[]", []) + labor_rates_by_year = _parse_labor_rates_json(payload.get("exec_labor_rates_json")) + if not labor_rates_by_year: + labor_rates_by_year = _parse_labor_rates_json(get_shared_exec_labor_rates_json()) + fallback_rate_year = normalize_text(payload.get("year")) or str(datetime.now().year) + + exec_labor_rows: list[dict[str, Any]] = [] + exec_labor_max_length = max( + len(exec_labor_grades), + len(exec_labor_hours), + len(exec_labor_amounts), + len(exec_labor_rate_years), ) - for row in exec_labor_rows: - row["group"] = "labor" + for index in range(exec_labor_max_length): + row = { + "grade": exec_labor_grades[index] if index < len(exec_labor_grades) else "", + "hours": exec_labor_hours[index] if index < len(exec_labor_hours) else "", + "rate_year": exec_labor_rate_years[index] if index < len(exec_labor_rate_years) else "", + "amount": exec_labor_amounts[index] if index < len(exec_labor_amounts) else "", + } + normalized_row = {key: clean_row_text(value) for key, value in row.items()} + amount = normalize_amount(normalized_row.get("amount")) + has_other_value = any(value for key, value in normalized_row.items() if key != "amount") + if amount or has_other_value: + hours_value = _parse_exec_hours_value(normalized_row.get("hours")) + normalized_row["hours"] = str(int(hours_value)) if hours_value else "" + computed_amount = _resolve_labor_rate( + labor_rates_by_year, + normalized_row.get("grade"), + normalized_row.get("rate_year"), + fallback_rate_year, + ) * hours_value + normalized_row["amount"] = computed_amount if computed_amount else amount + normalized_row["group"] = "labor" + exec_labor_rows.append(normalized_row) exec_outsource_rows = build_triplet_amount_rows( payload.get("exec_outsource_dept[]", []), payload.get("exec_outsource_work[]", []), @@ -6501,17 +7427,20 @@ def build_project_status_payload(payload: dict[str, Any]) -> dict[str, Any]: actual_labor_grades = payload.get("actual_labor_grade[]", []) actual_labor_minutes = payload.get("actual_labor_minutes[]", []) actual_labor_amounts = payload.get("actual_labor_amount[]", []) + actual_labor_rate_years = payload.get("actual_labor_rate_year[]", []) actual_labor_rows: list[dict[str, Any]] = [] actual_labor_max_length = max( len(actual_labor_grades), len(actual_labor_minutes), len(actual_labor_amounts), + len(actual_labor_rate_years), ) for index in range(actual_labor_max_length): row = { "grade": actual_labor_grades[index] if index < len(actual_labor_grades) else "", "minutes": actual_labor_minutes[index] if index < len(actual_labor_minutes) else "", "amount": actual_labor_amounts[index] if index < len(actual_labor_amounts) else "", + "rate_year": actual_labor_rate_years[index] if index < len(actual_labor_rate_years) else "", } normalized_row = {key: clean_row_text(value) for key, value in row.items()} amount = normalize_amount(normalized_row.get("amount")) @@ -6520,7 +7449,15 @@ def build_project_status_payload(payload: dict[str, Any]) -> dict[str, Any]: if key != "amount" ) if amount or has_other_value: - normalized_row["amount"] = amount + minutes_value = _parse_minutes_value(normalized_row.get("minutes")) + normalized_row["minutes"] = str(int(minutes_value)) if minutes_value else "" + computed_amount = _resolve_labor_rate( + labor_rates_by_year, + normalized_row.get("grade"), + normalized_row.get("rate_year"), + fallback_rate_year, + ) * (minutes_value / 60.0 if minutes_value else 0.0) + normalized_row["amount"] = computed_amount if computed_amount else amount actual_labor_rows.append(normalized_row) for row in actual_labor_rows: row["group"] = "labor" @@ -6681,10 +7618,13 @@ 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 + started_at = time.perf_counter() + session_id = normalize_text(payload.get("client_session_id")) 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", []) + previous_snapshot: dict[str, Any] = {} basic_info_field_keys = ( "support_dept_name", @@ -6727,6 +7667,7 @@ def save_project_status(payload: dict[str, Any]) -> None: exec_budget_field_keys = ( "exec_labor_grade[]", "exec_labor_hours[]", + "exec_labor_rate_year[]", "exec_labor_amount[]", "exec_outsource_dept[]", "exec_outsource_work[]", @@ -6739,6 +7680,7 @@ def save_project_status(payload: dict[str, Any]) -> None: "actual_labor_grade[]", "actual_labor_minutes[]", "actual_labor_amount[]", + "actual_labor_rate_year[]", "actual_labor_adjustment_total", "actual_labor_joint_label[]", "actual_labor_joint_amount[]", @@ -6752,6 +7694,7 @@ def save_project_status(payload: dict[str, Any]) -> None: return any(key in payload for key in keys) with engine.begin() as conn: + previous_snapshot = load_project_status_snapshot_payload(conn, support_dept_code) existing_row = conn.execute( text("SELECT * FROM project_status WHERE support_dept_code = :support_dept_code"), {"support_dept_code": support_dept_code}, @@ -6789,7 +7732,7 @@ def save_project_status(payload: dict[str, Any]) -> None: 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[]")): + if not payload_has_any(("exec_labor_grade[]", "exec_labor_hours[]", "exec_labor_rate_year[]", "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") @@ -6799,7 +7742,7 @@ def save_project_status(payload: dict[str, Any]) -> None: 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[]")): + if not payload_has_any(("actual_labor_grade[]", "actual_labor_minutes[]", "actual_labor_amount[]", "actual_labor_rate_year[]")): 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") @@ -6854,6 +7797,27 @@ def save_project_status(payload: dict[str, Any]) -> None: actual_input_rows, ) sync_project_status_cache_row(conn, support_dept_code) + next_snapshot = get_project_status_for_edit(support_dept_code) + duration_ms = int((time.perf_counter() - started_at) * 1000) + record_project_status_snapshot(support_dept_code, session_id, previous_snapshot, next_snapshot) + log_save_event( + "project_status_save", + "project_status", + support_dept_code, + session_id=session_id, + duration_ms=duration_ms, + payload={ + "support_dept_name": normalize_text(next_snapshot.get("support_dept_name")), + "selected_revision": normalize_text(payload.get("edit_revision")), + "saved_revision": normalize_text(next_snapshot.get("updated_at")), + "collection_count": len(collection_rows), + "task_plan_count": len(task_plan_rows), + "exec_budget_count": len(exec_budget_rows), + "actual_input_count": len(actual_input_rows), + "save_scope": normalize_text(payload.get("save_scope")) or "all", + }, + ) + maybe_create_database_backup("project_status_save", session_id) def base_context(request: Request, message: str = "") -> dict[str, Any]: @@ -7478,6 +8442,7 @@ async def process_cost( end_year: str | None = None, code: str | None = None, include_related: str | None = None, + active_related: str | None = None, ): try: return render_process_cost_page( @@ -7487,6 +8452,7 @@ async def process_cost( end_year=parse_optional_year(end_year), code=code, include_related=normalize_text(include_related) in {"1", "true", "y", "yes", "on"}, + active_related=active_related, ) except Exception as exc: logger.exception("프로세스 원가 페이지 에러: %s", exc) @@ -7896,8 +8862,52 @@ async def save_project_json(request: Request): } ) ) + except ValueError as exc: + code = normalize_text(form_data.get("support_dept_code")) + logger.warning("사업현황 JSON 저장 충돌/검증 오류(%s): %s", code, exc) + log_save_event( + "project_status_save", + "project_status", + code, + session_id=form_data.get("client_session_id"), + status="error", + error_message=str(exc), + payload={ + "selected_revision": normalize_text(form_data.get("edit_revision")), + "support_dept_code": code, + "save_scope": normalize_text(form_data.get("save_scope")) or "all", + }, + ) + status_code = 409 if "먼저 수정" in str(exc) else 400 + return JSONResponse( + status_code=status_code, + content=jsonable_encoder( + { + "ok": False, + "error": str(exc) or "사업현황 저장 중 오류가 발생했습니다.", + "conflict": status_code == 409, + "support_dept_code": code, + "project_edit": get_project_status_for_edit(code), + "project_row": get_project_status_row_for_code(code), + } + ), + ) except Exception as exc: logger.exception("사업현황 JSON 저장 에러: %s", exc) + code = normalize_text(form_data.get("support_dept_code")) + log_save_event( + "project_status_save", + "project_status", + code, + session_id=form_data.get("client_session_id"), + status="error", + error_message=str(exc), + payload={ + "selected_revision": normalize_text(form_data.get("edit_revision")), + "support_dept_code": code, + "save_scope": normalize_text(form_data.get("save_scope")) or "all", + }, + ) return JSONResponse( status_code=500, content={ diff --git a/templates/base.html b/templates/base.html index 41cbefb..773f3f1 100644 --- a/templates/base.html +++ b/templates/base.html @@ -390,6 +390,72 @@ stroke: #a93a3a; } + .modal-backdrop { + position: fixed; + inset: 0; + display: none; + align-items: center; + justify-content: center; + padding: 20px; + background: rgba(20, 33, 47, 0.5); + z-index: 10000; + isolation: isolate; + } + + .modal-backdrop.open, + .modal-backdrop.is-open { + display: flex; + } + + .modal-card, + .related-modal-card { + position: relative; + z-index: 1; + background: #ffffff; + border: 1px solid var(--line); + box-shadow: 0 24px 64px rgba(21, 24, 29, 0.18); + } + + .related-modal-card { + width: min(680px, 100%); + max-height: min(70vh, 760px); + overflow: hidden; + border-radius: 16px; + padding: 14px; + display: flex; + flex-direction: column; + gap: 12px; + } + + .related-modal-head { + display: flex; + justify-content: space-between; + align-items: center; + gap: 12px; + flex-wrap: wrap; + } + + .related-modal-head--singleline { + flex-wrap: nowrap; + align-items: center; + justify-content: space-between; + } + + .related-modal-title { + margin: 0; + font-size: 16px; + font-weight: 800; + line-height: 1.2; + } + + .related-modal-body { + display: grid; + gap: 10px; + overflow: auto; + min-height: 0; + overscroll-behavior: contain; + } + .sr-only { position: absolute; width: 1px; @@ -799,7 +865,7 @@ } function hasOpenModal() { - return Boolean(document.querySelector(".modal-backdrop.open")); + return Boolean(document.querySelector(".modal-backdrop.open, .modal-backdrop.is-open")); } function shouldDelayRefresh() { diff --git a/templates/process_cost.html b/templates/process_cost.html index 598f8ed..2d077f3 100644 --- a/templates/process_cost.html +++ b/templates/process_cost.html @@ -333,6 +333,10 @@ min-width: 0; } + .pc-selected-card[hidden] { + display: none; + } + .pc-selected-head { display: flex; align-items: center; @@ -452,6 +456,16 @@ min-height: 32px; } + .pc-chip-link { + color: inherit; + text-decoration: none; + font-weight: 700; + } + + .pc-chip-link:hover { + text-decoration: underline; + } + .pc-chip button { border: none; background: transparent; @@ -463,6 +477,33 @@ cursor: pointer; } + .pc-related-include { + display: inline-flex; + align-items: center; + justify-content: center; + width: 18px; + min-width: 18px; + height: 18px; + border-radius: 999px; + border: 1px solid rgba(217, 221, 227, 0.9); + background: #fff; + color: var(--muted); + font-size: 12px; + font-weight: 800; + padding: 0; + line-height: 1; + } + + .pc-related-include.is-active { + color: #1f6f3d; + background: #eefbf3; + } + + .pc-related-include:disabled { + opacity: 0.42; + cursor: default; + } + .pc-related-form { display: grid; grid-template-columns: minmax(0, 1fr) auto auto; @@ -686,12 +727,12 @@
- {% if selected_project %} -