diff --git a/main.py b/main.py index 477d7f5..30df8ac 100644 --- a/main.py +++ b/main.py @@ -17,11 +17,12 @@ import time import tempfile import uuid import zipfile -from datetime import date, datetime, timedelta +from io import BytesIO +from datetime import date, datetime, timedelta, timezone from decimal import Decimal, InvalidOperation, ROUND_HALF_UP from functools import lru_cache from pathlib import Path -from typing import Any +from typing import Any, Mapping, Sequence from urllib.parse import parse_qs, quote_plus, unquote_plus import uvicorn @@ -30,7 +31,9 @@ from fastapi.encoders import jsonable_encoder from fastapi.responses import FileResponse, HTMLResponse, JSONResponse, RedirectResponse, Response from fastapi.staticfiles import StaticFiles from fastapi.templating import Jinja2Templates -from openpyxl import load_workbook +from openpyxl import Workbook, load_workbook +from openpyxl.styles import Alignment, Font, PatternFill +from openpyxl.utils import get_column_letter from sqlalchemy import bindparam, create_engine, event, text from sqlalchemy.engine import URL from sqlalchemy.exc import OperationalError @@ -54,6 +57,7 @@ from wehago_compare import ( _offset_group_vector, _offset_vectors_cancel_each_other, _voucher_groups_within_days, + apply_wehago_final_status_counts_to_metric_sections, cleanup_compare_runtime_artifacts, enqueue_default_pair_recommend_precompute, export_wehago_status_rows_xlsx, @@ -64,8 +68,10 @@ from wehago_compare import ( get_status_export_job, get_status_field_suggestions, get_status_detail_rows, + get_dashboard_metric_counts_nonblocking, get_wehago_compare_dashboard, get_wehago_compare_summary, + get_wehago_final_status_summary_from_conn, get_wehago_filtered_rows, import_uploaded_erp_voucher_file, init_wehago_compare_db, @@ -136,6 +142,7 @@ AUTH_PERMISSION_BY_PREFIX = { "/process-cost": "process_cost", "/cost-analysis": "cost_analysis", "/projects": "projects", + "/wehago-benefit-entertainment": "wehago_compare", "/wehago-compare": "wehago_compare", "/hanmac-browser": "hanmac_browser", "/db-browser": "db_browser", @@ -150,6 +157,7 @@ AUTH_NAV_ITEMS = [ {"href": "/cost-analysis", "label": "프로젝트 손익분석", "permission": "cost_analysis", "active": "exact"}, {"href": "/projects", "label": "프로젝트 정보", "permission": "projects", "active": "exact"}, {"href": "/wehago-compare", "label": "전표비교", "permission": "wehago_compare", "active": "exact"}, + {"href": "/wehago-benefit-entertainment", "label": "복리/접대비", "permission": "wehago_compare", "active": "exact"}, {"href": "/hanmac-browser", "label": "hanmac DB_external", "permission": "hanmac_browser", "active": "exact"}, {"href": "/db-browser", "label": "DB 조회", "permission": "db_browser", "active": "db"}, {"href": "/admin/users", "label": "사용자 관리", "permission": "admin", "active": "admin"}, @@ -186,7 +194,7 @@ PROJECT_ACCOUNT_BREAKDOWN_CACHE_TTL_SECONDS = 300.0 _COST_ANALYSIS_PAYLOAD_CACHE_LOCK = threading.Lock() _COST_ANALYSIS_PAYLOAD_CACHE: dict[tuple[Any, ...], dict[str, Any]] = {} COST_ANALYSIS_PAYLOAD_CACHE_TTL_SECONDS = 90.0 -COST_ANALYSIS_HANMAC_AGGREGATE_SCHEMA = "cost-analysis-period-v11-linked-balance" +COST_ANALYSIS_HANMAC_AGGREGATE_SCHEMA = "cost-analysis-period-v13-cumulative-profit-rate" app = FastAPI() app.add_middleware(GZipMiddleware, minimum_size=1200, compresslevel=5) @@ -670,7 +678,7 @@ def ensure_auth_schema(conn: Any) -> None: for permission_key, permission_name in permissions: conn.execute( text( - """ + f""" INSERT INTO app_permissions (permission_key, permission_name, description) VALUES (:permission_key, :permission_name, '') ON CONFLICT(permission_key) DO UPDATE SET @@ -952,6 +960,15 @@ def _auth_user_can(user: dict[str, Any] | None, permission: str) -> bool: return permission in set(user.get("permissions") or []) +def _auth_default_landing_for_user(user: dict[str, Any] | None) -> str: + if not user: + return "/" + for item in AUTH_NAV_ITEMS: + if _auth_user_can(user, str(item.get("permission") or "")): + return str(item.get("href") or "/") + return "/" + + def _auth_path_permission(path: str) -> str: if path == "/": return "dashboard" @@ -1054,6 +1071,22 @@ def authenticate_user(username: str, password: str) -> dict[str, Any] | None: return _auth_fetch_user(int(row["id"])) +def _format_kst_display_from_utc(value: Any) -> str: + text_value = normalize_text(value) + if not text_value: + return "" + try: + if text_value.endswith("Z"): + parsed = datetime.fromisoformat(text_value[:-1] + "+00:00") + else: + parsed = datetime.fromisoformat(text_value) + if parsed.tzinfo is None: + parsed = parsed.replace(tzinfo=timezone.utc) + return parsed.astimezone(timezone(timedelta(hours=9))).strftime("%Y-%m-%d %H:%M:%S") + except Exception: + return text_value + + def list_admin_users() -> list[dict[str, Any]]: init_db() with engine.begin() as conn: @@ -1068,9 +1101,19 @@ def list_admin_users() -> list[dict[str, Any]]: u.is_admin, u.created_at, u.updated_at, + last_success.created_at AS last_login_at, COALESCE(GROUP_CONCAT(DISTINCT r.role_key), '') AS roles, COALESCE(GROUP_CONCAT(DISTINCT p.permission_key), '') AS direct_permissions FROM app_users AS u + LEFT JOIN ( + SELECT + user_id, + MAX(created_at) AS created_at + FROM app_login_events + WHERE success = 1 + AND user_id IS NOT NULL + GROUP BY user_id + ) AS last_success ON last_success.user_id = u.id LEFT JOIN app_user_roles AS ur ON ur.user_id = u.id LEFT JOIN app_roles AS r ON r.id = ur.role_id LEFT JOIN app_user_permissions AS up ON up.user_id = u.id @@ -1091,11 +1134,73 @@ def list_admin_users() -> list[dict[str, Any]]: "direct_permissions": [item for item in str(row["direct_permissions"] or "").split(",") if item], "created_at": str(row["created_at"] or ""), "updated_at": str(row["updated_at"] or ""), + "last_login_at": str(row["last_login_at"] or ""), + "last_login_at_kst": _format_kst_display_from_utc(row["last_login_at"]), } for row in rows ] +def list_admin_user_login_events(user_id: int, limit: int = 100) -> dict[str, Any]: + init_db() + limit = max(1, min(int(limit or 100), 300)) + with engine.begin() as conn: + user = conn.execute( + text( + """ + SELECT id, username, display_name + FROM app_users + WHERE id = :user_id + LIMIT 1 + """ + ), + {"user_id": int(user_id)}, + ).mappings().first() + if not user: + raise ValueError("사용자를 찾을 수 없습니다.") + rows = conn.execute( + text( + """ + SELECT + id, + user_id, + username, + success, + failure_reason, + ip_address, + user_agent, + created_at + FROM app_login_events + WHERE user_id = :user_id + OR username = :username + ORDER BY created_at DESC, id DESC + LIMIT :limit + """ + ), + {"user_id": int(user["id"]), "username": user["username"], "limit": limit}, + ).mappings().all() + return { + "user": { + "id": int(user["id"]), + "username": user["username"], + "display_name": user["display_name"] or user["username"], + }, + "events": [ + { + "id": int(row["id"]), + "username": row["username"] or "", + "success": bool(row["success"]), + "failure_reason": row["failure_reason"] or "", + "ip_address": row["ip_address"] or "", + "user_agent": row["user_agent"] or "", + "created_at": str(row["created_at"] or ""), + "created_at_kst": _format_kst_display_from_utc(row["created_at"]), + } + for row in rows + ], + } + + def list_admin_permission_options() -> list[dict[str, str]]: init_db() with engine.begin() as conn: @@ -1248,6 +1353,8 @@ async def auth_middleware(request: Request, call_next): return RedirectResponse(url=f"/login?next={_auth_redirect_target(request)}", status_code=303) permission = _auth_path_permission(path) if not _auth_user_can(user, permission): + if path == "/" and not _auth_is_api_request(request): + return RedirectResponse(url=_auth_default_landing_for_user(user), status_code=303) if _auth_is_api_request(request): return JSONResponse({"error": "forbidden"}, status_code=403) return HTMLResponse("
접근 권한이 없습니다.
", status_code=403) @@ -1452,12 +1559,35 @@ def _load_projection_group_counts( *, signature_like: str | None = None, ) -> tuple[dict[str, int], int]: + active_signature = "" + if signature_like and signature_like.startswith(f"{QUERY_PROJECTION_VERSION}|"): + try: + active_row = conn.execute( + """ + SELECT setting_json + FROM wehago_compare_settings + WHERE setting_key = ? + LIMIT 1 + """, + (f"wehago_active_query_projection:{int(start_year)}:{int(end_year)}",), + ).fetchone() + if active_row and active_row[0]: + active_payload = json.loads(str(active_row[0] or "{}")) + if isinstance(active_payload, dict): + active_signature = normalize_text(active_payload.get("signature")) + except Exception: + active_signature = "" + if not active_signature.startswith(f"{QUERY_PROJECTION_VERSION}|"): + return {}, 0 where = [ "start_year <= ?", "end_year >= ?", ] params: list[Any] = [int(start_year), int(end_year)] - if signature_like: + if active_signature: + where = ["start_year = ?", "end_year = ?", "signature = ?"] + params = [int(start_year), int(end_year), active_signature] + elif signature_like: where.append("signature LIKE ?") params.append(signature_like) scope_row = conn.execute( @@ -1556,15 +1686,14 @@ def _fast_wehago_compare_summary_payload( for status_key, value in current_group_counts.items(): if status_key in counts: counts[status_key] = int(value or 0) - if "hanmac_unconnected" not in current_group_counts: - with engine.begin() as sqlalchemy_conn: - counts["hanmac_unconnected"] = len( - _load_hanmac_unconnected_source_groups( - sqlalchemy_conn, - int(start_year or 0), - int(end_year or 0), - ) + with engine.begin() as sqlalchemy_conn: + counts["hanmac_unconnected"] = len( + _load_hanmac_unconnected_source_groups( + sqlalchemy_conn, + int(start_year or 0), + int(end_year or 0), ) + ) if current_group_counts: scope_row = conn.execute( """ @@ -1672,6 +1801,18 @@ def _fast_wehago_compare_summary_payload( ).fetchone() counts[status_key] = int((row or [0])[0] or 0) + try: + resolved_counts, count_pending = get_dashboard_metric_counts_nonblocking( + engine, + int(start_year or 0), + int(end_year or 0), + ) + if any(int(resolved_counts.get(status_key, 0) or 0) for status_key in counts): + for status_key in counts: + counts[status_key] = int(resolved_counts.get(status_key, counts[status_key]) or 0) + except Exception: + count_pending = False + snapshot_state = {"ready": [], "stale": [], "missing": [], "queued": [], "running": [], "failed": []} status_rows = { int(row["fiscal_year"]): str(row["state"] or "") @@ -1690,6 +1831,7 @@ def _fast_wehago_compare_summary_payload( snapshot_state.setdefault(state, []) snapshot_state[state].append(year) pending = bool(snapshot_state["missing"] or snapshot_state["stale"] or snapshot_state["queued"] or snapshot_state["running"]) + pending = pending or bool(count_pending) last_action = None last_action_row = conn.execute( @@ -1736,10 +1878,19 @@ def _fast_wehago_compare_summary_payload( ("bridge_expense_review", "2단계 비교", ""), ) ] + with engine.begin() as sqlalchemy_conn: + final_status_summary = get_wehago_final_status_summary_from_conn( + sqlalchemy_conn, + start_year, + end_year, + counts, + ) + metric_sections = apply_wehago_final_status_counts_to_metric_sections(metric_sections, final_status_summary) return { "selected_start_year": start_year, "selected_end_year": end_year, "metric_sections": metric_sections, + "wehago_final_status_summary": final_status_summary, "last_action": last_action, "pending": pending, "snapshot_state": snapshot_state, @@ -2483,6 +2634,27 @@ def init_db() -> None: """ ) ) + conn.execute( + text( + """ + CREATE TABLE IF NOT EXISTS wehago_benefit_category_overrides ( + ledger_row_id INTEGER PRIMARY KEY, + category TEXT NOT NULL DEFAULT '', + memo TEXT NOT NULL DEFAULT '', + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) + """ + ) + ) + conn.execute( + text( + """ + CREATE INDEX IF NOT EXISTS idx_wehago_benefit_category_overrides_updated + ON wehago_benefit_category_overrides (updated_at) + """ + ) + ) conn.execute( text( """ @@ -4866,8 +5038,12 @@ def _safe_next_url(value: Any) -> str: @app.get("/login") async def login_page(request: Request, next: str = ""): init_db() - if _auth_get_request_user(request): - return RedirectResponse(url=_safe_next_url(next), status_code=303) + current_user = _auth_get_request_user(request) + if current_user: + next_url = _safe_next_url(next) + if next_url == "/" and not _auth_user_can(current_user, "dashboard"): + next_url = _auth_default_landing_for_user(current_user) + return RedirectResponse(url=next_url, status_code=303) return templates.TemplateResponse( request, "login.html", @@ -4890,6 +5066,8 @@ async def login_submit(request: Request): {"request": request, "next_url": next_url, "error": "아이디 또는 비밀번호가 올바르지 않습니다."}, status_code=401, ) + if next_url == "/" and not _auth_user_can(user, "dashboard"): + next_url = _auth_default_landing_for_user(user) session_token = create_login_session(int(user["id"]), request) _auth_log_event(username, True, request, user_id=int(user["id"])) response = RedirectResponse(url=next_url, status_code=303) @@ -4920,6 +5098,14 @@ async def admin_users(request: Request, message: str = ""): return templates.TemplateResponse(request, "admin_users.html", context) +@app.get("/admin/users/{user_id}/login-events") +async def admin_user_login_events(user_id: int, limit: int = 100): + try: + return JSONResponse(list_admin_user_login_events(user_id, limit=limit)) + except ValueError as exc: + return JSONResponse({"error": str(exc)}, status_code=404) + + @app.post("/admin/users") async def admin_users_save(request: Request): form = await request.form() @@ -6730,6 +6916,7 @@ BUSINESS_DATA_INCLUDE_TABLES = { "project_uncontracted_classification", "transactions", "wehago_compare_settings", + "wehago_benefit_category_overrides", "wehago_comparison_results", "wehago_ledger_rows", "wehago_manual_pair_matches", @@ -11315,6 +11502,762 @@ def base_context(request: Request, message: str = "") -> dict[str, Any]: } +WEHAGO_BENEFIT_DEFAULT_START_YEAR = 2023 +WEHAGO_BENEFIT_DEFAULT_END_YEAR = 2025 +WEHAGO_BENEFIT_CATEGORIES = ["급여성비용", "공통복지비", "부서/현장 운영비", "임원 개인성 비용", "경조사", "식대", "운동비", "기타"] +WEHAGO_BENEFIT_EXPORT_COLUMNS = [ + ("year", "연도"), + ("ledger_date", "일자"), + ("voucher_no", "전표번호"), + ("account_group", "구분"), + ("category", "분류"), + ("person_name", "개인/귀속"), + ("hanmac_member_grade", "직급"), + ("account_name", "계정"), + ("vendor_name", "거래처"), + ("debit", "차변금액"), + ("credit", "대변금액"), + ("net_amount", "순금액"), + ("amount", "금액"), + ("description", "적요"), + ("counterpart_details", "상대계정"), + ("basis", "분류근거"), + ("classification_review", "직급검토"), +] + +WEHAGO_EXECUTIVE_GRADES = {"대표", "회장", "부회장", "사장", "부사장", "전무", "상무", "이사"} +WEHAGO_CONGRAT_CONDOLENCE_KEYWORDS = ( + "경조", + "조의", + "부의", + "부고", + "근조", + "화환", + "축의", + "결혼", + "장례", + "상조", + "부친상", + "모친상", + "빙부", + "빙부상", + "빙모", + "빙모상", + "시부상", + "시모상", + "배우자상", + "자녀출산", + "아기출산", + "출산", + "칠순", + "팔순", + "회갑", + "환갑", + "돌잔치", +) + + +def _normalize_report_year(value: Any, fallback: int) -> int: + try: + year = int(value) + except Exception: + return fallback + if year < 2000 or year > 2100: + return fallback + return year + + +def _normalize_report_bool(value: Any) -> bool: + return normalize_text(value).lower() in {"1", "true", "y", "yes", "on", "포함"} + + +def _contains_any_keyword(text_value: str, keywords: tuple[str, ...]) -> str: + normalized = normalize_text(text_value).lower() + for keyword in keywords: + if keyword.lower() in normalized: + return keyword + return "" + + +def _normalize_wehago_vendor_name(value: Any) -> str: + vendor = normalize_text(value) + corrections = { + "임원/한형과": "임원/한형관", + "한형과": "한형관", + } + return corrections.get(vendor, vendor) + + +def _is_wehago_adjustment_or_reclass_row(row: Mapping[str, Any]) -> bool: + text_value = " ".join( + [ + normalize_text(row.get("account_code")), + normalize_text(row.get("account_name")), + normalize_text(row.get("vendor_name")), + normalize_text(row.get("description")), + ] + ) + return bool(_contains_any_keyword(text_value, ("대체", "원가", "손익"))) + + +def _infer_wehago_person_name(vendor_name: Any, description: Any) -> str: + vendor = _normalize_wehago_vendor_name(vendor_name) + if "/" in vendor: + tail = normalize_text(vendor.rsplit("/", 1)[-1]) + if tail: + return tail + if re.fullmatch(r"[가-힣]{2,5}[A-Za-z]?", vendor): + return vendor + desc = normalize_text(description) + match = re.search(r"(?:대표|사장|부사장|전무|상무|이사|임원)?\s*([가-힣]{2,4}[A-Za-z]?)", desc) + if match and any(title in desc for title in ("대표", "사장", "부사장", "전무", "상무", "이사", "임원")): + return match.group(1) + return vendor or "미지정" + + +def _hanmac_member_grade_lookup_signature() -> str: + init_db() + with engine.begin() as conn: + db_signature = conn.execute( + text( + """ + SELECT COUNT(*) || ':' || COALESCE(MAX(updated_at), '') + FROM hanmac_aggregate_query_metrics + WHERE view_mode = 'member' + """ + ) + ).scalar() + joint_cache_path = BASE_DIR / "static" / "hanmac-joint-members-cache.json" + try: + stat = joint_cache_path.stat() + file_signature = f"{stat.st_mtime_ns}:{stat.st_size}" + except Exception: + file_signature = "" + return hashlib.sha1(f"{db_signature or ''}|{file_signature}".encode("utf-8")).hexdigest() + + +def _load_hanmac_member_grade_lookup() -> dict[str, dict[str, Any]]: + return dict(_load_hanmac_member_grade_lookup_cached(_hanmac_member_grade_lookup_signature())) + + +@lru_cache(maxsize=4) +def _load_hanmac_member_grade_lookup_cached(_signature: str) -> dict[str, dict[str, Any]]: + init_db() + with engine.begin() as conn: + cache_keys = [ + normalize_text(row[0]) + for row in conn.execute( + text( + """ + SELECT cache_key + FROM hanmac_aggregate_query_metrics + WHERE view_mode = 'member' + ORDER BY updated_at DESC + LIMIT 8 + """ + ) + ).fetchall() + if normalize_text(row[0]) + ] + row_items: list[str] = [] + for cache_key in cache_keys: + row_items.extend( + str(item or "") + for item in conn.execute( + text( + """ + SELECT row_json + FROM hanmac_aggregate_query_rows + WHERE cache_key = :cache_key + AND ( + row_json LIKE '%member_grade%' + OR row_json LIKE '%"grade"%' + OR row_json LIKE '%"position"%' + OR row_json LIKE '%"rank"%' + ) + ORDER BY row_index + """ + ), + {"cache_key": cache_key}, + ).scalars().all() + ) + lookup: dict[str, dict[str, Any]] = {} + + def add_member_row(row: dict[str, Any]) -> None: + member_name = normalize_text(row.get("member_name") or row.get("name")) + if not member_name: + return + member_grade = _normalize_labor_grade_name( + row.get("member_grade") + or row.get("grade") + or row.get("position") + or row.get("rank") + ) + member_key = _hanmac_normalize_person_name(member_name) + if not member_key: + return + existing = lookup.get(member_key) + if existing and existing.get("member_grade") and not member_grade: + return + lookup[member_key] = { + "member_name": member_name, + "member_no": normalize_text(row.get("member_no")), + "member_grade": member_grade, + "dept_name": normalize_text(row.get("dept_name")), + "status": normalize_text(row.get("status")), + } + + for item in row_items: + try: + row = json.loads(item or "{}") + except Exception: + continue + if isinstance(row, dict): + add_member_row(row) + + joint_cache_path = BASE_DIR / "static" / "hanmac-joint-members-cache.json" + try: + joint_payload = json.loads(joint_cache_path.read_text(encoding="utf-8")) + except Exception: + joint_payload = {} + if isinstance(joint_payload, dict): + payload_items = [] + by_key = joint_payload.get("by_key") + if isinstance(by_key, dict): + payload_items.extend(value for value in by_key.values() if isinstance(value, dict)) + latest = joint_payload.get("latest") + if isinstance(latest, dict): + payload_items.append(latest) + for payload_item in payload_items: + for row in payload_item.get("joint_members") or []: + if isinstance(row, dict): + add_member_row(row) + return lookup + + +def _lookup_hanmac_member_grade(person_name: Any, member_lookup: dict[str, dict[str, Any]]) -> dict[str, Any]: + person_key = _hanmac_normalize_person_name(person_name) + if not person_key: + return {} + return member_lookup.get(person_key) or {} + + +def _is_wehago_executive_grade(member_grade: Any) -> bool: + grade = _normalize_labor_grade_name(member_grade) + return bool(grade and (grade in WEHAGO_EXECUTIVE_GRADES or any(item in grade for item in WEHAGO_EXECUTIVE_GRADES))) + + +def save_wehago_benefit_category_override(ledger_row_id: Any, category: Any) -> dict[str, Any]: + row_id = int(ledger_row_id or 0) + normalized_category = normalize_text(category) + if row_id <= 0: + raise ValueError("전표 행 ID가 올바르지 않습니다.") + if normalized_category not in set(WEHAGO_BENEFIT_CATEGORIES): + raise ValueError("분류 값이 올바르지 않습니다.") + init_db() + init_wehago_compare_db(engine) + with engine.begin() as conn: + exists = conn.execute( + text("SELECT 1 FROM wehago_ledger_rows WHERE id = :row_id LIMIT 1"), + {"row_id": row_id}, + ).scalar() + if not exists: + raise ValueError("저장할 전표 행을 찾을 수 없습니다.") + conn.execute( + text( + """ + INSERT INTO wehago_benefit_category_overrides ( + ledger_row_id, category, updated_at + ) VALUES ( + :ledger_row_id, :category, CURRENT_TIMESTAMP + ) + ON CONFLICT(ledger_row_id) DO UPDATE SET + category = excluded.category, + updated_at = CURRENT_TIMESTAMP + """ + ), + {"ledger_row_id": row_id, "category": normalized_category}, + ) + return {"ok": True, "ledger_row_id": row_id, "category": normalized_category} + + +def _classify_wehago_benefit_row(row: dict[str, Any], member_grade: str = "") -> tuple[str, str, str]: + account_code = normalize_text(row.get("account_code")) + account_name = normalize_text(row.get("account_name")) + vendor_name = _normalize_wehago_vendor_name(row.get("vendor_name")) + description = normalize_text(row.get("description")) + text_value = " ".join([account_code, account_name, vendor_name, description]) + congrat_keyword = _contains_any_keyword(text_value, WEHAGO_CONGRAT_CONDOLENCE_KEYWORDS) + + if "접대" in account_name or account_code.startswith("813"): + account_group = "접대비" + rules = ( + ("경조사", WEHAGO_CONGRAT_CONDOLENCE_KEYWORDS), + ("운동비", ("골프", "운동", "체력", "연습장", "스포츠", "피트니스")), + ("식대", ("식대", "식사", "오찬", "만찬", "점심", "저녁", "회식", "음식", "식당", "카페", "커피", "주점")), + ) + for category, keywords in rules: + matched = _contains_any_keyword(text_value, keywords) + if matched: + return account_group, category, matched + return account_group, "기타", "" + + account_group = "복리후생비" + payroll_keyword = _contains_any_keyword(text_value, ("건강보험료", "장기요양보험료", "고용보험료", "산재보험료")) + if payroll_keyword: + return account_group, "급여성비용", payroll_keyword + + executive_keyword = _contains_any_keyword(text_value, ("체력", "운동", "헬스", "골프", "골프연습", "연습장", "피트니스")) + grade_is_executive = _is_wehago_executive_grade(member_grade) + vendor_is_executive = grade_is_executive or vendor_name.startswith("임원/") or _contains_any_keyword(text_value, ("대표", "사장", "부사장", "전무", "상무", "이사")) + if congrat_keyword: + return account_group, "경조사", congrat_keyword + + meal_keyword = _contains_any_keyword(text_value, ("식대", "식사", "오찬", "만찬", "점심", "저녁")) + if executive_keyword or (vendor_is_executive and meal_keyword): + return account_group, "임원 개인성 비용", executive_keyword or meal_keyword or member_grade or "임원" + + common_keyword = _contains_any_keyword( + text_value, + ( + "전직원", + "전 직원", + "임직원", + "전체", + "명절", + "선물", + "건강보험", + "고용보험", + "산재", + "장기요양", + "국민연금", + "보험료", + "복지", + "건강검진", + "단체", + "창립", + ), + ) + if common_keyword: + return account_group, "공통복지비", common_keyword + + operation_keyword = _contains_any_keyword( + text_value, + ( + "부서", + "현장", + "회식", + "간식", + "식대", + "식사", + "점심", + "저녁", + "야근", + "야식", + "합사", + "사무실", + "회의", + "워크샵", + "워크숍", + "송년회", + ), + ) + if operation_keyword: + return account_group, "부서/현장 운영비", operation_keyword + return account_group, "기타", "" + + +def _wehago_voucher_key(row: Mapping[str, Any]) -> tuple[int, str, str]: + return ( + int(row.get("year") or row.get("fiscal_year") or 0), + normalize_text(row.get("ledger_date")), + normalize_text(row.get("voucher_no")), + ) + + +def _format_wehago_counterpart_details(row: Mapping[str, Any], voucher_lines: Sequence[Mapping[str, Any]]) -> str: + row_id = int(row.get("ledger_row_id") or 0) + lines: list[str] = [] + for line in voucher_lines: + line_id = int(line.get("ledger_row_id") or 0) + if row_id and line_id == row_id: + continue + debit = normalize_amount(line.get("debit")) + credit = normalize_amount(line.get("credit")) + side = "차" if debit else "대" + amount = debit if debit else credit + pieces = [ + f"[{side}]", + normalize_text(line.get("account_name")) or normalize_text(line.get("account_code")), + ] + vendor_name = normalize_text(line.get("vendor_name")) + if vendor_name: + pieces.append(vendor_name) + if amount: + pieces.append(f"{amount:,.0f}") + description = normalize_text(line.get("description")) + if description: + pieces.append(description) + lines.append(" ".join(piece for piece in pieces if piece)) + return " / ".join(lines) + + +def _format_wehago_counterpart_tooltip(counterpart_details: Any) -> str: + return normalize_text(counterpart_details).replace(" / [", "\n[") + + +def get_wehago_benefit_entertainment_report( + start_year: int = WEHAGO_BENEFIT_DEFAULT_START_YEAR, + end_year: int = WEHAGO_BENEFIT_DEFAULT_END_YEAR, + account_group: str = "all", + category: str = "all", + person_keyword: str = "", + desc_keyword: str = "", + include_adjustments: bool = False, + limit: int | None = 300, +) -> dict[str, Any]: + init_db() + init_wehago_compare_db(engine) + start_year = _normalize_report_year(start_year, WEHAGO_BENEFIT_DEFAULT_START_YEAR) + end_year = _normalize_report_year(end_year, WEHAGO_BENEFIT_DEFAULT_END_YEAR) + if start_year > end_year: + start_year, end_year = end_year, start_year + + include_adjustments = bool(include_adjustments) + adjustment_sql = "" + if not include_adjustments: + adjustment_sql = """ + AND COALESCE(account_name, '') NOT LIKE '%대체%' + AND COALESCE(account_name, '') NOT LIKE '%원가%' + AND COALESCE(account_name, '') NOT LIKE '%손익%' + AND COALESCE(vendor_name, '') NOT LIKE '%대체%' + AND COALESCE(vendor_name, '') NOT LIKE '%원가%' + AND COALESCE(vendor_name, '') NOT LIKE '%손익%' + AND COALESCE(description, '') NOT LIKE '%대체%' + AND COALESCE(description, '') NOT LIKE '%원가%' + AND COALESCE(description, '') NOT LIKE '%손익%' + """ + params = {"start_year": start_year, "end_year": end_year} + with engine.begin() as conn: + source_rows = conn.execute( + text( + f""" + SELECT + id AS ledger_row_id, + fiscal_year AS year, + COALESCE(ledger_date, '') AS ledger_date, + COALESCE(voucher_no, '') AS voucher_no, + COALESCE(account_code, '') AS account_code, + COALESCE(account_name, '') AS account_name, + COALESCE(vendor_name, '') AS vendor_name, + COALESCE(debit, 0) AS debit, + COALESCE(credit, 0) AS credit, + COALESCE(description, '') AS description + FROM wehago_ledger_rows + WHERE fiscal_year BETWEEN :start_year AND :end_year + AND COALESCE(debit, 0) > 0 + AND ( + COALESCE(account_name, '') LIKE '%복리후생%' + OR COALESCE(account_name, '') LIKE '%접대%' + OR COALESCE(account_code, '') IN ('611', '811', '813') + ) + {adjustment_sql} + ORDER BY fiscal_year, ledger_date, voucher_no, id + """ + ), + params, + ).mappings().all() + override_rows = conn.execute( + text( + """ + SELECT ledger_row_id, category + FROM wehago_benefit_category_overrides + """ + ) + ).mappings().all() + voucher_line_rows: list[Mapping[str, Any]] = [] + voucher_keys = sorted({_wehago_voucher_key(row) for row in source_rows if normalize_text(row.get("voucher_no"))}) + with engine.begin() as conn: + for chunk_start in range(0, len(voucher_keys), 250): + chunk = voucher_keys[chunk_start : chunk_start + 250] + if not chunk: + continue + chunk_params: dict[str, Any] = {} + values_sql: list[str] = [] + for index, (year, ledger_date, voucher_no) in enumerate(chunk): + year_key = f"year_{index}" + date_key = f"ledger_date_{index}" + voucher_key = f"voucher_no_{index}" + values_sql.append(f"(:{year_key}, :{date_key}, :{voucher_key})") + chunk_params[year_key] = year + chunk_params[date_key] = ledger_date + chunk_params[voucher_key] = voucher_no + voucher_line_rows.extend( + conn.execute( + text( + f""" + WITH target_keys(year, ledger_date, voucher_no) AS ( + VALUES {", ".join(values_sql)} + ) + SELECT + line.id AS ledger_row_id, + line.fiscal_year AS year, + COALESCE(line.ledger_date, '') AS ledger_date, + COALESCE(line.voucher_no, '') AS voucher_no, + COALESCE(line.account_code, '') AS account_code, + COALESCE(line.account_name, '') AS account_name, + COALESCE(line.vendor_name, '') AS vendor_name, + COALESCE(line.debit, 0) AS debit, + COALESCE(line.credit, 0) AS credit, + COALESCE(line.description, '') AS description + FROM wehago_ledger_rows AS line + JOIN target_keys AS target + ON target.year = line.fiscal_year + AND target.ledger_date = COALESCE(line.ledger_date, '') + AND target.voucher_no = COALESCE(line.voucher_no, '') + ORDER BY line.fiscal_year, line.ledger_date, line.voucher_no, line.id + """ + ), + chunk_params, + ).mappings().all() + ) + voucher_lines_by_key: dict[tuple[int, str, str], list[dict[str, Any]]] = {} + for voucher_line in voucher_line_rows: + line_item = dict(voucher_line) + voucher_lines_by_key.setdefault(_wehago_voucher_key(line_item), []).append(line_item) + category_overrides = { + int(row["ledger_row_id"]): normalize_text(row["category"]) + for row in override_rows + if row.get("ledger_row_id") is not None + } + + account_group_filter = normalize_text(account_group) + category_filter = normalize_text(category) + person_filter = normalize_text(person_keyword).lower() + desc_filter = normalize_text(desc_keyword).lower() + member_grade_lookup = _load_hanmac_member_grade_lookup() + detail_rows: list[dict[str, Any]] = [] + for row in source_rows: + item = dict(row) + item["vendor_name"] = _normalize_wehago_vendor_name(item.get("vendor_name")) + person_name = _infer_wehago_person_name(item.get("vendor_name"), item.get("description")) + member_record = _lookup_hanmac_member_grade(person_name, member_grade_lookup) + member_grade = normalize_text(member_record.get("member_grade")) + row_group, row_category, basis = _classify_wehago_benefit_row(item, member_grade=member_grade) + ledger_row_id = int(item.get("ledger_row_id") or 0) + manual_category = category_overrides.get(ledger_row_id, "") + if manual_category: + row_category = manual_category + basis = "수동저장" + if account_group_filter not in {"", "all", "전체"} and row_group != account_group_filter: + continue + if category_filter not in {"", "all", "전체"} and row_category != category_filter: + continue + if person_filter and person_filter not in normalize_text(person_name).lower() and person_filter not in normalize_text(item.get("vendor_name")).lower(): + continue + searchable_desc = " ".join([normalize_text(item.get("description")), normalize_text(item.get("vendor_name")), normalize_text(item.get("account_name"))]).lower() + if desc_filter and desc_filter not in searchable_desc: + continue + debit = normalize_amount(item.get("debit")) + credit = normalize_amount(item.get("credit")) + if debit <= 0: + continue + if not include_adjustments and _is_wehago_adjustment_or_reclass_row(item): + continue + net_amount = debit - credit + amount = debit + counterpart_details = _format_wehago_counterpart_details(item, voucher_lines_by_key.get(_wehago_voucher_key(item), [])) + counterpart_tooltip = _format_wehago_counterpart_tooltip(counterpart_details) + classification_review = "" + if member_grade: + classification_review = f"hanmac DB_external 직급 확인: {member_grade}" + if _is_wehago_executive_grade(member_grade) and row_group == "복리후생비": + classification_review += " / 임원급 기준 검토" + detail_rows.append( + { + **item, + "ledger_row_id": ledger_row_id, + "account_group": row_group, + "category": row_category, + "manual_category": manual_category, + "is_manual_category": bool(manual_category), + "person_name": person_name, + "hanmac_member_no": normalize_text(member_record.get("member_no")), + "hanmac_member_grade": member_grade, + "hanmac_dept_name": normalize_text(member_record.get("dept_name")), + "debit": debit, + "credit": credit, + "net_amount": net_amount, + "amount": amount, + "counterpart_details": counterpart_details, + "counterpart_tooltip": counterpart_tooltip, + "basis": basis or "기타", + "classification_review": classification_review, + } + ) + + summary_map: dict[tuple[str, str, str], dict[str, Any]] = {} + category_map: dict[tuple[str, str], dict[str, Any]] = {} + yearly_map: dict[tuple[int, str], dict[str, Any]] = {} + category_year_map: dict[tuple[str, str], dict[str, Any]] = {} + report_years = list(range(start_year, end_year + 1)) + for item in detail_rows: + vendor_name = normalize_text(item.get("vendor_name")) or "미지정" + person_name = normalize_text(item.get("person_name")) + summary_key = (item["account_group"], item["category"], vendor_name) + summary = summary_map.setdefault( + summary_key, + { + "account_group": item["account_group"], + "category": item["category"], + "vendor_name": vendor_name, + "person_name": person_name, + "person_names": set(), + "hanmac_member_grade": item.get("hanmac_member_grade", ""), + "amount": 0.0, + "debit": 0.0, + "credit": 0.0, + "row_count": 0, + "last_date": "", + }, + ) + if person_name: + summary["person_names"].add(person_name) + if not summary.get("hanmac_member_grade") and item.get("hanmac_member_grade"): + summary["hanmac_member_grade"] = item.get("hanmac_member_grade", "") + summary["amount"] += item["amount"] + summary["debit"] += item["debit"] + summary["credit"] += item["credit"] + summary["row_count"] += 1 + summary["last_date"] = max(summary["last_date"], normalize_text(item.get("ledger_date"))) + + category_key = (item["account_group"], item["category"]) + category_summary = category_map.setdefault(category_key, {"account_group": item["account_group"], "category": item["category"], "amount": 0.0, "row_count": 0}) + category_summary["amount"] += item["amount"] + category_summary["row_count"] += 1 + + category_year_summary = category_year_map.setdefault( + category_key, + { + "account_group": item["account_group"], + "category": item["category"], + "year_amounts": {year: 0.0 for year in report_years}, + "total_amount": 0.0, + "row_count": 0, + }, + ) + item_year = int(item.get("year") or 0) + if item_year not in category_year_summary["year_amounts"]: + category_year_summary["year_amounts"][item_year] = 0.0 + category_year_summary["year_amounts"][item_year] += item["amount"] + category_year_summary["total_amount"] += item["amount"] + category_year_summary["row_count"] += 1 + + yearly_key = (int(item.get("year") or 0), item["account_group"]) + yearly_summary = yearly_map.setdefault(yearly_key, {"year": int(item.get("year") or 0), "account_group": item["account_group"], "amount": 0.0, "row_count": 0}) + yearly_summary["amount"] += item["amount"] + yearly_summary["row_count"] += 1 + + summary_rows: list[dict[str, Any]] = [] + for row in summary_map.values(): + person_names = sorted(name for name in row.pop("person_names", set()) if name) + row["person_name"] = ", ".join(person_names[:4]) + (" 외" if len(person_names) > 4 else "") + summary_rows.append(row) + vendor_total_amounts: dict[str, float] = {} + for row in summary_rows: + vendor_name = normalize_text(row.get("vendor_name")) or "미지정" + vendor_total_amounts[vendor_name] = vendor_total_amounts.get(vendor_name, 0.0) + float(row.get("amount") or 0.0) + + def summary_sort_key(row: Mapping[str, Any]) -> tuple[float, str, float, str, str]: + vendor_name = normalize_text(row.get("vendor_name")) or "미지정" + return ( + -vendor_total_amounts.get(vendor_name, 0.0), + vendor_name, + -float(row.get("amount") or 0.0), + normalize_text(row.get("account_group")), + normalize_text(row.get("category")), + ) + + sorted_summary_rows = sorted(summary_rows, key=summary_sort_key) + category_year_summary_rows = sorted( + category_year_map.values(), + key=lambda row: (normalize_text(row.get("account_group")), -float(row.get("total_amount") or 0), normalize_text(row.get("category"))), + ) + all_detail_rows = sorted(detail_rows, key=lambda row: (row.get("year") or 0, row.get("ledger_date") or "", row.get("voucher_no") or "")) + shown_detail_rows = all_detail_rows if limit is None else all_detail_rows[: max(0, int(limit))] + total_amount = sum(row["amount"] for row in all_detail_rows) + return { + "filters": { + "start_year": start_year, + "end_year": end_year, + "account_group": account_group_filter or "all", + "category": category_filter or "all", + "person_keyword": normalize_text(person_keyword), + "desc_keyword": normalize_text(desc_keyword), + "include_adjustments": include_adjustments, + }, + "total_amount": total_amount, + "row_count": len(all_detail_rows), + "shown_count": len(shown_detail_rows), + "detail_rows": shown_detail_rows, + "all_detail_rows": all_detail_rows, + "vendor_summary_rows": sorted_summary_rows, + "person_summary_rows": sorted_summary_rows, + "category_summary_rows": sorted(category_map.values(), key=lambda row: (row["account_group"], -row["amount"], row["category"])), + "yearly_summary_rows": sorted(yearly_map.values(), key=lambda row: (row["year"], row["account_group"])), + "category_year_summary_rows": category_year_summary_rows, + "summary_years": report_years, + "category_options": WEHAGO_BENEFIT_CATEGORIES, + "account_group_options": ["복리후생비", "접대비"], + "hanmac_grade_match_count": sum(1 for row in all_detail_rows if row.get("hanmac_member_grade")), + "hanmac_grade_lookup_count": len(member_grade_lookup), + } + + +def export_wehago_benefit_entertainment_xlsx(report: dict[str, Any]) -> tuple[str, bytes]: + workbook = Workbook() + worksheet = workbook.active + worksheet.title = "상세" + header_fill = PatternFill("solid", fgColor="1F2937") + header_font = Font(color="FFFFFF", bold=True) + worksheet.append([label for _, label in WEHAGO_BENEFIT_EXPORT_COLUMNS]) + for cell in worksheet[1]: + cell.fill = header_fill + cell.font = header_font + cell.alignment = Alignment(horizontal="center") + for row in report["all_detail_rows"]: + worksheet.append([row.get(field, "") for field, _ in WEHAGO_BENEFIT_EXPORT_COLUMNS]) + worksheet.freeze_panes = "A2" + worksheet.auto_filter.ref = f"A1:Q{max(len(report['all_detail_rows']) + 1, 1)}" + widths = [8, 12, 12, 14, 18, 16, 10, 18, 24, 14, 14, 14, 14, 46, 54, 14, 28] + for index, width in enumerate(widths, start=1): + worksheet.column_dimensions[get_column_letter(index)].width = width + for row in worksheet.iter_rows(min_row=2, min_col=10, max_col=13): + for cell in row: + cell.number_format = '#,##0' + + summary_sheet = workbook.create_sheet(title="거래처별 요약") + summary_sheet.append(["구분", "분류", "거래처", "개인/귀속", "직급", "금액", "차변금액", "대변금액", "건수", "최근일자"]) + for cell in summary_sheet[1]: + cell.fill = header_fill + cell.font = header_font + cell.alignment = Alignment(horizontal="center") + for row in report["vendor_summary_rows"]: + summary_sheet.append([row.get("account_group"), row.get("category"), row.get("vendor_name"), row.get("person_name"), row.get("hanmac_member_grade"), row.get("amount"), row.get("debit"), row.get("credit"), row.get("row_count"), row.get("last_date")]) + summary_sheet.freeze_panes = "A2" + for column, width in zip("ABCDEFGHIJ", [14, 18, 24, 18, 10, 14, 14, 14, 10, 12], strict=False): + summary_sheet.column_dimensions[column].width = width + for row in summary_sheet.iter_rows(min_row=2, min_col=6, max_col=8): + for cell in row: + cell.number_format = '#,##0' + + buffer = BytesIO() + workbook.save(buffer) + filters = report["filters"] + file_name = f"wehago_benefit_entertainment_{filters['start_year']}_{filters['end_year']}_{datetime.now().strftime('%Y%m%d_%H%M%S')}.xlsx" + return file_name, buffer.getvalue() + + def ensure_hmbiz_process_db() -> None: if HMBIZ_PROCESS_DB_PATH.exists(): return @@ -12718,6 +13661,154 @@ def _cost_analysis_load_hanmac_project_hours_by_year( return result +def _cost_analysis_load_hanmac_hours_and_labor_by_year( + start_date: date, + end_date: date, + project_meta: dict[str, dict[str, Any]], + allowed_codes: set[str] | None = None, +) -> tuple[dict[int, dict[str, dict[str, float]]], dict[int, dict[str, dict[str, float]]]]: + alias_to_code, title_to_codes = _cost_analysis_build_hanmac_matchers(project_meta) + metric, row_items = _cost_analysis_load_hanmac_member_rows(start_date, end_date, prefer_member_grade=True) + if not metric: + return {}, {} + + rates_by_year = _parse_labor_rates_json(get_shared_exec_labor_rates_json()) + if not rates_by_year: + rates_by_year = _parse_labor_rates_json(json.dumps(DEFAULT_EXEC_LABOR_RATES, ensure_ascii=False)) + completion_dates = _cost_analysis_get_completion_billing_dates() + hours_result: dict[int, dict[str, dict[str, float]]] = {} + labor_result: dict[int, dict[str, dict[str, float]]] = {} + resolve_cache: dict[tuple[str, str, str], list[str]] = {} + + def add_project(project: dict[str, Any], work_date_text: Any, hours: float, member_grade: str = "") -> None: + if hours <= 0: + return + work_date = _parse_iso_date(work_date_text) + if work_date and (work_date < start_date or work_date > end_date): + return + effective_date = work_date or start_date + resolve_key = ( + normalize_text(project.get("project_code")).upper(), + "|".join(normalize_text(value).upper() for value in (project.get("equivalent_project_codes") or [])), + f"{normalize_project_title_for_linking(project.get('project_name'))}|{effective_date.isoformat()}", + ) + if resolve_key in resolve_cache: + codes = resolve_cache[resolve_key] + else: + codes = _cost_analysis_resolve_hanmac_project_codes(project, effective_date, alias_to_code, title_to_codes, project_meta) + resolve_cache[resolve_key] = codes + if not codes: + return + + split_hours = hours / len(codes) + year = effective_date.year + year_text = str(year) + for code in codes: + normalized_code = normalize_text(code).upper() + if allowed_codes is not None and normalized_code not in allowed_codes: + continue + phase = _cost_analysis_phase_for_transaction(normalized_code, effective_date.isoformat(), completion_dates) + hours_result.setdefault(year, {}).setdefault( + normalized_code, + {"pre": 0.0, "during": 0.0, "post": 0.0}, + )[phase] += split_hours + + if not member_grade: + continue + rate = _resolve_labor_rate( + rates_by_year, + member_grade, + year_text, + year_text, + (project_meta.get(normalized_code) or {}).get("project_type"), + ) + if rate <= 0: + continue + labor_result.setdefault(year, {}).setdefault( + normalized_code, + {"pre": 0.0, "during": 0.0, "post": 0.0}, + )[phase] += rate * split_hours + + for row in row_items: + member_grade = _normalize_labor_grade_name( + row.get("member_grade") + or row.get("grade") + or row.get("position") + or row.get("rank") + ) + details = row.get("aggregate_details") if isinstance(row.get("aggregate_details"), dict) else {} + for detail in details.get("regular_hours") or []: + projects = detail.get("projects") if isinstance(detail.get("projects"), list) else [] + raw_total = sum(normalize_amount(project.get("hours")) for project in projects) + recognized_total = normalize_amount(detail.get("regular_hours")) + for project in projects: + raw_hours = normalize_amount(project.get("hours")) + hours = recognized_total * raw_hours / raw_total if raw_total > 0 and recognized_total > 0 else raw_hours + add_project(project, detail.get("work_date"), hours, member_grade) + for detail in details.get("overtime_hours") or []: + add_project(detail, detail.get("work_date"), normalize_amount(detail.get("overtime_hours")), member_grade) + for detail in details.get("holiday_hours") or []: + projects = detail.get("projects") if isinstance(detail.get("projects"), list) else [] + if projects: + raw_total = sum(normalize_amount(project.get("hours")) for project in projects) + recognized_total = normalize_amount(detail.get("holiday_hours")) + for project in projects: + raw_hours = normalize_amount(project.get("hours")) + hours = recognized_total * raw_hours / raw_total if raw_total > 0 and recognized_total > 0 else raw_hours + add_project(project, detail.get("work_date"), hours, member_grade) + else: + add_project(detail, detail.get("work_date"), normalize_amount(detail.get("holiday_hours")), member_grade) + return hours_result, labor_result + + +def _cost_analysis_load_hanmac_hours_and_labor_yearly( + start_date: date, + end_date: date, + project_meta: dict[str, dict[str, Any]], + allowed_codes: set[str] | None = None, +) -> tuple[dict[int, dict[str, dict[str, float]]], dict[int, dict[str, dict[str, float]]]]: + hours_result: dict[int, dict[str, dict[str, float]]] = {} + labor_result: dict[int, dict[str, dict[str, float]]] = {} + + def merge( + hours_by_year: dict[int, dict[str, dict[str, float]]], + labor_by_year: dict[int, dict[str, dict[str, float]]], + ) -> None: + for year, code_map in hours_by_year.items(): + for code, phase_hours in code_map.items(): + target = hours_result.setdefault(year, {}).setdefault(code, {"pre": 0.0, "during": 0.0, "post": 0.0}) + for phase, hours in phase_hours.items(): + if phase in target: + target[phase] += normalize_amount(hours) + for year, code_map in labor_by_year.items(): + for code, phase_amounts in code_map.items(): + target = labor_result.setdefault(year, {}).setdefault(code, {"pre": 0.0, "during": 0.0, "post": 0.0}) + for phase, amount in phase_amounts.items(): + if phase in target: + target[phase] += normalize_amount(amount) + + prefix_metric = _cost_analysis_select_hanmac_prefix_metric(start_date, end_date, prefer_member_grade=True) + prefix_end = _parse_iso_date((prefix_metric or {}).get("end_date")) + if prefix_metric and prefix_end and prefix_end >= start_date: + merge(*_cost_analysis_load_hanmac_hours_and_labor_by_year( + start_date, + min(prefix_end, end_date), + project_meta, + allowed_codes, + )) + start_date = min(prefix_end, end_date) + timedelta(days=1) + if start_date > end_date: + return hours_result, labor_result + for year_slice in _iter_year_slices(start_date, end_date): + merge(*_cost_analysis_load_hanmac_hours_and_labor_by_year( + year_slice["start"], + year_slice["end"], + project_meta, + allowed_codes, + )) + return hours_result, labor_result + + def _cost_analysis_get_annual_hanmac_total_hours(year: int) -> float: year_start = date(year, 1, 1).isoformat() year_end = date(year, 12, 31).isoformat() @@ -12786,6 +13877,17 @@ def _cost_analysis_row_template(code: str, meta: dict[str, Any], selected_year: "contract_amount": contract_amount, "billing_amount": 0.0, "collection_amount": 0.0, + "period_billing_amount": 0.0, + "period_collection_amount": 0.0, + "period_revenue_amount": 0.0, + "period_cost_total": 0.0, + "period_sga_total": 0.0, + "period_sales_total": 0.0, + "period_total_cost": 0.0, + "period_profit_amount": 0.0, + "period_revenue_profit_rate": 0.0, + "period_collection_profit_rate": 0.0, + "cumulative_profit_rate": 0.0, "contract_balance_amount": contract_amount, "collection_rate": 0.0, "revenue_amount": 0.0, @@ -12822,6 +13924,12 @@ def _cost_analysis_finalize_row(row: dict[str, Any]) -> None: row["sales_total"] = sales_total row["total_cost"] = cost_total + sga_total + sales_total row["profit_amount"] = normalize_amount(row.get("revenue_amount")) - row["total_cost"] + row["period_revenue_amount"] = normalize_amount(row.get("period_revenue_amount")) + row["period_cost_total"] = cost_total + row["period_sga_total"] = sga_total + row["period_sales_total"] = sales_total + row["period_total_cost"] = row["period_cost_total"] + row["period_sga_total"] + row["period_sales_total"] + row["period_profit_amount"] = row["period_revenue_amount"] - row["period_total_cost"] row["contract_balance_amount"] = max( normalize_amount(row.get("contract_amount")) - normalize_amount(row.get("collection_amount")), 0.0, @@ -12830,6 +13938,12 @@ def _cost_analysis_finalize_row(row: dict[str, Any]) -> None: row["contract_profit_rate"] = _safe_ratio(row.get("profit_amount"), row.get("contract_amount")) row["revenue_profit_rate"] = _safe_ratio(row.get("profit_amount"), row.get("revenue_amount")) row["collection_profit_rate"] = _safe_ratio(row.get("profit_amount"), row.get("collection_amount")) + row["period_revenue_profit_rate"] = _safe_ratio(row.get("period_profit_amount"), row.get("period_revenue_amount")) + row["period_collection_profit_rate"] = _safe_ratio(row.get("period_profit_amount"), row.get("period_collection_amount")) + row["cumulative_profit_rate"] = _safe_ratio( + normalize_amount(row.get("collection_amount")) - row["total_cost"], + row.get("collection_amount"), + ) def _cost_analysis_active_in_year(meta: dict[str, Any], year: int) -> bool: @@ -12902,9 +14016,62 @@ def _cost_analysis_get_link_representative_map() -> dict[str, str]: representative = total_codes[0] for code in component: representative_map[code] = representative + representative_map.update(_cost_analysis_infer_yz_link_representatives(representative_map)) return representative_map +def _cost_analysis_infer_yz_link_representatives(existing_representative_map: dict[str, str]) -> dict[str, str]: + project_meta = _cost_analysis_get_project_meta() + representative_titles: dict[str, set[str]] = {} + for code, representative in existing_representative_map.items(): + normalized_code = normalize_text(code).upper() + normalized_representative = normalize_text(representative).upper() + if not normalized_code or not normalized_representative or normalized_code == normalized_representative: + continue + title = normalize_project_title_for_linking((project_meta.get(normalized_code) or {}).get("support_dept_name")) + if title: + representative_titles.setdefault(normalized_representative, set()).add(title) + for representative, meta in project_meta.items(): + normalized_representative = normalize_text(representative).upper() + if normalized_representative[:1] not in {"0", "9"}: + continue + title = normalize_project_title_for_linking(meta.get("support_dept_name")) + if title: + representative_titles.setdefault(normalized_representative, set()).add(title) + + inferred: dict[str, str] = {} + for code, meta in project_meta.items(): + normalized_code = normalize_text(code).upper() + if ( + not normalized_code + or normalized_code in existing_representative_map + or normalized_code[:1] not in {"Y", "Z"} + ): + continue + title = normalize_project_title_for_linking(meta.get("support_dept_name")) + if not title: + continue + matches: list[tuple[int, str]] = [] + for representative, titles in representative_titles.items(): + best_score = 0 + for candidate_title in titles: + if not candidate_title: + continue + if title == candidate_title: + best_score = max(best_score, 100) + elif len(title) >= 8 and len(candidate_title) >= 8 and (title in candidate_title or candidate_title in title): + best_score = max(best_score, min(len(title), len(candidate_title))) + if best_score >= 8: + matches.append((best_score, representative)) + if not matches: + continue + matches.sort(reverse=True) + if len(matches) > 1 and matches[0][0] == matches[1][0]: + continue + inferred[normalized_code] = matches[0][1] + return inferred + + def _cost_analysis_latest_row(rows: list[dict[str, Any]]) -> dict[str, Any]: def sort_key(row: dict[str, Any]) -> tuple[str, str]: code = normalize_text(row.get("support_dept_code")).upper() @@ -12992,6 +14159,9 @@ def _cost_analysis_aggregate_rows( aggregate["contract_amount"] = sum(normalize_amount(row.get("contract_amount")) for row in group_rows) aggregate["billing_amount"] = sum(normalize_amount(row.get("billing_amount")) for row in group_rows) aggregate["collection_amount"] = sum(normalize_amount(row.get("collection_amount")) for row in group_rows) + aggregate["period_billing_amount"] = sum(normalize_amount(row.get("period_billing_amount")) for row in group_rows) + aggregate["period_collection_amount"] = sum(normalize_amount(row.get("period_collection_amount")) for row in group_rows) + aggregate["period_revenue_amount"] = sum(normalize_amount(row.get("period_revenue_amount") or row.get("revenue_amount")) for row in group_rows) aggregate["contract_balance_amount"] = 0.0 aggregate["revenue_amount"] = sum(normalize_amount(row.get("revenue_amount")) for row in group_rows) aggregate["phases"] = _cost_analysis_empty_phase_totals() @@ -13009,6 +14179,11 @@ def _cost_analysis_aggregate_rows( for item_key, amount in buckets.items(): if item_key in aggregate["allocated"][phase]: aggregate["allocated"][phase][item_key] += normalize_amount(amount) + aggregate["period_cost_total"] = sum(normalize_amount(row.get("period_cost_total") or row.get("cost_total")) for row in group_rows) + aggregate["period_sga_total"] = sum(normalize_amount(row.get("period_sga_total") or row.get("sga_total")) for row in group_rows) + aggregate["period_sales_total"] = sum(normalize_amount(row.get("period_sales_total") or row.get("sales_total")) for row in group_rows) + aggregate["period_total_cost"] = sum(normalize_amount(row.get("period_total_cost") or row.get("total_cost")) for row in group_rows) + aggregate["period_profit_amount"] = aggregate["period_revenue_amount"] - aggregate["period_total_cost"] _cost_analysis_finalize_row(aggregate) result.append(aggregate) return result @@ -13100,8 +14275,17 @@ def _cost_analysis_build_payload(start_date_text: str, end_date_text: str, mode: continue visible_candidate_codes.add(x_owner_map.get(source_code, xyz_code_map.get(source_code, source_code))) - period_hanmac_hours = _cost_analysis_load_hanmac_project_hours(start_date, end_date, project_meta) - visible_candidate_codes.update(period_hanmac_hours.keys()) + annual_hanmac_project_hours_by_year, annual_hanmac_labor_by_year = _cost_analysis_load_hanmac_hours_and_labor_yearly( + start_date, + end_date, + project_meta, + None, + ) + visible_candidate_codes.update( + code + for code_map in annual_hanmac_project_hours_by_year.values() + for code in code_map + ) source_candidate_codes: set[str] = set(visible_candidate_codes) for target_code in list(visible_candidate_codes): @@ -13113,12 +14297,15 @@ def _cost_analysis_build_payload(start_date_text: str, end_date_text: str, mode: if target_code in visible_candidate_codes: source_candidate_codes.add(source_code) - hanmac_labor_map = _cost_analysis_load_hanmac_labor_map_yearly( - start_date, - end_date, - project_meta, - visible_candidate_codes or None, - ) + hanmac_labor_map: dict[str, dict[str, float]] = {} + for code_map in annual_hanmac_labor_by_year.values(): + for code, phase_amounts in code_map.items(): + if visible_candidate_codes and code not in visible_candidate_codes: + continue + target = hanmac_labor_map.setdefault(code, {"pre": 0.0, "during": 0.0, "post": 0.0}) + for phase, amount in phase_amounts.items(): + if phase in target: + target[phase] += normalize_amount(amount) def ensure_row(code: str) -> dict[str, Any]: normalized_code = normalize_text(code).upper() @@ -13274,6 +14461,7 @@ def _cost_analysis_build_payload(start_date_text: str, end_date_text: str, mode: amount = normalize_amount(row.get("amount")) if bucket == "revenue": report_row["revenue_amount"] += amount + report_row["period_revenue_amount"] += amount continue if bucket not in {"cost", "sga"}: continue @@ -13302,6 +14490,7 @@ def _cost_analysis_build_payload(start_date_text: str, end_date_text: str, mode: if code != target_code and code not in report_row["direct_codes"]: report_row["direct_codes"].append(code) report_row["collection_amount"] += normalize_amount(collection_row.get("collection_amount")) + report_row["period_collection_amount"] += normalize_amount(collection_row.get("period_collection_amount")) if normalize_amount(collection_row.get("period_collection_amount")): visible_activity_codes.add(target_code) @@ -13318,6 +14507,7 @@ def _cost_analysis_build_payload(start_date_text: str, end_date_text: str, mode: if code != target_code and code not in report_row["direct_codes"]: report_row["direct_codes"].append(code) report_row["collection_amount"] += amount + report_row["period_collection_amount"] += normalize_amount(collection_row.get("period_collection_amount")) if normalize_amount(collection_row.get("period_collection_amount")): visible_activity_codes.add(target_code) @@ -13333,30 +14523,11 @@ def _cost_analysis_build_payload(start_date_text: str, end_date_text: str, mode: if code != target_code and code not in report_row["direct_codes"]: report_row["direct_codes"].append(code) report_row["billing_amount"] += amount + report_row["period_billing_amount"] += normalize_amount(billing_row.get("period_billing_amount")) if normalize_amount(billing_row.get("period_billing_amount")): visible_activity_codes.add(target_code) annual_sga_totals = {int(row["posting_year"]): normalize_amount(row.get("amount")) for row in annual_sga_rows if row.get("posting_year")} - annual_hanmac_project_hours_by_year: dict[int, dict[str, dict[str, float]]] = {} - annual_prefix_metric = _cost_analysis_select_hanmac_prefix_metric(start_date, end_date) - annual_prefix_end = _parse_iso_date((annual_prefix_metric or {}).get("end_date")) - annual_loop_start = start_date - if annual_prefix_metric and annual_prefix_end and annual_prefix_end >= start_date: - annual_hanmac_project_hours_by_year.update(_cost_analysis_load_hanmac_project_hours_by_year( - start_date, - min(annual_prefix_end, end_date), - project_meta, - visible_candidate_codes or None, - )) - annual_loop_start = min(annual_prefix_end, end_date) + timedelta(days=1) - if annual_loop_start <= end_date: - for year_slice in _iter_year_slices(annual_loop_start, end_date): - annual_hanmac_project_hours_by_year.update(_cost_analysis_load_hanmac_project_hours_by_year( - year_slice["start"], - year_slice["end"], - project_meta, - visible_candidate_codes or None, - )) for year_slice in _iter_year_slices(start_date, end_date): year = int(year_slice["year"]) annual_sga_total = annual_sga_totals.get(year, 0.0) @@ -13420,6 +14591,14 @@ def _cost_analysis_build_payload(start_date_text: str, end_date_text: str, mode: "contract_amount": sum(normalize_amount(row.get("contract_amount")) for row in final_rows), "billing_amount": sum(normalize_amount(row.get("billing_amount")) for row in final_rows), "collection_amount": sum(normalize_amount(row.get("collection_amount")) for row in final_rows), + "period_billing_amount": sum(normalize_amount(row.get("period_billing_amount")) for row in final_rows), + "period_collection_amount": sum(normalize_amount(row.get("period_collection_amount")) for row in final_rows), + "period_revenue_amount": sum(normalize_amount(row.get("period_revenue_amount") or row.get("revenue_amount")) for row in final_rows), + "period_cost_total": sum(normalize_amount(row.get("period_cost_total") or row.get("cost_total")) for row in final_rows), + "period_sga_total": sum(normalize_amount(row.get("period_sga_total") or row.get("sga_total")) for row in final_rows), + "period_sales_total": sum(normalize_amount(row.get("period_sales_total") or row.get("sales_total")) for row in final_rows), + "period_total_cost": sum(normalize_amount(row.get("period_total_cost") or row.get("total_cost")) for row in final_rows), + "period_profit_amount": sum(normalize_amount(row.get("period_profit_amount") or row.get("profit_amount")) for row in final_rows), "contract_balance_amount": sum(normalize_amount(row.get("contract_balance_amount")) for row in final_rows), "revenue_amount": sum(normalize_amount(row.get("revenue_amount")) for row in final_rows), "cost_total": sum(normalize_amount(row.get("cost_total")) for row in final_rows), @@ -13433,6 +14612,9 @@ def _cost_analysis_build_payload(start_date_text: str, end_date_text: str, mode: summary["contract_profit_rate"] = _safe_ratio(summary["profit_amount"], summary["contract_amount"]) summary["revenue_profit_rate"] = _safe_ratio(summary["profit_amount"], summary["revenue_amount"]) summary["collection_profit_rate"] = _safe_ratio(summary["profit_amount"], summary["collection_amount"]) + summary["period_revenue_profit_rate"] = _safe_ratio(summary["period_profit_amount"], summary["period_revenue_amount"]) + summary["period_collection_profit_rate"] = _safe_ratio(summary["period_profit_amount"], summary["period_collection_amount"]) + summary["cumulative_profit_rate"] = _safe_ratio(summary["collection_amount"] - summary["total_cost"], summary["collection_amount"]) payload = { "start_date": start_date.isoformat(), "end_date": end_date.isoformat(), @@ -13537,6 +14719,42 @@ def render_wehago_compare_page( return templates.TemplateResponse(request, "wehago_compare.html", context) +def render_wehago_benefit_entertainment_page( + request: Request, + start_year: int = WEHAGO_BENEFIT_DEFAULT_START_YEAR, + end_year: int = WEHAGO_BENEFIT_DEFAULT_END_YEAR, + account_group: str = "all", + category: str = "all", + person_keyword: str = "", + desc_keyword: str = "", + include_adjustments: bool = False, + message: str = "", +) -> HTMLResponse: + init_db() + report = get_wehago_benefit_entertainment_report( + start_year=start_year, + end_year=end_year, + account_group=account_group, + category=category, + person_keyword=person_keyword, + desc_keyword=desc_keyword, + include_adjustments=include_adjustments, + limit=300, + ) + context = { + **base_context(request, message), + "benefit_report": report, + "benefit_report_json": jsonable_encoder( + { + "detail_rows": report.get("all_detail_rows", []), + "vendor_summary_rows": report.get("vendor_summary_rows", []), + "category_options": report.get("category_options", []), + } + ), + } + return templates.TemplateResponse(request, "wehago_benefit_entertainment.html", context) + + def render_hanmac_browser_page( request: Request, message: str = "", @@ -19672,6 +20890,86 @@ async def wehago_compare(request: Request, start_year: str | None = None, end_ye return HTMLResponse("로그를 확인해주세요.
", status_code=500) +@app.get("/wehago-benefit-entertainment") +async def wehago_benefit_entertainment( + request: Request, + start_year: str | None = None, + end_year: str | None = None, + account_group: str = "all", + category: str = "all", + person_keyword: str = "", + desc_keyword: str = "", + include_adjustments: str | None = None, +): + try: + return await run_in_threadpool( + render_wehago_benefit_entertainment_page, + request, + start_year=_normalize_report_year(start_year, WEHAGO_BENEFIT_DEFAULT_START_YEAR), + end_year=_normalize_report_year(end_year, WEHAGO_BENEFIT_DEFAULT_END_YEAR), + account_group=account_group, + category=category, + person_keyword=person_keyword, + desc_keyword=desc_keyword, + include_adjustments=_normalize_report_bool(include_adjustments), + ) + except Exception as exc: + logger.exception("복리/접대비 보고서 페이지 에러: %s", exc) + return HTMLResponse("로그를 확인해주세요.
", status_code=500) + + +@app.get("/wehago-benefit-entertainment/export") +async def wehago_benefit_entertainment_export( + start_year: str | None = None, + end_year: str | None = None, + account_group: str = "all", + category: str = "all", + person_keyword: str = "", + desc_keyword: str = "", + include_adjustments: str | None = None, +): + try: + report = await run_in_threadpool( + get_wehago_benefit_entertainment_report, + start_year=_normalize_report_year(start_year, WEHAGO_BENEFIT_DEFAULT_START_YEAR), + end_year=_normalize_report_year(end_year, WEHAGO_BENEFIT_DEFAULT_END_YEAR), + account_group=account_group, + category=category, + person_keyword=person_keyword, + desc_keyword=desc_keyword, + include_adjustments=_normalize_report_bool(include_adjustments), + limit=None, + ) + file_name, content = await run_in_threadpool(export_wehago_benefit_entertainment_xlsx, report) + return Response( + content=content, + media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + headers={"Content-Disposition": f'attachment; filename="{file_name}"'}, + ) + except Exception as exc: + logger.exception("복리/접대비 엑셀 다운로드 에러: %s", exc) + return JSONResponse(content={"error": str(exc)}, status_code=500) + + +@app.post("/wehago-benefit-entertainment/api/category") +async def wehago_benefit_entertainment_save_category(request: Request): + try: + payload = await request.json() + if not isinstance(payload, dict): + payload = {} + result = await run_in_threadpool( + save_wehago_benefit_category_override, + payload.get("ledger_row_id"), + payload.get("category"), + ) + return JSONResponse(content=jsonable_encoder(result)) + except ValueError as exc: + return JSONResponse(content={"ok": False, "error": str(exc)}, status_code=400) + except Exception as exc: + logger.exception("복리/접대비 분류 저장 에러: %s", exc) + return JSONResponse(content={"ok": False, "error": str(exc)}, status_code=500) + + @app.post("/wehago-compare/upload-erp") async def upload_wehago_erp_file( request: Request, diff --git a/scripts/prune_wehago_projection_history.py b/scripts/prune_wehago_projection_history.py new file mode 100644 index 0000000..08997f5 --- /dev/null +++ b/scripts/prune_wehago_projection_history.py @@ -0,0 +1,120 @@ +from __future__ import annotations + +import argparse +import json +import sqlite3 +from pathlib import Path +import sys + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + +from runtime_config import DB_PATH + + +DERIVED_SIGNATURE_TABLES = ( + ("wehago_compare_query_groups", "signature"), + ("wehago_compare_query_rows", "signature"), + ("wehago_compare_query_metrics", "signature"), + ("wehago_compare_query_page_cache", "signature"), + ("wehago_compare_final_status_projection", "signature"), + ("wehago_metric_count_cache", "signature"), + ("wehago_summary_range_cache", "signature"), +) + + +def active_signature(conn: sqlite3.Connection, start_year: int, end_year: int) -> str: + row = conn.execute( + """ + SELECT setting_json + FROM wehago_compare_settings + WHERE setting_key = ? + LIMIT 1 + """, + (f"wehago_active_query_projection:{start_year}:{end_year}",), + ).fetchone() + if not row: + return "" + try: + payload = json.loads(row[0] or "{}") + except Exception: + return "" + return str(payload.get("signature") or "").strip() if isinstance(payload, dict) else "" + + +def table_exists(conn: sqlite3.Connection, table_name: str) -> bool: + return bool( + conn.execute( + "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ? LIMIT 1", + (table_name,), + ).fetchone() + ) + + +def main() -> None: + parser = argparse.ArgumentParser(description="Prune obsolete WEHAGO derived projection records.") + parser.add_argument("--start-year", type=int, default=2025) + parser.add_argument("--end-year", type=int, default=2025) + parser.add_argument("--execute", action="store_true", help="Actually delete rows. Without this flag, only prints counts.") + args = parser.parse_args() + + conn = sqlite3.connect(DB_PATH) + signature = active_signature(conn, args.start_year, args.end_year) + if not signature: + raise SystemExit("No active projection signature was found. Refusing to prune.") + + results: list[dict[str, object]] = [] + conn.execute("BEGIN") + try: + for table_name, signature_column in DERIVED_SIGNATURE_TABLES: + if not table_exists(conn, table_name): + continue + count = int( + conn.execute( + f""" + SELECT COUNT(*) + FROM {table_name} + WHERE start_year = ? AND end_year = ? + AND {signature_column} <> ? + """, + (args.start_year, args.end_year, signature), + ).fetchone()[0] + or 0 + ) + results.append({"table": table_name, "obsolete_rows": count}) + if args.execute and count: + conn.execute( + f""" + DELETE FROM {table_name} + WHERE start_year = ? AND end_year = ? + AND {signature_column} <> ? + """, + (args.start_year, args.end_year, signature), + ) + if args.execute: + conn.commit() + else: + conn.rollback() + except Exception: + conn.rollback() + raise + finally: + conn.close() + + print( + json.dumps( + { + "start_year": args.start_year, + "end_year": args.end_year, + "active_signature": signature, + "execute": bool(args.execute), + "tables": results, + "obsolete_total": sum(int(row["obsolete_rows"]) for row in results), + }, + ensure_ascii=False, + indent=2, + ) + ) + + +if __name__ == "__main__": + main() diff --git a/scripts/rebuild_compare_ranges.py b/scripts/rebuild_compare_ranges.py index b2dba5b..6a892f8 100644 --- a/scripts/rebuild_compare_ranges.py +++ b/scripts/rebuild_compare_ranges.py @@ -11,6 +11,7 @@ sys.path.insert(0, str(Path(__file__).resolve().parents[1])) from main import engine from wehago_compare import ( _build_db_state_signature, + _ensure_year_export_row_cache, _get_fast_year_export_row_cache_signature, _load_snapshot_status_map, _project_year_export_row_cache_from_latest_resolved, @@ -52,7 +53,18 @@ def main() -> None: projected = _project_year_export_row_cache_from_latest_resolved(conn, year, signature) if not projected: print({"step": "fast_year_projection_miss", "year": year}, flush=True) - _refresh_year_resolved_sections(conn, year) + selected_signature = _ensure_year_export_row_cache(conn, year) + else: + selected_signature = _get_fast_year_export_row_cache_signature(conn, year) + if selected_signature: + _upsert_snapshot_status( + conn, + year, + signature=selected_signature, + state="ready", + row_counts={}, + built_now=True, + ) else: _upsert_snapshot_status( conn, @@ -62,7 +74,6 @@ def main() -> None: row_counts={}, built_now=True, ) - selected_signature = _get_fast_year_export_row_cache_signature(conn, year) print( { "step": "fast_year_projection_done", diff --git a/scripts/reconcile_wehago_projection_to_db.py b/scripts/reconcile_wehago_projection_to_db.py index 4333b8f..eee3a15 100644 --- a/scripts/reconcile_wehago_projection_to_db.py +++ b/scripts/reconcile_wehago_projection_to_db.py @@ -16,13 +16,19 @@ sys.path.insert(0, str(Path(__file__).resolve().parents[1])) from runtime_config import DB_PATH from wehago_compare import ( QUERY_PROJECTION_VERSION, + _account_nature_signature, _account_category_pair_allowed, _classify_account_category, _classify_account_family, _is_wehago_excepted_voucher_group, _is_vat_family, + _group_has_offset_tax_invoice_structure, + _group_has_tax_invoice_cancel_signal, + _offset_group_vector, _move_wehago_confirmed_reversal_pairs_to_excepted, - _move_wehago_offset_tax_invoice_groups_to_excepted, + _voucher_group_has_review_reason, + _voucher_group_month_days, + _voucher_groups_within_days, _nature_compatible, build_voucher_row_key, clean, @@ -30,11 +36,27 @@ from wehago_compare import ( YEAR = 2025 +RECONCILED_PROJECTION_VERSION = "db-reconciled-v2" WEHAGO_STATUSES = ("voucher_matched", "voucher_unmatched", "voucher_recheck", "voucher_excepted") ERP_STATUSES = ("erp_voucher_matched", "erp_voucher_unmatched") ALL_VOUCHER_STATUSES = WEHAGO_STATUSES + ERP_STATUSES RAW_ERP_ROWS_BY_DRAFT_BASE: dict[str, list[dict[str, Any]]] = defaultdict(list) MANUAL_OFFSET_EXCEPTED_IDENTITIES: set[str] = set() +EXCEPTED_REASON_TOKENS = ( + "WEHAGO_EXCEPTED_OFFSET_REVERSAL_PAIR", + "WEHAGO_EXCEPTED_CONFIRMED_REVERSAL_PAIR", + "WEHAGO_EXCEPTED_OFFSET_ENTRY", + "WEHAGO_EXCEPTED_SUBSTITUTION_ENTRY", + "WEHAGO_EXCEPTED_AUDIT_ADJUSTMENT", + "WEHAGO_EXCEPTED_CLOSING_REVERSAL", + "WEHAGO_EXCEPTED_OPENING_BALANCE", + "WEHAGO_EXCEPTED_YEAR_OPENING_SUBSTITUTION", + "WEHAGO_EXCEPTED_EXACT_REVERSAL_PAIR", + "WEHAGO_EXCEPTED_MANAGEMENT_ITEM_REVERSAL_PAIR", + "WEHAGO_EXCEPTED_CANCEL_REISSUE_CANCEL", + "WEHAGO_EXCEPTED_CANCEL_REISSUE_SUPERSEDED", + "MANUAL_OFFSET_PAIR_EXCEPTED", +) GROUP_COLUMNS = ( "start_year", @@ -140,6 +162,10 @@ def dict_row(row: sqlite3.Row) -> dict[str, Any]: return {key: row[key] for key in row.keys()} +def log_step(message: str) -> None: + print(f"[reconcile] {datetime.now().isoformat(timespec='seconds')} {message}", flush=True) + + def latest_projection_signature(cur: sqlite3.Cursor) -> str: row = cur.execute( """ @@ -154,7 +180,7 @@ def latest_projection_signature(cur: sqlite3.Cursor) -> str: try: payload = json.loads(row[0] or "{}") signature = clean(payload.get("signature")) - if signature and "|db-reconciled-v1|" not in signature: + if signature and "|db-reconciled-" not in signature: exists = cur.execute( """ SELECT 1 @@ -173,7 +199,7 @@ def latest_projection_signature(cur: sqlite3.Cursor) -> str: SELECT signature, MAX(updated_at) AS max_updated_at FROM wehago_compare_query_groups WHERE start_year = ? AND end_year = ? AND signature LIKE ? - AND signature NOT LIKE '%|db-reconciled-v1|%' + AND signature NOT LIKE '%|db-reconciled-%' GROUP BY signature ORDER BY max_updated_at DESC LIMIT 1 @@ -186,13 +212,41 @@ def latest_projection_signature(cur: sqlite3.Cursor) -> str: SELECT signature, MAX(updated_at) AS max_updated_at FROM wehago_compare_query_groups WHERE start_year = ? AND end_year = ? - AND signature NOT LIKE '%|db-reconciled-v1|%' + AND signature NOT LIKE '%|db-reconciled-%' GROUP BY signature ORDER BY max_updated_at DESC LIMIT 1 """, (YEAR, YEAR), ).fetchone() + if not row: + settings_row = cur.execute( + """ + SELECT setting_json + FROM wehago_compare_settings + WHERE setting_key = ? + LIMIT 1 + """, + (f"wehago_active_query_projection:{YEAR}:{YEAR}",), + ).fetchone() + if settings_row: + try: + payload = json.loads(settings_row[0] or "{}") + signature = clean(payload.get("signature")) if isinstance(payload, dict) else "" + if signature: + exists = cur.execute( + """ + SELECT 1 + FROM wehago_compare_query_groups + WHERE start_year = ? AND end_year = ? AND signature = ? + LIMIT 1 + """, + (YEAR, YEAR, signature), + ).fetchone() + if exists: + return signature + except Exception: + pass if not row: raise RuntimeError("No current query projection was found.") return clean(row["signature"]) @@ -439,6 +493,112 @@ def direct_row_matches_account_nature(row: dict[str, Any]) -> bool: ) +def compact_account_name(value: Any) -> str: + return normalized_compact(re.sub(r"^\d+\s*", "", clean(value))) + + +def row_account_category(row: dict[str, Any], prefix: str) -> str: + account_name = clean(row.get(f"{prefix}_account_name")) + compact = compact_account_name(account_name) + if any(marker in compact for marker in ("세금과공과", "퇴직금", "퇴직급여", "임금", "제수당", "보험료", "보증수수료")): + return "expense" + return _classify_account_category("", account_name) + + +def row_account_family(row: dict[str, Any], prefix: str) -> str: + return _classify_account_family("", row.get(f"{prefix}_account_name")) + + +def is_business_category(category: str) -> bool: + return category in {"expense", "revenue"} + + +def is_vat_row(row: dict[str, Any], prefix: str) -> bool: + return _is_vat_family(row_account_family(row, prefix)) + + +def is_business_row(row: dict[str, Any], prefix: str) -> bool: + family = row_account_family(row, prefix) + if _is_vat_family(family) or family in {"bank", "payable", "receivable", "advance"}: + return False + return is_business_category(row_account_category(row, prefix)) + + +def is_settlement_row(row: dict[str, Any], prefix: str) -> bool: + family = row_account_family(row, prefix) + category = row_account_category(row, prefix) + return family in {"bank", "payable", "receivable", "advance"} or category in {"asset", "liability"} + + +def row_principle_signature(row: dict[str, Any], prefix: str) -> str: + return _account_nature_signature("", row.get(f"{prefix}_account_name"), effective_account_side(row, prefix)) + + +def row_business_match_key(row: dict[str, Any], prefix: str) -> tuple[str, str, str, str]: + return ( + row_account_category(row, prefix), + compact_account_name(row.get(f"{prefix}_account_name")), + amount_key(row_side_amount(row, prefix)), + normalized_compact(row.get(f"{prefix}_desc")), + ) + + +def row_principle_pair_allowed(ledger_row: dict[str, Any], voucher_row: dict[str, Any]) -> bool: + ledger_category = row_account_category(ledger_row, "ledger") + voucher_category = row_account_category(voucher_row, "voucher") + ledger_family = row_account_family(ledger_row, "ledger") + voucher_family = row_account_family(voucher_row, "voucher") + if _is_vat_family(ledger_family) or _is_vat_family(voucher_family): + return _is_vat_family(ledger_family) and _is_vat_family(voucher_family) + if ledger_category != voucher_category or ledger_category not in {"expense", "revenue"}: + return False + ledger_account = compact_account_name(ledger_row.get("ledger_account_name")) + voucher_account = compact_account_name(voucher_row.get("voucher_account_name")) + if ( + ledger_category == "expense" + and effective_account_side(ledger_row, "ledger") == effective_account_side(voucher_row, "voucher") + and ( + ("보증수수료" in ledger_account and ("보험료" in voucher_account or "보증보험" in voucher_account)) + or ("보증수수료" in voucher_account and ("보험료" in ledger_account or "보증보험" in ledger_account)) + ) + ): + return True + return _nature_compatible( + "", + ledger_row.get("ledger_account_name"), + effective_account_side(ledger_row, "ledger"), + "", + voucher_row.get("voucher_account_name"), + effective_account_side(voucher_row, "voucher"), + ) + + +def group_has_wehago_rows(group: dict[str, Any]) -> bool: + return any(has_wehago_value(row) for row in group.get("rows") or []) + + +def group_has_real_erp_candidate(group: dict[str, Any]) -> bool: + return any(has_erp_value(row) for row in group.get("rows") or []) + + +def group_has_unmatched_wehago_rows(group: dict[str, Any]) -> bool: + return any(has_wehago_value(row) and not has_erp_value(row) for row in group.get("rows") or []) + + +def group_review_text(group: dict[str, Any]) -> str: + return " ".join( + [ + clean((group.get("summary") or {}).get("review_reason")), + *(clean(row.get("review_reason")) for row in group.get("rows") or []), + ] + ) + + +def group_has_excepted_reason(group: dict[str, Any]) -> bool: + review_text = group_review_text(group) + return any(token in review_text for token in EXCEPTED_REASON_TOKENS) + + def row_side_amount(row: dict[str, Any], prefix: str) -> float: return max( abs(parse_amount(row.get(f"{prefix}_debit"))), @@ -465,6 +625,25 @@ def raw_erp_entry_amount_side(entry: dict[str, Any], category: str) -> tuple[flo return 0.0, "either" +def raw_erp_entry_signed_amount_side(entry: dict[str, Any], category: str) -> tuple[float, str]: + fields = ( + ("debit_supply", "debit"), + ("credit_supply", "credit"), + ) + if category in {"asset", "liability"}: + fields = ( + ("debit_supply", "debit"), + ("credit_supply", "credit"), + ("debit_tax", "debit"), + ("credit_tax", "credit"), + ) + for field, side in fields: + amount = parse_amount(entry.get(field)) + if abs(amount) >= 0.5: + return amount, side + return 0.0, "either" + + def raw_erp_entry_to_row(entry: dict[str, Any], ledger_row: dict[str, Any], side: str, reason: str) -> dict[str, Any]: amount, _entry_side = raw_erp_entry_amount_side( entry, @@ -501,6 +680,167 @@ def raw_erp_entry_to_row(entry: dict[str, Any], ledger_row: dict[str, Any], side return row +def normalized_compact(value: Any) -> str: + return re.sub(r"\s+", "", clean(value)).lower() + + +def row_draft_bases(rows: list[dict[str, Any]], summary: dict[str, Any]) -> set[str]: + bases: set[str] = set() + for value in [summary.get("draft_no"), *(row.get("draft_no") for row in rows)]: + for part in re.split(r"[,/]\s*", clean(value)): + base = erp_voucher_base(part) + if base: + bases.add(base) + return bases + + +def dates_match_wehago(proof_date: Any, ledger_date: Any, fiscal_year: int) -> bool: + proof = clean(proof_date) + ledger = clean(ledger_date) + if not proof or not ledger: + return False + if re.fullmatch(r"\d{4}-\d{2}-\d{2}", proof): + proof_key = proof + else: + matched = re.search(r"(20\d{2})[-./]?(\d{1,2})[-./]?(\d{1,2})", proof) + if not matched: + return False + proof_key = f"{int(matched.group(1)):04d}-{int(matched.group(2)):02d}-{int(matched.group(3)):02d}" + if re.fullmatch(r"\d{1,2}[-./]\d{1,2}", ledger): + month, day = re.split(r"[-./]", ledger) + ledger_key = f"{int(fiscal_year):04d}-{int(month):02d}-{int(day):02d}" + elif re.fullmatch(r"\d{4}-\d{2}-\d{2}", ledger): + ledger_key = ledger + else: + return False + return proof_key == ledger_key + + +def revenue_entry_semantically_matches(row: dict[str, Any], entry: dict[str, Any]) -> bool: + ledger_account = normalized_compact(row.get("ledger_account_name")) + ledger_desc = normalized_compact(row.get("ledger_desc")) + entry_text = normalized_compact(" ".join([clean(entry.get("account_name")), clean(entry.get("desc1")), clean(entry.get("desc2"))])) + if "주차" in ledger_account: + return "주차" in entry_text + if "임대" in ledger_account: + return ("임대" in entry_text or "관리비" in entry_text) and "주차" not in entry_text + if "관리" in ledger_account: + return "관리" in entry_text + ledger_tokens = {token for token in re.split(r"[^0-9a-z가-힣]+", ledger_account + " " + ledger_desc) if len(token) >= 2} + entry_tokens = {token for token in re.split(r"[^0-9a-z가-힣]+", entry_text) if len(token) >= 2} + return bool(ledger_tokens & entry_tokens) + + +def find_amount_matching_subset( + entries: list[dict[str, Any]], + target_amount: float, + category: str, +) -> tuple[list[dict[str, Any]], str] | None: + candidates: list[tuple[dict[str, Any], float, str]] = [] + for entry in entries[:12]: + amount, side = raw_erp_entry_amount_side(entry, category) + if amount > 0: + candidates.append((entry, amount, side)) + best: tuple[list[dict[str, Any]], str] | None = None + for mask in range(1, 1 << len(candidates)): + selected: list[dict[str, Any]] = [] + total = 0.0 + side = "" + for index, (entry, amount, entry_side) in enumerate(candidates): + if not (mask & (1 << index)): + continue + if side and entry_side != side: + selected = [] + break + side = entry_side + selected.append(entry) + total += amount + if not selected or len(selected) < 2: + continue + if abs(total - target_amount) < 0.5: + if best is None or len(selected) < len(best[0]): + best = (selected, side) + return best + + +def retarget_to_same_draft_split_revenue( + row: dict[str, Any], + group_rows: list[dict[str, Any]], + summary: dict[str, Any], +) -> dict[str, Any] | None: + if not has_wehago_value(row) or has_erp_value(row): + return None + ledger_category = _classify_account_category("", row.get("ledger_account_name")) + if ledger_category != "revenue": + return None + ledger_side = effective_account_side(row, "ledger") + ledger_amount = row_side_amount(row, "ledger") + if ledger_side not in {"debit", "credit"} or ledger_amount <= 0: + return None + fiscal_year = int(row.get("fiscal_year") or summary.get("fiscal_year") or YEAR) + bases = row_draft_bases(group_rows, summary) + for base in sorted(bases): + entries = [ + entry + for entry in RAW_ERP_ROWS_BY_DRAFT_BASE.get(base, []) + if _classify_account_category(entry.get("account_code"), entry.get("account_name")) == "revenue" + and dates_match_wehago(entry.get("proof_date"), row.get("ledger_date") or summary.get("ledger_date"), fiscal_year) + and revenue_entry_semantically_matches(row, entry) + and _nature_compatible("", row.get("ledger_account_name"), ledger_side, entry.get("account_code"), entry.get("account_name"), ledger_side) + and ( + not clean(row.get("ledger_vendor")) + or not clean(entry.get("vendor_name")) + or normalized_compact(row.get("ledger_vendor")) in normalized_compact(entry.get("vendor_name")) + or normalized_compact(entry.get("vendor_name")) in normalized_compact(row.get("ledger_vendor")) + ) + ] + matched = find_amount_matching_subset(entries, ledger_amount, "revenue") + if matched is None: + continue + selected, side = matched + if side != ledger_side: + continue + payload = dict(row) + payload["proof_date"] = clean(selected[0].get("proof_date")) + payload["draft_no"] = ", ".join(clean(entry.get("draft_no")) or clean(entry.get("confirmed_no")) for entry in selected if clean(entry.get("draft_no")) or clean(entry.get("confirmed_no"))) + payload["voucher_account_name"] = ", ".join(clean(entry.get("account_name")) for entry in selected if clean(entry.get("account_name"))) + payload["voucher_vendor"] = clean(selected[0].get("vendor_name")) + payload["voucher_debit"] = ledger_amount if side == "debit" else 0 + payload["voucher_credit"] = ledger_amount if side == "credit" else 0 + payload["voucher_desc"] = " / ".join( + clean(" ".join(part for part in (entry.get("desc1"), entry.get("desc2")) if clean(part))) + for entry in selected + if clean(entry.get("desc1")) or clean(entry.get("desc2")) + ) + payload["status_label"] = "Matched" + payload["review_reason"] = "PROJECTION_RECONCILE_SPLIT_REVENUE_SAME_DRAFT_PROOF_DATE" + payload["voucher_row_key"] = build_voucher_row_key(payload) + payload["match_identity_key"] = "|".join( + clean(part) + for part in ( + payload.get("fiscal_year"), + payload.get("voucher_no"), + payload.get("ledger_row_key"), + payload.get("draft_no"), + payload.get("voucher_row_key"), + ) + if clean(part) + ) + return payload + return None + + +def apply_same_draft_split_revenue_matches( + rows: list[dict[str, Any]], + summary: dict[str, Any], +) -> list[dict[str, Any]]: + result: list[dict[str, Any]] = [] + for row in rows: + retargeted = retarget_to_same_draft_split_revenue(row, rows, summary) + result.append(retargeted if retargeted is not None else row) + return result + + def retarget_to_same_draft_business_account(row: dict[str, Any]) -> dict[str, Any] | None: if not (has_wehago_value(row) and clean(row.get("draft_no"))): return None @@ -619,6 +959,7 @@ def allowed_erp_drafts_for_group(rows: list[dict[str, Any]]) -> set[str]: def clean_group_rows(group: dict[str, Any], status_key: str) -> dict[str, Any]: rows = [dict(row) for row in group.get("rows") or []] + summary = dict(group.get("summary") or {}) if status_key == "voucher_matched": normalized_rows: list[dict[str, Any]] = [] for row in rows: @@ -631,6 +972,7 @@ def clean_group_rows(group: dict[str, Any], status_key: str) -> dict[str, Any]: else: normalized_rows.append(blank_erp_side(row, "PROJECTION_RECONCILE_INVALID_ACCOUNT_NATURE")) rows = normalized_rows + rows = apply_same_draft_split_revenue_matches(rows, summary) allowed_drafts = allowed_erp_drafts_for_group(rows) cleaned: list[dict[str, Any]] = [] for row in rows: @@ -662,7 +1004,7 @@ def clean_group_rows(group: dict[str, Any], status_key: str) -> dict[str, Any]: cleaned.append(blank_erp_side(row, "PROJECTION_RECONCILE_RECHECK_NO_REAL_ERP_CANDIDATE")) continue cleaned.append(row) - rows = cleaned + rows = apply_same_draft_split_revenue_matches(cleaned, summary) seen_ledger: set[tuple[Any, ...]] = set() seen_voucher: set[tuple[Any, ...]] = set() @@ -737,33 +1079,31 @@ def split_group_by_wehago_voucher(group: dict[str, Any], status_key: str) -> dic def normalized_wehago_status(status_key: str, group: dict[str, Any]) -> str: - if status_key == "voucher_matched": - if any( - has_wehago_value(row) and has_erp_value(row) - for row in group.get("rows") or [] - ): - return "voucher_matched" - review_text = " ".join( - clean(row.get("review_reason")) - for row in group.get("rows") or [] - ) - if any( - token in review_text - for token in ( - "PROJECTION_RECONCILE_INVALID_ACCOUNT_NATURE", - "PROJECTION_RECONCILE_UNMATCHED_ERP_OVER_CAP", - ) - ): - return "voucher_recheck" + if group_identity(group) in MANUAL_OFFSET_EXCEPTED_IDENTITIES: + return "voucher_excepted" + if group_has_excepted_reason(group): + return "voucher_excepted" + is_excepted, _reason = _is_wehago_excepted_voucher_group(group) + if is_excepted: + return "voucher_excepted" + has_wehago = group_has_wehago_rows(group) + has_real_erp_candidate = group_has_real_erp_candidate(group) + if not has_wehago: return "voucher_unmatched" + review_text = group_review_text(group) + if status_key == "voucher_matched": + recheck_tokens = ( + "PROJECTION_RECONCILE_INVALID_ACCOUNT_NATURE", + "PROJECTION_RECONCILE_UNMATCHED_ERP_OVER_CAP", + "PROJECTION_RECONCILE_PARTIAL_MATCH_WEHAGO_ROW_UNMATCHED", + ) + if has_real_erp_candidate and not any(token in review_text for token in recheck_tokens): + return "voucher_matched" + return "voucher_recheck" if has_real_erp_candidate else "voucher_unmatched" + if status_key in {"voucher_unmatched", "voucher_excepted"}: + return "voucher_recheck" if has_real_erp_candidate else "voucher_unmatched" if status_key != "voucher_recheck": return status_key - review_text = " ".join( - [ - clean((group.get("summary") or {}).get("review_reason")), - *(clean(row.get("review_reason")) for row in group.get("rows") or []), - ] - ) if any( reason in review_text for reason in ( @@ -773,7 +1113,12 @@ def normalized_wehago_status(status_key: str, group: dict[str, Any]) -> str: ) ): return "voucher_recheck" - has_real_erp_candidate = any(has_erp_value(row) for row in group.get("rows") or []) + if ( + "PROJECTION_RECONCILE_SPLIT_REVENUE_SAME_DRAFT_PROOF_DATE" in review_text + and has_real_erp_candidate + and not group_has_unmatched_wehago_rows(group) + ): + return "voucher_matched" return "voucher_recheck" if has_real_erp_candidate else "voucher_unmatched" @@ -796,13 +1141,11 @@ def mark_excepted(group: dict[str, Any], reason: str) -> dict[str, Any]: payload = {"summary": summary, "rows": rows} payload["summary"]["status_label"] = "Excepted" existing_reason = clean(payload["summary"].get("review_reason")) - if reason not in existing_reason: - payload["summary"]["review_reason"] = " / ".join(item for item in (existing_reason, reason) if item) + payload["summary"]["review_reason"] = dedup_review_reason_text(existing_reason, reason) for row in payload["rows"]: row["status_label"] = "Excepted" row_reason = clean(row.get("review_reason")) - if reason not in row_reason: - row["review_reason"] = " / ".join(item for item in (row_reason, reason) if item) + row["review_reason"] = dedup_review_reason_text(row_reason, reason) payload["summary"] = rebuild_summary(payload, "voucher_excepted") return payload @@ -929,8 +1272,7 @@ def apply_net_adjustment_component_matches(status_groups: dict[str, list[dict[st def move_excepted_groups(status_groups: dict[str, list[dict[str, Any]]]) -> None: excepted: list[dict[str, Any]] = [] - recheck_from_matched: list[dict[str, Any]] = [] - unmatched_from_excepted: list[dict[str, Any]] = [] + relaxed_from_excepted: dict[str, list[dict[str, Any]]] = defaultdict(list) for group in list(status_groups.get("voucher_excepted") or []): existing_reason = clean((group.get("summary") or {}).get("review_reason")) if group_identity(group) in MANUAL_OFFSET_EXCEPTED_IDENTITIES: @@ -943,8 +1285,9 @@ def move_excepted_groups(status_groups: dict[str, list[dict[str, Any]]]) -> None if is_excepted: excepted.append(mark_excepted(group, reason)) else: - recheck_from_matched.append( - retag_group(group, "voucher_recheck", "PROJECTION_RECONCILE_EXCEPTED_RULE_RELAXED_RECHECK") + next_status = normalized_wehago_status("voucher_recheck", group) + relaxed_from_excepted[next_status].append( + retag_group(group, next_status, "PROJECTION_RECONCILE_EXCEPTED_RULE_RELAXED") ) retained_by_status: dict[str, list[dict[str, Any]]] = {} for status_key in ("voucher_matched", "voucher_unmatched", "voucher_recheck"): @@ -954,17 +1297,16 @@ def move_excepted_groups(status_groups: dict[str, list[dict[str, Any]]]) -> None excepted.append(mark_excepted(group, "MANUAL_OFFSET_PAIR_EXCEPTED")) continue is_excepted, reason = _is_wehago_excepted_voucher_group(group) - if is_excepted and status_key == "voucher_matched" and reason != "WEHAGO_EXCEPTED_DEPRECIATION_ACCOUNT": - recheck_from_matched.append( - retag_group(group, "voucher_recheck", f"PROJECTION_RECONCILE_EXCEPTED_CANDIDATE_RECHECK / {reason}") - ) - elif is_excepted: + if is_excepted: excepted.append(mark_excepted(group, reason)) else: retained.append(group) retained_by_status[status_key] = retained - retained_by_status["voucher_unmatched"].extend(unmatched_from_excepted) - retained_by_status["voucher_recheck"].extend(recheck_from_matched) + for relaxed_status, relaxed_groups in relaxed_from_excepted.items(): + if relaxed_status == "voucher_excepted": + excepted.extend(mark_excepted(group, "") for group in relaxed_groups) + else: + retained_by_status[relaxed_status].extend(relaxed_groups) confirmed = _move_wehago_confirmed_reversal_pairs_to_excepted( { "voucher_matched": retained_by_status["voucher_matched"], @@ -978,13 +1320,14 @@ def move_excepted_groups(status_groups: dict[str, list[dict[str, Any]]]) -> None retained_by_status["voucher_recheck"] = list(confirmed.get("voucher_recheck") or []) excepted = list(confirmed.get("voucher_excepted") or []) apply_net_adjustment_component_matches(retained_by_status) - moved = _move_wehago_offset_tax_invoice_groups_to_excepted( + moved = move_offset_tax_invoice_groups_to_excepted_fast( { "voucher_unmatched": retained_by_status["voucher_unmatched"], "voucher_recheck": retained_by_status["voucher_recheck"], "voucher_excepted": excepted, } ) + moved = move_exact_wehago_reversal_pairs_to_excepted(moved) status_groups["voucher_matched"] = retained_by_status["voucher_matched"] status_groups["voucher_unmatched"] = list(moved.get("voucher_unmatched") or []) status_groups["voucher_recheck"] = list(moved.get("voucher_recheck") or []) @@ -993,12 +1336,1044 @@ def move_excepted_groups(status_groups: dict[str, list[dict[str, Any]]]) -> None ] +def offset_vector_key(vector: dict[tuple[str, str, str], float], *, sign: int = 1) -> tuple[tuple[tuple[str, str, str], float], ...]: + return tuple(sorted((row_key, round(sign * amount, 4)) for row_key, amount in vector.items())) + + +def offset_vector_abs_key(vector: dict[tuple[str, str, str], float]) -> tuple[tuple[tuple[str, str, str], float], ...]: + return tuple(sorted((row_key, round(abs(amount), 4)) for row_key, amount in vector.items())) + + +def group_date_key(group: dict[str, Any]) -> str: + summary = group.get("summary") or {} + fiscal_year = int(summary.get("fiscal_year") or YEAR) + date_text = clean(summary.get("ledger_date")) or clean(summary.get("proof_date")) + if re.fullmatch(r"\d{1,2}[-./]\d{1,2}", date_text): + month, day = re.split(r"[-./]", date_text) + return f"{fiscal_year:04d}-{int(month):02d}-{int(day):02d}" + if re.fullmatch(r"\d{4}[-./]\d{1,2}[-./]\d{1,2}", date_text): + year, month, day = re.split(r"[-./]", date_text) + return f"{int(year):04d}-{int(month):02d}-{int(day):02d}" + return date_text + + +def group_vendor_key(group: dict[str, Any]) -> str: + vendors = { + normalized_compact(row.get("ledger_vendor")) + for row in group.get("rows") or [] + if clean(row.get("ledger_vendor")) + } + if not vendors: + vendors = {normalized_compact((group.get("summary") or {}).get("ledger_vendors"))} + vendors.discard("") + return "|".join(sorted(vendors)) + + +def group_desc_key(group: dict[str, Any]) -> str: + descs = { + normalized_compact(row.get("ledger_desc")) + for row in group.get("rows") or [] + if clean(row.get("ledger_desc")) + } + descs.discard("") + return "|".join(sorted(descs)) + + +def group_reversal_context_key(group: dict[str, Any]) -> tuple[str, str, str]: + return group_date_key(group), group_vendor_key(group), group_desc_key(group) + + +def group_sequence_key(group: dict[str, Any]) -> tuple[str, int]: + identity = group_identity(group) + if re.fullmatch(r"\d{8}-\d{5}", identity): + return identity[:8], int(identity.split("-", 1)[1]) + summary = group.get("summary") or {} + return group_date_key(group).replace("-", ""), int(re.sub(r"\D+", "", clean(summary.get("voucher_no"))) or 0) + + +def group_ledger_direction(group: dict[str, Any]) -> int: + total = 0.0 + for row in group.get("rows") or []: + if not clean(row.get("ledger_account_name")): + continue + total += parse_amount(row.get("ledger_debit")) + total += parse_amount(row.get("ledger_credit")) + if total > 0.5: + return 1 + if total < -0.5: + return -1 + return 0 + + +def group_has_erp_rows(group: dict[str, Any]) -> bool: + return any(has_wehago_value(row) and has_erp_value(row) for row in group.get("rows") or []) + + +def copy_erp_side(source: dict[str, Any], target: dict[str, Any], reason: str) -> dict[str, Any]: + payload = dict(target) + for field in ( + "proof_date", + "draft_no", + "voucher_account_name", + "voucher_vendor", + "voucher_debit", + "voucher_credit", + "voucher_desc", + "voucher_row_key", + ): + payload[field] = source.get(field) + payload["status_label"] = "Matched" + payload["review_reason"] = reason + payload["match_identity_key"] = "|".join( + clean(part) + for part in ( + payload.get("fiscal_year"), + payload.get("voucher_no"), + payload.get("ledger_row_key"), + payload.get("draft_no"), + payload.get("voucher_row_key"), + ) + if clean(part) + ) + return payload + + +def retarget_erp_rows_to_final_reissue(final_group: dict[str, Any], donor_groups: list[dict[str, Any]]) -> dict[str, Any]: + if group_has_erp_rows(final_group): + return final_group + donor_rows = [ + row + for donor in donor_groups + for row in donor.get("rows") or [] + if has_erp_value(row) + ] + if not donor_rows: + return final_group + + used: set[int] = set() + updated_rows: list[dict[str, Any]] = [] + changed = False + for row in final_group.get("rows") or []: + if not has_wehago_value(row) or has_erp_value(row): + updated_rows.append(dict(row)) + continue + ledger_amount = row_side_amount(row, "ledger") + ledger_side = effective_account_side(row, "ledger") + best_index = -1 + best_score: tuple[int, int, float] | None = None + for index, donor_row in enumerate(donor_rows): + if index in used: + continue + if abs(row_side_amount(donor_row, "voucher") - ledger_amount) >= 0.5: + continue + if effective_account_side(donor_row, "voucher") != ledger_side: + continue + if not _account_category_pair_allowed("", row.get("ledger_account_name"), "", donor_row.get("voucher_account_name")): + continue + if not _nature_compatible( + "", + row.get("ledger_account_name"), + ledger_side, + "", + donor_row.get("voucher_account_name"), + ledger_side, + ): + continue + vendor_match = int( + not clean(row.get("ledger_vendor")) + or not clean(donor_row.get("voucher_vendor")) + or normalized_compact(row.get("ledger_vendor")) in normalized_compact(donor_row.get("voucher_vendor")) + or normalized_compact(donor_row.get("voucher_vendor")) in normalized_compact(row.get("ledger_vendor")) + ) + desc_match = int( + not clean(row.get("ledger_desc")) + or not clean(donor_row.get("voucher_desc")) + or normalized_compact(row.get("ledger_desc")) in normalized_compact(donor_row.get("voucher_desc")) + or normalized_compact(donor_row.get("voucher_desc")) in normalized_compact(row.get("ledger_desc")) + ) + score = (vendor_match, desc_match, ledger_amount) + if best_score is None or score > best_score: + best_index = index + best_score = score + if best_index < 0: + updated_rows.append(dict(row)) + continue + used.add(best_index) + updated_rows.append(copy_erp_side(donor_rows[best_index], row, "PROJECTION_RECONCILE_CANCEL_REISSUE_RETARGET_FINAL")) + changed = True + + if not changed: + return final_group + payload = { + "summary": dict(final_group.get("summary") or {}), + "rows": updated_rows, + "source_group_index": final_group.get("source_group_index"), + } + payload["summary"] = rebuild_summary(payload, "voucher_matched") + return payload + + +def apply_cancel_reissue_final_match(status_groups: dict[str, list[dict[str, Any]]]) -> None: + candidates: list[tuple[str, dict[str, Any], tuple[str, str, tuple[tuple[tuple[str, str, str], float], ...]]]] = [] + for status_key in WEHAGO_STATUSES: + if status_key == "voucher_excepted": + continue + for group in status_groups.get(status_key) or []: + vector = _offset_group_vector(group) + if not vector: + continue + vendor_key = group_vendor_key(group) + desc_key = group_desc_key(group) + if not vendor_key or not desc_key: + continue + direction = group_ledger_direction(group) + if direction == 0: + continue + candidates.append((status_key, group, (vendor_key, desc_key, offset_vector_abs_key(vector)))) + if not candidates: + return + + by_chain: dict[tuple[str, str, tuple[tuple[tuple[str, str, str], float], ...]], list[tuple[str, dict[str, Any]]]] = defaultdict(list) + for status_key, group, chain_key in candidates: + by_chain[chain_key].append((status_key, group)) + + move_reasons: dict[int, str] = {} + retargeted: dict[int, dict[str, Any]] = {} + for chain_groups in by_chain.values(): + positives = [(status_key, group) for status_key, group in chain_groups if group_ledger_direction(group) > 0] + negatives = [(status_key, group) for status_key, group in chain_groups if group_ledger_direction(group) < 0] + if not positives or not negatives: + continue + positives_sorted = sorted(positives, key=lambda item: group_sequence_key(item[1])) + final_status, final_group = positives_sorted[-1] + if final_status == "voucher_excepted": + continue + donor_groups = [group for _status_key, group in positives_sorted[:-1] if group_has_erp_rows(group)] + adjusted_final = retarget_erp_rows_to_final_reissue(final_group, donor_groups) + if adjusted_final is not final_group: + retargeted[id(final_group)] = adjusted_final + final_has_erp = group_has_erp_rows(adjusted_final) + if not final_has_erp and not any(group_has_erp_rows(group) for _status_key, group in positives_sorted): + continue + for _status_key, group in negatives: + move_reasons[id(group)] = "WEHAGO_EXCEPTED_CANCEL_REISSUE_CANCEL" + for _status_key, group in positives_sorted[:-1]: + move_reasons[id(group)] = "WEHAGO_EXCEPTED_CANCEL_REISSUE_SUPERSEDED" + + if not move_reasons and not retargeted: + return + + rewritten: dict[str, list[dict[str, Any]]] = defaultdict(list) + for status_key in WEHAGO_STATUSES: + for group in status_groups.get(status_key) or []: + if id(group) in move_reasons: + rewritten["voucher_excepted"].append(mark_excepted(group, move_reasons[id(group)])) + continue + adjusted = retargeted.get(id(group), group) + final_status = normalized_wehago_status(status_key, adjusted) + rewritten[final_status].append(retag_group(adjusted, final_status)) + for status_key in WEHAGO_STATUSES: + status_groups[status_key] = rewritten.get(status_key, []) + + +def accrual_pair_score(ledger_row: dict[str, Any], voucher_row: dict[str, Any]) -> tuple[int, int, int, float]: + if not row_principle_pair_allowed(ledger_row, voucher_row): + return (-1, -1, -1, 0.0) + if abs(row_side_amount(ledger_row, "ledger") - row_side_amount(voucher_row, "voucher")) >= 0.5: + return (-1, -1, -1, 0.0) + same_account = int( + compact_account_name(ledger_row.get("ledger_account_name")) == compact_account_name(voucher_row.get("voucher_account_name")) + or compact_account_name(ledger_row.get("ledger_account_name")) in compact_account_name(voucher_row.get("voucher_account_name")) + or compact_account_name(voucher_row.get("voucher_account_name")) in compact_account_name(ledger_row.get("ledger_account_name")) + ) + vendor_match = int( + not clean(ledger_row.get("ledger_vendor")) + or not clean(voucher_row.get("voucher_vendor")) + or normalized_compact(ledger_row.get("ledger_vendor")) in normalized_compact(voucher_row.get("voucher_vendor")) + or normalized_compact(voucher_row.get("voucher_vendor")) in normalized_compact(ledger_row.get("ledger_vendor")) + ) + desc_match = int( + not clean(ledger_row.get("ledger_desc")) + or not clean(voucher_row.get("voucher_desc")) + or normalized_compact(ledger_row.get("ledger_desc")) in normalized_compact(voucher_row.get("voucher_desc")) + or normalized_compact(voucher_row.get("voucher_desc")) in normalized_compact(ledger_row.get("ledger_desc")) + ) + return same_account, vendor_match, desc_match, row_side_amount(ledger_row, "ledger") + + +def merge_accrual_pair(ledger_row: dict[str, Any], voucher_row: dict[str, Any], reason: str) -> dict[str, Any]: + payload = dict(ledger_row) + for field in ( + "proof_date", + "draft_no", + "voucher_account_name", + "voucher_vendor", + "voucher_debit", + "voucher_credit", + "voucher_desc", + "voucher_row_key", + ): + payload[field] = voucher_row.get(field) + payload["status_label"] = "Matched" + payload["review_reason"] = reason + payload["match_identity_key"] = "|".join( + clean(part) + for part in ( + payload.get("fiscal_year"), + payload.get("voucher_no"), + payload.get("ledger_row_key"), + payload.get("draft_no"), + payload.get("voucher_row_key"), + ) + if clean(part) + ) + return payload + + +def remove_recheck_reason_tokens(row: dict[str, Any], reason: str) -> dict[str, Any]: + blocked = { + "PROJECTION_RECONCILE_UNMATCHED_ERP_OVER_CAP", + "PROJECTION_RECONCILE_PARTIAL_MATCH_WEHAGO_ROW_UNMATCHED", + "PROJECTION_RECONCILE_INVALID_ACCOUNT_NATURE", + } + kept: list[str] = [] + append_review_reason_parts(kept, row.get("review_reason")) + kept = [part for part in kept if part not in blocked] + append_review_reason_parts(kept, reason) + payload = dict(row) + payload["review_reason"] = " / ".join(kept) + payload["status_label"] = "Matched" + return payload + + +def strip_review_reason_tokens_from_group(group: dict[str, Any], blocked: set[str]) -> dict[str, Any]: + payload = { + "summary": dict(group.get("summary") or {}), + "rows": [dict(row) for row in group.get("rows") or []], + "source_group_index": group.get("source_group_index"), + } + for target in [payload["summary"], *payload["rows"]]: + parts: list[str] = [] + append_review_reason_parts(parts, target.get("review_reason")) + target["review_reason"] = " / ".join(part for part in parts if part not in blocked) + return payload + + +def build_accrual_principle_match(group: dict[str, Any], reason: str) -> dict[str, Any] | None: + rows = [dict(row) for row in group.get("rows") or []] + ledger_business = [ + row for row in rows + if has_wehago_value(row) and (is_business_row(row, "ledger") or is_vat_row(row, "ledger")) + ] + voucher_business = [ + row for row in rows + if has_erp_value(row) and (is_business_row(row, "voucher") or is_vat_row(row, "voucher")) + ] + if not ledger_business or not voucher_business: + return None + + pair_candidates: list[tuple[tuple[int, int, int, float], int, int]] = [] + for ledger_index, ledger_row in enumerate(ledger_business): + for voucher_index, voucher_row in enumerate(voucher_business): + score = accrual_pair_score(ledger_row, voucher_row) + if score[0] < 0: + continue + pair_candidates.append((score, ledger_index, voucher_index)) + if not pair_candidates: + return None + pair_candidates.sort(reverse=True) + + used_ledger: set[int] = set() + used_voucher: set[int] = set() + merged_rows: list[dict[str, Any]] = [] + covered_ledger_keys: set[tuple[str, str, str, str]] = set() + covered_voucher_keys: set[tuple[str, str, str, str]] = set() + for _score, ledger_index, voucher_index in pair_candidates: + if ledger_index in used_ledger or voucher_index in used_voucher: + continue + ledger_row = ledger_business[ledger_index] + voucher_row = voucher_business[voucher_index] + used_ledger.add(ledger_index) + used_voucher.add(voucher_index) + covered_ledger_keys.add(row_business_match_key(ledger_row, "ledger")) + covered_voucher_keys.add(row_business_match_key(voucher_row, "voucher")) + merged_rows.append(merge_accrual_pair(ledger_row, voucher_row, reason)) + + if not merged_rows: + return None + + unmatched_voucher_business = [ + row for index, row in enumerate(voucher_business) + if index not in used_voucher and row_business_match_key(row, "voucher") not in covered_voucher_keys + ] + if unmatched_voucher_business: + return None + + for index, ledger_row in enumerate(ledger_business): + if index in used_ledger: + continue + ledger_key = row_business_match_key(ledger_row, "ledger") + if ledger_key in covered_ledger_keys: + continue + if row_principle_signature(ledger_row, "ledger").endswith(":decrease"): + continue + return None + + context_rows: list[dict[str, Any]] = [] + seen_context: set[tuple[Any, ...]] = set() + for row in rows: + if has_wehago_value(row) and (is_business_row(row, "ledger") or is_vat_row(row, "ledger")): + key = row_business_match_key(row, "ledger") + if key in covered_ledger_keys or row_principle_signature(row, "ledger").endswith(":decrease"): + continue + return None + if has_erp_value(row) and (is_business_row(row, "voucher") or is_vat_row(row, "voucher")): + key = row_business_match_key(row, "voucher") + if key in covered_voucher_keys: + continue + return None + ledger_context = has_wehago_value(row) and is_settlement_row(row, "ledger") + voucher_context = has_erp_value(row) and is_settlement_row(row, "voucher") + if not ledger_context and not voucher_context: + continue + context = remove_recheck_reason_tokens(row, reason) + context_key = ( + ledger_row_identity(context) if has_wehago_value(context) else None, + voucher_row_identity(context) if has_erp_value(context) else None, + ) + if context_key in seen_context: + continue + seen_context.add(context_key) + context_rows.append(context) + + payload = { + "summary": dict(group.get("summary") or {}), + "rows": merged_rows + context_rows, + "source_group_index": group.get("source_group_index"), + } + payload["summary"] = rebuild_summary(payload, "voucher_matched") + return payload + + +def index_erp_matched_accrual_donors(groups: dict[str, list[dict[str, Any]]]) -> dict[str, list[dict[str, Any]]]: + donors: dict[str, list[dict[str, Any]]] = defaultdict(list) + for group in groups.get("erp_voucher_matched") or []: + identity = group_identity(group) + if not identity: + continue + donors[identity].append(group) + return donors + + +def build_requested_draft_donor_match(group: dict[str, Any], donor: dict[str, Any]) -> dict[str, Any] | None: + requested_bases = row_draft_bases(group.get("rows") or [], group.get("summary") or {}) + if not requested_bases: + return None + filtered_rows = [ + dict(row) + for row in donor.get("rows") or [] + if clean(row.get("draft_no")) and erp_voucher_base(row.get("draft_no")) in requested_bases + ] + if not filtered_rows: + return None + filtered = { + "summary": dict(donor.get("summary") or {}), + "rows": filtered_rows, + "source_group_index": donor.get("source_group_index"), + } + filtered["summary"] = rebuild_summary(filtered, "voucher_matched") + return build_accrual_principle_match(filtered, "PROJECTION_RECONCILE_ACCRUAL_PRINCIPLE_ERP_DONOR_MATCH") + + +def apply_accrual_principle_matches( + status_groups: dict[str, list[dict[str, Any]]], + source_groups: dict[str, list[dict[str, Any]]], +) -> None: + donors_by_identity = index_erp_matched_accrual_donors(source_groups) + rewritten: dict[str, list[dict[str, Any]]] = defaultdict(list) + for status_key in WEHAGO_STATUSES: + for group in status_groups.get(status_key) or []: + if status_key != "voucher_recheck": + rewritten[status_key].append(group) + continue + promoted = build_accrual_principle_match(group, "PROJECTION_RECONCILE_ACCRUAL_PRINCIPLE_BUSINESS_MATCH") + if promoted is None: + donors = [ + donor_match + for donor in donors_by_identity.get(group_identity(group), []) + for donor_match in [build_requested_draft_donor_match(group, donor)] + if donor_match is not None + ] + if donors: + promoted = max( + donors, + key=lambda donor: ( + sum(1 for row in donor.get("rows") or [] if has_wehago_value(row) and has_erp_value(row)), + parse_amount((donor.get("summary") or {}).get("ledger_debit")) + parse_amount((donor.get("summary") or {}).get("ledger_credit")), + ), + ) + if promoted is None: + rewritten[status_key].append( + strip_review_reason_tokens_from_group( + group, + { + "PROJECTION_RECONCILE_ACCRUAL_PRINCIPLE_BUSINESS_MATCH", + "PROJECTION_RECONCILE_ACCRUAL_PRINCIPLE_ERP_DONOR_MATCH", + }, + ) + ) + continue + rewritten["voucher_matched"].append(retag_group(promoted, "voucher_matched")) + for status_key in WEHAGO_STATUSES: + status_groups[status_key] = rewritten.get(status_key, []) + + +def erp_entry_draft_year(entry: dict[str, Any]) -> int: + for value in (entry.get("draft_no"), entry.get("confirmed_no")): + match = re.search(r"11-(\d{4})\d{4}-", clean(value)) + if match: + return int(match.group(1)) + return int(entry.get("fiscal_year") or 0) + + +def raw_erp_entry_identity(entry: dict[str, Any]) -> tuple[Any, ...]: + return ( + entry.get("id"), + clean(entry.get("draft_no")), + clean(entry.get("confirmed_no")), + int(entry.get("row_number") or 0), + clean(entry.get("account_code")), + ) + + +def raw_erp_entry_draft_identity(entry: dict[str, Any]) -> tuple[str, str]: + return ("draft", clean(entry.get("draft_no")) or clean(entry.get("confirmed_no"))) + + +def raw_erp_entry_text(entry: dict[str, Any]) -> str: + return " ".join( + clean(part) + for part in ( + entry.get("account_name"), + entry.get("vendor_name"), + entry.get("desc1"), + entry.get("desc2"), + entry.get("management_item"), + ) + if clean(part) + ) + + +def compact_text_tokens(value: Any) -> set[str]: + tokens = { + token + for token in re.split(r"[^0-9a-z가-힣]+", normalized_compact(value)) + if len(token) >= 2 + } + return tokens + + +def compact_meaningful_parts(value: Any) -> list[str]: + parts: list[str] = [] + for token in re.split(r"[^0-9a-zA-Z가-힣]+", clean(value)): + compact = normalized_compact(token) + if len(compact) >= 4: + parts.append(compact) + return parts + + +def row_desc_matches_raw_entry(row: dict[str, Any], entry: dict[str, Any]) -> bool: + ledger_parts = compact_meaningful_parts(row.get("ledger_desc")) + entry_text = normalized_compact(" ".join([clean(entry.get("desc1")), clean(entry.get("desc2")), clean(entry.get("management_item"))])) + if not ledger_parts or not entry_text: + return False + return any(part in entry_text or entry_text in part for part in ledger_parts) + + +def row_text_matches_raw_entry(row: dict[str, Any], entry: dict[str, Any]) -> bool: + if row_desc_matches_raw_entry(row, entry): + return True + ledger_vendor = normalized_compact(row.get("ledger_vendor")) + entry_vendor = normalized_compact(entry.get("vendor_name")) + if ledger_vendor and entry_vendor and (ledger_vendor in entry_vendor or entry_vendor in ledger_vendor): + return True + ledger_text = " ".join( + clean(part) + for part in (row.get("ledger_account_name"), row.get("ledger_vendor"), row.get("ledger_desc")) + if clean(part) + ) + return bool(compact_text_tokens(ledger_text) & compact_text_tokens(raw_erp_entry_text(entry))) + + +def raw_entry_candidate_row(entry: dict[str, Any], ledger_row: dict[str, Any], reason: str) -> dict[str, Any] | None: + category = _classify_account_category(entry.get("account_code"), entry.get("account_name")) + signed_amount, side = raw_erp_entry_signed_amount_side(entry, category) + if abs(signed_amount) <= 0: + return None + ledger_debit = parse_amount(ledger_row.get("ledger_debit")) + ledger_credit = parse_amount(ledger_row.get("ledger_credit")) + if side == "debit" and abs(ledger_debit - signed_amount) >= 0.5: + return None + if side == "credit" and abs(ledger_credit - signed_amount) >= 0.5: + return None + payload = raw_erp_entry_to_row(entry, ledger_row, side, reason) + payload["voucher_debit"] = signed_amount if side == "debit" else 0 + payload["voucher_credit"] = signed_amount if side == "credit" else 0 + payload["voucher_row_key"] = build_voucher_row_key(payload) + if not row_principle_pair_allowed(ledger_row, payload): + return None + return payload + + +def split_draft_raw_candidate_score( + ledger_row: dict[str, Any], + entry: dict[str, Any], + fiscal_year: int, +) -> tuple[int, int, int, int, int, float]: + proof_match = int(dates_match_wehago(entry.get("proof_date"), ledger_row.get("ledger_date"), fiscal_year)) + desc_match = int(row_desc_matches_raw_entry(ledger_row, entry)) + same_account = int( + compact_account_name(ledger_row.get("ledger_account_name")) == compact_account_name(entry.get("account_name")) + or compact_account_name(ledger_row.get("ledger_account_name")) in compact_account_name(entry.get("account_name")) + or compact_account_name(entry.get("account_name")) in compact_account_name(ledger_row.get("ledger_account_name")) + ) + text_match = int(row_text_matches_raw_entry(ledger_row, entry)) + current_year = int(erp_entry_draft_year(entry) == fiscal_year) + return proof_match, desc_match, same_account, text_match, current_year, row_side_amount(ledger_row, "ledger") + + +def find_split_draft_raw_match( + ledger_row: dict[str, Any], + draft_bases: set[str], + used_entries: set[tuple[Any, ...]], + fiscal_year: int, +) -> dict[str, Any] | None: + best: tuple[tuple[int, int, int, int, float], dict[str, Any]] | None = None + for base in sorted(draft_bases): + for entry in RAW_ERP_ROWS_BY_DRAFT_BASE.get(base, []): + entry_identity = raw_erp_entry_identity(entry) + if entry_identity in used_entries or raw_erp_entry_draft_identity(entry) in used_entries: + continue + entry_year = erp_entry_draft_year(entry) + if entry_year and entry_year != fiscal_year: + continue + candidate = raw_entry_candidate_row( + entry, + ledger_row, + "PROJECTION_RECONCILE_SPLIT_DRAFT_ROW_MATCH", + ) + if candidate is None: + continue + score = split_draft_raw_candidate_score(ledger_row, entry, fiscal_year) + if not any(score[:4]): + continue + if best is None or score > best[0]: + best = (score, candidate) + if best is None: + return None + return best[1] + + +def row_existing_erp_is_material_match(row: dict[str, Any]) -> bool: + if not (has_wehago_value(row) and has_erp_value(row)): + return False + if abs(row_side_amount(row, "ledger") - row_side_amount(row, "voucher")) >= 0.5: + return False + return row_principle_pair_allowed(row, row) + + +def build_split_draft_row_match(group: dict[str, Any]) -> dict[str, Any] | None: + rows = [dict(row) for row in group.get("rows") or []] + summary = dict(group.get("summary") or {}) + draft_bases = row_draft_bases(rows, summary) + if not draft_bases: + return None + fiscal_year = int(summary.get("fiscal_year") or YEAR) + material_rows = [ + row + for row in rows + if has_wehago_value(row) and (is_business_row(row, "ledger") or is_vat_row(row, "ledger")) + ] + if not material_rows: + return None + + matched_rows: list[dict[str, Any]] = [] + used_entries: set[tuple[Any, ...]] = set() + covered_material_indexes: set[int] = set() + covered_material_keys: set[tuple[str, str, str, str]] = set() + raw_needed: list[tuple[int, dict[str, Any]]] = [] + for material_index, row in enumerate(material_rows): + if row_existing_erp_is_material_match(row): + matched = remove_recheck_reason_tokens(row, "PROJECTION_RECONCILE_SPLIT_DRAFT_EXISTING_ROW_MATCH") + matched_rows.append(matched) + covered_material_indexes.add(material_index) + covered_material_keys.add(row_business_match_key(row, "ledger")) + if clean(row.get("draft_no")): + used_entries.add(("draft", clean(row.get("draft_no")))) + continue + raw_needed.append((material_index, row)) + + raw_pair_candidates: list[tuple[tuple[int, int, int, int, int, float], int, dict[str, Any], tuple[Any, ...]]] = [] + for material_index, row in raw_needed: + for base in sorted(draft_bases): + for entry in RAW_ERP_ROWS_BY_DRAFT_BASE.get(base, []): + entry_identity = raw_erp_entry_identity(entry) + draft_identity = raw_erp_entry_draft_identity(entry) + if entry_identity in used_entries or draft_identity in used_entries: + continue + entry_year = erp_entry_draft_year(entry) + if entry_year and entry_year != fiscal_year: + continue + candidate = raw_entry_candidate_row( + entry, + row, + "PROJECTION_RECONCILE_SPLIT_DRAFT_ROW_MATCH", + ) + if candidate is None: + continue + score = split_draft_raw_candidate_score(row, entry, fiscal_year) + if not any(score[:4]): + continue + raw_pair_candidates.append((score, material_index, candidate, draft_identity)) + raw_pair_candidates.sort(key=lambda item: (item[0], -item[1]), reverse=True) + + for _score, material_index, candidate, draft_identity in raw_pair_candidates: + if material_index in covered_material_indexes or draft_identity in used_entries: + continue + matched_rows.append(candidate) + covered_material_indexes.add(material_index) + covered_material_keys.add(row_business_match_key(material_rows[material_index], "ledger")) + used_entries.add(draft_identity) + + for material_index, row in enumerate(material_rows): + if material_index in covered_material_indexes: + continue + if row_business_match_key(row, "ledger") in covered_material_keys: + continue + return None + + context_rows: list[dict[str, Any]] = [] + seen_context: set[tuple[Any, ...]] = set() + for row in rows: + if has_wehago_value(row) and (is_business_row(row, "ledger") or is_vat_row(row, "ledger")): + continue + if has_erp_value(row) and not has_wehago_value(row): + continue + if not has_wehago_value(row): + continue + context = remove_recheck_reason_tokens(row, "PROJECTION_RECONCILE_SPLIT_DRAFT_CONTEXT_ROW") + context_key = ledger_row_identity(context) + if context_key in seen_context: + continue + seen_context.add(context_key) + context_rows.append(context) + + payload = { + "summary": summary, + "rows": matched_rows + context_rows, + "source_group_index": group.get("source_group_index"), + } + payload["summary"] = rebuild_summary(payload, "voucher_matched") + return payload + + +def apply_split_draft_row_matches(status_groups: dict[str, list[dict[str, Any]]]) -> None: + rewritten: dict[str, list[dict[str, Any]]] = defaultdict(list) + for status_key in WEHAGO_STATUSES: + for group in status_groups.get(status_key) or []: + if status_key != "voucher_recheck": + rewritten[status_key].append(group) + continue + promoted = build_split_draft_row_match(group) + if promoted is None: + rewritten[status_key].append( + strip_review_reason_tokens_from_group( + group, + { + "PROJECTION_RECONCILE_SPLIT_DRAFT_EXISTING_ROW_MATCH", + "PROJECTION_RECONCILE_SPLIT_DRAFT_ROW_MATCH", + "PROJECTION_RECONCILE_SPLIT_DRAFT_CONTEXT_ROW", + }, + ) + ) + continue + rewritten["voucher_matched"].append(retag_group(promoted, "voucher_matched")) + for status_key in WEHAGO_STATUSES: + status_groups[status_key] = rewritten.get(status_key, []) + + +def extract_dates_from_text(value: Any) -> set[str]: + text = clean(value) + dates: set[str] = set() + for year, month, day in re.findall(r"((?:19|20)\d{2})[-./년\s]*(\d{1,2})[-./월\s]*(\d{1,2})", text): + dates.add(f"{int(year):04d}-{int(month):02d}-{int(day):02d}") + return dates + + +def raw_erp_unique_entries() -> list[dict[str, Any]]: + seen: set[Any] = set() + entries: list[dict[str, Any]] = [] + for rows in RAW_ERP_ROWS_BY_DRAFT_BASE.values(): + for row in rows: + key = row.get("id") or ( + row.get("draft_no"), + row.get("confirmed_no"), + row.get("account_code"), + row.get("row_number"), + ) + if key in seen: + continue + seen.add(key) + entries.append(row) + return entries + + +def group_has_management_item_trace(group: dict[str, Any]) -> bool: + date_key, vendor_key, desc_key = group_reversal_context_key(group) + if not date_key or not vendor_key: + return False + group_amounts = { + amount_key(abs(parse_amount(row.get("ledger_debit")) or parse_amount(row.get("ledger_credit")))) + for row in group.get("rows") or [] + if abs(parse_amount(row.get("ledger_debit")) or parse_amount(row.get("ledger_credit"))) >= 0.5 + } + for entry in raw_erp_unique_entries(): + management_text = clean(entry.get("management_item")) + if not management_text: + continue + entry_vendor = normalized_compact(entry.get("vendor_name")) + if entry_vendor and vendor_key and entry_vendor not in vendor_key and vendor_key not in entry_vendor: + continue + entry_dates = extract_dates_from_text(management_text) + proof_date = clean(entry.get("proof_date")) + if proof_date: + entry_dates.add(proof_date) + confirmed = clean(entry.get("confirmed_no")) + matched = re.search(r"11-((?:19|20)\d{6})-", confirmed) + if matched: + raw = matched.group(1) + entry_dates.add(f"{raw[:4]}-{raw[4:6]}-{raw[6:8]}") + if date_key not in entry_dates: + continue + entry_amounts = { + amount_key(abs(parse_amount(entry.get(field)))) + for field in ("debit_supply", "debit_tax", "credit_supply", "credit_tax") + if abs(parse_amount(entry.get(field))) >= 0.5 + } + if group_amounts and entry_amounts and group_amounts.isdisjoint(entry_amounts): + if desc_key and normalized_compact(entry.get("desc1")) not in desc_key and desc_key not in normalized_compact(entry.get("desc1")): + continue + return True + return False + + +def move_exact_wehago_reversal_pairs_to_excepted( + voucher_sections: dict[str, list[dict[str, Any]]], +) -> dict[str, list[dict[str, Any]]]: + candidate_statuses = ("voucher_unmatched", "voucher_recheck") + candidate_groups: list[tuple[str, dict[str, Any]]] = [ + (status_key, group) + for status_key in candidate_statuses + for group in list(voucher_sections.get(status_key) or []) + ] + if len(candidate_groups) < 2: + return voucher_sections + + vectors: dict[int, dict[tuple[str, str, str], float]] = {} + indexed: dict[tuple[tuple[str, str, str], tuple[tuple[tuple[str, str, str], float], ...]], list[tuple[str, dict[str, Any]]]] = defaultdict(list) + for status_key, group in candidate_groups: + vector = _offset_group_vector(group) + context_key = group_reversal_context_key(group) + if not vector or not all(context_key): + continue + vectors[id(group)] = vector + indexed[(context_key, offset_vector_key(vector))].append((status_key, group)) + + moved_ids: set[int] = set() + move_reasons: dict[int, str] = {} + for status_key, group in candidate_groups: + if id(group) in moved_ids: + continue + vector = vectors.get(id(group)) + if not vector: + continue + context_key = group_reversal_context_key(group) + for _other_status, other in indexed.get((context_key, offset_vector_key(vector, sign=-1)), []): + if other is group or id(other) in moved_ids: + continue + reason = ( + "WEHAGO_EXCEPTED_MANAGEMENT_ITEM_REVERSAL_PAIR" + if group_has_management_item_trace(group) or group_has_management_item_trace(other) + else "WEHAGO_EXCEPTED_EXACT_REVERSAL_PAIR" + ) + moved_ids.update((id(group), id(other))) + move_reasons[id(group)] = reason + move_reasons[id(other)] = reason + break + + if not moved_ids: + return voucher_sections + + retained_by_status: dict[str, list[dict[str, Any]]] = {status_key: [] for status_key in candidate_statuses} + moved_excepted: list[dict[str, Any]] = [] + for status_key, group in candidate_groups: + if id(group) not in moved_ids: + retained_by_status[status_key].append(group) + continue + moved_excepted.append(mark_excepted(group, move_reasons.get(id(group), "WEHAGO_EXCEPTED_EXACT_REVERSAL_PAIR"))) + for status_key in candidate_statuses: + voucher_sections[status_key] = retained_by_status[status_key] + voucher_sections["voucher_excepted"] = list(voucher_sections.get("voucher_excepted") or []) + moved_excepted + return voucher_sections + + +def move_offset_tax_invoice_groups_to_excepted_fast( + voucher_sections: dict[str, list[dict[str, Any]]], +) -> dict[str, list[dict[str, Any]]]: + candidate_statuses = ("voucher_unmatched", "voucher_recheck") + candidate_groups: list[tuple[str, dict[str, Any]]] = [ + (status_key, group) + for status_key in candidate_statuses + for group in list(voucher_sections.get(status_key) or []) + ] + if len(candidate_groups) < 2: + return voucher_sections + + candidate_vectors: dict[int, dict[tuple[str, str, str], float]] = {} + tax_offset_candidate_ids: set[int] = set() + indexed: dict[tuple[tuple[tuple[str, str, str], float], ...], list[tuple[str, dict[str, Any]]]] = defaultdict(list) + for status_key, group in candidate_groups: + if _voucher_group_has_review_reason( + group, + "MATCHED_CANCEL_TARGET_RECHECK", + "CANCEL_TARGET_ALREADY_MATCHED_RECHECK", + "CANCEL_REISSUE_RETARGET_RECHECK", + ): + continue + if _group_has_tax_invoice_cancel_signal(group) or _group_has_offset_tax_invoice_structure(group): + tax_offset_candidate_ids.add(id(group)) + vector = _offset_group_vector(group) + if not vector: + continue + candidate_vectors[id(group)] = vector + indexed[offset_vector_key(vector)].append((status_key, group)) + if not candidate_vectors: + return voucher_sections + + moved_ids: set[int] = set() + for status_key, group in candidate_groups: + if id(group) in moved_ids: + continue + left_vector = candidate_vectors.get(id(group)) + if not left_vector: + continue + left_dates = _voucher_group_month_days(group) + for _other_status_key, other in indexed.get(offset_vector_key(left_vector, sign=-1), []): + if other is group or id(other) in moved_ids: + continue + same_date = bool(left_dates & _voucher_group_month_days(other)) + is_tax_offset_pair = id(group) in tax_offset_candidate_ids and id(other) in tax_offset_candidate_ids + is_near_reversal_pair = _voucher_groups_within_days(group, other, 62) + if not (same_date or is_tax_offset_pair or is_near_reversal_pair): + continue + moved_ids.add(id(group)) + moved_ids.add(id(other)) + break + + if not moved_ids: + return voucher_sections + + retained_by_status: dict[str, list[dict[str, Any]]] = {status_key: [] for status_key in candidate_statuses} + moved_excepted: list[dict[str, Any]] = [] + for status_key, group in candidate_groups: + if id(group) not in moved_ids: + retained_by_status[status_key].append(group) + continue + moved_excepted.append(mark_excepted(group, "WEHAGO_EXCEPTED_OFFSET_REVERSAL_PAIR")) + + for status_key in candidate_statuses: + voucher_sections[status_key] = retained_by_status[status_key] + voucher_sections["voucher_excepted"] = list(voucher_sections.get("voucher_excepted") or []) + moved_excepted + return voucher_sections + + +def apply_split_revenue_matches_to_status_groups(status_groups: dict[str, list[dict[str, Any]]]) -> None: + updated: dict[str, list[dict[str, Any]]] = defaultdict(list) + for status_key in WEHAGO_STATUSES: + for group in list(status_groups.get(status_key) or []): + if status_key not in {"voucher_matched", "voucher_recheck"}: + updated[status_key].append(group) + continue + summary = dict(group.get("summary") or {}) + rows = apply_same_draft_split_revenue_matches([dict(row) for row in group.get("rows") or []], summary) + adjusted = {"summary": summary, "rows": rows, "source_group_index": group.get("source_group_index")} + adjusted["summary"] = rebuild_summary(adjusted, status_key) + final_status = normalized_wehago_status(status_key, adjusted) + updated[final_status].append(retag_group(adjusted, final_status)) + for status_key in WEHAGO_STATUSES: + status_groups[status_key] = updated.get(status_key, []) + + +def apply_exact_reversal_pairs_to_status_groups(status_groups: dict[str, list[dict[str, Any]]]) -> None: + moved = move_exact_wehago_reversal_pairs_to_excepted( + { + "voucher_unmatched": list(status_groups.get("voucher_unmatched") or []), + "voucher_recheck": list(status_groups.get("voucher_recheck") or []), + "voucher_excepted": list(status_groups.get("voucher_excepted") or []), + } + ) + status_groups["voucher_unmatched"] = list(moved.get("voucher_unmatched") or []) + status_groups["voucher_recheck"] = list(moved.get("voucher_recheck") or []) + status_groups["voucher_excepted"] = list(moved.get("voucher_excepted") or []) + + +def enforce_wehago_status_invariants(status_groups: dict[str, list[dict[str, Any]]]) -> dict[str, int]: + diagnostics = Counter() + reclassified: dict[str, list[dict[str, Any]]] = defaultdict(list) + for status_key in WEHAGO_STATUSES: + for group in list(status_groups.get(status_key) or []): + final_status = normalized_wehago_status(status_key, group) + if final_status != status_key: + diagnostics[f"{status_key}_to_{final_status}"] += 1 + if final_status == "voucher_excepted": + is_excepted, reason = _is_wehago_excepted_voucher_group(group) + if group_identity(group) in MANUAL_OFFSET_EXCEPTED_IDENTITIES: + reason = reason or "MANUAL_OFFSET_PAIR_EXCEPTED" + reclassified[final_status].append(mark_excepted(group, reason)) + else: + reclassified[final_status].append(retag_group(group, final_status)) + for status_key in WEHAGO_STATUSES: + status_groups[status_key] = reclassified.get(status_key, []) + diagnostics["voucher_recheck_without_erp"] = sum( + 1 for group in status_groups.get("voucher_recheck") or [] if not group_has_real_erp_candidate(group) + ) + diagnostics["voucher_matched_partial_wehago"] = sum( + 1 for group in status_groups.get("voucher_matched") or [] if group_has_unmatched_wehago_rows(group) + ) + diagnostics["voucher_unmatched_with_erp"] = sum( + 1 for group in status_groups.get("voucher_unmatched") or [] if group_has_real_erp_candidate(group) + ) + return {key: int(value) for key, value in diagnostics.items()} + + def append_unique(values: list[str], value: Any) -> None: text = clean(value) if text and text not in values: values.append(text) +def append_review_reason_parts(values: list[str], value: Any) -> None: + for part in re.split(r"\s*/\s*", clean(value)): + append_unique(values, part) + + +def dedup_review_reason_text(*values: Any) -> str: + parts: list[str] = [] + for value in values: + append_review_reason_parts(parts, value) + return " / ".join(parts) + + def rebuild_summary(group: dict[str, Any], status_key: str) -> dict[str, Any]: old = dict(group.get("summary") or {}) rows = list(group.get("rows") or []) @@ -1031,7 +2406,7 @@ def rebuild_summary(group: dict[str, Any], status_key: str) -> dict[str, Any]: append_unique(voucher_accounts, row.get("voucher_account_name")) append_unique(voucher_vendors, row.get("voucher_vendor")) append_unique(draft_nos, row.get("draft_no")) - append_unique(reasons, row.get("review_reason")) + append_review_reason_parts(reasons, row.get("review_reason")) if not clean(summary.get("ledger_date")) and clean(row.get("ledger_date")): summary["ledger_date"] = clean(row.get("ledger_date")) if not clean(summary.get("proof_date")) and clean(row.get("proof_date")): @@ -1042,7 +2417,9 @@ def rebuild_summary(group: dict[str, Any], status_key: str) -> dict[str, Any]: summary["ledger_vendors"] = ", ".join(ledger_vendors) summary["voucher_vendors"] = ", ".join(voucher_vendors) summary["draft_no"] = ", ".join(draft_nos) or clean(old.get("draft_no")) - summary["review_reason"] = " / ".join(reasons) or clean(old.get("review_reason")) + if not reasons: + append_review_reason_parts(reasons, old.get("review_reason")) + summary["review_reason"] = " / ".join(reasons) summary["search_text"] = " ".join( clean(part) for part in [ @@ -1308,6 +2685,188 @@ def update_snapshot_row_counts(conn: sqlite3.Connection, counts: dict[str, int]) ) +def ensure_compare_settings(conn: sqlite3.Connection) -> None: + conn.execute( + """ + CREATE TABLE IF NOT EXISTS wehago_compare_settings ( + setting_key TEXT PRIMARY KEY, + setting_json TEXT NOT NULL DEFAULT '{}', + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP + ) + """ + ) + + +def activate_projection_signature( + conn: sqlite3.Connection, + signature: str, + counts: dict[str, int], + diagnostics: dict[str, int], +) -> None: + ensure_compare_settings(conn) + payload = { + "start_year": YEAR, + "end_year": YEAR, + "signature": signature, + "logic_version": RECONCILED_PROJECTION_VERSION, + "counts": {key: int(counts.get(key, 0) or 0) for key in ALL_VOUCHER_STATUSES}, + "diagnostics": diagnostics, + "activated_at": datetime.now().isoformat(timespec="seconds"), + } + conn.execute( + """ + INSERT INTO wehago_compare_settings (setting_key, setting_json, updated_at) + VALUES (?, ?, CURRENT_TIMESTAMP) + ON CONFLICT(setting_key) DO UPDATE SET + setting_json = excluded.setting_json, + updated_at = CURRENT_TIMESTAMP + """, + ( + f"wehago_active_query_projection:{YEAR}:{YEAR}", + json.dumps(payload, ensure_ascii=False), + ), + ) + + +def store_query_metric_projection(conn: sqlite3.Connection, signature: str, counts: dict[str, int]) -> None: + conn.execute( + """ + INSERT INTO wehago_compare_query_metrics ( + start_year, end_year, signature, counts_json, snapshot_state_json, source_state_json, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) + ON CONFLICT(start_year, end_year, signature) DO UPDATE SET + counts_json = excluded.counts_json, + snapshot_state_json = excluded.snapshot_state_json, + source_state_json = excluded.source_state_json, + updated_at = CURRENT_TIMESTAMP + """, + ( + YEAR, + YEAR, + signature, + json.dumps({key: int(counts.get(key, 0) or 0) for key in ALL_VOUCHER_STATUSES}, ensure_ascii=False), + json.dumps({"ready": [YEAR], "missing": [], "stale": [], "queued": [], "running": []}, ensure_ascii=False), + json.dumps({"projection_signature": signature, "source": "reconcile_wehago_projection_to_db"}, ensure_ascii=False), + ), + ) + + +def backfill_final_status_projection(conn: sqlite3.Connection, signature: str) -> int: + priority = { + "voucher_excepted": 0, + "voucher_matched": 1, + "voucher_recheck": 2, + "voucher_unmatched": 3, + } + priority_case = " ".join(f"WHEN '{status}' THEN {rank}" for status, rank in priority.items()) + status_case = " ".join(f"WHEN {rank} THEN '{status}'" for status, rank in priority.items()) + conn.execute( + """ + DELETE FROM wehago_compare_final_status_projection + WHERE start_year = ? AND end_year = ? AND signature = ? + """, + (YEAR, YEAR, signature), + ) + conn.execute( + f""" + INSERT INTO wehago_compare_final_status_projection ( + start_year, end_year, signature, identity_key, compare_voucher_no, + fiscal_year, ledger_date, voucher_no, final_status, final_rank, + source_statuses, source_group_count, created_at, updated_at + ) + WITH source_groups AS ( + SELECT + fiscal_year, + status_key, + CASE status_key {priority_case} ELSE 99 END AS status_rank, + COALESCE(NULLIF(TRIM(ledger_date), ''), NULLIF(TRIM(proof_date), ''), '') AS raw_date, + COALESCE(NULLIF(TRIM(voucher_no), ''), CAST(group_index AS TEXT)) AS raw_voucher_no + FROM wehago_compare_query_groups + WHERE start_year = ? + AND end_year = ? + AND signature = ? + AND status_key IN ('voucher_matched', 'voucher_recheck', 'voucher_unmatched', 'voucher_excepted') + ), + normalized AS ( + SELECT + fiscal_year, + status_key, + status_rank, + CASE + WHEN raw_date GLOB '[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]' THEN raw_date + WHEN raw_date GLOB '[0-9][0-9]-[0-9][0-9]' THEN printf('%04d-%s', fiscal_year, raw_date) + WHEN raw_date GLOB '[0-9]-[0-9][0-9]' THEN printf('%04d-0%s', fiscal_year, raw_date) + ELSE raw_date + END AS ledger_date, + TRIM(CASE WHEN INSTR(raw_voucher_no, ',') > 0 THEN SUBSTR(raw_voucher_no, 1, INSTR(raw_voucher_no, ',') - 1) ELSE raw_voucher_no END) AS voucher_no + FROM source_groups + ), + classified AS ( + SELECT + status_key, + status_rank, + fiscal_year, + ledger_date, + voucher_no, + CAST(fiscal_year AS TEXT) || '|' || ledger_date || '|' || voucher_no AS identity_key, + CASE WHEN ledger_date <> '' AND voucher_no <> '' THEN REPLACE(ledger_date, '-', '') || '-' || voucher_no ELSE '' END AS compare_voucher_no + FROM normalized + WHERE COALESCE(voucher_no, '') <> '' + ), + resolved AS ( + SELECT + identity_key, + MIN(status_rank) AS final_rank, + MIN(fiscal_year) AS fiscal_year, + MIN(ledger_date) AS ledger_date, + MIN(voucher_no) AS voucher_no, + MIN(compare_voucher_no) AS compare_voucher_no, + GROUP_CONCAT(DISTINCT status_key) AS source_statuses, + COUNT(*) AS source_group_count + FROM classified + WHERE COALESCE(identity_key, '') <> '' + GROUP BY identity_key + ) + SELECT + ?, ?, ?, identity_key, compare_voucher_no, + fiscal_year, ledger_date, voucher_no, + CASE final_rank {status_case} ELSE '' END, + final_rank, COALESCE(source_statuses, ''), source_group_count, + CURRENT_TIMESTAMP, CURRENT_TIMESTAMP + FROM resolved + WHERE final_rank < 99 + """, + (YEAR, YEAR, signature, YEAR, YEAR, signature), + ) + return int( + conn.execute( + """ + SELECT COUNT(*) + FROM wehago_compare_final_status_projection + WHERE start_year = ? AND end_year = ? AND signature = ? + """, + (YEAR, YEAR, signature), + ).fetchone()[0] + or 0 + ) + + +def clear_projection_caches(conn: sqlite3.Connection, signature: str) -> None: + for table_name in ( + "wehago_compare_query_page_cache", + "wehago_metric_count_cache", + "wehago_summary_range_cache", + ): + conn.execute( + f""" + DELETE FROM {table_name} + WHERE start_year = ? AND end_year = ? + AND signature = ? + """, + (YEAR, YEAR, signature), + ) + + def main() -> None: global MANUAL_OFFSET_EXCEPTED_IDENTITIES, RAW_ERP_ROWS_BY_DRAFT_BASE, YEAR parser = argparse.ArgumentParser(description="Reconcile a WEHAGO comparison query projection to DB rows.") @@ -1318,13 +2877,20 @@ def main() -> None: conn = sqlite3.connect(DB_PATH) conn.row_factory = sqlite3.Row cur = conn.cursor() + log_step(f"selecting source projection for {YEAR}") source_signature = latest_projection_signature(cur) - reconciled_prefix = f"{QUERY_PROJECTION_VERSION}|db-reconciled-v1|" + reconciled_prefix = f"{QUERY_PROJECTION_VERSION}|{RECONCILED_PROJECTION_VERSION}|" target_signature = source_signature if source_signature.startswith(reconciled_prefix) else f"{reconciled_prefix}{source_signature}" + log_step(f"source={source_signature[:120]} target={target_signature[:120]}") + log_step("loading ERP source rows") RAW_ERP_ROWS_BY_DRAFT_BASE = load_raw_erp_rows_by_draft_base(conn) + log_step("loading manual excepted identities") MANUAL_OFFSET_EXCEPTED_IDENTITIES = load_manual_offset_excepted_identities(conn) + log_step("loading WEHAGO comparison rows") db_wehago = load_db_wehago(conn) + log_step(f"loading query groups for source signature ({len(db_wehago)} WEHAGO vouchers)") groups = load_groups(conn, source_signature) + log_step("loading raw WEHAGO ledger rows") ledger_rows_by_key: dict[str, list[sqlite3.Row]] = defaultdict(list) for row in conn.execute( @@ -1341,6 +2907,7 @@ def main() -> None: selected_by_key: dict[str, tuple[str, dict[str, Any]]] = {} duplicate_counter = 0 removed_non_db = 0 + log_step("selecting best WEHAGO groups") for status_key in WEHAGO_STATUSES: for group in groups.get(status_key, []): for identity, split_group in split_group_by_wehago_voucher(group, status_key).items(): @@ -1360,6 +2927,7 @@ def main() -> None: duplicate_counter += 1 missing_db = sorted(set(db_wehago) - set(selected_by_key)) + log_step(f"adding DB-only WEHAGO groups: {len(missing_db)}") for key in missing_db: selected_by_key[key] = ( "voucher_unmatched", @@ -1367,6 +2935,7 @@ def main() -> None: ) status_groups: dict[str, list[dict[str, Any]]] = defaultdict(list) + log_step("supplementing raw WEHAGO rows and normalizing statuses") for key in sorted(selected_by_key): status_key, group = selected_by_key[key] group = supplement_group_with_missing_wehago_rows( @@ -1375,9 +2944,25 @@ def main() -> None: key, ledger_rows_by_key.get(key, []), ) - status_groups[status_key].append(group) + final_status_key = normalized_wehago_status(status_key, group) + status_groups[final_status_key].append(retag_group(group, final_status_key)) + log_step("applying excepted rules") move_excepted_groups(status_groups) + log_step("reapplying split revenue, cancel/reissue, and exact reversal rules") + apply_split_revenue_matches_to_status_groups(status_groups) + apply_cancel_reissue_final_match(status_groups) + apply_accrual_principle_matches(status_groups, groups) + apply_split_draft_row_matches(status_groups) + apply_exact_reversal_pairs_to_status_groups(status_groups) + log_step("enforcing final WEHAGO status invariants") + invariant_diagnostics = enforce_wehago_status_invariants(status_groups) + log_step("rechecking cancel/reissue and exact reversal rules after invariants") + apply_cancel_reissue_final_match(status_groups) + apply_accrual_principle_matches(status_groups, groups) + apply_split_draft_row_matches(status_groups) + apply_exact_reversal_pairs_to_status_groups(status_groups) + invariant_diagnostics = enforce_wehago_status_invariants(status_groups) excepted_wehago_keys = { group_identity(group) for group in status_groups.get("voucher_excepted") or [] @@ -1390,6 +2975,7 @@ def main() -> None: if key } for status_key in ERP_STATUSES: + log_step(f"normalizing ERP side groups: {status_key}") seen_erp: set[str] = set() for group in groups.get(status_key, []): cleaned = clean_group_rows(group, status_key) @@ -1419,6 +3005,17 @@ def main() -> None: ) cleaned["summary"] = rebuild_summary(cleaned, final_status_key) else: + if group_identity(cleaned) in excepted_wehago_keys: + continue + normalized_rows = [ + row + for row in cleaned.get("rows") or [] + if not (has_wehago_value(row) and row_wehago_identity(row, cleaned) in excepted_wehago_keys) + ] + if not normalized_rows: + continue + cleaned["rows"] = normalized_rows + cleaned["summary"] = rebuild_summary(cleaned, status_key) final_status_key = status_key summary = cleaned.get("summary") or {} identity = "|".join( @@ -1431,6 +3028,7 @@ def main() -> None: seen_erp.add(identity) status_groups[final_status_key].append(cleaned) + log_step("writing reconciled projection") conn.execute("BEGIN") try: conn.execute( @@ -1441,11 +3039,20 @@ def main() -> None: "DELETE FROM wehago_compare_query_groups WHERE start_year = ? AND end_year = ? AND signature = ?", (YEAR, YEAR, target_signature), ) + clear_projection_caches(conn, target_signature) counts: dict[str, int] = {} for status_key in ALL_VOUCHER_STATUSES: + log_step(f"inserting {status_key}: {len(status_groups.get(status_key, []))}") for group_index, group in enumerate(status_groups.get(status_key, []), start=1): insert_group(conn, target_signature, status_key, group_index, group) counts[status_key] = len(status_groups.get(status_key, [])) + log_step("storing query metric projection") + store_query_metric_projection(conn, target_signature, counts) + log_step("backfilling final status projection") + final_projection_rows = backfill_final_status_projection(conn, target_signature) + log_step("activating projection signature") + activate_projection_signature(conn, target_signature, counts, invariant_diagnostics) + log_step("updating snapshot row counts") update_snapshot_row_counts(conn, counts) conn.execute( """ @@ -1464,6 +3071,8 @@ def main() -> None: "removed_duplicate_groups": duplicate_counter, "removed_non_db_groups": removed_non_db, "added_db_only_groups": len(missing_db), + "final_projection_rows": final_projection_rows, + "invariant_diagnostics": invariant_diagnostics, "created_at": datetime.now().isoformat(timespec="seconds"), }, ensure_ascii=False, @@ -1471,6 +3080,7 @@ def main() -> None: ), ) conn.commit() + log_step("commit complete") except Exception: conn.rollback() raise @@ -1485,6 +3095,8 @@ def main() -> None: "removed_duplicate_groups": duplicate_counter, "removed_non_db_groups": removed_non_db, "added_db_only_groups": len(missing_db), + "final_projection_rows": final_projection_rows, + "invariant_diagnostics": invariant_diagnostics, }, ensure_ascii=False, ) diff --git a/scripts/run_wehago_logic_iteration.py b/scripts/run_wehago_logic_iteration.py new file mode 100644 index 0000000..ec73f36 --- /dev/null +++ b/scripts/run_wehago_logic_iteration.py @@ -0,0 +1,259 @@ +from __future__ import annotations + +import argparse +import json +import sqlite3 +import subprocess +import sys +from pathlib import Path +from typing import Any + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + +from runtime_config import DB_PATH + + +DEFAULT_SAMPLES = ( + "2025-02-25-00029", + "2025-01-10-00006", + "2025-01-15-50027", + "2025-01-15-50028", + "2025-01-21-50197", + "2025-01-21-50198", +) + + +def run_command(command: list[str]) -> str: + proc = subprocess.run(command, cwd=Path(__file__).resolve().parents[1], text=True, capture_output=True) + if proc.returncode != 0: + payload = { + "command": command, + "returncode": proc.returncode, + "stdout": proc.stdout, + "stderr": proc.stderr, + } + raise SystemExit(json.dumps(payload, ensure_ascii=False, indent=2)) + return proc.stdout + + +def parse_sample(value: str, default_year: int) -> tuple[int, str, str]: + text = value.strip() + parts = text.replace("/", "-").split("-") + if len(parts) == 4: + year, month, day, voucher = parts + elif len(parts) == 3: + year = str(default_year) + month, day, voucher = parts + else: + raise ValueError(f"Invalid sample format: {value}") + return int(year), f"{int(month):02d}-{int(day):02d}", f"{int(voucher):05d}" if voucher.isdigit() else voucher + + +def active_signature(conn: sqlite3.Connection, year: int) -> str: + row = conn.execute( + """ + SELECT setting_json + FROM wehago_compare_settings + WHERE setting_key = ? + LIMIT 1 + """, + (f"wehago_active_query_projection:{year}:{year}",), + ).fetchone() + if not row: + return "" + try: + payload = json.loads(row[0] or "{}") + except Exception: + return "" + return str(payload.get("signature") or "") if isinstance(payload, dict) else "" + + +def validate_projection(conn: sqlite3.Connection, year: int, signature: str) -> dict[str, Any]: + counts = { + row[0]: int(row[1] or 0) + for row in conn.execute( + """ + SELECT status_key, COUNT(*) + FROM wehago_compare_query_groups + WHERE start_year = ? AND end_year = ? AND signature = ? + AND status_key IN ('voucher_matched', 'voucher_unmatched', 'voucher_recheck', 'voucher_excepted') + GROUP BY status_key + """, + (year, year, signature), + ).fetchall() + } + final_counts = { + row[0]: int(row[1] or 0) + for row in conn.execute( + """ + SELECT final_status, COUNT(*) + FROM wehago_compare_final_status_projection + WHERE start_year = ? AND end_year = ? AND signature = ? + GROUP BY final_status + """, + (year, year, signature), + ).fetchall() + } + raw_total = int( + conn.execute( + """ + SELECT COUNT(DISTINCT compare_voucher_no) + FROM wehago_ledger_rows + WHERE fiscal_year = ? + AND COALESCE(compare_voucher_no, '') <> '' + """, + (year,), + ).fetchone()[0] + or 0 + ) + recheck_without_erp = int( + conn.execute( + """ + SELECT COUNT(*) + FROM wehago_compare_query_groups g + WHERE g.start_year = ? AND g.end_year = ? AND g.signature = ? + AND g.status_key = 'voucher_recheck' + AND NOT EXISTS ( + SELECT 1 + FROM wehago_compare_query_rows r + WHERE r.start_year = g.start_year + AND r.end_year = g.end_year + AND r.signature = g.signature + AND r.status_key = g.status_key + AND r.group_index = g.group_index + AND COALESCE(r.voucher_account_name, '') <> '' + AND ( + ABS(COALESCE(r.voucher_debit, 0)) > 0.0001 + OR ABS(COALESCE(r.voucher_credit, 0)) > 0.0001 + ) + ) + """, + (year, year, signature), + ).fetchone()[0] + or 0 + ) + unmatched_with_erp = int( + conn.execute( + """ + SELECT COUNT(*) + FROM wehago_compare_query_groups g + WHERE g.start_year = ? AND g.end_year = ? AND g.signature = ? + AND g.status_key = 'voucher_unmatched' + AND EXISTS ( + SELECT 1 + FROM wehago_compare_query_rows r + WHERE r.start_year = g.start_year + AND r.end_year = g.end_year + AND r.signature = g.signature + AND r.status_key = g.status_key + AND r.group_index = g.group_index + AND COALESCE(r.voucher_account_name, '') <> '' + AND ( + ABS(COALESCE(r.voucher_debit, 0)) > 0.0001 + OR ABS(COALESCE(r.voucher_credit, 0)) > 0.0001 + ) + ) + """, + (year, year, signature), + ).fetchone()[0] + or 0 + ) + return { + "raw_total": raw_total, + "counts": counts, + "final_counts": final_counts, + "classified_total": sum(counts.values()), + "difference": raw_total - sum(counts.values()), + "recheck_without_erp": recheck_without_erp, + "unmatched_with_erp": unmatched_with_erp, + } + + +def sample_statuses(conn: sqlite3.Connection, year: int, signature: str, samples: list[str]) -> list[dict[str, Any]]: + result: list[dict[str, Any]] = [] + for sample in samples: + sample_year, ledger_date, voucher_no = parse_sample(sample, year) + rows = [ + { + "status_key": row[0], + "ledger_date": row[1], + "voucher_no": row[2], + "draft_no": row[3], + "review_reason": row[4], + } + for row in conn.execute( + """ + SELECT status_key, ledger_date, voucher_no, draft_no, review_reason + FROM wehago_compare_query_groups + WHERE start_year = ? AND end_year = ? AND signature = ? + AND fiscal_year = ? + AND ledger_date = ? + AND voucher_no = ? + ORDER BY status_key, group_index + """, + (year, year, signature, sample_year, ledger_date, voucher_no), + ).fetchall() + ] + result.append({"sample": sample, "rows": rows}) + return result + + +def main() -> None: + parser = argparse.ArgumentParser(description="Run a fast WEHAGO logic iteration: compile, reconcile, validate, sample-check, optionally prune.") + parser.add_argument("--year", type=int, default=2025) + parser.add_argument("--sample", action="append", default=[]) + parser.add_argument("--skip-compile", action="store_true") + parser.add_argument("--skip-reconcile", action="store_true") + parser.add_argument("--prune", action="store_true") + args = parser.parse_args() + + repo_root = Path(__file__).resolve().parents[1] + python = str(repo_root / ".venv" / "bin" / "python") + if not args.skip_compile: + run_command( + [ + python, + "-m", + "py_compile", + "scripts/reconcile_wehago_projection_to_db.py", + "scripts/prune_wehago_projection_history.py", + "wehago_compare.py", + "main.py", + ] + ) + reconcile_output = "" + if not args.skip_reconcile: + reconcile_output = run_command([python, "scripts/reconcile_wehago_projection_to_db.py", "--year", str(args.year)]) + prune_output = "" + if args.prune: + prune_output = run_command( + [ + python, + "scripts/prune_wehago_projection_history.py", + "--start-year", + str(args.year), + "--end-year", + str(args.year), + "--execute", + ] + ) + + conn = sqlite3.connect(DB_PATH) + try: + signature = active_signature(conn, args.year) + samples = args.sample or list(DEFAULT_SAMPLES) + payload = { + "year": args.year, + "active_signature": signature, + "validation": validate_projection(conn, args.year, signature) if signature else {}, + "samples": sample_statuses(conn, args.year, signature, samples) if signature else [], + "reconcile_output_tail": reconcile_output.strip().splitlines()[-8:], + "prune_output": json.loads(prune_output) if prune_output.strip().startswith("{") else prune_output, + } + finally: + conn.close() + print(json.dumps(payload, ensure_ascii=False, indent=2)) + + +if __name__ == "__main__": + main() diff --git a/templates/admin_users.html b/templates/admin_users.html index ae6d32c..afb6db0 100644 --- a/templates/admin_users.html +++ b/templates/admin_users.html @@ -121,6 +121,104 @@ color: var(--ink); } + .last-login-button { + border: 0; + background: transparent; + box-shadow: none; + padding: 0; + min-height: 0; + color: var(--ink); + font: inherit; + font-weight: 700; + line-height: 1.25; + text-align: left; + text-decoration: none; + cursor: pointer; + } + + .last-login-button:hover, + .last-login-button:focus { + background: transparent; + box-shadow: none; + text-decoration: none; + } + + .login-history-modal { + position: fixed; + inset: 0; + z-index: 80; + display: none; + align-items: center; + justify-content: center; + padding: 24px; + background: rgba(17, 24, 39, 0.28); + } + + .login-history-modal.open { + display: flex; + } + + .login-history-dialog { + width: min(1080px, calc(100vw - 48px)); + max-height: min(760px, calc(100vh - 48px)); + overflow: auto; + border: 1px solid var(--line); + border-radius: 0; + background: #ffffff; + box-shadow: none; + padding: 18px; + } + + .login-history-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + margin-bottom: 12px; + } + + .login-history-head strong { + font-size: 18px; + line-height: 1.3; + } + + .login-history-close { + border: 0; + background: transparent; + box-shadow: none; + color: var(--ink); + padding: 0; + min-height: 0; + font-weight: 800; + cursor: pointer; + } + + .login-history-close:hover, + .login-history-close:focus { + background: transparent; + box-shadow: none; + } + + .login-history-table-wrap { + overflow: auto; + } + + .login-history-table { + width: 100%; + table-layout: fixed; + } + + .login-history-table th, + .login-history-table td { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + .login-history-table .ua-cell { + max-width: 360px; + } + @media (max-width: 1100px) { .admin-user-main-row { grid-template-columns: repeat(2, minmax(0, 1fr)); @@ -201,12 +299,14 @@| 일시(KST) | +결과 | +아이디 | +IP | +실패사유 | +사용환경 | +
|---|---|---|---|---|---|
| ${escapeHtml(event.created_at_kst || event.created_at)} | +${event.success ? "성공" : "실패"} | +${escapeHtml(event.username || "-")} | +${escapeHtml(event.ip_address || "-")} | +${escapeHtml(event.failure_reason || "-")} | +${escapeHtml(event.user_agent || "-")} | +