Update wehago matching logic and exclude reports

This commit is contained in:
b17301
2026-07-03 09:07:04 +09:00
parent a09190ecd3
commit da073ad6ef
62 changed files with 36940 additions and 1554 deletions
+12
View File
@@ -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/
-169
View File
@@ -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.
-23
View File
@@ -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/`에 생성했습니다.
-50
View File
@@ -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` 같은 런타임 임시 파일은 커밋 대상에서 제외함
@@ -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.
@@ -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/`에 생성했습니다.
@@ -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` 같은 런타임 임시 파일은 커밋 대상에서 제외함
+10973 -524
View File
File diff suppressed because it is too large Load Diff
@@ -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())
@@ -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()
+372
View File
@@ -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()
@@ -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", "<br>") 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'<w:pPr><w:pStyle w:val="{style}"/></w:pPr>' if style else ""
return f"<w:p>{style_xml}<w:r><w:t>{escape(text)}</w:t></w:r></w:p>"
def docx_table(headers: list[str], rows: list[list[Any]]) -> str:
def cell(value: Any, bold: bool = False) -> str:
run_pr = "<w:rPr><w:b/></w:rPr>" if bold else ""
text = escape(clean(value))
return (
"<w:tc><w:tcPr><w:tcW w:w=\"2200\" w:type=\"dxa\"/></w:tcPr>"
f"<w:p><w:r>{run_pr}<w:t>{text}</w:t></w:r></w:p></w:tc>"
)
body = ["<w:tr>" + "".join(cell(header, True) for header in headers) + "</w:tr>"]
body.extend("<w:tr>" + "".join(cell(value) for value in row) + "</w:tr>" for row in rows)
return (
"<w:tbl><w:tblPr><w:tblW w:w=\"0\" w:type=\"auto\"/>"
"<w:tblBorders><w:top w:val=\"single\" w:sz=\"4\" w:space=\"0\" w:color=\"808080\"/>"
"<w:left w:val=\"single\" w:sz=\"4\" w:space=\"0\" w:color=\"808080\"/>"
"<w:bottom w:val=\"single\" w:sz=\"4\" w:space=\"0\" w:color=\"808080\"/>"
"<w:right w:val=\"single\" w:sz=\"4\" w:space=\"0\" w:color=\"808080\"/>"
"<w:insideH w:val=\"single\" w:sz=\"4\" w:space=\"0\" w:color=\"808080\"/>"
"<w:insideV w:val=\"single\" w:sz=\"4\" w:space=\"0\" w:color=\"808080\"/></w:tblBorders>"
"</w:tblPr>"
+ "".join(body)
+ "</w:tbl>"
)
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 = (
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
'<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">'
"<w:body>"
+ "".join(body)
+ '<w:sectPr><w:pgSz w:w="16838" w:h="11906" w:orient="landscape"/>'
'<w:pgMar w:top="900" w:right="700" w:bottom="900" w:left="700" w:header="720" w:footer="720" w:gutter="0"/>'
"</w:sectPr></w:body></w:document>"
)
styles_xml = (
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
'<w:styles xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">'
'<w:style w:type="paragraph" w:styleId="Title"><w:name w:val="Title"/>'
'<w:rPr><w:b/><w:sz w:val="32"/></w:rPr></w:style>'
'<w:style w:type="paragraph" w:styleId="Heading1"><w:name w:val="heading 1"/>'
'<w:rPr><w:b/><w:sz w:val="24"/></w:rPr></w:style>'
"</w:styles>"
)
content_types = (
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
'<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">'
'<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>'
'<Default Extension="xml" ContentType="application/xml"/>'
'<Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/>'
'<Override PartName="/word/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.styles+xml"/>'
"</Types>"
)
rels = (
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
'<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">'
'<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/>'
"</Relationships>"
)
doc_rels = (
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
'<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">'
'<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/>'
"</Relationships>"
)
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()
+688
View File
@@ -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'<w:pPr><w:pStyle w:val="{style}"/></w:pPr>' if style else ""
return f"<w:p>{style_xml}<w:r><w:t>{escape(text)}</w:t></w:r></w:p>"
def docx_table(headers: list[str], rows: list[list[str]]) -> str:
def cell(value: str, bold: bool = False) -> str:
run_pr = "<w:rPr><w:b/></w:rPr>" if bold else ""
return (
"<w:tc><w:tcPr><w:tcW w:w=\"2400\" w:type=\"dxa\"/></w:tcPr>"
f"<w:p><w:r>{run_pr}<w:t>{escape(str(value))}</w:t></w:r></w:p></w:tc>"
)
table_rows = ["<w:tr>" + "".join(cell(header, True) for header in headers) + "</w:tr>"]
for row in rows:
table_rows.append("<w:tr>" + "".join(cell(str(value)) for value in row) + "</w:tr>")
return (
"<w:tbl><w:tblPr><w:tblW w:w=\"0\" w:type=\"auto\"/>"
"<w:tblBorders><w:top w:val=\"single\" w:sz=\"4\" w:space=\"0\" w:color=\"808080\"/>"
"<w:left w:val=\"single\" w:sz=\"4\" w:space=\"0\" w:color=\"808080\"/>"
"<w:bottom w:val=\"single\" w:sz=\"4\" w:space=\"0\" w:color=\"808080\"/>"
"<w:right w:val=\"single\" w:sz=\"4\" w:space=\"0\" w:color=\"808080\"/>"
"<w:insideH w:val=\"single\" w:sz=\"4\" w:space=\"0\" w:color=\"808080\"/>"
"<w:insideV w:val=\"single\" w:sz=\"4\" w:space=\"0\" w:color=\"808080\"/></w:tblBorders>"
"</w:tblPr>"
+ "".join(table_rows)
+ "</w:tbl>"
)
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 = (
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
'<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">'
"<w:body>"
+ "".join(body)
+ '<w:sectPr><w:pgSz w:w="11906" w:h="16838"/>'
'<w:pgMar w:top="1440" w:right="1000" w:bottom="1440" w:left="1000" w:header="720" w:footer="720" w:gutter="0"/>'
"</w:sectPr></w:body></w:document>"
)
styles_xml = (
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
'<w:styles xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">'
'<w:style w:type="paragraph" w:styleId="Title"><w:name w:val="Title"/>'
'<w:rPr><w:b/><w:sz w:val="32"/></w:rPr></w:style>'
'<w:style w:type="paragraph" w:styleId="Heading1"><w:name w:val="heading 1"/>'
'<w:rPr><w:b/><w:sz w:val="26"/></w:rPr></w:style>'
'<w:style w:type="paragraph" w:styleId="Heading2"><w:name w:val="heading 2"/>'
'<w:rPr><w:b/><w:sz w:val="22"/></w:rPr></w:style>'
"</w:styles>"
)
content_types = (
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
'<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">'
'<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>'
'<Default Extension="xml" ContentType="application/xml"/>'
'<Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/>'
'<Override PartName="/word/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.styles+xml"/>'
"</Types>"
)
rels = (
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
'<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">'
'<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/>'
"</Relationships>"
)
doc_rels = (
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
'<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">'
'<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/>'
"</Relationships>"
)
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()
@@ -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()
@@ -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()
@@ -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()
@@ -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()
@@ -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()
@@ -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()
+37
View File
@@ -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()
+371
View File
@@ -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 = '<span class="red-marker"></span>' 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"""
<li class="{root_class}">
<span class="{name_class}">{escape(node.name)}</span>{marker}
{f'<ul>{child_html}</ul>' if child_html else ''}
</li>
"""
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(
"""
<div class="column">
<ul class="tree">
"""
+ "".join(render_node(node, root=True) for node in column)
+ """
</ul>
</div>
"""
)
return f"""<!doctype html>
<html lang="ko">
<head>
<meta charset="utf-8">
<style>
@font-face {{
font-family: "ReportKR";
src: url("file:///mnt/c/Windows/Fonts/NotoSansKR-VF.ttf") format("truetype");
font-weight: 100 900;
}}
* {{ box-sizing: border-box; }}
html, body {{
margin: 0;
width: 16.8cm;
height: 23.52cm;
background: #fff;
font-family: "ReportKR", "Malgun Gothic", Arial, sans-serif;
color: #111827;
}}
.page {{
width: 16.8cm;
height: 23.52cm;
padding: 0.26cm 0.3cm 0.24cm;
overflow: hidden;
background: #fff;
}}
.columns {{
display: grid;
grid-template-columns: 0.96fr 1.04fr 1.2fr;
gap: 7px;
align-items: start;
}}
.column {{
min-width: 0;
}}
ul.tree,
.tree ul {{
list-style: none;
margin: 0;
padding-left: 13px;
}}
ul.tree {{
padding-left: 0;
font-size: 6px;
line-height: 1.34;
}}
.tree ul {{
margin-left: 3px;
padding-left: 11px;
}}
.tree li {{
position: relative;
margin: 0;
padding: 0 0 1.7px 9px;
min-height: 8.45px;
white-space: nowrap;
}}
.tree li::after {{
content: "";
position: absolute;
left: 0;
top: -3px;
bottom: -3px;
border-left: 0.8px solid #a7b0bc;
}}
.tree li:last-child::after {{
bottom: calc(100% - 4px);
}}
.tree li::before {{
content: "";
position: absolute;
top: 4.6px;
left: 0;
width: 6.2px;
border-top: 0.8px solid #a7b0bc;
z-index: 1;
}}
.tree > li {{
padding-left: 0;
margin-bottom: 6px;
}}
.tree > li::after {{
display: none;
}}
.tree > li::before {{
display: none;
}}
.tree > li > .name {{
font-size: 7.4px;
font-weight: 850;
}}
.name {{
display: inline-block;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
vertical-align: top;
font-weight: 520;
position: relative;
z-index: 2;
background: #fff;
padding-left: 1.5px;
}}
.input-item {{
font-weight: 900;
color: #050b16;
}}
.red-marker {{
display: inline-block;
width: 1em;
height: 1em;
margin-left: 0.38em;
border: 0.18em solid #d71920;
border-radius: 50%;
vertical-align: -0.12em;
background: transparent;
box-sizing: border-box;
font-weight: 900;
}}
</style>
</head>
<body>
<main class="page">
<div class="columns">
{"".join(rendered_columns)}
</div>
</main>
</body>
</html>"""
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()
@@ -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()
@@ -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'<w:pStyle w:val="{style}"/>')
if page_break_before:
props.append("<w:pageBreakBefore/>")
ppr = f"<w:pPr>{''.join(props)}</w:pPr>" if props else ""
return f"<w:p>{ppr}<w:r><w:t>{escape(clean(text))}</w:t></w:r></w:p>"
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'<w:sz w:val="{font_size}"/>', '<w:szCs w:val="16"/>']
if bold:
run_props.append("<w:b/>")
shade = '<w:shd w:val="clear" w:color="auto" w:fill="DCE6F1"/>' if shaded else ""
return (
"<w:tc><w:tcPr>"
f'<w:tcW w:w="{width}" w:type="dxa"/>{shade}'
'<w:vAlign w:val="center"/>'
"</w:tcPr>"
'<w:p><w:pPr><w:spacing w:before="0" w:after="0" w:line="220" w:lineRule="auto"/></w:pPr>'
f"<w:r><w:rPr>{''.join(run_props)}</w:rPr><w:t>{escape(clean(value))}</w:t></w:r></w:p>"
"</w:tc>"
)
header_props = "<w:trPr><w:tblHeader/></w:trPr>" if repeat_header else ""
result_rows = [
f"<w:tr>{header_props}"
+ "".join(cell(header, width, bold=True, shaded=True) for header, width in zip(headers, widths))
+ "</w:tr>"
]
for row in rows:
padded = list(row) + [""] * (len(headers) - len(row))
result_rows.append(
"<w:tr>"
+ "".join(cell(value, width) for value, width in zip(padded[: len(headers)], widths))
+ "</w:tr>"
)
grid = "".join(f'<w:gridCol w:w="{width}"/>' for width in widths)
return (
"<w:tbl>"
"<w:tblPr>"
'<w:tblW w:w="0" w:type="auto"/>'
'<w:tblLayout w:type="fixed"/>'
"<w:tblBorders>"
'<w:top w:val="single" w:sz="4" w:space="0" w:color="8A98A8"/>'
'<w:left w:val="single" w:sz="4" w:space="0" w:color="8A98A8"/>'
'<w:bottom w:val="single" w:sz="4" w:space="0" w:color="8A98A8"/>'
'<w:right w:val="single" w:sz="4" w:space="0" w:color="8A98A8"/>'
'<w:insideH w:val="single" w:sz="3" w:space="0" w:color="B5C0CC"/>'
'<w:insideV w:val="single" w:sz="3" w:space="0" w:color="B5C0CC"/>'
"</w:tblBorders>"
'<w:tblCellMar><w:top w:w="60" w:type="dxa"/><w:left w:w="70" w:type="dxa"/>'
'<w:bottom w:w="60" w:type="dxa"/><w:right w:w="70" w:type="dxa"/></w:tblCellMar>'
"</w:tblPr>"
f"<w:tblGrid>{grid}</w:tblGrid>"
+ "".join(result_rows)
+ "</w:tbl>"
)
def write_docx(path: Path, body: list[str]) -> None:
document_xml = (
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
'<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">'
"<w:body>"
+ "".join(body)
+ '<w:sectPr><w:pgSz w:w="16838" w:h="11906" w:orient="landscape"/>'
'<w:pgMar w:top="650" w:right="500" w:bottom="650" w:left="500" '
'w:header="360" w:footer="360" w:gutter="0"/>'
"</w:sectPr></w:body></w:document>"
)
styles_xml = (
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
'<w:styles xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">'
'<w:docDefaults><w:rPrDefault><w:rPr><w:rFonts w:ascii="Malgun Gothic" '
'w:hAnsi="Malgun Gothic" w:eastAsia="Malgun Gothic"/><w:sz w:val="19"/>'
'</w:rPr></w:rPrDefault></w:docDefaults>'
'<w:style w:type="paragraph" w:styleId="Title"><w:name w:val="Title"/>'
'<w:pPr><w:spacing w:after="240"/></w:pPr><w:rPr><w:b/><w:sz w:val="34"/></w:rPr></w:style>'
'<w:style w:type="paragraph" w:styleId="Heading1"><w:name w:val="heading 1"/>'
'<w:pPr><w:spacing w:before="220" w:after="100"/></w:pPr>'
'<w:rPr><w:b/><w:sz w:val="27"/></w:rPr></w:style>'
'<w:style w:type="paragraph" w:styleId="Heading2"><w:name w:val="heading 2"/>'
'<w:pPr><w:spacing w:before="160" w:after="80"/></w:pPr>'
'<w:rPr><w:b/><w:sz w:val="23"/></w:rPr></w:style>'
"</w:styles>"
)
content_types = (
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
'<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">'
'<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>'
'<Default Extension="xml" ContentType="application/xml"/>'
'<Override PartName="/word/document.xml" '
'ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/>'
'<Override PartName="/word/styles.xml" '
'ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.styles+xml"/>'
"</Types>"
)
rels = (
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
'<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">'
'<Relationship Id="rId1" '
'Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" '
'Target="word/document.xml"/>'
"</Relationships>"
)
doc_rels = (
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
'<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">'
'<Relationship Id="rId1" '
'Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" '
'Target="styles.xml"/>'
"</Relationships>"
)
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()
@@ -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())
@@ -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())
+311
View File
@@ -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()
+152
View File
@@ -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())
+158
View File
@@ -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())
+162
View File
@@ -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, "<redacted>" 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: "<redacted>" 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())
@@ -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()
@@ -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()
@@ -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())
+3 -2
View File
@@ -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)
@@ -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()
+2 -1
View File
@@ -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}|%"),
@@ -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()
@@ -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()
@@ -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())
@@ -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 []
@@ -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())
+301
View File
@@ -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()
+383
View File
@@ -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())
+86
View File
@@ -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())
+92
View File
@@ -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())
@@ -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())
+276
View File
@@ -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"(?<!\d)(1[0-2]|0?[1-9])\s*월", text):
found.add(int(m.group(1)))
for m in re.finditer(r"(?<!\d)2025[-./](1[0-2]|0[1-9])(?:[-./]\d{1,2})?", text):
found.add(int(m.group(1)))
return found
def key_month(key: tuple[int, str, str]) -> 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()
+553
View File
@@ -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()
@@ -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()
@@ -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()
@@ -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()
+102
View File
@@ -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())
+32 -17
View File
@@ -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 메뉴에서 계정별원장 화면을 직접 열어주세요.")
+287
View File
@@ -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
File diff suppressed because it is too large Load Diff
+1
View File
@@ -251,6 +251,7 @@
<div class="section-title" style="margin-bottom: 14px;">
<h2>수익/비용 현황</h2>
<div class="summary-actions">
<a class="button-link button-secondary" href="/annual-summary/gap-analysis">차이분석</a>
<button type="button" class="button-secondary" id="annualRebuildCacheBtn">캐시 재계산</button>
<span class="page-job-status" id="annualJobStatus" data-state="">작업 상태 확인 전</span>
</div>
+659 -12
View File
@@ -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 @@
</button>
</div>
<div class="hanmac-wehago-audit">
<div class="hanmac-wehago-audit-title">
<strong>WEHAGO 검증자료</strong>
<a href="{{ hanmac_wehago_audit_sources.smarta_home_url }}" target="_blank" rel="noreferrer">SmartA</a>
</div>
<div class="hanmac-wehago-audit-list">
{% for menu in hanmac_wehago_audit_sources.menus %}
<div class="hanmac-wehago-audit-item">
<a href="{{ hanmac_wehago_audit_sources.smarta_home_url }}" target="_blank" rel="noreferrer">{{ menu.name }}{% if menu.program %} · {{ menu.program }}{% endif %}</a>
<span>{{ menu.basis }} · 메뉴 검색어: {{ menu.keyword }}</span>
</div>
{% endfor %}
</div>
<div class="hanmac-wehago-year-links" aria-label="계정별원장 연도별 바로가기">
{% for item in hanmac_wehago_audit_sources.ledger_urls %}
<a href="{{ item.url }}" target="_blank" rel="noreferrer">{{ item.year }}<br>{{ item.gisu }}기</a>
{% endfor %}
</div>
<div class="hanmac-panel-meta">
검토 계정: {{ hanmac_wehago_audit_sources.account_codes|join(", ") }}
</div>
</div>
<div class="hanmac-filter-bar">
<input id="hanmacTableSearch" type="search" placeholder="테이블 검색">
<div class="hanmac-filter-tabs">
@@ -1459,10 +1651,19 @@
<div class="section-title" style="margin-bottom: 0;">
<h2>서버 접속 정보</h2>
</div>
<div class="hanmac-connection-tabs" role="tablist" aria-label="서버 접속 정보 구분">
<button type="button" class="hanmac-connection-tab is-active" data-connection-tab="work" role="tab" aria-selected="true">근무DB</button>
<button type="button" class="hanmac-connection-tab" data-connection-tab="erp" role="tab" aria-selected="false">관리DB</button>
</div>
<button type="button" class="hanmac-close-button" id="hanmacCloseConfigButton" aria-label="닫기">×</button>
</div>
<form id="hanmacConnectionForm">
<section class="hanmac-connection-section" data-connection-panel="work">
<div class="hanmac-connection-section-title">
<strong>근무 DB 접속</strong>
<span>hanmac DB_external 조회와 집계에 사용하는 MySQL 계정입니다.</span>
</div>
<div class="hanmac-form-grid">
<div class="hanmac-field">
<label for="hanmac-host">서버 IP</label>
@@ -1493,12 +1694,64 @@
<input id="hanmac-engine" type="text" value="MySQL" readonly>
</div>
</div>
</section>
<section class="hanmac-connection-section is-erp" data-connection-panel="erp" hidden>
<div class="hanmac-connection-section-title">
<strong>관리DB / 관리 ERP 접속</strong>
<span>입력한 아이디와 비밀번호는 현재 브라우저 탭이 열려 있는 동안만 저장됩니다.</span>
</div>
<div class="hanmac-erp-server-note">
웹 주소: http://erp.hanmaceng.co.kr/planning_mng/<br>
MySQL 직접 경로: erp.hanmaceng.co.kr:3306 · 서버: MySQL 5.1.41-community-log
</div>
<div class="hanmac-form-grid">
<div class="hanmac-field">
<label for="hanmac-erp-host">관리DB 서버</label>
<input id="hanmac-erp-host" type="text" value="erp.hanmaceng.co.kr" readonly>
</div>
<div class="hanmac-field">
<label for="hanmac-erp-port">MySQL 포트</label>
<input id="hanmac-erp-port" type="text" value="3306" readonly>
</div>
<div class="hanmac-field">
<label for="hanmac-erp-engine">DB 서버</label>
<input id="hanmac-erp-engine" type="text" value="MySQL 5.1.41-community-log" readonly>
</div>
<div class="hanmac-field">
<label for="hanmac-erp-user">관리 ERP 아이디</label>
<input id="hanmac-erp-user" name="erp_user" type="text" value="" autocomplete="username" placeholder="관리 ERP 아이디 입력">
</div>
<div class="hanmac-field">
<label for="hanmac-erp-password">관리 ERP 비밀번호</label>
<input id="hanmac-erp-password" name="erp_password" type="password" value="" autocomplete="current-password" placeholder="관리 ERP 비밀번호 입력">
</div>
<div class="hanmac-field">
<label for="hanmac-erp-db-user">관리DB MySQL 아이디</label>
<input id="hanmac-erp-db-user" name="erp_db_user" type="text" value="" autocomplete="off" placeholder="DB 계정이 ERP 계정과 다르면 입력">
</div>
<div class="hanmac-field">
<label for="hanmac-erp-db-password">관리DB MySQL 비밀번호</label>
<input id="hanmac-erp-db-password" name="erp_db_password" type="password" value="" autocomplete="off" placeholder="MySQL 직접 접속 비밀번호 입력">
</div>
</div>
</section>
</form>
<div class="hanmac-modal-actions">
<div class="hanmac-connection-actions" data-connection-actions="work">
<button type="button" class="hanmac-button secondary" id="hanmacTestConnectionButton">연결 확인</button>
<button type="button" class="hanmac-button" id="hanmacLoadTablesButton">테이블 목록 보기</button>
<button type="button" class="hanmac-button secondary" id="hanmacLoadGradeCodesButton">직급 코드 조회</button>
</div>
<div class="hanmac-connection-actions" data-connection-actions="erp" hidden>
<button type="button" class="hanmac-button secondary" id="hanmacTestErpButton">관리 ERP·MySQL 확인</button>
<button type="button" class="hanmac-button" id="hanmacDiscoverSatisBudgetButton">Satis 예산 후보 탐색</button>
<button type="button" class="hanmac-button" id="hanmacSyncSatisBudgetButton">Satis 예산 금액 저장</button>
<button type="button" class="hanmac-button" id="hanmacNormalizeSatisBudgetButton">Satis 예산 정규화</button>
<button type="button" class="hanmac-button" id="hanmacProjectSatisBudgetButton">승인 최신 차수 반영</button>
<button type="button" class="hanmac-button" id="hanmacRunSatisBudgetFullSyncButton">Satis 예산 전체 실행</button>
<button type="button" class="hanmac-button" id="hanmacCollectSatisBudgetWebButton">Satis 웹로그인 예산 수집</button>
</div>
<div class="hanmac-inline-status" id="hanmacConnectionStatus"></div>
</div>
<div class="hanmac-grade-panel" id="hanmacGradeCodesPanel"></div>
@@ -1622,9 +1875,19 @@
const closeConfigButton = document.getElementById("hanmacCloseConfigButton");
const statusBox = document.getElementById("hanmacConnectionStatus");
const testButton = document.getElementById("hanmacTestConnectionButton");
const testErpButton = document.getElementById("hanmacTestErpButton");
const discoverSatisBudgetButton = document.getElementById("hanmacDiscoverSatisBudgetButton");
const syncSatisBudgetButton = document.getElementById("hanmacSyncSatisBudgetButton");
const normalizeSatisBudgetButton = document.getElementById("hanmacNormalizeSatisBudgetButton");
const projectSatisBudgetButton = document.getElementById("hanmacProjectSatisBudgetButton");
const runSatisBudgetFullSyncButton = document.getElementById("hanmacRunSatisBudgetFullSyncButton");
const collectSatisBudgetWebButton = document.getElementById("hanmacCollectSatisBudgetWebButton");
const loadTablesButton = document.getElementById("hanmacLoadTablesButton");
const loadGradeCodesButton = document.getElementById("hanmacLoadGradeCodesButton");
const gradeCodesPanel = document.getElementById("hanmacGradeCodesPanel");
const connectionTabs = Array.from(document.querySelectorAll("[data-connection-tab]"));
const connectionPanels = Array.from(document.querySelectorAll("[data-connection-panel]"));
const connectionActions = Array.from(document.querySelectorAll("[data-connection-actions]"));
const tableListMeta = document.getElementById("hanmacTableListMeta");
const tableList = document.getElementById("hanmacTableList");
const tableSearch = document.getElementById("hanmacTableSearch");
@@ -1734,6 +1997,7 @@
let aggregateLoadPromise = null;
let aggregateRequestSeq = 0;
const credentialStorageKey = "hanmac-db-external-credentials-v1";
const erpCredentialStorageKey = "hanmac-management-erp-session-credentials-v1";
const aggregateStateStorageKey = "hanmac-db-external-aggregate-state-v1";
const jointMembersFallbackUrl = "/static/hanmac-joint-members-cache.json";
@@ -1780,6 +2044,23 @@
DeptCode: "부서코드",
};
const selectConnectionTab = (tabName) => {
const selectedTab = tabName === "erp" ? "erp" : "work";
connectionTabs.forEach((button) => {
const active = button.dataset.connectionTab === selectedTab;
button.classList.toggle("is-active", active);
button.setAttribute("aria-selected", active ? "true" : "false");
});
connectionPanels.forEach((panel) => {
panel.hidden = panel.dataset.connectionPanel !== selectedTab;
});
connectionActions.forEach((actions) => {
actions.hidden = actions.dataset.connectionActions !== selectedTab;
});
gradeCodesPanel.classList.toggle("is-open", selectedTab === "work" && gradeCodesPanel.innerHTML.trim() !== "");
setStatus("");
};
const openModal = () => {
modal.hidden = false;
};
@@ -1848,6 +2129,39 @@
};
};
const getErpPayload = () => {
const formData = new FormData(form);
return {
erp_user: String(formData.get("erp_user") || "").trim(),
erp_password: String(formData.get("erp_password") || ""),
erp_db_user: String(formData.get("erp_db_user") || "").trim(),
erp_db_password: String(formData.get("erp_db_password") || ""),
};
};
const saveErpCredentials = () => {
try {
window.sessionStorage.setItem(erpCredentialStorageKey, JSON.stringify(getErpPayload()));
} catch (_error) {
// Keep the form usable when session storage is unavailable.
}
};
const restoreErpCredentials = () => {
try {
const raw = window.sessionStorage.getItem(erpCredentialStorageKey);
if (!raw) return false;
const saved = JSON.parse(raw);
["erp_user", "erp_password", "erp_db_user", "erp_db_password"].forEach((key) => {
const field = form.elements.namedItem(key);
if (field && key in saved) field.value = String(saved[key] || "");
});
return Boolean(saved.erp_user || saved.erp_password || saved.erp_db_user || saved.erp_db_password);
} catch (_error) {
return false;
}
};
const fetchJson = async (url, options = {}) => {
const response = await fetch(url, {
...options,
@@ -2289,17 +2603,21 @@
"project_count",
]);
const aggregateDetailColumns = new Set([
"population_review",
"regular_hours",
"overtime_hours",
"total_hours",
"legal_leave_days",
"missing_regular_days",
"project_count",
]);
const aggregateDetailLabels = {
population_review: "소속검토",
regular_hours: "정규근로",
overtime_hours: "연장근로",
total_hours: "총근로",
legal_leave_days: "법정휴가",
missing_regular_days: "정규근로 부족",
project_count: "프로젝트수",
};
@@ -2468,7 +2786,11 @@
const getEquivalentProjectTitle = (row) => {
const equivalentCodes = Array.isArray(row?.equivalent_project_codes) ? row.equivalent_project_codes.filter(Boolean) : [];
return equivalentCodes.length ? equivalentCodes.join(", ") : "";
const sourceCodes = Array.isArray(row?.source_project_codes) ? row.source_project_codes.filter(Boolean) : [];
const parts = [];
if (sourceCodes.length) parts.push(`원천코드: ${sourceCodes.join(", ")}`);
if (equivalentCodes.length) parts.push(`연결코드: ${equivalentCodes.join(", ")}`);
return parts.join(" / ");
};
const renderProjectCodeText = (row, fallbackKey = "project_code") => {
@@ -2486,9 +2808,27 @@
const renderAggregateMetricCell = (row, column, rowIndex, view) => {
if (view === "member" && column.key === "remarks") {
const duplicateDays = Number(row.multi_entry_days || 0);
const detailButton = duplicateDays > 0
? `<button type="button" class="hanmac-aggregate-detail-link" data-aggregate-duplicate-index="${rowIndex}">중복 ${duplicateDays.toLocaleString("ko-KR")}일</button>`
const overlapCounts = row.overlap_type_counts && typeof row.overlap_type_counts === "object"
? Object.entries(row.overlap_type_counts).filter(([, count]) => Number(count || 0) > 0)
: [];
const overlapLabel = overlapCounts.length
? overlapCounts.map(([label, count]) => `${label} ${Number(count).toLocaleString("ko-KR")}일`).join(" · ")
: `검토 필요 ${duplicateDays.toLocaleString("ko-KR")}일`;
const duplicateButton = duplicateDays > 0
? `<button type="button" class="hanmac-aggregate-detail-link" data-aggregate-duplicate-index="${rowIndex}">${escapeHtml(overlapLabel)}</button>`
: "";
const missingDays = Number(row.missing_regular_days || 0);
const missingHours = Number(row.missing_regular_hours || 0);
const missingButton = missingHours > 0
? `<button type="button" class="hanmac-aggregate-detail-link" data-aggregate-detail-index="${rowIndex}" data-aggregate-detail-key="missing_regular_days">정규근로 ${formatNumberText(missingDays)}일 부족 (${formatNumberText(missingHours)}시간)</button>`
: "";
return `<td>${[missingButton, duplicateButton].filter(Boolean).join(" · ")}</td>`;
}
if (view === "member" && column.key === "population_review") {
const label = String(row.population_review || "");
const detailButton = label && canOpenAggregateDetail(row, column.key)
? `<button type="button" class="hanmac-aggregate-detail-link" data-aggregate-detail-index="${rowIndex}" data-aggregate-detail-key="population_review">${escapeHtml(label)}</button>`
: escapeHtml(label);
return `<td>${detailButton}</td>`;
}
const isNumeric = aggregateNumericColumns.has(column.key);
@@ -2703,15 +3043,16 @@
const openMultiEntryModal = (row) => {
const details = Array.isArray(row?.multi_entry_details) ? row.multi_entry_details : [];
multiEntryTitle.textContent = `${row?.member_name || row?.member_no || "사원"} 중복 근무 상세`;
multiEntryMeta.textContent = `${row?.member_no || ""} · ${Number(row?.multi_entry_days || details.length || 0).toLocaleString("ko-KR")}일 중복`;
multiEntryTitle.textContent = `${row?.member_name || row?.member_no || "사원"} 근무기록 검토 상세`;
multiEntryMeta.textContent = `${row?.member_no || ""} · ${Number(row?.multi_entry_days || details.length || 0).toLocaleString("ko-KR")}일 검토 필요`;
if (!details.length) {
multiEntryBody.innerHTML = `<div class="hanmac-preview-empty">중복 상세가 없습니다.</div>`;
multiEntryBody.innerHTML = `<div class="hanmac-preview-empty">검토할 근무기록이 없습니다.</div>`;
} else {
multiEntryBody.innerHTML = details.map((detail) => `
<section class="hanmac-detail-day">
<div class="hanmac-detail-day-head">
<strong class="hanmac-detail-day-title">${escapeHtml(detail.work_date || "-")}</strong>
<span class="hanmac-detail-badge">${escapeHtml(detail.overlap_type || "근무기록 검토")}</span>
<span class="hanmac-detail-badge">원본근로 ${formatNumberText(detail.raw_total_hours)}시간</span>
<span class="hanmac-detail-badge">집계반영 ${formatNumberText(detail.capped_regular_hours)}시간</span>
<span class="hanmac-detail-badge">${Number(detail.row_count || 0).toLocaleString("ko-KR")}행</span>
@@ -2735,6 +3076,14 @@
<td>${Number(entry.row_count || 0) > 1 ? `원본 ${Number(entry.row_count).toLocaleString("ko-KR")}행` : ""}</td>
</tr>
`).join("")}
${(Array.isArray(detail.activity_evidence) ? detail.activity_evidence : []).map((entry) => `
<tr>
<td>${renderProjectCodeText(entry)}</td>
<td>${escapeHtml(entry.project_name || entry.project_code || "공통/미지정")}</td>
<td>-</td>
<td>${escapeHtml([entry.source_label || "활동 근거", entry.note || ""].filter(Boolean).join(" · "))}</td>
</tr>
`).join("")}
</tbody>
</table>
</section>
@@ -2787,7 +3136,7 @@
if (currentAggregateView !== "project" && Array.isArray(detail.projects) && detail.projects.length) {
return detail.projects.map((project) => ({
workDate: detail.work_date,
project: `${sourcePrefix(project.source_label)}${project.project_name || project.project_code || "(미지정)"}${project.project_code ? ` (${project.project_code})` : ""}`,
project: `${sourcePrefix(project.source_label)}${project.project_name || project.project_code || "(미지정)"}${project.project_code ? ` (${project.project_code})` : ""}${Array.isArray(project.source_project_codes) && project.source_project_codes.length ? ` · 원천 ${project.source_project_codes.join(", ")}` : ""}`,
recognizedHours: project.recognized_hours ?? (detail.projects.length === 1 ? detail.regular_hours : 0),
actualHours: project.hours,
}));
@@ -2824,6 +3173,60 @@
});
const renderAggregateDetailRows = (detailKey, details) => {
if (detailKey === "population_review") {
return `
<table class="hanmac-detail-table">
<thead>
<tr>
<th>판정</th>
<th>사유</th>
<th>Company</th>
<th>WorkCompany</th>
<th>부서</th>
</tr>
</thead>
<tbody>
${details.map((detail) => `
<tr>
<td>${escapeHtml(detail.decision || "소속검토필요")}</td>
<td>${escapeHtml(detail.reason || "")}</td>
<td>${escapeHtml(detail.company || "")}</td>
<td>${escapeHtml(detail.work_company || "")}</td>
<td>${escapeHtml(detail.dept_name || "")}</td>
</tr>
`).join("")}
</tbody>
</table>
`;
}
if (detailKey === "missing_regular_days") {
return `
<table class="hanmac-detail-table">
<thead>
<tr>
<th>일자</th>
<th class="is-numeric">근무의무</th>
<th class="is-numeric">휴가·시차</th>
<th class="is-numeric">인정근로</th>
<th class="is-numeric">부족</th>
<th>판정</th>
</tr>
</thead>
<tbody>
${details.map((detail) => `
<tr>
<td>${escapeHtml(detail.work_date || "-")}</td>
<td class="is-numeric">${formatNumberText(detail.expected_hours)}</td>
<td class="is-numeric">${formatNumberText(detail.leave_hours)}</td>
<td class="is-numeric">${formatNumberText(detail.recognized_hours)}</td>
<td class="is-numeric">${formatNumberText(detail.missing_hours)}</td>
<td>${escapeHtml(detail.reason || "")}</td>
</tr>
`).join("")}
</tbody>
</table>
`;
}
if (detailKey === "project_count") {
return `
<table class="hanmac-detail-table">
@@ -2983,8 +3386,9 @@
aggregateLeaveDays.textContent = Number(summary.legal_leave_hours || 0).toLocaleString("ko-KR");
aggregateProjectCount.textContent = Number(summary.project_count || 0).toLocaleString("ko-KR");
const diagnostics = payload.source_diagnostics || {};
const logicVersionText = diagnostics.logic_version ? ` · 로직 ${diagnostics.logic_version}` : "";
const diagnosticText = diagnostics
? ` · 공식연장 ${Number(diagnostics.official_overtime_rows || 0).toLocaleString("ko-KR")}행 · addwork ${Number(diagnostics.addwork_parsed_rows || 0).toLocaleString("ko-KR")}/${Number(diagnostics.addwork_source_rows || 0).toLocaleString("ko-KR")}행 · 합사 ${Number(diagnostics.joint_assignment_records || 0).toLocaleString("ko-KR")}건(코드 ${Number(diagnostics.joint_assignment_code_matched_rows || 0).toLocaleString("ko-KR")}, 문구 ${Number(diagnostics.joint_assignment_text_matched_rows || 0).toLocaleString("ko-KR")})/${Number(diagnostics.joint_assignment_regular_rows || 0).toLocaleString("ko-KR")}일 · 합사연장 ${Number(diagnostics.joint_assignment_overtime_rows || 0).toLocaleString("ko-KR")}일 · 사번통합 ${Number(diagnostics.canonical_member_no_rows || 0).toLocaleString("ko-KR")}행 · 임계 제외 ${Number((diagnostics.addwork_weekday_threshold_filtered_rows || 0) + (diagnostics.addwork_holiday_threshold_filtered_rows || 0)).toLocaleString("ko-KR")}행 · 휴가 ${Number(diagnostics.leave_matched_rows || 0).toLocaleString("ko-KR")}/${Number(diagnostics.tardy_candidate_rows || 0).toLocaleString("ko-KR")}행 · 탄력 제외 ${Number(diagnostics.leave_flexible_work_excluded_rows || 0).toLocaleString("ko-KR")}행 · 부서 연결 ${Number(diagnostics.dept_mapped_rows || 0).toLocaleString("ko-KR")}명 · 바론 제외 ${Number(diagnostics.center_excluded_member_count || 0).toLocaleString("ko-KR")}명 (동일사번 ${Number(diagnostics.center_same_member_hidden_count || 0).toLocaleString("ko-KR")}명)`
? `${logicVersionText} · 공식연장 ${Number(diagnostics.official_overtime_rows || 0).toLocaleString("ko-KR")}행 · addwork ${Number(diagnostics.addwork_parsed_rows || 0).toLocaleString("ko-KR")}/${Number(diagnostics.addwork_source_rows || 0).toLocaleString("ko-KR")}행 · 합사 ${Number(diagnostics.joint_assignment_records || 0).toLocaleString("ko-KR")}건(코드 ${Number(diagnostics.joint_assignment_code_matched_rows || 0).toLocaleString("ko-KR")}, 문구 ${Number(diagnostics.joint_assignment_text_matched_rows || 0).toLocaleString("ko-KR")})/${Number(diagnostics.joint_assignment_regular_rows || 0).toLocaleString("ko-KR")}일 · 합사연장 ${Number(diagnostics.joint_assignment_overtime_rows || 0).toLocaleString("ko-KR")}일 · 사번통합 ${Number(diagnostics.canonical_member_no_rows || 0).toLocaleString("ko-KR")}행 · 임계 제외 ${Number((diagnostics.addwork_weekday_threshold_filtered_rows || 0) + (diagnostics.addwork_holiday_threshold_filtered_rows || 0)).toLocaleString("ko-KR")}행 · 휴가 ${Number(diagnostics.leave_matched_rows || 0).toLocaleString("ko-KR")}/${Number(diagnostics.tardy_candidate_rows || 0).toLocaleString("ko-KR")}행 · 탄력 제외 ${Number(diagnostics.leave_flexible_work_excluded_rows || 0).toLocaleString("ko-KR")}행 · 부서 연결 ${Number(diagnostics.dept_mapped_rows || 0).toLocaleString("ko-KR")}명 · 계열사스키마 ${Number(diagnostics.affiliate_schema_count || 0).toLocaleString("ko-KR")}개 · 계열사/소속 제외 ${Number((diagnostics.population_owner_excluded_member_count || 0) + (diagnostics.affiliate_actual_work_excluded_count || diagnostics.center_excluded_member_count || 0)).toLocaleString("ko-KR")}명 · 소속코드 검토 ${Number(diagnostics.company_review_member_count || 0).toLocaleString("ko-KR")}명 · 근무기록 없음 검토 ${Number(diagnostics.no_hanmac_work_review_member_count || 0).toLocaleString("ko-KR")}명 · 시스템 제외 ${Number(diagnostics.system_member_filtered_rows || 0).toLocaleString("ko-KR")}건`
: "";
aggregateMeta.textContent = `${payload.start_date || ""} ~ ${payload.end_date || ""}${diagnosticText}`;
if (currentAggregateSort.key && !columns.some((column) => column.key === currentAggregateSort.key)) {
@@ -3287,6 +3691,12 @@
openConfigButton.addEventListener("click", openModal);
closeConfigButton.addEventListener("click", closeModal);
connectionTabs.forEach((button) => {
button.addEventListener("click", () => selectConnectionTab(button.dataset.connectionTab || "work"));
});
["erp_user", "erp_password", "erp_db_user", "erp_db_password"].forEach((name) => {
form.elements.namedItem(name)?.addEventListener("input", saveErpCredentials);
});
modal.addEventListener("click", (event) => {
if (event.target === modal) closeModal();
});
@@ -3390,6 +3800,241 @@
}
});
testErpButton.addEventListener("click", async () => {
testErpButton.disabled = true;
setStatus("관리 ERP 로그인과 내부 접근 범위를 확인하고 있습니다...");
try {
saveErpCredentials();
const payload = await fetchJson("/hanmac-browser/api/test-management-erp", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(getErpPayload()),
});
setStatus(
`${payload.message} 내부 링크 ${Number(payload.internal_link_count || 0).toLocaleString("ko-KR")}개 확인 · ${payload.direct_db_message || "DB 직접 접속정보 미확인"} · ${payload.transport_warning || ""}`,
"success",
);
} catch (error) {
setStatus(error.message || "관리 ERP 접근 확인 중 오류가 발생했습니다.", "error");
} finally {
testErpButton.disabled = false;
}
});
discoverSatisBudgetButton.addEventListener("click", async () => {
discoverSatisBudgetButton.disabled = true;
setStatus("Satis 예산 후보 DB·테이블을 탐색하고 로컬 이력 저장소를 준비하고 있습니다...");
try {
saveErpCredentials();
const payload = await fetchJson("/hanmac-browser/api/discover-satis-budget", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(getErpPayload()),
});
const dbCount = Number(payload.direct_db_database_count || 0).toLocaleString("ko-KR");
const tableCount = Number(payload.candidate_table_count || 0).toLocaleString("ko-KR");
const firstTables = Array.isArray(payload.candidate_tables)
? payload.candidate_tables.slice(0, 3).map((row) => `${row.database}.${row.table}`).join(", ")
: "";
const preparedTables = Array.isArray(payload.prepared_local_tables)
? payload.prepared_local_tables.length
: 0;
setStatus(
`${payload.message} DB ${dbCount}개 · 후보 테이블 ${tableCount}개 · 로컬 이력 테이블 ${preparedTables}개 준비${firstTables ? ` · 예: ${firstTables}` : ""}`,
payload.direct_db_access || payload.web_access ? "success" : "error",
);
if (payload.transport_warning) {
console.warn(payload.transport_warning);
}
if (payload.candidate_tables?.length) {
console.table(payload.candidate_tables.slice(0, 20));
}
} catch (error) {
setStatus(error.message || "Satis 예산 후보 탐색 중 오류가 발생했습니다.", "error");
} finally {
discoverSatisBudgetButton.disabled = false;
}
});
syncSatisBudgetButton.addEventListener("click", async () => {
syncSatisBudgetButton.disabled = true;
setStatus("Satis 후보 테이블에서 실제 예산 금액 행을 읽어 원본 저장소에 저장하고 있습니다...");
try {
saveErpCredentials();
const payload = await fetchJson("/hanmac-browser/api/sync-satis-budget-raw", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
...getErpPayload(),
max_tables: 12,
row_limit: 500,
}),
});
const rowCount = Number(payload.inserted_or_updated_rows || 0).toLocaleString("ko-KR");
const tableCount = Array.isArray(payload.table_results)
? payload.table_results.filter((row) => row.status === "synced").length
: 0;
const firstTables = Array.isArray(payload.table_results)
? payload.table_results
.filter((row) => row.status === "synced" && Number(row.row_count || 0) > 0)
.slice(0, 3)
.map((row) => `${row.database}.${row.table} ${Number(row.row_count || 0).toLocaleString("ko-KR")}건`)
.join(", ")
: "";
setStatus(
`${payload.message} 저장 테이블: ${payload.raw_table || "satis_project_budget_raw_rows"} · 처리 테이블 ${tableCount.toLocaleString("ko-KR")}개${firstTables ? ` · 예: ${firstTables}` : ""}`,
Number(payload.inserted_or_updated_rows || 0) > 0 ? "success" : "error",
);
if (payload.table_results?.length) {
console.table(payload.table_results);
}
} catch (error) {
setStatus(error.message || "Satis 예산 금액 저장 중 오류가 발생했습니다.", "error");
} finally {
syncSatisBudgetButton.disabled = false;
}
});
normalizeSatisBudgetButton.addEventListener("click", async () => {
normalizeSatisBudgetButton.disabled = true;
setStatus("저장된 Satis 원본 금액을 차수/상세 예산 테이블로 정규화하고 있습니다...");
try {
const payload = await fetchJson("/hanmac-browser/api/normalize-satis-budget", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({}),
});
const revisionCount = Number(payload.normalized_revisions || 0).toLocaleString("ko-KR");
const taskLineCount = Number(payload.task_lines || 0).toLocaleString("ko-KR");
const execLineCount = Number(payload.exec_lines || 0).toLocaleString("ko-KR");
const firstSummary = Array.isArray(payload.summary)
? payload.summary
.slice(0, 3)
.map((row) => `${row.budget_type}: ${Number(row.revision_count || 0).toLocaleString("ko-KR")}차수`)
.join(", ")
: "";
setStatus(
`${payload.message} 차수 ${revisionCount}건 · 과업수행계획 ${taskLineCount}행 · 실행예산 ${execLineCount}행${firstSummary ? ` · ${firstSummary}` : ""}`,
Number(payload.normalized_revisions || 0) > 0 ? "success" : "error",
);
if (payload.summary?.length) {
console.table(payload.summary);
}
if (payload.source_tables?.length) {
console.table(payload.source_tables);
}
} catch (error) {
setStatus(error.message || "Satis 예산 정규화 중 오류가 발생했습니다.", "error");
} finally {
normalizeSatisBudgetButton.disabled = false;
}
});
projectSatisBudgetButton.addEventListener("click", async () => {
projectSatisBudgetButton.disabled = true;
setStatus("프로젝트별 승인 최신 차수를 기존 예산 입력 테이블에 반영하고 있습니다. 승인 중 최신 차수는 가반영합니다...");
try {
const payload = await fetchJson("/hanmac-browser/api/project-satis-budget-current", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ include_pending: true }),
});
const projectCount = Number(payload.project_count || 0).toLocaleString("ko-KR");
const taskRows = Number(payload.task_rows_inserted || 0).toLocaleString("ko-KR");
const execRows = Number(payload.exec_rows_inserted || 0).toLocaleString("ko-KR");
const provisionalCount = Number(payload.provisional_count || 0).toLocaleString("ko-KR");
const firstProjected = Array.isArray(payload.projected)
? payload.projected
.slice(0, 3)
.map((row) => `${row.support_dept_code}/${row.budget_type}/${row.projection_mode}`)
.join(", ")
: "";
setStatus(
`${payload.message} 과업수행계획 ${taskRows}행 · 실행예산 ${execRows}행 · 가반영 ${provisionalCount}건${firstProjected ? ` · 예: ${firstProjected}` : ""}`,
Number(payload.project_count || 0) > 0 ? "success" : "error",
);
if (payload.projected?.length) {
console.table(payload.projected);
}
if (payload.skipped?.length) {
console.table(payload.skipped);
}
} catch (error) {
setStatus(error.message || "Satis 예산 기존 입력 테이블 반영 중 오류가 발생했습니다.", "error");
} finally {
projectSatisBudgetButton.disabled = false;
}
});
runSatisBudgetFullSyncButton.addEventListener("click", async () => {
runSatisBudgetFullSyncButton.disabled = true;
setStatus("Satis 예산 전체 실행 중입니다. 원본 저장 → 정규화 → 승인 최신 차수 반영을 순차 처리합니다...");
try {
saveErpCredentials();
const payload = await fetchJson("/hanmac-browser/api/run-satis-budget-full-sync", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
...getErpPayload(),
max_tables: 12,
row_limit: 500,
}),
});
const rawRows = Number(payload.inserted_or_updated_rows || 0).toLocaleString("ko-KR");
const revisions = Number(payload.normalized_revisions || 0).toLocaleString("ko-KR");
const projects = Number(payload.project_count || 0).toLocaleString("ko-KR");
const taskRows = Number(payload.task_rows_inserted || 0).toLocaleString("ko-KR");
const execRows = Number(payload.exec_rows_inserted || 0).toLocaleString("ko-KR");
const provisional = Number(payload.provisional_count || 0).toLocaleString("ko-KR");
setStatus(
`${payload.message} 원본 ${rawRows}건 · 차수 ${revisions}건 · 프로젝트 ${projects}개 · 과업 ${taskRows}행 · 실행 ${execRows}행 · 가반영 ${provisional}건`,
Number(payload.project_count || 0) > 0 ? "success" : "error",
);
console.log("Satis full sync", payload);
} catch (error) {
setStatus(error.message || "Satis 예산 전체 실행 중 오류가 발생했습니다.", "error");
} finally {
runSatisBudgetFullSyncButton.disabled = false;
}
});
collectSatisBudgetWebButton.addEventListener("click", async () => {
collectSatisBudgetWebButton.disabled = true;
setStatus("Satis 웹로그인 세션으로 예산 관련 메뉴/컨트롤러 응답을 수집하고 있습니다...");
try {
saveErpCredentials();
const payload = await fetchJson("/hanmac-browser/api/collect-satis-budget-web", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
...getErpPayload(),
max_pages: 40,
}),
});
const capturedCount = Number(payload.captured_count || 0).toLocaleString("ko-KR");
const matchedCount = Number(payload.matched_count || 0).toLocaleString("ko-KR");
const errorCount = Number(payload.error_count || 0).toLocaleString("ko-KR");
const firstExamples = Array.isArray(payload.matched_examples)
? payload.matched_examples
.slice(0, 2)
.map((row) => `${row.title || row.url || "응답"} (${Number(row.amount_candidate_count || 0).toLocaleString("ko-KR")}개 금액후보)`)
.join(", ")
: "";
setStatus(
`${payload.message} 매칭 ${matchedCount}건 · 오류 ${errorCount}건${firstExamples ? ` · 예: ${firstExamples}` : ""}`,
Number(payload.captured_count || 0) > 0 ? "success" : "error",
);
console.log("Satis web collection", payload);
if (payload.matched_examples?.length) {
console.table(payload.matched_examples);
}
} catch (error) {
setStatus(error.message || "Satis 웹로그인 예산 수집 중 오류가 발생했습니다.", "error");
} finally {
collectSatisBudgetWebButton.disabled = false;
}
});
loadTablesButton.addEventListener("click", loadTables);
loadGradeCodesButton.addEventListener("click", async () => {
loadGradeCodesButton.disabled = true;
@@ -3496,6 +4141,8 @@
const today = new Date();
const startOfYear = new Date(today.getFullYear(), 0, 1);
restoreCredentials();
restoreErpCredentials();
selectConnectionTab("work");
aggregateStartDate.value = startOfYear.toISOString().slice(0, 10);
aggregateEndDate.value = today.toISOString().slice(0, 10);
const restoredAggregateState = restoreAggregateState();
+253 -25
View File
@@ -466,6 +466,17 @@
margin-left: 0;
}
.status-unit {
display: block;
width: 100%;
margin-top: 2px;
font-size: 11px;
font-weight: 800;
line-height: 1;
color: #64748b;
text-align: left;
}
.status-card[data-count-pending="true"] .status-value {
color: #64748b;
font-size: 13px;
@@ -653,18 +664,20 @@
.table-wrap {
width: 100%;
max-width: 100%;
overflow-x: hidden;
overflow-x: auto;
overflow-y: auto;
border-top: 1px solid rgba(217, 221, 227, 0.9);
background: transparent;
max-height: 460px;
background: #fff;
max-height: none;
box-sizing: border-box;
position: relative;
isolation: isolate;
scrollbar-gutter: stable;
}
.detail-panel .table-wrap {
max-height: 360px;
overflow-x: hidden;
overflow-y: auto;
height: clamp(440px, 68dvh, 980px);
max-height: none;
}
.table-footer {
@@ -703,8 +716,10 @@
.table-wrap th {
position: sticky;
top: 0;
z-index: 1;
z-index: 10;
background: #f8fafc;
background-clip: padding-box;
box-shadow: 0 1px 0 rgba(148, 163, 184, 0.55), 0 3px 8px rgba(15, 23, 42, 0.1);
font-size: 13px;
letter-spacing: 0.01em;
white-space: nowrap;
@@ -784,14 +799,46 @@
font-size: 12px;
}
.voucher-review-notice td {
background: #fff7ed;
color: #9a3412;
font-size: 12px;
border-bottom-color: rgba(251, 146, 60, 0.28);
}
.voucher-review-badges {
display: flex;
flex-wrap: wrap;
gap: 6px;
align-items: center;
}
.voucher-review-badge {
display: inline-flex;
align-items: center;
min-height: 22px;
padding: 0 8px;
border-radius: 999px;
background: #fed7aa;
color: #7c2d12;
font-size: 11px;
font-weight: 800;
}
.voucher-review-note {
color: #9a3412;
}
.voucher-group-lines {
overflow: visible;
max-height: none;
position: relative;
background: #fff;
}
.voucher-group-lines table {
width: 100%;
min-width: 0;
min-width: 1540px;
border-collapse: collapse;
table-layout: fixed;
}
@@ -830,8 +877,10 @@
.voucher-group-lines th {
position: sticky;
top: 0;
z-index: 2;
z-index: 10;
background: #f8fafc;
background-clip: padding-box;
box-shadow: 0 1px 0 rgba(148, 163, 184, 0.55), 0 3px 8px rgba(15, 23, 42, 0.1);
color: inherit;
font-size: 13px;
letter-spacing: 0.01em;
@@ -1513,11 +1562,14 @@
}
body[data-view-mode="dual"] #pairRecommendPanel.active .table-wrap {
max-height: calc(100vh - 230px);
height: calc(100dvh - 230px);
min-height: 360px;
max-height: none;
}
body[data-view-mode="dual"] .detail-panel .table-wrap {
max-height: clamp(300px, 44vh, 520px);
height: clamp(400px, 62dvh, 840px);
max-height: none;
}
body[data-view-mode="dual"] .cell-code {
@@ -1549,6 +1601,10 @@
}
@media (max-width: 720px) {
.detail-panel .table-wrap {
height: clamp(360px, 62dvh, 720px);
}
.voucher-topbar,
.panel-header,
.data-panel summary {
@@ -1638,6 +1694,15 @@
<div><h2>기간 {{ wehago_compare.selected_start_year or '-' }} ~ {{ wehago_compare.selected_end_year or '-' }}</h2></div>
</div>
<div class="panel-body">
{% set final_summary = wehago_compare.wehago_final_status_summary or {} %}
{% set final_diag = final_summary.diagnostics or {} %}
<div class="panel-meta" id="wehagoFinalBasisMeta">
WEHAGO 전표 단위 기준:
{{ "{:,}".format(final_diag.wehago_status_total or final_summary.classified_total or 0) }}건 /
원천 {{ "{:,}".format(final_diag.wehago_voucher_total or final_summary.raw_total or 0) }}건 /
차이 {{ "{:,}".format(final_diag.wehago_status_difference or final_summary.difference or 0) }}건 /
{{ final_summary.source or 'projection' }}
</div>
<div class="status-grid">
{% for section in wehago_compare.metric_sections %}
{% if section.key in ['voucher_matched', 'voucher_unmatched', 'voucher_recheck', 'voucher_excepted', 'hanmac_unconnected', 'erp_voucher_matched', 'erp_voucher_unmatched'] %}
@@ -1646,6 +1711,9 @@
class="status-card"
data-target="detail-{{ section.key }}"
data-status="{{ section.key }}"
data-count="{{ section.count or 0 }}"
data-count-unit="{{ section.count_unit|default('전표그룹') }}"
data-projection-signature="{{ section.projection_signature|default('') }}"
data-count-pending="{% if wehago_compare.pending and section.count == 0 %}true{% else %}false{% endif %}"
aria-expanded="false"
>
@@ -1653,6 +1721,7 @@
<span class="status-value">
{% if wehago_compare.pending and section.count == 0 %}갱신 중{% else %}{{ "{:,}".format(section.count) }}{% endif %}
</span>
<span class="status-unit">{{ section.count_unit|default('전표그룹') }}</span>
</button>
{% endif %}
{% endfor %}
@@ -1698,7 +1767,7 @@
<div class="suggest-dropdown" data-erp-account-suggest="{{ section.key }}"></div>
</div>
<div class="suggest-field">
<input type="text" name="voucher_no" placeholder="전표번호" autocomplete="off">
<input type="text" name="voucher_no" placeholder="일자+전표번호" autocomplete="off">
<div class="suggest-dropdown" data-voucher-suggest="{{ section.key }}"></div>
</div>
<div class="suggest-field">
@@ -1723,7 +1792,7 @@
{% else %}
<form class="filter-form" data-filter-kind="status" data-status="{{ section.key }}">
<div class="suggest-field">
<input type="text" name="voucher_no" placeholder="전표번호" autocomplete="off">
<input type="text" name="voucher_no" placeholder="일자+전표번호" autocomplete="off">
<div class="suggest-dropdown" data-voucher-suggest="{{ section.key }}"></div>
</div>
<div class="suggest-field">
@@ -2064,15 +2133,65 @@
];
};
const firstPresentValue = (...values) => {
for (const value of values) {
if (value === null || value === undefined) continue;
if (typeof value === 'string' && value.trim() === '') continue;
return value;
}
return '';
};
const normalizeVoucherDisplayRow = (row = {}, summary = {}) => {
const source = row || {};
const group = summary || {};
return {
...source,
status_label: firstPresentValue(source.status_label, group.status_label),
fiscal_year: firstPresentValue(source.fiscal_year, group.fiscal_year),
ledger_date: firstPresentValue(source.ledger_date, group.ledger_date),
proof_date: firstPresentValue(source.proof_date, group.proof_date),
voucher_no: firstPresentValue(source.voucher_no, group.voucher_no),
// 가전표번호는 확정전표번호로 보완하지 않는다. 번호 오염을 막기 위해 draft_no 계열만 허용한다.
draft_no: firstPresentValue(source.draft_no, source.erp_draft_no, source.hanmac_draft_no, group.draft_no),
ledger_account_name: firstPresentValue(source.ledger_account_name, source.wehago_account_name, group.ledger_accounts),
ledger_vendor: firstPresentValue(source.ledger_vendor, source.wehago_vendor, group.ledger_vendors),
ledger_debit: firstPresentValue(source.ledger_debit, source.wehago_debit, group.ledger_debit),
ledger_credit: firstPresentValue(source.ledger_credit, source.wehago_credit, group.ledger_credit),
ledger_desc: firstPresentValue(source.ledger_desc, source.wehago_desc, group.ledger_desc),
voucher_account_name: firstPresentValue(
source.voucher_account_name,
source.erp_account_name,
source.hanmac_account_name,
group.voucher_account_name,
group.voucher_accounts
),
voucher_vendor: firstPresentValue(
source.voucher_vendor,
source.erp_vendor,
source.hanmac_vendor,
group.voucher_vendor,
group.voucher_vendors
),
voucher_debit: firstPresentValue(source.voucher_debit, source.erp_debit, source.hanmac_debit, group.voucher_debit),
voucher_credit: firstPresentValue(source.voucher_credit, source.erp_credit, source.hanmac_credit, group.voucher_credit),
voucher_desc: firstPresentValue(source.voucher_desc, source.erp_desc, source.hanmac_desc, group.voucher_desc),
};
};
const renderVoucherDetailTable = (group) => {
if (!voucherDetailTableWrap) return;
const rows = Array.isArray(group?.rows) ? group.rows : [];
const summary = group?.summary || {};
const rawRows = Array.isArray(group?.rows) ? group.rows : [];
const rows = rawRows.length
? rawRows.map((row) => normalizeVoucherDisplayRow(row, summary))
: [normalizeVoucherDisplayRow(summary, summary)];
const isHanmacUnconnected = String(group?.summary?.status_label || '').toLowerCase() === 'hanmac unconnected';
const lineColumns = [
['status_label', '구분'],
...getVoucherLineColumns(isHanmacUnconnected ? 'hanmac_unconnected' : ''),
];
if (!rows.length) {
if (!rawRows.length && !Object.keys(summary).length) {
voucherDetailTableWrap.innerHTML = '<div class="table-placeholder">표시할 상세 행이 없습니다.</div>';
return;
}
@@ -2383,6 +2502,57 @@
}
return String(raw ?? '');
};
const getVoucherReviewBadges = (group) => {
const summary = group?.summary || {};
const reasonText = [
summary.review_reason || '',
...(Array.isArray(group?.rows) ? group.rows.map((row) => row?.review_reason || '') : []),
].join(' / ');
const badges = [];
const add = (key, label, note = '') => {
if (!reasonText.includes(key)) return;
if (badges.some((item) => item.label === label)) return;
badges.push({ label, note });
};
const hasStrongInternalCandidate = [
'INTERNAL_STATUS=matched_direct_candidate',
'INTERNAL_STATUS=matched_with_settlement_bridge_candidate',
'INTERNAL_STATUS=matched_bundle_candidate',
].some((key) => reasonText.includes(key));
add('PARTIAL_WEAK_MONTH_MISMATCH', '월 불일치·약한 근거', '부가세/비용 직접행 없이 일부 정산행만 맞아 재검토가 필요합니다.');
add('ERP_PARTIAL_VOUCHER_ROWS_ONLY', 'ERP 일부행 후보', '같은 ERP 전표의 일부 행만 연결된 후보입니다.');
add('TAX_INVOICE_CORE_ROWS_NOT_MATCHED', '핵심 계정 누락', '부가세·비용·채권채무 구조가 완전하지 않습니다.');
add('PROJECT_TOKEN_CONFLICT', '현장명 불일치', 'WEHAGO와 ERP의 현장/사업명 단서가 다릅니다.');
add('MONTH_CONFLICT_RECHECK', '월 불일치', '전표월 또는 증빙월 확인이 필요합니다.');
if (!hasStrongInternalCandidate) {
add('WEAK_MATCH_RECHECK', '약한 매칭', '금액 외 단서가 부족합니다.');
}
add('DIRECT_MATCH_CANDIDATE', '직접 대응 후보', '금액·계정·거래 문맥이 함께 일치하는 ERP 원본행입니다.');
add('SETTLEMENT_BRIDGE_CANDIDATE', '정산 브리지', '직접 업무행이 아니라 결제·정산 상대행으로 연결된 후보입니다.');
add('SHARED_ALLOCATION_CANDIDATE', '공유 합산행', '여러 WEHAGO 전표의 배분 합계가 이 ERP 원본행을 구성합니다.');
add('SHARED_BUNDLE_PAYMENT_CANDIDATE', 'N:1 결제 묶음', '여러 WEHAGO 전표가 하나의 ERP 결제행을 공유하는 묶음 후보입니다.');
add('ERP_FULL_DRAFT_VOUCHER_CONTEXT', 'ERP 전체 전표', '후보 가전표의 원본행 전체를 표시합니다.');
return badges;
};
const renderVoucherReviewNoticeRow = (group, colSpan) => {
if (statusKey !== 'voucher_recheck') return '';
const badges = getVoucherReviewBadges(group);
if (!badges.length) return '';
const badgeHtml = badges
.map((item) => `<span class="voucher-review-badge" title="${escapeHtml(item.note || item.label)}">${escapeHtml(item.label)}</span>`)
.join('');
const note = badges.map((item) => item.note).find(Boolean) || '상세 버튼에서 연결 행을 확인해주세요.';
return `
<tr class="voucher-review-notice">
<td colspan="${Number(colSpan || 1)}">
<div class="voucher-review-badges">
${badgeHtml}
<span class="voucher-review-note">${escapeHtml(note)}</span>
</div>
</td>
</tr>
`;
};
if (!groups.length) {
const fallbackRows = Array.isArray(payload.rows) ? payload.rows : [];
if (!fallbackRows.length && !append) {
@@ -2393,7 +2563,8 @@
includeReviewCheckbox ? '<th class="selection-col">매칭</th><th class="selection-col">분리</th>' : '',
...lineColumns.map(([field, label]) => `<th class="${getColumnClass(field)}">${escapeHtml(label)}</th>`),
].join('');
const fallbackBody = fallbackRows.map((row, summaryIndex) => {
const fallbackBody = fallbackRows.map((rawRow, summaryIndex) => {
const row = normalizeVoucherDisplayRow(rawRow, rawRow);
const groupKey = `${row.fiscal_year || ''}|${row.voucher_no || ''}|${row.draft_no || ''}|${row.ledger_date || ''}|${row.proof_date || ''}|${summaryIndex}`;
const cells = lineColumns.map(([field]) => {
const display = formatValue(field, row[field]);
@@ -2431,9 +2602,13 @@
...lineColumns.map(([field, label]) => `<th class="${getColumnClass(field)}">${escapeHtml(label)}</th>`),
].join('');
const lineBody = groups.map((group, groupIndex) => {
const rows = Array.isArray(group.rows) ? group.rows : [];
const summary = group?.summary || {};
const rawRows = Array.isArray(group.rows) ? group.rows : [];
const rows = rawRows.length
? rawRows.map((row) => normalizeVoucherDisplayRow(row, summary))
: [normalizeVoucherDisplayRow(summary, summary)];
const groupKey = buildVoucherRecheckGroupKey(group, groupIndex);
return rows.map((row, rowIndex) => {
const groupRowsHtml = rows.map((row, rowIndex) => {
const rowClasses = [];
if (rowIndex === 0) {
rowClasses.push('voucher-table-group-start');
@@ -2455,6 +2630,7 @@
}
return `<tr class="${rowClasses.join(' ')}">${checkboxCell}${cells}</tr>`;
}).join('');
return `${renderVoucherReviewNoticeRow(group, lineColumns.length + (includeReviewCheckbox ? 2 : 0))}${groupRowsHtml}`;
}).join('');
const tableHtml = `
<table>
@@ -2575,9 +2751,10 @@
const setMeta = (key, totalCount, shownCount, notice = '', stats = {}) => {
const target = document.querySelector(`[data-result-meta="${key}"]`);
if (target) {
const countText = `조회 결과 ${Number(totalCount || 0).toLocaleString()}건`;
const unit = String(stats.count_unit || document.querySelector(`.status-card[data-status="${key}"]`)?.dataset.countUnit || '건');
const countText = `조회 결과 ${Number(totalCount || 0).toLocaleString()}${unit}`;
const shownText = shownCount
? ` / 현재 ${Number(shownCount || 0).toLocaleString()}`
? ` / 현재 ${Number(shownCount || 0).toLocaleString()}${unit}`
: '';
const readyYears = Array.isArray(stats.ready_years) ? stats.ready_years : [];
const pendingYears = Array.isArray(stats.pending_years) ? stats.pending_years : [];
@@ -2616,7 +2793,21 @@
},
signal: options.signal || controller?.signal,
});
const payload = await response.json();
const responseText = await response.text();
let payload = {};
if (responseText) {
try {
payload = JSON.parse(responseText);
} catch (_error) {
const fallbackMessage = responseText.length > 240
? `${responseText.slice(0, 240)}...`
: responseText;
if (!response.ok) {
throw new Error(fallbackMessage || `HTTP ${response.status}`);
}
throw new Error('서버 응답을 JSON으로 해석할 수 없습니다.');
}
}
if (!response.ok) {
throw new Error(payload.error || '조회 중 오류가 발생했습니다.');
}
@@ -2755,6 +2946,16 @@
};
const applySummaryPayload = (payload) => {
const finalBasisMeta = document.getElementById('wehagoFinalBasisMeta');
if (finalBasisMeta) {
const summary = payload?.wehago_final_status_summary || {};
const diagnostics = summary.diagnostics || {};
const statusTotal = Number(diagnostics.wehago_status_total ?? summary.classified_total ?? 0);
const rawTotal = Number(diagnostics.wehago_voucher_total ?? summary.raw_total ?? 0);
const difference = Number(diagnostics.wehago_status_difference ?? summary.difference ?? 0);
const source = String(summary.source || 'projection');
finalBasisMeta.textContent = `WEHAGO 전표 단위 기준: ${statusTotal.toLocaleString()}건 / 원천 ${rawTotal.toLocaleString()}건 / 차이 ${difference.toLocaleString()}건 / ${source}`;
}
const sections = Array.isArray(payload?.metric_sections) ? payload.metric_sections : [];
sections.forEach((section) => {
const card = document.querySelector(`.status-card[data-status="${escapeSelectorValue(section.key || '')}"]`);
@@ -2763,6 +2964,13 @@
const isPendingCount = Boolean(payload?.pending) && countValue === 0;
if (card) {
card.dataset.countPending = isPendingCount ? 'true' : 'false';
card.dataset.count = String(countValue || 0);
card.dataset.countUnit = String(section.count_unit || card.dataset.countUnit || '전표');
card.dataset.projectionSignature = String(section.projection_signature || '');
const unitNode = card.querySelector('.status-unit');
if (unitNode) {
unitNode.textContent = card.dataset.countUnit;
}
}
if (valueNode) {
valueNode.textContent = isPendingCount ? '갱신 중' : countValue.toLocaleString();
@@ -2779,7 +2987,8 @@
if (undoLastActionBtn) {
undoLastActionBtn.disabled = !(lastAction && lastAction.id);
}
const pending = Boolean(payload?.pending);
const jobActive = ['queued', 'running'].includes(String(payload?.latest_job?.status || payload?.system_job?.status || ''));
const pending = Boolean(payload?.pending) && jobActive;
summaryRefreshState.pending = pending;
if (summaryRefreshState.timerId) {
clearTimeout(summaryRefreshState.timerId);
@@ -3843,6 +4052,11 @@
clearTimeout(detailRetryTimers.get(statusKey));
detailRetryTimers.delete(statusKey);
}
if (!append) {
detailRequestControllers.forEach((controller, key) => {
if (key !== statusKey) controller.abort();
});
}
const current = detailState.get(statusKey) || { offset: 0, totalCount: 0, loading: false };
if (current.loading) {
const activeController = detailRequestControllers.get(statusKey);
@@ -3863,7 +4077,12 @@
setMeta(statusKey, current.totalCount || 0, 0, '조회 중입니다...');
}
const requestController = new AbortController();
const requestTimeoutId = window.setTimeout(() => requestController.abort(), 20000);
let requestAbortReason = '';
const detailRequestTimeoutMs = 30000;
const requestTimeoutId = window.setTimeout(() => {
requestAbortReason = 'timeout';
requestController.abort();
}, detailRequestTimeoutMs);
detailRequestControllers.set(statusKey, requestController);
try {
const pageLimit = usesCursorPaging ? 24 : 60;
@@ -3875,6 +4094,12 @@
{ signal: requestController.signal, timeoutMs: 20000 },
);
if (detailRequestControllers.get(statusKey) !== requestController) return;
const card = document.querySelector(`.status-card[data-status="${statusKey}"]`);
const cardSignature = String(card?.dataset.projectionSignature || '').trim();
const payloadSignature = String(payload?.projection_signature || '').trim();
if (!append && cardSignature && payloadSignature && cardSignature !== payloadSignature) {
card.dataset.projectionSignature = payloadSignature;
}
renderTable(wrap, payload, ['voucher_no'], append, statusKey);
const shownCount = append ? nextOffset + payload.shown_count : payload.shown_count;
detailState.set(statusKey, {
@@ -3900,8 +4125,11 @@
if (error.name === 'AbortError') {
if (detailRequestControllers.get(statusKey) === requestController) {
if (!append) {
wrap.innerHTML = '<div class="table-placeholder">조회 시간이 길어져 중단했습니다. 조건을 줄여 다시 조회해주세요.</div>';
setMeta(statusKey, current.totalCount || 0, 0, '조회 시간이 길어져 중단했습니다.');
const message = requestAbortReason === 'timeout'
? '상세 조회 캐시를 준비하는 데 시간이 걸리고 있습니다. 조회 캐시 재생성 또는 잠시 후 다시 확인해주세요.'
: '이전 조회가 취소되었습니다.';
wrap.innerHTML = `<div class="table-placeholder">${escapeHtml(message)}</div>`;
setMeta(statusKey, current.totalCount || 0, 0, message);
}
detailState.set(statusKey, { ...current, loading: false, form });
setLoadMoreState(statusKey, current.hasMore, false);
+11237 -288
View File
File diff suppressed because it is too large Load Diff