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'' 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 %}
+
+
+
+
수익/비용 차이분석
+
+
+
+
+
+
+
+
+
+
핵심 브릿지
+
+
+
+
+
+ | 연도 |
+ wehago 매출 |
+ 매출조정 |
+ wehago 매출(조정전) |
+ 프로젝트손익분석 매출 |
+ 수익 차이 |
+ 수익 차이(조정전) |
+ wehago 비용 |
+ 프로젝트손익분석 비용 |
+ 비용 차이 |
+ wehago 손익 |
+ 프로젝트손익분석 손익 |
+ 손익 차이 |
+ 손익 차이(조정전) |
+
+
+
+ {% 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 %}
+
+ | {{ row.year }}년 |
+
+
+ |
+
+
+ |
+
+
+ |
+
+ {% if project_bridge %}
+
+ {% else %}-{% endif %}
+ |
+
+
+ |
+
+
+ |
+
+
+ |
+
+ {% if project_bridge %}
+
+ {% else %}-{% endif %}
+ |
+
+
+ |
+
+
+ |
+
+ {% if project_bridge %}
+
+ {% else %}-{% endif %}
+ |
+
+
+ |
+
+
+ |
+
+ {% endfor %}
+
+
+
+
+
+
+
+
+
+{% 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 @@
+
+
+
+ {% for menu in hanmac_wehago_audit_sources.menus %}
+
+ {% endfor %}
+
+
+
+ 검토 계정: {{ hanmac_wehago_audit_sources.account_codes|join(", ") }}
+
+
+
@@ -1459,46 +1651,107 @@
서버 접속 정보
+
+
+
+