From da073ad6ef65bb764b1faa979e06e662f2761e49 Mon Sep 17 00:00:00 2001 From: b17301 Date: Fri, 3 Jul 2026 09:07:04 +0900 Subject: [PATCH] Update wehago matching logic and exclude reports --- .gitignore | 12 + DB_HEALTH_CHECK_20260409.md | 169 - WORK_SUMMARY_20260408.md | 23 - WORK_SUMMARY_20260409.md | 50 - .../20260410_0900/DB_HEALTH_CHECK_20260409.md | 169 - .../20260410_0900/WORK_SUMMARY_20260408.md | 23 - .../20260410_0900/WORK_SUMMARY_20260409.md | 50 - main.py | 11575 ++++++++++++++- ...anet-app_architecture_report_20260522.docx | Bin 8690 -> 0 bytes ...t-app_architecture_report_20260522_v2.docx | Bin 13668 -> 0 bytes ...t-app_architecture_report_20260522_v3.docx | Bin 17224 -> 0 bytes ...t-app_architecture_report_20260522_v4.docx | Bin 39035 -> 0 bytes ...ivate_hanmac_voucher_existence_snapshot.py | 43 + .../analyze_hanmac_work_history_coverage.py | 256 + scripts/analyze_satis_project_mapping.py | 372 + .../analyze_wehago_accounting_risk_report.py | 428 + scripts/analyze_wehago_case_report.py | 688 + ...yze_wehago_unmatched_recheck_cost_types.py | 572 + .../apply_highpass_bundle_projection_patch.py | 120 + .../apply_proof_date_exact_projection_fix.py | 232 + ...wehago_month_bundle_and_safe_promotions.py | 323 + .../apply_wehago_row_allocator_display_fix.py | 254 + .../build_wehago_voucher_shadow_diagnostic.py | 457 + scripts/click_wehago_query_9225.py | 37 + scripts/create_erp_tree_report_image.py | 371 + .../create_voucher_card_analysis_reports.py | 465 + .../create_wehago_cost_type_word_report.py | 343 + ...download_hanmac_legacy_related_accounts.py | 159 + scripts/export_hanmac_related_party_loans.py | 386 + scripts/export_selected_work_history.py | 311 + scripts/fix_hanmac_2025_137_minimal.py | 152 + scripts/import_hanmac_voucher_xlsx.py | 158 + scripts/inspect_wehago_ledger_api_9225.py | 162 + ...lize_wehago_2025_final_basis_projection.py | 679 + ...ialize_wehago_2025_integrity_projection.py | 700 + ...preview_cost_analysis1_labor_adjustment.py | 266 + scripts/project_export_cache_ranges.py | 5 +- .../promote_wehago_allocator_safe_matches.py | 235 + scripts/promote_wehago_recheck_projection.py | 3 +- .../rebuild_hanmac_aggregate_cache_once.py | 86 + ...ebuild_wehago_recheck_anchor_projection.py | 73 + ...uild_wehago_yearly_projections_watchdog.py | 563 + scripts/reconcile_wehago_projection_to_db.py | 87 + ...edownload_and_fix_hanmac_ledger_account.py | 285 + scripts/refine_wehago_cost_type_groups.py | 301 + scripts/refresh_hanmac_wehago_ledgers.py | 383 + scripts/retry_2025_all_direct_runner.py | 86 + scripts/retry_failed_wehago_accounts.py | 92 + .../retry_failed_wehago_accounts_direct.py | 226 + scripts/test_wehago_month_bundle_shadow.py | 276 + scripts/test_wehago_row_allocator_shadow.py | 553 + scripts/validate_wehago_anchor_projection.py | 174 + scripts/validate_wehago_projection_shadow.py | 183 + .../validate_wehago_recheck_regressions.py | 216 + scripts/verify_hanmac_all_direct.py | 102 + scripts/wehago_data_download_2022_work.py | 49 +- scripts/wehago_ledger_api_download.py | 287 + templates/annual_gap_analysis.html | 1425 ++ templates/annual_summary.html | 1 + templates/hanmac_browser.html | 727 +- templates/wehago_compare.html | 278 +- wehago_compare.py | 11793 +++++++++++++++- 62 files changed, 36940 insertions(+), 1554 deletions(-) delete mode 100644 DB_HEALTH_CHECK_20260409.md delete mode 100644 WORK_SUMMARY_20260408.md delete mode 100644 WORK_SUMMARY_20260409.md delete mode 100644 backup_snapshots/20260410_0900/DB_HEALTH_CHECK_20260409.md delete mode 100644 backup_snapshots/20260410_0900/WORK_SUMMARY_20260408.md delete mode 100644 backup_snapshots/20260410_0900/WORK_SUMMARY_20260409.md delete mode 100644 reports/my-intranet-app_architecture_report_20260522.docx delete mode 100644 reports/my-intranet-app_architecture_report_20260522_v2.docx delete mode 100644 reports/my-intranet-app_architecture_report_20260522_v3.docx delete mode 100644 reports/my-intranet-app_architecture_report_20260522_v4.docx create mode 100644 scripts/activate_hanmac_voucher_existence_snapshot.py create mode 100644 scripts/analyze_hanmac_work_history_coverage.py create mode 100644 scripts/analyze_satis_project_mapping.py create mode 100644 scripts/analyze_wehago_accounting_risk_report.py create mode 100644 scripts/analyze_wehago_case_report.py create mode 100644 scripts/analyze_wehago_unmatched_recheck_cost_types.py create mode 100644 scripts/apply_highpass_bundle_projection_patch.py create mode 100644 scripts/apply_proof_date_exact_projection_fix.py create mode 100644 scripts/apply_wehago_month_bundle_and_safe_promotions.py create mode 100644 scripts/apply_wehago_row_allocator_display_fix.py create mode 100644 scripts/build_wehago_voucher_shadow_diagnostic.py create mode 100644 scripts/click_wehago_query_9225.py create mode 100644 scripts/create_erp_tree_report_image.py create mode 100644 scripts/create_voucher_card_analysis_reports.py create mode 100644 scripts/create_wehago_cost_type_word_report.py create mode 100644 scripts/download_hanmac_legacy_related_accounts.py create mode 100644 scripts/export_hanmac_related_party_loans.py create mode 100644 scripts/export_selected_work_history.py create mode 100644 scripts/fix_hanmac_2025_137_minimal.py create mode 100644 scripts/import_hanmac_voucher_xlsx.py create mode 100644 scripts/inspect_wehago_ledger_api_9225.py create mode 100644 scripts/materialize_wehago_2025_final_basis_projection.py create mode 100644 scripts/materialize_wehago_2025_integrity_projection.py create mode 100644 scripts/preview_cost_analysis1_labor_adjustment.py create mode 100644 scripts/promote_wehago_allocator_safe_matches.py create mode 100644 scripts/rebuild_hanmac_aggregate_cache_once.py create mode 100644 scripts/rebuild_wehago_recheck_anchor_projection.py create mode 100644 scripts/rebuild_wehago_yearly_projections_watchdog.py create mode 100644 scripts/redownload_and_fix_hanmac_ledger_account.py create mode 100644 scripts/refine_wehago_cost_type_groups.py create mode 100644 scripts/refresh_hanmac_wehago_ledgers.py create mode 100644 scripts/retry_2025_all_direct_runner.py create mode 100644 scripts/retry_failed_wehago_accounts.py create mode 100644 scripts/retry_failed_wehago_accounts_direct.py create mode 100644 scripts/test_wehago_month_bundle_shadow.py create mode 100644 scripts/test_wehago_row_allocator_shadow.py create mode 100644 scripts/validate_wehago_anchor_projection.py create mode 100644 scripts/validate_wehago_projection_shadow.py create mode 100644 scripts/validate_wehago_recheck_regressions.py create mode 100644 scripts/verify_hanmac_all_direct.py create mode 100644 scripts/wehago_ledger_api_download.py create mode 100644 templates/annual_gap_analysis.html diff --git a/.gitignore b/.gitignore index d010cba..386bfb4 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,15 @@ scripts/.chrome-wehago-profile/ runtime_cache/ intranet-runtime/ .dev-state/ +reports/ +static/reports/ +runtime_diagnostics/ +.local-tools/ +*.pdf +*.xls +*.xlsx +WORK_SUMMARY_*.md +DB_HEALTH_CHECK_*.md +backup_snapshots/**/*.md +reports/hanmac_related_party_loans_*.xlsx +reports/wehago_account_fixes/ diff --git a/DB_HEALTH_CHECK_20260409.md b/DB_HEALTH_CHECK_20260409.md deleted file mode 100644 index 1bc99af..0000000 --- a/DB_HEALTH_CHECK_20260409.md +++ /dev/null @@ -1,169 +0,0 @@ -# DB Health Check 2026-04-09 - -## Summary - -- Current SQLite size and row counts are still within a manageable range for this app. -- Main medium-term risks were: - - concurrent writes causing `database is locked` - - all users sharing one `project_page_state` row - - growing scan cost around billing-link and project-status entry access -- These were addressed without data loss. - -## Current Scale - -- `transactions`: about 49k rows -- `project_contract_info`: 760 rows -- `project_billing_entries`: 1,774 rows -- `project_related_links`: 1,276 rows -- `project_status`: few rows now, but each row can contain many logical sub-entries - -## Improvements Applied - -### 1. SQLite concurrency and durability tuning - -Applied on every DB connection in [main.py](/home/b17301/my-intranet-app/main.py#L39): - -- `PRAGMA journal_mode=WAL` -- `PRAGMA synchronous=NORMAL` -- `PRAGMA foreign_keys=ON` -- `PRAGMA busy_timeout=5000` -- `PRAGMA temp_store=MEMORY` - -Effect: - -- better concurrent read/write behavior -- lower chance of write collisions during multi-user editing -- safer long-running usage than SQLite defaults - -### 2. Added indexes for growth hotspots - -Added in [main.py](/home/b17301/my-intranet-app/main.py#L119): - -- `transactions` - - `idx_transactions_support_category` - - `idx_transactions_support_account` - - `idx_transactions_source_file` - - `idx_transactions_updated_at` -- `project_billing_entries` - - `idx_project_billing_entries_raw_code` - - `idx_project_billing_entries_round_code` - - `idx_project_billing_entries_source_file` - - `idx_project_billing_entries_updated_at` -- `project_related_links` - - `idx_project_related_links_related` -- `project_page_state` - - `idx_project_page_state_page_session` - -Effect: - -- faster changed-round grouping -- better support/account based project aggregations -- faster source-file based reimport paths -- safer scaling as billing and transaction history grows - -### 3. Page state changed from shared row to session-scoped rows - -The old structure used one shared record: - -- `project_page_state(page_key PRIMARY KEY, ...)` - -This meant different users could overwrite each other's selected project, year, and open/closed detail state. - -It now migrates to: - -- `project_page_state(page_key, session_id, ...)` -- primary key: `(page_key, session_id)` - -Relevant code: - -- schema migration: [main.py](/home/b17301/my-intranet-app/main.py#L264) -- load/save logic: [main.py](/home/b17301/my-intranet-app/main.py#L1970) -- API: [main.py](/home/b17301/my-intranet-app/main.py#L3473) -- browser session id: [templates/base.html](/home/b17301/my-intranet-app/templates/base.html#L538) -- client page-state save/load: [templates/projects.html](/home/b17301/my-intranet-app/templates/projects.html#L4880) - -Effect: - -- browser A and browser B no longer fight over one shared project-search state - -### 4. Project status JSON blobs normalized into child tables - -Previously, logical row collections were stored only inside JSON columns in `project_status`: - -- `collection_entries_json` -- `task_plan_entries_json` -- `exec_budget_entries_json` -- `actual_input_entries_json` - -This has now been normalized into child tables: - -- `project_collection_entries` -- `project_task_plan_entries` -- `project_exec_budget_entries` -- `project_actual_input_entries` - -Relevant code: - -- row extraction and migration helpers: [main.py](/home/b17301/my-intranet-app/main.py#L987) -- child-table schema: [main.py](/home/b17301/my-intranet-app/main.py#L273) -- migration from legacy JSON: [main.py](/home/b17301/my-intranet-app/main.py#L1201) -- project status read paths: [main.py](/home/b17301/my-intranet-app/main.py#L2143) -- project status save path: [main.py](/home/b17301/my-intranet-app/main.py#L3323) - -Effect: - -- less dependence on large JSON blobs for active reads -- cleaner future path for per-section editing -- safer long-term maintainability - -## Backward Compatibility - -Legacy JSON columns are still kept in `project_status` for compatibility and rollback safety. - -Current behavior: - -- reads prefer normalized child rows -- if child rows do not exist, legacy JSON/scalar fallback still works -- writes update both: - - scalar summary fields - - legacy JSON cache - - normalized child rows - -This avoids data loss during migration. - -## Remaining Structural Risk - -The biggest remaining architectural limitation is: - -- `project_status` still acts as one large parent record for many independently editable sections - -So while row collections are normalized now, parent-level fields such as: - -- project type -- expected rates -- contract amount -- dates -- notes - -still live together in one row and one update flow. - -This is acceptable for now, but if many users edit the same project simultaneously, the next best improvement would be: - -1. split edit APIs by section -2. add per-section revision tracking -3. optionally move more parent fields into section-specific tables - -## Recommendation - -Current DB can continue operating efficiently with the applied changes. - -Recommended next step if the app keeps expanding: - -- introduce section-level save endpoints for - - collection - - task plan - - exec budget - - actual input - - project metadata - -That would reduce cross-section write conflicts even more. diff --git a/WORK_SUMMARY_20260408.md b/WORK_SUMMARY_20260408.md deleted file mode 100644 index de2a715..0000000 --- a/WORK_SUMMARY_20260408.md +++ /dev/null @@ -1,23 +0,0 @@ -# 작업 요약 - -작업일: 2026-04-08 - -## 핵심 변경 - -- 프로젝트 검색 성능 저하 구간을 줄이기 위해 검색 목록 렌더 흐름을 정리하고 표시 개수를 제한했습니다. -- 프로젝트 정보 페이지의 상세 레이아웃을 재구성해 상단 정보 카드의 중첩 박스를 제거하고 주요 지표 배치를 정리했습니다. -- 계획 대비 실제 비교에서 인건비, 외주비, 제경비, A/S비, 판관비 세부 로직과 실제 집행 합산 기준을 여러 차례 보정했습니다. -- 실투입 관리, 실행예산계획, 과업수행계획 입력 UI와 저장 구조를 정리했습니다. -- 대시보드 상단을 재구성해 사업현황 요약과 수금/지출 구성 그래프를 다시 배치했습니다. -- 프로젝트 페이지 상태 저장은 버튼 클릭 시 DB에 저장되도록 연결했습니다. - -## 주요 파일 - -- `main.py` -- `templates/base.html` -- `templates/dashboard.html` -- `templates/projects.html` - -## 참고 - -- 템플릿 백업은 `template_backups/20260408_ko/`에 생성했습니다. diff --git a/WORK_SUMMARY_20260409.md b/WORK_SUMMARY_20260409.md deleted file mode 100644 index ba9cb58..0000000 --- a/WORK_SUMMARY_20260409.md +++ /dev/null @@ -1,50 +0,0 @@ -# 작업 요약 2026-04-09 - -## 이번 반영 범위 - -- 프로젝트 정보 페이지 안정화 - - 저장 후 프로젝트 정보 화면이 비거나 모달이 갑자기 닫히는 문제 구조 개선 - - 프로젝트 검색/상세/미계약 비용 발생 현황의 데이터 흐름 및 자동 최신화 충돌 완화 - - 바로가기, 연관 프로젝트, 세부 내역 정렬/표시 개선 - -- 사업현황 추가/수정 저장 구조 보강 - - 프로젝트 저장을 DB 기준으로 즉시 반영되도록 보강 - - 수금정보 분류값 정리 - - 기성구분: `선급금 / 기성금 / 준공금` - - 청구구분: `계약분 / 기타` - - 실행예산/실투입/예상 배분 설정 연계 보강 - -- 계약/청구/변경계약 데이터 반영 - - 계약현황, 기성청구현황, 변경계약금액현황(총괄/차수) 파일을 DB에 반영 - - 변경차수/보완/연계 프로젝트 자동 연결 로직 보강 - - 본계약과 연결 가능한 건은 상세 페이지와 연관 프로젝트 태그/집행내역에 합산 반영 - -- DB 구조 및 안정성 개선 - - 설정성 하드코딩 일부를 DB 설정 테이블로 이동 - - 프로젝트 입력 데이터의 섹션 분리 구조 확장 - - 건강 점검 문서 추가: `DB_HEALTH_CHECK_20260409.md` - -- 대시보드 / 연도별 수익·비용 UI 개선 - - 대시보드 카드/그래프 구조 정리 - - 연도별 수익/비용 그래프 크기, 라벨, 축, 카드 활용도 개선 - - 페이지 공통 여백 구조 정리 - -## 주요 수정 파일 - -- `main.py` -- `templates/projects.html` -- `templates/annual_summary.html` -- `templates/base.html` -- `templates/dashboard.html` -- `templates/index.html` -- `data.db` - -## 참고 데이터 파일 - -- `변경계약금액현황(회계)_총괄_20210101_20260409_260409.xlsx` -- `변경계약금액현황(회계)_차수_20210101_20260409_260409.xlsx` - -## 비고 - -- SQLite 기반 운영은 현재 데이터 규모에서는 가능하지만, 동시 작업과 화면 상태 저장은 계속 점검이 필요함 -- `data.db-wal`, `data.db-shm` 같은 런타임 임시 파일은 커밋 대상에서 제외함 diff --git a/backup_snapshots/20260410_0900/DB_HEALTH_CHECK_20260409.md b/backup_snapshots/20260410_0900/DB_HEALTH_CHECK_20260409.md deleted file mode 100644 index 1bc99af..0000000 --- a/backup_snapshots/20260410_0900/DB_HEALTH_CHECK_20260409.md +++ /dev/null @@ -1,169 +0,0 @@ -# DB Health Check 2026-04-09 - -## Summary - -- Current SQLite size and row counts are still within a manageable range for this app. -- Main medium-term risks were: - - concurrent writes causing `database is locked` - - all users sharing one `project_page_state` row - - growing scan cost around billing-link and project-status entry access -- These were addressed without data loss. - -## Current Scale - -- `transactions`: about 49k rows -- `project_contract_info`: 760 rows -- `project_billing_entries`: 1,774 rows -- `project_related_links`: 1,276 rows -- `project_status`: few rows now, but each row can contain many logical sub-entries - -## Improvements Applied - -### 1. SQLite concurrency and durability tuning - -Applied on every DB connection in [main.py](/home/b17301/my-intranet-app/main.py#L39): - -- `PRAGMA journal_mode=WAL` -- `PRAGMA synchronous=NORMAL` -- `PRAGMA foreign_keys=ON` -- `PRAGMA busy_timeout=5000` -- `PRAGMA temp_store=MEMORY` - -Effect: - -- better concurrent read/write behavior -- lower chance of write collisions during multi-user editing -- safer long-running usage than SQLite defaults - -### 2. Added indexes for growth hotspots - -Added in [main.py](/home/b17301/my-intranet-app/main.py#L119): - -- `transactions` - - `idx_transactions_support_category` - - `idx_transactions_support_account` - - `idx_transactions_source_file` - - `idx_transactions_updated_at` -- `project_billing_entries` - - `idx_project_billing_entries_raw_code` - - `idx_project_billing_entries_round_code` - - `idx_project_billing_entries_source_file` - - `idx_project_billing_entries_updated_at` -- `project_related_links` - - `idx_project_related_links_related` -- `project_page_state` - - `idx_project_page_state_page_session` - -Effect: - -- faster changed-round grouping -- better support/account based project aggregations -- faster source-file based reimport paths -- safer scaling as billing and transaction history grows - -### 3. Page state changed from shared row to session-scoped rows - -The old structure used one shared record: - -- `project_page_state(page_key PRIMARY KEY, ...)` - -This meant different users could overwrite each other's selected project, year, and open/closed detail state. - -It now migrates to: - -- `project_page_state(page_key, session_id, ...)` -- primary key: `(page_key, session_id)` - -Relevant code: - -- schema migration: [main.py](/home/b17301/my-intranet-app/main.py#L264) -- load/save logic: [main.py](/home/b17301/my-intranet-app/main.py#L1970) -- API: [main.py](/home/b17301/my-intranet-app/main.py#L3473) -- browser session id: [templates/base.html](/home/b17301/my-intranet-app/templates/base.html#L538) -- client page-state save/load: [templates/projects.html](/home/b17301/my-intranet-app/templates/projects.html#L4880) - -Effect: - -- browser A and browser B no longer fight over one shared project-search state - -### 4. Project status JSON blobs normalized into child tables - -Previously, logical row collections were stored only inside JSON columns in `project_status`: - -- `collection_entries_json` -- `task_plan_entries_json` -- `exec_budget_entries_json` -- `actual_input_entries_json` - -This has now been normalized into child tables: - -- `project_collection_entries` -- `project_task_plan_entries` -- `project_exec_budget_entries` -- `project_actual_input_entries` - -Relevant code: - -- row extraction and migration helpers: [main.py](/home/b17301/my-intranet-app/main.py#L987) -- child-table schema: [main.py](/home/b17301/my-intranet-app/main.py#L273) -- migration from legacy JSON: [main.py](/home/b17301/my-intranet-app/main.py#L1201) -- project status read paths: [main.py](/home/b17301/my-intranet-app/main.py#L2143) -- project status save path: [main.py](/home/b17301/my-intranet-app/main.py#L3323) - -Effect: - -- less dependence on large JSON blobs for active reads -- cleaner future path for per-section editing -- safer long-term maintainability - -## Backward Compatibility - -Legacy JSON columns are still kept in `project_status` for compatibility and rollback safety. - -Current behavior: - -- reads prefer normalized child rows -- if child rows do not exist, legacy JSON/scalar fallback still works -- writes update both: - - scalar summary fields - - legacy JSON cache - - normalized child rows - -This avoids data loss during migration. - -## Remaining Structural Risk - -The biggest remaining architectural limitation is: - -- `project_status` still acts as one large parent record for many independently editable sections - -So while row collections are normalized now, parent-level fields such as: - -- project type -- expected rates -- contract amount -- dates -- notes - -still live together in one row and one update flow. - -This is acceptable for now, but if many users edit the same project simultaneously, the next best improvement would be: - -1. split edit APIs by section -2. add per-section revision tracking -3. optionally move more parent fields into section-specific tables - -## Recommendation - -Current DB can continue operating efficiently with the applied changes. - -Recommended next step if the app keeps expanding: - -- introduce section-level save endpoints for - - collection - - task plan - - exec budget - - actual input - - project metadata - -That would reduce cross-section write conflicts even more. diff --git a/backup_snapshots/20260410_0900/WORK_SUMMARY_20260408.md b/backup_snapshots/20260410_0900/WORK_SUMMARY_20260408.md deleted file mode 100644 index de2a715..0000000 --- a/backup_snapshots/20260410_0900/WORK_SUMMARY_20260408.md +++ /dev/null @@ -1,23 +0,0 @@ -# 작업 요약 - -작업일: 2026-04-08 - -## 핵심 변경 - -- 프로젝트 검색 성능 저하 구간을 줄이기 위해 검색 목록 렌더 흐름을 정리하고 표시 개수를 제한했습니다. -- 프로젝트 정보 페이지의 상세 레이아웃을 재구성해 상단 정보 카드의 중첩 박스를 제거하고 주요 지표 배치를 정리했습니다. -- 계획 대비 실제 비교에서 인건비, 외주비, 제경비, A/S비, 판관비 세부 로직과 실제 집행 합산 기준을 여러 차례 보정했습니다. -- 실투입 관리, 실행예산계획, 과업수행계획 입력 UI와 저장 구조를 정리했습니다. -- 대시보드 상단을 재구성해 사업현황 요약과 수금/지출 구성 그래프를 다시 배치했습니다. -- 프로젝트 페이지 상태 저장은 버튼 클릭 시 DB에 저장되도록 연결했습니다. - -## 주요 파일 - -- `main.py` -- `templates/base.html` -- `templates/dashboard.html` -- `templates/projects.html` - -## 참고 - -- 템플릿 백업은 `template_backups/20260408_ko/`에 생성했습니다. diff --git a/backup_snapshots/20260410_0900/WORK_SUMMARY_20260409.md b/backup_snapshots/20260410_0900/WORK_SUMMARY_20260409.md deleted file mode 100644 index ba9cb58..0000000 --- a/backup_snapshots/20260410_0900/WORK_SUMMARY_20260409.md +++ /dev/null @@ -1,50 +0,0 @@ -# 작업 요약 2026-04-09 - -## 이번 반영 범위 - -- 프로젝트 정보 페이지 안정화 - - 저장 후 프로젝트 정보 화면이 비거나 모달이 갑자기 닫히는 문제 구조 개선 - - 프로젝트 검색/상세/미계약 비용 발생 현황의 데이터 흐름 및 자동 최신화 충돌 완화 - - 바로가기, 연관 프로젝트, 세부 내역 정렬/표시 개선 - -- 사업현황 추가/수정 저장 구조 보강 - - 프로젝트 저장을 DB 기준으로 즉시 반영되도록 보강 - - 수금정보 분류값 정리 - - 기성구분: `선급금 / 기성금 / 준공금` - - 청구구분: `계약분 / 기타` - - 실행예산/실투입/예상 배분 설정 연계 보강 - -- 계약/청구/변경계약 데이터 반영 - - 계약현황, 기성청구현황, 변경계약금액현황(총괄/차수) 파일을 DB에 반영 - - 변경차수/보완/연계 프로젝트 자동 연결 로직 보강 - - 본계약과 연결 가능한 건은 상세 페이지와 연관 프로젝트 태그/집행내역에 합산 반영 - -- DB 구조 및 안정성 개선 - - 설정성 하드코딩 일부를 DB 설정 테이블로 이동 - - 프로젝트 입력 데이터의 섹션 분리 구조 확장 - - 건강 점검 문서 추가: `DB_HEALTH_CHECK_20260409.md` - -- 대시보드 / 연도별 수익·비용 UI 개선 - - 대시보드 카드/그래프 구조 정리 - - 연도별 수익/비용 그래프 크기, 라벨, 축, 카드 활용도 개선 - - 페이지 공통 여백 구조 정리 - -## 주요 수정 파일 - -- `main.py` -- `templates/projects.html` -- `templates/annual_summary.html` -- `templates/base.html` -- `templates/dashboard.html` -- `templates/index.html` -- `data.db` - -## 참고 데이터 파일 - -- `변경계약금액현황(회계)_총괄_20210101_20260409_260409.xlsx` -- `변경계약금액현황(회계)_차수_20210101_20260409_260409.xlsx` - -## 비고 - -- SQLite 기반 운영은 현재 데이터 규모에서는 가능하지만, 동시 작업과 화면 상태 저장은 계속 점검이 필요함 -- `data.db-wal`, `data.db-shm` 같은 런타임 임시 파일은 커밋 대상에서 제외함 diff --git a/main.py b/main.py index 30df8ac..936421f 100644 --- a/main.py +++ b/main.py @@ -17,13 +17,18 @@ import time import tempfile import uuid import zipfile +from contextlib import nullcontext +from http.cookiejar import CookieJar from io import BytesIO from datetime import date, datetime, timedelta, timezone from decimal import Decimal, InvalidOperation, ROUND_HALF_UP +from difflib import SequenceMatcher from functools import lru_cache +from html.parser import HTMLParser from pathlib import Path from typing import Any, Mapping, Sequence -from urllib.parse import parse_qs, quote_plus, unquote_plus +from urllib.parse import parse_qs, quote_plus, unquote_plus, urlencode, urljoin, urlparse +from urllib.request import HTTPCookieProcessor, Request as UrlRequest, build_opener import uvicorn from fastapi import FastAPI, File, Request, UploadFile @@ -50,7 +55,10 @@ from runtime_config import ( ) from wehago_compare import ( QUERY_PROJECTION_VERSION, + _active_status_projection_run_matches_current_sources, _clear_compare_runtime_caches, + _discover_available_fiscal_years, + _get_compare_snapshot_state, _group_has_offset_tax_invoice_structure, _group_has_tax_invoice_cancel_signal, _load_hanmac_unconnected_source_groups, @@ -75,6 +83,7 @@ from wehago_compare import ( get_wehago_filtered_rows, import_uploaded_erp_voucher_file, init_wehago_compare_db, + ensure_wehago_canonical_projection_state, load_bridge_review_settings, request_compare_snapshot_rebuild, request_status_export_xlsx, @@ -194,7 +203,16 @@ PROJECT_ACCOUNT_BREAKDOWN_CACHE_TTL_SECONDS = 300.0 _COST_ANALYSIS_PAYLOAD_CACHE_LOCK = threading.Lock() _COST_ANALYSIS_PAYLOAD_CACHE: dict[tuple[Any, ...], dict[str, Any]] = {} COST_ANALYSIS_PAYLOAD_CACHE_TTL_SECONDS = 90.0 -COST_ANALYSIS_HANMAC_AGGREGATE_SCHEMA = "cost-analysis-period-v13-cumulative-profit-rate" +_COST_ANALYSIS_LINK_MAP_CACHE_LOCK = threading.Lock() +_COST_ANALYSIS_LINK_MAP_CACHE: dict[tuple[Any, ...], dict[str, Any]] = {} +COST_ANALYSIS_LINK_MAP_CACHE_TTL_SECONDS = 6 * 60 * 60.0 +_COST_ANALYSIS_DETAIL_CACHE_LOCK = threading.Lock() +_COST_ANALYSIS_DETAIL_CACHE: dict[tuple[Any, ...], dict[str, Any]] = {} +COST_ANALYSIS_DETAIL_CACHE_TTL_SECONDS = 10 * 60.0 +COST_ANALYSIS_FINANCIAL_LOGIC_VERSION = "cost-analysis-financial-v44-benefit-rnd-display" +COST_ANALYSIS_LINK_LOGIC_VERSION = "cost-analysis-link-v5-satis-display-names" +COST_ANALYSIS_H_PROJECT_MAPPING_VERSION = "h-code-confirmed-project-map-v1" +COST_ANALYSIS_HANMAC_AGGREGATE_SCHEMA = "cost-analysis-period-v28-benefit-rnd-display" app = FastAPI() app.add_middleware(GZipMiddleware, minimum_size=1200, compresslevel=5) @@ -1341,6 +1359,20 @@ def upsert_admin_user(payload: dict[str, Any]) -> None: ) +@app.middleware("http") +async def hmbiz_process_static_no_cache_middleware(request: Request, call_next): + response = await call_next(request) + if request.url.path.startswith("/static/hm-biz-process/"): + response.headers["Cache-Control"] = "no-store, max-age=0" + response.headers["Pragma"] = "no-cache" + response.headers["Expires"] = "0" + if request.url.path == "/hanmac-browser": + response.headers["Cache-Control"] = "no-store, max-age=0" + response.headers["Pragma"] = "no-cache" + response.headers["Expires"] = "0" + return response + + @app.middleware("http") async def auth_middleware(request: Request, call_next): path = request.url.path @@ -1559,6 +1591,13 @@ def _load_projection_group_counts( *, signature_like: str | None = None, ) -> tuple[dict[str, int], int]: + def is_derived_projection_signature(value: Any) -> bool: + normalized = normalize_text(value) + return ( + "snapshot-recheck-promote" in normalized + or "db-reconciled-" in normalized + ) + active_signature = "" if signature_like and signature_like.startswith(f"{QUERY_PROJECTION_VERSION}|"): try: @@ -1577,8 +1616,8 @@ def _load_projection_group_counts( active_signature = normalize_text(active_payload.get("signature")) except Exception: active_signature = "" - if not active_signature.startswith(f"{QUERY_PROJECTION_VERSION}|"): - return {}, 0 + if not active_signature.startswith(f"{QUERY_PROJECTION_VERSION}|") or is_derived_projection_signature(active_signature): + active_signature = "" where = [ "start_year <= ?", "end_year >= ?", @@ -1597,6 +1636,8 @@ def _load_projection_group_counts( MAX(updated_at) AS max_updated_at FROM wehago_compare_query_groups WHERE {' AND '.join(where)} + AND signature NOT LIKE '%|snapshot-recheck-promote|%' + AND signature NOT LIKE '%|db-reconciled-%' GROUP BY start_year, end_year, signature ORDER BY CASE WHEN start_year = ? AND end_year = ? THEN 0 ELSE 1 END ASC, @@ -1629,6 +1670,118 @@ def _load_projection_group_counts( return counts, int(scope_row["status_count"] or 0) +def _apply_active_status_projection_card_counts( + conn: sqlite3.Connection, + metric_sections: list[dict[str, Any]], + start_year: int, + end_year: int, +) -> list[dict[str, Any]]: + status_keys = { + "hanmac_unconnected", + "erp_voucher_matched", + "erp_voucher_unmatched", + } + setting_keys = { + f"wehago_active_status_projection:{status_key}:{int(start_year)}:{int(end_year)}": status_key + for status_key in status_keys + } + if not setting_keys: + return metric_sections + placeholders = ", ".join("?" for _key in setting_keys) + rows = conn.execute( + f""" + SELECT setting_key, setting_json + FROM wehago_compare_settings + WHERE setting_key IN ({placeholders}) + """, + tuple(setting_keys.keys()), + ).fetchall() + active_by_status: dict[str, dict[str, Any]] = {} + for row in rows: + status_key = setting_keys.get(str(row["setting_key"] or "")) + if not status_key: + continue + try: + payload = json.loads(str(row["setting_json"] or "{}")) + except Exception: + continue + if isinstance(payload, dict): + active_by_status[status_key] = payload + missing_statuses = {status_key for status_key in status_keys if status_key not in active_by_status} + if missing_statuses: + try: + query_start, query_end, query_signature = ( + _latest_year_query_source(conn, int(start_year)) + if int(start_year) == int(end_year) + else (int(start_year), int(end_year), "") + ) + placeholders = ", ".join("?" for _status in missing_statuses) + params: tuple[Any, ...] + if query_signature: + where_scope = "start_year = ? AND end_year = ? AND signature = ?" + params = ( + int(query_start), + int(query_end), + query_signature, + int(start_year), + int(end_year), + *tuple(sorted(missing_statuses)), + ) + else: + where_scope = "fiscal_year BETWEEN ? AND ? AND signature LIKE ?" + params = ( + int(start_year), + int(end_year), + f"{QUERY_PROJECTION_VERSION}|%", + int(start_year), + int(end_year), + *tuple(sorted(missing_statuses)), + ) + for row in conn.execute( + f""" + SELECT status_key, COUNT(*) AS total_count, MAX(signature) AS signature + FROM wehago_compare_query_groups + WHERE {where_scope} + AND fiscal_year BETWEEN ? AND ? + AND status_key IN ({placeholders}) + GROUP BY status_key + """, + params, + ).fetchall(): + status_key = str(row["status_key"] or "") + if status_key: + active_by_status[status_key] = { + "total_count": int(row["total_count"] or 0), + "signature": str(row["signature"] or ""), + } + except Exception: + pass + if int((active_by_status.get("hanmac_unconnected") or {}).get("total_count") or 0) <= 0: + try: + active_by_status["hanmac_unconnected"] = { + "total_count": len( + _load_hanmac_unconnected_source_groups( + conn, + int(start_year), + int(end_year), + ) + ), + "signature": str((active_by_status.get("hanmac_unconnected") or {}).get("signature") or ""), + } + except Exception: + pass + for section in metric_sections: + status_key = str(section.get("key") or "") + payload = active_by_status.get(status_key) + if not payload: + continue + section["count"] = int(payload.get("total_count") or section.get("count") or 0) + section["group_count"] = int(payload.get("total_count") or section.get("group_count") or section["count"] or 0) + section["projection_signature"] = str(payload.get("signature") or section.get("projection_signature") or "") + section["count_unit"] = "ERP 전표" if status_key == "hanmac_unconnected" else "전표그룹" + return metric_sections + + def _fast_wehago_compare_summary_payload( start_year: int | None = None, end_year: int | None = None, @@ -1669,6 +1822,97 @@ def _fast_wehago_compare_summary_payload( if start_year and end_year and start_year > end_year: start_year, end_year = end_year, start_year + active_run_summary = None + if active_run_summary: + snapshot_state = {"ready": [], "stale": [], "missing": [], "queued": [], "running": [], "failed": []} + status_rows = { + int(row["fiscal_year"]): str(row["state"] or "") + for row in conn.execute( + """ + SELECT fiscal_year, state + FROM wehago_snapshot_status + WHERE fiscal_year BETWEEN ? AND ? + """, + (int(start_year or 0), int(end_year or 0)), + ).fetchall() + if int(row["fiscal_year"] or 0) > 0 + } + for year in range(int(start_year or 0), int(end_year or 0) + 1): + state = status_rows.get(year, "missing") + snapshot_state.setdefault(state, []) + snapshot_state[state].append(year) + last_action = None + last_action_row = conn.execute( + """ + SELECT id, action_type, payload_json, created_at + FROM wehago_action_history + ORDER BY id DESC + LIMIT 1 + """ + ).fetchone() + if last_action_row: + try: + action_payload = json.loads(last_action_row["payload_json"] or "{}") + except Exception: + action_payload = {} + last_action = { + "id": int(last_action_row["id"] or 0), + "action_type": str(last_action_row["action_type"] or ""), + "count": int(action_payload.get("count") or 0), + "created_at": str(last_action_row["created_at"] or ""), + } + counts = _empty_compare_metric_counts() + metric_sections = [ + { + "key": status_key, + "label": label, + "description": description, + "count": int(counts.get(status_key, 0) or 0), + "columns": [], + "rows": [], + } + for status_key, label, description in ( + ("matched", "Matched", ""), + ("ledger_only", "Unmatched", ""), + ("voucher_only", "ERP Unmatched", ""), + ("amount_mismatch", "Recheck", ""), + ("voucher_matched", "WEHAGO Voucher", ""), + ("voucher_unmatched", "WEHAGO Unmatched", ""), + ("voucher_recheck", "WEHAGO Recheck", ""), + ("voucher_excepted", "WEHAGO Excepted", ""), + ("hanmac_unconnected", "Hanmac unconnected", ""), + ("erp_voucher_matched", "HANMAC Voucher", ""), + ("erp_voucher_unmatched", "HANMAC Unmatched", ""), + ("bridge_expense_review", "2단계 비교", ""), + ) + ] + metric_sections = apply_wehago_final_status_counts_to_metric_sections(metric_sections, active_run_summary) + metric_sections = _apply_active_status_projection_card_counts( + conn, + metric_sections, + int(start_year or 0), + int(end_year or 0), + ) + return { + "selected_start_year": start_year, + "selected_end_year": end_year, + "metric_sections": metric_sections, + "wehago_final_status_summary": active_run_summary, + "last_action": last_action, + "pending": False, + "snapshot_state": snapshot_state, + "snapshot_policy": {"available_years": available_years}, + "snapshot_aggregate": { + "ready_count": len(snapshot_state["ready"]), + "stale_count": len(snapshot_state["stale"]), + "missing_count": len(snapshot_state["missing"]), + "queued_count": len(snapshot_state["queued"]), + "running_count": len(snapshot_state["running"]), + "failed_count": len(snapshot_state["failed"]), + }, + "snapshot_status_payload": None, + } + counts = _empty_compare_metric_counts() current_group_counts, projection_status_count = _load_projection_group_counts( conn, @@ -1686,14 +1930,15 @@ def _fast_wehago_compare_summary_payload( for status_key, value in current_group_counts.items(): if status_key in counts: counts[status_key] = int(value or 0) - with engine.begin() as sqlalchemy_conn: - counts["hanmac_unconnected"] = len( - _load_hanmac_unconnected_source_groups( - sqlalchemy_conn, - int(start_year or 0), - int(end_year or 0), + if int(counts.get("hanmac_unconnected", 0) or 0) <= 0: + with engine.begin() as sqlalchemy_conn: + counts["hanmac_unconnected"] = len( + _load_hanmac_unconnected_source_groups( + sqlalchemy_conn, + int(start_year or 0), + int(end_year or 0), + ) ) - ) if current_group_counts: scope_row = conn.execute( """ @@ -1742,50 +1987,6 @@ def _fast_wehago_compare_summary_payload( if status_key in counts: counts[status_key] = int(row[1] or 0) - if projection_status_count < 5: - cached_summary = conn.execute( - """ - SELECT payload_json - FROM wehago_summary_range_cache - WHERE start_year = ? - AND end_year = ? - ORDER BY updated_at DESC, created_at DESC - LIMIT 1 - """, - (int(start_year or 0), int(end_year or 0)), - ).fetchone() - if cached_summary and cached_summary["payload_json"]: - try: - cached_payload = json.loads(str(cached_summary["payload_json"])) - cached_counts = cached_payload.get("counts") if isinstance(cached_payload, dict) else None - if isinstance(cached_counts, dict): - for status_key in counts: - counts[status_key] = int(cached_counts.get(status_key, counts[status_key]) or 0) - except Exception: - pass - - cached_summary = conn.execute( - """ - SELECT payload_json - FROM wehago_summary_range_cache - WHERE start_year = ? - AND end_year = ? - ORDER BY updated_at DESC, created_at DESC - LIMIT 1 - """, - (int(start_year or 0), int(end_year or 0)), - ).fetchone() - if cached_summary and cached_summary["payload_json"]: - try: - cached_payload = json.loads(str(cached_summary["payload_json"])) - cached_counts = cached_payload.get("counts") if isinstance(cached_payload, dict) else None - if isinstance(cached_counts, dict): - for status_key in ("matched", "ledger_only", "voucher_only", "amount_mismatch", "bridge_expense_review"): - if int(counts.get(status_key, 0) or 0) == 0: - counts[status_key] = int(cached_counts.get(status_key, 0) or 0) - except Exception: - pass - if any(int(counts.get(status_key, 0) or 0) for status_key in ("voucher_matched", "erp_voucher_matched", "voucher_unmatched", "erp_voucher_unmatched", "voucher_recheck", "hanmac_unconnected")): for status_key in ("matched", "ledger_only", "voucher_only", "amount_mismatch"): if int(counts.get(status_key, 0) or 0) != 0: @@ -1801,37 +2002,30 @@ def _fast_wehago_compare_summary_payload( ).fetchone() counts[status_key] = int((row or [0])[0] or 0) - try: - resolved_counts, count_pending = get_dashboard_metric_counts_nonblocking( - engine, + count_pending = False + if not has_current_projection_counts: + try: + resolved_counts, count_pending = get_dashboard_metric_counts_nonblocking( + engine, + int(start_year or 0), + int(end_year or 0), + ) + if any(int(resolved_counts.get(status_key, 0) or 0) for status_key in counts): + for status_key in counts: + resolved_value = int(resolved_counts.get(status_key, 0) or 0) + if resolved_value or int(counts.get(status_key, 0) or 0) == 0: + counts[status_key] = resolved_value + except Exception: + count_pending = False + + with engine.begin() as sqlalchemy_conn: + snapshot_state = _get_compare_snapshot_state( + sqlalchemy_conn, int(start_year or 0), int(end_year or 0), ) - if any(int(resolved_counts.get(status_key, 0) or 0) for status_key in counts): - for status_key in counts: - counts[status_key] = int(resolved_counts.get(status_key, counts[status_key]) or 0) - except Exception: - count_pending = False - - snapshot_state = {"ready": [], "stale": [], "missing": [], "queued": [], "running": [], "failed": []} - status_rows = { - int(row["fiscal_year"]): str(row["state"] or "") - for row in conn.execute( - """ - SELECT fiscal_year, state - FROM wehago_snapshot_status - WHERE fiscal_year BETWEEN ? AND ? - """, - (int(start_year or 0), int(end_year or 0)), - ).fetchall() - if int(row["fiscal_year"] or 0) > 0 - } - for year in range(int(start_year or 0), int(end_year or 0) + 1): - state = status_rows.get(year, "missing") - snapshot_state.setdefault(state, []) - snapshot_state[state].append(year) pending = bool(snapshot_state["missing"] or snapshot_state["stale"] or snapshot_state["queued"] or snapshot_state["running"]) - pending = pending or bool(count_pending) + pending = pending or (bool(count_pending) and not has_current_projection_counts) last_action = None last_action_row = conn.execute( @@ -1879,12 +2073,24 @@ def _fast_wehago_compare_summary_payload( ) ] with engine.begin() as sqlalchemy_conn: + projection_repair = ensure_wehago_canonical_projection_state( + sqlalchemy_conn, + start_year, + end_year, + repair=True, + ) final_status_summary = get_wehago_final_status_summary_from_conn( sqlalchemy_conn, start_year, end_year, counts, ) + if projection_repair.get("needs_query_projection_rebuild"): + pending = True + if str(final_status_summary.get("source") or "") == "active_status_projection_missing": + pending = True + if projection_repair.get("repaired"): + final_status_summary["auto_repair"] = projection_repair metric_sections = apply_wehago_final_status_counts_to_metric_sections(metric_sections, final_status_summary) return { "selected_start_year": start_year, @@ -2803,6 +3009,9 @@ def init_db() -> None: dept_name TEXT DEFAULT '', work_name TEXT DEFAULT '', amount REAL DEFAULT 0, + source_support_dept_code TEXT DEFAULT '', + source_project_code TEXT DEFAULT '', + source_revision_id INTEGER DEFAULT 0, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ) """ @@ -2832,6 +3041,9 @@ def init_db() -> None: account_code TEXT DEFAULT '', account_name TEXT DEFAULT '', amount REAL DEFAULT 0, + source_support_dept_code TEXT DEFAULT '', + source_project_code TEXT DEFAULT '', + source_revision_id INTEGER DEFAULT 0, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ) """ @@ -2845,6 +3057,283 @@ def init_db() -> None: """ ) ) + conn.execute( + text( + """ + CREATE TABLE IF NOT EXISTS satis_project_mapping ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + erp_project_code TEXT NOT NULL, + erp_project_name TEXT DEFAULT '', + support_dept_code TEXT DEFAULT '', + mapping_status TEXT DEFAULT 'pending', + mapping_basis TEXT DEFAULT '', + manual_override INTEGER DEFAULT 0, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + UNIQUE (erp_project_code) + ) + """ + ) + ) + conn.execute( + text( + """ + CREATE INDEX IF NOT EXISTS idx_satis_project_mapping_support_code + ON satis_project_mapping (support_dept_code) + """ + ) + ) + conn.execute( + text( + """ + CREATE TABLE IF NOT EXISTS satis_project_code_links ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + local_project_code TEXT NOT NULL, + local_project_name TEXT DEFAULT '', + project_kind TEXT DEFAULT '', + own_master_project_code TEXT DEFAULT '', + own_master_project_name TEXT DEFAULT '', + linked_main_project_code TEXT DEFAULT '', + linked_main_project_name TEXT DEFAULT '', + cost_project_code TEXT DEFAULT '', + cost_project_name TEXT DEFAULT '', + cost_kind TEXT DEFAULT '', + pm_department_name TEXT DEFAULT '', + is_joint_project TEXT DEFAULT '', + is_tax_exempt TEXT DEFAULT '', + is_active INTEGER DEFAULT 1, + mapping_status TEXT DEFAULT 'confirmed', + mapping_source TEXT DEFAULT '', + source_file TEXT DEFAULT '', + raw_payload_json TEXT DEFAULT '{}', + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + UNIQUE (local_project_code) + ) + """ + ) + ) + conn.execute( + text( + """ + CREATE INDEX IF NOT EXISTS idx_satis_project_code_links_own_master + ON satis_project_code_links (own_master_project_code) + """ + ) + ) + conn.execute( + text( + """ + CREATE INDEX IF NOT EXISTS idx_satis_project_code_links_linked_main + ON satis_project_code_links (linked_main_project_code) + """ + ) + ) + conn.execute( + text( + """ + CREATE INDEX IF NOT EXISTS idx_satis_project_code_links_cost_code + ON satis_project_code_links (cost_project_code) + """ + ) + ) + conn.execute( + text( + """ + CREATE TABLE IF NOT EXISTS satis_project_budget_revisions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + source_system TEXT DEFAULT 'satis', + project_code TEXT NOT NULL, + support_dept_code TEXT DEFAULT '', + project_name TEXT DEFAULT '', + budget_type TEXT NOT NULL, + revision_no TEXT DEFAULT '', + revision_name TEXT DEFAULT '', + approval_status TEXT DEFAULT '', + is_approved INTEGER DEFAULT 0, + is_latest INTEGER DEFAULT 0, + written_at TEXT DEFAULT '', + approved_at TEXT DEFAULT '', + source_updated_at TEXT DEFAULT '', + source_key TEXT NOT NULL, + source_hash TEXT DEFAULT '', + raw_payload_json TEXT DEFAULT '', + synced_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + UNIQUE (source_system, budget_type, source_key) + ) + """ + ) + ) + conn.execute( + text( + """ + CREATE INDEX IF NOT EXISTS idx_satis_project_budget_revisions_project + ON satis_project_budget_revisions (project_code, support_dept_code, budget_type, revision_no) + """ + ) + ) + conn.execute( + text( + """ + CREATE TABLE IF NOT EXISTS satis_project_task_plan_budget_lines ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + revision_id INTEGER NOT NULL, + line_no INTEGER NOT NULL DEFAULT 0, + group_name TEXT DEFAULT '', + dept_code TEXT DEFAULT '', + dept_name TEXT DEFAULT '', + work_code TEXT DEFAULT '', + work_name TEXT DEFAULT '', + amount REAL DEFAULT 0, + currency TEXT DEFAULT 'KRW', + source_line_key TEXT DEFAULT NULL, + raw_payload_json TEXT DEFAULT '', + synced_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + UNIQUE (revision_id, source_line_key), + FOREIGN KEY (revision_id) REFERENCES satis_project_budget_revisions(id) ON DELETE CASCADE + ) + """ + ) + ) + conn.execute( + text( + """ + CREATE INDEX IF NOT EXISTS idx_satis_task_plan_budget_lines_revision + ON satis_project_task_plan_budget_lines (revision_id, line_no) + """ + ) + ) + conn.execute( + text( + """ + CREATE TABLE IF NOT EXISTS satis_project_exec_budget_lines ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + revision_id INTEGER NOT NULL, + line_no INTEGER NOT NULL DEFAULT 0, + group_name TEXT DEFAULT '', + grade TEXT DEFAULT '', + hours TEXT DEFAULT '', + rate_year TEXT DEFAULT '', + unit_rate REAL DEFAULT 0, + dept_code TEXT DEFAULT '', + dept_name TEXT DEFAULT '', + work_code TEXT DEFAULT '', + work_name TEXT DEFAULT '', + account_code TEXT DEFAULT '', + account_name TEXT DEFAULT '', + amount REAL DEFAULT 0, + currency TEXT DEFAULT 'KRW', + source_line_key TEXT DEFAULT NULL, + raw_payload_json TEXT DEFAULT '', + synced_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + UNIQUE (revision_id, source_line_key), + FOREIGN KEY (revision_id) REFERENCES satis_project_budget_revisions(id) ON DELETE CASCADE + ) + """ + ) + ) + conn.execute( + text( + """ + CREATE INDEX IF NOT EXISTS idx_satis_exec_budget_lines_revision + ON satis_project_exec_budget_lines (revision_id, line_no) + """ + ) + ) + conn.execute( + text( + """ + CREATE TABLE IF NOT EXISTS satis_project_budget_raw_rows ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + source_system TEXT DEFAULT 'satis', + source_database TEXT NOT NULL, + source_table TEXT NOT NULL, + source_row_index INTEGER NOT NULL DEFAULT 0, + budget_type TEXT DEFAULT '', + project_code TEXT DEFAULT '', + project_name TEXT DEFAULT '', + revision_no TEXT DEFAULT '', + approval_status TEXT DEFAULT '', + amount_total REAL DEFAULT 0, + amount_values_json TEXT DEFAULT '', + inferred_columns_json TEXT DEFAULT '', + raw_payload_json TEXT DEFAULT '', + source_hash TEXT NOT NULL, + synced_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + UNIQUE (source_hash) + ) + """ + ) + ) + conn.execute( + text( + """ + CREATE INDEX IF NOT EXISTS idx_satis_project_budget_raw_rows_project + ON satis_project_budget_raw_rows (project_code, budget_type, source_database, source_table) + """ + ) + ) + conn.execute( + text( + """ + CREATE TABLE IF NOT EXISTS satis_project_budget_projection_status ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + support_dept_code TEXT NOT NULL, + project_code TEXT DEFAULT '', + project_name TEXT DEFAULT '', + budget_type TEXT NOT NULL, + revision_id INTEGER NOT NULL DEFAULT 0, + revision_no TEXT DEFAULT '', + approval_status TEXT DEFAULT '', + projection_mode TEXT NOT NULL DEFAULT 'approved_latest', + is_provisional INTEGER DEFAULT 0, + line_count INTEGER DEFAULT 0, + amount_total REAL DEFAULT 0, + note TEXT DEFAULT '', + projected_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + UNIQUE (support_dept_code, budget_type) + ) + """ + ) + ) + conn.execute( + text( + """ + CREATE INDEX IF NOT EXISTS idx_satis_budget_projection_status_revision + ON satis_project_budget_projection_status (revision_id, projection_mode) + """ + ) + ) + conn.execute( + text( + """ + CREATE TABLE IF NOT EXISTS satis_project_budget_web_captures ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + capture_key TEXT NOT NULL, + source_url TEXT NOT NULL, + request_method TEXT DEFAULT 'GET', + http_status INTEGER DEFAULT 0, + final_url TEXT DEFAULT '', + page_title TEXT DEFAULT '', + matched_keywords TEXT DEFAULT '', + internal_links_json TEXT DEFAULT '[]', + amount_candidates_json TEXT DEFAULT '[]', + body_preview TEXT DEFAULT '', + body_hash TEXT DEFAULT '', + captured_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + UNIQUE (capture_key) + ) + """ + ) + ) + conn.execute( + text( + """ + CREATE INDEX IF NOT EXISTS idx_satis_budget_web_captures_hash + ON satis_project_budget_web_captures (body_hash, captured_at) + """ + ) + ) conn.execute( text( """ @@ -3408,12 +3897,31 @@ def init_db() -> None: } if "rate_year" not in exec_budget_entry_columns: conn.execute(text("ALTER TABLE project_exec_budget_entries ADD COLUMN rate_year TEXT DEFAULT ''")) + for column_name, column_type in { + "source_support_dept_code": "TEXT DEFAULT ''", + "source_project_code": "TEXT DEFAULT ''", + "source_revision_id": "INTEGER DEFAULT 0", + }.items(): + if column_name not in exec_budget_entry_columns: + conn.execute(text(f"ALTER TABLE project_exec_budget_entries ADD COLUMN {column_name} {column_type}")) + task_plan_entry_columns = { + row[1] + for row in conn.execute(text("PRAGMA table_info(project_task_plan_entries)")).fetchall() + } + for column_name, column_type in { + "source_support_dept_code": "TEXT DEFAULT ''", + "source_project_code": "TEXT DEFAULT ''", + "source_revision_id": "INTEGER DEFAULT 0", + }.items(): + if column_name not in task_plan_entry_columns: + conn.execute(text(f"ALTER TABLE project_task_plan_entries ADD COLUMN {column_name} {column_type}")) actual_input_entry_columns = { row[1] for row in conn.execute(text("PRAGMA table_info(project_actual_input_entries)")).fetchall() } if "rate_year" not in actual_input_entry_columns: conn.execute(text("ALTER TABLE project_actual_input_entries ADD COLUMN rate_year TEXT DEFAULT ''")) + sync_satis_project_code_register(conn) migrate_project_status_entries(conn) migrate_project_basic_info(conn) ensure_auth_schema(conn) @@ -4166,10 +4674,20 @@ def import_billing_status_workbook(workbook: Any, source_file: str) -> int: current.get("raw_project_code"), default_prefix=round_prefix, ) + raw_common_tokens = { + normalize_text(current.get("round_code")).replace(" ", "").upper(), + normalize_text(current.get("raw_project_code")).replace(" ", "").upper(), + normalize_text(current.get("support_dept_name")).replace(" ", "").upper(), + } + is_common_billing = any( + token == "ZZZZZZ" or token in {"공통", "공통매출", "공통청구"} or token.startswith("공통매출") + for token in raw_common_tokens + if token + ) # Billing workbook stores the parent contract code in raw_project_code # and the actual charge/collection project code in round_code. # Prefer round_code when present so each sub-project keeps its own billing history. - support_dept_code = normalized_round_code or normalized_raw_project_code + support_dept_code = "ZZZZZZ" if is_common_billing else normalized_round_code or normalized_raw_project_code if not support_dept_code: continue @@ -4561,6 +5079,27 @@ def sync_auto_project_related_links() -> None: """ ) ).mappings().all() + satis_link_rows = conn.execute( + text( + """ + SELECT + local_project_code, + local_project_name, + own_master_project_code, + own_master_project_name, + linked_main_project_code, + linked_main_project_name, + cost_project_code, + cost_project_name, + mapping_status, + is_active + FROM satis_project_code_links + WHERE COALESCE(local_project_code, '') <> '' + AND COALESCE(mapping_status, '') IN ('confirmed', 'exception') + AND COALESCE(is_active, 1) = 1 + """ + ) + ).mappings().all() existing_codes = { normalize_text(row[0]) for row in conn.execute( @@ -4580,6 +5119,18 @@ def sync_auto_project_related_links() -> None: UNION SELECT DISTINCT support_dept_code FROM project_contract_change_round WHERE COALESCE(support_dept_code, '') <> '' + UNION + SELECT DISTINCT local_project_code FROM satis_project_code_links + WHERE COALESCE(local_project_code, '') <> '' + UNION + SELECT DISTINCT own_master_project_code FROM satis_project_code_links + WHERE COALESCE(own_master_project_code, '') <> '' + UNION + SELECT DISTINCT linked_main_project_code FROM satis_project_code_links + WHERE COALESCE(linked_main_project_code, '') <> '' + UNION + SELECT DISTINCT cost_project_code FROM satis_project_code_links + WHERE COALESCE(cost_project_code, '') <> '' """ ) ).fetchall() @@ -4607,6 +5158,22 @@ def sync_auto_project_related_links() -> None: SELECT support_dept_code, support_dept_name FROM project_contract_change_round WHERE COALESCE(support_dept_code, '') <> '' + UNION + SELECT local_project_code AS support_dept_code, local_project_name AS support_dept_name + FROM satis_project_code_links + WHERE COALESCE(local_project_code, '') <> '' + UNION + SELECT own_master_project_code AS support_dept_code, own_master_project_name AS support_dept_name + FROM satis_project_code_links + WHERE COALESCE(own_master_project_code, '') <> '' + UNION + SELECT linked_main_project_code AS support_dept_code, linked_main_project_name AS support_dept_name + FROM satis_project_code_links + WHERE COALESCE(linked_main_project_code, '') <> '' + UNION + SELECT cost_project_code AS support_dept_code, cost_project_name AS support_dept_name + FROM satis_project_code_links + WHERE COALESCE(cost_project_code, '') <> '' ) SELECT support_dept_code, support_dept_name FROM project_names @@ -4635,25 +5202,51 @@ def sync_auto_project_related_links() -> None: if normalize_text(row[0]) } - round_cluster_map: dict[str, set[str]] = {} cluster_map: dict[str, set[str]] = {} + for row in satis_link_rows: + local_code = normalize_text(row.get("local_project_code")).upper() + if not local_code: + continue + master_candidates = [ + normalize_text(row.get("linked_main_project_code")).upper(), + normalize_text(row.get("own_master_project_code")).upper(), + normalize_text(row.get("cost_project_code")).upper(), + ] + master_code = next( + ( + code + for code in master_candidates + if code and code != local_code and code[:1] in {"0", "9"} and code[1:].isdigit() + ), + "", + ) + if not master_code: + continue + existing_codes.update({local_code, master_code}) + project_names.setdefault(local_code, normalize_text(row.get("local_project_name"))) + project_names.setdefault( + master_code, + normalize_text(row.get("linked_main_project_name")) + or normalize_text(row.get("own_master_project_name")) + or normalize_text(row.get("cost_project_name")), + ) + cluster_map.setdefault(f"satis_code::{master_code}", set()).update({master_code, local_code}) + for row in billing_rows: base_code = normalize_text(row["support_dept_code"]) - raw_project_code = normalize_project_code( - row["raw_project_code"], - default_prefix=base_code[:1] or "Y", - ) or normalize_text(row["raw_project_code"]) + raw_project_code = normalize_actual_project_code(row["raw_project_code"]) round_code = normalize_project_code(row["round_code"], default_prefix=base_code[:1] or "Y") if not base_code: continue - cluster_key = raw_project_code or base_code - round_cluster = round_cluster_map.setdefault(cluster_key, set()) + is_total_code = raw_project_code[:1] in {"0", "9"} and raw_project_code.isdigit() + cluster_key = f"billing::{raw_project_code or base_code}" cluster = cluster_map.setdefault(cluster_key, set()) + if is_total_code: + existing_codes.add(raw_project_code) + cluster.add(raw_project_code) if base_code in existing_codes: - round_cluster.add(base_code) cluster.add(base_code) if round_code and round_code in existing_codes: - round_cluster.add(round_code) cluster.add(round_code) change_round_title_groups: dict[str, set[str]] = {} @@ -4795,6 +5388,10 @@ def sync_auto_project_related_links() -> None: link_source = "auto_change_contract" elif str(cluster_key).startswith("code_family::"): link_source = "auto_code_family" + elif str(cluster_key).startswith("billing::"): + link_source = "auto_billing" + elif str(cluster_key).startswith("satis_code::"): + link_source = "auto_satis_code" elif str(cluster_key).startswith("title_fuzzy::"): link_source = "auto_title_fuzzy" elif str(cluster_key).startswith("title::"): @@ -4821,8 +5418,15 @@ def sync_auto_project_related_links() -> None: ) ON CONFLICT(base_support_dept_code, related_support_dept_code) DO UPDATE SET link_source = CASE + WHEN COALESCE(project_related_links.link_source, 'manual') = 'manual' + THEN COALESCE(project_related_links.link_source, 'manual') + WHEN excluded.link_source = 'manual' THEN excluded.link_source + WHEN project_related_links.link_source = 'auto_satis_code' THEN project_related_links.link_source + WHEN excluded.link_source = 'auto_satis_code' THEN excluded.link_source WHEN project_related_links.link_source = 'auto_code_family' THEN project_related_links.link_source WHEN excluded.link_source = 'auto_code_family' THEN excluded.link_source + WHEN project_related_links.link_source = 'auto_billing' THEN project_related_links.link_source + WHEN excluded.link_source = 'auto_billing' THEN excluded.link_source ELSE excluded.link_source END, updated_at = CURRENT_TIMESTAMP @@ -4925,44 +5529,6 @@ def sync_auto_project_related_links() -> None: }, ) - for cluster_codes in round_cluster_map.values(): - normalized_cluster = sorted(code for code in cluster_codes if code in existing_codes) - if len(normalized_cluster) < 2: - continue - for base_code in normalized_cluster: - for related_code in normalized_cluster: - if base_code == related_code: - continue - conn.execute( - text( - """ - INSERT INTO project_related_links ( - base_support_dept_code, - related_support_dept_code, - link_source, - updated_at - ) VALUES ( - :base_support_dept_code, - :related_support_dept_code, - 'auto_round', - CURRENT_TIMESTAMP - ) - ON CONFLICT(base_support_dept_code, related_support_dept_code) DO UPDATE SET - link_source = CASE - WHEN project_related_links.link_source = 'auto_code_family' THEN project_related_links.link_source - WHEN excluded.link_source = 'auto_code_family' THEN excluded.link_source - ELSE excluded.link_source - END, - updated_at = CURRENT_TIMESTAMP - """ - ), - { - "base_support_dept_code": base_code, - "related_support_dept_code": related_code, - }, - ) - - @app.on_event("startup") def on_startup() -> None: sqlite_status = validate_sqlite_runtime() @@ -4985,6 +5551,12 @@ def _run_app_post_startup_warmup() -> None: init_wehago_compare_db(engine) except Exception as exc: logger.warning("Post-startup compare DB init skipped due to error: %s", exc) + try: + repaired = _auto_repair_wehago_projection_active_ranges() + if repaired: + logger.info("Auto-repaired WEHAGO projection state for %s range(s)", repaired) + except Exception as exc: + logger.warning("Post-startup WEHAGO projection auto-repair skipped due to error: %s", exc) try: _ensure_app_maintenance_worker() except Exception as exc: @@ -5026,6 +5598,50 @@ def _ensure_app_post_startup_warmup() -> None: _APP_POST_STARTUP_WARMUP_STARTED = True +def _auto_repair_wehago_projection_active_ranges(limit: int = 20) -> int: + ranges: set[tuple[int, int]] = set() + with engine.begin() as conn: + rows = conn.execute( + text( + """ + SELECT setting_key + FROM wehago_compare_settings + WHERE setting_key LIKE 'wehago_active_query_projection:%:%' + ORDER BY updated_at DESC + LIMIT :limit + """ + ), + {"limit": int(limit)}, + ).mappings().all() + for row in rows: + parts = normalize_text(row.get("setting_key")).split(":") + if len(parts) < 3: + continue + try: + start = int(parts[-2]) + end = int(parts[-1]) + except Exception: + continue + if start > 0 and end >= start: + ranges.add((start, end)) + if not ranges: + available_years = sorted( + { + int(year) + for year in _discover_available_fiscal_years(conn) + if int(year or 0) > 0 + } + ) + if available_years: + ranges.add((available_years[-1], available_years[-1])) + repaired_count = 0 + for start, end in sorted(ranges): + result = ensure_wehago_canonical_projection_state(conn, start, end, repair=True) + if result.get("repaired"): + repaired_count += 1 + return repaired_count + + def _safe_next_url(value: Any) -> str: next_url = unquote_plus(normalize_text(value)) if not next_url.startswith("/") or next_url.startswith("//"): @@ -5288,6 +5904,56 @@ def build_hanmac_browser_plan() -> dict[str, Any]: } +def build_hanmac_wehago_audit_sources() -> dict[str, Any]: + years = [ + {"year": 2018, "gisu": 23}, + {"year": 2019, "gisu": 24}, + {"year": 2020, "gisu": 25}, + {"year": 2021, "gisu": 26}, + {"year": 2022, "gisu": 27}, + {"year": 2023, "gisu": 28}, + {"year": 2024, "gisu": 29}, + {"year": 2025, "gisu": 30}, + ] + ledger_urls = [ + { + **item, + "url": ( + "https://smarta.wehago.com/#/smarta/account/SABK0107?sao" + f"&cno=1173867&cd_com=biz202103030006368&gisu={item['gisu']}&yminsa=2026" + f"&searchData={item['year']}0101{item['year']}1231&color=#1C90FB" + "&companyName=(%EC%A3%BC)%ED%95%9C%EB%A7%A5%EA%B8%B0%EC%88%A0&companyID=b21344" + ), + } + for item in years + ] + return { + "menus": [ + { + "name": "계정별원장", + "program": "SABK0107", + "basis": "현재 DB 적재 원천입니다.", + "keyword": "계정별원장", + }, + { + "name": "총계정원장", + "program": "", + "basis": "계정별원장 잔액과 보고서 잔액을 결산 기준으로 대조할 때 사용합니다.", + "keyword": "총계정원장", + }, + { + "name": "합계잔액시산표", + "program": "", + "basis": "기말 잔액과 손익계정 총액을 보고서 금액과 대조할 때 사용합니다.", + "keyword": "합계잔액시산표", + }, + ], + "ledger_urls": ledger_urls, + "account_codes": ["114", "137", "179", "260", "290", "901", "931", "116", "136"], + "smarta_home_url": "https://smarta.wehago.com/", + } + + def normalize_text(value: Any) -> str: if value is None: return "" @@ -6369,6 +7035,18 @@ LABOR_RATE_CATEGORY_ALIASES = { } LABOR_RATE_BASE_GRADES = {"사장", "부사장", "전무", "전무이사", "상무", "상무이사", "이사", "부장", "차장", "과장", "대리", "사원"} LABOR_RATE_DERIVED_GRADES = {"수석", "책임", "선임", "연구원"} +LABOR_RATE_GRADE_ORDER_HIGH_TO_LOW = ( + "사장", + "부사장", + "전무", + "상무", + "이사", + "부장", + "차장", + "과장", + "대리", + "사원", +) def _normalize_labor_grade_name(value: Any) -> str: @@ -6386,6 +7064,36 @@ def _normalize_labor_grade_name(value: Any) -> str: return aliases.get(grade_text, grade_text) +def _hanmac_is_researcher_grade(value: Any) -> bool: + return "연구원" in _normalize_labor_grade_name(value) + + +def _hanmac_is_system_member_record(member_record: Mapping[str, Any] | None) -> bool: + record = member_record or {} + member_no = normalize_text(record.get("member_no")).lower() + member_name = _hanmac_normalize_person_name(record.get("member_name") or record.get("name")) + login_id = normalize_text(record.get("login_id") or record.get("user_id") or record.get("account_id")).lower() + display_name = normalize_text(record.get("member_name") or record.get("name")).replace(" ", "") + explicit_system_member_nos = {"g26001", "g26002", "b24062", "office1"} + organization_account_names = {"센터_기술기획팀", "인재성장팀", "관리실"} + if member_no in explicit_system_member_nos or display_name in organization_account_names: + return True + system_tokens = ("tadmin", "admin", "test", "tester", "system", "sys", "office") + if member_no.startswith(system_tokens) or login_id.startswith(system_tokens): + return True + if member_name in {"관리자", "시스템관리자", "테스트"} or "관리자" in member_name or "테스트" in member_name: + return True + has_person_marker = any( + normalize_text(record.get(key)) + for key in ("member_grade", "dept_name", "entry_date", "leave_date") + ) + return bool(member_no and member_name == member_no and not has_person_marker) + + +def _hanmac_counts_as_member(member_record: Mapping[str, Any] | None) -> bool: + return not _hanmac_is_researcher_grade((member_record or {}).get("member_grade")) + + def _normalize_labor_rate_category(value: Any) -> str: text_value = normalize_text(value) if "감리" in text_value: @@ -6496,6 +7204,25 @@ def _resolve_labor_rate( year_candidates.append(text_value) if not year_candidates: year_candidates.append(str(datetime.now().year)) + numeric_requested_years = [ + int(year_text) + for year_text in year_candidates + if year_text.isdigit() + ] + available_years = sorted( + { + int(year_text) + for year_text in rates_by_year + if normalize_text(year_text).isdigit() + } + ) + if available_years: + requested_year = max(numeric_requested_years or [datetime.now().year]) + previous_years = [year for year in available_years if year <= requested_year] + fallback_rate_year = previous_years[-1] if previous_years else available_years[0] + fallback_rate_year_text = str(fallback_rate_year) + if fallback_rate_year_text not in year_candidates: + year_candidates.append(fallback_rate_year_text) for year_text in year_candidates: year_bucket = rates_by_year.get(year_text) or {} design_bucket = year_bucket.get("설계") or {} @@ -6511,6 +7238,14 @@ def _resolve_labor_rate( amount = normalize_amount(category_bucket.get(grade_text)) if amount: return amount + # 해당 직급의 시급이 없으면 직급 서열상 바로 위 직급부터 + # 순서대로 찾아 가장 가까운 차상위 직급 시급을 사용한다. + if grade_text in LABOR_RATE_GRADE_ORDER_HIGH_TO_LOW: + grade_index = LABOR_RATE_GRADE_ORDER_HIGH_TO_LOW.index(grade_text) + for higher_grade in reversed(LABOR_RATE_GRADE_ORDER_HIGH_TO_LOW[:grade_index]): + amount = normalize_amount(category_bucket.get(higher_grade)) + if amount: + return amount return 0.0 @@ -6553,6 +7288,8 @@ def sanitize_project_labor_amount_rows(conn: Any) -> int: for entry in exec_entries: if normalize_text(entry.get("group")) != "labor": continue + if normalize_text(entry.get("account_code")).startswith("SATIS_"): + continue hours_value = _parse_exec_hours_value(entry.get("hours")) next_amount = _resolve_labor_rate(rates, entry.get("grade"), entry.get("rate_year")) * hours_value if abs(normalize_amount(entry.get("amount")) - next_amount) > 0.5: @@ -11624,8 +12361,10 @@ def _hanmac_member_grade_lookup_signature() -> str: SELECT COUNT(*) || ':' || COALESCE(MAX(updated_at), '') FROM hanmac_aggregate_query_metrics WHERE view_mode = 'member' + AND payload_signature LIKE :compatible_signature_pattern """ - ) + ), + _cost_analysis_hanmac_signature_params(), ).scalar() joint_cache_path = BASE_DIR / "static" / "hanmac-joint-members-cache.json" try: @@ -11652,10 +12391,14 @@ def _load_hanmac_member_grade_lookup_cached(_signature: str) -> dict[str, dict[s SELECT cache_key FROM hanmac_aggregate_query_metrics WHERE view_mode = 'member' - ORDER BY updated_at DESC + AND payload_signature LIKE :compatible_signature_pattern + ORDER BY + CASE WHEN payload_signature LIKE :current_signature_prefix THEN 0 ELSE 1 END, + updated_at DESC LIMIT 8 """ - ) + ), + _cost_analysis_hanmac_signature_params(), ).fetchall() if normalize_text(row[0]) ] @@ -12432,9 +13175,71 @@ def get_projects_bootstrap_payload(selected_year: int | None = None) -> dict[str ) -COST_ANALYSIS_LABOR_KEYWORDS = ("급여", "상여", "퇴직급여", "건강보험료", "고용보험료", "산재보험료") +COST_ANALYSIS_LABOR_KEYWORDS = ( + "급여", + "상여", + "제수당", + "퇴직급여", + "퇴직금", + "국민연금", + "건강보험료", + "고용보험료", + "산재보험료", +) COST_ANALYSIS_OUTSOURCE_KEYWORDS = ("기술협력비", "설계외주비", "외주비") COST_ANALYSIS_COMMON_CODES = {"", "ZZZZZZ"} +COST_ANALYSIS_COMMON_ACTIVITY_PREFIX = "__COMMON_ACTIVITY__:" +COST_ANALYSIS_COMMON_ACTIVITY_SPECIAL_NAMES = { + "H00-합사-01": "공통/합사", + "H00-대기-01": "공통/감리대기", + "HXX-영업-01": "공통/영업", + "HV009109": "공통/영업", + "HXX-고문-02": "공통/고문", + "HV009111": "공통/고문", + "HXX-교휴-04": "공통/휴가", + "HV009104": "공통/휴가", + "HXX-교휴-06": "공통/기타", + "HV009110": "공통/기타", + "HXX-교휴-08": "공통/행사·학회·협회", + "HV009102": "공통/행사·학회·협회", + "HV009101": "공통/회의", + "HV009106": "공통/기타업무", + "HP241101": "송산그린시티 용수공급시설(2차) 실시설계 용역", +} +COST_ANALYSIS_CONFIRMED_H_PROJECT_CODE_MAP = { + # H/HP/HV source codes confirmed against ERP project/billing/transaction data. + "H21-고속-09": "Y22004", + "H22-제안-33": "Y22239", + "H22-지방-08": "Y22239", + "H24-제안-01": "Y24061", + "H24-제안-05": "X24005", + "H24-제안-10": "Z24138", + "H24-제안-22": "Y24170", + "H20-제안-16": "Z25031", + "H21-제안-17": "Y22004", + "HP241101": "X24005", +} +COST_ANALYSIS_CONFIRMED_H_TITLE_CODE_MAP = { + "경호안전교육원3단계사업지명설계공모": "Y22239", + "경호안전교육원3단계사업설계용역": "Y22239", + "동광주광산대안제시경쟁": "Y24061", + "동부간선도로지하화민간투자사업감독권한대행등건설사업관리용역": "Z24138", + "포항안동111공구건설사업관리종심": "Z25031", + "현대자동차남양연구소고속주회로재포장기본및실시설계": "Y22004", +} +COST_ANALYSIS_COMMON_VISIBLE_EXCEPTION_TITLES = { + "국지도98호선양근대교도로건설공사감독권한대행등", + "대산당진건설공사제1공구", + "아산시노후하수관로개량사업", + "중랑처리구역하수관로기술진단1권역", +} + + +def _cost_analysis_is_visible_common_exception(value: Any) -> bool: + return normalize_project_title_for_linking(value) in COST_ANALYSIS_COMMON_VISIBLE_EXCEPTION_TITLES +COST_ANALYSIS_LABOR_ACCOUNT_SQL = " OR ".join( + f"COALESCE(account_name, '') LIKE '%{keyword}%'" for keyword in COST_ANALYSIS_LABOR_KEYWORDS +) COST_ANALYSIS_TX_DATE_SQL = ( "CASE " "WHEN COALESCE(posting_date, '') GLOB '[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]' THEN posting_date " @@ -12459,6 +13264,260 @@ def _date_text(value: Any) -> str: return parsed.isoformat() if parsed else "" +def _cost_analysis_voucher_stem(value: Any) -> str: + voucher = normalize_text(value) + return voucher.rsplit("-", 1)[0] if "-" in voucher else voucher + + +def _cost_analysis_voucher_date(value: Any) -> str: + voucher = normalize_text(value) + match = re.match(r"^11-(\d{4})(\d{2})(\d{2})-", voucher) + if not match: + return "" + return f"{match.group(1)}-{match.group(2)}-{match.group(3)}" + + +def _cost_analysis_erp_collection_events(end_date: date) -> list[dict[str, Any]]: + # Only receivables created by customer billing belong to project collection. + # General receivables include payroll/tax/asset-sale settlements. + receivable_codes = {"10111101", "10111501"} + non_cash_receivable_codes = {*receivable_codes, "10111901"} + with engine.begin() as conn: + transaction_rows = conn.execute( + text( + """ + SELECT + COALESCE(voucher_number, '') AS voucher_number, + COALESCE(confirmed_voucher_number, '') AS confirmed_voucher_number, + COALESCE(account_code, '') AS account_code, + COALESCE(account_name, '') AS account_name, + UPPER(COALESCE(support_dept_code, '')) AS support_dept_code, + COALESCE(partner_name, '') AS partner_name, + COALESCE(memo1, '') AS memo1, + COALESCE(debit_supply, 0) AS debit_supply, + COALESCE(debit_vat, 0) AS debit_vat, + COALESCE(credit_supply, 0) AS credit_supply, + COALESCE(credit_vat, 0) AS credit_vat + FROM transactions + WHERE account_code LIKE '101%' + OR account_code LIKE '4%' + """ + ) + ).mappings().all() + + voucher_groups: dict[str, list[dict[str, Any]]] = {} + for raw_row in transaction_rows: + row = dict(raw_row) + voucher_key = _cost_analysis_voucher_stem( + row.get("confirmed_voucher_number") or row.get("voucher_number") + ) + if voucher_key: + voucher_groups.setdefault(voucher_key, []).append(row) + + invoice_candidates: dict[tuple[str, str], list[dict[str, Any]]] = {} + for voucher_key, rows in voucher_groups.items(): + revenue_supply = sum( + normalize_amount(row.get("credit_supply")) - normalize_amount(row.get("debit_supply")) + for row in rows + if normalize_text(row.get("account_code")).startswith("4") + ) + receivable_debits = [ + row + for row in rows + if normalize_text(row.get("account_code")) in receivable_codes + and ( + normalize_amount(row.get("debit_supply")) + + normalize_amount(row.get("debit_vat")) + - normalize_amount(row.get("credit_supply")) + - normalize_amount(row.get("credit_vat")) + ) > 0.5 + ] + gross_total = sum( + normalize_amount(row.get("debit_supply")) + + normalize_amount(row.get("debit_vat")) + - normalize_amount(row.get("credit_supply")) + - normalize_amount(row.get("credit_vat")) + for row in receivable_debits + ) + if not receivable_debits or gross_total <= 0.5 or revenue_supply <= 0.5: + continue + for row in receivable_debits: + gross_amount = ( + normalize_amount(row.get("debit_supply")) + + normalize_amount(row.get("debit_vat")) + - normalize_amount(row.get("credit_supply")) + - normalize_amount(row.get("credit_vat")) + ) + code = normalize_text(row.get("support_dept_code")).upper() + partner = normalize_text(row.get("partner_name")) + invoice_candidates.setdefault((code, partner), []).append( + { + "gross_amount": gross_amount, + "supply_amount": revenue_supply * gross_amount / gross_total, + "voucher_number": normalize_text(row.get("voucher_number")), + "confirmed_voucher_number": normalize_text(row.get("confirmed_voucher_number")), + "invoice_date": _cost_analysis_voucher_date(row.get("voucher_number")), + "memo1": normalize_text(row.get("memo1")), + } + ) + + events: list[dict[str, Any]] = [] + for voucher_key, rows in voucher_groups.items(): + receipt_date = _cost_analysis_voucher_date(rows[0].get("voucher_number")) + if not receipt_date or receipt_date > end_date.isoformat(): + continue + receivable_credits = [ + row + for row in rows + if normalize_text(row.get("account_code")) in receivable_codes + and ( + normalize_amount(row.get("credit_supply")) + + normalize_amount(row.get("credit_vat")) + - normalize_amount(row.get("debit_supply")) + - normalize_amount(row.get("debit_vat")) + ) > 0.5 + ] + cash_debit_total = sum( + normalize_amount(row.get("debit_supply")) + + normalize_amount(row.get("debit_vat")) + - normalize_amount(row.get("credit_supply")) + - normalize_amount(row.get("credit_vat")) + for row in rows + if normalize_text(row.get("account_code")).startswith("101") + and normalize_text(row.get("account_code")) not in non_cash_receivable_codes + ) + if cash_debit_total <= 0.5: + continue + if not receivable_credits: + revenue_rows = [ + row + for row in rows + if normalize_text(row.get("account_code")).startswith("4") + and ( + normalize_amount(row.get("credit_supply")) + - normalize_amount(row.get("debit_supply")) + ) > 0.5 + ] + receivable_debit_total = sum( + normalize_amount(row.get("debit_supply")) + + normalize_amount(row.get("debit_vat")) + - normalize_amount(row.get("credit_supply")) + - normalize_amount(row.get("credit_vat")) + for row in rows + if normalize_text(row.get("account_code")) in receivable_codes + ) + revenue_supply_total = sum( + normalize_amount(row.get("credit_supply")) + - normalize_amount(row.get("debit_supply")) + for row in revenue_rows + ) + if not revenue_rows or receivable_debit_total > 0.5 or revenue_supply_total <= 0.5: + continue + for row in revenue_rows: + supply_amount = ( + normalize_amount(row.get("credit_supply")) + - normalize_amount(row.get("debit_supply")) + ) + events.append( + { + "support_dept_code": normalize_text(row.get("support_dept_code")).upper(), + "posting_date": receipt_date, + "voucher_number": normalize_text(row.get("voucher_number")), + "confirmed_voucher_number": normalize_text(row.get("confirmed_voucher_number")), + "partner_name": normalize_text(row.get("partner_name")), + "memo1": normalize_text(row.get("memo1")), + "receivable_account_code": "", + "receivable_account_name": "즉시 현금·카드 매출", + "gross_amount": cash_debit_total * supply_amount / revenue_supply_total, + "amount": supply_amount, + "conversion_status": "immediate-cash", + "source_invoice_voucher_number": normalize_text(row.get("voucher_number")), + "source_invoice_confirmed_voucher_number": normalize_text( + row.get("confirmed_voucher_number") + ), + "cash_match_gap": cash_debit_total - sum( + normalize_amount(item.get("credit_supply")) + + normalize_amount(item.get("credit_vat")) + - normalize_amount(item.get("debit_supply")) + - normalize_amount(item.get("debit_vat")) + for item in revenue_rows + ), + } + ) + continue + receivable_credit_total = sum( + normalize_amount(row.get("credit_supply")) + + normalize_amount(row.get("credit_vat")) + - normalize_amount(row.get("debit_supply")) + - normalize_amount(row.get("debit_vat")) + for row in receivable_credits + ) + matched_cash_total = min(receivable_credit_total, cash_debit_total) + for row in receivable_credits: + gross_credit = ( + normalize_amount(row.get("credit_supply")) + + normalize_amount(row.get("credit_vat")) + - normalize_amount(row.get("debit_supply")) + - normalize_amount(row.get("debit_vat")) + ) + matched_gross = gross_credit * matched_cash_total / receivable_credit_total + code = normalize_text(row.get("support_dept_code")).upper() + partner = normalize_text(row.get("partner_name")) + candidates = invoice_candidates.get((code, partner), []) + exact_candidates = [ + candidate + for candidate in candidates + if abs(normalize_amount(candidate.get("gross_amount")) - gross_credit) < 0.5 + and normalize_text(candidate.get("invoice_date")) <= receipt_date + ] + receipt_memo = normalize_project_title_for_linking(row.get("memo1")) + exact_invoice = max( + exact_candidates, + key=lambda candidate: ( + SequenceMatcher( + None, + receipt_memo, + normalize_project_title_for_linking(candidate.get("memo1")), + ).ratio(), + normalize_text(candidate.get("invoice_date")), + ), + default=None, + ) + if exact_invoice: + supply_ratio = ( + normalize_amount(exact_invoice.get("supply_amount")) + / normalize_amount(exact_invoice.get("gross_amount")) + ) + supply_amount = matched_gross * supply_ratio + conversion_status = "invoice-matched" + else: + supply_amount = matched_gross / 1.1 + conversion_status = "vat-estimated" + events.append( + { + "support_dept_code": code, + "posting_date": receipt_date, + "voucher_number": normalize_text(row.get("voucher_number")), + "confirmed_voucher_number": normalize_text(row.get("confirmed_voucher_number")), + "partner_name": partner, + "memo1": normalize_text(row.get("memo1")), + "receivable_account_code": normalize_text(row.get("account_code")), + "receivable_account_name": normalize_text(row.get("account_name")), + "gross_amount": matched_gross, + "amount": supply_amount, + "conversion_status": conversion_status, + "source_invoice_voucher_number": normalize_text( + (exact_invoice or {}).get("voucher_number") + ), + "source_invoice_confirmed_voucher_number": normalize_text( + (exact_invoice or {}).get("confirmed_voucher_number") + ), + "cash_match_gap": cash_debit_total - receivable_credit_total, + } + ) + return events + + def _iter_year_slices(start_date: date, end_date: date) -> list[dict[str, Any]]: slices: list[dict[str, Any]] = [] current = start_date @@ -12526,6 +13585,74 @@ def _cost_analysis_project_type(code: str, fallback: Any = "") -> str: return "기타" +def _cost_analysis_project_source_codes(project: Mapping[str, Any]) -> list[str]: + return sorted( + { + normalize_text(value).upper() + for value in ( + project.get("project_code"), + project.get("raw_project_code"), + *(project.get("equivalent_project_codes") or []), + *(project.get("source_project_codes") or []), + ) + if normalize_text(value) + } + ) + + +def _cost_analysis_common_activity_info(project: Mapping[str, Any]) -> dict[str, Any] | None: + source_codes = _cost_analysis_project_source_codes(project) + h_codes = [code for code in source_codes if code.startswith("H")] + if not h_codes: + return None + + canonical_code = normalize_text(project.get("project_code")).upper() + preferred_codes = [canonical_code, *h_codes] + label = next( + ( + COST_ANALYSIS_COMMON_ACTIVITY_SPECIAL_NAMES[code] + for code in preferred_codes + if code in COST_ANALYSIS_COMMON_ACTIVITY_SPECIAL_NAMES + ), + "", + ) + if not label: + category_match = re.match(r"^H(?:XX|\d{2})-([^-]+)-\d+$", canonical_code) + category = normalize_text(category_match.group(1) if category_match else "") + if category in {"영업", "고문", "관리"}: + label = f"공통/{category}" + elif category == "교휴": + label = f"공통/교휴 ({canonical_code})" + + project_name = normalize_text(project.get("project_name")) + if not label and project_name and project_name.upper() not in set(h_codes): + label = project_name + if not label: + label = f"공통/기타업무 ({canonical_code or h_codes[0]})" + + raw_code = normalize_text(project.get("raw_project_code")).upper() + display_source_codes = [] + for code in h_codes: + mapped_label = COST_ANALYSIS_COMMON_ACTIVITY_SPECIAL_NAMES.get(code) + if mapped_label and mapped_label != label: + continue + if code in {canonical_code, raw_code} or mapped_label == label or not mapped_label: + display_source_codes.append(code) + if not display_source_codes: + display_source_codes = [canonical_code or h_codes[0]] + + label_key = normalize_project_title_for_linking(label) or re.sub(r"[^A-Z0-9가-힣]+", "", label.upper()) + return { + "key": f"{COST_ANALYSIS_COMMON_ACTIVITY_PREFIX}{label_key}", + "label": label, + "source_codes": sorted(set(display_source_codes)), + } + + +def _cost_analysis_is_common_activity_code(value: Any) -> bool: + return normalize_text(value).upper().startswith(COST_ANALYSIS_COMMON_ACTIVITY_PREFIX) + + def _cost_analysis_financial_bucket(account_code: Any) -> str: code = normalize_text(account_code) if code.startswith("4"): @@ -12551,6 +13678,28 @@ def _cost_analysis_expense_item(account_code: Any, account_name: Any, is_sales_c return "overhead" +def _cost_analysis_detail_item_matches(bucket: str, item_key: str, requested_item: str) -> bool: + if requested_item == "revenue": + return bucket == "revenue" + if bucket not in {"cost", "sga"}: + return False + if requested_item == "cost_total": + return bucket == "cost" and item_key in {"labor", "outsource", "overhead"} + if requested_item == "sga_total": + return bucket == "sga" and item_key == "sga" + if requested_item == "sales_total": + return item_key == "sales" + if requested_item == "total_cost": + return item_key in {"labor", "outsource", "overhead", "sga", "sales"} + if requested_item in {"labor", "outsource", "overhead"}: + return bucket == "cost" and item_key == requested_item + if requested_item == "sga": + return bucket == "sga" and item_key == "sga" + if requested_item == "sales": + return item_key == "sales" + return False + + def _cost_analysis_is_sales_cost(row: dict[str, Any]) -> bool: for code_key, name_key in ( ("issuing_dept_code", "issuing_dept_name"), @@ -12566,15 +13715,174 @@ def _cost_analysis_is_sales_cost(row: dict[str, Any]) -> bool: def _cost_analysis_empty_phase_totals() -> dict[str, dict[str, float]]: return { - "pre": {"labor": 0.0, "outsource": 0.0, "overhead": 0.0, "sga": 0.0, "sales": 0.0}, - "during": {"labor": 0.0, "outsource": 0.0, "overhead": 0.0, "sga": 0.0, "sales": 0.0}, - "post": {"labor": 0.0, "outsource": 0.0, "overhead": 0.0, "sga": 0.0, "sales": 0.0}, + "pre": {"labor": 0.0, "labor_adjustment": 0.0, "outsource": 0.0, "overhead": 0.0, "sga_labor": 0.0, "sga_labor_adjustment": 0.0, "sga": 0.0, "sales": 0.0}, + "during": {"labor": 0.0, "labor_adjustment": 0.0, "outsource": 0.0, "overhead": 0.0, "sga_labor": 0.0, "sga_labor_adjustment": 0.0, "sga": 0.0, "sales": 0.0}, + "post": {"labor": 0.0, "labor_adjustment": 0.0, "outsource": 0.0, "overhead": 0.0, "sga_labor": 0.0, "sga_labor_adjustment": 0.0, "sga": 0.0, "sales": 0.0}, } +_COST_ANALYSIS_ROUND_CODE_RE = re.compile(r"^[XYZ]\d{5}$") +_COST_ANALYSIS_MASTER_CODE_RE = re.compile(r"^[09]\d{5}$") + + +def _cost_analysis_name_needs_display_fix(name: Any, code: Any = "") -> bool: + normalized_name = normalize_text(name) + normalized_code = normalize_text(code).upper() + if not normalized_name: + return True + if normalized_code and normalized_name.upper() == normalized_code: + return True + return bool(re.fullmatch(r"[09]?\d{5,6}", normalized_name)) + + +def _cost_analysis_get_satis_display_maps() -> dict[str, dict[str, Any]]: + """Return Satis-derived display names and preferred round-code aliases. + + 손익분석 계산은 총괄/차수 코드가 모두 필요하지만, 화면 표시는 사용자가 + 보는 차수 프로젝트(X/Y/Z)와 사업명으로 맞춰야 한다. 이 함수는 + satis_project_code_links와 이미 반영된 Satis 예산 출처를 이용해 그 표시 + 기준만 별도로 만든다. + """ + + names: dict[str, str] = {} + link_candidates: dict[str, list[dict[str, Any]]] = {} + preferred_round: dict[str, str] = {} + + def put_name(code: Any, name: Any, *, prefer: bool = False) -> None: + normalized_code = normalize_text(code).upper() + normalized_name = normalize_text(name) + if not normalized_code or not normalized_name: + return + current = names.get(normalized_code, "") + if prefer or _cost_analysis_name_needs_display_fix(current, normalized_code): + names[normalized_code] = normalized_name + + try: + with engine.begin() as conn: + link_rows = conn.execute( + text( + """ + SELECT + COALESCE(local_project_code, '') AS local_project_code, + COALESCE(local_project_name, '') AS local_project_name, + COALESCE(own_master_project_code, '') AS own_master_project_code, + COALESCE(own_master_project_name, '') AS own_master_project_name, + COALESCE(linked_main_project_code, '') AS linked_main_project_code, + COALESCE(linked_main_project_name, '') AS linked_main_project_name, + COALESCE(cost_project_code, '') AS cost_project_code, + COALESCE(cost_project_name, '') AS cost_project_name, + COALESCE(mapping_status, '') AS mapping_status, + COALESCE(project_kind, '') AS project_kind, + COALESCE(is_active, 1) AS is_active + FROM satis_project_code_links + WHERE COALESCE(local_project_code, '') <> '' + """ + ) + ).mappings().all() + budget_source_rows = conn.execute( + text( + """ + SELECT + UPPER(COALESCE(support_dept_code, '')) AS master_code, + UPPER(COALESCE(source_support_dept_code, '')) AS source_code, + COUNT(*) AS line_count, + SUM(COALESCE(amount, 0)) AS amount + FROM project_exec_budget_entries + WHERE COALESCE(source_support_dept_code, '') <> '' + AND UPPER(COALESCE(support_dept_code, '')) <> UPPER(COALESCE(source_support_dept_code, '')) + GROUP BY UPPER(COALESCE(support_dept_code, '')), UPPER(COALESCE(source_support_dept_code, '')) + """ + ) + ).mappings().all() + status_codes = { + normalize_text(code).upper() + for code in conn.execute( + text( + """ + SELECT support_dept_code + FROM project_status + WHERE COALESCE(support_dept_code, '') <> '' + """ + ) + ).scalars().all() + if normalize_text(code) + } + except Exception: + return {"names": names, "preferred_round": preferred_round} + + for row in link_rows: + local_code = normalize_text(row.get("local_project_code")).upper() + if not local_code: + continue + put_name(local_code, row.get("local_project_name"), prefer=True) + put_name(row.get("own_master_project_code"), row.get("own_master_project_name")) + put_name(row.get("linked_main_project_code"), row.get("linked_main_project_name")) + put_name(row.get("cost_project_code"), row.get("cost_project_name")) + + for master_code in { + normalize_text(row.get("own_master_project_code")).upper(), + normalize_text(row.get("linked_main_project_code")).upper(), + normalize_text(row.get("cost_project_code")).upper(), + }: + if not master_code or not _COST_ANALYSIS_MASTER_CODE_RE.fullmatch(master_code): + continue + link_candidates.setdefault(master_code, []).append( + { + "local_code": local_code, + "is_active": int(normalize_amount(row.get("is_active")) or 0), + "mapping_status": normalize_text(row.get("mapping_status")), + "project_kind": normalize_text(row.get("project_kind")), + "in_project_status": local_code in status_codes, + } + ) + + budget_candidates: dict[str, list[tuple[float, int, str]]] = {} + for row in budget_source_rows: + master_code = normalize_text(row.get("master_code")).upper() + source_code = normalize_text(row.get("source_code")).upper() + if not _COST_ANALYSIS_MASTER_CODE_RE.fullmatch(master_code): + continue + if not _COST_ANALYSIS_ROUND_CODE_RE.fullmatch(source_code): + continue + budget_candidates.setdefault(master_code, []).append( + ( + normalize_amount(row.get("amount")), + int(normalize_amount(row.get("line_count")) or 0), + source_code, + ) + ) + for master_code, candidates in budget_candidates.items(): + preferred_round[master_code] = sorted(candidates, key=lambda item: (-item[0], -item[1], item[2]))[0][2] + + def link_rank(item: dict[str, Any]) -> tuple[int, int, int, int, str]: + local_code = normalize_text(item.get("local_code")).upper() + prefix = local_code[:1] + return ( + 0 if item.get("in_project_status") else 1, + 0 if item.get("is_active") else 1, + 0 if normalize_text(item.get("mapping_status")) in {"confirmed", "exception"} else 1, + 0 if prefix in {"Y", "Z"} else 1 if prefix == "X" else 2, + local_code, + ) + + for master_code, candidates in link_candidates.items(): + if master_code in preferred_round: + continue + valid_candidates = [ + item for item in candidates + if _COST_ANALYSIS_ROUND_CODE_RE.fullmatch(normalize_text(item.get("local_code")).upper()) + ] + if valid_candidates: + preferred_round[master_code] = sorted(valid_candidates, key=link_rank)[0]["local_code"] + + return {"names": names, "preferred_round": preferred_round} + + def _cost_analysis_get_project_meta() -> dict[str, dict[str, Any]]: billing_summary = get_project_billing_summary_map() latest_summary_by_title, latest_round_by_code, representative_by_title, title_by_code = get_project_contract_change_maps() + satis_display = _cost_analysis_get_satis_display_maps() + satis_names = satis_display.get("names") or {} with engine.begin() as conn: rows = conn.execute( text( @@ -12586,6 +13894,10 @@ def _cost_analysis_get_project_meta() -> dict[str, dict[str, Any]]: UNION SELECT DISTINCT support_dept_code FROM project_contract_info WHERE COALESCE(support_dept_code, '') <> '' UNION SELECT DISTINCT support_dept_code FROM project_billing_entries WHERE COALESCE(support_dept_code, '') <> '' UNION SELECT DISTINCT support_dept_code FROM project_contract_change_round WHERE COALESCE(support_dept_code, '') <> '' + UNION SELECT DISTINCT local_project_code FROM satis_project_code_links WHERE COALESCE(local_project_code, '') <> '' + UNION SELECT DISTINCT own_master_project_code FROM satis_project_code_links WHERE COALESCE(own_master_project_code, '') <> '' + UNION SELECT DISTINCT linked_main_project_code FROM satis_project_code_links WHERE COALESCE(linked_main_project_code, '') <> '' + UNION SELECT DISTINCT cost_project_code FROM satis_project_code_links WHERE COALESCE(cost_project_code, '') <> '' ) SELECT COALESCE(u.support_dept_code, '') AS support_dept_code, @@ -12650,9 +13962,30 @@ def _cost_analysis_get_project_meta() -> dict[str, dict[str, Any]]: contract_amount = latest_summary_amount explicit_start_date = _date_text(row.get("project_start_date")) fallback_start_date = _date_text(row.get("first_change_date")) or _date_text(row.get("first_billing_date")) or _date_text(row.get("first_posting_date")) + collected_amount = normalize_amount(billing_row.get("collected_amount")) + billing_balance_amount = normalize_amount(billing_row.get("balance_amount")) + collection_rate = _safe_ratio(collected_amount, contract_amount) + if not collection_rate: + collection_rate = max( + [ + normalize_amount(entry.get("collection_rate")) + for entry in (billing_row.get("entries") or []) + ] + or [0.0] + ) + display_name = ( + normalize_text(row.get("support_dept_name")) + or normalize_text(billing_row.get("support_dept_name")) + or normalize_text(latest_summary_change.get("support_dept_name")) + or normalize_text(latest_round_change.get("support_dept_name")) + or normalize_text(satis_names.get(code)) + or code + ) + if _cost_analysis_name_needs_display_fix(display_name, code): + display_name = normalize_text(satis_names.get(code)) or display_name result[code] = { "support_dept_code": code, - "support_dept_name": normalize_text(row.get("support_dept_name")) or normalize_text(billing_row.get("support_dept_name")) or normalize_text(latest_summary_change.get("support_dept_name")) or normalize_text(latest_round_change.get("support_dept_name")) or code, + "support_dept_name": display_name, "pm_department": normalize_text(row.get("pm_department")) or normalize_text(billing_row.get("support_department")) or normalize_text(latest_summary_change.get("owner_department")) or normalize_text(latest_round_change.get("owner_department")), "project_type": _cost_analysis_project_type(code, row.get("project_type") or latest_summary_change.get("business_division") or latest_round_change.get("business_division")), "completion_status": normalize_text(row.get("completion_status")) or normalize_text(latest_summary_change.get("progress_status")), @@ -12660,6 +13993,9 @@ def _cost_analysis_get_project_meta() -> dict[str, dict[str, Any]]: "project_start_date_source": "기본정보" if explicit_start_date else ("계약변경/청구/전표" if fallback_start_date else ""), "project_end_date": _date_text(row.get("project_end_date")) or _date_text(latest_summary_change.get("changed_project_end_date")) or _date_text(latest_round_change.get("changed_project_end_date")), "contract_amount": contract_amount, + "collected_amount": collected_amount, + "collection_balance_amount": billing_balance_amount, + "collection_rate": collection_rate, "contract_source": "변경계약 차수" if latest_round_amount and abs(contract_amount - latest_round_amount) < 0.5 else ("변경계약 총괄" if latest_summary_amount and abs(contract_amount - latest_summary_amount) < 0.5 else "계약/청구"), } return result @@ -12727,6 +14063,23 @@ def _cost_analysis_get_xyz_code_map() -> dict[str, str]: } +def _cost_analysis_get_x_owner_map( + x_links: dict[str, list[str]] | None = None, + xyz_code_map: dict[str, str] | None = None, +) -> dict[str, str]: + resolved_x_links = x_links if x_links is not None else _cost_analysis_get_x_links() + resolved_xyz_code_map = xyz_code_map if xyz_code_map is not None else _cost_analysis_get_xyz_code_map() + result: dict[str, str] = {} + for owner_code, x_codes in resolved_x_links.items(): + normalized_owner_code = normalize_text(owner_code).upper() + display_owner_code = resolved_xyz_code_map.get(normalized_owner_code, normalized_owner_code) + for x_code in x_codes: + normalized_x_code = normalize_text(x_code).upper() + if normalized_x_code: + result.setdefault(normalized_x_code, display_owner_code) + return result + + def _cost_analysis_get_completion_billing_dates() -> dict[str, str]: result: dict[str, str] = {} with engine.begin() as conn: @@ -12762,9 +14115,114 @@ def _cost_analysis_get_completion_billing_dates() -> dict[str, str]: def _clear_cost_analysis_payload_caches() -> None: with _COST_ANALYSIS_PAYLOAD_CACHE_LOCK: _COST_ANALYSIS_PAYLOAD_CACHE.clear() + with _COST_ANALYSIS_DETAIL_CACHE_LOCK: + _COST_ANALYSIS_DETAIL_CACHE.clear() + with _COST_ANALYSIS_LINK_MAP_CACHE_LOCK: + _COST_ANALYSIS_LINK_MAP_CACHE.clear() + _cost_analysis_field_cost_dept_names.cache_clear() _cost_analysis_load_hanmac_member_rows_for_cache.cache_clear() with engine.begin() as conn: conn.execute(text("DELETE FROM system_page_cache WHERE page_key = 'cost_analysis_payload'")) + conn.execute(text("DELETE FROM system_page_cache WHERE page_key = 'cost_analysis_link_map'")) + + +def _cost_analysis_hanmac_signature_params() -> dict[str, str]: + return { + "current_signature_prefix": f"{HANMAC_AGGREGATE_SIGNATURE_PREFIX}%", + "compatible_signature_pattern": f"{HANMAC_AGGREGATE_SIGNATURE_PREFIX}%", + } + + +def _cost_analysis_latest_hanmac_connection_params() -> dict[str, Any]: + with engine.begin() as conn: + rows = conn.execute( + text( + """ + SELECT params_json + FROM system_jobs + WHERE job_type = 'hanmac_aggregate_cache' + AND status = 'done' + ORDER BY finished_at DESC, created_at DESC + LIMIT 20 + """ + ) + ).scalars().all() + for raw_params in rows: + try: + params = json.loads(str(raw_params or "{}")) + except Exception: + continue + if not isinstance(params, dict): + continue + if normalize_text(params.get("host")) and normalize_text(params.get("user")) and params.get("password"): + return { + "host": normalize_text(params.get("host")), + "port": normalize_text(params.get("port")) or "3306", + "user": normalize_text(params.get("user")), + "password": params.get("password") or "", + "database": normalize_text(params.get("database") or HANMAC_PRIMARY_MANHOUR_SCHEMA), + "view": "member", + "employment": normalize_text(params.get("employment") or "all"), + "include_center_member_nos": params.get("include_center_member_nos") or [], + } + return {} + + +def _cost_analysis_ensure_current_hanmac_cache_jobs(start_date: date, end_date: date) -> list[dict[str, Any]]: + missing_slices: list[dict[str, Any]] = [] + with engine.begin() as conn: + for year_slice in _iter_year_slices(start_date, end_date): + row = conn.execute( + text( + """ + SELECT cache_key, row_count, summary_json, updated_at + FROM hanmac_aggregate_query_metrics + WHERE view_mode = 'member' + AND payload_signature LIKE :current_signature_prefix + AND COALESCE(start_date, '') <= :start_date + AND COALESCE(end_date, '') >= :end_date + AND COALESCE(row_count, 0) > 0 + ORDER BY + CASE WHEN start_date = :start_date AND end_date = :end_date THEN 0 ELSE 1 END, + updated_at DESC + LIMIT 1 + """ + ), + { + "start_date": year_slice["start"].isoformat(), + "end_date": year_slice["end"].isoformat(), + **_cost_analysis_hanmac_signature_params(), + }, + ).mappings().first() + summary: dict[str, Any] = {} + if row: + try: + summary = json.loads(str(row.get("summary_json") or "{}")) + except Exception: + summary = {} + if not row or normalize_amount(summary.get("total_hours")) <= 0: + missing_slices.append(year_slice) + if not missing_slices: + return [] + + connection_params = _cost_analysis_latest_hanmac_connection_params() + if not connection_params: + raise ValueError("최신 한맥 근무 캐시를 자동 갱신할 DB_external 접속 설정을 찾지 못했습니다.") + jobs: list[dict[str, Any]] = [] + for year_slice in missing_slices: + job = _create_system_job( + page_key="cost_analysis", + job_type="hanmac_aggregate_cache", + start_year=int(year_slice["year"]), + end_year=int(year_slice["year"]), + params={ + **connection_params, + "start_date": year_slice["start"].isoformat(), + "end_date": year_slice["end"].isoformat(), + }, + ) + jobs.append(job) + return jobs def _cost_analysis_hanmac_cache_version() -> str: @@ -12778,8 +14236,10 @@ def _cost_analysis_hanmac_cache_version() -> str: COALESCE(MAX(rowid), 0) AS max_rowid FROM hanmac_aggregate_query_metrics WHERE view_mode = 'member' + AND payload_signature LIKE :compatible_signature_pattern """ - ) + ), + _cost_analysis_hanmac_signature_params(), ).mappings().first() except Exception: return "" @@ -12827,15 +14287,21 @@ def _cost_analysis_select_hanmac_member_metric( SELECT cache_key, start_date, end_date, updated_at FROM hanmac_aggregate_query_metrics WHERE view_mode = 'member' + AND payload_signature LIKE :compatible_signature_pattern AND COALESCE(start_date, '') <= :start_date AND COALESCE(end_date, '') >= :end_date ORDER BY + CASE WHEN payload_signature LIKE :current_signature_prefix THEN 0 ELSE 1 END, CASE WHEN start_date = :start_date AND end_date = :end_date THEN 0 ELSE 1 END, updated_at DESC LIMIT 10 """ ), - {"start_date": start_date.isoformat(), "end_date": end_date.isoformat()}, + { + "start_date": start_date.isoformat(), + "end_date": end_date.isoformat(), + **_cost_analysis_hanmac_signature_params(), + }, ).mappings().all() if not candidates: candidates = conn.execute( @@ -12844,9 +14310,11 @@ def _cost_analysis_select_hanmac_member_metric( SELECT cache_key, start_date, end_date, updated_at FROM hanmac_aggregate_query_metrics WHERE view_mode = 'member' + AND payload_signature LIKE :compatible_signature_pattern AND COALESCE(start_date, '') <= :end_date AND COALESCE(end_date, '') >= :start_date ORDER BY + CASE WHEN payload_signature LIKE :current_signature_prefix THEN 0 ELSE 1 END, CASE WHEN COALESCE(start_date, '') <= :start_date AND COALESCE(end_date, '') >= :end_date @@ -12857,7 +14325,11 @@ def _cost_analysis_select_hanmac_member_metric( LIMIT 10 """ ), - {"start_date": start_date.isoformat(), "end_date": end_date.isoformat()}, + { + "start_date": start_date.isoformat(), + "end_date": end_date.isoformat(), + **_cost_analysis_hanmac_signature_params(), + }, ).mappings().all() if not candidates: return None @@ -12928,14 +14400,22 @@ def _cost_analysis_select_hanmac_prefix_metric( SELECT cache_key, start_date, end_date, updated_at FROM hanmac_aggregate_query_metrics WHERE view_mode = 'member' + AND payload_signature LIKE :compatible_signature_pattern AND COALESCE(start_date, '') <= :start_date AND COALESCE(end_date, '') >= :start_date AND COALESCE(end_date, '') <= :end_date - ORDER BY end_date DESC, updated_at DESC + ORDER BY + CASE WHEN payload_signature LIKE :current_signature_prefix THEN 0 ELSE 1 END, + end_date DESC, + updated_at DESC LIMIT 10 """ ), - {"start_date": start_date.isoformat(), "end_date": end_date.isoformat()}, + { + "start_date": start_date.isoformat(), + "end_date": end_date.isoformat(), + **_cost_analysis_hanmac_signature_params(), + }, ).mappings().all() candidate_dicts = [dict(candidate) for candidate in candidates] if prefer_member_grade: @@ -13023,6 +14503,7 @@ def _cost_analysis_load_hanmac_labor_map_by_year( return year_text = str((work_date or start_date).year) split_hours = hours / len(codes) + cost_weight = normalize_amount(project.get("cost_weight")) or 1.0 for code in codes: normalized_code = normalize_text(code).upper() if allowed_codes is not None and normalized_code not in allowed_codes: @@ -13040,11 +14521,12 @@ def _cost_analysis_load_hanmac_labor_map_by_year( normalized_code, (work_date or start_date).isoformat(), completion_dates, + project_meta, ) result.setdefault((work_date or start_date).year, {}).setdefault( normalized_code, {"pre": 0.0, "during": 0.0, "post": 0.0}, - )[phase] += rate * split_hours + )[phase] += rate * split_hours * cost_weight for row in row_items: member_grade = _normalize_labor_grade_name( @@ -13125,7 +14607,6 @@ def _cost_analysis_load_hanmac_labor_detail_rows( project_meta: dict[str, dict[str, Any]], ) -> list[dict[str, Any]]: normalized_requested_codes = {normalize_text(code).upper() for code in requested_codes if normalize_text(code)} - normalized_requested_codes -= COST_ANALYSIS_COMMON_CODES if not normalized_requested_codes: return [] @@ -13138,8 +14619,9 @@ def _cost_analysis_load_hanmac_labor_detail_rows( if not rates_by_year: rates_by_year = _parse_labor_rates_json(json.dumps(DEFAULT_EXEC_LABOR_RATES, ensure_ascii=False)) completion_dates = _cost_analysis_get_completion_billing_dates() + representative_map = _cost_analysis_get_link_representative_map() normalized_phase = normalize_text(requested_phase).lower() - detail_by_member: dict[tuple[str, str, str], dict[str, Any]] = {} + detail_by_member: dict[tuple[str, str, str, str], dict[str, Any]] = {} grade_order = { grade: index for index, grade in enumerate(("회장", "부회장", "사장", "부사장", "전무", "상무", "이사", "부장", "차장", "과장", "대리", "사원")) @@ -13161,16 +14643,24 @@ def _cost_analysis_load_hanmac_labor_detail_rows( codes = _cost_analysis_resolve_hanmac_project_codes(project, work_date, alias_to_code, title_to_codes, project_meta) if not codes: return + source_codes = _cost_analysis_project_source_codes(project) year_text = str((work_date or start_date).year) split_hours = hours / len(codes) + cost_weight = normalize_amount(project.get("cost_weight")) or 1.0 for code in codes: normalized_code = normalize_text(code).upper() - if normalized_code not in normalized_requested_codes: + is_common_activity = _cost_analysis_is_common_activity_code(normalized_code) + if ( + normalized_code not in normalized_requested_codes + and not (set(source_codes) & normalized_requested_codes) + and not (is_common_activity and "ZZZZZZ" in normalized_requested_codes) + ): continue phase = _cost_analysis_phase_for_transaction( normalized_code, (work_date or start_date).isoformat(), completion_dates, + project_meta, ) if normalized_phase and normalized_phase != "all" and phase != normalized_phase: continue @@ -13185,7 +14675,17 @@ def _cost_analysis_load_hanmac_labor_detail_rows( continue member_no = normalize_text(source_row.get("member_no")) member_name = normalize_text(source_row.get("member_name")) - key = (member_no, member_name, member_grade) + source_project_code = next( + (source_code for source_code in source_codes if source_code.startswith("H")), + normalized_code, + ) + total_project_code = ( + "ZZZZZZ" + if is_common_activity + else normalize_text(representative_map.get(normalized_code, "")).upper() + ) + display_project_code = source_project_code if is_common_activity else normalized_code + key = (member_no, member_name, member_grade, display_project_code) detail = detail_by_member.setdefault( key, { @@ -13193,14 +14693,37 @@ def _cost_analysis_load_hanmac_labor_detail_rows( "dept_name": normalize_text(source_row.get("dept_name")), "member_name": member_name, "member_grade": member_grade, + "total_project_code": total_project_code, + "project_code": display_project_code, + "source_project_code": source_project_code, + "project_name": ( + (_cost_analysis_common_activity_info(project) or {}).get("label") + if is_common_activity + else normalize_text(project.get("project_name")) + ), "regular_hours": 0.0, "overtime_hours": 0.0, "holiday_hours": 0.0, + "regular_amount": 0.0, + "overtime_base_amount": 0.0, + "overtime_premium_amount": 0.0, + "holiday_base_amount": 0.0, + "holiday_premium_amount": 0.0, "amount": 0.0, }, ) detail[f"{hour_kind}_hours"] += split_hours - detail["amount"] += rate * split_hours + base_amount = rate * split_hours * cost_weight + premium_amount = base_amount * 0.5 if hour_kind in {"overtime", "holiday"} else 0.0 + if hour_kind == "regular": + detail["regular_amount"] += base_amount + elif hour_kind == "overtime": + detail["overtime_base_amount"] += base_amount + detail["overtime_premium_amount"] += premium_amount + else: + detail["holiday_base_amount"] += base_amount + detail["holiday_premium_amount"] += premium_amount + detail["amount"] += base_amount + premium_amount for item in row_items: try: @@ -13249,7 +14772,15 @@ def _cost_analysis_load_hanmac_labor_detail_rows( holiday_hours = normalize_amount(detail.get("holiday_hours")) detail["total_hours"] = regular_hours + overtime_hours + holiday_hours detail["extra_hours"] = overtime_hours + holiday_hours - detail["amount"] = int(round(normalize_amount(detail.get("amount")))) + for field in ( + "regular_amount", + "overtime_base_amount", + "overtime_premium_amount", + "holiday_base_amount", + "holiday_premium_amount", + "amount", + ): + detail[field] = int(round(normalize_amount(detail.get(field)))) rows.append(detail) return sorted( rows, @@ -13257,6 +14788,7 @@ def _cost_analysis_load_hanmac_labor_detail_rows( normalize_text(item.get("dept_name")), grade_order.get(normalize_text(item.get("member_grade")), len(grade_order)), normalize_text(item.get("member_no")), + normalize_text(item.get("project_code")), ), ) @@ -13268,7 +14800,7 @@ def _cost_analysis_load_hanmac_labor_detail_rows_yearly( requested_phase: str, project_meta: dict[str, dict[str, Any]], ) -> list[dict[str, Any]]: - merged: dict[tuple[str, str, str], dict[str, Any]] = {} + merged: dict[tuple[str, str, str, str], dict[str, Any]] = {} for year_slice in _iter_year_slices(start_date, end_date): for row in _cost_analysis_load_hanmac_labor_detail_rows( year_slice["start"], @@ -13281,6 +14813,7 @@ def _cost_analysis_load_hanmac_labor_detail_rows_yearly( normalize_text(row.get("member_no")), normalize_text(row.get("member_name")), normalize_text(row.get("member_grade")), + normalize_text(row.get("project_code")), ) detail = merged.setdefault( key, @@ -13289,13 +14822,32 @@ def _cost_analysis_load_hanmac_labor_detail_rows_yearly( "dept_name": normalize_text(row.get("dept_name")), "member_name": normalize_text(row.get("member_name")), "member_grade": normalize_text(row.get("member_grade")), + "total_project_code": normalize_text(row.get("total_project_code")), + "project_code": normalize_text(row.get("project_code")), + "source_project_code": normalize_text(row.get("source_project_code")), + "project_name": normalize_text(row.get("project_name")), "regular_hours": 0.0, "overtime_hours": 0.0, "holiday_hours": 0.0, + "regular_amount": 0.0, + "overtime_base_amount": 0.0, + "overtime_premium_amount": 0.0, + "holiday_base_amount": 0.0, + "holiday_premium_amount": 0.0, "amount": 0.0, }, ) - for field in ("regular_hours", "overtime_hours", "holiday_hours", "amount"): + for field in ( + "regular_hours", + "overtime_hours", + "holiday_hours", + "regular_amount", + "overtime_base_amount", + "overtime_premium_amount", + "holiday_base_amount", + "holiday_premium_amount", + "amount", + ): detail[field] += normalize_amount(row.get(field)) rows = [] for detail in merged.values(): @@ -13304,7 +14856,15 @@ def _cost_analysis_load_hanmac_labor_detail_rows_yearly( holiday_hours = normalize_amount(detail.get("holiday_hours")) detail["total_hours"] = regular_hours + overtime_hours + holiday_hours detail["extra_hours"] = overtime_hours + holiday_hours - detail["amount"] = int(round(normalize_amount(detail.get("amount")))) + for field in ( + "regular_amount", + "overtime_base_amount", + "overtime_premium_amount", + "holiday_base_amount", + "holiday_premium_amount", + "amount", + ): + detail[field] = int(round(normalize_amount(detail.get(field)))) rows.append(detail) return sorted( rows, @@ -13312,10 +14872,192 @@ def _cost_analysis_load_hanmac_labor_detail_rows_yearly( normalize_text(item.get("dept_name")), normalize_text(item.get("member_grade")), normalize_text(item.get("member_no")), + normalize_text(item.get("project_code")), ), ) +def _is_hanmac_joint_detail(value: dict[str, Any]) -> bool: + return bool( + normalize_text(value.get("joint_code")) + or "합사" in normalize_text(value.get("joint_label")) + or "합사" in normalize_text(value.get("source_label")) + or "합사" in normalize_text(value.get("source")) + or "합사" in normalize_text(value.get("note")) + ) + +def _clear_project_cost_related_caches() -> None: + with _PROCESS_COST_RUNTIME_CACHE_LOCK: + _PROCESS_COST_PROJECT_OPTIONS_CACHE.clear() + _PROCESS_COST_PROJECT_DETAIL_CACHE.clear() + with engine.begin() as conn: + conn.execute(text("DELETE FROM system_page_cache WHERE page_key IN ('process_cost_bootstrap', 'cost_analysis_payload')")) + _clear_cost_analysis_payload_caches() + +def _cost_analysis_build_post_labor_summary( + start_date: date, + end_date: date, + codes: list[str], + row: dict[str, Any], + project_meta: dict[str, dict[str, Any]], + completion_dates: dict[str, str], +) -> dict[str, Any]: + normalized_codes = [normalize_text(code).upper() for code in codes if normalize_text(code)] + normalized_codes = [code for code in normalized_codes if code and code not in COST_ANALYSIS_COMMON_CODES] + if not normalized_codes: + return {} + completion_date_values = sorted({completion_dates.get(code, "") for code in normalized_codes if completion_dates.get(code, "")}) + completion_label = completion_date_values[0] if len(completion_date_values) == 1 else "코드별 준공금 기준" + detail_rows = _cost_analysis_load_hanmac_labor_detail_rows_yearly( + start_date, + end_date, + normalized_codes, + "post", + project_meta, + ) + if not detail_rows: + return { + "completion_billing_date": completion_label, + "member_count": 0, + "total_hours": 0.0, + "extra_hours": 0.0, + "amount": 0, + "display_amount": int(round(normalize_amount(((row.get("phases") or {}).get("post") or {}).get("labor")))), + "difference": -int(round(normalize_amount(((row.get("phases") or {}).get("post") or {}).get("labor")))), + "matches_display": False, + "people": [], + } + + total_hours = sum(normalize_amount(item.get("total_hours")) for item in detail_rows) + extra_hours = sum(normalize_amount(item.get("extra_hours")) for item in detail_rows) + amount = int(round(sum(normalize_amount(item.get("amount")) for item in detail_rows))) + display_amount = int(round(normalize_amount(((row.get("phases") or {}).get("post") or {}).get("labor")))) + unique_members = { + ( + normalize_text(item.get("member_no")), + normalize_text(item.get("member_name")), + normalize_text(item.get("member_grade")), + ) + for item in detail_rows + } + return { + "completion_billing_date": completion_label, + "member_count": len(unique_members), + "total_hours": round(total_hours, 2), + "extra_hours": round(extra_hours, 2), + "amount": amount, + "display_amount": display_amount, + "difference": int(round(amount - display_amount)), + "matches_display": abs(amount - display_amount) < 1, + "people": [ + { + "member_no": normalize_text(item.get("member_no")), + "member_name": normalize_text(item.get("member_name")), + "member_grade": normalize_text(item.get("member_grade")), + "total_project_code": normalize_text(item.get("total_project_code")), + "project_code": normalize_text(item.get("project_code")), + "regular_hours": round(normalize_amount(item.get("regular_hours")), 2), + "extra_hours": round(normalize_amount(item.get("extra_hours")), 2), + "total_hours": round(normalize_amount(item.get("total_hours")), 2), + "amount": int(round(normalize_amount(item.get("amount")))), + } + for item in detail_rows + ], + } + + +def _cost_analysis_build_allocated_common_detail_rows( + start_date: date, + end_date: date, + requested_codes: list[str], + requested_phase: str, + project_meta: dict[str, dict[str, Any]], + requested_item: str, +) -> list[dict[str, Any]]: + normalized_codes = {normalize_text(code).upper() for code in requested_codes if normalize_text(code)} + normalized_codes -= COST_ANALYSIS_COMMON_CODES + if not normalized_codes: + return [] + with engine.begin() as conn: + annual_common_rows = conn.execute( + text( + f""" + SELECT CAST(strftime('%Y', {COST_ANALYSIS_TX_DATE_SQL}) AS INTEGER) AS posting_year, + SUM( + CASE + WHEN account_code LIKE '6%' THEN COALESCE(amount, 0) + ELSE 0 + END + ) AS sga_amount, + SUM( + CASE + WHEN account_code LIKE '5%' + AND NOT ({COST_ANALYSIS_LABOR_ACCOUNT_SQL}) + THEN COALESCE(amount, 0) + ELSE 0 + END + ) AS common_cost_amount + FROM transactions + WHERE UPPER(COALESCE(support_dept_code, '')) IN ('', 'ZZZZZZ') + AND (account_code LIKE '5%' OR account_code LIKE '6%') + AND {COST_ANALYSIS_TX_DATE_SQL} >= :start_date + AND {COST_ANALYSIS_TX_DATE_SQL} <= :end_date + GROUP BY CAST(strftime('%Y', {COST_ANALYSIS_TX_DATE_SQL}) AS INTEGER) + """ + ), + {"start_date": start_date.isoformat(), "end_date": end_date.isoformat()}, + ).mappings().all() + normalized_item = normalize_text(requested_item).lower() + amount_field = "sga_amount" if normalized_item == "sga" else "common_cost_amount" + account_label = "공통 판관비 배부" if normalized_item == "sga" else "공통 제경비 배부" + annual_totals = { + int(row["posting_year"]): normalize_amount(row.get(amount_field)) + for row in annual_common_rows + if row.get("posting_year") + } + annual_hanmac_project_hours_by_year, _, annual_hanmac_missing_sga_by_year = _cost_analysis_load_hanmac_hours_and_labor_yearly( + start_date, + end_date, + project_meta, + None, + ) + normalized_phase = normalize_text(requested_phase).lower() + rows: list[dict[str, Any]] = [] + for year_slice in _iter_year_slices(start_date, end_date): + year = int(year_slice["year"]) + annual_total = annual_totals.get(year, 0.0) + if normalized_item == "sga": + annual_total += normalize_amount(annual_hanmac_missing_sga_by_year.get(year)) + hanmac_project_hours = annual_hanmac_project_hours_by_year.get(year, {}) + period_total_hours = sum( + normalize_amount(hours) + for phase_hours in hanmac_project_hours.values() + for hours in phase_hours.values() + ) + if annual_total <= 0 or period_total_hours <= 0: + continue + hourly_amount = annual_total / period_total_hours + for code in sorted(normalized_codes): + phase_hours = hanmac_project_hours.get(code) or {} + for phase, hours_value in phase_hours.items(): + if normalized_phase and normalized_phase != "all" and phase != normalized_phase: + continue + hours = normalize_amount(hours_value) + amount = int(round(hourly_amount * hours)) + if not amount: + continue + rows.append( + { + "posting_date": f"{year}", + "account_name": account_label, + "partner_name": code, + "memo1": f"{phase} 한맥근무 {hours:,.1f}h 기준 배부", + "amount": amount, + } + ) + return rows + + def _cost_analysis_collect_missing_hanmac_grade_rows( start_date: date, end_date: date, @@ -13363,7 +15105,7 @@ def _cost_analysis_collect_missing_hanmac_grade_rows( if allowed_codes is not None and normalized_code not in allowed_codes: continue meta = project_meta.get(normalized_code) or {} - phase = _cost_analysis_phase_for_transaction(normalized_code, effective_date.isoformat(), completion_dates) + phase = _cost_analysis_phase_for_transaction(normalized_code, effective_date.isoformat(), completion_dates, project_meta) result_rows.append( { "work_date": effective_date.isoformat(), @@ -13508,6 +15250,14 @@ def _cost_analysis_resolve_hanmac_project_codes( project_meta: dict[str, dict[str, Any]], ) -> list[str]: title_key = normalize_project_title_for_linking(project.get("project_name")) + source_codes = _cost_analysis_project_source_codes(project) + for source_code in source_codes: + confirmed_code = COST_ANALYSIS_CONFIRMED_H_PROJECT_CODE_MAP.get(source_code) + if confirmed_code and confirmed_code in project_meta: + return [confirmed_code] + confirmed_title_code = COST_ANALYSIS_CONFIRMED_H_TITLE_CODE_MAP.get(title_key) + if confirmed_title_code and confirmed_title_code in project_meta: + return [confirmed_title_code] title_candidates = title_to_codes.get(title_key, []) for value in [project.get("project_code"), *(project.get("equivalent_project_codes") or [])]: alias = normalize_text(value).upper() @@ -13522,7 +15272,8 @@ def _cost_analysis_resolve_hanmac_project_codes( break candidates = title_candidates if not candidates: - return [] + common_activity = _cost_analysis_common_activity_info(project) + return [common_activity["key"]] if common_activity else [] xyz_candidates = [ code for code in candidates @@ -13629,7 +15380,7 @@ def _cost_analysis_load_hanmac_project_hours_by_year( normalized_code = normalize_text(code).upper() if allowed_codes is not None and normalized_code not in allowed_codes: continue - phase = _cost_analysis_phase_for_transaction(normalized_code, effective_date.isoformat(), completion_dates) + phase = _cost_analysis_phase_for_transaction(normalized_code, effective_date.isoformat(), completion_dates, project_meta) result.setdefault(effective_date.year, {}).setdefault( normalized_code, {"pre": 0.0, "during": 0.0, "post": 0.0}, @@ -13661,16 +15412,64 @@ def _cost_analysis_load_hanmac_project_hours_by_year( return result +_COST_ANALYSIS_ADMIN_DEPT_NAMES = { + "경영지원부", + "임원실", + "총괄기획실", + "기술개발센터", + "기술개발부", + "공통", + "관리실", + "인사총무", + "사업관리", +} + + +def _cost_analysis_normalize_dept_name(value: Any) -> str: + return re.sub(r"\s+", "", normalize_text(value)) + + +def _cost_analysis_is_admin_dept_name(value: Any) -> bool: + dept_name = _cost_analysis_normalize_dept_name(value) + if not dept_name: + return True + admin_names = {_cost_analysis_normalize_dept_name(name) for name in _COST_ANALYSIS_ADMIN_DEPT_NAMES} + if dept_name in admin_names: + return True + return any(token in dept_name for token in ("경영지원", "임원", "총괄", "관리실", "인사총무", "사업관리", "센터")) + + +@lru_cache(maxsize=1) +def _cost_analysis_field_cost_dept_names() -> set[str]: + init_db() + with engine.begin() as conn: + rows = conn.execute( + text( + """ + SELECT DISTINCT COALESCE(cost_dept_name, '') AS cost_dept_name + FROM transactions + WHERE UPPER(COALESCE(support_dept_code, '')) NOT IN ('', 'ZZZZZZ') + AND COALESCE(cost_dept_name, '') <> '' + """ + ) + ).mappings().all() + return { + _cost_analysis_normalize_dept_name(row.get("cost_dept_name")) + for row in rows + if not _cost_analysis_is_admin_dept_name(row.get("cost_dept_name")) + } + + def _cost_analysis_load_hanmac_hours_and_labor_by_year( start_date: date, end_date: date, project_meta: dict[str, dict[str, Any]], allowed_codes: set[str] | None = None, -) -> tuple[dict[int, dict[str, dict[str, float]]], dict[int, dict[str, dict[str, float]]]]: +) -> tuple[dict[int, dict[str, dict[str, float]]], dict[int, dict[str, dict[str, float]]], dict[int, float]]: alias_to_code, title_to_codes = _cost_analysis_build_hanmac_matchers(project_meta) metric, row_items = _cost_analysis_load_hanmac_member_rows(start_date, end_date, prefer_member_grade=True) if not metric: - return {}, {} + return {}, {}, {} rates_by_year = _parse_labor_rates_json(get_shared_exec_labor_rates_json()) if not rates_by_year: @@ -13678,9 +15477,12 @@ def _cost_analysis_load_hanmac_hours_and_labor_by_year( completion_dates = _cost_analysis_get_completion_billing_dates() hours_result: dict[int, dict[str, dict[str, float]]] = {} labor_result: dict[int, dict[str, dict[str, float]]] = {} + missing_sga_result: dict[int, float] = {} + dept_project_phase_hours: dict[int, dict[str, dict[tuple[str, str], float]]] = {} resolve_cache: dict[tuple[str, str, str], list[str]] = {} + field_dept_names = _cost_analysis_field_cost_dept_names() - def add_project(project: dict[str, Any], work_date_text: Any, hours: float, member_grade: str = "") -> None: + def add_project(project: dict[str, Any], work_date_text: Any, hours: float, member_grade: str = "", dept_name: str = "") -> None: if hours <= 0: return work_date = _parse_iso_date(work_date_text) @@ -13701,17 +15503,25 @@ def _cost_analysis_load_hanmac_hours_and_labor_by_year( return split_hours = hours / len(codes) + cost_weight = normalize_amount(project.get("cost_weight")) or 1.0 year = effective_date.year year_text = str(year) for code in codes: normalized_code = normalize_text(code).upper() if allowed_codes is not None and normalized_code not in allowed_codes: continue - phase = _cost_analysis_phase_for_transaction(normalized_code, effective_date.isoformat(), completion_dates) + phase = _cost_analysis_phase_for_transaction(normalized_code, effective_date.isoformat(), completion_dates, project_meta) + normalized_dept_name = _cost_analysis_normalize_dept_name(dept_name) hours_result.setdefault(year, {}).setdefault( normalized_code, {"pre": 0.0, "during": 0.0, "post": 0.0}, )[phase] += split_hours + if normalized_dept_name and normalized_dept_name in field_dept_names: + split_key = (normalized_code, phase) + dept_project_phase_hours.setdefault(year, {}).setdefault(normalized_dept_name, {}) + dept_project_phase_hours[year][normalized_dept_name][split_key] = ( + dept_project_phase_hours[year][normalized_dept_name].get(split_key, 0.0) + split_hours + ) if not member_grade: continue @@ -13727,7 +15537,7 @@ def _cost_analysis_load_hanmac_hours_and_labor_by_year( labor_result.setdefault(year, {}).setdefault( normalized_code, {"pre": 0.0, "during": 0.0, "post": 0.0}, - )[phase] += rate * split_hours + )[phase] += rate * split_hours * cost_weight for row in row_items: member_grade = _normalize_labor_grade_name( @@ -13736,6 +15546,7 @@ def _cost_analysis_load_hanmac_hours_and_labor_by_year( or row.get("position") or row.get("rank") ) + dept_name = normalize_text(row.get("dept_name")) details = row.get("aggregate_details") if isinstance(row.get("aggregate_details"), dict) else {} for detail in details.get("regular_hours") or []: projects = detail.get("projects") if isinstance(detail.get("projects"), list) else [] @@ -13743,10 +15554,15 @@ def _cost_analysis_load_hanmac_hours_and_labor_by_year( recognized_total = normalize_amount(detail.get("regular_hours")) for project in projects: raw_hours = normalize_amount(project.get("hours")) - hours = recognized_total * raw_hours / raw_total if raw_total > 0 and recognized_total > 0 else raw_hours - add_project(project, detail.get("work_date"), hours, member_grade) + joint_recognized_hours = normalize_amount(project.get("recognized_hours")) if _is_hanmac_joint_detail(project) else 0.0 + hours = ( + joint_recognized_hours + if joint_recognized_hours > 0 + else recognized_total * raw_hours / raw_total if raw_total > 0 and recognized_total > 0 else raw_hours + ) + add_project(project, detail.get("work_date"), hours, member_grade, dept_name) for detail in details.get("overtime_hours") or []: - add_project(detail, detail.get("work_date"), normalize_amount(detail.get("overtime_hours")), member_grade) + add_project(detail, detail.get("work_date"), normalize_amount(detail.get("overtime_hours")), member_grade, dept_name) for detail in details.get("holiday_hours") or []: projects = detail.get("projects") if isinstance(detail.get("projects"), list) else [] if projects: @@ -13755,10 +15571,50 @@ def _cost_analysis_load_hanmac_hours_and_labor_by_year( for project in projects: raw_hours = normalize_amount(project.get("hours")) hours = recognized_total * raw_hours / raw_total if raw_total > 0 and recognized_total > 0 else raw_hours - add_project(project, detail.get("work_date"), hours, member_grade) + add_project(project, detail.get("work_date"), hours, member_grade, dept_name) else: - add_project(detail, detail.get("work_date"), normalize_amount(detail.get("holiday_hours")), member_grade) - return hours_result, labor_result + add_project(detail, detail.get("work_date"), normalize_amount(detail.get("holiday_hours")), member_grade, dept_name) + + for row in row_items: + member_grade = _normalize_labor_grade_name( + row.get("member_grade") + or row.get("grade") + or row.get("position") + or row.get("rank") + ) + if not member_grade: + continue + normalized_dept_name = _cost_analysis_normalize_dept_name(row.get("dept_name")) + details = row.get("aggregate_details") if isinstance(row.get("aggregate_details"), dict) else {} + for detail in details.get("missing_regular_days") or []: + missing_hours = normalize_amount(detail.get("missing_hours")) + if missing_hours <= 0: + continue + work_date = _parse_iso_date(detail.get("work_date")) or start_date + if work_date < start_date or work_date > end_date: + continue + year = work_date.year + year_text = str(year) + rate = _resolve_labor_rate(rates_by_year, member_grade, year_text, year_text, "") + missing_amount = rate * missing_hours + if missing_amount <= 0: + continue + dept_ratios = dept_project_phase_hours.get(year, {}).get(normalized_dept_name, {}) + dept_total_hours = sum(max(0.0, normalize_amount(hours)) for hours in dept_ratios.values()) + if normalized_dept_name in field_dept_names and dept_total_hours > 0: + for (code, phase), hours in dept_ratios.items(): + if allowed_codes is not None and code not in allowed_codes: + continue + amount = missing_amount * (normalize_amount(hours) / dept_total_hours) + if amount <= 0: + continue + labor_result.setdefault(year, {}).setdefault( + code, + {"pre": 0.0, "during": 0.0, "post": 0.0}, + )[phase] += amount + else: + missing_sga_result[year] = missing_sga_result.get(year, 0.0) + missing_amount + return hours_result, labor_result, missing_sga_result def _cost_analysis_load_hanmac_hours_and_labor_yearly( @@ -13766,13 +15622,15 @@ def _cost_analysis_load_hanmac_hours_and_labor_yearly( end_date: date, project_meta: dict[str, dict[str, Any]], allowed_codes: set[str] | None = None, -) -> tuple[dict[int, dict[str, dict[str, float]]], dict[int, dict[str, dict[str, float]]]]: +) -> tuple[dict[int, dict[str, dict[str, float]]], dict[int, dict[str, dict[str, float]]], dict[int, float]]: hours_result: dict[int, dict[str, dict[str, float]]] = {} labor_result: dict[int, dict[str, dict[str, float]]] = {} + missing_sga_result: dict[int, float] = {} def merge( hours_by_year: dict[int, dict[str, dict[str, float]]], labor_by_year: dict[int, dict[str, dict[str, float]]], + missing_sga_by_year: dict[int, float], ) -> None: for year, code_map in hours_by_year.items(): for code, phase_hours in code_map.items(): @@ -13786,6 +15644,8 @@ def _cost_analysis_load_hanmac_hours_and_labor_yearly( for phase, amount in phase_amounts.items(): if phase in target: target[phase] += normalize_amount(amount) + for year, amount in missing_sga_by_year.items(): + missing_sga_result[year] = missing_sga_result.get(year, 0.0) + normalize_amount(amount) prefix_metric = _cost_analysis_select_hanmac_prefix_metric(start_date, end_date, prefer_member_grade=True) prefix_end = _parse_iso_date((prefix_metric or {}).get("end_date")) @@ -13798,7 +15658,7 @@ def _cost_analysis_load_hanmac_hours_and_labor_yearly( )) start_date = min(prefix_end, end_date) + timedelta(days=1) if start_date > end_date: - return hours_result, labor_result + return hours_result, labor_result, missing_sga_result for year_slice in _iter_year_slices(start_date, end_date): merge(*_cost_analysis_load_hanmac_hours_and_labor_by_year( year_slice["start"], @@ -13806,7 +15666,25 @@ def _cost_analysis_load_hanmac_hours_and_labor_yearly( project_meta, allowed_codes, )) - return hours_result, labor_result + return hours_result, labor_result, missing_sga_result + + +def _cost_analysis_hanmac_labor_totals_by_year( + labor_by_year: dict[int, dict[str, dict[str, float]]], + missing_sga_by_year: dict[int, float], +) -> dict[int, float]: + years = set(labor_by_year) | set(missing_sga_by_year) + return { + year: ( + sum( + normalize_amount(amount) + for phase_amounts in labor_by_year.get(year, {}).values() + for amount in phase_amounts.values() + ) + + normalize_amount(missing_sga_by_year.get(year)) + ) + for year in years + } def _cost_analysis_get_annual_hanmac_total_hours(year: int) -> float: @@ -13819,15 +15697,21 @@ def _cost_analysis_get_annual_hanmac_total_hours(year: int) -> float: SELECT cache_key, summary_json FROM hanmac_aggregate_query_metrics WHERE view_mode = 'member' + AND payload_signature LIKE :compatible_signature_pattern AND COALESCE(start_date, '') <= :year_start AND COALESCE(end_date, '') >= :year_end ORDER BY + CASE WHEN payload_signature LIKE :current_signature_prefix THEN 0 ELSE 1 END, CASE WHEN start_date = :year_start AND end_date = :year_end THEN 0 ELSE 1 END, updated_at DESC LIMIT 1 """ ), - {"year_start": year_start, "year_end": year_end}, + { + "year_start": year_start, + "year_end": year_end, + **_cost_analysis_hanmac_signature_params(), + }, ).mappings().first() if not metric: return 0.0 @@ -13851,21 +15735,44 @@ def _cost_analysis_get_annual_hanmac_total_hours(year: int) -> float: return sum(normalize_amount((json.loads(row) if row else {}).get("total_hours")) for row in rows) -def _cost_analysis_phase_for_transaction(code: str, posting_date: str, completion_dates: dict[str, str]) -> str: +def _cost_analysis_phase_for_transaction( + code: str, + posting_date: str, + completion_dates: dict[str, str], + project_meta: dict[str, dict[str, Any]] | None = None, +) -> str: normalized_code = normalize_text(code).upper() + if _cost_analysis_is_common_activity_code(normalized_code): + return "during" + meta = (project_meta or {}).get(normalized_code) or {} if normalized_code.startswith("X"): return "pre" if normalized_code.startswith(("Y", "Z")): + has_start_date = bool(normalize_text(meta.get("project_start_date"))) completion_date = completion_dates.get(normalized_code, "") - if completion_date and posting_date and posting_date >= completion_date: + collection_complete = ( + normalize_amount(meta.get("collection_rate")) >= 99.5 + or ( + normalize_amount(meta.get("contract_amount")) > 0 + and normalize_amount(meta.get("collected_amount")) >= normalize_amount(meta.get("contract_amount")) - 1 + ) + or ( + normalize_amount(meta.get("collected_amount")) > 0 + and abs(normalize_amount(meta.get("collection_balance_amount"))) <= 1 + ) + ) + if completion_date and posting_date and posting_date >= completion_date and (has_start_date or collection_complete): return "post" return "during" + if meta and (normalize_text(meta.get("project_type")) == "사업전" or not normalize_text(meta.get("project_start_date"))): + return "pre" return "during" def _cost_analysis_row_template(code: str, meta: dict[str, Any], selected_year: int | None) -> dict[str, Any]: contract_amount = normalize_amount(meta.get("contract_amount")) return { + "row_key": normalize_text(meta.get("row_key")) or code, "support_dept_code": code, "pm_department": normalize_text(meta.get("pm_department")), "year": selected_year or "", @@ -13878,9 +15785,14 @@ def _cost_analysis_row_template(code: str, meta: dict[str, Any], selected_year: "billing_amount": 0.0, "collection_amount": 0.0, "period_billing_amount": 0.0, + "period_negative_billing_amount": 0.0, "period_collection_amount": 0.0, "period_revenue_amount": 0.0, + "period_revenue_billing_gap": 0.0, + "period_revenue_collection_gap": 0.0, "period_cost_total": 0.0, + "period_cost_labor_total": 0.0, + "period_sga_labor_total": 0.0, "period_sga_total": 0.0, "period_sales_total": 0.0, "period_total_cost": 0.0, @@ -13888,6 +15800,12 @@ def _cost_analysis_row_template(code: str, meta: dict[str, Any], selected_year: "period_revenue_profit_rate": 0.0, "period_collection_profit_rate": 0.0, "cumulative_profit_rate": 0.0, + "cumulative_revenue_amount": 0.0, + "cumulative_cost_total": 0.0, + "cumulative_sga_total": 0.0, + "cumulative_sales_total": 0.0, + "cumulative_total_cost": 0.0, + "cumulative_profit_amount": 0.0, "contract_balance_amount": contract_amount, "collection_rate": 0.0, "revenue_amount": 0.0, @@ -13901,37 +15819,53 @@ def _cost_analysis_row_template(code: str, meta: dict[str, Any], selected_year: "collection_profit_rate": 0.0, "total_cost": 0.0, "cost_total": 0.0, + "cost_labor_total": 0.0, + "sga_labor_total": 0.0, "sga_total": 0.0, "sales_total": 0.0, + "is_common_revenue": False, + "common_revenue_amount": 0.0, } def _cost_analysis_finalize_row(row: dict[str, Any]) -> None: cost_total = 0.0 + cost_labor_total = 0.0 + sga_labor_total = 0.0 sga_total = 0.0 sales_total = 0.0 for phase_name, buckets in row["phases"].items(): for item_key, amount in buckets.items(): amount = normalize_amount(amount) - if item_key in {"labor", "outsource", "overhead"}: + if item_key in {"labor", "labor_adjustment", "outsource", "overhead"}: cost_total += amount + if item_key in {"labor", "labor_adjustment"}: + cost_labor_total += amount elif item_key == "sales": sales_total += amount else: sga_total += amount + if item_key in {"sga_labor", "sga_labor_adjustment"}: + sga_labor_total += amount row["cost_total"] = cost_total + row["cost_labor_total"] = cost_labor_total + row["sga_labor_total"] = sga_labor_total row["sga_total"] = sga_total row["sales_total"] = sales_total row["total_cost"] = cost_total + sga_total + sales_total row["profit_amount"] = normalize_amount(row.get("revenue_amount")) - row["total_cost"] row["period_revenue_amount"] = normalize_amount(row.get("period_revenue_amount")) row["period_cost_total"] = cost_total + row["period_cost_labor_total"] = cost_labor_total + row["period_sga_labor_total"] = sga_labor_total row["period_sga_total"] = sga_total row["period_sales_total"] = sales_total row["period_total_cost"] = row["period_cost_total"] + row["period_sga_total"] + row["period_sales_total"] row["period_profit_amount"] = row["period_revenue_amount"] - row["period_total_cost"] + row["period_revenue_billing_gap"] = row["period_revenue_amount"] - normalize_amount(row.get("period_billing_amount")) + row["period_revenue_collection_gap"] = row["period_revenue_amount"] - normalize_amount(row.get("period_collection_amount")) row["contract_balance_amount"] = max( - normalize_amount(row.get("contract_amount")) - normalize_amount(row.get("collection_amount")), + normalize_amount(row.get("contract_amount")) - normalize_amount(row.get("billing_amount")), 0.0, ) row["collection_rate"] = _safe_ratio(row.get("collection_amount"), row.get("contract_amount")) @@ -13940,10 +15874,56 @@ def _cost_analysis_finalize_row(row: dict[str, Any]) -> None: row["collection_profit_rate"] = _safe_ratio(row.get("profit_amount"), row.get("collection_amount")) row["period_revenue_profit_rate"] = _safe_ratio(row.get("period_profit_amount"), row.get("period_revenue_amount")) row["period_collection_profit_rate"] = _safe_ratio(row.get("period_profit_amount"), row.get("period_collection_amount")) - row["cumulative_profit_rate"] = _safe_ratio( - normalize_amount(row.get("collection_amount")) - row["total_cost"], - row.get("collection_amount"), + row["cumulative_revenue_amount"] = normalize_amount(row.get("cumulative_revenue_amount")) + row["cumulative_cost_total"] = normalize_amount(row.get("cumulative_cost_total")) + row["cumulative_sga_total"] = normalize_amount(row.get("cumulative_sga_total")) + row["cumulative_sales_total"] = normalize_amount(row.get("cumulative_sales_total")) + row["cumulative_total_cost"] = normalize_amount(row.get("cumulative_total_cost")) + row["cumulative_profit_amount"] = ( + row["cumulative_revenue_amount"] - row["cumulative_total_cost"] ) + row["cumulative_profit_rate"] = _safe_ratio( + row["cumulative_profit_amount"], + row["cumulative_revenue_amount"], + ) + + +def _cost_analysis_clean_common_master_row(row: dict[str, Any]) -> None: + if not row.get("is_common_master"): + return + preserved_period_revenue = normalize_amount(row.get("period_revenue_amount")) + preserved_collection = normalize_amount(row.get("collection_amount")) + preserved_period_collection = normalize_amount(row.get("period_collection_amount")) + for key in ( + "billing_amount", "collection_amount", "period_billing_amount", + "period_negative_billing_amount", "period_collection_amount", + "period_revenue_billing_gap", "period_revenue_collection_gap", + "period_cost_total", "period_cost_labor_total", "period_sga_labor_total", + "period_sga_total", "period_sales_total", "period_total_cost", + "period_profit_amount", "period_revenue_profit_rate", + "period_collection_profit_rate", "contract_amount", + "contract_balance_amount", "collection_rate", "revenue_amount", + "cost_total", "cost_labor_total", "sga_labor_total", "sga_total", + "sales_total", "total_cost", "profit_amount", "contract_profit_rate", + "revenue_profit_rate", "collection_profit_rate", + "cumulative_revenue_amount", "cumulative_cost_total", + "cumulative_sga_total", "cumulative_sales_total", + "cumulative_total_cost", "cumulative_profit_amount", + "cumulative_profit_rate", + ): + row[key] = 0.0 + row["period_revenue_amount"] = preserved_period_revenue + row["collection_amount"] = preserved_collection + row["period_collection_amount"] = preserved_period_collection + row["period_revenue_collection_gap"] = preserved_period_revenue - preserved_period_collection + row["period_profit_amount"] = preserved_period_revenue + row["profit_amount"] = preserved_period_revenue + row["period_revenue_profit_rate"] = _safe_ratio(preserved_period_revenue, preserved_period_revenue) + row["period_collection_profit_rate"] = _safe_ratio(preserved_period_revenue, preserved_period_collection) + row["collection_profit_rate"] = _safe_ratio(preserved_period_revenue, preserved_collection) + row["cumulative_revenue_amount"] = preserved_period_revenue + row["cumulative_profit_amount"] = preserved_period_revenue + row["cumulative_profit_rate"] = _safe_ratio(preserved_period_revenue, preserved_period_revenue) def _cost_analysis_active_in_year(meta: dict[str, Any], year: int) -> bool: @@ -13976,23 +15956,92 @@ def _cost_analysis_project_group_key(code: str, row: dict[str, Any]) -> str: return normalized_code -def _cost_analysis_get_link_representative_map() -> dict[str, str]: +def _cost_analysis_link_map_source_version() -> str: + with engine.begin() as conn: + row = conn.execute( + text( + """ + SELECT + (SELECT COUNT(*) FROM project_related_links) AS link_count, + (SELECT COALESCE(MAX(updated_at), '') FROM project_related_links) AS link_updated_at, + (SELECT COUNT(*) FROM project_billing_entries) AS billing_count, + (SELECT COALESCE(MAX(updated_at), '') FROM project_billing_entries) AS billing_updated_at, + (SELECT COUNT(*) FROM project_basic_info) AS project_count, + (SELECT COALESCE(MAX(updated_at), '') FROM project_basic_info) AS project_updated_at, + (SELECT COUNT(*) FROM satis_project_code_links) AS satis_link_count, + (SELECT COALESCE(MAX(updated_at), '') FROM satis_project_code_links) AS satis_link_updated_at + """ + ) + ).mappings().first() or {} + return _json_hash( + { + "logic": COST_ANALYSIS_LINK_LOGIC_VERSION, + "link_count": int(row.get("link_count") or 0), + "link_updated_at": normalize_text(row.get("link_updated_at")), + "billing_count": int(row.get("billing_count") or 0), + "billing_updated_at": normalize_text(row.get("billing_updated_at")), + "project_count": int(row.get("project_count") or 0), + "project_updated_at": normalize_text(row.get("project_updated_at")), + "satis_link_count": int(row.get("satis_link_count") or 0), + "satis_link_updated_at": normalize_text(row.get("satis_link_updated_at")), + } + ) + + +def _cost_analysis_build_link_representative_map() -> dict[str, str]: with engine.begin() as conn: rows = conn.execute( text( """ - SELECT base_support_dept_code, related_support_dept_code + SELECT base_support_dept_code, related_support_dept_code, link_source FROM project_related_links WHERE COALESCE(base_support_dept_code, '') <> '' AND COALESCE(related_support_dept_code, '') <> '' """ ) ).mappings().all() + billing_rows = conn.execute( + text( + """ + SELECT support_dept_code, raw_project_code, round_code + FROM project_billing_entries + WHERE COALESCE(raw_project_code, '') <> '' + AND COALESCE(round_code, '') <> '' + """ + ) + ).mappings().all() + satis_link_rows = conn.execute( + text( + """ + SELECT + local_project_code, + own_master_project_code, + linked_main_project_code, + cost_project_code, + mapping_status + FROM satis_project_code_links + WHERE COALESCE(local_project_code, '') <> '' + AND COALESCE(mapping_status, '') IN ('confirmed', 'exception') + """ + ) + ).mappings().all() graph: dict[str, set[str]] = {} for row in rows: base = normalize_text(row.get("base_support_dept_code")).upper() related = normalize_text(row.get("related_support_dept_code")).upper() - if not base or not related: + link_source = normalize_text(row.get("link_source")).lower() + if ( + not base + or not related + or link_source not in { + "manual", + "auto_billing", + "auto_satis_code", + "auto_code_family", + "auto_round", + "auto_change_contract", + } + ): continue graph.setdefault(base, set()).add(related) graph.setdefault(related, set()).add(base) @@ -14013,60 +16062,150 @@ def _cost_analysis_get_link_representative_map() -> dict[str, str]: total_codes = sorted(code for code in component if code[:1] in {"0", "9"}) if not total_codes: continue - representative = total_codes[0] for code in component: - representative_map[code] = representative + preferred_prefix = "9" if code.startswith("X") else "0" if code.startswith(("Y", "Z")) else code[:1] + matching_totals = [candidate for candidate in total_codes if candidate.startswith(preferred_prefix)] + representative_map[code] = (matching_totals or total_codes)[0] representative_map.update(_cost_analysis_infer_yz_link_representatives(representative_map)) + # The billing application explicitly stores its parent contract code and + # charged round code. This direct relation is authoritative when a broader + # graph component contains more than one possible total project. + for row in billing_rows: + base_code = normalize_text(row.get("support_dept_code")).upper() + total_code = normalize_actual_project_code(row.get("raw_project_code")) + round_code = normalize_project_code( + row.get("round_code"), + default_prefix=base_code[:1] or "Y", + ) + if total_code[:1] not in {"0", "9"} or not total_code.isdigit(): + continue + if round_code: + representative_map[round_code] = total_code + if base_code: + representative_map[base_code] = total_code + representative_map[total_code] = total_code + # Satis 차수사업코드등록은 총괄/차수/사전사업 연결의 공식 원장이다. + # 프로젝트 손익분석의 집계 대표코드도 동일한 기준을 사용해야 예산 + # 합산 결과와 손익분석 화면의 프로젝트 묶음이 어긋나지 않는다. + for row in satis_link_rows: + local_code = normalize_text(row.get("local_project_code")).upper() + if not local_code or local_code == "ZZZZZZ": + continue + master_code = ( + normalize_text(row.get("linked_main_project_code")).upper() + or normalize_text(row.get("own_master_project_code")).upper() + or normalize_text(row.get("cost_project_code")).upper() + ) + if master_code[:1] not in {"0", "9"} or not master_code[1:].isdigit(): + continue + representative_map[local_code] = master_code + representative_map[master_code] = master_code return representative_map +def _cost_analysis_get_link_representative_map(force: bool = False) -> dict[str, str]: + source_version = _cost_analysis_link_map_source_version() + memory_key = (source_version,) + if not force: + cached = _get_deepcopy_ttl_cache_entry( + _COST_ANALYSIS_LINK_MAP_CACHE, + _COST_ANALYSIS_LINK_MAP_CACHE_LOCK, + memory_key, + COST_ANALYSIS_LINK_MAP_CACHE_TTL_SECONDS, + ) + if cached is not None: + return cached + persistent = _load_system_page_cache("cost_analysis_link_map", source_version) + if persistent is not None and isinstance(persistent.get("representative_map"), dict): + return _set_deepcopy_ttl_cache_entry( + _COST_ANALYSIS_LINK_MAP_CACHE, + _COST_ANALYSIS_LINK_MAP_CACHE_LOCK, + memory_key, + persistent["representative_map"], + ) + representative_map = _cost_analysis_build_link_representative_map() + _store_system_page_cache( + "cost_analysis_link_map", + source_version, + params={ + "source_version": source_version, + "logic_version": COST_ANALYSIS_LINK_LOGIC_VERSION, + }, + payload={"representative_map": representative_map}, + row_count=len(representative_map), + signature=source_version, + ) + return _set_deepcopy_ttl_cache_entry( + _COST_ANALYSIS_LINK_MAP_CACHE, + _COST_ANALYSIS_LINK_MAP_CACHE_LOCK, + memory_key, + representative_map, + ) + + def _cost_analysis_infer_yz_link_representatives(existing_representative_map: dict[str, str]) -> dict[str, str]: project_meta = _cost_analysis_get_project_meta() - representative_titles: dict[str, set[str]] = {} - for code, representative in existing_representative_map.items(): - normalized_code = normalize_text(code).upper() - normalized_representative = normalize_text(representative).upper() - if not normalized_code or not normalized_representative or normalized_code == normalized_representative: - continue - title = normalize_project_title_for_linking((project_meta.get(normalized_code) or {}).get("support_dept_name")) - if title: - representative_titles.setdefault(normalized_representative, set()).add(title) - for representative, meta in project_meta.items(): - normalized_representative = normalize_text(representative).upper() - if normalized_representative[:1] not in {"0", "9"}: - continue - title = normalize_project_title_for_linking(meta.get("support_dept_name")) - if title: - representative_titles.setdefault(normalized_representative, set()).add(title) - inferred: dict[str, str] = {} + representative_titles: dict[str, str] = { + normalize_text(code).upper(): normalize_project_title_for_linking(meta.get("support_dept_name")) + for code, meta in project_meta.items() + if normalize_text(code).upper().startswith(("0", "9")) + } + exact_title_index: dict[tuple[str, str], list[str]] = {} + trigram_index: dict[tuple[str, str], set[str]] = {} + for representative, title in representative_titles.items(): + if len(title) < 8: + continue + prefix = representative[:1] + exact_title_index.setdefault((prefix, title), []).append(representative) + for index in range(max(1, len(title) - 2)): + trigram = title[index:index + 3] + if trigram: + trigram_index.setdefault((prefix, trigram), set()).add(representative) for code, meta in project_meta.items(): normalized_code = normalize_text(code).upper() if ( not normalized_code or normalized_code in existing_representative_map - or normalized_code[:1] not in {"Y", "Z"} + or normalized_code[:1] not in {"X", "Y", "Z"} ): continue - title = normalize_project_title_for_linking(meta.get("support_dept_name")) - if not title: + expected_total_prefix = "9" if normalized_code.startswith("X") else "0" + suffix = contract_family_code_suffix(normalized_code) + expected_total_code = f"{expected_total_prefix}{suffix.zfill(5)}" if suffix else "" + if expected_total_code in project_meta: + inferred[normalized_code] = expected_total_code continue - matches: list[tuple[int, str]] = [] - for representative, titles in representative_titles.items(): - best_score = 0 - for candidate_title in titles: - if not candidate_title: - continue - if title == candidate_title: - best_score = max(best_score, 100) - elif len(title) >= 8 and len(candidate_title) >= 8 and (title in candidate_title or candidate_title in title): - best_score = max(best_score, min(len(title), len(candidate_title))) - if best_score >= 8: - matches.append((best_score, representative)) + title = normalize_project_title_for_linking(meta.get("support_dept_name")) + if len(title) < 8: + continue + exact_matches = exact_title_index.get((expected_total_prefix, title), []) + if len(exact_matches) == 1: + inferred[normalized_code] = exact_matches[0] + continue + candidate_overlap: dict[str, int] = {} + for index in range(max(1, len(title) - 2)): + trigram = title[index:index + 3] + for representative in trigram_index.get((expected_total_prefix, trigram), set()): + candidate_overlap[representative] = candidate_overlap.get(representative, 0) + 1 + candidate_codes = [ + representative + for representative, _ in sorted(candidate_overlap.items(), key=lambda item: (-item[1], item[0]))[:24] + ] + matches: list[tuple[float, str]] = [] + for representative in candidate_codes: + candidate_title = representative_titles.get(representative, "") + if len(candidate_title) < 8: + continue + score = SequenceMatcher(None, title, candidate_title).ratio() + if title in candidate_title or candidate_title in title: + score = max(score, min(len(title), len(candidate_title)) / max(len(title), len(candidate_title))) + if score >= 0.94: + matches.append((score, representative)) if not matches: continue matches.sort(reverse=True) - if len(matches) > 1 and matches[0][0] == matches[1][0]: + if len(matches) > 1 and matches[0][0] - matches[1][0] < 0.12: continue inferred[normalized_code] = matches[0][1] return inferred @@ -14082,6 +16221,8 @@ def _cost_analysis_latest_row(rows: list[dict[str, Any]]) -> dict[str, Any]: def _cost_analysis_linked_group_key(row: dict[str, Any], representative_map: dict[str, str]) -> str: + if row.get("is_common_activity"): + return normalize_text(row.get("row_key")) or normalize_text(row.get("project_name")) codes = [ normalize_text(row.get("support_dept_code")).upper(), *[normalize_text(code).upper() for code in (row.get("direct_codes") or [])], @@ -14091,7 +16232,7 @@ def _cost_analysis_linked_group_key(row: dict[str, Any], representative_map: dic representative = representative_map.get(code) if representative: return representative - return _cost_analysis_project_group_key(row.get("support_dept_code", ""), row) + return normalize_text(row.get("support_dept_code")).upper() def _cost_analysis_aggregate_rows( @@ -14100,9 +16241,15 @@ def _cost_analysis_aggregate_rows( representative_map: dict[str, str] | None = None, ) -> list[dict[str, Any]]: representative_map = representative_map or _cost_analysis_get_link_representative_map() + satis_display = _cost_analysis_get_satis_display_maps() + satis_names = satis_display.get("names") or {} grouped: dict[str, list[dict[str, Any]]] = {} + common_revenue_rows: list[dict[str, Any]] = [] for row in rows: + if row.get("is_common_revenue"): + common_revenue_rows.append(copy.deepcopy(row)) + continue grouped.setdefault(_cost_analysis_linked_group_key(row, representative_map), []).append(row) result: list[dict[str, Any]] = [] @@ -14113,6 +16260,7 @@ def _cost_analysis_aggregate_rows( aggregate = copy.deepcopy(latest) aggregate["view_mode"] = "aggregate" aggregate["aggregate_project_count"] = len(group_rows) + aggregate["row_key"] = representative_code aggregate["aggregate_codes"] = sorted({normalize_text(row.get("support_dept_code")).upper() for row in group_rows if normalize_text(row.get("support_dept_code"))}) aggregate["aggregate_members"] = sorted( [ @@ -14136,7 +16284,10 @@ def _cost_analysis_aggregate_rows( ) if representative_meta: aggregate["support_dept_code"] = representative_code - aggregate["project_name"] = normalize_text(representative_meta.get("support_dept_name")) or representative_code + representative_name = normalize_text(representative_meta.get("support_dept_name")) or normalize_text(satis_names.get(representative_code)) or representative_code + if _cost_analysis_name_needs_display_fix(representative_name, representative_code): + representative_name = normalize_text(satis_names.get(representative_code)) or representative_name + aggregate["project_name"] = representative_name aggregate["pm_department"] = normalize_text(representative_meta.get("pm_department")) or normalize_text(aggregate.get("pm_department")) aggregate["project_type"] = _cost_analysis_project_type(representative_code, representative_meta.get("project_type")) aggregate["completion_status"] = normalize_text(representative_meta.get("completion_status")) or normalize_text(aggregate.get("completion_status")) @@ -14146,6 +16297,11 @@ def _cost_analysis_aggregate_rows( for code in [row.get("support_dept_code"), *(row.get("direct_codes") or [])] if normalize_text(code) }) + aggregate["allocation_details"] = [ + copy.deepcopy(detail) + for row in group_rows + for detail in (row.get("allocation_details") or []) + ] start_dates = [_parse_iso_date(row.get("project_start_date")) for row in group_rows] end_dates = [_parse_iso_date(row.get("project_end_date")) for row in group_rows] aggregate["project_start_date"] = min([value for value in start_dates if value], default=None) @@ -14160,8 +16316,11 @@ def _cost_analysis_aggregate_rows( aggregate["billing_amount"] = sum(normalize_amount(row.get("billing_amount")) for row in group_rows) aggregate["collection_amount"] = sum(normalize_amount(row.get("collection_amount")) for row in group_rows) aggregate["period_billing_amount"] = sum(normalize_amount(row.get("period_billing_amount")) for row in group_rows) + aggregate["period_negative_billing_amount"] = sum(normalize_amount(row.get("period_negative_billing_amount")) for row in group_rows) aggregate["period_collection_amount"] = sum(normalize_amount(row.get("period_collection_amount")) for row in group_rows) - aggregate["period_revenue_amount"] = sum(normalize_amount(row.get("period_revenue_amount") or row.get("revenue_amount")) for row in group_rows) + aggregate["period_revenue_amount"] = sum(normalize_amount(row.get("period_revenue_amount")) for row in group_rows) + aggregate["period_revenue_billing_gap"] = sum(normalize_amount(row.get("period_revenue_billing_gap")) for row in group_rows) + aggregate["period_revenue_collection_gap"] = sum(normalize_amount(row.get("period_revenue_collection_gap")) for row in group_rows) aggregate["contract_balance_amount"] = 0.0 aggregate["revenue_amount"] = sum(normalize_amount(row.get("revenue_amount")) for row in group_rows) aggregate["phases"] = _cost_analysis_empty_phase_totals() @@ -14179,29 +16338,213 @@ def _cost_analysis_aggregate_rows( for item_key, amount in buckets.items(): if item_key in aggregate["allocated"][phase]: aggregate["allocated"][phase][item_key] += normalize_amount(amount) - aggregate["period_cost_total"] = sum(normalize_amount(row.get("period_cost_total") or row.get("cost_total")) for row in group_rows) - aggregate["period_sga_total"] = sum(normalize_amount(row.get("period_sga_total") or row.get("sga_total")) for row in group_rows) - aggregate["period_sales_total"] = sum(normalize_amount(row.get("period_sales_total") or row.get("sales_total")) for row in group_rows) - aggregate["period_total_cost"] = sum(normalize_amount(row.get("period_total_cost") or row.get("total_cost")) for row in group_rows) + aggregate["period_cost_total"] = sum(normalize_amount(row.get("period_cost_total")) for row in group_rows) + aggregate["period_cost_labor_total"] = sum(normalize_amount(row.get("period_cost_labor_total")) for row in group_rows) + aggregate["period_sga_labor_total"] = sum(normalize_amount(row.get("period_sga_labor_total")) for row in group_rows) + aggregate["period_sga_total"] = sum(normalize_amount(row.get("period_sga_total")) for row in group_rows) + aggregate["period_sales_total"] = sum(normalize_amount(row.get("period_sales_total")) for row in group_rows) + aggregate["period_total_cost"] = sum(normalize_amount(row.get("period_total_cost")) for row in group_rows) aggregate["period_profit_amount"] = aggregate["period_revenue_amount"] - aggregate["period_total_cost"] + aggregate["common_revenue_amount"] = sum(normalize_amount(row.get("common_revenue_amount")) for row in group_rows) + aggregate["cumulative_revenue_amount"] = sum(normalize_amount(row.get("cumulative_revenue_amount")) for row in group_rows) + aggregate["cumulative_cost_total"] = sum(normalize_amount(row.get("cumulative_cost_total")) for row in group_rows) + aggregate["cumulative_sga_total"] = sum(normalize_amount(row.get("cumulative_sga_total")) for row in group_rows) + aggregate["cumulative_sales_total"] = sum(normalize_amount(row.get("cumulative_sales_total")) for row in group_rows) + aggregate["cumulative_total_cost"] = sum(normalize_amount(row.get("cumulative_total_cost")) for row in group_rows) + aggregate["cumulative_profit_amount"] = aggregate["cumulative_revenue_amount"] - aggregate["cumulative_total_cost"] _cost_analysis_finalize_row(aggregate) result.append(aggregate) + result.extend(common_revenue_rows) return result -def _cost_analysis_build_payload(start_date_text: str, end_date_text: str, mode: str = "individual") -> dict[str, Any]: +def _cost_analysis_apply_individual_display_codes( + rows: list[dict[str, Any]], + project_meta: dict[str, dict[str, Any]], +) -> list[dict[str, Any]]: + satis_display = _cost_analysis_get_satis_display_maps() + preferred_round = satis_display.get("preferred_round") or {} + satis_names = satis_display.get("names") or {} + if not preferred_round: + for row in rows: + code = normalize_text(row.get("support_dept_code")).upper() + if _cost_analysis_name_needs_display_fix(row.get("project_name"), code): + row["project_name"] = normalize_text(satis_names.get(code)) or normalize_text(row.get("project_name")) or code + return rows + + def display_code_for(code: Any) -> str: + normalized = normalize_text(code).upper() + if _COST_ANALYSIS_MASTER_CODE_RE.fullmatch(normalized): + return normalize_text(preferred_round.get(normalized)).upper() or normalized + return normalized + + def display_name_for(code: str, fallback: Any = "") -> str: + meta = project_meta.get(code) or {} + name = normalize_text(meta.get("support_dept_name")) or normalize_text(satis_names.get(code)) or normalize_text(fallback) or code + if _cost_analysis_name_needs_display_fix(name, code): + name = normalize_text(satis_names.get(code)) or name + return name + + grouped: dict[str, list[dict[str, Any]]] = {} + for source_row in rows: + row = copy.deepcopy(source_row) + original_code = normalize_text(row.get("support_dept_code")).upper() + display_code = display_code_for(original_code) + if display_code and display_code != original_code: + row["support_dept_code"] = display_code + row["row_key"] = display_code + row["project_name"] = display_name_for(display_code, row.get("project_name")) + display_meta = project_meta.get(display_code) or {} + row["pm_department"] = normalize_text(display_meta.get("pm_department")) or normalize_text(row.get("pm_department")) + row["project_type"] = _cost_analysis_project_type(display_code, display_meta.get("project_type") or row.get("project_type")) + row["completion_status"] = normalize_text(display_meta.get("completion_status")) or normalize_text(row.get("completion_status")) + row["project_start_date"] = normalize_text(display_meta.get("project_start_date")) or normalize_text(row.get("project_start_date")) + row["project_end_date"] = normalize_text(display_meta.get("project_end_date")) or normalize_text(row.get("project_end_date")) + row["direct_codes"] = sorted({ + display_code, + original_code, + *[normalize_text(code).upper() for code in (row.get("direct_codes") or []) if normalize_text(code)], + }) + elif _cost_analysis_name_needs_display_fix(row.get("project_name"), original_code): + row["project_name"] = display_name_for(original_code, row.get("project_name")) + normalized_display_code = normalize_text(row.get("support_dept_code")).upper() + if row.get("is_common_master"): + group_key = f"{normalized_display_code}::common-master" + elif row.get("is_common_activity") or row.get("is_hidden_common_activity"): + group_key = f"{normalized_display_code}::common-activity::{normalize_text(row.get('row_key')) or normalize_text(row.get('project_name'))}" + else: + group_key = normalized_display_code + grouped.setdefault(group_key, []).append(row) + + merged_rows: list[dict[str, Any]] = [] + additive_fields = { + "billing_amount", + "collection_amount", + "period_billing_amount", + "period_negative_billing_amount", + "period_collection_amount", + "period_revenue_amount", + "period_revenue_billing_gap", + "period_revenue_collection_gap", + "period_cost_total", + "period_cost_labor_total", + "period_sga_labor_total", + "period_sga_total", + "period_sales_total", + "period_total_cost", + "period_profit_amount", + "cumulative_revenue_amount", + "cumulative_cost_total", + "cumulative_sga_total", + "cumulative_sales_total", + "cumulative_total_cost", + "cumulative_profit_amount", + "revenue_amount", + "common_revenue_amount", + } + for group_key, group_rows in grouped.items(): + display_code = normalize_text(group_rows[0].get("support_dept_code")).upper() if group_rows else normalize_text(group_key).split("::", 1)[0] + if len(group_rows) == 1: + merged_rows.append(group_rows[0]) + continue + base = copy.deepcopy(_cost_analysis_latest_row(group_rows)) + base["support_dept_code"] = display_code + base["row_key"] = display_code + base["project_name"] = display_name_for(display_code, base.get("project_name")) + base["direct_codes"] = sorted({ + code + for row in group_rows + for code in [row.get("support_dept_code"), *(row.get("direct_codes") or [])] + if normalize_text(code) + }) + base["aggregate_codes"] = sorted({ + code + for row in group_rows + for code in (row.get("aggregate_codes") or []) + if normalize_text(code) + }) + base["allocation_details"] = [ + copy.deepcopy(detail) + for row in group_rows + for detail in (row.get("allocation_details") or []) + ] + for field in additive_fields: + base[field] = sum(normalize_amount(row.get(field)) for row in group_rows) + base["contract_amount"] = max(normalize_amount(row.get("contract_amount")) for row in group_rows) + base["collected_amount"] = max(normalize_amount(row.get("collected_amount")) for row in group_rows) + base["contract_balance_amount"] = max(normalize_amount(row.get("contract_balance_amount")) for row in group_rows) + base["phases"] = _cost_analysis_empty_phase_totals() + base["allocated"] = _cost_analysis_empty_phase_totals() + for row in group_rows: + for phase, buckets in (row.get("phases") or {}).items(): + if phase not in base["phases"]: + continue + for item_key, amount in buckets.items(): + if item_key in base["phases"][phase]: + base["phases"][phase][item_key] += normalize_amount(amount) + for phase, buckets in (row.get("allocated") or {}).items(): + if phase not in base["allocated"]: + continue + for item_key, amount in buckets.items(): + if item_key in base["allocated"][phase]: + base["allocated"][phase][item_key] += normalize_amount(amount) + _cost_analysis_finalize_row(base) + base["cumulative_revenue_amount"] = normalize_amount(base.get("revenue_amount")) + base["cumulative_cost_total"] = normalize_amount(base.get("cost_total")) + base["cumulative_sga_total"] = normalize_amount(base.get("sga_total")) + base["cumulative_sales_total"] = normalize_amount(base.get("sales_total")) + base["cumulative_total_cost"] = normalize_amount(base.get("total_cost")) + base["cumulative_profit_amount"] = base["cumulative_revenue_amount"] - base["cumulative_total_cost"] + base["cumulative_profit_rate"] = _safe_ratio(base["cumulative_profit_amount"], base["cumulative_revenue_amount"]) + merged_rows.append(base) + return merged_rows + + +def _cost_analysis_payload_cache_context(start_date_text: str, end_date_text: str) -> dict[str, Any]: start_date = _parse_iso_date(start_date_text) or date(date.today().year, 1, 1) end_date = _parse_iso_date(end_date_text) or date.today() if end_date < start_date: start_date, end_date = end_date, start_date - normalized_mode = normalize_text(mode).lower() - cache_key = ( - start_date.isoformat(), - end_date.isoformat(), - "aggregate" if normalized_mode in {"aggregate", "sum", "합산", "연계", "linked", "link"} else "individual", - get_business_data_version(), - _cost_analysis_hanmac_cache_version(), + return { + "start_date": start_date, + "end_date": end_date, + "data_version": get_business_data_version(), + "hanmac_cache_version": _cost_analysis_hanmac_cache_version(), + } + + +def _cost_analysis_cache_key(context: dict[str, Any], mode: str) -> tuple[Any, ...]: + return ( + context["start_date"].isoformat(), + context["end_date"].isoformat(), + mode, + context["data_version"], + context["hanmac_cache_version"], + COST_ANALYSIS_HANMAC_AGGREGATE_SCHEMA, + COST_ANALYSIS_FINANCIAL_LOGIC_VERSION, + COST_ANALYSIS_H_PROJECT_MAPPING_VERSION, + COST_ANALYSIS_LINK_LOGIC_VERSION if mode == "aggregate" else "", ) + + +def _cost_analysis_persistent_cache_key(context: dict[str, Any], mode: str) -> str: + return _json_hash( + { + "start_date": context["start_date"].isoformat(), + "end_date": context["end_date"].isoformat(), + "mode": mode, + "data_version": context["data_version"], + "hanmac_cache_version": context["hanmac_cache_version"], + "financial_logic_version": COST_ANALYSIS_FINANCIAL_LOGIC_VERSION, + "h_project_mapping_version": COST_ANALYSIS_H_PROJECT_MAPPING_VERSION, + "link_logic_version": COST_ANALYSIS_LINK_LOGIC_VERSION if mode == "aggregate" else "", + "schema": COST_ANALYSIS_HANMAC_AGGREGATE_SCHEMA, + } + ) + + +def _cost_analysis_load_cached_payload(context: dict[str, Any], mode: str) -> dict[str, Any] | None: + cache_key = _cost_analysis_cache_key(context, mode) cached_payload = _get_deepcopy_ttl_cache_entry( _COST_ANALYSIS_PAYLOAD_CACHE, _COST_ANALYSIS_PAYLOAD_CACHE_LOCK, @@ -14209,37 +16552,130 @@ def _cost_analysis_build_payload(start_date_text: str, end_date_text: str, mode: COST_ANALYSIS_PAYLOAD_CACHE_TTL_SECONDS, ) if cached_payload is not None: + cached_payload["cache_info"] = {**(cached_payload.get("cache_info") or {}), "source": "memory", "ready": True} return cached_payload - persistent_cache_key = _json_hash( - { - "start_date": start_date.isoformat(), - "end_date": end_date.isoformat(), - "mode": cache_key[2], - "data_version": cache_key[3], - "hanmac_cache_version": cache_key[4], - "schema": COST_ANALYSIS_HANMAC_AGGREGATE_SCHEMA, - } - ) + persistent_cache_key = _cost_analysis_persistent_cache_key(context, mode) persistent_payload = _load_system_page_cache("cost_analysis_payload", persistent_cache_key) - if persistent_payload is not None: - return _set_deepcopy_ttl_cache_entry( - _COST_ANALYSIS_PAYLOAD_CACHE, - _COST_ANALYSIS_PAYLOAD_CACHE_LOCK, - cache_key, - persistent_payload, + if persistent_payload is None: + return None + persistent_payload["cache_info"] = {**(persistent_payload.get("cache_info") or {}), "source": "persistent", "ready": True} + return _set_deepcopy_ttl_cache_entry( + _COST_ANALYSIS_PAYLOAD_CACHE, + _COST_ANALYSIS_PAYLOAD_CACHE_LOCK, + cache_key, + persistent_payload, + ) + + +def _cost_analysis_store_payload(context: dict[str, Any], mode: str, payload: dict[str, Any]) -> dict[str, Any]: + cache_key = _cost_analysis_cache_key(context, mode) + persistent_cache_key = _cost_analysis_persistent_cache_key(context, mode) + payload["cache_info"] = { + "source": "computed", + "ready": True, + "financial_logic_version": COST_ANALYSIS_FINANCIAL_LOGIC_VERSION, + "h_project_mapping_version": COST_ANALYSIS_H_PROJECT_MAPPING_VERSION, + "link_logic_version": COST_ANALYSIS_LINK_LOGIC_VERSION, + "data_version": context["data_version"], + "hanmac_cache_version": context["hanmac_cache_version"], + "generated_at": datetime.now().isoformat(timespec="seconds"), + } + try: + _store_system_page_cache( + "cost_analysis_payload", + persistent_cache_key, + params={ + "start_date": context["start_date"].isoformat(), + "end_date": context["end_date"].isoformat(), + "mode": mode, + "data_version": context["data_version"], + "hanmac_cache_version": context["hanmac_cache_version"], + "financial_logic_version": COST_ANALYSIS_FINANCIAL_LOGIC_VERSION, + "h_project_mapping_version": COST_ANALYSIS_H_PROJECT_MAPPING_VERSION, + "link_logic_version": COST_ANALYSIS_LINK_LOGIC_VERSION, + }, + payload=payload, + row_count=len(payload.get("rows") or []), + signature=str(context["data_version"]), ) + except OperationalError as exc: + if "database is locked" not in str(exc).lower(): + raise + logger.warning("cost analysis persistent cache store skipped because database is locked") + payload["cache_info"] = { + **(payload.get("cache_info") or {}), + "persistent_cache_skipped": True, + "persistent_cache_error": "database is locked", + } + return _set_deepcopy_ttl_cache_entry( + _COST_ANALYSIS_PAYLOAD_CACHE, + _COST_ANALYSIS_PAYLOAD_CACHE_LOCK, + cache_key, + payload, + ) + + +def _cost_analysis_load_last_valid_payload(start_date_text: str, end_date_text: str, mode: str) -> dict[str, Any] | None: + normalized_mode = "aggregate" if normalize_text(mode).lower() in {"aggregate", "sum", "합산", "연계", "linked", "link"} else "individual" + start_date = (_parse_iso_date(start_date_text) or date(date.today().year, 1, 1)).isoformat() + end_date = (_parse_iso_date(end_date_text) or date.today()).isoformat() + with engine.begin() as conn: + row = conn.execute( + text( + """ + SELECT payload_json, updated_at + FROM system_page_cache + WHERE page_key = 'cost_analysis_payload' + AND json_extract(params_json, '$.start_date') = :start_date + AND json_extract(params_json, '$.end_date') = :end_date + AND json_extract(params_json, '$.mode') = :mode + ORDER BY updated_at DESC + LIMIT 1 + """ + ), + {"start_date": start_date, "end_date": end_date, "mode": normalized_mode}, + ).mappings().first() + if not row: + return None + try: + payload = json.loads(str(row.get("payload_json") or "{}")) + except Exception: + return None + if not isinstance(payload, dict) or not isinstance(payload.get("rows"), list): + return None + payload["cache_info"] = { + **(payload.get("cache_info") or {}), + "source": "last-valid", + "ready": False, + "stale": True, + "updated_at": normalize_text(row.get("updated_at")), + } + return payload + + +def _cost_analysis_build_individual_payload( + context: dict[str, Any], + force: bool = False, + include_cumulative: bool = True, +) -> dict[str, Any]: + start_date = context["start_date"] + end_date = context["end_date"] + cache_key = _cost_analysis_cache_key(context, "individual") + if not force: + cached_payload = _cost_analysis_load_cached_payload(context, "individual") + if cached_payload is not None: + return cached_payload + linked_mode = False selected_year = start_date.year if start_date.year == end_date.year else None project_meta = _cost_analysis_get_project_meta() - x_links = _cost_analysis_get_x_links() - xyz_code_map = _cost_analysis_get_xyz_code_map() completion_dates = _cost_analysis_get_completion_billing_dates() rows_by_code: dict[str, dict[str, Any]] = {} - x_owner_map: dict[str, str] = {} - for owner_code, x_codes in x_links.items(): - normalized_owner_code = normalize_text(owner_code).upper() - display_owner_code = xyz_code_map.get(normalized_owner_code, normalized_owner_code) - for x_code in x_codes: - x_owner_map.setdefault(x_code, display_owner_code) + + def resolve_report_code(source_code: Any) -> str: + normalized_code = normalize_text(source_code).upper() + if normalized_code in COST_ANALYSIS_COMMON_CODES: + return "" + return normalized_code if normalized_code.startswith(("0", "9", "X", "Y", "Z")) else "" with engine.begin() as conn: period_code_rows = conn.execute( @@ -14260,8 +16696,8 @@ def _cost_analysis_build_payload(start_date_text: str, end_date_text: str, mode: UNION SELECT DISTINCT support_dept_code FROM project_billing_entries - WHERE COALESCE(COALESCE(tax_invoice_date, billing_date), '') >= :start_date - AND COALESCE(COALESCE(tax_invoice_date, billing_date), '') <= :end_date + WHERE COALESCE(billing_date, '') >= :start_date + AND COALESCE(billing_date, '') <= :end_date AND COALESCE(support_dept_code, '') <> '' """ ), @@ -14273,36 +16709,47 @@ def _cost_analysis_build_payload(start_date_text: str, end_date_text: str, mode: for source_code in period_source_codes: if not source_code or source_code in COST_ANALYSIS_COMMON_CODES: continue - visible_candidate_codes.add(x_owner_map.get(source_code, xyz_code_map.get(source_code, source_code))) + target_code = resolve_report_code(source_code) + if target_code: + visible_candidate_codes.add(target_code) - annual_hanmac_project_hours_by_year, annual_hanmac_labor_by_year = _cost_analysis_load_hanmac_hours_and_labor_yearly( + annual_hanmac_project_hours_by_year, annual_hanmac_labor_by_year, annual_hanmac_missing_sga_by_year = _cost_analysis_load_hanmac_hours_and_labor_yearly( start_date, end_date, project_meta, None, ) - visible_candidate_codes.update( - code + annual_source_codes = { + normalize_text(code).upper() for code_map in annual_hanmac_project_hours_by_year.values() for code in code_map + } + visible_candidate_codes.update( + target_code + for code in annual_source_codes + if (target_code := resolve_report_code(code)) ) - source_candidate_codes: set[str] = set(visible_candidate_codes) - for target_code in list(visible_candidate_codes): - source_candidate_codes.update(x_links.get(target_code, [])) - for source_code, target_code in xyz_code_map.items(): - if target_code in visible_candidate_codes: - source_candidate_codes.add(source_code) - for source_code, target_code in x_owner_map.items(): - if target_code in visible_candidate_codes: - source_candidate_codes.add(source_code) + source_candidate_codes: set[str] = { + code for code in [*period_source_codes, *annual_source_codes] if code and code not in COST_ANALYSIS_COMMON_CODES + } + if linked_mode: + visible_link_keys = { + representative_map.get(code, code) + for code in visible_candidate_codes + } + source_candidate_codes.update( + code for code, representative in representative_map.items() + if representative in visible_link_keys + ) hanmac_labor_map: dict[str, dict[str, float]] = {} for code_map in annual_hanmac_labor_by_year.values(): for code, phase_amounts in code_map.items(): - if visible_candidate_codes and code not in visible_candidate_codes: + target_code = resolve_report_code(code) + if not target_code or (visible_candidate_codes and target_code not in visible_candidate_codes): continue - target = hanmac_labor_map.setdefault(code, {"pre": 0.0, "during": 0.0, "post": 0.0}) + target = hanmac_labor_map.setdefault(target_code, {"pre": 0.0, "during": 0.0, "post": 0.0}) for phase, amount in phase_amounts.items(): if phase in target: target[phase] += normalize_amount(amount) @@ -14314,8 +16761,8 @@ def _cost_analysis_build_payload(start_date_text: str, end_date_text: str, mode: meta = project_meta.get(normalized_code, {"support_dept_code": normalized_code, "support_dept_name": normalized_code}) if normalized_code not in rows_by_code: rows_by_code[normalized_code] = _cost_analysis_row_template(normalized_code, meta, selected_year) - rows_by_code[normalized_code]["pre_codes"] = x_links.get(normalized_code, []) - rows_by_code[normalized_code]["direct_codes"] = [normalized_code, *x_links.get(normalized_code, [])] + rows_by_code[normalized_code]["pre_codes"] = [] + rows_by_code[normalized_code]["direct_codes"] = [normalized_code] return rows_by_code[normalized_code] with engine.begin() as conn: @@ -14401,35 +16848,106 @@ def _cost_analysis_build_payload(start_date_text: str, end_date_text: str, mode: SUM(COALESCE(billed_amount, 0)) AS billing_amount, SUM( CASE - WHEN COALESCE(COALESCE(tax_invoice_date, billing_date), '') >= :start_date - AND COALESCE(COALESCE(tax_invoice_date, billing_date), '') <= :end_date + WHEN COALESCE(billing_date, '') >= :start_date + AND COALESCE(billing_date, '') <= :end_date THEN COALESCE(billed_amount, 0) ELSE 0 END ) AS period_billing_amount FROM project_billing_entries - WHERE COALESCE(COALESCE(tax_invoice_date, billing_date), '') <= :end_date + WHERE COALESCE(billing_date, '') <= :end_date GROUP BY support_dept_code """ ), {"start_date": start_date.isoformat(), "end_date": end_date.isoformat()}, ).mappings().all() - annual_sga_rows = conn.execute( + negative_billing_rows = conn.execute( + text( + """ + SELECT + support_dept_code, + SUM(COALESCE(billed_amount, 0)) AS period_negative_billing_amount + FROM project_billing_entries + WHERE COALESCE(billing_date, '') >= :start_date + AND COALESCE(billing_date, '') <= :end_date + AND COALESCE(billed_amount, 0) < 0 + GROUP BY support_dept_code + """ + ), + {"start_date": start_date.isoformat(), "end_date": end_date.isoformat()}, + ).mappings().all() + common_revenue_rows = conn.execute( + text( + f""" + SELECT + CAST(strftime('%Y', {COST_ANALYSIS_TX_DATE_SQL}) AS INTEGER) AS posting_year, + COALESCE(account_code, '') AS account_code, + COALESCE(account_name, '') AS account_name, + SUM(COALESCE(amount, 0)) AS revenue_amount, + COUNT(*) AS row_count + FROM transactions + WHERE UPPER(COALESCE(support_dept_code, '')) IN ('', 'ZZZZZZ') + AND (accounting_category = '수입/매출액' OR account_code LIKE '4%') + AND {COST_ANALYSIS_TX_DATE_SQL} >= :start_date + AND {COST_ANALYSIS_TX_DATE_SQL} <= :end_date + GROUP BY + CAST(strftime('%Y', {COST_ANALYSIS_TX_DATE_SQL}) AS INTEGER), + COALESCE(account_code, ''), + COALESCE(account_name, '') + """ + ), + { + "start_date": start_date.isoformat(), + "end_date": end_date.isoformat(), + }, + ).mappings().all() + annual_erp_revenue_rows = conn.execute( + text( + f""" + SELECT + CAST(strftime('%Y', {COST_ANALYSIS_TX_DATE_SQL}) AS INTEGER) AS posting_year, + SUM(COALESCE(amount, 0)) AS revenue_amount + FROM transactions + WHERE (accounting_category = '수입/매출액' OR account_code LIKE '4%') + AND {COST_ANALYSIS_TX_DATE_SQL} >= :start_date + AND {COST_ANALYSIS_TX_DATE_SQL} <= :end_date + GROUP BY CAST(strftime('%Y', {COST_ANALYSIS_TX_DATE_SQL}) AS INTEGER) + """ + ), + { + "start_date": start_date.isoformat(), + "end_date": end_date.isoformat(), + }, + ).mappings().all() + annual_common_rows = conn.execute( text( f""" SELECT CAST(strftime('%Y', {COST_ANALYSIS_TX_DATE_SQL}) AS INTEGER) AS posting_year, - SUM(COALESCE(amount, 0)) AS amount + SUM( + CASE + WHEN account_code LIKE '6%' THEN COALESCE(amount, 0) + ELSE 0 + END + ) AS sga_amount, + SUM( + CASE + WHEN account_code LIKE '5%' + AND NOT ({COST_ANALYSIS_LABOR_ACCOUNT_SQL}) + THEN COALESCE(amount, 0) + ELSE 0 + END + ) AS common_cost_amount, + SUM( + CASE + WHEN account_code LIKE '5%' + AND ({COST_ANALYSIS_LABOR_ACCOUNT_SQL}) + THEN COALESCE(amount, 0) + ELSE 0 + END + ) AS excluded_common_labor_amount FROM transactions - WHERE ( - ( - UPPER(COALESCE(support_dept_code, '')) IN ('', 'ZZZZZZ') - AND account_code LIKE '6%' - ) - OR ( - UPPER(COALESCE(support_dept_code, '')) IN ('ZZZZZZ') - AND account_code LIKE '5%' - ) - ) + WHERE UPPER(COALESCE(support_dept_code, '')) IN ('', 'ZZZZZZ') + AND (account_code LIKE '5%' OR account_code LIKE '6%') AND {COST_ANALYSIS_TX_DATE_SQL} >= :start_date AND {COST_ANALYSIS_TX_DATE_SQL} <= :end_date GROUP BY CAST(strftime('%Y', {COST_ANALYSIS_TX_DATE_SQL}) AS INTEGER) @@ -14451,64 +16969,65 @@ def _cost_analysis_build_payload(start_date_text: str, end_date_text: str, mode: if bucket in {"sga", "cost"}: common_rows.append(row) continue - target_code = x_owner_map.get(code, xyz_code_map.get(code, code)) + target_code = resolve_report_code(code) + if not target_code: + continue report_row = ensure_row(target_code) if code != target_code and code not in report_row["direct_codes"]: report_row["direct_codes"].append(code) posting_date = _date_text(row.get("posting_date")) - if posting_date and start_date.isoformat() <= posting_date <= end_date.isoformat(): - visible_activity_codes.add(target_code) amount = normalize_amount(row.get("amount")) if bucket == "revenue": report_row["revenue_amount"] += amount report_row["period_revenue_amount"] += amount + if amount: + visible_activity_codes.add(target_code) continue if bucket not in {"cost", "sga"}: continue - phase = "pre" if code.startswith("X") else _cost_analysis_phase_for_transaction(target_code, posting_date, completion_dates) + if posting_date and start_date.isoformat() <= posting_date <= end_date.isoformat() and amount: + visible_activity_codes.add(target_code) + phase = "pre" if code.startswith("X") else _cost_analysis_phase_for_transaction(target_code, posting_date, completion_dates, project_meta) item_key = _cost_analysis_expense_item(row.get("account_code"), row.get("account_name"), _cost_analysis_is_sales_cost(row)) - if item_key == "labor" and target_code in hanmac_labor_map: + # Labor is calculated exclusively from external work records. ERP payroll + # vouchers must never be used as a fallback when work records are absent. + if item_key == "labor": continue - if item_key == "outsource" and phase == "pre": - item_key = "overhead" report_row["phases"][phase][item_key] += amount for code, phase_amounts in hanmac_labor_map.items(): if normalize_text(code).upper() in COST_ANALYSIS_COMMON_CODES: continue - report_row = ensure_row(code) + target_code = resolve_report_code(code) + if not target_code: + continue + report_row = ensure_row(target_code) for phase, amount in phase_amounts.items(): if phase in report_row["phases"] and amount: report_row["phases"][phase]["labor"] += amount + visible_activity_codes.add(target_code) - for collection_row in collection_rows: - code = normalize_text(collection_row.get("support_dept_code")).upper() - if not code or code in COST_ANALYSIS_COMMON_CODES: + common_collection_amount = 0.0 + common_period_collection_amount = 0.0 + for collection_event in _cost_analysis_erp_collection_events(end_date): + code = normalize_text(collection_event.get("support_dept_code")).upper() + amount = normalize_amount(collection_event.get("amount")) + posting_date = _date_text(collection_event.get("posting_date")) + is_period = bool(posting_date and start_date.isoformat() <= posting_date <= end_date.isoformat()) + if code in COST_ANALYSIS_COMMON_CODES: + common_collection_amount += amount + if is_period: + common_period_collection_amount += amount continue - target_code = xyz_code_map.get(code, code) - report_row = ensure_row(target_code) - if code != target_code and code not in report_row["direct_codes"]: - report_row["direct_codes"].append(code) - report_row["collection_amount"] += normalize_amount(collection_row.get("collection_amount")) - report_row["period_collection_amount"] += normalize_amount(collection_row.get("period_collection_amount")) - if normalize_amount(collection_row.get("period_collection_amount")): - visible_activity_codes.add(target_code) - - collected_codes = {normalize_text(row.get("support_dept_code")).upper() for row in collection_rows} - for collection_row in billing_collection_rows: - code = normalize_text(collection_row.get("support_dept_code")).upper() - if not code or code in COST_ANALYSIS_COMMON_CODES or code in collected_codes: + target_code = resolve_report_code(code) + if not target_code or not amount: continue - amount = normalize_amount(collection_row.get("collection_amount")) - if not amount: - continue - target_code = xyz_code_map.get(code, code) report_row = ensure_row(target_code) if code != target_code and code not in report_row["direct_codes"]: report_row["direct_codes"].append(code) report_row["collection_amount"] += amount - report_row["period_collection_amount"] += normalize_amount(collection_row.get("period_collection_amount")) - if normalize_amount(collection_row.get("period_collection_amount")): + if is_period: + report_row["period_collection_amount"] += amount visible_activity_codes.add(target_code) for billing_row in billing_amount_rows: @@ -14518,38 +17037,175 @@ def _cost_analysis_build_payload(start_date_text: str, end_date_text: str, mode: amount = normalize_amount(billing_row.get("billing_amount")) if not amount: continue - target_code = xyz_code_map.get(code, code) + target_code = resolve_report_code(code) + if not target_code: + continue report_row = ensure_row(target_code) if code != target_code and code not in report_row["direct_codes"]: report_row["direct_codes"].append(code) report_row["billing_amount"] += amount - report_row["period_billing_amount"] += normalize_amount(billing_row.get("period_billing_amount")) - if normalize_amount(billing_row.get("period_billing_amount")): + period_billing_amount = normalize_amount(billing_row.get("period_billing_amount")) + report_row["period_billing_amount"] += period_billing_amount + if period_billing_amount: visible_activity_codes.add(target_code) - annual_sga_totals = {int(row["posting_year"]): normalize_amount(row.get("amount")) for row in annual_sga_rows if row.get("posting_year")} + for billing_row in negative_billing_rows: + code = normalize_text(billing_row.get("support_dept_code")).upper() + if not code or code in COST_ANALYSIS_COMMON_CODES: + continue + target_code = resolve_report_code(code) + if not target_code: + continue + report_row = ensure_row(target_code) + report_row["period_negative_billing_amount"] += normalize_amount(billing_row.get("period_negative_billing_amount")) + + annual_common_totals = { + int(row["posting_year"]): { + "sga": normalize_amount(row.get("sga_amount")), + "overhead": normalize_amount(row.get("common_cost_amount")), + "excluded_common_labor": normalize_amount(row.get("excluded_common_labor_amount")), + } + for row in annual_common_rows + if row.get("posting_year") + } + common_revenue_totals_by_year: dict[int, float] = {} + erp_revenue_totals_by_year = { + int(row.get("posting_year") or 0): normalize_amount(row.get("revenue_amount")) + for row in annual_erp_revenue_rows + if int(row.get("posting_year") or 0) + } + common_sga_allocated_by_year: dict[int, float] = {} + for row in common_revenue_rows: + year = int(row.get("posting_year") or 0) + if year: + common_revenue_totals_by_year[year] = common_revenue_totals_by_year.get(year, 0.0) + normalize_amount(row.get("revenue_amount")) + for row in common_revenue_rows: + year = int(row.get("posting_year") or 0) + revenue_amount = normalize_amount(row.get("revenue_amount")) + account_code = normalize_text(row.get("account_code")) + account_name = normalize_text(row.get("account_name")) or "공통매출" + if not year or not revenue_amount: + continue + row_key = f"ZZZZZZ:{year}:{account_code or 'common'}:{normalize_project_title_for_linking(account_name) or 'common'}" + common_meta = { + "row_key": row_key, + "support_dept_name": account_name, + "pm_department": "공통", + "project_type": "공통매출", + "completion_status": "", + "contract_amount": 0, + } + report_row = _cost_analysis_row_template("ZZZZZZ", common_meta, selected_year) + report_row["row_key"] = row_key + report_row["support_dept_code"] = "ZZZZZZ" + report_row["direct_codes"] = ["ZZZZZZ"] + report_row["project_name"] = account_name + report_row["project_type"] = "공통매출" + report_row["pm_department"] = "공통" + report_row["is_common_revenue"] = True + report_row["common_revenue_amount"] = revenue_amount + report_row["revenue_amount"] = revenue_amount + report_row["period_revenue_amount"] = revenue_amount + annual_sga = normalize_amount(annual_common_totals.get(year, {}).get("sga")) + erp_revenue_total = erp_revenue_totals_by_year.get(year) or 0.0 + allocated_sga = annual_sga * revenue_amount / erp_revenue_total if erp_revenue_total else 0.0 + common_sga_allocated_by_year[year] = common_sga_allocated_by_year.get(year, 0.0) + allocated_sga + report_row["phases"]["during"]["sga"] += allocated_sga + report_row["allocated"]["during"]["sga"] += allocated_sga + rows_by_code[row_key] = report_row + visible_activity_codes.add(row_key) + + common_billing_rows = [ + row + for row in billing_amount_rows + if normalize_text(row.get("support_dept_code")).upper() in COST_ANALYSIS_COMMON_CODES + ] + common_negative_billing_rows = [ + row + for row in negative_billing_rows + if normalize_text(row.get("support_dept_code")).upper() in COST_ANALYSIS_COMMON_CODES + ] + common_billing_amount = sum(normalize_amount(row.get("billing_amount")) for row in common_billing_rows) + common_period_billing_amount = sum(normalize_amount(row.get("period_billing_amount")) for row in common_billing_rows) + common_period_negative_billing_amount = sum( + normalize_amount(row.get("period_negative_billing_amount")) + for row in common_negative_billing_rows + ) + if not common_billing_rows: + for common_revenue_row in rows_by_code.values(): + if not common_revenue_row.get("is_common_revenue"): + continue + revenue_amount = normalize_amount(common_revenue_row.get("revenue_amount")) + period_revenue_amount = normalize_amount(common_revenue_row.get("period_revenue_amount")) + common_revenue_row["billing_amount"] = revenue_amount + common_revenue_row["period_billing_amount"] = period_revenue_amount + common_revenue_row["common_billing_fallback_from_erp"] = True + elif common_billing_amount or common_period_billing_amount or common_period_negative_billing_amount: + common_billing_key = f"ZZZZZZ:{start_date.isoformat()}:{end_date.isoformat()}:billing" + common_billing_meta = { + "row_key": common_billing_key, + "support_dept_name": "공통 청구", + "pm_department": "공통", + "project_type": "공통매출", + "completion_status": "", + "contract_amount": 0, + } + common_billing_row = _cost_analysis_row_template("ZZZZZZ", common_billing_meta, selected_year) + common_billing_row["row_key"] = common_billing_key + common_billing_row["support_dept_code"] = "ZZZZZZ" + common_billing_row["direct_codes"] = ["ZZZZZZ"] + common_billing_row["project_name"] = "공통 청구" + common_billing_row["project_type"] = "공통매출" + common_billing_row["pm_department"] = "공통" + common_billing_row["is_common_revenue"] = True + common_billing_row["billing_amount"] = common_billing_amount + common_billing_row["period_billing_amount"] = common_period_billing_amount + common_billing_row["period_negative_billing_amount"] = common_period_negative_billing_amount + rows_by_code[common_billing_key] = common_billing_row + if common_period_billing_amount or common_period_negative_billing_amount: + visible_activity_codes.add(common_billing_key) + for year, common_sga_amount in common_sga_allocated_by_year.items(): + if year in annual_common_totals: + annual_common_totals[year]["sga"] = max(0.0, normalize_amount(annual_common_totals[year].get("sga")) - common_sga_amount) + for year_slice in _iter_year_slices(start_date, end_date): year = int(year_slice["year"]) - annual_sga_total = annual_sga_totals.get(year, 0.0) + annual_totals = annual_common_totals.get(year, {}) + missing_regular_sga_amount = normalize_amount(annual_hanmac_missing_sga_by_year.get(year)) hanmac_project_hours = annual_hanmac_project_hours_by_year.get(year, {}) period_total_hours = sum( normalize_amount(hours) for phase_hours in hanmac_project_hours.values() for hours in phase_hours.values() ) - if annual_sga_total <= 0 or period_total_hours <= 0: + if period_total_hours <= 0: + continue + hourly_allocations = { + item: (normalize_amount(annual_totals.get(item)) + (missing_regular_sga_amount if item == "sga" else 0.0)) / period_total_hours + for item in ("overhead", "sga") + if normalize_amount(annual_totals.get(item)) + (missing_regular_sga_amount if item == "sga" else 0.0) > 0 + } + if not hourly_allocations: continue - hourly_sga = annual_sga_total / period_total_hours for code, phase_hours in hanmac_project_hours.items(): normalized_code = normalize_text(code).upper() if normalized_code in COST_ANALYSIS_COMMON_CODES: continue - report_row = ensure_row(code) + target_code = resolve_report_code(normalized_code) + if not target_code: + continue + report_row = ensure_row(target_code) for phase, hours in phase_hours.items(): - amount = hourly_sga * normalize_amount(hours) - if phase in report_row["phases"] and amount: - report_row["phases"][phase]["sga"] += amount - report_row["allocated"][phase]["sga"] += amount + if phase not in report_row["phases"]: + continue + normalized_hours = normalize_amount(hours) + for item, hourly_amount in hourly_allocations.items(): + amount = hourly_amount * normalized_hours + if not amount: + continue + report_row["phases"][phase][item] += amount + report_row["allocated"][phase][item] += amount + visible_activity_codes.add(target_code) all_finalized_rows = [] period_finalized_rows = [] @@ -14560,52 +17216,121 @@ def _cost_analysis_build_payload(start_date_text: str, end_date_text: str, mode: if has_direct: period_finalized_rows.append(row) - linked_mode = normalized_mode in {"aggregate", "sum", "합산", "연계", "linked", "link"} if linked_mode: - representative_map = _cost_analysis_get_link_representative_map() visible_link_keys = { _cost_analysis_linked_group_key(row, representative_map) for row in period_finalized_rows } - for code, representative in representative_map.items(): - if representative not in visible_link_keys: - continue - if code in rows_by_code or code in COST_ANALYSIS_COMMON_CODES or code not in project_meta: - continue - row = ensure_row(code) - _cost_analysis_finalize_row(row) - all_finalized_rows.append(row) final_rows = [ row - for row in all_finalized_rows + for row in period_finalized_rows if _cost_analysis_linked_group_key(row, representative_map) in visible_link_keys ] final_rows = _cost_analysis_aggregate_rows(final_rows, project_meta, representative_map) for row in final_rows: _cost_analysis_finalize_row(row) else: - final_rows = period_finalized_rows + final_rows = list(period_finalized_rows) + + post_labor_summary_codes = {"Y22216", "Y24090"} + for row in final_rows: + row_codes = { + normalize_text(row.get("support_dept_code")).upper(), + *[normalize_text(code).upper() for code in (row.get("direct_codes") or [])], + } + if not any(code in post_labor_summary_codes for code in row_codes): + continue + target_codes = sorted(code for code in row_codes if code.startswith(("Y", "Z"))) + row["post_labor_after_completion"] = _cost_analysis_build_post_labor_summary( + start_date, + end_date, + target_codes, + row, + project_meta, + completion_dates, + ) final_rows.sort(key=lambda item: (normalize_text(item.get("pm_department")), normalize_text(item.get("project_type")), normalize_text(item.get("project_name")))) + accumulation_start = _cost_analysis_get_accumulation_start(end_date.isoformat()) + if include_cumulative and accumulation_start < start_date: + cumulative_context = _cost_analysis_payload_cache_context( + accumulation_start.isoformat(), + end_date.isoformat(), + ) + cumulative_payload = _cost_analysis_build_individual_payload( + cumulative_context, + force=False, + include_cumulative=False, + ) + cumulative_rows_by_code = { + normalize_text(item.get("row_key") or item.get("support_dept_code")).upper(): item + for item in (cumulative_payload.get("rows") or []) + } + for row in final_rows: + cumulative_row = cumulative_rows_by_code.get( + normalize_text(row.get("row_key") or row.get("support_dept_code")).upper() + ) or {} + row["cumulative_revenue_amount"] = normalize_amount(cumulative_row.get("revenue_amount")) + row["cumulative_cost_total"] = normalize_amount(cumulative_row.get("cost_total")) + row["cumulative_sga_total"] = normalize_amount(cumulative_row.get("sga_total")) + row["cumulative_sales_total"] = normalize_amount(cumulative_row.get("sales_total")) + row["cumulative_total_cost"] = normalize_amount(cumulative_row.get("total_cost")) + row["cumulative_profit_amount"] = ( + row["cumulative_revenue_amount"] - row["cumulative_total_cost"] + ) + row["cumulative_profit_rate"] = _safe_ratio( + row["cumulative_profit_amount"], + row["cumulative_revenue_amount"], + ) + else: + for row in final_rows: + row["cumulative_revenue_amount"] = normalize_amount(row.get("revenue_amount")) + row["cumulative_cost_total"] = normalize_amount(row.get("cost_total")) + row["cumulative_sga_total"] = normalize_amount(row.get("sga_total")) + row["cumulative_sales_total"] = normalize_amount(row.get("sales_total")) + row["cumulative_total_cost"] = normalize_amount(row.get("total_cost")) + row["cumulative_profit_amount"] = ( + row["cumulative_revenue_amount"] - row["cumulative_total_cost"] + ) + row["cumulative_profit_rate"] = _safe_ratio( + row["cumulative_profit_amount"], + row["cumulative_revenue_amount"], + ) summary = { "contract_amount": sum(normalize_amount(row.get("contract_amount")) for row in final_rows), "billing_amount": sum(normalize_amount(row.get("billing_amount")) for row in final_rows), "collection_amount": sum(normalize_amount(row.get("collection_amount")) for row in final_rows), "period_billing_amount": sum(normalize_amount(row.get("period_billing_amount")) for row in final_rows), + "period_negative_billing_amount": sum(normalize_amount(row.get("period_negative_billing_amount")) for row in final_rows), "period_collection_amount": sum(normalize_amount(row.get("period_collection_amount")) for row in final_rows), - "period_revenue_amount": sum(normalize_amount(row.get("period_revenue_amount") or row.get("revenue_amount")) for row in final_rows), - "period_cost_total": sum(normalize_amount(row.get("period_cost_total") or row.get("cost_total")) for row in final_rows), - "period_sga_total": sum(normalize_amount(row.get("period_sga_total") or row.get("sga_total")) for row in final_rows), - "period_sales_total": sum(normalize_amount(row.get("period_sales_total") or row.get("sales_total")) for row in final_rows), - "period_total_cost": sum(normalize_amount(row.get("period_total_cost") or row.get("total_cost")) for row in final_rows), - "period_profit_amount": sum(normalize_amount(row.get("period_profit_amount") or row.get("profit_amount")) for row in final_rows), + "period_revenue_amount": sum(normalize_amount(row.get("period_revenue_amount")) for row in final_rows), + "period_revenue_billing_gap": sum(normalize_amount(row.get("period_revenue_billing_gap")) for row in final_rows), + "period_revenue_collection_gap": sum(normalize_amount(row.get("period_revenue_collection_gap")) for row in final_rows), + "period_cost_total": sum(normalize_amount(row.get("period_cost_total")) for row in final_rows), + "period_cost_labor_total": sum(normalize_amount(row.get("period_cost_labor_total")) for row in final_rows), + "period_sga_labor_total": sum(normalize_amount(row.get("period_sga_labor_total")) for row in final_rows), + "period_sga_total": sum(normalize_amount(row.get("period_sga_total")) for row in final_rows), + "period_sales_total": sum(normalize_amount(row.get("period_sales_total")) for row in final_rows), + "period_total_cost": sum(normalize_amount(row.get("period_total_cost")) for row in final_rows), + "period_profit_amount": sum(normalize_amount(row.get("period_profit_amount")) for row in final_rows), "contract_balance_amount": sum(normalize_amount(row.get("contract_balance_amount")) for row in final_rows), "revenue_amount": sum(normalize_amount(row.get("revenue_amount")) for row in final_rows), "cost_total": sum(normalize_amount(row.get("cost_total")) for row in final_rows), + "cost_labor_total": sum(normalize_amount(row.get("cost_labor_total")) for row in final_rows), + "sga_labor_total": sum(normalize_amount(row.get("sga_labor_total")) for row in final_rows), "sga_total": sum(normalize_amount(row.get("sga_total")) for row in final_rows), "sales_total": sum(normalize_amount(row.get("sales_total")) for row in final_rows), "total_cost": sum(normalize_amount(row.get("total_cost")) for row in final_rows), "profit_amount": sum(normalize_amount(row.get("profit_amount")) for row in final_rows), + "cumulative_revenue_amount": sum(normalize_amount(row.get("cumulative_revenue_amount")) for row in final_rows), + "cumulative_cost_total": sum(normalize_amount(row.get("cumulative_cost_total")) for row in final_rows), + "cumulative_sga_total": sum(normalize_amount(row.get("cumulative_sga_total")) for row in final_rows), + "cumulative_sales_total": sum(normalize_amount(row.get("cumulative_sales_total")) for row in final_rows), + "cumulative_total_cost": sum(normalize_amount(row.get("cumulative_total_cost")) for row in final_rows), + "cumulative_profit_amount": sum(normalize_amount(row.get("cumulative_profit_amount")) for row in final_rows), + "common_revenue_amount": sum(normalize_amount(row.get("common_revenue_amount")) for row in final_rows), + "project_revenue_amount": sum(normalize_amount(row.get("period_revenue_amount")) for row in final_rows if not row.get("is_common_revenue")), + "common_revenue_row_count": sum(1 for row in final_rows if row.get("is_common_revenue")), "project_count": len(final_rows), } summary["collection_rate"] = _safe_ratio(summary["collection_amount"], summary["contract_amount"]) @@ -14614,45 +17339,1448 @@ def _cost_analysis_build_payload(start_date_text: str, end_date_text: str, mode: summary["collection_profit_rate"] = _safe_ratio(summary["profit_amount"], summary["collection_amount"]) summary["period_revenue_profit_rate"] = _safe_ratio(summary["period_profit_amount"], summary["period_revenue_amount"]) summary["period_collection_profit_rate"] = _safe_ratio(summary["period_profit_amount"], summary["period_collection_amount"]) - summary["cumulative_profit_rate"] = _safe_ratio(summary["collection_amount"] - summary["total_cost"], summary["collection_amount"]) + summary["cumulative_profit_rate"] = _safe_ratio(summary["cumulative_profit_amount"], summary["cumulative_revenue_amount"]) + allocation_diagnostics = { + "common_labor_excluded": sum( + normalize_amount(row.get("excluded_common_labor_amount")) + for row in annual_common_rows + ), + "common_cost_allocated": sum( + normalize_amount(row.get("common_cost_amount")) + for row in annual_common_rows + ), + "common_sga_allocated": sum( + normalize_amount(row.get("sga_amount")) + for row in annual_common_rows + ), + "hanmac_missing_regular_sga_allocated": sum( + normalize_amount(amount) + for amount in annual_hanmac_missing_sga_by_year.values() + ), + "hanmac_labor_total": sum( + _cost_analysis_hanmac_labor_totals_by_year( + annual_hanmac_labor_by_year, + annual_hanmac_missing_sga_by_year, + ).values() + ), + } payload = { "start_date": start_date.isoformat(), "end_date": end_date.isoformat(), - "mode": "aggregate" if linked_mode else "individual", + "mode": "individual", "rows": final_rows, "summary": summary, + "accumulation_start_date": accumulation_start.isoformat(), + "allocation_diagnostics": allocation_diagnostics, + "accounting_comparison": _cost_analysis_accounting_comparison( + start_date, + end_date, + summary, + allocation_diagnostics, + ), } - _store_system_page_cache( - "cost_analysis_payload", - persistent_cache_key, - params={ - "start_date": start_date.isoformat(), - "end_date": end_date.isoformat(), - "mode": payload["mode"], - "data_version": cache_key[3], - "hanmac_cache_version": cache_key[4], + return _cost_analysis_store_payload(context, "individual", payload) + + +def _cost_analysis_summary_for_rows(rows: list[dict[str, Any]]) -> dict[str, Any]: + summary = { + "contract_amount": sum(normalize_amount(row.get("contract_amount")) for row in rows), + "billing_amount": sum(normalize_amount(row.get("billing_amount")) for row in rows), + "collection_amount": sum(normalize_amount(row.get("collection_amount")) for row in rows), + "period_billing_amount": sum(normalize_amount(row.get("period_billing_amount")) for row in rows), + "period_negative_billing_amount": sum(normalize_amount(row.get("period_negative_billing_amount")) for row in rows), + "period_collection_amount": sum(normalize_amount(row.get("period_collection_amount")) for row in rows), + "period_revenue_amount": sum(normalize_amount(row.get("period_revenue_amount")) for row in rows), + "period_revenue_billing_gap": sum(normalize_amount(row.get("period_revenue_billing_gap")) for row in rows), + "period_revenue_collection_gap": sum(normalize_amount(row.get("period_revenue_collection_gap")) for row in rows), + "period_cost_total": sum(normalize_amount(row.get("period_cost_total")) for row in rows), + "period_cost_labor_total": sum(normalize_amount(row.get("period_cost_labor_total")) for row in rows), + "period_sga_labor_total": sum(normalize_amount(row.get("period_sga_labor_total")) for row in rows), + "period_sga_total": sum(normalize_amount(row.get("period_sga_total")) for row in rows), + "period_sales_total": sum(normalize_amount(row.get("period_sales_total")) for row in rows), + "period_total_cost": sum(normalize_amount(row.get("period_total_cost")) for row in rows), + "period_profit_amount": sum(normalize_amount(row.get("period_profit_amount")) for row in rows), + "contract_balance_amount": sum(normalize_amount(row.get("contract_balance_amount")) for row in rows), + "revenue_amount": sum(normalize_amount(row.get("revenue_amount")) for row in rows), + "cost_total": sum(normalize_amount(row.get("cost_total")) for row in rows), + "cost_labor_total": sum(normalize_amount(row.get("cost_labor_total")) for row in rows), + "sga_labor_total": sum(normalize_amount(row.get("sga_labor_total")) for row in rows), + "sga_total": sum(normalize_amount(row.get("sga_total")) for row in rows), + "sales_total": sum(normalize_amount(row.get("sales_total")) for row in rows), + "total_cost": sum(normalize_amount(row.get("total_cost")) for row in rows), + "profit_amount": sum(normalize_amount(row.get("profit_amount")) for row in rows), + "cumulative_revenue_amount": sum(normalize_amount(row.get("cumulative_revenue_amount")) for row in rows), + "cumulative_cost_total": sum(normalize_amount(row.get("cumulative_cost_total")) for row in rows), + "cumulative_sga_total": sum(normalize_amount(row.get("cumulative_sga_total")) for row in rows), + "cumulative_sales_total": sum(normalize_amount(row.get("cumulative_sales_total")) for row in rows), + "cumulative_total_cost": sum(normalize_amount(row.get("cumulative_total_cost")) for row in rows), + "cumulative_profit_amount": sum(normalize_amount(row.get("cumulative_profit_amount")) for row in rows), + "common_revenue_amount": sum(normalize_amount(row.get("common_revenue_amount")) for row in rows), + "project_revenue_amount": sum(normalize_amount(row.get("period_revenue_amount")) for row in rows if not row.get("is_common_revenue")), + "common_revenue_row_count": sum(1 for row in rows if row.get("is_common_revenue")), + "project_count": len(rows), + } + summary["collection_rate"] = _safe_ratio(summary["collection_amount"], summary["contract_amount"]) + summary["contract_profit_rate"] = _safe_ratio(summary["profit_amount"], summary["contract_amount"]) + summary["revenue_profit_rate"] = _safe_ratio(summary["profit_amount"], summary["revenue_amount"]) + summary["collection_profit_rate"] = _safe_ratio(summary["profit_amount"], summary["collection_amount"]) + summary["period_revenue_profit_rate"] = _safe_ratio(summary["period_profit_amount"], summary["period_revenue_amount"]) + summary["period_collection_profit_rate"] = _safe_ratio(summary["period_profit_amount"], summary["period_collection_amount"]) + summary["cumulative_profit_rate"] = _safe_ratio(summary["cumulative_profit_amount"], summary["cumulative_revenue_amount"]) + return summary + + +def _cost_analysis_accounting_comparison( + start_date: date, + end_date: date, + allocated_summary: dict[str, Any], + allocation_diagnostics: dict[str, Any], +) -> dict[str, Any]: + is_full_year = ( + start_date.year == end_date.year + and start_date == date(start_date.year, 1, 1) + and end_date == date(end_date.year, 12, 31) + ) + result = { + "available": is_full_year, + "scope_label": f"{start_date.isoformat()}~{end_date.isoformat()}", + "allocated_cost": normalize_amount(allocated_summary.get("total_cost")), + "allocated_profit": normalize_amount(allocated_summary.get("period_profit_amount")), + "allocated_profit_rate": _safe_ratio( + allocated_summary.get("period_profit_amount"), + allocated_summary.get("period_revenue_amount"), + ), + **allocation_diagnostics, + } + if not is_full_year: + result["unavailable_reason"] = "WEHAGO 감사 후 재무제표 비교는 전체 회계연도 조회에서 제공합니다." + return result + + with engine.begin() as conn: + erp = conn.execute( + text( + """ + SELECT + SUM(CASE WHEN accounting_category = '원가' THEN amount ELSE 0 END) AS cost, + SUM(CASE WHEN accounting_category = '판관비' THEN amount ELSE 0 END) AS sga, + SUM(CASE WHEN accounting_category = '수입/매출액' OR account_code LIKE '4%' THEN amount ELSE 0 END) AS revenue + FROM transactions + WHERE year = :year + """ + ), + {"year": start_date.year}, + ).mappings().first() + wehago_rows = conn.execute( + text( + """ + SELECT COALESCE(account_code, '') AS account_code, + SUM(COALESCE(debit, 0)) AS debit, + SUM(COALESCE(credit, 0)) AS credit + FROM wehago_ledger_rows + WHERE fiscal_year = :year + GROUP BY account_code + """ + ), + {"year": start_date.year}, + ).mappings().all() + + wehago_revenue = 0.0 + wehago_cost = 0.0 + wehago_sga = 0.0 + wehago_labor_total = 0.0 + for row in wehago_rows: + code = normalize_text(row.get("account_code")) + amount = _financial_gap_statement_amount(row) + if code in {"411", "412", "413", "414", "415", "416", "417"}: + wehago_revenue += abs(normalize_amount(row.get("credit"))) + elif code == "452": + wehago_cost += amount + elif code.startswith("8"): + wehago_sga += amount + elif code == "908": + wehago_sga -= amount + if code in {"604", "606", "609", "611", "802", "808", "811"}: + wehago_labor_total += amount + + erp_cost = normalize_amount((erp or {}).get("cost")) + erp_sga = normalize_amount((erp or {}).get("sga")) + erp_revenue = normalize_amount((erp or {}).get("revenue")) + wehago_operating_expense = wehago_cost + wehago_sga + wehago_operating_profit = wehago_revenue - wehago_operating_expense + result.update( + { + "year": start_date.year, + "erp_cost": erp_cost, + "erp_sga": erp_sga, + "erp_operating_expense": erp_cost + erp_sga, + "erp_revenue": erp_revenue, + "wehago_cost": wehago_cost, + "wehago_sga": wehago_sga, + "wehago_operating_expense": wehago_operating_expense, + "wehago_revenue": wehago_revenue, + "wehago_operating_profit": wehago_operating_profit, + "wehago_operating_margin": _safe_ratio(wehago_operating_profit, wehago_revenue), + "allocated_to_wehago_expense_gap": normalize_amount(allocated_summary.get("total_cost")) - wehago_operating_expense, + "wehago_labor_total": wehago_labor_total, + "hanmac_to_wehago_labor_gap": normalize_amount(result.get("hanmac_labor_total")) - wehago_labor_total, + } + ) + return result + + +def _cost_analysis_filter_payload_codes(payload: dict[str, Any], codes: list[str] | set[str] | tuple[str, ...]) -> dict[str, Any]: + requested_codes = {normalize_text(code).upper() for code in codes if normalize_text(code)} + if not requested_codes: + return payload + filtered = copy.deepcopy(payload) + filtered_rows = [] + for row in payload.get("rows") or []: + row_codes = { + normalize_text(row.get("support_dept_code")).upper(), + *[normalize_text(code).upper() for code in (row.get("direct_codes") or [])], + *[normalize_text(code).upper() for code in (row.get("aggregate_codes") or [])], + } + if row_codes & requested_codes: + filtered_rows.append(copy.deepcopy(row)) + filtered["rows"] = filtered_rows + filtered["summary"] = _cost_analysis_summary_for_rows(filtered_rows) + filtered["validation_codes"] = sorted(requested_codes) + filtered["cache_info"] = { + **(filtered.get("cache_info") or {}), + "validation_mode": True, + } + return filtered + + +PAGE2_LABOR_TOKENS = ( + "급여", + "임금", + "상여", + "제수당", + "퇴직", + "퇴직금", + "퇴직급여", + "잡급", + "연차", + "연월차", + "국민연금", + "건강보험", + "고용보험", + "산재보험", + "장기요양", +) +PAGE2_INSURANCE_LABOR_TOKENS = ("국민연금", "건강보험", "고용보험", "산재보험", "장기요양") +PAGE2_WEHAGO_LABOR_CODES = {"604", "606", "609", "611", "802", "808", "811"} +PAGE2_WEHAGO_RND_CODES = {"650", "823"} + + +def _cost_analysis2_text_blob(row: Mapping[str, Any]) -> str: + return " ".join( + normalize_text(row.get(key)) + for key in ( + "account_code", + "account_name", + "memo1", + "memo2", + "description", + "vendor_name", + "partner_name", + ) + ) + + +def _cost_analysis2_is_labor_like(row: Mapping[str, Any]) -> bool: + text_blob = _cost_analysis2_text_blob(row) + account_name = normalize_text(row.get("account_name")) + if "복리후생" in account_name and not any(token in text_blob for token in PAGE2_INSURANCE_LABOR_TOKENS): + return False + return any(token in text_blob for token in PAGE2_LABOR_TOKENS) + + +def _cost_analysis2_is_rnd_like(row: Mapping[str, Any]) -> bool: + text_blob = _cost_analysis2_text_blob(row) + return "경상시험연구" in text_blob or "연구개발" in text_blob or "연구원" in text_blob + + +def _cost_analysis2_expense_item(row: Mapping[str, Any]) -> str: + if _cost_analysis_is_sales_cost(dict(row)): + return "sales" + account_code = normalize_text(row.get("account_code")) + account_name = normalize_text(row.get("account_name")) + if account_code.startswith("6"): + return "sga" + if any(keyword in account_name for keyword in COST_ANALYSIS_OUTSOURCE_KEYWORDS): + return "outsource" + return "overhead" + + +def _cost_analysis2_reset_costs(row: dict[str, Any]) -> None: + row["phases"] = _cost_analysis_empty_phase_totals() + row["allocated"] = _cost_analysis_empty_phase_totals() + for key in ( + "period_cost_total", + "period_cost_labor_total", + "period_sga_labor_total", + "period_sga_total", + "period_sales_total", + "period_total_cost", + "period_profit_amount", + "cost_total", + "cost_labor_total", + "sga_labor_total", + "sga_total", + "sales_total", + "total_cost", + "profit_amount", + ): + row[key] = 0.0 + + +def _cost_analysis2_wehago_adjusted_statement(year: int) -> dict[str, float]: + with engine.begin() as conn: + account_rows = conn.execute( + text( + """ + SELECT COALESCE(account_code, '') AS account_code, + COALESCE(account_name, '') AS account_name, + SUM(COALESCE(debit, 0)) AS debit, + SUM(COALESCE(credit, 0)) AS credit + FROM wehago_ledger_rows + WHERE fiscal_year = :year + GROUP BY account_code, account_name + """ + ), + {"year": year}, + ).mappings().all() + detail_rows = conn.execute( + text( + """ + SELECT fiscal_year, + COALESCE(ledger_date, '') AS ledger_date, + COALESCE(account_code, '') AS account_code, + COALESCE(account_name, '') AS account_name, + COALESCE(description, '') AS description, + COALESCE(vendor_name, '') AS vendor_name, + COALESCE(compare_desc, '') AS compare_desc, + COALESCE(voucher_no, '') AS voucher_no, + COALESCE(debit, 0) AS debit, + COALESCE(credit, 0) AS credit + FROM wehago_ledger_rows + WHERE fiscal_year = :year + AND ( + account_code IN ('411','412','413','414','415','416','417','452','908') + OR account_code LIKE '6%' + OR account_code LIKE '8%' + ) + """ + ), + {"year": year}, + ).mappings().all() + operating_expense = 0.0 + labor = 0.0 + rnd = 0.0 + for row in account_rows: + code = normalize_text(row.get("account_code")) + amount = _financial_gap_statement_amount(row) + if code == "452": + operating_expense += amount + elif code.startswith("8"): + operating_expense += amount + elif code == "908": + operating_expense -= amount + if code in PAGE2_WEHAGO_LABOR_CODES: + labor += amount + if code in PAGE2_WEHAGO_RND_CODES: + rnd += amount + adjustment_total = 0.0 + adjustment_labor = 0.0 + adjustment_rnd = 0.0 + for row in detail_rows: + code = normalize_text(row.get("account_code")) + bucket_key = _financial_gap_bucket_for_account(code) + if bucket_key not in {"cogs", "sga", "cost_detail"}: + continue + if _financial_gap_is_closing_transfer(row) or not _financial_gap_is_audit_adjustment(row): + continue + delta = _financial_gap_signed_statement_delta(code, row.get("debit"), row.get("credit")) + if not delta: + continue + adjustment_total += delta + if code in PAGE2_WEHAGO_LABOR_CODES or _cost_analysis2_is_labor_like(row): + adjustment_labor += delta + if code in PAGE2_WEHAGO_RND_CODES or _cost_analysis2_is_rnd_like(row): + adjustment_rnd += delta + adjusted_expense = operating_expense - adjustment_total + adjusted_labor = labor - adjustment_labor + adjusted_rnd = rnd - adjustment_rnd + return { + "wehago_operating_expense": operating_expense, + "wehago_adjustment_total": adjustment_total, + "wehago_adjusted_expense": adjusted_expense, + "wehago_labor": labor, + "wehago_adjusted_labor": adjusted_labor, + "wehago_rnd": rnd, + "wehago_adjusted_rnd": adjusted_rnd, + "wehago_adjusted_nonlabor": adjusted_expense - adjusted_labor, + "wehago_adjusted_nonlabor_without_rnd": adjusted_expense - adjusted_labor - adjusted_rnd, + } + + +def _cost_analysis_erp_basis_payload(start_date_text: str, end_date_text: str, mode: str = "individual") -> dict[str, Any]: + context = _cost_analysis_payload_cache_context(start_date_text, end_date_text) + start_date = context["start_date"] + end_date = context["end_date"] + normalized_mode = "aggregate" if normalize_text(mode).lower() in {"aggregate", "sum", "합산", "연계", "linked", "link"} else "individual" + base_payload = copy.deepcopy(_cost_analysis_load_cached_payload(context, "individual") or {}) + if not base_payload: + base_payload = copy.deepcopy( + _cost_analysis_build_individual_payload( + context, + force=False, + include_cumulative=False, + ) + ) + common_period_revenue_amount = sum( + normalize_amount(row.get("period_revenue_amount")) + for row in (base_payload.get("rows") or []) + if row.get("is_common_revenue") + ) + rows_by_code: dict[str, dict[str, Any]] = {} + for row in base_payload.get("rows") or []: + if row.get("is_common_revenue"): + continue + copied = copy.deepcopy(row) + _cost_analysis2_reset_costs(copied) + rows_by_code[normalize_text(copied.get("support_dept_code")).upper()] = copied + + project_meta = _cost_analysis_get_project_meta() + completion_dates = _cost_analysis_get_completion_billing_dates() + + def resolve_report_code(source_code: Any) -> str: + normalized_code = normalize_text(source_code).upper() + if normalized_code in COST_ANALYSIS_COMMON_CODES: + return "" + return normalized_code if normalized_code.startswith(("0", "9", "X", "Y", "Z")) else "" + + def ensure_row(code: str) -> dict[str, Any]: + normalized_code = normalize_text(code).upper() + if normalized_code not in rows_by_code: + meta = project_meta.get(normalized_code, {"support_dept_code": normalized_code, "support_dept_name": normalized_code}) + rows_by_code[normalized_code] = _cost_analysis_row_template( + normalized_code, + meta, + start_date.year if start_date.year == end_date.year else None, + ) + return rows_by_code[normalized_code] + + hanmac_hours_by_year, _old_labor_by_year, _old_missing_sga = _cost_analysis_load_hanmac_hours_and_labor_yearly( + start_date, + end_date, + project_meta, + None, + ) + hour_weights: dict[int, dict[str, dict[str, float]]] = {} + for year, code_map in hanmac_hours_by_year.items(): + for code, phase_hours in code_map.items(): + target_code = resolve_report_code(code) + if not target_code: + continue + target = hour_weights.setdefault(year, {}).setdefault(target_code, {"pre": 0.0, "during": 0.0, "post": 0.0}) + for phase, hours in phase_hours.items(): + if phase in target: + target[phase] += normalize_amount(hours) + ensure_row(target_code) + + with engine.begin() as conn: + tx_rows = conn.execute( + text( + f""" + SELECT + COALESCE(voucher_number, '') AS voucher_number, + {COST_ANALYSIS_TX_DATE_SQL} AS posting_date, + CAST(strftime('%Y', {COST_ANALYSIS_TX_DATE_SQL}) AS INTEGER) AS posting_year, + COALESCE(account_code, '') AS account_code, + COALESCE(account_name, '') AS account_name, + COALESCE(accounting_category, '') AS accounting_category, + COALESCE(support_dept_code, '') AS support_dept_code, + COALESCE(support_dept_name, '') AS support_dept_name, + COALESCE(issuing_dept_code, '') AS issuing_dept_code, + COALESCE(issuing_dept_name, '') AS issuing_dept_name, + COALESCE(cost_dept_code, '') AS cost_dept_code, + COALESCE(cost_dept_name, '') AS cost_dept_name, + COALESCE(partner_name, '') AS partner_name, + COALESCE(memo1, '') AS memo1, + COALESCE(memo2, '') AS memo2, + COALESCE(amount, 0) AS amount + FROM transactions + WHERE {COST_ANALYSIS_TX_DATE_SQL} >= :start_date + AND {COST_ANALYSIS_TX_DATE_SQL} <= :end_date + AND (account_code LIKE '5%' OR account_code LIKE '6%') + """ + ), + {"start_date": start_date.isoformat(), "end_date": end_date.isoformat()}, + ).mappings().all() + + pools: dict[int, dict[str, float]] = {} + diagnostics = { + "erp_total_expense": 0.0, + "erp_labor_pool": 0.0, + "erp_cost_labor_pool": 0.0, + "erp_sga_labor_pool": 0.0, + "erp_rnd_labor_pool": 0.0, + "erp_rnd_nonlabor_pool": 0.0, + "erp_direct_nonlabor": 0.0, + "erp_common_nonlabor_cost_pool": 0.0, + "erp_common_nonlabor_sga_pool": 0.0, + "judgement_required_amount": 0.0, + } + for raw_row in tx_rows: + row = dict(raw_row) + year = int(row.get("posting_year") or 0) + if not year: + continue + amount = normalize_amount(row.get("amount")) + if not amount: + continue + bucket = _cost_analysis_financial_bucket(row.get("account_code")) + if bucket not in {"cost", "sga"}: + continue + diagnostics["erp_total_expense"] += amount + pool = pools.setdefault(year, {"labor": 0.0, "sga_labor": 0.0, "common_cost": 0.0, "common_sga": 0.0}) + is_rnd = _cost_analysis2_is_rnd_like(row) + is_labor = _cost_analysis2_is_labor_like(row) + if is_labor: + labor_key = "sga_labor" if normalize_text(row.get("accounting_category")) == "판관비" else "labor" + pool[labor_key] += amount + diagnostics["erp_labor_pool"] += amount + diagnostics["erp_sga_labor_pool" if labor_key == "sga_labor" else "erp_cost_labor_pool"] += amount + if is_rnd: + diagnostics["erp_rnd_labor_pool"] += amount + continue + if is_rnd: + diagnostics["erp_rnd_nonlabor_pool"] += amount + if "기타" in _cost_analysis2_text_blob(row) or "복리후생" in normalize_text(row.get("account_name")): + diagnostics["judgement_required_amount"] += amount + source_code = normalize_text(row.get("support_dept_code")).upper() + target_code = resolve_report_code(source_code) + item_key = _cost_analysis2_expense_item(row) + if not target_code: + if item_key == "sga": + pool["common_sga"] += amount + diagnostics["erp_common_nonlabor_sga_pool"] += amount + else: + pool["common_cost"] += amount + diagnostics["erp_common_nonlabor_cost_pool"] += amount + continue + report_row = ensure_row(target_code) + phase = "pre" if target_code.startswith("X") else _cost_analysis_phase_for_transaction( + target_code, + _date_text(row.get("posting_date")), + completion_dates, + project_meta, + ) + if item_key not in report_row["phases"].get(phase, {}): + item_key = "sga" if bucket == "sga" else "overhead" + report_row["phases"][phase][item_key] += amount + diagnostics["erp_direct_nonlabor"] += amount + + unresolved_labor = 0.0 + unresolved_common = 0.0 + for year, pool in pools.items(): + weights = hour_weights.get(year, {}) + total_hours = sum( + normalize_amount(hours) + for phase_hours in weights.values() + for hours in phase_hours.values() + ) + if total_hours <= 0: + unresolved_labor += pool.get("labor", 0.0) + pool.get("sga_labor", 0.0) + unresolved_common += pool.get("common_cost", 0.0) + pool.get("common_sga", 0.0) + continue + for code, phase_hours in weights.items(): + report_row = ensure_row(code) + for phase, hours in phase_hours.items(): + if phase not in report_row["phases"]: + continue + ratio = normalize_amount(hours) / total_hours + labor_amount = pool.get("labor", 0.0) * ratio + sga_labor_amount = pool.get("sga_labor", 0.0) * ratio + common_cost_amount = pool.get("common_cost", 0.0) * ratio + common_sga_amount = pool.get("common_sga", 0.0) * ratio + report_row["phases"][phase]["labor"] += labor_amount + report_row["phases"][phase]["sga_labor"] += sga_labor_amount + report_row["phases"][phase]["overhead"] += common_cost_amount + report_row["phases"][phase]["sga"] += common_sga_amount + report_row["allocated"][phase]["labor"] += labor_amount + report_row["allocated"][phase]["sga_labor"] += sga_labor_amount + report_row["allocated"][phase]["overhead"] += common_cost_amount + report_row["allocated"][phase]["sga"] += common_sga_amount + + final_rows = list(rows_by_code.values()) + for row in final_rows: + _cost_analysis_finalize_row(row) + row["cumulative_revenue_amount"] = normalize_amount(row.get("revenue_amount")) + row["cumulative_cost_total"] = normalize_amount(row.get("cost_total")) + row["cumulative_sga_total"] = normalize_amount(row.get("sga_total")) + row["cumulative_sales_total"] = normalize_amount(row.get("sales_total")) + row["cumulative_total_cost"] = normalize_amount(row.get("total_cost")) + row["cumulative_profit_amount"] = row["cumulative_revenue_amount"] - row["cumulative_total_cost"] + row["cumulative_profit_rate"] = _safe_ratio(row["cumulative_profit_amount"], row["cumulative_revenue_amount"]) + if normalized_mode == "aggregate": + representative_map = _cost_analysis_get_link_representative_map() + final_rows = _cost_analysis_aggregate_rows(final_rows, project_meta, representative_map) + + final_rows.sort(key=lambda item: (normalize_text(item.get("pm_department")), normalize_text(item.get("project_type")), normalize_text(item.get("project_name")))) + summary = _cost_analysis_summary_for_rows(final_rows) + year_comparisons: list[dict[str, Any]] = [] + for year in sorted(pools): + statement = _cost_analysis2_wehago_adjusted_statement(year) + year_project_rows = [ + row for row in final_rows + if int(normalize_amount(row.get("year")) or year) == year or start_date.year == end_date.year + ] if start_date.year == end_date.year else final_rows + project_total = sum(normalize_amount(row.get("total_cost")) for row in year_project_rows) if start_date.year == end_date.year else summary["total_cost"] + year_comparisons.append( + { + "year": year, + **statement, + "erp_basis_allocated_total": project_total, + "erp_basis_to_wehago_adjusted_gap": project_total - statement["wehago_adjusted_expense"], + } + ) + diagnostics["unresolved_labor_pool"] = unresolved_labor + diagnostics["unresolved_common_pool"] = unresolved_common + diagnostics["erp_basis_total_cost"] = summary["total_cost"] + diagnostics["erp_basis_total_gap_to_erp"] = summary["total_cost"] - diagnostics["erp_total_expense"] + payload = { + **base_payload, + "mode": normalized_mode, + "rows": final_rows, + "summary": summary, + "allocation_diagnostics": { + **(base_payload.get("allocation_diagnostics") or {}), + **diagnostics, }, - payload=payload, - row_count=len(final_rows), - signature=str(cache_key[3]), + "accounting_comparison": { + **(base_payload.get("accounting_comparison") or {}), + "erp_basis_available": True, + "erp_basis": "hanmac_erp_transactions", + "erp_basis_year_comparisons": year_comparisons, + **diagnostics, + }, + "common_period_revenue_amount": common_period_revenue_amount, + "cache_info": { + "source": "computed", + "ready": True, + "financial_logic_version": COST_ANALYSIS_FINANCIAL_LOGIC_VERSION, + "h_project_mapping_version": COST_ANALYSIS_H_PROJECT_MAPPING_VERSION, + "link_logic_version": COST_ANALYSIS_LINK_LOGIC_VERSION, + "data_version": context["data_version"], + "hanmac_cache_version": context["hanmac_cache_version"], + "generated_at": datetime.now().isoformat(timespec="seconds"), + }, + } + return payload + + +def _cost_analysis1_effective_period(start_date: date, end_date: date) -> tuple[date, date]: + minimum = date(2023, 1, 1) + cutoff = date(2026, 3, 31) + effective_start = max(start_date, minimum) + effective_end = min(end_date, cutoff) + if effective_end < effective_start: + effective_start = effective_end + return effective_start, effective_end + + +def _cost_analysis1_labor_basis( + start_date: date, + end_date: date, + project_meta: dict[str, dict[str, Any]], +) -> dict[str, Any]: + alias_to_code, title_to_codes = _cost_analysis_build_hanmac_matchers(project_meta) + completion_dates = _cost_analysis_get_completion_billing_dates() + rates_by_year = _parse_labor_rates_json(get_shared_exec_labor_rates_json()) + if not rates_by_year: + rates_by_year = _parse_labor_rates_json(json.dumps(DEFAULT_EXEC_LABOR_RATES, ensure_ascii=False)) + standard: dict[int, dict[str, dict[str, float]]] = {} + hours: dict[int, dict[str, dict[str, float]]] = {} + dept_hours: dict[int, dict[str, dict[tuple[str, str], float]]] = {} + dept_standard: dict[int, dict[str, dict[tuple[str, str], float]]] = {} + common_activity_meta: dict[str, dict[str, Any]] = {} + unresolved_hours = 0.0 + + def labor_bucket(code: str) -> str: + normalized = normalize_text(code).upper() + return "cost" if normalized.startswith(("Y", "Z")) and normalized != "ZZZZZZ" else "sga" + + for year_slice in _iter_year_slices(start_date, end_date): + metric, row_items = _cost_analysis_load_hanmac_member_rows( + year_slice["start"], + year_slice["end"], + prefer_member_grade=True, + ) + if not metric: + continue + resolve_cache: dict[tuple[str, str, str], list[str]] = {} + + def add_project( + project: dict[str, Any], + work_date_text: Any, + member_grade: str, + dept_name: str, + recognized_hours: float, + multiplier: float, + ) -> None: + nonlocal unresolved_hours + if recognized_hours <= 0: + return + work_date = _parse_iso_date(work_date_text) or year_slice["start"] + if work_date < year_slice["start"] or work_date > year_slice["end"]: + return + resolve_key = ( + normalize_text(project.get("project_code")).upper(), + "|".join(normalize_text(value).upper() for value in (project.get("equivalent_project_codes") or [])), + f"{normalize_project_title_for_linking(project.get('project_name'))}|{work_date.isoformat()}", + ) + codes = resolve_cache.get(resolve_key) + if codes is None: + codes = _cost_analysis_resolve_hanmac_project_codes( + project, + work_date, + alias_to_code, + title_to_codes, + project_meta, + ) + resolve_cache[resolve_key] = codes + if not codes: + fallback_code = normalize_text(project.get("project_code")).upper() + if not fallback_code: + fallback_code = next( + ( + normalize_text(value).upper() + for value in (project.get("equivalent_project_codes") or []) + if normalize_text(value) + ), + "", + ) + codes = [fallback_code] if fallback_code else [] + if not codes: + unresolved_hours += recognized_hours + return + common_activity = _cost_analysis_common_activity_info(project) + if common_activity: + meta = common_activity_meta.setdefault( + common_activity["key"], + { + "label": common_activity["label"], + "source_codes": set(), + }, + ) + meta["source_codes"].update(common_activity["source_codes"]) + split_hours = recognized_hours / len(codes) + cost_weight = normalize_amount(project.get("cost_weight")) or 1.0 + year = work_date.year + for code in codes: + normalized_code = normalize_text(code).upper() + normalized_dept = _cost_analysis_normalize_dept_name( + dept_name + or (project_meta.get(normalized_code) or {}).get("pm_department") + or (project_meta.get(normalized_code) or {}).get("department_name") + ) + phase = ( + "pre" + if normalized_code.startswith("X") + else _cost_analysis_phase_for_transaction( + normalized_code, + work_date.isoformat(), + completion_dates, + project_meta, + ) + ) + rate = _resolve_labor_rate( + rates_by_year, + member_grade, + str(year), + str(year), + (project_meta.get(normalized_code) or {}).get("project_type"), + ) + amount = rate * split_hours * multiplier * cost_weight + target_key = "labor" if labor_bucket(normalized_code) == "cost" else "sga_labor" + standard.setdefault(year, {}).setdefault( + normalized_code, + {"pre": 0.0, "during": 0.0, "post": 0.0, "bucket": target_key}, + )[phase] += amount + hours.setdefault(year, {}).setdefault( + normalized_code, + {"pre": 0.0, "during": 0.0, "post": 0.0}, + )[phase] += split_hours + if normalized_dept: + dept_key = (normalized_code, phase) + dept_hours.setdefault(year, {}).setdefault(normalized_dept, {}) + dept_hours[year][normalized_dept][dept_key] = ( + dept_hours[year][normalized_dept].get(dept_key, 0.0) + split_hours + ) + dept_standard.setdefault(year, {}).setdefault(normalized_dept, {}) + dept_standard[year][normalized_dept][dept_key] = ( + dept_standard[year][normalized_dept].get(dept_key, 0.0) + amount + ) + + for row in row_items: + member_grade = _normalize_labor_grade_name( + row.get("member_grade") or row.get("grade") or row.get("position") or row.get("rank") + ) + if not member_grade: + continue + dept_name = normalize_text(row.get("dept_name")) + details = row.get("aggregate_details") if isinstance(row.get("aggregate_details"), dict) else {} + for detail in details.get("regular_hours") or []: + projects = detail.get("projects") if isinstance(detail.get("projects"), list) else [] + raw_total = sum(normalize_amount(project.get("hours")) for project in projects) + recognized_total = normalize_amount(detail.get("regular_hours")) + for project in projects: + raw_hours = normalize_amount(project.get("hours")) + joint_hours = normalize_amount(project.get("recognized_hours")) if _is_hanmac_joint_detail(project) else 0.0 + next_hours = ( + joint_hours + if joint_hours > 0 + else recognized_total * raw_hours / raw_total + if raw_total > 0 and recognized_total > 0 + else raw_hours + ) + add_project(project, detail.get("work_date"), member_grade, dept_name, next_hours, 1.0) + for detail in details.get("overtime_hours") or []: + add_project( + detail, + detail.get("work_date"), + member_grade, + dept_name, + normalize_amount(detail.get("overtime_hours")), + 1.5, + ) + for detail in details.get("holiday_hours") or []: + projects = detail.get("projects") if isinstance(detail.get("projects"), list) else [] + recognized_total = min(normalize_amount(detail.get("holiday_hours")), 5.0) + if projects: + raw_total = sum(normalize_amount(project.get("hours")) for project in projects) + for project in projects: + raw_hours = normalize_amount(project.get("hours")) + next_hours = ( + recognized_total * raw_hours / raw_total + if raw_total > 0 and recognized_total > 0 + else min(raw_hours, 5.0) + ) + add_project(project, detail.get("work_date"), member_grade, dept_name, next_hours, 1.5) + else: + add_project(detail, detail.get("work_date"), member_grade, dept_name, recognized_total, 1.5) + + # ZZZZZZ는 미배부가 아니라 공통 프로젝트의 유효 코드다. + # 한맥 근무기록이 ZZZZZZ에 연결된 경우 해당 기준인건비를 그대로 보존한다. + return { + "standard": standard, + "hours": hours, + "dept_hours": dept_hours, + "dept_standard": dept_standard, + "common_activity_meta": { + code: { + "label": normalize_text(meta.get("label")), + "source_codes": sorted(meta.get("source_codes") or []), + } + for code, meta in common_activity_meta.items() + }, + "unresolved_hours": unresolved_hours, + } + + +def _cost_analysis1_build_payload(start_date_text: str, end_date_text: str, mode: str = "individual") -> dict[str, Any]: + context = _cost_analysis_payload_cache_context(start_date_text, end_date_text) + start_date, end_date = _cost_analysis1_effective_period( + context["start_date"], + context["end_date"], ) - return _set_deepcopy_ttl_cache_entry( - _COST_ANALYSIS_PAYLOAD_CACHE, - _COST_ANALYSIS_PAYLOAD_CACHE_LOCK, - cache_key, - payload, + normalized_mode = "aggregate" if normalize_text(mode).lower() in {"aggregate", "sum", "합산", "연계", "linked", "link"} else "individual" + erp_basis = copy.deepcopy(_cost_analysis_erp_basis_payload(start_date.isoformat(), end_date.isoformat(), "individual")) + rows_by_code: dict[str, dict[str, Any]] = {} + common_period_revenue = normalize_amount(erp_basis.get("common_period_revenue_amount")) + common_collection_amount = 0.0 + common_period_collection_amount = 0.0 + for collection_event in _cost_analysis_erp_collection_events(end_date): + code = normalize_text(collection_event.get("support_dept_code")).upper() + if code not in COST_ANALYSIS_COMMON_CODES: + continue + amount = normalize_amount(collection_event.get("amount")) + common_collection_amount += amount + posting_date = _date_text(collection_event.get("posting_date")) + if posting_date and start_date.isoformat() <= posting_date <= end_date.isoformat(): + common_period_collection_amount += amount + for source in erp_basis.get("rows") or []: + if source.get("is_common_revenue"): + continue + row = copy.deepcopy(source) + for phase in ("pre", "during", "post"): + bucket = row["phases"][phase] + bucket["overhead"] -= normalize_amount(row["allocated"][phase].get("overhead")) + bucket["sga"] -= normalize_amount(row["allocated"][phase].get("sga")) + bucket["labor"] = 0.0 + bucket["labor_adjustment"] = 0.0 + bucket["sga_labor"] = 0.0 + bucket["sga_labor_adjustment"] = 0.0 + row["allocated"][phase]["labor"] = 0.0 + row["allocated"][phase]["sga_labor"] = 0.0 + row["allocated"][phase]["overhead"] = 0.0 + row["allocated"][phase]["sga"] = 0.0 + rows_by_code[normalize_text(row.get("support_dept_code")).upper()] = row + + project_meta = _cost_analysis_get_project_meta() + common_activity_meta: dict[str, dict[str, Any]] = {} + common_activity_code_map: dict[str, str] = {} + + def ensure_row(code: str) -> dict[str, Any]: + normalized = normalize_text(code).upper() + normalized = common_activity_code_map.get(normalized, normalized) + if normalized not in rows_by_code: + activity_meta = common_activity_meta.get(normalized) + if activity_meta: + meta = { + "row_key": normalized, + "support_dept_name": normalize_text(activity_meta.get("label")) or "공통업무", + "pm_department": "공통", + "project_type": "공통업무", + } + row = _cost_analysis_row_template( + "ZZZZZZ", + meta, + start_date.year if start_date.year == end_date.year else None, + ) + row["row_key"] = normalized + row["support_dept_code"] = "ZZZZZZ" + row["project_name"] = meta["support_dept_name"] + row["pm_department"] = "공통" + row["project_type"] = "공통업무" + row["direct_codes"] = list(activity_meta.get("source_codes") or []) + row["detail_codes"] = [normalized] + row["is_common_activity"] = True + rows_by_code[normalized] = row + else: + meta = project_meta.get(normalized, {"support_dept_code": normalized, "support_dept_name": normalized}) + rows_by_code[normalized] = _cost_analysis_row_template( + normalized, + meta, + start_date.year if start_date.year == end_date.year else None, + ) + return rows_by_code[normalized] + + basis = _cost_analysis1_labor_basis(start_date, end_date, project_meta) + common_activity_meta = basis.get("common_activity_meta") or {} + common_activity_code_map = { + normalize_text(source_code).upper(): activity_code + for activity_code, activity_meta in common_activity_meta.items() + for source_code in (activity_meta.get("source_codes") or []) + if normalize_text(source_code) + } + generic_common_code = f"{COST_ANALYSIS_COMMON_ACTIVITY_PREFIX}ERP공통비" + common_activity_meta.setdefault( + generic_common_code, + { + "label": "공통/ERP 공통비", + "source_codes": ["ZZZZZZ"], + }, ) + all_hour_weights: dict[int, dict[tuple[str, str], float]] = {} + for year, code_map in basis["hours"].items(): + for code, phase_map in code_map.items(): + normalized_code = normalize_text(code).upper() + if not normalized_code or normalized_code in COST_ANALYSIS_COMMON_CODES: + continue + for phase in ("pre", "during", "post"): + amount = normalize_amount(phase_map.get(phase)) + if amount > 0: + all_hour_weights.setdefault(year, {})[(normalized_code, phase)] = ( + all_hour_weights.setdefault(year, {}).get((normalized_code, phase), 0.0) + amount + ) + + with engine.begin() as conn: + common_rows = conn.execute( + text( + f""" + SELECT + CAST(strftime('%Y', {COST_ANALYSIS_TX_DATE_SQL}) AS INTEGER) AS posting_year, + COALESCE(account_code, '') AS account_code, + COALESCE(account_name, '') AS account_name, + COALESCE(accounting_category, '') AS accounting_category, + COALESCE(support_dept_code, '') AS support_dept_code, + COALESCE(support_dept_name, '') AS support_dept_name, + COALESCE(issuing_dept_code, '') AS issuing_dept_code, + COALESCE(issuing_dept_name, '') AS issuing_dept_name, + COALESCE(cost_dept_code, '') AS cost_dept_code, + COALESCE(cost_dept_name, '') AS cost_dept_name, + COALESCE(partner_name, '') AS partner_name, + COALESCE(memo1, '') AS memo1, + COALESCE(memo2, '') AS memo2, + COALESCE(amount, 0) AS amount + FROM transactions + WHERE {COST_ANALYSIS_TX_DATE_SQL} >= :start_date + AND {COST_ANALYSIS_TX_DATE_SQL} <= :end_date + AND (account_code LIKE '5%' OR account_code LIKE '6%') + AND UPPER(COALESCE(support_dept_code, '')) IN ('', 'ZZZZZZ') + """ + ), + {"start_date": start_date.isoformat(), "end_date": end_date.isoformat()}, + ).mappings().all() + for raw_row in common_rows: + tx_row = dict(raw_row) + if _cost_analysis2_is_labor_like(tx_row): + continue + amount = normalize_amount(tx_row.get("amount")) + if not amount: + continue + target = ensure_row(generic_common_code) + phase = "during" + item_key = _cost_analysis2_expense_item(tx_row) + if item_key not in target["phases"][phase]: + item_key = "sga" if normalize_text(tx_row.get("accounting_category")) == "판관비" else "overhead" + target["phases"][phase][item_key] += amount + + # ERP 기초자료에서 접두어 필터로 빠진 기타 코드를 페이지1에 직접 보완한다. + # ZZZZZZ/미지정 비용은 위 공통비 풀에서 전체 프로젝트 투입시간으로 배부한다. + initial_basis_codes = set(rows_by_code) + completion_dates = _cost_analysis_get_completion_billing_dates() + with engine.begin() as conn: + direct_rows = conn.execute( + text( + f""" + SELECT + {COST_ANALYSIS_TX_DATE_SQL} AS posting_date, + COALESCE(account_code, '') AS account_code, + COALESCE(account_name, '') AS account_name, + COALESCE(accounting_category, '') AS accounting_category, + UPPER(COALESCE(support_dept_code, '')) AS support_dept_code, + COALESCE(support_dept_name, '') AS support_dept_name, + COALESCE(issuing_dept_code, '') AS issuing_dept_code, + COALESCE(issuing_dept_name, '') AS issuing_dept_name, + COALESCE(cost_dept_code, '') AS cost_dept_code, + COALESCE(cost_dept_name, '') AS cost_dept_name, + COALESCE(partner_name, '') AS partner_name, + COALESCE(memo1, '') AS memo1, + COALESCE(memo2, '') AS memo2, + COALESCE(amount, 0) AS amount + FROM transactions + WHERE {COST_ANALYSIS_TX_DATE_SQL} >= :start_date + AND {COST_ANALYSIS_TX_DATE_SQL} <= :end_date + AND COALESCE(support_dept_code, '') <> '' + AND ( + account_code LIKE '4%' + OR account_code LIKE '5%' + OR account_code LIKE '6%' + ) + """ + ), + {"start_date": start_date.isoformat(), "end_date": end_date.isoformat()}, + ).mappings().all() + for raw_row in direct_rows: + tx_row = dict(raw_row) + code = normalize_text(tx_row.get("support_dept_code")).upper() + if not code: + continue + amount = normalize_amount(tx_row.get("amount")) + if not amount: + continue + row = ensure_row(code) + if not normalize_text(row.get("project_name")) or row.get("project_name") == code: + row["project_name"] = normalize_text(tx_row.get("support_dept_name")) or code + bucket = _cost_analysis_financial_bucket(tx_row.get("account_code")) + if bucket == "revenue": + if code in COST_ANALYSIS_COMMON_CODES: + continue + if code not in initial_basis_codes or code == "ZZZZZZ": + row["revenue_amount"] += amount + row["period_revenue_amount"] += amount + continue + if bucket not in {"cost", "sga"} or _cost_analysis2_is_labor_like(tx_row): + continue + if code in COST_ANALYSIS_COMMON_CODES: + continue + if code in initial_basis_codes and code != "ZZZZZZ": + continue + phase = "pre" if code.startswith("X") else _cost_analysis_phase_for_transaction( + code, + _date_text(tx_row.get("posting_date")), + completion_dates, + project_meta, + ) + item_key = _cost_analysis2_expense_item(tx_row) + if item_key not in row["phases"][phase]: + item_key = "sga" if bucket == "sga" else "overhead" + row["phases"][phase][item_key] += amount + + with engine.begin() as conn: + labor_rows = conn.execute( + text( + f""" + SELECT + CAST(strftime('%Y', {COST_ANALYSIS_TX_DATE_SQL}) AS INTEGER) AS posting_year, + COALESCE(account_code, '') AS account_code, + COALESCE(account_name, '') AS account_name, + COALESCE(accounting_category, '') AS accounting_category, + COALESCE(support_dept_code, '') AS support_dept_code, + COALESCE(support_dept_name, '') AS support_dept_name, + COALESCE(issuing_dept_code, '') AS issuing_dept_code, + COALESCE(issuing_dept_name, '') AS issuing_dept_name, + COALESCE(cost_dept_code, '') AS cost_dept_code, + COALESCE(cost_dept_name, '') AS cost_dept_name, + COALESCE(partner_name, '') AS partner_name, + COALESCE(memo1, '') AS memo1, + COALESCE(memo2, '') AS memo2, + COALESCE(amount, 0) AS amount + FROM transactions + WHERE {COST_ANALYSIS_TX_DATE_SQL} >= :start_date + AND {COST_ANALYSIS_TX_DATE_SQL} <= :end_date + AND (account_code LIKE '5%' OR account_code LIKE '6%') + """ + ), + {"start_date": start_date.isoformat(), "end_date": end_date.isoformat()}, + ).mappings().all() + + labor_pools: dict[int, dict[str, float]] = {} + for raw_row in labor_rows: + tx_row = dict(raw_row) + if not _cost_analysis2_is_labor_like(tx_row): + continue + year = int(tx_row.get("posting_year") or 0) + amount = normalize_amount(tx_row.get("amount")) + if not year or not amount: + continue + bucket_key = "labor" if normalize_text(tx_row.get("account_code")).startswith("5") else "sga_labor" + pool = labor_pools.setdefault(year, {"labor": 0.0, "sga_labor": 0.0}) + pool[bucket_key] += amount + + yearly_labor_diagnostics: list[dict[str, Any]] = [] + for year, code_map in basis["standard"].items(): + standard_weights_by_bucket: dict[str, dict[tuple[str, str], float]] = { + "labor": {}, + "sga_labor": {}, + } + for code, phase_map in code_map.items(): + normalized_code = normalize_text(code).upper() + if normalized_code in COST_ANALYSIS_COMMON_CODES: + continue + item_key = normalize_text(phase_map.get("bucket")) or "sga_labor" + if item_key not in standard_weights_by_bucket: + item_key = "sga_labor" + for phase in ("pre", "during", "post"): + standard_amount = normalize_amount(phase_map.get(phase)) + if standard_amount > 0: + standard_weights_by_bucket[item_key][(normalized_code, phase)] = standard_amount + + year_pool = labor_pools.get(year) or {"labor": 0.0, "sga_labor": 0.0} + year_diagnostic: dict[str, Any] = {"year": year, "unallocated_pool": 0.0} + for item_key in ("labor", "sga_labor"): + standard_weights = standard_weights_by_bucket[item_key] + total_standard_weight = sum(standard_weights.values()) + actual_labor = normalize_amount(year_pool.get(item_key)) + adjustment_pool = actual_labor - total_standard_weight + standard_scale = ( + min(1.0, actual_labor / total_standard_weight) + if total_standard_weight > 0 + else 0.0 + ) + adjustment_pool = max(0.0, adjustment_pool) + adjustment_key = "labor_adjustment" if item_key == "labor" else "sga_labor_adjustment" + for (code, phase), standard_amount in standard_weights.items(): + row = ensure_row(code) + applied_standard_amount = standard_amount * standard_scale + row["phases"][phase][item_key] += applied_standard_amount + if total_standard_weight > 0 and adjustment_pool > 0: + allocated_amount = adjustment_pool * standard_amount / total_standard_weight + row["phases"][phase][adjustment_key] += allocated_amount + row["allocated"][phase][adjustment_key] += allocated_amount + row.setdefault("allocation_details", []).append( + { + "source_row_key": f"ERP:{year}:{item_key}", + "source_project_code": "ZZZZZZ", + "source_project_name": ( + "ERP 공통 원가 인건비성 비용" + if item_key == "labor" + else "ERP 공통 판관비 인건비성 비용" + ), + "source_phase": "all", + "source_item": adjustment_key, + "target_phase": phase, + "target_item": adjustment_key, + "project_hours": 0.0, + "project_phase_hours": 0.0, + "eligible_total_hours": 0.0, + "project_ratio": standard_amount / total_standard_weight, + "phase_ratio": 1.0, + "source_amount": adjustment_pool, + "allocated_amount": allocated_amount, + } + ) + year_diagnostic[f"{item_key}_standard"] = total_standard_weight + year_diagnostic[f"{item_key}_standard_scale"] = standard_scale + year_diagnostic[f"{item_key}_applied_standard"] = total_standard_weight * standard_scale + year_diagnostic[f"{item_key}_actual"] = actual_labor + year_diagnostic[f"{item_key}_adjustment"] = adjustment_pool + year_diagnostic["standard_labor"] = ( + normalize_amount(year_diagnostic.get("labor_standard")) + + normalize_amount(year_diagnostic.get("sga_labor_standard")) + ) + year_diagnostic["actual_labor"] = ( + normalize_amount(year_diagnostic.get("labor_actual")) + + normalize_amount(year_diagnostic.get("sga_labor_actual")) + ) + year_diagnostic["adjustment"] = ( + normalize_amount(year_diagnostic.get("labor_adjustment")) + + normalize_amount(year_diagnostic.get("sga_labor_adjustment")) + ) + yearly_labor_diagnostics.append( + year_diagnostic + ) + + project_phase_hours: dict[str, dict[str, float]] = {} + for code_map in basis["hours"].values(): + for code, phase_map in code_map.items(): + normalized_code = normalize_text(code).upper() + if not normalized_code.startswith(("X", "Y", "Z")) or normalized_code == "ZZZZZZ": + continue + target = project_phase_hours.setdefault( + normalized_code, + {"pre": 0.0, "during": 0.0, "post": 0.0}, + ) + for phase in ("pre", "during", "post"): + target[phase] += normalize_amount(phase_map.get(phase)) + ensure_row(normalized_code) + + def common_allocation_item(source_item: str) -> str: + normalized_item = normalize_text(source_item) + if normalized_item in {"labor", "sga_labor"}: + return "sga_labor" + if normalized_item in {"labor_adjustment", "sga_labor_adjustment"}: + return "sga_labor_adjustment" + if normalized_item == "outsource": + return "outsource" + if normalized_item in {"overhead", "sga", "sales"}: + return "sga" + return "sga" + + common_allocation_total = 0.0 + common_source_total = 0.0 + common_unallocated_total = 0.0 + for source_code, source_row in list(rows_by_code.items()): + if not source_row.get("is_common_activity"): + continue + is_visible_exception = _cost_analysis_is_visible_common_exception(source_row.get("project_name")) + source_row["is_common_exception"] = is_visible_exception + source_row["is_hidden_common_activity"] = not is_visible_exception + source_row["exclude_from_totals"] = not is_visible_exception + source_row["allocation_details"] = [] + if is_visible_exception: + continue + + restrict_to_cm = "감리대기" in normalize_text(source_row.get("project_name")) + eligible_hours: dict[str, dict[str, float]] = {} + for project_code, phase_hours in project_phase_hours.items(): + project_row = rows_by_code.get(project_code) or {} + pm_department = normalize_text( + project_row.get("pm_department") + or (project_meta.get(project_code) or {}).get("pm_department") + ) + if restrict_to_cm and _cost_analysis_normalize_dept_name(pm_department) != _cost_analysis_normalize_dept_name("건설사업관리부"): + continue + project_total_hours = sum(normalize_amount(phase_hours.get(phase)) for phase in ("pre", "during", "post")) + if project_total_hours > 0: + eligible_hours[project_code] = phase_hours + eligible_total_hours = sum( + sum(normalize_amount(phase_hours.get(phase)) for phase in ("pre", "during", "post")) + for phase_hours in eligible_hours.values() + ) + + for source_phase in ("pre", "during", "post"): + for source_item, source_amount_value in source_row["phases"][source_phase].items(): + source_amount = normalize_amount(source_amount_value) + if not source_amount: + continue + common_source_total += source_amount + if eligible_total_hours <= 0: + common_unallocated_total += source_amount + continue + target_item = common_allocation_item(source_item) + for project_code, phase_hours in eligible_hours.items(): + project_total_hours = sum( + normalize_amount(phase_hours.get(phase)) + for phase in ("pre", "during", "post") + ) + if project_total_hours <= 0: + continue + project_allocated_amount = source_amount * project_total_hours / eligible_total_hours + target_row = ensure_row(project_code) + for target_phase in ("pre", "during", "post"): + phase_hours_value = normalize_amount(phase_hours.get(target_phase)) + if phase_hours_value <= 0: + continue + allocated_amount = project_allocated_amount * phase_hours_value / project_total_hours + target_row["phases"][target_phase][target_item] += allocated_amount + target_row["allocated"][target_phase][target_item] += allocated_amount + common_allocation_total += allocated_amount + allocation_detail = { + "source_row_key": source_row.get("row_key") or source_code, + "source_project_code": ", ".join(source_row.get("direct_codes") or []) or "ZZZZZZ", + "source_project_name": source_row.get("project_name") or "공통업무", + "source_phase": source_phase, + "source_item": source_item, + "target_phase": target_phase, + "target_item": target_item, + "project_hours": project_total_hours, + "project_phase_hours": phase_hours_value, + "eligible_total_hours": eligible_total_hours, + "project_ratio": project_total_hours / eligible_total_hours, + "phase_ratio": phase_hours_value / project_total_hours, + "source_amount": source_amount, + "allocated_amount": allocated_amount, + } + target_row.setdefault("allocation_details", []).append(allocation_detail) + source_row["allocation_details"].append( + { + **allocation_detail, + "target_project_code": project_code, + "target_project_name": target_row.get("project_name") or project_code, + } + ) + + representative_row = ensure_row("ZZZZZZ") + representative_row["support_dept_code"] = "ZZZZZZ" + representative_row["project_name"] = "공통" + representative_row["pm_department"] = "공통" + representative_row["project_type"] = "공통" + representative_row["is_common_master"] = True + representative_row["exclude_from_totals"] = False + representative_row["period_revenue_amount"] = common_period_revenue + representative_row["revenue_amount"] = 0.0 + representative_row["billing_amount"] = 0.0 + representative_row["collection_amount"] = common_collection_amount + representative_row["period_billing_amount"] = 0.0 + representative_row["period_negative_billing_amount"] = 0.0 + representative_row["period_collection_amount"] = common_period_collection_amount + representative_row["contract_amount"] = 0.0 + representative_row["contract_balance_amount"] = 0.0 + representative_row["phases"] = _cost_analysis_empty_phase_totals() + representative_row["allocated"] = _cost_analysis_empty_phase_totals() + + total_standard = sum(normalize_amount(item["standard_labor"]) for item in yearly_labor_diagnostics) + actual_labor_total = sum(normalize_amount(item["actual_labor"]) for item in yearly_labor_diagnostics) + adjustment_total = actual_labor_total - total_standard + actual_ratio = actual_labor_total / total_standard if total_standard > 0 else 0.0 + + final_rows = [ + row + for code, row in rows_by_code.items() + if normalize_text(code).upper() + ] + for row in final_rows: + _cost_analysis_finalize_row(row) + row["cumulative_revenue_amount"] = normalize_amount(row.get("revenue_amount")) + row["cumulative_cost_total"] = normalize_amount(row.get("cost_total")) + row["cumulative_sga_total"] = normalize_amount(row.get("sga_total")) + row["cumulative_sales_total"] = normalize_amount(row.get("sales_total")) + row["cumulative_total_cost"] = normalize_amount(row.get("total_cost")) + row["cumulative_profit_amount"] = row["cumulative_revenue_amount"] - row["cumulative_total_cost"] + row["cumulative_profit_rate"] = _safe_ratio(row["cumulative_profit_amount"], row["cumulative_revenue_amount"]) + _cost_analysis_clean_common_master_row(row) + + if normalized_mode == "individual": + final_rows = _cost_analysis_apply_individual_display_codes(final_rows, project_meta) + for row in final_rows: + _cost_analysis_clean_common_master_row(row) + if normalized_mode == "aggregate": + final_rows = _cost_analysis_aggregate_rows( + final_rows, + project_meta, + _cost_analysis_get_link_representative_map(), + ) + for row in final_rows: + _cost_analysis_clean_common_master_row(row) + final_rows.sort( + key=lambda item: ( + 0 if item.get("is_common_master") else 1 if item.get("is_hidden_common_activity") else 2, + normalize_text(item.get("pm_department")), + normalize_text(item.get("project_type")), + normalize_text(item.get("project_name")), + ) + ) + accounting_rows = [row for row in final_rows if not row.get("exclude_from_totals")] + summary = _cost_analysis_summary_for_rows(accounting_rows) + summary["phases"] = { + phase: { + item: sum(normalize_amount((row.get("phases") or {}).get(phase, {}).get(item)) for row in accounting_rows) + for item in ("labor", "labor_adjustment", "outsource", "overhead", "sga_labor", "sga_labor_adjustment", "sga", "sales") + } + for phase in ("pre", "during", "post") + } + displayed_labor_total = sum( + normalize_amount((row.get("phases") or {}).get(phase, {}).get(item)) + for row in accounting_rows + for phase in ("pre", "during", "post") + for item in ("labor", "labor_adjustment", "sga_labor", "sga_labor_adjustment") + ) + displayed_nonlabor_total = summary["total_cost"] - displayed_labor_total + erp_total_expense = normalize_amount((erp_basis.get("allocation_diagnostics") or {}).get("erp_total_expense")) + expense_reconciliation_gap = summary["total_cost"] - erp_total_expense + return { + **erp_basis, + "start_date": start_date.isoformat(), + "end_date": end_date.isoformat(), + "mode": normalized_mode, + "rows": final_rows, + "summary": summary, + "allocation_diagnostics": { + **(erp_basis.get("allocation_diagnostics") or {}), + "standard_labor_total": total_standard, + "actual_labor_total": actual_labor_total, + "labor_adjustment_total": adjustment_total, + "labor_actual_ratio": actual_ratio, + "yearly_labor_allocation": yearly_labor_diagnostics, + "unresolved_hours": basis["unresolved_hours"], + "calculation_cutoff": end_date.isoformat(), + "displayed_labor_total": displayed_labor_total, + "displayed_nonlabor_total": displayed_nonlabor_total, + "displayed_total_expense": summary["total_cost"], + "erp_total_expense": erp_total_expense, + "expense_reconciliation_gap": expense_reconciliation_gap, + "expense_reconciled": abs(expense_reconciliation_gap) < 0.5, + "common_source_total": common_source_total, + "common_allocated_total": common_allocation_total, + "common_unallocated_total": common_unallocated_total, + }, + "cache_info": { + "source": "computed", + "ready": True, + "financial_logic_version": COST_ANALYSIS_FINANCIAL_LOGIC_VERSION, + "h_project_mapping_version": COST_ANALYSIS_H_PROJECT_MAPPING_VERSION, + "link_logic_version": COST_ANALYSIS_LINK_LOGIC_VERSION, + "data_version": context["data_version"], + "hanmac_cache_version": context["hanmac_cache_version"], + "generated_at": datetime.now().isoformat(timespec="seconds"), + }, + } + + +def _cost_analysis_build_payload(start_date_text: str, end_date_text: str, mode: str = "individual", force: bool = False) -> dict[str, Any]: + return _cost_analysis1_build_payload(start_date_text, end_date_text, mode) def render_cost_analysis_page(request: Request, message: str = "") -> HTMLResponse: init_db() - today = date.today() + cutoff = date(2026, 3, 31) context = { **base_context(request, message), - "default_start_date": date(today.year, 1, 1).isoformat(), - "default_end_date": today.isoformat(), + "default_start_date": date(2023, 1, 1).isoformat(), + "default_end_date": cutoff.isoformat(), } - return templates.TemplateResponse(request, "cost_analysis.html", context) + response = templates.TemplateResponse(request, "cost_analysis.html", context) + response.headers["Cache-Control"] = "no-store, max-age=0" + response.headers["Pragma"] = "no-cache" + response.headers["Expires"] = "0" + return response def render_annual_summary_page(request: Request, message: str = "") -> HTMLResponse: @@ -14667,6 +18795,1466 @@ def render_annual_summary_page(request: Request, message: str = "") -> HTMLRespo return templates.TemplateResponse(request, "annual_summary.html", context) +def _financial_gap_statement_amount(row: Mapping[str, Any]) -> float: + debit = normalize_amount(row.get("debit")) + credit = normalize_amount(row.get("credit")) + return max(abs(debit), abs(credit)) + + +def _financial_gap_signed_gap(wehago_amount: Any, erp_amount: Any) -> float: + return normalize_amount(wehago_amount) - normalize_amount(erp_amount) + + +def _financial_gap_bucket_for_account(code: str) -> str: + if code in {"411", "412", "413", "414", "415", "416", "417"}: + return "revenue" + if code == "452": + return "cogs" + if code.startswith("8") or code == "908": + return "sga" + if code.startswith("6"): + return "cost_detail" + if code in {"901", "902", "903", "904", "905", "906", "907", "914", "930"}: + return "nonop_income" + if code in {"931", "932", "933", "934", "935", "936", "937", "960"}: + return "nonop_expense" + if code == "998": + return "tax" + return "" + + +def _financial_gap_signed_statement_delta(code: str, debit: Any, credit: Any) -> float: + debit_amount = normalize_amount(debit) + credit_amount = normalize_amount(credit) + bucket = _financial_gap_bucket_for_account(code) + if bucket in {"revenue", "nonop_income"}: + return credit_amount - debit_amount + if bucket in {"cogs", "sga", "cost_detail", "nonop_expense", "tax"}: + return debit_amount - credit_amount + return 0.0 + + +FINANCIAL_GAP_ERP_COST_LABOR_CODES = {"50120301", "50120501", "50152521"} +FINANCIAL_GAP_ERP_COST_BENEFIT_CODES = { + "50152501", + "50152503", + "50152505", + "50152507", + "50152511", + "50152519", +} +FINANCIAL_GAP_ERP_SGA_LABOR_CODES = {"60110501"} +FINANCIAL_GAP_ERP_RND_DISPLAY_CODES = {"60114701", "60114741"} +FINANCIAL_GAP_WEHAGO_COST_BENEFIT_CODES = {"611"} +FINANCIAL_GAP_WEHAGO_SGA_BENEFIT_CODES = {"811"} +FINANCIAL_GAP_WEHAGO_RND_DISPLAY_CODES = {"650"} + + +def _financial_gap_is_closing_transfer(row: Mapping[str, Any]) -> bool: + code = normalize_text(row.get("account_code")) + text_blob = " ".join( + [ + normalize_text(row.get("description")), + normalize_text(row.get("vendor_name")), + normalize_text(row.get("compare_desc")), + ] + ) + if code == "400": + return True + closing_signals = ( + "손익계정에 대체", + "수익에서 대체", + "비용에서 대체", + "당기순손익", + "잉여금에 대체", + ) + return any(signal in text_blob for signal in closing_signals) + + +def _financial_gap_is_audit_adjustment(row: Mapping[str, Any]) -> bool: + if _financial_gap_is_closing_transfer(row): + return False + code = normalize_text(row.get("account_code")) + text_blob = " ".join( + [ + normalize_text(row.get("description")), + normalize_text(row.get("vendor_name")), + normalize_text(row.get("compare_desc")), + ] + ) + if code == "452": + return False + if code == "417" and "기초미완성공사" in text_blob: + return False + adjustment_signals = ( + "감사", + "결산", + "수정분개", + "수정신고", + "환원분개", + "결산분개", + "진행율 매출액", + "진행률 매출액", + "계상분 대체", + "기초미완성공사", + ) + return any(signal in text_blob for signal in adjustment_signals) + + +def _financial_gap_ratio(gap: Any, base_amount: Any) -> float: + base = abs(normalize_amount(base_amount)) + if base <= 0: + return 0.0 + return (normalize_amount(gap) / base) * 100.0 + + +def _financial_gap_substantive_group( + source: str, + code: str, + name: str, + category: str = "", +) -> tuple[str, str, str, str]: + code_text = normalize_text(code) + name_text = normalize_text(name) + category_text = normalize_text(category) + text_blob = f"{code_text} {name_text} {category_text}" + insurance_labor_tokens = ("국민연금", "건강보험", "고용보험", "산재보험", "장기요양") + payroll_labor_tokens = ("급여", "임금", "상여", "제수당", "퇴직", "퇴직금", "퇴직급여", "잡급", "연차", "연월차", *insurance_labor_tokens) + + if source == "wehago": + revenue_map = { + "411": ("수익", "revenue_design", "설계용역수입", "411 설계용역수입"), + "412": ("수익", "revenue_supervision", "감리용역수입", "412 감리용역수입"), + "415": ("수익", "revenue_safety", "안전점검수입", "415 안전점검수입"), + "413": ("수익", "revenue_rent", "임대·관리수입", "413 임대료수입"), + "414": ("수익", "revenue_parking", "주차수입", "414 주차료수입"), + "417": ("수익", "revenue_research", "연구용역수입", "417 연구용역수입"), + } + if code_text in revenue_map: + return revenue_map[code_text] + if code_text == "452": + return ("영업비용", "expense_cogs_total", "매출원가 총액", "452 도급공사매출원가") + if code_text in FINANCIAL_GAP_WEHAGO_COST_BENEFIT_CODES: + return ("영업비용", "expense_cost_benefit", "원가성 복리후생비", f"{code_text} {name_text}") + if code_text in FINANCIAL_GAP_WEHAGO_SGA_BENEFIT_CODES: + return ("영업비용", "expense_sga_benefit", "판관 복리후생비", f"{code_text} {name_text}") + if code_text in FINANCIAL_GAP_WEHAGO_RND_DISPLAY_CODES: + return ("영업비용", "expense_rnd_display", "연구개발비 별도 표시", f"{code_text} {name_text}") + if code_text in {"604", "606", "609"}: + return ("영업비용", "expense_cost_labor", "원가성 인건비", f"{code_text} {name_text}") + if code_text == "602": + return ("영업비용", "expense_cost_outsourcing", "외주비", f"{code_text} {name_text}") + if code_text in {"631", "639"}: + return ("영업비용", "expense_cost_fee", "원가 지급수수료·보증수수료", f"{code_text} {name_text}") + if code_text in {"645", "644"}: + return ("영업비용", "expense_cost_field_ops", "현장운영·행사비", f"{code_text} {name_text}") + if code_text in {"612"}: + return ("영업비용", "expense_cost_travel", "원가 여비교통비", f"{code_text} {name_text}") + if code_text in {"619"}: + return ("영업비용", "expense_cost_rent", "원가 지급임차료", f"{code_text} {name_text}") + if code_text in {"626", "629", "630"}: + return ("영업비용", "expense_cost_supplies_print", "원가 도서·사무·소모품", f"{code_text} {name_text}") + if code_text in {"617", "618", "622", "614", "625", "634"}: + return ("영업비용", "expense_cost_admin", "원가 기타 운영비", f"{code_text} {name_text}") + if code_text in {"802", "808"}: + return ("영업비용", "expense_sga_labor", "판관 인건비", f"{code_text} {name_text}") + if code_text == "823": + return ("영업비용", "expense_sga_rnd", "판관 연구개발비", f"{code_text} {name_text}") + if code_text in {"831", "837"}: + return ("영업비용", "expense_sga_fee", "판관 지급수수료·건물관리비", f"{code_text} {name_text}") + if code_text.startswith("8") or code_text == "908": + return ("영업비용", "expense_sga_other", "판관 기타비용", f"{code_text} {name_text}") + if code_text.startswith("6"): + return ("영업비용", "expense_cost_other", "원가 기타비용", f"{code_text} {name_text}") + return ("", "", "", "") + + if category_text == "수입/매출액" or code_text.startswith("4"): + if "설계" in text_blob: + return ("수익", "revenue_design", "설계용역수입", f"{code_text} {name_text}") + if "감리" in text_blob: + return ("수익", "revenue_supervision", "감리용역수입", f"{code_text} {name_text}") + if "안전" in text_blob: + return ("수익", "revenue_safety", "안전점검수입", f"{code_text} {name_text}") + if "임대" in text_blob or "관리비" in text_blob: + return ("수익", "revenue_rent", "임대·관리수입", f"{code_text} {name_text}") + if "주차" in text_blob: + return ("수익", "revenue_parking", "주차수입", f"{code_text} {name_text}") + if "연구" in text_blob: + return ("수익", "revenue_research", "연구용역수입", f"{code_text} {name_text}") + return ("수익", "revenue_other", "기타수입", f"{code_text} {name_text}") + + if category_text == "원가" or code_text.startswith("5"): + if code_text in FINANCIAL_GAP_ERP_COST_LABOR_CODES: + return ("영업비용", "expense_cost_labor", "원가성 인건비", f"{code_text} {name_text}") + if code_text in FINANCIAL_GAP_ERP_COST_BENEFIT_CODES: + return ("영업비용", "expense_cost_benefit", "원가성 복리후생비", f"{code_text} {name_text}") + if any(token in text_blob for token in payroll_labor_tokens): + return ("영업비용", "expense_cost_labor", "원가성 인건비", f"{code_text} {name_text}") + if "복리후생" in text_blob: + return ("영업비용", "expense_cost_benefit", "원가성 복리후생비", f"{code_text} {name_text}") + if any(token in text_blob for token in ("기술협력", "외주")): + return ("영업비용", "expense_cost_outsourcing", "외주비", f"{code_text} {name_text}") + if any(token in text_blob for token in ("지급수수료", "보증수수료")): + return ("영업비용", "expense_cost_fee", "원가 지급수수료·보증수수료", f"{code_text} {name_text}") + if any(token in text_blob for token in ("감리현장운영", "합사경비", "부서비")): + return ("영업비용", "expense_cost_field_ops", "현장운영·행사비", f"{code_text} {name_text}") + if "여비교통" in text_blob: + return ("영업비용", "expense_cost_travel", "원가 여비교통비", f"{code_text} {name_text}") + if "지급임차료" in text_blob: + return ("영업비용", "expense_cost_rent", "원가 지급임차료", f"{code_text} {name_text}") + if any(token in text_blob for token in ("도서인쇄", "사무용품", "소모품")): + return ("영업비용", "expense_cost_supplies_print", "원가 도서·사무·소모품", f"{code_text} {name_text}") + if "연구" in text_blob: + return ("영업비용", "expense_cost_rnd", "원가 연구개발비", f"{code_text} {name_text}") + return ("영업비용", "expense_cost_admin", "원가 기타 운영비", f"{code_text} {name_text}") + + if category_text == "판관비" or code_text.startswith("6"): + if code_text in FINANCIAL_GAP_ERP_SGA_LABOR_CODES: + return ("영업비용", "expense_sga_labor", "판관 인건비", f"{code_text} {name_text}") + if code_text in FINANCIAL_GAP_ERP_RND_DISPLAY_CODES: + return ("영업비용", "expense_rnd_display", "연구개발비 별도 표시", f"{code_text} {name_text}") + if any(token in text_blob for token in payroll_labor_tokens): + return ("영업비용", "expense_sga_labor", "판관 인건비", f"{code_text} {name_text}") + if "복리후생" in text_blob: + return ("영업비용", "expense_sga_benefit", "판관 복리후생비", f"{code_text} {name_text}") + if "경상시험연구" in text_blob or "연구" in text_blob: + return ("영업비용", "expense_sga_rnd", "판관 연구개발비", f"{code_text} {name_text}") + if any(token in text_blob for token in ("지급수수료", "건물관리")): + return ("영업비용", "expense_sga_fee", "판관 지급수수료·건물관리비", f"{code_text} {name_text}") + return ("영업비용", "expense_sga_other", "판관 기타비용", f"{code_text} {name_text}") + + return ("", "", "", "") + + +def _financial_gap_item_interpretation( + item_key: str, + wehago_amount: Any, + erp_amount: Any, + adjustment_amount: Any, +) -> str: + gap = _financial_gap_signed_gap(wehago_amount, erp_amount) + residual = gap - normalize_amount(adjustment_amount) + if item_key == "expense_cogs_total": + return "WEHAGO의 452는 손익계산서 매출원가 총액입니다. 6xx 원가 상세와 합산하지 말고 ERP 원가 합계와 방향을 확인합니다." + if item_key.startswith("revenue_"): + if abs(normalize_amount(adjustment_amount)) >= abs(gap) * 0.6: + return "진행률·결산 조정으로 상당 부분 설명되나, 남은 차이는 매출 계정 매핑 또는 ERP 원매출 누락 범위를 확인해야 합니다." + return "조정 전표만으로 설명되지 않는 매출 차이입니다. 같은 용역 성격의 ERP 매출 계정과 WEHAGO 수익계정 매핑을 우선 확인합니다." + if item_key.startswith("expense_cost_"): + return "WEHAGO 원가 상세와 ERP 원가 계정의 성격별 차이입니다. 452 총액과 중복 합산하지 않고 비용 성격·부서·프로젝트 귀속 차이를 봅니다." + if item_key.startswith("expense_sga_"): + return "판관비 성격 비용의 차이입니다. ERP 판관비가 WEHAGO에서 원가성 6xx로 이동했는지, 또는 반대로 남았는지 확인합니다." + if item_key == "expense_rnd_display": + return "연구개발비를 별도 표시한 항목입니다. 원가/판관 재배분 없이 WEHAGO와 ERP의 표시 계정만 나누어 확인합니다." + if abs(residual) > 0: + return "동일 성격 항목으로 맞춘 뒤에도 잔차가 남아 원장 행 단위 확인이 필요합니다." + return "동일 성격 항목 기준으로 큰 잔차는 제한적입니다." + + +def _financial_gap_raw_account_note(item_key: str, default_note: str, erp_amount: Any = 0) -> str: + if item_key == "expense_rnd_display": + amount = normalize_amount(erp_amount) + return f"연구개발비 별도 표시 항목입니다. 연구개발비 인건비성 ERP 금액: {amount:,.0f}원" + if item_key == "expense_cost_benefit": + return "건강보험료는 인건비성 비용으로 유지하고, 그 외 원가 복리후생비만 별도 표시합니다." + if item_key == "expense_sga_benefit": + return "판관 복리후생비를 인건비와 분리해 별도 표시합니다." + return default_note + + +def _financial_gap_classification_review_rows() -> list[dict[str, Any]]: + checks = [ + { + "key": "baron_support", + "label": "바론 경영/기술지원 수수료", + "erp_where": """ + accounting_category = '판관비' + AND ( + partner_name LIKE '%바론%' + OR memo1 LIKE '%경영,기술지원%' + OR memo1 LIKE '%인건비 정산%' + OR memo1 LIKE '%정산분(인건비)%' + ) + """, + "wehago_where": """ + ( + vendor_name LIKE '%바론%' + OR description LIKE '%경영,기술지원%' + OR description LIKE '%인건비 정산%' + OR description LIKE '%정산분(인건비)%' + ) + """, + "finding": "ERP 판관비 지급수수료로 잡힌 금액이 WEHAGO에서는 주로 602 외주비, 즉 원가성 비용으로 보입니다.", + }, + { + "key": "social_insurance", + "label": "4대보험 회사부담금", + "erp_where": """ + accounting_category = '판관비' + AND ( + account_name LIKE '%건강보험%' + OR account_name LIKE '%고용보험%' + OR account_name LIKE '%산재보험%' + OR memo1 LIKE '%건강보험%' + OR memo1 LIKE '%고용보험%' + OR memo1 LIKE '%산재보험%' + ) + """, + "wehago_where": """ + ( + description LIKE '%건강보험%' + OR description LIKE '%고용보험%' + OR description LIKE '%산재보험%' + ) + """, + "finding": "ERP 판관비의 4대보험 회사부담금 일부가 WEHAGO에서는 611 복리후생비 또는 617 세금과공과금 등 원가성 계정으로 보입니다.", + }, + { + "key": "public_property_rent", + "label": "공유재산사용료/임차료", + "erp_where": """ + accounting_category = '판관비' + AND ( + memo1 LIKE '%공유재산%' + OR memo1 LIKE '%시특별%' + OR partner_name LIKE '%서울특별시%' + ) + """, + "wehago_where": """ + ( + description LIKE '%공유재산%' + OR description LIKE '%시특별%' + OR vendor_name LIKE '%서울특별시%' + ) + """, + "finding": "ERP에서는 판관비 월세로 보이나, WEHAGO에서는 619 지급임차료 원가성 계정으로 보이는 금액이 있습니다.", + }, + { + "key": "building_management", + "label": "건물관리용역비", + "erp_where": """ + accounting_category = '판관비' + AND ( + memo1 LIKE '%건물관리%' + OR partner_name LIKE '%두레티엠에스%' + OR partner_name LIKE '%만나%' + ) + """, + "wehago_where": """ + ( + description LIKE '%건물관리%' + OR vendor_name LIKE '%두레티엠에스%' + OR vendor_name LIKE '%만나%' + ) + """, + "finding": "건물관리용역비는 ERP에서는 지급수수료성 판관비, WEHAGO에서는 주로 837 건물관리비 판관비로 보여 구분은 유사하나 계정명이 다릅니다.", + }, + { + "key": "rnd_salary", + "label": "연구원 급여/경상연구개발비", + "erp_where": """ + accounting_category = '판관비' + AND ( + account_name LIKE '%경상시험연구%' + OR memo1 LIKE '%연구원%' + ) + """, + "wehago_where": """ + ( + account_code IN ('823','650') + OR description LIKE '%연구원%' + OR description LIKE '%스마트과제%' + OR description LIKE '%XR과제%' + OR description LIKE '%자율주행%' + ) + """, + "finding": "연구개발성 비용은 ERP와 WEHAGO 모두 별도 연구개발성 항목으로 보이나, 650 원가성 연구개발비와 823 판관 연구개발비 포함 범위를 구분해야 합니다.", + }, + ] + result: list[dict[str, Any]] = [] + with engine.begin() as conn: + for year in (2022, 2023, 2024, 2025): + for check in checks: + erp_row = conn.execute( + text( + f""" + SELECT SUM(amount) AS amount, + COUNT(*) AS row_count, + GROUP_CONCAT(DISTINCT account_name) AS account_names + FROM transactions + WHERE year = :year + AND ({check['erp_where']}) + """ + ), + {"year": year}, + ).mappings().first() + wehago_row = conn.execute( + text( + f""" + SELECT + SUM(CASE + WHEN account_code LIKE '6%' THEN ABS(COALESCE(debit, 0)) + WHEN account_code LIKE '8%' THEN ABS(COALESCE(debit, 0)) + ELSE ABS(COALESCE(debit, 0)) + ABS(COALESCE(credit, 0)) + END) AS amount, + SUM(CASE WHEN account_code LIKE '6%' THEN ABS(COALESCE(debit, 0)) ELSE 0 END) AS cost_amount, + SUM(CASE WHEN account_code LIKE '8%' THEN ABS(COALESCE(debit, 0)) ELSE 0 END) AS sga_amount, + COUNT(*) AS row_count, + GROUP_CONCAT(DISTINCT account_code || ' ' || account_name) AS account_names + FROM wehago_ledger_rows + WHERE fiscal_year = :year + AND ({check['wehago_where']}) + AND (account_code LIKE '6%' OR account_code LIKE '8%') + """ + ), + {"year": year}, + ).mappings().first() + result.append( + { + "year": year, + "key": check["key"], + "label": check["label"], + "finding": check["finding"], + "erp_sga_amount": normalize_amount((erp_row or {}).get("amount")), + "erp_row_count": int(normalize_amount((erp_row or {}).get("row_count"))), + "erp_account_names": normalize_text((erp_row or {}).get("account_names")), + "wehago_amount": normalize_amount((wehago_row or {}).get("amount")), + "wehago_cost_amount": normalize_amount((wehago_row or {}).get("cost_amount")), + "wehago_sga_amount": normalize_amount((wehago_row or {}).get("sga_amount")), + "wehago_row_count": int(normalize_amount((wehago_row or {}).get("row_count"))), + "wehago_account_names": normalize_text((wehago_row or {}).get("account_names")), + } + ) + return result + + +def _financial_gap_get_hanmac_hours_summary(year: int) -> dict[str, Any]: + year_start = date(year, 1, 1).isoformat() + year_end = date(year, 12, 31).isoformat() + with engine.begin() as conn: + metric = conn.execute( + text( + """ + SELECT cache_key, row_count, summary_json, updated_at + FROM hanmac_aggregate_query_metrics + WHERE view_mode = 'member' + AND payload_signature LIKE :compatible_signature_pattern + AND COALESCE(start_date, '') <= :year_start + AND COALESCE(end_date, '') >= :year_end + ORDER BY + CASE WHEN payload_signature LIKE :current_signature_prefix THEN 0 ELSE 1 END, + CASE WHEN start_date = :year_start AND end_date = :year_end THEN 0 ELSE 1 END, + updated_at DESC + LIMIT 1 + """ + ), + { + "year_start": year_start, + "year_end": year_end, + **_cost_analysis_hanmac_signature_params(), + }, + ).mappings().first() + if not metric: + return { + "total_hours": 0.0, + "regular_hours": 0.0, + "overtime_hours": 0.0, + "holiday_hours": 0.0, + "member_count": 0, + "project_count": 0, + "cache_updated_at": "", + } + try: + summary = json.loads(metric.get("summary_json") or "{}") + except Exception: + summary = {} + return { + "total_hours": normalize_amount(summary.get("total_hours")), + "regular_hours": normalize_amount(summary.get("regular_hours")), + "overtime_hours": normalize_amount(summary.get("overtime_hours")), + "holiday_hours": normalize_amount(summary.get("holiday_hours")), + "member_count": int(normalize_amount(summary.get("member_count")) or normalize_amount(metric.get("row_count"))), + "project_count": int(normalize_amount(summary.get("project_count"))), + "cache_updated_at": normalize_text(metric.get("updated_at")), + } + + +def get_financial_gap_analysis_payload() -> dict[str, Any]: + # WEHAGO 원장과 프로젝트 손익분석1을 실제로 대사할 수 있는 공통 연도. + # 2026년 WEHAGO 손익 원장이 아직 없어 0원과 비교하는 오해를 막기 위해 제외한다. + target_years = [2023, 2024, 2025] + project_payloads_by_year: dict[int, dict[str, Any]] = {} + for year in target_years: + year_end = date(2026, 3, 31) if year == 2026 else date(year, 12, 31) + project_payloads_by_year[year] = _cost_analysis1_build_payload( + date(year, 1, 1).isoformat(), + year_end.isoformat(), + "individual", + ) + account_rows_by_year: dict[int, list[dict[str, Any]]] = {year: [] for year in target_years} + ledger_detail_rows_by_year: dict[int, list[dict[str, Any]]] = {year: [] for year in target_years} + erp_by_year: dict[int, dict[str, Any]] = {year: {} for year in target_years} + erp_account_rows_by_year: dict[int, list[dict[str, Any]]] = {year: [] for year in target_years} + classification_review_rows = _financial_gap_classification_review_rows() + + with engine.begin() as conn: + wehago_rows = conn.execute( + text( + """ + SELECT fiscal_year, + COALESCE(account_code, '') AS account_code, + COALESCE(account_name, '') AS account_name, + SUM(COALESCE(debit, 0)) AS debit, + SUM(COALESCE(credit, 0)) AS credit + FROM wehago_ledger_rows + WHERE fiscal_year BETWEEN 2023 AND 2026 + AND (fiscal_year < 2026 OR COALESCE(ledger_date, '') <= '2026-03-31') + GROUP BY fiscal_year, account_code, account_name + ORDER BY fiscal_year, account_code + """ + ) + ).mappings().all() + ledger_detail_rows = conn.execute( + text( + """ + SELECT fiscal_year, + COALESCE(ledger_date, '') AS ledger_date, + COALESCE(account_code, '') AS account_code, + COALESCE(account_name, '') AS account_name, + COALESCE(description, '') AS description, + COALESCE(vendor_name, '') AS vendor_name, + COALESCE(compare_desc, '') AS compare_desc, + COALESCE(voucher_no, '') AS voucher_no, + COALESCE(debit, 0) AS debit, + COALESCE(credit, 0) AS credit + FROM wehago_ledger_rows + WHERE fiscal_year BETWEEN 2023 AND 2026 + AND (fiscal_year < 2026 OR COALESCE(ledger_date, '') <= '2026-03-31') + AND ( + account_code IN ('411','412','413','414','415','416','417','452','908','901','902','903','904','905','906','907','914','930','931','932','933','934','935','936','937','960','998') + OR account_code LIKE '6%' + OR account_code LIKE '8%' + ) + ORDER BY fiscal_year, ledger_date, voucher_no, account_code + """ + ) + ).mappings().all() + erp_rows = conn.execute( + text( + f""" + SELECT year, + SUM(CASE WHEN accounting_category = '수입/매출액' OR account_code LIKE '4%' THEN amount ELSE 0 END) AS revenue, + SUM(CASE WHEN accounting_category = '원가' THEN amount ELSE 0 END) AS cost, + SUM(CASE WHEN accounting_category = '판관비' THEN amount ELSE 0 END) AS sga, + SUM(CASE WHEN account_code LIKE '5012%' THEN amount ELSE 0 END) AS labor_direct, + SUM(CASE WHEN account_code LIKE '5017%' THEN amount ELSE 0 END) AS outsourcing, + SUM(CASE WHEN account_code LIKE '7%' THEN amount ELSE 0 END) AS nonop_income, + SUM(CASE WHEN account_code LIKE '9%' THEN amount ELSE 0 END) AS nonop_expense, + SUM(CASE WHEN accounting_category = '기타' THEN amount ELSE 0 END) AS other_amount + FROM transactions + WHERE {COST_ANALYSIS_TX_DATE_SQL} >= '2023-01-01' + AND {COST_ANALYSIS_TX_DATE_SQL} <= '2026-03-31' + GROUP BY year + ORDER BY year + """ + ) + ).mappings().all() + erp_account_rows = conn.execute( + text( + f""" + SELECT year, + COALESCE(account_code, '') AS account_code, + COALESCE(account_name, '') AS account_name, + COALESCE(accounting_category, '') AS accounting_category, + SUM(COALESCE(amount, 0)) AS amount, + COUNT(*) AS row_count + FROM transactions + WHERE {COST_ANALYSIS_TX_DATE_SQL} >= '2023-01-01' + AND {COST_ANALYSIS_TX_DATE_SQL} <= '2026-03-31' + AND ( + accounting_category IN ('수입/매출액','원가','판관비') + OR account_code LIKE '4%' + OR account_code LIKE '5%' + OR account_code LIKE '6%' + ) + GROUP BY year, account_code, account_name, accounting_category + ORDER BY year, account_code + """ + ) + ).mappings().all() + + for row in wehago_rows: + year = int(row.get("fiscal_year") or 0) + if year in account_rows_by_year: + account_rows_by_year[year].append(dict(row)) + for row in ledger_detail_rows: + year = int(row.get("fiscal_year") or 0) + if year in ledger_detail_rows_by_year: + ledger_detail_rows_by_year[year].append(dict(row)) + for row in erp_rows: + year = int(row.get("year") or 0) + if year in erp_by_year: + erp_by_year[year] = dict(row) + for row in erp_account_rows: + year = int(row.get("year") or 0) + if year in erp_account_rows_by_year: + erp_account_rows_by_year[year].append(dict(row)) + + yearly_rows: list[dict[str, Any]] = [] + account_detail_rows: list[dict[str, Any]] = [] + adjustment_rows: list[dict[str, Any]] = [] + labor_rows: list[dict[str, Any]] = [] + review_items: list[dict[str, Any]] = [] + anomaly_cards: list[dict[str, Any]] = [] + reason_items: list[dict[str, Any]] = [] + substantive_gap_rows: list[dict[str, Any]] = [] + raw_account_comparison_rows: list[dict[str, Any]] = [] + + for year in target_years: + buckets = { + "revenue": 0.0, + "cogs": 0.0, + "sga": 0.0, + "nonop_income": 0.0, + "nonop_expense": 0.0, + "tax": 0.0, + "cost_detail_6xx": 0.0, + "direct_labor": 0.0, + "sga_labor": 0.0, + "rnd_like": 0.0, + "outsourcing_6xx": 0.0, + } + adjustment_buckets = { + "revenue": 0.0, + "cogs": 0.0, + "sga": 0.0, + "cost_detail": 0.0, + "nonop_income": 0.0, + "nonop_expense": 0.0, + "tax": 0.0, + } + substantive_wehago: dict[str, dict[str, Any]] = {} + substantive_erp: dict[str, dict[str, Any]] = {} + substantive_adjustments: dict[str, float] = {} + closing_transfer_count = 0 + audit_adjustment_count = 0 + for ledger_row in ledger_detail_rows_by_year.get(year, []): + code = normalize_text(ledger_row.get("account_code")) + bucket_key = _financial_gap_bucket_for_account(code) + if not bucket_key: + continue + if _financial_gap_is_closing_transfer(ledger_row): + closing_transfer_count += 1 + continue + if not _financial_gap_is_audit_adjustment(ledger_row): + continue + delta = _financial_gap_signed_statement_delta( + code, + ledger_row.get("debit"), + ledger_row.get("credit"), + ) + if abs(delta) <= 0: + continue + audit_adjustment_count += 1 + target_bucket_key = "cogs" if bucket_key == "cost_detail" else bucket_key + adjustment_buckets[target_bucket_key] += delta + section, item_key, item_label, _ = _financial_gap_substantive_group( + "wehago", + code, + ledger_row.get("account_name"), + ) + if item_key and item_key != "expense_cogs_total": + substantive_adjustments[item_key] = substantive_adjustments.get(item_key, 0.0) + delta + adjustment_rows.append( + { + "year": year, + "ledger_date": normalize_text(ledger_row.get("ledger_date")), + "voucher_no": normalize_text(ledger_row.get("voucher_no")), + "bucket": target_bucket_key, + "account_code": code, + "account_name": normalize_text(ledger_row.get("account_name")), + "description": normalize_text(ledger_row.get("description")), + "vendor_name": normalize_text(ledger_row.get("vendor_name")), + "debit": normalize_amount(ledger_row.get("debit")), + "credit": normalize_amount(ledger_row.get("credit")), + "delta": delta, + } + ) + for account in account_rows_by_year.get(year, []): + code = normalize_text(account.get("account_code")) + name = normalize_text(account.get("account_name")) + amount = _financial_gap_statement_amount(account) + if code in {"411", "412", "413", "414", "415", "416", "417"}: + amount = abs(normalize_amount(account.get("credit"))) + if not amount: + continue + if code in {"411", "412", "413", "414", "415", "416", "417"}: + buckets["revenue"] += amount + elif code == "452": + buckets["cogs"] += amount + elif code.startswith("8"): + buckets["sga"] += amount + elif code == "908": + buckets["sga"] -= amount + elif code == "998": + buckets["tax"] += amount + elif code.startswith("9"): + if code in {"901", "902", "903", "904", "905", "906", "907", "914", "930"}: + buckets["nonop_income"] += amount + elif code in {"931", "932", "933", "934", "935", "936", "937", "960"}: + buckets["nonop_expense"] += amount + if code.startswith("6"): + buckets["cost_detail_6xx"] += amount + if code in {"604", "606", "609"}: + buckets["direct_labor"] += amount + if code in {"802", "808"}: + buckets["sga_labor"] += amount + if code in {"650", "823"}: + buckets["rnd_like"] += amount + if code == "602": + buckets["outsourcing_6xx"] += amount + if code in {"411", "412", "413", "414", "415", "416", "417", "452"} or code.startswith(("6", "8")) or code in {"901", "902", "903", "904", "905", "906", "907", "908", "914", "930", "931", "932", "933", "934", "935", "936", "937", "960", "998"}: + account_detail_rows.append( + { + "year": year, + "account_code": code, + "account_name": name, + "wehago_amount": -amount if code == "908" else amount, + "bucket": ( + "매출" if code in {"411", "412", "413", "414", "415", "416", "417"} + else "매출원가 총액" if code == "452" + else "원가 상세" if code.startswith("6") + else "판관비" if code.startswith("8") + else "판관비 조정" if code == "908" + else "법인세" if code == "998" + else "영업외" + ), + } + ) + section, item_key, item_label, source_label = _financial_gap_substantive_group( + "wehago", + code, + name, + ) + if item_key: + group = substantive_wehago.setdefault( + item_key, + { + "section": section, + "item_label": item_label, + "amount": 0.0, + "accounts": [], + "row_count": 0, + }, + ) + group["amount"] += -amount if code == "908" else amount + group["row_count"] += 1 + if source_label and source_label not in group["accounts"]: + group["accounts"].append(source_label) + + for erp_account in erp_account_rows_by_year.get(year, []): + section, item_key, item_label, source_label = _financial_gap_substantive_group( + "erp", + erp_account.get("account_code"), + erp_account.get("account_name"), + erp_account.get("accounting_category"), + ) + if not item_key: + continue + group = substantive_erp.setdefault( + item_key, + { + "section": section, + "item_label": item_label, + "amount": 0.0, + "accounts": [], + "row_count": 0, + }, + ) + group["amount"] += normalize_amount(erp_account.get("amount")) + group["row_count"] += int(normalize_amount(erp_account.get("row_count"))) + if source_label and source_label not in group["accounts"]: + group["accounts"].append(source_label) + + erp = erp_by_year.get(year, {}) + project_payload = project_payloads_by_year.get(year) or {} + project_summary = project_payload.get("summary") or {} + project_diagnostics = project_payload.get("allocation_diagnostics") or {} + # Hanmac ERP 비교값은 프로젝트 손익분석 페이지1과 동일한 최종 + # 분류·배부 결과를 사용한다. 원천 전표 직접 합계는 상세 추적용으로만 남긴다. + erp_revenue = normalize_amount(project_summary.get("period_revenue_amount")) + erp_cost = normalize_amount(project_summary.get("period_cost_total")) + erp_sga = ( + normalize_amount(project_summary.get("period_sga_total")) + + normalize_amount(project_summary.get("period_sales_total")) + ) + adjusted_erp_revenue = erp_revenue + adjustment_buckets["revenue"] + adjusted_erp_cost = erp_cost + adjustment_buckets["cogs"] + adjusted_erp_sga = erp_sga + adjustment_buckets["sga"] + erp_operating_expense = erp_cost + erp_sga + adjusted_erp_operating_expense = adjusted_erp_cost + adjusted_erp_sga + wehago_operating_expense = buckets["cogs"] + buckets["sga"] + cost_gap = _financial_gap_signed_gap(buckets["cogs"], erp_cost) + sga_gap = _financial_gap_signed_gap(buckets["sga"], erp_sga) + total_expense_gap = _financial_gap_signed_gap(wehago_operating_expense, erp_operating_expense) + revenue_gap = _financial_gap_signed_gap(buckets["revenue"], erp_revenue) + adjusted_revenue_gap = _financial_gap_signed_gap(buckets["revenue"], adjusted_erp_revenue) + adjusted_cost_gap = _financial_gap_signed_gap(buckets["cogs"], adjusted_erp_cost) + adjusted_sga_gap = _financial_gap_signed_gap(buckets["sga"], adjusted_erp_sga) + adjusted_total_expense_gap = _financial_gap_signed_gap(wehago_operating_expense, adjusted_erp_operating_expense) + expense_summary_comparisons = ( + ( + "expense_total", + "영업비용 총액", + wehago_operating_expense, + erp_operating_expense, + adjustment_buckets["cogs"] + adjustment_buckets["sga"], + "WEHAGO 매출원가 총액+판관비와 Hanmac ERP 원가+판관비의 회사 전체 비교", + ), + ( + "expense_cogs_total", + "매출원가 총액", + buckets["cogs"], + erp_cost, + adjustment_buckets["cogs"], + "WEHAGO 452 총액과 Hanmac ERP 5xx 원가 합계 비교", + ), + ( + "expense_sga_total", + "판매비와관리비 총액", + buckets["sga"], + erp_sga, + adjustment_buckets["sga"], + "WEHAGO 8xx(908 환입 차감)와 Hanmac ERP 6xx 판관비 합계 비교", + ), + ) + for item_key, item_label, wehago_amount, erp_amount, adjustment_amount, note in expense_summary_comparisons: + pre_adjustment_wehago_amount = wehago_amount - adjustment_amount + raw_account_comparison_rows.append( + { + "year": year, + "section": "영업비용", + "comparison_level": "총액", + "item_key": item_key, + "item_label": item_label, + "wehago_current_amount": wehago_amount, + "audit_adjustment_amount": adjustment_amount, + "wehago_pre_adjustment_amount": pre_adjustment_wehago_amount, + "erp_amount": erp_amount, + "pre_adjustment_gap": pre_adjustment_wehago_amount - erp_amount, + "wehago_accounts": ( + "452 + 8xx - 908" + if item_key == "expense_total" + else "452 도급공사매출원가" + if item_key == "expense_cogs_total" + else "8xx 판관비 - 908 대손충당금환입" + ), + "erp_accounts": ( + "5xx 원가 + 6xx 판관비" + if item_key == "expense_total" + else "5xx 원가" + if item_key == "expense_cogs_total" + else "6xx 판관비" + ), + "note": note, + } + ) + substantive_erp.setdefault( + "expense_cogs_total", + { + "section": "영업비용", + "item_label": "매출원가 총액", + "amount": 0.0, + "accounts": [], + "row_count": 0, + }, + ) + substantive_erp["expense_cogs_total"]["amount"] = erp_cost + substantive_erp["expense_cogs_total"]["accounts"] = ["ERP 원가 계정 합계"] + substantive_erp["expense_cogs_total"]["row_count"] = sum( + int(normalize_amount(row.get("row_count"))) + for row in erp_account_rows_by_year.get(year, []) + if normalize_text(row.get("accounting_category")) == "원가" + ) + substantive_adjustments["expense_cogs_total"] = adjustment_buckets["cogs"] + for item_key in sorted(set(substantive_wehago) | set(substantive_erp)): + wehago_item = substantive_wehago.get(item_key, {}) + erp_item = substantive_erp.get(item_key, {}) + wehago_amount = normalize_amount(wehago_item.get("amount")) + erp_amount = normalize_amount(erp_item.get("amount")) + adjustment_amount = normalize_amount(substantive_adjustments.get(item_key)) + raw_gap = _financial_gap_signed_gap(wehago_amount, erp_amount) + residual_gap = raw_gap - adjustment_amount + if item_key != "expense_cogs_total": + pre_adjustment_wehago_amount = wehago_amount - adjustment_amount + raw_account_comparison_rows.append( + { + "year": year, + "section": normalize_text(wehago_item.get("section") or erp_item.get("section")), + "comparison_level": "유사 계정군", + "item_key": item_key, + "item_label": normalize_text(wehago_item.get("item_label") or erp_item.get("item_label")), + "wehago_current_amount": wehago_amount, + "audit_adjustment_amount": adjustment_amount, + "wehago_pre_adjustment_amount": pre_adjustment_wehago_amount, + "erp_amount": erp_amount, + "pre_adjustment_gap": pre_adjustment_wehago_amount - erp_amount, + "wehago_accounts": ", ".join((wehago_item.get("accounts") or [])[:12]), + "erp_accounts": ", ".join((erp_item.get("accounts") or [])[:12]), + "note": _financial_gap_raw_account_note( + item_key, + "감사·결산·대체 문구로 식별한 조정효과를 WEHAGO 현재액에서 제거한 뒤 ERP와 비교", + erp_amount, + ), + } + ) + if ( + max(abs(wehago_amount), abs(erp_amount), abs(raw_gap), abs(residual_gap)) < 50_000_000 + and item_key != "expense_cogs_total" + ): + continue + section = normalize_text(wehago_item.get("section") or erp_item.get("section")) + item_label = normalize_text(wehago_item.get("item_label") or erp_item.get("item_label")) + substantive_gap_rows.append( + { + "year": year, + "section": section, + "item_key": item_key, + "item_label": item_label, + "wehago_amount": wehago_amount, + "erp_amount": erp_amount, + "gap_amount": raw_gap, + "adjustment_amount": adjustment_amount, + "residual_gap": residual_gap, + "gap_rate": _financial_gap_ratio(raw_gap, max(abs(wehago_amount), abs(erp_amount), 1.0)), + "wehago_accounts": ", ".join((wehago_item.get("accounts") or [])[:8]), + "erp_accounts": ", ".join((erp_item.get("accounts") or [])[:8]), + "wehago_row_count": int(normalize_amount(wehago_item.get("row_count"))), + "erp_row_count": int(normalize_amount(erp_item.get("row_count"))), + "interpretation": _financial_gap_item_interpretation( + item_key, + wehago_amount, + erp_amount, + adjustment_amount, + ), + } + ) + gross_profit = buckets["revenue"] - buckets["cogs"] + operating_profit = gross_profit - buckets["sga"] + erp_operating_profit = erp_revenue - erp_operating_expense + adjusted_erp_operating_profit = adjusted_erp_revenue - adjusted_erp_operating_expense + adjusted_operating_profit_gap = _financial_gap_signed_gap( + operating_profit, + adjusted_erp_operating_profit, + ) + net_profit_proxy = ( + operating_profit + + buckets["nonop_income"] + - buckets["nonop_expense"] + - buckets["tax"] + ) + review_tags: list[str] = [] + if abs(cost_gap) > 0 and abs(sga_gap) > 0 and cost_gap * sga_gap < 0: + paired = min(abs(cost_gap), abs(sga_gap)) / max(abs(cost_gap), abs(sga_gap)) + if paired >= 0.75: + review_tags.append("원가/판관비 재분류") + if buckets["cogs"] > 0 and buckets["cost_detail_6xx"] > 0: + review_tags.append("452 총액/6xx 상세 구분") + if abs(revenue_gap) / max(abs(buckets["revenue"]), 1.0) >= 0.03: + review_tags.append("매출 인식/계정 매핑") + if abs(adjustment_buckets["cogs"]) > 0 and abs(adjustment_buckets["sga"]) > 0: + review_tags.append("6xx/8xx 조정흐름") + if abs(total_expense_gap) / max(abs(wehago_operating_expense), 1.0) < 0.02 and ("원가/판관비 재분류" not in review_tags): + review_tags.append("총비용 유사") + if abs(adjusted_total_expense_gap) < abs(total_expense_gap): + review_tags.append("감사조정 반영시 개선") + elif abs(sum(adjustment_buckets.values())) > 0: + review_tags.append("조정후 잔차 검토") + + def add_reason( + tag: str, + summary: str, + basis_rows: list[dict[str, Any]], + next_checks: list[str], + outline_rows: list[dict[str, str]] | None = None, + ) -> None: + reason_items.append( + { + "year": year, + "tag": tag, + "summary": summary, + "basis_rows": basis_rows, + "next_checks": next_checks, + "outline_rows": outline_rows or [], + } + ) + + if "원가/판관비 재분류" in review_tags: + add_reason( + "원가/판관비 재분류", + "원가 차이와 판관비 차이가 서로 반대 방향으로 발생하고, 두 금액의 크기가 비슷해 총비용 자체가 틀렸다기보다 비용이 원가와 판관비 사이에서 다르게 분류되었을 가능성을 먼저 의심했습니다.", + [ + {"label": "조정 전 원가 차이", "value": cost_gap}, + {"label": "조정 전 판관비 차이", "value": sga_gap}, + {"label": "조정 후 원가 차이", "value": adjusted_cost_gap}, + {"label": "조정 후 판관비 차이", "value": adjusted_sga_gap}, + {"label": "조정 후 원가+판관비 차이", "value": adjusted_total_expense_gap}, + ], + [ + "ERP transactions의 원가/판관비 분류 규칙이 WEHAGO 손익계산서 분류와 같은지 확인", + "지원부서/현업부서 판관비가 원가 또는 판관비 중 어디로 들어가는지 확인", + "원가성 인건비, 외주비, 관리현장운영비가 ERP와 WEHAGO에서 같은 항목으로 분류되는지 확인", + ], + [ + {"title": "무엇을 뜻하나", "body": "WEHAGO에서는 매출원가로 본 비용을 ERP에서는 판관비로 보거나, 반대로 ERP에서는 원가로 본 비용을 WEHAGO에서는 판관비로 본 경우를 말합니다."}, + {"title": "왜 의심하나", "body": "원가 차이는 플러스인데 판관비 차이는 마이너스처럼 서로 반대 방향이고, 원가+판관비 합계 차이는 상대적으로 작으면 총비용 누락보다 분류 위치 차이가 더 그럴듯합니다."}, + {"title": "무엇이 아닌가", "body": "이 판단은 비용을 억지로 맞추자는 뜻도 아니고, 452와 6xx가 하나의 조정 전표라는 뜻도 아닙니다. 비용이 어느 칸에 들어갔는지를 확인하자는 신호입니다."}, + {"title": "예시", "body": "예를 들어 ERP에서 본사/지원부서 비용을 판관비로 집계했는데 감사 후 WEHAGO에서는 프로젝트 수행과 관련된 원가성 비용으로 재분류했다면 원가는 늘고 판관비는 줄어듭니다."}, + {"title": "볼 지점", "body": "6xx 원가 계정, 8xx 판관비 계정, 부서 기준, 프로젝트 코드 유무, 인건비/외주비/관리현장운영비의 분류 기준을 함께 확인해야 합니다."}, + ], + ) + if "452 총액/6xx 상세 구분" in review_tags: + add_reason( + "452 총액/6xx 상세 구분", + "452는 재무제표에 표시되는 매출원가 총액이고, 6xx는 그 안을 구성하는 상세 원가 성격으로 봅니다. 이 둘은 하나의 조정 사건이라기보다 서로 다른 층위의 정보입니다.", + [ + {"label": "452 도급공사매출원가", "value": buckets["cogs"]}, + {"label": "6xx 원가상세 합계", "value": buckets["cost_detail_6xx"]}, + {"label": "452와 6xx의 차이", "value": buckets["cogs"] - buckets["cost_detail_6xx"]}, + {"label": "식별된 마감/대체 전표 수", "value": closing_transfer_count}, + ], + [ + "손익계산서 총액을 볼 때는 452를 사용하고, 원가 구성 내역을 볼 때는 6xx를 사용합니다.", + "452와 6xx를 동시에 더해 원가 총액을 만들고 있지 않은지 확인합니다.", + "6xx와 8xx 사이의 조정 여부는 이 태그가 아니라 '6xx/8xx 조정흐름'에서 확인합니다.", + ], + [ + {"title": "무엇을 뜻하나", "body": "452와 6xx를 같은 표에서 볼 수 있지만, 452는 총액이고 6xx는 상세입니다."}, + {"title": "무엇이 아닌가", "body": "452와 6xx가 서로 조정되었다거나, 6xx와 8xx 조정을 이 태그 하나로 설명한다는 뜻은 아닙니다."}, + {"title": "왜 표시하나", "body": "총액 비교 화면에서 452와 6xx를 함께 더하면 원가가 과대 표시될 수 있어 집계 기준을 환기하기 위한 표시입니다."}, + ], + ) + if "매출 인식/계정 매핑" in review_tags: + add_reason( + "매출 인식/계정 매핑", + "매출 쪽은 진행률 매출액 계상, 환원분개, 수정신고분 때문에 ERP 현재 매출과 WEHAGO 재무제표 매출이 달라질 수 있습니다.", + [ + {"label": "WEHAGO 매출", "value": buckets["revenue"]}, + {"label": "ERP 매출", "value": erp_revenue}, + {"label": "매출 조정효과", "value": adjustment_buckets["revenue"]}, + {"label": "ERP+조정 매출", "value": adjusted_erp_revenue}, + {"label": "조정 후 매출 차이", "value": adjusted_revenue_gap}, + ], + [ + "진행률 매출액, 결산 환원분개, 수정신고분이 ERP 매출 집계에 반영되어 있는지 확인", + "WEHAGO 매출 계정 411~417과 ERP 매출 계정 4xx의 매핑 기준 확인", + "2022년처럼 특정 결산대체 행이 매출 계정에 들어간 경우 별도 제외/분류 기준 확인", + ], + [ + {"title": "무엇을 뜻하나", "body": "매출 차이는 주로 진행률 매출 계상/환원 및 수정분개가 ERP와 WEHAGO에 같은 방식으로 반영되지 않을 때 발생합니다."}, + {"title": "먼저 볼 자료", "body": "411~417 매출 계정, 진행율 매출액, 결산 환원분개, 수정신고분 전표를 봅니다."}, + {"title": "비용 조정과의 관계", "body": "매출 진행률 조정은 6xx/8xx 비용 조정과 별도 흐름으로 보되, 같은 결산 과정에서 함께 발생할 수는 있습니다."}, + ], + ) + if "6xx/8xx 조정흐름" in review_tags: + add_reason( + "6xx/8xx 조정흐름", + "감사/결산 조정 전표 중 원가 상세인 6xx 계정과 판관비인 8xx 계정에 모두 영향이 있어, 비용 조정 흐름을 함께 확인해야 합니다.", + [ + {"label": "6xx 원가 조정효과", "value": adjustment_buckets["cogs"]}, + {"label": "8xx 판관비 조정효과", "value": adjustment_buckets["sga"]}, + {"label": "조정 후 원가 차이", "value": adjusted_cost_gap}, + {"label": "조정 후 판관비 차이", "value": adjusted_sga_gap}, + {"label": "조정 후 영업비용 차이", "value": adjusted_total_expense_gap}, + ], + [ + "6xx 조정 전표와 8xx 조정 전표가 같은 결산 판단에서 나온 것인지 확인", + "원가성 인건비/복리후생비/퇴직급여와 판관 인건비 계정이 각각 어디로 반영됐는지 확인", + "ERP의 원가/판관비 기준이 WEHAGO 감사 후 분류 기준과 달라졌는지 확인", + ], + [ + {"title": "무엇을 뜻하나", "body": "사용자께서 보신 것처럼 6xx와 8xx에 조정이 함께 있을 때 비용 조정을 하나의 흐름으로 보는 탭입니다."}, + {"title": "452와의 차이", "body": "452는 매출원가 총액 표시 계정이고, 여기서는 6xx 원가상세와 8xx 판관비 조정 전표의 방향과 규모를 봅니다."}, + {"title": "판단 포인트", "body": "6xx 조정과 8xx 조정이 서로 상쇄되는지, 아니면 둘 다 같은 방향으로 비용을 바꾸는지 확인합니다."}, + ], + ) + if "감사조정 반영시 개선" in review_tags or "조정후 잔차 검토" in review_tags: + add_reason( + "감사조정 반영시 개선" if "감사조정 반영시 개선" in review_tags else "조정후 잔차 검토", + "감사/결산 조정 전표를 ERP 현재값에 더해 본 뒤, 차이가 줄어드는지와 남는 잔차가 어디인지 비교했습니다.", + [ + {"label": "조정 전 매출 차이", "value": revenue_gap}, + {"label": "조정 후 매출 차이", "value": adjusted_revenue_gap}, + {"label": "조정 전 영업비용 차이", "value": total_expense_gap}, + {"label": "조정 후 영업비용 차이", "value": adjusted_total_expense_gap}, + {"label": "식별된 조정효과 합계", "value": sum(adjustment_buckets.values())}, + ], + [ + "모달의 조정 전표 목록에서 결산/감사/수정 전표가 실제 감사 반영분인지 확인", + "조정 후에도 남는 항목은 ERP 원천전표 누락, 계정 매핑, 시점 차이로 분류", + "452 같은 마감 총액 전표는 중복 방지를 위해 조정효과에서 제외한 기준이 맞는지 확인", + ], + ) + + yearly_rows.append( + { + "year": year, + "wehago_revenue": buckets["revenue"], + "erp_revenue": erp_revenue, + "adjusted_erp_revenue": adjusted_erp_revenue, + "revenue_gap": revenue_gap, + "adjusted_revenue_gap": adjusted_revenue_gap, + "revenue_gap_rate": _financial_gap_ratio(revenue_gap, buckets["revenue"]), + "wehago_cogs": buckets["cogs"], + "erp_cost": erp_cost, + "adjusted_erp_cost": adjusted_erp_cost, + "cost_gap": cost_gap, + "adjusted_cost_gap": adjusted_cost_gap, + "cost_gap_rate": _financial_gap_ratio(cost_gap, buckets["cogs"]), + "wehago_sga": buckets["sga"], + "erp_sga": erp_sga, + "adjusted_erp_sga": adjusted_erp_sga, + "sga_gap": sga_gap, + "adjusted_sga_gap": adjusted_sga_gap, + "sga_gap_rate": _financial_gap_ratio(sga_gap, buckets["sga"]), + "wehago_operating_expense": wehago_operating_expense, + "erp_operating_expense": erp_operating_expense, + "adjusted_erp_operating_expense": adjusted_erp_operating_expense, + "operating_expense_gap": total_expense_gap, + "adjusted_operating_expense_gap": adjusted_total_expense_gap, + "operating_expense_gap_rate": _financial_gap_ratio(total_expense_gap, wehago_operating_expense), + "wehago_gross_profit": gross_profit, + "wehago_operating_profit": operating_profit, + "erp_operating_profit": erp_operating_profit, + "adjusted_erp_operating_profit": adjusted_erp_operating_profit, + "operating_profit_gap": _financial_gap_signed_gap(operating_profit, erp_operating_profit), + "adjusted_operating_profit_gap": adjusted_operating_profit_gap, + "wehago_nonop_income": buckets["nonop_income"], + "wehago_nonop_expense": buckets["nonop_expense"], + "wehago_tax": buckets["tax"], + "wehago_net_profit_proxy": net_profit_proxy, + "cost_detail_6xx": buckets["cost_detail_6xx"], + "adjustments": adjustment_buckets, + "audit_adjustment_count": audit_adjustment_count, + "closing_transfer_count": closing_transfer_count, + "review_tags": review_tags, + } + ) + + hours = _financial_gap_get_hanmac_hours_summary(year) + direct_labor = buckets["direct_labor"] + sga_labor = buckets["sga_labor"] + labor_pool_without_rnd = direct_labor + sga_labor + labor_pool_with_rnd = labor_pool_without_rnd + buckets["rnd_like"] + hanmac_labor_total = normalize_amount(project_diagnostics.get("displayed_labor_total")) + hanmac_wehago_labor_gap = hanmac_labor_total - labor_pool_without_rnd + total_hours = normalize_amount(hours.get("total_hours")) + hourly_without_rnd = labor_pool_without_rnd / total_hours if total_hours > 0 else 0.0 + hourly_with_rnd = labor_pool_with_rnd / total_hours if total_hours > 0 else 0.0 + labor_tags: list[str] = [] + if total_hours <= 0: + labor_tags.append("근무시간 캐시 확인") + if hourly_without_rnd >= 60000: + labor_tags.append("시간/단가 누락 가능") + if buckets["rnd_like"] / max(labor_pool_without_rnd, 1.0) >= 0.05: + labor_tags.append("연구개발 포함여부 검토") + if normalize_amount(erp.get("labor_direct")) > 0 and direct_labor > 0: + direct_gap = _financial_gap_signed_gap(direct_labor, erp.get("labor_direct")) + if abs(direct_gap) / max(direct_labor, 1.0) >= 0.1: + labor_tags.append("ERP 원가인건비 매핑차") + labor_rows.append( + { + "year": year, + "direct_labor": direct_labor, + "sga_labor": sga_labor, + "rnd_like": buckets["rnd_like"], + "labor_pool_without_rnd": labor_pool_without_rnd, + "labor_pool_with_rnd": labor_pool_with_rnd, + "wehago_labor_total": labor_pool_without_rnd, + "hanmac_labor_total": hanmac_labor_total, + "hanmac_wehago_labor_gap": hanmac_wehago_labor_gap, + "erp_labor_direct": normalize_amount(erp.get("labor_direct")), + "erp_outsourcing": normalize_amount(erp.get("outsourcing")), + "wehago_outsourcing": buckets["outsourcing_6xx"], + **hours, + "implied_hourly_without_rnd": hourly_without_rnd, + "implied_hourly_with_rnd": hourly_with_rnd, + "tags": labor_tags, + } + ) + + for tag in review_tags: + review_items.append( + { + "year": year, + "area": "손익", + "tag": tag, + "basis": f"원가차이 {cost_gap:,.0f}, 판관비차이 {sga_gap:,.0f}, 총비용차이 {total_expense_gap:,.0f}", + } + ) + for tag in labor_tags: + review_items.append( + { + "year": year, + "area": "인건비", + "tag": tag, + "basis": f"총근무 {total_hours:,.1f}h, 역산단가 {hourly_without_rnd:,.0f}원/h", + } + ) + severity = "ok" + headline = "큰 이상 없음" + primary_gap = abs(adjusted_total_expense_gap) + if abs(adjusted_revenue_gap) / max(abs(buckets["revenue"]), 1.0) >= 0.1: + severity = "danger" + headline = "매출 차이 큼" + primary_gap = abs(adjusted_revenue_gap) + elif abs(adjusted_total_expense_gap) / max(abs(wehago_operating_expense), 1.0) >= 0.05: + severity = "danger" + headline = "영업비용 차이 큼" + elif abs(adjusted_cost_gap) > 0 and abs(adjusted_sga_gap) > 0 and adjusted_cost_gap * adjusted_sga_gap < 0: + severity = "warning" + headline = "원가/판관비 재분류 의심" + primary_gap = min(abs(adjusted_cost_gap), abs(adjusted_sga_gap)) + elif any(tag in review_tags for tag in ("452 총액/6xx 상세 구분", "조정후 잔차 검토", "6xx/8xx 조정흐름")): + severity = "warning" + headline = "집계 기준 확인" + anomaly_cards.append( + { + "year": year, + "severity": severity, + "headline": headline, + "primary_gap": primary_gap, + "revenue_gap": adjusted_revenue_gap, + "cost_gap": adjusted_cost_gap, + "sga_gap": adjusted_sga_gap, + "operating_expense_gap": adjusted_total_expense_gap, + "audit_adjustment_total": sum(adjustment_buckets.values()), + "audit_adjustment_count": audit_adjustment_count, + "tags": review_tags[:4], + } + ) + + totals = { + "wehago_revenue": sum(row["wehago_revenue"] for row in yearly_rows), + "erp_revenue": sum(row["erp_revenue"] for row in yearly_rows), + "adjusted_erp_revenue": sum(row["adjusted_erp_revenue"] for row in yearly_rows), + "wehago_operating_expense": sum(row["wehago_operating_expense"] for row in yearly_rows), + "erp_operating_expense": sum(row["erp_operating_expense"] for row in yearly_rows), + "adjusted_erp_operating_expense": sum(row["adjusted_erp_operating_expense"] for row in yearly_rows), + "labor_pool_without_rnd": sum(row["labor_pool_without_rnd"] for row in labor_rows), + "hanmac_labor_total": sum(row["hanmac_labor_total"] for row in labor_rows), + "hanmac_wehago_labor_gap": sum(row["hanmac_wehago_labor_gap"] for row in labor_rows), + "total_hours": sum(row["total_hours"] for row in labor_rows), + "audit_adjustment_total": sum(sum(row["adjustments"].values()) for row in yearly_rows), + "audit_adjustment_count": sum(row["audit_adjustment_count"] for row in yearly_rows), + } + totals["revenue_gap"] = _financial_gap_signed_gap(totals["wehago_revenue"], totals["erp_revenue"]) + totals["adjusted_revenue_gap"] = _financial_gap_signed_gap(totals["wehago_revenue"], totals["adjusted_erp_revenue"]) + totals["operating_expense_gap"] = _financial_gap_signed_gap( + totals["wehago_operating_expense"], + totals["erp_operating_expense"], + ) + totals["adjusted_operating_expense_gap"] = _financial_gap_signed_gap( + totals["wehago_operating_expense"], + totals["adjusted_erp_operating_expense"], + ) + totals["implied_hourly_without_rnd"] = ( + totals["labor_pool_without_rnd"] / totals["total_hours"] + if totals["total_hours"] > 0 + else 0.0 + ) + sorted_substantive_gap_rows = sorted( + substantive_gap_rows, + key=lambda item: ( + item["year"], + 0 if item["section"] == "수익" else 1, + -abs(normalize_amount(item["residual_gap"])), + -abs(normalize_amount(item["gap_amount"])), + ), + ) + substantive_gap_preview_rows: list[dict[str, Any]] = [] + for preview_year in sorted(target_years, reverse=True): + year_rows = [ + row + for row in substantive_gap_rows + if int(row.get("year") or 0) == preview_year + ] + for preview_section, section_limit in (("수익", 3), ("영업비용", 4)): + section_rows = [ + row + for row in year_rows + if row.get("section") == preview_section + ] + section_rows.sort( + key=lambda item: ( + -abs(normalize_amount(item["residual_gap"])), + -abs(normalize_amount(item["gap_amount"])), + ) + ) + substantive_gap_preview_rows.extend(section_rows[:section_limit]) + + project_profit_bridge_rows: list[dict[str, Any]] = [] + for bridge_year in target_years: + latest_financial = next((row for row in yearly_rows if row.get("year") == bridge_year), {}) + try: + project_payload = project_payloads_by_year.get(bridge_year) or {} + project_summary = project_payload.get("summary") or {} + diagnostics = project_payload.get("allocation_diagnostics") or {} + project_cache_info = project_payload.get("cache_info") or {} + project_rows = project_payload.get("rows") or [] + common_rows = [ + row + for row in project_rows + if normalize_text(row.get("support_dept_code")).upper() == "ZZZZZZ" + ] + project_revenue = normalize_amount(project_summary.get("period_revenue_amount")) + project_expense = normalize_amount(project_summary.get("period_total_cost")) + project_profit = normalize_amount(project_summary.get("period_profit_amount")) + common_revenue = sum(normalize_amount(row.get("period_revenue_amount")) for row in common_rows) + wehago_revenue = normalize_amount(latest_financial.get("wehago_revenue")) + wehago_expense = normalize_amount(latest_financial.get("wehago_operating_expense")) + wehago_profit = normalize_amount(latest_financial.get("wehago_operating_profit")) + erp_revenue = normalize_amount(latest_financial.get("erp_revenue")) + adjusted_erp_revenue = normalize_amount(latest_financial.get("adjusted_erp_revenue")) + revenue_adjustment_total = normalize_amount((latest_financial.get("adjustments") or {}).get("revenue")) + latest_revenue_adjustments = [ + row + for row in adjustment_rows + if row.get("year") == bridge_year and row.get("bucket") == "revenue" + ] + revenue_adjustment_increase = sum( + max(0.0, normalize_amount(row.get("delta"))) + for row in latest_revenue_adjustments + ) + revenue_adjustment_decrease = sum( + min(0.0, normalize_amount(row.get("delta"))) + for row in latest_revenue_adjustments + ) + hanmac_labor_total = normalize_amount(diagnostics.get("displayed_labor_total")) + common_cost_allocated = 0.0 + common_sga_allocated = sum(normalize_amount(row.get("total_cost")) for row in common_rows) + project_profit_bridge_rows.append( + { + "year": bridge_year, + "project_revenue": project_revenue, + "project_only_revenue": project_revenue - common_revenue, + "common_revenue": common_revenue, + "project_billing": normalize_amount(project_summary.get("period_billing_amount")), + "project_negative_billing": normalize_amount(project_summary.get("period_negative_billing_amount")), + "project_collection": normalize_amount(project_summary.get("period_collection_amount")), + "project_revenue_billing_gap": normalize_amount(project_summary.get("period_revenue_billing_gap")), + "project_revenue_collection_gap": normalize_amount(project_summary.get("period_revenue_collection_gap")), + "common_revenue_row_count": len(common_rows), + "wehago_revenue": wehago_revenue, + "erp_revenue": erp_revenue, + "adjusted_erp_revenue": adjusted_erp_revenue, + "revenue_adjustment_total": revenue_adjustment_total, + "revenue_adjustment_increase": revenue_adjustment_increase, + "revenue_adjustment_decrease": revenue_adjustment_decrease, + "revenue_adjustment_count": len(latest_revenue_adjustments), + "project_revenue_gap": project_revenue - wehago_revenue, + "wehago_to_erp_revenue_gap": wehago_revenue - erp_revenue, + "erp_to_project_revenue_gap": erp_revenue - project_revenue, + "adjusted_erp_to_project_revenue_gap": adjusted_erp_revenue - project_revenue, + "project_unassigned_erp_revenue": erp_revenue - project_revenue, + "project_expense": project_expense, + "erp_total_expense": normalize_amount(diagnostics.get("erp_total_expense")), + "expense_reconciliation_gap": normalize_amount(diagnostics.get("expense_reconciliation_gap")), + "expense_reconciled": bool(diagnostics.get("expense_reconciled")), + "project_profit_financial_logic_version": normalize_text(project_cache_info.get("financial_logic_version")), + "project_profit_h_mapping_version": normalize_text(project_cache_info.get("h_project_mapping_version")), + "project_profit_generated_at": normalize_text(project_cache_info.get("generated_at") or project_cache_info.get("updated_at")), + "wehago_expense": wehago_expense, + "project_expense_gap": project_expense - wehago_expense, + "project_profit": project_profit, + "wehago_profit": wehago_profit, + "project_profit_gap": project_profit - wehago_profit, + "hanmac_labor_total": hanmac_labor_total, + "common_labor_excluded": 0.0, + "hanmac_missing_regular_sga_allocated": 0.0, + "common_cost_allocated": common_cost_allocated, + "common_sga_allocated": common_sga_allocated, + "common_source_total": normalize_amount(diagnostics.get("common_source_total")), + "direct_project_nonlabor": ( + project_expense + - hanmac_labor_total + - common_cost_allocated + - common_sga_allocated + ), + "profit_bridge_validation_gap": ( + (project_revenue - wehago_revenue) + - (project_expense - wehago_expense) + - (project_profit - wehago_profit) + ), + "revenue_bridge_validation_gap": ( + (wehago_revenue - erp_revenue) + + (erp_revenue - project_revenue) + - (wehago_revenue - project_revenue) + ), + "adjusted_revenue_bridge_validation_gap": ( + erp_revenue + + revenue_adjustment_total + - adjusted_erp_revenue + ), + "finding": ( + "프로젝트손익 수익은 프로젝트 귀속 ERP 매출과 공통매출 행을 함께 표시합니다. " + "청구·수금 금액은 참고값이며 매출-청구 차이를 별도 확인합니다." + ), + } + ) + except Exception as exc: + logger.warning("프로젝트손익/WEHAGO 손익 브릿지 생성 실패(%s): %s", bridge_year, exc) + + return { + "years": target_years, + "yearly_rows": yearly_rows, + "labor_rows": labor_rows, + "account_detail_rows": account_detail_rows, + "substantive_gap_rows": sorted_substantive_gap_rows, + "substantive_gap_preview_rows": substantive_gap_preview_rows, + "raw_account_comparison_rows": sorted( + raw_account_comparison_rows, + key=lambda item: ( + item["year"], + 0 if item["section"] == "수익" else 1, + 0 if item["comparison_level"] == "총액" else 1, + -abs(normalize_amount(item["pre_adjustment_gap"])), + item["item_label"], + ), + ), + "adjustment_rows": sorted(adjustment_rows, key=lambda item: (item["year"], item["ledger_date"], item["voucher_no"], item["account_code"])), + "classification_review_rows": classification_review_rows, + "review_items": review_items, + "anomaly_cards": anomaly_cards, + "reason_items": reason_items, + "project_profit_bridge_rows": project_profit_bridge_rows, + "totals": totals, + "assumptions": [ + "WEHAGO 손익계산서 금액은 계정별 원장의 차변/대변 합계 중 큰 금액을 표시 금액으로 사용했습니다.", + "452 도급공사매출원가는 재무제표 매출원가 총액으로 보고, 6xx 계정은 매출원가 상세 구성으로만 표시했습니다.", + "감사/결산 조정효과는 결산, 감사, 수정, 환원, 진행율 매출액, 계상분 대체 문맥의 원장 행만 별도 집계했습니다.", + "손익계정 대체, 수익/비용에서 대체, 당기순손익 대체 같은 마감 전표는 최종 손익 금액 중복을 막기 위해 조정효과에서 제외했습니다.", + "Hanmac ERP 비교 수익·원가·판관비·손익은 프로젝트 손익분석 페이지1과 동일한 연도별 최종 payload를 사용합니다.", + "프로젝트 인건비는 한맥 근무시간에 연도·사업분류·직급별 시급과 연장·휴일 가산율을 적용한 뒤 ERP 실제 인건비성 비용과의 차액을 비례 배분한 값입니다.", + "인건비 차이는 프로젝트 손익분석 페이지1의 최종 인건비와 WEHAGO 원가성·판관 인건비의 차이입니다.", + "프로젝트손익은 프로젝트 코드로 연결된 ERP 4xx 매출을 수익으로 사용하므로, 회사 전체 WEHAGO 매출과 범위가 다를 수 있습니다.", + "ERP+조정매출은 ERP 원매출에 WEHAGO 원장의 적요에서 감사·결산·진행률 매출 조정으로 식별한 전표 효과를 더한 분석용 비교값이며, ERP에 저장된 별도 확정 매출값이 아닙니다.", + "식별 조정 전표는 적요 문구 기반이므로 전표 상세에서 실제 결산 조정 여부를 확인해야 하며, 프로젝트손익에는 프로젝트별 귀속 근거가 없는 조정액을 자동 배부하지 않습니다.", + "ZZZZZZ 공통 프로젝트의 수익·비용·인건비는 프로젝트 손익분석 페이지1의 공통 행에 보존합니다.", + "퇴직급여, 복리후생비, 상여/연차/4대보험 성격 비용은 인건비 풀에 포함될 수 있다는 전제로 검토합니다.", + ], + } + + +def render_annual_gap_analysis_page(request: Request, message: str = "") -> HTMLResponse: + init_db() + context = { + **base_context(request, message), + "gap_analysis": get_financial_gap_analysis_payload(), + } + return templates.TemplateResponse(request, "annual_gap_analysis.html", context) + + def get_annual_summary_bootstrap_payload() -> dict[str, Any]: cached = _get_deepcopy_ttl_cache_entry( _ANNUAL_SUMMARY_BOOTSTRAP_CACHE, @@ -14716,7 +20304,10 @@ def render_wehago_compare_page( warm_caches=False, ), } - return templates.TemplateResponse(request, "wehago_compare.html", context) + response = templates.TemplateResponse(request, "wehago_compare.html", context) + response.headers["Cache-Control"] = "no-store, max-age=0" + response.headers["Pragma"] = "no-cache" + return response def render_wehago_benefit_entertainment_page( @@ -14761,6 +20352,7 @@ def render_hanmac_browser_page( ) -> HTMLResponse: context = { **base_context(request, message), + "hanmac_wehago_audit_sources": build_hanmac_wehago_audit_sources(), } return templates.TemplateResponse(request, "hanmac_browser.html", context) @@ -14822,6 +20414,2901 @@ def test_hanmac_mysql_connection(payload: dict[str, Any]) -> dict[str, Any]: test_engine.dispose() +HANMAC_MANAGEMENT_ERP_BASE_URL = "http://erp.hanmaceng.co.kr/planning_mng/" +HANMAC_MANAGEMENT_ERP_LOGIN_URL = f"{HANMAC_MANAGEMENT_ERP_BASE_URL}LoginCheck.php" +HANMAC_MANAGEMENT_ERP_MAIN_URL = f"{HANMAC_MANAGEMENT_ERP_BASE_URL}sys/controller/main_controller.php" +HANMAC_SATIS_ERP_BASE_URL = "http://erp.hanmaceng.co.kr/satis/" +HANMAC_SATIS_ERP_LOGIN_CONTROLLER_URL = f"{HANMAC_SATIS_ERP_BASE_URL}sys/controller/Login/Login_Controller.php" +HANMAC_SATIS_ERP_LOGIN_PAGE_URL = ( + f"{HANMAC_SATIS_ERP_BASE_URL}sys/controller/Login/Login_controller.php?ActionMode=GoLogin" +) +HANMAC_ERP_DIRECT_DB_HOST = "erp.hanmaceng.co.kr" +HANMAC_ERP_DIRECT_DB_PORT = 3306 +HANMAC_SATIS_BUDGET_DISCOVERY_KEYWORDS = ( + "satis", + "project", + "proj", + "budget", + "exec", + "plan", + "task", + "cost", + "amount", + "approval", + "approve", + "revision", + "rev", + "round", + "degree", + "change", + "dept", + "work", + "account", + "acct", +) +HANMAC_SATIS_NUMERIC_TYPES = {"bigint", "decimal", "double", "float", "int", "integer", "mediumint", "numeric", "real", "smallint", "tinyint"} +HANMAC_SATIS_AMOUNT_COLUMN_TOKENS = ("amount", "amt", "budget", "cost", "price", "sum", "total", "money", "supply") +HANMAC_SATIS_PROJECT_CODE_TOKENS = ("project", "proj", "pjt", "pj", "prj") +HANMAC_SATIS_REVISION_TOKENS = ("revision", "rev", "round", "degree", "change", "seq", "cha", "turn") +HANMAC_SATIS_STATUS_TOKENS = ("approval", "approve", "status", "state", "confirm", "app") +HANMAC_SATIS_PROJECT_CODE_REGISTER_FILENAME = "차수사업코드등록_260622.xls" +HANMAC_SATIS_LINKED_MAIN_PROJECT_OVERRIDES = { + "X24016": "024240", + "X24020": "024241", + "X25011": "025227", +} +HANMAC_SATIS_COMMON_PROJECT_CODES = {"ZZZZZZ", "X24003"} +HANMAC_SATIS_EXCEPTION_PROJECT_CODES = {"006061"} + + +class _SatisProjectCodeRegisterHtmlParser(HTMLParser): + def __init__(self) -> None: + super().__init__() + self._in_cell = False + self._cell_parts: list[str] = [] + self._row: list[str] = [] + self.rows: list[list[str]] = [] + + def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None: + if tag == "tr": + self._row = [] + if tag in {"td", "th"}: + self._in_cell = True + self._cell_parts = [] + + def handle_data(self, data: str) -> None: + if self._in_cell: + self._cell_parts.append(data) + + def handle_endtag(self, tag: str) -> None: + if tag in {"td", "th"} and self._in_cell: + self._row.append(" ".join("".join(self._cell_parts).split())) + self._in_cell = False + self._cell_parts = [] + elif tag == "tr" and self._row: + self.rows.append(self._row) + + +def _parse_satis_project_code_register(path: Path) -> list[dict[str, str]]: + if not path.exists(): + return [] + body = path.read_text(encoding="utf-8", errors="replace") + parser = _SatisProjectCodeRegisterHtmlParser() + parser.feed(body) + header: list[str] = [] + data_rows: list[list[str]] = [] + for index, row in enumerate(parser.rows): + if row and row[0] == "차수사업코드": + header = row + data_rows = parser.rows[index + 1 :] + break + if not header: + return [] + records: list[dict[str, str]] = [] + for row in data_rows: + if not row or row[0].startswith("총 "): + continue + if len(row) < len(header): + row = [*row, *([""] * (len(header) - len(row)))] + records.append({column: normalize_text(row[position]) for position, column in enumerate(header)}) + return records + + +def _default_satis_master_code_for_local_project(local_project_code: str, project_kind: str) -> str: + local_project_code = normalize_text(local_project_code) + project_kind = normalize_text(project_kind) + if re.fullmatch(r"X\d{5}", local_project_code): + return f"9{local_project_code[1:]}" + if re.fullmatch(r"[YZ]\d{5}", local_project_code): + return f"0{local_project_code[1:]}" + if re.fullmatch(r"[09]\d{5}", local_project_code): + return local_project_code + if local_project_code == "ZZZZZZ": + return "" + return "" + + +def sync_satis_project_code_register(conn: Any | None = None) -> dict[str, Any]: + """Import the ERP round-project code register into the local mapping table.""" + + register_path = BASE_DIR / HANMAC_SATIS_PROJECT_CODE_REGISTER_FILENAME + records = _parse_satis_project_code_register(register_path) + if not records: + return { + "status": "skipped", + "message": f"{HANMAC_SATIS_PROJECT_CODE_REGISTER_FILENAME} 파일을 찾지 못했거나 읽을 수 없습니다.", + "source_file": str(register_path), + "row_count": 0, + } + + owns_connection = conn is None + context = engine.begin() if owns_connection else nullcontext(conn) + inserted_or_updated = 0 + kind_counts: dict[str, int] = {} + status_counts: dict[str, int] = {} + with context as active_conn: + for record in records: + local_code = normalize_text(record.get("차수사업코드")) + if not local_code: + continue + project_kind = normalize_text(record.get("사업종류")) + own_master_code = normalize_text(record.get("총괄사업코드")) or _default_satis_master_code_for_local_project(local_code, project_kind) + linked_main_code = own_master_code + mapping_status = "confirmed" + if local_code in HANMAC_SATIS_LINKED_MAIN_PROJECT_OVERRIDES: + linked_main_code = HANMAC_SATIS_LINKED_MAIN_PROJECT_OVERRIDES[local_code] + elif project_kind == "사전": + linked_main_code = "" + if local_code in HANMAC_SATIS_COMMON_PROJECT_CODES: + mapping_status = "common" + linked_main_code = "" + if local_code in HANMAC_SATIS_EXCEPTION_PROJECT_CODES or own_master_code in HANMAC_SATIS_EXCEPTION_PROJECT_CODES: + mapping_status = "exception" + is_active = 1 if normalize_text(record.get("사용여부")) != "N" else 0 + kind_counts[project_kind or "미지정"] = kind_counts.get(project_kind or "미지정", 0) + 1 + status_counts[mapping_status] = status_counts.get(mapping_status, 0) + 1 + active_conn.execute( + text( + """ + INSERT INTO satis_project_code_links ( + local_project_code, local_project_name, project_kind, + own_master_project_code, own_master_project_name, + linked_main_project_code, linked_main_project_name, + cost_project_code, cost_project_name, cost_kind, + pm_department_name, is_joint_project, is_tax_exempt, + is_active, mapping_status, mapping_source, source_file, + raw_payload_json, updated_at + ) VALUES ( + :local_project_code, :local_project_name, :project_kind, + :own_master_project_code, :own_master_project_name, + :linked_main_project_code, :linked_main_project_name, + :cost_project_code, :cost_project_name, :cost_kind, + :pm_department_name, :is_joint_project, :is_tax_exempt, + :is_active, :mapping_status, 'project_code_register', + :source_file, :raw_payload_json, CURRENT_TIMESTAMP + ) + ON CONFLICT(local_project_code) DO UPDATE SET + local_project_name = excluded.local_project_name, + project_kind = excluded.project_kind, + own_master_project_code = excluded.own_master_project_code, + own_master_project_name = excluded.own_master_project_name, + linked_main_project_code = excluded.linked_main_project_code, + linked_main_project_name = excluded.linked_main_project_name, + cost_project_code = excluded.cost_project_code, + cost_project_name = excluded.cost_project_name, + cost_kind = excluded.cost_kind, + pm_department_name = excluded.pm_department_name, + is_joint_project = excluded.is_joint_project, + is_tax_exempt = excluded.is_tax_exempt, + is_active = excluded.is_active, + mapping_status = excluded.mapping_status, + mapping_source = excluded.mapping_source, + source_file = excluded.source_file, + raw_payload_json = excluded.raw_payload_json, + updated_at = CURRENT_TIMESTAMP + """ + ), + { + "local_project_code": local_code, + "local_project_name": normalize_text(record.get("차수사업명칭")), + "project_kind": project_kind, + "own_master_project_code": own_master_code, + "own_master_project_name": normalize_text(record.get("총괄사업명칭")), + "linked_main_project_code": linked_main_code, + "linked_main_project_name": "", + "cost_project_code": normalize_text(record.get("원가코드")), + "cost_project_name": normalize_text(record.get("원가사업명칭")), + "cost_kind": normalize_text(record.get("원가종류")), + "pm_department_name": normalize_text(record.get("PM부서명칭")), + "is_joint_project": normalize_text(record.get("공동공사여부")), + "is_tax_exempt": normalize_text(record.get("면세여부")), + "is_active": is_active, + "mapping_status": mapping_status, + "source_file": register_path.name, + "raw_payload_json": json.dumps(record, ensure_ascii=False, sort_keys=True), + }, + ) + inserted_or_updated += 1 + return { + "status": "ok", + "message": f"Satis 차수사업코드 원장 {inserted_or_updated:,}건을 동기화했습니다.", + "source_file": str(register_path), + "row_count": inserted_or_updated, + "kind_counts": kind_counts, + "status_counts": status_counts, + } + + +def _hanmac_mysql_identifier(value: Any) -> str: + normalized = normalize_text(value) + if not re.fullmatch(r"[A-Za-z0-9_]+", normalized): + raise ValueError(f"MySQL 식별자 형식이 올바르지 않습니다: {normalized}") + return f"`{normalized}`" + + +def _lower_identifier(value: Any) -> str: + return normalize_text(value).lower().replace("-", "_") + + +def _pick_satis_column(columns: list[dict[str, Any]], token_groups: Sequence[Sequence[str]]) -> str: + scored: list[tuple[int, str]] = [] + for column in columns: + name = normalize_text(column.get("name")) + lowered = _lower_identifier(name) + score = 0 + for group_index, tokens in enumerate(token_groups): + if any(token in lowered for token in tokens): + score += max(1, 10 - group_index) + if score > 0: + scored.append((score, name)) + scored.sort(key=lambda item: (-item[0], len(item[1]), item[1])) + return scored[0][1] if scored else "" + + +def _infer_satis_budget_type(database: str, table: str, columns: list[dict[str, Any]]) -> str: + haystack = " ".join([database, table, *[normalize_text(column.get("name")) for column in columns]]).lower() + if any(token in haystack for token in ("exec", "execution", "실행")): + return "exec_budget" + if any(token in haystack for token in ("task", "plan", "과업", "수행")): + return "task_plan" + if any(token in haystack for token in ("overview", "summary", "개요")): + return "project_overview" + return "project_budget" + + +def _infer_satis_amount_columns(columns: list[dict[str, Any]]) -> list[str]: + scored: list[tuple[int, int, str]] = [] + for position, column in enumerate(columns): + name = normalize_text(column.get("name")) + lowered = _lower_identifier(name) + data_type = _lower_identifier(column.get("data_type")) + score = 0 + if data_type in HANMAC_SATIS_NUMERIC_TYPES: + score += 4 + if any(token in lowered for token in HANMAC_SATIS_AMOUNT_COLUMN_TOKENS): + score += 8 + if any(token in lowered for token in ("date", "year", "no", "code", "cd", "id", "seq", "rate", "percent")): + score -= 4 + if score >= 6: + scored.append((score, position, name)) + scored.sort(key=lambda item: (-item[0], item[1], item[2])) + return [name for _score, _position, name in scored[:12]] + + +def _safe_float(value: Any) -> float: + if value in (None, ""): + return 0.0 + try: + return float(str(value).replace(",", "")) + except Exception: + return 0.0 + + +def _json_default(value: Any) -> Any: + if isinstance(value, (date, datetime)): + return value.isoformat() + if isinstance(value, Decimal): + return float(value) + return str(value) + + +def _hanmac_erp_web_credentials(payload: dict[str, Any]) -> tuple[str, str]: + user = normalize_text(payload.get("erp_user")) + password = str(payload.get("erp_password") or "") + if not user: + raise ValueError("Satis ERP 아이디를 입력해주세요.") + if not password: + raise ValueError("Satis ERP 비밀번호를 입력해주세요.") + return user, password + + +def _hanmac_erp_mysql_credentials(payload: dict[str, Any]) -> tuple[str, str, bool]: + db_user = normalize_text(payload.get("erp_db_user")) + db_password = str(payload.get("erp_db_password") or "") + if db_user or db_password: + if not db_user: + raise ValueError("관리DB MySQL 아이디를 입력해주세요.") + if not db_password: + raise ValueError("관리DB MySQL 비밀번호를 입력해주세요.") + return db_user, db_password, True + user, password = _hanmac_erp_web_credentials(payload) + return user, password, False + + +def _build_hanmac_mysql_access_denied_message(user: str, explicit_db_credentials: bool) -> str: + if explicit_db_credentials: + return f"관리DB MySQL 계정 '{user}'로 직접 DB 접속이 거부되었습니다. MySQL 권한 또는 비밀번호를 확인해주세요." + return ( + f"ERP 웹 계정 '{user}'로 MySQL 직접 DB 접속이 거부되었습니다. " + "G26001 같은 관리 ERP 웹 로그인 계정과 MySQL DB 계정은 별도일 수 있습니다. " + "예산 원본 DB를 직접 조회하려면 관리DB MySQL 아이디/비밀번호 또는 읽기전용 View/API 정보가 필요합니다." + ) + + +def _sync_satis_budget_raw_rows(payload: dict[str, Any]) -> dict[str, Any]: + _hanmac_erp_web_credentials(payload) + db_user, db_password, explicit_db_credentials = _hanmac_erp_mysql_credentials(payload) + + try: + max_tables = max(1, min(int(payload.get("max_tables") or 12), 40)) + except Exception: + max_tables = 12 + try: + row_limit = max(10, min(int(payload.get("row_limit") or 500), 5000)) + except Exception: + row_limit = 500 + + try: + discovery = _discover_hanmac_erp_budget_tables(db_user, db_password) + except OperationalError as exc: + if "access denied" in str(exc).lower(): + raise ValueError(_build_hanmac_mysql_access_denied_message(db_user, explicit_db_credentials)) from exc + raise + candidates = list(discovery.get("candidate_tables") or [])[:max_tables] + if not candidates: + return { + "status": "ok", + "message": "직접 DB 접속은 됐지만 예산 후보 테이블을 찾지 못했습니다.", + "inserted_or_updated_rows": 0, + "table_results": [], + **discovery, + } + + remote_engine = _build_hanmac_erp_direct_mysql_engine(db_user, db_password) + table_results: list[dict[str, Any]] = [] + inserted_or_updated = 0 + skipped_tables = 0 + try: + with remote_engine.connect() as remote_conn, engine.begin() as local_conn: + for candidate in candidates: + database = normalize_text(candidate.get("database")) + table = normalize_text(candidate.get("table")) + if not database or not table: + continue + column_rows = remote_conn.execute( + text( + """ + SELECT COLUMN_NAME, DATA_TYPE, ORDINAL_POSITION + FROM information_schema.columns + WHERE table_schema = :database + AND table_name = :table + ORDER BY ORDINAL_POSITION + """ + ), + {"database": database, "table": table}, + ).mappings().fetchall() + columns = [ + { + "name": normalize_text(row.get("COLUMN_NAME")), + "data_type": normalize_text(row.get("DATA_TYPE")), + "position": int(row.get("ORDINAL_POSITION") or 0), + } + for row in column_rows + if normalize_text(row.get("COLUMN_NAME")) + ] + amount_columns = _infer_satis_amount_columns(columns) + if not amount_columns: + skipped_tables += 1 + table_results.append( + { + "database": database, + "table": table, + "status": "skipped", + "reason": "금액성 컬럼을 추정하지 못했습니다.", + "row_count": 0, + } + ) + continue + + project_code_column = _pick_satis_column(columns, (HANMAC_SATIS_PROJECT_CODE_TOKENS, ("code", "cd", "no"))) + project_name_column = _pick_satis_column(columns, (HANMAC_SATIS_PROJECT_CODE_TOKENS, ("name", "nm", "title"))) + revision_column = _pick_satis_column(columns, (HANMAC_SATIS_REVISION_TOKENS,)) + approval_status_column = _pick_satis_column(columns, (HANMAC_SATIS_STATUS_TOKENS,)) + budget_type = _infer_satis_budget_type(database, table, columns) + + selected_columns: list[str] = [] + for name in [ + project_code_column, + project_name_column, + revision_column, + approval_status_column, + *amount_columns, + *[normalize_text(column.get("name")) for column in columns[:30]], + ]: + if name and name not in selected_columns: + selected_columns.append(name) + selected_columns = selected_columns[:80] + quoted_selected = ", ".join(_hanmac_mysql_identifier(name) for name in selected_columns) + quoted_amounts = [_hanmac_mysql_identifier(name) for name in amount_columns] + where_clause = " OR ".join(f"COALESCE({quoted}, 0) <> 0" for quoted in quoted_amounts) + source_sql = text( + f""" + SELECT {quoted_selected} + FROM {_hanmac_mysql_identifier(database)}.{_hanmac_mysql_identifier(table)} + WHERE {where_clause} + LIMIT {int(row_limit)} + """ + ) + rows = remote_conn.execute(source_sql).mappings().fetchall() + table_inserted = 0 + inferred_columns = { + "project_code": project_code_column, + "project_name": project_name_column, + "revision_no": revision_column, + "approval_status": approval_status_column, + "amount_columns": amount_columns, + } + for row_index, row in enumerate(rows): + row_dict = {key: row.get(key) for key in selected_columns} + amount_values = { + column_name: _safe_float(row_dict.get(column_name)) + for column_name in amount_columns + } + amount_total = sum(amount_values.values()) + if amount_total == 0: + continue + raw_payload_json = json.dumps(row_dict, ensure_ascii=False, sort_keys=True, default=_json_default) + source_hash = hashlib.sha256( + json.dumps( + { + "database": database, + "table": table, + "row": row_dict, + }, + ensure_ascii=False, + sort_keys=True, + default=_json_default, + ).encode("utf-8") + ).hexdigest() + local_conn.execute( + text( + """ + INSERT OR REPLACE INTO satis_project_budget_raw_rows ( + source_system, source_database, source_table, source_row_index, + budget_type, project_code, project_name, revision_no, approval_status, + amount_total, amount_values_json, inferred_columns_json, + raw_payload_json, source_hash, synced_at + ) VALUES ( + 'satis', :source_database, :source_table, :source_row_index, + :budget_type, :project_code, :project_name, :revision_no, :approval_status, + :amount_total, :amount_values_json, :inferred_columns_json, + :raw_payload_json, :source_hash, CURRENT_TIMESTAMP + ) + """ + ), + { + "source_database": database, + "source_table": table, + "source_row_index": row_index, + "budget_type": budget_type, + "project_code": normalize_text(row_dict.get(project_code_column)), + "project_name": normalize_text(row_dict.get(project_name_column)), + "revision_no": normalize_text(row_dict.get(revision_column)), + "approval_status": normalize_text(row_dict.get(approval_status_column)), + "amount_total": amount_total, + "amount_values_json": json.dumps(amount_values, ensure_ascii=False, sort_keys=True), + "inferred_columns_json": json.dumps(inferred_columns, ensure_ascii=False, sort_keys=True), + "raw_payload_json": raw_payload_json, + "source_hash": source_hash, + }, + ) + table_inserted += 1 + inserted_or_updated += table_inserted + table_results.append( + { + "database": database, + "table": table, + "status": "synced", + "budget_type": budget_type, + "row_count": table_inserted, + "sampled_row_limit": row_limit, + "inferred_columns": inferred_columns, + } + ) + finally: + remote_engine.dispose() + + return { + "status": "ok", + "message": f"Satis 후보 테이블 {len(candidates)}개에서 실제 금액 행 {inserted_or_updated:,}건을 원본 저장소에 반영했습니다.", + "inserted_or_updated_rows": inserted_or_updated, + "skipped_tables": skipped_tables, + "table_results": table_results, + "raw_table": "satis_project_budget_raw_rows", + "candidate_table_count": discovery.get("candidate_table_count", 0), + "direct_db_database_count": discovery.get("direct_db_database_count", 0), + } + + +def _parse_json_dict(value: Any) -> dict[str, Any]: + if isinstance(value, dict): + return value + try: + parsed = json.loads(str(value or "{}")) + return parsed if isinstance(parsed, dict) else {} + except Exception: + return {} + + +def _satis_raw_project_code(row: Mapping[str, Any]) -> str: + project_code = normalize_text(row.get("project_code")) + if project_code: + return project_code + payload = _parse_json_dict(row.get("raw_payload_json")) + inferred = _parse_json_dict(row.get("inferred_columns_json")) + inferred_project_column = normalize_text(inferred.get("project_code")) + if inferred_project_column: + return normalize_text(payload.get(inferred_project_column)) + for key, value in payload.items(): + lowered = _lower_identifier(key) + if any(token in lowered for token in HANMAC_SATIS_PROJECT_CODE_TOKENS) and any(token in lowered for token in ("code", "cd", "no")): + candidate = normalize_text(value) + if candidate: + return candidate + return "UNKNOWN" + + +def _satis_raw_project_name(row: Mapping[str, Any]) -> str: + project_name = normalize_text(row.get("project_name")) + if project_name: + return project_name + payload = _parse_json_dict(row.get("raw_payload_json")) + inferred = _parse_json_dict(row.get("inferred_columns_json")) + inferred_project_name_column = normalize_text(inferred.get("project_name")) + if inferred_project_name_column: + return normalize_text(payload.get(inferred_project_name_column)) + for key, value in payload.items(): + lowered = _lower_identifier(key) + if any(token in lowered for token in HANMAC_SATIS_PROJECT_CODE_TOKENS) and any(token in lowered for token in ("name", "nm", "title")): + candidate = normalize_text(value) + if candidate: + return candidate + return "" + + +def _lookup_support_dept_code_for_satis_project(conn: Any, project_code: str, project_name: str) -> str: + project_code = normalize_text(project_code) + project_name = normalize_text(project_name) + if not project_code and not project_name: + return "" + row = None + if project_code: + row = conn.execute( + text( + """ + SELECT support_dept_code + FROM project_status + WHERE support_dept_code = :project_code + LIMIT 1 + """ + ), + {"project_code": project_code}, + ).mappings().first() + if project_code: + link_row = conn.execute( + text( + """ + SELECT l.local_project_code AS support_dept_code + FROM satis_project_code_links l + INNER JOIN project_status p + ON p.support_dept_code = l.local_project_code + WHERE l.mapping_status IN ('confirmed', 'exception') + AND l.local_project_code <> 'ZZZZZZ' + AND ( + l.local_project_code = :project_code + OR l.own_master_project_code = :project_code + OR l.linked_main_project_code = :project_code + OR l.cost_project_code = :project_code + ) + ORDER BY + CASE WHEN l.local_project_code = :project_code THEN 0 ELSE 1 END, + CASE WHEN l.own_master_project_code = :project_code THEN 0 ELSE 1 END, + CASE WHEN l.linked_main_project_code = :project_code THEN 0 ELSE 1 END, + CASE WHEN l.is_active = 1 THEN 0 ELSE 1 END, + l.local_project_code + LIMIT 1 + """ + ), + {"project_code": project_code}, + ).mappings().first() + if link_row: + row = link_row + if not row and project_code: + row = conn.execute( + text( + """ + SELECT support_dept_code + FROM satis_project_mapping + WHERE erp_project_code = :project_code + AND mapping_status = 'matched' + LIMIT 1 + """ + ), + {"project_code": project_code}, + ).mappings().first() + if not row and project_name: + row = conn.execute( + text( + """ + SELECT support_dept_code + FROM project_status + WHERE support_dept_name = :project_name + LIMIT 1 + """ + ), + {"project_name": project_name}, + ).mappings().first() + return normalize_text(row.get("support_dept_code")) if row else "" + + +def _normalize_satis_budget_raw_rows(_payload: dict[str, Any] | None = None) -> dict[str, Any]: + init_db() + normalized_revisions = 0 + task_lines = 0 + exec_lines = 0 + skipped_rows = 0 + source_table_counts: dict[str, int] = {} + with engine.begin() as conn: + conn.execute(text("DELETE FROM satis_project_budget_projection_status")) + conn.execute(text("DELETE FROM satis_project_task_plan_budget_lines")) + conn.execute(text("DELETE FROM satis_project_exec_budget_lines")) + conn.execute(text("DELETE FROM satis_project_budget_revisions")) + raw_rows = conn.execute( + text( + """ + SELECT * + FROM satis_project_budget_raw_rows + WHERE source_database = 'satis_web' + AND inferred_columns_json LIKE '%GET_DETAIL%' + AND source_table IN ( + 'SCREEN_03:Ajax_02', + 'SCREEN_06:Ajax_00', + 'SCREEN_02:Ajax_01', + 'SCREEN_02:Ajax_03', + 'SCREEN_02:Ajax_04' + ) + ORDER BY source_database, source_table, project_code, revision_no, id + """ + ) + ).mappings().fetchall() + deduplicated_raw_rows: list[Mapping[str, Any]] = [] + seen_raw_signatures: set[tuple[str, str, str, str]] = set() + for candidate_row in raw_rows: + signature = ( + normalize_text(candidate_row.get("source_table")), + _satis_raw_project_code(candidate_row), + normalize_text(candidate_row.get("revision_no")), + normalize_text(candidate_row.get("raw_payload_json")), + ) + if signature in seen_raw_signatures: + continue + seen_raw_signatures.add(signature) + deduplicated_raw_rows.append(candidate_row) + raw_rows = deduplicated_raw_rows + revision_metadata: dict[tuple[str, str], dict[str, str]] = {} + for metadata_row in raw_rows: + if normalize_text(metadata_row.get("source_table")) != "SCREEN_03:Ajax_02": + continue + metadata_project_code = _satis_raw_project_code(metadata_row) + metadata_revision_no = normalize_text(metadata_row.get("revision_no")) or "00" + revision_metadata[(metadata_project_code, metadata_revision_no)] = { + "approval_status": normalize_text(metadata_row.get("approval_status")), + "project_name": _satis_raw_project_name(metadata_row), + } + for raw_row in raw_rows: + raw_id = int(raw_row.get("id") or 0) + source_database = normalize_text(raw_row.get("source_database")) + source_table = normalize_text(raw_row.get("source_table")) + budget_type = normalize_text(raw_row.get("budget_type")) or "project_budget" + project_code = _satis_raw_project_code(raw_row) + project_name = _satis_raw_project_name(raw_row) + revision_no = normalize_text(raw_row.get("revision_no")) or "0" + approval_status = normalize_text(raw_row.get("approval_status")) + metadata = revision_metadata.get((project_code, revision_no), {}) + if metadata: + approval_status = normalize_text(metadata.get("approval_status")) or approval_status + project_name = normalize_text(metadata.get("project_name")) or project_name + raw_payload_json = normalize_text(raw_row.get("raw_payload_json")) + raw_payload = _parse_json_dict(raw_payload_json) + amount_values = _parse_json_dict(raw_row.get("amount_values_json")) + inferred_columns = _parse_json_dict(raw_row.get("inferred_columns_json")) + if not amount_values: + skipped_rows += 1 + continue + + support_dept_code = _lookup_support_dept_code_for_satis_project(conn, project_code, project_name) + source_key = f"{source_database}:{project_code}:{budget_type}:{revision_no}" + source_hash = hashlib.sha256( + json.dumps( + { + "source_key": source_key, + "raw_hash": normalize_text(raw_row.get("source_hash")), + "amount_values": amount_values, + }, + ensure_ascii=False, + sort_keys=True, + default=_json_default, + ).encode("utf-8") + ).hexdigest() + approval_status_lower = re.sub(r"<[^>]+>", "", approval_status).strip().lower() + is_approved = int( + any( + token in approval_status_lower + for token in ("승인완료", "결재완료", "확정:y", "approved", "completed") + ) + or approval_status_lower in ("70", "y", "1") + ) + conn.execute( + text( + """ + INSERT INTO satis_project_budget_revisions ( + source_system, project_code, support_dept_code, project_name, budget_type, + revision_no, revision_name, approval_status, is_approved, is_latest, + source_key, source_hash, raw_payload_json, synced_at + ) VALUES ( + 'satis', :project_code, :support_dept_code, :project_name, :budget_type, + :revision_no, :revision_name, :approval_status, :is_approved, 0, + :source_key, :source_hash, :raw_payload_json, CURRENT_TIMESTAMP + ) + ON CONFLICT(source_system, budget_type, source_key) DO UPDATE SET + support_dept_code = excluded.support_dept_code, + project_name = excluded.project_name, + revision_no = excluded.revision_no, + revision_name = excluded.revision_name, + approval_status = excluded.approval_status, + is_approved = excluded.is_approved, + source_hash = excluded.source_hash, + raw_payload_json = excluded.raw_payload_json, + synced_at = CURRENT_TIMESTAMP + """ + ), + { + "project_code": project_code, + "support_dept_code": support_dept_code, + "project_name": project_name, + "budget_type": budget_type, + "revision_no": revision_no, + "revision_name": f"{source_database}.{source_table} #{raw_id}", + "approval_status": approval_status, + "is_approved": is_approved, + "source_key": source_key, + "source_hash": source_hash, + "raw_payload_json": raw_payload_json, + }, + ) + revision_row = conn.execute( + text( + """ + SELECT id + FROM satis_project_budget_revisions + WHERE source_system = 'satis' + AND budget_type = :budget_type + AND source_key = :source_key + LIMIT 1 + """ + ), + {"budget_type": budget_type, "source_key": source_key}, + ).mappings().first() + if not revision_row: + skipped_rows += 1 + continue + revision_id = int(revision_row.get("id") or 0) + for line_no, (amount_column, amount_value) in enumerate(amount_values.items()): + amount = _safe_float(amount_value) + if amount == 0: + continue + source_line_key = f"{raw_id}:{amount_column}" + group_name = source_table + dept_name = "" + work_name = amount_column + grade = "" + hours = "" + account_code = amount_column + account_name = amount_column + if source_table == "SCREEN_06:Ajax_00": + dept_name = normalize_text(raw_payload.get("item09") or raw_payload.get("item08")) + work_name = normalize_text(raw_payload.get("item06")) or amount_column + if amount_column == "item40": + group_name = "outsource" + work_name = f"{work_name} 외주예상" + elif normalize_text(raw_payload.get("item14")) == "Y": + group_name = "outsource" + else: + group_name = "department" + elif source_table == "SCREEN_02:Ajax_01": + group_name = "labor" + grade = normalize_text(raw_payload.get("item07")) + account_code = "SATIS_LABOR" + account_name = grade or "인건비" + elif source_table == "SCREEN_02:Ajax_03": + group_name = "outsource" + work_name = normalize_text(raw_payload.get("item06")) + account_code = normalize_text(raw_payload.get("item05")) + account_name = work_name or "외주비" + elif source_table == "SCREEN_02:Ajax_04": + group_name = "cost_plan" + account_code = normalize_text(raw_payload.get("item06")) + account_name = normalize_text(raw_payload.get("item07")) or "제경비" + line_payload = { + "source_database": source_database, + "source_table": source_table, + "raw_row_id": raw_id, + "amount_column": amount_column, + "inferred_columns": inferred_columns, + "raw": raw_payload, + } + if budget_type == "exec_budget": + conn.execute( + text( + """ + INSERT INTO satis_project_exec_budget_lines ( + revision_id, line_no, group_name, grade, hours, + dept_name, work_name, account_code, account_name, + amount, source_line_key, raw_payload_json, synced_at + ) VALUES ( + :revision_id, :line_no, :group_name, :grade, :hours, + :dept_name, :work_name, :account_code, :account_name, + :amount, :source_line_key, :raw_payload_json, CURRENT_TIMESTAMP + ) + """ + ), + { + "revision_id": revision_id, + "line_no": line_no, + "group_name": group_name, + "grade": grade, + "hours": hours, + "dept_name": dept_name, + "work_name": work_name, + "account_code": account_code, + "account_name": account_name, + "amount": amount, + "source_line_key": source_line_key, + "raw_payload_json": json.dumps(line_payload, ensure_ascii=False, sort_keys=True, default=_json_default), + }, + ) + exec_lines += 1 + else: + conn.execute( + text( + """ + INSERT INTO satis_project_task_plan_budget_lines ( + revision_id, line_no, group_name, dept_name, work_name, + amount, source_line_key, raw_payload_json, synced_at + ) VALUES ( + :revision_id, :line_no, :group_name, :dept_name, :work_name, + :amount, :source_line_key, :raw_payload_json, CURRENT_TIMESTAMP + ) + """ + ), + { + "revision_id": revision_id, + "line_no": line_no, + "group_name": group_name, + "dept_name": dept_name, + "work_name": work_name, + "amount": amount, + "source_line_key": source_line_key, + "raw_payload_json": json.dumps(line_payload, ensure_ascii=False, sort_keys=True, default=_json_default), + }, + ) + task_lines += 1 + normalized_revisions += 1 + source_table_key = f"{source_database}.{source_table}" + source_table_counts[source_table_key] = source_table_counts.get(source_table_key, 0) + 1 + + summary_rows = conn.execute( + text( + """ + SELECT + r.budget_type, + COUNT(DISTINCT r.id) AS revision_count, + COALESCE(SUM(t.amount), 0) AS task_amount, + COALESCE(SUM(e.amount), 0) AS exec_amount + FROM satis_project_budget_revisions r + LEFT JOIN satis_project_task_plan_budget_lines t ON t.revision_id = r.id + LEFT JOIN satis_project_exec_budget_lines e ON e.revision_id = r.id + GROUP BY r.budget_type + ORDER BY r.budget_type + """ + ) + ).mappings().fetchall() + return { + "status": "ok", + "message": f"Satis 원본 금액 {normalized_revisions:,}건을 차수/상세 예산 테이블로 정규화했습니다.", + "raw_row_count": len(raw_rows), + "normalized_revisions": normalized_revisions, + "task_lines": task_lines, + "exec_lines": exec_lines, + "skipped_rows": skipped_rows, + "source_tables": [ + {"table": key, "row_count": count} + for key, count in sorted(source_table_counts.items(), key=lambda item: (-item[1], item[0]))[:20] + ], + "summary": [ + { + "budget_type": normalize_text(row.get("budget_type")), + "revision_count": int(row.get("revision_count") or 0), + "task_amount": float(row.get("task_amount") or 0), + "exec_amount": float(row.get("exec_amount") or 0), + } + for row in summary_rows + ], + } + + +def _is_satis_pending_approval_status(value: Any) -> bool: + status = normalize_text(value).lower() + if not status: + return False + pending_tokens = ("승인중", "승인 중", "결재중", "결재 중", "상신", "진행", "검토", "요청", "대기", "pending", "progress", "review") + rejected_tokens = ("반려", "취소", "삭제", "reject", "cancel", "deleted") + return any(token in status for token in pending_tokens) and not any(token in status for token in rejected_tokens) + + +def _satis_revision_sort_key(row: Mapping[str, Any]) -> tuple[float, int]: + revision_no_text = normalize_text(row.get("revision_no")) + numbers = re.findall(r"-?\d+(?:\.\d+)?", revision_no_text) + revision_value = float(numbers[-1]) if numbers else 0.0 + return (revision_value, int(row.get("id") or 0)) + + +def _select_satis_projection_revisions(conn: Any, include_pending: bool) -> list[dict[str, Any]]: + rows = [ + dict(row) + for row in conn.execute( + text( + """ + SELECT * + FROM satis_project_budget_revisions + WHERE support_dept_code <> '' + AND budget_type IN ('task_plan', 'exec_budget') + ORDER BY support_dept_code, budget_type, id + """ + ) + ).mappings().fetchall() + ] + grouped: dict[tuple[str, str], list[dict[str, Any]]] = {} + for row in rows: + budget_type = normalize_text(row.get("budget_type")) + target_type = "exec_budget" if budget_type == "exec_budget" else "task_plan" + key = (normalize_text(row.get("support_dept_code")), target_type) + grouped.setdefault(key, []).append(row) + + selected: list[dict[str, Any]] = [] + for (_support_dept_code, target_type), candidates in grouped.items(): + approved = [row for row in candidates if int(row.get("is_approved") or 0) == 1] + pending = [row for row in candidates if _is_satis_pending_approval_status(row.get("approval_status"))] + base = max(approved, key=_satis_revision_sort_key) if approved else None + pending_latest = max(pending, key=_satis_revision_sort_key) if pending else None + chosen = base + projection_mode = "approved_latest" + is_provisional = 0 + note = "" + if include_pending and pending_latest and ( + not base or _satis_revision_sort_key(pending_latest) >= _satis_revision_sort_key(base) + ): + chosen = pending_latest + projection_mode = "pending_provisional" + is_provisional = 1 + note = "승인 중인 최신 차수를 가반영했습니다. 승인 완료 후 재반영이 필요합니다." + elif not chosen and pending_latest and include_pending: + chosen = pending_latest + projection_mode = "pending_provisional" + is_provisional = 1 + note = "승인 완료 차수가 없어 승인 중인 차수를 가반영했습니다." + if not chosen: + continue + chosen["target_budget_type"] = target_type + chosen["projection_mode"] = projection_mode + chosen["is_provisional"] = is_provisional + chosen["projection_note"] = note + selected.append(chosen) + return selected + + +def _aggregate_satis_budget_entries_to_master_projects(conn: Any) -> dict[str, Any]: + master_rows = [ + dict(row) + for row in conn.execute( + text( + """ + SELECT DISTINCT + COALESCE(NULLIF(linked_main_project_code, ''), own_master_project_code) AS master_code + FROM satis_project_code_links + WHERE mapping_status IN ('confirmed', 'exception') + AND COALESCE(NULLIF(linked_main_project_code, ''), own_master_project_code) <> '' + AND COALESCE(NULLIF(linked_main_project_code, ''), own_master_project_code) GLOB '[09][0-9][0-9][0-9][0-9][0-9]' + UNION + SELECT support_dept_code AS master_code + FROM project_status + WHERE support_dept_code GLOB '[09][0-9][0-9][0-9][0-9][0-9]' + ORDER BY master_code + """ + ) + ).mappings().fetchall() + ] + aggregated_project_count = 0 + task_rows_inserted = 0 + exec_rows_inserted = 0 + status_rows: list[dict[str, Any]] = [] + + for master_row in master_rows: + master_code = normalize_text(master_row.get("master_code")) + if not master_code: + continue + source_codes = [ + normalize_text(row.get("local_project_code")) + for row in conn.execute( + text( + """ + SELECT local_project_code + FROM satis_project_code_links + WHERE mapping_status IN ('confirmed', 'exception') + AND local_project_code <> 'ZZZZZZ' + AND ( + own_master_project_code = :master_code + OR linked_main_project_code = :master_code + OR cost_project_code = :master_code + ) + ORDER BY + CASE WHEN local_project_code = :master_code THEN 0 ELSE 1 END, + local_project_code + """ + ), + {"master_code": master_code}, + ).mappings().fetchall() + if normalize_text(row.get("local_project_code")) + ] + if master_code not in source_codes: + source_codes.insert(0, master_code) + source_codes = list(dict.fromkeys(source_codes)) + + child_codes = [code for code in source_codes if code != master_code] + child_exec_count = int( + conn.execute( + text( + """ + SELECT COUNT(*) + FROM project_exec_budget_entries + WHERE support_dept_code IN :source_codes + AND COALESCE(source_support_dept_code, support_dept_code) = support_dept_code + """ + ).bindparams(bindparam("source_codes", expanding=True)), + {"source_codes": child_codes or ["__NO_SOURCE__"]}, + ).scalar() + or 0 + ) + child_task_count = int( + conn.execute( + text( + """ + SELECT COUNT(*) + FROM project_task_plan_entries + WHERE support_dept_code IN :source_codes + AND COALESCE(source_support_dept_code, support_dept_code) = support_dept_code + """ + ).bindparams(bindparam("source_codes", expanding=True)), + {"source_codes": child_codes or ["__NO_SOURCE__"]}, + ).scalar() + or 0 + ) + effective_exec_sources = child_codes if child_exec_count else [master_code] + effective_task_sources = child_codes if child_task_count else [master_code] + + exec_source_rows = [ + dict(row) + for row in conn.execute( + text( + """ + SELECT * + FROM project_exec_budget_entries + WHERE support_dept_code IN :source_codes + AND COALESCE(source_support_dept_code, support_dept_code) = support_dept_code + ORDER BY support_dept_code, position, id + """ + ).bindparams(bindparam("source_codes", expanding=True)), + {"source_codes": effective_exec_sources or ["__NO_SOURCE__"]}, + ).mappings().fetchall() + ] + task_source_rows = [ + dict(row) + for row in conn.execute( + text( + """ + SELECT * + FROM project_task_plan_entries + WHERE support_dept_code IN :source_codes + AND COALESCE(source_support_dept_code, support_dept_code) = support_dept_code + ORDER BY support_dept_code, position, id + """ + ).bindparams(bindparam("source_codes", expanding=True)), + {"source_codes": effective_task_sources or ["__NO_SOURCE__"]}, + ).mappings().fetchall() + ] + if not exec_source_rows and not task_source_rows: + continue + + conn.execute( + text("DELETE FROM project_exec_budget_entries WHERE support_dept_code = :master_code"), + {"master_code": master_code}, + ) + conn.execute( + text("DELETE FROM project_task_plan_entries WHERE support_dept_code = :master_code"), + {"master_code": master_code}, + ) + + exec_amount_total = 0.0 + for position, row in enumerate(exec_source_rows): + source_support_dept_code = normalize_text(row.get("source_support_dept_code")) or normalize_text(row.get("support_dept_code")) + amount = normalize_amount(row.get("amount")) + conn.execute( + text( + """ + INSERT INTO project_exec_budget_entries ( + support_dept_code, position, group_name, grade, hours, rate_year, + dept_name, work_name, account_code, account_name, amount, + source_support_dept_code, source_project_code, source_revision_id, updated_at + ) VALUES ( + :support_dept_code, :position, :group_name, :grade, :hours, :rate_year, + :dept_name, :work_name, :account_code, :account_name, :amount, + :source_support_dept_code, :source_project_code, :source_revision_id, CURRENT_TIMESTAMP + ) + """ + ), + { + "support_dept_code": master_code, + "position": position, + "group_name": normalize_text(row.get("group_name")), + "grade": normalize_text(row.get("grade")), + "hours": normalize_text(row.get("hours")), + "rate_year": normalize_text(row.get("rate_year")), + "dept_name": normalize_text(row.get("dept_name")), + "work_name": normalize_text(row.get("work_name")), + "account_code": normalize_text(row.get("account_code")), + "account_name": normalize_text(row.get("account_name")), + "amount": amount, + "source_support_dept_code": source_support_dept_code, + "source_project_code": normalize_text(row.get("source_project_code")), + "source_revision_id": int(row.get("source_revision_id") or 0), + }, + ) + exec_amount_total += amount + exec_rows_inserted += 1 + + task_amount_total = 0.0 + for position, row in enumerate(task_source_rows): + source_support_dept_code = normalize_text(row.get("source_support_dept_code")) or normalize_text(row.get("support_dept_code")) + amount = normalize_amount(row.get("amount")) + conn.execute( + text( + """ + INSERT INTO project_task_plan_entries ( + support_dept_code, position, group_name, dept_name, work_name, amount, + source_support_dept_code, source_project_code, source_revision_id, updated_at + ) VALUES ( + :support_dept_code, :position, :group_name, :dept_name, :work_name, :amount, + :source_support_dept_code, :source_project_code, :source_revision_id, CURRENT_TIMESTAMP + ) + """ + ), + { + "support_dept_code": master_code, + "position": position, + "group_name": normalize_text(row.get("group_name")), + "dept_name": normalize_text(row.get("dept_name")), + "work_name": normalize_text(row.get("work_name")), + "amount": amount, + "source_support_dept_code": source_support_dept_code, + "source_project_code": normalize_text(row.get("source_project_code")), + "source_revision_id": int(row.get("source_revision_id") or 0), + }, + ) + task_amount_total += amount + task_rows_inserted += 1 + + if exec_source_rows: + actual_exec_source_codes = list( + dict.fromkeys( + normalize_text(row.get("source_support_dept_code")) or normalize_text(row.get("support_dept_code")) + for row in exec_source_rows + if normalize_text(row.get("source_support_dept_code")) or normalize_text(row.get("support_dept_code")) + ) + ) + conn.execute( + text( + """ + INSERT INTO satis_project_budget_projection_status ( + support_dept_code, project_code, project_name, budget_type, + revision_id, revision_no, approval_status, projection_mode, + is_provisional, line_count, amount_total, note, projected_at + ) VALUES ( + :support_dept_code, :project_code, :project_name, 'exec_budget', + 0, '', '', 'master_aggregate', + 0, :line_count, :amount_total, :note, CURRENT_TIMESTAMP + ) + ON CONFLICT(support_dept_code, budget_type) DO UPDATE SET + project_code = excluded.project_code, + project_name = excluded.project_name, + revision_id = excluded.revision_id, + revision_no = excluded.revision_no, + approval_status = excluded.approval_status, + projection_mode = excluded.projection_mode, + is_provisional = excluded.is_provisional, + line_count = excluded.line_count, + amount_total = excluded.amount_total, + note = excluded.note, + projected_at = CURRENT_TIMESTAMP + """ + ), + { + "support_dept_code": master_code, + "project_code": master_code, + "project_name": "", + "line_count": len(exec_source_rows), + "amount_total": exec_amount_total, + "note": f"연계 차수 프로젝트 예산 합산: {', '.join(actual_exec_source_codes)}", + }, + ) + status_rows.append( + { + "support_dept_code": master_code, + "budget_type": "exec_budget", + "line_count": len(exec_source_rows), + "amount_total": exec_amount_total, + "source_codes": actual_exec_source_codes, + } + ) + if task_source_rows: + actual_task_source_codes = list( + dict.fromkeys( + normalize_text(row.get("source_support_dept_code")) or normalize_text(row.get("support_dept_code")) + for row in task_source_rows + if normalize_text(row.get("source_support_dept_code")) or normalize_text(row.get("support_dept_code")) + ) + ) + conn.execute( + text( + """ + INSERT INTO satis_project_budget_projection_status ( + support_dept_code, project_code, project_name, budget_type, + revision_id, revision_no, approval_status, projection_mode, + is_provisional, line_count, amount_total, note, projected_at + ) VALUES ( + :support_dept_code, :project_code, :project_name, 'task_plan', + 0, '', '', 'master_aggregate', + 0, :line_count, :amount_total, :note, CURRENT_TIMESTAMP + ) + ON CONFLICT(support_dept_code, budget_type) DO UPDATE SET + project_code = excluded.project_code, + project_name = excluded.project_name, + revision_id = excluded.revision_id, + revision_no = excluded.revision_no, + approval_status = excluded.approval_status, + projection_mode = excluded.projection_mode, + is_provisional = excluded.is_provisional, + line_count = excluded.line_count, + amount_total = excluded.amount_total, + note = excluded.note, + projected_at = CURRENT_TIMESTAMP + """ + ), + { + "support_dept_code": master_code, + "project_code": master_code, + "project_name": "", + "line_count": len(task_source_rows), + "amount_total": task_amount_total, + "note": f"연계 차수 프로젝트 예산 합산: {', '.join(actual_task_source_codes)}", + }, + ) + status_rows.append( + { + "support_dept_code": master_code, + "budget_type": "task_plan", + "line_count": len(task_source_rows), + "amount_total": task_amount_total, + "source_codes": actual_task_source_codes, + } + ) + aggregated_project_count += 1 + sync_project_status_cache_row(conn, master_code) + + return { + "aggregated_project_count": aggregated_project_count, + "task_rows_inserted": task_rows_inserted, + "exec_rows_inserted": exec_rows_inserted, + "status_rows": status_rows[:50], + } + + +def _project_satis_budget_to_current_entries(payload: dict[str, Any] | None = None) -> dict[str, Any]: + payload = payload or {} + include_pending = bool(payload.get("include_pending", True)) + init_db() + selected_count = 0 + projected_projects: set[str] = set() + task_rows_inserted = 0 + exec_rows_inserted = 0 + provisional_count = 0 + skipped: list[dict[str, Any]] = [] + projected_status_rows: list[dict[str, Any]] = [] + + with engine.begin() as conn: + selected_revisions = _select_satis_projection_revisions(conn, include_pending) + target_keys = { + ( + normalize_text(row.get("support_dept_code")), + normalize_text(row.get("target_budget_type")), + ) + for row in selected_revisions + if normalize_text(row.get("support_dept_code")) + } + for support_dept_code, target_budget_type in sorted(target_keys): + if target_budget_type == "exec_budget": + conn.execute( + text("DELETE FROM project_exec_budget_entries WHERE support_dept_code = :support_dept_code"), + {"support_dept_code": support_dept_code}, + ) + elif target_budget_type == "task_plan": + conn.execute( + text("DELETE FROM project_task_plan_entries WHERE support_dept_code = :support_dept_code"), + {"support_dept_code": support_dept_code}, + ) + + for revision in selected_revisions: + revision_id = int(revision.get("id") or 0) + support_dept_code = normalize_text(revision.get("support_dept_code")) + if not revision_id or not support_dept_code: + skipped.append({"revision_id": revision_id, "reason": "프로젝트 매핑이 없습니다."}) + continue + target_budget_type = normalize_text(revision.get("target_budget_type")) + selected_count += 1 + projected_projects.add(support_dept_code) + if int(revision.get("is_provisional") or 0): + provisional_count += 1 + + line_count = 0 + amount_total = 0.0 + if target_budget_type == "exec_budget": + lines = conn.execute( + text( + """ + SELECT * + FROM satis_project_exec_budget_lines + WHERE revision_id = :revision_id + ORDER BY line_no, id + """ + ), + {"revision_id": revision_id}, + ).mappings().fetchall() + for position, line in enumerate(lines): + amount = normalize_amount(line.get("amount")) + conn.execute( + text( + """ + INSERT INTO project_exec_budget_entries ( + support_dept_code, position, group_name, grade, hours, rate_year, + dept_name, work_name, account_code, account_name, amount, + source_support_dept_code, source_project_code, source_revision_id, updated_at + ) VALUES ( + :support_dept_code, :position, :group_name, :grade, :hours, :rate_year, + :dept_name, :work_name, :account_code, :account_name, :amount, + :source_support_dept_code, :source_project_code, :source_revision_id, CURRENT_TIMESTAMP + ) + """ + ), + { + "support_dept_code": support_dept_code, + "position": position, + "group_name": normalize_text(line.get("group_name")) or "satis_exec_budget", + "grade": normalize_text(line.get("grade")), + "hours": normalize_text(line.get("hours")), + "rate_year": normalize_text(line.get("rate_year")), + "dept_name": normalize_text(line.get("dept_name")), + "work_name": normalize_text(line.get("work_name")), + "account_code": normalize_text(line.get("account_code")), + "account_name": normalize_text(line.get("account_name")), + "amount": amount, + "source_support_dept_code": support_dept_code, + "source_project_code": normalize_text(revision.get("project_code")), + "source_revision_id": revision_id, + }, + ) + line_count += 1 + amount_total += amount + exec_rows_inserted += 1 + else: + lines = conn.execute( + text( + """ + SELECT * + FROM satis_project_task_plan_budget_lines + WHERE revision_id = :revision_id + ORDER BY line_no, id + """ + ), + {"revision_id": revision_id}, + ).mappings().fetchall() + for position, line in enumerate(lines): + amount = normalize_amount(line.get("amount")) + conn.execute( + text( + """ + INSERT INTO project_task_plan_entries ( + support_dept_code, position, group_name, dept_name, work_name, amount, + source_support_dept_code, source_project_code, source_revision_id, updated_at + ) VALUES ( + :support_dept_code, :position, :group_name, :dept_name, :work_name, :amount, + :source_support_dept_code, :source_project_code, :source_revision_id, CURRENT_TIMESTAMP + ) + """ + ), + { + "support_dept_code": support_dept_code, + "position": position, + "group_name": normalize_text(line.get("group_name")) or "satis_task_plan", + "dept_name": normalize_text(line.get("dept_name")), + "work_name": normalize_text(line.get("work_name")), + "amount": amount, + "source_support_dept_code": support_dept_code, + "source_project_code": normalize_text(revision.get("project_code")), + "source_revision_id": revision_id, + }, + ) + line_count += 1 + amount_total += amount + task_rows_inserted += 1 + + conn.execute( + text( + """ + INSERT INTO satis_project_budget_projection_status ( + support_dept_code, project_code, project_name, budget_type, + revision_id, revision_no, approval_status, projection_mode, + is_provisional, line_count, amount_total, note, projected_at + ) VALUES ( + :support_dept_code, :project_code, :project_name, :budget_type, + :revision_id, :revision_no, :approval_status, :projection_mode, + :is_provisional, :line_count, :amount_total, :note, CURRENT_TIMESTAMP + ) + ON CONFLICT(support_dept_code, budget_type) DO UPDATE SET + project_code = excluded.project_code, + project_name = excluded.project_name, + revision_id = excluded.revision_id, + revision_no = excluded.revision_no, + approval_status = excluded.approval_status, + projection_mode = excluded.projection_mode, + is_provisional = excluded.is_provisional, + line_count = excluded.line_count, + amount_total = excluded.amount_total, + note = excluded.note, + projected_at = CURRENT_TIMESTAMP + """ + ), + { + "support_dept_code": support_dept_code, + "project_code": normalize_text(revision.get("project_code")), + "project_name": normalize_text(revision.get("project_name")), + "budget_type": target_budget_type, + "revision_id": revision_id, + "revision_no": normalize_text(revision.get("revision_no")), + "approval_status": normalize_text(revision.get("approval_status")), + "projection_mode": normalize_text(revision.get("projection_mode")), + "is_provisional": int(revision.get("is_provisional") or 0), + "line_count": line_count, + "amount_total": amount_total, + "note": normalize_text(revision.get("projection_note")), + }, + ) + projected_status_rows.append( + { + "support_dept_code": support_dept_code, + "project_code": normalize_text(revision.get("project_code")), + "budget_type": target_budget_type, + "revision_no": normalize_text(revision.get("revision_no")), + "approval_status": normalize_text(revision.get("approval_status")), + "projection_mode": normalize_text(revision.get("projection_mode")), + "line_count": line_count, + "amount_total": amount_total, + } + ) + for support_dept_code in sorted(projected_projects): + sync_project_status_cache_row(conn, support_dept_code) + aggregate_result = _aggregate_satis_budget_entries_to_master_projects(conn) + + return { + "status": "ok", + "message": f"승인 최신 차수 기준으로 {len(projected_projects):,}개 프로젝트의 Satis 예산을 기존 입력 테이블에 반영했습니다.", + "include_pending": include_pending, + "selected_revisions": selected_count, + "project_count": len(projected_projects), + "task_rows_inserted": task_rows_inserted, + "exec_rows_inserted": exec_rows_inserted, + "provisional_count": provisional_count, + "skipped": skipped[:20], + "projected": projected_status_rows[:50], + "master_aggregate": aggregate_result, + "projection_status_table": "satis_project_budget_projection_status", + } + + +def _run_satis_budget_full_sync(payload: dict[str, Any]) -> dict[str, Any]: + _hanmac_erp_web_credentials(payload) + _hanmac_erp_mysql_credentials(payload) + + raw_result = _sync_satis_budget_raw_rows(payload) + normalize_result = _normalize_satis_budget_raw_rows({}) + projection_result = _project_satis_budget_to_current_entries({"include_pending": True}) + return { + "status": "ok", + "message": "Satis 예산 원본 저장, 정규화, 승인 최신 차수 반영을 순차 실행했습니다.", + "raw": raw_result, + "normalize": normalize_result, + "projection": projection_result, + "inserted_or_updated_rows": int(raw_result.get("inserted_or_updated_rows") or 0), + "normalized_revisions": int(normalize_result.get("normalized_revisions") or 0), + "project_count": int(projection_result.get("project_count") or 0), + "task_rows_inserted": int(projection_result.get("task_rows_inserted") or 0), + "exec_rows_inserted": int(projection_result.get("exec_rows_inserted") or 0), + "provisional_count": int(projection_result.get("provisional_count") or 0), + } + + +HANMAC_SATIS_WEB_BUDGET_KEYWORDS = ( + "프로젝트개요관리", + "과업수행계획서작성", + "실행계획서작성", + "과업수행계획서변경차수관리", + "과업수행계획서", + "실행계획서", + "예산", + "승인", + "차수", +) + + +def _extract_satis_web_links(base_url: str, body: str) -> list[str]: + links: set[str] = set() + static_ext_pattern = re.compile(r"\.(?:css|js|png|gif|jpg|jpeg|ico|bmp|svg|woff|ttf)(?:$|\?)", re.IGNORECASE) + for match in re.findall(r"""(?:href|src|action)\s*=\s*["']([^"']+)["']""", body, flags=re.IGNORECASE): + link = normalize_text(match) + if not link or link.startswith(("#", "javascript:", "mailto:")): + continue + absolute = urljoin(base_url, link) + if static_ext_pattern.search(urlparse(absolute).path): + continue + parsed = urlparse(absolute) + if parsed.netloc and parsed.netloc != "erp.hanmaceng.co.kr": + continue + if "/satis/" in parsed.path.lower() or absolute.lower().startswith(HANMAC_SATIS_ERP_BASE_URL.lower()): + links.add(absolute) + for match in re.findall(r"""(?:open|go|url|href|src|action)[A-Za-z0-9_]*\s*\(\s*["']([^"']+)["']""", body, flags=re.IGNORECASE): + link = normalize_text(match) + if not link or link.startswith(("#", "javascript:", "mailto:")): + continue + absolute = urljoin(base_url, link) + if static_ext_pattern.search(urlparse(absolute).path): + continue + parsed = urlparse(absolute) + if parsed.netloc and parsed.netloc != "erp.hanmaceng.co.kr": + continue + if "/satis/" in parsed.path.lower() or "controller" in parsed.path.lower(): + links.add(absolute) + return sorted(links) + + +def _extract_satis_amount_candidates(body: str, limit: int = 80) -> list[dict[str, Any]]: + candidates: list[dict[str, Any]] = [] + compact = re.sub(r"\s+", " ", body) + for match in re.finditer(r"(?= limit: + break + return candidates + + +def _extract_satis_menu_items(body: str) -> list[dict[str, Any]]: + menu_items: list[dict[str, Any]] = [] + for match in re.finditer(r"""var\s+(list_data\d*)\s*=\s*jQuery\.parseJSON\(\s*'(.+?)'\s*\);""", body, flags=re.IGNORECASE | re.DOTALL): + variable_name = normalize_text(match.group(1)) + raw_json = match.group(2) + try: + items = json.loads(raw_json) + except Exception: + try: + items = json.loads(raw_json.encode("utf-8").decode("unicode_escape")) + except Exception: + continue + if not isinstance(items, list): + continue + for item in items: + if not isinstance(item, dict): + continue + name = normalize_text(item.get("item04")) + primary_url = normalize_text(item.get("item05")) + secondary_url = normalize_text(item.get("item07")) + if not name and not primary_url and not secondary_url: + continue + menu_items.append( + { + "source_variable": variable_name, + "category1": normalize_text(item.get("item01")), + "category2": normalize_text(item.get("item02")), + "category3": normalize_text(item.get("item03")), + "name": name, + "primary_url": primary_url, + "secondary_url": secondary_url, + "screen_id": normalize_text(item.get("item08")), + "menu_code": normalize_text(item.get("item10")), + } + ) + return menu_items + + +def _extract_satis_form_defaults(body: str) -> dict[str, str]: + defaults: dict[str, str] = {} + for match in re.finditer(r"]+>", body, flags=re.IGNORECASE): + tag = match.group(0) + name_match = re.search(r"""name\s*=\s*["']?([^"'\s>]+)""", tag, flags=re.IGNORECASE) + if not name_match: + continue + value_match = re.search(r"""value\s*=\s*["']([^"']*)["']""", tag, flags=re.IGNORECASE) + if not value_match: + value_match = re.search(r"""value\s*=\s*([^"'\s>]*)""", tag, flags=re.IGNORECASE) + defaults[name_match.group(1)] = value_match.group(1) if value_match else "" + for match in re.finditer(r"]*)>(.*?)", body, flags=re.IGNORECASE | re.DOTALL): + attributes, options_html = match.groups() + name_match = re.search(r"""name\s*=\s*["']?([^"'\s>]+)""", attributes, flags=re.IGNORECASE) + if not name_match: + continue + option_matches = list( + re.finditer(r"]*)>(.*?)", options_html, flags=re.IGNORECASE | re.DOTALL) + ) + if not option_matches: + defaults[name_match.group(1)] = "" + continue + selected_option = next( + (option for option in option_matches if re.search(r"\bselected\b", option.group(1), flags=re.IGNORECASE)), + option_matches[0], + ) + option_attributes, option_label = selected_option.groups() + value_match = re.search( + r"""value\s*=\s*(?:"([^"]*)"|'([^']*)'|([^"'\s>]*))""", + option_attributes, + flags=re.IGNORECASE, + ) + if value_match: + defaults[name_match.group(1)] = next( + (value for value in value_match.groups() if value is not None), + "", + ) + else: + defaults[name_match.group(1)] = re.sub(r"<[^>]+>", "", option_label).strip() + return defaults + + +def _relax_satis_search_defaults(action_mode: str, main_action: str, defaults: Mapping[str, Any]) -> dict[str, str]: + relaxed = {normalize_text(key): normalize_text(value) for key, value in defaults.items() if normalize_text(key)} + if action_mode == "SCREEN_01" and main_action == "Ajax_01": + # 프로젝트 개요 화면의 초기 선택값은 사용자의 부서/진행 사업으로 제한된다. + # 예산 전체 수집에서는 ERP 화면이 제공하는 '%' 값을 사용해 과거·종료 사업도 조회한다. + for key in ("input_select_01", "input_select_02", "input_select_03", "input_select_04", "input_select_05"): + if key in relaxed: + relaxed[key] = "%" + for key in ("input_item_01", "input_item_02", "input_item_03"): + if key in relaxed: + relaxed[key] = "" + return relaxed + + +def _extract_satis_ajax_actions(body: str) -> list[str]: + actions = set(re.findall(r"""["']((?:HTML_)?Ajax_\d+|HTML_Page_\d+|Plan_chg)["']""", body)) + actions.update(re.findall(r"""MainAction\s*=\s*["']((?:HTML_)?Ajax_\d+|HTML_Page_\d+|Plan_chg)["']""", body)) + actions.update(re.findall(r"""MainAction['"]?\s*:\s*["']((?:HTML_)?Ajax_\d+|HTML_Page_\d+|Plan_chg)["']""", body)) + return sorted(actions) + + +def _build_satis_capture( + *, + source_url: str, + request_method: str, + http_status: int, + final_url: str, + body: str, +) -> dict[str, Any]: + title_match = re.search(r"]*>(.*?)", body, flags=re.IGNORECASE | re.DOTALL) + title = re.sub(r"\s+", " ", title_match.group(1)).strip() if title_match else "" + matched_keywords = [keyword for keyword in HANMAC_SATIS_WEB_BUDGET_KEYWORDS if keyword in body or keyword in title or keyword in final_url] + links = _extract_satis_web_links(final_url or source_url, body) + amount_candidates = _extract_satis_amount_candidates(body) + body_hash = hashlib.sha256(body.encode("utf-8", "replace")).hexdigest() + capture_key = hashlib.sha256(f"{request_method}|{source_url}|{final_url}|{body_hash}".encode("utf-8")).hexdigest() + return { + "capture_key": capture_key, + "source_url": source_url, + "request_method": request_method, + "http_status": http_status, + "final_url": final_url, + "page_title": title[:300], + "matched_keywords": ", ".join(matched_keywords), + "internal_links": links[:80], + "amount_candidates": amount_candidates[:80], + "body_preview": body[:2_000_000], + "body_hash": body_hash, + } + + +def _json_loads_loose(value: str) -> Any: + text_value = normalize_text(value) + if not text_value: + return None + try: + return json.loads(text_value) + except Exception: + pass + start_candidates = [idx for idx in (text_value.find("["), text_value.find("{")) if idx >= 0] + if not start_candidates: + return None + start = min(start_candidates) + end = max(text_value.rfind("]"), text_value.rfind("}")) + if end <= start: + return None + try: + return json.loads(text_value[start : end + 1]) + except Exception: + return None + + +def _flatten_satis_json_rows(parsed: Any) -> list[dict[str, Any]]: + if isinstance(parsed, list): + return [item for item in parsed if isinstance(item, dict)] + if not isinstance(parsed, dict): + return [] + for key in ("rows", "data", "list_data", "list", "records"): + value = parsed.get(key) + if isinstance(value, list): + rows: list[dict[str, Any]] = [] + for item in value: + if isinstance(item, dict): + if isinstance(item.get("cell"), list): + rows.append({f"cell{idx + 1:02d}": cell for idx, cell in enumerate(item["cell"])}) + else: + rows.append(item) + return rows + return [parsed] if parsed else [] + + +def _infer_satis_web_budget_type(action_mode: str, main_action: str, source_url: str, row: Mapping[str, Any]) -> str: + haystack = " ".join( + [ + normalize_text(action_mode), + normalize_text(main_action), + normalize_text(source_url), + " ".join(normalize_text(value) for value in row.values()), + ] + ) + if "SCREEN_02" in haystack or "실행계획" in haystack or "exec" in haystack.lower(): + return "exec_budget" + if "SCREEN_01" in haystack or "프로젝트개요" in haystack: + return "project_overview" + if "과업수행" in haystack or "SCREEN_03" in haystack or "SCREEN_06" in haystack: + return "task_plan" + return "project_budget" + + +def _pick_satis_web_value(row: Mapping[str, Any], keys: Sequence[str]) -> str: + lowered = {normalize_text(key).lower(): value for key, value in row.items()} + for key in keys: + key_l = key.lower() + if key_l in lowered: + return normalize_text(lowered.get(key_l)) + return "" + + +def _promote_satis_web_captures_to_raw_rows(captures: Sequence[Mapping[str, Any]]) -> dict[str, Any]: + inserted_or_updated = 0 + parsed_capture_count = 0 + skipped_capture_count = 0 + with engine.begin() as conn: + conn.execute(text("DELETE FROM satis_project_budget_raw_rows WHERE source_database = 'satis_web'")) + for capture in captures: + body = normalize_text(capture.get("body_preview")) + parsed = _json_loads_loose(body) + rows = _flatten_satis_json_rows(parsed) + if not rows: + skipped_capture_count += 1 + continue + parsed_capture_count += 1 + source_url = normalize_text(capture.get("source_url")) + query = parse_qs(urlparse(source_url).query) + action_mode = normalize_text((query.get("ActionMode") or [""])[0]) + main_action = normalize_text((query.get("MainAction") or [""])[0]) + source_table = f"{action_mode or 'WEB'}:{main_action or normalize_text(capture.get('request_method')) or 'response'}" + for row_index, row in enumerate(rows): + normalized_row = {normalize_text(key): value for key, value in row.items() if normalize_text(key)} + if action_mode == "SCREEN_01" and main_action in ("Ajax_01", "HTML_Ajax_01"): + project_code = _pick_satis_web_value(normalized_row, ("view02", "view28", "item02", "item01")) + project_name = _pick_satis_web_value(normalized_row, ("view04", "item04", "item02")) + revision_no = _pick_satis_web_value(normalized_row, ("item17",)) + approval_status = _pick_satis_web_value(normalized_row, ("item18",)) + preferred_amount_keys = ("item10",) + elif action_mode == "SCREEN_03" and main_action == "HTML_Ajax_01": + project_code = _pick_satis_web_value(normalized_row, ("view02", "view28", "item03", "item02")) + project_name = _pick_satis_web_value(normalized_row, ("view04", "item04")) + revision_no = _pick_satis_web_value(normalized_row, ("item17",)) + approval_status = _pick_satis_web_value(normalized_row, ("item18",)) + preferred_amount_keys = ("item10",) + elif action_mode == "SCREEN_03" and main_action in ("Ajax_02", "Ajax_03"): + project_code = _pick_satis_web_value(normalized_row, ("project_code", "proj_code", "item02", "item01")) + project_name = _pick_satis_web_value(normalized_row, ("project_name", "proj_name")) + revision_no = _pick_satis_web_value(normalized_row, ("degree", "item03", "item04")) + approval_status = " ".join( + value + for value in ( + _pick_satis_web_value(normalized_row, ("item07",)), + f"확정:{_pick_satis_web_value(normalized_row, ('item08',))}" + if _pick_satis_web_value(normalized_row, ("item08",)) + else "", + ) + if value + ) + preferred_amount_keys = ("item12", "item13", "item14") if main_action == "Ajax_03" else () + elif action_mode == "SCREEN_06" and main_action == "Ajax_00": + project_code = _pick_satis_web_value(normalized_row, ("item02",)) + project_name = "" + revision_no = _pick_satis_web_value(normalized_row, ("item03",)) + approval_status = "" + preferred_amount_keys = ("item10", "item40") + elif action_mode == "SCREEN_06" and main_action == "Ajax_03": + project_code = _pick_satis_web_value(normalized_row, ("item02",)) + project_name = "" + revision_no = _pick_satis_web_value(normalized_row, ("item03",)) + approval_status = "" + preferred_amount_keys = ("item08", "item09") + elif action_mode == "SCREEN_02" and main_action in ("Ajax_01", "Ajax_02", "Ajax_03", "Ajax_04"): + project_code = _pick_satis_web_value( + normalized_row, + ("item01",) if main_action == "Ajax_03" else ("item02",), + ) + project_name = "" + revision_no = _pick_satis_web_value( + normalized_row, + ("item02",) if main_action == "Ajax_03" else ("item03",), + ) + approval_status = "" + preferred_amount_keys = { + "Ajax_01": ("item08",), + "Ajax_02": ("item08",), + "Ajax_03": ("item07",), + "Ajax_04": ("item08",), + }[main_action] + else: + project_code = _pick_satis_web_value( + normalized_row, + ("project_code", "proj_code", "proj_cd", "view02", "view28", "item02", "cell02"), + ) + project_name = _pick_satis_web_value( + normalized_row, + ("project_name", "proj_name", "proj_nm", "view04", "item03", "cell03"), + ) + revision_no = _pick_satis_web_value( + normalized_row, + ("revision_no", "degree", "cha", "item01", "item02", "cell01", "cell02"), + ) + approval_status = _pick_satis_web_value( + normalized_row, + ("approval_status", "status", "state", "item07", "item12", "cell07"), + ) + preferred_amount_keys = () + project_code = project_code or normalize_text( + (query.get("proj_code") or query.get("input_item_01") or [""])[0] + ) + project_name = project_name or normalize_text((query.get("input_item_02") or [""])[0]) + revision_no = revision_no or normalize_text( + (query.get("degree") or query.get("input_item_03") or [""])[0] + ) + approval_status = approval_status or normalize_text((query.get("input_item_04") or [""])[0]) + amount_values = { + key: _safe_float(value) + for key, value in normalized_row.items() + if _safe_float(value) != 0 and re.search(r"(amount|amt|price|cost|money|budget|sum|total|금액|예산|합계|계약|수금|잔액|item0[4-9]|item1[0-9]|view)", key, flags=re.IGNORECASE) + } + if preferred_amount_keys: + amount_values = { + key: _safe_float(normalized_row.get(key)) + for key in preferred_amount_keys + if _safe_float(normalized_row.get(key)) != 0 + } + if ( + (action_mode == "SCREEN_03" and main_action == "Ajax_02") + or (action_mode == "SCREEN_06" and main_action in ("Ajax_01", "Ajax_02", "Ajax_03", "Ajax_04", "Ajax_05")) + or (action_mode == "SCREEN_02" and main_action in ("Ajax_02", "Ajax_05")) + ): + amount_values = {} + amount_total = sum(amount_values.values()) + is_revision_metadata = action_mode == "SCREEN_03" and main_action == "Ajax_02" and project_code + if amount_total == 0 and not is_revision_metadata and not any( + "합계" in normalize_text(value) or "계약" in normalize_text(value) + for value in normalized_row.values() + ): + continue + budget_type = _infer_satis_web_budget_type(action_mode, main_action, source_url, normalized_row) + if action_mode == "SCREEN_03" and main_action == "Ajax_03": + budget_type = "project_overview" + inferred_columns = { + "source_url": source_url, + "request_method": normalize_text(capture.get("request_method")), + "action_mode": action_mode, + "main_action": main_action, + "amount_columns": sorted(amount_values.keys()), + "web_capture_id": capture.get("id"), + } + raw_payload_json = json.dumps(normalized_row, ensure_ascii=False, sort_keys=True, default=_json_default) + source_hash = hashlib.sha256( + json.dumps( + { + "source": "satis_web", + "url": source_url, + "method": normalize_text(capture.get("request_method")), + "row": normalized_row, + }, + ensure_ascii=False, + sort_keys=True, + default=_json_default, + ).encode("utf-8") + ).hexdigest() + conn.execute( + text( + """ + INSERT OR REPLACE INTO satis_project_budget_raw_rows ( + source_system, source_database, source_table, source_row_index, + budget_type, project_code, project_name, revision_no, approval_status, + amount_total, amount_values_json, inferred_columns_json, + raw_payload_json, source_hash, synced_at + ) VALUES ( + 'satis', 'satis_web', :source_table, :source_row_index, + :budget_type, :project_code, :project_name, :revision_no, :approval_status, + :amount_total, :amount_values_json, :inferred_columns_json, + :raw_payload_json, :source_hash, CURRENT_TIMESTAMP + ) + """ + ), + { + "source_table": source_table, + "source_row_index": row_index, + "budget_type": budget_type, + "project_code": project_code, + "project_name": project_name, + "revision_no": revision_no, + "approval_status": approval_status, + "amount_total": amount_total, + "amount_values_json": json.dumps(amount_values, ensure_ascii=False, sort_keys=True, default=_json_default), + "inferred_columns_json": json.dumps(inferred_columns, ensure_ascii=False, sort_keys=True, default=_json_default), + "raw_payload_json": raw_payload_json, + "source_hash": source_hash, + }, + ) + inserted_or_updated += 1 + return { + "parsed_capture_count": parsed_capture_count, + "skipped_capture_count": skipped_capture_count, + "inserted_or_updated_rows": inserted_or_updated, + } + + +def _target_satis_budget_menu_items(menu_items: list[dict[str, Any]]) -> list[dict[str, Any]]: + targets: list[dict[str, Any]] = [] + target_names = ("프로젝트개요관리", "과업수행계획서작성", "실행계획서작성", "과업수행계획서변경차수관리") + for item in menu_items: + haystack = " ".join( + normalize_text(item.get(key)) + for key in ("category1", "category2", "category3", "name", "primary_url", "secondary_url", "screen_id") + ) + if any(target in haystack for target in target_names): + targets.append(item) + continue + if "프로젝트" in haystack and any(token in haystack for token in ("과업수행", "실행계획", "예산", "변경차수")): + targets.append(item) + return targets + + +def _satis_web_login_opener(user: str, password: str) -> tuple[Any, CookieJar, dict[str, Any]]: + cookie_jar = CookieJar() + opener = build_opener(HTTPCookieProcessor(cookie_jar)) + login_info: dict[str, Any] = {} + login_page_request = UrlRequest( + HANMAC_SATIS_ERP_LOGIN_PAGE_URL, + headers={"User-Agent": "my-intranet-app/satis-web-budget-collector"}, + ) + with opener.open(login_page_request, timeout=10) as response: + login_info["login_page_status"] = int(getattr(response, "status", 200) or 200) + response.read(1000) + + check_request = UrlRequest( + f"{HANMAC_SATIS_ERP_LOGIN_CONTROLLER_URL}?ActionMode=SCREEN_10&MainAction=Ajax_01&SubAction=checktype04&item02=&item03=&userid={quote_plus(user)}&password={quote_plus(password)}", + data=b"", + headers={"User-Agent": "my-intranet-app/satis-web-budget-collector"}, + ) + try: + with opener.open(check_request, timeout=10) as response: + login_info["credential_check_status"] = int(getattr(response, "status", 200) or 200) + login_info["credential_check_body"] = response.read(2000).decode("utf-8", "replace").strip()[:200] + except Exception as exc: + login_info["credential_check_error"] = str(exc) + + login_request = UrlRequest( + HANMAC_SATIS_ERP_LOGIN_CONTROLLER_URL, + data=urlencode( + { + "ActionMode": "SCREEN_10", + "MainAction": "Ajax_02", + "SubAction": "checktype05", + "userid": user, + "password": password, + "item02": "", + "item03": password, + } + ).encode("utf-8"), + headers={ + "Content-Type": "application/x-www-form-urlencoded", + "User-Agent": "my-intranet-app/satis-web-budget-collector", + }, + ) + with opener.open(login_request, timeout=10) as response: + login_info["login_status"] = int(getattr(response, "status", 200) or 200) + login_info["login_url"] = str(response.geturl() or "") + login_info["login_body_preview"] = response.read(2_000_000).decode("utf-8", "replace")[:500_000] + login_info["php_session"] = any(cookie.name == "PHPSESSID" for cookie in cookie_jar) + return opener, cookie_jar, login_info + + +def _collect_satis_budget_via_web(payload: dict[str, Any]) -> dict[str, Any]: + user, password = _hanmac_erp_web_credentials(payload) + try: + max_pages = max(5, min(int(payload.get("max_pages") or 40), 120)) + except Exception: + max_pages = 40 + + opener, _cookie_jar, login_info = _satis_web_login_opener(user, password) + seed_urls = [ + HANMAC_SATIS_ERP_BASE_URL, + HANMAC_SATIS_ERP_LOGIN_CONTROLLER_URL, + f"{HANMAC_SATIS_ERP_BASE_URL}sys/controller/main_controller.php", + f"{HANMAC_SATIS_ERP_BASE_URL}sys/controller/Main/Main_controller.php", + ] + login_body_preview = normalize_text(login_info.get("login_body_preview")) + seed_urls.extend(_extract_satis_web_links(HANMAC_SATIS_ERP_LOGIN_CONTROLLER_URL, login_body_preview)[:20]) + menu_items = _extract_satis_menu_items(login_body_preview) + target_menu_items = _target_satis_budget_menu_items(menu_items) + for item in target_menu_items: + for key in ("primary_url", "secondary_url"): + url = normalize_text(item.get(key)) + if url: + seed_urls.append(url) + + queue: list[str] = [] + seen: set[str] = set() + for url in seed_urls: + if url not in seen: + queue.append(url) + seen.add(url) + + captures: list[dict[str, Any]] = [] + matched_captures: list[dict[str, Any]] = [] + errors: list[dict[str, Any]] = [] + + login_body = normalize_text(login_info.get("login_body_preview")) + if login_body: + login_capture = _build_satis_capture( + source_url=HANMAC_SATIS_ERP_LOGIN_CONTROLLER_URL, + request_method="POST", + http_status=int(login_info.get("login_status") or 0), + final_url=normalize_text(login_info.get("login_url")), + body=login_body, + ) + captures.append(login_capture) + if login_capture["matched_keywords"] or login_capture["amount_candidates"] or login_capture["internal_links"]: + matched_captures.append(login_capture) + for link in login_capture["internal_links"]: + if link not in seen: + queue.append(link) + seen.add(link) + + while queue and len(captures) < max_pages: + url = queue.pop(0) + if re.search(r"\.(?:css|js|png|gif|jpg|jpeg|ico|bmp|svg|woff|ttf)(?:$|\?)", urlparse(url).path, flags=re.IGNORECASE): + continue + try: + request = UrlRequest(url, headers={"User-Agent": "my-intranet-app/satis-web-budget-collector"}) + with opener.open(request, timeout=10) as response: + status = int(getattr(response, "status", 200) or 200) + final_url = str(response.geturl() or "") + raw = response.read(700_000) + body = raw.decode("utf-8", "replace") + except Exception as exc: + errors.append({"url": url, "error": str(exc)[:300]}) + continue + capture = _build_satis_capture( + source_url=url, + request_method="GET", + http_status=status, + final_url=final_url, + body=body, + ) + links = capture["internal_links"] + for link in links: + if link not in seen and len(seen) < max_pages * 4: + if any(token in link.lower() for token in ("project", "plan", "exec", "budget", "task", "controller", "satis")) or capture["matched_keywords"]: + queue.append(link) + seen.add(link) + captures.append(capture) + if capture["matched_keywords"] or capture["amount_candidates"]: + matched_captures.append(capture) + + ajax_seen: set[str] = set() + ajax_captures: list[dict[str, Any]] = [] + for screen_capture in list(captures): + screen_body = normalize_text(screen_capture.get("body_preview")) + final_url = normalize_text(screen_capture.get("final_url") or screen_capture.get("source_url")) + if "Project_Controller.php" not in final_url and "ProjectMenHour_Controller.php" not in final_url: + continue + parsed = urlparse(final_url) + query = parse_qs(parsed.query) + action_mode = normalize_text((query.get("ActionMode") or [""])[0]) + if not action_mode: + action_match = re.search(r"""var\s+ActionMode\s*=\s*["']([^"']+)""", screen_body) + action_mode = normalize_text(action_match.group(1) if action_match else "") + if not action_mode: + continue + controller_url = final_url.split("?", 1)[0] + form_defaults = _extract_satis_form_defaults(screen_body) + ajax_actions = [ + action + for action in _extract_satis_ajax_actions(screen_body) + if not action.startswith("HTML_Page_") + ] + for main_action in ajax_actions: + params = _relax_satis_search_defaults(action_mode, main_action, form_defaults) + params.update( + { + "ActionMode": action_mode, + "MainAction": main_action, + "SubAction": "select", + "page": "1", + "rows": "500", + "_search": "false", + "sidx": "", + "sord": "asc", + "nd": str(int(time.time() * 1000)), + } + ) + flat_ajax_url = f"{controller_url}?{urlencode(params)}" + query_ajax_url = f"{controller_url}?{urlencode({key: value for key, value in params.items() if key in {'ActionMode', 'MainAction', 'SubAction', 'page', 'rows', '_search', 'sidx', 'sord', 'nd'}})}" + nested_jggrid_form = { + f"jgGridData[{key}]": value + for key, value in params.items() + if key not in {"ActionMode", "MainAction", "SubAction", "page", "rows", "_search", "sidx", "sord", "nd"} + } + request_specs = [ + ("GET", flat_ajax_url, None), + ("POST", flat_ajax_url, params), + ("POST_JGGRID", query_ajax_url, nested_jggrid_form), + ] + for request_method, ajax_url, post_params in request_specs: + ajax_key = f"{request_method}|{ajax_url}|{json.dumps(post_params or {}, ensure_ascii=False, sort_keys=True)}" + if ajax_key in ajax_seen: + continue + ajax_seen.add(ajax_key) + try: + data = urlencode(post_params).encode("utf-8") if post_params is not None else None + headers = {"User-Agent": "my-intranet-app/satis-web-budget-collector"} + if data is not None: + headers["Content-Type"] = "application/x-www-form-urlencoded" + request = UrlRequest(ajax_url, data=data, headers=headers) + with opener.open(request, timeout=12) as response: + status = int(getattr(response, "status", 200) or 200) + response_final_url = str(response.geturl() or "") + raw = response.read(2_000_000) + ajax_body = raw.decode("utf-8", "replace") + except Exception as exc: + errors.append({"url": ajax_url, "method": request_method, "error": str(exc)[:300]}) + continue + ajax_capture = _build_satis_capture( + source_url=ajax_url, + request_method=request_method, + http_status=status, + final_url=response_final_url, + body=ajax_body, + ) + ajax_captures.append(ajax_capture) + captures.append(ajax_capture) + if ajax_capture["matched_keywords"] or ajax_capture["amount_candidates"] or len(ajax_body) > 100: + matched_captures.append(ajax_capture) + + # 현재 포트 DB에 존재하는 프로젝트만 대상으로 차수 및 예산 상세를 순회한다. + # support_dept_code는 Satis 프로젝트 코드(Y24196 등)와 동일하게 관리되고 있다. + try: + max_detail_projects = max(1, min(int(payload.get("max_detail_projects") or 50), 200)) + except Exception: + max_detail_projects = 50 + external_projects: dict[str, str] = {} + for capture in captures: + parsed_capture_url = urlparse(normalize_text(capture.get("source_url"))) + capture_query = parse_qs(parsed_capture_url.query) + capture_action_mode = normalize_text((capture_query.get("ActionMode") or [""])[0]) + capture_main_action = normalize_text((capture_query.get("MainAction") or [""])[0]) + if (capture_action_mode, capture_main_action) not in { + ("SCREEN_01", "Ajax_01"), + ("SCREEN_01", "HTML_Ajax_01"), + ("SCREEN_03", "HTML_Ajax_01"), + }: + continue + for external_row in _flatten_satis_json_rows( + _json_loads_loose(normalize_text(capture.get("body_preview"))) + ): + if capture_action_mode == "SCREEN_03": + external_code = _pick_satis_web_value(external_row, ("view02", "view28", "item03", "item02")) + external_name = _pick_satis_web_value(external_row, ("view04", "item04")) + else: + external_code = _pick_satis_web_value(external_row, ("view02", "view28", "item02", "item01")) + external_name = _pick_satis_web_value(external_row, ("view04", "item04", "item02")) + if external_code and external_name: + external_projects[external_code] = external_name + + def project_name_key(value: Any) -> str: + return re.sub(r"[^0-9a-z가-힣]", "", normalize_text(value).lower()) + + with engine.begin() as conn: + local_projects = [ + dict(row) + for row in conn.execute( + text( + """ + SELECT support_dept_code, support_dept_name + FROM project_status + WHERE support_dept_code <> '' + ORDER BY support_dept_code + LIMIT :limit + """ + ), + {"limit": max_detail_projects}, + ).mappings().fetchall() + ] + detail_projects: list[dict[str, str]] = [] + for local_project in local_projects: + support_dept_code = normalize_text(local_project.get("support_dept_code")) + support_dept_name = normalize_text(local_project.get("support_dept_name")) + mapped_code = "" + mapping_basis = "" + register_row = conn.execute( + text( + """ + SELECT * + FROM satis_project_code_links + WHERE local_project_code = :support_dept_code + LIMIT 1 + """ + ), + {"support_dept_code": support_dept_code}, + ).mappings().first() + if register_row: + register_status = normalize_text(register_row.get("mapping_status")) + if register_status not in {"common"}: + linked_main_code = normalize_text(register_row.get("linked_main_project_code")) + own_master_code = normalize_text(register_row.get("own_master_project_code")) + if linked_main_code: + mapped_code = linked_main_code + mapping_basis = "project_code_register:linked_main" + elif own_master_code: + mapped_code = own_master_code + mapping_basis = "project_code_register:own_master" + candidates = [support_dept_code] + if re.fullmatch(r"X\d{5}", support_dept_code): + candidates.insert(0, f"9{support_dept_code[1:]}") + if re.fullmatch(r"[YZ]\d{5}", support_dept_code): + candidates.insert(0, f"0{support_dept_code[1:]}") + if not mapped_code: + for candidate in candidates: + if candidate in external_projects: + mapped_code = candidate + mapping_basis = "code_pattern" if candidate != support_dept_code else "exact_code" + break + if not mapped_code and support_dept_name: + local_name_key = project_name_key(support_dept_name) + exact_name_matches = [ + code + for code, name in external_projects.items() + if project_name_key(name) == local_name_key + ] + if len(exact_name_matches) == 1: + mapped_code = exact_name_matches[0] + mapping_basis = "exact_name" + else: + scored = sorted( + ( + SequenceMatcher(None, local_name_key, project_name_key(name)).ratio(), + code, + ) + for code, name in external_projects.items() + if project_name_key(name) + ) + if scored and scored[-1][0] >= 0.88 and ( + len(scored) == 1 or scored[-1][0] - scored[-2][0] >= 0.04 + ): + mapped_code = scored[-1][1] + mapping_basis = f"fuzzy_name:{scored[-1][0]:.3f}" + if mapped_code: + mapped_name = ( + external_projects.get(mapped_code) + or normalize_text((register_row or {}).get("linked_main_project_name")) + or normalize_text((register_row or {}).get("own_master_project_name")) + or normalize_text((register_row or {}).get("local_project_name")) + or support_dept_name + ) + conn.execute( + text( + """ + INSERT INTO satis_project_mapping ( + erp_project_code, erp_project_name, support_dept_code, + mapping_status, mapping_basis, manual_override, updated_at + ) VALUES ( + :erp_project_code, :erp_project_name, :support_dept_code, + 'matched', :mapping_basis, 0, CURRENT_TIMESTAMP + ) + ON CONFLICT(erp_project_code) DO UPDATE SET + erp_project_name = excluded.erp_project_name, + support_dept_code = CASE + WHEN satis_project_mapping.manual_override = 1 + THEN satis_project_mapping.support_dept_code + ELSE excluded.support_dept_code + END, + mapping_status = CASE + WHEN satis_project_mapping.manual_override = 1 + THEN satis_project_mapping.mapping_status + ELSE excluded.mapping_status + END, + mapping_basis = CASE + WHEN satis_project_mapping.manual_override = 1 + THEN satis_project_mapping.mapping_basis + ELSE excluded.mapping_basis + END, + updated_at = CURRENT_TIMESTAMP + """ + ), + { + "erp_project_code": mapped_code, + "erp_project_name": mapped_name, + "support_dept_code": support_dept_code, + "mapping_basis": mapping_basis, + }, + ) + detail_projects.append( + { + "project_code": mapped_code, + "project_name": mapped_name, + "support_dept_code": support_dept_code, + } + ) + + detail_capture_count = 0 + detail_revision_count = 0 + project_controller_url = f"{HANMAC_SATIS_ERP_BASE_URL}sys/controller/Project/Project_Controller.php" + + def fetch_detail(action_mode: str, main_action: str, params: Mapping[str, Any]) -> dict[str, Any] | None: + nonlocal detail_capture_count + request_params = { + "ActionMode": action_mode, + "MainAction": main_action, + "SubAction": "select", + "page": "1", + "rows": "500", + "_search": "false", + "sidx": "", + "sord": "asc", + "nd": str(int(time.time() * 1000)), + **{normalize_text(key): normalize_text(value) for key, value in params.items() if normalize_text(key)}, + } + detail_url = f"{project_controller_url}?{urlencode(request_params)}" + try: + request = UrlRequest(detail_url, headers={"User-Agent": "my-intranet-app/satis-web-budget-collector"}) + with opener.open(request, timeout=15) as response: + status = int(getattr(response, "status", 200) or 200) + response_final_url = str(response.geturl() or "") + raw = response.read(4_000_000) + detail_body = raw.decode("utf-8", "replace") + except Exception as exc: + errors.append( + { + "url": detail_url, + "method": "GET_DETAIL", + "error": str(exc)[:300], + } + ) + return None + detail_capture = _build_satis_capture( + source_url=detail_url, + request_method="GET_DETAIL", + http_status=status, + final_url=response_final_url, + body=detail_body, + ) + captures.append(detail_capture) + detail_capture_count += 1 + if detail_capture["matched_keywords"] or detail_capture["amount_candidates"] or len(detail_body) > 100: + matched_captures.append(detail_capture) + return detail_capture + + for project in detail_projects: + project_code = normalize_text(project.get("project_code")) + project_name = normalize_text(project.get("project_name")) + if not project_code: + continue + revision_capture = fetch_detail( + "SCREEN_03", + "Ajax_02", + { + "proj_code": project_code, + "input_item_01": project_code, + "input_item_02": project_name, + "degree_pre": "00", + }, + ) + revision_rows = _flatten_satis_json_rows( + _json_loads_loose(normalize_text((revision_capture or {}).get("body_preview"))) + ) + revisions: list[dict[str, str]] = [] + for revision_row in revision_rows: + revision_no = _pick_satis_web_value(revision_row, ("item03", "degree", "revision_no")) or "00" + revision_status = " ".join( + value + for value in ( + _pick_satis_web_value(revision_row, ("item07", "status")), + f"확정:{_pick_satis_web_value(revision_row, ('item08',))}" + if _pick_satis_web_value(revision_row, ("item08",)) + else "", + ) + if value + ) + revision_key = (revision_no, revision_status) + if not any((item["revision_no"], item["approval_status"]) == revision_key for item in revisions): + revisions.append({"revision_no": revision_no, "approval_status": revision_status}) + if not revisions: + revisions = [{"revision_no": "00", "approval_status": ""}] + detail_revision_count += len(revisions) + + for revision in revisions: + revision_no = normalize_text(revision.get("revision_no")) or "00" + approval_status = normalize_text(revision.get("approval_status")) + common_params = { + "proj_code": project_code, + "degree": revision_no, + "degree_pre": revision_no, + "input_item_01": project_code, + "input_item_02": project_name, + "input_item_03": revision_no, + "input_item_03_name": revision_no, + "input_item_04": approval_status, + } + fetch_detail("SCREEN_03", "Ajax_03", common_params) + fetch_detail("SCREEN_06", "Ajax_00", common_params) + fetch_detail("SCREEN_06", "Ajax_03", common_params) + + dept_capture = fetch_detail( + "SCREEN_02", + "Info_dept", + { + "input_item_01": project_code, + "input_item_03": revision_no, + }, + ) + dept_rows = _flatten_satis_json_rows( + _json_loads_loose(normalize_text((dept_capture or {}).get("body_preview"))) + ) + dept_codes = sorted( + { + _pick_satis_web_value(row, ("CODE", "code", "item01")) + for row in dept_rows + if _pick_satis_web_value(row, ("CODE", "code", "item01")) + } + ) + if not dept_codes: + dept_codes = ["%"] + for dept_code in dept_codes: + dept_params = {**common_params, "input_select_01": dept_code} + fetch_detail("SCREEN_02", "Ajax_00", dept_params) + for main_action in ("Ajax_01", "Ajax_02", "Ajax_03", "Ajax_04"): + fetch_detail("SCREEN_02", main_action, dept_params) + + with engine.begin() as conn: + for capture in captures: + conn.execute( + text( + """ + INSERT INTO satis_project_budget_web_captures ( + capture_key, source_url, request_method, http_status, final_url, + page_title, matched_keywords, internal_links_json, amount_candidates_json, + body_preview, body_hash, captured_at + ) VALUES ( + :capture_key, :source_url, :request_method, :http_status, :final_url, + :page_title, :matched_keywords, :internal_links_json, :amount_candidates_json, + :body_preview, :body_hash, CURRENT_TIMESTAMP + ) + ON CONFLICT(capture_key) DO UPDATE SET + http_status = excluded.http_status, + final_url = excluded.final_url, + page_title = excluded.page_title, + matched_keywords = excluded.matched_keywords, + internal_links_json = excluded.internal_links_json, + amount_candidates_json = excluded.amount_candidates_json, + body_preview = excluded.body_preview, + body_hash = excluded.body_hash, + captured_at = CURRENT_TIMESTAMP + """ + ), + { + **{key: capture[key] for key in ("capture_key", "source_url", "request_method", "http_status", "final_url", "page_title", "matched_keywords", "body_preview", "body_hash")}, + "internal_links_json": json.dumps(capture["internal_links"], ensure_ascii=False), + "amount_candidates_json": json.dumps(capture["amount_candidates"], ensure_ascii=False), + }, + ) + + promoted_result = _promote_satis_web_captures_to_raw_rows(captures) + + return { + "status": "ok", + "message": f"Satis 웹로그인 세션으로 {len(captures):,}개 페이지/컨트롤러 응답을 수집했습니다.", + "login_info": {key: value for key, value in login_info.items() if "password" not in key.lower()}, + "captured_count": len(captures), + "ajax_captured_count": len(ajax_captures), + "detail_project_count": len(detail_projects), + "detail_revision_count": detail_revision_count, + "detail_captured_count": detail_capture_count, + "raw_inserted_or_updated_rows": int(promoted_result.get("inserted_or_updated_rows") or 0), + "raw_parsed_capture_count": int(promoted_result.get("parsed_capture_count") or 0), + "matched_count": len(matched_captures), + "error_count": len(errors), + "errors": errors[:10], + "matched_examples": [ + { + "url": item["final_url"] or item["source_url"], + "title": item["page_title"], + "keywords": item["matched_keywords"], + "amount_candidate_count": len(item["amount_candidates"]), + "link_count": len(item["internal_links"]), + } + for item in matched_captures[:12] + ], + "menu_item_count": len(menu_items), + "target_menu_items": target_menu_items[:30], + "capture_table": "satis_project_budget_web_captures", + "next_action": "matched_examples 또는 DB의 satis_project_budget_web_captures에서 실제 프로젝트 조회 컨트롤러와 파라미터를 확인해야 합니다.", + } + + +def _build_hanmac_erp_direct_mysql_engine(user: str, password: str): + return create_engine( + URL.create( + "mysql+pymysql", + username=user, + password=password, + host=HANMAC_ERP_DIRECT_DB_HOST, + port=HANMAC_ERP_DIRECT_DB_PORT, + query={"charset": "utf8"}, + ), + pool_pre_ping=True, + pool_recycle=300, + connect_args={"connect_timeout": 5}, + ) + + +def _discover_hanmac_erp_budget_tables(user: str, password: str) -> dict[str, Any]: + direct_db_engine = _build_hanmac_erp_direct_mysql_engine(user, password) + try: + with direct_db_engine.connect() as connection: + databases = [ + normalize_text(row[0]) + for row in connection.execute(text("SHOW DATABASES")).fetchall() + if normalize_text(row[0]) + ] + visible_databases = [ + database + for database in databases + if database.lower() not in {"information_schema", "mysql", "performance_schema"} + ] + like_clauses: list[str] = [] + params: dict[str, Any] = {} + for index, keyword in enumerate(HANMAC_SATIS_BUDGET_DISCOVERY_KEYWORDS): + key = f"keyword_{index}" + params[key] = f"%{keyword.lower()}%" + like_clauses.append(f"LOWER(c.table_name) LIKE :{key}") + like_clauses.append(f"LOWER(c.column_name) LIKE :{key}") + rows = connection.execute( + text( + f""" + SELECT + c.table_schema, + c.table_name, + COUNT(*) AS matched_column_count, + COUNT(DISTINCT c.column_name) AS distinct_column_count, + GROUP_CONCAT(c.column_name ORDER BY c.ordinal_position SEPARATOR ', ') AS matched_columns + FROM information_schema.columns c + WHERE c.table_schema NOT IN ('information_schema', 'mysql', 'performance_schema') + AND ({" OR ".join(like_clauses)}) + GROUP BY c.table_schema, c.table_name + ORDER BY matched_column_count DESC, c.table_schema, c.table_name + LIMIT 80 + """ + ), + params, + ).mappings().fetchall() + candidates = [] + for row in rows: + candidates.append( + { + "database": normalize_text(row.get("table_schema")), + "table": normalize_text(row.get("table_name")), + "matched_column_count": int(row.get("matched_column_count") or 0), + "distinct_column_count": int(row.get("distinct_column_count") or 0), + "matched_columns": normalize_text(row.get("matched_columns"))[:600], + } + ) + return { + "direct_db_access": True, + "direct_db_message": f"MySQL 직접 접속에 성공했습니다. 조회 가능한 DB {len(visible_databases)}개, 예산 후보 테이블 {len(candidates)}개를 확인했습니다.", + "direct_db_database_count": len(visible_databases), + "direct_db_database_examples": visible_databases[:12], + "candidate_table_count": len(candidates), + "candidate_tables": candidates[:40], + } + finally: + direct_db_engine.dispose() + + +def test_hanmac_satis_budget_discovery(payload: dict[str, Any]) -> dict[str, Any]: + user, password = _hanmac_erp_web_credentials(payload) + db_user, db_password, explicit_db_credentials = _hanmac_erp_mysql_credentials(payload) + + result: dict[str, Any] = { + "status": "ok", + "message": "Satis 예산 연동 사전 탐색을 완료했습니다.", + "web_access": False, + "web_message": "", + "direct_db_access": False, + "direct_db_message": "", + "candidate_table_count": 0, + "candidate_tables": [], + "prepared_local_tables": [ + "satis_project_mapping", + "satis_project_budget_revisions", + "satis_project_task_plan_budget_lines", + "satis_project_exec_budget_lines", + ], + "transport_warning": "Satis ERP 로그인 주소가 HTTP이므로 계정 전송 구간이 암호화되지 않습니다.", + } + + cookie_jar = CookieJar() + opener = build_opener(HTTPCookieProcessor(cookie_jar)) + try: + login_page_request = UrlRequest( + HANMAC_SATIS_ERP_LOGIN_PAGE_URL, + headers={"User-Agent": "my-intranet-app/satis-budget-discovery"}, + ) + with opener.open(login_page_request, timeout=10) as response: + login_page_status = int(getattr(response, "status", 200) or 200) + response.read(1000) + + login_request = UrlRequest( + HANMAC_SATIS_ERP_LOGIN_CONTROLLER_URL, + data=urlencode( + { + "ActionMode": "SCREEN_10", + "MainAction": "Ajax_02", + "SubAction": "checktype05", + "userid": user, + "password": password, + "item02": "", + "item03": "", + } + ).encode("utf-8"), + headers={ + "Content-Type": "application/x-www-form-urlencoded", + "User-Agent": "my-intranet-app/satis-budget-discovery", + }, + ) + with opener.open(login_request, timeout=10) as response: + login_status = int(getattr(response, "status", 200) or 200) + login_url = str(response.geturl() or "") + login_body = response.read(300_000).decode("utf-8", "replace") + links = { + normalize_text(match) + for match in re.findall(r"""(?:href|src|url)\s*=\s*["']?([^"' >]+)""", login_body, flags=re.IGNORECASE) + if normalize_text(match) + } + internal_links = sorted( + link + for link in links + if "satis" in link.lower() or link.startswith(("./", "../", "/")) + ) + result.update( + { + "web_access": True, + "web_message": "Satis 로그인 컨트롤러 호출에 성공했습니다.", + "login_page_status": login_page_status, + "login_status": login_status, + "login_url": login_url, + "php_session": any(cookie.name == "PHPSESSID" for cookie in cookie_jar), + "internal_link_count": len(internal_links), + "internal_link_examples": internal_links[:8], + } + ) + except Exception as exc: + result["web_message"] = f"Satis 웹 로그인 확인은 실패했습니다: {exc}" + + try: + result.update(_discover_hanmac_erp_budget_tables(db_user, db_password)) + except OperationalError as exc: + lowered = str(exc).lower() + if "access denied" in lowered: + result["direct_db_message"] = _build_hanmac_mysql_access_denied_message(db_user, explicit_db_credentials) + else: + result["direct_db_message"] = "MySQL 3306 포트는 열려 있지만 직접 DB 접속 또는 메타정보 조회에 실패했습니다." + except Exception as exc: + result["direct_db_message"] = f"직접 DB 탐색 중 오류가 발생했습니다: {exc}" + + if result.get("direct_db_access"): + result["message"] = "Satis 후보 DB/테이블 탐색과 로컬 저장소 준비를 완료했습니다." + elif result.get("web_access"): + result["message"] = "Satis 웹 접근은 확인했지만 직접 DB 후보 테이블은 확인하지 못했습니다." + else: + result["message"] = "로컬 저장소는 준비했지만 Satis 웹/DB 접근은 확인하지 못했습니다." + return result + + +def test_hanmac_management_erp_access(payload: dict[str, Any]) -> dict[str, Any]: + user = normalize_text(payload.get("erp_user")) + password = str(payload.get("erp_password") or "") + if not user: + raise ValueError("관리 ERP 아이디를 입력해주세요.") + if not password: + raise ValueError("관리 ERP 비밀번호를 입력해주세요.") + + cookie_jar = CookieJar() + opener = build_opener(HTTPCookieProcessor(cookie_jar)) + login_request = UrlRequest( + HANMAC_MANAGEMENT_ERP_LOGIN_URL, + data=urlencode( + { + "memberID": user, + "LoginID": user, + "passwd": password, + "CheckSave": "", + "login": "1", + } + ).encode("utf-8"), + headers={ + "Content-Type": "application/x-www-form-urlencoded", + "User-Agent": "my-intranet-app/hanmac-erp-access-check", + }, + ) + with opener.open(login_request, timeout=10) as response: + login_result = response.read(2000).decode("utf-8", "replace").strip().lower() + if login_result != "success": + if login_result == "auth": + raise ValueError("관리 ERP 로그인은 확인됐지만 접근권한이 없습니다.") + raise ValueError("관리 ERP 아이디 또는 비밀번호가 올바르지 않습니다.") + + main_request = UrlRequest( + HANMAC_MANAGEMENT_ERP_MAIN_URL, + headers={"User-Agent": "my-intranet-app/hanmac-erp-access-check"}, + ) + with opener.open(main_request, timeout=10) as response: + main_status = int(getattr(response, "status", 200) or 200) + main_url = str(response.geturl() or "") + main_body = response.read(500_000).decode("utf-8", "replace") + + links = { + normalize_text(match) + for match in re.findall(r"""(?:href|src|url)\s*=\s*["']?([^"' >]+)""", main_body, flags=re.IGNORECASE) + if normalize_text(match) + } + internal_links = sorted( + link + for link in links + if "planning_mng" in link or link.startswith(("./", "../", "/")) + ) + db_markers = sorted( + marker + for marker in ("mysql", "mysqli", "pdo", "db_host", "db_name", "database", "3306") + if marker in main_body.lower() + ) + direct_db_access = False + direct_db_databases: list[str] = [] + direct_db_message = "" + direct_db_engine = create_engine( + URL.create( + "mysql+pymysql", + username=user, + password=password, + host="erp.hanmaceng.co.kr", + port=3306, + query={"charset": "utf8"}, + ), + pool_pre_ping=True, + connect_args={"connect_timeout": 5}, + ) + try: + with direct_db_engine.connect() as connection: + direct_db_databases = [ + normalize_text(row[0]) + for row in connection.execute(text("SHOW DATABASES")).fetchall() + if normalize_text(row[0]) + ] + direct_db_access = True + direct_db_message = f"동일 계정으로 MySQL 직접 접속에 성공했습니다. 조회 가능한 DB {len(direct_db_databases)}개를 확인했습니다." + except OperationalError as exc: + lowered = str(exc).lower() + if "access denied" in lowered: + direct_db_message = "MySQL 3306 포트는 열려 있지만 관리 ERP 웹 계정으로는 DB 직접 로그인이 거부되었습니다." + else: + direct_db_message = "MySQL 3306 포트는 열려 있지만 관리 ERP 웹 계정으로 DB 직접 접속하지 못했습니다." + finally: + direct_db_engine.dispose() + + return { + "status": "ok", + "message": "관리 ERP 로그인 및 내부 메인 페이지 접근에 성공했습니다.", + "web_access": True, + "main_status": main_status, + "main_url": main_url, + "php_session": any(cookie.name == "PHPSESSID" for cookie in cookie_jar), + "internal_link_count": len(internal_links), + "internal_link_examples": internal_links[:8], + "direct_db_access": direct_db_access, + "direct_db_database_count": len(direct_db_databases), + "direct_db_database_examples": direct_db_databases[:8], + "direct_db_info_found": bool(db_markers), + "direct_db_markers": db_markers, + "direct_db_message": direct_db_message, + "transport_warning": "관리 ERP 로그인 주소가 HTTP이므로 계정 전송 구간이 암호화되지 않습니다.", + } + + def _validate_hanmac_table_name(table_name: Any) -> str: normalized = normalize_text(table_name) if not normalized: @@ -15147,6 +23634,98 @@ def _hanmac_normalize_member_restore_keys(payload: dict[str, Any]) -> set[str]: return {_hanmac_normalize_member_token(item) for item in items if _hanmac_normalize_member_token(item)} +def _hanmac_company_code(value: Any) -> str: + return normalize_text(value).upper() + + +def _hanmac_company_is_hanmac(value: Any) -> bool: + text_value = normalize_text(value).replace(" ", "").upper() + return not text_value or text_value in {"HANMAC", "HM", "한맥", "한맥기술", "(주)한맥기술", "주식회사한맥기술"} + + +def _hanmac_member_is_active_for_period(member_record: Mapping[str, Any], start_date: date, end_date: date) -> bool: + entry_date = _hanmac_parse_date_value(member_record.get("entry_date")) + leave_date = _hanmac_parse_date_value(member_record.get("leave_date")) + return (entry_date is None or entry_date <= end_date) and (leave_date is None or leave_date >= start_date) + + +def _hanmac_load_member_work_keys_for_period( + connection: Any, + schema_name: str, + metadata: dict[str, list[str]], + start_date: date, + end_date: date, +) -> set[str]: + columns = metadata.get("dallyproject_tbl") or [] + member_col = _hanmac_find_column(columns, ["MemberNo", "member_no", "EmpNo", "UserID", "MemberID", "member_id"]) + entry_col = _hanmac_find_column(columns, ["EntryTime", "entry_time", "WorkDate", "work_date", "EntryDate", "entry_date"]) + if not member_col or not entry_col: + return set() + try: + rows = connection.execute( + text( + f""" + SELECT DISTINCT {_hanmac_build_select_alias(member_col, "member_no")} + FROM `{schema_name}`.`dallyproject_tbl` + WHERE `{member_col}` IS NOT NULL + AND LEFT(CAST(`{entry_col}` AS CHAR), 10) >= :start_date + AND LEFT(CAST(`{entry_col}` AS CHAR), 10) <= :end_date + """ + ), + {"start_date": start_date.isoformat(), "end_date": end_date.isoformat()}, + ).mappings().all() + except Exception: + return set() + return { + _hanmac_normalize_member_token(row.get("member_no")) + for row in rows + if _hanmac_normalize_member_token(row.get("member_no")) + } + + +def _hanmac_discover_affiliate_manhour_schemas(connection: Any, primary_schema: str) -> list[str]: + try: + rows = connection.execute( + text( + """ + SELECT table_schema, + SUM(CASE WHEN table_name = 'member_tbl' THEN 1 ELSE 0 END) AS has_member, + SUM(CASE WHEN table_name = 'dallyproject_tbl' THEN 1 ELSE 0 END) AS has_work + FROM information_schema.tables + WHERE table_type = 'BASE TABLE' + AND table_schema <> :primary_schema + AND table_schema LIKE '%manhour%' + GROUP BY table_schema + HAVING has_member > 0 AND has_work > 0 + ORDER BY table_schema + """ + ), + {"primary_schema": primary_schema}, + ).mappings().all() + except Exception: + return [HANMAC_CENTER_MANHOUR_SCHEMA] + schemas = [normalize_text(row.get("table_schema")) for row in rows if normalize_text(row.get("table_schema"))] + if HANMAC_CENTER_MANHOUR_SCHEMA not in schemas: + schemas.append(HANMAC_CENTER_MANHOUR_SCHEMA) + return [schema for schema in schemas if schema and schema != primary_schema] + + +def _hanmac_affiliate_schema_label(schema_name: Any) -> str: + text_value = normalize_text(schema_name) + lowered = text_value.lower() + aliases = { + "baron": "바론컨설턴트", + "saman": "삼안", + "samahn": "삼안", + "jangheon": "장헌산업", + "ptc": "피티씨", + } + for token, label in aliases.items(): + if token in lowered: + return label + return text_value + + def _hanmac_load_member_info( connection: Any, schema_name: str, @@ -15158,6 +23737,8 @@ def _hanmac_load_member_info( "member_name_col": "", "member_name_fallback_rows": 0, "member_group_col": "", + "member_company_col": "", + "member_work_company_col": "", "dept_source_table": "", "dept_code_col": "", "dept_name_col": "", @@ -15176,8 +23757,12 @@ def _hanmac_load_member_info( leave_date_col = _hanmac_find_column(member_columns, ["LeaveDate", "leave_date", "RetireDate", "OutDate"]) dept_name_col = _hanmac_find_column(member_columns, ["DeptName", "Department", "PartName", "TeamName", "Dept"]) grade_col = _hanmac_find_column(member_columns, ["Grade", "grade", "Position", "position", "Rank", "rank", "Duty", "duty", "JobGrade", "job_grade", "RankCode", "rank_code", "WorkPosition", "work_position", "직급"]) + company_col = _hanmac_find_column(member_columns, ["Company", "company", "Corp", "corp"]) + work_company_col = _hanmac_find_column(member_columns, ["WorkCompany", "work_company", "WorkCorp", "work_corp"]) member_group_col = _hanmac_find_column(member_columns, ["GroupCode", "group_code", "DeptCode", "dept_code", "DepartmentCode", "TeamCode"]) diagnostics["member_grade_col"] = grade_col or "" + diagnostics["member_company_col"] = company_col or "" + diagnostics["member_work_company_col"] = work_company_col or "" diagnostics["member_group_col"] = member_group_col or "" dept_name_by_code: dict[str, str] = {} if not dept_name_col and member_group_col: @@ -15266,6 +23851,8 @@ def _hanmac_load_member_info( {_hanmac_build_select_alias(leave_date_col, "leave_date")}, {_hanmac_build_select_alias(dept_name_col, "dept_name")}, {_hanmac_build_select_alias(grade_col, "member_grade")}, + {_hanmac_build_select_alias(company_col, "company")}, + {_hanmac_build_select_alias(work_company_col, "work_company")}, {_hanmac_build_select_alias(member_group_col, "group_code")} FROM `{schema_name}`.`member_tbl` """ @@ -15291,6 +23878,8 @@ def _hanmac_load_member_info( "leave_date": _hanmac_parse_date_value(row.get("leave_date")), "dept_name": dept_name, "member_grade": member_grade, + "company": _hanmac_company_code(row.get("company")), + "work_company": _hanmac_company_code(row.get("work_company")), "source_schema": schema_name, } return member_info, diagnostics @@ -15299,6 +23888,7 @@ def _hanmac_load_member_info( def _hanmac_build_project_code_relation_maps( project_code_alias_groups: list[dict[str, Any]], ) -> dict[str, Any]: + common_codes = {"0", "ZZZZZZ"} canonical_map: dict[str, str] = {} equivalent_code_map: dict[str, set[str]] = {} relation_groups: dict[str, list[str]] = {} @@ -15312,17 +23902,38 @@ def _hanmac_build_project_code_relation_maps( for group in project_code_alias_groups if normalize_text(group.get("new_project_code")) } - adjacency: dict[str, set[str]] = {} + alias_base_codes: dict[str, set[str]] = {} for group in project_code_alias_groups: project_code = normalize_text(group.get("project_code")) if not project_code: continue + for alias_key in ("project_view_code", "old_project_code", "new_project_code"): + alias_code = normalize_text(group.get(alias_key)) + if alias_code and alias_code not in common_codes and alias_code != project_code: + alias_base_codes.setdefault(alias_code, set()).add(project_code) + shared_alias_codes = { + alias_code + for alias_code, linked_base_codes in alias_base_codes.items() + if len(linked_base_codes) > 1 + } + adjacency: dict[str, set[str]] = {} + for group in project_code_alias_groups: + project_code = normalize_text(group.get("project_code")) + if not project_code or project_code in common_codes: + continue equivalent_codes = { normalize_text(group.get("project_view_code")), normalize_text(group.get("old_project_code")), normalize_text(group.get("new_project_code")), } - equivalent_codes = {code for code in equivalent_codes if code and code != project_code} + equivalent_codes = { + code + for code in equivalent_codes + if code + and code != project_code + and code not in common_codes + and code not in shared_alias_codes + } adjacency.setdefault(project_code, set()) for code in equivalent_codes: adjacency.setdefault(project_code, set()).add(code) @@ -15356,6 +23967,8 @@ def _hanmac_build_project_code_relation_maps( "canonical_map": canonical_map, "equivalent_code_map": equivalent_code_map, "relation_groups": relation_groups, + "excluded_common_codes": sorted(common_codes), + "excluded_shared_alias_codes": sorted(shared_alias_codes), } @@ -15459,6 +24072,23 @@ def _hanmac_calculate_regular_hours(entry_time: Any, leave_time: Any) -> float: return round(hours, 2) +def _hanmac_calculate_official_overtime_hours(entry_time: Any, overtime_time: Any, leave_time: Any) -> tuple[float, str]: + overtime_text = normalize_text(overtime_time) + if not overtime_text or overtime_text in {"0000-00-00 00:00:00", "00:00:00"}: + return 0.0, "" + overtime_started_at = _hanmac_parse_datetime_value(overtime_time) + ended_at = _hanmac_parse_datetime_value(leave_time) + started_at = _hanmac_parse_datetime_value(entry_time) + if overtime_started_at and ended_at: + if started_at and overtime_started_at.date() == started_at.date() and ended_at < overtime_started_at: + ended_at = ended_at + timedelta(days=1) + hours = (ended_at - overtime_started_at).total_seconds() / 3600.0 + if 0 < hours <= 24: + return round(hours, 2), "time_range" + return 0.0, "invalid_time_range" + return _hanmac_parse_duration_hours(overtime_time), "duration" + + def _hanmac_floor_regular_hours(hours: Any, cap: float = 8.0) -> float: return float(min(max(math.floor(max(_hanmac_parse_float_value(hours), 0.0)), 0), int(cap))) @@ -15536,6 +24166,10 @@ def _load_hanmac_holiday_dates(start_date: date, end_date: date) -> set[date]: parsed = _hanmac_parse_date_value(row[0]) if parsed: holiday_dates.add(parsed) + for year in range(start_date.year, end_date.year + 1): + labor_day = date(year, 5, 1) + if start_date <= labor_day <= end_date: + holiday_dates.add(labor_day) return holiday_dates @@ -15674,6 +24308,28 @@ def _hanmac_match_leave_rule(leave_type: Any, rules: list[dict[str, Any]]) -> di return None +def _hanmac_userstate_leave_rule(state_code: Any, leave_type: Any) -> dict[str, Any] | None: + code = normalize_text(state_code).zfill(2) + if code == "01": + return {"keyword": "state:1", "leave_label": "연차", "rule_type": "full_day", "default_hours": 8.0} + if code == "30": + return {"keyword": "state:30", "leave_label": "오전반차", "rule_type": "fixed_hours", "default_hours": 4.0} + if code == "31": + return {"keyword": "state:31", "leave_label": "오후반차", "rule_type": "fixed_hours", "default_hours": 4.0} + if code == "18": + return {"keyword": "state:18", "leave_label": "시차", "rule_type": "explicit_hours", "default_hours": 0.0} + if code == "07": + return {"keyword": "state:7", "leave_label": "경조휴가", "rule_type": "full_day", "default_hours": 8.0} + if code == "08": + leave_label = "출산휴가" if "출산" in normalize_text(leave_type) else "특별휴가" + return {"keyword": "state:8", "leave_label": leave_label, "rule_type": "full_day", "default_hours": 8.0} + if code == "10": + return {"keyword": "state:10", "leave_label": "병가", "rule_type": "full_day", "default_hours": 8.0} + if code == "16": + return {"keyword": "state:16", "leave_label": "휴직", "rule_type": "full_day", "default_hours": 8.0} + return None + + def _hanmac_calculate_leave_amounts( *, leave_type: Any, @@ -15723,6 +24379,7 @@ def _hanmac_build_text_concat_alias(columns: list[str], alias: str) -> str: def _hanmac_leave_source_profile(table_name: str, columns: list[str]) -> dict[str, Any] | None: member_col = _hanmac_find_column(columns, ["MemberNo", "member_no", "EmpNo", "UserID", "MemberID", "member_id"]) date_col = _hanmac_find_column(columns, ["work_date", "WorkDate", "EntryDate", "Date", "TardyDate", "s_date", "SDate", "StartDate", "start_date", "start_time", "StartTime", "UseDate", "use_date"]) + state_col = _hanmac_find_column(columns, ["state", "State", "WorkState"]) type_cols = _hanmac_find_columns(columns, ["reason", "Reason", "ReasonName", "TardyReason", "state", "State", "WorkState", "gubun", "Gubun", "TardyGubun", "type", "Type", "TardyType", "kind", "Kind", "TardyKind", "TardyCode", "TardyCD", "HolidayType", "VacationType", "AbsenceType", "contents", "Contents", "info", "Info", "note", "Note", "memo", "Memo", "remark", "Remark", "Name", "Description"]) lower_name = table_name.lower() name_hint = any(keyword in lower_name for keyword in ("tardy", "leave", "vac", "holiday", "absence", "annual", "dayoff")) @@ -15735,6 +24392,7 @@ def _hanmac_leave_source_profile(table_name: str, columns: list[str]) -> dict[st "date_col": date_col, "end_date_col": _hanmac_find_column(columns, ["e_date", "EDate", "EndDate", "end_date", "end_time", "EndTime"]), "project_col": _hanmac_find_column(columns, ["project_code", "ProjectCode", "new_project_code", "NewProjectCode", "ProjectKey", "PCode"]), + "state_col": state_col, "type_cols": type_cols, "value_col": _hanmac_find_column(columns, ["day_count", "DayCount", "days", "Days", "day", "Day", "DayCnt", "use_day", "UseDay", "use_days", "UseDays", "used_days", "UsedDays", "work_day", "WorkDay", "tardy_day", "TardyDay", "tardy_days", "TardyDays", "hours", "Hours", "hour", "Hour", "time", "Time", "TardyTime", "TardyHour", "TardyHours", "UseHour", "use_hour"]), "hour_col": _hanmac_find_column(columns, ["tardy_h", "TardyH", "tardy_hour", "TardyHour", "UseHour", "use_hour"]), @@ -15940,6 +24598,7 @@ def get_hanmac_grade_code_summary(payload: dict[str, Any]) -> dict[str, Any]: buckets: dict[str, dict[str, Any]] = {} for row in member_rows: grade_code = normalize_text(row.get("grade_code")) or "(빈값)" + normalized_grade = _normalize_labor_grade_name(rank_code_names.get(grade_code) or grade_code) entry_date = _hanmac_parse_date_value(row.get("entry_date")) leave_date = _hanmac_parse_date_value(row.get("leave_date")) is_active = (entry_date is None or entry_date <= today) and (leave_date is None or leave_date >= today) @@ -15948,15 +24607,16 @@ def get_hanmac_grade_code_summary(payload: dict[str, Any]) -> dict[str, Any]: { "grade_code": grade_code, "mapped_name": rank_code_names.get(grade_code, ""), - "normalized_name": _normalize_labor_grade_name(rank_code_names.get(grade_code) or grade_code), + "normalized_name": normalized_grade, "member_count": 0, "active_member_count": 0, "examples": [], }, ) - bucket["member_count"] += 1 - if is_active: - bucket["active_member_count"] += 1 + if not _hanmac_is_researcher_grade(normalized_grade): + bucket["member_count"] += 1 + if is_active: + bucket["active_member_count"] += 1 example_name = normalize_text(row.get("member_name")) or normalize_text(row.get("member_no")) if example_name and len(bucket["examples"]) < 5 and example_name not in bucket["examples"]: bucket["examples"].append(example_name) @@ -15985,6 +24645,7 @@ def _hanmac_load_joint_assignment_records( "joint_assignment_source_rows": 0, "joint_assignment_records": 0, "joint_assignment_code_matched_rows": 0, + "joint_assignment_state_matched_rows": 0, "joint_assignment_text_matched_rows": 0, "joint_assignment_table": "", } @@ -15997,6 +24658,7 @@ def _hanmac_load_joint_assignment_records( project_col = _hanmac_find_column(columns, ["NewProjectCode", "new_project_code", "ProjectCode", "project_code", "ProjectKey", "PCode"]) fallback_project_col = _hanmac_find_column(columns, ["ProjectCode", "project_code"]) note_col = _hanmac_find_column(columns, ["note", "Note", "memo", "Memo", "remark", "Remark"]) + state_col = _hanmac_find_column(columns, ["state", "State", "state_code", "StateCode"]) sub_code_col = _hanmac_find_column(columns, ["sub_code", "SubCode", "AbsentCode", "absent_code"]) active_code_col = _hanmac_find_column(columns, ["active_code", "ActiveCode"]) if not member_col or not start_col: @@ -16013,6 +24675,8 @@ def _hanmac_load_joint_assignment_records( for index, code in enumerate(code_values): key = f"joint_code_{index}" params[key] = code + if state_col: + code_conditions.append(f"CAST(`{state_col}` AS CHAR) = :{key}") if sub_code_col: code_conditions.append(f"CAST(`{sub_code_col}` AS CHAR) = :{key}") if active_code_col: @@ -16035,6 +24699,7 @@ def _hanmac_load_joint_assignment_records( {_hanmac_build_select_alias(project_col, "project_code")}, {_hanmac_build_select_alias(fallback_project_col, "fallback_project_code")}, {_hanmac_build_select_alias(note_col, "note")}, + {_hanmac_build_select_alias(state_col, "state_code")}, {_hanmac_build_select_alias(sub_code_col, "sub_code")}, {_hanmac_build_select_alias(active_code_col, "active_code")} FROM `{schema_name}`.`userstate_tbl` @@ -16055,12 +24720,23 @@ def _hanmac_load_joint_assignment_records( continue if record_end and record_end < record_start: record_start, record_end = record_end, record_start + state_code = normalize_text(row.get("state_code")) sub_code = normalize_text(row.get("sub_code")) active_code = normalize_text(row.get("active_code")) - matched_code = sub_code if sub_code in joint_codes else active_code if active_code in joint_codes else "" + matched_code = ( + state_code + if state_code in joint_codes + else sub_code + if sub_code in joint_codes + else active_code + if active_code in joint_codes + else "" + ) note = normalize_text(row.get("note")) if matched_code: diagnostics["joint_assignment_code_matched_rows"] += 1 + if state_code == matched_code: + diagnostics["joint_assignment_state_matched_rows"] += 1 elif "합사" in note: diagnostics["joint_assignment_text_matched_rows"] += 1 records.append( @@ -16079,6 +24755,186 @@ def _hanmac_load_joint_assignment_records( return records, diagnostics +def _hanmac_status_work_rule(state_code: Any, note: Any, project_code: Any) -> dict[str, Any] | None: + code = normalize_text(state_code).zfill(2) + note_text = normalize_text(note) + project_text = normalize_text(project_code) + if code == "22": + return {"label": "감리현장", "source_label": "감리현장", "cost_weight": 1.0} + if code == "23": + return {"label": "감리대기", "source_label": "감리대기", "cost_weight": 0.7} + if code != "03": + return None + compact_note = re.sub(r"\s+", "", note_text).lower() + personal_keywords = ( + "개인", + "개인사유", + "개인업무", + "개인용무", + "개인일정", + "병원", + "치과", + "한의원", + "검진", + "진료", + "치료", + "약처방", + "은행", + "부동산", + "차량", + "자동차", + "가족", + "자녀", + "배우자", + "모친", + "부친", + "장례", + "조문", + "결혼", + "이사", + "휴가", + "연차", + "반차", + ) + if any(keyword in compact_note for keyword in personal_keywords): + return None + business_keywords = ( + "현장", + "조사", + "회의", + "협의", + "점검", + "검사", + "교육", + "착수", + "보고", + "발표", + "준공", + "심의", + "평가", + "설계", + "용역", + "공사", + "공단", + "공사", + "국토청", + "사업", + "관리", + "열차감시", + "발주처", + ) + if project_text or any(keyword in compact_note for keyword in business_keywords): + return {"label": "업무회의", "source_label": "회의중", "cost_weight": 1.0} + return None + + +def _hanmac_load_status_work_records( + connection: Any, + schema_name: str, + metadata: dict[str, list[str]], + start_date: date, + end_date: date, +) -> tuple[list[dict[str, Any]], dict[str, Any]]: + diagnostics = { + "status_work_source_rows": 0, + "status_work_records": 0, + "status_work_duplicate_rows": 0, + "status_work_meeting_records": 0, + "status_work_supervision_site_records": 0, + "status_work_supervision_wait_records": 0, + "status_work_personal_meeting_skipped_rows": 0, + "status_work_table": "", + } + columns = metadata.get("userstate_tbl") or [] + if not columns: + return [], diagnostics + member_col = _hanmac_find_column(columns, ["MemberNo", "member_no", "EmpNo", "UserID", "MemberID", "member_id"]) + state_col = _hanmac_find_column(columns, ["state", "State", "state_code", "StateCode"]) + start_col = _hanmac_find_column(columns, ["start_time", "StartTime", "s_date", "SDate", "start_date", "StartDate"]) + end_col = _hanmac_find_column(columns, ["end_time", "EndTime", "e_date", "EDate", "end_date", "EndDate"]) + project_col = _hanmac_find_column(columns, ["NewProjectCode", "new_project_code", "ProjectCode", "project_code", "ProjectKey", "PCode"]) + fallback_project_col = _hanmac_find_column(columns, ["ProjectCode", "project_code"]) + note_col = _hanmac_find_column(columns, ["note", "Note", "memo", "Memo", "remark", "Remark"]) + if not member_col or not state_col or not start_col: + return [], diagnostics + + date_end_expr = f"LEFT(CAST(`{end_col}` AS CHAR), 10)" if end_col else f"LEFT(CAST(`{start_col}` AS CHAR), 10)" + rows = connection.execute( + text( + f""" + SELECT + {_hanmac_build_select_alias(member_col, "member_no")}, + {_hanmac_build_select_alias(state_col, "state_code")}, + {_hanmac_build_select_alias(start_col, "start_date")}, + {_hanmac_build_select_alias(end_col, "end_date")}, + {_hanmac_build_select_alias(project_col, "project_code")}, + {_hanmac_build_select_alias(fallback_project_col, "fallback_project_code")}, + {_hanmac_build_select_alias(note_col, "note")} + FROM `{schema_name}`.`userstate_tbl` + WHERE `{member_col}` IS NOT NULL + AND CAST(`{state_col}` AS CHAR) IN ('3', '03', '22', '23') + AND LEFT(CAST(`{start_col}` AS CHAR), 10) <= :end_date + AND {date_end_expr} >= :start_date + """ + ), + {"start_date": start_date.isoformat(), "end_date": end_date.isoformat()}, + ).mappings().all() + diagnostics["status_work_source_rows"] = len(rows) + diagnostics["status_work_table"] = "userstate_tbl" + records: list[dict[str, Any]] = [] + seen_record_keys: set[tuple[str, str, date, date, str, str]] = set() + for row in rows: + member_no = normalize_text(row.get("member_no")) + record_start = _hanmac_parse_date_value(row.get("start_date")) + record_end = _hanmac_parse_date_value(row.get("end_date")) or record_start + project_code = normalize_text(row.get("project_code")) or normalize_text(row.get("fallback_project_code")) + note = normalize_text(row.get("note")) + rule = _hanmac_status_work_rule(row.get("state_code"), note, project_code) + if not rule: + if normalize_text(row.get("state_code")).zfill(2) == "03": + diagnostics["status_work_personal_meeting_skipped_rows"] += 1 + continue + if not member_no or not record_start: + continue + if record_end and record_end < record_start: + record_start, record_end = record_end, record_start + state_code = normalize_text(row.get("state_code")).zfill(2) + record_key = ( + _hanmac_normalize_member_token(member_no), + state_code, + record_start, + record_end or record_start, + project_code, + note, + ) + if record_key in seen_record_keys: + diagnostics["status_work_duplicate_rows"] += 1 + continue + seen_record_keys.add(record_key) + if state_code == "03": + diagnostics["status_work_meeting_records"] += 1 + elif state_code == "22": + diagnostics["status_work_supervision_site_records"] += 1 + elif state_code == "23": + diagnostics["status_work_supervision_wait_records"] += 1 + records.append( + { + "member_no": member_no, + "state_code": state_code, + "start_date": max(record_start, start_date), + "end_date": min(record_end or record_start, end_date), + "project_code": project_code, + "source_label": rule["source_label"], + "status_label": rule["label"], + "cost_weight": float(rule["cost_weight"]), + "note": note, + "source": "userstate_tbl", + } + ) + diagnostics["status_work_records"] = len(records) + return records, diagnostics + + def _hanmac_member_status_label(entry_date: date | None, leave_date: date | None, today: date) -> str: if leave_date and leave_date < today: return "퇴사" @@ -16129,6 +24985,8 @@ def _hanmac_expected_regular_hours_for_period( ) -> float: entry_date = _hanmac_parse_date_value(member_record.get("entry_date")) leave_date = _hanmac_parse_date_value(member_record.get("leave_date")) + if not entry_date and not leave_date and not normalize_text(member_record.get("source_schema")): + return 0.0 effective_start = max(start_date, entry_date) if entry_date else start_date effective_end = min(end_date, leave_date) if leave_date else end_date if effective_end < effective_start: @@ -16141,13 +24999,17 @@ def _hanmac_expected_regular_hours_for_period( return round(work_days * 8.0, 2) +HANMAC_AGGREGATE_LOGIC_VERSION = "hanmac-aggregate-v40-joint-fallback-replacement" +HANMAC_AGGREGATE_SIGNATURE_PREFIX = f"{HANMAC_AGGREGATE_LOGIC_VERSION}:" + + def _hanmac_aggregate_cache_key(payload: dict[str, Any]) -> str: employment_value = normalize_text(payload.get("employment") or "all") if employment_value == "current": employment_value = "active" include_center_member_nos = sorted(_hanmac_normalize_member_restore_keys(payload)) normalized = { - "logic_version": "hanmac-aggregate-v19-regular-gap-remarks", + "logic_version": HANMAC_AGGREGATE_LOGIC_VERSION, "host": normalize_text(payload.get("host")), "port": normalize_text(payload.get("port")), "user": normalize_text(payload.get("user")), @@ -16296,7 +25158,7 @@ def _store_hanmac_aggregate_cache(cache_key: str, payload: dict[str, Any]) -> No ), { "cache_key": cache_key, - "payload_signature": cache_key, + "payload_signature": f"{HANMAC_AGGREGATE_SIGNATURE_PREFIX}{cache_key}", "payload_json": json.dumps(payload, ensure_ascii=False), }, ) @@ -16324,7 +25186,7 @@ def _store_hanmac_aggregate_cache(cache_key: str, payload: dict[str, Any]) -> No ), { "cache_key": cache_key, - "payload_signature": cache_key, + "payload_signature": f"{HANMAC_AGGREGATE_SIGNATURE_PREFIX}{cache_key}", "view_mode": normalize_text(payload.get("view")), "employment_filter": normalize_text(payload.get("employment")), "start_date": str(payload.get("start_date") or ""), @@ -16464,7 +25326,7 @@ def _hanmac_export_payload_signature(payload: dict[str, Any]) -> str: def _hanmac_aggregate_export_payload_signature(payload: dict[str, Any]) -> str: normalized = { - "logic_version": "hanmac-aggregate-v16-grade-code-map", + "logic_version": HANMAC_AGGREGATE_LOGIC_VERSION, "host": normalize_text(payload.get("host")), "port": normalize_text(payload.get("port")), "user": normalize_text(payload.get("user")), @@ -17042,6 +25904,8 @@ def _create_system_job( if not normalized_page or not normalized_type: raise ValueError("작업 페이지와 작업 종류가 필요합니다.") params = dict(params or {}) + params_json = json.dumps(params, ensure_ascii=False, sort_keys=True) + dedupe_by_params = normalized_page == "cost_analysis" _cleanup_stale_system_jobs("job creation") job_id = "" for attempt in range(12): @@ -17056,6 +25920,7 @@ def _create_system_job( AND job_type = :job_type AND COALESCE(start_year, -1) = COALESCE(:start_year, -1) AND COALESCE(end_year, -1) = COALESCE(:end_year, -1) + AND (:dedupe_by_params = 0 OR params_json = :params_json) AND status IN ('queued', 'running') ORDER BY created_at DESC LIMIT 1 @@ -17066,6 +25931,8 @@ def _create_system_job( "job_type": normalized_type, "start_year": start_year, "end_year": end_year, + "dedupe_by_params": 1 if dedupe_by_params else 0, + "params_json": params_json, }, ).mappings().first() if existing: @@ -17094,7 +25961,7 @@ def _create_system_job( "job_type": normalized_type, "start_year": start_year, "end_year": end_year, - "params_json": json.dumps(params, ensure_ascii=False), + "params_json": params_json, "message": "작업 대기 중입니다.", }, ) @@ -17844,6 +26711,37 @@ def _run_projects_bootstrap_job(job: dict[str, Any]) -> dict[str, Any]: } +def _run_cost_analysis_payload_job(job: dict[str, Any]) -> dict[str, Any]: + job_id = str(job.get("id") or "") + params = job.get("params") if isinstance(job.get("params"), dict) else {} + start_date = normalize_text(params.get("start_date")) + end_date = normalize_text(params.get("end_date")) + mode = normalize_text(params.get("mode")) or "individual" + force = bool(params.get("force")) + codes = sorted( + { + normalize_text(code).upper() + for code in (params.get("codes") or []) + if normalize_text(code) + } + ) + _update_system_job( + job_id, + message="프로젝트 손익분석 데이터를 계산 중입니다.", + progress_current=0, + progress_total=1, + ) + payload = _cost_analysis_build_payload(start_date, end_date, mode, force=force) + return { + "start_date": payload.get("start_date"), + "end_date": payload.get("end_date"), + "mode": payload.get("mode"), + "project_count": len(payload.get("rows") or []), + "requested_codes": codes, + "cache_info": payload.get("cache_info") or {}, + } + + def _run_hanmac_preview_cache_job(job: dict[str, Any]) -> dict[str, Any]: job_id = str(job.get("id") or "") params = job.get("params") if isinstance(job.get("params"), dict) else {} @@ -17899,6 +26797,8 @@ def _run_system_job(job: dict[str, Any]) -> None: result = _run_annual_summary_bootstrap_job(job) elif job_type == "projects_bootstrap": result = _run_projects_bootstrap_job(job) + elif job_type == "cost_analysis_payload": + result = _run_cost_analysis_payload_job(job) elif job_type == "hanmac_preview_cache": result = _run_hanmac_preview_cache_job(job) elif job_type == "hanmac_aggregate_cache": @@ -18017,16 +26917,41 @@ def get_hanmac_aggregate_summary(payload: dict[str, Any]) -> dict[str, Any]: "center_duplicate_member_count": 0, "center_same_member_hidden_count": 0, "center_excluded_member_count": 0, + "affiliate_schema_count": 0, + "affiliate_actual_work_excluded_count": 0, + "center_duplicate_retained_primary_work_count": 0, + "center_duplicate_excluded_no_primary_work_count": 0, + "company_review_member_count": 0, + "no_hanmac_work_review_member_count": 0, + "researcher_member_filtered_rows": 0, + "system_member_filtered_rows": 0, "center_restored_member_count": 0, "configured_holiday_count": len(configured_holiday_dates), "joint_absent_codes": {}, "joint_assignment_source_rows": 0, "joint_assignment_records": 0, "joint_assignment_code_matched_rows": 0, + "joint_assignment_state_matched_rows": 0, "joint_assignment_text_matched_rows": 0, "joint_assignment_regular_rows": 0, "joint_assignment_overtime_rows": 0, "joint_assignment_leave_skipped_days": 0, + "status_work_source_rows": 0, + "status_work_records": 0, + "status_work_duplicate_rows": 0, + "status_work_meeting_records": 0, + "status_work_supervision_site_records": 0, + "status_work_supervision_wait_records": 0, + "status_work_regular_rows": 0, + "status_work_activity_evidence_rows": 0, + "status_work_activity_only_regular_rows": 0, + "status_work_leave_skipped_days": 0, + "status_work_personal_meeting_skipped_rows": 0, + "supervision_priority_days": 0, + "supervision_overridden_regular_rows": 0, + "regular_exact_duplicate_rows_removed": 0, + "generated_baseline_overlap_rows_removed": 0, + "activity_evidence_duplicate_rows_removed": 0, } member_info, member_diagnostics = _hanmac_load_member_info(connection, schema_name, metadata) @@ -18034,25 +26959,30 @@ def get_hanmac_aggregate_summary(payload: dict[str, Any]) -> dict[str, Any]: _hanmac_normalize_member_token(member_no): record for member_no, record in member_info.items() } + primary_regular_member_keys = _hanmac_load_member_work_keys_for_period( + connection, + schema_name, + metadata, + start_date, + end_date, + ) source_diagnostics["member_columns"] = member_diagnostics.get("member_columns", []) source_diagnostics["member_name_col"] = member_diagnostics.get("member_name_col", "") source_diagnostics["member_name_fallback_rows"] = member_diagnostics.get("member_name_fallback_rows", 0) - for diagnostics_key in ("member_group_col", "member_grade_col", "member_grade_code_map_rows", "dept_source_table", "dept_code_col", "dept_name_col", "dept_mapped_rows"): + for diagnostics_key in ("member_group_col", "member_grade_col", "member_company_col", "member_work_company_col", "member_grade_code_map_rows", "dept_source_table", "dept_code_col", "dept_name_col", "dept_mapped_rows"): source_diagnostics[diagnostics_key] = member_diagnostics.get(diagnostics_key, "") center_metadata: dict[str, list[str]] = {} center_member_info: dict[str, dict[str, Any]] = {} center_member_rows_by_key: dict[str, dict[str, Any]] = {} center_excluded_member_nos: set[str] = set() - try: - center_metadata = _hanmac_fetch_table_columns(connection, HANMAC_CENTER_MANHOUR_SCHEMA) - center_member_info, _center_diagnostics = _hanmac_load_member_info(connection, HANMAC_CENTER_MANHOUR_SCHEMA, center_metadata) - source_diagnostics["center_schema_available"] = bool(center_metadata) - except Exception as exc: - logger.info("baron_manhour center member lookup skipped: %s", exc) - center_metadata = {} - center_member_info = {} - + affiliate_work_excluded_member_keys: set[str] = set() + affiliate_work_reasons_by_member_key: dict[str, list[str]] = {} + affiliate_membership_reasons_by_member_key: dict[str, list[str]] = {} + member_review_notes_by_key: dict[str, list[str]] = {} + center_regular_member_keys: set[str] = set() + affiliate_schema_names = _hanmac_discover_affiliate_manhour_schemas(connection, schema_name) + source_diagnostics["affiliate_schema_count"] = len(affiliate_schema_names) primary_member_nos = set(member_info_by_key) primary_member_no_by_key = { _hanmac_normalize_member_token(member_no): member_no @@ -18063,6 +26993,69 @@ def get_hanmac_aggregate_summary(payload: dict[str, Any]) -> dict[str, Any]: for member_no, record in member_info.items() if _hanmac_normalize_person_name(record.get("member_name")) } + for affiliate_schema in affiliate_schema_names: + try: + affiliate_metadata = _hanmac_fetch_table_columns(connection, affiliate_schema) + affiliate_member_info, _affiliate_diagnostics = _hanmac_load_member_info(connection, affiliate_schema, affiliate_metadata) + affiliate_regular_member_keys = _hanmac_load_member_work_keys_for_period( + connection, + affiliate_schema, + affiliate_metadata, + start_date, + end_date, + ) + except Exception as exc: + logger.info("affiliate manhour lookup skipped(%s): %s", affiliate_schema, exc) + continue + affiliate_label = _hanmac_affiliate_schema_label(affiliate_schema) + if affiliate_schema == HANMAC_CENTER_MANHOUR_SCHEMA: + center_metadata = affiliate_metadata + center_member_info = affiliate_member_info + center_regular_member_keys = affiliate_regular_member_keys + source_diagnostics["center_schema_available"] = bool(center_metadata) + for affiliate_no, affiliate_record in affiliate_member_info.items(): + affiliate_key = _hanmac_normalize_member_token(affiliate_no) + affiliate_name_key = _hanmac_normalize_person_name(affiliate_record.get("member_name")) + matched_member_no = "" + if affiliate_key and affiliate_key in primary_member_nos: + matched_member_no = primary_member_no_by_key[affiliate_key] + elif affiliate_name_key and affiliate_name_key in primary_member_names: + matched_member_no = primary_member_names[affiliate_name_key] + if not matched_member_no: + continue + member_key = _hanmac_normalize_member_token(matched_member_no) + if member_key in restored_center_member_keys or affiliate_key in restored_center_member_keys: + continue + if _hanmac_member_is_active_for_period(affiliate_record, start_date, end_date): + reason = f"{affiliate_label} 소속 등록 확인" + if reason not in affiliate_membership_reasons_by_member_key.setdefault(member_key, []): + affiliate_membership_reasons_by_member_key[member_key].append(reason) + if affiliate_key not in affiliate_regular_member_keys: + continue + reason = f"{affiliate_label} 실제근무 확인" + if reason not in affiliate_work_reasons_by_member_key.setdefault(member_key, []): + affiliate_work_reasons_by_member_key[member_key].append(reason) + if member_key in primary_regular_member_keys: + member_review_notes_by_key.setdefault(member_key, []).append(f"{affiliate_label} 실제근무도 존재") + continue + affiliate_work_excluded_member_keys.add(member_key) + try: + if not center_metadata: + center_metadata = _hanmac_fetch_table_columns(connection, HANMAC_CENTER_MANHOUR_SCHEMA) + center_member_info, _center_diagnostics = _hanmac_load_member_info(connection, HANMAC_CENTER_MANHOUR_SCHEMA, center_metadata) + center_regular_member_keys = _hanmac_load_member_work_keys_for_period( + connection, + HANMAC_CENTER_MANHOUR_SCHEMA, + center_metadata, + start_date, + end_date, + ) + source_diagnostics["center_schema_available"] = bool(center_metadata) + except Exception as exc: + logger.info("baron_manhour center member lookup skipped: %s", exc) + center_metadata = {} + center_member_info = {} + center_regular_member_keys = set() for center_no, center_record in sorted(center_member_info.items(), key=lambda item: (item[1].get("member_name") or "", item[0])): center_no_key = _hanmac_normalize_member_token(center_no) center_name_key = _hanmac_normalize_person_name(center_record.get("member_name")) @@ -18096,8 +27089,22 @@ def get_hanmac_aggregate_summary(payload: dict[str, Any]) -> dict[str, Any]: restored = center_no_key in restored_center_member_keys or member_key in restored_center_member_keys if center_no_key == member_key: source_diagnostics["center_same_member_hidden_count"] += 1 - if not restored: + center_active = _hanmac_member_is_active_for_period(center_record, start_date, end_date) + has_primary_regular_work = member_key in primary_regular_member_keys + has_center_regular_work = center_no_key in center_regular_member_keys + should_exclude_center_duplicate = member_key in affiliate_work_excluded_member_keys + if should_exclude_center_duplicate: center_excluded_member_nos.add(member_key) + source_diagnostics["center_duplicate_excluded_no_primary_work_count"] += 1 + elif matched_member_no and center_active and not restored: + source_diagnostics["center_duplicate_retained_primary_work_count"] += 1 + center_status = ( + "복구" + if restored + else " / ".join(affiliate_work_reasons_by_member_key.get(member_key, [])) + " 제외" + if should_exclude_center_duplicate + else "한맥근무 유지" + ) display_key = f"member:{member_key}" existing_center_row = center_member_rows_by_key.get(display_key) if existing_center_row: @@ -18105,7 +27112,7 @@ def get_hanmac_aggregate_summary(payload: dict[str, Any]) -> dict[str, Any]: existing_center_row["center_member_nos"].append(center_no) existing_center_row["center_member_no"] = ", ".join(existing_center_row["center_member_nos"]) existing_center_row["restored"] = bool(existing_center_row["restored"] or restored) - existing_center_row["status"] = "복구" if existing_center_row["restored"] else "기본 제외" + existing_center_row["status"] = "복구" if existing_center_row["restored"] else center_status if existing_center_row["matched_by"] != matched_by: existing_center_row["matched_by"] = "사번/이름" continue @@ -18117,7 +27124,7 @@ def get_hanmac_aggregate_summary(payload: dict[str, Any]) -> dict[str, Any]: "dept_name": center_record.get("dept_name") or "", "entry_date": center_record["entry_date"].isoformat() if center_record.get("entry_date") else "", "leave_date": center_record["leave_date"].isoformat() if center_record.get("leave_date") else "", - "status": "복구" if restored else "기본 제외", + "status": center_status, "matched_by": matched_by, "source_schema": HANMAC_CENTER_MANHOUR_SCHEMA, "table_label": "센터/총괄", @@ -18130,7 +27137,9 @@ def get_hanmac_aggregate_summary(payload: dict[str, Any]) -> dict[str, Any]: ) source_diagnostics["center_member_count"] = len(center_member_info) source_diagnostics["center_duplicate_member_count"] = len(center_member_rows) + center_excluded_member_nos.update(affiliate_work_excluded_member_keys) source_diagnostics["center_excluded_member_count"] = len(center_excluded_member_nos) + source_diagnostics["affiliate_actual_work_excluded_count"] = len(affiliate_work_excluded_member_keys) source_diagnostics["center_restored_member_count"] = sum(1 for item in center_member_rows if item.get("restored")) project_map: dict[str, str] = {} @@ -18176,6 +27185,14 @@ def get_hanmac_aggregate_summary(payload: dict[str, Any]) -> dict[str, Any]: end_date, ) source_diagnostics.update(joint_assignment_diagnostics) + status_work_records, status_work_diagnostics = _hanmac_load_status_work_records( + connection, + schema_name, + metadata, + start_date, + end_date, + ) + source_diagnostics.update(status_work_diagnostics) regular_tables = [ table_name @@ -18246,7 +27263,11 @@ def get_hanmac_aggregate_summary(payload: dict[str, Any]) -> dict[str, Any]: holiday_time_hours = _hanmac_parse_duration_hours(row.get("holiday_time")) calculated_regular_hours = _hanmac_calculate_regular_hours(row.get("entry_time"), row.get("leave_time")) regular_hours = calculated_regular_hours if calculated_regular_hours > 0 else work_time_hours - official_overtime_hours = _hanmac_parse_duration_hours(row.get("overtime_time")) + official_overtime_hours, official_overtime_source = _hanmac_calculate_official_overtime_hours( + row.get("entry_time"), + row.get("overtime_time"), + row.get("leave_time"), + ) if holiday_time_hours > 0: source_diagnostics["holiday_time_rows"] += 1 source_diagnostics["holiday_time_hours"] = round(source_diagnostics["holiday_time_hours"] + holiday_time_hours, 2) @@ -18269,7 +27290,7 @@ def get_hanmac_aggregate_summary(payload: dict[str, Any]) -> dict[str, Any]: "project_code": normalize_text(row.get("project_code")) or "", "work_date": _hanmac_parse_date_value(row.get("entry_time")), "overtime_hours": round(official_overtime_hours, 2), - "source": f"{table_name}.{overtime_time_col}", + "source": f"{table_name}.{overtime_time_col}:{official_overtime_source}", } ) source_diagnostics["official_overtime_rows"] += 1 @@ -18390,6 +27411,7 @@ def get_hanmac_aggregate_summary(payload: dict[str, Any]) -> dict[str, Any]: tardy_date_col = leave_profile["date_col"] tardy_end_date_col = leave_profile["end_date_col"] tardy_project_col = leave_profile["project_col"] + tardy_state_col = leave_profile.get("state_col") tardy_value_col = leave_profile["value_col"] tardy_hour_col = leave_profile["hour_col"] tardy_min_col = leave_profile["min_col"] @@ -18412,6 +27434,7 @@ def get_hanmac_aggregate_summary(payload: dict[str, Any]) -> dict[str, Any]: {_hanmac_build_select_alias(tardy_date_col, "work_date")}, {_hanmac_build_select_alias(tardy_end_date_col, "end_date")}, {_hanmac_build_select_alias(tardy_project_col, "project_code")}, + {_hanmac_build_select_alias(tardy_state_col, "state_code")}, {_hanmac_build_text_concat_alias(leave_profile["type_cols"], "leave_type")}, {_hanmac_build_select_alias(tardy_value_col, "leave_value")}, {_hanmac_build_select_alias(tardy_hour_col, "leave_hour")}, @@ -18433,7 +27456,10 @@ def get_hanmac_aggregate_summary(payload: dict[str, Any]) -> dict[str, Any]: source_diagnostics["leave_flexible_work_excluded_rows"] += 1 leave_source_stat["flexible_work_excluded_rows"] += 1 continue - leave_rule = _hanmac_match_leave_rule(leave_type, leave_rules) + if leave_table.lower() == "userstate_tbl": + leave_rule = _hanmac_userstate_leave_rule(row.get("state_code"), leave_type) + else: + leave_rule = _hanmac_match_leave_rule(leave_type, leave_rules) if not leave_rule: continue source_diagnostics["leave_matched_rows"] += 1 @@ -18486,19 +27512,28 @@ def get_hanmac_aggregate_summary(payload: dict[str, Any]) -> dict[str, Any]: project_relation_maps = _hanmac_build_project_code_relation_maps(project_code_alias_groups) project_canonical_map: dict[str, str] = project_relation_maps["canonical_map"] project_equivalent_code_map: dict[str, set[str]] = project_relation_maps["equivalent_code_map"] + source_diagnostics["project_alias_excluded_common_codes"] = project_relation_maps["excluded_common_codes"] + source_diagnostics["project_alias_excluded_shared_codes"] = project_relation_maps["excluded_shared_alias_codes"] + project_map.setdefault("H00-대기-01", "감리대기") def canonical_project_code(project_code: Any) -> str: normalized_code = normalize_text(project_code) + if not normalized_code or normalized_code.upper() in {"0", "ZZZZZZ"}: + return "ZZZZZZ" return project_canonical_map.get(normalized_code, normalized_code) def project_display_name(project_code: Any) -> str: normalized_code = normalize_text(project_code) canonical_code = canonical_project_code(normalized_code) + if canonical_code == "ZZZZZZ": + return "공통/미지정" + if canonical_code == "H00-합사-01": + return "합사" return ( project_map.get(canonical_code) or project_map.get(normalized_code) or canonical_code - or "(미지정)" + or "공통/미지정" ) def equivalent_project_codes(project_code: Any) -> list[str]: @@ -18509,10 +27544,63 @@ def get_hanmac_aggregate_summary(payload: dict[str, Any]) -> dict[str, Any]: if code and code != canonical_code ) + def project_classification(project_code: Any, raw_project_code: Any = "", source_label: Any = "") -> dict[str, Any]: + canonical_code = canonical_project_code(project_code) + raw_code = normalize_text(raw_project_code) + source_text = normalize_text(source_label) + project_name = project_display_name(canonical_code) + equivalent_codes = {code.upper() for code in equivalent_project_codes(canonical_code)} + code_tokens = { + normalize_text(canonical_code).upper(), + raw_code.upper(), + *equivalent_codes, + } + text_blob = f"{canonical_code} {raw_code} {project_name} {' '.join(sorted(equivalent_codes))}".upper() + if canonical_code in {"", "ZZZZZZ"}: + return {"category": "common", "label": "공통/미지정", "allocable": False, "compensation_candidate": False} + if canonical_code == "H00-대기-01" or source_text == "감리대기": + return {"category": "supervision_wait", "label": "감리대기", "allocable": True, "compensation_candidate": False} + if canonical_code == "H00-합사-01": + return {"category": "common", "label": "합사 발령", "allocable": False, "compensation_candidate": False} + if ( + "HV009111" in code_tokens + or "HV-00-간접-11" in code_tokens + or canonical_code == "HXX-고문-02" + or "HV009109" in code_tokens + or "HV-00-간접-09" in code_tokens + or canonical_code == "HXX-영업-01" + ): + return {"category": "indirect_sales", "label": "영업/고문 간접", "allocable": False, "compensation_candidate": False} + if "HV009110" in code_tokens or canonical_code == "HXX-교휴-06": + return {"category": "common", "label": "기타/교휴 활동", "allocable": False, "compensation_candidate": False} + if any(token in text_blob for token in ("제안", "PQ", "입찰", "수주", "검토프로젝트")): + return {"category": "pre_sales", "label": "사전사업/제안", "allocable": False, "compensation_candidate": False} + if "회의중" in source_text and not normalize_text(project_code): + return {"category": "activity_only", "label": "회의/활동근거", "allocable": False, "compensation_candidate": False} + return {"category": "actual_project", "label": "실제 수행 프로젝트", "allocable": True, "compensation_candidate": True} + + def normalize_joint_assignment_project_code(project_code: Any, raw_project_code: Any, source_label: Any) -> str: + canonical_code = canonical_project_code(project_code) + raw_code = normalize_text(raw_project_code).upper() + source_text = normalize_text(source_label) + code_tokens = { + normalize_text(canonical_code).upper(), + raw_code, + *(code.upper() for code in equivalent_project_codes(canonical_code)), + } + if source_text == "합사" and ( + canonical_code == "HXX-교휴-06" + or "HV009110" in code_tokens + or "HXX-교휴-06" in code_tokens + ): + return "H00-합사-01" + return canonical_code + today = date.today() member_aggregates: dict[str, dict[str, Any]] = {} project_aggregates: dict[str, dict[str, Any]] = {} - leave_days_by_member_date: dict[tuple[str, date], float] = {} + leave_hours_by_member_date: dict[tuple[str, date], float] = {} + recognized_regular_hours_by_member_date: dict[tuple[str, date], float] = {} def get_member_record(member_no: str) -> dict[str, Any]: return member_info.get(member_no) or member_info_by_key.get(_hanmac_normalize_member_token(member_no)) or { @@ -18531,10 +27619,114 @@ def get_hanmac_aggregate_summary(payload: dict[str, Any]) -> dict[str, Any]: source_diagnostics["canonical_member_no_rows"] += 1 return canonical_no + affiliate_name_overrides = { + _hanmac_normalize_person_name(name): label + for name, label in { + "신현우": "바론컨설턴트", + "김소연": "바론컨설턴트", + "문수혁": "삼안", + "양규순": "삼안", + "신동호": "삼안", + "이용운": "삼안", + "전미현": "한라산업개발", + "김현지": "바론컨설턴트", + "유지원": "바론컨설턴트", + "장종찬": "바론컨설턴트", + "정태원": "바론컨설턴트", + "양병홍": "바론컨설턴트", + "한형관": "명시 제외", + }.items() + } + + company_label_by_code = { + "BARON": "바론컨설턴트", + "SAMAN": "삼안", + "SAMAHN": "삼안", + "JANGHEON": "장헌산업", + "PTC": "피티씨", + "HALLA": "한라산업개발", + "HALLASAN": "한라산업개발", + "ETC": "기타/공용", + } + + def population_detail(member_record: Mapping[str, Any], reason: str, decision: str = "소속검토필요") -> dict[str, Any]: + return { + "decision": decision, + "reason": reason, + "member_no": normalize_text(member_record.get("member_no")), + "member_name": normalize_text(member_record.get("member_name")), + "company": normalize_text(member_record.get("company")), + "work_company": normalize_text(member_record.get("work_company")), + "dept_name": normalize_text(member_record.get("dept_name")), + } + + population_decision_cache: dict[str, dict[str, Any]] = {} + population_excluded_member_keys: set[str] = set() + + def classify_population_member(member_no: str) -> dict[str, Any]: + member_key = _hanmac_normalize_member_token(member_no) + cached = population_decision_cache.get(member_key) + if cached is not None: + return cached + member_record = get_member_record(member_no) + company_code = _hanmac_company_code(member_record.get("company")) + work_company_code = _hanmac_company_code(member_record.get("work_company")) + company_codes = [code for code in (company_code, work_company_code) if code] + non_hanmac_company_codes = [ + code + for code in company_codes + if not _hanmac_company_is_hanmac(code) + ] + explicit_hanmac_company = any(_hanmac_company_is_hanmac(code) for code in company_codes) + name_key = _hanmac_normalize_person_name(member_record.get("member_name")) + explicit_affiliate_label = affiliate_name_overrides.get(name_key) + affiliate_reasons = list(affiliate_work_reasons_by_member_key.get(member_key, [])) + affiliate_membership_reasons = list(affiliate_membership_reasons_by_member_key.get(member_key, [])) + details: list[dict[str, Any]] = [] + + if _hanmac_is_system_member_record(member_record): + details.append(population_detail(member_record, "사람 이름이 아닌 조직/관리자/공용 계정", "관리자제외")) + decision = {"include": False, "label": "관리자제외", "details": details} + population_decision_cache[member_key] = decision + return decision + + if explicit_affiliate_label: + details.append(population_detail(member_record, f"{explicit_affiliate_label} 소속 명시 규칙", "계열사제외")) + for code in non_hanmac_company_codes: + label = company_label_by_code.get(code, code) + details.append(population_detail(member_record, f"현재 소속 코드가 한맥이 아님({label})", "계열사제외")) + for reason in affiliate_membership_reasons: + details.append(population_detail(member_record, reason, "계열사소속확인")) + for reason in affiliate_reasons: + details.append(population_detail(member_record, reason, "계열사근무확인")) + + has_affiliate_evidence = bool(explicit_affiliate_label or non_hanmac_company_codes or affiliate_membership_reasons or affiliate_reasons) + has_current_non_hanmac_company = bool(explicit_affiliate_label or non_hanmac_company_codes or affiliate_membership_reasons) + if has_current_non_hanmac_company: + decision = {"include": False, "label": "계열사제외", "details": details} + elif affiliate_reasons and not explicit_hanmac_company: + decision = {"include": False, "label": "계열사제외", "details": details} + elif has_affiliate_evidence: + decision = {"include": True, "label": "소속검토필요", "details": details} + else: + decision = {"include": True, "label": "", "details": []} + population_decision_cache[member_key] = decision + return decision + def include_member(member_no: str) -> bool: member_record = get_member_record(member_no) if _hanmac_normalize_member_token(member_no) in center_excluded_member_nos: return False + if _hanmac_is_researcher_grade(member_record.get("member_grade")): + source_diagnostics["researcher_member_filtered_rows"] += 1 + return False + population_decision = classify_population_member(member_no) + if population_decision.get("label") == "관리자제외": + source_diagnostics["system_member_filtered_rows"] += 1 + return False + if not population_decision.get("include", True): + population_excluded_member_keys.add(_hanmac_normalize_member_token(member_no)) + return False return _hanmac_member_matches_filter(member_record, employment_filter, start_date, end_date, today) joint_member_map: dict[str, dict[str, Any]] = {} @@ -18595,9 +27787,13 @@ def get_hanmac_aggregate_summary(payload: dict[str, Any]) -> dict[str, Any]: continue if work_date: key = (member_no, work_date) - leave_days_by_member_date[key] = round( - leave_days_by_member_date.get(key, 0.0) + row["leave_days"], - 2, + leave_hours_by_member_date[key] = round( + min( + 8.0, + leave_hours_by_member_date.get(key, 0.0) + + max(0.0, _hanmac_parse_float_value(row.get("leave_hours"))), + ), + 4, ) for record in joint_assignment_records: @@ -18616,8 +27812,8 @@ def get_hanmac_aggregate_summary(payload: dict[str, Any]) -> dict[str, Any]: continue if not _hanmac_member_is_active_on(member_record, work_date): continue - leave_days = min(1.0, max(0.0, leave_days_by_member_date.get((member_no, work_date), 0.0))) - regular_hours = round(max(0.0, 8.0 * (1.0 - leave_days)), 4) + leave_hours = min(8.0, max(0.0, leave_hours_by_member_date.get((member_no, work_date), 0.0))) + regular_hours = round(max(0.0, 8.0 - leave_hours), 4) if regular_hours <= 0: source_diagnostics["joint_assignment_leave_skipped_days"] += 1 continue @@ -18637,15 +27833,16 @@ def get_hanmac_aggregate_summary(payload: dict[str, Any]) -> dict[str, Any]: } ) source_diagnostics["joint_assignment_regular_rows"] += 1 - if leave_days <= 0: + joint_overtime_hours = _hanmac_cap_weekday_overtime(3.0 if leave_hours <= 0 else 0.0) + if joint_overtime_hours > 0: overtime_records.append( { "member_no": member_no, "project_code": normalize_text(record.get("project_code")) or "", "work_date": work_date, - "overtime_hours": 3.0, + "overtime_hours": joint_overtime_hours, "source": "합사", - "raw_overtime_hours": 3.0, + "raw_overtime_hours": joint_overtime_hours, "joint_label": record.get("joint_label") or "합사", "joint_code": record.get("joint_code") or "", "note": record.get("note") or "", @@ -18653,8 +27850,77 @@ def get_hanmac_aggregate_summary(payload: dict[str, Any]) -> dict[str, Any]: ) source_diagnostics["joint_assignment_overtime_rows"] += 1 + baseline_regular_day_keys = { + (canonical_member_no(row["member_no"]), row.get("work_date")) + for row in regular_records + if row.get("work_date") + and ( + _hanmac_parse_float_value(row.get("regular_hours")) > 0 + or _hanmac_parse_float_value(row.get("holiday_hours")) > 0 + ) + } + for record in status_work_records: + member_no = canonical_member_no(record["member_no"]) + if not include_member(member_no): + continue + member_record = get_member_record(member_no) + record_start = record.get("start_date") + record_end = record.get("end_date") or record_start + if not record_start or not record_end: + continue + if record_end < record_start: + record_start, record_end = record_end, record_start + for work_date in _hanmac_iter_dates(record_start, record_end): + if work_date.weekday() >= 5 or work_date in configured_holiday_dates: + continue + if not _hanmac_member_is_active_on(member_record, work_date): + continue + leave_hours = min(8.0, max(0.0, leave_hours_by_member_date.get((member_no, work_date), 0.0))) + regular_hours = round(max(0.0, 8.0 - leave_hours), 4) + if regular_hours <= 0: + source_diagnostics["status_work_leave_skipped_days"] += 1 + continue + state_code = normalize_text(record.get("state_code")).zfill(2) + is_activity_evidence = state_code == "03" and (member_no, work_date) in baseline_regular_day_keys + applied_regular_hours = 0.0 if is_activity_evidence else regular_hours + regular_records.append( + { + "member_no": member_no, + "project_code": "H00-대기-01" if state_code == "23" else normalize_text(record.get("project_code")) or "", + "raw_project_code": normalize_text(record.get("project_code")) or "", + "work_date": work_date, + "entry_time": None, + "leave_time": None, + "regular_hours": applied_regular_hours, + "holiday_hours": 0.0, + "source_label": record.get("source_label") or "상태근무", + "status_label": record.get("status_label") or "상태근무", + "state_code": state_code, + "record_role": "activity_evidence" if is_activity_evidence else "baseline", + "cost_weight": _hanmac_parse_float_value(record.get("cost_weight")) or 1.0, + "note": record.get("note") or "", + } + ) + if is_activity_evidence: + source_diagnostics["status_work_activity_evidence_rows"] += 1 + else: + baseline_regular_day_keys.add((member_no, work_date)) + source_diagnostics["status_work_regular_rows"] += 1 + if state_code == "03": + source_diagnostics["status_work_activity_only_regular_rows"] += 1 + def ensure_member_bucket(member_no: str) -> dict[str, Any]: member_record = get_member_record(member_no) + member_key = _hanmac_normalize_member_token(member_no) + review_notes = member_review_notes_by_key.setdefault(member_key, []) + population_decision = classify_population_member(member_no) + population_details = list(population_decision.get("details") or []) + for detail in population_details: + note = normalize_text(detail.get("reason")) + if note and note not in review_notes: + review_notes.append(note) + company_code = normalize_text(member_record.get("company")) + work_company_code = normalize_text(member_record.get("work_company")) return member_aggregates.setdefault( member_no, { @@ -18665,6 +27931,11 @@ def get_hanmac_aggregate_summary(payload: dict[str, Any]) -> dict[str, Any]: "leave_date": member_record["leave_date"].isoformat() if member_record["leave_date"] else "", "dept_name": member_record["dept_name"], "member_grade": member_record.get("member_grade", ""), + "population_review_notes": review_notes, + "population_review_label": population_decision.get("label") or "", + "population_review_details": population_details, + "company": company_code, + "work_company": work_company_code, "regular_hours": 0.0, "overtime_hours": 0.0, "holiday_hours": 0.0, @@ -18677,13 +27948,15 @@ def get_hanmac_aggregate_summary(payload: dict[str, Any]) -> dict[str, Any]: "holiday_details": [], "overtime_details": [], "leave_details": [], + "missing_regular_details": [], "multi_entry_days": 0, "multi_entry_details": [], + "overlap_type_counts": {}, }, ) def ensure_project_bucket(project_code: str) -> dict[str, Any]: - project_key = canonical_project_code(project_code) or "(미지정)" + project_key = canonical_project_code(project_code) return project_aggregates.setdefault( project_key, { @@ -18705,6 +27978,13 @@ def get_hanmac_aggregate_summary(payload: dict[str, Any]) -> dict[str, Any]: }, ) + supervision_day_keys = { + (canonical_member_no(row["member_no"]), row.get("work_date")) + for row in regular_records + if normalize_text(row.get("state_code")).zfill(2) == "22" and row.get("work_date") + } + source_diagnostics["supervision_priority_days"] = len(supervision_day_keys) + regular_day_groups: dict[tuple[str, date], dict[str, Any]] = {} for row in regular_records: member_no = canonical_member_no(row["member_no"]) @@ -18715,6 +27995,9 @@ def get_hanmac_aggregate_summary(payload: dict[str, Any]) -> dict[str, Any]: if not work_date or not _hanmac_member_is_active_on(member_record, work_date): continue key = (member_no, work_date) + if key in supervision_day_keys and normalize_text(row.get("state_code")).zfill(2) != "22": + source_diagnostics["supervision_overridden_regular_rows"] += 1 + continue day_group = regular_day_groups.setdefault( key, { @@ -18723,25 +28006,96 @@ def get_hanmac_aggregate_summary(payload: dict[str, Any]) -> dict[str, Any]: "project_hours": {}, "holiday_project_hours": {}, "project_source_labels": {}, + "project_raw_codes": {}, + "project_cost_weight_sums": {}, "entries": [], + "activity_evidence": [], + "baseline_signatures": set(), + "generated_baseline_keys": set(), + "activity_evidence_signatures": set(), "ordered_entries": [], }, ) - raw_project_code = row["project_code"] - project_code = canonical_project_code(raw_project_code) + raw_project_code = row.get("raw_project_code") or row["project_code"] + source_label = normalize_text(row.get("source_label")) + project_code = ( + "H00-대기-01" + if source_label == "감리대기" + else normalize_joint_assignment_project_code(row["project_code"], raw_project_code, source_label) + ) raw_hours = max(0.0, _hanmac_parse_float_value(row["regular_hours"])) raw_holiday_hours = max(0.0, _hanmac_parse_float_value(row.get("holiday_hours"))) - source_label = normalize_text(row.get("source_label")) + record_role = normalize_text(row.get("record_role")) or "baseline" + cost_weight = _hanmac_parse_float_value(row.get("cost_weight")) or 1.0 + if record_role == "activity_evidence": + evidence_signature = ( + project_code, + source_label, + normalize_text(row.get("note")), + ) + if evidence_signature in day_group["activity_evidence_signatures"]: + source_diagnostics["activity_evidence_duplicate_rows_removed"] += 1 + continue + day_group["activity_evidence_signatures"].add(evidence_signature) + day_group["activity_evidence"].append( + { + "project_code": project_code or "ZZZZZZ", + "project_name": project_display_name(project_code), + "raw_project_code": raw_project_code, + "equivalent_project_codes": equivalent_project_codes(project_code), + "project_classification": project_classification(project_code, raw_project_code, source_label), + "regular_hours": 0.0, + "holiday_hours": 0.0, + "source_label": source_label, + "note": normalize_text(row.get("note")), + "record_role": record_role, + } + ) + if source_label: + day_group["project_source_labels"].setdefault(project_code, set()).add(source_label) + continue + if source_label in {"감리현장", "감리대기", "합사"}: + generated_baseline_key = ( + project_code, + source_label, + normalize_text(row.get("state_code")).zfill(2), + ) + if generated_baseline_key in day_group["generated_baseline_keys"]: + source_diagnostics["generated_baseline_overlap_rows_removed"] += 1 + continue + day_group["generated_baseline_keys"].add(generated_baseline_key) + baseline_signature = ( + project_code, + source_label, + normalize_text(row.get("state_code")).zfill(2), + normalize_text(row.get("joint_code")), + normalize_text(row.get("note")), + row.get("entry_time"), + row.get("leave_time"), + round(raw_hours, 4), + round(raw_holiday_hours, 4), + ) + if baseline_signature in day_group["baseline_signatures"]: + source_diagnostics["regular_exact_duplicate_rows_removed"] += 1 + continue + day_group["baseline_signatures"].add(baseline_signature) day_group["project_hours"][project_code] = round( day_group["project_hours"].get(project_code, 0.0) + raw_hours, 4, ) + if raw_hours > 0: + day_group["project_cost_weight_sums"][project_code] = round( + day_group["project_cost_weight_sums"].get(project_code, 0.0) + (raw_hours * cost_weight), + 4, + ) day_group["holiday_project_hours"][project_code] = round( day_group["holiday_project_hours"].get(project_code, 0.0) + raw_holiday_hours, 4, ) if source_label: day_group["project_source_labels"].setdefault(project_code, set()).add(source_label) + if raw_project_code and raw_project_code != project_code: + day_group["project_raw_codes"].setdefault(project_code, set()).add(raw_project_code) day_group["entries"].append( { "project_code": project_code or "(미지정)", @@ -18751,6 +28105,8 @@ def get_hanmac_aggregate_summary(payload: dict[str, Any]) -> dict[str, Any]: "regular_hours": round(raw_hours, 2), "holiday_hours": round(raw_holiday_hours, 2), "source_label": source_label, + "record_role": record_role, + "cost_weight": round(cost_weight, 4), } ) day_group["ordered_entries"].append( @@ -18766,13 +28122,402 @@ def get_hanmac_aggregate_summary(payload: dict[str, Any]) -> dict[str, Any]: for (member_no, work_date), day_group in regular_day_groups.items(): member_bucket = ensure_member_bucket(member_no) + leave_hours = min(8.0, max(0.0, leave_hours_by_member_date.get((member_no, work_date), 0.0))) + leave_days = round(leave_hours / 8.0, 4) + is_configured_holiday = work_date in configured_holiday_dates + raw_holiday_total_hours_before_adjustment = round(sum(day_group["holiday_project_hours"].values()), 2) + is_weekend_or_holiday = ( + work_date.weekday() >= 5 + or is_configured_holiday + or raw_holiday_total_hours_before_adjustment > 0 + ) + weekday_cap = 0.0 if is_weekend_or_holiday else max(0.0, 8.0 - leave_hours) + generated_source_labels = {"감리현장", "감리대기", "합사"} + rebuilt_entries: list[dict[str, Any]] = [] + generated_suppressed_evidence: list[dict[str, Any]] = [] + project_real_hours: dict[str, float] = {} + for entry in day_group["entries"]: + entry_project_code = canonical_project_code(entry.get("project_code")) + entry_source_label = normalize_text(entry.get("source_label")) + entry_hours = max(0.0, _hanmac_parse_float_value(entry.get("regular_hours"))) + if entry_source_label not in generated_source_labels and entry_hours > 0: + project_real_hours[entry_project_code] = round( + project_real_hours.get(entry_project_code, 0.0) + entry_hours, + 4, + ) + + generated_topup_used_by_project: dict[str, float] = {} + for entry in day_group["entries"]: + entry_project_code = canonical_project_code(entry.get("project_code")) + entry_source_label = normalize_text(entry.get("source_label")) + entry_hours = max(0.0, _hanmac_parse_float_value(entry.get("regular_hours"))) + if entry_source_label not in generated_source_labels or entry_hours <= 0 or project_real_hours.get(entry_project_code, 0.0) <= 0: + rebuilt_entries.append(entry) + continue + already_used = generated_topup_used_by_project.get(entry_project_code, 0.0) + topup_capacity = max(0.0, weekday_cap - project_real_hours.get(entry_project_code, 0.0) - already_used) + topup_hours = round(min(entry_hours, topup_capacity), 4) + evidence_entry = { + "project_code": entry_project_code or "ZZZZZZ", + "project_name": project_display_name(entry_project_code), + "raw_project_code": normalize_text(entry.get("raw_project_code")), + "equivalent_project_codes": equivalent_project_codes(entry_project_code), + "project_classification": project_classification( + entry_project_code, + entry.get("raw_project_code"), + entry_source_label, + ), + "regular_hours": 0.0, + "holiday_hours": 0.0, + "source_label": entry_source_label, + "note": "실제근무행 우선 적용으로 상태근무 생성행은 근거로만 보관", + "record_role": "generated_evidence", + } + generated_suppressed_evidence.append(evidence_entry) + if topup_hours > 0: + topup_entry = { + **entry, + "regular_hours": round(topup_hours, 2), + "source_label": f"{entry_source_label} 보정", + "record_role": "generated_topup", + } + rebuilt_entries.append(topup_entry) + generated_topup_used_by_project[entry_project_code] = round(already_used + topup_hours, 4) + source_diagnostics["generated_status_topup_rows"] = ( + int(source_diagnostics.get("generated_status_topup_rows") or 0) + 1 + ) + source_diagnostics["generated_status_topup_hours"] = round( + float(source_diagnostics.get("generated_status_topup_hours") or 0.0) + topup_hours, + 2, + ) + else: + source_diagnostics["generated_status_suppressed_rows"] = ( + int(source_diagnostics.get("generated_status_suppressed_rows") or 0) + 1 + ) + source_diagnostics["generated_status_suppressed_hours"] = round( + float(source_diagnostics.get("generated_status_suppressed_hours") or 0.0) + entry_hours, + 2, + ) + if len(rebuilt_entries) != len(day_group["entries"]) or generated_topup_used_by_project: + day_group["entries"] = rebuilt_entries + day_group["project_hours"] = {} + day_group["holiday_project_hours"] = {} + day_group["project_source_labels"] = {} + day_group["project_raw_codes"] = {} + day_group["project_cost_weight_sums"] = {} + for entry in day_group["entries"]: + entry_project_code = canonical_project_code(entry.get("project_code")) + entry_source_label = normalize_text(entry.get("source_label")) + entry_hours = max(0.0, _hanmac_parse_float_value(entry.get("regular_hours"))) + entry_holiday_hours = max(0.0, _hanmac_parse_float_value(entry.get("holiday_hours"))) + entry_cost_weight = _hanmac_parse_float_value(entry.get("cost_weight")) or 1.0 + day_group["project_hours"][entry_project_code] = round( + day_group["project_hours"].get(entry_project_code, 0.0) + entry_hours, + 4, + ) + if entry_hours > 0: + day_group["project_cost_weight_sums"][entry_project_code] = round( + day_group["project_cost_weight_sums"].get(entry_project_code, 0.0) + (entry_hours * entry_cost_weight), + 4, + ) + day_group["holiday_project_hours"][entry_project_code] = round( + day_group["holiday_project_hours"].get(entry_project_code, 0.0) + entry_holiday_hours, + 4, + ) + if entry_source_label: + day_group["project_source_labels"].setdefault(entry_project_code, set()).add(entry_source_label) + raw_entry_code = normalize_text(entry.get("raw_project_code")) + if raw_entry_code and raw_entry_code != entry_project_code: + day_group["project_raw_codes"].setdefault(entry_project_code, set()).add(raw_entry_code) + day_group["activity_evidence"].extend(generated_suppressed_evidence) + positive_project_codes_for_fallback = { + project_code + for project_code, project_hours in day_group["project_hours"].items() + if project_hours > 0 + } + if len(positive_project_codes_for_fallback) == 1 and not is_weekend_or_holiday and leave_hours <= 0 and weekday_cap > 0: + fallback_project_code = next(iter(positive_project_codes_for_fallback)) + fallback_classification = project_classification(fallback_project_code) + actual_activity_evidence = [] + seen_activity_project_codes: set[str] = set() + for evidence in day_group["activity_evidence"]: + evidence_project_code = canonical_project_code(evidence.get("project_code")) + evidence_classification = evidence.get("project_classification") or project_classification( + evidence_project_code, + evidence.get("raw_project_code"), + evidence.get("source_label"), + ) + if ( + evidence_project_code != "ZZZZZZ" + and evidence_classification.get("category") == "actual_project" + and evidence_project_code not in seen_activity_project_codes + ): + actual_activity_evidence.append(evidence) + seen_activity_project_codes.add(evidence_project_code) + if fallback_classification.get("category") == "common" and actual_activity_evidence: + replaced_hours = round(day_group["project_hours"].get(fallback_project_code, 0.0), 4) + replacement_hours = round(min(replaced_hours, weekday_cap), 4) + retained_entries = [] + fallback_entries = [] + for entry in day_group["entries"]: + if canonical_project_code(entry.get("project_code")) == fallback_project_code: + fallback_entries.append(entry) + else: + retained_entries.append(entry) + if replacement_hours > 0 and fallback_entries: + for entry in fallback_entries: + day_group["activity_evidence"].append( + { + "project_code": fallback_project_code or "ZZZZZZ", + "project_name": project_display_name(fallback_project_code), + "raw_project_code": normalize_text(entry.get("raw_project_code")), + "equivalent_project_codes": equivalent_project_codes(fallback_project_code), + "project_classification": project_classification( + fallback_project_code, + entry.get("raw_project_code"), + entry.get("source_label"), + ), + "regular_hours": 0.0, + "holiday_hours": 0.0, + "source_label": normalize_text(entry.get("source_label")) or "상태근무", + "note": "실제 프로젝트 활동근거가 있어 합사/기타 생성행은 근거로만 보관", + "record_role": "nonallocable_fallback_evidence", + } + ) + allocated_replacement = _hanmac_allocate_recognized_hours( + replacement_hours, + {index: 1.0 for index, _evidence in enumerate(actual_activity_evidence)}, + ) + for index, evidence in enumerate(actual_activity_evidence): + evidence_project_code = canonical_project_code(evidence.get("project_code")) + evidence_hours = round(allocated_replacement.get(index, 0.0), 4) + if evidence_hours <= 0: + continue + raw_evidence_code = normalize_text(evidence.get("raw_project_code")) + retained_entries.append( + { + "project_code": evidence_project_code, + "project_name": project_display_name(evidence_project_code), + "raw_project_code": raw_evidence_code, + "equivalent_project_codes": equivalent_project_codes(evidence_project_code), + "regular_hours": round(evidence_hours, 2), + "holiday_hours": 0.0, + "source_label": "활동근거 대체", + "record_role": "activity_replacement", + "cost_weight": 1.0, + } + ) + day_group["entries"] = retained_entries + day_group["project_hours"] = {} + day_group["holiday_project_hours"] = {} + day_group["project_source_labels"] = {} + day_group["project_raw_codes"] = {} + day_group["project_cost_weight_sums"] = {} + for entry in day_group["entries"]: + entry_project_code = canonical_project_code(entry.get("project_code")) + entry_source_label = normalize_text(entry.get("source_label")) + entry_hours = max(0.0, _hanmac_parse_float_value(entry.get("regular_hours"))) + entry_holiday_hours = max(0.0, _hanmac_parse_float_value(entry.get("holiday_hours"))) + entry_cost_weight = _hanmac_parse_float_value(entry.get("cost_weight")) or 1.0 + day_group["project_hours"][entry_project_code] = round( + day_group["project_hours"].get(entry_project_code, 0.0) + entry_hours, + 4, + ) + if entry_hours > 0: + day_group["project_cost_weight_sums"][entry_project_code] = round( + day_group["project_cost_weight_sums"].get(entry_project_code, 0.0) + (entry_hours * entry_cost_weight), + 4, + ) + day_group["holiday_project_hours"][entry_project_code] = round( + day_group["holiday_project_hours"].get(entry_project_code, 0.0) + entry_holiday_hours, + 4, + ) + if entry_source_label: + day_group["project_source_labels"].setdefault(entry_project_code, set()).add(entry_source_label) + raw_entry_code = normalize_text(entry.get("raw_project_code")) + if raw_entry_code and raw_entry_code != entry_project_code: + day_group["project_raw_codes"].setdefault(entry_project_code, set()).add(raw_entry_code) + source_diagnostics["nonallocable_fallback_replaced_rows"] = ( + int(source_diagnostics.get("nonallocable_fallback_replaced_rows") or 0) + len(fallback_entries) + ) + source_diagnostics["nonallocable_fallback_replaced_hours"] = round( + float(source_diagnostics.get("nonallocable_fallback_replaced_hours") or 0.0) + replacement_hours, + 2, + ) + positive_project_codes_before_priority = { + project_code + for project_code, project_hours in day_group["project_hours"].items() + if project_hours > 0 + } + has_wait_project = "H00-대기-01" in positive_project_codes_before_priority + actual_project_codes = [ + project_code + for project_code in positive_project_codes_before_priority + if project_classification(project_code).get("category") == "actual_project" + ] + if has_wait_project and actual_project_codes: + removed_hours = day_group["project_hours"].pop("H00-대기-01", 0.0) + day_group["holiday_project_hours"].pop("H00-대기-01", None) + day_group["project_cost_weight_sums"].pop("H00-대기-01", None) + day_group["project_source_labels"].pop("H00-대기-01", None) + day_group["project_raw_codes"].pop("H00-대기-01", None) + day_group["entries"] = [ + entry + for entry in day_group["entries"] + if canonical_project_code(entry.get("project_code")) != "H00-대기-01" + ] + source_diagnostics["supervision_wait_replaced_by_project_rows"] = ( + int(source_diagnostics.get("supervision_wait_replaced_by_project_rows") or 0) + 1 + ) + source_diagnostics["supervision_wait_replaced_by_project_hours"] = round( + float(source_diagnostics.get("supervision_wait_replaced_by_project_hours") or 0.0) + removed_hours, + 2, + ) + elif has_wait_project: + retained_entries = [] + for entry in day_group["entries"]: + entry_project_code = canonical_project_code(entry.get("project_code")) + if entry_project_code == "H00-대기-01": + retained_entries.append(entry) + continue + entry_classification = project_classification(entry_project_code, entry.get("raw_project_code"), entry.get("source_label")) + if entry_classification.get("category") in {"pre_sales", "indirect_sales", "common", "activity_only"}: + removed_hours = day_group["project_hours"].pop(entry_project_code, 0.0) + day_group["holiday_project_hours"].pop(entry_project_code, None) + day_group["project_cost_weight_sums"].pop(entry_project_code, None) + day_group["project_source_labels"].pop(entry_project_code, None) + day_group["project_raw_codes"].pop(entry_project_code, None) + source_diagnostics["supervision_wait_retained_over_presales_rows"] = ( + int(source_diagnostics.get("supervision_wait_retained_over_presales_rows") or 0) + 1 + ) + source_diagnostics["supervision_wait_retained_over_presales_hours"] = round( + float(source_diagnostics.get("supervision_wait_retained_over_presales_hours") or 0.0) + removed_hours, + 2, + ) + continue + retained_entries.append(entry) + day_group["entries"] = retained_entries + positive_project_codes_after_wait = { + project_code + for project_code, project_hours in day_group["project_hours"].items() + if project_hours > 0 + } + allocable_positive_project_codes = { + project_code + for project_code in positive_project_codes_after_wait + if project_classification(project_code).get("allocable") + } + if allocable_positive_project_codes and len(positive_project_codes_after_wait) > len(allocable_positive_project_codes): + retained_entries = [] + for entry in day_group["entries"]: + entry_project_code = canonical_project_code(entry.get("project_code")) + entry_classification = project_classification(entry_project_code, entry.get("raw_project_code"), entry.get("source_label")) + if entry_project_code not in allocable_positive_project_codes and not entry_classification.get("allocable"): + removed_hours = day_group["project_hours"].pop(entry_project_code, 0.0) + day_group["holiday_project_hours"].pop(entry_project_code, None) + day_group["project_cost_weight_sums"].pop(entry_project_code, None) + day_group["project_source_labels"].pop(entry_project_code, None) + day_group["project_raw_codes"].pop(entry_project_code, None) + source_diagnostics["nonallocable_project_suppressed_rows"] = ( + int(source_diagnostics.get("nonallocable_project_suppressed_rows") or 0) + 1 + ) + source_diagnostics["nonallocable_project_suppressed_hours"] = round( + float(source_diagnostics.get("nonallocable_project_suppressed_hours") or 0.0) + removed_hours, + 2, + ) + continue + retained_entries.append(entry) + day_group["entries"] = retained_entries + positive_project_codes_after_allocable = { + project_code + for project_code, project_hours in day_group["project_hours"].items() + if project_hours > 0 + } + if len(positive_project_codes_after_allocable) > 1: + retained_entries = [] + for entry in day_group["entries"]: + entry_project_code = canonical_project_code(entry.get("project_code")) + entry_classification = project_classification(entry_project_code, entry.get("raw_project_code"), entry.get("source_label")) + if entry_classification.get("category") in {"common", "activity_only", "indirect_sales"}: + removed_hours = day_group["project_hours"].pop(entry_project_code, 0.0) + day_group["holiday_project_hours"].pop(entry_project_code, None) + day_group["project_cost_weight_sums"].pop(entry_project_code, None) + day_group["project_source_labels"].pop(entry_project_code, None) + day_group["project_raw_codes"].pop(entry_project_code, None) + source_diagnostics["nonallocable_project_suppressed_rows"] = ( + int(source_diagnostics.get("nonallocable_project_suppressed_rows") or 0) + 1 + ) + source_diagnostics["nonallocable_project_suppressed_hours"] = round( + float(source_diagnostics.get("nonallocable_project_suppressed_hours") or 0.0) + removed_hours, + 2, + ) + continue + retained_entries.append(entry) + day_group["entries"] = retained_entries raw_total_hours = round(sum(day_group["project_hours"].values()), 2) raw_holiday_total_hours = round(sum(day_group["holiday_project_hours"].values()), 2) - leave_days = min(1.0, max(0.0, leave_days_by_member_date.get((member_no, work_date), 0.0))) - is_configured_holiday = work_date in configured_holiday_dates is_weekend_or_holiday = work_date.weekday() >= 5 or is_configured_holiday or raw_holiday_total_hours > 0 - weekday_cap = 0.0 if is_weekend_or_holiday else max(0.0, 8.0 * (1.0 - leave_days)) - capped_regular_hours = _hanmac_floor_regular_hours(min(raw_total_hours, weekday_cap)) + weekday_cap = 0.0 if is_weekend_or_holiday else max(0.0, 8.0 - leave_hours) + if not is_weekend_or_holiday and leave_hours <= 0 and raw_total_hours < weekday_cap: + compensation_candidates: list[dict[str, Any]] = [] + for evidence in day_group["activity_evidence"]: + evidence_project_code = canonical_project_code(evidence.get("project_code")) + evidence_classification = evidence.get("project_classification") or project_classification( + evidence_project_code, + evidence.get("raw_project_code"), + evidence.get("source_label"), + ) + if evidence_classification.get("compensation_candidate") and evidence_project_code != "ZZZZZZ": + compensation_candidates.append(evidence) + if compensation_candidates: + missing_hours = round(max(0.0, weekday_cap - raw_total_hours), 4) + candidate_weights = { + index: 1.0 + for index, _evidence in enumerate(compensation_candidates) + } + allocated_compensation = _hanmac_allocate_recognized_hours(missing_hours, candidate_weights) + for index, evidence in enumerate(compensation_candidates): + compensation_hours = round(allocated_compensation.get(index, 0.0), 4) + if compensation_hours <= 0: + continue + evidence_project_code = canonical_project_code(evidence.get("project_code")) + day_group["project_hours"][evidence_project_code] = round( + day_group["project_hours"].get(evidence_project_code, 0.0) + compensation_hours, + 4, + ) + day_group["project_cost_weight_sums"][evidence_project_code] = round( + day_group["project_cost_weight_sums"].get(evidence_project_code, 0.0) + compensation_hours, + 4, + ) + day_group["project_source_labels"].setdefault(evidence_project_code, set()).add("회의근거 보정") + raw_evidence_code = normalize_text(evidence.get("raw_project_code")) + if raw_evidence_code and raw_evidence_code != evidence_project_code: + day_group["project_raw_codes"].setdefault(evidence_project_code, set()).add(raw_evidence_code) + day_group["entries"].append( + { + "project_code": evidence_project_code, + "project_name": project_display_name(evidence_project_code), + "raw_project_code": raw_evidence_code, + "equivalent_project_codes": equivalent_project_codes(evidence_project_code), + "regular_hours": round(compensation_hours, 2), + "holiday_hours": 0.0, + "source_label": "회의근거 보정", + "record_role": "compensation", + "cost_weight": 1.0, + } + ) + raw_total_hours = round(sum(day_group["project_hours"].values()), 2) + source_diagnostics["meeting_evidence_compensation_days"] = ( + int(source_diagnostics.get("meeting_evidence_compensation_days") or 0) + 1 + ) + source_diagnostics["meeting_evidence_compensation_hours"] = round( + float(source_diagnostics.get("meeting_evidence_compensation_hours") or 0.0) + missing_hours, + 2, + ) + if (member_no, work_date) in supervision_day_keys: + capped_regular_hours = round(min(raw_total_hours, weekday_cap), 2) + else: + capped_regular_hours = _hanmac_floor_regular_hours(min(raw_total_hours, weekday_cap)) holiday_source_total = raw_holiday_total_hours if raw_holiday_total_hours > 0 else (raw_total_hours if is_weekend_or_holiday else 0.0) holiday_hours = _hanmac_cap_holiday_hours(holiday_source_total) allocated_project_regular_hours = _hanmac_allocate_recognized_hours( @@ -18789,6 +28534,7 @@ def get_hanmac_aggregate_summary(payload: dict[str, Any]) -> dict[str, Any]: member_bucket["regular_hours"] += capped_regular_hours member_bucket["holiday_hours"] += holiday_hours + recognized_regular_hours_by_member_date[(member_no, work_date)] = round(capped_regular_hours, 4) if capped_regular_hours > 0: member_bucket["regular_work_days"] += 1 member_bucket["regular_details"].append( @@ -18797,20 +28543,32 @@ def get_hanmac_aggregate_summary(payload: dict[str, Any]) -> dict[str, Any]: "regular_hours": capped_regular_hours, "raw_total_hours": raw_total_hours, "leave_days": round(leave_days, 2), + "leave_hours": round(leave_hours, 2), "projects": sorted( [ { "project_code": project_code or "(미지정)", "project_name": project_display_name(project_code), "equivalent_project_codes": equivalent_project_codes(project_code), + "source_project_codes": sorted(day_group["project_raw_codes"].get(project_code, set())), "hours": round(project_hours, 2), "recognized_hours": round(allocated_project_regular_hours.get(project_code, 0.0), 2), "source_label": ", ".join(sorted(day_group["project_source_labels"].get(project_code, set()))), + "cost_weight": round( + day_group["project_cost_weight_sums"].get(project_code, project_hours) / project_hours + if project_hours > 0 + else 1.0, + 4, + ), } for project_code, project_hours in day_group["project_hours"].items() ], key=lambda item: (-item["hours"], item["project_code"]), ), + "activity_evidence": sorted( + day_group["activity_evidence"], + key=lambda item: (item["project_code"], item.get("source_label") or ""), + ), } ) if holiday_hours > 0: @@ -18843,7 +28601,59 @@ def get_hanmac_aggregate_summary(payload: dict[str, Any]) -> dict[str, Any]: if project_code: member_bucket["project_codes"].add(project_code) - if len(day_group["entries"]) > 1: + positive_project_codes = { + project_code + for project_code, project_hours in day_group["project_hours"].items() + if project_hours > 0 + } + evidence_project_codes = { + evidence_project_code + for entry in day_group["activity_evidence"] + if ( + (evidence_project_code := canonical_project_code(entry.get("project_code"))) != "ZZZZZZ" + and (entry.get("project_classification") or project_classification( + evidence_project_code, + entry.get("raw_project_code"), + entry.get("source_label"), + )).get("category") == "actual_project" + ) + } + conflicting_evidence_codes = evidence_project_codes - positive_project_codes + overlap_type = "" + if len(positive_project_codes) > 1: + overlap_type = "복수 프로젝트 배부" + elif conflicting_evidence_codes: + overlap_type = "실제 프로젝트 활동근거 검토" + else: + positive_entries = [ + entry + for entry in day_group["entries"] + if ( + float(entry.get("regular_hours") or 0.0) > 0 + or float(entry.get("holiday_hours") or 0.0) > 0 + ) + ] + positive_entry_project_codes = { + canonical_project_code(entry.get("project_code")) + for entry in positive_entries + } + if ( + len(positive_entry_project_codes) == 1 + and any(normalize_text(entry.get("record_role")) == "compensation" for entry in positive_entries) + ): + positive_entries = [] + if not overlap_type and len(positive_entries) > 1: + entry_signatures = { + ( + canonical_project_code(entry.get("project_code")), + normalize_text(entry.get("source_label")), + round(float(entry.get("regular_hours") or 0.0), 2), + ) + for entry in positive_entries + } + overlap_type = "원천 데이터 중복" if len(entry_signatures) < len(positive_entries) else "동일 프로젝트 근무기록 중첩" + + if overlap_type: collapsed_entries: dict[tuple[str, str, str], dict[str, Any]] = {} for entry in day_group["entries"]: collapse_key = ( @@ -18870,14 +28680,22 @@ def get_hanmac_aggregate_summary(payload: dict[str, Any]) -> dict[str, Any]: ) collapsed_entry["row_count"] += 1 member_bucket["multi_entry_days"] += 1 + member_bucket["overlap_type_counts"][overlap_type] = ( + member_bucket["overlap_type_counts"].get(overlap_type, 0) + 1 + ) member_bucket["multi_entry_details"].append( { "work_date": work_date.isoformat(), + "overlap_type": overlap_type, "row_count": len(day_group["entries"]), "display_row_count": len(collapsed_entries), "raw_total_hours": raw_total_hours, "capped_regular_hours": capped_regular_hours, "leave_days": round(leave_days, 2), + "activity_evidence": sorted( + day_group["activity_evidence"], + key=lambda item: (item["project_code"], item.get("source_label") or ""), + ), "entries": sorted( collapsed_entries.values(), key=lambda item: (-item["regular_hours"], item["project_code"]), @@ -18904,7 +28722,8 @@ def get_hanmac_aggregate_summary(payload: dict[str, Any]) -> dict[str, Any]: "raw_project_hours": project_hours, } ) - project_bucket["member_nos"].add(member_no) + if _hanmac_counts_as_member(member_record): + project_bucket["member_nos"].add(member_no) if is_weekend_or_holiday and raw_holiday_total_hours <= 0: continue @@ -18923,9 +28742,16 @@ def get_hanmac_aggregate_summary(payload: dict[str, Any]) -> dict[str, Any]: "raw_project_hours": round(project_hours, 2), "leave_days": round(leave_days, 2), "source_label": ", ".join(sorted(day_group["project_source_labels"].get(project_code, set()))), + "cost_weight": round( + day_group["project_cost_weight_sums"].get(project_code, project_hours) / project_hours + if project_hours > 0 + else 1.0, + 4, + ), } ) - project_bucket["member_nos"].add(member_no) + if _hanmac_counts_as_member(member_record): + project_bucket["member_nos"].add(member_no) overtime_day_groups: dict[tuple[str, date | None], dict[str, Any]] = {} for row in overtime_records: @@ -19073,7 +28899,8 @@ def get_hanmac_aggregate_summary(payload: dict[str, Any]) -> dict[str, Any]: "raw_holiday_hours": round(raw_overtime_hours, 2), } ) - project_bucket["member_nos"].add(member_no) + if _hanmac_counts_as_member(member_record): + project_bucket["member_nos"].add(member_no) for row in leave_records: member_no = canonical_member_no(row["member_no"]) @@ -19104,7 +28931,8 @@ def get_hanmac_aggregate_summary(payload: dict[str, Any]) -> dict[str, Any]: project_bucket = ensure_project_bucket(leave_project_code) project_bucket["legal_leave_days"] += row["leave_days"] project_bucket["legal_leave_hours"] += row.get("leave_hours", row["leave_days"] * 8.0) - project_bucket["member_nos"].add(member_no) + if _hanmac_counts_as_member(member_record): + project_bucket["member_nos"].add(member_no) project_bucket["leave_details"].append( { "work_date": row["work_date"].isoformat() if row.get("work_date") else "", @@ -19122,6 +28950,10 @@ def get_hanmac_aggregate_summary(payload: dict[str, Any]) -> dict[str, Any]: } ) + for member_no, member_record in member_info.items(): + if include_member(member_no) and _hanmac_member_is_active_for_period(member_record, start_date, end_date): + ensure_member_bucket(member_no) + for member_no, bucket in member_aggregates.items(): member_record = get_member_record(member_no) expected_regular_hours = _hanmac_expected_regular_hours_for_period( @@ -19130,11 +28962,84 @@ def get_hanmac_aggregate_summary(payload: dict[str, Any]) -> dict[str, Any]: end_date, configured_holiday_dates, ) - expected_after_leave_hours = round(max(0.0, expected_regular_hours - bucket["legal_leave_hours"]), 2) + entry_date = _hanmac_parse_date_value(member_record.get("entry_date")) + leave_date = _hanmac_parse_date_value(member_record.get("leave_date")) + effective_start = max(start_date, entry_date) if entry_date else start_date + effective_end = min(end_date, leave_date) if leave_date else end_date + expected_after_leave_hours = 0.0 + missing_regular_details: list[dict[str, Any]] = [] + if effective_end >= effective_start: + for work_date in _hanmac_iter_dates(effective_start, effective_end): + if work_date.weekday() >= 5 or work_date in configured_holiday_dates: + continue + leave_hours = min( + 8.0, + max(0.0, leave_hours_by_member_date.get((member_no, work_date), 0.0)), + ) + expected_day_hours = round(max(0.0, 8.0 - leave_hours), 2) + recognized_day_hours = round( + max(0.0, recognized_regular_hours_by_member_date.get((member_no, work_date), 0.0)), + 2, + ) + expected_after_leave_hours += expected_day_hours + missing_hours = round(max(0.0, expected_day_hours - recognized_day_hours), 2) + if missing_hours <= 0: + continue + missing_regular_details.append( + { + "work_date": work_date.isoformat(), + "expected_hours": expected_day_hours, + "recognized_hours": recognized_day_hours, + "leave_hours": round(leave_hours, 2), + "missing_hours": missing_hours, + "reason": "근무 근거 없음" if recognized_day_hours <= 0 else "부분 근무시간 부족", + } + ) + expected_after_leave_hours = round(expected_after_leave_hours, 2) regular_hour_gap = round(bucket["regular_hours"] - expected_after_leave_hours, 2) + missing_regular_hours = round(sum(item["missing_hours"] for item in missing_regular_details), 2) bucket["expected_regular_hours"] = expected_regular_hours bucket["expected_regular_after_leave_hours"] = expected_after_leave_hours bucket["regular_hour_gap"] = regular_hour_gap + bucket["missing_regular_hours"] = missing_regular_hours + bucket["missing_regular_days"] = round(missing_regular_hours / 8.0, 2) + bucket["missing_regular_date_count"] = len(missing_regular_details) + bucket["missing_regular_details"] = missing_regular_details + has_actual_hanmac_work = ( + bucket["regular_hours"] > 0 + or bucket["overtime_hours"] > 0 + or bucket["holiday_hours"] > 0 + or bool(bucket["regular_details"]) + or bool(bucket["overtime_details"]) + or bool(bucket["holiday_details"]) + ) + if not has_actual_hanmac_work: + note = "한맥 실제 근무기록 없음 · 소속 유지 가능성 검토" + if note not in bucket["population_review_notes"]: + bucket["population_review_notes"].append(note) + bucket["population_review_label"] = bucket.get("population_review_label") or "소속검토필요" + bucket.setdefault("population_review_details", []).append( + { + "decision": "소속검토필요", + "reason": note, + "member_no": bucket.get("member_no", ""), + "member_name": bucket.get("member_name", ""), + "company": bucket.get("company", ""), + "work_company": bucket.get("work_company", ""), + "dept_name": bucket.get("dept_name", ""), + } + ) + source_diagnostics["population_owner_excluded_member_count"] = len(population_excluded_member_keys) + source_diagnostics["company_review_member_count"] = sum( + 1 + for bucket in member_aggregates.values() + if any("현재 소속 코드" in note for note in (bucket.get("population_review_notes") or [])) + ) + source_diagnostics["no_hanmac_work_review_member_count"] = sum( + 1 + for bucket in member_aggregates.values() + if any("한맥 실제 근무기록 없음" in note for note in (bucket.get("population_review_notes") or [])) + ) if view_mode == "project": rows = [ @@ -19197,6 +29102,10 @@ def get_hanmac_aggregate_summary(payload: dict[str, Any]) -> dict[str, Any]: "leave_date": bucket["leave_date"], "dept_name": bucket["dept_name"], "member_grade": bucket.get("member_grade", ""), + "population_review": bucket.get("population_review_label", ""), + "population_review_notes": list(bucket.get("population_review_notes") or []), + "company": bucket.get("company", ""), + "work_company": bucket.get("work_company", ""), "regular_hours": round(bucket["regular_hours"], 2), "overtime_hours": round(bucket["overtime_hours"], 2), "holiday_hours": round(bucket["holiday_hours"], 2), @@ -19207,6 +29116,10 @@ def get_hanmac_aggregate_summary(payload: dict[str, Any]) -> dict[str, Any]: "legal_leave_hours": round(bucket["legal_leave_hours"], 2), "project_count": len(bucket["project_codes"]), "aggregate_details": { + "population_review": sorted( + bucket.get("population_review_details") or [], + key=lambda item: (item.get("decision") or "", item.get("reason") or ""), + ), "regular_hours": sorted(bucket["regular_details"], key=lambda item: item.get("work_date") or ""), "overtime_hours": sorted(bucket["overtime_details"], key=lambda item: item.get("work_date") or ""), "holiday_hours": sorted(bucket["holiday_details"], key=lambda item: item.get("work_date") or ""), @@ -19228,6 +29141,10 @@ def get_hanmac_aggregate_summary(payload: dict[str, Any]) -> dict[str, Any]: key=lambda item: (item.get("work_date") or "", item.get("detail_type") or ""), ), "legal_leave_days": sorted(bucket["leave_details"], key=lambda item: item.get("work_date") or ""), + "missing_regular_days": sorted( + bucket["missing_regular_details"], + key=lambda item: item.get("work_date") or "", + ), "project_count": sorted( [ { @@ -19241,16 +29158,29 @@ def get_hanmac_aggregate_summary(payload: dict[str, Any]) -> dict[str, Any]: ), }, "multi_entry_days": bucket["multi_entry_days"], + "overlap_type_counts": dict(sorted(bucket["overlap_type_counts"].items())), "expected_regular_hours": round(bucket.get("expected_regular_hours", 0.0), 2), "expected_regular_after_leave_hours": round(bucket.get("expected_regular_after_leave_hours", 0.0), 2), "regular_hour_gap": round(bucket.get("regular_hour_gap", 0.0), 2), + "missing_regular_hours": round(bucket.get("missing_regular_hours", 0.0), 2), + "missing_regular_days": round(bucket.get("missing_regular_days", 0.0), 2), + "missing_regular_date_count": int(bucket.get("missing_regular_date_count", 0)), "remarks": " · ".join( part for part in ( - f"중복 {bucket['multi_entry_days']}일" if bucket["multi_entry_days"] else "", + bucket.get("population_review_label", ""), ( - f"근무시간 {bucket.get('regular_hour_gap', 0.0):+,.2f}시간" - if abs(bucket.get("regular_hour_gap", 0.0)) >= 0.01 + " · ".join( + f"{label} {count}일" + for label, count in sorted(bucket["overlap_type_counts"].items()) + ) + if bucket["overlap_type_counts"] + else "" + ), + ( + f"정규근로 {bucket.get('missing_regular_days', 0.0):,.2f}일 부족" + f" ({bucket.get('missing_regular_hours', 0.0):,.2f}시간)" + if bucket.get("missing_regular_hours", 0.0) >= 0.01 else "" ), ) @@ -19268,6 +29198,7 @@ def get_hanmac_aggregate_summary(payload: dict[str, Any]) -> dict[str, Any]: {"key": "member_no", "label": "사번"}, {"key": "member_name", "label": "이름"}, {"key": "member_grade", "label": "직급"}, + {"key": "population_review", "label": "소속검토"}, {"key": "status", "label": "구분"}, {"key": "entry_date", "label": "입사일"}, {"key": "leave_date", "label": "퇴사일"}, @@ -19281,7 +29212,11 @@ def get_hanmac_aggregate_summary(payload: dict[str, Any]) -> dict[str, Any]: ] summary = { - "member_count": len(member_aggregates), + "member_count": sum( + 1 + for member_no in member_aggregates + if _hanmac_counts_as_member(get_member_record(member_no)) + ), "project_count": len(project_aggregates), "regular_hours": round(sum(item.get("regular_hours", 0.0) for item in rows), 2), "overtime_hours": round(sum(item.get("overtime_hours", 0.0) for item in rows), 2), @@ -19290,6 +29225,12 @@ def get_hanmac_aggregate_summary(payload: dict[str, Any]) -> dict[str, Any]: } summary["holiday_hours"] = round(sum(item.get("holiday_hours", 0.0) for item in rows), 2) summary["total_hours"] = round(summary["regular_hours"] + summary["overtime_hours"] + summary["holiday_hours"], 2) + source_diagnostics["headcount_excluded_researcher_count"] = sum( + 1 + for member_no in member_aggregates + if not _hanmac_counts_as_member(get_member_record(member_no)) + ) + source_diagnostics["logic_version"] = HANMAC_AGGREGATE_LOGIC_VERSION response_payload = { "status": "ok", @@ -19355,17 +29296,47 @@ def build_hanmac_mysql_error_message(exc: OperationalError) -> str: def build_wehago_compare_health_payload() -> dict[str, Any]: init_db() - payload = get_wehago_compare_dashboard( - engine, - include_metric_counts=False, - warm_caches=False, - ) + with engine.begin() as conn: + years = sorted(_discover_available_fiscal_years(conn), reverse=True) + active_row = conn.execute( + text( + """ + SELECT setting_key + FROM wehago_compare_settings + WHERE setting_key LIKE 'wehago_active_query_projection:%:%' + ORDER BY updated_at DESC + LIMIT 1 + """ + ) + ).mappings().first() + selected_start = None + selected_end = None + if active_row: + parts = normalize_text(active_row.get("setting_key")).split(":") + if len(parts) >= 3: + try: + selected_start = int(parts[-2]) + selected_end = int(parts[-1]) + except Exception: + selected_start = None + selected_end = None + if selected_start is None or selected_end is None: + fallback_year = int(years[0]) if years else date.today().year + selected_start = fallback_year + selected_end = fallback_year + projection_state = ensure_wehago_canonical_projection_state( + conn, + selected_start, + selected_end, + repair=True, + ) return { "status": "ok", - "selected_start_year": payload.get("selected_start_year"), - "selected_end_year": payload.get("selected_end_year"), - "available_year_count": len(payload.get("available_years") or []), - "metric_section_count": len(payload.get("metric_sections") or []), + "logic_version": QUERY_PROJECTION_VERSION, + "selected_start_year": selected_start, + "selected_end_year": selected_end, + "available_year_count": len(years), + "projection_state": projection_state, } @@ -19455,8 +29426,68 @@ async def cost_analysis(request: Request): @app.get("/cost-analysis/data") -async def cost_analysis_data(start_date: str = "", end_date: str = "", mode: str = "individual"): +async def cost_analysis_data( + start_date: str = "", + end_date: str = "", + mode: str = "individual", + background: bool = False, +): try: + if background: + requested_start = _parse_iso_date(start_date) or date(date.today().year, 1, 1) + requested_end = _parse_iso_date(end_date) or date.today() + if requested_end < requested_start: + requested_start, requested_end = requested_end, requested_start + requested_start, requested_end = _cost_analysis1_effective_period( + requested_start, + requested_end, + ) + cumulative_start = await run_in_threadpool( + _cost_analysis_get_accumulation_start, + requested_end.isoformat(), + ) + hanmac_refresh_jobs = await run_in_threadpool( + _cost_analysis_ensure_current_hanmac_cache_jobs, + cumulative_start, + requested_end, + ) + if hanmac_refresh_jobs: + normalized_mode = "aggregate" if normalize_text(mode).lower() in {"aggregate", "sum", "합산", "연계", "linked", "link"} else "individual" + last_valid = await run_in_threadpool( + _cost_analysis_load_last_valid_payload, + requested_start.isoformat(), + requested_end.isoformat(), + normalized_mode, + ) + return JSONResponse( + content=jsonable_encoder( + { + **(last_valid or {}), + "pending": True, + "job": hanmac_refresh_jobs[0], + "hanmac_refresh_jobs": hanmac_refresh_jobs, + "cache_info": { + **((last_valid or {}).get("cache_info") or {}), + "ready": False, + "source": "hanmac-auto-refresh", + }, + } + ), + status_code=202, + headers={"Cache-Control": "no-store, max-age=0"}, + ) + # 프로젝트 손익분석은 ERP 기초자료에 표준·조정인건비를 반영한 + # 단일 최종 payload를 반환한다. + payload = await run_in_threadpool( + _cost_analysis_build_payload, + start_date, + end_date, + mode, + ) + return JSONResponse( + content=jsonable_encoder(payload), + headers={"Cache-Control": "no-store, max-age=0"}, + ) payload = await run_in_threadpool(_cost_analysis_build_payload, start_date, end_date, mode) return JSONResponse( content=jsonable_encoder(payload), @@ -19467,6 +29498,98 @@ async def cost_analysis_data(start_date: str = "", end_date: str = "", mode: str return JSONResponse(content={"error": str(exc)}, status_code=500) +@app.post("/cost-analysis/refresh") +async def cost_analysis_refresh(request: Request): + try: + payload = await request.json() + if not isinstance(payload, dict): + payload = {} + context = await run_in_threadpool( + _cost_analysis_payload_cache_context, + normalize_text(payload.get("start_date")), + normalize_text(payload.get("end_date")), + ) + normalized_mode = "aggregate" if normalize_text(payload.get("mode")).lower() in {"aggregate", "sum", "합산", "연계", "linked", "link"} else "individual" + codes = [ + normalize_text(code).upper() + for code in (payload.get("codes") or []) + if normalize_text(code) + ] + job = await run_in_threadpool( + _create_system_job, + page_key="cost_analysis", + job_type="cost_analysis_payload", + start_year=context["start_date"].year, + end_year=context["end_date"].year, + params={ + "start_date": context["start_date"].isoformat(), + "end_date": context["end_date"].isoformat(), + "mode": normalized_mode, + "force": bool(payload.get("force")), + "codes": codes, + }, + ) + return JSONResponse(content=jsonable_encoder({"ok": True, "job": job})) + except Exception as exc: + logger.exception("프로젝트 손익분석 갱신 작업 등록 에러: %s", exc) + return JSONResponse(content={"ok": False, "error": str(exc)}, status_code=500) + + +@app.get("/cost-analysis/validate") +async def cost_analysis_validate( + start_date: str = "", + end_date: str = "", + mode: str = "individual", + codes: str = "", +): + try: + requested_codes = parse_support_dept_codes_param(codes) + if not requested_codes: + raise ValueError("검증할 프로젝트 코드가 필요합니다.") + payload = await run_in_threadpool(_cost_analysis_build_payload, start_date, end_date, mode) + filtered = await run_in_threadpool(_cost_analysis_filter_payload_codes, payload, requested_codes) + return JSONResponse( + content=jsonable_encoder(filtered), + headers={"Cache-Control": "no-store, max-age=0"}, + ) + except Exception as exc: + logger.exception("프로젝트 손익분석 선택 프로젝트 검증 에러: %s", exc) + return JSONResponse(content={"error": str(exc)}, status_code=500) + + +@app.get("/cost-analysis/cache-diagnostics") +async def cost_analysis_cache_diagnostics(start_date: str = "", end_date: str = "", mode: str = "individual"): + try: + context = await run_in_threadpool(_cost_analysis_payload_cache_context, start_date, end_date) + normalized_mode = "aggregate" if normalize_text(mode).lower() in {"aggregate", "sum", "합산", "연계", "linked", "link"} else "individual" + cached = await run_in_threadpool(_cost_analysis_load_cached_payload, context, normalized_mode) + latest_job = await run_in_threadpool( + _fetch_latest_system_job, + page_key="cost_analysis", + job_type="cost_analysis_payload", + start_year=context["start_date"].year, + end_year=context["end_date"].year, + ) + return JSONResponse( + content=jsonable_encoder( + { + "ready": cached is not None, + "mode": normalized_mode, + "start_date": context["start_date"].isoformat(), + "end_date": context["end_date"].isoformat(), + "cache_info": (cached or {}).get("cache_info") or {}, + "job": latest_job, + "financial_logic_version": COST_ANALYSIS_FINANCIAL_LOGIC_VERSION, + "h_project_mapping_version": COST_ANALYSIS_H_PROJECT_MAPPING_VERSION, + "link_logic_version": COST_ANALYSIS_LINK_LOGIC_VERSION, + } + ) + ) + except Exception as exc: + logger.exception("프로젝트 손익분석 캐시 진단 에러: %s", exc) + return JSONResponse(content={"error": str(exc)}, status_code=500) + + @app.get("/cost-analysis/missing-grade-export") async def cost_analysis_missing_grade_export(start_date: str = "", end_date: str = "", codes: str = ""): try: @@ -19620,6 +29743,7 @@ async def cost_analysis_detail( start_date: str = "", end_date: str = "", codes: str = "", + representative_code: str = "", phase: str = "", item: str = "", ): @@ -19633,9 +29757,86 @@ async def cost_analysis_detail( raise ValueError("조회할 프로젝트 코드가 필요합니다.") normalized_phase = normalize_text(phase).lower() normalized_item = normalize_text(item).lower() - completion_dates = _cost_analysis_get_completion_billing_dates() + detail_cache_key = ( + start.isoformat(), + end.isoformat(), + tuple(sorted(normalized_codes)), + normalize_text(representative_code).upper(), + normalized_phase, + normalized_item, + get_business_data_version(), + _cost_analysis_hanmac_cache_version(), + COST_ANALYSIS_FINANCIAL_LOGIC_VERSION, + COST_ANALYSIS_LINK_LOGIC_VERSION, + ) + cached_detail = _get_deepcopy_ttl_cache_entry( + _COST_ANALYSIS_DETAIL_CACHE, + _COST_ANALYSIS_DETAIL_CACHE_LOCK, + detail_cache_key, + COST_ANALYSIS_DETAIL_CACHE_TTL_SECONDS, + ) + if cached_detail is not None: + cached_detail["cache_info"] = {**(cached_detail.get("cache_info") or {}), "source": "memory"} + return JSONResponse(content=jsonable_encoder(cached_detail)) - if normalized_item == "labor": + def detail_response(payload: dict[str, Any]) -> JSONResponse: + payload["cache_info"] = { + "source": "computed", + "generated_at": datetime.now().isoformat(timespec="seconds"), + } + cached = _set_deepcopy_ttl_cache_entry( + _COST_ANALYSIS_DETAIL_CACHE, + _COST_ANALYSIS_DETAIL_CACHE_LOCK, + detail_cache_key, + payload, + ) + return JSONResponse(content=jsonable_encoder(cached)) + + completion_dates = _cost_analysis_get_completion_billing_dates() + representative_map = _cost_analysis_get_link_representative_map() + requested_total_codes = [code for code in normalized_codes if normalize_text(code).upper()[:1] in {"0", "9"}] + explicit_representative_code = normalize_text(representative_code).upper() + if explicit_representative_code[:1] not in {"0", "9"}: + explicit_representative_code = "" + + def detail_project_codes(source_code: Any) -> dict[str, str]: + individual_code = normalize_text(source_code).upper() + if not individual_code: + return { + "total_project_code": explicit_representative_code, + "project_code": "", + "source_project_code": "", + "mapping_status": "explicit" if explicit_representative_code else "unresolved", + } + total_code = explicit_representative_code or representative_map.get(individual_code, "") + mapping_status = "explicit" if explicit_representative_code else "linked" + if not total_code and individual_code[:1] in {"0", "9"}: + total_code = individual_code + mapping_status = "self-total" + if not total_code and requested_total_codes: + total_code = requested_total_codes[0] + mapping_status = "requested-total" + if not total_code: + mapping_status = "unresolved" + return { + "total_project_code": total_code, + "project_code": individual_code, + "source_project_code": individual_code, + "mapping_status": mapping_status, + } + + def enrich_detail_row(row: dict[str, Any]) -> dict[str, Any]: + enriched = dict(row) + code_fields = detail_project_codes(enriched.get("project_code") or enriched.get("source_project_code")) + for key, value in code_fields.items(): + if key in {"total_project_code", "mapping_status"}: + if not normalize_text(enriched.get(key)): + enriched[key] = value + elif not normalize_text(enriched.get(key)): + enriched[key] = value + return enriched + + if normalized_item in {"labor", "sga_labor", "labor_combined", "sga_labor_combined"}: project_meta = _cost_analysis_get_project_meta() rows = _cost_analysis_load_hanmac_labor_detail_rows_yearly( start, @@ -19644,106 +29845,263 @@ async def cost_analysis_detail( normalized_phase, project_meta, ) - if rows: - return JSONResponse( - content=jsonable_encoder( - { - "detail_type": "labor", - "rows": rows, - "total_amount": sum(normalize_amount(row.get("amount")) for row in rows), - } - ) + _, hanmac_labor_by_year, _ = _cost_analysis_load_hanmac_hours_and_labor_yearly( + start, + end, + project_meta, + set(normalized_codes), + ) + has_hanmac_labor = any( + normalize_amount(phase_amounts.get(phase_key)) + for code_map in hanmac_labor_by_year.values() + for phase_amounts in code_map.values() + for phase_key in ("pre", "during", "post") + ) + if rows or has_hanmac_labor: + rows = [enrich_detail_row(row) for row in rows] + hours_summary = { + field: round(sum(normalize_amount(row.get(field)) for row in rows), 2) + for field in ("regular_hours", "overtime_hours", "holiday_hours", "extra_hours", "total_hours") + } + return detail_response( + { + "detail_type": "labor", + "rows": rows, + "total_amount": sum(normalize_amount(row.get("amount")) for row in rows), + "hours_summary": hours_summary, + } ) if normalized_item == "collection": - in_clause, code_params = build_in_clause("cost_analysis_collection_code", normalized_codes) - query = text( - f""" - SELECT - COALESCE(date, '') AS posting_date, - '수금' AS account_name, - COALESCE(vendor, '') AS partner_name, - COALESCE(note, '') AS memo1, - COALESCE(amount, 0) AS amount - FROM project_collection_entries - WHERE support_dept_code IN ({in_clause}) - AND COALESCE(date, '') >= :start_date - AND COALESCE(date, '') <= :end_date - ORDER BY date DESC, vendor - """ - ) - params = {**code_params, "start_date": start.isoformat(), "end_date": end.isoformat()} - with engine.begin() as conn: - rows = [ + code_set = set(normalized_codes) + rows = [] + for event in _cost_analysis_erp_collection_events(end): + code = normalize_text(event.get("support_dept_code")).upper() + posting_date = _date_text(event.get("posting_date")) + if code not in code_set or not (start.isoformat() <= posting_date <= end.isoformat()): + continue + conversion_status = normalize_text(event.get("conversion_status")) + rows.append( { - "posting_date": _date_text(row["posting_date"]), - "account_name": normalize_text(row["account_name"]), - "partner_name": normalize_text(row["partner_name"]), - "memo1": normalize_text(row["memo1"]), - "amount": int(round(normalize_amount(row["amount"]))), + "posting_date": posting_date, + "account_name": normalize_text(event.get("receivable_account_name")) or "ERP 수금", + "partner_name": normalize_text(event.get("partner_name")), + **detail_project_codes(code), + "memo1": normalize_text(event.get("memo1")), + "amount": int(round(normalize_amount(event.get("amount")))), + "gross_amount": int(round(normalize_amount(event.get("gross_amount")))), + "voucher_number": normalize_text(event.get("voucher_number")), + "confirmed_voucher_number": normalize_text(event.get("confirmed_voucher_number")), + "source_invoice_voucher_number": normalize_text(event.get("source_invoice_voucher_number")), + "source_invoice_confirmed_voucher_number": normalize_text( + event.get("source_invoice_confirmed_voucher_number") + ), + "match_status": ( + "원 청구 전표 매칭" + if conversion_status == "invoice-matched" + else ( + "즉시 현금·카드 매출" + if conversion_status == "immediate-cash" + else "부가세 10% 추정 환산" + ) + ), + "included_in_total": True, } - for row in conn.execute(query, params).mappings() - ] - if not rows: - billing_query = text( - f""" - SELECT - COALESCE(COALESCE(tax_invoice_date, billing_date), '') AS posting_date, - '수금' AS account_name, - COALESCE(client_name, '') AS partner_name, - COALESCE(note, '') AS memo1, - COALESCE(collected_amount, 0) AS amount - FROM project_billing_entries - WHERE support_dept_code IN ({in_clause}) - AND COALESCE(COALESCE(tax_invoice_date, billing_date), '') >= :start_date - AND COALESCE(COALESCE(tax_invoice_date, billing_date), '') <= :end_date - AND COALESCE(collected_amount, 0) <> 0 - ORDER BY posting_date DESC, client_name - """ - ) - rows = [ - { - "posting_date": _date_text(row["posting_date"]), - "account_name": normalize_text(row["account_name"]), - "partner_name": normalize_text(row["partner_name"]), - "memo1": normalize_text(row["memo1"]), - "amount": int(round(normalize_amount(row["amount"]))), - } - for row in conn.execute(billing_query, params).mappings() - ] - return JSONResponse(content=jsonable_encoder({"rows": rows, "total_amount": sum(row["amount"] for row in rows)})) + ) + rows.sort(key=lambda row: (row.get("posting_date") or "", row.get("voucher_number") or ""), reverse=True) + rows = [enrich_detail_row(row) for row in rows] + return detail_response( + { + "detail_type": "collection", + "rows": rows, + "total_amount": sum(row["amount"] for row in rows), + } + ) if normalized_item == "billing": in_clause, code_params = build_in_clause("cost_analysis_billing_code", normalized_codes) query = text( f""" SELECT - COALESCE(COALESCE(tax_invoice_date, billing_date), '') AS posting_date, + id, + COALESCE(support_dept_code, '') AS support_dept_code, + COALESCE(billing_date, '') AS posting_date, + COALESCE(tax_invoice_date, '') AS tax_invoice_date, '청구금액' AS account_name, COALESCE(client_name, '') AS partner_name, COALESCE(note, '') AS memo1, COALESCE(billed_amount, 0) AS amount FROM project_billing_entries WHERE support_dept_code IN ({in_clause}) - AND COALESCE(COALESCE(tax_invoice_date, billing_date), '') >= :start_date - AND COALESCE(COALESCE(tax_invoice_date, billing_date), '') <= :end_date + AND COALESCE(billing_date, '') >= :start_date + AND COALESCE(billing_date, '') <= :end_date AND COALESCE(billed_amount, 0) <> 0 ORDER BY posting_date DESC, client_name """ ) params = {**code_params, "start_date": start.isoformat(), "end_date": end.isoformat()} with engine.begin() as conn: - rows = [ - { - "posting_date": _date_text(row["posting_date"]), - "account_name": normalize_text(row["account_name"]), - "partner_name": normalize_text(row["partner_name"]), - "memo1": normalize_text(row["memo1"]), - "amount": int(round(normalize_amount(row["amount"]))), - } - for row in conn.execute(query, params).mappings() + billing_rows = [dict(row) for row in conn.execute(query, params).mappings()] + erp_rows = [ + dict(row) + for row in conn.execute( + text( + f""" + SELECT + COALESCE(voucher_number, '') AS voucher_number, + COALESCE(confirmed_voucher_number, '') AS confirmed_voucher_number, + {COST_ANALYSIS_TX_DATE_SQL} AS posting_date, + COALESCE(account_code, '') AS account_code, + COALESCE(account_name, '') AS account_name, + UPPER(COALESCE(support_dept_code, '')) AS support_dept_code, + COALESCE(partner_name, '') AS partner_name, + COALESCE(memo1, '') AS memo1, + COALESCE(amount, 0) AS amount + FROM transactions + WHERE UPPER(COALESCE(support_dept_code, '')) IN ({in_clause}) + AND {COST_ANALYSIS_TX_DATE_SQL} >= :start_date + AND {COST_ANALYSIS_TX_DATE_SQL} <= :end_date + AND account_code LIKE '4%' + AND COALESCE(amount, 0) <> 0 + ORDER BY posting_date DESC, voucher_number DESC + """ + ), + params, + ).mappings() ] - return JSONResponse(content=jsonable_encoder({"rows": rows, "total_amount": sum(row["amount"] for row in rows)})) + used_erp_indexes: set[int] = set() + rows = [] + for billing_row in billing_rows: + code = normalize_text(billing_row.get("support_dept_code")).upper() + amount = normalize_amount(billing_row.get("amount")) + tax_invoice_date = _date_text(billing_row.get("tax_invoice_date")) + exact_index = next( + ( + index + for index, erp_row in enumerate(erp_rows) + if index not in used_erp_indexes + and normalize_text(erp_row.get("support_dept_code")).upper() == code + and abs(normalize_amount(erp_row.get("amount")) - amount) < 0.5 + and _date_text(erp_row.get("posting_date")) == tax_invoice_date + ), + None, + ) + amount_index = exact_index + if amount_index is None: + amount_index = next( + ( + index + for index, erp_row in enumerate(erp_rows) + if index not in used_erp_indexes + and normalize_text(erp_row.get("support_dept_code")).upper() == code + and abs(normalize_amount(erp_row.get("amount")) - amount) < 0.5 + ), + None, + ) + erp_row = erp_rows[amount_index] if amount_index is not None else {} + if amount_index is not None: + used_erp_indexes.add(amount_index) + match_status = "ERP 전표 일치" + if amount_index is None: + match_status = "ERP 전표 미확인" + elif exact_index is None: + match_status = "ERP 증빙일자 불일치" + rows.append( + { + "posting_date": _date_text(billing_row.get("posting_date")), + "tax_invoice_date": tax_invoice_date, + "account_name": normalize_text(billing_row.get("account_name")), + "partner_name": normalize_text(billing_row.get("partner_name")), + **detail_project_codes(code), + "memo1": normalize_text(billing_row.get("memo1")), + "amount": int(round(amount)), + "erp_posting_date": _date_text(erp_row.get("posting_date")), + "voucher_number": normalize_text(erp_row.get("voucher_number")), + "confirmed_voucher_number": normalize_text(erp_row.get("confirmed_voucher_number")), + "match_status": match_status, + "included_in_total": True, + } + ) + for index, erp_row in enumerate(erp_rows): + if index in used_erp_indexes: + continue + code = normalize_text(erp_row.get("support_dept_code")).upper() + rows.append( + { + "posting_date": "", + "tax_invoice_date": "", + "erp_posting_date": _date_text(erp_row.get("posting_date")), + "account_name": normalize_text(erp_row.get("account_name")), + "partner_name": normalize_text(erp_row.get("partner_name")), + **detail_project_codes(code), + "memo1": normalize_text(erp_row.get("memo1")), + "amount": int(round(normalize_amount(erp_row.get("amount")))), + "voucher_number": normalize_text(erp_row.get("voucher_number")), + "confirmed_voucher_number": normalize_text(erp_row.get("confirmed_voucher_number")), + "match_status": "DB 청구 미연결 ERP 전표", + "included_in_total": False, + } + ) + rows = [enrich_detail_row(row) for row in rows] + return detail_response( + { + "detail_type": "billing", + "rows": rows, + "total_amount": sum( + row["amount"] for row in rows if row.get("included_in_total") + ), + } + ) + + project_meta = _cost_analysis_get_project_meta() + synthetic_rows: list[dict[str, Any]] = [] + hanmac_labor_detail_rows: list[dict[str, Any]] = [] + if normalized_item in {"cost_total", "total_cost"}: + hanmac_labor_detail_rows = _cost_analysis_load_hanmac_labor_detail_rows_yearly( + start, + end, + normalized_codes, + normalized_phase, + project_meta, + ) + for labor_row in hanmac_labor_detail_rows: + synthetic_rows.append( + { + "posting_date": "한맥", + "account_name": "한맥 인건비", + "partner_name": normalize_text(labor_row.get("member_name")), + "total_project_code": normalize_text(labor_row.get("total_project_code")), + "project_code": normalize_text(labor_row.get("project_code")), + "memo1": ( + f"{normalize_text(labor_row.get('member_grade'))} " + f"총 {normalize_amount(labor_row.get('total_hours')):,.1f}h" + f" / 초과 {normalize_amount(labor_row.get('extra_hours')):,.1f}h" + ).strip(), + "amount": int(round(normalize_amount(labor_row.get("amount")))), + } + ) + if normalized_item in {"overhead", "cost_total", "total_cost"}: + synthetic_rows.extend( + _cost_analysis_build_allocated_common_detail_rows( + start, + end, + normalized_codes, + normalized_phase, + project_meta, + "overhead", + ) + ) + if normalized_item in {"sga", "sga_total", "total_cost"}: + synthetic_rows.extend( + _cost_analysis_build_allocated_common_detail_rows( + start, + end, + normalized_codes, + normalized_phase, + project_meta, + "sga", + ) + ) in_clause, code_params = build_in_clause("cost_analysis_detail_code", normalized_codes) query = text( @@ -19778,36 +30136,26 @@ async def cost_analysis_detail( code = normalize_text(row.get("support_dept_code")).upper() bucket = _cost_analysis_financial_bucket(row.get("account_code")) posting_date = _date_text(row.get("posting_date")) - row_phase = _cost_analysis_phase_for_transaction(code, posting_date, completion_dates) + row_phase = "pre" if code.startswith("X") else _cost_analysis_phase_for_transaction(code, posting_date, completion_dates, project_meta) row_item = _cost_analysis_expense_item(row.get("account_code"), row.get("account_name"), _cost_analysis_is_sales_cost(row)) - if row_item == "outsource" and row_phase == "pre": - row_item = "overhead" if normalized_phase and normalized_phase != "all" and row_phase != normalized_phase: continue - if normalized_item == "revenue" and bucket != "revenue": + if row_item == "labor": continue - elif normalized_item == "cost_total" and row_item not in {"labor", "outsource", "overhead"}: - continue - elif normalized_item == "sga_total" and row_item != "sga": - continue - elif normalized_item == "sales_total" and row_item != "sales": - continue - elif normalized_item == "total_cost" and row_item not in {"labor", "outsource", "overhead", "sga", "sales"}: - continue - elif normalized_item in {"labor", "outsource", "overhead", "sga", "sales"} and row_item != normalized_item: - continue - elif normalized_item not in {"revenue", "cost_total", "sga_total", "sales_total", "total_cost", "labor", "outsource", "overhead", "sga", "sales"}: + if not _cost_analysis_detail_item_matches(bucket, row_item, normalized_item): continue result_rows.append( { "posting_date": build_transaction_posting_display(row["voucher_number"], row["posting_date"]), "account_name": normalize_text(row["account_name"]), "partner_name": normalize_text(row["partner_name"]), + **detail_project_codes(code), "memo1": normalize_text(row["memo1"]), "amount": int(round(normalize_amount(row["amount"]))), } ) - return JSONResponse(content=jsonable_encoder({"rows": result_rows, "total_amount": sum(row["amount"] for row in result_rows)})) + rows = [enrich_detail_row(row) for row in [*synthetic_rows, *result_rows]] + return detail_response({"rows": rows, "total_amount": sum(row["amount"] for row in rows)}) except Exception as exc: logger.exception("프로젝트 손익분석 상세 조회 에러: %s", exc) return JSONResponse(content={"error": str(exc)}, status_code=500) @@ -20350,6 +30698,15 @@ async def annual_summary(request: Request): return HTMLResponse("

서버 오류

로그를 확인해주세요.

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

서버 오류

로그를 확인해주세요.

", status_code=500) + + @app.get("/biz-process") async def biz_process(request: Request): context = base_context(request) @@ -20542,6 +30899,122 @@ async def hanmac_browser_test_connection(request: Request): ) +@app.post("/hanmac-browser/api/test-management-erp") +async def hanmac_browser_test_management_erp(request: Request): + try: + payload = await request.json() + if not isinstance(payload, dict): + raise ValueError("잘못된 요청 형식입니다.") + result = await run_in_threadpool(test_hanmac_management_erp_access, payload) + return JSONResponse(content=jsonable_encoder(result)) + except Exception as exc: + logger.exception("hanmac 관리 ERP 접근 확인 에러: %s", exc) + return JSONResponse( + content={"status": "error", "message": str(exc)}, + status_code=400, + ) + + +@app.post("/hanmac-browser/api/discover-satis-budget") +async def hanmac_browser_discover_satis_budget(request: Request): + try: + payload = await request.json() + if not isinstance(payload, dict): + raise ValueError("잘못된 요청 형식입니다.") + init_db() + result = await run_in_threadpool(test_hanmac_satis_budget_discovery, payload) + return JSONResponse(content=jsonable_encoder(result)) + except Exception as exc: + logger.exception("hanmac Satis 예산 연동 탐색 에러: %s", exc) + return JSONResponse( + content={"status": "error", "message": str(exc)}, + status_code=400, + ) + + +@app.post("/hanmac-browser/api/sync-satis-budget-raw") +async def hanmac_browser_sync_satis_budget_raw(request: Request): + try: + payload = await request.json() + if not isinstance(payload, dict): + raise ValueError("잘못된 요청 형식입니다.") + init_db() + result = await run_in_threadpool(_sync_satis_budget_raw_rows, payload) + return JSONResponse(content=jsonable_encoder(result)) + except Exception as exc: + logger.exception("hanmac Satis 예산 원본 금액 동기화 에러: %s", exc) + return JSONResponse( + content={"status": "error", "message": str(exc)}, + status_code=400, + ) + + +@app.post("/hanmac-browser/api/normalize-satis-budget") +async def hanmac_browser_normalize_satis_budget(request: Request): + try: + payload = await request.json() + if not isinstance(payload, dict): + raise ValueError("잘못된 요청 형식입니다.") + result = await run_in_threadpool(_normalize_satis_budget_raw_rows, payload) + return JSONResponse(content=jsonable_encoder(result)) + except Exception as exc: + logger.exception("hanmac Satis 예산 정규화 에러: %s", exc) + return JSONResponse( + content={"status": "error", "message": str(exc)}, + status_code=400, + ) + + +@app.post("/hanmac-browser/api/project-satis-budget-current") +async def hanmac_browser_project_satis_budget_current(request: Request): + try: + payload = await request.json() + if not isinstance(payload, dict): + raise ValueError("잘못된 요청 형식입니다.") + result = await run_in_threadpool(_project_satis_budget_to_current_entries, payload) + return JSONResponse(content=jsonable_encoder(result)) + except Exception as exc: + logger.exception("hanmac Satis 예산 기존 입력 테이블 반영 에러: %s", exc) + return JSONResponse( + content={"status": "error", "message": str(exc)}, + status_code=400, + ) + + +@app.post("/hanmac-browser/api/run-satis-budget-full-sync") +async def hanmac_browser_run_satis_budget_full_sync(request: Request): + try: + payload = await request.json() + if not isinstance(payload, dict): + raise ValueError("잘못된 요청 형식입니다.") + init_db() + result = await run_in_threadpool(_run_satis_budget_full_sync, payload) + return JSONResponse(content=jsonable_encoder(result)) + except Exception as exc: + logger.exception("hanmac Satis 예산 전체 실행 에러: %s", exc) + return JSONResponse( + content={"status": "error", "message": str(exc)}, + status_code=400, + ) + + +@app.post("/hanmac-browser/api/collect-satis-budget-web") +async def hanmac_browser_collect_satis_budget_web(request: Request): + try: + payload = await request.json() + if not isinstance(payload, dict): + raise ValueError("잘못된 요청 형식입니다.") + init_db() + result = await run_in_threadpool(_collect_satis_budget_via_web, payload) + return JSONResponse(content=jsonable_encoder(result)) + except Exception as exc: + logger.exception("hanmac Satis 웹로그인 예산 수집 에러: %s", exc) + return JSONResponse( + content={"status": "error", "message": str(exc)}, + status_code=400, + ) + + @app.post("/hanmac-browser/api/tables") async def hanmac_browser_tables(request: Request): try: @@ -20880,7 +31353,8 @@ async def wehago_compare_rebuild_query_cache(request: Request): @app.get("/wehago-compare") async def wehago_compare(request: Request, start_year: str | None = None, end_year: str | None = None): try: - return render_wehago_compare_page( + return await run_in_threadpool( + render_wehago_compare_page, request, start_year=parse_optional_year(start_year), end_year=parse_optional_year(end_year), @@ -21288,36 +31762,11 @@ async def wehago_compare_status_suggestions( def _refresh_wehago_recheck_projection_after_change() -> dict[str, Any]: - scripts = [ - Path("scripts/promote_wehago_recheck_projection.py"), - Path("scripts/reconcile_wehago_projection_to_db.py"), - ] - outputs: list[str] = [] - for script in scripts: - completed = subprocess.run( - [sys.executable, str(script)], - cwd=Path(__file__).resolve().parent, - text=True, - capture_output=True, - timeout=180, - check=False, - ) - output = "\n".join(part for part in [completed.stdout, completed.stderr] if part).strip() - if output: - outputs.append(output) - if completed.returncode != 0: - raise RuntimeError(output or f"{script.name} 실행에 실패했습니다.") _clear_compare_runtime_caches() - reconcile_payload: dict[str, Any] = {} - if outputs: - last_line = outputs[-1].splitlines()[-1].strip() - try: - parsed = json.loads(last_line) - if isinstance(parsed, dict): - reconcile_payload = parsed - except Exception: - reconcile_payload = {"output": last_line} - return reconcile_payload + return { + "mode": "manual_overlay_only", + "message": "선택한 recheck 변경만 저장했고, 전체 projection 재생성은 실행하지 않았습니다.", + } def _ensure_wehago_manual_offset_excepted_table(conn: sqlite3.Connection) -> None: diff --git a/reports/my-intranet-app_architecture_report_20260522.docx b/reports/my-intranet-app_architecture_report_20260522.docx deleted file mode 100644 index 144fec0ede3a0fcc9921f33041dc9fc671e195e2..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 8690 zcmaKS1yCJJx9-M0xCRRr+})kv?yeizxV!ttg1fs1c7WjS1a}Wkut0!dd7S^=`_4J3 z``%2=bWK-(Yih0a_3EkqMnxV576$+TAOf(=KN|>M2bMBI0RRvf008^7S6{->!Ohaa z%~-?R$!cb(jfdv%-HP96tsXC^|PKZf<;xiuij|S5yHt zN?M3}%avx%>eCyent}rP##H>DACRMHbny>s{qlq~mek0Pts%;odTmfC$yWuL_Ur{F z&I4M^Q9t6;tnn~77wIG{O5~}ncY&GJHIuPQZEr$kn7eBZ<+&tj`PPA&s+Ts7~H2e2^GQBn$7tkRsJ6)$CwcCnZG3>spTRc_s$WL&AvBMb;cXe z!1tLCjy~bStp>4=_v}8{l~dZQFY&=3_+O;Svj>f|qjw3Di?#DUN#mBpAv`@&2y8iW z0Vo!qWFkASGx0#IOmvj_I>GrZULplXhRy*I;^*IwB2_$o|JQY@(<{|BX zQu$YKp@jw;PhNwn0}TM6ytc+JmLOLa=D+T>AA6PhSy4qVo?}MtxHb|o#1%col077> z{en_k?Ip7%;}O=ju-}QIvMmRj6F>f1acLKxNcN$b5{8Ev{{kh|D}C5+vhCcWyB;(a z(z}BLL&&QZ;jRrq=PXRg5`0L}3=RXC;K;V<0R?8kZSm3Kzm6%t((aFjDp}_{k$2i= z*-aV~PHW2Lnk+05ZrAFEtdd|Gh9vl3Fl|V+=hKa#8+KXZu>TM>+ot|BcP?ar&v?`% zUOgT@^|cy^wgVJ*(>LWUt3Q{7YmmEii?G#xx4)<_5R}Af5j+qvZ_xMRr=09Cb2X3 zL|l^JoiarK*gwcL>%~NXL!oH7r5olB85x<`AOMMvPqwXHkKa?%D09ITe zpE9wABbt^m7kSdnhD_w|v5g`P<*nLJ#{YI+njAsK4-Wvi_W}Sg|Ht`%x_u_JruKFz z?c!IAp+Rw2BLP|k9BL!3DO34(XypeD>}A=q{qxvamSQlO&9h5$*Nq=l z)mQ3VFEF5u<4#AP`0lxuyX|Nm2|r!i#DRY%R2?@NE~$IyovKawmwBN#1m3nObP1$F z6uGKbHmXc9^yt4}SD()-yA>~_~7n)~eiC^;wL#^k+ zeLA395A!s&;k}y{>*k$19ock#=I-OU@O;5;xEccUMT6#Mvg6v7T711SMnL3L@MR0p zIiRZ7zeUHDyXr|S#8I(^U%4z-MgUq(Zv{#e=^{nH3Ik|1?yZ|x;oGnXR(&wZOWT~@ zrvqB8X6nX~VmGF9;mYVb$`9O1k8Zc7jjU9v#-Z-B6D~}U#T5T$ctRJ|vCK6p7l8YI zj6+@~%3PTy( zj(FoIyzLdEat26o0qgQ$;P-o2>S<2H0LhC9GwkKut)s#ZGr z%5w*f+#L4eO^I{-0g_=xxpItK<%e~FH3u6MHu52K!weYWp2G_sqqEjh$Tv}141GhF zi$&bKUXXcH#kB2AC%VLmdt;+e`Sc)ve9gEmcQMV#;5F(O zbC=KpQT}Lhek8H+2!)?#PCyAm{ZZ$ z-z%@LZ!+2JB}o>|k%Y`E$|8)1XD&JzDg2-6w>E_FoMTuDV0bN$C?ptbaWEUtcRQLs z!vPXo>(=ll{6eWT3dF;n66Yp51f#2?_4~{6#%V_Hd<4atoh5ke1tSW1#)rNN7Dd*9 zpb&VG4dX?AJrwP^*m{*bR&iIAjs|5C1ka>2xR!Y!A}d^F%KI#SE}&bGD^UZsGau>7 zD_3jb8xRqW=(RK3@bcBBtom`8!cFm|>|XbHtTY#myDD-ta0E#>FFO+2LWh-_+NN2> z+ZrAs9>0t?Jo6-Ab}Dy~C{nv|$afTOtZaWHy9hA}TY`X-wCpD-fFSRR4t$bEj$;vJ z?TD@yWIk@f2^ut^@aY`YqM3>!qDn;7j?C3K#vA21hTdnmwfDtzTs=R2Gv;DUJV67e z_KM=?!kV{N0@L0gsoq(VT0^d_hikzJm&{KYbQXis@6dsC})4O{pTDVV9vOJTo1R*RzSr~;Ha6o&%TexC61?t9n?2Uev% zsu+#y6vz5zLK%blJpxr`R5ZG+N?n3(dh+T?CKUax;tBT+iPPexmj6v=nh#>fG zWig*gMw}XY`lyDbR8DrQ7RE3HWWk)(iAkY9hm%3ArH9-3uH+85Zt_eDnEc*rxiyF_ zG8q+Q#LCt-`*QH%0ojc~dFqrRc@C}|p<4#(C36-#!nySKp!XpG7{_~BBnHv-#!(UGeQ0D5WWk8jXkn!q6df*q|3UQE zzM4@*=RhWt6E2uZXIk96ze2~uxw%EaMCiKJ1hjdJ=PyM1Y)7F$XI|jvC7zYv>t(`&~-=-LsDr3&g`1;njg9gg(dZ)|w z*UBSWFCby|;R(&QNM(4Rawt&A!yN=2LG=s;57#JpY>UhEybI-%i2ZiugCQQ#1FU66 z$pm{)tW)qKZ7v!OKyIe@j^;WDb4Oy7bhw$fz5Dqr_-z9MBolEB-k7njbM!b9XGm=( zi{)6Xd@yBYA*y6YKvVegU9Hb5qr8)iY2e6cCR3b;Bszmyw5C(7J_SN<=Bl^95WQKl zMyRfa8+;^G1%=|UxX#+$hg{QINsr;kOf%#mI9Fwq>YQQ;#^uTK&G#X^BB805CMX2_ zzHX&M&2$-!30q-|By4pwKpo_ShEYN`hH9ck#ARB!>_+b^K|{uSXY)KV*CJ_St(>1E zoT1x&g(E%rEk9WOa0aK@rd3O#BYco!W7fRpRx3FdZMq`fc!)XW(C3+Ys1=`3nZWGl zA}QLGcw&LfKFs2)yYXoZTTK_)aGt z@7}ScYS9Zp4Jh56sF%8R@gOb5qUqG5oj9Y(Zbk^w`{Bew(kYO}U*#U?czkB0;IQ~| z$^~rNTnzv~^PJ2vO+=~dORNn$Iq;CQMHc9LAqH7Z;(N?AZ!{_IiWZ_DV&eK`V%MZ_ zQDGk;?>xTI)46;%!Q`gqR-7F2mEwZazz6m*e$OedHn>6KXo9|W6K}+Xh}lK9<%amY zg?k`4s9-)^mU}A=1sJWQQ@hdk#ysS-(?dDE6Q!)zQmubpT78v3h03~KdL+E?BFrCM zIX~@cm7cP2gKC#K(_!Z$Qlw-@pjs8{duz#ilPI@7K)>fX6TRO|uW^=+ks}CaT|hHX zlcJRfvkH_|S>Vi1y;Qi$oD#&2hmv^qVw9k7U;Z&am;%PO)$TpP_PZ}rpGGqBA~6x9 z?$XI}Wi-V`S!H*z|J9o9~&WUd^`FdSKI^Wmu4`)B98lr_q=kf%e%apLV|74 zF!9k=vz3&lKwJ4(-da&+V*Vg{O%d}^;Oq1y0fEXIy$1mzA}IHj=NqcWaPbdhZl;z=s=JErPywM+wkC~YIANhG{KhQqqTI|y6vTb~MdH|TKTZYdql zI0Z8zIYGP--~HaV7Wh;=Irq%!wMT{!MjwWr?bPf|?tugPy4X{Ir0#bOKn@>|;U3pW zf&CH;=7*=CH+Nofk{>r84bI4vx7)??u`yevWwmJyvXk@UGoi9v@fjz+#$HGnUvsOf zDFFLuL|o|?*5gx3O7rz5u56nY_{jztRz{o@V0Ix+?XH2r1y1%no0gK99NI}Qc+4j$ zL$O&+1~diViWX1PJ|9nfNz5vw5Z;!x=3sxbB(y2LAY0BQt>#_7p*3kbrIKMZ@pg=H zTH0Svk5gPsh=~Zr81$iJaR?&GmFbCwOC@Kgdzdt2jRA1}(8OBZkoA?L(r$kXbKkjair0S7Mtywqb?ZJUuRiO;-@* zyL4;2xyhs+s!ZiD*t&XZ{I!BHYf43(hIs8#rG1o0K?2EVY`QFEs&E>v5SG?@E<(bf z8~Ow4vv?O0F*db$R%o5sy82L_Jx7q@5Q)t^@&vy8MIC>#y4#Ob?HbrTnC$qrWO4L_ z)S20Lvx+*AeL>(x7v_&zyLmv3nD#CKN>QPU)u=or>IJ{GmCEMu&Ut*y_ssrB?N5h0 zO^ZSDX~BzDVTj#W<>9<+^QKIALq#1Uygq6sq^tYx^F}l!5?ZtjBbd(}$P$y~?p)rq z4zPxohqdbw`G_q$rOm>NMG#8A7RUUH+s{=5xK7|N@~%_g7NO*PCpK^K$yQV8WpmiC zn<0o!lm_O<7(L#aC`mLWCF~tOaHvfWpD!cWAUX#LZaDK|`bmSe-psouV|xLG-^vj@ zt3Me#9Mfe{Ktgor)$e_}DexvbY$owc_^Wt)y^45lEep9>RIIzFKhD7z;az@fOt?Te z`Dwb)VnQuakI^&g($=+zd%_>n=8R}wfz8Q6?8M(h4V`dx8QoTOAq>~e0l9FEWn>RWY$Nt-`~t;utGHIO_K}o$ zB`8J*(0IU{h7Kj{;ByW&UuBs+ylqfx|5LfRHwIh-psc6zf|A7Ta7WY;^b^mB>Cx6X zg4OcNsGQz^9PrnnGOx-UC9EldF&2+Y46cW-dl?RVBfT8^IZd+(DJV@dH01>+f-?X~ z3X5g;GBTElr)^V87YJ3Wa6v)Q2pu#F;z%V&>iMuu&(g(MBfBm5LUI$-PdM@$oTgay zy{vaCE~nOeY6|-lOmO}B2|TE_khkOIq-ZNXKKDGJOnvnc(<@8>6Z5=1R*5n~_~^J$ ze-6ZjKv|C@ZG^3!l6#YPa@Jo?fNy%2i)vU7_a0GC z${b^9DhkG)=-swHs}Pj^;Flg%K@`zD!ze6$ZbR;^Jd-SGgnBGz87R@*0ptuywb zt!m2&L>Vk^c`w(+%~^LBH1>-m!$n5jYh_nwE`)Gy9WE3>lXHmvQ?{y|q{BERw=i@+ zat52H*R}vbEC43X81BW7$uZ$bH%XCvf&g{zKr!aotCdm2DyWk-I`(3VptTCs@I~#C zXx=c}<2ebrsz$KaYA|mXn=y>Irs{-vT*$&Xe|T++8n7N!7JuN}&OA3oXvC~6RxrN8 zSg@54U4l$|)kVq(bLbSCv!r>5nNZT+GJ+~JF`=^peNOl7$MeLSK;L3t9b#BTSd;3& zWy!>Ly(?-*V8CYOOO*J{)fY}NF*H%^60DxKYkiW9^o6xNg4-l*j{pYIK+2Z`V>4HV zU>BM(DOvhWI%^AF%VbGC^`>1S*W9$2sR=WI+qRQ9_&Fz&2S1WwvHEj-+%+>ICXtZ3 zn{mb396dBtRSzvXep(padoBrF6Sv}JuStaE1jjuGl@XcEEpss2v7ugac8Fq)0QRhh zIf2D7yCBRix1i@lA$_3(`wpAIk#-Pv*wqi@HGu8bMz&to;3cI$UPi)zoLtli4LC+# zpd!0zmrVL$H!+Hvt46Yhs#X^rTO`dw5yN~sjD{9#FEql z!D`+RTT~8-U5{&e2(RU;Y%OePTO%6-eSA09cXk-X@1aBQ-+cTerH8*b3UIhRO<=TAWXp65w28qU zbCqsDf+L1}@yD_L4ah!<{peW2ASeH^zS%h&btTK06Av>nXcKJqyxWg;>UYUBBPjC` zf6vbQO%cJ@`NJt+7GdPj()%tO4&@c1d@6zboZ&s>-cd1TqmHrGMeLf{=r-XFgF_A7 z36Pc3*#XiAfd*vL&*ZLaxeNnl)q6vcE+SGm7OAi0nwJ^HF6#{x5cjLQf$0kkAT z!25?JySU&!bK3g84Tak9o`>Si6&6_YGaco)w#fOSuM7B*@~sSn(P}J&6vwTPh#zG~ z8+5~si9MW(bV#~Hht;)(7GT2SlMqRpp=q>Gd7zUKY^R-gv2DS4H38O!>n7!=oE-P= zi4>h>ezUhkh>eW{NuK2_BJtd3G+QK3ED~zpLnAOTq*niCK`s}e(*d=$ibz9WXJtJ~ zxid5$FjA_3#-j!)y7glCX1evuuLPK&2JyPkm#H50o%W5?Di=E1FHUt6Izo}D4E7m$ zL`Z`cI@&1K929)*sea>4{(xSa)>>~bQA0LcKr+Bi@8p$ydFH1d&;yMc?ad*0W4xYn zcJ^%dbr>{*7BBL06RO-+JV)@NW@%^m1#0QBwau<9LI%Wpwo~rB@%bk>P@r}Lbsg>- zyw3beo$|x-vEuf-;JKZgNu|;Yy^yjfrtqy^$~McZHqRHbe{WT>`iAG>h4@E|9axQ@(- zXgr()bswlyEt&IE@~5J1cb1$fXAL>ODPy0+pb66Qipo(sH}xti7bWJ4!hHwMkyaL% znmgsl!w8ZZ;DCMEx#(KV{z=IrM5u%0AsJw73;EI2d47Q^{4Oo)i&wa z@^@fMd>)Z8MVrQ1$T=;dgy-pIr`Z+H!iK6@c2G?uzf% z;9(qo3>~G=d80S;fcQ(=jy3{)ylyVu+{|JrGaK+kLYgWlk0c*8)F@}Rz29x9*Ngr5Z2Ffo zIKESfMX8RhrEb*+G4c`R-&|A6nf+1y%CUvl>;K9ku9j|YHV(l5uuUpn%%P7JL+Xw% zoXfXiyhuuw*rOBR1mGbg|0rbc4G%5kU(S$beDS-Uhd7VA9f4angTfC4!&?%47+!}t zRemu^nqytWXGsIT6c6rSMzdq7uw`<-6`ifebf|*d8I`?FqtdVYFvPqrrxts5qV3>n zFIX6*D1Y0IA?z=U(!0zDtnZ!}to0w{epg8t4hZ06{YJgflsEfTwM}n7$tA3QL*yL( z_bp+&$iF=s)z4P6c~!c~UT4x@p1o>4DlU#rt}Nz`E|!1Iquh^T%6+Uj!xw%rWp}ma z>SlP{SZp~|Y?#GYi_Pdd2JL0F(AuFg2{fdFUYFQW`Eok~I`G+8g5IFt-FJ4z#%sBi zBQqq{$++yjiXuITR197;GDuwnHyxBN=mLV|VKxAtXbTGqUC;n4ORtRvAS z(V23W%X+xjX3GSyTwRnUD3RfK^Iu9yF{M_}NT85H3#4Fm#wtrKm~J%e2lT0d5Vehy z4jiXf{R(Q9X!a(1FA`D^{;R1#As~sOi32XMNK9%;Hyxmb z@x6yig6-yDmF%WvEAB{*2?H~(R1={p96jMl?K{Wn$l?7*jt4zIKW3x!>vZl)l+Oza zFpzMicDP6zLTv%N9!2uLhq~RY~Ab6Cp}-J$3Oax z7#SiK;=gm-B$^U9rRF0TaPVAp7$CwZGL6ym@50gRe7`sdqaqIljRW)lR`_4t{m&8f z>ihqy_y1|&PZj&W-~d1nl;Nw1e`wl&0{_%{{{}j}&WHb>^82TyKZVo3E!n^Rx9}fJ z|CCYxg#Wpi{S7A|`fvE(OWU8|KdaN<;ILN#`#*@ahIF!-* diff --git a/reports/my-intranet-app_architecture_report_20260522_v2.docx b/reports/my-intranet-app_architecture_report_20260522_v2.docx deleted file mode 100644 index 2335adb8d355ab03e7a19ada86af583639b364dd..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 13668 zcmaKT1yEeu(k?E8I|LovU4y#>_u%gC?hu^duEE{iEx5b82Y1Wk-2dKp?n(Xc&D6}U zJvCoft?pjE`|H&${{N% zn*-~KW*ml0V>k)30NW>L-aYtsz{+FizU}PcNi@sYjHP+tx1=hNCpT8J!ZMhl(6gHT zx{Tcp5Dd}1uLN+dSB z+&gF1$$$9E7>zK@Os71;NW$UIt)VpV;cp9!PdSVAMC;BO0079xMO>N}$P`gsq=17& z$k!Jl-1P`lfr~WRr#|_e^9GLls>gD#81>y4pGzG5n8SjBE{^tv|f*FZcVmj>c{Jh~WJ$ z+yQ$pLNk4=GQbY$c#q6lXn`%{dJq(svi)D*?te5WJ~$3dFL}L39^Y5#V^WOmDJf4a zAizYDe zu@vI5CNpxknBC;zk7&=Xjd)G?b)xQo(IBb{@-1QZSJOWddVLbNy#%Hjb`PnwO6)k( zlY|PIek3V1NZXWlaN|L*RvCJR@0nwHK1wr|blKxvgzqB4QR47u8rkm?@VT3unX@=k=g#5ah=C|f3@{)k%yJ3R?#7Y?qqx&a=u*O_W}{N} zF2!ZX_$>!rVu;Hke2>3Gm<0#nSD9u@ zB6#1nI|&T(^Nv$eKXokK+^G@XWnBQH%Zdy>RLi`)wYzF%`jV&IFaSF#-VCvk86x?h130|V9jIwla?Ew4G)^&yR;d&w|Ah1 zB8L*MX(;WGPkg`(35m&EGj$nJcuiE$xe!Z6vpE-Ua*japzTIbbcUV5U_rs8Jew)^K z*uY4nsN=8*Ufooi-lJ)H@0@wtE#I{-!^1i(`zszJcAn0o#I)Hrg}`5jB0g9W4n1IBEa9@XP)9?6TN{8n{JYPDp8>+Patdfl`NkL%o~`M6vjRG{cWY&buT3xU76q& z*OXy+YTT;knc-q}Wc_vm{)g&dd zZe$9c^jCFzl4*q@&e$k;nNo@|F>}WX4#($I(mQ3brS6KaM``_Dv9BQ>lB^p7)f4(X z4MZp<*e8gu@|Qr&MvM5xO(({RpDWgvOYo9FCqvLVy%?F~4jxRQ!z7s1&HNXx=6Gv? z(Q8KpW1`OYkpnw$S>eSX^7UtlflC~T-{3B~f}%}F$Z z+HB}6%Bg(b$KxvQ6#Lop@14>DT-)Z9$Mi$Bja}Uf*^kY{-_R*1xrG?_IP8u===B*! zdtfcV=qQ}rW1G0*>o4gyc(}xTcVm_hFwOH4-R!Hn&uRUhjh1KGdK+Dzc#YfLE1zYP z6QEiH>5UZhdkw`bXhn!oESXFDXoyh#3H&gN@nvCi`%vq;p?JsUOblP zL{?jOmsv$q;G2CPxe5FV0!QI3sX9gSFEhLlfw}ReC0g&fh4}t=)`iVyOK%u@b$b@- z^nBMTVw-x_5i9yT>TdYqB@^fZ4e~0_FFo?C83eT_^T9v5!;^xG25nLllxbd3-wdRs z8tK*P`>I*k!vP1Ifn5ULmh?H=+kLRJ&qLCr^!}ljDNDj)4ojiK2A%;RG<~3iejM3`V}jacsUw-FGPZsNE?- zJiIf1L)RHFwkVmSJMPNU_~S^;E>;6s?5J#CGg8#-)19y8UlN?}wbBDn2SaJ-vn^fS zIe)cI6^Q@dfTF_F_FK@vsZfDf(&NDko4lc_4C!KBLPF`HzlnPO zKBCd>vMRaxiIlnEsPV>eUa%q?t@5y9*0k{5Nz?r%OX-@gru_+`2HgDyn7ik#=w4TW z!`K^>)TH_?5wUxJkzy}C{2@G(z!hHe<#~3CNJR3Eh2^e-Pk`^~Erd$=JG4_UFqd+daUy34!UradY z-1OL|(%igSb=lxXG?-2Mq;NDTk@1JuNPagtXU)i+)~{10mb6ACV89m$eM?24v>o$7 z#3qKVJ~^pCCMMQV2U#l<266WK>pGS}2i~GLA=c3YWYk2mT}6g3Y;2ht%xY$Z3XPu< zkU6W-7KdFIUe`CLy6_9W3tQ9=fgWi)RD6z|XS2_!G8A;{?C6~k)LJ!HKeS)2#k~3J zQmS|v?E3Wv@KV@=Y!a3tx&oH3>!q^;-Rrvg8{ss_F}{2oa>eP(n(JqW+-}1Hd~Kam z;rL#^)htHs5gCcmo}pasfTm!RNNoixTkF8x$~W#0JEMr`dKzK5q1DidLf3R(7}ziOfR zSea0OM=l73K=;=Y;#24-ocrdT3i=?gA5aGZ^|jJVXUwZ^IrE2KfU@W-%fVfC--HtB z??x|Z`!I1A0BQm8rDzQ@qt5lNeX@A(>hYHpX_4ExvsHRz0_wvUM=sITPjL^IHksa( z)evI|q8`4%a><=c`ckat$Qf{7T|5kGQ{Te!W%K7MlI?e$p6~Pm2?NNcfMnYpjQYM@ zdSzcwA=p~$MIu~tnHgi_%lp|Iu+{d0I4CIK;Tw&Cw_S@#b&Q;D1`c4pA5L66zPf0Y z_{-#>t(F9&|6Pw%bbIqX?_Ri|4(RK$D`E&>Sl)v-Ki~ke` zTY>oQnu{?bKp2KhVY4h`zv;VW_kyXqJ(Kj_;I`J?ymhzQ!eV>Lz;|1UXE>L&+z_{i zQJwcxu;lEk#-sooNETbkymy@(_40>nPpeMaSg?cC7P`+55Mgo~Qk~l>{T(=4uaNU)%3%A6SAvBrm;S~T?Lb{ z3c#SkG}}7Wp|QErsD)1NXHs#;hjWJTnk?xTp>=^n;sV$=Fs&}fdErNT4;C4Dz$$%+ zBqjT!2#x+Q`Inzw^=f@s>`#&>(aQq*Na> zYpw^u85_~L)3`UPl5HSy^*V^Q#>eOCpdUGv%zid~rH)i^8D5Pu`(jk}IWx3^4y{$m zc(qL*z6djJkF-|`$<3`{IRKt8B!cFKY{x5EAnv7_pd+z{m4Nt5$f%IRYy@69&9*I5 zEjHNq$BM{-0+8Z1kU8#33Zc&5f>mg8)FObXN?)xK>nn9XKss2H2V?P;+~mi;ZCMGZ zS#!JON#XIQ3%sFdjTfcIR2RYawee2+DVd>-`3-K{WGznFI*hm3nRFKc;1HXvTWPIw zbjutz74b3Fc_d>=qf>`vRjrS1tls~p~;Vd zmun3{tNe-X?c*m95UcS7F*b??Ny^&_{yo1s|BE+?m7F-7Bn|6MzSC$}k+!(f(18+ufqJBsau-lS56gb1xpo+4ve-a1tOpAaCk})syeAxC0n?qh;6l_w* ze;jKVs{s&mTfX4AF+yBd)@GtSNNpzs(ZbB!T?>{20!pcNYg0va3oD;z9pK9W{S3-o z*n1CG@8#z|Q$Q1vvtF|dMzaT#`L1ic;#3ug2yLh4Hv@U6mV%liuW80yrP%(s2h*QL zQ-d|C?<)x<>1NwV97$Mot<8&-RL+N>jVp4Hz%GZc)>%sZ{eIFE8PPHmeqeE0EheD} zkiQ<=ek_#a$^3|@2*ZRgp&LAZF8y4!*Y~+Xp){bf9khjy(@w!*UPuI2wn+PDgL4^H zll8WUwChfv`FgZ@tFc8RQw9@S&BkKCFb`Ed<+7m^zj(~Gyil5PQ5RdD&7O8IJHecb zL;i!V*t##pME>rl;n0GZPbeFpe6zV(wz93=BQIa~aXKO1cbk*_0`g|9N9%*$XbwOV zJ}eh1;Xuj>BJ#eiA6lwwf6MgaL9^(G`Vk;C`X~?z+nogtyR5dAbbXc;rB*Qy;Y|}` zmw#W^VkSOmJW4eA9 zKw0YR4Oxs%4oTs%O?!IiV(f~^IT~*}DN$~|;JM~6LVk2o7{YVkz$-$j>Lw=A{`o25 zg4yI2Q!=GQGa9Ubo?$w#PXMv4poCwQdmD~Q?=v9fR+;(-g-50|bIEEvh)e|qI;Zp3 zHn#gdR13G8{z~W1t~W@8qf{Cs4`Bfrl~C>|NhQ6hAogMqo+pVtS&ziiZ2*}7G&`kF zXk#=-a*b&$aApPu1Diz#6J)k=Uu8^^kKZt2Px|HTdkJ{;Se6$Zg3ym3PzZmYU<7l5 z63b?MX>e69TviU9&C~+tDFc7L2OE+QsIf8}V;yAkcA8@sb6!MtkKLO`vV9i>m2zz> zB*2jZmGbjL#U1pn)F)IxPl70}W^8hTUu0vI*QV0V*BhQwwlWOIAZ%AHBD2`~S<5bG>k`!qgnE8&{mcH3yKt1bp2srYs+q3#Bamsg zNh){Oq3+U)1ZUzWIO`f=$#*u@GCTiUOw8lg5@An5fA%iEudXSN-`}d;&fbS4^+jPD z-BUR^r{oIrZceDts58dHFG?7O zzx>&Rvy-MtW_*vYRo?yLk}1D?p!Ft%x)xVcyOlGON17RNqT#Zw_2qy+CQPG&D5w^W ztEjo!h?WOsS={s`WTiMsQv_JbPC-Dbt}_;JM#zKD4r_K$2nV1#71JJIP&;$Lmra)M zW0nOJZ0*q~sd61h;iOoOLKl-3tZlC&(A^erQmVUC(_JKjwR8j!F7poFCl2P^0nl$0-%hAz}=+%`hB z0S~qc6pxGy^?n+B%8%BX3@u1!$7{2T7b>w)7vdQq&=lXF^l%NQQ>K9*5s#2#w37iZ zKYBl_A;1R2f`zG0b`W8pLn4Y^0J>&BPe?47?zKhg%ZY99I_XUL6K77gbw*ZSnD*+5k@*MSXjBI|E$B5jZW;G3|5LvW!3}0G!s!oB?FUm zid#Hk6(!6@hlfNE2H^>y3m@y>30%1e9P}z*XO-EOUaa-PSkf9!ry4ptOcToJ{!t!& ziMv_&O+U|>67ySc620E2*o0gzQGHq!C~vFq;TM3`hKcW9(;iqCIe7y>H2|&B=4)*I zfa{WsA9^;{<> zq=ym^TE-3;3KCB0Q<)}b{6*<^1U%iH7lD)hV4ql zM*h$o<0>7&IQ%3Ks!z5mDNqEJgE;T~EJ1iA^Y1+-E#aO|6#U`K&zx)Yox3=eL(*hp z34#WNyFS60y)l(@F_nukoqOVF!y+Fgl_vW8e(l9iGpx`X`I76fMRX*PSmHA-imuyb zyrsiMWN4p=hI2xG2#c-y)u{~(5OmS!u@45p{RkGJpudOL0kIQ@2$#(h)7jFkUsyFX zpJFu_%vpcqB`u&31Tw5nbHa}nr`ObceJ-w`2zxUx@9NrLd`8x)-Z4>Tm%PXkm0Xu* zW6Nn#cKShtvwD`JJi6y>ONAXCGApdw(Q1dTK3#h`w}cRnr1ftOH;F}?tiObVD++mY z(W9Hw$%ZvmdAS{A?rlV_H+S^t4@K<#F}!f`ALi-neqI14tZdi@wX>gOVZL7CAW zz=?3QC9Vx?^f!s#cT{CC%M~X<%aX5c7TjJL$HHhNE~xz5(?$WKMvBBK(f{CfAY(hA ztoWsKfrocBr_tpZz7ge_h8@)M_5|+@lvxXT?lK-5&GLkI8s1A^^rkev3kpo6nv5O8 z>`yd_wmbz8Vr1Dc=<__1JZ>O@5EdG46MlyF)Y4~&mNa`ZnSCN*JXoYSMf)){O7_evHz}fv zH}%3Lgq5r>rBr6*i;GuJ$yDbRJuz9kuW0v}8?-XD4uvIsFe|WfKbRna9wa&b=_Ok{ zKMiphh6^||whA*`Q2BkWezzpQXcSx7cNf%&@}t&+!RpM=4cVb~o#fDf1;=CDB+aKL zzlDCusgb2pjh@5#063cf`+PcNb(k`kRe_&_Hc33DK%)u5=hN&GSr+*8ExnG$M#|`_ zW@J(>(vEz#eJ5>ayS>uU6A<6J+Egpv($POF`qaXhI6oH^dz%c7%?(Hoh&Lli?Fd3z zlOKozozgE^?b!7IbEO9v{N4*xT-`OH$@E#sSnNb(*CY9hJR}EI9TPf4)c_rr!QJ#j z31d;eT8>!B(WG1XL}AfPTPX>uCqpON*?0X_u3@%t**n3=c%}kBwRWqe_Dt1AU@1oDikgQurc|uk656y1DoVUZEkknD>al^}?A>pBf@Ea9TWrFZVtd@#4GI0qI?(FvSez|~xruh9jQ{Ysd< zpB3XU=dI-EihiT^o0}@4{H*>7&wbV}-0ymtsXf@OPVA=}Vbe!vLLG9O{UQ-P!ukuZngY6at?}7}U2}qvt%fGIoH+Tf^pVa8Q)C#?B2){Uh#iA#1^) zWxMS|EBGA9B(HcNcrjMG)lJ41)OxtUGP*SPlBRH%^ za$N+3!lQD)9TDqU2mA_S01n<#apm^J)(zRkwl=Fh*~9&Hl-zz21VyJk zJ06ObHY5+g&4)|KI?uKdImOGsF9)Bf2n{FgFZSC`+NskK(3bjk;q4V%^^jP*7R)>P zfRQi9eIAtLYPpI*@EqtGsx_vh{-gRJnPjdHPb!R01C6fE=d3?I&hXd}1(iG9Em0lh zS2OcHUchCf`mhdtB{+)KP(5Gf>*fd7=SA8+^5hcV)azp~aKLE`l z6L3b+gPK1qPokZ)1^-RLn&Rg*16w}-K_jc6I_lwTwk&A|Z188$ya|s~Ylhh(-_=Km_ zuRd^urtntBe|XhMIGfoxZ5$%>L8B&!-r`5fI@MyMVF&ug#Dp^xPxl&CYKh*VT@C%Go zQLPyi&cV=$V?)`7fAonH15@VrE=8#vcDfh$QMGjkGf7jPrZRaO>{c;2Ft_8$lxP;i zlA;(Pd*@@TkGpy)-ROG%%0rXhy!EpwsNeJ%Y1H>S^OK*;xa(_=d9659u+?ry(WV$C zoA$qE{j!wn;_-EO-gg&;IMdU54lBO)E#S9y0dZ>wPh8?&(=U=6HTYT)uQu*3+HurS_DKRWwj-}d>nRqWum(INGU*hqKh<#b^0q!VNq{|P(F2c`Evuf z(DsBmc+$?OQ>zGv39Oi>@)laLTa}J@5)%y8uWM<%)8{*(qzaoE{>?Co5h>u{Sro3bFiO%)?h#zoT<8RI z6geVmj7uy50an3B&y%Ly_CY|MapKO?Q7#Fy~!x0QIi-H^vHK0TnWsw>-} zZJmt-n=31atIO7c2!6NjI$U4wYrr!oK$}APu>wP$+CRmz;iV!|)cSh84=lu;&aOL~ z;0+85 zCvMMl>9wtw;{rNVwypElJr>NX41q zrY-i`NMg>XTFB17PN+OBg$87*I1@?ljX07J1mbZ>m|IH66_|!dlR-4tj`U6sIYD7{ z9g;*>sP6GGUlooVIkv`;vERC^uuI@0yrKKATdXfvuj$X6+z?j~XctqRBdb=PyAoOq z^PyfJ2ai+Hx;U7vJmz9vT+DcW$=KD};Acvkrs%&t;kQBSNK~$e4^Yu*HT^j^@ z>kQIxp{(X8&5b<4Z!Vq;77P&L^d#cfoxUM1gLCcrx$peUPnHK8;1gS^K5nh1-A>VrA_g+5? z?ypL7dzH3%t{MM1P_gsF%A^V$SJ;uFQgbn z_=2m*RuaSHVIQ0P-hF&v!nyKhkM`j=W-I$c4|{3h)je;Kr13s&{^?)2*qoHG?dzLK z*4l*$Jb20>9&O&+0ek z5Pl}e%i@6I(`~vH_eKL;7KZM*-NC~efWLKFuEO=l;GWG6#$tmKI-N`Ml6U`L9zivR z2Oze_C`_nb%U5^|P92c?2cJ{%2QlC#XdK4z{SuY)w@ulu5L*SC(hzWeftwm zr~AqX)hbi>aB1*M?T4%J$S`tpCE*%0~l#I^jK=B$U*1*2AnbhCV z|2)dCHkyei_~>=Wg9ZUX`p;3mqm#RpvE#qmY?N0OR~eDL4{2CE7#e5ShPD`Cfm5j) zg$3Tp)?^Ejv@J8AfnK~cn?%Sfg@v{&yff41%cD;N+T_C_xHRY`n(R_w(igtl9XRU;VwZ4qq-iYU8aXl>EGQ+}UN_2hU0-T7;|$WClTG z>h2p?NL!|vnlO)9j_t zV$kMir)Xh?YgtN&p5-jDKa5nJd7}wT+VUj3Y#yqBvi9t$FtVF6QSat`1?2$p;&S>> zWn6c>H#2o&pyHQBy%T?yndZf?bZa1BV4Eo7=b4?alaPDS5&>?<`lac@3Bh10!3Bb2YwW#}%LLm$iSvEdtd*McpO2Q*>Tk5fiT9C91{fhCYb9h8sFSYU^LgH$*v(*FeS2DD6a2vcN9i!~B6<@1U z{LK*X3Gg{yRT||?ld-1!9122oL4VwLH3bB_JIA?y?VQ?kNMA5}KL55i#moa(drI3H$ z5`rZ|u?3y|y}(QricM+5gH9oC5~*gT#322elw!oM6Ez!0Yo6RtnJ@Qk$b85tYui<2^!E*2`#m2@26Hd#pV9H; zv8^_=IjZfJ-O0z~HHNl6eVx{*rBaZP<{E+{5Qa`tML>U%0&fXmXuxxX#Ua0)^-syU zxx4;vl#7dp?p|4r*}J>pO^YFw=IT0$(=hfUw5!KX*Z4r&HtQsDm>Sjq|L6^3jl}IE zMDVn{CN@Q5(kz{`W2wWW^$KPfTk~ zuGtbz#U|ZRL}glOhD8z;WBtBgNiDsCotb(f;X9@& z0o1zzDFMf1*eZz_?jhzm#0PeOK}kl@L?v7nX$1_?NJYja=U)>(sMOb}aT;pEGG~0s zYt91}iLTnaQ7Sj%&2dTARM0dSE*`{*NqHU(Qg{d+Wg{u@{b~8_Q@gJEB~}#^4o0TQ(g_UN>$j}OUL0Yyw9@X?dEAmC>2Uu zNie6E+A16d+80tQrWEh&wUpoImG>I(G8=US^431}v|UbDiaIH^l$wf>!KHdgmutEg zLDPD*7I9cFN|B&73z2`-+8*A$!JE2uKyG(K<$1v**F{qN3THrmy7wsl+X)_FmAoFU zx=z_iGIAII$A%Ph`-$QX^NUVGIEw563(b3&!DlG)a90(H-X43cLgcvb)wcU=2A@iV zh~u@2g5a#3wUtQCFTEN)`QGg`DFF41Jx{76GV+B;B}5+0?`2Gnn=t|LSNZ&T)+n#s zFwr3;BM`v-NX$g8MJlv#9NFBF^Ys$j4R69mtJ$7&MNLv^$b?@l(RtgA-d#8>(@>He znXmlX6xYj*Wrm~uGR?DO;g66E)ycb^r*`H-&KI=)!m4qg3Xl2lL4yH#4 zqY?_k4V;nG_L*1Di8)3a+;cCTy4NS3^H<{@^MH+{AOd58@#X4ZSNmnGhv^+lgkChsv*GV=+fF0Fp}HYk zzqt^jU*7eO2;b7Omw~UXm2ZDM$f2vYURyq5lmt8o2=+f?l(o63gT9lwt<7HnDoRaT zZdVN5`%q0Q6