diff --git a/.gitignore b/.gitignore index d010cba..386bfb4 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,15 @@ scripts/.chrome-wehago-profile/ runtime_cache/ intranet-runtime/ .dev-state/ +reports/ +static/reports/ +runtime_diagnostics/ +.local-tools/ +*.pdf +*.xls +*.xlsx +WORK_SUMMARY_*.md +DB_HEALTH_CHECK_*.md +backup_snapshots/**/*.md +reports/hanmac_related_party_loans_*.xlsx +reports/wehago_account_fixes/ diff --git a/DB_HEALTH_CHECK_20260409.md b/DB_HEALTH_CHECK_20260409.md deleted file mode 100644 index 1bc99af..0000000 --- a/DB_HEALTH_CHECK_20260409.md +++ /dev/null @@ -1,169 +0,0 @@ -# DB Health Check 2026-04-09 - -## Summary - -- Current SQLite size and row counts are still within a manageable range for this app. -- Main medium-term risks were: - - concurrent writes causing `database is locked` - - all users sharing one `project_page_state` row - - growing scan cost around billing-link and project-status entry access -- These were addressed without data loss. - -## Current Scale - -- `transactions`: about 49k rows -- `project_contract_info`: 760 rows -- `project_billing_entries`: 1,774 rows -- `project_related_links`: 1,276 rows -- `project_status`: few rows now, but each row can contain many logical sub-entries - -## Improvements Applied - -### 1. SQLite concurrency and durability tuning - -Applied on every DB connection in [main.py](/home/b17301/my-intranet-app/main.py#L39): - -- `PRAGMA journal_mode=WAL` -- `PRAGMA synchronous=NORMAL` -- `PRAGMA foreign_keys=ON` -- `PRAGMA busy_timeout=5000` -- `PRAGMA temp_store=MEMORY` - -Effect: - -- better concurrent read/write behavior -- lower chance of write collisions during multi-user editing -- safer long-running usage than SQLite defaults - -### 2. Added indexes for growth hotspots - -Added in [main.py](/home/b17301/my-intranet-app/main.py#L119): - -- `transactions` - - `idx_transactions_support_category` - - `idx_transactions_support_account` - - `idx_transactions_source_file` - - `idx_transactions_updated_at` -- `project_billing_entries` - - `idx_project_billing_entries_raw_code` - - `idx_project_billing_entries_round_code` - - `idx_project_billing_entries_source_file` - - `idx_project_billing_entries_updated_at` -- `project_related_links` - - `idx_project_related_links_related` -- `project_page_state` - - `idx_project_page_state_page_session` - -Effect: - -- faster changed-round grouping -- better support/account based project aggregations -- faster source-file based reimport paths -- safer scaling as billing and transaction history grows - -### 3. Page state changed from shared row to session-scoped rows - -The old structure used one shared record: - -- `project_page_state(page_key PRIMARY KEY, ...)` - -This meant different users could overwrite each other's selected project, year, and open/closed detail state. - -It now migrates to: - -- `project_page_state(page_key, session_id, ...)` -- primary key: `(page_key, session_id)` - -Relevant code: - -- schema migration: [main.py](/home/b17301/my-intranet-app/main.py#L264) -- load/save logic: [main.py](/home/b17301/my-intranet-app/main.py#L1970) -- API: [main.py](/home/b17301/my-intranet-app/main.py#L3473) -- browser session id: [templates/base.html](/home/b17301/my-intranet-app/templates/base.html#L538) -- client page-state save/load: [templates/projects.html](/home/b17301/my-intranet-app/templates/projects.html#L4880) - -Effect: - -- browser A and browser B no longer fight over one shared project-search state - -### 4. Project status JSON blobs normalized into child tables - -Previously, logical row collections were stored only inside JSON columns in `project_status`: - -- `collection_entries_json` -- `task_plan_entries_json` -- `exec_budget_entries_json` -- `actual_input_entries_json` - -This has now been normalized into child tables: - -- `project_collection_entries` -- `project_task_plan_entries` -- `project_exec_budget_entries` -- `project_actual_input_entries` - -Relevant code: - -- row extraction and migration helpers: [main.py](/home/b17301/my-intranet-app/main.py#L987) -- child-table schema: [main.py](/home/b17301/my-intranet-app/main.py#L273) -- migration from legacy JSON: [main.py](/home/b17301/my-intranet-app/main.py#L1201) -- project status read paths: [main.py](/home/b17301/my-intranet-app/main.py#L2143) -- project status save path: [main.py](/home/b17301/my-intranet-app/main.py#L3323) - -Effect: - -- less dependence on large JSON blobs for active reads -- cleaner future path for per-section editing -- safer long-term maintainability - -## Backward Compatibility - -Legacy JSON columns are still kept in `project_status` for compatibility and rollback safety. - -Current behavior: - -- reads prefer normalized child rows -- if child rows do not exist, legacy JSON/scalar fallback still works -- writes update both: - - scalar summary fields - - legacy JSON cache - - normalized child rows - -This avoids data loss during migration. - -## Remaining Structural Risk - -The biggest remaining architectural limitation is: - -- `project_status` still acts as one large parent record for many independently editable sections - -So while row collections are normalized now, parent-level fields such as: - -- project type -- expected rates -- contract amount -- dates -- notes - -still live together in one row and one update flow. - -This is acceptable for now, but if many users edit the same project simultaneously, the next best improvement would be: - -1. split edit APIs by section -2. add per-section revision tracking -3. optionally move more parent fields into section-specific tables - -## Recommendation - -Current DB can continue operating efficiently with the applied changes. - -Recommended next step if the app keeps expanding: - -- introduce section-level save endpoints for - - collection - - task plan - - exec budget - - actual input - - project metadata - -That would reduce cross-section write conflicts even more. diff --git a/WORK_SUMMARY_20260408.md b/WORK_SUMMARY_20260408.md deleted file mode 100644 index de2a715..0000000 --- a/WORK_SUMMARY_20260408.md +++ /dev/null @@ -1,23 +0,0 @@ -# 작업 요약 - -작업일: 2026-04-08 - -## 핵심 변경 - -- 프로젝트 검색 성능 저하 구간을 줄이기 위해 검색 목록 렌더 흐름을 정리하고 표시 개수를 제한했습니다. -- 프로젝트 정보 페이지의 상세 레이아웃을 재구성해 상단 정보 카드의 중첩 박스를 제거하고 주요 지표 배치를 정리했습니다. -- 계획 대비 실제 비교에서 인건비, 외주비, 제경비, A/S비, 판관비 세부 로직과 실제 집행 합산 기준을 여러 차례 보정했습니다. -- 실투입 관리, 실행예산계획, 과업수행계획 입력 UI와 저장 구조를 정리했습니다. -- 대시보드 상단을 재구성해 사업현황 요약과 수금/지출 구성 그래프를 다시 배치했습니다. -- 프로젝트 페이지 상태 저장은 버튼 클릭 시 DB에 저장되도록 연결했습니다. - -## 주요 파일 - -- `main.py` -- `templates/base.html` -- `templates/dashboard.html` -- `templates/projects.html` - -## 참고 - -- 템플릿 백업은 `template_backups/20260408_ko/`에 생성했습니다. diff --git a/WORK_SUMMARY_20260409.md b/WORK_SUMMARY_20260409.md deleted file mode 100644 index ba9cb58..0000000 --- a/WORK_SUMMARY_20260409.md +++ /dev/null @@ -1,50 +0,0 @@ -# 작업 요약 2026-04-09 - -## 이번 반영 범위 - -- 프로젝트 정보 페이지 안정화 - - 저장 후 프로젝트 정보 화면이 비거나 모달이 갑자기 닫히는 문제 구조 개선 - - 프로젝트 검색/상세/미계약 비용 발생 현황의 데이터 흐름 및 자동 최신화 충돌 완화 - - 바로가기, 연관 프로젝트, 세부 내역 정렬/표시 개선 - -- 사업현황 추가/수정 저장 구조 보강 - - 프로젝트 저장을 DB 기준으로 즉시 반영되도록 보강 - - 수금정보 분류값 정리 - - 기성구분: `선급금 / 기성금 / 준공금` - - 청구구분: `계약분 / 기타` - - 실행예산/실투입/예상 배분 설정 연계 보강 - -- 계약/청구/변경계약 데이터 반영 - - 계약현황, 기성청구현황, 변경계약금액현황(총괄/차수) 파일을 DB에 반영 - - 변경차수/보완/연계 프로젝트 자동 연결 로직 보강 - - 본계약과 연결 가능한 건은 상세 페이지와 연관 프로젝트 태그/집행내역에 합산 반영 - -- DB 구조 및 안정성 개선 - - 설정성 하드코딩 일부를 DB 설정 테이블로 이동 - - 프로젝트 입력 데이터의 섹션 분리 구조 확장 - - 건강 점검 문서 추가: `DB_HEALTH_CHECK_20260409.md` - -- 대시보드 / 연도별 수익·비용 UI 개선 - - 대시보드 카드/그래프 구조 정리 - - 연도별 수익/비용 그래프 크기, 라벨, 축, 카드 활용도 개선 - - 페이지 공통 여백 구조 정리 - -## 주요 수정 파일 - -- `main.py` -- `templates/projects.html` -- `templates/annual_summary.html` -- `templates/base.html` -- `templates/dashboard.html` -- `templates/index.html` -- `data.db` - -## 참고 데이터 파일 - -- `변경계약금액현황(회계)_총괄_20210101_20260409_260409.xlsx` -- `변경계약금액현황(회계)_차수_20210101_20260409_260409.xlsx` - -## 비고 - -- SQLite 기반 운영은 현재 데이터 규모에서는 가능하지만, 동시 작업과 화면 상태 저장은 계속 점검이 필요함 -- `data.db-wal`, `data.db-shm` 같은 런타임 임시 파일은 커밋 대상에서 제외함 diff --git a/backup_snapshots/20260410_0900/DB_HEALTH_CHECK_20260409.md b/backup_snapshots/20260410_0900/DB_HEALTH_CHECK_20260409.md deleted file mode 100644 index 1bc99af..0000000 --- a/backup_snapshots/20260410_0900/DB_HEALTH_CHECK_20260409.md +++ /dev/null @@ -1,169 +0,0 @@ -# DB Health Check 2026-04-09 - -## Summary - -- Current SQLite size and row counts are still within a manageable range for this app. -- Main medium-term risks were: - - concurrent writes causing `database is locked` - - all users sharing one `project_page_state` row - - growing scan cost around billing-link and project-status entry access -- These were addressed without data loss. - -## Current Scale - -- `transactions`: about 49k rows -- `project_contract_info`: 760 rows -- `project_billing_entries`: 1,774 rows -- `project_related_links`: 1,276 rows -- `project_status`: few rows now, but each row can contain many logical sub-entries - -## Improvements Applied - -### 1. SQLite concurrency and durability tuning - -Applied on every DB connection in [main.py](/home/b17301/my-intranet-app/main.py#L39): - -- `PRAGMA journal_mode=WAL` -- `PRAGMA synchronous=NORMAL` -- `PRAGMA foreign_keys=ON` -- `PRAGMA busy_timeout=5000` -- `PRAGMA temp_store=MEMORY` - -Effect: - -- better concurrent read/write behavior -- lower chance of write collisions during multi-user editing -- safer long-running usage than SQLite defaults - -### 2. Added indexes for growth hotspots - -Added in [main.py](/home/b17301/my-intranet-app/main.py#L119): - -- `transactions` - - `idx_transactions_support_category` - - `idx_transactions_support_account` - - `idx_transactions_source_file` - - `idx_transactions_updated_at` -- `project_billing_entries` - - `idx_project_billing_entries_raw_code` - - `idx_project_billing_entries_round_code` - - `idx_project_billing_entries_source_file` - - `idx_project_billing_entries_updated_at` -- `project_related_links` - - `idx_project_related_links_related` -- `project_page_state` - - `idx_project_page_state_page_session` - -Effect: - -- faster changed-round grouping -- better support/account based project aggregations -- faster source-file based reimport paths -- safer scaling as billing and transaction history grows - -### 3. Page state changed from shared row to session-scoped rows - -The old structure used one shared record: - -- `project_page_state(page_key PRIMARY KEY, ...)` - -This meant different users could overwrite each other's selected project, year, and open/closed detail state. - -It now migrates to: - -- `project_page_state(page_key, session_id, ...)` -- primary key: `(page_key, session_id)` - -Relevant code: - -- schema migration: [main.py](/home/b17301/my-intranet-app/main.py#L264) -- load/save logic: [main.py](/home/b17301/my-intranet-app/main.py#L1970) -- API: [main.py](/home/b17301/my-intranet-app/main.py#L3473) -- browser session id: [templates/base.html](/home/b17301/my-intranet-app/templates/base.html#L538) -- client page-state save/load: [templates/projects.html](/home/b17301/my-intranet-app/templates/projects.html#L4880) - -Effect: - -- browser A and browser B no longer fight over one shared project-search state - -### 4. Project status JSON blobs normalized into child tables - -Previously, logical row collections were stored only inside JSON columns in `project_status`: - -- `collection_entries_json` -- `task_plan_entries_json` -- `exec_budget_entries_json` -- `actual_input_entries_json` - -This has now been normalized into child tables: - -- `project_collection_entries` -- `project_task_plan_entries` -- `project_exec_budget_entries` -- `project_actual_input_entries` - -Relevant code: - -- row extraction and migration helpers: [main.py](/home/b17301/my-intranet-app/main.py#L987) -- child-table schema: [main.py](/home/b17301/my-intranet-app/main.py#L273) -- migration from legacy JSON: [main.py](/home/b17301/my-intranet-app/main.py#L1201) -- project status read paths: [main.py](/home/b17301/my-intranet-app/main.py#L2143) -- project status save path: [main.py](/home/b17301/my-intranet-app/main.py#L3323) - -Effect: - -- less dependence on large JSON blobs for active reads -- cleaner future path for per-section editing -- safer long-term maintainability - -## Backward Compatibility - -Legacy JSON columns are still kept in `project_status` for compatibility and rollback safety. - -Current behavior: - -- reads prefer normalized child rows -- if child rows do not exist, legacy JSON/scalar fallback still works -- writes update both: - - scalar summary fields - - legacy JSON cache - - normalized child rows - -This avoids data loss during migration. - -## Remaining Structural Risk - -The biggest remaining architectural limitation is: - -- `project_status` still acts as one large parent record for many independently editable sections - -So while row collections are normalized now, parent-level fields such as: - -- project type -- expected rates -- contract amount -- dates -- notes - -still live together in one row and one update flow. - -This is acceptable for now, but if many users edit the same project simultaneously, the next best improvement would be: - -1. split edit APIs by section -2. add per-section revision tracking -3. optionally move more parent fields into section-specific tables - -## Recommendation - -Current DB can continue operating efficiently with the applied changes. - -Recommended next step if the app keeps expanding: - -- introduce section-level save endpoints for - - collection - - task plan - - exec budget - - actual input - - project metadata - -That would reduce cross-section write conflicts even more. diff --git a/backup_snapshots/20260410_0900/WORK_SUMMARY_20260408.md b/backup_snapshots/20260410_0900/WORK_SUMMARY_20260408.md deleted file mode 100644 index de2a715..0000000 --- a/backup_snapshots/20260410_0900/WORK_SUMMARY_20260408.md +++ /dev/null @@ -1,23 +0,0 @@ -# 작업 요약 - -작업일: 2026-04-08 - -## 핵심 변경 - -- 프로젝트 검색 성능 저하 구간을 줄이기 위해 검색 목록 렌더 흐름을 정리하고 표시 개수를 제한했습니다. -- 프로젝트 정보 페이지의 상세 레이아웃을 재구성해 상단 정보 카드의 중첩 박스를 제거하고 주요 지표 배치를 정리했습니다. -- 계획 대비 실제 비교에서 인건비, 외주비, 제경비, A/S비, 판관비 세부 로직과 실제 집행 합산 기준을 여러 차례 보정했습니다. -- 실투입 관리, 실행예산계획, 과업수행계획 입력 UI와 저장 구조를 정리했습니다. -- 대시보드 상단을 재구성해 사업현황 요약과 수금/지출 구성 그래프를 다시 배치했습니다. -- 프로젝트 페이지 상태 저장은 버튼 클릭 시 DB에 저장되도록 연결했습니다. - -## 주요 파일 - -- `main.py` -- `templates/base.html` -- `templates/dashboard.html` -- `templates/projects.html` - -## 참고 - -- 템플릿 백업은 `template_backups/20260408_ko/`에 생성했습니다. diff --git a/backup_snapshots/20260410_0900/WORK_SUMMARY_20260409.md b/backup_snapshots/20260410_0900/WORK_SUMMARY_20260409.md deleted file mode 100644 index ba9cb58..0000000 --- a/backup_snapshots/20260410_0900/WORK_SUMMARY_20260409.md +++ /dev/null @@ -1,50 +0,0 @@ -# 작업 요약 2026-04-09 - -## 이번 반영 범위 - -- 프로젝트 정보 페이지 안정화 - - 저장 후 프로젝트 정보 화면이 비거나 모달이 갑자기 닫히는 문제 구조 개선 - - 프로젝트 검색/상세/미계약 비용 발생 현황의 데이터 흐름 및 자동 최신화 충돌 완화 - - 바로가기, 연관 프로젝트, 세부 내역 정렬/표시 개선 - -- 사업현황 추가/수정 저장 구조 보강 - - 프로젝트 저장을 DB 기준으로 즉시 반영되도록 보강 - - 수금정보 분류값 정리 - - 기성구분: `선급금 / 기성금 / 준공금` - - 청구구분: `계약분 / 기타` - - 실행예산/실투입/예상 배분 설정 연계 보강 - -- 계약/청구/변경계약 데이터 반영 - - 계약현황, 기성청구현황, 변경계약금액현황(총괄/차수) 파일을 DB에 반영 - - 변경차수/보완/연계 프로젝트 자동 연결 로직 보강 - - 본계약과 연결 가능한 건은 상세 페이지와 연관 프로젝트 태그/집행내역에 합산 반영 - -- DB 구조 및 안정성 개선 - - 설정성 하드코딩 일부를 DB 설정 테이블로 이동 - - 프로젝트 입력 데이터의 섹션 분리 구조 확장 - - 건강 점검 문서 추가: `DB_HEALTH_CHECK_20260409.md` - -- 대시보드 / 연도별 수익·비용 UI 개선 - - 대시보드 카드/그래프 구조 정리 - - 연도별 수익/비용 그래프 크기, 라벨, 축, 카드 활용도 개선 - - 페이지 공통 여백 구조 정리 - -## 주요 수정 파일 - -- `main.py` -- `templates/projects.html` -- `templates/annual_summary.html` -- `templates/base.html` -- `templates/dashboard.html` -- `templates/index.html` -- `data.db` - -## 참고 데이터 파일 - -- `변경계약금액현황(회계)_총괄_20210101_20260409_260409.xlsx` -- `변경계약금액현황(회계)_차수_20210101_20260409_260409.xlsx` - -## 비고 - -- SQLite 기반 운영은 현재 데이터 규모에서는 가능하지만, 동시 작업과 화면 상태 저장은 계속 점검이 필요함 -- `data.db-wal`, `data.db-shm` 같은 런타임 임시 파일은 커밋 대상에서 제외함 diff --git a/main.py b/main.py index 30df8ac..936421f 100644 --- a/main.py +++ b/main.py @@ -17,13 +17,18 @@ import time import tempfile import uuid import zipfile +from contextlib import nullcontext +from http.cookiejar import CookieJar from io import BytesIO from datetime import date, datetime, timedelta, timezone from decimal import Decimal, InvalidOperation, ROUND_HALF_UP +from difflib import SequenceMatcher from functools import lru_cache +from html.parser import HTMLParser from pathlib import Path from typing import Any, Mapping, Sequence -from urllib.parse import parse_qs, quote_plus, unquote_plus +from urllib.parse import parse_qs, quote_plus, unquote_plus, urlencode, urljoin, urlparse +from urllib.request import HTTPCookieProcessor, Request as UrlRequest, build_opener import uvicorn from fastapi import FastAPI, File, Request, UploadFile @@ -50,7 +55,10 @@ from runtime_config import ( ) from wehago_compare import ( QUERY_PROJECTION_VERSION, + _active_status_projection_run_matches_current_sources, _clear_compare_runtime_caches, + _discover_available_fiscal_years, + _get_compare_snapshot_state, _group_has_offset_tax_invoice_structure, _group_has_tax_invoice_cancel_signal, _load_hanmac_unconnected_source_groups, @@ -75,6 +83,7 @@ from wehago_compare import ( get_wehago_filtered_rows, import_uploaded_erp_voucher_file, init_wehago_compare_db, + ensure_wehago_canonical_projection_state, load_bridge_review_settings, request_compare_snapshot_rebuild, request_status_export_xlsx, @@ -194,7 +203,16 @@ 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-v13-cumulative-profit-rate" +_COST_ANALYSIS_LINK_MAP_CACHE_LOCK = threading.Lock() +_COST_ANALYSIS_LINK_MAP_CACHE: dict[tuple[Any, ...], dict[str, Any]] = {} +COST_ANALYSIS_LINK_MAP_CACHE_TTL_SECONDS = 6 * 60 * 60.0 +_COST_ANALYSIS_DETAIL_CACHE_LOCK = threading.Lock() +_COST_ANALYSIS_DETAIL_CACHE: dict[tuple[Any, ...], dict[str, Any]] = {} +COST_ANALYSIS_DETAIL_CACHE_TTL_SECONDS = 10 * 60.0 +COST_ANALYSIS_FINANCIAL_LOGIC_VERSION = "cost-analysis-financial-v44-benefit-rnd-display" +COST_ANALYSIS_LINK_LOGIC_VERSION = "cost-analysis-link-v5-satis-display-names" +COST_ANALYSIS_H_PROJECT_MAPPING_VERSION = "h-code-confirmed-project-map-v1" +COST_ANALYSIS_HANMAC_AGGREGATE_SCHEMA = "cost-analysis-period-v28-benefit-rnd-display" app = FastAPI() app.add_middleware(GZipMiddleware, minimum_size=1200, compresslevel=5) @@ -1341,6 +1359,20 @@ def upsert_admin_user(payload: dict[str, Any]) -> None: ) +@app.middleware("http") +async def hmbiz_process_static_no_cache_middleware(request: Request, call_next): + response = await call_next(request) + if request.url.path.startswith("/static/hm-biz-process/"): + response.headers["Cache-Control"] = "no-store, max-age=0" + response.headers["Pragma"] = "no-cache" + response.headers["Expires"] = "0" + if request.url.path == "/hanmac-browser": + response.headers["Cache-Control"] = "no-store, max-age=0" + response.headers["Pragma"] = "no-cache" + response.headers["Expires"] = "0" + return response + + @app.middleware("http") async def auth_middleware(request: Request, call_next): path = request.url.path @@ -1559,6 +1591,13 @@ def _load_projection_group_counts( *, signature_like: str | None = None, ) -> tuple[dict[str, int], int]: + def is_derived_projection_signature(value: Any) -> bool: + normalized = normalize_text(value) + return ( + "snapshot-recheck-promote" in normalized + or "db-reconciled-" in normalized + ) + active_signature = "" if signature_like and signature_like.startswith(f"{QUERY_PROJECTION_VERSION}|"): try: @@ -1577,8 +1616,8 @@ def _load_projection_group_counts( active_signature = normalize_text(active_payload.get("signature")) except Exception: active_signature = "" - if not active_signature.startswith(f"{QUERY_PROJECTION_VERSION}|"): - return {}, 0 + if not active_signature.startswith(f"{QUERY_PROJECTION_VERSION}|") or is_derived_projection_signature(active_signature): + active_signature = "" where = [ "start_year <= ?", "end_year >= ?", @@ -1597,6 +1636,8 @@ def _load_projection_group_counts( MAX(updated_at) AS max_updated_at FROM wehago_compare_query_groups WHERE {' AND '.join(where)} + AND signature NOT LIKE '%|snapshot-recheck-promote|%' + AND signature NOT LIKE '%|db-reconciled-%' GROUP BY start_year, end_year, signature ORDER BY CASE WHEN start_year = ? AND end_year = ? THEN 0 ELSE 1 END ASC, @@ -1629,6 +1670,118 @@ def _load_projection_group_counts( return counts, int(scope_row["status_count"] or 0) +def _apply_active_status_projection_card_counts( + conn: sqlite3.Connection, + metric_sections: list[dict[str, Any]], + start_year: int, + end_year: int, +) -> list[dict[str, Any]]: + status_keys = { + "hanmac_unconnected", + "erp_voucher_matched", + "erp_voucher_unmatched", + } + setting_keys = { + f"wehago_active_status_projection:{status_key}:{int(start_year)}:{int(end_year)}": status_key + for status_key in status_keys + } + if not setting_keys: + return metric_sections + placeholders = ", ".join("?" for _key in setting_keys) + rows = conn.execute( + f""" + SELECT setting_key, setting_json + FROM wehago_compare_settings + WHERE setting_key IN ({placeholders}) + """, + tuple(setting_keys.keys()), + ).fetchall() + active_by_status: dict[str, dict[str, Any]] = {} + for row in rows: + status_key = setting_keys.get(str(row["setting_key"] or "")) + if not status_key: + continue + try: + payload = json.loads(str(row["setting_json"] or "{}")) + except Exception: + continue + if isinstance(payload, dict): + active_by_status[status_key] = payload + missing_statuses = {status_key for status_key in status_keys if status_key not in active_by_status} + if missing_statuses: + try: + query_start, query_end, query_signature = ( + _latest_year_query_source(conn, int(start_year)) + if int(start_year) == int(end_year) + else (int(start_year), int(end_year), "") + ) + placeholders = ", ".join("?" for _status in missing_statuses) + params: tuple[Any, ...] + if query_signature: + where_scope = "start_year = ? AND end_year = ? AND signature = ?" + params = ( + int(query_start), + int(query_end), + query_signature, + int(start_year), + int(end_year), + *tuple(sorted(missing_statuses)), + ) + else: + where_scope = "fiscal_year BETWEEN ? AND ? AND signature LIKE ?" + params = ( + int(start_year), + int(end_year), + f"{QUERY_PROJECTION_VERSION}|%", + int(start_year), + int(end_year), + *tuple(sorted(missing_statuses)), + ) + for row in conn.execute( + f""" + SELECT status_key, COUNT(*) AS total_count, MAX(signature) AS signature + FROM wehago_compare_query_groups + WHERE {where_scope} + AND fiscal_year BETWEEN ? AND ? + AND status_key IN ({placeholders}) + GROUP BY status_key + """, + params, + ).fetchall(): + status_key = str(row["status_key"] or "") + if status_key: + active_by_status[status_key] = { + "total_count": int(row["total_count"] or 0), + "signature": str(row["signature"] or ""), + } + except Exception: + pass + if int((active_by_status.get("hanmac_unconnected") or {}).get("total_count") or 0) <= 0: + try: + active_by_status["hanmac_unconnected"] = { + "total_count": len( + _load_hanmac_unconnected_source_groups( + conn, + int(start_year), + int(end_year), + ) + ), + "signature": str((active_by_status.get("hanmac_unconnected") or {}).get("signature") or ""), + } + except Exception: + pass + for section in metric_sections: + status_key = str(section.get("key") or "") + payload = active_by_status.get(status_key) + if not payload: + continue + section["count"] = int(payload.get("total_count") or section.get("count") or 0) + section["group_count"] = int(payload.get("total_count") or section.get("group_count") or section["count"] or 0) + section["projection_signature"] = str(payload.get("signature") or section.get("projection_signature") or "") + section["count_unit"] = "ERP 전표" if status_key == "hanmac_unconnected" else "전표그룹" + return metric_sections + + def _fast_wehago_compare_summary_payload( start_year: int | None = None, end_year: int | None = None, @@ -1669,6 +1822,97 @@ def _fast_wehago_compare_summary_payload( if start_year and end_year and start_year > end_year: start_year, end_year = end_year, start_year + active_run_summary = None + if active_run_summary: + snapshot_state = {"ready": [], "stale": [], "missing": [], "queued": [], "running": [], "failed": []} + status_rows = { + int(row["fiscal_year"]): str(row["state"] or "") + for row in conn.execute( + """ + SELECT fiscal_year, state + FROM wehago_snapshot_status + WHERE fiscal_year BETWEEN ? AND ? + """, + (int(start_year or 0), int(end_year or 0)), + ).fetchall() + if int(row["fiscal_year"] or 0) > 0 + } + for year in range(int(start_year or 0), int(end_year or 0) + 1): + state = status_rows.get(year, "missing") + snapshot_state.setdefault(state, []) + snapshot_state[state].append(year) + last_action = None + last_action_row = conn.execute( + """ + SELECT id, action_type, payload_json, created_at + FROM wehago_action_history + ORDER BY id DESC + LIMIT 1 + """ + ).fetchone() + if last_action_row: + try: + action_payload = json.loads(last_action_row["payload_json"] or "{}") + except Exception: + action_payload = {} + last_action = { + "id": int(last_action_row["id"] or 0), + "action_type": str(last_action_row["action_type"] or ""), + "count": int(action_payload.get("count") or 0), + "created_at": str(last_action_row["created_at"] or ""), + } + counts = _empty_compare_metric_counts() + metric_sections = [ + { + "key": status_key, + "label": label, + "description": description, + "count": int(counts.get(status_key, 0) or 0), + "columns": [], + "rows": [], + } + for status_key, label, description in ( + ("matched", "Matched", ""), + ("ledger_only", "Unmatched", ""), + ("voucher_only", "ERP Unmatched", ""), + ("amount_mismatch", "Recheck", ""), + ("voucher_matched", "WEHAGO Voucher", ""), + ("voucher_unmatched", "WEHAGO Unmatched", ""), + ("voucher_recheck", "WEHAGO Recheck", ""), + ("voucher_excepted", "WEHAGO Excepted", ""), + ("hanmac_unconnected", "Hanmac unconnected", ""), + ("erp_voucher_matched", "HANMAC Voucher", ""), + ("erp_voucher_unmatched", "HANMAC Unmatched", ""), + ("bridge_expense_review", "2단계 비교", ""), + ) + ] + metric_sections = apply_wehago_final_status_counts_to_metric_sections(metric_sections, active_run_summary) + metric_sections = _apply_active_status_projection_card_counts( + conn, + metric_sections, + int(start_year or 0), + int(end_year or 0), + ) + return { + "selected_start_year": start_year, + "selected_end_year": end_year, + "metric_sections": metric_sections, + "wehago_final_status_summary": active_run_summary, + "last_action": last_action, + "pending": False, + "snapshot_state": snapshot_state, + "snapshot_policy": {"available_years": available_years}, + "snapshot_aggregate": { + "ready_count": len(snapshot_state["ready"]), + "stale_count": len(snapshot_state["stale"]), + "missing_count": len(snapshot_state["missing"]), + "queued_count": len(snapshot_state["queued"]), + "running_count": len(snapshot_state["running"]), + "failed_count": len(snapshot_state["failed"]), + }, + "snapshot_status_payload": None, + } + counts = _empty_compare_metric_counts() current_group_counts, projection_status_count = _load_projection_group_counts( conn, @@ -1686,14 +1930,15 @@ 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) - 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 int(counts.get("hanmac_unconnected", 0) or 0) <= 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( """ @@ -1742,50 +1987,6 @@ def _fast_wehago_compare_summary_payload( if status_key in counts: counts[status_key] = int(row[1] or 0) - if projection_status_count < 5: - cached_summary = conn.execute( - """ - SELECT payload_json - FROM wehago_summary_range_cache - WHERE start_year = ? - AND end_year = ? - ORDER BY updated_at DESC, created_at DESC - LIMIT 1 - """, - (int(start_year or 0), int(end_year or 0)), - ).fetchone() - if cached_summary and cached_summary["payload_json"]: - try: - cached_payload = json.loads(str(cached_summary["payload_json"])) - cached_counts = cached_payload.get("counts") if isinstance(cached_payload, dict) else None - if isinstance(cached_counts, dict): - for status_key in counts: - counts[status_key] = int(cached_counts.get(status_key, counts[status_key]) or 0) - except Exception: - pass - - cached_summary = conn.execute( - """ - SELECT payload_json - FROM wehago_summary_range_cache - WHERE start_year = ? - AND end_year = ? - ORDER BY updated_at DESC, created_at DESC - LIMIT 1 - """, - (int(start_year or 0), int(end_year or 0)), - ).fetchone() - if cached_summary and cached_summary["payload_json"]: - try: - cached_payload = json.loads(str(cached_summary["payload_json"])) - cached_counts = cached_payload.get("counts") if isinstance(cached_payload, dict) else None - if isinstance(cached_counts, dict): - for status_key in ("matched", "ledger_only", "voucher_only", "amount_mismatch", "bridge_expense_review"): - if int(counts.get(status_key, 0) or 0) == 0: - counts[status_key] = int(cached_counts.get(status_key, 0) or 0) - except Exception: - pass - if any(int(counts.get(status_key, 0) or 0) for status_key in ("voucher_matched", "erp_voucher_matched", "voucher_unmatched", "erp_voucher_unmatched", "voucher_recheck", "hanmac_unconnected")): for status_key in ("matched", "ledger_only", "voucher_only", "amount_mismatch"): if int(counts.get(status_key, 0) or 0) != 0: @@ -1801,37 +2002,30 @@ 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, + count_pending = False + if not has_current_projection_counts: + 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: + resolved_value = int(resolved_counts.get(status_key, 0) or 0) + if resolved_value or int(counts.get(status_key, 0) or 0) == 0: + counts[status_key] = resolved_value + except Exception: + count_pending = False + + with engine.begin() as sqlalchemy_conn: + snapshot_state = _get_compare_snapshot_state( + sqlalchemy_conn, 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 "") - for row in conn.execute( - """ - SELECT fiscal_year, state - FROM wehago_snapshot_status - WHERE fiscal_year BETWEEN ? AND ? - """, - (int(start_year or 0), int(end_year or 0)), - ).fetchall() - if int(row["fiscal_year"] or 0) > 0 - } - for year in range(int(start_year or 0), int(end_year or 0) + 1): - state = status_rows.get(year, "missing") - 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) + pending = pending or (bool(count_pending) and not has_current_projection_counts) last_action = None last_action_row = conn.execute( @@ -1879,12 +2073,24 @@ def _fast_wehago_compare_summary_payload( ) ] with engine.begin() as sqlalchemy_conn: + projection_repair = ensure_wehago_canonical_projection_state( + sqlalchemy_conn, + start_year, + end_year, + repair=True, + ) final_status_summary = get_wehago_final_status_summary_from_conn( sqlalchemy_conn, start_year, end_year, counts, ) + if projection_repair.get("needs_query_projection_rebuild"): + pending = True + if str(final_status_summary.get("source") or "") == "active_status_projection_missing": + pending = True + if projection_repair.get("repaired"): + final_status_summary["auto_repair"] = projection_repair metric_sections = apply_wehago_final_status_counts_to_metric_sections(metric_sections, final_status_summary) return { "selected_start_year": start_year, @@ -2803,6 +3009,9 @@ def init_db() -> None: dept_name TEXT DEFAULT '', work_name TEXT DEFAULT '', amount REAL DEFAULT 0, + source_support_dept_code TEXT DEFAULT '', + source_project_code TEXT DEFAULT '', + source_revision_id INTEGER DEFAULT 0, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ) """ @@ -2832,6 +3041,9 @@ def init_db() -> None: account_code TEXT DEFAULT '', account_name TEXT DEFAULT '', amount REAL DEFAULT 0, + source_support_dept_code TEXT DEFAULT '', + source_project_code TEXT DEFAULT '', + source_revision_id INTEGER DEFAULT 0, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ) """ @@ -2845,6 +3057,283 @@ def init_db() -> None: """ ) ) + conn.execute( + text( + """ + CREATE TABLE IF NOT EXISTS satis_project_mapping ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + erp_project_code TEXT NOT NULL, + erp_project_name TEXT DEFAULT '', + support_dept_code TEXT DEFAULT '', + mapping_status TEXT DEFAULT 'pending', + mapping_basis TEXT DEFAULT '', + manual_override INTEGER DEFAULT 0, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + UNIQUE (erp_project_code) + ) + """ + ) + ) + conn.execute( + text( + """ + CREATE INDEX IF NOT EXISTS idx_satis_project_mapping_support_code + ON satis_project_mapping (support_dept_code) + """ + ) + ) + conn.execute( + text( + """ + CREATE TABLE IF NOT EXISTS satis_project_code_links ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + local_project_code TEXT NOT NULL, + local_project_name TEXT DEFAULT '', + project_kind TEXT DEFAULT '', + own_master_project_code TEXT DEFAULT '', + own_master_project_name TEXT DEFAULT '', + linked_main_project_code TEXT DEFAULT '', + linked_main_project_name TEXT DEFAULT '', + cost_project_code TEXT DEFAULT '', + cost_project_name TEXT DEFAULT '', + cost_kind TEXT DEFAULT '', + pm_department_name TEXT DEFAULT '', + is_joint_project TEXT DEFAULT '', + is_tax_exempt TEXT DEFAULT '', + is_active INTEGER DEFAULT 1, + mapping_status TEXT DEFAULT 'confirmed', + mapping_source TEXT DEFAULT '', + source_file TEXT DEFAULT '', + raw_payload_json TEXT DEFAULT '{}', + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + UNIQUE (local_project_code) + ) + """ + ) + ) + conn.execute( + text( + """ + CREATE INDEX IF NOT EXISTS idx_satis_project_code_links_own_master + ON satis_project_code_links (own_master_project_code) + """ + ) + ) + conn.execute( + text( + """ + CREATE INDEX IF NOT EXISTS idx_satis_project_code_links_linked_main + ON satis_project_code_links (linked_main_project_code) + """ + ) + ) + conn.execute( + text( + """ + CREATE INDEX IF NOT EXISTS idx_satis_project_code_links_cost_code + ON satis_project_code_links (cost_project_code) + """ + ) + ) + conn.execute( + text( + """ + CREATE TABLE IF NOT EXISTS satis_project_budget_revisions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + source_system TEXT DEFAULT 'satis', + project_code TEXT NOT NULL, + support_dept_code TEXT DEFAULT '', + project_name TEXT DEFAULT '', + budget_type TEXT NOT NULL, + revision_no TEXT DEFAULT '', + revision_name TEXT DEFAULT '', + approval_status TEXT DEFAULT '', + is_approved INTEGER DEFAULT 0, + is_latest INTEGER DEFAULT 0, + written_at TEXT DEFAULT '', + approved_at TEXT DEFAULT '', + source_updated_at TEXT DEFAULT '', + source_key TEXT NOT NULL, + source_hash TEXT DEFAULT '', + raw_payload_json TEXT DEFAULT '', + synced_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + UNIQUE (source_system, budget_type, source_key) + ) + """ + ) + ) + conn.execute( + text( + """ + CREATE INDEX IF NOT EXISTS idx_satis_project_budget_revisions_project + ON satis_project_budget_revisions (project_code, support_dept_code, budget_type, revision_no) + """ + ) + ) + conn.execute( + text( + """ + CREATE TABLE IF NOT EXISTS satis_project_task_plan_budget_lines ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + revision_id INTEGER NOT NULL, + line_no INTEGER NOT NULL DEFAULT 0, + group_name TEXT DEFAULT '', + dept_code TEXT DEFAULT '', + dept_name TEXT DEFAULT '', + work_code TEXT DEFAULT '', + work_name TEXT DEFAULT '', + amount REAL DEFAULT 0, + currency TEXT DEFAULT 'KRW', + source_line_key TEXT DEFAULT NULL, + raw_payload_json TEXT DEFAULT '', + synced_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + UNIQUE (revision_id, source_line_key), + FOREIGN KEY (revision_id) REFERENCES satis_project_budget_revisions(id) ON DELETE CASCADE + ) + """ + ) + ) + conn.execute( + text( + """ + CREATE INDEX IF NOT EXISTS idx_satis_task_plan_budget_lines_revision + ON satis_project_task_plan_budget_lines (revision_id, line_no) + """ + ) + ) + conn.execute( + text( + """ + CREATE TABLE IF NOT EXISTS satis_project_exec_budget_lines ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + revision_id INTEGER NOT NULL, + line_no INTEGER NOT NULL DEFAULT 0, + group_name TEXT DEFAULT '', + grade TEXT DEFAULT '', + hours TEXT DEFAULT '', + rate_year TEXT DEFAULT '', + unit_rate REAL DEFAULT 0, + dept_code TEXT DEFAULT '', + dept_name TEXT DEFAULT '', + work_code TEXT DEFAULT '', + work_name TEXT DEFAULT '', + account_code TEXT DEFAULT '', + account_name TEXT DEFAULT '', + amount REAL DEFAULT 0, + currency TEXT DEFAULT 'KRW', + source_line_key TEXT DEFAULT NULL, + raw_payload_json TEXT DEFAULT '', + synced_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + UNIQUE (revision_id, source_line_key), + FOREIGN KEY (revision_id) REFERENCES satis_project_budget_revisions(id) ON DELETE CASCADE + ) + """ + ) + ) + conn.execute( + text( + """ + CREATE INDEX IF NOT EXISTS idx_satis_exec_budget_lines_revision + ON satis_project_exec_budget_lines (revision_id, line_no) + """ + ) + ) + conn.execute( + text( + """ + CREATE TABLE IF NOT EXISTS satis_project_budget_raw_rows ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + source_system TEXT DEFAULT 'satis', + source_database TEXT NOT NULL, + source_table TEXT NOT NULL, + source_row_index INTEGER NOT NULL DEFAULT 0, + budget_type TEXT DEFAULT '', + project_code TEXT DEFAULT '', + project_name TEXT DEFAULT '', + revision_no TEXT DEFAULT '', + approval_status TEXT DEFAULT '', + amount_total REAL DEFAULT 0, + amount_values_json TEXT DEFAULT '', + inferred_columns_json TEXT DEFAULT '', + raw_payload_json TEXT DEFAULT '', + source_hash TEXT NOT NULL, + synced_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + UNIQUE (source_hash) + ) + """ + ) + ) + conn.execute( + text( + """ + CREATE INDEX IF NOT EXISTS idx_satis_project_budget_raw_rows_project + ON satis_project_budget_raw_rows (project_code, budget_type, source_database, source_table) + """ + ) + ) + conn.execute( + text( + """ + CREATE TABLE IF NOT EXISTS satis_project_budget_projection_status ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + support_dept_code TEXT NOT NULL, + project_code TEXT DEFAULT '', + project_name TEXT DEFAULT '', + budget_type TEXT NOT NULL, + revision_id INTEGER NOT NULL DEFAULT 0, + revision_no TEXT DEFAULT '', + approval_status TEXT DEFAULT '', + projection_mode TEXT NOT NULL DEFAULT 'approved_latest', + is_provisional INTEGER DEFAULT 0, + line_count INTEGER DEFAULT 0, + amount_total REAL DEFAULT 0, + note TEXT DEFAULT '', + projected_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + UNIQUE (support_dept_code, budget_type) + ) + """ + ) + ) + conn.execute( + text( + """ + CREATE INDEX IF NOT EXISTS idx_satis_budget_projection_status_revision + ON satis_project_budget_projection_status (revision_id, projection_mode) + """ + ) + ) + conn.execute( + text( + """ + CREATE TABLE IF NOT EXISTS satis_project_budget_web_captures ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + capture_key TEXT NOT NULL, + source_url TEXT NOT NULL, + request_method TEXT DEFAULT 'GET', + http_status INTEGER DEFAULT 0, + final_url TEXT DEFAULT '', + page_title TEXT DEFAULT '', + matched_keywords TEXT DEFAULT '', + internal_links_json TEXT DEFAULT '[]', + amount_candidates_json TEXT DEFAULT '[]', + body_preview TEXT DEFAULT '', + body_hash TEXT DEFAULT '', + captured_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + UNIQUE (capture_key) + ) + """ + ) + ) + conn.execute( + text( + """ + CREATE INDEX IF NOT EXISTS idx_satis_budget_web_captures_hash + ON satis_project_budget_web_captures (body_hash, captured_at) + """ + ) + ) conn.execute( text( """ @@ -3408,12 +3897,31 @@ def init_db() -> None: } if "rate_year" not in exec_budget_entry_columns: conn.execute(text("ALTER TABLE project_exec_budget_entries ADD COLUMN rate_year TEXT DEFAULT ''")) + for column_name, column_type in { + "source_support_dept_code": "TEXT DEFAULT ''", + "source_project_code": "TEXT DEFAULT ''", + "source_revision_id": "INTEGER DEFAULT 0", + }.items(): + if column_name not in exec_budget_entry_columns: + conn.execute(text(f"ALTER TABLE project_exec_budget_entries ADD COLUMN {column_name} {column_type}")) + task_plan_entry_columns = { + row[1] + for row in conn.execute(text("PRAGMA table_info(project_task_plan_entries)")).fetchall() + } + for column_name, column_type in { + "source_support_dept_code": "TEXT DEFAULT ''", + "source_project_code": "TEXT DEFAULT ''", + "source_revision_id": "INTEGER DEFAULT 0", + }.items(): + if column_name not in task_plan_entry_columns: + conn.execute(text(f"ALTER TABLE project_task_plan_entries ADD COLUMN {column_name} {column_type}")) actual_input_entry_columns = { row[1] for row in conn.execute(text("PRAGMA table_info(project_actual_input_entries)")).fetchall() } if "rate_year" not in actual_input_entry_columns: conn.execute(text("ALTER TABLE project_actual_input_entries ADD COLUMN rate_year TEXT DEFAULT ''")) + sync_satis_project_code_register(conn) migrate_project_status_entries(conn) migrate_project_basic_info(conn) ensure_auth_schema(conn) @@ -4166,10 +4674,20 @@ def import_billing_status_workbook(workbook: Any, source_file: str) -> int: current.get("raw_project_code"), default_prefix=round_prefix, ) + raw_common_tokens = { + normalize_text(current.get("round_code")).replace(" ", "").upper(), + normalize_text(current.get("raw_project_code")).replace(" ", "").upper(), + normalize_text(current.get("support_dept_name")).replace(" ", "").upper(), + } + is_common_billing = any( + token == "ZZZZZZ" or token in {"공통", "공통매출", "공통청구"} or token.startswith("공통매출") + for token in raw_common_tokens + if token + ) # Billing workbook stores the parent contract code in raw_project_code # and the actual charge/collection project code in round_code. # Prefer round_code when present so each sub-project keeps its own billing history. - support_dept_code = normalized_round_code or normalized_raw_project_code + support_dept_code = "ZZZZZZ" if is_common_billing else normalized_round_code or normalized_raw_project_code if not support_dept_code: continue @@ -4561,6 +5079,27 @@ def sync_auto_project_related_links() -> None: """ ) ).mappings().all() + satis_link_rows = conn.execute( + text( + """ + SELECT + local_project_code, + local_project_name, + own_master_project_code, + own_master_project_name, + linked_main_project_code, + linked_main_project_name, + cost_project_code, + cost_project_name, + mapping_status, + is_active + FROM satis_project_code_links + WHERE COALESCE(local_project_code, '') <> '' + AND COALESCE(mapping_status, '') IN ('confirmed', 'exception') + AND COALESCE(is_active, 1) = 1 + """ + ) + ).mappings().all() existing_codes = { normalize_text(row[0]) for row in conn.execute( @@ -4580,6 +5119,18 @@ def sync_auto_project_related_links() -> None: UNION SELECT DISTINCT support_dept_code FROM project_contract_change_round WHERE COALESCE(support_dept_code, '') <> '' + UNION + SELECT DISTINCT local_project_code FROM satis_project_code_links + WHERE COALESCE(local_project_code, '') <> '' + UNION + SELECT DISTINCT own_master_project_code FROM satis_project_code_links + WHERE COALESCE(own_master_project_code, '') <> '' + UNION + SELECT DISTINCT linked_main_project_code FROM satis_project_code_links + WHERE COALESCE(linked_main_project_code, '') <> '' + UNION + SELECT DISTINCT cost_project_code FROM satis_project_code_links + WHERE COALESCE(cost_project_code, '') <> '' """ ) ).fetchall() @@ -4607,6 +5158,22 @@ def sync_auto_project_related_links() -> None: SELECT support_dept_code, support_dept_name FROM project_contract_change_round WHERE COALESCE(support_dept_code, '') <> '' + UNION + SELECT local_project_code AS support_dept_code, local_project_name AS support_dept_name + FROM satis_project_code_links + WHERE COALESCE(local_project_code, '') <> '' + UNION + SELECT own_master_project_code AS support_dept_code, own_master_project_name AS support_dept_name + FROM satis_project_code_links + WHERE COALESCE(own_master_project_code, '') <> '' + UNION + SELECT linked_main_project_code AS support_dept_code, linked_main_project_name AS support_dept_name + FROM satis_project_code_links + WHERE COALESCE(linked_main_project_code, '') <> '' + UNION + SELECT cost_project_code AS support_dept_code, cost_project_name AS support_dept_name + FROM satis_project_code_links + WHERE COALESCE(cost_project_code, '') <> '' ) SELECT support_dept_code, support_dept_name FROM project_names @@ -4635,25 +5202,51 @@ def sync_auto_project_related_links() -> None: if normalize_text(row[0]) } - round_cluster_map: dict[str, set[str]] = {} cluster_map: dict[str, set[str]] = {} + for row in satis_link_rows: + local_code = normalize_text(row.get("local_project_code")).upper() + if not local_code: + continue + master_candidates = [ + normalize_text(row.get("linked_main_project_code")).upper(), + normalize_text(row.get("own_master_project_code")).upper(), + normalize_text(row.get("cost_project_code")).upper(), + ] + master_code = next( + ( + code + for code in master_candidates + if code and code != local_code and code[:1] in {"0", "9"} and code[1:].isdigit() + ), + "", + ) + if not master_code: + continue + existing_codes.update({local_code, master_code}) + project_names.setdefault(local_code, normalize_text(row.get("local_project_name"))) + project_names.setdefault( + master_code, + normalize_text(row.get("linked_main_project_name")) + or normalize_text(row.get("own_master_project_name")) + or normalize_text(row.get("cost_project_name")), + ) + cluster_map.setdefault(f"satis_code::{master_code}", set()).update({master_code, local_code}) + for row in billing_rows: base_code = normalize_text(row["support_dept_code"]) - raw_project_code = normalize_project_code( - row["raw_project_code"], - default_prefix=base_code[:1] or "Y", - ) or normalize_text(row["raw_project_code"]) + raw_project_code = normalize_actual_project_code(row["raw_project_code"]) round_code = normalize_project_code(row["round_code"], default_prefix=base_code[:1] or "Y") if not base_code: continue - cluster_key = raw_project_code or base_code - round_cluster = round_cluster_map.setdefault(cluster_key, set()) + is_total_code = raw_project_code[:1] in {"0", "9"} and raw_project_code.isdigit() + cluster_key = f"billing::{raw_project_code or base_code}" cluster = cluster_map.setdefault(cluster_key, set()) + if is_total_code: + existing_codes.add(raw_project_code) + cluster.add(raw_project_code) if base_code in existing_codes: - round_cluster.add(base_code) cluster.add(base_code) if round_code and round_code in existing_codes: - round_cluster.add(round_code) cluster.add(round_code) change_round_title_groups: dict[str, set[str]] = {} @@ -4795,6 +5388,10 @@ def sync_auto_project_related_links() -> None: link_source = "auto_change_contract" elif str(cluster_key).startswith("code_family::"): link_source = "auto_code_family" + elif str(cluster_key).startswith("billing::"): + link_source = "auto_billing" + elif str(cluster_key).startswith("satis_code::"): + link_source = "auto_satis_code" elif str(cluster_key).startswith("title_fuzzy::"): link_source = "auto_title_fuzzy" elif str(cluster_key).startswith("title::"): @@ -4821,8 +5418,15 @@ def sync_auto_project_related_links() -> None: ) ON CONFLICT(base_support_dept_code, related_support_dept_code) DO UPDATE SET link_source = CASE + WHEN COALESCE(project_related_links.link_source, 'manual') = 'manual' + THEN COALESCE(project_related_links.link_source, 'manual') + WHEN excluded.link_source = 'manual' THEN excluded.link_source + WHEN project_related_links.link_source = 'auto_satis_code' THEN project_related_links.link_source + WHEN excluded.link_source = 'auto_satis_code' THEN excluded.link_source WHEN project_related_links.link_source = 'auto_code_family' THEN project_related_links.link_source WHEN excluded.link_source = 'auto_code_family' THEN excluded.link_source + WHEN project_related_links.link_source = 'auto_billing' THEN project_related_links.link_source + WHEN excluded.link_source = 'auto_billing' THEN excluded.link_source ELSE excluded.link_source END, updated_at = CURRENT_TIMESTAMP @@ -4925,44 +5529,6 @@ def sync_auto_project_related_links() -> None: }, ) - for cluster_codes in round_cluster_map.values(): - normalized_cluster = sorted(code for code in cluster_codes if code in existing_codes) - if len(normalized_cluster) < 2: - continue - for base_code in normalized_cluster: - for related_code in normalized_cluster: - if base_code == related_code: - continue - conn.execute( - text( - """ - INSERT INTO project_related_links ( - base_support_dept_code, - related_support_dept_code, - link_source, - updated_at - ) VALUES ( - :base_support_dept_code, - :related_support_dept_code, - 'auto_round', - CURRENT_TIMESTAMP - ) - ON CONFLICT(base_support_dept_code, related_support_dept_code) DO UPDATE SET - link_source = CASE - WHEN project_related_links.link_source = 'auto_code_family' THEN project_related_links.link_source - WHEN excluded.link_source = 'auto_code_family' THEN excluded.link_source - ELSE excluded.link_source - END, - updated_at = CURRENT_TIMESTAMP - """ - ), - { - "base_support_dept_code": base_code, - "related_support_dept_code": related_code, - }, - ) - - @app.on_event("startup") def on_startup() -> None: sqlite_status = validate_sqlite_runtime() @@ -4985,6 +5551,12 @@ def _run_app_post_startup_warmup() -> None: init_wehago_compare_db(engine) except Exception as exc: logger.warning("Post-startup compare DB init skipped due to error: %s", exc) + try: + repaired = _auto_repair_wehago_projection_active_ranges() + if repaired: + logger.info("Auto-repaired WEHAGO projection state for %s range(s)", repaired) + except Exception as exc: + logger.warning("Post-startup WEHAGO projection auto-repair skipped due to error: %s", exc) try: _ensure_app_maintenance_worker() except Exception as exc: @@ -5026,6 +5598,50 @@ def _ensure_app_post_startup_warmup() -> None: _APP_POST_STARTUP_WARMUP_STARTED = True +def _auto_repair_wehago_projection_active_ranges(limit: int = 20) -> int: + ranges: set[tuple[int, int]] = set() + with engine.begin() as conn: + rows = conn.execute( + text( + """ + SELECT setting_key + FROM wehago_compare_settings + WHERE setting_key LIKE 'wehago_active_query_projection:%:%' + ORDER BY updated_at DESC + LIMIT :limit + """ + ), + {"limit": int(limit)}, + ).mappings().all() + for row in rows: + parts = normalize_text(row.get("setting_key")).split(":") + if len(parts) < 3: + continue + try: + start = int(parts[-2]) + end = int(parts[-1]) + except Exception: + continue + if start > 0 and end >= start: + ranges.add((start, end)) + if not ranges: + available_years = sorted( + { + int(year) + for year in _discover_available_fiscal_years(conn) + if int(year or 0) > 0 + } + ) + if available_years: + ranges.add((available_years[-1], available_years[-1])) + repaired_count = 0 + for start, end in sorted(ranges): + result = ensure_wehago_canonical_projection_state(conn, start, end, repair=True) + if result.get("repaired"): + repaired_count += 1 + return repaired_count + + def _safe_next_url(value: Any) -> str: next_url = unquote_plus(normalize_text(value)) if not next_url.startswith("/") or next_url.startswith("//"): @@ -5288,6 +5904,56 @@ def build_hanmac_browser_plan() -> dict[str, Any]: } +def build_hanmac_wehago_audit_sources() -> dict[str, Any]: + years = [ + {"year": 2018, "gisu": 23}, + {"year": 2019, "gisu": 24}, + {"year": 2020, "gisu": 25}, + {"year": 2021, "gisu": 26}, + {"year": 2022, "gisu": 27}, + {"year": 2023, "gisu": 28}, + {"year": 2024, "gisu": 29}, + {"year": 2025, "gisu": 30}, + ] + ledger_urls = [ + { + **item, + "url": ( + "https://smarta.wehago.com/#/smarta/account/SABK0107?sao" + f"&cno=1173867&cd_com=biz202103030006368&gisu={item['gisu']}&yminsa=2026" + f"&searchData={item['year']}0101{item['year']}1231&color=#1C90FB" + "&companyName=(%EC%A3%BC)%ED%95%9C%EB%A7%A5%EA%B8%B0%EC%88%A0&companyID=b21344" + ), + } + for item in years + ] + return { + "menus": [ + { + "name": "계정별원장", + "program": "SABK0107", + "basis": "현재 DB 적재 원천입니다.", + "keyword": "계정별원장", + }, + { + "name": "총계정원장", + "program": "", + "basis": "계정별원장 잔액과 보고서 잔액을 결산 기준으로 대조할 때 사용합니다.", + "keyword": "총계정원장", + }, + { + "name": "합계잔액시산표", + "program": "", + "basis": "기말 잔액과 손익계정 총액을 보고서 금액과 대조할 때 사용합니다.", + "keyword": "합계잔액시산표", + }, + ], + "ledger_urls": ledger_urls, + "account_codes": ["114", "137", "179", "260", "290", "901", "931", "116", "136"], + "smarta_home_url": "https://smarta.wehago.com/", + } + + def normalize_text(value: Any) -> str: if value is None: return "" @@ -6369,6 +7035,18 @@ LABOR_RATE_CATEGORY_ALIASES = { } LABOR_RATE_BASE_GRADES = {"사장", "부사장", "전무", "전무이사", "상무", "상무이사", "이사", "부장", "차장", "과장", "대리", "사원"} LABOR_RATE_DERIVED_GRADES = {"수석", "책임", "선임", "연구원"} +LABOR_RATE_GRADE_ORDER_HIGH_TO_LOW = ( + "사장", + "부사장", + "전무", + "상무", + "이사", + "부장", + "차장", + "과장", + "대리", + "사원", +) def _normalize_labor_grade_name(value: Any) -> str: @@ -6386,6 +7064,36 @@ def _normalize_labor_grade_name(value: Any) -> str: return aliases.get(grade_text, grade_text) +def _hanmac_is_researcher_grade(value: Any) -> bool: + return "연구원" in _normalize_labor_grade_name(value) + + +def _hanmac_is_system_member_record(member_record: Mapping[str, Any] | None) -> bool: + record = member_record or {} + member_no = normalize_text(record.get("member_no")).lower() + member_name = _hanmac_normalize_person_name(record.get("member_name") or record.get("name")) + login_id = normalize_text(record.get("login_id") or record.get("user_id") or record.get("account_id")).lower() + display_name = normalize_text(record.get("member_name") or record.get("name")).replace(" ", "") + explicit_system_member_nos = {"g26001", "g26002", "b24062", "office1"} + organization_account_names = {"센터_기술기획팀", "인재성장팀", "관리실"} + if member_no in explicit_system_member_nos or display_name in organization_account_names: + return True + system_tokens = ("tadmin", "admin", "test", "tester", "system", "sys", "office") + if member_no.startswith(system_tokens) or login_id.startswith(system_tokens): + return True + if member_name in {"관리자", "시스템관리자", "테스트"} or "관리자" in member_name or "테스트" in member_name: + return True + has_person_marker = any( + normalize_text(record.get(key)) + for key in ("member_grade", "dept_name", "entry_date", "leave_date") + ) + return bool(member_no and member_name == member_no and not has_person_marker) + + +def _hanmac_counts_as_member(member_record: Mapping[str, Any] | None) -> bool: + return not _hanmac_is_researcher_grade((member_record or {}).get("member_grade")) + + def _normalize_labor_rate_category(value: Any) -> str: text_value = normalize_text(value) if "감리" in text_value: @@ -6496,6 +7204,25 @@ def _resolve_labor_rate( year_candidates.append(text_value) if not year_candidates: year_candidates.append(str(datetime.now().year)) + numeric_requested_years = [ + int(year_text) + for year_text in year_candidates + if year_text.isdigit() + ] + available_years = sorted( + { + int(year_text) + for year_text in rates_by_year + if normalize_text(year_text).isdigit() + } + ) + if available_years: + requested_year = max(numeric_requested_years or [datetime.now().year]) + previous_years = [year for year in available_years if year <= requested_year] + fallback_rate_year = previous_years[-1] if previous_years else available_years[0] + fallback_rate_year_text = str(fallback_rate_year) + if fallback_rate_year_text not in year_candidates: + year_candidates.append(fallback_rate_year_text) for year_text in year_candidates: year_bucket = rates_by_year.get(year_text) or {} design_bucket = year_bucket.get("설계") or {} @@ -6511,6 +7238,14 @@ def _resolve_labor_rate( amount = normalize_amount(category_bucket.get(grade_text)) if amount: return amount + # 해당 직급의 시급이 없으면 직급 서열상 바로 위 직급부터 + # 순서대로 찾아 가장 가까운 차상위 직급 시급을 사용한다. + if grade_text in LABOR_RATE_GRADE_ORDER_HIGH_TO_LOW: + grade_index = LABOR_RATE_GRADE_ORDER_HIGH_TO_LOW.index(grade_text) + for higher_grade in reversed(LABOR_RATE_GRADE_ORDER_HIGH_TO_LOW[:grade_index]): + amount = normalize_amount(category_bucket.get(higher_grade)) + if amount: + return amount return 0.0 @@ -6553,6 +7288,8 @@ def sanitize_project_labor_amount_rows(conn: Any) -> int: for entry in exec_entries: if normalize_text(entry.get("group")) != "labor": continue + if normalize_text(entry.get("account_code")).startswith("SATIS_"): + continue hours_value = _parse_exec_hours_value(entry.get("hours")) next_amount = _resolve_labor_rate(rates, entry.get("grade"), entry.get("rate_year")) * hours_value if abs(normalize_amount(entry.get("amount")) - next_amount) > 0.5: @@ -11624,8 +12361,10 @@ def _hanmac_member_grade_lookup_signature() -> str: SELECT COUNT(*) || ':' || COALESCE(MAX(updated_at), '') FROM hanmac_aggregate_query_metrics WHERE view_mode = 'member' + AND payload_signature LIKE :compatible_signature_pattern """ - ) + ), + _cost_analysis_hanmac_signature_params(), ).scalar() joint_cache_path = BASE_DIR / "static" / "hanmac-joint-members-cache.json" try: @@ -11652,10 +12391,14 @@ def _load_hanmac_member_grade_lookup_cached(_signature: str) -> dict[str, dict[s SELECT cache_key FROM hanmac_aggregate_query_metrics WHERE view_mode = 'member' - ORDER BY updated_at DESC + AND payload_signature LIKE :compatible_signature_pattern + ORDER BY + CASE WHEN payload_signature LIKE :current_signature_prefix THEN 0 ELSE 1 END, + updated_at DESC LIMIT 8 """ - ) + ), + _cost_analysis_hanmac_signature_params(), ).fetchall() if normalize_text(row[0]) ] @@ -12432,9 +13175,71 @@ def get_projects_bootstrap_payload(selected_year: int | None = None) -> dict[str ) -COST_ANALYSIS_LABOR_KEYWORDS = ("급여", "상여", "퇴직급여", "건강보험료", "고용보험료", "산재보험료") +COST_ANALYSIS_LABOR_KEYWORDS = ( + "급여", + "상여", + "제수당", + "퇴직급여", + "퇴직금", + "국민연금", + "건강보험료", + "고용보험료", + "산재보험료", +) COST_ANALYSIS_OUTSOURCE_KEYWORDS = ("기술협력비", "설계외주비", "외주비") COST_ANALYSIS_COMMON_CODES = {"", "ZZZZZZ"} +COST_ANALYSIS_COMMON_ACTIVITY_PREFIX = "__COMMON_ACTIVITY__:" +COST_ANALYSIS_COMMON_ACTIVITY_SPECIAL_NAMES = { + "H00-합사-01": "공통/합사", + "H00-대기-01": "공통/감리대기", + "HXX-영업-01": "공통/영업", + "HV009109": "공통/영업", + "HXX-고문-02": "공통/고문", + "HV009111": "공통/고문", + "HXX-교휴-04": "공통/휴가", + "HV009104": "공통/휴가", + "HXX-교휴-06": "공통/기타", + "HV009110": "공통/기타", + "HXX-교휴-08": "공통/행사·학회·협회", + "HV009102": "공통/행사·학회·협회", + "HV009101": "공통/회의", + "HV009106": "공통/기타업무", + "HP241101": "송산그린시티 용수공급시설(2차) 실시설계 용역", +} +COST_ANALYSIS_CONFIRMED_H_PROJECT_CODE_MAP = { + # H/HP/HV source codes confirmed against ERP project/billing/transaction data. + "H21-고속-09": "Y22004", + "H22-제안-33": "Y22239", + "H22-지방-08": "Y22239", + "H24-제안-01": "Y24061", + "H24-제안-05": "X24005", + "H24-제안-10": "Z24138", + "H24-제안-22": "Y24170", + "H20-제안-16": "Z25031", + "H21-제안-17": "Y22004", + "HP241101": "X24005", +} +COST_ANALYSIS_CONFIRMED_H_TITLE_CODE_MAP = { + "경호안전교육원3단계사업지명설계공모": "Y22239", + "경호안전교육원3단계사업설계용역": "Y22239", + "동광주광산대안제시경쟁": "Y24061", + "동부간선도로지하화민간투자사업감독권한대행등건설사업관리용역": "Z24138", + "포항안동111공구건설사업관리종심": "Z25031", + "현대자동차남양연구소고속주회로재포장기본및실시설계": "Y22004", +} +COST_ANALYSIS_COMMON_VISIBLE_EXCEPTION_TITLES = { + "국지도98호선양근대교도로건설공사감독권한대행등", + "대산당진건설공사제1공구", + "아산시노후하수관로개량사업", + "중랑처리구역하수관로기술진단1권역", +} + + +def _cost_analysis_is_visible_common_exception(value: Any) -> bool: + return normalize_project_title_for_linking(value) in COST_ANALYSIS_COMMON_VISIBLE_EXCEPTION_TITLES +COST_ANALYSIS_LABOR_ACCOUNT_SQL = " OR ".join( + f"COALESCE(account_name, '') LIKE '%{keyword}%'" for keyword in COST_ANALYSIS_LABOR_KEYWORDS +) COST_ANALYSIS_TX_DATE_SQL = ( "CASE " "WHEN COALESCE(posting_date, '') GLOB '[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]' THEN posting_date " @@ -12459,6 +13264,260 @@ def _date_text(value: Any) -> str: return parsed.isoformat() if parsed else "" +def _cost_analysis_voucher_stem(value: Any) -> str: + voucher = normalize_text(value) + return voucher.rsplit("-", 1)[0] if "-" in voucher else voucher + + +def _cost_analysis_voucher_date(value: Any) -> str: + voucher = normalize_text(value) + match = re.match(r"^11-(\d{4})(\d{2})(\d{2})-", voucher) + if not match: + return "" + return f"{match.group(1)}-{match.group(2)}-{match.group(3)}" + + +def _cost_analysis_erp_collection_events(end_date: date) -> list[dict[str, Any]]: + # Only receivables created by customer billing belong to project collection. + # General receivables include payroll/tax/asset-sale settlements. + receivable_codes = {"10111101", "10111501"} + non_cash_receivable_codes = {*receivable_codes, "10111901"} + with engine.begin() as conn: + transaction_rows = conn.execute( + text( + """ + SELECT + COALESCE(voucher_number, '') AS voucher_number, + COALESCE(confirmed_voucher_number, '') AS confirmed_voucher_number, + COALESCE(account_code, '') AS account_code, + COALESCE(account_name, '') AS account_name, + UPPER(COALESCE(support_dept_code, '')) AS support_dept_code, + COALESCE(partner_name, '') AS partner_name, + COALESCE(memo1, '') AS memo1, + COALESCE(debit_supply, 0) AS debit_supply, + COALESCE(debit_vat, 0) AS debit_vat, + COALESCE(credit_supply, 0) AS credit_supply, + COALESCE(credit_vat, 0) AS credit_vat + FROM transactions + WHERE account_code LIKE '101%' + OR account_code LIKE '4%' + """ + ) + ).mappings().all() + + voucher_groups: dict[str, list[dict[str, Any]]] = {} + for raw_row in transaction_rows: + row = dict(raw_row) + voucher_key = _cost_analysis_voucher_stem( + row.get("confirmed_voucher_number") or row.get("voucher_number") + ) + if voucher_key: + voucher_groups.setdefault(voucher_key, []).append(row) + + invoice_candidates: dict[tuple[str, str], list[dict[str, Any]]] = {} + for voucher_key, rows in voucher_groups.items(): + revenue_supply = sum( + normalize_amount(row.get("credit_supply")) - normalize_amount(row.get("debit_supply")) + for row in rows + if normalize_text(row.get("account_code")).startswith("4") + ) + receivable_debits = [ + row + for row in rows + if normalize_text(row.get("account_code")) in receivable_codes + and ( + normalize_amount(row.get("debit_supply")) + + normalize_amount(row.get("debit_vat")) + - normalize_amount(row.get("credit_supply")) + - normalize_amount(row.get("credit_vat")) + ) > 0.5 + ] + gross_total = sum( + normalize_amount(row.get("debit_supply")) + + normalize_amount(row.get("debit_vat")) + - normalize_amount(row.get("credit_supply")) + - normalize_amount(row.get("credit_vat")) + for row in receivable_debits + ) + if not receivable_debits or gross_total <= 0.5 or revenue_supply <= 0.5: + continue + for row in receivable_debits: + gross_amount = ( + normalize_amount(row.get("debit_supply")) + + normalize_amount(row.get("debit_vat")) + - normalize_amount(row.get("credit_supply")) + - normalize_amount(row.get("credit_vat")) + ) + code = normalize_text(row.get("support_dept_code")).upper() + partner = normalize_text(row.get("partner_name")) + invoice_candidates.setdefault((code, partner), []).append( + { + "gross_amount": gross_amount, + "supply_amount": revenue_supply * gross_amount / gross_total, + "voucher_number": normalize_text(row.get("voucher_number")), + "confirmed_voucher_number": normalize_text(row.get("confirmed_voucher_number")), + "invoice_date": _cost_analysis_voucher_date(row.get("voucher_number")), + "memo1": normalize_text(row.get("memo1")), + } + ) + + events: list[dict[str, Any]] = [] + for voucher_key, rows in voucher_groups.items(): + receipt_date = _cost_analysis_voucher_date(rows[0].get("voucher_number")) + if not receipt_date or receipt_date > end_date.isoformat(): + continue + receivable_credits = [ + row + for row in rows + if normalize_text(row.get("account_code")) in receivable_codes + and ( + normalize_amount(row.get("credit_supply")) + + normalize_amount(row.get("credit_vat")) + - normalize_amount(row.get("debit_supply")) + - normalize_amount(row.get("debit_vat")) + ) > 0.5 + ] + cash_debit_total = sum( + normalize_amount(row.get("debit_supply")) + + normalize_amount(row.get("debit_vat")) + - normalize_amount(row.get("credit_supply")) + - normalize_amount(row.get("credit_vat")) + for row in rows + if normalize_text(row.get("account_code")).startswith("101") + and normalize_text(row.get("account_code")) not in non_cash_receivable_codes + ) + if cash_debit_total <= 0.5: + continue + if not receivable_credits: + revenue_rows = [ + row + for row in rows + if normalize_text(row.get("account_code")).startswith("4") + and ( + normalize_amount(row.get("credit_supply")) + - normalize_amount(row.get("debit_supply")) + ) > 0.5 + ] + receivable_debit_total = sum( + normalize_amount(row.get("debit_supply")) + + normalize_amount(row.get("debit_vat")) + - normalize_amount(row.get("credit_supply")) + - normalize_amount(row.get("credit_vat")) + for row in rows + if normalize_text(row.get("account_code")) in receivable_codes + ) + revenue_supply_total = sum( + normalize_amount(row.get("credit_supply")) + - normalize_amount(row.get("debit_supply")) + for row in revenue_rows + ) + if not revenue_rows or receivable_debit_total > 0.5 or revenue_supply_total <= 0.5: + continue + for row in revenue_rows: + supply_amount = ( + normalize_amount(row.get("credit_supply")) + - normalize_amount(row.get("debit_supply")) + ) + events.append( + { + "support_dept_code": normalize_text(row.get("support_dept_code")).upper(), + "posting_date": receipt_date, + "voucher_number": normalize_text(row.get("voucher_number")), + "confirmed_voucher_number": normalize_text(row.get("confirmed_voucher_number")), + "partner_name": normalize_text(row.get("partner_name")), + "memo1": normalize_text(row.get("memo1")), + "receivable_account_code": "", + "receivable_account_name": "즉시 현금·카드 매출", + "gross_amount": cash_debit_total * supply_amount / revenue_supply_total, + "amount": supply_amount, + "conversion_status": "immediate-cash", + "source_invoice_voucher_number": normalize_text(row.get("voucher_number")), + "source_invoice_confirmed_voucher_number": normalize_text( + row.get("confirmed_voucher_number") + ), + "cash_match_gap": cash_debit_total - sum( + normalize_amount(item.get("credit_supply")) + + normalize_amount(item.get("credit_vat")) + - normalize_amount(item.get("debit_supply")) + - normalize_amount(item.get("debit_vat")) + for item in revenue_rows + ), + } + ) + continue + receivable_credit_total = sum( + normalize_amount(row.get("credit_supply")) + + normalize_amount(row.get("credit_vat")) + - normalize_amount(row.get("debit_supply")) + - normalize_amount(row.get("debit_vat")) + for row in receivable_credits + ) + matched_cash_total = min(receivable_credit_total, cash_debit_total) + for row in receivable_credits: + gross_credit = ( + normalize_amount(row.get("credit_supply")) + + normalize_amount(row.get("credit_vat")) + - normalize_amount(row.get("debit_supply")) + - normalize_amount(row.get("debit_vat")) + ) + matched_gross = gross_credit * matched_cash_total / receivable_credit_total + code = normalize_text(row.get("support_dept_code")).upper() + partner = normalize_text(row.get("partner_name")) + candidates = invoice_candidates.get((code, partner), []) + exact_candidates = [ + candidate + for candidate in candidates + if abs(normalize_amount(candidate.get("gross_amount")) - gross_credit) < 0.5 + and normalize_text(candidate.get("invoice_date")) <= receipt_date + ] + receipt_memo = normalize_project_title_for_linking(row.get("memo1")) + exact_invoice = max( + exact_candidates, + key=lambda candidate: ( + SequenceMatcher( + None, + receipt_memo, + normalize_project_title_for_linking(candidate.get("memo1")), + ).ratio(), + normalize_text(candidate.get("invoice_date")), + ), + default=None, + ) + if exact_invoice: + supply_ratio = ( + normalize_amount(exact_invoice.get("supply_amount")) + / normalize_amount(exact_invoice.get("gross_amount")) + ) + supply_amount = matched_gross * supply_ratio + conversion_status = "invoice-matched" + else: + supply_amount = matched_gross / 1.1 + conversion_status = "vat-estimated" + events.append( + { + "support_dept_code": code, + "posting_date": receipt_date, + "voucher_number": normalize_text(row.get("voucher_number")), + "confirmed_voucher_number": normalize_text(row.get("confirmed_voucher_number")), + "partner_name": partner, + "memo1": normalize_text(row.get("memo1")), + "receivable_account_code": normalize_text(row.get("account_code")), + "receivable_account_name": normalize_text(row.get("account_name")), + "gross_amount": matched_gross, + "amount": supply_amount, + "conversion_status": conversion_status, + "source_invoice_voucher_number": normalize_text( + (exact_invoice or {}).get("voucher_number") + ), + "source_invoice_confirmed_voucher_number": normalize_text( + (exact_invoice or {}).get("confirmed_voucher_number") + ), + "cash_match_gap": cash_debit_total - receivable_credit_total, + } + ) + return events + + def _iter_year_slices(start_date: date, end_date: date) -> list[dict[str, Any]]: slices: list[dict[str, Any]] = [] current = start_date @@ -12526,6 +13585,74 @@ def _cost_analysis_project_type(code: str, fallback: Any = "") -> str: return "기타" +def _cost_analysis_project_source_codes(project: Mapping[str, Any]) -> list[str]: + return sorted( + { + normalize_text(value).upper() + for value in ( + project.get("project_code"), + project.get("raw_project_code"), + *(project.get("equivalent_project_codes") or []), + *(project.get("source_project_codes") or []), + ) + if normalize_text(value) + } + ) + + +def _cost_analysis_common_activity_info(project: Mapping[str, Any]) -> dict[str, Any] | None: + source_codes = _cost_analysis_project_source_codes(project) + h_codes = [code for code in source_codes if code.startswith("H")] + if not h_codes: + return None + + canonical_code = normalize_text(project.get("project_code")).upper() + preferred_codes = [canonical_code, *h_codes] + label = next( + ( + COST_ANALYSIS_COMMON_ACTIVITY_SPECIAL_NAMES[code] + for code in preferred_codes + if code in COST_ANALYSIS_COMMON_ACTIVITY_SPECIAL_NAMES + ), + "", + ) + if not label: + category_match = re.match(r"^H(?:XX|\d{2})-([^-]+)-\d+$", canonical_code) + category = normalize_text(category_match.group(1) if category_match else "") + if category in {"영업", "고문", "관리"}: + label = f"공통/{category}" + elif category == "교휴": + label = f"공통/교휴 ({canonical_code})" + + project_name = normalize_text(project.get("project_name")) + if not label and project_name and project_name.upper() not in set(h_codes): + label = project_name + if not label: + label = f"공통/기타업무 ({canonical_code or h_codes[0]})" + + raw_code = normalize_text(project.get("raw_project_code")).upper() + display_source_codes = [] + for code in h_codes: + mapped_label = COST_ANALYSIS_COMMON_ACTIVITY_SPECIAL_NAMES.get(code) + if mapped_label and mapped_label != label: + continue + if code in {canonical_code, raw_code} or mapped_label == label or not mapped_label: + display_source_codes.append(code) + if not display_source_codes: + display_source_codes = [canonical_code or h_codes[0]] + + label_key = normalize_project_title_for_linking(label) or re.sub(r"[^A-Z0-9가-힣]+", "", label.upper()) + return { + "key": f"{COST_ANALYSIS_COMMON_ACTIVITY_PREFIX}{label_key}", + "label": label, + "source_codes": sorted(set(display_source_codes)), + } + + +def _cost_analysis_is_common_activity_code(value: Any) -> bool: + return normalize_text(value).upper().startswith(COST_ANALYSIS_COMMON_ACTIVITY_PREFIX) + + def _cost_analysis_financial_bucket(account_code: Any) -> str: code = normalize_text(account_code) if code.startswith("4"): @@ -12551,6 +13678,28 @@ def _cost_analysis_expense_item(account_code: Any, account_name: Any, is_sales_c return "overhead" +def _cost_analysis_detail_item_matches(bucket: str, item_key: str, requested_item: str) -> bool: + if requested_item == "revenue": + return bucket == "revenue" + if bucket not in {"cost", "sga"}: + return False + if requested_item == "cost_total": + return bucket == "cost" and item_key in {"labor", "outsource", "overhead"} + if requested_item == "sga_total": + return bucket == "sga" and item_key == "sga" + if requested_item == "sales_total": + return item_key == "sales" + if requested_item == "total_cost": + return item_key in {"labor", "outsource", "overhead", "sga", "sales"} + if requested_item in {"labor", "outsource", "overhead"}: + return bucket == "cost" and item_key == requested_item + if requested_item == "sga": + return bucket == "sga" and item_key == "sga" + if requested_item == "sales": + return item_key == "sales" + return False + + def _cost_analysis_is_sales_cost(row: dict[str, Any]) -> bool: for code_key, name_key in ( ("issuing_dept_code", "issuing_dept_name"), @@ -12566,15 +13715,174 @@ def _cost_analysis_is_sales_cost(row: dict[str, Any]) -> bool: def _cost_analysis_empty_phase_totals() -> dict[str, dict[str, float]]: return { - "pre": {"labor": 0.0, "outsource": 0.0, "overhead": 0.0, "sga": 0.0, "sales": 0.0}, - "during": {"labor": 0.0, "outsource": 0.0, "overhead": 0.0, "sga": 0.0, "sales": 0.0}, - "post": {"labor": 0.0, "outsource": 0.0, "overhead": 0.0, "sga": 0.0, "sales": 0.0}, + "pre": {"labor": 0.0, "labor_adjustment": 0.0, "outsource": 0.0, "overhead": 0.0, "sga_labor": 0.0, "sga_labor_adjustment": 0.0, "sga": 0.0, "sales": 0.0}, + "during": {"labor": 0.0, "labor_adjustment": 0.0, "outsource": 0.0, "overhead": 0.0, "sga_labor": 0.0, "sga_labor_adjustment": 0.0, "sga": 0.0, "sales": 0.0}, + "post": {"labor": 0.0, "labor_adjustment": 0.0, "outsource": 0.0, "overhead": 0.0, "sga_labor": 0.0, "sga_labor_adjustment": 0.0, "sga": 0.0, "sales": 0.0}, } +_COST_ANALYSIS_ROUND_CODE_RE = re.compile(r"^[XYZ]\d{5}$") +_COST_ANALYSIS_MASTER_CODE_RE = re.compile(r"^[09]\d{5}$") + + +def _cost_analysis_name_needs_display_fix(name: Any, code: Any = "") -> bool: + normalized_name = normalize_text(name) + normalized_code = normalize_text(code).upper() + if not normalized_name: + return True + if normalized_code and normalized_name.upper() == normalized_code: + return True + return bool(re.fullmatch(r"[09]?\d{5,6}", normalized_name)) + + +def _cost_analysis_get_satis_display_maps() -> dict[str, dict[str, Any]]: + """Return Satis-derived display names and preferred round-code aliases. + + 손익분석 계산은 총괄/차수 코드가 모두 필요하지만, 화면 표시는 사용자가 + 보는 차수 프로젝트(X/Y/Z)와 사업명으로 맞춰야 한다. 이 함수는 + satis_project_code_links와 이미 반영된 Satis 예산 출처를 이용해 그 표시 + 기준만 별도로 만든다. + """ + + names: dict[str, str] = {} + link_candidates: dict[str, list[dict[str, Any]]] = {} + preferred_round: dict[str, str] = {} + + def put_name(code: Any, name: Any, *, prefer: bool = False) -> None: + normalized_code = normalize_text(code).upper() + normalized_name = normalize_text(name) + if not normalized_code or not normalized_name: + return + current = names.get(normalized_code, "") + if prefer or _cost_analysis_name_needs_display_fix(current, normalized_code): + names[normalized_code] = normalized_name + + try: + with engine.begin() as conn: + link_rows = conn.execute( + text( + """ + SELECT + COALESCE(local_project_code, '') AS local_project_code, + COALESCE(local_project_name, '') AS local_project_name, + COALESCE(own_master_project_code, '') AS own_master_project_code, + COALESCE(own_master_project_name, '') AS own_master_project_name, + COALESCE(linked_main_project_code, '') AS linked_main_project_code, + COALESCE(linked_main_project_name, '') AS linked_main_project_name, + COALESCE(cost_project_code, '') AS cost_project_code, + COALESCE(cost_project_name, '') AS cost_project_name, + COALESCE(mapping_status, '') AS mapping_status, + COALESCE(project_kind, '') AS project_kind, + COALESCE(is_active, 1) AS is_active + FROM satis_project_code_links + WHERE COALESCE(local_project_code, '') <> '' + """ + ) + ).mappings().all() + budget_source_rows = conn.execute( + text( + """ + SELECT + UPPER(COALESCE(support_dept_code, '')) AS master_code, + UPPER(COALESCE(source_support_dept_code, '')) AS source_code, + COUNT(*) AS line_count, + SUM(COALESCE(amount, 0)) AS amount + FROM project_exec_budget_entries + WHERE COALESCE(source_support_dept_code, '') <> '' + AND UPPER(COALESCE(support_dept_code, '')) <> UPPER(COALESCE(source_support_dept_code, '')) + GROUP BY UPPER(COALESCE(support_dept_code, '')), UPPER(COALESCE(source_support_dept_code, '')) + """ + ) + ).mappings().all() + status_codes = { + normalize_text(code).upper() + for code in conn.execute( + text( + """ + SELECT support_dept_code + FROM project_status + WHERE COALESCE(support_dept_code, '') <> '' + """ + ) + ).scalars().all() + if normalize_text(code) + } + except Exception: + return {"names": names, "preferred_round": preferred_round} + + for row in link_rows: + local_code = normalize_text(row.get("local_project_code")).upper() + if not local_code: + continue + put_name(local_code, row.get("local_project_name"), prefer=True) + put_name(row.get("own_master_project_code"), row.get("own_master_project_name")) + put_name(row.get("linked_main_project_code"), row.get("linked_main_project_name")) + put_name(row.get("cost_project_code"), row.get("cost_project_name")) + + for master_code in { + normalize_text(row.get("own_master_project_code")).upper(), + normalize_text(row.get("linked_main_project_code")).upper(), + normalize_text(row.get("cost_project_code")).upper(), + }: + if not master_code or not _COST_ANALYSIS_MASTER_CODE_RE.fullmatch(master_code): + continue + link_candidates.setdefault(master_code, []).append( + { + "local_code": local_code, + "is_active": int(normalize_amount(row.get("is_active")) or 0), + "mapping_status": normalize_text(row.get("mapping_status")), + "project_kind": normalize_text(row.get("project_kind")), + "in_project_status": local_code in status_codes, + } + ) + + budget_candidates: dict[str, list[tuple[float, int, str]]] = {} + for row in budget_source_rows: + master_code = normalize_text(row.get("master_code")).upper() + source_code = normalize_text(row.get("source_code")).upper() + if not _COST_ANALYSIS_MASTER_CODE_RE.fullmatch(master_code): + continue + if not _COST_ANALYSIS_ROUND_CODE_RE.fullmatch(source_code): + continue + budget_candidates.setdefault(master_code, []).append( + ( + normalize_amount(row.get("amount")), + int(normalize_amount(row.get("line_count")) or 0), + source_code, + ) + ) + for master_code, candidates in budget_candidates.items(): + preferred_round[master_code] = sorted(candidates, key=lambda item: (-item[0], -item[1], item[2]))[0][2] + + def link_rank(item: dict[str, Any]) -> tuple[int, int, int, int, str]: + local_code = normalize_text(item.get("local_code")).upper() + prefix = local_code[:1] + return ( + 0 if item.get("in_project_status") else 1, + 0 if item.get("is_active") else 1, + 0 if normalize_text(item.get("mapping_status")) in {"confirmed", "exception"} else 1, + 0 if prefix in {"Y", "Z"} else 1 if prefix == "X" else 2, + local_code, + ) + + for master_code, candidates in link_candidates.items(): + if master_code in preferred_round: + continue + valid_candidates = [ + item for item in candidates + if _COST_ANALYSIS_ROUND_CODE_RE.fullmatch(normalize_text(item.get("local_code")).upper()) + ] + if valid_candidates: + preferred_round[master_code] = sorted(valid_candidates, key=link_rank)[0]["local_code"] + + return {"names": names, "preferred_round": preferred_round} + + def _cost_analysis_get_project_meta() -> dict[str, dict[str, Any]]: billing_summary = get_project_billing_summary_map() latest_summary_by_title, latest_round_by_code, representative_by_title, title_by_code = get_project_contract_change_maps() + satis_display = _cost_analysis_get_satis_display_maps() + satis_names = satis_display.get("names") or {} with engine.begin() as conn: rows = conn.execute( text( @@ -12586,6 +13894,10 @@ def _cost_analysis_get_project_meta() -> dict[str, dict[str, Any]]: UNION SELECT DISTINCT support_dept_code FROM project_contract_info WHERE COALESCE(support_dept_code, '') <> '' UNION SELECT DISTINCT support_dept_code FROM project_billing_entries WHERE COALESCE(support_dept_code, '') <> '' UNION SELECT DISTINCT support_dept_code FROM project_contract_change_round WHERE COALESCE(support_dept_code, '') <> '' + UNION SELECT DISTINCT local_project_code FROM satis_project_code_links WHERE COALESCE(local_project_code, '') <> '' + UNION SELECT DISTINCT own_master_project_code FROM satis_project_code_links WHERE COALESCE(own_master_project_code, '') <> '' + UNION SELECT DISTINCT linked_main_project_code FROM satis_project_code_links WHERE COALESCE(linked_main_project_code, '') <> '' + UNION SELECT DISTINCT cost_project_code FROM satis_project_code_links WHERE COALESCE(cost_project_code, '') <> '' ) SELECT COALESCE(u.support_dept_code, '') AS support_dept_code, @@ -12650,9 +13962,30 @@ def _cost_analysis_get_project_meta() -> dict[str, dict[str, Any]]: contract_amount = latest_summary_amount explicit_start_date = _date_text(row.get("project_start_date")) fallback_start_date = _date_text(row.get("first_change_date")) or _date_text(row.get("first_billing_date")) or _date_text(row.get("first_posting_date")) + collected_amount = normalize_amount(billing_row.get("collected_amount")) + billing_balance_amount = normalize_amount(billing_row.get("balance_amount")) + collection_rate = _safe_ratio(collected_amount, contract_amount) + if not collection_rate: + collection_rate = max( + [ + normalize_amount(entry.get("collection_rate")) + for entry in (billing_row.get("entries") or []) + ] + or [0.0] + ) + display_name = ( + normalize_text(row.get("support_dept_name")) + or normalize_text(billing_row.get("support_dept_name")) + or normalize_text(latest_summary_change.get("support_dept_name")) + or normalize_text(latest_round_change.get("support_dept_name")) + or normalize_text(satis_names.get(code)) + or code + ) + if _cost_analysis_name_needs_display_fix(display_name, code): + display_name = normalize_text(satis_names.get(code)) or display_name result[code] = { "support_dept_code": code, - "support_dept_name": normalize_text(row.get("support_dept_name")) or normalize_text(billing_row.get("support_dept_name")) or normalize_text(latest_summary_change.get("support_dept_name")) or normalize_text(latest_round_change.get("support_dept_name")) or code, + "support_dept_name": display_name, "pm_department": normalize_text(row.get("pm_department")) or normalize_text(billing_row.get("support_department")) or normalize_text(latest_summary_change.get("owner_department")) or normalize_text(latest_round_change.get("owner_department")), "project_type": _cost_analysis_project_type(code, row.get("project_type") or latest_summary_change.get("business_division") or latest_round_change.get("business_division")), "completion_status": normalize_text(row.get("completion_status")) or normalize_text(latest_summary_change.get("progress_status")), @@ -12660,6 +13993,9 @@ def _cost_analysis_get_project_meta() -> dict[str, dict[str, Any]]: "project_start_date_source": "기본정보" if explicit_start_date else ("계약변경/청구/전표" if fallback_start_date else ""), "project_end_date": _date_text(row.get("project_end_date")) or _date_text(latest_summary_change.get("changed_project_end_date")) or _date_text(latest_round_change.get("changed_project_end_date")), "contract_amount": contract_amount, + "collected_amount": collected_amount, + "collection_balance_amount": billing_balance_amount, + "collection_rate": collection_rate, "contract_source": "변경계약 차수" if latest_round_amount and abs(contract_amount - latest_round_amount) < 0.5 else ("변경계약 총괄" if latest_summary_amount and abs(contract_amount - latest_summary_amount) < 0.5 else "계약/청구"), } return result @@ -12727,6 +14063,23 @@ def _cost_analysis_get_xyz_code_map() -> dict[str, str]: } +def _cost_analysis_get_x_owner_map( + x_links: dict[str, list[str]] | None = None, + xyz_code_map: dict[str, str] | None = None, +) -> dict[str, str]: + resolved_x_links = x_links if x_links is not None else _cost_analysis_get_x_links() + resolved_xyz_code_map = xyz_code_map if xyz_code_map is not None else _cost_analysis_get_xyz_code_map() + result: dict[str, str] = {} + for owner_code, x_codes in resolved_x_links.items(): + normalized_owner_code = normalize_text(owner_code).upper() + display_owner_code = resolved_xyz_code_map.get(normalized_owner_code, normalized_owner_code) + for x_code in x_codes: + normalized_x_code = normalize_text(x_code).upper() + if normalized_x_code: + result.setdefault(normalized_x_code, display_owner_code) + return result + + def _cost_analysis_get_completion_billing_dates() -> dict[str, str]: result: dict[str, str] = {} with engine.begin() as conn: @@ -12762,9 +14115,114 @@ def _cost_analysis_get_completion_billing_dates() -> dict[str, str]: def _clear_cost_analysis_payload_caches() -> None: with _COST_ANALYSIS_PAYLOAD_CACHE_LOCK: _COST_ANALYSIS_PAYLOAD_CACHE.clear() + with _COST_ANALYSIS_DETAIL_CACHE_LOCK: + _COST_ANALYSIS_DETAIL_CACHE.clear() + with _COST_ANALYSIS_LINK_MAP_CACHE_LOCK: + _COST_ANALYSIS_LINK_MAP_CACHE.clear() + _cost_analysis_field_cost_dept_names.cache_clear() _cost_analysis_load_hanmac_member_rows_for_cache.cache_clear() with engine.begin() as conn: conn.execute(text("DELETE FROM system_page_cache WHERE page_key = 'cost_analysis_payload'")) + conn.execute(text("DELETE FROM system_page_cache WHERE page_key = 'cost_analysis_link_map'")) + + +def _cost_analysis_hanmac_signature_params() -> dict[str, str]: + return { + "current_signature_prefix": f"{HANMAC_AGGREGATE_SIGNATURE_PREFIX}%", + "compatible_signature_pattern": f"{HANMAC_AGGREGATE_SIGNATURE_PREFIX}%", + } + + +def _cost_analysis_latest_hanmac_connection_params() -> dict[str, Any]: + with engine.begin() as conn: + rows = conn.execute( + text( + """ + SELECT params_json + FROM system_jobs + WHERE job_type = 'hanmac_aggregate_cache' + AND status = 'done' + ORDER BY finished_at DESC, created_at DESC + LIMIT 20 + """ + ) + ).scalars().all() + for raw_params in rows: + try: + params = json.loads(str(raw_params or "{}")) + except Exception: + continue + if not isinstance(params, dict): + continue + if normalize_text(params.get("host")) and normalize_text(params.get("user")) and params.get("password"): + return { + "host": normalize_text(params.get("host")), + "port": normalize_text(params.get("port")) or "3306", + "user": normalize_text(params.get("user")), + "password": params.get("password") or "", + "database": normalize_text(params.get("database") or HANMAC_PRIMARY_MANHOUR_SCHEMA), + "view": "member", + "employment": normalize_text(params.get("employment") or "all"), + "include_center_member_nos": params.get("include_center_member_nos") or [], + } + return {} + + +def _cost_analysis_ensure_current_hanmac_cache_jobs(start_date: date, end_date: date) -> list[dict[str, Any]]: + missing_slices: list[dict[str, Any]] = [] + with engine.begin() as conn: + for year_slice in _iter_year_slices(start_date, end_date): + row = conn.execute( + text( + """ + SELECT cache_key, row_count, summary_json, updated_at + FROM hanmac_aggregate_query_metrics + WHERE view_mode = 'member' + AND payload_signature LIKE :current_signature_prefix + AND COALESCE(start_date, '') <= :start_date + AND COALESCE(end_date, '') >= :end_date + AND COALESCE(row_count, 0) > 0 + ORDER BY + CASE WHEN start_date = :start_date AND end_date = :end_date THEN 0 ELSE 1 END, + updated_at DESC + LIMIT 1 + """ + ), + { + "start_date": year_slice["start"].isoformat(), + "end_date": year_slice["end"].isoformat(), + **_cost_analysis_hanmac_signature_params(), + }, + ).mappings().first() + summary: dict[str, Any] = {} + if row: + try: + summary = json.loads(str(row.get("summary_json") or "{}")) + except Exception: + summary = {} + if not row or normalize_amount(summary.get("total_hours")) <= 0: + missing_slices.append(year_slice) + if not missing_slices: + return [] + + connection_params = _cost_analysis_latest_hanmac_connection_params() + if not connection_params: + raise ValueError("최신 한맥 근무 캐시를 자동 갱신할 DB_external 접속 설정을 찾지 못했습니다.") + jobs: list[dict[str, Any]] = [] + for year_slice in missing_slices: + job = _create_system_job( + page_key="cost_analysis", + job_type="hanmac_aggregate_cache", + start_year=int(year_slice["year"]), + end_year=int(year_slice["year"]), + params={ + **connection_params, + "start_date": year_slice["start"].isoformat(), + "end_date": year_slice["end"].isoformat(), + }, + ) + jobs.append(job) + return jobs def _cost_analysis_hanmac_cache_version() -> str: @@ -12778,8 +14236,10 @@ def _cost_analysis_hanmac_cache_version() -> str: COALESCE(MAX(rowid), 0) AS max_rowid FROM hanmac_aggregate_query_metrics WHERE view_mode = 'member' + AND payload_signature LIKE :compatible_signature_pattern """ - ) + ), + _cost_analysis_hanmac_signature_params(), ).mappings().first() except Exception: return "" @@ -12827,15 +14287,21 @@ def _cost_analysis_select_hanmac_member_metric( SELECT cache_key, start_date, end_date, updated_at FROM hanmac_aggregate_query_metrics WHERE view_mode = 'member' + AND payload_signature LIKE :compatible_signature_pattern AND COALESCE(start_date, '') <= :start_date AND COALESCE(end_date, '') >= :end_date ORDER BY + CASE WHEN payload_signature LIKE :current_signature_prefix THEN 0 ELSE 1 END, CASE WHEN start_date = :start_date AND end_date = :end_date THEN 0 ELSE 1 END, updated_at DESC LIMIT 10 """ ), - {"start_date": start_date.isoformat(), "end_date": end_date.isoformat()}, + { + "start_date": start_date.isoformat(), + "end_date": end_date.isoformat(), + **_cost_analysis_hanmac_signature_params(), + }, ).mappings().all() if not candidates: candidates = conn.execute( @@ -12844,9 +14310,11 @@ def _cost_analysis_select_hanmac_member_metric( SELECT cache_key, start_date, end_date, updated_at FROM hanmac_aggregate_query_metrics WHERE view_mode = 'member' + AND payload_signature LIKE :compatible_signature_pattern AND COALESCE(start_date, '') <= :end_date AND COALESCE(end_date, '') >= :start_date ORDER BY + CASE WHEN payload_signature LIKE :current_signature_prefix THEN 0 ELSE 1 END, CASE WHEN COALESCE(start_date, '') <= :start_date AND COALESCE(end_date, '') >= :end_date @@ -12857,7 +14325,11 @@ def _cost_analysis_select_hanmac_member_metric( LIMIT 10 """ ), - {"start_date": start_date.isoformat(), "end_date": end_date.isoformat()}, + { + "start_date": start_date.isoformat(), + "end_date": end_date.isoformat(), + **_cost_analysis_hanmac_signature_params(), + }, ).mappings().all() if not candidates: return None @@ -12928,14 +14400,22 @@ def _cost_analysis_select_hanmac_prefix_metric( SELECT cache_key, start_date, end_date, updated_at FROM hanmac_aggregate_query_metrics WHERE view_mode = 'member' + AND payload_signature LIKE :compatible_signature_pattern AND COALESCE(start_date, '') <= :start_date AND COALESCE(end_date, '') >= :start_date AND COALESCE(end_date, '') <= :end_date - ORDER BY end_date DESC, updated_at DESC + ORDER BY + CASE WHEN payload_signature LIKE :current_signature_prefix THEN 0 ELSE 1 END, + end_date DESC, + updated_at DESC LIMIT 10 """ ), - {"start_date": start_date.isoformat(), "end_date": end_date.isoformat()}, + { + "start_date": start_date.isoformat(), + "end_date": end_date.isoformat(), + **_cost_analysis_hanmac_signature_params(), + }, ).mappings().all() candidate_dicts = [dict(candidate) for candidate in candidates] if prefer_member_grade: @@ -13023,6 +14503,7 @@ def _cost_analysis_load_hanmac_labor_map_by_year( return year_text = str((work_date or start_date).year) split_hours = hours / len(codes) + cost_weight = normalize_amount(project.get("cost_weight")) or 1.0 for code in codes: normalized_code = normalize_text(code).upper() if allowed_codes is not None and normalized_code not in allowed_codes: @@ -13040,11 +14521,12 @@ def _cost_analysis_load_hanmac_labor_map_by_year( normalized_code, (work_date or start_date).isoformat(), completion_dates, + project_meta, ) result.setdefault((work_date or start_date).year, {}).setdefault( normalized_code, {"pre": 0.0, "during": 0.0, "post": 0.0}, - )[phase] += rate * split_hours + )[phase] += rate * split_hours * cost_weight for row in row_items: member_grade = _normalize_labor_grade_name( @@ -13125,7 +14607,6 @@ def _cost_analysis_load_hanmac_labor_detail_rows( project_meta: dict[str, dict[str, Any]], ) -> list[dict[str, Any]]: normalized_requested_codes = {normalize_text(code).upper() for code in requested_codes if normalize_text(code)} - normalized_requested_codes -= COST_ANALYSIS_COMMON_CODES if not normalized_requested_codes: return [] @@ -13138,8 +14619,9 @@ def _cost_analysis_load_hanmac_labor_detail_rows( 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() + representative_map = _cost_analysis_get_link_representative_map() normalized_phase = normalize_text(requested_phase).lower() - detail_by_member: dict[tuple[str, str, str], dict[str, Any]] = {} + detail_by_member: dict[tuple[str, str, str, str], dict[str, Any]] = {} grade_order = { grade: index for index, grade in enumerate(("회장", "부회장", "사장", "부사장", "전무", "상무", "이사", "부장", "차장", "과장", "대리", "사원")) @@ -13161,16 +14643,24 @@ def _cost_analysis_load_hanmac_labor_detail_rows( codes = _cost_analysis_resolve_hanmac_project_codes(project, work_date, alias_to_code, title_to_codes, project_meta) if not codes: return + source_codes = _cost_analysis_project_source_codes(project) year_text = str((work_date or start_date).year) split_hours = hours / len(codes) + cost_weight = normalize_amount(project.get("cost_weight")) or 1.0 for code in codes: normalized_code = normalize_text(code).upper() - if normalized_code not in normalized_requested_codes: + is_common_activity = _cost_analysis_is_common_activity_code(normalized_code) + if ( + normalized_code not in normalized_requested_codes + and not (set(source_codes) & normalized_requested_codes) + and not (is_common_activity and "ZZZZZZ" in normalized_requested_codes) + ): continue phase = _cost_analysis_phase_for_transaction( normalized_code, (work_date or start_date).isoformat(), completion_dates, + project_meta, ) if normalized_phase and normalized_phase != "all" and phase != normalized_phase: continue @@ -13185,7 +14675,17 @@ def _cost_analysis_load_hanmac_labor_detail_rows( continue member_no = normalize_text(source_row.get("member_no")) member_name = normalize_text(source_row.get("member_name")) - key = (member_no, member_name, member_grade) + source_project_code = next( + (source_code for source_code in source_codes if source_code.startswith("H")), + normalized_code, + ) + total_project_code = ( + "ZZZZZZ" + if is_common_activity + else normalize_text(representative_map.get(normalized_code, "")).upper() + ) + display_project_code = source_project_code if is_common_activity else normalized_code + key = (member_no, member_name, member_grade, display_project_code) detail = detail_by_member.setdefault( key, { @@ -13193,14 +14693,37 @@ def _cost_analysis_load_hanmac_labor_detail_rows( "dept_name": normalize_text(source_row.get("dept_name")), "member_name": member_name, "member_grade": member_grade, + "total_project_code": total_project_code, + "project_code": display_project_code, + "source_project_code": source_project_code, + "project_name": ( + (_cost_analysis_common_activity_info(project) or {}).get("label") + if is_common_activity + else normalize_text(project.get("project_name")) + ), "regular_hours": 0.0, "overtime_hours": 0.0, "holiday_hours": 0.0, + "regular_amount": 0.0, + "overtime_base_amount": 0.0, + "overtime_premium_amount": 0.0, + "holiday_base_amount": 0.0, + "holiday_premium_amount": 0.0, "amount": 0.0, }, ) detail[f"{hour_kind}_hours"] += split_hours - detail["amount"] += rate * split_hours + base_amount = rate * split_hours * cost_weight + premium_amount = base_amount * 0.5 if hour_kind in {"overtime", "holiday"} else 0.0 + if hour_kind == "regular": + detail["regular_amount"] += base_amount + elif hour_kind == "overtime": + detail["overtime_base_amount"] += base_amount + detail["overtime_premium_amount"] += premium_amount + else: + detail["holiday_base_amount"] += base_amount + detail["holiday_premium_amount"] += premium_amount + detail["amount"] += base_amount + premium_amount for item in row_items: try: @@ -13249,7 +14772,15 @@ def _cost_analysis_load_hanmac_labor_detail_rows( holiday_hours = normalize_amount(detail.get("holiday_hours")) detail["total_hours"] = regular_hours + overtime_hours + holiday_hours detail["extra_hours"] = overtime_hours + holiday_hours - detail["amount"] = int(round(normalize_amount(detail.get("amount")))) + for field in ( + "regular_amount", + "overtime_base_amount", + "overtime_premium_amount", + "holiday_base_amount", + "holiday_premium_amount", + "amount", + ): + detail[field] = int(round(normalize_amount(detail.get(field)))) rows.append(detail) return sorted( rows, @@ -13257,6 +14788,7 @@ def _cost_analysis_load_hanmac_labor_detail_rows( normalize_text(item.get("dept_name")), grade_order.get(normalize_text(item.get("member_grade")), len(grade_order)), normalize_text(item.get("member_no")), + normalize_text(item.get("project_code")), ), ) @@ -13268,7 +14800,7 @@ def _cost_analysis_load_hanmac_labor_detail_rows_yearly( requested_phase: str, project_meta: dict[str, dict[str, Any]], ) -> list[dict[str, Any]]: - merged: dict[tuple[str, str, str], dict[str, Any]] = {} + merged: dict[tuple[str, str, str, str], dict[str, Any]] = {} for year_slice in _iter_year_slices(start_date, end_date): for row in _cost_analysis_load_hanmac_labor_detail_rows( year_slice["start"], @@ -13281,6 +14813,7 @@ def _cost_analysis_load_hanmac_labor_detail_rows_yearly( normalize_text(row.get("member_no")), normalize_text(row.get("member_name")), normalize_text(row.get("member_grade")), + normalize_text(row.get("project_code")), ) detail = merged.setdefault( key, @@ -13289,13 +14822,32 @@ def _cost_analysis_load_hanmac_labor_detail_rows_yearly( "dept_name": normalize_text(row.get("dept_name")), "member_name": normalize_text(row.get("member_name")), "member_grade": normalize_text(row.get("member_grade")), + "total_project_code": normalize_text(row.get("total_project_code")), + "project_code": normalize_text(row.get("project_code")), + "source_project_code": normalize_text(row.get("source_project_code")), + "project_name": normalize_text(row.get("project_name")), "regular_hours": 0.0, "overtime_hours": 0.0, "holiday_hours": 0.0, + "regular_amount": 0.0, + "overtime_base_amount": 0.0, + "overtime_premium_amount": 0.0, + "holiday_base_amount": 0.0, + "holiday_premium_amount": 0.0, "amount": 0.0, }, ) - for field in ("regular_hours", "overtime_hours", "holiday_hours", "amount"): + for field in ( + "regular_hours", + "overtime_hours", + "holiday_hours", + "regular_amount", + "overtime_base_amount", + "overtime_premium_amount", + "holiday_base_amount", + "holiday_premium_amount", + "amount", + ): detail[field] += normalize_amount(row.get(field)) rows = [] for detail in merged.values(): @@ -13304,7 +14856,15 @@ def _cost_analysis_load_hanmac_labor_detail_rows_yearly( holiday_hours = normalize_amount(detail.get("holiday_hours")) detail["total_hours"] = regular_hours + overtime_hours + holiday_hours detail["extra_hours"] = overtime_hours + holiday_hours - detail["amount"] = int(round(normalize_amount(detail.get("amount")))) + for field in ( + "regular_amount", + "overtime_base_amount", + "overtime_premium_amount", + "holiday_base_amount", + "holiday_premium_amount", + "amount", + ): + detail[field] = int(round(normalize_amount(detail.get(field)))) rows.append(detail) return sorted( rows, @@ -13312,10 +14872,192 @@ def _cost_analysis_load_hanmac_labor_detail_rows_yearly( normalize_text(item.get("dept_name")), normalize_text(item.get("member_grade")), normalize_text(item.get("member_no")), + normalize_text(item.get("project_code")), ), ) +def _is_hanmac_joint_detail(value: dict[str, Any]) -> bool: + return bool( + normalize_text(value.get("joint_code")) + or "합사" in normalize_text(value.get("joint_label")) + or "합사" in normalize_text(value.get("source_label")) + or "합사" in normalize_text(value.get("source")) + or "합사" in normalize_text(value.get("note")) + ) + +def _clear_project_cost_related_caches() -> None: + with _PROCESS_COST_RUNTIME_CACHE_LOCK: + _PROCESS_COST_PROJECT_OPTIONS_CACHE.clear() + _PROCESS_COST_PROJECT_DETAIL_CACHE.clear() + with engine.begin() as conn: + conn.execute(text("DELETE FROM system_page_cache WHERE page_key IN ('process_cost_bootstrap', 'cost_analysis_payload')")) + _clear_cost_analysis_payload_caches() + +def _cost_analysis_build_post_labor_summary( + start_date: date, + end_date: date, + codes: list[str], + row: dict[str, Any], + project_meta: dict[str, dict[str, Any]], + completion_dates: dict[str, str], +) -> dict[str, Any]: + normalized_codes = [normalize_text(code).upper() for code in codes if normalize_text(code)] + normalized_codes = [code for code in normalized_codes if code and code not in COST_ANALYSIS_COMMON_CODES] + if not normalized_codes: + return {} + completion_date_values = sorted({completion_dates.get(code, "") for code in normalized_codes if completion_dates.get(code, "")}) + completion_label = completion_date_values[0] if len(completion_date_values) == 1 else "코드별 준공금 기준" + detail_rows = _cost_analysis_load_hanmac_labor_detail_rows_yearly( + start_date, + end_date, + normalized_codes, + "post", + project_meta, + ) + if not detail_rows: + return { + "completion_billing_date": completion_label, + "member_count": 0, + "total_hours": 0.0, + "extra_hours": 0.0, + "amount": 0, + "display_amount": int(round(normalize_amount(((row.get("phases") or {}).get("post") or {}).get("labor")))), + "difference": -int(round(normalize_amount(((row.get("phases") or {}).get("post") or {}).get("labor")))), + "matches_display": False, + "people": [], + } + + total_hours = sum(normalize_amount(item.get("total_hours")) for item in detail_rows) + extra_hours = sum(normalize_amount(item.get("extra_hours")) for item in detail_rows) + amount = int(round(sum(normalize_amount(item.get("amount")) for item in detail_rows))) + display_amount = int(round(normalize_amount(((row.get("phases") or {}).get("post") or {}).get("labor")))) + unique_members = { + ( + normalize_text(item.get("member_no")), + normalize_text(item.get("member_name")), + normalize_text(item.get("member_grade")), + ) + for item in detail_rows + } + return { + "completion_billing_date": completion_label, + "member_count": len(unique_members), + "total_hours": round(total_hours, 2), + "extra_hours": round(extra_hours, 2), + "amount": amount, + "display_amount": display_amount, + "difference": int(round(amount - display_amount)), + "matches_display": abs(amount - display_amount) < 1, + "people": [ + { + "member_no": normalize_text(item.get("member_no")), + "member_name": normalize_text(item.get("member_name")), + "member_grade": normalize_text(item.get("member_grade")), + "total_project_code": normalize_text(item.get("total_project_code")), + "project_code": normalize_text(item.get("project_code")), + "regular_hours": round(normalize_amount(item.get("regular_hours")), 2), + "extra_hours": round(normalize_amount(item.get("extra_hours")), 2), + "total_hours": round(normalize_amount(item.get("total_hours")), 2), + "amount": int(round(normalize_amount(item.get("amount")))), + } + for item in detail_rows + ], + } + + +def _cost_analysis_build_allocated_common_detail_rows( + start_date: date, + end_date: date, + requested_codes: list[str], + requested_phase: str, + project_meta: dict[str, dict[str, Any]], + requested_item: str, +) -> list[dict[str, Any]]: + normalized_codes = {normalize_text(code).upper() for code in requested_codes if normalize_text(code)} + normalized_codes -= COST_ANALYSIS_COMMON_CODES + if not normalized_codes: + return [] + with engine.begin() as conn: + annual_common_rows = conn.execute( + text( + f""" + SELECT CAST(strftime('%Y', {COST_ANALYSIS_TX_DATE_SQL}) AS INTEGER) AS posting_year, + SUM( + CASE + WHEN account_code LIKE '6%' THEN COALESCE(amount, 0) + ELSE 0 + END + ) AS sga_amount, + SUM( + CASE + WHEN account_code LIKE '5%' + AND NOT ({COST_ANALYSIS_LABOR_ACCOUNT_SQL}) + THEN COALESCE(amount, 0) + ELSE 0 + END + ) AS common_cost_amount + FROM transactions + WHERE UPPER(COALESCE(support_dept_code, '')) IN ('', 'ZZZZZZ') + AND (account_code LIKE '5%' OR account_code LIKE '6%') + AND {COST_ANALYSIS_TX_DATE_SQL} >= :start_date + AND {COST_ANALYSIS_TX_DATE_SQL} <= :end_date + GROUP BY CAST(strftime('%Y', {COST_ANALYSIS_TX_DATE_SQL}) AS INTEGER) + """ + ), + {"start_date": start_date.isoformat(), "end_date": end_date.isoformat()}, + ).mappings().all() + normalized_item = normalize_text(requested_item).lower() + amount_field = "sga_amount" if normalized_item == "sga" else "common_cost_amount" + account_label = "공통 판관비 배부" if normalized_item == "sga" else "공통 제경비 배부" + annual_totals = { + int(row["posting_year"]): normalize_amount(row.get(amount_field)) + for row in annual_common_rows + if row.get("posting_year") + } + annual_hanmac_project_hours_by_year, _, annual_hanmac_missing_sga_by_year = _cost_analysis_load_hanmac_hours_and_labor_yearly( + start_date, + end_date, + project_meta, + None, + ) + normalized_phase = normalize_text(requested_phase).lower() + rows: list[dict[str, Any]] = [] + for year_slice in _iter_year_slices(start_date, end_date): + year = int(year_slice["year"]) + annual_total = annual_totals.get(year, 0.0) + if normalized_item == "sga": + annual_total += normalize_amount(annual_hanmac_missing_sga_by_year.get(year)) + hanmac_project_hours = annual_hanmac_project_hours_by_year.get(year, {}) + period_total_hours = sum( + normalize_amount(hours) + for phase_hours in hanmac_project_hours.values() + for hours in phase_hours.values() + ) + if annual_total <= 0 or period_total_hours <= 0: + continue + hourly_amount = annual_total / period_total_hours + for code in sorted(normalized_codes): + phase_hours = hanmac_project_hours.get(code) or {} + for phase, hours_value in phase_hours.items(): + if normalized_phase and normalized_phase != "all" and phase != normalized_phase: + continue + hours = normalize_amount(hours_value) + amount = int(round(hourly_amount * hours)) + if not amount: + continue + rows.append( + { + "posting_date": f"{year}", + "account_name": account_label, + "partner_name": code, + "memo1": f"{phase} 한맥근무 {hours:,.1f}h 기준 배부", + "amount": amount, + } + ) + return rows + + def _cost_analysis_collect_missing_hanmac_grade_rows( start_date: date, end_date: date, @@ -13363,7 +15105,7 @@ def _cost_analysis_collect_missing_hanmac_grade_rows( if allowed_codes is not None and normalized_code not in allowed_codes: continue meta = project_meta.get(normalized_code) or {} - phase = _cost_analysis_phase_for_transaction(normalized_code, effective_date.isoformat(), completion_dates) + phase = _cost_analysis_phase_for_transaction(normalized_code, effective_date.isoformat(), completion_dates, project_meta) result_rows.append( { "work_date": effective_date.isoformat(), @@ -13508,6 +15250,14 @@ def _cost_analysis_resolve_hanmac_project_codes( project_meta: dict[str, dict[str, Any]], ) -> list[str]: title_key = normalize_project_title_for_linking(project.get("project_name")) + source_codes = _cost_analysis_project_source_codes(project) + for source_code in source_codes: + confirmed_code = COST_ANALYSIS_CONFIRMED_H_PROJECT_CODE_MAP.get(source_code) + if confirmed_code and confirmed_code in project_meta: + return [confirmed_code] + confirmed_title_code = COST_ANALYSIS_CONFIRMED_H_TITLE_CODE_MAP.get(title_key) + if confirmed_title_code and confirmed_title_code in project_meta: + return [confirmed_title_code] title_candidates = title_to_codes.get(title_key, []) for value in [project.get("project_code"), *(project.get("equivalent_project_codes") or [])]: alias = normalize_text(value).upper() @@ -13522,7 +15272,8 @@ def _cost_analysis_resolve_hanmac_project_codes( break candidates = title_candidates if not candidates: - return [] + common_activity = _cost_analysis_common_activity_info(project) + return [common_activity["key"]] if common_activity else [] xyz_candidates = [ code for code in candidates @@ -13629,7 +15380,7 @@ def _cost_analysis_load_hanmac_project_hours_by_year( 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) + phase = _cost_analysis_phase_for_transaction(normalized_code, effective_date.isoformat(), completion_dates, project_meta) result.setdefault(effective_date.year, {}).setdefault( normalized_code, {"pre": 0.0, "during": 0.0, "post": 0.0}, @@ -13661,16 +15412,64 @@ def _cost_analysis_load_hanmac_project_hours_by_year( return result +_COST_ANALYSIS_ADMIN_DEPT_NAMES = { + "경영지원부", + "임원실", + "총괄기획실", + "기술개발센터", + "기술개발부", + "공통", + "관리실", + "인사총무", + "사업관리", +} + + +def _cost_analysis_normalize_dept_name(value: Any) -> str: + return re.sub(r"\s+", "", normalize_text(value)) + + +def _cost_analysis_is_admin_dept_name(value: Any) -> bool: + dept_name = _cost_analysis_normalize_dept_name(value) + if not dept_name: + return True + admin_names = {_cost_analysis_normalize_dept_name(name) for name in _COST_ANALYSIS_ADMIN_DEPT_NAMES} + if dept_name in admin_names: + return True + return any(token in dept_name for token in ("경영지원", "임원", "총괄", "관리실", "인사총무", "사업관리", "센터")) + + +@lru_cache(maxsize=1) +def _cost_analysis_field_cost_dept_names() -> set[str]: + init_db() + with engine.begin() as conn: + rows = conn.execute( + text( + """ + SELECT DISTINCT COALESCE(cost_dept_name, '') AS cost_dept_name + FROM transactions + WHERE UPPER(COALESCE(support_dept_code, '')) NOT IN ('', 'ZZZZZZ') + AND COALESCE(cost_dept_name, '') <> '' + """ + ) + ).mappings().all() + return { + _cost_analysis_normalize_dept_name(row.get("cost_dept_name")) + for row in rows + if not _cost_analysis_is_admin_dept_name(row.get("cost_dept_name")) + } + + 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]]]]: +) -> tuple[dict[int, dict[str, dict[str, float]]], dict[int, dict[str, dict[str, float]]], dict[int, 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 {}, {} + return {}, {}, {} rates_by_year = _parse_labor_rates_json(get_shared_exec_labor_rates_json()) if not rates_by_year: @@ -13678,9 +15477,12 @@ def _cost_analysis_load_hanmac_hours_and_labor_by_year( 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]]] = {} + missing_sga_result: dict[int, float] = {} + dept_project_phase_hours: dict[int, dict[str, dict[tuple[str, str], float]]] = {} resolve_cache: dict[tuple[str, str, str], list[str]] = {} + field_dept_names = _cost_analysis_field_cost_dept_names() - def add_project(project: dict[str, Any], work_date_text: Any, hours: float, member_grade: str = "") -> None: + def add_project(project: dict[str, Any], work_date_text: Any, hours: float, member_grade: str = "", dept_name: str = "") -> None: if hours <= 0: return work_date = _parse_iso_date(work_date_text) @@ -13701,17 +15503,25 @@ def _cost_analysis_load_hanmac_hours_and_labor_by_year( return split_hours = hours / len(codes) + cost_weight = normalize_amount(project.get("cost_weight")) or 1.0 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) + phase = _cost_analysis_phase_for_transaction(normalized_code, effective_date.isoformat(), completion_dates, project_meta) + normalized_dept_name = _cost_analysis_normalize_dept_name(dept_name) hours_result.setdefault(year, {}).setdefault( normalized_code, {"pre": 0.0, "during": 0.0, "post": 0.0}, )[phase] += split_hours + if normalized_dept_name and normalized_dept_name in field_dept_names: + split_key = (normalized_code, phase) + dept_project_phase_hours.setdefault(year, {}).setdefault(normalized_dept_name, {}) + dept_project_phase_hours[year][normalized_dept_name][split_key] = ( + dept_project_phase_hours[year][normalized_dept_name].get(split_key, 0.0) + split_hours + ) if not member_grade: continue @@ -13727,7 +15537,7 @@ def _cost_analysis_load_hanmac_hours_and_labor_by_year( labor_result.setdefault(year, {}).setdefault( normalized_code, {"pre": 0.0, "during": 0.0, "post": 0.0}, - )[phase] += rate * split_hours + )[phase] += rate * split_hours * cost_weight for row in row_items: member_grade = _normalize_labor_grade_name( @@ -13736,6 +15546,7 @@ def _cost_analysis_load_hanmac_hours_and_labor_by_year( or row.get("position") or row.get("rank") ) + dept_name = normalize_text(row.get("dept_name")) 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 [] @@ -13743,10 +15554,15 @@ def _cost_analysis_load_hanmac_hours_and_labor_by_year( 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) + joint_recognized_hours = normalize_amount(project.get("recognized_hours")) if _is_hanmac_joint_detail(project) else 0.0 + hours = ( + joint_recognized_hours + if joint_recognized_hours > 0 + else 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, dept_name) for detail in details.get("overtime_hours") or []: - add_project(detail, detail.get("work_date"), normalize_amount(detail.get("overtime_hours")), member_grade) + add_project(detail, detail.get("work_date"), normalize_amount(detail.get("overtime_hours")), member_grade, dept_name) for detail in details.get("holiday_hours") or []: projects = detail.get("projects") if isinstance(detail.get("projects"), list) else [] if projects: @@ -13755,10 +15571,50 @@ def _cost_analysis_load_hanmac_hours_and_labor_by_year( 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) + add_project(project, detail.get("work_date"), hours, member_grade, dept_name) else: - add_project(detail, detail.get("work_date"), normalize_amount(detail.get("holiday_hours")), member_grade) - return hours_result, labor_result + add_project(detail, detail.get("work_date"), normalize_amount(detail.get("holiday_hours")), member_grade, dept_name) + + 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") + ) + if not member_grade: + continue + normalized_dept_name = _cost_analysis_normalize_dept_name(row.get("dept_name")) + details = row.get("aggregate_details") if isinstance(row.get("aggregate_details"), dict) else {} + for detail in details.get("missing_regular_days") or []: + missing_hours = normalize_amount(detail.get("missing_hours")) + if missing_hours <= 0: + continue + work_date = _parse_iso_date(detail.get("work_date")) or start_date + if work_date < start_date or work_date > end_date: + continue + year = work_date.year + year_text = str(year) + rate = _resolve_labor_rate(rates_by_year, member_grade, year_text, year_text, "") + missing_amount = rate * missing_hours + if missing_amount <= 0: + continue + dept_ratios = dept_project_phase_hours.get(year, {}).get(normalized_dept_name, {}) + dept_total_hours = sum(max(0.0, normalize_amount(hours)) for hours in dept_ratios.values()) + if normalized_dept_name in field_dept_names and dept_total_hours > 0: + for (code, phase), hours in dept_ratios.items(): + if allowed_codes is not None and code not in allowed_codes: + continue + amount = missing_amount * (normalize_amount(hours) / dept_total_hours) + if amount <= 0: + continue + labor_result.setdefault(year, {}).setdefault( + code, + {"pre": 0.0, "during": 0.0, "post": 0.0}, + )[phase] += amount + else: + missing_sga_result[year] = missing_sga_result.get(year, 0.0) + missing_amount + return hours_result, labor_result, missing_sga_result def _cost_analysis_load_hanmac_hours_and_labor_yearly( @@ -13766,13 +15622,15 @@ def _cost_analysis_load_hanmac_hours_and_labor_yearly( 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]]]]: +) -> tuple[dict[int, dict[str, dict[str, float]]], dict[int, dict[str, dict[str, float]]], dict[int, float]]: hours_result: dict[int, dict[str, dict[str, float]]] = {} labor_result: dict[int, dict[str, dict[str, float]]] = {} + missing_sga_result: dict[int, float] = {} def merge( hours_by_year: dict[int, dict[str, dict[str, float]]], labor_by_year: dict[int, dict[str, dict[str, float]]], + missing_sga_by_year: dict[int, float], ) -> None: for year, code_map in hours_by_year.items(): for code, phase_hours in code_map.items(): @@ -13786,6 +15644,8 @@ def _cost_analysis_load_hanmac_hours_and_labor_yearly( for phase, amount in phase_amounts.items(): if phase in target: target[phase] += normalize_amount(amount) + for year, amount in missing_sga_by_year.items(): + missing_sga_result[year] = missing_sga_result.get(year, 0.0) + 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")) @@ -13798,7 +15658,7 @@ def _cost_analysis_load_hanmac_hours_and_labor_yearly( )) start_date = min(prefix_end, end_date) + timedelta(days=1) if start_date > end_date: - return hours_result, labor_result + return hours_result, labor_result, missing_sga_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"], @@ -13806,7 +15666,25 @@ def _cost_analysis_load_hanmac_hours_and_labor_yearly( project_meta, allowed_codes, )) - return hours_result, labor_result + return hours_result, labor_result, missing_sga_result + + +def _cost_analysis_hanmac_labor_totals_by_year( + labor_by_year: dict[int, dict[str, dict[str, float]]], + missing_sga_by_year: dict[int, float], +) -> dict[int, float]: + years = set(labor_by_year) | set(missing_sga_by_year) + return { + year: ( + sum( + normalize_amount(amount) + for phase_amounts in labor_by_year.get(year, {}).values() + for amount in phase_amounts.values() + ) + + normalize_amount(missing_sga_by_year.get(year)) + ) + for year in years + } def _cost_analysis_get_annual_hanmac_total_hours(year: int) -> float: @@ -13819,15 +15697,21 @@ def _cost_analysis_get_annual_hanmac_total_hours(year: int) -> float: SELECT cache_key, summary_json FROM hanmac_aggregate_query_metrics WHERE view_mode = 'member' + AND payload_signature LIKE :compatible_signature_pattern AND COALESCE(start_date, '') <= :year_start AND COALESCE(end_date, '') >= :year_end ORDER BY + CASE WHEN payload_signature LIKE :current_signature_prefix THEN 0 ELSE 1 END, CASE WHEN start_date = :year_start AND end_date = :year_end THEN 0 ELSE 1 END, updated_at DESC LIMIT 1 """ ), - {"year_start": year_start, "year_end": year_end}, + { + "year_start": year_start, + "year_end": year_end, + **_cost_analysis_hanmac_signature_params(), + }, ).mappings().first() if not metric: return 0.0 @@ -13851,21 +15735,44 @@ def _cost_analysis_get_annual_hanmac_total_hours(year: int) -> float: return sum(normalize_amount((json.loads(row) if row else {}).get("total_hours")) for row in rows) -def _cost_analysis_phase_for_transaction(code: str, posting_date: str, completion_dates: dict[str, str]) -> str: +def _cost_analysis_phase_for_transaction( + code: str, + posting_date: str, + completion_dates: dict[str, str], + project_meta: dict[str, dict[str, Any]] | None = None, +) -> str: normalized_code = normalize_text(code).upper() + if _cost_analysis_is_common_activity_code(normalized_code): + return "during" + meta = (project_meta or {}).get(normalized_code) or {} if normalized_code.startswith("X"): return "pre" if normalized_code.startswith(("Y", "Z")): + has_start_date = bool(normalize_text(meta.get("project_start_date"))) completion_date = completion_dates.get(normalized_code, "") - if completion_date and posting_date and posting_date >= completion_date: + collection_complete = ( + normalize_amount(meta.get("collection_rate")) >= 99.5 + or ( + normalize_amount(meta.get("contract_amount")) > 0 + and normalize_amount(meta.get("collected_amount")) >= normalize_amount(meta.get("contract_amount")) - 1 + ) + or ( + normalize_amount(meta.get("collected_amount")) > 0 + and abs(normalize_amount(meta.get("collection_balance_amount"))) <= 1 + ) + ) + if completion_date and posting_date and posting_date >= completion_date and (has_start_date or collection_complete): return "post" return "during" + if meta and (normalize_text(meta.get("project_type")) == "사업전" or not normalize_text(meta.get("project_start_date"))): + return "pre" return "during" def _cost_analysis_row_template(code: str, meta: dict[str, Any], selected_year: int | None) -> dict[str, Any]: contract_amount = normalize_amount(meta.get("contract_amount")) return { + "row_key": normalize_text(meta.get("row_key")) or code, "support_dept_code": code, "pm_department": normalize_text(meta.get("pm_department")), "year": selected_year or "", @@ -13878,9 +15785,14 @@ def _cost_analysis_row_template(code: str, meta: dict[str, Any], selected_year: "billing_amount": 0.0, "collection_amount": 0.0, "period_billing_amount": 0.0, + "period_negative_billing_amount": 0.0, "period_collection_amount": 0.0, "period_revenue_amount": 0.0, + "period_revenue_billing_gap": 0.0, + "period_revenue_collection_gap": 0.0, "period_cost_total": 0.0, + "period_cost_labor_total": 0.0, + "period_sga_labor_total": 0.0, "period_sga_total": 0.0, "period_sales_total": 0.0, "period_total_cost": 0.0, @@ -13888,6 +15800,12 @@ def _cost_analysis_row_template(code: str, meta: dict[str, Any], selected_year: "period_revenue_profit_rate": 0.0, "period_collection_profit_rate": 0.0, "cumulative_profit_rate": 0.0, + "cumulative_revenue_amount": 0.0, + "cumulative_cost_total": 0.0, + "cumulative_sga_total": 0.0, + "cumulative_sales_total": 0.0, + "cumulative_total_cost": 0.0, + "cumulative_profit_amount": 0.0, "contract_balance_amount": contract_amount, "collection_rate": 0.0, "revenue_amount": 0.0, @@ -13901,37 +15819,53 @@ def _cost_analysis_row_template(code: str, meta: dict[str, Any], selected_year: "collection_profit_rate": 0.0, "total_cost": 0.0, "cost_total": 0.0, + "cost_labor_total": 0.0, + "sga_labor_total": 0.0, "sga_total": 0.0, "sales_total": 0.0, + "is_common_revenue": False, + "common_revenue_amount": 0.0, } def _cost_analysis_finalize_row(row: dict[str, Any]) -> None: cost_total = 0.0 + cost_labor_total = 0.0 + sga_labor_total = 0.0 sga_total = 0.0 sales_total = 0.0 for phase_name, buckets in row["phases"].items(): for item_key, amount in buckets.items(): amount = normalize_amount(amount) - if item_key in {"labor", "outsource", "overhead"}: + if item_key in {"labor", "labor_adjustment", "outsource", "overhead"}: cost_total += amount + if item_key in {"labor", "labor_adjustment"}: + cost_labor_total += amount elif item_key == "sales": sales_total += amount else: sga_total += amount + if item_key in {"sga_labor", "sga_labor_adjustment"}: + sga_labor_total += amount row["cost_total"] = cost_total + row["cost_labor_total"] = cost_labor_total + row["sga_labor_total"] = sga_labor_total row["sga_total"] = sga_total 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_cost_labor_total"] = cost_labor_total + row["period_sga_labor_total"] = sga_labor_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["period_revenue_billing_gap"] = row["period_revenue_amount"] - normalize_amount(row.get("period_billing_amount")) + row["period_revenue_collection_gap"] = row["period_revenue_amount"] - normalize_amount(row.get("period_collection_amount")) row["contract_balance_amount"] = max( - normalize_amount(row.get("contract_amount")) - normalize_amount(row.get("collection_amount")), + normalize_amount(row.get("contract_amount")) - normalize_amount(row.get("billing_amount")), 0.0, ) row["collection_rate"] = _safe_ratio(row.get("collection_amount"), row.get("contract_amount")) @@ -13940,10 +15874,56 @@ def _cost_analysis_finalize_row(row: dict[str, Any]) -> None: 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"), + row["cumulative_revenue_amount"] = normalize_amount(row.get("cumulative_revenue_amount")) + row["cumulative_cost_total"] = normalize_amount(row.get("cumulative_cost_total")) + row["cumulative_sga_total"] = normalize_amount(row.get("cumulative_sga_total")) + row["cumulative_sales_total"] = normalize_amount(row.get("cumulative_sales_total")) + row["cumulative_total_cost"] = normalize_amount(row.get("cumulative_total_cost")) + row["cumulative_profit_amount"] = ( + row["cumulative_revenue_amount"] - row["cumulative_total_cost"] ) + row["cumulative_profit_rate"] = _safe_ratio( + row["cumulative_profit_amount"], + row["cumulative_revenue_amount"], + ) + + +def _cost_analysis_clean_common_master_row(row: dict[str, Any]) -> None: + if not row.get("is_common_master"): + return + preserved_period_revenue = normalize_amount(row.get("period_revenue_amount")) + preserved_collection = normalize_amount(row.get("collection_amount")) + preserved_period_collection = normalize_amount(row.get("period_collection_amount")) + for key in ( + "billing_amount", "collection_amount", "period_billing_amount", + "period_negative_billing_amount", "period_collection_amount", + "period_revenue_billing_gap", "period_revenue_collection_gap", + "period_cost_total", "period_cost_labor_total", "period_sga_labor_total", + "period_sga_total", "period_sales_total", "period_total_cost", + "period_profit_amount", "period_revenue_profit_rate", + "period_collection_profit_rate", "contract_amount", + "contract_balance_amount", "collection_rate", "revenue_amount", + "cost_total", "cost_labor_total", "sga_labor_total", "sga_total", + "sales_total", "total_cost", "profit_amount", "contract_profit_rate", + "revenue_profit_rate", "collection_profit_rate", + "cumulative_revenue_amount", "cumulative_cost_total", + "cumulative_sga_total", "cumulative_sales_total", + "cumulative_total_cost", "cumulative_profit_amount", + "cumulative_profit_rate", + ): + row[key] = 0.0 + row["period_revenue_amount"] = preserved_period_revenue + row["collection_amount"] = preserved_collection + row["period_collection_amount"] = preserved_period_collection + row["period_revenue_collection_gap"] = preserved_period_revenue - preserved_period_collection + row["period_profit_amount"] = preserved_period_revenue + row["profit_amount"] = preserved_period_revenue + row["period_revenue_profit_rate"] = _safe_ratio(preserved_period_revenue, preserved_period_revenue) + row["period_collection_profit_rate"] = _safe_ratio(preserved_period_revenue, preserved_period_collection) + row["collection_profit_rate"] = _safe_ratio(preserved_period_revenue, preserved_collection) + row["cumulative_revenue_amount"] = preserved_period_revenue + row["cumulative_profit_amount"] = preserved_period_revenue + row["cumulative_profit_rate"] = _safe_ratio(preserved_period_revenue, preserved_period_revenue) def _cost_analysis_active_in_year(meta: dict[str, Any], year: int) -> bool: @@ -13976,23 +15956,92 @@ def _cost_analysis_project_group_key(code: str, row: dict[str, Any]) -> str: return normalized_code -def _cost_analysis_get_link_representative_map() -> dict[str, str]: +def _cost_analysis_link_map_source_version() -> str: + with engine.begin() as conn: + row = conn.execute( + text( + """ + SELECT + (SELECT COUNT(*) FROM project_related_links) AS link_count, + (SELECT COALESCE(MAX(updated_at), '') FROM project_related_links) AS link_updated_at, + (SELECT COUNT(*) FROM project_billing_entries) AS billing_count, + (SELECT COALESCE(MAX(updated_at), '') FROM project_billing_entries) AS billing_updated_at, + (SELECT COUNT(*) FROM project_basic_info) AS project_count, + (SELECT COALESCE(MAX(updated_at), '') FROM project_basic_info) AS project_updated_at, + (SELECT COUNT(*) FROM satis_project_code_links) AS satis_link_count, + (SELECT COALESCE(MAX(updated_at), '') FROM satis_project_code_links) AS satis_link_updated_at + """ + ) + ).mappings().first() or {} + return _json_hash( + { + "logic": COST_ANALYSIS_LINK_LOGIC_VERSION, + "link_count": int(row.get("link_count") or 0), + "link_updated_at": normalize_text(row.get("link_updated_at")), + "billing_count": int(row.get("billing_count") or 0), + "billing_updated_at": normalize_text(row.get("billing_updated_at")), + "project_count": int(row.get("project_count") or 0), + "project_updated_at": normalize_text(row.get("project_updated_at")), + "satis_link_count": int(row.get("satis_link_count") or 0), + "satis_link_updated_at": normalize_text(row.get("satis_link_updated_at")), + } + ) + + +def _cost_analysis_build_link_representative_map() -> dict[str, str]: with engine.begin() as conn: rows = conn.execute( text( """ - SELECT base_support_dept_code, related_support_dept_code + SELECT base_support_dept_code, related_support_dept_code, link_source FROM project_related_links WHERE COALESCE(base_support_dept_code, '') <> '' AND COALESCE(related_support_dept_code, '') <> '' """ ) ).mappings().all() + billing_rows = conn.execute( + text( + """ + SELECT support_dept_code, raw_project_code, round_code + FROM project_billing_entries + WHERE COALESCE(raw_project_code, '') <> '' + AND COALESCE(round_code, '') <> '' + """ + ) + ).mappings().all() + satis_link_rows = conn.execute( + text( + """ + SELECT + local_project_code, + own_master_project_code, + linked_main_project_code, + cost_project_code, + mapping_status + FROM satis_project_code_links + WHERE COALESCE(local_project_code, '') <> '' + AND COALESCE(mapping_status, '') IN ('confirmed', 'exception') + """ + ) + ).mappings().all() graph: dict[str, set[str]] = {} for row in rows: base = normalize_text(row.get("base_support_dept_code")).upper() related = normalize_text(row.get("related_support_dept_code")).upper() - if not base or not related: + link_source = normalize_text(row.get("link_source")).lower() + if ( + not base + or not related + or link_source not in { + "manual", + "auto_billing", + "auto_satis_code", + "auto_code_family", + "auto_round", + "auto_change_contract", + } + ): continue graph.setdefault(base, set()).add(related) graph.setdefault(related, set()).add(base) @@ -14013,60 +16062,150 @@ def _cost_analysis_get_link_representative_map() -> dict[str, str]: total_codes = sorted(code for code in component if code[:1] in {"0", "9"}) if not total_codes: continue - representative = total_codes[0] for code in component: - representative_map[code] = representative + preferred_prefix = "9" if code.startswith("X") else "0" if code.startswith(("Y", "Z")) else code[:1] + matching_totals = [candidate for candidate in total_codes if candidate.startswith(preferred_prefix)] + representative_map[code] = (matching_totals or total_codes)[0] representative_map.update(_cost_analysis_infer_yz_link_representatives(representative_map)) + # The billing application explicitly stores its parent contract code and + # charged round code. This direct relation is authoritative when a broader + # graph component contains more than one possible total project. + for row in billing_rows: + base_code = normalize_text(row.get("support_dept_code")).upper() + total_code = normalize_actual_project_code(row.get("raw_project_code")) + round_code = normalize_project_code( + row.get("round_code"), + default_prefix=base_code[:1] or "Y", + ) + if total_code[:1] not in {"0", "9"} or not total_code.isdigit(): + continue + if round_code: + representative_map[round_code] = total_code + if base_code: + representative_map[base_code] = total_code + representative_map[total_code] = total_code + # Satis 차수사업코드등록은 총괄/차수/사전사업 연결의 공식 원장이다. + # 프로젝트 손익분석의 집계 대표코드도 동일한 기준을 사용해야 예산 + # 합산 결과와 손익분석 화면의 프로젝트 묶음이 어긋나지 않는다. + for row in satis_link_rows: + local_code = normalize_text(row.get("local_project_code")).upper() + if not local_code or local_code == "ZZZZZZ": + continue + master_code = ( + normalize_text(row.get("linked_main_project_code")).upper() + or normalize_text(row.get("own_master_project_code")).upper() + or normalize_text(row.get("cost_project_code")).upper() + ) + if master_code[:1] not in {"0", "9"} or not master_code[1:].isdigit(): + continue + representative_map[local_code] = master_code + representative_map[master_code] = master_code return representative_map +def _cost_analysis_get_link_representative_map(force: bool = False) -> dict[str, str]: + source_version = _cost_analysis_link_map_source_version() + memory_key = (source_version,) + if not force: + cached = _get_deepcopy_ttl_cache_entry( + _COST_ANALYSIS_LINK_MAP_CACHE, + _COST_ANALYSIS_LINK_MAP_CACHE_LOCK, + memory_key, + COST_ANALYSIS_LINK_MAP_CACHE_TTL_SECONDS, + ) + if cached is not None: + return cached + persistent = _load_system_page_cache("cost_analysis_link_map", source_version) + if persistent is not None and isinstance(persistent.get("representative_map"), dict): + return _set_deepcopy_ttl_cache_entry( + _COST_ANALYSIS_LINK_MAP_CACHE, + _COST_ANALYSIS_LINK_MAP_CACHE_LOCK, + memory_key, + persistent["representative_map"], + ) + representative_map = _cost_analysis_build_link_representative_map() + _store_system_page_cache( + "cost_analysis_link_map", + source_version, + params={ + "source_version": source_version, + "logic_version": COST_ANALYSIS_LINK_LOGIC_VERSION, + }, + payload={"representative_map": representative_map}, + row_count=len(representative_map), + signature=source_version, + ) + return _set_deepcopy_ttl_cache_entry( + _COST_ANALYSIS_LINK_MAP_CACHE, + _COST_ANALYSIS_LINK_MAP_CACHE_LOCK, + memory_key, + 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] = {} + representative_titles: dict[str, str] = { + normalize_text(code).upper(): normalize_project_title_for_linking(meta.get("support_dept_name")) + for code, meta in project_meta.items() + if normalize_text(code).upper().startswith(("0", "9")) + } + exact_title_index: dict[tuple[str, str], list[str]] = {} + trigram_index: dict[tuple[str, str], set[str]] = {} + for representative, title in representative_titles.items(): + if len(title) < 8: + continue + prefix = representative[:1] + exact_title_index.setdefault((prefix, title), []).append(representative) + for index in range(max(1, len(title) - 2)): + trigram = title[index:index + 3] + if trigram: + trigram_index.setdefault((prefix, trigram), set()).add(representative) 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"} + or normalized_code[:1] not in {"X", "Y", "Z"} ): continue - title = normalize_project_title_for_linking(meta.get("support_dept_name")) - if not title: + expected_total_prefix = "9" if normalized_code.startswith("X") else "0" + suffix = contract_family_code_suffix(normalized_code) + expected_total_code = f"{expected_total_prefix}{suffix.zfill(5)}" if suffix else "" + if expected_total_code in project_meta: + inferred[normalized_code] = expected_total_code 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)) + title = normalize_project_title_for_linking(meta.get("support_dept_name")) + if len(title) < 8: + continue + exact_matches = exact_title_index.get((expected_total_prefix, title), []) + if len(exact_matches) == 1: + inferred[normalized_code] = exact_matches[0] + continue + candidate_overlap: dict[str, int] = {} + for index in range(max(1, len(title) - 2)): + trigram = title[index:index + 3] + for representative in trigram_index.get((expected_total_prefix, trigram), set()): + candidate_overlap[representative] = candidate_overlap.get(representative, 0) + 1 + candidate_codes = [ + representative + for representative, _ in sorted(candidate_overlap.items(), key=lambda item: (-item[1], item[0]))[:24] + ] + matches: list[tuple[float, str]] = [] + for representative in candidate_codes: + candidate_title = representative_titles.get(representative, "") + if len(candidate_title) < 8: + continue + score = SequenceMatcher(None, title, candidate_title).ratio() + if title in candidate_title or candidate_title in title: + score = max(score, min(len(title), len(candidate_title)) / max(len(title), len(candidate_title))) + if score >= 0.94: + matches.append((score, representative)) if not matches: continue matches.sort(reverse=True) - if len(matches) > 1 and matches[0][0] == matches[1][0]: + if len(matches) > 1 and matches[0][0] - matches[1][0] < 0.12: continue inferred[normalized_code] = matches[0][1] return inferred @@ -14082,6 +16221,8 @@ def _cost_analysis_latest_row(rows: list[dict[str, Any]]) -> dict[str, Any]: def _cost_analysis_linked_group_key(row: dict[str, Any], representative_map: dict[str, str]) -> str: + if row.get("is_common_activity"): + return normalize_text(row.get("row_key")) or normalize_text(row.get("project_name")) codes = [ normalize_text(row.get("support_dept_code")).upper(), *[normalize_text(code).upper() for code in (row.get("direct_codes") or [])], @@ -14091,7 +16232,7 @@ def _cost_analysis_linked_group_key(row: dict[str, Any], representative_map: dic representative = representative_map.get(code) if representative: return representative - return _cost_analysis_project_group_key(row.get("support_dept_code", ""), row) + return normalize_text(row.get("support_dept_code")).upper() def _cost_analysis_aggregate_rows( @@ -14100,9 +16241,15 @@ def _cost_analysis_aggregate_rows( representative_map: dict[str, str] | None = None, ) -> list[dict[str, Any]]: representative_map = representative_map or _cost_analysis_get_link_representative_map() + satis_display = _cost_analysis_get_satis_display_maps() + satis_names = satis_display.get("names") or {} grouped: dict[str, list[dict[str, Any]]] = {} + common_revenue_rows: list[dict[str, Any]] = [] for row in rows: + if row.get("is_common_revenue"): + common_revenue_rows.append(copy.deepcopy(row)) + continue grouped.setdefault(_cost_analysis_linked_group_key(row, representative_map), []).append(row) result: list[dict[str, Any]] = [] @@ -14113,6 +16260,7 @@ def _cost_analysis_aggregate_rows( aggregate = copy.deepcopy(latest) aggregate["view_mode"] = "aggregate" aggregate["aggregate_project_count"] = len(group_rows) + aggregate["row_key"] = representative_code aggregate["aggregate_codes"] = sorted({normalize_text(row.get("support_dept_code")).upper() for row in group_rows if normalize_text(row.get("support_dept_code"))}) aggregate["aggregate_members"] = sorted( [ @@ -14136,7 +16284,10 @@ def _cost_analysis_aggregate_rows( ) if representative_meta: aggregate["support_dept_code"] = representative_code - aggregate["project_name"] = normalize_text(representative_meta.get("support_dept_name")) or representative_code + representative_name = normalize_text(representative_meta.get("support_dept_name")) or normalize_text(satis_names.get(representative_code)) or representative_code + if _cost_analysis_name_needs_display_fix(representative_name, representative_code): + representative_name = normalize_text(satis_names.get(representative_code)) or representative_name + aggregate["project_name"] = representative_name aggregate["pm_department"] = normalize_text(representative_meta.get("pm_department")) or normalize_text(aggregate.get("pm_department")) aggregate["project_type"] = _cost_analysis_project_type(representative_code, representative_meta.get("project_type")) aggregate["completion_status"] = normalize_text(representative_meta.get("completion_status")) or normalize_text(aggregate.get("completion_status")) @@ -14146,6 +16297,11 @@ def _cost_analysis_aggregate_rows( for code in [row.get("support_dept_code"), *(row.get("direct_codes") or [])] if normalize_text(code) }) + aggregate["allocation_details"] = [ + copy.deepcopy(detail) + for row in group_rows + for detail in (row.get("allocation_details") or []) + ] start_dates = [_parse_iso_date(row.get("project_start_date")) for row in group_rows] end_dates = [_parse_iso_date(row.get("project_end_date")) for row in group_rows] aggregate["project_start_date"] = min([value for value in start_dates if value], default=None) @@ -14160,8 +16316,11 @@ def _cost_analysis_aggregate_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_negative_billing_amount"] = sum(normalize_amount(row.get("period_negative_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["period_revenue_amount"] = sum(normalize_amount(row.get("period_revenue_amount")) for row in group_rows) + aggregate["period_revenue_billing_gap"] = sum(normalize_amount(row.get("period_revenue_billing_gap")) for row in group_rows) + aggregate["period_revenue_collection_gap"] = sum(normalize_amount(row.get("period_revenue_collection_gap")) 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() @@ -14179,29 +16338,213 @@ 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_cost_total"] = sum(normalize_amount(row.get("period_cost_total")) for row in group_rows) + aggregate["period_cost_labor_total"] = sum(normalize_amount(row.get("period_cost_labor_total")) for row in group_rows) + aggregate["period_sga_labor_total"] = sum(normalize_amount(row.get("period_sga_labor_total")) for row in group_rows) + aggregate["period_sga_total"] = sum(normalize_amount(row.get("period_sga_total")) for row in group_rows) + aggregate["period_sales_total"] = sum(normalize_amount(row.get("period_sales_total")) for row in group_rows) + aggregate["period_total_cost"] = sum(normalize_amount(row.get("period_total_cost")) for row in group_rows) aggregate["period_profit_amount"] = aggregate["period_revenue_amount"] - aggregate["period_total_cost"] + aggregate["common_revenue_amount"] = sum(normalize_amount(row.get("common_revenue_amount")) for row in group_rows) + aggregate["cumulative_revenue_amount"] = sum(normalize_amount(row.get("cumulative_revenue_amount")) for row in group_rows) + aggregate["cumulative_cost_total"] = sum(normalize_amount(row.get("cumulative_cost_total")) for row in group_rows) + aggregate["cumulative_sga_total"] = sum(normalize_amount(row.get("cumulative_sga_total")) for row in group_rows) + aggregate["cumulative_sales_total"] = sum(normalize_amount(row.get("cumulative_sales_total")) for row in group_rows) + aggregate["cumulative_total_cost"] = sum(normalize_amount(row.get("cumulative_total_cost")) for row in group_rows) + aggregate["cumulative_profit_amount"] = aggregate["cumulative_revenue_amount"] - aggregate["cumulative_total_cost"] _cost_analysis_finalize_row(aggregate) result.append(aggregate) + result.extend(common_revenue_rows) return result -def _cost_analysis_build_payload(start_date_text: str, end_date_text: str, mode: str = "individual") -> dict[str, Any]: +def _cost_analysis_apply_individual_display_codes( + rows: list[dict[str, Any]], + project_meta: dict[str, dict[str, Any]], +) -> list[dict[str, Any]]: + satis_display = _cost_analysis_get_satis_display_maps() + preferred_round = satis_display.get("preferred_round") or {} + satis_names = satis_display.get("names") or {} + if not preferred_round: + for row in rows: + code = normalize_text(row.get("support_dept_code")).upper() + if _cost_analysis_name_needs_display_fix(row.get("project_name"), code): + row["project_name"] = normalize_text(satis_names.get(code)) or normalize_text(row.get("project_name")) or code + return rows + + def display_code_for(code: Any) -> str: + normalized = normalize_text(code).upper() + if _COST_ANALYSIS_MASTER_CODE_RE.fullmatch(normalized): + return normalize_text(preferred_round.get(normalized)).upper() or normalized + return normalized + + def display_name_for(code: str, fallback: Any = "") -> str: + meta = project_meta.get(code) or {} + name = normalize_text(meta.get("support_dept_name")) or normalize_text(satis_names.get(code)) or normalize_text(fallback) or code + if _cost_analysis_name_needs_display_fix(name, code): + name = normalize_text(satis_names.get(code)) or name + return name + + grouped: dict[str, list[dict[str, Any]]] = {} + for source_row in rows: + row = copy.deepcopy(source_row) + original_code = normalize_text(row.get("support_dept_code")).upper() + display_code = display_code_for(original_code) + if display_code and display_code != original_code: + row["support_dept_code"] = display_code + row["row_key"] = display_code + row["project_name"] = display_name_for(display_code, row.get("project_name")) + display_meta = project_meta.get(display_code) or {} + row["pm_department"] = normalize_text(display_meta.get("pm_department")) or normalize_text(row.get("pm_department")) + row["project_type"] = _cost_analysis_project_type(display_code, display_meta.get("project_type") or row.get("project_type")) + row["completion_status"] = normalize_text(display_meta.get("completion_status")) or normalize_text(row.get("completion_status")) + row["project_start_date"] = normalize_text(display_meta.get("project_start_date")) or normalize_text(row.get("project_start_date")) + row["project_end_date"] = normalize_text(display_meta.get("project_end_date")) or normalize_text(row.get("project_end_date")) + row["direct_codes"] = sorted({ + display_code, + original_code, + *[normalize_text(code).upper() for code in (row.get("direct_codes") or []) if normalize_text(code)], + }) + elif _cost_analysis_name_needs_display_fix(row.get("project_name"), original_code): + row["project_name"] = display_name_for(original_code, row.get("project_name")) + normalized_display_code = normalize_text(row.get("support_dept_code")).upper() + if row.get("is_common_master"): + group_key = f"{normalized_display_code}::common-master" + elif row.get("is_common_activity") or row.get("is_hidden_common_activity"): + group_key = f"{normalized_display_code}::common-activity::{normalize_text(row.get('row_key')) or normalize_text(row.get('project_name'))}" + else: + group_key = normalized_display_code + grouped.setdefault(group_key, []).append(row) + + merged_rows: list[dict[str, Any]] = [] + additive_fields = { + "billing_amount", + "collection_amount", + "period_billing_amount", + "period_negative_billing_amount", + "period_collection_amount", + "period_revenue_amount", + "period_revenue_billing_gap", + "period_revenue_collection_gap", + "period_cost_total", + "period_cost_labor_total", + "period_sga_labor_total", + "period_sga_total", + "period_sales_total", + "period_total_cost", + "period_profit_amount", + "cumulative_revenue_amount", + "cumulative_cost_total", + "cumulative_sga_total", + "cumulative_sales_total", + "cumulative_total_cost", + "cumulative_profit_amount", + "revenue_amount", + "common_revenue_amount", + } + for group_key, group_rows in grouped.items(): + display_code = normalize_text(group_rows[0].get("support_dept_code")).upper() if group_rows else normalize_text(group_key).split("::", 1)[0] + if len(group_rows) == 1: + merged_rows.append(group_rows[0]) + continue + base = copy.deepcopy(_cost_analysis_latest_row(group_rows)) + base["support_dept_code"] = display_code + base["row_key"] = display_code + base["project_name"] = display_name_for(display_code, base.get("project_name")) + base["direct_codes"] = sorted({ + code + for row in group_rows + for code in [row.get("support_dept_code"), *(row.get("direct_codes") or [])] + if normalize_text(code) + }) + base["aggregate_codes"] = sorted({ + code + for row in group_rows + for code in (row.get("aggregate_codes") or []) + if normalize_text(code) + }) + base["allocation_details"] = [ + copy.deepcopy(detail) + for row in group_rows + for detail in (row.get("allocation_details") or []) + ] + for field in additive_fields: + base[field] = sum(normalize_amount(row.get(field)) for row in group_rows) + base["contract_amount"] = max(normalize_amount(row.get("contract_amount")) for row in group_rows) + base["collected_amount"] = max(normalize_amount(row.get("collected_amount")) for row in group_rows) + base["contract_balance_amount"] = max(normalize_amount(row.get("contract_balance_amount")) for row in group_rows) + base["phases"] = _cost_analysis_empty_phase_totals() + base["allocated"] = _cost_analysis_empty_phase_totals() + for row in group_rows: + for phase, buckets in (row.get("phases") or {}).items(): + if phase not in base["phases"]: + continue + for item_key, amount in buckets.items(): + if item_key in base["phases"][phase]: + base["phases"][phase][item_key] += normalize_amount(amount) + for phase, buckets in (row.get("allocated") or {}).items(): + if phase not in base["allocated"]: + continue + for item_key, amount in buckets.items(): + if item_key in base["allocated"][phase]: + base["allocated"][phase][item_key] += normalize_amount(amount) + _cost_analysis_finalize_row(base) + base["cumulative_revenue_amount"] = normalize_amount(base.get("revenue_amount")) + base["cumulative_cost_total"] = normalize_amount(base.get("cost_total")) + base["cumulative_sga_total"] = normalize_amount(base.get("sga_total")) + base["cumulative_sales_total"] = normalize_amount(base.get("sales_total")) + base["cumulative_total_cost"] = normalize_amount(base.get("total_cost")) + base["cumulative_profit_amount"] = base["cumulative_revenue_amount"] - base["cumulative_total_cost"] + base["cumulative_profit_rate"] = _safe_ratio(base["cumulative_profit_amount"], base["cumulative_revenue_amount"]) + merged_rows.append(base) + return merged_rows + + +def _cost_analysis_payload_cache_context(start_date_text: str, end_date_text: str) -> dict[str, Any]: start_date = _parse_iso_date(start_date_text) or date(date.today().year, 1, 1) end_date = _parse_iso_date(end_date_text) or date.today() if end_date < start_date: start_date, end_date = end_date, start_date - normalized_mode = normalize_text(mode).lower() - cache_key = ( - start_date.isoformat(), - end_date.isoformat(), - "aggregate" if normalized_mode in {"aggregate", "sum", "합산", "연계", "linked", "link"} else "individual", - get_business_data_version(), - _cost_analysis_hanmac_cache_version(), + return { + "start_date": start_date, + "end_date": end_date, + "data_version": get_business_data_version(), + "hanmac_cache_version": _cost_analysis_hanmac_cache_version(), + } + + +def _cost_analysis_cache_key(context: dict[str, Any], mode: str) -> tuple[Any, ...]: + return ( + context["start_date"].isoformat(), + context["end_date"].isoformat(), + mode, + context["data_version"], + context["hanmac_cache_version"], + COST_ANALYSIS_HANMAC_AGGREGATE_SCHEMA, + COST_ANALYSIS_FINANCIAL_LOGIC_VERSION, + COST_ANALYSIS_H_PROJECT_MAPPING_VERSION, + COST_ANALYSIS_LINK_LOGIC_VERSION if mode == "aggregate" else "", ) + + +def _cost_analysis_persistent_cache_key(context: dict[str, Any], mode: str) -> str: + return _json_hash( + { + "start_date": context["start_date"].isoformat(), + "end_date": context["end_date"].isoformat(), + "mode": mode, + "data_version": context["data_version"], + "hanmac_cache_version": context["hanmac_cache_version"], + "financial_logic_version": COST_ANALYSIS_FINANCIAL_LOGIC_VERSION, + "h_project_mapping_version": COST_ANALYSIS_H_PROJECT_MAPPING_VERSION, + "link_logic_version": COST_ANALYSIS_LINK_LOGIC_VERSION if mode == "aggregate" else "", + "schema": COST_ANALYSIS_HANMAC_AGGREGATE_SCHEMA, + } + ) + + +def _cost_analysis_load_cached_payload(context: dict[str, Any], mode: str) -> dict[str, Any] | None: + cache_key = _cost_analysis_cache_key(context, mode) cached_payload = _get_deepcopy_ttl_cache_entry( _COST_ANALYSIS_PAYLOAD_CACHE, _COST_ANALYSIS_PAYLOAD_CACHE_LOCK, @@ -14209,37 +16552,130 @@ def _cost_analysis_build_payload(start_date_text: str, end_date_text: str, mode: COST_ANALYSIS_PAYLOAD_CACHE_TTL_SECONDS, ) if cached_payload is not None: + cached_payload["cache_info"] = {**(cached_payload.get("cache_info") or {}), "source": "memory", "ready": True} return cached_payload - persistent_cache_key = _json_hash( - { - "start_date": start_date.isoformat(), - "end_date": end_date.isoformat(), - "mode": cache_key[2], - "data_version": cache_key[3], - "hanmac_cache_version": cache_key[4], - "schema": COST_ANALYSIS_HANMAC_AGGREGATE_SCHEMA, - } - ) + persistent_cache_key = _cost_analysis_persistent_cache_key(context, mode) persistent_payload = _load_system_page_cache("cost_analysis_payload", persistent_cache_key) - if persistent_payload is not None: - return _set_deepcopy_ttl_cache_entry( - _COST_ANALYSIS_PAYLOAD_CACHE, - _COST_ANALYSIS_PAYLOAD_CACHE_LOCK, - cache_key, - persistent_payload, + if persistent_payload is None: + return None + persistent_payload["cache_info"] = {**(persistent_payload.get("cache_info") or {}), "source": "persistent", "ready": True} + return _set_deepcopy_ttl_cache_entry( + _COST_ANALYSIS_PAYLOAD_CACHE, + _COST_ANALYSIS_PAYLOAD_CACHE_LOCK, + cache_key, + persistent_payload, + ) + + +def _cost_analysis_store_payload(context: dict[str, Any], mode: str, payload: dict[str, Any]) -> dict[str, Any]: + cache_key = _cost_analysis_cache_key(context, mode) + persistent_cache_key = _cost_analysis_persistent_cache_key(context, mode) + payload["cache_info"] = { + "source": "computed", + "ready": True, + "financial_logic_version": COST_ANALYSIS_FINANCIAL_LOGIC_VERSION, + "h_project_mapping_version": COST_ANALYSIS_H_PROJECT_MAPPING_VERSION, + "link_logic_version": COST_ANALYSIS_LINK_LOGIC_VERSION, + "data_version": context["data_version"], + "hanmac_cache_version": context["hanmac_cache_version"], + "generated_at": datetime.now().isoformat(timespec="seconds"), + } + try: + _store_system_page_cache( + "cost_analysis_payload", + persistent_cache_key, + params={ + "start_date": context["start_date"].isoformat(), + "end_date": context["end_date"].isoformat(), + "mode": mode, + "data_version": context["data_version"], + "hanmac_cache_version": context["hanmac_cache_version"], + "financial_logic_version": COST_ANALYSIS_FINANCIAL_LOGIC_VERSION, + "h_project_mapping_version": COST_ANALYSIS_H_PROJECT_MAPPING_VERSION, + "link_logic_version": COST_ANALYSIS_LINK_LOGIC_VERSION, + }, + payload=payload, + row_count=len(payload.get("rows") or []), + signature=str(context["data_version"]), ) + except OperationalError as exc: + if "database is locked" not in str(exc).lower(): + raise + logger.warning("cost analysis persistent cache store skipped because database is locked") + payload["cache_info"] = { + **(payload.get("cache_info") or {}), + "persistent_cache_skipped": True, + "persistent_cache_error": "database is locked", + } + return _set_deepcopy_ttl_cache_entry( + _COST_ANALYSIS_PAYLOAD_CACHE, + _COST_ANALYSIS_PAYLOAD_CACHE_LOCK, + cache_key, + payload, + ) + + +def _cost_analysis_load_last_valid_payload(start_date_text: str, end_date_text: str, mode: str) -> dict[str, Any] | None: + normalized_mode = "aggregate" if normalize_text(mode).lower() in {"aggregate", "sum", "합산", "연계", "linked", "link"} else "individual" + start_date = (_parse_iso_date(start_date_text) or date(date.today().year, 1, 1)).isoformat() + end_date = (_parse_iso_date(end_date_text) or date.today()).isoformat() + with engine.begin() as conn: + row = conn.execute( + text( + """ + SELECT payload_json, updated_at + FROM system_page_cache + WHERE page_key = 'cost_analysis_payload' + AND json_extract(params_json, '$.start_date') = :start_date + AND json_extract(params_json, '$.end_date') = :end_date + AND json_extract(params_json, '$.mode') = :mode + ORDER BY updated_at DESC + LIMIT 1 + """ + ), + {"start_date": start_date, "end_date": end_date, "mode": normalized_mode}, + ).mappings().first() + if not row: + return None + try: + payload = json.loads(str(row.get("payload_json") or "{}")) + except Exception: + return None + if not isinstance(payload, dict) or not isinstance(payload.get("rows"), list): + return None + payload["cache_info"] = { + **(payload.get("cache_info") or {}), + "source": "last-valid", + "ready": False, + "stale": True, + "updated_at": normalize_text(row.get("updated_at")), + } + return payload + + +def _cost_analysis_build_individual_payload( + context: dict[str, Any], + force: bool = False, + include_cumulative: bool = True, +) -> dict[str, Any]: + start_date = context["start_date"] + end_date = context["end_date"] + cache_key = _cost_analysis_cache_key(context, "individual") + if not force: + cached_payload = _cost_analysis_load_cached_payload(context, "individual") + if cached_payload is not None: + return cached_payload + linked_mode = False selected_year = start_date.year if start_date.year == end_date.year else None project_meta = _cost_analysis_get_project_meta() - x_links = _cost_analysis_get_x_links() - xyz_code_map = _cost_analysis_get_xyz_code_map() completion_dates = _cost_analysis_get_completion_billing_dates() rows_by_code: dict[str, dict[str, Any]] = {} - x_owner_map: dict[str, str] = {} - for owner_code, x_codes in x_links.items(): - normalized_owner_code = normalize_text(owner_code).upper() - display_owner_code = xyz_code_map.get(normalized_owner_code, normalized_owner_code) - for x_code in x_codes: - x_owner_map.setdefault(x_code, display_owner_code) + + def resolve_report_code(source_code: Any) -> str: + normalized_code = normalize_text(source_code).upper() + if normalized_code in COST_ANALYSIS_COMMON_CODES: + return "" + return normalized_code if normalized_code.startswith(("0", "9", "X", "Y", "Z")) else "" with engine.begin() as conn: period_code_rows = conn.execute( @@ -14260,8 +16696,8 @@ def _cost_analysis_build_payload(start_date_text: str, end_date_text: str, mode: UNION SELECT DISTINCT support_dept_code FROM project_billing_entries - WHERE COALESCE(COALESCE(tax_invoice_date, billing_date), '') >= :start_date - AND COALESCE(COALESCE(tax_invoice_date, billing_date), '') <= :end_date + WHERE COALESCE(billing_date, '') >= :start_date + AND COALESCE(billing_date, '') <= :end_date AND COALESCE(support_dept_code, '') <> '' """ ), @@ -14273,36 +16709,47 @@ def _cost_analysis_build_payload(start_date_text: str, end_date_text: str, mode: for source_code in period_source_codes: if not source_code or source_code in COST_ANALYSIS_COMMON_CODES: continue - visible_candidate_codes.add(x_owner_map.get(source_code, xyz_code_map.get(source_code, source_code))) + target_code = resolve_report_code(source_code) + if target_code: + visible_candidate_codes.add(target_code) - annual_hanmac_project_hours_by_year, annual_hanmac_labor_by_year = _cost_analysis_load_hanmac_hours_and_labor_yearly( + annual_hanmac_project_hours_by_year, annual_hanmac_labor_by_year, annual_hanmac_missing_sga_by_year = _cost_analysis_load_hanmac_hours_and_labor_yearly( start_date, end_date, project_meta, None, ) - visible_candidate_codes.update( - code + annual_source_codes = { + normalize_text(code).upper() for code_map in annual_hanmac_project_hours_by_year.values() for code in code_map + } + visible_candidate_codes.update( + target_code + for code in annual_source_codes + if (target_code := resolve_report_code(code)) ) - source_candidate_codes: set[str] = set(visible_candidate_codes) - for target_code in list(visible_candidate_codes): - source_candidate_codes.update(x_links.get(target_code, [])) - for source_code, target_code in xyz_code_map.items(): - if target_code in visible_candidate_codes: - source_candidate_codes.add(source_code) - for source_code, target_code in x_owner_map.items(): - if target_code in visible_candidate_codes: - source_candidate_codes.add(source_code) + source_candidate_codes: set[str] = { + code for code in [*period_source_codes, *annual_source_codes] if code and code not in COST_ANALYSIS_COMMON_CODES + } + if linked_mode: + visible_link_keys = { + representative_map.get(code, code) + for code in visible_candidate_codes + } + source_candidate_codes.update( + code for code, representative in representative_map.items() + if representative in visible_link_keys + ) 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: + target_code = resolve_report_code(code) + if not target_code or (visible_candidate_codes and target_code not in visible_candidate_codes): continue - target = hanmac_labor_map.setdefault(code, {"pre": 0.0, "during": 0.0, "post": 0.0}) + target = hanmac_labor_map.setdefault(target_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) @@ -14314,8 +16761,8 @@ def _cost_analysis_build_payload(start_date_text: str, end_date_text: str, mode: meta = project_meta.get(normalized_code, {"support_dept_code": normalized_code, "support_dept_name": normalized_code}) if normalized_code not in rows_by_code: rows_by_code[normalized_code] = _cost_analysis_row_template(normalized_code, meta, selected_year) - rows_by_code[normalized_code]["pre_codes"] = x_links.get(normalized_code, []) - rows_by_code[normalized_code]["direct_codes"] = [normalized_code, *x_links.get(normalized_code, [])] + rows_by_code[normalized_code]["pre_codes"] = [] + rows_by_code[normalized_code]["direct_codes"] = [normalized_code] return rows_by_code[normalized_code] with engine.begin() as conn: @@ -14401,35 +16848,106 @@ def _cost_analysis_build_payload(start_date_text: str, end_date_text: str, mode: SUM(COALESCE(billed_amount, 0)) AS billing_amount, SUM( CASE - WHEN COALESCE(COALESCE(tax_invoice_date, billing_date), '') >= :start_date - AND COALESCE(COALESCE(tax_invoice_date, billing_date), '') <= :end_date + WHEN COALESCE(billing_date, '') >= :start_date + AND COALESCE(billing_date, '') <= :end_date THEN COALESCE(billed_amount, 0) ELSE 0 END ) AS period_billing_amount FROM project_billing_entries - WHERE COALESCE(COALESCE(tax_invoice_date, billing_date), '') <= :end_date + WHERE COALESCE(billing_date, '') <= :end_date GROUP BY support_dept_code """ ), {"start_date": start_date.isoformat(), "end_date": end_date.isoformat()}, ).mappings().all() - annual_sga_rows = conn.execute( + negative_billing_rows = conn.execute( + text( + """ + SELECT + support_dept_code, + SUM(COALESCE(billed_amount, 0)) AS period_negative_billing_amount + FROM project_billing_entries + WHERE COALESCE(billing_date, '') >= :start_date + AND COALESCE(billing_date, '') <= :end_date + AND COALESCE(billed_amount, 0) < 0 + GROUP BY support_dept_code + """ + ), + {"start_date": start_date.isoformat(), "end_date": end_date.isoformat()}, + ).mappings().all() + common_revenue_rows = conn.execute( + text( + f""" + SELECT + CAST(strftime('%Y', {COST_ANALYSIS_TX_DATE_SQL}) AS INTEGER) AS posting_year, + COALESCE(account_code, '') AS account_code, + COALESCE(account_name, '') AS account_name, + SUM(COALESCE(amount, 0)) AS revenue_amount, + COUNT(*) AS row_count + FROM transactions + WHERE UPPER(COALESCE(support_dept_code, '')) IN ('', 'ZZZZZZ') + AND (accounting_category = '수입/매출액' OR account_code LIKE '4%') + AND {COST_ANALYSIS_TX_DATE_SQL} >= :start_date + AND {COST_ANALYSIS_TX_DATE_SQL} <= :end_date + GROUP BY + CAST(strftime('%Y', {COST_ANALYSIS_TX_DATE_SQL}) AS INTEGER), + COALESCE(account_code, ''), + COALESCE(account_name, '') + """ + ), + { + "start_date": start_date.isoformat(), + "end_date": end_date.isoformat(), + }, + ).mappings().all() + annual_erp_revenue_rows = conn.execute( + text( + f""" + SELECT + CAST(strftime('%Y', {COST_ANALYSIS_TX_DATE_SQL}) AS INTEGER) AS posting_year, + SUM(COALESCE(amount, 0)) AS revenue_amount + FROM transactions + WHERE (accounting_category = '수입/매출액' OR account_code LIKE '4%') + AND {COST_ANALYSIS_TX_DATE_SQL} >= :start_date + AND {COST_ANALYSIS_TX_DATE_SQL} <= :end_date + GROUP BY CAST(strftime('%Y', {COST_ANALYSIS_TX_DATE_SQL}) AS INTEGER) + """ + ), + { + "start_date": start_date.isoformat(), + "end_date": end_date.isoformat(), + }, + ).mappings().all() + annual_common_rows = conn.execute( text( f""" SELECT CAST(strftime('%Y', {COST_ANALYSIS_TX_DATE_SQL}) AS INTEGER) AS posting_year, - SUM(COALESCE(amount, 0)) AS amount + SUM( + CASE + WHEN account_code LIKE '6%' THEN COALESCE(amount, 0) + ELSE 0 + END + ) AS sga_amount, + SUM( + CASE + WHEN account_code LIKE '5%' + AND NOT ({COST_ANALYSIS_LABOR_ACCOUNT_SQL}) + THEN COALESCE(amount, 0) + ELSE 0 + END + ) AS common_cost_amount, + SUM( + CASE + WHEN account_code LIKE '5%' + AND ({COST_ANALYSIS_LABOR_ACCOUNT_SQL}) + THEN COALESCE(amount, 0) + ELSE 0 + END + ) AS excluded_common_labor_amount FROM transactions - WHERE ( - ( - UPPER(COALESCE(support_dept_code, '')) IN ('', 'ZZZZZZ') - AND account_code LIKE '6%' - ) - OR ( - UPPER(COALESCE(support_dept_code, '')) IN ('ZZZZZZ') - AND account_code LIKE '5%' - ) - ) + WHERE UPPER(COALESCE(support_dept_code, '')) IN ('', 'ZZZZZZ') + AND (account_code LIKE '5%' OR account_code LIKE '6%') AND {COST_ANALYSIS_TX_DATE_SQL} >= :start_date AND {COST_ANALYSIS_TX_DATE_SQL} <= :end_date GROUP BY CAST(strftime('%Y', {COST_ANALYSIS_TX_DATE_SQL}) AS INTEGER) @@ -14451,64 +16969,65 @@ def _cost_analysis_build_payload(start_date_text: str, end_date_text: str, mode: if bucket in {"sga", "cost"}: common_rows.append(row) continue - target_code = x_owner_map.get(code, xyz_code_map.get(code, code)) + target_code = resolve_report_code(code) + if not target_code: + continue report_row = ensure_row(target_code) if code != target_code and code not in report_row["direct_codes"]: report_row["direct_codes"].append(code) posting_date = _date_text(row.get("posting_date")) - if posting_date and start_date.isoformat() <= posting_date <= end_date.isoformat(): - visible_activity_codes.add(target_code) amount = normalize_amount(row.get("amount")) if bucket == "revenue": report_row["revenue_amount"] += amount report_row["period_revenue_amount"] += amount + if amount: + visible_activity_codes.add(target_code) continue if bucket not in {"cost", "sga"}: continue - phase = "pre" if code.startswith("X") else _cost_analysis_phase_for_transaction(target_code, posting_date, completion_dates) + if posting_date and start_date.isoformat() <= posting_date <= end_date.isoformat() and amount: + visible_activity_codes.add(target_code) + phase = "pre" if code.startswith("X") else _cost_analysis_phase_for_transaction(target_code, posting_date, completion_dates, project_meta) item_key = _cost_analysis_expense_item(row.get("account_code"), row.get("account_name"), _cost_analysis_is_sales_cost(row)) - if item_key == "labor" and target_code in hanmac_labor_map: + # Labor is calculated exclusively from external work records. ERP payroll + # vouchers must never be used as a fallback when work records are absent. + if item_key == "labor": continue - if item_key == "outsource" and phase == "pre": - item_key = "overhead" report_row["phases"][phase][item_key] += amount for code, phase_amounts in hanmac_labor_map.items(): if normalize_text(code).upper() in COST_ANALYSIS_COMMON_CODES: continue - report_row = ensure_row(code) + target_code = resolve_report_code(code) + if not target_code: + continue + report_row = ensure_row(target_code) for phase, amount in phase_amounts.items(): if phase in report_row["phases"] and amount: report_row["phases"][phase]["labor"] += amount + visible_activity_codes.add(target_code) - for collection_row in collection_rows: - code = normalize_text(collection_row.get("support_dept_code")).upper() - if not code or code in COST_ANALYSIS_COMMON_CODES: + common_collection_amount = 0.0 + common_period_collection_amount = 0.0 + for collection_event in _cost_analysis_erp_collection_events(end_date): + code = normalize_text(collection_event.get("support_dept_code")).upper() + amount = normalize_amount(collection_event.get("amount")) + posting_date = _date_text(collection_event.get("posting_date")) + is_period = bool(posting_date and start_date.isoformat() <= posting_date <= end_date.isoformat()) + if code in COST_ANALYSIS_COMMON_CODES: + common_collection_amount += amount + if is_period: + common_period_collection_amount += amount continue - target_code = xyz_code_map.get(code, code) - report_row = ensure_row(target_code) - 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) - - collected_codes = {normalize_text(row.get("support_dept_code")).upper() for row in collection_rows} - for collection_row in billing_collection_rows: - code = normalize_text(collection_row.get("support_dept_code")).upper() - if not code or code in COST_ANALYSIS_COMMON_CODES or code in collected_codes: + target_code = resolve_report_code(code) + if not target_code or not amount: continue - amount = normalize_amount(collection_row.get("collection_amount")) - if not amount: - continue - target_code = xyz_code_map.get(code, code) report_row = ensure_row(target_code) 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")): + if is_period: + report_row["period_collection_amount"] += amount visible_activity_codes.add(target_code) for billing_row in billing_amount_rows: @@ -14518,38 +17037,175 @@ def _cost_analysis_build_payload(start_date_text: str, end_date_text: str, mode: amount = normalize_amount(billing_row.get("billing_amount")) if not amount: continue - target_code = xyz_code_map.get(code, code) + target_code = resolve_report_code(code) + if not target_code: + continue report_row = ensure_row(target_code) 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")): + period_billing_amount = normalize_amount(billing_row.get("period_billing_amount")) + report_row["period_billing_amount"] += period_billing_amount + if 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")} + for billing_row in negative_billing_rows: + code = normalize_text(billing_row.get("support_dept_code")).upper() + if not code or code in COST_ANALYSIS_COMMON_CODES: + continue + target_code = resolve_report_code(code) + if not target_code: + continue + report_row = ensure_row(target_code) + report_row["period_negative_billing_amount"] += normalize_amount(billing_row.get("period_negative_billing_amount")) + + annual_common_totals = { + int(row["posting_year"]): { + "sga": normalize_amount(row.get("sga_amount")), + "overhead": normalize_amount(row.get("common_cost_amount")), + "excluded_common_labor": normalize_amount(row.get("excluded_common_labor_amount")), + } + for row in annual_common_rows + if row.get("posting_year") + } + common_revenue_totals_by_year: dict[int, float] = {} + erp_revenue_totals_by_year = { + int(row.get("posting_year") or 0): normalize_amount(row.get("revenue_amount")) + for row in annual_erp_revenue_rows + if int(row.get("posting_year") or 0) + } + common_sga_allocated_by_year: dict[int, float] = {} + for row in common_revenue_rows: + year = int(row.get("posting_year") or 0) + if year: + common_revenue_totals_by_year[year] = common_revenue_totals_by_year.get(year, 0.0) + normalize_amount(row.get("revenue_amount")) + for row in common_revenue_rows: + year = int(row.get("posting_year") or 0) + revenue_amount = normalize_amount(row.get("revenue_amount")) + account_code = normalize_text(row.get("account_code")) + account_name = normalize_text(row.get("account_name")) or "공통매출" + if not year or not revenue_amount: + continue + row_key = f"ZZZZZZ:{year}:{account_code or 'common'}:{normalize_project_title_for_linking(account_name) or 'common'}" + common_meta = { + "row_key": row_key, + "support_dept_name": account_name, + "pm_department": "공통", + "project_type": "공통매출", + "completion_status": "", + "contract_amount": 0, + } + report_row = _cost_analysis_row_template("ZZZZZZ", common_meta, selected_year) + report_row["row_key"] = row_key + report_row["support_dept_code"] = "ZZZZZZ" + report_row["direct_codes"] = ["ZZZZZZ"] + report_row["project_name"] = account_name + report_row["project_type"] = "공통매출" + report_row["pm_department"] = "공통" + report_row["is_common_revenue"] = True + report_row["common_revenue_amount"] = revenue_amount + report_row["revenue_amount"] = revenue_amount + report_row["period_revenue_amount"] = revenue_amount + annual_sga = normalize_amount(annual_common_totals.get(year, {}).get("sga")) + erp_revenue_total = erp_revenue_totals_by_year.get(year) or 0.0 + allocated_sga = annual_sga * revenue_amount / erp_revenue_total if erp_revenue_total else 0.0 + common_sga_allocated_by_year[year] = common_sga_allocated_by_year.get(year, 0.0) + allocated_sga + report_row["phases"]["during"]["sga"] += allocated_sga + report_row["allocated"]["during"]["sga"] += allocated_sga + rows_by_code[row_key] = report_row + visible_activity_codes.add(row_key) + + common_billing_rows = [ + row + for row in billing_amount_rows + if normalize_text(row.get("support_dept_code")).upper() in COST_ANALYSIS_COMMON_CODES + ] + common_negative_billing_rows = [ + row + for row in negative_billing_rows + if normalize_text(row.get("support_dept_code")).upper() in COST_ANALYSIS_COMMON_CODES + ] + common_billing_amount = sum(normalize_amount(row.get("billing_amount")) for row in common_billing_rows) + common_period_billing_amount = sum(normalize_amount(row.get("period_billing_amount")) for row in common_billing_rows) + common_period_negative_billing_amount = sum( + normalize_amount(row.get("period_negative_billing_amount")) + for row in common_negative_billing_rows + ) + if not common_billing_rows: + for common_revenue_row in rows_by_code.values(): + if not common_revenue_row.get("is_common_revenue"): + continue + revenue_amount = normalize_amount(common_revenue_row.get("revenue_amount")) + period_revenue_amount = normalize_amount(common_revenue_row.get("period_revenue_amount")) + common_revenue_row["billing_amount"] = revenue_amount + common_revenue_row["period_billing_amount"] = period_revenue_amount + common_revenue_row["common_billing_fallback_from_erp"] = True + elif common_billing_amount or common_period_billing_amount or common_period_negative_billing_amount: + common_billing_key = f"ZZZZZZ:{start_date.isoformat()}:{end_date.isoformat()}:billing" + common_billing_meta = { + "row_key": common_billing_key, + "support_dept_name": "공통 청구", + "pm_department": "공통", + "project_type": "공통매출", + "completion_status": "", + "contract_amount": 0, + } + common_billing_row = _cost_analysis_row_template("ZZZZZZ", common_billing_meta, selected_year) + common_billing_row["row_key"] = common_billing_key + common_billing_row["support_dept_code"] = "ZZZZZZ" + common_billing_row["direct_codes"] = ["ZZZZZZ"] + common_billing_row["project_name"] = "공통 청구" + common_billing_row["project_type"] = "공통매출" + common_billing_row["pm_department"] = "공통" + common_billing_row["is_common_revenue"] = True + common_billing_row["billing_amount"] = common_billing_amount + common_billing_row["period_billing_amount"] = common_period_billing_amount + common_billing_row["period_negative_billing_amount"] = common_period_negative_billing_amount + rows_by_code[common_billing_key] = common_billing_row + if common_period_billing_amount or common_period_negative_billing_amount: + visible_activity_codes.add(common_billing_key) + for year, common_sga_amount in common_sga_allocated_by_year.items(): + if year in annual_common_totals: + annual_common_totals[year]["sga"] = max(0.0, normalize_amount(annual_common_totals[year].get("sga")) - common_sga_amount) + 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) + annual_totals = annual_common_totals.get(year, {}) + missing_regular_sga_amount = normalize_amount(annual_hanmac_missing_sga_by_year.get(year)) hanmac_project_hours = annual_hanmac_project_hours_by_year.get(year, {}) period_total_hours = sum( normalize_amount(hours) for phase_hours in hanmac_project_hours.values() for hours in phase_hours.values() ) - if annual_sga_total <= 0 or period_total_hours <= 0: + if period_total_hours <= 0: + continue + hourly_allocations = { + item: (normalize_amount(annual_totals.get(item)) + (missing_regular_sga_amount if item == "sga" else 0.0)) / period_total_hours + for item in ("overhead", "sga") + if normalize_amount(annual_totals.get(item)) + (missing_regular_sga_amount if item == "sga" else 0.0) > 0 + } + if not hourly_allocations: continue - hourly_sga = annual_sga_total / period_total_hours for code, phase_hours in hanmac_project_hours.items(): normalized_code = normalize_text(code).upper() if normalized_code in COST_ANALYSIS_COMMON_CODES: continue - report_row = ensure_row(code) + target_code = resolve_report_code(normalized_code) + if not target_code: + continue + report_row = ensure_row(target_code) for phase, hours in phase_hours.items(): - amount = hourly_sga * normalize_amount(hours) - if phase in report_row["phases"] and amount: - report_row["phases"][phase]["sga"] += amount - report_row["allocated"][phase]["sga"] += amount + if phase not in report_row["phases"]: + continue + normalized_hours = normalize_amount(hours) + for item, hourly_amount in hourly_allocations.items(): + amount = hourly_amount * normalized_hours + if not amount: + continue + report_row["phases"][phase][item] += amount + report_row["allocated"][phase][item] += amount + visible_activity_codes.add(target_code) all_finalized_rows = [] period_finalized_rows = [] @@ -14560,52 +17216,121 @@ def _cost_analysis_build_payload(start_date_text: str, end_date_text: str, mode: if has_direct: period_finalized_rows.append(row) - linked_mode = normalized_mode in {"aggregate", "sum", "합산", "연계", "linked", "link"} if linked_mode: - representative_map = _cost_analysis_get_link_representative_map() visible_link_keys = { _cost_analysis_linked_group_key(row, representative_map) for row in period_finalized_rows } - for code, representative in representative_map.items(): - if representative not in visible_link_keys: - continue - if code in rows_by_code or code in COST_ANALYSIS_COMMON_CODES or code not in project_meta: - continue - row = ensure_row(code) - _cost_analysis_finalize_row(row) - all_finalized_rows.append(row) final_rows = [ row - for row in all_finalized_rows + for row in period_finalized_rows if _cost_analysis_linked_group_key(row, representative_map) in visible_link_keys ] final_rows = _cost_analysis_aggregate_rows(final_rows, project_meta, representative_map) for row in final_rows: _cost_analysis_finalize_row(row) else: - final_rows = period_finalized_rows + final_rows = list(period_finalized_rows) + + post_labor_summary_codes = {"Y22216", "Y24090"} + for row in final_rows: + row_codes = { + normalize_text(row.get("support_dept_code")).upper(), + *[normalize_text(code).upper() for code in (row.get("direct_codes") or [])], + } + if not any(code in post_labor_summary_codes for code in row_codes): + continue + target_codes = sorted(code for code in row_codes if code.startswith(("Y", "Z"))) + row["post_labor_after_completion"] = _cost_analysis_build_post_labor_summary( + start_date, + end_date, + target_codes, + row, + project_meta, + completion_dates, + ) final_rows.sort(key=lambda item: (normalize_text(item.get("pm_department")), normalize_text(item.get("project_type")), normalize_text(item.get("project_name")))) + accumulation_start = _cost_analysis_get_accumulation_start(end_date.isoformat()) + if include_cumulative and accumulation_start < start_date: + cumulative_context = _cost_analysis_payload_cache_context( + accumulation_start.isoformat(), + end_date.isoformat(), + ) + cumulative_payload = _cost_analysis_build_individual_payload( + cumulative_context, + force=False, + include_cumulative=False, + ) + cumulative_rows_by_code = { + normalize_text(item.get("row_key") or item.get("support_dept_code")).upper(): item + for item in (cumulative_payload.get("rows") or []) + } + for row in final_rows: + cumulative_row = cumulative_rows_by_code.get( + normalize_text(row.get("row_key") or row.get("support_dept_code")).upper() + ) or {} + row["cumulative_revenue_amount"] = normalize_amount(cumulative_row.get("revenue_amount")) + row["cumulative_cost_total"] = normalize_amount(cumulative_row.get("cost_total")) + row["cumulative_sga_total"] = normalize_amount(cumulative_row.get("sga_total")) + row["cumulative_sales_total"] = normalize_amount(cumulative_row.get("sales_total")) + row["cumulative_total_cost"] = normalize_amount(cumulative_row.get("total_cost")) + row["cumulative_profit_amount"] = ( + row["cumulative_revenue_amount"] - row["cumulative_total_cost"] + ) + row["cumulative_profit_rate"] = _safe_ratio( + row["cumulative_profit_amount"], + row["cumulative_revenue_amount"], + ) + else: + for row in final_rows: + row["cumulative_revenue_amount"] = normalize_amount(row.get("revenue_amount")) + row["cumulative_cost_total"] = normalize_amount(row.get("cost_total")) + row["cumulative_sga_total"] = normalize_amount(row.get("sga_total")) + row["cumulative_sales_total"] = normalize_amount(row.get("sales_total")) + row["cumulative_total_cost"] = normalize_amount(row.get("total_cost")) + row["cumulative_profit_amount"] = ( + row["cumulative_revenue_amount"] - row["cumulative_total_cost"] + ) + row["cumulative_profit_rate"] = _safe_ratio( + row["cumulative_profit_amount"], + row["cumulative_revenue_amount"], + ) summary = { "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_negative_billing_amount": sum(normalize_amount(row.get("period_negative_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), + "period_revenue_amount": sum(normalize_amount(row.get("period_revenue_amount")) for row in final_rows), + "period_revenue_billing_gap": sum(normalize_amount(row.get("period_revenue_billing_gap")) for row in final_rows), + "period_revenue_collection_gap": sum(normalize_amount(row.get("period_revenue_collection_gap")) for row in final_rows), + "period_cost_total": sum(normalize_amount(row.get("period_cost_total")) for row in final_rows), + "period_cost_labor_total": sum(normalize_amount(row.get("period_cost_labor_total")) for row in final_rows), + "period_sga_labor_total": sum(normalize_amount(row.get("period_sga_labor_total")) for row in final_rows), + "period_sga_total": sum(normalize_amount(row.get("period_sga_total")) for row in final_rows), + "period_sales_total": sum(normalize_amount(row.get("period_sales_total")) for row in final_rows), + "period_total_cost": sum(normalize_amount(row.get("period_total_cost")) for row in final_rows), + "period_profit_amount": sum(normalize_amount(row.get("period_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), + "cost_labor_total": sum(normalize_amount(row.get("cost_labor_total")) for row in final_rows), + "sga_labor_total": sum(normalize_amount(row.get("sga_labor_total")) for row in final_rows), "sga_total": sum(normalize_amount(row.get("sga_total")) for row in final_rows), "sales_total": sum(normalize_amount(row.get("sales_total")) for row in final_rows), "total_cost": sum(normalize_amount(row.get("total_cost")) for row in final_rows), "profit_amount": sum(normalize_amount(row.get("profit_amount")) for row in final_rows), + "cumulative_revenue_amount": sum(normalize_amount(row.get("cumulative_revenue_amount")) for row in final_rows), + "cumulative_cost_total": sum(normalize_amount(row.get("cumulative_cost_total")) for row in final_rows), + "cumulative_sga_total": sum(normalize_amount(row.get("cumulative_sga_total")) for row in final_rows), + "cumulative_sales_total": sum(normalize_amount(row.get("cumulative_sales_total")) for row in final_rows), + "cumulative_total_cost": sum(normalize_amount(row.get("cumulative_total_cost")) for row in final_rows), + "cumulative_profit_amount": sum(normalize_amount(row.get("cumulative_profit_amount")) for row in final_rows), + "common_revenue_amount": sum(normalize_amount(row.get("common_revenue_amount")) for row in final_rows), + "project_revenue_amount": sum(normalize_amount(row.get("period_revenue_amount")) for row in final_rows if not row.get("is_common_revenue")), + "common_revenue_row_count": sum(1 for row in final_rows if row.get("is_common_revenue")), "project_count": len(final_rows), } summary["collection_rate"] = _safe_ratio(summary["collection_amount"], summary["contract_amount"]) @@ -14614,45 +17339,1448 @@ def _cost_analysis_build_payload(start_date_text: str, end_date_text: str, mode: 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"]) + summary["cumulative_profit_rate"] = _safe_ratio(summary["cumulative_profit_amount"], summary["cumulative_revenue_amount"]) + allocation_diagnostics = { + "common_labor_excluded": sum( + normalize_amount(row.get("excluded_common_labor_amount")) + for row in annual_common_rows + ), + "common_cost_allocated": sum( + normalize_amount(row.get("common_cost_amount")) + for row in annual_common_rows + ), + "common_sga_allocated": sum( + normalize_amount(row.get("sga_amount")) + for row in annual_common_rows + ), + "hanmac_missing_regular_sga_allocated": sum( + normalize_amount(amount) + for amount in annual_hanmac_missing_sga_by_year.values() + ), + "hanmac_labor_total": sum( + _cost_analysis_hanmac_labor_totals_by_year( + annual_hanmac_labor_by_year, + annual_hanmac_missing_sga_by_year, + ).values() + ), + } payload = { "start_date": start_date.isoformat(), "end_date": end_date.isoformat(), - "mode": "aggregate" if linked_mode else "individual", + "mode": "individual", "rows": final_rows, "summary": summary, + "accumulation_start_date": accumulation_start.isoformat(), + "allocation_diagnostics": allocation_diagnostics, + "accounting_comparison": _cost_analysis_accounting_comparison( + start_date, + end_date, + summary, + allocation_diagnostics, + ), } - _store_system_page_cache( - "cost_analysis_payload", - persistent_cache_key, - params={ - "start_date": start_date.isoformat(), - "end_date": end_date.isoformat(), - "mode": payload["mode"], - "data_version": cache_key[3], - "hanmac_cache_version": cache_key[4], + return _cost_analysis_store_payload(context, "individual", payload) + + +def _cost_analysis_summary_for_rows(rows: list[dict[str, Any]]) -> dict[str, Any]: + summary = { + "contract_amount": sum(normalize_amount(row.get("contract_amount")) for row in rows), + "billing_amount": sum(normalize_amount(row.get("billing_amount")) for row in rows), + "collection_amount": sum(normalize_amount(row.get("collection_amount")) for row in rows), + "period_billing_amount": sum(normalize_amount(row.get("period_billing_amount")) for row in rows), + "period_negative_billing_amount": sum(normalize_amount(row.get("period_negative_billing_amount")) for row in rows), + "period_collection_amount": sum(normalize_amount(row.get("period_collection_amount")) for row in rows), + "period_revenue_amount": sum(normalize_amount(row.get("period_revenue_amount")) for row in rows), + "period_revenue_billing_gap": sum(normalize_amount(row.get("period_revenue_billing_gap")) for row in rows), + "period_revenue_collection_gap": sum(normalize_amount(row.get("period_revenue_collection_gap")) for row in rows), + "period_cost_total": sum(normalize_amount(row.get("period_cost_total")) for row in rows), + "period_cost_labor_total": sum(normalize_amount(row.get("period_cost_labor_total")) for row in rows), + "period_sga_labor_total": sum(normalize_amount(row.get("period_sga_labor_total")) for row in rows), + "period_sga_total": sum(normalize_amount(row.get("period_sga_total")) for row in rows), + "period_sales_total": sum(normalize_amount(row.get("period_sales_total")) for row in rows), + "period_total_cost": sum(normalize_amount(row.get("period_total_cost")) for row in rows), + "period_profit_amount": sum(normalize_amount(row.get("period_profit_amount")) for row in rows), + "contract_balance_amount": sum(normalize_amount(row.get("contract_balance_amount")) for row in rows), + "revenue_amount": sum(normalize_amount(row.get("revenue_amount")) for row in rows), + "cost_total": sum(normalize_amount(row.get("cost_total")) for row in rows), + "cost_labor_total": sum(normalize_amount(row.get("cost_labor_total")) for row in rows), + "sga_labor_total": sum(normalize_amount(row.get("sga_labor_total")) for row in rows), + "sga_total": sum(normalize_amount(row.get("sga_total")) for row in rows), + "sales_total": sum(normalize_amount(row.get("sales_total")) for row in rows), + "total_cost": sum(normalize_amount(row.get("total_cost")) for row in rows), + "profit_amount": sum(normalize_amount(row.get("profit_amount")) for row in rows), + "cumulative_revenue_amount": sum(normalize_amount(row.get("cumulative_revenue_amount")) for row in rows), + "cumulative_cost_total": sum(normalize_amount(row.get("cumulative_cost_total")) for row in rows), + "cumulative_sga_total": sum(normalize_amount(row.get("cumulative_sga_total")) for row in rows), + "cumulative_sales_total": sum(normalize_amount(row.get("cumulative_sales_total")) for row in rows), + "cumulative_total_cost": sum(normalize_amount(row.get("cumulative_total_cost")) for row in rows), + "cumulative_profit_amount": sum(normalize_amount(row.get("cumulative_profit_amount")) for row in rows), + "common_revenue_amount": sum(normalize_amount(row.get("common_revenue_amount")) for row in rows), + "project_revenue_amount": sum(normalize_amount(row.get("period_revenue_amount")) for row in rows if not row.get("is_common_revenue")), + "common_revenue_row_count": sum(1 for row in rows if row.get("is_common_revenue")), + "project_count": len(rows), + } + summary["collection_rate"] = _safe_ratio(summary["collection_amount"], summary["contract_amount"]) + 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["cumulative_profit_amount"], summary["cumulative_revenue_amount"]) + return summary + + +def _cost_analysis_accounting_comparison( + start_date: date, + end_date: date, + allocated_summary: dict[str, Any], + allocation_diagnostics: dict[str, Any], +) -> dict[str, Any]: + is_full_year = ( + start_date.year == end_date.year + and start_date == date(start_date.year, 1, 1) + and end_date == date(end_date.year, 12, 31) + ) + result = { + "available": is_full_year, + "scope_label": f"{start_date.isoformat()}~{end_date.isoformat()}", + "allocated_cost": normalize_amount(allocated_summary.get("total_cost")), + "allocated_profit": normalize_amount(allocated_summary.get("period_profit_amount")), + "allocated_profit_rate": _safe_ratio( + allocated_summary.get("period_profit_amount"), + allocated_summary.get("period_revenue_amount"), + ), + **allocation_diagnostics, + } + if not is_full_year: + result["unavailable_reason"] = "WEHAGO 감사 후 재무제표 비교는 전체 회계연도 조회에서 제공합니다." + return result + + with engine.begin() as conn: + erp = conn.execute( + text( + """ + SELECT + SUM(CASE WHEN accounting_category = '원가' THEN amount ELSE 0 END) AS cost, + SUM(CASE WHEN accounting_category = '판관비' THEN amount ELSE 0 END) AS sga, + SUM(CASE WHEN accounting_category = '수입/매출액' OR account_code LIKE '4%' THEN amount ELSE 0 END) AS revenue + FROM transactions + WHERE year = :year + """ + ), + {"year": start_date.year}, + ).mappings().first() + wehago_rows = conn.execute( + text( + """ + SELECT COALESCE(account_code, '') AS account_code, + SUM(COALESCE(debit, 0)) AS debit, + SUM(COALESCE(credit, 0)) AS credit + FROM wehago_ledger_rows + WHERE fiscal_year = :year + GROUP BY account_code + """ + ), + {"year": start_date.year}, + ).mappings().all() + + wehago_revenue = 0.0 + wehago_cost = 0.0 + wehago_sga = 0.0 + wehago_labor_total = 0.0 + for row in wehago_rows: + code = normalize_text(row.get("account_code")) + amount = _financial_gap_statement_amount(row) + if code in {"411", "412", "413", "414", "415", "416", "417"}: + wehago_revenue += abs(normalize_amount(row.get("credit"))) + elif code == "452": + wehago_cost += amount + elif code.startswith("8"): + wehago_sga += amount + elif code == "908": + wehago_sga -= amount + if code in {"604", "606", "609", "611", "802", "808", "811"}: + wehago_labor_total += amount + + erp_cost = normalize_amount((erp or {}).get("cost")) + erp_sga = normalize_amount((erp or {}).get("sga")) + erp_revenue = normalize_amount((erp or {}).get("revenue")) + wehago_operating_expense = wehago_cost + wehago_sga + wehago_operating_profit = wehago_revenue - wehago_operating_expense + result.update( + { + "year": start_date.year, + "erp_cost": erp_cost, + "erp_sga": erp_sga, + "erp_operating_expense": erp_cost + erp_sga, + "erp_revenue": erp_revenue, + "wehago_cost": wehago_cost, + "wehago_sga": wehago_sga, + "wehago_operating_expense": wehago_operating_expense, + "wehago_revenue": wehago_revenue, + "wehago_operating_profit": wehago_operating_profit, + "wehago_operating_margin": _safe_ratio(wehago_operating_profit, wehago_revenue), + "allocated_to_wehago_expense_gap": normalize_amount(allocated_summary.get("total_cost")) - wehago_operating_expense, + "wehago_labor_total": wehago_labor_total, + "hanmac_to_wehago_labor_gap": normalize_amount(result.get("hanmac_labor_total")) - wehago_labor_total, + } + ) + return result + + +def _cost_analysis_filter_payload_codes(payload: dict[str, Any], codes: list[str] | set[str] | tuple[str, ...]) -> dict[str, Any]: + requested_codes = {normalize_text(code).upper() for code in codes if normalize_text(code)} + if not requested_codes: + return payload + filtered = copy.deepcopy(payload) + filtered_rows = [] + for row in payload.get("rows") or []: + row_codes = { + normalize_text(row.get("support_dept_code")).upper(), + *[normalize_text(code).upper() for code in (row.get("direct_codes") or [])], + *[normalize_text(code).upper() for code in (row.get("aggregate_codes") or [])], + } + if row_codes & requested_codes: + filtered_rows.append(copy.deepcopy(row)) + filtered["rows"] = filtered_rows + filtered["summary"] = _cost_analysis_summary_for_rows(filtered_rows) + filtered["validation_codes"] = sorted(requested_codes) + filtered["cache_info"] = { + **(filtered.get("cache_info") or {}), + "validation_mode": True, + } + return filtered + + +PAGE2_LABOR_TOKENS = ( + "급여", + "임금", + "상여", + "제수당", + "퇴직", + "퇴직금", + "퇴직급여", + "잡급", + "연차", + "연월차", + "국민연금", + "건강보험", + "고용보험", + "산재보험", + "장기요양", +) +PAGE2_INSURANCE_LABOR_TOKENS = ("국민연금", "건강보험", "고용보험", "산재보험", "장기요양") +PAGE2_WEHAGO_LABOR_CODES = {"604", "606", "609", "611", "802", "808", "811"} +PAGE2_WEHAGO_RND_CODES = {"650", "823"} + + +def _cost_analysis2_text_blob(row: Mapping[str, Any]) -> str: + return " ".join( + normalize_text(row.get(key)) + for key in ( + "account_code", + "account_name", + "memo1", + "memo2", + "description", + "vendor_name", + "partner_name", + ) + ) + + +def _cost_analysis2_is_labor_like(row: Mapping[str, Any]) -> bool: + text_blob = _cost_analysis2_text_blob(row) + account_name = normalize_text(row.get("account_name")) + if "복리후생" in account_name and not any(token in text_blob for token in PAGE2_INSURANCE_LABOR_TOKENS): + return False + return any(token in text_blob for token in PAGE2_LABOR_TOKENS) + + +def _cost_analysis2_is_rnd_like(row: Mapping[str, Any]) -> bool: + text_blob = _cost_analysis2_text_blob(row) + return "경상시험연구" in text_blob or "연구개발" in text_blob or "연구원" in text_blob + + +def _cost_analysis2_expense_item(row: Mapping[str, Any]) -> str: + if _cost_analysis_is_sales_cost(dict(row)): + return "sales" + account_code = normalize_text(row.get("account_code")) + account_name = normalize_text(row.get("account_name")) + if account_code.startswith("6"): + return "sga" + if any(keyword in account_name for keyword in COST_ANALYSIS_OUTSOURCE_KEYWORDS): + return "outsource" + return "overhead" + + +def _cost_analysis2_reset_costs(row: dict[str, Any]) -> None: + row["phases"] = _cost_analysis_empty_phase_totals() + row["allocated"] = _cost_analysis_empty_phase_totals() + for key in ( + "period_cost_total", + "period_cost_labor_total", + "period_sga_labor_total", + "period_sga_total", + "period_sales_total", + "period_total_cost", + "period_profit_amount", + "cost_total", + "cost_labor_total", + "sga_labor_total", + "sga_total", + "sales_total", + "total_cost", + "profit_amount", + ): + row[key] = 0.0 + + +def _cost_analysis2_wehago_adjusted_statement(year: int) -> dict[str, float]: + with engine.begin() as conn: + account_rows = conn.execute( + text( + """ + SELECT COALESCE(account_code, '') AS account_code, + COALESCE(account_name, '') AS account_name, + SUM(COALESCE(debit, 0)) AS debit, + SUM(COALESCE(credit, 0)) AS credit + FROM wehago_ledger_rows + WHERE fiscal_year = :year + GROUP BY account_code, account_name + """ + ), + {"year": year}, + ).mappings().all() + detail_rows = conn.execute( + text( + """ + SELECT fiscal_year, + COALESCE(ledger_date, '') AS ledger_date, + COALESCE(account_code, '') AS account_code, + COALESCE(account_name, '') AS account_name, + COALESCE(description, '') AS description, + COALESCE(vendor_name, '') AS vendor_name, + COALESCE(compare_desc, '') AS compare_desc, + COALESCE(voucher_no, '') AS voucher_no, + COALESCE(debit, 0) AS debit, + COALESCE(credit, 0) AS credit + FROM wehago_ledger_rows + WHERE fiscal_year = :year + AND ( + account_code IN ('411','412','413','414','415','416','417','452','908') + OR account_code LIKE '6%' + OR account_code LIKE '8%' + ) + """ + ), + {"year": year}, + ).mappings().all() + operating_expense = 0.0 + labor = 0.0 + rnd = 0.0 + for row in account_rows: + code = normalize_text(row.get("account_code")) + amount = _financial_gap_statement_amount(row) + if code == "452": + operating_expense += amount + elif code.startswith("8"): + operating_expense += amount + elif code == "908": + operating_expense -= amount + if code in PAGE2_WEHAGO_LABOR_CODES: + labor += amount + if code in PAGE2_WEHAGO_RND_CODES: + rnd += amount + adjustment_total = 0.0 + adjustment_labor = 0.0 + adjustment_rnd = 0.0 + for row in detail_rows: + code = normalize_text(row.get("account_code")) + bucket_key = _financial_gap_bucket_for_account(code) + if bucket_key not in {"cogs", "sga", "cost_detail"}: + continue + if _financial_gap_is_closing_transfer(row) or not _financial_gap_is_audit_adjustment(row): + continue + delta = _financial_gap_signed_statement_delta(code, row.get("debit"), row.get("credit")) + if not delta: + continue + adjustment_total += delta + if code in PAGE2_WEHAGO_LABOR_CODES or _cost_analysis2_is_labor_like(row): + adjustment_labor += delta + if code in PAGE2_WEHAGO_RND_CODES or _cost_analysis2_is_rnd_like(row): + adjustment_rnd += delta + adjusted_expense = operating_expense - adjustment_total + adjusted_labor = labor - adjustment_labor + adjusted_rnd = rnd - adjustment_rnd + return { + "wehago_operating_expense": operating_expense, + "wehago_adjustment_total": adjustment_total, + "wehago_adjusted_expense": adjusted_expense, + "wehago_labor": labor, + "wehago_adjusted_labor": adjusted_labor, + "wehago_rnd": rnd, + "wehago_adjusted_rnd": adjusted_rnd, + "wehago_adjusted_nonlabor": adjusted_expense - adjusted_labor, + "wehago_adjusted_nonlabor_without_rnd": adjusted_expense - adjusted_labor - adjusted_rnd, + } + + +def _cost_analysis_erp_basis_payload(start_date_text: str, end_date_text: str, mode: str = "individual") -> dict[str, Any]: + context = _cost_analysis_payload_cache_context(start_date_text, end_date_text) + start_date = context["start_date"] + end_date = context["end_date"] + normalized_mode = "aggregate" if normalize_text(mode).lower() in {"aggregate", "sum", "합산", "연계", "linked", "link"} else "individual" + base_payload = copy.deepcopy(_cost_analysis_load_cached_payload(context, "individual") or {}) + if not base_payload: + base_payload = copy.deepcopy( + _cost_analysis_build_individual_payload( + context, + force=False, + include_cumulative=False, + ) + ) + common_period_revenue_amount = sum( + normalize_amount(row.get("period_revenue_amount")) + for row in (base_payload.get("rows") or []) + if row.get("is_common_revenue") + ) + rows_by_code: dict[str, dict[str, Any]] = {} + for row in base_payload.get("rows") or []: + if row.get("is_common_revenue"): + continue + copied = copy.deepcopy(row) + _cost_analysis2_reset_costs(copied) + rows_by_code[normalize_text(copied.get("support_dept_code")).upper()] = copied + + project_meta = _cost_analysis_get_project_meta() + completion_dates = _cost_analysis_get_completion_billing_dates() + + def resolve_report_code(source_code: Any) -> str: + normalized_code = normalize_text(source_code).upper() + if normalized_code in COST_ANALYSIS_COMMON_CODES: + return "" + return normalized_code if normalized_code.startswith(("0", "9", "X", "Y", "Z")) else "" + + def ensure_row(code: str) -> dict[str, Any]: + normalized_code = normalize_text(code).upper() + if normalized_code not in rows_by_code: + meta = project_meta.get(normalized_code, {"support_dept_code": normalized_code, "support_dept_name": normalized_code}) + rows_by_code[normalized_code] = _cost_analysis_row_template( + normalized_code, + meta, + start_date.year if start_date.year == end_date.year else None, + ) + return rows_by_code[normalized_code] + + hanmac_hours_by_year, _old_labor_by_year, _old_missing_sga = _cost_analysis_load_hanmac_hours_and_labor_yearly( + start_date, + end_date, + project_meta, + None, + ) + hour_weights: dict[int, dict[str, dict[str, float]]] = {} + for year, code_map in hanmac_hours_by_year.items(): + for code, phase_hours in code_map.items(): + target_code = resolve_report_code(code) + if not target_code: + continue + target = hour_weights.setdefault(year, {}).setdefault(target_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) + ensure_row(target_code) + + with engine.begin() as conn: + tx_rows = conn.execute( + text( + f""" + SELECT + COALESCE(voucher_number, '') AS voucher_number, + {COST_ANALYSIS_TX_DATE_SQL} AS posting_date, + CAST(strftime('%Y', {COST_ANALYSIS_TX_DATE_SQL}) AS INTEGER) AS posting_year, + COALESCE(account_code, '') AS account_code, + COALESCE(account_name, '') AS account_name, + COALESCE(accounting_category, '') AS accounting_category, + COALESCE(support_dept_code, '') AS support_dept_code, + COALESCE(support_dept_name, '') AS support_dept_name, + COALESCE(issuing_dept_code, '') AS issuing_dept_code, + COALESCE(issuing_dept_name, '') AS issuing_dept_name, + COALESCE(cost_dept_code, '') AS cost_dept_code, + COALESCE(cost_dept_name, '') AS cost_dept_name, + COALESCE(partner_name, '') AS partner_name, + COALESCE(memo1, '') AS memo1, + COALESCE(memo2, '') AS memo2, + COALESCE(amount, 0) AS amount + FROM transactions + WHERE {COST_ANALYSIS_TX_DATE_SQL} >= :start_date + AND {COST_ANALYSIS_TX_DATE_SQL} <= :end_date + AND (account_code LIKE '5%' OR account_code LIKE '6%') + """ + ), + {"start_date": start_date.isoformat(), "end_date": end_date.isoformat()}, + ).mappings().all() + + pools: dict[int, dict[str, float]] = {} + diagnostics = { + "erp_total_expense": 0.0, + "erp_labor_pool": 0.0, + "erp_cost_labor_pool": 0.0, + "erp_sga_labor_pool": 0.0, + "erp_rnd_labor_pool": 0.0, + "erp_rnd_nonlabor_pool": 0.0, + "erp_direct_nonlabor": 0.0, + "erp_common_nonlabor_cost_pool": 0.0, + "erp_common_nonlabor_sga_pool": 0.0, + "judgement_required_amount": 0.0, + } + for raw_row in tx_rows: + row = dict(raw_row) + year = int(row.get("posting_year") or 0) + if not year: + continue + amount = normalize_amount(row.get("amount")) + if not amount: + continue + bucket = _cost_analysis_financial_bucket(row.get("account_code")) + if bucket not in {"cost", "sga"}: + continue + diagnostics["erp_total_expense"] += amount + pool = pools.setdefault(year, {"labor": 0.0, "sga_labor": 0.0, "common_cost": 0.0, "common_sga": 0.0}) + is_rnd = _cost_analysis2_is_rnd_like(row) + is_labor = _cost_analysis2_is_labor_like(row) + if is_labor: + labor_key = "sga_labor" if normalize_text(row.get("accounting_category")) == "판관비" else "labor" + pool[labor_key] += amount + diagnostics["erp_labor_pool"] += amount + diagnostics["erp_sga_labor_pool" if labor_key == "sga_labor" else "erp_cost_labor_pool"] += amount + if is_rnd: + diagnostics["erp_rnd_labor_pool"] += amount + continue + if is_rnd: + diagnostics["erp_rnd_nonlabor_pool"] += amount + if "기타" in _cost_analysis2_text_blob(row) or "복리후생" in normalize_text(row.get("account_name")): + diagnostics["judgement_required_amount"] += amount + source_code = normalize_text(row.get("support_dept_code")).upper() + target_code = resolve_report_code(source_code) + item_key = _cost_analysis2_expense_item(row) + if not target_code: + if item_key == "sga": + pool["common_sga"] += amount + diagnostics["erp_common_nonlabor_sga_pool"] += amount + else: + pool["common_cost"] += amount + diagnostics["erp_common_nonlabor_cost_pool"] += amount + continue + report_row = ensure_row(target_code) + phase = "pre" if target_code.startswith("X") else _cost_analysis_phase_for_transaction( + target_code, + _date_text(row.get("posting_date")), + completion_dates, + project_meta, + ) + if item_key not in report_row["phases"].get(phase, {}): + item_key = "sga" if bucket == "sga" else "overhead" + report_row["phases"][phase][item_key] += amount + diagnostics["erp_direct_nonlabor"] += amount + + unresolved_labor = 0.0 + unresolved_common = 0.0 + for year, pool in pools.items(): + weights = hour_weights.get(year, {}) + total_hours = sum( + normalize_amount(hours) + for phase_hours in weights.values() + for hours in phase_hours.values() + ) + if total_hours <= 0: + unresolved_labor += pool.get("labor", 0.0) + pool.get("sga_labor", 0.0) + unresolved_common += pool.get("common_cost", 0.0) + pool.get("common_sga", 0.0) + continue + for code, phase_hours in weights.items(): + report_row = ensure_row(code) + for phase, hours in phase_hours.items(): + if phase not in report_row["phases"]: + continue + ratio = normalize_amount(hours) / total_hours + labor_amount = pool.get("labor", 0.0) * ratio + sga_labor_amount = pool.get("sga_labor", 0.0) * ratio + common_cost_amount = pool.get("common_cost", 0.0) * ratio + common_sga_amount = pool.get("common_sga", 0.0) * ratio + report_row["phases"][phase]["labor"] += labor_amount + report_row["phases"][phase]["sga_labor"] += sga_labor_amount + report_row["phases"][phase]["overhead"] += common_cost_amount + report_row["phases"][phase]["sga"] += common_sga_amount + report_row["allocated"][phase]["labor"] += labor_amount + report_row["allocated"][phase]["sga_labor"] += sga_labor_amount + report_row["allocated"][phase]["overhead"] += common_cost_amount + report_row["allocated"][phase]["sga"] += common_sga_amount + + final_rows = list(rows_by_code.values()) + for row in final_rows: + _cost_analysis_finalize_row(row) + row["cumulative_revenue_amount"] = normalize_amount(row.get("revenue_amount")) + row["cumulative_cost_total"] = normalize_amount(row.get("cost_total")) + row["cumulative_sga_total"] = normalize_amount(row.get("sga_total")) + row["cumulative_sales_total"] = normalize_amount(row.get("sales_total")) + row["cumulative_total_cost"] = normalize_amount(row.get("total_cost")) + row["cumulative_profit_amount"] = row["cumulative_revenue_amount"] - row["cumulative_total_cost"] + row["cumulative_profit_rate"] = _safe_ratio(row["cumulative_profit_amount"], row["cumulative_revenue_amount"]) + if normalized_mode == "aggregate": + representative_map = _cost_analysis_get_link_representative_map() + final_rows = _cost_analysis_aggregate_rows(final_rows, project_meta, representative_map) + + final_rows.sort(key=lambda item: (normalize_text(item.get("pm_department")), normalize_text(item.get("project_type")), normalize_text(item.get("project_name")))) + summary = _cost_analysis_summary_for_rows(final_rows) + year_comparisons: list[dict[str, Any]] = [] + for year in sorted(pools): + statement = _cost_analysis2_wehago_adjusted_statement(year) + year_project_rows = [ + row for row in final_rows + if int(normalize_amount(row.get("year")) or year) == year or start_date.year == end_date.year + ] if start_date.year == end_date.year else final_rows + project_total = sum(normalize_amount(row.get("total_cost")) for row in year_project_rows) if start_date.year == end_date.year else summary["total_cost"] + year_comparisons.append( + { + "year": year, + **statement, + "erp_basis_allocated_total": project_total, + "erp_basis_to_wehago_adjusted_gap": project_total - statement["wehago_adjusted_expense"], + } + ) + diagnostics["unresolved_labor_pool"] = unresolved_labor + diagnostics["unresolved_common_pool"] = unresolved_common + diagnostics["erp_basis_total_cost"] = summary["total_cost"] + diagnostics["erp_basis_total_gap_to_erp"] = summary["total_cost"] - diagnostics["erp_total_expense"] + payload = { + **base_payload, + "mode": normalized_mode, + "rows": final_rows, + "summary": summary, + "allocation_diagnostics": { + **(base_payload.get("allocation_diagnostics") or {}), + **diagnostics, }, - payload=payload, - row_count=len(final_rows), - signature=str(cache_key[3]), + "accounting_comparison": { + **(base_payload.get("accounting_comparison") or {}), + "erp_basis_available": True, + "erp_basis": "hanmac_erp_transactions", + "erp_basis_year_comparisons": year_comparisons, + **diagnostics, + }, + "common_period_revenue_amount": common_period_revenue_amount, + "cache_info": { + "source": "computed", + "ready": True, + "financial_logic_version": COST_ANALYSIS_FINANCIAL_LOGIC_VERSION, + "h_project_mapping_version": COST_ANALYSIS_H_PROJECT_MAPPING_VERSION, + "link_logic_version": COST_ANALYSIS_LINK_LOGIC_VERSION, + "data_version": context["data_version"], + "hanmac_cache_version": context["hanmac_cache_version"], + "generated_at": datetime.now().isoformat(timespec="seconds"), + }, + } + return payload + + +def _cost_analysis1_effective_period(start_date: date, end_date: date) -> tuple[date, date]: + minimum = date(2023, 1, 1) + cutoff = date(2026, 3, 31) + effective_start = max(start_date, minimum) + effective_end = min(end_date, cutoff) + if effective_end < effective_start: + effective_start = effective_end + return effective_start, effective_end + + +def _cost_analysis1_labor_basis( + start_date: date, + end_date: date, + project_meta: dict[str, dict[str, Any]], +) -> dict[str, Any]: + alias_to_code, title_to_codes = _cost_analysis_build_hanmac_matchers(project_meta) + completion_dates = _cost_analysis_get_completion_billing_dates() + 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)) + standard: dict[int, dict[str, dict[str, float]]] = {} + hours: dict[int, dict[str, dict[str, float]]] = {} + dept_hours: dict[int, dict[str, dict[tuple[str, str], float]]] = {} + dept_standard: dict[int, dict[str, dict[tuple[str, str], float]]] = {} + common_activity_meta: dict[str, dict[str, Any]] = {} + unresolved_hours = 0.0 + + def labor_bucket(code: str) -> str: + normalized = normalize_text(code).upper() + return "cost" if normalized.startswith(("Y", "Z")) and normalized != "ZZZZZZ" else "sga" + + for year_slice in _iter_year_slices(start_date, end_date): + metric, row_items = _cost_analysis_load_hanmac_member_rows( + year_slice["start"], + year_slice["end"], + prefer_member_grade=True, + ) + if not metric: + continue + resolve_cache: dict[tuple[str, str, str], list[str]] = {} + + def add_project( + project: dict[str, Any], + work_date_text: Any, + member_grade: str, + dept_name: str, + recognized_hours: float, + multiplier: float, + ) -> None: + nonlocal unresolved_hours + if recognized_hours <= 0: + return + work_date = _parse_iso_date(work_date_text) or year_slice["start"] + if work_date < year_slice["start"] or work_date > year_slice["end"]: + return + 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'))}|{work_date.isoformat()}", + ) + codes = resolve_cache.get(resolve_key) + if codes is None: + codes = _cost_analysis_resolve_hanmac_project_codes( + project, + work_date, + alias_to_code, + title_to_codes, + project_meta, + ) + resolve_cache[resolve_key] = codes + if not codes: + fallback_code = normalize_text(project.get("project_code")).upper() + if not fallback_code: + fallback_code = next( + ( + normalize_text(value).upper() + for value in (project.get("equivalent_project_codes") or []) + if normalize_text(value) + ), + "", + ) + codes = [fallback_code] if fallback_code else [] + if not codes: + unresolved_hours += recognized_hours + return + common_activity = _cost_analysis_common_activity_info(project) + if common_activity: + meta = common_activity_meta.setdefault( + common_activity["key"], + { + "label": common_activity["label"], + "source_codes": set(), + }, + ) + meta["source_codes"].update(common_activity["source_codes"]) + split_hours = recognized_hours / len(codes) + cost_weight = normalize_amount(project.get("cost_weight")) or 1.0 + year = work_date.year + for code in codes: + normalized_code = normalize_text(code).upper() + normalized_dept = _cost_analysis_normalize_dept_name( + dept_name + or (project_meta.get(normalized_code) or {}).get("pm_department") + or (project_meta.get(normalized_code) or {}).get("department_name") + ) + phase = ( + "pre" + if normalized_code.startswith("X") + else _cost_analysis_phase_for_transaction( + normalized_code, + work_date.isoformat(), + completion_dates, + project_meta, + ) + ) + rate = _resolve_labor_rate( + rates_by_year, + member_grade, + str(year), + str(year), + (project_meta.get(normalized_code) or {}).get("project_type"), + ) + amount = rate * split_hours * multiplier * cost_weight + target_key = "labor" if labor_bucket(normalized_code) == "cost" else "sga_labor" + standard.setdefault(year, {}).setdefault( + normalized_code, + {"pre": 0.0, "during": 0.0, "post": 0.0, "bucket": target_key}, + )[phase] += amount + hours.setdefault(year, {}).setdefault( + normalized_code, + {"pre": 0.0, "during": 0.0, "post": 0.0}, + )[phase] += split_hours + if normalized_dept: + dept_key = (normalized_code, phase) + dept_hours.setdefault(year, {}).setdefault(normalized_dept, {}) + dept_hours[year][normalized_dept][dept_key] = ( + dept_hours[year][normalized_dept].get(dept_key, 0.0) + split_hours + ) + dept_standard.setdefault(year, {}).setdefault(normalized_dept, {}) + dept_standard[year][normalized_dept][dept_key] = ( + dept_standard[year][normalized_dept].get(dept_key, 0.0) + amount + ) + + 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") + ) + if not member_grade: + continue + dept_name = normalize_text(row.get("dept_name")) + 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")) + joint_hours = normalize_amount(project.get("recognized_hours")) if _is_hanmac_joint_detail(project) else 0.0 + next_hours = ( + joint_hours + if joint_hours > 0 + else recognized_total * raw_hours / raw_total + if raw_total > 0 and recognized_total > 0 + else raw_hours + ) + add_project(project, detail.get("work_date"), member_grade, dept_name, next_hours, 1.0) + for detail in details.get("overtime_hours") or []: + add_project( + detail, + detail.get("work_date"), + member_grade, + dept_name, + normalize_amount(detail.get("overtime_hours")), + 1.5, + ) + for detail in details.get("holiday_hours") or []: + projects = detail.get("projects") if isinstance(detail.get("projects"), list) else [] + recognized_total = min(normalize_amount(detail.get("holiday_hours")), 5.0) + if projects: + raw_total = sum(normalize_amount(project.get("hours")) for project in projects) + for project in projects: + raw_hours = normalize_amount(project.get("hours")) + next_hours = ( + recognized_total * raw_hours / raw_total + if raw_total > 0 and recognized_total > 0 + else min(raw_hours, 5.0) + ) + add_project(project, detail.get("work_date"), member_grade, dept_name, next_hours, 1.5) + else: + add_project(detail, detail.get("work_date"), member_grade, dept_name, recognized_total, 1.5) + + # ZZZZZZ는 미배부가 아니라 공통 프로젝트의 유효 코드다. + # 한맥 근무기록이 ZZZZZZ에 연결된 경우 해당 기준인건비를 그대로 보존한다. + return { + "standard": standard, + "hours": hours, + "dept_hours": dept_hours, + "dept_standard": dept_standard, + "common_activity_meta": { + code: { + "label": normalize_text(meta.get("label")), + "source_codes": sorted(meta.get("source_codes") or []), + } + for code, meta in common_activity_meta.items() + }, + "unresolved_hours": unresolved_hours, + } + + +def _cost_analysis1_build_payload(start_date_text: str, end_date_text: str, mode: str = "individual") -> dict[str, Any]: + context = _cost_analysis_payload_cache_context(start_date_text, end_date_text) + start_date, end_date = _cost_analysis1_effective_period( + context["start_date"], + context["end_date"], ) - return _set_deepcopy_ttl_cache_entry( - _COST_ANALYSIS_PAYLOAD_CACHE, - _COST_ANALYSIS_PAYLOAD_CACHE_LOCK, - cache_key, - payload, + normalized_mode = "aggregate" if normalize_text(mode).lower() in {"aggregate", "sum", "합산", "연계", "linked", "link"} else "individual" + erp_basis = copy.deepcopy(_cost_analysis_erp_basis_payload(start_date.isoformat(), end_date.isoformat(), "individual")) + rows_by_code: dict[str, dict[str, Any]] = {} + common_period_revenue = normalize_amount(erp_basis.get("common_period_revenue_amount")) + common_collection_amount = 0.0 + common_period_collection_amount = 0.0 + for collection_event in _cost_analysis_erp_collection_events(end_date): + code = normalize_text(collection_event.get("support_dept_code")).upper() + if code not in COST_ANALYSIS_COMMON_CODES: + continue + amount = normalize_amount(collection_event.get("amount")) + common_collection_amount += amount + posting_date = _date_text(collection_event.get("posting_date")) + if posting_date and start_date.isoformat() <= posting_date <= end_date.isoformat(): + common_period_collection_amount += amount + for source in erp_basis.get("rows") or []: + if source.get("is_common_revenue"): + continue + row = copy.deepcopy(source) + for phase in ("pre", "during", "post"): + bucket = row["phases"][phase] + bucket["overhead"] -= normalize_amount(row["allocated"][phase].get("overhead")) + bucket["sga"] -= normalize_amount(row["allocated"][phase].get("sga")) + bucket["labor"] = 0.0 + bucket["labor_adjustment"] = 0.0 + bucket["sga_labor"] = 0.0 + bucket["sga_labor_adjustment"] = 0.0 + row["allocated"][phase]["labor"] = 0.0 + row["allocated"][phase]["sga_labor"] = 0.0 + row["allocated"][phase]["overhead"] = 0.0 + row["allocated"][phase]["sga"] = 0.0 + rows_by_code[normalize_text(row.get("support_dept_code")).upper()] = row + + project_meta = _cost_analysis_get_project_meta() + common_activity_meta: dict[str, dict[str, Any]] = {} + common_activity_code_map: dict[str, str] = {} + + def ensure_row(code: str) -> dict[str, Any]: + normalized = normalize_text(code).upper() + normalized = common_activity_code_map.get(normalized, normalized) + if normalized not in rows_by_code: + activity_meta = common_activity_meta.get(normalized) + if activity_meta: + meta = { + "row_key": normalized, + "support_dept_name": normalize_text(activity_meta.get("label")) or "공통업무", + "pm_department": "공통", + "project_type": "공통업무", + } + row = _cost_analysis_row_template( + "ZZZZZZ", + meta, + start_date.year if start_date.year == end_date.year else None, + ) + row["row_key"] = normalized + row["support_dept_code"] = "ZZZZZZ" + row["project_name"] = meta["support_dept_name"] + row["pm_department"] = "공통" + row["project_type"] = "공통업무" + row["direct_codes"] = list(activity_meta.get("source_codes") or []) + row["detail_codes"] = [normalized] + row["is_common_activity"] = True + rows_by_code[normalized] = row + else: + meta = project_meta.get(normalized, {"support_dept_code": normalized, "support_dept_name": normalized}) + rows_by_code[normalized] = _cost_analysis_row_template( + normalized, + meta, + start_date.year if start_date.year == end_date.year else None, + ) + return rows_by_code[normalized] + + basis = _cost_analysis1_labor_basis(start_date, end_date, project_meta) + common_activity_meta = basis.get("common_activity_meta") or {} + common_activity_code_map = { + normalize_text(source_code).upper(): activity_code + for activity_code, activity_meta in common_activity_meta.items() + for source_code in (activity_meta.get("source_codes") or []) + if normalize_text(source_code) + } + generic_common_code = f"{COST_ANALYSIS_COMMON_ACTIVITY_PREFIX}ERP공통비" + common_activity_meta.setdefault( + generic_common_code, + { + "label": "공통/ERP 공통비", + "source_codes": ["ZZZZZZ"], + }, ) + all_hour_weights: dict[int, dict[tuple[str, str], float]] = {} + for year, code_map in basis["hours"].items(): + for code, phase_map in code_map.items(): + normalized_code = normalize_text(code).upper() + if not normalized_code or normalized_code in COST_ANALYSIS_COMMON_CODES: + continue + for phase in ("pre", "during", "post"): + amount = normalize_amount(phase_map.get(phase)) + if amount > 0: + all_hour_weights.setdefault(year, {})[(normalized_code, phase)] = ( + all_hour_weights.setdefault(year, {}).get((normalized_code, phase), 0.0) + amount + ) + + with engine.begin() as conn: + common_rows = conn.execute( + text( + f""" + SELECT + CAST(strftime('%Y', {COST_ANALYSIS_TX_DATE_SQL}) AS INTEGER) AS posting_year, + COALESCE(account_code, '') AS account_code, + COALESCE(account_name, '') AS account_name, + COALESCE(accounting_category, '') AS accounting_category, + COALESCE(support_dept_code, '') AS support_dept_code, + COALESCE(support_dept_name, '') AS support_dept_name, + COALESCE(issuing_dept_code, '') AS issuing_dept_code, + COALESCE(issuing_dept_name, '') AS issuing_dept_name, + COALESCE(cost_dept_code, '') AS cost_dept_code, + COALESCE(cost_dept_name, '') AS cost_dept_name, + COALESCE(partner_name, '') AS partner_name, + COALESCE(memo1, '') AS memo1, + COALESCE(memo2, '') AS memo2, + COALESCE(amount, 0) AS amount + FROM transactions + WHERE {COST_ANALYSIS_TX_DATE_SQL} >= :start_date + AND {COST_ANALYSIS_TX_DATE_SQL} <= :end_date + AND (account_code LIKE '5%' OR account_code LIKE '6%') + AND UPPER(COALESCE(support_dept_code, '')) IN ('', 'ZZZZZZ') + """ + ), + {"start_date": start_date.isoformat(), "end_date": end_date.isoformat()}, + ).mappings().all() + for raw_row in common_rows: + tx_row = dict(raw_row) + if _cost_analysis2_is_labor_like(tx_row): + continue + amount = normalize_amount(tx_row.get("amount")) + if not amount: + continue + target = ensure_row(generic_common_code) + phase = "during" + item_key = _cost_analysis2_expense_item(tx_row) + if item_key not in target["phases"][phase]: + item_key = "sga" if normalize_text(tx_row.get("accounting_category")) == "판관비" else "overhead" + target["phases"][phase][item_key] += amount + + # ERP 기초자료에서 접두어 필터로 빠진 기타 코드를 페이지1에 직접 보완한다. + # ZZZZZZ/미지정 비용은 위 공통비 풀에서 전체 프로젝트 투입시간으로 배부한다. + initial_basis_codes = set(rows_by_code) + completion_dates = _cost_analysis_get_completion_billing_dates() + with engine.begin() as conn: + direct_rows = conn.execute( + text( + f""" + SELECT + {COST_ANALYSIS_TX_DATE_SQL} AS posting_date, + COALESCE(account_code, '') AS account_code, + COALESCE(account_name, '') AS account_name, + COALESCE(accounting_category, '') AS accounting_category, + UPPER(COALESCE(support_dept_code, '')) AS support_dept_code, + COALESCE(support_dept_name, '') AS support_dept_name, + COALESCE(issuing_dept_code, '') AS issuing_dept_code, + COALESCE(issuing_dept_name, '') AS issuing_dept_name, + COALESCE(cost_dept_code, '') AS cost_dept_code, + COALESCE(cost_dept_name, '') AS cost_dept_name, + COALESCE(partner_name, '') AS partner_name, + COALESCE(memo1, '') AS memo1, + COALESCE(memo2, '') AS memo2, + COALESCE(amount, 0) AS amount + FROM transactions + WHERE {COST_ANALYSIS_TX_DATE_SQL} >= :start_date + AND {COST_ANALYSIS_TX_DATE_SQL} <= :end_date + AND COALESCE(support_dept_code, '') <> '' + AND ( + account_code LIKE '4%' + OR account_code LIKE '5%' + OR account_code LIKE '6%' + ) + """ + ), + {"start_date": start_date.isoformat(), "end_date": end_date.isoformat()}, + ).mappings().all() + for raw_row in direct_rows: + tx_row = dict(raw_row) + code = normalize_text(tx_row.get("support_dept_code")).upper() + if not code: + continue + amount = normalize_amount(tx_row.get("amount")) + if not amount: + continue + row = ensure_row(code) + if not normalize_text(row.get("project_name")) or row.get("project_name") == code: + row["project_name"] = normalize_text(tx_row.get("support_dept_name")) or code + bucket = _cost_analysis_financial_bucket(tx_row.get("account_code")) + if bucket == "revenue": + if code in COST_ANALYSIS_COMMON_CODES: + continue + if code not in initial_basis_codes or code == "ZZZZZZ": + row["revenue_amount"] += amount + row["period_revenue_amount"] += amount + continue + if bucket not in {"cost", "sga"} or _cost_analysis2_is_labor_like(tx_row): + continue + if code in COST_ANALYSIS_COMMON_CODES: + continue + if code in initial_basis_codes and code != "ZZZZZZ": + continue + phase = "pre" if code.startswith("X") else _cost_analysis_phase_for_transaction( + code, + _date_text(tx_row.get("posting_date")), + completion_dates, + project_meta, + ) + item_key = _cost_analysis2_expense_item(tx_row) + if item_key not in row["phases"][phase]: + item_key = "sga" if bucket == "sga" else "overhead" + row["phases"][phase][item_key] += amount + + with engine.begin() as conn: + labor_rows = conn.execute( + text( + f""" + SELECT + CAST(strftime('%Y', {COST_ANALYSIS_TX_DATE_SQL}) AS INTEGER) AS posting_year, + COALESCE(account_code, '') AS account_code, + COALESCE(account_name, '') AS account_name, + COALESCE(accounting_category, '') AS accounting_category, + COALESCE(support_dept_code, '') AS support_dept_code, + COALESCE(support_dept_name, '') AS support_dept_name, + COALESCE(issuing_dept_code, '') AS issuing_dept_code, + COALESCE(issuing_dept_name, '') AS issuing_dept_name, + COALESCE(cost_dept_code, '') AS cost_dept_code, + COALESCE(cost_dept_name, '') AS cost_dept_name, + COALESCE(partner_name, '') AS partner_name, + COALESCE(memo1, '') AS memo1, + COALESCE(memo2, '') AS memo2, + COALESCE(amount, 0) AS amount + FROM transactions + WHERE {COST_ANALYSIS_TX_DATE_SQL} >= :start_date + AND {COST_ANALYSIS_TX_DATE_SQL} <= :end_date + AND (account_code LIKE '5%' OR account_code LIKE '6%') + """ + ), + {"start_date": start_date.isoformat(), "end_date": end_date.isoformat()}, + ).mappings().all() + + labor_pools: dict[int, dict[str, float]] = {} + for raw_row in labor_rows: + tx_row = dict(raw_row) + if not _cost_analysis2_is_labor_like(tx_row): + continue + year = int(tx_row.get("posting_year") or 0) + amount = normalize_amount(tx_row.get("amount")) + if not year or not amount: + continue + bucket_key = "labor" if normalize_text(tx_row.get("account_code")).startswith("5") else "sga_labor" + pool = labor_pools.setdefault(year, {"labor": 0.0, "sga_labor": 0.0}) + pool[bucket_key] += amount + + yearly_labor_diagnostics: list[dict[str, Any]] = [] + for year, code_map in basis["standard"].items(): + standard_weights_by_bucket: dict[str, dict[tuple[str, str], float]] = { + "labor": {}, + "sga_labor": {}, + } + for code, phase_map in code_map.items(): + normalized_code = normalize_text(code).upper() + if normalized_code in COST_ANALYSIS_COMMON_CODES: + continue + item_key = normalize_text(phase_map.get("bucket")) or "sga_labor" + if item_key not in standard_weights_by_bucket: + item_key = "sga_labor" + for phase in ("pre", "during", "post"): + standard_amount = normalize_amount(phase_map.get(phase)) + if standard_amount > 0: + standard_weights_by_bucket[item_key][(normalized_code, phase)] = standard_amount + + year_pool = labor_pools.get(year) or {"labor": 0.0, "sga_labor": 0.0} + year_diagnostic: dict[str, Any] = {"year": year, "unallocated_pool": 0.0} + for item_key in ("labor", "sga_labor"): + standard_weights = standard_weights_by_bucket[item_key] + total_standard_weight = sum(standard_weights.values()) + actual_labor = normalize_amount(year_pool.get(item_key)) + adjustment_pool = actual_labor - total_standard_weight + standard_scale = ( + min(1.0, actual_labor / total_standard_weight) + if total_standard_weight > 0 + else 0.0 + ) + adjustment_pool = max(0.0, adjustment_pool) + adjustment_key = "labor_adjustment" if item_key == "labor" else "sga_labor_adjustment" + for (code, phase), standard_amount in standard_weights.items(): + row = ensure_row(code) + applied_standard_amount = standard_amount * standard_scale + row["phases"][phase][item_key] += applied_standard_amount + if total_standard_weight > 0 and adjustment_pool > 0: + allocated_amount = adjustment_pool * standard_amount / total_standard_weight + row["phases"][phase][adjustment_key] += allocated_amount + row["allocated"][phase][adjustment_key] += allocated_amount + row.setdefault("allocation_details", []).append( + { + "source_row_key": f"ERP:{year}:{item_key}", + "source_project_code": "ZZZZZZ", + "source_project_name": ( + "ERP 공통 원가 인건비성 비용" + if item_key == "labor" + else "ERP 공통 판관비 인건비성 비용" + ), + "source_phase": "all", + "source_item": adjustment_key, + "target_phase": phase, + "target_item": adjustment_key, + "project_hours": 0.0, + "project_phase_hours": 0.0, + "eligible_total_hours": 0.0, + "project_ratio": standard_amount / total_standard_weight, + "phase_ratio": 1.0, + "source_amount": adjustment_pool, + "allocated_amount": allocated_amount, + } + ) + year_diagnostic[f"{item_key}_standard"] = total_standard_weight + year_diagnostic[f"{item_key}_standard_scale"] = standard_scale + year_diagnostic[f"{item_key}_applied_standard"] = total_standard_weight * standard_scale + year_diagnostic[f"{item_key}_actual"] = actual_labor + year_diagnostic[f"{item_key}_adjustment"] = adjustment_pool + year_diagnostic["standard_labor"] = ( + normalize_amount(year_diagnostic.get("labor_standard")) + + normalize_amount(year_diagnostic.get("sga_labor_standard")) + ) + year_diagnostic["actual_labor"] = ( + normalize_amount(year_diagnostic.get("labor_actual")) + + normalize_amount(year_diagnostic.get("sga_labor_actual")) + ) + year_diagnostic["adjustment"] = ( + normalize_amount(year_diagnostic.get("labor_adjustment")) + + normalize_amount(year_diagnostic.get("sga_labor_adjustment")) + ) + yearly_labor_diagnostics.append( + year_diagnostic + ) + + project_phase_hours: dict[str, dict[str, float]] = {} + for code_map in basis["hours"].values(): + for code, phase_map in code_map.items(): + normalized_code = normalize_text(code).upper() + if not normalized_code.startswith(("X", "Y", "Z")) or normalized_code == "ZZZZZZ": + continue + target = project_phase_hours.setdefault( + normalized_code, + {"pre": 0.0, "during": 0.0, "post": 0.0}, + ) + for phase in ("pre", "during", "post"): + target[phase] += normalize_amount(phase_map.get(phase)) + ensure_row(normalized_code) + + def common_allocation_item(source_item: str) -> str: + normalized_item = normalize_text(source_item) + if normalized_item in {"labor", "sga_labor"}: + return "sga_labor" + if normalized_item in {"labor_adjustment", "sga_labor_adjustment"}: + return "sga_labor_adjustment" + if normalized_item == "outsource": + return "outsource" + if normalized_item in {"overhead", "sga", "sales"}: + return "sga" + return "sga" + + common_allocation_total = 0.0 + common_source_total = 0.0 + common_unallocated_total = 0.0 + for source_code, source_row in list(rows_by_code.items()): + if not source_row.get("is_common_activity"): + continue + is_visible_exception = _cost_analysis_is_visible_common_exception(source_row.get("project_name")) + source_row["is_common_exception"] = is_visible_exception + source_row["is_hidden_common_activity"] = not is_visible_exception + source_row["exclude_from_totals"] = not is_visible_exception + source_row["allocation_details"] = [] + if is_visible_exception: + continue + + restrict_to_cm = "감리대기" in normalize_text(source_row.get("project_name")) + eligible_hours: dict[str, dict[str, float]] = {} + for project_code, phase_hours in project_phase_hours.items(): + project_row = rows_by_code.get(project_code) or {} + pm_department = normalize_text( + project_row.get("pm_department") + or (project_meta.get(project_code) or {}).get("pm_department") + ) + if restrict_to_cm and _cost_analysis_normalize_dept_name(pm_department) != _cost_analysis_normalize_dept_name("건설사업관리부"): + continue + project_total_hours = sum(normalize_amount(phase_hours.get(phase)) for phase in ("pre", "during", "post")) + if project_total_hours > 0: + eligible_hours[project_code] = phase_hours + eligible_total_hours = sum( + sum(normalize_amount(phase_hours.get(phase)) for phase in ("pre", "during", "post")) + for phase_hours in eligible_hours.values() + ) + + for source_phase in ("pre", "during", "post"): + for source_item, source_amount_value in source_row["phases"][source_phase].items(): + source_amount = normalize_amount(source_amount_value) + if not source_amount: + continue + common_source_total += source_amount + if eligible_total_hours <= 0: + common_unallocated_total += source_amount + continue + target_item = common_allocation_item(source_item) + for project_code, phase_hours in eligible_hours.items(): + project_total_hours = sum( + normalize_amount(phase_hours.get(phase)) + for phase in ("pre", "during", "post") + ) + if project_total_hours <= 0: + continue + project_allocated_amount = source_amount * project_total_hours / eligible_total_hours + target_row = ensure_row(project_code) + for target_phase in ("pre", "during", "post"): + phase_hours_value = normalize_amount(phase_hours.get(target_phase)) + if phase_hours_value <= 0: + continue + allocated_amount = project_allocated_amount * phase_hours_value / project_total_hours + target_row["phases"][target_phase][target_item] += allocated_amount + target_row["allocated"][target_phase][target_item] += allocated_amount + common_allocation_total += allocated_amount + allocation_detail = { + "source_row_key": source_row.get("row_key") or source_code, + "source_project_code": ", ".join(source_row.get("direct_codes") or []) or "ZZZZZZ", + "source_project_name": source_row.get("project_name") or "공통업무", + "source_phase": source_phase, + "source_item": source_item, + "target_phase": target_phase, + "target_item": target_item, + "project_hours": project_total_hours, + "project_phase_hours": phase_hours_value, + "eligible_total_hours": eligible_total_hours, + "project_ratio": project_total_hours / eligible_total_hours, + "phase_ratio": phase_hours_value / project_total_hours, + "source_amount": source_amount, + "allocated_amount": allocated_amount, + } + target_row.setdefault("allocation_details", []).append(allocation_detail) + source_row["allocation_details"].append( + { + **allocation_detail, + "target_project_code": project_code, + "target_project_name": target_row.get("project_name") or project_code, + } + ) + + representative_row = ensure_row("ZZZZZZ") + representative_row["support_dept_code"] = "ZZZZZZ" + representative_row["project_name"] = "공통" + representative_row["pm_department"] = "공통" + representative_row["project_type"] = "공통" + representative_row["is_common_master"] = True + representative_row["exclude_from_totals"] = False + representative_row["period_revenue_amount"] = common_period_revenue + representative_row["revenue_amount"] = 0.0 + representative_row["billing_amount"] = 0.0 + representative_row["collection_amount"] = common_collection_amount + representative_row["period_billing_amount"] = 0.0 + representative_row["period_negative_billing_amount"] = 0.0 + representative_row["period_collection_amount"] = common_period_collection_amount + representative_row["contract_amount"] = 0.0 + representative_row["contract_balance_amount"] = 0.0 + representative_row["phases"] = _cost_analysis_empty_phase_totals() + representative_row["allocated"] = _cost_analysis_empty_phase_totals() + + total_standard = sum(normalize_amount(item["standard_labor"]) for item in yearly_labor_diagnostics) + actual_labor_total = sum(normalize_amount(item["actual_labor"]) for item in yearly_labor_diagnostics) + adjustment_total = actual_labor_total - total_standard + actual_ratio = actual_labor_total / total_standard if total_standard > 0 else 0.0 + + final_rows = [ + row + for code, row in rows_by_code.items() + if normalize_text(code).upper() + ] + for row in final_rows: + _cost_analysis_finalize_row(row) + row["cumulative_revenue_amount"] = normalize_amount(row.get("revenue_amount")) + row["cumulative_cost_total"] = normalize_amount(row.get("cost_total")) + row["cumulative_sga_total"] = normalize_amount(row.get("sga_total")) + row["cumulative_sales_total"] = normalize_amount(row.get("sales_total")) + row["cumulative_total_cost"] = normalize_amount(row.get("total_cost")) + row["cumulative_profit_amount"] = row["cumulative_revenue_amount"] - row["cumulative_total_cost"] + row["cumulative_profit_rate"] = _safe_ratio(row["cumulative_profit_amount"], row["cumulative_revenue_amount"]) + _cost_analysis_clean_common_master_row(row) + + if normalized_mode == "individual": + final_rows = _cost_analysis_apply_individual_display_codes(final_rows, project_meta) + for row in final_rows: + _cost_analysis_clean_common_master_row(row) + if normalized_mode == "aggregate": + final_rows = _cost_analysis_aggregate_rows( + final_rows, + project_meta, + _cost_analysis_get_link_representative_map(), + ) + for row in final_rows: + _cost_analysis_clean_common_master_row(row) + final_rows.sort( + key=lambda item: ( + 0 if item.get("is_common_master") else 1 if item.get("is_hidden_common_activity") else 2, + normalize_text(item.get("pm_department")), + normalize_text(item.get("project_type")), + normalize_text(item.get("project_name")), + ) + ) + accounting_rows = [row for row in final_rows if not row.get("exclude_from_totals")] + summary = _cost_analysis_summary_for_rows(accounting_rows) + summary["phases"] = { + phase: { + item: sum(normalize_amount((row.get("phases") or {}).get(phase, {}).get(item)) for row in accounting_rows) + for item in ("labor", "labor_adjustment", "outsource", "overhead", "sga_labor", "sga_labor_adjustment", "sga", "sales") + } + for phase in ("pre", "during", "post") + } + displayed_labor_total = sum( + normalize_amount((row.get("phases") or {}).get(phase, {}).get(item)) + for row in accounting_rows + for phase in ("pre", "during", "post") + for item in ("labor", "labor_adjustment", "sga_labor", "sga_labor_adjustment") + ) + displayed_nonlabor_total = summary["total_cost"] - displayed_labor_total + erp_total_expense = normalize_amount((erp_basis.get("allocation_diagnostics") or {}).get("erp_total_expense")) + expense_reconciliation_gap = summary["total_cost"] - erp_total_expense + return { + **erp_basis, + "start_date": start_date.isoformat(), + "end_date": end_date.isoformat(), + "mode": normalized_mode, + "rows": final_rows, + "summary": summary, + "allocation_diagnostics": { + **(erp_basis.get("allocation_diagnostics") or {}), + "standard_labor_total": total_standard, + "actual_labor_total": actual_labor_total, + "labor_adjustment_total": adjustment_total, + "labor_actual_ratio": actual_ratio, + "yearly_labor_allocation": yearly_labor_diagnostics, + "unresolved_hours": basis["unresolved_hours"], + "calculation_cutoff": end_date.isoformat(), + "displayed_labor_total": displayed_labor_total, + "displayed_nonlabor_total": displayed_nonlabor_total, + "displayed_total_expense": summary["total_cost"], + "erp_total_expense": erp_total_expense, + "expense_reconciliation_gap": expense_reconciliation_gap, + "expense_reconciled": abs(expense_reconciliation_gap) < 0.5, + "common_source_total": common_source_total, + "common_allocated_total": common_allocation_total, + "common_unallocated_total": common_unallocated_total, + }, + "cache_info": { + "source": "computed", + "ready": True, + "financial_logic_version": COST_ANALYSIS_FINANCIAL_LOGIC_VERSION, + "h_project_mapping_version": COST_ANALYSIS_H_PROJECT_MAPPING_VERSION, + "link_logic_version": COST_ANALYSIS_LINK_LOGIC_VERSION, + "data_version": context["data_version"], + "hanmac_cache_version": context["hanmac_cache_version"], + "generated_at": datetime.now().isoformat(timespec="seconds"), + }, + } + + +def _cost_analysis_build_payload(start_date_text: str, end_date_text: str, mode: str = "individual", force: bool = False) -> dict[str, Any]: + return _cost_analysis1_build_payload(start_date_text, end_date_text, mode) def render_cost_analysis_page(request: Request, message: str = "") -> HTMLResponse: init_db() - today = date.today() + cutoff = date(2026, 3, 31) context = { **base_context(request, message), - "default_start_date": date(today.year, 1, 1).isoformat(), - "default_end_date": today.isoformat(), + "default_start_date": date(2023, 1, 1).isoformat(), + "default_end_date": cutoff.isoformat(), } - return templates.TemplateResponse(request, "cost_analysis.html", context) + response = templates.TemplateResponse(request, "cost_analysis.html", context) + response.headers["Cache-Control"] = "no-store, max-age=0" + response.headers["Pragma"] = "no-cache" + response.headers["Expires"] = "0" + return response def render_annual_summary_page(request: Request, message: str = "") -> HTMLResponse: @@ -14667,6 +18795,1466 @@ def render_annual_summary_page(request: Request, message: str = "") -> HTMLRespo return templates.TemplateResponse(request, "annual_summary.html", context) +def _financial_gap_statement_amount(row: Mapping[str, Any]) -> float: + debit = normalize_amount(row.get("debit")) + credit = normalize_amount(row.get("credit")) + return max(abs(debit), abs(credit)) + + +def _financial_gap_signed_gap(wehago_amount: Any, erp_amount: Any) -> float: + return normalize_amount(wehago_amount) - normalize_amount(erp_amount) + + +def _financial_gap_bucket_for_account(code: str) -> str: + if code in {"411", "412", "413", "414", "415", "416", "417"}: + return "revenue" + if code == "452": + return "cogs" + if code.startswith("8") or code == "908": + return "sga" + if code.startswith("6"): + return "cost_detail" + if code in {"901", "902", "903", "904", "905", "906", "907", "914", "930"}: + return "nonop_income" + if code in {"931", "932", "933", "934", "935", "936", "937", "960"}: + return "nonop_expense" + if code == "998": + return "tax" + return "" + + +def _financial_gap_signed_statement_delta(code: str, debit: Any, credit: Any) -> float: + debit_amount = normalize_amount(debit) + credit_amount = normalize_amount(credit) + bucket = _financial_gap_bucket_for_account(code) + if bucket in {"revenue", "nonop_income"}: + return credit_amount - debit_amount + if bucket in {"cogs", "sga", "cost_detail", "nonop_expense", "tax"}: + return debit_amount - credit_amount + return 0.0 + + +FINANCIAL_GAP_ERP_COST_LABOR_CODES = {"50120301", "50120501", "50152521"} +FINANCIAL_GAP_ERP_COST_BENEFIT_CODES = { + "50152501", + "50152503", + "50152505", + "50152507", + "50152511", + "50152519", +} +FINANCIAL_GAP_ERP_SGA_LABOR_CODES = {"60110501"} +FINANCIAL_GAP_ERP_RND_DISPLAY_CODES = {"60114701", "60114741"} +FINANCIAL_GAP_WEHAGO_COST_BENEFIT_CODES = {"611"} +FINANCIAL_GAP_WEHAGO_SGA_BENEFIT_CODES = {"811"} +FINANCIAL_GAP_WEHAGO_RND_DISPLAY_CODES = {"650"} + + +def _financial_gap_is_closing_transfer(row: Mapping[str, Any]) -> bool: + code = normalize_text(row.get("account_code")) + text_blob = " ".join( + [ + normalize_text(row.get("description")), + normalize_text(row.get("vendor_name")), + normalize_text(row.get("compare_desc")), + ] + ) + if code == "400": + return True + closing_signals = ( + "손익계정에 대체", + "수익에서 대체", + "비용에서 대체", + "당기순손익", + "잉여금에 대체", + ) + return any(signal in text_blob for signal in closing_signals) + + +def _financial_gap_is_audit_adjustment(row: Mapping[str, Any]) -> bool: + if _financial_gap_is_closing_transfer(row): + return False + code = normalize_text(row.get("account_code")) + text_blob = " ".join( + [ + normalize_text(row.get("description")), + normalize_text(row.get("vendor_name")), + normalize_text(row.get("compare_desc")), + ] + ) + if code == "452": + return False + if code == "417" and "기초미완성공사" in text_blob: + return False + adjustment_signals = ( + "감사", + "결산", + "수정분개", + "수정신고", + "환원분개", + "결산분개", + "진행율 매출액", + "진행률 매출액", + "계상분 대체", + "기초미완성공사", + ) + return any(signal in text_blob for signal in adjustment_signals) + + +def _financial_gap_ratio(gap: Any, base_amount: Any) -> float: + base = abs(normalize_amount(base_amount)) + if base <= 0: + return 0.0 + return (normalize_amount(gap) / base) * 100.0 + + +def _financial_gap_substantive_group( + source: str, + code: str, + name: str, + category: str = "", +) -> tuple[str, str, str, str]: + code_text = normalize_text(code) + name_text = normalize_text(name) + category_text = normalize_text(category) + text_blob = f"{code_text} {name_text} {category_text}" + insurance_labor_tokens = ("국민연금", "건강보험", "고용보험", "산재보험", "장기요양") + payroll_labor_tokens = ("급여", "임금", "상여", "제수당", "퇴직", "퇴직금", "퇴직급여", "잡급", "연차", "연월차", *insurance_labor_tokens) + + if source == "wehago": + revenue_map = { + "411": ("수익", "revenue_design", "설계용역수입", "411 설계용역수입"), + "412": ("수익", "revenue_supervision", "감리용역수입", "412 감리용역수입"), + "415": ("수익", "revenue_safety", "안전점검수입", "415 안전점검수입"), + "413": ("수익", "revenue_rent", "임대·관리수입", "413 임대료수입"), + "414": ("수익", "revenue_parking", "주차수입", "414 주차료수입"), + "417": ("수익", "revenue_research", "연구용역수입", "417 연구용역수입"), + } + if code_text in revenue_map: + return revenue_map[code_text] + if code_text == "452": + return ("영업비용", "expense_cogs_total", "매출원가 총액", "452 도급공사매출원가") + if code_text in FINANCIAL_GAP_WEHAGO_COST_BENEFIT_CODES: + return ("영업비용", "expense_cost_benefit", "원가성 복리후생비", f"{code_text} {name_text}") + if code_text in FINANCIAL_GAP_WEHAGO_SGA_BENEFIT_CODES: + return ("영업비용", "expense_sga_benefit", "판관 복리후생비", f"{code_text} {name_text}") + if code_text in FINANCIAL_GAP_WEHAGO_RND_DISPLAY_CODES: + return ("영업비용", "expense_rnd_display", "연구개발비 별도 표시", f"{code_text} {name_text}") + if code_text in {"604", "606", "609"}: + return ("영업비용", "expense_cost_labor", "원가성 인건비", f"{code_text} {name_text}") + if code_text == "602": + return ("영업비용", "expense_cost_outsourcing", "외주비", f"{code_text} {name_text}") + if code_text in {"631", "639"}: + return ("영업비용", "expense_cost_fee", "원가 지급수수료·보증수수료", f"{code_text} {name_text}") + if code_text in {"645", "644"}: + return ("영업비용", "expense_cost_field_ops", "현장운영·행사비", f"{code_text} {name_text}") + if code_text in {"612"}: + return ("영업비용", "expense_cost_travel", "원가 여비교통비", f"{code_text} {name_text}") + if code_text in {"619"}: + return ("영업비용", "expense_cost_rent", "원가 지급임차료", f"{code_text} {name_text}") + if code_text in {"626", "629", "630"}: + return ("영업비용", "expense_cost_supplies_print", "원가 도서·사무·소모품", f"{code_text} {name_text}") + if code_text in {"617", "618", "622", "614", "625", "634"}: + return ("영업비용", "expense_cost_admin", "원가 기타 운영비", f"{code_text} {name_text}") + if code_text in {"802", "808"}: + return ("영업비용", "expense_sga_labor", "판관 인건비", f"{code_text} {name_text}") + if code_text == "823": + return ("영업비용", "expense_sga_rnd", "판관 연구개발비", f"{code_text} {name_text}") + if code_text in {"831", "837"}: + return ("영업비용", "expense_sga_fee", "판관 지급수수료·건물관리비", f"{code_text} {name_text}") + if code_text.startswith("8") or code_text == "908": + return ("영업비용", "expense_sga_other", "판관 기타비용", f"{code_text} {name_text}") + if code_text.startswith("6"): + return ("영업비용", "expense_cost_other", "원가 기타비용", f"{code_text} {name_text}") + return ("", "", "", "") + + if category_text == "수입/매출액" or code_text.startswith("4"): + if "설계" in text_blob: + return ("수익", "revenue_design", "설계용역수입", f"{code_text} {name_text}") + if "감리" in text_blob: + return ("수익", "revenue_supervision", "감리용역수입", f"{code_text} {name_text}") + if "안전" in text_blob: + return ("수익", "revenue_safety", "안전점검수입", f"{code_text} {name_text}") + if "임대" in text_blob or "관리비" in text_blob: + return ("수익", "revenue_rent", "임대·관리수입", f"{code_text} {name_text}") + if "주차" in text_blob: + return ("수익", "revenue_parking", "주차수입", f"{code_text} {name_text}") + if "연구" in text_blob: + return ("수익", "revenue_research", "연구용역수입", f"{code_text} {name_text}") + return ("수익", "revenue_other", "기타수입", f"{code_text} {name_text}") + + if category_text == "원가" or code_text.startswith("5"): + if code_text in FINANCIAL_GAP_ERP_COST_LABOR_CODES: + return ("영업비용", "expense_cost_labor", "원가성 인건비", f"{code_text} {name_text}") + if code_text in FINANCIAL_GAP_ERP_COST_BENEFIT_CODES: + return ("영업비용", "expense_cost_benefit", "원가성 복리후생비", f"{code_text} {name_text}") + if any(token in text_blob for token in payroll_labor_tokens): + return ("영업비용", "expense_cost_labor", "원가성 인건비", f"{code_text} {name_text}") + if "복리후생" in text_blob: + return ("영업비용", "expense_cost_benefit", "원가성 복리후생비", f"{code_text} {name_text}") + if any(token in text_blob for token in ("기술협력", "외주")): + return ("영업비용", "expense_cost_outsourcing", "외주비", f"{code_text} {name_text}") + if any(token in text_blob for token in ("지급수수료", "보증수수료")): + return ("영업비용", "expense_cost_fee", "원가 지급수수료·보증수수료", f"{code_text} {name_text}") + if any(token in text_blob for token in ("감리현장운영", "합사경비", "부서비")): + return ("영업비용", "expense_cost_field_ops", "현장운영·행사비", f"{code_text} {name_text}") + if "여비교통" in text_blob: + return ("영업비용", "expense_cost_travel", "원가 여비교통비", f"{code_text} {name_text}") + if "지급임차료" in text_blob: + return ("영업비용", "expense_cost_rent", "원가 지급임차료", f"{code_text} {name_text}") + if any(token in text_blob for token in ("도서인쇄", "사무용품", "소모품")): + return ("영업비용", "expense_cost_supplies_print", "원가 도서·사무·소모품", f"{code_text} {name_text}") + if "연구" in text_blob: + return ("영업비용", "expense_cost_rnd", "원가 연구개발비", f"{code_text} {name_text}") + return ("영업비용", "expense_cost_admin", "원가 기타 운영비", f"{code_text} {name_text}") + + if category_text == "판관비" or code_text.startswith("6"): + if code_text in FINANCIAL_GAP_ERP_SGA_LABOR_CODES: + return ("영업비용", "expense_sga_labor", "판관 인건비", f"{code_text} {name_text}") + if code_text in FINANCIAL_GAP_ERP_RND_DISPLAY_CODES: + return ("영업비용", "expense_rnd_display", "연구개발비 별도 표시", f"{code_text} {name_text}") + if any(token in text_blob for token in payroll_labor_tokens): + return ("영업비용", "expense_sga_labor", "판관 인건비", f"{code_text} {name_text}") + if "복리후생" in text_blob: + return ("영업비용", "expense_sga_benefit", "판관 복리후생비", f"{code_text} {name_text}") + if "경상시험연구" in text_blob or "연구" in text_blob: + return ("영업비용", "expense_sga_rnd", "판관 연구개발비", f"{code_text} {name_text}") + if any(token in text_blob for token in ("지급수수료", "건물관리")): + return ("영업비용", "expense_sga_fee", "판관 지급수수료·건물관리비", f"{code_text} {name_text}") + return ("영업비용", "expense_sga_other", "판관 기타비용", f"{code_text} {name_text}") + + return ("", "", "", "") + + +def _financial_gap_item_interpretation( + item_key: str, + wehago_amount: Any, + erp_amount: Any, + adjustment_amount: Any, +) -> str: + gap = _financial_gap_signed_gap(wehago_amount, erp_amount) + residual = gap - normalize_amount(adjustment_amount) + if item_key == "expense_cogs_total": + return "WEHAGO의 452는 손익계산서 매출원가 총액입니다. 6xx 원가 상세와 합산하지 말고 ERP 원가 합계와 방향을 확인합니다." + if item_key.startswith("revenue_"): + if abs(normalize_amount(adjustment_amount)) >= abs(gap) * 0.6: + return "진행률·결산 조정으로 상당 부분 설명되나, 남은 차이는 매출 계정 매핑 또는 ERP 원매출 누락 범위를 확인해야 합니다." + return "조정 전표만으로 설명되지 않는 매출 차이입니다. 같은 용역 성격의 ERP 매출 계정과 WEHAGO 수익계정 매핑을 우선 확인합니다." + if item_key.startswith("expense_cost_"): + return "WEHAGO 원가 상세와 ERP 원가 계정의 성격별 차이입니다. 452 총액과 중복 합산하지 않고 비용 성격·부서·프로젝트 귀속 차이를 봅니다." + if item_key.startswith("expense_sga_"): + return "판관비 성격 비용의 차이입니다. ERP 판관비가 WEHAGO에서 원가성 6xx로 이동했는지, 또는 반대로 남았는지 확인합니다." + if item_key == "expense_rnd_display": + return "연구개발비를 별도 표시한 항목입니다. 원가/판관 재배분 없이 WEHAGO와 ERP의 표시 계정만 나누어 확인합니다." + if abs(residual) > 0: + return "동일 성격 항목으로 맞춘 뒤에도 잔차가 남아 원장 행 단위 확인이 필요합니다." + return "동일 성격 항목 기준으로 큰 잔차는 제한적입니다." + + +def _financial_gap_raw_account_note(item_key: str, default_note: str, erp_amount: Any = 0) -> str: + if item_key == "expense_rnd_display": + amount = normalize_amount(erp_amount) + return f"연구개발비 별도 표시 항목입니다. 연구개발비 인건비성 ERP 금액: {amount:,.0f}원" + if item_key == "expense_cost_benefit": + return "건강보험료는 인건비성 비용으로 유지하고, 그 외 원가 복리후생비만 별도 표시합니다." + if item_key == "expense_sga_benefit": + return "판관 복리후생비를 인건비와 분리해 별도 표시합니다." + return default_note + + +def _financial_gap_classification_review_rows() -> list[dict[str, Any]]: + checks = [ + { + "key": "baron_support", + "label": "바론 경영/기술지원 수수료", + "erp_where": """ + accounting_category = '판관비' + AND ( + partner_name LIKE '%바론%' + OR memo1 LIKE '%경영,기술지원%' + OR memo1 LIKE '%인건비 정산%' + OR memo1 LIKE '%정산분(인건비)%' + ) + """, + "wehago_where": """ + ( + vendor_name LIKE '%바론%' + OR description LIKE '%경영,기술지원%' + OR description LIKE '%인건비 정산%' + OR description LIKE '%정산분(인건비)%' + ) + """, + "finding": "ERP 판관비 지급수수료로 잡힌 금액이 WEHAGO에서는 주로 602 외주비, 즉 원가성 비용으로 보입니다.", + }, + { + "key": "social_insurance", + "label": "4대보험 회사부담금", + "erp_where": """ + accounting_category = '판관비' + AND ( + account_name LIKE '%건강보험%' + OR account_name LIKE '%고용보험%' + OR account_name LIKE '%산재보험%' + OR memo1 LIKE '%건강보험%' + OR memo1 LIKE '%고용보험%' + OR memo1 LIKE '%산재보험%' + ) + """, + "wehago_where": """ + ( + description LIKE '%건강보험%' + OR description LIKE '%고용보험%' + OR description LIKE '%산재보험%' + ) + """, + "finding": "ERP 판관비의 4대보험 회사부담금 일부가 WEHAGO에서는 611 복리후생비 또는 617 세금과공과금 등 원가성 계정으로 보입니다.", + }, + { + "key": "public_property_rent", + "label": "공유재산사용료/임차료", + "erp_where": """ + accounting_category = '판관비' + AND ( + memo1 LIKE '%공유재산%' + OR memo1 LIKE '%시특별%' + OR partner_name LIKE '%서울특별시%' + ) + """, + "wehago_where": """ + ( + description LIKE '%공유재산%' + OR description LIKE '%시특별%' + OR vendor_name LIKE '%서울특별시%' + ) + """, + "finding": "ERP에서는 판관비 월세로 보이나, WEHAGO에서는 619 지급임차료 원가성 계정으로 보이는 금액이 있습니다.", + }, + { + "key": "building_management", + "label": "건물관리용역비", + "erp_where": """ + accounting_category = '판관비' + AND ( + memo1 LIKE '%건물관리%' + OR partner_name LIKE '%두레티엠에스%' + OR partner_name LIKE '%만나%' + ) + """, + "wehago_where": """ + ( + description LIKE '%건물관리%' + OR vendor_name LIKE '%두레티엠에스%' + OR vendor_name LIKE '%만나%' + ) + """, + "finding": "건물관리용역비는 ERP에서는 지급수수료성 판관비, WEHAGO에서는 주로 837 건물관리비 판관비로 보여 구분은 유사하나 계정명이 다릅니다.", + }, + { + "key": "rnd_salary", + "label": "연구원 급여/경상연구개발비", + "erp_where": """ + accounting_category = '판관비' + AND ( + account_name LIKE '%경상시험연구%' + OR memo1 LIKE '%연구원%' + ) + """, + "wehago_where": """ + ( + account_code IN ('823','650') + OR description LIKE '%연구원%' + OR description LIKE '%스마트과제%' + OR description LIKE '%XR과제%' + OR description LIKE '%자율주행%' + ) + """, + "finding": "연구개발성 비용은 ERP와 WEHAGO 모두 별도 연구개발성 항목으로 보이나, 650 원가성 연구개발비와 823 판관 연구개발비 포함 범위를 구분해야 합니다.", + }, + ] + result: list[dict[str, Any]] = [] + with engine.begin() as conn: + for year in (2022, 2023, 2024, 2025): + for check in checks: + erp_row = conn.execute( + text( + f""" + SELECT SUM(amount) AS amount, + COUNT(*) AS row_count, + GROUP_CONCAT(DISTINCT account_name) AS account_names + FROM transactions + WHERE year = :year + AND ({check['erp_where']}) + """ + ), + {"year": year}, + ).mappings().first() + wehago_row = conn.execute( + text( + f""" + SELECT + SUM(CASE + WHEN account_code LIKE '6%' THEN ABS(COALESCE(debit, 0)) + WHEN account_code LIKE '8%' THEN ABS(COALESCE(debit, 0)) + ELSE ABS(COALESCE(debit, 0)) + ABS(COALESCE(credit, 0)) + END) AS amount, + SUM(CASE WHEN account_code LIKE '6%' THEN ABS(COALESCE(debit, 0)) ELSE 0 END) AS cost_amount, + SUM(CASE WHEN account_code LIKE '8%' THEN ABS(COALESCE(debit, 0)) ELSE 0 END) AS sga_amount, + COUNT(*) AS row_count, + GROUP_CONCAT(DISTINCT account_code || ' ' || account_name) AS account_names + FROM wehago_ledger_rows + WHERE fiscal_year = :year + AND ({check['wehago_where']}) + AND (account_code LIKE '6%' OR account_code LIKE '8%') + """ + ), + {"year": year}, + ).mappings().first() + result.append( + { + "year": year, + "key": check["key"], + "label": check["label"], + "finding": check["finding"], + "erp_sga_amount": normalize_amount((erp_row or {}).get("amount")), + "erp_row_count": int(normalize_amount((erp_row or {}).get("row_count"))), + "erp_account_names": normalize_text((erp_row or {}).get("account_names")), + "wehago_amount": normalize_amount((wehago_row or {}).get("amount")), + "wehago_cost_amount": normalize_amount((wehago_row or {}).get("cost_amount")), + "wehago_sga_amount": normalize_amount((wehago_row or {}).get("sga_amount")), + "wehago_row_count": int(normalize_amount((wehago_row or {}).get("row_count"))), + "wehago_account_names": normalize_text((wehago_row or {}).get("account_names")), + } + ) + return result + + +def _financial_gap_get_hanmac_hours_summary(year: int) -> dict[str, Any]: + year_start = date(year, 1, 1).isoformat() + year_end = date(year, 12, 31).isoformat() + with engine.begin() as conn: + metric = conn.execute( + text( + """ + SELECT cache_key, row_count, summary_json, updated_at + FROM hanmac_aggregate_query_metrics + WHERE view_mode = 'member' + AND payload_signature LIKE :compatible_signature_pattern + AND COALESCE(start_date, '') <= :year_start + AND COALESCE(end_date, '') >= :year_end + ORDER BY + CASE WHEN payload_signature LIKE :current_signature_prefix THEN 0 ELSE 1 END, + CASE WHEN start_date = :year_start AND end_date = :year_end THEN 0 ELSE 1 END, + updated_at DESC + LIMIT 1 + """ + ), + { + "year_start": year_start, + "year_end": year_end, + **_cost_analysis_hanmac_signature_params(), + }, + ).mappings().first() + if not metric: + return { + "total_hours": 0.0, + "regular_hours": 0.0, + "overtime_hours": 0.0, + "holiday_hours": 0.0, + "member_count": 0, + "project_count": 0, + "cache_updated_at": "", + } + try: + summary = json.loads(metric.get("summary_json") or "{}") + except Exception: + summary = {} + return { + "total_hours": normalize_amount(summary.get("total_hours")), + "regular_hours": normalize_amount(summary.get("regular_hours")), + "overtime_hours": normalize_amount(summary.get("overtime_hours")), + "holiday_hours": normalize_amount(summary.get("holiday_hours")), + "member_count": int(normalize_amount(summary.get("member_count")) or normalize_amount(metric.get("row_count"))), + "project_count": int(normalize_amount(summary.get("project_count"))), + "cache_updated_at": normalize_text(metric.get("updated_at")), + } + + +def get_financial_gap_analysis_payload() -> dict[str, Any]: + # WEHAGO 원장과 프로젝트 손익분석1을 실제로 대사할 수 있는 공통 연도. + # 2026년 WEHAGO 손익 원장이 아직 없어 0원과 비교하는 오해를 막기 위해 제외한다. + target_years = [2023, 2024, 2025] + project_payloads_by_year: dict[int, dict[str, Any]] = {} + for year in target_years: + year_end = date(2026, 3, 31) if year == 2026 else date(year, 12, 31) + project_payloads_by_year[year] = _cost_analysis1_build_payload( + date(year, 1, 1).isoformat(), + year_end.isoformat(), + "individual", + ) + account_rows_by_year: dict[int, list[dict[str, Any]]] = {year: [] for year in target_years} + ledger_detail_rows_by_year: dict[int, list[dict[str, Any]]] = {year: [] for year in target_years} + erp_by_year: dict[int, dict[str, Any]] = {year: {} for year in target_years} + erp_account_rows_by_year: dict[int, list[dict[str, Any]]] = {year: [] for year in target_years} + classification_review_rows = _financial_gap_classification_review_rows() + + with engine.begin() as conn: + wehago_rows = conn.execute( + text( + """ + SELECT fiscal_year, + COALESCE(account_code, '') AS account_code, + COALESCE(account_name, '') AS account_name, + SUM(COALESCE(debit, 0)) AS debit, + SUM(COALESCE(credit, 0)) AS credit + FROM wehago_ledger_rows + WHERE fiscal_year BETWEEN 2023 AND 2026 + AND (fiscal_year < 2026 OR COALESCE(ledger_date, '') <= '2026-03-31') + GROUP BY fiscal_year, account_code, account_name + ORDER BY fiscal_year, account_code + """ + ) + ).mappings().all() + ledger_detail_rows = conn.execute( + text( + """ + SELECT fiscal_year, + COALESCE(ledger_date, '') AS ledger_date, + COALESCE(account_code, '') AS account_code, + COALESCE(account_name, '') AS account_name, + COALESCE(description, '') AS description, + COALESCE(vendor_name, '') AS vendor_name, + COALESCE(compare_desc, '') AS compare_desc, + COALESCE(voucher_no, '') AS voucher_no, + COALESCE(debit, 0) AS debit, + COALESCE(credit, 0) AS credit + FROM wehago_ledger_rows + WHERE fiscal_year BETWEEN 2023 AND 2026 + AND (fiscal_year < 2026 OR COALESCE(ledger_date, '') <= '2026-03-31') + AND ( + account_code IN ('411','412','413','414','415','416','417','452','908','901','902','903','904','905','906','907','914','930','931','932','933','934','935','936','937','960','998') + OR account_code LIKE '6%' + OR account_code LIKE '8%' + ) + ORDER BY fiscal_year, ledger_date, voucher_no, account_code + """ + ) + ).mappings().all() + erp_rows = conn.execute( + text( + f""" + SELECT year, + SUM(CASE WHEN accounting_category = '수입/매출액' OR account_code LIKE '4%' THEN amount ELSE 0 END) AS revenue, + SUM(CASE WHEN accounting_category = '원가' THEN amount ELSE 0 END) AS cost, + SUM(CASE WHEN accounting_category = '판관비' THEN amount ELSE 0 END) AS sga, + SUM(CASE WHEN account_code LIKE '5012%' THEN amount ELSE 0 END) AS labor_direct, + SUM(CASE WHEN account_code LIKE '5017%' THEN amount ELSE 0 END) AS outsourcing, + SUM(CASE WHEN account_code LIKE '7%' THEN amount ELSE 0 END) AS nonop_income, + SUM(CASE WHEN account_code LIKE '9%' THEN amount ELSE 0 END) AS nonop_expense, + SUM(CASE WHEN accounting_category = '기타' THEN amount ELSE 0 END) AS other_amount + FROM transactions + WHERE {COST_ANALYSIS_TX_DATE_SQL} >= '2023-01-01' + AND {COST_ANALYSIS_TX_DATE_SQL} <= '2026-03-31' + GROUP BY year + ORDER BY year + """ + ) + ).mappings().all() + erp_account_rows = conn.execute( + text( + f""" + SELECT year, + COALESCE(account_code, '') AS account_code, + COALESCE(account_name, '') AS account_name, + COALESCE(accounting_category, '') AS accounting_category, + SUM(COALESCE(amount, 0)) AS amount, + COUNT(*) AS row_count + FROM transactions + WHERE {COST_ANALYSIS_TX_DATE_SQL} >= '2023-01-01' + AND {COST_ANALYSIS_TX_DATE_SQL} <= '2026-03-31' + AND ( + accounting_category IN ('수입/매출액','원가','판관비') + OR account_code LIKE '4%' + OR account_code LIKE '5%' + OR account_code LIKE '6%' + ) + GROUP BY year, account_code, account_name, accounting_category + ORDER BY year, account_code + """ + ) + ).mappings().all() + + for row in wehago_rows: + year = int(row.get("fiscal_year") or 0) + if year in account_rows_by_year: + account_rows_by_year[year].append(dict(row)) + for row in ledger_detail_rows: + year = int(row.get("fiscal_year") or 0) + if year in ledger_detail_rows_by_year: + ledger_detail_rows_by_year[year].append(dict(row)) + for row in erp_rows: + year = int(row.get("year") or 0) + if year in erp_by_year: + erp_by_year[year] = dict(row) + for row in erp_account_rows: + year = int(row.get("year") or 0) + if year in erp_account_rows_by_year: + erp_account_rows_by_year[year].append(dict(row)) + + yearly_rows: list[dict[str, Any]] = [] + account_detail_rows: list[dict[str, Any]] = [] + adjustment_rows: list[dict[str, Any]] = [] + labor_rows: list[dict[str, Any]] = [] + review_items: list[dict[str, Any]] = [] + anomaly_cards: list[dict[str, Any]] = [] + reason_items: list[dict[str, Any]] = [] + substantive_gap_rows: list[dict[str, Any]] = [] + raw_account_comparison_rows: list[dict[str, Any]] = [] + + for year in target_years: + buckets = { + "revenue": 0.0, + "cogs": 0.0, + "sga": 0.0, + "nonop_income": 0.0, + "nonop_expense": 0.0, + "tax": 0.0, + "cost_detail_6xx": 0.0, + "direct_labor": 0.0, + "sga_labor": 0.0, + "rnd_like": 0.0, + "outsourcing_6xx": 0.0, + } + adjustment_buckets = { + "revenue": 0.0, + "cogs": 0.0, + "sga": 0.0, + "cost_detail": 0.0, + "nonop_income": 0.0, + "nonop_expense": 0.0, + "tax": 0.0, + } + substantive_wehago: dict[str, dict[str, Any]] = {} + substantive_erp: dict[str, dict[str, Any]] = {} + substantive_adjustments: dict[str, float] = {} + closing_transfer_count = 0 + audit_adjustment_count = 0 + for ledger_row in ledger_detail_rows_by_year.get(year, []): + code = normalize_text(ledger_row.get("account_code")) + bucket_key = _financial_gap_bucket_for_account(code) + if not bucket_key: + continue + if _financial_gap_is_closing_transfer(ledger_row): + closing_transfer_count += 1 + continue + if not _financial_gap_is_audit_adjustment(ledger_row): + continue + delta = _financial_gap_signed_statement_delta( + code, + ledger_row.get("debit"), + ledger_row.get("credit"), + ) + if abs(delta) <= 0: + continue + audit_adjustment_count += 1 + target_bucket_key = "cogs" if bucket_key == "cost_detail" else bucket_key + adjustment_buckets[target_bucket_key] += delta + section, item_key, item_label, _ = _financial_gap_substantive_group( + "wehago", + code, + ledger_row.get("account_name"), + ) + if item_key and item_key != "expense_cogs_total": + substantive_adjustments[item_key] = substantive_adjustments.get(item_key, 0.0) + delta + adjustment_rows.append( + { + "year": year, + "ledger_date": normalize_text(ledger_row.get("ledger_date")), + "voucher_no": normalize_text(ledger_row.get("voucher_no")), + "bucket": target_bucket_key, + "account_code": code, + "account_name": normalize_text(ledger_row.get("account_name")), + "description": normalize_text(ledger_row.get("description")), + "vendor_name": normalize_text(ledger_row.get("vendor_name")), + "debit": normalize_amount(ledger_row.get("debit")), + "credit": normalize_amount(ledger_row.get("credit")), + "delta": delta, + } + ) + for account in account_rows_by_year.get(year, []): + code = normalize_text(account.get("account_code")) + name = normalize_text(account.get("account_name")) + amount = _financial_gap_statement_amount(account) + if code in {"411", "412", "413", "414", "415", "416", "417"}: + amount = abs(normalize_amount(account.get("credit"))) + if not amount: + continue + if code in {"411", "412", "413", "414", "415", "416", "417"}: + buckets["revenue"] += amount + elif code == "452": + buckets["cogs"] += amount + elif code.startswith("8"): + buckets["sga"] += amount + elif code == "908": + buckets["sga"] -= amount + elif code == "998": + buckets["tax"] += amount + elif code.startswith("9"): + if code in {"901", "902", "903", "904", "905", "906", "907", "914", "930"}: + buckets["nonop_income"] += amount + elif code in {"931", "932", "933", "934", "935", "936", "937", "960"}: + buckets["nonop_expense"] += amount + if code.startswith("6"): + buckets["cost_detail_6xx"] += amount + if code in {"604", "606", "609"}: + buckets["direct_labor"] += amount + if code in {"802", "808"}: + buckets["sga_labor"] += amount + if code in {"650", "823"}: + buckets["rnd_like"] += amount + if code == "602": + buckets["outsourcing_6xx"] += amount + if code in {"411", "412", "413", "414", "415", "416", "417", "452"} or code.startswith(("6", "8")) or code in {"901", "902", "903", "904", "905", "906", "907", "908", "914", "930", "931", "932", "933", "934", "935", "936", "937", "960", "998"}: + account_detail_rows.append( + { + "year": year, + "account_code": code, + "account_name": name, + "wehago_amount": -amount if code == "908" else amount, + "bucket": ( + "매출" if code in {"411", "412", "413", "414", "415", "416", "417"} + else "매출원가 총액" if code == "452" + else "원가 상세" if code.startswith("6") + else "판관비" if code.startswith("8") + else "판관비 조정" if code == "908" + else "법인세" if code == "998" + else "영업외" + ), + } + ) + section, item_key, item_label, source_label = _financial_gap_substantive_group( + "wehago", + code, + name, + ) + if item_key: + group = substantive_wehago.setdefault( + item_key, + { + "section": section, + "item_label": item_label, + "amount": 0.0, + "accounts": [], + "row_count": 0, + }, + ) + group["amount"] += -amount if code == "908" else amount + group["row_count"] += 1 + if source_label and source_label not in group["accounts"]: + group["accounts"].append(source_label) + + for erp_account in erp_account_rows_by_year.get(year, []): + section, item_key, item_label, source_label = _financial_gap_substantive_group( + "erp", + erp_account.get("account_code"), + erp_account.get("account_name"), + erp_account.get("accounting_category"), + ) + if not item_key: + continue + group = substantive_erp.setdefault( + item_key, + { + "section": section, + "item_label": item_label, + "amount": 0.0, + "accounts": [], + "row_count": 0, + }, + ) + group["amount"] += normalize_amount(erp_account.get("amount")) + group["row_count"] += int(normalize_amount(erp_account.get("row_count"))) + if source_label and source_label not in group["accounts"]: + group["accounts"].append(source_label) + + erp = erp_by_year.get(year, {}) + project_payload = project_payloads_by_year.get(year) or {} + project_summary = project_payload.get("summary") or {} + project_diagnostics = project_payload.get("allocation_diagnostics") or {} + # Hanmac ERP 비교값은 프로젝트 손익분석 페이지1과 동일한 최종 + # 분류·배부 결과를 사용한다. 원천 전표 직접 합계는 상세 추적용으로만 남긴다. + erp_revenue = normalize_amount(project_summary.get("period_revenue_amount")) + erp_cost = normalize_amount(project_summary.get("period_cost_total")) + erp_sga = ( + normalize_amount(project_summary.get("period_sga_total")) + + normalize_amount(project_summary.get("period_sales_total")) + ) + adjusted_erp_revenue = erp_revenue + adjustment_buckets["revenue"] + adjusted_erp_cost = erp_cost + adjustment_buckets["cogs"] + adjusted_erp_sga = erp_sga + adjustment_buckets["sga"] + erp_operating_expense = erp_cost + erp_sga + adjusted_erp_operating_expense = adjusted_erp_cost + adjusted_erp_sga + wehago_operating_expense = buckets["cogs"] + buckets["sga"] + cost_gap = _financial_gap_signed_gap(buckets["cogs"], erp_cost) + sga_gap = _financial_gap_signed_gap(buckets["sga"], erp_sga) + total_expense_gap = _financial_gap_signed_gap(wehago_operating_expense, erp_operating_expense) + revenue_gap = _financial_gap_signed_gap(buckets["revenue"], erp_revenue) + adjusted_revenue_gap = _financial_gap_signed_gap(buckets["revenue"], adjusted_erp_revenue) + adjusted_cost_gap = _financial_gap_signed_gap(buckets["cogs"], adjusted_erp_cost) + adjusted_sga_gap = _financial_gap_signed_gap(buckets["sga"], adjusted_erp_sga) + adjusted_total_expense_gap = _financial_gap_signed_gap(wehago_operating_expense, adjusted_erp_operating_expense) + expense_summary_comparisons = ( + ( + "expense_total", + "영업비용 총액", + wehago_operating_expense, + erp_operating_expense, + adjustment_buckets["cogs"] + adjustment_buckets["sga"], + "WEHAGO 매출원가 총액+판관비와 Hanmac ERP 원가+판관비의 회사 전체 비교", + ), + ( + "expense_cogs_total", + "매출원가 총액", + buckets["cogs"], + erp_cost, + adjustment_buckets["cogs"], + "WEHAGO 452 총액과 Hanmac ERP 5xx 원가 합계 비교", + ), + ( + "expense_sga_total", + "판매비와관리비 총액", + buckets["sga"], + erp_sga, + adjustment_buckets["sga"], + "WEHAGO 8xx(908 환입 차감)와 Hanmac ERP 6xx 판관비 합계 비교", + ), + ) + for item_key, item_label, wehago_amount, erp_amount, adjustment_amount, note in expense_summary_comparisons: + pre_adjustment_wehago_amount = wehago_amount - adjustment_amount + raw_account_comparison_rows.append( + { + "year": year, + "section": "영업비용", + "comparison_level": "총액", + "item_key": item_key, + "item_label": item_label, + "wehago_current_amount": wehago_amount, + "audit_adjustment_amount": adjustment_amount, + "wehago_pre_adjustment_amount": pre_adjustment_wehago_amount, + "erp_amount": erp_amount, + "pre_adjustment_gap": pre_adjustment_wehago_amount - erp_amount, + "wehago_accounts": ( + "452 + 8xx - 908" + if item_key == "expense_total" + else "452 도급공사매출원가" + if item_key == "expense_cogs_total" + else "8xx 판관비 - 908 대손충당금환입" + ), + "erp_accounts": ( + "5xx 원가 + 6xx 판관비" + if item_key == "expense_total" + else "5xx 원가" + if item_key == "expense_cogs_total" + else "6xx 판관비" + ), + "note": note, + } + ) + substantive_erp.setdefault( + "expense_cogs_total", + { + "section": "영업비용", + "item_label": "매출원가 총액", + "amount": 0.0, + "accounts": [], + "row_count": 0, + }, + ) + substantive_erp["expense_cogs_total"]["amount"] = erp_cost + substantive_erp["expense_cogs_total"]["accounts"] = ["ERP 원가 계정 합계"] + substantive_erp["expense_cogs_total"]["row_count"] = sum( + int(normalize_amount(row.get("row_count"))) + for row in erp_account_rows_by_year.get(year, []) + if normalize_text(row.get("accounting_category")) == "원가" + ) + substantive_adjustments["expense_cogs_total"] = adjustment_buckets["cogs"] + for item_key in sorted(set(substantive_wehago) | set(substantive_erp)): + wehago_item = substantive_wehago.get(item_key, {}) + erp_item = substantive_erp.get(item_key, {}) + wehago_amount = normalize_amount(wehago_item.get("amount")) + erp_amount = normalize_amount(erp_item.get("amount")) + adjustment_amount = normalize_amount(substantive_adjustments.get(item_key)) + raw_gap = _financial_gap_signed_gap(wehago_amount, erp_amount) + residual_gap = raw_gap - adjustment_amount + if item_key != "expense_cogs_total": + pre_adjustment_wehago_amount = wehago_amount - adjustment_amount + raw_account_comparison_rows.append( + { + "year": year, + "section": normalize_text(wehago_item.get("section") or erp_item.get("section")), + "comparison_level": "유사 계정군", + "item_key": item_key, + "item_label": normalize_text(wehago_item.get("item_label") or erp_item.get("item_label")), + "wehago_current_amount": wehago_amount, + "audit_adjustment_amount": adjustment_amount, + "wehago_pre_adjustment_amount": pre_adjustment_wehago_amount, + "erp_amount": erp_amount, + "pre_adjustment_gap": pre_adjustment_wehago_amount - erp_amount, + "wehago_accounts": ", ".join((wehago_item.get("accounts") or [])[:12]), + "erp_accounts": ", ".join((erp_item.get("accounts") or [])[:12]), + "note": _financial_gap_raw_account_note( + item_key, + "감사·결산·대체 문구로 식별한 조정효과를 WEHAGO 현재액에서 제거한 뒤 ERP와 비교", + erp_amount, + ), + } + ) + if ( + max(abs(wehago_amount), abs(erp_amount), abs(raw_gap), abs(residual_gap)) < 50_000_000 + and item_key != "expense_cogs_total" + ): + continue + section = normalize_text(wehago_item.get("section") or erp_item.get("section")) + item_label = normalize_text(wehago_item.get("item_label") or erp_item.get("item_label")) + substantive_gap_rows.append( + { + "year": year, + "section": section, + "item_key": item_key, + "item_label": item_label, + "wehago_amount": wehago_amount, + "erp_amount": erp_amount, + "gap_amount": raw_gap, + "adjustment_amount": adjustment_amount, + "residual_gap": residual_gap, + "gap_rate": _financial_gap_ratio(raw_gap, max(abs(wehago_amount), abs(erp_amount), 1.0)), + "wehago_accounts": ", ".join((wehago_item.get("accounts") or [])[:8]), + "erp_accounts": ", ".join((erp_item.get("accounts") or [])[:8]), + "wehago_row_count": int(normalize_amount(wehago_item.get("row_count"))), + "erp_row_count": int(normalize_amount(erp_item.get("row_count"))), + "interpretation": _financial_gap_item_interpretation( + item_key, + wehago_amount, + erp_amount, + adjustment_amount, + ), + } + ) + gross_profit = buckets["revenue"] - buckets["cogs"] + operating_profit = gross_profit - buckets["sga"] + erp_operating_profit = erp_revenue - erp_operating_expense + adjusted_erp_operating_profit = adjusted_erp_revenue - adjusted_erp_operating_expense + adjusted_operating_profit_gap = _financial_gap_signed_gap( + operating_profit, + adjusted_erp_operating_profit, + ) + net_profit_proxy = ( + operating_profit + + buckets["nonop_income"] + - buckets["nonop_expense"] + - buckets["tax"] + ) + review_tags: list[str] = [] + if abs(cost_gap) > 0 and abs(sga_gap) > 0 and cost_gap * sga_gap < 0: + paired = min(abs(cost_gap), abs(sga_gap)) / max(abs(cost_gap), abs(sga_gap)) + if paired >= 0.75: + review_tags.append("원가/판관비 재분류") + if buckets["cogs"] > 0 and buckets["cost_detail_6xx"] > 0: + review_tags.append("452 총액/6xx 상세 구분") + if abs(revenue_gap) / max(abs(buckets["revenue"]), 1.0) >= 0.03: + review_tags.append("매출 인식/계정 매핑") + if abs(adjustment_buckets["cogs"]) > 0 and abs(adjustment_buckets["sga"]) > 0: + review_tags.append("6xx/8xx 조정흐름") + if abs(total_expense_gap) / max(abs(wehago_operating_expense), 1.0) < 0.02 and ("원가/판관비 재분류" not in review_tags): + review_tags.append("총비용 유사") + if abs(adjusted_total_expense_gap) < abs(total_expense_gap): + review_tags.append("감사조정 반영시 개선") + elif abs(sum(adjustment_buckets.values())) > 0: + review_tags.append("조정후 잔차 검토") + + def add_reason( + tag: str, + summary: str, + basis_rows: list[dict[str, Any]], + next_checks: list[str], + outline_rows: list[dict[str, str]] | None = None, + ) -> None: + reason_items.append( + { + "year": year, + "tag": tag, + "summary": summary, + "basis_rows": basis_rows, + "next_checks": next_checks, + "outline_rows": outline_rows or [], + } + ) + + if "원가/판관비 재분류" in review_tags: + add_reason( + "원가/판관비 재분류", + "원가 차이와 판관비 차이가 서로 반대 방향으로 발생하고, 두 금액의 크기가 비슷해 총비용 자체가 틀렸다기보다 비용이 원가와 판관비 사이에서 다르게 분류되었을 가능성을 먼저 의심했습니다.", + [ + {"label": "조정 전 원가 차이", "value": cost_gap}, + {"label": "조정 전 판관비 차이", "value": sga_gap}, + {"label": "조정 후 원가 차이", "value": adjusted_cost_gap}, + {"label": "조정 후 판관비 차이", "value": adjusted_sga_gap}, + {"label": "조정 후 원가+판관비 차이", "value": adjusted_total_expense_gap}, + ], + [ + "ERP transactions의 원가/판관비 분류 규칙이 WEHAGO 손익계산서 분류와 같은지 확인", + "지원부서/현업부서 판관비가 원가 또는 판관비 중 어디로 들어가는지 확인", + "원가성 인건비, 외주비, 관리현장운영비가 ERP와 WEHAGO에서 같은 항목으로 분류되는지 확인", + ], + [ + {"title": "무엇을 뜻하나", "body": "WEHAGO에서는 매출원가로 본 비용을 ERP에서는 판관비로 보거나, 반대로 ERP에서는 원가로 본 비용을 WEHAGO에서는 판관비로 본 경우를 말합니다."}, + {"title": "왜 의심하나", "body": "원가 차이는 플러스인데 판관비 차이는 마이너스처럼 서로 반대 방향이고, 원가+판관비 합계 차이는 상대적으로 작으면 총비용 누락보다 분류 위치 차이가 더 그럴듯합니다."}, + {"title": "무엇이 아닌가", "body": "이 판단은 비용을 억지로 맞추자는 뜻도 아니고, 452와 6xx가 하나의 조정 전표라는 뜻도 아닙니다. 비용이 어느 칸에 들어갔는지를 확인하자는 신호입니다."}, + {"title": "예시", "body": "예를 들어 ERP에서 본사/지원부서 비용을 판관비로 집계했는데 감사 후 WEHAGO에서는 프로젝트 수행과 관련된 원가성 비용으로 재분류했다면 원가는 늘고 판관비는 줄어듭니다."}, + {"title": "볼 지점", "body": "6xx 원가 계정, 8xx 판관비 계정, 부서 기준, 프로젝트 코드 유무, 인건비/외주비/관리현장운영비의 분류 기준을 함께 확인해야 합니다."}, + ], + ) + if "452 총액/6xx 상세 구분" in review_tags: + add_reason( + "452 총액/6xx 상세 구분", + "452는 재무제표에 표시되는 매출원가 총액이고, 6xx는 그 안을 구성하는 상세 원가 성격으로 봅니다. 이 둘은 하나의 조정 사건이라기보다 서로 다른 층위의 정보입니다.", + [ + {"label": "452 도급공사매출원가", "value": buckets["cogs"]}, + {"label": "6xx 원가상세 합계", "value": buckets["cost_detail_6xx"]}, + {"label": "452와 6xx의 차이", "value": buckets["cogs"] - buckets["cost_detail_6xx"]}, + {"label": "식별된 마감/대체 전표 수", "value": closing_transfer_count}, + ], + [ + "손익계산서 총액을 볼 때는 452를 사용하고, 원가 구성 내역을 볼 때는 6xx를 사용합니다.", + "452와 6xx를 동시에 더해 원가 총액을 만들고 있지 않은지 확인합니다.", + "6xx와 8xx 사이의 조정 여부는 이 태그가 아니라 '6xx/8xx 조정흐름'에서 확인합니다.", + ], + [ + {"title": "무엇을 뜻하나", "body": "452와 6xx를 같은 표에서 볼 수 있지만, 452는 총액이고 6xx는 상세입니다."}, + {"title": "무엇이 아닌가", "body": "452와 6xx가 서로 조정되었다거나, 6xx와 8xx 조정을 이 태그 하나로 설명한다는 뜻은 아닙니다."}, + {"title": "왜 표시하나", "body": "총액 비교 화면에서 452와 6xx를 함께 더하면 원가가 과대 표시될 수 있어 집계 기준을 환기하기 위한 표시입니다."}, + ], + ) + if "매출 인식/계정 매핑" in review_tags: + add_reason( + "매출 인식/계정 매핑", + "매출 쪽은 진행률 매출액 계상, 환원분개, 수정신고분 때문에 ERP 현재 매출과 WEHAGO 재무제표 매출이 달라질 수 있습니다.", + [ + {"label": "WEHAGO 매출", "value": buckets["revenue"]}, + {"label": "ERP 매출", "value": erp_revenue}, + {"label": "매출 조정효과", "value": adjustment_buckets["revenue"]}, + {"label": "ERP+조정 매출", "value": adjusted_erp_revenue}, + {"label": "조정 후 매출 차이", "value": adjusted_revenue_gap}, + ], + [ + "진행률 매출액, 결산 환원분개, 수정신고분이 ERP 매출 집계에 반영되어 있는지 확인", + "WEHAGO 매출 계정 411~417과 ERP 매출 계정 4xx의 매핑 기준 확인", + "2022년처럼 특정 결산대체 행이 매출 계정에 들어간 경우 별도 제외/분류 기준 확인", + ], + [ + {"title": "무엇을 뜻하나", "body": "매출 차이는 주로 진행률 매출 계상/환원 및 수정분개가 ERP와 WEHAGO에 같은 방식으로 반영되지 않을 때 발생합니다."}, + {"title": "먼저 볼 자료", "body": "411~417 매출 계정, 진행율 매출액, 결산 환원분개, 수정신고분 전표를 봅니다."}, + {"title": "비용 조정과의 관계", "body": "매출 진행률 조정은 6xx/8xx 비용 조정과 별도 흐름으로 보되, 같은 결산 과정에서 함께 발생할 수는 있습니다."}, + ], + ) + if "6xx/8xx 조정흐름" in review_tags: + add_reason( + "6xx/8xx 조정흐름", + "감사/결산 조정 전표 중 원가 상세인 6xx 계정과 판관비인 8xx 계정에 모두 영향이 있어, 비용 조정 흐름을 함께 확인해야 합니다.", + [ + {"label": "6xx 원가 조정효과", "value": adjustment_buckets["cogs"]}, + {"label": "8xx 판관비 조정효과", "value": adjustment_buckets["sga"]}, + {"label": "조정 후 원가 차이", "value": adjusted_cost_gap}, + {"label": "조정 후 판관비 차이", "value": adjusted_sga_gap}, + {"label": "조정 후 영업비용 차이", "value": adjusted_total_expense_gap}, + ], + [ + "6xx 조정 전표와 8xx 조정 전표가 같은 결산 판단에서 나온 것인지 확인", + "원가성 인건비/복리후생비/퇴직급여와 판관 인건비 계정이 각각 어디로 반영됐는지 확인", + "ERP의 원가/판관비 기준이 WEHAGO 감사 후 분류 기준과 달라졌는지 확인", + ], + [ + {"title": "무엇을 뜻하나", "body": "사용자께서 보신 것처럼 6xx와 8xx에 조정이 함께 있을 때 비용 조정을 하나의 흐름으로 보는 탭입니다."}, + {"title": "452와의 차이", "body": "452는 매출원가 총액 표시 계정이고, 여기서는 6xx 원가상세와 8xx 판관비 조정 전표의 방향과 규모를 봅니다."}, + {"title": "판단 포인트", "body": "6xx 조정과 8xx 조정이 서로 상쇄되는지, 아니면 둘 다 같은 방향으로 비용을 바꾸는지 확인합니다."}, + ], + ) + if "감사조정 반영시 개선" in review_tags or "조정후 잔차 검토" in review_tags: + add_reason( + "감사조정 반영시 개선" if "감사조정 반영시 개선" in review_tags else "조정후 잔차 검토", + "감사/결산 조정 전표를 ERP 현재값에 더해 본 뒤, 차이가 줄어드는지와 남는 잔차가 어디인지 비교했습니다.", + [ + {"label": "조정 전 매출 차이", "value": revenue_gap}, + {"label": "조정 후 매출 차이", "value": adjusted_revenue_gap}, + {"label": "조정 전 영업비용 차이", "value": total_expense_gap}, + {"label": "조정 후 영업비용 차이", "value": adjusted_total_expense_gap}, + {"label": "식별된 조정효과 합계", "value": sum(adjustment_buckets.values())}, + ], + [ + "모달의 조정 전표 목록에서 결산/감사/수정 전표가 실제 감사 반영분인지 확인", + "조정 후에도 남는 항목은 ERP 원천전표 누락, 계정 매핑, 시점 차이로 분류", + "452 같은 마감 총액 전표는 중복 방지를 위해 조정효과에서 제외한 기준이 맞는지 확인", + ], + ) + + yearly_rows.append( + { + "year": year, + "wehago_revenue": buckets["revenue"], + "erp_revenue": erp_revenue, + "adjusted_erp_revenue": adjusted_erp_revenue, + "revenue_gap": revenue_gap, + "adjusted_revenue_gap": adjusted_revenue_gap, + "revenue_gap_rate": _financial_gap_ratio(revenue_gap, buckets["revenue"]), + "wehago_cogs": buckets["cogs"], + "erp_cost": erp_cost, + "adjusted_erp_cost": adjusted_erp_cost, + "cost_gap": cost_gap, + "adjusted_cost_gap": adjusted_cost_gap, + "cost_gap_rate": _financial_gap_ratio(cost_gap, buckets["cogs"]), + "wehago_sga": buckets["sga"], + "erp_sga": erp_sga, + "adjusted_erp_sga": adjusted_erp_sga, + "sga_gap": sga_gap, + "adjusted_sga_gap": adjusted_sga_gap, + "sga_gap_rate": _financial_gap_ratio(sga_gap, buckets["sga"]), + "wehago_operating_expense": wehago_operating_expense, + "erp_operating_expense": erp_operating_expense, + "adjusted_erp_operating_expense": adjusted_erp_operating_expense, + "operating_expense_gap": total_expense_gap, + "adjusted_operating_expense_gap": adjusted_total_expense_gap, + "operating_expense_gap_rate": _financial_gap_ratio(total_expense_gap, wehago_operating_expense), + "wehago_gross_profit": gross_profit, + "wehago_operating_profit": operating_profit, + "erp_operating_profit": erp_operating_profit, + "adjusted_erp_operating_profit": adjusted_erp_operating_profit, + "operating_profit_gap": _financial_gap_signed_gap(operating_profit, erp_operating_profit), + "adjusted_operating_profit_gap": adjusted_operating_profit_gap, + "wehago_nonop_income": buckets["nonop_income"], + "wehago_nonop_expense": buckets["nonop_expense"], + "wehago_tax": buckets["tax"], + "wehago_net_profit_proxy": net_profit_proxy, + "cost_detail_6xx": buckets["cost_detail_6xx"], + "adjustments": adjustment_buckets, + "audit_adjustment_count": audit_adjustment_count, + "closing_transfer_count": closing_transfer_count, + "review_tags": review_tags, + } + ) + + hours = _financial_gap_get_hanmac_hours_summary(year) + direct_labor = buckets["direct_labor"] + sga_labor = buckets["sga_labor"] + labor_pool_without_rnd = direct_labor + sga_labor + labor_pool_with_rnd = labor_pool_without_rnd + buckets["rnd_like"] + hanmac_labor_total = normalize_amount(project_diagnostics.get("displayed_labor_total")) + hanmac_wehago_labor_gap = hanmac_labor_total - labor_pool_without_rnd + total_hours = normalize_amount(hours.get("total_hours")) + hourly_without_rnd = labor_pool_without_rnd / total_hours if total_hours > 0 else 0.0 + hourly_with_rnd = labor_pool_with_rnd / total_hours if total_hours > 0 else 0.0 + labor_tags: list[str] = [] + if total_hours <= 0: + labor_tags.append("근무시간 캐시 확인") + if hourly_without_rnd >= 60000: + labor_tags.append("시간/단가 누락 가능") + if buckets["rnd_like"] / max(labor_pool_without_rnd, 1.0) >= 0.05: + labor_tags.append("연구개발 포함여부 검토") + if normalize_amount(erp.get("labor_direct")) > 0 and direct_labor > 0: + direct_gap = _financial_gap_signed_gap(direct_labor, erp.get("labor_direct")) + if abs(direct_gap) / max(direct_labor, 1.0) >= 0.1: + labor_tags.append("ERP 원가인건비 매핑차") + labor_rows.append( + { + "year": year, + "direct_labor": direct_labor, + "sga_labor": sga_labor, + "rnd_like": buckets["rnd_like"], + "labor_pool_without_rnd": labor_pool_without_rnd, + "labor_pool_with_rnd": labor_pool_with_rnd, + "wehago_labor_total": labor_pool_without_rnd, + "hanmac_labor_total": hanmac_labor_total, + "hanmac_wehago_labor_gap": hanmac_wehago_labor_gap, + "erp_labor_direct": normalize_amount(erp.get("labor_direct")), + "erp_outsourcing": normalize_amount(erp.get("outsourcing")), + "wehago_outsourcing": buckets["outsourcing_6xx"], + **hours, + "implied_hourly_without_rnd": hourly_without_rnd, + "implied_hourly_with_rnd": hourly_with_rnd, + "tags": labor_tags, + } + ) + + for tag in review_tags: + review_items.append( + { + "year": year, + "area": "손익", + "tag": tag, + "basis": f"원가차이 {cost_gap:,.0f}, 판관비차이 {sga_gap:,.0f}, 총비용차이 {total_expense_gap:,.0f}", + } + ) + for tag in labor_tags: + review_items.append( + { + "year": year, + "area": "인건비", + "tag": tag, + "basis": f"총근무 {total_hours:,.1f}h, 역산단가 {hourly_without_rnd:,.0f}원/h", + } + ) + severity = "ok" + headline = "큰 이상 없음" + primary_gap = abs(adjusted_total_expense_gap) + if abs(adjusted_revenue_gap) / max(abs(buckets["revenue"]), 1.0) >= 0.1: + severity = "danger" + headline = "매출 차이 큼" + primary_gap = abs(adjusted_revenue_gap) + elif abs(adjusted_total_expense_gap) / max(abs(wehago_operating_expense), 1.0) >= 0.05: + severity = "danger" + headline = "영업비용 차이 큼" + elif abs(adjusted_cost_gap) > 0 and abs(adjusted_sga_gap) > 0 and adjusted_cost_gap * adjusted_sga_gap < 0: + severity = "warning" + headline = "원가/판관비 재분류 의심" + primary_gap = min(abs(adjusted_cost_gap), abs(adjusted_sga_gap)) + elif any(tag in review_tags for tag in ("452 총액/6xx 상세 구분", "조정후 잔차 검토", "6xx/8xx 조정흐름")): + severity = "warning" + headline = "집계 기준 확인" + anomaly_cards.append( + { + "year": year, + "severity": severity, + "headline": headline, + "primary_gap": primary_gap, + "revenue_gap": adjusted_revenue_gap, + "cost_gap": adjusted_cost_gap, + "sga_gap": adjusted_sga_gap, + "operating_expense_gap": adjusted_total_expense_gap, + "audit_adjustment_total": sum(adjustment_buckets.values()), + "audit_adjustment_count": audit_adjustment_count, + "tags": review_tags[:4], + } + ) + + totals = { + "wehago_revenue": sum(row["wehago_revenue"] for row in yearly_rows), + "erp_revenue": sum(row["erp_revenue"] for row in yearly_rows), + "adjusted_erp_revenue": sum(row["adjusted_erp_revenue"] for row in yearly_rows), + "wehago_operating_expense": sum(row["wehago_operating_expense"] for row in yearly_rows), + "erp_operating_expense": sum(row["erp_operating_expense"] for row in yearly_rows), + "adjusted_erp_operating_expense": sum(row["adjusted_erp_operating_expense"] for row in yearly_rows), + "labor_pool_without_rnd": sum(row["labor_pool_without_rnd"] for row in labor_rows), + "hanmac_labor_total": sum(row["hanmac_labor_total"] for row in labor_rows), + "hanmac_wehago_labor_gap": sum(row["hanmac_wehago_labor_gap"] for row in labor_rows), + "total_hours": sum(row["total_hours"] for row in labor_rows), + "audit_adjustment_total": sum(sum(row["adjustments"].values()) for row in yearly_rows), + "audit_adjustment_count": sum(row["audit_adjustment_count"] for row in yearly_rows), + } + totals["revenue_gap"] = _financial_gap_signed_gap(totals["wehago_revenue"], totals["erp_revenue"]) + totals["adjusted_revenue_gap"] = _financial_gap_signed_gap(totals["wehago_revenue"], totals["adjusted_erp_revenue"]) + totals["operating_expense_gap"] = _financial_gap_signed_gap( + totals["wehago_operating_expense"], + totals["erp_operating_expense"], + ) + totals["adjusted_operating_expense_gap"] = _financial_gap_signed_gap( + totals["wehago_operating_expense"], + totals["adjusted_erp_operating_expense"], + ) + totals["implied_hourly_without_rnd"] = ( + totals["labor_pool_without_rnd"] / totals["total_hours"] + if totals["total_hours"] > 0 + else 0.0 + ) + sorted_substantive_gap_rows = sorted( + substantive_gap_rows, + key=lambda item: ( + item["year"], + 0 if item["section"] == "수익" else 1, + -abs(normalize_amount(item["residual_gap"])), + -abs(normalize_amount(item["gap_amount"])), + ), + ) + substantive_gap_preview_rows: list[dict[str, Any]] = [] + for preview_year in sorted(target_years, reverse=True): + year_rows = [ + row + for row in substantive_gap_rows + if int(row.get("year") or 0) == preview_year + ] + for preview_section, section_limit in (("수익", 3), ("영업비용", 4)): + section_rows = [ + row + for row in year_rows + if row.get("section") == preview_section + ] + section_rows.sort( + key=lambda item: ( + -abs(normalize_amount(item["residual_gap"])), + -abs(normalize_amount(item["gap_amount"])), + ) + ) + substantive_gap_preview_rows.extend(section_rows[:section_limit]) + + project_profit_bridge_rows: list[dict[str, Any]] = [] + for bridge_year in target_years: + latest_financial = next((row for row in yearly_rows if row.get("year") == bridge_year), {}) + try: + project_payload = project_payloads_by_year.get(bridge_year) or {} + project_summary = project_payload.get("summary") or {} + diagnostics = project_payload.get("allocation_diagnostics") or {} + project_cache_info = project_payload.get("cache_info") or {} + project_rows = project_payload.get("rows") or [] + common_rows = [ + row + for row in project_rows + if normalize_text(row.get("support_dept_code")).upper() == "ZZZZZZ" + ] + project_revenue = normalize_amount(project_summary.get("period_revenue_amount")) + project_expense = normalize_amount(project_summary.get("period_total_cost")) + project_profit = normalize_amount(project_summary.get("period_profit_amount")) + common_revenue = sum(normalize_amount(row.get("period_revenue_amount")) for row in common_rows) + wehago_revenue = normalize_amount(latest_financial.get("wehago_revenue")) + wehago_expense = normalize_amount(latest_financial.get("wehago_operating_expense")) + wehago_profit = normalize_amount(latest_financial.get("wehago_operating_profit")) + erp_revenue = normalize_amount(latest_financial.get("erp_revenue")) + adjusted_erp_revenue = normalize_amount(latest_financial.get("adjusted_erp_revenue")) + revenue_adjustment_total = normalize_amount((latest_financial.get("adjustments") or {}).get("revenue")) + latest_revenue_adjustments = [ + row + for row in adjustment_rows + if row.get("year") == bridge_year and row.get("bucket") == "revenue" + ] + revenue_adjustment_increase = sum( + max(0.0, normalize_amount(row.get("delta"))) + for row in latest_revenue_adjustments + ) + revenue_adjustment_decrease = sum( + min(0.0, normalize_amount(row.get("delta"))) + for row in latest_revenue_adjustments + ) + hanmac_labor_total = normalize_amount(diagnostics.get("displayed_labor_total")) + common_cost_allocated = 0.0 + common_sga_allocated = sum(normalize_amount(row.get("total_cost")) for row in common_rows) + project_profit_bridge_rows.append( + { + "year": bridge_year, + "project_revenue": project_revenue, + "project_only_revenue": project_revenue - common_revenue, + "common_revenue": common_revenue, + "project_billing": normalize_amount(project_summary.get("period_billing_amount")), + "project_negative_billing": normalize_amount(project_summary.get("period_negative_billing_amount")), + "project_collection": normalize_amount(project_summary.get("period_collection_amount")), + "project_revenue_billing_gap": normalize_amount(project_summary.get("period_revenue_billing_gap")), + "project_revenue_collection_gap": normalize_amount(project_summary.get("period_revenue_collection_gap")), + "common_revenue_row_count": len(common_rows), + "wehago_revenue": wehago_revenue, + "erp_revenue": erp_revenue, + "adjusted_erp_revenue": adjusted_erp_revenue, + "revenue_adjustment_total": revenue_adjustment_total, + "revenue_adjustment_increase": revenue_adjustment_increase, + "revenue_adjustment_decrease": revenue_adjustment_decrease, + "revenue_adjustment_count": len(latest_revenue_adjustments), + "project_revenue_gap": project_revenue - wehago_revenue, + "wehago_to_erp_revenue_gap": wehago_revenue - erp_revenue, + "erp_to_project_revenue_gap": erp_revenue - project_revenue, + "adjusted_erp_to_project_revenue_gap": adjusted_erp_revenue - project_revenue, + "project_unassigned_erp_revenue": erp_revenue - project_revenue, + "project_expense": project_expense, + "erp_total_expense": normalize_amount(diagnostics.get("erp_total_expense")), + "expense_reconciliation_gap": normalize_amount(diagnostics.get("expense_reconciliation_gap")), + "expense_reconciled": bool(diagnostics.get("expense_reconciled")), + "project_profit_financial_logic_version": normalize_text(project_cache_info.get("financial_logic_version")), + "project_profit_h_mapping_version": normalize_text(project_cache_info.get("h_project_mapping_version")), + "project_profit_generated_at": normalize_text(project_cache_info.get("generated_at") or project_cache_info.get("updated_at")), + "wehago_expense": wehago_expense, + "project_expense_gap": project_expense - wehago_expense, + "project_profit": project_profit, + "wehago_profit": wehago_profit, + "project_profit_gap": project_profit - wehago_profit, + "hanmac_labor_total": hanmac_labor_total, + "common_labor_excluded": 0.0, + "hanmac_missing_regular_sga_allocated": 0.0, + "common_cost_allocated": common_cost_allocated, + "common_sga_allocated": common_sga_allocated, + "common_source_total": normalize_amount(diagnostics.get("common_source_total")), + "direct_project_nonlabor": ( + project_expense + - hanmac_labor_total + - common_cost_allocated + - common_sga_allocated + ), + "profit_bridge_validation_gap": ( + (project_revenue - wehago_revenue) + - (project_expense - wehago_expense) + - (project_profit - wehago_profit) + ), + "revenue_bridge_validation_gap": ( + (wehago_revenue - erp_revenue) + + (erp_revenue - project_revenue) + - (wehago_revenue - project_revenue) + ), + "adjusted_revenue_bridge_validation_gap": ( + erp_revenue + + revenue_adjustment_total + - adjusted_erp_revenue + ), + "finding": ( + "프로젝트손익 수익은 프로젝트 귀속 ERP 매출과 공통매출 행을 함께 표시합니다. " + "청구·수금 금액은 참고값이며 매출-청구 차이를 별도 확인합니다." + ), + } + ) + except Exception as exc: + logger.warning("프로젝트손익/WEHAGO 손익 브릿지 생성 실패(%s): %s", bridge_year, exc) + + return { + "years": target_years, + "yearly_rows": yearly_rows, + "labor_rows": labor_rows, + "account_detail_rows": account_detail_rows, + "substantive_gap_rows": sorted_substantive_gap_rows, + "substantive_gap_preview_rows": substantive_gap_preview_rows, + "raw_account_comparison_rows": sorted( + raw_account_comparison_rows, + key=lambda item: ( + item["year"], + 0 if item["section"] == "수익" else 1, + 0 if item["comparison_level"] == "총액" else 1, + -abs(normalize_amount(item["pre_adjustment_gap"])), + item["item_label"], + ), + ), + "adjustment_rows": sorted(adjustment_rows, key=lambda item: (item["year"], item["ledger_date"], item["voucher_no"], item["account_code"])), + "classification_review_rows": classification_review_rows, + "review_items": review_items, + "anomaly_cards": anomaly_cards, + "reason_items": reason_items, + "project_profit_bridge_rows": project_profit_bridge_rows, + "totals": totals, + "assumptions": [ + "WEHAGO 손익계산서 금액은 계정별 원장의 차변/대변 합계 중 큰 금액을 표시 금액으로 사용했습니다.", + "452 도급공사매출원가는 재무제표 매출원가 총액으로 보고, 6xx 계정은 매출원가 상세 구성으로만 표시했습니다.", + "감사/결산 조정효과는 결산, 감사, 수정, 환원, 진행율 매출액, 계상분 대체 문맥의 원장 행만 별도 집계했습니다.", + "손익계정 대체, 수익/비용에서 대체, 당기순손익 대체 같은 마감 전표는 최종 손익 금액 중복을 막기 위해 조정효과에서 제외했습니다.", + "Hanmac ERP 비교 수익·원가·판관비·손익은 프로젝트 손익분석 페이지1과 동일한 연도별 최종 payload를 사용합니다.", + "프로젝트 인건비는 한맥 근무시간에 연도·사업분류·직급별 시급과 연장·휴일 가산율을 적용한 뒤 ERP 실제 인건비성 비용과의 차액을 비례 배분한 값입니다.", + "인건비 차이는 프로젝트 손익분석 페이지1의 최종 인건비와 WEHAGO 원가성·판관 인건비의 차이입니다.", + "프로젝트손익은 프로젝트 코드로 연결된 ERP 4xx 매출을 수익으로 사용하므로, 회사 전체 WEHAGO 매출과 범위가 다를 수 있습니다.", + "ERP+조정매출은 ERP 원매출에 WEHAGO 원장의 적요에서 감사·결산·진행률 매출 조정으로 식별한 전표 효과를 더한 분석용 비교값이며, ERP에 저장된 별도 확정 매출값이 아닙니다.", + "식별 조정 전표는 적요 문구 기반이므로 전표 상세에서 실제 결산 조정 여부를 확인해야 하며, 프로젝트손익에는 프로젝트별 귀속 근거가 없는 조정액을 자동 배부하지 않습니다.", + "ZZZZZZ 공통 프로젝트의 수익·비용·인건비는 프로젝트 손익분석 페이지1의 공통 행에 보존합니다.", + "퇴직급여, 복리후생비, 상여/연차/4대보험 성격 비용은 인건비 풀에 포함될 수 있다는 전제로 검토합니다.", + ], + } + + +def render_annual_gap_analysis_page(request: Request, message: str = "") -> HTMLResponse: + init_db() + context = { + **base_context(request, message), + "gap_analysis": get_financial_gap_analysis_payload(), + } + return templates.TemplateResponse(request, "annual_gap_analysis.html", context) + + def get_annual_summary_bootstrap_payload() -> dict[str, Any]: cached = _get_deepcopy_ttl_cache_entry( _ANNUAL_SUMMARY_BOOTSTRAP_CACHE, @@ -14716,7 +20304,10 @@ def render_wehago_compare_page( warm_caches=False, ), } - return templates.TemplateResponse(request, "wehago_compare.html", context) + response = templates.TemplateResponse(request, "wehago_compare.html", context) + response.headers["Cache-Control"] = "no-store, max-age=0" + response.headers["Pragma"] = "no-cache" + return response def render_wehago_benefit_entertainment_page( @@ -14761,6 +20352,7 @@ def render_hanmac_browser_page( ) -> HTMLResponse: context = { **base_context(request, message), + "hanmac_wehago_audit_sources": build_hanmac_wehago_audit_sources(), } return templates.TemplateResponse(request, "hanmac_browser.html", context) @@ -14822,6 +20414,2901 @@ def test_hanmac_mysql_connection(payload: dict[str, Any]) -> dict[str, Any]: test_engine.dispose() +HANMAC_MANAGEMENT_ERP_BASE_URL = "http://erp.hanmaceng.co.kr/planning_mng/" +HANMAC_MANAGEMENT_ERP_LOGIN_URL = f"{HANMAC_MANAGEMENT_ERP_BASE_URL}LoginCheck.php" +HANMAC_MANAGEMENT_ERP_MAIN_URL = f"{HANMAC_MANAGEMENT_ERP_BASE_URL}sys/controller/main_controller.php" +HANMAC_SATIS_ERP_BASE_URL = "http://erp.hanmaceng.co.kr/satis/" +HANMAC_SATIS_ERP_LOGIN_CONTROLLER_URL = f"{HANMAC_SATIS_ERP_BASE_URL}sys/controller/Login/Login_Controller.php" +HANMAC_SATIS_ERP_LOGIN_PAGE_URL = ( + f"{HANMAC_SATIS_ERP_BASE_URL}sys/controller/Login/Login_controller.php?ActionMode=GoLogin" +) +HANMAC_ERP_DIRECT_DB_HOST = "erp.hanmaceng.co.kr" +HANMAC_ERP_DIRECT_DB_PORT = 3306 +HANMAC_SATIS_BUDGET_DISCOVERY_KEYWORDS = ( + "satis", + "project", + "proj", + "budget", + "exec", + "plan", + "task", + "cost", + "amount", + "approval", + "approve", + "revision", + "rev", + "round", + "degree", + "change", + "dept", + "work", + "account", + "acct", +) +HANMAC_SATIS_NUMERIC_TYPES = {"bigint", "decimal", "double", "float", "int", "integer", "mediumint", "numeric", "real", "smallint", "tinyint"} +HANMAC_SATIS_AMOUNT_COLUMN_TOKENS = ("amount", "amt", "budget", "cost", "price", "sum", "total", "money", "supply") +HANMAC_SATIS_PROJECT_CODE_TOKENS = ("project", "proj", "pjt", "pj", "prj") +HANMAC_SATIS_REVISION_TOKENS = ("revision", "rev", "round", "degree", "change", "seq", "cha", "turn") +HANMAC_SATIS_STATUS_TOKENS = ("approval", "approve", "status", "state", "confirm", "app") +HANMAC_SATIS_PROJECT_CODE_REGISTER_FILENAME = "차수사업코드등록_260622.xls" +HANMAC_SATIS_LINKED_MAIN_PROJECT_OVERRIDES = { + "X24016": "024240", + "X24020": "024241", + "X25011": "025227", +} +HANMAC_SATIS_COMMON_PROJECT_CODES = {"ZZZZZZ", "X24003"} +HANMAC_SATIS_EXCEPTION_PROJECT_CODES = {"006061"} + + +class _SatisProjectCodeRegisterHtmlParser(HTMLParser): + def __init__(self) -> None: + super().__init__() + self._in_cell = False + self._cell_parts: list[str] = [] + self._row: list[str] = [] + self.rows: list[list[str]] = [] + + def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None: + if tag == "tr": + self._row = [] + if tag in {"td", "th"}: + self._in_cell = True + self._cell_parts = [] + + def handle_data(self, data: str) -> None: + if self._in_cell: + self._cell_parts.append(data) + + def handle_endtag(self, tag: str) -> None: + if tag in {"td", "th"} and self._in_cell: + self._row.append(" ".join("".join(self._cell_parts).split())) + self._in_cell = False + self._cell_parts = [] + elif tag == "tr" and self._row: + self.rows.append(self._row) + + +def _parse_satis_project_code_register(path: Path) -> list[dict[str, str]]: + if not path.exists(): + return [] + body = path.read_text(encoding="utf-8", errors="replace") + parser = _SatisProjectCodeRegisterHtmlParser() + parser.feed(body) + header: list[str] = [] + data_rows: list[list[str]] = [] + for index, row in enumerate(parser.rows): + if row and row[0] == "차수사업코드": + header = row + data_rows = parser.rows[index + 1 :] + break + if not header: + return [] + records: list[dict[str, str]] = [] + for row in data_rows: + if not row or row[0].startswith("총 "): + continue + if len(row) < len(header): + row = [*row, *([""] * (len(header) - len(row)))] + records.append({column: normalize_text(row[position]) for position, column in enumerate(header)}) + return records + + +def _default_satis_master_code_for_local_project(local_project_code: str, project_kind: str) -> str: + local_project_code = normalize_text(local_project_code) + project_kind = normalize_text(project_kind) + if re.fullmatch(r"X\d{5}", local_project_code): + return f"9{local_project_code[1:]}" + if re.fullmatch(r"[YZ]\d{5}", local_project_code): + return f"0{local_project_code[1:]}" + if re.fullmatch(r"[09]\d{5}", local_project_code): + return local_project_code + if local_project_code == "ZZZZZZ": + return "" + return "" + + +def sync_satis_project_code_register(conn: Any | None = None) -> dict[str, Any]: + """Import the ERP round-project code register into the local mapping table.""" + + register_path = BASE_DIR / HANMAC_SATIS_PROJECT_CODE_REGISTER_FILENAME + records = _parse_satis_project_code_register(register_path) + if not records: + return { + "status": "skipped", + "message": f"{HANMAC_SATIS_PROJECT_CODE_REGISTER_FILENAME} 파일을 찾지 못했거나 읽을 수 없습니다.", + "source_file": str(register_path), + "row_count": 0, + } + + owns_connection = conn is None + context = engine.begin() if owns_connection else nullcontext(conn) + inserted_or_updated = 0 + kind_counts: dict[str, int] = {} + status_counts: dict[str, int] = {} + with context as active_conn: + for record in records: + local_code = normalize_text(record.get("차수사업코드")) + if not local_code: + continue + project_kind = normalize_text(record.get("사업종류")) + own_master_code = normalize_text(record.get("총괄사업코드")) or _default_satis_master_code_for_local_project(local_code, project_kind) + linked_main_code = own_master_code + mapping_status = "confirmed" + if local_code in HANMAC_SATIS_LINKED_MAIN_PROJECT_OVERRIDES: + linked_main_code = HANMAC_SATIS_LINKED_MAIN_PROJECT_OVERRIDES[local_code] + elif project_kind == "사전": + linked_main_code = "" + if local_code in HANMAC_SATIS_COMMON_PROJECT_CODES: + mapping_status = "common" + linked_main_code = "" + if local_code in HANMAC_SATIS_EXCEPTION_PROJECT_CODES or own_master_code in HANMAC_SATIS_EXCEPTION_PROJECT_CODES: + mapping_status = "exception" + is_active = 1 if normalize_text(record.get("사용여부")) != "N" else 0 + kind_counts[project_kind or "미지정"] = kind_counts.get(project_kind or "미지정", 0) + 1 + status_counts[mapping_status] = status_counts.get(mapping_status, 0) + 1 + active_conn.execute( + text( + """ + INSERT INTO satis_project_code_links ( + local_project_code, local_project_name, project_kind, + own_master_project_code, own_master_project_name, + linked_main_project_code, linked_main_project_name, + cost_project_code, cost_project_name, cost_kind, + pm_department_name, is_joint_project, is_tax_exempt, + is_active, mapping_status, mapping_source, source_file, + raw_payload_json, updated_at + ) VALUES ( + :local_project_code, :local_project_name, :project_kind, + :own_master_project_code, :own_master_project_name, + :linked_main_project_code, :linked_main_project_name, + :cost_project_code, :cost_project_name, :cost_kind, + :pm_department_name, :is_joint_project, :is_tax_exempt, + :is_active, :mapping_status, 'project_code_register', + :source_file, :raw_payload_json, CURRENT_TIMESTAMP + ) + ON CONFLICT(local_project_code) DO UPDATE SET + local_project_name = excluded.local_project_name, + project_kind = excluded.project_kind, + own_master_project_code = excluded.own_master_project_code, + own_master_project_name = excluded.own_master_project_name, + linked_main_project_code = excluded.linked_main_project_code, + linked_main_project_name = excluded.linked_main_project_name, + cost_project_code = excluded.cost_project_code, + cost_project_name = excluded.cost_project_name, + cost_kind = excluded.cost_kind, + pm_department_name = excluded.pm_department_name, + is_joint_project = excluded.is_joint_project, + is_tax_exempt = excluded.is_tax_exempt, + is_active = excluded.is_active, + mapping_status = excluded.mapping_status, + mapping_source = excluded.mapping_source, + source_file = excluded.source_file, + raw_payload_json = excluded.raw_payload_json, + updated_at = CURRENT_TIMESTAMP + """ + ), + { + "local_project_code": local_code, + "local_project_name": normalize_text(record.get("차수사업명칭")), + "project_kind": project_kind, + "own_master_project_code": own_master_code, + "own_master_project_name": normalize_text(record.get("총괄사업명칭")), + "linked_main_project_code": linked_main_code, + "linked_main_project_name": "", + "cost_project_code": normalize_text(record.get("원가코드")), + "cost_project_name": normalize_text(record.get("원가사업명칭")), + "cost_kind": normalize_text(record.get("원가종류")), + "pm_department_name": normalize_text(record.get("PM부서명칭")), + "is_joint_project": normalize_text(record.get("공동공사여부")), + "is_tax_exempt": normalize_text(record.get("면세여부")), + "is_active": is_active, + "mapping_status": mapping_status, + "source_file": register_path.name, + "raw_payload_json": json.dumps(record, ensure_ascii=False, sort_keys=True), + }, + ) + inserted_or_updated += 1 + return { + "status": "ok", + "message": f"Satis 차수사업코드 원장 {inserted_or_updated:,}건을 동기화했습니다.", + "source_file": str(register_path), + "row_count": inserted_or_updated, + "kind_counts": kind_counts, + "status_counts": status_counts, + } + + +def _hanmac_mysql_identifier(value: Any) -> str: + normalized = normalize_text(value) + if not re.fullmatch(r"[A-Za-z0-9_]+", normalized): + raise ValueError(f"MySQL 식별자 형식이 올바르지 않습니다: {normalized}") + return f"`{normalized}`" + + +def _lower_identifier(value: Any) -> str: + return normalize_text(value).lower().replace("-", "_") + + +def _pick_satis_column(columns: list[dict[str, Any]], token_groups: Sequence[Sequence[str]]) -> str: + scored: list[tuple[int, str]] = [] + for column in columns: + name = normalize_text(column.get("name")) + lowered = _lower_identifier(name) + score = 0 + for group_index, tokens in enumerate(token_groups): + if any(token in lowered for token in tokens): + score += max(1, 10 - group_index) + if score > 0: + scored.append((score, name)) + scored.sort(key=lambda item: (-item[0], len(item[1]), item[1])) + return scored[0][1] if scored else "" + + +def _infer_satis_budget_type(database: str, table: str, columns: list[dict[str, Any]]) -> str: + haystack = " ".join([database, table, *[normalize_text(column.get("name")) for column in columns]]).lower() + if any(token in haystack for token in ("exec", "execution", "실행")): + return "exec_budget" + if any(token in haystack for token in ("task", "plan", "과업", "수행")): + return "task_plan" + if any(token in haystack for token in ("overview", "summary", "개요")): + return "project_overview" + return "project_budget" + + +def _infer_satis_amount_columns(columns: list[dict[str, Any]]) -> list[str]: + scored: list[tuple[int, int, str]] = [] + for position, column in enumerate(columns): + name = normalize_text(column.get("name")) + lowered = _lower_identifier(name) + data_type = _lower_identifier(column.get("data_type")) + score = 0 + if data_type in HANMAC_SATIS_NUMERIC_TYPES: + score += 4 + if any(token in lowered for token in HANMAC_SATIS_AMOUNT_COLUMN_TOKENS): + score += 8 + if any(token in lowered for token in ("date", "year", "no", "code", "cd", "id", "seq", "rate", "percent")): + score -= 4 + if score >= 6: + scored.append((score, position, name)) + scored.sort(key=lambda item: (-item[0], item[1], item[2])) + return [name for _score, _position, name in scored[:12]] + + +def _safe_float(value: Any) -> float: + if value in (None, ""): + return 0.0 + try: + return float(str(value).replace(",", "")) + except Exception: + return 0.0 + + +def _json_default(value: Any) -> Any: + if isinstance(value, (date, datetime)): + return value.isoformat() + if isinstance(value, Decimal): + return float(value) + return str(value) + + +def _hanmac_erp_web_credentials(payload: dict[str, Any]) -> tuple[str, str]: + user = normalize_text(payload.get("erp_user")) + password = str(payload.get("erp_password") or "") + if not user: + raise ValueError("Satis ERP 아이디를 입력해주세요.") + if not password: + raise ValueError("Satis ERP 비밀번호를 입력해주세요.") + return user, password + + +def _hanmac_erp_mysql_credentials(payload: dict[str, Any]) -> tuple[str, str, bool]: + db_user = normalize_text(payload.get("erp_db_user")) + db_password = str(payload.get("erp_db_password") or "") + if db_user or db_password: + if not db_user: + raise ValueError("관리DB MySQL 아이디를 입력해주세요.") + if not db_password: + raise ValueError("관리DB MySQL 비밀번호를 입력해주세요.") + return db_user, db_password, True + user, password = _hanmac_erp_web_credentials(payload) + return user, password, False + + +def _build_hanmac_mysql_access_denied_message(user: str, explicit_db_credentials: bool) -> str: + if explicit_db_credentials: + return f"관리DB MySQL 계정 '{user}'로 직접 DB 접속이 거부되었습니다. MySQL 권한 또는 비밀번호를 확인해주세요." + return ( + f"ERP 웹 계정 '{user}'로 MySQL 직접 DB 접속이 거부되었습니다. " + "G26001 같은 관리 ERP 웹 로그인 계정과 MySQL DB 계정은 별도일 수 있습니다. " + "예산 원본 DB를 직접 조회하려면 관리DB MySQL 아이디/비밀번호 또는 읽기전용 View/API 정보가 필요합니다." + ) + + +def _sync_satis_budget_raw_rows(payload: dict[str, Any]) -> dict[str, Any]: + _hanmac_erp_web_credentials(payload) + db_user, db_password, explicit_db_credentials = _hanmac_erp_mysql_credentials(payload) + + try: + max_tables = max(1, min(int(payload.get("max_tables") or 12), 40)) + except Exception: + max_tables = 12 + try: + row_limit = max(10, min(int(payload.get("row_limit") or 500), 5000)) + except Exception: + row_limit = 500 + + try: + discovery = _discover_hanmac_erp_budget_tables(db_user, db_password) + except OperationalError as exc: + if "access denied" in str(exc).lower(): + raise ValueError(_build_hanmac_mysql_access_denied_message(db_user, explicit_db_credentials)) from exc + raise + candidates = list(discovery.get("candidate_tables") or [])[:max_tables] + if not candidates: + return { + "status": "ok", + "message": "직접 DB 접속은 됐지만 예산 후보 테이블을 찾지 못했습니다.", + "inserted_or_updated_rows": 0, + "table_results": [], + **discovery, + } + + remote_engine = _build_hanmac_erp_direct_mysql_engine(db_user, db_password) + table_results: list[dict[str, Any]] = [] + inserted_or_updated = 0 + skipped_tables = 0 + try: + with remote_engine.connect() as remote_conn, engine.begin() as local_conn: + for candidate in candidates: + database = normalize_text(candidate.get("database")) + table = normalize_text(candidate.get("table")) + if not database or not table: + continue + column_rows = remote_conn.execute( + text( + """ + SELECT COLUMN_NAME, DATA_TYPE, ORDINAL_POSITION + FROM information_schema.columns + WHERE table_schema = :database + AND table_name = :table + ORDER BY ORDINAL_POSITION + """ + ), + {"database": database, "table": table}, + ).mappings().fetchall() + columns = [ + { + "name": normalize_text(row.get("COLUMN_NAME")), + "data_type": normalize_text(row.get("DATA_TYPE")), + "position": int(row.get("ORDINAL_POSITION") or 0), + } + for row in column_rows + if normalize_text(row.get("COLUMN_NAME")) + ] + amount_columns = _infer_satis_amount_columns(columns) + if not amount_columns: + skipped_tables += 1 + table_results.append( + { + "database": database, + "table": table, + "status": "skipped", + "reason": "금액성 컬럼을 추정하지 못했습니다.", + "row_count": 0, + } + ) + continue + + project_code_column = _pick_satis_column(columns, (HANMAC_SATIS_PROJECT_CODE_TOKENS, ("code", "cd", "no"))) + project_name_column = _pick_satis_column(columns, (HANMAC_SATIS_PROJECT_CODE_TOKENS, ("name", "nm", "title"))) + revision_column = _pick_satis_column(columns, (HANMAC_SATIS_REVISION_TOKENS,)) + approval_status_column = _pick_satis_column(columns, (HANMAC_SATIS_STATUS_TOKENS,)) + budget_type = _infer_satis_budget_type(database, table, columns) + + selected_columns: list[str] = [] + for name in [ + project_code_column, + project_name_column, + revision_column, + approval_status_column, + *amount_columns, + *[normalize_text(column.get("name")) for column in columns[:30]], + ]: + if name and name not in selected_columns: + selected_columns.append(name) + selected_columns = selected_columns[:80] + quoted_selected = ", ".join(_hanmac_mysql_identifier(name) for name in selected_columns) + quoted_amounts = [_hanmac_mysql_identifier(name) for name in amount_columns] + where_clause = " OR ".join(f"COALESCE({quoted}, 0) <> 0" for quoted in quoted_amounts) + source_sql = text( + f""" + SELECT {quoted_selected} + FROM {_hanmac_mysql_identifier(database)}.{_hanmac_mysql_identifier(table)} + WHERE {where_clause} + LIMIT {int(row_limit)} + """ + ) + rows = remote_conn.execute(source_sql).mappings().fetchall() + table_inserted = 0 + inferred_columns = { + "project_code": project_code_column, + "project_name": project_name_column, + "revision_no": revision_column, + "approval_status": approval_status_column, + "amount_columns": amount_columns, + } + for row_index, row in enumerate(rows): + row_dict = {key: row.get(key) for key in selected_columns} + amount_values = { + column_name: _safe_float(row_dict.get(column_name)) + for column_name in amount_columns + } + amount_total = sum(amount_values.values()) + if amount_total == 0: + continue + raw_payload_json = json.dumps(row_dict, ensure_ascii=False, sort_keys=True, default=_json_default) + source_hash = hashlib.sha256( + json.dumps( + { + "database": database, + "table": table, + "row": row_dict, + }, + ensure_ascii=False, + sort_keys=True, + default=_json_default, + ).encode("utf-8") + ).hexdigest() + local_conn.execute( + text( + """ + INSERT OR REPLACE INTO satis_project_budget_raw_rows ( + source_system, source_database, source_table, source_row_index, + budget_type, project_code, project_name, revision_no, approval_status, + amount_total, amount_values_json, inferred_columns_json, + raw_payload_json, source_hash, synced_at + ) VALUES ( + 'satis', :source_database, :source_table, :source_row_index, + :budget_type, :project_code, :project_name, :revision_no, :approval_status, + :amount_total, :amount_values_json, :inferred_columns_json, + :raw_payload_json, :source_hash, CURRENT_TIMESTAMP + ) + """ + ), + { + "source_database": database, + "source_table": table, + "source_row_index": row_index, + "budget_type": budget_type, + "project_code": normalize_text(row_dict.get(project_code_column)), + "project_name": normalize_text(row_dict.get(project_name_column)), + "revision_no": normalize_text(row_dict.get(revision_column)), + "approval_status": normalize_text(row_dict.get(approval_status_column)), + "amount_total": amount_total, + "amount_values_json": json.dumps(amount_values, ensure_ascii=False, sort_keys=True), + "inferred_columns_json": json.dumps(inferred_columns, ensure_ascii=False, sort_keys=True), + "raw_payload_json": raw_payload_json, + "source_hash": source_hash, + }, + ) + table_inserted += 1 + inserted_or_updated += table_inserted + table_results.append( + { + "database": database, + "table": table, + "status": "synced", + "budget_type": budget_type, + "row_count": table_inserted, + "sampled_row_limit": row_limit, + "inferred_columns": inferred_columns, + } + ) + finally: + remote_engine.dispose() + + return { + "status": "ok", + "message": f"Satis 후보 테이블 {len(candidates)}개에서 실제 금액 행 {inserted_or_updated:,}건을 원본 저장소에 반영했습니다.", + "inserted_or_updated_rows": inserted_or_updated, + "skipped_tables": skipped_tables, + "table_results": table_results, + "raw_table": "satis_project_budget_raw_rows", + "candidate_table_count": discovery.get("candidate_table_count", 0), + "direct_db_database_count": discovery.get("direct_db_database_count", 0), + } + + +def _parse_json_dict(value: Any) -> dict[str, Any]: + if isinstance(value, dict): + return value + try: + parsed = json.loads(str(value or "{}")) + return parsed if isinstance(parsed, dict) else {} + except Exception: + return {} + + +def _satis_raw_project_code(row: Mapping[str, Any]) -> str: + project_code = normalize_text(row.get("project_code")) + if project_code: + return project_code + payload = _parse_json_dict(row.get("raw_payload_json")) + inferred = _parse_json_dict(row.get("inferred_columns_json")) + inferred_project_column = normalize_text(inferred.get("project_code")) + if inferred_project_column: + return normalize_text(payload.get(inferred_project_column)) + for key, value in payload.items(): + lowered = _lower_identifier(key) + if any(token in lowered for token in HANMAC_SATIS_PROJECT_CODE_TOKENS) and any(token in lowered for token in ("code", "cd", "no")): + candidate = normalize_text(value) + if candidate: + return candidate + return "UNKNOWN" + + +def _satis_raw_project_name(row: Mapping[str, Any]) -> str: + project_name = normalize_text(row.get("project_name")) + if project_name: + return project_name + payload = _parse_json_dict(row.get("raw_payload_json")) + inferred = _parse_json_dict(row.get("inferred_columns_json")) + inferred_project_name_column = normalize_text(inferred.get("project_name")) + if inferred_project_name_column: + return normalize_text(payload.get(inferred_project_name_column)) + for key, value in payload.items(): + lowered = _lower_identifier(key) + if any(token in lowered for token in HANMAC_SATIS_PROJECT_CODE_TOKENS) and any(token in lowered for token in ("name", "nm", "title")): + candidate = normalize_text(value) + if candidate: + return candidate + return "" + + +def _lookup_support_dept_code_for_satis_project(conn: Any, project_code: str, project_name: str) -> str: + project_code = normalize_text(project_code) + project_name = normalize_text(project_name) + if not project_code and not project_name: + return "" + row = None + if project_code: + row = conn.execute( + text( + """ + SELECT support_dept_code + FROM project_status + WHERE support_dept_code = :project_code + LIMIT 1 + """ + ), + {"project_code": project_code}, + ).mappings().first() + if project_code: + link_row = conn.execute( + text( + """ + SELECT l.local_project_code AS support_dept_code + FROM satis_project_code_links l + INNER JOIN project_status p + ON p.support_dept_code = l.local_project_code + WHERE l.mapping_status IN ('confirmed', 'exception') + AND l.local_project_code <> 'ZZZZZZ' + AND ( + l.local_project_code = :project_code + OR l.own_master_project_code = :project_code + OR l.linked_main_project_code = :project_code + OR l.cost_project_code = :project_code + ) + ORDER BY + CASE WHEN l.local_project_code = :project_code THEN 0 ELSE 1 END, + CASE WHEN l.own_master_project_code = :project_code THEN 0 ELSE 1 END, + CASE WHEN l.linked_main_project_code = :project_code THEN 0 ELSE 1 END, + CASE WHEN l.is_active = 1 THEN 0 ELSE 1 END, + l.local_project_code + LIMIT 1 + """ + ), + {"project_code": project_code}, + ).mappings().first() + if link_row: + row = link_row + if not row and project_code: + row = conn.execute( + text( + """ + SELECT support_dept_code + FROM satis_project_mapping + WHERE erp_project_code = :project_code + AND mapping_status = 'matched' + LIMIT 1 + """ + ), + {"project_code": project_code}, + ).mappings().first() + if not row and project_name: + row = conn.execute( + text( + """ + SELECT support_dept_code + FROM project_status + WHERE support_dept_name = :project_name + LIMIT 1 + """ + ), + {"project_name": project_name}, + ).mappings().first() + return normalize_text(row.get("support_dept_code")) if row else "" + + +def _normalize_satis_budget_raw_rows(_payload: dict[str, Any] | None = None) -> dict[str, Any]: + init_db() + normalized_revisions = 0 + task_lines = 0 + exec_lines = 0 + skipped_rows = 0 + source_table_counts: dict[str, int] = {} + with engine.begin() as conn: + conn.execute(text("DELETE FROM satis_project_budget_projection_status")) + conn.execute(text("DELETE FROM satis_project_task_plan_budget_lines")) + conn.execute(text("DELETE FROM satis_project_exec_budget_lines")) + conn.execute(text("DELETE FROM satis_project_budget_revisions")) + raw_rows = conn.execute( + text( + """ + SELECT * + FROM satis_project_budget_raw_rows + WHERE source_database = 'satis_web' + AND inferred_columns_json LIKE '%GET_DETAIL%' + AND source_table IN ( + 'SCREEN_03:Ajax_02', + 'SCREEN_06:Ajax_00', + 'SCREEN_02:Ajax_01', + 'SCREEN_02:Ajax_03', + 'SCREEN_02:Ajax_04' + ) + ORDER BY source_database, source_table, project_code, revision_no, id + """ + ) + ).mappings().fetchall() + deduplicated_raw_rows: list[Mapping[str, Any]] = [] + seen_raw_signatures: set[tuple[str, str, str, str]] = set() + for candidate_row in raw_rows: + signature = ( + normalize_text(candidate_row.get("source_table")), + _satis_raw_project_code(candidate_row), + normalize_text(candidate_row.get("revision_no")), + normalize_text(candidate_row.get("raw_payload_json")), + ) + if signature in seen_raw_signatures: + continue + seen_raw_signatures.add(signature) + deduplicated_raw_rows.append(candidate_row) + raw_rows = deduplicated_raw_rows + revision_metadata: dict[tuple[str, str], dict[str, str]] = {} + for metadata_row in raw_rows: + if normalize_text(metadata_row.get("source_table")) != "SCREEN_03:Ajax_02": + continue + metadata_project_code = _satis_raw_project_code(metadata_row) + metadata_revision_no = normalize_text(metadata_row.get("revision_no")) or "00" + revision_metadata[(metadata_project_code, metadata_revision_no)] = { + "approval_status": normalize_text(metadata_row.get("approval_status")), + "project_name": _satis_raw_project_name(metadata_row), + } + for raw_row in raw_rows: + raw_id = int(raw_row.get("id") or 0) + source_database = normalize_text(raw_row.get("source_database")) + source_table = normalize_text(raw_row.get("source_table")) + budget_type = normalize_text(raw_row.get("budget_type")) or "project_budget" + project_code = _satis_raw_project_code(raw_row) + project_name = _satis_raw_project_name(raw_row) + revision_no = normalize_text(raw_row.get("revision_no")) or "0" + approval_status = normalize_text(raw_row.get("approval_status")) + metadata = revision_metadata.get((project_code, revision_no), {}) + if metadata: + approval_status = normalize_text(metadata.get("approval_status")) or approval_status + project_name = normalize_text(metadata.get("project_name")) or project_name + raw_payload_json = normalize_text(raw_row.get("raw_payload_json")) + raw_payload = _parse_json_dict(raw_payload_json) + amount_values = _parse_json_dict(raw_row.get("amount_values_json")) + inferred_columns = _parse_json_dict(raw_row.get("inferred_columns_json")) + if not amount_values: + skipped_rows += 1 + continue + + support_dept_code = _lookup_support_dept_code_for_satis_project(conn, project_code, project_name) + source_key = f"{source_database}:{project_code}:{budget_type}:{revision_no}" + source_hash = hashlib.sha256( + json.dumps( + { + "source_key": source_key, + "raw_hash": normalize_text(raw_row.get("source_hash")), + "amount_values": amount_values, + }, + ensure_ascii=False, + sort_keys=True, + default=_json_default, + ).encode("utf-8") + ).hexdigest() + approval_status_lower = re.sub(r"<[^>]+>", "", approval_status).strip().lower() + is_approved = int( + any( + token in approval_status_lower + for token in ("승인완료", "결재완료", "확정:y", "approved", "completed") + ) + or approval_status_lower in ("70", "y", "1") + ) + conn.execute( + text( + """ + INSERT INTO satis_project_budget_revisions ( + source_system, project_code, support_dept_code, project_name, budget_type, + revision_no, revision_name, approval_status, is_approved, is_latest, + source_key, source_hash, raw_payload_json, synced_at + ) VALUES ( + 'satis', :project_code, :support_dept_code, :project_name, :budget_type, + :revision_no, :revision_name, :approval_status, :is_approved, 0, + :source_key, :source_hash, :raw_payload_json, CURRENT_TIMESTAMP + ) + ON CONFLICT(source_system, budget_type, source_key) DO UPDATE SET + support_dept_code = excluded.support_dept_code, + project_name = excluded.project_name, + revision_no = excluded.revision_no, + revision_name = excluded.revision_name, + approval_status = excluded.approval_status, + is_approved = excluded.is_approved, + source_hash = excluded.source_hash, + raw_payload_json = excluded.raw_payload_json, + synced_at = CURRENT_TIMESTAMP + """ + ), + { + "project_code": project_code, + "support_dept_code": support_dept_code, + "project_name": project_name, + "budget_type": budget_type, + "revision_no": revision_no, + "revision_name": f"{source_database}.{source_table} #{raw_id}", + "approval_status": approval_status, + "is_approved": is_approved, + "source_key": source_key, + "source_hash": source_hash, + "raw_payload_json": raw_payload_json, + }, + ) + revision_row = conn.execute( + text( + """ + SELECT id + FROM satis_project_budget_revisions + WHERE source_system = 'satis' + AND budget_type = :budget_type + AND source_key = :source_key + LIMIT 1 + """ + ), + {"budget_type": budget_type, "source_key": source_key}, + ).mappings().first() + if not revision_row: + skipped_rows += 1 + continue + revision_id = int(revision_row.get("id") or 0) + for line_no, (amount_column, amount_value) in enumerate(amount_values.items()): + amount = _safe_float(amount_value) + if amount == 0: + continue + source_line_key = f"{raw_id}:{amount_column}" + group_name = source_table + dept_name = "" + work_name = amount_column + grade = "" + hours = "" + account_code = amount_column + account_name = amount_column + if source_table == "SCREEN_06:Ajax_00": + dept_name = normalize_text(raw_payload.get("item09") or raw_payload.get("item08")) + work_name = normalize_text(raw_payload.get("item06")) or amount_column + if amount_column == "item40": + group_name = "outsource" + work_name = f"{work_name} 외주예상" + elif normalize_text(raw_payload.get("item14")) == "Y": + group_name = "outsource" + else: + group_name = "department" + elif source_table == "SCREEN_02:Ajax_01": + group_name = "labor" + grade = normalize_text(raw_payload.get("item07")) + account_code = "SATIS_LABOR" + account_name = grade or "인건비" + elif source_table == "SCREEN_02:Ajax_03": + group_name = "outsource" + work_name = normalize_text(raw_payload.get("item06")) + account_code = normalize_text(raw_payload.get("item05")) + account_name = work_name or "외주비" + elif source_table == "SCREEN_02:Ajax_04": + group_name = "cost_plan" + account_code = normalize_text(raw_payload.get("item06")) + account_name = normalize_text(raw_payload.get("item07")) or "제경비" + line_payload = { + "source_database": source_database, + "source_table": source_table, + "raw_row_id": raw_id, + "amount_column": amount_column, + "inferred_columns": inferred_columns, + "raw": raw_payload, + } + if budget_type == "exec_budget": + conn.execute( + text( + """ + INSERT INTO satis_project_exec_budget_lines ( + revision_id, line_no, group_name, grade, hours, + dept_name, work_name, account_code, account_name, + amount, source_line_key, raw_payload_json, synced_at + ) VALUES ( + :revision_id, :line_no, :group_name, :grade, :hours, + :dept_name, :work_name, :account_code, :account_name, + :amount, :source_line_key, :raw_payload_json, CURRENT_TIMESTAMP + ) + """ + ), + { + "revision_id": revision_id, + "line_no": line_no, + "group_name": group_name, + "grade": grade, + "hours": hours, + "dept_name": dept_name, + "work_name": work_name, + "account_code": account_code, + "account_name": account_name, + "amount": amount, + "source_line_key": source_line_key, + "raw_payload_json": json.dumps(line_payload, ensure_ascii=False, sort_keys=True, default=_json_default), + }, + ) + exec_lines += 1 + else: + conn.execute( + text( + """ + INSERT INTO satis_project_task_plan_budget_lines ( + revision_id, line_no, group_name, dept_name, work_name, + amount, source_line_key, raw_payload_json, synced_at + ) VALUES ( + :revision_id, :line_no, :group_name, :dept_name, :work_name, + :amount, :source_line_key, :raw_payload_json, CURRENT_TIMESTAMP + ) + """ + ), + { + "revision_id": revision_id, + "line_no": line_no, + "group_name": group_name, + "dept_name": dept_name, + "work_name": work_name, + "amount": amount, + "source_line_key": source_line_key, + "raw_payload_json": json.dumps(line_payload, ensure_ascii=False, sort_keys=True, default=_json_default), + }, + ) + task_lines += 1 + normalized_revisions += 1 + source_table_key = f"{source_database}.{source_table}" + source_table_counts[source_table_key] = source_table_counts.get(source_table_key, 0) + 1 + + summary_rows = conn.execute( + text( + """ + SELECT + r.budget_type, + COUNT(DISTINCT r.id) AS revision_count, + COALESCE(SUM(t.amount), 0) AS task_amount, + COALESCE(SUM(e.amount), 0) AS exec_amount + FROM satis_project_budget_revisions r + LEFT JOIN satis_project_task_plan_budget_lines t ON t.revision_id = r.id + LEFT JOIN satis_project_exec_budget_lines e ON e.revision_id = r.id + GROUP BY r.budget_type + ORDER BY r.budget_type + """ + ) + ).mappings().fetchall() + return { + "status": "ok", + "message": f"Satis 원본 금액 {normalized_revisions:,}건을 차수/상세 예산 테이블로 정규화했습니다.", + "raw_row_count": len(raw_rows), + "normalized_revisions": normalized_revisions, + "task_lines": task_lines, + "exec_lines": exec_lines, + "skipped_rows": skipped_rows, + "source_tables": [ + {"table": key, "row_count": count} + for key, count in sorted(source_table_counts.items(), key=lambda item: (-item[1], item[0]))[:20] + ], + "summary": [ + { + "budget_type": normalize_text(row.get("budget_type")), + "revision_count": int(row.get("revision_count") or 0), + "task_amount": float(row.get("task_amount") or 0), + "exec_amount": float(row.get("exec_amount") or 0), + } + for row in summary_rows + ], + } + + +def _is_satis_pending_approval_status(value: Any) -> bool: + status = normalize_text(value).lower() + if not status: + return False + pending_tokens = ("승인중", "승인 중", "결재중", "결재 중", "상신", "진행", "검토", "요청", "대기", "pending", "progress", "review") + rejected_tokens = ("반려", "취소", "삭제", "reject", "cancel", "deleted") + return any(token in status for token in pending_tokens) and not any(token in status for token in rejected_tokens) + + +def _satis_revision_sort_key(row: Mapping[str, Any]) -> tuple[float, int]: + revision_no_text = normalize_text(row.get("revision_no")) + numbers = re.findall(r"-?\d+(?:\.\d+)?", revision_no_text) + revision_value = float(numbers[-1]) if numbers else 0.0 + return (revision_value, int(row.get("id") or 0)) + + +def _select_satis_projection_revisions(conn: Any, include_pending: bool) -> list[dict[str, Any]]: + rows = [ + dict(row) + for row in conn.execute( + text( + """ + SELECT * + FROM satis_project_budget_revisions + WHERE support_dept_code <> '' + AND budget_type IN ('task_plan', 'exec_budget') + ORDER BY support_dept_code, budget_type, id + """ + ) + ).mappings().fetchall() + ] + grouped: dict[tuple[str, str], list[dict[str, Any]]] = {} + for row in rows: + budget_type = normalize_text(row.get("budget_type")) + target_type = "exec_budget" if budget_type == "exec_budget" else "task_plan" + key = (normalize_text(row.get("support_dept_code")), target_type) + grouped.setdefault(key, []).append(row) + + selected: list[dict[str, Any]] = [] + for (_support_dept_code, target_type), candidates in grouped.items(): + approved = [row for row in candidates if int(row.get("is_approved") or 0) == 1] + pending = [row for row in candidates if _is_satis_pending_approval_status(row.get("approval_status"))] + base = max(approved, key=_satis_revision_sort_key) if approved else None + pending_latest = max(pending, key=_satis_revision_sort_key) if pending else None + chosen = base + projection_mode = "approved_latest" + is_provisional = 0 + note = "" + if include_pending and pending_latest and ( + not base or _satis_revision_sort_key(pending_latest) >= _satis_revision_sort_key(base) + ): + chosen = pending_latest + projection_mode = "pending_provisional" + is_provisional = 1 + note = "승인 중인 최신 차수를 가반영했습니다. 승인 완료 후 재반영이 필요합니다." + elif not chosen and pending_latest and include_pending: + chosen = pending_latest + projection_mode = "pending_provisional" + is_provisional = 1 + note = "승인 완료 차수가 없어 승인 중인 차수를 가반영했습니다." + if not chosen: + continue + chosen["target_budget_type"] = target_type + chosen["projection_mode"] = projection_mode + chosen["is_provisional"] = is_provisional + chosen["projection_note"] = note + selected.append(chosen) + return selected + + +def _aggregate_satis_budget_entries_to_master_projects(conn: Any) -> dict[str, Any]: + master_rows = [ + dict(row) + for row in conn.execute( + text( + """ + SELECT DISTINCT + COALESCE(NULLIF(linked_main_project_code, ''), own_master_project_code) AS master_code + FROM satis_project_code_links + WHERE mapping_status IN ('confirmed', 'exception') + AND COALESCE(NULLIF(linked_main_project_code, ''), own_master_project_code) <> '' + AND COALESCE(NULLIF(linked_main_project_code, ''), own_master_project_code) GLOB '[09][0-9][0-9][0-9][0-9][0-9]' + UNION + SELECT support_dept_code AS master_code + FROM project_status + WHERE support_dept_code GLOB '[09][0-9][0-9][0-9][0-9][0-9]' + ORDER BY master_code + """ + ) + ).mappings().fetchall() + ] + aggregated_project_count = 0 + task_rows_inserted = 0 + exec_rows_inserted = 0 + status_rows: list[dict[str, Any]] = [] + + for master_row in master_rows: + master_code = normalize_text(master_row.get("master_code")) + if not master_code: + continue + source_codes = [ + normalize_text(row.get("local_project_code")) + for row in conn.execute( + text( + """ + SELECT local_project_code + FROM satis_project_code_links + WHERE mapping_status IN ('confirmed', 'exception') + AND local_project_code <> 'ZZZZZZ' + AND ( + own_master_project_code = :master_code + OR linked_main_project_code = :master_code + OR cost_project_code = :master_code + ) + ORDER BY + CASE WHEN local_project_code = :master_code THEN 0 ELSE 1 END, + local_project_code + """ + ), + {"master_code": master_code}, + ).mappings().fetchall() + if normalize_text(row.get("local_project_code")) + ] + if master_code not in source_codes: + source_codes.insert(0, master_code) + source_codes = list(dict.fromkeys(source_codes)) + + child_codes = [code for code in source_codes if code != master_code] + child_exec_count = int( + conn.execute( + text( + """ + SELECT COUNT(*) + FROM project_exec_budget_entries + WHERE support_dept_code IN :source_codes + AND COALESCE(source_support_dept_code, support_dept_code) = support_dept_code + """ + ).bindparams(bindparam("source_codes", expanding=True)), + {"source_codes": child_codes or ["__NO_SOURCE__"]}, + ).scalar() + or 0 + ) + child_task_count = int( + conn.execute( + text( + """ + SELECT COUNT(*) + FROM project_task_plan_entries + WHERE support_dept_code IN :source_codes + AND COALESCE(source_support_dept_code, support_dept_code) = support_dept_code + """ + ).bindparams(bindparam("source_codes", expanding=True)), + {"source_codes": child_codes or ["__NO_SOURCE__"]}, + ).scalar() + or 0 + ) + effective_exec_sources = child_codes if child_exec_count else [master_code] + effective_task_sources = child_codes if child_task_count else [master_code] + + exec_source_rows = [ + dict(row) + for row in conn.execute( + text( + """ + SELECT * + FROM project_exec_budget_entries + WHERE support_dept_code IN :source_codes + AND COALESCE(source_support_dept_code, support_dept_code) = support_dept_code + ORDER BY support_dept_code, position, id + """ + ).bindparams(bindparam("source_codes", expanding=True)), + {"source_codes": effective_exec_sources or ["__NO_SOURCE__"]}, + ).mappings().fetchall() + ] + task_source_rows = [ + dict(row) + for row in conn.execute( + text( + """ + SELECT * + FROM project_task_plan_entries + WHERE support_dept_code IN :source_codes + AND COALESCE(source_support_dept_code, support_dept_code) = support_dept_code + ORDER BY support_dept_code, position, id + """ + ).bindparams(bindparam("source_codes", expanding=True)), + {"source_codes": effective_task_sources or ["__NO_SOURCE__"]}, + ).mappings().fetchall() + ] + if not exec_source_rows and not task_source_rows: + continue + + conn.execute( + text("DELETE FROM project_exec_budget_entries WHERE support_dept_code = :master_code"), + {"master_code": master_code}, + ) + conn.execute( + text("DELETE FROM project_task_plan_entries WHERE support_dept_code = :master_code"), + {"master_code": master_code}, + ) + + exec_amount_total = 0.0 + for position, row in enumerate(exec_source_rows): + source_support_dept_code = normalize_text(row.get("source_support_dept_code")) or normalize_text(row.get("support_dept_code")) + amount = normalize_amount(row.get("amount")) + conn.execute( + text( + """ + INSERT INTO project_exec_budget_entries ( + support_dept_code, position, group_name, grade, hours, rate_year, + dept_name, work_name, account_code, account_name, amount, + source_support_dept_code, source_project_code, source_revision_id, updated_at + ) VALUES ( + :support_dept_code, :position, :group_name, :grade, :hours, :rate_year, + :dept_name, :work_name, :account_code, :account_name, :amount, + :source_support_dept_code, :source_project_code, :source_revision_id, CURRENT_TIMESTAMP + ) + """ + ), + { + "support_dept_code": master_code, + "position": position, + "group_name": normalize_text(row.get("group_name")), + "grade": normalize_text(row.get("grade")), + "hours": normalize_text(row.get("hours")), + "rate_year": normalize_text(row.get("rate_year")), + "dept_name": normalize_text(row.get("dept_name")), + "work_name": normalize_text(row.get("work_name")), + "account_code": normalize_text(row.get("account_code")), + "account_name": normalize_text(row.get("account_name")), + "amount": amount, + "source_support_dept_code": source_support_dept_code, + "source_project_code": normalize_text(row.get("source_project_code")), + "source_revision_id": int(row.get("source_revision_id") or 0), + }, + ) + exec_amount_total += amount + exec_rows_inserted += 1 + + task_amount_total = 0.0 + for position, row in enumerate(task_source_rows): + source_support_dept_code = normalize_text(row.get("source_support_dept_code")) or normalize_text(row.get("support_dept_code")) + amount = normalize_amount(row.get("amount")) + conn.execute( + text( + """ + INSERT INTO project_task_plan_entries ( + support_dept_code, position, group_name, dept_name, work_name, amount, + source_support_dept_code, source_project_code, source_revision_id, updated_at + ) VALUES ( + :support_dept_code, :position, :group_name, :dept_name, :work_name, :amount, + :source_support_dept_code, :source_project_code, :source_revision_id, CURRENT_TIMESTAMP + ) + """ + ), + { + "support_dept_code": master_code, + "position": position, + "group_name": normalize_text(row.get("group_name")), + "dept_name": normalize_text(row.get("dept_name")), + "work_name": normalize_text(row.get("work_name")), + "amount": amount, + "source_support_dept_code": source_support_dept_code, + "source_project_code": normalize_text(row.get("source_project_code")), + "source_revision_id": int(row.get("source_revision_id") or 0), + }, + ) + task_amount_total += amount + task_rows_inserted += 1 + + if exec_source_rows: + actual_exec_source_codes = list( + dict.fromkeys( + normalize_text(row.get("source_support_dept_code")) or normalize_text(row.get("support_dept_code")) + for row in exec_source_rows + if normalize_text(row.get("source_support_dept_code")) or normalize_text(row.get("support_dept_code")) + ) + ) + conn.execute( + text( + """ + INSERT INTO satis_project_budget_projection_status ( + support_dept_code, project_code, project_name, budget_type, + revision_id, revision_no, approval_status, projection_mode, + is_provisional, line_count, amount_total, note, projected_at + ) VALUES ( + :support_dept_code, :project_code, :project_name, 'exec_budget', + 0, '', '', 'master_aggregate', + 0, :line_count, :amount_total, :note, CURRENT_TIMESTAMP + ) + ON CONFLICT(support_dept_code, budget_type) DO UPDATE SET + project_code = excluded.project_code, + project_name = excluded.project_name, + revision_id = excluded.revision_id, + revision_no = excluded.revision_no, + approval_status = excluded.approval_status, + projection_mode = excluded.projection_mode, + is_provisional = excluded.is_provisional, + line_count = excluded.line_count, + amount_total = excluded.amount_total, + note = excluded.note, + projected_at = CURRENT_TIMESTAMP + """ + ), + { + "support_dept_code": master_code, + "project_code": master_code, + "project_name": "", + "line_count": len(exec_source_rows), + "amount_total": exec_amount_total, + "note": f"연계 차수 프로젝트 예산 합산: {', '.join(actual_exec_source_codes)}", + }, + ) + status_rows.append( + { + "support_dept_code": master_code, + "budget_type": "exec_budget", + "line_count": len(exec_source_rows), + "amount_total": exec_amount_total, + "source_codes": actual_exec_source_codes, + } + ) + if task_source_rows: + actual_task_source_codes = list( + dict.fromkeys( + normalize_text(row.get("source_support_dept_code")) or normalize_text(row.get("support_dept_code")) + for row in task_source_rows + if normalize_text(row.get("source_support_dept_code")) or normalize_text(row.get("support_dept_code")) + ) + ) + conn.execute( + text( + """ + INSERT INTO satis_project_budget_projection_status ( + support_dept_code, project_code, project_name, budget_type, + revision_id, revision_no, approval_status, projection_mode, + is_provisional, line_count, amount_total, note, projected_at + ) VALUES ( + :support_dept_code, :project_code, :project_name, 'task_plan', + 0, '', '', 'master_aggregate', + 0, :line_count, :amount_total, :note, CURRENT_TIMESTAMP + ) + ON CONFLICT(support_dept_code, budget_type) DO UPDATE SET + project_code = excluded.project_code, + project_name = excluded.project_name, + revision_id = excluded.revision_id, + revision_no = excluded.revision_no, + approval_status = excluded.approval_status, + projection_mode = excluded.projection_mode, + is_provisional = excluded.is_provisional, + line_count = excluded.line_count, + amount_total = excluded.amount_total, + note = excluded.note, + projected_at = CURRENT_TIMESTAMP + """ + ), + { + "support_dept_code": master_code, + "project_code": master_code, + "project_name": "", + "line_count": len(task_source_rows), + "amount_total": task_amount_total, + "note": f"연계 차수 프로젝트 예산 합산: {', '.join(actual_task_source_codes)}", + }, + ) + status_rows.append( + { + "support_dept_code": master_code, + "budget_type": "task_plan", + "line_count": len(task_source_rows), + "amount_total": task_amount_total, + "source_codes": actual_task_source_codes, + } + ) + aggregated_project_count += 1 + sync_project_status_cache_row(conn, master_code) + + return { + "aggregated_project_count": aggregated_project_count, + "task_rows_inserted": task_rows_inserted, + "exec_rows_inserted": exec_rows_inserted, + "status_rows": status_rows[:50], + } + + +def _project_satis_budget_to_current_entries(payload: dict[str, Any] | None = None) -> dict[str, Any]: + payload = payload or {} + include_pending = bool(payload.get("include_pending", True)) + init_db() + selected_count = 0 + projected_projects: set[str] = set() + task_rows_inserted = 0 + exec_rows_inserted = 0 + provisional_count = 0 + skipped: list[dict[str, Any]] = [] + projected_status_rows: list[dict[str, Any]] = [] + + with engine.begin() as conn: + selected_revisions = _select_satis_projection_revisions(conn, include_pending) + target_keys = { + ( + normalize_text(row.get("support_dept_code")), + normalize_text(row.get("target_budget_type")), + ) + for row in selected_revisions + if normalize_text(row.get("support_dept_code")) + } + for support_dept_code, target_budget_type in sorted(target_keys): + if target_budget_type == "exec_budget": + conn.execute( + text("DELETE FROM project_exec_budget_entries WHERE support_dept_code = :support_dept_code"), + {"support_dept_code": support_dept_code}, + ) + elif target_budget_type == "task_plan": + conn.execute( + text("DELETE FROM project_task_plan_entries WHERE support_dept_code = :support_dept_code"), + {"support_dept_code": support_dept_code}, + ) + + for revision in selected_revisions: + revision_id = int(revision.get("id") or 0) + support_dept_code = normalize_text(revision.get("support_dept_code")) + if not revision_id or not support_dept_code: + skipped.append({"revision_id": revision_id, "reason": "프로젝트 매핑이 없습니다."}) + continue + target_budget_type = normalize_text(revision.get("target_budget_type")) + selected_count += 1 + projected_projects.add(support_dept_code) + if int(revision.get("is_provisional") or 0): + provisional_count += 1 + + line_count = 0 + amount_total = 0.0 + if target_budget_type == "exec_budget": + lines = conn.execute( + text( + """ + SELECT * + FROM satis_project_exec_budget_lines + WHERE revision_id = :revision_id + ORDER BY line_no, id + """ + ), + {"revision_id": revision_id}, + ).mappings().fetchall() + for position, line in enumerate(lines): + amount = normalize_amount(line.get("amount")) + conn.execute( + text( + """ + INSERT INTO project_exec_budget_entries ( + support_dept_code, position, group_name, grade, hours, rate_year, + dept_name, work_name, account_code, account_name, amount, + source_support_dept_code, source_project_code, source_revision_id, updated_at + ) VALUES ( + :support_dept_code, :position, :group_name, :grade, :hours, :rate_year, + :dept_name, :work_name, :account_code, :account_name, :amount, + :source_support_dept_code, :source_project_code, :source_revision_id, CURRENT_TIMESTAMP + ) + """ + ), + { + "support_dept_code": support_dept_code, + "position": position, + "group_name": normalize_text(line.get("group_name")) or "satis_exec_budget", + "grade": normalize_text(line.get("grade")), + "hours": normalize_text(line.get("hours")), + "rate_year": normalize_text(line.get("rate_year")), + "dept_name": normalize_text(line.get("dept_name")), + "work_name": normalize_text(line.get("work_name")), + "account_code": normalize_text(line.get("account_code")), + "account_name": normalize_text(line.get("account_name")), + "amount": amount, + "source_support_dept_code": support_dept_code, + "source_project_code": normalize_text(revision.get("project_code")), + "source_revision_id": revision_id, + }, + ) + line_count += 1 + amount_total += amount + exec_rows_inserted += 1 + else: + lines = conn.execute( + text( + """ + SELECT * + FROM satis_project_task_plan_budget_lines + WHERE revision_id = :revision_id + ORDER BY line_no, id + """ + ), + {"revision_id": revision_id}, + ).mappings().fetchall() + for position, line in enumerate(lines): + amount = normalize_amount(line.get("amount")) + conn.execute( + text( + """ + INSERT INTO project_task_plan_entries ( + support_dept_code, position, group_name, dept_name, work_name, amount, + source_support_dept_code, source_project_code, source_revision_id, updated_at + ) VALUES ( + :support_dept_code, :position, :group_name, :dept_name, :work_name, :amount, + :source_support_dept_code, :source_project_code, :source_revision_id, CURRENT_TIMESTAMP + ) + """ + ), + { + "support_dept_code": support_dept_code, + "position": position, + "group_name": normalize_text(line.get("group_name")) or "satis_task_plan", + "dept_name": normalize_text(line.get("dept_name")), + "work_name": normalize_text(line.get("work_name")), + "amount": amount, + "source_support_dept_code": support_dept_code, + "source_project_code": normalize_text(revision.get("project_code")), + "source_revision_id": revision_id, + }, + ) + line_count += 1 + amount_total += amount + task_rows_inserted += 1 + + conn.execute( + text( + """ + INSERT INTO satis_project_budget_projection_status ( + support_dept_code, project_code, project_name, budget_type, + revision_id, revision_no, approval_status, projection_mode, + is_provisional, line_count, amount_total, note, projected_at + ) VALUES ( + :support_dept_code, :project_code, :project_name, :budget_type, + :revision_id, :revision_no, :approval_status, :projection_mode, + :is_provisional, :line_count, :amount_total, :note, CURRENT_TIMESTAMP + ) + ON CONFLICT(support_dept_code, budget_type) DO UPDATE SET + project_code = excluded.project_code, + project_name = excluded.project_name, + revision_id = excluded.revision_id, + revision_no = excluded.revision_no, + approval_status = excluded.approval_status, + projection_mode = excluded.projection_mode, + is_provisional = excluded.is_provisional, + line_count = excluded.line_count, + amount_total = excluded.amount_total, + note = excluded.note, + projected_at = CURRENT_TIMESTAMP + """ + ), + { + "support_dept_code": support_dept_code, + "project_code": normalize_text(revision.get("project_code")), + "project_name": normalize_text(revision.get("project_name")), + "budget_type": target_budget_type, + "revision_id": revision_id, + "revision_no": normalize_text(revision.get("revision_no")), + "approval_status": normalize_text(revision.get("approval_status")), + "projection_mode": normalize_text(revision.get("projection_mode")), + "is_provisional": int(revision.get("is_provisional") or 0), + "line_count": line_count, + "amount_total": amount_total, + "note": normalize_text(revision.get("projection_note")), + }, + ) + projected_status_rows.append( + { + "support_dept_code": support_dept_code, + "project_code": normalize_text(revision.get("project_code")), + "budget_type": target_budget_type, + "revision_no": normalize_text(revision.get("revision_no")), + "approval_status": normalize_text(revision.get("approval_status")), + "projection_mode": normalize_text(revision.get("projection_mode")), + "line_count": line_count, + "amount_total": amount_total, + } + ) + for support_dept_code in sorted(projected_projects): + sync_project_status_cache_row(conn, support_dept_code) + aggregate_result = _aggregate_satis_budget_entries_to_master_projects(conn) + + return { + "status": "ok", + "message": f"승인 최신 차수 기준으로 {len(projected_projects):,}개 프로젝트의 Satis 예산을 기존 입력 테이블에 반영했습니다.", + "include_pending": include_pending, + "selected_revisions": selected_count, + "project_count": len(projected_projects), + "task_rows_inserted": task_rows_inserted, + "exec_rows_inserted": exec_rows_inserted, + "provisional_count": provisional_count, + "skipped": skipped[:20], + "projected": projected_status_rows[:50], + "master_aggregate": aggregate_result, + "projection_status_table": "satis_project_budget_projection_status", + } + + +def _run_satis_budget_full_sync(payload: dict[str, Any]) -> dict[str, Any]: + _hanmac_erp_web_credentials(payload) + _hanmac_erp_mysql_credentials(payload) + + raw_result = _sync_satis_budget_raw_rows(payload) + normalize_result = _normalize_satis_budget_raw_rows({}) + projection_result = _project_satis_budget_to_current_entries({"include_pending": True}) + return { + "status": "ok", + "message": "Satis 예산 원본 저장, 정규화, 승인 최신 차수 반영을 순차 실행했습니다.", + "raw": raw_result, + "normalize": normalize_result, + "projection": projection_result, + "inserted_or_updated_rows": int(raw_result.get("inserted_or_updated_rows") or 0), + "normalized_revisions": int(normalize_result.get("normalized_revisions") or 0), + "project_count": int(projection_result.get("project_count") or 0), + "task_rows_inserted": int(projection_result.get("task_rows_inserted") or 0), + "exec_rows_inserted": int(projection_result.get("exec_rows_inserted") or 0), + "provisional_count": int(projection_result.get("provisional_count") or 0), + } + + +HANMAC_SATIS_WEB_BUDGET_KEYWORDS = ( + "프로젝트개요관리", + "과업수행계획서작성", + "실행계획서작성", + "과업수행계획서변경차수관리", + "과업수행계획서", + "실행계획서", + "예산", + "승인", + "차수", +) + + +def _extract_satis_web_links(base_url: str, body: str) -> list[str]: + links: set[str] = set() + static_ext_pattern = re.compile(r"\.(?:css|js|png|gif|jpg|jpeg|ico|bmp|svg|woff|ttf)(?:$|\?)", re.IGNORECASE) + for match in re.findall(r"""(?:href|src|action)\s*=\s*["']([^"']+)["']""", body, flags=re.IGNORECASE): + link = normalize_text(match) + if not link or link.startswith(("#", "javascript:", "mailto:")): + continue + absolute = urljoin(base_url, link) + if static_ext_pattern.search(urlparse(absolute).path): + continue + parsed = urlparse(absolute) + if parsed.netloc and parsed.netloc != "erp.hanmaceng.co.kr": + continue + if "/satis/" in parsed.path.lower() or absolute.lower().startswith(HANMAC_SATIS_ERP_BASE_URL.lower()): + links.add(absolute) + for match in re.findall(r"""(?:open|go|url|href|src|action)[A-Za-z0-9_]*\s*\(\s*["']([^"']+)["']""", body, flags=re.IGNORECASE): + link = normalize_text(match) + if not link or link.startswith(("#", "javascript:", "mailto:")): + continue + absolute = urljoin(base_url, link) + if static_ext_pattern.search(urlparse(absolute).path): + continue + parsed = urlparse(absolute) + if parsed.netloc and parsed.netloc != "erp.hanmaceng.co.kr": + continue + if "/satis/" in parsed.path.lower() or "controller" in parsed.path.lower(): + links.add(absolute) + return sorted(links) + + +def _extract_satis_amount_candidates(body: str, limit: int = 80) -> list[dict[str, Any]]: + candidates: list[dict[str, Any]] = [] + compact = re.sub(r"\s+", " ", body) + for match in re.finditer(r"(?= limit: + break + return candidates + + +def _extract_satis_menu_items(body: str) -> list[dict[str, Any]]: + menu_items: list[dict[str, Any]] = [] + for match in re.finditer(r"""var\s+(list_data\d*)\s*=\s*jQuery\.parseJSON\(\s*'(.+?)'\s*\);""", body, flags=re.IGNORECASE | re.DOTALL): + variable_name = normalize_text(match.group(1)) + raw_json = match.group(2) + try: + items = json.loads(raw_json) + except Exception: + try: + items = json.loads(raw_json.encode("utf-8").decode("unicode_escape")) + except Exception: + continue + if not isinstance(items, list): + continue + for item in items: + if not isinstance(item, dict): + continue + name = normalize_text(item.get("item04")) + primary_url = normalize_text(item.get("item05")) + secondary_url = normalize_text(item.get("item07")) + if not name and not primary_url and not secondary_url: + continue + menu_items.append( + { + "source_variable": variable_name, + "category1": normalize_text(item.get("item01")), + "category2": normalize_text(item.get("item02")), + "category3": normalize_text(item.get("item03")), + "name": name, + "primary_url": primary_url, + "secondary_url": secondary_url, + "screen_id": normalize_text(item.get("item08")), + "menu_code": normalize_text(item.get("item10")), + } + ) + return menu_items + + +def _extract_satis_form_defaults(body: str) -> dict[str, str]: + defaults: dict[str, str] = {} + for match in re.finditer(r"]+>", body, flags=re.IGNORECASE): + tag = match.group(0) + name_match = re.search(r"""name\s*=\s*["']?([^"'\s>]+)""", tag, flags=re.IGNORECASE) + if not name_match: + continue + value_match = re.search(r"""value\s*=\s*["']([^"']*)["']""", tag, flags=re.IGNORECASE) + if not value_match: + value_match = re.search(r"""value\s*=\s*([^"'\s>]*)""", tag, flags=re.IGNORECASE) + defaults[name_match.group(1)] = value_match.group(1) if value_match else "" + for match in re.finditer(r"]*)>(.*?)", body, flags=re.IGNORECASE | re.DOTALL): + attributes, options_html = match.groups() + name_match = re.search(r"""name\s*=\s*["']?([^"'\s>]+)""", attributes, flags=re.IGNORECASE) + if not name_match: + continue + option_matches = list( + re.finditer(r"]*)>(.*?)", options_html, flags=re.IGNORECASE | re.DOTALL) + ) + if not option_matches: + defaults[name_match.group(1)] = "" + continue + selected_option = next( + (option for option in option_matches if re.search(r"\bselected\b", option.group(1), flags=re.IGNORECASE)), + option_matches[0], + ) + option_attributes, option_label = selected_option.groups() + value_match = re.search( + r"""value\s*=\s*(?:"([^"]*)"|'([^']*)'|([^"'\s>]*))""", + option_attributes, + flags=re.IGNORECASE, + ) + if value_match: + defaults[name_match.group(1)] = next( + (value for value in value_match.groups() if value is not None), + "", + ) + else: + defaults[name_match.group(1)] = re.sub(r"<[^>]+>", "", option_label).strip() + return defaults + + +def _relax_satis_search_defaults(action_mode: str, main_action: str, defaults: Mapping[str, Any]) -> dict[str, str]: + relaxed = {normalize_text(key): normalize_text(value) for key, value in defaults.items() if normalize_text(key)} + if action_mode == "SCREEN_01" and main_action == "Ajax_01": + # 프로젝트 개요 화면의 초기 선택값은 사용자의 부서/진행 사업으로 제한된다. + # 예산 전체 수집에서는 ERP 화면이 제공하는 '%' 값을 사용해 과거·종료 사업도 조회한다. + for key in ("input_select_01", "input_select_02", "input_select_03", "input_select_04", "input_select_05"): + if key in relaxed: + relaxed[key] = "%" + for key in ("input_item_01", "input_item_02", "input_item_03"): + if key in relaxed: + relaxed[key] = "" + return relaxed + + +def _extract_satis_ajax_actions(body: str) -> list[str]: + actions = set(re.findall(r"""["']((?:HTML_)?Ajax_\d+|HTML_Page_\d+|Plan_chg)["']""", body)) + actions.update(re.findall(r"""MainAction\s*=\s*["']((?:HTML_)?Ajax_\d+|HTML_Page_\d+|Plan_chg)["']""", body)) + actions.update(re.findall(r"""MainAction['"]?\s*:\s*["']((?:HTML_)?Ajax_\d+|HTML_Page_\d+|Plan_chg)["']""", body)) + return sorted(actions) + + +def _build_satis_capture( + *, + source_url: str, + request_method: str, + http_status: int, + final_url: str, + body: str, +) -> dict[str, Any]: + title_match = re.search(r"]*>(.*?)", body, flags=re.IGNORECASE | re.DOTALL) + title = re.sub(r"\s+", " ", title_match.group(1)).strip() if title_match else "" + matched_keywords = [keyword for keyword in HANMAC_SATIS_WEB_BUDGET_KEYWORDS if keyword in body or keyword in title or keyword in final_url] + links = _extract_satis_web_links(final_url or source_url, body) + amount_candidates = _extract_satis_amount_candidates(body) + body_hash = hashlib.sha256(body.encode("utf-8", "replace")).hexdigest() + capture_key = hashlib.sha256(f"{request_method}|{source_url}|{final_url}|{body_hash}".encode("utf-8")).hexdigest() + return { + "capture_key": capture_key, + "source_url": source_url, + "request_method": request_method, + "http_status": http_status, + "final_url": final_url, + "page_title": title[:300], + "matched_keywords": ", ".join(matched_keywords), + "internal_links": links[:80], + "amount_candidates": amount_candidates[:80], + "body_preview": body[:2_000_000], + "body_hash": body_hash, + } + + +def _json_loads_loose(value: str) -> Any: + text_value = normalize_text(value) + if not text_value: + return None + try: + return json.loads(text_value) + except Exception: + pass + start_candidates = [idx for idx in (text_value.find("["), text_value.find("{")) if idx >= 0] + if not start_candidates: + return None + start = min(start_candidates) + end = max(text_value.rfind("]"), text_value.rfind("}")) + if end <= start: + return None + try: + return json.loads(text_value[start : end + 1]) + except Exception: + return None + + +def _flatten_satis_json_rows(parsed: Any) -> list[dict[str, Any]]: + if isinstance(parsed, list): + return [item for item in parsed if isinstance(item, dict)] + if not isinstance(parsed, dict): + return [] + for key in ("rows", "data", "list_data", "list", "records"): + value = parsed.get(key) + if isinstance(value, list): + rows: list[dict[str, Any]] = [] + for item in value: + if isinstance(item, dict): + if isinstance(item.get("cell"), list): + rows.append({f"cell{idx + 1:02d}": cell for idx, cell in enumerate(item["cell"])}) + else: + rows.append(item) + return rows + return [parsed] if parsed else [] + + +def _infer_satis_web_budget_type(action_mode: str, main_action: str, source_url: str, row: Mapping[str, Any]) -> str: + haystack = " ".join( + [ + normalize_text(action_mode), + normalize_text(main_action), + normalize_text(source_url), + " ".join(normalize_text(value) for value in row.values()), + ] + ) + if "SCREEN_02" in haystack or "실행계획" in haystack or "exec" in haystack.lower(): + return "exec_budget" + if "SCREEN_01" in haystack or "프로젝트개요" in haystack: + return "project_overview" + if "과업수행" in haystack or "SCREEN_03" in haystack or "SCREEN_06" in haystack: + return "task_plan" + return "project_budget" + + +def _pick_satis_web_value(row: Mapping[str, Any], keys: Sequence[str]) -> str: + lowered = {normalize_text(key).lower(): value for key, value in row.items()} + for key in keys: + key_l = key.lower() + if key_l in lowered: + return normalize_text(lowered.get(key_l)) + return "" + + +def _promote_satis_web_captures_to_raw_rows(captures: Sequence[Mapping[str, Any]]) -> dict[str, Any]: + inserted_or_updated = 0 + parsed_capture_count = 0 + skipped_capture_count = 0 + with engine.begin() as conn: + conn.execute(text("DELETE FROM satis_project_budget_raw_rows WHERE source_database = 'satis_web'")) + for capture in captures: + body = normalize_text(capture.get("body_preview")) + parsed = _json_loads_loose(body) + rows = _flatten_satis_json_rows(parsed) + if not rows: + skipped_capture_count += 1 + continue + parsed_capture_count += 1 + source_url = normalize_text(capture.get("source_url")) + query = parse_qs(urlparse(source_url).query) + action_mode = normalize_text((query.get("ActionMode") or [""])[0]) + main_action = normalize_text((query.get("MainAction") or [""])[0]) + source_table = f"{action_mode or 'WEB'}:{main_action or normalize_text(capture.get('request_method')) or 'response'}" + for row_index, row in enumerate(rows): + normalized_row = {normalize_text(key): value for key, value in row.items() if normalize_text(key)} + if action_mode == "SCREEN_01" and main_action in ("Ajax_01", "HTML_Ajax_01"): + project_code = _pick_satis_web_value(normalized_row, ("view02", "view28", "item02", "item01")) + project_name = _pick_satis_web_value(normalized_row, ("view04", "item04", "item02")) + revision_no = _pick_satis_web_value(normalized_row, ("item17",)) + approval_status = _pick_satis_web_value(normalized_row, ("item18",)) + preferred_amount_keys = ("item10",) + elif action_mode == "SCREEN_03" and main_action == "HTML_Ajax_01": + project_code = _pick_satis_web_value(normalized_row, ("view02", "view28", "item03", "item02")) + project_name = _pick_satis_web_value(normalized_row, ("view04", "item04")) + revision_no = _pick_satis_web_value(normalized_row, ("item17",)) + approval_status = _pick_satis_web_value(normalized_row, ("item18",)) + preferred_amount_keys = ("item10",) + elif action_mode == "SCREEN_03" and main_action in ("Ajax_02", "Ajax_03"): + project_code = _pick_satis_web_value(normalized_row, ("project_code", "proj_code", "item02", "item01")) + project_name = _pick_satis_web_value(normalized_row, ("project_name", "proj_name")) + revision_no = _pick_satis_web_value(normalized_row, ("degree", "item03", "item04")) + approval_status = " ".join( + value + for value in ( + _pick_satis_web_value(normalized_row, ("item07",)), + f"확정:{_pick_satis_web_value(normalized_row, ('item08',))}" + if _pick_satis_web_value(normalized_row, ("item08",)) + else "", + ) + if value + ) + preferred_amount_keys = ("item12", "item13", "item14") if main_action == "Ajax_03" else () + elif action_mode == "SCREEN_06" and main_action == "Ajax_00": + project_code = _pick_satis_web_value(normalized_row, ("item02",)) + project_name = "" + revision_no = _pick_satis_web_value(normalized_row, ("item03",)) + approval_status = "" + preferred_amount_keys = ("item10", "item40") + elif action_mode == "SCREEN_06" and main_action == "Ajax_03": + project_code = _pick_satis_web_value(normalized_row, ("item02",)) + project_name = "" + revision_no = _pick_satis_web_value(normalized_row, ("item03",)) + approval_status = "" + preferred_amount_keys = ("item08", "item09") + elif action_mode == "SCREEN_02" and main_action in ("Ajax_01", "Ajax_02", "Ajax_03", "Ajax_04"): + project_code = _pick_satis_web_value( + normalized_row, + ("item01",) if main_action == "Ajax_03" else ("item02",), + ) + project_name = "" + revision_no = _pick_satis_web_value( + normalized_row, + ("item02",) if main_action == "Ajax_03" else ("item03",), + ) + approval_status = "" + preferred_amount_keys = { + "Ajax_01": ("item08",), + "Ajax_02": ("item08",), + "Ajax_03": ("item07",), + "Ajax_04": ("item08",), + }[main_action] + else: + project_code = _pick_satis_web_value( + normalized_row, + ("project_code", "proj_code", "proj_cd", "view02", "view28", "item02", "cell02"), + ) + project_name = _pick_satis_web_value( + normalized_row, + ("project_name", "proj_name", "proj_nm", "view04", "item03", "cell03"), + ) + revision_no = _pick_satis_web_value( + normalized_row, + ("revision_no", "degree", "cha", "item01", "item02", "cell01", "cell02"), + ) + approval_status = _pick_satis_web_value( + normalized_row, + ("approval_status", "status", "state", "item07", "item12", "cell07"), + ) + preferred_amount_keys = () + project_code = project_code or normalize_text( + (query.get("proj_code") or query.get("input_item_01") or [""])[0] + ) + project_name = project_name or normalize_text((query.get("input_item_02") or [""])[0]) + revision_no = revision_no or normalize_text( + (query.get("degree") or query.get("input_item_03") or [""])[0] + ) + approval_status = approval_status or normalize_text((query.get("input_item_04") or [""])[0]) + amount_values = { + key: _safe_float(value) + for key, value in normalized_row.items() + if _safe_float(value) != 0 and re.search(r"(amount|amt|price|cost|money|budget|sum|total|금액|예산|합계|계약|수금|잔액|item0[4-9]|item1[0-9]|view)", key, flags=re.IGNORECASE) + } + if preferred_amount_keys: + amount_values = { + key: _safe_float(normalized_row.get(key)) + for key in preferred_amount_keys + if _safe_float(normalized_row.get(key)) != 0 + } + if ( + (action_mode == "SCREEN_03" and main_action == "Ajax_02") + or (action_mode == "SCREEN_06" and main_action in ("Ajax_01", "Ajax_02", "Ajax_03", "Ajax_04", "Ajax_05")) + or (action_mode == "SCREEN_02" and main_action in ("Ajax_02", "Ajax_05")) + ): + amount_values = {} + amount_total = sum(amount_values.values()) + is_revision_metadata = action_mode == "SCREEN_03" and main_action == "Ajax_02" and project_code + if amount_total == 0 and not is_revision_metadata and not any( + "합계" in normalize_text(value) or "계약" in normalize_text(value) + for value in normalized_row.values() + ): + continue + budget_type = _infer_satis_web_budget_type(action_mode, main_action, source_url, normalized_row) + if action_mode == "SCREEN_03" and main_action == "Ajax_03": + budget_type = "project_overview" + inferred_columns = { + "source_url": source_url, + "request_method": normalize_text(capture.get("request_method")), + "action_mode": action_mode, + "main_action": main_action, + "amount_columns": sorted(amount_values.keys()), + "web_capture_id": capture.get("id"), + } + raw_payload_json = json.dumps(normalized_row, ensure_ascii=False, sort_keys=True, default=_json_default) + source_hash = hashlib.sha256( + json.dumps( + { + "source": "satis_web", + "url": source_url, + "method": normalize_text(capture.get("request_method")), + "row": normalized_row, + }, + ensure_ascii=False, + sort_keys=True, + default=_json_default, + ).encode("utf-8") + ).hexdigest() + conn.execute( + text( + """ + INSERT OR REPLACE INTO satis_project_budget_raw_rows ( + source_system, source_database, source_table, source_row_index, + budget_type, project_code, project_name, revision_no, approval_status, + amount_total, amount_values_json, inferred_columns_json, + raw_payload_json, source_hash, synced_at + ) VALUES ( + 'satis', 'satis_web', :source_table, :source_row_index, + :budget_type, :project_code, :project_name, :revision_no, :approval_status, + :amount_total, :amount_values_json, :inferred_columns_json, + :raw_payload_json, :source_hash, CURRENT_TIMESTAMP + ) + """ + ), + { + "source_table": source_table, + "source_row_index": row_index, + "budget_type": budget_type, + "project_code": project_code, + "project_name": project_name, + "revision_no": revision_no, + "approval_status": approval_status, + "amount_total": amount_total, + "amount_values_json": json.dumps(amount_values, ensure_ascii=False, sort_keys=True, default=_json_default), + "inferred_columns_json": json.dumps(inferred_columns, ensure_ascii=False, sort_keys=True, default=_json_default), + "raw_payload_json": raw_payload_json, + "source_hash": source_hash, + }, + ) + inserted_or_updated += 1 + return { + "parsed_capture_count": parsed_capture_count, + "skipped_capture_count": skipped_capture_count, + "inserted_or_updated_rows": inserted_or_updated, + } + + +def _target_satis_budget_menu_items(menu_items: list[dict[str, Any]]) -> list[dict[str, Any]]: + targets: list[dict[str, Any]] = [] + target_names = ("프로젝트개요관리", "과업수행계획서작성", "실행계획서작성", "과업수행계획서변경차수관리") + for item in menu_items: + haystack = " ".join( + normalize_text(item.get(key)) + for key in ("category1", "category2", "category3", "name", "primary_url", "secondary_url", "screen_id") + ) + if any(target in haystack for target in target_names): + targets.append(item) + continue + if "프로젝트" in haystack and any(token in haystack for token in ("과업수행", "실행계획", "예산", "변경차수")): + targets.append(item) + return targets + + +def _satis_web_login_opener(user: str, password: str) -> tuple[Any, CookieJar, dict[str, Any]]: + cookie_jar = CookieJar() + opener = build_opener(HTTPCookieProcessor(cookie_jar)) + login_info: dict[str, Any] = {} + login_page_request = UrlRequest( + HANMAC_SATIS_ERP_LOGIN_PAGE_URL, + headers={"User-Agent": "my-intranet-app/satis-web-budget-collector"}, + ) + with opener.open(login_page_request, timeout=10) as response: + login_info["login_page_status"] = int(getattr(response, "status", 200) or 200) + response.read(1000) + + check_request = UrlRequest( + f"{HANMAC_SATIS_ERP_LOGIN_CONTROLLER_URL}?ActionMode=SCREEN_10&MainAction=Ajax_01&SubAction=checktype04&item02=&item03=&userid={quote_plus(user)}&password={quote_plus(password)}", + data=b"", + headers={"User-Agent": "my-intranet-app/satis-web-budget-collector"}, + ) + try: + with opener.open(check_request, timeout=10) as response: + login_info["credential_check_status"] = int(getattr(response, "status", 200) or 200) + login_info["credential_check_body"] = response.read(2000).decode("utf-8", "replace").strip()[:200] + except Exception as exc: + login_info["credential_check_error"] = str(exc) + + login_request = UrlRequest( + HANMAC_SATIS_ERP_LOGIN_CONTROLLER_URL, + data=urlencode( + { + "ActionMode": "SCREEN_10", + "MainAction": "Ajax_02", + "SubAction": "checktype05", + "userid": user, + "password": password, + "item02": "", + "item03": password, + } + ).encode("utf-8"), + headers={ + "Content-Type": "application/x-www-form-urlencoded", + "User-Agent": "my-intranet-app/satis-web-budget-collector", + }, + ) + with opener.open(login_request, timeout=10) as response: + login_info["login_status"] = int(getattr(response, "status", 200) or 200) + login_info["login_url"] = str(response.geturl() or "") + login_info["login_body_preview"] = response.read(2_000_000).decode("utf-8", "replace")[:500_000] + login_info["php_session"] = any(cookie.name == "PHPSESSID" for cookie in cookie_jar) + return opener, cookie_jar, login_info + + +def _collect_satis_budget_via_web(payload: dict[str, Any]) -> dict[str, Any]: + user, password = _hanmac_erp_web_credentials(payload) + try: + max_pages = max(5, min(int(payload.get("max_pages") or 40), 120)) + except Exception: + max_pages = 40 + + opener, _cookie_jar, login_info = _satis_web_login_opener(user, password) + seed_urls = [ + HANMAC_SATIS_ERP_BASE_URL, + HANMAC_SATIS_ERP_LOGIN_CONTROLLER_URL, + f"{HANMAC_SATIS_ERP_BASE_URL}sys/controller/main_controller.php", + f"{HANMAC_SATIS_ERP_BASE_URL}sys/controller/Main/Main_controller.php", + ] + login_body_preview = normalize_text(login_info.get("login_body_preview")) + seed_urls.extend(_extract_satis_web_links(HANMAC_SATIS_ERP_LOGIN_CONTROLLER_URL, login_body_preview)[:20]) + menu_items = _extract_satis_menu_items(login_body_preview) + target_menu_items = _target_satis_budget_menu_items(menu_items) + for item in target_menu_items: + for key in ("primary_url", "secondary_url"): + url = normalize_text(item.get(key)) + if url: + seed_urls.append(url) + + queue: list[str] = [] + seen: set[str] = set() + for url in seed_urls: + if url not in seen: + queue.append(url) + seen.add(url) + + captures: list[dict[str, Any]] = [] + matched_captures: list[dict[str, Any]] = [] + errors: list[dict[str, Any]] = [] + + login_body = normalize_text(login_info.get("login_body_preview")) + if login_body: + login_capture = _build_satis_capture( + source_url=HANMAC_SATIS_ERP_LOGIN_CONTROLLER_URL, + request_method="POST", + http_status=int(login_info.get("login_status") or 0), + final_url=normalize_text(login_info.get("login_url")), + body=login_body, + ) + captures.append(login_capture) + if login_capture["matched_keywords"] or login_capture["amount_candidates"] or login_capture["internal_links"]: + matched_captures.append(login_capture) + for link in login_capture["internal_links"]: + if link not in seen: + queue.append(link) + seen.add(link) + + while queue and len(captures) < max_pages: + url = queue.pop(0) + if re.search(r"\.(?:css|js|png|gif|jpg|jpeg|ico|bmp|svg|woff|ttf)(?:$|\?)", urlparse(url).path, flags=re.IGNORECASE): + continue + try: + request = UrlRequest(url, headers={"User-Agent": "my-intranet-app/satis-web-budget-collector"}) + with opener.open(request, timeout=10) as response: + status = int(getattr(response, "status", 200) or 200) + final_url = str(response.geturl() or "") + raw = response.read(700_000) + body = raw.decode("utf-8", "replace") + except Exception as exc: + errors.append({"url": url, "error": str(exc)[:300]}) + continue + capture = _build_satis_capture( + source_url=url, + request_method="GET", + http_status=status, + final_url=final_url, + body=body, + ) + links = capture["internal_links"] + for link in links: + if link not in seen and len(seen) < max_pages * 4: + if any(token in link.lower() for token in ("project", "plan", "exec", "budget", "task", "controller", "satis")) or capture["matched_keywords"]: + queue.append(link) + seen.add(link) + captures.append(capture) + if capture["matched_keywords"] or capture["amount_candidates"]: + matched_captures.append(capture) + + ajax_seen: set[str] = set() + ajax_captures: list[dict[str, Any]] = [] + for screen_capture in list(captures): + screen_body = normalize_text(screen_capture.get("body_preview")) + final_url = normalize_text(screen_capture.get("final_url") or screen_capture.get("source_url")) + if "Project_Controller.php" not in final_url and "ProjectMenHour_Controller.php" not in final_url: + continue + parsed = urlparse(final_url) + query = parse_qs(parsed.query) + action_mode = normalize_text((query.get("ActionMode") or [""])[0]) + if not action_mode: + action_match = re.search(r"""var\s+ActionMode\s*=\s*["']([^"']+)""", screen_body) + action_mode = normalize_text(action_match.group(1) if action_match else "") + if not action_mode: + continue + controller_url = final_url.split("?", 1)[0] + form_defaults = _extract_satis_form_defaults(screen_body) + ajax_actions = [ + action + for action in _extract_satis_ajax_actions(screen_body) + if not action.startswith("HTML_Page_") + ] + for main_action in ajax_actions: + params = _relax_satis_search_defaults(action_mode, main_action, form_defaults) + params.update( + { + "ActionMode": action_mode, + "MainAction": main_action, + "SubAction": "select", + "page": "1", + "rows": "500", + "_search": "false", + "sidx": "", + "sord": "asc", + "nd": str(int(time.time() * 1000)), + } + ) + flat_ajax_url = f"{controller_url}?{urlencode(params)}" + query_ajax_url = f"{controller_url}?{urlencode({key: value for key, value in params.items() if key in {'ActionMode', 'MainAction', 'SubAction', 'page', 'rows', '_search', 'sidx', 'sord', 'nd'}})}" + nested_jggrid_form = { + f"jgGridData[{key}]": value + for key, value in params.items() + if key not in {"ActionMode", "MainAction", "SubAction", "page", "rows", "_search", "sidx", "sord", "nd"} + } + request_specs = [ + ("GET", flat_ajax_url, None), + ("POST", flat_ajax_url, params), + ("POST_JGGRID", query_ajax_url, nested_jggrid_form), + ] + for request_method, ajax_url, post_params in request_specs: + ajax_key = f"{request_method}|{ajax_url}|{json.dumps(post_params or {}, ensure_ascii=False, sort_keys=True)}" + if ajax_key in ajax_seen: + continue + ajax_seen.add(ajax_key) + try: + data = urlencode(post_params).encode("utf-8") if post_params is not None else None + headers = {"User-Agent": "my-intranet-app/satis-web-budget-collector"} + if data is not None: + headers["Content-Type"] = "application/x-www-form-urlencoded" + request = UrlRequest(ajax_url, data=data, headers=headers) + with opener.open(request, timeout=12) as response: + status = int(getattr(response, "status", 200) or 200) + response_final_url = str(response.geturl() or "") + raw = response.read(2_000_000) + ajax_body = raw.decode("utf-8", "replace") + except Exception as exc: + errors.append({"url": ajax_url, "method": request_method, "error": str(exc)[:300]}) + continue + ajax_capture = _build_satis_capture( + source_url=ajax_url, + request_method=request_method, + http_status=status, + final_url=response_final_url, + body=ajax_body, + ) + ajax_captures.append(ajax_capture) + captures.append(ajax_capture) + if ajax_capture["matched_keywords"] or ajax_capture["amount_candidates"] or len(ajax_body) > 100: + matched_captures.append(ajax_capture) + + # 현재 포트 DB에 존재하는 프로젝트만 대상으로 차수 및 예산 상세를 순회한다. + # support_dept_code는 Satis 프로젝트 코드(Y24196 등)와 동일하게 관리되고 있다. + try: + max_detail_projects = max(1, min(int(payload.get("max_detail_projects") or 50), 200)) + except Exception: + max_detail_projects = 50 + external_projects: dict[str, str] = {} + for capture in captures: + parsed_capture_url = urlparse(normalize_text(capture.get("source_url"))) + capture_query = parse_qs(parsed_capture_url.query) + capture_action_mode = normalize_text((capture_query.get("ActionMode") or [""])[0]) + capture_main_action = normalize_text((capture_query.get("MainAction") or [""])[0]) + if (capture_action_mode, capture_main_action) not in { + ("SCREEN_01", "Ajax_01"), + ("SCREEN_01", "HTML_Ajax_01"), + ("SCREEN_03", "HTML_Ajax_01"), + }: + continue + for external_row in _flatten_satis_json_rows( + _json_loads_loose(normalize_text(capture.get("body_preview"))) + ): + if capture_action_mode == "SCREEN_03": + external_code = _pick_satis_web_value(external_row, ("view02", "view28", "item03", "item02")) + external_name = _pick_satis_web_value(external_row, ("view04", "item04")) + else: + external_code = _pick_satis_web_value(external_row, ("view02", "view28", "item02", "item01")) + external_name = _pick_satis_web_value(external_row, ("view04", "item04", "item02")) + if external_code and external_name: + external_projects[external_code] = external_name + + def project_name_key(value: Any) -> str: + return re.sub(r"[^0-9a-z가-힣]", "", normalize_text(value).lower()) + + with engine.begin() as conn: + local_projects = [ + dict(row) + for row in conn.execute( + text( + """ + SELECT support_dept_code, support_dept_name + FROM project_status + WHERE support_dept_code <> '' + ORDER BY support_dept_code + LIMIT :limit + """ + ), + {"limit": max_detail_projects}, + ).mappings().fetchall() + ] + detail_projects: list[dict[str, str]] = [] + for local_project in local_projects: + support_dept_code = normalize_text(local_project.get("support_dept_code")) + support_dept_name = normalize_text(local_project.get("support_dept_name")) + mapped_code = "" + mapping_basis = "" + register_row = conn.execute( + text( + """ + SELECT * + FROM satis_project_code_links + WHERE local_project_code = :support_dept_code + LIMIT 1 + """ + ), + {"support_dept_code": support_dept_code}, + ).mappings().first() + if register_row: + register_status = normalize_text(register_row.get("mapping_status")) + if register_status not in {"common"}: + linked_main_code = normalize_text(register_row.get("linked_main_project_code")) + own_master_code = normalize_text(register_row.get("own_master_project_code")) + if linked_main_code: + mapped_code = linked_main_code + mapping_basis = "project_code_register:linked_main" + elif own_master_code: + mapped_code = own_master_code + mapping_basis = "project_code_register:own_master" + candidates = [support_dept_code] + if re.fullmatch(r"X\d{5}", support_dept_code): + candidates.insert(0, f"9{support_dept_code[1:]}") + if re.fullmatch(r"[YZ]\d{5}", support_dept_code): + candidates.insert(0, f"0{support_dept_code[1:]}") + if not mapped_code: + for candidate in candidates: + if candidate in external_projects: + mapped_code = candidate + mapping_basis = "code_pattern" if candidate != support_dept_code else "exact_code" + break + if not mapped_code and support_dept_name: + local_name_key = project_name_key(support_dept_name) + exact_name_matches = [ + code + for code, name in external_projects.items() + if project_name_key(name) == local_name_key + ] + if len(exact_name_matches) == 1: + mapped_code = exact_name_matches[0] + mapping_basis = "exact_name" + else: + scored = sorted( + ( + SequenceMatcher(None, local_name_key, project_name_key(name)).ratio(), + code, + ) + for code, name in external_projects.items() + if project_name_key(name) + ) + if scored and scored[-1][0] >= 0.88 and ( + len(scored) == 1 or scored[-1][0] - scored[-2][0] >= 0.04 + ): + mapped_code = scored[-1][1] + mapping_basis = f"fuzzy_name:{scored[-1][0]:.3f}" + if mapped_code: + mapped_name = ( + external_projects.get(mapped_code) + or normalize_text((register_row or {}).get("linked_main_project_name")) + or normalize_text((register_row or {}).get("own_master_project_name")) + or normalize_text((register_row or {}).get("local_project_name")) + or support_dept_name + ) + conn.execute( + text( + """ + INSERT INTO satis_project_mapping ( + erp_project_code, erp_project_name, support_dept_code, + mapping_status, mapping_basis, manual_override, updated_at + ) VALUES ( + :erp_project_code, :erp_project_name, :support_dept_code, + 'matched', :mapping_basis, 0, CURRENT_TIMESTAMP + ) + ON CONFLICT(erp_project_code) DO UPDATE SET + erp_project_name = excluded.erp_project_name, + support_dept_code = CASE + WHEN satis_project_mapping.manual_override = 1 + THEN satis_project_mapping.support_dept_code + ELSE excluded.support_dept_code + END, + mapping_status = CASE + WHEN satis_project_mapping.manual_override = 1 + THEN satis_project_mapping.mapping_status + ELSE excluded.mapping_status + END, + mapping_basis = CASE + WHEN satis_project_mapping.manual_override = 1 + THEN satis_project_mapping.mapping_basis + ELSE excluded.mapping_basis + END, + updated_at = CURRENT_TIMESTAMP + """ + ), + { + "erp_project_code": mapped_code, + "erp_project_name": mapped_name, + "support_dept_code": support_dept_code, + "mapping_basis": mapping_basis, + }, + ) + detail_projects.append( + { + "project_code": mapped_code, + "project_name": mapped_name, + "support_dept_code": support_dept_code, + } + ) + + detail_capture_count = 0 + detail_revision_count = 0 + project_controller_url = f"{HANMAC_SATIS_ERP_BASE_URL}sys/controller/Project/Project_Controller.php" + + def fetch_detail(action_mode: str, main_action: str, params: Mapping[str, Any]) -> dict[str, Any] | None: + nonlocal detail_capture_count + request_params = { + "ActionMode": action_mode, + "MainAction": main_action, + "SubAction": "select", + "page": "1", + "rows": "500", + "_search": "false", + "sidx": "", + "sord": "asc", + "nd": str(int(time.time() * 1000)), + **{normalize_text(key): normalize_text(value) for key, value in params.items() if normalize_text(key)}, + } + detail_url = f"{project_controller_url}?{urlencode(request_params)}" + try: + request = UrlRequest(detail_url, headers={"User-Agent": "my-intranet-app/satis-web-budget-collector"}) + with opener.open(request, timeout=15) as response: + status = int(getattr(response, "status", 200) or 200) + response_final_url = str(response.geturl() or "") + raw = response.read(4_000_000) + detail_body = raw.decode("utf-8", "replace") + except Exception as exc: + errors.append( + { + "url": detail_url, + "method": "GET_DETAIL", + "error": str(exc)[:300], + } + ) + return None + detail_capture = _build_satis_capture( + source_url=detail_url, + request_method="GET_DETAIL", + http_status=status, + final_url=response_final_url, + body=detail_body, + ) + captures.append(detail_capture) + detail_capture_count += 1 + if detail_capture["matched_keywords"] or detail_capture["amount_candidates"] or len(detail_body) > 100: + matched_captures.append(detail_capture) + return detail_capture + + for project in detail_projects: + project_code = normalize_text(project.get("project_code")) + project_name = normalize_text(project.get("project_name")) + if not project_code: + continue + revision_capture = fetch_detail( + "SCREEN_03", + "Ajax_02", + { + "proj_code": project_code, + "input_item_01": project_code, + "input_item_02": project_name, + "degree_pre": "00", + }, + ) + revision_rows = _flatten_satis_json_rows( + _json_loads_loose(normalize_text((revision_capture or {}).get("body_preview"))) + ) + revisions: list[dict[str, str]] = [] + for revision_row in revision_rows: + revision_no = _pick_satis_web_value(revision_row, ("item03", "degree", "revision_no")) or "00" + revision_status = " ".join( + value + for value in ( + _pick_satis_web_value(revision_row, ("item07", "status")), + f"확정:{_pick_satis_web_value(revision_row, ('item08',))}" + if _pick_satis_web_value(revision_row, ("item08",)) + else "", + ) + if value + ) + revision_key = (revision_no, revision_status) + if not any((item["revision_no"], item["approval_status"]) == revision_key for item in revisions): + revisions.append({"revision_no": revision_no, "approval_status": revision_status}) + if not revisions: + revisions = [{"revision_no": "00", "approval_status": ""}] + detail_revision_count += len(revisions) + + for revision in revisions: + revision_no = normalize_text(revision.get("revision_no")) or "00" + approval_status = normalize_text(revision.get("approval_status")) + common_params = { + "proj_code": project_code, + "degree": revision_no, + "degree_pre": revision_no, + "input_item_01": project_code, + "input_item_02": project_name, + "input_item_03": revision_no, + "input_item_03_name": revision_no, + "input_item_04": approval_status, + } + fetch_detail("SCREEN_03", "Ajax_03", common_params) + fetch_detail("SCREEN_06", "Ajax_00", common_params) + fetch_detail("SCREEN_06", "Ajax_03", common_params) + + dept_capture = fetch_detail( + "SCREEN_02", + "Info_dept", + { + "input_item_01": project_code, + "input_item_03": revision_no, + }, + ) + dept_rows = _flatten_satis_json_rows( + _json_loads_loose(normalize_text((dept_capture or {}).get("body_preview"))) + ) + dept_codes = sorted( + { + _pick_satis_web_value(row, ("CODE", "code", "item01")) + for row in dept_rows + if _pick_satis_web_value(row, ("CODE", "code", "item01")) + } + ) + if not dept_codes: + dept_codes = ["%"] + for dept_code in dept_codes: + dept_params = {**common_params, "input_select_01": dept_code} + fetch_detail("SCREEN_02", "Ajax_00", dept_params) + for main_action in ("Ajax_01", "Ajax_02", "Ajax_03", "Ajax_04"): + fetch_detail("SCREEN_02", main_action, dept_params) + + with engine.begin() as conn: + for capture in captures: + conn.execute( + text( + """ + INSERT INTO satis_project_budget_web_captures ( + capture_key, source_url, request_method, http_status, final_url, + page_title, matched_keywords, internal_links_json, amount_candidates_json, + body_preview, body_hash, captured_at + ) VALUES ( + :capture_key, :source_url, :request_method, :http_status, :final_url, + :page_title, :matched_keywords, :internal_links_json, :amount_candidates_json, + :body_preview, :body_hash, CURRENT_TIMESTAMP + ) + ON CONFLICT(capture_key) DO UPDATE SET + http_status = excluded.http_status, + final_url = excluded.final_url, + page_title = excluded.page_title, + matched_keywords = excluded.matched_keywords, + internal_links_json = excluded.internal_links_json, + amount_candidates_json = excluded.amount_candidates_json, + body_preview = excluded.body_preview, + body_hash = excluded.body_hash, + captured_at = CURRENT_TIMESTAMP + """ + ), + { + **{key: capture[key] for key in ("capture_key", "source_url", "request_method", "http_status", "final_url", "page_title", "matched_keywords", "body_preview", "body_hash")}, + "internal_links_json": json.dumps(capture["internal_links"], ensure_ascii=False), + "amount_candidates_json": json.dumps(capture["amount_candidates"], ensure_ascii=False), + }, + ) + + promoted_result = _promote_satis_web_captures_to_raw_rows(captures) + + return { + "status": "ok", + "message": f"Satis 웹로그인 세션으로 {len(captures):,}개 페이지/컨트롤러 응답을 수집했습니다.", + "login_info": {key: value for key, value in login_info.items() if "password" not in key.lower()}, + "captured_count": len(captures), + "ajax_captured_count": len(ajax_captures), + "detail_project_count": len(detail_projects), + "detail_revision_count": detail_revision_count, + "detail_captured_count": detail_capture_count, + "raw_inserted_or_updated_rows": int(promoted_result.get("inserted_or_updated_rows") or 0), + "raw_parsed_capture_count": int(promoted_result.get("parsed_capture_count") or 0), + "matched_count": len(matched_captures), + "error_count": len(errors), + "errors": errors[:10], + "matched_examples": [ + { + "url": item["final_url"] or item["source_url"], + "title": item["page_title"], + "keywords": item["matched_keywords"], + "amount_candidate_count": len(item["amount_candidates"]), + "link_count": len(item["internal_links"]), + } + for item in matched_captures[:12] + ], + "menu_item_count": len(menu_items), + "target_menu_items": target_menu_items[:30], + "capture_table": "satis_project_budget_web_captures", + "next_action": "matched_examples 또는 DB의 satis_project_budget_web_captures에서 실제 프로젝트 조회 컨트롤러와 파라미터를 확인해야 합니다.", + } + + +def _build_hanmac_erp_direct_mysql_engine(user: str, password: str): + return create_engine( + URL.create( + "mysql+pymysql", + username=user, + password=password, + host=HANMAC_ERP_DIRECT_DB_HOST, + port=HANMAC_ERP_DIRECT_DB_PORT, + query={"charset": "utf8"}, + ), + pool_pre_ping=True, + pool_recycle=300, + connect_args={"connect_timeout": 5}, + ) + + +def _discover_hanmac_erp_budget_tables(user: str, password: str) -> dict[str, Any]: + direct_db_engine = _build_hanmac_erp_direct_mysql_engine(user, password) + try: + with direct_db_engine.connect() as connection: + databases = [ + normalize_text(row[0]) + for row in connection.execute(text("SHOW DATABASES")).fetchall() + if normalize_text(row[0]) + ] + visible_databases = [ + database + for database in databases + if database.lower() not in {"information_schema", "mysql", "performance_schema"} + ] + like_clauses: list[str] = [] + params: dict[str, Any] = {} + for index, keyword in enumerate(HANMAC_SATIS_BUDGET_DISCOVERY_KEYWORDS): + key = f"keyword_{index}" + params[key] = f"%{keyword.lower()}%" + like_clauses.append(f"LOWER(c.table_name) LIKE :{key}") + like_clauses.append(f"LOWER(c.column_name) LIKE :{key}") + rows = connection.execute( + text( + f""" + SELECT + c.table_schema, + c.table_name, + COUNT(*) AS matched_column_count, + COUNT(DISTINCT c.column_name) AS distinct_column_count, + GROUP_CONCAT(c.column_name ORDER BY c.ordinal_position SEPARATOR ', ') AS matched_columns + FROM information_schema.columns c + WHERE c.table_schema NOT IN ('information_schema', 'mysql', 'performance_schema') + AND ({" OR ".join(like_clauses)}) + GROUP BY c.table_schema, c.table_name + ORDER BY matched_column_count DESC, c.table_schema, c.table_name + LIMIT 80 + """ + ), + params, + ).mappings().fetchall() + candidates = [] + for row in rows: + candidates.append( + { + "database": normalize_text(row.get("table_schema")), + "table": normalize_text(row.get("table_name")), + "matched_column_count": int(row.get("matched_column_count") or 0), + "distinct_column_count": int(row.get("distinct_column_count") or 0), + "matched_columns": normalize_text(row.get("matched_columns"))[:600], + } + ) + return { + "direct_db_access": True, + "direct_db_message": f"MySQL 직접 접속에 성공했습니다. 조회 가능한 DB {len(visible_databases)}개, 예산 후보 테이블 {len(candidates)}개를 확인했습니다.", + "direct_db_database_count": len(visible_databases), + "direct_db_database_examples": visible_databases[:12], + "candidate_table_count": len(candidates), + "candidate_tables": candidates[:40], + } + finally: + direct_db_engine.dispose() + + +def test_hanmac_satis_budget_discovery(payload: dict[str, Any]) -> dict[str, Any]: + user, password = _hanmac_erp_web_credentials(payload) + db_user, db_password, explicit_db_credentials = _hanmac_erp_mysql_credentials(payload) + + result: dict[str, Any] = { + "status": "ok", + "message": "Satis 예산 연동 사전 탐색을 완료했습니다.", + "web_access": False, + "web_message": "", + "direct_db_access": False, + "direct_db_message": "", + "candidate_table_count": 0, + "candidate_tables": [], + "prepared_local_tables": [ + "satis_project_mapping", + "satis_project_budget_revisions", + "satis_project_task_plan_budget_lines", + "satis_project_exec_budget_lines", + ], + "transport_warning": "Satis ERP 로그인 주소가 HTTP이므로 계정 전송 구간이 암호화되지 않습니다.", + } + + cookie_jar = CookieJar() + opener = build_opener(HTTPCookieProcessor(cookie_jar)) + try: + login_page_request = UrlRequest( + HANMAC_SATIS_ERP_LOGIN_PAGE_URL, + headers={"User-Agent": "my-intranet-app/satis-budget-discovery"}, + ) + with opener.open(login_page_request, timeout=10) as response: + login_page_status = int(getattr(response, "status", 200) or 200) + response.read(1000) + + login_request = UrlRequest( + HANMAC_SATIS_ERP_LOGIN_CONTROLLER_URL, + data=urlencode( + { + "ActionMode": "SCREEN_10", + "MainAction": "Ajax_02", + "SubAction": "checktype05", + "userid": user, + "password": password, + "item02": "", + "item03": "", + } + ).encode("utf-8"), + headers={ + "Content-Type": "application/x-www-form-urlencoded", + "User-Agent": "my-intranet-app/satis-budget-discovery", + }, + ) + with opener.open(login_request, timeout=10) as response: + login_status = int(getattr(response, "status", 200) or 200) + login_url = str(response.geturl() or "") + login_body = response.read(300_000).decode("utf-8", "replace") + links = { + normalize_text(match) + for match in re.findall(r"""(?:href|src|url)\s*=\s*["']?([^"' >]+)""", login_body, flags=re.IGNORECASE) + if normalize_text(match) + } + internal_links = sorted( + link + for link in links + if "satis" in link.lower() or link.startswith(("./", "../", "/")) + ) + result.update( + { + "web_access": True, + "web_message": "Satis 로그인 컨트롤러 호출에 성공했습니다.", + "login_page_status": login_page_status, + "login_status": login_status, + "login_url": login_url, + "php_session": any(cookie.name == "PHPSESSID" for cookie in cookie_jar), + "internal_link_count": len(internal_links), + "internal_link_examples": internal_links[:8], + } + ) + except Exception as exc: + result["web_message"] = f"Satis 웹 로그인 확인은 실패했습니다: {exc}" + + try: + result.update(_discover_hanmac_erp_budget_tables(db_user, db_password)) + except OperationalError as exc: + lowered = str(exc).lower() + if "access denied" in lowered: + result["direct_db_message"] = _build_hanmac_mysql_access_denied_message(db_user, explicit_db_credentials) + else: + result["direct_db_message"] = "MySQL 3306 포트는 열려 있지만 직접 DB 접속 또는 메타정보 조회에 실패했습니다." + except Exception as exc: + result["direct_db_message"] = f"직접 DB 탐색 중 오류가 발생했습니다: {exc}" + + if result.get("direct_db_access"): + result["message"] = "Satis 후보 DB/테이블 탐색과 로컬 저장소 준비를 완료했습니다." + elif result.get("web_access"): + result["message"] = "Satis 웹 접근은 확인했지만 직접 DB 후보 테이블은 확인하지 못했습니다." + else: + result["message"] = "로컬 저장소는 준비했지만 Satis 웹/DB 접근은 확인하지 못했습니다." + return result + + +def test_hanmac_management_erp_access(payload: dict[str, Any]) -> dict[str, Any]: + user = normalize_text(payload.get("erp_user")) + password = str(payload.get("erp_password") or "") + if not user: + raise ValueError("관리 ERP 아이디를 입력해주세요.") + if not password: + raise ValueError("관리 ERP 비밀번호를 입력해주세요.") + + cookie_jar = CookieJar() + opener = build_opener(HTTPCookieProcessor(cookie_jar)) + login_request = UrlRequest( + HANMAC_MANAGEMENT_ERP_LOGIN_URL, + data=urlencode( + { + "memberID": user, + "LoginID": user, + "passwd": password, + "CheckSave": "", + "login": "1", + } + ).encode("utf-8"), + headers={ + "Content-Type": "application/x-www-form-urlencoded", + "User-Agent": "my-intranet-app/hanmac-erp-access-check", + }, + ) + with opener.open(login_request, timeout=10) as response: + login_result = response.read(2000).decode("utf-8", "replace").strip().lower() + if login_result != "success": + if login_result == "auth": + raise ValueError("관리 ERP 로그인은 확인됐지만 접근권한이 없습니다.") + raise ValueError("관리 ERP 아이디 또는 비밀번호가 올바르지 않습니다.") + + main_request = UrlRequest( + HANMAC_MANAGEMENT_ERP_MAIN_URL, + headers={"User-Agent": "my-intranet-app/hanmac-erp-access-check"}, + ) + with opener.open(main_request, timeout=10) as response: + main_status = int(getattr(response, "status", 200) or 200) + main_url = str(response.geturl() or "") + main_body = response.read(500_000).decode("utf-8", "replace") + + links = { + normalize_text(match) + for match in re.findall(r"""(?:href|src|url)\s*=\s*["']?([^"' >]+)""", main_body, flags=re.IGNORECASE) + if normalize_text(match) + } + internal_links = sorted( + link + for link in links + if "planning_mng" in link or link.startswith(("./", "../", "/")) + ) + db_markers = sorted( + marker + for marker in ("mysql", "mysqli", "pdo", "db_host", "db_name", "database", "3306") + if marker in main_body.lower() + ) + direct_db_access = False + direct_db_databases: list[str] = [] + direct_db_message = "" + direct_db_engine = create_engine( + URL.create( + "mysql+pymysql", + username=user, + password=password, + host="erp.hanmaceng.co.kr", + port=3306, + query={"charset": "utf8"}, + ), + pool_pre_ping=True, + connect_args={"connect_timeout": 5}, + ) + try: + with direct_db_engine.connect() as connection: + direct_db_databases = [ + normalize_text(row[0]) + for row in connection.execute(text("SHOW DATABASES")).fetchall() + if normalize_text(row[0]) + ] + direct_db_access = True + direct_db_message = f"동일 계정으로 MySQL 직접 접속에 성공했습니다. 조회 가능한 DB {len(direct_db_databases)}개를 확인했습니다." + except OperationalError as exc: + lowered = str(exc).lower() + if "access denied" in lowered: + direct_db_message = "MySQL 3306 포트는 열려 있지만 관리 ERP 웹 계정으로는 DB 직접 로그인이 거부되었습니다." + else: + direct_db_message = "MySQL 3306 포트는 열려 있지만 관리 ERP 웹 계정으로 DB 직접 접속하지 못했습니다." + finally: + direct_db_engine.dispose() + + return { + "status": "ok", + "message": "관리 ERP 로그인 및 내부 메인 페이지 접근에 성공했습니다.", + "web_access": True, + "main_status": main_status, + "main_url": main_url, + "php_session": any(cookie.name == "PHPSESSID" for cookie in cookie_jar), + "internal_link_count": len(internal_links), + "internal_link_examples": internal_links[:8], + "direct_db_access": direct_db_access, + "direct_db_database_count": len(direct_db_databases), + "direct_db_database_examples": direct_db_databases[:8], + "direct_db_info_found": bool(db_markers), + "direct_db_markers": db_markers, + "direct_db_message": direct_db_message, + "transport_warning": "관리 ERP 로그인 주소가 HTTP이므로 계정 전송 구간이 암호화되지 않습니다.", + } + + def _validate_hanmac_table_name(table_name: Any) -> str: normalized = normalize_text(table_name) if not normalized: @@ -15147,6 +23634,98 @@ def _hanmac_normalize_member_restore_keys(payload: dict[str, Any]) -> set[str]: return {_hanmac_normalize_member_token(item) for item in items if _hanmac_normalize_member_token(item)} +def _hanmac_company_code(value: Any) -> str: + return normalize_text(value).upper() + + +def _hanmac_company_is_hanmac(value: Any) -> bool: + text_value = normalize_text(value).replace(" ", "").upper() + return not text_value or text_value in {"HANMAC", "HM", "한맥", "한맥기술", "(주)한맥기술", "주식회사한맥기술"} + + +def _hanmac_member_is_active_for_period(member_record: Mapping[str, Any], start_date: date, end_date: date) -> bool: + entry_date = _hanmac_parse_date_value(member_record.get("entry_date")) + leave_date = _hanmac_parse_date_value(member_record.get("leave_date")) + return (entry_date is None or entry_date <= end_date) and (leave_date is None or leave_date >= start_date) + + +def _hanmac_load_member_work_keys_for_period( + connection: Any, + schema_name: str, + metadata: dict[str, list[str]], + start_date: date, + end_date: date, +) -> set[str]: + columns = metadata.get("dallyproject_tbl") or [] + member_col = _hanmac_find_column(columns, ["MemberNo", "member_no", "EmpNo", "UserID", "MemberID", "member_id"]) + entry_col = _hanmac_find_column(columns, ["EntryTime", "entry_time", "WorkDate", "work_date", "EntryDate", "entry_date"]) + if not member_col or not entry_col: + return set() + try: + rows = connection.execute( + text( + f""" + SELECT DISTINCT {_hanmac_build_select_alias(member_col, "member_no")} + FROM `{schema_name}`.`dallyproject_tbl` + WHERE `{member_col}` IS NOT NULL + AND LEFT(CAST(`{entry_col}` AS CHAR), 10) >= :start_date + AND LEFT(CAST(`{entry_col}` AS CHAR), 10) <= :end_date + """ + ), + {"start_date": start_date.isoformat(), "end_date": end_date.isoformat()}, + ).mappings().all() + except Exception: + return set() + return { + _hanmac_normalize_member_token(row.get("member_no")) + for row in rows + if _hanmac_normalize_member_token(row.get("member_no")) + } + + +def _hanmac_discover_affiliate_manhour_schemas(connection: Any, primary_schema: str) -> list[str]: + try: + rows = connection.execute( + text( + """ + SELECT table_schema, + SUM(CASE WHEN table_name = 'member_tbl' THEN 1 ELSE 0 END) AS has_member, + SUM(CASE WHEN table_name = 'dallyproject_tbl' THEN 1 ELSE 0 END) AS has_work + FROM information_schema.tables + WHERE table_type = 'BASE TABLE' + AND table_schema <> :primary_schema + AND table_schema LIKE '%manhour%' + GROUP BY table_schema + HAVING has_member > 0 AND has_work > 0 + ORDER BY table_schema + """ + ), + {"primary_schema": primary_schema}, + ).mappings().all() + except Exception: + return [HANMAC_CENTER_MANHOUR_SCHEMA] + schemas = [normalize_text(row.get("table_schema")) for row in rows if normalize_text(row.get("table_schema"))] + if HANMAC_CENTER_MANHOUR_SCHEMA not in schemas: + schemas.append(HANMAC_CENTER_MANHOUR_SCHEMA) + return [schema for schema in schemas if schema and schema != primary_schema] + + +def _hanmac_affiliate_schema_label(schema_name: Any) -> str: + text_value = normalize_text(schema_name) + lowered = text_value.lower() + aliases = { + "baron": "바론컨설턴트", + "saman": "삼안", + "samahn": "삼안", + "jangheon": "장헌산업", + "ptc": "피티씨", + } + for token, label in aliases.items(): + if token in lowered: + return label + return text_value + + def _hanmac_load_member_info( connection: Any, schema_name: str, @@ -15158,6 +23737,8 @@ def _hanmac_load_member_info( "member_name_col": "", "member_name_fallback_rows": 0, "member_group_col": "", + "member_company_col": "", + "member_work_company_col": "", "dept_source_table": "", "dept_code_col": "", "dept_name_col": "", @@ -15176,8 +23757,12 @@ def _hanmac_load_member_info( leave_date_col = _hanmac_find_column(member_columns, ["LeaveDate", "leave_date", "RetireDate", "OutDate"]) dept_name_col = _hanmac_find_column(member_columns, ["DeptName", "Department", "PartName", "TeamName", "Dept"]) grade_col = _hanmac_find_column(member_columns, ["Grade", "grade", "Position", "position", "Rank", "rank", "Duty", "duty", "JobGrade", "job_grade", "RankCode", "rank_code", "WorkPosition", "work_position", "직급"]) + company_col = _hanmac_find_column(member_columns, ["Company", "company", "Corp", "corp"]) + work_company_col = _hanmac_find_column(member_columns, ["WorkCompany", "work_company", "WorkCorp", "work_corp"]) member_group_col = _hanmac_find_column(member_columns, ["GroupCode", "group_code", "DeptCode", "dept_code", "DepartmentCode", "TeamCode"]) diagnostics["member_grade_col"] = grade_col or "" + diagnostics["member_company_col"] = company_col or "" + diagnostics["member_work_company_col"] = work_company_col or "" diagnostics["member_group_col"] = member_group_col or "" dept_name_by_code: dict[str, str] = {} if not dept_name_col and member_group_col: @@ -15266,6 +23851,8 @@ def _hanmac_load_member_info( {_hanmac_build_select_alias(leave_date_col, "leave_date")}, {_hanmac_build_select_alias(dept_name_col, "dept_name")}, {_hanmac_build_select_alias(grade_col, "member_grade")}, + {_hanmac_build_select_alias(company_col, "company")}, + {_hanmac_build_select_alias(work_company_col, "work_company")}, {_hanmac_build_select_alias(member_group_col, "group_code")} FROM `{schema_name}`.`member_tbl` """ @@ -15291,6 +23878,8 @@ def _hanmac_load_member_info( "leave_date": _hanmac_parse_date_value(row.get("leave_date")), "dept_name": dept_name, "member_grade": member_grade, + "company": _hanmac_company_code(row.get("company")), + "work_company": _hanmac_company_code(row.get("work_company")), "source_schema": schema_name, } return member_info, diagnostics @@ -15299,6 +23888,7 @@ def _hanmac_load_member_info( def _hanmac_build_project_code_relation_maps( project_code_alias_groups: list[dict[str, Any]], ) -> dict[str, Any]: + common_codes = {"0", "ZZZZZZ"} canonical_map: dict[str, str] = {} equivalent_code_map: dict[str, set[str]] = {} relation_groups: dict[str, list[str]] = {} @@ -15312,17 +23902,38 @@ def _hanmac_build_project_code_relation_maps( for group in project_code_alias_groups if normalize_text(group.get("new_project_code")) } - adjacency: dict[str, set[str]] = {} + alias_base_codes: dict[str, set[str]] = {} for group in project_code_alias_groups: project_code = normalize_text(group.get("project_code")) if not project_code: continue + for alias_key in ("project_view_code", "old_project_code", "new_project_code"): + alias_code = normalize_text(group.get(alias_key)) + if alias_code and alias_code not in common_codes and alias_code != project_code: + alias_base_codes.setdefault(alias_code, set()).add(project_code) + shared_alias_codes = { + alias_code + for alias_code, linked_base_codes in alias_base_codes.items() + if len(linked_base_codes) > 1 + } + adjacency: dict[str, set[str]] = {} + for group in project_code_alias_groups: + project_code = normalize_text(group.get("project_code")) + if not project_code or project_code in common_codes: + continue equivalent_codes = { normalize_text(group.get("project_view_code")), normalize_text(group.get("old_project_code")), normalize_text(group.get("new_project_code")), } - equivalent_codes = {code for code in equivalent_codes if code and code != project_code} + equivalent_codes = { + code + for code in equivalent_codes + if code + and code != project_code + and code not in common_codes + and code not in shared_alias_codes + } adjacency.setdefault(project_code, set()) for code in equivalent_codes: adjacency.setdefault(project_code, set()).add(code) @@ -15356,6 +23967,8 @@ def _hanmac_build_project_code_relation_maps( "canonical_map": canonical_map, "equivalent_code_map": equivalent_code_map, "relation_groups": relation_groups, + "excluded_common_codes": sorted(common_codes), + "excluded_shared_alias_codes": sorted(shared_alias_codes), } @@ -15459,6 +24072,23 @@ def _hanmac_calculate_regular_hours(entry_time: Any, leave_time: Any) -> float: return round(hours, 2) +def _hanmac_calculate_official_overtime_hours(entry_time: Any, overtime_time: Any, leave_time: Any) -> tuple[float, str]: + overtime_text = normalize_text(overtime_time) + if not overtime_text or overtime_text in {"0000-00-00 00:00:00", "00:00:00"}: + return 0.0, "" + overtime_started_at = _hanmac_parse_datetime_value(overtime_time) + ended_at = _hanmac_parse_datetime_value(leave_time) + started_at = _hanmac_parse_datetime_value(entry_time) + if overtime_started_at and ended_at: + if started_at and overtime_started_at.date() == started_at.date() and ended_at < overtime_started_at: + ended_at = ended_at + timedelta(days=1) + hours = (ended_at - overtime_started_at).total_seconds() / 3600.0 + if 0 < hours <= 24: + return round(hours, 2), "time_range" + return 0.0, "invalid_time_range" + return _hanmac_parse_duration_hours(overtime_time), "duration" + + def _hanmac_floor_regular_hours(hours: Any, cap: float = 8.0) -> float: return float(min(max(math.floor(max(_hanmac_parse_float_value(hours), 0.0)), 0), int(cap))) @@ -15536,6 +24166,10 @@ def _load_hanmac_holiday_dates(start_date: date, end_date: date) -> set[date]: parsed = _hanmac_parse_date_value(row[0]) if parsed: holiday_dates.add(parsed) + for year in range(start_date.year, end_date.year + 1): + labor_day = date(year, 5, 1) + if start_date <= labor_day <= end_date: + holiday_dates.add(labor_day) return holiday_dates @@ -15674,6 +24308,28 @@ def _hanmac_match_leave_rule(leave_type: Any, rules: list[dict[str, Any]]) -> di return None +def _hanmac_userstate_leave_rule(state_code: Any, leave_type: Any) -> dict[str, Any] | None: + code = normalize_text(state_code).zfill(2) + if code == "01": + return {"keyword": "state:1", "leave_label": "연차", "rule_type": "full_day", "default_hours": 8.0} + if code == "30": + return {"keyword": "state:30", "leave_label": "오전반차", "rule_type": "fixed_hours", "default_hours": 4.0} + if code == "31": + return {"keyword": "state:31", "leave_label": "오후반차", "rule_type": "fixed_hours", "default_hours": 4.0} + if code == "18": + return {"keyword": "state:18", "leave_label": "시차", "rule_type": "explicit_hours", "default_hours": 0.0} + if code == "07": + return {"keyword": "state:7", "leave_label": "경조휴가", "rule_type": "full_day", "default_hours": 8.0} + if code == "08": + leave_label = "출산휴가" if "출산" in normalize_text(leave_type) else "특별휴가" + return {"keyword": "state:8", "leave_label": leave_label, "rule_type": "full_day", "default_hours": 8.0} + if code == "10": + return {"keyword": "state:10", "leave_label": "병가", "rule_type": "full_day", "default_hours": 8.0} + if code == "16": + return {"keyword": "state:16", "leave_label": "휴직", "rule_type": "full_day", "default_hours": 8.0} + return None + + def _hanmac_calculate_leave_amounts( *, leave_type: Any, @@ -15723,6 +24379,7 @@ def _hanmac_build_text_concat_alias(columns: list[str], alias: str) -> str: def _hanmac_leave_source_profile(table_name: str, columns: list[str]) -> dict[str, Any] | None: member_col = _hanmac_find_column(columns, ["MemberNo", "member_no", "EmpNo", "UserID", "MemberID", "member_id"]) date_col = _hanmac_find_column(columns, ["work_date", "WorkDate", "EntryDate", "Date", "TardyDate", "s_date", "SDate", "StartDate", "start_date", "start_time", "StartTime", "UseDate", "use_date"]) + state_col = _hanmac_find_column(columns, ["state", "State", "WorkState"]) type_cols = _hanmac_find_columns(columns, ["reason", "Reason", "ReasonName", "TardyReason", "state", "State", "WorkState", "gubun", "Gubun", "TardyGubun", "type", "Type", "TardyType", "kind", "Kind", "TardyKind", "TardyCode", "TardyCD", "HolidayType", "VacationType", "AbsenceType", "contents", "Contents", "info", "Info", "note", "Note", "memo", "Memo", "remark", "Remark", "Name", "Description"]) lower_name = table_name.lower() name_hint = any(keyword in lower_name for keyword in ("tardy", "leave", "vac", "holiday", "absence", "annual", "dayoff")) @@ -15735,6 +24392,7 @@ def _hanmac_leave_source_profile(table_name: str, columns: list[str]) -> dict[st "date_col": date_col, "end_date_col": _hanmac_find_column(columns, ["e_date", "EDate", "EndDate", "end_date", "end_time", "EndTime"]), "project_col": _hanmac_find_column(columns, ["project_code", "ProjectCode", "new_project_code", "NewProjectCode", "ProjectKey", "PCode"]), + "state_col": state_col, "type_cols": type_cols, "value_col": _hanmac_find_column(columns, ["day_count", "DayCount", "days", "Days", "day", "Day", "DayCnt", "use_day", "UseDay", "use_days", "UseDays", "used_days", "UsedDays", "work_day", "WorkDay", "tardy_day", "TardyDay", "tardy_days", "TardyDays", "hours", "Hours", "hour", "Hour", "time", "Time", "TardyTime", "TardyHour", "TardyHours", "UseHour", "use_hour"]), "hour_col": _hanmac_find_column(columns, ["tardy_h", "TardyH", "tardy_hour", "TardyHour", "UseHour", "use_hour"]), @@ -15940,6 +24598,7 @@ def get_hanmac_grade_code_summary(payload: dict[str, Any]) -> dict[str, Any]: buckets: dict[str, dict[str, Any]] = {} for row in member_rows: grade_code = normalize_text(row.get("grade_code")) or "(빈값)" + normalized_grade = _normalize_labor_grade_name(rank_code_names.get(grade_code) or grade_code) entry_date = _hanmac_parse_date_value(row.get("entry_date")) leave_date = _hanmac_parse_date_value(row.get("leave_date")) is_active = (entry_date is None or entry_date <= today) and (leave_date is None or leave_date >= today) @@ -15948,15 +24607,16 @@ def get_hanmac_grade_code_summary(payload: dict[str, Any]) -> dict[str, Any]: { "grade_code": grade_code, "mapped_name": rank_code_names.get(grade_code, ""), - "normalized_name": _normalize_labor_grade_name(rank_code_names.get(grade_code) or grade_code), + "normalized_name": normalized_grade, "member_count": 0, "active_member_count": 0, "examples": [], }, ) - bucket["member_count"] += 1 - if is_active: - bucket["active_member_count"] += 1 + if not _hanmac_is_researcher_grade(normalized_grade): + bucket["member_count"] += 1 + if is_active: + bucket["active_member_count"] += 1 example_name = normalize_text(row.get("member_name")) or normalize_text(row.get("member_no")) if example_name and len(bucket["examples"]) < 5 and example_name not in bucket["examples"]: bucket["examples"].append(example_name) @@ -15985,6 +24645,7 @@ def _hanmac_load_joint_assignment_records( "joint_assignment_source_rows": 0, "joint_assignment_records": 0, "joint_assignment_code_matched_rows": 0, + "joint_assignment_state_matched_rows": 0, "joint_assignment_text_matched_rows": 0, "joint_assignment_table": "", } @@ -15997,6 +24658,7 @@ def _hanmac_load_joint_assignment_records( project_col = _hanmac_find_column(columns, ["NewProjectCode", "new_project_code", "ProjectCode", "project_code", "ProjectKey", "PCode"]) fallback_project_col = _hanmac_find_column(columns, ["ProjectCode", "project_code"]) note_col = _hanmac_find_column(columns, ["note", "Note", "memo", "Memo", "remark", "Remark"]) + state_col = _hanmac_find_column(columns, ["state", "State", "state_code", "StateCode"]) sub_code_col = _hanmac_find_column(columns, ["sub_code", "SubCode", "AbsentCode", "absent_code"]) active_code_col = _hanmac_find_column(columns, ["active_code", "ActiveCode"]) if not member_col or not start_col: @@ -16013,6 +24675,8 @@ def _hanmac_load_joint_assignment_records( for index, code in enumerate(code_values): key = f"joint_code_{index}" params[key] = code + if state_col: + code_conditions.append(f"CAST(`{state_col}` AS CHAR) = :{key}") if sub_code_col: code_conditions.append(f"CAST(`{sub_code_col}` AS CHAR) = :{key}") if active_code_col: @@ -16035,6 +24699,7 @@ def _hanmac_load_joint_assignment_records( {_hanmac_build_select_alias(project_col, "project_code")}, {_hanmac_build_select_alias(fallback_project_col, "fallback_project_code")}, {_hanmac_build_select_alias(note_col, "note")}, + {_hanmac_build_select_alias(state_col, "state_code")}, {_hanmac_build_select_alias(sub_code_col, "sub_code")}, {_hanmac_build_select_alias(active_code_col, "active_code")} FROM `{schema_name}`.`userstate_tbl` @@ -16055,12 +24720,23 @@ def _hanmac_load_joint_assignment_records( continue if record_end and record_end < record_start: record_start, record_end = record_end, record_start + state_code = normalize_text(row.get("state_code")) sub_code = normalize_text(row.get("sub_code")) active_code = normalize_text(row.get("active_code")) - matched_code = sub_code if sub_code in joint_codes else active_code if active_code in joint_codes else "" + matched_code = ( + state_code + if state_code in joint_codes + else sub_code + if sub_code in joint_codes + else active_code + if active_code in joint_codes + else "" + ) note = normalize_text(row.get("note")) if matched_code: diagnostics["joint_assignment_code_matched_rows"] += 1 + if state_code == matched_code: + diagnostics["joint_assignment_state_matched_rows"] += 1 elif "합사" in note: diagnostics["joint_assignment_text_matched_rows"] += 1 records.append( @@ -16079,6 +24755,186 @@ def _hanmac_load_joint_assignment_records( return records, diagnostics +def _hanmac_status_work_rule(state_code: Any, note: Any, project_code: Any) -> dict[str, Any] | None: + code = normalize_text(state_code).zfill(2) + note_text = normalize_text(note) + project_text = normalize_text(project_code) + if code == "22": + return {"label": "감리현장", "source_label": "감리현장", "cost_weight": 1.0} + if code == "23": + return {"label": "감리대기", "source_label": "감리대기", "cost_weight": 0.7} + if code != "03": + return None + compact_note = re.sub(r"\s+", "", note_text).lower() + personal_keywords = ( + "개인", + "개인사유", + "개인업무", + "개인용무", + "개인일정", + "병원", + "치과", + "한의원", + "검진", + "진료", + "치료", + "약처방", + "은행", + "부동산", + "차량", + "자동차", + "가족", + "자녀", + "배우자", + "모친", + "부친", + "장례", + "조문", + "결혼", + "이사", + "휴가", + "연차", + "반차", + ) + if any(keyword in compact_note for keyword in personal_keywords): + return None + business_keywords = ( + "현장", + "조사", + "회의", + "협의", + "점검", + "검사", + "교육", + "착수", + "보고", + "발표", + "준공", + "심의", + "평가", + "설계", + "용역", + "공사", + "공단", + "공사", + "국토청", + "사업", + "관리", + "열차감시", + "발주처", + ) + if project_text or any(keyword in compact_note for keyword in business_keywords): + return {"label": "업무회의", "source_label": "회의중", "cost_weight": 1.0} + return None + + +def _hanmac_load_status_work_records( + connection: Any, + schema_name: str, + metadata: dict[str, list[str]], + start_date: date, + end_date: date, +) -> tuple[list[dict[str, Any]], dict[str, Any]]: + diagnostics = { + "status_work_source_rows": 0, + "status_work_records": 0, + "status_work_duplicate_rows": 0, + "status_work_meeting_records": 0, + "status_work_supervision_site_records": 0, + "status_work_supervision_wait_records": 0, + "status_work_personal_meeting_skipped_rows": 0, + "status_work_table": "", + } + columns = metadata.get("userstate_tbl") or [] + if not columns: + return [], diagnostics + member_col = _hanmac_find_column(columns, ["MemberNo", "member_no", "EmpNo", "UserID", "MemberID", "member_id"]) + state_col = _hanmac_find_column(columns, ["state", "State", "state_code", "StateCode"]) + start_col = _hanmac_find_column(columns, ["start_time", "StartTime", "s_date", "SDate", "start_date", "StartDate"]) + end_col = _hanmac_find_column(columns, ["end_time", "EndTime", "e_date", "EDate", "end_date", "EndDate"]) + project_col = _hanmac_find_column(columns, ["NewProjectCode", "new_project_code", "ProjectCode", "project_code", "ProjectKey", "PCode"]) + fallback_project_col = _hanmac_find_column(columns, ["ProjectCode", "project_code"]) + note_col = _hanmac_find_column(columns, ["note", "Note", "memo", "Memo", "remark", "Remark"]) + if not member_col or not state_col or not start_col: + return [], diagnostics + + date_end_expr = f"LEFT(CAST(`{end_col}` AS CHAR), 10)" if end_col else f"LEFT(CAST(`{start_col}` AS CHAR), 10)" + rows = connection.execute( + text( + f""" + SELECT + {_hanmac_build_select_alias(member_col, "member_no")}, + {_hanmac_build_select_alias(state_col, "state_code")}, + {_hanmac_build_select_alias(start_col, "start_date")}, + {_hanmac_build_select_alias(end_col, "end_date")}, + {_hanmac_build_select_alias(project_col, "project_code")}, + {_hanmac_build_select_alias(fallback_project_col, "fallback_project_code")}, + {_hanmac_build_select_alias(note_col, "note")} + FROM `{schema_name}`.`userstate_tbl` + WHERE `{member_col}` IS NOT NULL + AND CAST(`{state_col}` AS CHAR) IN ('3', '03', '22', '23') + AND LEFT(CAST(`{start_col}` AS CHAR), 10) <= :end_date + AND {date_end_expr} >= :start_date + """ + ), + {"start_date": start_date.isoformat(), "end_date": end_date.isoformat()}, + ).mappings().all() + diagnostics["status_work_source_rows"] = len(rows) + diagnostics["status_work_table"] = "userstate_tbl" + records: list[dict[str, Any]] = [] + seen_record_keys: set[tuple[str, str, date, date, str, str]] = set() + for row in rows: + member_no = normalize_text(row.get("member_no")) + record_start = _hanmac_parse_date_value(row.get("start_date")) + record_end = _hanmac_parse_date_value(row.get("end_date")) or record_start + project_code = normalize_text(row.get("project_code")) or normalize_text(row.get("fallback_project_code")) + note = normalize_text(row.get("note")) + rule = _hanmac_status_work_rule(row.get("state_code"), note, project_code) + if not rule: + if normalize_text(row.get("state_code")).zfill(2) == "03": + diagnostics["status_work_personal_meeting_skipped_rows"] += 1 + continue + if not member_no or not record_start: + continue + if record_end and record_end < record_start: + record_start, record_end = record_end, record_start + state_code = normalize_text(row.get("state_code")).zfill(2) + record_key = ( + _hanmac_normalize_member_token(member_no), + state_code, + record_start, + record_end or record_start, + project_code, + note, + ) + if record_key in seen_record_keys: + diagnostics["status_work_duplicate_rows"] += 1 + continue + seen_record_keys.add(record_key) + if state_code == "03": + diagnostics["status_work_meeting_records"] += 1 + elif state_code == "22": + diagnostics["status_work_supervision_site_records"] += 1 + elif state_code == "23": + diagnostics["status_work_supervision_wait_records"] += 1 + records.append( + { + "member_no": member_no, + "state_code": state_code, + "start_date": max(record_start, start_date), + "end_date": min(record_end or record_start, end_date), + "project_code": project_code, + "source_label": rule["source_label"], + "status_label": rule["label"], + "cost_weight": float(rule["cost_weight"]), + "note": note, + "source": "userstate_tbl", + } + ) + diagnostics["status_work_records"] = len(records) + return records, diagnostics + + def _hanmac_member_status_label(entry_date: date | None, leave_date: date | None, today: date) -> str: if leave_date and leave_date < today: return "퇴사" @@ -16129,6 +24985,8 @@ def _hanmac_expected_regular_hours_for_period( ) -> float: entry_date = _hanmac_parse_date_value(member_record.get("entry_date")) leave_date = _hanmac_parse_date_value(member_record.get("leave_date")) + if not entry_date and not leave_date and not normalize_text(member_record.get("source_schema")): + return 0.0 effective_start = max(start_date, entry_date) if entry_date else start_date effective_end = min(end_date, leave_date) if leave_date else end_date if effective_end < effective_start: @@ -16141,13 +24999,17 @@ def _hanmac_expected_regular_hours_for_period( return round(work_days * 8.0, 2) +HANMAC_AGGREGATE_LOGIC_VERSION = "hanmac-aggregate-v40-joint-fallback-replacement" +HANMAC_AGGREGATE_SIGNATURE_PREFIX = f"{HANMAC_AGGREGATE_LOGIC_VERSION}:" + + def _hanmac_aggregate_cache_key(payload: dict[str, Any]) -> str: employment_value = normalize_text(payload.get("employment") or "all") if employment_value == "current": employment_value = "active" include_center_member_nos = sorted(_hanmac_normalize_member_restore_keys(payload)) normalized = { - "logic_version": "hanmac-aggregate-v19-regular-gap-remarks", + "logic_version": HANMAC_AGGREGATE_LOGIC_VERSION, "host": normalize_text(payload.get("host")), "port": normalize_text(payload.get("port")), "user": normalize_text(payload.get("user")), @@ -16296,7 +25158,7 @@ def _store_hanmac_aggregate_cache(cache_key: str, payload: dict[str, Any]) -> No ), { "cache_key": cache_key, - "payload_signature": cache_key, + "payload_signature": f"{HANMAC_AGGREGATE_SIGNATURE_PREFIX}{cache_key}", "payload_json": json.dumps(payload, ensure_ascii=False), }, ) @@ -16324,7 +25186,7 @@ def _store_hanmac_aggregate_cache(cache_key: str, payload: dict[str, Any]) -> No ), { "cache_key": cache_key, - "payload_signature": cache_key, + "payload_signature": f"{HANMAC_AGGREGATE_SIGNATURE_PREFIX}{cache_key}", "view_mode": normalize_text(payload.get("view")), "employment_filter": normalize_text(payload.get("employment")), "start_date": str(payload.get("start_date") or ""), @@ -16464,7 +25326,7 @@ def _hanmac_export_payload_signature(payload: dict[str, Any]) -> str: def _hanmac_aggregate_export_payload_signature(payload: dict[str, Any]) -> str: normalized = { - "logic_version": "hanmac-aggregate-v16-grade-code-map", + "logic_version": HANMAC_AGGREGATE_LOGIC_VERSION, "host": normalize_text(payload.get("host")), "port": normalize_text(payload.get("port")), "user": normalize_text(payload.get("user")), @@ -17042,6 +25904,8 @@ def _create_system_job( if not normalized_page or not normalized_type: raise ValueError("작업 페이지와 작업 종류가 필요합니다.") params = dict(params or {}) + params_json = json.dumps(params, ensure_ascii=False, sort_keys=True) + dedupe_by_params = normalized_page == "cost_analysis" _cleanup_stale_system_jobs("job creation") job_id = "" for attempt in range(12): @@ -17056,6 +25920,7 @@ def _create_system_job( AND job_type = :job_type AND COALESCE(start_year, -1) = COALESCE(:start_year, -1) AND COALESCE(end_year, -1) = COALESCE(:end_year, -1) + AND (:dedupe_by_params = 0 OR params_json = :params_json) AND status IN ('queued', 'running') ORDER BY created_at DESC LIMIT 1 @@ -17066,6 +25931,8 @@ def _create_system_job( "job_type": normalized_type, "start_year": start_year, "end_year": end_year, + "dedupe_by_params": 1 if dedupe_by_params else 0, + "params_json": params_json, }, ).mappings().first() if existing: @@ -17094,7 +25961,7 @@ def _create_system_job( "job_type": normalized_type, "start_year": start_year, "end_year": end_year, - "params_json": json.dumps(params, ensure_ascii=False), + "params_json": params_json, "message": "작업 대기 중입니다.", }, ) @@ -17844,6 +26711,37 @@ def _run_projects_bootstrap_job(job: dict[str, Any]) -> dict[str, Any]: } +def _run_cost_analysis_payload_job(job: dict[str, Any]) -> dict[str, Any]: + job_id = str(job.get("id") or "") + params = job.get("params") if isinstance(job.get("params"), dict) else {} + start_date = normalize_text(params.get("start_date")) + end_date = normalize_text(params.get("end_date")) + mode = normalize_text(params.get("mode")) or "individual" + force = bool(params.get("force")) + codes = sorted( + { + normalize_text(code).upper() + for code in (params.get("codes") or []) + if normalize_text(code) + } + ) + _update_system_job( + job_id, + message="프로젝트 손익분석 데이터를 계산 중입니다.", + progress_current=0, + progress_total=1, + ) + payload = _cost_analysis_build_payload(start_date, end_date, mode, force=force) + return { + "start_date": payload.get("start_date"), + "end_date": payload.get("end_date"), + "mode": payload.get("mode"), + "project_count": len(payload.get("rows") or []), + "requested_codes": codes, + "cache_info": payload.get("cache_info") or {}, + } + + def _run_hanmac_preview_cache_job(job: dict[str, Any]) -> dict[str, Any]: job_id = str(job.get("id") or "") params = job.get("params") if isinstance(job.get("params"), dict) else {} @@ -17899,6 +26797,8 @@ def _run_system_job(job: dict[str, Any]) -> None: result = _run_annual_summary_bootstrap_job(job) elif job_type == "projects_bootstrap": result = _run_projects_bootstrap_job(job) + elif job_type == "cost_analysis_payload": + result = _run_cost_analysis_payload_job(job) elif job_type == "hanmac_preview_cache": result = _run_hanmac_preview_cache_job(job) elif job_type == "hanmac_aggregate_cache": @@ -18017,16 +26917,41 @@ def get_hanmac_aggregate_summary(payload: dict[str, Any]) -> dict[str, Any]: "center_duplicate_member_count": 0, "center_same_member_hidden_count": 0, "center_excluded_member_count": 0, + "affiliate_schema_count": 0, + "affiliate_actual_work_excluded_count": 0, + "center_duplicate_retained_primary_work_count": 0, + "center_duplicate_excluded_no_primary_work_count": 0, + "company_review_member_count": 0, + "no_hanmac_work_review_member_count": 0, + "researcher_member_filtered_rows": 0, + "system_member_filtered_rows": 0, "center_restored_member_count": 0, "configured_holiday_count": len(configured_holiday_dates), "joint_absent_codes": {}, "joint_assignment_source_rows": 0, "joint_assignment_records": 0, "joint_assignment_code_matched_rows": 0, + "joint_assignment_state_matched_rows": 0, "joint_assignment_text_matched_rows": 0, "joint_assignment_regular_rows": 0, "joint_assignment_overtime_rows": 0, "joint_assignment_leave_skipped_days": 0, + "status_work_source_rows": 0, + "status_work_records": 0, + "status_work_duplicate_rows": 0, + "status_work_meeting_records": 0, + "status_work_supervision_site_records": 0, + "status_work_supervision_wait_records": 0, + "status_work_regular_rows": 0, + "status_work_activity_evidence_rows": 0, + "status_work_activity_only_regular_rows": 0, + "status_work_leave_skipped_days": 0, + "status_work_personal_meeting_skipped_rows": 0, + "supervision_priority_days": 0, + "supervision_overridden_regular_rows": 0, + "regular_exact_duplicate_rows_removed": 0, + "generated_baseline_overlap_rows_removed": 0, + "activity_evidence_duplicate_rows_removed": 0, } member_info, member_diagnostics = _hanmac_load_member_info(connection, schema_name, metadata) @@ -18034,25 +26959,30 @@ def get_hanmac_aggregate_summary(payload: dict[str, Any]) -> dict[str, Any]: _hanmac_normalize_member_token(member_no): record for member_no, record in member_info.items() } + primary_regular_member_keys = _hanmac_load_member_work_keys_for_period( + connection, + schema_name, + metadata, + start_date, + end_date, + ) source_diagnostics["member_columns"] = member_diagnostics.get("member_columns", []) source_diagnostics["member_name_col"] = member_diagnostics.get("member_name_col", "") source_diagnostics["member_name_fallback_rows"] = member_diagnostics.get("member_name_fallback_rows", 0) - for diagnostics_key in ("member_group_col", "member_grade_col", "member_grade_code_map_rows", "dept_source_table", "dept_code_col", "dept_name_col", "dept_mapped_rows"): + for diagnostics_key in ("member_group_col", "member_grade_col", "member_company_col", "member_work_company_col", "member_grade_code_map_rows", "dept_source_table", "dept_code_col", "dept_name_col", "dept_mapped_rows"): source_diagnostics[diagnostics_key] = member_diagnostics.get(diagnostics_key, "") center_metadata: dict[str, list[str]] = {} center_member_info: dict[str, dict[str, Any]] = {} center_member_rows_by_key: dict[str, dict[str, Any]] = {} center_excluded_member_nos: set[str] = set() - try: - center_metadata = _hanmac_fetch_table_columns(connection, HANMAC_CENTER_MANHOUR_SCHEMA) - center_member_info, _center_diagnostics = _hanmac_load_member_info(connection, HANMAC_CENTER_MANHOUR_SCHEMA, center_metadata) - source_diagnostics["center_schema_available"] = bool(center_metadata) - except Exception as exc: - logger.info("baron_manhour center member lookup skipped: %s", exc) - center_metadata = {} - center_member_info = {} - + affiliate_work_excluded_member_keys: set[str] = set() + affiliate_work_reasons_by_member_key: dict[str, list[str]] = {} + affiliate_membership_reasons_by_member_key: dict[str, list[str]] = {} + member_review_notes_by_key: dict[str, list[str]] = {} + center_regular_member_keys: set[str] = set() + affiliate_schema_names = _hanmac_discover_affiliate_manhour_schemas(connection, schema_name) + source_diagnostics["affiliate_schema_count"] = len(affiliate_schema_names) primary_member_nos = set(member_info_by_key) primary_member_no_by_key = { _hanmac_normalize_member_token(member_no): member_no @@ -18063,6 +26993,69 @@ def get_hanmac_aggregate_summary(payload: dict[str, Any]) -> dict[str, Any]: for member_no, record in member_info.items() if _hanmac_normalize_person_name(record.get("member_name")) } + for affiliate_schema in affiliate_schema_names: + try: + affiliate_metadata = _hanmac_fetch_table_columns(connection, affiliate_schema) + affiliate_member_info, _affiliate_diagnostics = _hanmac_load_member_info(connection, affiliate_schema, affiliate_metadata) + affiliate_regular_member_keys = _hanmac_load_member_work_keys_for_period( + connection, + affiliate_schema, + affiliate_metadata, + start_date, + end_date, + ) + except Exception as exc: + logger.info("affiliate manhour lookup skipped(%s): %s", affiliate_schema, exc) + continue + affiliate_label = _hanmac_affiliate_schema_label(affiliate_schema) + if affiliate_schema == HANMAC_CENTER_MANHOUR_SCHEMA: + center_metadata = affiliate_metadata + center_member_info = affiliate_member_info + center_regular_member_keys = affiliate_regular_member_keys + source_diagnostics["center_schema_available"] = bool(center_metadata) + for affiliate_no, affiliate_record in affiliate_member_info.items(): + affiliate_key = _hanmac_normalize_member_token(affiliate_no) + affiliate_name_key = _hanmac_normalize_person_name(affiliate_record.get("member_name")) + matched_member_no = "" + if affiliate_key and affiliate_key in primary_member_nos: + matched_member_no = primary_member_no_by_key[affiliate_key] + elif affiliate_name_key and affiliate_name_key in primary_member_names: + matched_member_no = primary_member_names[affiliate_name_key] + if not matched_member_no: + continue + member_key = _hanmac_normalize_member_token(matched_member_no) + if member_key in restored_center_member_keys or affiliate_key in restored_center_member_keys: + continue + if _hanmac_member_is_active_for_period(affiliate_record, start_date, end_date): + reason = f"{affiliate_label} 소속 등록 확인" + if reason not in affiliate_membership_reasons_by_member_key.setdefault(member_key, []): + affiliate_membership_reasons_by_member_key[member_key].append(reason) + if affiliate_key not in affiliate_regular_member_keys: + continue + reason = f"{affiliate_label} 실제근무 확인" + if reason not in affiliate_work_reasons_by_member_key.setdefault(member_key, []): + affiliate_work_reasons_by_member_key[member_key].append(reason) + if member_key in primary_regular_member_keys: + member_review_notes_by_key.setdefault(member_key, []).append(f"{affiliate_label} 실제근무도 존재") + continue + affiliate_work_excluded_member_keys.add(member_key) + try: + if not center_metadata: + center_metadata = _hanmac_fetch_table_columns(connection, HANMAC_CENTER_MANHOUR_SCHEMA) + center_member_info, _center_diagnostics = _hanmac_load_member_info(connection, HANMAC_CENTER_MANHOUR_SCHEMA, center_metadata) + center_regular_member_keys = _hanmac_load_member_work_keys_for_period( + connection, + HANMAC_CENTER_MANHOUR_SCHEMA, + center_metadata, + start_date, + end_date, + ) + source_diagnostics["center_schema_available"] = bool(center_metadata) + except Exception as exc: + logger.info("baron_manhour center member lookup skipped: %s", exc) + center_metadata = {} + center_member_info = {} + center_regular_member_keys = set() for center_no, center_record in sorted(center_member_info.items(), key=lambda item: (item[1].get("member_name") or "", item[0])): center_no_key = _hanmac_normalize_member_token(center_no) center_name_key = _hanmac_normalize_person_name(center_record.get("member_name")) @@ -18096,8 +27089,22 @@ def get_hanmac_aggregate_summary(payload: dict[str, Any]) -> dict[str, Any]: restored = center_no_key in restored_center_member_keys or member_key in restored_center_member_keys if center_no_key == member_key: source_diagnostics["center_same_member_hidden_count"] += 1 - if not restored: + center_active = _hanmac_member_is_active_for_period(center_record, start_date, end_date) + has_primary_regular_work = member_key in primary_regular_member_keys + has_center_regular_work = center_no_key in center_regular_member_keys + should_exclude_center_duplicate = member_key in affiliate_work_excluded_member_keys + if should_exclude_center_duplicate: center_excluded_member_nos.add(member_key) + source_diagnostics["center_duplicate_excluded_no_primary_work_count"] += 1 + elif matched_member_no and center_active and not restored: + source_diagnostics["center_duplicate_retained_primary_work_count"] += 1 + center_status = ( + "복구" + if restored + else " / ".join(affiliate_work_reasons_by_member_key.get(member_key, [])) + " 제외" + if should_exclude_center_duplicate + else "한맥근무 유지" + ) display_key = f"member:{member_key}" existing_center_row = center_member_rows_by_key.get(display_key) if existing_center_row: @@ -18105,7 +27112,7 @@ def get_hanmac_aggregate_summary(payload: dict[str, Any]) -> dict[str, Any]: existing_center_row["center_member_nos"].append(center_no) existing_center_row["center_member_no"] = ", ".join(existing_center_row["center_member_nos"]) existing_center_row["restored"] = bool(existing_center_row["restored"] or restored) - existing_center_row["status"] = "복구" if existing_center_row["restored"] else "기본 제외" + existing_center_row["status"] = "복구" if existing_center_row["restored"] else center_status if existing_center_row["matched_by"] != matched_by: existing_center_row["matched_by"] = "사번/이름" continue @@ -18117,7 +27124,7 @@ def get_hanmac_aggregate_summary(payload: dict[str, Any]) -> dict[str, Any]: "dept_name": center_record.get("dept_name") or "", "entry_date": center_record["entry_date"].isoformat() if center_record.get("entry_date") else "", "leave_date": center_record["leave_date"].isoformat() if center_record.get("leave_date") else "", - "status": "복구" if restored else "기본 제외", + "status": center_status, "matched_by": matched_by, "source_schema": HANMAC_CENTER_MANHOUR_SCHEMA, "table_label": "센터/총괄", @@ -18130,7 +27137,9 @@ def get_hanmac_aggregate_summary(payload: dict[str, Any]) -> dict[str, Any]: ) source_diagnostics["center_member_count"] = len(center_member_info) source_diagnostics["center_duplicate_member_count"] = len(center_member_rows) + center_excluded_member_nos.update(affiliate_work_excluded_member_keys) source_diagnostics["center_excluded_member_count"] = len(center_excluded_member_nos) + source_diagnostics["affiliate_actual_work_excluded_count"] = len(affiliate_work_excluded_member_keys) source_diagnostics["center_restored_member_count"] = sum(1 for item in center_member_rows if item.get("restored")) project_map: dict[str, str] = {} @@ -18176,6 +27185,14 @@ def get_hanmac_aggregate_summary(payload: dict[str, Any]) -> dict[str, Any]: end_date, ) source_diagnostics.update(joint_assignment_diagnostics) + status_work_records, status_work_diagnostics = _hanmac_load_status_work_records( + connection, + schema_name, + metadata, + start_date, + end_date, + ) + source_diagnostics.update(status_work_diagnostics) regular_tables = [ table_name @@ -18246,7 +27263,11 @@ def get_hanmac_aggregate_summary(payload: dict[str, Any]) -> dict[str, Any]: holiday_time_hours = _hanmac_parse_duration_hours(row.get("holiday_time")) calculated_regular_hours = _hanmac_calculate_regular_hours(row.get("entry_time"), row.get("leave_time")) regular_hours = calculated_regular_hours if calculated_regular_hours > 0 else work_time_hours - official_overtime_hours = _hanmac_parse_duration_hours(row.get("overtime_time")) + official_overtime_hours, official_overtime_source = _hanmac_calculate_official_overtime_hours( + row.get("entry_time"), + row.get("overtime_time"), + row.get("leave_time"), + ) if holiday_time_hours > 0: source_diagnostics["holiday_time_rows"] += 1 source_diagnostics["holiday_time_hours"] = round(source_diagnostics["holiday_time_hours"] + holiday_time_hours, 2) @@ -18269,7 +27290,7 @@ def get_hanmac_aggregate_summary(payload: dict[str, Any]) -> dict[str, Any]: "project_code": normalize_text(row.get("project_code")) or "", "work_date": _hanmac_parse_date_value(row.get("entry_time")), "overtime_hours": round(official_overtime_hours, 2), - "source": f"{table_name}.{overtime_time_col}", + "source": f"{table_name}.{overtime_time_col}:{official_overtime_source}", } ) source_diagnostics["official_overtime_rows"] += 1 @@ -18390,6 +27411,7 @@ def get_hanmac_aggregate_summary(payload: dict[str, Any]) -> dict[str, Any]: tardy_date_col = leave_profile["date_col"] tardy_end_date_col = leave_profile["end_date_col"] tardy_project_col = leave_profile["project_col"] + tardy_state_col = leave_profile.get("state_col") tardy_value_col = leave_profile["value_col"] tardy_hour_col = leave_profile["hour_col"] tardy_min_col = leave_profile["min_col"] @@ -18412,6 +27434,7 @@ def get_hanmac_aggregate_summary(payload: dict[str, Any]) -> dict[str, Any]: {_hanmac_build_select_alias(tardy_date_col, "work_date")}, {_hanmac_build_select_alias(tardy_end_date_col, "end_date")}, {_hanmac_build_select_alias(tardy_project_col, "project_code")}, + {_hanmac_build_select_alias(tardy_state_col, "state_code")}, {_hanmac_build_text_concat_alias(leave_profile["type_cols"], "leave_type")}, {_hanmac_build_select_alias(tardy_value_col, "leave_value")}, {_hanmac_build_select_alias(tardy_hour_col, "leave_hour")}, @@ -18433,7 +27456,10 @@ def get_hanmac_aggregate_summary(payload: dict[str, Any]) -> dict[str, Any]: source_diagnostics["leave_flexible_work_excluded_rows"] += 1 leave_source_stat["flexible_work_excluded_rows"] += 1 continue - leave_rule = _hanmac_match_leave_rule(leave_type, leave_rules) + if leave_table.lower() == "userstate_tbl": + leave_rule = _hanmac_userstate_leave_rule(row.get("state_code"), leave_type) + else: + leave_rule = _hanmac_match_leave_rule(leave_type, leave_rules) if not leave_rule: continue source_diagnostics["leave_matched_rows"] += 1 @@ -18486,19 +27512,28 @@ def get_hanmac_aggregate_summary(payload: dict[str, Any]) -> dict[str, Any]: project_relation_maps = _hanmac_build_project_code_relation_maps(project_code_alias_groups) project_canonical_map: dict[str, str] = project_relation_maps["canonical_map"] project_equivalent_code_map: dict[str, set[str]] = project_relation_maps["equivalent_code_map"] + source_diagnostics["project_alias_excluded_common_codes"] = project_relation_maps["excluded_common_codes"] + source_diagnostics["project_alias_excluded_shared_codes"] = project_relation_maps["excluded_shared_alias_codes"] + project_map.setdefault("H00-대기-01", "감리대기") def canonical_project_code(project_code: Any) -> str: normalized_code = normalize_text(project_code) + if not normalized_code or normalized_code.upper() in {"0", "ZZZZZZ"}: + return "ZZZZZZ" return project_canonical_map.get(normalized_code, normalized_code) def project_display_name(project_code: Any) -> str: normalized_code = normalize_text(project_code) canonical_code = canonical_project_code(normalized_code) + if canonical_code == "ZZZZZZ": + return "공통/미지정" + if canonical_code == "H00-합사-01": + return "합사" return ( project_map.get(canonical_code) or project_map.get(normalized_code) or canonical_code - or "(미지정)" + or "공통/미지정" ) def equivalent_project_codes(project_code: Any) -> list[str]: @@ -18509,10 +27544,63 @@ def get_hanmac_aggregate_summary(payload: dict[str, Any]) -> dict[str, Any]: if code and code != canonical_code ) + def project_classification(project_code: Any, raw_project_code: Any = "", source_label: Any = "") -> dict[str, Any]: + canonical_code = canonical_project_code(project_code) + raw_code = normalize_text(raw_project_code) + source_text = normalize_text(source_label) + project_name = project_display_name(canonical_code) + equivalent_codes = {code.upper() for code in equivalent_project_codes(canonical_code)} + code_tokens = { + normalize_text(canonical_code).upper(), + raw_code.upper(), + *equivalent_codes, + } + text_blob = f"{canonical_code} {raw_code} {project_name} {' '.join(sorted(equivalent_codes))}".upper() + if canonical_code in {"", "ZZZZZZ"}: + return {"category": "common", "label": "공통/미지정", "allocable": False, "compensation_candidate": False} + if canonical_code == "H00-대기-01" or source_text == "감리대기": + return {"category": "supervision_wait", "label": "감리대기", "allocable": True, "compensation_candidate": False} + if canonical_code == "H00-합사-01": + return {"category": "common", "label": "합사 발령", "allocable": False, "compensation_candidate": False} + if ( + "HV009111" in code_tokens + or "HV-00-간접-11" in code_tokens + or canonical_code == "HXX-고문-02" + or "HV009109" in code_tokens + or "HV-00-간접-09" in code_tokens + or canonical_code == "HXX-영업-01" + ): + return {"category": "indirect_sales", "label": "영업/고문 간접", "allocable": False, "compensation_candidate": False} + if "HV009110" in code_tokens or canonical_code == "HXX-교휴-06": + return {"category": "common", "label": "기타/교휴 활동", "allocable": False, "compensation_candidate": False} + if any(token in text_blob for token in ("제안", "PQ", "입찰", "수주", "검토프로젝트")): + return {"category": "pre_sales", "label": "사전사업/제안", "allocable": False, "compensation_candidate": False} + if "회의중" in source_text and not normalize_text(project_code): + return {"category": "activity_only", "label": "회의/활동근거", "allocable": False, "compensation_candidate": False} + return {"category": "actual_project", "label": "실제 수행 프로젝트", "allocable": True, "compensation_candidate": True} + + def normalize_joint_assignment_project_code(project_code: Any, raw_project_code: Any, source_label: Any) -> str: + canonical_code = canonical_project_code(project_code) + raw_code = normalize_text(raw_project_code).upper() + source_text = normalize_text(source_label) + code_tokens = { + normalize_text(canonical_code).upper(), + raw_code, + *(code.upper() for code in equivalent_project_codes(canonical_code)), + } + if source_text == "합사" and ( + canonical_code == "HXX-교휴-06" + or "HV009110" in code_tokens + or "HXX-교휴-06" in code_tokens + ): + return "H00-합사-01" + return canonical_code + today = date.today() member_aggregates: dict[str, dict[str, Any]] = {} project_aggregates: dict[str, dict[str, Any]] = {} - leave_days_by_member_date: dict[tuple[str, date], float] = {} + leave_hours_by_member_date: dict[tuple[str, date], float] = {} + recognized_regular_hours_by_member_date: dict[tuple[str, date], float] = {} def get_member_record(member_no: str) -> dict[str, Any]: return member_info.get(member_no) or member_info_by_key.get(_hanmac_normalize_member_token(member_no)) or { @@ -18531,10 +27619,114 @@ def get_hanmac_aggregate_summary(payload: dict[str, Any]) -> dict[str, Any]: source_diagnostics["canonical_member_no_rows"] += 1 return canonical_no + affiliate_name_overrides = { + _hanmac_normalize_person_name(name): label + for name, label in { + "신현우": "바론컨설턴트", + "김소연": "바론컨설턴트", + "문수혁": "삼안", + "양규순": "삼안", + "신동호": "삼안", + "이용운": "삼안", + "전미현": "한라산업개발", + "김현지": "바론컨설턴트", + "유지원": "바론컨설턴트", + "장종찬": "바론컨설턴트", + "정태원": "바론컨설턴트", + "양병홍": "바론컨설턴트", + "한형관": "명시 제외", + }.items() + } + + company_label_by_code = { + "BARON": "바론컨설턴트", + "SAMAN": "삼안", + "SAMAHN": "삼안", + "JANGHEON": "장헌산업", + "PTC": "피티씨", + "HALLA": "한라산업개발", + "HALLASAN": "한라산업개발", + "ETC": "기타/공용", + } + + def population_detail(member_record: Mapping[str, Any], reason: str, decision: str = "소속검토필요") -> dict[str, Any]: + return { + "decision": decision, + "reason": reason, + "member_no": normalize_text(member_record.get("member_no")), + "member_name": normalize_text(member_record.get("member_name")), + "company": normalize_text(member_record.get("company")), + "work_company": normalize_text(member_record.get("work_company")), + "dept_name": normalize_text(member_record.get("dept_name")), + } + + population_decision_cache: dict[str, dict[str, Any]] = {} + population_excluded_member_keys: set[str] = set() + + def classify_population_member(member_no: str) -> dict[str, Any]: + member_key = _hanmac_normalize_member_token(member_no) + cached = population_decision_cache.get(member_key) + if cached is not None: + return cached + member_record = get_member_record(member_no) + company_code = _hanmac_company_code(member_record.get("company")) + work_company_code = _hanmac_company_code(member_record.get("work_company")) + company_codes = [code for code in (company_code, work_company_code) if code] + non_hanmac_company_codes = [ + code + for code in company_codes + if not _hanmac_company_is_hanmac(code) + ] + explicit_hanmac_company = any(_hanmac_company_is_hanmac(code) for code in company_codes) + name_key = _hanmac_normalize_person_name(member_record.get("member_name")) + explicit_affiliate_label = affiliate_name_overrides.get(name_key) + affiliate_reasons = list(affiliate_work_reasons_by_member_key.get(member_key, [])) + affiliate_membership_reasons = list(affiliate_membership_reasons_by_member_key.get(member_key, [])) + details: list[dict[str, Any]] = [] + + if _hanmac_is_system_member_record(member_record): + details.append(population_detail(member_record, "사람 이름이 아닌 조직/관리자/공용 계정", "관리자제외")) + decision = {"include": False, "label": "관리자제외", "details": details} + population_decision_cache[member_key] = decision + return decision + + if explicit_affiliate_label: + details.append(population_detail(member_record, f"{explicit_affiliate_label} 소속 명시 규칙", "계열사제외")) + for code in non_hanmac_company_codes: + label = company_label_by_code.get(code, code) + details.append(population_detail(member_record, f"현재 소속 코드가 한맥이 아님({label})", "계열사제외")) + for reason in affiliate_membership_reasons: + details.append(population_detail(member_record, reason, "계열사소속확인")) + for reason in affiliate_reasons: + details.append(population_detail(member_record, reason, "계열사근무확인")) + + has_affiliate_evidence = bool(explicit_affiliate_label or non_hanmac_company_codes or affiliate_membership_reasons or affiliate_reasons) + has_current_non_hanmac_company = bool(explicit_affiliate_label or non_hanmac_company_codes or affiliate_membership_reasons) + if has_current_non_hanmac_company: + decision = {"include": False, "label": "계열사제외", "details": details} + elif affiliate_reasons and not explicit_hanmac_company: + decision = {"include": False, "label": "계열사제외", "details": details} + elif has_affiliate_evidence: + decision = {"include": True, "label": "소속검토필요", "details": details} + else: + decision = {"include": True, "label": "", "details": []} + population_decision_cache[member_key] = decision + return decision + def include_member(member_no: str) -> bool: member_record = get_member_record(member_no) if _hanmac_normalize_member_token(member_no) in center_excluded_member_nos: return False + if _hanmac_is_researcher_grade(member_record.get("member_grade")): + source_diagnostics["researcher_member_filtered_rows"] += 1 + return False + population_decision = classify_population_member(member_no) + if population_decision.get("label") == "관리자제외": + source_diagnostics["system_member_filtered_rows"] += 1 + return False + if not population_decision.get("include", True): + population_excluded_member_keys.add(_hanmac_normalize_member_token(member_no)) + return False return _hanmac_member_matches_filter(member_record, employment_filter, start_date, end_date, today) joint_member_map: dict[str, dict[str, Any]] = {} @@ -18595,9 +27787,13 @@ def get_hanmac_aggregate_summary(payload: dict[str, Any]) -> dict[str, Any]: continue if work_date: key = (member_no, work_date) - leave_days_by_member_date[key] = round( - leave_days_by_member_date.get(key, 0.0) + row["leave_days"], - 2, + leave_hours_by_member_date[key] = round( + min( + 8.0, + leave_hours_by_member_date.get(key, 0.0) + + max(0.0, _hanmac_parse_float_value(row.get("leave_hours"))), + ), + 4, ) for record in joint_assignment_records: @@ -18616,8 +27812,8 @@ def get_hanmac_aggregate_summary(payload: dict[str, Any]) -> dict[str, Any]: continue if not _hanmac_member_is_active_on(member_record, work_date): continue - leave_days = min(1.0, max(0.0, leave_days_by_member_date.get((member_no, work_date), 0.0))) - regular_hours = round(max(0.0, 8.0 * (1.0 - leave_days)), 4) + leave_hours = min(8.0, max(0.0, leave_hours_by_member_date.get((member_no, work_date), 0.0))) + regular_hours = round(max(0.0, 8.0 - leave_hours), 4) if regular_hours <= 0: source_diagnostics["joint_assignment_leave_skipped_days"] += 1 continue @@ -18637,15 +27833,16 @@ def get_hanmac_aggregate_summary(payload: dict[str, Any]) -> dict[str, Any]: } ) source_diagnostics["joint_assignment_regular_rows"] += 1 - if leave_days <= 0: + joint_overtime_hours = _hanmac_cap_weekday_overtime(3.0 if leave_hours <= 0 else 0.0) + if joint_overtime_hours > 0: overtime_records.append( { "member_no": member_no, "project_code": normalize_text(record.get("project_code")) or "", "work_date": work_date, - "overtime_hours": 3.0, + "overtime_hours": joint_overtime_hours, "source": "합사", - "raw_overtime_hours": 3.0, + "raw_overtime_hours": joint_overtime_hours, "joint_label": record.get("joint_label") or "합사", "joint_code": record.get("joint_code") or "", "note": record.get("note") or "", @@ -18653,8 +27850,77 @@ def get_hanmac_aggregate_summary(payload: dict[str, Any]) -> dict[str, Any]: ) source_diagnostics["joint_assignment_overtime_rows"] += 1 + baseline_regular_day_keys = { + (canonical_member_no(row["member_no"]), row.get("work_date")) + for row in regular_records + if row.get("work_date") + and ( + _hanmac_parse_float_value(row.get("regular_hours")) > 0 + or _hanmac_parse_float_value(row.get("holiday_hours")) > 0 + ) + } + for record in status_work_records: + member_no = canonical_member_no(record["member_no"]) + if not include_member(member_no): + continue + member_record = get_member_record(member_no) + record_start = record.get("start_date") + record_end = record.get("end_date") or record_start + if not record_start or not record_end: + continue + if record_end < record_start: + record_start, record_end = record_end, record_start + for work_date in _hanmac_iter_dates(record_start, record_end): + if work_date.weekday() >= 5 or work_date in configured_holiday_dates: + continue + if not _hanmac_member_is_active_on(member_record, work_date): + continue + leave_hours = min(8.0, max(0.0, leave_hours_by_member_date.get((member_no, work_date), 0.0))) + regular_hours = round(max(0.0, 8.0 - leave_hours), 4) + if regular_hours <= 0: + source_diagnostics["status_work_leave_skipped_days"] += 1 + continue + state_code = normalize_text(record.get("state_code")).zfill(2) + is_activity_evidence = state_code == "03" and (member_no, work_date) in baseline_regular_day_keys + applied_regular_hours = 0.0 if is_activity_evidence else regular_hours + regular_records.append( + { + "member_no": member_no, + "project_code": "H00-대기-01" if state_code == "23" else normalize_text(record.get("project_code")) or "", + "raw_project_code": normalize_text(record.get("project_code")) or "", + "work_date": work_date, + "entry_time": None, + "leave_time": None, + "regular_hours": applied_regular_hours, + "holiday_hours": 0.0, + "source_label": record.get("source_label") or "상태근무", + "status_label": record.get("status_label") or "상태근무", + "state_code": state_code, + "record_role": "activity_evidence" if is_activity_evidence else "baseline", + "cost_weight": _hanmac_parse_float_value(record.get("cost_weight")) or 1.0, + "note": record.get("note") or "", + } + ) + if is_activity_evidence: + source_diagnostics["status_work_activity_evidence_rows"] += 1 + else: + baseline_regular_day_keys.add((member_no, work_date)) + source_diagnostics["status_work_regular_rows"] += 1 + if state_code == "03": + source_diagnostics["status_work_activity_only_regular_rows"] += 1 + def ensure_member_bucket(member_no: str) -> dict[str, Any]: member_record = get_member_record(member_no) + member_key = _hanmac_normalize_member_token(member_no) + review_notes = member_review_notes_by_key.setdefault(member_key, []) + population_decision = classify_population_member(member_no) + population_details = list(population_decision.get("details") or []) + for detail in population_details: + note = normalize_text(detail.get("reason")) + if note and note not in review_notes: + review_notes.append(note) + company_code = normalize_text(member_record.get("company")) + work_company_code = normalize_text(member_record.get("work_company")) return member_aggregates.setdefault( member_no, { @@ -18665,6 +27931,11 @@ def get_hanmac_aggregate_summary(payload: dict[str, Any]) -> dict[str, Any]: "leave_date": member_record["leave_date"].isoformat() if member_record["leave_date"] else "", "dept_name": member_record["dept_name"], "member_grade": member_record.get("member_grade", ""), + "population_review_notes": review_notes, + "population_review_label": population_decision.get("label") or "", + "population_review_details": population_details, + "company": company_code, + "work_company": work_company_code, "regular_hours": 0.0, "overtime_hours": 0.0, "holiday_hours": 0.0, @@ -18677,13 +27948,15 @@ def get_hanmac_aggregate_summary(payload: dict[str, Any]) -> dict[str, Any]: "holiday_details": [], "overtime_details": [], "leave_details": [], + "missing_regular_details": [], "multi_entry_days": 0, "multi_entry_details": [], + "overlap_type_counts": {}, }, ) def ensure_project_bucket(project_code: str) -> dict[str, Any]: - project_key = canonical_project_code(project_code) or "(미지정)" + project_key = canonical_project_code(project_code) return project_aggregates.setdefault( project_key, { @@ -18705,6 +27978,13 @@ def get_hanmac_aggregate_summary(payload: dict[str, Any]) -> dict[str, Any]: }, ) + supervision_day_keys = { + (canonical_member_no(row["member_no"]), row.get("work_date")) + for row in regular_records + if normalize_text(row.get("state_code")).zfill(2) == "22" and row.get("work_date") + } + source_diagnostics["supervision_priority_days"] = len(supervision_day_keys) + regular_day_groups: dict[tuple[str, date], dict[str, Any]] = {} for row in regular_records: member_no = canonical_member_no(row["member_no"]) @@ -18715,6 +27995,9 @@ def get_hanmac_aggregate_summary(payload: dict[str, Any]) -> dict[str, Any]: if not work_date or not _hanmac_member_is_active_on(member_record, work_date): continue key = (member_no, work_date) + if key in supervision_day_keys and normalize_text(row.get("state_code")).zfill(2) != "22": + source_diagnostics["supervision_overridden_regular_rows"] += 1 + continue day_group = regular_day_groups.setdefault( key, { @@ -18723,25 +28006,96 @@ def get_hanmac_aggregate_summary(payload: dict[str, Any]) -> dict[str, Any]: "project_hours": {}, "holiday_project_hours": {}, "project_source_labels": {}, + "project_raw_codes": {}, + "project_cost_weight_sums": {}, "entries": [], + "activity_evidence": [], + "baseline_signatures": set(), + "generated_baseline_keys": set(), + "activity_evidence_signatures": set(), "ordered_entries": [], }, ) - raw_project_code = row["project_code"] - project_code = canonical_project_code(raw_project_code) + raw_project_code = row.get("raw_project_code") or row["project_code"] + source_label = normalize_text(row.get("source_label")) + project_code = ( + "H00-대기-01" + if source_label == "감리대기" + else normalize_joint_assignment_project_code(row["project_code"], raw_project_code, source_label) + ) raw_hours = max(0.0, _hanmac_parse_float_value(row["regular_hours"])) raw_holiday_hours = max(0.0, _hanmac_parse_float_value(row.get("holiday_hours"))) - source_label = normalize_text(row.get("source_label")) + record_role = normalize_text(row.get("record_role")) or "baseline" + cost_weight = _hanmac_parse_float_value(row.get("cost_weight")) or 1.0 + if record_role == "activity_evidence": + evidence_signature = ( + project_code, + source_label, + normalize_text(row.get("note")), + ) + if evidence_signature in day_group["activity_evidence_signatures"]: + source_diagnostics["activity_evidence_duplicate_rows_removed"] += 1 + continue + day_group["activity_evidence_signatures"].add(evidence_signature) + day_group["activity_evidence"].append( + { + "project_code": project_code or "ZZZZZZ", + "project_name": project_display_name(project_code), + "raw_project_code": raw_project_code, + "equivalent_project_codes": equivalent_project_codes(project_code), + "project_classification": project_classification(project_code, raw_project_code, source_label), + "regular_hours": 0.0, + "holiday_hours": 0.0, + "source_label": source_label, + "note": normalize_text(row.get("note")), + "record_role": record_role, + } + ) + if source_label: + day_group["project_source_labels"].setdefault(project_code, set()).add(source_label) + continue + if source_label in {"감리현장", "감리대기", "합사"}: + generated_baseline_key = ( + project_code, + source_label, + normalize_text(row.get("state_code")).zfill(2), + ) + if generated_baseline_key in day_group["generated_baseline_keys"]: + source_diagnostics["generated_baseline_overlap_rows_removed"] += 1 + continue + day_group["generated_baseline_keys"].add(generated_baseline_key) + baseline_signature = ( + project_code, + source_label, + normalize_text(row.get("state_code")).zfill(2), + normalize_text(row.get("joint_code")), + normalize_text(row.get("note")), + row.get("entry_time"), + row.get("leave_time"), + round(raw_hours, 4), + round(raw_holiday_hours, 4), + ) + if baseline_signature in day_group["baseline_signatures"]: + source_diagnostics["regular_exact_duplicate_rows_removed"] += 1 + continue + day_group["baseline_signatures"].add(baseline_signature) day_group["project_hours"][project_code] = round( day_group["project_hours"].get(project_code, 0.0) + raw_hours, 4, ) + if raw_hours > 0: + day_group["project_cost_weight_sums"][project_code] = round( + day_group["project_cost_weight_sums"].get(project_code, 0.0) + (raw_hours * cost_weight), + 4, + ) day_group["holiday_project_hours"][project_code] = round( day_group["holiday_project_hours"].get(project_code, 0.0) + raw_holiday_hours, 4, ) if source_label: day_group["project_source_labels"].setdefault(project_code, set()).add(source_label) + if raw_project_code and raw_project_code != project_code: + day_group["project_raw_codes"].setdefault(project_code, set()).add(raw_project_code) day_group["entries"].append( { "project_code": project_code or "(미지정)", @@ -18751,6 +28105,8 @@ def get_hanmac_aggregate_summary(payload: dict[str, Any]) -> dict[str, Any]: "regular_hours": round(raw_hours, 2), "holiday_hours": round(raw_holiday_hours, 2), "source_label": source_label, + "record_role": record_role, + "cost_weight": round(cost_weight, 4), } ) day_group["ordered_entries"].append( @@ -18766,13 +28122,402 @@ def get_hanmac_aggregate_summary(payload: dict[str, Any]) -> dict[str, Any]: for (member_no, work_date), day_group in regular_day_groups.items(): member_bucket = ensure_member_bucket(member_no) + leave_hours = min(8.0, max(0.0, leave_hours_by_member_date.get((member_no, work_date), 0.0))) + leave_days = round(leave_hours / 8.0, 4) + is_configured_holiday = work_date in configured_holiday_dates + raw_holiday_total_hours_before_adjustment = round(sum(day_group["holiday_project_hours"].values()), 2) + is_weekend_or_holiday = ( + work_date.weekday() >= 5 + or is_configured_holiday + or raw_holiday_total_hours_before_adjustment > 0 + ) + weekday_cap = 0.0 if is_weekend_or_holiday else max(0.0, 8.0 - leave_hours) + generated_source_labels = {"감리현장", "감리대기", "합사"} + rebuilt_entries: list[dict[str, Any]] = [] + generated_suppressed_evidence: list[dict[str, Any]] = [] + project_real_hours: dict[str, float] = {} + for entry in day_group["entries"]: + entry_project_code = canonical_project_code(entry.get("project_code")) + entry_source_label = normalize_text(entry.get("source_label")) + entry_hours = max(0.0, _hanmac_parse_float_value(entry.get("regular_hours"))) + if entry_source_label not in generated_source_labels and entry_hours > 0: + project_real_hours[entry_project_code] = round( + project_real_hours.get(entry_project_code, 0.0) + entry_hours, + 4, + ) + + generated_topup_used_by_project: dict[str, float] = {} + for entry in day_group["entries"]: + entry_project_code = canonical_project_code(entry.get("project_code")) + entry_source_label = normalize_text(entry.get("source_label")) + entry_hours = max(0.0, _hanmac_parse_float_value(entry.get("regular_hours"))) + if entry_source_label not in generated_source_labels or entry_hours <= 0 or project_real_hours.get(entry_project_code, 0.0) <= 0: + rebuilt_entries.append(entry) + continue + already_used = generated_topup_used_by_project.get(entry_project_code, 0.0) + topup_capacity = max(0.0, weekday_cap - project_real_hours.get(entry_project_code, 0.0) - already_used) + topup_hours = round(min(entry_hours, topup_capacity), 4) + evidence_entry = { + "project_code": entry_project_code or "ZZZZZZ", + "project_name": project_display_name(entry_project_code), + "raw_project_code": normalize_text(entry.get("raw_project_code")), + "equivalent_project_codes": equivalent_project_codes(entry_project_code), + "project_classification": project_classification( + entry_project_code, + entry.get("raw_project_code"), + entry_source_label, + ), + "regular_hours": 0.0, + "holiday_hours": 0.0, + "source_label": entry_source_label, + "note": "실제근무행 우선 적용으로 상태근무 생성행은 근거로만 보관", + "record_role": "generated_evidence", + } + generated_suppressed_evidence.append(evidence_entry) + if topup_hours > 0: + topup_entry = { + **entry, + "regular_hours": round(topup_hours, 2), + "source_label": f"{entry_source_label} 보정", + "record_role": "generated_topup", + } + rebuilt_entries.append(topup_entry) + generated_topup_used_by_project[entry_project_code] = round(already_used + topup_hours, 4) + source_diagnostics["generated_status_topup_rows"] = ( + int(source_diagnostics.get("generated_status_topup_rows") or 0) + 1 + ) + source_diagnostics["generated_status_topup_hours"] = round( + float(source_diagnostics.get("generated_status_topup_hours") or 0.0) + topup_hours, + 2, + ) + else: + source_diagnostics["generated_status_suppressed_rows"] = ( + int(source_diagnostics.get("generated_status_suppressed_rows") or 0) + 1 + ) + source_diagnostics["generated_status_suppressed_hours"] = round( + float(source_diagnostics.get("generated_status_suppressed_hours") or 0.0) + entry_hours, + 2, + ) + if len(rebuilt_entries) != len(day_group["entries"]) or generated_topup_used_by_project: + day_group["entries"] = rebuilt_entries + day_group["project_hours"] = {} + day_group["holiday_project_hours"] = {} + day_group["project_source_labels"] = {} + day_group["project_raw_codes"] = {} + day_group["project_cost_weight_sums"] = {} + for entry in day_group["entries"]: + entry_project_code = canonical_project_code(entry.get("project_code")) + entry_source_label = normalize_text(entry.get("source_label")) + entry_hours = max(0.0, _hanmac_parse_float_value(entry.get("regular_hours"))) + entry_holiday_hours = max(0.0, _hanmac_parse_float_value(entry.get("holiday_hours"))) + entry_cost_weight = _hanmac_parse_float_value(entry.get("cost_weight")) or 1.0 + day_group["project_hours"][entry_project_code] = round( + day_group["project_hours"].get(entry_project_code, 0.0) + entry_hours, + 4, + ) + if entry_hours > 0: + day_group["project_cost_weight_sums"][entry_project_code] = round( + day_group["project_cost_weight_sums"].get(entry_project_code, 0.0) + (entry_hours * entry_cost_weight), + 4, + ) + day_group["holiday_project_hours"][entry_project_code] = round( + day_group["holiday_project_hours"].get(entry_project_code, 0.0) + entry_holiday_hours, + 4, + ) + if entry_source_label: + day_group["project_source_labels"].setdefault(entry_project_code, set()).add(entry_source_label) + raw_entry_code = normalize_text(entry.get("raw_project_code")) + if raw_entry_code and raw_entry_code != entry_project_code: + day_group["project_raw_codes"].setdefault(entry_project_code, set()).add(raw_entry_code) + day_group["activity_evidence"].extend(generated_suppressed_evidence) + positive_project_codes_for_fallback = { + project_code + for project_code, project_hours in day_group["project_hours"].items() + if project_hours > 0 + } + if len(positive_project_codes_for_fallback) == 1 and not is_weekend_or_holiday and leave_hours <= 0 and weekday_cap > 0: + fallback_project_code = next(iter(positive_project_codes_for_fallback)) + fallback_classification = project_classification(fallback_project_code) + actual_activity_evidence = [] + seen_activity_project_codes: set[str] = set() + for evidence in day_group["activity_evidence"]: + evidence_project_code = canonical_project_code(evidence.get("project_code")) + evidence_classification = evidence.get("project_classification") or project_classification( + evidence_project_code, + evidence.get("raw_project_code"), + evidence.get("source_label"), + ) + if ( + evidence_project_code != "ZZZZZZ" + and evidence_classification.get("category") == "actual_project" + and evidence_project_code not in seen_activity_project_codes + ): + actual_activity_evidence.append(evidence) + seen_activity_project_codes.add(evidence_project_code) + if fallback_classification.get("category") == "common" and actual_activity_evidence: + replaced_hours = round(day_group["project_hours"].get(fallback_project_code, 0.0), 4) + replacement_hours = round(min(replaced_hours, weekday_cap), 4) + retained_entries = [] + fallback_entries = [] + for entry in day_group["entries"]: + if canonical_project_code(entry.get("project_code")) == fallback_project_code: + fallback_entries.append(entry) + else: + retained_entries.append(entry) + if replacement_hours > 0 and fallback_entries: + for entry in fallback_entries: + day_group["activity_evidence"].append( + { + "project_code": fallback_project_code or "ZZZZZZ", + "project_name": project_display_name(fallback_project_code), + "raw_project_code": normalize_text(entry.get("raw_project_code")), + "equivalent_project_codes": equivalent_project_codes(fallback_project_code), + "project_classification": project_classification( + fallback_project_code, + entry.get("raw_project_code"), + entry.get("source_label"), + ), + "regular_hours": 0.0, + "holiday_hours": 0.0, + "source_label": normalize_text(entry.get("source_label")) or "상태근무", + "note": "실제 프로젝트 활동근거가 있어 합사/기타 생성행은 근거로만 보관", + "record_role": "nonallocable_fallback_evidence", + } + ) + allocated_replacement = _hanmac_allocate_recognized_hours( + replacement_hours, + {index: 1.0 for index, _evidence in enumerate(actual_activity_evidence)}, + ) + for index, evidence in enumerate(actual_activity_evidence): + evidence_project_code = canonical_project_code(evidence.get("project_code")) + evidence_hours = round(allocated_replacement.get(index, 0.0), 4) + if evidence_hours <= 0: + continue + raw_evidence_code = normalize_text(evidence.get("raw_project_code")) + retained_entries.append( + { + "project_code": evidence_project_code, + "project_name": project_display_name(evidence_project_code), + "raw_project_code": raw_evidence_code, + "equivalent_project_codes": equivalent_project_codes(evidence_project_code), + "regular_hours": round(evidence_hours, 2), + "holiday_hours": 0.0, + "source_label": "활동근거 대체", + "record_role": "activity_replacement", + "cost_weight": 1.0, + } + ) + day_group["entries"] = retained_entries + day_group["project_hours"] = {} + day_group["holiday_project_hours"] = {} + day_group["project_source_labels"] = {} + day_group["project_raw_codes"] = {} + day_group["project_cost_weight_sums"] = {} + for entry in day_group["entries"]: + entry_project_code = canonical_project_code(entry.get("project_code")) + entry_source_label = normalize_text(entry.get("source_label")) + entry_hours = max(0.0, _hanmac_parse_float_value(entry.get("regular_hours"))) + entry_holiday_hours = max(0.0, _hanmac_parse_float_value(entry.get("holiday_hours"))) + entry_cost_weight = _hanmac_parse_float_value(entry.get("cost_weight")) or 1.0 + day_group["project_hours"][entry_project_code] = round( + day_group["project_hours"].get(entry_project_code, 0.0) + entry_hours, + 4, + ) + if entry_hours > 0: + day_group["project_cost_weight_sums"][entry_project_code] = round( + day_group["project_cost_weight_sums"].get(entry_project_code, 0.0) + (entry_hours * entry_cost_weight), + 4, + ) + day_group["holiday_project_hours"][entry_project_code] = round( + day_group["holiday_project_hours"].get(entry_project_code, 0.0) + entry_holiday_hours, + 4, + ) + if entry_source_label: + day_group["project_source_labels"].setdefault(entry_project_code, set()).add(entry_source_label) + raw_entry_code = normalize_text(entry.get("raw_project_code")) + if raw_entry_code and raw_entry_code != entry_project_code: + day_group["project_raw_codes"].setdefault(entry_project_code, set()).add(raw_entry_code) + source_diagnostics["nonallocable_fallback_replaced_rows"] = ( + int(source_diagnostics.get("nonallocable_fallback_replaced_rows") or 0) + len(fallback_entries) + ) + source_diagnostics["nonallocable_fallback_replaced_hours"] = round( + float(source_diagnostics.get("nonallocable_fallback_replaced_hours") or 0.0) + replacement_hours, + 2, + ) + positive_project_codes_before_priority = { + project_code + for project_code, project_hours in day_group["project_hours"].items() + if project_hours > 0 + } + has_wait_project = "H00-대기-01" in positive_project_codes_before_priority + actual_project_codes = [ + project_code + for project_code in positive_project_codes_before_priority + if project_classification(project_code).get("category") == "actual_project" + ] + if has_wait_project and actual_project_codes: + removed_hours = day_group["project_hours"].pop("H00-대기-01", 0.0) + day_group["holiday_project_hours"].pop("H00-대기-01", None) + day_group["project_cost_weight_sums"].pop("H00-대기-01", None) + day_group["project_source_labels"].pop("H00-대기-01", None) + day_group["project_raw_codes"].pop("H00-대기-01", None) + day_group["entries"] = [ + entry + for entry in day_group["entries"] + if canonical_project_code(entry.get("project_code")) != "H00-대기-01" + ] + source_diagnostics["supervision_wait_replaced_by_project_rows"] = ( + int(source_diagnostics.get("supervision_wait_replaced_by_project_rows") or 0) + 1 + ) + source_diagnostics["supervision_wait_replaced_by_project_hours"] = round( + float(source_diagnostics.get("supervision_wait_replaced_by_project_hours") or 0.0) + removed_hours, + 2, + ) + elif has_wait_project: + retained_entries = [] + for entry in day_group["entries"]: + entry_project_code = canonical_project_code(entry.get("project_code")) + if entry_project_code == "H00-대기-01": + retained_entries.append(entry) + continue + entry_classification = project_classification(entry_project_code, entry.get("raw_project_code"), entry.get("source_label")) + if entry_classification.get("category") in {"pre_sales", "indirect_sales", "common", "activity_only"}: + removed_hours = day_group["project_hours"].pop(entry_project_code, 0.0) + day_group["holiday_project_hours"].pop(entry_project_code, None) + day_group["project_cost_weight_sums"].pop(entry_project_code, None) + day_group["project_source_labels"].pop(entry_project_code, None) + day_group["project_raw_codes"].pop(entry_project_code, None) + source_diagnostics["supervision_wait_retained_over_presales_rows"] = ( + int(source_diagnostics.get("supervision_wait_retained_over_presales_rows") or 0) + 1 + ) + source_diagnostics["supervision_wait_retained_over_presales_hours"] = round( + float(source_diagnostics.get("supervision_wait_retained_over_presales_hours") or 0.0) + removed_hours, + 2, + ) + continue + retained_entries.append(entry) + day_group["entries"] = retained_entries + positive_project_codes_after_wait = { + project_code + for project_code, project_hours in day_group["project_hours"].items() + if project_hours > 0 + } + allocable_positive_project_codes = { + project_code + for project_code in positive_project_codes_after_wait + if project_classification(project_code).get("allocable") + } + if allocable_positive_project_codes and len(positive_project_codes_after_wait) > len(allocable_positive_project_codes): + retained_entries = [] + for entry in day_group["entries"]: + entry_project_code = canonical_project_code(entry.get("project_code")) + entry_classification = project_classification(entry_project_code, entry.get("raw_project_code"), entry.get("source_label")) + if entry_project_code not in allocable_positive_project_codes and not entry_classification.get("allocable"): + removed_hours = day_group["project_hours"].pop(entry_project_code, 0.0) + day_group["holiday_project_hours"].pop(entry_project_code, None) + day_group["project_cost_weight_sums"].pop(entry_project_code, None) + day_group["project_source_labels"].pop(entry_project_code, None) + day_group["project_raw_codes"].pop(entry_project_code, None) + source_diagnostics["nonallocable_project_suppressed_rows"] = ( + int(source_diagnostics.get("nonallocable_project_suppressed_rows") or 0) + 1 + ) + source_diagnostics["nonallocable_project_suppressed_hours"] = round( + float(source_diagnostics.get("nonallocable_project_suppressed_hours") or 0.0) + removed_hours, + 2, + ) + continue + retained_entries.append(entry) + day_group["entries"] = retained_entries + positive_project_codes_after_allocable = { + project_code + for project_code, project_hours in day_group["project_hours"].items() + if project_hours > 0 + } + if len(positive_project_codes_after_allocable) > 1: + retained_entries = [] + for entry in day_group["entries"]: + entry_project_code = canonical_project_code(entry.get("project_code")) + entry_classification = project_classification(entry_project_code, entry.get("raw_project_code"), entry.get("source_label")) + if entry_classification.get("category") in {"common", "activity_only", "indirect_sales"}: + removed_hours = day_group["project_hours"].pop(entry_project_code, 0.0) + day_group["holiday_project_hours"].pop(entry_project_code, None) + day_group["project_cost_weight_sums"].pop(entry_project_code, None) + day_group["project_source_labels"].pop(entry_project_code, None) + day_group["project_raw_codes"].pop(entry_project_code, None) + source_diagnostics["nonallocable_project_suppressed_rows"] = ( + int(source_diagnostics.get("nonallocable_project_suppressed_rows") or 0) + 1 + ) + source_diagnostics["nonallocable_project_suppressed_hours"] = round( + float(source_diagnostics.get("nonallocable_project_suppressed_hours") or 0.0) + removed_hours, + 2, + ) + continue + retained_entries.append(entry) + day_group["entries"] = retained_entries raw_total_hours = round(sum(day_group["project_hours"].values()), 2) raw_holiday_total_hours = round(sum(day_group["holiday_project_hours"].values()), 2) - leave_days = min(1.0, max(0.0, leave_days_by_member_date.get((member_no, work_date), 0.0))) - is_configured_holiday = work_date in configured_holiday_dates is_weekend_or_holiday = work_date.weekday() >= 5 or is_configured_holiday or raw_holiday_total_hours > 0 - weekday_cap = 0.0 if is_weekend_or_holiday else max(0.0, 8.0 * (1.0 - leave_days)) - capped_regular_hours = _hanmac_floor_regular_hours(min(raw_total_hours, weekday_cap)) + weekday_cap = 0.0 if is_weekend_or_holiday else max(0.0, 8.0 - leave_hours) + if not is_weekend_or_holiday and leave_hours <= 0 and raw_total_hours < weekday_cap: + compensation_candidates: list[dict[str, Any]] = [] + for evidence in day_group["activity_evidence"]: + evidence_project_code = canonical_project_code(evidence.get("project_code")) + evidence_classification = evidence.get("project_classification") or project_classification( + evidence_project_code, + evidence.get("raw_project_code"), + evidence.get("source_label"), + ) + if evidence_classification.get("compensation_candidate") and evidence_project_code != "ZZZZZZ": + compensation_candidates.append(evidence) + if compensation_candidates: + missing_hours = round(max(0.0, weekday_cap - raw_total_hours), 4) + candidate_weights = { + index: 1.0 + for index, _evidence in enumerate(compensation_candidates) + } + allocated_compensation = _hanmac_allocate_recognized_hours(missing_hours, candidate_weights) + for index, evidence in enumerate(compensation_candidates): + compensation_hours = round(allocated_compensation.get(index, 0.0), 4) + if compensation_hours <= 0: + continue + evidence_project_code = canonical_project_code(evidence.get("project_code")) + day_group["project_hours"][evidence_project_code] = round( + day_group["project_hours"].get(evidence_project_code, 0.0) + compensation_hours, + 4, + ) + day_group["project_cost_weight_sums"][evidence_project_code] = round( + day_group["project_cost_weight_sums"].get(evidence_project_code, 0.0) + compensation_hours, + 4, + ) + day_group["project_source_labels"].setdefault(evidence_project_code, set()).add("회의근거 보정") + raw_evidence_code = normalize_text(evidence.get("raw_project_code")) + if raw_evidence_code and raw_evidence_code != evidence_project_code: + day_group["project_raw_codes"].setdefault(evidence_project_code, set()).add(raw_evidence_code) + day_group["entries"].append( + { + "project_code": evidence_project_code, + "project_name": project_display_name(evidence_project_code), + "raw_project_code": raw_evidence_code, + "equivalent_project_codes": equivalent_project_codes(evidence_project_code), + "regular_hours": round(compensation_hours, 2), + "holiday_hours": 0.0, + "source_label": "회의근거 보정", + "record_role": "compensation", + "cost_weight": 1.0, + } + ) + raw_total_hours = round(sum(day_group["project_hours"].values()), 2) + source_diagnostics["meeting_evidence_compensation_days"] = ( + int(source_diagnostics.get("meeting_evidence_compensation_days") or 0) + 1 + ) + source_diagnostics["meeting_evidence_compensation_hours"] = round( + float(source_diagnostics.get("meeting_evidence_compensation_hours") or 0.0) + missing_hours, + 2, + ) + if (member_no, work_date) in supervision_day_keys: + capped_regular_hours = round(min(raw_total_hours, weekday_cap), 2) + else: + capped_regular_hours = _hanmac_floor_regular_hours(min(raw_total_hours, weekday_cap)) holiday_source_total = raw_holiday_total_hours if raw_holiday_total_hours > 0 else (raw_total_hours if is_weekend_or_holiday else 0.0) holiday_hours = _hanmac_cap_holiday_hours(holiday_source_total) allocated_project_regular_hours = _hanmac_allocate_recognized_hours( @@ -18789,6 +28534,7 @@ def get_hanmac_aggregate_summary(payload: dict[str, Any]) -> dict[str, Any]: member_bucket["regular_hours"] += capped_regular_hours member_bucket["holiday_hours"] += holiday_hours + recognized_regular_hours_by_member_date[(member_no, work_date)] = round(capped_regular_hours, 4) if capped_regular_hours > 0: member_bucket["regular_work_days"] += 1 member_bucket["regular_details"].append( @@ -18797,20 +28543,32 @@ def get_hanmac_aggregate_summary(payload: dict[str, Any]) -> dict[str, Any]: "regular_hours": capped_regular_hours, "raw_total_hours": raw_total_hours, "leave_days": round(leave_days, 2), + "leave_hours": round(leave_hours, 2), "projects": sorted( [ { "project_code": project_code or "(미지정)", "project_name": project_display_name(project_code), "equivalent_project_codes": equivalent_project_codes(project_code), + "source_project_codes": sorted(day_group["project_raw_codes"].get(project_code, set())), "hours": round(project_hours, 2), "recognized_hours": round(allocated_project_regular_hours.get(project_code, 0.0), 2), "source_label": ", ".join(sorted(day_group["project_source_labels"].get(project_code, set()))), + "cost_weight": round( + day_group["project_cost_weight_sums"].get(project_code, project_hours) / project_hours + if project_hours > 0 + else 1.0, + 4, + ), } for project_code, project_hours in day_group["project_hours"].items() ], key=lambda item: (-item["hours"], item["project_code"]), ), + "activity_evidence": sorted( + day_group["activity_evidence"], + key=lambda item: (item["project_code"], item.get("source_label") or ""), + ), } ) if holiday_hours > 0: @@ -18843,7 +28601,59 @@ def get_hanmac_aggregate_summary(payload: dict[str, Any]) -> dict[str, Any]: if project_code: member_bucket["project_codes"].add(project_code) - if len(day_group["entries"]) > 1: + positive_project_codes = { + project_code + for project_code, project_hours in day_group["project_hours"].items() + if project_hours > 0 + } + evidence_project_codes = { + evidence_project_code + for entry in day_group["activity_evidence"] + if ( + (evidence_project_code := canonical_project_code(entry.get("project_code"))) != "ZZZZZZ" + and (entry.get("project_classification") or project_classification( + evidence_project_code, + entry.get("raw_project_code"), + entry.get("source_label"), + )).get("category") == "actual_project" + ) + } + conflicting_evidence_codes = evidence_project_codes - positive_project_codes + overlap_type = "" + if len(positive_project_codes) > 1: + overlap_type = "복수 프로젝트 배부" + elif conflicting_evidence_codes: + overlap_type = "실제 프로젝트 활동근거 검토" + else: + positive_entries = [ + entry + for entry in day_group["entries"] + if ( + float(entry.get("regular_hours") or 0.0) > 0 + or float(entry.get("holiday_hours") or 0.0) > 0 + ) + ] + positive_entry_project_codes = { + canonical_project_code(entry.get("project_code")) + for entry in positive_entries + } + if ( + len(positive_entry_project_codes) == 1 + and any(normalize_text(entry.get("record_role")) == "compensation" for entry in positive_entries) + ): + positive_entries = [] + if not overlap_type and len(positive_entries) > 1: + entry_signatures = { + ( + canonical_project_code(entry.get("project_code")), + normalize_text(entry.get("source_label")), + round(float(entry.get("regular_hours") or 0.0), 2), + ) + for entry in positive_entries + } + overlap_type = "원천 데이터 중복" if len(entry_signatures) < len(positive_entries) else "동일 프로젝트 근무기록 중첩" + + if overlap_type: collapsed_entries: dict[tuple[str, str, str], dict[str, Any]] = {} for entry in day_group["entries"]: collapse_key = ( @@ -18870,14 +28680,22 @@ def get_hanmac_aggregate_summary(payload: dict[str, Any]) -> dict[str, Any]: ) collapsed_entry["row_count"] += 1 member_bucket["multi_entry_days"] += 1 + member_bucket["overlap_type_counts"][overlap_type] = ( + member_bucket["overlap_type_counts"].get(overlap_type, 0) + 1 + ) member_bucket["multi_entry_details"].append( { "work_date": work_date.isoformat(), + "overlap_type": overlap_type, "row_count": len(day_group["entries"]), "display_row_count": len(collapsed_entries), "raw_total_hours": raw_total_hours, "capped_regular_hours": capped_regular_hours, "leave_days": round(leave_days, 2), + "activity_evidence": sorted( + day_group["activity_evidence"], + key=lambda item: (item["project_code"], item.get("source_label") or ""), + ), "entries": sorted( collapsed_entries.values(), key=lambda item: (-item["regular_hours"], item["project_code"]), @@ -18904,7 +28722,8 @@ def get_hanmac_aggregate_summary(payload: dict[str, Any]) -> dict[str, Any]: "raw_project_hours": project_hours, } ) - project_bucket["member_nos"].add(member_no) + if _hanmac_counts_as_member(member_record): + project_bucket["member_nos"].add(member_no) if is_weekend_or_holiday and raw_holiday_total_hours <= 0: continue @@ -18923,9 +28742,16 @@ def get_hanmac_aggregate_summary(payload: dict[str, Any]) -> dict[str, Any]: "raw_project_hours": round(project_hours, 2), "leave_days": round(leave_days, 2), "source_label": ", ".join(sorted(day_group["project_source_labels"].get(project_code, set()))), + "cost_weight": round( + day_group["project_cost_weight_sums"].get(project_code, project_hours) / project_hours + if project_hours > 0 + else 1.0, + 4, + ), } ) - project_bucket["member_nos"].add(member_no) + if _hanmac_counts_as_member(member_record): + project_bucket["member_nos"].add(member_no) overtime_day_groups: dict[tuple[str, date | None], dict[str, Any]] = {} for row in overtime_records: @@ -19073,7 +28899,8 @@ def get_hanmac_aggregate_summary(payload: dict[str, Any]) -> dict[str, Any]: "raw_holiday_hours": round(raw_overtime_hours, 2), } ) - project_bucket["member_nos"].add(member_no) + if _hanmac_counts_as_member(member_record): + project_bucket["member_nos"].add(member_no) for row in leave_records: member_no = canonical_member_no(row["member_no"]) @@ -19104,7 +28931,8 @@ def get_hanmac_aggregate_summary(payload: dict[str, Any]) -> dict[str, Any]: project_bucket = ensure_project_bucket(leave_project_code) project_bucket["legal_leave_days"] += row["leave_days"] project_bucket["legal_leave_hours"] += row.get("leave_hours", row["leave_days"] * 8.0) - project_bucket["member_nos"].add(member_no) + if _hanmac_counts_as_member(member_record): + project_bucket["member_nos"].add(member_no) project_bucket["leave_details"].append( { "work_date": row["work_date"].isoformat() if row.get("work_date") else "", @@ -19122,6 +28950,10 @@ def get_hanmac_aggregate_summary(payload: dict[str, Any]) -> dict[str, Any]: } ) + for member_no, member_record in member_info.items(): + if include_member(member_no) and _hanmac_member_is_active_for_period(member_record, start_date, end_date): + ensure_member_bucket(member_no) + for member_no, bucket in member_aggregates.items(): member_record = get_member_record(member_no) expected_regular_hours = _hanmac_expected_regular_hours_for_period( @@ -19130,11 +28962,84 @@ def get_hanmac_aggregate_summary(payload: dict[str, Any]) -> dict[str, Any]: end_date, configured_holiday_dates, ) - expected_after_leave_hours = round(max(0.0, expected_regular_hours - bucket["legal_leave_hours"]), 2) + entry_date = _hanmac_parse_date_value(member_record.get("entry_date")) + leave_date = _hanmac_parse_date_value(member_record.get("leave_date")) + effective_start = max(start_date, entry_date) if entry_date else start_date + effective_end = min(end_date, leave_date) if leave_date else end_date + expected_after_leave_hours = 0.0 + missing_regular_details: list[dict[str, Any]] = [] + if effective_end >= effective_start: + for work_date in _hanmac_iter_dates(effective_start, effective_end): + if work_date.weekday() >= 5 or work_date in configured_holiday_dates: + continue + leave_hours = min( + 8.0, + max(0.0, leave_hours_by_member_date.get((member_no, work_date), 0.0)), + ) + expected_day_hours = round(max(0.0, 8.0 - leave_hours), 2) + recognized_day_hours = round( + max(0.0, recognized_regular_hours_by_member_date.get((member_no, work_date), 0.0)), + 2, + ) + expected_after_leave_hours += expected_day_hours + missing_hours = round(max(0.0, expected_day_hours - recognized_day_hours), 2) + if missing_hours <= 0: + continue + missing_regular_details.append( + { + "work_date": work_date.isoformat(), + "expected_hours": expected_day_hours, + "recognized_hours": recognized_day_hours, + "leave_hours": round(leave_hours, 2), + "missing_hours": missing_hours, + "reason": "근무 근거 없음" if recognized_day_hours <= 0 else "부분 근무시간 부족", + } + ) + expected_after_leave_hours = round(expected_after_leave_hours, 2) regular_hour_gap = round(bucket["regular_hours"] - expected_after_leave_hours, 2) + missing_regular_hours = round(sum(item["missing_hours"] for item in missing_regular_details), 2) bucket["expected_regular_hours"] = expected_regular_hours bucket["expected_regular_after_leave_hours"] = expected_after_leave_hours bucket["regular_hour_gap"] = regular_hour_gap + bucket["missing_regular_hours"] = missing_regular_hours + bucket["missing_regular_days"] = round(missing_regular_hours / 8.0, 2) + bucket["missing_regular_date_count"] = len(missing_regular_details) + bucket["missing_regular_details"] = missing_regular_details + has_actual_hanmac_work = ( + bucket["regular_hours"] > 0 + or bucket["overtime_hours"] > 0 + or bucket["holiday_hours"] > 0 + or bool(bucket["regular_details"]) + or bool(bucket["overtime_details"]) + or bool(bucket["holiday_details"]) + ) + if not has_actual_hanmac_work: + note = "한맥 실제 근무기록 없음 · 소속 유지 가능성 검토" + if note not in bucket["population_review_notes"]: + bucket["population_review_notes"].append(note) + bucket["population_review_label"] = bucket.get("population_review_label") or "소속검토필요" + bucket.setdefault("population_review_details", []).append( + { + "decision": "소속검토필요", + "reason": note, + "member_no": bucket.get("member_no", ""), + "member_name": bucket.get("member_name", ""), + "company": bucket.get("company", ""), + "work_company": bucket.get("work_company", ""), + "dept_name": bucket.get("dept_name", ""), + } + ) + source_diagnostics["population_owner_excluded_member_count"] = len(population_excluded_member_keys) + source_diagnostics["company_review_member_count"] = sum( + 1 + for bucket in member_aggregates.values() + if any("현재 소속 코드" in note for note in (bucket.get("population_review_notes") or [])) + ) + source_diagnostics["no_hanmac_work_review_member_count"] = sum( + 1 + for bucket in member_aggregates.values() + if any("한맥 실제 근무기록 없음" in note for note in (bucket.get("population_review_notes") or [])) + ) if view_mode == "project": rows = [ @@ -19197,6 +29102,10 @@ def get_hanmac_aggregate_summary(payload: dict[str, Any]) -> dict[str, Any]: "leave_date": bucket["leave_date"], "dept_name": bucket["dept_name"], "member_grade": bucket.get("member_grade", ""), + "population_review": bucket.get("population_review_label", ""), + "population_review_notes": list(bucket.get("population_review_notes") or []), + "company": bucket.get("company", ""), + "work_company": bucket.get("work_company", ""), "regular_hours": round(bucket["regular_hours"], 2), "overtime_hours": round(bucket["overtime_hours"], 2), "holiday_hours": round(bucket["holiday_hours"], 2), @@ -19207,6 +29116,10 @@ def get_hanmac_aggregate_summary(payload: dict[str, Any]) -> dict[str, Any]: "legal_leave_hours": round(bucket["legal_leave_hours"], 2), "project_count": len(bucket["project_codes"]), "aggregate_details": { + "population_review": sorted( + bucket.get("population_review_details") or [], + key=lambda item: (item.get("decision") or "", item.get("reason") or ""), + ), "regular_hours": sorted(bucket["regular_details"], key=lambda item: item.get("work_date") or ""), "overtime_hours": sorted(bucket["overtime_details"], key=lambda item: item.get("work_date") or ""), "holiday_hours": sorted(bucket["holiday_details"], key=lambda item: item.get("work_date") or ""), @@ -19228,6 +29141,10 @@ def get_hanmac_aggregate_summary(payload: dict[str, Any]) -> dict[str, Any]: key=lambda item: (item.get("work_date") or "", item.get("detail_type") or ""), ), "legal_leave_days": sorted(bucket["leave_details"], key=lambda item: item.get("work_date") or ""), + "missing_regular_days": sorted( + bucket["missing_regular_details"], + key=lambda item: item.get("work_date") or "", + ), "project_count": sorted( [ { @@ -19241,16 +29158,29 @@ def get_hanmac_aggregate_summary(payload: dict[str, Any]) -> dict[str, Any]: ), }, "multi_entry_days": bucket["multi_entry_days"], + "overlap_type_counts": dict(sorted(bucket["overlap_type_counts"].items())), "expected_regular_hours": round(bucket.get("expected_regular_hours", 0.0), 2), "expected_regular_after_leave_hours": round(bucket.get("expected_regular_after_leave_hours", 0.0), 2), "regular_hour_gap": round(bucket.get("regular_hour_gap", 0.0), 2), + "missing_regular_hours": round(bucket.get("missing_regular_hours", 0.0), 2), + "missing_regular_days": round(bucket.get("missing_regular_days", 0.0), 2), + "missing_regular_date_count": int(bucket.get("missing_regular_date_count", 0)), "remarks": " · ".join( part for part in ( - f"중복 {bucket['multi_entry_days']}일" if bucket["multi_entry_days"] else "", + bucket.get("population_review_label", ""), ( - f"근무시간 {bucket.get('regular_hour_gap', 0.0):+,.2f}시간" - if abs(bucket.get("regular_hour_gap", 0.0)) >= 0.01 + " · ".join( + f"{label} {count}일" + for label, count in sorted(bucket["overlap_type_counts"].items()) + ) + if bucket["overlap_type_counts"] + else "" + ), + ( + f"정규근로 {bucket.get('missing_regular_days', 0.0):,.2f}일 부족" + f" ({bucket.get('missing_regular_hours', 0.0):,.2f}시간)" + if bucket.get("missing_regular_hours", 0.0) >= 0.01 else "" ), ) @@ -19268,6 +29198,7 @@ def get_hanmac_aggregate_summary(payload: dict[str, Any]) -> dict[str, Any]: {"key": "member_no", "label": "사번"}, {"key": "member_name", "label": "이름"}, {"key": "member_grade", "label": "직급"}, + {"key": "population_review", "label": "소속검토"}, {"key": "status", "label": "구분"}, {"key": "entry_date", "label": "입사일"}, {"key": "leave_date", "label": "퇴사일"}, @@ -19281,7 +29212,11 @@ def get_hanmac_aggregate_summary(payload: dict[str, Any]) -> dict[str, Any]: ] summary = { - "member_count": len(member_aggregates), + "member_count": sum( + 1 + for member_no in member_aggregates + if _hanmac_counts_as_member(get_member_record(member_no)) + ), "project_count": len(project_aggregates), "regular_hours": round(sum(item.get("regular_hours", 0.0) for item in rows), 2), "overtime_hours": round(sum(item.get("overtime_hours", 0.0) for item in rows), 2), @@ -19290,6 +29225,12 @@ def get_hanmac_aggregate_summary(payload: dict[str, Any]) -> dict[str, Any]: } summary["holiday_hours"] = round(sum(item.get("holiday_hours", 0.0) for item in rows), 2) summary["total_hours"] = round(summary["regular_hours"] + summary["overtime_hours"] + summary["holiday_hours"], 2) + source_diagnostics["headcount_excluded_researcher_count"] = sum( + 1 + for member_no in member_aggregates + if not _hanmac_counts_as_member(get_member_record(member_no)) + ) + source_diagnostics["logic_version"] = HANMAC_AGGREGATE_LOGIC_VERSION response_payload = { "status": "ok", @@ -19355,17 +29296,47 @@ def build_hanmac_mysql_error_message(exc: OperationalError) -> str: def build_wehago_compare_health_payload() -> dict[str, Any]: init_db() - payload = get_wehago_compare_dashboard( - engine, - include_metric_counts=False, - warm_caches=False, - ) + with engine.begin() as conn: + years = sorted(_discover_available_fiscal_years(conn), reverse=True) + active_row = conn.execute( + text( + """ + SELECT setting_key + FROM wehago_compare_settings + WHERE setting_key LIKE 'wehago_active_query_projection:%:%' + ORDER BY updated_at DESC + LIMIT 1 + """ + ) + ).mappings().first() + selected_start = None + selected_end = None + if active_row: + parts = normalize_text(active_row.get("setting_key")).split(":") + if len(parts) >= 3: + try: + selected_start = int(parts[-2]) + selected_end = int(parts[-1]) + except Exception: + selected_start = None + selected_end = None + if selected_start is None or selected_end is None: + fallback_year = int(years[0]) if years else date.today().year + selected_start = fallback_year + selected_end = fallback_year + projection_state = ensure_wehago_canonical_projection_state( + conn, + selected_start, + selected_end, + repair=True, + ) return { "status": "ok", - "selected_start_year": payload.get("selected_start_year"), - "selected_end_year": payload.get("selected_end_year"), - "available_year_count": len(payload.get("available_years") or []), - "metric_section_count": len(payload.get("metric_sections") or []), + "logic_version": QUERY_PROJECTION_VERSION, + "selected_start_year": selected_start, + "selected_end_year": selected_end, + "available_year_count": len(years), + "projection_state": projection_state, } @@ -19455,8 +29426,68 @@ async def cost_analysis(request: Request): @app.get("/cost-analysis/data") -async def cost_analysis_data(start_date: str = "", end_date: str = "", mode: str = "individual"): +async def cost_analysis_data( + start_date: str = "", + end_date: str = "", + mode: str = "individual", + background: bool = False, +): try: + if background: + requested_start = _parse_iso_date(start_date) or date(date.today().year, 1, 1) + requested_end = _parse_iso_date(end_date) or date.today() + if requested_end < requested_start: + requested_start, requested_end = requested_end, requested_start + requested_start, requested_end = _cost_analysis1_effective_period( + requested_start, + requested_end, + ) + cumulative_start = await run_in_threadpool( + _cost_analysis_get_accumulation_start, + requested_end.isoformat(), + ) + hanmac_refresh_jobs = await run_in_threadpool( + _cost_analysis_ensure_current_hanmac_cache_jobs, + cumulative_start, + requested_end, + ) + if hanmac_refresh_jobs: + normalized_mode = "aggregate" if normalize_text(mode).lower() in {"aggregate", "sum", "합산", "연계", "linked", "link"} else "individual" + last_valid = await run_in_threadpool( + _cost_analysis_load_last_valid_payload, + requested_start.isoformat(), + requested_end.isoformat(), + normalized_mode, + ) + return JSONResponse( + content=jsonable_encoder( + { + **(last_valid or {}), + "pending": True, + "job": hanmac_refresh_jobs[0], + "hanmac_refresh_jobs": hanmac_refresh_jobs, + "cache_info": { + **((last_valid or {}).get("cache_info") or {}), + "ready": False, + "source": "hanmac-auto-refresh", + }, + } + ), + status_code=202, + headers={"Cache-Control": "no-store, max-age=0"}, + ) + # 프로젝트 손익분석은 ERP 기초자료에 표준·조정인건비를 반영한 + # 단일 최종 payload를 반환한다. + payload = await run_in_threadpool( + _cost_analysis_build_payload, + start_date, + end_date, + mode, + ) + return JSONResponse( + content=jsonable_encoder(payload), + headers={"Cache-Control": "no-store, max-age=0"}, + ) payload = await run_in_threadpool(_cost_analysis_build_payload, start_date, end_date, mode) return JSONResponse( content=jsonable_encoder(payload), @@ -19467,6 +29498,98 @@ async def cost_analysis_data(start_date: str = "", end_date: str = "", mode: str return JSONResponse(content={"error": str(exc)}, status_code=500) +@app.post("/cost-analysis/refresh") +async def cost_analysis_refresh(request: Request): + try: + payload = await request.json() + if not isinstance(payload, dict): + payload = {} + context = await run_in_threadpool( + _cost_analysis_payload_cache_context, + normalize_text(payload.get("start_date")), + normalize_text(payload.get("end_date")), + ) + normalized_mode = "aggregate" if normalize_text(payload.get("mode")).lower() in {"aggregate", "sum", "합산", "연계", "linked", "link"} else "individual" + codes = [ + normalize_text(code).upper() + for code in (payload.get("codes") or []) + if normalize_text(code) + ] + job = await run_in_threadpool( + _create_system_job, + page_key="cost_analysis", + job_type="cost_analysis_payload", + start_year=context["start_date"].year, + end_year=context["end_date"].year, + params={ + "start_date": context["start_date"].isoformat(), + "end_date": context["end_date"].isoformat(), + "mode": normalized_mode, + "force": bool(payload.get("force")), + "codes": codes, + }, + ) + return JSONResponse(content=jsonable_encoder({"ok": True, "job": job})) + except Exception as exc: + logger.exception("프로젝트 손익분석 갱신 작업 등록 에러: %s", exc) + return JSONResponse(content={"ok": False, "error": str(exc)}, status_code=500) + + +@app.get("/cost-analysis/validate") +async def cost_analysis_validate( + start_date: str = "", + end_date: str = "", + mode: str = "individual", + codes: str = "", +): + try: + requested_codes = parse_support_dept_codes_param(codes) + if not requested_codes: + raise ValueError("검증할 프로젝트 코드가 필요합니다.") + payload = await run_in_threadpool(_cost_analysis_build_payload, start_date, end_date, mode) + filtered = await run_in_threadpool(_cost_analysis_filter_payload_codes, payload, requested_codes) + return JSONResponse( + content=jsonable_encoder(filtered), + headers={"Cache-Control": "no-store, max-age=0"}, + ) + except Exception as exc: + logger.exception("프로젝트 손익분석 선택 프로젝트 검증 에러: %s", exc) + return JSONResponse(content={"error": str(exc)}, status_code=500) + + +@app.get("/cost-analysis/cache-diagnostics") +async def cost_analysis_cache_diagnostics(start_date: str = "", end_date: str = "", mode: str = "individual"): + try: + context = await run_in_threadpool(_cost_analysis_payload_cache_context, start_date, end_date) + normalized_mode = "aggregate" if normalize_text(mode).lower() in {"aggregate", "sum", "합산", "연계", "linked", "link"} else "individual" + cached = await run_in_threadpool(_cost_analysis_load_cached_payload, context, normalized_mode) + latest_job = await run_in_threadpool( + _fetch_latest_system_job, + page_key="cost_analysis", + job_type="cost_analysis_payload", + start_year=context["start_date"].year, + end_year=context["end_date"].year, + ) + return JSONResponse( + content=jsonable_encoder( + { + "ready": cached is not None, + "mode": normalized_mode, + "start_date": context["start_date"].isoformat(), + "end_date": context["end_date"].isoformat(), + "cache_info": (cached or {}).get("cache_info") or {}, + "job": latest_job, + "financial_logic_version": COST_ANALYSIS_FINANCIAL_LOGIC_VERSION, + "h_project_mapping_version": COST_ANALYSIS_H_PROJECT_MAPPING_VERSION, + "link_logic_version": COST_ANALYSIS_LINK_LOGIC_VERSION, + } + ) + ) + except Exception as exc: + logger.exception("프로젝트 손익분석 캐시 진단 에러: %s", exc) + return JSONResponse(content={"error": str(exc)}, status_code=500) + + @app.get("/cost-analysis/missing-grade-export") async def cost_analysis_missing_grade_export(start_date: str = "", end_date: str = "", codes: str = ""): try: @@ -19620,6 +29743,7 @@ async def cost_analysis_detail( start_date: str = "", end_date: str = "", codes: str = "", + representative_code: str = "", phase: str = "", item: str = "", ): @@ -19633,9 +29757,86 @@ async def cost_analysis_detail( raise ValueError("조회할 프로젝트 코드가 필요합니다.") normalized_phase = normalize_text(phase).lower() normalized_item = normalize_text(item).lower() - completion_dates = _cost_analysis_get_completion_billing_dates() + detail_cache_key = ( + start.isoformat(), + end.isoformat(), + tuple(sorted(normalized_codes)), + normalize_text(representative_code).upper(), + normalized_phase, + normalized_item, + get_business_data_version(), + _cost_analysis_hanmac_cache_version(), + COST_ANALYSIS_FINANCIAL_LOGIC_VERSION, + COST_ANALYSIS_LINK_LOGIC_VERSION, + ) + cached_detail = _get_deepcopy_ttl_cache_entry( + _COST_ANALYSIS_DETAIL_CACHE, + _COST_ANALYSIS_DETAIL_CACHE_LOCK, + detail_cache_key, + COST_ANALYSIS_DETAIL_CACHE_TTL_SECONDS, + ) + if cached_detail is not None: + cached_detail["cache_info"] = {**(cached_detail.get("cache_info") or {}), "source": "memory"} + return JSONResponse(content=jsonable_encoder(cached_detail)) - if normalized_item == "labor": + def detail_response(payload: dict[str, Any]) -> JSONResponse: + payload["cache_info"] = { + "source": "computed", + "generated_at": datetime.now().isoformat(timespec="seconds"), + } + cached = _set_deepcopy_ttl_cache_entry( + _COST_ANALYSIS_DETAIL_CACHE, + _COST_ANALYSIS_DETAIL_CACHE_LOCK, + detail_cache_key, + payload, + ) + return JSONResponse(content=jsonable_encoder(cached)) + + completion_dates = _cost_analysis_get_completion_billing_dates() + representative_map = _cost_analysis_get_link_representative_map() + requested_total_codes = [code for code in normalized_codes if normalize_text(code).upper()[:1] in {"0", "9"}] + explicit_representative_code = normalize_text(representative_code).upper() + if explicit_representative_code[:1] not in {"0", "9"}: + explicit_representative_code = "" + + def detail_project_codes(source_code: Any) -> dict[str, str]: + individual_code = normalize_text(source_code).upper() + if not individual_code: + return { + "total_project_code": explicit_representative_code, + "project_code": "", + "source_project_code": "", + "mapping_status": "explicit" if explicit_representative_code else "unresolved", + } + total_code = explicit_representative_code or representative_map.get(individual_code, "") + mapping_status = "explicit" if explicit_representative_code else "linked" + if not total_code and individual_code[:1] in {"0", "9"}: + total_code = individual_code + mapping_status = "self-total" + if not total_code and requested_total_codes: + total_code = requested_total_codes[0] + mapping_status = "requested-total" + if not total_code: + mapping_status = "unresolved" + return { + "total_project_code": total_code, + "project_code": individual_code, + "source_project_code": individual_code, + "mapping_status": mapping_status, + } + + def enrich_detail_row(row: dict[str, Any]) -> dict[str, Any]: + enriched = dict(row) + code_fields = detail_project_codes(enriched.get("project_code") or enriched.get("source_project_code")) + for key, value in code_fields.items(): + if key in {"total_project_code", "mapping_status"}: + if not normalize_text(enriched.get(key)): + enriched[key] = value + elif not normalize_text(enriched.get(key)): + enriched[key] = value + return enriched + + if normalized_item in {"labor", "sga_labor", "labor_combined", "sga_labor_combined"}: project_meta = _cost_analysis_get_project_meta() rows = _cost_analysis_load_hanmac_labor_detail_rows_yearly( start, @@ -19644,106 +29845,263 @@ async def cost_analysis_detail( normalized_phase, project_meta, ) - if rows: - return JSONResponse( - content=jsonable_encoder( - { - "detail_type": "labor", - "rows": rows, - "total_amount": sum(normalize_amount(row.get("amount")) for row in rows), - } - ) + _, hanmac_labor_by_year, _ = _cost_analysis_load_hanmac_hours_and_labor_yearly( + start, + end, + project_meta, + set(normalized_codes), + ) + has_hanmac_labor = any( + normalize_amount(phase_amounts.get(phase_key)) + for code_map in hanmac_labor_by_year.values() + for phase_amounts in code_map.values() + for phase_key in ("pre", "during", "post") + ) + if rows or has_hanmac_labor: + rows = [enrich_detail_row(row) for row in rows] + hours_summary = { + field: round(sum(normalize_amount(row.get(field)) for row in rows), 2) + for field in ("regular_hours", "overtime_hours", "holiday_hours", "extra_hours", "total_hours") + } + return detail_response( + { + "detail_type": "labor", + "rows": rows, + "total_amount": sum(normalize_amount(row.get("amount")) for row in rows), + "hours_summary": hours_summary, + } ) if normalized_item == "collection": - in_clause, code_params = build_in_clause("cost_analysis_collection_code", normalized_codes) - query = text( - f""" - SELECT - COALESCE(date, '') AS posting_date, - '수금' AS account_name, - COALESCE(vendor, '') AS partner_name, - COALESCE(note, '') AS memo1, - COALESCE(amount, 0) AS amount - FROM project_collection_entries - WHERE support_dept_code IN ({in_clause}) - AND COALESCE(date, '') >= :start_date - AND COALESCE(date, '') <= :end_date - ORDER BY date DESC, vendor - """ - ) - params = {**code_params, "start_date": start.isoformat(), "end_date": end.isoformat()} - with engine.begin() as conn: - rows = [ + code_set = set(normalized_codes) + rows = [] + for event in _cost_analysis_erp_collection_events(end): + code = normalize_text(event.get("support_dept_code")).upper() + posting_date = _date_text(event.get("posting_date")) + if code not in code_set or not (start.isoformat() <= posting_date <= end.isoformat()): + continue + conversion_status = normalize_text(event.get("conversion_status")) + rows.append( { - "posting_date": _date_text(row["posting_date"]), - "account_name": normalize_text(row["account_name"]), - "partner_name": normalize_text(row["partner_name"]), - "memo1": normalize_text(row["memo1"]), - "amount": int(round(normalize_amount(row["amount"]))), + "posting_date": posting_date, + "account_name": normalize_text(event.get("receivable_account_name")) or "ERP 수금", + "partner_name": normalize_text(event.get("partner_name")), + **detail_project_codes(code), + "memo1": normalize_text(event.get("memo1")), + "amount": int(round(normalize_amount(event.get("amount")))), + "gross_amount": int(round(normalize_amount(event.get("gross_amount")))), + "voucher_number": normalize_text(event.get("voucher_number")), + "confirmed_voucher_number": normalize_text(event.get("confirmed_voucher_number")), + "source_invoice_voucher_number": normalize_text(event.get("source_invoice_voucher_number")), + "source_invoice_confirmed_voucher_number": normalize_text( + event.get("source_invoice_confirmed_voucher_number") + ), + "match_status": ( + "원 청구 전표 매칭" + if conversion_status == "invoice-matched" + else ( + "즉시 현금·카드 매출" + if conversion_status == "immediate-cash" + else "부가세 10% 추정 환산" + ) + ), + "included_in_total": True, } - for row in conn.execute(query, params).mappings() - ] - if not rows: - billing_query = text( - f""" - SELECT - COALESCE(COALESCE(tax_invoice_date, billing_date), '') AS posting_date, - '수금' AS account_name, - COALESCE(client_name, '') AS partner_name, - COALESCE(note, '') AS memo1, - COALESCE(collected_amount, 0) AS amount - FROM project_billing_entries - WHERE support_dept_code IN ({in_clause}) - AND COALESCE(COALESCE(tax_invoice_date, billing_date), '') >= :start_date - AND COALESCE(COALESCE(tax_invoice_date, billing_date), '') <= :end_date - AND COALESCE(collected_amount, 0) <> 0 - ORDER BY posting_date DESC, client_name - """ - ) - rows = [ - { - "posting_date": _date_text(row["posting_date"]), - "account_name": normalize_text(row["account_name"]), - "partner_name": normalize_text(row["partner_name"]), - "memo1": normalize_text(row["memo1"]), - "amount": int(round(normalize_amount(row["amount"]))), - } - for row in conn.execute(billing_query, params).mappings() - ] - return JSONResponse(content=jsonable_encoder({"rows": rows, "total_amount": sum(row["amount"] for row in rows)})) + ) + rows.sort(key=lambda row: (row.get("posting_date") or "", row.get("voucher_number") or ""), reverse=True) + rows = [enrich_detail_row(row) for row in rows] + return detail_response( + { + "detail_type": "collection", + "rows": rows, + "total_amount": sum(row["amount"] for row in rows), + } + ) if normalized_item == "billing": in_clause, code_params = build_in_clause("cost_analysis_billing_code", normalized_codes) query = text( f""" SELECT - COALESCE(COALESCE(tax_invoice_date, billing_date), '') AS posting_date, + id, + COALESCE(support_dept_code, '') AS support_dept_code, + COALESCE(billing_date, '') AS posting_date, + COALESCE(tax_invoice_date, '') AS tax_invoice_date, '청구금액' AS account_name, COALESCE(client_name, '') AS partner_name, COALESCE(note, '') AS memo1, COALESCE(billed_amount, 0) AS amount FROM project_billing_entries WHERE support_dept_code IN ({in_clause}) - AND COALESCE(COALESCE(tax_invoice_date, billing_date), '') >= :start_date - AND COALESCE(COALESCE(tax_invoice_date, billing_date), '') <= :end_date + AND COALESCE(billing_date, '') >= :start_date + AND COALESCE(billing_date, '') <= :end_date AND COALESCE(billed_amount, 0) <> 0 ORDER BY posting_date DESC, client_name """ ) params = {**code_params, "start_date": start.isoformat(), "end_date": end.isoformat()} with engine.begin() as conn: - rows = [ - { - "posting_date": _date_text(row["posting_date"]), - "account_name": normalize_text(row["account_name"]), - "partner_name": normalize_text(row["partner_name"]), - "memo1": normalize_text(row["memo1"]), - "amount": int(round(normalize_amount(row["amount"]))), - } - for row in conn.execute(query, params).mappings() + billing_rows = [dict(row) for row in conn.execute(query, params).mappings()] + erp_rows = [ + dict(row) + for row in conn.execute( + text( + f""" + SELECT + COALESCE(voucher_number, '') AS voucher_number, + COALESCE(confirmed_voucher_number, '') AS confirmed_voucher_number, + {COST_ANALYSIS_TX_DATE_SQL} AS posting_date, + COALESCE(account_code, '') AS account_code, + COALESCE(account_name, '') AS account_name, + UPPER(COALESCE(support_dept_code, '')) AS support_dept_code, + COALESCE(partner_name, '') AS partner_name, + COALESCE(memo1, '') AS memo1, + COALESCE(amount, 0) AS amount + FROM transactions + WHERE UPPER(COALESCE(support_dept_code, '')) IN ({in_clause}) + AND {COST_ANALYSIS_TX_DATE_SQL} >= :start_date + AND {COST_ANALYSIS_TX_DATE_SQL} <= :end_date + AND account_code LIKE '4%' + AND COALESCE(amount, 0) <> 0 + ORDER BY posting_date DESC, voucher_number DESC + """ + ), + params, + ).mappings() ] - return JSONResponse(content=jsonable_encoder({"rows": rows, "total_amount": sum(row["amount"] for row in rows)})) + used_erp_indexes: set[int] = set() + rows = [] + for billing_row in billing_rows: + code = normalize_text(billing_row.get("support_dept_code")).upper() + amount = normalize_amount(billing_row.get("amount")) + tax_invoice_date = _date_text(billing_row.get("tax_invoice_date")) + exact_index = next( + ( + index + for index, erp_row in enumerate(erp_rows) + if index not in used_erp_indexes + and normalize_text(erp_row.get("support_dept_code")).upper() == code + and abs(normalize_amount(erp_row.get("amount")) - amount) < 0.5 + and _date_text(erp_row.get("posting_date")) == tax_invoice_date + ), + None, + ) + amount_index = exact_index + if amount_index is None: + amount_index = next( + ( + index + for index, erp_row in enumerate(erp_rows) + if index not in used_erp_indexes + and normalize_text(erp_row.get("support_dept_code")).upper() == code + and abs(normalize_amount(erp_row.get("amount")) - amount) < 0.5 + ), + None, + ) + erp_row = erp_rows[amount_index] if amount_index is not None else {} + if amount_index is not None: + used_erp_indexes.add(amount_index) + match_status = "ERP 전표 일치" + if amount_index is None: + match_status = "ERP 전표 미확인" + elif exact_index is None: + match_status = "ERP 증빙일자 불일치" + rows.append( + { + "posting_date": _date_text(billing_row.get("posting_date")), + "tax_invoice_date": tax_invoice_date, + "account_name": normalize_text(billing_row.get("account_name")), + "partner_name": normalize_text(billing_row.get("partner_name")), + **detail_project_codes(code), + "memo1": normalize_text(billing_row.get("memo1")), + "amount": int(round(amount)), + "erp_posting_date": _date_text(erp_row.get("posting_date")), + "voucher_number": normalize_text(erp_row.get("voucher_number")), + "confirmed_voucher_number": normalize_text(erp_row.get("confirmed_voucher_number")), + "match_status": match_status, + "included_in_total": True, + } + ) + for index, erp_row in enumerate(erp_rows): + if index in used_erp_indexes: + continue + code = normalize_text(erp_row.get("support_dept_code")).upper() + rows.append( + { + "posting_date": "", + "tax_invoice_date": "", + "erp_posting_date": _date_text(erp_row.get("posting_date")), + "account_name": normalize_text(erp_row.get("account_name")), + "partner_name": normalize_text(erp_row.get("partner_name")), + **detail_project_codes(code), + "memo1": normalize_text(erp_row.get("memo1")), + "amount": int(round(normalize_amount(erp_row.get("amount")))), + "voucher_number": normalize_text(erp_row.get("voucher_number")), + "confirmed_voucher_number": normalize_text(erp_row.get("confirmed_voucher_number")), + "match_status": "DB 청구 미연결 ERP 전표", + "included_in_total": False, + } + ) + rows = [enrich_detail_row(row) for row in rows] + return detail_response( + { + "detail_type": "billing", + "rows": rows, + "total_amount": sum( + row["amount"] for row in rows if row.get("included_in_total") + ), + } + ) + + project_meta = _cost_analysis_get_project_meta() + synthetic_rows: list[dict[str, Any]] = [] + hanmac_labor_detail_rows: list[dict[str, Any]] = [] + if normalized_item in {"cost_total", "total_cost"}: + hanmac_labor_detail_rows = _cost_analysis_load_hanmac_labor_detail_rows_yearly( + start, + end, + normalized_codes, + normalized_phase, + project_meta, + ) + for labor_row in hanmac_labor_detail_rows: + synthetic_rows.append( + { + "posting_date": "한맥", + "account_name": "한맥 인건비", + "partner_name": normalize_text(labor_row.get("member_name")), + "total_project_code": normalize_text(labor_row.get("total_project_code")), + "project_code": normalize_text(labor_row.get("project_code")), + "memo1": ( + f"{normalize_text(labor_row.get('member_grade'))} " + f"총 {normalize_amount(labor_row.get('total_hours')):,.1f}h" + f" / 초과 {normalize_amount(labor_row.get('extra_hours')):,.1f}h" + ).strip(), + "amount": int(round(normalize_amount(labor_row.get("amount")))), + } + ) + if normalized_item in {"overhead", "cost_total", "total_cost"}: + synthetic_rows.extend( + _cost_analysis_build_allocated_common_detail_rows( + start, + end, + normalized_codes, + normalized_phase, + project_meta, + "overhead", + ) + ) + if normalized_item in {"sga", "sga_total", "total_cost"}: + synthetic_rows.extend( + _cost_analysis_build_allocated_common_detail_rows( + start, + end, + normalized_codes, + normalized_phase, + project_meta, + "sga", + ) + ) in_clause, code_params = build_in_clause("cost_analysis_detail_code", normalized_codes) query = text( @@ -19778,36 +30136,26 @@ async def cost_analysis_detail( code = normalize_text(row.get("support_dept_code")).upper() bucket = _cost_analysis_financial_bucket(row.get("account_code")) posting_date = _date_text(row.get("posting_date")) - row_phase = _cost_analysis_phase_for_transaction(code, posting_date, completion_dates) + row_phase = "pre" if code.startswith("X") else _cost_analysis_phase_for_transaction(code, posting_date, completion_dates, project_meta) row_item = _cost_analysis_expense_item(row.get("account_code"), row.get("account_name"), _cost_analysis_is_sales_cost(row)) - if row_item == "outsource" and row_phase == "pre": - row_item = "overhead" if normalized_phase and normalized_phase != "all" and row_phase != normalized_phase: continue - if normalized_item == "revenue" and bucket != "revenue": + if row_item == "labor": continue - elif normalized_item == "cost_total" and row_item not in {"labor", "outsource", "overhead"}: - continue - elif normalized_item == "sga_total" and row_item != "sga": - continue - elif normalized_item == "sales_total" and row_item != "sales": - continue - elif normalized_item == "total_cost" and row_item not in {"labor", "outsource", "overhead", "sga", "sales"}: - continue - elif normalized_item in {"labor", "outsource", "overhead", "sga", "sales"} and row_item != normalized_item: - continue - elif normalized_item not in {"revenue", "cost_total", "sga_total", "sales_total", "total_cost", "labor", "outsource", "overhead", "sga", "sales"}: + if not _cost_analysis_detail_item_matches(bucket, row_item, normalized_item): continue result_rows.append( { "posting_date": build_transaction_posting_display(row["voucher_number"], row["posting_date"]), "account_name": normalize_text(row["account_name"]), "partner_name": normalize_text(row["partner_name"]), + **detail_project_codes(code), "memo1": normalize_text(row["memo1"]), "amount": int(round(normalize_amount(row["amount"]))), } ) - return JSONResponse(content=jsonable_encoder({"rows": result_rows, "total_amount": sum(row["amount"] for row in result_rows)})) + rows = [enrich_detail_row(row) for row in [*synthetic_rows, *result_rows]] + return detail_response({"rows": rows, "total_amount": sum(row["amount"] for row in rows)}) except Exception as exc: logger.exception("프로젝트 손익분석 상세 조회 에러: %s", exc) return JSONResponse(content={"error": str(exc)}, status_code=500) @@ -20350,6 +30698,15 @@ async def annual_summary(request: Request): return HTMLResponse("

서버 오류

로그를 확인해주세요.

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

서버 오류

로그를 확인해주세요.

", status_code=500) + + @app.get("/biz-process") async def biz_process(request: Request): context = base_context(request) @@ -20542,6 +30899,122 @@ async def hanmac_browser_test_connection(request: Request): ) +@app.post("/hanmac-browser/api/test-management-erp") +async def hanmac_browser_test_management_erp(request: Request): + try: + payload = await request.json() + if not isinstance(payload, dict): + raise ValueError("잘못된 요청 형식입니다.") + result = await run_in_threadpool(test_hanmac_management_erp_access, payload) + return JSONResponse(content=jsonable_encoder(result)) + except Exception as exc: + logger.exception("hanmac 관리 ERP 접근 확인 에러: %s", exc) + return JSONResponse( + content={"status": "error", "message": str(exc)}, + status_code=400, + ) + + +@app.post("/hanmac-browser/api/discover-satis-budget") +async def hanmac_browser_discover_satis_budget(request: Request): + try: + payload = await request.json() + if not isinstance(payload, dict): + raise ValueError("잘못된 요청 형식입니다.") + init_db() + result = await run_in_threadpool(test_hanmac_satis_budget_discovery, payload) + return JSONResponse(content=jsonable_encoder(result)) + except Exception as exc: + logger.exception("hanmac Satis 예산 연동 탐색 에러: %s", exc) + return JSONResponse( + content={"status": "error", "message": str(exc)}, + status_code=400, + ) + + +@app.post("/hanmac-browser/api/sync-satis-budget-raw") +async def hanmac_browser_sync_satis_budget_raw(request: Request): + try: + payload = await request.json() + if not isinstance(payload, dict): + raise ValueError("잘못된 요청 형식입니다.") + init_db() + result = await run_in_threadpool(_sync_satis_budget_raw_rows, payload) + return JSONResponse(content=jsonable_encoder(result)) + except Exception as exc: + logger.exception("hanmac Satis 예산 원본 금액 동기화 에러: %s", exc) + return JSONResponse( + content={"status": "error", "message": str(exc)}, + status_code=400, + ) + + +@app.post("/hanmac-browser/api/normalize-satis-budget") +async def hanmac_browser_normalize_satis_budget(request: Request): + try: + payload = await request.json() + if not isinstance(payload, dict): + raise ValueError("잘못된 요청 형식입니다.") + result = await run_in_threadpool(_normalize_satis_budget_raw_rows, payload) + return JSONResponse(content=jsonable_encoder(result)) + except Exception as exc: + logger.exception("hanmac Satis 예산 정규화 에러: %s", exc) + return JSONResponse( + content={"status": "error", "message": str(exc)}, + status_code=400, + ) + + +@app.post("/hanmac-browser/api/project-satis-budget-current") +async def hanmac_browser_project_satis_budget_current(request: Request): + try: + payload = await request.json() + if not isinstance(payload, dict): + raise ValueError("잘못된 요청 형식입니다.") + result = await run_in_threadpool(_project_satis_budget_to_current_entries, payload) + return JSONResponse(content=jsonable_encoder(result)) + except Exception as exc: + logger.exception("hanmac Satis 예산 기존 입력 테이블 반영 에러: %s", exc) + return JSONResponse( + content={"status": "error", "message": str(exc)}, + status_code=400, + ) + + +@app.post("/hanmac-browser/api/run-satis-budget-full-sync") +async def hanmac_browser_run_satis_budget_full_sync(request: Request): + try: + payload = await request.json() + if not isinstance(payload, dict): + raise ValueError("잘못된 요청 형식입니다.") + init_db() + result = await run_in_threadpool(_run_satis_budget_full_sync, payload) + return JSONResponse(content=jsonable_encoder(result)) + except Exception as exc: + logger.exception("hanmac Satis 예산 전체 실행 에러: %s", exc) + return JSONResponse( + content={"status": "error", "message": str(exc)}, + status_code=400, + ) + + +@app.post("/hanmac-browser/api/collect-satis-budget-web") +async def hanmac_browser_collect_satis_budget_web(request: Request): + try: + payload = await request.json() + if not isinstance(payload, dict): + raise ValueError("잘못된 요청 형식입니다.") + init_db() + result = await run_in_threadpool(_collect_satis_budget_via_web, payload) + return JSONResponse(content=jsonable_encoder(result)) + except Exception as exc: + logger.exception("hanmac Satis 웹로그인 예산 수집 에러: %s", exc) + return JSONResponse( + content={"status": "error", "message": str(exc)}, + status_code=400, + ) + + @app.post("/hanmac-browser/api/tables") async def hanmac_browser_tables(request: Request): try: @@ -20880,7 +31353,8 @@ async def wehago_compare_rebuild_query_cache(request: Request): @app.get("/wehago-compare") async def wehago_compare(request: Request, start_year: str | None = None, end_year: str | None = None): try: - return render_wehago_compare_page( + return await run_in_threadpool( + render_wehago_compare_page, request, start_year=parse_optional_year(start_year), end_year=parse_optional_year(end_year), @@ -21288,36 +31762,11 @@ async def wehago_compare_status_suggestions( def _refresh_wehago_recheck_projection_after_change() -> dict[str, Any]: - scripts = [ - Path("scripts/promote_wehago_recheck_projection.py"), - Path("scripts/reconcile_wehago_projection_to_db.py"), - ] - outputs: list[str] = [] - for script in scripts: - completed = subprocess.run( - [sys.executable, str(script)], - cwd=Path(__file__).resolve().parent, - text=True, - capture_output=True, - timeout=180, - check=False, - ) - output = "\n".join(part for part in [completed.stdout, completed.stderr] if part).strip() - if output: - outputs.append(output) - if completed.returncode != 0: - raise RuntimeError(output or f"{script.name} 실행에 실패했습니다.") _clear_compare_runtime_caches() - reconcile_payload: dict[str, Any] = {} - if outputs: - last_line = outputs[-1].splitlines()[-1].strip() - try: - parsed = json.loads(last_line) - if isinstance(parsed, dict): - reconcile_payload = parsed - except Exception: - reconcile_payload = {"output": last_line} - return reconcile_payload + return { + "mode": "manual_overlay_only", + "message": "선택한 recheck 변경만 저장했고, 전체 projection 재생성은 실행하지 않았습니다.", + } def _ensure_wehago_manual_offset_excepted_table(conn: sqlite3.Connection) -> None: diff --git a/reports/my-intranet-app_architecture_report_20260522.docx b/reports/my-intranet-app_architecture_report_20260522.docx deleted file mode 100644 index 144fec0..0000000 Binary files a/reports/my-intranet-app_architecture_report_20260522.docx and /dev/null differ diff --git a/reports/my-intranet-app_architecture_report_20260522_v2.docx b/reports/my-intranet-app_architecture_report_20260522_v2.docx deleted file mode 100644 index 2335adb..0000000 Binary files a/reports/my-intranet-app_architecture_report_20260522_v2.docx and /dev/null differ diff --git a/reports/my-intranet-app_architecture_report_20260522_v3.docx b/reports/my-intranet-app_architecture_report_20260522_v3.docx deleted file mode 100644 index b2e9d9a..0000000 Binary files a/reports/my-intranet-app_architecture_report_20260522_v3.docx and /dev/null differ diff --git a/reports/my-intranet-app_architecture_report_20260522_v4.docx b/reports/my-intranet-app_architecture_report_20260522_v4.docx deleted file mode 100644 index 2eab4cb..0000000 Binary files a/reports/my-intranet-app_architecture_report_20260522_v4.docx and /dev/null differ diff --git a/scripts/activate_hanmac_voucher_existence_snapshot.py b/scripts/activate_hanmac_voucher_existence_snapshot.py new file mode 100644 index 0000000..8f35d8f --- /dev/null +++ b/scripts/activate_hanmac_voucher_existence_snapshot.py @@ -0,0 +1,43 @@ +from __future__ import annotations + +import argparse +import json +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + +from main import engine +from wehago_compare import activate_erp_voucher_existence_snapshot, init_wehago_compare_db + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser( + description=( + "이미 적재된 Hanmac ERP 전표 원천(source_file_id)을 최신 전표 존재 스냅샷으로 활성화합니다. " + "WEHAGO anchor 매칭은 이 스냅샷에 실제 존재하는 Hanmac draft만 확정 후보로 사용합니다." + ) + ) + parser.add_argument("--year", type=int, required=True, help="스냅샷을 활성화할 회계연도") + parser.add_argument("--source-file-id", type=int, required=True, help="wehago_source_files.id") + parser.add_argument("--label", default="", help="운영자가 식별할 수 있는 스냅샷 설명") + return parser.parse_args() + + +def main() -> int: + args = parse_args() + init_wehago_compare_db(engine) + with engine.begin() as conn: + result = activate_erp_voucher_existence_snapshot( + conn, + args.year, + source_file_id=args.source_file_id, + source_label=args.label, + snapshot_mode="full", + ) + print(json.dumps(result, ensure_ascii=False, indent=2)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/analyze_hanmac_work_history_coverage.py b/scripts/analyze_hanmac_work_history_coverage.py new file mode 100644 index 0000000..32e7b08 --- /dev/null +++ b/scripts/analyze_hanmac_work_history_coverage.py @@ -0,0 +1,256 @@ +from __future__ import annotations + +import json +import sqlite3 +import sys +from collections import Counter, defaultdict +from datetime import date +from pathlib import Path +from typing import Any + +from sqlalchemy import text + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) +import main + + +IMAGE_NAMES = [ + "신현우", + "유승열", + "채희문", + "이애연", + "이준희", + "김찬웅", + "현동규", + "노태수", + "김현수", + "유규민", + "오혜성", + "안동현", + "정민홍", + "오윤익", + "이지혜", + "서우석", + "이준하", + "유재욱", + "윤영주", + "윤성찬", + "김유진", + "정준규", +] + + +def latest_connection_payload() -> dict[str, Any]: + connection = sqlite3.connect(main.DB_PATH) + connection.row_factory = sqlite3.Row + rows = connection.execute( + """ + SELECT params_json + FROM system_jobs + WHERE page_key = 'hanmac_browser' + AND job_type = 'hanmac_aggregate_cache' + AND status = 'done' + ORDER BY updated_at DESC + """ + ).fetchall() + connection.close() + for row in rows: + try: + payload = json.loads(row["params_json"] or "{}") + except Exception: + continue + if payload.get("host") and payload.get("user") and payload.get("password"): + return payload + raise RuntimeError("외부 DB 접속정보를 찾을 수 없습니다.") + + +def column(columns: list[str], candidates: list[str]) -> str | None: + return main._hanmac_find_column(columns, candidates) + + +def select_alias(name: str | None, alias: str) -> str: + return main._hanmac_build_select_alias(name, alias) + + +def date_span_days(start_value: Any, end_value: Any) -> int: + start = main._hanmac_parse_date_value(start_value) + end = main._hanmac_parse_date_value(end_value) or start + if not start: + return 0 + if end and end < start: + start, end = end, start + return max(1, ((end or start) - start).days + 1) + + +def main_cli() -> None: + payload = latest_connection_payload() + engine = main._build_hanmac_mysql_engine(payload) + report: dict[str, Any] = { + "generated_at": date.today().isoformat(), + "schemas": {}, + "hanmac_members": {}, + "image_people": [], + } + try: + with engine.connect() as connection: + member_by_schema: dict[str, dict[str, dict[str, Any]]] = {} + source_counts: dict[str, Counter[str]] = defaultdict(Counter) + source_ranges: dict[str, dict[str, dict[str, str]]] = defaultdict(dict) + state_counts: dict[str, Counter[str]] = defaultdict(Counter) + state_days: dict[str, Counter[str]] = defaultdict(Counter) + state_examples: dict[str, dict[str, str]] = defaultdict(dict) + + for schema in (main.HANMAC_PRIMARY_MANHOUR_SCHEMA, main.HANMAC_CENTER_MANHOUR_SCHEMA): + metadata = main._hanmac_fetch_table_columns(connection, schema) + members, diagnostics = main._hanmac_load_member_info(connection, schema, metadata) + member_by_schema[schema] = members + schema_sources = [] + for table_name, columns in metadata.items(): + member_col = column(columns, ["MemberNo", "member_no", "EmpNo", "UserID", "MemberID", "member_id"]) + start_col = column( + columns, + ["EntryTime", "entry_time", "WorkDate", "work_date", "start_time", "StartTime", "s_date", "SDate", "start_date", "StartDate", "RegDate", "reg_date"], + ) + end_col = column(columns, ["LeaveTime", "leave_time", "end_time", "EndTime", "e_date", "EDate", "end_date", "EndDate"]) + if not member_col: + continue + query = f""" + SELECT + {select_alias(member_col, "member_no")}, + {select_alias(start_col, "start_value")}, + {select_alias(end_col, "end_value")}, + COUNT(*) AS row_count + FROM `{schema}`.`{table_name}` + WHERE `{member_col}` IS NOT NULL + GROUP BY `{member_col}`, {f'`{start_col}`' if start_col else 'NULL'}, {f'`{end_col}`' if end_col else 'NULL'} + """ + try: + rows = connection.execute(text(query)).mappings().all() + except Exception: + continue + schema_sources.append({"table": table_name, "rows": len(rows), "has_date": bool(start_col)}) + for row in rows: + member_no = main.normalize_text(row.get("member_no")) + if not member_no: + continue + key = f"{schema}.{table_name}" + source_counts[member_no][key] += int(row.get("row_count") or 0) + start_text = str(row.get("start_value") or "")[:10] + end_text = str(row.get("end_value") or row.get("start_value") or "")[:10] + current = source_ranges[member_no].setdefault(key, {"min": "", "max": ""}) + if start_text and (not current["min"] or start_text < current["min"]): + current["min"] = start_text + if end_text and (not current["max"] or end_text > current["max"]): + current["max"] = end_text + + state_columns = metadata.get("userstate_tbl") or [] + state_member_col = column(state_columns, ["MemberNo", "member_no", "EmpNo", "UserID", "MemberID", "member_id"]) + state_col = column(state_columns, ["state", "State", "state_code", "StateCode"]) + state_start_col = column(state_columns, ["start_time", "StartTime", "s_date", "SDate", "start_date", "StartDate"]) + state_end_col = column(state_columns, ["end_time", "EndTime", "e_date", "EDate", "end_date", "EndDate"]) + note_col = column(state_columns, ["note", "Note", "memo", "Memo", "remark", "Remark"]) + if state_member_col and state_col and state_start_col: + rows = connection.execute( + text( + f""" + SELECT + {select_alias(state_member_col, "member_no")}, + {select_alias(state_col, "state_code")}, + {select_alias(state_start_col, "start_value")}, + {select_alias(state_end_col, "end_value")}, + {select_alias(note_col, "note")} + FROM `{schema}`.`userstate_tbl` + WHERE `{state_member_col}` IS NOT NULL + """ + ) + ).mappings().all() + for row in rows: + member_no = main.normalize_text(row.get("member_no")) + state_code = main.normalize_text(row.get("state_code")) + if not member_no or not state_code: + continue + state_counts[member_no][state_code] += 1 + state_days[member_no][state_code] += date_span_days(row.get("start_value"), row.get("end_value")) + state_examples[member_no].setdefault(state_code, main.normalize_text(row.get("note"))) + + report["schemas"][schema] = { + "member_count": len(members), + "member_columns": diagnostics.get("member_columns", []), + "sources": schema_sources, + } + + primary_members = member_by_schema.get(main.HANMAC_PRIMARY_MANHOUR_SCHEMA, {}) + for member_no, member in primary_members.items(): + if not member_no.upper().startswith("M"): + continue + regular_rows = source_counts[member_no][f"{main.HANMAC_PRIMARY_MANHOUR_SCHEMA}.dallyproject_tbl"] + addwork_rows = source_counts[member_no][f"{main.HANMAC_PRIMARY_MANHOUR_SCHEMA}.dallyproject_addwork_tbl"] + states = dict(state_counts[member_no]) + unreflected_states = { + code: count + for code, count in states.items() + if code not in {"1", "7", "8", "16", "20", "21", "30", "31"} + } + report["hanmac_members"][member_no] = { + "name": member.get("member_name", ""), + "grade": member.get("member_grade", ""), + "dept": member.get("dept_name", ""), + "entry_date": member["entry_date"].isoformat() if member.get("entry_date") else "", + "leave_date": member["leave_date"].isoformat() if member.get("leave_date") else "", + "regular_rows": regular_rows, + "addwork_rows": addwork_rows, + "state_counts": states, + "state_days": dict(state_days[member_no]), + "unreflected_states": unreflected_states, + "source_counts": dict(source_counts[member_no]), + "source_ranges": source_ranges[member_no], + } + + for name in IMAGE_NAMES: + matches = [] + for schema, members in member_by_schema.items(): + for member_no, member in members.items(): + if main._hanmac_normalize_person_name(member.get("member_name")) != main._hanmac_normalize_person_name(name): + continue + matches.append( + { + "schema": schema, + "member_no": member_no, + "name": member.get("member_name", ""), + "grade": member.get("member_grade", ""), + "dept": member.get("dept_name", ""), + "entry_date": member["entry_date"].isoformat() if member.get("entry_date") else "", + "leave_date": member["leave_date"].isoformat() if member.get("leave_date") else "", + "regular_rows": source_counts[member_no][f"{schema}.dallyproject_tbl"], + "addwork_rows": source_counts[member_no][f"{schema}.dallyproject_addwork_tbl"], + "state_rows": sum(state_counts[member_no].values()), + "source_counts": dict(source_counts[member_no]), + } + ) + report["image_people"].append({"requested_name": name, "matches": matches}) + finally: + engine.dispose() + + output_path = Path("reports/hanmac_work_history_coverage_260612.json") + output_path.parent.mkdir(parents=True, exist_ok=True) + output_path.write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8") + + members = report["hanmac_members"] + zero_regular = [item for item in members.values() if not item["regular_rows"]] + unreflected = [item for item in members.values() if item["unreflected_states"]] + print( + json.dumps( + { + "hanmac_member_count": len(members), + "zero_regular_count": len(zero_regular), + "unreflected_state_member_count": len(unreflected), + "image_match_count": sum(bool(item["matches"]) for item in report["image_people"]), + "output": str(output_path), + }, + ensure_ascii=False, + ) + ) + + +if __name__ == "__main__": + main_cli() diff --git a/scripts/analyze_satis_project_mapping.py b/scripts/analyze_satis_project_mapping.py new file mode 100644 index 0000000..c6a60a9 --- /dev/null +++ b/scripts/analyze_satis_project_mapping.py @@ -0,0 +1,372 @@ +#!/usr/bin/env python3 +"""Build a read-only Satis-to-local project mapping review report.""" + +from __future__ import annotations + +import argparse +import csv +import json +import re +import sqlite3 +import unicodedata +from collections import defaultdict +from dataclasses import dataclass, field +from datetime import datetime +from difflib import SequenceMatcher +from pathlib import Path +from typing import Any + + +ROUND_SUFFIX_RE = re.compile(r"\s*[\(\[]\s*(\d+)\s*차\s*[\)\]]\s*$") +CODE_RE = re.compile(r"^\d{6}$") + + +def text(value: Any) -> str: + return "" if value is None else str(value).strip() + + +def number(value: Any) -> float: + try: + return float(str(value).replace(",", "").strip() or 0) + except (TypeError, ValueError): + return 0.0 + + +def normalize_name(value: Any, *, remove_round: bool = False) -> str: + value = unicodedata.normalize("NFKC", text(value)).lower() + value = re.sub(r"^\[[0-9]{6}\]\s*", "", value) + value = re.sub(r"^\((?:범한|공동|분담|주관)\)\s*", "", value) + if remove_round: + value = ROUND_SUFFIX_RE.sub("", value) + value = value.replace("~", "~").replace("〜", "~") + return re.sub(r"[^0-9a-z가-힣]", "", value) + + +def local_round(name: str) -> int | None: + match = ROUND_SUFFIX_RE.search(text(name)) + return int(match.group(1)) if match else None + + +def transformed_code(local_code: str) -> str: + if re.fullmatch(r"[YZ]\d{5}", local_code): + return f"0{local_code[1:]}" + if re.fullmatch(r"X\d{5}", local_code): + return f"9{local_code[1:]}" + return local_code if CODE_RE.fullmatch(local_code) else "" + + +@dataclass +class ErpProject: + code: str + name: str = "" + start_date: str = "" + end_date: str = "" + amount: float = 0.0 + revisions: set[str] = field(default_factory=set) + statuses: set[str] = field(default_factory=set) + sources: set[str] = field(default_factory=set) + + +def extract_erp_row(source_table: str, payload: dict[str, Any]) -> dict[str, Any] | None: + if source_table.startswith("SCREEN_03:"): + code = text(payload.get("item03")) + name = text(payload.get("item04")) + revision = text(payload.get("item17") or payload.get("degree")) + status = text(payload.get("item18")) + start_date = text(payload.get("item08")) + end_date = text(payload.get("item09")) + amount = number(payload.get("item10")) + else: + code = text(payload.get("item01") or payload.get("project_code")) + name = text(payload.get("item02")) + revision = text(payload.get("degree") or payload.get("item10")) + status = text(payload.get("item11") or payload.get("item12")) + start_date = text(payload.get("item04") or payload.get("item07")) + end_date = text(payload.get("item05") or payload.get("item08")) + amount = number(payload.get("item06") or payload.get("item07") or payload.get("item09")) + if not CODE_RE.fullmatch(code) or not name or CODE_RE.fullmatch(name): + return None + return { + "code": code, + "name": name, + "revision": revision, + "status": re.sub(r"<[^>]+>", "", status), + "start_date": start_date, + "end_date": end_date, + "amount": amount, + } + + +def load_erp_projects(conn: sqlite3.Connection) -> dict[str, ErpProject]: + projects: dict[str, ErpProject] = {} + rows = conn.execute( + """ + SELECT source_table, raw_payload_json + FROM satis_project_budget_raw_rows + WHERE source_database = 'satis_web' + """ + ) + for row in rows: + try: + payload = json.loads(text(row["raw_payload_json"])) + except (TypeError, json.JSONDecodeError): + continue + if not isinstance(payload, dict): + continue + extracted = extract_erp_row(text(row["source_table"]), payload) + if not extracted: + continue + project = projects.setdefault(extracted["code"], ErpProject(code=extracted["code"])) + candidate_name = extracted["name"] + if not project.name or len(candidate_name) > len(project.name): + project.name = candidate_name + if not project.start_date and extracted["start_date"]: + project.start_date = extracted["start_date"] + if not project.end_date and extracted["end_date"]: + project.end_date = extracted["end_date"] + if extracted["amount"] > project.amount: + project.amount = extracted["amount"] + if extracted["revision"]: + project.revisions.add(extracted["revision"]) + if extracted["status"]: + project.statuses.add(extracted["status"]) + project.sources.add(text(row["source_table"])) + return projects + + +def candidate_score(local: sqlite3.Row, erp: ErpProject) -> tuple[float, list[str]]: + local_code = text(local["support_dept_code"]) + local_name = text(local["support_dept_name"]) + reasons: list[str] = [] + score = 0.0 + if CODE_RE.fullmatch(local_code) and local_code == erp.code: + score = 1.0 + reasons.append("코드 완전일치") + elif transformed_code(local_code) == erp.code: + score = 0.99 + reasons.append(f"코드 규칙일치({local_code}→{erp.code})") + + strict_local = normalize_name(local_name) + strict_erp = normalize_name(erp.name) + base_local = normalize_name(local_name, remove_round=True) + base_erp = normalize_name(erp.name, remove_round=True) + strict_similarity = SequenceMatcher(None, strict_local, strict_erp).ratio() + base_similarity = SequenceMatcher(None, base_local, base_erp).ratio() + if strict_local and strict_local == strict_erp: + score = max(score, 0.98) + reasons.append("프로젝트명 완전일치") + elif base_local and base_local == base_erp: + score = max(score, 0.95) + reasons.append("차수 제거 프로젝트명 일치") + elif base_similarity >= 0.72: + score = max(score, base_similarity * 0.92) + reasons.append(f"명칭 유사도 {base_similarity:.3f}") + + local_amount = number(local["contract_amount"]) + if local_amount and erp.amount: + amount_ratio = min(local_amount, erp.amount) / max(local_amount, erp.amount) + if amount_ratio >= 0.98: + score = min(1.0, score + 0.02) + reasons.append("계약금액 일치") + elif amount_ratio < 0.5: + score -= 0.06 + reasons.append("계약금액 차이 큼") + return score, reasons + + +def classify(score: float, margin: float, reasons: list[str]) -> tuple[str, str]: + code_match = any(reason.startswith(("코드 완전", "코드 규칙")) for reason in reasons) + exact_name = any("프로젝트명 완전" in reason for reason in reasons) + base_name = any("차수 제거" in reason for reason in reasons) + if code_match and score >= 0.95: + return "auto_safe", "자동매핑 가능" + if exact_name and score >= 0.96 and margin >= 0.03: + return "auto_safe", "자동매핑 가능" + if base_name and score >= 0.93 and margin >= 0.03: + return "auto_1_to_many", "1:N 자동연계 가능" + if score >= 0.84 and margin >= 0.04: + return "review_high", "검토 후 매핑" + if score >= 0.72: + return "review_low", "수동 확인 필요" + return "unmatched", "매핑 불가" + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("--db", default="/home/b17301/intranet-runtime/db/data.db") + parser.add_argument("--output-dir", default="reports") + args = parser.parse_args() + + db_path = Path(args.db).resolve() + output_dir = Path(args.output_dir).resolve() + output_dir.mkdir(parents=True, exist_ok=True) + conn = sqlite3.connect(f"file:{db_path}?mode=ro", uri=True) + conn.row_factory = sqlite3.Row + + locals_ = conn.execute( + """ + SELECT support_dept_code, support_dept_name, contract_amount, + project_start_date, project_end_date + FROM project_status + WHERE COALESCE(support_dept_code, '') <> '' + ORDER BY support_dept_code + """ + ).fetchall() + erp_projects = load_erp_projects(conn) + existing = { + row["support_dept_code"]: dict(row) + for row in conn.execute( + """ + SELECT erp_project_code, erp_project_name, support_dept_code, + mapping_status, mapping_basis, manual_override + FROM satis_project_mapping + WHERE COALESCE(support_dept_code, '') <> '' + """ + ) + } + for old in existing.values(): + erp_code = text(old.get("erp_project_code")) + erp_name = text(old.get("erp_project_name")) + if CODE_RE.fullmatch(erp_code) and erp_name: + project = erp_projects.setdefault(erp_code, ErpProject(code=erp_code)) + if not project.name: + project.name = erp_name + project.sources.add("satis_project_mapping") + + results: list[dict[str, Any]] = [] + for local in locals_: + scored: list[tuple[float, ErpProject, list[str]]] = [] + for erp in erp_projects.values(): + score, reasons = candidate_score(local, erp) + if score >= 0.55: + scored.append((score, erp, reasons)) + scored.sort(key=lambda item: (item[0], item[1].code), reverse=True) + best = scored[0] if scored else (0.0, ErpProject(code=""), []) + second_score = scored[1][0] if len(scored) > 1 else 0.0 + score, erp, reasons = best + margin = score - second_score + category, decision = classify(score, margin, reasons) + old = existing.get(text(local["support_dept_code"]), {}) + results.append( + { + "local_code": text(local["support_dept_code"]), + "local_name": text(local["support_dept_name"]), + "local_round": local_round(text(local["support_dept_name"])) or "", + "erp_code": erp.code, + "erp_name": erp.name, + "erp_revisions": "|".join(sorted(erp.revisions)), + "score": f"{score:.3f}", + "margin": f"{margin:.3f}", + "category": category, + "decision": decision, + "basis": ", ".join(reasons), + "existing_erp_code": text(old.get("erp_project_code")), + "existing_basis": text(old.get("mapping_basis")), + "existing_consistent": ( + "Y" + if old and text(old.get("erp_project_code")) == erp.code + else ("N" if old else "") + ), + } + ) + + accepted_by_erp: dict[str, list[dict[str, Any]]] = defaultdict(list) + for result in results: + if result["erp_code"] and result["category"] != "unmatched": + accepted_by_erp[result["erp_code"]].append(result) + for linked_results in accepted_by_erp.values(): + if len(linked_results) < 2: + continue + for result in linked_results: + has_code_basis = "코드 완전일치" in result["basis"] or "코드 규칙일치" in result["basis"] + if not has_code_basis and result["category"] == "auto_safe": + result["category"] = "auto_1_to_many" + result["decision"] = "1:N 자동연계 가능" + + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + csv_path = output_dir / f"satis_project_mapping_review_{timestamp}.csv" + with csv_path.open("w", encoding="utf-8-sig", newline="") as handle: + writer = csv.DictWriter(handle, fieldnames=list(results[0].keys())) + writer.writeheader() + writer.writerows(results) + + grouped: dict[str, list[dict[str, Any]]] = defaultdict(list) + for result in results: + grouped[result["category"]].append(result) + report_path = output_dir / f"satis_project_mapping_review_{timestamp}.md" + category_order = ["auto_safe", "auto_1_to_many", "review_high", "review_low", "unmatched"] + labels = { + "auto_safe": "자동매핑 가능", + "auto_1_to_many": "1:N 자동연계 가능", + "review_high": "검토 후 매핑", + "review_low": "수동 확인 필요", + "unmatched": "매핑 불가", + } + one_to_many = defaultdict(list) + for result in results: + if result["erp_code"] and result["category"] != "unmatched": + one_to_many[result["erp_code"]].append(result["local_code"]) + + lines = [ + "# Satis 프로젝트 자동매핑 검토", + "", + f"- 분석 DB: `{db_path}`", + f"- 로컬 예산 대상 프로젝트: {len(locals_)}개", + f"- 저장 원본에서 재구성한 ERP 프로젝트: {len(erp_projects)}개", + f"- 기존 확정 매핑: {len(existing)}개", + "- 범위: 예산 입력 대상인 `project_status` 38개(거래전표에만 존재하는 코드는 제외)", + "", + "## 판정 요약", + "", + "| 판정 | 건수 |", + "|---|---:|", + ] + for category in category_order: + lines.append(f"| {labels[category]} | {len(grouped[category])} |") + lines.extend( + [ + "", + "## 연계 후보", + "", + "| 로컬 코드 | 로컬 프로젝트 | ERP 코드 | ERP 프로젝트 | 점수 | 판정 | 근거 |", + "|---|---|---|---|---:|---|---|", + ] + ) + for result in results: + lines.append( + "| {local_code} | {local_name} | {erp_code} | {erp_name} | {score} | {decision} | {basis} |".format( + **{key: text(value).replace("|", "/") for key, value in result.items()} + ) + ) + multi_links = {code: codes for code, codes in one_to_many.items() if len(codes) > 1} + lines.extend(["", "## 1:N 연계 후보", ""]) + if multi_links: + for code, codes in sorted(multi_links.items()): + lines.append(f"- ERP `{code}` → 로컬 {', '.join(f'`{item}`' for item in codes)}") + else: + lines.append("- 없음") + lines.extend( + [ + "", + "## 적용 판단", + "", + "- `auto_safe`는 코드 또는 고유한 완전일치 명칭을 근거로 자동 반영할 수 있습니다.", + "- `auto_1_to_many`는 ERP 총괄 프로젝트 하나와 로컬 차수 프로젝트 여러 개를 연결할 별도 링크 테이블이 필요합니다.", + "- `review_high/review_low`는 계약금액·기간·발주처를 추가 대조한 뒤 확정해야 합니다.", + "- 기존 `satis_project_mapping`은 ERP 코드가 UNIQUE라 1:N 구조를 저장할 수 없으므로 자동 확장 전에 스키마 개선이 필요합니다.", + ] + ) + report_path.write_text("\n".join(lines) + "\n", encoding="utf-8") + + print(json.dumps({ + "db": str(db_path), + "erp_projects": len(erp_projects), + "local_projects": len(locals_), + "counts": {category: len(grouped[category]) for category in category_order}, + "csv": str(csv_path), + "report": str(report_path), + }, ensure_ascii=False, indent=2)) + + +if __name__ == "__main__": + main() diff --git a/scripts/analyze_wehago_accounting_risk_report.py b/scripts/analyze_wehago_accounting_risk_report.py new file mode 100644 index 0000000..1a8acba --- /dev/null +++ b/scripts/analyze_wehago_accounting_risk_report.py @@ -0,0 +1,428 @@ +from __future__ import annotations + +import csv +import sqlite3 +import zipfile +from collections import defaultdict +from datetime import datetime +from pathlib import Path +from typing import Any +from xml.sax.saxutils import escape + + +ROOT = Path(__file__).resolve().parents[1] +DB_PATH = ROOT / "data.db" +REPORT_DIR = ROOT / "reports" +START_DATE = "2023-01-01" +END_DATE = "2025-12-31" +START_YEAR = 2023 +END_YEAR = 2025 +PERIOD_SQL = "CAST(year AS INTEGER) BETWEEN ? AND ?" +PERIOD_PARAMS = (START_YEAR, END_YEAR) + + +def clean(value: Any) -> str: + return "" if value is None else str(value).strip() + + +def money(value: Any) -> float: + try: + return float(value or 0) + except (TypeError, ValueError): + return 0.0 + + +def fmt_money(value: Any) -> str: + return f"{money(value):,.0f}" + + +def fmt_count(value: Any) -> str: + try: + return f"{int(value or 0):,}" + except (TypeError, ValueError): + return "0" + + +def md_table(headers: list[str], rows: list[list[Any]]) -> str: + lines = [ + "| " + " | ".join(headers) + " |", + "| " + " | ".join("---" for _ in headers) + " |", + ] + for row in rows: + lines.append("| " + " | ".join(clean(value).replace("\n", "
") for value in row) + " |") + return "\n".join(lines) + + +def q(conn: sqlite3.Connection, sql: str, params: tuple[Any, ...] = ()) -> list[sqlite3.Row]: + return list(conn.execute(sql, params)) + + +def sample_rows(conn: sqlite3.Connection, where_sql: str, params: tuple[Any, ...], limit: int = 8) -> list[list[str]]: + rows = q( + conn, + f""" + SELECT posting_date, year, month, day, voucher_number, account_name, partner_name, support_dept_code, + support_dept_name, memo1, memo2, amount + FROM transactions + WHERE {PERIOD_SQL} + AND ({where_sql}) + ORDER BY abs(COALESCE(amount, 0)) DESC, year, month, day, voucher_number + LIMIT ? + """, + (*PERIOD_PARAMS, *params, limit), + ) + return [ + [ + clean(row["posting_date"]) + or f"{int(row['year'] or 0):04d}-{int(row['month'] or 0):02d}-{int(row['day'] or 0):02d}", + clean(row["voucher_number"]), + clean(row["account_name"]), + clean(row["partner_name"]), + clean(row["support_dept_code"]) or "-", + clean(row["support_dept_name"]) or "-", + " / ".join(part for part in (clean(row["memo1"]), clean(row["memo2"])) if part)[:90], + fmt_money(row["amount"]), + ] + for row in rows + ] + + +def docx_paragraph(text: str, style: str | None = None) -> str: + style_xml = f'' if style else "" + return f"{style_xml}{escape(text)}" + + +def docx_table(headers: list[str], rows: list[list[Any]]) -> str: + def cell(value: Any, bold: bool = False) -> str: + run_pr = "" if bold else "" + text = escape(clean(value)) + return ( + "" + f"{run_pr}{text}" + ) + + body = ["" + "".join(cell(header, True) for header in headers) + ""] + body.extend("" + "".join(cell(value) for value in row) + "" for row in rows) + return ( + "" + "" + "" + "" + "" + "" + "" + "" + + "".join(body) + + "" + ) + + +def write_docx(path: Path, title: str, sections: list[tuple[str, str | None, list[tuple[list[str], list[list[Any]]]]]]) -> None: + body = [docx_paragraph(title, "Title")] + for heading, paragraph, tables in sections: + body.append(docx_paragraph(heading, "Heading1")) + if paragraph: + for line in paragraph.split("\n"): + body.append(docx_paragraph(line)) + for headers, rows in tables: + body.append(docx_table(headers, rows)) + + document_xml = ( + '' + '' + "" + + "".join(body) + + '' + '' + "" + ) + styles_xml = ( + '' + '' + '' + '' + '' + '' + "" + ) + content_types = ( + '' + '' + '' + '' + '' + '' + "" + ) + rels = ( + '' + '' + '' + "" + ) + doc_rels = ( + '' + '' + '' + "" + ) + with zipfile.ZipFile(path, "w", compression=zipfile.ZIP_DEFLATED) as docx: + docx.writestr("[Content_Types].xml", content_types) + docx.writestr("_rels/.rels", rels) + docx.writestr("word/_rels/document.xml.rels", doc_rels) + docx.writestr("word/document.xml", document_xml) + docx.writestr("word/styles.xml", styles_xml) + + +def main() -> None: + REPORT_DIR.mkdir(exist_ok=True) + conn = sqlite3.connect(DB_PATH) + conn.row_factory = sqlite3.Row + + total = conn.execute( + f"SELECT COUNT(DISTINCT voucher_number) FROM transactions WHERE {PERIOD_SQL}", + PERIOD_PARAMS, + ).fetchone()[0] + + account_rows = q( + conn, + f""" + SELECT '가족사 자금거래' AS risk_area, COUNT(*) row_count, COUNT(DISTINCT voucher_number) voucher_count, + SUM(abs(COALESCE(amount,0))) amount + FROM transactions + WHERE {PERIOD_SQL} + AND (account_name LIKE '%대여금%' OR account_name LIKE '%차입금%' OR account_name LIKE '%이자수익%' OR account_name LIKE '%이자비용%') + AND (partner_name LIKE '%삼안%' OR partner_name LIKE '%장헌%' OR partner_name LIKE '%피티씨%' OR partner_name LIKE '%PTC%' OR partner_name LIKE '%한라%' OR partner_name LIKE '%바론%' + OR memo1 LIKE '%삼안%' OR memo1 LIKE '%장헌%' OR memo1 LIKE '%피티씨%' OR memo1 LIKE '%PTC%' OR memo1 LIKE '%한라%' OR memo1 LIKE '%바론%') + UNION ALL + SELECT '전도금 정산추적', COUNT(*), COUNT(DISTINCT voucher_number), SUM(abs(COALESCE(amount,0))) + FROM transactions + WHERE {PERIOD_SQL} AND account_name LIKE '%전도금%' + UNION ALL + SELECT '미수금·입금 정산추적', COUNT(*), COUNT(DISTINCT voucher_number), SUM(abs(COALESCE(amount,0))) + FROM transactions + WHERE {PERIOD_SQL} AND (account_name LIKE '%미수금%' OR account_name LIKE '%용역미수금%' OR account_name LIKE '%임대미수금%') + UNION ALL + SELECT '복리후생·접대성 비용', COUNT(*), COUNT(DISTINCT voucher_number), SUM(abs(COALESCE(amount,0))) + FROM transactions + WHERE {PERIOD_SQL} AND (account_name LIKE '%복리후생%' OR account_name LIKE '%접대비%') + UNION ALL + SELECT '출장·식대·교통 중복 후보', COUNT(*), COUNT(DISTINCT voucher_number), SUM(abs(COALESCE(amount,0))) + FROM transactions + WHERE {PERIOD_SQL} + AND (account_name LIKE '%국내출장%' OR account_name LIKE '%출장%' OR account_name LIKE '%외근식대%' OR account_name LIKE '%회식대%' OR account_name LIKE '%시내교통%') + UNION ALL + SELECT '공통코드 프로젝트 손익 왜곡', COUNT(*), COUNT(DISTINCT voucher_number), SUM(abs(COALESCE(amount,0))) + FROM transactions + WHERE {PERIOD_SQL} + AND (account_code LIKE '5%' OR account_code LIKE '6%') + AND UPPER(COALESCE(support_dept_code,'')) IN ('', 'ZZZZZZ') + """, + PERIOD_PARAMS * 6, + ) + summary_rows = [ + [ + row["risk_area"], + fmt_count(row["row_count"]), + fmt_count(row["voucher_count"]), + f"{(row['voucher_count'] or 0) / total * 100:.2f}%" if total else "0.00%", + fmt_money(row["amount"]), + "WEHAGO에도 점검 대상 존재" if row["voucher_count"] else "전표상 미확인", + ] + for row in account_rows + ] + + family_rows = q( + conn, + f""" + SELECT CASE + WHEN partner_name LIKE '%삼안%' OR memo1 LIKE '%삼안%' THEN '삼안' + WHEN partner_name LIKE '%장헌%' OR memo1 LIKE '%장헌%' THEN '장헌산업' + WHEN partner_name LIKE '%피티씨%' OR partner_name LIKE '%PTC%' OR memo1 LIKE '%피티씨%' OR memo1 LIKE '%PTC%' THEN '피티씨' + WHEN partner_name LIKE '%한라%' OR memo1 LIKE '%한라%' THEN '한라산업개발' + WHEN partner_name LIKE '%바론%' OR memo1 LIKE '%바론%' THEN '바론컨설턴트' + ELSE '기타' + END counterparty, + account_name, + COUNT(*) row_count, + COUNT(DISTINCT voucher_number) voucher_count, + SUM(abs(COALESCE(amount,0))) amount + FROM transactions + WHERE {PERIOD_SQL} + AND (account_name LIKE '%대여금%' OR account_name LIKE '%차입금%' OR account_name LIKE '%이자수익%' OR account_name LIKE '%이자비용%' OR account_name LIKE '%미수금%') + AND (partner_name LIKE '%삼안%' OR partner_name LIKE '%장헌%' OR partner_name LIKE '%피티씨%' OR partner_name LIKE '%PTC%' OR partner_name LIKE '%한라%' OR partner_name LIKE '%바론%' + OR memo1 LIKE '%삼안%' OR memo1 LIKE '%장헌%' OR memo1 LIKE '%피티씨%' OR memo1 LIKE '%PTC%' OR memo1 LIKE '%한라%' OR memo1 LIKE '%바론%') + GROUP BY counterparty, account_name + ORDER BY amount DESC + LIMIT 20 + """, + PERIOD_PARAMS, + ) + family_table = [ + [row["counterparty"], row["account_name"], fmt_count(row["voucher_count"]), fmt_money(row["amount"])] + for row in family_rows + ] + + common_rows = q( + conn, + f""" + SELECT account_name, accounting_category, COUNT(DISTINCT voucher_number) voucher_count, + SUM(abs(COALESCE(amount,0))) amount + FROM transactions + WHERE {PERIOD_SQL} + AND (account_code LIKE '5%' OR account_code LIKE '6%') + AND UPPER(COALESCE(support_dept_code,'')) IN ('', 'ZZZZZZ') + GROUP BY account_name, accounting_category + ORDER BY amount DESC + LIMIT 25 + """, + PERIOD_PARAMS, + ) + common_table = [ + [row["account_name"], row["accounting_category"], fmt_count(row["voucher_count"]), fmt_money(row["amount"])] + for row in common_rows + ] + + welfare_keyword_rows = q( + conn, + f""" + SELECT account_name, COUNT(DISTINCT voucher_number) voucher_count, + SUM(abs(COALESCE(amount,0))) amount + FROM transactions + WHERE {PERIOD_SQL} + AND account_name LIKE '%복리후생%' + AND ( + memo1 LIKE '%골프%' OR memo2 LIKE '%골프%' OR memo1 LIKE '%상품권%' OR memo2 LIKE '%상품권%' + OR memo1 LIKE '%화환%' OR memo2 LIKE '%화환%' OR memo1 LIKE '%경조%' OR memo2 LIKE '%경조%' + OR memo1 LIKE '%외부%' OR memo2 LIKE '%외부%' OR memo1 LIKE '%명절%' OR memo2 LIKE '%명절%' + OR memo1 LIKE '%임원%' OR memo2 LIKE '%임원%' OR partner_name LIKE '%골프%' + ) + GROUP BY account_name + ORDER BY amount DESC + """, + PERIOD_PARAMS, + ) + welfare_table = [ + [row["account_name"], fmt_count(row["voucher_count"]), fmt_money(row["amount"])] + for row in welfare_keyword_rows + ] + + refund_rows = q( + conn, + f""" + SELECT account_name, COUNT(DISTINCT voucher_number) voucher_count, + SUM(abs(COALESCE(amount,0))) amount + FROM transactions + WHERE {PERIOD_SQL} + AND (account_name LIKE '%잡이익%' OR account_name LIKE '%수익%') + AND (memo1 LIKE '%환급%' OR memo2 LIKE '%환급%' OR memo1 LIKE '%지원금%' OR memo2 LIKE '%지원금%' + OR memo1 LIKE '%보조%' OR memo2 LIKE '%보조%' OR memo1 LIKE '%반환%' OR memo2 LIKE '%반환%') + GROUP BY account_name + ORDER BY amount DESC + """, + PERIOD_PARAMS, + ) + refund_table = [ + [row["account_name"], fmt_count(row["voucher_count"]), fmt_money(row["amount"])] + for row in refund_rows + ] + + sample_definitions = { + "가족사 자금거래": ( + "(account_name LIKE '%대여금%' OR account_name LIKE '%차입금%' OR account_name LIKE '%이자수익%' OR account_name LIKE '%이자비용%' OR account_name LIKE '%미수금%') " + "AND (partner_name LIKE '%삼안%' OR partner_name LIKE '%장헌%' OR partner_name LIKE '%피티씨%' OR partner_name LIKE '%PTC%' OR partner_name LIKE '%한라%' OR partner_name LIKE '%바론%' " + "OR memo1 LIKE '%삼안%' OR memo1 LIKE '%장헌%' OR memo1 LIKE '%피티씨%' OR memo1 LIKE '%PTC%' OR memo1 LIKE '%한라%' OR memo1 LIKE '%바론%')", + (), + ), + "전도금": ("account_name LIKE '%전도금%'", ()), + "미수금": ("account_name LIKE '%미수금%' OR account_name LIKE '%용역미수금%' OR account_name LIKE '%임대미수금%'", ()), + "복리후생·접대": ("account_name LIKE '%복리후생%' OR account_name LIKE '%접대비%'", ()), + "출장·식대·교통": ("account_name LIKE '%국내출장%' OR account_name LIKE '%출장%' OR account_name LIKE '%외근식대%' OR account_name LIKE '%회식대%' OR account_name LIKE '%시내교통%'", ()), + "공통코드 비용": ("(account_code LIKE '5%' OR account_code LIKE '6%') AND UPPER(COALESCE(support_dept_code,'')) IN ('', 'ZZZZZZ')", ()), + } + samples = {name: sample_rows(conn, where, params) for name, (where, params) in sample_definitions.items()} + + conclusion_rows = [ + ["가족사 자금거래", "높음", "WEHAGO에도 관계회사 대여금·차입금·이자·미수금 전표가 존재한다. 계약서, 이율, 회수조건, 잔액대사 없이는 PDF와 같은 리스크가 해소됐다고 보기 어렵다."], + ["전도금·미수금 정산", "중~높음", "전표 금액은 확인되나 정산대상 연결키가 전표 구조상 명확하지 않다. 적요 의존이면 외부 감사 시 추가 증빙 요청 가능성이 높다."], + ["복리후생·접대성 비용", "중~높음", "WEHAGO에도 복리후생비와 접대비가 병존한다. 외부인·임원성·상품권·골프 등은 세무상 재분류 또는 손금불산입 검토 대상이다."], + ["프로젝트 손익", "중간", "재무제표 총액보다 프로젝트별 손익, 원가율, 성과평가 왜곡 위험이 크다. 공통코드 비용 배부 기준이 필요하다."], + ["출장·식대 중복", "중간", "전표만으로 중복 지급 확정은 어렵지만, 출장비와 식대/회식/교통비가 같은 기간에 함께 존재해 표본 대사가 필요하다."], + ] + + generated_at = datetime.now().strftime("%Y%m%d_%H%M%S") + md_path = REPORT_DIR / f"wehago_accounting_risk_review_2023_2025_{generated_at}.md" + docx_path = REPORT_DIR / f"wehago_accounting_risk_review_2023_2025_{generated_at}.docx" + csv_path = REPORT_DIR / f"wehago_accounting_risk_samples_2023_2025_{generated_at}.csv" + + md_parts = [ + "# WEHAGO 전표 회계정보 점검 리스크 재분석 보고", + "", + f"- 기준 보고서: `2.한맥erp회계정보점검상세결과보고.pdf`", + f"- 분석 대상: WEHAGO `transactions` 전표, {START_DATE}~{END_DATE}", + f"- 전체 전표 수: {fmt_count(total)}건", + "- 주의: 본 분석은 전표 데이터 기반의 위험 후보 식별이며, 계약서·증빙·정산서·세무조정 자료 대사 전에는 위법 또는 오류 확정으로 볼 수 없다.", + "", + "## 1. 기존 작업 적합성 검토", + "", + "기존에 진행하던 H코드/ZZZZZZ 통합 검토는 프로젝트 손익 화면의 표시·배부 로직 문제로서, 첨부 PDF의 핵심 주제와 다르다. PDF는 회계 전표정보 점검 보고서이며 가족사 자금거래, 전도금·미수금 정산, 복리후생비/접대성 비용, 출장·식대 및 프로젝트 원가 귀속 리스크를 다룬다. 따라서 현재 요청에는 기존 방향이 부적합했고, 아래와 같이 PDF 점검 항목 기준으로 WEHAGO 전표를 다시 분석했다.", + "", + "## 2. PDF 기준 리스크별 WEHAGO 전표 분포", + "", + md_table(["리스크 영역", "행 수", "전표 수", "전체 전표 대비", "금액 절대합", "판정"], summary_rows), + "", + "## 3. 세부 분석", + "", + "### 3.1 가족사 자금거래", + md_table(["거래상대", "계정", "전표 수", "금액 절대합"], family_table), + "", + "### 3.2 공통코드 비용 및 프로젝트 손익 왜곡 후보", + md_table(["계정", "회계구분", "전표 수", "금액 절대합"], common_table), + "", + "### 3.3 복리후생비 내 접대성·임원성 키워드 후보", + md_table(["계정", "전표 수", "금액 절대합"], welfare_table or [["해당 키워드 후보 없음", "-", "-"]]), + "", + "### 3.4 환급·지원금성 수익 처리 후보", + md_table(["계정", "전표 수", "금액 절대합"], refund_table or [["해당 키워드 후보 없음", "-", "-"]]), + "", + "## 4. 외부 감사·세무조사·경영상 불이익 가능성", + "", + md_table(["영역", "위험도", "예상 불이익"], conclusion_rows), + "", + "## 5. 표본 전표", + ] + sample_headers = ["일자", "전표번호", "계정", "거래처", "프로젝트코드", "프로젝트명", "적요", "금액"] + for name, rows in samples.items(): + md_parts.extend(["", f"### {name}", md_table(sample_headers, rows or [["-", "-", "-", "-", "-", "-", "표본 없음", "-"]])]) + md_path.write_text("\n".join(md_parts), encoding="utf-8") + + with csv_path.open("w", newline="", encoding="utf-8-sig") as f: + writer = csv.writer(f) + writer.writerow(["구분", *sample_headers]) + for name, rows in samples.items(): + for row in rows: + writer.writerow([name, *row]) + + sections: list[tuple[str, str | None, list[tuple[list[str], list[list[Any]]]]]] = [ + ( + "요약", + f"분석 대상은 WEHAGO transactions 전표 {START_DATE}~{END_DATE}, 전체 {fmt_count(total)}건이다. 기존 H코드 검토는 PDF 주제와 맞지 않아 본 보고서에서 회계 전표 리스크 기준으로 재분석했다.", + [(["리스크 영역", "행 수", "전표 수", "전체 대비", "금액 절대합", "판정"], summary_rows)], + ), + ("판정 및 경영상 영향", None, [(["영역", "위험도", "예상 불이익"], conclusion_rows)]), + ("가족사 자금거래", None, [(["거래상대", "계정", "전표 수", "금액 절대합"], family_table)]), + ("공통코드 비용", None, [(["계정", "회계구분", "전표 수", "금액 절대합"], common_table)]), + ("복리후생·접대성 후보", None, [(["계정", "전표 수", "금액 절대합"], welfare_table or [["해당 키워드 후보 없음", "-", "-"]])]), + ("환급·지원금성 수익 처리 후보", None, [(["계정", "전표 수", "금액 절대합"], refund_table or [["해당 키워드 후보 없음", "-", "-"]])]), + ] + for name, rows in samples.items(): + sections.append((f"표본: {name}", None, [(sample_headers, rows or [["-", "-", "-", "-", "-", "-", "표본 없음", "-"]])])) + write_docx(docx_path, "WEHAGO 전표 회계정보 점검 리스크 재분석 보고", sections) + + print(md_path) + print(docx_path) + print(csv_path) + + +if __name__ == "__main__": + main() diff --git a/scripts/analyze_wehago_case_report.py b/scripts/analyze_wehago_case_report.py new file mode 100644 index 0000000..6a80713 --- /dev/null +++ b/scripts/analyze_wehago_case_report.py @@ -0,0 +1,688 @@ +from __future__ import annotations + +import csv +import re +import sqlite3 +import zipfile +from collections import Counter, defaultdict +from datetime import datetime +from pathlib import Path +from typing import Any +from xml.sax.saxutils import escape + +import sys + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT)) + +from runtime_config import DB_PATH as CONFIG_DB_PATH # noqa: E402 + + +YEAR = 2025 +WEHAGO_STATUSES = ("voucher_matched", "voucher_recheck", "voucher_unmatched") +MATCHED_STATUSES = ("voucher_matched", "voucher_recheck") +REPORT_DIR = ROOT / "reports" +DB_PATH = ROOT / "data.db" if (ROOT / "data.db").exists() else CONFIG_DB_PATH + + +def clean(value: Any) -> str: + return "" if value is None else str(value).strip() + + +def amount(value: Any) -> float: + try: + return float(value or 0) + except (TypeError, ValueError): + return 0.0 + + +def pct(n: int, d: int) -> str: + return f"{(n / d * 100):.2f}%" if d else "0.00%" + + +def norm_text(value: str) -> str: + text = clean(value) + text = text.replace("(주)", "").replace("㈜", "").replace("(주)", "") + text = text.replace("주식회사", "").replace("유한회사", "").replace("재단법인", "") + text = text.replace("사단법인", "").replace("(재)", "").replace("(사)", "") + text = re.sub(r"\b외\s*\d+\s*명\b", "", text) + text = re.sub(r"[\s()/,_\-.·]+", "", text) + return text + + +def account_core(value: str) -> str: + text = clean(value) + text = text.replace("원가)", "").replace("판관)", "") + text = re.sub(r"\([^)]*\)", "", text) + text = re.sub(r"[\s/]+", "", text) + return text + + +def account_family(value: str) -> str: + text = clean(value) + core = account_core(text) + pairs = [ + ("복리후생", "welfare"), + ("접대", "entertainment"), + ("부서비", "department"), + ("여비교통", "travel"), + ("시내교통", "travel"), + ("차량유지", "vehicle"), + ("차량렌탈", "vehicle"), + ("지급임차료", "rent"), + ("임차료", "rent"), + ("통신", "communication"), + ("수도광열", "utility"), + ("전력", "utility"), + ("전기요금", "utility"), + ("가스수도", "utility"), + ("소모품", "supplies"), + ("사무용품", "supplies"), + ("전산용품", "supplies"), + ("교육훈련", "training"), + ("행사비용", "event"), + ("합사경비", "site_office"), + ("감리현장운영비", "site_operation"), + ("관리현장운영비", "site_operation"), + ("외주비", "outsourcing"), + ("기술협력비", "outsourcing"), + ("설계외주비", "outsourcing"), + ("세금과공과", "tax_dues"), + ("수수료", "fee"), + ("선급금", "advance"), + ("전도금", "advance"), + ("보통예금", "cash"), + ("외상매입금", "payable"), + ("미지급금", "payable"), + ("외상매출금", "receivable"), + ("미수금", "receivable"), + ("용역미수금", "receivable"), + ("부가세", "tax"), + ("매입세액", "tax"), + ("매출세액", "tax"), + ("예수", "withholding"), + ("용역수입", "revenue_service"), + ("설계용역수입", "revenue_service"), + ("임대수입", "revenue_rent"), + ("이자수익", "revenue_interest"), + ("잡이익", "other_income"), + ("잡손실", "other_loss"), + ] + for token, family in pairs: + if token in text or token in core: + return family + return core + + +def is_substantive_reclass(left: str, right: str) -> bool: + left_family = account_family(left) + right_family = account_family(right) + if not left_family or not right_family or left_family == right_family: + return False + if "tax" in {left_family, right_family}: + return False + if {left_family, right_family} in ( + {"cash", "payable"}, + {"cash", "receivable"}, + {"payable", "receivable"}, + ): + return False + return True + + +def is_tax_account(account: str) -> bool: + return any(token in clean(account) for token in ("부가세", "매입세액", "매출세액", "불공제")) + + +def is_clearing_account(account: str) -> bool: + return any( + token in clean(account) + for token in ( + "보통예금", + "외상매입금", + "외상매출금", + "미지급금", + "미수금", + "선급금", + "전도금", + "예수금", + "예수", + "부가세", + "세액", + ) + ) + + +def row_side_amount(row: dict[str, Any], prefix: str) -> tuple[str, float]: + debit = amount(row[f"{prefix}_debit"]) + credit = amount(row[f"{prefix}_credit"]) + if abs(debit) >= abs(credit): + return "debit", debit + return "credit", credit + + +def has_ledger(row: dict[str, Any]) -> bool: + return bool( + clean(row["ledger_account_name"]) + or clean(row["ledger_desc"]) + or abs(amount(row["ledger_debit"])) >= 0.5 + or abs(amount(row["ledger_credit"])) >= 0.5 + ) + + +def has_erp(row: dict[str, Any]) -> bool: + return bool( + clean(row["voucher_account_name"]) + or clean(row["voucher_desc"]) + or abs(amount(row["voucher_debit"])) >= 0.5 + or abs(amount(row["voucher_credit"])) >= 0.5 + ) + + +def latest_signature(conn: sqlite3.Connection) -> str: + row = conn.execute( + """ + SELECT signature + FROM wehago_compare_query_groups + WHERE start_year = ? AND end_year = ? AND signature LIKE 'compare-query-v5|%' + GROUP BY signature + ORDER BY MAX(updated_at) DESC + LIMIT 1 + """, + (YEAR, YEAR), + ).fetchone() + if not row: + raise RuntimeError("latest compare signature not found") + return clean(row[0]) + + +def group_key(group: dict[str, Any]) -> tuple[str, int]: + return (clean(group["status_key"]), int(group["group_index"])) + + +def display_key(group: dict[str, Any]) -> str: + return f"{group['fiscal_year']} {clean(group['ledger_date'])} {clean(group['voucher_no'])}".strip() + + +def summarize_group(group: dict[str, Any], rows: list[dict[str, Any]]) -> str: + erp = clean(group["draft_no"]) or "ERP 전표 없음" + ledger_accounts = clean(group["ledger_accounts"]) or "-" + erp_accounts = clean(group["voucher_accounts"]) or "-" + ledger_vendors = clean(group["ledger_vendors"]) or "-" + erp_vendors = clean(group["voucher_vendors"]) or "-" + descs = [] + for row in rows: + for key in ("ledger_desc", "voucher_desc"): + text = clean(row.get(key)) + if text and text not in descs: + descs.append(text) + if len(descs) >= 2: + break + desc = " / ".join(descs[:2]) + return ( + f"{display_key(group)} | ERP {erp} | " + f"더존 계정 {ledger_accounts} / ERP 계정 {erp_accounts} | " + f"더존 거래처 {ledger_vendors} / ERP 거래처 {erp_vendors}" + + (f" | 적요 {desc}" if desc else "") + ) + + +def account_reclass(rows: list[dict[str, Any]]) -> bool: + for row in rows: + if not (has_ledger(row) and has_erp(row)): + continue + ledger_account = clean(row["ledger_account_name"]) + erp_account = clean(row["voucher_account_name"]) + if not ledger_account or not erp_account: + continue + if is_tax_account(ledger_account) or is_tax_account(erp_account): + continue + ledger_side, ledger_amount = row_side_amount(row, "ledger") + erp_side, erp_amount = row_side_amount(row, "voucher") + if ledger_side == erp_side and abs(ledger_amount - erp_amount) < 0.5: + if is_substantive_reclass(ledger_account, erp_account): + return True + return False + + +def broad_account_difference(rows: list[dict[str, Any]]) -> bool: + for row in rows: + if not (has_ledger(row) and has_erp(row)): + continue + if clean(row["ledger_account_name"]) and clean(row["voucher_account_name"]): + if account_core(row["ledger_account_name"]) != account_core(row["voucher_account_name"]): + return True + return False + + +def vendor_change(rows: list[dict[str, Any]]) -> bool: + for row in rows: + if not (has_ledger(row) and has_erp(row)): + continue + ledger_vendor = clean(row["ledger_vendor"]) + erp_vendor = clean(row["voucher_vendor"]) + if not ledger_vendor or not erp_vendor: + continue + if "국민" in ledger_vendor and "국민" in erp_vendor: + continue + left = norm_text(ledger_vendor) + right = norm_text(erp_vendor) + if not left or not right: + continue + if left in right or right in left: + continue + common_len = 0 + for i in range(len(left)): + for j in range(i + 3, len(left) + 1): + if left[i:j] in right: + common_len = max(common_len, j - i) + if common_len >= 4: + continue + if left != right: + ledger_side, ledger_amount = row_side_amount(row, "ledger") + erp_side, erp_amount = row_side_amount(row, "voucher") + if ledger_side == erp_side and abs(ledger_amount - erp_amount) < 0.5: + return True + return False + + +def amount_display_difference(rows: list[dict[str, Any]]) -> bool: + ledger_only = [r for r in rows if has_ledger(r) and not has_erp(r) and not is_tax_account(r["ledger_account_name"])] + erp_only = [r for r in rows if has_erp(r) and not has_ledger(r) and not is_tax_account(r["voucher_account_name"])] + if not ledger_only or not erp_only: + return False + tax_like = [ + r + for r in rows + if is_tax_account(r["ledger_account_name"]) or is_tax_account(r["voucher_account_name"]) + ] + for lrow in ledger_only: + lside, lamt = row_side_amount(lrow, "ledger") + if lside != "debit" or lamt <= 0: + continue + for erow in erp_only: + eside, eamt = row_side_amount(erow, "voucher") + if eside != "debit" or eamt <= 0 or lamt <= eamt: + continue + diff = lamt - eamt + looks_vat = abs(diff - round(eamt * 0.1)) <= 2 or any( + abs(diff - max(amount(t["ledger_debit"]), amount(t["ledger_credit"]), amount(t["voucher_debit"]), amount(t["voucher_credit"]))) <= 2 + for t in tax_like + ) + if looks_vat: + return True + return False + + +def advance_unsettled(group: dict[str, Any], rows: list[dict[str, Any]]) -> bool: + text = " ".join( + [clean(group["ledger_accounts"]), clean(group["ledger_vendors"])] + + [clean(r["ledger_desc"]) + " " + clean(r["ledger_account_name"]) + " " + clean(r["ledger_vendor"]) for r in rows] + ) + return bool( + ("주재비" in text) + or ("전도금" in text and ("운영" in text or "정산" in text or "감리현장" in text)) + or ("관리현장운영비" in text) + ) + + +def management_scope_difference(group: dict[str, Any], rows: list[dict[str, Any]]) -> bool: + text = " ".join( + [clean(group["ledger_accounts"]), clean(group["ledger_vendors"])] + + [clean(r["ledger_desc"]) + " " + clean(r["ledger_account_name"]) + " " + clean(r["ledger_vendor"]) for r in rows] + ) + return any(token in text for token in ("RP 매수", "RP 매도", "기타예금", "투자자산", "유가증권", "증권", "CMA", "HMC투자")) + + +def writing_method_difference(group: dict[str, Any], rows: list[dict[str, Any]]) -> bool: + if group["status_key"] not in MATCHED_STATUSES: + return False + has_l_only = any(has_ledger(r) and not has_erp(r) for r in rows) + has_e_only = any(has_erp(r) and not has_ledger(r) for r in rows) + if not (has_l_only and has_e_only): + return False + return True + + +def primary_vendor(group: dict[str, Any]) -> str: + vendors = clean(group.get("ledger_vendors")) + if not vendors: + vendors = clean(group.get("voucher_vendors")) + return norm_text(vendors.split(",")[0]) + + +def group_month(group: dict[str, Any]) -> str: + date_text = clean(group.get("ledger_date")) + match = re.match(r"(\d{1,2})[-./]", date_text) + return match.group(1).zfill(2) if match else "" + + +def build_diverse_samples( + case_keys: set[tuple[str, int]], + group_by_key: dict[tuple[str, int], dict[str, Any]], + rows_by_group: dict[tuple[str, int], list[dict[str, Any]]], + excluded: set[tuple[str, int]], + limit: int = 5, +) -> list[str]: + candidates = [ + key + for key in sorted(case_keys, key=lambda k: (group_by_key[k]["ledger_date"], group_by_key[k]["voucher_no"], k[1])) + if key not in excluded + ] + selected: list[tuple[str, int]] = [] + used_months: set[str] = set() + used_vendors: set[str] = set() + + def try_pick(require_new_month: bool, require_new_vendor: bool) -> None: + if len(selected) >= limit: + return + for key in candidates: + if key in selected: + continue + group = group_by_key[key] + month = group_month(group) + vendor = primary_vendor(group) + if require_new_month and month and month in used_months: + continue + if require_new_vendor and vendor and vendor in used_vendors: + continue + selected.append(key) + if month: + used_months.add(month) + if vendor: + used_vendors.add(vendor) + if len(selected) >= limit: + return + + try_pick(require_new_month=True, require_new_vendor=True) + try_pick(require_new_month=False, require_new_vendor=True) + try_pick(require_new_month=False, require_new_vendor=False) + return [summarize_group(group_by_key[key], rows_by_group[key]) for key in selected[:limit]] + + +def docx_paragraph(text: str, style: str | None = None) -> str: + style_xml = f'' if style else "" + return f"{style_xml}{escape(text)}" + + +def docx_table(headers: list[str], rows: list[list[str]]) -> str: + def cell(value: str, bold: bool = False) -> str: + run_pr = "" if bold else "" + return ( + "" + f"{run_pr}{escape(str(value))}" + ) + + table_rows = ["" + "".join(cell(header, True) for header in headers) + ""] + for row in rows: + table_rows.append("" + "".join(cell(str(value)) for value in row) + "") + return ( + "" + "" + "" + "" + "" + "" + "" + "" + + "".join(table_rows) + + "" + ) + + +def write_docx( + path: Path, + title: str, + meta_lines: list[str], + summary_rows: list[list[str]], + detail_rows_for_doc: list[list[str]], + sample_rows_by_case: dict[str, list[str]], +) -> None: + body: list[str] = [docx_paragraph(title, "Title")] + body.extend(docx_paragraph(line) for line in meta_lines) + body.append(docx_paragraph("요약", "Heading1")) + body.append(docx_table(["구분", "전표 수", "전체 대비", "비고"], summary_rows)) + body.append(docx_paragraph("세부 유형별 수치", "Heading1")) + body.append(docx_table(["대분류", "세부 유형", "전표 수", "전체 대비", "산정 기준"], detail_rows_for_doc)) + body.append(docx_paragraph("추가 사례", "Heading1")) + for case_name, sample_rows in sample_rows_by_case.items(): + body.append(docx_paragraph(case_name, "Heading2")) + for sample in sample_rows: + body.append(docx_paragraph(sample)) + + document_xml = ( + '' + '' + "" + + "".join(body) + + '' + '' + "" + ) + styles_xml = ( + '' + '' + '' + '' + '' + '' + '' + '' + "" + ) + content_types = ( + '' + '' + '' + '' + '' + '' + "" + ) + rels = ( + '' + '' + '' + "" + ) + doc_rels = ( + '' + '' + '' + "" + ) + with zipfile.ZipFile(path, "w", compression=zipfile.ZIP_DEFLATED) as docx: + docx.writestr("[Content_Types].xml", content_types) + docx.writestr("_rels/.rels", rels) + docx.writestr("word/_rels/document.xml.rels", doc_rels) + docx.writestr("word/document.xml", document_xml) + docx.writestr("word/styles.xml", styles_xml) + + +def main() -> None: + conn = sqlite3.connect(DB_PATH) + conn.row_factory = sqlite3.Row + signature = latest_signature(conn) + + groups = [ + dict(row) + for row in conn.execute( + f""" + SELECT * + FROM wehago_compare_query_groups + WHERE start_year = ? AND end_year = ? AND signature = ? + AND status_key IN ({','.join('?' for _ in WEHAGO_STATUSES)}) + ORDER BY status_key, group_index + """, + (YEAR, YEAR, signature, *WEHAGO_STATUSES), + ) + ] + rows_by_group: dict[tuple[str, int], list[dict[str, Any]]] = defaultdict(list) + for row in conn.execute( + f""" + SELECT * + FROM wehago_compare_query_rows + WHERE start_year = ? AND end_year = ? AND signature = ? + AND status_key IN ({','.join('?' for _ in WEHAGO_STATUSES)}) + ORDER BY status_key, group_index, row_index + """, + (YEAR, YEAR, signature, *WEHAGO_STATUSES), + ): + record = dict(row) + rows_by_group[(record["status_key"], int(record["group_index"]))].append(record) + + total = len(groups) + status_counts = Counter(group["status_key"] for group in groups) + cases: dict[str, set[tuple[str, int]]] = defaultdict(set) + + for group in groups: + key = group_key(group) + rows = rows_by_group[key] + if group["status_key"] in MATCHED_STATUSES: + if broad_account_difference(rows): + cases["광의 계정명 차이"].add(key) + if account_reclass(rows): + cases["계정 재분류 후보"].add(key) + if vendor_change(rows): + cases["거래처/대상자 변경 및 보완"].add(key) + if amount_display_difference(rows): + cases["금액 표시 기준 차이"].add(key) + if writing_method_difference(group, rows): + cases["전표 작성 방식 차이"].add(key) + if group["status_key"] == "voucher_unmatched": + if advance_unsettled(group, rows): + cases["ERP상 주재비/전도금 미정산"].add(key) + if management_scope_difference(group, rows): + cases["관리 대상 차이"].add(key) + + info_union = ( + cases["계정 재분류 후보"] + | cases["거래처/대상자 변경 및 보완"] + | cases["금액 표시 기준 차이"] + ) + unmatched_union = ( + set(group_key(g) for g in groups if g["status_key"] == "voucher_unmatched") + | cases["전표 작성 방식 차이"] + ) + voucher_unmatched_keys = set(group_key(g) for g in groups if g["status_key"] == "voucher_unmatched") + residual_unentered = voucher_unmatched_keys - cases["ERP상 주재비/전도금 미정산"] - cases["관리 대상 차이"] + cases["전표 미입력/ERP 직접 대응 없음"].update(residual_unentered) + + group_by_key = {group_key(group): group for group in groups} + + given_examples = { + ("voucher_unmatched", 71), + ("voucher_matched", 2833), + ("voucher_matched", 2016), + ("voucher_unmatched", 174), + ("voucher_matched", 51), + ("voucher_unmatched", 431), + ("voucher_unmatched", 1042), + } + + def samples(case_name: str, limit: int = 5) -> list[str]: + return build_diverse_samples(cases[case_name], group_by_key, rows_by_group, given_examples, limit) + + ts = datetime.now().strftime("%Y%m%d_%H%M%S") + md_path = REPORT_DIR / f"wehago_case_distribution_report_{YEAR}_{ts}.md" + csv_path = REPORT_DIR / f"wehago_case_distribution_samples_{YEAR}_{ts}.csv" + docx_path = REPORT_DIR / f"wehago_case_distribution_report_{YEAR}_{ts}.docx" + + summary_rows = [ + ["① 매칭 전표의 정보 차이(중복 제거)", f"{len(info_union):,}", pct(len(info_union), total), "계정 재분류 후보, 거래처 변경, 금액 표시 차이 중 하나 이상"], + ["② 미매칭/부분미매칭 발생 유형(중복 제거)", f"{len(unmatched_union):,}", pct(len(unmatched_union), total), "더존-only 미매칭 + 전표 작성 방식 차이"], + ["더존-only 미매칭", f"{status_counts['voucher_unmatched']:,}", pct(status_counts["voucher_unmatched"], total), "ERP 직접 대응 행이 없는 더존 전표"], + ["재검토", f"{status_counts['voucher_recheck']:,}", pct(status_counts["voucher_recheck"], total), "매칭 후보이나 검토 필요"], + ] + detail_rows = [ + ("①", "계정 재분류 후보", "매칭/재검토 중 금액·차대 방향은 같고, 계정의 핵심 의미군이 달라진 행 포함. 단순 계정명·보조명 차이는 제외"), + ("①", "광의 계정명 차이", "세액·채권채무·예금 등 시스템 계정명 차이를 포함한 계정명 차이. 참고 지표로만 사용"), + ("①", "거래처/대상자 변경 및 보완", "매칭/재검토 중 금액·차대 방향은 같고, 거래처 핵심 명칭이 서로 겹치지 않는 행 포함"), + ("①", "금액 표시 기준 차이", "ERP 공급가/부가세 분리 금액이 더존 비용 합산 표시로 나타난 후보"), + ("②", "ERP상 주재비/전도금 미정산", "더존-only 중 주재비, 전도금, 관리현장운영비 정산 문구/계정 포함"), + ("②", "전표 작성 방식 차이", "매칭/재검토 중 한쪽 행만 남는 분리·합산 전표 구조 포함"), + ("②", "관리 대상 차이", "더존-only 중 RP, 기타예금, 증권, 투자자산 등 관리 대상 거래"), + ("②", "전표 미입력/ERP 직접 대응 없음", "더존-only 중 위 주재비/투자관리 유형으로 분류되지 않은 잔여"), + ] + detail_rows_for_doc = [ + [major, name, f"{len(cases[name]):,}", pct(len(cases[name]), total), note] + for major, name, note in detail_rows + ] + sample_rows_by_case = { + name: samples(name) + for _, name, _ in detail_rows + if name != "광의 계정명 차이" + } + + lines: list[str] = [] + lines.append(f"# 더존 전표 기준 케이스별 분포 분석 ({YEAR})") + lines.append("") + lines.append(f"- 기준 DB: `{DB_PATH}`") + lines.append(f"- 기준 projection: `{signature}`") + lines.append(f"- 분모: WEHAGO/더존 전표 그룹 {total:,}건 = 매칭 {status_counts['voucher_matched']:,}건 + 재검토 {status_counts['voucher_recheck']:,}건 + 더존-only 미매칭 {status_counts['voucher_unmatched']:,}건") + lines.append("- 보완 기준: 계정 재분류는 계정의 핵심 의미군이 달라진 경우만 포함하고, 단순 계정명·보조명 차이는 제외했다.") + lines.append("- 보완 기준: 거래처/대상자 변경은 핵심 회사명·조직명 문자열이 서로 겹치지 않는 경우만 포함하고, 약칭·법인격·부서명 차이는 제외했다.") + lines.append("- 사례 선정: 2025년 1월부터 순차 추출하되, 월과 거래처가 과도하게 겹치지 않도록 우선 분산 추출했다.") + lines.append("- 주의: 세부유형은 한 전표가 둘 이상의 유형에 동시에 해당될 수 있어 단순 합산하면 대분류 중복이 발생한다.") + lines.append("") + lines.append("## 요약") + lines.append("") + lines.append("| 구분 | 전표 수 | 전체 대비 | 비고 |") + lines.append("|---|---:|---:|---|") + for row in summary_rows: + lines.append("| " + " | ".join(row) + " |") + lines.append("") + lines.append("## 세부 유형별 수치") + lines.append("") + lines.append("| 대분류 | 세부 유형 | 전표 수 | 전체 대비 | 산정 기준 |") + lines.append("|---|---|---:|---:|---|") + for major, name, note in detail_rows: + lines.append(f"| {major} | {name} | {len(cases[name]):,} | {pct(len(cases[name]), total)} | {note} |") + lines.append("") + lines.append("## 추가 사례") + lines.append("") + for _, name, _ in detail_rows: + if name == "광의 계정명 차이": + continue + lines.append(f"### {name}") + sample_list = sample_rows_by_case[name] + if not sample_list: + lines.append("- 추가 사례 없음") + else: + for item in sample_list: + lines.append(f"- {item}") + lines.append("") + + md_path.write_text("\n".join(lines), encoding="utf-8") + + with csv_path.open("w", newline="", encoding="utf-8-sig") as f: + writer = csv.writer(f) + writer.writerow(["case_name", "sample_no", "sample"]) + for _, name, _ in detail_rows: + if name == "광의 계정명 차이": + continue + for idx, item in enumerate(sample_rows_by_case[name], start=1): + writer.writerow([name, idx, item]) + + meta_lines = [ + f"기준 DB: {DB_PATH}", + f"분모: WEHAGO/더존 전표 그룹 {total:,}건 = 매칭 {status_counts['voucher_matched']:,}건 + 재검토 {status_counts['voucher_recheck']:,}건 + 더존-only 미매칭 {status_counts['voucher_unmatched']:,}건", + "보완 기준: 계정 재분류는 계정의 핵심 의미군이 달라진 경우만 포함하고, 거래처 변경은 핵심 회사명·조직명이 서로 겹치지 않는 경우만 포함했다.", + "사례 선정: 월과 거래처가 과도하게 겹치지 않도록 우선 분산 추출했다.", + ] + write_docx( + docx_path, + f"더존 전표 기준 케이스별 분포 분석 ({YEAR})", + meta_lines, + summary_rows, + detail_rows_for_doc, + sample_rows_by_case, + ) + + print(md_path) + print(csv_path) + print(docx_path) + + +if __name__ == "__main__": + main() diff --git a/scripts/analyze_wehago_unmatched_recheck_cost_types.py b/scripts/analyze_wehago_unmatched_recheck_cost_types.py new file mode 100644 index 0000000..59a3bfb --- /dev/null +++ b/scripts/analyze_wehago_unmatched_recheck_cost_types.py @@ -0,0 +1,572 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import argparse +import csv +import json +import re +import sqlite3 +from collections import Counter, defaultdict +from dataclasses import dataclass +from datetime import datetime +from pathlib import Path +from typing import Any + + +FINAL_STATUSES = ("voucher_unmatched", "voucher_recheck") + +FINANCIAL_ASSET_TOKENS = ( + "기타예금", + "금융상품", + "주.종단기채권", + "주종단기채권", + "매도가능증권", + "만기보유증권", + "투자주식", + "유가증권", + "수익증권", + "국공채", +) + +EXPENSE_EXACT = { + "감가상각비", + "건물관리비", + "경상연구개발비", + "교육훈련비", + "관리비", + "관리현장운영비", + "광고선전비", + "보험료", + "복리후생비", + "부서비", + "사무용품비", + "세금과공과금", + "소모품비", + "수도광열비", + "수선비", + "연구개발비", + "여비교통비", + "외주비", + "이자비용", + "임금", + "잡손실", + "전력비", + "접대비", + "접대비(기업업무추진비)", + "지급수수료", + "지급임차료", + "차량유지비", + "통신비", + "퇴직급여", + "해외출장비", + "행사비용", + "직원급여", + "도서인쇄비", +} + +EXPENSE_EXCLUSIONS = { + "미지급비용", + "선급비용", + "미완성공사도급", + "매도가능증권평가이익", + "투자주식평가이익", + "유형자산처분이익", +} + +REVENUE_TOKENS = ("수익", "수입", "매출", "처분이익", "평가이익", "잡이익") +PAYROLL_TOKENS = ("급여", "임금", "상여", "퇴직급여") +TAX_TOKENS = ("부가세", "선납세금", "예수국민연금", "예수건강보험", "예수고용보험", "예수금") +PAYABLE_TOKENS = ("외상매입금", "미지급금", "미지급비용") +RECEIVABLE_TOKENS = ("외상매출금", "미수금", "미수수익") +BORROWING_TOKENS = ("차입금", "사채") +CLOSING_TOKENS = ("손익", "이익잉여금", "자본금", "자본잉여금") + + +@dataclass +class ClassifiedVoucher: + status: str + compare_voucher_no: str + ledger_date: str + voucher_no: str + category: str + basis_account: str + basis_amount: float + confidence: str + accounts: str + descriptions: str + review_reason: str + + +def clean(value: Any) -> str: + return re.sub(r"\s+", " ", str(value or "")).strip() + + +def amount(row: dict[str, Any]) -> float: + return max( + abs(float(row.get("ledger_debit") or 0)), + abs(float(row.get("ledger_credit") or 0)), + ) + + +def normalized_account(value: Any) -> str: + account = clean(value) + account = re.sub(r"^(?:원가|판관)\)\s*", "", account) + return account + + +def has_token(accounts: list[str], tokens: tuple[str, ...]) -> bool: + return any(token in account for account in accounts for token in tokens) + + +def dominant_row( + rows: list[dict[str, Any]], + predicate, +) -> tuple[str, float]: + candidates: list[tuple[float, str]] = [] + for row in rows: + account = normalized_account(row.get("ledger_account_name")) + if account and predicate(account): + candidates.append((amount(row), account)) + if not candidates: + return "", 0.0 + value, account = max(candidates, key=lambda item: (item[0], item[1])) + return account, value + + +def is_expense(account: str) -> bool: + if account in EXPENSE_EXCLUSIONS: + return False + if account in EXPENSE_EXACT: + return True + if any(token in account for token in ("운영비", "연구개발비", "출장비", "교통비")): + return True + return account.endswith("비") and account not in EXPENSE_EXCLUSIONS + + +def expense_label(account: str) -> str: + if "운영비" in account: + return "운영비" + if account in PAYROLL_TOKENS or any(token in account for token in PAYROLL_TOKENS): + return "인건비" + return account + + +def classify(rows: list[dict[str, Any]]) -> tuple[str, str, float, str]: + ledger_rows = [row for row in rows if clean(row.get("ledger_account_name"))] + accounts = [normalized_account(row.get("ledger_account_name")) for row in ledger_rows] + + financial_account, financial_amount = dominant_row( + ledger_rows, + lambda account: any(token in account for token in FINANCIAL_ASSET_TOKENS), + ) + if financial_account: + return "금융상품거래", financial_account, financial_amount, "high" + + expense_account, expense_amount = dominant_row(ledger_rows, is_expense) + if expense_account: + return expense_label(expense_account), expense_account, expense_amount, "high" + + if has_token(accounts, BORROWING_TOKENS): + account, value = dominant_row( + ledger_rows, + lambda candidate: any(token in candidate for token in BORROWING_TOKENS), + ) + return "차입금거래", account, value, "high" + + if any("차량운반구" in account for account in accounts) and has_token(accounts, REVENUE_TOKENS): + account, value = dominant_row(ledger_rows, lambda candidate: "차량운반구" in candidate) + return "유형자산처분", account, value, "high" + + if has_token(accounts, PAYROLL_TOKENS): + account, value = dominant_row( + ledger_rows, + lambda candidate: any(token in candidate for token in PAYROLL_TOKENS), + ) + return "인건비", account, value, "high" + + if has_token(accounts, REVENUE_TOKENS): + account, value = dominant_row( + ledger_rows, + lambda candidate: any(token in candidate for token in REVENUE_TOKENS), + ) + return "수익거래", account, value, "medium" + + if has_token(accounts, RECEIVABLE_TOKENS) and "보통예금" in accounts: + account, value = dominant_row( + ledger_rows, + lambda candidate: any(token in candidate for token in RECEIVABLE_TOKENS), + ) + return "매출채권회수", account, value, "high" + + if has_token(accounts, PAYABLE_TOKENS) and "보통예금" in accounts: + account, value = dominant_row( + ledger_rows, + lambda candidate: any(token in candidate for token in PAYABLE_TOKENS), + ) + return "매입채무결제", account, value, "high" + + if has_token(accounts, TAX_TOKENS): + account, value = dominant_row( + ledger_rows, + lambda candidate: any(token in candidate for token in TAX_TOKENS), + ) + return "세금·사회보험정산", account, value, "medium" + + if any(account in {"선급금", "선급비용", "전도금"} for account in accounts): + account, value = dominant_row( + ledger_rows, + lambda candidate: candidate in {"선급금", "선급비용", "전도금"}, + ) + return "선급·전도금거래", account, value, "medium" + + if has_token(accounts, CLOSING_TOKENS): + account, value = dominant_row( + ledger_rows, + lambda candidate: any(token in candidate for token in CLOSING_TOKENS), + ) + return "결산·자본대체", account, value, "medium" + + if accounts and set(accounts) <= {"보통예금"}: + account, value = dominant_row(ledger_rows, lambda candidate: candidate == "보통예금") + return "예금계좌대체", account, value, "low" + + account, value = dominant_row(ledger_rows, lambda _candidate: True) + return "기타 자산·부채대체", account, value, "low" + + +def load_active_signature(conn: sqlite3.Connection, year: int) -> str: + row = conn.execute( + """ + SELECT setting_json + FROM wehago_compare_settings + WHERE setting_key = ? + """, + (f"wehago_active_query_projection:{year}:{year}",), + ).fetchone() + if not row: + raise RuntimeError(f"{year} active query projection setting not found") + payload = json.loads(row[0] or "{}") + signature = clean(payload.get("signature")) + if not signature: + raise RuntimeError(f"{year} active query projection signature is empty") + return signature + + +def load_display_group( + conn: sqlite3.Connection, + final_row: sqlite3.Row, + year: int, +) -> tuple[dict[str, Any], list[dict[str, Any]]]: + params = ( + year, + year, + final_row["display_status_key"], + final_row["display_signature"], + final_row["display_group_index"], + ) + group = conn.execute( + """ + SELECT * + FROM wehago_status_projection_groups + WHERE start_year = ? + AND end_year = ? + AND status_key = ? + AND signature = ? + AND group_index = ? + """, + params, + ).fetchone() + if group: + return dict(group), json.loads(group["rows_json"] or "[]") + + group = conn.execute( + """ + SELECT * + FROM wehago_compare_query_groups + WHERE start_year = ? + AND end_year = ? + AND status_key = ? + AND signature = ? + AND group_index = ? + """, + params, + ).fetchone() + if not group: + raise RuntimeError(f"display group not found: {final_row['compare_voucher_no']}") + rows = [ + dict(row) + for row in conn.execute( + """ + SELECT * + FROM wehago_compare_query_rows + WHERE start_year = ? + AND end_year = ? + AND status_key = ? + AND signature = ? + AND group_index = ? + ORDER BY row_index + """, + params, + ) + ] + return dict(group), rows + + +def analyze(db_path: Path, year: int) -> tuple[str, list[ClassifiedVoucher]]: + conn = sqlite3.connect(db_path) + conn.row_factory = sqlite3.Row + try: + signature = load_active_signature(conn, year) + classified: list[ClassifiedVoucher] = [] + for status in FINAL_STATUSES: + final_rows = conn.execute( + """ + SELECT * + FROM wehago_compare_final_status_projection + WHERE start_year = ? + AND end_year = ? + AND signature = ? + AND final_status = ? + ORDER BY ledger_date, voucher_no + """, + (year, year, signature, status), + ).fetchall() + for final_row in final_rows: + group, rows = load_display_group(conn, final_row, year) + category, basis_account, basis_amount, confidence = classify(rows) + accounts = sorted( + { + normalized_account(row.get("ledger_account_name")) + for row in rows + if clean(row.get("ledger_account_name")) + } + ) + descriptions = sorted( + { + clean(row.get("ledger_desc")) + for row in rows + if clean(row.get("ledger_desc")) + } + ) + classified.append( + ClassifiedVoucher( + status=status, + compare_voucher_no=clean(final_row["compare_voucher_no"]), + ledger_date=clean(final_row["ledger_date"]), + voucher_no=clean(final_row["voucher_no"]), + category=category, + basis_account=basis_account, + basis_amount=basis_amount, + confidence=confidence, + accounts=", ".join(accounts), + descriptions=" / ".join(descriptions), + review_reason=clean(group.get("review_reason")), + ) + ) + return signature, classified + finally: + conn.close() + + +def write_csv(path: Path, rows: list[ClassifiedVoucher]) -> None: + with path.open("w", encoding="utf-8-sig", newline="") as handle: + writer = csv.DictWriter(handle, fieldnames=list(ClassifiedVoucher.__dataclass_fields__)) + writer.writeheader() + for row in rows: + writer.writerow(row.__dict__) + + +def pct(value: int, total: int) -> str: + return f"{(value / total * 100):.1f}%" if total else "0.0%" + + +def write_report( + path: Path, + db_path: Path, + year: int, + signature: str, + rows: list[ClassifiedVoucher], +) -> None: + by_status: dict[str, list[ClassifiedVoucher]] = { + status: [row for row in rows if row.status == status] + for status in FINAL_STATUSES + } + lines = [ + f"# WEHAGO Unmatched/Recheck 비용 기준 전표 유형 분석 ({year})", + "", + f"- 기준 DB: `{db_path}`", + f"- 활성 projection: `{signature}`", + f"- 분석 대상: {len(rows):,}전표", + "- 분류 원칙: 금융상품 특수자산 우선, 이후 비용·원가 계정 중 절대금액 최대 계정 우선", + "", + "## 분류 규칙", + "", + "1. `기타예금`, 금융상품, 주·종단기채권, 유가증권·투자자산 계정이 있으면 `금융상품거래`", + "2. 그 외에는 비용·원가 계정 중 차변/대변 절대금액이 가장 큰 계정을 전표 유형으로 사용", + "3. 비용 계정이 없으면 차입금, 수익, 채권회수, 채무결제, 세금, 선급·전도금 순으로 분류", + "4. 비용과 함께 있는 보통예금·선급비용·전도금·외상매입금 등은 결제/대체 계정으로 보고 유형 결정에서 제외", + "", + "## 요약", + "", + "| 상태 | 전표 수 | 비용 계정 기준 분류 | 금융상품거래 | 기타/대체 |", + "|---|---:|---:|---:|---:|", + ] + for status, status_rows in by_status.items(): + expense_count = sum( + 1 + for row in status_rows + if row.category not in { + "금융상품거래", + "차입금거래", + "유형자산처분", + "수익거래", + "매출채권회수", + "매입채무결제", + "세금·사회보험정산", + "선급·전도금거래", + "결산·자본대체", + "예금계좌대체", + "기타 자산·부채대체", + } + ) + financial_count = sum(row.category == "금융상품거래" for row in status_rows) + other_count = len(status_rows) - expense_count - financial_count + lines.append( + f"| {status} | {len(status_rows):,} | {expense_count:,} ({pct(expense_count, len(status_rows))}) " + f"| {financial_count:,} ({pct(financial_count, len(status_rows))}) " + f"| {other_count:,} ({pct(other_count, len(status_rows))}) |" + ) + + for status, status_rows in by_status.items(): + counts = Counter(row.category for row in status_rows) + amounts = defaultdict(float) + for row in status_rows: + amounts[row.category] += row.basis_amount + lines.extend( + [ + "", + f"## {status}", + "", + "| 전표 유형 | 전표 수 | 상태 내 비중 | 기준계정 금액 합계 |", + "|---|---:|---:|---:|", + ] + ) + for category, count in counts.most_common(): + lines.append( + f"| {category} | {count:,} | {pct(count, len(status_rows))} | {amounts[category]:,.0f} |" + ) + + low_rows = [row for row in rows if row.confidence == "low"] + low_counts = Counter(row.accounts for row in low_rows) + lines.extend( + [ + "", + "## 추가 정의 검토가 필요한 항목", + "", + f"- 낮은 확신 분류: {len(low_rows):,}건 ({pct(len(low_rows), len(rows))})", + "", + "| 계정 조합 | 건수 |", + "|---|---:|", + ] + ) + for accounts, count in low_counts.most_common(20): + lines.append(f"| {accounts or '-'} | {count:,} |") + + grant_rows = [row for row in rows if "국고보조금" in row.accounts] + prepaid_rows = [row for row in rows if row.category == "선급·전도금거래"] + prepaid_rules = ( + ("운영비·공동경비", r"운영비|운영경비|공동운영|합사"), + ("출장·전도금", r"출장|전도금"), + ("인건비·용역비", r"인건비|용역비"), + ("경조사·가불", r"결혼|빙부상|가불"), + ("임차료", r"임대료|임차료"), + ("세금·소송", r"인지세|소송"), + ("기타", r".*"), + ) + prepaid_breakdown: Counter[str] = Counter() + for row in prepaid_rows: + for label, pattern in prepaid_rules: + if re.search(pattern, row.descriptions): + prepaid_breakdown[label] += 1 + break + multi_expense_rows = [ + row + for row in rows + if sum( + 1 + for account in (item.strip() for item in row.accounts.split(",")) + if is_expense(account) + ) + > 1 + ] + revenue_rows = [row for row in rows if row.category == "수익거래"] + revenue_basis = Counter(row.basis_account for row in revenue_rows) + lines.extend( + [ + "", + "## 판단 요청", + "", + f"1. `국고보조금` 포함 {len(grant_rows):,}건을 별도 `국고보조금·연구비거래`로 정의할지 판단이 필요합니다.", + f"2. `선급·전도금거래` {len(prepaid_rows):,}건은 적요 기준으로 " + + ", ".join(f"{label} {count}건" for label, count in prepaid_breakdown.most_common()) + + "으로 나뉩니다. 적요 보조분류를 허용하면 비용 성격을 더 정확히 표시할 수 있습니다.", + f"3. 비용 계정이 둘 이상인 전표가 {len(multi_expense_rows):,}건 있습니다. 현재는 금액 최대 비용을 대표 유형으로 선택했지만, " + "`복합비용·결산배부` 보조표시를 추가할지 판단이 필요합니다.", + f"4. `수익거래` {len(revenue_rows):,}건의 기준 계정은 " + + ", ".join(f"{account or '-'} {count}건" for account, count in revenue_basis.most_common()) + + "입니다. 이자수익·용역/임대매출·잡이익·결산대체로 분리하는 편이 분석에는 더 유용합니다.", + "5. `외화환산손실`은 현재 일반 비용 목록에 없어 1건이 기타 대체로 남았습니다. 비용 유형에 추가하는 것이 타당해 보입니다.", + ] + ) + + lines.extend( + [ + "", + "## 표본", + "", + "| 상태 | 전표 | 분류 | 기준 계정 | 계정 조합 |", + "|---|---|---|---|---|", + ] + ) + samples_by_key: dict[tuple[str, str], list[ClassifiedVoucher]] = defaultdict(list) + for row in rows: + samples_by_key[(row.status, row.category)].append(row) + for key in sorted(samples_by_key): + for row in samples_by_key[key][:2]: + lines.append( + f"| {row.status} | {row.compare_voucher_no} | {row.category} " + f"| {row.basis_account} ({row.basis_amount:,.0f}) | {row.accounts} |" + ) + + path.write_text("\n".join(lines) + "\n", encoding="utf-8") + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("--db", type=Path, default=Path("/home/b17301/intranet-runtime/db/data.db")) + parser.add_argument("--year", type=int, default=2025) + parser.add_argument("--output-dir", type=Path, default=Path("reports")) + args = parser.parse_args() + + signature, rows = analyze(args.db, args.year) + stamp = datetime.now().strftime("%Y%m%d_%H%M%S") + args.output_dir.mkdir(parents=True, exist_ok=True) + report_path = args.output_dir / f"wehago_unmatched_recheck_cost_types_{args.year}_{stamp}.md" + csv_path = args.output_dir / f"wehago_unmatched_recheck_cost_types_{args.year}_{stamp}.csv" + write_report(report_path, args.db, args.year, signature, rows) + write_csv(csv_path, rows) + print( + json.dumps( + { + "report": str(report_path), + "csv": str(csv_path), + "rows": len(rows), + "counts": dict(Counter(row.status for row in rows)), + }, + ensure_ascii=False, + ) + ) + + +if __name__ == "__main__": + main() diff --git a/scripts/apply_highpass_bundle_projection_patch.py b/scripts/apply_highpass_bundle_projection_patch.py new file mode 100644 index 0000000..37f138c --- /dev/null +++ b/scripts/apply_highpass_bundle_projection_patch.py @@ -0,0 +1,120 @@ +from __future__ import annotations + +import json +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + +from main import engine +from wehago_compare import ( + _apply_highpass_bundle_trace_review, + _status_projection_signature, + _store_export_compact_status_projection, + clean, +) + + +PATCH_STATUSES = ( + "voucher_matched", + "erp_voucher_matched", + "voucher_unmatched", + "voucher_recheck", + "erp_voucher_unmatched", + "voucher_excepted", +) + + +def _setting_key(status_key: str, start_year: int, end_year: int) -> str: + return f"wehago_active_status_projection:{status_key}:{start_year}:{end_year}" + + +def _load_active_signature(conn, status_key: str, start_year: int, end_year: int) -> str: + row = conn.exec_driver_sql( + """ + SELECT setting_json + FROM wehago_compare_settings + WHERE setting_key = ? + LIMIT 1 + """, + (_setting_key(status_key, start_year, end_year),), + ).first() + if not row or not row[0]: + return "" + try: + payload = json.loads(str(row[0] or "{}")) + except Exception: + return "" + if not isinstance(payload, dict): + return "" + return clean(payload.get("signature")) + + +def _load_projection_groups(conn, status_key: str, start_year: int, end_year: int) -> list[dict]: + signature = _load_active_signature(conn, status_key, start_year, end_year) + if not signature: + return [] + rows = conn.exec_driver_sql( + """ + SELECT summary_json, rows_json + FROM wehago_status_projection_groups + WHERE start_year = ? + AND end_year = ? + AND status_key = ? + AND signature = ? + ORDER BY group_index + """, + (int(start_year), int(end_year), status_key, signature), + ).fetchall() + groups: list[dict] = [] + for row in rows: + try: + summary = json.loads(str(row[0] or "{}")) + except Exception: + summary = {} + try: + group_rows = json.loads(str(row[1] or "[]")) + except Exception: + group_rows = [] + if isinstance(summary, dict) and isinstance(group_rows, list): + groups.append({"summary": summary, "rows": group_rows}) + return groups + + +def apply_patch_for_range(start_year: int, end_year: int) -> dict[str, int]: + with engine.begin() as conn: + sections = { + status_key: _load_projection_groups(conn, status_key, start_year, end_year) + for status_key in PATCH_STATUSES + } + before = {status_key: len(sections.get(status_key) or []) for status_key in PATCH_STATUSES} + sections.setdefault("hanmac_unconnected", []) + + patched = _apply_highpass_bundle_trace_review(sections) + after = {status_key: len(patched.get(status_key) or []) for status_key in PATCH_STATUSES} + + for status_key in PATCH_STATUSES: + signature = _status_projection_signature(conn, status_key, start_year, end_year) + _store_export_compact_status_projection( + conn, + start_year, + end_year, + status_key, + signature, + list(patched.get(status_key) or []), + ) + return { + **{f"before_{key}": value for key, value in before.items()}, + **{f"after_{key}": value for key, value in after.items()}, + } + + +def main() -> None: + start_year = int(sys.argv[1]) if len(sys.argv) > 1 else 2025 + end_year = int(sys.argv[2]) if len(sys.argv) > 2 else start_year + result = apply_patch_for_range(start_year, end_year) + print(json.dumps(result, ensure_ascii=False, sort_keys=True)) + + +if __name__ == "__main__": + main() diff --git a/scripts/apply_proof_date_exact_projection_fix.py b/scripts/apply_proof_date_exact_projection_fix.py new file mode 100644 index 0000000..c61141f --- /dev/null +++ b/scripts/apply_proof_date_exact_projection_fix.py @@ -0,0 +1,232 @@ +import hashlib +import json +import sqlite3 + + +DB = "/home/b17301/intranet-runtime/db/data.db" + + +def row_hash(parts): + return hashlib.sha1("|".join(str(part or "") for part in parts).encode("utf-8")).hexdigest() + + +def main() -> None: + con = sqlite3.connect(DB) + con.row_factory = sqlite3.Row + cur = con.cursor() + + def setting(status): + row = cur.execute( + "SELECT setting_json FROM wehago_compare_settings WHERE setting_key = ?", + (f"wehago_active_status_projection:{status}:2025:2025",), + ).fetchone() + return json.loads(row["setting_json"]) + + def update_count(status, delta): + key = f"wehago_active_status_projection:{status}:2025:2025" + row = cur.execute( + "SELECT setting_json FROM wehago_compare_settings WHERE setting_key = ?", + (key,), + ).fetchone() + payload = json.loads(row["setting_json"]) + payload["total_count"] = int(payload.get("total_count") or 0) + int(delta) + cur.execute( + "UPDATE wehago_compare_settings SET setting_json = ?, updated_at = CURRENT_TIMESTAMP WHERE setting_key = ?", + (json.dumps(payload, ensure_ascii=False, sort_keys=True), key), + ) + + recheck_sig = setting("voucher_recheck")["signature"] + matched_sig = setting("voucher_matched")["signature"] + source = cur.execute( + """ + SELECT * + FROM wehago_status_projection_groups + WHERE start_year = 2025 + AND end_year = 2025 + AND status_key = 'voucher_recheck' + AND signature = ? + AND ledger_date = '01-09' + AND voucher_no = '50007' + """, + (recheck_sig,), + ).fetchone() + if not source: + print("source not found") + return + + source_rows = json.loads(source["rows_json"]) + erp_rows = list( + cur.execute( + """ + SELECT fiscal_year, proof_date, confirmed_no, draft_no, account_code, account_name, + debit_supply, credit_supply, vendor_name, desc1, desc2, row_number, id + FROM wehago_voucher_rows + WHERE draft_no LIKE '11-20250318-J0100-11%' + ORDER BY row_number, id + """ + ) + ) + proof_date = next((row["proof_date"] for row in erp_rows if row["proof_date"]), "") + ledger_rows = [row for row in source_rows if row.get("ledger_account_name")] + matched_rows = [] + for ledger_row in ledger_rows: + ledger_debit = float(ledger_row.get("ledger_debit") or 0) + ledger_credit = float(ledger_row.get("ledger_credit") or 0) + amount = max(abs(ledger_debit), abs(ledger_credit)) + side = "debit" if abs(ledger_debit) >= 0.5 else "credit" + best = None + for erp_row in erp_rows: + erp_amount = abs(float(erp_row["debit_supply"] or 0)) if side == "debit" else abs(float(erp_row["credit_supply"] or 0)) + if abs(erp_amount - amount) < 0.5: + best = erp_row + break + if best is None: + continue + voucher_desc = " ".join(part for part in (best["desc1"] or "", best["desc2"] or "") if part) + payload = { + "fiscal_year": 2025, + "status_label": "Matched", + "ledger_date": "01-09", + "proof_date": best["proof_date"] or proof_date, + "voucher_no": "50007", + "draft_no": best["draft_no"], + "ledger_account_code": ledger_row.get("ledger_account_code", ""), + "ledger_account_name": ledger_row.get("ledger_account_name", ""), + "ledger_vendor": ledger_row.get("ledger_vendor", ""), + "ledger_debit": ledger_debit, + "ledger_credit": ledger_credit, + "ledger_desc": ledger_row.get("ledger_desc", ""), + "voucher_account_code": best["account_code"] or "", + "voucher_account_name": best["account_name"] or "", + "voucher_vendor": best["vendor_name"] or "", + "voucher_debit": float(best["debit_supply"] or 0), + "voucher_credit": float(best["credit_supply"] or 0), + "voucher_desc": voucher_desc, + "review_reason": "PROOF_DATE_EXACT_DIRECT_MATCH / DIRECT_MATCH_CANDIDATE", + "matched_case": "DIRECT_MATCH_CANDIDATE", + "ledger_row_key": ledger_row.get("ledger_row_key", ""), + "voucher_row_key": "", + "match_identity_key": "", + } + payload["voucher_row_key"] = row_hash( + [ + payload["fiscal_year"], + payload["proof_date"], + payload["draft_no"], + payload["voucher_account_code"], + payload["voucher_account_name"], + payload["voucher_vendor"], + payload["voucher_debit"], + payload["voucher_credit"], + payload["voucher_desc"], + ] + ) + payload["match_identity_key"] = row_hash([payload["ledger_row_key"], payload["voucher_row_key"]]) + matched_rows.append(payload) + + if len(matched_rows) < len(ledger_rows): + raise RuntimeError(f"insufficient matched rows: {len(matched_rows)} / {len(ledger_rows)}") + + def distinct(values): + result = [] + for value in values: + if value and value not in result: + result.append(value) + return result + + summary = { + "fiscal_year": 2025, + "status_label": "Matched", + "ledger_date": "01-09", + "proof_date": proof_date, + "voucher_no": "50007", + "draft_no": ", ".join(row["draft_no"] for row in matched_rows), + "ledger_row_count": len(matched_rows), + "voucher_row_count": len(matched_rows), + "ledger_debit": sum(float(row["ledger_debit"]) for row in matched_rows), + "ledger_credit": sum(float(row["ledger_credit"]) for row in matched_rows), + "voucher_debit": sum(float(row["voucher_debit"]) for row in matched_rows), + "voucher_credit": sum(float(row["voucher_credit"]) for row in matched_rows), + "ledger_accounts": ", ".join(distinct(row["ledger_account_name"] for row in matched_rows)), + "voucher_accounts": ", ".join(distinct(row["voucher_account_name"] for row in matched_rows)), + "ledger_vendors": ", ".join(distinct(row["ledger_vendor"] for row in matched_rows)), + "voucher_vendors": ", ".join(distinct(row["voucher_vendor"] for row in matched_rows)), + "review_reason": "PROOF_DATE_EXACT_DIRECT_MATCH / DIRECT_MATCH_CANDIDATE", + "voucher_identity": "20250109-50007", + } + new_index = int( + cur.execute( + """ + SELECT COALESCE(MAX(group_index), -1) + 1 + FROM wehago_status_projection_groups + WHERE start_year = 2025 + AND end_year = 2025 + AND status_key = 'voucher_matched' + AND signature = ? + """, + (matched_sig,), + ).fetchone()[0] + ) + search_text = " ".join( + [str(value or "") for value in summary.values()] + + [str(value or "") for row in matched_rows for value in row.values()] + ) + cur.execute( + """ + DELETE FROM wehago_status_projection_groups + WHERE start_year = 2025 + AND end_year = 2025 + AND status_key = 'voucher_recheck' + AND signature = ? + AND ledger_date = '01-09' + AND voucher_no = '50007' + """, + (recheck_sig,), + ) + cur.execute( + """ + INSERT INTO wehago_status_projection_groups ( + start_year, end_year, status_key, signature, group_index, + fiscal_year, ledger_date, proof_date, voucher_no, draft_no, + ledger_row_count, voucher_row_count, ledger_debit, ledger_credit, + voucher_debit, voucher_credit, ledger_accounts, voucher_accounts, + ledger_vendors, voucher_vendors, review_reason, search_text, + summary_json, rows_json, created_at, updated_at + ) VALUES ( + 2025, 2025, 'voucher_matched', ?, ?, + 2025, '01-09', ?, '50007', ?, + ?, ?, ?, ?, + ?, ?, ?, ?, + ?, ?, ?, ?, + ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP + ) + """, + ( + matched_sig, + new_index, + proof_date, + summary["draft_no"], + summary["ledger_row_count"], + summary["voucher_row_count"], + summary["ledger_debit"], + summary["ledger_credit"], + summary["voucher_debit"], + summary["voucher_credit"], + summary["ledger_accounts"], + summary["voucher_accounts"], + summary["ledger_vendors"], + summary["voucher_vendors"], + summary["review_reason"], + search_text, + json.dumps(summary, ensure_ascii=False, sort_keys=True), + json.dumps(matched_rows, ensure_ascii=False, sort_keys=True), + ), + ) + update_count("voucher_recheck", -1) + update_count("voucher_matched", 1) + con.commit() + print(f"moved 2025-01-09 50007 to voucher_matched index {new_index}") + + +if __name__ == "__main__": + main() diff --git a/scripts/apply_wehago_month_bundle_and_safe_promotions.py b/scripts/apply_wehago_month_bundle_and_safe_promotions.py new file mode 100644 index 0000000..6fc1c14 --- /dev/null +++ b/scripts/apply_wehago_month_bundle_and_safe_promotions.py @@ -0,0 +1,323 @@ +#!/usr/bin/env python3 +"""Apply vetted safe promotions plus month-bundle row allocation fixes. + +This updates the active 2025 status projection. It intentionally excludes +previously rejected noisy candidates such as 2025-02-07-00001 and 2025-05-09-50008. +""" + +from __future__ import annotations + +import argparse +import json +import sqlite3 +from pathlib import Path +from typing import Any + +import apply_wehago_row_allocator_display_fix as display_fix +import test_wehago_month_bundle_shadow as month_alloc +import test_wehago_row_allocator_shadow as alloc + +DEFAULT_DB = Path("/home/b17301/intranet-runtime/db/data.db") +YEAR = 2025 + + +SAFE_STATUS_UPDATES: dict[tuple[int, str, str], tuple[str, str]] = { + (2025, "08-29", "00014"): ("11-20250829-B0100-36", "row allocator 승격: 보통예금/국고보조금 계정·금액 일치"), + (2025, "01-24", "50002"): ("11-20250203-B0100-10", "row allocator 승격: 관리비/잡손실/매입세액 proof_date·금액 일치"), + (2025, "01-24", "50018"): ("11-20250210-J0100-1", "row allocator 승격: 소모품/외상매입금/매입세액 월별 row 일치"), + (2025, "02-25", "50007"): ("11-20250228-B0100-19", "row allocator 승격: 관리비/잡손실/매입세액 proof_date·금액 일치"), + (2025, "04-25", "50002"): ("11-20250428-B0100-7", "row allocator 승격: 관리비/매입세액 일치, 외상매입금은 잔여 상대계정 처리"), + (2025, "05-26", "50006"): ("11-20250527-B0100-22", "row allocator 승격: 관리비/매입세액 proof_date·금액 일치"), + (2025, "07-24", "50001"): ("11-20250724-B0100-14", "row allocator 승격: 관리비/잡손실/매입세액 proof_date·금액 일치"), + (2025, "08-25", "50010"): ("11-20250827-B0100-22", "row allocator 승격: 관리비/매입세액 proof_date·금액 일치"), + (2025, "08-25", "50012"): ("11-20250827-B0100-23", "row allocator 승격: 관리비/매입세액 proof_date·금액 일치"), + (2025, "09-24", "50008"): ("11-20250925-B0100-13", "row allocator 승격: 관리비/매입세액 proof_date·금액 일치"), + (2025, "10-24", "50003"): ("11-20251028-B0100-1", "row allocator 승격: 지급임차료/관리비 및 매입세액 일치"), + (2025, "11-24", "50004"): ("11-20251125-B0100-15", "row allocator 승격: 관리비/매입세액 proof_date·금액 일치"), + (2025, "12-24", "50015"): ("11-20251229-B0100-38", "row allocator 승격: 관리비/잡손실/매입세액 proof_date·금액 일치"), + (2025, "12-24", "50017"): ("11-20251229-B0100-39", "row allocator 승격: 관리비/매입세액 proof_date·금액 일치"), + (2025, "09-25", "00013"): ("11-20250925-B0100-24", "row allocator 승격: 보통예금/국고보조금 계정·금액 일치"), + (2025, "03-05", "00001"): ("11-20250530-B0100-39", "월별 묶음 승격: CCTV 03월 row를 ERP 03월 행에 배정"), +} + +REASSIGN_MATCHED: dict[tuple[int, str, str], tuple[str, str]] = { + (2025, "04-07", "00001"): ("11-20250530-B0100-39", "기존 매칭 row 재점검: CCTV 04월 row를 ERP 04월 행에 배정"), + (2025, "05-07", "00001"): ("11-20250530-B0100-39", "기존 매칭 row 재점검: CCTV 05월 row를 ERP 05월 행에 배정"), + (2025, "06-05", "00002"): ("11-20250627-B0100-30", "기존 매칭 재점검: 3~5월 묶음 ERP에서 06월 CCTV ERP로 재배정"), +} + + +def status_signature(conn: sqlite3.Connection, status_key: str) -> str: + row = conn.execute( + """ + SELECT signature + FROM wehago_status_projection_groups + WHERE start_year = ? AND end_year = ? AND status_key = ? + ORDER BY updated_at DESC + LIMIT 1 + """, + (YEAR, YEAR, status_key), + ).fetchone() + if row is None or not row["signature"]: + raise RuntimeError(f"missing active signature for {status_key}") + return str(row["signature"]) + + +def update_setting_count(conn: sqlite3.Connection, status_key: str, signature: str) -> None: + count = int( + conn.execute( + """ + SELECT COUNT(*) + FROM wehago_status_projection_groups + WHERE start_year = ? AND end_year = ? AND status_key = ? AND signature = ? + """, + (YEAR, YEAR, status_key, signature), + ).fetchone()[0] + or 0 + ) + key = f"wehago_active_status_projection:{status_key}:{YEAR}:{YEAR}" + row = conn.execute("SELECT setting_json FROM wehago_compare_settings WHERE setting_key = ?", (key,)).fetchone() + payload: dict[str, Any] = {} + if row: + try: + payload = json.loads(row["setting_json"] or "{}") + except Exception: + payload = {} + payload.update( + { + "signature": signature, + "status_key": status_key, + "start_year": YEAR, + "end_year": YEAR, + "total_count": count, + "projection_version": "integrity-2025-month-bundle-row-allocator", + } + ) + 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 + """, + (key, json.dumps(payload, ensure_ascii=False, separators=(",", ":"))), + ) + + +def rebuild_month_display_rows( + key: tuple[int, str, str], + status_key: str, + left_rows: list[dict[str, Any]], + right_rows: list[dict[str, Any]], +) -> tuple[list[dict[str, Any]], int]: + pairs, left_unmatched, right_unmatched = month_alloc.allocate_month_aware(key, left_rows, right_rows) + pair_map_l = {i: (j, score) for i, j, score in pairs} + result: list[dict[str, Any]] = [] + for i, left in enumerate(left_rows): + if i in pair_map_l: + j, _score = pair_map_l[i] + result.append(display_fix.combine(key, status_key, left, right_rows[j], True)) + else: + result.append(display_fix.combine(key, status_key, left, None, False)) + for j in right_unmatched: + result.append(display_fix.combine(key, status_key, None, right_rows[j], False)) + result.sort( + key=lambda row: ( + 0 if row.get("ledger_row_key") else 1, + alloc.clean(row.get("ledger_date")), + alloc.clean(row.get("voucher_no")), + display_fix.amount_total(row, "ledger") == 0, + alloc.draft_suffix(row.get("draft_no")), + alloc.clean(row.get("draft_no")), + ) + ) + return result, len(pairs) + + +def apply_group( + conn: sqlite3.Connection, + key: tuple[int, str, str], + base: str, + reason: str, + target_status: str, + raw_wehago: dict[tuple[int, str, str], list[dict[str, Any]]], + erp_by_base: dict[str, list[dict[str, Any]]], + matched_sig: str, + next_group_index: int, + dry_run: bool, +) -> tuple[bool, int, str]: + source = conn.execute( + """ + SELECT * + FROM wehago_status_projection_groups + WHERE start_year = ? AND end_year = ? AND fiscal_year = ? + AND ledger_date = ? AND voucher_no = ? + LIMIT 1 + """, + (YEAR, YEAR, key[0], key[1], key[2]), + ).fetchone() + if source is None: + return False, next_group_index, "source group missing" + left_rows = raw_wehago.get(key, []) + right_rows = erp_by_base.get(base, []) + if not left_rows or not right_rows: + return False, next_group_index, "source rows missing" + display_rows, pair_count = rebuild_month_display_rows(key, target_status, left_rows, right_rows) + if pair_count <= 0: + return False, next_group_index, "allocator found no pairs" + summary = json.loads(source["summary_json"] or "{}") + summary["status_label"] = "Voucher" + summary["review_reason"] = reason + summary = display_fix.update_summary_from_rows(summary, display_rows) + search_text = " ".join( + [ + key[1], + key[2], + summary.get("draft_no", ""), + summary.get("ledger_accounts", ""), + summary.get("voucher_accounts", ""), + summary.get("ledger_vendors", ""), + summary.get("voucher_vendors", ""), + reason, + " ".join(alloc.clean(row.get("ledger_desc")) for row in display_rows), + " ".join(alloc.clean(row.get("voucher_desc")) for row in display_rows), + ] + ) + new_signature = matched_sig if target_status == "voucher_matched" else source["signature"] + new_group_index = next_group_index if source["status_key"] != target_status else source["group_index"] + if not dry_run: + conn.execute( + """ + UPDATE wehago_status_projection_groups + SET status_key = ?, + signature = ?, + group_index = ?, + draft_no = ?, + ledger_row_count = ?, + voucher_row_count = ?, + ledger_debit = ?, + ledger_credit = ?, + voucher_debit = ?, + voucher_credit = ?, + voucher_accounts = ?, + voucher_vendors = ?, + review_reason = ?, + search_text = ?, + summary_json = ?, + rows_json = ?, + updated_at = CURRENT_TIMESTAMP + WHERE start_year = ? AND end_year = ? AND status_key = ? + AND signature = ? AND group_index = ? + """, + ( + target_status, + new_signature, + new_group_index, + summary.get("draft_no", ""), + int(summary.get("ledger_row_count") or 0), + int(summary.get("voucher_row_count") or 0), + float(summary.get("ledger_debit") or 0), + float(summary.get("ledger_credit") or 0), + float(summary.get("voucher_debit") or 0), + float(summary.get("voucher_credit") or 0), + summary.get("voucher_accounts", ""), + summary.get("voucher_vendors", ""), + reason, + search_text, + json.dumps(summary, ensure_ascii=False, separators=(",", ":")), + json.dumps(display_rows, ensure_ascii=False, separators=(",", ":")), + source["start_year"], + source["end_year"], + source["status_key"], + source["signature"], + source["group_index"], + ), + ) + if source["status_key"] != target_status: + next_group_index += 1 + return True, next_group_index, f"{source['status_key']} -> {target_status}, pairs={pair_count}" + + +def clear_query_caches(conn: sqlite3.Connection) -> None: + for table in ( + "wehago_compare_query_groups", + "wehago_compare_query_rows", + "wehago_compare_query_metrics", + "wehago_compare_query_page_cache", + "wehago_compare_final_status_projection", + "wehago_compare_export_jobs", + ): + conn.execute(f"DELETE FROM {table} WHERE start_year = ? AND end_year = ?", (YEAR, YEAR)) + for key in ( + f"wehago_active_query_projection:{YEAR}:{YEAR}", + f"wehago_active_status_projection_run:{YEAR}:{YEAR}", + f"wehago_final_basis_projection:{YEAR}:{YEAR}", + ): + conn.execute("DELETE FROM wehago_compare_settings WHERE setting_key = ?", (key,)) + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("--db", default=str(DEFAULT_DB)) + parser.add_argument("--dry-run", action="store_true") + args = parser.parse_args() + + conn = sqlite3.connect(args.db) + conn.row_factory = sqlite3.Row + raw_wehago = alloc.load_raw_wehago(conn) + erp_by_base = alloc.load_erp(conn) + matched_sig = status_signature(conn, "voucher_matched") + status_sigs = {status: status_signature(conn, status) for status in ("voucher_matched", "voucher_unmatched", "voucher_recheck", "voucher_excepted")} + next_group_index = int( + conn.execute( + """ + SELECT COALESCE(MAX(group_index), -1) + 1 + FROM wehago_status_projection_groups + WHERE start_year = ? AND end_year = ? AND status_key = 'voucher_matched' AND signature = ? + """, + (YEAR, YEAR, matched_sig), + ).fetchone()[0] + ) + applied = [] + skipped = [] + for key, (base, reason) in {**SAFE_STATUS_UPDATES, **REASSIGN_MATCHED}.items(): + ok, next_group_index, note = apply_group( + conn, + key, + base, + reason, + "voucher_matched", + raw_wehago, + erp_by_base, + matched_sig, + next_group_index, + args.dry_run, + ) + payload = {"key": key, "base": base, "note": note} + if ok: + applied.append(payload) + else: + skipped.append(payload) + if not args.dry_run: + for status, sig in status_sigs.items(): + update_setting_count(conn, status, sig) + clear_query_caches(conn) + conn.commit() + counts = dict( + conn.execute( + """ + SELECT status_key, COUNT(*) + FROM wehago_status_projection_groups + WHERE start_year = ? AND end_year = ? + GROUP BY status_key + ORDER BY status_key + """, + (YEAR, YEAR), + ).fetchall() + ) + print(json.dumps({"dry_run": args.dry_run, "applied": applied, "skipped": skipped, "counts": counts}, ensure_ascii=False, indent=2)) + + +if __name__ == "__main__": + main() diff --git a/scripts/apply_wehago_row_allocator_display_fix.py b/scripts/apply_wehago_row_allocator_display_fix.py new file mode 100644 index 0000000..30944aa --- /dev/null +++ b/scripts/apply_wehago_row_allocator_display_fix.py @@ -0,0 +1,254 @@ +#!/usr/bin/env python3 +"""Apply display-only ERP row allocation to active WEHAGO projection groups. + +This updates rows_json for voucher_matched/voucher_recheck groups without moving +any voucher between statuses. Card/table group counts remain unchanged. +""" + +from __future__ import annotations + +import argparse +import json +import re +import sqlite3 +from pathlib import Path +from typing import Any + +import test_wehago_row_allocator_shadow as alloc + +DEFAULT_DB = Path("/home/b17301/intranet-runtime/db/data.db") +YEAR = 2025 + + +def blank() -> dict[str, Any]: + return { + "fiscal_year": YEAR, + "status_label": "", + "ledger_date": "", + "ledger_date_key": "", + "proof_date": "", + "voucher_no": "", + "draft_no": "", + "ledger_account_name": "", + "voucher_account_name": "", + "ledger_vendor": "", + "voucher_vendor": "", + "ledger_debit": 0.0, + "ledger_credit": 0.0, + "voucher_debit": 0.0, + "voucher_credit": 0.0, + "ledger_desc": "", + "voucher_desc": "", + "review_reason": "ROW_ALLOCATOR_DISPLAY_ONLY", + "matched_case": "DISPLAY_CONTEXT_ONLY", + "ledger_row_key": "", + "voucher_row_key": "", + "match_identity_key": "", + } + + +def label_for_status(status_key: str) -> str: + return { + "voucher_matched": "Voucher", + "voucher_recheck": "Recheck", + "voucher_unmatched": "Unmatched", + "voucher_excepted": "Excepted", + }.get(status_key, status_key) + + +def combine( + key: tuple[int, str, str], + status_key: str, + left: dict[str, Any] | None, + right: dict[str, Any] | None, + matched: bool, +) -> dict[str, Any]: + row = blank() + left = left or {} + right = right or {} + row.update( + { + "fiscal_year": key[0], + "status_label": label_for_status(status_key), + "ledger_date": key[1] if left else "", + "ledger_date_key": f"{key[0]}-{key[1]}" if left else "", + "proof_date": alloc.clean(right.get("proof_date")), + "voucher_no": key[2] if left else "", + "draft_no": alloc.clean(right.get("draft_no")), + "ledger_account_name": alloc.clean(left.get("ledger_account_name")), + "voucher_account_name": alloc.clean(right.get("voucher_account_name")), + "ledger_vendor": alloc.clean(left.get("ledger_vendor")), + "voucher_vendor": alloc.clean(right.get("voucher_vendor")), + "ledger_debit": alloc.money(left.get("ledger_debit")), + "ledger_credit": alloc.money(left.get("ledger_credit")), + "voucher_debit": alloc.money(right.get("voucher_debit")), + "voucher_credit": alloc.money(right.get("voucher_credit")), + "ledger_desc": alloc.clean(left.get("ledger_desc")), + "voucher_desc": alloc.clean(right.get("voucher_desc")), + "review_reason": "ROW_ALLOCATOR_DISPLAY_ONLY", + "matched_case": "ROW_ALLOCATED_MATCH" if matched else ("WEHAGO_CONTEXT_ONLY" if left else "ERP_CONTEXT_ONLY"), + "ledger_row_key": alloc.clean(left.get("ledger_row_key")), + "voucher_row_key": alloc.clean(right.get("voucher_row_key")), + } + ) + if matched: + row["match_identity_key"] = f"{row['ledger_row_key']}|{row['voucher_row_key']}" + return row + + +def draft_bases_from_rows(rows: list[dict[str, Any]]) -> list[str]: + return sorted({alloc.draft_base(row.get("draft_no")) for row in rows if alloc.clean(row.get("draft_no"))}) + + +def amount_total(row: dict[str, Any], prefix: str) -> float: + if prefix == "ledger": + return abs(alloc.money(row.get("ledger_debit")) + alloc.money(row.get("ledger_credit"))) + return abs(alloc.money(row.get("voucher_debit")) + alloc.money(row.get("voucher_credit"))) + + +def rebuild_display_rows( + key: tuple[int, str, str], + status_key: str, + raw_left: list[dict[str, Any]], + right_rows: list[dict[str, Any]], +) -> tuple[list[dict[str, Any]], int]: + pairs, left_unmatched, right_unmatched = alloc.allocate_rows(raw_left, right_rows) + pair_map_l = {i: (j, score) for i, j, score in pairs} + result: list[dict[str, Any]] = [] + for i, left in enumerate(raw_left): + if i in pair_map_l: + j, _score = pair_map_l[i] + result.append(combine(key, status_key, left, right_rows[j], True)) + else: + result.append(combine(key, status_key, left, None, False)) + for j in right_unmatched: + result.append(combine(key, status_key, None, right_rows[j], False)) + # Keep unmatched right rows in ERP row-number order, not string order. + result.sort( + key=lambda row: ( + 0 if row.get("ledger_row_key") else 1, + alloc.clean(row.get("ledger_date")), + alloc.clean(row.get("voucher_no")), + amount_total(row, "ledger") == 0, + alloc.draft_suffix(row.get("draft_no")), + alloc.clean(row.get("draft_no")), + ) + ) + return result, len(pairs) + + +def update_summary_from_rows(summary: dict[str, Any], rows: list[dict[str, Any]]) -> dict[str, Any]: + updated = dict(summary) + updated["ledger_row_count"] = sum( + 1 + for row in rows + if alloc.clean(row.get("ledger_account_name")) + or abs(alloc.money(row.get("ledger_debit"))) >= 0.5 + or abs(alloc.money(row.get("ledger_credit"))) >= 0.5 + ) + updated["voucher_row_count"] = sum( + 1 + for row in rows + if alloc.clean(row.get("draft_no")) + or alloc.clean(row.get("voucher_account_name")) + or abs(alloc.money(row.get("voucher_debit"))) >= 0.5 + or abs(alloc.money(row.get("voucher_credit"))) >= 0.5 + ) + updated["ledger_debit"] = sum(alloc.money(row.get("ledger_debit")) for row in rows) + updated["ledger_credit"] = sum(alloc.money(row.get("ledger_credit")) for row in rows) + updated["voucher_debit"] = sum(alloc.money(row.get("voucher_debit")) for row in rows) + updated["voucher_credit"] = sum(alloc.money(row.get("voucher_credit")) for row in rows) + updated["draft_no"] = ", ".join(sorted({alloc.clean(row.get("draft_no")) for row in rows if alloc.clean(row.get("draft_no"))}, key=lambda x: (alloc.draft_base(x), alloc.draft_suffix(x)))) + updated["voucher_accounts"] = ", ".join(sorted({alloc.clean(row.get("voucher_account_name")) for row in rows if alloc.clean(row.get("voucher_account_name"))})) + updated["voucher_vendors"] = ", ".join(sorted({alloc.clean(row.get("voucher_vendor")) for row in rows if alloc.clean(row.get("voucher_vendor"))})) + return updated + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("--db", default=str(DEFAULT_DB)) + parser.add_argument("--dry-run", action="store_true") + args = parser.parse_args() + + conn = sqlite3.connect(args.db) + conn.row_factory = sqlite3.Row + groups = alloc.load_groups(conn) + raw_wehago = alloc.load_raw_wehago(conn) + erp_by_base = alloc.load_erp(conn) + + changed = 0 + paired_total = 0 + for db_row in conn.execute( + """ + SELECT start_year, end_year, status_key, signature, group_index, fiscal_year, + ledger_date, voucher_no, summary_json, rows_json + FROM wehago_status_projection_groups + WHERE start_year = ? AND end_year = ? + AND status_key IN ('voucher_matched', 'voucher_recheck') + ORDER BY status_key, group_index + """, + (YEAR, YEAR), + ).fetchall(): + key = (int(db_row["fiscal_year"] or YEAR), alloc.mmdd(db_row["ledger_date"]), alloc.voucher_no(db_row["voucher_no"])) + old_rows = json.loads(db_row["rows_json"] or "[]") + bases = draft_bases_from_rows(old_rows) + if not bases: + continue + right_rows: list[dict[str, Any]] = [] + for base in bases: + right_rows.extend(erp_by_base.get(base, [])) + if not right_rows: + continue + new_rows, pair_count = rebuild_display_rows(key, db_row["status_key"], raw_wehago.get(key, []), right_rows) + paired_total += pair_count + old_payload = json.dumps(old_rows, ensure_ascii=False, separators=(",", ":")) + new_payload = json.dumps(new_rows, ensure_ascii=False, separators=(",", ":")) + if old_payload == new_payload: + continue + changed += 1 + summary = json.loads(db_row["summary_json"] or "{}") + new_summary = update_summary_from_rows(summary, new_rows) + if not args.dry_run: + conn.execute( + """ + UPDATE wehago_status_projection_groups + SET rows_json = ?, summary_json = ?, draft_no = ?, voucher_row_count = ?, + voucher_debit = ?, voucher_credit = ?, voucher_accounts = ?, + voucher_vendors = ?, updated_at = CURRENT_TIMESTAMP + WHERE start_year = ? AND end_year = ? AND status_key = ? + AND signature = ? AND group_index = ? + """, + ( + new_payload, + json.dumps(new_summary, ensure_ascii=False, separators=(",", ":")), + new_summary.get("draft_no", ""), + int(new_summary.get("voucher_row_count") or 0), + float(new_summary.get("voucher_debit") or 0), + float(new_summary.get("voucher_credit") or 0), + new_summary.get("voucher_accounts", ""), + new_summary.get("voucher_vendors", ""), + db_row["start_year"], + db_row["end_year"], + db_row["status_key"], + db_row["signature"], + db_row["group_index"], + ), + ) + if not args.dry_run: + conn.commit() + counts = dict( + conn.execute( + """ + SELECT status_key, COUNT(*) + FROM wehago_status_projection_groups + WHERE start_year = ? AND end_year = ? + GROUP BY status_key + """, + (YEAR, YEAR), + ).fetchall() + ) + print(json.dumps({"dry_run": args.dry_run, "changed_groups": changed, "allocated_pairs": paired_total, "counts": counts}, ensure_ascii=False, indent=2)) + + +if __name__ == "__main__": + main() diff --git a/scripts/build_wehago_voucher_shadow_diagnostic.py b/scripts/build_wehago_voucher_shadow_diagnostic.py new file mode 100644 index 0000000..9ed82ae --- /dev/null +++ b/scripts/build_wehago_voucher_shadow_diagnostic.py @@ -0,0 +1,457 @@ +from __future__ import annotations + +import argparse +import json +import re +import sqlite3 +import sys +from collections import Counter, defaultdict +from datetime import datetime +from pathlib import Path +from typing import Any + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + +from runtime_config import DB_PATH + + +SHADOW_VERSION = "voucher-shadow-v1-wehago-anchor-diagnostic" +FINAL_STATUSES = ("voucher_matched", "voucher_recheck", "voucher_unmatched", "voucher_excepted") + + +def clean(value: Any) -> str: + return "" if value is None else str(value).strip() + + +def amount(value: Any) -> float: + try: + return float(value or 0) + except (TypeError, ValueError): + return 0.0 + + +def erp_base(value: Any) -> str: + text = clean(value) + return re.sub(r"-\d+$", "", text) if text else "" + + +def has_ledger(row: sqlite3.Row | dict[str, Any]) -> bool: + return bool( + clean(row["ledger_account_name"]) + or clean(row["ledger_desc"]) + or abs(amount(row["ledger_debit"])) >= 0.5 + or abs(amount(row["ledger_credit"])) >= 0.5 + ) + + +def has_erp(row: sqlite3.Row | dict[str, Any]) -> bool: + return bool( + clean(row["voucher_account_name"]) + or clean(row["voucher_desc"]) + or abs(amount(row["voucher_debit"])) >= 0.5 + or abs(amount(row["voucher_credit"])) >= 0.5 + ) + + +def latest_signature(conn: sqlite3.Connection, start_year: int, end_year: int, status: str) -> str: + row = conn.execute( + """ + SELECT signature + FROM wehago_compare_query_groups + WHERE start_year = ? + AND end_year = ? + AND status_key = ? + GROUP BY signature + ORDER BY MAX(updated_at) DESC + LIMIT 1 + """, + (start_year, end_year, status), + ).fetchone() + return clean(row[0]) if row else "" + + +def status_invariants( + conn: sqlite3.Connection, + start_year: int, + end_year: int, + signatures: dict[str, str], +) -> dict[str, Any]: + result: dict[str, Any] = {} + for status in ("voucher_unmatched", "voucher_excepted"): + signature = signatures.get(status, "") + if not signature: + result[status] = {"missing_projection": True} + continue + mixed = conn.execute( + """ + WITH per_group AS ( + SELECT group_index, + COUNT(DISTINCT CASE + WHEN COALESCE(ledger_account_name, '') <> '' + OR ABS(COALESCE(ledger_debit, 0)) >= 0.5 + OR ABS(COALESCE(ledger_credit, 0)) >= 0.5 + THEN COALESCE(ledger_date, '') || '|' || COALESCE(voucher_no, '') + END) AS wehago_keys + FROM wehago_compare_query_rows + WHERE start_year = ? + AND end_year = ? + AND status_key = ? + AND signature = ? + GROUP BY group_index + ) + SELECT COUNT(*) FROM per_group WHERE wehago_keys > 1 + """, + (start_year, end_year, status, signature), + ).fetchone()[0] + zero_direct = conn.execute( + """ + SELECT COUNT(*) + FROM wehago_compare_query_rows + WHERE start_year = ? + AND end_year = ? + AND status_key = ? + AND signature = ? + AND matched_case = 'DIRECT_MATCH_CANDIDATE' + AND ( + MAX(ABS(ledger_debit), ABS(ledger_credit)) < 0.5 + OR MAX(ABS(voucher_debit), ABS(voucher_credit)) < 0.5 + ) + """, + (start_year, end_year, status, signature), + ).fetchone()[0] + result[status] = { + "signature": signature, + "mixed_wehago_groups": int(mixed or 0), + "zero_or_one_sided_direct_rows": int(zero_direct or 0), + "classification_changed": False, + } + + source_anchors: dict[tuple[int, str, str], set[str]] = defaultdict(set) + for status in FINAL_STATUSES: + signature = signatures.get(status, "") + if not signature: + continue + for row in conn.execute( + """ + SELECT fiscal_year, ledger_date, voucher_no + FROM wehago_compare_query_groups + WHERE start_year = ? + AND end_year = ? + AND status_key = ? + AND signature = ? + AND COALESCE(voucher_no, '') <> '' + """, + (start_year, end_year, status, signature), + ): + source_anchors[(int(row[0] or 0), clean(row[1]), clean(row[2]))].add(status) + source_overlaps = { + "|".join(map(str, key)): sorted(statuses) + for key, statuses in source_anchors.items() + if len(statuses) > 1 + } + final_signature_row = conn.execute( + """ + SELECT signature + FROM wehago_compare_final_status_projection + WHERE start_year = ? + AND end_year = ? + GROUP BY signature + ORDER BY MAX(updated_at) DESC + LIMIT 1 + """, + (start_year, end_year), + ).fetchone() + final_signature = clean(final_signature_row[0]) if final_signature_row else "" + final_duplicates = [] + if final_signature: + final_duplicates = conn.execute( + """ + SELECT fiscal_year, ledger_date, voucher_no, COUNT(DISTINCT final_status) AS status_count + FROM wehago_compare_final_status_projection + WHERE start_year = ? + AND end_year = ? + AND signature = ? + GROUP BY fiscal_year, ledger_date, voucher_no + HAVING status_count > 1 + LIMIT 20 + """, + (start_year, end_year, final_signature), + ).fetchall() + result["cross_status"] = { + "final_signature": final_signature, + "duplicate_final_status_vouchers": len(final_duplicates), + "final_duplicate_samples": [tuple(row) for row in final_duplicates], + "source_projection_overlap_count": len(source_overlaps), + "source_projection_overlap_note": "후보 source projection 간 중첩이며 final status 중복과는 다릅니다.", + "source_overlap_samples": dict(list(source_overlaps.items())[:20]), + } + return result + + +def build_shadow( + conn: sqlite3.Connection, + start_year: int, + end_year: int, + signature: str, +) -> tuple[list[dict[str, Any]], dict[str, Any]]: + group_rows = conn.execute( + """ + SELECT * + FROM wehago_compare_query_groups + WHERE start_year = ? + AND end_year = ? + AND status_key = 'voucher_matched' + AND signature = ? + ORDER BY group_index + """, + (start_year, end_year, signature), + ).fetchall() + rows_by_group: dict[int, list[sqlite3.Row]] = defaultdict(list) + for row in conn.execute( + """ + SELECT * + FROM wehago_compare_query_rows + WHERE start_year = ? + AND end_year = ? + AND status_key = 'voucher_matched' + AND signature = ? + ORDER BY group_index, row_index + """, + (start_year, end_year, signature), + ): + rows_by_group[int(row["group_index"])] .append(row) + + shadow_groups: list[dict[str, Any]] = [] + classifications: Counter[str] = Counter() + mixed_source_groups = 0 + for group in group_rows: + source_index = int(group["group_index"]) + rows = rows_by_group.get(source_index, []) + anchors = sorted( + { + (int(row["fiscal_year"] or 0), clean(row["ledger_date"]), clean(row["voucher_no"])) + for row in rows + if has_ledger(row) + } + ) + if len(anchors) <= 1: + continue + mixed_source_groups += 1 + erp_bases = {erp_base(row["draft_no"]) for row in rows if has_erp(row) and erp_base(row["draft_no"])} + direct_rows = [ + row + for row in rows + if has_ledger(row) + and has_erp(row) + and max(abs(amount(row["ledger_debit"])), abs(amount(row["ledger_credit"]))) >= 0.5 + and max(abs(amount(row["voucher_debit"])), abs(amount(row["voucher_credit"]))) >= 0.5 + ] + exact_direct = sum( + 1 + for row in direct_rows + if abs( + max(abs(amount(row["ledger_debit"])), abs(amount(row["ledger_credit"]))) + - max(abs(amount(row["voucher_debit"])), abs(amount(row["voucher_credit"]))) + ) + < 0.5 + ) + ledger_debit = sum(amount(row["ledger_debit"]) for row in rows if has_ledger(row)) + ledger_credit = sum(amount(row["ledger_credit"]) for row in rows if has_ledger(row)) + voucher_debit = sum(amount(row["voucher_debit"]) for row in rows if has_erp(row)) + voucher_credit = sum(amount(row["voucher_credit"]) for row in rows if has_erp(row)) + balanced = abs(ledger_debit - voucher_debit) < 0.5 and abs(ledger_credit - voucher_credit) < 0.5 + ledger_nonzero = sum( + 1 + for row in rows + if has_ledger(row) + and max(abs(amount(row["ledger_debit"])), abs(amount(row["ledger_credit"]))) >= 0.5 + ) + if len(erp_bases) == 1 and balanced: + classification = "normal_structural_n_to_one_or_one_to_n" + elif ledger_nonzero and exact_direct >= max(1, int(ledger_nonzero * 0.8)): + classification = "display_only_grouping_issue" + else: + classification = "likely_mismatch_requires_review" + classifications[classification] += 1 + erp_context_rows = sum(1 for row in rows if has_erp(row) and not has_ledger(row)) + for anchor in anchors: + anchor_rows = [ + row + for row in rows + if has_ledger(row) + and (int(row["fiscal_year"] or 0), clean(row["ledger_date"]), clean(row["voucher_no"])) == anchor + ] + shadow_groups.append( + { + "source_group_index": source_index, + "fiscal_year": anchor[0], + "ledger_date": anchor[1], + "voucher_no": anchor[2], + "draft_bases": sorted(erp_bases), + "classification": classification, + "source_anchor_count": len(anchors), + "ledger_row_count": len(anchor_rows), + "erp_context_row_count": erp_context_rows, + "source_review_reason": clean(group["review_reason"]), + } + ) + summary = { + "source_groups": len(group_rows), + "mixed_source_groups": mixed_source_groups, + "shadow_wehago_groups": len(shadow_groups), + "classifications": dict(classifications), + "active_voucher_judgement_changed": False, + } + return shadow_groups, summary + + +def store_shadow( + conn: sqlite3.Connection, + start_year: int, + end_year: int, + source_signature: str, + groups: list[dict[str, Any]], + summary: dict[str, Any], +) -> int: + conn.execute( + """ + CREATE TABLE IF NOT EXISTS wehago_voucher_shadow_runs ( + shadow_version TEXT NOT NULL, + start_year INTEGER NOT NULL, + end_year INTEGER NOT NULL, + source_signature TEXT NOT NULL, + summary_json TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (shadow_version, start_year, end_year, source_signature) + ) + """ + ) + conn.execute( + """ + CREATE TABLE IF NOT EXISTS wehago_voucher_shadow_groups ( + shadow_version TEXT NOT NULL, + start_year INTEGER NOT NULL, + end_year INTEGER NOT NULL, + source_signature TEXT NOT NULL, + shadow_group_index INTEGER NOT NULL, + source_group_index INTEGER NOT NULL, + fiscal_year INTEGER NOT NULL, + ledger_date TEXT NOT NULL, + voucher_no TEXT NOT NULL, + draft_no TEXT NOT NULL, + classification TEXT NOT NULL, + payload_json TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY ( + shadow_version, start_year, end_year, source_signature, shadow_group_index + ) + ) + """ + ) + conn.execute( + """ + INSERT INTO wehago_voucher_shadow_runs ( + shadow_version, start_year, end_year, source_signature, summary_json, created_at + ) VALUES (?, ?, ?, ?, ?, CURRENT_TIMESTAMP) + ON CONFLICT(shadow_version, start_year, end_year, source_signature) DO UPDATE SET + summary_json = excluded.summary_json, + created_at = CURRENT_TIMESTAMP + """, + (SHADOW_VERSION, start_year, end_year, source_signature, json.dumps(summary, ensure_ascii=False)), + ) + conn.execute( + """ + DELETE FROM wehago_voucher_shadow_groups + WHERE shadow_version = ? + AND start_year = ? + AND end_year = ? + AND source_signature = ? + """, + (SHADOW_VERSION, start_year, end_year, source_signature), + ) + conn.executemany( + """ + INSERT INTO wehago_voucher_shadow_groups ( + shadow_version, start_year, end_year, source_signature, + shadow_group_index, source_group_index, fiscal_year, ledger_date, + voucher_no, draft_no, classification, payload_json, created_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP) + """, + [ + ( + SHADOW_VERSION, + start_year, + end_year, + source_signature, + index, + int(group["source_group_index"]), + int(group["fiscal_year"]), + clean(group["ledger_date"]), + clean(group["voucher_no"]), + ", ".join(group["draft_bases"]), + clean(group["classification"]), + json.dumps(group, ensure_ascii=False), + ) + for index, group in enumerate(groups) + ], + ) + return len(groups) + + +def main() -> None: + parser = argparse.ArgumentParser(description="Build a non-active Voucher shadow projection and invariant report.") + parser.add_argument("--start-year", type=int, default=2025) + parser.add_argument("--end-year", type=int, default=2025) + parser.add_argument("--output", default="") + args = parser.parse_args() + + conn = sqlite3.connect(DB_PATH) + conn.row_factory = sqlite3.Row + signatures = { + status: latest_signature(conn, args.start_year, args.end_year, status) + for status in FINAL_STATUSES + } + voucher_signature = signatures["voucher_matched"] + if not voucher_signature: + raise SystemExit("No voucher_matched query projection found.") + invariants = status_invariants(conn, args.start_year, args.end_year, signatures) + shadow_groups, shadow_summary = build_shadow( + conn, + args.start_year, + args.end_year, + voucher_signature, + ) + stored = store_shadow( + conn, + args.start_year, + args.end_year, + voucher_signature, + shadow_groups, + shadow_summary, + ) + conn.commit() + + payload = { + "generated_at": datetime.now().isoformat(timespec="seconds"), + "db": str(DB_PATH), + "shadow_version": SHADOW_VERSION, + "start_year": args.start_year, + "end_year": args.end_year, + "source_signatures": signatures, + "invariants": invariants, + "shadow_summary": shadow_summary, + "stored_shadow_groups": stored, + "samples": shadow_groups[:50], + } + output = Path(args.output) if args.output else ( + Path(__file__).resolve().parents[1] + / "reports" + / f"wehago_voucher_shadow_diagnostic_{args.start_year}_{args.end_year}_{datetime.now().strftime('%Y%m%d_%H%M%S')}.json" + ) + output.parent.mkdir(parents=True, exist_ok=True) + output.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8") + print(json.dumps({"output": str(output), **shadow_summary, "stored_shadow_groups": stored}, ensure_ascii=False)) + + +if __name__ == "__main__": + main() diff --git a/scripts/click_wehago_query_9225.py b/scripts/click_wehago_query_9225.py new file mode 100644 index 0000000..52095f3 --- /dev/null +++ b/scripts/click_wehago_query_9225.py @@ -0,0 +1,37 @@ +from __future__ import annotations + +import os +import time + +from selenium import webdriver +from selenium.webdriver.chrome.options import Options +from selenium.webdriver.common.keys import Keys + + +options = Options() +options.add_experimental_option("debuggerAddress", os.environ.get("WEHAGO_CHROME_DEBUGGER_ADDRESS", "127.0.0.1:9225")) +driver = webdriver.Chrome(options=options) + +try: + webdriver.ActionChains(driver).send_keys(Keys.ESCAPE).perform() + clicked = driver.execute_script( + """ + const width = window.innerWidth; + const candidates = Array.from(document.querySelectorAll('button, a, div, span')) + .filter(el => (el.textContent || '').trim() === '조회') + .filter(el => { + const r = el.getBoundingClientRect(); + const style = getComputedStyle(el); + return r.width > 0 && r.height > 0 && r.top >= 70 && r.top <= 240 + && r.left > width * 0.6 && style.display !== 'none' && style.visibility !== 'hidden'; + }) + .sort((a, b) => b.getBoundingClientRect().left - a.getBoundingClientRect().left); + if (!candidates.length) return false; + candidates[0].click(); + return true; + """ + ) + print(f"query_clicked={bool(clicked)}", flush=True) + time.sleep(4) +finally: + driver.quit() diff --git a/scripts/create_erp_tree_report_image.py b/scripts/create_erp_tree_report_image.py new file mode 100644 index 0000000..3a87dd5 --- /dev/null +++ b/scripts/create_erp_tree_report_image.py @@ -0,0 +1,371 @@ +from __future__ import annotations + +from dataclasses import dataclass, field +from html import escape +from pathlib import Path + +from openpyxl import load_workbook + + +ROOT = Path(__file__).resolve().parents[1] +XLSX = ROOT / "한맥기술ERP구조_일부_260608.xlsx" +OUT_HTML = ROOT / "reports" / "hanmac_erp_tree_260608.html" +OUT_PNG = ROOT / "reports" / "hanmac_erp_tree_260608.png" + + +def normalize_name(name: str) -> str: + return "".join(name.split()) + + +MARK_NAMES = { + normalize_name(name) + for name in { + "입찰공고등록(일반경쟁)", + "입찰진행현황", + "계약정보등록", + "도급대장등록", + "변경계약등록", + "수금등록", + "가전표등록", + "과업수행계획서작성", + "실행계획서작성", + "외주기성검사조서확정(전표처리)", + "거래처등록", + "외주계약관리", + "외주기성청구내역등록", + "외주기성검토(부서)", + "외주기성검토(관리)", + "미지급자동반제전표", + "입찰공고등록(PQ)", + "참가여부검토현황", + "엔지니어링활동주체", + "프로젝트개요관리", + "품의서작성양식", + "(변경)품의서작성양식", + "외주계약변경관리", + "과업수행계획서작성(관리)", + "사전사업등록", + } +} + + +@dataclass +class Node: + name: str + level: int + children: list["Node"] = field(default_factory=list) + is_input: bool = False + + +def is_input_leaf(name: str) -> bool: + forced_exclude = { + "프로젝트자료등록현황", + "작업일보등록현황", + "법인세할주민세안분내역서", + "법인세할주민세사업장별내역", + "상각완료CHECK", + "원가코드별세부내역", + "과업수행계획서(회계)", + "월별수금계획대비실적", + } + if name in forced_exclude: + return False + + forced_input = { + "참가여부검토현황", + "입찰진행현황", + "엔지니어링활동주체", + "임직원교육이수현황", + "업면허등록현황", + "재무제표(변경)", + "도급대장등록", + "외주기성검토(부서)", + "미지급자동반제전표", + } + if name in forced_input: + return True + + result_terms = ( + "현황", + "조회", + "분석", + "집계", + "명세", + "원장", + "재무제표", + "보고", + "순위", + "리스트", + "일정표", + "잔액", + "대장", + "출력", + ) + mixed_input_terms = ("등록현황", "입력조회", "관리등록") + if any(term in name for term in result_terms) and not any(term in name for term in mixed_input_terms): + return False + + input_terms = ( + "등록", + "작성", + "입력", + "발행", + "확정", + "취소", + "마감", + "처리", + "변경", + "삭제", + "이체", + "계산", + "안분", + "대체", + "수정", + "CHECK", + "코드", + "마스터", + "분류", + "유형", + "산식", + "사업장", + "계획", + "관리", + ) + return any(term in name for term in input_terms) + + +def read_tree() -> Node: + workbook = load_workbook(XLSX, data_only=True) + sheet = workbook.active + root = Node("통합정보", 0) + stack: list[Node] = [root] + first = True + + for row in sheet.iter_rows(values_only=True): + cells = [str(value).strip() if value is not None else "" for value in row] + if not any(cells): + continue + level = next(idx for idx, value in enumerate(cells) if value) + name = cells[level] + if first and level == 0 and name == root.name: + first = False + continue + first = False + + node = Node(name, level) + while stack and stack[-1].level >= level: + stack.pop() + stack[-1].children.append(node) + stack.append(node) + + mark_input_leaves(root) + return prune_to_input(root) + + +def mark_input_leaves(node: Node) -> bool: + if not node.children: + node.is_input = is_input_leaf(node.name) + return node.is_input + found = False + for child in node.children: + found = mark_input_leaves(child) or found + return found + + +def prune_to_input(node: Node) -> Node: + pruned = Node(node.name, node.level, is_input=node.is_input) + for child in node.children: + if has_input(child): + pruned.children.append(prune_to_input(child)) + return pruned + + +def has_input(node: Node) -> bool: + if not node.children: + return node.is_input + return any(has_input(child) for child in node.children) + + +def count_nodes(node: Node) -> int: + return 1 + sum(count_nodes(child) for child in node.children) + + +def render_node(node: Node, root: bool = False) -> str: + name_class = "name input-item" if node.is_input else "name" + marker = '' if normalize_name(node.name) in MARK_NAMES else "" + child_html = "".join(render_node(child) for child in node.children) + root_class = " root-node" if root else "" + return f""" +
  • + {escape(node.name)}{marker} + {f'
      {child_html}
    ' if child_html else ''} +
  • + """ + + +def build_html(root: Node) -> str: + columns = [[], [], []] + for child in root.children: + if child.name == "프로젝트": + columns[1].append(child) + elif child.name == "회계자금(회계)": + columns[2].append(child) + else: + columns[0].append(child) + + rendered_columns = [] + for column in columns: + rendered_columns.append( + """ +
    +
      + """ + + "".join(render_node(node, root=True) for node in column) + + """ +
    +
    + """ + ) + + return f""" + + + + + + +
    +
    + {"".join(rendered_columns)} +
    +
    + +""" + + +def main() -> None: + root = read_tree() + OUT_HTML.parent.mkdir(parents=True, exist_ok=True) + OUT_HTML.write_text(build_html(root), encoding="utf-8") + print(OUT_HTML) + print(OUT_PNG) + print(f"rendered nodes: {count_nodes(root)}") + + +if __name__ == "__main__": + main() diff --git a/scripts/create_voucher_card_analysis_reports.py b/scripts/create_voucher_card_analysis_reports.py new file mode 100644 index 0000000..9f11b42 --- /dev/null +++ b/scripts/create_voucher_card_analysis_reports.py @@ -0,0 +1,465 @@ +from __future__ import annotations + +import logging +import sys +from collections import Counter +from datetime import datetime +from pathlib import Path +from typing import Any, Iterable + +from openpyxl import Workbook +from openpyxl.cell import WriteOnlyCell +from openpyxl.styles import Alignment, Font, PatternFill + +BASE_DIR = Path(__file__).resolve().parent.parent +if str(BASE_DIR) not in sys.path: + sys.path.insert(0, str(BASE_DIR)) + +from main import engine +from sqlalchemy import text + +from wehago_compare import ( + _get_fast_year_export_row_cache_signature, + _load_hanmac_unconnected_source_groups, + get_status_detail_rows, +) + +START_YEAR = 2022 +END_YEAR = 2026 +SAMPLE_GROUP_LIMIT = 500 +OUTPUT_DIR = BASE_DIR / "static" / "exports" + +HEADER_FILL = PatternFill(fill_type="solid", fgColor="1F4E78") +SECTION_FILL = PatternFill(fill_type="solid", fgColor="D9EAF7") +ALERT_FILL = PatternFill(fill_type="solid", fgColor="FFF2CC") +HEADER_FONT = Font(color="FFFFFF", bold=True) +BOLD_FONT = Font(bold=True) + + +def clean(value: Any) -> str: + return "" if value is None else str(value).strip() + + +def number(value: Any) -> float: + try: + return float(value or 0) + except (TypeError, ValueError): + return 0.0 + + +def write_row(ws: Any, values: Iterable[Any], *, header: bool = False, section: bool = False, alert: bool = False) -> None: + cells: list[WriteOnlyCell] = [] + for value in values: + cell = WriteOnlyCell(ws, value=value) + cell.alignment = Alignment(vertical="top", wrap_text=True) + if header: + cell.fill = HEADER_FILL + cell.font = HEADER_FONT + cell.alignment = Alignment(horizontal="center", vertical="center", wrap_text=True) + elif section: + cell.fill = SECTION_FILL + cell.font = BOLD_FONT + elif alert: + cell.fill = ALERT_FILL + cells.append(cell) + ws.append(cells) + + +def configure_sheet(ws: Any, widths: list[float], freeze: str = "A2") -> None: + ws.freeze_panes = freeze + ws.sheet_view.showGridLines = False + for index, width in enumerate(widths, start=1): + ws.column_dimensions[chr(64 + index) if index <= 26 else "A"].width = width + + +def get_page(status: str, start_year: int, end_year: int, limit: int) -> dict[str, Any]: + logging.disable(logging.CRITICAL) + return get_status_detail_rows( + engine, + start_year=start_year, + end_year=end_year, + status=status, + offset=0, + limit=limit, + ) + + +def get_year_counts(status: str) -> dict[int, int]: + result: dict[int, int] = {} + with engine.connect() as conn: + for year in range(START_YEAR, END_YEAR + 1): + signature = _get_fast_year_export_row_cache_signature(conn, year) + if not signature: + result[year] = 0 + continue + result[year] = int( + conn.execute( + text( + """ + SELECT COUNT(*) + FROM wehago_compare_export_row_cache + WHERE fiscal_year = :year + AND status_key = :status + AND snapshot_signature = :signature + AND row_sort = 0 + """ + ), + {"year": year, "status": status, "signature": signature}, + ).scalar_one() + or 0 + ) + return result + + +def split_reasons(value: Any) -> list[str]: + text = clean(value) + if not text: + return ["검증근거 없음"] + normalized = text.replace(" / ", "|").replace("/", "|") + return [part.strip() for part in normalized.split("|") if part.strip()] or ["검증근거 없음"] + + +def group_summary_row(group: dict[str, Any], index: int) -> list[Any]: + summary = dict(group.get("summary") or {}) + rows = list(group.get("rows") or []) + status_counts = Counter(clean(row.get("status_label")) or "빈 상태" for row in rows) + return [ + index, + summary.get("fiscal_year"), + clean(summary.get("status_label")), + clean(summary.get("ledger_date")), + clean(summary.get("proof_date")), + clean(summary.get("voucher_no")), + clean(summary.get("draft_no")), + int(summary.get("ledger_row_count") or 0), + int(summary.get("voucher_row_count") or 0), + number(summary.get("ledger_debit")), + number(summary.get("ledger_credit")), + number(summary.get("voucher_debit")), + number(summary.get("voucher_credit")), + clean(summary.get("ledger_accounts")), + clean(summary.get("voucher_accounts")), + clean(summary.get("ledger_vendors")), + clean(summary.get("voucher_vendors")), + clean(summary.get("review_reason")), + ", ".join(f"{key} {value}" for key, value in status_counts.most_common()), + ] + + +def detail_row(group_index: int, row_index: int, row: dict[str, Any]) -> list[Any]: + return [ + group_index, + row_index, + row.get("fiscal_year"), + clean(row.get("status_label")), + clean(row.get("ledger_date")), + clean(row.get("proof_date")), + clean(row.get("voucher_no")), + clean(row.get("draft_no")), + clean(row.get("ledger_account_name")), + clean(row.get("voucher_account_name")), + clean(row.get("ledger_vendor")), + clean(row.get("voucher_vendor")), + number(row.get("ledger_debit")), + number(row.get("ledger_credit")), + number(row.get("voucher_debit")), + number(row.get("voucher_credit")), + clean(row.get("ledger_desc")), + clean(row.get("voucher_desc")), + clean(row.get("review_reason")), + clean(row.get("matched_case")), + ] + + +def add_summary_sheet( + wb: Workbook, + *, + title: str, + total_count: int, + sample_groups: list[dict[str, Any]], + year_counts: dict[int, int], + definition: str, + decision_guide: list[tuple[str, str]], + caveat: str, +) -> None: + ws = wb.create_sheet("1_핵심요약") + configure_sheet(ws, [28, 22, 80]) + write_row(ws, [title, "", ""], section=True) + write_row(ws, ["항목", "값", "업무 해석"], header=True) + write_row(ws, ["분석 범위", f"{START_YEAR}~{END_YEAR}", "전표비교 페이지 기본 전체 기간"]) + write_row(ws, ["카드/표 기준 그룹 수", total_count, definition]) + write_row(ws, ["보고서 상세 표본 그룹 수", len(sample_groups), f"화면 상세 조회 정렬 기준 상위 {len(sample_groups):,}그룹"]) + write_row(ws, ["보고서 생성시각", datetime.now().strftime("%Y-%m-%d %H:%M:%S"), "현재 data.db와 화면 상세 로직 기준"]) + write_row(ws, ["주의사항", caveat, "카드 수와 펼친 행 수를 직접 비교하지 않아야 합니다."], alert=True) + write_row(ws, []) + write_row(ws, ["판단 질문", "확인 위치", "판단 방법"], header=True) + for question, guide in decision_guide: + write_row(ws, [question, guide, guide]) + write_row(ws, []) + write_row(ws, ["연도", "연도별 export 캐시 그룹 수", "전체 기준 수치 대비 비중"], header=True) + for year, count in year_counts.items(): + write_row(ws, [year, count, count / total_count if total_count else 0]) + + +def add_logic_sheet(wb: Workbook, rows: list[tuple[str, str, str]]) -> None: + ws = wb.create_sheet("2_집계기준_로직") + configure_sheet(ws, [28, 42, 100]) + write_row(ws, ["구분", "판정/집계 기준", "설명"], header=True) + for row in rows: + write_row(ws, row) + + +def add_reason_sheet(wb: Workbook, groups: list[dict[str, Any]], *, sample_label: str) -> None: + reason_counts: Counter[str] = Counter() + status_counts: Counter[str] = Counter() + account_counts: Counter[str] = Counter() + vendor_counts: Counter[str] = Counter() + for group in groups: + summary = dict(group.get("summary") or {}) + for reason in split_reasons(summary.get("review_reason")): + reason_counts[reason] += 1 + for row in group.get("rows") or []: + status_counts[clean(row.get("status_label")) or "빈 상태"] += 1 + account = clean(row.get("ledger_account_name")) or clean(row.get("voucher_account_name")) or "계정 없음" + vendor = clean(row.get("ledger_vendor")) or clean(row.get("voucher_vendor")) or "거래처 없음" + account_counts[account] += 1 + vendor_counts[vendor] += 1 + + ws = wb.create_sheet("3_주요원인") + configure_sheet(ws, [32, 18, 80]) + write_row(ws, [sample_label, "", ""], section=True) + write_row(ws, ["검증근거/원인", "그룹 수", "해석"], header=True) + for reason, count in reason_counts.most_common(50): + write_row(ws, [reason, count, "그룹 summary.review_reason 기준"]) + write_row(ws, []) + write_row(ws, ["행 상태", "행 수", "해석"], header=True) + for status, count in status_counts.most_common(): + write_row(ws, [status, count, "그룹 내부 행 상태"]) + write_row(ws, []) + write_row(ws, ["상위 계정", "행 수", "해석"], header=True) + for account, count in account_counts.most_common(30): + write_row(ws, [account, count, "WEHAGO 계정 우선, 없으면 Hanmac 계정"]) + write_row(ws, []) + write_row(ws, ["상위 거래처", "행 수", "해석"], header=True) + for vendor, count in vendor_counts.most_common(30): + write_row(ws, [vendor, count, "WEHAGO 거래처 우선, 없으면 Hanmac 거래처"]) + + +def add_group_and_detail_sheets(wb: Workbook, groups: list[dict[str, Any]]) -> None: + ws = wb.create_sheet("4_전표그룹상세") + write_row( + ws, + [ + "그룹순번", "연도", "그룹상태", "WEHAGO 일자", "Hanmac 증빙일", "전표번호", "가전표번호", + "WEHAGO 행수", "Hanmac 행수", "WEHAGO 차변", "WEHAGO 대변", "Hanmac 차변", "Hanmac 대변", + "WEHAGO 계정", "Hanmac 계정", "WEHAGO 거래처", "Hanmac 거래처", "검증근거", "그룹 내부 행상태", + ], + header=True, + ) + for index, group in enumerate(groups, start=1): + write_row(ws, group_summary_row(group, index)) + ws.freeze_panes = "A2" + ws.sheet_view.showGridLines = False + + ws = wb.create_sheet("5_행상세") + write_row( + ws, + [ + "그룹순번", "행순번", "연도", "행상태", "WEHAGO 일자", "Hanmac 증빙일", "전표번호", "가전표번호", + "WEHAGO 계정", "Hanmac 계정", "WEHAGO 거래처", "Hanmac 거래처", + "WEHAGO 차변", "WEHAGO 대변", "Hanmac 차변", "Hanmac 대변", + "WEHAGO 적요", "Hanmac 적요", "검증근거", "매칭유형", + ], + header=True, + ) + for group_index, group in enumerate(groups, start=1): + for row_index, row in enumerate(group.get("rows") or [], start=1): + write_row(ws, detail_row(group_index, row_index, dict(row))) + ws.freeze_panes = "A2" + ws.sheet_view.showGridLines = False + + +def build_report( + *, + status: str, + file_stem: str, + title: str, + definition: str, + caveat: str, + logic_rows: list[tuple[str, str, str]], + decision_guide: list[tuple[str, str]], + full_hanmac: bool = False, +) -> Path: + if full_hanmac: + with engine.connect() as conn: + groups = _load_hanmac_unconnected_source_groups(conn, START_YEAR, END_YEAR) + total_count = len(groups) + year_counts = Counter(int(group["summary"].get("fiscal_year") or 0) for group in groups) + sample_label = "전체 Hanmac unconnected 그룹 기준" + else: + page = get_page(status, START_YEAR, END_YEAR, SAMPLE_GROUP_LIMIT) + groups = list(page.get("groups") or []) + total_count = int(page.get("total_count") or 0) + year_counts = get_year_counts(status) + sample_label = f"현재 화면 상세 상위 {len(groups):,}그룹 표본 기준" + + wb = Workbook(write_only=True) + add_summary_sheet( + wb, + title=title, + total_count=total_count, + sample_groups=groups, + year_counts=dict(sorted(year_counts.items())), + definition=definition, + decision_guide=decision_guide, + caveat=caveat, + ) + add_logic_sheet(wb, logic_rows) + add_reason_sheet(wb, groups, sample_label=sample_label) + add_group_and_detail_sheets(wb, groups) + OUTPUT_DIR.mkdir(parents=True, exist_ok=True) + path = OUTPUT_DIR / f"{file_stem}_{START_YEAR}_{END_YEAR}_{datetime.now().strftime('%Y%m%d_%H%M%S')}.xlsx" + wb.save(path) + return path + + +def write_improvement_proposal(paths: list[Path], totals: dict[str, int]) -> Path: + report_path = OUTPUT_DIR / f"voucher_card_count_improvement_{START_YEAR}_{END_YEAR}_{datetime.now().strftime('%Y%m%d_%H%M%S')}.md" + content = f"""# 전표비교 카드/표 수치 개선 제안 + +생성일: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')} +분석 범위: {START_YEAR}~{END_YEAR} + +## 확인된 기준 수치 + +| 카드 | 현재 화면 상세와 동일한 그룹 기준 수치 | 표에서 펼쳐지는 값 | +|---|---:|---| +| WEHAGO Voucher | {totals['voucher_matched']:,} 그룹 | 그룹 내부의 Matched/Unmatched/ERP Unmatched/Recheck 행 | +| WEHAGO Unmatched | {totals['voucher_unmatched']:,} 그룹 | 그룹 내부 WEHAGO 행 | +| Hanmac unconnected | {totals['hanmac_unconnected']:,} 그룹 | {totals['hanmac_unconnected_rows']:,} Hanmac 원천 행 | + +## 어떤 값이 맞는가 + +카드의 업무 의미가 "전표 건수"라면 **그룹 수가 기준값**입니다. 표의 행 수는 한 전표 안의 계정 라인 수이므로 카드와 같아질 필요가 없습니다. + +다만 현재 구조는 카드 집계, 상세 조회, 엑셀 export가 서로 다른 캐시와 signature를 선택할 수 있습니다. 이 경우 동일 기간·동일 상태인데도 그룹 수 자체가 달라질 수 있으며, 이는 표시 문제가 아니라 데이터 버전 불일치입니다. + +## 재현된 그룹 수 불일치 + +| 상태 | 현재 상세 조회 그룹 수 | 연도별 active export 캐시 합계 | 차이 | +|---|---:|---:|---:| +| WEHAGO Voucher | 48,195 | 50,276 | +2,081 | +| WEHAGO Unmatched | 18,187 | 3,586 | -14,601 | + +연도별 active export 캐시는 2022년 값이 없고, 최신 상세 로직과 다른 signature를 사용합니다. 따라서 현재 업무 판단 기준은 상세 API `total_count`와 같은 active range projection 그룹 수로 통일하는 것이 맞습니다. + +## 불일치 원인 + +1. 카드 수는 metric/query projection 또는 range cache를 우선 사용합니다. +2. 상세 표는 현재 상세 로직 signature로 그룹을 다시 구성하거나 다른 projection을 사용합니다. +3. 엑셀은 연도별 export row cache의 snapshot signature를 사용합니다. +4. snapshot 상태가 failed/queued/running인 연도가 섞여 있어 최신 로직 반영 시점이 동일하지 않습니다. +5. Hanmac unconnected는 원천행을 가전표 기본번호 단위로 다시 묶으므로 원천 행 수와 그룹 수가 크게 다릅니다. +6. 연도 단독 상세 조회 경로 `_fast_sqlite_query_group_page`에서 `active_signature` 미정의 오류가 재현되어, 기간에 따라 상세 집계가 실패하거나 다른 fallback을 탈 수 있습니다. + +## 권고 개선안 + +1. **단일 기준 projection 적용**: 카드, 상세 표, 엑셀 모두 `(start_year, end_year, status_key, active_signature)` 하나를 사용합니다. +2. **카드에 단위 표시**: `48,195 전표그룹`처럼 단위를 명시하고, 상세 패널에는 `전표그룹 N / 펼친 행 M`을 함께 표시합니다. +3. **signature 표시와 차단**: 카드 signature와 상세 signature가 다르면 숫자 대신 `갱신 필요`를 표시하고 상세 열기를 막습니다. +4. **원자적 캐시 교체**: 모든 상태 projection 생성이 끝난 뒤 active signature를 한 번에 교체합니다. 생성 도중 일부 상태만 새 버전을 노출하지 않습니다. +5. **수치 검증 규칙 추가**: 상세 API의 `total_count`와 카드 count가 다르면 경고 배너를 띄우고 로그에 기간·상태·양쪽 signature를 기록합니다. +6. **Hanmac 보조 수치 제공**: `unconnected 18,143 전표그룹 / 72,869 원천행`처럼 두 수치를 같이 보여줍니다. +7. **연도 상세 조회 오류 수정**: `_fast_sqlite_query_group_page`가 사용할 active signature를 조회·전달하도록 수정하고 해당 경로의 회귀 테스트를 추가합니다. + +## 제작 보고서 + +""" + "\n".join(f"- [{path.name}]({path.name})" for path in paths) + "\n" + report_path.write_text(content, encoding="utf-8") + return report_path + + +def main() -> None: + reports: list[Path] = [] + reports.append( + build_report( + status="voucher_matched", + file_stem="wehago_voucher_analysis", + title="WEHAGO Voucher 분석 보고서", + definition="WEHAGO 전표 기준 최종 Matched 그룹 수. 한 그룹 안에 일부 미매칭 행이 남을 수 있습니다.", + caveat="그룹 전체가 Matched 카드에 있더라도 내부 행상태는 Matched 외 상태를 포함할 수 있습니다.", + logic_rows=[ + ("원천", "WEHAGO 원장 + Hanmac ERP 전표", "전표/행 키, 일자, 계정, 금액, 거래처, 적요를 사용"), + ("그룹 단위", "WEHAGO 전표 identity", "연도, 전표번호, 원장일자 등을 이용해 WEHAGO 전표 그룹 구성"), + ("기본 매칭", "행 키/금액/계정/일자/텍스트", "직접 매칭 후 미사용 행은 그룹 내부 Unmatched 또는 ERP Unmatched로 유지"), + ("보정 매칭", "연도교차, VAT 일자, 그룹 비용, 2단계 비교", "검증근거와 matched_case에 보정 매칭 사유 기록"), + ("최종 카드", "최종 상태가 voucher_matched", "카드는 행 수가 아니라 WEHAGO 전표 그룹 수를 표시"), + ], + decision_guide=[ + ("왜 매칭되었나?", "3_주요원인과 5_행상세의 검증근거/매칭유형 확인"), + ("그룹 안에 미매칭이 남았나?", "4_전표그룹상세의 그룹 내부 행상태 확인"), + ("금액이 맞나?", "4_전표그룹상세의 WEHAGO/Hanmac 차변·대변 비교"), + ], + ) + ) + reports.append( + build_report( + status="voucher_unmatched", + file_stem="wehago_unmatched_analysis", + title="WEHAGO Unmatched 분석 보고서", + definition="WEHAGO 전표 기준으로 최종 연결 대상 Hanmac 전표를 찾지 못한 그룹 수.", + caveat="원천 비교결과의 ledger_only 행 수와 최종 WEHAGO Unmatched 그룹 수는 보정 로직과 그룹화 때문에 다릅니다.", + logic_rows=[ + ("원천", "WEHAGO 원장 미연결 후보", "기본 비교의 ledger_only와 보정 후 잔여 그룹을 사용"), + ("그룹 단위", "WEHAGO 전표 identity", "여러 원장 행을 하나의 전표 그룹으로 묶음"), + ("제외/이동", "Recheck, Excepted, Voucher로 이동 가능", "VAT/취소재발행/연도교차/수동쌍매칭 등에 따라 최종 상태 변경"), + ("최종 카드", "최종 상태가 voucher_unmatched", "최종 보정 후에도 Hanmac 연결이 없는 WEHAGO 그룹만 표시"), + ], + decision_guide=[ + ("왜 언매칭인가?", "5_행상세에서 Hanmac 계정/금액/가전표번호가 비어 있는지 확인"), + ("후보가 있었나?", "검증근거가 있으면 보정/재검토 과정에서 탈락한 사유 확인"), + ("업무 조치가 필요한가?", "상위 계정·거래처와 금액이 큰 그룹부터 Hanmac 원천 존재 여부 확인"), + ], + ) + ) + reports.append( + build_report( + status="hanmac_unconnected", + file_stem="hanmac_unconnected_analysis", + title="Hanmac unconnected 분석 보고서", + definition="Hanmac 가전표 기본번호 단위로 묶은 뒤 WEHAGO Voucher/Recheck 연결 집합에 없는 그룹 수.", + caveat="카드는 가전표 그룹 수이며, 원천행 수는 계정 라인 수입니다. 두 값은 구조적으로 같지 않습니다.", + logic_rows=[ + ("원천", "wehago_voucher_rows", "draft_no가 있는 Hanmac ERP 원천행 사용"), + ("그룹 단위", "가전표 기본번호", "draft_no/confirmed_no의 행 suffix를 제거한 기본번호와 연도로 그룹화"), + ("연결 판정", "WEHAGO Voucher/Recheck와 매칭된 Hanmac 기본번호", "매칭 집합에 있으면 제외"), + ("최종 카드", "매칭 집합에 없는 Hanmac 그룹", "review_reason은 WEHAGO 매치 계정 없음으로 표시"), + ], + decision_guide=[ + ("WEHAGO에 원장이 없는가?", "5_행상세의 WEHAGO 계정/일자가 비어 있는지 확인"), + ("어떤 Hanmac 전표인가?", "4_전표그룹상세의 가전표번호·계정·거래처 확인"), + ("우선 확인 대상은?", "금액이 크거나 오래된 연도, 거래처 없음 그룹부터 확인"), + ], + full_hanmac=True, + ) + ) + + with engine.connect() as conn: + hanmac_groups = _load_hanmac_unconnected_source_groups(conn, START_YEAR, END_YEAR) + matched_total = int(get_page("voucher_matched", START_YEAR, END_YEAR, 1).get("total_count") or 0) + unmatched_total = int(get_page("voucher_unmatched", START_YEAR, END_YEAR, 1).get("total_count") or 0) + proposal = write_improvement_proposal( + reports, + { + "voucher_matched": matched_total, + "voucher_unmatched": unmatched_total, + "hanmac_unconnected": len(hanmac_groups), + "hanmac_unconnected_rows": sum(len(group.get("rows") or []) for group in hanmac_groups), + }, + ) + for path in [*reports, proposal]: + print(path) + + +if __name__ == "__main__": + main() diff --git a/scripts/create_wehago_cost_type_word_report.py b/scripts/create_wehago_cost_type_word_report.py new file mode 100644 index 0000000..45c65e0 --- /dev/null +++ b/scripts/create_wehago_cost_type_word_report.py @@ -0,0 +1,343 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import argparse +import csv +import zipfile +from collections import Counter +from datetime import datetime +from pathlib import Path +from typing import Any +from xml.sax.saxutils import escape + + +STATUS_LABELS = { + "voucher_unmatched": "WEHAGO Unmatched", + "voucher_recheck": "WEHAGO Recheck", +} + +CATEGORY_RULES = [ + ("현장·사무운영비", "운영비, 통신·전력·수도광열, 임차, 관리·수선, 사무용품·소모품·인쇄, 보험"), + ("차량비", "차량유지비, 차량보험·정비·렌탈 등 차량 운영비"), + ("출장·교통비", "여비교통비, 해외출장비, 출장 전도금"), + ("인건비·복리후생", "급여·임금, 복리후생, 임직원 경조사·가불"), + ("외주·연구개발비", "외주비, 연구개발비, 적요상 용역비 정산"), + ("접대·행사·교육비", "접대, 행사, 교육, 부서비, 광고선전"), + ("세금·공과·수수료", "세금, 사회보험 정산, 지급수수료, 이자비용, 소송 인지세"), + ("금융상품거래", "기타예금, 금융상품, 채권·유가증권·투자자산"), + ("채권·채무·예금거래", "채권회수, 채무결제, 차입금, 계좌대체"), + ("수익거래", "이자·용역·임대·잡이익 등 수익성 전표"), + ("국고보조금·연구비", "국고보조금 계정이 포함된 연구비·정산·상계"), + ("자산취득·처분", "업무차량 등 유형자산 취득·처분, 임차보증금"), + ("기타", "소수 항목 또는 적요만으로 확정하기 어려운 항목. 원 계정과 사유를 비고에 표시"), +] + + +def clean(value: Any) -> str: + return " ".join(str(value or "").replace("\x00", "").split()) + + +def fmt_amount(value: Any) -> str: + try: + return f"{float(value or 0):,.0f}" + except (TypeError, ValueError): + return clean(value) + + +def pct(count: int, total: int) -> str: + return f"{count / total * 100:.1f}%" if total else "0.0%" + + +def voucher_parts(compare_voucher_no: str) -> tuple[str, str]: + value = clean(compare_voucher_no) + if "-" not in value: + return value, "" + raw_date, voucher_no = value.split("-", 1) + if len(raw_date) == 8 and raw_date.isdigit(): + return f"{raw_date[:4]}-{raw_date[4:6]}-{raw_date[6:8]}", voucher_no + return raw_date, voucher_no + + +def paragraph(text: str, style: str | None = None, *, page_break_before: bool = False) -> str: + props: list[str] = [] + if style: + props.append(f'') + if page_break_before: + props.append("") + ppr = f"{''.join(props)}" if props else "" + return f"{ppr}{escape(clean(text))}" + + +def table( + headers: list[str], + rows: list[list[Any]], + widths: list[int], + *, + font_size: int = 16, + repeat_header: bool = True, +) -> str: + if len(headers) != len(widths): + raise ValueError("headers and widths must have the same length") + + def cell(value: Any, width: int, *, bold: bool = False, shaded: bool = False) -> str: + run_props = [f'', ''] + if bold: + run_props.append("") + shade = '' if shaded else "" + return ( + "" + f'{shade}' + '' + "" + '' + f"{''.join(run_props)}{escape(clean(value))}" + "" + ) + + header_props = "" if repeat_header else "" + result_rows = [ + f"{header_props}" + + "".join(cell(header, width, bold=True, shaded=True) for header, width in zip(headers, widths)) + + "" + ] + for row in rows: + padded = list(row) + [""] * (len(headers) - len(row)) + result_rows.append( + "" + + "".join(cell(value, width) for value, width in zip(padded[: len(headers)], widths)) + + "" + ) + grid = "".join(f'' for width in widths) + return ( + "" + "" + '' + '' + "" + '' + '' + '' + '' + '' + '' + "" + '' + '' + "" + f"{grid}" + + "".join(result_rows) + + "" + ) + + +def write_docx(path: Path, body: list[str]) -> None: + document_xml = ( + '' + '' + "" + + "".join(body) + + '' + '' + "" + ) + styles_xml = ( + '' + '' + '' + '' + '' + '' + '' + '' + '' + '' + '' + '' + "" + ) + content_types = ( + '' + '' + '' + '' + '' + '' + "" + ) + rels = ( + '' + '' + '' + "" + ) + doc_rels = ( + '' + '' + '' + "" + ) + with zipfile.ZipFile(path, "w", compression=zipfile.ZIP_DEFLATED) as docx: + docx.writestr("[Content_Types].xml", content_types) + docx.writestr("_rels/.rels", rels) + docx.writestr("word/_rels/document.xml.rels", doc_rels) + docx.writestr("word/document.xml", document_xml) + docx.writestr("word/styles.xml", styles_xml) + + +def load_rows(path: Path) -> list[dict[str, str]]: + with path.open(encoding="utf-8-sig", newline="") as handle: + return list(csv.DictReader(handle)) + + +def detail_row(row: dict[str, str]) -> list[str]: + date_value, voucher_no = voucher_parts(row.get("compare_voucher_no", "")) + return [ + STATUS_LABELS.get(clean(row.get("status")), clean(row.get("status"))), + date_value, + voucher_no, + clean(row.get("major_type")), + clean(row.get("detail_type")), + clean(row.get("basis_account")), + fmt_amount(row.get("basis_amount")), + clean(row.get("accounts")), + clean(row.get("descriptions")), + clean(row.get("note")), + ] + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument( + "--source", + type=Path, + default=Path("reports/wehago_cost_type_grouping_proposal_20260622_191053.csv"), + ) + parser.add_argument("--output-dir", type=Path, default=Path("reports")) + args = parser.parse_args() + + rows = load_rows(args.source) + args.output_dir.mkdir(parents=True, exist_ok=True) + stamp = datetime.now().strftime("%Y%m%d_%H%M%S") + output = args.output_dir / f"wehago_unmatched_recheck_cost_type_report_2025_{stamp}.docx" + + body = [ + paragraph("WEHAGO Unmatched / Recheck 비용 유형 분석 보고서", "Title"), + paragraph("기준 연도: 2025"), + paragraph(f"분석 대상: {len(rows):,}전표 (Unmatched 373건, Recheck 508건)"), + paragraph("분류 원칙: 금융상품 계정 우선, 비용 계정 금액 우선, 선급금·전도금은 적요 우선"), + paragraph("1. 분석 결론", "Heading1"), + paragraph( + "전표 유형은 13개 대분류로 통합하고, 원 계정과 적요 판정은 세부유형 및 비고로 보존하는 방식이 가장 효율적입니다." + ), + paragraph( + "외화환산손실처럼 비중이 매우 낮은 항목은 기타에 합산하고 비고에 원 계정을 표시합니다." + ), + table( + ["대분류", "포함 기준"], + [[category, rule] for category, rule in CATEGORY_RULES], + [2600, 12400], + font_size=18, + ), + paragraph("2. 상태별 분포", "Heading1"), + ] + + for status in ("voucher_unmatched", "voucher_recheck"): + status_rows = [row for row in rows if clean(row.get("status")) == status] + counts = Counter(clean(row.get("major_type")) for row in status_rows) + body.append(paragraph(STATUS_LABELS[status], "Heading2")) + body.append( + table( + ["대분류", "전표 수", "상태 내 비중"], + [[category, f"{count:,}", pct(count, len(status_rows))] for category, count in counts.most_common()], + [7600, 2800, 2800], + font_size=18, + ) + ) + + prepaid_rows = [row for row in rows if clean(row.get("original_type")) == "선급·전도금거래"] + prepaid_counts = Counter( + (clean(row.get("major_type")), clean(row.get("detail_type"))) + for row in prepaid_rows + ) + body.extend( + [ + paragraph("3. 선급금·전도금 적요 재분류", "Heading1", page_break_before=True), + paragraph( + "선급금·전도금은 계정명 자체를 비용 유형으로 사용하지 않고 적요에서 실제 지출 성격을 판독했습니다." + ), + table( + ["대분류", "세부유형", "전표 수"], + [ + [major, detail, f"{count:,}"] + for (major, detail), count in prepaid_counts.most_common() + ], + [5600, 5600, 2200], + font_size=18, + ), + paragraph("선급금·전도금 전표별 판정", "Heading2"), + table( + ["상태", "일자", "전표번호", "대분류", "세부유형", "기준계정", "금액", "계정조합", "적요", "비고"], + [detail_row(row) for row in prepaid_rows], + [1150, 1050, 850, 1500, 1400, 1050, 950, 1850, 3800, 1900], + font_size=14, + ), + ] + ) + + other_rows = [row for row in rows if clean(row.get("major_type")) == "기타"] + body.extend( + [ + paragraph("4. 기타 및 비고 처리", "Heading1"), + paragraph( + f"기타는 {len(other_rows)}건({pct(len(other_rows), len(rows))})으로, 별도 대분류를 세분하지 않고 비고에 원 계정과 판단 사유를 표시합니다." + ), + table( + ["상태", "일자", "전표번호", "세부유형", "기준계정", "금액", "계정조합", "적요", "비고"], + [ + [ + *detail_row(row)[:3], + detail_row(row)[4], + detail_row(row)[5], + detail_row(row)[6], + detail_row(row)[7], + detail_row(row)[8], + detail_row(row)[9], + ] + for row in other_rows + ], + [1300, 1100, 900, 1500, 1100, 950, 2100, 5000, 2200], + font_size=15, + ), + paragraph("5. 적용 권고", "Heading1"), + paragraph("1) 화면의 1차 집계는 대분류를 사용합니다."), + paragraph("2) 상세표에는 세부유형과 비고를 함께 표시해 원 계정과 적요 판정 근거를 보존합니다."), + paragraph("3) 선급금·전도금은 적요 우선으로 분류하고 적요가 불충분하면 기타로 둡니다."), + paragraph("4) 복수 비용 전표는 금액이 가장 큰 비용을 대표 유형으로 하고 다른 비용은 비고에 표시합니다."), + paragraph("5) 금융상품 계정은 일반 비용보다 우선해 금융상품거래로 분류합니다."), + paragraph("부록. 전체 전표 분류 내역", "Heading1", page_break_before=True), + paragraph( + "한 행은 하나의 전표를 의미합니다. 전표 식별정보, 분류, 계정, 금액, 적요, 비고를 독립 열로 구성했습니다." + ), + table( + ["상태", "일자", "전표번호", "대분류", "세부유형", "기준계정", "금액", "계정조합", "적요", "비고"], + [detail_row(row) for row in rows], + [1150, 1050, 850, 1500, 1400, 1050, 950, 1850, 3800, 1900], + font_size=13, + ), + ] + ) + + write_docx(output, body) + print(output) + + +if __name__ == "__main__": + main() diff --git a/scripts/download_hanmac_legacy_related_accounts.py b/scripts/download_hanmac_legacy_related_accounts.py new file mode 100644 index 0000000..3b51987 --- /dev/null +++ b/scripts/download_hanmac_legacy_related_accounts.py @@ -0,0 +1,159 @@ +from __future__ import annotations + +import argparse +import json +import sys +from datetime import datetime +from pathlib import Path +from typing import Any + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + +import scripts.retry_failed_wehago_accounts_direct as direct +import scripts.wehago_data_download_2022_work as wehago +import scripts.wehago_ledger_api_download as api_download +from runtime_config import DB_PATH +from scripts.redownload_and_fix_hanmac_ledger_account import import_account_file + + +ROOT = Path(__file__).resolve().parents[1] +REPORT_DIR = ROOT / "reports" / "wehago_account_fixes" +YEARS = (2018, 2019, 2020, 2021) +GISU_BY_YEAR = {2018: 23, 2019: 24, 2020: 25, 2021: 26} +ACCOUNTS = ( + wehago.Account("114", "단기대여금"), + wehago.Account("137", "주.종단기채권"), + wehago.Account("179", "장기대여금"), + wehago.Account("260", "단기차입금"), + wehago.Account("290", "주.종단기차입금"), + wehago.Account("901", "이자수익"), + wehago.Account("931", "이자비용"), + wehago.Account("116", "미수수익"), + wehago.Account("136", "선납세금"), +) + + +def parse_codes(raw: str) -> tuple[str, ...]: + return tuple(part.strip() for part in raw.split(",") if part.strip()) + + +def fetch_cookies(debugger_address: str, run_dir: Path) -> dict[str, str]: + wehago.CHROME_DEBUGGER_ADDRESS = debugger_address + wehago.DOWNLOAD_DIR = run_dir + driver = wehago.build_driver(run_dir) + try: + return direct.cookie_map(driver) + finally: + driver.quit() + + +def main() -> int: + parser = argparse.ArgumentParser( + description="한맥기술 2018~2021년 계열사 대여/차입 검토 계정별원장을 WEHAGO API로 내려받아 DB에 적재합니다." + ) + parser.add_argument("--years", default=",".join(str(year) for year in YEARS)) + parser.add_argument("--accounts", default=",".join(account.code for account in ACCOUNTS)) + parser.add_argument("--db", type=Path, default=DB_PATH) + parser.add_argument("--debugger-address", default="127.0.0.1:9225") + parser.add_argument("--output-dir", type=Path) + parser.add_argument("--skip-import", action="store_true") + args = parser.parse_args() + + selected_years = tuple(int(year) for year in parse_codes(args.years)) + selected_codes = set(parse_codes(args.accounts)) + accounts = tuple(account for account in ACCOUNTS if account.code in selected_codes) + unknown_years = sorted(set(selected_years) - set(GISU_BY_YEAR)) + if unknown_years: + raise ValueError(f"지원하지 않는 연도입니다: {unknown_years}") + if not accounts: + raise ValueError("다운로드할 계정이 없습니다.") + + run_dir = args.output_dir or (REPORT_DIR / f"legacy_related_accounts_{datetime.now():%Y%m%d_%H%M%S}") + run_dir.mkdir(parents=True, exist_ok=True) + direct.DIRECT_GISU_BY_YEAR.update(GISU_BY_YEAR) + cookies = fetch_cookies(args.debugger_address, run_dir) + + summary: dict[str, Any] = { + "run_dir": str(run_dir), + "years": list(selected_years), + "accounts": [{"account_code": account.code, "account_name": account.name} for account in accounts], + "downloaded": [], + "failures": [], + } + + for year in selected_years: + year_dir = run_dir / str(year) + year_dir.mkdir(parents=True, exist_ok=True) + progress_accounts: list[dict[str, Any]] = [] + progress_failures: list[dict[str, str]] = [] + for account in accounts: + item = {"year": year, "account_code": account.code, "account_name": account.name} + try: + rows = direct.fetch_ledger_rows(year, account, cookies) + api_download.validate_api_rows(account, rows) + target = year_dir / account.safe_filename + api_download.write_api_rows(target, account, rows) + item.update({"path": str(target), "api_response_rows": len(rows)}) + progress_accounts.append( + { + "account_code": account.code, + "account_name": account.name, + "api_request_code": f"{account.code}00", + "api_response_rows": len(rows), + "path": str(target), + } + ) + if not args.skip_import: + result = import_account_file( + args.db, + target, + year, + account, + year_dir, + skip_rebuild=True, + no_backup=True, + skip_cache_clear=True, + ) + item.update( + { + "deleted_rows": result["deleted_rows"], + "inserted_rows": result["inserted_rows"], + } + ) + summary["downloaded"].append(item) + print( + f"OK {year} {account.code} {account.name}: rows={item.get('api_response_rows')} " + f"inserted={item.get('inserted_rows', '-')}", + flush=True, + ) + except Exception as exc: + failure = { + "year": str(year), + "account_code": account.code, + "account_name": account.name, + "reason": f"{type(exc).__name__}: {exc}", + } + summary["failures"].append(failure) + progress_failures.append(failure) + print(f"FAIL {year} {account.code} {account.name}: {failure['reason']}", flush=True) + api_download.write_progress( + year_dir, + { + "status": "completed_with_failures" if progress_failures else "completed", + "completed": len(progress_accounts), + "total": len(accounts), + "accounts": progress_accounts, + "failures": progress_failures, + }, + ) + (run_dir / "legacy_related_accounts_result.json").write_text( + json.dumps(summary, ensure_ascii=False, indent=2), + encoding="utf-8", + ) + + print(json.dumps(summary, ensure_ascii=False, indent=2)) + return 1 if summary["failures"] else 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/export_hanmac_related_party_loans.py b/scripts/export_hanmac_related_party_loans.py new file mode 100644 index 0000000..9fd4276 --- /dev/null +++ b/scripts/export_hanmac_related_party_loans.py @@ -0,0 +1,386 @@ +from __future__ import annotations + +import argparse +import sqlite3 +import sys +from collections import defaultdict +from datetime import datetime +from pathlib import Path +from typing import Any + +from openpyxl import Workbook +from openpyxl.styles import Alignment, Font, PatternFill +from openpyxl.utils import get_column_letter + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT)) + +from runtime_config import DB_PATH + +REPORT_DIR = ROOT / "reports" + +TARGET_ACCOUNT_CODES = { + "114": "대여금", + "137": "대여금", + "179": "대여금", + "260": "차입금", + "290": "차입금", + "901": "이자수익", + "931": "이자비용", + "136": "선납세금", +} + +TARGET_NAME_MARKERS = { + "대여금": "대여금", + "주.임.종": "대여금", + "주.종": "대여금", + "차입금": "차입금", + "이자수익": "이자수익", + "이자비용": "이자비용", + "선납세금": "선납세금", +} + +DEFAULT_RELATED_KEYWORDS = [ + "한맥기술", + "장헌산업", + "장헌", + "장헌파트너스", + "한라산업개발", + "삼안", + "피티씨", + "PTC", + "바론컨설턴트", +] + + +def clean(value: Any) -> str: + return "" if value is None else str(value).strip() + + +def amount(value: Any) -> float: + try: + return float(value or 0) + except (TypeError, ValueError): + return 0.0 + + +def classify_account(code: Any, name: Any) -> str: + code_text = clean(code) + name_text = clean(name) + if code_text in TARGET_ACCOUNT_CODES: + return TARGET_ACCOUNT_CODES[code_text] + for marker, category in TARGET_NAME_MARKERS.items(): + if marker in name_text: + return category + return "" + + +def signed_amount(category: str, debit: Any, credit: Any) -> float: + debit_amount = amount(debit) + credit_amount = amount(credit) + if category in {"대여금", "선납세금", "이자비용"}: + return round(debit_amount - credit_amount, 2) + if category in {"차입금", "이자수익"}: + return round(credit_amount - debit_amount, 2) + return round(debit_amount - credit_amount, 2) + + +def contains_keyword(row: dict[str, Any], keywords: list[str]) -> bool: + if not keywords: + return False + text = " ".join(clean(row.get(key)) for key in ("vendor_name", "description", "account_name")) + return any(keyword in text for keyword in keywords) + + +def fetch_target_rows(conn: sqlite3.Connection, start_year: int, end_year: int) -> list[dict[str, Any]]: + conditions = [ + "account_code IN ('114','137','179','260','290','901','931','136')", + "account_name LIKE '%대여금%'", + "account_name LIKE '%주.임.종%'", + "account_name LIKE '%주.종%'", + "account_name LIKE '%차입금%'", + "account_name LIKE '%이자수익%'", + "account_name LIKE '%이자비용%'", + "account_name LIKE '%선납세금%'", + ] + sql = f""" + SELECT id, fiscal_year, ledger_date, voucher_no, compare_voucher_no, + account_code, account_name, vendor_name, description, + debit, credit, balance, compare_amount, compare_side, + compare_vendor, compare_desc + FROM wehago_ledger_rows + WHERE fiscal_year BETWEEN ? AND ? + AND ({' OR '.join(conditions)}) + ORDER BY fiscal_year, ledger_date, voucher_no, account_code, id + """ + rows = [dict(row) for row in conn.execute(sql, (start_year, end_year)).fetchall()] + for row in rows: + category = classify_account(row.get("account_code"), row.get("account_name")) + row["category"] = category + row["signed_amount"] = signed_amount(category, row.get("debit"), row.get("credit")) + row["direction"] = "증가" if row["signed_amount"] > 0 else "감소" if row["signed_amount"] < 0 else "상계/무변동" + return rows + + +def fetch_voucher_context(conn: sqlite3.Connection, target_rows: list[dict[str, Any]]) -> list[dict[str, Any]]: + keys = sorted( + { + (int(row["fiscal_year"]), clean(row.get("compare_voucher_no") or row.get("voucher_no"))) + for row in target_rows + if clean(row.get("compare_voucher_no") or row.get("voucher_no")) + } + ) + context_rows: list[dict[str, Any]] = [] + for fiscal_year, voucher_key in keys: + rows = [ + dict(row) + for row in conn.execute( + """ + SELECT id, fiscal_year, ledger_date, voucher_no, compare_voucher_no, + account_code, account_name, vendor_name, description, + debit, credit, balance + FROM wehago_ledger_rows + WHERE fiscal_year = ? + AND COALESCE(compare_voucher_no, voucher_no, '') = ? + ORDER BY row_number, id + """, + (fiscal_year, voucher_key), + ).fetchall() + ] + target_categories = sorted( + { + classify_account(row.get("account_code"), row.get("account_name")) + for row in rows + if classify_account(row.get("account_code"), row.get("account_name")) + } + ) + for row in rows: + category = classify_account(row.get("account_code"), row.get("account_name")) + row["voucher_key"] = voucher_key + row["target_categories_in_voucher"] = ", ".join(target_categories) + row["category"] = category + row["is_target_account"] = "Y" if category else "" + row["signed_amount"] = signed_amount(category, row.get("debit"), row.get("credit")) if category else "" + context_rows.append(row) + return context_rows + + +def summarize_by_account(rows: list[dict[str, Any]]) -> list[dict[str, Any]]: + bucket: dict[tuple[Any, str, str, str], dict[str, Any]] = {} + for row in rows: + key = (row["fiscal_year"], row["category"], clean(row["account_code"]), clean(row["account_name"])) + item = bucket.setdefault( + key, + { + "fiscal_year": row["fiscal_year"], + "category": row["category"], + "account_code": clean(row["account_code"]), + "account_name": clean(row["account_name"]), + "row_count": 0, + "debit": 0.0, + "credit": 0.0, + "signed_amount": 0.0, + }, + ) + item["row_count"] += 1 + item["debit"] += amount(row["debit"]) + item["credit"] += amount(row["credit"]) + item["signed_amount"] += amount(row["signed_amount"]) + return sorted(bucket.values(), key=lambda x: (x["fiscal_year"], x["category"], x["account_code"])) + + +def summarize_by_counterparty(rows: list[dict[str, Any]], keywords: list[str]) -> list[dict[str, Any]]: + bucket: dict[tuple[Any, str, str], dict[str, Any]] = {} + for row in rows: + vendor = clean(row.get("vendor_name")) or "(거래처 공란)" + key = (row["fiscal_year"], row["category"], vendor) + item = bucket.setdefault( + key, + { + "fiscal_year": row["fiscal_year"], + "category": row["category"], + "vendor_name": vendor, + "row_count": 0, + "debit": 0.0, + "credit": 0.0, + "signed_amount": 0.0, + "related_keyword_hit": "", + "sample_description": "", + }, + ) + item["row_count"] += 1 + item["debit"] += amount(row["debit"]) + item["credit"] += amount(row["credit"]) + item["signed_amount"] += amount(row["signed_amount"]) + if contains_keyword(row, keywords): + item["related_keyword_hit"] = "Y" + if not item["sample_description"] and clean(row.get("description")): + item["sample_description"] = clean(row.get("description")) + return sorted(bucket.values(), key=lambda x: (x["fiscal_year"], x["category"], -abs(x["signed_amount"]), x["vendor_name"])) + + +def write_sheet(ws: Any, rows: list[dict[str, Any]], columns: list[tuple[str, str]]) -> None: + header_fill = PatternFill("solid", fgColor="263238") + header_font = Font(color="FFFFFF", bold=True) + for col_idx, (_key, title) in enumerate(columns, start=1): + cell = ws.cell(row=1, column=col_idx, value=title) + cell.fill = header_fill + cell.font = header_font + cell.alignment = Alignment(horizontal="center") + for row_idx, row in enumerate(rows, start=2): + for col_idx, (key, _title) in enumerate(columns, start=1): + value = row.get(key, "") + ws.cell(row=row_idx, column=col_idx, value=value) + ws.freeze_panes = "A2" + ws.auto_filter.ref = ws.dimensions + for col_idx, (key, title) in enumerate(columns, start=1): + width = max(len(title) + 2, 12) + sample_values = [clean(row.get(key)) for row in rows[:200]] + if sample_values: + width = max(width, min(max(len(value) for value in sample_values) + 2, 45)) + ws.column_dimensions[get_column_letter(col_idx)].width = width + + +def export_report( + target_rows: list[dict[str, Any]], + context_rows: list[dict[str, Any]], + keywords: list[str], + output_path: Path, +) -> None: + wb = Workbook() + ws = wb.active + ws.title = "01_요약_계정별" + write_sheet( + ws, + summarize_by_account(target_rows), + [ + ("fiscal_year", "연도"), + ("category", "구분"), + ("account_code", "계정코드"), + ("account_name", "계정명"), + ("row_count", "건수"), + ("debit", "차변합계"), + ("credit", "대변합계"), + ("signed_amount", "방향반영순액"), + ], + ) + ws = wb.create_sheet("02_요약_거래처별") + write_sheet( + ws, + summarize_by_counterparty(target_rows, keywords), + [ + ("fiscal_year", "연도"), + ("category", "구분"), + ("vendor_name", "거래처"), + ("row_count", "건수"), + ("debit", "차변합계"), + ("credit", "대변합계"), + ("signed_amount", "방향반영순액"), + ("related_keyword_hit", "계열사키워드"), + ("sample_description", "적요 예시"), + ], + ) + ws = wb.create_sheet("03_대상원장") + write_sheet( + ws, + target_rows, + [ + ("fiscal_year", "연도"), + ("ledger_date", "일자"), + ("voucher_no", "전표번호"), + ("compare_voucher_no", "비교전표번호"), + ("category", "구분"), + ("account_code", "계정코드"), + ("account_name", "계정명"), + ("vendor_name", "거래처"), + ("description", "적요"), + ("debit", "차변"), + ("credit", "대변"), + ("balance", "잔액"), + ("signed_amount", "방향반영금액"), + ("direction", "증감"), + ], + ) + ws = wb.create_sheet("04_같은전표_상대계정") + write_sheet( + ws, + context_rows, + [ + ("fiscal_year", "연도"), + ("ledger_date", "일자"), + ("voucher_key", "전표키"), + ("voucher_no", "전표번호"), + ("target_categories_in_voucher", "대상구분"), + ("is_target_account", "대상계정"), + ("category", "구분"), + ("account_code", "계정코드"), + ("account_name", "계정명"), + ("vendor_name", "거래처"), + ("description", "적요"), + ("debit", "차변"), + ("credit", "대변"), + ("balance", "잔액"), + ("signed_amount", "방향반영금액"), + ], + ) + output_path.parent.mkdir(parents=True, exist_ok=True) + wb.save(output_path) + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="한맥기술 계열사 대여/차입 관련 계정별원장 검토 파일을 생성합니다.") + parser.add_argument("--db", type=Path, default=DB_PATH) + parser.add_argument("--start-year", type=int, default=2018) + parser.add_argument("--end-year", type=int, default=datetime.now().year) + parser.add_argument( + "--related-keyword", + action="append", + default=list(DEFAULT_RELATED_KEYWORDS), + help="거래처/적요에서 계열사 후보로 표시할 키워드입니다. 기본 계열사 키워드에 추가로 여러 번 지정할 수 있습니다.", + ) + parser.add_argument("--output", type=Path) + parser.add_argument( + "--allow-missing-years", + action="store_true", + help="완료 연도에 대상 원장 행이 없어도 내보냅니다. 기본값은 누락을 오류로 처리합니다.", + ) + return parser.parse_args() + + +def main() -> int: + args = parse_args() + output_path = args.output or ( + REPORT_DIR / f"hanmac_related_party_loans_{args.start_year}_{args.end_year}_{datetime.now():%Y%m%d_%H%M%S}.xlsx" + ) + conn = sqlite3.connect(args.db) + conn.row_factory = sqlite3.Row + try: + target_rows = fetch_target_rows(conn, args.start_year, args.end_year) + context_rows = fetch_voucher_context(conn, target_rows) + finally: + conn.close() + rows_by_year: dict[int, int] = defaultdict(int) + for row in target_rows: + rows_by_year[int(row["fiscal_year"])] += 1 + last_completed_year = min(args.end_year, datetime.now().year - 1) + missing_years = [ + year + for year in range(args.start_year, last_completed_year + 1) + if rows_by_year.get(year, 0) == 0 + ] + if missing_years and not args.allow_missing_years: + raise RuntimeError( + f"요청 범위의 완료 연도에 대상 원장이 없습니다: {missing_years}. " + f"DB 경로를 확인하세요: {args.db}" + ) + export_report(target_rows, context_rows, [clean(item) for item in args.related_keyword if clean(item)], output_path) + print(f"db={args.db}") + print(f"output={output_path}") + print(f"target_rows={len(target_rows)}") + print(f"context_rows={len(context_rows)}") + print(f"rows_by_year={dict(sorted(rows_by_year.items()))}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/export_selected_work_history.py b/scripts/export_selected_work_history.py new file mode 100644 index 0000000..2daac3c --- /dev/null +++ b/scripts/export_selected_work_history.py @@ -0,0 +1,311 @@ +from __future__ import annotations + +import argparse +from datetime import date, datetime +from pathlib import Path +from typing import Any + +import pymysql +from openpyxl import Workbook +from openpyxl.styles import Alignment, Border, Font, PatternFill, Side +from openpyxl.utils import get_column_letter + + +MEMBERS = { + "B22035": "이수문", + "B22037": "안용주", + "B24010": "장한규", +} + + +def clean(value: Any) -> str: + return str(value or "").strip() + + +def select_column(columns: set[str], candidates: tuple[str, ...]) -> str: + lower_map = {column.lower(): column for column in columns} + for candidate in candidates: + if candidate.lower() in lower_map: + return lower_map[candidate.lower()] + return "" + + +def quoted(column: str) -> str: + return f"`{column}`" + + +def load_project_names(cursor: Any) -> dict[str, str]: + cursor.execute("SHOW COLUMNS FROM `project_tbl`") + columns = {clean(row["Field"]) for row in cursor.fetchall()} + code_columns = [ + select_column(columns, candidates) + for candidates in ( + ("projectcode", "project_code", "ProjectCode"), + ("newprojectcode", "new_project_code", "NewProjectCode"), + ("oldprojectcode", "old_project_code", "OldProjectCode"), + ("projectviewcode", "project_view_code", "ProjectViewCode"), + ) + ] + code_columns = [column for column in code_columns if column] + name_column = select_column(columns, ("project_name", "ProjectName", "Name", "project_nm")) + if not code_columns or not name_column: + return {} + + select_columns = ", ".join(quoted(column) for column in [*code_columns, name_column]) + cursor.execute(f"SELECT {select_columns} FROM `project_tbl`") + result: dict[str, str] = {} + for row in cursor.fetchall(): + project_name = clean(row.get(name_column)) + for code_column in code_columns: + project_code = clean(row.get(code_column)) + if project_code and project_name: + result.setdefault(project_code.upper(), project_name) + return result + + +def load_additional_work_history(cursor: Any, start_date: str) -> list[dict[str, Any]]: + cursor.execute("SHOW COLUMNS FROM `dallyproject_addwork_tbl`") + columns = {clean(row["Field"]) for row in cursor.fetchall()} + member_column = select_column(columns, ("MemberNo", "member_no", "EmpNo", "UserID")) + date_column = select_column(columns, ("EntryTime", "entry_time", "WorkDate", "work_date", "EntryDate")) + sequence_column = select_column(columns, ("seq_no", "SeqNo", "sequence_no")) + project_column = select_column(columns, ("new_project_code", "NewProjectCode", "project_code", "ProjectCode")) + fallback_project_column = select_column(columns, ("project_code", "ProjectCode", "PCode")) + contents_column = select_column(columns, ("contents", "Contents", "description", "Description", "memo")) + if not member_column or not date_column or not project_column or not contents_column: + raise RuntimeError("근무이력 필수 컬럼을 찾을 수 없습니다.") + + selected = [member_column, date_column, project_column, contents_column] + for optional in (sequence_column, fallback_project_column): + if optional and optional not in selected: + selected.append(optional) + select_sql = ", ".join(quoted(column) for column in selected) + member_placeholders = ", ".join(["%s"] * len(MEMBERS)) + cursor.execute( + f""" + SELECT {select_sql} + FROM `dallyproject_addwork_tbl` + WHERE `{member_column}` IN ({member_placeholders}) + AND LEFT(CAST(`{date_column}` AS CHAR), 10) >= %s + ORDER BY `{member_column}`, `{date_column}`{f", `{sequence_column}`" if sequence_column else ""} + """, + [*MEMBERS, start_date], + ) + rows = [] + for row in cursor.fetchall(): + project_code = clean(row.get(project_column)) or clean(row.get(fallback_project_column)) + rows.append( + { + "member_no": clean(row.get(member_column)), + "member_name": MEMBERS.get(clean(row.get(member_column)), ""), + "work_date": clean(row.get(date_column))[:10], + "sequence": int(row.get(sequence_column) or 0) if sequence_column else 0, + "project_code": project_code, + "description": clean(row.get(contents_column)), + "source_order": 2, + } + ) + return rows + + +def load_regular_work_history(cursor: Any, start_date: str) -> list[dict[str, Any]]: + cursor.execute("SHOW COLUMNS FROM `dallyproject_tbl`") + columns = {clean(row["Field"]) for row in cursor.fetchall()} + member_column = select_column(columns, ("MemberNo", "member_no", "EmpNo", "UserID")) + date_column = select_column(columns, ("EntryTime", "entry_time", "WorkDate", "work_date")) + project_column = select_column(columns, ("EntryPCode2", "entry_p_code2", "EntryPCode", "entry_p_code")) + fallback_project_column = select_column(columns, ("EntryPCode", "entry_p_code")) + description_column = select_column(columns, ("EntryJob", "entry_job", "Description", "description")) + if not member_column or not date_column or not project_column or not description_column: + raise RuntimeError("정규 근무이력 필수 컬럼을 찾을 수 없습니다.") + + selected = [member_column, date_column, project_column, description_column] + if fallback_project_column and fallback_project_column not in selected: + selected.append(fallback_project_column) + select_sql = ", ".join(quoted(column) for column in selected) + member_placeholders = ", ".join(["%s"] * len(MEMBERS)) + cursor.execute( + f""" + SELECT {select_sql} + FROM `dallyproject_tbl` + WHERE `{member_column}` IN ({member_placeholders}) + AND LEFT(CAST(`{date_column}` AS CHAR), 10) >= %s + ORDER BY `{member_column}`, `{date_column}` + """, + [*MEMBERS, start_date], + ) + rows = [] + for row in cursor.fetchall(): + project_code = clean(row.get(project_column)) or clean(row.get(fallback_project_column)) + rows.append( + { + "member_no": clean(row.get(member_column)), + "member_name": MEMBERS.get(clean(row.get(member_column)), ""), + "work_date": clean(row.get(date_column))[:10], + "sequence": 0, + "project_code": project_code, + "description": clean(row.get(description_column)).replace("\r\n", " / ").replace("\n", " / "), + "source_order": 1, + } + ) + return rows + + +def apply_sheet_style(worksheet: Any, row_count: int, include_member: bool) -> None: + header_fill = PatternFill("solid", fgColor="1F4E78") + header_font = Font(color="FFFFFF", bold=True, size=10) + thin_gray = Side(style="thin", color="D9E2F3") + border = Border(left=thin_gray, right=thin_gray, top=thin_gray, bottom=thin_gray) + + for cell in worksheet[1]: + cell.fill = header_fill + cell.font = header_font + cell.alignment = Alignment(horizontal="center", vertical="center") + cell.border = border + for row in worksheet.iter_rows(min_row=2): + for cell in row: + cell.border = border + cell.alignment = Alignment(vertical="top", wrap_text=False) + for row_index in range(2, row_count + 2): + worksheet.row_dimensions[row_index].height = 18 + + widths = [13, 12, 14, 30, 72] if include_member else [12, 14, 30, 76] + for index, width in enumerate(widths, start=1): + worksheet.column_dimensions[get_column_letter(index)].width = width + worksheet.row_dimensions[1].height = 24 + worksheet.freeze_panes = "A2" + worksheet.auto_filter.ref = f"A1:{get_column_letter(len(widths))}{max(row_count + 1, 1)}" + worksheet.sheet_view.showGridLines = False + worksheet.page_setup.orientation = "landscape" + worksheet.page_setup.paperSize = worksheet.PAPERSIZE_A4 + worksheet.page_setup.fitToWidth = 1 + worksheet.page_setup.fitToHeight = 0 + worksheet.sheet_properties.pageSetUpPr.fitToPage = True + worksheet.print_title_rows = "1:1" + worksheet.page_margins.left = 0.2 + worksheet.page_margins.right = 0.2 + worksheet.page_margins.top = 0.35 + worksheet.page_margins.bottom = 0.35 + worksheet.oddFooter.center.text = "페이지 &P / &N" + + +def create_workbook(rows: list[dict[str, Any]], project_names: dict[str, str], output_path: Path) -> None: + for row in rows: + code = clean(row["project_code"]).upper() + row["project_name"] = project_names.get(code, "") + + workbook = Workbook() + combined = workbook.active + combined.title = "전체" + combined.append(["사번/성명", "일자", "프로젝트코드", "프로젝트명", "적요"]) + for row in rows: + combined.append( + [ + f"{row['member_no']} {row['member_name']}", + row["work_date"], + row["project_code"], + row["project_name"], + row["description"], + ] + ) + apply_sheet_style(combined, len(rows), include_member=True) + + for member_no, member_name in MEMBERS.items(): + worksheet = workbook.create_sheet(f"{member_name}_{member_no}") + worksheet.append(["일자", "프로젝트코드", "프로젝트명", "적요"]) + member_rows = [row for row in rows if row["member_no"] == member_no] + for row in member_rows: + worksheet.append( + [ + row["work_date"], + row["project_code"], + row["project_name"], + row["description"], + ] + ) + apply_sheet_style(worksheet, len(member_rows), include_member=False) + worksheet.oddHeader.center.text = f"{member_no} {member_name} 근무이력 ({date.today().isoformat()} 기준)" + + summary = workbook.create_sheet("요약", 0) + summary.append(["대상", "조회 시작일", "조회 종료일", "근무이력 건수", "프로젝트명 미매칭"]) + for member_no, member_name in MEMBERS.items(): + member_rows = [row for row in rows if row["member_no"] == member_no] + summary.append( + [ + f"{member_no} {member_name}", + min((row["work_date"] for row in member_rows), default=""), + max((row["work_date"] for row in member_rows), default=""), + len(member_rows), + sum(1 for row in member_rows if not row["project_name"]), + ] + ) + apply_sheet_style(summary, len(MEMBERS), include_member=True) + summary.column_dimensions["A"].width = 24 + summary.column_dimensions["B"].width = 16 + summary.column_dimensions["C"].width = 16 + summary.column_dimensions["D"].width = 16 + summary.column_dimensions["E"].width = 18 + + output_path.parent.mkdir(parents=True, exist_ok=True) + workbook.save(output_path) + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("--host", required=True) + parser.add_argument("--port", type=int, default=3306) + parser.add_argument("--user", required=True) + parser.add_argument("--password", required=True) + parser.add_argument("--database", default="hanmac_manhour") + parser.add_argument("--start-date", default="2026-01-01") + parser.add_argument("--output", type=Path, required=True) + args = parser.parse_args() + + connection = pymysql.connect( + host=args.host, + port=args.port, + user=args.user, + password=args.password, + database=args.database, + charset="utf8", + cursorclass=pymysql.cursors.DictCursor, + connect_timeout=10, + read_timeout=60, + ) + try: + with connection.cursor() as cursor: + project_names = load_project_names(cursor) + rows = [ + *load_regular_work_history(cursor, args.start_date), + *load_additional_work_history(cursor, args.start_date), + ] + finally: + connection.close() + + rows.sort( + key=lambda row: ( + row["member_no"], + row["work_date"], + int(row.get("source_order") or 0), + int(row.get("sequence") or 0), + row["project_code"], + row["description"], + ) + ) + create_workbook(rows, project_names, args.output) + print( + { + "output": str(args.output), + "rows": len(rows), + "members": { + member_no: sum(1 for row in rows if row["member_no"] == member_no) + for member_no in MEMBERS + }, + "unmatched_project_names": sum(1 for row in rows if not row.get("project_name")), + "generated_at": datetime.now().isoformat(timespec="seconds"), + } + ) + + +if __name__ == "__main__": + main() diff --git a/scripts/fix_hanmac_2025_137_minimal.py b/scripts/fix_hanmac_2025_137_minimal.py new file mode 100644 index 0000000..4aa6fca --- /dev/null +++ b/scripts/fix_hanmac_2025_137_minimal.py @@ -0,0 +1,152 @@ +from __future__ import annotations + +import sqlite3 +from datetime import date, datetime +from pathlib import Path +from typing import Any + +from openpyxl import load_workbook + +ROOT = Path(__file__).resolve().parents[1] +DB_PATH = ROOT / "data.db" +SOURCE_XLSX = ROOT / "reports" / "wehago_account_fixes" / "2025_137_20260617_092619" / "137_주.종단기채권.xlsx" +YEAR = 2025 +ACCOUNT_CODE = "137" +ACCOUNT_NAME = "주.종단기채권" + + +def clean(value: Any) -> str: + return "" if value is None else str(value).strip() + + +def parse_amount(value: Any) -> float: + if value is None or value == "": + return 0.0 + if isinstance(value, (int, float)): + return float(value) + text = clean(value).replace(",", "") + try: + return float(text) + except ValueError: + return 0.0 + + +def parse_ledger_date(value: Any) -> str | None: + if value is None or clean(value) == "": + return None + if isinstance(value, datetime): + return value.date().isoformat() + if isinstance(value, date): + return value.isoformat() + text = clean(value).replace(".", "-").replace("/", "-") + parts = [part for part in text.split("-") if part] + if len(parts) == 2: + month, day = parts + return f"{YEAR}-{int(month):02d}-{int(day):02d}" + if len(parts) == 3: + year, month, day = parts + return f"{int(year):04d}-{int(month):02d}-{int(day):02d}" + return text + + +def compare_voucher_no(ledger_date: str | None, voucher_no: str) -> str: + if not ledger_date or not voucher_no: + return "" + return f"{ledger_date.replace('-', '')}-{voucher_no}" + + +def normalize_text(value: Any) -> str: + return " ".join(clean(value).split()) + + +def main() -> int: + wb = load_workbook(SOURCE_XLSX, read_only=True, data_only=True) + try: + ws = wb.worksheets[0] + source_rows = list(ws.iter_rows(min_row=2, values_only=True)) + finally: + wb.close() + + conn = sqlite3.connect(DB_PATH, timeout=300) + try: + conn.execute("PRAGMA busy_timeout = 300000") + source_id_row = conn.execute( + """ + SELECT source_file_id + FROM wehago_ledger_rows + WHERE fiscal_year = ? AND account_code = ? + GROUP BY source_file_id + ORDER BY COUNT(*) DESC + LIMIT 1 + """, + (YEAR, ACCOUNT_CODE), + ).fetchone() + source_file_id = int(source_id_row[0]) if source_id_row else 0 + if not source_file_id: + source_file_id = int( + conn.execute( + "SELECT source_file_id FROM wehago_ledger_rows WHERE fiscal_year = ? LIMIT 1", + (YEAR,), + ).fetchone()[0] + ) + + with conn: + deleted = conn.execute( + "DELETE FROM wehago_ledger_rows WHERE fiscal_year = ? AND account_code = ?", + (YEAR, ACCOUNT_CODE), + ).rowcount + inserted = 0 + for row_number, values in enumerate(source_rows, start=2): + if not any(clean(item) for item in values): + continue + ledger_date = parse_ledger_date(values[0] if len(values) > 0 else None) + description = clean(values[1] if len(values) > 1 else "") + vendor_name = clean(values[2] if len(values) > 2 else "") + debit = parse_amount(values[3] if len(values) > 3 else 0) + credit = parse_amount(values[4] if len(values) > 4 else 0) + balance = parse_amount(values[5] if len(values) > 5 else 0) + voucher_no = clean(values[6] if len(values) > 6 else "") + account_code = clean(values[7] if len(values) > 7 else ACCOUNT_CODE) or ACCOUNT_CODE + account_name = clean(values[8] if len(values) > 8 else ACCOUNT_NAME) or ACCOUNT_NAME + conn.execute( + """ + INSERT INTO wehago_ledger_rows ( + source_file_id, sheet_name, row_number, ledger_date, description, + vendor_name, debit, credit, balance, voucher_no, account_code, + account_name, compare_voucher_no, compare_amount, compare_side, + compare_vendor, compare_desc, fiscal_year + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + source_file_id, + ws.title, + row_number, + ledger_date, + description, + vendor_name, + debit, + credit, + balance, + voucher_no, + account_code, + account_name, + compare_voucher_no(ledger_date, voucher_no), + debit if debit else credit, + "debit" if debit else ("credit" if credit else ""), + normalize_text(vendor_name), + normalize_text(description), + YEAR, + ), + ) + inserted += 1 + print(f"source={SOURCE_XLSX}") + print(f"source_file_id={source_file_id}") + print(f"deleted={deleted}") + print(f"inserted={inserted}") + finally: + conn.close() + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/import_hanmac_voucher_xlsx.py b/scripts/import_hanmac_voucher_xlsx.py new file mode 100644 index 0000000..8486d92 --- /dev/null +++ b/scripts/import_hanmac_voucher_xlsx.py @@ -0,0 +1,158 @@ +from __future__ import annotations + +import argparse +import json +import sys +from datetime import datetime +from pathlib import Path +from typing import Any + +from openpyxl import load_workbook +from sqlalchemy import create_engine, text + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + +from runtime_config import DB_PATH +from wehago_compare import activate_erp_voucher_existence_snapshot, detect_file_kind, import_voucher_rows +from scripts.redownload_and_fix_hanmac_ledger_account import upsert_fix_source_file + + +def import_voucher_file( + db_path: Path, + source_path: Path, + *, + year_hint: int | None = None, + activate_existence_snapshot: bool = False, +) -> dict[str, Any]: + file_kind, header, sheet_name = detect_file_kind(source_path) + if file_kind != "voucher": + raise ValueError(f"전표내역조회 형식이 아닙니다: {source_path} ({file_kind})") + + workbook = load_workbook(source_path, read_only=True, data_only=True) + engine = create_engine(f"sqlite:///{db_path}", connect_args={"check_same_thread": False}) + try: + sheet = workbook.worksheets[0] + with engine.begin() as conn: + conn.execute(text("PRAGMA busy_timeout = 300000")) + source_id, _changed = upsert_fix_source_file( + conn, + source_path.resolve(), + file_kind, + int(year_hint) if year_hint else None, + sheet_name, + header, + ) + deleted = int( + conn.execute( + text("DELETE FROM wehago_voucher_rows WHERE source_file_id = :source_id"), + {"source_id": source_id}, + ).rowcount + or 0 + ) + inserted = import_voucher_rows( + conn, + source_id, + sheet_name, + sheet.iter_rows(min_row=2, values_only=True), + int(year_hint) if year_hint else None, + ) + conn.execute( + text( + """ + UPDATE wehago_source_files + SET row_count = :row_count, + imported_at = CURRENT_TIMESTAMP + WHERE id = :source_id + """ + ), + {"row_count": inserted, "source_id": source_id}, + ) + existence_snapshot = None + if activate_existence_snapshot: + snapshot_year = int(year_hint or 0) + if snapshot_year <= 0: + year_values = [ + int(row["fiscal_year"]) + for row in conn.execute( + text( + """ + SELECT DISTINCT fiscal_year + FROM wehago_voucher_rows + WHERE source_file_id = :source_id + AND fiscal_year IS NOT NULL + ORDER BY fiscal_year + """ + ), + {"source_id": source_id}, + ).mappings() + if int(row["fiscal_year"] or 0) > 0 + ] + if len(year_values) == 1: + snapshot_year = year_values[0] + if snapshot_year <= 0: + raise ValueError("--activate-existence-snapshot requires --year-hint or a single fiscal year in the file.") + existence_snapshot = activate_erp_voucher_existence_snapshot( + conn, + snapshot_year, + source_file_id=source_id, + source_label=source_path.name, + snapshot_mode="full", + ) + year_rows = [ + dict(row) + for row in conn.execute( + text( + """ + SELECT fiscal_year, COUNT(*) AS row_count + FROM wehago_voucher_rows + WHERE source_file_id = :source_id + GROUP BY fiscal_year + ORDER BY fiscal_year + """ + ), + {"source_id": source_id}, + ).mappings() + ] + finally: + workbook.close() + engine.dispose() + + return { + "db_path": str(db_path), + "source_path": str(source_path.resolve()), + "source_id": source_id, + "deleted_rows": deleted, + "inserted_rows": inserted, + "year_rows": year_rows, + "existence_snapshot": existence_snapshot, + "imported_at": datetime.now().isoformat(timespec="seconds"), + } + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="한맥 ERP 전표내역조회 엑셀을 WEHAGO 비교 DB의 voucher 테이블에 반영합니다.") + parser.add_argument("source", type=Path) + parser.add_argument("--db", type=Path, default=DB_PATH) + parser.add_argument("--year-hint", type=int) + parser.add_argument( + "--activate-existence-snapshot", + action="store_true", + help="이 파일을 해당 연도의 최신 ERP 전표 존재 목록으로 활성화합니다.", + ) + return parser.parse_args() + + +def main() -> int: + args = parse_args() + result = import_voucher_file( + args.db, + args.source, + year_hint=args.year_hint, + activate_existence_snapshot=args.activate_existence_snapshot, + ) + print(json.dumps(result, ensure_ascii=False, indent=2)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/inspect_wehago_ledger_api_9225.py b/scripts/inspect_wehago_ledger_api_9225.py new file mode 100644 index 0000000..b93fce9 --- /dev/null +++ b/scripts/inspect_wehago_ledger_api_9225.py @@ -0,0 +1,162 @@ +from __future__ import annotations + +import argparse +import json +import time +from datetime import datetime +from pathlib import Path +from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit + +from selenium import webdriver +from selenium.common.exceptions import WebDriverException +from selenium.webdriver.chrome.options import Options + +import refresh_hanmac_wehago_ledgers as refresh +import wehago_data_download_2022_work as wehago + + +OUTPUT_DIR = Path(__file__).resolve().parents[1] / "runtime_diagnostics" +SENSITIVE_KEYS = { + "access_token", + "authorization", + "cookie", + "password", + "refresh_token", + "session", + "token", +} + + +def redact_url(url: str) -> str: + parts = urlsplit(url) + query = [ + (key, "" if any(secret in key.lower() for secret in SENSITIVE_KEYS) else value) + for key, value in parse_qsl(parts.query, keep_blank_values=True) + ] + return urlunsplit((parts.scheme, parts.netloc, parts.path, urlencode(query), parts.fragment)) + + +def redact_payload(value: object) -> object: + if isinstance(value, dict): + return { + key: "" if any(secret in key.lower() for secret in SENSITIVE_KEYS) else redact_payload(item) + for key, item in value.items() + } + if isinstance(value, list): + return [redact_payload(item) for item in value] + return value + + +def safe_post_data(raw: str | None) -> object: + if not raw: + return None + try: + return redact_payload(json.loads(raw)) + except json.JSONDecodeError: + pairs = parse_qsl(raw, keep_blank_values=True) + if pairs: + return redact_payload(dict(pairs)) + return raw[:4000] + + +def drain_network(driver: webdriver.Chrome) -> list[dict[str, object]]: + entries: list[dict[str, object]] = [] + for item in driver.get_log("performance"): + try: + message = json.loads(item["message"])["message"] + except (KeyError, TypeError, json.JSONDecodeError): + continue + method = message.get("method") + params = message.get("params") or {} + if method == "Network.requestWillBeSent": + request = params.get("request") or {} + url = str(request.get("url") or "") + if url.startswith("http"): + entries.append( + { + "event": "request", + "request_id": params.get("requestId"), + "method": request.get("method"), + "url": redact_url(url), + "header_names": sorted((request.get("headers") or {}).keys()), + "post_data": safe_post_data(request.get("postData")), + } + ) + elif method == "Network.responseReceived": + response = params.get("response") or {} + url = str(response.get("url") or "") + mime_type = str(response.get("mimeType") or "") + if not url.startswith("http") or "json" not in mime_type.lower(): + continue + body: object = None + try: + raw_body = driver.execute_cdp_cmd( + "Network.getResponseBody", + {"requestId": params.get("requestId")}, + ).get("body", "") + body = redact_payload(json.loads(raw_body)) + except (WebDriverException, json.JSONDecodeError, AttributeError): + body = None + entries.append( + { + "event": "response", + "request_id": params.get("requestId"), + "status": response.get("status"), + "url": redact_url(url), + "mime_type": mime_type, + "body": body, + } + ) + return entries + + +def main() -> int: + parser = argparse.ArgumentParser(description="로그인된 WEHAGO 계정별원장 화면의 API 요청을 안전하게 진단합니다.") + parser.add_argument("--year", type=int, default=2024, choices=refresh.YEARS) + parser.add_argument("--accounts", nargs="+", default=["223", "231"]) + parser.add_argument("--debugger-address", default="127.0.0.1:9225") + args = parser.parse_args() + + options = Options() + options.add_experimental_option("debuggerAddress", args.debugger_address) + options.set_capability("goog:loggingPrefs", {"performance": "ALL"}) + driver = webdriver.Chrome(options=options) + driver.set_page_load_timeout(30) + driver.set_script_timeout(15) + driver.execute_cdp_cmd("Network.enable", {}) + + target_by_code = {account.code: account for account in wehago.ACCOUNTS} + captures: list[dict[str, object]] = [] + try: + driver.get(refresh.HANMAC_LEDGER_URL.format(year=args.year, gisu=refresh.GISU_BY_YEAR[args.year])) + wehago.ensure_ledger_data_loaded(driver, wehago.ACCOUNTS) + drain_network(driver) + + for code in args.accounts: + account = target_by_code.get(code, wehago.Account(code, "")) + before = wehago.detail_grid_signature(driver) + wehago.select_account_from_left_list(driver, account) + wehago.assert_selected_account(driver, account) + time.sleep(1.5) + after = wehago.detail_grid_signature(driver) + captures.append( + { + "account_code": account.code, + "account_name": account.name, + "detail_changed": bool(after and after != before), + "detail_signature": after[:500], + "network": drain_network(driver), + } + ) + finally: + driver.quit() + + OUTPUT_DIR.mkdir(parents=True, exist_ok=True) + output = OUTPUT_DIR / f"wehago_ledger_api_{args.year}_{datetime.now():%Y%m%d_%H%M%S}.json" + output.write_text(json.dumps(captures, ensure_ascii=False, indent=2), encoding="utf-8") + print(output) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/materialize_wehago_2025_final_basis_projection.py b/scripts/materialize_wehago_2025_final_basis_projection.py new file mode 100644 index 0000000..1ef039f --- /dev/null +++ b/scripts/materialize_wehago_2025_final_basis_projection.py @@ -0,0 +1,679 @@ +#!/usr/bin/env python3 +"""Materialize the 2025 WEHAGO compare basis projection. + +The projection is voucher-key based: one WEHAGO voucher belongs to exactly one +of voucher_matched, voucher_recheck, voucher_unmatched, voucher_excepted. +""" + +from __future__ import annotations + +import json +import re +import sqlite3 +import sys +from collections import defaultdict +from pathlib import Path +from typing import Any +from difflib import SequenceMatcher + +from openpyxl import load_workbook +from sqlalchemy import create_engine + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + +from wehago_compare import ( + STATUS_PROJECTION_VERSION, + _json_safe_projection_payload, + _status_projection_setting_key, + _status_projection_signature, + clean, + parse_amount, +) + + +DB_PATH = Path("/runtime/db/data.db") +DB_URL = f"sqlite:///{DB_PATH}" +EXPORT_ROOT = Path("/runtime/exports/wehago_compare") +START_YEAR = 2025 +END_YEAR = 2025 + +SOURCE_FILES = { + "voucher_matched": EXPORT_ROOT / "wehago_compare_voucher_matched_2025_2025_20260628_131151.xlsx", + "voucher_recheck": EXPORT_ROOT / "wehago_compare_voucher_recheck_2025_2025_20260628_131152.xlsx", + "voucher_unmatched": EXPORT_ROOT / "wehago_compare_voucher_unmatched_2025_2025_20260628_131154.xlsx", + "voucher_excepted": EXPORT_ROOT / "wehago_compare_voucher_excepted_2025_2025_20260628_131156.xlsx", +} +WEHAGO_STATUS_KEYS = ( + "voucher_matched", + "voucher_unmatched", + "voucher_recheck", + "voucher_excepted", +) + +WORD_RE = re.compile(r"[가-힣A-Za-z0-9]+") +CONTEXT_STOP_WORDS = { + "주식회사", + "비용", + "수수료", + "지급", + "대금", + "관리", + "일반", + "보통", + "예금", + "외상", + "매입금", + "매입세액", + "원가", + "기타", + "국민", + "128528", + "종로중앙", + "주거래", + "국민카드", + "현대카드", + "KB국민카드", +} +SETTLEMENT_ACCOUNT_TOKENS = ( + "보통예금", + "현금", + "미지급금", + "외상매입금", + "외상매출금", + "용역미수금", + "단기차입금", + "예수금", + "기타예금", + "단기금융상품", +) +VAT_ACCOUNT_TOKENS = ("매입세액", "부가세대급금", "부가세") + +FORCED_RECHECK = { + ("2025", "02-21", "00176"): "사용자 판정: 카드 비용 전표와 ERP 미지급금 출금 전표가 전표 전체로는 동일 성격이 아니므로 recheck 유지", + ("2025", "04-23", "00007"): "사용자 판정: 카드 비용 전표와 ERP 미지급금 출금 전표가 전표 전체로는 동일 성격이 아니므로 recheck 유지", + ("2025", "03-31", "00048"): "사용자 판정: 상계 특수 사례는 일부 행만 대응 표시하고 전표 전체는 recheck 유지", + ("2025", "06-30", "00156"): "사용자 판정: 상계 특수 사례는 일부 행만 대응 표시하고 전표 전체는 recheck 유지", + ("2025", "01-01", "00063"): "전표 일부 직접 대응은 있으나 ERP 국고보조금/보통예금 잔여행이 있어 recheck 유지", + ("2025", "04-16", "50009"): "지급수수료/매입세액 직접 대응과 ERP 조정행이 함께 있어 unmatched가 아닌 recheck", + ("2025", "04-29", "00163"): "세금계산서 증빙일자 우선 확인 대상: 적요 월 불일치가 있어 recheck 유지", + ("2025", "10-14", "00001"): "기타예금/단기금융상품 동종 계정 후보이나 복수 ERP draft가 섞여 recheck 유지", +} +FORCED_MATCHED = { + ("2025", "01-10", "00018"): "사용자 판정: 용역미수금과 수입인지/세금과공과 차액 정산으로 금액 설명 가능", + ("2025", "01-01", "00036"): "전표 단위 검증: 금액·계정군·거래처·적요가 모두 일치하여 voucher 승격", + ("2025", "06-14", "00002"): "이자수익 전표 단위 검증: 보통예금·이자수익 금액/계정군 일치", + ("2025", "06-21", "00009"): "이자수익 전표 단위 검증: 보통예금·선납세금·이자수익 금액/계정군 일치", + ("2025", "06-21", "00011"): "이자수익 전표 단위 검증: 보통예금·선납세금·이자수익 금액/계정군 일치", + ("2025", "06-21", "00012"): "이자수익 전표 단위 검증: 보통예금·선납세금·이자수익 금액/계정군 일치", + ("2025", "04-25", "50003"): "복합전표 검증: 직접 대응 행과 같은 ERP draft 잔여행으로 설명 가능", + ("2025", "08-22", "00003"): "법인카드 비용 전표 검증: 핵심 비용 계정 금액이 ERP 일부 행과 일치", + ("2025", "06-27", "50018"): "부분 ERP draft 검증: WEHAGO 핵심 비용/부가세 행이 ERP 원본행 일부로 설명됨", + ("2025", "08-29", "00012"): "국고보조금 정산 전표 검증: 국고보조금 직접 대응행을 포함한 같은 draft 구조", + ("2025", "08-29", "00059"): "복수 WEHAGO 전표 묶음 검증: 00059/00060이 ERP 11-20250829-B0100-12를 함께 설명", + ("2025", "08-29", "00060"): "복수 WEHAGO 전표 묶음 검증: 00059/00060이 ERP 11-20250829-B0100-12를 함께 설명", + ("2025", "09-08", "00036"): "사용자 판정 특수사례: 여비교통비와 복리후생비(회식대)를 비용 계정군으로 보고 금액·거래처·문맥 일치", + ("2025", "09-29", "50009"): "전표 단위 검증: 미수금·외주비·매입세액 금액/계정군/거래처 일치", + ("2025", "12-10", "00021"): "지급/원매입 연결 검증: 외상매입금 직접 대응 및 같은 ERP draft 비용/부가세로 설명 가능", + ("2025", "07-03", "50008"): "사용자 판정: 잡이익/잡손실 차대 음양 정산으로 본질 금액 일치", +} +FORCED_UNMATCHED = { + ("2025", "08-07", "50005"): "취소/발행 반대방향 전표: 같은 차대 위치의 음수/양수 절대값 일치는 매칭 후보에서 제거", + ("2025", "06-13", "00038"): "금액 유사 오염 제거: 계정·거래처·적요가 다른 후보", + ("2025", "07-07", "00001"): "월/기간 불일치 후보 제거: ERP는 3~5월 합산, WEHAGO는 7월 전표", +} + + +def norm_text(value: Any) -> str: + return clean(value) + + +def norm_year(value: Any) -> str: + text = norm_text(value) + return text[:-2] if text.endswith(".0") else text + + +def norm_voucher_no(value: Any) -> str: + text = norm_text(value) + if text.endswith(".0"): + text = text[:-2] + return text.zfill(5) if text.isdigit() else text + + +def amount(value: Any) -> float: + return parse_amount(value) + + +def row_has_wehago(row: dict[str, Any]) -> bool: + return bool( + norm_text(row.get("ledger_account_name")) + or norm_text(row.get("ledger_vendor")) + or abs(amount(row.get("ledger_debit"))) >= 0.5 + or abs(amount(row.get("ledger_credit"))) >= 0.5 + ) + + +def row_has_erp(row: dict[str, Any]) -> bool: + return bool( + norm_text(row.get("voucher_account_name")) + or norm_text(row.get("voucher_vendor")) + or norm_text(row.get("draft_no")) + or abs(amount(row.get("voucher_debit"))) >= 0.5 + or abs(amount(row.get("voucher_credit"))) >= 0.5 + ) + + +def draft_base(value: Any) -> str: + text = norm_text(value) + return re.sub(r"-\d+$", "", text) + + +def context_words(value: Any) -> set[str]: + words = set() + for word in WORD_RE.findall(norm_text(value).replace("_", " ")): + if len(word) <= 1: + continue + if word in CONTEXT_STOP_WORDS: + continue + words.add(word) + return words + + +def text_similarity(left: Any, right: Any) -> float: + left_text = norm_text(left) + right_text = norm_text(right) + if not left_text or not right_text: + return 0.0 + return SequenceMatcher(None, left_text, right_text).ratio() + + +def any_context_overlap(left_rows: list[dict[str, Any]], right_rows: list[dict[str, Any]], left_key: str, right_key: str) -> bool: + left_words: set[str] = set() + right_words: set[str] = set() + for row in left_rows: + left_words.update(context_words(row.get(left_key))) + for row in right_rows: + right_words.update(context_words(row.get(right_key))) + return bool(left_words and right_words and left_words.intersection(right_words)) + + +def all_settlement_or_vat_accounts(rows: list[dict[str, Any]]) -> bool: + accounts: list[str] = [] + for row in rows: + for key in ("ledger_account_name", "voucher_account_name"): + account = norm_text(row.get(key)) + if account: + accounts.append(account) + if not accounts: + return False + for account in accounts: + if any(token in account for token in VAT_ACCOUNT_TOKENS): + continue + if not any(token in account for token in SETTLEMENT_ACCOUNT_TOKENS): + return False + return True + + +def has_amount_overlap(ledger_rows: list[dict[str, Any]], erp_rows: list[dict[str, Any]]) -> bool: + ledger_amounts: set[float] = set() + erp_amounts: set[float] = set() + for row in ledger_rows: + for key in ("ledger_debit", "ledger_credit"): + value = abs(amount(row.get(key))) + if value >= 0.5: + ledger_amounts.add(round(value, 2)) + for row in erp_rows: + for key in ("voucher_debit", "voucher_credit"): + value = abs(amount(row.get(key))) + if value >= 0.5: + erp_amounts.add(round(value, 2)) + return bool(ledger_amounts.intersection(erp_amounts)) + + +def has_strong_match_context(rows: list[dict[str, Any]]) -> bool: + ledger_rows = [row for row in rows if row_has_wehago(row)] + erp_rows = [row for row in rows if row_has_erp(row)] + if not ledger_rows or not erp_rows: + return False + if not has_amount_overlap(ledger_rows, erp_rows): + return False + vendor_overlap = any_context_overlap(ledger_rows, erp_rows, "ledger_vendor", "voucher_vendor") + desc_overlap = any_context_overlap(ledger_rows, erp_rows, "ledger_desc", "voucher_desc") + best_desc = max( + ( + text_similarity(ledger_row.get("ledger_desc"), erp_row.get("voucher_desc")) + for ledger_row in ledger_rows + for erp_row in erp_rows + ), + default=0.0, + ) + best_vendor = max( + ( + text_similarity(ledger_row.get("ledger_vendor"), erp_row.get("voucher_vendor")) + for ledger_row in ledger_rows + for erp_row in erp_rows + ), + default=0.0, + ) + if vendor_overlap or desc_overlap or best_desc >= 0.35 or best_vendor >= 0.58: + return True + if all_settlement_or_vat_accounts(ledger_rows + erp_rows): + return vendor_overlap or desc_overlap or best_desc >= 0.30 or best_vendor >= 0.58 + return False + + +def strip_erp_side(row: dict[str, Any]) -> dict[str, Any]: + cleaned = dict(row) + for key in ( + "draft_no", + "voucher_account_name", + "voucher_vendor", + "voucher_debit", + "voucher_credit", + "voucher_desc", + "voucher_row_key", + "match_identity_key", + ): + cleaned[key] = 0.0 if key in {"voucher_debit", "voucher_credit"} else "" + return cleaned + + +def strip_erp_candidates(rows: list[dict[str, Any]]) -> list[dict[str, Any]]: + cleaned_rows: list[dict[str, Any]] = [] + for row in rows: + if row_has_wehago(row): + cleaned_rows.append(strip_erp_side(row)) + return cleaned_rows + + +def same_side_opposite_sign_candidate(rows: list[dict[str, Any]]) -> bool: + for row in rows: + ledger_debit = amount(row.get("ledger_debit")) + ledger_credit = amount(row.get("ledger_credit")) + voucher_debit = amount(row.get("voucher_debit")) + voucher_credit = amount(row.get("voucher_credit")) + if abs(ledger_debit) >= 0.5 and abs(voucher_debit) >= 0.5 and ledger_debit * voucher_debit < 0: + return True + if abs(ledger_credit) >= 0.5 and abs(voucher_credit) >= 0.5 and ledger_credit * voucher_credit < 0: + return True + return False + + +def make_row(raw: tuple[Any, ...]) -> dict[str, Any]: + return { + "fiscal_year": int(norm_year(raw[0]) or START_YEAR), + "status_label": "", + "ledger_date": norm_text(raw[1]), + "proof_date": "", + "voucher_no": norm_voucher_no(raw[2]), + "draft_no": norm_text(raw[3]), + "ledger_account_name": norm_text(raw[4]), + "voucher_account_name": norm_text(raw[5]), + "ledger_vendor": norm_text(raw[6]), + "voucher_vendor": norm_text(raw[7]), + "ledger_debit": amount(raw[8]), + "ledger_credit": amount(raw[9]), + "voucher_debit": amount(raw[10]), + "voucher_credit": amount(raw[11]), + "ledger_desc": norm_text(raw[12]), + "voucher_desc": norm_text(raw[13]), + "review_reason": "", + "matched_case": "", + "ledger_row_key": "", + "voucher_row_key": "", + "match_identity_key": "", + } + + +def raw_erp_row_to_context(row: sqlite3.Row) -> dict[str, Any]: + voucher_debit = amount(row["debit_supply"]) + voucher_credit = amount(row["credit_supply"]) + desc = " ".join(part for part in [norm_text(row["desc1"]), norm_text(row["desc2"])] if part) + return { + "fiscal_year": int(row["fiscal_year"] or START_YEAR), + "status_label": "", + "ledger_date": "", + "proof_date": norm_text(row["proof_date"]), + "voucher_no": norm_text(row["confirmed_no"]), + "draft_no": norm_text(row["draft_no"]), + "ledger_account_name": "", + "voucher_account_name": norm_text(row["account_name"]), + "ledger_vendor": "", + "voucher_vendor": norm_text(row["vendor_name"]), + "ledger_debit": 0.0, + "ledger_credit": 0.0, + "voucher_debit": voucher_debit, + "voucher_credit": voucher_credit, + "ledger_desc": "", + "voucher_desc": desc, + "review_reason": "ERP_FULL_DRAFT_CONTEXT", + "matched_case": "DISPLAY_CONTEXT_ONLY", + "ledger_row_key": "", + "voucher_row_key": f"{norm_text(row['draft_no'])}|{norm_text(row['account_name'])}|{voucher_debit:.2f}|{voucher_credit:.2f}|{norm_text(row['vendor_name'])}|{desc}", + "match_identity_key": "", + } + + +def load_raw_erp_context(conn: sqlite3.Connection) -> dict[str, list[dict[str, Any]]]: + rows = conn.execute( + """ + SELECT fiscal_year, proof_date, confirmed_no, draft_no, account_name, + debit_supply, credit_supply, vendor_name, desc1, desc2, row_number, id + FROM wehago_voucher_rows + WHERE fiscal_year = ? AND COALESCE(draft_no, '') <> '' + ORDER BY draft_no ASC, row_number ASC, id ASC + """, + (START_YEAR,), + ).fetchall() + by_base: dict[str, list[dict[str, Any]]] = defaultdict(list) + for row in rows: + by_base[draft_base(row["draft_no"])].append(raw_erp_row_to_context(row)) + return by_base + + +def direct_erp_row_identity(row: dict[str, Any]) -> str: + return "|".join( + [ + norm_text(row.get("draft_no")), + norm_text(row.get("voucher_account_name")), + f"{amount(row.get('voucher_debit')):.2f}", + f"{amount(row.get('voucher_credit')):.2f}", + norm_text(row.get("voucher_vendor")), + norm_text(row.get("voucher_desc")), + ] + ) + + +def supplement_display_context(rows: list[dict[str, Any]], raw_erp_by_base: dict[str, list[dict[str, Any]]]) -> list[dict[str, Any]]: + bases = sorted({draft_base(row.get("draft_no")) for row in rows if norm_text(row.get("draft_no"))}) + if not bases: + return rows + completed = [dict(row) for row in rows] + seen = {direct_erp_row_identity(row) for row in completed if row_has_erp(row)} + for base in bases: + for context_row in raw_erp_by_base.get(base, []): + identity = direct_erp_row_identity(context_row) + if identity in seen: + continue + completed.append(dict(context_row)) + seen.add(identity) + return completed + + +def group_summary(key: tuple[str, str, str], rows: list[dict[str, Any]], status_key: str, reason: str = "") -> dict[str, Any]: + ledger_accounts = sorted({norm_text(row.get("ledger_account_name")) for row in rows if norm_text(row.get("ledger_account_name"))}) + voucher_accounts = sorted({norm_text(row.get("voucher_account_name")) for row in rows if norm_text(row.get("voucher_account_name"))}) + ledger_vendors = sorted({norm_text(row.get("ledger_vendor")) for row in rows if norm_text(row.get("ledger_vendor"))}) + voucher_vendors = sorted({norm_text(row.get("voucher_vendor")) for row in rows if norm_text(row.get("voucher_vendor"))}) + draft_numbers = sorted({norm_text(row.get("draft_no")) for row in rows if norm_text(row.get("draft_no"))}) + for row in rows: + row["status_label"] = { + "voucher_matched": "Voucher", + "voucher_recheck": "Recheck", + "voucher_unmatched": "Unmatched", + "voucher_excepted": "Excepted", + }.get(status_key, status_key) + if reason: + row["review_reason"] = " / ".join(part for part in [norm_text(row.get("review_reason")), reason] if part) + return { + "fiscal_year": int(key[0]), + "status_label": rows[0].get("status_label") if rows else "", + "ledger_date": key[1], + "proof_date": "", + "voucher_no": key[2], + "draft_no": ", ".join(draft_numbers), + "ledger_row_count": sum(1 for row in rows if row_has_wehago(row)), + "voucher_row_count": sum(1 for row in rows if row_has_erp(row)), + "ledger_debit": sum(amount(row.get("ledger_debit")) for row in rows), + "ledger_credit": sum(amount(row.get("ledger_credit")) for row in rows), + "voucher_debit": sum(amount(row.get("voucher_debit")) for row in rows), + "voucher_credit": sum(amount(row.get("voucher_credit")) for row in rows), + "ledger_accounts": ", ".join(ledger_accounts), + "voucher_accounts": ", ".join(voucher_accounts), + "ledger_vendors": ", ".join(ledger_vendors), + "voucher_vendors": ", ".join(voucher_vendors), + "review_reason": reason, + } + + +def load_groups() -> dict[str, dict[tuple[str, str, str], list[dict[str, Any]]]]: + groups: dict[str, dict[tuple[str, str, str], list[dict[str, Any]]]] = defaultdict(dict) + for status_key, path in SOURCE_FILES.items(): + if not path.exists(): + raise FileNotFoundError(path) + wb = load_workbook(path, read_only=True, data_only=True) + ws = wb.active + header = [norm_text(cell) for cell in next(ws.iter_rows(min_row=1, max_row=1, values_only=True))] + expected = ["연도", "WEHAGO 일자", "전표번호", "가전표번호", "WEHAGO 계정", "ERP 계정", "WEHAGO 거래처", "ERP 거래처", "WEHAGO 차변", "WEHAGO 대변", "ERP 차변", "ERP 대변", "WEHAGO 적요", "ERP 적요"] + if header[: len(expected)] != expected: + raise RuntimeError(f"Unexpected header for {path.name}: {header}") + current_key: tuple[str, str, str] | None = None + for raw in ws.iter_rows(min_row=2, values_only=True): + year = norm_year(raw[0]) + date = norm_text(raw[1]) + voucher_no = norm_voucher_no(raw[2]) + if year and date and voucher_no: + current_key = (year, date, voucher_no) + groups[status_key].setdefault(current_key, []) + if current_key is None: + continue + groups[status_key].setdefault(current_key, []).append(make_row(raw)) + return groups + + +def resolve_groups(source: dict[str, dict[tuple[str, str, str], list[dict[str, Any]]]]) -> dict[str, dict[tuple[str, str, str], list[dict[str, Any]]]]: + all_keys = set().union(*(set(groups) for groups in source.values())) + resolved: dict[str, dict[tuple[str, str, str], list[dict[str, Any]]]] = defaultdict(dict) + for key in sorted(all_keys): + source_statuses = [status for status in SOURCE_FILES if key in source.get(status, {})] + if key in FORCED_UNMATCHED: + target_status = "voucher_unmatched" + elif key in FORCED_RECHECK: + target_status = "voucher_recheck" + elif key in FORCED_MATCHED: + target_status = "voucher_matched" + elif "voucher_matched" in source_statuses: + target_status = "voucher_matched" + elif "voucher_recheck" in source_statuses: + target_status = "voucher_recheck" + elif "voucher_unmatched" in source_statuses: + target_status = "voucher_unmatched" + else: + target_status = "voucher_excepted" + source_status = target_status if key in source.get(target_status, {}) else source_statuses[0] + rows = [dict(row) for row in source[source_status][key]] + if target_status == "voucher_unmatched": + rows = strip_erp_candidates(rows) + elif same_side_opposite_sign_candidate(rows) and key not in FORCED_MATCHED: + target_status = "voucher_unmatched" + rows = strip_erp_candidates(rows) + elif target_status == "voucher_matched" and key not in FORCED_MATCHED and not has_strong_match_context(rows): + target_status = "voucher_unmatched" + rows = strip_erp_candidates(rows) + resolved[target_status][key] = rows + return resolved + + +def expected_signatures() -> dict[str, str]: + engine = create_engine(DB_URL) + with engine.begin() as conn: + return { + status_key: _status_projection_signature(conn, status_key, START_YEAR, END_YEAR) + for status_key in WEHAGO_STATUS_KEYS + } + + +def store_active_signature(conn: sqlite3.Connection, status_key: str, signature: str, total_count: int) -> None: + payload = { + "signature": signature, + "status_key": status_key, + "start_year": START_YEAR, + "end_year": END_YEAR, + "total_count": int(total_count or 0), + "projection_version": STATUS_PROJECTION_VERSION, + } + 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 + """, + ( + _status_projection_setting_key(status_key, START_YEAR, END_YEAR), + json.dumps(payload, ensure_ascii=False), + ), + ) + + +def insert_projection( + conn: sqlite3.Connection, + resolved: dict[str, dict[tuple[str, str, str], list[dict[str, Any]]]], + signatures: dict[str, str], +) -> dict[str, int]: + counts: dict[str, int] = {} + statuses = list(WEHAGO_STATUS_KEYS) + raw_erp_by_base = load_raw_erp_context(conn) + for status_key in statuses: + signature = signatures.get(status_key, "") + if not signature: + raise RuntimeError(f"Missing expected signature for {status_key}") + conn.execute( + """ + DELETE FROM wehago_status_projection_groups + WHERE start_year = ? AND end_year = ? AND status_key = ? + """, + (START_YEAR, END_YEAR, status_key), + ) + groups = resolved.get(status_key, {}) + payloads = [] + for group_index, (key, rows) in enumerate(sorted(groups.items())): + reason = ( + FORCED_RECHECK.get(key) + or FORCED_MATCHED.get(key) + or FORCED_UNMATCHED.get(key) + or "원천 재검증 기반 최종 projection" + ) + display_rows = supplement_display_context(rows, raw_erp_by_base) if status_key in {"voucher_matched", "voucher_recheck"} else rows + summary = group_summary(key, display_rows, status_key, reason) + search_text = " ".join( + [ + norm_text(summary.get("voucher_no")), + norm_text(summary.get("draft_no")), + norm_text(summary.get("ledger_accounts")), + norm_text(summary.get("voucher_accounts")), + norm_text(summary.get("ledger_vendors")), + norm_text(summary.get("voucher_vendors")), + norm_text(summary.get("review_reason")), + " ".join(norm_text(row.get("ledger_desc")) for row in display_rows), + " ".join(norm_text(row.get("voucher_desc")) for row in display_rows), + ] + ) + payloads.append( + ( + START_YEAR, + END_YEAR, + status_key, + signature, + group_index, + int(summary.get("fiscal_year") or 0), + norm_text(summary.get("ledger_date")), + norm_text(summary.get("proof_date")), + norm_text(summary.get("voucher_no")), + norm_text(summary.get("draft_no")), + int(summary.get("ledger_row_count") or 0), + int(summary.get("voucher_row_count") or 0), + amount(summary.get("ledger_debit")), + amount(summary.get("ledger_credit")), + amount(summary.get("voucher_debit")), + amount(summary.get("voucher_credit")), + norm_text(summary.get("ledger_accounts")), + norm_text(summary.get("voucher_accounts")), + norm_text(summary.get("ledger_vendors")), + norm_text(summary.get("voucher_vendors")), + norm_text(summary.get("review_reason")), + search_text, + json.dumps(_json_safe_projection_payload(summary), ensure_ascii=False, separators=(",", ":")), + json.dumps(_json_safe_projection_payload(display_rows), ensure_ascii=False, separators=(",", ":")), + ) + ) + conn.executemany( + """ + INSERT INTO wehago_status_projection_groups ( + start_year, end_year, status_key, signature, group_index, + fiscal_year, ledger_date, proof_date, voucher_no, draft_no, + ledger_row_count, voucher_row_count, + ledger_debit, ledger_credit, voucher_debit, voucher_credit, + ledger_accounts, voucher_accounts, ledger_vendors, voucher_vendors, + review_reason, search_text, summary_json, rows_json, + created_at, updated_at + ) VALUES ( + ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, + CURRENT_TIMESTAMP, CURRENT_TIMESTAMP + ) + """, + payloads, + ) + store_active_signature(conn, status_key, signature, len(payloads)) + counts[status_key] = len(payloads) + return counts + + +def cleanup_stale_runtime(conn: sqlite3.Connection) -> None: + conn.execute("DELETE FROM wehago_status_projection_groups WHERE start_year = ? AND end_year = ?", (START_YEAR, END_YEAR)) + conn.execute("DELETE FROM wehago_compare_query_groups WHERE start_year = ? AND end_year = ?", (START_YEAR, END_YEAR)) + conn.execute("DELETE FROM wehago_compare_query_rows WHERE start_year = ? AND end_year = ?", (START_YEAR, END_YEAR)) + conn.execute("DELETE FROM wehago_compare_query_metrics WHERE start_year = ? AND end_year = ?", (START_YEAR, END_YEAR)) + conn.execute("DELETE FROM wehago_compare_query_page_cache WHERE start_year = ? AND end_year = ?", (START_YEAR, END_YEAR)) + conn.execute("DELETE FROM wehago_compare_final_status_projection WHERE start_year = ? AND end_year = ?", (START_YEAR, END_YEAR)) + conn.execute("DELETE FROM wehago_compare_export_jobs WHERE start_year = ? AND end_year = ?", (START_YEAR, END_YEAR)) + conn.execute("DELETE FROM wehago_compare_settings WHERE setting_key = ?", (f"wehago_active_query_projection:{START_YEAR}:{END_YEAR}",)) + conn.execute("DELETE FROM wehago_compare_settings WHERE setting_key = ?", (f"wehago_active_status_projection_run:{START_YEAR}:{END_YEAR}",)) + conn.execute("DELETE FROM wehago_compare_settings WHERE setting_key LIKE ?", (f"wehago_active_status_projection:%:{START_YEAR}:{END_YEAR}",)) + conn.execute("DELETE FROM wehago_compare_settings WHERE setting_key = ?", (f"wehago_final_basis_projection:{START_YEAR}:{END_YEAR}",)) + + +def main() -> None: + source = load_groups() + resolved = resolve_groups(source) + signatures = expected_signatures() + conn = sqlite3.connect(DB_PATH) + conn.row_factory = sqlite3.Row + try: + conn.execute("PRAGMA busy_timeout = 120000") + conn.execute("BEGIN IMMEDIATE") + cleanup_stale_runtime(conn) + counts = insert_projection(conn, resolved, signatures) + total = sum(counts.get(status, 0) for status in ("voucher_matched", "voucher_unmatched", "voucher_recheck", "voucher_excepted")) + basis = { + "source": "raw revalidated projection; legacy export/cache only used as candidate seed, not as final authority", + "counts": counts, + "wehago_total": total, + "forced_recheck": sorted("-".join(key) for key in FORCED_RECHECK), + "forced_matched": sorted("-".join(key) for key in FORCED_MATCHED), + "forced_unmatched": sorted("-".join(key) for key in FORCED_UNMATCHED), + } + 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_final_basis_projection:{START_YEAR}:{END_YEAR}", + json.dumps(basis, ensure_ascii=False, separators=(",", ":")), + ), + ) + conn.commit() + except Exception: + conn.rollback() + raise + finally: + conn.close() + print(json.dumps({"counts": counts, "wehago_total": total}, ensure_ascii=False, sort_keys=True)) + + +if __name__ == "__main__": + main() diff --git a/scripts/materialize_wehago_2025_integrity_projection.py b/scripts/materialize_wehago_2025_integrity_projection.py new file mode 100644 index 0000000..47a4811 --- /dev/null +++ b/scripts/materialize_wehago_2025_integrity_projection.py @@ -0,0 +1,700 @@ +#!/usr/bin/env python3 +"""Materialize the 2025 WEHAGO compare projection from canonical voucher keys. + +This script is intentionally conservative. It uses the current projection as a +candidate seed, but the final WEHAGO population is the raw ledger voucher set. +That prevents stale export rows, display-date variants, and partial context rows +from changing card/table/export counts. +""" + +from __future__ import annotations + +import json +import re +import sqlite3 +from collections import Counter, defaultdict +from pathlib import Path +from typing import Any + +START_YEAR = 2025 +END_YEAR = 2025 +DB_PATH = Path("/runtime/db/data.db") + +WEHAGO_STATUSES = ( + "voucher_matched", + "voucher_unmatched", + "voucher_recheck", + "voucher_excepted", +) +STATUS_LABELS = { + "voucher_matched": "Voucher", + "voucher_unmatched": "Unmatched", + "voucher_recheck": "Recheck", + "voucher_excepted": "Excepted", +} + +PROMOTE_TO_MATCHED = { + (2025, "04-29", "00163"): "최종 validator: proof_date와 전표일자 일치, 금액·계정·거래처 문맥 일치", + (2025, "09-24", "50036"): "최종 validator: proof_date와 전표일자 일치, 음수 조정 방향·계정·금액 일치", + (2025, "10-14", "00001"): "최종 validator: 자금성 전표 기타예금/단기금융상품(기타예금) 동치 및 보통예금 금액 일치", + (2025, "10-27", "00002"): "사용자 지정 오타 허용 특수 승격: 금액·계정·거래처 일치", + (2025, "10-28", "00001"): "최종 validator: 원천 ERP 외부 후보 탐색 대상 포함, 자금성 전표 동치 규칙 적용", + (2025, "10-30", "00005"): "사용자 지정 오타 허용 특수 승격: 금액·계정 일치", + (2025, "11-24", "00106"): "사용자 지정 오타 허용 특수 승격: 통신비 금액·거래처 문맥 일치", + (2025, "11-25", "00009"): "최종 validator: 비용 차감 전표 금액·계정·거래 문맥 일치", +} + +KEEP_RECHECK = { + (2025, "01-01", "00063"): "전표 일부 직접 대응은 있으나 ERP 국고보조금/보통예금 잔여행 검토 필요", + (2025, "02-21", "00176"): "사용자 판정: 카드 비용 전표와 ERP 미지급금 출금 전표가 전표 전체로는 동일 성격이 아님", + (2025, "03-31", "00048"): "사용자 판정: 상계 특수 사례는 일부 행만 대응 표시하고 전표 전체는 recheck 유지", + (2025, "04-16", "50009"): "지급수수료/매입세액 직접 대응과 ERP 조정행이 함께 있어 recheck 유지", + (2025, "04-23", "00007"): "사용자 판정: 카드 비용 전표와 ERP 미지급금 출금 전표가 전표 전체로는 동일 성격이 아님", + (2025, "06-30", "00156"): "사용자 판정: 상계 특수 사례는 일부 행만 대응 표시하고 전표 전체는 recheck 유지", + (2025, "09-22", "50046"): "VAT/proof_date 우선 검증: 현재 ERP 후보 proof_date가 WEHAGO 전표일자와 불일치", + (2025, "12-19", "50036"): "VAT/proof_date 우선 검증: 현재 ERP 후보 proof_date가 WEHAGO 전표일자와 불일치", + (2025, "12-25", "50006"): "VAT/proof_date 우선 검증: 현재 ERP 후보 proof_date가 WEHAGO 전표일자와 불일치", +} + +FORCE_UNMATCHED = { + (2025, "08-07", "50005"): "취소/발행 반대방향 전표: 같은 차대 위치의 음수/양수 절대값 일치는 매칭 후보에서 제거", + (2025, "06-13", "00038"): "금액 유사 오염 제거: 계정·거래처·적요가 다른 후보", + (2025, "07-07", "00001"): "월/기간 불일치 후보 제거: ERP는 3~5월 합산, WEHAGO는 7월 전표", +} + + +EXCEPTED_RETAIN_RECHECK_KEYS = { + (2025, "03-31", "00048"), + (2025, "06-30", "00156"), +} + + +def clean(value: Any) -> str: + if value is None: + return "" + text = str(value).strip() + if text.endswith(".0") and text[:-2].isdigit(): + return text[:-2] + return text + + +def amount(value: Any) -> float: + if value is None or value == "": + return 0.0 + if isinstance(value, (int, float)): + return float(value) + text = str(value).replace(",", "").strip() + if not text: + return 0.0 + try: + return float(text) + except ValueError: + return 0.0 + + +def display_date(value: Any) -> str: + text = clean(value) + match = re.match(r"^(\d{4})-(\d{2})-(\d{2})$", text) + if match: + return f"{match.group(2)}-{match.group(3)}" + match = re.match(r"^(\d{2})-(\d{2})$", text) + if match: + return text + return text + + +def date_key(year: int, mmdd: str) -> str: + return f"{year}-{mmdd}" if re.match(r"^\d{2}-\d{2}$", mmdd) else mmdd + + +def voucher_no(value: Any) -> str: + text = clean(value) + return text.zfill(5) if text.isdigit() else text + + +def draft_base(value: Any) -> str: + return re.sub(r"-\d+$", "", clean(value)) + + +def row_has_wehago(row: dict[str, Any]) -> bool: + return bool( + clean(row.get("ledger_account_name")) + or clean(row.get("ledger_vendor")) + or abs(amount(row.get("ledger_debit"))) >= 0.5 + or abs(amount(row.get("ledger_credit"))) >= 0.5 + ) + + +def row_has_erp(row: dict[str, Any]) -> bool: + return bool( + clean(row.get("draft_no")) + or clean(row.get("voucher_account_name")) + or clean(row.get("voucher_vendor")) + or abs(amount(row.get("voucher_debit"))) >= 0.5 + or abs(amount(row.get("voucher_credit"))) >= 0.5 + ) + + +def normalize_row(row: dict[str, Any]) -> dict[str, Any]: + normalized = dict(row) + normalized["fiscal_year"] = int(normalized.get("fiscal_year") or START_YEAR) + normalized["ledger_date"] = display_date(normalized.get("ledger_date")) + normalized["voucher_no"] = voucher_no(normalized.get("voucher_no")) + for key in ("ledger_debit", "ledger_credit", "voucher_debit", "voucher_credit"): + normalized[key] = amount(normalized.get(key)) + return normalized + + +def load_current_projection(conn: sqlite3.Connection) -> dict[tuple[int, str, str], dict[str, Any]]: + by_key: dict[tuple[int, str, str], dict[str, Any]] = {} + rows = conn.execute( + """ + SELECT * + FROM wehago_status_projection_groups + WHERE start_year = ? AND end_year = ? + AND status_key IN ('voucher_matched', 'voucher_unmatched', 'voucher_recheck', 'voucher_excepted') + ORDER BY status_key, group_index + """, + (START_YEAR, END_YEAR), + ).fetchall() + status_rank = {"voucher_matched": 4, "voucher_recheck": 3, "voucher_unmatched": 2, "voucher_excepted": 1} + for db_row in rows: + year = int(db_row["fiscal_year"] or START_YEAR) + key = (year, display_date(db_row["ledger_date"]), voucher_no(db_row["voucher_no"])) + payload = dict(db_row) + try: + detail_rows = json.loads(db_row["rows_json"] or "[]") + except Exception: + detail_rows = [] + payload["rows"] = [normalize_row(row) for row in detail_rows if isinstance(row, dict)] + existing = by_key.get(key) + if not existing: + by_key[key] = payload + continue + existing_iso = re.match(r"^\d{4}-", clean(existing.get("ledger_date") or "")) + payload_iso = re.match(r"^\d{4}-", clean(payload.get("ledger_date") or "")) + if existing_iso and not payload_iso: + by_key[key] = payload + elif bool(existing_iso) == bool(payload_iso) and status_rank.get(payload["status_key"], 0) > status_rank.get(existing["status_key"], 0): + by_key[key] = payload + return by_key + + +def raw_wehago_row(raw: sqlite3.Row) -> dict[str, Any]: + year = int(raw["fiscal_year"] or START_YEAR) + mmdd = display_date(raw["ledger_date"]) + return { + "fiscal_year": year, + "status_label": "", + "ledger_date": mmdd, + "ledger_date_key": date_key(year, mmdd), + "proof_date": "", + "voucher_no": voucher_no(raw["voucher_no"]), + "draft_no": "", + "ledger_account_name": clean(raw["account_name"]), + "voucher_account_name": "", + "ledger_vendor": clean(raw["vendor_name"]), + "voucher_vendor": "", + "ledger_debit": amount(raw["debit"]), + "ledger_credit": amount(raw["credit"]), + "voucher_debit": 0.0, + "voucher_credit": 0.0, + "ledger_desc": clean(raw["description"]), + "voucher_desc": "", + "review_reason": "WEHAGO_FULL_VOUCHER_CONTEXT", + "matched_case": "WEHAGO_CONTEXT_ONLY", + "ledger_row_key": f"{raw['id']}", + "voucher_row_key": "", + "match_identity_key": "", + } + + +def load_raw_wehago(conn: sqlite3.Connection) -> dict[tuple[int, str, str], list[dict[str, Any]]]: + by_key: dict[tuple[int, str, str], list[dict[str, Any]]] = defaultdict(list) + rows = conn.execute( + """ + SELECT id, fiscal_year, ledger_date, voucher_no, account_name, vendor_name, + debit, credit, description, row_number + FROM wehago_ledger_rows + WHERE fiscal_year = ? + AND COALESCE(compare_voucher_no, '') <> '' + ORDER BY ledger_date, voucher_no, row_number, id + """, + (START_YEAR,), + ).fetchall() + for raw in rows: + row = raw_wehago_row(raw) + key = (int(row["fiscal_year"]), row["ledger_date"], row["voucher_no"]) + by_key[key].append(row) + return by_key + + +def raw_erp_row(raw: sqlite3.Row) -> dict[str, Any]: + desc = " ".join(part for part in [clean(raw["desc1"]), clean(raw["desc2"])] if part) + return { + "fiscal_year": int(raw["fiscal_year"] or START_YEAR), + "status_label": "", + "ledger_date": "", + "proof_date": clean(raw["proof_date"]), + "voucher_no": clean(raw["confirmed_no"]), + "draft_no": clean(raw["draft_no"]), + "ledger_account_name": "", + "voucher_account_name": clean(raw["account_name"]), + "ledger_vendor": "", + "voucher_vendor": clean(raw["vendor_name"]), + "ledger_debit": 0.0, + "ledger_credit": 0.0, + "voucher_debit": amount(raw["debit_supply"]), + "voucher_credit": amount(raw["credit_supply"]), + "ledger_desc": "", + "voucher_desc": desc, + "review_reason": "ERP_FULL_DRAFT_CONTEXT", + "matched_case": "ERP_CONTEXT_ONLY", + "ledger_row_key": "", + "voucher_row_key": f"{raw['id']}", + "match_identity_key": "", + } + + +def load_raw_erp(conn: sqlite3.Connection) -> dict[str, list[dict[str, Any]]]: + by_base: dict[str, list[dict[str, Any]]] = defaultdict(list) + rows = conn.execute( + """ + SELECT id, fiscal_year, proof_date, confirmed_no, draft_no, account_name, + debit_supply, credit_supply, vendor_name, desc1, desc2, row_number + FROM wehago_voucher_rows + WHERE fiscal_year = ? AND COALESCE(draft_no, '') <> '' + ORDER BY draft_no, row_number, id + """, + (START_YEAR,), + ).fetchall() + for raw in rows: + by_base[draft_base(raw["draft_no"])].append(raw_erp_row(raw)) + return by_base + + +def identity(row: dict[str, Any], side: str) -> str: + if side == "wehago": + return "|".join( + [ + display_date(row.get("ledger_date")), + voucher_no(row.get("voucher_no")), + clean(row.get("ledger_account_name")), + clean(row.get("ledger_vendor")), + f"{amount(row.get('ledger_debit')):.2f}", + f"{amount(row.get('ledger_credit')):.2f}", + clean(row.get("ledger_desc")), + ] + ) + return "|".join( + [ + clean(row.get("draft_no")), + clean(row.get("voucher_account_name")), + clean(row.get("voucher_vendor")), + f"{amount(row.get('voucher_debit')):.2f}", + f"{amount(row.get('voucher_credit')):.2f}", + clean(row.get("voucher_desc")), + ] + ) + + +def blank_row() -> dict[str, Any]: + return { + "fiscal_year": START_YEAR, + "status_label": "", + "ledger_date": "", + "ledger_date_key": "", + "proof_date": "", + "voucher_no": "", + "draft_no": "", + "ledger_account_name": "", + "voucher_account_name": "", + "ledger_vendor": "", + "voucher_vendor": "", + "ledger_debit": 0.0, + "ledger_credit": 0.0, + "voucher_debit": 0.0, + "voucher_credit": 0.0, + "ledger_desc": "", + "voucher_desc": "", + "review_reason": "", + "matched_case": "", + "ledger_row_key": "", + "voucher_row_key": "", + "match_identity_key": "", + } + + +def combine_context_rows(wehago_row: dict[str, Any] | None, erp_row: dict[str, Any] | None) -> dict[str, Any]: + left = wehago_row or blank_row() + right = erp_row or blank_row() + row = blank_row() + row.update( + { + "fiscal_year": int(left.get("fiscal_year") or right.get("fiscal_year") or START_YEAR), + "status_label": "", + "ledger_date": display_date(left.get("ledger_date")), + "ledger_date_key": clean(left.get("ledger_date_key")), + "proof_date": clean(right.get("proof_date")), + "voucher_no": voucher_no(left.get("voucher_no")), + "draft_no": clean(right.get("draft_no")), + "ledger_account_name": clean(left.get("ledger_account_name")), + "voucher_account_name": clean(right.get("voucher_account_name")), + "ledger_vendor": clean(left.get("ledger_vendor")), + "voucher_vendor": clean(right.get("voucher_vendor")), + "ledger_debit": amount(left.get("ledger_debit")), + "ledger_credit": amount(left.get("ledger_credit")), + "voucher_debit": amount(right.get("voucher_debit")), + "voucher_credit": amount(right.get("voucher_credit")), + "ledger_desc": clean(left.get("ledger_desc")), + "voucher_desc": clean(right.get("voucher_desc")), + "review_reason": "FULL_VOUCHER_CONTEXT", + "matched_case": "FULL_CONTEXT_ALIGNED", + "ledger_row_key": clean(left.get("ledger_row_key")), + "voucher_row_key": clean(right.get("voucher_row_key")), + "match_identity_key": "", + } + ) + if not wehago_row: + row["matched_case"] = "ERP_CONTEXT_ONLY" + elif not erp_row: + row["matched_case"] = "WEHAGO_CONTEXT_ONLY" + return row + + +def hydrate_rows( + status_key: str, + seed_rows: list[dict[str, Any]], + raw_wehago_rows: list[dict[str, Any]], + raw_erp_by_base: dict[str, list[dict[str, Any]]], +) -> list[dict[str, Any]]: + if status_key in {"voucher_unmatched", "voucher_excepted"}: + rows = [dict(row) for row in raw_wehago_rows] + else: + bases = sorted({draft_base(row.get("draft_no")) for row in seed_rows if clean(row.get("draft_no"))}) + erp_context: list[dict[str, Any]] = [] + seen_erp: set[str] = set() + for base in bases: + for erp_row in raw_erp_by_base.get(base, []): + row_id = identity(erp_row, "erp") + if row_id in seen_erp: + continue + erp_context.append(dict(erp_row)) + seen_erp.add(row_id) + row_count = max(len(raw_wehago_rows), len(erp_context)) + rows = [ + combine_context_rows( + raw_wehago_rows[index] if index < len(raw_wehago_rows) else None, + erp_context[index] if index < len(erp_context) else None, + ) + for index in range(row_count) + ] + label = STATUS_LABELS.get(status_key, status_key) + for row in rows: + row["status_label"] = label + row["ledger_date"] = display_date(row.get("ledger_date")) + return rows + + +def determine_status(key: tuple[int, str, str], seed: dict[str, Any] | None) -> tuple[str, str]: + if key in FORCE_UNMATCHED: + return "voucher_unmatched", FORCE_UNMATCHED[key] + if key in PROMOTE_TO_MATCHED: + return "voucher_matched", PROMOTE_TO_MATCHED[key] + if key in KEEP_RECHECK: + return "voucher_recheck", KEEP_RECHECK[key] + if seed: + status = clean(seed.get("status_key")) + if status in WEHAGO_STATUSES: + return status, clean(seed.get("review_reason")) or "원천 WEHAGO 기준 integrity projection" + return "voucher_unmatched", "원천 WEHAGO 전표 복원: 검증된 ERP 후보 없음" + + +def revised_excepted_reason(key: tuple[int, str, str], status_key: str, rows: list[dict[str, Any]], summary: dict[str, Any]) -> str: + if status_key not in {"voucher_unmatched", "voucher_recheck"}: + return "" + if status_key == "voucher_recheck" and key in EXCEPTED_RETAIN_RECHECK_KEYS: + return "" + text = " ".join( + [ + clean(summary.get("ledger_accounts")), + clean(summary.get("ledger_vendors")), + clean(summary.get("review_reason")), + " ".join(clean(row.get("ledger_desc")) for row in rows), + " ".join(clean(row.get("ledger_account_name")) for row in rows), + ] + ) + compact = text.replace(" ", "") + if re.search(r"상계\s*\d+(?:-\d+)?\s*소구", text) or re.search(r"상계\d+(?:-\d+)?소구", compact): + return "" + tax_work_tokens = ( + "부가세예수금과상계", + "부가세대급금과상계", + "부가예수금과상계", + "부가대급금과상계", + ) + if any(token in compact for token in tax_work_tokens): + return "" + rules = ( + ("WEHAGO_EXCEPTED_COST_SUBSTITUTION", (r"원가.*대체",)), + ("WEHAGO_EXCEPTED_GOV_GRANT_OFFSET", (r"국고보조금과상계",)), + ("WEHAGO_EXCEPTED_EMPLOYEE_PORTION_SUBSTITUTION", (r"본인분대체",)), + ("WEHAGO_EXCEPTED_OTHER_ACCOUNT_SUBSTITUTION", (r"타계정.*대체",)), + ( + "WEHAGO_EXCEPTED_ACCOUNT_SUBSTITUTION", + ( + r"계정대체", + r"계정.*대체", + r"거래처대체", + r"거래처.*대체", + r"대체분개", + r"전기대체", + r"전년대체", + r"전년도대체", + r"결산대체", + r"감사대체", + r"환입대체", + r"계상분대체", + ), + ), + ( + "WEHAGO_EXCEPTED_RETAINED_EARNINGS", + (r"잉여금.*대체", r"미처분이익잉여금", r"이월이익잉여금", r"차기이월"), + ), + ("WEHAGO_EXCEPTED_CARRY_FORWARD", (r"전기이월", r"기초이월", r"이월")), + ("WEHAGO_EXCEPTED_VALUATION_GAIN_LOSS", (r"평가이익", r"평가손실", r"외화평가")), + ("WEHAGO_EXCEPTED_WIP_CLOSING", (r"미완성공사도급",)), + ("WEHAGO_EXCEPTED_PROFIT_LOSS_TRANSFER", (r"손익.*대체", r"당기순손익.*대체", r"수익에서대체")), + ) + for reason, patterns in rules: + if any(re.search(pattern, compact) for pattern in patterns): + return reason + return "" + + +def summarize(key: tuple[int, str, str], status_key: str, rows: list[dict[str, Any]], reason: str) -> dict[str, Any]: + ledger_accounts = sorted({clean(row.get("ledger_account_name")) for row in rows if clean(row.get("ledger_account_name"))}) + voucher_accounts = sorted({clean(row.get("voucher_account_name")) for row in rows if clean(row.get("voucher_account_name"))}) + ledger_vendors = sorted({clean(row.get("ledger_vendor")) for row in rows if clean(row.get("ledger_vendor"))}) + voucher_vendors = sorted({clean(row.get("voucher_vendor")) for row in rows if clean(row.get("voucher_vendor"))}) + drafts = sorted({clean(row.get("draft_no")) for row in rows if clean(row.get("draft_no"))}) + ledger_basis = [row for row in rows if row_has_wehago(row)] + erp_basis = [row for row in rows if row_has_erp(row)] + return { + "fiscal_year": key[0], + "status_label": STATUS_LABELS.get(status_key, status_key), + "ledger_date": key[1], + "ledger_date_key": date_key(key[0], key[1]), + "proof_date": "", + "voucher_no": key[2], + "draft_no": ", ".join(drafts), + "ledger_row_count": len(ledger_basis), + "voucher_row_count": len(erp_basis), + "ledger_debit": sum(amount(row.get("ledger_debit")) for row in ledger_basis), + "ledger_credit": sum(amount(row.get("ledger_credit")) for row in ledger_basis), + "voucher_debit": sum(amount(row.get("voucher_debit")) for row in erp_basis), + "voucher_credit": sum(amount(row.get("voucher_credit")) for row in erp_basis), + "ledger_accounts": ", ".join(ledger_accounts), + "voucher_accounts": ", ".join(voucher_accounts), + "ledger_vendors": ", ".join(ledger_vendors), + "voucher_vendors": ", ".join(voucher_vendors), + "review_reason": reason, + } + + +def get_signatures(conn: sqlite3.Connection) -> dict[str, str]: + result: dict[str, str] = {} + for status in WEHAGO_STATUSES: + row = conn.execute( + """ + SELECT signature + FROM wehago_status_projection_groups + WHERE start_year = ? AND end_year = ? AND status_key = ? + LIMIT 1 + """, + (START_YEAR, END_YEAR, status), + ).fetchone() + result[status] = clean(row["signature"] if row else "") or f"integrity:{status}:{START_YEAR}:{END_YEAR}" + return result + + +def json_payload(value: Any) -> str: + return json.dumps(value, ensure_ascii=False, separators=(",", ":")) + + +def store_setting(conn: sqlite3.Connection, status_key: str, signature: str, count: int) -> None: + setting_key = f"wehago_active_status_projection:{status_key}:{START_YEAR}:{END_YEAR}" + payload = { + "signature": signature, + "status_key": status_key, + "start_year": START_YEAR, + "end_year": END_YEAR, + "total_count": count, + "projection_version": "integrity-2025-20260629", + } + 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 + """, + (setting_key, json_payload(payload)), + ) + + +def cleanup_runtime(conn: sqlite3.Connection) -> None: + conn.execute("DELETE FROM wehago_status_projection_groups WHERE start_year = ? AND end_year = ?", (START_YEAR, END_YEAR)) + for table in ( + "wehago_compare_query_groups", + "wehago_compare_query_rows", + "wehago_compare_query_metrics", + "wehago_compare_query_page_cache", + "wehago_compare_final_status_projection", + "wehago_compare_export_jobs", + ): + conn.execute(f"DELETE FROM {table} WHERE start_year = ? AND end_year = ?", (START_YEAR, END_YEAR)) + conn.execute("DELETE FROM wehago_compare_settings WHERE setting_key = ?", (f"wehago_active_query_projection:{START_YEAR}:{END_YEAR}",)) + conn.execute("DELETE FROM wehago_compare_settings WHERE setting_key = ?", (f"wehago_active_status_projection_run:{START_YEAR}:{END_YEAR}",)) + conn.execute("DELETE FROM wehago_compare_settings WHERE setting_key LIKE ?", (f"wehago_active_status_projection:%:{START_YEAR}:{END_YEAR}",)) + conn.execute("DELETE FROM wehago_compare_settings WHERE setting_key = ?", (f"wehago_final_basis_projection:{START_YEAR}:{END_YEAR}",)) + + +def materialize(conn: sqlite3.Connection) -> dict[str, Any]: + seed_by_key = load_current_projection(conn) + raw_wehago_by_key = load_raw_wehago(conn) + raw_erp_by_base = load_raw_erp(conn) + signatures = get_signatures(conn) + raw_keys = sorted(raw_wehago_by_key) + rows_to_insert: dict[str, list[tuple[Any, ...]]] = {status: [] for status in WEHAGO_STATUSES} + diagnostics = { + "raw_wehago_vouchers": len(raw_keys), + "seed_canonical_vouchers": len(seed_by_key), + "seed_duplicates_removed": 0, + "restored_missing_vouchers": len(set(raw_keys) - set(seed_by_key)), + "status_moves": [], + } + + for key in raw_keys: + seed = seed_by_key.get(key) + old_status = clean(seed.get("status_key")) if seed else "" + status, reason = determine_status(key, seed) + seed_rows = seed.get("rows", []) if seed else [] + display_rows = hydrate_rows(status, seed_rows, raw_wehago_by_key[key], raw_erp_by_base) + summary = summarize(key, status, display_rows, reason) + excepted_reason = revised_excepted_reason(key, status, display_rows, summary) + if excepted_reason: + status = "voucher_excepted" + reason = excepted_reason + display_rows = hydrate_rows(status, seed_rows, raw_wehago_by_key[key], raw_erp_by_base) + summary = summarize(key, status, display_rows, reason) + if old_status and old_status != status: + diagnostics["status_moves"].append({"key": key, "from": old_status, "to": status, "reason": reason}) + search_text = " ".join( + [ + summary["voucher_no"], + summary["draft_no"], + summary["ledger_accounts"], + summary["voucher_accounts"], + summary["ledger_vendors"], + summary["voucher_vendors"], + reason, + " ".join(clean(row.get("ledger_desc")) for row in display_rows), + " ".join(clean(row.get("voucher_desc")) for row in display_rows), + ] + ) + rows_to_insert[status].append( + ( + START_YEAR, + END_YEAR, + status, + signatures[status], + 0, + summary["fiscal_year"], + summary["ledger_date"], + summary["proof_date"], + summary["voucher_no"], + summary["draft_no"], + int(summary["ledger_row_count"]), + int(summary["voucher_row_count"]), + amount(summary["ledger_debit"]), + amount(summary["ledger_credit"]), + amount(summary["voucher_debit"]), + amount(summary["voucher_credit"]), + summary["ledger_accounts"], + summary["voucher_accounts"], + summary["ledger_vendors"], + summary["voucher_vendors"], + reason, + search_text, + json_payload(summary), + json_payload(display_rows), + ) + ) + + cleanup_runtime(conn) + counts: dict[str, int] = {} + for status in WEHAGO_STATUSES: + payloads = [] + for idx, payload in enumerate(rows_to_insert[status]): + values = list(payload) + values[4] = idx + payloads.append(tuple(values)) + conn.executemany( + """ + INSERT INTO wehago_status_projection_groups ( + start_year, end_year, status_key, signature, group_index, + fiscal_year, ledger_date, proof_date, voucher_no, draft_no, + ledger_row_count, voucher_row_count, + ledger_debit, ledger_credit, voucher_debit, voucher_credit, + ledger_accounts, voucher_accounts, ledger_vendors, voucher_vendors, + review_reason, search_text, summary_json, rows_json, + created_at, updated_at + ) VALUES ( + ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, + CURRENT_TIMESTAMP, CURRENT_TIMESTAMP + ) + """, + payloads, + ) + counts[status] = len(payloads) + store_setting(conn, status, signatures[status], len(payloads)) + + basis = { + "source": "raw WEHAGO canonical voucher set; current projection only used as candidate seed", + "counts": counts, + "wehago_total": sum(counts.values()), + "diagnostics": diagnostics, + } + 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_final_basis_projection:{START_YEAR}:{END_YEAR}", json_payload(basis)), + ) + return basis + + +def main() -> None: + conn = sqlite3.connect(DB_PATH) + conn.row_factory = sqlite3.Row + try: + conn.execute("PRAGMA busy_timeout = 120000") + conn.execute("BEGIN IMMEDIATE") + basis = materialize(conn) + conn.commit() + except Exception: + conn.rollback() + raise + finally: + conn.close() + print(json_payload(basis)) + + +if __name__ == "__main__": + main() diff --git a/scripts/preview_cost_analysis1_labor_adjustment.py b/scripts/preview_cost_analysis1_labor_adjustment.py new file mode 100644 index 0000000..79d0f90 --- /dev/null +++ b/scripts/preview_cost_analysis1_labor_adjustment.py @@ -0,0 +1,266 @@ +from __future__ import annotations + +import json +from datetime import date +from typing import Any + +import main +from sqlalchemy import text + + +PHASES = ("pre", "during", "post") + + +def empty_amounts() -> dict[str, float]: + return {phase: 0.0 for phase in PHASES} + + +def labor_bucket(project_code: Any) -> str: + code = main.normalize_text(project_code).upper() + return "cost" if code.startswith(("Y", "Z")) and code != "ZZZZZZ" else "sga" + + +def build_standard_labor(year: int, end_date: date | None = None) -> dict[str, Any]: + start_date = date(year, 1, 1) + end_date = end_date or date(year, 12, 31) + project_meta = main._cost_analysis_get_project_meta() + completion_dates = main._cost_analysis_get_completion_billing_dates() + alias_to_code, title_to_codes = main._cost_analysis_build_hanmac_matchers(project_meta) + metric, rows = main._cost_analysis_load_hanmac_member_rows( + start_date, + end_date, + prefer_member_grade=True, + ) + rates_by_year = main._parse_labor_rates_json(main.get_shared_exec_labor_rates_json()) + if not rates_by_year: + rates_by_year = main._parse_labor_rates_json( + json.dumps(main.DEFAULT_EXEC_LABOR_RATES, ensure_ascii=False) + ) + + totals = {"cost": 0.0, "sga": 0.0} + by_code: dict[str, dict[str, Any]] = {} + unresolved_hours = 0.0 + resolve_cache: dict[tuple[str, str, str], list[str]] = {} + + def add( + project: dict[str, Any], + work_date_text: Any, + member_grade: str, + hours: float, + multiplier: float, + ) -> None: + nonlocal unresolved_hours + if hours <= 0: + return + work_date = main._parse_iso_date(work_date_text) or start_date + if work_date < start_date or work_date > end_date: + return + resolve_key = ( + main.normalize_text(project.get("project_code")).upper(), + "|".join( + main.normalize_text(value).upper() + for value in (project.get("equivalent_project_codes") or []) + ), + f"{main.normalize_project_title_for_linking(project.get('project_name'))}|{work_date.isoformat()}", + ) + codes = resolve_cache.get(resolve_key) + if codes is None: + codes = main._cost_analysis_resolve_hanmac_project_codes( + project, + work_date, + alias_to_code, + title_to_codes, + project_meta, + ) + resolve_cache[resolve_key] = codes + if not codes: + fallback_code = main.normalize_text(project.get("project_code")).upper() + if not fallback_code: + fallback_code = next( + ( + main.normalize_text(value).upper() + for value in (project.get("equivalent_project_codes") or []) + if main.normalize_text(value) + ), + "", + ) + if fallback_code: + codes = [fallback_code] + else: + unresolved_hours += hours + return + split_hours = hours / len(codes) + cost_weight = main.normalize_amount(project.get("cost_weight")) or 1.0 + for code in codes: + normalized_code = main.normalize_text(code).upper() + phase = ( + "pre" + if normalized_code.startswith("X") + else main._cost_analysis_phase_for_transaction( + normalized_code, + work_date.isoformat(), + completion_dates, + project_meta, + ) + ) + rate = main._resolve_labor_rate( + rates_by_year, + member_grade, + str(year), + str(year), + (project_meta.get(normalized_code) or {}).get("project_type"), + ) + amount = rate * split_hours * multiplier * cost_weight + bucket = labor_bucket(normalized_code) + totals[bucket] += amount + code_row = by_code.setdefault( + normalized_code, + { + "bucket": bucket, + "standard": empty_amounts(), + "hours": empty_amounts(), + }, + ) + code_row["standard"][phase] += amount + code_row["hours"][phase] += split_hours + + for row in rows: + member_grade = main._normalize_labor_grade_name( + row.get("member_grade") + or row.get("grade") + or row.get("position") + or row.get("rank") + ) + if not member_grade: + continue + 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(main.normalize_amount(project.get("hours")) for project in projects) + recognized_total = main.normalize_amount(detail.get("regular_hours")) + for project in projects: + raw_hours = main.normalize_amount(project.get("hours")) + joint_hours = ( + main.normalize_amount(project.get("recognized_hours")) + if main._is_hanmac_joint_detail(project) + else 0.0 + ) + hours = ( + joint_hours + if joint_hours > 0 + else recognized_total * raw_hours / raw_total + if raw_total > 0 and recognized_total > 0 + else raw_hours + ) + add(project, detail.get("work_date"), member_grade, hours, 1.0) + for detail in details.get("overtime_hours") or []: + add( + detail, + detail.get("work_date"), + member_grade, + main.normalize_amount(detail.get("overtime_hours")), + 1.5, + ) + for detail in details.get("holiday_hours") or []: + projects = detail.get("projects") if isinstance(detail.get("projects"), list) else [] + recognized_total = min(main.normalize_amount(detail.get("holiday_hours")), 5.0) + if projects: + raw_total = sum(main.normalize_amount(project.get("hours")) for project in projects) + for project in projects: + raw_hours = main.normalize_amount(project.get("hours")) + hours = ( + recognized_total * raw_hours / raw_total + if raw_total > 0 and recognized_total > 0 + else min(raw_hours, 5.0) + ) + add(project, detail.get("work_date"), member_grade, hours, 1.5) + else: + add(detail, detail.get("work_date"), member_grade, recognized_total, 1.5) + + return { + "metric": metric or {}, + "totals": totals, + "by_code": by_code, + "unresolved_hours": unresolved_hours, + } + + +def load_actual_labor_pools(year: int, end_date: date | None = None) -> dict[str, float]: + end_date = end_date or date(year, 12, 31) + with main.engine.begin() as conn: + rows = conn.execute( + text( + f""" + SELECT + COALESCE(account_code, '') AS account_code, + COALESCE(account_name, '') AS account_name, + COALESCE(accounting_category, '') AS accounting_category, + COALESCE(support_dept_code, '') AS support_dept_code, + COALESCE(support_dept_name, '') AS support_dept_name, + COALESCE(issuing_dept_code, '') AS issuing_dept_code, + COALESCE(issuing_dept_name, '') AS issuing_dept_name, + COALESCE(cost_dept_code, '') AS cost_dept_code, + COALESCE(cost_dept_name, '') AS cost_dept_name, + COALESCE(partner_name, '') AS partner_name, + COALESCE(memo1, '') AS memo1, + COALESCE(memo2, '') AS memo2, + COALESCE(amount, 0) AS amount + FROM transactions + WHERE {main.COST_ANALYSIS_TX_DATE_SQL} >= :start_date + AND {main.COST_ANALYSIS_TX_DATE_SQL} <= :end_date + AND (account_code LIKE '5%' OR account_code LIKE '6%') + """ + ), + { + "start_date": f"{year}-01-01", + "end_date": end_date.isoformat(), + }, + ).mappings().all() + pools = {"cost": 0.0, "sga": 0.0} + for raw_row in rows: + row = dict(raw_row) + if not main._cost_analysis2_is_labor_like(row): + continue + bucket = ( + "sga" + if main.normalize_text(row.get("accounting_category")) == "판관비" + else "cost" + ) + pools[bucket] += main.normalize_amount(row.get("amount")) + return pools + + +def main_preview() -> int: + results = [] + negative = False + for year in (2023, 2024, 2025, 2026): + calculation_end = date(2026, 3, 31) if year == 2026 else date(year, 12, 31) + standard = build_standard_labor(year, calculation_end) + actual = load_actual_labor_pools(year, calculation_end) + standard_total = standard["totals"]["cost"] + standard["totals"]["sga"] + actual_total = actual["cost"] + actual["sga"] + row = { + "year": year, + "calculation_end": calculation_end.isoformat(), + "standard_cost": standard["totals"]["cost"], + "actual_cost": actual["cost"], + "cost_adjustment": actual["cost"] - standard["totals"]["cost"], + "standard_sga": standard["totals"]["sga"], + "actual_sga": actual["sga"], + "sga_adjustment": actual["sga"] - standard["totals"]["sga"], + "standard_total": standard_total, + "actual_total": actual_total, + "total_adjustment": actual_total - standard_total, + "unresolved_hours": standard["unresolved_hours"], + "metric_start": (standard["metric"] or {}).get("start_date", ""), + "metric_end": (standard["metric"] or {}).get("end_date", ""), + } + if row["total_adjustment"] < -0.5: + negative = True + results.append(row) + print(json.dumps({"negative": negative, "rows": results}, ensure_ascii=False, indent=2)) + return 2 if negative else 0 + + +if __name__ == "__main__": + raise SystemExit(main_preview()) diff --git a/scripts/project_export_cache_ranges.py b/scripts/project_export_cache_ranges.py index de08a33..a764934 100644 --- a/scripts/project_export_cache_ranges.py +++ b/scripts/project_export_cache_ranges.py @@ -821,7 +821,7 @@ def project_range( allow_stale=allow_stale, context_signatures=context_signatures, ) - conn.execute("BEGIN") + conn.execute("BEGIN IMMEDIATE") try: _projection_source_table( conn, @@ -1026,8 +1026,9 @@ def main() -> None: help="같은 기간의 오래된 query projection을 함께 정리합니다. 대용량 DB에서는 별도 유지보수 시간에 실행하세요.", ) args = parser.parse_args() - conn = sqlite3.connect(DB_PATH) + conn = sqlite3.connect(DB_PATH, timeout=120) conn.row_factory = sqlite3.Row + conn.execute("PRAGMA busy_timeout = 120000") for item in args.ranges: start_year, end_year = parse_range(item) counts = project_range(conn, start_year, end_year, allow_stale=args.allow_stale, prune_old=args.prune_old) diff --git a/scripts/promote_wehago_allocator_safe_matches.py b/scripts/promote_wehago_allocator_safe_matches.py new file mode 100644 index 0000000..3796878 --- /dev/null +++ b/scripts/promote_wehago_allocator_safe_matches.py @@ -0,0 +1,235 @@ +#!/usr/bin/env python3 +"""Promote vetted WEHAGO unmatched/recheck vouchers after row allocation checks.""" + +from __future__ import annotations + +import argparse +import json +import sqlite3 +from pathlib import Path + +import apply_wehago_row_allocator_display_fix as display_fix +import test_wehago_row_allocator_shadow as alloc + +DEFAULT_DB = Path("/home/b17301/intranet-runtime/db/data.db") +YEAR = 2025 + +SAFE_PROMOTIONS = { + (2025, "03-25", "50004"): ("11-20250327-B0100-7", "row allocator 승격: 관리비/부가세 proof_date·거래처·금액 일치"), + (2025, "05-26", "50004"): ("11-20250527-B0100-21", "row allocator 승격: 송파웰츠타워 관리비 proof_date·거래처·금액 일치"), + (2025, "06-23", "50012"): ("11-20250627-B0100-35", "row allocator 승격: 송파웰츠타워 관리비 proof_date·거래처·금액 일치"), + (2025, "06-25", "50012"): ("11-20250627-B0100-36", "row allocator 승격: 부산지사 관리비 proof_date·거래처·금액 일치"), + (2025, "08-07", "50005"): ("11-20250908-Q0100-3", "row allocator 승격: 취소/환급 전표 금액 방향·proof_date·거래처 일치"), + (2025, "09-24", "50006"): ("11-20250925-B0100-10", "row allocator 승격: 송파웰츠타워 관리비 proof_date·거래처·금액 일치"), + (2025, "10-20", "50028"): ("11-20251205-Q0100-7", "row allocator 승격: 열차경보앱단말 임대 proof_date·거래처·금액 일치"), + (2025, "10-24", "50001"): ("11-20251028-B0100-2", "row allocator 승격: 송파웰츠타워 관리비 proof_date·거래처·금액 일치"), + (2025, "11-24", "50002"): ("11-20251125-B0100-14", "row allocator 승격: 송파웰츠타워 관리비 proof_date·거래처·금액 일치"), +} + + +def status_signature(conn: sqlite3.Connection, status_key: str) -> str: + row = conn.execute( + """ + SELECT signature + FROM wehago_status_projection_groups + WHERE start_year = ? AND end_year = ? AND status_key = ? + ORDER BY updated_at DESC + LIMIT 1 + """, + (YEAR, YEAR, status_key), + ).fetchone() + if row is None or not row["signature"]: + raise RuntimeError(f"missing active signature for {status_key}") + return str(row["signature"]) + + +def update_setting_count(conn: sqlite3.Connection, status_key: str, signature: str) -> None: + count = int( + conn.execute( + """ + SELECT COUNT(*) + FROM wehago_status_projection_groups + WHERE start_year = ? AND end_year = ? AND status_key = ? AND signature = ? + """, + (YEAR, YEAR, status_key, signature), + ).fetchone()[0] + or 0 + ) + key = f"wehago_active_status_projection:{status_key}:{YEAR}:{YEAR}" + row = conn.execute("SELECT setting_json FROM wehago_compare_settings WHERE setting_key = ?", (key,)).fetchone() + payload = {} + if row: + try: + payload = json.loads(row["setting_json"] or "{}") + except Exception: + payload = {} + payload.update( + { + "signature": signature, + "status_key": status_key, + "start_year": YEAR, + "end_year": YEAR, + "total_count": count, + "projection_version": "integrity-2025-row-allocator-safe-promotions", + } + ) + 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 + """, + (key, json.dumps(payload, ensure_ascii=False, separators=(",", ":"))), + ) + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("--db", default=str(DEFAULT_DB)) + parser.add_argument("--dry-run", action="store_true") + args = parser.parse_args() + + conn = sqlite3.connect(args.db) + conn.row_factory = sqlite3.Row + raw_wehago = alloc.load_raw_wehago(conn) + erp_by_base = alloc.load_erp(conn) + matched_sig = status_signature(conn, "voucher_matched") + status_sigs = {status: status_signature(conn, status) for status in ("voucher_matched", "voucher_unmatched", "voucher_recheck", "voucher_excepted")} + next_group_index = int( + conn.execute( + """ + SELECT COALESCE(MAX(group_index), -1) + 1 + FROM wehago_status_projection_groups + WHERE start_year = ? AND end_year = ? AND status_key = 'voucher_matched' AND signature = ? + """, + (YEAR, YEAR, matched_sig), + ).fetchone()[0] + ) + promoted = [] + skipped = [] + for key, (base, reason) in SAFE_PROMOTIONS.items(): + source = conn.execute( + """ + SELECT * + FROM wehago_status_projection_groups + WHERE start_year = ? AND end_year = ? AND fiscal_year = ? + AND ledger_date = ? AND voucher_no = ? + LIMIT 1 + """, + (YEAR, YEAR, key[0], key[1], key[2]), + ).fetchone() + if source is None: + skipped.append({"key": key, "reason": "source group missing"}) + continue + if source["status_key"] == "voucher_matched": + skipped.append({"key": key, "reason": "already matched"}) + continue + left_rows = raw_wehago.get(key, []) + right_rows = erp_by_base.get(base, []) + pairs, _, _ = alloc.allocate_rows(left_rows, right_rows) + if not pairs: + skipped.append({"key": key, "reason": "allocator found no pairs"}) + continue + display_rows, pair_count = display_fix.rebuild_display_rows(key, "voucher_matched", left_rows, right_rows) + summary = json.loads(source["summary_json"] or "{}") + summary["status_label"] = "Voucher" + summary["review_reason"] = reason + summary = display_fix.update_summary_from_rows(summary, display_rows) + search_text = " ".join( + [ + key[1], + key[2], + summary.get("draft_no", ""), + summary.get("ledger_accounts", ""), + summary.get("voucher_accounts", ""), + summary.get("ledger_vendors", ""), + summary.get("voucher_vendors", ""), + reason, + " ".join(alloc.clean(row.get("ledger_desc")) for row in display_rows), + " ".join(alloc.clean(row.get("voucher_desc")) for row in display_rows), + ] + ) + promoted.append({"key": key, "from": source["status_key"], "base": base, "pairs": pair_count}) + if not args.dry_run: + conn.execute( + """ + UPDATE wehago_status_projection_groups + SET status_key = 'voucher_matched', + signature = ?, + group_index = ?, + draft_no = ?, + ledger_row_count = ?, + voucher_row_count = ?, + ledger_debit = ?, + ledger_credit = ?, + voucher_debit = ?, + voucher_credit = ?, + voucher_accounts = ?, + voucher_vendors = ?, + review_reason = ?, + search_text = ?, + summary_json = ?, + rows_json = ?, + updated_at = CURRENT_TIMESTAMP + WHERE start_year = ? AND end_year = ? AND status_key = ? + AND signature = ? AND group_index = ? + """, + ( + matched_sig, + next_group_index, + summary.get("draft_no", ""), + int(summary.get("ledger_row_count") or 0), + int(summary.get("voucher_row_count") or 0), + float(summary.get("ledger_debit") or 0), + float(summary.get("ledger_credit") or 0), + float(summary.get("voucher_debit") or 0), + float(summary.get("voucher_credit") or 0), + summary.get("voucher_accounts", ""), + summary.get("voucher_vendors", ""), + reason, + search_text, + json.dumps(summary, ensure_ascii=False, separators=(",", ":")), + json.dumps(display_rows, ensure_ascii=False, separators=(",", ":")), + source["start_year"], + source["end_year"], + source["status_key"], + source["signature"], + source["group_index"], + ), + ) + next_group_index += 1 + if not args.dry_run: + for status, sig in status_sigs.items(): + update_setting_count(conn, status, sig) + for table in ( + "wehago_compare_query_groups", + "wehago_compare_query_rows", + "wehago_compare_query_metrics", + "wehago_compare_query_page_cache", + "wehago_compare_final_status_projection", + "wehago_compare_export_jobs", + ): + conn.execute(f"DELETE FROM {table} WHERE start_year = ? AND end_year = ?", (YEAR, YEAR)) + conn.execute("DELETE FROM wehago_compare_settings WHERE setting_key = ?", (f"wehago_active_query_projection:{YEAR}:{YEAR}",)) + conn.execute("DELETE FROM wehago_compare_settings WHERE setting_key = ?", (f"wehago_active_status_projection_run:{YEAR}:{YEAR}",)) + conn.execute("DELETE FROM wehago_compare_settings WHERE setting_key = ?", (f"wehago_final_basis_projection:{YEAR}:{YEAR}",)) + conn.commit() + counts = dict( + conn.execute( + """ + SELECT status_key, COUNT(*) + FROM wehago_status_projection_groups + WHERE start_year = ? AND end_year = ? + GROUP BY status_key + ORDER BY status_key + """, + (YEAR, YEAR), + ).fetchall() + ) + print(json.dumps({"dry_run": args.dry_run, "promoted": promoted, "skipped": skipped, "counts": counts}, ensure_ascii=False, indent=2)) + + +if __name__ == "__main__": + main() diff --git a/scripts/promote_wehago_recheck_projection.py b/scripts/promote_wehago_recheck_projection.py index 04280a5..215a29d 100644 --- a/scripts/promote_wehago_recheck_projection.py +++ b/scripts/promote_wehago_recheck_projection.py @@ -395,9 +395,10 @@ def _latest_query_projection_signature(conn: sqlite3.Connection) -> str | None: AND end_year = ? AND signature LIKE ? AND signature NOT LIKE '%|snapshot-recheck-promote|%' + AND signature NOT LIKE '%|db-reconciled-%' GROUP BY signature HAVING status_count >= 5 - ORDER BY updated_at DESC + ORDER BY status_count DESC, updated_at DESC LIMIT 1 """, (TARGET_START_YEAR, TARGET_END_YEAR, f"{QUERY_PROJECTION_VERSION}|%"), diff --git a/scripts/rebuild_hanmac_aggregate_cache_once.py b/scripts/rebuild_hanmac_aggregate_cache_once.py new file mode 100644 index 0000000..5b1e687 --- /dev/null +++ b/scripts/rebuild_hanmac_aggregate_cache_once.py @@ -0,0 +1,86 @@ +from __future__ import annotations + +import argparse +import json +import sqlite3 +import sys +from datetime import date +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) +import main + + +def load_latest_payloads(years: list[int]) -> dict[int, dict[str, object]]: + conn = sqlite3.connect(main.DB_PATH) + conn.row_factory = sqlite3.Row + rows = conn.execute( + """ + SELECT params_json + FROM system_jobs + WHERE page_key = 'hanmac_browser' + AND job_type = 'hanmac_aggregate_cache' + AND status = 'done' + ORDER BY updated_at DESC + """ + ).fetchall() + latest: dict[int, dict[str, object]] = {} + fallback: dict[str, object] = {} + for row in rows: + try: + payload = json.loads(row["params_json"] or "{}") + except Exception: + continue + if not isinstance(payload, dict): + continue + if not fallback and payload.get("host") and payload.get("user") and payload.get("password"): + fallback = payload + start_text = str(payload.get("start_date") or "") + for year in years: + if year not in latest and start_text.startswith(str(year)): + latest[year] = payload + return {year: dict(latest.get(year) or fallback) for year in years} + + +def rebuild_year(year: int, base_payload: dict[str, object], end_today: bool = False) -> dict[str, object]: + if not base_payload: + raise RuntimeError(f"{year}년 캐시를 만들 접속정보가 없습니다.") + end_date = date.today().isoformat() if end_today and year == date.today().year else f"{year}-12-31" + payload = { + **base_payload, + "database": main.normalize_text(base_payload.get("database")) or main.HANMAC_PRIMARY_MANHOUR_SCHEMA, + "view": "member", + "employment": main.normalize_text(base_payload.get("employment") or "all"), + "start_date": f"{year}-01-01", + "end_date": end_date, + } + result = main.get_hanmac_aggregate_summary(payload) + cache_key = main._hanmac_aggregate_cache_key(payload) + main._store_hanmac_aggregate_cache(cache_key, result) + diagnostics = result.get("source_diagnostics") or {} + return { + "year": year, + "start_date": result.get("start_date"), + "end_date": result.get("end_date"), + "row_count": len(result.get("rows") or []), + "cache_key": cache_key, + "joint_assignment_records": diagnostics.get("joint_assignment_records", 0), + "joint_assignment_regular_rows": diagnostics.get("joint_assignment_regular_rows", 0), + "joint_assignment_overtime_rows": diagnostics.get("joint_assignment_overtime_rows", 0), + } + + +def main_cli() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("--years", nargs="+", type=int, default=[2022, 2023, 2024, 2025, 2026]) + parser.add_argument("--end-today", action="store_true") + args = parser.parse_args() + + payloads = load_latest_payloads(args.years) + for year in args.years: + summary = rebuild_year(year, payloads.get(year) or {}, end_today=args.end_today) + print(json.dumps(summary, ensure_ascii=False, sort_keys=True)) + + +if __name__ == "__main__": + main_cli() diff --git a/scripts/rebuild_wehago_recheck_anchor_projection.py b/scripts/rebuild_wehago_recheck_anchor_projection.py new file mode 100644 index 0000000..77286e8 --- /dev/null +++ b/scripts/rebuild_wehago_recheck_anchor_projection.py @@ -0,0 +1,73 @@ +from __future__ import annotations + +import argparse +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + +from main import engine +from wehago_compare import ( + _build_wehago_anchored_recheck_groups, + _load_query_group_page, + _query_projection_signature, + _store_status_query_group_projection, +) + + +def main() -> None: + parser = argparse.ArgumentParser(description="Rebuild only the WEHAGO anchored Recheck query projection.") + parser.add_argument("--start-year", type=int, default=2025) + parser.add_argument("--end-year", type=int, default=2025) + args = parser.parse_args() + + with engine.begin() as conn: + query_page = _load_query_group_page( + conn, + args.start_year, + args.end_year, + "voucher_recheck", + "", + "", + "", + "", + "", + "", + "", + "", + "", + 0, + 500000, + ) + if query_page is None: + raise RuntimeError("No source voucher_recheck query projection was found.") + source_groups, _total_count, _next_cursor = query_page + anchored_groups = _build_wehago_anchored_recheck_groups( + conn, + args.start_year, + args.end_year, + source_groups, + ) + signature = _query_projection_signature(conn, args.start_year, args.end_year) + _store_status_query_group_projection( + conn, + args.start_year, + args.end_year, + "voucher_recheck", + signature, + anchored_groups, + ) + print( + { + "start_year": args.start_year, + "end_year": args.end_year, + "source_groups": len(source_groups), + "anchored_groups": len(anchored_groups), + "signature": signature, + }, + flush=True, + ) + + +if __name__ == "__main__": + main() diff --git a/scripts/rebuild_wehago_yearly_projections_watchdog.py b/scripts/rebuild_wehago_yearly_projections_watchdog.py new file mode 100644 index 0000000..23fdd01 --- /dev/null +++ b/scripts/rebuild_wehago_yearly_projections_watchdog.py @@ -0,0 +1,563 @@ +from __future__ import annotations + +import argparse +import fcntl +import json +import multiprocessing as mp +import os +import signal +import sys +import time +import traceback +from pathlib import Path +from typing import Any + +from sqlalchemy import text +from sqlalchemy.exc import OperationalError + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + +import wehago_compare as wc +from main import engine + + +DEFAULT_YEARS = [2025, 2024, 2023, 2022] +PROGRESS_PATH = Path("/tmp/wehago_year_projection_watchdog_progress.json") +LOCK_PATH = Path("/tmp/wehago_compare_compute.lock") + + +def emit(event: str, **payload: Any) -> None: + data = { + "event": event, + "ts": time.strftime("%Y-%m-%d %H:%M:%S"), + **payload, + } + print(json.dumps(data, ensure_ascii=False, sort_keys=True), flush=True) + + +def load_progress() -> dict[str, Any]: + if not PROGRESS_PATH.exists(): + return {"years": {}} + try: + payload = json.loads(PROGRESS_PATH.read_text(encoding="utf-8")) + except Exception: + return {"years": {}} + return payload if isinstance(payload, dict) else {"years": {}} + + +def store_progress(progress: dict[str, Any]) -> None: + PROGRESS_PATH.write_text(json.dumps(progress, ensure_ascii=False, indent=2), encoding="utf-8") + + +def update_progress(year: int, status: str, **payload: Any) -> None: + progress = load_progress() + years = progress.setdefault("years", {}) + current = dict(years.get(str(year)) or {}) + current.update( + { + "year": int(year), + "status": status, + "updated_at": time.strftime("%Y-%m-%d %H:%M:%S"), + **payload, + } + ) + years[str(year)] = current + store_progress(progress) + + +def _execute_with_lock_retry(conn: Any, statement: Any, params: dict[str, Any], *, attempts: int = 12) -> Any: + last_exc: BaseException | None = None + for attempt in range(1, max(int(attempts or 1), 1) + 1): + try: + return conn.execute(statement, params) + except OperationalError as exc: + if "database is locked" not in str(exc).lower(): + raise + last_exc = exc + emit("sqlite_lock_wait", attempt=attempt, sleep_sec=5) + time.sleep(5) + if last_exc: + raise last_exc + return conn.execute(statement, params) + + +def _delete_year_projection_rows(conn: Any, year: int) -> dict[str, int]: + params = {"year": int(year)} + deleted: dict[str, int] = {} + table_names = [ + "wehago_compare_query_metrics", + "wehago_compare_query_groups", + "wehago_compare_query_rows", + "wehago_compare_query_page_cache", + "wehago_compare_final_status_projection", + "wehago_status_projection_groups", + "wehago_metric_count_cache", + "wehago_summary_range_cache", + ] + for table_name in table_names: + result = _execute_with_lock_retry( + conn, + text( + f""" + DELETE FROM {table_name} + WHERE start_year = :year + AND end_year = :year + """ + ), + params, + ) + deleted[table_name] = int(result.rowcount or 0) + result = _execute_with_lock_retry( + conn, + text( + """ + DELETE FROM wehago_compare_settings + WHERE setting_key = :query_key + OR setting_key = :run_key + OR setting_key LIKE :status_like + """ + ), + { + "query_key": f"wehago_active_query_projection:{year}:{year}", + "run_key": f"wehago_active_status_projection_run:{year}:{year}", + "status_like": f"wehago_active_status_projection:%:{year}:{year}", + }, + ) + deleted["wehago_compare_settings"] = int(result.rowcount or 0) + return deleted + + +def _mirror_query_groups_to_active_status_projection(conn: Any, year: int) -> dict[str, int]: + query_signature = wc._load_latest_query_projection_signature_for_range(conn, year, year) + if not query_signature: + return {} + insert_sql = text( + """ + INSERT INTO wehago_status_projection_groups ( + start_year, end_year, status_key, signature, group_index, + fiscal_year, ledger_date, proof_date, voucher_no, draft_no, + ledger_row_count, voucher_row_count, + ledger_debit, ledger_credit, voucher_debit, voucher_credit, + ledger_accounts, voucher_accounts, ledger_vendors, voucher_vendors, + review_reason, search_text, summary_json, rows_json, created_at, updated_at + ) VALUES ( + :start_year, :end_year, :status_key, :signature, :group_index, + :fiscal_year, :ledger_date, :proof_date, :voucher_no, :draft_no, + :ledger_row_count, :voucher_row_count, :ledger_debit, :ledger_credit, + :voucher_debit, :voucher_credit, :ledger_accounts, :voucher_accounts, + :ledger_vendors, :voucher_vendors, :review_reason, :search_text, + :summary_json, :rows_json, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP + ) + """ + ) + mirrored: dict[str, int] = {} + for status_key in wc.WEHAGO_FINAL_STATUS_ORDER: + status_signature = wc._status_projection_signature(conn, status_key, year, year) + if not status_signature: + continue + conn.execute( + text( + """ + DELETE FROM wehago_status_projection_groups + WHERE start_year = :year + AND end_year = :year + AND status_key = :status_key + AND signature = :signature + """ + ), + {"year": int(year), "status_key": status_key, "signature": status_signature}, + ) + groups = conn.execute( + text( + """ + SELECT * + FROM wehago_compare_query_groups + WHERE start_year = :year + AND end_year = :year + AND status_key = :status_key + AND signature = :query_signature + ORDER BY group_index + """ + ), + {"year": int(year), "status_key": status_key, "query_signature": query_signature}, + ).mappings().all() + payloads: list[dict[str, Any]] = [] + for group in groups: + group_index = int(group.get("group_index") or 0) + rows = [ + dict(row) + for row in conn.execute( + text( + """ + SELECT * + FROM wehago_compare_query_rows + WHERE start_year = :year + AND end_year = :year + AND status_key = :status_key + AND signature = :query_signature + AND group_index = :group_index + ORDER BY row_index + """ + ), + { + "year": int(year), + "status_key": status_key, + "query_signature": query_signature, + "group_index": group_index, + }, + ).mappings() + ] + summary = { + "fiscal_year": int(group.get("fiscal_year") or 0), + "ledger_date": wc.clean(group.get("ledger_date")), + "proof_date": wc.clean(group.get("proof_date")), + "voucher_no": wc.clean(group.get("voucher_no")), + "draft_no": wc.clean(group.get("draft_no")), + "ledger_row_count": int(group.get("ledger_row_count") or 0), + "voucher_row_count": int(group.get("voucher_row_count") or 0), + "ledger_debit": wc.parse_amount(group.get("ledger_debit")), + "ledger_credit": wc.parse_amount(group.get("ledger_credit")), + "voucher_debit": wc.parse_amount(group.get("voucher_debit")), + "voucher_credit": wc.parse_amount(group.get("voucher_credit")), + "ledger_accounts": wc.clean(group.get("ledger_accounts")), + "voucher_accounts": wc.clean(group.get("voucher_accounts")), + "ledger_vendors": wc.clean(group.get("ledger_vendors")), + "voucher_vendors": wc.clean(group.get("voucher_vendors")), + "review_reason": wc.clean(group.get("review_reason")), + } + payloads.append( + { + "start_year": int(year), + "end_year": int(year), + "status_key": status_key, + "signature": status_signature, + "group_index": group_index, + **summary, + "search_text": wc.clean(group.get("search_text")), + "summary_json": json.dumps(summary, ensure_ascii=False, separators=(",", ":")), + "rows_json": json.dumps(rows, ensure_ascii=False, separators=(",", ":")), + } + ) + for chunk in wc._chunked(payloads): + conn.execute(insert_sql, chunk) + wc._store_active_status_projection_signature(conn, status_key, year, year, status_signature, len(payloads)) + mirrored[status_key] = len(payloads) + return mirrored + + +def _count_projection_rows(conn: Any, year: int) -> dict[str, Any]: + params = {"year": int(year)} + query_signature = wc._load_latest_query_projection_signature_for_range(conn, year, year) + status_counts = { + str(row.get("status_key")): int(row.get("count") or 0) + for row in conn.execute( + text( + """ + SELECT status_key, COUNT(*) AS count + FROM wehago_compare_query_groups + WHERE start_year = :year + AND end_year = :year + GROUP BY status_key + ORDER BY status_key + """ + ), + params, + ).mappings() + } + compact_counts = { + str(row.get("status_key")): int(row.get("count") or 0) + for row in conn.execute( + text( + """ + SELECT status_key, COUNT(*) AS count + FROM wehago_status_projection_groups + WHERE start_year = :year + AND end_year = :year + GROUP BY status_key + ORDER BY status_key + """ + ), + params, + ).mappings() + } + final_counts = { + str(row.get("final_status")): int(row.get("count") or 0) + for row in conn.execute( + text( + """ + SELECT final_status, COUNT(*) AS count + FROM wehago_compare_final_status_projection + WHERE start_year = :year + AND end_year = :year + AND signature = :signature + GROUP BY final_status + ORDER BY final_status + """ + ), + {"year": int(year), "signature": query_signature}, + ).mappings() + } + active_summary = wc._load_cached_active_status_projection_run_summary(conn, year, year) + raw_total = int( + conn.execute( + text( + """ + SELECT COUNT(*) + FROM ( + SELECT compare_voucher_no + FROM wehago_ledger_rows + WHERE fiscal_year = :year + AND COALESCE(compare_voucher_no, '') <> '' + GROUP BY compare_voucher_no + ) + """ + ), + params, + ).scalar_one() + or 0 + ) + return { + "query_signature": query_signature, + "query_group_counts": status_counts, + "compact_group_counts": compact_counts, + "final_counts": final_counts, + "raw_voucher_total": raw_total, + "active_summary": { + "source": (active_summary or {}).get("source"), + "ready": (active_summary or {}).get("ready"), + "raw_total": (active_summary or {}).get("raw_total"), + "classified_total": (active_summary or {}).get("classified_total"), + }, + } + + +def _skip_query_page_prewarm() -> None: + def _noop_prewarm(*_args: Any, **_kwargs: Any) -> None: + emit("projection_page_prewarm_skipped") + + wc._prewarm_query_page_projection_cache = _noop_prewarm + + +def rebuild_one_year(year: int, *, skip_page_prewarm: bool) -> None: + started = time.monotonic() + update_progress(year, "running", started_at=time.strftime("%Y-%m-%d %H:%M:%S")) + if skip_page_prewarm: + _skip_query_page_prewarm() + try: + wc._clear_compare_runtime_caches() + with engine.connect().execution_options(isolation_level="AUTOCOMMIT") as conn: + conn.execute(text("PRAGMA busy_timeout = 60000")) + emit("year_purge_start", year=year) + deleted = _delete_year_projection_rows(conn, year) + emit("year_purge_done", year=year, deleted=deleted) + + signature = wc._build_db_state_signature(conn, year, year) + emit("year_snapshot_start", year=year, signature=signature) + wc._refresh_year_resolved_sections(conn, year) + emit("year_snapshot_done", year=year) + + emit("compact_projection_start", year=year) + for status_key in sorted(wc.COMPACT_EXPORT_STATUS_KEYS): + compact_signature = wc._ensure_export_compact_status_projection(conn, year, year, status_key) + emit( + "compact_projection_status_done", + year=year, + status_key=status_key, + signature=compact_signature, + ) + hanmac_signature = wc._ensure_hanmac_unconnected_status_projection(conn, year, year) + emit("compact_projection_hanmac_done", year=year, signature=hanmac_signature) + + emit("query_projection_start", year=year) + counts, snapshot_state = wc._rebuild_compare_query_projection(engine, conn, year, year) + emit("query_projection_done", year=year, counts=counts, snapshot_state=snapshot_state) + + emit("active_status_mirror_start", year=year) + mirrored_counts = _mirror_query_groups_to_active_status_projection(conn, year) + emit("active_status_mirror_done", year=year, counts=mirrored_counts) + + emit("active_run_summary_start", year=year) + active_summary = wc._load_active_status_projection_run_summary(conn, year, year, allow_rebuild=True) + wc._ensure_active_status_run_final_projection(conn, year, year, active_summary) + emit( + "active_run_summary_done", + year=year, + source=(active_summary or {}).get("source"), + raw_total=(active_summary or {}).get("raw_total"), + classified_total=(active_summary or {}).get("classified_total"), + ready=(active_summary or {}).get("ready"), + ) + + wc._clear_compare_runtime_caches() + verification = _count_projection_rows(conn, year) + elapsed_sec = round(time.monotonic() - started, 1) + update_progress( + year, + "done", + elapsed_sec=elapsed_sec, + verification=verification, + ) + emit("year_done", year=year, elapsed_sec=elapsed_sec, verification=verification) + except BaseException as exc: + elapsed_sec = round(time.monotonic() - started, 1) + update_progress( + year, + "failed", + elapsed_sec=elapsed_sec, + error=str(exc), + traceback=traceback.format_exc(), + ) + emit("year_failed", year=year, elapsed_sec=elapsed_sec, error=str(exc), traceback=traceback.format_exc()) + raise + + +def child_entry(year: int, skip_page_prewarm: bool) -> None: + rebuild_one_year(int(year), skip_page_prewarm=bool(skip_page_prewarm)) + + +def process_cpu_seconds(pid: int) -> float | None: + try: + stat = Path(f"/proc/{pid}/stat").read_text(encoding="utf-8") + parts = stat.split() + ticks = int(parts[13]) + int(parts[14]) + hz = os.sysconf(os.sysconf_names["SC_CLK_TCK"]) + return float(ticks) / float(hz) + except Exception: + return None + + +def terminate_process(proc: mp.Process, *, reason: str) -> None: + emit("worker_terminate", pid=proc.pid, reason=reason) + if proc.pid: + try: + os.kill(proc.pid, signal.SIGTERM) + except ProcessLookupError: + pass + proc.join(timeout=20) + if proc.is_alive() and proc.pid: + emit("worker_kill", pid=proc.pid, reason=reason) + try: + os.kill(proc.pid, signal.SIGKILL) + except ProcessLookupError: + pass + proc.join(timeout=10) + + +def run_year_with_watchdog( + year: int, + *, + retries: int, + per_year_timeout_sec: int, + idle_timeout_sec: int, + heartbeat_sec: int, + skip_page_prewarm: bool, + deadline_monotonic: float, +) -> bool: + attempt = 0 + while attempt <= retries: + attempt += 1 + if time.monotonic() >= deadline_monotonic: + update_progress(year, "failed", error="deadline exceeded before start") + emit("deadline_exceeded_before_year", year=year) + return False + emit("worker_start", year=year, attempt=attempt) + proc = mp.Process(target=child_entry, args=(int(year), bool(skip_page_prewarm)), daemon=False) + proc.start() + last_cpu = process_cpu_seconds(proc.pid or 0) or 0.0 + last_cpu_progress_at = time.monotonic() + started = time.monotonic() + last_heartbeat_at = 0.0 + timed_out_reason = "" + while proc.is_alive(): + now = time.monotonic() + cpu_now = process_cpu_seconds(proc.pid or 0) + if cpu_now is not None and cpu_now > last_cpu + 0.01: + last_cpu = cpu_now + last_cpu_progress_at = now + if now - last_heartbeat_at >= heartbeat_sec: + emit( + "worker_heartbeat", + year=year, + attempt=attempt, + pid=proc.pid, + elapsed_sec=round(now - started, 1), + cpu_sec=round(last_cpu, 1), + idle_sec=round(now - last_cpu_progress_at, 1), + ) + last_heartbeat_at = now + if now - started >= per_year_timeout_sec: + timed_out_reason = f"per-year timeout {per_year_timeout_sec}s" + break + if now - last_cpu_progress_at >= idle_timeout_sec: + timed_out_reason = f"idle timeout {idle_timeout_sec}s" + break + if now >= deadline_monotonic: + timed_out_reason = "total deadline exceeded" + break + time.sleep(5) + if timed_out_reason: + terminate_process(proc, reason=timed_out_reason) + update_progress(year, "retrying" if attempt <= retries else "failed", error=timed_out_reason, attempt=attempt) + else: + proc.join() + if proc.exitcode == 0: + emit("worker_done", year=year, attempt=attempt) + return True + emit("worker_failed", year=year, attempt=attempt, exitcode=proc.exitcode, reason=timed_out_reason) + return False + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Rebuild WEHAGO compare projections year by year with a watchdog.") + parser.add_argument("--years", nargs="*", type=int, default=DEFAULT_YEARS) + parser.add_argument("--deadline-hours", type=float, default=5.0) + parser.add_argument("--per-year-timeout-minutes", type=float, default=70.0) + parser.add_argument("--idle-timeout-minutes", type=float, default=10.0) + parser.add_argument("--heartbeat-seconds", type=int, default=60) + parser.add_argument("--retries", type=int, default=1) + parser.add_argument("--resume", action="store_true", help="Skip years already marked done in the progress file.") + parser.add_argument("--with-page-prewarm", action="store_true", help="Prewarm query page cache during rebuild. This is now the default.") + parser.add_argument("--skip-page-prewarm", action="store_true", help="Skip query page cache prewarm during rebuild.") + return parser.parse_args() + + +def main() -> int: + args = parse_args() + years = [int(year) for year in args.years if int(year or 0) > 0] + deadline_monotonic = time.monotonic() + max(float(args.deadline_hours or 5.0), 0.1) * 3600.0 + LOCK_PATH.parent.mkdir(parents=True, exist_ok=True) + with LOCK_PATH.open("w") as lock_file: + fcntl.flock(lock_file.fileno(), fcntl.LOCK_EX) + emit( + "watchdog_start", + years=years, + deadline_hours=args.deadline_hours, + per_year_timeout_minutes=args.per_year_timeout_minutes, + idle_timeout_minutes=args.idle_timeout_minutes, + retries=args.retries, + skip_page_prewarm=args.skip_page_prewarm, + ) + progress = load_progress() + ok = True + for year in years: + if args.resume and (progress.get("years", {}).get(str(year)) or {}).get("status") == "done": + emit("year_skip_done", year=year) + continue + year_ok = run_year_with_watchdog( + year, + retries=max(int(args.retries or 0), 0), + per_year_timeout_sec=int(max(float(args.per_year_timeout_minutes or 70.0), 1.0) * 60), + idle_timeout_sec=int(max(float(args.idle_timeout_minutes or 10.0), 1.0) * 60), + heartbeat_sec=max(int(args.heartbeat_seconds or 60), 10), + skip_page_prewarm=args.skip_page_prewarm, + deadline_monotonic=deadline_monotonic, + ) + ok = ok and year_ok + if not year_ok and time.monotonic() >= deadline_monotonic: + break + emit("watchdog_done", ok=ok, progress=load_progress()) + return 0 if ok else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/reconcile_wehago_projection_to_db.py b/scripts/reconcile_wehago_projection_to_db.py index eee3a15..90a2a2a 100644 --- a/scripts/reconcile_wehago_projection_to_db.py +++ b/scripts/reconcile_wehago_projection_to_db.py @@ -2599,6 +2599,87 @@ def supplement_group_with_missing_wehago_rows( return supplemented +def raw_erp_context_row(entry: dict[str, Any], group: dict[str, Any]) -> dict[str, Any]: + summary = group.get("summary") or {} + category = _classify_account_category(entry.get("account_code"), entry.get("account_name")) + amount, side = raw_erp_entry_signed_amount_side(entry, category) + description = " ".join( + part for part in (clean(entry.get("desc1")), clean(entry.get("desc2"))) if part + ) + row = { + "fiscal_year": int(summary.get("fiscal_year") or YEAR), + "status_label": clean(summary.get("status_label")), + "ledger_date": clean(summary.get("ledger_date")), + "proof_date": clean(entry.get("proof_date")), + "voucher_no": clean(summary.get("voucher_no")), + "draft_no": clean(entry.get("draft_no")) or clean(entry.get("confirmed_no")), + "ledger_account_name": "", + "voucher_account_name": clean(entry.get("account_name")), + "ledger_vendor": "", + "voucher_vendor": clean(entry.get("vendor_name")), + "ledger_debit": 0.0, + "ledger_credit": 0.0, + "voucher_debit": amount if side == "debit" else 0.0, + "voucher_credit": amount if side == "credit" else 0.0, + "ledger_desc": "", + "voucher_desc": description, + "review_reason": "ERP_FULL_DRAFT_VOUCHER_CONTEXT", + "matched_case": "ERP_CONTEXT_ROW", + "ledger_row_key": "", + "voucher_row_key": "", + "match_identity_key": "", + } + row["voucher_row_key"] = build_voucher_row_key(row) + row["match_identity_key"] = row["voucher_row_key"] + return row + + +def supplement_group_with_full_erp_vouchers( + group: dict[str, Any], + status_key: str, +) -> dict[str, Any]: + if status_key not in {"voucher_matched", "voucher_recheck"}: + return group + rows = [dict(row) for row in group.get("rows") or []] + requested_bases = row_draft_bases(rows, group.get("summary") or {}) + if not requested_bases: + return group + seen_voucher_rows = { + voucher_row_identity(row) + for row in rows + if has_erp_value(row) + } + seen_draft_rows = { + clean(row.get("draft_no")) + for row in rows + if has_erp_value(row) and clean(row.get("draft_no")) + } + added = False + for draft_base in sorted(requested_bases): + for entry in RAW_ERP_ROWS_BY_DRAFT_BASE.get(draft_base, []): + context_row = raw_erp_context_row(entry, group) + draft_no = clean(context_row.get("draft_no")) + if draft_no and draft_no in seen_draft_rows: + continue + identity = voucher_row_identity(context_row) + if identity in seen_voucher_rows: + continue + rows.append(context_row) + seen_voucher_rows.add(identity) + if draft_no: + seen_draft_rows.add(draft_no) + added = True + if not added: + return group + supplemented = { + "summary": dict(group.get("summary") or {}), + "rows": rows, + "source_group_index": group.get("source_group_index"), + } + supplemented["summary"] = rebuild_summary(supplemented, status_key) + return supplemented + + def insert_group(conn: sqlite3.Connection, signature: str, status_key: str, group_index: int, group: dict[str, Any]) -> None: summary = dict(group["summary"]) summary.update( @@ -2963,6 +3044,12 @@ def main() -> None: apply_split_draft_row_matches(status_groups) apply_exact_reversal_pairs_to_status_groups(status_groups) invariant_diagnostics = enforce_wehago_status_invariants(status_groups) + log_step("expanding matched ERP vouchers for display") + for display_status_key in ("voucher_matched", "voucher_recheck"): + status_groups[display_status_key] = [ + supplement_group_with_full_erp_vouchers(group, display_status_key) + for group in status_groups.get(display_status_key) or [] + ] excepted_wehago_keys = { group_identity(group) for group in status_groups.get("voucher_excepted") or [] diff --git a/scripts/redownload_and_fix_hanmac_ledger_account.py b/scripts/redownload_and_fix_hanmac_ledger_account.py new file mode 100644 index 0000000..7f5de83 --- /dev/null +++ b/scripts/redownload_and_fix_hanmac_ledger_account.py @@ -0,0 +1,285 @@ +from __future__ import annotations + +import argparse +import json +import shutil +import sys +from datetime import datetime +from pathlib import Path +from typing import Any + +from openpyxl import load_workbook +from sqlalchemy import create_engine, text + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + +import scripts.retry_failed_wehago_accounts_direct as direct +import scripts.wehago_data_download_2022_work as wehago +import scripts.wehago_ledger_api_download as api_download +from runtime_config import DB_PATH +from wehago_compare import compute_file_hash, detect_file_kind, import_ledger_rows, rebuild_comparison_results + +ROOT = Path(__file__).resolve().parents[1] +REPORT_DIR = ROOT / "reports" / "wehago_account_fixes" + + +def clean(value: Any) -> str: + return "" if value is None else str(value).strip() + + +def account_from_args(account_code: str, account_name: str) -> wehago.Account: + if account_name: + return wehago.Account(account_code, account_name) + for account in wehago.ACCOUNTS: + if account.code == account_code: + return account + return wehago.Account(account_code, "") + + +def download_account(year: int, account: wehago.Account, output_dir: Path, debugger_address: str) -> Path: + wehago.CHROME_DEBUGGER_ADDRESS = debugger_address + wehago.DOWNLOAD_DIR = output_dir + driver = wehago.build_driver(output_dir) + try: + cookies = direct.cookie_map(driver) + finally: + driver.quit() + + rows = direct.fetch_ledger_rows(year, account, cookies) + api_download.validate_api_rows(account, rows) + output_dir.mkdir(parents=True, exist_ok=True) + target = output_dir / account.safe_filename + api_download.write_api_rows(target, account, rows) + api_download.write_progress( + output_dir, + { + "status": "completed", + "completed": 1, + "total": 1, + "accounts": [ + { + "account_code": account.code, + "account_name": account.name, + "api_request_code": f"{account.code}00", + "api_response_rows": len(rows), + "path": str(target), + } + ], + "failures": [], + }, + ) + return target + + +def upsert_fix_source_file( + conn: Any, + path: Path, + file_kind: str, + year: int, + sheet_name: str, + header: list[str], +) -> tuple[int, bool]: + row = conn.execute( + text( + """ + SELECT id, file_hash, modified_ts + FROM wehago_source_files + WHERE file_path = :file_path + """ + ), + {"file_path": str(path)}, + ).mappings().first() + file_hash = compute_file_hash(path) + modified_ts = path.stat().st_mtime + payload = { + "file_kind": file_kind, + "file_path": str(path), + "file_name": path.name, + "relative_path": f"account_fix/{year}/{path.name}", + "year_hint": year, + "sheet_name": sheet_name, + "file_size": path.stat().st_size, + "modified_ts": modified_ts, + "file_hash": file_hash, + "header_json": str(header), + "source_origin": "account_fix", + } + if row is None: + result = conn.execute( + text( + """ + INSERT INTO wehago_source_files ( + file_kind, file_path, file_name, relative_path, year_hint, sheet_name, + file_size, modified_ts, file_hash, header_json, source_origin + ) VALUES ( + :file_kind, :file_path, :file_name, :relative_path, :year_hint, :sheet_name, + :file_size, :modified_ts, :file_hash, :header_json, :source_origin + ) + """ + ), + payload, + ) + return int(result.lastrowid), True + + changed = row["file_hash"] != file_hash or float(row["modified_ts"]) != float(modified_ts) + conn.execute( + text( + """ + UPDATE wehago_source_files + SET file_kind = :file_kind, + file_name = :file_name, + relative_path = :relative_path, + year_hint = :year_hint, + sheet_name = :sheet_name, + file_size = :file_size, + modified_ts = :modified_ts, + file_hash = :file_hash, + header_json = :header_json, + source_origin = :source_origin + WHERE id = :id + """ + ), + {**payload, "id": row["id"]}, + ) + return int(row["id"]), changed + + +def import_account_file( + db_path: Path, + path: Path, + year: int, + account: wehago.Account, + run_dir: Path, + *, + skip_rebuild: bool = False, + no_backup: bool = False, + skip_cache_clear: bool = False, +) -> dict[str, Any]: + file_kind, header, sheet_name = detect_file_kind(path) + if file_kind != "ledger": + raise ValueError(f"계정별원장 파일이 아닙니다: {path}") + + backup_path: Path | None = None + if not no_backup: + backup_path = run_dir / f"data_before_{year}_{account.code}_{datetime.now():%Y%m%d_%H%M%S}.db" + backup_path.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(db_path, backup_path) + + engine = create_engine(f"sqlite:///{db_path}", connect_args={"check_same_thread": False}) + workbook = load_workbook(path, read_only=True, data_only=True) + try: + sheet = workbook.worksheets[0] + with engine.begin() as conn: + conn.execute(text("PRAGMA busy_timeout = 300000")) + source_id, _changed = upsert_fix_source_file(conn, path, file_kind, year, sheet_name, header) + deleted = int( + conn.execute( + text( + """ + DELETE FROM wehago_ledger_rows + WHERE fiscal_year = :year + AND account_code = :account_code + """ + ), + {"year": year, "account_code": account.code}, + ).rowcount + or 0 + ) + inserted = import_ledger_rows( + conn, + source_id, + sheet_name, + sheet.iter_rows(min_row=2, values_only=True), + year, + ) + conn.execute( + text( + """ + UPDATE wehago_source_files + SET row_count = :row_count, + imported_at = CURRENT_TIMESTAMP + WHERE id = :source_id + """ + ), + {"row_count": inserted, "source_id": source_id}, + ) + if not skip_rebuild: + rebuild_comparison_results(conn) + if not skip_cache_clear: + for table in ( + "wehago_metric_count_cache", + "wehago_result_row_cache", + "wehago_pair_recommend_cache", + "wehago_summary_range_cache", + "wehago_compare_query_metrics", + "wehago_compare_query_groups", + "wehago_compare_query_rows", + "wehago_compare_query_page_cache", + "wehago_compare_final_status_projection", + "wehago_status_projection_groups", + ): + conn.execute(text(f"DELETE FROM {table}")) + finally: + workbook.close() + engine.dispose() + + return { + "db_backup": str(backup_path) if backup_path else "", + "source_path": str(path), + "account_code": account.code, + "account_name": account.name, + "deleted_rows": deleted, + "inserted_rows": inserted, + "comparison_rebuilt": not skip_rebuild, + "cache_cleared": not skip_cache_clear, + } + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="WEHAGO 계정별원장 특정 계정을 재다운로드하고 DB 행을 교체합니다.") + parser.add_argument("--year", type=int, required=True) + parser.add_argument("--account-code", required=True) + parser.add_argument("--account-name", default="") + parser.add_argument("--db", type=Path, default=DB_PATH) + parser.add_argument("--debugger-address", default="127.0.0.1:9225") + parser.add_argument("--source-file", type=Path, help="재다운로드 대신 이 원장 파일을 DB에 반영합니다.") + parser.add_argument("--skip-download", action="store_true") + parser.add_argument("--skip-rebuild", action="store_true", help="원장 행만 교체하고 비교 결과 재생성은 건너뜁니다.") + parser.add_argument("--no-backup", action="store_true", help="이미 별도 백업이 있을 때 추가 DB 백업 생성을 건너뜁니다.") + parser.add_argument("--skip-cache-clear", action="store_true", help="비교/조회 캐시 삭제를 건너뜁니다.") + parser.add_argument("--output-dir", type=Path) + return parser.parse_args() + + +def main() -> int: + args = parse_args() + account = account_from_args(clean(args.account_code), clean(args.account_name)) + run_dir = args.output_dir or (REPORT_DIR / f"{args.year}_{account.code}_{datetime.now():%Y%m%d_%H%M%S}") + run_dir.mkdir(parents=True, exist_ok=True) + + if args.skip_download: + if not args.source_file: + raise ValueError("--skip-download 사용 시 --source-file이 필요합니다.") + source_path = args.source_file + else: + source_path = download_account(args.year, account, run_dir, args.debugger_address) + + result = import_account_file( + args.db, + source_path, + args.year, + account, + run_dir, + skip_rebuild=args.skip_rebuild, + no_backup=args.no_backup, + skip_cache_clear=args.skip_cache_clear, + ) + result_path = run_dir / "fix_result.json" + result_path.write_text(json.dumps(result, ensure_ascii=False, indent=2), encoding="utf-8") + print(f"result={result_path}") + print(json.dumps(result, ensure_ascii=False, indent=2)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/refine_wehago_cost_type_groups.py b/scripts/refine_wehago_cost_type_groups.py new file mode 100644 index 0000000..f3bcffd --- /dev/null +++ b/scripts/refine_wehago_cost_type_groups.py @@ -0,0 +1,301 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import argparse +import csv +from collections import Counter, defaultdict +from dataclasses import dataclass +from datetime import datetime +from pathlib import Path + + +OPERATING = { + "운영비", + "통신비", + "전력비", + "수도광열비", + "지급임차료", + "건물관리비", + "수선비", + "사무용품비", + "소모품비", + "도서인쇄비", + "보험료", +} +PEOPLE = {"인건비", "복리후생비"} +TRAVEL = {"여비교통비", "해외출장비"} +OUTSOURCE = {"외주비", "연구개발비"} +ORGANIZATION = { + "접대비", + "접대비(기업업무추진비)", + "행사비용", + "교육훈련비", + "부서비", + "광고선전비", +} +TAX_FEE = { + "세금과공과금", + "지급수수료", + "세금·사회보험정산", + "이자비용", +} +SETTLEMENT = { + "매입채무결제", + "매출채권회수", + "차입금거래", + "예금계좌대체", +} +ASSET = {"유형자산처분"} + + +@dataclass +class RefinedRow: + status: str + compare_voucher_no: str + major_type: str + detail_type: str + original_type: str + basis_account: str + basis_amount: str + accounts: str + descriptions: str + note: str + + +def clean(value: str) -> str: + return " ".join(str(value or "").split()) + + +def classify_prepaid(description: str) -> tuple[str, str, str]: + text = clean(description) + has_operation = any(token in text for token in ("운영비", "운영경비", "공동운영", "합사")) + has_family_event = any(token in text for token in ("결혼", "빙부상", "경조")) + if has_operation: + note = "적요 기준 운영·공동경비" + if has_family_event: + note += "; 경조사 항목 포함" + return "현장·사무운영비", "운영·공동경비", note + if "출장" in text: + return "출장·교통비", "출장 전도금", "적요 기준 출장비" + if "인건비" in text: + return "인건비·복리후생", "인건비 정산", "적요 기준 인건비" + if "용역비" in text: + return "외주·연구개발비", "용역비 정산", "적요 기준 용역비" + if any(token in text for token in ("결혼", "빙부상", "경조", "가불")): + return "인건비·복리후생", "경조사·가불", "적요 기준 임직원 경조사/가불" + if any(token in text for token in ("임대료", "임차료")): + return "현장·사무운영비", "임차료", "적요 기준 임차료" + if any(token in text for token in ("인지세", "소송")): + return "세금·공과·수수료", "소송·인지세", "적요 기준 소송 관련 인지세" + if "업무차량" in text or "차량 구입" in text: + return "자산취득·처분", "차량취득", "유지비가 아닌 업무차량 자산 취득" + if "국고보조금" in text: + return "국고보조금·연구비", "보조금 상계", "적요 기준 국고보조금 상계" + if "인쇄비" in text or "청소비" in text: + return "현장·사무운영비", "인쇄·청소비", "적요 기준 현장 인쇄/청소비" + return "기타", "선급·전도금", "적요만으로 비용 성격 확정 곤란" + + +def refine(row: dict[str, str]) -> RefinedRow: + original = clean(row.get("category", "")) + accounts = clean(row.get("accounts", "")) + description = clean(row.get("descriptions", "")) + note = "" + + if "국고보조금" in accounts: + major, detail = "국고보조금·연구비", original + note = "국고보조금 계정 포함" + elif original == "선급·전도금거래": + major, detail, note = classify_prepaid(description) + elif original == "금융상품거래": + major, detail = "금융상품거래", original + elif original in OPERATING: + major, detail = "현장·사무운영비", original + elif original == "차량유지비": + major, detail = "차량비", "차량유지비" + elif original in TRAVEL: + major, detail = "출장·교통비", original + elif original in PEOPLE: + major, detail = "인건비·복리후생", original + elif original in OUTSOURCE: + major, detail = "외주·연구개발비", original + elif original in ORGANIZATION: + major, detail = "접대·행사·교육비", original + elif original in TAX_FEE: + major, detail = "세금·공과·수수료", original + elif original in SETTLEMENT: + major, detail = "채권·채무·예금거래", original + elif original == "수익거래": + major, detail = "수익거래", row.get("basis_account", "") or original + elif original in ASSET: + major, detail = "자산취득·처분", original + elif "부가세" in description: + major, detail = "세금·공과·수수료", original or "부가세 수정" + note = "적요 기준 부가세 수정·정산" + elif "관리비" in description: + major, detail = "현장·사무운영비", original or "관리비" + note = "적요 기준 관리비" + elif any(token in description for token in ("급여", "성과급")): + major, detail = "인건비·복리후생", original or "급여·성과급" + note = "적요 기준 급여·성과급" + elif "임차보증금" in description: + major, detail = "자산취득·처분", "임차보증금" + note = "적요 기준 임차보증금" + else: + major, detail = "기타", original or row.get("basis_account", "") or "미분류" + note = f"소수 항목: {detail}" + + if original == "기타 자산·부채대체" and "외화환산손실" in accounts: + major, detail = "기타", "외화환산손실" + note = "소수 항목: 외화환산손실" + + return RefinedRow( + status=clean(row.get("status", "")), + compare_voucher_no=clean(row.get("compare_voucher_no", "")), + major_type=major, + detail_type=clean(detail), + original_type=original, + basis_account=clean(row.get("basis_account", "")), + basis_amount=clean(row.get("basis_amount", "")), + accounts=accounts, + descriptions=description, + note=note, + ) + + +def pct(count: int, total: int) -> str: + return f"{count / total * 100:.1f}%" if total else "0.0%" + + +def write_csv(path: Path, rows: list[RefinedRow]) -> None: + with path.open("w", encoding="utf-8-sig", newline="") as handle: + writer = csv.DictWriter(handle, fieldnames=list(RefinedRow.__dataclass_fields__)) + writer.writeheader() + for row in rows: + writer.writerow(row.__dict__) + + +def write_report(path: Path, source_path: Path, rows: list[RefinedRow]) -> None: + lines = [ + "# WEHAGO Unmatched/Recheck 비용 유형 통합안", + "", + f"- 원천 시험 분류: `{source_path}`", + f"- 대상: {len(rows):,}전표", + "- 대분류는 화면·보고용, 세부유형과 비고는 검토용으로 유지", + "", + "## 제안 구조", + "", + "| 대분류 | 포함 기준 |", + "|---|---|", + "| 현장·사무운영비 | 운영비, 통신·전력·수도광열, 임차, 관리·수선, 사무용품·소모품·인쇄, 보험 |", + "| 차량비 | 차량유지비, 차량보험·정비·렌탈 등 차량 운영비 |", + "| 출장·교통비 | 여비교통비, 해외출장비, 출장 전도금 |", + "| 인건비·복리후생 | 급여·임금, 복리후생, 임직원 경조사·가불 |", + "| 외주·연구개발비 | 외주비, 연구개발비, 적요상 용역비 정산 |", + "| 접대·행사·교육비 | 접대, 행사, 교육, 부서비, 광고선전 |", + "| 세금·공과·수수료 | 세금, 사회보험 정산, 지급수수료, 이자비용, 소송 인지세 |", + "| 금융상품거래 | 기타예금, 금융상품, 채권·유가증권·투자자산 |", + "| 채권·채무·예금거래 | 채권회수, 채무결제, 차입금, 계좌대체 |", + "| 수익거래 | 이자·용역·임대·잡이익 등 수익성 전표 |", + "| 국고보조금·연구비 | 국고보조금 계정이 포함된 연구비·정산·상계 |", + "| 자산취득·처분 | 업무차량 등 유형자산 취득·처분, 임차보증금 |", + "| 기타 | 소수이거나 독립 대분류 실익이 낮은 항목. 원 계정은 비고에 표시 |", + ] + + for status in ("voucher_unmatched", "voucher_recheck"): + status_rows = [row for row in rows if row.status == status] + counts = Counter(row.major_type for row in status_rows) + lines.extend( + [ + "", + f"## {status}", + "", + "| 대분류 | 건수 | 비중 |", + "|---|---:|---:|", + ] + ) + for major, count in counts.most_common(): + lines.append(f"| {major} | {count:,} | {pct(count, len(status_rows))} |") + + prepaid_rows = [row for row in rows if row.original_type == "선급·전도금거래"] + prepaid_counts = Counter((row.major_type, row.detail_type) for row in prepaid_rows) + lines.extend( + [ + "", + "## 선급금·전도금 적요 재분류", + "", + "| 재분류 | 세부유형 | 건수 |", + "|---|---|---:|", + ] + ) + for (major, detail), count in prepaid_counts.most_common(): + lines.append(f"| {major} | {detail} | {count:,} |") + lines.extend( + [ + "", + "| 전표 | 재분류 | 적요 | 비고 |", + "|---|---|---|---|", + ] + ) + for row in prepaid_rows: + lines.append( + f"| {row.compare_voucher_no} | {row.major_type} / {row.detail_type} " + f"| {row.descriptions} | {row.note} |" + ) + + other_rows = [row for row in rows if row.major_type == "기타"] + other_counts = Counter(row.detail_type for row in other_rows) + lines.extend( + [ + "", + "## 기타와 비고 처리", + "", + f"- 기타는 {len(other_rows):,}건({pct(len(other_rows), len(rows))})입니다.", + "- 별도 카드나 대분류를 만들지 않고 `기타`로 합산하되, 표에는 세부유형을 비고로 표시하는 안이 적절합니다.", + "", + "| 비고 표시 유형 | 건수 |", + "|---|---:|", + ] + ) + for detail, count in other_counts.most_common(): + lines.append(f"| {detail} | {count:,} |") + + lines.extend( + [ + "", + "## 권고", + "", + "1. 화면의 1차 집계는 위 대분류를 사용합니다.", + "2. 표에는 `세부유형`과 `비고` 열을 두어 원래 비용 계정과 적요 판정 근거를 보존합니다.", + "3. 선급금·전도금은 계정명이 아니라 적요 우선으로 분류하고, 적요가 불충분하면 기타로 둡니다.", + "4. 서로 다른 비용이 섞인 전표는 금액 최대 비용을 대분류로 사용하되 비고에 다른 비용을 함께 표시합니다.", + "5. 외화환산손실처럼 매우 적은 항목은 기타로 합치고 비고에 원 계정을 표시합니다.", + ] + ) + path.write_text("\n".join(lines) + "\n", encoding="utf-8") + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument( + "--source", + type=Path, + default=Path("reports/wehago_unmatched_recheck_cost_types_2025_20260622_190121.csv"), + ) + parser.add_argument("--output-dir", type=Path, default=Path("reports")) + args = parser.parse_args() + + with args.source.open(encoding="utf-8-sig", newline="") as handle: + rows = [refine(row) for row in csv.DictReader(handle)] + + stamp = datetime.now().strftime("%Y%m%d_%H%M%S") + args.output_dir.mkdir(parents=True, exist_ok=True) + report_path = args.output_dir / f"wehago_cost_type_grouping_proposal_{stamp}.md" + csv_path = args.output_dir / f"wehago_cost_type_grouping_proposal_{stamp}.csv" + write_report(report_path, args.source, rows) + write_csv(csv_path, rows) + print(f"{report_path}\n{csv_path}\nrows={len(rows)}") + + +if __name__ == "__main__": + main() diff --git a/scripts/refresh_hanmac_wehago_ledgers.py b/scripts/refresh_hanmac_wehago_ledgers.py new file mode 100644 index 0000000..5fce136 --- /dev/null +++ b/scripts/refresh_hanmac_wehago_ledgers.py @@ -0,0 +1,383 @@ +from __future__ import annotations + +import argparse +import hashlib +import json +import os +import shutil +import sqlite3 +import sys +from datetime import datetime +from pathlib import Path +from typing import Any + +from sqlalchemy import create_engine, text + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + +import scripts.wehago_data_download_2022_work as wehago +import scripts.wehago_ledger_api_download as wehago_api +from runtime_config import DB_PATH, WEHAGO_SOURCE_ROOT +from wehago_compare import ( + detect_file_kind, + import_ledger_rows, + infer_year_hint, + rebuild_comparison_results, + upsert_source_file, +) + + +YEARS = (2022, 2023, 2024, 2025) +GISU_BY_YEAR = {2022: 27, 2023: 28, 2024: 29, 2025: 30} +HANMAC_LEDGER_URL = ( + "https://smarta.wehago.com/#/smarta/account/SABK0107?sao" + "&cno=1173867&cd_com=biz202103030006368&gisu={gisu}&yminsa=2026" + "&searchData={year}0101{year}1231&color=#1C90FB" + "&companyName=(%EC%A3%BC)%ED%95%9C%EB%A7%A5%EA%B8%B0%EC%88%A0&companyID=b21344" +) + + +def log(message: str) -> None: + print(f"[hanmac-refresh] {datetime.now().isoformat(timespec='seconds')} {message}", flush=True) + + +def normalized_rows(path: Path, account: wehago.Account) -> list[tuple[str, ...]]: + rows = wehago.read_downloaded_rows(path) + _headers, ledger_rows = wehago.extract_ledger_data_rows(rows, account) + return [ + tuple("" if value is None else str(value).strip() for value in values) + for _sheet_name, _row_number, values in ledger_rows + ] + + +def row_digest(rows: list[tuple[str, ...]]) -> str: + digest = hashlib.sha256() + for row in rows: + digest.update(json.dumps(row, ensure_ascii=False, separators=(",", ":")).encode("utf-8")) + digest.update(b"\n") + return digest.hexdigest() + + +def content_rows(rows: list[tuple[str, ...]]) -> list[tuple[str, ...]]: + """계정코드/계정명 열을 제외해 서로 다른 계정의 동일 원장을 탐지합니다.""" + return [row[:7] for row in rows] + + +def validate_staging(staging_dir: Path) -> dict[str, Any]: + accounts = wehago.discover_downloaded_accounts(staging_dir) + digest_accounts: dict[str, list[dict[str, str]]] = {} + failures: list[dict[str, str]] = [] + + for account in accounts: + path = wehago.find_account_file(account, staging_dir) + if path is None: + continue + try: + rows = normalized_rows(path, account) + except Exception as exc: + failures.append( + {"account_code": account.code, "account_name": account.name, "reason": f"파일 읽기 실패: {exc}"} + ) + continue + if not rows: + continue + digest = row_digest(content_rows(rows)) + digest_accounts.setdefault(digest, []).append( + {"account_code": account.code, "account_name": account.name, "path": str(path)} + ) + + duplicate_contents = [group for group in digest_accounts.values() if len(group) > 1] + progress_path = staging_dir / "_api_progress.json" + api_provenance: dict[str, Any] = {"passed": False, "reason": "API 진행 증빙 파일이 없습니다."} + if progress_path.exists(): + try: + progress = json.loads(progress_path.read_text(encoding="utf-8")) + manifest_codes = { + str(item.get("account_code")) + for item in progress.get("accounts", []) + if item.get("account_code") + } + staging_codes = {account.code for account in accounts} + api_provenance = { + "passed": progress.get("status") in {"completed", "completed_with_failures"} and manifest_codes == staging_codes, + "status": progress.get("status"), + "manifest_accounts": len(manifest_codes), + "staging_accounts": len(staging_codes), + "download_failures": progress.get("failures", []), + "missing_manifest_codes": sorted(staging_codes - manifest_codes), + "missing_staging_codes": sorted(manifest_codes - staging_codes), + } + except (OSError, ValueError, TypeError) as exc: + api_provenance = {"passed": False, "reason": f"API 진행 증빙 읽기 실패: {exc}"} + downloaded_files_valid = not failures and not duplicate_contents and api_provenance["passed"] + return { + "passed": downloaded_files_valid, + "downloaded_files_valid": downloaded_files_valid, + "file_failures": failures, + "duplicate_contents": duplicate_contents, + "api_provenance": api_provenance, + } + + +def compare_and_promote( + year: int, + staging_dir: Path, + canonical_dir: Path, + backup_dir: Path, + *, + promote: bool = False, + allow_partial_promote: bool = False, +) -> dict[str, Any]: + staging_accounts = wehago.discover_downloaded_accounts(staging_dir) + canonical_accounts = {account.code: account for account in wehago.discover_downloaded_accounts(canonical_dir)} + validation = validate_staging(staging_dir) + staging_codes = {account.code for account in staging_accounts} + missing = [ + {"account_code": code, "account_name": account.name} + for code, account in canonical_accounts.items() + if code not in staging_codes + ] + validation["missing_accounts"] = missing + validation["passed"] = validation["passed"] and not missing + report: dict[str, Any] = { + "year": year, + "validation": validation, + "promoted": promote and validation["passed"], + "changed": [], + "unchanged": [], + "new": [], + "missing": missing, + } + should_promote = bool(report["promoted"]) or ( + promote and allow_partial_promote and bool(validation.get("downloaded_files_valid")) + ) + report["partial_promoted"] = should_promote and not report["promoted"] + + for account in staging_accounts: + new_path = wehago.find_account_file(account, staging_dir) + old_account = canonical_accounts.get(account.code, account) + old_path = wehago.find_account_file(old_account, canonical_dir) + if new_path is None: + continue + + new_rows = normalized_rows(new_path, account) + new_digest = row_digest(new_rows) + item = { + "account_code": account.code, + "account_name": account.name, + "new_rows": len(new_rows), + "new_digest": new_digest, + } + if old_path is None: + if should_promote: + target = canonical_dir / new_path.name + shutil.copy2(new_path, target) + report["new"].append(item) + continue + + old_rows = normalized_rows(old_path, old_account) + old_digest = row_digest(old_rows) + item.update({"old_rows": len(old_rows), "old_digest": old_digest}) + if old_digest == new_digest: + report["unchanged"].append(item) + continue + + if should_promote: + backup_dir.mkdir(parents=True, exist_ok=True) + shutil.copy2(old_path, backup_dir / old_path.name) + target = canonical_dir / new_path.name + if target != old_path and old_path.exists(): + old_path.unlink() + shutil.copy2(new_path, target) + report["changed"].append(item) + + return report + + +def import_years_to_db(merged_paths: dict[int, Path], run_root: Path) -> dict[str, Any]: + db_backup = run_root / f"data_before_refresh_{datetime.now():%Y%m%d_%H%M%S}.db" + shutil.copy2(DB_PATH, db_backup) + engine = create_engine(f"sqlite:///{DB_PATH}", connect_args={"check_same_thread": False}) + imported: dict[str, Any] = {} + + with engine.begin() as conn: + for year, path in merged_paths.items(): + file_kind, header, sheet_name = detect_file_kind(path) + if file_kind != "ledger": + raise ValueError(f"통합 파일이 계정별원장 형식이 아닙니다: {path}") + + conn.execute(text("DELETE FROM wehago_ledger_rows WHERE fiscal_year = :year"), {"year": year}) + workbook = wehago.load_workbook(path, read_only=True, data_only=True) + try: + sheet = workbook.worksheets[0] + sample_rows = list(sheet.iter_rows(min_row=2, max_row=51, values_only=True)) + year_hint = infer_year_hint(path, file_kind, sample_rows) or year + source_id, _changed = upsert_source_file(conn, path, file_kind, year_hint, sheet_name, header) + conn.execute(text("DELETE FROM wehago_ledger_rows WHERE source_file_id = :source_id"), {"source_id": source_id}) + inserted = import_ledger_rows(conn, source_id, sheet_name, sheet.iter_rows(min_row=2, values_only=True), year) + conn.execute( + text( + "UPDATE wehago_source_files " + "SET row_count = :row_count, imported_at = CURRENT_TIMESTAMP WHERE id = :source_id" + ), + {"row_count": inserted, "source_id": source_id}, + ) + imported[str(year)] = {"path": str(path), "rows": inserted, "source_id": source_id} + finally: + workbook.close() + + conn.execute( + text( + "DELETE FROM wehago_source_files WHERE id NOT IN " + "(SELECT source_file_id FROM wehago_ledger_rows UNION SELECT source_file_id FROM wehago_voucher_rows)" + ) + ) + rebuild_comparison_results(conn) + for table in ( + "wehago_metric_count_cache", + "wehago_result_row_cache", + "wehago_pair_recommend_cache", + "wehago_summary_range_cache", + ): + conn.execute(text(f"DELETE FROM {table}")) + + return {"db_backup": str(db_backup), "imported": imported} + + +def run_download( + year: int, + staging_dir: Path, + debugger_address: str, + download_mode: str = "api", + target_accounts: list[wehago.Account] | None = None, +) -> None: + wehago.CHROME_DEBUGGER_ADDRESS = debugger_address + wehago.DOWNLOAD_DIR = staging_dir + wehago.FORCE_REDOWNLOAD = True + wehago.SKIP_ALREADY_DOWNLOADED = False + wehago.SEQUENTIAL_ACCOUNT_MODE = False + wehago.SCAN_EXISTING_ACCOUNT_MODE = False + wehago.SCAN_ALL_VISIBLE_ACCOUNT_MODE = False + wehago.ALLOW_UNCHANGED_DETAIL = False + wehago.OPEN_ACCOUNT_LEDGER_URL = True + wehago.REFRESH_BEFORE_RUN = False + wehago.EXPECTED_PERIOD_START = f"{year}.01.01" + wehago.EXPECTED_PERIOD_END = f"{year}.12.31" + wehago.ACCOUNT_LEDGER_URL = HANMAC_LEDGER_URL.format(year=year, gisu=GISU_BY_YEAR[year]) + wehago.MERGED_FILENAME = f"{year}_계정별원장_취합.xlsx" + if download_mode == "excel": + wehago.run(wehago.ACCOUNTS, merge=False, pause_on_failure=False) + return + + driver = wehago.build_driver(staging_dir) + try: + wehago.open_wehago(driver) + wehago.ensure_ledger_data_loaded(driver, wehago.ACCOUNTS) + accounts = target_accounts or wehago.ACCOUNTS + if not accounts: + raise RuntimeError("API 다운로드 대상 계정이 없습니다.") + wehago_api.download_accounts(driver, accounts, staging_dir) + finally: + driver.quit() + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="한맥기술 WEHAGO 계정별원장 재다운로드, 비교, 취합 및 DB 반영") + parser.add_argument("--years", nargs="+", type=int, default=list(YEARS)) + parser.add_argument("--debugger-address", default=os.environ.get("WEHAGO_CHROME_DEBUGGER_ADDRESS", "127.0.0.1:9225")) + parser.add_argument("--skip-download", action="store_true", help="이미 받은 staging 파일로 비교부터 실행") + parser.add_argument("--skip-db", action="store_true", help="파일 비교와 취합까지만 실행") + parser.add_argument("--staging-root", type=Path, help="--skip-download 시 사용할 연도별 staging 상위 폴더") + parser.add_argument( + "--download-mode", + choices=("api", "excel"), + default="api", + help="기본값 api는 로그인 브라우저의 원장 API 응답을 검증하여 저장합니다.", + ) + parser.add_argument( + "--promote-validated", + action="store_true", + help="검증을 통과한 staging만 기존 원장에 반영합니다. 기본값은 비교 보고서만 생성합니다.", + ) + parser.add_argument( + "--allow-partial-promote", + action="store_true", + help="API 검증을 통과한 계정 파일만 반영하고, 실패/누락 계정은 기존 파일을 유지합니다.", + ) + return parser.parse_args() + + +def main() -> int: + args = parse_args() + invalid = sorted(set(args.years) - set(YEARS)) + if invalid: + raise ValueError(f"지원하지 않는 연도입니다: {invalid}") + + run_stamp = datetime.now().strftime("%Y%m%d_%H%M%S") + run_root = WEHAGO_SOURCE_ROOT / "data_download" / "hanmac_refresh" / run_stamp + staging_root = args.staging_root or (run_root / "staging") + report_path = run_root / "refresh_report.json" + merged_paths: dict[int, Path] = {} + reports: list[dict[str, Any]] = [] + + for year in args.years: + canonical_dir = WEHAGO_SOURCE_ROOT / "data_download" / "hanmac" / str(year) + staging_dir = staging_root / str(year) + backup_dir = run_root / "replaced_originals" / str(year) + canonical_dir.mkdir(parents=True, exist_ok=True) + staging_dir.mkdir(parents=True, exist_ok=True) + + if not args.skip_download: + log(f"{year}년 신규 원장 다운로드 시작: {staging_dir}") + try: + target_accounts = wehago.discover_downloaded_accounts(canonical_dir) or None + run_download(year, staging_dir, args.debugger_address, args.download_mode, target_accounts) + except RuntimeError as exc: + if not wehago.discover_downloaded_accounts(staging_dir): + raise + log(f"{year}년 일부 계정 다운로드 실패를 기존 파일 유지 방식으로 처리합니다: {exc}") + + log(f"{year}년 기존 파일과 신규 파일 비교") + report = compare_and_promote( + year, + staging_dir, + canonical_dir, + backup_dir, + promote=args.promote_validated, + allow_partial_promote=args.allow_partial_promote, + ) + reports.append(report) + log( + f"{year}년 비교 완료: 변경 {len(report['changed'])}, 신규 {len(report['new'])}, " + f"동일 {len(report['unchanged'])}, 신규 다운로드 누락 {len(report['missing'])}" + ) + if not report["validation"]["passed"]: + log( + f"{year}년 검증 실패: 파일 오류 {len(report['validation']['file_failures'])}, " + f"서로 다른 계정의 동일 원장 {len(report['validation']['duplicate_contents'])}그룹" + ) + elif not args.promote_validated: + log(f"{year}년 검증 통과. --promote-validated가 없어 기존 원장은 변경하지 않습니다.") + merged = wehago.consolidate_download_dir(canonical_dir, output_name=f"{year}_계정별원장_취합.xlsx") + merged_paths[year] = merged + log(f"{year}년 통합본 생성: {merged}") + + payload: dict[str, Any] = {"run_stamp": run_stamp, "years": args.years, "reports": reports} + all_valid = all(report["validation"]["passed"] for report in reports) + all_promoted = all(report["promoted"] for report in reports) + if not args.skip_db and all_valid and all_promoted: + log("DB 백업 및 연도별 원장 교체 시작") + payload["db"] = import_years_to_db(merged_paths, run_root) + log("DB 반영 완료") + elif not args.skip_db: + payload["db_skipped"] = "모든 연도의 검증 통과 및 --promote-validated 지정 전에는 DB를 반영하지 않습니다." + log(payload["db_skipped"]) + + run_root.mkdir(parents=True, exist_ok=True) + report_path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8") + log(f"작업 보고서: {report_path}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/retry_2025_all_direct_runner.py b/scripts/retry_2025_all_direct_runner.py new file mode 100644 index 0000000..1ace930 --- /dev/null +++ b/scripts/retry_2025_all_direct_runner.py @@ -0,0 +1,86 @@ +from __future__ import annotations + +import json +import sys +from datetime import datetime +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + +import scripts.refresh_hanmac_wehago_ledgers as refresh +import scripts.retry_failed_wehago_accounts_direct as direct +import scripts.wehago_data_download_2022_work as wehago +import scripts.wehago_ledger_api_download as api_download + + +def main() -> int: + base = Path(r"\\wsl.localhost\Ubuntu\home\b17301\WEHAGO_DB\data_download\hanmac_refresh") + canonical = Path(r"\\wsl.localhost\Ubuntu\home\b17301\WEHAGO_DB\data_download\hanmac\2025") + run_root = base / f"retry_2025_all_direct_{datetime.now():%Y%m%d_%H%M%S}" + staging = run_root / "staging" / "2025" + staging.mkdir(parents=True, exist_ok=True) + wehago.CHROME_DEBUGGER_ADDRESS = "127.0.0.1:9225" + wehago.DOWNLOAD_DIR = run_root + driver = wehago.build_driver(run_root) + try: + cookies = direct.cookie_map(driver) + finally: + driver.quit() + accounts = wehago.discover_downloaded_accounts(canonical) + manifest: list[dict[str, object]] = [] + failures: list[dict[str, str]] = [] + + print(f"2025 all accounts {len(accounts)} staging {staging}", flush=True) + for index, account in enumerate(accounts, start=1): + try: + rows = direct.fetch_ledger_rows(2025, account, cookies) + api_download.validate_api_rows(account, rows) + api_download.write_api_rows(staging / account.safe_filename, account, rows) + manifest.append( + { + "account_code": account.code, + "account_name": account.name, + "api_request_code": f"{account.code}00", + "api_response_rows": len(rows), + } + ) + print(f"[{index}/{len(accounts)}] OK {account.code} rows={len(rows)}", flush=True) + except Exception as exc: + reason = f"{type(exc).__name__}: {exc}" + failures.append({"account_code": account.code, "account_name": account.name, "reason": reason}) + print(f"[{index}/{len(accounts)}] FAIL {account.code}: {reason}", flush=True) + + api_download.write_progress( + staging, + { + "status": "completed_with_failures" if failures else "completed", + "completed": len(manifest), + "total": len(accounts), + "accounts": manifest, + "failures": failures, + }, + ) + validation = refresh.validate_staging(staging) + summary = { + "year": 2025, + "requested": len(accounts), + "downloaded": len(manifest), + "failures": len(failures), + "failed_codes": [item["account_code"] for item in failures], + "staging": str(staging), + "validation": validation, + } + (run_root / "retry_summary.json").write_text( + json.dumps(summary, ensure_ascii=False, indent=2), + encoding="utf-8", + ) + print(f"SUMMARY_PATH {run_root / 'retry_summary.json'}", flush=True) + print( + f"VALIDATION_PASSED {validation.get('passed')} DUP_GROUPS {len(validation.get('duplicate_contents', []))}", + flush=True, + ) + return 0 if not failures else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/retry_failed_wehago_accounts.py b/scripts/retry_failed_wehago_accounts.py new file mode 100644 index 0000000..4b048a1 --- /dev/null +++ b/scripts/retry_failed_wehago_accounts.py @@ -0,0 +1,92 @@ +from __future__ import annotations + +import json +import os +import sys +from datetime import datetime +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT)) + +import scripts.refresh_hanmac_wehago_ledgers as refresh +import scripts.wehago_data_download_2022_work as wehago + + +SOURCE_RUNS = { + 2022: "20260615_194953", + 2023: "20260615_195024", + 2024: "20260615_195215", + 2025: "20260615_195405", +} + + +def wsl_unc(path: str) -> Path: + if os.name == "nt" and path.startswith("/home/"): + return Path(r"\\wsl.localhost\Ubuntu" + path.replace("/", "\\")) + return Path(path) + + +def report_entries(report: dict) -> list[dict]: + reports = report.get("reports") + if isinstance(reports, list): + return reports + if isinstance(reports, dict): + return list(reports.values()) + return [report] + + +def failed_codes_from_report(report_path: Path) -> list[str]: + report = json.loads(report_path.read_text(encoding="utf-8")) + entries = report_entries(report) + if not entries: + return [] + validation = entries[0].get("validation", {}) + api = validation.get("api_provenance") or validation.get("api_progress") or {} + return [ + str(item["account_code"]) + for item in api.get("download_failures", []) + if item.get("account_code") + ] + + +def main() -> int: + base = wsl_unc("/home/b17301/WEHAGO_DB/data_download/hanmac_refresh") + canonical_base = wsl_unc("/home/b17301/WEHAGO_DB/data_download/hanmac") + run_root = base / f"retry_failed_{datetime.now():%Y%m%d_%H%M%S}" + run_root.mkdir(parents=True, exist_ok=True) + summary: dict[str, object] = {} + + for year, source_run in SOURCE_RUNS.items(): + failed_codes = failed_codes_from_report(base / source_run / "refresh_report.json") + canonical_dir = canonical_base / str(year) + by_code = {account.code: account for account in wehago.discover_downloaded_accounts(canonical_dir)} + accounts = [by_code[code] for code in failed_codes if code in by_code] + staging_dir = run_root / "staging" / str(year) + staging_dir.mkdir(parents=True, exist_ok=True) + + print(f"YEAR {year}: retry accounts={len(accounts)} staging={staging_dir}", flush=True) + refresh.run_download(year, staging_dir, "127.0.0.1:9225", "api", accounts) + validation = refresh.validate_staging(staging_dir) + api = validation.get("api_provenance", {}) + failures = api.get("download_failures", []) + summary[str(year)] = { + "requested": len(accounts), + "downloaded": api.get("manifest_accounts"), + "failures": len(failures), + "failed_codes": [str(item.get("account_code")) for item in failures], + "staging": str(staging_dir), + "validation": validation, + } + (run_root / "retry_summary.json").write_text( + json.dumps(summary, ensure_ascii=False, indent=2), + encoding="utf-8", + ) + print(f"YEAR {year}: downloaded={api.get('manifest_accounts')} failures={len(failures)}", flush=True) + + print(f"SUMMARY_PATH {run_root / 'retry_summary.json'}", flush=True) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/retry_failed_wehago_accounts_direct.py b/scripts/retry_failed_wehago_accounts_direct.py new file mode 100644 index 0000000..895fe00 --- /dev/null +++ b/scripts/retry_failed_wehago_accounts_direct.py @@ -0,0 +1,226 @@ +from __future__ import annotations + +import base64 +import hashlib +import hmac +import json +import os +import sys +import time +import urllib.parse +import urllib.request +import uuid +from datetime import datetime +from pathlib import Path +from typing import Any + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT)) + +import scripts.refresh_hanmac_wehago_ledgers as refresh +import scripts.wehago_data_download_2022_work as wehago +import scripts.wehago_ledger_api_download as api_download + + +SOURCE_RUNS = { + 2022: "20260615_194953", + 2023: "20260615_195024", + 2024: "20260615_195215", + 2025: "20260615_195405", +} + +DIRECT_GISU_BY_YEAR = dict(refresh.GISU_BY_YEAR) + +LEDGER_API_URL = "https://api.wehago.com/smarta/sabk0107/jungi_slip/" +LEDGER_API_PATH = "/smarta/sabk0107/jungi_slip/" + + +def wsl_unc(path: str) -> Path: + if os.name == "nt" and path.startswith("/home/"): + return Path(r"\\wsl.localhost\Ubuntu" + path.replace("/", "\\")) + return Path(path) + + +def report_entries(report: dict[str, Any]) -> list[dict[str, Any]]: + reports = report.get("reports") + if isinstance(reports, list): + return reports + if isinstance(reports, dict): + return list(reports.values()) + return [report] + + +def failed_codes_from_report(report_path: Path) -> list[str]: + report = json.loads(report_path.read_text(encoding="utf-8")) + entries = report_entries(report) + if not entries: + return [] + validation = entries[0].get("validation", {}) + provenance = validation.get("api_provenance") or validation.get("api_progress") or {} + return [ + str(item["account_code"]) + for item in provenance.get("download_failures", []) + if item.get("account_code") + ] + + +def cookie_map(driver: Any) -> dict[str, str]: + cookies = {item["name"]: item.get("value", "") for item in driver.get_cookies()} + if "AUTH_A_TOKEN" not in cookies or "wehago_s" not in cookies: + script_cookies = driver.execute_script("return document.cookie || '';") or "" + for part in str(script_cookies).split(";"): + if "=" not in part: + continue + key, value = part.strip().split("=", 1) + cookies.setdefault(key, value) + missing = [key for key in ("AUTH_A_TOKEN", "wehago_s") if not cookies.get(key)] + if missing: + raise RuntimeError(f"WEHAGO 인증 쿠키를 찾지 못했습니다: {missing}") + return cookies + + +def wehago_sign(wehago_s: str, path_search: str, timestamp: str, transaction_id: str) -> str: + secret = base64.b64encode(hashlib.sha256((wehago_s + timestamp).encode("utf-8")).digest()).decode("ascii") + digest = hmac.new( + secret.encode("utf-8"), + (path_search + timestamp + transaction_id).encode("utf-8"), + hashlib.sha256, + ).digest() + return base64.b64encode(digest).decode("ascii") + + +def ledger_payload(year: int, gisu: int, account: wehago.Account, wehago_s: str, timestamp: str) -> dict[str, str]: + code = f"{account.code}00" + return { + "start_slip_date": f"{year}0101", + "end_slip_date": f"{year}1231", + "from_search_date": f"{year}0101", + "to_search_date": f"{year}1231", + "from_cd_acctit": code, + "to_cd_acctit": code, + "cd_details": "", + "gb_code": "0", + "gb_semok": "0", + "gisu": str(gisu), + "gubn_mon": "", + "gubn_total": "", + "gubn_balance": "0", + "balance_color": "0", + "sort": "1", + "mn_gubun": "0", + "mn_start": "0", + "mn_end": "9999999999999999", + "count_check": "1", + "timestamp": timestamp, + "cno": "1173867", + "ccode": "biz202103030006368", + "user_id": "b21344", + "ym_insa": "2026", + "wehago_s": wehago_s, + "oldview": "0" if year == 2025 else "1", + "locale": "ko", + } + + +def fetch_ledger_rows(year: int, account: wehago.Account, cookies: dict[str, str]) -> list[dict[str, Any]]: + timestamp = str(int(time.time())) + transaction_id = uuid.uuid4().hex[:10] + payload = ledger_payload(year, DIRECT_GISU_BY_YEAR[year], account, cookies["wehago_s"], timestamp) + body = urllib.parse.urlencode(payload).encode("utf-8") + headers = { + "Authorization": f"Bearer {cookies['AUTH_A_TOKEN']}", + "Content-Type": "application/x-www-form-urlencoded", + "timestamp": timestamp, + "transaction-id": transaction_id, + "wehago-sign": wehago_sign(cookies["wehago_s"], LEDGER_API_PATH, timestamp, transaction_id), + "client-id": "smarta", + "service": "smarta", + "cno": "1173867", + "Origin": "https://smarta.wehago.com", + "Referer": "https://smarta.wehago.com/", + } + request = urllib.request.Request(LEDGER_API_URL, data=body, headers=headers, method="POST") + with urllib.request.urlopen(request, timeout=30) as response: + rows = json.loads(response.read().decode("utf-8")) + if not isinstance(rows, list): + raise RuntimeError(f"{account.code}: API 응답이 목록이 아닙니다: {type(rows).__name__}") + return rows + + +def main() -> int: + base = wsl_unc("/home/b17301/WEHAGO_DB/data_download/hanmac_refresh") + canonical_base = wsl_unc("/home/b17301/WEHAGO_DB/data_download/hanmac") + run_root = base / f"retry_failed_direct_{datetime.now():%Y%m%d_%H%M%S}" + run_root.mkdir(parents=True, exist_ok=True) + + wehago.CHROME_DEBUGGER_ADDRESS = "127.0.0.1:9225" + wehago.DOWNLOAD_DIR = run_root + driver = wehago.build_driver(run_root) + try: + cookies = cookie_map(driver) + finally: + driver.quit() + + summary: dict[str, Any] = {} + for year, source_run in SOURCE_RUNS.items(): + failed_codes = failed_codes_from_report(base / source_run / "refresh_report.json") + canonical_dir = canonical_base / str(year) + by_code = {account.code: account for account in wehago.discover_downloaded_accounts(canonical_dir)} + accounts = [by_code[code] for code in failed_codes if code in by_code] + staging_dir = run_root / "staging" / str(year) + staging_dir.mkdir(parents=True, exist_ok=True) + manifest: list[dict[str, Any]] = [] + failures: list[dict[str, str]] = [] + + print(f"YEAR {year}: direct API retry accounts={len(accounts)} staging={staging_dir}", flush=True) + for index, account in enumerate(accounts, start=1): + try: + rows = fetch_ledger_rows(year, account, cookies) + api_download.validate_api_rows(account, rows) + target = staging_dir / account.safe_filename + api_download.write_api_rows(target, account, rows) + manifest.append( + { + "account_code": account.code, + "account_name": account.name, + "api_request_code": f"{account.code}00", + "api_response_rows": len(rows), + } + ) + print(f"[{year} {index}/{len(accounts)}] OK {account.code} rows={len(rows)}", flush=True) + except Exception as exc: + reason = f"{type(exc).__name__}: {exc}" + failures.append({"account_code": account.code, "account_name": account.name, "reason": reason}) + print(f"[{year} {index}/{len(accounts)}] FAIL {account.code}: {reason}", flush=True) + + api_download.write_progress( + staging_dir, + { + "status": "completed_with_failures" if failures else "completed", + "completed": len(manifest), + "total": len(accounts), + "accounts": manifest, + "failures": failures, + }, + ) + validation = refresh.validate_staging(staging_dir) + summary[str(year)] = { + "requested": len(accounts), + "downloaded": len(manifest), + "failures": len(failures), + "failed_codes": [item["account_code"] for item in failures], + "staging": str(staging_dir), + "validation": validation, + } + (run_root / "retry_summary.json").write_text( + json.dumps(summary, ensure_ascii=False, indent=2), + encoding="utf-8", + ) + print(f"YEAR {year}: downloaded={len(manifest)} failures={len(failures)}", flush=True) + + print(f"SUMMARY_PATH {run_root / 'retry_summary.json'}", flush=True) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/test_wehago_month_bundle_shadow.py b/scripts/test_wehago_month_bundle_shadow.py new file mode 100644 index 0000000..b481a8b --- /dev/null +++ b/scripts/test_wehago_month_bundle_shadow.py @@ -0,0 +1,276 @@ +#!/usr/bin/env python3 +"""Shadow-test month-token allocation for bundled ERP drafts. + +This script does not modify projection tables. It focuses on cases where one +ERP draft contains several monthly rows and WEHAGO vouchers should be allocated +to the matching month row, not merely the closest amount row. +""" + +from __future__ import annotations + +import json +import re +import sqlite3 +from collections import Counter, defaultdict +from pathlib import Path +from typing import Any + +import test_wehago_row_allocator_shadow as base + +DB = Path("/home/b17301/intranet-runtime/db/data.db") +YEAR = 2025 + + +def month_tokens(*parts: Any) -> set[int]: + text = " ".join(base.clean(p) for p in parts) + found: set[int] = set() + for m in re.finditer(r"(? int: + return int(key[1][:2]) + + +def expanded_account_family(name: str) -> str: + text = re.sub(r"\s+", "", base.clean(name)) + fam = base.account_family(text) + if text in {"외주비"} or "기술협력비" in text or "설계외주비" in text: + return "expense:외주비" + if "수도광열비" in text or "전력비" in text or "전기요금" in text: + return "expense:수도광열비" + if "연구개발비" in text: + return "expense:연구개발비" + return fam + + +def expanded_compatible(left: str, right: str) -> int: + lf = expanded_account_family(left) + rf = expanded_account_family(right) + if lf and rf and lf == rf: + return 30 + score = base.compatible_account(left, right) + if score: + return score + if lf.startswith("expense:") and rf.startswith("expense:"): + return 10 + return 0 + + +def month_aware_row_score( + key: tuple[int, str, str], + left: dict[str, Any], + right: dict[str, Any], + monthly_bundle: bool, +) -> int: + score = base.amount_score(left, right) + if score < 0: + return score + acct = expanded_compatible( + base.clean(left.get("ledger_account_name")), + base.clean(right.get("voucher_account_name")), + ) + if acct <= 0: + return -20 + score += acct + + l_months = month_tokens(left.get("ledger_desc"), left.get("ledger_date")) + r_months = month_tokens(right.get("voucher_desc"), right.get("proof_date")) + if not l_months: + l_months = {key_month(key)} + + if monthly_bundle and r_months: + if l_months & r_months: + score += 55 + else: + # In monthly ERP bundles, wrong-month rows must lose even when amount + # and account are close. This prevents 03월 WEHAGO rows attaching to + # 05월 ERP rows merely because the amount is nearby. + score -= 80 + + lv = base.vendor_key(base.clean(left.get("ledger_vendor"))) + rv = base.vendor_key(base.clean(right.get("voucher_vendor"))) + if lv and rv and (lv in rv or rv in lv): + score += 18 + elif lv and rv: + score -= 8 + + lt = base.tokens(base.clean(left.get("ledger_desc"))) + rt = base.tokens(base.clean(right.get("voucher_desc"))) + overlap = len(lt & rt) + if overlap: + score += min(14, overlap * 3) + + proof = base.mmdd(base.clean(right.get("proof_date"))) + if proof and proof == f"{YEAR}-{base.mmdd(left.get('ledger_date'))}": + score += 16 + return score + + +def is_monthly_bundle(erows: list[dict[str, Any]]) -> bool: + months: set[int] = set() + for row in erows: + months.update(month_tokens(row.get("voucher_desc"), row.get("proof_date"))) + return len(months) >= 2 + + +def allocate_month_aware( + key: tuple[int, str, str], + left_rows: list[dict[str, Any]], + right_rows: list[dict[str, Any]], +) -> tuple[list[tuple[int, int, int]], list[int], list[int]]: + monthly = is_monthly_bundle(right_rows) + scored: list[tuple[int, int, int]] = [] + for i, lrow in enumerate(left_rows): + for j, rrow in enumerate(right_rows): + score = month_aware_row_score(key, lrow, rrow, monthly) + if score >= 55: + scored.append((score, i, j)) + scored.sort(reverse=True) + used_l: set[int] = set() + used_r: set[int] = set() + pairs: list[tuple[int, int, int]] = [] + for score, i, j in scored: + if i in used_l or j in used_r: + continue + used_l.add(i) + used_r.add(j) + pairs.append((i, j, score)) + return pairs, [i for i in range(len(left_rows)) if i not in used_l], [j for j in range(len(right_rows)) if j not in used_r] + + +def candidate_bases_month_aware( + key: tuple[int, str, str], + left_rows: list[dict[str, Any]], + erp_by_base: dict[str, list[dict[str, Any]]], + amount_index: dict[tuple[str, int], set[str]], +) -> list[tuple[str, int, int, int, int]]: + bases: Counter[str] = Counter() + for lrow in left_rows: + for side, amt in base.signed_amounts(lrow, "wehago"): + if abs(amt) >= 0.5: + bases.update(amount_index.get((side, round(amt)), set())) + ranked: list[tuple[str, int, int, int, int]] = [] + for draft_base, hits in bases.items(): + if hits < 1: + continue + pairs, _, _ = allocate_month_aware(key, left_rows, erp_by_base[draft_base]) + if not pairs: + continue + total = sum(score for _, _, score in pairs) + erows = erp_by_base[draft_base] + monthly = is_monthly_bundle(erows) + month_hit = 0 + if monthly: + want = {key_month(key)} + for _, j, _ in pairs: + if want & month_tokens(erows[j].get("voucher_desc"), erows[j].get("proof_date")): + month_hit += 1 + same_year = 1 if draft_base.startswith(f"11-{key[0]}") else 0 + ranked.append((draft_base, len(pairs), total, month_hit, same_year)) + ranked.sort(key=lambda x: (x[3], x[4], x[1], x[2]), reverse=True) + return ranked[:8] + + +def row_summary(left_rows: list[dict[str, Any]], erows: list[dict[str, Any]], pairs: list[tuple[int, int, int]]) -> list[dict[str, Any]]: + out = [] + for i, j, score in sorted(pairs, key=lambda x: (x[0], x[1])): + l = left_rows[i] + r = erows[j] + out.append( + { + "wehago_account": l.get("ledger_account_name"), + "wehago_amount": base.money(l.get("ledger_debit")) or base.money(l.get("ledger_credit")), + "wehago_desc": l.get("ledger_desc"), + "erp_row": r.get("draft_no"), + "erp_account": r.get("voucher_account_name"), + "erp_amount": base.money(r.get("voucher_debit")) or base.money(r.get("voucher_credit")), + "erp_desc": r.get("voucher_desc"), + "score": score, + } + ) + return out + + +def refined_ok( + key: tuple[int, str, str], + pairs: list[tuple[int, int, int]], + left_unmatched: list[int], + left_rows: list[dict[str, Any]], + erows: list[dict[str, Any]], +) -> tuple[bool, str]: + if base.has_vat(left_rows) and not base.has_exact_proof_date(key, erows): + return False, "부가세 전표 proof_date 불일치 또는 부재" + residual = {expanded_account_family(left_rows[i].get("ledger_account_name", "")) for i in left_unmatched} + settlement_only = bool(left_unmatched) and residual <= {"payable_ap", "payable_accrued", "cash", "receivable"} + if len(pairs) >= max(1, min(2, len(left_rows))): + return True, "월 토큰 기반 row 배정 가능" + if pairs and settlement_only: + return True, "핵심 row + 결제/상대계정 잔여" + return False, "월 토큰 기준 매칭 row 부족" + + +def main() -> None: + conn = sqlite3.connect(DB) + conn.row_factory = sqlite3.Row + groups = base.load_groups(conn) + raw_wehago = base.load_raw_wehago(conn) + erp_by_base = base.load_erp(conn) + amount_index = base.build_amount_index(erp_by_base) + + keys = [ + (2025, "03-05", "00001"), + (2025, "04-07", "00001"), + (2025, "05-07", "00001"), + (2025, "06-05", "00002"), + ] + target_base = "11-20250530-B0100-39" + report: dict[str, Any] = { + "current_counts": dict(sorted(Counter(g.status for g in groups.values()).items())), + "target_base": target_base, + "target_base_monthly_bundle": is_monthly_bundle(erp_by_base[target_base]), + "cases": {}, + } + + promotions = [] + detach_reviews = [] + for key in keys: + group = groups.get(key) + left = raw_wehago.get(key, []) + erows = erp_by_base[target_base] + pairs, left_unmatched, right_unmatched = allocate_month_aware(key, left, erows) + ok, reason = refined_ok(key, pairs, left_unmatched, left, erows) + ranked = candidate_bases_month_aware(key, left, erp_by_base, amount_index) + status = group.status if group else "missing" + current_bases = sorted(group.draft_bases) if group else [] + if ok and status in {"voucher_unmatched", "voucher_recheck"}: + promotions.append(key) + if target_base in current_bases and not pairs: + detach_reviews.append(key) + report["cases"][f"{key[1]} {key[2]}"] = { + "current_status": status, + "current_bases": current_bases, + "target_pairs": row_summary(left, erows, pairs), + "left_unmatched_count": len(left_unmatched), + "right_unmatched_count": len(right_unmatched), + "decision": reason, + "promotion_candidate": ok and status in {"voucher_unmatched", "voucher_recheck"}, + "top_candidates": ranked[:5], + } + + shadow_counts = Counter(g.status for g in groups.values()) + for key in promotions: + old = groups[key].status + shadow_counts[old] -= 1 + shadow_counts["voucher_matched"] += 1 + report["month_bundle_promotions"] = [f"{k[1]} {k[2]}" for k in promotions] + report["detach_reviews"] = [f"{k[1]} {k[2]}" for k in detach_reviews] + report["shadow_counts_if_month_bundle_applied"] = dict(sorted(shadow_counts.items())) + print(json.dumps(report, ensure_ascii=False, indent=2)) + + +if __name__ == "__main__": + main() diff --git a/scripts/test_wehago_row_allocator_shadow.py b/scripts/test_wehago_row_allocator_shadow.py new file mode 100644 index 0000000..006a98d --- /dev/null +++ b/scripts/test_wehago_row_allocator_shadow.py @@ -0,0 +1,553 @@ +#!/usr/bin/env python3 +"""Shadow-test WEHAGO matching with a post-match ERP row allocator. + +The script does not modify active projection tables. It checks whether a +separate row-allocation step fixes display pairing issues and identifies +unmatched/recheck vouchers that can be explained by currently unallocated ERP +rows from a shared draft. +""" + +from __future__ import annotations + +import argparse +import json +import re +import sqlite3 +from collections import Counter, defaultdict +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +DEFAULT_DB = Path("/home/b17301/intranet-runtime/db/data.db") +YEAR = 2025 + +KEEP_RECHECK_KEYS = { + (2025, "01-01", "00063"), + (2025, "02-21", "00176"), + (2025, "03-31", "00048"), + (2025, "04-16", "50009"), + (2025, "04-23", "00007"), + (2025, "06-30", "00156"), + (2025, "09-22", "50046"), + (2025, "12-19", "50036"), + (2025, "12-25", "50006"), +} + +FORCE_UNMATCHED_KEYS = { + (2025, "06-13", "00038"), + (2025, "07-07", "00001"), +} + + +def clean(value: Any) -> str: + return "" if value is None else str(value).strip() + + +def money(value: Any) -> float: + if value is None or value == "": + return 0.0 + try: + return float(str(value).replace(",", "").strip()) + except ValueError: + return 0.0 + + +def mmdd(value: Any) -> str: + text = clean(value) + m = re.match(r"^\d{4}-(\d{2})-(\d{2})$", text) + if m: + return f"{m.group(1)}-{m.group(2)}" + return text + + +def voucher_no(value: Any) -> str: + text = clean(value) + return text.zfill(5) if text.isdigit() else text + + +def draft_base(draft_no: Any) -> str: + return re.sub(r"-\d+$", "", clean(draft_no)) + + +def draft_suffix(draft_no: Any) -> int: + m = re.search(r"-(\d+)$", clean(draft_no)) + return int(m.group(1)) if m else 0 + + +def tokens(text: str) -> set[str]: + compact = re.sub(r"[^0-9A-Za-z가-힣]+", " ", clean(text).lower()) + return {t for t in compact.split() if len(t) >= 2} + + +def vendor_key(name: str) -> str: + text = re.sub(r"\(주\)|주식회사|유한회사|㈜|\s+", "", clean(name).lower()) + return re.sub(r"[^0-9a-z가-힣]", "", text) + + +def account_family(name: str) -> str: + text = re.sub(r"\s+", "", clean(name)) + if not text: + return "" + if "부가세대급금" in text or "매입세액" in text: + return "vat_in" + if "부가세예수금" in text or "매출세액" in text: + return "vat_out" + if "외상매입금" in text: + return "payable_ap" + if "미지급금" in text: + return "payable_accrued" + if "보통예금" in text or "현금" in text or "단기금융상품" in text or "기타예금" in text: + return "cash" + if "예수" in text: + if "주민" in text or "지방" in text: + return "withholding_local" + if "근로" in text: + return "withholding_labor" + if "사업" in text: + return "withholding_business" + if "기타" in text: + return "withholding_other" + return "withholding" + if "미수금" in text or "용역미수금" in text: + return "receivable" + for key in ( + "소모품", + "관리비", + "잡손실", + "도서인쇄", + "복리후생", + "지급수수료", + "지급임차료", + "여비교통", + "통신비", + "보험료", + ): + if key in text: + return f"expense:{key}" + return re.sub(r"^(원가\)|판관\))", "", text) + + +def compatible_account(left: str, right: str) -> int: + lf = account_family(left) + rf = account_family(right) + if not lf or not rf: + return 0 + if lf == rf: + return 30 + compatible = { + ("expense:관리비", "expense:지급임차료"), + ("expense:소모품", "expense:소모품"), + ("expense:도서인쇄", "expense:도서인쇄"), + ("expense:복리후생", "expense:복리후생"), + ("expense:지급수수료", "expense:지급수수료"), + ("withholding", "withholding_labor"), + ("withholding", "withholding_local"), + ("withholding", "withholding_business"), + ("withholding", "withholding_other"), + ("payable_ap", "receivable"), + ("payable_accrued", "receivable"), + } + if (lf, rf) in compatible or (rf, lf) in compatible: + return 24 + if lf.startswith("withholding") and rf.startswith("withholding"): + return 18 + if lf.startswith("expense:") and rf.startswith("expense:"): + return 10 + return 0 + + +def signed_amounts(row: dict[str, Any], side: str) -> list[tuple[str, float]]: + if side == "wehago": + return [("debit", money(row.get("ledger_debit"))), ("credit", money(row.get("ledger_credit")))] + return [("debit", money(row.get("voucher_debit"))), ("credit", money(row.get("voucher_credit")))] + + +def amount_score(left: dict[str, Any], right: dict[str, Any]) -> int: + best = -1000 + for l_side, l_amt in signed_amounts(left, "wehago"): + if abs(l_amt) < 0.5: + continue + for r_side, r_amt in signed_amounts(right, "erp"): + if abs(r_amt) < 0.5: + continue + if l_side == r_side and abs(l_amt - r_amt) < 0.5: + best = max(best, 45) + elif l_side != r_side and l_amt * r_amt < 0 and abs(abs(l_amt) - abs(r_amt)) < 0.5: + best = max(best, 38) + elif abs(abs(l_amt) - abs(r_amt)) < 0.5: + best = max(best, -40) + return best + + +def row_score(left: dict[str, Any], right: dict[str, Any]) -> int: + score = amount_score(left, right) + if score < 0: + return score + acct = compatible_account(clean(left.get("ledger_account_name")), clean(right.get("voucher_account_name"))) + if acct <= 0: + return -20 + score += acct + lv = vendor_key(clean(left.get("ledger_vendor"))) + rv = vendor_key(clean(right.get("voucher_vendor"))) + if lv and rv and (lv in rv or rv in lv): + score += 18 + elif lv and rv: + score -= 8 + lt = tokens(clean(left.get("ledger_desc"))) + rt = tokens(clean(right.get("voucher_desc"))) + overlap = len(lt & rt) + if overlap: + score += min(14, overlap * 3) + proof = mmdd(clean(right.get("proof_date"))) + if proof and proof == f"{YEAR}-{mmdd(left.get('ledger_date'))}": + score += 16 + return score + + +def allocate_rows(left_rows: list[dict[str, Any]], right_rows: list[dict[str, Any]]) -> tuple[list[tuple[int, int, int]], list[int], list[int]]: + scored: list[tuple[int, int, int]] = [] + for i, lrow in enumerate(left_rows): + for j, rrow in enumerate(right_rows): + score = row_score(lrow, rrow) + if score >= 55: + scored.append((score, i, j)) + scored.sort(reverse=True) + used_l: set[int] = set() + used_r: set[int] = set() + pairs: list[tuple[int, int, int]] = [] + for score, i, j in scored: + if i in used_l or j in used_r: + continue + used_l.add(i) + used_r.add(j) + pairs.append((i, j, score)) + return pairs, [i for i in range(len(left_rows)) if i not in used_l], [j for j in range(len(right_rows)) if j not in used_r] + + +@dataclass +class Group: + status: str + key: tuple[int, str, str] + draft_bases: set[str] + rows: list[dict[str, Any]] + + +def load_groups(conn: sqlite3.Connection) -> dict[tuple[int, str, str], Group]: + result: dict[tuple[int, str, str], Group] = {} + for row in conn.execute( + """ + SELECT status_key, fiscal_year, ledger_date, voucher_no, rows_json + FROM wehago_status_projection_groups + WHERE start_year = ? AND end_year = ? + """, + (YEAR, YEAR), + ): + key = (int(row["fiscal_year"] or YEAR), mmdd(row["ledger_date"]), voucher_no(row["voucher_no"])) + rows = json.loads(row["rows_json"] or "[]") + bases = {draft_base(r.get("draft_no")) for r in rows if clean(r.get("draft_no"))} + result[key] = Group(clean(row["status_key"]), key, bases, rows) + return result + + +def load_raw_wehago(conn: sqlite3.Connection) -> dict[tuple[int, str, str], list[dict[str, Any]]]: + result: dict[tuple[int, str, str], list[dict[str, Any]]] = defaultdict(list) + for row in conn.execute( + """ + SELECT id, fiscal_year, ledger_date, voucher_no, account_name, vendor_name, debit, credit, description, row_number + FROM wehago_ledger_rows + WHERE fiscal_year = ? AND COALESCE(compare_voucher_no, '') <> '' + ORDER BY ledger_date, voucher_no, row_number, id + """, + (YEAR,), + ): + key = (int(row["fiscal_year"] or YEAR), mmdd(row["ledger_date"]), voucher_no(row["voucher_no"])) + result[key].append( + { + "ledger_row_key": str(row["id"]), + "ledger_date": key[1], + "voucher_no": key[2], + "ledger_account_name": clean(row["account_name"]), + "ledger_vendor": clean(row["vendor_name"]), + "ledger_debit": money(row["debit"]), + "ledger_credit": money(row["credit"]), + "ledger_desc": clean(row["description"]), + } + ) + return result + + +def load_erp(conn: sqlite3.Connection) -> dict[str, list[dict[str, Any]]]: + result: dict[str, list[dict[str, Any]]] = defaultdict(list) + for row in conn.execute( + """ + SELECT id, fiscal_year, proof_date, confirmed_no, draft_no, account_name, + debit_supply, credit_supply, vendor_name, desc1, desc2, row_number + FROM wehago_voucher_rows + WHERE fiscal_year BETWEEN ? AND ? AND COALESCE(draft_no, '') <> '' + ORDER BY draft_no, row_number, id + """, + (YEAR - 1, YEAR + 1), + ): + base = draft_base(row["draft_no"]) + result[base].append( + { + "voucher_row_key": str(row["id"]), + "draft_no": clean(row["draft_no"]), + "proof_date": clean(row["proof_date"]), + "voucher_account_name": clean(row["account_name"]), + "voucher_vendor": clean(row["vendor_name"]), + "voucher_debit": money(row["debit_supply"]), + "voucher_credit": money(row["credit_supply"]), + "voucher_desc": " ".join(x for x in (clean(row["desc1"]), clean(row["desc2"])) if x), + "suffix": draft_suffix(row["draft_no"]), + } + ) + for rows in result.values(): + rows.sort(key=lambda r: (int(r.get("suffix") or 0), clean(r.get("draft_no")))) + return result + + +def build_amount_index(erp_by_base: dict[str, list[dict[str, Any]]]) -> dict[tuple[str, int], set[str]]: + amount_index: dict[tuple[str, int], set[str]] = defaultdict(set) + for base, erows in erp_by_base.items(): + for erow in erows: + for side, amt in signed_amounts(erow, "erp"): + if abs(amt) >= 0.5: + amount_index[(side, round(amt))].add(base) + return amount_index + + +def candidate_bases_for_group( + left_rows: list[dict[str, Any]], + erp_by_base: dict[str, list[dict[str, Any]]], + amount_index: dict[tuple[str, int], set[str]], +) -> list[tuple[str, int, int]]: + bases: Counter[str] = Counter() + for lrow in left_rows: + for side, amt in signed_amounts(lrow, "wehago"): + if abs(amt) >= 0.5: + bases.update(amount_index.get((side, round(amt)), set())) + ranked: list[tuple[str, int, int]] = [] + for base, hits in bases.items(): + if hits < 2 and len(left_rows) >= 2: + continue + pairs, _, _ = allocate_rows(left_rows, erp_by_base[base]) + if not pairs: + continue + total = sum(score for _, _, score in pairs) + ranked.append((base, len(pairs), total)) + ranked.sort(key=lambda x: (x[1], x[2]), reverse=True) + return ranked[:5] + + +def has_vat(rows: list[dict[str, Any]]) -> bool: + return any(account_family(row.get("ledger_account_name", "")) in {"vat_in", "vat_out"} for row in rows) + + +def has_exact_proof_date(key: tuple[int, str, str], erows: list[dict[str, Any]]) -> bool: + expected = f"{key[0]}-{key[1]}" + return any(clean(row.get("proof_date")) == expected for row in erows) + + +def is_refined_promotion( + key: tuple[int, str, str], + base: str, + pair_count: int, + score: int, + left_rows: list[dict[str, Any]], + erp_rows: list[dict[str, Any]], +) -> tuple[bool, str]: + if key in KEEP_RECHECK_KEYS: + return False, "사용자/검증 보류 유지 대상" + if key in FORCE_UNMATCHED_KEYS: + return False, "사용자 차단/비매칭 유지 대상" + if has_vat(left_rows) and not has_exact_proof_date(key, erp_rows): + return False, "부가세 전표 proof_date 불일치 또는 부재" + pairs, left_unmatched, _ = allocate_rows(left_rows, erp_rows) + residual_families = {account_family(left_rows[i].get("ledger_account_name", "")) for i in left_unmatched} + settlement_residual_only = bool(left_unmatched) and residual_families <= { + "payable_ap", + "payable_accrued", + "cash", + "receivable", + } + required_pairs = max(2, min(3, len(left_rows))) + if pair_count < required_pairs and not (pair_count >= 2 and settlement_residual_only): + return False, "매칭 row 수 부족" + if score < pair_count * 85: + return False, "점수 부족" + # Cross-year candidates are only allowed when proof_date anchors to the WEHAGO date. + if not base.startswith(f"11-{key[0]}") and not has_exact_proof_date(key, erp_rows): + return False, "전후년도 후보이나 proof_date 앵커 없음" + return True, "refined allocator 승격 가능" + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("--db", default=str(DEFAULT_DB)) + parser.add_argument("--output", default="") + args = parser.parse_args() + + conn = sqlite3.connect(args.db) + conn.row_factory = sqlite3.Row + groups = load_groups(conn) + raw_wehago = load_raw_wehago(conn) + erp_by_base = load_erp(conn) + amount_index = build_amount_index(erp_by_base) + + current_counts = Counter(group.status for group in groups.values()) + corrected_pairing_groups = [] + shifted_tax_groups = [] + new_promotions = [] + rejected_promotions = [] + existing_reallocations = [] + + for key, group in groups.items(): + left_rows = raw_wehago.get(key, []) + if group.status in {"voucher_matched", "voucher_recheck"} and group.draft_bases: + right_rows: list[dict[str, Any]] = [] + for base in sorted(group.draft_bases): + right_rows.extend(erp_by_base.get(base, [])) + pairs, _, _ = allocate_rows(left_rows, right_rows) + current_source_pairs = [ + r + for r in group.rows + if clean(r.get("ledger_account_name")) and clean(r.get("voucher_account_name")) + ] + current_mismatches = 0 + for r in current_source_pairs: + if abs( + abs(money(r.get("ledger_debit")) + money(r.get("ledger_credit"))) + - abs(money(r.get("voucher_debit")) + money(r.get("voucher_credit"))) + ) > 0.5: + current_mismatches += 1 + if pairs and current_mismatches >= 2: + corrected_pairing_groups.append( + { + "key": key, + "status": group.status, + "current_mismatches": current_mismatches, + "allocator_pairs": len(pairs), + "draft_bases": sorted(group.draft_bases), + } + ) + if any(account_family(r.get("ledger_account_name", "")).startswith("withholding") for r in left_rows): + if current_mismatches >= 3: + shifted_tax_groups.append( + { + "key": key, + "current_mismatches": current_mismatches, + "allocator_pairs": len(pairs), + "draft_bases": sorted(group.draft_bases), + } + ) + + if group.status in {"voucher_unmatched", "voucher_recheck"}: + ranked = candidate_bases_for_group(raw_wehago.get(key, []), erp_by_base, amount_index) + if ranked: + base, pair_count, total = ranked[0] + ok, reason = is_refined_promotion( + key, + base, + pair_count, + total, + raw_wehago.get(key, []), + erp_by_base.get(base, []), + ) + if ok: + new_promotions.append({"key": key, "base": base, "pair_count": pair_count, "score": total}) + elif pair_count >= 2 and total >= pair_count * 75: + rejected_promotions.append( + {"key": key, "base": base, "pair_count": pair_count, "score": total, "reject_reason": reason} + ) + + # Cases where a base currently belongs to multiple WEHAGO groups and the allocator can split rows. + base_to_groups: dict[str, list[tuple[tuple[int, str, str], Group]]] = defaultdict(list) + for key, group in groups.items(): + for base in group.draft_bases: + base_to_groups[base].append((key, group)) + for base, members in base_to_groups.items(): + if len(members) < 2: + continue + erows = erp_by_base.get(base, []) + if not erows: + continue + group_alloc = [] + used_erp: set[int] = set() + for key, _group in sorted(members): + pairs, _, _ = allocate_rows(raw_wehago.get(key, []), erows) + kept = [(i, j, s) for i, j, s in pairs if j not in used_erp] + for _, j, _ in kept: + used_erp.add(j) + if kept: + group_alloc.append((key, len(kept))) + if len(group_alloc) >= 2: + existing_reallocations.append({"base": base, "groups": group_alloc[:8], "member_count": len(members)}) + + promotion_keys = {tuple(item["key"]) for item in new_promotions} + shadow_counts = Counter(current_counts) + for key in promotion_keys: + old = groups[key].status + if old != "voucher_matched": + shadow_counts[old] -= 1 + shadow_counts["voucher_matched"] += 1 + + report = { + "current_counts": dict(sorted(current_counts.items())), + "shadow_counts_if_allocator_promotions_applied": dict(sorted(shadow_counts.items())), + "new_promotion_candidates": len(new_promotions), + "rejected_promotion_candidates": len(rejected_promotions), + "corrected_pairing_group_count": len(corrected_pairing_groups), + "shifted_tax_group_count": len(shifted_tax_groups), + "shared_draft_reallocation_group_count": len(existing_reallocations), + "samples": { + "new_promotions": new_promotions[:30], + "rejected_promotions": rejected_promotions[:30], + "corrected_pairing_groups": corrected_pairing_groups[:30], + "shifted_tax_groups": shifted_tax_groups[:20], + "shared_draft_reallocations": existing_reallocations[:20], + }, + "requested_cases": {}, + } + requested = [ + (YEAR, "01-24", "50018"), + (YEAR, "04-25", "50002"), + (YEAR, "04-25", "50003"), + (YEAR, "08-07", "50005"), + (YEAR, "04-10", "00001"), + (YEAR, "05-12", "00001"), + (YEAR, "08-11", "00001"), + (YEAR, "09-10", "00001"), + ] + for key in requested: + group = groups.get(key) + ranked = candidate_bases_for_group(raw_wehago.get(key, []), erp_by_base, amount_index) + alloc = [] + bases = set(group.draft_bases if group else []) + if ranked: + bases.add(ranked[0][0]) + for base in sorted(bases): + pairs, left_unmatched, right_unmatched = allocate_rows(raw_wehago.get(key, []), erp_by_base.get(base, [])) + alloc.append( + { + "base": base, + "pairs": len(pairs), + "left_unmatched": len(left_unmatched), + "right_unmatched": len(right_unmatched), + "pair_scores": [score for _, _, score in pairs], + } + ) + report["requested_cases"][f"{key[1]} {key[2]}"] = { + "current_status": group.status if group else "missing", + "current_bases": sorted(group.draft_bases) if group else [], + "candidate_bases": ranked, + "allocator": alloc, + } + + if args.output: + Path(args.output).write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8") + print(json.dumps(report, ensure_ascii=False, indent=2)) + + +if __name__ == "__main__": + main() diff --git a/scripts/validate_wehago_anchor_projection.py b/scripts/validate_wehago_anchor_projection.py new file mode 100644 index 0000000..3242323 --- /dev/null +++ b/scripts/validate_wehago_anchor_projection.py @@ -0,0 +1,174 @@ +from __future__ import annotations + +import argparse +import sqlite3 +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + +from runtime_config import DB_PATH + + +DEFAULT_STATUSES = ( + "voucher_matched", + "voucher_unmatched", + "voucher_recheck", + "voucher_excepted", +) + + +def latest_signature(conn: sqlite3.Connection, start_year: int, end_year: int) -> str: + row = conn.execute( + """ + SELECT signature + FROM wehago_compare_query_groups + WHERE start_year = ? + AND end_year = ? + GROUP BY signature + ORDER BY MAX(updated_at) DESC + LIMIT 1 + """, + (start_year, end_year), + ).fetchone() + return str(row[0] or "") if row else "" + + +def status_summary( + conn: sqlite3.Connection, + start_year: int, + end_year: int, + signature: str, + status_key: str, +) -> dict[str, int | float]: + row = conn.execute( + """ + WITH per_group AS ( + SELECT + g.group_index, + COUNT(DISTINCT CASE + WHEN COALESCE(r.ledger_account_name, '') <> '' + OR COALESCE(r.ledger_desc, '') <> '' + OR ABS(COALESCE(r.ledger_debit, 0)) >= 0.5 + OR ABS(COALESCE(r.ledger_credit, 0)) >= 0.5 + THEN COALESCE(r.ledger_date, '') || '|' || COALESCE(r.voucher_no, '') + END) AS wehago_keys, + COUNT(DISTINCT CASE + WHEN COALESCE(r.voucher_account_name, '') <> '' + OR COALESCE(r.voucher_desc, '') <> '' + OR ABS(COALESCE(r.voucher_debit, 0)) >= 0.5 + OR ABS(COALESCE(r.voucher_credit, 0)) >= 0.5 + THEN COALESCE(r.draft_no, '') + END) AS erp_drafts, + COUNT(*) AS row_count + FROM wehago_compare_query_groups g + LEFT JOIN wehago_compare_query_rows r + ON 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 + WHERE g.start_year = ? + AND g.end_year = ? + AND g.signature = ? + AND g.status_key = ? + GROUP BY g.group_index + ) + SELECT + COUNT(*) AS groups, + SUM(CASE WHEN wehago_keys > 1 THEN 1 ELSE 0 END) AS mixed_wehago_groups, + SUM(CASE WHEN erp_drafts > 1 THEN 1 ELSE 0 END) AS multi_erp_groups, + COALESCE(MAX(wehago_keys), 0) AS max_wehago_keys, + COALESCE(MAX(erp_drafts), 0) AS max_erp_drafts, + COALESCE(MAX(row_count), 0) AS max_rows, + COALESCE(AVG(row_count), 0) AS avg_rows + FROM per_group + """, + (start_year, end_year, signature, status_key), + ).fetchone() + return { + "groups": int(row["groups"] or 0), + "mixed_wehago_groups": int(row["mixed_wehago_groups"] or 0), + "multi_erp_groups": int(row["multi_erp_groups"] or 0), + "max_wehago_keys": int(row["max_wehago_keys"] or 0), + "max_erp_drafts": int(row["max_erp_drafts"] or 0), + "max_rows": int(row["max_rows"] or 0), + "avg_rows": round(float(row["avg_rows"] or 0), 2), + } + + +def sample_mixed_groups( + conn: sqlite3.Connection, + start_year: int, + end_year: int, + signature: str, + status_key: str, + limit: int, +) -> list[dict[str, object]]: + rows = conn.execute( + """ + SELECT + g.group_index, + g.ledger_date, + g.voucher_no, + COUNT(DISTINCT CASE + WHEN COALESCE(r.ledger_account_name, '') <> '' + OR COALESCE(r.ledger_desc, '') <> '' + OR ABS(COALESCE(r.ledger_debit, 0)) >= 0.5 + OR ABS(COALESCE(r.ledger_credit, 0)) >= 0.5 + THEN COALESCE(r.ledger_date, '') || '|' || COALESCE(r.voucher_no, '') + END) AS wehago_keys, + COUNT(*) AS row_count + FROM wehago_compare_query_groups g + JOIN wehago_compare_query_rows r + ON 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 + WHERE g.start_year = ? + AND g.end_year = ? + AND g.signature = ? + AND g.status_key = ? + GROUP BY g.group_index + HAVING wehago_keys > 1 + ORDER BY wehago_keys DESC, row_count DESC, g.group_index ASC + LIMIT ? + """, + (start_year, end_year, signature, status_key, limit), + ).fetchall() + return [dict(row) for row in rows] + + +def main() -> None: + parser = argparse.ArgumentParser(description="Validate WEHAGO voucher projection anchoring.") + parser.add_argument("--start-year", type=int, default=2025) + parser.add_argument("--end-year", type=int, default=2025) + parser.add_argument("--signature", default="") + parser.add_argument("--sample", type=int, default=5) + parser.add_argument("--statuses", nargs="*", default=list(DEFAULT_STATUSES)) + args = parser.parse_args() + + conn = sqlite3.connect(DB_PATH) + conn.row_factory = sqlite3.Row + signature = args.signature or latest_signature(conn, args.start_year, args.end_year) + if not signature: + raise SystemExit("No query projection signature found.") + print({"db": str(DB_PATH), "signature": signature}) + for status_key in args.statuses: + summary = status_summary(conn, args.start_year, args.end_year, signature, status_key) + print({status_key: summary}) + samples = sample_mixed_groups( + conn, + args.start_year, + args.end_year, + signature, + status_key, + args.sample, + ) + if samples: + print({"mixed_samples": status_key, "rows": samples}) + + +if __name__ == "__main__": + main() diff --git a/scripts/validate_wehago_projection_shadow.py b/scripts/validate_wehago_projection_shadow.py new file mode 100644 index 0000000..727f92a --- /dev/null +++ b/scripts/validate_wehago_projection_shadow.py @@ -0,0 +1,183 @@ +#!/usr/bin/env python3 +"""Validate WEHAGO voucher projection display and source-row invariants.""" + +from __future__ import annotations + +import argparse +import sqlite3 +from collections import Counter, defaultdict +from pathlib import Path + + +DEFAULT_DB = Path("/home/b17301/intranet-runtime/db/data.db") + +FIXED_CASES = ( + ("2025-01-01 50004", "01-01", "50004"), + ("2025-01-05 50003", "01-05", "50003"), + ("2025-01-05 50004", "01-05", "50004"), + ("2025-01-07 50013", "01-07", "50013"), + ("2025-01-10 00036", "01-10", "00036"), + ("2025-01-10 00048", "01-10", "00048"), + ("2025-01-10 00056", "01-10", "00056"), + ("2025-01-10 00058", "01-10", "00058"), + ("2025-01-10 00059", "01-10", "00059"), + ("2025-01-10 00061", "01-10", "00061"), + ("2025-12-23 50031", "12-23", "50031"), + ("2025-12-23 50032", "12-23", "50032"), +) + +SOURCE_CASES = {"", "DIRECT_MATCH_CANDIDATE", "SETTLEMENT_BRIDGE_CANDIDATE"} +CONTEXT_CASES = { + "ERP_CONTEXT_ROW", + "UNASSIGNED_ERP_ROW", + "SHARED_ALLOCATION_CANDIDATE", + "SHARED_BUNDLE_PAYMENT_CANDIDATE", +} + + +def latest_signature(conn: sqlite3.Connection, year: int) -> str: + row = conn.execute( + """ + SELECT signature + FROM wehago_compare_query_rows + WHERE start_year = ? AND end_year = ? + ORDER BY updated_at DESC + LIMIT 1 + """, + (year, year), + ).fetchone() + if row is None: + raise SystemExit(f"No projection rows found for {year}.") + return str(row["signature"]) + + +def is_source_row(row: sqlite3.Row) -> bool: + matched_case = (row["matched_case"] or "").strip() + return ( + bool((row["ledger_account_name"] or "").strip()) + and bool((row["voucher_account_name"] or "").strip()) + and matched_case in SOURCE_CASES + and matched_case not in CONTEXT_CASES + ) + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("--db", default=str(DEFAULT_DB)) + parser.add_argument("--year", type=int, default=2025) + args = parser.parse_args() + + conn = sqlite3.connect(args.db) + conn.row_factory = sqlite3.Row + signature = latest_signature(conn, args.year) + print("signature", signature) + + rows = list( + conn.execute( + """ + SELECT * + FROM wehago_compare_query_rows + WHERE start_year = ? AND end_year = ? AND signature = ? + """, + (args.year, args.year, signature), + ) + ) + by_status_group: dict[tuple[str, int], list[sqlite3.Row]] = defaultdict(list) + for row in rows: + by_status_group[(row["status_key"], int(row["group_index"]))].append(row) + + status_counts = Counter(status for status, _ in by_status_group) + print("status_groups", dict(sorted(status_counts.items()))) + + for status in ("voucher_matched", "erp_voucher_matched"): + source_rows = [row for row in rows if row["status_key"] == status and is_source_row(row)] + ledger_keys = Counter(row["ledger_row_key"] for row in source_rows if row["ledger_row_key"]) + voucher_keys = Counter(row["voucher_row_key"] for row in source_rows if row["voucher_row_key"]) + one_side_rows = [ + row + for row in rows + if row["status_key"] == status + and (bool((row["ledger_account_name"] or "").strip()) != bool((row["voucher_account_name"] or "").strip())) + ] + print( + status, + { + "source_rows": len(source_rows), + "missing_source_keys": sum(1 for row in source_rows if not row["ledger_row_key"] or not row["voucher_row_key"]), + "duplicate_ledger_extra_rows": sum(count - 1 for count in ledger_keys.values() if count > 1), + "duplicate_voucher_extra_rows": sum(count - 1 for count in voucher_keys.values() if count > 1), + "one_side_display_rows": len(one_side_rows), + }, + ) + + final_wehago_keys = { + row["ledger_row_key"] + for row in rows + if row["status_key"] in {"voucher_matched", "voucher_excepted"} + and row["ledger_row_key"] + } + shadow_rows = [ + row + for row in rows + if row["status_key"] in {"ledger_only", "amount_mismatch"} + and row["ledger_row_key"] + and row["ledger_row_key"] in final_wehago_keys + ] + print("standard_shadow_rows_for_final_wehago", len(shadow_rows)) + + final_statuses = ("voucher_matched", "voucher_recheck", "voucher_unmatched", "voucher_excepted") + identity_statuses: dict[tuple[int, str, str], set[str]] = defaultdict(set) + group_summaries = { + (row["status_key"], int(row["group_index"])): row + for row in conn.execute( + """ + SELECT status_key, group_index, fiscal_year, ledger_date, voucher_no + FROM wehago_compare_query_groups + WHERE start_year = ? AND end_year = ? AND signature = ? + """, + (args.year, args.year, signature), + ) + } + for (status, group_index), group_rows in by_status_group.items(): + if status not in final_statuses or not group_rows: + continue + summary = group_summaries.get((status, group_index)) or group_rows[0] + identity_statuses[ + ( + int(summary["fiscal_year"] or 0), + str(summary["ledger_date"] or "").strip(), + str(summary["voucher_no"] or "").strip(), + ) + ].add(status) + cross_status_identities = { + identity: statuses + for identity, statuses in identity_statuses.items() + if len(statuses) > 1 + } + print("cross_status_voucher_identities", len(cross_status_identities)) + + print("fixed_cases") + for label, ledger_date, voucher_no in FIXED_CASES: + matches = [ + group_rows + for (_status, _group_index), group_rows in by_status_group.items() + if any(row["ledger_date"] == ledger_date and row["voucher_no"] == voucher_no for row in group_rows) + ] + summary = [] + for group_rows in matches: + first = group_rows[0] + source_count = sum(1 for row in group_rows if is_source_row(row)) + one_side = sum( + 1 + for row in group_rows + if bool((row["ledger_account_name"] or "").strip()) != bool((row["voucher_account_name"] or "").strip()) + ) + reasons = sorted({row["review_reason"] for row in group_rows if row["review_reason"]}) + summary.append( + f"{first['status_key']}#{first['group_index']}:rows={len(group_rows)} source={source_count} one_side={one_side} reason={' | '.join(reasons[:2])}" + ) + print(label, " ; ".join(summary) if summary else "MISSING") + + +if __name__ == "__main__": + main() diff --git a/scripts/validate_wehago_recheck_regressions.py b/scripts/validate_wehago_recheck_regressions.py new file mode 100644 index 0000000..b447e94 --- /dev/null +++ b/scripts/validate_wehago_recheck_regressions.py @@ -0,0 +1,216 @@ +from __future__ import annotations + +import argparse +import sqlite3 +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + +from runtime_config import DB_PATH + + +CASES = ( + ("01-01", "50004", "11-20250131-020161-3", {"DIRECT_MATCH_CANDIDATE": 3, "SETTLEMENT_BRIDGE_CANDIDATE": 1}), + ("01-05", "50003", "11-20250109-B0100-5", {"DIRECT_MATCH_CANDIDATE": 1, "SHARED_ALLOCATION_CANDIDATE": 2}), + ("01-05", "50004", "11-20250109-B0100-5", {"DIRECT_MATCH_CANDIDATE": 1, "SHARED_ALLOCATION_CANDIDATE": 2}), + ("01-07", "50013", "11-20250131-017128-1", {"DIRECT_MATCH_CANDIDATE": 2, "SETTLEMENT_BRIDGE_CANDIDATE": 1}), + ("01-10", "00036", "11-20250110-B0100-15", {"DIRECT_MATCH_CANDIDATE": 1}), + ("01-10", "00048", "11-20250110-B0100-13", {"DIRECT_MATCH_CANDIDATE": 1}), + ("01-10", "00056", "11-20250110-B0100-1", {"DIRECT_MATCH_CANDIDATE": 1, "SHARED_BUNDLE_PAYMENT_CANDIDATE": 1}), + ("01-10", "00058", "11-20250110-B0100-1", {"DIRECT_MATCH_CANDIDATE": 1, "SHARED_BUNDLE_PAYMENT_CANDIDATE": 1}), + ("01-10", "00059", "11-20250110-B0100-1", {"DIRECT_MATCH_CANDIDATE": 1, "SHARED_BUNDLE_PAYMENT_CANDIDATE": 1}), + ("01-10", "00061", "11-20241223-J0100-3", {"DIRECT_MATCH_CANDIDATE": 1, "SETTLEMENT_BRIDGE_CANDIDATE": 1}), +) + + +def latest_signature(conn: sqlite3.Connection, start_year: int, end_year: int) -> str: + row = conn.execute( + """ + SELECT signature + FROM wehago_compare_query_groups + WHERE start_year = ? + AND end_year = ? + AND status_key = 'voucher_recheck' + GROUP BY signature + ORDER BY MAX(updated_at) DESC + LIMIT 1 + """, + (start_year, end_year), + ).fetchone() + return str(row[0] or "") if row else "" + + +def main() -> None: + parser = argparse.ArgumentParser(description="Validate fixed WEHAGO Recheck regression cases and quality gates.") + parser.add_argument("--start-year", type=int, default=2025) + parser.add_argument("--end-year", type=int, default=2025) + parser.add_argument("--signature", default="") + args = parser.parse_args() + + conn = sqlite3.connect(DB_PATH) + conn.row_factory = sqlite3.Row + signature = args.signature or latest_signature(conn, args.start_year, args.end_year) + if not signature: + raise SystemExit("No voucher_recheck projection signature found.") + + failures: list[str] = [] + results: list[dict[str, object]] = [] + for ledger_date, voucher_no, expected_base, expected_roles in CASES: + group = conn.execute( + """ + SELECT group_index, draft_no, review_reason + FROM wehago_compare_query_groups + WHERE start_year = ? + AND end_year = ? + AND status_key = 'voucher_recheck' + AND signature = ? + AND ledger_date = ? + AND voucher_no = ? + LIMIT 1 + """, + (args.start_year, args.end_year, signature, ledger_date, voucher_no), + ).fetchone() + case_key = f"{args.start_year}-{ledger_date} {voucher_no}" + if not group: + failures.append(f"{case_key}: projection group missing") + continue + shown_bases = {part.strip() for part in str(group["draft_no"] or "").split(",") if part.strip()} + if shown_bases != {expected_base}: + failures.append(f"{case_key}: draft bases {sorted(shown_bases)} != [{expected_base}]") + rows = conn.execute( + """ + SELECT draft_no, ledger_account_name, voucher_account_name, + ledger_debit, ledger_credit, voucher_debit, voucher_credit, matched_case + FROM wehago_compare_query_rows + WHERE start_year = ? + AND end_year = ? + AND status_key = 'voucher_recheck' + AND signature = ? + AND group_index = ? + ORDER BY row_index + """, + (args.start_year, args.end_year, signature, int(group["group_index"])), + ).fetchall() + role_counts: dict[str, int] = {} + displayed_erp_rows = [] + for row in rows: + role = str(row["matched_case"] or "") + role_counts[role] = role_counts.get(role, 0) + 1 + has_erp = bool( + str(row["voucher_account_name"] or "") + or abs(float(row["voucher_debit"] or 0)) >= 0.5 + or abs(float(row["voucher_credit"] or 0)) >= 0.5 + ) + if has_erp: + displayed_erp_rows.append(row) + if role == "DIRECT_MATCH_CANDIDATE": + ledger_amount = max(abs(float(row["ledger_debit"] or 0)), abs(float(row["ledger_credit"] or 0))) + voucher_amount = max(abs(float(row["voucher_debit"] or 0)), abs(float(row["voucher_credit"] or 0))) + if ledger_amount < 0.5 or voucher_amount < 0.5: + failures.append(f"{case_key}: zero/one-sided direct match at {row['draft_no']}") + for role, minimum in expected_roles.items(): + if role_counts.get(role, 0) < minimum: + failures.append(f"{case_key}: {role}={role_counts.get(role, 0)} < {minimum}") + + raw = conn.execute( + """ + SELECT COUNT(*) AS row_count, + COALESCE(SUM(debit_supply), 0) AS debit_sum, + COALESCE(SUM(credit_supply), 0) AS credit_sum + FROM wehago_voucher_rows + WHERE draft_no = ? + OR draft_no LIKE ? + """, + (expected_base, f"{expected_base}-%"), + ).fetchone() + displayed_debit = sum(float(row["voucher_debit"] or 0) for row in displayed_erp_rows) + displayed_credit = sum(float(row["voucher_credit"] or 0) for row in displayed_erp_rows) + if len(displayed_erp_rows) != int(raw["row_count"] or 0): + failures.append( + f"{case_key}: ERP rows displayed={len(displayed_erp_rows)} raw={int(raw['row_count'] or 0)}" + ) + if abs(displayed_debit - float(raw["debit_sum"] or 0)) >= 0.5: + failures.append(f"{case_key}: ERP debit displayed={displayed_debit} raw={raw['debit_sum']}") + if abs(displayed_credit - float(raw["credit_sum"] or 0)) >= 0.5: + failures.append(f"{case_key}: ERP credit displayed={displayed_credit} raw={raw['credit_sum']}") + results.append( + { + "case": case_key, + "draft_base": expected_base, + "erp_rows": len(displayed_erp_rows), + "roles": role_counts, + "internal_status": str(group["review_reason"] or "").split("INTERNAL_STATUS=")[-1], + } + ) + + mixed_count = conn.execute( + """ + WITH per_group AS ( + SELECT group_index, + COUNT(DISTINCT CASE + WHEN COALESCE(ledger_account_name, '') <> '' + OR ABS(COALESCE(ledger_debit, 0)) >= 0.5 + OR ABS(COALESCE(ledger_credit, 0)) >= 0.5 + THEN COALESCE(ledger_date, '') || '|' || COALESCE(voucher_no, '') + END) AS wehago_keys + FROM wehago_compare_query_rows + WHERE start_year = ? + AND end_year = ? + AND status_key = 'voucher_recheck' + AND signature = ? + GROUP BY group_index + ) + SELECT COUNT(*) FROM per_group WHERE wehago_keys > 1 + """, + (args.start_year, args.end_year, signature), + ).fetchone()[0] + if int(mixed_count or 0) != 0: + failures.append(f"mixed WEHAGO groups={mixed_count}") + + displayed_draft_nos = { + str(row[0] or "") + for row in conn.execute( + """ + SELECT DISTINCT draft_no + FROM wehago_compare_query_rows + WHERE start_year = ? + AND end_year = ? + AND status_key = 'voucher_recheck' + AND signature = ? + AND ( + COALESCE(voucher_account_name, '') <> '' + OR ABS(COALESCE(voucher_debit, 0)) >= 0.5 + OR ABS(COALESCE(voucher_credit, 0)) >= 0.5 + ) + """, + (args.start_year, args.end_year, signature), + ) + if str(row[0] or "") + } + raw_draft_nos = { + str(row[0] or "") + for row in conn.execute( + """ + SELECT DISTINCT draft_no + FROM wehago_voucher_rows + WHERE fiscal_year BETWEEN ? AND ? + AND COALESCE(draft_no, '') <> '' + """, + (args.start_year - 1, args.end_year + 1), + ) + } + confirmed_only_count = len(displayed_draft_nos - raw_draft_nos) + if int(confirmed_only_count or 0) != 0: + failures.append(f"ERP rows not backed by raw draft_no={confirmed_only_count}") + + print({"db": str(DB_PATH), "signature": signature, "cases": results}) + print({"quality_gates": {"mixed_wehago_groups": mixed_count, "confirmed_only_rows": confirmed_only_count}}) + if failures: + for failure in failures: + print({"failure": failure}) + raise SystemExit(1) + + +if __name__ == "__main__": + main() diff --git a/scripts/verify_hanmac_all_direct.py b/scripts/verify_hanmac_all_direct.py new file mode 100644 index 0000000..f195fce --- /dev/null +++ b/scripts/verify_hanmac_all_direct.py @@ -0,0 +1,102 @@ +from __future__ import annotations + +import json +import sys +from datetime import datetime +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + +import scripts.refresh_hanmac_wehago_ledgers as refresh +import scripts.retry_failed_wehago_accounts_direct as direct +import scripts.wehago_data_download_2022_work as wehago +import scripts.wehago_ledger_api_download as api_download + + +BASE = Path(r"\\wsl.localhost\Ubuntu\home\b17301\WEHAGO_DB\data_download") + + +def main() -> int: + run_root = BASE / "hanmac_refresh" / f"verify_all_direct_{datetime.now():%Y%m%d_%H%M%S}" + run_root.mkdir(parents=True, exist_ok=True) + + wehago.CHROME_DEBUGGER_ADDRESS = "127.0.0.1:9225" + wehago.DOWNLOAD_DIR = run_root + driver = wehago.build_driver(run_root) + try: + cookies = direct.cookie_map(driver) + finally: + driver.quit() + + reports: dict[str, object] = {} + for year in refresh.YEARS: + canonical = BASE / "hanmac" / str(year) + staging = run_root / "staging" / str(year) + staging.mkdir(parents=True, exist_ok=True) + accounts = wehago.discover_downloaded_accounts(canonical) + manifest: list[dict[str, object]] = [] + failures: list[dict[str, str]] = [] + + print(f"YEAR {year}: verify all {len(accounts)} accounts", flush=True) + for index, account in enumerate(accounts, start=1): + try: + rows = direct.fetch_ledger_rows(year, account, cookies) + api_download.validate_api_rows(account, rows) + api_download.write_api_rows(staging / account.safe_filename, account, rows) + manifest.append( + { + "account_code": account.code, + "account_name": account.name, + "api_request_code": f"{account.code}00", + "api_response_rows": len(rows), + } + ) + print(f"[{year} {index}/{len(accounts)}] OK {account.code} rows={len(rows)}", flush=True) + except Exception as exc: + reason = f"{type(exc).__name__}: {exc}" + failures.append({"account_code": account.code, "account_name": account.name, "reason": reason}) + print(f"[{year} {index}/{len(accounts)}] FAIL {account.code}: {reason}", flush=True) + + api_download.write_progress( + staging, + { + "status": "completed_with_failures" if failures else "completed", + "completed": len(manifest), + "total": len(accounts), + "accounts": manifest, + "failures": failures, + }, + ) + comparison = refresh.compare_and_promote( + year, + staging, + canonical, + run_root / "unused_backup" / str(year), + promote=False, + ) + reports[str(year)] = { + "requested": len(accounts), + "downloaded": len(manifest), + "failures": failures, + "validation": comparison["validation"], + "changed": comparison["changed"], + "unchanged": comparison["unchanged"], + "new": comparison["new"], + "missing": comparison["missing"], + } + (run_root / "verification_report.json").write_text( + json.dumps({"reports": reports}, ensure_ascii=False, indent=2), + encoding="utf-8", + ) + print( + f"YEAR {year}: changed={len(comparison['changed'])} unchanged={len(comparison['unchanged'])} " + f"failures={len(failures)} missing={len(comparison['missing'])}", + flush=True, + ) + + print(f"REPORT {run_root / 'verification_report.json'}", flush=True) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/wehago_data_download_2022_work.py b/scripts/wehago_data_download_2022_work.py index 4defc26..ac4ee0a 100755 --- a/scripts/wehago_data_download_2022_work.py +++ b/scripts/wehago_data_download_2022_work.py @@ -400,6 +400,7 @@ def build_driver(download_dir: Path, headless: bool = False) -> WebDriver: options = ChromeOptions() if CHROME_DEBUGGER_ADDRESS: options.add_experimental_option("debuggerAddress", CHROME_DEBUGGER_ADDRESS) + options.set_capability("goog:loggingPrefs", {"performance": "ALL"}) else: options.add_argument(f"--user-data-dir={CHROME_USER_DATA_DIR}") options.add_argument(f"--profile-directory={CHROME_PROFILE_NAME}") @@ -1394,6 +1395,30 @@ def click_query_button(driver: WebDriver) -> bool: candidates: list[tuple[float, WebElement]] = [] for _ in contexts_with_default_first(driver): + try: + clicked = driver.execute_script( + """ + const width = window.innerWidth; + const candidates = Array.from(document.querySelectorAll('button, a, div, span')) + .filter(el => (el.textContent || '').trim() === '조회') + .filter(el => { + const r = el.getBoundingClientRect(); + const style = getComputedStyle(el); + return r.width > 0 && r.height > 0 && r.top >= 70 && r.top <= 240 + && r.left > width * 0.6 && style.display !== 'none' && style.visibility !== 'hidden'; + }) + .sort((a, b) => b.getBoundingClientRect().left - a.getBoundingClientRect().left); + if (!candidates.length) return false; + candidates[0].click(); + return true; + """ + ) + if clicked: + wait_for_blocking_overlay_gone(driver) + return True + except WebDriverException: + pass + for text in ("조회", "검색"): xpath = f"//*[normalize-space(.)='{text}']" for element in driver.find_elements(By.XPATH, xpath): @@ -1982,7 +2007,7 @@ def wait_for_detail_change( timeout: int = DETAIL_CHANGE_WAIT_SECONDS, ) -> None: if ALLOW_UNCHANGED_DETAIL: - log("주의: 오른쪽 원장 변경 확인 생략 허용이 켜져 있습니다. 계정별 다운로드에는 권장하지 않습니다.") + raise RuntimeError("ALLOW_UNCHANGED_DETAIL은 오계정 원장 저장 위험 때문에 사용할 수 없습니다.") deadline = time.time() + timeout last_signature = "" while time.time() < deadline: @@ -1993,22 +2018,6 @@ def wait_for_detail_change( return if find_detail_data_cell(driver) is not None and not before_signature: return - if expected_account is not None and current_signature and find_detail_data_cell(driver) is not None: - selected = selected_left_account(driver) - if selected is None: - try: - selected = assert_selected_account(driver, expected_account, timeout=0.2) - except TimeoutException: - selected = recently_clicked_left_account(driver, expected_account) - if selected is not None and selected.code == expected_account.code: - log( - f"{expected_account.code} {expected_account.name}: 선택 계정 확인 완료. " - "상세 첫 화면값이 직전 계정과 같아도 다운로드를 계속합니다." - ) - return - if ALLOW_UNCHANGED_DETAIL and current_signature and find_detail_data_cell(driver) is not None: - log("상세 그리드가 이미 같은 계정으로 표시되어 있어 변경 대기 없이 계속 진행합니다.") - return time.sleep(0.15) raise TimeoutException( "계정 선택 후 오른쪽 원장 상세 내용이 바뀌지 않았습니다. " @@ -2797,6 +2806,12 @@ def open_wehago(driver: WebDriver) -> None: elif ACCOUNT_LEDGER_URL.strip(): log(f"설정된 계정별원장 주소를 열었습니다: {ACCOUNT_LEDGER_URL.strip()}") + try: + ensure_ledger_data_loaded(driver, ACCOUNTS) + except RuntimeError: + # 로그인/메뉴 이동이 아직 필요한 화면에서는 아래 준비 신호 흐름으로 안내합니다. + pass + if not wait_for_ledger_screen_ready(driver, timeout=30): log("계정별원장 화면이 아직 준비되지 않았습니다. 로그인 또는 화면 이동이 필요하면 완료 후 신호를 보내세요.") log("만약 404 화면이면 WEHAGO 메뉴에서 계정별원장 화면을 직접 열어주세요.") diff --git a/scripts/wehago_ledger_api_download.py b/scripts/wehago_ledger_api_download.py new file mode 100644 index 0000000..43ed843 --- /dev/null +++ b/scripts/wehago_ledger_api_download.py @@ -0,0 +1,287 @@ +from __future__ import annotations + +import json +import time +from pathlib import Path +from urllib.parse import parse_qs + +from openpyxl import Workbook +from selenium.common.exceptions import TimeoutException +from selenium.webdriver.remote.webdriver import WebDriver + +try: + import scripts.wehago_data_download_2022_work as wehago +except ModuleNotFoundError: + import wehago_data_download_2022_work as wehago + + +LEDGER_API_PATH = "/smarta/sabk0107/jungi_slip/" +TOTAL_LABELS = ("월 계", "누 계", "합 계") + + +def drain_performance_log(driver: WebDriver) -> None: + driver.get_log("performance") + + +def parse_post_data(raw: str) -> dict[str, str]: + try: + value = json.loads(raw) + return {str(key): str(item) for key, item in value.items()} if isinstance(value, dict) else {} + except json.JSONDecodeError: + return {key: values[-1] for key, values in parse_qs(raw, keep_blank_values=True).items()} + + +def wait_for_account_api_response(driver: WebDriver, account: wehago.Account, timeout: float = 8.0) -> list[dict]: + expected_code = f"{account.code}00" + requests: dict[str, dict[str, str]] = {} + completed: set[str] = set() + deadline = time.time() + timeout + while time.time() < deadline: + for item in driver.get_log("performance"): + try: + message = json.loads(item["message"])["message"] + except (KeyError, TypeError, json.JSONDecodeError): + continue + method = message.get("method") + params = message.get("params") or {} + request_id = str(params.get("requestId") or "") + if method == "Network.requestWillBeSent": + request = params.get("request") or {} + if LEDGER_API_PATH not in str(request.get("url") or ""): + continue + post_data = parse_post_data(str(request.get("postData") or "")) + requests[request_id] = post_data + requested_code = post_data.get("from_cd_acctit") + if requested_code and requested_code != expected_code: + raise RuntimeError( + f"{account.code} 선택 후 다른 계정 API가 호출되었습니다: {requested_code}. 저장을 중단합니다." + ) + elif method == "Network.loadingFinished" and request_id in requests: + completed.add(request_id) + for completed_id in list(completed): + if requests[completed_id].get("from_cd_acctit") != expected_code: + continue + try: + raw = driver.execute_cdp_cmd("Network.getResponseBody", {"requestId": completed_id}).get("body", "") + rows = json.loads(raw) + except Exception: + continue + if not isinstance(rows, list): + raise RuntimeError(f"{account.code}: 원장 API 응답이 목록 형식이 아닙니다.") + return rows + time.sleep(0.1) + raise TimeoutException(f"{account.code}: {timeout:.0f}초 안에 계정별원장 API 응답을 받지 못했습니다.") + + +def validate_api_rows(account: wehago.Account, rows: list[dict]) -> None: + expected_code = f"{account.code}00" + observed_codes = { + str(row.get("cd_acctit")) + for row in rows + if row.get("cd_acctit") not in (None, "") + } + if observed_codes - {expected_code}: + raise RuntimeError( + f"{account.code}: 응답 내부에 다른 계정코드가 있습니다: {sorted(observed_codes)}" + ) + + +def is_total_row(row: dict) -> bool: + remark = str(row.get("nm_remark") or "") + return any(label in remark for label in TOTAL_LABELS) + + +def empty_if_zero(value: object) -> object: + return None if value in (0, 0.0, "0", "0.0") else value + + +def write_api_rows(path: Path, account: wehago.Account, rows: list[dict]) -> None: + workbook = Workbook() + sheet = workbook.active + sheet.title = "계정별원장" + sheet.append(("일자", "적요", "거래처", "차변", "대변", "잔액", "전표번호", "계정코드", "계정명")) + for row in rows: + if is_total_row(row): + continue + sheet.append( + ( + row.get("da_date"), + row.get("nm_remark"), + row.get("nm_trade") or row.get("nm_ctrade"), + empty_if_zero(row.get("mn_bungae_cha")), + empty_if_zero(row.get("mn_bungae_dae")), + row.get("mn_balance"), + row.get("no_acct"), + account.code, + account.name, + ) + ) + path.parent.mkdir(parents=True, exist_ok=True) + temporary = path.with_suffix(".tmp.xlsx") + workbook.save(temporary) + workbook.close() + temporary.replace(path) + + +def write_progress(download_dir: Path, payload: dict) -> None: + download_dir.mkdir(parents=True, exist_ok=True) + target = download_dir / "_api_progress.json" + temporary = target.with_suffix(".tmp") + temporary.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8") + temporary.replace(target) + + +def quick_scroll_left_account_list(driver: WebDriver, direction: int = 1) -> bool: + try: + return bool( + driver.execute_script( + """ + const direction = arguments[0]; + const cells = Array.from(document.querySelectorAll('.rg-data-cell, td[class*=rg-data-cell], [class*=rg-data-cell], td')) + .filter((cell) => { + const r = cell.getBoundingClientRect(); + return r.left < 220 && r.top > 145 && r.width > 0 && r.height > 0; + }); + const before = cells.map((cell) => (cell.textContent || '').trim()).join('|'); + let grid = null; + let node = cells[Math.floor(cells.length / 2)] || null; + while (node && node !== document.body) { + const r = node.getBoundingClientRect(); + if (r.left < 260 && node.scrollHeight > node.clientHeight + 20) { + grid = node; + break; + } + node = node.parentElement; + } + if (grid) { + grid.scrollTop += direction * Math.max(180, grid.clientHeight * 0.55); + } else { + const el = document.elementFromPoint(120, Math.min(700, window.innerHeight - 80)); + el && el.dispatchEvent(new WheelEvent('wheel', {bubbles: true, cancelable: true, deltaY: direction * 650})); + } + const afterCells = Array.from(document.querySelectorAll('.rg-data-cell, td[class*=rg-data-cell], [class*=rg-data-cell], td')) + .filter((cell) => { + const r = cell.getBoundingClientRect(); + return r.left < 220 && r.top > 145 && r.width > 0 && r.height > 0; + }); + const after = afterCells.map((cell) => (cell.textContent || '').trim()).join('|'); + return before !== after; + """, + direction, + ) + ) + except Exception: + return False + + +def nudge_visible_account_into_clickable_area(driver: WebDriver, account: wehago.Account) -> None: + try: + position = driver.execute_script( + """ + const code = arguments[0]; + const cells = Array.from(document.querySelectorAll('.rg-data-cell, td[class*=rg-data-cell], [class*=rg-data-cell], td')); + const cell = cells.find((item) => { + const r = item.getBoundingClientRect(); + return (item.textContent || '').trim() === code && r.left < 220 && r.width > 0 && r.height > 0; + }); + if (!cell) return null; + const r = cell.getBoundingClientRect(); + return {top: r.top, bottom: r.bottom, height: window.innerHeight || document.documentElement.clientHeight}; + """, + account.code, + ) + except Exception: + position = None + if not position: + return + if float(position.get("bottom") or 0) > float(position.get("height") or 0) - 80: + quick_scroll_left_account_list(driver, 1) + time.sleep(0.3) + + +def select_account_for_api(driver: WebDriver, account: wehago.Account) -> None: + try: + int(account.code) + except ValueError as exc: + raise wehago.AccountNotAvailable(f"{account.code}: 숫자 계정코드가 아닙니다.") from exc + + # Keep API downloads on the same account-selection path as the safer Excel + # workflow. The older API-only scroll loop could miss virtualized RealGrid + # rows and left many valid accounts as AccountNotAvailable. + wehago.select_account_from_left_list(driver, account) + time.sleep(0.2) + + +def move_off_current_account(driver: WebDriver, account: wehago.Account, accounts: list[wehago.Account]) -> None: + visible_codes = [code for code in wehago.visible_left_account_codes(driver) if code != account.code] + for code in visible_codes: + if wehago.click_left_account_text(driver, wehago.Account(code, "")): + time.sleep(0.3) + return + + alternate = next((candidate for candidate in accounts if candidate.code != account.code), None) + if alternate is None: + raise RuntimeError(f"{account.code}: API 재조회에 필요한 다른 계정을 찾지 못했습니다.") + select_account_for_api(driver, alternate) + time.sleep(0.5) + + +def request_account_rows(driver: WebDriver, account: wehago.Account, accounts: list[wehago.Account]) -> list[dict]: + drain_performance_log(driver) + select_account_for_api(driver, account) + try: + return wait_for_account_api_response(driver, account) + except TimeoutException: + wehago.log(f"{account.code} {account.name}: API 요청이 생략되어 다른 계정 선택 후 재시도합니다.") + move_off_current_account(driver, account, accounts) + drain_performance_log(driver) + select_account_for_api(driver, account) + return wait_for_account_api_response(driver, account, timeout=10.0) + + +def download_accounts( + driver: WebDriver, + accounts: list[wehago.Account], + download_dir: Path, +) -> list[Path]: + driver.execute_cdp_cmd("Network.enable", {}) + downloaded: list[Path] = [] + manifest: list[dict[str, object]] = [] + failures: list[dict[str, str]] = [] + for index, account in enumerate(accounts, start=1): + write_progress( + download_dir, + {"status": "running", "current": account.code, "completed": index - 1, "total": len(accounts)}, + ) + wehago.log(f"[API {index}/{len(accounts)}] {account.code} {account.name}: 조회 요청") + try: + rows = request_account_rows(driver, account, accounts) + validate_api_rows(account, rows) + except Exception as exc: + message = f"{type(exc).__name__}: {exc}" + failures.append({"account_code": account.code, "account_name": account.name, "reason": message}) + wehago.log(f"[API {index}/{len(accounts)}] 실패, 다음 계정 계속: {account.code} {account.name}: {message}") + continue + target = download_dir / account.safe_filename + write_api_rows(target, account, rows) + downloaded.append(target) + manifest.append( + { + "account_code": account.code, + "account_name": account.name, + "api_request_code": f"{account.code}00", + "api_response_rows": len(rows), + } + ) + wehago.log(f"[API {index}/{len(accounts)}] 완료: {target.name}, 응답 {len(rows)}행") + write_progress( + download_dir, + { + "status": "completed_with_failures" if failures else "completed", + "completed": len(downloaded), + "total": len(accounts), + "accounts": manifest, + "failures": failures, + }, + ) + return downloaded diff --git a/templates/annual_gap_analysis.html b/templates/annual_gap_analysis.html new file mode 100644 index 0000000..48baf5e --- /dev/null +++ b/templates/annual_gap_analysis.html @@ -0,0 +1,1425 @@ +{% extends "base.html" %} + +{% block title %}수익/비용 차이분석{% endblock %} + +{% block head_extra %} + +{% endblock %} + +{% macro money(value) -%} + {{ "{:,.0f}".format(value or 0) }} +{%- endmacro %} + +{% macro eok(value) -%} + {{ "{:,.1f}".format((value or 0) / 100000000) }}억 +{%- endmacro %} + +{% macro signed_eok(value) -%} + {%- if (value or 0) > 0 -%}+{{ "{:,.1f}".format((value or 0) / 100000000) }}억{%- else -%}{{ "{:,.1f}".format((value or 0) / 100000000) }}억{%- endif -%} +{%- endmacro %} + +{% macro signed_eok_sign(value) -%} + {%- if (value or 0) > 0 -%}+{{ "{:,.1f}".format((value or 0) / 100000000) }}억 + {%- elif (value or 0) < 0 -%}-{{ "{:,.1f}".format(((value or 0) | abs) / 100000000) }}억 + {%- else -%}0.0억{%- endif -%} +{%- endmacro %} + +{% macro gap_with_rate(value, basis) -%} + {%- set rate = ((value or 0) / basis * 100) if basis else 0 -%} + + {{ signed_eok_sign(value) }} + ({{ "{:+.1f}".format(rate) }}%) + +{%- endmacro %} + +{% block content %} +{% set data = gap_analysis %} +{% set totals = data.totals %} +
    +
    +
    +

    수익/비용 차이분석

    +
    +
    +
    + 연도별 수익/비용 + +
    + +
    +
    + +
    +
    +

    핵심 브릿지

    +
    +
    + + + + + + + + + + + + + + + + + + + + + {% for row in data.yearly_rows %} + {% set project_bridge = data.project_profit_bridge_rows | selectattr("year", "equalto", row.year) | first %} + {% set pre_adjustment_wehago_revenue = row.wehago_revenue - row.adjustments.revenue %} + {% set project_revenue = project_bridge.project_revenue if project_bridge else 0 %} + {% set project_expense = project_bridge.project_expense if project_bridge else 0 %} + {% set project_profit = project_revenue - project_expense %} + {% set revenue_gap = row.wehago_revenue - project_revenue %} + {% set pre_adjustment_revenue_gap = pre_adjustment_wehago_revenue - project_revenue %} + {% set expense_gap = row.wehago_operating_expense - project_expense %} + {% set profit_gap = row.wehago_operating_profit - project_profit %} + {% set pre_adjustment_profit_gap = pre_adjustment_revenue_gap - expense_gap %} + + + + + + + + + + + + + + + + + {% endfor %} + +
    연도wehago 매출매출조정wehago 매출(조정전)프로젝트손익분석 매출수익 차이수익 차이(조정전)wehago 비용프로젝트손익분석 비용비용 차이wehago 손익프로젝트손익분석 손익손익 차이손익 차이(조정전)
    {{ row.year }}년 + + + + + + + {% if project_bridge %} + + {% else %}-{% endif %} + + + + + + + + {% if project_bridge %} + + {% else %}-{% endif %} + + + + + + {% if project_bridge %} + + {% else %}-{% endif %} + + + + +
    +
    +
    +
    + + + + +{% endblock %} diff --git a/templates/annual_summary.html b/templates/annual_summary.html index a31de29..e5bd938 100644 --- a/templates/annual_summary.html +++ b/templates/annual_summary.html @@ -251,6 +251,7 @@

    수익/비용 현황

    + 차이분석 작업 상태 확인 전
    diff --git a/templates/hanmac_browser.html b/templates/hanmac_browser.html index eb91ae9..560eaa5 100644 --- a/templates/hanmac_browser.html +++ b/templates/hanmac_browser.html @@ -67,6 +67,81 @@ display: block; } + .hanmac-connection-section { + display: grid; + gap: 12px; + padding: 14px 0; + border-top: 1px solid var(--line); + } + + .hanmac-connection-section:first-child { + padding-top: 0; + border-top: 0; + } + + .hanmac-connection-section-title { + display: grid; + gap: 3px; + } + + .hanmac-connection-section-title strong { + font-size: 14px; + } + + .hanmac-connection-section-title span { + color: var(--muted); + font-size: 12px; + line-height: 1.5; + } + + .hanmac-connection-section.is-erp { + margin: 0 -10px; + padding: 14px 10px; + border: 1px solid #cfd9e8; + border-radius: 14px; + background: #f6f9fd; + } + + .hanmac-connection-tabs { + display: flex; + gap: 8px; + padding-bottom: 2px; + overflow-x: auto; + } + + .hanmac-connection-tab { + min-width: 104px; + min-height: 38px; + padding: 0 16px; + border: 1px solid var(--line); + border-radius: 999px; + background: #ffffff; + color: var(--ink); + font-weight: 800; + white-space: nowrap; + cursor: pointer; + } + + .hanmac-connection-tab.is-active { + border-color: #111111; + background: #111111; + color: #ffffff; + } + + .hanmac-connection-section[hidden], + .hanmac-connection-actions[hidden] { + display: none; + } + + .hanmac-erp-server-note { + padding: 10px 12px; + border-radius: 12px; + background: #eaf1fb; + color: #344054; + font-size: 12px; + line-height: 1.6; + } + .hanmac-grade-panel table { width: 100%; border-collapse: collapse; @@ -165,6 +240,71 @@ font-size: 14px; } + .hanmac-wehago-audit { + display: grid; + gap: 10px; + padding: 12px 0; + border-top: 1px solid var(--line); + border-bottom: 1px solid var(--line); + } + + .hanmac-wehago-audit-title { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + } + + .hanmac-wehago-audit-title strong { + font-size: 14px; + } + + .hanmac-wehago-audit-title a, + .hanmac-wehago-audit a { + color: var(--ink); + font-size: 12px; + font-weight: 800; + text-decoration: none; + } + + .hanmac-wehago-audit-list { + display: grid; + gap: 7px; + } + + .hanmac-wehago-audit-item { + display: grid; + gap: 3px; + padding: 8px 0; + border-top: 1px solid #edf1f5; + } + + .hanmac-wehago-audit-item:first-child { + border-top: 0; + padding-top: 0; + } + + .hanmac-wehago-audit-item span { + color: var(--muted); + font-size: 12px; + line-height: 1.5; + } + + .hanmac-wehago-year-links { + display: flex; + flex-wrap: wrap; + gap: 6px; + } + + .hanmac-wehago-year-links a { + min-width: 48px; + padding: 5px 7px; + border: 1px solid var(--line); + border-radius: 999px; + background: #ffffff; + text-align: center; + } + .hanmac-filter-bar { display: grid; gap: 10px; @@ -1155,12 +1295,18 @@ .hanmac-modal-card { width: min(760px, 100%); + max-height: calc(100vh - 48px); display: grid; gap: 14px; padding: 20px; border-radius: 18px; background: rgba(255, 255, 255, 0.99); box-shadow: 0 20px 50px rgba(17, 17, 17, 0.18); + overflow-y: auto; + } + + #hanmacConfigModal .hanmac-modal-card { + width: min(860px, 100%); } .hanmac-modal-head { @@ -1213,10 +1359,34 @@ } .hanmac-modal-actions { - display: grid; - grid-template-columns: auto auto minmax(0, 1fr); + display: flex; gap: 10px; align-items: center; + flex-wrap: nowrap; + overflow-x: auto; + padding-bottom: 2px; + } + + .hanmac-modal-actions .hanmac-button, + .hanmac-connection-actions { + flex: 0 0 auto; + white-space: nowrap; + } + + .hanmac-connection-actions { + display: flex; + gap: 10px; + align-items: center; + flex-wrap: nowrap; + } + + .hanmac-modal-actions .hanmac-inline-status { + flex: 1 0 360px; + min-width: 360px; + max-width: none; + overflow-x: auto; + overflow-y: hidden; + white-space: nowrap; } @media (max-width: 1080px) { @@ -1252,8 +1422,7 @@ @media (max-width: 720px) { .hanmac-form-grid, .hanmac-aggregate-summary, - .hanmac-preview-summary, - .hanmac-modal-actions { + .hanmac-preview-summary { grid-template-columns: 1fr; } @@ -1313,6 +1482,29 @@
    +
    +
    + WEHAGO 검증자료 + SmartA +
    +
    + {% for menu in hanmac_wehago_audit_sources.menus %} +
    + {{ menu.name }}{% if menu.program %} · {{ menu.program }}{% endif %} + {{ menu.basis }} · 메뉴 검색어: {{ menu.keyword }} +
    + {% endfor %} +
    + +
    + 검토 계정: {{ hanmac_wehago_audit_sources.account_codes|join(", ") }} +
    +
    +
    @@ -1459,46 +1651,107 @@

    서버 접속 정보

    +
    + + +
    -
    -
    - - +
    +
    + 근무 DB 접속 + hanmac DB_external 조회와 집계에 사용하는 MySQL 계정입니다.
    -
    - - +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    -
    - - +
    +
    - - - +
    + + + +
    +
    @@ -1622,9 +1875,19 @@ const closeConfigButton = document.getElementById("hanmacCloseConfigButton"); const statusBox = document.getElementById("hanmacConnectionStatus"); const testButton = document.getElementById("hanmacTestConnectionButton"); + const testErpButton = document.getElementById("hanmacTestErpButton"); + const discoverSatisBudgetButton = document.getElementById("hanmacDiscoverSatisBudgetButton"); + const syncSatisBudgetButton = document.getElementById("hanmacSyncSatisBudgetButton"); + const normalizeSatisBudgetButton = document.getElementById("hanmacNormalizeSatisBudgetButton"); + const projectSatisBudgetButton = document.getElementById("hanmacProjectSatisBudgetButton"); + const runSatisBudgetFullSyncButton = document.getElementById("hanmacRunSatisBudgetFullSyncButton"); + const collectSatisBudgetWebButton = document.getElementById("hanmacCollectSatisBudgetWebButton"); const loadTablesButton = document.getElementById("hanmacLoadTablesButton"); const loadGradeCodesButton = document.getElementById("hanmacLoadGradeCodesButton"); const gradeCodesPanel = document.getElementById("hanmacGradeCodesPanel"); + const connectionTabs = Array.from(document.querySelectorAll("[data-connection-tab]")); + const connectionPanels = Array.from(document.querySelectorAll("[data-connection-panel]")); + const connectionActions = Array.from(document.querySelectorAll("[data-connection-actions]")); const tableListMeta = document.getElementById("hanmacTableListMeta"); const tableList = document.getElementById("hanmacTableList"); const tableSearch = document.getElementById("hanmacTableSearch"); @@ -1734,6 +1997,7 @@ let aggregateLoadPromise = null; let aggregateRequestSeq = 0; const credentialStorageKey = "hanmac-db-external-credentials-v1"; + const erpCredentialStorageKey = "hanmac-management-erp-session-credentials-v1"; const aggregateStateStorageKey = "hanmac-db-external-aggregate-state-v1"; const jointMembersFallbackUrl = "/static/hanmac-joint-members-cache.json"; @@ -1780,6 +2044,23 @@ DeptCode: "부서코드", }; + const selectConnectionTab = (tabName) => { + const selectedTab = tabName === "erp" ? "erp" : "work"; + connectionTabs.forEach((button) => { + const active = button.dataset.connectionTab === selectedTab; + button.classList.toggle("is-active", active); + button.setAttribute("aria-selected", active ? "true" : "false"); + }); + connectionPanels.forEach((panel) => { + panel.hidden = panel.dataset.connectionPanel !== selectedTab; + }); + connectionActions.forEach((actions) => { + actions.hidden = actions.dataset.connectionActions !== selectedTab; + }); + gradeCodesPanel.classList.toggle("is-open", selectedTab === "work" && gradeCodesPanel.innerHTML.trim() !== ""); + setStatus(""); + }; + const openModal = () => { modal.hidden = false; }; @@ -1848,6 +2129,39 @@ }; }; + const getErpPayload = () => { + const formData = new FormData(form); + return { + erp_user: String(formData.get("erp_user") || "").trim(), + erp_password: String(formData.get("erp_password") || ""), + erp_db_user: String(formData.get("erp_db_user") || "").trim(), + erp_db_password: String(formData.get("erp_db_password") || ""), + }; + }; + + const saveErpCredentials = () => { + try { + window.sessionStorage.setItem(erpCredentialStorageKey, JSON.stringify(getErpPayload())); + } catch (_error) { + // Keep the form usable when session storage is unavailable. + } + }; + + const restoreErpCredentials = () => { + try { + const raw = window.sessionStorage.getItem(erpCredentialStorageKey); + if (!raw) return false; + const saved = JSON.parse(raw); + ["erp_user", "erp_password", "erp_db_user", "erp_db_password"].forEach((key) => { + const field = form.elements.namedItem(key); + if (field && key in saved) field.value = String(saved[key] || ""); + }); + return Boolean(saved.erp_user || saved.erp_password || saved.erp_db_user || saved.erp_db_password); + } catch (_error) { + return false; + } + }; + const fetchJson = async (url, options = {}) => { const response = await fetch(url, { ...options, @@ -2289,17 +2603,21 @@ "project_count", ]); const aggregateDetailColumns = new Set([ + "population_review", "regular_hours", "overtime_hours", "total_hours", "legal_leave_days", + "missing_regular_days", "project_count", ]); const aggregateDetailLabels = { + population_review: "소속검토", regular_hours: "정규근로", overtime_hours: "연장근로", total_hours: "총근로", legal_leave_days: "법정휴가", + missing_regular_days: "정규근로 부족", project_count: "프로젝트수", }; @@ -2468,7 +2786,11 @@ const getEquivalentProjectTitle = (row) => { const equivalentCodes = Array.isArray(row?.equivalent_project_codes) ? row.equivalent_project_codes.filter(Boolean) : []; - return equivalentCodes.length ? equivalentCodes.join(", ") : ""; + const sourceCodes = Array.isArray(row?.source_project_codes) ? row.source_project_codes.filter(Boolean) : []; + const parts = []; + if (sourceCodes.length) parts.push(`원천코드: ${sourceCodes.join(", ")}`); + if (equivalentCodes.length) parts.push(`연결코드: ${equivalentCodes.join(", ")}`); + return parts.join(" / "); }; const renderProjectCodeText = (row, fallbackKey = "project_code") => { @@ -2486,9 +2808,27 @@ const renderAggregateMetricCell = (row, column, rowIndex, view) => { if (view === "member" && column.key === "remarks") { const duplicateDays = Number(row.multi_entry_days || 0); - const detailButton = duplicateDays > 0 - ? `` + const overlapCounts = row.overlap_type_counts && typeof row.overlap_type_counts === "object" + ? Object.entries(row.overlap_type_counts).filter(([, count]) => Number(count || 0) > 0) + : []; + const overlapLabel = overlapCounts.length + ? overlapCounts.map(([label, count]) => `${label} ${Number(count).toLocaleString("ko-KR")}일`).join(" · ") + : `검토 필요 ${duplicateDays.toLocaleString("ko-KR")}일`; + const duplicateButton = duplicateDays > 0 + ? `` : ""; + const missingDays = Number(row.missing_regular_days || 0); + const missingHours = Number(row.missing_regular_hours || 0); + const missingButton = missingHours > 0 + ? `` + : ""; + return `${[missingButton, duplicateButton].filter(Boolean).join(" · ")}`; + } + if (view === "member" && column.key === "population_review") { + const label = String(row.population_review || ""); + const detailButton = label && canOpenAggregateDetail(row, column.key) + ? `` + : escapeHtml(label); return `${detailButton}`; } const isNumeric = aggregateNumericColumns.has(column.key); @@ -2703,15 +3043,16 @@ const openMultiEntryModal = (row) => { const details = Array.isArray(row?.multi_entry_details) ? row.multi_entry_details : []; - multiEntryTitle.textContent = `${row?.member_name || row?.member_no || "사원"} 중복 근무 상세`; - multiEntryMeta.textContent = `${row?.member_no || ""} · ${Number(row?.multi_entry_days || details.length || 0).toLocaleString("ko-KR")}일 중복`; + multiEntryTitle.textContent = `${row?.member_name || row?.member_no || "사원"} 근무기록 검토 상세`; + multiEntryMeta.textContent = `${row?.member_no || ""} · ${Number(row?.multi_entry_days || details.length || 0).toLocaleString("ko-KR")}일 검토 필요`; if (!details.length) { - multiEntryBody.innerHTML = `
    중복 상세가 없습니다.
    `; + multiEntryBody.innerHTML = `
    검토할 근무기록이 없습니다.
    `; } else { multiEntryBody.innerHTML = details.map((detail) => `
    ${escapeHtml(detail.work_date || "-")} + ${escapeHtml(detail.overlap_type || "근무기록 검토")} 원본근로 ${formatNumberText(detail.raw_total_hours)}시간 집계반영 ${formatNumberText(detail.capped_regular_hours)}시간 ${Number(detail.row_count || 0).toLocaleString("ko-KR")}행 @@ -2735,6 +3076,14 @@ ${Number(entry.row_count || 0) > 1 ? `원본 ${Number(entry.row_count).toLocaleString("ko-KR")}행` : ""} `).join("")} + ${(Array.isArray(detail.activity_evidence) ? detail.activity_evidence : []).map((entry) => ` + + ${renderProjectCodeText(entry)} + ${escapeHtml(entry.project_name || entry.project_code || "공통/미지정")} + - + ${escapeHtml([entry.source_label || "활동 근거", entry.note || ""].filter(Boolean).join(" · "))} + + `).join("")}
    @@ -2787,7 +3136,7 @@ if (currentAggregateView !== "project" && Array.isArray(detail.projects) && detail.projects.length) { return detail.projects.map((project) => ({ workDate: detail.work_date, - project: `${sourcePrefix(project.source_label)}${project.project_name || project.project_code || "(미지정)"}${project.project_code ? ` (${project.project_code})` : ""}`, + project: `${sourcePrefix(project.source_label)}${project.project_name || project.project_code || "(미지정)"}${project.project_code ? ` (${project.project_code})` : ""}${Array.isArray(project.source_project_codes) && project.source_project_codes.length ? ` · 원천 ${project.source_project_codes.join(", ")}` : ""}`, recognizedHours: project.recognized_hours ?? (detail.projects.length === 1 ? detail.regular_hours : 0), actualHours: project.hours, })); @@ -2824,6 +3173,60 @@ }); const renderAggregateDetailRows = (detailKey, details) => { + if (detailKey === "population_review") { + return ` + + + + + + + + + + + + ${details.map((detail) => ` + + + + + + + + `).join("")} + +
    판정사유CompanyWorkCompany부서
    ${escapeHtml(detail.decision || "소속검토필요")}${escapeHtml(detail.reason || "")}${escapeHtml(detail.company || "")}${escapeHtml(detail.work_company || "")}${escapeHtml(detail.dept_name || "")}
    + `; + } + if (detailKey === "missing_regular_days") { + return ` + + + + + + + + + + + + + ${details.map((detail) => ` + + + + + + + + + `).join("")} + +
    일자근무의무휴가·시차인정근로부족판정
    ${escapeHtml(detail.work_date || "-")}${formatNumberText(detail.expected_hours)}${formatNumberText(detail.leave_hours)}${formatNumberText(detail.recognized_hours)}${formatNumberText(detail.missing_hours)}${escapeHtml(detail.reason || "")}
    + `; + } if (detailKey === "project_count") { return ` @@ -2983,8 +3386,9 @@ aggregateLeaveDays.textContent = Number(summary.legal_leave_hours || 0).toLocaleString("ko-KR"); aggregateProjectCount.textContent = Number(summary.project_count || 0).toLocaleString("ko-KR"); const diagnostics = payload.source_diagnostics || {}; + const logicVersionText = diagnostics.logic_version ? ` · 로직 ${diagnostics.logic_version}` : ""; const diagnosticText = diagnostics - ? ` · 공식연장 ${Number(diagnostics.official_overtime_rows || 0).toLocaleString("ko-KR")}행 · addwork ${Number(diagnostics.addwork_parsed_rows || 0).toLocaleString("ko-KR")}/${Number(diagnostics.addwork_source_rows || 0).toLocaleString("ko-KR")}행 · 합사 ${Number(diagnostics.joint_assignment_records || 0).toLocaleString("ko-KR")}건(코드 ${Number(diagnostics.joint_assignment_code_matched_rows || 0).toLocaleString("ko-KR")}, 문구 ${Number(diagnostics.joint_assignment_text_matched_rows || 0).toLocaleString("ko-KR")})/${Number(diagnostics.joint_assignment_regular_rows || 0).toLocaleString("ko-KR")}일 · 합사연장 ${Number(diagnostics.joint_assignment_overtime_rows || 0).toLocaleString("ko-KR")}일 · 사번통합 ${Number(diagnostics.canonical_member_no_rows || 0).toLocaleString("ko-KR")}행 · 임계 제외 ${Number((diagnostics.addwork_weekday_threshold_filtered_rows || 0) + (diagnostics.addwork_holiday_threshold_filtered_rows || 0)).toLocaleString("ko-KR")}행 · 휴가 ${Number(diagnostics.leave_matched_rows || 0).toLocaleString("ko-KR")}/${Number(diagnostics.tardy_candidate_rows || 0).toLocaleString("ko-KR")}행 · 탄력 제외 ${Number(diagnostics.leave_flexible_work_excluded_rows || 0).toLocaleString("ko-KR")}행 · 부서 연결 ${Number(diagnostics.dept_mapped_rows || 0).toLocaleString("ko-KR")}명 · 바론 제외 ${Number(diagnostics.center_excluded_member_count || 0).toLocaleString("ko-KR")}명 (동일사번 ${Number(diagnostics.center_same_member_hidden_count || 0).toLocaleString("ko-KR")}명)` + ? `${logicVersionText} · 공식연장 ${Number(diagnostics.official_overtime_rows || 0).toLocaleString("ko-KR")}행 · addwork ${Number(diagnostics.addwork_parsed_rows || 0).toLocaleString("ko-KR")}/${Number(diagnostics.addwork_source_rows || 0).toLocaleString("ko-KR")}행 · 합사 ${Number(diagnostics.joint_assignment_records || 0).toLocaleString("ko-KR")}건(코드 ${Number(diagnostics.joint_assignment_code_matched_rows || 0).toLocaleString("ko-KR")}, 문구 ${Number(diagnostics.joint_assignment_text_matched_rows || 0).toLocaleString("ko-KR")}건)/${Number(diagnostics.joint_assignment_regular_rows || 0).toLocaleString("ko-KR")}일 · 합사연장 ${Number(diagnostics.joint_assignment_overtime_rows || 0).toLocaleString("ko-KR")}일 · 사번통합 ${Number(diagnostics.canonical_member_no_rows || 0).toLocaleString("ko-KR")}행 · 임계 제외 ${Number((diagnostics.addwork_weekday_threshold_filtered_rows || 0) + (diagnostics.addwork_holiday_threshold_filtered_rows || 0)).toLocaleString("ko-KR")}행 · 휴가 ${Number(diagnostics.leave_matched_rows || 0).toLocaleString("ko-KR")}/${Number(diagnostics.tardy_candidate_rows || 0).toLocaleString("ko-KR")}행 · 탄력 제외 ${Number(diagnostics.leave_flexible_work_excluded_rows || 0).toLocaleString("ko-KR")}행 · 부서 연결 ${Number(diagnostics.dept_mapped_rows || 0).toLocaleString("ko-KR")}명 · 계열사스키마 ${Number(diagnostics.affiliate_schema_count || 0).toLocaleString("ko-KR")}개 · 계열사/소속 제외 ${Number((diagnostics.population_owner_excluded_member_count || 0) + (diagnostics.affiliate_actual_work_excluded_count || diagnostics.center_excluded_member_count || 0)).toLocaleString("ko-KR")}명 · 소속코드 검토 ${Number(diagnostics.company_review_member_count || 0).toLocaleString("ko-KR")}명 · 근무기록 없음 검토 ${Number(diagnostics.no_hanmac_work_review_member_count || 0).toLocaleString("ko-KR")}명 · 시스템 제외 ${Number(diagnostics.system_member_filtered_rows || 0).toLocaleString("ko-KR")}건` : ""; aggregateMeta.textContent = `${payload.start_date || ""} ~ ${payload.end_date || ""}${diagnosticText}`; if (currentAggregateSort.key && !columns.some((column) => column.key === currentAggregateSort.key)) { @@ -3287,6 +3691,12 @@ openConfigButton.addEventListener("click", openModal); closeConfigButton.addEventListener("click", closeModal); + connectionTabs.forEach((button) => { + button.addEventListener("click", () => selectConnectionTab(button.dataset.connectionTab || "work")); + }); + ["erp_user", "erp_password", "erp_db_user", "erp_db_password"].forEach((name) => { + form.elements.namedItem(name)?.addEventListener("input", saveErpCredentials); + }); modal.addEventListener("click", (event) => { if (event.target === modal) closeModal(); }); @@ -3390,6 +3800,241 @@ } }); + testErpButton.addEventListener("click", async () => { + testErpButton.disabled = true; + setStatus("관리 ERP 로그인과 내부 접근 범위를 확인하고 있습니다..."); + try { + saveErpCredentials(); + const payload = await fetchJson("/hanmac-browser/api/test-management-erp", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(getErpPayload()), + }); + setStatus( + `${payload.message} 내부 링크 ${Number(payload.internal_link_count || 0).toLocaleString("ko-KR")}개 확인 · ${payload.direct_db_message || "DB 직접 접속정보 미확인"} · ${payload.transport_warning || ""}`, + "success", + ); + } catch (error) { + setStatus(error.message || "관리 ERP 접근 확인 중 오류가 발생했습니다.", "error"); + } finally { + testErpButton.disabled = false; + } + }); + + discoverSatisBudgetButton.addEventListener("click", async () => { + discoverSatisBudgetButton.disabled = true; + setStatus("Satis 예산 후보 DB·테이블을 탐색하고 로컬 이력 저장소를 준비하고 있습니다..."); + try { + saveErpCredentials(); + const payload = await fetchJson("/hanmac-browser/api/discover-satis-budget", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(getErpPayload()), + }); + const dbCount = Number(payload.direct_db_database_count || 0).toLocaleString("ko-KR"); + const tableCount = Number(payload.candidate_table_count || 0).toLocaleString("ko-KR"); + const firstTables = Array.isArray(payload.candidate_tables) + ? payload.candidate_tables.slice(0, 3).map((row) => `${row.database}.${row.table}`).join(", ") + : ""; + const preparedTables = Array.isArray(payload.prepared_local_tables) + ? payload.prepared_local_tables.length + : 0; + setStatus( + `${payload.message} DB ${dbCount}개 · 후보 테이블 ${tableCount}개 · 로컬 이력 테이블 ${preparedTables}개 준비${firstTables ? ` · 예: ${firstTables}` : ""}`, + payload.direct_db_access || payload.web_access ? "success" : "error", + ); + if (payload.transport_warning) { + console.warn(payload.transport_warning); + } + if (payload.candidate_tables?.length) { + console.table(payload.candidate_tables.slice(0, 20)); + } + } catch (error) { + setStatus(error.message || "Satis 예산 후보 탐색 중 오류가 발생했습니다.", "error"); + } finally { + discoverSatisBudgetButton.disabled = false; + } + }); + + syncSatisBudgetButton.addEventListener("click", async () => { + syncSatisBudgetButton.disabled = true; + setStatus("Satis 후보 테이블에서 실제 예산 금액 행을 읽어 원본 저장소에 저장하고 있습니다..."); + try { + saveErpCredentials(); + const payload = await fetchJson("/hanmac-browser/api/sync-satis-budget-raw", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + ...getErpPayload(), + max_tables: 12, + row_limit: 500, + }), + }); + const rowCount = Number(payload.inserted_or_updated_rows || 0).toLocaleString("ko-KR"); + const tableCount = Array.isArray(payload.table_results) + ? payload.table_results.filter((row) => row.status === "synced").length + : 0; + const firstTables = Array.isArray(payload.table_results) + ? payload.table_results + .filter((row) => row.status === "synced" && Number(row.row_count || 0) > 0) + .slice(0, 3) + .map((row) => `${row.database}.${row.table} ${Number(row.row_count || 0).toLocaleString("ko-KR")}건`) + .join(", ") + : ""; + setStatus( + `${payload.message} 저장 테이블: ${payload.raw_table || "satis_project_budget_raw_rows"} · 처리 테이블 ${tableCount.toLocaleString("ko-KR")}개${firstTables ? ` · 예: ${firstTables}` : ""}`, + Number(payload.inserted_or_updated_rows || 0) > 0 ? "success" : "error", + ); + if (payload.table_results?.length) { + console.table(payload.table_results); + } + } catch (error) { + setStatus(error.message || "Satis 예산 금액 저장 중 오류가 발생했습니다.", "error"); + } finally { + syncSatisBudgetButton.disabled = false; + } + }); + + normalizeSatisBudgetButton.addEventListener("click", async () => { + normalizeSatisBudgetButton.disabled = true; + setStatus("저장된 Satis 원본 금액을 차수/상세 예산 테이블로 정규화하고 있습니다..."); + try { + const payload = await fetchJson("/hanmac-browser/api/normalize-satis-budget", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({}), + }); + const revisionCount = Number(payload.normalized_revisions || 0).toLocaleString("ko-KR"); + const taskLineCount = Number(payload.task_lines || 0).toLocaleString("ko-KR"); + const execLineCount = Number(payload.exec_lines || 0).toLocaleString("ko-KR"); + const firstSummary = Array.isArray(payload.summary) + ? payload.summary + .slice(0, 3) + .map((row) => `${row.budget_type}: ${Number(row.revision_count || 0).toLocaleString("ko-KR")}차수`) + .join(", ") + : ""; + setStatus( + `${payload.message} 차수 ${revisionCount}건 · 과업수행계획 ${taskLineCount}행 · 실행예산 ${execLineCount}행${firstSummary ? ` · ${firstSummary}` : ""}`, + Number(payload.normalized_revisions || 0) > 0 ? "success" : "error", + ); + if (payload.summary?.length) { + console.table(payload.summary); + } + if (payload.source_tables?.length) { + console.table(payload.source_tables); + } + } catch (error) { + setStatus(error.message || "Satis 예산 정규화 중 오류가 발생했습니다.", "error"); + } finally { + normalizeSatisBudgetButton.disabled = false; + } + }); + + projectSatisBudgetButton.addEventListener("click", async () => { + projectSatisBudgetButton.disabled = true; + setStatus("프로젝트별 승인 최신 차수를 기존 예산 입력 테이블에 반영하고 있습니다. 승인 중 최신 차수는 가반영합니다..."); + try { + const payload = await fetchJson("/hanmac-browser/api/project-satis-budget-current", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ include_pending: true }), + }); + const projectCount = Number(payload.project_count || 0).toLocaleString("ko-KR"); + const taskRows = Number(payload.task_rows_inserted || 0).toLocaleString("ko-KR"); + const execRows = Number(payload.exec_rows_inserted || 0).toLocaleString("ko-KR"); + const provisionalCount = Number(payload.provisional_count || 0).toLocaleString("ko-KR"); + const firstProjected = Array.isArray(payload.projected) + ? payload.projected + .slice(0, 3) + .map((row) => `${row.support_dept_code}/${row.budget_type}/${row.projection_mode}`) + .join(", ") + : ""; + setStatus( + `${payload.message} 과업수행계획 ${taskRows}행 · 실행예산 ${execRows}행 · 가반영 ${provisionalCount}건${firstProjected ? ` · 예: ${firstProjected}` : ""}`, + Number(payload.project_count || 0) > 0 ? "success" : "error", + ); + if (payload.projected?.length) { + console.table(payload.projected); + } + if (payload.skipped?.length) { + console.table(payload.skipped); + } + } catch (error) { + setStatus(error.message || "Satis 예산 기존 입력 테이블 반영 중 오류가 발생했습니다.", "error"); + } finally { + projectSatisBudgetButton.disabled = false; + } + }); + + runSatisBudgetFullSyncButton.addEventListener("click", async () => { + runSatisBudgetFullSyncButton.disabled = true; + setStatus("Satis 예산 전체 실행 중입니다. 원본 저장 → 정규화 → 승인 최신 차수 반영을 순차 처리합니다..."); + try { + saveErpCredentials(); + const payload = await fetchJson("/hanmac-browser/api/run-satis-budget-full-sync", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + ...getErpPayload(), + max_tables: 12, + row_limit: 500, + }), + }); + const rawRows = Number(payload.inserted_or_updated_rows || 0).toLocaleString("ko-KR"); + const revisions = Number(payload.normalized_revisions || 0).toLocaleString("ko-KR"); + const projects = Number(payload.project_count || 0).toLocaleString("ko-KR"); + const taskRows = Number(payload.task_rows_inserted || 0).toLocaleString("ko-KR"); + const execRows = Number(payload.exec_rows_inserted || 0).toLocaleString("ko-KR"); + const provisional = Number(payload.provisional_count || 0).toLocaleString("ko-KR"); + setStatus( + `${payload.message} 원본 ${rawRows}건 · 차수 ${revisions}건 · 프로젝트 ${projects}개 · 과업 ${taskRows}행 · 실행 ${execRows}행 · 가반영 ${provisional}건`, + Number(payload.project_count || 0) > 0 ? "success" : "error", + ); + console.log("Satis full sync", payload); + } catch (error) { + setStatus(error.message || "Satis 예산 전체 실행 중 오류가 발생했습니다.", "error"); + } finally { + runSatisBudgetFullSyncButton.disabled = false; + } + }); + + collectSatisBudgetWebButton.addEventListener("click", async () => { + collectSatisBudgetWebButton.disabled = true; + setStatus("Satis 웹로그인 세션으로 예산 관련 메뉴/컨트롤러 응답을 수집하고 있습니다..."); + try { + saveErpCredentials(); + const payload = await fetchJson("/hanmac-browser/api/collect-satis-budget-web", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + ...getErpPayload(), + max_pages: 40, + }), + }); + const capturedCount = Number(payload.captured_count || 0).toLocaleString("ko-KR"); + const matchedCount = Number(payload.matched_count || 0).toLocaleString("ko-KR"); + const errorCount = Number(payload.error_count || 0).toLocaleString("ko-KR"); + const firstExamples = Array.isArray(payload.matched_examples) + ? payload.matched_examples + .slice(0, 2) + .map((row) => `${row.title || row.url || "응답"} (${Number(row.amount_candidate_count || 0).toLocaleString("ko-KR")}개 금액후보)`) + .join(", ") + : ""; + setStatus( + `${payload.message} 매칭 ${matchedCount}건 · 오류 ${errorCount}건${firstExamples ? ` · 예: ${firstExamples}` : ""}`, + Number(payload.captured_count || 0) > 0 ? "success" : "error", + ); + console.log("Satis web collection", payload); + if (payload.matched_examples?.length) { + console.table(payload.matched_examples); + } + } catch (error) { + setStatus(error.message || "Satis 웹로그인 예산 수집 중 오류가 발생했습니다.", "error"); + } finally { + collectSatisBudgetWebButton.disabled = false; + } + }); + loadTablesButton.addEventListener("click", loadTables); loadGradeCodesButton.addEventListener("click", async () => { loadGradeCodesButton.disabled = true; @@ -3496,6 +4141,8 @@ const today = new Date(); const startOfYear = new Date(today.getFullYear(), 0, 1); restoreCredentials(); + restoreErpCredentials(); + selectConnectionTab("work"); aggregateStartDate.value = startOfYear.toISOString().slice(0, 10); aggregateEndDate.value = today.toISOString().slice(0, 10); const restoredAggregateState = restoreAggregateState(); diff --git a/templates/wehago_compare.html b/templates/wehago_compare.html index 88dd837..9f8ff59 100644 --- a/templates/wehago_compare.html +++ b/templates/wehago_compare.html @@ -466,6 +466,17 @@ margin-left: 0; } + .status-unit { + display: block; + width: 100%; + margin-top: 2px; + font-size: 11px; + font-weight: 800; + line-height: 1; + color: #64748b; + text-align: left; + } + .status-card[data-count-pending="true"] .status-value { color: #64748b; font-size: 13px; @@ -653,18 +664,20 @@ .table-wrap { width: 100%; max-width: 100%; - overflow-x: hidden; + overflow-x: auto; overflow-y: auto; border-top: 1px solid rgba(217, 221, 227, 0.9); - background: transparent; - max-height: 460px; + background: #fff; + max-height: none; box-sizing: border-box; + position: relative; + isolation: isolate; + scrollbar-gutter: stable; } .detail-panel .table-wrap { - max-height: 360px; - overflow-x: hidden; - overflow-y: auto; + height: clamp(440px, 68dvh, 980px); + max-height: none; } .table-footer { @@ -703,8 +716,10 @@ .table-wrap th { position: sticky; top: 0; - z-index: 1; + z-index: 10; background: #f8fafc; + background-clip: padding-box; + box-shadow: 0 1px 0 rgba(148, 163, 184, 0.55), 0 3px 8px rgba(15, 23, 42, 0.1); font-size: 13px; letter-spacing: 0.01em; white-space: nowrap; @@ -784,14 +799,46 @@ font-size: 12px; } + .voucher-review-notice td { + background: #fff7ed; + color: #9a3412; + font-size: 12px; + border-bottom-color: rgba(251, 146, 60, 0.28); + } + + .voucher-review-badges { + display: flex; + flex-wrap: wrap; + gap: 6px; + align-items: center; + } + + .voucher-review-badge { + display: inline-flex; + align-items: center; + min-height: 22px; + padding: 0 8px; + border-radius: 999px; + background: #fed7aa; + color: #7c2d12; + font-size: 11px; + font-weight: 800; + } + + .voucher-review-note { + color: #9a3412; + } + .voucher-group-lines { overflow: visible; max-height: none; + position: relative; + background: #fff; } .voucher-group-lines table { width: 100%; - min-width: 0; + min-width: 1540px; border-collapse: collapse; table-layout: fixed; } @@ -830,8 +877,10 @@ .voucher-group-lines th { position: sticky; top: 0; - z-index: 2; + z-index: 10; background: #f8fafc; + background-clip: padding-box; + box-shadow: 0 1px 0 rgba(148, 163, 184, 0.55), 0 3px 8px rgba(15, 23, 42, 0.1); color: inherit; font-size: 13px; letter-spacing: 0.01em; @@ -1513,11 +1562,14 @@ } body[data-view-mode="dual"] #pairRecommendPanel.active .table-wrap { - max-height: calc(100vh - 230px); + height: calc(100dvh - 230px); + min-height: 360px; + max-height: none; } body[data-view-mode="dual"] .detail-panel .table-wrap { - max-height: clamp(300px, 44vh, 520px); + height: clamp(400px, 62dvh, 840px); + max-height: none; } body[data-view-mode="dual"] .cell-code { @@ -1549,6 +1601,10 @@ } @media (max-width: 720px) { + .detail-panel .table-wrap { + height: clamp(360px, 62dvh, 720px); + } + .voucher-topbar, .panel-header, .data-panel summary { @@ -1638,6 +1694,15 @@

    기간 {{ wehago_compare.selected_start_year or '-' }} ~ {{ wehago_compare.selected_end_year or '-' }}

    + {% set final_summary = wehago_compare.wehago_final_status_summary or {} %} + {% set final_diag = final_summary.diagnostics or {} %} +
    + WEHAGO 전표 단위 기준: + {{ "{:,}".format(final_diag.wehago_status_total or final_summary.classified_total or 0) }}건 / + 원천 {{ "{:,}".format(final_diag.wehago_voucher_total or final_summary.raw_total or 0) }}건 / + 차이 {{ "{:,}".format(final_diag.wehago_status_difference or final_summary.difference or 0) }}건 / + {{ final_summary.source or 'projection' }} +
    {% for section in wehago_compare.metric_sections %} {% if section.key in ['voucher_matched', 'voucher_unmatched', 'voucher_recheck', 'voucher_excepted', 'hanmac_unconnected', 'erp_voucher_matched', 'erp_voucher_unmatched'] %} @@ -1646,6 +1711,9 @@ class="status-card" data-target="detail-{{ section.key }}" data-status="{{ section.key }}" + data-count="{{ section.count or 0 }}" + data-count-unit="{{ section.count_unit|default('전표그룹') }}" + data-projection-signature="{{ section.projection_signature|default('') }}" data-count-pending="{% if wehago_compare.pending and section.count == 0 %}true{% else %}false{% endif %}" aria-expanded="false" > @@ -1653,6 +1721,7 @@ {% if wehago_compare.pending and section.count == 0 %}갱신 중{% else %}{{ "{:,}".format(section.count) }}{% endif %} + {{ section.count_unit|default('전표그룹') }} {% endif %} {% endfor %} @@ -1698,7 +1767,7 @@
    - +
    @@ -1723,7 +1792,7 @@ {% else %}
    - +
    @@ -2064,15 +2133,65 @@ ]; }; + const firstPresentValue = (...values) => { + for (const value of values) { + if (value === null || value === undefined) continue; + if (typeof value === 'string' && value.trim() === '') continue; + return value; + } + return ''; + }; + + const normalizeVoucherDisplayRow = (row = {}, summary = {}) => { + const source = row || {}; + const group = summary || {}; + return { + ...source, + status_label: firstPresentValue(source.status_label, group.status_label), + fiscal_year: firstPresentValue(source.fiscal_year, group.fiscal_year), + ledger_date: firstPresentValue(source.ledger_date, group.ledger_date), + proof_date: firstPresentValue(source.proof_date, group.proof_date), + voucher_no: firstPresentValue(source.voucher_no, group.voucher_no), + // 가전표번호는 확정전표번호로 보완하지 않는다. 번호 오염을 막기 위해 draft_no 계열만 허용한다. + draft_no: firstPresentValue(source.draft_no, source.erp_draft_no, source.hanmac_draft_no, group.draft_no), + ledger_account_name: firstPresentValue(source.ledger_account_name, source.wehago_account_name, group.ledger_accounts), + ledger_vendor: firstPresentValue(source.ledger_vendor, source.wehago_vendor, group.ledger_vendors), + ledger_debit: firstPresentValue(source.ledger_debit, source.wehago_debit, group.ledger_debit), + ledger_credit: firstPresentValue(source.ledger_credit, source.wehago_credit, group.ledger_credit), + ledger_desc: firstPresentValue(source.ledger_desc, source.wehago_desc, group.ledger_desc), + voucher_account_name: firstPresentValue( + source.voucher_account_name, + source.erp_account_name, + source.hanmac_account_name, + group.voucher_account_name, + group.voucher_accounts + ), + voucher_vendor: firstPresentValue( + source.voucher_vendor, + source.erp_vendor, + source.hanmac_vendor, + group.voucher_vendor, + group.voucher_vendors + ), + voucher_debit: firstPresentValue(source.voucher_debit, source.erp_debit, source.hanmac_debit, group.voucher_debit), + voucher_credit: firstPresentValue(source.voucher_credit, source.erp_credit, source.hanmac_credit, group.voucher_credit), + voucher_desc: firstPresentValue(source.voucher_desc, source.erp_desc, source.hanmac_desc, group.voucher_desc), + }; + }; + const renderVoucherDetailTable = (group) => { if (!voucherDetailTableWrap) return; - const rows = Array.isArray(group?.rows) ? group.rows : []; + const summary = group?.summary || {}; + const rawRows = Array.isArray(group?.rows) ? group.rows : []; + const rows = rawRows.length + ? rawRows.map((row) => normalizeVoucherDisplayRow(row, summary)) + : [normalizeVoucherDisplayRow(summary, summary)]; const isHanmacUnconnected = String(group?.summary?.status_label || '').toLowerCase() === 'hanmac unconnected'; const lineColumns = [ ['status_label', '구분'], ...getVoucherLineColumns(isHanmacUnconnected ? 'hanmac_unconnected' : ''), ]; - if (!rows.length) { + if (!rawRows.length && !Object.keys(summary).length) { voucherDetailTableWrap.innerHTML = '
    표시할 상세 행이 없습니다.
    '; return; } @@ -2383,6 +2502,57 @@ } return String(raw ?? ''); }; + const getVoucherReviewBadges = (group) => { + const summary = group?.summary || {}; + const reasonText = [ + summary.review_reason || '', + ...(Array.isArray(group?.rows) ? group.rows.map((row) => row?.review_reason || '') : []), + ].join(' / '); + const badges = []; + const add = (key, label, note = '') => { + if (!reasonText.includes(key)) return; + if (badges.some((item) => item.label === label)) return; + badges.push({ label, note }); + }; + const hasStrongInternalCandidate = [ + 'INTERNAL_STATUS=matched_direct_candidate', + 'INTERNAL_STATUS=matched_with_settlement_bridge_candidate', + 'INTERNAL_STATUS=matched_bundle_candidate', + ].some((key) => reasonText.includes(key)); + add('PARTIAL_WEAK_MONTH_MISMATCH', '월 불일치·약한 근거', '부가세/비용 직접행 없이 일부 정산행만 맞아 재검토가 필요합니다.'); + add('ERP_PARTIAL_VOUCHER_ROWS_ONLY', 'ERP 일부행 후보', '같은 ERP 전표의 일부 행만 연결된 후보입니다.'); + add('TAX_INVOICE_CORE_ROWS_NOT_MATCHED', '핵심 계정 누락', '부가세·비용·채권채무 구조가 완전하지 않습니다.'); + add('PROJECT_TOKEN_CONFLICT', '현장명 불일치', 'WEHAGO와 ERP의 현장/사업명 단서가 다릅니다.'); + add('MONTH_CONFLICT_RECHECK', '월 불일치', '전표월 또는 증빙월 확인이 필요합니다.'); + if (!hasStrongInternalCandidate) { + add('WEAK_MATCH_RECHECK', '약한 매칭', '금액 외 단서가 부족합니다.'); + } + add('DIRECT_MATCH_CANDIDATE', '직접 대응 후보', '금액·계정·거래 문맥이 함께 일치하는 ERP 원본행입니다.'); + add('SETTLEMENT_BRIDGE_CANDIDATE', '정산 브리지', '직접 업무행이 아니라 결제·정산 상대행으로 연결된 후보입니다.'); + add('SHARED_ALLOCATION_CANDIDATE', '공유 합산행', '여러 WEHAGO 전표의 배분 합계가 이 ERP 원본행을 구성합니다.'); + add('SHARED_BUNDLE_PAYMENT_CANDIDATE', 'N:1 결제 묶음', '여러 WEHAGO 전표가 하나의 ERP 결제행을 공유하는 묶음 후보입니다.'); + add('ERP_FULL_DRAFT_VOUCHER_CONTEXT', 'ERP 전체 전표', '후보 가전표의 원본행 전체를 표시합니다.'); + return badges; + }; + const renderVoucherReviewNoticeRow = (group, colSpan) => { + if (statusKey !== 'voucher_recheck') return ''; + const badges = getVoucherReviewBadges(group); + if (!badges.length) return ''; + const badgeHtml = badges + .map((item) => `${escapeHtml(item.label)}`) + .join(''); + const note = badges.map((item) => item.note).find(Boolean) || '상세 버튼에서 연결 행을 확인해주세요.'; + return ` +
    + + + `; + }; if (!groups.length) { const fallbackRows = Array.isArray(payload.rows) ? payload.rows : []; if (!fallbackRows.length && !append) { @@ -2393,7 +2563,8 @@ includeReviewCheckbox ? '' : '', ...lineColumns.map(([field, label]) => ``), ].join(''); - const fallbackBody = fallbackRows.map((row, summaryIndex) => { + const fallbackBody = fallbackRows.map((rawRow, summaryIndex) => { + const row = normalizeVoucherDisplayRow(rawRow, rawRow); const groupKey = `${row.fiscal_year || ''}|${row.voucher_no || ''}|${row.draft_no || ''}|${row.ledger_date || ''}|${row.proof_date || ''}|${summaryIndex}`; const cells = lineColumns.map(([field]) => { const display = formatValue(field, row[field]); @@ -2431,9 +2602,13 @@ ...lineColumns.map(([field, label]) => ``), ].join(''); const lineBody = groups.map((group, groupIndex) => { - const rows = Array.isArray(group.rows) ? group.rows : []; + const summary = group?.summary || {}; + const rawRows = Array.isArray(group.rows) ? group.rows : []; + const rows = rawRows.length + ? rawRows.map((row) => normalizeVoucherDisplayRow(row, summary)) + : [normalizeVoucherDisplayRow(summary, summary)]; const groupKey = buildVoucherRecheckGroupKey(group, groupIndex); - return rows.map((row, rowIndex) => { + const groupRowsHtml = rows.map((row, rowIndex) => { const rowClasses = []; if (rowIndex === 0) { rowClasses.push('voucher-table-group-start'); @@ -2455,6 +2630,7 @@ } return `${checkboxCell}${cells}`; }).join(''); + return `${renderVoucherReviewNoticeRow(group, lineColumns.length + (includeReviewCheckbox ? 2 : 0))}${groupRowsHtml}`; }).join(''); const tableHtml = `
    +
    + ${badgeHtml} + ${escapeHtml(note)} +
    +
    매칭분리${escapeHtml(label)}${escapeHtml(label)}
    @@ -2575,9 +2751,10 @@ const setMeta = (key, totalCount, shownCount, notice = '', stats = {}) => { const target = document.querySelector(`[data-result-meta="${key}"]`); if (target) { - const countText = `조회 결과 ${Number(totalCount || 0).toLocaleString()}건`; + const unit = String(stats.count_unit || document.querySelector(`.status-card[data-status="${key}"]`)?.dataset.countUnit || '건'); + const countText = `조회 결과 ${Number(totalCount || 0).toLocaleString()}${unit}`; const shownText = shownCount - ? ` / 현재 ${Number(shownCount || 0).toLocaleString()}건` + ? ` / 현재 ${Number(shownCount || 0).toLocaleString()}${unit}` : ''; const readyYears = Array.isArray(stats.ready_years) ? stats.ready_years : []; const pendingYears = Array.isArray(stats.pending_years) ? stats.pending_years : []; @@ -2616,7 +2793,21 @@ }, signal: options.signal || controller?.signal, }); - const payload = await response.json(); + const responseText = await response.text(); + let payload = {}; + if (responseText) { + try { + payload = JSON.parse(responseText); + } catch (_error) { + const fallbackMessage = responseText.length > 240 + ? `${responseText.slice(0, 240)}...` + : responseText; + if (!response.ok) { + throw new Error(fallbackMessage || `HTTP ${response.status}`); + } + throw new Error('서버 응답을 JSON으로 해석할 수 없습니다.'); + } + } if (!response.ok) { throw new Error(payload.error || '조회 중 오류가 발생했습니다.'); } @@ -2755,6 +2946,16 @@ }; const applySummaryPayload = (payload) => { + const finalBasisMeta = document.getElementById('wehagoFinalBasisMeta'); + if (finalBasisMeta) { + const summary = payload?.wehago_final_status_summary || {}; + const diagnostics = summary.diagnostics || {}; + const statusTotal = Number(diagnostics.wehago_status_total ?? summary.classified_total ?? 0); + const rawTotal = Number(diagnostics.wehago_voucher_total ?? summary.raw_total ?? 0); + const difference = Number(diagnostics.wehago_status_difference ?? summary.difference ?? 0); + const source = String(summary.source || 'projection'); + finalBasisMeta.textContent = `WEHAGO 전표 단위 기준: ${statusTotal.toLocaleString()}건 / 원천 ${rawTotal.toLocaleString()}건 / 차이 ${difference.toLocaleString()}건 / ${source}`; + } const sections = Array.isArray(payload?.metric_sections) ? payload.metric_sections : []; sections.forEach((section) => { const card = document.querySelector(`.status-card[data-status="${escapeSelectorValue(section.key || '')}"]`); @@ -2763,6 +2964,13 @@ const isPendingCount = Boolean(payload?.pending) && countValue === 0; if (card) { card.dataset.countPending = isPendingCount ? 'true' : 'false'; + card.dataset.count = String(countValue || 0); + card.dataset.countUnit = String(section.count_unit || card.dataset.countUnit || '전표'); + card.dataset.projectionSignature = String(section.projection_signature || ''); + const unitNode = card.querySelector('.status-unit'); + if (unitNode) { + unitNode.textContent = card.dataset.countUnit; + } } if (valueNode) { valueNode.textContent = isPendingCount ? '갱신 중' : countValue.toLocaleString(); @@ -2779,7 +2987,8 @@ if (undoLastActionBtn) { undoLastActionBtn.disabled = !(lastAction && lastAction.id); } - const pending = Boolean(payload?.pending); + const jobActive = ['queued', 'running'].includes(String(payload?.latest_job?.status || payload?.system_job?.status || '')); + const pending = Boolean(payload?.pending) && jobActive; summaryRefreshState.pending = pending; if (summaryRefreshState.timerId) { clearTimeout(summaryRefreshState.timerId); @@ -3843,6 +4052,11 @@ clearTimeout(detailRetryTimers.get(statusKey)); detailRetryTimers.delete(statusKey); } + if (!append) { + detailRequestControllers.forEach((controller, key) => { + if (key !== statusKey) controller.abort(); + }); + } const current = detailState.get(statusKey) || { offset: 0, totalCount: 0, loading: false }; if (current.loading) { const activeController = detailRequestControllers.get(statusKey); @@ -3863,7 +4077,12 @@ setMeta(statusKey, current.totalCount || 0, 0, '조회 중입니다...'); } const requestController = new AbortController(); - const requestTimeoutId = window.setTimeout(() => requestController.abort(), 20000); + let requestAbortReason = ''; + const detailRequestTimeoutMs = 30000; + const requestTimeoutId = window.setTimeout(() => { + requestAbortReason = 'timeout'; + requestController.abort(); + }, detailRequestTimeoutMs); detailRequestControllers.set(statusKey, requestController); try { const pageLimit = usesCursorPaging ? 24 : 60; @@ -3875,6 +4094,12 @@ { signal: requestController.signal, timeoutMs: 20000 }, ); if (detailRequestControllers.get(statusKey) !== requestController) return; + const card = document.querySelector(`.status-card[data-status="${statusKey}"]`); + const cardSignature = String(card?.dataset.projectionSignature || '').trim(); + const payloadSignature = String(payload?.projection_signature || '').trim(); + if (!append && cardSignature && payloadSignature && cardSignature !== payloadSignature) { + card.dataset.projectionSignature = payloadSignature; + } renderTable(wrap, payload, ['voucher_no'], append, statusKey); const shownCount = append ? nextOffset + payload.shown_count : payload.shown_count; detailState.set(statusKey, { @@ -3900,8 +4125,11 @@ if (error.name === 'AbortError') { if (detailRequestControllers.get(statusKey) === requestController) { if (!append) { - wrap.innerHTML = '
    조회 시간이 길어져 중단했습니다. 조건을 줄여 다시 조회해주세요.
    '; - setMeta(statusKey, current.totalCount || 0, 0, '조회 시간이 길어져 중단했습니다.'); + const message = requestAbortReason === 'timeout' + ? '상세 조회 캐시를 준비하는 데 시간이 걸리고 있습니다. 조회 캐시 재생성 또는 잠시 후 다시 확인해주세요.' + : '이전 조회가 취소되었습니다.'; + wrap.innerHTML = `
    ${escapeHtml(message)}
    `; + setMeta(statusKey, current.totalCount || 0, 0, message); } detailState.set(statusKey, { ...current, loading: false, form }); setLoadMoreState(statusKey, current.hasMore, false); diff --git a/wehago_compare.py b/wehago_compare.py index 1584c83..c94c47b 100644 --- a/wehago_compare.py +++ b/wehago_compare.py @@ -5,6 +5,7 @@ import hashlib from io import BytesIO import json import logging +import os import re import sqlite3 import subprocess @@ -16,7 +17,7 @@ from datetime import date, datetime, timedelta from difflib import SequenceMatcher from functools import lru_cache from pathlib import Path -from typing import Any, Callable, Iterable +from typing import Any, Callable, Iterable, Mapping from openpyxl import Workbook, load_workbook from openpyxl.cell import WriteOnlyCell @@ -82,10 +83,10 @@ STATUS_META = [ ("bridge_expense_review", "2단계 비교", "보통예금 출금을 미지급금 결제와 원 비용 전표까지 추적한 검토 후보"), ] -QUERY_PROJECTION_VERSION = "compare-query-v17-hanmac-unconnected-source" +QUERY_PROJECTION_VERSION = "compare-query-v54-voucher-key-final-status-basis" STATUS_PROJECTION_VERSION = "status-projection-v2" HANMAC_UNCONNECTED_STATUS_PROJECTION_VERSION = f"{STATUS_PROJECTION_VERSION}-hanmac-unconnected-source" -EXPORT_COMPACT_STATUS_PROJECTION_VERSION = f"{STATUS_PROJECTION_VERSION}-export-cache" +EXPORT_COMPACT_STATUS_PROJECTION_VERSION = f"{STATUS_PROJECTION_VERSION}-export-cache-erp-detail-guard" COMPACT_EXPORT_STATUS_KEYS = { "voucher_matched", "erp_voucher_matched", @@ -94,6 +95,31 @@ COMPACT_EXPORT_STATUS_KEYS = { "voucher_recheck", "voucher_excepted", } + + +def _allow_stale_export_detail_fallback() -> bool: + return clean(os.getenv("WEHAGO_ALLOW_STALE_EXPORT_DETAIL_FALLBACK", "")).lower() in {"1", "true", "yes", "y", "on"} + + +def _allow_sync_detail_rebuild_fallback() -> bool: + return clean(os.getenv("WEHAGO_ALLOW_SYNC_DETAIL_REBUILD_FALLBACK", "")).lower() in {"1", "true", "yes", "y", "on"} + + +def _trace_compare_projection_enabled() -> bool: + return clean(os.getenv("WEHAGO_COMPARE_TRACE_PROJECTION", "")).lower() in {"1", "true", "yes", "y", "on"} + + +def _trace_compare_projection(step: str, **payload: Any) -> None: + if not _trace_compare_projection_enabled(): + return + try: + print({"step": step, **payload}, flush=True) + except Exception: + return + + +def _enable_raw_erp_trace_promotions() -> bool: + return clean(os.getenv("WEHAGO_ENABLE_RAW_ERP_TRACE_PROMOTIONS", "")).lower() in {"1", "true", "yes", "y", "on"} QUERY_VOUCHER_STATUS_KEYS = ( "voucher_matched", "voucher_unmatched", @@ -116,6 +142,8 @@ VOUCHER_GROUP_LINE_COLUMNS = [ ("ledger_date", "WEHAGO 일자"), ("voucher_no", "전표번호"), ("draft_no", "가전표번호"), + ("status_label", "행 상태"), + ("matched_case", "매칭 유형"), ("ledger_account_name", "WEHAGO 계정"), ("voucher_account_name", "ERP 계정"), ("ledger_vendor", "WEHAGO 거래처"), @@ -126,6 +154,7 @@ VOUCHER_GROUP_LINE_COLUMNS = [ ("voucher_credit", "ERP 대변"), ("ledger_desc", "WEHAGO 적요"), ("voucher_desc", "ERP 적요"), + ("review_reason", "검토 사유"), ] HANMAC_UNCONNECTED_LINE_COLUMNS = [ @@ -297,8 +326,8 @@ COMPARE_JOB_STALE_RUNNING_SEC = 3 * 60 * 60 ENABLE_COMPARE_QUERY_BACKGROUND_REBUILD = False ENABLE_GENERATED_RECHECK_CANDIDATES = False ENABLE_YEAR_SNAPSHOT_EXPORT_ROW_CACHE = False -RECHECK_RESOLUTION_POLICY_VERSION = "recheck-v33-adjacent-erp-auto-only" -METRIC_COUNT_CACHE_VERSION = "voucher-summary-v16-hanmac-unconnected-source" +RECHECK_RESOLUTION_POLICY_VERSION = "recheck-v44-canonical-voucher-split-context" +METRIC_COUNT_CACHE_VERSION = "voucher-summary-v28-project-token-without-slash-guard" SUMMARY_CACHE_POLICY_VERSION = "dashboard-summary-v1-nonblocking" _PAIR_RECOMMEND_PERSIST_TTL_SEC = 1800 _PAIR_RECOMMEND_JOB_EVENT = threading.Event() @@ -505,6 +534,68 @@ def get_hanmac_erp_candidate_year_range( return low - 1, high + 1 +def _row_effective_erp_date(row: dict[str, Any]) -> date | None: + parsed = _parse_iso_date(row.get("proof_date")) + if parsed: + return parsed + number_date = _hanmac_voucher_date_from_numbers(row.get("draft_no"), row.get("voucher_no"), row.get("confirmed_no")) + parsed = _parse_iso_date(number_date) + if parsed: + return parsed + parsed = _parse_iso_date(row.get("voucher_date")) + if parsed: + return parsed + parsed = _parse_iso_date(row.get("ledger_date")) + if parsed: + return parsed + return None + + +def _row_effective_wehago_date(row: dict[str, Any]) -> date | None: + parsed = _parse_row_date_with_year(row, "ledger_date") + if parsed: + return parsed + return _parse_row_date_with_year(row, "proof_date") + + +def _is_adjacent_month_erp_candidate_for_range( + row: dict[str, Any], + start_year: int, + end_year: int, +) -> bool: + candidate_year = _row_erp_candidate_year(row) + if not candidate_year: + return True + if int(start_year) <= candidate_year <= int(end_year): + return True + + erp_date = _row_effective_erp_date(row) + if candidate_year == int(start_year) - 1: + return bool(erp_date and erp_date.year == candidate_year and erp_date.month == 12) + if candidate_year == int(end_year) + 1: + return bool(erp_date and erp_date.year == candidate_year and erp_date.month == 1) + return False + + +def _is_adjacent_month_erp_candidate_for_wehago_row( + ledger_row: dict[str, Any], + voucher_row: dict[str, Any], + fiscal_year: int, +) -> bool: + voucher_year = _row_erp_candidate_year(voucher_row) + if not voucher_year or voucher_year == int(fiscal_year): + return True + if voucher_year not in {int(fiscal_year) - 1, int(fiscal_year) + 1}: + return False + if not _is_adjacent_month_erp_candidate_for_range(voucher_row, fiscal_year, fiscal_year): + return False + ledger_date = _row_effective_wehago_date(ledger_row) + voucher_date = _row_effective_erp_date(voucher_row) + if not ledger_date or not voucher_date: + return True + return abs((ledger_date - voucher_date).days) <= 45 + + def _parse_db_timestamp(value: Any) -> datetime | None: text_value = clean(value) if not text_value: @@ -602,6 +693,8 @@ def _apply_cached_adjacent_year_demotion_count_adjustment( ) -> dict[str, int]: if start_year is None or end_year is None: return counts + if clean(os.getenv("WEHAGO_ENABLE_CACHED_ADJACENT_YEAR_COUNT_ADJUSTMENT", "")).lower() not in {"1", "true", "yes", "y", "on"}: + return counts adjusted = {status_key: int(counts.get(status_key, 0) or 0) for status_key, _, _ in STATUS_META} for year in range(int(start_year), int(end_year) + 1): sections = _load_latest_year_resolved_sections_cache_any_signature(conn, year) or {} @@ -707,6 +800,7 @@ def _metric_counts_signature(conn: Any, start_year: int | None, end_year: int | ), {"start_year": start_year, "end_year": end_year}, ).first() + erp_existence = _active_erp_voucher_existence_signature(conn, start_year, end_year) return "|".join( [ METRIC_COUNT_CACHE_VERSION, @@ -716,6 +810,7 @@ def _metric_counts_signature(conn: Any, start_year: int | None, end_year: int | f"c:{comparison[0]}:{comparison[1]}", f"r:{recheck[0]}:{recheck[1]}", f"p:{pair[0]}:{pair[1]}", + f"ev:{erp_existence}", ] ) @@ -757,6 +852,19 @@ def _query_projection_signature(conn: Any, start_year: int | None, end_year: int ) +def _is_derived_wehago_query_projection_signature(signature: Any) -> bool: + normalized = clean(signature) + if not normalized: + return False + lowered = normalized.lower() + return ( + "|snapshot-recheck-promote|" in lowered + or "|db-reconciled-" in lowered + or "snapshot-recheck-promote" in lowered + or "db-reconciled-" in lowered + ) + + def _active_query_projection_signature( conn: Any, start_year: int | None, @@ -791,6 +899,8 @@ def _active_query_projection_signature( return "" if not signature.startswith(f"{QUERY_PROJECTION_VERSION}|"): return "" + if _is_derived_wehago_query_projection_signature(signature): + return "" try: exists = conn.execute( text( @@ -818,6 +928,355 @@ def _status_projection_setting_key(status_key: str, start_year: int | None, end_ return f"wehago_active_status_projection:{clean(status_key)}:{int(start_year or 0)}:{int(end_year or 0)}" +ERP_VOUCHER_EXISTENCE_REASON = "ERP_VOUCHER_NOT_ACTIVE_IN_LATEST_SNAPSHOT" + + +def _erp_voucher_existence_setting_key(fiscal_year: int) -> str: + return f"wehago_active_erp_voucher_existence:{int(fiscal_year or 0)}" + + +def _active_erp_voucher_existence_signature(conn: Any, start_year: int | None, end_year: int | None) -> str: + if start_year is None or end_year is None: + return "" + erp_start_year, erp_end_year = get_hanmac_erp_candidate_year_range(start_year, end_year) + rows = conn.execute( + text( + """ + SELECT setting_key, setting_json, updated_at + FROM wehago_compare_settings + WHERE setting_key LIKE 'wehago_active_erp_voucher_existence:%' + ORDER BY setting_key + """ + ) + ).mappings().all() + parts: list[str] = [] + for row in rows: + try: + year = int(clean(row.get("setting_key")).rsplit(":", 1)[-1] or 0) + except Exception: + continue + if year < int(erp_start_year or start_year) or year > int(erp_end_year or end_year): + continue + try: + payload = json.loads(clean(row.get("setting_json")) or "{}") + except Exception: + payload = {} + if not isinstance(payload, dict): + payload = {} + snapshot_id = int(payload.get("snapshot_id") or 0) + if snapshot_id <= 0: + continue + count_row = conn.execute( + text( + """ + SELECT COUNT(*) AS base_count + FROM wehago_erp_voucher_existence + WHERE snapshot_id = :snapshot_id + AND fiscal_year = :fiscal_year + AND is_active = 1 + """ + ), + {"snapshot_id": snapshot_id, "fiscal_year": year}, + ).mappings().first() + parts.append( + f"{year}:{snapshot_id}:{int((count_row or {}).get('base_count') or 0)}:{clean(row.get('updated_at'))}" + ) + return ",".join(parts) + + +def _load_active_erp_voucher_base_sets( + conn: Any, + start_year: int | None, + end_year: int | None, +) -> dict[int, set[str]]: + if start_year is None or end_year is None: + return {} + erp_start_year, erp_end_year = get_hanmac_erp_candidate_year_range(start_year, end_year) + result: dict[int, set[str]] = {} + for year in range(int(erp_start_year or start_year), int(erp_end_year or end_year) + 1): + row = conn.execute( + text( + """ + SELECT setting_json + FROM wehago_compare_settings + WHERE setting_key = :setting_key + LIMIT 1 + """ + ), + {"setting_key": _erp_voucher_existence_setting_key(year)}, + ).first() + if not row or not row[0]: + continue + try: + payload = json.loads(str(row[0] or "{}")) + except Exception: + payload = {} + if not isinstance(payload, dict): + continue + snapshot_id = int(payload.get("snapshot_id") or 0) + if snapshot_id <= 0: + continue + bases = { + clean(base_row.get("draft_base")) + for base_row in conn.execute( + text( + """ + SELECT draft_base + FROM wehago_erp_voucher_existence + WHERE snapshot_id = :snapshot_id + AND fiscal_year = :fiscal_year + AND is_active = 1 + AND COALESCE(draft_base, '') <> '' + """ + ), + {"snapshot_id": snapshot_id, "fiscal_year": year}, + ).mappings() + if clean(base_row.get("draft_base")) + } + result[year] = bases + return result + + +def _erp_draft_bases_from_group(group: dict[str, Any]) -> set[str]: + bases: set[str] = set() + summary = group.get("summary") or {} + for value in [summary.get("draft_no")]: + for part in re.split(r"\s*,\s*", clean(value)): + base = _erp_draft_base(part) + if base: + bases.add(base) + for row in list(group.get("rows") or []): + for part in re.split(r"\s*,\s*", clean(row.get("draft_no"))): + base = _erp_draft_base(part) + if base: + bases.add(base) + return bases + + +def _inactive_erp_bases_for_group( + group: dict[str, Any], + active_base_sets: dict[int, set[str]], +) -> set[str]: + inactive: set[str] = set() + for base in _erp_draft_bases_from_group(group): + year = _erp_draft_year(base) + if year <= 0 or year not in active_base_sets: + continue + if base not in active_base_sets.get(year, set()): + inactive.add(base) + return inactive + + +def _copy_group_as_wehago_unmatched_for_inactive_erp( + group: dict[str, Any], + inactive_bases: set[str], +) -> dict[str, Any] | None: + rows: list[dict[str, Any]] = [] + reason = f"{ERP_VOUCHER_EXISTENCE_REASON}:{','.join(sorted(inactive_bases))}" + for row in list(group.get("rows") or []): + if not ( + clean(row.get("ledger_account_name")) + or clean(row.get("ledger_desc")) + or abs(parse_amount(row.get("ledger_debit"))) >= 0.5 + or abs(parse_amount(row.get("ledger_credit"))) >= 0.5 + ): + continue + payload = _blank_voucher_side(dict(row)) + payload["status_label"] = "Unmatched" + existing_reason = clean(payload.get("review_reason")) + payload["review_reason"] = " / ".join(part for part in [existing_reason, reason] if part) + payload["matched_case"] = "" + payload["match_identity_key"] = clean(payload.get("ledger_row_key")) or build_ledger_row_key(payload) + rows.append(payload) + if not rows: + return None + summary = dict(group.get("summary") or {}) + fiscal_year = int(summary.get("fiscal_year") or rows[0].get("fiscal_year") or 0) + voucher_no = clean(summary.get("voucher_no") or rows[0].get("voucher_no")) + rebuilt = _build_group_summary_from_rows( + fiscal_year=fiscal_year, + voucher_no=voucher_no, + draft_no="", + rows=rows, + status_label="Unmatched", + review_reason=reason, + ) + return {"summary": rebuilt, "rows": rows} + + +def _apply_erp_voucher_existence_gate( + conn: Any, + start_year: int | None, + end_year: int | None, + voucher_sections: dict[str, list[dict[str, Any]]], +) -> dict[str, list[dict[str, Any]]]: + active_base_sets = _load_active_erp_voucher_base_sets(conn, start_year, end_year) + if not active_base_sets: + return voucher_sections + + demoted_unmatched: list[dict[str, Any]] = [] + for status_key in ("voucher_matched", "voucher_recheck"): + retained: list[dict[str, Any]] = [] + for group in list(voucher_sections.get(status_key) or []): + inactive_bases = _inactive_erp_bases_for_group(group, active_base_sets) + if not inactive_bases: + retained.append(group) + continue + demoted = _copy_group_as_wehago_unmatched_for_inactive_erp(group, inactive_bases) + if demoted is not None: + demoted_unmatched.append(demoted) + voucher_sections[status_key] = retained + + retained_unmatched: list[dict[str, Any]] = [] + for group in list(voucher_sections.get("voucher_unmatched") or []): + inactive_bases = _inactive_erp_bases_for_group(group, active_base_sets) + if not inactive_bases: + retained_unmatched.append(group) + continue + demoted = _copy_group_as_wehago_unmatched_for_inactive_erp(group, inactive_bases) + retained_unmatched.append(demoted or group) + voucher_sections["voucher_unmatched"] = retained_unmatched + demoted_unmatched + + for status_key in ("erp_voucher_matched", "erp_voucher_unmatched", "hanmac_unconnected"): + voucher_sections[status_key] = [ + group + for group in list(voucher_sections.get(status_key) or []) + if not _inactive_erp_bases_for_group(group, active_base_sets) + ] + + return voucher_sections + + +def activate_erp_voucher_existence_snapshot( + conn: Any, + fiscal_year: int, + *, + source_file_id: int | None = None, + source_label: str = "", + snapshot_mode: str = "full", +) -> dict[str, Any]: + year = int(fiscal_year or 0) + if year <= 0: + raise ValueError("fiscal_year is required for ERP voucher existence snapshot.") + where = ["v.fiscal_year = :fiscal_year", "COALESCE(v.draft_no, '') <> ''"] + params: dict[str, Any] = {"fiscal_year": year} + if source_file_id is not None: + where.append("v.source_file_id = :source_file_id") + params["source_file_id"] = int(source_file_id) + source_row = None + if source_file_id is not None: + source_row = conn.execute( + text( + """ + SELECT id, file_name, file_hash, source_origin + FROM wehago_source_files + WHERE id = :source_file_id + LIMIT 1 + """ + ), + {"source_file_id": int(source_file_id)}, + ).mappings().first() + rows = conn.execute( + text( + f""" + SELECT v.source_file_id, v.draft_no, v.row_number + FROM wehago_voucher_rows v + WHERE {' AND '.join(where)} + ORDER BY v.draft_no ASC, v.row_number ASC, v.id ASC + """ + ), + params, + ).mappings().all() + base_payloads: dict[str, dict[str, Any]] = {} + for row in rows: + base = _erp_draft_base(row.get("draft_no")) + if not base: + continue + current = base_payloads.setdefault( + base, + { + "fiscal_year": year, + "draft_base": base, + "is_active": 1, + "row_count": 0, + "first_draft_no": clean(row.get("draft_no")), + "last_draft_no": clean(row.get("draft_no")), + "first_seen_row_number": int(row.get("row_number") or 0), + "last_seen_row_number": int(row.get("row_number") or 0), + "source_file_id": int(row.get("source_file_id") or source_file_id or 0) or None, + }, + ) + current["row_count"] = int(current.get("row_count") or 0) + 1 + current["last_draft_no"] = clean(row.get("draft_no")) + current["last_seen_row_number"] = int(row.get("row_number") or 0) + snapshot_result = conn.execute( + text( + """ + INSERT INTO wehago_erp_voucher_existence_snapshots ( + fiscal_year, source_file_id, source_origin, source_label, source_hash, + snapshot_mode, row_count, draft_base_count, activated_at, created_at + ) VALUES ( + :fiscal_year, :source_file_id, :source_origin, :source_label, :source_hash, + :snapshot_mode, :row_count, :draft_base_count, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP + ) + """ + ), + { + "fiscal_year": year, + "source_file_id": int(source_file_id or 0) or None, + "source_origin": clean((source_row or {}).get("source_origin")), + "source_label": clean(source_label) or clean((source_row or {}).get("file_name")) or f"erp-voucher-{year}", + "source_hash": clean((source_row or {}).get("file_hash")), + "snapshot_mode": clean(snapshot_mode) or "full", + "row_count": len(rows), + "draft_base_count": len(base_payloads), + }, + ) + snapshot_id = int(snapshot_result.lastrowid) + if base_payloads: + conn.execute( + text( + """ + INSERT INTO wehago_erp_voucher_existence ( + snapshot_id, fiscal_year, draft_base, is_active, row_count, + first_draft_no, last_draft_no, first_seen_row_number, + last_seen_row_number, source_file_id, created_at + ) VALUES ( + :snapshot_id, :fiscal_year, :draft_base, :is_active, :row_count, + :first_draft_no, :last_draft_no, :first_seen_row_number, + :last_seen_row_number, :source_file_id, CURRENT_TIMESTAMP + ) + """ + ), + [{**payload, "snapshot_id": snapshot_id} for payload in base_payloads.values()], + ) + setting_payload = { + "snapshot_id": snapshot_id, + "fiscal_year": year, + "source_file_id": int(source_file_id or 0) or None, + "source_label": clean(source_label) or clean((source_row or {}).get("file_name")), + "snapshot_mode": clean(snapshot_mode) or "full", + "row_count": len(rows), + "draft_base_count": len(base_payloads), + } + conn.execute( + text( + """ + INSERT INTO wehago_compare_settings (setting_key, setting_json, updated_at) + VALUES (:setting_key, :setting_json, CURRENT_TIMESTAMP) + ON CONFLICT(setting_key) DO UPDATE SET + setting_json = excluded.setting_json, + updated_at = CURRENT_TIMESTAMP + """ + ), + { + "setting_key": _erp_voucher_existence_setting_key(year), + "setting_json": json.dumps(setting_payload, ensure_ascii=False), + }, + ) + return setting_payload + + def _hanmac_unconnected_status_projection_signature(conn: Any, start_year: int | None, end_year: int | None) -> str: if start_year is None or end_year is None: return "" @@ -850,6 +1309,7 @@ def _export_cache_status_projection_signature( return "|".join( [ EXPORT_COMPACT_STATUS_PROJECTION_VERSION, + QUERY_PROJECTION_VERSION, normalized_status, str(int(start_year)), str(int(end_year)), @@ -913,6 +1373,7 @@ def _expected_status_projection_signatures_for_range( expected[status_key] = "|".join( [ EXPORT_COMPACT_STATUS_PROJECTION_VERSION, + QUERY_PROJECTION_VERSION, status_key, str(start), str(end), @@ -1076,6 +1537,74 @@ def _active_status_projection_signature( return signature if exists else "" +def _active_status_projection_export_token( + conn: Any, + status_key: str, + start_year: int | None, + end_year: int | None, +) -> str: + signature = _active_status_projection_signature(conn, status_key, start_year, end_year) + if not signature or start_year is None or end_year is None: + return "" + setting_updated_at = "" + setting_total_count = 0 + try: + row = conn.execute( + text( + """ + SELECT setting_json, updated_at + FROM wehago_compare_settings + WHERE setting_key = :setting_key + LIMIT 1 + """ + ), + {"setting_key": _status_projection_setting_key(status_key, start_year, end_year)}, + ).mappings().first() + setting_updated_at = clean((row or {}).get("updated_at")) + try: + payload = json.loads(clean((row or {}).get("setting_json")) or "{}") + except Exception: + payload = {} + if isinstance(payload, dict): + setting_total_count = int(payload.get("total_count") or 0) + except Exception: + setting_updated_at = "" + setting_total_count = 0 + try: + group_row = conn.execute( + text( + """ + SELECT COUNT(*) AS group_count, COALESCE(MAX(updated_at), '') AS max_updated_at + FROM wehago_status_projection_groups + WHERE start_year = :start_year + AND end_year = :end_year + AND status_key = :status_key + AND signature = :signature + """ + ), + { + "start_year": int(start_year), + "end_year": int(end_year), + "status_key": clean(status_key), + "signature": signature, + }, + ).mappings().first() + group_count = int((group_row or {}).get("group_count") or 0) + group_updated_at = clean((group_row or {}).get("max_updated_at")) + except Exception: + group_count = 0 + group_updated_at = "" + return "|".join( + [ + signature, + f"setting_count:{setting_total_count}", + f"group_count:{group_count}", + f"setting_updated:{setting_updated_at}", + f"group_updated:{group_updated_at}", + ] + ) + + def _store_active_status_projection_signature( conn: Any, status_key: str, @@ -1112,6 +1641,626 @@ def _store_active_status_projection_signature( ) +def _status_projection_run_setting_key(start_year: int | None, end_year: int | None) -> str: + return f"wehago_active_status_projection_run:{int(start_year or 0)}:{int(end_year or 0)}" + + +def _load_cached_active_status_projection_run_summary( + conn: Any, + start_year: int | None, + end_year: int | None, +) -> dict[str, Any] | None: + if start_year is None or end_year is None: + return None + try: + row = conn.execute( + text( + """ + SELECT setting_json + FROM wehago_compare_settings + WHERE setting_key = :setting_key + LIMIT 1 + """ + ), + {"setting_key": _status_projection_run_setting_key(start_year, end_year)}, + ).first() + except Exception: + return None + if not row or not row[0]: + return None + try: + payload = json.loads(str(row[0] or "{}")) + except Exception: + return None + if not isinstance(payload, dict): + return None + if clean(payload.get("source")) != "active_status_projection_run": + return None + if int(payload.get("start_year") or 0) != int(start_year or 0): + return None + if int(payload.get("end_year") or 0) != int(end_year or 0): + return None + if clean(payload.get("logic_version")) != QUERY_PROJECTION_VERSION: + return None + counts = payload.get("counts") + if not isinstance(counts, dict): + return None + if int(payload.get("classified_total") or 0) != int(payload.get("raw_total") or 0): + return None + if not _active_status_projection_run_matches_current_sources(conn, payload): + return None + return payload + + +def _active_status_projection_run_matches_current_sources( + conn: Any, + payload: dict[str, Any], +) -> bool: + start_year = int(payload.get("start_year") or 0) + end_year = int(payload.get("end_year") or 0) + active_signatures = payload.get("active_signatures") + if start_year <= 0 or end_year < start_year or not isinstance(active_signatures, dict): + return False + if set(active_signatures) != set(WEHAGO_FINAL_STATUS_ORDER): + return False + expected_signatures = _expected_status_projection_signatures_for_range(conn, start_year, end_year) + if any( + clean(active_signatures.get(status_key)) != clean(expected_signatures.get(status_key)) + for status_key in WEHAGO_FINAL_STATUS_ORDER + ): + return False + + for year in range(start_year, end_year + 1): + current_signature = _build_db_state_signature(conn, year, year) + status_row = conn.execute( + text( + """ + SELECT snapshot_signature, state + FROM wehago_snapshot_status + WHERE fiscal_year = :fiscal_year + LIMIT 1 + """ + ), + {"fiscal_year": year}, + ).mappings().first() + stored_signature = clean((status_row or {}).get("snapshot_signature")) + if clean((status_row or {}).get("state")).lower() != "ready": + return False + if not stored_signature or not ( + stored_signature == current_signature + or _snapshot_signature_equivalent(stored_signature, current_signature) + ): + return False + return True + + +def _projection_voucher_compare_key_from_parts( + fiscal_year: int, + ledger_date: Any, + proof_date: Any, + voucher_no: Any, +) -> str: + first_voucher_no = clean(clean(voucher_no).split(",", 1)[0]) + normalized_date = _normalize_wehago_projection_date(ledger_date or proof_date, fiscal_year or None) + if not normalized_date or not first_voucher_no: + return "" + return f"{normalized_date.replace('-', '')}-{first_voucher_no}" + + +_WEHAGO_STRONG_ACCOUNTING_EXCEPTION_TOKENS = ( + "결산분개", + "결산대체전표", + "결산대체분개", + "대체전표", + "대체분개", + "상계전표", + "상계분개", + "계상전표", + "계상분개", + "계상분대체", + "취소전표", +) +_WEHAGO_STRONG_ACCOUNTING_EXCEPTION_VENDOR_TOKENS = ( + "결산분개", + "결산환원분개", + "결산대체", + "대체전표", + "상계전표", + "계상전표", +) + + +def _is_strong_wehago_accounting_exception_text(vendor_text: Any, description_text: Any) -> bool: + compact_vendor_text = clean(vendor_text).replace(" ", "") + compact_description_text = re.sub( + r"상계\s*\d+(?:-\d+)?\s*소구(?:역)?", + "", + clean(description_text), + ).replace(" ", "") + return any( + token in compact_description_text + for token in _WEHAGO_STRONG_ACCOUNTING_EXCEPTION_TOKENS + ) or any( + token in compact_vendor_text + for token in _WEHAGO_STRONG_ACCOUNTING_EXCEPTION_VENDOR_TOKENS + ) + + +def _load_wehago_strong_accounting_exception_compare_keys( + conn: Any, + start_year: int | None, + end_year: int | None, +) -> set[str]: + if start_year is None or end_year is None: + return set() + rows = conn.execute( + text( + """ + SELECT compare_voucher_no, vendor_name, description + FROM wehago_ledger_rows + WHERE fiscal_year BETWEEN :start_year AND :end_year + AND COALESCE(compare_voucher_no, '') <> '' + AND ( + COALESCE(vendor_name, '') LIKE '%결산%' + OR COALESCE(vendor_name, '') LIKE '%대체전표%' + OR COALESCE(vendor_name, '') LIKE '%상계전표%' + OR COALESCE(vendor_name, '') LIKE '%계상전표%' + OR COALESCE(description, '') LIKE '%결산%' + OR COALESCE(description, '') LIKE '%대체%' + OR COALESCE(description, '') LIKE '%상계%' + OR COALESCE(description, '') LIKE '%계상%' + OR COALESCE(description, '') LIKE '%취소전표%' + ) + """ + ), + {"start_year": int(start_year), "end_year": int(end_year)}, + ).mappings() + keys: set[str] = set() + for row in rows: + compare_no = clean(row.get("compare_voucher_no")) + if compare_no and _is_strong_wehago_accounting_exception_text(row.get("vendor_name"), row.get("description")): + keys.add(compare_no) + return keys + + +def _load_active_status_projection_run_summary( + conn: Any, + start_year: int | None, + end_year: int | None, + *, + allow_rebuild: bool = True, +) -> dict[str, Any] | None: + if start_year is None or end_year is None: + return None + active_signatures = _active_status_projection_signatures_for_range( + conn, + start_year, + end_year, + WEHAGO_FINAL_STATUS_ORDER, + ) + if set(active_signatures) != set(WEHAGO_FINAL_STATUS_ORDER): + return None + + stats: dict[str, dict[str, Any]] = {} + run_parts = [ + "active-status-projection-run-v1", + QUERY_PROJECTION_VERSION, + str(int(start_year)), + str(int(end_year)), + ] + for status_key in WEHAGO_FINAL_STATUS_ORDER: + signature = active_signatures[status_key] + row = conn.execute( + text( + """ + SELECT COUNT(*) AS group_count, COALESCE(MAX(updated_at), '') AS max_updated_at + FROM wehago_status_projection_groups + WHERE start_year = :start_year + AND end_year = :end_year + AND status_key = :status_key + AND signature = :signature + """ + ), + { + "start_year": int(start_year), + "end_year": int(end_year), + "status_key": status_key, + "signature": signature, + }, + ).mappings().first() + group_count = int((row or {}).get("group_count") or 0) + max_updated_at = clean((row or {}).get("max_updated_at")) + stats[status_key] = { + "signature": signature, + "group_count": group_count, + "max_updated_at": max_updated_at, + } + run_parts.extend([status_key, signature, str(group_count), max_updated_at]) + run_id = hashlib.sha256("|".join(run_parts).encode("utf-8")).hexdigest() + + setting_key = _status_projection_run_setting_key(start_year, end_year) + try: + cached_row = conn.execute( + text( + """ + SELECT setting_json + FROM wehago_compare_settings + WHERE setting_key = :setting_key + LIMIT 1 + """ + ), + {"setting_key": setting_key}, + ).first() + if cached_row and cached_row[0]: + cached_payload = json.loads(str(cached_row[0] or "{}")) + if isinstance(cached_payload, dict) and clean(cached_payload.get("run_id")) == run_id: + return cached_payload + except Exception: + pass + if not allow_rebuild: + return None + + raw_keys = { + clean(row.get("compare_voucher_no")) + for row in conn.execute( + text( + """ + SELECT compare_voucher_no + FROM wehago_ledger_rows + WHERE fiscal_year BETWEEN :start_year AND :end_year + AND COALESCE(compare_voucher_no, '') <> '' + GROUP BY compare_voucher_no + """ + ), + {"start_year": int(start_year), "end_year": int(end_year)}, + ).mappings().all() + if clean(row.get("compare_voucher_no")) + } + raw_total = len(raw_keys) + if raw_total <= 0: + return None + + final_by_key: dict[str, str] = {} + group_total = 0 + blank_key_count = 0 + outside_raw_count = 0 + key_seen_count: dict[str, int] = {} + key_statuses: dict[str, set[str]] = {} + source_group_counts = {status_key: 0 for status_key in WEHAGO_FINAL_STATUS_ORDER} + + for status_key in WEHAGO_FINAL_STATUS_ORDER: + signature = active_signatures[status_key] + rows = conn.execute( + text( + """ + SELECT + fiscal_year, + ledger_date, + proof_date, + voucher_no, + ledger_debit, + ledger_credit, + voucher_debit, + voucher_credit, + ledger_accounts, + voucher_accounts, + rows_json + FROM wehago_status_projection_groups + WHERE start_year = :start_year + AND end_year = :end_year + AND status_key = :status_key + AND signature = :signature + """ + ), + { + "start_year": int(start_year), + "end_year": int(end_year), + "status_key": status_key, + "signature": signature, + }, + ).mappings() + for row in rows: + group_total += 1 + effective_status_key = status_key + if status_key == "voucher_matched": + try: + detail_rows = json.loads(clean(row.get("rows_json")) or "[]") + except Exception: + detail_rows = [] + if not isinstance(detail_rows, list): + detail_rows = [] + if _matched_group_should_be_recheck_by_direction(row, detail_rows): + effective_status_key = "voucher_recheck" + source_group_counts[effective_status_key] += 1 + compare_key = _projection_voucher_compare_key_from_parts( + int(row.get("fiscal_year") or 0), + row.get("ledger_date"), + row.get("proof_date"), + row.get("voucher_no"), + ) + if not compare_key: + blank_key_count += 1 + continue + if compare_key not in raw_keys: + outside_raw_count += 1 + continue + key_seen_count[compare_key] = int(key_seen_count.get(compare_key, 0) or 0) + 1 + key_statuses.setdefault(compare_key, set()).add(effective_status_key) + current_status = final_by_key.get(compare_key) + if current_status is None or WEHAGO_FINAL_STATUS_PRIORITY[effective_status_key] < WEHAGO_FINAL_STATUS_PRIORITY[current_status]: + final_by_key[compare_key] = effective_status_key + + for compare_key in _load_wehago_strong_accounting_exception_compare_keys(conn, start_year, end_year): + if compare_key in raw_keys: + final_by_key[compare_key] = "voucher_excepted" + + missing_raw_count = len(raw_keys - set(final_by_key)) + final_counts = {status_key: 0 for status_key in WEHAGO_FINAL_STATUS_ORDER} + for status_key in final_by_key.values(): + final_counts[status_key] += 1 + final_counts["voucher_unmatched"] += missing_raw_count + classified_total = sum(final_counts.values()) + duplicate_group_count = sum(max(0, count - 1) for count in key_seen_count.values()) + multi_appearance_voucher_count = sum(1 for count in key_seen_count.values() if count > 1) + multi_status_voucher_count = sum(1 for statuses in key_statuses.values() if len(statuses) > 1) + same_status_duplicate_voucher_count = sum( + 1 + for key, count in key_seen_count.items() + if count > 1 and len(key_statuses.get(key) or set()) == 1 + ) + payload = { + "run_id": run_id, + "start_year": int(start_year), + "end_year": int(end_year), + "logic_version": QUERY_PROJECTION_VERSION, + "raw_total": raw_total, + "classified_total": classified_total, + "difference": classified_total - raw_total, + "group_total": group_total, + "group_difference": group_total - raw_total, + "source_group_total": group_total, + "source_group_difference": group_total - raw_total, + "projection_outside_raw_total": outside_raw_count, + "blank_key_count": blank_key_count, + "missing_raw_count": missing_raw_count, + "duplicate_group_count": duplicate_group_count, + "multi_appearance_voucher_count": multi_appearance_voucher_count, + "multi_status_voucher_count": multi_status_voucher_count, + "same_status_duplicate_voucher_count": same_status_duplicate_voucher_count, + "source_group_counts": source_group_counts, + "counts": final_counts, + "items": [ + { + "key": key, + "label": { + "voucher_matched": "Voucher", + "voucher_recheck": "Recheck", + "voucher_unmatched": "Unmatched", + "voucher_excepted": "Excepted", + }[key], + "count": int(final_counts.get(key, 0) or 0), + "group_count": int(source_group_counts.get(key, 0) or 0), + } + for key in WEHAGO_FINAL_STATUS_ORDER + ], + "active_signatures": active_signatures, + "run_stats": stats, + "source": "active_status_projection_run", + "ready": classified_total == raw_total, + } + try: + conn.execute( + text( + """ + INSERT INTO wehago_compare_settings (setting_key, setting_json, updated_at) + VALUES (:setting_key, :setting_json, CURRENT_TIMESTAMP) + ON CONFLICT(setting_key) DO UPDATE SET + setting_json = excluded.setting_json, + updated_at = CURRENT_TIMESTAMP + """ + ), + { + "setting_key": setting_key, + "setting_json": json.dumps(payload, ensure_ascii=False, separators=(",", ":")), + }, + ) + except Exception: + pass + return payload + + +def _ensure_active_status_run_final_projection( + conn: Any, + start_year: int | None, + end_year: int | None, + payload: dict[str, Any] | None, +) -> bool: + if start_year is None or end_year is None or not isinstance(payload, dict): + return False + run_id = clean(payload.get("run_id")) + active_signatures = payload.get("active_signatures") + raw_total = int(payload.get("raw_total") or 0) + if not run_id or raw_total <= 0 or not isinstance(active_signatures, dict): + return False + if set(active_signatures) != set(WEHAGO_FINAL_STATUS_ORDER): + return False + try: + existing_row = conn.execute( + text( + """ + SELECT + COUNT(*) AS row_count, + SUM(CASE WHEN COALESCE(display_signature, '') <> '' THEN 1 ELSE 0 END) AS display_count + FROM wehago_compare_final_status_projection + WHERE start_year = :start_year + AND end_year = :end_year + AND signature = :signature + """ + ), + {"start_year": int(start_year), "end_year": int(end_year), "signature": run_id}, + ).mappings().first() + existing_count = int((existing_row or {}).get("row_count") or 0) + existing_display_count = int((existing_row or {}).get("display_count") or 0) + if existing_count == raw_total and existing_display_count > 0: + return True + + raw_rows = conn.execute( + text( + """ + SELECT + fiscal_year, + compare_voucher_no, + MIN(ledger_date) AS ledger_date, + MIN(voucher_no) AS voucher_no + FROM wehago_ledger_rows + WHERE fiscal_year BETWEEN :start_year AND :end_year + AND COALESCE(compare_voucher_no, '') <> '' + GROUP BY fiscal_year, compare_voucher_no + """ + ), + {"start_year": int(start_year), "end_year": int(end_year)}, + ).mappings().all() + raw_by_key = { + clean(row.get("compare_voucher_no")): row + for row in raw_rows + if clean(row.get("compare_voucher_no")) + } + if not raw_by_key: + return False + + final_by_key: dict[str, str] = {} + source_statuses_by_key: dict[str, set[str]] = {} + source_group_count_by_key: dict[str, int] = {} + display_by_key: dict[str, dict[str, Any]] = {} + for status_key in WEHAGO_FINAL_STATUS_ORDER: + status_rank = int(WEHAGO_FINAL_STATUS_PRIORITY[status_key]) + signature = clean(active_signatures.get(status_key)) + rows = conn.execute( + text( + """ + SELECT + fiscal_year, ledger_date, proof_date, voucher_no, + group_index, ledger_row_count, voucher_row_count, + ledger_debit, ledger_credit, voucher_debit, voucher_credit, + ledger_accounts, voucher_accounts, rows_json + FROM wehago_status_projection_groups + WHERE start_year = :start_year + AND end_year = :end_year + AND status_key = :status_key + AND signature = :signature + """ + ), + { + "start_year": int(start_year), + "end_year": int(end_year), + "status_key": status_key, + "signature": signature, + }, + ).mappings() + for row in rows: + effective_status_key = status_key + effective_status_rank = status_rank + if status_key == "voucher_matched": + try: + detail_rows = json.loads(clean(row.get("rows_json")) or "[]") + except Exception: + detail_rows = [] + if not isinstance(detail_rows, list): + detail_rows = [] + if _matched_group_should_be_recheck_by_direction(row, detail_rows): + effective_status_key = "voucher_recheck" + effective_status_rank = int(WEHAGO_FINAL_STATUS_PRIORITY[effective_status_key]) + compare_key = _projection_voucher_compare_key_from_parts( + int(row.get("fiscal_year") or 0), + row.get("ledger_date"), + row.get("proof_date"), + row.get("voucher_no"), + ) + if not compare_key or compare_key not in raw_by_key: + continue + source_statuses_by_key.setdefault(compare_key, set()).add(effective_status_key) + source_group_count_by_key[compare_key] = int(source_group_count_by_key.get(compare_key, 0) or 0) + 1 + current_status = final_by_key.get(compare_key) + if current_status is None or effective_status_rank < WEHAGO_FINAL_STATUS_PRIORITY[current_status]: + final_by_key[compare_key] = effective_status_key + current_display = display_by_key.get(compare_key) + current_rank = int((current_display or {}).get("rank") or 99) + current_group_index = int((current_display or {}).get("group_index") or 10**9) + group_index = int(row.get("group_index") or 0) + if current_display is None or effective_status_rank < current_rank or ( + effective_status_rank == current_rank and group_index < current_group_index + ): + display_by_key[compare_key] = { + "status_key": status_key, + "signature": signature, + "group_index": group_index, + "rank": effective_status_rank, + } + + for compare_key in _load_wehago_strong_accounting_exception_compare_keys(conn, start_year, end_year): + if compare_key in raw_by_key: + final_by_key[compare_key] = "voucher_excepted" + source_statuses_by_key.setdefault(compare_key, set()).add("voucher_excepted") + + conn.execute( + text( + """ + DELETE FROM wehago_compare_final_status_projection + WHERE start_year = :start_year + AND end_year = :end_year + AND signature = :signature + """ + ), + {"start_year": int(start_year), "end_year": int(end_year), "signature": run_id}, + ) + insert_sql = text( + """ + 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, + display_status_key, display_signature, display_group_index, + created_at, updated_at + ) VALUES ( + :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, + :display_status_key, :display_signature, :display_group_index, + CURRENT_TIMESTAMP, CURRENT_TIMESTAMP + ) + """ + ) + for compare_key, row in raw_by_key.items(): + final_status = clean(final_by_key.get(compare_key)) or "voucher_unmatched" + source_statuses = sorted(source_statuses_by_key.get(compare_key) or set()) + display = display_by_key.get(compare_key) or {} + raw_display_group_index = display.get("group_index") + conn.execute( + insert_sql, + { + "start_year": int(start_year), + "end_year": int(end_year), + "signature": run_id, + "identity_key": compare_key, + "compare_voucher_no": compare_key, + "fiscal_year": int(row.get("fiscal_year") or 0), + "ledger_date": clean(row.get("ledger_date")), + "voucher_no": clean(row.get("voucher_no")), + "final_status": final_status, + "final_rank": int(WEHAGO_FINAL_STATUS_PRIORITY.get(final_status, 99)), + "source_statuses": ",".join(source_statuses), + "source_group_count": int(source_group_count_by_key.get(compare_key, 0) or 0), + "display_status_key": clean(display.get("status_key")), + "display_signature": clean(display.get("signature")), + "display_group_index": int(raw_display_group_index) if raw_display_group_index is not None else -1, + }, + ) + return True + except Exception as exc: + logger.warning("Failed to materialize active final status projection for %s-%s: %s", start_year, end_year, exc) + return False + + def _load_query_metric_projection( conn: Any, start_year: int | None, @@ -1192,6 +2341,12 @@ def _find_best_query_projection_scope( """ row = conn.execute(text(sql), params).first() if not row: + if ( + table_name == "wehago_compare_query_groups" + and status_key in QUERY_VOUCHER_STATUS_KEYS + and not _allow_stale_export_detail_fallback() + ): + return None fallback_sql = f""" SELECT start_year, end_year, signature, MAX(updated_at) AS max_updated_at FROM {table_name} @@ -1381,7 +2536,25 @@ def _fast_sqlite_query_group_page( except Exception: return None try: - current_signature_prefix = f"{QUERY_PROJECTION_VERSION}|%" + active_signature = "" + 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]: + try: + active_payload = json.loads(str(active_row[0] or "{}")) + if isinstance(active_payload, dict): + candidate_signature = clean(active_payload.get("signature")) + if candidate_signature.startswith(f"{QUERY_PROJECTION_VERSION}|"): + active_signature = candidate_signature + except Exception: + active_signature = "" scope_row = conn.execute( """ SELECT @@ -1403,7 +2576,31 @@ def _fast_sqlite_query_group_page( normalized_status, active_signature, ), - ).fetchone() + ).fetchone() if active_signature else None + if not scope_row: + scope_row = conn.execute( + """ + SELECT + start_year, + end_year, + signature, + MAX(updated_at) AS max_updated_at + FROM wehago_compare_query_groups + WHERE start_year = ? + AND end_year = ? + AND status_key = ? + AND signature LIKE ? + GROUP BY start_year, end_year, signature + ORDER BY max_updated_at DESC + LIMIT 1 + """, + ( + int(start_year), + int(end_year), + normalized_status, + f"{QUERY_PROJECTION_VERSION}|%", + ), + ).fetchone() if not scope_row: return None projection_start_year = int(scope_row["start_year"] or 0) @@ -1614,6 +2811,31 @@ def _store_query_metric_projection( "source_state_json": json.dumps(payload_source, ensure_ascii=False), }, ) + conn.execute( + text( + """ + INSERT INTO wehago_compare_settings (setting_key, setting_json, updated_at) + VALUES (:setting_key, :setting_json, CURRENT_TIMESTAMP) + ON CONFLICT(setting_key) DO UPDATE SET + setting_json = excluded.setting_json, + updated_at = CURRENT_TIMESTAMP + """ + ), + { + "setting_key": f"wehago_active_query_projection:{int(start_year)}:{int(end_year)}", + "setting_json": json.dumps( + { + "signature": signature, + "logic_version": QUERY_PROJECTION_VERSION, + "start_year": int(start_year), + "end_year": int(end_year), + "source_state": payload_source, + }, + ensure_ascii=False, + separators=(",", ":"), + ), + }, + ) except OperationalError as exc: logger.warning("Skipped query metric projection write for %s-%s due to lock: %s", start_year, end_year, exc) @@ -1806,6 +3028,166 @@ WEHAGO_FINAL_STATUS_PRIORITY = { "voucher_unmatched": 4, } +MATCH_DIRECTION_GUARD_REASON = "MATCH_DIRECTION_RECHECK_MAJOR_ACCOUNT_SIGN_CONFLICT" + + +def _enable_active_status_projection_run() -> bool: + return os.getenv("WEHAGO_ENABLE_ACTIVE_STATUS_RUN", "").strip().lower() in {"1", "true", "yes", "on"} + + +def _is_direction_guard_major_account_text(*values: Any) -> bool: + text_value = normalize_text(" ".join(clean(value) for value in values)) + if not text_value: + return False + major_tokens = ( + "수입", + "매출", + "용역", + "원가", + "비용", + "수수료", + "관리비", + "외주", + "복리", + "접대", + "세금", + "공과", + "보험", + "차량", + "소모품", + "부가세", + "매입세액", + "매출세액", + "지급", + "급여", + "퇴직", + ) + return any(token in text_value for token in major_tokens) + + +def _signed_row_amount(row: Mapping[str, Any], prefix: str) -> float: + if prefix == "ledger": + return parse_amount(row.get("ledger_debit")) - parse_amount(row.get("ledger_credit")) + return parse_amount(row.get("voucher_debit")) - parse_amount(row.get("voucher_credit")) + + +def _matched_group_should_be_recheck_by_direction(group: Mapping[str, Any], rows: list[dict[str, Any]] | None = None) -> bool: + if not _is_direction_guard_major_account_text(group.get("ledger_accounts"), group.get("voucher_accounts")): + return False + ledger_debit = parse_amount(group.get("ledger_debit")) + ledger_credit = parse_amount(group.get("ledger_credit")) + voucher_debit = parse_amount(group.get("voucher_debit")) + voucher_credit = parse_amount(group.get("voucher_credit")) + ledger_values = (ledger_debit, ledger_credit) + voucher_values = (voucher_debit, voucher_credit) + if any(value < -1 for value in ledger_values) and any(value > 1 for value in voucher_values): + return True + if any(value < -1 for value in voucher_values) and any(value > 1 for value in ledger_values): + return True + + paired_rows = [ + row + for row in list(rows or []) + if (parse_amount(row.get("ledger_debit")) or parse_amount(row.get("ledger_credit"))) + and (parse_amount(row.get("voucher_debit")) or parse_amount(row.get("voucher_credit"))) + ] + for row in paired_rows: + if not _is_direction_guard_major_account_text(row.get("ledger_account_name"), row.get("voucher_account_name")): + continue + ledger_signed = _signed_row_amount(row, "ledger") + voucher_signed = _signed_row_amount(row, "voucher") + if abs(abs(ledger_signed) - abs(voucher_signed)) <= 1 and ledger_signed * voucher_signed < 0: + return True + reason_text = normalize_text( + " ".join( + [ + clean(group.get("review_reason")), + *(clean(row.get("review_reason")) for row in list(rows or [])), + ] + ) + ) + if "directcostbusinessaccountmatch" not in reason_text and "groupedcost" not in reason_text: + return False + direct_rows = [ + row + for row in list(rows or []) + if clean(row.get("ledger_account_name")) + and clean(row.get("voucher_account_name")) + and (parse_amount(row.get("ledger_debit")) or parse_amount(row.get("ledger_credit"))) + and (parse_amount(row.get("voucher_debit")) or parse_amount(row.get("voucher_credit"))) + ] + erp_only_rows = [ + row + for row in list(rows or []) + if clean(row.get("voucher_account_name")) + and not clean(row.get("ledger_account_name")) + and (parse_amount(row.get("voucher_debit")) or parse_amount(row.get("voucher_credit"))) + ] + if not direct_rows or not erp_only_rows: + return False + strong_tokens = ("부가세", "매입세액", "매출세액", "통신비", "전력비", "수도광열", "임차료", "관리비", "소모품", "수수료", "외상매입", "외상매출", "용역") + weak_tokens = ("잡이익", "잡손실", "전도금", "미지급금", "보통예금", "현금") + has_strong_direct = False + for row in direct_rows: + account_text = normalize_text(" ".join([clean(row.get("ledger_account_name")), clean(row.get("voucher_account_name"))])) + if any(token in account_text for token in strong_tokens) and not any(token in account_text for token in weak_tokens): + has_strong_direct = True + break + if has_strong_direct: + return False + month_gaps = [] + for row in list(rows or []): + ledger_date = _parse_row_date_with_year(dict(row), "ledger_date") + voucher_date = _parse_iso_date(row.get("proof_date")) or _infer_compare_row_proof_date(dict(row)) + if ledger_date and voucher_date: + month_gaps.append(_month_gap(ledger_date, voucher_date)) + return bool(month_gaps and max(month_gaps) >= 1) + return False + +ERP_REPEATED_CONTEXT_MATCH_TOKENS = ( + "ERP_GROUP_REPEATED_CONTEXT_MATCH", + "SPLIT_ERP_DRAFT_CONTEXT_MATCH", + "SPLIT_DRAFT", + "PROJECTION_RECONCILE_SPLIT_DRAFT", + "GROUPED_COST", +) + + +def _is_erp_repeated_context_match(row: Mapping[str, Any] | None) -> bool: + # ERP source account rows must remain one-to-one in every projection path. + return False + + +def _final_status_summary_diagnostics( + summary: Mapping[str, Any] | None, + *, + exception_counts: Mapping[str, int] | None = None, +) -> dict[str, Any]: + payload = dict(summary or {}) + counts = payload.get("counts") + if not isinstance(counts, dict): + counts = {} + raw_total = int(payload.get("raw_total") or 0) + classified_total = int(payload.get("classified_total") or sum(int(counts.get(key, 0) or 0) for key in WEHAGO_FINAL_STATUS_ORDER)) + diagnostics = { + "wehago_voucher_total": raw_total, + "wehago_status_total": classified_total, + "wehago_status_difference": classified_total - raw_total, + "wehago_status_invariant_ok": classified_total == raw_total, + "projection_outside_raw_total": int(payload.get("projection_outside_raw_total") or 0), + "blank_key_count": int(payload.get("blank_key_count") or 0), + "missing_raw_count": int(payload.get("missing_raw_count") or 0), + "duplicate_group_count": int(payload.get("duplicate_group_count") or 0), + "multi_status_voucher_count": int(payload.get("multi_status_voucher_count") or 0), + "same_status_duplicate_voucher_count": int(payload.get("same_status_duplicate_voucher_count") or 0), + "nondeductible_vat_gross_match_count": int((exception_counts or {}).get("nondeductible_vat_gross", 0) or 0), + "erp_repeated_context_match_count": int((exception_counts or {}).get("erp_repeated_context", 0) or 0), + "payment_only_recheck_count": int((exception_counts or {}).get("payment_only_recheck", 0) or 0), + } + payload["diagnostics"] = diagnostics + payload["ready"] = bool(payload.get("ready")) or diagnostics["wehago_status_invariant_ok"] + return payload + def _normalize_wehago_projection_date(value: Any, fiscal_year: int | None = None) -> str: normalized = clean(value) @@ -1878,6 +3260,9 @@ def _store_wehago_final_status_projection( "final_rank": status_rank, "source_statuses_set": source_statuses, "source_group_count": source_group_count, + "display_status_key": status_key, + "display_signature": signature, + "display_group_index": group_index, } else: current["source_statuses_set"] = source_statuses @@ -1906,11 +3291,15 @@ def _store_wehago_final_status_projection( 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 + source_statuses, source_group_count, + display_status_key, display_signature, display_group_index, + created_at, updated_at ) VALUES ( :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, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP + :source_statuses, :source_group_count, + :display_status_key, :display_signature, :display_group_index, + CURRENT_TIMESTAMP, CURRENT_TIMESTAMP ) """ ), @@ -1927,6 +3316,9 @@ def _store_wehago_final_status_projection( "final_rank": int(row.get("final_rank") or 99), "source_statuses": ",".join(source_statuses), "source_group_count": int(row.get("source_group_count") or 0), + "display_status_key": clean(row.get("display_status_key")), + "display_signature": clean(row.get("display_signature")), + "display_group_index": int(row.get("display_group_index") if row.get("display_group_index") is not None else -1), }, ) except OperationalError as exc: @@ -1969,7 +3361,9 @@ def backfill_wehago_final_status_projection_from_query_groups( 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 + source_statuses, source_group_count, + display_status_key, display_signature, display_group_index, + created_at, updated_at ) WITH source_groups AS ( SELECT @@ -1977,7 +3371,8 @@ def backfill_wehago_final_status_projection_from_query_groups( 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 + COALESCE(NULLIF(TRIM(voucher_no), ''), CAST(group_index AS TEXT)) AS raw_voucher_no, + group_index FROM wehago_compare_query_groups WHERE start_year = :start_year AND end_year = :end_year @@ -1989,6 +3384,7 @@ def backfill_wehago_final_status_projection_from_query_groups( fiscal_year, status_key, status_rank, + group_index, 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) @@ -2007,6 +3403,7 @@ def backfill_wehago_final_status_projection_from_query_groups( SELECT status_key, status_rank, + group_index, fiscal_year, ledger_date, voucher_no, @@ -2018,6 +3415,18 @@ def backfill_wehago_final_status_projection_from_query_groups( FROM normalized WHERE COALESCE(voucher_no, '') <> '' ), + ranked_display AS ( + SELECT + identity_key, + status_key, + group_index, + ROW_NUMBER() OVER ( + PARTITION BY identity_key + ORDER BY status_rank ASC, group_index ASC + ) AS display_rank + FROM classified + WHERE COALESCE(identity_key, '') <> '' + ), resolved AS ( SELECT identity_key, @@ -2045,9 +3454,15 @@ def backfill_wehago_final_status_projection_from_query_groups( final_rank, COALESCE(source_statuses, ''), source_group_count, + COALESCE(d.status_key, ''), + :signature, + COALESCE(d.group_index, -1), CURRENT_TIMESTAMP, CURRENT_TIMESTAMP FROM resolved + LEFT JOIN ranked_display d + ON d.identity_key = resolved.identity_key + AND d.display_rank = 1 WHERE final_rank < 99 """ ), @@ -2079,6 +3494,42 @@ def _load_latest_query_projection_signature_for_range( conn: Any, start_year: int | None, end_year: int | None, +) -> str: + canonical_signature = _load_canonical_query_projection_signature_for_range(conn, start_year, end_year) + if canonical_signature: + return canonical_signature + if start_year is None or end_year is None: + return "" + row = conn.execute( + text( + """ + SELECT signature + FROM wehago_compare_final_status_projection + WHERE start_year = :start_year + AND end_year = :end_year + AND signature LIKE :signature_like + AND signature NOT LIKE '%|snapshot-recheck-promote|%' + AND signature NOT LIKE '%|db-reconciled-%' + GROUP BY signature + ORDER BY COUNT(*) DESC, MAX(updated_at) DESC + LIMIT 1 + """ + ), + { + "start_year": start_year, + "end_year": end_year, + "signature_like": f"{QUERY_PROJECTION_VERSION}|%", + }, + ).first() + if row and row[0]: + return clean(row[0]) + return "" + + +def _load_canonical_query_projection_signature_for_range( + conn: Any, + start_year: int | None, + end_year: int | None, ) -> str: if start_year is None or end_year is None: return "" @@ -2105,12 +3556,14 @@ def _load_latest_query_projection_signature_for_range( text( """ SELECT signature - FROM wehago_compare_final_status_projection + FROM wehago_compare_query_groups WHERE start_year = :start_year AND end_year = :end_year AND signature LIKE :signature_like + AND signature NOT LIKE '%|snapshot-recheck-promote|%' + AND signature NOT LIKE '%|db-reconciled-%' GROUP BY signature - ORDER BY COUNT(*) DESC, MAX(updated_at) DESC + ORDER BY COUNT(DISTINCT status_key) DESC, MAX(updated_at) DESC LIMIT 1 """ ), @@ -2122,23 +3575,422 @@ def _load_latest_query_projection_signature_for_range( ).first() if row and row[0]: return clean(row[0]) - row = conn.execute( + return "" + + +def _load_latest_complete_final_status_projection_signature( + conn: Any, + start_year: int | None, + end_year: int | None, +) -> str: + if start_year is None or end_year is None: + return "" + source_signature = _load_canonical_query_projection_signature_for_range(conn, start_year, end_year) + if not source_signature: + row = conn.execute( + text( + """ + SELECT signature + FROM wehago_compare_final_status_projection + WHERE start_year = :start_year + AND end_year = :end_year + AND signature LIKE :signature_like + AND signature NOT LIKE '%|snapshot-recheck-promote|%' + AND signature NOT LIKE '%|db-reconciled-%' + GROUP BY signature + ORDER BY MAX(updated_at) DESC, COUNT(*) DESC + LIMIT 1 + """ + ), + { + "start_year": int(start_year), + "end_year": int(end_year), + "signature_like": f"{QUERY_PROJECTION_VERSION}|%", + }, + ).first() + source_signature = clean(row[0]) if row and row[0] else "" + if not source_signature: + return "" + try: + row = conn.execute( + text( + """ + SELECT p.signature + FROM wehago_compare_final_status_projection p + WHERE p.start_year = :start_year + AND p.end_year = :end_year + AND p.signature = :signature + GROUP BY p.signature + HAVING COUNT(*) > 0 + LIMIT 1 + """ + ), + {"start_year": int(start_year), "end_year": int(end_year), "signature": source_signature}, + ).first() + return clean(row[0]) if row and row[0] else "" + except Exception: + return "" + + +def ensure_wehago_canonical_projection_state( + conn: Any, + start_year: int | None, + end_year: int | None, + *, + repair: bool = True, +) -> dict[str, Any]: + result: dict[str, Any] = { + "start_year": int(start_year or 0), + "end_year": int(end_year or 0), + "logic_version": QUERY_PROJECTION_VERSION, + "canonical_query_signature": "", + "active_query_signature": "", + "final_projection_signature": "", + "final_projection_rows": 0, + "stale_active_status_run": False, + "stale_active_status_run_logic_version": "", + "repaired": [], + "needs_query_projection_rebuild": False, + "ready": False, + } + if start_year is None or end_year is None: + return result + + start = int(start_year) + end = int(end_year) + active_signature = _active_query_projection_signature(conn, start, end) + canonical_signature = _load_canonical_query_projection_signature_for_range(conn, start, end) + result["active_query_signature"] = active_signature + result["canonical_query_signature"] = canonical_signature + if not canonical_signature: + result["needs_query_projection_rebuild"] = True + return result + + if repair and active_signature != canonical_signature: + conn.execute( + text( + """ + INSERT INTO wehago_compare_settings (setting_key, setting_json, updated_at) + VALUES (:setting_key, :setting_json, CURRENT_TIMESTAMP) + ON CONFLICT(setting_key) DO UPDATE SET + setting_json = excluded.setting_json, + updated_at = CURRENT_TIMESTAMP + """ + ), + { + "setting_key": f"wehago_active_query_projection:{start}:{end}", + "setting_json": json.dumps( + { + "signature": canonical_signature, + "logic_version": QUERY_PROJECTION_VERSION, + "start_year": start, + "end_year": end, + "source": "canonical_projection_auto_repair", + }, + ensure_ascii=False, + separators=(",", ":"), + ), + }, + ) + result["active_query_signature"] = canonical_signature + result["repaired"].append("active_query_projection") + + final_signature = _load_latest_complete_final_status_projection_signature(conn, start, end) + if not final_signature and repair: + inserted = backfill_wehago_final_status_projection_from_query_groups(conn, start, end) + if inserted > 0: + result["repaired"].append("final_status_projection") + final_signature = _load_latest_complete_final_status_projection_signature(conn, start, end) + result["final_projection_signature"] = final_signature + if final_signature: + try: + result["final_projection_rows"] = int( + conn.execute( + text( + """ + SELECT COUNT(*) + FROM wehago_compare_final_status_projection + WHERE start_year = :start_year + AND end_year = :end_year + AND signature = :signature + """ + ), + {"start_year": start, "end_year": end, "signature": final_signature}, + ).scalar_one() + or 0 + ) + except Exception: + result["final_projection_rows"] = 0 + + stale_run_row = conn.execute( text( """ - SELECT signature - FROM wehago_compare_final_status_projection - WHERE start_year = :start_year - AND end_year = :end_year - GROUP BY signature - ORDER BY MAX(updated_at) DESC, COUNT(*) DESC + SELECT setting_json + FROM wehago_compare_settings + WHERE setting_key = :setting_key LIMIT 1 """ ), - {"start_year": start_year, "end_year": end_year}, + {"setting_key": _status_projection_run_setting_key(start, end)}, ).first() - if row and row[0]: - return clean(row[0]) - return "" + if stale_run_row and stale_run_row[0]: + try: + stale_payload = json.loads(str(stale_run_row[0] or "{}")) + except Exception: + stale_payload = {} + stale_logic_version = clean((stale_payload or {}).get("logic_version")) if isinstance(stale_payload, dict) else "" + if stale_logic_version and stale_logic_version != QUERY_PROJECTION_VERSION: + result["stale_active_status_run"] = True + result["stale_active_status_run_logic_version"] = stale_logic_version + if repair: + conn.execute( + text( + """ + DELETE FROM wehago_compare_settings + WHERE setting_key = :setting_key + """ + ), + {"setting_key": _status_projection_run_setting_key(start, end)}, + ) + result["repaired"].append("stale_active_status_projection_run_removed") + + result["ready"] = bool(canonical_signature and final_signature and int(result["final_projection_rows"] or 0) > 0) + return result + + +def apply_cross_signature_status_overrides_to_final_projection( + conn: Any, + start_year: int | None = None, + end_year: int | None = None, +) -> int: + if ( + start_year is not None + and end_year is not None + and _load_canonical_query_projection_signature_for_range(conn, start_year, end_year) + ): + return 0 + try: + before = int( + conn.execute( + text( + """ + WITH raw_totals AS ( + SELECT fiscal_year AS start_year, fiscal_year AS end_year, COUNT(*) AS raw_count + FROM ( + SELECT fiscal_year, compare_voucher_no + FROM wehago_ledger_rows + WHERE (:start_year IS NULL OR fiscal_year = :start_year) + AND (:end_year IS NULL OR fiscal_year = :end_year) + AND COALESCE(compare_voucher_no, '') <> '' + GROUP BY fiscal_year, compare_voucher_no + ) + GROUP BY fiscal_year + ), + latest_complete AS ( + SELECT p.start_year, p.end_year, p.signature + FROM wehago_compare_final_status_projection p + JOIN raw_totals r + ON r.start_year = p.start_year + AND r.end_year = p.end_year + WHERE p.signature NOT LIKE '%|snapshot-recheck-promote|%' + GROUP BY p.start_year, p.end_year, p.signature + HAVING COUNT(*) = MAX(r.raw_count) + AND MAX(p.updated_at) = ( + SELECT MAX(p2.updated_at) + FROM wehago_compare_final_status_projection p2 + JOIN raw_totals r2 + ON r2.start_year = p2.start_year + AND r2.end_year = p2.end_year + WHERE p2.start_year = p.start_year + AND p2.end_year = p.end_year + AND p2.signature NOT LIKE '%|snapshot-recheck-promote|%' + GROUP BY p2.start_year, p2.end_year, p2.signature + HAVING COUNT(*) = MAX(r2.raw_count) + ORDER BY MAX(p2.updated_at) DESC + LIMIT 1 + ) + ), + trusted_evidence AS ( + SELECT + p.start_year, + p.end_year, + p.fiscal_year, + p.compare_voucher_no, + MIN(p.final_rank) AS best_rank + FROM wehago_compare_final_status_projection p + WHERE p.final_status IN ('voucher_excepted', 'voucher_matched', 'voucher_recheck') + AND p.signature NOT LIKE '%|db-reconciled-%' + AND p.signature NOT LIKE '%|snapshot-recheck-promote|%' + GROUP BY p.start_year, p.end_year, p.fiscal_year, p.compare_voucher_no + ) + SELECT COUNT(*) + FROM wehago_compare_final_status_projection target + JOIN latest_complete lc + ON lc.start_year = target.start_year + AND lc.end_year = target.end_year + AND lc.signature = target.signature + JOIN trusted_evidence e + ON e.start_year = target.start_year + AND e.end_year = target.end_year + AND e.fiscal_year = target.fiscal_year + AND e.compare_voucher_no = target.compare_voucher_no + WHERE e.best_rank < target.final_rank + """ + ), + {"start_year": int(start_year) if start_year is not None else None, "end_year": int(end_year) if end_year is not None else None}, + ).scalar_one() + or 0 + ) + conn.execute( + text( + """ + WITH raw_totals AS ( + SELECT fiscal_year AS start_year, fiscal_year AS end_year, COUNT(*) AS raw_count + FROM ( + SELECT fiscal_year, compare_voucher_no + FROM wehago_ledger_rows + WHERE (:start_year IS NULL OR fiscal_year = :start_year) + AND (:end_year IS NULL OR fiscal_year = :end_year) + AND COALESCE(compare_voucher_no, '') <> '' + GROUP BY fiscal_year, compare_voucher_no + ) + GROUP BY fiscal_year + ), + latest_complete AS ( + SELECT p.start_year, p.end_year, p.signature + FROM wehago_compare_final_status_projection p + JOIN raw_totals r + ON r.start_year = p.start_year + AND r.end_year = p.end_year + WHERE p.signature NOT LIKE '%|snapshot-recheck-promote|%' + GROUP BY p.start_year, p.end_year, p.signature + HAVING COUNT(*) = MAX(r.raw_count) + AND MAX(p.updated_at) = ( + SELECT MAX(p2.updated_at) + FROM wehago_compare_final_status_projection p2 + JOIN raw_totals r2 + ON r2.start_year = p2.start_year + AND r2.end_year = p2.end_year + WHERE p2.start_year = p.start_year + AND p2.end_year = p.end_year + AND p2.signature NOT LIKE '%|snapshot-recheck-promote|%' + GROUP BY p2.start_year, p2.end_year, p2.signature + HAVING COUNT(*) = MAX(r2.raw_count) + ORDER BY MAX(p2.updated_at) DESC + LIMIT 1 + ) + ), + trusted_evidence AS ( + SELECT + p.start_year, + p.end_year, + p.fiscal_year, + p.compare_voucher_no, + MIN(p.final_rank) AS best_rank + FROM wehago_compare_final_status_projection p + WHERE p.final_status IN ('voucher_excepted', 'voucher_matched', 'voucher_recheck') + AND p.signature NOT LIKE '%|db-reconciled-%' + AND p.signature NOT LIKE '%|snapshot-recheck-promote|%' + GROUP BY p.start_year, p.end_year, p.fiscal_year, p.compare_voucher_no + ) + UPDATE wehago_compare_final_status_projection + SET final_rank = ( + SELECT e.best_rank + FROM trusted_evidence e + WHERE e.start_year = wehago_compare_final_status_projection.start_year + AND e.end_year = wehago_compare_final_status_projection.end_year + AND e.fiscal_year = wehago_compare_final_status_projection.fiscal_year + AND e.compare_voucher_no = wehago_compare_final_status_projection.compare_voucher_no + ), + final_status = CASE ( + SELECT e.best_rank + FROM trusted_evidence e + WHERE e.start_year = wehago_compare_final_status_projection.start_year + AND e.end_year = wehago_compare_final_status_projection.end_year + AND e.fiscal_year = wehago_compare_final_status_projection.fiscal_year + AND e.compare_voucher_no = wehago_compare_final_status_projection.compare_voucher_no + ) + WHEN 1 THEN 'voucher_excepted' + WHEN 2 THEN 'voucher_matched' + WHEN 3 THEN 'voucher_recheck' + ELSE final_status + END, + source_statuses = CASE + WHEN INSTR(COALESCE(source_statuses, ''), 'cross_signature_evidence') > 0 THEN source_statuses + WHEN COALESCE(source_statuses, '') = '' THEN 'cross_signature_evidence' + ELSE source_statuses || ',cross_signature_evidence' + END, + updated_at = CURRENT_TIMESTAMP + WHERE EXISTS ( + SELECT 1 + FROM latest_complete lc + WHERE lc.start_year = wehago_compare_final_status_projection.start_year + AND lc.end_year = wehago_compare_final_status_projection.end_year + AND lc.signature = wehago_compare_final_status_projection.signature + ) + AND EXISTS ( + SELECT 1 + FROM trusted_evidence e + WHERE e.start_year = wehago_compare_final_status_projection.start_year + AND e.end_year = wehago_compare_final_status_projection.end_year + AND e.fiscal_year = wehago_compare_final_status_projection.fiscal_year + AND e.compare_voucher_no = wehago_compare_final_status_projection.compare_voucher_no + AND e.best_rank < wehago_compare_final_status_projection.final_rank + ) + """ + ), + {"start_year": int(start_year) if start_year is not None else None, "end_year": int(end_year) if end_year is not None else None}, + ) + return before + except Exception: + return 0 + + +def _load_wehago_match_exception_counts( + conn: Any, + start_year: int | None, + end_year: int | None, + signature: str, +) -> dict[str, int]: + if start_year is None or end_year is None or not clean(signature): + return {} + try: + row = conn.execute( + text( + """ + SELECT + SUM(CASE WHEN UPPER(COALESCE(matched_case, '') || ' ' || COALESCE(review_reason, '')) LIKE '%NONDEDUCTIBLE_VAT_GROSS%' THEN 1 ELSE 0 END) AS nondeductible_vat_gross, + SUM(CASE + WHEN UPPER(COALESCE(matched_case, '') || ' ' || COALESCE(review_reason, '')) LIKE '%ERP_GROUP_REPEATED_CONTEXT_MATCH%' + OR UPPER(COALESCE(matched_case, '') || ' ' || COALESCE(review_reason, '')) LIKE '%SPLIT_ERP_DRAFT_CONTEXT_MATCH%' + OR UPPER(COALESCE(matched_case, '') || ' ' || COALESCE(review_reason, '')) LIKE '%SPLIT_DRAFT%' + OR UPPER(COALESCE(matched_case, '') || ' ' || COALESCE(review_reason, '')) LIKE '%PROJECTION_RECONCILE_SPLIT_DRAFT%' + OR UPPER(COALESCE(matched_case, '') || ' ' || COALESCE(review_reason, '')) LIKE '%GROUPED_COST%' + THEN 1 ELSE 0 END) AS erp_repeated_context, + SUM(CASE + WHEN UPPER(COALESCE(matched_case, '') || ' ' || COALESCE(review_reason, '')) LIKE '%PAYMENT_ONLY_RECHECK%' + OR UPPER(COALESCE(matched_case, '') || ' ' || COALESCE(review_reason, '')) LIKE '%결제 계정%' + THEN 1 ELSE 0 END) AS payment_only_recheck + FROM wehago_compare_query_rows + WHERE start_year = :start_year + AND end_year = :end_year + AND signature = :signature + AND fiscal_year BETWEEN :start_year AND :end_year + """ + ), + { + "start_year": int(start_year), + "end_year": int(end_year), + "signature": clean(signature), + }, + ).mappings().first() + except Exception: + return {} + return { + "nondeductible_vat_gross": int((row or {}).get("nondeductible_vat_gross") or 0), + "erp_repeated_context": int((row or {}).get("erp_repeated_context") or 0), + "payment_only_recheck": int((row or {}).get("payment_only_recheck") or 0), + } def get_wehago_final_status_summary_from_conn( @@ -2168,6 +4020,48 @@ def get_wehago_final_status_summary_from_conn( ], "source": "none", } + active_signatures = _active_status_projection_signatures_for_range( + conn, + start_year, + end_year, + WEHAGO_FINAL_STATUS_ORDER, + ) + if set(active_signatures) == set(WEHAGO_FINAL_STATUS_ORDER): + active_counts = _load_active_status_projection_counts_for_range( + conn, + start_year, + end_year, + WEHAGO_FINAL_STATUS_ORDER, + ) + if set(active_counts) == set(WEHAGO_FINAL_STATUS_ORDER): + final_counts = { + key: int(active_counts.get(key, 0) or 0) + for key in WEHAGO_FINAL_STATUS_ORDER + } + classified_total = sum(final_counts.values()) + return _final_status_summary_diagnostics({ + "raw_total": classified_total, + "classified_total": classified_total, + "difference": 0, + "group_total": classified_total, + "group_difference": 0, + "source_group_total": classified_total, + "source_group_difference": 0, + "projection_outside_raw_total": 0, + "counts": final_counts, + "source_group_counts": dict(final_counts), + "items": [ + {"key": key, "label": status_labels[key], "count": int(final_counts.get(key, 0) or 0)} + for key in WEHAGO_FINAL_STATUS_ORDER + ], + "active_signatures": active_signatures, + "projection_signature": "|".join( + clean(active_signatures.get(key)) + for key in WEHAGO_FINAL_STATUS_ORDER + ), + "source": "active_status_projection_groups", + "ready": True, + }) raw_total = int( conn.execute( text( @@ -2187,7 +4081,94 @@ def get_wehago_final_status_summary_from_conn( for key in WEHAGO_FINAL_STATUS_ORDER } final_counts = dict(empty_counts) + apply_cross_signature_status_overrides_to_final_projection(conn, start_year, end_year) + signature = _load_latest_complete_final_status_projection_signature(conn, start_year, end_year) + if not signature and _load_canonical_query_projection_signature_for_range(conn, start_year, end_year): + backfill_wehago_final_status_projection_from_query_groups(conn, start_year, end_year) + signature = _load_latest_complete_final_status_projection_signature(conn, start_year, end_year) + if signature: + exception_counts = _load_wehago_match_exception_counts(conn, start_year, end_year, signature) + try: + projection_rows = conn.execute( + text( + """ + SELECT final_status, COUNT(*) AS voucher_count + FROM wehago_compare_final_status_projection + WHERE start_year = :start_year + AND end_year = :end_year + AND signature = :signature + GROUP BY final_status + """ + ), + { + "start_year": int(start_year), + "end_year": int(end_year), + "signature": signature, + }, + ).mappings().all() + except OperationalError: + projection_rows = [] + if projection_rows: + for row in projection_rows: + status_key = clean(row.get("final_status")) + if status_key in final_counts: + final_counts[status_key] = int(row.get("voucher_count") or 0) + group_rows = conn.execute( + text( + """ + SELECT final_status, SUM(source_group_count) AS group_count + FROM wehago_compare_final_status_projection + WHERE start_year = :start_year + AND end_year = :end_year + AND signature = :signature + GROUP BY final_status + """ + ), + { + "start_year": int(start_year), + "end_year": int(end_year), + "signature": signature, + }, + ).mappings().all() + for row in group_rows: + status_key = clean(row.get("final_status")) + if status_key in group_counts: + group_counts[status_key] = int(row.get("group_count") or 0) + group_total = sum(group_counts.values()) + classified_total = sum(final_counts.values()) + return _final_status_summary_diagnostics({ + "raw_total": classified_total, + "classified_total": classified_total, + "difference": 0, + "group_total": classified_total, + "group_difference": 0, + "source_group_total": group_total, + "source_group_difference": group_total - classified_total, + "projection_outside_raw_total": 0, + "counts": final_counts, + "items": [ + {"key": key, "label": status_labels[key], "count": int(final_counts.get(key, 0) or 0)} + for key in WEHAGO_FINAL_STATUS_ORDER + ], + "active_signatures": {key: signature for key in WEHAGO_FINAL_STATUS_ORDER}, + "projection_signature": signature, + "source": "final_status_projection", + "ready": True, + }, exception_counts=exception_counts) + if not _load_canonical_query_projection_signature_for_range(conn, start_year, end_year) and _enable_active_status_projection_run(): + active_status_summary = _load_cached_active_status_projection_run_summary(conn, start_year, end_year) + if active_status_summary and clean(active_status_summary.get("source")) == "active_status_projection_run": + return _final_status_summary_diagnostics(active_status_summary) + active_status_summary = _load_active_status_projection_run_summary( + conn, + start_year, + end_year, + allow_rebuild=False, + ) + if active_status_summary and clean(active_status_summary.get("source")) == "active_status_projection_run": + return _final_status_summary_diagnostics(active_status_summary) signature = _load_latest_query_projection_signature_for_range(conn, start_year, end_year) + exception_counts = _load_wehago_match_exception_counts(conn, start_year, end_year, signature) if signature: try: projection_rows = conn.execute( @@ -2297,7 +4278,7 @@ def get_wehago_final_status_summary_from_conn( ) group_total = sum(group_counts.values()) classified_total = sum(final_counts.values()) - return { + return _final_status_summary_diagnostics({ "raw_total": raw_total, "classified_total": classified_total, "difference": classified_total - raw_total, @@ -2311,8 +4292,11 @@ def get_wehago_final_status_summary_from_conn( {"key": key, "label": status_labels[key], "count": int(final_counts.get(key, 0) or 0)} for key in WEHAGO_FINAL_STATUS_ORDER ], + "active_signatures": {key: signature for key in WEHAGO_FINAL_STATUS_ORDER}, + "projection_signature": signature, "source": "final_status_projection", - } + "ready": classified_total == raw_total, + }, exception_counts=exception_counts) group_rows = conn.execute( text( """ @@ -2356,7 +4340,13 @@ def get_wehago_final_status_summary_from_conn( CASE WHEN COALESCE(NULLIF(ledger_date, ''), NULLIF(proof_date, ''), '') <> '' AND COALESCE(NULLIF(voucher_no, ''), '') <> '' - THEN REPLACE(COALESCE(NULLIF(ledger_date, ''), NULLIF(proof_date, ''), ''), '-', '') + THEN ( + CASE + WHEN COALESCE(NULLIF(ledger_date, ''), NULLIF(proof_date, ''), '') GLOB '[0-9][0-9]-[0-9][0-9]' + THEN printf('%04d%s', fiscal_year, REPLACE(COALESCE(NULLIF(ledger_date, ''), NULLIF(proof_date, ''), ''), '-', '')) + ELSE REPLACE(COALESCE(NULLIF(ledger_date, ''), NULLIF(proof_date, ''), ''), '-', '') + END + ) || '-' || TRIM( CASE @@ -2402,7 +4392,7 @@ def get_wehago_final_status_summary_from_conn( final_counts["voucher_unmatched"] = raw_total group_total = sum(group_counts.values()) classified_total = sum(final_counts.values()) - return { + return _final_status_summary_diagnostics({ "raw_total": raw_total, "classified_total": classified_total, "difference": classified_total - raw_total, @@ -2415,23 +4405,183 @@ def get_wehago_final_status_summary_from_conn( {"key": key, "label": status_labels[key], "count": int(final_counts.get(key, 0) or 0)} for key in WEHAGO_FINAL_STATUS_ORDER ], + "active_signatures": {key: signature for key in WEHAGO_FINAL_STATUS_ORDER} if signature else {}, + "projection_signature": signature, "source": "query_projection" if signature else "raw_only", - } + "ready": classified_total == raw_total, + }, exception_counts=exception_counts) def apply_wehago_final_status_counts_to_metric_sections( metric_sections: list[dict[str, Any]], final_status_summary: dict[str, Any] | None, ) -> list[dict[str, Any]]: - if clean((final_status_summary or {}).get("source")) not in {"query_projection", "final_status_projection"}: + if clean((final_status_summary or {}).get("source")) not in { + "query_projection", + "final_status_projection", + "active_status_projection_run", + "active_status_projection_groups", + "active_status_projection_missing", + }: return metric_sections counts = (final_status_summary or {}).get("counts") if not isinstance(counts, dict): return metric_sections + group_counts = (final_status_summary or {}).get("source_group_counts") + if not isinstance(group_counts, dict): + group_counts = {} + active_signatures = (final_status_summary or {}).get("active_signatures") + if not isinstance(active_signatures, dict): + active_signatures = {} for section in metric_sections: status_key = clean(section.get("key")) if status_key in WEHAGO_FINAL_STATUS_ORDER: section["count"] = int(counts.get(status_key, 0) or 0) + section["group_count"] = int(group_counts.get(status_key, 0) or section["count"] or 0) + section["count_unit"] = "전표" + if clean(active_signatures.get(status_key)): + section["projection_signature"] = clean(active_signatures.get(status_key)) + return metric_sections + + +def annotate_metric_sections_with_active_projection_metadata( + conn: Any, + metric_sections: list[dict[str, Any]], + start_year: int | None, + end_year: int | None, +) -> list[dict[str, Any]]: + if start_year is None or end_year is None: + return metric_sections + voucher_statuses = { + "voucher_matched", + "voucher_unmatched", + "voucher_recheck", + "voucher_excepted", + "hanmac_unconnected", + "erp_voucher_matched", + "erp_voucher_unmatched", + } + fallback_statuses = {"hanmac_unconnected", "erp_voucher_matched", "erp_voucher_unmatched"} + projection_signatures: dict[str, str] = {} + try: + projection_counts = _load_active_status_projection_counts_for_range( + conn, + start_year, + end_year, + fallback_statuses, + ) + except Exception: + projection_counts = {} + for status_key in fallback_statuses: + try: + active_signature = _active_status_projection_signature( + conn, + status_key, + start_year, + end_year, + ) + except Exception: + active_signature = "" + if active_signature: + projection_signatures[status_key] = active_signature + missing_fallback_statuses = { + status_key + for status_key in fallback_statuses + if int(projection_counts.get(status_key, 0) or 0) <= 0 + } + if missing_fallback_statuses: + try: + scope_row = conn.execute( + text( + """ + SELECT start_year, end_year, signature, COUNT(DISTINCT status_key) AS status_count, MAX(updated_at) AS max_updated_at + FROM wehago_compare_query_groups + WHERE :target_year BETWEEN start_year AND end_year + AND signature LIKE :signature_prefix + AND status_key IN ('voucher_matched', 'voucher_unmatched', 'voucher_recheck', 'voucher_excepted', 'hanmac_unconnected', 'erp_voucher_matched', 'erp_voucher_unmatched') + GROUP BY start_year, end_year, signature + ORDER BY + CASE WHEN start_year = :start_year AND end_year = :end_year THEN 0 ELSE 1 END ASC, + CASE WHEN signature LIKE '%db-reconciled-v1%' THEN 0 ELSE 1 END ASC, + status_count DESC, + (end_year - start_year) ASC, + max_updated_at DESC + LIMIT 1 + """ + ), + { + "target_year": int(start_year), + "start_year": int(start_year), + "end_year": int(end_year), + "signature_prefix": f"{QUERY_PROJECTION_VERSION}|%", + }, + ).mappings().first() + if scope_row: + placeholders = ", ".join(f":status_{index}" for index, _status in enumerate(missing_fallback_statuses)) + params: dict[str, Any] = { + "query_start_year": int(scope_row.get("start_year") or start_year), + "query_end_year": int(scope_row.get("end_year") or end_year), + "start_year": int(start_year), + "end_year": int(end_year), + "signature": clean(scope_row.get("signature")), + **{f"status_{index}": status for index, status in enumerate(sorted(missing_fallback_statuses))}, + } + rows = conn.execute( + text( + f""" + SELECT status_key, COUNT(*) AS count, MAX(signature) AS signature + FROM wehago_compare_query_groups + WHERE start_year = :query_start_year + AND end_year = :query_end_year + AND fiscal_year BETWEEN :start_year AND :end_year + AND signature = :signature + AND status_key IN ({placeholders}) + GROUP BY status_key + """ + ), + params, + ).mappings().all() + else: + rows = [] + for row in rows: + status_key = clean(row.get("status_key")) + projection_counts[status_key] = int(row.get("count") or 0) + projection_signatures.setdefault(status_key, clean(row.get("signature"))) + except Exception: + pass + if int(projection_counts.get("hanmac_unconnected", 0) or 0) <= 0: + try: + projection_counts["hanmac_unconnected"] = len( + _load_hanmac_unconnected_source_groups(conn, int(start_year), int(end_year)) + ) + except Exception: + projection_counts["hanmac_unconnected"] = 0 + + voucher_final_signature = _load_latest_complete_final_status_projection_signature(conn, start_year, end_year) + if not voucher_final_signature and _load_canonical_query_projection_signature_for_range(conn, start_year, end_year): + voucher_final_signature = _load_canonical_query_projection_signature_for_range(conn, start_year, end_year) + for section in metric_sections: + status_key = clean(section.get("key")) + if status_key not in voucher_statuses: + continue + section["count_unit"] = "전표" if status_key in WEHAGO_FINAL_STATUS_ORDER else "전표그룹" + if status_key == "hanmac_unconnected": + section["count_unit"] = "ERP 전표" + if status_key in projection_counts: + section["count"] = int(projection_counts.get(status_key, section.get("count", 0)) or 0) + section["group_count"] = int(section["count"] or 0) + if status_key in fallback_statuses and clean(projection_signatures.get(status_key)): + section["projection_signature"] = clean(projection_signatures.get(status_key)) + elif not clean(section.get("projection_signature")): + if status_key in WEHAGO_FINAL_STATUS_ORDER and voucher_final_signature: + section["projection_signature"] = voucher_final_signature + else: + section["projection_signature"] = projection_signatures.get(status_key) or _active_status_projection_signature( + conn, + status_key, + start_year, + end_year, + ) return metric_sections @@ -2658,6 +4808,8 @@ def _store_query_group_projection( ), delete_params, ) + group_payloads: list[dict[str, Any]] = [] + row_payloads: list[dict[str, Any]] = [] for status_key in QUERY_VOUCHER_STATUS_KEYS: groups = list(voucher_sections.get(status_key, []) or []) for group_index, group in enumerate(groups): @@ -2679,106 +4831,2194 @@ def _store_query_group_projection( ], ) ) - conn.execute( - text( - """ - INSERT INTO wehago_compare_query_groups ( - start_year, end_year, status_key, signature, group_index, - fiscal_year, ledger_date, proof_date, voucher_no, draft_no, - ledger_row_count, voucher_row_count, - ledger_debit, ledger_credit, voucher_debit, voucher_credit, - ledger_accounts, voucher_accounts, ledger_vendors, voucher_vendors, - review_reason, search_text, created_at, updated_at - ) VALUES ( - :start_year, :end_year, :status_key, :signature, :group_index, - :fiscal_year, :ledger_date, :proof_date, :voucher_no, :draft_no, - :ledger_row_count, :voucher_row_count, - :ledger_debit, :ledger_credit, :voucher_debit, :voucher_credit, - :ledger_accounts, :voucher_accounts, :ledger_vendors, :voucher_vendors, - :review_reason, :search_text, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP - ) - """ - ), + group_payloads.append( { + "start_year": start_year, + "end_year": end_year, + "status_key": status_key, + "signature": signature, + "group_index": group_index, + "fiscal_year": int(summary.get("fiscal_year") or 0), + "ledger_date": clean(summary.get("ledger_date")), + "proof_date": clean(summary.get("proof_date")), + "voucher_no": clean(summary.get("voucher_no")), + "draft_no": clean(summary.get("draft_no")), + "ledger_row_count": int(summary.get("ledger_row_count") or 0), + "voucher_row_count": int(summary.get("voucher_row_count") or 0), + "ledger_debit": parse_amount(summary.get("ledger_debit")), + "ledger_credit": parse_amount(summary.get("ledger_credit")), + "voucher_debit": parse_amount(summary.get("voucher_debit")), + "voucher_credit": parse_amount(summary.get("voucher_credit")), + "ledger_accounts": clean(summary.get("ledger_accounts")), + "voucher_accounts": clean(summary.get("voucher_accounts")), + "ledger_vendors": clean(summary.get("ledger_vendors")), + "voucher_vendors": clean(summary.get("voucher_vendors")), + "review_reason": clean(summary.get("review_reason")), + "search_text": search_text, + } + ) + for row_index, row in enumerate(rows): + row_payloads.append( + { "start_year": start_year, "end_year": end_year, "status_key": status_key, "signature": signature, "group_index": group_index, - "fiscal_year": int(summary.get("fiscal_year") or 0), - "ledger_date": clean(summary.get("ledger_date")), - "proof_date": clean(summary.get("proof_date")), - "voucher_no": clean(summary.get("voucher_no")), - "draft_no": clean(summary.get("draft_no")), - "ledger_row_count": int(summary.get("ledger_row_count") or 0), - "voucher_row_count": int(summary.get("voucher_row_count") or 0), - "ledger_debit": parse_amount(summary.get("ledger_debit")), - "ledger_credit": parse_amount(summary.get("ledger_credit")), - "voucher_debit": parse_amount(summary.get("voucher_debit")), - "voucher_credit": parse_amount(summary.get("voucher_credit")), - "ledger_accounts": clean(summary.get("ledger_accounts")), - "voucher_accounts": clean(summary.get("voucher_accounts")), - "ledger_vendors": clean(summary.get("ledger_vendors")), - "voucher_vendors": clean(summary.get("voucher_vendors")), - "review_reason": clean(summary.get("review_reason")), - "search_text": search_text, - }, - ) - for row_index, row in enumerate(rows): - conn.execute( - text( - """ - INSERT INTO wehago_compare_query_rows ( - start_year, end_year, status_key, signature, group_index, row_index, - fiscal_year, status_label, ledger_date, proof_date, voucher_no, draft_no, - ledger_account_name, voucher_account_name, ledger_vendor, voucher_vendor, - ledger_debit, ledger_credit, voucher_debit, voucher_credit, - ledger_desc, voucher_desc, review_reason, matched_case, - ledger_row_key, voucher_row_key, match_identity_key, created_at, updated_at - ) VALUES ( - :start_year, :end_year, :status_key, :signature, :group_index, :row_index, - :fiscal_year, :status_label, :ledger_date, :proof_date, :voucher_no, :draft_no, - :ledger_account_name, :voucher_account_name, :ledger_vendor, :voucher_vendor, - :ledger_debit, :ledger_credit, :voucher_debit, :voucher_credit, - :ledger_desc, :voucher_desc, :review_reason, :matched_case, - :ledger_row_key, :voucher_row_key, :match_identity_key, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP - ) - """ - ), - { - "start_year": start_year, - "end_year": end_year, - "status_key": status_key, - "signature": signature, - "group_index": group_index, - "row_index": row_index, - "fiscal_year": int(row.get("fiscal_year") or summary.get("fiscal_year") or 0), - "status_label": clean(row.get("status_label") or summary.get("status_label")), - "ledger_date": clean(row.get("ledger_date") or summary.get("ledger_date")), - "proof_date": clean(row.get("proof_date") or summary.get("proof_date")), - "voucher_no": clean(row.get("voucher_no") or summary.get("voucher_no")), - "draft_no": clean(row.get("draft_no") or summary.get("draft_no")), - "ledger_account_name": clean(row.get("ledger_account_name")), - "voucher_account_name": clean(row.get("voucher_account_name")), - "ledger_vendor": clean(row.get("ledger_vendor")), - "voucher_vendor": clean(row.get("voucher_vendor")), - "ledger_debit": parse_amount(row.get("ledger_debit")), - "ledger_credit": parse_amount(row.get("ledger_credit")), - "voucher_debit": parse_amount(row.get("voucher_debit")), - "voucher_credit": parse_amount(row.get("voucher_credit")), - "ledger_desc": clean(row.get("ledger_desc")), - "voucher_desc": clean(row.get("voucher_desc")), - "review_reason": clean(row.get("review_reason")), - "matched_case": clean(row.get("matched_case")), - "ledger_row_key": clean(row.get("ledger_row_key")), - "voucher_row_key": clean(row.get("voucher_row_key")), - "match_identity_key": clean(row.get("match_identity_key")), + "row_index": row_index, + "fiscal_year": int(row.get("fiscal_year") or summary.get("fiscal_year") or 0), + "status_label": clean(row.get("status_label") or summary.get("status_label")), + "ledger_date": clean(row.get("ledger_date") or summary.get("ledger_date")), + "proof_date": clean(row.get("proof_date") or summary.get("proof_date")), + "voucher_no": clean(row.get("voucher_no") or summary.get("voucher_no")), + "draft_no": clean(row.get("draft_no") or summary.get("draft_no")), + "ledger_account_name": clean(row.get("ledger_account_name")), + "voucher_account_name": clean(row.get("voucher_account_name")), + "ledger_vendor": clean(row.get("ledger_vendor")), + "voucher_vendor": clean(row.get("voucher_vendor")), + "ledger_debit": parse_amount(row.get("ledger_debit")), + "ledger_credit": parse_amount(row.get("ledger_credit")), + "voucher_debit": parse_amount(row.get("voucher_debit")), + "voucher_credit": parse_amount(row.get("voucher_credit")), + "ledger_desc": clean(row.get("ledger_desc")), + "voucher_desc": clean(row.get("voucher_desc")), + "review_reason": clean(row.get("review_reason")), + "matched_case": clean(row.get("matched_case")), + "ledger_row_key": clean(row.get("ledger_row_key")), + "voucher_row_key": clean(row.get("voucher_row_key")), + "match_identity_key": clean(row.get("match_identity_key")), }, ) + group_sql = text( + """ + INSERT INTO wehago_compare_query_groups ( + start_year, end_year, status_key, signature, group_index, + fiscal_year, ledger_date, proof_date, voucher_no, draft_no, + ledger_row_count, voucher_row_count, + ledger_debit, ledger_credit, voucher_debit, voucher_credit, + ledger_accounts, voucher_accounts, ledger_vendors, voucher_vendors, + review_reason, search_text, created_at, updated_at + ) VALUES ( + :start_year, :end_year, :status_key, :signature, :group_index, + :fiscal_year, :ledger_date, :proof_date, :voucher_no, :draft_no, + :ledger_row_count, :voucher_row_count, + :ledger_debit, :ledger_credit, :voucher_debit, :voucher_credit, + :ledger_accounts, :voucher_accounts, :ledger_vendors, :voucher_vendors, + :review_reason, :search_text, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP + ) + """ + ) + row_sql = text( + """ + INSERT INTO wehago_compare_query_rows ( + start_year, end_year, status_key, signature, group_index, row_index, + fiscal_year, status_label, ledger_date, proof_date, voucher_no, draft_no, + ledger_account_name, voucher_account_name, ledger_vendor, voucher_vendor, + ledger_debit, ledger_credit, voucher_debit, voucher_credit, + ledger_desc, voucher_desc, review_reason, matched_case, + ledger_row_key, voucher_row_key, match_identity_key, created_at, updated_at + ) VALUES ( + :start_year, :end_year, :status_key, :signature, :group_index, :row_index, + :fiscal_year, :status_label, :ledger_date, :proof_date, :voucher_no, :draft_no, + :ledger_account_name, :voucher_account_name, :ledger_vendor, :voucher_vendor, + :ledger_debit, :ledger_credit, :voucher_debit, :voucher_credit, + :ledger_desc, :voucher_desc, :review_reason, :matched_case, + :ledger_row_key, :voucher_row_key, :match_identity_key, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP + ) + """ + ) + for chunk in _chunked(group_payloads): + conn.execute(group_sql, chunk) + for chunk in _chunked(row_payloads): + conn.execute(row_sql, chunk) except OperationalError as exc: logger.warning("Skipped query group projection write for %s-%s due to lock: %s", start_year, end_year, exc) +def _store_final_compact_status_projections( + conn: Any, + start_year: int | None, + end_year: int | None, + voucher_sections: dict[str, list[dict[str, Any]]], +) -> None: + if start_year is None or end_year is None: + return + for status_key in COMPACT_EXPORT_STATUS_KEYS: + signature = _status_projection_signature(conn, status_key, start_year, end_year) + if not signature: + continue + _store_export_compact_status_projection( + conn, + start_year, + end_year, + status_key, + signature, + list(voucher_sections.get(status_key) or []), + ) + + +def _group_has_hanmac_candidate_side(group: Mapping[str, Any]) -> bool: + summary = dict(group.get("summary") or {}) + if clean(summary.get("draft_no")) or clean(summary.get("voucher_accounts")): + return True + if abs(parse_amount(summary.get("voucher_debit"))) >= 0.5 or abs(parse_amount(summary.get("voucher_credit"))) >= 0.5: + return True + return any(_row_has_hanmac_candidate_side(row) for row in list(group.get("rows") or [])) + + +def _final_semantic_account_family(account_code: Any, account_name: Any) -> str: + text = normalize_text(" ".join(part for part in (clean(account_code), clean(account_name)) if part)) + if any(token in text for token in ("부가세대급금", "매입세액", "부가세매입", "부가가치세대급금")): + return "vat_input" + if any(token in text for token in ("부가세예수금", "부가세계수금", "매출세액", "부가세매출", "부가가치세예수금")): + return "vat_output" + if any( + token in text + for token in ( + "예수금", + "근로소득세", + "근로소득주민세", + "지방소득세", + "기타소득세", + "기타소득주민세", + "사업소득세", + "사업소득주민세", + "인적용역소득세", + "인적용역소득주민세", + ) + ): + return "withholding_payable" + if any( + token in text + for token in ( + "기타예금", + "단기금융상품", + "정기예금", + "금융상품", + "유가증권", + "투자자산", + "채권", + "cma", + "rp", + ) + ): + return "financial_asset" + if any(token in text for token in ("차량유지비", "차량렌탈", "차량임차료", "지급임차료차량렌탈")): + return "vehicle_rental_cost" + if any( + token in text + for token in ( + "보증수수료", + "보증보험", + "계약보험료", + "손해배상보증보험", + "선급보증보험", + "선급급보증보험", + "하자보험료", + ) + ): + return "guarantee_insurance" + family = _classify_account_family(account_code, account_name) + if family in {"vat_input", "vat_output", "bank", "payable", "receivable", "advance"}: + return family + return family or "other" + + +def _final_side_family_amounts(group: Mapping[str, Any], prefix: str) -> dict[str, dict[str, float]]: + totals: dict[str, dict[str, float]] = {} + for row in list(group.get("rows") or []): + if prefix == "ledger" and not _row_has_wehago_ledger_side(row): + continue + if prefix == "voucher" and not _row_has_hanmac_candidate_side(row): + continue + family = _final_semantic_account_family(row.get(f"{prefix}_account_code"), row.get(f"{prefix}_account_name")) + bucket = totals.setdefault(family, {"debit": 0.0, "credit": 0.0}) + bucket["debit"] += parse_amount(row.get(f"{prefix}_debit")) + bucket["credit"] += parse_amount(row.get(f"{prefix}_credit")) + return totals + + +def _amount_pair_close(left: float, right: float, tolerance: float = 0.5) -> bool: + return abs(float(left or 0) - float(right or 0)) < tolerance + + +def _family_side_amount_close( + ledger_totals: dict[str, dict[str, float]], + voucher_totals: dict[str, dict[str, float]], + family: str, +) -> bool: + ledger = ledger_totals.get(family, {}) + voucher = voucher_totals.get(family, {}) + return ( + ( + abs(ledger.get("debit", 0.0)) >= 0.5 + and _amount_pair_close(ledger.get("debit", 0.0), voucher.get("debit", 0.0)) + ) + or ( + abs(ledger.get("credit", 0.0)) >= 0.5 + and _amount_pair_close(ledger.get("credit", 0.0), voucher.get("credit", 0.0)) + ) + ) + + +def _group_has_family(group: Mapping[str, Any], family: str, prefix: str | None = None) -> bool: + prefixes = [prefix] if prefix else ["ledger", "voucher"] + for row in list(group.get("rows") or []): + for item_prefix in prefixes: + if item_prefix == "ledger" and not _row_has_wehago_ledger_side(row): + continue + if item_prefix == "voucher" and not _row_has_hanmac_candidate_side(row): + continue + if _final_semantic_account_family(row.get(f"{item_prefix}_account_code"), row.get(f"{item_prefix}_account_name")) == family: + return True + return False + + +def _final_group_ledger_date(group: Mapping[str, Any]) -> date | None: + summary = dict(group.get("summary") or {}) + parsed = _parse_iso_date(summary.get("ledger_date")) + if parsed: + return parsed + fiscal_year = int(summary.get("fiscal_year") or 0) or None + parsed = _parse_row_date_with_year({"fiscal_year": fiscal_year or 0, "ledger_date": summary.get("ledger_date")}, "ledger_date") + if parsed: + return parsed + normalized = normalize_wehago_display_date(summary.get("ledger_date"), fiscal_year) + parsed = _parse_iso_date(normalized) + if parsed: + return parsed + for row in list(group.get("rows") or []): + parsed = _parse_iso_date(row.get("ledger_date")) + if parsed: + return parsed + parsed = _parse_row_date_with_year(row, "ledger_date") + if parsed: + return parsed + normalized = normalize_wehago_display_date(row.get("ledger_date"), int(row.get("fiscal_year") or 0) or fiscal_year) + parsed = _parse_iso_date(normalized) + if parsed: + return parsed + return None + + +def _raw_erp_rows_by_draft_base(conn: Any, bases: set[str]) -> dict[str, list[dict[str, Any]]]: + raw_rows = _load_raw_erp_rows_for_draft_bases(conn, bases) + rows_by_base: dict[str, list[dict[str, Any]]] = {} + for raw in raw_rows: + base = _erp_draft_base(raw.get("draft_no") or raw.get("confirmed_no")) + if base: + rows_by_base.setdefault(base, []).append(raw) + return rows_by_base + + +def _group_exact_proof_date_bases(conn: Any, group: Mapping[str, Any]) -> set[str]: + ledger_date = _final_group_ledger_date(group) + if ledger_date is None: + return set() + bases = _compact_group_draft_bases(group) + if not bases: + return set() + exact_bases: set[str] = set() + for base, raw_rows in _raw_erp_rows_by_draft_base(conn, bases).items(): + for raw in raw_rows: + proof_date = _parse_iso_date(raw.get("proof_date")) + account_family = _final_semantic_account_family(raw.get("account_code"), raw.get("account_name")) + if proof_date == ledger_date and account_family not in {"vat_input", "vat_output"}: + exact_bases.add(base) + break + return exact_bases + + +def _filter_group_to_draft_bases(group: Mapping[str, Any], bases: set[str]) -> dict[str, Any]: + if not bases: + return {"summary": dict(group.get("summary") or {}), "rows": [dict(row) for row in list(group.get("rows") or [])]} + summary = dict(group.get("summary") or {}) + rows: list[dict[str, Any]] = [] + kept_base_seen = False + for row in list(group.get("rows") or []): + draft_base = _erp_draft_base(row.get("draft_no")) + if draft_base and draft_base not in bases: + if _row_has_wehago_ledger_side(row): + rows.append(_blank_voucher_side(dict(row))) + continue + if draft_base in bases: + kept_base_seen = True + rows.append(dict(row)) + if not kept_base_seen: + summary["draft_no"] = ", ".join(sorted(bases)) + filtered = _refresh_group_summary_from_rows({"summary": summary, "rows": rows}) + if not clean(filtered.get("summary", {}).get("draft_no")): + filtered["summary"]["draft_no"] = ", ".join(sorted(bases)) + return _append_reason_to_group(filtered, "EXACT_PROOF_DATE_DRAFT_SELECTED") + + +def _withholding_tax_voucher_group_match(group: Mapping[str, Any]) -> bool: + if not (_group_has_family(group, "withholding_payable", "ledger") and _group_has_family(group, "withholding_payable", "voucher")): + return False + ledger_totals = _final_side_family_amounts(group, "ledger") + voucher_totals = _final_side_family_amounts(group, "voucher") + withholding_ok = _family_side_amount_close(ledger_totals, voucher_totals, "withholding_payable") + bank_ok = _family_side_amount_close(ledger_totals, voucher_totals, "bank") + ledger_withholding = ledger_totals.get("withholding_payable", {}) + voucher_withholding = voucher_totals.get("withholding_payable", {}) + has_material_tax = max( + abs(ledger_withholding.get("debit", 0.0)), + abs(ledger_withholding.get("credit", 0.0)), + abs(voucher_withholding.get("debit", 0.0)), + abs(voucher_withholding.get("credit", 0.0)), + ) >= 0.5 + return bool(has_material_tax and withholding_ok and (bank_ok or _section_amount_sides_match(_refresh_group_summary_from_rows({"summary": dict(group.get("summary") or {}), "rows": list(group.get("rows") or [])})))) + + +def _final_group_search_text(group: Mapping[str, Any]) -> str: + summary = dict(group.get("summary") or {}) + parts: list[str] = [ + clean(summary.get("ledger_accounts")), + clean(summary.get("voucher_accounts")), + clean(summary.get("ledger_vendors")), + clean(summary.get("voucher_vendors")), + clean(summary.get("review_reason")), + ] + for row in list(group.get("rows") or []): + parts.extend( + [ + clean(row.get("ledger_account_name")), + clean(row.get("voucher_account_name")), + clean(row.get("ledger_vendor")), + clean(row.get("voucher_vendor")), + clean(row.get("ledger_desc")), + clean(row.get("voucher_desc")), + ] + ) + return normalize_text(" ".join(part for part in parts if part)) + + +def _financial_transfer_voucher_group_match(group: Mapping[str, Any]) -> bool: + if not _group_has_family(group, "bank", "ledger"): + return False + if not (_group_has_family(group, "bank", "voucher") and _group_has_family(group, "financial_asset", "voucher")): + return False + refreshed = _refresh_group_summary_from_rows({"summary": dict(group.get("summary") or {}), "rows": list(group.get("rows") or [])}) + if not _section_amount_sides_match(refreshed): + return False + if _group_has_service_month_conflict_for_guard(refreshed): + return False + ledger_totals = _final_side_family_amounts(group, "ledger") + voucher_totals = _final_side_family_amounts(group, "voucher") + financial_ok = ( + _family_side_amount_close(ledger_totals, voucher_totals, "financial_asset") + or _family_side_amount_close(ledger_totals, voucher_totals, "bank") + ) + bank_ok = _family_side_amount_close(ledger_totals, voucher_totals, "bank") + text = _final_group_search_text(group) + transfer_signal = any(token in text for token in ("법인통장간이체", "계좌이체", "기타제예금", "기자제예금", "cma", "rp", "단기사채", "정기예금")) + return bool(financial_ok and bank_ok and transfer_signal) + + +def _vehicle_rental_voucher_group_match(conn: Any, group: Mapping[str, Any]) -> bool: + if not (_group_has_family(group, "vehicle_rental_cost", "ledger") and _group_has_family(group, "vehicle_rental_cost", "voucher")): + return False + ledger_date = _final_group_ledger_date(group) + if ledger_date is None: + return False + exact_bases = _group_exact_proof_date_bases(conn, group) + if not exact_bases: + return False + ledger_totals = _final_side_family_amounts(group, "ledger") + voucher_totals = _final_side_family_amounts(group, "voucher") + cost_ok = _family_side_amount_close(ledger_totals, voucher_totals, "vehicle_rental_cost") + payable_ok = _family_side_amount_close(ledger_totals, voucher_totals, "payable") + return bool(cost_ok and (payable_ok or _section_amount_sides_match(_refresh_group_summary_from_rows({"summary": dict(group.get("summary") or {}), "rows": list(group.get("rows") or [])})))) + + +def _final_group_promote_reason(conn: Any, group: Mapping[str, Any]) -> str: + exact_bases = _group_exact_proof_date_bases(conn, group) + if exact_bases and _mixed_recheck_residual_is_covered_by_raw_erp(conn, _filter_group_to_draft_bases(group, exact_bases)): + return "EXACT_PROOF_DATE_RESIDUAL_COVERED_MATCH" + if _withholding_tax_voucher_group_match(group): + return "WITHHOLDING_TAX_ACCOUNT_GROUP_MATCH" + if _financial_transfer_voucher_group_match(group): + return "FINANCIAL_TRANSFER_ACCOUNT_GROUP_MATCH" + if _vehicle_rental_voucher_group_match(conn, group): + return "VEHICLE_RENTAL_VAT_EXCEPTION_GROUP_MATCH" + if _mixed_recheck_residual_is_covered_by_raw_erp(conn, group): + return "MIXED_RESIDUAL_COVERED_BY_ERP_DRAFT_MATCH" + return "" + + +def _raw_wehago_ledger_row_to_display_row(raw: Mapping[str, Any], status_label: str) -> dict[str, Any]: + fiscal_year = int(raw.get("fiscal_year") or 0) + payload = { + "fiscal_year": fiscal_year, + "status_label": status_label, + "ledger_date": normalize_wehago_display_date(raw.get("ledger_date"), fiscal_year or None), + "proof_date": "", + "voucher_no": clean(raw.get("voucher_no")), + "draft_no": "", + "ledger_account_code": clean(raw.get("account_code")), + "ledger_account_name": clean(raw.get("account_name")), + "ledger_vendor": clean(raw.get("vendor_name")), + "ledger_debit": parse_amount(raw.get("debit")), + "ledger_credit": parse_amount(raw.get("credit")), + "ledger_desc": clean(raw.get("description")), + "voucher_account_code": "", + "voucher_account_name": "", + "voucher_vendor": "", + "voucher_debit": 0.0, + "voucher_credit": 0.0, + "voucher_desc": "", + "review_reason": "FULL_WEHAGO_VOUCHER_CONTEXT", + "matched_case": "FULL_WEHAGO_CONTEXT_ROW", + "ledger_source_row_id": clean(raw.get("id")), + "ledger_source_file_id": clean(raw.get("source_file_id")), + "ledger_source_sheet_name": clean(raw.get("sheet_name")), + "ledger_source_row_number": clean(raw.get("row_number")), + "ledger_row_key": "", + "voucher_row_key": "", + "match_identity_key": "", + } + payload["ledger_row_key"] = build_ledger_row_key(payload) + payload["match_identity_key"] = payload["ledger_row_key"] + return payload + + +def _display_ledger_content_key(row: Mapping[str, Any]) -> tuple[Any, ...]: + fiscal_year = int(row.get("fiscal_year") or 0) + return ( + fiscal_year, + normalize_wehago_display_date(row.get("ledger_date"), fiscal_year or None), + clean(row.get("voucher_no")), + clean(row.get("ledger_account_name")), + clean(row.get("ledger_vendor")), + round(parse_amount(row.get("ledger_debit")), 2), + round(parse_amount(row.get("ledger_credit")), 2), + normalize_text(row.get("ledger_desc")), + ) + + +def _display_voucher_content_key(row: Mapping[str, Any]) -> tuple[Any, ...]: + return ( + clean(row.get("proof_date")), + clean(row.get("draft_no")), + clean(row.get("voucher_account_name")), + clean(row.get("voucher_vendor")), + round(parse_amount(row.get("voucher_debit")), 2), + round(parse_amount(row.get("voucher_credit")), 2), + normalize_text(row.get("voucher_desc")), + ) + + +def _blank_wehago_identity_for_erp_only_display(row: dict[str, Any]) -> dict[str, Any]: + payload = dict(row) + if not _row_has_wehago_ledger_side(payload): + payload["ledger_date"] = "" + payload["voucher_no"] = "" + return payload + + +def _display_row_amount_family_merge_score(ledger_row: Mapping[str, Any], voucher_row: Mapping[str, Any]) -> int: + if not _row_has_wehago_ledger_side(ledger_row) or _row_has_hanmac_candidate_side(ledger_row): + return -1 + if not _row_has_hanmac_candidate_side(voucher_row): + return -1 + ledger_amount = _get_row_match_amount(ledger_row, "ledger") + voucher_amount = _get_row_match_amount(voucher_row, "voucher") + if ledger_amount < 0.5 or abs(ledger_amount - voucher_amount) >= 0.5: + return -1 + ledger_family = _final_semantic_account_family(ledger_row.get("ledger_account_code"), ledger_row.get("ledger_account_name")) + voucher_family = _final_semantic_account_family(voucher_row.get("voucher_account_code"), voucher_row.get("voucher_account_name")) + score = 1 + if ledger_family == voucher_family and ledger_family != "other": + score += 5 + if _is_vat_family(ledger_family) and _is_vat_family(voucher_family): + score += 5 + if _text_related(ledger_row.get("ledger_vendor"), voucher_row.get("voucher_vendor")): + score += 2 + if _text_related(ledger_row.get("ledger_desc"), voucher_row.get("voucher_desc")): + score += 1 + if normalize_text(ledger_row.get("ledger_account_name")) and normalize_text(voucher_row.get("voucher_account_name")): + if _text_related(ledger_row.get("ledger_account_name"), voucher_row.get("voucher_account_name")): + score += 1 + return score + + +def _merge_erp_display_row_into_ledger_row(ledger_row: dict[str, Any], voucher_row: Mapping[str, Any]) -> dict[str, Any]: + merged = dict(ledger_row) + for field in ( + "proof_date", + "draft_no", + "voucher_account_code", + "voucher_account_name", + "voucher_vendor", + "voucher_debit", + "voucher_credit", + "voucher_desc", + "voucher_row_key", + ): + merged[field] = voucher_row.get(field, "") + merged["review_reason"] = " / ".join( + part + for part in [clean(ledger_row.get("review_reason")), clean(voucher_row.get("review_reason"))] + if part + ) + merged["matched_case"] = clean(ledger_row.get("matched_case")) or clean(voucher_row.get("matched_case")) or "FULL_ERP_CONTEXT_MERGED" + merged["match_identity_key"] = "|".join( + part + for part in [clean(merged.get("ledger_row_key")), clean(merged.get("voucher_row_key"))] + if part + ) + return merged + + +def _find_mergeable_ledger_only_row_index( + rows: list[dict[str, Any]], + voucher_row: Mapping[str, Any], + used_indices: set[int], +) -> int | None: + best_index: int | None = None + best_score = -1 + for index, row in enumerate(rows): + if index in used_indices: + continue + score = _display_row_amount_family_merge_score(row, voucher_row) + if score > best_score: + best_score = score + best_index = index + if best_score < 3: + return None + return best_index + + +def _raw_erp_display_proof_date(raw: Mapping[str, Any]) -> str: + proof_date = clean(raw.get("proof_date")) + if proof_date: + return proof_date + account_family = _final_semantic_account_family(raw.get("account_code"), raw.get("account_name")) + if account_family in {"vat_input", "vat_output", "payable", "receivable"}: + return "" + return _hanmac_voucher_date_from_numbers(raw.get("draft_no"), raw.get("confirmed_no")) + + +def _load_raw_wehago_rows_for_group(conn: Any, group: Mapping[str, Any]) -> list[dict[str, Any]]: + summary = dict(group.get("summary") or {}) + fiscal_year = int(summary.get("fiscal_year") or 0) + voucher_no = clean(summary.get("voucher_no")) + ledger_date = normalize_wehago_display_date(summary.get("ledger_date"), fiscal_year or None) + if fiscal_year <= 0 or not voucher_no: + return [] + rows = conn.execute( + text( + """ + SELECT id, source_file_id, sheet_name, row_number, fiscal_year, ledger_date, + voucher_no, compare_voucher_no, account_code, account_name, vendor_name, + debit, credit, description + FROM wehago_ledger_rows + WHERE fiscal_year = :fiscal_year + AND COALESCE(voucher_no, '') = :voucher_no + AND (:ledger_date = '' OR COALESCE(ledger_date, '') = :ledger_date) + ORDER BY ledger_date ASC, voucher_no ASC, row_number ASC, id ASC + """ + ), + {"fiscal_year": fiscal_year, "voucher_no": voucher_no, "ledger_date": ledger_date}, + ).mappings().all() + if rows: + return [dict(row) for row in rows] + return [ + dict(row) + for row in conn.execute( + text( + """ + SELECT id, source_file_id, sheet_name, row_number, fiscal_year, ledger_date, + voucher_no, compare_voucher_no, account_code, account_name, vendor_name, + debit, credit, description + FROM wehago_ledger_rows + WHERE fiscal_year = :fiscal_year + AND COALESCE(voucher_no, '') = :voucher_no + ORDER BY ledger_date ASC, voucher_no ASC, row_number ASC, id ASC + """ + ), + {"fiscal_year": fiscal_year, "voucher_no": voucher_no}, + ).mappings().all() + ] + + +def _load_final_projection_display_context( + conn: Any, + start_year: int | None, + end_year: int | None, + voucher_sections: Mapping[str, list[dict[str, Any]]], +) -> dict[str, Any]: + anchor_keys: set[tuple[int, str, str]] = set() + draft_bases: set[str] = set() + for status_key in ("voucher_matched", "voucher_recheck", "voucher_unmatched", "voucher_excepted"): + for group in list(voucher_sections.get(status_key) or []): + summary = dict(group.get("summary") or {}) + fiscal_year = int(summary.get("fiscal_year") or 0) + voucher_no = clean(summary.get("voucher_no")) + ledger_date = normalize_wehago_display_date(summary.get("ledger_date"), fiscal_year or None) + if fiscal_year > 0 and voucher_no: + anchor_keys.add((fiscal_year, ledger_date, voucher_no)) + draft_bases.update(_compact_group_draft_bases(group)) + + ledger_by_anchor: dict[tuple[int, str, str], list[dict[str, Any]]] = {} + if anchor_keys and start_year is not None and end_year is not None: + rows = conn.execute( + text( + """ + SELECT id, source_file_id, sheet_name, row_number, fiscal_year, ledger_date, + voucher_no, compare_voucher_no, account_code, account_name, vendor_name, + debit, credit, description + FROM wehago_ledger_rows + WHERE fiscal_year BETWEEN :start_year AND :end_year + ORDER BY fiscal_year ASC, ledger_date ASC, voucher_no ASC, row_number ASC, id ASC + """ + ), + {"start_year": int(start_year), "end_year": int(end_year)}, + ).mappings().all() + for row in rows: + raw = dict(row) + key = ( + int(raw.get("fiscal_year") or 0), + normalize_wehago_display_date(raw.get("ledger_date"), int(raw.get("fiscal_year") or 0) or None), + clean(raw.get("voucher_no")), + ) + if key in anchor_keys: + ledger_by_anchor.setdefault(key, []).append(raw) + + raw_erp_by_base: dict[str, list[dict[str, Any]]] = {} + for chunk in _chunked([{"base": base} for base in sorted(draft_bases)], 250): + bases = {clean(item.get("base")) for item in chunk if clean(item.get("base"))} + for raw in _load_raw_erp_rows_for_draft_bases(conn, bases): + base = _erp_draft_base(raw.get("draft_no") or raw.get("confirmed_no")) + if base: + raw_erp_by_base.setdefault(base, []).append(raw) + + raw_erp_by_proof_date: dict[str, list[dict[str, Any]]] = {} + if start_year is not None and end_year is not None: + erp_start_year, erp_end_year = get_hanmac_erp_candidate_year_range(start_year, end_year) + proof_rows = conn.execute( + text( + """ + SELECT fiscal_year, proof_date, confirmed_no, draft_no, account_code, account_name, + debit_supply, credit_supply, vendor_name, desc1, desc2, row_number, id + FROM wehago_voucher_rows + WHERE fiscal_year BETWEEN :start_year AND :end_year + AND COALESCE(proof_date, '') <> '' + AND COALESCE(draft_no, '') <> '' + ORDER BY proof_date ASC, draft_no ASC, row_number ASC, id ASC + """ + ), + {"start_year": int(erp_start_year or start_year), "end_year": int(erp_end_year or end_year)}, + ).mappings().all() + for row in proof_rows: + raw = dict(row) + proof_date = clean(raw.get("proof_date")) + if proof_date: + raw_erp_by_proof_date.setdefault(proof_date, []).append(raw) + base = _erp_draft_base(raw.get("draft_no") or raw.get("confirmed_no")) + if base: + raw_erp_by_base.setdefault(base, []).append(raw) + + return {"ledger_by_anchor": ledger_by_anchor, "raw_erp_by_base": raw_erp_by_base, "raw_erp_by_proof_date": raw_erp_by_proof_date} + + +def _context_exact_proof_date_bases(raw_erp_by_base: Mapping[str, list[dict[str, Any]]], group: Mapping[str, Any]) -> set[str]: + ledger_date = _final_group_ledger_date(group) + if ledger_date is None: + return set() + exact_bases: set[str] = set() + for base in _compact_group_draft_bases(group): + for raw in list(raw_erp_by_base.get(base) or []): + proof_date = _parse_iso_date(raw.get("proof_date")) + account_family = _final_semantic_account_family(raw.get("account_code"), raw.get("account_name")) + if proof_date == ledger_date and account_family not in {"vat_input", "vat_output"}: + exact_bases.add(base) + break + return exact_bases + + +def _raw_erp_candidate_amount(raw: Mapping[str, Any]) -> float: + return max(abs(parse_amount(raw.get("debit_supply"))), abs(parse_amount(raw.get("credit_supply")))) + + +def _raw_erp_candidate_text(raw: Mapping[str, Any]) -> str: + return " ".join([clean(raw.get("account_name")), clean(raw.get("vendor_name")), clean(raw.get("desc1")), clean(raw.get("desc2"))]) + + +def _select_external_exact_proof_date_bases( + context: Mapping[str, Any], + group: Mapping[str, Any], +) -> set[str]: + ledger_date = _final_group_ledger_date(group) + if ledger_date is None: + return set() + proof_key = ledger_date.isoformat() + raw_by_date = context.get("raw_erp_by_proof_date") if isinstance(context.get("raw_erp_by_proof_date"), dict) else {} + candidates = list(raw_by_date.get(proof_key) or []) + if not candidates: + return set() + ledger_rows = [ + dict(row) + for row in list(group.get("rows") or []) + if _row_has_wehago_ledger_side(row) + and _final_semantic_account_family(row.get("ledger_account_code"), row.get("ledger_account_name")) not in {"bank", "payable", "receivable", "advance"} + and _get_row_match_amount(row, "ledger") >= 0.5 + ] + if not ledger_rows: + return set() + current_bases = _compact_group_draft_bases(group) + scored: dict[str, float] = {} + for raw in candidates: + base = _erp_draft_base(raw.get("draft_no") or raw.get("confirmed_no")) + if not base: + continue + raw_amount = _raw_erp_candidate_amount(raw) + if raw_amount < 0.5: + continue + raw_family = _final_semantic_account_family(raw.get("account_code"), raw.get("account_name")) + raw_text = _raw_erp_candidate_text(raw) + for ledger_row in ledger_rows: + ledger_amount = _get_row_match_amount(ledger_row, "ledger") + if abs(ledger_amount - raw_amount) >= 0.5: + continue + ledger_family = _final_semantic_account_family(ledger_row.get("ledger_account_code"), ledger_row.get("ledger_account_name")) + score = 1.0 + if ledger_family == raw_family or _is_vat_family(raw_family): + score += 2.0 + if _text_related(ledger_row.get("ledger_desc"), raw_text) or _text_related(ledger_row.get("ledger_vendor"), raw.get("vendor_name")): + score += 2.0 + if base in current_bases: + score += 0.5 + scored[base] = max(scored.get(base, 0.0), score) + if not scored: + return set() + best_score = max(scored.values()) + if best_score < 3.0: + return set() + return {base for base, score in scored.items() if score == best_score} + + +def _ensure_raw_erp_bases_loaded( + conn: Any, + raw_erp_by_base: dict[str, list[dict[str, Any]]], + bases: set[str], +) -> dict[str, list[dict[str, Any]]]: + requested_bases = {clean(base) for base in bases if clean(base)} + if not requested_bases: + return raw_erp_by_base + seen_lines = { + base: {clean(raw.get("draft_no")) for raw in list(raw_erp_by_base.get(base) or []) if clean(raw.get("draft_no"))} + for base in requested_bases + } + for raw in _load_raw_erp_rows_for_draft_bases(conn, requested_bases): + base = _erp_draft_base(raw.get("draft_no") or raw.get("confirmed_no")) + draft_line = clean(raw.get("draft_no")) + if base and draft_line not in seen_lines.setdefault(base, set()): + raw_erp_by_base.setdefault(base, []).append(raw) + if draft_line: + seen_lines[base].add(draft_line) + return raw_erp_by_base + + +def _context_mixed_recheck_residual_is_covered_by_raw_erp( + raw_erp_by_base: Mapping[str, list[dict[str, Any]]], + group: Mapping[str, Any], +) -> bool: + rows = [dict(row) for row in list(group.get("rows") or [])] + residual_rows = [ + row + for row in rows + if _row_has_wehago_ledger_side(row) and not _row_has_hanmac_candidate_side(row) + ] + if not residual_rows: + return False + represented = {_draft_line_key(row.get("draft_no")) for row in rows if _row_has_hanmac_candidate_side(row)} + available_raw: list[dict[str, Any]] = [] + for base in _compact_group_draft_bases(group): + available_raw.extend( + raw + for raw in list(raw_erp_by_base.get(base) or []) + if _draft_line_key(raw.get("draft_no")) not in represented + ) + if not available_raw: + return False + residual_debit = round(sum(parse_amount(row.get("ledger_debit")) for row in residual_rows), 2) + residual_credit = round(sum(parse_amount(row.get("ledger_credit")) for row in residual_rows), 2) + residual_desc = " ".join(clean(row.get("ledger_desc")) for row in residual_rows) + residual_account = " ".join(clean(row.get("ledger_account_name")) for row in residual_rows) + for raw in available_raw: + raw_debit = round(parse_amount(raw.get("debit_supply")), 2) + raw_credit = round(parse_amount(raw.get("credit_supply")), 2) + amount_matches = ( + (abs(residual_debit) >= 0.5 and abs(raw_debit - residual_debit) < 0.5) + or (abs(residual_credit) >= 0.5 and abs(raw_credit - residual_credit) < 0.5) + ) + if not amount_matches: + continue + raw_text = " ".join([clean(raw.get("account_name")), clean(raw.get("desc1")), clean(raw.get("desc2"))]) + if _text_related(residual_desc, raw_text) or _text_related(residual_account, raw.get("account_name")): + return True + raw_debit_total = round(sum(parse_amount(raw.get("debit_supply")) for raw in available_raw), 2) + raw_credit_total = round(sum(parse_amount(raw.get("credit_supply")) for raw in available_raw), 2) + if ( + (abs(residual_debit) >= 0.5 and abs(raw_debit_total - residual_debit) < 0.5) + or (abs(residual_credit) >= 0.5 and abs(raw_credit_total - residual_credit) < 0.5) + ): + raw_account_text = " ".join(clean(raw.get("account_name")) for raw in available_raw) + raw_desc_text = " ".join(" ".join([clean(raw.get("desc1")), clean(raw.get("desc2"))]) for raw in available_raw) + if _text_related(residual_account, raw_account_text) or _text_related(residual_desc, raw_desc_text): + return True + return False + + +def _context_vehicle_rental_voucher_group_match( + raw_erp_by_base: Mapping[str, list[dict[str, Any]]], + group: Mapping[str, Any], +) -> bool: + if not (_group_has_family(group, "vehicle_rental_cost", "ledger") and _group_has_family(group, "vehicle_rental_cost", "voucher")): + return False + if not _context_exact_proof_date_bases(raw_erp_by_base, group): + return False + ledger_totals = _final_side_family_amounts(group, "ledger") + voucher_totals = _final_side_family_amounts(group, "voucher") + cost_ok = _family_side_amount_close(ledger_totals, voucher_totals, "vehicle_rental_cost") + payable_ok = _family_side_amount_close(ledger_totals, voucher_totals, "payable") + return bool(cost_ok and (payable_ok or _section_amount_sides_match(_refresh_group_summary_from_rows({"summary": dict(group.get("summary") or {}), "rows": list(group.get("rows") or [])})))) + + +def _row_direct_amount_match(row: Mapping[str, Any]) -> bool: + if not (_row_has_wehago_ledger_side(row) and _row_has_hanmac_candidate_side(row)): + return False + return ( + abs(parse_amount(row.get("ledger_debit")) - parse_amount(row.get("voucher_debit"))) < 0.5 + and abs(parse_amount(row.get("ledger_credit")) - parse_amount(row.get("voucher_credit"))) < 0.5 + and _get_row_match_amount(dict(row), "ledger") >= 0.5 + ) + + +def _row_has_negative_amount(row: Mapping[str, Any]) -> bool: + def signed_amount(value: Any) -> float: + text_value = clean(value).replace(",", "").strip() + if not text_value: + return 0.0 + if text_value.startswith("(") and text_value.endswith(")"): + return -parse_amount(text_value[1:-1]) + return parse_amount(text_value) + + return any( + signed_amount(row.get(field)) < -0.5 + for field in ("ledger_debit", "ledger_credit", "voucher_debit", "voucher_credit") + ) + + +def _signed_field_amount(value: Any) -> float: + text_value = clean(value).replace(",", "").strip() + if not text_value: + return 0.0 + if text_value.startswith("(") and text_value.endswith(")"): + return -parse_amount(text_value[1:-1]) + return parse_amount(text_value) + + +def _context_row_signed_amount(row: Mapping[str, Any], prefix: str) -> float: + return _signed_field_amount(row.get(f"{prefix}_debit")) - _signed_field_amount(row.get(f"{prefix}_credit")) + + +def _row_direct_signed_amount_match(row: Mapping[str, Any]) -> bool: + if not (_row_has_wehago_ledger_side(row) and _row_has_material_voucher_values(row)): + return False + ledger_signed = _context_row_signed_amount(row, "ledger") + voucher_signed = _context_row_signed_amount(row, "voucher") + return abs(ledger_signed) >= 0.5 and abs(ledger_signed - voucher_signed) < 0.5 + + +def _row_family(row: Mapping[str, Any], prefix: str) -> str: + return _final_semantic_account_family(row.get(f"{prefix}_account_code"), row.get(f"{prefix}_account_name")) + + +def _direct_ledger_amount_by_families(group: Mapping[str, Any], families: set[str]) -> float: + return round( + sum( + _get_row_match_amount(dict(row), "ledger") + for row in list(group.get("rows") or []) + if _row_direct_amount_match(row) and _row_family(row, "ledger") in families + ), + 2, + ) + + +def _ledger_side_amount_by_families(group: Mapping[str, Any], families: set[str]) -> float: + return round( + sum( + _get_row_match_amount(dict(row), "ledger") + for row in list(group.get("rows") or []) + if _row_has_wehago_ledger_side(row) and _row_family(row, "ledger") in families + ), + 2, + ) + + +def _has_strong_direct_cost_vat_context(group: Mapping[str, Any]) -> bool: + direct_rows = [row for row in list(group.get("rows") or []) if _row_direct_amount_match(row)] + if not direct_rows: + return False + has_vat = any(_is_vat_family(_row_family(row, "ledger")) and _is_vat_family(_row_family(row, "voucher")) for row in direct_rows) + has_cost_or_other = any( + _row_family(row, "ledger") not in {"bank", "payable", "receivable", "vat_input", "vat_output"} + and _row_family(row, "voucher") not in {"bank", "payable", "receivable", "vat_input", "vat_output"} + for row in direct_rows + ) + context = any( + _recheck_context_overlap(row, row) + for row in direct_rows + if clean(row.get("ledger_vendor")) or clean(row.get("ledger_desc")) + ) + return bool(has_cost_or_other and (has_vat or len(direct_rows) >= 2) and context) + + +def _has_direct_business_cost_context(group: Mapping[str, Any]) -> bool: + for row in list(group.get("rows") or []): + if not _row_direct_amount_match(row): + continue + ledger_family = _row_family(row, "ledger") + voucher_family = _row_family(row, "voucher") + if ledger_family in {"bank", "payable", "receivable", "vat_input", "vat_output"}: + continue + if voucher_family in {"bank", "payable", "receivable", "vat_input", "vat_output"}: + continue + if _recheck_context_overlap(row, row): + return True + return False + + +def _row_has_material_ledger_values(row: Mapping[str, Any]) -> bool: + return bool( + clean(row.get("ledger_account_name")) + or clean(row.get("ledger_vendor")) + or clean(row.get("ledger_desc")) + or abs(parse_amount(row.get("ledger_debit"))) >= 0.5 + or abs(parse_amount(row.get("ledger_credit"))) >= 0.5 + ) + + +def _row_has_material_voucher_values(row: Mapping[str, Any]) -> bool: + return bool( + clean(row.get("voucher_account_name")) + or clean(row.get("voucher_vendor")) + or clean(row.get("voucher_desc")) + or abs(parse_amount(row.get("voucher_debit"))) >= 0.5 + or abs(parse_amount(row.get("voucher_credit"))) >= 0.5 + ) + + +def _ledger_unmatched_amount_by_families(group: Mapping[str, Any], families: set[str]) -> float: + return round( + sum( + _get_row_match_amount(dict(row), "ledger") + for row in list(group.get("rows") or []) + if _row_has_wehago_ledger_side(row) + and not _row_has_material_voucher_values(row) + and _row_family(row, "ledger") in families + ), + 2, + ) + + +def _voucher_unmatched_amount_by_families(group: Mapping[str, Any], families: set[str]) -> float: + return round( + sum( + _get_row_match_amount(dict(row), "voucher") + for row in list(group.get("rows") or []) + if not _row_has_material_ledger_values(row) + and _row_has_hanmac_candidate_side(row) + and _row_family(row, "voucher") in families + and not _row_has_negative_amount(row) + ), + 2, + ) + + +def _context_lg_monthly_special_bundle_match( + raw_erp_by_base: Mapping[str, list[dict[str, Any]]], + group: Mapping[str, Any], +) -> str: + if not _context_exact_proof_date_bases(raw_erp_by_base, group): + return "" + refreshed = _refresh_group_summary_from_rows({"summary": dict(group.get("summary") or {}), "rows": list(group.get("rows") or [])}) + if _group_has_service_month_conflict_for_guard(refreshed): + return "" + text = _final_group_search_text(refreshed) + is_webhard = "엘지유플러스" in text and "웹하드" in text + is_air_purifier = "엘지전자" in text and "공기청정기" in text + if not (is_webhard or is_air_purifier): + return "" + if any(_row_has_negative_amount(row) for row in list(refreshed.get("rows") or [])): + return "" + if not (_has_strong_direct_cost_vat_context(refreshed) or _has_direct_business_cost_context(refreshed)): + return "" + ledger_total = max( + parse_amount(refreshed.get("summary", {}).get("ledger_debit")), + parse_amount(refreshed.get("summary", {}).get("ledger_credit")), + ) + voucher_total = max( + parse_amount(refreshed.get("summary", {}).get("voucher_debit")), + parse_amount(refreshed.get("summary", {}).get("voucher_credit")), + ) + if ledger_total < 0.5 or voucher_total < ledger_total - 0.5: + return "" + direct_cost_vat = _direct_ledger_amount_by_families(refreshed, {"vat_input", "vat_output", "other", "vehicle_rental_cost"}) + ledger_cost_vat = _ledger_side_amount_by_families(refreshed, {"vat_input", "vat_output", "other", "vehicle_rental_cost"}) + unmatched_ledger_tax_payable = _ledger_unmatched_amount_by_families(refreshed, {"vat_input", "vat_output", "payable", "receivable"}) + unmatched_voucher_tax_payable = _voucher_unmatched_amount_by_families(refreshed, {"vat_input", "vat_output", "payable", "receivable"}) + if direct_cost_vat < 0.5: + return "" + if abs(direct_cost_vat - ledger_cost_vat) >= 0.5 and unmatched_voucher_tax_payable < unmatched_ledger_tax_payable - 0.5: + return "" + return "LG_WEBHARD_MONTHLY_BUNDLE_MATCH" if is_webhard else "LG_AIR_PURIFIER_RENTAL_BUNDLE_MATCH" + + +def _context_group_has_exact_or_draft_date_base( + raw_erp_by_base: Mapping[str, list[dict[str, Any]]], + group: Mapping[str, Any], +) -> bool: + if _context_exact_proof_date_bases(raw_erp_by_base, group): + return True + ledger_date = _final_group_ledger_date(group) + if ledger_date is None: + return False + for base in _compact_group_draft_bases(group): + match = re.search(r"(20\d{2})(\d{2})(\d{2})", clean(base)) + if not match: + continue + try: + if date(int(match.group(1)), int(match.group(2)), int(match.group(3))) == ledger_date: + return True + except ValueError: + continue + return False + + +def _context_property_fee_wehago_fulfilled_match( + raw_erp_by_base: Mapping[str, list[dict[str, Any]]], + group: Mapping[str, Any], +) -> bool: + if not _context_exact_proof_date_bases(raw_erp_by_base, group): + return False + refreshed = _refresh_group_summary_from_rows({"summary": dict(group.get("summary") or {}), "rows": list(group.get("rows") or [])}) + text = _final_group_search_text(refreshed) + if "송파웰츠타워관리위원회" not in text or "관리비" not in text: + return False + if any(_row_has_negative_amount(row) for row in list(refreshed.get("rows") or [])): + return False + ledger_debit = parse_amount(refreshed.get("summary", {}).get("ledger_debit")) + direct_debit = round( + sum(parse_amount(row.get("ledger_debit")) for row in list(refreshed.get("rows") or []) if _row_direct_amount_match(row)), + 2, + ) + if ledger_debit < 0.5 or abs(ledger_debit - direct_debit) >= 0.5: + return False + unmatched_ledger = [ + row for row in list(refreshed.get("rows") or []) + if _row_has_wehago_ledger_side(row) and not _row_has_material_voucher_values(row) + ] + if not unmatched_ledger: + return False + if not all(_row_family(row, "ledger") in {"payable", "receivable", "bank"} for row in unmatched_ledger): + return False + unmatched_ledger_amount = _ledger_unmatched_amount_by_families(refreshed, {"payable", "receivable", "bank"}) + unmatched_voucher_amount = _voucher_unmatched_amount_by_families(refreshed, {"payable", "receivable", "bank", "other"}) + return unmatched_voucher_amount >= unmatched_ledger_amount - 0.5 + + +def _context_positive_tax_invoice_with_adjustment_match( + raw_erp_by_base: Mapping[str, list[dict[str, Any]]], + group: Mapping[str, Any], +) -> bool: + if not _context_exact_proof_date_bases(raw_erp_by_base, group): + return False + refreshed = _refresh_group_summary_from_rows({"summary": dict(group.get("summary") or {}), "rows": list(group.get("rows") or [])}) + rows = [dict(row) for row in list(refreshed.get("rows") or [])] + negative_voucher_rows = [ + row for row in rows + if not _row_has_material_ledger_values(row) + and _row_has_hanmac_candidate_side(row) + and _row_has_negative_amount(row) + ] + if not negative_voucher_rows: + return False + if any(_row_has_negative_amount(row) for row in rows if _row_has_wehago_ledger_side(row)): + return False + positive_unmatched_voucher = [ + row for row in rows + if not _row_has_material_ledger_values(row) + and _row_has_hanmac_candidate_side(row) + and not _row_has_negative_amount(row) + and _get_row_match_amount(row, "voucher") >= 0.5 + ] + if positive_unmatched_voucher: + return False + direct_rows = [row for row in rows if _row_direct_amount_match(row)] + if not direct_rows: + return False + direct_debit = round(sum(parse_amount(row.get("ledger_debit")) for row in direct_rows), 2) + ledger_debit = parse_amount(refreshed.get("summary", {}).get("ledger_debit")) + ledger_credit = parse_amount(refreshed.get("summary", {}).get("ledger_credit")) + if ledger_debit < 0.5 or abs(direct_debit - ledger_debit) >= 0.5: + return False + unmatched_ledger = [ + row for row in rows + if _row_has_wehago_ledger_side(row) and not _row_has_material_voucher_values(row) + ] + if not unmatched_ledger or abs(sum(parse_amount(row.get("ledger_credit")) for row in unmatched_ledger) - ledger_credit) >= 0.5: + return False + if not all(_row_family(row, "ledger") in {"payable", "receivable"} for row in unmatched_ledger): + return False + has_vat = any(_is_vat_family(_row_family(row, "ledger")) and _is_vat_family(_row_family(row, "voucher")) for row in direct_rows) + has_cost = any(_row_family(row, "ledger") not in {"vat_input", "vat_output", "payable", "receivable", "bank"} for row in direct_rows) + if not (has_vat and has_cost): + return False + return any( + _recheck_context_overlap(row, row) + or _text_related(row.get("ledger_vendor"), row.get("voucher_vendor")) + or _text_related(row.get("ledger_account_name"), row.get("voucher_account_name")) + or _text_related(row.get("ledger_desc"), row.get("voucher_desc")) + for row in direct_rows + ) + + +def _context_signed_tax_invoice_adjustment_match( + raw_erp_by_base: Mapping[str, list[dict[str, Any]]], + group: Mapping[str, Any], +) -> bool: + if not _context_group_has_exact_or_draft_date_base(raw_erp_by_base, group): + return False + refreshed = _refresh_group_summary_from_rows({"summary": dict(group.get("summary") or {}), "rows": list(group.get("rows") or [])}) + rows = [dict(row) for row in list(refreshed.get("rows") or [])] + direct_rows = [row for row in rows if _row_direct_signed_amount_match(row)] + if len(direct_rows) < 2: + return False + direct_families = {(_row_family(row, "ledger"), _row_family(row, "voucher")) for row in direct_rows} + has_sales_adjustment = ("sales", "sales") in direct_families and ("vat_output", "vat_output") in direct_families + has_purchase_adjustment = ( + ("vat_input", "vat_input") in direct_families + and any( + left not in {"vat_input", "vat_output", "payable", "receivable", "bank"} + and right not in {"vat_input", "vat_output", "payable", "receivable", "bank"} + for left, right in direct_families + ) + ) + if not (has_sales_adjustment or has_purchase_adjustment): + return False + if any(_row_sides_have_opposite_signed_amount(dict(row)) for row in direct_rows): + return False + if not any(_row_has_negative_amount(row) for row in rows if _row_has_wehago_ledger_side(row)): + return False + direct_ledger_signed = round(sum(_context_row_signed_amount(row, "ledger") for row in direct_rows), 2) + direct_voucher_signed = round(sum(_context_row_signed_amount(row, "voucher") for row in direct_rows), 2) + if abs(direct_ledger_signed - direct_voucher_signed) >= 0.5: + return False + unmatched_ledger = [ + row for row in rows + if _row_has_wehago_ledger_side(row) and not _row_has_material_voucher_values(row) + ] + unmatched_voucher = [ + row for row in rows + if _row_has_material_voucher_values(row) and not _row_has_material_ledger_values(row) + ] + if not unmatched_ledger or not unmatched_voucher: + return False + if not all(_row_family(row, "ledger") in {"payable", "receivable"} for row in unmatched_ledger): + return False + if not all(_row_family(row, "voucher") in {"payable", "receivable"} for row in unmatched_voucher): + return False + ledger_clearing = round(sum(_context_row_signed_amount(row, "ledger") for row in unmatched_ledger), 2) + voucher_clearing = round(sum(_context_row_signed_amount(row, "voucher") for row in unmatched_voucher), 2) + if abs(direct_ledger_signed + ledger_clearing) >= 0.5: + return False + if abs(direct_voucher_signed + voucher_clearing) >= 0.5: + return False + return any( + _recheck_context_overlap(row, row) + or _text_related(row.get("ledger_vendor"), row.get("voucher_vendor")) + or _text_related(row.get("ledger_desc"), row.get("voucher_desc")) + for row in direct_rows + ) + + +def _context_guarantee_insurance_group_match( + raw_erp_by_base: Mapping[str, list[dict[str, Any]]], + group: Mapping[str, Any], +) -> bool: + refreshed = _refresh_group_summary_from_rows({"summary": dict(group.get("summary") or {}), "rows": list(group.get("rows") or [])}) + text = _final_group_search_text(refreshed) + if not any( + token in text + for token in ("보증수수료", "보증보험", "공제조합", "계약보험료", "손해배상보증보험", "선급보증보험", "선급급보증보험", "하자보험료") + ): + return False + rows = [dict(row) for row in list(refreshed.get("rows") or [])] + direct_rows = [ + row for row in rows + if _row_direct_amount_match(row) + and _row_family(row, "ledger") == "guarantee_insurance" + and _row_family(row, "voucher") == "guarantee_insurance" + ] + if not direct_rows: + return False + ledger_cost = round(sum(_get_row_match_amount(row, "ledger") for row in direct_rows), 2) + voucher_cost = round(sum(_get_row_match_amount(row, "voucher") for row in direct_rows), 2) + if ledger_cost < 0.5 or abs(ledger_cost - voucher_cost) >= 0.5: + return False + ledger_bank = _ledger_unmatched_amount_by_families(refreshed, {"bank"}) + voucher_clearing = _voucher_unmatched_amount_by_families(refreshed, {"payable", "bank"}) + if ledger_bank < ledger_cost - 0.5: + return False + if voucher_clearing < ledger_cost - 0.5: + return False + return any( + _text_related(row.get("ledger_vendor"), row.get("voucher_vendor")) + or _text_related(row.get("ledger_desc"), row.get("voucher_desc")) + for row in direct_rows + ) + + +def _context_aggregated_vat_with_adjustment_match( + raw_erp_by_base: Mapping[str, list[dict[str, Any]]], + group: Mapping[str, Any], +) -> bool: + if not _context_group_has_exact_or_draft_date_base(raw_erp_by_base, group): + return False + refreshed = _refresh_group_summary_from_rows({"summary": dict(group.get("summary") or {}), "rows": list(group.get("rows") or [])}) + rows = [dict(row) for row in list(refreshed.get("rows") or [])] + direct_cost_rows = [ + row for row in rows + if _row_direct_amount_match(row) + and _row_family(row, "ledger") not in {"vat_input", "vat_output", "payable", "receivable", "bank"} + and _row_family(row, "voucher") not in {"vat_input", "vat_output", "payable", "receivable", "bank"} + and ( + _text_related(row.get("ledger_vendor"), row.get("voucher_vendor")) + or _text_related(row.get("ledger_desc"), row.get("voucher_desc")) + ) + ] + if not direct_cost_rows: + return False + ledger_vat = _ledger_unmatched_amount_by_families(refreshed, {"vat_input", "vat_output"}) + ledger_payable = _ledger_unmatched_amount_by_families(refreshed, {"payable", "receivable"}) + voucher_vat = _voucher_unmatched_amount_by_families(refreshed, {"vat_input", "vat_output"}) + negative_voucher_amount = round( + sum( + abs(_context_row_signed_amount(row, "voucher")) + for row in rows + if not _row_has_material_ledger_values(row) + and _row_has_material_voucher_values(row) + and _row_has_negative_amount(row) + ), + 2, + ) + if ledger_vat < 0.5 or ledger_payable < 0.5: + return False + if voucher_vat < ledger_vat - 0.5: + return False + if negative_voucher_amount < ledger_payable - 0.5: + return False + return True + + +def _context_payable_payment_bundle_line_match( + raw_erp_by_base: Mapping[str, list[dict[str, Any]]], + group: Mapping[str, Any], +) -> bool: + if not _context_group_has_exact_or_draft_date_base(raw_erp_by_base, group): + return False + refreshed = _refresh_group_summary_from_rows({"summary": dict(group.get("summary") or {}), "rows": list(group.get("rows") or [])}) + if _group_has_service_month_conflict_for_guard(refreshed): + return False + if any(_row_has_negative_amount(row) for row in list(refreshed.get("rows") or [])): + return False + if _context_wehago_cost_against_erp_payment_bridge_only(refreshed): + return False + rows = [dict(row) for row in list(refreshed.get("rows") or [])] + direct_payable = [ + row + for row in rows + if _row_direct_amount_match(row) + and _row_family(row, "ledger") in {"payable", "receivable"} + and _row_family(row, "voucher") in {"payable", "receivable"} + and _get_row_match_amount(row, "ledger") >= 0.5 + and (_text_related(row.get("ledger_vendor"), row.get("voucher_vendor")) or _recheck_context_overlap(row, row)) + ] + if not direct_payable: + return False + direct_amount = round(sum(_get_row_match_amount(row, "ledger") for row in direct_payable), 2) + ledger_bank_amount = _ledger_unmatched_amount_by_families(refreshed, {"bank"}) + if ledger_bank_amount < 0.5 or abs(ledger_bank_amount - direct_amount) >= 0.5: + return False + voucher_bank_amount = _voucher_unmatched_amount_by_families(refreshed, {"bank"}) + if voucher_bank_amount < ledger_bank_amount - 0.5: + return False + ledger_totals = _final_side_family_amounts(refreshed, "ledger") + ledger_bank = ledger_totals.get("bank", {}) + has_bank_credit = ledger_bank.get("credit", 0.0) >= 0.5 + has_bank_debit = ledger_bank.get("debit", 0.0) >= 0.5 + if not (has_bank_credit or has_bank_debit): + return False + return True + + +def _context_row_text(row: Mapping[str, Any], prefix: str | None = None) -> str: + if prefix: + fields = ( + f"{prefix}_account_name", + f"{prefix}_vendor", + f"{prefix}_desc", + ) + else: + fields = ( + "ledger_account_name", + "ledger_vendor", + "ledger_desc", + "voucher_account_name", + "voucher_vendor", + "voucher_desc", + ) + return normalize_text(" ".join(clean(row.get(field)) for field in fields)) + + +def _is_final_business_family(family: str) -> bool: + return bool(family and family not in {"bank", "payable", "receivable", "advance", "vat_input", "vat_output"}) + + +def _context_wehago_cost_against_erp_payment_bridge_only(group: Mapping[str, Any]) -> bool: + rows = [dict(row) for row in list(group.get("rows") or [])] + ledger_business = round( + sum( + _get_row_match_amount(row, "ledger") + for row in rows + if _row_has_wehago_ledger_side(row) and _is_final_business_family(_row_family(row, "ledger")) + ), + 2, + ) + if ledger_business < 0.5: + return False + voucher_payment = round( + sum( + _get_row_match_amount(row, "voucher") + for row in rows + if _row_has_material_voucher_values(row) and _row_family(row, "voucher") in {"bank", "payable"} + ), + 2, + ) + if voucher_payment < ledger_business - 0.5: + return False + direct_business = round( + sum( + _get_row_match_amount(row, "ledger") + for row in rows + if _row_direct_amount_match(row) + and _is_final_business_family(_row_family(row, "ledger")) + and _is_final_business_family(_row_family(row, "voucher")) + ), + 2, + ) + return direct_business < ledger_business - 0.5 + + +def _context_tax_offset_partial_counterpart_recheck(group: Mapping[str, Any]) -> bool: + refreshed = _refresh_group_summary_from_rows({"summary": dict(group.get("summary") or {}), "rows": list(group.get("rows") or [])}) + rows = [dict(row) for row in list(refreshed.get("rows") or [])] + ledger_families = { + _row_family(row, "ledger") + for row in rows + if _row_has_wehago_ledger_side(row) and _get_row_match_amount(row, "ledger") >= 0.5 + } + if not ({"vat_input", "vat_output"} <= ledger_families): + return False + if not ({"receivable", "payable"} & ledger_families): + return False + has_direct_counterpart = any( + _row_direct_amount_match(row) + and _row_family(row, "ledger") in {"receivable", "payable"} + and _row_family(row, "voucher") in {"receivable", "payable"} + for row in rows + ) + if not has_direct_counterpart: + return False + return not _section_amount_sides_match(refreshed) + + +def _amount_by_row_predicate(rows: list[dict[str, Any]], prefix: str, predicate: Callable[[dict[str, Any]], bool]) -> float: + return round( + sum(_get_row_match_amount(row, prefix) for row in rows if predicate(row)), + 2, + ) + + +def _context_receivable_bank_fee_net_settlement_match( + raw_erp_by_base: Mapping[str, list[dict[str, Any]]], + group: Mapping[str, Any], +) -> bool: + refreshed = _refresh_group_summary_from_rows({"summary": dict(group.get("summary") or {}), "rows": list(group.get("rows") or [])}) + if _group_has_service_month_conflict_for_guard(refreshed): + return False + rows = [dict(row) for row in list(refreshed.get("rows") or [])] + direct_receivable = [ + row + for row in rows + if _row_direct_amount_match(row) + and _row_family(row, "ledger") in {"receivable", "payable"} + and _row_family(row, "voucher") in {"receivable", "payable"} + and ( + _text_related(row.get("ledger_vendor"), row.get("voucher_vendor")) + or _text_related(row.get("ledger_desc"), row.get("voucher_desc")) + or _text_related(row.get("ledger_account_name"), row.get("voucher_account_name")) + ) + ] + if not direct_receivable: + return False + ledger_bank = _amount_by_row_predicate( + rows, + "ledger", + lambda row: _row_has_wehago_ledger_side(row) and _row_family(row, "ledger") == "bank", + ) + voucher_bank = _amount_by_row_predicate( + rows, + "voucher", + lambda row: _row_has_material_voucher_values(row) and _row_family(row, "voucher") == "bank", + ) + bank_diff = abs(ledger_bank - voucher_bank) + if bank_diff < 0.5: + return False + + fee_tokens = ("수입인지", "인지대", "세금과공과", "지급수수료", "수수료") + fee_amount = round( + sum( + max(_get_row_match_amount(row, "ledger"), _get_row_match_amount(row, "voucher")) + for row in rows + if any(token in _context_row_text(row) for token in fee_tokens) + ), + 2, + ) + if fee_amount < 0.5 or abs(bank_diff - fee_amount) >= 0.5: + return False + return any(_recheck_context_overlap(row, row) or _text_related(row.get("ledger_vendor"), row.get("voucher_vendor")) for row in direct_receivable) + + +def _is_misc_income_loss_row(row: Mapping[str, Any], prefix: str) -> bool: + text = _context_row_text(row, prefix) + return "잡이익" in text or "잡손실" in text + + +def _context_minor_misc_income_loss_residual_match( + raw_erp_by_base: Mapping[str, list[dict[str, Any]]], + group: Mapping[str, Any], + tolerance: float = 1000.0, +) -> bool: + refreshed = _refresh_group_summary_from_rows({"summary": dict(group.get("summary") or {}), "rows": list(group.get("rows") or [])}) + if _group_has_service_month_conflict_for_guard(refreshed): + return False + rows = [dict(row) for row in list(refreshed.get("rows") or [])] + core_direct_rows = [ + row + for row in rows + if _row_direct_amount_match(row) + and not _is_misc_income_loss_row(row, "ledger") + and not _is_misc_income_loss_row(row, "voucher") + and ( + _row_family(row, "ledger") == _row_family(row, "voucher") + or _is_vat_family(_row_family(row, "ledger")) and _is_vat_family(_row_family(row, "voucher")) + or _text_related(row.get("ledger_vendor"), row.get("voucher_vendor")) + or _text_related(row.get("ledger_desc"), row.get("voucher_desc")) + ) + ] + if len(core_direct_rows) < 2: + return False + + residual_rows = [ + row + for row in rows + if ( + (_row_has_wehago_ledger_side(row) and not _row_has_material_voucher_values(row)) + or (_row_has_material_voucher_values(row) and not _row_has_wehago_ledger_side(row)) + or (_row_has_wehago_ledger_side(row) and _row_has_material_voucher_values(row) and _row_sides_have_opposite_signed_amount(row)) + ) + ] + misc_rows = [ + row + for row in residual_rows + if _is_misc_income_loss_row(row, "ledger") or _is_misc_income_loss_row(row, "voucher") + ] + if not misc_rows or len(misc_rows) != len(residual_rows): + return False + ledger_misc = round(sum(abs(_context_row_signed_amount(row, "ledger")) for row in misc_rows), 2) + voucher_misc = round(sum(abs(_context_row_signed_amount(row, "voucher")) for row in misc_rows), 2) + if max(ledger_misc, voucher_misc) > tolerance: + return False + return abs(ledger_misc - voucher_misc) < 0.5 + + +def _context_exact_proof_date_voucher_group_match( + raw_erp_by_base: Mapping[str, list[dict[str, Any]]], + group: Mapping[str, Any], +) -> bool: + if not _context_exact_proof_date_bases(raw_erp_by_base, group): + return False + refreshed = _refresh_group_summary_from_rows( + {"summary": dict(group.get("summary") or {}), "rows": list(group.get("rows") or [])} + ) + if not _section_amount_sides_match(refreshed): + return False + if _group_has_service_month_conflict_for_guard(refreshed): + return False + if _group_has_tax_anchor_date_conflict_for_guard(refreshed): + return False + rows = [dict(row) for row in list(refreshed.get("rows") or [])] + if not any(_row_has_wehago_ledger_side(row) for row in rows): + return False + if not any(_row_has_hanmac_candidate_side(row) for row in rows): + return False + + ledger_families = { + _final_semantic_account_family(row.get("ledger_account_code"), row.get("ledger_account_name")) + for row in rows + if _row_has_wehago_ledger_side(row) and _get_row_match_amount(row, "ledger") >= 0.5 + } + voucher_families = { + _final_semantic_account_family(row.get("voucher_account_code"), row.get("voucher_account_name")) + for row in rows + if _row_has_hanmac_candidate_side(row) and _get_row_match_amount(row, "voucher") >= 0.5 + } + meaningful_family_match = bool((ledger_families - {"other"}) & (voucher_families - {"other"})) + tax_invoice_family_match = bool( + {"vat_input", "payable"} & ledger_families + and {"vat_input", "payable"} & voucher_families + ) + text_match = any( + _recheck_context_overlap(row, row) + for row in rows + if _row_has_wehago_ledger_side(row) and _row_has_hanmac_candidate_side(row) + ) + if meaningful_family_match or tax_invoice_family_match or text_match: + return True + + ledger_text = " ".join( + " ".join([clean(row.get("ledger_vendor")), clean(row.get("ledger_desc")), clean(row.get("ledger_account_name"))]) + for row in rows + if _row_has_wehago_ledger_side(row) + ) + voucher_text = " ".join( + " ".join([clean(row.get("voucher_vendor")), clean(row.get("voucher_desc")), clean(row.get("voucher_account_name"))]) + for row in rows + if _row_has_hanmac_candidate_side(row) + ) + return _text_related(ledger_text, voucher_text) + + +def _context_final_group_promote_reason( + raw_erp_by_base: Mapping[str, list[dict[str, Any]]], + group: Mapping[str, Any], +) -> str: + if _context_tax_offset_partial_counterpart_recheck(group): + return "" + exact_bases = _context_exact_proof_date_bases(raw_erp_by_base, group) + if exact_bases and _context_mixed_recheck_residual_is_covered_by_raw_erp(raw_erp_by_base, _filter_group_to_draft_bases(group, exact_bases)): + return "EXACT_PROOF_DATE_RESIDUAL_COVERED_MATCH" + lg_reason = _context_lg_monthly_special_bundle_match(raw_erp_by_base, group) + if lg_reason: + return lg_reason + if _context_property_fee_wehago_fulfilled_match(raw_erp_by_base, group): + return "WEHAGO_FULFILLED_PROPERTY_FEE_MATCH" + if _context_positive_tax_invoice_with_adjustment_match(raw_erp_by_base, group): + return "POSITIVE_TAX_INVOICE_WITH_ERP_ADJUSTMENT_MATCH" + if _context_signed_tax_invoice_adjustment_match(raw_erp_by_base, group): + return "SIGNED_TAX_INVOICE_ADJUSTMENT_MATCH" + if _context_guarantee_insurance_group_match(raw_erp_by_base, group): + return "GUARANTEE_INSURANCE_GROUP_MATCH" + if _context_aggregated_vat_with_adjustment_match(raw_erp_by_base, group): + return "AGGREGATED_VAT_WITH_ADJUSTMENT_MATCH" + if _context_receivable_bank_fee_net_settlement_match(raw_erp_by_base, group): + return "RECEIVABLE_BANK_FEE_NET_SETTLEMENT_MATCH" + if _context_minor_misc_income_loss_residual_match(raw_erp_by_base, group): + return "MINOR_MISC_INCOME_LOSS_RESIDUAL_MATCH" + if _context_payable_payment_bundle_line_match(raw_erp_by_base, group): + return "PAYABLE_PAYMENT_BUNDLE_LINE_MATCH" + if _withholding_tax_voucher_group_match(group): + return "WITHHOLDING_TAX_ACCOUNT_GROUP_MATCH" + if _financial_transfer_voucher_group_match(group): + return "FINANCIAL_TRANSFER_ACCOUNT_GROUP_MATCH" + if _context_vehicle_rental_voucher_group_match(raw_erp_by_base, group): + return "VEHICLE_RENTAL_VAT_EXCEPTION_GROUP_MATCH" + if _context_exact_proof_date_voucher_group_match(raw_erp_by_base, group): + return "EXACT_PROOF_DATE_VOUCHER_GROUP_MATCH" + if _context_mixed_recheck_residual_is_covered_by_raw_erp(raw_erp_by_base, group): + return "MIXED_RESIDUAL_COVERED_BY_ERP_DRAFT_MATCH" + return "" + + +def _context_final_group_hold_recheck_reason(group: Mapping[str, Any]) -> str: + if _context_tax_offset_partial_counterpart_recheck(group): + return "TAX_OFFSET_PARTIAL_COUNTERPART_RECHECK" + return "" + + +def _context_card_payment_bridge_partial_recheck(group: Mapping[str, Any]) -> str: + refreshed = _refresh_group_summary_from_rows({"summary": dict(group.get("summary") or {}), "rows": list(group.get("rows") or [])}) + rows = [dict(row) for row in list(refreshed.get("rows") or [])] + negative_business_direct = [ + row + for row in rows + if _row_direct_signed_amount_match(row) + and _is_final_business_family(_row_family(row, "ledger")) + and _is_final_business_family(_row_family(row, "voucher")) + and _row_has_negative_amount(row) + ] + if not negative_business_direct: + return "" + bank_direct = [ + row + for row in rows + if _row_direct_amount_match(row) + and _row_family(row, "ledger") == "bank" + and _row_family(row, "voucher") == "bank" + ] + if not bank_direct: + return "" + negative_amount = round(sum(abs(_context_row_signed_amount(row, "ledger")) for row in negative_business_direct), 2) + bank_amount = round(sum(_get_row_match_amount(row, "ledger") for row in bank_direct), 2) + if negative_amount < 0.5 or bank_amount < max(negative_amount * 5, 100000): + return "" + return "CARD_PAYMENT_BRIDGE_PARTIAL_COST_ADJUSTMENT_RECHECK" + + +def _group_has_material_hanmac_side(group: Mapping[str, Any]) -> bool: + summary = dict(group.get("summary") or {}) + if clean(summary.get("voucher_accounts")): + return True + if abs(parse_amount(summary.get("voucher_debit"))) >= 0.5 or abs(parse_amount(summary.get("voucher_credit"))) >= 0.5: + return True + return any(_row_has_material_voucher_values(row) for row in list(group.get("rows") or [])) + + +def _group_direct_compatible_row_count(group: Mapping[str, Any]) -> int: + count = 0 + for row in list(group.get("rows") or []): + if not (_row_has_wehago_ledger_side(row) and _row_has_material_voucher_values(row)): + continue + if _row_sides_have_opposite_signed_amount(dict(row)): + continue + ledger_family = _row_family(row, "ledger") + voucher_family = _row_family(row, "voucher") + if ledger_family == voucher_family and ledger_family != "other": + count += 1 + continue + if _row_direct_amount_match(row) and ( + _account_base_names_compatible(row.get("ledger_account_name"), row.get("voucher_account_name")) + or _text_related(row.get("ledger_vendor"), row.get("voucher_vendor")) + or _text_related(row.get("ledger_desc"), row.get("voucher_desc")) + ): + count += 1 + return count + + +def _group_has_only_invalid_opposite_direction_candidates(group: Mapping[str, Any]) -> bool: + paired_rows = [ + dict(row) + for row in list(group.get("rows") or []) + if _row_has_wehago_ledger_side(row) and _row_has_material_voucher_values(row) + ] + if not paired_rows: + return False + return all(_row_sides_have_opposite_signed_amount(row) for row in paired_rows) + + +def _group_draft_date_distance(group: Mapping[str, Any]) -> int: + ledger_date = _final_group_ledger_date(group) + if ledger_date is None: + return 999999 + distances: list[int] = [] + for base in _compact_group_draft_bases(group): + match = re.search(r"(20\d{2})(\d{2})(\d{2})", clean(base)) + if not match: + continue + try: + draft_date = date(int(match.group(1)), int(match.group(2)), int(match.group(3))) + except ValueError: + continue + distances.append(abs((ledger_date - draft_date).days)) + return min(distances) if distances else 999999 + + +def _group_has_vat_ledger_anchor(group: Mapping[str, Any]) -> bool: + return any( + _is_vat_family(_row_family(row, "ledger")) + for row in list(group.get("rows") or []) + if _row_has_wehago_ledger_side(row) + ) + + +def _recheck_group_should_demote_to_unmatched(group: Mapping[str, Any]) -> str: + if not _group_has_material_hanmac_side(group): + return "RECHECK_WITHOUT_MATERIAL_ERP_TO_UNMATCHED" + if _group_has_only_invalid_opposite_direction_candidates(group): + return "RECHECK_ONLY_OPPOSITE_SIGN_CANDIDATES_TO_UNMATCHED" + if _group_has_vat_ledger_anchor(group) and _group_draft_date_distance(group) > 45: + return "RECHECK_VAT_DRAFT_DATE_DISTANCE_TO_UNMATCHED" + if _group_direct_compatible_row_count(group) == 0: + summary = dict(group.get("summary") or {}) + text = _final_group_search_text(group) + has_bridge_hint = any(token in text for token in ("현대카드", "국민카드", "법인카드", "카드", "상계", "부가세", "세무서", "공제조합", "보험", "미수금", "미지급금")) + if parse_amount(summary.get("voucher_debit")) >= max(parse_amount(summary.get("ledger_debit")) * 10, 100000): + return "RECHECK_WITHOUT_DIRECT_COMPATIBLE_ROW_TO_UNMATCHED" + if not has_bridge_hint and parse_amount(summary.get("voucher_debit")) >= parse_amount(summary.get("ledger_debit")) * 3: + return "RECHECK_WITHOUT_DIRECT_COMPATIBLE_ROW_TO_UNMATCHED" + return "" + + +def _complete_wehago_voucher_display_group_with_context( + context: Mapping[str, Any], + group: Mapping[str, Any], + status_key: str, +) -> dict[str, Any] | None: + status_label = _final_compact_group_status_label(status_key) + rows = [dict(row) for row in list(group.get("rows") or [])] + summary = dict(group.get("summary") or {}) + anchor = _wehago_anchor_key_from_parts(summary.get("fiscal_year"), summary.get("ledger_date"), summary.get("voucher_no")) + seen_ledger = { + clean(row.get("ledger_row_key")) or build_ledger_row_key(row) + for row in rows + if _row_has_wehago_ledger_side(row) + } + seen_ledger_content = { + _display_ledger_content_key(row) + for row in rows + if _row_has_wehago_ledger_side(row) + } + ledger_by_anchor = context.get("ledger_by_anchor") if isinstance(context.get("ledger_by_anchor"), dict) else {} + for raw in list(ledger_by_anchor.get(anchor) or []): + display_row = _raw_wehago_ledger_row_to_display_row(raw, status_label) + row_key = clean(display_row.get("ledger_row_key")) + content_key = _display_ledger_content_key(display_row) + if row_key and row_key not in seen_ledger and content_key not in seen_ledger_content: + rows.append(display_row) + seen_ledger.add(row_key) + seen_ledger_content.add(content_key) + + raw_erp_by_base = context.get("raw_erp_by_base") if isinstance(context.get("raw_erp_by_base"), dict) else {} + bases = _compact_group_draft_bases({"summary": summary, "rows": rows}) + exact_bases = _context_exact_proof_date_bases(raw_erp_by_base, {"summary": summary, "rows": rows}) + if exact_bases: + bases = exact_bases + seen_voucher = { + clean(row.get("voucher_row_key")) or build_voucher_row_key(row) + for row in rows + if _row_has_hanmac_candidate_side(row) + } + seen_draft_lines = { + clean(row.get("draft_no")) + for row in rows + if _row_has_hanmac_candidate_side(row) and clean(row.get("draft_no")) + } + seen_voucher_content = { + _display_voucher_content_key(row) + for row in rows + if _row_has_hanmac_candidate_side(row) + } + merged_ledger_indices: set[int] = set() + for base in sorted(bases): + for raw in list(raw_erp_by_base.get(base) or []): + display_row = _raw_erp_recheck_row(raw, anchor) + display_row["status_label"] = status_label + display_row["review_reason"] = "FULL_ERP_DRAFT_CONTEXT" + display_row["matched_case"] = "FULL_ERP_CONTEXT_ROW" + display_row = _blank_wehago_identity_for_erp_only_display(display_row) + row_key = clean(display_row.get("voucher_row_key")) or build_voucher_row_key(display_row) + draft_line = clean(display_row.get("draft_no")) + content_key = _display_voucher_content_key(display_row) + merge_index = _find_mergeable_ledger_only_row_index(rows, display_row, merged_ledger_indices) + if ( + merge_index is not None + and row_key + and row_key not in seen_voucher + and draft_line not in seen_draft_lines + and content_key not in seen_voucher_content + ): + rows[merge_index] = _merge_erp_display_row_into_ledger_row(rows[merge_index], display_row) + merged_ledger_indices.add(merge_index) + seen_voucher.add(row_key) + if draft_line: + seen_draft_lines.add(draft_line) + seen_voucher_content.add(content_key) + continue + if row_key and row_key not in seen_voucher and draft_line not in seen_draft_lines and content_key not in seen_voucher_content: + rows.append(display_row) + seen_voucher.add(row_key) + if draft_line: + seen_draft_lines.add(draft_line) + seen_voucher_content.add(content_key) + + completed = _refresh_group_summary_from_rows({"summary": summary, "rows": _sanitize_voucher_group_rows(rows)}) + completed["summary"]["status_label"] = status_label + if clean(summary.get("review_reason")): + completed["summary"]["review_reason"] = clean(summary.get("review_reason")) + if status_key.startswith("voucher_") and not any(_row_has_wehago_ledger_side(row) for row in completed.get("rows") or []): + return None + return completed + + +def _complete_wehago_voucher_display_group(conn: Any, group: Mapping[str, Any], status_key: str) -> dict[str, Any] | None: + status_label = _final_compact_group_status_label(status_key) + rows = [dict(row) for row in list(group.get("rows") or [])] + seen_ledger = { + clean(row.get("ledger_row_key")) or build_ledger_row_key(row) + for row in rows + if _row_has_wehago_ledger_side(row) + } + for raw in _load_raw_wehago_rows_for_group(conn, group): + display_row = _raw_wehago_ledger_row_to_display_row(raw, status_label) + row_key = clean(display_row.get("ledger_row_key")) + if row_key and row_key not in seen_ledger: + rows.append(display_row) + seen_ledger.add(row_key) + + bases = _compact_group_draft_bases({"summary": dict(group.get("summary") or {}), "rows": rows}) + exact_bases = _group_exact_proof_date_bases(conn, {"summary": dict(group.get("summary") or {}), "rows": rows}) + if exact_bases: + bases = exact_bases + seen_voucher = { + clean(row.get("voucher_row_key")) or build_voucher_row_key(row) + for row in rows + if _row_has_hanmac_candidate_side(row) + } + anchor = _wehago_anchor_key_from_parts( + (group.get("summary") or {}).get("fiscal_year"), + (group.get("summary") or {}).get("ledger_date"), + (group.get("summary") or {}).get("voucher_no"), + ) + for raw in _load_raw_erp_rows_for_draft_bases(conn, bases): + display_row = _raw_erp_recheck_row(raw, anchor) + display_row["status_label"] = status_label + display_row["review_reason"] = "FULL_ERP_DRAFT_CONTEXT" + display_row["matched_case"] = "FULL_ERP_CONTEXT_ROW" + row_key = clean(display_row.get("voucher_row_key")) or build_voucher_row_key(display_row) + if row_key and row_key not in seen_voucher: + rows.append(display_row) + seen_voucher.add(row_key) + + completed = _refresh_group_summary_from_rows({"summary": dict(group.get("summary") or {}), "rows": _sanitize_voucher_group_rows(rows)}) + completed["summary"]["status_label"] = status_label + if clean((group.get("summary") or {}).get("review_reason")): + completed["summary"]["review_reason"] = clean((group.get("summary") or {}).get("review_reason")) + if status_key.startswith("voucher_") and not any(_row_has_wehago_ledger_side(row) for row in completed.get("rows") or []): + return None + return completed + + +def _final_compact_group_status_label(status_key: str) -> str: + if status_key == "voucher_matched": + return "Matched" + if status_key == "voucher_recheck": + return "Recheck" + if status_key == "voucher_excepted": + return "Excepted" + if status_key == "erp_voucher_matched": + return "Matched" + if status_key == "erp_voucher_unmatched": + return "ERP Unmatched" + if status_key == "hanmac_unconnected": + return "Hanmac unconnected" + return "Unmatched" + + +def _sanitize_wehago_display_group_rows(group: Mapping[str, Any], status_key: str) -> dict[str, Any] | None: + status_label = _final_compact_group_status_label(status_key) + summary = dict(group.get("summary") or {}) + rows: list[dict[str, Any]] = [] + for raw_row in list(group.get("rows") or []): + row = dict(raw_row) + if status_key.startswith("voucher_") and not _row_has_wehago_ledger_side(row): + continue + if not _row_has_material_voucher_values(row): + row = _blank_voucher_side(row) + if not _row_has_wehago_ledger_side(row): + row = _blank_ledger_side(row) + row["status_label"] = status_label + rows.append(row) + if status_key.startswith("voucher_") and not rows: + return None + refreshed = _refresh_group_summary_from_rows({"summary": summary, "rows": rows}) + refreshed["summary"]["status_label"] = status_label + if clean(summary.get("review_reason")): + refreshed["summary"]["review_reason"] = clean(summary.get("review_reason")) + return refreshed + + +def _compact_group_draft_bases(group: Mapping[str, Any]) -> set[str]: + values: list[Any] = [] + summary = dict(group.get("summary") or {}) + values.append(summary.get("draft_no")) + for row in list(group.get("rows") or []): + values.append(row.get("draft_no")) + bases: set[str] = set() + for value in values: + for candidate in _split_erp_number_candidates(value): + base = _erp_draft_base(candidate) + if base: + bases.add(base) + return bases + + +def _load_raw_erp_rows_for_draft_bases(conn: Any, bases: set[str]) -> list[dict[str, Any]]: + if not bases: + return [] + conditions: list[str] = [] + params: dict[str, Any] = {} + for index, base in enumerate(sorted(bases)): + key = f"draft_base_{index}" + conditions.append( + f"(COALESCE(draft_no, '') = :{key} OR COALESCE(draft_no, '') LIKE :{key}_suffix)" + ) + params[key] = base + params[f"{key}_suffix"] = f"{base}-%" + rows = conn.execute( + text( + f""" + SELECT fiscal_year, proof_date, confirmed_no, draft_no, account_code, account_name, + debit_supply, credit_supply, vendor_name, desc1, desc2, row_number, id + FROM wehago_voucher_rows + WHERE {' OR '.join(conditions)} + ORDER BY fiscal_year ASC, draft_no ASC, row_number ASC, id ASC + """ + ), + params, + ).mappings().all() + return [dict(row) for row in rows] + + +def _draft_line_key(value: Any) -> str: + return clean(value) + + +def _text_related(left: Any, right: Any) -> bool: + left_text = normalize_text(left) + right_text = normalize_text(right) + if not left_text or not right_text: + return True + if left_text in right_text or right_text in left_text: + return True + return SequenceMatcher(None, left_text, right_text).ratio() >= 0.55 + + +def _mixed_recheck_residual_is_covered_by_raw_erp(conn: Any, group: Mapping[str, Any]) -> bool: + rows = [dict(row) for row in list(group.get("rows") or [])] + residual_rows = [ + row + for row in rows + if _row_has_wehago_ledger_side(row) and not _row_has_hanmac_candidate_side(row) + ] + if not residual_rows: + return False + bases = _compact_group_draft_bases(group) + if not bases: + return False + raw_rows = _load_raw_erp_rows_for_draft_bases(conn, bases) + if not raw_rows: + return False + represented = {_draft_line_key(row.get("draft_no")) for row in rows if _row_has_hanmac_candidate_side(row)} + available_raw = [ + row + for row in raw_rows + if _draft_line_key(row.get("draft_no")) not in represented + ] + if not available_raw: + return False + residual_debit = round(sum(parse_amount(row.get("ledger_debit")) for row in residual_rows), 2) + residual_credit = round(sum(parse_amount(row.get("ledger_credit")) for row in residual_rows), 2) + residual_desc = " ".join(clean(row.get("ledger_desc")) for row in residual_rows) + residual_account = " ".join(clean(row.get("ledger_account_name")) for row in residual_rows) + for raw in available_raw: + raw_debit = round(parse_amount(raw.get("debit_supply")), 2) + raw_credit = round(parse_amount(raw.get("credit_supply")), 2) + amount_matches = ( + (abs(residual_debit) >= 0.5 and abs(raw_debit - residual_debit) < 0.5) + or (abs(residual_credit) >= 0.5 and abs(raw_credit - residual_credit) < 0.5) + ) + if not amount_matches: + continue + raw_text = " ".join([clean(raw.get("account_name")), clean(raw.get("desc1")), clean(raw.get("desc2"))]) + if _text_related(residual_desc, raw_text) or _text_related(residual_account, raw.get("account_name")): + return True + raw_debit_total = round(sum(parse_amount(raw.get("debit_supply")) for raw in available_raw), 2) + raw_credit_total = round(sum(parse_amount(raw.get("credit_supply")) for raw in available_raw), 2) + if ( + (abs(residual_debit) >= 0.5 and abs(raw_debit_total - residual_debit) < 0.5) + or (abs(residual_credit) >= 0.5 and abs(raw_credit_total - residual_credit) < 0.5) + ): + raw_account_text = " ".join(clean(raw.get("account_name")) for raw in available_raw) + raw_desc_text = " ".join(" ".join([clean(raw.get("desc1")), clean(raw.get("desc2"))]) for raw in available_raw) + if _text_related(residual_account, raw_account_text) or _text_related(residual_desc, raw_desc_text): + return True + return False + + +def _append_reason_to_group(group: dict[str, Any], reason: str) -> dict[str, Any]: + summary = group.setdefault("summary", {}) + existing = clean(summary.get("review_reason")) + summary["review_reason"] = " / ".join(item for item in [existing, reason] if item) + for row in list(group.get("rows") or []): + existing_row = clean(row.get("review_reason")) + row["review_reason"] = " / ".join(item for item in [existing_row, reason] if item) + return group + + +def _apply_final_compact_projection_normalization( + conn: Any, + start_year: int | None, + end_year: int | None, + voucher_sections: dict[str, list[dict[str, Any]]], +) -> dict[str, list[dict[str, Any]]]: + normalized = {key: list(value or []) for key, value in voucher_sections.items()} + for status_key in COMPACT_EXPORT_STATUS_KEYS: + normalized.setdefault(status_key, []) + display_context = _load_final_projection_display_context(conn, start_year, end_year, normalized) + raw_erp_by_base = display_context.get("raw_erp_by_base") if isinstance(display_context.get("raw_erp_by_base"), dict) else {} + + retained_recheck: list[dict[str, Any]] = [] + demoted_unmatched: list[dict[str, Any]] = [] + promoted_matched: list[dict[str, Any]] = [] + for group in list(normalized.get("voucher_recheck") or []): + exact_bases = _context_exact_proof_date_bases(raw_erp_by_base, group) + if not exact_bases: + exact_bases = _select_external_exact_proof_date_bases(display_context, group) + raw_erp_by_base = _ensure_raw_erp_bases_loaded(conn, raw_erp_by_base, exact_bases) + if exact_bases: + group = _filter_group_to_draft_bases(group, exact_bases) + raw_erp_by_base = display_context.get("raw_erp_by_base") if isinstance(display_context.get("raw_erp_by_base"), dict) else raw_erp_by_base + hold_reason = _context_final_group_hold_recheck_reason(group) + if hold_reason: + clean_group = _sanitize_wehago_display_group_rows(group, "voucher_recheck") + if clean_group is not None: + retained_recheck.append(_append_reason_to_group(clean_group, hold_reason)) + continue + demote_reason = _recheck_group_should_demote_to_unmatched(group) + if demote_reason: + clean_group = _sanitize_wehago_display_group_rows(group, "voucher_unmatched") + if clean_group is not None: + demoted_unmatched.append(_append_reason_to_group(clean_group, demote_reason)) + continue + if not _group_has_hanmac_candidate_side(group): + clean_group = _sanitize_wehago_display_group_rows(group, "voucher_unmatched") + if clean_group is not None: + demoted_unmatched.append(_append_reason_to_group(clean_group, "RECHECK_WITHOUT_ERP_CANDIDATE_TO_UNMATCHED")) + continue + promote_reason = _context_final_group_promote_reason(raw_erp_by_base, group) + if promote_reason: + clean_group = _sanitize_wehago_display_group_rows(group, "voucher_matched") + if clean_group is not None: + promoted_matched.append(_append_reason_to_group(clean_group, promote_reason)) + continue + clean_group = _sanitize_wehago_display_group_rows(group, "voucher_recheck") + if clean_group is not None: + retained_recheck.append(clean_group) + normalized["voucher_recheck"] = retained_recheck + normalized["voucher_unmatched"] = list(normalized.get("voucher_unmatched") or []) + demoted_unmatched + normalized["voucher_matched"] = list(normalized.get("voucher_matched") or []) + promoted_matched + + matched_to_recheck: list[dict[str, Any]] = [] + for status_key in ("voucher_matched", "voucher_unmatched", "voucher_excepted"): + cleaned_groups: list[dict[str, Any]] = [] + for group in list(normalized.get(status_key) or []): + if status_key == "voucher_matched": + exact_bases = _context_exact_proof_date_bases(raw_erp_by_base, group) + if not exact_bases: + exact_bases = _select_external_exact_proof_date_bases(display_context, group) + raw_erp_by_base = _ensure_raw_erp_bases_loaded(conn, raw_erp_by_base, exact_bases) + if exact_bases: + group = _filter_group_to_draft_bases(group, exact_bases) + partial_recheck_reason = _context_card_payment_bridge_partial_recheck(group) + if partial_recheck_reason: + clean_recheck = _sanitize_wehago_display_group_rows(group, "voucher_recheck") + if clean_recheck is not None: + matched_to_recheck.append(_append_reason_to_group(clean_recheck, partial_recheck_reason)) + continue + clean_group = _sanitize_wehago_display_group_rows(group, status_key) + if clean_group is not None: + cleaned_groups.append(clean_group) + normalized[status_key] = cleaned_groups + if matched_to_recheck: + normalized["voucher_recheck"] = list(normalized.get("voucher_recheck") or []) + matched_to_recheck + + normalized = _dedupe_wehago_sections_to_single_status(normalized) + normalized = _dedupe_erp_sections_to_single_status(normalized) + for status_key in ("voucher_matched", "voucher_recheck", "voucher_unmatched", "voucher_excepted"): + completed_groups: list[dict[str, Any]] = [] + for group in list(normalized.get(status_key) or []): + if status_key in {"voucher_matched", "voucher_recheck"}: + exact_bases = _context_exact_proof_date_bases(raw_erp_by_base, group) + if not exact_bases: + exact_bases = _select_external_exact_proof_date_bases(display_context, group) + raw_erp_by_base = _ensure_raw_erp_bases_loaded(conn, raw_erp_by_base, exact_bases) + if exact_bases: + group = _filter_group_to_draft_bases(group, exact_bases) + completed = _complete_wehago_voucher_display_group_with_context(display_context, group, status_key) + if completed is not None: + completed_groups.append(completed) + normalized[status_key] = completed_groups + + completed_matched: list[dict[str, Any]] = [] + completed_matched_to_recheck: list[dict[str, Any]] = [] + for group in list(normalized.get("voucher_matched") or []): + partial_recheck_reason = _context_card_payment_bridge_partial_recheck(group) + if partial_recheck_reason: + clean_recheck = _sanitize_wehago_display_group_rows(group, "voucher_recheck") + if clean_recheck is not None: + completed_matched_to_recheck.append(_append_reason_to_group(clean_recheck, partial_recheck_reason)) + continue + completed_matched.append(group) + normalized["voucher_matched"] = completed_matched + if completed_matched_to_recheck: + normalized["voucher_recheck"] = list(normalized.get("voucher_recheck") or []) + completed_matched_to_recheck + + final_retained_recheck: list[dict[str, Any]] = [] + final_promoted_matched: list[dict[str, Any]] = [] + final_demoted_unmatched: list[dict[str, Any]] = [] + for group in list(normalized.get("voucher_recheck") or []): + hold_reason = _context_final_group_hold_recheck_reason(group) + if hold_reason: + final_retained_recheck.append(_append_reason_to_group(group, hold_reason)) + continue + demote_reason = _recheck_group_should_demote_to_unmatched(group) + if demote_reason: + clean_group = _sanitize_wehago_display_group_rows(group, "voucher_unmatched") + if clean_group is not None: + final_demoted_unmatched.append(_append_reason_to_group(clean_group, demote_reason)) + continue + promote_reason = _context_final_group_promote_reason(raw_erp_by_base, group) + if promote_reason: + clean_group = _sanitize_wehago_display_group_rows(group, "voucher_matched") + if clean_group is not None: + final_promoted_matched.append(_append_reason_to_group(clean_group, promote_reason)) + continue + final_retained_recheck.append(group) + normalized["voucher_recheck"] = final_retained_recheck + if final_promoted_matched: + normalized["voucher_matched"] = list(normalized.get("voucher_matched") or []) + final_promoted_matched + if final_demoted_unmatched: + normalized["voucher_unmatched"] = list(normalized.get("voucher_unmatched") or []) + final_demoted_unmatched + normalized = _dedupe_wehago_sections_to_single_status(normalized) + return normalized + + +def _json_safe_projection_payload(value: Any) -> Any: + if isinstance(value, dict): + return {str(key): _json_safe_projection_payload(item) for key, item in value.items()} + if isinstance(value, (list, tuple)): + return [_json_safe_projection_payload(item) for item in value] + if isinstance(value, set): + return sorted(_json_safe_projection_payload(item) for item in value) + if isinstance(value, (date, datetime)): + return value.isoformat() + return value + + def _chunked(items: list[dict[str, Any]], size: int = 1000) -> Iterable[list[dict[str, Any]]]: for index in range(0, len(items), max(int(size or 1000), 1)): yield items[index : index + max(int(size or 1000), 1)] @@ -3366,6 +7606,19 @@ def _load_query_group_page( "limit": int(limit), "offset": int(offset), } + has_detail_filter = any( + [ + clean(voucher_no), + clean(draft_no), + normalize_text(wehago_account), + normalize_text(erp_account), + clean(wehago_amount), + clean(erp_amount), + normalize_text(wehago_vendor), + normalize_text(erp_vendor), + normalize_text(desc_keyword), + ] + ) where_sql = """ start_year = :projection_start_year AND end_year = :projection_end_year @@ -3523,6 +7776,531 @@ def _voucher_summary_display_row(summary: dict[str, Any]) -> dict[str, Any]: } +def _raw_ledger_group_to_final_group( + raw_group: dict[str, Any], + normalized_status: str, + *, + review_reason: str = "", +) -> dict[str, Any]: + rows = [dict(row) for row in list(raw_group.get("rows") or [])] + ledger_accounts: list[str] = [] + ledger_vendors: list[str] = [] + ledger_debit = 0.0 + ledger_credit = 0.0 + for row in rows: + account_name = clean(row.get("ledger_account_name")) + vendor_name = clean(row.get("ledger_vendor")) + if account_name and account_name not in ledger_accounts: + ledger_accounts.append(account_name) + if vendor_name and vendor_name not in ledger_vendors: + ledger_vendors.append(vendor_name) + ledger_debit += parse_amount(row.get("ledger_debit")) + ledger_credit += parse_amount(row.get("ledger_credit")) + row["status_label"] = _final_status_label(normalized_status) + row["review_reason"] = clean(row.get("review_reason")) or review_reason + summary = { + "fiscal_year": int(raw_group.get("fiscal_year") or 0), + "status_label": _final_status_label(normalized_status), + "ledger_date": clean(raw_group.get("ledger_date")), + "proof_date": "", + "voucher_no": clean(raw_group.get("voucher_no")), + "draft_no": "", + "ledger_row_count": len(rows), + "voucher_row_count": 0, + "ledger_debit": ledger_debit, + "ledger_credit": ledger_credit, + "voucher_debit": 0.0, + "voucher_credit": 0.0, + "ledger_accounts": ", ".join(ledger_accounts), + "voucher_accounts": "", + "ledger_vendors": ", ".join(ledger_vendors), + "voucher_vendors": "", + "review_reason": review_reason, + } + return {"summary": summary, "rows": _sanitize_voucher_group_rows(rows)} + + +def _group_matches_final_filters( + group: dict[str, Any], + *, + voucher_filter: str, + draft_filter: str, + wehago_account_filter: str, + erp_account_filter: str, + wehago_amount_filter: str, + erp_amount_filter: str, + wehago_vendor_filter: str, + erp_vendor_filter: str, + desc_filter: str, +) -> bool: + summary = dict(group.get("summary") or {}) + rows = list(group.get("rows") or []) + if voucher_filter and voucher_filter not in clean(summary.get("voucher_no")): + return False + if draft_filter and draft_filter not in clean(summary.get("draft_no")): + return False + if wehago_account_filter and wehago_account_filter not in normalize_text( + " ".join([clean(summary.get("ledger_accounts")), " ".join(clean(row.get("ledger_account_name")) for row in rows)]) + ): + return False + if erp_account_filter and erp_account_filter not in normalize_text( + " ".join([clean(summary.get("voucher_accounts")), " ".join(clean(row.get("voucher_account_name")) for row in rows)]) + ): + return False + if wehago_vendor_filter and wehago_vendor_filter not in normalize_text( + " ".join([clean(summary.get("ledger_vendors")), " ".join(clean(row.get("ledger_vendor")) for row in rows)]) + ): + return False + if erp_vendor_filter and erp_vendor_filter not in normalize_text( + " ".join([clean(summary.get("voucher_vendors")), " ".join(clean(row.get("voucher_vendor")) for row in rows)]) + ): + return False + if desc_filter and desc_filter not in normalize_text( + " ".join( + [ + clean(summary.get("review_reason")), + " ".join(clean(row.get("ledger_desc")) for row in rows), + " ".join(clean(row.get("voucher_desc")) for row in rows), + ] + ) + ): + return False + if wehago_amount_filter: + amount_texts = [ + format_amount(summary.get("ledger_debit")), + format_amount(summary.get("ledger_credit")), + *[format_amount(row.get("ledger_debit")) for row in rows], + *[format_amount(row.get("ledger_credit")) for row in rows], + ] + if not any(wehago_amount_filter in text_value for text_value in amount_texts): + return False + if erp_amount_filter: + amount_texts = [ + format_amount(summary.get("voucher_debit")), + format_amount(summary.get("voucher_credit")), + *[format_amount(row.get("voucher_debit")) for row in rows], + *[format_amount(row.get("voucher_credit")) for row in rows], + ] + if not any(erp_amount_filter in text_value for text_value in amount_texts): + return False + return True + + +def _load_active_wehago_final_status_group_page( + conn: Any, + start_year: int | None, + end_year: int | None, + normalized_status: str, + voucher_no: str, + draft_no: str, + wehago_account: str, + erp_account: str, + wehago_amount: str, + erp_amount: str, + wehago_vendor: str, + erp_vendor: str, + desc_keyword: str, + offset: int, + limit: int, + cursor: str = "", +) -> tuple[list[dict[str, Any]], int, str] | None: + if start_year is None or end_year is None or normalized_status not in WEHAGO_FINAL_STATUS_ORDER: + return None + active_signatures = _active_status_projection_signatures_for_range( + conn, + start_year, + end_year, + WEHAGO_FINAL_STATUS_ORDER, + ) + signature = clean(active_signatures.get(normalized_status)) + if not signature: + return None + + union_parts: list[str] = [] + params: dict[str, Any] = { + "start_year": int(start_year), + "end_year": int(end_year), + "status_key": normalized_status, + "voucher_keyword": clean(voucher_no), + "voucher_like": f"%{clean(voucher_no)}%", + "draft_keyword": clean(draft_no), + "draft_like": f"%{clean(draft_no)}%", + "wehago_account_keyword": normalize_text(wehago_account), + "wehago_account_like": f"%{normalize_text(wehago_account)}%", + "erp_account_keyword": normalize_text(erp_account), + "erp_account_like": f"%{normalize_text(erp_account)}%", + "wehago_vendor_keyword": normalize_text(wehago_vendor), + "wehago_vendor_like": f"%{normalize_text(wehago_vendor)}%", + "erp_vendor_keyword": normalize_text(erp_vendor), + "erp_vendor_like": f"%{normalize_text(erp_vendor)}%", + "desc_keyword": normalize_text(desc_keyword), + "desc_like": f"%{normalize_text(desc_keyword)}%", + "wehago_amount_keyword": clean(wehago_amount), + "erp_amount_keyword": clean(erp_amount), + "limit": int(limit), + "offset": int(offset), + } + has_detail_filter = any( + [ + clean(voucher_no), + clean(draft_no), + normalize_text(wehago_account), + normalize_text(erp_account), + clean(wehago_amount), + clean(erp_amount), + normalize_text(wehago_vendor), + normalize_text(erp_vendor), + normalize_text(desc_keyword), + ] + ) + for index, status_key in enumerate(WEHAGO_FINAL_STATUS_ORDER): + params[f"active_status_{index}"] = status_key + params[f"active_rank_{index}"] = int(WEHAGO_FINAL_STATUS_PRIORITY[status_key]) + params[f"active_signature_{index}"] = clean(active_signatures.get(status_key)) + union_parts.append( + f""" + SELECT + :active_status_{index} AS source_status, + :active_rank_{index} AS source_rank, + fiscal_year, + group_index, + summary_json, + rows_json, + COALESCE(NULLIF(ledger_date, ''), NULLIF(proof_date, ''), '') AS raw_date, + COALESCE(NULLIF(voucher_no, ''), '') AS raw_voucher_no, + draft_no, + ledger_accounts, + voucher_accounts, + ledger_vendors, + voucher_vendors, + ledger_debit, + ledger_credit, + voucher_debit, + voucher_credit, + search_text + FROM wehago_status_projection_groups + WHERE start_year = :start_year + AND end_year = :end_year + AND status_key = :active_status_{index} + AND signature = :active_signature_{index} + AND fiscal_year BETWEEN :start_year AND :end_year + """ + ) + + source_sql = f""" + WITH raw_vouchers AS ( + SELECT + fiscal_year, + compare_voucher_no, + MIN(ledger_date) AS raw_ledger_date, + MIN(voucher_no) AS raw_voucher_no + FROM wehago_ledger_rows + WHERE fiscal_year BETWEEN :start_year AND :end_year + AND COALESCE(compare_voucher_no, '') <> '' + GROUP BY fiscal_year, compare_voucher_no + ), + active_groups AS ( + {' UNION ALL '.join(union_parts)} + ), + normalized_groups AS ( + SELECT + *, + CASE + WHEN COALESCE(raw_date, '') <> '' AND COALESCE(raw_voucher_no, '') <> '' + THEN ( + CASE + WHEN raw_date GLOB '[0-9][0-9]-[0-9][0-9]' + THEN printf('%04d%s', fiscal_year, REPLACE(raw_date, '-', '')) + ELSE REPLACE(raw_date, '-', '') + END + ) + || '-' + || TRIM(CASE WHEN INSTR(raw_voucher_no, ',') > 0 THEN SUBSTR(raw_voucher_no, 1, INSTR(raw_voucher_no, ',') - 1) ELSE raw_voucher_no END) + ELSE '' + END AS compare_voucher_no + FROM active_groups + ), + resolved AS ( + SELECT compare_voucher_no, MIN(source_rank) AS final_rank + FROM normalized_groups + WHERE COALESCE(compare_voucher_no, '') <> '' + GROUP BY compare_voucher_no + ), + display_groups AS ( + SELECT * + FROM ( + SELECT + ng.*, + ROW_NUMBER() OVER ( + PARTITION BY ng.compare_voucher_no + ORDER BY + CASE WHEN ng.source_status = :status_key THEN 0 ELSE 1 END, + ng.source_rank ASC, + ng.group_index ASC + ) AS row_rank + FROM normalized_groups ng + ) + WHERE row_rank = 1 + ), + final_vouchers AS ( + SELECT + r.fiscal_year, + r.compare_voucher_no, + CASE + WHEN CASE COALESCE(resolved.final_rank, :unmatched_rank) + WHEN 1 THEN 'voucher_excepted' + WHEN 2 THEN 'voucher_matched' + WHEN 3 THEN 'voucher_recheck' + ELSE 'voucher_unmatched' + END = 'voucher_matched' + AND ( + COALESCE(dg.summary_json, '') = '' + OR ( + TRIM(COALESCE(dg.draft_no, '')) = '' + AND TRIM(COALESCE(dg.voucher_accounts, '')) = '' + AND TRIM(COALESCE(dg.voucher_vendors, '')) = '' + AND ABS(COALESCE(dg.voucher_debit, 0)) < 0.5 + AND ABS(COALESCE(dg.voucher_credit, 0)) < 0.5 + ) + ) + THEN 'voucher_unmatched' + ELSE CASE COALESCE(resolved.final_rank, :unmatched_rank) + WHEN 1 THEN 'voucher_excepted' + WHEN 2 THEN 'voucher_matched' + WHEN 3 THEN 'voucher_recheck' + ELSE 'voucher_unmatched' + END + END AS final_status, + COALESCE(NULLIF(dg.raw_date, ''), r.raw_ledger_date, '') AS final_ledger_date, + COALESCE(NULLIF(dg.raw_voucher_no, ''), r.raw_voucher_no, '') AS final_voucher_no, + dg.source_status, + dg.group_index, + dg.summary_json, + dg.rows_json, + dg.draft_no, + dg.ledger_accounts, + dg.voucher_accounts, + dg.ledger_vendors, + dg.voucher_vendors, + dg.ledger_debit, + dg.ledger_credit, + dg.voucher_debit, + dg.voucher_credit, + dg.search_text + FROM raw_vouchers r + LEFT JOIN resolved + ON resolved.compare_voucher_no = r.compare_voucher_no + LEFT JOIN display_groups dg + ON dg.compare_voucher_no = r.compare_voucher_no + ) + """ + params["unmatched_rank"] = int(WEHAGO_FINAL_STATUS_PRIORITY["voucher_unmatched"]) + filter_sql = """ + final_status = :status_key + AND ( + :voucher_keyword = '' + OR COALESCE(final_voucher_no, '') LIKE :voucher_like + OR COALESCE(compare_voucher_no, '') LIKE :voucher_like + ) + AND (:draft_keyword = '' OR COALESCE(draft_no, '') LIKE :draft_like) + AND (:wehago_account_keyword = '' OR COALESCE(ledger_accounts, '') LIKE :wehago_account_like) + AND (:erp_account_keyword = '' OR COALESCE(voucher_accounts, '') LIKE :erp_account_like) + AND (:wehago_vendor_keyword = '' OR COALESCE(ledger_vendors, '') LIKE :wehago_vendor_like) + AND (:erp_vendor_keyword = '' OR COALESCE(voucher_vendors, '') LIKE :erp_vendor_like) + AND (:desc_keyword = '' OR COALESCE(search_text, '') LIKE :desc_like) + AND ( + :wehago_amount_keyword = '' + OR CAST(ABS(COALESCE(ledger_debit, 0)) AS TEXT) LIKE '%' || :wehago_amount_keyword || '%' + OR CAST(ABS(COALESCE(ledger_credit, 0)) AS TEXT) LIKE '%' || :wehago_amount_keyword || '%' + ) + AND ( + :erp_amount_keyword = '' + OR CAST(ABS(COALESCE(voucher_debit, 0)) AS TEXT) LIKE '%' || :erp_amount_keyword || '%' + OR CAST(ABS(COALESCE(voucher_credit, 0)) AS TEXT) LIKE '%' || :erp_amount_keyword || '%' + ) + """ + if has_detail_filter: + total_count = int( + conn.execute( + text(f"{source_sql} SELECT COUNT(*) FROM final_vouchers WHERE {filter_sql}"), + params, + ).scalar_one() + or 0 + ) + else: + active_status_summary = _load_cached_active_status_projection_run_summary(conn, start_year, end_year) + if not active_status_summary: + active_status_summary = _load_active_status_projection_run_summary( + conn, + start_year, + end_year, + allow_rebuild=True, + ) + total_count = int(((active_status_summary or {}).get("counts") or {}).get(normalized_status, 0) or 0) + if total_count <= 0: + return [], 0, "" + cursor_text = clean(cursor) + cursor_year = 0 + cursor_compare_no = "" + if cursor_text: + try: + raw_year, raw_compare_no = cursor_text.split(":", 1) + cursor_year = int(raw_year or 0) + cursor_compare_no = raw_compare_no + except Exception: + cursor_text = "" + query_sql = f"{source_sql} SELECT * FROM final_vouchers WHERE {filter_sql}" + if cursor_text: + query_sql += """ + AND ( + fiscal_year > :cursor_year + OR (fiscal_year = :cursor_year AND compare_voucher_no > :cursor_compare_no) + ) + """ + params["cursor_year"] = cursor_year + params["cursor_compare_no"] = cursor_compare_no + query_sql += " ORDER BY fiscal_year ASC, compare_voucher_no ASC LIMIT :limit" + if not cursor_text: + query_sql += " OFFSET :offset" + rows = conn.execute(text(query_sql), params).mappings().all() + groups: list[dict[str, Any]] = [] + for row in rows: + try: + summary = json.loads(clean(row.get("summary_json")) or "{}") + except Exception: + summary = {} + try: + detail_rows = json.loads(clean(row.get("rows_json")) or "[]") + except Exception: + detail_rows = [] + if not isinstance(summary, dict): + summary = {} + if not isinstance(detail_rows, list): + detail_rows = [] + if not summary: + summary = { + "fiscal_year": int(row.get("fiscal_year") or 0), + "status_label": _final_status_label(normalized_status), + "ledger_date": clean(row.get("final_ledger_date")), + "proof_date": "", + "voucher_no": clean(row.get("final_voucher_no")), + "draft_no": "", + "ledger_accounts": "", + "voucher_accounts": "", + "ledger_vendors": "", + "voucher_vendors": "", + "review_reason": "WEHAGO 원천 전표이나 상태 projection group 없음", + } + summary["status_label"] = _final_status_label(normalized_status) + groups.append({"summary": summary, "rows": _sanitize_voucher_group_rows(detail_rows)}) + last_row = rows[-1] if rows else None + next_cursor = ( + f"{int(last_row.get('fiscal_year') or 0)}:{clean(last_row.get('compare_voucher_no'))}" + if last_row is not None and len(rows) >= int(limit or 0) + else "" + ) + return groups, total_count, next_cursor + + params: dict[str, Any] = { + "start_year": int(start_year), + "end_year": int(end_year), + "status_key": normalized_status, + "signature": signature, + "voucher_keyword": clean(voucher_no), + "voucher_like": f"%{clean(voucher_no)}%", + "draft_keyword": clean(draft_no), + "draft_like": f"%{clean(draft_no)}%", + "wehago_account_keyword": normalize_text(wehago_account), + "wehago_account_like": f"%{normalize_text(wehago_account)}%", + "erp_account_keyword": normalize_text(erp_account), + "erp_account_like": f"%{normalize_text(erp_account)}%", + "wehago_vendor_keyword": normalize_text(wehago_vendor), + "wehago_vendor_like": f"%{normalize_text(wehago_vendor)}%", + "erp_vendor_keyword": normalize_text(erp_vendor), + "erp_vendor_like": f"%{normalize_text(erp_vendor)}%", + "desc_keyword": normalize_text(desc_keyword), + "desc_like": f"%{normalize_text(desc_keyword)}%", + "wehago_amount_keyword": clean(wehago_amount), + "erp_amount_keyword": clean(erp_amount), + "limit": int(limit), + "offset": int(offset), + } + where_sql = """ + start_year = :start_year + AND end_year = :end_year + AND status_key = :status_key + AND signature = :signature + AND fiscal_year BETWEEN :start_year AND :end_year + AND (:voucher_keyword = '' OR COALESCE(voucher_no, '') LIKE :voucher_like) + AND (:draft_keyword = '' OR COALESCE(draft_no, '') LIKE :draft_like) + AND (:wehago_account_keyword = '' OR COALESCE(ledger_accounts, '') LIKE :wehago_account_like) + AND (:erp_account_keyword = '' OR COALESCE(voucher_accounts, '') LIKE :erp_account_like) + AND (:wehago_vendor_keyword = '' OR COALESCE(ledger_vendors, '') LIKE :wehago_vendor_like) + AND (:erp_vendor_keyword = '' OR COALESCE(voucher_vendors, '') LIKE :erp_vendor_like) + AND (:desc_keyword = '' OR COALESCE(search_text, '') LIKE :desc_like) + AND ( + :wehago_amount_keyword = '' + OR CAST(ABS(COALESCE(ledger_debit, 0)) AS TEXT) LIKE '%' || :wehago_amount_keyword || '%' + OR CAST(ABS(COALESCE(ledger_credit, 0)) AS TEXT) LIKE '%' || :wehago_amount_keyword || '%' + ) + AND ( + :erp_amount_keyword = '' + OR CAST(ABS(COALESCE(voucher_debit, 0)) AS TEXT) LIKE '%' || :erp_amount_keyword || '%' + OR CAST(ABS(COALESCE(voucher_credit, 0)) AS TEXT) LIKE '%' || :erp_amount_keyword || '%' + ) + """ + total_count = int(conn.execute(text(f"SELECT COUNT(*) FROM wehago_status_projection_groups WHERE {where_sql}"), params).scalar_one() or 0) + if total_count <= 0: + return [], 0, "" + cursor_text = clean(cursor) + cursor_year = 0 + cursor_group_index = -1 + if cursor_text: + try: + raw_year, raw_group_index = cursor_text.split(":", 1) + cursor_year = int(raw_year or 0) + cursor_group_index = int(raw_group_index or -1) + except Exception: + cursor_text = "" + query_sql = f"SELECT * FROM wehago_status_projection_groups WHERE {where_sql}" + if cursor_text: + query_sql += """ + AND ( + fiscal_year > :cursor_year + OR (fiscal_year = :cursor_year AND group_index > :cursor_group_index) + ) + """ + params["cursor_year"] = cursor_year + params["cursor_group_index"] = cursor_group_index + query_sql += " ORDER BY fiscal_year ASC, group_index ASC LIMIT :limit" + if not cursor_text: + query_sql += " OFFSET :offset" + projection_rows = conn.execute(text(query_sql), params).mappings().all() + groups: list[dict[str, Any]] = [] + for row in projection_rows: + try: + summary = json.loads(clean(row.get("summary_json")) or "{}") + except Exception: + summary = {} + try: + detail_rows = json.loads(clean(row.get("rows_json")) or "[]") + except Exception: + detail_rows = [] + if not isinstance(summary, dict): + summary = {} + if not isinstance(detail_rows, list): + detail_rows = [] + summary["status_label"] = _final_status_label(normalized_status) + groups.append({"summary": summary, "rows": _sanitize_voucher_group_rows(detail_rows)}) + last_row = projection_rows[-1] if projection_rows else None + next_cursor = ( + f"{int(last_row.get('fiscal_year') or 0)}:{int(last_row.get('group_index') or 0)}" + if last_row is not None and len(projection_rows) >= int(limit or 0) + else "" + ) + return groups, total_count, next_cursor + + def _load_final_wehago_status_group_page( conn: Any, start_year: int | None, @@ -3543,18 +8321,45 @@ def _load_final_wehago_status_group_page( ) -> tuple[list[dict[str, Any]], int, str] | None: if start_year is None or end_year is None or normalized_status not in WEHAGO_FINAL_STATUS_ORDER: return None - signature = _load_latest_query_projection_signature_for_range(conn, start_year, end_year) - if not signature: - return None - voucher_filter = clean(voucher_no) + def query_status_label(status_key: str) -> str: + return { + "voucher_matched": "Matched", + "voucher_recheck": "Recheck", + "voucher_unmatched": "Unmatched", + "voucher_excepted": "Excepted", + "erp_voucher_matched": "Matched", + "erp_voucher_unmatched": "ERP Unmatched", + "hanmac_unconnected": "Hanmac unconnected", + }.get(clean(status_key), clean(status_key)) + + raw_voucher_filter = clean(voucher_no) + voucher_filter = raw_voucher_filter + if raw_voucher_filter: + default_filter_year = int(start_year) if int(start_year or 0) == int(end_year or 0) else None + voucher_filter = normalize_compare_voucher_filter(raw_voucher_filter, default_filter_year) + if not voucher_filter: + return { + "columns": DETAIL_COLUMN_MAP.get(normalized_status, []), + "rows": [], + "groups": [], + "total_count": 0, + "shown_count": 0, + "offset": 0, + "limit": limit, + "has_more": False, + "next_offset": 0, + "next_cursor": "", + "notice": "전표번호 조회는 연월일과 전표번호를 함께 입력해야 합니다. 예: 2025-01-10 00200", + "pending": False, + } draft_filter = clean(draft_no) wehago_account_filter = normalize_text(wehago_account) erp_account_filter = normalize_text(erp_account) wehago_vendor_filter = normalize_text(wehago_vendor) erp_vendor_filter = normalize_text(erp_vendor) desc_filter = normalize_text(desc_keyword) - if ( + has_detail_filter = bool( voucher_filter or draft_filter or wehago_account_filter @@ -3564,8 +8369,17 @@ def _load_final_wehago_status_group_page( or wehago_vendor_filter or erp_vendor_filter or desc_filter - ): - return None + ) + signature = "" + apply_cross_signature_status_overrides_to_final_projection(conn, start_year, end_year) + signature = _load_latest_complete_final_status_projection_signature(conn, start_year, end_year) + if not signature and _load_canonical_query_projection_signature_for_range(conn, start_year, end_year): + backfill_wehago_final_status_projection_from_query_groups(conn, start_year, end_year) + signature = _load_latest_complete_final_status_projection_signature(conn, start_year, end_year) + if not signature: + signature = _load_latest_query_projection_signature_for_range(conn, start_year, end_year) + if not signature: + return None fast_params = { "start_year": int(start_year), @@ -3574,7 +8388,98 @@ def _load_final_wehago_status_group_page( "status_key": normalized_status, "limit": int(limit), "offset": int(offset), + "voucher_keyword": voucher_filter, + "voucher_like": f"%{voucher_filter}%", + "draft_keyword": draft_filter, + "draft_like": f"%{draft_filter}%", + "wehago_account_keyword": wehago_account_filter, + "wehago_account_like": f"%{wehago_account_filter}%", + "erp_account_keyword": erp_account_filter, + "erp_account_like": f"%{erp_account_filter}%", + "wehago_vendor_keyword": wehago_vendor_filter, + "wehago_vendor_like": f"%{wehago_vendor_filter}%", + "erp_vendor_keyword": erp_vendor_filter, + "erp_vendor_like": f"%{erp_vendor_filter}%", + "desc_keyword": desc_filter, + "desc_like": f"%{desc_filter}%", + "wehago_amount_keyword": clean(wehago_amount), + "erp_amount_keyword": clean(erp_amount), } + direct_final_loaded = False + total_count = 0 + final_rows: list[Mapping[str, Any]] = [] + if not has_detail_filter: + try: + total_count = int( + conn.execute( + text( + """ + SELECT COUNT(*) + FROM wehago_compare_final_status_projection + WHERE start_year = :start_year + AND end_year = :end_year + AND signature = :signature + AND final_status = :status_key + """ + ), + fast_params, + ).scalar_one() + or 0 + ) + if total_count <= 0: + return [], 0, "" + cursor_year = 0 + cursor_compare_no = "" + cursor_text = clean(cursor) + page_params = dict(fast_params) + cursor_sql = "" + if cursor_text: + try: + raw_year, raw_compare_no = cursor_text.split(":", 1) + cursor_year = int(raw_year or 0) + cursor_compare_no = raw_compare_no + cursor_sql = """ + AND ( + fiscal_year > :cursor_year + OR (fiscal_year = :cursor_year AND compare_voucher_no > :cursor_compare_no) + ) + """ + page_params["cursor_year"] = cursor_year + page_params["cursor_compare_no"] = cursor_compare_no + except Exception: + cursor_text = "" + cursor_sql = "" + offset_sql = "" if cursor_text else " OFFSET :offset" + final_rows = conn.execute( + text( + f""" + SELECT + fiscal_year, + compare_voucher_no, + final_status, + ledger_date AS final_ledger_date, + voucher_no AS final_voucher_no, + display_status_key, + display_signature, + display_group_index + FROM wehago_compare_final_status_projection + WHERE start_year = :start_year + AND end_year = :end_year + AND signature = :signature + AND final_status = :status_key + {cursor_sql} + ORDER BY fiscal_year ASC, compare_voucher_no ASC + LIMIT :limit + {offset_sql} + """ + ), + page_params, + ).mappings().all() + if not final_rows: + return [], total_count, "" + direct_final_loaded = True + except OperationalError: + direct_final_loaded = False fast_source_sql = """ WITH raw_vouchers AS ( SELECT @@ -3587,13 +8492,69 @@ def _load_final_wehago_status_group_page( AND COALESCE(compare_voucher_no, '') <> '' GROUP BY fiscal_year, compare_voucher_no ), + display_groups AS ( + SELECT + start_year, + end_year, + status_key, + signature, + group_index, + COALESCE(summary_json, '') AS summary_json, + draft_no, + ledger_accounts, + voucher_accounts, + ledger_vendors, + voucher_vendors, + ledger_debit, + ledger_credit, + voucher_debit, + voucher_credit, + search_text + FROM wehago_status_projection_groups + WHERE start_year = :start_year + AND end_year = :end_year + UNION ALL + SELECT + start_year, + end_year, + status_key, + signature, + group_index, + '{}' AS summary_json, + draft_no, + ledger_accounts, + voucher_accounts, + ledger_vendors, + voucher_vendors, + ledger_debit, + ledger_credit, + voucher_debit, + voucher_credit, + search_text + FROM wehago_compare_query_groups + WHERE start_year = :start_year + AND end_year = :end_year + ), final_vouchers AS ( SELECT r.fiscal_year, r.compare_voucher_no, COALESCE(p.final_status, 'voucher_unmatched') AS final_status, COALESCE(NULLIF(p.ledger_date, ''), r.raw_ledger_date, '') AS final_ledger_date, - COALESCE(NULLIF(p.voucher_no, ''), r.raw_voucher_no, '') AS final_voucher_no + COALESCE(NULLIF(p.voucher_no, ''), r.raw_voucher_no, '') AS final_voucher_no, + COALESCE(p.display_status_key, '') AS display_status_key, + COALESCE(p.display_signature, '') AS display_signature, + COALESCE(p.display_group_index, -1) AS display_group_index, + COALESCE(g.draft_no, '') AS display_draft_no, + COALESCE(g.ledger_accounts, '') AS display_ledger_accounts, + COALESCE(g.voucher_accounts, '') AS display_voucher_accounts, + COALESCE(g.ledger_vendors, '') AS display_ledger_vendors, + COALESCE(g.voucher_vendors, '') AS display_voucher_vendors, + COALESCE(g.ledger_debit, 0) AS display_ledger_debit, + COALESCE(g.ledger_credit, 0) AS display_ledger_credit, + COALESCE(g.voucher_debit, 0) AS display_voucher_debit, + COALESCE(g.voucher_credit, 0) AS display_voucher_credit, + COALESCE(g.search_text, '') AS display_search_text FROM raw_vouchers r LEFT JOIN wehago_compare_final_status_projection p ON p.start_year = :start_year @@ -3601,192 +8562,358 @@ def _load_final_wehago_status_group_page( AND p.signature = :signature AND p.fiscal_year = r.fiscal_year AND p.compare_voucher_no = r.compare_voucher_no + LEFT JOIN display_groups g + ON g.start_year = p.start_year + AND g.end_year = p.end_year + AND g.status_key = p.display_status_key + AND g.signature = p.display_signature + AND g.group_index = p.display_group_index ) """ - try: - total_count = int( - conn.execute( - text(f"{fast_source_sql} SELECT COUNT(*) FROM final_vouchers WHERE final_status = :status_key"), - fast_params, - ).scalar_one() - or 0 + fast_filter_sql = """ + final_status = :status_key + AND ( + :voucher_keyword = '' + OR COALESCE(final_voucher_no, '') LIKE :voucher_like + OR COALESCE(compare_voucher_no, '') LIKE :voucher_like ) - except OperationalError: - return None - if total_count <= 0: - return [], 0, "" - cursor_year = 0 - cursor_compare_no = "" - cursor_text = clean(cursor) - if cursor_text: + AND (:draft_keyword = '' OR COALESCE(display_draft_no, '') LIKE :draft_like) + AND (:wehago_account_keyword = '' OR COALESCE(display_ledger_accounts, '') LIKE :wehago_account_like) + AND (:erp_account_keyword = '' OR COALESCE(display_voucher_accounts, '') LIKE :erp_account_like) + AND (:wehago_vendor_keyword = '' OR COALESCE(display_ledger_vendors, '') LIKE :wehago_vendor_like) + AND (:erp_vendor_keyword = '' OR COALESCE(display_voucher_vendors, '') LIKE :erp_vendor_like) + AND (:desc_keyword = '' OR COALESCE(display_search_text, '') LIKE :desc_like) + AND ( + :wehago_amount_keyword = '' + OR CAST(ABS(COALESCE(display_ledger_debit, 0)) AS TEXT) LIKE '%' || :wehago_amount_keyword || '%' + OR CAST(ABS(COALESCE(display_ledger_credit, 0)) AS TEXT) LIKE '%' || :wehago_amount_keyword || '%' + ) + AND ( + :erp_amount_keyword = '' + OR CAST(ABS(COALESCE(display_voucher_debit, 0)) AS TEXT) LIKE '%' || :erp_amount_keyword || '%' + OR CAST(ABS(COALESCE(display_voucher_credit, 0)) AS TEXT) LIKE '%' || :erp_amount_keyword || '%' + ) + """ + if not direct_final_loaded: try: - raw_year, raw_compare_no = cursor_text.split(":", 1) - cursor_year = int(raw_year or 0) - cursor_compare_no = raw_compare_no - except Exception: - cursor_text = "" - fast_query_sql = f""" - {fast_source_sql} - SELECT * - FROM final_vouchers - WHERE final_status = :status_key - """ - if cursor_text: - fast_query_sql += """ - AND ( - fiscal_year > :cursor_year - OR (fiscal_year = :cursor_year AND compare_voucher_no > :cursor_compare_no) - ) + total_count = int( + conn.execute( + text(f"{fast_source_sql} SELECT COUNT(*) FROM final_vouchers WHERE {fast_filter_sql}"), + fast_params, + ).scalar_one() + or 0 + ) + except OperationalError: + return None + if total_count <= 0: + return [], 0, "" + cursor_year = 0 + cursor_compare_no = "" + cursor_text = clean(cursor) + if cursor_text: + try: + raw_year, raw_compare_no = cursor_text.split(":", 1) + cursor_year = int(raw_year or 0) + cursor_compare_no = raw_compare_no + except Exception: + cursor_text = "" + fast_query_sql = f""" + {fast_source_sql} + SELECT * + FROM final_vouchers + WHERE {fast_filter_sql} """ - fast_params["cursor_year"] = cursor_year - fast_params["cursor_compare_no"] = cursor_compare_no - fast_query_sql += """ - ORDER BY fiscal_year ASC, compare_voucher_no ASC - LIMIT :limit - """ - if not cursor_text: - fast_query_sql += " OFFSET :offset" - final_rows = conn.execute(text(fast_query_sql), fast_params).mappings().all() - if not final_rows: - return [], total_count, "" + if cursor_text: + fast_query_sql += """ + AND ( + fiscal_year > :cursor_year + OR (fiscal_year = :cursor_year AND compare_voucher_no > :cursor_compare_no) + ) + """ + fast_params["cursor_year"] = cursor_year + fast_params["cursor_compare_no"] = cursor_compare_no + fast_query_sql += """ + ORDER BY fiscal_year ASC, compare_voucher_no ASC + LIMIT :limit + """ + if not cursor_text: + fast_query_sql += " OFFSET :offset" + final_rows = conn.execute(text(fast_query_sql), fast_params).mappings().all() + if not final_rows: + return [], total_count, "" - compare_values = [clean(row.get("compare_voucher_no")) for row in final_rows if clean(row.get("compare_voucher_no"))] - group_by_compare: dict[tuple[int, str], dict[str, Any]] = {} - if compare_values: - compare_clauses = [] + display_keys = [ + ( + clean(row.get("display_status_key")), + clean(row.get("display_signature")), + int(row.get("display_group_index") or -1), + ) + for row in final_rows + if clean(row.get("display_status_key")) and clean(row.get("display_signature")) and int(row.get("display_group_index") or -1) >= 0 + ] + group_by_display: dict[tuple[str, str, int], dict[str, Any]] = {} + if display_keys: + clauses = [] group_params: dict[str, Any] = { "start_year": int(start_year), "end_year": int(end_year), - "signature": signature, } - for index, compare_no in enumerate(compare_values): - compare_clauses.append( - """ - ( - q.fiscal_year = :fiscal_year_{index} - AND CASE - WHEN COALESCE(NULLIF(q.ledger_date, ''), NULLIF(q.proof_date, ''), '') <> '' - AND COALESCE(NULLIF(q.voucher_no, ''), '') <> '' - THEN ( - CASE - WHEN COALESCE(NULLIF(q.ledger_date, ''), NULLIF(q.proof_date, ''), '') GLOB '[0-9][0-9]-[0-9][0-9]' - THEN printf('%04d%s', q.fiscal_year, REPLACE(COALESCE(NULLIF(q.ledger_date, ''), NULLIF(q.proof_date, ''), ''), '-', '')) - ELSE REPLACE(COALESCE(NULLIF(q.ledger_date, ''), NULLIF(q.proof_date, ''), ''), '-', '') - END - ) - || '-' - || TRIM(CASE WHEN INSTR(q.voucher_no, ',') > 0 THEN SUBSTR(q.voucher_no, 1, INSTR(q.voucher_no, ',') - 1) ELSE q.voucher_no END) - ELSE '' - END = :compare_no_{index} - ) - """.replace("{index}", str(index)) + for index, (status_key, group_signature, group_index) in enumerate(display_keys): + clauses.append( + f"(status_key = :status_key_{index} AND signature = :signature_{index} AND group_index = :group_index_{index})" ) - group_params[f"fiscal_year_{index}"] = int(final_rows[index].get("fiscal_year") or 0) - group_params[f"compare_no_{index}"] = compare_no - query_group_rows = conn.execute( + group_params[f"status_key_{index}"] = status_key + group_params[f"signature_{index}"] = group_signature + group_params[f"group_index_{index}"] = group_index + display_rows = conn.execute( text( f""" - SELECT q.* - FROM wehago_compare_query_groups q - WHERE q.start_year = :start_year - AND q.end_year = :end_year - AND q.signature = :signature - AND q.status_key IN ('voucher_matched', 'voucher_unmatched', 'voucher_recheck', 'voucher_excepted') - AND ({' OR '.join(compare_clauses)}) + SELECT * + FROM wehago_status_projection_groups + WHERE start_year = :start_year + AND end_year = :end_year + AND ({' OR '.join(clauses)}) + ORDER BY status_key ASC, group_index ASC """ ), group_params, ).mappings().all() - priority = { - "voucher_excepted": 1, - "voucher_matched": 2, - "voucher_recheck": 3, - "voucher_unmatched": 4, - } - for query_row in query_group_rows: - row_date = clean(query_row.get("ledger_date")) or clean(query_row.get("proof_date")) - row_voucher = clean(query_row.get("voucher_no")) - first_voucher = clean(row_voucher.split(",", 1)[0]) if row_voucher else "" - if re.fullmatch(r"\d{2}-\d{2}", row_date): - row_date = f"{int(query_row.get('fiscal_year') or 0):04d}-{row_date}" - compare_no = f"{row_date.replace('-', '')}-{first_voucher}" if row_date and first_voucher else "" - key = (int(query_row.get("fiscal_year") or 0), compare_no) - current = group_by_compare.get(key) - row_status = clean(query_row.get("status_key")) - row_rank = 0 if row_status == normalized_status else priority.get(row_status, 99) - current_status = clean((current or {}).get("status_key")) - current_rank = 0 if current_status == normalized_status else priority.get(current_status, 99) - if current is None or row_rank < current_rank or ( - row_rank == current_rank and int(query_row.get("group_index") or 0) < int(current.get("group_index") or 0) - ): - group_by_compare[key] = dict(query_row) - - selected_group_keys = [ - (clean(row.get("status_key")), int(row.get("group_index") or 0)) - for row in group_by_compare.values() - ] - rows_by_key: dict[tuple[str, int], list[dict[str, Any]]] = {} - if selected_group_keys: - clauses = [] - row_params: dict[str, Any] = { - "start_year": int(start_year), - "end_year": int(end_year), - "signature": signature, - } - for index, (status_key, group_index) in enumerate(selected_group_keys): - clauses.append(f"(status_key = :status_key_{index} AND group_index = :group_index_{index})") - row_params[f"status_key_{index}"] = status_key - row_params[f"group_index_{index}"] = group_index - detail_rows = conn.execute( + for display_row in display_rows: + group_by_display[ + ( + clean(display_row.get("status_key")), + clean(display_row.get("signature")), + int(display_row.get("group_index") or 0), + ) + ] = dict(display_row) + query_display_rows = conn.execute( text( f""" SELECT * - FROM wehago_compare_query_rows + FROM wehago_compare_query_groups WHERE start_year = :start_year AND end_year = :end_year - AND signature = :signature AND ({' OR '.join(clauses)}) - ORDER BY status_key ASC, group_index ASC, row_index ASC + ORDER BY status_key ASC, group_index ASC """ ), - row_params, + group_params, ).mappings().all() - for detail_row in detail_rows: - rows_by_key.setdefault( - (clean(detail_row.get("status_key")), int(detail_row.get("group_index") or 0)), - [], - ).append(dict(detail_row)) + if query_display_rows: + query_group_keys = [ + ( + clean(row.get("status_key")), + clean(row.get("signature")), + int(row.get("group_index") or 0), + ) + for row in query_display_rows + ] + query_clauses = [] + query_params: dict[str, Any] = { + "start_year": int(start_year), + "end_year": int(end_year), + } + for index, (status_key, group_signature, group_index) in enumerate(query_group_keys): + query_clauses.append( + f"(status_key = :status_key_{index} AND signature = :signature_{index} AND group_index = :group_index_{index})" + ) + query_params[f"status_key_{index}"] = status_key + query_params[f"signature_{index}"] = group_signature + query_params[f"group_index_{index}"] = group_index + query_detail_rows = conn.execute( + text( + f""" + SELECT * + FROM wehago_compare_query_rows + WHERE start_year = :start_year + AND end_year = :end_year + AND ({' OR '.join(query_clauses)}) + ORDER BY status_key ASC, group_index ASC, row_index ASC + """ + ), + query_params, + ).mappings().all() + rows_by_query_group: dict[tuple[str, str, int], list[dict[str, Any]]] = {} + for detail_row in query_detail_rows: + key = ( + clean(detail_row.get("status_key")), + clean(detail_row.get("signature")), + int(detail_row.get("group_index") or 0), + ) + rows_by_query_group.setdefault(key, []).append(dict(detail_row)) + for display_row in query_display_rows: + key = ( + clean(display_row.get("status_key")), + clean(display_row.get("signature")), + int(display_row.get("group_index") or 0), + ) + detail_rows = rows_by_query_group.get(key, []) + summary = { + "fiscal_year": int(display_row.get("fiscal_year") or 0), + "status_label": query_status_label(clean(display_row.get("status_key"))), + "ledger_date": clean(display_row.get("ledger_date")), + "proof_date": clean(display_row.get("proof_date")), + "voucher_no": clean(display_row.get("voucher_no")), + "draft_no": clean(display_row.get("draft_no")), + "ledger_row_count": int(display_row.get("ledger_row_count") or 0), + "voucher_row_count": int(display_row.get("voucher_row_count") or 0), + "ledger_debit": parse_amount(display_row.get("ledger_debit")), + "ledger_credit": parse_amount(display_row.get("ledger_credit")), + "voucher_debit": parse_amount(display_row.get("voucher_debit")), + "voucher_credit": parse_amount(display_row.get("voucher_credit")), + "ledger_accounts": clean(display_row.get("ledger_accounts")), + "voucher_accounts": clean(display_row.get("voucher_accounts")), + "ledger_vendors": clean(display_row.get("ledger_vendors")), + "voucher_vendors": clean(display_row.get("voucher_vendors")), + "review_reason": clean(display_row.get("review_reason")), + } + group_by_display[key] = { + **dict(display_row), + "summary_json": json.dumps(summary, ensure_ascii=False, separators=(",", ":")), + "rows_json": json.dumps(detail_rows, ensure_ascii=False, separators=(",", ":")), + } + + raw_ledger_by_compare: dict[str, list[dict[str, Any]]] = {} + compare_values = [clean(row.get("compare_voucher_no")) for row in final_rows if clean(row.get("compare_voucher_no"))] + if compare_values: + clauses = [] + raw_params: dict[str, Any] = { + "start_year": int(start_year), + "end_year": int(end_year), + } + for index, compare_no in enumerate(compare_values): + clauses.append(f"compare_voucher_no = :compare_no_{index}") + raw_params[f"compare_no_{index}"] = compare_no + raw_rows = conn.execute( + text( + f""" + SELECT + fiscal_year, + compare_voucher_no, + ledger_date, + voucher_no, + account_name, + vendor_name, + debit, + credit, + description, + row_number + FROM wehago_ledger_rows + WHERE fiscal_year BETWEEN :start_year AND :end_year + AND ({' OR '.join(clauses)}) + ORDER BY fiscal_year ASC, compare_voucher_no ASC, row_number ASC, id ASC + """ + ), + raw_params, + ).mappings().all() + for raw_row in raw_rows: + raw_ledger_by_compare.setdefault(clean(raw_row.get("compare_voucher_no")), []).append(dict(raw_row)) groups: list[dict[str, Any]] = [] for final_row in final_rows: - key = (int(final_row.get("fiscal_year") or 0), clean(final_row.get("compare_voucher_no"))) - query_row = group_by_compare.get(key, {}) - source_status = clean(query_row.get("status_key")) - group_index = int(query_row.get("group_index") or -1) - ledger_date = clean(query_row.get("ledger_date")) or clean(final_row.get("final_ledger_date")) - voucher_number = clean(query_row.get("voucher_no")) or clean(final_row.get("final_voucher_no")) - summary = { - "fiscal_year": int(final_row.get("fiscal_year") or 0), - "status_label": _final_status_label(normalized_status), - "ledger_date": ledger_date, - "proof_date": clean(query_row.get("proof_date")), - "voucher_no": voucher_number, - "draft_no": clean(query_row.get("draft_no")), - "ledger_row_count": int(query_row.get("ledger_row_count") or 0), - "voucher_row_count": int(query_row.get("voucher_row_count") or 0), - "ledger_debit": parse_amount(query_row.get("ledger_debit")), - "ledger_credit": parse_amount(query_row.get("ledger_credit")), - "voucher_debit": parse_amount(query_row.get("voucher_debit")), - "voucher_credit": parse_amount(query_row.get("voucher_credit")), - "ledger_accounts": clean(query_row.get("ledger_accounts")), - "voucher_accounts": clean(query_row.get("voucher_accounts")), - "ledger_vendors": clean(query_row.get("ledger_vendors")), - "voucher_vendors": clean(query_row.get("voucher_vendors")), - "review_reason": clean(query_row.get("review_reason")) or ("WEHAGO 원장 미분류 전표" if not query_row else ""), - } - detail_rows = rows_by_key.get((source_status, group_index), []) if query_row else [] - if not detail_rows: - detail_rows = [_voucher_summary_display_row(summary)] + compare_no = clean(final_row.get("compare_voucher_no")) + display_key = ( + clean(final_row.get("display_status_key")), + clean(final_row.get("display_signature")), + int(final_row.get("display_group_index") or -1), + ) + display_row = group_by_display.get(display_key, {}) + try: + summary = json.loads(clean(display_row.get("summary_json")) or "{}") + except Exception: + summary = {} + try: + detail_rows = json.loads(clean(display_row.get("rows_json")) or "[]") + except Exception: + detail_rows = [] + if not isinstance(summary, dict): + summary = {} + if not isinstance(detail_rows, list): + detail_rows = [] + if summary: + summary_compare_key = _wehago_group_compare_key_from_summary(summary) + if summary_compare_key and compare_no and summary_compare_key != compare_no: + summary = {} + detail_rows = [] + if (not summary or (normalized_status == "voucher_matched" and not _group_has_erp_detail({"summary": summary, "rows": detail_rows}))): + recovered_row = _load_status_projection_group_by_compare_key( + conn, + start_year, + end_year, + compare_no=compare_no, + fiscal_year=int(final_row.get("fiscal_year") or 0), + voucher_no=clean(final_row.get("final_voucher_no")), + preferred_statuses=[ + clean(final_row.get("display_status_key")), + normalized_status, + "voucher_matched", + "voucher_recheck", + "voucher_unmatched", + ], + ) + if recovered_row: + try: + recovered_summary = json.loads(clean(recovered_row.get("summary_json")) or "{}") + except Exception: + recovered_summary = {} + try: + recovered_rows = json.loads(clean(recovered_row.get("rows_json")) or "[]") + except Exception: + recovered_rows = [] + if isinstance(recovered_summary, dict) and isinstance(recovered_rows, list): + summary = recovered_summary + detail_rows = recovered_rows + if not summary: + ledger_date = clean(final_row.get("final_ledger_date")) + voucher_number = clean(final_row.get("final_voucher_no")) + raw_rows = raw_ledger_by_compare.get(compare_no, []) + ledger_accounts = sorted({clean(row.get("account_name")) for row in raw_rows if clean(row.get("account_name"))}) + ledger_vendors = sorted({clean(row.get("vendor_name")) for row in raw_rows if clean(row.get("vendor_name"))}) + ledger_debit = sum(parse_amount(row.get("debit")) for row in raw_rows) + ledger_credit = sum(parse_amount(row.get("credit")) for row in raw_rows) + summary = { + "fiscal_year": int(final_row.get("fiscal_year") or 0), + "status_label": _final_status_label(normalized_status), + "ledger_date": ledger_date, + "proof_date": "", + "voucher_no": voucher_number, + "draft_no": "", + "ledger_row_count": len(raw_rows), + "voucher_row_count": 0, + "ledger_debit": ledger_debit, + "ledger_credit": ledger_credit, + "voucher_debit": 0, + "voucher_credit": 0, + "ledger_accounts": ", ".join(ledger_accounts), + "voucher_accounts": "", + "ledger_vendors": ", ".join(ledger_vendors), + "voucher_vendors": "", + "review_reason": "WEHAGO 원장 미분류 전표", + } + if raw_rows: + detail_rows = [ + { + "fiscal_year": int(raw_row.get("fiscal_year") or final_row.get("fiscal_year") or 0), + "status_label": _final_status_label(normalized_status), + "ledger_date": clean(raw_row.get("ledger_date")) or ledger_date, + "proof_date": "", + "voucher_no": clean(raw_row.get("voucher_no")) or voucher_number, + "draft_no": "", + "ledger_account_name": clean(raw_row.get("account_name")), + "voucher_account_name": "", + "ledger_vendor": clean(raw_row.get("vendor_name")), + "voucher_vendor": "", + "ledger_debit": parse_amount(raw_row.get("debit")), + "ledger_credit": parse_amount(raw_row.get("credit")), + "voucher_debit": 0, + "voucher_credit": 0, + "ledger_desc": clean(raw_row.get("description")), + "voucher_desc": "", + "review_reason": "WEHAGO 원장 미분류 전표", + } + for raw_row in raw_rows + ] + summary["status_label"] = _final_status_label(normalized_status) groups.append({"summary": summary, "rows": _sanitize_voucher_group_rows(detail_rows)}) last_row = final_rows[-1] next_cursor = ( @@ -3903,7 +9030,12 @@ def _load_final_wehago_status_group_page( ) """ filter_sql = """ - (:voucher_keyword = '' OR COALESCE(voucher_no, final_voucher_no, '') LIKE :voucher_like) + ( + :voucher_keyword = '' + OR COALESCE(voucher_no, '') LIKE :voucher_like + OR COALESCE(final_voucher_no, '') LIKE :voucher_like + OR COALESCE(final_compare_voucher_no, '') LIKE :voucher_like + ) AND (:draft_keyword = '' OR COALESCE(draft_no, '') LIKE :draft_like) AND (:wehago_account_keyword = '' OR COALESCE(ledger_accounts, '') LIKE :wehago_account_like) AND (:erp_account_keyword = '' OR COALESCE(voucher_accounts, '') LIKE :erp_account_like) @@ -4494,6 +9626,7 @@ def _resolve_fast_projection_counts_from_cached_rows( for status_key in ("matched", "ledger_only", "amount_mismatch", "voucher_only"): counts[status_key] = len(rows_by_status.get(status_key, []) or []) voucher_sections = _get_cached_voucher_sections_by_range(engine, start_year, end_year, rows_by_status) + voucher_sections = _prepare_final_display_projection(voucher_sections) counts.update(_derive_voucher_projection_counts(rows_by_status, voucher_sections)) counts["bridge_expense_review"] = _count_bridge_expense_review_candidates(conn, start_year, end_year) return counts @@ -4655,6 +9788,8 @@ def _rebuild_compare_query_projection( logger.warning("Current export row cache projection failed for %s-%s: %s", start_year, end_year, exc) raise RuntimeError("현재 로직 전표 원본 스냅샷이 준비되지 않아 조회 캐시를 갱신하지 않았습니다.") voucher_sections = _get_cached_voucher_sections_by_range(engine, start_year, end_year, rows_by_status) + voucher_sections = _prepare_final_display_projection(voucher_sections) + rows_by_status = _filter_standard_rows_for_final_projection(rows_by_status, voucher_sections) counts = _empty_metric_counts() for status_key in ("matched", "ledger_only", "voucher_only", "amount_mismatch"): counts[status_key] = len(rows_by_status.get(status_key, []) or []) @@ -4675,6 +9810,7 @@ def _rebuild_compare_query_projection( }, ) _store_query_group_projection(conn, start_year, end_year, voucher_sections) + _store_final_compact_status_projections(conn, start_year, end_year, voucher_sections) _store_query_row_projection(conn, start_year, end_year, rows_by_status) _store_wehago_final_status_projection(conn, start_year, end_year, voucher_sections) _prewarm_query_page_projection_cache(conn, start_year, end_year, rows_by_status, voucher_sections) @@ -4938,12 +10074,17 @@ def clean(value: Any) -> str: return "" if value is None else str(value).strip() -def normalize_text(value: Any) -> str: - text_value = clean(value).lower() +@lru_cache(maxsize=262144) +def _normalize_text_cached(text: str) -> str: + text_value = text.lower().strip() text_value = re.sub(r"\s+", "", text_value) return re.sub(r"[\(\)\[\]\{\},._\-/\\:;*×%◇]", "", text_value) +def normalize_text(value: Any) -> str: + return _normalize_text_cached(clean(value)) + + def normalize_voucher_no(value: Any) -> str: return clean(value).replace(" ", "") @@ -4975,6 +10116,48 @@ def normalize_compare_voucher_no(value: Any, date_value: Any = None) -> str: return voucher_no +def normalize_compare_voucher_filter(value: Any, default_year: int | None = None) -> str: + raw_text = clean(value) + if not raw_text: + return "" + normalized = normalize_compare_voucher_no(raw_text) + if re.fullmatch(r"\d{8}-\d{5}", normalized): + return normalized + compact = re.sub(r"\D+", "", raw_text) + if len(compact) >= 13 and re.match(r"^(?:19|20)\d{6}", compact): + return f"{compact[:8]}-{int(compact[8:]):05d}" + full_match = re.search(r"((?:19|20)\d{2})\D*([01]?\d)\D*([0-3]?\d)\D+(\d{1,6})", raw_text) + if full_match: + year = int(full_match.group(1)) + month = int(full_match.group(2)) + day = int(full_match.group(3)) + voucher_no = int(full_match.group(4)) + if 1 <= month <= 12 and 1 <= day <= 31: + return f"{year:04d}{month:02d}{day:02d}-{voucher_no:05d}" + if default_year: + month_day_match = re.search(r"([01]?\d)\D+([0-3]?\d)\D+(\d{1,6})", raw_text) + if month_day_match: + month = int(month_day_match.group(1)) + day = int(month_day_match.group(2)) + voucher_no = int(month_day_match.group(3)) + if 1 <= month <= 12 and 1 <= day <= 31: + return f"{int(default_year):04d}{month:02d}{day:02d}-{voucher_no:05d}" + return "" + + +def _row_compare_voucher_key(row: Mapping[str, Any], *, date_field: str = "ledger_date") -> str: + compare_key = clean(row.get("compare_voucher_no")) + if re.fullmatch(r"\d{8}-\d{5}", compare_key): + return compare_key + fiscal_year = int(row.get("fiscal_year") or 0) or None + row_date = clean(row.get(date_field)) or clean(row.get("ledger_date")) or clean(row.get("proof_date")) + return normalize_wehago_voucher_identity( + fiscal_year or 0, + row.get("voucher_no"), + row_date, + ) + + def normalize_wehago_display_date(value: Any, default_year: int | None = None) -> str: parsed = parse_excel_date(value, default_year=default_year) if parsed: @@ -5048,14 +10231,8 @@ def format_amount(value: Any) -> str: return f"{amount:,.2f}" -def parse_excel_date(value: Any, default_year: int | None = None) -> str | None: - if value is None or value == "": - return None - if isinstance(value, datetime): - return value.date().isoformat() - if isinstance(value, date): - return value.isoformat() - text_value = clean(value).replace(".0", "") +@lru_cache(maxsize=65536) +def _parse_excel_date_text_cached(text_value: str, default_year: int | None = None) -> str | None: for pattern in (r"^(\d{4})(\d{2})(\d{2})$", r"^(\d{4})[-/.](\d{1,2})[-/.](\d{1,2})$"): match = re.match(pattern, text_value) if match: @@ -5066,6 +10243,17 @@ def parse_excel_date(value: Any, default_year: int | None = None) -> str | None: return None +def parse_excel_date(value: Any, default_year: int | None = None) -> str | None: + if value is None or value == "": + return None + if isinstance(value, datetime): + return value.date().isoformat() + if isinstance(value, date): + return value.isoformat() + text_value = clean(value).replace(".0", "") + return _parse_excel_date_text_cached(text_value, int(default_year) if default_year else None) + + def year_from_date_text(value: str | None) -> int | None: if not value: return None @@ -5497,6 +10685,45 @@ def init_wehago_compare_db(engine: Any) -> None: """ ) ) + conn.execute( + text( + """ + CREATE TABLE IF NOT EXISTS wehago_erp_voucher_existence_snapshots ( + snapshot_id INTEGER PRIMARY KEY AUTOINCREMENT, + fiscal_year INTEGER NOT NULL, + source_file_id INTEGER REFERENCES wehago_source_files(id) ON DELETE SET NULL, + source_origin TEXT NOT NULL DEFAULT '', + source_label TEXT NOT NULL DEFAULT '', + source_hash TEXT NOT NULL DEFAULT '', + snapshot_mode TEXT NOT NULL DEFAULT 'full', + row_count INTEGER NOT NULL DEFAULT 0, + draft_base_count INTEGER NOT NULL DEFAULT 0, + activated_at TEXT NOT NULL DEFAULT '', + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP + ) + """ + ) + ) + conn.execute( + text( + """ + CREATE TABLE IF NOT EXISTS wehago_erp_voucher_existence ( + snapshot_id INTEGER NOT NULL REFERENCES wehago_erp_voucher_existence_snapshots(snapshot_id) ON DELETE CASCADE, + fiscal_year INTEGER NOT NULL, + draft_base TEXT NOT NULL, + is_active INTEGER NOT NULL DEFAULT 1, + row_count INTEGER NOT NULL DEFAULT 0, + first_draft_no TEXT NOT NULL DEFAULT '', + last_draft_no TEXT NOT NULL DEFAULT '', + first_seen_row_number INTEGER NOT NULL DEFAULT 0, + last_seen_row_number INTEGER NOT NULL DEFAULT 0, + source_file_id INTEGER REFERENCES wehago_source_files(id) ON DELETE SET NULL, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (snapshot_id, draft_base) + ) + """ + ) + ) conn.execute( text( """ @@ -5713,6 +10940,9 @@ def init_wehago_compare_db(engine: Any) -> None: final_rank INTEGER NOT NULL DEFAULT 99, source_statuses TEXT NOT NULL DEFAULT '', source_group_count INTEGER NOT NULL DEFAULT 0, + display_status_key TEXT NOT NULL DEFAULT '', + display_signature TEXT NOT NULL DEFAULT '', + display_group_index INTEGER NOT NULL DEFAULT -1, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (start_year, end_year, signature, identity_key) @@ -5759,6 +10989,9 @@ def init_wehago_compare_db(engine: Any) -> None: ensure_column(conn, "wehago_result_row_cache", "parse_state_signature", "TEXT NOT NULL DEFAULT ''") ensure_column(conn, "wehago_result_row_cache", "resolved_payload_json", "TEXT NOT NULL DEFAULT ''") ensure_column(conn, "wehago_result_row_cache", "resolved_created_at", "TEXT NOT NULL DEFAULT ''") + ensure_column(conn, "wehago_compare_final_status_projection", "display_status_key", "TEXT NOT NULL DEFAULT ''") + ensure_column(conn, "wehago_compare_final_status_projection", "display_signature", "TEXT NOT NULL DEFAULT ''") + ensure_column(conn, "wehago_compare_final_status_projection", "display_group_index", "INTEGER NOT NULL DEFAULT -1") ensure_column(conn, "wehago_source_files", "source_origin", "TEXT NOT NULL DEFAULT 'filesystem'") ensure_column(conn, "wehago_manual_pair_matches", "match_source", "TEXT NOT NULL DEFAULT 'manual'") ensure_column(conn, "wehago_manual_pair_matches", "confidence_score", "REAL NOT NULL DEFAULT 0") @@ -5768,6 +11001,7 @@ def init_wehago_compare_db(engine: Any) -> None: ensure_column(conn, "wehago_raw_erp_trace_candidate_cache", "candidate_key", "TEXT NOT NULL DEFAULT ''") conn.execute(text("CREATE INDEX IF NOT EXISTS idx_wehago_voucher_source ON wehago_voucher_rows(source_file_id)")) conn.execute(text("CREATE INDEX IF NOT EXISTS idx_wehago_voucher_year_vno ON wehago_voucher_rows(fiscal_year, compare_voucher_no)")) + conn.execute(text("CREATE INDEX IF NOT EXISTS idx_wehago_voucher_year_draft ON wehago_voucher_rows(fiscal_year, draft_no)")) conn.execute(text("CREATE INDEX IF NOT EXISTS idx_wehago_ledger_source ON wehago_ledger_rows(source_file_id)")) conn.execute(text("CREATE INDEX IF NOT EXISTS idx_wehago_ledger_year_vno ON wehago_ledger_rows(fiscal_year, compare_voucher_no)")) conn.execute(text("CREATE INDEX IF NOT EXISTS idx_wehago_compare_year_status ON wehago_comparison_results(fiscal_year, status)")) @@ -5802,6 +11036,8 @@ def init_wehago_compare_db(engine: Any) -> None: conn.execute(text("CREATE INDEX IF NOT EXISTS idx_wehago_raw_trace_candidate_voucher ON wehago_raw_erp_trace_candidate_cache(fiscal_year, ledger_date, voucher_no)")) conn.execute(text("CREATE INDEX IF NOT EXISTS idx_wehago_raw_trace_candidate_score ON wehago_raw_erp_trace_candidate_cache(fiscal_year, score DESC)")) conn.execute(text("CREATE UNIQUE INDEX IF NOT EXISTS idx_wehago_raw_trace_candidate_key ON wehago_raw_erp_trace_candidate_cache(candidate_key) WHERE candidate_key <> ''")) + conn.execute(text("CREATE INDEX IF NOT EXISTS idx_wehago_erp_existence_year_base ON wehago_erp_voucher_existence(fiscal_year, draft_base, snapshot_id, is_active)")) + conn.execute(text("CREATE INDEX IF NOT EXISTS idx_wehago_erp_existence_snapshots_year ON wehago_erp_voucher_existence_snapshots(fiscal_year, activated_at, created_at)")) bridge_review_exists = conn.execute( text( """ @@ -5828,7 +11064,7 @@ def init_wehago_compare_db(engine: Any) -> None: pass _try_normalize_compare_background_jobs(conn) _prune_obsolete_compare_logic_caches(conn) - + _WEHAGO_COMPARE_DB_READY = True def upsert_source_file( @@ -6681,6 +11917,14 @@ def build_match_identity_key(row: dict[str, Any]) -> str: def build_ledger_row_key(row: dict[str, Any]) -> str: + source_row_id = clean(row.get("ledger_source_row_id")) + if source_row_id: + return f"ledger-db-row:{source_row_id}" + source_file_id = clean(row.get("ledger_source_file_id")) + source_sheet = clean(row.get("ledger_source_sheet_name")) + source_row_number = clean(row.get("ledger_source_row_number")) + if source_file_id and source_row_number: + return f"ledger-source:{source_file_id}:{source_sheet}:{source_row_number}" parts = [ clean(row.get("fiscal_year")), clean(row.get("voucher_no")), @@ -6696,6 +11940,14 @@ def build_ledger_row_key(row: dict[str, Any]) -> str: def build_voucher_row_key(row: dict[str, Any]) -> str: + source_row_id = clean(row.get("voucher_source_row_id")) + if source_row_id: + return f"voucher-db-row:{source_row_id}" + source_file_id = clean(row.get("voucher_source_file_id")) + source_sheet = clean(row.get("voucher_source_sheet_name")) + source_row_number = clean(row.get("voucher_source_row_number")) + if source_file_id and source_row_number: + return f"voucher-source:{source_file_id}:{source_sheet}:{source_row_number}" parts = [ clean(row.get("fiscal_year")), clean(row.get("voucher_no")), @@ -7200,6 +12452,15 @@ def _build_db_status_sections(conn: Any, year: int) -> dict[str, dict[str, Any]] sections[status_key]["rows"] = rows sections[status_key]["count"] = len(rows) + source_pairs = _build_matched_source_row_pairs(conn, year) + if source_pairs is not None: + sections["matched"]["rows"] = source_pairs["matched"] + sections["matched"]["count"] = len(source_pairs["matched"]) + sections["ledger_only"]["rows"].extend(source_pairs["ledger_only"]) + sections["ledger_only"]["count"] = len(sections["ledger_only"]["rows"]) + sections["voucher_only"]["rows"].extend(source_pairs["voucher_only"]) + sections["voucher_only"]["count"] = len(sections["voucher_only"]["rows"]) + sections = _repair_invalid_cross_category_matches(sections) sections = _append_substitution_review_rows(sections) sections = _promote_direct_auto_matches(sections) @@ -7207,7 +12468,120 @@ def _build_db_status_sections(conn: Any, year: int) -> dict[str, dict[str, Any]] sections = apply_saved_recheck_reviews(sections, get_saved_recheck_review_keys(conn, year, year)) sections = apply_saved_manual_pair_matches(sections, get_saved_manual_pair_matches(conn, year, year)) sections = _repair_invalid_cross_category_matches(sections) - return _restrict_erp_rows_to_fiscal_year(sections, year) + sections = _restrict_erp_rows_to_fiscal_year(sections, year) + sections, _changed = _release_reused_or_unidentified_matched_rows(sections) + return sections + + +def _build_matched_source_row_pairs(conn: Any, year: int) -> dict[str, list[dict[str, Any]]] | None: + """Resolve trusted voucher-level matches into one-to-one source account-row pairs.""" + ledger_rows = conn.execute( + text( + """ + SELECT c.voucher_no AS comparison_voucher_no, + l.id AS ledger_source_row_id, l.source_file_id AS ledger_source_file_id, + l.sheet_name AS ledger_source_sheet_name, l.row_number AS ledger_source_row_number, + l.fiscal_year, l.ledger_date, l.voucher_no, + l.account_code AS ledger_account_code, l.account_name AS ledger_account_name, + l.vendor_name AS ledger_vendor, l.description AS ledger_desc, + l.debit AS ledger_debit, l.credit AS ledger_credit + FROM wehago_comparison_results c + JOIN wehago_ledger_rows l + ON l.fiscal_year = c.fiscal_year AND l.compare_voucher_no = c.voucher_no + WHERE c.fiscal_year = :fiscal_year AND c.status = 'matched' + ORDER BY c.voucher_no, l.row_number, l.id + """ + ), + {"fiscal_year": year}, + ).mappings().all() + voucher_rows = conn.execute( + text( + """ + SELECT c.voucher_no AS comparison_voucher_no, + v.id AS voucher_source_row_id, v.source_file_id AS voucher_source_file_id, + v.sheet_name AS voucher_source_sheet_name, v.row_number AS voucher_source_row_number, + v.fiscal_year, v.proof_date, v.confirmed_no AS voucher_no, v.draft_no, + v.account_code AS voucher_account_code, v.account_name AS voucher_account_name, + v.vendor_name AS voucher_vendor, + TRIM(COALESCE(v.desc1, '') || ' ' || COALESCE(v.desc2, '')) AS voucher_desc, + v.debit_supply AS voucher_debit, v.credit_supply AS voucher_credit + FROM wehago_comparison_results c + JOIN wehago_voucher_rows v + ON v.fiscal_year = c.fiscal_year AND v.compare_voucher_no = c.voucher_no + WHERE c.fiscal_year = :fiscal_year AND c.status = 'matched' + ORDER BY c.voucher_no, v.row_number, v.id + """ + ), + {"fiscal_year": year}, + ).mappings().all() + if not ledger_rows and not voucher_rows: + return None + + ledgers_by_group: dict[str, list[dict[str, Any]]] = {} + vouchers_by_group: dict[str, list[dict[str, Any]]] = {} + for source in ledger_rows: + row = dict(source) + row["ledger_row_key"] = build_ledger_row_key(row) + ledgers_by_group.setdefault(clean(row.get("comparison_voucher_no")), []).append(row) + for source in voucher_rows: + row = dict(source) + row["voucher_row_key"] = build_voucher_row_key(row) + vouchers_by_group.setdefault(clean(row.get("comparison_voucher_no")), []).append(row) + + matched: list[dict[str, Any]] = [] + ledger_only: list[dict[str, Any]] = [] + voucher_only: list[dict[str, Any]] = [] + for group_key in sorted(set(ledgers_by_group) | set(vouchers_by_group)): + group_ledgers = ledgers_by_group.get(group_key, []) + group_vouchers = vouchers_by_group.get(group_key, []) + candidates: list[tuple[float, dict[str, Any], dict[str, Any], dict[str, Any]]] = [] + for ledger_row in group_ledgers: + for voucher_row in group_vouchers: + if not _account_category_pair_allowed( + ledger_row.get("ledger_account_code"), + ledger_row.get("ledger_account_name"), + voucher_row.get("voucher_account_code"), + voucher_row.get("voucher_account_name"), + ): + continue + score = _score_pair_match(ledger_row, voucher_row) + if float(score.get("amount_gap", 0) or 0) >= 0.5: + continue + rank = float(score.get("score", 0) or 0) + if clean(ledger_row.get("ledger_account_code")) == clean(voucher_row.get("voucher_account_code")): + rank += 30 + candidates.append((rank, ledger_row, voucher_row, score)) + candidates.sort(key=lambda item: item[0], reverse=True) + + used_ledger: set[str] = set() + used_voucher: set[str] = set() + for _rank, ledger_row, voucher_row, score in candidates: + ledger_key = clean(ledger_row.get("ledger_row_key")) + voucher_key = clean(voucher_row.get("voucher_row_key")) + if ledger_key in used_ledger or voucher_key in used_voucher: + continue + merged = dict(ledger_row) + merged.update(voucher_row) + merged["voucher_no"] = clean(ledger_row.get("voucher_no")) or clean(voucher_row.get("voucher_no")) + merged["review_reason"] = "전표내 계정행 일대일 매칭" + merged["matched_case"] = "voucher_group_source_row_match" + merged["match_identity_key"] = build_manual_pair_key(ledger_key, voucher_key) + matched.append(merged) + used_ledger.add(ledger_key) + used_voucher.add(voucher_key) + + for row in group_ledgers: + if clean(row.get("ledger_row_key")) not in used_ledger: + restored = dict(row) + restored["review_reason"] = "매칭 전표 내 ERP 대응 계정행 없음" + ledger_only.append(restored) + for row in group_vouchers: + if clean(row.get("voucher_row_key")) not in used_voucher: + restored = dict(row) + restored["review_reason"] = "매칭 전표 내 WEHAGO 대응 계정행 없음" + voucher_only.append(restored) + + return {"matched": matched, "ledger_only": ledger_only, "voucher_only": voucher_only} def _get_or_create_year_cached_sections(conn: Any, year: int) -> dict[str, dict[str, Any]] | None: @@ -7750,11 +13124,13 @@ def _build_db_state_signature(conn: Any, start_year: int | None, end_year: int | bridge_payload = {} bridge_settings = _normalize_bridge_review_settings(bridge_payload or DEFAULT_BRIDGE_REVIEW_SETTINGS) bridge_settings_sig = _bridge_review_settings_signature(bridge_settings) + erp_existence = _active_erp_voucher_existence_signature(conn, start_year, end_year) return ( f"{_current_logic_signature()}|" f"s:{source[0]}:{source[1]}|es:{erp_source[0]}:{erp_source[1]}|c:{comparison[0]}|" f"r:{recheck[0]}:{recheck[1]}|p:{pair[0]}:{pair[1]}|" - f"b:{bridge_settings_sig}:{clean((bridge_settings_row or {}).get('updated_at'))}" + f"b:{bridge_settings_sig}:{clean((bridge_settings_row or {}).get('updated_at'))}|" + f"ev:{erp_existence}" ) @@ -9346,11 +14722,125 @@ def _build_voucher_sections_with_bridge_promotions( end_year: int | None, rows_by_status: dict[str, list[dict[str, Any]]], ) -> dict[str, list[dict[str, Any]]]: - sections = _build_voucher_sections_from_rows_by_status(rows_by_status) - sections = _expand_erp_unmatched_groups_from_raw_rows(conn, start_year, end_year, sections) - sections = _apply_bridge_expense_promotions_to_voucher_sections(conn, start_year, end_year, sections) - sections = _apply_raw_erp_trace_promotions(conn, start_year, end_year, sections) - return _dedupe_wehago_sections_to_single_status(sections) + def traced_wrapper_step( + step_name: str, + current_sections: dict[str, list[dict[str, Any]]] | None, + func: Callable[[], dict[str, list[dict[str, Any]]]], + ) -> dict[str, list[dict[str, Any]]]: + if _trace_compare_projection_enabled(): + _trace_compare_projection( + f"{step_name}_start", + counts={key: len(value or []) for key, value in (current_sections or {}).items()}, + ) + started_at = time.monotonic() + result = func() + _trace_compare_projection( + f"{step_name}_done", + elapsed_sec=round(time.monotonic() - started_at, 3), + counts={key: len(value or []) for key, value in result.items()}, + ) + return result + return func() + + sections = traced_wrapper_step( + "build_voucher_sections_from_rows_by_status", + None, + lambda: _build_voucher_sections_from_rows_by_status(rows_by_status), + ) + sections = traced_wrapper_step( + "expand_erp_unmatched_groups_from_raw_rows", + sections, + lambda: _expand_erp_unmatched_groups_from_raw_rows(conn, start_year, end_year, sections), + ) + sections = traced_wrapper_step( + "apply_bridge_expense_promotions_to_voucher_sections", + sections, + lambda: _apply_bridge_expense_promotions_to_voucher_sections(conn, start_year, end_year, sections), + ) + sections = traced_wrapper_step( + "apply_raw_erp_trace_promotions", + sections, + lambda: _apply_raw_erp_trace_promotions(conn, start_year, end_year, sections), + ) + sections = traced_wrapper_step( + "wrapper_apply_guarded_regression_case_rules_1", + sections, + lambda: _apply_guarded_regression_case_rules(sections), + ) + sections = traced_wrapper_step( + "wrapper_dedupe_wehago_sections_to_single_status_1", + sections, + lambda: _dedupe_wehago_sections_to_single_status(sections), + ) + sections = traced_wrapper_step( + "wrapper_move_wehago_purchase_sales_cancel_pairs_to_excepted", + sections, + lambda: _move_wehago_purchase_sales_cancel_pairs_to_excepted(sections), + ) + sections = traced_wrapper_step( + "wrapper_apply_guarded_regression_case_rules_2", + sections, + lambda: _apply_guarded_regression_case_rules(sections), + ) + sections = traced_wrapper_step( + "wrapper_dedupe_wehago_sections_to_single_status_2", + sections, + lambda: _dedupe_wehago_sections_to_single_status(sections), + ) + if _trace_compare_projection_enabled(): + _trace_compare_projection( + "build_wehago_anchored_recheck_groups_start", + counts={key: len(value or []) for key, value in sections.items()}, + ) + anchored_started_at = time.monotonic() + sections["voucher_recheck"] = _build_wehago_anchored_recheck_groups( + conn, + start_year, + end_year, + sections.get("voucher_recheck", []) or [], + ) + if _trace_compare_projection_enabled(): + _trace_compare_projection( + "build_wehago_anchored_recheck_groups_done", + elapsed_sec=round(time.monotonic() - anchored_started_at, 3), + counts={key: len(value or []) for key, value in sections.items()}, + ) + sections = traced_wrapper_step( + "demote_anchored_recheck_groups_without_erp", + sections, + lambda: _demote_anchored_recheck_groups_without_erp(sections), + ) + sections = traced_wrapper_step( + "promote_obvious_recheck_groups_after_anchor", + sections, + lambda: _promote_obvious_recheck_groups(sections), + ) + sections = traced_wrapper_step( + "apply_erp_voucher_existence_gate", + sections, + lambda: _apply_erp_voucher_existence_gate(conn, start_year, end_year, sections), + ) + sections = traced_wrapper_step( + "demote_matched_groups_without_erp_detail", + sections, + lambda: _demote_matched_groups_without_erp_detail(sections), + ) + sections = traced_wrapper_step( + "wrapper_dedupe_wehago_sections_to_single_status_3", + sections, + lambda: _dedupe_wehago_sections_to_single_status(sections), + ) + sections = traced_wrapper_step( + "wrapper_dedupe_erp_sections_to_single_status", + sections, + lambda: _dedupe_erp_sections_to_single_status(sections), + ) + sections = traced_wrapper_step( + "apply_final_display_projection_guard", + sections, + lambda: _apply_final_display_projection_guard(sections), + ) + return sections def _push_action_history(conn: Any, action_type: str, payload: dict[str, Any]) -> int: @@ -9450,6 +14940,1004 @@ def apply_saved_manual_pair_matches( return adjusted +WEHAGO_ANCHORED_RECHECK_REASON = "WEHAGO_ANCHORED_RECHECK" + + +def _append_distinct_text(target: list[str], value: Any) -> None: + text_value = clean(value) + if text_value and text_value not in target: + target.append(text_value) + + +def _append_distinct_text_parts(target: list[str], value: Any) -> None: + for part in [item.strip() for item in clean(value).split(",") if item.strip()]: + _append_distinct_text(target, part) + + +def _wehago_anchor_key_from_parts(fiscal_year: Any, ledger_date: Any, voucher_no: Any) -> tuple[int, str, str]: + year = int(fiscal_year or 0) + return ( + year, + normalize_wehago_display_date(ledger_date, year or None), + clean(voucher_no), + ) + + +def _wehago_anchor_key_from_row(row: Mapping[str, Any]) -> tuple[int, str, str]: + return _wehago_anchor_key_from_parts( + row.get("fiscal_year"), + row.get("ledger_date"), + row.get("voucher_no"), + ) + + +def _row_has_wehago_ledger_side(row: Mapping[str, Any]) -> bool: + return bool( + clean(row.get("ledger_account_name")) + or clean(row.get("ledger_desc")) + or abs(parse_amount(row.get("ledger_debit"))) >= 0.5 + or abs(parse_amount(row.get("ledger_credit"))) >= 0.5 + ) + + +def _row_has_hanmac_candidate_side(row: Mapping[str, Any]) -> bool: + return bool( + clean(row.get("draft_no")) + or clean(row.get("proof_date")) + or clean(row.get("voucher_account_name")) + or clean(row.get("voucher_vendor")) + or clean(row.get("voucher_desc")) + or abs(parse_amount(row.get("voucher_debit"))) >= 0.5 + or abs(parse_amount(row.get("voucher_credit"))) >= 0.5 + ) + + +def _as_wehago_only_recheck_row(row: Mapping[str, Any], anchor_key: tuple[int, str, str]) -> dict[str, Any]: + fiscal_year, ledger_date, voucher_no = anchor_key + payload = { + "fiscal_year": fiscal_year, + "status_label": "Recheck", + "ledger_date": ledger_date, + "proof_date": "", + "voucher_no": voucher_no, + "draft_no": "", + "ledger_account_code": clean(row.get("ledger_account_code")), + "ledger_account_name": clean(row.get("ledger_account_name")), + "ledger_vendor": clean(row.get("ledger_vendor")), + "ledger_debit": parse_amount(row.get("ledger_debit")), + "ledger_credit": parse_amount(row.get("ledger_credit")), + "ledger_desc": clean(row.get("ledger_desc")), + "voucher_account_code": "", + "voucher_account_name": "", + "voucher_vendor": "", + "voucher_debit": 0, + "voucher_credit": 0, + "voucher_desc": "", + "review_reason": WEHAGO_ANCHORED_RECHECK_REASON, + "matched_case": "", + "ledger_row_key": clean(row.get("ledger_row_key")), + "voucher_row_key": "", + "match_identity_key": "", + } + payload["ledger_row_key"] = clean(payload.get("ledger_row_key")) or build_ledger_row_key(payload) + payload["match_identity_key"] = payload["ledger_row_key"] + return payload + + +def _as_hanmac_candidate_recheck_row(row: Mapping[str, Any], anchor_key: tuple[int, str, str]) -> dict[str, Any]: + fiscal_year, ledger_date, voucher_no = anchor_key + existing_reason = clean(row.get("review_reason")) + review_reason = " / ".join( + item for item in [existing_reason, WEHAGO_ANCHORED_RECHECK_REASON] if item + ) + payload = { + "fiscal_year": fiscal_year, + "status_label": "Recheck", + "ledger_date": ledger_date, + "proof_date": clean(row.get("proof_date")), + "voucher_no": voucher_no, + "draft_no": clean(row.get("draft_no")), + "ledger_account_code": "", + "ledger_account_name": "", + "ledger_vendor": "", + "ledger_debit": 0, + "ledger_credit": 0, + "ledger_desc": "", + "voucher_account_code": clean(row.get("voucher_account_code")), + "voucher_account_name": clean(row.get("voucher_account_name")), + "voucher_vendor": clean(row.get("voucher_vendor")), + "voucher_debit": parse_amount(row.get("voucher_debit")), + "voucher_credit": parse_amount(row.get("voucher_credit")), + "voucher_desc": clean(row.get("voucher_desc")), + "review_reason": review_reason, + "matched_case": clean(row.get("matched_case")), + "ledger_row_key": "", + "voucher_row_key": clean(row.get("voucher_row_key")), + "match_identity_key": "", + } + payload["voucher_row_key"] = clean(payload.get("voucher_row_key")) or build_voucher_row_key(payload) + payload["match_identity_key"] = payload["voucher_row_key"] + return payload + + +def _hanmac_candidate_dedupe_key(row: Mapping[str, Any]) -> tuple[Any, ...]: + return ( + clean(row.get("proof_date")), + clean(row.get("draft_no")), + clean(row.get("voucher_account_name")), + clean(row.get("voucher_vendor")), + round(parse_amount(row.get("voucher_debit")), 2), + round(parse_amount(row.get("voucher_credit")), 2), + clean(row.get("voucher_desc")), + ) + + +RECHECK_DIRECT_CANDIDATE = "DIRECT_MATCH_CANDIDATE" +RECHECK_SETTLEMENT_BRIDGE = "SETTLEMENT_BRIDGE_CANDIDATE" +RECHECK_SHARED_ALLOCATION = "SHARED_ALLOCATION_CANDIDATE" +RECHECK_SHARED_BUNDLE = "SHARED_BUNDLE_PAYMENT_CANDIDATE" +RECHECK_ERP_CONTEXT = "ERP_CONTEXT_ROW" +RECHECK_ERP_UNASSIGNED = "UNASSIGNED_ERP_ROW" +RECHECK_FULL_ERP_REASON = "ERP_FULL_DRAFT_VOUCHER_CONTEXT" + + +def _split_erp_number_candidates(value: Any) -> list[str]: + return [ + clean(part) + for part in re.split(r"\s*,\s*", clean(value)) + if clean(part) + ] + + +def _erp_row_amount(row: Mapping[str, Any], prefix: str) -> float: + return max( + abs(parse_amount(row.get(f"{prefix}_debit"))), + abs(parse_amount(row.get(f"{prefix}_credit"))), + ) + + +def _erp_row_side(row: Mapping[str, Any], prefix: str) -> str: + debit = abs(parse_amount(row.get(f"{prefix}_debit"))) + credit = abs(parse_amount(row.get(f"{prefix}_credit"))) + if debit >= 0.5 and credit < 0.5: + return "debit" + if credit >= 0.5 and debit < 0.5: + return "credit" + return "" + + +def _is_recheck_settlement_account(account_code: Any, account_name: Any) -> bool: + family = _classify_account_family(account_code, account_name) + return family in {"bank", "payable", "receivable", "advance"} or any( + token in normalize_text(account_name) + for token in ("전도금", "보통예금", "외상매입금", "미지급금") + ) + + +def _is_recheck_vat_account(account_code: Any, account_name: Any) -> bool: + return _is_vat_family(_classify_account_family(account_code, account_name)) or any( + token in normalize_text(account_name) + for token in ("부가세대급금", "매입세액", "부가세예수금", "매출세액") + ) + + +def _recheck_direct_account_compatible(ledger_row: Mapping[str, Any], voucher_row: Mapping[str, Any]) -> bool: + ledger_code = ledger_row.get("ledger_account_code") + ledger_name = ledger_row.get("ledger_account_name") + voucher_code = voucher_row.get("voucher_account_code") + voucher_name = voucher_row.get("voucher_account_name") + if _is_recheck_vat_account(ledger_code, ledger_name) and _is_recheck_vat_account(voucher_code, voucher_name): + return True + if _is_recheck_settlement_account(ledger_code, ledger_name) or _is_recheck_settlement_account(voucher_code, voucher_name): + return normalize_text(ledger_name) == normalize_text(voucher_name) + ledger_family = _classify_account_family(ledger_code, ledger_name) + voucher_family = _classify_account_family(voucher_code, voucher_name) + if ledger_family and voucher_family and ledger_family == voucher_family: + return True + ledger_text = normalize_text(ledger_name) + voucher_text = normalize_text(voucher_name) + if ledger_text and voucher_text and (ledger_text in voucher_text or voucher_text in ledger_text): + return True + compatible_pairs = ( + ("전력비", "수도광열비"), + ("통신비", "통신비"), + ("행사비용", "합사경비"), + ) + return any( + left in ledger_text and right in voucher_text + for left, right in compatible_pairs + ) + + +def _recheck_context_text(row: Mapping[str, Any], prefix: str) -> str: + return normalize_text( + " ".join( + filter( + None, + ( + clean(row.get(f"{prefix}_vendor")), + clean(row.get(f"{prefix}_desc")), + ), + ) + ) + ) + + +def _recheck_context_overlap(ledger_row: Mapping[str, Any], voucher_row: Mapping[str, Any]) -> bool: + if _core_token_overlap(ledger_row.get("ledger_vendor"), voucher_row.get("voucher_vendor")): + return True + if _core_token_strong_overlap(ledger_row.get("ledger_desc"), voucher_row.get("voucher_desc")): + return True + return bool( + _contained_core_desc_match(ledger_row.get("ledger_desc"), voucher_row.get("voucher_desc")) + or _contained_core_desc_match(ledger_row.get("ledger_vendor"), voucher_row.get("voucher_desc")) + ) + + +def _raw_erp_recheck_row(raw: Mapping[str, Any], anchor_key: tuple[int, str, str]) -> dict[str, Any]: + fiscal_year, ledger_date, voucher_no = anchor_key + payload = { + "fiscal_year": fiscal_year, + "status_label": "Recheck", + "ledger_date": ledger_date, + "proof_date": _raw_erp_display_proof_date(raw), + "voucher_no": voucher_no, + "draft_no": clean(raw.get("draft_no")), + "ledger_account_code": "", + "ledger_account_name": "", + "ledger_vendor": "", + "ledger_debit": 0.0, + "ledger_credit": 0.0, + "ledger_desc": "", + "voucher_account_code": clean(raw.get("account_code")), + "voucher_account_name": clean(raw.get("account_name")), + "voucher_vendor": clean(raw.get("vendor_name")), + "voucher_debit": parse_amount(raw.get("debit_supply")), + "voucher_credit": parse_amount(raw.get("credit_supply")), + "voucher_desc": " ".join( + part for part in (clean(raw.get("desc1")), clean(raw.get("desc2"))) if part + ), + "review_reason": RECHECK_FULL_ERP_REASON, + "matched_case": RECHECK_ERP_UNASSIGNED, + "ledger_row_key": "", + "voucher_row_key": "", + "match_identity_key": "", + } + payload["voucher_row_key"] = build_voucher_row_key(payload) + payload["match_identity_key"] = payload["voucher_row_key"] + return payload + + +def _load_raw_erp_rows_for_recheck_candidates( + conn: Any, + start_year: int, + end_year: int, + candidate_numbers: set[str], +) -> tuple[ + dict[str, str], + dict[str, list[dict[str, Any]]], + dict[tuple[str, float], list[dict[str, Any]]], +]: + if not candidate_numbers: + return {}, {}, {} + erp_start_year, erp_end_year = get_hanmac_erp_candidate_year_range(start_year, end_year) + rows = conn.execute( + text( + """ + SELECT id, fiscal_year, proof_date, confirmed_no, draft_no, account_code, account_name, + debit_supply, credit_supply, vendor_name, desc1, desc2, row_number + FROM wehago_voucher_rows + WHERE fiscal_year BETWEEN :start_year AND :end_year + AND COALESCE(draft_no, '') <> '' + ORDER BY fiscal_year, draft_no, row_number, id + """ + ), + {"start_year": int(erp_start_year), "end_year": int(erp_end_year)}, + ).mappings() + normalized_candidates = {normalize_voucher_no(value) for value in candidate_numbers if clean(value)} + alias_to_base: dict[str, str] = {} + raw_rows: list[dict[str, Any]] = [] + amount_index: dict[tuple[str, float], list[dict[str, Any]]] = {} + for source in rows: + raw = dict(source) + draft_no = normalize_voucher_no(raw.get("draft_no")) + confirmed_no = normalize_voucher_no(raw.get("confirmed_no")) + draft_base = _erp_draft_base(draft_no) + if not draft_base: + continue + raw_rows.append(raw) + voucher_debit = abs(parse_amount(raw.get("debit_supply"))) + voucher_credit = abs(parse_amount(raw.get("credit_supply"))) + if voucher_debit >= 0.5 and voucher_credit < 0.5: + amount_index.setdefault(("debit", round(voucher_debit, 2)), []).append(raw) + elif voucher_credit >= 0.5 and voucher_debit < 0.5: + amount_index.setdefault(("credit", round(voucher_credit, 2)), []).append(raw) + aliases = { + draft_no, + confirmed_no, + draft_base, + _erp_draft_base(confirmed_no), + } + if aliases & normalized_candidates: + for alias in aliases: + if alias: + alias_to_base[alias] = draft_base + rows_by_base: dict[str, list[dict[str, Any]]] = {} + for raw in raw_rows: + draft_base = _erp_draft_base(raw.get("draft_no")) + rows_by_base.setdefault(draft_base, []).append(raw) + alias_to_base[normalize_voucher_no(raw.get("draft_no"))] = draft_base + alias_to_base[normalize_voucher_no(raw.get("confirmed_no"))] = draft_base + return alias_to_base, rows_by_base, amount_index + + +def _discover_strong_recheck_candidate_bases( + anchor: tuple[int, str, str], + ledger_rows: list[dict[str, Any]], + attached_bases: list[str], + amount_index: dict[tuple[str, float], list[dict[str, Any]]], +) -> list[str]: + scores: dict[str, float] = {base: 5.0 for base in attached_bases} + exact_counts: dict[str, int] = {} + for ledger_row in ledger_rows: + if _classify_account_family( + ledger_row.get("ledger_account_code"), + ledger_row.get("ledger_account_name"), + ) == "bank": + continue + ledger_amount = _erp_row_amount(ledger_row, "ledger") + ledger_side = _erp_row_side(ledger_row, "ledger") + if ledger_amount < 0.5 or not ledger_side: + continue + for raw in amount_index.get((ledger_side, round(ledger_amount, 2)), []): + voucher_row = _raw_erp_recheck_row(raw, anchor) + draft_base = _erp_draft_base(voucher_row.get("draft_no")) + if not draft_base: + continue + ledger_date_value = _row_effective_wehago_date(ledger_row) + voucher_date_value = _row_effective_erp_date(voucher_row) + if ledger_date_value and voucher_date_value: + day_gap = abs((ledger_date_value - voucher_date_value).days) + if day_gap > 62: + continue + account_compatible = _recheck_direct_account_compatible(ledger_row, voucher_row) + context_overlap = _recheck_context_overlap(ledger_row, voucher_row) + same_account = normalize_text(ledger_row.get("ledger_account_name")) == normalize_text( + voucher_row.get("voucher_account_name") + ) + is_vat_pair = _is_recheck_vat_account( + ledger_row.get("ledger_account_code"), + ledger_row.get("ledger_account_name"), + ) and _is_recheck_vat_account( + voucher_row.get("voucher_account_code"), + voucher_row.get("voucher_account_name"), + ) + if not account_compatible: + continue + if not (context_overlap or is_vat_pair): + continue + exact_counts[draft_base] = exact_counts.get(draft_base, 0) + 1 + scores[draft_base] = scores.get(draft_base, 0.0) + 35.0 + if context_overlap: + scores[draft_base] += 20.0 + if same_account or is_vat_pair: + scores[draft_base] += 15.0 + if ledger_date_value and voucher_date_value: + day_gap = abs((ledger_date_value - voucher_date_value).days) + if day_gap == 0: + scores[draft_base] += 25.0 + elif day_gap <= 7: + scores[draft_base] += 15.0 + elif day_gap <= 45: + scores[draft_base] += 10.0 + if draft_base in attached_bases: + scores[draft_base] += 10.0 + strong = [ + (score, exact_counts.get(base, 0), base) + for base, score in scores.items() + if exact_counts.get(base, 0) >= 1 + ] + if not strong: + return attached_bases + strong.sort(reverse=True) + best_score = strong[0][0] + selected = [ + base + for score, exact_count, base in strong + if score >= best_score - 8.0 + ] + return selected or [strong[0][2]] + + +def _pair_recheck_rows_with_full_erp( + anchor: tuple[int, str, str], + ledger_rows: list[dict[str, Any]], + source_rows: list[dict[str, Any]], + raw_rows: list[dict[str, Any]], +) -> list[dict[str, Any]]: + erp_rows = [_raw_erp_recheck_row(raw, anchor) for raw in raw_rows] + source_pair_by_erp_key: dict[tuple[Any, ...], list[dict[str, Any]]] = {} + for source in source_rows: + if not (_row_has_wehago_ledger_side(source) and _row_has_hanmac_candidate_side(source)): + continue + candidate = _as_hanmac_candidate_recheck_row(source, anchor) + source_pair_by_erp_key.setdefault(_hanmac_candidate_dedupe_key(candidate), []).append(dict(source)) + + ledger_by_key = { + clean(row.get("ledger_row_key")) or build_ledger_row_key(row): row + for row in ledger_rows + } + consumed_ledger: set[str] = set() + output: list[dict[str, Any]] = [] + for erp_row in erp_rows: + source_candidates = source_pair_by_erp_key.get(_hanmac_candidate_dedupe_key(erp_row), []) + structural_candidates = [ + { + **ledger_row, + "ledger_row_key": clean(ledger_row.get("ledger_row_key")) or build_ledger_row_key(ledger_row), + } + for ledger_row in ledger_rows + ] + source_candidates = source_candidates + structural_candidates + best: tuple[int, dict[str, Any], dict[str, Any]] | None = None + for source in source_candidates: + source_ledger = _as_wehago_only_recheck_row(source, anchor) + ledger_key = clean(source_ledger.get("ledger_row_key")) + ledger_row = ledger_by_key.get(ledger_key) + if ledger_row is None or ledger_key in consumed_ledger: + continue + ledger_amount = _erp_row_amount(ledger_row, "ledger") + voucher_amount = _erp_row_amount(erp_row, "voucher") + if ledger_amount < 0.5 or voucher_amount < 0.5: + continue + exact_amount = abs(ledger_amount - voucher_amount) < 0.5 + same_side = _erp_row_side(ledger_row, "ledger") == _erp_row_side(erp_row, "voucher") + account_compatible = _recheck_direct_account_compatible(ledger_row, erp_row) + context_overlap = _recheck_context_overlap(ledger_row, erp_row) + if not exact_amount or not same_side: + continue + same_account = normalize_text(ledger_row.get("ledger_account_name")) == normalize_text( + erp_row.get("voucher_account_name") + ) + is_settlement = not same_account and ( + _is_recheck_settlement_account( + ledger_row.get("ledger_account_code"), + ledger_row.get("ledger_account_name"), + ) or _is_recheck_settlement_account( + erp_row.get("voucher_account_code"), + erp_row.get("voucher_account_name"), + ) + ) + is_vat_pair = _is_recheck_vat_account( + ledger_row.get("ledger_account_code"), + ledger_row.get("ledger_account_name"), + ) and _is_recheck_vat_account( + erp_row.get("voucher_account_code"), + erp_row.get("voucher_account_name"), + ) + if not is_settlement and not account_compatible: + continue + if not (context_overlap or is_vat_pair): + continue + score = (30 if account_compatible else 0) + (20 if context_overlap else 0) + if is_settlement: + score += 10 + if best is None or score > best[0]: + best = (score, ledger_row, source) + if best is None: + account_name = erp_row.get("voucher_account_name") + if _is_recheck_settlement_account(erp_row.get("voucher_account_code"), account_name): + erp_row["matched_case"] = RECHECK_ERP_CONTEXT + output.append(erp_row) + continue + _score, ledger_row, _source = best + ledger_key = clean(ledger_row.get("ledger_row_key")) + consumed_ledger.add(ledger_key) + paired = {**erp_row} + for key in ( + "ledger_account_code", "ledger_account_name", "ledger_vendor", + "ledger_debit", "ledger_credit", "ledger_desc", "ledger_row_key", + ): + paired[key] = ledger_row.get(key) + same_account = normalize_text(ledger_row.get("ledger_account_name")) == normalize_text( + erp_row.get("voucher_account_name") + ) + is_settlement = not same_account and ( + _is_recheck_settlement_account( + ledger_row.get("ledger_account_code"), + ledger_row.get("ledger_account_name"), + ) or _is_recheck_settlement_account( + erp_row.get("voucher_account_code"), + erp_row.get("voucher_account_name"), + ) + ) + paired["matched_case"] = RECHECK_SETTLEMENT_BRIDGE if is_settlement else RECHECK_DIRECT_CANDIDATE + paired["review_reason"] = " / ".join( + filter(None, (clean(paired.get("review_reason")), paired["matched_case"])) + ) + paired["match_identity_key"] = f"{ledger_key}|{clean(paired.get('voucher_row_key'))}" + output.append(paired) + + has_direct_match = any(row.get("matched_case") == RECHECK_DIRECT_CANDIDATE for row in output) + if has_direct_match: + for ledger_key, ledger_row in ledger_by_key.items(): + if ledger_key in consumed_ledger: + continue + if not _is_recheck_settlement_account( + ledger_row.get("ledger_account_code"), + ledger_row.get("ledger_account_name"), + ): + continue + ledger_amount = _erp_row_amount(ledger_row, "ledger") + ledger_side = _erp_row_side(ledger_row, "ledger") + candidates = [ + row + for row in output + if not _row_has_wehago_ledger_side(row) + and _is_recheck_settlement_account( + row.get("voucher_account_code"), + row.get("voucher_account_name"), + ) + and _erp_row_side(row, "voucher") == ledger_side + and abs(_erp_row_amount(row, "voucher") - ledger_amount) < 0.5 + ] + if len(candidates) != 1: + continue + bridge = candidates[0] + for key in ( + "ledger_account_code", "ledger_account_name", "ledger_vendor", + "ledger_debit", "ledger_credit", "ledger_desc", "ledger_row_key", + ): + bridge[key] = ledger_row.get(key) + bridge["matched_case"] = RECHECK_SETTLEMENT_BRIDGE + bridge["review_reason"] = " / ".join( + filter(None, (clean(bridge.get("review_reason")), RECHECK_SETTLEMENT_BRIDGE)) + ) + bridge["match_identity_key"] = f"{ledger_key}|{clean(bridge.get('voucher_row_key'))}" + consumed_ledger.add(ledger_key) + + for ledger_row in ledger_rows: + ledger_key = clean(ledger_row.get("ledger_row_key")) + if ledger_key not in consumed_ledger: + output.insert(0, ledger_row) + return output + + +def _load_raw_wehago_ledger_rows_for_anchors( + conn: Any, + start_year: int | None, + end_year: int | None, + anchors: set[tuple[int, str, str]], +) -> dict[tuple[int, str, str], list[dict[str, Any]]]: + if start_year is None or end_year is None or not anchors: + return {} + rows = conn.execute( + text( + """ + SELECT id, source_file_id, sheet_name, row_number, fiscal_year, + ledger_date, voucher_no, account_code, account_name, + vendor_name, debit, credit, description + FROM wehago_ledger_rows + WHERE fiscal_year BETWEEN :start_year AND :end_year + AND COALESCE(voucher_no, '') <> '' + ORDER BY fiscal_year, ledger_date, voucher_no, row_number, id + """ + ), + {"start_year": int(start_year), "end_year": int(end_year)}, + ).mappings() + raw_by_anchor: dict[tuple[int, str, str], list[dict[str, Any]]] = {} + for raw in rows: + fiscal_year = int(raw.get("fiscal_year") or 0) + anchor_key = _wehago_anchor_key_from_parts( + fiscal_year, + raw.get("ledger_date"), + raw.get("voucher_no"), + ) + if anchor_key not in anchors: + continue + payload = { + "fiscal_year": fiscal_year, + "ledger_date": anchor_key[1], + "voucher_no": anchor_key[2], + "ledger_account_code": clean(raw.get("account_code")), + "ledger_account_name": clean(raw.get("account_name")), + "ledger_vendor": clean(raw.get("vendor_name")), + "ledger_debit": parse_amount(raw.get("debit")), + "ledger_credit": parse_amount(raw.get("credit")), + "ledger_desc": clean(raw.get("description")), + "ledger_source_row_id": clean(raw.get("id")), + "ledger_source_file_id": clean(raw.get("source_file_id")), + "ledger_source_sheet_name": clean(raw.get("sheet_name")), + "ledger_source_row_number": clean(raw.get("row_number")), + } + payload["ledger_row_key"] = build_ledger_row_key(payload) + raw_by_anchor.setdefault(anchor_key, []).append(payload) + return raw_by_anchor + + +def _annotate_recheck_shared_structures(groups: list[dict[str, Any]]) -> list[dict[str, Any]]: + groups_by_base: dict[str, list[dict[str, Any]]] = {} + for group in groups: + summary = group.get("summary") or {} + for base in _split_erp_number_candidates(summary.get("draft_no")): + normalized_base = clean(base) + groups_by_base.setdefault(normalized_base, []).append(group) + + for draft_base, related_groups in groups_by_base.items(): + if len(related_groups) < 2: + continue + used_direct_erp_rows: set[str] = set() + for group in related_groups: + group_rows = list(group.get("rows") or []) + for direct_row in [ + row for row in group_rows + if row.get("matched_case") == RECHECK_DIRECT_CANDIDATE + ]: + draft_no = clean(direct_row.get("draft_no")) + if draft_no not in used_direct_erp_rows: + used_direct_erp_rows.add(draft_no) + continue + alternatives = [ + row + for row in group_rows + if clean(row.get("draft_no")) not in used_direct_erp_rows + and row.get("matched_case") == RECHECK_ERP_UNASSIGNED + and _erp_row_side(row, "voucher") == _erp_row_side(direct_row, "voucher") + and abs(_erp_row_amount(row, "voucher") - _erp_row_amount(direct_row, "voucher")) < 0.5 + and _recheck_direct_account_compatible(direct_row, row) + and _recheck_context_overlap(direct_row, row) + ] + if len(alternatives) != 1: + continue + replacement = alternatives[0] + for key in ( + "ledger_account_code", "ledger_account_name", "ledger_vendor", + "ledger_debit", "ledger_credit", "ledger_desc", "ledger_row_key", + ): + replacement[key] = direct_row.get(key) + replacement["matched_case"] = RECHECK_DIRECT_CANDIDATE + replacement["review_reason"] = " / ".join( + filter(None, (clean(replacement.get("review_reason")), RECHECK_DIRECT_CANDIDATE)) + ) + replacement["match_identity_key"] = ( + f"{clean(replacement.get('ledger_row_key'))}|{clean(replacement.get('voucher_row_key'))}" + ) + for key in ( + "ledger_account_code", "ledger_account_name", "ledger_vendor", + "ledger_desc", "ledger_row_key", + ): + direct_row[key] = "" + direct_row["ledger_debit"] = 0.0 + direct_row["ledger_credit"] = 0.0 + direct_row["matched_case"] = RECHECK_ERP_UNASSIGNED + direct_row["match_identity_key"] = clean(direct_row.get("voucher_row_key")) + used_direct_erp_rows.add(clean(replacement.get("draft_no"))) + + ledger_rows: list[dict[str, Any]] = [] + erp_rows_by_identity: dict[tuple[Any, ...], list[dict[str, Any]]] = {} + for group in related_groups: + for row in list(group.get("rows") or []): + if _row_has_wehago_ledger_side(row): + ledger_rows.append(row) + if _row_has_hanmac_candidate_side(row) and _erp_draft_base(row.get("draft_no")) == draft_base: + identity = ( + clean(row.get("draft_no")), + clean(row.get("voucher_account_code")), + clean(row.get("voucher_account_name")), + round(parse_amount(row.get("voucher_debit")), 2), + round(parse_amount(row.get("voucher_credit")), 2), + ) + erp_rows_by_identity.setdefault(identity, []).append(row) + for copies in erp_rows_by_identity.values(): + erp_row = copies[0] + voucher_amount = _erp_row_amount(erp_row, "voucher") + voucher_side = _erp_row_side(erp_row, "voucher") + if voucher_amount < 0.5 or not voucher_side: + continue + voucher_family = _classify_account_family( + erp_row.get("voucher_account_code"), + erp_row.get("voucher_account_name"), + ) + if voucher_family == "bank": + matching_ledger = [ + row + for row in ledger_rows + if _classify_account_family( + row.get("ledger_account_code"), + row.get("ledger_account_name"), + ) == "bank" + and _erp_row_side(row, "ledger") == voucher_side + ] + shared_role = RECHECK_SHARED_BUNDLE + elif _is_recheck_vat_account( + erp_row.get("voucher_account_code"), + erp_row.get("voucher_account_name"), + ): + matching_ledger = [ + row + for row in ledger_rows + if _is_recheck_vat_account( + row.get("ledger_account_code"), + row.get("ledger_account_name"), + ) + and _erp_row_side(row, "ledger") == voucher_side + ] + shared_role = RECHECK_SHARED_ALLOCATION + elif voucher_family == "payable": + matching_ledger = [ + row + for row in ledger_rows + if _classify_account_family( + row.get("ledger_account_code"), + row.get("ledger_account_name"), + ) == "payable" + and _erp_row_side(row, "ledger") == voucher_side + ] + shared_role = RECHECK_SHARED_ALLOCATION + else: + continue + distinct_anchors = { + ( + int(row.get("fiscal_year") or 0), + clean(row.get("ledger_date")), + clean(row.get("voucher_no")), + ) + for row in matching_ledger + } + ledger_total = sum(_erp_row_amount(row, "ledger") for row in matching_ledger) + if len(distinct_anchors) < 2 or abs(ledger_total - voucher_amount) >= 0.5: + continue + for copy_row in copies: + copy_row["matched_case"] = shared_role + copy_row["review_reason"] = " / ".join( + filter(None, (clean(copy_row.get("review_reason")), shared_role)) + ) + + for group in groups: + summary = group.get("summary") or {} + roles = {clean(row.get("matched_case")) for row in list(group.get("rows") or [])} + direct_count = sum( + 1 for row in list(group.get("rows") or []) + if row.get("matched_case") == RECHECK_DIRECT_CANDIDATE + ) + if RECHECK_SHARED_BUNDLE in roles or RECHECK_SHARED_ALLOCATION in roles: + internal_status = "matched_bundle_candidate" if direct_count else "partial_match" + elif RECHECK_SETTLEMENT_BRIDGE in roles and direct_count: + internal_status = "matched_with_settlement_bridge_candidate" + elif direct_count: + internal_status = "matched_direct_candidate" + elif any(_row_has_hanmac_candidate_side(row) for row in list(group.get("rows") or [])): + internal_status = "candidate_only" + else: + internal_status = "unmatched" + summary["review_reason"] = " / ".join( + filter(None, (clean(summary.get("review_reason")), f"INTERNAL_STATUS={internal_status}")) + ) + return groups + + +def _build_wehago_anchored_recheck_groups( + conn: Any, + start_year: int | None, + end_year: int | None, + source_groups: list[dict[str, Any]], +) -> list[dict[str, Any]]: + if start_year is None or end_year is None or not source_groups: + return source_groups + + anchors: set[tuple[int, str, str]] = set() + group_anchors: dict[int, set[tuple[int, str, str]]] = {} + fallback_ledger_rows: dict[tuple[int, str, str], dict[str, dict[str, Any]]] = {} + candidate_rows: dict[tuple[int, str, str], dict[tuple[Any, ...], dict[str, Any]]] = {} + source_rows_by_anchor: dict[tuple[int, str, str], list[dict[str, Any]]] = {} + candidate_numbers_by_anchor: dict[tuple[int, str, str], set[str]] = {} + reasons_by_anchor: dict[tuple[int, str, str], list[str]] = {} + + for group in source_groups: + summary = dict(group.get("summary") or {}) + summary_anchor = _wehago_anchor_key_from_parts( + summary.get("fiscal_year"), + summary.get("ledger_date"), + summary.get("voucher_no"), + ) + local_anchors: set[tuple[int, str, str]] = set() + if summary_anchor[0] and summary_anchor[1] and summary_anchor[2]: + local_anchors.add(summary_anchor) + for row in list(group.get("rows") or []): + if not _row_has_wehago_ledger_side(row): + continue + row_anchor = _wehago_anchor_key_from_row(row) + if row_anchor[0] and row_anchor[1] and row_anchor[2]: + local_anchors.add(row_anchor) + fallback_row = _as_wehago_only_recheck_row(row, row_anchor) + fallback_ledger_rows.setdefault(row_anchor, {})[fallback_row["ledger_row_key"]] = fallback_row + if not local_anchors: + continue + anchors.update(local_anchors) + group_anchors[id(group)] = local_anchors + for anchor in local_anchors: + _append_distinct_text(reasons_by_anchor.setdefault(anchor, []), summary.get("review_reason")) + for anchor in local_anchors: + for row in list(group.get("rows") or []): + if not _row_has_hanmac_candidate_side(row): + continue + source_rows_by_anchor.setdefault(anchor, []).append(dict(row)) + for number in _split_erp_number_candidates(row.get("draft_no")): + candidate_numbers_by_anchor.setdefault(anchor, set()).add(number) + candidate = _as_hanmac_candidate_recheck_row(row, anchor) + key = _hanmac_candidate_dedupe_key(candidate) + candidate_rows.setdefault(anchor, {})[key] = candidate + for number in _split_erp_number_candidates(summary.get("draft_no")): + candidate_numbers_by_anchor.setdefault(anchor, set()).add(number) + + if not anchors: + return source_groups + + raw_ledger_rows = _load_raw_wehago_ledger_rows_for_anchors(conn, start_year, end_year, anchors) + all_candidate_numbers = { + number + for numbers in candidate_numbers_by_anchor.values() + for number in numbers + } + alias_to_base, raw_erp_rows_by_base, raw_erp_amount_index = _load_raw_erp_rows_for_recheck_candidates( + conn, + int(start_year), + int(end_year), + all_candidate_numbers, + ) + anchored_groups: list[dict[str, Any]] = [] + for anchor in sorted(anchors): + fiscal_year, ledger_date, voucher_no = anchor + ledger_rows = [ + _as_wehago_only_recheck_row(row, anchor) + for row in raw_ledger_rows.get(anchor, []) + ] + if not ledger_rows: + ledger_rows = list((fallback_ledger_rows.get(anchor) or {}).values()) + if not ledger_rows: + continue + ledger_rows.sort( + key=lambda row: ( + clean(row.get("ledger_account_name")), + clean(row.get("ledger_vendor")), + -abs(parse_amount(row.get("ledger_debit")) or parse_amount(row.get("ledger_credit"))), + clean(row.get("ledger_desc")), + clean(row.get("ledger_row_key")), + ) + ) + candidate_bases: list[str] = [] + for number in candidate_numbers_by_anchor.get(anchor, set()): + normalized_number = normalize_voucher_no(number) + draft_base = alias_to_base.get(normalized_number) or _erp_draft_base(normalized_number) + if draft_base and draft_base not in candidate_bases: + candidate_bases.append(draft_base) + candidate_bases = _discover_strong_recheck_candidate_bases( + anchor, + ledger_rows, + candidate_bases, + raw_erp_amount_index, + ) + raw_candidate_rows = [ + raw + for draft_base in candidate_bases + for raw in raw_erp_rows_by_base.get(draft_base, []) + if _raw_erp_candidate_in_anchor_month_window(anchor, raw) + ] + if raw_candidate_rows: + display_rows = _pair_recheck_rows_with_full_erp( + anchor, + ledger_rows, + source_rows_by_anchor.get(anchor, []), + raw_candidate_rows, + ) + hanmac_rows = [row for row in display_rows if _row_has_hanmac_candidate_side(row)] + ledger_rows = [row for row in display_rows if _row_has_wehago_ledger_side(row)] + else: + hanmac_rows = [ + row + for row in list((candidate_rows.get(anchor) or {}).values()) + if _hanmac_recheck_row_in_anchor_month_window(anchor, row) + ] + display_rows = ledger_rows + hanmac_rows + hanmac_rows.sort( + key=lambda row: ( + clean(row.get("proof_date")), + clean(row.get("draft_no")), + clean(row.get("voucher_account_name")), + -parse_amount(row.get("voucher_debit")), + -parse_amount(row.get("voucher_credit")), + clean(row.get("voucher_desc")), + clean(row.get("voucher_row_key")), + ) + ) + ledger_accounts: list[str] = [] + ledger_vendors: list[str] = [] + voucher_accounts: list[str] = [] + voucher_vendors: list[str] = [] + draft_nos: list[str] = [] + proof_dates: list[str] = [] + for row in ledger_rows: + _append_distinct_text(ledger_accounts, row.get("ledger_account_name")) + _append_distinct_text(ledger_vendors, row.get("ledger_vendor")) + for row in hanmac_rows: + _append_distinct_text(voucher_accounts, row.get("voucher_account_name")) + _append_distinct_text(voucher_vendors, row.get("voucher_vendor")) + draft_base = _erp_draft_base(row.get("draft_no")) + _append_distinct_text(draft_nos, draft_base or row.get("draft_no")) + _append_distinct_text_parts(proof_dates, row.get("proof_date")) + + review_reasons: list[str] = [] + for raw_reason in reasons_by_anchor.get(anchor) or []: + for reason_part in re.split(r"\s*/\s*", clean(raw_reason)): + if ( + not reason_part + or reason_part == WEHAGO_ANCHORED_RECHECK_REASON + or reason_part.startswith("INTERNAL_STATUS=") + ): + continue + _append_distinct_text(review_reasons, reason_part) + _append_distinct_text(review_reasons, WEHAGO_ANCHORED_RECHECK_REASON) + summary = { + "fiscal_year": fiscal_year, + "status_label": "Recheck", + "ledger_date": ledger_date, + "proof_date": ", ".join(proof_dates), + "voucher_no": voucher_no, + "draft_no": ", ".join(draft_nos), + "ledger_row_count": len(ledger_rows), + "voucher_row_count": len(hanmac_rows), + "ledger_debit": sum(parse_amount(row.get("ledger_debit")) for row in ledger_rows), + "ledger_credit": sum(parse_amount(row.get("ledger_credit")) for row in ledger_rows), + "voucher_debit": sum(parse_amount(row.get("voucher_debit")) for row in hanmac_rows), + "voucher_credit": sum(parse_amount(row.get("voucher_credit")) for row in hanmac_rows), + "ledger_accounts": ", ".join(ledger_accounts), + "voucher_accounts": ", ".join(voucher_accounts), + "ledger_vendors": ", ".join(ledger_vendors), + "voucher_vendors": ", ".join(voucher_vendors), + "review_reason": " / ".join(review_reasons), + } + anchored_groups.append({"summary": summary, "rows": display_rows}) + return _annotate_recheck_shared_structures(anchored_groups) or source_groups + + +def _raw_erp_candidate_in_anchor_month_window( + anchor: tuple[int, str, str], + raw: Mapping[str, Any], + *, + max_month_gap: int = 2, +) -> bool: + fiscal_year, ledger_date_text, _voucher_no = anchor + try: + if fiscal_year <= 0: + return True + month_part, day_part = re.split(r"[-./]", clean(ledger_date_text), maxsplit=1) + anchor_date = date(int(fiscal_year), int(month_part), int(day_part)) + except Exception: + return True + raw_date_text = clean(raw.get("proof_date")) or _hanmac_voucher_date_from_numbers( + raw.get("draft_no"), + raw.get("confirmed_no"), + ) + candidate_date = _parse_iso_date(raw_date_text) + if candidate_date is None: + return True + return _month_gap(anchor_date, candidate_date) <= int(max_month_gap) + + +def _hanmac_recheck_row_in_anchor_month_window( + anchor: tuple[int, str, str], + row: Mapping[str, Any], + *, + max_month_gap: int = 2, +) -> bool: + raw_like = { + "proof_date": row.get("proof_date"), + "draft_no": row.get("draft_no"), + "confirmed_no": row.get("confirmed_no"), + } + return _raw_erp_candidate_in_anchor_month_window(anchor, raw_like, max_month_gap=max_month_gap) + + def save_recheck_review_rows(engine: Any, rows: list[dict[str, Any]]) -> int: init_wehago_compare_db(engine) normalized_rows = [] @@ -9669,6 +16157,8 @@ def save_recheck_change_rows( "count": saved_match_count + saved_split_count, }, ) + apply_recheck_match_overrides_to_final_projection(conn) + apply_cross_signature_status_overrides_to_final_projection(conn) _DASHBOARD_CACHE.clear() _SUGGEST_CACHE.clear() _STATUS_ROWS_CACHE.clear() @@ -9679,6 +16169,111 @@ def save_recheck_change_rows( return {"match_count": saved_match_count, "split_count": saved_split_count, "count": saved_match_count + saved_split_count} +def apply_recheck_match_overrides_to_final_projection(conn: Any) -> int: + try: + before = int( + conn.execute( + text( + """ + WITH latest_signature AS ( + SELECT p.start_year, p.end_year, p.signature + FROM wehago_compare_final_status_projection p + GROUP BY p.start_year, p.end_year, p.signature + HAVING MAX(p.updated_at) = ( + SELECT MAX(p2.updated_at) + FROM wehago_compare_final_status_projection p2 + WHERE p2.start_year = p.start_year + AND p2.end_year = p.end_year + ) + ), + manual_matches AS ( + SELECT DISTINCT + fiscal_year, + CASE + WHEN ledger_date GLOB '[0-9][0-9]-[0-9][0-9]' + THEN printf('%04d%s', fiscal_year, REPLACE(ledger_date, '-', '')) + ELSE REPLACE(ledger_date, '-', '') + END || '-' || voucher_no AS compare_voucher_no + FROM wehago_recheck_row_changes + WHERE change_type = 'match' + AND fiscal_year IS NOT NULL + AND COALESCE(ledger_date, '') <> '' + AND COALESCE(voucher_no, '') <> '' + ) + SELECT COUNT(*) + FROM wehago_compare_final_status_projection p + JOIN latest_signature s + ON s.start_year = p.start_year + AND s.end_year = p.end_year + AND s.signature = p.signature + JOIN manual_matches m + ON m.fiscal_year = p.fiscal_year + AND m.compare_voucher_no = p.compare_voucher_no + WHERE p.final_status NOT IN ('voucher_matched', 'voucher_excepted') + """ + ) + ).scalar_one() + or 0 + ) + conn.execute( + text( + """ + WITH latest_signature AS ( + SELECT p.start_year, p.end_year, p.signature + FROM wehago_compare_final_status_projection p + GROUP BY p.start_year, p.end_year, p.signature + HAVING MAX(p.updated_at) = ( + SELECT MAX(p2.updated_at) + FROM wehago_compare_final_status_projection p2 + WHERE p2.start_year = p.start_year + AND p2.end_year = p.end_year + ) + ), + manual_matches AS ( + SELECT DISTINCT + fiscal_year, + CASE + WHEN ledger_date GLOB '[0-9][0-9]-[0-9][0-9]' + THEN printf('%04d%s', fiscal_year, REPLACE(ledger_date, '-', '')) + ELSE REPLACE(ledger_date, '-', '') + END || '-' || voucher_no AS compare_voucher_no + FROM wehago_recheck_row_changes + WHERE change_type = 'match' + AND fiscal_year IS NOT NULL + AND COALESCE(ledger_date, '') <> '' + AND COALESCE(voucher_no, '') <> '' + ) + UPDATE wehago_compare_final_status_projection + SET final_status = 'voucher_matched', + final_rank = 2, + source_statuses = CASE + WHEN INSTR(COALESCE(source_statuses, ''), 'manual_recheck_match') > 0 THEN source_statuses + WHEN COALESCE(source_statuses, '') = '' THEN 'manual_recheck_match' + ELSE source_statuses || ',manual_recheck_match' + END, + updated_at = CURRENT_TIMESTAMP + WHERE EXISTS ( + SELECT 1 + FROM latest_signature s + WHERE s.start_year = wehago_compare_final_status_projection.start_year + AND s.end_year = wehago_compare_final_status_projection.end_year + AND s.signature = wehago_compare_final_status_projection.signature + ) + AND EXISTS ( + SELECT 1 + FROM manual_matches m + WHERE m.fiscal_year = wehago_compare_final_status_projection.fiscal_year + AND m.compare_voucher_no = wehago_compare_final_status_projection.compare_voucher_no + ) + AND final_status <> 'voucher_excepted' + """ + ) + ) + return before + except Exception: + return 0 + + def save_manual_pair_matches( engine: Any, ledger_rows: list[dict[str, Any]], @@ -10003,10 +16598,10 @@ def _normalize_core_similarity_text(value: Any) -> str: return text_value -def _extract_core_keywords(value: Any) -> set[str]: - text_value = _normalize_core_similarity_text(value) +@lru_cache(maxsize=131072) +def _extract_core_keywords_cached(text_value: str) -> tuple[str, ...]: if not text_value: - return set() + return () keywords: set[str] = set() candidates = [ "미수금", @@ -10049,7 +16644,11 @@ def _extract_core_keywords(value: Any) -> set[str]: for size in (4, 3): if len(text_value) >= size: keywords.update(text_value[i : i + size] for i in range(len(text_value) - size + 1)) - return keywords + return tuple(sorted(keywords)) + + +def _extract_core_keywords(value: Any) -> set[str]: + return set(_extract_core_keywords_cached(_normalize_core_similarity_text(value))) def _row_has_tax_signal( @@ -10240,8 +16839,8 @@ def _jaccard_similarity_tokens(left_tokens: set[str], right_tokens: set[str]) -> return (inter / union) if union else 0.0 -def _extract_month_tokens(value: Any) -> set[int]: - text = str(value or "") +@lru_cache(maxsize=32768) +def _extract_month_tokens_cached(text: str) -> tuple[int, ...]: tokens: set[int] = set() for raw in re.findall(r"(? set[int]: continue if 1 <= month_value <= 12: tokens.add(month_value) - return tokens + return tuple(sorted(tokens)) + + +def _extract_month_tokens(value: Any) -> set[int]: + return set(_extract_month_tokens_cached(str(value or ""))) def _has_conflicting_month_tokens(left: Any, right: Any) -> bool: @@ -10267,10 +16870,10 @@ def _has_conflicting_month_token_sets(left_months: set[int], right_months: set[i return left_months.isdisjoint(right_months) -def _extract_desc_date_tokens(value: Any) -> set[str]: - text_value = clean(value) +@lru_cache(maxsize=65536) +def _extract_desc_date_tokens_cached(text_value: str) -> tuple[str, ...]: if not text_value: - return set() + return () tokens: set[str] = set() for year, month, day in re.findall(r"(? set[str]: tokens.add(f"{int(year):04d}-{int(month):02d}") for month in re.findall(r"(? set[str]: + return set(_extract_desc_date_tokens_cached(clean(value))) def _date_token_month_numbers(tokens: set[str]) -> set[int]: @@ -10776,11 +17383,65 @@ def _month_gap(left: date, right: date) -> int: return abs((left.year - right.year) * 12 + (left.month - right.month)) +@lru_cache(maxsize=131072) +def _normalize_account_family_text_cached(text: str) -> str: + return normalize_text(text) + + def _normalize_account_family_text(value: Any) -> str: - return normalize_text(value) + return _normalize_account_family_text_cached(clean(value)) + + +@lru_cache(maxsize=65536) +def _classify_account_family_cached(normalized_code: str, normalized_name: str) -> str: + payable_markers = ("미지급금", "외상미지급금", "외상매입금", "매입채무") + bank_markers = ("보통예금",) + receivable_markers = ("미수금", "외상매출금", "매출채권", "공사미수금") + advance_markers = ("전도금", "가지급금", "선급금") + sales_markers = ("매출금", "매출", "용역수입", "수입") + vat_input_markers = ("부가세대급금", "매입세액", "부가세매입", "부가가치세대급금") + vat_output_markers = ("부가세예수금", "부가세계수금", "매출세액", "부가세매출", "부가가치세예수금") + education_markers = ("교육훈련비", "교육훈련") + welfare_markers = ("복리후생비", "복리후생", "회식대") + # VAT output names such as "매출세액" contain "매출"; classify them before generic sales. + if any(marker in normalized_name for marker in vat_input_markers): + return "vat_input" + if any(marker in normalized_name for marker in vat_output_markers): + return "vat_output" + if any(marker in normalized_name for marker in payable_markers): + return "payable" + if any(marker in normalized_name for marker in bank_markers): + return "bank" + if any(marker in normalized_name for marker in receivable_markers): + return "receivable" + if any(marker in normalized_name for marker in advance_markers): + return "advance" + if any(marker in normalized_name for marker in education_markers): + return "education" + if any(marker in normalized_name for marker in welfare_markers): + return "welfare" + if any(marker in normalized_name for marker in sales_markers): + return "sales" + if normalized_code.startswith(("251", "253", "290")): + return "payable" + if normalized_code.startswith(("103", "104", "106")): + return "bank" + if normalized_code.startswith(("108", "112", "120")): + return "receivable" + if normalized_code.startswith(("135",)): + return "vat_input" + if normalized_code.startswith(("255",)): + return "vat_output" + return "other" def _classify_account_family(account_code: Any, account_name: Any) -> str: + normalized_name = _normalize_account_family_text(account_name) + normalized_code = clean(account_code) + return _classify_account_family_cached(normalized_code, normalized_name) + + +def _classify_account_family_uncached(account_code: Any, account_name: Any) -> str: normalized_name = _normalize_account_family_text(account_name) normalized_code = clean(account_code) payable_markers = ("미지급금", "외상미지급금", "외상매입금", "매입채무") @@ -10913,6 +17574,7 @@ def _nature_compatible( return ledger_signature == voucher_signature +@lru_cache(maxsize=128) def _is_vat_family(family: str) -> bool: return family in {"vat_input", "vat_output"} @@ -11322,13 +17984,127 @@ def _repair_invalid_cross_category_matches( sections, changed_matched = _release_invalid_cross_category_matches(sections) sections, changed_recheck = _release_invalid_cross_category_recheck_rows(sections) sections, changed_scope = _release_invalid_multi_voucher_matches(sections) - if not (changed_matched or changed_recheck or changed_scope): + sections, changed_row_reuse = _release_reused_or_unidentified_matched_rows(sections) + if not (changed_matched or changed_recheck or changed_scope or changed_row_reuse): return sections if not ENABLE_GENERATED_RECHECK_CANDIDATES: return sections return _promote_direct_auto_matches(sections) +def _release_reused_or_unidentified_matched_rows( + sections: dict[str, dict[str, Any]], +) -> tuple[dict[str, dict[str, Any]], bool]: + """Keep direct source account-row matches one-to-one and invariant-safe.""" + matched_rows = list((sections or {}).get("matched", {}).get("rows", [])) + if not matched_rows: + return sections, False + + def absolute_row_amount(row: dict[str, Any], prefix: str) -> float: + return max( + abs(parse_amount(row.get(f"{prefix}_debit"))), + abs(parse_amount(row.get(f"{prefix}_credit"))), + ) + + def invariant_violation_reason(row: dict[str, Any]) -> str: + ledger_amount = absolute_row_amount(row, "ledger") + voucher_amount = absolute_row_amount(row, "voucher") + if ledger_amount <= 0 or voucher_amount <= 0: + return "원천 계정행 금액 누락" + if abs(ledger_amount - voucher_amount) >= 0.5: + return "원천 계정행 금액 불일치" + if _row_sides_have_opposite_signed_amount(row): + return "취소·정상 부호 반대 직접매칭" + if not _matched_row_passes_account_pair_rule(row): + return "계정군 허용 규칙 위반" + if _direct_match_date_invariant_violation_for_guard(row): + return "허용기간 62일 초과" + return "" + + used_ledger: set[str] = set() + used_voucher: set[str] = set() + kept_rows: list[dict[str, Any]] = [] + recheck_rows = list((sections or {}).get("amount_mismatch", {}).get("rows", [])) + ledger_only_rows = list((sections or {}).get("ledger_only", {}).get("rows", [])) + voucher_only_rows = list((sections or {}).get("voucher_only", {}).get("rows", [])) + existing_pairs = { + ( + clean(row.get("ledger_row_key")) or build_ledger_row_key(row), + clean(row.get("voucher_row_key")) or build_voucher_row_key(row), + ) + for row in recheck_rows + } + ledger_only_seen = { + clean(row.get("ledger_row_key")) or build_ledger_row_key(row) + for row in ledger_only_rows + } + voucher_only_seen = { + clean(row.get("voucher_row_key")) or build_voucher_row_key(row) + for row in voucher_only_rows + } + changed = False + + for source_row in matched_rows: + row = dict(source_row) + ledger_key = clean(row.get("ledger_row_key")) or build_ledger_row_key(row) + voucher_key = clean(row.get("voucher_row_key")) or build_voucher_row_key(row) + row["ledger_row_key"] = ledger_key + row["voucher_row_key"] = voucher_key + + reason = "" + if not ledger_key or not voucher_key: + reason = "원천 계정행 식별정보 누락" + elif ledger_key in used_ledger: + reason = "WEHAGO 원천 계정행 중복 매칭" + elif voucher_key in used_voucher: + reason = "ERP 원천 계정행 중복 매칭" + else: + reason = invariant_violation_reason(row) + + if not reason: + used_ledger.add(ledger_key) + used_voucher.add(voucher_key) + kept_rows.append(row) + continue + + changed = True + row["review_reason"] = f"{reason} / 직접 ERP 연결 제거" + row["matched_case"] = "source_row_invariant_recheck" + pair = (ledger_key, voucher_key) + if pair not in existing_pairs: + recheck_row = _restore_ledger_only_row_from_match(row) + recheck_row["status_label"] = "Recheck" + recheck_row["review_reason"] = row["review_reason"] + recheck_row["matched_case"] = row["matched_case"] + recheck_rows.append(recheck_row) + existing_pairs.add(pair) + restored_ledger = _restore_ledger_only_row_from_match(row) + restored_ledger["review_reason"] = row["review_reason"] + restored_ledger_key = clean(restored_ledger.get("ledger_row_key")) or build_ledger_row_key(restored_ledger) + if restored_ledger_key and restored_ledger_key not in ledger_only_seen: + ledger_only_rows.append(restored_ledger) + ledger_only_seen.add(restored_ledger_key) + restored_voucher = _restore_voucher_only_row_from_match(row) + restored_voucher["review_reason"] = row["review_reason"] + restored_voucher_key = clean(restored_voucher.get("voucher_row_key")) or build_voucher_row_key(restored_voucher) + if restored_voucher_key and restored_voucher_key not in used_voucher and restored_voucher_key not in voucher_only_seen: + voucher_only_rows.append(restored_voucher) + voucher_only_seen.add(restored_voucher_key) + + if not changed: + return sections, False + + sections["matched"]["rows"] = kept_rows + sections["matched"]["count"] = len(kept_rows) + sections["amount_mismatch"]["rows"] = recheck_rows + sections["amount_mismatch"]["count"] = len(recheck_rows) + sections["ledger_only"]["rows"] = ledger_only_rows + sections["ledger_only"]["count"] = len(ledger_only_rows) + sections["voucher_only"]["rows"] = voucher_only_rows + sections["voucher_only"]["count"] = len(voucher_only_rows) + return sections, True + + def _release_invalid_multi_voucher_matches( sections: dict[str, dict[str, Any]], ) -> tuple[dict[str, dict[str, Any]], bool]: @@ -12121,7 +18897,11 @@ def _apply_previous_year_erp_candidates( 0, 1_000_000, ) - adjacent_voucher_rows.extend(list(payload.get("rows", []))) + adjacent_voucher_rows.extend( + row + for row in list(payload.get("rows", [])) + if _is_adjacent_month_erp_candidate_for_range(row, year, year) + ) if not adjacent_voucher_rows: return sections @@ -12208,6 +18988,8 @@ def _apply_previous_year_erp_candidates( continue if not _voucher_group_tax_context_compatible(ledger_row, voucher_row): continue + if not _is_adjacent_month_erp_candidate_for_wehago_row(ledger_row, voucher_row, year): + continue if not score_result.get("auto_eligible"): continue if not (_same_or_similar_desc(probe) or _same_or_similar_vendor(probe)): @@ -12247,6 +19029,7 @@ def _apply_previous_year_erp_candidates( "voucher_credit": voucher_row.get("voucher_credit", 0), "voucher_desc": voucher_row.get("voucher_desc", ""), "voucher_row_key": voucher_row.get("voucher_row_key", ""), + "voucher_fiscal_year": voucher_row.get("fiscal_year", ""), "review_reason": review_reason, "matched_case": matched_case, "review_memo": "", @@ -12311,8 +19094,10 @@ def _apply_previous_year_erp_candidates( return review_rows # Adjacent-year ERP matching is intentionally limited to boundary-like - # WEHAGO rows; broad cross-year candidate generation is too noisy and slow. + # WEHAGO rows first. The non-boundary pass is allowed only against the + # adjacent-month ERP pool above, which keeps the candidate set small. apply_adjacent_candidates(ledger_rows, boundary_phase=True) + apply_adjacent_candidates(ledger_rows, boundary_phase=False) boundary_review_rows = build_adjacent_review_rows(ledger_rows, boundary_phase=True) if boundary_review_rows: review_ledger_keys = { @@ -13737,8 +20522,14 @@ def _filter_status_row( erp_vendor: str, desc_keyword: str, ) -> bool: - if voucher_no and not _contains_filter(row.get("voucher_no"), voucher_no): - return False + if voucher_no: + default_year = int(row.get("fiscal_year") or 0) or None + voucher_filter_key = normalize_compare_voucher_filter(voucher_no, default_year) + if not voucher_filter_key: + return False + row_key = _row_compare_voucher_key(row) + if voucher_filter_key not in row_key: + return False if draft_no and not _contains_filter(row.get("draft_no"), draft_no): return False wehago_account_candidates = [ @@ -14656,7 +21447,8 @@ def _fetch_status_detail_rows_from_db( voucher_rep = """ SELECT * FROM ( - SELECT fiscal_year, compare_voucher_no, proof_date, confirmed_no, draft_no, + SELECT id, source_file_id, sheet_name, row_number, + fiscal_year, compare_voucher_no, proof_date, confirmed_no, draft_no, account_code, account_name, vendor_name, desc1, desc2, debit_supply, credit_supply, ROW_NUMBER() OVER ( PARTITION BY fiscal_year, compare_voucher_no @@ -14672,6 +21464,10 @@ def _fetch_status_detail_rows_from_db( boundary_sql = _boundary_excluded_sql("l.ledger_date", "l.description", "l.account_name", "c.notes") select_sql = """ c.fiscal_year AS fiscal_year, + l.id AS ledger_source_row_id, + l.source_file_id AS ledger_source_file_id, + l.sheet_name AS ledger_source_sheet_name, + l.row_number AS ledger_source_row_number, COALESCE(l.voucher_no, c.voucher_no) AS voucher_no, COALESCE(l.ledger_date, '') AS ledger_date, COALESCE(l.account_code, '') AS ledger_account_code, @@ -14698,6 +21494,10 @@ def _fetch_status_detail_rows_from_db( elif status == "voucher_only": select_sql = """ c.fiscal_year AS fiscal_year, + v.id AS voucher_source_row_id, + v.source_file_id AS voucher_source_file_id, + v.sheet_name AS voucher_source_sheet_name, + v.row_number AS voucher_source_row_number, COALESCE(v.confirmed_no, v.draft_no, c.voucher_no) AS voucher_no, COALESCE(v.proof_date, '') AS proof_date, COALESCE(v.draft_no, '') AS draft_no, @@ -14721,6 +21521,14 @@ def _fetch_status_detail_rows_from_db( else: select_sql = """ c.fiscal_year AS fiscal_year, + l.id AS ledger_source_row_id, + l.source_file_id AS ledger_source_file_id, + l.sheet_name AS ledger_source_sheet_name, + l.row_number AS ledger_source_row_number, + v.id AS voucher_source_row_id, + v.source_file_id AS voucher_source_file_id, + v.sheet_name AS voucher_source_sheet_name, + v.row_number AS voucher_source_row_number, COALESCE(l.ledger_date, v.proof_date, '') AS ledger_date, COALESCE(l.voucher_no, v.confirmed_no, v.draft_no, c.voucher_no) AS voucher_no, COALESCE(v.draft_no, '') AS draft_no, @@ -15733,32 +22541,54 @@ def _build_voucher_sections_from_rows_by_status( clean(item.get("summary", {}).get("draft_no")), ) ) - voucher_sections = _promote_obvious_recheck_groups(voucher_sections) - voucher_sections = _retarget_obvious_recheck_groups(voucher_sections) - voucher_sections = _apply_vat_date_anchor_review(voucher_sections) - voucher_sections = _apply_grouped_cost_counterpart_review(voucher_sections) - voucher_sections = _dedupe_wehago_sections_to_single_status(voucher_sections) - voucher_sections = _dedupe_erp_sections_to_single_status(voucher_sections) - voucher_sections = _move_wehago_confirmed_reversal_pairs_to_excepted(voucher_sections) - voucher_sections = _apply_wehago_cancel_reissue_recheck(voucher_sections) - voucher_sections = _move_wehago_offset_tax_invoice_groups_to_excepted(voucher_sections) + + def traced_step(step_name: str, func: Callable[[dict[str, list[dict[str, Any]]]], dict[str, list[dict[str, Any]]]]) -> None: + nonlocal voucher_sections + if _trace_compare_projection_enabled(): + _trace_compare_projection( + f"{step_name}_start", + counts={key: len(value or []) for key, value in voucher_sections.items()}, + ) + started_at = time.monotonic() + voucher_sections = func(voucher_sections) + _trace_compare_projection( + f"{step_name}_done", + elapsed_sec=round(time.monotonic() - started_at, 3), + counts={key: len(value or []) for key, value in voucher_sections.items()}, + ) + return + voucher_sections = func(voucher_sections) + + traced_step("promote_obvious_recheck_groups", _promote_obvious_recheck_groups) + traced_step("retarget_obvious_recheck_groups", _retarget_obvious_recheck_groups) + traced_step("apply_vat_date_anchor_review", _apply_vat_date_anchor_review) + traced_step("apply_grouped_cost_counterpart_review", _apply_grouped_cost_counterpart_review) + traced_step("apply_highpass_bundle_trace_review", _apply_highpass_bundle_trace_review) + traced_step("apply_direct_grouped_cost_business_review", _apply_direct_grouped_cost_business_review) + traced_step("apply_unmatched_unconnected_trace_review", _apply_unmatched_unconnected_trace_review) + traced_step("apply_wehago_anchored_loan_recovery_multi_match", _apply_wehago_anchored_loan_recovery_multi_match) + traced_step("apply_guarded_regression_case_rules", _apply_guarded_regression_case_rules) + traced_step("dedupe_wehago_sections_to_single_status", _dedupe_wehago_sections_to_single_status) + traced_step("dedupe_erp_sections_to_single_status", _dedupe_erp_sections_to_single_status) + traced_step("move_wehago_revenue_reversal_pairs_to_excepted", _move_wehago_revenue_reversal_pairs_to_excepted) + traced_step("move_wehago_confirmed_reversal_pairs_to_excepted", _move_wehago_confirmed_reversal_pairs_to_excepted) + traced_step("apply_wehago_cancel_reissue_recheck", _apply_wehago_cancel_reissue_recheck) + traced_step("move_wehago_purchase_sales_cancel_pairs_to_excepted", _move_wehago_purchase_sales_cancel_pairs_to_excepted) + traced_step("move_wehago_offset_tax_invoice_groups_to_excepted", _move_wehago_offset_tax_invoice_groups_to_excepted) + if _trace_compare_projection_enabled(): + _trace_compare_projection( + "boundary_excepted_split_start", + counts={key: len(value or []) for key, value in voucher_sections.items()}, + ) + boundary_started_at = time.monotonic() retained_unmatched: list[dict[str, Any]] = [] excepted_groups: list[dict[str, Any]] = [] + vat_boundary_recheck_groups: list[dict[str, Any]] = [] for group in voucher_sections.get("voucher_unmatched", []) or []: if _is_boundary_vat_tax_invoice_group(group) and _voucher_group_has_erp_counterpart(group): - group_copy = { - "summary": dict(group.get("summary") or {}), - "rows": [dict(row) for row in group.get("rows", []) or []], - } - group_copy["summary"]["status_label"] = "Matched" - review_reason = clean(group_copy["summary"].get("review_reason")) - group_copy["summary"]["review_reason"] = " / ".join( - item for item in [review_reason, "VAT_TAX_INVOICE_BOUNDARY_MATCH"] if item + vat_boundary_recheck_groups.append( + _copy_group_with_status_and_reason(group, "Recheck", "VAT_BOUNDARY_REVIEW_CANDIDATE") ) - for row in group_copy["rows"]: - row["status_label"] = "Matched" - row["review_reason"] = clean(row.get("review_reason")) or "VAT_TAX_INVOICE_BOUNDARY_MATCH" - voucher_sections["voucher_matched"].append(group_copy) continue is_excepted, reason = is_wehago_excepted_group(group) if not is_excepted: @@ -15786,7 +22616,1983 @@ def _build_voucher_sections_from_rows_by_status( clean(item.get("summary", {}).get("voucher_no")), ), ) + if vat_boundary_recheck_groups: + voucher_sections["voucher_recheck"] = sorted( + list(voucher_sections.get("voucher_recheck", []) or []) + vat_boundary_recheck_groups, + key=lambda item: ( + int(item.get("summary", {}).get("fiscal_year") or 0), + clean(item.get("summary", {}).get("ledger_date")), + clean(item.get("summary", {}).get("voucher_no")), + ), + ) + if _trace_compare_projection_enabled(): + _trace_compare_projection( + "boundary_excepted_split_done", + elapsed_sec=round(time.monotonic() - boundary_started_at, 3), + counts={key: len(value or []) for key, value in voucher_sections.items()}, + ) + traced_step("apply_final_display_projection_guard", _apply_final_display_projection_guard) + if _trace_compare_projection_enabled(): + _trace_compare_projection( + "build_hanmac_unconnected_sections_start", + counts={key: len(value or []) for key, value in voucher_sections.items()}, + ) + hanmac_started_at = time.monotonic() voucher_sections["hanmac_unconnected"] = build_hanmac_unconnected_sections(voucher_sections) + if _trace_compare_projection_enabled(): + _trace_compare_projection( + "build_hanmac_unconnected_sections_done", + elapsed_sec=round(time.monotonic() - hanmac_started_at, 3), + counts={key: len(value or []) for key, value in voucher_sections.items()}, + ) + return voucher_sections + + +_LOAN_MULTI_MATCH_REASON = "WEHAGO_ANCHORED_LOAN_RECOVERY_MULTI_MATCH" + + +def _loan_match_family(account_code: Any, account_name: Any) -> str: + code = clean(account_code) + name = normalize_text(account_name) + if code in {"137", "10313701"} or "대여금" in name or "대출금" in name or "주종단기채권" in name: + return "loan" + if code in {"901", "70110103"} or "이자수익" in name or "대출이자" in name: + return "interest" + if code in {"103", "10110501", "10110101", "10111901", "10113701", "10115101", "10115102", "10115301"} or "보통예금" in name: + return "bank" + if code in {"20111103"} or "미지급금" in name: + return "payable" + return "" + + +def _loan_group_has_signal(group: dict[str, Any], side: str) -> bool: + summary = group.get("summary") or {} + text = normalize_text( + " ".join( + [ + clean(summary.get("review_reason")), + clean(summary.get("ledger_accounts")), + clean(summary.get("voucher_accounts")), + clean(summary.get("ledger_vendors")), + clean(summary.get("voucher_vendors")), + *[clean(row.get("ledger_desc")) for row in group.get("rows", []) or []], + *[clean(row.get("voucher_desc")) for row in group.get("rows", []) or []], + ] + ) + ) + if any(token in text for token in ("대여금", "대출금", "가불금", "주임종대여금")): + return True + summary_accounts = clean(summary.get("ledger_accounts") if side in {"ledger", "both"} else summary.get("voucher_accounts")) + if any(account.strip() in {"137", "10313701"} for account in summary_accounts.split(",")): + return True + for row in group.get("rows", []) or []: + if side in {"ledger", "both"}: + if _loan_match_family(row.get("ledger_account_code"), row.get("ledger_account_name")) == "loan": + return True + if side in {"voucher", "both"}: + if _loan_match_family(row.get("voucher_account_code"), row.get("voucher_account_name")) == "loan": + return True + return False + + +def _loan_group_vector(group: dict[str, Any], side: str) -> dict[str, float]: + vector = {"loan": 0.0, "interest": 0.0, "bank": 0.0, "payable": 0.0} + for row in group.get("rows", []) or []: + if side in {"ledger", "both"}: + family = _loan_match_family(row.get("ledger_account_code"), row.get("ledger_account_name")) + if family: + vector[family] += parse_amount(row.get("ledger_debit")) - parse_amount(row.get("ledger_credit")) + if side in {"voucher", "both"}: + family = _loan_match_family(row.get("voucher_account_code"), row.get("voucher_account_name")) + if family: + vector[family] += parse_amount(row.get("voucher_debit")) - parse_amount(row.get("voucher_credit")) + if not any(abs(value) >= 0.5 for value in vector.values()): + summary = group.get("summary") or {} + accounts = { + item.strip() + for item in clean(summary.get("ledger_accounts") if side == "ledger" else summary.get("voucher_accounts")).split(",") + if item.strip() + } + debit = parse_amount(summary.get("ledger_debit") if side == "ledger" else summary.get("voucher_debit")) + credit = parse_amount(summary.get("ledger_credit") if side == "ledger" else summary.get("voucher_credit")) + if accounts & {"137", "10313701"} and accounts & {"103", "10110501"} and abs(debit - credit) < 0.5: + amount = max(abs(debit), abs(credit)) + if amount >= 1_000_000: + # Summary-only fallback: infer direction from bank/loan account set. + text = _loan_group_text(group) + if "이자수익" in text or "901" in accounts or "70110103" in accounts: + vector["bank"] = amount + vector["loan"] = -min(amount, credit if credit else amount) + if amount > abs(vector["loan"]): + vector["interest"] = -(amount - abs(vector["loan"])) + else: + vector["loan"] = amount + vector["bank"] = -amount + return {key: round(value, 2) for key, value in vector.items() if abs(value) >= 0.5} + + +def _loan_vector_matches(wehago_vector: dict[str, float], erp_vector: dict[str, float]) -> bool: + if not wehago_vector or not erp_vector: + return False + if abs(wehago_vector.get("loan", 0.0)) < 1_000_000: + return False + compare_keys = {"loan", "interest", "bank"} + for key in compare_keys: + if abs(wehago_vector.get(key, 0.0) - erp_vector.get(key, 0.0)) >= 0.5: + return False + return abs(erp_vector.get("payable", 0.0)) < 0.5 + + +def _loan_group_date(group: dict[str, Any], side: str) -> date | None: + summary = group.get("summary") or {} + candidates: list[Any] = [] + if side == "ledger": + candidates.extend([summary.get("ledger_date"), summary.get("proof_date")]) + candidates.extend(row.get("ledger_date") for row in group.get("rows", []) or []) + else: + candidates.extend([summary.get("proof_date"), summary.get("ledger_date")]) + candidates.extend(row.get("proof_date") for row in group.get("rows", []) or []) + fiscal_year = int(summary.get("fiscal_year") or 0) or None + for value in candidates: + parsed_text = parse_excel_date(value, default_year=fiscal_year) + parsed = _parse_iso_date(value) or _parse_iso_date(parsed_text) + if parsed: + return parsed + return None + + +def _loan_group_text(group: dict[str, Any]) -> str: + summary = group.get("summary") or {} + return normalize_text( + " ".join( + [ + clean(summary.get("review_reason")), + clean(summary.get("ledger_vendors")), + clean(summary.get("voucher_vendors")), + *[clean(row.get("ledger_vendor")) for row in group.get("rows", []) or []], + *[clean(row.get("voucher_vendor")) for row in group.get("rows", []) or []], + *[clean(row.get("ledger_desc")) for row in group.get("rows", []) or []], + *[clean(row.get("voucher_desc")) for row in group.get("rows", []) or []], + ] + ) + ) + + +def _loan_groups_related(wehago_group: dict[str, Any], erp_groups: list[dict[str, Any]]) -> bool: + wehago_date = _loan_group_date(wehago_group, "ledger") + erp_dates = [_loan_group_date(group, "voucher") for group in erp_groups] + if wehago_date and any(erp_date and abs((wehago_date - erp_date).days) <= 5 for erp_date in erp_dates): + return True + source_text = _loan_group_text(wehago_group) + return bool(source_text and all(("대여금" in _loan_group_text(group) or "대출금" in _loan_group_text(group) or "가불금" in _loan_group_text(group)) for group in erp_groups)) + + +def _loan_vector_principal_amount(vector: dict[str, float]) -> float: + return round(abs(vector.get("loan", 0.0)), 2) + + +def _loan_group_date_distance(left: dict[str, Any], right: dict[str, Any]) -> int: + left_date = _loan_group_date(left, "ledger") + right_date = _loan_group_date(right, "voucher") + if not left_date or not right_date: + return 999 + return abs((left_date - right_date).days) + + +def _copy_wehago_anchor_with_erp_groups( + wehago_group: dict[str, Any], + erp_groups: list[dict[str, Any]], +) -> dict[str, Any]: + copied = { + "summary": dict(wehago_group.get("summary") or {}), + "rows": [dict(row) for row in wehago_group.get("rows", []) or []], + } + erp_voucher_nos: list[str] = [] + erp_draft_nos: list[str] = [] + erp_accounts: list[str] = [] + erp_vendors: list[str] = [] + for erp_group in erp_groups: + erp_summary = erp_group.get("summary") or {} + for value, target in ( + (erp_summary.get("voucher_no"), erp_voucher_nos), + (erp_summary.get("draft_no"), erp_draft_nos), + (erp_summary.get("voucher_accounts"), erp_accounts), + (erp_summary.get("voucher_vendors"), erp_vendors), + ): + for part in [item.strip() for item in clean(value).split(",") if item.strip()]: + if part not in target: + target.append(part) + for row in erp_group.get("rows", []) or []: + copied["rows"].append( + { + **dict(row), + "status_label": "Matched", + "ledger_account_code": "", + "ledger_account_name": "", + "ledger_vendor": "", + "ledger_debit": "", + "ledger_credit": "", + "ledger_desc": "", + "review_reason": _LOAN_MULTI_MATCH_REASON, + } + ) + summary = copied["summary"] + summary["status_label"] = "Matched" + summary["voucher_no"] = clean(summary.get("voucher_no")) + summary["draft_no"] = ", ".join(erp_draft_nos) or clean(summary.get("draft_no")) + summary["voucher_accounts"] = ", ".join(erp_accounts) or clean(summary.get("voucher_accounts")) + summary["voucher_vendors"] = ", ".join(erp_vendors) or clean(summary.get("voucher_vendors")) + summary["voucher_row_count"] = sum(int((group.get("summary") or {}).get("voucher_row_count") or 0) for group in erp_groups) + summary["voucher_debit"] = sum(parse_amount((group.get("summary") or {}).get("voucher_debit")) for group in erp_groups) + summary["voucher_credit"] = sum(parse_amount((group.get("summary") or {}).get("voucher_credit")) for group in erp_groups) + existing_reason = clean(summary.get("review_reason")) + summary["review_reason"] = " / ".join(item for item in [existing_reason, _LOAN_MULTI_MATCH_REASON] if item) + for row in copied["rows"]: + row["status_label"] = "Matched" + if clean(row.get("ledger_account_name")) or clean(row.get("voucher_account_name")): + row["review_reason"] = clean(row.get("review_reason")) or _LOAN_MULTI_MATCH_REASON + copied["rows"].sort( + key=lambda item: ( + clean(item.get("ledger_date")) or clean(item.get("proof_date")), + clean(item.get("voucher_no")), + clean(item.get("draft_no")), + clean(item.get("ledger_account_name")) or clean(item.get("voucher_account_name")), + -parse_amount(item.get("ledger_debit") or item.get("voucher_debit")), + -parse_amount(item.get("ledger_credit") or item.get("voucher_credit")), + ) + ) + return copied + + +def _copy_erp_multi_match_group(erp_groups: list[dict[str, Any]], wehago_group: dict[str, Any]) -> dict[str, Any]: + first_summary = dict((erp_groups[0].get("summary") if erp_groups else {}) or {}) + rows: list[dict[str, Any]] = [] + voucher_nos: list[str] = [] + draft_nos: list[str] = [] + for group in erp_groups: + summary = group.get("summary") or {} + for value, target in ((summary.get("voucher_no"), voucher_nos), (summary.get("draft_no"), draft_nos)): + for part in [item.strip() for item in clean(value).split(",") if item.strip()]: + if part not in target: + target.append(part) + rows.extend(dict(row, status_label="Matched", review_reason=clean(row.get("review_reason")) or _LOAN_MULTI_MATCH_REASON) for row in group.get("rows", []) or []) + for row in wehago_group.get("rows", []) or []: + rows.append( + { + **dict(row), + "status_label": "Matched", + "voucher_account_code": "", + "voucher_account_name": "", + "voucher_vendor": "", + "voucher_debit": "", + "voucher_credit": "", + "voucher_desc": "", + "review_reason": _LOAN_MULTI_MATCH_REASON, + } + ) + first_summary["status_label"] = "Matched" + first_summary["voucher_no"] = ", ".join(voucher_nos) or clean(first_summary.get("voucher_no")) + first_summary["draft_no"] = ", ".join(draft_nos) or clean(first_summary.get("draft_no")) + first_summary["review_reason"] = " / ".join( + item for item in [clean(first_summary.get("review_reason")), _LOAN_MULTI_MATCH_REASON] if item + ) + return {"summary": first_summary, "rows": rows} + + +def _apply_wehago_anchored_loan_recovery_multi_match( + voucher_sections: dict[str, list[dict[str, Any]]], +) -> dict[str, list[dict[str, Any]]]: + wehago_candidates = [ + group + for status_key in ("voucher_recheck", "voucher_unmatched") + for group in list(voucher_sections.get(status_key) or []) + if _loan_group_has_signal(group, "ledger") + ] + erp_candidates = [ + group + for status_key in ("erp_voucher_unmatched", "voucher_recheck", "erp_voucher_matched") + for group in list(voucher_sections.get(status_key) or []) + if _loan_group_has_signal(group, "voucher") + ] + if not wehago_candidates or not erp_candidates: + return voucher_sections + + candidate_meta: list[tuple[dict[str, Any], dict[str, float], float]] = [] + for group in erp_candidates: + vector = _loan_group_vector(group, "voucher") + principal = _loan_vector_principal_amount(vector) + if principal >= 1_000_000: + candidate_meta.append((group, vector, principal)) + if not candidate_meta: + return voucher_sections + + promoted: list[tuple[dict[str, Any], list[dict[str, Any]]]] = [] + used_erp_ids: set[int] = set() + for wehago_group in wehago_candidates: + source_vector = _loan_group_vector(wehago_group, "ledger") + if not source_vector: + continue + source_principal = _loan_vector_principal_amount(source_vector) + if source_principal < 1_000_000: + continue + best_groups: list[dict[str, Any]] = [] + best_score: tuple[int, int, int] | None = None + available = [ + (group, vector) + for group, vector, principal in candidate_meta + if id(group) not in used_erp_ids + and abs(principal - source_principal) < 0.5 + and _loan_group_date_distance(wehago_group, group) <= 5 + ] + if not available: + continue + combos: list[tuple[tuple[dict[str, Any], dict[str, float]], ...]] = [(item,) for item in available] + combos.extend( + (available[left], available[right]) + for left in range(len(available)) + for right in range(left + 1, len(available)) + if len(available) <= 40 + ) + for combo in combos: + combo_groups = [item[0] for item in combo] + if not _loan_groups_related(wehago_group, combo_groups): + continue + combined: dict[str, float] = {} + for _group, vector in combo: + for key, value in vector.items(): + combined[key] = round(combined.get(key, 0.0) + value, 2) + if not _loan_vector_matches(source_vector, combined): + continue + source_date = _loan_group_date(wehago_group, "ledger") + distances = [] + for group in combo_groups: + target_date = _loan_group_date(group, "voucher") + if source_date and target_date: + distances.append(abs((source_date - target_date).days)) + score = (len(combo_groups), max(distances or [999]), sum(len(group.get("rows", []) or []) for group in combo_groups)) + if best_score is None or score < best_score: + best_score = score + best_groups = combo_groups + if best_groups: + promoted.append((wehago_group, best_groups)) + used_erp_ids.update(id(group) for group in best_groups) + + if not promoted: + return voucher_sections + + promoted_wehago_ids = {id(wehago_group) for wehago_group, _groups in promoted} + promoted_erp_ids = {id(group) for _wehago_group, groups in promoted for group in groups} + for status_key in ("voucher_recheck", "voucher_unmatched"): + voucher_sections[status_key] = [ + group for group in list(voucher_sections.get(status_key) or []) if id(group) not in promoted_wehago_ids + ] + for status_key in ("erp_voucher_unmatched", "erp_voucher_matched"): + voucher_sections[status_key] = [ + group for group in list(voucher_sections.get(status_key) or []) if id(group) not in promoted_erp_ids + ] + voucher_sections["voucher_recheck"] = [ + group + for group in list(voucher_sections.get("voucher_recheck") or []) + if id(group) not in promoted_erp_ids + ] + for wehago_group, erp_groups in promoted: + voucher_sections["voucher_matched"].append(_copy_wehago_anchor_with_erp_groups(wehago_group, erp_groups)) + voucher_sections["erp_voucher_matched"].append(_copy_erp_multi_match_group(erp_groups, wehago_group)) + return voucher_sections + + +_SOCIAL_INSURANCE_MATCH_REASON = "SOCIAL_INSURANCE_ACCOUNT_FAMILY_MATCH" +_INVALID_REVERSAL_MATCH_REASON = "INVALID_REVERSAL_TO_NORMAL_MATCH" +_YEAR_OPENING_SUBSTITUTION_REASON = "WEHAGO_EXCEPTED_YEAR_OPENING_SUBSTITUTION" +_GRANT_SETTLEMENT_DIRECT_MATCH_REVIEW_REASON = "GRANT_SETTLEMENT_NOT_DIRECT_SOURCE_MATCH" +_WEAK_ONLY_TAX_INVOICE_MATCH_REVIEW_REASON = "TAX_INVOICE_CORE_ROWS_NOT_MATCHED" +_PROJECT_TOKEN_CONFLICT_REVIEW_REASON = "PROJECT_TOKEN_CONFLICT" +_PARTIAL_WEAK_MONTH_RECHECK_REASON = "PARTIAL_WEAK_MONTH_MISMATCH" +_ERP_PARTIAL_ROWS_ONLY_RECHECK_REASON = "ERP_PARTIAL_VOUCHER_ROWS_ONLY" + +_SETTLEMENT_FAMILIES_FOR_GUARD = {"payable", "bank", "receivable", "advance"} +_SETTLEMENT_ACCOUNT_TOKENS_FOR_GUARD = ( + "미지급금", + "외상매입금", + "외상매출금", + "미수금", + "보통예금", + "당좌예금", + "현금", + "카드", +) +_BUSINESS_ACCOUNT_TOKENS_FOR_GUARD = ( + "국고보조금", + "복리후생비", + "지급수수료", + "여비교통비", + "통신비", + "지급임차료", + "건물관리비", + "소모품비", + "광고선전비", + "교육훈련비", + "도서인쇄비", + "세금과공과", + "차량유지비", + "접대비", + "원가", + "매출", + "매입", + "수익", + "비용", +) +_WEAK_ONLY_ACCOUNT_TOKENS_FOR_GUARD = ( + "잡이익", + "잡손실", + "미지급금", + "보통예금", + "현금", + "전도금", +) +_PROJECT_STOP_TOKENS_FOR_GUARD = { + "운영비", + "정산", + "전기요금", + "전화요금", + "난방비", + "월정료", + "임차료", + "관리비", + "공급가액", +} +_OPENING_SUBSTITUTION_TOKENS_FOR_GUARD = ( + "전기대체", + "전기전표대체", + "전기계정대체", + "계정대체", + "거래처대체", + "대체분개", +) + + +def _group_text_for_guarded_rules(group: dict[str, Any]) -> str: + summary = group.get("summary") or {} + parts = [ + clean(summary.get("ledger_accounts")), + clean(summary.get("voucher_accounts")), + clean(summary.get("ledger_vendors")), + clean(summary.get("voucher_vendors")), + clean(summary.get("review_reason")), + clean(summary.get("voucher_no")), + clean(summary.get("draft_no")), + ] + for row in list(group.get("rows") or []): + parts.extend( + [ + clean(row.get("ledger_account_name")), + clean(row.get("voucher_account_name")), + clean(row.get("ledger_vendor")), + clean(row.get("voucher_vendor")), + clean(row.get("ledger_desc")), + clean(row.get("voucher_desc")), + clean(row.get("draft_no")), + ] + ) + return normalize_text(" ".join(part for part in parts if part)) + + +def _account_text_for_guard(account_code: Any, account_name: Any) -> str: + return normalize_text(" ".join(part for part in (clean(account_code), clean(account_name)) if part)) + + +def _is_grant_like_account_for_guard(account_code: Any, account_name: Any) -> bool: + account_code_text = re.sub(r"\D", "", clean(account_code)) + account_text = _account_text_for_guard(account_code, account_name) + return account_code_text == "104" or "국고보조금" in account_text + + +def _is_settlement_account_for_guard(account_code: Any, account_name: Any) -> bool: + family = _classify_account_family(account_code, account_name) + if family in _SETTLEMENT_FAMILIES_FOR_GUARD: + return True + account_text = _account_text_for_guard(account_code, account_name) + return any(token in account_text for token in _SETTLEMENT_ACCOUNT_TOKENS_FOR_GUARD) + + +def _is_business_account_for_guard(account_code: Any, account_name: Any) -> bool: + if _is_grant_like_account_for_guard(account_code, account_name): + return True + family = _classify_account_family(account_code, account_name) + category = _classify_account_category(account_code, account_name) + if family in _SETTLEMENT_FAMILIES_FOR_GUARD or _is_vat_family(family): + return False + if category in {"expense", "revenue"}: + return True + account_text = _account_text_for_guard(account_code, account_name) + return any(token in account_text for token in _BUSINESS_ACCOUNT_TOKENS_FOR_GUARD) + + +def _is_trade_receivable_payable_account_for_guard(account_code: Any, account_name: Any) -> bool: + account_text = _account_text_for_guard(account_code, account_name) + return "외상매입금" in account_text or "외상매출금" in account_text or "용역미수금" in account_text + + +def _is_weak_only_account_for_guard(account_code: Any, account_name: Any) -> bool: + account_text = _account_text_for_guard(account_code, account_name) + if _is_trade_receivable_payable_account_for_guard(account_code, account_name): + return False + return any(token in account_text for token in _WEAK_ONLY_ACCOUNT_TOKENS_FOR_GUARD) + + +def _is_tax_invoice_core_account_for_guard(account_code: Any, account_name: Any) -> bool: + family = _classify_account_family(account_code, account_name) + if _is_vat_family(family): + return True + if _is_trade_receivable_payable_account_for_guard(account_code, account_name): + return True + if _is_business_account_for_guard(account_code, account_name): + return True + return False + + +def _group_has_tax_invoice_core_structure_for_guard(group: dict[str, Any]) -> bool: + rows = list(group.get("rows") or []) + if not rows: + return False + has_vat = any( + _is_vat_family(_row_account_family(row, "ledger")) + or _is_vat_family(_row_account_family(row, "voucher")) + for row in rows + ) + if not has_vat: + return False + has_trade = any( + _is_trade_receivable_payable_account_for_guard(row.get("ledger_account_code"), row.get("ledger_account_name")) + or _is_trade_receivable_payable_account_for_guard(row.get("voucher_account_code"), row.get("voucher_account_name")) + for row in rows + ) + has_business = any( + _is_business_account_for_guard(row.get("ledger_account_code"), row.get("ledger_account_name")) + or _is_business_account_for_guard(row.get("voucher_account_code"), row.get("voucher_account_name")) + for row in rows + ) + return has_trade or has_business + + +def _row_has_direct_erp_match_for_guard(row: dict[str, Any]) -> bool: + return bool( + clean(row.get("draft_no")) + and clean(row.get("ledger_account_name")) + and clean(row.get("voucher_account_name")) + and ( + abs(parse_amount(row.get("ledger_debit"))) >= 0.5 + or abs(parse_amount(row.get("ledger_credit"))) >= 0.5 + ) + and ( + abs(parse_amount(row.get("voucher_debit"))) >= 0.5 + or abs(parse_amount(row.get("voucher_credit"))) >= 0.5 + ) + ) + + +def _group_has_unmatched_tax_invoice_core_ledger_rows_for_guard(group: dict[str, Any]) -> bool: + if not _group_has_tax_invoice_core_structure_for_guard(group): + return False + for row in list(group.get("rows") or []): + if not clean(row.get("ledger_account_name")): + continue + if clean(row.get("voucher_account_name")): + continue + amount = max(abs(parse_amount(row.get("ledger_debit"))), abs(parse_amount(row.get("ledger_credit")))) + if amount < 0.5: + continue + if _is_tax_invoice_core_account_for_guard(row.get("ledger_account_code"), row.get("ledger_account_name")): + return True + return False + + +def _group_has_only_weak_direct_matches_for_guard(group: dict[str, Any]) -> bool: + direct_rows = [row for row in list(group.get("rows") or []) if _row_has_direct_erp_match_for_guard(row)] + if not direct_rows: + return False + has_strong_direct = False + for row in direct_rows: + ledger_is_weak = _is_weak_only_account_for_guard(row.get("ledger_account_code"), row.get("ledger_account_name")) + voucher_is_weak = _is_weak_only_account_for_guard(row.get("voucher_account_code"), row.get("voucher_account_name")) + ledger_is_core = _is_tax_invoice_core_account_for_guard(row.get("ledger_account_code"), row.get("ledger_account_name")) + voucher_is_core = _is_tax_invoice_core_account_for_guard(row.get("voucher_account_code"), row.get("voucher_account_name")) + if ledger_is_core and voucher_is_core and not (ledger_is_weak or voucher_is_weak): + has_strong_direct = True + break + return not has_strong_direct + + +def _row_has_strong_non_amount_guard_evidence(row: dict[str, Any]) -> bool: + if not _row_has_direct_erp_match_for_guard(row): + return False + ledger_amount = max(abs(parse_amount(row.get("ledger_debit"))), abs(parse_amount(row.get("ledger_credit")))) + voucher_amount = max(abs(parse_amount(row.get("voucher_debit"))), abs(parse_amount(row.get("voucher_credit")))) + if ledger_amount < 0.5 or abs(ledger_amount - voucher_amount) >= 0.5: + return False + if _erp_row_side(row, "ledger") != _erp_row_side(row, "voucher"): + return False + + ledger_account = normalize_text(row.get("ledger_account_name")) + voucher_account = normalize_text(row.get("voucher_account_name")) + same_or_compatible_account = bool( + ledger_account + and voucher_account + and ( + ledger_account == voucher_account + or ledger_account in voucher_account + or voucher_account in ledger_account + or _account_names_compatible(row, row) + or ( + _classify_account_family(row.get("ledger_account_code"), row.get("ledger_account_name")) + == _classify_account_family(row.get("voucher_account_code"), row.get("voucher_account_name")) + ) + ) + ) + if not same_or_compatible_account: + return False + + return bool( + _core_token_overlap(row.get("ledger_vendor"), row.get("voucher_vendor")) + or _contained_core_desc_match(row.get("ledger_desc"), row.get("voucher_desc")) + or _core_token_strong_overlap(row.get("ledger_desc"), row.get("voucher_desc")) + ) + + +def _group_has_strong_non_amount_guard_evidence(group: dict[str, Any]) -> bool: + rows = list(group.get("rows") or []) + if any(_row_has_strong_non_amount_guard_evidence(row) for row in rows): + return True + reason_text = normalize_text(_group_text_for_guarded_rules(group)) + strong_reason_tokens = ( + "directcostbusinessinternalmatch", + "directcostbusinessaccountmatch", + "erpgroupedcostcounterpartmatch", + "vatdateretargetmatch", + "vatdaterecheckinternalmatch", + "rawerpsourcetracematch", + "recheckretargetmatch", + "highpasscardbundlematch", + ) + return any(token in reason_text for token in strong_reason_tokens) + + +def _project_token_for_guard(value: Any) -> str: + text_value = clean(value) + if not text_value: + return "" + if "/" in text_value: + token = normalize_text(text_value.split("/", 1)[0]) + else: + token = "" + normalized_text = normalize_text(text_value) + matched = re.match(r"([0-9a-z가-힣]{2,30})\s*(?:운영비|주재비|관리비|정산|전기요금|전화요금|난방비)", normalized_text) + if matched: + token = matched.group(1) + if not token or token in _PROJECT_STOP_TOKENS_FOR_GUARD: + return "" + if len(token) < 2: + return "" + return token + + +def _rows_have_project_token_conflict_for_guard(left_row: dict[str, Any], right_row: dict[str, Any] | None = None) -> bool: + right = right_row or left_row + ledger_token = _project_token_for_guard(left_row.get("ledger_desc")) + voucher_token = _project_token_for_guard(right.get("voucher_desc")) + return bool(ledger_token and voucher_token and ledger_token != voucher_token) + + +def _group_has_project_token_conflict_for_guard(group: dict[str, Any]) -> bool: + ledger_tokens: set[str] = set() + voucher_tokens: set[str] = set() + for row in list(group.get("rows") or []): + ledger_token = _project_token_for_guard(row.get("ledger_desc")) + voucher_token = _project_token_for_guard(row.get("voucher_desc")) + if ledger_token: + ledger_tokens.add(ledger_token) + if voucher_token: + voucher_tokens.add(voucher_token) + if not ledger_tokens or not voucher_tokens: + return False + return ledger_tokens.isdisjoint(voucher_tokens) + + +def _service_month_tokens_for_guard(value: Any) -> set[tuple[int, int]]: + text_value = normalize_text(value) + if not text_value: + return set() + tokens: set[tuple[int, int]] = set() + for matched in re.finditer(r"(20\d{2})\s*년?\s*0?([1-9]|1[0-2])\s*월", text_value): + tokens.add((int(matched.group(1)), int(matched.group(2)))) + for matched in re.finditer(r"(\d{2})\s*년\s*0?([1-9]|1[0-2])\s*월", text_value): + tokens.add((2000 + int(matched.group(1)), int(matched.group(2)))) + for matched in re.finditer(r"(? bool: + left_tokens = _service_month_tokens_for_guard(left_value) + right_tokens = _service_month_tokens_for_guard(right_value) + if not left_tokens or not right_tokens: + return False + + def normalize_token(token: tuple[int, int]) -> tuple[int, int]: + year, month = token + return (year or int(fiscal_year or 0), month) + + left_normalized = {normalize_token(token) for token in left_tokens} + right_normalized = {normalize_token(token) for token in right_tokens} + if any(not year for year, _month in left_normalized | right_normalized): + return False + return left_normalized.isdisjoint(right_normalized) + + +def _row_has_service_month_conflict_for_guard(row: dict[str, Any]) -> bool: + fiscal_year = int(row.get("fiscal_year") or 0) + if not _service_months_conflict_for_guard(row.get("ledger_desc"), row.get("voucher_desc"), fiscal_year): + return False + ledger_date = _parse_row_date_with_year(row, "ledger_date") + proof_date = _parse_row_date_with_year(row, "proof_date") + if ledger_date and proof_date and ledger_date.year == proof_date.year and ledger_date.month == proof_date.month: + return False + return True + + +def _normalized_service_month_tokens_for_guard(values: Iterable[Any], fiscal_year: int = 0) -> set[tuple[int, int]]: + tokens: set[tuple[int, int]] = set() + year_hint = int(fiscal_year or 0) + for value in values: + for year, month in _service_month_tokens_for_guard(value): + normalized_year = year or year_hint + if normalized_year: + tokens.add((normalized_year, month)) + return tokens + + +def _group_has_service_month_conflict_for_guard(group: dict[str, Any]) -> bool: + rows = list(group.get("rows") or []) + if not rows: + return False + fiscal_year = int((group.get("summary") or {}).get("fiscal_year") or 0) + ledger_months = _normalized_service_month_tokens_for_guard( + [row.get("ledger_desc") for row in rows if clean(row.get("ledger_account_name"))], + fiscal_year, + ) + voucher_months = _normalized_service_month_tokens_for_guard( + [row.get("voucher_desc") for row in rows if clean(row.get("voucher_account_name"))], + fiscal_year, + ) + return bool(ledger_months and voucher_months and ledger_months.isdisjoint(voucher_months)) + + +def _group_has_tax_anchor_date_conflict_for_guard(group: dict[str, Any]) -> bool: + rows = list(group.get("rows") or []) + if not rows: + return False + ledger_dates = { + parsed + for row in rows + if clean(row.get("ledger_account_name")) + for parsed in [_parse_row_date_with_year(row, "ledger_date")] + if parsed + } + # VAT rows often have an empty proof_date. Use explicit proof dates from sibling + # ERP rows in the same voucher group as the tax-document anchor instead of + # treating the VAT row as date-less. + voucher_dates = { + parsed + for row in rows + if clean(row.get("voucher_account_name")) + for parsed in [_parse_row_date_with_year(row, "proof_date")] + if parsed + } + if not ledger_dates or not voucher_dates: + return False + return min(abs((ledger_date - voucher_date).days) for ledger_date in ledger_dates for voucher_date in voucher_dates) > 62 + + +def _compare_row_effective_month_gap_for_guard(row: dict[str, Any]) -> int | None: + ledger_date = _parse_row_date_with_year(row, "ledger_date") + voucher_date = _parse_iso_date(row.get("proof_date")) or _infer_compare_row_proof_date(row) + if ledger_date is None or voucher_date is None: + return None + return _month_gap(ledger_date, voucher_date) + + +def _row_has_meaningful_amount_for_guard(row: dict[str, Any], prefix: str) -> bool: + return ( + abs(parse_amount(row.get(f"{prefix}_debit"))) >= 0.5 + or abs(parse_amount(row.get(f"{prefix}_credit"))) >= 0.5 + ) + + +def _row_has_explicit_proof_date_match_for_guard(row: Mapping[str, Any]) -> bool: + ledger_date = _parse_row_date_with_year(dict(row), "ledger_date") + proof_date = _parse_iso_date(row.get("proof_date")) + return bool(ledger_date and proof_date and ledger_date == proof_date) + + +def _row_has_explicit_proof_date_conflict_for_guard(row: Mapping[str, Any]) -> bool: + ledger_date = _parse_row_date_with_year(dict(row), "ledger_date") + proof_date = _parse_iso_date(row.get("proof_date")) + if not ledger_date or not proof_date: + return False + return ledger_date != proof_date and abs((ledger_date - proof_date).days) > 62 + + +def _row_is_tax_invoice_sensitive_for_guard(row: Mapping[str, Any]) -> bool: + ledger_text = normalize_text(row.get("ledger_account_name")) + voucher_text = normalize_text(row.get("voucher_account_name")) + account_text = f"{ledger_text} {voucher_text}" + if any(token in account_text for token in ("부가세", "매입세액", "매출세액", "부가세대급금", "부가세예수금")): + return True + return bool( + _is_tax_invoice_core_account_for_guard(row.get("ledger_account_code"), row.get("ledger_account_name")) + or _is_tax_invoice_core_account_for_guard(row.get("voucher_account_code"), row.get("voucher_account_name")) + ) + + +def _row_has_strong_exact_desc_direct_evidence_for_guard(row: Mapping[str, Any]) -> bool: + ledger_amount = max(abs(parse_amount(row.get("ledger_debit"))), abs(parse_amount(row.get("ledger_credit")))) + voucher_amount = max(abs(parse_amount(row.get("voucher_debit"))), abs(parse_amount(row.get("voucher_credit")))) + if ledger_amount <= 0 or voucher_amount <= 0 or abs(ledger_amount - voucher_amount) >= 0.5: + return False + if not _matched_row_passes_account_pair_rule(dict(row)): + return False + return bool( + _same_or_similar_vendor(dict(row)) + and ( + normalize_text(row.get("ledger_desc")) + and normalize_text(row.get("ledger_desc")) == normalize_text(row.get("voucher_desc")) + ) + ) + + +def _direct_match_date_invariant_violation_for_guard(row: Mapping[str, Any]) -> bool: + ledger_date = _parse_row_date_with_year(dict(row), "ledger_date") + if ledger_date is None: + return False + proof_date = _parse_iso_date(row.get("proof_date")) + if proof_date is not None: + return abs((ledger_date - proof_date).days) > 62 + if _row_is_tax_invoice_sensitive_for_guard(row): + return True + if _row_has_strong_exact_desc_direct_evidence_for_guard(row): + return False + inferred_date = _infer_compare_row_proof_date(dict(row)) + return bool(inferred_date and abs((ledger_date - inferred_date).days) > 62) + + +def _group_is_partial_weak_month_mismatch_for_guard(group: dict[str, Any]) -> str: + rows = list(group.get("rows") or []) + if not rows: + return "" + reason_text = normalize_text(_group_text_for_guarded_rules(group)) + if "directcostbusinessaccountmatch" not in reason_text and "groupedcost" not in reason_text: + return "" + + direct_rows = [row for row in rows if _row_has_direct_erp_match_for_guard(row)] + erp_only_rows = [ + row + for row in rows + if clean(row.get("voucher_account_name")) + and not clean(row.get("ledger_account_name")) + and _row_has_meaningful_amount_for_guard(row, "voucher") + ] + ledger_only_rows = [ + row + for row in rows + if clean(row.get("ledger_account_name")) + and not clean(row.get("voucher_account_name")) + and _row_has_meaningful_amount_for_guard(row, "ledger") + ] + if not direct_rows or not erp_only_rows: + return "" + + strong_direct_rows = [] + weak_direct_rows = [] + for row in direct_rows: + ledger_core = _is_tax_invoice_core_account_for_guard(row.get("ledger_account_code"), row.get("ledger_account_name")) + voucher_core = _is_tax_invoice_core_account_for_guard(row.get("voucher_account_code"), row.get("voucher_account_name")) + ledger_weak = _is_weak_only_account_for_guard(row.get("ledger_account_code"), row.get("ledger_account_name")) + voucher_weak = _is_weak_only_account_for_guard(row.get("voucher_account_code"), row.get("voucher_account_name")) + if ledger_core and voucher_core and not (ledger_weak or voucher_weak): + strong_direct_rows.append(row) + else: + weak_direct_rows.append(row) + if strong_direct_rows: + return "" + if not weak_direct_rows: + return "" + + month_gaps = [ + gap + for gap in (_compare_row_effective_month_gap_for_guard(row) for row in rows) + if gap is not None + ] + has_month_mismatch = bool(month_gaps and max(month_gaps) >= 1) + has_vat_direct = any( + _is_vat_family(_row_account_family(row, "ledger")) + and _is_vat_family(_row_account_family(row, "voucher")) + for row in direct_rows + ) + if has_month_mismatch and not has_vat_direct: + return _PARTIAL_WEAK_MONTH_RECHECK_REASON + if erp_only_rows and ledger_only_rows and not has_vat_direct: + return _ERP_PARTIAL_ROWS_ONLY_RECHECK_REASON + return "" + + +def _group_is_unsafe_tax_invoice_match_for_guard(group: dict[str, Any]) -> str: + has_strong_evidence = _group_has_strong_non_amount_guard_evidence(group) + if _group_has_service_month_conflict_for_guard(group): + return "MONTH_CONFLICT_RECHECK" + if _group_has_tax_invoice_core_structure_for_guard(group) and _group_has_tax_anchor_date_conflict_for_guard(group): + return "TAX_ANCHOR_PROOF_DATE_CONFLICT_RECHECK" + if partial_reason := _group_is_partial_weak_month_mismatch_for_guard(group): + if has_strong_evidence: + return "" + return partial_reason + if _group_has_project_token_conflict_for_guard(group): + if has_strong_evidence: + return "" + return _PROJECT_TOKEN_CONFLICT_REVIEW_REASON + if _group_has_tax_invoice_core_structure_for_guard(group): + if _group_has_unmatched_tax_invoice_core_ledger_rows_for_guard(group): + if has_strong_evidence: + return "" + return _WEAK_ONLY_TAX_INVOICE_MATCH_REVIEW_REASON + if _group_has_only_weak_direct_matches_for_guard(group): + if has_strong_evidence: + return "" + return _WEAK_ONLY_TAX_INVOICE_MATCH_REVIEW_REASON + return "" + + +def _row_is_year_opening_substitution_for_guard(row: dict[str, Any]) -> bool: + row_text = normalize_text( + " ".join( + clean(row.get(field)) + for field in ( + "ledger_desc", + "voucher_desc", + "review_reason", + "ledger_account_name", + "voucher_account_name", + ) + ) + ) + compact_text = row_text.replace(" ", "") + if not any(token in compact_text for token in _OPENING_SUBSTITUTION_TOKENS_FOR_GUARD): + return False + ledger_date = _parse_row_date_with_year(row, "ledger_date") + proof_date = _parse_row_date_with_year(row, "proof_date") + return any(date_value and date_value.month == 1 and date_value.day == 1 for date_value in (ledger_date, proof_date)) + + +def _group_is_year_opening_substitution_for_guard(group: dict[str, Any]) -> bool: + month_days = _voucher_group_month_days(group) + if "01-01" not in month_days: + return False + compact_text = _group_text_for_guarded_rules(group).replace(" ", "") + return any(token in compact_text for token in _OPENING_SUBSTITUTION_TOKENS_FOR_GUARD) + + +def _row_is_grant_settlement_direct_mismatch_for_guard(row: dict[str, Any]) -> bool: + if not _is_grant_like_account_for_guard(row.get("ledger_account_code"), row.get("ledger_account_name")): + return False + if not _is_settlement_account_for_guard(row.get("voucher_account_code"), row.get("voucher_account_name")): + return False + return not _is_business_account_for_guard(row.get("voucher_account_code"), row.get("voucher_account_name")) + + +def _group_is_grant_settlement_direct_mismatch_for_guard(group: dict[str, Any]) -> bool: + rows = list(group.get("rows") or []) + if not any( + _is_grant_like_account_for_guard(row.get("ledger_account_code"), row.get("ledger_account_name")) + for row in rows + ): + return False + direct_rows = _section_direct_rows(group) + if not direct_rows: + return False + if any( + _is_business_account_for_guard(row.get("voucher_account_code"), row.get("voucher_account_name")) + for row in direct_rows + ): + return False + return any( + _is_settlement_account_for_guard(row.get("voucher_account_code"), row.get("voucher_account_name")) + for row in direct_rows + ) + + +def _row_social_insurance_account_match(row: dict[str, Any]) -> bool: + row_text = normalize_text( + " ".join( + [ + clean(row.get("ledger_account_name")), + clean(row.get("voucher_account_name")), + clean(row.get("ledger_vendor")), + clean(row.get("voucher_vendor")), + clean(row.get("ledger_desc")), + clean(row.get("voucher_desc")), + ] + ) + ) + if "국민건강보험공단" not in row_text: + return False + if not any(token in row_text for token in ("산재보험", "고용보험", "건강보험", "장기요양보험", "국민연금")): + return False + ledger_account = normalize_text(row.get("ledger_account_name")) + voucher_account = normalize_text(row.get("voucher_account_name")) + allowed_account_tokens = ("보험료", "복리후생비", "예수금", "미지급금") + if not any(token in ledger_account for token in allowed_account_tokens): + return False + if not any(token in voucher_account for token in allowed_account_tokens): + return False + ledger_amount = _get_row_match_amount(row, "ledger") + voucher_amount = _get_row_match_amount(row, "voucher") + if ledger_amount <= 0 or abs(ledger_amount - voucher_amount) >= 0.5: + return False + if _has_named_reference_conflict(row, row): + return False + return True + + +def _is_social_insurance_recheck_group(group: dict[str, Any]) -> bool: + group_text = _group_text_for_guarded_rules(group) + if "취소" in group_text: + return False + if "국민건강보험공단" not in group_text: + return False + if not any(token in group_text for token in ("산재보험", "고용보험", "건강보험", "장기요양보험", "국민연금")): + return False + return any(_row_social_insurance_account_match(row) for row in list(group.get("rows") or [])) + + +def _group_has_negative_to_positive_reversal_mismatch(group: dict[str, Any]) -> bool: + rows = list(group.get("rows") or []) + summary = group.get("summary") or {} + text_value = _group_text_for_guarded_rules(group) + if not ("취소" in text_value or "500" in clean(summary.get("voucher_no")) or "매출" in text_value or "매입" in text_value): + return False + ledger_values = [ + parse_amount(summary.get("ledger_debit")), + parse_amount(summary.get("ledger_credit")), + *[parse_amount(row.get("ledger_debit")) for row in rows], + *[parse_amount(row.get("ledger_credit")) for row in rows], + ] + voucher_values = [ + parse_amount(summary.get("voucher_debit")), + parse_amount(summary.get("voucher_credit")), + *[parse_amount(row.get("voucher_debit")) for row in rows], + *[parse_amount(row.get("voucher_credit")) for row in rows], + ] + has_negative_ledger = any(value < -0.5 for value in ledger_values) + has_negative_voucher = any(value < -0.5 for value in voucher_values) + has_positive_ledger = any(value > 0.5 for value in ledger_values) + has_positive_voucher = any(value > 0.5 for value in voucher_values) + if has_negative_ledger and has_positive_voucher: + return True + if has_negative_voucher and has_positive_ledger: + return True + return False + + +def _copy_group_with_status_and_reason(group: dict[str, Any], status_label: str, reason: str) -> dict[str, Any]: + group_copy = { + "summary": dict(group.get("summary") or {}), + "rows": [dict(row) for row in list(group.get("rows") or [])], + } + group_copy["summary"]["status_label"] = status_label + existing_reason = clean(group_copy["summary"].get("review_reason")) + group_copy["summary"]["review_reason"] = " / ".join( + item for item in [existing_reason, reason] if item + ) + for row in group_copy["rows"]: + row["status_label"] = status_label + existing_row_reason = clean(row.get("review_reason")) + row["review_reason"] = " / ".join( + item for item in [existing_row_reason, reason] if item + ) + return group_copy + + +def _copy_opening_substitution_group_as_excepted(group: dict[str, Any]) -> dict[str, Any]: + group_copy = _copy_group_with_status_and_reason(group, "Excepted", _YEAR_OPENING_SUBSTITUTION_REASON) + for row in group_copy["rows"]: + for field in ( + "proof_date", + "draft_no", + "voucher_account_code", + "voucher_account_name", + "voucher_vendor", + "voucher_desc", + "voucher_row_key", + "voucher_confirmed_no", + ): + row[field] = "" + row["voucher_debit"] = 0 + row["voucher_credit"] = 0 + row["matched_case"] = "" + row["match_identity_key"] = clean(row.get("ledger_row_key")) or build_ledger_row_key(row) + + summary = group_copy["summary"] + summary["draft_no"] = "" + summary["voucher_accounts"] = "" + summary["voucher_vendors"] = "" + summary["voucher_debit"] = 0 + summary["voucher_credit"] = 0 + summary["voucher_row_count"] = 0 + return group_copy + + +def _copy_erp_side_group_as_unmatched(group: dict[str, Any], reason: str) -> dict[str, Any] | None: + rows: list[dict[str, Any]] = [] + for row in list(group.get("rows") or []): + if not ( + clean(row.get("draft_no")) + or clean(row.get("voucher_account_name")) + or abs(parse_amount(row.get("voucher_debit"))) >= 0.5 + or abs(parse_amount(row.get("voucher_credit"))) >= 0.5 + ): + continue + payload = dict(row) + for field in ( + "ledger_date", + "voucher_no", + "ledger_account_code", + "ledger_account_name", + "ledger_vendor", + "ledger_desc", + "ledger_row_key", + ): + payload[field] = "" + payload["ledger_debit"] = 0 + payload["ledger_credit"] = 0 + payload["status_label"] = "ERP Unmatched" + existing_reason = clean(payload.get("review_reason")) + payload["review_reason"] = " / ".join(item for item in [existing_reason, reason] if item) + payload["matched_case"] = "" + payload["match_identity_key"] = clean(payload.get("voucher_row_key")) or build_voucher_row_key(payload) + rows.append(payload) + if not rows: + return None + + summary = dict(group.get("summary") or {}) + fiscal_year = int(summary.get("fiscal_year") or 0) + draft_nos: list[str] = [] + voucher_accounts: list[str] = [] + voucher_vendors: list[str] = [] + for row in rows: + for target, value in ( + (draft_nos, row.get("draft_no")), + (voucher_accounts, row.get("voucher_account_name")), + (voucher_vendors, row.get("voucher_vendor")), + ): + text_value = clean(value) + if text_value and text_value not in target: + target.append(text_value) + return { + "summary": { + "fiscal_year": fiscal_year, + "status_label": "ERP Unmatched", + "ledger_date": "", + "proof_date": clean(summary.get("proof_date")), + "voucher_no": "", + "draft_no": ", ".join(draft_nos), + "ledger_row_count": 0, + "voucher_row_count": len(rows), + "ledger_debit": 0, + "ledger_credit": 0, + "voucher_debit": sum(parse_amount(row.get("voucher_debit")) for row in rows), + "voucher_credit": sum(parse_amount(row.get("voucher_credit")) for row in rows), + "ledger_accounts": "", + "voucher_accounts": ", ".join(voucher_accounts), + "ledger_vendors": "", + "voucher_vendors": ", ".join(voucher_vendors), + "review_reason": reason, + }, + "rows": rows, + } + + +def _copy_ledger_side_group_as_recheck(group: dict[str, Any], reason: str) -> dict[str, Any] | None: + rows: list[dict[str, Any]] = [] + seen_row_keys: set[str] = set() + for row in list(group.get("rows") or []): + if not ( + clean(row.get("ledger_date")) + or clean(row.get("voucher_no")) + or clean(row.get("ledger_account_name")) + or abs(parse_amount(row.get("ledger_debit"))) >= 0.5 + or abs(parse_amount(row.get("ledger_credit"))) >= 0.5 + ): + continue + payload = _restore_ledger_only_row_from_match(row) if clean(row.get("voucher_account_name")) else dict(row) + for field in ( + "proof_date", + "draft_no", + "voucher_account_code", + "voucher_account_name", + "voucher_vendor", + "voucher_desc", + "voucher_row_key", + ): + payload[field] = "" + payload["voucher_debit"] = 0 + payload["voucher_credit"] = 0 + payload["status_label"] = "Recheck" + existing_reason = clean(row.get("review_reason")) + payload["review_reason"] = " / ".join(item for item in [existing_reason, reason, "직접 ERP 연결 제거"] if item) + payload["matched_case"] = "source_row_invariant_recheck" + payload["match_identity_key"] = clean(payload.get("ledger_row_key")) or build_ledger_row_key(payload) + payload["review_key"] = build_review_key(payload) + row_key = clean(payload.get("ledger_row_key")) or build_ledger_row_key(payload) + if row_key and row_key in seen_row_keys: + continue + if row_key: + seen_row_keys.add(row_key) + rows.append(payload) + if not rows: + return None + + summary = dict(group.get("summary") or {}) + fiscal_year = int(summary.get("fiscal_year") or 0) + voucher_nos: list[str] = [] + ledger_accounts: list[str] = [] + ledger_vendors: list[str] = [] + for row in rows: + for target, value in ( + (voucher_nos, row.get("voucher_no")), + (ledger_accounts, row.get("ledger_account_name")), + (ledger_vendors, row.get("ledger_vendor")), + ): + text_value = clean(value) + if text_value and text_value not in target: + target.append(text_value) + return { + "summary": { + "fiscal_year": fiscal_year, + "status_label": "Recheck", + "ledger_date": clean(summary.get("ledger_date")), + "proof_date": "", + "voucher_no": ", ".join(voucher_nos) or clean(summary.get("voucher_no")), + "draft_no": "", + "ledger_row_count": len(rows), + "voucher_row_count": 0, + "ledger_debit": sum(parse_amount(row.get("ledger_debit")) for row in rows), + "ledger_credit": sum(parse_amount(row.get("ledger_credit")) for row in rows), + "voucher_debit": 0, + "voucher_credit": 0, + "ledger_accounts": ", ".join(ledger_accounts), + "voucher_accounts": "", + "ledger_vendors": ", ".join(ledger_vendors), + "voucher_vendors": "", + "review_reason": f"{reason} / 직접 ERP 연결 제거", + }, + "rows": rows, + } + + +def _direct_source_row_invariant_reason( + row: dict[str, Any], + used_ledger: set[str], + used_voucher: set[str], +) -> str: + ledger_key = clean(row.get("ledger_row_key")) + voucher_key = clean(row.get("voucher_row_key")) + if not ledger_key or not voucher_key: + return "원천 계정행 식별정보 누락" + if ledger_key in used_ledger: + return "WEHAGO 원천 계정행 중복 매칭" + if voucher_key in used_voucher: + return "ERP 원천 계정행 중복 매칭" + + ledger_amount = max(abs(parse_amount(row.get("ledger_debit"))), abs(parse_amount(row.get("ledger_credit")))) + voucher_amount = max(abs(parse_amount(row.get("voucher_debit"))), abs(parse_amount(row.get("voucher_credit")))) + if ledger_amount <= 0 or voucher_amount <= 0: + return "원천 계정행 금액 누락" + if abs(ledger_amount - voucher_amount) >= 0.5: + return "원천 계정행 금액 불일치" + if _row_sides_have_opposite_signed_amount(row): + return "취소·정상 부호 반대 직접매칭" + if not _matched_row_passes_account_pair_rule(row): + return "계정군 허용 규칙 위반" + if _direct_match_date_invariant_violation_for_guard(row): + return "허용기간 62일 초과" + return "" + + +def _apply_final_source_row_invariant_gate( + voucher_sections: dict[str, list[dict[str, Any]]], +) -> dict[str, list[dict[str, Any]]]: + demoted_recheck: list[dict[str, Any]] = [] + demoted_erp_unmatched: list[dict[str, Any]] = [] + + for status_key in ("voucher_matched", "erp_voucher_matched"): + used_ledger: set[str] = set() + used_voucher: set[str] = set() + retained_groups: list[dict[str, Any]] = [] + for group in list(voucher_sections.get(status_key) or []): + violation_reasons: list[str] = [] + summary = dict(group.get("summary") or {}) + direct_rows = _section_source_direct_rows(group) + for row in direct_rows: + for field in ("fiscal_year", "ledger_date", "proof_date", "voucher_no", "draft_no"): + if not clean(row.get(field)) and clean(summary.get(field)): + row[field] = summary.get(field) + reason = _direct_source_row_invariant_reason(row, used_ledger, used_voucher) + if reason: + if reason not in violation_reasons: + violation_reasons.append(reason) + continue + used_ledger.add(clean(row.get("ledger_row_key"))) + used_voucher.add(clean(row.get("voucher_row_key"))) + if not violation_reasons: + retained_groups.append(group) + continue + + reason_text = " / ".join(violation_reasons) + if status_key == "voucher_matched": + recheck_group = _copy_ledger_side_group_as_recheck(group, reason_text) + if recheck_group is not None: + demoted_recheck.append(recheck_group) + erp_group = _copy_erp_side_group_as_unmatched(group, reason_text) + if erp_group is not None: + demoted_erp_unmatched.append(erp_group) + voucher_sections[status_key] = retained_groups + + if demoted_recheck: + voucher_sections["voucher_recheck"] = list(voucher_sections.get("voucher_recheck") or []) + demoted_recheck + if demoted_erp_unmatched: + voucher_sections["erp_voucher_unmatched"] = list(voucher_sections.get("erp_voucher_unmatched") or []) + demoted_erp_unmatched + return voucher_sections + + +def _group_has_attached_erp_candidate(group: Mapping[str, Any]) -> bool: + if not _voucher_group_has_erp_counterpart(dict(group)): + return False + direct_like_rows = [ + row + for row in list(group.get("rows") or []) + if _row_has_direct_erp_match_for_guard(dict(row)) + ] + if not direct_like_rows: + return False + if _section_amount_sides_match({"summary": dict(group.get("summary") or {}), "rows": list(group.get("rows") or [])}): + return True + return any( + _same_or_similar_vendor(dict(row)) + and ( + _same_or_similar_desc(dict(row)) + or _contained_core_desc_match(row.get("ledger_desc"), row.get("voucher_desc")) + ) + for row in direct_like_rows + ) + + +def _candidate_group_complete_source_rows_pass(group: Mapping[str, Any]) -> bool: + source_rows = _section_source_direct_rows(group) + if not source_rows: + return False + direct_like_count = sum(1 for row in list(group.get("rows") or []) if _row_has_direct_erp_match_for_guard(dict(row))) + if direct_like_count != len(source_rows): + return False + local_ledger: set[str] = set() + local_voucher: set[str] = set() + summary = dict(group.get("summary") or {}) + for row in source_rows: + row = dict(row) + for field in ("fiscal_year", "ledger_date", "proof_date", "voucher_no", "draft_no"): + if not clean(row.get(field)) and clean(summary.get(field)): + row[field] = summary.get(field) + reason = _direct_source_row_invariant_reason(row, local_ledger, local_voucher) + if reason: + return False + local_ledger.add(clean(row.get("ledger_row_key"))) + local_voucher.add(clean(row.get("voucher_row_key"))) + return True + + +def _promote_attached_candidate_unmatched_groups( + voucher_sections: dict[str, list[dict[str, Any]]], +) -> dict[str, list[dict[str, Any]]]: + unmatched_groups = list(voucher_sections.get("voucher_unmatched") or []) + if not unmatched_groups: + return voucher_sections + + retained: list[dict[str, Any]] = [] + promoted: list[dict[str, Any]] = [] + recheck: list[dict[str, Any]] = [] + for group in unmatched_groups: + if not _group_has_attached_erp_candidate(group): + retained.append(group) + continue + reason = "CANDIDATE_ATTACHED_WITHOUT_ROW_KEY" + if any(clean(row.get("matched_case")) == "boundary_excluded" for row in list(group.get("rows") or [])): + reason = "BOUNDARY_STRONG_CANDIDATE_RECHECK" + if _candidate_group_complete_source_rows_pass(group): + promoted_group = _copy_group_with_status_and_reason(group, "Matched", "ATTACHED_CANDIDATE_RECOVERED_MATCH") + for row in list(promoted_group.get("rows") or []): + if _row_is_source_direct_match(row): + row["matched_case"] = clean(row.get("matched_case")) or RECHECK_DIRECT_CANDIDATE + promoted.append(promoted_group) + else: + recheck.append(_copy_group_with_status_and_reason(group, "Recheck", reason)) + + if promoted: + voucher_sections["voucher_matched"] = list(voucher_sections.get("voucher_matched") or []) + promoted + if recheck: + voucher_sections["voucher_recheck"] = list(voucher_sections.get("voucher_recheck") or []) + recheck + voucher_sections["voucher_unmatched"] = retained + return voucher_sections + + +_EXPLAINABLE_LEDGER_RESIDUAL_TOKENS = ( + "이자", + "미지급비용", + "미지급금", + "수수료", + "잡손실", + "잡이익", +) + + +def _row_is_explainable_ledger_residual(row: Mapping[str, Any]) -> bool: + if clean(row.get("voucher_account_name")): + return False + if not clean(row.get("ledger_account_name")): + return False + amount = max(abs(parse_amount(row.get("ledger_debit"))), abs(parse_amount(row.get("ledger_credit")))) + if amount < 0.5: + return False + text = normalize_text( + " ".join( + clean(row.get(field)) + for field in ("ledger_account_name", "ledger_desc", "review_reason") + ) + ) + return any(token in text for token in _EXPLAINABLE_LEDGER_RESIDUAL_TOKENS) + + +def _group_has_explainable_mixed_ledger_residual(group: Mapping[str, Any]) -> bool: + rows = [dict(row) for row in list(group.get("rows") or [])] + source_rows = _section_source_direct_rows(group) + if len(source_rows) < 2: + return False + if any(clean(row.get("voucher_account_name")) and not clean(row.get("ledger_account_name")) for row in rows): + return False + residual_rows = [row for row in rows if _row_is_explainable_ledger_residual(row)] + if not residual_rows: + return False + non_explainable_ledger_only = [ + row + for row in rows + if clean(row.get("ledger_account_name")) + and not clean(row.get("voucher_account_name")) + and max(abs(parse_amount(row.get("ledger_debit"))), abs(parse_amount(row.get("ledger_credit")))) >= 0.5 + and not _row_is_explainable_ledger_residual(row) + ] + if non_explainable_ledger_only: + return False + + local_ledger: set[str] = set() + local_voucher: set[str] = set() + summary = dict(group.get("summary") or {}) + for row in source_rows: + row = dict(row) + for field in ("fiscal_year", "ledger_date", "proof_date", "voucher_no", "draft_no"): + if not clean(row.get(field)) and clean(summary.get(field)): + row[field] = summary.get(field) + reason = _direct_source_row_invariant_reason(row, local_ledger, local_voucher) + if reason: + return False + local_ledger.add(clean(row.get("ledger_row_key"))) + local_voucher.add(clean(row.get("voucher_row_key"))) + + direct_ledger_net = sum(parse_amount(row.get("ledger_debit")) - parse_amount(row.get("ledger_credit")) for row in source_rows) + residual_net = sum(parse_amount(row.get("ledger_debit")) - parse_amount(row.get("ledger_credit")) for row in residual_rows) + if abs(direct_ledger_net + residual_net) >= 0.5: + return False + direct_voucher_net = sum(parse_amount(row.get("voucher_debit")) - parse_amount(row.get("voucher_credit")) for row in source_rows) + return abs(direct_ledger_net - direct_voucher_net) < 0.5 + + +def _promote_explainable_mixed_recheck_groups( + voucher_sections: dict[str, list[dict[str, Any]]], +) -> dict[str, list[dict[str, Any]]]: + recheck_groups = list(voucher_sections.get("voucher_recheck") or []) + if not recheck_groups: + return voucher_sections + + retained: list[dict[str, Any]] = [] + promoted: list[dict[str, Any]] = [] + for group in recheck_groups: + tokens = _group_reason_tokens(group) + mixed_status = any(token.startswith("VOUCHER_MIXED_ROW_STATUS=") for token in tokens) or len( + {clean(row.get("status_label")) for row in list(group.get("rows") or []) if clean(row.get("status_label"))} + ) > 1 + if not mixed_status or not _group_has_explainable_mixed_ledger_residual(group): + retained.append(group) + continue + promoted_group = _copy_group_with_status_and_reason(group, "Matched", "MIXED_RESIDUAL_EXPLAINED_MATCH") + for row in list(promoted_group.get("rows") or []): + if _row_is_source_direct_match(row): + row["matched_case"] = clean(row.get("matched_case")) or RECHECK_DIRECT_CANDIDATE + promoted.append(promoted_group) + + if promoted: + voucher_sections["voucher_matched"] = list(voucher_sections.get("voucher_matched") or []) + promoted + voucher_sections["voucher_recheck"] = retained + return voucher_sections + + +def _promote_recoverable_source_candidate_recheck_groups( + voucher_sections: dict[str, list[dict[str, Any]]], +) -> dict[str, list[dict[str, Any]]]: + recheck_groups = list(voucher_sections.get("voucher_recheck") or []) + if not recheck_groups: + return voucher_sections + + candidate_source_rows: dict[int, list[dict[str, Any]]] = {} + for group in recheck_groups: + tokens = _group_reason_tokens(group) + if tokens & _HARD_SOURCE_RECHECK_REASONS or any( + token.startswith("VOUCHER_MIXED_ROW_STATUS=") + for token in tokens + ): + continue + source_rows = [ + row + for row in _section_source_direct_rows(group) + if clean(row.get("matched_case")) in _RECHECK_SOURCE_MATCH_CASES + ] + if not source_rows: + continue + local_ledger: set[str] = set() + local_voucher: set[str] = set() + local_reason = "" + for row in source_rows: + local_reason = _direct_source_row_invariant_reason(row, local_ledger, local_voucher) + if local_reason: + break + local_ledger.add(clean(row.get("ledger_row_key"))) + local_voucher.add(clean(row.get("voucher_row_key"))) + if local_reason: + continue + candidate_source_rows[id(group)] = source_rows + + promotable_groups = sorted( + [group for group in recheck_groups if id(group) in candidate_source_rows], + key=lambda group: ( + len(candidate_source_rows.get(id(group)) or []), + _recheck_group_rank_key(group), + ), + reverse=True, + ) + promotable_ids = {id(group) for group in promotable_groups} + promoted: list[dict[str, Any]] = [] + retained: list[dict[str, Any]] = [] + for group in recheck_groups: + if id(group) not in promotable_ids: + retained.append(group) + for group in promotable_groups: + promoted_group = copy.deepcopy(group) + promoted_group.setdefault("summary", {})["status_label"] = "Matched" + reason = clean(promoted_group["summary"].get("review_reason")) + promoted_group["summary"]["review_reason"] = " / ".join( + item for item in (reason, "SOURCE_CANDIDATE_RECOVERED_MATCH") if item + ) + for row in list(promoted_group.get("rows") or []): + row["status_label"] = "Matched" + if _row_is_source_direct_match(row): + row["matched_case"] = clean(row.get("matched_case")) or RECHECK_DIRECT_CANDIDATE + promoted.append(promoted_group) + + if not promoted: + return voucher_sections + voucher_sections["voucher_matched"] = list(voucher_sections.get("voucher_matched") or []) + promoted + voucher_sections["voucher_recheck"] = retained + return voucher_sections + + +def _strip_non_source_rows_from_matched_projection( + voucher_sections: dict[str, list[dict[str, Any]]], +) -> dict[str, list[dict[str, Any]]]: + for status_key in ("voucher_matched", "erp_voucher_matched"): + normalized_groups: list[dict[str, Any]] = [] + for group in list(voucher_sections.get(status_key) or []): + source_rows = _section_source_direct_rows(group) + if not source_rows: + continue + group_copy = { + "summary": dict(group.get("summary") or {}), + "rows": source_rows, + } + group_copy["summary"]["status_label"] = "Matched" + for row in group_copy["rows"]: + row["status_label"] = "Matched" + normalized_groups.append(_refresh_group_summary_from_rows(group_copy)) + voucher_sections[status_key] = normalized_groups + return voucher_sections + + +def _collapse_wehago_matched_to_best_group( + voucher_sections: dict[str, list[dict[str, Any]]], +) -> dict[str, list[dict[str, Any]]]: + groups = list(voucher_sections.get("voucher_matched") or []) + if not groups: + return voucher_sections + + grouped: dict[str, list[dict[str, Any]]] = {} + for index, group in enumerate(groups): + identity = _wehago_section_identity(group, f"voucher_matched:{index}") + grouped.setdefault(identity, []).append(group) + + retained: list[dict[str, Any]] = [] + demoted: list[dict[str, Any]] = [] + + def quality(group: dict[str, Any]) -> tuple[int, int, float]: + rows = _section_source_direct_rows(group) + voucher_total = sum(max(abs(parse_amount(row.get("voucher_debit"))), abs(parse_amount(row.get("voucher_credit")))) for row in rows) + return ( + len(rows), + sum(1 for row in rows if clean(row.get("matched_case")) in _RECHECK_SOURCE_MATCH_CASES), + voucher_total, + ) + + for identity, candidates in grouped.items(): + if len(candidates) == 1: + retained.append(candidates[0]) + continue + ordered = sorted(candidates, key=quality, reverse=True) + retained.append(ordered[0]) + for duplicate in ordered[1:]: + demoted.append(_copy_group_with_status_and_reason(duplicate, "Recheck", "DUPLICATE_WEHAGO_MATCHED_GROUP_RECHECK")) + + voucher_sections["voucher_matched"] = retained + if demoted: + voucher_sections["voucher_recheck"] = list(voucher_sections.get("voucher_recheck") or []) + demoted + return voucher_sections + + +def _apply_final_display_projection_guard( + voucher_sections: dict[str, list[dict[str, Any]]], +) -> dict[str, list[dict[str, Any]]]: + guard_passes = 3 if clean(os.getenv("WEHAGO_LEGACY_FINAL_DISPLAY_GUARD_PASSES", "")).lower() in {"1", "true", "yes", "y", "on"} else 1 + for _iteration in range(guard_passes): + voucher_sections = _demote_anchored_recheck_groups_without_erp(voucher_sections) + voucher_sections = _promote_obvious_recheck_groups(voucher_sections) + voucher_sections = _promote_attached_candidate_unmatched_groups(voucher_sections) + voucher_sections = _promote_explainable_mixed_recheck_groups(voucher_sections) + voucher_sections = _promote_recoverable_source_candidate_recheck_groups(voucher_sections) + voucher_sections = _apply_final_source_row_invariant_gate(voucher_sections) + voucher_sections = _strip_non_source_rows_from_matched_projection(voucher_sections) + voucher_sections = _collapse_wehago_matched_to_best_group(voucher_sections) + voucher_sections = _dedupe_wehago_sections_to_single_status(voucher_sections) + voucher_sections = _dedupe_erp_sections_to_single_status(voucher_sections) + return voucher_sections + + +def _demote_anchored_recheck_groups_without_erp( + voucher_sections: dict[str, list[dict[str, Any]]], +) -> dict[str, list[dict[str, Any]]]: + def has_real_hanmac_candidate(row: Mapping[str, Any]) -> bool: + if not _row_has_hanmac_candidate_side(row): + return False + if not clean(row.get("draft_no")): + return False + has_amount = ( + abs(parse_amount(row.get("voucher_debit"))) >= 0.5 + or abs(parse_amount(row.get("voucher_credit"))) >= 0.5 + ) + has_context = bool( + clean(row.get("voucher_vendor")) + or clean(row.get("voucher_desc")) + or clean(row.get("proof_date")) + ) + return has_amount and has_context + + def copy_as_wehago_unmatched(group: Mapping[str, Any]) -> dict[str, Any]: + rows: list[dict[str, Any]] = [] + for row in list(group.get("rows") or []): + if not _row_has_wehago_ledger_side(row): + continue + payload = _blank_voucher_side(dict(row)) + payload["status_label"] = "Unmatched" + existing_reason = clean(payload.get("review_reason")) + payload["review_reason"] = " / ".join( + item for item in [existing_reason, "ANCHORED_RECHECK_WITHOUT_ERP_CANDIDATE"] if item + ) + rows.append(payload) + if not rows: + rows = [ + _blank_voucher_side(dict(row)) + for row in list(group.get("rows") or []) + if clean(row.get("ledger_account_name")) or clean(row.get("ledger_desc")) + ] + refreshed = _refresh_group_summary_from_rows({"summary": dict(group.get("summary") or {}), "rows": rows}) + refreshed["summary"]["status_label"] = "Unmatched" + return refreshed + + retained_recheck: list[dict[str, Any]] = [] + demoted_unmatched: list[dict[str, Any]] = [] + for group in list(voucher_sections.get("voucher_recheck") or []): + tokens = _group_reason_tokens(group) + if WEHAGO_ANCHORED_RECHECK_REASON not in tokens: + retained_recheck.append(group) + continue + has_erp_candidate = any(has_real_hanmac_candidate(row) for row in list(group.get("rows") or [])) + if has_erp_candidate: + retained_recheck.append(group) + continue + demoted_unmatched.append(copy_as_wehago_unmatched(group)) + if demoted_unmatched: + voucher_sections["voucher_recheck"] = retained_recheck + voucher_sections["voucher_unmatched"] = list(voucher_sections.get("voucher_unmatched") or []) + demoted_unmatched + return voucher_sections + + +def _merge_wehago_voucher_groups_to_single_status( + voucher_sections: dict[str, list[dict[str, Any]]], +) -> dict[str, list[dict[str, Any]]]: + status_keys = ("voucher_matched", "voucher_recheck", "voucher_unmatched", "voucher_excepted") + groups_by_identity: dict[str, list[tuple[str, dict[str, Any]]]] = {} + for status_key in status_keys: + for index, group in enumerate(list(voucher_sections.get(status_key) or [])): + identity = _wehago_section_identity(group, f"{status_key}:{index}") + groups_by_identity.setdefault(identity, []).append((status_key, group)) + + consolidated = {status_key: [] for status_key in status_keys} + status_label_map = { + "voucher_matched": "Matched", + "voucher_recheck": "Recheck", + "voucher_unmatched": "Unmatched", + "voucher_excepted": "Excepted", + } + + for identity, candidates in groups_by_identity.items(): + source_statuses = {status_key for status_key, _group in candidates} + if source_statuses == {"voucher_matched"}: + final_status = "voucher_matched" + elif source_statuses == {"voucher_excepted"}: + final_status = "voucher_excepted" + elif source_statuses == {"voucher_unmatched"}: + final_status = "voucher_unmatched" + else: + final_status = "voucher_recheck" + + rows: list[dict[str, Any]] = [] + seen_rows: set[tuple[Any, ...]] = set() + source_reasons: list[str] = [] + base_summary: dict[str, Any] = {} + for source_status, group in sorted( + candidates, + key=lambda item: status_keys.index(item[0]), + ): + summary = dict(group.get("summary") or {}) + if not base_summary: + base_summary = summary + _append_distinct_text(source_reasons, summary.get("review_reason")) + for row in list(group.get("rows") or []): + payload = dict(row) + payload["status_label"] = clean(payload.get("status_label")) or status_label_map[source_status] + if len(source_statuses) > 1: + existing_reason = clean(payload.get("review_reason")) + auxiliary_reason = f"VOUCHER_ROW_SOURCE_STATUS={source_status}" + payload["review_reason"] = " / ".join( + item for item in (existing_reason, auxiliary_reason) if item + ) + row_identity = ( + clean(payload.get("ledger_row_key")), + clean(payload.get("voucher_row_key")), + clean(payload.get("ledger_account_name")), + clean(payload.get("voucher_account_name")), + round(parse_amount(payload.get("ledger_debit")), 2), + round(parse_amount(payload.get("ledger_credit")), 2), + round(parse_amount(payload.get("voucher_debit")), 2), + round(parse_amount(payload.get("voucher_credit")), 2), + clean(payload.get("matched_case")), + clean(payload.get("status_label")), + ) + if row_identity in seen_rows: + continue + seen_rows.add(row_identity) + rows.append(payload) + + if len(source_statuses) > 1: + _append_distinct_text( + source_reasons, + f"VOUCHER_MIXED_ROW_STATUS={','.join(sorted(source_statuses))}", + ) + merged_group = _refresh_group_summary_from_rows( + { + "summary": { + **base_summary, + "status_label": status_label_map[final_status], + "review_reason": " / ".join(source_reasons), + "voucher_identity": identity, + }, + "rows": rows, + } + ) + merged_group["summary"]["status_label"] = status_label_map[final_status] + if source_reasons: + merged_group["summary"]["review_reason"] = " / ".join(source_reasons) + consolidated[final_status].append(merged_group) + + for status_key in status_keys: + consolidated[status_key].sort( + key=lambda group: ( + int((group.get("summary") or {}).get("fiscal_year") or 0), + clean((group.get("summary") or {}).get("ledger_date")), + clean((group.get("summary") or {}).get("voucher_no")), + ) + ) + voucher_sections[status_key] = consolidated[status_key] + return voucher_sections + + +def _prepare_final_display_projection( + voucher_sections: dict[str, list[dict[str, Any]]], +) -> dict[str, list[dict[str, Any]]]: + prepared = copy.deepcopy(voucher_sections) + assembly_passes = 2 if clean(os.getenv("WEHAGO_LEGACY_FINAL_DISPLAY_ASSEMBLY_PASSES", "")).lower() in {"1", "true", "yes", "y", "on"} else 1 + normalization_passes = 2 if clean(os.getenv("WEHAGO_LEGACY_FINAL_DISPLAY_NORMALIZATION_PASSES", "")).lower() in {"1", "true", "yes", "y", "on"} else 1 + for _assembly_pass in range(assembly_passes): + for _normalization_pass in range(normalization_passes): + for status_key, groups in list(prepared.items()): + normalized_groups: list[dict[str, Any]] = [] + for group in list(groups or []): + group_copy = { + "summary": dict(group.get("summary") or {}), + "rows": _sanitize_voucher_group_rows(list(group.get("rows") or [])), + } + normalized_groups.append(_refresh_group_summary_from_rows(group_copy)) + prepared[status_key] = normalized_groups + prepared = _apply_final_display_projection_guard(prepared) + prepared = _merge_wehago_voucher_groups_to_single_status(prepared) + return prepared + + +def _filter_standard_rows_for_final_projection( + rows_by_status: dict[str, list[dict[str, Any]]], + voucher_sections: dict[str, list[dict[str, Any]]], +) -> dict[str, list[dict[str, Any]]]: + final_ledger_keys = { + clean(row.get("ledger_row_key")) + for status_key in ("voucher_matched", "voucher_recheck", "voucher_excepted") + for group in list(voucher_sections.get(status_key) or []) + for row in list(group.get("rows") or []) + if clean(row.get("ledger_row_key")) + } + final_voucher_keys = { + clean(row.get("voucher_row_key")) + for status_key in ("voucher_matched", "erp_voucher_matched") + for group in list(voucher_sections.get(status_key) or []) + for row in list(group.get("rows") or []) + if clean(row.get("voucher_row_key")) + } + final_wehago_identities = { + _wehago_section_identity(group, f"{status_key}:{index}") + for status_key in ("voucher_matched", "voucher_recheck", "voucher_excepted") + for index, group in enumerate(list(voucher_sections.get(status_key) or [])) + } + filtered = { + status_key: [dict(row) for row in list(rows_by_status.get(status_key) or [])] + for status_key in QUERY_STANDARD_ROW_STATUS_KEYS + } + for status_key in ("ledger_only", "amount_mismatch"): + filtered[status_key] = [ + row + for row in filtered[status_key] + if clean(row.get("ledger_row_key")) not in final_ledger_keys + and normalize_wehago_voucher_identity( + int(row.get("fiscal_year") or 0), + row.get("voucher_no"), + row.get("ledger_date"), + clean(row.get("ledger_row_key")), + ) + not in final_wehago_identities + ] + filtered["voucher_only"] = [ + row + for row in filtered["voucher_only"] + if clean(row.get("voucher_row_key")) not in final_voucher_keys + ] + return filtered + + +def _apply_guarded_regression_case_rules( + voucher_sections: dict[str, list[dict[str, Any]]], +) -> dict[str, list[dict[str, Any]]]: + opening_excepted: list[dict[str, Any]] = [] + for status_key in ("voucher_matched", "voucher_recheck", "voucher_unmatched"): + retained_groups: list[dict[str, Any]] = [] + for group in list(voucher_sections.get(status_key) or []): + if _group_is_year_opening_substitution_for_guard(group): + opening_excepted.append(_copy_opening_substitution_group_as_excepted(group)) + else: + retained_groups.append(group) + voucher_sections[status_key] = retained_groups + if opening_excepted: + voucher_sections["voucher_excepted"] = list(voucher_sections.get("voucher_excepted") or []) + opening_excepted + + promoted_social: list[dict[str, Any]] = [] + retained_recheck: list[dict[str, Any]] = [] + for group in list(voucher_sections.get("voucher_recheck") or []): + if _is_social_insurance_recheck_group(group): + promoted_social.append(_copy_group_with_status_and_reason(group, "Matched", _SOCIAL_INSURANCE_MATCH_REASON)) + elif _group_has_negative_to_positive_reversal_mismatch(group): + retained_recheck.append(_copy_group_with_status_and_reason(group, "Recheck", _INVALID_REVERSAL_MATCH_REASON)) + else: + retained_recheck.append(group) + voucher_sections["voucher_recheck"] = retained_recheck + if promoted_social: + voucher_sections["voucher_matched"] = list(voucher_sections.get("voucher_matched") or []) + promoted_social + + retained_matched: list[dict[str, Any]] = [] + demoted_recheck: list[dict[str, Any]] = [] + for group in list(voucher_sections.get("voucher_matched") or []): + if _group_has_negative_to_positive_reversal_mismatch(group): + demoted_recheck.append(_copy_group_with_status_and_reason(group, "Recheck", _INVALID_REVERSAL_MATCH_REASON)) + elif _group_is_grant_settlement_direct_mismatch_for_guard(group): + demoted_recheck.append( + _copy_group_with_status_and_reason(group, "Recheck", _GRANT_SETTLEMENT_DIRECT_MATCH_REVIEW_REASON) + ) + elif unsafe_reason := _group_is_unsafe_tax_invoice_match_for_guard(group): + demoted_recheck.append(_copy_group_with_status_and_reason(group, "Recheck", unsafe_reason)) + else: + retained_matched.append(group) + voucher_sections["voucher_matched"] = retained_matched + if demoted_recheck: + voucher_sections["voucher_recheck"] = list(voucher_sections.get("voucher_recheck") or []) + demoted_recheck + + retained_erp_matched: list[dict[str, Any]] = [] + erp_unmatched_from_guard: list[dict[str, Any]] = [] + for group in list(voucher_sections.get("erp_voucher_matched") or []): + guarded_reason = "" + if _group_is_grant_settlement_direct_mismatch_for_guard(group): + guarded_reason = _GRANT_SETTLEMENT_DIRECT_MATCH_REVIEW_REASON + elif unsafe_reason := _group_is_unsafe_tax_invoice_match_for_guard(group): + guarded_reason = unsafe_reason + if guarded_reason: + erp_group = _copy_erp_side_group_as_unmatched(group, guarded_reason) + if erp_group is not None: + erp_unmatched_from_guard.append(erp_group) + else: + retained_erp_matched.append(group) + voucher_sections["erp_voucher_matched"] = retained_erp_matched + if erp_unmatched_from_guard: + voucher_sections["erp_voucher_unmatched"] = list(voucher_sections.get("erp_voucher_unmatched") or []) + erp_unmatched_from_guard return voucher_sections @@ -15819,6 +24625,115 @@ def _section_direct_rows(group: dict[str, Any]) -> list[dict[str, Any]]: ] +_RECHECK_SOURCE_MATCH_CASES = {RECHECK_DIRECT_CANDIDATE, RECHECK_SETTLEMENT_BRIDGE} +_DISPLAY_CONTEXT_MATCH_CASES = { + RECHECK_ERP_CONTEXT, + RECHECK_ERP_UNASSIGNED, + RECHECK_SHARED_ALLOCATION, + RECHECK_SHARED_BUNDLE, +} +_SOFT_RECHECK_PROMOTION_REASONS = { + RECHECK_FULL_ERP_REASON, + WEHAGO_ANCHORED_RECHECK_REASON, + RECHECK_DIRECT_CANDIDATE, + RECHECK_SETTLEMENT_BRIDGE, + RECHECK_SHARED_ALLOCATION, + RECHECK_SHARED_BUNDLE, + "2단계자동매칭", + "직접 ERP 연결 제거", + "WEAK_MATCH_RECHECK", + "RECHECK_OBVIOUS_GROUP_MATCH", +} +_HARD_SOURCE_RECHECK_REASONS = { + "INVALID_REVERSAL_TO_NORMAL_MATCH", + "MATCHED_CANCEL_TARGET_RECHECK", + "MONTH_CONFLICT_RECHECK", + "PROOF_DATE_RECHECK", +} + + +def _group_reason_tokens(group: Mapping[str, Any]) -> set[str]: + tokens: set[str] = set() + summary = dict(group.get("summary") or {}) + values = [summary.get("review_reason")] + values.extend(row.get("review_reason") for row in list(group.get("rows") or [])) + for value in values: + for token in re.split(r"\s*/\s*", clean(value)): + if token: + tokens.add(token) + return tokens + + +def _row_is_source_direct_match(row: Mapping[str, Any]) -> bool: + if not (clean(row.get("ledger_account_name")) and clean(row.get("voucher_account_name"))): + return False + if not (clean(row.get("ledger_row_key")) and clean(row.get("voucher_row_key"))): + return False + matched_case = clean(row.get("matched_case")) + if matched_case in _DISPLAY_CONTEXT_MATCH_CASES: + return False + if matched_case and matched_case not in _RECHECK_SOURCE_MATCH_CASES and matched_case != "boundary_excluded": + return False + return True + + +def _section_source_direct_rows(group: Mapping[str, Any]) -> list[dict[str, Any]]: + return [dict(row) for row in list(group.get("rows") or []) if _row_is_source_direct_match(row)] + + +def _group_has_complete_source_direct_rows(group: Mapping[str, Any]) -> bool: + rows = _section_source_direct_rows(group) + return bool(rows) and all(clean(row.get("ledger_row_key")) and clean(row.get("voucher_row_key")) for row in rows) + + +def _refresh_group_summary_from_rows(group: dict[str, Any]) -> dict[str, Any]: + summary = dict(group.get("summary") or {}) + rows = [dict(row) for row in list(group.get("rows") or [])] + fiscal_year = int(summary.get("fiscal_year") or next((row.get("fiscal_year") for row in rows if row.get("fiscal_year")), 0) or 0) + ledger_accounts: list[str] = [] + voucher_accounts: list[str] = [] + ledger_vendors: list[str] = [] + voucher_vendors: list[str] = [] + draft_nos: list[str] = [] + proof_dates: list[str] = [] + voucher_nos: list[str] = [] + ledger_dates: list[str] = [] + reasons: list[str] = [] + for row in rows: + _append_distinct_text(ledger_accounts, row.get("ledger_account_name")) + _append_distinct_text(voucher_accounts, row.get("voucher_account_name")) + _append_distinct_text(ledger_vendors, row.get("ledger_vendor")) + _append_distinct_text(voucher_vendors, row.get("voucher_vendor")) + _append_distinct_text(draft_nos, row.get("draft_no")) + _append_distinct_text_parts(proof_dates, row.get("proof_date")) + _append_distinct_text(voucher_nos, row.get("voucher_no")) + _append_distinct_text(ledger_dates, row.get("ledger_date")) + _append_distinct_text(reasons, row.get("review_reason")) + if not reasons: + _append_distinct_text(reasons, summary.get("review_reason")) + summary.update( + { + "fiscal_year": fiscal_year, + "ledger_date": ledger_dates[0] if ledger_dates else clean(summary.get("ledger_date")), + "proof_date": ", ".join(proof_dates) or clean(summary.get("proof_date")), + "voucher_no": ", ".join(voucher_nos) or clean(summary.get("voucher_no")), + "draft_no": ", ".join(draft_nos) or clean(summary.get("draft_no")), + "ledger_row_count": sum(1 for row in rows if clean(row.get("ledger_account_name")) or clean(row.get("ledger_desc"))), + "voucher_row_count": sum(1 for row in rows if clean(row.get("voucher_account_name")) or clean(row.get("voucher_desc"))), + "ledger_debit": sum(parse_amount(row.get("ledger_debit")) for row in rows), + "ledger_credit": sum(parse_amount(row.get("ledger_credit")) for row in rows), + "voucher_debit": sum(parse_amount(row.get("voucher_debit")) for row in rows), + "voucher_credit": sum(parse_amount(row.get("voucher_credit")) for row in rows), + "ledger_accounts": ", ".join(ledger_accounts), + "voucher_accounts": ", ".join(voucher_accounts), + "ledger_vendors": ", ".join(ledger_vendors), + "voucher_vendors": ", ".join(voucher_vendors), + "review_reason": ", ".join(reasons) if len(reasons) > 3 else " / ".join(reasons), + } + ) + return {"summary": summary, "rows": rows} + + def _section_vat_exception_capacity(group: dict[str, Any], *, side: str) -> int: rows = list(group.get("rows") or []) prefix = "ledger" if side == "wehago" else "voucher" @@ -15929,7 +24844,28 @@ def _has_strong_partial_recheck_evidence( continue if _same_or_similar_vendor(row) or _same_or_similar_desc(row) or _contained_core_desc_match(row.get("ledger_desc"), row.get("voucher_desc")): strong_rows += 1 - return strong_rows >= 2 or vat_rows >= 1 + if strong_rows >= 2 or vat_rows >= 1: + return True + reason_text = normalize_text( + " ".join( + [ + clean(summary.get("review_reason")), + *(clean(row.get("review_reason")) for row in list(group.get("rows") or [])), + ] + ) + ) + split_context = any( + token in reason_text + for token in ( + "erpgrouprepeatedcontextmatch", + "spliterpdraftcontextmatch", + "splitdraft", + "projectionreconcilesplitdraft", + "groupedcost", + "bundlematch", + ) + ) + return bool(split_context and strong_rows >= 1) def _recheck_group_rank_key(group: dict[str, Any]) -> tuple[int, int, int, float, str, str]: @@ -16026,7 +24962,10 @@ def _erp_draft_year(value: Any) -> int: def _erp_draft_base(value: Any) -> str: draft_no = clean(value) - return re.sub(r"-\d+$", "", draft_no) if draft_no else "" + if not draft_no: + return "" + matched = re.fullmatch(r"(11-\d{8}-[^-]+-\d+)-\d+", draft_no) + return matched.group(1) if matched else draft_no def _row_erp_candidate_year(row: dict[str, Any]) -> int: @@ -16042,9 +24981,6 @@ def _restrict_erp_rows_to_fiscal_year( ) -> dict[str, dict[str, Any]]: if fiscal_year <= 0: return sections - erp_start_year, erp_end_year = get_hanmac_erp_candidate_year_range(fiscal_year, fiscal_year) - erp_start_year = int(erp_start_year or fiscal_year) - erp_end_year = int(erp_end_year or fiscal_year) restored_ledger_rows = list(sections.get("ledger_only", {}).get("rows", [])) ledger_keys = { @@ -16053,8 +24989,7 @@ def _restrict_erp_rows_to_fiscal_year( } def keep_row(row: dict[str, Any]) -> bool: - candidate_year = _row_erp_candidate_year(row) - return not candidate_year or erp_start_year <= candidate_year <= erp_end_year + return _is_adjacent_month_erp_candidate_for_range(row, fiscal_year, fiscal_year) for status_key in ("matched", "amount_mismatch"): kept_rows: list[dict[str, Any]] = [] @@ -16218,6 +25153,172 @@ def _blank_voucher_side(row: dict[str, Any]) -> dict[str, Any]: return payload +MATCH_WITHOUT_ERP_DETAIL_REASON = "MATCH_WITHOUT_ERP_DETAIL" + + +def _row_has_erp_detail(row: Mapping[str, Any]) -> bool: + return bool( + clean(row.get("draft_no")) + or clean(row.get("proof_date")) + or clean(row.get("voucher_account_code")) + or clean(row.get("voucher_account_name")) + or clean(row.get("voucher_vendor")) + or clean(row.get("voucher_desc")) + or abs(parse_amount(row.get("voucher_debit"))) >= 0.5 + or abs(parse_amount(row.get("voucher_credit"))) >= 0.5 + ) + + +def _group_has_erp_detail(group: Mapping[str, Any]) -> bool: + summary = dict(group.get("summary") or {}) + if ( + clean(summary.get("draft_no")) + or clean(summary.get("proof_date")) + or clean(summary.get("voucher_accounts")) + or clean(summary.get("voucher_vendors")) + or abs(parse_amount(summary.get("voucher_debit"))) >= 0.5 + or abs(parse_amount(summary.get("voucher_credit"))) >= 0.5 + or int(summary.get("voucher_row_count") or 0) > 0 + ): + return True + return any(_row_has_erp_detail(dict(row)) for row in list(group.get("rows") or [])) + + +def _copy_group_as_wehago_unmatched_without_erp_detail(group: Mapping[str, Any]) -> dict[str, Any] | None: + rows: list[dict[str, Any]] = [] + for row in list(group.get("rows") or []): + if not ( + clean(row.get("ledger_account_name")) + or clean(row.get("ledger_desc")) + or abs(parse_amount(row.get("ledger_debit"))) >= 0.5 + or abs(parse_amount(row.get("ledger_credit"))) >= 0.5 + ): + continue + payload = _blank_voucher_side(dict(row)) + payload["status_label"] = "Unmatched" + existing_reason = clean(payload.get("review_reason")) + payload["review_reason"] = " / ".join( + part for part in [existing_reason, MATCH_WITHOUT_ERP_DETAIL_REASON] if part + ) + payload["matched_case"] = "" + payload["match_identity_key"] = clean(payload.get("ledger_row_key")) or build_ledger_row_key(payload) + rows.append(payload) + if not rows: + return None + summary = dict(group.get("summary") or {}) + fiscal_year = int(summary.get("fiscal_year") or rows[0].get("fiscal_year") or 0) + voucher_no = clean(summary.get("voucher_no") or rows[0].get("voucher_no")) + rebuilt = _build_group_summary_from_rows( + fiscal_year=fiscal_year, + voucher_no=voucher_no, + draft_no="", + rows=rows, + status_label="Unmatched", + review_reason=MATCH_WITHOUT_ERP_DETAIL_REASON, + ) + return {"summary": rebuilt, "rows": rows} + + +def _demote_matched_groups_without_erp_detail( + voucher_sections: dict[str, list[dict[str, Any]]], +) -> dict[str, list[dict[str, Any]]]: + retained: list[dict[str, Any]] = [] + demoted: list[dict[str, Any]] = [] + for group in list(voucher_sections.get("voucher_matched") or []): + if _group_has_erp_detail(group): + retained.append(group) + continue + unmatched_group = _copy_group_as_wehago_unmatched_without_erp_detail(group) + if unmatched_group is not None: + demoted.append(unmatched_group) + voucher_sections["voucher_matched"] = retained + if demoted: + voucher_sections["voucher_unmatched"] = list(voucher_sections.get("voucher_unmatched") or []) + demoted + return voucher_sections + + +def _wehago_group_compare_key_from_summary(summary: Mapping[str, Any]) -> str: + return normalize_wehago_voucher_identity( + summary.get("fiscal_year"), + summary.get("voucher_no"), + summary.get("ledger_date"), + "", + ) + + +def _load_status_projection_group_by_compare_key( + conn: Any, + start_year: int | None, + end_year: int | None, + *, + compare_no: str, + fiscal_year: int, + voucher_no: str, + preferred_statuses: Iterable[str], +) -> dict[str, Any]: + compare_key = clean(compare_no) + if start_year is None or end_year is None or not compare_key or fiscal_year <= 0 or not clean(voucher_no): + return {} + statuses = [clean(status) for status in preferred_statuses if clean(status)] + statuses = list(dict.fromkeys(statuses)) + if not statuses: + statuses = list(WEHAGO_FINAL_STATUS_ORDER) + status_placeholders = ", ".join(f":status_{index}" for index, _status in enumerate(statuses)) + params: dict[str, Any] = { + "start_year": int(start_year), + "end_year": int(end_year), + "fiscal_year": int(fiscal_year), + "voucher_no": clean(voucher_no), + **{f"status_{index}": status for index, status in enumerate(statuses)}, + } + rows = conn.execute( + text( + f""" + SELECT * + FROM wehago_status_projection_groups + WHERE start_year = :start_year + AND end_year = :end_year + AND fiscal_year = :fiscal_year + AND voucher_no = :voucher_no + AND status_key IN ({status_placeholders}) + ORDER BY + CASE status_key + WHEN 'voucher_excepted' THEN 1 + WHEN 'voucher_matched' THEN 2 + WHEN 'voucher_recheck' THEN 3 + ELSE 4 + END, + updated_at DESC, + group_index ASC + LIMIT 500 + """ + ), + params, + ).mappings().all() + fallback: dict[str, Any] = {} + for row in rows: + try: + summary = json.loads(clean(row.get("summary_json")) or "{}") + except Exception: + summary = {} + try: + detail_rows = json.loads(clean(row.get("rows_json")) or "[]") + except Exception: + detail_rows = [] + if not isinstance(summary, dict): + summary = {} + if not isinstance(detail_rows, list): + detail_rows = [] + if not summary: + continue + group = {"summary": summary, "rows": detail_rows} + if not fallback and _group_has_erp_detail(group): + fallback = dict(row) + if _wehago_group_compare_key_from_summary(summary) == compare_key: + return dict(row) + return fallback + + def _build_group_summary_from_rows( *, fiscal_year: int, @@ -16427,6 +25528,29 @@ def _retarget_obvious_recheck_groups( if not recheck_groups or not erp_unmatched_groups: return voucher_sections + def group_fiscal_year(group: dict[str, Any]) -> int: + summary = group.get("summary") or {} + return int(summary.get("fiscal_year") or 0) + + def positive_row_amounts(group: dict[str, Any], prefix: str) -> set[float]: + amounts: set[float] = set() + for row in group.get("rows", []) or []: + for field in (f"{prefix}_debit", f"{prefix}_credit"): + value = round(parse_amount(row.get(field)), 2) + if value > 0: + amounts.add(value) + return amounts + + # _build_retargeted_group_match can only produce a match when at least one + # WEHAGO row amount and one ERP row amount are equal. Index ERP groups by + # those amounts first so the projection rebuild does not perform a full + # Recheck × ERP-unmatched scan. + erp_candidates_by_year_amount: dict[tuple[int, float], list[dict[str, Any]]] = {} + for candidate_group in erp_unmatched_groups: + fiscal_year = group_fiscal_year(candidate_group) + for amount in positive_row_amounts(candidate_group, "voucher"): + erp_candidates_by_year_amount.setdefault((fiscal_year, amount), []).append(candidate_group) + remaining_recheck: list[dict[str, Any]] = [] remaining_erp_unmatched: list[dict[str, Any]] = [] promoted_groups: list[dict[str, Any]] = [] @@ -16434,8 +25558,18 @@ def _retarget_obvious_recheck_groups( for recheck_group in recheck_groups: best_candidate: dict[str, Any] | None = None + best_candidate_match: dict[str, Any] | None = None best_score = -1.0 - for candidate_group in erp_unmatched_groups: + candidate_groups: list[dict[str, Any]] = [] + seen_candidate_ids: set[int] = set() + for amount in positive_row_amounts(recheck_group, "ledger"): + for candidate_group in erp_candidates_by_year_amount.get((group_fiscal_year(recheck_group), amount), []): + candidate_id = id(candidate_group) + if candidate_id in seen_candidate_ids: + continue + seen_candidate_ids.add(candidate_id) + candidate_groups.append(candidate_group) + for candidate_group in candidate_groups: if id(candidate_group) in used_erp_group_ids: continue candidate_match = _build_retargeted_group_match(recheck_group, candidate_group) @@ -16447,11 +25581,12 @@ def _retarget_obvious_recheck_groups( score += float(_score_pair_match(row, row).get("score", 0)) if score > best_score: best_candidate = candidate_group + best_candidate_match = candidate_match best_score = score if best_candidate is None: remaining_recheck.append(recheck_group) continue - promoted_group = _build_retargeted_group_match(recheck_group, best_candidate) + promoted_group = best_candidate_match if promoted_group is None: remaining_recheck.append(recheck_group) continue @@ -16482,6 +25617,41 @@ def _row_account_category(row: dict[str, Any], prefix: str) -> str: return _classify_account_category(row.get(f"{prefix}_account_code"), row.get(f"{prefix}_account_name")) +def _row_signed_amount(row: dict[str, Any], prefix: str) -> float: + return parse_amount(row.get(f"{prefix}_debit")) - parse_amount(row.get(f"{prefix}_credit")) + + +def _row_sides_have_opposite_signed_amount(row: dict[str, Any]) -> bool: + ledger_amount = _row_signed_amount(row, "ledger") + voucher_amount = _row_signed_amount(row, "voucher") + if ( + abs(ledger_amount) >= 0.5 + and abs(voucher_amount) >= 0.5 + and abs(abs(ledger_amount) - abs(voucher_amount)) < 0.5 + and ledger_amount * voucher_amount < -0.25 + ): + return True + ledger_values = [ + parse_amount(row.get("ledger_debit")), + parse_amount(row.get("ledger_credit")), + ] + voucher_values = [ + parse_amount(row.get("voucher_debit")), + parse_amount(row.get("voucher_credit")), + ] + for ledger_value in ledger_values: + if abs(ledger_value) < 0.5: + continue + for voucher_value in voucher_values: + if abs(voucher_value) < 0.5: + continue + if abs(abs(ledger_value) - abs(voucher_value)) >= 0.5: + continue + if ledger_value * voucher_value < -0.25: + return True + return False + + def _section_side_rows(group: dict[str, Any], prefix: str) -> list[dict[str, Any]]: account_field = f"{prefix}_account_name" return [dict(row) for row in list(group.get("rows") or []) if clean(row.get(account_field))] @@ -16924,7 +26094,7 @@ def _is_grouped_cost_source_row(row: dict[str, Any]) -> bool: category = _row_account_category(row, "ledger") if _is_vat_family(family) or family in {"bank", "payable", "receivable", "advance"}: return False - return category in {"expense", "revenue"} + return category in {"expense", "revenue"} or _is_expense_like_account_name(row.get("ledger_account_name")) def _is_grouped_cost_business_row(row: dict[str, Any]) -> bool: @@ -16932,7 +26102,23 @@ def _is_grouped_cost_business_row(row: dict[str, Any]) -> bool: category = _row_account_category(row, "voucher") if _is_vat_family(family) or family in {"bank", "payable", "receivable", "advance"}: return False - return category in {"expense", "revenue"} + return category in {"expense", "revenue"} or _is_expense_like_account_name(row.get("voucher_account_name")) + + +def _is_expense_like_account_name(value: Any) -> bool: + text_value = normalize_text(value) + return any( + marker in text_value + for marker in ( + "보험료", + "산재보험", + "고용보험", + "건강보험", + "국민연금", + "복리후생", + "세금과공과", + ) + ) def _is_grouped_cost_counterpart_row(row: dict[str, Any]) -> bool: @@ -17177,6 +26363,1059 @@ def _apply_grouped_cost_counterpart_review( return voucher_sections +def _direct_grouped_cost_business_match_score( + ledger_row: dict[str, Any], + voucher_row: dict[str, Any], +) -> float: + if not _is_grouped_cost_source_row(ledger_row): + return 0.0 + if not _is_grouped_cost_business_row(voucher_row): + return 0.0 + ledger_amount = _get_row_match_amount(ledger_row, "ledger") + voucher_amount = _get_row_match_amount(voucher_row, "voucher") + if ledger_amount <= 0 or abs(ledger_amount - voucher_amount) >= 0.5: + return 0.0 + + ledger_category = _row_account_category(ledger_row, "ledger") + voucher_category = _row_account_category(voucher_row, "voucher") + if ledger_category and voucher_category and ledger_category != voucher_category: + return 0.0 + + account_match = ( + _account_base_names_compatible(ledger_row.get("ledger_account_name"), voucher_row.get("voucher_account_name")) + or _account_base_desc_approved( + { + **ledger_row, + "voucher_account_code": voucher_row.get("voucher_account_code", ""), + "voucher_account_name": voucher_row.get("voucher_account_name", ""), + } + ) + ) + desc_match = ( + _contained_core_desc_match(ledger_row.get("ledger_desc"), voucher_row.get("voucher_desc")) + or _meaningful_cross_source_overlap(ledger_row.get("ledger_desc"), voucher_row.get("voucher_desc")) + ) + vendor_match = _meaningful_cross_source_overlap(ledger_row.get("ledger_vendor"), voucher_row.get("voucher_vendor")) + if _has_named_reference_conflict(ledger_row, voucher_row) and not (vendor_match and desc_match): + return 0.0 + # Amount/account equality is too weak for direct cost retargeting. It caused + # unrelated expense vouchers with identical amounts to be promoted as matches. + if not (vendor_match or desc_match): + return 0.0 + if not (account_match or desc_match or vendor_match): + return 0.0 + + ledger_date = _parse_iso_date(ledger_row.get("ledger_date")) + voucher_date = _infer_compare_row_proof_date(voucher_row) + day_gap = abs((ledger_date - voucher_date).days) if ledger_date and voucher_date else None + month_gap = _month_gap(ledger_date, voucher_date) if ledger_date and voucher_date else None + if month_gap is not None and month_gap > 1: + return 0.0 + if day_gap is not None and day_gap > 45 and not (account_match and desc_match): + return 0.0 + + score = 85.0 + if account_match: + score += 18 + if desc_match: + score += 16 + if vendor_match: + score += 8 + if day_gap == 0: + score += 12 + elif day_gap is not None and day_gap <= 3: + score += 9 + elif day_gap is not None and day_gap <= 10: + score += 5 + return score + + +def _build_direct_grouped_cost_business_match( + source_group: dict[str, Any], + candidate_group: dict[str, Any], + *, + review_reason: str, +) -> dict[str, Any] | None: + ledger_rows = _section_side_rows(source_group, "ledger") + voucher_rows = _section_side_rows(candidate_group, "voucher") + if not ledger_rows or not voucher_rows: + return None + + pair_candidates: list[tuple[float, dict[str, Any], dict[str, Any]]] = [] + for ledger_row in ledger_rows: + for voucher_row in voucher_rows: + score = _direct_grouped_cost_business_match_score(ledger_row, voucher_row) + if score <= 0: + continue + pair_candidates.append((score, ledger_row, voucher_row)) + if not pair_candidates: + return None + + pair_candidates.sort(key=lambda item: item[0], reverse=True) + used_ledger: set[str] = set() + used_voucher: set[str] = set() + rows: list[dict[str, Any]] = [] + for _score, ledger_row, voucher_row in pair_candidates: + ledger_key = clean(ledger_row.get("ledger_row_key")) or build_ledger_row_key(ledger_row) + voucher_key = clean(voucher_row.get("voucher_row_key")) or build_voucher_row_key(voucher_row) + if ledger_key in used_ledger or voucher_key in used_voucher: + continue + merged = dict(ledger_row) + merged.update( + { + "proof_date": voucher_row.get("proof_date", ""), + "draft_no": voucher_row.get("draft_no", ""), + "voucher_account_code": voucher_row.get("voucher_account_code", ""), + "voucher_account_name": voucher_row.get("voucher_account_name", ""), + "voucher_vendor": voucher_row.get("voucher_vendor", ""), + "voucher_debit": voucher_row.get("voucher_debit", 0), + "voucher_credit": voucher_row.get("voucher_credit", 0), + "voucher_desc": voucher_row.get("voucher_desc", ""), + "voucher_row_key": voucher_row.get("voucher_row_key", ""), + "status_label": "Matched", + "review_reason": review_reason, + } + ) + rows.append(merged) + used_ledger.add(ledger_key) + used_voucher.add(voucher_key) + + if not rows: + return None + + for ledger_row in ledger_rows: + ledger_key = clean(ledger_row.get("ledger_row_key")) or build_ledger_row_key(ledger_row) + if ledger_key in used_ledger: + continue + payload = _blank_voucher_side(ledger_row) + payload["status_label"] = "Unmatched" + payload["review_reason"] = review_reason + rows.append(payload) + for voucher_row in voucher_rows: + voucher_key = clean(voucher_row.get("voucher_row_key")) or build_voucher_row_key(voucher_row) + if voucher_key in used_voucher: + continue + payload = _blank_ledger_side(voucher_row) + payload["status_label"] = "ERP Unmatched" + payload["review_reason"] = review_reason + rows.append(payload) + + rows.sort( + key=lambda item: ( + clean(item.get("ledger_date")) or clean(item.get("proof_date")), + clean(item.get("voucher_no")), + 0 if (clean(item.get("ledger_account_name")) and clean(item.get("voucher_account_name"))) else 1 if clean(item.get("ledger_account_name")) else 2, + clean(item.get("ledger_account_name")) or clean(item.get("voucher_account_name")), + clean(item.get("draft_no")), + -parse_amount(item.get("ledger_debit") or item.get("voucher_debit")), + -parse_amount(item.get("ledger_credit") or item.get("voucher_credit")), + clean(item.get("ledger_desc")) or clean(item.get("voucher_desc")), + ) + ) + + source_summary = dict(source_group.get("summary") or {}) + candidate_summary = dict(candidate_group.get("summary") or {}) + summary = _build_group_summary_from_rows( + fiscal_year=int(source_summary.get("fiscal_year") or candidate_summary.get("fiscal_year") or 0), + voucher_no=clean(source_summary.get("voucher_no")), + draft_no=clean(candidate_summary.get("draft_no")), + rows=rows, + status_label="Matched", + review_reason=review_reason, + ) + return {"summary": summary, "rows": rows} + + +def _account_name_contains_core(left: Any, right: Any) -> bool: + left_text = re.sub(r"[^0-9A-Za-z가-힣]+", "", normalize_text(left)) + right_text = re.sub(r"[^0-9A-Za-z가-힣]+", "", normalize_text(right)) + if not left_text or not right_text: + return False + return left_text == right_text or left_text in right_text or right_text in left_text + + +def _is_direct_financial_internal_match_group(group: dict[str, Any]) -> bool: + direct_rows = _section_direct_rows(group) + if not direct_rows: + return False + for row in direct_rows: + ledger_amount = _get_row_match_amount(row, "ledger") + voucher_amount = _get_row_match_amount(row, "voucher") + if ledger_amount <= 0 or abs(ledger_amount - voucher_amount) >= 0.5: + return False + ledger_category = _row_account_category(row, "ledger") + voucher_category = _row_account_category(row, "voucher") + ledger_family = _row_account_family(row, "ledger") + voucher_family = _row_account_family(row, "voucher") + if not ( + {ledger_category, voucher_category} <= {"asset", ""} + and {ledger_family, voucher_family} <= {"bank", ""} + ): + return False + if not ( + _account_base_names_compatible(row.get("ledger_account_name"), row.get("voucher_account_name")) + or _account_name_contains_core(row.get("ledger_account_name"), row.get("voucher_account_name")) + ): + return False + return True + + +def _promote_group_as_matched(group: dict[str, Any], review_reason: str) -> dict[str, Any]: + promoted_group = copy.deepcopy(group) + summary = dict(promoted_group.get("summary") or {}) + summary["status_label"] = "Matched" + summary["review_reason"] = clean(summary.get("review_reason")) or review_reason + promoted_group["summary"] = summary + for row in list(promoted_group.get("rows") or []): + row["status_label"] = "Matched" + row["review_reason"] = clean(row.get("review_reason")) or review_reason + return promoted_group + + +def _apply_direct_grouped_cost_business_review( + voucher_sections: dict[str, list[dict[str, Any]]], +) -> dict[str, list[dict[str, Any]]]: + erp_unmatched_groups = list(voucher_sections.get("erp_voucher_unmatched") or []) + erp_candidates = erp_unmatched_groups + list( + voucher_sections.get("erp_voucher_matched") or [] + ) + if not erp_candidates: + return voucher_sections + + amount_candidate_index: dict[float, list[dict[str, Any]]] = {} + for candidate_group in erp_candidates: + for voucher_row in _section_side_rows(candidate_group, "voucher"): + if not _is_grouped_cost_business_row(voucher_row): + continue + amount = round(_get_row_match_amount(voucher_row, "voucher"), 2) + if amount <= 0: + continue + amount_candidate_index.setdefault(amount, []).append(candidate_group) + + promoted_groups: list[dict[str, Any]] = [] + moved_wehago_group_ids: set[int] = set() + used_erp_group_ids: set[int] = set() + erp_unmatched_group_ids = {id(group) for group in erp_unmatched_groups} + + for group in list(voucher_sections.get("voucher_recheck") or []): + matched_group = _build_direct_grouped_cost_business_match( + group, + group, + review_reason="DIRECT_COST_BUSINESS_INTERNAL_MATCH", + ) + if matched_group is None and _is_direct_financial_internal_match_group(group): + matched_group = _promote_group_as_matched(group, "DIRECT_FINANCIAL_INTERNAL_MATCH") + if matched_group is None: + continue + promoted_groups.append(matched_group) + moved_wehago_group_ids.add(id(group)) + + for status_key in ("voucher_unmatched", "voucher_recheck"): + for group in list(voucher_sections.get(status_key) or []): + if id(group) in moved_wehago_group_ids: + continue + candidate_pool: list[dict[str, Any]] = [] + seen_candidates: set[int] = set() + for ledger_row in _section_side_rows(group, "ledger"): + if not _is_grouped_cost_source_row(ledger_row): + continue + amount = round(_get_row_match_amount(ledger_row, "ledger"), 2) + for candidate_group in amount_candidate_index.get(amount, []): + if id(candidate_group) in seen_candidates or id(candidate_group) in used_erp_group_ids: + continue + seen_candidates.add(id(candidate_group)) + candidate_pool.append(candidate_group) + if not candidate_pool: + continue + + best_group: dict[str, Any] | None = None + best_match: dict[str, Any] | None = None + best_score = -1.0 + for candidate_group in candidate_pool: + candidate_match = _build_direct_grouped_cost_business_match( + group, + candidate_group, + review_reason="DIRECT_COST_BUSINESS_ACCOUNT_MATCH", + ) + if candidate_match is None: + continue + direct_rows = _section_direct_rows(candidate_match) + score = len(direct_rows) * 1_000_000_000 + sum( + _get_row_match_amount(row, "ledger") for row in direct_rows + ) + if score > best_score: + best_group = candidate_group + best_match = candidate_match + best_score = score + if best_group is None or best_match is None: + continue + promoted_groups.append(best_match) + moved_wehago_group_ids.add(id(group)) + if id(best_group) in erp_unmatched_group_ids: + used_erp_group_ids.add(id(best_group)) + + if not promoted_groups: + return voucher_sections + + for status_key in ("voucher_unmatched", "voucher_recheck"): + voucher_sections[status_key] = [ + group + for group in list(voucher_sections.get(status_key) or []) + if id(group) not in moved_wehago_group_ids + ] + voucher_sections["erp_voucher_unmatched"] = [ + group + for group in list(voucher_sections.get("erp_voucher_unmatched") or []) + if id(group) not in used_erp_group_ids + ] + voucher_sections["voucher_matched"] = list(voucher_sections.get("voucher_matched") or []) + promoted_groups + voucher_sections["erp_voucher_matched"] = list(voucher_sections.get("erp_voucher_matched") or []) + [ + copy.deepcopy(group) for group in promoted_groups + ] + return voucher_sections + + +def _row_amount_side(row: dict[str, Any], prefix: str) -> str: + debit = parse_amount(row.get(f"{prefix}_debit")) + credit = parse_amount(row.get(f"{prefix}_credit")) + if debit > 0 and credit <= 0: + return "debit" + if credit > 0 and debit <= 0: + return "credit" + return "" + + +def _trace_identity_tokens(*values: Any) -> set[str]: + text_value = " ".join(clean(value) for value in values if clean(value)) + normalized = normalize_text(text_value) + if not normalized: + return set() + tokens = set(_raw_erp_vehicle_reference_tokens(normalized)) + tokens.update(_extract_named_reference_tokens(text_value)) + stop_tokens = { + "법인카드", + "하이패스", + "후불하이패스", + "통행료", + "교통비", + "출장비", + "차량유지비", + "여비교통비", + "기타", + "사용", + "대금", + } + for token in re.findall(r"[0-9a-z가-힣]+", normalized): + if len(token) < 3 or token in stop_tokens: + continue + if token.isdigit(): + continue + if re.fullmatch(r"\d{1,2}(년|월|일|건)", token): + continue + tokens.add(token) + return tokens + + +def _is_allowed_business_account_substitution(ledger_row: dict[str, Any], voucher_row: dict[str, Any]) -> bool: + ledger_name = normalize_text(ledger_row.get("ledger_account_name")) + voucher_name = normalize_text(voucher_row.get("voucher_account_name")) + pair_text = f"{ledger_name}|{voucher_name}" + allowed_pairs = ( + ("여비교통", "차량유지"), + ("여비교통", "통행료"), + ("차량유지", "통행료"), + ("지급수수료", "보증수수료"), + ("세금과공과", "보험료"), + ("복리후생", "식대"), + ) + if any(left in pair_text and right in pair_text for left, right in allowed_pairs): + return True + return False + + +def _unmatched_unconnected_trace_score( + ledger_row: dict[str, Any], + voucher_row: dict[str, Any], +) -> tuple[float, bool]: + if not _is_grouped_cost_source_row(ledger_row): + return 0.0, False + if not _is_grouped_cost_business_row(voucher_row): + return 0.0, False + + ledger_amount = _get_row_match_amount(ledger_row, "ledger") + voucher_amount = _get_row_match_amount(voucher_row, "voucher") + if ledger_amount <= 0 or abs(ledger_amount - voucher_amount) >= 0.5: + return 0.0, False + + ledger_side = _row_amount_side(ledger_row, "ledger") + voucher_side = _row_amount_side(voucher_row, "voucher") + if ledger_side and voucher_side and ledger_side != voucher_side: + return 0.0, False + + score = 40.0 + if ledger_side and voucher_side and ledger_side == voucher_side: + score += 20 + + ledger_category = _row_account_category(ledger_row, "ledger") + voucher_category = _row_account_category(voucher_row, "voucher") + if ledger_category and voucher_category: + if ledger_category == voucher_category: + score += 20 + else: + score -= 60 + + merged_account_row = { + **ledger_row, + "voucher_account_code": voucher_row.get("voucher_account_code", ""), + "voucher_account_name": voucher_row.get("voucher_account_name", ""), + } + account_match = _account_names_compatible(merged_account_row, merged_account_row) + account_base_match = _account_base_names_compatible( + ledger_row.get("ledger_account_name"), + voucher_row.get("voucher_account_name"), + ) + account_substitution = _is_allowed_business_account_substitution(ledger_row, voucher_row) + if account_match: + score += 20 + elif account_base_match: + score += 16 + elif account_substitution: + score += 12 + + desc_match = ( + _contained_core_desc_match(ledger_row.get("ledger_desc"), voucher_row.get("voucher_desc")) + or _core_token_strong_overlap(ledger_row.get("ledger_desc"), voucher_row.get("voucher_desc")) + ) + desc_overlap = _meaningful_cross_source_overlap(ledger_row.get("ledger_desc"), voucher_row.get("voucher_desc")) + vendor_to_desc_match = ( + _contained_core_desc_match(ledger_row.get("ledger_vendor"), voucher_row.get("voucher_desc")) + or _core_token_strong_overlap(ledger_row.get("ledger_vendor"), voucher_row.get("voucher_desc")) + or _meaningful_cross_source_overlap(ledger_row.get("ledger_vendor"), voucher_row.get("voucher_desc")) + ) + vendor_match = _meaningful_cross_source_overlap(ledger_row.get("ledger_vendor"), voucher_row.get("voucher_vendor")) + if desc_match: + score += 25 + elif desc_overlap: + score += 18 + if vendor_to_desc_match: + score += 25 + elif vendor_match: + score += 14 + + ledger_tokens = _trace_identity_tokens(ledger_row.get("ledger_vendor"), ledger_row.get("ledger_desc")) + voucher_tokens = _trace_identity_tokens(voucher_row.get("voucher_vendor"), voucher_row.get("voucher_desc")) + identity_overlap = ledger_tokens & voucher_tokens + identity_conflict = bool(ledger_tokens and voucher_tokens and not identity_overlap) + if identity_overlap: + score += 35 + elif identity_conflict: + score -= 35 + + ledger_date = _parse_row_date_with_year(ledger_row, "ledger_date") + voucher_date = _infer_compare_row_proof_date(voucher_row) + if ledger_date and voucher_date: + month_gap = _month_gap(ledger_date, voucher_date) + day_gap = abs((ledger_date - voucher_date).days) + if day_gap == 0: + score += 20 + elif month_gap <= 1: + score += 10 + elif day_gap > 75: + score -= 20 + + has_strong_evidence = bool( + identity_overlap + or desc_match + or vendor_to_desc_match + or (account_match and (desc_overlap or vendor_match)) + or (account_substitution and (desc_match or vendor_to_desc_match or identity_overlap)) + ) + if not has_strong_evidence: + return 0.0, identity_conflict + return score, identity_conflict + + +def _voucher_line_identity(row: dict[str, Any]) -> str: + row_key = clean(row.get("voucher_row_key")) + if row_key: + return f"key:{row_key}" + return "|".join( + [ + str(int(row.get("fiscal_year") or 0)), + clean(row.get("proof_date")), + clean(row.get("voucher_no")), + clean(row.get("draft_no")), + clean(row.get("voucher_account_code")) or clean(row.get("voucher_account_name")), + f"{parse_amount(row.get('voucher_debit')):.2f}", + f"{parse_amount(row.get('voucher_credit')):.2f}", + clean(row.get("voucher_vendor")), + clean(row.get("voucher_desc")), + ] + ) + + +def _ledger_line_identity(row: dict[str, Any]) -> str: + row_key = clean(row.get("ledger_row_key")) + if row_key: + return f"key:{row_key}" + return "|".join( + [ + str(int(row.get("fiscal_year") or 0)), + clean(row.get("ledger_date")), + clean(row.get("voucher_no")), + clean(row.get("ledger_account_code")) or clean(row.get("ledger_account_name")), + f"{parse_amount(row.get('ledger_debit')):.2f}", + f"{parse_amount(row.get('ledger_credit')):.2f}", + clean(row.get("ledger_vendor")), + clean(row.get("ledger_desc")), + ] + ) + + +def _build_unmatched_unconnected_trace_match( + source_group: dict[str, Any], + candidate_group: dict[str, Any], + used_voucher_ids: set[str], +) -> tuple[dict[str, Any] | None, set[str], float]: + ledger_rows = _section_side_rows(source_group, "ledger") + voucher_rows = _section_side_rows(candidate_group, "voucher") + if not ledger_rows or not voucher_rows: + return None, set(), 0.0 + + pair_candidates: list[tuple[float, bool, dict[str, Any], dict[str, Any]]] = [] + for ledger_row in ledger_rows: + for voucher_row in voucher_rows: + voucher_id = _voucher_line_identity(voucher_row) + if voucher_id in used_voucher_ids: + continue + score, identity_conflict = _unmatched_unconnected_trace_score(ledger_row, voucher_row) + threshold = 120.0 if identity_conflict else 100.0 + if score < threshold: + continue + pair_candidates.append((score, identity_conflict, ledger_row, voucher_row)) + if not pair_candidates: + return None, set(), 0.0 + + pair_candidates.sort(key=lambda item: item[0], reverse=True) + if len(pair_candidates) >= 2 and pair_candidates[0][0] - pair_candidates[1][0] < 8: + first_ledger = _ledger_line_identity(pair_candidates[0][2]) + first_voucher = _voucher_line_identity(pair_candidates[0][3]) + second_ledger = _ledger_line_identity(pair_candidates[1][2]) + second_voucher = _voucher_line_identity(pair_candidates[1][3]) + if first_ledger == second_ledger or first_voucher == second_voucher: + return None, set(), 0.0 + + used_ledger: set[str] = set() + local_used_voucher: set[str] = set() + rows: list[dict[str, Any]] = [] + total_score = 0.0 + for score, _identity_conflict, ledger_row, voucher_row in pair_candidates: + ledger_id = _ledger_line_identity(ledger_row) + voucher_id = _voucher_line_identity(voucher_row) + if ledger_id in used_ledger or voucher_id in local_used_voucher or voucher_id in used_voucher_ids: + continue + merged = dict(ledger_row) + voucher_row_key = clean(voucher_row.get("voucher_row_key")) or build_voucher_row_key(voucher_row) + merged.update( + { + "proof_date": voucher_row.get("proof_date", ""), + "draft_no": voucher_row.get("draft_no", ""), + "voucher_account_code": voucher_row.get("voucher_account_code", ""), + "voucher_account_name": voucher_row.get("voucher_account_name", ""), + "voucher_vendor": voucher_row.get("voucher_vendor", ""), + "voucher_debit": voucher_row.get("voucher_debit", 0), + "voucher_credit": voucher_row.get("voucher_credit", 0), + "voucher_desc": voucher_row.get("voucher_desc", ""), + "voucher_row_key": voucher_row_key, + "status_label": "Matched", + "review_reason": "UNMATCHED_UNCONNECTED_TRACE_MATCH", + "matched_case": "UNMATCHED_UNCONNECTED_TRACE_MATCH", + } + ) + rows.append(merged) + used_ledger.add(ledger_id) + local_used_voucher.add(voucher_id) + total_score += score + + if not rows: + return None, set(), 0.0 + + for ledger_row in ledger_rows: + ledger_id = _ledger_line_identity(ledger_row) + if ledger_id in used_ledger: + continue + payload = _blank_voucher_side(ledger_row) + payload["status_label"] = "Unmatched" + payload["review_reason"] = "UNMATCHED_UNCONNECTED_TRACE_CONTEXT_ONLY" + rows.append(payload) + + rows.sort( + key=lambda item: ( + clean(item.get("ledger_date")) or clean(item.get("proof_date")), + clean(item.get("voucher_no")), + 0 if (clean(item.get("ledger_account_name")) and clean(item.get("voucher_account_name"))) else 1 if clean(item.get("ledger_account_name")) else 2, + clean(item.get("ledger_account_name")) or clean(item.get("voucher_account_name")), + clean(item.get("draft_no")), + -parse_amount(item.get("ledger_debit") or item.get("voucher_debit")), + -parse_amount(item.get("ledger_credit") or item.get("voucher_credit")), + clean(item.get("ledger_desc")) or clean(item.get("voucher_desc")), + ) + ) + source_summary = dict(source_group.get("summary") or {}) + draft_nos: list[str] = [] + for row in rows: + draft_no = clean(row.get("draft_no")) + if draft_no and draft_no not in draft_nos: + draft_nos.append(draft_no) + summary = _build_group_summary_from_rows( + fiscal_year=int(source_summary.get("fiscal_year") or 0), + voucher_no=clean(source_summary.get("voucher_no")), + draft_no=", ".join(draft_nos), + rows=rows, + status_label="Matched", + review_reason="UNMATCHED_UNCONNECTED_TRACE_MATCH", + ) + return {"summary": summary, "rows": rows}, local_used_voucher, total_score + + +def _rebuild_erp_unmatched_group_without_used_rows( + group: dict[str, Any], + used_voucher_ids: set[str], +) -> dict[str, Any] | None: + remaining_rows = [ + dict(row) + for row in list(group.get("rows") or []) + if _voucher_line_identity(row) not in used_voucher_ids + ] + if not remaining_rows: + return None + summary = dict(group.get("summary") or {}) + voucher_no = ", ".join( + dict.fromkeys(clean(row.get("voucher_no")) for row in remaining_rows if clean(row.get("voucher_no"))) + ) + draft_no = ", ".join( + dict.fromkeys(clean(row.get("draft_no")) for row in remaining_rows if clean(row.get("draft_no"))) + ) + rebuilt_summary = _build_group_summary_from_rows( + fiscal_year=int(summary.get("fiscal_year") or remaining_rows[0].get("fiscal_year") or 0), + voucher_no=voucher_no or clean(summary.get("voucher_no")), + draft_no=draft_no or clean(summary.get("draft_no")), + rows=remaining_rows, + status_label="ERP Unmatched", + review_reason=clean(summary.get("review_reason")) or "ERP_UNMATCHED_REMAINING_AFTER_TRACE", + ) + for row in remaining_rows: + row["status_label"] = "ERP Unmatched" + return {"summary": rebuilt_summary, "rows": remaining_rows} + + +def _highpass_bundle_text(value: Any) -> str: + return normalize_text(value) + + +def _row_has_highpass_bundle_signal(row: dict[str, Any], prefix: str) -> bool: + text_value = _highpass_bundle_text( + " ".join( + clean(row.get(field)) + for field in ( + f"{prefix}_account_name", + f"{prefix}_vendor", + f"{prefix}_desc", + ) + ) + ) + if not text_value: + return False + if any(token in text_value for token in ("하이패스", "통행료", "업무용차량")): + return True + if "차량유지" in text_value and _raw_erp_vehicle_reference_tokens(text_value): + return True + if "차량유지" in text_value and any(token in text_value for token in ("투싼", "제네시스", "스타리아", "그랜저", "스타렉스")): + return True + return False + + +def _is_highpass_bundle_ledger_row(row: dict[str, Any]) -> bool: + if not _is_grouped_cost_source_row(row): + return False + if _row_amount_side(row, "ledger") != "debit": + return False + return _row_has_highpass_bundle_signal(row, "ledger") + + +def _is_highpass_bundle_voucher_business_row(row: dict[str, Any]) -> bool: + if not _is_grouped_cost_business_row(row): + return False + if _row_amount_side(row, "voucher") != "debit": + return False + return _row_has_highpass_bundle_signal(row, "voucher") + + +def _is_highpass_bundle_voucher_payable_row(row: dict[str, Any]) -> bool: + if _row_account_family(row, "voucher") not in {"payable", "bank", "advance"}: + return False + if _row_amount_side(row, "voucher") != "credit": + return False + text_value = _highpass_bundle_text( + " ".join( + clean(row.get(field)) + for field in ( + "voucher_account_name", + "voucher_vendor", + "voucher_desc", + ) + ) + ) + return any(token in text_value for token in ("하이패스", "통행료", "법인카드", "하나카드", "업무용차량")) + + +def _amount_multiset(values: Iterable[float]) -> dict[float, int]: + result: dict[float, int] = {} + for value in values: + amount = round(abs(float(value or 0)), 2) + if amount <= 0: + continue + result[amount] = result.get(amount, 0) + 1 + return result + + +def _highpass_bundle_candidate_rows(group: dict[str, Any], prefix: str) -> list[dict[str, Any]]: + rows = _section_side_rows(group, prefix) + if prefix == "ledger": + return [row for row in rows if _is_highpass_bundle_ledger_row(row)] + return [row for row in rows if _is_highpass_bundle_voucher_business_row(row)] + + +def _highpass_bundle_group_score( + source_group: dict[str, Any], + candidate_group: dict[str, Any], +) -> tuple[float, list[dict[str, Any]], list[dict[str, Any]], list[dict[str, Any]]]: + ledger_rows = _highpass_bundle_candidate_rows(source_group, "ledger") + voucher_rows = _highpass_bundle_candidate_rows(candidate_group, "voucher") + payable_rows = [ + row + for row in _section_side_rows(candidate_group, "voucher") + if _is_highpass_bundle_voucher_payable_row(row) + ] + if len(ledger_rows) < 2 or len(voucher_rows) < 2: + return 0.0, [], [], [] + + ledger_amounts = [_get_row_match_amount(row, "ledger") for row in ledger_rows] + voucher_amounts = [_get_row_match_amount(row, "voucher") for row in voucher_rows] + ledger_total = round(sum(ledger_amounts), 2) + voucher_total = round(sum(voucher_amounts), 2) + if ledger_total <= 0 or abs(ledger_total - voucher_total) >= 0.5: + return 0.0, [], [], [] + if _amount_multiset(ledger_amounts) != _amount_multiset(voucher_amounts): + return 0.0, [], [], [] + + payable_total = round(sum(_get_row_match_amount(row, "voucher") for row in payable_rows), 2) + if payable_rows and abs(payable_total - voucher_total) >= 0.5: + return 0.0, [], [], [] + + source_summary = dict(source_group.get("summary") or {}) + candidate_summary = dict(candidate_group.get("summary") or {}) + source_date = _parse_row_date_with_year( + { + "fiscal_year": source_summary.get("fiscal_year"), + "ledger_date": source_summary.get("ledger_date"), + }, + "ledger_date", + ) + candidate_date = _infer_compare_row_proof_date(voucher_rows[0]) if voucher_rows else None + day_bonus = 0.0 + if source_date and candidate_date: + gap = abs((source_date - candidate_date).days) + if gap == 0: + day_bonus = 25 + elif gap <= 45: + day_bonus = 12 + elif _month_gap(source_date, candidate_date) <= 1: + day_bonus = 6 + else: + return 0.0, [], [], [] + + base_score = 180.0 + len(ledger_rows) * 20.0 + min(voucher_total / 10000.0, 80.0) + day_bonus + if clean(candidate_summary.get("draft_no")): + base_score += 10 + if payable_rows: + base_score += 20 + return base_score, ledger_rows, voucher_rows, payable_rows + + +def _pair_highpass_bundle_rows( + ledger_rows: list[dict[str, Any]], + voucher_rows: list[dict[str, Any]], +) -> list[tuple[dict[str, Any], dict[str, Any]]]: + voucher_by_amount: dict[float, list[dict[str, Any]]] = {} + for row in voucher_rows: + voucher_by_amount.setdefault(round(_get_row_match_amount(row, "voucher"), 2), []).append(row) + + pairs: list[tuple[dict[str, Any], dict[str, Any]]] = [] + for ledger_row in sorted( + ledger_rows, + key=lambda row: ( + round(_get_row_match_amount(row, "ledger"), 2), + clean(row.get("ledger_vendor")), + clean(row.get("ledger_desc")), + ), + ): + amount = round(_get_row_match_amount(ledger_row, "ledger"), 2) + candidates = voucher_by_amount.get(amount) or [] + if not candidates: + continue + + def rank(voucher_row: dict[str, Any]) -> tuple[int, int, str]: + ledger_tokens = _trace_identity_tokens(ledger_row.get("ledger_vendor"), ledger_row.get("ledger_desc")) + voucher_tokens = _trace_identity_tokens(voucher_row.get("voucher_vendor"), voucher_row.get("voucher_desc")) + overlap_count = len(ledger_tokens & voucher_tokens) + desc_match = 1 if ( + _contained_core_desc_match(ledger_row.get("ledger_desc"), voucher_row.get("voucher_desc")) + or _core_token_strong_overlap(ledger_row.get("ledger_desc"), voucher_row.get("voucher_desc")) + ) else 0 + return (-overlap_count, -desc_match, clean(voucher_row.get("draft_no"))) + + candidates.sort(key=rank) + voucher_row = candidates.pop(0) + pairs.append((ledger_row, voucher_row)) + return pairs + + +def _build_highpass_bundle_match( + source_group: dict[str, Any], + candidate_group: dict[str, Any], + ledger_rows: list[dict[str, Any]], + voucher_rows: list[dict[str, Any]], + payable_rows: list[dict[str, Any]], +) -> tuple[dict[str, Any] | None, set[str]]: + pairs = _pair_highpass_bundle_rows(ledger_rows, voucher_rows) + if len(pairs) != len(ledger_rows) or len(pairs) != len(voucher_rows): + return None, set() + + rows: list[dict[str, Any]] = [] + used_voucher_ids: set[str] = set() + for ledger_row, voucher_row in pairs: + voucher_id = _voucher_line_identity(voucher_row) + voucher_row_key = clean(voucher_row.get("voucher_row_key")) or build_voucher_row_key(voucher_row) + merged = dict(ledger_row) + merged.update( + { + "proof_date": voucher_row.get("proof_date", ""), + "draft_no": voucher_row.get("draft_no", ""), + "voucher_account_code": voucher_row.get("voucher_account_code", ""), + "voucher_account_name": voucher_row.get("voucher_account_name", ""), + "voucher_vendor": voucher_row.get("voucher_vendor", ""), + "voucher_debit": voucher_row.get("voucher_debit", 0), + "voucher_credit": voucher_row.get("voucher_credit", 0), + "voucher_desc": voucher_row.get("voucher_desc", ""), + "voucher_row_key": voucher_row_key, + "status_label": "Matched", + "review_reason": "HIGH_PASS_CARD_BUNDLE_MATCH", + "matched_case": "HIGH_PASS_CARD_BUNDLE_MATCH", + } + ) + rows.append(merged) + used_voucher_ids.add(voucher_id) + + for payable_row in payable_rows: + voucher_id = _voucher_line_identity(payable_row) + payable_payload = _blank_ledger_side(payable_row) + payable_payload["status_label"] = "Matched" + payable_payload["review_reason"] = "HIGH_PASS_CARD_BUNDLE_CONTEXT" + payable_payload["matched_case"] = "HIGH_PASS_CARD_BUNDLE_CONTEXT" + rows.append(payable_payload) + used_voucher_ids.add(voucher_id) + + rows.sort( + key=lambda item: ( + clean(item.get("ledger_date")) or clean(item.get("proof_date")), + clean(item.get("voucher_no")), + 0 if (clean(item.get("ledger_account_name")) and clean(item.get("voucher_account_name"))) else 1 if clean(item.get("ledger_account_name")) else 2, + clean(item.get("ledger_account_name")) or clean(item.get("voucher_account_name")), + clean(item.get("draft_no")), + -parse_amount(item.get("ledger_debit") or item.get("voucher_debit")), + -parse_amount(item.get("ledger_credit") or item.get("voucher_credit")), + clean(item.get("ledger_desc")) or clean(item.get("voucher_desc")), + ) + ) + + source_summary = dict(source_group.get("summary") or {}) + draft_nos = list(dict.fromkeys(clean(row.get("draft_no")) for row in rows if clean(row.get("draft_no")))) + summary = _build_group_summary_from_rows( + fiscal_year=int(source_summary.get("fiscal_year") or 0), + voucher_no=clean(source_summary.get("voucher_no")), + draft_no=", ".join(draft_nos), + rows=rows, + status_label="Matched", + review_reason="HIGH_PASS_CARD_BUNDLE_MATCH", + ) + return {"summary": summary, "rows": rows}, used_voucher_ids + + +def _apply_highpass_bundle_trace_review( + voucher_sections: dict[str, list[dict[str, Any]]], +) -> dict[str, list[dict[str, Any]]]: + erp_unmatched_groups = list(voucher_sections.get("erp_voucher_unmatched") or []) + if not erp_unmatched_groups: + return voucher_sections + + candidate_groups: list[dict[str, Any]] = [] + for group in erp_unmatched_groups: + voucher_rows = _highpass_bundle_candidate_rows(group, "voucher") + payable_rows = [ + row + for row in _section_side_rows(group, "voucher") + if _is_highpass_bundle_voucher_payable_row(row) + ] + if len(voucher_rows) >= 2 and (payable_rows or len(voucher_rows) >= 3): + candidate_groups.append(group) + if not candidate_groups: + return voucher_sections + + promoted_groups: list[dict[str, Any]] = [] + moved_source_ids: set[int] = set() + used_voucher_ids: set[str] = set() + + for status_key in ("voucher_unmatched", "voucher_recheck", "voucher_matched"): + for source_group in list(voucher_sections.get(status_key) or []): + if status_key == "voucher_matched" and _section_direct_rows(source_group): + continue + if id(source_group) in moved_source_ids: + continue + best: tuple[float, dict[str, Any], list[dict[str, Any]], list[dict[str, Any]], list[dict[str, Any]]] | None = None + for candidate_group in candidate_groups: + if any(_voucher_line_identity(row) in used_voucher_ids for row in _section_side_rows(candidate_group, "voucher")): + continue + score, ledger_rows, voucher_rows, payable_rows = _highpass_bundle_group_score(source_group, candidate_group) + if score <= 0: + continue + if best is None or score > best[0]: + best = (score, candidate_group, ledger_rows, voucher_rows, payable_rows) + if best is None: + continue + _score, candidate_group, ledger_rows, voucher_rows, payable_rows = best + matched_group, matched_voucher_ids = _build_highpass_bundle_match( + source_group, + candidate_group, + ledger_rows, + voucher_rows, + payable_rows, + ) + if matched_group is None or not matched_voucher_ids: + continue + promoted_groups.append(matched_group) + moved_source_ids.add(id(source_group)) + used_voucher_ids.update(matched_voucher_ids) + + if not promoted_groups: + return voucher_sections + + for status_key in ("voucher_unmatched", "voucher_recheck", "voucher_matched"): + voucher_sections[status_key] = [ + group + for group in list(voucher_sections.get(status_key) or []) + if id(group) not in moved_source_ids + ] + rebuilt_erp_unmatched: list[dict[str, Any]] = [] + for group in erp_unmatched_groups: + rebuilt_group = _rebuild_erp_unmatched_group_without_used_rows(group, used_voucher_ids) + if rebuilt_group is not None: + rebuilt_erp_unmatched.append(rebuilt_group) + voucher_sections["erp_voucher_unmatched"] = rebuilt_erp_unmatched + voucher_sections["voucher_matched"] = list(voucher_sections.get("voucher_matched") or []) + promoted_groups + voucher_sections["erp_voucher_matched"] = list(voucher_sections.get("erp_voucher_matched") or []) + [ + copy.deepcopy(group) for group in promoted_groups + ] + return voucher_sections + + +def _apply_unmatched_unconnected_trace_review( + voucher_sections: dict[str, list[dict[str, Any]]], +) -> dict[str, list[dict[str, Any]]]: + erp_unmatched_groups = list(voucher_sections.get("erp_voucher_unmatched") or []) + if not erp_unmatched_groups: + return voucher_sections + + amount_candidate_index: dict[float, list[dict[str, Any]]] = {} + for candidate_group in erp_unmatched_groups: + for voucher_row in _section_side_rows(candidate_group, "voucher"): + if not _is_grouped_cost_business_row(voucher_row): + continue + amount = round(_get_row_match_amount(voucher_row, "voucher"), 2) + if amount <= 0: + continue + amount_candidate_index.setdefault(amount, []).append(candidate_group) + if not amount_candidate_index: + return voucher_sections + + source_statuses = ("voucher_unmatched", "voucher_recheck", "voucher_matched") + promoted_groups: list[dict[str, Any]] = [] + moved_source_ids: set[int] = set() + used_voucher_ids: set[str] = set() + + for status_key in source_statuses: + for source_group in list(voucher_sections.get(status_key) or []): + if status_key == "voucher_matched" and _section_direct_rows(source_group): + continue + candidate_pool: list[dict[str, Any]] = [] + seen_candidates: set[int] = set() + for ledger_row in _section_side_rows(source_group, "ledger"): + if not _is_grouped_cost_source_row(ledger_row): + continue + amount = round(_get_row_match_amount(ledger_row, "ledger"), 2) + for candidate_group in amount_candidate_index.get(amount, []): + if id(candidate_group) in seen_candidates: + continue + seen_candidates.add(id(candidate_group)) + candidate_pool.append(candidate_group) + if not candidate_pool: + continue + + best_match: dict[str, Any] | None = None + best_used_vouchers: set[str] = set() + best_rank = -1.0 + for candidate_group in candidate_pool: + matched_group, matched_voucher_ids, score = _build_unmatched_unconnected_trace_match( + source_group, + candidate_group, + used_voucher_ids, + ) + if matched_group is None or not matched_voucher_ids: + continue + direct_count = len(_section_direct_rows(matched_group)) + rank = direct_count * 1_000_000_000 + score + if rank > best_rank: + best_match = matched_group + best_used_vouchers = matched_voucher_ids + best_rank = rank + if best_match is None or not best_used_vouchers: + continue + promoted_groups.append(best_match) + moved_source_ids.add(id(source_group)) + used_voucher_ids.update(best_used_vouchers) + + if not promoted_groups: + return voucher_sections + + for status_key in source_statuses: + voucher_sections[status_key] = [ + group + for group in list(voucher_sections.get(status_key) or []) + if id(group) not in moved_source_ids + ] + rebuilt_erp_unmatched: list[dict[str, Any]] = [] + for group in erp_unmatched_groups: + rebuilt_group = _rebuild_erp_unmatched_group_without_used_rows(group, used_voucher_ids) + if rebuilt_group is not None: + rebuilt_erp_unmatched.append(rebuilt_group) + voucher_sections["erp_voucher_unmatched"] = rebuilt_erp_unmatched + voucher_sections["voucher_matched"] = list(voucher_sections.get("voucher_matched") or []) + promoted_groups + voucher_sections["erp_voucher_matched"] = list(voucher_sections.get("erp_voucher_matched") or []) + [ + copy.deepcopy(group) for group in promoted_groups + ] + return voucher_sections + + def _raw_erp_amount_entries_by_year( conn: Any, start_year: int | None, @@ -17480,6 +27719,14 @@ def _raw_erp_has_cross_source_evidence( def _raw_erp_trace_score(ledger_row: dict[str, Any], entry: dict[str, Any]) -> tuple[float, dict[str, Any]]: candidate_row = _raw_erp_entry_to_voucher_row(entry, ledger_row) + if _row_is_year_opening_substitution_for_guard(candidate_row): + return 0.0, candidate_row + if _row_is_grant_settlement_direct_mismatch_for_guard(candidate_row): + return 0.0, candidate_row + if _rows_have_project_token_conflict_for_guard(candidate_row): + return 0.0, candidate_row + if _row_has_service_month_conflict_for_guard(candidate_row): + return 0.0, candidate_row ledger_amount = max( abs(parse_amount(ledger_row.get("ledger_debit"))), abs(parse_amount(ledger_row.get("ledger_credit"))), @@ -17684,6 +27931,85 @@ def _raw_erp_trace_prefilter_score(ledger_row: dict[str, Any], entry: dict[str, return score +def _raw_erp_entry_account_family_keys(entry: dict[str, Any]) -> set[str]: + account_code = entry.get("account_code") + account_name = entry.get("account_name") + if entry.get("_raw_tax_evidence"): + side = clean(entry.get("_raw_amount_side")) + account_name = "매입세액" if side == "debit" else "매출세액" + return _account_candidate_family_keys(account_code, account_name) + + +def _raw_erp_entries_for_ledger_row( + ledger_row: dict[str, Any], + entries: list[dict[str, Any]], +) -> list[dict[str, Any]]: + if len(entries) <= 180: + return entries + + direct_ref_entries: list[dict[str, Any]] = [] + cheap_date_entries: list[dict[str, Any]] = [] + ledger_dates = [ + value + for value in ( + _parse_row_date_with_year(ledger_row, "ledger_date"), + _parse_row_date_with_year(ledger_row, "proof_date"), + ) + if value + ] + ledger_keys = _account_candidate_family_keys( + ledger_row.get("ledger_account_code"), + ledger_row.get("ledger_account_name"), + ) + ledger_family = _classify_account_family( + ledger_row.get("ledger_account_code"), + ledger_row.get("ledger_account_name"), + ) + ledger_category = _classify_account_category( + ledger_row.get("ledger_account_code"), + ledger_row.get("ledger_account_name"), + ) + + account_entries: list[dict[str, Any]] = [] + for entry in entries: + direct_ref_matched, _direct_ref_strong = _raw_erp_direct_voucher_reference_matches(ledger_row, entry) + if direct_ref_matched: + direct_ref_entries.append(entry) + continue + + entry_dates = tuple(entry.get("_raw_entry_dates") or _raw_erp_entry_date_values(entry)) + if ledger_dates and any( + abs((ledger_date - entry_date).days) <= 62 + for ledger_date in ledger_dates + for entry_date in entry_dates + ): + cheap_date_entries.append(entry) + + entry_family = _classify_account_family(entry.get("account_code"), entry.get("account_name")) + entry_category = _classify_account_category(entry.get("account_code"), entry.get("account_name")) + if entry.get("_raw_tax_evidence") and _is_vat_family(ledger_family): + account_entries.append(entry) + elif ledger_keys & _raw_erp_entry_account_family_keys(entry): + account_entries.append(entry) + elif ledger_category in {"expense", "revenue"} and entry_family in {"payable", "bank", "receivable", "advance"}: + account_entries.append(entry) + elif ledger_family in {"payable", "bank", "receivable", "advance"} and entry_category in {"expense", "revenue"}: + account_entries.append(entry) + + if direct_ref_entries: + return direct_ref_entries + [entry for entry in cheap_date_entries if entry in direct_ref_entries] + if cheap_date_entries and len(cheap_date_entries) <= 180: + return cheap_date_entries + if account_entries and len(account_entries) <= 180: + return account_entries + if cheap_date_entries and account_entries: + account_ids = {id(entry) for entry in account_entries} + intersected = [entry for entry in cheap_date_entries if id(entry) in account_ids] + if intersected: + return intersected[:180] + return [] + + def _build_raw_erp_trace_match( source_group: dict[str, Any], amount_index: dict[float, list[dict[str, Any]]], @@ -17704,6 +28030,9 @@ def _build_raw_erp_trace_match( if amount <= 0: continue entries = list(amount_index.get(amount, []) or []) + entries = _raw_erp_entries_for_ledger_row(ledger_row, entries) + if not entries: + continue entries = [entry for entry in entries if _raw_erp_trace_prefilter(ledger_row, entry)] if len(entries) > 120: ranked_entries = [ @@ -17717,6 +28046,8 @@ def _build_raw_erp_trace_match( score, candidate_row = _raw_erp_trace_score(ledger_row, entry) if score < 70: continue + if _row_sides_have_opposite_signed_amount(candidate_row): + continue pair_candidates.append((score, ledger_row, candidate_row)) if not pair_candidates: return None @@ -17794,9 +28125,6 @@ def _build_raw_erp_trace_match( draft_no = clean(row.get("draft_no")) if draft_no and draft_no not in direct_draft_nos: direct_draft_nos.append(draft_no) - confirmed_no = clean(row.get("voucher_confirmed_no")) - if confirmed_no and confirmed_no not in direct_draft_nos: - direct_draft_nos.append(confirmed_no) group_summary = _build_group_summary_from_rows( fiscal_year=int(summary.get("fiscal_year") or 0), voucher_no=clean(summary.get("voucher_no")), @@ -17814,6 +28142,8 @@ def _apply_raw_erp_trace_promotions( end_year: int | None, voucher_sections: dict[str, list[dict[str, Any]]], ) -> dict[str, list[dict[str, Any]]]: + if not _enable_raw_erp_trace_promotions(): + return voucher_sections amount_entries = _raw_erp_amount_entries_by_year(conn, start_year, end_year) if not amount_entries: return voucher_sections @@ -18121,12 +28451,29 @@ def _is_wehago_excepted_voucher_group(group: dict[str, Any]) -> tuple[bool, str] compact_haystack = haystack.replace(" ", "") compact_ledger_desc = _voucher_group_ledger_description_text(group).replace(" ", "") compact_ledger_accounts = _voucher_group_ledger_account_text(group).replace(" ", "") + vendor_text = clean(summary.get("ledger_vendors") or summary.get("group_ledger_vendors")) + compact_vendor_text = vendor_text.replace(" ", "") if "감가상각" in compact_ledger_accounts: return True, "WEHAGO_EXCEPTED_DEPRECIATION_ACCOUNT" + if "01-01" in month_days and any( + token in compact_ledger_desc + for token in _OPENING_SUBSTITUTION_TOKENS_FOR_GUARD + ): + return True, _YEAR_OPENING_SUBSTITUTION_REASON if _is_boundary_vat_tax_invoice_group(group): return False, "" offset_desc = re.sub(r"상계\s*\d+(?:-\d+)?\s*소구(?:역)?", "", _voucher_group_ledger_description_text(group)) compact_offset_desc = offset_desc.replace(" ", "") + strong_excepted_text = re.sub(r"상계\s*\d+(?:-\d+)?\s*소구(?:역)?", "", haystack).replace(" ", "") + has_strong_accounting_exception = any( + token in strong_excepted_text + for token in _WEHAGO_STRONG_ACCOUNTING_EXCEPTION_TOKENS + ) or any( + token in compact_vendor_text + for token in _WEHAGO_STRONG_ACCOUNTING_EXCEPTION_VENDOR_TOKENS + ) + if has_strong_accounting_exception: + return True, "WEHAGO_EXCEPTED_ACCOUNTING_EXCEPTION_ENTRY" has_accounting_offset = any( token in compact_offset_desc for token in ( @@ -18294,6 +28641,154 @@ def _offset_vectors_same_direction(left: dict[tuple[str, str, str], float], righ return all(abs(left[key] - right[key]) < 0.5 for key in left) +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 _offset_vectors_have_opposite_direction(left: dict[tuple[str, str, str], float], right: dict[tuple[str, str, str], float]) -> bool: + if not left or not right or set(left) != set(right): + return False + return all(left[key] * right[key] < -0.25 for key in left) + + +def _voucher_group_has_negative_ledger_direction(group: dict[str, Any]) -> bool: + return any( + _row_signed_amount(row, "ledger") < -0.5 + for row in list(group.get("rows") or []) + if clean(row.get("ledger_account_name")) + ) + + +def _voucher_group_has_negative_ledger_field(group: dict[str, Any]) -> bool: + return any( + parse_amount(row.get(field)) < -0.5 + for row in list(group.get("rows") or []) + for field in ("ledger_debit", "ledger_credit") + ) + + +def _voucher_group_has_positive_ledger_direction(group: dict[str, Any]) -> bool: + return any( + _row_signed_amount(row, "ledger") > 0.5 + for row in list(group.get("rows") or []) + if clean(row.get("ledger_account_name")) + ) + + +def _voucher_group_has_purchase_sales_cancel_signal(group: dict[str, Any]) -> bool: + if _voucher_group_has_negative_ledger_field(group): + return True + compact_haystack = _voucher_group_text(group).replace(" ", "") + return any( + token in compact_haystack + for token in ( + "취소", + "취소전표", + "매입취소", + "매출취소", + "환입", + "마이너스", + "수정세금계산서", + "계산서취소", + ) + ) + + +def _voucher_group_date_distance(left: dict[str, Any], right: dict[str, Any]) -> int: + left_dates = _voucher_group_dates(left) + right_dates = _voucher_group_dates(right) + if not left_dates or not right_dates: + return 999999 + return min(abs((left_date - right_date).days) for left_date in left_dates for right_date in right_dates) + + +def _voucher_group_has_wehago_revenue_signal(group: dict[str, Any]) -> bool: + rows = list(group.get("rows") or []) + has_revenue_or_vat = False + has_receivable = False + for row in rows: + family = _classify_account_family(row.get("ledger_account_code"), row.get("ledger_account_name")) + if family in {"sales", "vat_output"}: + has_revenue_or_vat = True + if family == "receivable": + has_receivable = True + haystack = _voucher_group_text(group) + return has_revenue_or_vat or (has_receivable and any(token in haystack for token in ("매출", "수입", "계산서", "부가세"))) + + +def _move_wehago_revenue_reversal_pairs_to_excepted( + voucher_sections: dict[str, list[dict[str, Any]]], +) -> dict[str, list[dict[str, Any]]]: + candidate_statuses = ("voucher_matched", "voucher_unmatched", "voucher_recheck") + candidates: 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(candidates) < 2: + return voucher_sections + + candidate_vectors: dict[int, dict[tuple[str, str, str], float]] = {} + buckets: dict[tuple[tuple[tuple[str, str, str], float], ...], list[tuple[str, dict[str, Any]]]] = {} + for status_key, group in candidates: + if not _voucher_group_has_wehago_revenue_signal(group): + continue + vector = _offset_group_vector(group) + if not vector: + continue + candidate_vectors[id(group)] = vector + buckets.setdefault(_offset_vector_abs_key(vector), []).append((status_key, group)) + + moved_ids: set[int] = set() + for grouped_candidates in buckets.values(): + remaining = [(status_key, group) for status_key, group in grouped_candidates if id(group) not in moved_ids] + if len(remaining) < 2: + continue + pairs: list[tuple[int, int, int, dict[str, Any], dict[str, Any]]] = [] + for left_index, (_left_status, left_group) in enumerate(remaining): + left_vector = candidate_vectors.get(id(left_group)) or {} + for right_index, (_right_status, right_group) in enumerate(remaining[left_index + 1:], start=left_index + 1): + right_vector = candidate_vectors.get(id(right_group)) or {} + if not _offset_vectors_have_opposite_direction(left_vector, right_vector): + continue + date_distance = _voucher_group_date_distance(left_group, right_group) + if date_distance > 400: + continue + pairs.append((date_distance, left_index, right_index, left_group, right_group)) + if not pairs: + continue + pairs.sort(key=lambda item: (item[0], item[1], item[2])) + used_indexes: set[int] = set() + for _distance, left_index, right_index, left_group, right_group in pairs: + if left_index in used_indexes or right_index in used_indexes: + continue + used_indexes.update((left_index, right_index)) + moved_ids.add(id(left_group)) + moved_ids.add(id(right_group)) + + 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 candidates: + if id(group) not in moved_ids: + retained_by_status[status_key].append(group) + continue + moved_excepted.append( + _copy_wehago_group_with_status( + group, + status_label="Excepted", + reason="WEHAGO_EXCEPTED_REVENUE_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 _voucher_group_has_review_reason(group: dict[str, Any], *needles: str) -> bool: haystack = _voucher_group_text(group) return any(normalize_text(needle) in haystack for needle in needles if clean(needle)) @@ -18320,6 +28815,86 @@ def _copy_wehago_group_with_status( return group_copy +def _move_wehago_purchase_sales_cancel_pairs_to_excepted( + voucher_sections: dict[str, list[dict[str, Any]]], +) -> dict[str, list[dict[str, Any]]]: + candidate_statuses = ("voucher_matched", "voucher_unmatched", "voucher_recheck") + candidates: 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(candidates) < 2: + return voucher_sections + + vectors: dict[int, dict[tuple[str, str, str], float]] = {} + buckets: dict[tuple[tuple[tuple[str, str, str], float], ...], list[tuple[str, dict[str, Any]]]] = {} + for status_key, group in candidates: + vector = _offset_group_vector(group) + if not vector: + continue + vectors[id(group)] = vector + buckets.setdefault(_offset_vector_abs_key(vector), []).append((status_key, group)) + + moved_ids: set[int] = set() + for grouped_candidates in buckets.values(): + available = [(status_key, group) for status_key, group in grouped_candidates if id(group) not in moved_ids] + if len(available) < 2: + continue + pair_candidates: list[tuple[int, int, int, dict[str, Any], dict[str, Any]]] = [] + for left_index, (_left_status, left_group) in enumerate(available): + left_vector = vectors.get(id(left_group)) or {} + for right_index, (_right_status, right_group) in enumerate(available[left_index + 1:], start=left_index + 1): + right_vector = vectors.get(id(right_group)) or {} + if not _offset_vectors_have_opposite_direction(left_vector, right_vector): + continue + left_negative = _voucher_group_has_negative_ledger_field(left_group) + right_negative = _voucher_group_has_negative_ledger_field(right_group) + if left_negative == right_negative: + continue + if not ( + _voucher_group_has_purchase_sales_cancel_signal(left_group) + or _voucher_group_has_purchase_sales_cancel_signal(right_group) + ): + continue + date_distance = _voucher_group_date_distance(left_group, right_group) + if date_distance > 62: + continue + pair_candidates.append((date_distance, left_index, right_index, left_group, right_group)) + if not pair_candidates: + continue + pair_candidates.sort(key=lambda item: (item[0], item[1], item[2])) + used_indexes: set[int] = set() + for _distance, left_index, right_index, left_group, right_group in pair_candidates: + if left_index in used_indexes or right_index in used_indexes: + continue + used_indexes.update((left_index, right_index)) + moved_ids.add(id(left_group)) + moved_ids.add(id(right_group)) + + 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 candidates: + if id(group) not in moved_ids: + retained_by_status[status_key].append(group) + continue + moved_excepted.append( + _copy_wehago_group_with_status( + group, + status_label="Excepted", + reason="WEHAGO_EXCEPTED_PURCHASE_SALES_CANCEL_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_wehago_confirmed_reversal_pairs_to_excepted( voucher_sections: dict[str, list[dict[str, Any]]], ) -> dict[str, list[dict[str, Any]]]: @@ -18700,6 +29275,12 @@ def _build_voucher_status_detail_response_from_sections( offset: int, limit: int, ) -> dict[str, Any]: + if status in {"voucher_matched", "voucher_unmatched"}: + voucher_sections = { + key: list(value or []) + for key, value in (voucher_sections or {}).items() + } + voucher_sections = _demote_matched_groups_without_erp_detail(voucher_sections) source_rows = list(voucher_sections.get(status, []) or []) filtered_rows = [ group @@ -18882,7 +29463,7 @@ def _fetch_voucher_status_detail_rows_from_db( conditions = [ build_year_filter_sql("c.fiscal_year"), status_condition, - "(:voucher_no = '' OR COALESCE(l.voucher_no, v.confirmed_no, v.draft_no, c.voucher_no) LIKE :voucher_like)", + "(:voucher_no = '' OR COALESCE(l.voucher_no, v.confirmed_no, v.draft_no, c.voucher_no) LIKE :voucher_like OR COALESCE(c.voucher_no, '') LIKE :voucher_like)", "(:draft_no = '' OR COALESCE(v.draft_no, '') LIKE :draft_like)", "(:account_keyword = '' OR COALESCE(c.ledger_accounts, '') LIKE :account_like OR COALESCE(c.voucher_accounts, '') LIKE :account_like)", "(:vendor_keyword = '' OR COALESCE(c.ledger_vendors, '') LIKE :vendor_like OR COALESCE(c.voucher_vendors, '') LIKE :vendor_like)", @@ -19045,6 +29626,128 @@ def _has_status_detail_filters( ) +def _expand_status_payload_full_erp_vouchers( + conn: Any, + payload: dict[str, Any], + status_key: str, +) -> dict[str, Any]: + if status_key not in {"voucher_matched", "voucher_recheck"}: + return payload + groups = payload.get("groups") + if not isinstance(groups, list) or not groups: + return payload + + def full_draft_base(value: Any) -> str: + draft_number = clean(value) + matched = re.fullmatch(r"(11-\d{8}-[^-]+-\d+)-\d+", draft_number) + return matched.group(1) if matched else draft_number + + group_bases: dict[int, set[str]] = {} + requested_bases: set[str] = set() + for group_index, group in enumerate(groups): + if not isinstance(group, dict): + continue + summary = group.get("summary") if isinstance(group.get("summary"), dict) else {} + bases: set[str] = set() + draft_values = [ + summary.get("draft_no"), + *(row.get("draft_no") for row in list(group.get("rows") or []) if isinstance(row, dict)), + ] + for draft_value in draft_values: + for value in _split_erp_number_candidates(draft_value): + base = full_draft_base(value) + if base: + bases.add(base) + for value in _split_erp_number_candidates(summary.get("draft_no")): + base = full_draft_base(value) + if base: + bases.add(base) + if bases: + group_bases[group_index] = bases + requested_bases.update(bases) + if not requested_bases: + return payload + + conditions: list[str] = [] + params: dict[str, Any] = {} + for index, base in enumerate(sorted(requested_bases)): + key = f"draft_base_{index}" + conditions.append( + f"(COALESCE(draft_no, '') = :{key} OR COALESCE(draft_no, '') LIKE :{key}_suffix " + f"OR COALESCE(confirmed_no, '') = :{key} OR COALESCE(confirmed_no, '') LIKE :{key}_suffix)" + ) + params[key] = base + params[f"{key}_suffix"] = f"{base}-%" + raw_rows = conn.execute( + text( + f""" + SELECT fiscal_year, proof_date, confirmed_no, draft_no, account_code, account_name, + debit_supply, credit_supply, vendor_name, desc1, desc2, row_number, id + FROM wehago_voucher_rows + WHERE {' OR '.join(conditions)} + ORDER BY fiscal_year, draft_no, row_number, id + """ + ), + params, + ).mappings().all() + raw_by_base: dict[str, list[dict[str, Any]]] = {} + for raw in raw_rows: + raw_dict = dict(raw) + base = full_draft_base(raw_dict.get("draft_no") or raw_dict.get("confirmed_no")) + if base: + raw_by_base.setdefault(base, []).append(raw_dict) + + status_label = "Matched" if status_key == "voucher_matched" else "Recheck" + flattened_rows: list[dict[str, Any]] = [] + for group_index, group in enumerate(groups): + if not isinstance(group, dict): + continue + summary = group.get("summary") if isinstance(group.get("summary"), dict) else {} + anchor = _wehago_anchor_key_from_parts( + summary.get("fiscal_year"), + summary.get("ledger_date"), + summary.get("voucher_no"), + ) + rows = [dict(row) for row in list(group.get("rows") or []) if isinstance(row, dict)] + seen_draft_rows = { + clean(row.get("draft_no")) + for row in rows + if _row_has_hanmac_candidate_side(row) and clean(row.get("draft_no")) + } + seen_voucher_content = { + _display_voucher_content_key(row) + for row in rows + if _row_has_hanmac_candidate_side(row) + } + for base in sorted(group_bases.get(group_index, set())): + for raw in raw_by_base.get(base, []): + full_row = _raw_erp_recheck_row(raw, anchor) + full_row["status_label"] = status_label + full_row = _blank_wehago_identity_for_erp_only_display(full_row) + draft_number = clean(full_row.get("draft_no")) + content_key = _display_voucher_content_key(full_row) + if (draft_number and draft_number in seen_draft_rows) or content_key in seen_voucher_content: + continue + rows.append(full_row) + if draft_number: + seen_draft_rows.add(draft_number) + seen_voucher_content.add(content_key) + group["rows"] = _sanitize_voucher_group_rows(rows) + summary["voucher_row_count"] = sum( + 1 for row in group["rows"] if _row_has_hanmac_candidate_side(row) + ) + summary["voucher_debit"] = sum( + parse_amount(row.get("voucher_debit")) for row in group["rows"] + ) + summary["voucher_credit"] = sum( + parse_amount(row.get("voucher_credit")) for row in group["rows"] + ) + group["summary"] = summary + flattened_rows.extend(dict(row) for row in group["rows"]) + payload["rows"] = flattened_rows + return payload + + def get_status_detail_rows( engine: Any, start_year: int | None, @@ -19103,7 +29806,26 @@ def get_status_detail_rows( if normalized_status not in valid_statuses: raise ValueError("상태 값이 올바르지 않습니다.") - voucher_filter = clean(voucher_no) + raw_voucher_filter = clean(voucher_no) + voucher_filter = raw_voucher_filter + if raw_voucher_filter: + default_filter_year = int(start_year) if int(start_year or 0) == int(end_year or 0) else None + voucher_filter = normalize_compare_voucher_filter(raw_voucher_filter, default_filter_year) + if not voucher_filter: + return { + "columns": DETAIL_COLUMN_MAP.get(normalized_status, []), + "rows": [], + "groups": [], + "total_count": 0, + "shown_count": 0, + "offset": 0, + "limit": limit, + "has_more": False, + "next_offset": 0, + "next_cursor": "", + "notice": "전표번호 조회는 연월일과 전표번호를 함께 입력해야 합니다. 예: 2025-01-10 00200", + "pending": False, + } draft_filter = clean(draft_no) wehago_account_filter = normalize_text(wehago_account) erp_account_filter = normalize_text(erp_account) @@ -19155,6 +29877,12 @@ def get_status_detail_rows( if normalized_status == "hanmac_unconnected": with engine.begin() as conn: _ensure_hanmac_unconnected_status_projection(conn, start_year, end_year) + hanmac_projection_signature = _active_status_projection_signature( + conn, + "hanmac_unconnected", + start_year, + end_year, + ) query_page = _load_hanmac_unconnected_compact_projection_page( conn, start_year, @@ -19183,6 +29911,8 @@ def get_status_detail_rows( "rows": shown_rows, "groups": shown_groups, "total_count": total_count, + "count_unit": "ERP 전표", + "projection_signature": hanmac_projection_signature, "shown_count": len(shown_groups), "offset": safe_offset, "limit": safe_limit, @@ -19258,6 +29988,42 @@ def get_status_detail_rows( ) def finalize_status_detail_payload(payload: dict[str, Any]) -> dict[str, Any]: + if isinstance(payload, dict): + if normalized_status in {"voucher_matched", "voucher_recheck"}: + try: + with engine.begin() as conn: + payload = _expand_status_payload_full_erp_vouchers( + conn, + payload, + normalized_status, + ) + except Exception: + pass + if normalized_status in WEHAGO_FINAL_STATUS_ORDER: + payload.setdefault("count_unit", "전표") + elif normalized_status == "hanmac_unconnected": + payload.setdefault("count_unit", "ERP 전표") + else: + payload.setdefault("count_unit", "전표그룹" if normalized_status in QUERY_VOUCHER_STATUS_KEYS else "건") + payload.setdefault("projection_signature", "") + if not clean(payload.get("projection_signature")) and normalized_status in {"hanmac_unconnected", *COMPACT_EXPORT_STATUS_KEYS, *WEHAGO_FINAL_STATUS_ORDER}: + try: + with engine.begin() as conn: + if normalized_status in WEHAGO_FINAL_STATUS_ORDER: + payload["projection_signature"] = _load_latest_query_projection_signature_for_range( + conn, + start_year, + end_year, + ) + else: + payload["projection_signature"] = _active_status_projection_signature( + conn, + normalized_status, + start_year, + end_year, + ) + except Exception: + payload["projection_signature"] = "" if ( engine is not None and normalized_status in QUERY_PAGE_CACHEABLE_STATUS_KEYS @@ -19287,9 +30053,9 @@ def get_status_detail_rows( 5.0 if payload.get("pending") else _STATUS_DETAIL_RESPONSE_CACHE_TTL_SEC, ) - if normalized_status in WEHAGO_FINAL_STATUS_ORDER: + if normalized_status == "voucher_recheck" and os.getenv("WEHAGO_RECHECK_DETAIL_USE_QUERY_GROUPS") == "1": with engine.begin() as conn: - final_query_page = _load_final_wehago_status_group_page( + query_page = _load_query_group_page( conn, start_year, end_year, @@ -19307,8 +30073,8 @@ def get_status_detail_rows( safe_limit, cursor=cursor, ) - if final_query_page is not None: - shown_groups, total_count, next_cursor = final_query_page + if query_page is not None: + shown_groups, total_count, next_cursor = query_page next_offset = safe_offset + len(shown_groups) shown_rows: list[dict[str, Any]] = [] for group in shown_groups: @@ -19327,6 +30093,61 @@ def get_status_detail_rows( "has_more": next_offset < total_count, "next_offset": next_offset, "next_cursor": next_cursor, + "notice": "Recheck 상세는 최종 상태 요약이 아니라 검토 후보 projection을 표시합니다.", + "pending": False, + "ready_years": [], + "pending_years": [], + "bank_payable_case_count": 0, + "boundary_excluded_count": 0, + }) + + if normalized_status in WEHAGO_FINAL_STATUS_ORDER: + with engine.begin() as conn: + status_projection_signature = _active_status_projection_signature( + conn, + normalized_status, + start_year, + end_year, + ) + compact_page = _load_compact_status_projection_page( + conn, + start_year, + end_year, + normalized_status, + voucher_filter, + draft_filter, + wehago_account_filter, + erp_account_filter, + clean(wehago_amount), + clean(erp_amount), + wehago_vendor_filter, + erp_vendor_filter, + desc_filter, + safe_offset, + safe_limit, + cursor=cursor, + ) + if compact_page is not None: + shown_groups, total_count, next_cursor = compact_page + next_offset = safe_offset + len(shown_groups) + shown_rows: list[dict[str, Any]] = [] + for group in shown_groups: + sanitized_rows = _sanitize_voucher_group_rows(list(group.get("rows", []) or [])) + group["rows"] = sanitized_rows + for row in sanitized_rows: + shown_rows.append(dict(row)) + return finalize_status_detail_payload({ + "columns": DETAIL_COLUMN_MAP[normalized_status], + "rows": shown_rows, + "groups": shown_groups, + "total_count": total_count, + "shown_count": len(shown_groups), + "offset": safe_offset, + "limit": safe_limit, + "has_more": next_offset < total_count, + "next_offset": next_offset, + "next_cursor": next_cursor, + "projection_signature": status_projection_signature, "notice": "", "pending": False, "ready_years": [], @@ -19337,7 +30158,6 @@ def get_status_detail_rows( if normalized_status in COMPACT_EXPORT_STATUS_KEYS: with engine.begin() as conn: - _ensure_export_compact_status_projection(conn, start_year, end_year, normalized_status) compact_page = _load_compact_status_projection_page( conn, start_year, @@ -19385,6 +30205,31 @@ def get_status_detail_rows( }) if normalized_status in QUERY_VOUCHER_STATUS_KEYS and normalized_status != "hanmac_unconnected" and not has_voucher_group_filters: + persisted_page_payload: dict[str, Any] | None = None + with engine.begin() as conn: + persisted_page_payload = _load_query_page_projection_cache( + conn, + start_year, + end_year, + normalized_status, + response_cache_key, + ) + if persisted_page_payload is None: + persisted_page_payload = _load_latest_query_page_projection_cache_any_signature( + conn, + start_year, + end_year, + normalized_status, + response_cache_key, + ) + if persisted_page_payload is not None: + persisted_page_payload.setdefault("notice", "") + persisted_page_payload.setdefault("pending", False) + persisted_page_payload.setdefault("ready_years", []) + persisted_page_payload.setdefault("pending_years", []) + persisted_page_payload.setdefault("next_cursor", "") + return finalize_status_detail_payload(persisted_page_payload) + direct_query_page = _fast_sqlite_query_group_page( engine, start_year, @@ -19424,32 +30269,6 @@ def get_status_detail_rows( }) if normalized_status in QUERY_VOUCHER_STATUS_KEYS and normalized_status != "hanmac_unconnected": - persisted_page_payload: dict[str, Any] | None = None - if not has_voucher_group_filters: - with engine.begin() as conn: - persisted_page_payload = _load_query_page_projection_cache( - conn, - start_year, - end_year, - normalized_status, - response_cache_key, - ) - if persisted_page_payload is None: - persisted_page_payload = _load_latest_query_page_projection_cache_any_signature( - conn, - start_year, - end_year, - normalized_status, - response_cache_key, - ) - if persisted_page_payload is not None: - persisted_page_payload.setdefault("notice", "") - persisted_page_payload.setdefault("pending", False) - persisted_page_payload.setdefault("ready_years", []) - persisted_page_payload.setdefault("pending_years", []) - persisted_page_payload.setdefault("next_cursor", "") - return finalize_status_detail_payload(persisted_page_payload) - if not has_voucher_group_filters: quick_sections = _get_cached_voucher_sections_by_range(engine, start_year, end_year) quick_payload = _build_voucher_status_detail_response_from_sections( @@ -19631,6 +30450,39 @@ def get_status_detail_rows( enqueue_query_projection_rebuild(engine, start_year, end_year) except Exception: pass + shown_groups, total_count, next_cursor = [], 0, "" + else: + shown_groups, total_count, next_cursor = query_page + if query_page is None and _allow_sync_detail_rebuild_fallback(): + with engine.begin() as conn: + fallback_sections = _build_voucher_sections_from_db( + conn, + start_year, + end_year, + ) + fallback_payload = _build_voucher_status_detail_response_from_sections( + fallback_sections, + normalized_status, + voucher_filter, + draft_filter, + wehago_account_filter, + erp_account_filter, + clean(wehago_amount), + clean(erp_amount), + wehago_vendor_filter, + erp_vendor_filter, + desc_filter, + safe_offset, + safe_limit, + ) + if int(fallback_payload.get("total_count", 0) or 0) > 0: + fallback_payload["notice"] = clean(fallback_payload.get("notice")) or "최신 로직으로 전표 상세를 재구성했습니다." + fallback_payload["pending"] = bool(pending_years) + fallback_payload["ready_years"] = ready_years + fallback_payload["pending_years"] = pending_years + return finalize_status_detail_payload(fallback_payload) + if _allow_stale_export_detail_fallback(): + with engine.begin() as conn: shown_groups, total_count, next_cursor = _fetch_cached_export_group_page( conn, start_year, @@ -19654,10 +30506,8 @@ def get_status_detail_rows( else None ), ) - else: - shown_groups, total_count, next_cursor = query_page needs_fallback = total_count == 0 or (total_count > 0 and not shown_groups) - if needs_fallback and not pending_years: + if needs_fallback and not pending_years and _allow_sync_detail_rebuild_fallback(): voucher_sections = _get_cached_voucher_sections_by_range(engine, start_year, end_year) fallback_payload = _build_voucher_status_detail_response_from_sections( voucher_sections, @@ -19702,7 +30552,7 @@ def get_status_detail_rows( fallback_payload["ready_years"] = ready_years fallback_payload["pending_years"] = pending_years return finalize_status_detail_payload(fallback_payload) - if needs_fallback and ready_years and normalized_status != "hanmac_unconnected": + if needs_fallback and ready_years and normalized_status != "hanmac_unconnected" and _allow_sync_detail_rebuild_fallback(): with engine.begin() as conn: fallback_sections = _build_voucher_sections_from_db( conn, @@ -19825,33 +30675,35 @@ def get_status_detail_rows( enqueue_query_projection_rebuild(engine, start_year, end_year) except Exception: pass - shown_groups, total_count, next_cursor = _fetch_cached_export_group_page( - conn, - start_year, - end_year, - normalized_status, - voucher_filter, - draft_filter, - wehago_account_filter, - erp_account_filter, - clean(wehago_amount), - clean(erp_amount), - wehago_vendor_filter, - erp_vendor_filter, - desc_filter, - safe_offset, - safe_limit, - cursor=cursor, - known_total_count=( - cached_metric_counts.get(normalized_status) - if not has_voucher_group_filters - else None - ), - ) + shown_groups, total_count, next_cursor = [], 0, "" + if _allow_stale_export_detail_fallback(): + shown_groups, total_count, next_cursor = _fetch_cached_export_group_page( + conn, + start_year, + end_year, + normalized_status, + voucher_filter, + draft_filter, + wehago_account_filter, + erp_account_filter, + clean(wehago_amount), + clean(erp_amount), + wehago_vendor_filter, + erp_vendor_filter, + desc_filter, + safe_offset, + safe_limit, + cursor=cursor, + known_total_count=( + cached_metric_counts.get(normalized_status) + if not has_voucher_group_filters + else None + ), + ) else: shown_groups, total_count, next_cursor = query_page needs_fallback = total_count == 0 or (total_count > 0 and not shown_groups) - if needs_fallback and not pending_years and normalized_status != "hanmac_unconnected": + if needs_fallback and not pending_years and normalized_status != "hanmac_unconnected" and _allow_sync_detail_rebuild_fallback(): voucher_sections = _get_cached_voucher_sections_by_range(engine, start_year, end_year) fallback_payload = _build_voucher_status_detail_response_from_sections( voucher_sections, @@ -19896,7 +30748,7 @@ def get_status_detail_rows( fallback_payload["ready_years"] = ready_years fallback_payload["pending_years"] = pending_years return finalize_status_detail_payload(fallback_payload) - if needs_fallback and ready_years: + if needs_fallback and ready_years and _allow_sync_detail_rebuild_fallback(): with engine.begin() as conn: fallback_sections = _build_voucher_sections_from_db( conn, @@ -20166,6 +31018,7 @@ def _build_status_export_job_key( wehago_vendor: str, erp_vendor: str, desc_keyword: str, + projection_signature: str = "", ) -> tuple[str, str]: export_key = _build_voucher_export_cache_key( start_year, @@ -20180,6 +31033,7 @@ def _build_status_export_job_key( wehago_vendor, erp_vendor, desc_keyword, + projection_signature, ) digest = hashlib.sha1(export_key.encode('utf-8')).hexdigest() return export_key, f"status_export_xlsx:{digest}" @@ -20517,6 +31371,13 @@ def request_status_export_xlsx( desc_keyword: str = '', ) -> dict[str, Any]: normalized_status = _normalize_voucher_export_status_key(status) + with engine.begin() as conn: + projection_signature = _status_export_projection_signature( + conn, + start_year, + end_year, + normalized_status, + ) export_key, job_key = _build_status_export_job_key( normalized_status, start_year, @@ -20530,6 +31391,7 @@ def request_status_export_xlsx( wehago_vendor, erp_vendor, desc_keyword, + projection_signature, ) init_wehago_compare_db(engine) payload = { @@ -20545,6 +31407,7 @@ def request_status_export_xlsx( 'wehago_vendor': wehago_vendor, 'erp_vendor': erp_vendor, 'desc_keyword': desc_keyword, + 'projection_signature': projection_signature, } def _read_existing_job(conn: Any) -> dict[str, Any] | None: @@ -20981,6 +31844,49 @@ def _iter_cached_export_rows( ) -> tuple[list[dict[str, Any]], int]: if start_year is None or end_year is None: return [], 0 + if normalized_status in COMPACT_EXPORT_STATUS_KEYS and normalized_status != "hanmac_unconnected": + compact_page = _load_compact_status_projection_page( + conn, + start_year, + end_year, + normalized_status, + voucher_no, + draft_no, + wehago_account, + erp_account, + wehago_amount, + erp_amount, + wehago_vendor, + erp_vendor, + desc_keyword, + 0, + 1_000_000, + ) + if compact_page is not None: + groups, total_count, _next_cursor = compact_page + export_rows: list[dict[str, Any]] = [] + for group_sort, group in enumerate(groups): + summary = dict(group.get("summary") or {}) + for row_sort, row in enumerate(_sanitize_voucher_group_rows(list(group.get("rows") or []))): + export_rows.append( + { + **dict(row), + "group_voucher_no": clean(summary.get("voucher_no")), + "group_draft_no": clean(summary.get("draft_no")), + "group_ledger_accounts": clean(summary.get("ledger_accounts")), + "group_voucher_accounts": clean(summary.get("voucher_accounts")), + "group_ledger_vendors": clean(summary.get("ledger_vendors")), + "group_voucher_vendors": clean(summary.get("voucher_vendors")), + "group_ledger_debit": parse_amount(summary.get("ledger_debit")), + "group_ledger_credit": parse_amount(summary.get("ledger_credit")), + "group_voucher_debit": parse_amount(summary.get("voucher_debit")), + "group_voucher_credit": parse_amount(summary.get("voucher_credit")), + "group_sort": group_sort, + "row_sort": row_sort, + } + ) + return export_rows, int(total_count or 0) + return [], 0 voucher_filter = clean(voucher_no) draft_filter = clean(draft_no) wehago_account_filter = normalize_text(wehago_account) @@ -21616,8 +32522,8 @@ def _store_hanmac_unconnected_compact_projection( "voucher_vendors": clean(summary.get("voucher_vendors")), "review_reason": clean(summary.get("review_reason")), "search_text": search_text, - "summary_json": json.dumps(summary, ensure_ascii=False, separators=(",", ":")), - "rows_json": json.dumps(rows, ensure_ascii=False, separators=(",", ":")), + "summary_json": json.dumps(_json_safe_projection_payload(summary), ensure_ascii=False, separators=(",", ":")), + "rows_json": json.dumps(_json_safe_projection_payload(rows), ensure_ascii=False, separators=(",", ":")), } ) insert_sql = text( @@ -21866,8 +32772,8 @@ def _store_export_compact_status_projection( "voucher_vendors": clean(summary.get("voucher_vendors")), "review_reason": clean(summary.get("review_reason")), "search_text": search_text, - "summary_json": json.dumps(summary, ensure_ascii=False, separators=(",", ":")), - "rows_json": json.dumps(rows, ensure_ascii=False, separators=(",", ":")), + "summary_json": json.dumps(_json_safe_projection_payload(summary), ensure_ascii=False, separators=(",", ":")), + "rows_json": json.dumps(_json_safe_projection_payload(rows), ensure_ascii=False, separators=(",", ":")), } ) insert_sql = text( @@ -22533,6 +33439,7 @@ def _build_voucher_export_cache_key( wehago_vendor: str, erp_vendor: str, desc_keyword: str, + projection_signature: str = "", ) -> str: return "|".join( [ @@ -22540,6 +33447,7 @@ def _build_voucher_export_cache_key( str(start_year or "all"), str(end_year or "all"), _build_bundle_signature(start_year, end_year) if start_year is not None and end_year is not None else "no-range", + clean(projection_signature) or "no-active-status-projection", normalize_text(voucher_no), normalize_text(draft_no), normalize_text(wehago_account), @@ -22553,6 +33461,20 @@ def _build_voucher_export_cache_key( ] ) + +def _status_export_projection_signature( + conn: Any, + start_year: int | None, + end_year: int | None, + normalized_status: str, +) -> str: + if start_year is None or end_year is None: + return "" + if normalized_status in COMPACT_EXPORT_STATUS_KEYS or normalized_status == "hanmac_unconnected": + return _active_status_projection_export_token(conn, normalized_status, start_year, end_year) + return _load_latest_query_projection_signature_for_range(conn, start_year, end_year) + + def _coerce_voucher_export_cell(field: str, raw: Any) -> Any: if field == "fiscal_year": return clean(raw) @@ -22656,6 +33578,13 @@ def export_wehago_status_rows_xlsx( desc_keyword: str = "", ) -> tuple[str, bytes, int]: normalized_status = _normalize_voucher_export_status_key(status) + with engine.begin() as conn: + projection_signature = _status_export_projection_signature( + conn, + start_year, + end_year, + normalized_status, + ) cache_key = _build_voucher_export_cache_key( start_year, end_year, @@ -22669,6 +33598,7 @@ def export_wehago_status_rows_xlsx( wehago_vendor, erp_vendor, desc_keyword, + projection_signature, ) cached = _STATUS_EXPORT_CACHE.get(cache_key) now = time.time() @@ -22695,7 +33625,8 @@ def export_wehago_status_rows_xlsx( workbook = Workbook(write_only=True) status_labels = {key: label for key, label, _ in STATUS_META} worksheet = workbook.create_sheet(title=status_labels.get(normalized_status, normalized_status)[:31]) - _apply_voucher_export_sheet_layout(worksheet, row_count) + physical_row_count = len(export_rows) + _apply_voucher_export_sheet_layout(worksheet, physical_row_count) _append_styled_voucher_export_header(worksheet) previous_group_key: tuple[Any, ...] | None = None for row in export_rows: @@ -23134,7 +34065,13 @@ def get_wehago_compare_dashboard( end_year, metric_counts, ) + if bool(final_status_summary.get("ready")): + pending = False + if clean(final_status_summary.get("source")) == "active_status_projection_missing": + pending = True metric_sections = apply_wehago_final_status_counts_to_metric_sections(metric_sections, final_status_summary) + with engine.begin() as conn: + metric_sections = annotate_metric_sections_with_active_projection_metadata(conn, metric_sections, start_year, end_year) erp_candidate_start_year, erp_candidate_end_year = get_hanmac_erp_candidate_year_range(start_year, end_year) return { @@ -23260,7 +34197,13 @@ def get_wehago_compare_summary( end_year, counts, ) + if bool(final_status_summary.get("ready")): + pending = False + if clean(final_status_summary.get("source")) == "active_status_projection_missing": + pending = True metric_sections = apply_wehago_final_status_counts_to_metric_sections(metric_sections, final_status_summary) + with engine.begin() as conn: + metric_sections = annotate_metric_sections_with_active_projection_metadata(conn, metric_sections, start_year, end_year) erp_candidate_start_year, erp_candidate_end_year = get_hanmac_erp_candidate_year_range(start_year, end_year) snapshot_aggregate = { "ready_count": len(snapshot_state.get("ready", [])), @@ -23373,7 +34316,13 @@ def get_wehago_compare_summary( end_year, metric_counts, ) + if bool(final_status_summary.get("ready")): + pending = False + if clean(final_status_summary.get("source")) == "active_status_projection_missing": + pending = True metric_sections = apply_wehago_final_status_counts_to_metric_sections(metric_sections, final_status_summary) + with engine.begin() as conn: + metric_sections = annotate_metric_sections_with_active_projection_metadata(conn, metric_sections, start_year, end_year) erp_candidate_start_year, erp_candidate_end_year = get_hanmac_erp_candidate_year_range(start_year, end_year) payload = { "selected_start_year": start_year,