Update wehago matching logic and exclude reports
This commit is contained in:
+12
@@ -12,3 +12,15 @@ scripts/.chrome-wehago-profile/
|
|||||||
runtime_cache/
|
runtime_cache/
|
||||||
intranet-runtime/
|
intranet-runtime/
|
||||||
.dev-state/
|
.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/
|
||||||
|
|||||||
@@ -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` 같은 런타임 임시 파일은 커밋 대상에서 제외함
|
|
||||||
@@ -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` 같은 런타임 임시 파일은 커밋 대상에서 제외함
|
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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()
|
||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
@@ -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())
|
||||||
@@ -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()
|
||||||
@@ -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())
|
||||||
@@ -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())
|
||||||
@@ -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())
|
||||||
@@ -821,7 +821,7 @@ def project_range(
|
|||||||
allow_stale=allow_stale,
|
allow_stale=allow_stale,
|
||||||
context_signatures=context_signatures,
|
context_signatures=context_signatures,
|
||||||
)
|
)
|
||||||
conn.execute("BEGIN")
|
conn.execute("BEGIN IMMEDIATE")
|
||||||
try:
|
try:
|
||||||
_projection_source_table(
|
_projection_source_table(
|
||||||
conn,
|
conn,
|
||||||
@@ -1026,8 +1026,9 @@ def main() -> None:
|
|||||||
help="같은 기간의 오래된 query projection을 함께 정리합니다. 대용량 DB에서는 별도 유지보수 시간에 실행하세요.",
|
help="같은 기간의 오래된 query projection을 함께 정리합니다. 대용량 DB에서는 별도 유지보수 시간에 실행하세요.",
|
||||||
)
|
)
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
conn = sqlite3.connect(DB_PATH)
|
conn = sqlite3.connect(DB_PATH, timeout=120)
|
||||||
conn.row_factory = sqlite3.Row
|
conn.row_factory = sqlite3.Row
|
||||||
|
conn.execute("PRAGMA busy_timeout = 120000")
|
||||||
for item in args.ranges:
|
for item in args.ranges:
|
||||||
start_year, end_year = parse_range(item)
|
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)
|
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()
|
||||||
@@ -395,9 +395,10 @@ def _latest_query_projection_signature(conn: sqlite3.Connection) -> str | None:
|
|||||||
AND end_year = ?
|
AND end_year = ?
|
||||||
AND signature LIKE ?
|
AND signature LIKE ?
|
||||||
AND signature NOT LIKE '%|snapshot-recheck-promote|%'
|
AND signature NOT LIKE '%|snapshot-recheck-promote|%'
|
||||||
|
AND signature NOT LIKE '%|db-reconciled-%'
|
||||||
GROUP BY signature
|
GROUP BY signature
|
||||||
HAVING status_count >= 5
|
HAVING status_count >= 5
|
||||||
ORDER BY updated_at DESC
|
ORDER BY status_count DESC, updated_at DESC
|
||||||
LIMIT 1
|
LIMIT 1
|
||||||
""",
|
""",
|
||||||
(TARGET_START_YEAR, TARGET_END_YEAR, f"{QUERY_PROJECTION_VERSION}|%"),
|
(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
|
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:
|
def insert_group(conn: sqlite3.Connection, signature: str, status_key: str, group_index: int, group: dict[str, Any]) -> None:
|
||||||
summary = dict(group["summary"])
|
summary = dict(group["summary"])
|
||||||
summary.update(
|
summary.update(
|
||||||
@@ -2963,6 +3044,12 @@ def main() -> None:
|
|||||||
apply_split_draft_row_matches(status_groups)
|
apply_split_draft_row_matches(status_groups)
|
||||||
apply_exact_reversal_pairs_to_status_groups(status_groups)
|
apply_exact_reversal_pairs_to_status_groups(status_groups)
|
||||||
invariant_diagnostics = enforce_wehago_status_invariants(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 = {
|
excepted_wehago_keys = {
|
||||||
group_identity(group)
|
group_identity(group)
|
||||||
for group in status_groups.get("voucher_excepted") or []
|
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())
|
||||||
@@ -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()
|
||||||
@@ -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())
|
||||||
@@ -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())
|
||||||
@@ -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())
|
||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
@@ -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())
|
||||||
@@ -400,6 +400,7 @@ def build_driver(download_dir: Path, headless: bool = False) -> WebDriver:
|
|||||||
options = ChromeOptions()
|
options = ChromeOptions()
|
||||||
if CHROME_DEBUGGER_ADDRESS:
|
if CHROME_DEBUGGER_ADDRESS:
|
||||||
options.add_experimental_option("debuggerAddress", CHROME_DEBUGGER_ADDRESS)
|
options.add_experimental_option("debuggerAddress", CHROME_DEBUGGER_ADDRESS)
|
||||||
|
options.set_capability("goog:loggingPrefs", {"performance": "ALL"})
|
||||||
else:
|
else:
|
||||||
options.add_argument(f"--user-data-dir={CHROME_USER_DATA_DIR}")
|
options.add_argument(f"--user-data-dir={CHROME_USER_DATA_DIR}")
|
||||||
options.add_argument(f"--profile-directory={CHROME_PROFILE_NAME}")
|
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]] = []
|
candidates: list[tuple[float, WebElement]] = []
|
||||||
|
|
||||||
for _ in contexts_with_default_first(driver):
|
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 ("조회", "검색"):
|
for text in ("조회", "검색"):
|
||||||
xpath = f"//*[normalize-space(.)='{text}']"
|
xpath = f"//*[normalize-space(.)='{text}']"
|
||||||
for element in driver.find_elements(By.XPATH, xpath):
|
for element in driver.find_elements(By.XPATH, xpath):
|
||||||
@@ -1982,7 +2007,7 @@ def wait_for_detail_change(
|
|||||||
timeout: int = DETAIL_CHANGE_WAIT_SECONDS,
|
timeout: int = DETAIL_CHANGE_WAIT_SECONDS,
|
||||||
) -> None:
|
) -> None:
|
||||||
if ALLOW_UNCHANGED_DETAIL:
|
if ALLOW_UNCHANGED_DETAIL:
|
||||||
log("주의: 오른쪽 원장 변경 확인 생략 허용이 켜져 있습니다. 계정별 다운로드에는 권장하지 않습니다.")
|
raise RuntimeError("ALLOW_UNCHANGED_DETAIL은 오계정 원장 저장 위험 때문에 사용할 수 없습니다.")
|
||||||
deadline = time.time() + timeout
|
deadline = time.time() + timeout
|
||||||
last_signature = ""
|
last_signature = ""
|
||||||
while time.time() < deadline:
|
while time.time() < deadline:
|
||||||
@@ -1993,22 +2018,6 @@ def wait_for_detail_change(
|
|||||||
return
|
return
|
||||||
if find_detail_data_cell(driver) is not None and not before_signature:
|
if find_detail_data_cell(driver) is not None and not before_signature:
|
||||||
return
|
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)
|
time.sleep(0.15)
|
||||||
raise TimeoutException(
|
raise TimeoutException(
|
||||||
"계정 선택 후 오른쪽 원장 상세 내용이 바뀌지 않았습니다. "
|
"계정 선택 후 오른쪽 원장 상세 내용이 바뀌지 않았습니다. "
|
||||||
@@ -2797,6 +2806,12 @@ def open_wehago(driver: WebDriver) -> None:
|
|||||||
elif ACCOUNT_LEDGER_URL.strip():
|
elif ACCOUNT_LEDGER_URL.strip():
|
||||||
log(f"설정된 계정별원장 주소를 열었습니다: {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):
|
if not wait_for_ledger_screen_ready(driver, timeout=30):
|
||||||
log("계정별원장 화면이 아직 준비되지 않았습니다. 로그인 또는 화면 이동이 필요하면 완료 후 신호를 보내세요.")
|
log("계정별원장 화면이 아직 준비되지 않았습니다. 로그인 또는 화면 이동이 필요하면 완료 후 신호를 보내세요.")
|
||||||
log("만약 404 화면이면 WEHAGO 메뉴에서 계정별원장 화면을 직접 열어주세요.")
|
log("만약 404 화면이면 WEHAGO 메뉴에서 계정별원장 화면을 직접 열어주세요.")
|
||||||
|
|||||||
@@ -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
@@ -251,6 +251,7 @@
|
|||||||
<div class="section-title" style="margin-bottom: 14px;">
|
<div class="section-title" style="margin-bottom: 14px;">
|
||||||
<h2>수익/비용 현황</h2>
|
<h2>수익/비용 현황</h2>
|
||||||
<div class="summary-actions">
|
<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>
|
<button type="button" class="button-secondary" id="annualRebuildCacheBtn">캐시 재계산</button>
|
||||||
<span class="page-job-status" id="annualJobStatus" data-state="">작업 상태 확인 전</span>
|
<span class="page-job-status" id="annualJobStatus" data-state="">작업 상태 확인 전</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+687
-40
@@ -67,6 +67,81 @@
|
|||||||
display: block;
|
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 {
|
.hanmac-grade-panel table {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
border-collapse: collapse;
|
border-collapse: collapse;
|
||||||
@@ -165,6 +240,71 @@
|
|||||||
font-size: 14px;
|
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 {
|
.hanmac-filter-bar {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
@@ -1155,12 +1295,18 @@
|
|||||||
|
|
||||||
.hanmac-modal-card {
|
.hanmac-modal-card {
|
||||||
width: min(760px, 100%);
|
width: min(760px, 100%);
|
||||||
|
max-height: calc(100vh - 48px);
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 14px;
|
gap: 14px;
|
||||||
padding: 20px;
|
padding: 20px;
|
||||||
border-radius: 18px;
|
border-radius: 18px;
|
||||||
background: rgba(255, 255, 255, 0.99);
|
background: rgba(255, 255, 255, 0.99);
|
||||||
box-shadow: 0 20px 50px rgba(17, 17, 17, 0.18);
|
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 {
|
.hanmac-modal-head {
|
||||||
@@ -1213,10 +1359,34 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.hanmac-modal-actions {
|
.hanmac-modal-actions {
|
||||||
display: grid;
|
display: flex;
|
||||||
grid-template-columns: auto auto minmax(0, 1fr);
|
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
align-items: center;
|
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) {
|
@media (max-width: 1080px) {
|
||||||
@@ -1252,8 +1422,7 @@
|
|||||||
@media (max-width: 720px) {
|
@media (max-width: 720px) {
|
||||||
.hanmac-form-grid,
|
.hanmac-form-grid,
|
||||||
.hanmac-aggregate-summary,
|
.hanmac-aggregate-summary,
|
||||||
.hanmac-preview-summary,
|
.hanmac-preview-summary {
|
||||||
.hanmac-modal-actions {
|
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1313,6 +1482,29 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</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">
|
<div class="hanmac-filter-bar">
|
||||||
<input id="hanmacTableSearch" type="search" placeholder="테이블 검색">
|
<input id="hanmacTableSearch" type="search" placeholder="테이블 검색">
|
||||||
<div class="hanmac-filter-tabs">
|
<div class="hanmac-filter-tabs">
|
||||||
@@ -1459,46 +1651,107 @@
|
|||||||
<div class="section-title" style="margin-bottom: 0;">
|
<div class="section-title" style="margin-bottom: 0;">
|
||||||
<h2>서버 접속 정보</h2>
|
<h2>서버 접속 정보</h2>
|
||||||
</div>
|
</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>
|
<button type="button" class="hanmac-close-button" id="hanmacCloseConfigButton" aria-label="닫기">×</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form id="hanmacConnectionForm">
|
<form id="hanmacConnectionForm">
|
||||||
<div class="hanmac-form-grid">
|
<section class="hanmac-connection-section" data-connection-panel="work">
|
||||||
<div class="hanmac-field">
|
<div class="hanmac-connection-section-title">
|
||||||
<label for="hanmac-host">서버 IP</label>
|
<strong>근무 DB 접속</strong>
|
||||||
<input id="hanmac-host" name="host" type="text" value="172.16.42.111" autocomplete="off">
|
<span>hanmac DB_external 조회와 집계에 사용하는 MySQL 계정입니다.</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="hanmac-field">
|
<div class="hanmac-form-grid">
|
||||||
<label for="hanmac-port">포트</label>
|
<div class="hanmac-field">
|
||||||
<input id="hanmac-port" name="port" type="text" value="3306" autocomplete="off">
|
<label for="hanmac-host">서버 IP</label>
|
||||||
|
<input id="hanmac-host" name="host" type="text" value="172.16.42.111" autocomplete="off">
|
||||||
|
</div>
|
||||||
|
<div class="hanmac-field">
|
||||||
|
<label for="hanmac-port">포트</label>
|
||||||
|
<input id="hanmac-port" name="port" type="text" value="3306" autocomplete="off">
|
||||||
|
</div>
|
||||||
|
<div class="hanmac-field">
|
||||||
|
<label for="hanmac-user">아이디</label>
|
||||||
|
<input id="hanmac-user" name="user" type="text" value="root" autocomplete="off">
|
||||||
|
</div>
|
||||||
|
<div class="hanmac-field">
|
||||||
|
<label for="hanmac-password">비밀번호</label>
|
||||||
|
<input id="hanmac-password" name="password" type="password" value="" placeholder="여기에 비밀번호 입력">
|
||||||
|
</div>
|
||||||
|
<div class="hanmac-field">
|
||||||
|
<label for="hanmac-database">기본 연결 DB</label>
|
||||||
|
<select id="hanmac-database" name="database">
|
||||||
|
<option value="hanmac_manhour">hanmac_manhour</option>
|
||||||
|
<option value="baron_manhour">baron_manhour</option>
|
||||||
|
<option value="hanmac">hanmac</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="hanmac-field">
|
||||||
|
<label for="hanmac-engine">DB 종류</label>
|
||||||
|
<input id="hanmac-engine" type="text" value="MySQL" readonly>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="hanmac-field">
|
</section>
|
||||||
<label for="hanmac-user">아이디</label>
|
<section class="hanmac-connection-section is-erp" data-connection-panel="erp" hidden>
|
||||||
<input id="hanmac-user" name="user" type="text" value="root" autocomplete="off">
|
<div class="hanmac-connection-section-title">
|
||||||
|
<strong>관리DB / 관리 ERP 접속</strong>
|
||||||
|
<span>입력한 아이디와 비밀번호는 현재 브라우저 탭이 열려 있는 동안만 저장됩니다.</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="hanmac-field">
|
<div class="hanmac-erp-server-note">
|
||||||
<label for="hanmac-password">비밀번호</label>
|
웹 주소: http://erp.hanmaceng.co.kr/planning_mng/<br>
|
||||||
<input id="hanmac-password" name="password" type="password" value="" placeholder="여기에 비밀번호 입력">
|
MySQL 직접 경로: erp.hanmaceng.co.kr:3306 · 서버: MySQL 5.1.41-community-log
|
||||||
</div>
|
</div>
|
||||||
<div class="hanmac-field">
|
<div class="hanmac-form-grid">
|
||||||
<label for="hanmac-database">기본 연결 DB</label>
|
<div class="hanmac-field">
|
||||||
<select id="hanmac-database" name="database">
|
<label for="hanmac-erp-host">관리DB 서버</label>
|
||||||
<option value="hanmac_manhour">hanmac_manhour</option>
|
<input id="hanmac-erp-host" type="text" value="erp.hanmaceng.co.kr" readonly>
|
||||||
<option value="baron_manhour">baron_manhour</option>
|
</div>
|
||||||
<option value="hanmac">hanmac</option>
|
<div class="hanmac-field">
|
||||||
</select>
|
<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>
|
</div>
|
||||||
<div class="hanmac-field">
|
</section>
|
||||||
<label for="hanmac-engine">DB 종류</label>
|
|
||||||
<input id="hanmac-engine" type="text" value="MySQL" readonly>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<div class="hanmac-modal-actions">
|
<div class="hanmac-modal-actions">
|
||||||
<button type="button" class="hanmac-button secondary" id="hanmacTestConnectionButton">연결 확인</button>
|
<div class="hanmac-connection-actions" data-connection-actions="work">
|
||||||
<button type="button" class="hanmac-button" id="hanmacLoadTablesButton">테이블 목록 보기</button>
|
<button type="button" class="hanmac-button secondary" id="hanmacTestConnectionButton">연결 확인</button>
|
||||||
<button type="button" class="hanmac-button secondary" id="hanmacLoadGradeCodesButton">직급 코드 조회</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 class="hanmac-inline-status" id="hanmacConnectionStatus"></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="hanmac-grade-panel" id="hanmacGradeCodesPanel"></div>
|
<div class="hanmac-grade-panel" id="hanmacGradeCodesPanel"></div>
|
||||||
@@ -1622,9 +1875,19 @@
|
|||||||
const closeConfigButton = document.getElementById("hanmacCloseConfigButton");
|
const closeConfigButton = document.getElementById("hanmacCloseConfigButton");
|
||||||
const statusBox = document.getElementById("hanmacConnectionStatus");
|
const statusBox = document.getElementById("hanmacConnectionStatus");
|
||||||
const testButton = document.getElementById("hanmacTestConnectionButton");
|
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 loadTablesButton = document.getElementById("hanmacLoadTablesButton");
|
||||||
const loadGradeCodesButton = document.getElementById("hanmacLoadGradeCodesButton");
|
const loadGradeCodesButton = document.getElementById("hanmacLoadGradeCodesButton");
|
||||||
const gradeCodesPanel = document.getElementById("hanmacGradeCodesPanel");
|
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 tableListMeta = document.getElementById("hanmacTableListMeta");
|
||||||
const tableList = document.getElementById("hanmacTableList");
|
const tableList = document.getElementById("hanmacTableList");
|
||||||
const tableSearch = document.getElementById("hanmacTableSearch");
|
const tableSearch = document.getElementById("hanmacTableSearch");
|
||||||
@@ -1734,6 +1997,7 @@
|
|||||||
let aggregateLoadPromise = null;
|
let aggregateLoadPromise = null;
|
||||||
let aggregateRequestSeq = 0;
|
let aggregateRequestSeq = 0;
|
||||||
const credentialStorageKey = "hanmac-db-external-credentials-v1";
|
const credentialStorageKey = "hanmac-db-external-credentials-v1";
|
||||||
|
const erpCredentialStorageKey = "hanmac-management-erp-session-credentials-v1";
|
||||||
const aggregateStateStorageKey = "hanmac-db-external-aggregate-state-v1";
|
const aggregateStateStorageKey = "hanmac-db-external-aggregate-state-v1";
|
||||||
const jointMembersFallbackUrl = "/static/hanmac-joint-members-cache.json";
|
const jointMembersFallbackUrl = "/static/hanmac-joint-members-cache.json";
|
||||||
|
|
||||||
@@ -1780,6 +2044,23 @@
|
|||||||
DeptCode: "부서코드",
|
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 = () => {
|
const openModal = () => {
|
||||||
modal.hidden = false;
|
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 fetchJson = async (url, options = {}) => {
|
||||||
const response = await fetch(url, {
|
const response = await fetch(url, {
|
||||||
...options,
|
...options,
|
||||||
@@ -2289,17 +2603,21 @@
|
|||||||
"project_count",
|
"project_count",
|
||||||
]);
|
]);
|
||||||
const aggregateDetailColumns = new Set([
|
const aggregateDetailColumns = new Set([
|
||||||
|
"population_review",
|
||||||
"regular_hours",
|
"regular_hours",
|
||||||
"overtime_hours",
|
"overtime_hours",
|
||||||
"total_hours",
|
"total_hours",
|
||||||
"legal_leave_days",
|
"legal_leave_days",
|
||||||
|
"missing_regular_days",
|
||||||
"project_count",
|
"project_count",
|
||||||
]);
|
]);
|
||||||
const aggregateDetailLabels = {
|
const aggregateDetailLabels = {
|
||||||
|
population_review: "소속검토",
|
||||||
regular_hours: "정규근로",
|
regular_hours: "정규근로",
|
||||||
overtime_hours: "연장근로",
|
overtime_hours: "연장근로",
|
||||||
total_hours: "총근로",
|
total_hours: "총근로",
|
||||||
legal_leave_days: "법정휴가",
|
legal_leave_days: "법정휴가",
|
||||||
|
missing_regular_days: "정규근로 부족",
|
||||||
project_count: "프로젝트수",
|
project_count: "프로젝트수",
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -2468,7 +2786,11 @@
|
|||||||
|
|
||||||
const getEquivalentProjectTitle = (row) => {
|
const getEquivalentProjectTitle = (row) => {
|
||||||
const equivalentCodes = Array.isArray(row?.equivalent_project_codes) ? row.equivalent_project_codes.filter(Boolean) : [];
|
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") => {
|
const renderProjectCodeText = (row, fallbackKey = "project_code") => {
|
||||||
@@ -2486,9 +2808,27 @@
|
|||||||
const renderAggregateMetricCell = (row, column, rowIndex, view) => {
|
const renderAggregateMetricCell = (row, column, rowIndex, view) => {
|
||||||
if (view === "member" && column.key === "remarks") {
|
if (view === "member" && column.key === "remarks") {
|
||||||
const duplicateDays = Number(row.multi_entry_days || 0);
|
const duplicateDays = Number(row.multi_entry_days || 0);
|
||||||
const detailButton = duplicateDays > 0
|
const overlapCounts = row.overlap_type_counts && typeof row.overlap_type_counts === "object"
|
||||||
? `<button type="button" class="hanmac-aggregate-detail-link" data-aggregate-duplicate-index="${rowIndex}">중복 ${duplicateDays.toLocaleString("ko-KR")}일</button>`
|
? 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>`;
|
return `<td>${detailButton}</td>`;
|
||||||
}
|
}
|
||||||
const isNumeric = aggregateNumericColumns.has(column.key);
|
const isNumeric = aggregateNumericColumns.has(column.key);
|
||||||
@@ -2703,15 +3043,16 @@
|
|||||||
|
|
||||||
const openMultiEntryModal = (row) => {
|
const openMultiEntryModal = (row) => {
|
||||||
const details = Array.isArray(row?.multi_entry_details) ? row.multi_entry_details : [];
|
const details = Array.isArray(row?.multi_entry_details) ? row.multi_entry_details : [];
|
||||||
multiEntryTitle.textContent = `${row?.member_name || row?.member_no || "사원"} 중복 근무 상세`;
|
multiEntryTitle.textContent = `${row?.member_name || row?.member_no || "사원"} 근무기록 검토 상세`;
|
||||||
multiEntryMeta.textContent = `${row?.member_no || ""} · ${Number(row?.multi_entry_days || details.length || 0).toLocaleString("ko-KR")}일 중복`;
|
multiEntryMeta.textContent = `${row?.member_no || ""} · ${Number(row?.multi_entry_days || details.length || 0).toLocaleString("ko-KR")}일 검토 필요`;
|
||||||
if (!details.length) {
|
if (!details.length) {
|
||||||
multiEntryBody.innerHTML = `<div class="hanmac-preview-empty">중복 상세가 없습니다.</div>`;
|
multiEntryBody.innerHTML = `<div class="hanmac-preview-empty">검토할 근무기록이 없습니다.</div>`;
|
||||||
} else {
|
} else {
|
||||||
multiEntryBody.innerHTML = details.map((detail) => `
|
multiEntryBody.innerHTML = details.map((detail) => `
|
||||||
<section class="hanmac-detail-day">
|
<section class="hanmac-detail-day">
|
||||||
<div class="hanmac-detail-day-head">
|
<div class="hanmac-detail-day-head">
|
||||||
<strong class="hanmac-detail-day-title">${escapeHtml(detail.work_date || "-")}</strong>
|
<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.raw_total_hours)}시간</span>
|
||||||
<span class="hanmac-detail-badge">집계반영 ${formatNumberText(detail.capped_regular_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>
|
<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>
|
<td>${Number(entry.row_count || 0) > 1 ? `원본 ${Number(entry.row_count).toLocaleString("ko-KR")}행` : ""}</td>
|
||||||
</tr>
|
</tr>
|
||||||
`).join("")}
|
`).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>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</section>
|
</section>
|
||||||
@@ -2787,7 +3136,7 @@
|
|||||||
if (currentAggregateView !== "project" && Array.isArray(detail.projects) && detail.projects.length) {
|
if (currentAggregateView !== "project" && Array.isArray(detail.projects) && detail.projects.length) {
|
||||||
return detail.projects.map((project) => ({
|
return detail.projects.map((project) => ({
|
||||||
workDate: detail.work_date,
|
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),
|
recognizedHours: project.recognized_hours ?? (detail.projects.length === 1 ? detail.regular_hours : 0),
|
||||||
actualHours: project.hours,
|
actualHours: project.hours,
|
||||||
}));
|
}));
|
||||||
@@ -2824,6 +3173,60 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
const renderAggregateDetailRows = (detailKey, details) => {
|
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") {
|
if (detailKey === "project_count") {
|
||||||
return `
|
return `
|
||||||
<table class="hanmac-detail-table">
|
<table class="hanmac-detail-table">
|
||||||
@@ -2983,8 +3386,9 @@
|
|||||||
aggregateLeaveDays.textContent = Number(summary.legal_leave_hours || 0).toLocaleString("ko-KR");
|
aggregateLeaveDays.textContent = Number(summary.legal_leave_hours || 0).toLocaleString("ko-KR");
|
||||||
aggregateProjectCount.textContent = Number(summary.project_count || 0).toLocaleString("ko-KR");
|
aggregateProjectCount.textContent = Number(summary.project_count || 0).toLocaleString("ko-KR");
|
||||||
const diagnostics = payload.source_diagnostics || {};
|
const diagnostics = payload.source_diagnostics || {};
|
||||||
|
const logicVersionText = diagnostics.logic_version ? ` · 로직 ${diagnostics.logic_version}` : "";
|
||||||
const diagnosticText = diagnostics
|
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}`;
|
aggregateMeta.textContent = `${payload.start_date || ""} ~ ${payload.end_date || ""}${diagnosticText}`;
|
||||||
if (currentAggregateSort.key && !columns.some((column) => column.key === currentAggregateSort.key)) {
|
if (currentAggregateSort.key && !columns.some((column) => column.key === currentAggregateSort.key)) {
|
||||||
@@ -3287,6 +3691,12 @@
|
|||||||
|
|
||||||
openConfigButton.addEventListener("click", openModal);
|
openConfigButton.addEventListener("click", openModal);
|
||||||
closeConfigButton.addEventListener("click", closeModal);
|
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) => {
|
modal.addEventListener("click", (event) => {
|
||||||
if (event.target === modal) closeModal();
|
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);
|
loadTablesButton.addEventListener("click", loadTables);
|
||||||
loadGradeCodesButton.addEventListener("click", async () => {
|
loadGradeCodesButton.addEventListener("click", async () => {
|
||||||
loadGradeCodesButton.disabled = true;
|
loadGradeCodesButton.disabled = true;
|
||||||
@@ -3496,6 +4141,8 @@
|
|||||||
const today = new Date();
|
const today = new Date();
|
||||||
const startOfYear = new Date(today.getFullYear(), 0, 1);
|
const startOfYear = new Date(today.getFullYear(), 0, 1);
|
||||||
restoreCredentials();
|
restoreCredentials();
|
||||||
|
restoreErpCredentials();
|
||||||
|
selectConnectionTab("work");
|
||||||
aggregateStartDate.value = startOfYear.toISOString().slice(0, 10);
|
aggregateStartDate.value = startOfYear.toISOString().slice(0, 10);
|
||||||
aggregateEndDate.value = today.toISOString().slice(0, 10);
|
aggregateEndDate.value = today.toISOString().slice(0, 10);
|
||||||
const restoredAggregateState = restoreAggregateState();
|
const restoredAggregateState = restoreAggregateState();
|
||||||
|
|||||||
+253
-25
@@ -466,6 +466,17 @@
|
|||||||
margin-left: 0;
|
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 {
|
.status-card[data-count-pending="true"] .status-value {
|
||||||
color: #64748b;
|
color: #64748b;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
@@ -653,18 +664,20 @@
|
|||||||
.table-wrap {
|
.table-wrap {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
overflow-x: hidden;
|
overflow-x: auto;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
border-top: 1px solid rgba(217, 221, 227, 0.9);
|
border-top: 1px solid rgba(217, 221, 227, 0.9);
|
||||||
background: transparent;
|
background: #fff;
|
||||||
max-height: 460px;
|
max-height: none;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
|
position: relative;
|
||||||
|
isolation: isolate;
|
||||||
|
scrollbar-gutter: stable;
|
||||||
}
|
}
|
||||||
|
|
||||||
.detail-panel .table-wrap {
|
.detail-panel .table-wrap {
|
||||||
max-height: 360px;
|
height: clamp(440px, 68dvh, 980px);
|
||||||
overflow-x: hidden;
|
max-height: none;
|
||||||
overflow-y: auto;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.table-footer {
|
.table-footer {
|
||||||
@@ -703,8 +716,10 @@
|
|||||||
.table-wrap th {
|
.table-wrap th {
|
||||||
position: sticky;
|
position: sticky;
|
||||||
top: 0;
|
top: 0;
|
||||||
z-index: 1;
|
z-index: 10;
|
||||||
background: #f8fafc;
|
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;
|
font-size: 13px;
|
||||||
letter-spacing: 0.01em;
|
letter-spacing: 0.01em;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
@@ -784,14 +799,46 @@
|
|||||||
font-size: 12px;
|
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 {
|
.voucher-group-lines {
|
||||||
overflow: visible;
|
overflow: visible;
|
||||||
max-height: none;
|
max-height: none;
|
||||||
|
position: relative;
|
||||||
|
background: #fff;
|
||||||
}
|
}
|
||||||
|
|
||||||
.voucher-group-lines table {
|
.voucher-group-lines table {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
min-width: 0;
|
min-width: 1540px;
|
||||||
border-collapse: collapse;
|
border-collapse: collapse;
|
||||||
table-layout: fixed;
|
table-layout: fixed;
|
||||||
}
|
}
|
||||||
@@ -830,8 +877,10 @@
|
|||||||
.voucher-group-lines th {
|
.voucher-group-lines th {
|
||||||
position: sticky;
|
position: sticky;
|
||||||
top: 0;
|
top: 0;
|
||||||
z-index: 2;
|
z-index: 10;
|
||||||
background: #f8fafc;
|
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;
|
color: inherit;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
letter-spacing: 0.01em;
|
letter-spacing: 0.01em;
|
||||||
@@ -1513,11 +1562,14 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
body[data-view-mode="dual"] #pairRecommendPanel.active .table-wrap {
|
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 {
|
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 {
|
body[data-view-mode="dual"] .cell-code {
|
||||||
@@ -1549,6 +1601,10 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 720px) {
|
@media (max-width: 720px) {
|
||||||
|
.detail-panel .table-wrap {
|
||||||
|
height: clamp(360px, 62dvh, 720px);
|
||||||
|
}
|
||||||
|
|
||||||
.voucher-topbar,
|
.voucher-topbar,
|
||||||
.panel-header,
|
.panel-header,
|
||||||
.data-panel summary {
|
.data-panel summary {
|
||||||
@@ -1638,6 +1694,15 @@
|
|||||||
<div><h2>기간 {{ wehago_compare.selected_start_year or '-' }} ~ {{ wehago_compare.selected_end_year or '-' }}</h2></div>
|
<div><h2>기간 {{ wehago_compare.selected_start_year or '-' }} ~ {{ wehago_compare.selected_end_year or '-' }}</h2></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="panel-body">
|
<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">
|
<div class="status-grid">
|
||||||
{% for section in wehago_compare.metric_sections %}
|
{% 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'] %}
|
{% 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"
|
class="status-card"
|
||||||
data-target="detail-{{ section.key }}"
|
data-target="detail-{{ section.key }}"
|
||||||
data-status="{{ 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 %}"
|
data-count-pending="{% if wehago_compare.pending and section.count == 0 %}true{% else %}false{% endif %}"
|
||||||
aria-expanded="false"
|
aria-expanded="false"
|
||||||
>
|
>
|
||||||
@@ -1653,6 +1721,7 @@
|
|||||||
<span class="status-value">
|
<span class="status-value">
|
||||||
{% if wehago_compare.pending and section.count == 0 %}갱신 중{% else %}{{ "{:,}".format(section.count) }}{% endif %}
|
{% if wehago_compare.pending and section.count == 0 %}갱신 중{% else %}{{ "{:,}".format(section.count) }}{% endif %}
|
||||||
</span>
|
</span>
|
||||||
|
<span class="status-unit">{{ section.count_unit|default('전표그룹') }}</span>
|
||||||
</button>
|
</button>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
@@ -1698,7 +1767,7 @@
|
|||||||
<div class="suggest-dropdown" data-erp-account-suggest="{{ section.key }}"></div>
|
<div class="suggest-dropdown" data-erp-account-suggest="{{ section.key }}"></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="suggest-field">
|
<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 class="suggest-dropdown" data-voucher-suggest="{{ section.key }}"></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="suggest-field">
|
<div class="suggest-field">
|
||||||
@@ -1723,7 +1792,7 @@
|
|||||||
{% else %}
|
{% else %}
|
||||||
<form class="filter-form" data-filter-kind="status" data-status="{{ section.key }}">
|
<form class="filter-form" data-filter-kind="status" data-status="{{ section.key }}">
|
||||||
<div class="suggest-field">
|
<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 class="suggest-dropdown" data-voucher-suggest="{{ section.key }}"></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="suggest-field">
|
<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) => {
|
const renderVoucherDetailTable = (group) => {
|
||||||
if (!voucherDetailTableWrap) return;
|
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 isHanmacUnconnected = String(group?.summary?.status_label || '').toLowerCase() === 'hanmac unconnected';
|
||||||
const lineColumns = [
|
const lineColumns = [
|
||||||
['status_label', '구분'],
|
['status_label', '구분'],
|
||||||
...getVoucherLineColumns(isHanmacUnconnected ? 'hanmac_unconnected' : ''),
|
...getVoucherLineColumns(isHanmacUnconnected ? 'hanmac_unconnected' : ''),
|
||||||
];
|
];
|
||||||
if (!rows.length) {
|
if (!rawRows.length && !Object.keys(summary).length) {
|
||||||
voucherDetailTableWrap.innerHTML = '<div class="table-placeholder">표시할 상세 행이 없습니다.</div>';
|
voucherDetailTableWrap.innerHTML = '<div class="table-placeholder">표시할 상세 행이 없습니다.</div>';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -2383,6 +2502,57 @@
|
|||||||
}
|
}
|
||||||
return String(raw ?? '');
|
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) {
|
if (!groups.length) {
|
||||||
const fallbackRows = Array.isArray(payload.rows) ? payload.rows : [];
|
const fallbackRows = Array.isArray(payload.rows) ? payload.rows : [];
|
||||||
if (!fallbackRows.length && !append) {
|
if (!fallbackRows.length && !append) {
|
||||||
@@ -2393,7 +2563,8 @@
|
|||||||
includeReviewCheckbox ? '<th class="selection-col">매칭</th><th class="selection-col">분리</th>' : '',
|
includeReviewCheckbox ? '<th class="selection-col">매칭</th><th class="selection-col">분리</th>' : '',
|
||||||
...lineColumns.map(([field, label]) => `<th class="${getColumnClass(field)}">${escapeHtml(label)}</th>`),
|
...lineColumns.map(([field, label]) => `<th class="${getColumnClass(field)}">${escapeHtml(label)}</th>`),
|
||||||
].join('');
|
].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 groupKey = `${row.fiscal_year || ''}|${row.voucher_no || ''}|${row.draft_no || ''}|${row.ledger_date || ''}|${row.proof_date || ''}|${summaryIndex}`;
|
||||||
const cells = lineColumns.map(([field]) => {
|
const cells = lineColumns.map(([field]) => {
|
||||||
const display = formatValue(field, row[field]);
|
const display = formatValue(field, row[field]);
|
||||||
@@ -2431,9 +2602,13 @@
|
|||||||
...lineColumns.map(([field, label]) => `<th class="${getColumnClass(field)}">${escapeHtml(label)}</th>`),
|
...lineColumns.map(([field, label]) => `<th class="${getColumnClass(field)}">${escapeHtml(label)}</th>`),
|
||||||
].join('');
|
].join('');
|
||||||
const lineBody = groups.map((group, groupIndex) => {
|
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);
|
const groupKey = buildVoucherRecheckGroupKey(group, groupIndex);
|
||||||
return rows.map((row, rowIndex) => {
|
const groupRowsHtml = rows.map((row, rowIndex) => {
|
||||||
const rowClasses = [];
|
const rowClasses = [];
|
||||||
if (rowIndex === 0) {
|
if (rowIndex === 0) {
|
||||||
rowClasses.push('voucher-table-group-start');
|
rowClasses.push('voucher-table-group-start');
|
||||||
@@ -2455,6 +2630,7 @@
|
|||||||
}
|
}
|
||||||
return `<tr class="${rowClasses.join(' ')}">${checkboxCell}${cells}</tr>`;
|
return `<tr class="${rowClasses.join(' ')}">${checkboxCell}${cells}</tr>`;
|
||||||
}).join('');
|
}).join('');
|
||||||
|
return `${renderVoucherReviewNoticeRow(group, lineColumns.length + (includeReviewCheckbox ? 2 : 0))}${groupRowsHtml}`;
|
||||||
}).join('');
|
}).join('');
|
||||||
const tableHtml = `
|
const tableHtml = `
|
||||||
<table>
|
<table>
|
||||||
@@ -2575,9 +2751,10 @@
|
|||||||
const setMeta = (key, totalCount, shownCount, notice = '', stats = {}) => {
|
const setMeta = (key, totalCount, shownCount, notice = '', stats = {}) => {
|
||||||
const target = document.querySelector(`[data-result-meta="${key}"]`);
|
const target = document.querySelector(`[data-result-meta="${key}"]`);
|
||||||
if (target) {
|
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
|
const shownText = shownCount
|
||||||
? ` / 현재 ${Number(shownCount || 0).toLocaleString()}건`
|
? ` / 현재 ${Number(shownCount || 0).toLocaleString()}${unit}`
|
||||||
: '';
|
: '';
|
||||||
const readyYears = Array.isArray(stats.ready_years) ? stats.ready_years : [];
|
const readyYears = Array.isArray(stats.ready_years) ? stats.ready_years : [];
|
||||||
const pendingYears = Array.isArray(stats.pending_years) ? stats.pending_years : [];
|
const pendingYears = Array.isArray(stats.pending_years) ? stats.pending_years : [];
|
||||||
@@ -2616,7 +2793,21 @@
|
|||||||
},
|
},
|
||||||
signal: options.signal || controller?.signal,
|
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) {
|
if (!response.ok) {
|
||||||
throw new Error(payload.error || '조회 중 오류가 발생했습니다.');
|
throw new Error(payload.error || '조회 중 오류가 발생했습니다.');
|
||||||
}
|
}
|
||||||
@@ -2755,6 +2946,16 @@
|
|||||||
};
|
};
|
||||||
|
|
||||||
const applySummaryPayload = (payload) => {
|
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 : [];
|
const sections = Array.isArray(payload?.metric_sections) ? payload.metric_sections : [];
|
||||||
sections.forEach((section) => {
|
sections.forEach((section) => {
|
||||||
const card = document.querySelector(`.status-card[data-status="${escapeSelectorValue(section.key || '')}"]`);
|
const card = document.querySelector(`.status-card[data-status="${escapeSelectorValue(section.key || '')}"]`);
|
||||||
@@ -2763,6 +2964,13 @@
|
|||||||
const isPendingCount = Boolean(payload?.pending) && countValue === 0;
|
const isPendingCount = Boolean(payload?.pending) && countValue === 0;
|
||||||
if (card) {
|
if (card) {
|
||||||
card.dataset.countPending = isPendingCount ? 'true' : 'false';
|
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) {
|
if (valueNode) {
|
||||||
valueNode.textContent = isPendingCount ? '갱신 중' : countValue.toLocaleString();
|
valueNode.textContent = isPendingCount ? '갱신 중' : countValue.toLocaleString();
|
||||||
@@ -2779,7 +2987,8 @@
|
|||||||
if (undoLastActionBtn) {
|
if (undoLastActionBtn) {
|
||||||
undoLastActionBtn.disabled = !(lastAction && lastAction.id);
|
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;
|
summaryRefreshState.pending = pending;
|
||||||
if (summaryRefreshState.timerId) {
|
if (summaryRefreshState.timerId) {
|
||||||
clearTimeout(summaryRefreshState.timerId);
|
clearTimeout(summaryRefreshState.timerId);
|
||||||
@@ -3843,6 +4052,11 @@
|
|||||||
clearTimeout(detailRetryTimers.get(statusKey));
|
clearTimeout(detailRetryTimers.get(statusKey));
|
||||||
detailRetryTimers.delete(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 };
|
const current = detailState.get(statusKey) || { offset: 0, totalCount: 0, loading: false };
|
||||||
if (current.loading) {
|
if (current.loading) {
|
||||||
const activeController = detailRequestControllers.get(statusKey);
|
const activeController = detailRequestControllers.get(statusKey);
|
||||||
@@ -3863,7 +4077,12 @@
|
|||||||
setMeta(statusKey, current.totalCount || 0, 0, '조회 중입니다...');
|
setMeta(statusKey, current.totalCount || 0, 0, '조회 중입니다...');
|
||||||
}
|
}
|
||||||
const requestController = new AbortController();
|
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);
|
detailRequestControllers.set(statusKey, requestController);
|
||||||
try {
|
try {
|
||||||
const pageLimit = usesCursorPaging ? 24 : 60;
|
const pageLimit = usesCursorPaging ? 24 : 60;
|
||||||
@@ -3875,6 +4094,12 @@
|
|||||||
{ signal: requestController.signal, timeoutMs: 20000 },
|
{ signal: requestController.signal, timeoutMs: 20000 },
|
||||||
);
|
);
|
||||||
if (detailRequestControllers.get(statusKey) !== requestController) return;
|
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);
|
renderTable(wrap, payload, ['voucher_no'], append, statusKey);
|
||||||
const shownCount = append ? nextOffset + payload.shown_count : payload.shown_count;
|
const shownCount = append ? nextOffset + payload.shown_count : payload.shown_count;
|
||||||
detailState.set(statusKey, {
|
detailState.set(statusKey, {
|
||||||
@@ -3900,8 +4125,11 @@
|
|||||||
if (error.name === 'AbortError') {
|
if (error.name === 'AbortError') {
|
||||||
if (detailRequestControllers.get(statusKey) === requestController) {
|
if (detailRequestControllers.get(statusKey) === requestController) {
|
||||||
if (!append) {
|
if (!append) {
|
||||||
wrap.innerHTML = '<div class="table-placeholder">조회 시간이 길어져 중단했습니다. 조건을 줄여 다시 조회해주세요.</div>';
|
const message = requestAbortReason === 'timeout'
|
||||||
setMeta(statusKey, current.totalCount || 0, 0, '조회 시간이 길어져 중단했습니다.');
|
? '상세 조회 캐시를 준비하는 데 시간이 걸리고 있습니다. 조회 캐시 재생성 또는 잠시 후 다시 확인해주세요.'
|
||||||
|
: '이전 조회가 취소되었습니다.';
|
||||||
|
wrap.innerHTML = `<div class="table-placeholder">${escapeHtml(message)}</div>`;
|
||||||
|
setMeta(statusKey, current.totalCount || 0, 0, message);
|
||||||
}
|
}
|
||||||
detailState.set(statusKey, { ...current, loading: false, form });
|
detailState.set(statusKey, { ...current, loading: false, form });
|
||||||
setLoadMoreState(statusKey, current.hasMore, false);
|
setLoadMoreState(statusKey, current.hasMore, false);
|
||||||
|
|||||||
+11370
-421
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user