diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..1c6e71d --- /dev/null +++ b/.dockerignore @@ -0,0 +1,15 @@ +.git +.env +.venv +__pycache__ +*.pyc +data.db +data.db-wal +data.db-shm +backups +runtime_cache +static/exports +scripts/.chrome-wehago-profile +scripts/data_download +reports +dump.sql diff --git a/.env.docker.example b/.env.docker.example new file mode 100644 index 0000000..8b7e60f --- /dev/null +++ b/.env.docker.example @@ -0,0 +1,19 @@ +INTRANET_PORT=8010 +# Windows Docker Desktop에서 compose를 실행할 때는 아래 UNC 경로를 사용합니다. +INTRANET_RUNTIME_ROOT=\\wsl.localhost\Ubuntu\home\b17301\intranet-runtime +WEHAGO_HOST_SOURCE_ROOT=\\wsl.localhost\Ubuntu\home\b17301\WEHAGO_DB +# WSL 내부 docker CLI를 정상 사용할 수 있는 환경이면 아래 Linux 경로도 사용할 수 있습니다. +# INTRANET_RUNTIME_ROOT=/home/b17301/intranet-runtime +# WEHAGO_HOST_SOURCE_ROOT=/home/b17301/WEHAGO_DB +APP_UID=1000 +APP_GID=1000 +INTRANET_DB_PATH=/home/b17301/intranet-runtime/db/data.db +INTRANET_BACKUP_DIR=/home/b17301/intranet-runtime/backups +INTRANET_CACHE_ROOT=/home/b17301/intranet-runtime/cache +INTRANET_COMPARE_EXPORT_DIR=/home/b17301/intranet-runtime/exports/wehago_compare +INTRANET_HANMAC_EXPORT_DIR=/home/b17301/intranet-runtime/exports/hanmac +WEHAGO_SOURCE_ROOT=/home/b17301/WEHAGO_DB +INTRANET_REQUIRE_SAFE_SQLITE=0 +INTRANET_WAL_WARN_BYTES=268435456 +INTRANET_WAL_BLOCK_HEAVY_BYTES=536870912 +HM_APP_MAINTENANCE_ENABLED=0 diff --git a/.gitignore b/.gitignore index d2c1d07..d010cba 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ .venv/ +.env __pycache__/ *.pyc data.db-shm @@ -7,3 +8,7 @@ backups/ tmp_*.py static/exports/ scripts/data_download/ +scripts/.chrome-wehago-profile/ +runtime_cache/ +intranet-runtime/ +.dev-state/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..a0f4d40 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,54 @@ +FROM python:3.12-slim-bookworm + +ARG SQLITE_ARCHIVE=sqlite-autoconf-3530100.tar.gz +ARG SQLITE_URL=https://www.sqlite.org/2026/sqlite-autoconf-3530100.tar.gz +ARG SQLITE_SHA3_256=36ca143645cf76997d07b66e9244c636b8ccdec64a1d50558259c4e415e6558b +ARG APP_UID=1000 +ARG APP_GID=1000 + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + LD_LIBRARY_PATH=/usr/local/lib \ + INTRANET_DB_PATH=/runtime/db/data.db \ + INTRANET_BACKUP_DIR=/runtime/backups \ + INTRANET_CACHE_ROOT=/runtime/cache \ + INTRANET_COMPARE_EXPORT_DIR=/runtime/exports/wehago_compare \ + INTRANET_HANMAC_EXPORT_DIR=/runtime/exports/hanmac \ + WEHAGO_SOURCE_ROOT=/source/wehago \ + INTRANET_REQUIRE_SAFE_SQLITE=1 + +RUN apt-get update \ + && apt-get install -y --no-install-recommends build-essential ca-certificates curl \ + && curl -fsSL "${SQLITE_URL}" -o "/tmp/${SQLITE_ARCHIVE}" \ + && python -c "import hashlib, pathlib; p=pathlib.Path('/tmp/${SQLITE_ARCHIVE}'); actual=hashlib.sha3_256(p.read_bytes()).hexdigest(); expected='${SQLITE_SHA3_256}'; assert actual == expected, (actual, expected)" \ + && mkdir -p /tmp/sqlite-src \ + && tar -xzf "/tmp/${SQLITE_ARCHIVE}" -C /tmp/sqlite-src --strip-components=1 \ + && cd /tmp/sqlite-src \ + && ./configure --prefix=/usr/local --enable-shared --disable-static \ + && make -j2 \ + && make install \ + && ldconfig \ + && python -c "import sqlite3; assert sqlite3.sqlite_version_info >= (3, 51, 3), sqlite3.sqlite_version" \ + && rm -rf /tmp/sqlite-src "/tmp/${SQLITE_ARCHIVE}" \ + && apt-get purge -y --auto-remove build-essential curl \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /app + +COPY requirements.txt /app/requirements.txt +RUN pip install --no-cache-dir -r /app/requirements.txt + +COPY . /app + +RUN mkdir -p /runtime/db /runtime/backups /runtime/cache /runtime/exports/wehago_compare /runtime/exports/hanmac \ + && groupadd --gid "${APP_GID}" intranet \ + && useradd --create-home --uid "${APP_UID}" --gid "${APP_GID}" intranet \ + && chown -R intranet:intranet /app /runtime + +USER intranet +EXPOSE 8010 + +HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \ + CMD python -c "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8010/health', timeout=3).read()" + +CMD ["python", "main.py"] diff --git a/compose.dev.yaml b/compose.dev.yaml new file mode 100644 index 0000000..0b5d5e7 --- /dev/null +++ b/compose.dev.yaml @@ -0,0 +1,6 @@ +services: + intranet-app: + environment: + INTRANET_AUTO_RELOAD: "1" + volumes: + - ./:/app:ro diff --git a/compose.yaml b/compose.yaml new file mode 100644 index 0000000..d747a74 --- /dev/null +++ b/compose.yaml @@ -0,0 +1,33 @@ +services: + intranet-app: + build: + context: . + dockerfile: Dockerfile + args: + APP_UID: "${APP_UID:-1000}" + APP_GID: "${APP_GID:-1000}" + image: my-intranet-app:sqlite-3.53.1 + container_name: my-intranet-app + restart: unless-stopped + environment: + INTRANET_PORT: "8010" + INTRANET_AUTO_RELOAD: "0" + INTRANET_REQUIRE_SAFE_SQLITE: "1" + INTRANET_WAL_WARN_BYTES: "268435456" + INTRANET_WAL_BLOCK_HEAVY_BYTES: "536870912" + HM_APP_MAINTENANCE_ENABLED: "0" + INTRANET_DB_PATH: /runtime/db/data.db + INTRANET_BACKUP_DIR: /runtime/backups + INTRANET_CACHE_ROOT: /runtime/cache + INTRANET_COMPARE_EXPORT_DIR: /runtime/exports/wehago_compare + INTRANET_HANMAC_EXPORT_DIR: /runtime/exports/hanmac + HMBIZ_PROCESS_DB_PATH: /runtime/db/hmbiz-process-flow.db + WEHAGO_SOURCE_ROOT: /source/wehago + ports: + - "${INTRANET_PORT:-8010}:8010" + volumes: + - "${INTRANET_RUNTIME_ROOT:-/home/b17301/intranet-runtime}/db:/runtime/db" + - "${INTRANET_RUNTIME_ROOT:-/home/b17301/intranet-runtime}/backups:/runtime/backups" + - "${INTRANET_RUNTIME_ROOT:-/home/b17301/intranet-runtime}/cache:/runtime/cache" + - "${INTRANET_RUNTIME_ROOT:-/home/b17301/intranet-runtime}/exports:/runtime/exports" + - "${WEHAGO_HOST_SOURCE_ROOT:-/home/b17301/WEHAGO_DB}:/source/wehago:ro" diff --git a/data.db b/data.db index bd731a6..acade9f 100644 Binary files a/data.db and b/data.db differ diff --git a/deployment/nginx_intranet_auth.conf.example b/deployment/nginx_intranet_auth.conf.example new file mode 100644 index 0000000..5d58e3d --- /dev/null +++ b/deployment/nginx_intranet_auth.conf.example @@ -0,0 +1,24 @@ +# Example reverse-proxy guard for the intranet app. +# Put this in an nginx server block. To keep 172.16.40.90:8010 as the public +# address, move the FastAPI app to 127.0.0.1:8011 and let nginx listen on 8010. +# +# Create the password file: +# sudo apt-get install apache2-utils +# sudo htpasswd -c /etc/nginx/.htpasswd-hanmac USERNAME + +server { + listen 8010; + server_name 172.16.40.90; + + auth_basic "Hanmac Intranet"; + auth_basic_user_file /etc/nginx/.htpasswd-hanmac; + + location / { + proxy_pass http://127.0.0.1:8011; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } +} diff --git a/docs/DOCKER_SQLITE_MIGRATION.md b/docs/DOCKER_SQLITE_MIGRATION.md new file mode 100644 index 0000000..5302a7d --- /dev/null +++ b/docs/DOCKER_SQLITE_MIGRATION.md @@ -0,0 +1,191 @@ +# Docker and SQLite Migration + +## Goal + +Move the application to a reproducible Docker runtime with SQLite 3.53.1, while +keeping the live database safe and preparing to separate rebuildable comparison +caches from durable business data. + +## Current Constraints + +- The live database is large and active. Do not copy `data.db` while assuming the + WAL file can be ignored. +- WEHAGO browser automation currently depends on the Windows Chrome debugging + session. It remains outside the app container during the first migration. +- SQLite WAL databases must remain on the WSL Linux filesystem. Do not place the + runtime DB on `/mnt/c`, `/mnt/d`, a network share, or a synchronized folder. +- Only one app container may write the SQLite database in the initial Docker + rollout. + +## Phase 1: Safe Runtime Foundation + +Implemented in the application: + +- Runtime paths can be supplied through environment variables. +- Docker requires SQLite `>= 3.51.3` and builds SQLite `3.53.1`. +- Heavy WEHAGO query-cache generation is blocked when WAL is at least `512MB`. +- Docker starts with `HM_APP_MAINTENANCE_ENABLED=0` so the legacy + single-transaction cache cleanup does not lock new snapshot work; run cleanup + only in a maintenance window until cache tables are separated. +- `scripts/sqlite_runtime_admin.py` provides read-only status, progress-reporting + backup with selectable verification, and explicitly acknowledged checkpoint commands. + +Check the existing database without changing it: + +```bash +.venv/bin/python scripts/sqlite_runtime_admin.py status --include-counts +``` + +## Phase 2: Install Docker Desktop + +On Windows, install Docker Desktop and enable: + +1. Use the WSL 2 based engine. +2. WSL integration for the Ubuntu distribution containing this repository. + +After installation, inside WSL confirm: + +```bash +docker version +docker compose version +``` + +## Phase 3: Create an Isolated Trial Runtime + +Do not connect the first container run to the live DB. + +```bash +mkdir -p /home/b17301/intranet-runtime/{db,cache,backups,exports} +.venv/bin/python scripts/sqlite_runtime_admin.py backup \ + --output /home/b17301/intranet-runtime/db/data.db \ + --verify smoke +cp .env.docker.example .env +# APP_UID/APP_GID는 WSL에서 `id -u` / `id -g` 결과와 맞춰야 합니다. +docker compose build intranet-app +docker compose run --rm intranet-app python -c \ + "import sqlite3; print(sqlite3.sqlite_version); assert sqlite3.sqlite_version_info >= (3, 51, 3)" +docker compose up -d intranet-app +``` + +Verify the trial application on `http://127.0.0.1:8010` only after stopping the +existing local server or publishing the container on a different port. +`smoke` verifies that the copied DB opens and its schema is readable; schedule +`--verify quick` or `--verify full` separately because this cache-heavy DB makes +even `quick_check` a long maintenance operation. + +Trial validation checklist: + +```bash +curl -sS http://127.0.0.1:8010/health +docker compose exec intranet-app python scripts/sqlite_runtime_admin.py status --include-counts +docker compose exec intranet-app python scripts/sqlite_runtime_admin.py cache-retention-report +``` + +The status output should show SQLite `3.53.1`, no WAL warning, and the expected +runtime DB path under `/runtime/db`. The cache retention report shows whether +old export-row cache signatures are still occupying the DB. It is diagnostic by +default. + +The application loads `.env` itself through `python-dotenv`, so local Python +commands and VS Code terminal sessions do not need VS Code terminal environment +injection to be enabled. `.env` is ignored by Git and excluded from Docker image +build context; keep secrets and machine-specific paths there, not in committed +files. + +## Phase 4: Maintenance Cutover + +Schedule a maintenance window before operating on the live database: + +1. Stop the existing app and all background cache jobs. +2. Create a final SQLite backup. +3. Run a checkpoint only while the app is stopped. +4. Validate the copied DB under Docker. +5. Start only the Docker app service. +6. Preserve the old live database for rollback. + +Example commands after the server is stopped: + +```bash +.venv/bin/python scripts/sqlite_runtime_admin.py backup \ + --output /home/b17301/intranet-runtime/db/data.db \ + --verify full +.venv/bin/python scripts/sqlite_runtime_admin.py checkpoint \ + --mode truncate --ack-maintenance-window +docker compose up -d intranet-app +``` + +## Phase 5: Cache Database Separation + +This is the next application migration after Docker trial validation. + +Planned storage layout: + +```text +/runtime/db/core.db + Durable project data, review decisions, manual pairings, settings + +/runtime/db/source.db + Imported WEHAGO/Hanmac source rows and source metadata + +/runtime/cache/compare///projection.db + Rebuildable query projection and export rows + +/runtime/cache/candidate///candidate.db + Rebuildable raw ERP match candidates +``` + +The application must switch projection files only after a complete build and +verification. Old logic signatures must never be served as current results. +Cache files can then be removed by retention policy instead of deleting millions +of rows from the durable DB. + +The first separation target should be the query projection/export-row cache, +because it is rebuilt by year/range and already has a logic signature. The +second target should be raw ERP trace candidates. Durable review decisions, +manual matches, source imports, and configuration should stay in the core DB. + +Until the cache DB split is complete, use the retention report first and delete +only rebuildable orphan export-row cache in a maintenance window: + +```bash +docker compose exec intranet-app python scripts/sqlite_runtime_admin.py prune-orphan-export-cache +docker compose exec intranet-app python scripts/sqlite_runtime_admin.py prune-orphan-export-cache \ + --execute --ack-delete-rebuildable-cache +``` + +The first command is a dry run. The second command deletes only export-row cache +whose `(year, snapshot_signature)` no longer matches a ready snapshot. Follow it +with a checkpoint only while the app is stopped if the WAL/database file needs to +be compacted. + +Query projections are also rebuildable and can become the largest cache tables. +Keep the newest projection signatures per range and prune older ones during a +maintenance window: + +```bash +docker compose exec intranet-app python scripts/sqlite_runtime_admin.py query-retention-report --keep 2 +docker compose exec intranet-app python scripts/sqlite_runtime_admin.py prune-old-query-projections --keep 2 +docker compose exec intranet-app python scripts/sqlite_runtime_admin.py prune-old-query-projections \ + --keep 2 --execute --ack-delete-rebuildable-cache +``` + +## Daily Startup + +Docker Desktop must be running for the containerized app. Visual Studio Code is +only an editor; opening VS Code alone does not start the Docker engine or the app +container. + +Recommended daily workflow after Docker cutover: + +1. Start Docker Desktop, or configure Docker Desktop to start on Windows login. +2. Open VS Code for editing. +3. Confirm the app container is running: + +```bash +docker compose ps +curl -sS http://127.0.0.1:8010/health +``` + +Because `compose.yaml` uses `restart: unless-stopped`, the app container normally +starts again when Docker Desktop starts. If you explicitly stop the container, +run `docker compose up -d intranet-app` before using the app. diff --git a/dump.sql b/dump.sql new file mode 100644 index 0000000..e69de29 diff --git a/main.py b/main.py index f5808c1..477d7f5 100644 --- a/main.py +++ b/main.py @@ -1,19 +1,28 @@ import copy +import base64 import os import logging +import hashlib +import hmac import json +import math import re +import secrets +import shutil import sqlite3 +import subprocess +import sys import threading import time import tempfile +import uuid import zipfile from datetime import date, datetime, timedelta from decimal import Decimal, InvalidOperation, ROUND_HALF_UP from functools import lru_cache from pathlib import Path from typing import Any -from urllib.parse import parse_qs, quote_plus +from urllib.parse import parse_qs, quote_plus, unquote_plus import uvicorn from fastapi import FastAPI, File, Request, UploadFile @@ -26,8 +35,25 @@ from sqlalchemy import bindparam, create_engine, event, text from sqlalchemy.engine import URL from sqlalchemy.exc import OperationalError from starlette.concurrency import run_in_threadpool +from starlette.middleware.gzip import GZipMiddleware from datasette.app import Datasette +from runtime_config import ( + BACKUP_DIR, + DB_PATH, + HANMAC_EXPORT_DIR, + WAL_BLOCK_HEAVY_BYTES, + ensure_runtime_directories, + validate_sqlite_runtime, +) from wehago_compare import ( + QUERY_PROJECTION_VERSION, + _clear_compare_runtime_caches, + _group_has_offset_tax_invoice_structure, + _group_has_tax_invoice_cancel_signal, + _load_hanmac_unconnected_source_groups, + _offset_group_vector, + _offset_vectors_cancel_each_other, + _voucher_groups_within_days, cleanup_compare_runtime_artifacts, enqueue_default_pair_recommend_precompute, export_wehago_status_rows_xlsx, @@ -50,6 +76,7 @@ from wehago_compare import ( save_bridge_review_settings, save_recommended_pair_matches, save_manual_pair_matches, + save_recheck_change_rows, save_recheck_review_rows, undo_last_action, ) @@ -69,6 +96,8 @@ _DB_INIT_LOCK = threading.Lock() _DB_INIT_DONE = False _DB_ANALYZE_LOCK = threading.Lock() _DB_ANALYZE_LAST_ATTEMPT_AT = 0.0 +_DB_VACUUM_LOCK = threading.Lock() +_DB_VACUUM_LAST_ATTEMPT_AT = 0.0 _HANMAC_LAST_AGGREGATE_DIAGNOSTICS: dict[str, Any] = {} _HANMAC_AGGREGATE_CACHE_TTL_SEC = 300.0 _HANMAC_AGGREGATE_REFRESHING: set[str] = set() @@ -83,9 +112,55 @@ _HANMAC_EXPORT_WORKER_THREAD: threading.Thread | None = None _APP_MAINTENANCE_WORKER_LOCK = threading.Lock() _APP_MAINTENANCE_WORKER_STARTED = False _APP_MAINTENANCE_WORKER_THREAD: threading.Thread | None = None +_SYSTEM_JOB_EVENT = threading.Event() +_SYSTEM_JOB_WORKER_LOCK = threading.Lock() +_SYSTEM_JOB_WORKER_STARTED = False +_SYSTEM_JOB_WORKER_THREAD: threading.Thread | None = None +_SYSTEM_JOB_LAST_STALE_CLEANUP_AT = 0.0 +_APP_POST_STARTUP_WARMUP_LOCK = threading.Lock() +_APP_POST_STARTUP_WARMUP_STARTED = False +SYSTEM_JOB_STALE_RUNNING_SECONDS = 2 * 60 * 60 +AUTH_COOKIE_NAME = "hm_session" +AUTH_SESSION_SECONDS = int(os.getenv("HM_AUTH_SESSION_SECONDS", "28800") or "28800") +AUTH_PBKDF2_ITERATIONS = 260000 +AUTH_LAST_SEEN_UPDATE_SECONDS = int(os.getenv("HM_AUTH_LAST_SEEN_UPDATE_SECONDS", "300") or "300") +AUTH_SECRET = os.getenv("HM_AUTH_SECRET", "") +if not AUTH_SECRET: + AUTH_SECRET = hashlib.sha256(str(DB_PATH).encode("utf-8")).hexdigest() +AUTH_PUBLIC_PATHS = {"/login", "/logout", "/health", "/healthz"} +AUTH_PERMISSION_BY_PREFIX = { + "/annual-summary": "annual_summary", + "/static/hm-biz-process": "biz_process", + "/biz-process-viewer": "biz_process", + "/biz-process": "biz_process", + "/process-cost": "process_cost", + "/cost-analysis": "cost_analysis", + "/projects": "projects", + "/wehago-compare": "wehago_compare", + "/hanmac-browser": "hanmac_browser", + "/db-browser": "db_browser", + "/db": "db_browser", + "/admin": "admin", +} +AUTH_NAV_ITEMS = [ + {"href": "/", "label": "대시보드", "permission": "dashboard", "active": "exact"}, + {"href": "/annual-summary", "label": "연도별 수익/비용", "permission": "annual_summary", "active": "exact"}, + {"href": "/biz-process", "label": "HM-BIZ-PROCESS", "permission": "biz_process", "active": "exact"}, + {"href": "/process-cost", "label": "프로젝트 원가", "permission": "process_cost", "active": "exact"}, + {"href": "/cost-analysis", "label": "프로젝트 손익분석", "permission": "cost_analysis", "active": "exact"}, + {"href": "/projects", "label": "프로젝트 정보", "permission": "projects", "active": "exact"}, + {"href": "/wehago-compare", "label": "전표비교", "permission": "wehago_compare", "active": "exact"}, + {"href": "/hanmac-browser", "label": "hanmac DB_external", "permission": "hanmac_browser", "active": "exact"}, + {"href": "/db-browser", "label": "DB 조회", "permission": "db_browser", "active": "db"}, + {"href": "/admin/users", "label": "사용자 관리", "permission": "admin", "active": "admin"}, +] DB_BACKUP_MIN_INTERVAL_SECONDS = 900.0 DB_BACKUP_KEEP_COUNT = 24 DB_ANALYZE_MIN_INTERVAL_SECONDS = 6 * 60 * 60 +DB_VACUUM_MIN_INTERVAL_SECONDS = 24 * 60 * 60 +DB_VACUUM_FREELIST_MIN_BYTES = int(os.getenv("HM_AUTO_VACUUM_FREELIST_MIN_BYTES", str(2 * 1024 * 1024 * 1024))) +DB_VACUUM_WINDOW_START_HOUR = int(os.getenv("HM_AUTO_VACUUM_WINDOW_START_HOUR", "2") or "2") +DB_VACUUM_WINDOW_END_HOUR = int(os.getenv("HM_AUTO_VACUUM_WINDOW_END_HOUR", "5") or "5") APP_MAINTENANCE_INTERVAL_SECONDS = 15 * 60.0 EXPORT_RETENTION_SECONDS = 24 * 60 * 60 QUERY_CACHE_RETENTION_SECONDS = 7 * 24 * 60 * 60 @@ -102,18 +177,29 @@ ANNUAL_SUMMARY_BOOTSTRAP_CACHE_TTL_SECONDS = 300.0 _DASHBOARD_BOOTSTRAP_CACHE_LOCK = threading.Lock() _DASHBOARD_BOOTSTRAP_CACHE: dict[tuple[Any, ...], dict[str, Any]] = {} DASHBOARD_BOOTSTRAP_CACHE_TTL_SECONDS = 300.0 +_PROJECT_BOOTSTRAP_CACHE_LOCK = threading.Lock() +_PROJECT_BOOTSTRAP_CACHE: dict[tuple[Any, ...], dict[str, Any]] = {} +PROJECT_BOOTSTRAP_CACHE_TTL_SECONDS = 300.0 +_PROJECT_ACCOUNT_BREAKDOWN_CACHE_LOCK = threading.Lock() +_PROJECT_ACCOUNT_BREAKDOWN_CACHE: dict[tuple[Any, ...], dict[str, Any]] = {} +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-v11-linked-balance" app = FastAPI() +app.add_middleware(GZipMiddleware, minimum_size=1200, compresslevel=5) BASE_DIR = Path(__file__).resolve().parent STATIC_DIR = BASE_DIR / "static" TEMPLATES_DIR = BASE_DIR / "templates" -DB_PATH = BASE_DIR / "data.db" -BACKUP_DIR = BASE_DIR / "backups" +HMBIZ_PROCESS_STATIC_DIR = STATIC_DIR / "hm-biz-process" +HMBIZ_PROCESS_SEED_DB_PATH = BASE_DIR / "storage" / "hm-biz-process" / "flow.db" +HMBIZ_PROCESS_DB_PATH = Path(os.getenv("HMBIZ_PROCESS_DB_PATH", str(HMBIZ_PROCESS_SEED_DB_PATH))) STATIC_DIR.mkdir(exist_ok=True) -BACKUP_DIR.mkdir(exist_ok=True) -HANMAC_EXPORT_DIR = Path(tempfile.gettempdir()) / "my_intranet_hanmac_exports" +ensure_runtime_directories() templates = Jinja2Templates(directory=str(TEMPLATES_DIR)) app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static") @@ -122,6 +208,516 @@ engine = create_engine( connect_args={"check_same_thread": False, "timeout": 30}, ) +_DEFAULT_HANMAC_HOLIDAY_LINES = """ +2018-01-01|새해 +2018-02-15|설날 +2018-02-16|설날 +2018-02-17|설날 +2018-03-01|삼일절 +2018-05-05|어린이날 +2018-05-07|대체휴일 +2018-05-22|부처님오신날 +2018-06-06|현충일 +2018-06-13|지방선거 +2018-08-15|광복절 +2018-09-23|추석 +2018-09-24|추석 +2018-09-25|추석 +2018-09-26|대체휴일 +2018-10-03|개천절 +2018-10-09|한글날 +2018-12-25|크리스마스 +2019-01-01|새해 +2019-02-04|설날 +2019-02-05|설날 +2019-02-06|설날 +2019-03-01|삼일절 +2019-05-05|어린이날 +2019-05-06|대체휴일 +2019-05-12|부처님오신날 +2019-06-06|현충일 +2019-08-15|광복절 +2019-09-12|추석 +2019-09-13|추석 +2019-09-14|추석 +2019-10-03|개천절 +2019-10-09|한글날 +2019-12-25|크리스마스 +2020-01-01|새해 +2020-01-24|설날 +2020-01-25|설날 +2020-01-26|설날 +2020-01-27|설날대체휴일 +2020-03-01|삼일절 +2020-04-15|국회의원선거 +2020-04-30|부처님오신날 +2020-05-05|어린이날 +2020-06-06|현충일 +2020-08-15|광복절 +2020-08-17|임시공휴일 +2020-09-30|추석 +2020-10-01|추석 +2020-10-02|추석 +2020-10-03|개천절 +2020-10-09|한글날 +2020-12-25|크리스마스 +2021-01-01|새해 +2021-02-11|설날 +2021-02-12|설날 +2021-02-13|설날 +2021-03-01|삼일절 +2021-05-05|어린이날 +2021-05-19|부처님오신날 +2021-06-06|현충일 +2021-08-15|광복절 +2021-08-16|대체휴일 +2021-09-20|추석 +2021-09-21|추석 +2021-09-22|추석 +2021-10-03|개천절 +2021-10-04|대체휴일 +2021-10-09|한글날 +2021-10-11|대체휴일 +2021-12-25|크리스마스 +2022-01-01|1월1일 +2022-01-31|설날 +2022-02-01|설날 +2022-02-02|설날 +2022-03-01|삼일절 +2022-03-09|대통령선거일 +2022-05-05|어린이날 +2022-05-08|부처님오신날 +2022-06-01|전국동시지방선거 +2022-06-06|현충일 +2022-08-15|광복절 +2022-09-09|추석 +2022-09-10|추석 +2022-09-11|추석 +2022-09-12|대체공휴일 +2022-10-03|개천절 +2022-10-09|한글날 +2022-10-10|대체공휴일 +2022-12-25|기독탄신일 +2023-01-01|1월1일 +2023-01-21|설날 +2023-01-22|설날 +2023-01-23|설날 +2023-01-24|대체공휴일 +2023-03-01|삼일절 +2023-05-05|어린이날 +2023-05-27|부처님오신날 +2023-05-29|대체공휴일 +2023-06-06|현충일 +2023-08-15|광복절 +2023-09-28|추석 +2023-09-29|추석 +2023-09-30|추석 +2023-10-02|임시공휴일 +2023-10-03|개천절 +2023-10-09|한글날 +2023-12-25|기독탄신일 +2024-01-01|1월1일 +2024-02-09|설날 +2024-02-10|설날 +2024-02-11|설날 +2024-02-12|대체공휴일(설날) +2024-03-01|삼일절 +2024-04-10|국회의원선거 +2024-05-05|어린이날 +2024-05-06|대체공휴일(어린이날) +2024-05-15|부처님오신날 +2024-06-06|현충일 +2024-08-15|광복절 +2024-09-16|추석 +2024-09-17|추석 +2024-09-18|추석 +2024-10-01|임시공휴일 +2024-10-03|개천절 +2024-10-09|한글날 +2024-12-25|기독탄신일 +2025-01-01|1월1일 +2025-01-27|임시공휴일 +2025-01-28|설날 +2025-01-29|설날 +2025-01-30|설날 +2025-03-01|삼일절 +2025-03-03|대체공휴일 +2025-05-05|어린이날 / 부처님오신날 +2025-05-06|대체공휴일 +2025-06-06|현충일 +2025-08-15|광복절 +2025-10-03|개천절 +2025-10-05|추석 +2025-10-06|추석 +2025-10-07|추석 +2025-10-08|대체공휴일 +2025-10-09|한글날 +2025-12-25|기독탄신일 +2026-01-01|1월1일 +2026-02-16|설날 +2026-02-17|설날 +2026-02-18|설날 +2026-03-01|삼일절 +2026-03-02|대체공휴일(삼일절) +2026-05-05|어린이날 +2026-05-24|부처님오신날 +2026-05-25|대체공휴일(부처님오신날) +2026-06-03|전국동시지방선거 +2026-06-06|현충일 +2026-08-15|광복절 +2026-08-17|대체공휴일(광복절) +2026-09-24|추석 +2026-09-25|추석 +2026-09-26|추석 +2026-10-03|개천절 +2026-10-05|대체공휴일(개천절) +2026-10-09|한글날 +2026-12-25|기독탄신일 +""".strip() + + +def _hanmac_default_holiday_type(holiday_name: str) -> str: + if "대체" in holiday_name: + return "substitute" + if "임시" in holiday_name or "선거" in holiday_name: + return "company" + return "legal" + + +def _seed_default_hanmac_holidays(conn: Any) -> None: + existing_count = conn.execute(text("SELECT COUNT(*) FROM hanmac_holidays")).scalar() or 0 + if existing_count: + return + rows: list[dict[str, str]] = [] + for line in _DEFAULT_HANMAC_HOLIDAY_LINES.splitlines(): + holiday_date, holiday_name = [part.strip() for part in line.split("|", 1)] + rows.append( + { + "holiday_date": holiday_date, + "holiday_name": holiday_name, + "holiday_type": _hanmac_default_holiday_type(holiday_name), + "memo": "기본 휴무일 기준(사용자 제공 2018년 이후)", + } + ) + conn.execute( + text( + """ + INSERT OR IGNORE INTO hanmac_holidays ( + holiday_date, holiday_name, holiday_type, memo, created_at, updated_at + ) VALUES ( + :holiday_date, :holiday_name, :holiday_type, :memo, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP + ) + """ + ), + rows, + ) + + +DEFAULT_HANMAC_LEAVE_RULES: tuple[dict[str, Any], ...] = ( + { + "keyword": "시차", + "leave_label": "시차", + "rule_type": "explicit_hours", + "default_hours": 0.0, + "priority": 10, + "memo": "개별 입력 시간/분 또는 값 컬럼을 시간으로 반영", + }, + { + "keyword": "반차", + "leave_label": "반차", + "rule_type": "fixed_hours", + "default_hours": 4.0, + "priority": 20, + "memo": "명시 시간이 없으면 4시간", + }, + { + "keyword": "연차", + "leave_label": "연차", + "rule_type": "full_day", + "default_hours": 8.0, + "priority": 30, + "memo": "명시 시간이 없으면 1일 8시간", + }, + { + "keyword": "포상", + "leave_label": "포상휴가", + "rule_type": "full_day", + "default_hours": 8.0, + "priority": 40, + "memo": "명시 시간이 없으면 1일 8시간", + }, + { + "keyword": "대휴", + "leave_label": "대체휴가", + "rule_type": "full_day", + "default_hours": 8.0, + "priority": 50, + "memo": "명시 시간이 없으면 1일 8시간", + }, + { + "keyword": "대체", + "leave_label": "대체휴가", + "rule_type": "full_day", + "default_hours": 8.0, + "priority": 60, + "memo": "명시 시간이 없으면 1일 8시간", + }, + { + "keyword": "보상", + "leave_label": "보상휴가", + "rule_type": "full_day", + "default_hours": 8.0, + "priority": 70, + "memo": "명시 시간이 없으면 1일 8시간", + }, + { + "keyword": "휴가", + "leave_label": "휴가", + "rule_type": "full_day", + "default_hours": 8.0, + "priority": 80, + "memo": "명시 시간이 없으면 1일 8시간", + }, + { + "keyword": "공가", + "leave_label": "공가", + "rule_type": "full_day", + "default_hours": 8.0, + "priority": 90, + "memo": "명시 시간이 없으면 1일 8시간", + }, + { + "keyword": "병가", + "leave_label": "병가", + "rule_type": "full_day", + "default_hours": 8.0, + "priority": 100, + "memo": "명시 시간이 없으면 1일 8시간", + }, + { + "keyword": "휴직", + "leave_label": "휴직", + "rule_type": "full_day", + "default_hours": 8.0, + "priority": 110, + "memo": "명시 시간이 없으면 1일 8시간", + }, + { + "keyword": "출산", + "leave_label": "출산휴가", + "rule_type": "full_day", + "default_hours": 8.0, + "priority": 120, + "memo": "명시 시간이 없으면 1일 8시간", + }, +) + + +def _seed_default_hanmac_leave_rules(conn: Any) -> None: + existing_count = conn.execute(text("SELECT COUNT(*) FROM hanmac_leave_rules")).scalar() or 0 + if existing_count: + return + conn.execute( + text( + """ + INSERT OR IGNORE INTO hanmac_leave_rules ( + keyword, leave_label, rule_type, default_hours, enabled, priority, memo, created_at, updated_at + ) VALUES ( + :keyword, :leave_label, :rule_type, :default_hours, 1, :priority, :memo, + CURRENT_TIMESTAMP, CURRENT_TIMESTAMP + ) + """ + ), + list(DEFAULT_HANMAC_LEAVE_RULES), + ) + conn.execute( + text( + """ + DELETE FROM hanmac_leave_rules + WHERE keyword = '육아' + AND leave_label = '육아휴직/휴가' + AND memo = '명시 시간이 없으면 1일 8시간' + """ + ) + ) + + +def ensure_auth_schema(conn: Any) -> None: + conn.execute( + text( + """ + CREATE TABLE IF NOT EXISTS app_users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT NOT NULL UNIQUE, + password_hash TEXT NOT NULL, + display_name TEXT DEFAULT '', + is_active INTEGER NOT NULL DEFAULT 1, + is_admin INTEGER NOT NULL DEFAULT 0, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) + """ + ) + ) + conn.execute( + text( + """ + CREATE TABLE IF NOT EXISTS app_roles ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + role_key TEXT NOT NULL UNIQUE, + role_name TEXT NOT NULL, + description TEXT DEFAULT '', + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) + """ + ) + ) + conn.execute( + text( + """ + CREATE TABLE IF NOT EXISTS app_permissions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + permission_key TEXT NOT NULL UNIQUE, + permission_name TEXT NOT NULL, + description TEXT DEFAULT '', + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) + """ + ) + ) + conn.execute( + text( + """ + CREATE TABLE IF NOT EXISTS app_user_roles ( + user_id INTEGER NOT NULL, + role_id INTEGER NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (user_id, role_id) + ) + """ + ) + ) + conn.execute( + text( + """ + CREATE TABLE IF NOT EXISTS app_role_permissions ( + role_id INTEGER NOT NULL, + permission_id INTEGER NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (role_id, permission_id) + ) + """ + ) + ) + conn.execute( + text( + """ + CREATE TABLE IF NOT EXISTS app_user_permissions ( + user_id INTEGER NOT NULL, + permission_id INTEGER NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (user_id, permission_id) + ) + """ + ) + ) + conn.execute( + text( + """ + CREATE TABLE IF NOT EXISTS app_sessions ( + session_id TEXT PRIMARY KEY, + user_id INTEGER NOT NULL, + expires_at TEXT NOT NULL, + revoked_at TEXT DEFAULT '', + ip_address TEXT DEFAULT '', + user_agent TEXT DEFAULT '', + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + last_seen_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) + """ + ) + ) + conn.execute( + text( + """ + CREATE TABLE IF NOT EXISTS app_login_events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER, + username TEXT DEFAULT '', + success INTEGER NOT NULL DEFAULT 0, + failure_reason TEXT DEFAULT '', + ip_address TEXT DEFAULT '', + user_agent TEXT DEFAULT '', + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) + """ + ) + ) + permissions = [ + ("dashboard", "대시보드"), + ("annual_summary", "연도별 수익/비용"), + ("biz_process", "HM-BIZ-PROCESS"), + ("process_cost", "프로젝트 원가"), + ("cost_analysis", "프로젝트 손익분석"), + ("projects", "프로젝트 정보"), + ("wehago_compare", "전표비교"), + ("hanmac_browser", "hanmac DB_external"), + ("db_browser", "DB 조회"), + ("admin", "사용자 관리"), + ] + for permission_key, permission_name in permissions: + conn.execute( + text( + """ + INSERT INTO app_permissions (permission_key, permission_name, description) + VALUES (:permission_key, :permission_name, '') + ON CONFLICT(permission_key) DO UPDATE SET + permission_name = excluded.permission_name, + updated_at = CURRENT_TIMESTAMP + """ + ), + {"permission_key": permission_key, "permission_name": permission_name}, + ) + roles = [ + ("admin", "관리자", "모든 메뉴와 사용자 관리 권한"), + ("viewer", "조회자", "기본 조회 메뉴 권한"), + ] + for role_key, role_name, description in roles: + conn.execute( + text( + """ + INSERT INTO app_roles (role_key, role_name, description) + VALUES (:role_key, :role_name, :description) + ON CONFLICT(role_key) DO UPDATE SET + role_name = excluded.role_name, + description = excluded.description, + updated_at = CURRENT_TIMESTAMP + """ + ), + {"role_key": role_key, "role_name": role_name, "description": description}, + ) + conn.execute( + text( + """ + INSERT OR IGNORE INTO app_role_permissions (role_id, permission_id) + SELECT r.id, p.id + FROM app_roles AS r + JOIN app_permissions AS p + WHERE r.role_key = 'admin' + """ + ) + ) + conn.execute( + text( + """ + DELETE FROM app_role_permissions + WHERE role_id IN (SELECT id FROM app_roles WHERE role_key = 'viewer') + """ + ) + ) + def _get_runtime_cache_entry( cache: dict[tuple[Any, ...], dict[str, Any]], @@ -152,6 +748,595 @@ def _set_runtime_cache_entry( return copy.deepcopy(value) +def _get_deepcopy_ttl_cache_entry( + cache: dict[tuple[Any, ...], dict[str, Any]], + lock: threading.Lock, + key: tuple[Any, ...], + ttl_seconds: float, +) -> Any | None: + now = time.time() + with lock: + cached = cache.get(key) + if not cached: + return None + if now - float(cached.get("stored_at") or 0.0) > ttl_seconds: + cache.pop(key, None) + return None + return copy.deepcopy(cached.get("payload")) + + +def _set_deepcopy_ttl_cache_entry( + cache: dict[tuple[Any, ...], dict[str, Any]], + lock: threading.Lock, + key: tuple[Any, ...], + value: Any, +) -> Any: + with lock: + cache[key] = { + "stored_at": time.time(), + "payload": copy.deepcopy(value), + } + return copy.deepcopy(value) + + +def _auth_now_ts() -> int: + return int(time.time()) + + +def _auth_b64encode(value: bytes) -> str: + return base64.urlsafe_b64encode(value).decode("ascii").rstrip("=") + + +def _auth_b64decode(value: str) -> bytes: + padding = "=" * (-len(value) % 4) + return base64.urlsafe_b64decode((value + padding).encode("ascii")) + + +def hash_password(password: str) -> str: + salt = secrets.token_bytes(16) + digest = hashlib.pbkdf2_hmac("sha256", password.encode("utf-8"), salt, AUTH_PBKDF2_ITERATIONS) + return f"pbkdf2_sha256${AUTH_PBKDF2_ITERATIONS}${_auth_b64encode(salt)}${_auth_b64encode(digest)}" + + +def verify_password(password: str, password_hash: str) -> bool: + parts = str(password_hash or "").split("$") + if len(parts) != 4 or parts[0] != "pbkdf2_sha256": + return False + try: + iterations = int(parts[1]) + salt = _auth_b64decode(parts[2]) + expected = _auth_b64decode(parts[3]) + except Exception: + return False + digest = hashlib.pbkdf2_hmac("sha256", password.encode("utf-8"), salt, iterations) + return hmac.compare_digest(digest, expected) + + +def _auth_sign_payload(payload: dict[str, Any]) -> str: + raw_payload = _auth_b64encode(json.dumps(payload, ensure_ascii=False, separators=(",", ":")).encode("utf-8")) + signature = hmac.new(AUTH_SECRET.encode("utf-8"), raw_payload.encode("ascii"), hashlib.sha256).digest() + return f"{raw_payload}.{_auth_b64encode(signature)}" + + +def _auth_unsign_payload(token: str) -> dict[str, Any] | None: + try: + raw_payload, signature = str(token or "").split(".", 1) + expected = hmac.new(AUTH_SECRET.encode("utf-8"), raw_payload.encode("ascii"), hashlib.sha256).digest() + if not hmac.compare_digest(_auth_b64decode(signature), expected): + return None + payload = json.loads(_auth_b64decode(raw_payload).decode("utf-8")) + if not isinstance(payload, dict): + return None + if int(payload.get("exp") or 0) < _auth_now_ts(): + return None + return payload + except Exception: + return None + + +def _auth_cookie_response(response: Response, session_token: str = "") -> Response: + if session_token: + response.set_cookie( + AUTH_COOKIE_NAME, + session_token, + max_age=AUTH_SESSION_SECONDS, + httponly=True, + samesite="lax", + ) + else: + response.delete_cookie(AUTH_COOKIE_NAME) + return response + + +def _auth_fetch_user(user_id: int) -> dict[str, Any] | None: + init_db() + with engine.begin() as conn: + row = conn.execute( + text( + """ + SELECT id, username, display_name, is_active, is_admin + FROM app_users + WHERE id = :user_id + LIMIT 1 + """ + ), + {"user_id": int(user_id or 0)}, + ).mappings().first() + if not row or not int(row["is_active"] or 0): + return None + permissions = conn.execute( + text( + """ + SELECT DISTINCT p.permission_key + FROM ( + SELECT p.permission_key + FROM app_permissions AS p + JOIN app_role_permissions AS rp ON rp.permission_id = p.id + JOIN app_user_roles AS ur ON ur.role_id = rp.role_id + WHERE ur.user_id = :user_id + UNION + SELECT p.permission_key + FROM app_permissions AS p + JOIN app_user_permissions AS up ON up.permission_id = p.id + WHERE up.user_id = :user_id + ) AS p + """ + ), + {"user_id": int(row["id"])}, + ).scalars().all() + permission_set = set(str(item) for item in permissions) + if int(row["is_admin"] or 0): + permission_set.add("admin") + permission_set.update(item["permission"] for item in AUTH_NAV_ITEMS) + return { + "id": int(row["id"]), + "username": row["username"], + "display_name": row["display_name"] or row["username"], + "is_admin": bool(row["is_admin"]), + "permissions": sorted(permission_set), + } + + +def _auth_get_request_user(request: Request) -> dict[str, Any] | None: + payload = _auth_unsign_payload(request.cookies.get(AUTH_COOKIE_NAME, "")) + if not payload: + return None + session_id = normalize_text(payload.get("sid")) + user_id = int(payload.get("uid") or 0) + if not session_id or not user_id: + return None + init_db() + with engine.begin() as conn: + row = conn.execute( + text( + """ + SELECT user_id, expires_at, last_seen_at + FROM app_sessions + WHERE session_id = :session_id + AND COALESCE(revoked_at, '') = '' + LIMIT 1 + """ + ), + {"session_id": session_id}, + ).mappings().first() + if not row or int(row["user_id"] or 0) != user_id: + return None + try: + expires_at = datetime.fromisoformat(str(row["expires_at"])) + except Exception: + return None + if expires_at < datetime.now(): + return None + should_touch_session = True + try: + last_seen_at = datetime.fromisoformat(str(row["last_seen_at"] or "")) + should_touch_session = (datetime.now() - last_seen_at).total_seconds() >= AUTH_LAST_SEEN_UPDATE_SECONDS + except Exception: + should_touch_session = True + if should_touch_session: + conn.execute( + text("UPDATE app_sessions SET last_seen_at = CURRENT_TIMESTAMP WHERE session_id = :session_id"), + {"session_id": session_id}, + ) + user = _auth_fetch_user(user_id) + if user: + user["session_id"] = session_id + return user + + +def _auth_user_can(user: dict[str, Any] | None, permission: str) -> bool: + if not user: + return False + if user.get("is_admin"): + return True + return permission in set(user.get("permissions") or []) + + +def _auth_path_permission(path: str) -> str: + if path == "/": + return "dashboard" + for prefix, permission in sorted(AUTH_PERMISSION_BY_PREFIX.items(), key=lambda item: len(item[0]), reverse=True): + if path == prefix or path.startswith(f"{prefix}/"): + return permission + return "dashboard" + + +def _auth_is_api_request(request: Request) -> bool: + accept = request.headers.get("accept", "") + return request.url.path.endswith("/api") or "/api/" in request.url.path or "application/json" in accept + + +def _auth_redirect_target(request: Request) -> str: + path = request.url.path + query = request.url.query + target = path + (f"?{query}" if query else "") + return quote_plus(target) + + +def _auth_log_event( + username: str, + success: bool, + request: Request, + failure_reason: str = "", + user_id: int | None = None, +) -> None: + try: + init_db() + with engine.begin() as conn: + conn.execute( + text( + """ + INSERT INTO app_login_events ( + user_id, username, success, failure_reason, ip_address, user_agent, created_at + ) VALUES ( + :user_id, :username, :success, :failure_reason, :ip_address, :user_agent, CURRENT_TIMESTAMP + ) + """ + ), + { + "user_id": user_id, + "username": username, + "success": 1 if success else 0, + "failure_reason": failure_reason, + "ip_address": request.client.host if request.client else "", + "user_agent": request.headers.get("user-agent", ""), + }, + ) + except Exception as exc: + logger.warning("login event logging skipped: %s", exc) + + +def create_login_session(user_id: int, request: Request) -> str: + session_id = secrets.token_urlsafe(32) + expires_at = datetime.now() + timedelta(seconds=AUTH_SESSION_SECONDS) + token = _auth_sign_payload({"sid": session_id, "uid": int(user_id), "exp": int(expires_at.timestamp())}) + init_db() + with engine.begin() as conn: + conn.execute( + text( + """ + INSERT INTO app_sessions ( + session_id, user_id, expires_at, ip_address, user_agent, created_at, last_seen_at + ) VALUES ( + :session_id, :user_id, :expires_at, :ip_address, :user_agent, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP + ) + """ + ), + { + "session_id": session_id, + "user_id": int(user_id), + "expires_at": expires_at.isoformat(timespec="seconds"), + "ip_address": request.client.host if request.client else "", + "user_agent": request.headers.get("user-agent", ""), + }, + ) + return token + + +def authenticate_user(username: str, password: str) -> dict[str, Any] | None: + init_db() + with engine.begin() as conn: + row = conn.execute( + text( + """ + SELECT id, username, password_hash, display_name, is_active, is_admin + FROM app_users + WHERE username = :username + LIMIT 1 + """ + ), + {"username": username}, + ).mappings().first() + if not row or not int(row["is_active"] or 0): + return None + if not verify_password(password, row["password_hash"]): + return None + return _auth_fetch_user(int(row["id"])) + + +def list_admin_users() -> list[dict[str, Any]]: + init_db() + with engine.begin() as conn: + rows = conn.execute( + text( + """ + SELECT + u.id, + u.username, + u.display_name, + u.is_active, + u.is_admin, + u.created_at, + u.updated_at, + COALESCE(GROUP_CONCAT(DISTINCT r.role_key), '') AS roles, + COALESCE(GROUP_CONCAT(DISTINCT p.permission_key), '') AS direct_permissions + FROM app_users AS u + LEFT JOIN app_user_roles AS ur ON ur.user_id = u.id + LEFT JOIN app_roles AS r ON r.id = ur.role_id + LEFT JOIN app_user_permissions AS up ON up.user_id = u.id + LEFT JOIN app_permissions AS p ON p.id = up.permission_id + GROUP BY u.id + ORDER BY u.username + """ + ) + ).mappings().all() + return [ + { + "id": int(row["id"]), + "username": row["username"], + "display_name": row["display_name"] or row["username"], + "is_active": bool(row["is_active"]), + "is_admin": bool(row["is_admin"]), + "roles": [item for item in str(row["roles"] or "").split(",") if item], + "direct_permissions": [item for item in str(row["direct_permissions"] or "").split(",") if item], + "created_at": str(row["created_at"] or ""), + "updated_at": str(row["updated_at"] or ""), + } + for row in rows + ] + + +def list_admin_permission_options() -> list[dict[str, str]]: + init_db() + with engine.begin() as conn: + rows = conn.execute( + text( + """ + SELECT permission_key, permission_name + FROM app_permissions + ORDER BY + CASE permission_key + WHEN 'dashboard' THEN 0 + WHEN 'annual_summary' THEN 1 + WHEN 'biz_process' THEN 2 + WHEN 'process_cost' THEN 3 + WHEN 'cost_analysis' THEN 4 + WHEN 'projects' THEN 5 + WHEN 'wehago_compare' THEN 6 + WHEN 'hanmac_browser' THEN 7 + WHEN 'db_browser' THEN 8 + WHEN 'admin' THEN 9 + ELSE 99 + END, + permission_name + """ + ) + ).mappings().all() + return [{"key": row["permission_key"], "label": row["permission_name"]} for row in rows] + + +def upsert_admin_user(payload: dict[str, Any]) -> None: + username = normalize_text(payload.get("username")) + if not username: + raise ValueError("아이디가 필요합니다.") + password = str(payload.get("password") or "") + display_name = normalize_text(payload.get("display_name")) or username + role_key = normalize_text(payload.get("role")) or "viewer" + if role_key not in {"admin", "viewer"}: + role_key = "viewer" + raw_permissions = payload.get("permissions") + if raw_permissions is None: + raw_permissions = payload.get("permissions[]") + if isinstance(raw_permissions, str): + selected_permissions = {raw_permissions} if raw_permissions else set() + elif isinstance(raw_permissions, (list, tuple, set)): + selected_permissions = {normalize_text(item) for item in raw_permissions if normalize_text(item)} + else: + selected_permissions = set() + is_active = 1 if normalize_text(payload.get("is_active")) in {"1", "true", "on", "yes", "활성"} else 0 + is_admin = 1 if role_key == "admin" else 0 + if is_admin: + selected_permissions = set() + init_db() + with engine.begin() as conn: + existing = conn.execute( + text("SELECT id FROM app_users WHERE username = :username"), + {"username": username}, + ).mappings().first() + if existing: + params = { + "username": username, + "display_name": display_name, + "is_active": is_active, + "is_admin": is_admin, + } + password_sql = "" + if password: + params["password_hash"] = hash_password(password) + password_sql = "password_hash = :password_hash," + conn.execute( + text( + f""" + UPDATE app_users + SET display_name = :display_name, + {password_sql} + is_active = :is_active, + is_admin = :is_admin, + updated_at = CURRENT_TIMESTAMP + WHERE username = :username + """ + ), + params, + ) + user_id = int(existing["id"]) + else: + if not password: + raise ValueError("새 사용자 비밀번호가 필요합니다.") + user_id = conn.execute( + text( + """ + INSERT INTO app_users ( + username, password_hash, display_name, is_active, is_admin, created_at, updated_at + ) VALUES ( + :username, :password_hash, :display_name, :is_active, :is_admin, + CURRENT_TIMESTAMP, CURRENT_TIMESTAMP + ) + RETURNING id + """ + ), + { + "username": username, + "password_hash": hash_password(password), + "display_name": display_name, + "is_active": is_active, + "is_admin": is_admin, + }, + ).scalar_one() + role_id = conn.execute( + text("SELECT id FROM app_roles WHERE role_key = :role_key"), + {"role_key": role_key}, + ).scalar_one() + conn.execute(text("DELETE FROM app_user_roles WHERE user_id = :user_id"), {"user_id": user_id}) + conn.execute( + text( + """ + INSERT OR IGNORE INTO app_user_roles (user_id, role_id, created_at) + VALUES (:user_id, :role_id, CURRENT_TIMESTAMP) + """ + ), + {"user_id": user_id, "role_id": role_id}, + ) + conn.execute(text("DELETE FROM app_user_permissions WHERE user_id = :user_id"), {"user_id": user_id}) + if selected_permissions: + conn.execute( + text( + """ + INSERT OR IGNORE INTO app_user_permissions (user_id, permission_id, created_at) + SELECT :user_id, id, CURRENT_TIMESTAMP + FROM app_permissions + WHERE permission_key IN :permission_keys + """ + ).bindparams(bindparam("permission_keys", expanding=True)), + {"user_id": user_id, "permission_keys": sorted(selected_permissions)}, + ) + if not is_active: + conn.execute( + text("UPDATE app_sessions SET revoked_at = CURRENT_TIMESTAMP WHERE user_id = :user_id AND revoked_at IS NULL"), + {"user_id": user_id}, + ) + + +@app.middleware("http") +async def auth_middleware(request: Request, call_next): + path = request.url.path + if path in AUTH_PUBLIC_PATHS or path.startswith("/login") or path.startswith("/health/"): + return await call_next(request) + user = _auth_get_request_user(request) + if not user: + if _auth_is_api_request(request): + return JSONResponse({"error": "login_required"}, status_code=401) + return RedirectResponse(url=f"/login?next={_auth_redirect_target(request)}", status_code=303) + permission = _auth_path_permission(path) + if not _auth_user_can(user, permission): + if _auth_is_api_request(request): + return JSONResponse({"error": "forbidden"}, status_code=403) + return HTMLResponse("

403

접근 권한이 없습니다.

", status_code=403) + request.state.current_user = user + return await call_next(request) + + +def _json_hash(value: dict[str, Any]) -> str: + return hashlib.sha1( + json.dumps(value, ensure_ascii=False, sort_keys=True, default=str).encode("utf-8") + ).hexdigest() + + +def _load_system_page_cache(page_key: str, cache_key: str) -> dict[str, Any] | None: + init_db() + with engine.begin() as conn: + row = conn.execute( + text( + """ + SELECT payload_json, updated_at, params_json, row_count, signature + FROM system_page_cache + WHERE page_key = :page_key + AND cache_key = :cache_key + LIMIT 1 + """ + ), + {"page_key": page_key, "cache_key": cache_key}, + ).mappings().first() + if not row: + return None + try: + payload = json.loads(str(row["payload_json"] or "{}")) + except Exception: + payload = {} + if not isinstance(payload, dict): + return None + payload.setdefault("cacheMeta", {}) + payload["cacheMeta"].update( + { + "source": "system_page_cache", + "pageKey": page_key, + "cacheKey": cache_key, + "updatedAt": str(row["updated_at"] or ""), + "rowCount": int(row["row_count"] or 0), + "signature": str(row["signature"] or ""), + } + ) + return payload + + +def _store_system_page_cache( + page_key: str, + cache_key: str, + *, + params: dict[str, Any], + payload: dict[str, Any], + row_count: int = 0, + signature: str = "", +) -> None: + init_db() + with engine.begin() as conn: + conn.execute( + text( + """ + INSERT INTO system_page_cache ( + page_key, cache_key, params_json, payload_json, row_count, signature, created_at, updated_at + ) VALUES ( + :page_key, :cache_key, :params_json, :payload_json, :row_count, :signature, + CURRENT_TIMESTAMP, CURRENT_TIMESTAMP + ) + ON CONFLICT(page_key, cache_key) DO UPDATE SET + params_json = excluded.params_json, + payload_json = excluded.payload_json, + row_count = excluded.row_count, + signature = excluded.signature, + updated_at = CURRENT_TIMESTAMP + """ + ), + { + "page_key": page_key, + "cache_key": cache_key, + "params_json": json.dumps(params, ensure_ascii=False, default=str), + "payload_json": json.dumps(payload, ensure_ascii=False, default=str), + "row_count": int(row_count or 0), + "signature": signature, + }, + ) + + def build_datasette_metadata() -> dict[str, Any]: return { "title": "한맥 인트라넷 DB 조회", @@ -243,6 +1428,336 @@ def build_datasette_metadata() -> dict[str, Any]: } +def _empty_compare_metric_counts() -> dict[str, int]: + return { + "matched": 0, + "ledger_only": 0, + "voucher_only": 0, + "amount_mismatch": 0, + "voucher_matched": 0, + "erp_voucher_matched": 0, + "bridge_expense_review": 0, + "voucher_unmatched": 0, + "erp_voucher_unmatched": 0, + "voucher_recheck": 0, + "voucher_excepted": 0, + "hanmac_unconnected": 0, + } + + +def _load_projection_group_counts( + conn: sqlite3.Connection, + start_year: int, + end_year: int, + *, + signature_like: str | None = None, +) -> tuple[dict[str, int], int]: + where = [ + "start_year <= ?", + "end_year >= ?", + ] + params: list[Any] = [int(start_year), int(end_year)] + if signature_like: + where.append("signature LIKE ?") + params.append(signature_like) + scope_row = conn.execute( + f""" + SELECT start_year, end_year, signature, + COUNT(DISTINCT status_key) AS status_count, + MAX(updated_at) AS max_updated_at + FROM wehago_compare_query_groups + WHERE {' AND '.join(where)} + GROUP BY start_year, end_year, signature + ORDER BY + CASE WHEN start_year = ? AND end_year = ? THEN 0 ELSE 1 END ASC, + status_count DESC, + (end_year - start_year) ASC, + max_updated_at DESC + LIMIT 1 + """, + (*params, int(start_year), int(end_year)), + ).fetchone() + if not scope_row: + return {}, 0 + proj_start = int(scope_row["start_year"] or 0) + proj_end = int(scope_row["end_year"] or 0) + signature = str(scope_row["signature"] or "") + counts: dict[str, int] = {} + for row in conn.execute( + """ + SELECT status_key, COUNT(*) AS row_count + FROM wehago_compare_query_groups + WHERE start_year = ? + AND end_year = ? + AND signature = ? + AND fiscal_year BETWEEN ? AND ? + GROUP BY status_key + """, + (proj_start, proj_end, signature, int(start_year), int(end_year)), + ).fetchall(): + counts[str(row["status_key"] or "")] = int(row["row_count"] or 0) + return counts, int(scope_row["status_count"] or 0) + + +def _fast_wehago_compare_summary_payload( + start_year: int | None = None, + end_year: int | None = None, +) -> dict[str, Any]: + conn = sqlite3.connect(DB_PATH) + conn.row_factory = sqlite3.Row + try: + available_years = sorted( + { + int(row[0]) + for row in conn.execute( + """ + SELECT fiscal_year FROM wehago_snapshot_status + UNION + SELECT DISTINCT fiscal_year FROM wehago_compare_query_groups + """ + ).fetchall() + if int(row[0] or 0) > 0 + }, + reverse=True, + ) + current_year = date.today().year + default_start_year = min(available_years) if available_years else current_year + default_end_year = max(available_years) if available_years else current_year + if start_year is None and end_year is None: + start_year = default_start_year + end_year = default_end_year + elif start_year is None: + start_year = end_year + elif end_year is None: + end_year = start_year + valid_years = set(available_years) + if valid_years: + if start_year not in valid_years: + start_year = default_start_year + if end_year not in valid_years: + end_year = default_end_year if start_year == default_start_year else (start_year or default_end_year) + if start_year and end_year and start_year > end_year: + start_year, end_year = end_year, start_year + + counts = _empty_compare_metric_counts() + current_group_counts, projection_status_count = _load_projection_group_counts( + conn, + int(start_year or 0), + int(end_year or 0), + signature_like=f"{QUERY_PROJECTION_VERSION}|%", + ) + has_current_projection_counts = bool(current_group_counts) + if not current_group_counts: + current_group_counts, projection_status_count = _load_projection_group_counts( + conn, + int(start_year or 0), + int(end_year or 0), + ) + for status_key, value in current_group_counts.items(): + if status_key in counts: + counts[status_key] = int(value or 0) + if "hanmac_unconnected" not in current_group_counts: + 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( + """ + SELECT start_year, end_year, signature + FROM wehago_compare_query_groups + WHERE start_year <= ? + AND end_year >= ? + AND status_key = ? + GROUP BY start_year, end_year, signature + ORDER BY + CASE WHEN signature LIKE ? THEN 0 ELSE 1 END ASC, + CASE WHEN start_year = ? AND end_year = ? THEN 0 ELSE 1 END ASC, + (end_year - start_year) ASC, + MAX(updated_at) DESC + LIMIT 1 + """, + ( + int(start_year or 0), + int(end_year or 0), + "voucher_matched", + f"{QUERY_PROJECTION_VERSION}|%", + int(start_year or 0), + int(end_year or 0), + ), + ).fetchone() + else: + scope_row = None + if scope_row: + proj_start = int(scope_row["start_year"] or 0) + proj_end = int(scope_row["end_year"] or 0) + signature = str(scope_row["signature"] or "") + for row in conn.execute( + """ + SELECT status_key, COUNT(*) + FROM wehago_compare_query_rows + WHERE start_year = ? + AND end_year = ? + AND signature = ? + AND fiscal_year BETWEEN ? AND ? + AND status_key IN ('matched', 'ledger_only', 'amount_mismatch', 'voucher_only') + GROUP BY status_key + """, + (proj_start, proj_end, signature, int(start_year or 0), int(end_year or 0)), + ).fetchall(): + status_key = str(row["status_key"] or "") + 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: + continue + row = conn.execute( + """ + SELECT COUNT(*) + FROM wehago_comparison_results + WHERE fiscal_year BETWEEN ? AND ? + AND status = ? + """, + (int(start_year or 0), int(end_year or 0), status_key), + ).fetchone() + counts[status_key] = int((row or [0])[0] or 0) + + 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"]) + + 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 ""), + } + + 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단계 비교", ""), + ) + ] + return { + "selected_start_year": start_year, + "selected_end_year": end_year, + "metric_sections": metric_sections, + "last_action": last_action, + "pending": pending, + "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, + } + finally: + conn.close() + + datasette_app = Datasette( files=[str(DB_PATH)], metadata=build_datasette_metadata(), @@ -340,6 +1855,39 @@ DIRECT_HEADER_MAP = { "관리항목": "management_item", } +DEFAULT_EXEC_LABOR_RATES = { + "2025": { + "설계": {"부사장": 56100, "전무": 56100, "상무": 49400, "이사": 46600, "부장": 41400, "차장": 38000, "과장": 34600, "대리": 31600, "사원": 27100}, + "감리": {"부사장": 51800, "전무": 49000, "상무": 46000, "이사": 43900, "부장": 41000, "차장": 38200, "과장": 38200, "대리": 36600, "사원": 28700}, + "지원": {"부사장": 51800, "전무": 49000, "상무": 46000, "이사": 43900, "부장": 41000, "차장": 38200, "과장": 38200, "대리": 36600, "사원": 28700}, + }, + "2024": { + "설계": {"부사장": 56100, "전무": 56100, "상무": 47700, "이사": 45200, "부장": 40900, "차장": 37600, "과장": 34200, "대리": 30800, "사원": 26300}, + "감리": {"부사장": 51800, "전무": 49000, "상무": 46000, "이사": 43100, "부장": 40200, "차장": 38200, "과장": 38200, "대리": 30500, "사원": 26500}, + "지원": {"부사장": 51800, "전무": 49000, "상무": 46000, "이사": 43100, "부장": 40200, "차장": 38200, "과장": 38200, "대리": 30500, "사원": 26500}, + }, + "2023": { + "설계": {"부사장": 56100, "전무": 56100, "상무": 46900, "이사": 44700, "부장": 40200, "차장": 36800, "과장": 33500, "대리": 30000, "사원": 25600}, + "감리": {"부사장": 51800, "전무": 51800, "상무": 46000, "이사": 43100, "부장": 40200, "차장": 38200, "과장": 38200, "대리": 28600, "사원": 25600}, + "지원": {"부사장": 51800, "전무": 51800, "상무": 46000, "이사": 43100, "부장": 40200, "차장": 38200, "과장": 38200, "대리": 28600, "사원": 25600}, + }, + "2022": { + "설계": {"부사장": 56100, "전무": 56100, "상무": 46900, "이사": 44700, "부장": 40200, "차장": 36800, "과장": 33400, "대리": 30000, "사원": 25600}, + "감리": {"부사장": 51800, "전무": 51800, "상무": 46000, "이사": 42100, "부장": 39300, "차장": 38200, "과장": 38200, "대리": 28000, "사원": 25500}, + "지원": {"부사장": 51800, "전무": 51800, "상무": 46000, "이사": 42100, "부장": 39300, "차장": 38200, "과장": 38200, "대리": 28000, "사원": 25500}, + }, + "2021": { + "설계": {"부사장": 54700, "전무": 54700, "상무": 45700, "이사": 45700, "부장": 38300, "차장": 35200, "과장": 31500, "대리": 28100, "사원": 24300}, + "감리": {"부사장": 47000, "전무": 47000, "상무": 44500, "이사": 39000, "부장": 36800, "차장": 33400, "과장": 33400, "대리": 24200, "사원": 23200}, + "지원": {"부사장": 47000, "전무": 47000, "상무": 44500, "이사": 39000, "부장": 36800, "차장": 33400, "과장": 33400, "대리": 24200, "사원": 23200}, + }, + "2020": { + "설계": {"부사장": 52300, "전무": 52300, "상무": 52300, "이사": 52300, "부장": 35000, "차장": 31700, "과장": 28800, "대리": 25900, "사원": 23000}, + "감리": {"부사장": 45200, "전무": 45200, "상무": 41900, "이사": 41900, "부장": 34500, "차장": 32700, "과장": 32700, "대리": 22600, "사원": 22600}, + "지원": {"부사장": 45200, "전무": 45200, "상무": 41900, "이사": 41900, "부장": 34500, "차장": 32700, "과장": 32700, "대리": 22600, "사원": 22600}, + }, +} + DEFAULT_APP_OPTION_ITEMS = { "labor_grades": [ ("president", "사장", "사장"), @@ -380,7 +1928,7 @@ DEFAULT_APP_OPTION_ITEMS = { ("detail_visible_min_year", "세부내역 반영 시작 연도", "2023"), ], "project_shared": [ - ("exec_labor_rates_json", "공통 기준인건비", "{}"), + ("exec_labor_rates_json", "공통 기준인건비", json.dumps(DEFAULT_EXEC_LABOR_RATES, ensure_ascii=False, separators=(",", ":"))), ], "dashboard_revenue_metrics": [ ("design_revenue", "설계", "#4f7cff"), @@ -642,6 +2190,18 @@ def init_db() -> None: """ ) ) + conn.execute( + text( + f""" + CREATE INDEX IF NOT EXISTS idx_transactions_cost_analysis_date_account_support + ON transactions ( + ({COST_ANALYSIS_TX_DATE_SQL}), + account_code, + support_dept_code + ) + """ + ) + ) transaction_columns = { row[1] for row in conn.execute(text("PRAGMA table_info(transactions)")).fetchall() @@ -753,6 +2313,121 @@ def init_db() -> None: """ ) ) + conn.execute( + text( + """ + CREATE TABLE IF NOT EXISTS hanmac_holidays ( + holiday_date TEXT PRIMARY KEY, + holiday_name TEXT NOT NULL DEFAULT '', + holiday_type TEXT NOT NULL DEFAULT 'company', + memo TEXT NOT NULL DEFAULT '', + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) + """ + ) + ) + conn.execute( + text( + """ + CREATE INDEX IF NOT EXISTS idx_hanmac_holidays_type_date + ON hanmac_holidays (holiday_type, holiday_date) + """ + ) + ) + _seed_default_hanmac_holidays(conn) + conn.execute( + text( + """ + CREATE TABLE IF NOT EXISTS hanmac_leave_rules ( + keyword TEXT PRIMARY KEY, + leave_label TEXT NOT NULL DEFAULT '', + rule_type TEXT NOT NULL DEFAULT 'full_day', + default_hours REAL NOT NULL DEFAULT 8, + enabled INTEGER NOT NULL DEFAULT 1, + priority INTEGER NOT NULL DEFAULT 100, + memo TEXT NOT NULL DEFAULT '', + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) + """ + ) + ) + conn.execute( + text( + """ + CREATE INDEX IF NOT EXISTS idx_hanmac_leave_rules_enabled_priority + ON hanmac_leave_rules (enabled, priority) + """ + ) + ) + _seed_default_hanmac_leave_rules(conn) + conn.execute( + text( + """ + CREATE TABLE IF NOT EXISTS system_jobs ( + id TEXT PRIMARY KEY, + page_key TEXT NOT NULL DEFAULT '', + job_type TEXT NOT NULL DEFAULT '', + status TEXT NOT NULL DEFAULT 'queued', + start_year INTEGER, + end_year INTEGER, + params_json TEXT NOT NULL DEFAULT '{}', + progress_current INTEGER NOT NULL DEFAULT 0, + progress_total INTEGER NOT NULL DEFAULT 0, + message TEXT NOT NULL DEFAULT '', + result_json TEXT NOT NULL DEFAULT '{}', + error_message TEXT NOT NULL DEFAULT '', + cancel_requested INTEGER NOT NULL DEFAULT 0, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + started_at TIMESTAMP, + finished_at TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) + """ + ) + ) + conn.execute( + text( + """ + CREATE INDEX IF NOT EXISTS idx_system_jobs_page_status + ON system_jobs (page_key, status, created_at) + """ + ) + ) + conn.execute( + text( + """ + CREATE INDEX IF NOT EXISTS idx_system_jobs_type_period + ON system_jobs (job_type, start_year, end_year, created_at) + """ + ) + ) + conn.execute( + text( + """ + CREATE TABLE IF NOT EXISTS system_page_cache ( + page_key TEXT NOT NULL, + cache_key TEXT NOT NULL, + params_json TEXT NOT NULL DEFAULT '{}', + payload_json TEXT NOT NULL DEFAULT '{}', + row_count INTEGER NOT NULL DEFAULT 0, + signature TEXT NOT NULL DEFAULT '', + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (page_key, cache_key) + ) + """ + ) + ) + conn.execute( + text( + """ + CREATE INDEX IF NOT EXISTS idx_system_page_cache_updated_at + ON system_page_cache (page_key, updated_at) + """ + ) + ) conn.execute( text( """ @@ -937,6 +2612,14 @@ def init_db() -> None: """ ) ) + conn.execute( + text( + """ + CREATE INDEX IF NOT EXISTS idx_project_collection_entries_date_code + ON project_collection_entries (date, support_dept_code) + """ + ) + ) conn.execute( text( """ @@ -1482,6 +3165,17 @@ def init_db() -> None: """ ) ) + conn.execute( + text( + """ + CREATE INDEX IF NOT EXISTS idx_project_billing_entries_effective_date_code + ON project_billing_entries ( + COALESCE(COALESCE(tax_invoice_date, billing_date), ''), + support_dept_code + ) + """ + ) + ) existing_columns = { row[1] for row in conn.execute(text("PRAGMA table_info(project_status)")).fetchall() @@ -1550,12 +3244,16 @@ def init_db() -> None: conn.execute(text("ALTER TABLE project_actual_input_entries ADD COLUMN rate_year TEXT DEFAULT ''")) migrate_project_status_entries(conn) migrate_project_basic_info(conn) + ensure_auth_schema(conn) ensure_default_app_config(conn) trans.commit() conn.close() - init_wehago_compare_db(engine) _DB_INIT_DONE = True - _maybe_run_db_analyze() + run_startup_analyze = normalize_text(os.getenv("HM_RUN_STARTUP_ANALYZE", "0")) in {"1", "true", "yes", "on"} + if run_startup_analyze: + _maybe_run_db_analyze() + else: + logger.info("Skipping startup ANALYZE; set HM_RUN_STARTUP_ANALYZE=1 to run it before serving") @lru_cache(maxsize=1) @@ -1741,9 +3439,49 @@ def get_shared_exec_labor_rates_json() -> str: def get_shared_exec_labor_rates() -> dict[str, Any]: try: parsed = json.loads(get_shared_exec_labor_rates_json()) - return parsed if isinstance(parsed, dict) else {} + if not isinstance(parsed, dict): + return copy.deepcopy(DEFAULT_EXEC_LABOR_RATES) + return merge_exec_labor_rates_with_defaults(parsed) except json.JSONDecodeError: - return {} + return copy.deepcopy(DEFAULT_EXEC_LABOR_RATES) + + +def merge_exec_labor_rates_with_defaults(raw_rates: Any) -> dict[str, Any]: + """Keep the Hanmac default labor table complete while preserving saved overrides.""" + merged = copy.deepcopy(DEFAULT_EXEC_LABOR_RATES) + if not isinstance(raw_rates, dict): + return merged + for year_key, year_bucket in raw_rates.items(): + year_text = normalize_text(year_key) + if not year_text or not isinstance(year_bucket, dict): + continue + merged.setdefault(year_text, {"설계": {}, "감리": {}, "지원": {}}) + has_category_bucket = any(isinstance(value, dict) for value in year_bucket.values()) + if has_category_bucket: + for category_key, category_bucket in year_bucket.items(): + if not isinstance(category_bucket, dict): + continue + category = _normalize_labor_rate_category(category_key) + merged[year_text].setdefault(category, {}) + for grade_key, amount_value in category_bucket.items(): + grade_text = _normalize_labor_grade_name(grade_key) + if not grade_text: + continue + amount = normalize_amount(amount_value) + if amount: + merged[year_text][category][grade_text] = int(amount) + else: + merged[year_text].setdefault("설계", {}) + for grade_key, amount_value in year_bucket.items(): + grade_text = _normalize_labor_grade_name(grade_key) + if not grade_text: + continue + amount = normalize_amount(amount_value) + if amount: + merged[year_text]["설계"][grade_text] = int(amount) + if not merged[year_text].get("지원"): + merged[year_text]["지원"] = dict(merged[year_text].get("감리") or {}) + return merged def save_shared_exec_labor_rates(conn: Any, exec_labor_rates_json: str) -> None: @@ -1830,15 +3568,15 @@ def prune_old_backups() -> None: logger.warning("오래된 백업 파일 정리 실패(%s): %s", stale_path.name, exc) -def maybe_create_database_backup(trigger_action: str, session_id: Any = "") -> str: +def maybe_create_database_backup(trigger_action: str, session_id: Any = "", force: bool = False) -> str: now = time.monotonic() - if now - float(_DB_BACKUP_STATE.get("last_run_at") or 0.0) < DB_BACKUP_MIN_INTERVAL_SECONDS: + if not force and now - float(_DB_BACKUP_STATE.get("last_run_at") or 0.0) < DB_BACKUP_MIN_INTERVAL_SECONDS: return "" if not _DB_BACKUP_LOCK.acquire(blocking=False): return "" try: now = time.monotonic() - if now - float(_DB_BACKUP_STATE.get("last_run_at") or 0.0) < DB_BACKUP_MIN_INTERVAL_SECONDS: + if not force and now - float(_DB_BACKUP_STATE.get("last_run_at") or 0.0) < DB_BACKUP_MIN_INTERVAL_SECONDS: return "" stamp = datetime.now().strftime("%Y%m%d-%H%M%S") temp_path = BACKUP_DIR / f".data-{stamp}.tmp" @@ -1890,6 +3628,62 @@ def maybe_create_database_backup(trigger_action: str, session_id: Any = "") -> s _DB_BACKUP_LOCK.release() +def _hour_is_in_window(hour: int, start_hour: int, end_hour: int) -> bool: + if start_hour == end_hour: + return True + if start_hour < end_hour: + return start_hour <= hour < end_hour + return hour >= start_hour or hour < end_hour + + +def _maybe_run_db_vacuum() -> None: + global _DB_VACUUM_LAST_ATTEMPT_AT + enabled = normalize_text(os.getenv("HM_AUTO_VACUUM_ENABLED", "0")).lower() in {"1", "true", "yes", "on"} + if not enabled: + return + now = time.time() + if now - _DB_VACUUM_LAST_ATTEMPT_AT < DB_VACUUM_MIN_INTERVAL_SECONDS: + return + current_hour = datetime.now().hour + if not _hour_is_in_window(current_hour, DB_VACUUM_WINDOW_START_HOUR, DB_VACUUM_WINDOW_END_HOUR): + return + if not _DB_VACUUM_LOCK.acquire(blocking=False): + return + try: + now = time.time() + if now - _DB_VACUUM_LAST_ATTEMPT_AT < DB_VACUUM_MIN_INTERVAL_SECONDS: + return + _DB_VACUUM_LAST_ATTEMPT_AT = now + with engine.begin() as conn: + page_size = int(conn.execute(text("PRAGMA page_size")).scalar() or 0) + freelist_count = int(conn.execute(text("PRAGMA freelist_count")).scalar() or 0) + free_bytes = page_size * freelist_count + if free_bytes < DB_VACUUM_FREELIST_MIN_BYTES: + return + backup_name = maybe_create_database_backup("auto_vacuum", force=True) + if not backup_name: + logger.warning("Auto VACUUM skipped because pre-vacuum backup was not created") + return + logger.warning( + "Auto VACUUM starting after backup %s; freelist bytes=%s", + backup_name, + free_bytes, + ) + vacuum_conn = sqlite3.connect(DB_PATH, timeout=30) + try: + vacuum_conn.execute("PRAGMA busy_timeout=30000") + vacuum_conn.execute("VACUUM") + finally: + vacuum_conn.close() + logger.warning("Auto VACUUM completed") + except OperationalError as exc: + logger.warning("Auto VACUUM skipped due to database lock: %s", exc) + except Exception as exc: + logger.warning("Auto VACUUM skipped due to unexpected error: %s", exc) + finally: + _DB_VACUUM_LOCK.release() + + def load_project_status_snapshot_payload(conn: Any, support_dept_code: str) -> dict[str, Any]: normalized_code = normalize_text(support_dept_code) if not normalized_code: @@ -2087,13 +3881,14 @@ def import_contract_status_workbook(workbook: Any, source_file: str) -> int: ) inserted = 0 for row in sheet.iter_rows(min_row=2, values_only=True): - support_dept_code = normalize_project_code(row[1] if len(row) > 1 else "") + business_division = normalize_text(row[0] if len(row) > 0 else "") + support_dept_code = normalize_actual_project_code(row[1] if len(row) > 1 else "") if not support_dept_code: continue payload = { "support_dept_code": support_dept_code, "raw_contract_code": normalize_text(row[1] if len(row) > 1 else ""), - "business_division": normalize_text(row[0] if len(row) > 0 else ""), + "business_division": business_division, "order_method": normalize_text(row[2] if len(row) > 2 else ""), "owner_department": normalize_text(row[3] if len(row) > 3 else ""), "client_name": normalize_text(row[4] if len(row) > 4 else ""), @@ -2279,11 +4074,12 @@ def import_change_contract_summary_workbook(workbook: Any, source_file: str) -> support_dept_name = normalize_text(values[3] if len(values) > 3 else "") if not raw_summary_code or not support_dept_name: continue + business_division = normalize_text(values[1] if len(values) > 1 else "") payload = { "raw_summary_code": "".join(character for character in raw_summary_code if character.isdigit()), "normalized_title": normalize_project_title_for_linking(support_dept_name), "owner_department": current_department, - "business_division": normalize_text(values[1] if len(values) > 1 else ""), + "business_division": business_division, "support_dept_name": support_dept_name, "change_date": normalize_date_text(values[4] if len(values) > 4 else ""), "client_name": normalize_text(values[5] if len(values) > 5 else ""), @@ -2338,7 +4134,8 @@ def import_change_contract_round_workbook(workbook: Any, source_file: str) -> in current_department = first_value raw_round_code = normalize_text(values[2] if len(values) > 2 else "").replace("\xa0", "") support_dept_name = normalize_text(values[3] if len(values) > 3 else "") - support_dept_code = normalize_project_code(raw_round_code) + business_division = normalize_text(values[1] if len(values) > 1 else "") + support_dept_code = normalize_actual_project_code(raw_round_code) if not support_dept_code or not support_dept_name: continue payload = { @@ -2346,7 +4143,7 @@ def import_change_contract_round_workbook(workbook: Any, source_file: str) -> in "raw_round_code": raw_round_code, "normalized_title": normalize_project_title_for_linking(support_dept_name), "owner_department": current_department, - "business_division": normalize_text(values[1] if len(values) > 1 else ""), + "business_division": business_division, "support_dept_name": support_dept_name, "change_date": normalize_date_text(values[4] if len(values) > 4 else ""), "client_name": normalize_text(values[5] if len(values) > 5 else ""), @@ -2444,7 +4241,7 @@ def get_project_contract_change_maps() -> tuple[dict[str, dict[str, Any]], dict[ title_key = normalize_text(row["normalized_title"]) if title_key: summary_by_title.setdefault(title_key, []).append(dict(row)) - summary_code = normalize_project_code(row.get("raw_summary_code")) + summary_code = normalize_actual_project_code(row.get("raw_summary_code")) if summary_code: summary_codes_by_title.setdefault(title_key, set()).add(summary_code) latest_summary_by_title = { @@ -2583,6 +4380,15 @@ def sync_auto_project_related_links() -> None: """ ) ).mappings().all() + change_summary_rows = conn.execute( + text( + """ + SELECT raw_summary_code, normalized_title, support_dept_name, business_division + FROM project_contract_change_summary + WHERE COALESCE(raw_summary_code, '') <> '' + """ + ) + ).mappings().all() existing_codes = { normalize_text(row[0]) for row in conn.execute( @@ -2687,6 +4493,15 @@ def sync_auto_project_related_links() -> None: continue change_contract_codes.add(code) change_round_title_groups.setdefault(title_key, set()).add(code) + for row in change_summary_rows: + code = normalize_actual_project_code(row["raw_summary_code"]) + title_key = normalize_text(row["normalized_title"]) or normalize_project_title_for_linking(row["support_dept_name"]) + if not code or not title_key: + continue + existing_codes.add(code) + change_contract_codes.add(code) + project_names.setdefault(code, normalize_text(row["support_dept_name"])) + change_round_title_groups.setdefault(title_key, set()).add(code) project_codes_by_title: dict[str, set[str]] = {} for code, name in project_names.items(): @@ -2701,6 +4516,30 @@ def sync_auto_project_related_links() -> None: code for code in cluster_codes if code in existing_codes ) + code_family_groups: dict[str, set[str]] = {} + for code in existing_codes: + normalized_code = normalize_text(code).upper() + if len(normalized_code) < 2: + continue + suffix = contract_family_code_suffix(normalized_code) + if not suffix: + continue + code_family_groups.setdefault(str(int(suffix)), set()).add(normalized_code) + for suffix, codes in code_family_groups.items(): + x_code = f"X{suffix}" + raw_x_code = f"9{suffix.zfill(5)}" + if raw_x_code in codes and x_code in codes: + cluster_map.setdefault(f"code_family::{suffix}::X", set()).update({raw_x_code, x_code}) + raw_total_code = f"0{suffix.zfill(5)}" + if raw_total_code not in codes: + continue + for prefix in ("Y", "Z"): + related_code = f"{prefix}{suffix}" + if related_code in codes: + cluster_map.setdefault(f"code_family::{suffix}::{prefix}", set()).update( + {raw_total_code, related_code} + ) + title_groups: dict[str, set[str]] = {} for code, name in project_names.items(): normalized_title = normalize_project_title_for_linking(name) @@ -2782,6 +4621,8 @@ def sync_auto_project_related_links() -> None: continue if str(cluster_key).startswith("change_round::"): link_source = "auto_change_contract" + elif str(cluster_key).startswith("code_family::"): + link_source = "auto_code_family" elif str(cluster_key).startswith("title_fuzzy::"): link_source = "auto_title_fuzzy" elif str(cluster_key).startswith("title::"): @@ -2807,7 +4648,11 @@ def sync_auto_project_related_links() -> None: CURRENT_TIMESTAMP ) ON CONFLICT(base_support_dept_code, related_support_dept_code) DO UPDATE SET - link_source = excluded.link_source, + 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 """ ), @@ -2931,7 +4776,11 @@ def sync_auto_project_related_links() -> None: CURRENT_TIMESTAMP ) ON CONFLICT(base_support_dept_code, related_support_dept_code) DO UPDATE SET - link_source = excluded.link_source, + 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 """ ), @@ -2944,21 +4793,148 @@ def sync_auto_project_related_links() -> None: @app.on_event("startup") def on_startup() -> None: - init_db() - init_wehago_compare_db(engine) - _ensure_app_maintenance_worker() - with engine.begin() as conn: - corrected = sanitize_project_labor_amount_rows(conn) - if corrected: - logger.info("Sanitized project labor amounts for %s project(s)", corrected) + sqlite_status = validate_sqlite_runtime() + try: + init_db() + except OperationalError as exc: + if "readonly database" not in str(exc).lower(): + raise + logger.warning("Startup DB initialization skipped because runtime DB is readonly: %s", exc) + _ensure_app_post_startup_warmup() + logger.info("DB ready at %s (SQLite %s)", DB_PATH, sqlite_status["sqlite_version"]) + + +def _run_app_post_startup_warmup() -> None: + time.sleep(1.0) + cleaned_jobs = _cleanup_stale_system_jobs("startup", all_running=True) + if cleaned_jobs: + logger.warning("Cleaned %s stale running system job(s) during startup", cleaned_jobs) + try: + init_wehago_compare_db(engine) + except Exception as exc: + logger.warning("Post-startup compare DB init skipped due to error: %s", exc) + try: + _ensure_app_maintenance_worker() + except Exception as exc: + logger.warning("Post-startup maintenance worker init skipped due to error: %s", exc) + try: + _ensure_system_job_worker() + except Exception as exc: + logger.warning("Post-startup system job worker init skipped due to error: %s", exc) + try: + with engine.begin() as conn: + corrected = sanitize_project_labor_amount_rows(conn) + if corrected: + logger.info("Sanitized project labor amounts for %s project(s)", corrected) + except Exception as exc: + logger.warning("Post-startup labor sanitize skipped due to error: %s", exc) run_heavy_startup = normalize_text(os.getenv("HM_RUN_HEAVY_STARTUP", "0")) in {"1", "true", "yes", "on"} - if run_heavy_startup: + if not run_heavy_startup: + logger.info("Skipping heavy startup refresh tasks; existing DB state will be used as-is") + return + try: auto_import_project_excels() sync_auto_project_related_links() normalize_all_collection_entry_storage() - else: - logger.info("Skipping heavy startup refresh tasks; existing DB state will be used as-is") - logger.info("DB ready at %s", DB_PATH) + except Exception as exc: + logger.warning("Post-startup heavy refresh skipped due to error: %s", exc) + + +def _ensure_app_post_startup_warmup() -> None: + global _APP_POST_STARTUP_WARMUP_STARTED + with _APP_POST_STARTUP_WARMUP_LOCK: + if _APP_POST_STARTUP_WARMUP_STARTED: + return + worker = threading.Thread( + target=_run_app_post_startup_warmup, + daemon=True, + name="app-post-startup-warmup", + ) + worker.start() + _APP_POST_STARTUP_WARMUP_STARTED = True + + +def _safe_next_url(value: Any) -> str: + next_url = unquote_plus(normalize_text(value)) + if not next_url.startswith("/") or next_url.startswith("//"): + return "/" + if next_url.startswith("/login") or next_url.startswith("/logout"): + return "/" + return next_url + + +@app.get("/login") +async def login_page(request: Request, next: str = ""): + init_db() + if _auth_get_request_user(request): + return RedirectResponse(url=_safe_next_url(next), status_code=303) + return templates.TemplateResponse( + request, + "login.html", + {"request": request, "next_url": _safe_next_url(next), "error": ""}, + ) + + +@app.post("/login") +async def login_submit(request: Request): + form = await request.form() + username = normalize_text(form.get("username")) + password = str(form.get("password") or "") + next_url = _safe_next_url(form.get("next")) + user = authenticate_user(username, password) + if not user: + _auth_log_event(username, False, request, "invalid_credentials") + return templates.TemplateResponse( + request, + "login.html", + {"request": request, "next_url": next_url, "error": "아이디 또는 비밀번호가 올바르지 않습니다."}, + status_code=401, + ) + session_token = create_login_session(int(user["id"]), request) + _auth_log_event(username, True, request, user_id=int(user["id"])) + response = RedirectResponse(url=next_url, status_code=303) + return _auth_cookie_response(response, session_token) + + +@app.get("/logout") +async def logout(request: Request): + payload = _auth_unsign_payload(request.cookies.get(AUTH_COOKIE_NAME, "")) + session_id = normalize_text((payload or {}).get("sid")) + if session_id: + with engine.begin() as conn: + conn.execute( + text("UPDATE app_sessions SET revoked_at = CURRENT_TIMESTAMP WHERE session_id = :session_id"), + {"session_id": session_id}, + ) + response = RedirectResponse(url="/login", status_code=303) + return _auth_cookie_response(response, "") + + +@app.get("/admin/users") +async def admin_users(request: Request, message: str = ""): + context = { + **base_context(request, message), + "users": list_admin_users(), + "permission_options": list_admin_permission_options(), + } + return templates.TemplateResponse(request, "admin_users.html", context) + + +@app.post("/admin/users") +async def admin_users_save(request: Request): + form = await request.form() + try: + payload = dict(form) + payload["permissions"] = form.getlist("permissions") + upsert_admin_user(payload) + return RedirectResponse(url="/admin/users?message=%EC%A0%80%EC%9E%A5%EB%90%98%EC%97%88%EC%8A%B5%EB%8B%88%EB%8B%A4", status_code=303) + except Exception as exc: + context = { + **base_context(request, str(exc)), + "users": list_admin_users(), + "permission_options": list_admin_permission_options(), + } + return templates.TemplateResponse(request, "admin_users.html", context, status_code=400) @app.get("/db", include_in_schema=False) @@ -3016,7 +4992,7 @@ def build_hanmac_browser_plan() -> dict[str, Any]: "strategy": [ { "title": "1단계: 바로 보기", - "description": "hanmac / hanmac_manhour에서 자주 보는 테이블을 읽기 전용으로 조회합니다.", + "description": "hanmac / hanmac_manhour / baron_manhour에서 자주 보는 테이블을 읽기 전용으로 조회합니다.", }, { "title": "2단계: 기준키 정리", @@ -3044,6 +5020,11 @@ def build_hanmac_browser_plan() -> dict[str, Any]: "project_tbl", ], }, + { + "name": "baron_manhour", + "role": "센터/총괄 인원 기준 및 중복 투입시간 제외 여부 확인", + "tables": ["member_tbl"], + }, ], "recommended_views": [ { @@ -3115,7 +5096,7 @@ def build_hanmac_browser_plan() -> dict[str, Any]: ], "connection_requirements": [ "MySQL 접속 정보(host, port, user, password)", - "허용할 스키마 목록(hanmac, hanmac_manhour)", + "허용할 스키마 목록(hanmac, hanmac_manhour, baron_manhour)", "읽기 전용 계정 여부 확인", ], } @@ -3215,6 +5196,100 @@ def normalize_project_code(value: Any, default_prefix: str = "Y") -> str: return f"{prefix or default_prefix}{int(digits)}" +def normalize_actual_project_code(value: Any) -> str: + text_value = normalize_text(value).replace("\u3164", "").replace("\xa0", "").upper() + prefix = next((character for character in text_value if character.isalpha()), "") + digits = "".join(character for character in text_value if character.isdigit()) + if not digits: + return "" + if prefix: + return f"{prefix}{int(digits)}" + if len(digits) <= 5: + return digits.zfill(6) + return digits + + +def normalize_contract_family_code( + value: Any, + business_division: Any = "", + default_prefix: str = "Y", +) -> str: + text_value = normalize_text(value).replace("\u3164", "").replace("\xa0", "").upper() + digits = "".join(character for character in text_value if character.isdigit()) + if not digits: + return "" + explicit_prefix = next((character for character in text_value if character.isalpha()), "") + if explicit_prefix in {"X", "Y", "Z"}: + return f"{explicit_prefix}{int(digits)}" + + business_text = normalize_text(business_division) + if len(digits) >= 6 and digits[0] == "9": + return f"X{int(digits[1:])}" + if len(digits) >= 6 and digits[0] == "0": + prefix = "Z" if "감리" in business_text else "Y" + return f"{prefix}{int(digits[1:])}" + return f"{default_prefix}{int(digits)}" + + +def contract_family_code_suffix(value: Any) -> str: + text_value = normalize_text(value).upper() + digits = "".join(character for character in text_value if character.isdigit()) + if not digits: + return "" + has_alpha_prefix = any(character.isalpha() for character in text_value) + if not has_alpha_prefix and len(digits) >= 6 and digits[0] in {"0", "9"}: + digits = digits[1:] + return str(int(digits)) if digits else "" + + +def resolve_contract_family_code_from_contract_info( + conn: Any, + value: Any, + business_division: Any = "", + support_dept_name: Any = "", + default_prefix: str = "Y", +) -> str: + fallback_code = normalize_contract_family_code( + value, + business_division=business_division, + default_prefix=default_prefix, + ) + suffix = contract_family_code_suffix(value) + if not suffix: + return fallback_code + + candidate_codes = [f"{prefix}{suffix}" for prefix in ("X", "Y", "Z")] + rows = conn.execute( + text( + """ + SELECT support_dept_code, business_division, support_dept_name + FROM project_contract_info + WHERE support_dept_code IN :candidate_codes + """ + ).bindparams(bindparam("candidate_codes", expanding=True)), + {"candidate_codes": candidate_codes}, + ).mappings().all() + if not rows: + return fallback_code + + target_title = normalize_project_title_for_linking(support_dept_name) + target_business = normalize_text(business_division) + matching_title_rows = [ + row + for row in rows + if target_title and normalize_project_title_for_linking(row.get("support_dept_name")) == target_title + ] + if matching_title_rows: + matching_business_rows = [ + row + for row in matching_title_rows + if not target_business or normalize_text(row.get("business_division")) == target_business + ] + chosen_row = (matching_business_rows or matching_title_rows)[0] + return normalize_text(chosen_row.get("support_dept_code")) or fallback_code + return fallback_code + + def normalize_round_value(value: Any) -> str: text_value = normalize_text(value) if not text_value: @@ -4098,36 +6173,136 @@ def sum_row_amounts(rows: list[dict[str, Any]], amount_key: str = "amount") -> f return sum(normalize_amount(row.get(amount_key)) for row in rows) -def _parse_labor_rates_json(raw_json: Any) -> dict[str, dict[str, float]]: +LABOR_RATE_CATEGORY_ALIASES = { + "design": "설계", + "supervision": "감리", + "support": "지원", + "설계": "설계", + "감리": "감리", + "지원": "지원", +} +LABOR_RATE_BASE_GRADES = {"사장", "부사장", "전무", "전무이사", "상무", "상무이사", "이사", "부장", "차장", "과장", "대리", "사원"} +LABOR_RATE_DERIVED_GRADES = {"수석", "책임", "선임", "연구원"} + + +def _normalize_labor_grade_name(value: Any) -> str: + grade_text = normalize_text(value).replace(" ", "") + aliases = { + "전무이사": "전무", + "상무이사": "상무", + "409091": "이사", + "272727": "부장", + "250000": "차장", + "227273": "과장", + "204545": "대리", + "181818": "사원", + } + return aliases.get(grade_text, grade_text) + + +def _normalize_labor_rate_category(value: Any) -> str: + text_value = normalize_text(value) + if "감리" in text_value: + return "감리" + if "지원" in text_value: + return "지원" + if "설계" in text_value: + return "설계" + return LABOR_RATE_CATEGORY_ALIASES.get(text_value, "설계") + + +def _labor_rate_lookup_category(value: Any) -> str: + category = _normalize_labor_rate_category(value) + return "감리" if category == "지원" else category + + +def _ceil_to_hundreds(value: float) -> float: + amount = float(value or 0) + if amount <= 0: + return 0.0 + return float(math.ceil(amount / 100.0) * 100) + + +def _get_numeric_labor_rate(bucket: dict[str, float], grade: str) -> float: + return normalize_amount(bucket.get(_normalize_labor_grade_name(grade))) + + +def _get_derived_labor_rate(design_bucket: dict[str, float], grade: str) -> float: + grade_text = _normalize_labor_grade_name(grade) + if grade_text == "수석": + return _get_numeric_labor_rate(design_bucket, "이사") + if grade_text == "책임": + manager_rate = _get_numeric_labor_rate(design_bucket, "부장") + deputy_rate = _get_numeric_labor_rate(design_bucket, "차장") + if not manager_rate or not deputy_rate: + return 0.0 + return _ceil_to_hundreds(((manager_rate * 5) + (deputy_rate * 2)) / 7) + if grade_text == "선임": + deputy_rate = _get_numeric_labor_rate(design_bucket, "차장") + section_rate = _get_numeric_labor_rate(design_bucket, "과장") + assistant_rate = _get_numeric_labor_rate(design_bucket, "대리") + if not deputy_rate or not section_rate or not assistant_rate: + return 0.0 + return _ceil_to_hundreds(((deputy_rate * 2) + (section_rate * 3) + assistant_rate) / 6) + if grade_text == "연구원": + assistant_rate = _get_numeric_labor_rate(design_bucket, "대리") + staff_rate = _get_numeric_labor_rate(design_bucket, "사원") + if not assistant_rate or not staff_rate: + return 0.0 + return _ceil_to_hundreds(((assistant_rate * 2) + (staff_rate * 3)) / 5) + return 0.0 + + +def _parse_labor_rates_json(raw_json: Any) -> dict[str, dict[str, dict[str, float]]]: try: parsed = json.loads(normalize_text(raw_json) or "{}") except json.JSONDecodeError: return {} if not isinstance(parsed, dict): return {} - normalized: dict[str, dict[str, float]] = {} + normalized: dict[str, dict[str, dict[str, float]]] = {} for year_key, bucket in parsed.items(): year_text = normalize_text(year_key) if not year_text or not isinstance(bucket, dict): continue normalized[year_text] = {} - for grade_key, amount_value in bucket.items(): - grade_text = normalize_text(grade_key) - if not grade_text: - continue - normalized[year_text][grade_text] = normalize_amount(amount_value) + has_category_buckets = any( + _normalize_labor_rate_category(key) in {"설계", "감리", "지원"} + and isinstance(value, dict) + for key, value in bucket.items() + ) + if has_category_buckets: + for category_key, category_bucket in bucket.items(): + if not isinstance(category_bucket, dict): + continue + category = _normalize_labor_rate_category(category_key) + normalized[year_text].setdefault(category, {}) + for grade_key, amount_value in category_bucket.items(): + grade_text = _normalize_labor_grade_name(grade_key) + if not grade_text: + continue + normalized[year_text][category][grade_text] = normalize_amount(amount_value) + else: + normalized[year_text].setdefault("설계", {}) + for grade_key, amount_value in bucket.items(): + grade_text = _normalize_labor_grade_name(grade_key) + if not grade_text: + continue + normalized[year_text]["설계"][grade_text] = normalize_amount(amount_value) return normalized def _resolve_labor_rate( - rates_by_year: dict[str, dict[str, float]], + rates_by_year: dict[str, dict[str, dict[str, float]]], grade: Any, rate_year: Any, fallback_year: Any = "", + project_type: Any = "", ) -> float: - grade_text = normalize_text(grade) + grade_text = _normalize_labor_grade_name(grade) if not grade_text: return 0.0 + category = _labor_rate_lookup_category(project_type) year_candidates: list[str] = [] for value in (rate_year, fallback_year): text_value = normalize_text(value) @@ -4137,7 +6312,17 @@ def _resolve_labor_rate( year_candidates.append(str(datetime.now().year)) for year_text in year_candidates: year_bucket = rates_by_year.get(year_text) or {} - amount = normalize_amount(year_bucket.get(grade_text)) + design_bucket = year_bucket.get("설계") or {} + if grade_text in LABOR_RATE_DERIVED_GRADES: + amount = _get_derived_labor_rate(design_bucket, grade_text) + if amount: + return amount + category_bucket = year_bucket.get(category) or {} + if not category_bucket and category == "감리": + category_bucket = year_bucket.get("지원") or {} + if not category_bucket: + category_bucket = design_bucket + amount = normalize_amount(category_bucket.get(grade_text)) if amount: return amount return 0.0 @@ -4445,6 +6630,8 @@ def merge_project_external_fields( or normalize_text(billing_summary.get("business_division")) or normalize_text(latest_summary_change.get("business_division")) or normalize_text(latest_round_change.get("business_division")) + or ("설계" if current_code.upper().startswith("Y") else "") + or ("감리" if current_code.upper().startswith("Z") else "") ) progress_rate = normalize_amount(item.get("progress_rate")) if not progress_rate and contract_amount: @@ -4512,22 +6699,149 @@ def build_transaction_posting_display(voucher_number: Any, posting_date: Any) -> def get_data_version() -> str: - with engine.begin() as conn: - transaction_updated = conn.execute(text("SELECT MAX(updated_at) FROM transactions")).scalar() - project_updated = conn.execute(text("SELECT MAX(updated_at) FROM project_status")).scalar() - contract_updated = conn.execute(text("SELECT MAX(updated_at) FROM project_contract_info")).scalar() - billing_updated = conn.execute(text("SELECT MAX(updated_at) FROM project_billing_entries")).scalar() - change_summary_updated = conn.execute(text("SELECT MAX(updated_at) FROM project_contract_change_summary")).scalar() - change_round_updated = conn.execute(text("SELECT MAX(updated_at) FROM project_contract_change_round")).scalar() - versions = [ - normalize_text(transaction_updated), - normalize_text(project_updated), - normalize_text(contract_updated), - normalize_text(billing_updated), - normalize_text(change_summary_updated), - normalize_text(change_round_updated), + version_parts: list[str] = [] + for path in (DB_PATH, DB_PATH.with_name(f"{DB_PATH.name}-wal")): + try: + stat = path.stat() + except FileNotFoundError: + continue + version_parts.append(f"{stat.st_mtime_ns}:{stat.st_size}") + return "|".join(version_parts) + + +BUSINESS_DATA_INCLUDE_TABLES = { + "app_keyword_rules", + "app_option_items", + "hanmac_holidays", + "project_actual_input_entries", + "project_analysis_settings", + "project_basic_info", + "project_billing_entries", + "project_collection_entries", + "project_comparison_notes", + "project_contract_change_round", + "project_contract_change_summary", + "project_contract_info", + "project_exec_budget_entries", + "project_quick_links", + "project_related_links", + "project_status", + "project_task_plan_entries", + "project_uncontracted_classification", + "transactions", + "wehago_compare_settings", + "wehago_comparison_results", + "wehago_ledger_rows", + "wehago_manual_pair_matches", + "wehago_recheck_reviews", + "wehago_recheck_row_changes", + "wehago_source_files", + "wehago_voucher_rows", +} + +BUSINESS_DATA_EXCLUDE_PREFIXES = ( + "app_login", + "app_role", + "app_session", + "app_user", + "db_backup", + "system_", +) + +BUSINESS_DATA_EXCLUDE_TABLES = { + "app_save_events", + "hanmac_aggregate_query_cache", + "hanmac_aggregate_query_metrics", + "hanmac_aggregate_query_rows", + "hanmac_export_jobs", + "hanmac_preview_query_cache", + "project_page_state", + "project_status_snapshots", + "wehago_action_history", + "wehago_background_jobs", + "wehago_compare_export_jobs", + "wehago_compare_export_row_cache", + "wehago_compare_query_groups", + "wehago_compare_query_metrics", + "wehago_compare_query_page_cache", + "wehago_compare_query_rows", + "wehago_metric_count_cache", + "wehago_pair_recommend_cache", + "wehago_raw_erp_trace_candidate_cache", + "wehago_result_row_cache", + "wehago_snapshot_status", + "wehago_summary_range_cache", +} + + +def _quote_sqlite_identifier(identifier: str) -> str: + return '"' + identifier.replace('"', '""') + '"' + + +def _business_version_table_names(conn: Any) -> list[str]: + rows = conn.execute( + text( + """ + SELECT name + FROM sqlite_master + WHERE type = 'table' + AND name NOT LIKE 'sqlite_%' + ORDER BY name + """ + ) + ).fetchall() + names = [str(row[0]) for row in rows] + return [ + name + for name in names + if name in BUSINESS_DATA_INCLUDE_TABLES + and name not in BUSINESS_DATA_EXCLUDE_TABLES + and not any(name.startswith(prefix) for prefix in BUSINESS_DATA_EXCLUDE_PREFIXES) ] - return max((version for version in versions if version), default="") + + +def get_business_data_version() -> str: + init_db() + version_parts: list[str] = [] + try: + with engine.connect() as conn: + for table_name in _business_version_table_names(conn): + quoted_table = _quote_sqlite_identifier(table_name) + columns = [ + str(row[1]) + for row in conn.exec_driver_sql(f"PRAGMA table_info({quoted_table})").fetchall() + ] + probes = ["COUNT(*) AS row_count"] + if "updated_at" in columns: + probes.append("COALESCE(MAX(updated_at), '') AS updated_at") + else: + probes.append("'' AS updated_at") + if "created_at" in columns: + probes.append("COALESCE(MAX(created_at), '') AS created_at") + else: + probes.append("'' AS created_at") + if "id" in columns: + probes.append("COALESCE(MAX(id), 0) AS max_id") + else: + probes.append("COALESCE(MAX(rowid), 0) AS max_id") + row = conn.exec_driver_sql(f"SELECT {', '.join(probes)} FROM {quoted_table}").mappings().first() + if row: + version_parts.append( + "|".join( + [ + table_name, + str(row["row_count"] or 0), + str(row["updated_at"] or ""), + str(row["created_at"] or ""), + str(row["max_id"] or 0), + ] + ) + ) + except Exception as exc: + logger.warning("business data version fallback to file version: %s", exc) + return get_data_version() + raw_version = "\n".join(version_parts) + return hashlib.sha1(raw_version.encode("utf-8")).hexdigest() if raw_version else "" def build_health_payload(force: bool = False) -> dict[str, str]: @@ -4536,10 +6850,14 @@ def build_health_payload(force: bool = False) -> dict[str, str]: if not force and cached_payload and now < float(_HEALTH_PAYLOAD_CACHE.get("expires_at") or 0.0): return dict(cached_payload) + business_data_version = get_business_data_version() + system_data_version = get_data_version() payload = { "status": "ok", "server_time": datetime.now().isoformat(timespec="seconds"), - "data_version": get_data_version(), + "business_data_version": business_data_version, + "system_data_version": system_data_version, + "data_version": business_data_version, } _HEALTH_PAYLOAD_CACHE["payload"] = payload _HEALTH_PAYLOAD_CACHE["expires_at"] = now + 2.0 @@ -5092,6 +7410,67 @@ def get_project_status_row_for_code(support_dept_code: str | None) -> dict[str, return None +def slim_project_status_row(item: dict[str, Any]) -> dict[str, Any]: + keys = ( + "support_dept_code", + "support_dept_name", + "row_count", + "progress_rate", + "contract_amount", + "collection_amount", + "change_round", + "item_investment", + "task_plan_department_budget", + "task_plan_outsource_budget", + "task_plan_joint_operating_cost", + "exec_budget_labor_by_grade", + "exec_budget_outsource", + "exec_budget_cost_plan", + "expected_as_cost", + "expected_sga_budget", + "project_start_date", + "project_end_date", + "completion_status", + "total_cost", + "total_sga", + "total_revenue", + "actual_labor", + "actual_outsource", + "latest_year", + "latest_month", + "project_type", + "shared_input_owner_code", + "client_name", + "order_method", + "joint_contract", + "pm_name", + "contract_status", + "progress_status", + "work_category", + "review_tag", + "review_note", + "total_contract_amount", + "hanmac_contract_amount", + "billing_contract_amount", + "billed_amount", + "collection_balance_amount", + "latest_billing_date", + "changed_contract_amount", + "changed_contract_date", + "changed_project_end_date", + "change_contract_representative_code", + "change_contract_title_key", + ) + result = {key: item.get(key) for key in keys if key in item} + result["shared_input_cluster_codes"] = list(item.get("shared_input_cluster_codes") or []) + result["_detail_loaded"] = False + return result + + +def get_project_status_search_rows() -> list[dict[str, Any]]: + return [slim_project_status_row(item) for item in get_project_status_rows()] + + def get_project_comparison_notes_map() -> dict[str, dict[str, str]]: with engine.begin() as conn: rows = conn.execute( @@ -5269,7 +7648,7 @@ def get_project_status_for_edit(support_dept_code: str | None) -> dict[str, Any] "task_plan_joint_operating_cost": "", "task_plan_entries": [], "exec_budget_labor_by_grade": "", - "exec_labor_rates": {}, + "exec_labor_rates": get_shared_exec_labor_rates(), "exec_budget_outsource": "", "exec_budget_cost_plan": "", "exec_budget_entries": [], @@ -5349,7 +7728,7 @@ def get_project_status_for_edit(support_dept_code: str | None) -> dict[str, Any] {"support_dept_code": support_dept_code}, ).mappings().first() if not row: - return { + result = { "support_dept_code": support_dept_code, "support_dept_name": "", "progress_rate": "", @@ -5395,6 +7774,18 @@ def get_project_status_for_edit(support_dept_code: str | None) -> dict[str, Any] "latest_billing_date": "", "updated_at": "", } + result["exec_labor_rates"] = get_shared_exec_labor_rates() + title_key = title_by_code.get(normalize_text(support_dept_code)) or normalize_project_title_for_linking(result.get("support_dept_name")) + result = merge_project_external_fields( + result, + contract_info_map.get(normalize_text(support_dept_code)), + billing_summary_map.get(normalize_text(support_dept_code)), + latest_summary_by_title.get(title_key), + latest_round_by_code.get(normalize_text(support_dept_code)), + representative_by_title.get(title_key, ""), + title_key, + ) + return result result = dict(row) support_dept_code = normalize_text(result.get("support_dept_code")) entry_set = ensure_project_entry_set(entry_maps.get(support_dept_code)) if support_dept_code in entry_maps else extract_project_status_entry_sets(result) @@ -5878,8 +8269,23 @@ def get_project_year_options() -> list[int]: return get_available_years() +def get_latest_completed_year(reference_date: date | None = None) -> int | None: + available_years = get_available_years() + if not available_years: + return None + today = reference_date or date.today() + cutoff = date(today.year, 1, 10) + while cutoff.weekday() >= 5: + cutoff += timedelta(days=1) + completed_boundary_year = today.year - 1 if today >= cutoff else today.year - 2 + completed_years = [year for year in available_years if int(year) <= completed_boundary_year] + return max(completed_years or available_years) + + def resolve_selected_year(selected_year: int | None) -> int | None: - return selected_year + if selected_year is not None: + return selected_year + return get_latest_completed_year() def parse_optional_year(value: Any) -> int | None: @@ -6251,13 +8657,13 @@ def get_project_revenue_mix_monthly() -> list[dict[str, Any]]: return result -def get_project_cost_by_year(selected_year: int | None) -> list[dict[str, Any]]: +def get_project_cost_by_year(selected_year: int | None = None, *, all_years: bool = False) -> list[dict[str, Any]]: year_clause = "" params: dict[str, Any] = {} if selected_year: year_clause = "AND year = :selected_year" params["selected_year"] = selected_year - else: + elif not all_years: recent_10_start_year = get_recent_10_start_year() if recent_10_start_year is not None: year_clause = "AND year >= :recent_10_start_year" @@ -6322,6 +8728,19 @@ def get_project_account_breakdowns( selected_year: int | None, codes: list[str] | None = None, ) -> dict[str, dict[str, list[dict[str, Any]]]]: + cache_key = ( + selected_year or 0, + tuple(sorted(normalize_text(code) for code in (codes or []) if normalize_text(code))), + ) + cached = _get_deepcopy_ttl_cache_entry( + _PROJECT_ACCOUNT_BREAKDOWN_CACHE, + _PROJECT_ACCOUNT_BREAKDOWN_CACHE_LOCK, + cache_key, + PROJECT_ACCOUNT_BREAKDOWN_CACHE_TTL_SECONDS, + ) + if cached is not None: + return cached + year_clause = "" params: dict[str, Any] = {} code_clause = "" @@ -6434,7 +8853,12 @@ def get_project_account_breakdowns( } for label, amount in sorted(entries.items(), key=lambda item: item[1], reverse=True) ] - return normalized_result + return _set_deepcopy_ttl_cache_entry( + _PROJECT_ACCOUNT_BREAKDOWN_CACHE, + _PROJECT_ACCOUNT_BREAKDOWN_CACHE_LOCK, + cache_key, + normalized_result, + ) def parse_support_dept_codes_param(raw_codes: Any, fallback_code: Any = "") -> list[str]: @@ -6727,7 +9151,7 @@ def _get_process_cost_project_kind(code: Any) -> tuple[str, str]: normalized = normalize_text(code).upper() prefix = normalized[:1] if prefix == "X": - return "X", "사전사업" + return "X", "사업전" if prefix == "Y": return "Y", "설계" if prefix == "Z": @@ -7620,7 +10044,48 @@ def render_process_cost_page( return templates.TemplateResponse(request, "process_cost.html", context) -def get_process_cost_bootstrap_payload( +def _process_cost_bootstrap_cache_params( + source: str | None = None, + start_year: int | None = None, + end_year: int | None = None, + code: str | None = None, + include_related: bool = False, + active_related: str | None = None, +) -> dict[str, Any]: + normalized_source = normalize_text(source).lower() or "hanmac" + if normalized_source not in {"hanmac", "wehago"}: + normalized_source = "hanmac" + return { + "source": normalized_source, + "start_year": int(start_year or 0), + "end_year": int(end_year or 0), + "code": normalize_text(code), + "include_related": bool(include_related), + "active_related": normalize_text(active_related), + } + + +def _process_cost_bootstrap_cache_key( + source: str | None = None, + start_year: int | None = None, + end_year: int | None = None, + code: str | None = None, + include_related: bool = False, + active_related: str | None = None, +) -> str: + return _json_hash( + _process_cost_bootstrap_cache_params( + source, + start_year, + end_year, + code, + include_related, + active_related, + ) + ) + + +def _build_process_cost_bootstrap_payload_uncached( source: str | None = None, start_year: int | None = None, end_year: int | None = None, @@ -7704,6 +10169,52 @@ def get_process_cost_bootstrap_payload( } +def get_process_cost_bootstrap_payload( + source: str | None = None, + start_year: int | None = None, + end_year: int | None = None, + code: str | None = None, + include_related: bool = False, + active_related: str | None = None, +) -> dict[str, Any]: + cache_key = ( + normalize_text(source).lower() or "hanmac", + start_year or 0, + end_year or 0, + normalize_text(code), + bool(include_related), + normalize_text(active_related), + ) + cached = _get_runtime_cache_entry( + _PROCESS_COST_PROJECT_DETAIL_CACHE, + cache_key, + ttl_seconds=PROCESS_COST_CACHE_TTL_SECONDS, + ) + if cached is not None: + return cached + persistent_cache_key = _process_cost_bootstrap_cache_key( + source, + start_year, + end_year, + code, + include_related, + active_related, + ) + persistent = _load_system_page_cache("process_cost_bootstrap", persistent_cache_key) + if persistent is not None: + return _set_runtime_cache_entry(_PROCESS_COST_PROJECT_DETAIL_CACHE, cache_key, persistent) + + payload = _build_process_cost_bootstrap_payload_uncached( + source, + start_year, + end_year, + code, + include_related, + active_related, + ) + return _set_runtime_cache_entry(_PROCESS_COST_PROJECT_DETAIL_CACHE, cache_key, payload) + + def get_financial_series(granularity: str) -> list[dict[str, Any]]: group_fields = "year" if granularity == "yearly" else "year, month" order_fields = "year" if granularity == "yearly" else "year, month" @@ -8358,6 +10869,7 @@ def build_project_status_payload(payload: dict[str, Any]) -> dict[str, Any]: normalized_row.get("grade"), normalized_row.get("rate_year"), fallback_rate_year, + payload.get("project_type"), ) * hours_value normalized_row["amount"] = computed_amount if computed_amount else amount normalized_row["group"] = "labor" @@ -8414,6 +10926,7 @@ def build_project_status_payload(payload: dict[str, Any]) -> dict[str, Any]: normalized_row.get("grade"), normalized_row.get("rate_year"), fallback_rate_year, + payload.get("project_type"), ) * (minutes_value / 60.0 if minutes_value else 0.0) normalized_row["amount"] = computed_amount if computed_amount else amount actual_labor_rows.append(normalized_row) @@ -8782,15 +11295,70 @@ def save_project_status(payload: dict[str, Any]) -> None: def base_context(request: Request, message: str = "") -> dict[str, Any]: health_payload = build_health_payload() + current_user = getattr(request.state, "current_user", None) + permission_set = set((current_user or {}).get("permissions") or []) + if current_user and current_user.get("is_admin"): + permission_set.update(item["permission"] for item in AUTH_NAV_ITEMS) + nav_items = [ + item + for item in AUTH_NAV_ITEMS + if current_user and (current_user.get("is_admin") or item["permission"] in permission_set) + ] return { "request": request, "message": message, + "current_user": current_user, + "nav_items": nav_items, "data_version": health_payload["data_version"], "server_time": health_payload["server_time"], "import_sync_summary": get_import_sync_summary(), } +def ensure_hmbiz_process_db() -> None: + if HMBIZ_PROCESS_DB_PATH.exists(): + return + if not HMBIZ_PROCESS_SEED_DB_PATH.exists(): + raise FileNotFoundError("HM-BIZ-PROCESS 초기 DB를 찾을 수 없습니다.") + HMBIZ_PROCESS_DB_PATH.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(HMBIZ_PROCESS_SEED_DB_PATH, HMBIZ_PROCESS_DB_PATH) + + +def load_hmbiz_process_flow_data() -> dict[str, Any]: + ensure_hmbiz_process_db() + with sqlite3.connect(HMBIZ_PROCESS_DB_PATH) as conn: + row = conn.execute("SELECT payload FROM app_state WHERE key = 'flow-data'").fetchone() + if not row: + raise FileNotFoundError("HM-BIZ-PROCESS 진행도 데이터를 찾을 수 없습니다.") + payload = json.loads(row[0]) + if not isinstance(payload, dict): + raise ValueError("HM-BIZ-PROCESS 진행도 데이터 형식이 올바르지 않습니다.") + return payload + + +def save_hmbiz_process_flow_data(payload: dict[str, Any]) -> dict[str, Any]: + if not isinstance(payload, dict) or not isinstance(payload.get("flowModel"), dict): + raise ValueError("저장할 HM-BIZ-PROCESS 진행도 데이터 형식이 올바르지 않습니다.") + ensure_hmbiz_process_db() + stored_payload = copy.deepcopy(payload) + stored_payload["exportedAt"] = datetime.now().astimezone().isoformat() + with sqlite3.connect(HMBIZ_PROCESS_DB_PATH) as conn: + conn.execute( + """ + INSERT INTO app_state (key, payload, updated_at) + VALUES ('flow-data', :payload, :updated_at) + ON CONFLICT(key) DO UPDATE SET + payload = excluded.payload, + updated_at = excluded.updated_at + """, + { + "payload": json.dumps(stored_payload, ensure_ascii=False, separators=(",", ":")), + "updated_at": stored_payload["exportedAt"], + }, + ) + return stored_payload + + def render_home( request: Request, edit_id: int | None = None, @@ -8799,6 +11367,7 @@ def render_home( ) -> HTMLResponse: init_db() available_years = get_available_years() + overview_year = resolve_selected_year(overview_year) context = { **base_context(request, message), "overview": get_overview_stats(overview_year), @@ -8813,11 +11382,24 @@ def render_home( def get_dashboard_bootstrap_payload(overview_year: int | None = None) -> dict[str, Any]: cache_key = (overview_year or 0,) - now = time.time() - with _DASHBOARD_BOOTSTRAP_CACHE_LOCK: - cached = _DASHBOARD_BOOTSTRAP_CACHE.get(cache_key) - if cached and now - float(cached.get("stored_at") or 0.0) <= DASHBOARD_BOOTSTRAP_CACHE_TTL_SECONDS: - return copy.deepcopy(cached.get("payload") or {}) + cached = _get_deepcopy_ttl_cache_entry( + _DASHBOARD_BOOTSTRAP_CACHE, + _DASHBOARD_BOOTSTRAP_CACHE_LOCK, + cache_key, + DASHBOARD_BOOTSTRAP_CACHE_TTL_SECONDS, + ) + if cached is not None: + return cached + + persistent_cache_key = _json_hash({"overview_year": overview_year or 0}) + persistent = _load_system_page_cache("dashboard_bootstrap", persistent_cache_key) + if persistent is not None: + return _set_deepcopy_ttl_cache_entry( + _DASHBOARD_BOOTSTRAP_CACHE, + _DASHBOARD_BOOTSTRAP_CACHE_LOCK, + cache_key, + persistent, + ) payload = { "yearly_summary": get_yearly_summary(), @@ -8826,13 +11408,12 @@ def get_dashboard_bootstrap_payload(overview_year: int | None = None) -> dict[st "project_revenue_mix_monthly": get_project_revenue_mix_monthly(), "overview_selected_year": overview_year, } - - with _DASHBOARD_BOOTSTRAP_CACHE_LOCK: - _DASHBOARD_BOOTSTRAP_CACHE[cache_key] = { - "stored_at": now, - "payload": copy.deepcopy(payload), - } - return payload + return _set_deepcopy_ttl_cache_entry( + _DASHBOARD_BOOTSTRAP_CACHE, + _DASHBOARD_BOOTSTRAP_CACHE_LOCK, + cache_key, + payload, + ) def render_projects_page( @@ -8868,10 +11449,2030 @@ def render_projects_page( "uncontracted_category_options": get_uncontracted_category_options(), "special_x_classification_rules": get_special_x_classification_rules(), "project_runtime_settings": get_project_runtime_settings(), + "default_exec_labor_rates": copy.deepcopy(DEFAULT_EXEC_LABOR_RATES), } return templates.TemplateResponse(request, "projects.html", context) +def get_projects_bootstrap_payload(selected_year: int | None = None) -> dict[str, Any]: + cache_scope = "all-project-cost-v3-slim-status" + cache_key = (selected_year or 0, cache_scope) + cached = _get_deepcopy_ttl_cache_entry( + _PROJECT_BOOTSTRAP_CACHE, + _PROJECT_BOOTSTRAP_CACHE_LOCK, + cache_key, + PROJECT_BOOTSTRAP_CACHE_TTL_SECONDS, + ) + if cached is not None: + return cached + + persistent_cache_key = _json_hash({"selected_year": selected_year or 0, "project_cost_scope": cache_scope}) + persistent = _load_system_page_cache("projects_bootstrap", persistent_cache_key) + if persistent is not None: + return _set_deepcopy_ttl_cache_entry( + _PROJECT_BOOTSTRAP_CACHE, + _PROJECT_BOOTSTRAP_CACHE_LOCK, + cache_key, + persistent, + ) + + payload = { + "revenue_mix": get_project_revenue_mix(selected_year), + "project_cost_by_year": get_project_cost_by_year(None, all_years=True), + "project_status_rows": get_project_status_search_rows(), + } + return _set_deepcopy_ttl_cache_entry( + _PROJECT_BOOTSTRAP_CACHE, + _PROJECT_BOOTSTRAP_CACHE_LOCK, + cache_key, + payload, + ) + + +COST_ANALYSIS_LABOR_KEYWORDS = ("급여", "상여", "퇴직급여", "건강보험료", "고용보험료", "산재보험료") +COST_ANALYSIS_OUTSOURCE_KEYWORDS = ("기술협력비", "설계외주비", "외주비") +COST_ANALYSIS_COMMON_CODES = {"", "ZZZZZZ"} +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 " + "WHEN COALESCE(voucher_number, '') GLOB '11-[0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9]-*' " + "THEN substr(voucher_number, 4, 4) || '-' || substr(voucher_number, 8, 2) || '-' || substr(voucher_number, 10, 2) " + "ELSE '' END" +) + + +def _parse_iso_date(value: Any) -> date | None: + text_value = normalize_date_text(value) + if not re.match(r"^\d{4}-\d{2}-\d{2}$", text_value): + return None + try: + return datetime.strptime(text_value, "%Y-%m-%d").date() + except ValueError: + return None + + +def _date_text(value: Any) -> str: + parsed = _parse_iso_date(value) + return parsed.isoformat() if parsed else "" + + +def _iter_year_slices(start_date: date, end_date: date) -> list[dict[str, Any]]: + slices: list[dict[str, Any]] = [] + current = start_date + while current <= end_date: + year_start = date(current.year, 1, 1) + year_end = date(current.year, 12, 31) + slice_start = max(current, year_start) + slice_end = min(end_date, year_end) + slices.append( + { + "year": current.year, + "start": slice_start, + "end": slice_end, + "days": (slice_end - slice_start).days + 1, + "year_days": (year_end - year_start).days + 1, + } + ) + current = slice_end + timedelta(days=1) + return slices + + +@lru_cache(maxsize=64) +def _cost_analysis_get_accumulation_start(end_date_text: str) -> date: + fallback = date(2021, 1, 1) + with engine.begin() as conn: + row = conn.execute( + text( + f""" + SELECT MIN(source_date) AS first_date + FROM ( + SELECT {COST_ANALYSIS_TX_DATE_SQL} AS source_date + FROM transactions + WHERE {COST_ANALYSIS_TX_DATE_SQL} <> '' + AND {COST_ANALYSIS_TX_DATE_SQL} <= :end_date + UNION ALL + SELECT COALESCE(COALESCE(tax_invoice_date, billing_date), '') AS source_date + FROM project_billing_entries + WHERE COALESCE(COALESCE(tax_invoice_date, billing_date), '') <> '' + AND COALESCE(COALESCE(tax_invoice_date, billing_date), '') <= :end_date + UNION ALL + SELECT COALESCE(date, '') AS source_date + FROM project_collection_entries + WHERE COALESCE(date, '') <> '' + AND COALESCE(date, '') <= :end_date + ) + """ + ), + {"end_date": end_date_text}, + ).mappings().first() + first_date = _parse_iso_date((row or {}).get("first_date")) + return first_date or fallback + + +def _cost_analysis_project_type(code: str, fallback: Any = "") -> str: + normalized_fallback = normalize_text(fallback) + if normalized_fallback: + return normalized_fallback + normalized_code = normalize_text(code).upper() + if normalized_code.startswith("Y"): + return "설계" + if normalized_code.startswith("Z"): + return "감리" + if normalized_code.startswith("X"): + return "사업전" + return "기타" + + +def _cost_analysis_financial_bucket(account_code: Any) -> str: + code = normalize_text(account_code) + if code.startswith("4"): + return "revenue" + if code.startswith("5"): + return "cost" + if code.startswith("6"): + return "sga" + return "other" + + +def _cost_analysis_expense_item(account_code: Any, account_name: Any, is_sales_cost: bool) -> str: + if is_sales_cost: + return "sales" + bucket = _cost_analysis_financial_bucket(account_code) + if bucket == "sga": + return "sga" + name = normalize_text(account_name) + if any(keyword in name for keyword in COST_ANALYSIS_LABOR_KEYWORDS): + return "labor" + if any(keyword in name for keyword in COST_ANALYSIS_OUTSOURCE_KEYWORDS): + return "outsource" + return "overhead" + + +def _cost_analysis_is_sales_cost(row: dict[str, Any]) -> bool: + for code_key, name_key in ( + ("issuing_dept_code", "issuing_dept_name"), + ("support_dept_code", "support_dept_name"), + ("cost_dept_code", "cost_dept_name"), + ): + if normalize_text(row.get(code_key)).upper() == "A0100": + return True + if "임원실" in normalize_text(row.get(name_key)): + return True + return False + + +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}, + } + + +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() + with engine.begin() as conn: + rows = conn.execute( + text( + """ + WITH code_universe AS ( + SELECT DISTINCT support_dept_code FROM transactions WHERE COALESCE(support_dept_code, '') <> '' + UNION SELECT DISTINCT support_dept_code FROM project_status WHERE COALESCE(support_dept_code, '') <> '' + UNION SELECT DISTINCT support_dept_code FROM project_basic_info WHERE COALESCE(support_dept_code, '') <> '' + 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, '') <> '' + ) + SELECT + COALESCE(u.support_dept_code, '') AS support_dept_code, + COALESCE(c.support_dept_name, p.support_dept_name, b.support_dept_name, tx.support_dept_name, '') AS support_dept_name, + COALESCE(c.owner_department, '') AS pm_department, + COALESCE(p.project_type, b.project_type, '') AS project_type, + COALESCE(p.completion_status, b.completion_status, '') AS completion_status, + COALESCE(p.project_start_date, b.project_start_date, '') AS project_start_date, + COALESCE(p.project_end_date, b.project_end_date, '') AS project_end_date, + COALESCE(p.contract_amount, b.contract_amount, c.hanmac_contract_amount, 0) AS status_contract_amount, + COALESCE(c.hanmac_contract_amount, 0) AS hanmac_contract_amount, + COALESCE(tx.first_posting_date, '') AS first_posting_date, + COALESCE(bill.first_billing_date, '') AS first_billing_date, + COALESCE(chg.first_change_date, '') AS first_change_date + FROM code_universe AS u + LEFT JOIN project_contract_info AS c ON c.support_dept_code = u.support_dept_code + LEFT JOIN project_status AS p ON p.support_dept_code = u.support_dept_code + LEFT JOIN project_basic_info AS b ON b.support_dept_code = u.support_dept_code + LEFT JOIN ( + SELECT + support_dept_code, + MAX(COALESCE(support_dept_name, '')) AS support_dept_name, + MIN(NULLIF(posting_date, '')) AS first_posting_date + FROM transactions + GROUP BY support_dept_code + ) AS tx ON tx.support_dept_code = u.support_dept_code + LEFT JOIN ( + SELECT support_dept_code, MIN(NULLIF(COALESCE(tax_invoice_date, billing_date), '')) AS first_billing_date + FROM project_billing_entries + GROUP BY support_dept_code + ) AS bill ON bill.support_dept_code = u.support_dept_code + LEFT JOIN ( + SELECT support_dept_code, MIN(NULLIF(change_date, '')) AS first_change_date + FROM project_contract_change_round + GROUP BY support_dept_code + ) AS chg ON chg.support_dept_code = u.support_dept_code + """ + ) + ).mappings().all() + result: dict[str, dict[str, Any]] = {} + for row in rows: + code = normalize_text(row.get("support_dept_code")) + if not code: + continue + billing_row = billing_summary.get(code, {}) + title_key = title_by_code.get(code) or normalize_project_title_for_linking(row.get("support_dept_name")) or normalize_project_title_for_linking(billing_row.get("support_dept_name")) + latest_summary_change = latest_summary_by_title.get(title_key) or {} + latest_round_change = latest_round_by_code.get(code) or {} + representative_code = representative_by_title.get(title_key, "") + latest_round_amount = normalize_amount(latest_round_change.get("changed_contract_amount")) + latest_summary_amount = normalize_amount(latest_summary_change.get("changed_contract_amount")) + contract_amount = ( + normalize_amount(row.get("status_contract_amount")) + or normalize_amount(billing_row.get("contract_amount")) + or normalize_amount(row.get("hanmac_contract_amount")) + or latest_round_amount + or (latest_summary_amount if (not representative_code or representative_code == code) else 0) + ) + if latest_round_amount and latest_round_amount > contract_amount: + contract_amount = latest_round_amount + if latest_summary_amount and (not representative_code or representative_code == code) and latest_summary_amount > contract_amount: + 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")) + 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, + "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")), + "project_start_date": explicit_start_date or fallback_start_date, + "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, + "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 + + +def _cost_analysis_get_x_links() -> dict[str, list[str]]: + with engine.begin() as conn: + rows = conn.execute( + text( + """ + SELECT base_support_dept_code, related_support_dept_code + FROM project_related_links + WHERE COALESCE(base_support_dept_code, '') <> '' + AND COALESCE(related_support_dept_code, '') <> '' + """ + ) + ).mappings().all() + result: dict[str, list[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 base.startswith("X") and not related.startswith("X"): + result.setdefault(related, []).append(base) + if related.startswith("X") and not base.startswith("X"): + result.setdefault(base, []).append(related) + return {code: sorted(set(values)) for code, values in result.items()} + + +def _cost_analysis_get_xyz_code_map() -> dict[str, str]: + with engine.begin() as conn: + rows = conn.execute( + text( + """ + SELECT base_support_dept_code, related_support_dept_code + FROM project_related_links + WHERE COALESCE(base_support_dept_code, '') <> '' + AND COALESCE(related_support_dept_code, '') <> '' + """ + ) + ).mappings().all() + candidates: 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() + for source, target in ((base, related), (related, base)): + if not source or source[:1] not in {"0", "9"}: + continue + if target[:1] in {"X", "Y", "Z"}: + candidates.setdefault(source, set()).add(target) + + def representative_rank(code: str) -> tuple[int, int, str]: + prefix = code[:1] + if prefix in {"Y", "Z"}: + priority = 0 + elif prefix == "X": + priority = 1 + else: + priority = 2 + return (priority, -(_extract_year_from_project_code(code) or 0), code) + + return { + source: sorted(targets, key=representative_rank)[0] + for source, targets in candidates.items() + if targets + } + + +def _cost_analysis_get_completion_billing_dates() -> dict[str, str]: + result: dict[str, str] = {} + with engine.begin() as conn: + rows = conn.execute( + text( + """ + SELECT support_dept_code, progress_type, billing_type, billing_date + FROM project_collection_entries + WHERE COALESCE(support_dept_code, '') <> '' + UNION ALL + SELECT support_dept_code, '' AS progress_type, billing_type, billing_date + FROM project_billing_entries + WHERE COALESCE(support_dept_code, '') <> '' + """ + ) + ).mappings().all() + for row in rows: + code = normalize_text(row.get("support_dept_code")) + if not code: + continue + progress_type = normalize_collection_progress_type(row.get("progress_type")) or normalize_collection_progress_type(row.get("billing_type")) + billing_type = normalize_collection_billing_type(row.get("billing_type")) + if progress_type != "준공금" and billing_type != "준공금": + continue + billing_date = _date_text(row.get("billing_date")) + if not billing_date: + continue + if code not in result or billing_date < result[code]: + result[code] = billing_date + return result + + +def _clear_cost_analysis_payload_caches() -> None: + with _COST_ANALYSIS_PAYLOAD_CACHE_LOCK: + _COST_ANALYSIS_PAYLOAD_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'")) + + +def _cost_analysis_hanmac_cache_version() -> str: + try: + with engine.begin() as conn: + row = conn.execute( + text( + """ + SELECT COUNT(*) AS row_count, + COALESCE(MAX(updated_at), '') AS updated_at, + COALESCE(MAX(rowid), 0) AS max_rowid + FROM hanmac_aggregate_query_metrics + WHERE view_mode = 'member' + """ + ) + ).mappings().first() + except Exception: + return "" + if not row: + return "" + raw = "|".join([str(row.get("row_count") or 0), str(row.get("updated_at") or ""), str(row.get("max_rowid") or 0)]) + return hashlib.sha1(raw.encode("utf-8")).hexdigest() + + +def _cost_analysis_metric_has_member_grade(cache_key: Any) -> bool: + normalized_key = normalize_text(cache_key) + if not normalized_key: + return False + with engine.begin() as conn: + row_json = conn.execute( + text( + """ + SELECT row_json + FROM hanmac_aggregate_query_rows + WHERE cache_key = :cache_key + AND row_json LIKE '%member_grade%' + LIMIT 1 + """ + ), + {"cache_key": normalized_key}, + ).scalar() + if not row_json: + return False + try: + row = json.loads(str(row_json or "{}")) + except Exception: + return False + return bool(_normalize_labor_grade_name(row.get("member_grade") or row.get("grade") or row.get("position") or row.get("rank"))) + + +def _cost_analysis_select_hanmac_member_metric( + start_date: date, + end_date: date, + prefer_member_grade: bool = False, +) -> dict[str, Any] | None: + with engine.begin() as conn: + candidates = conn.execute( + text( + """ + SELECT cache_key, start_date, end_date, updated_at + FROM hanmac_aggregate_query_metrics + WHERE view_mode = 'member' + AND COALESCE(start_date, '') <= :start_date + AND COALESCE(end_date, '') >= :end_date + ORDER BY + 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()}, + ).mappings().all() + if not candidates: + candidates = conn.execute( + text( + """ + SELECT cache_key, start_date, end_date, updated_at + FROM hanmac_aggregate_query_metrics + WHERE view_mode = 'member' + AND COALESCE(start_date, '') <= :end_date + AND COALESCE(end_date, '') >= :start_date + ORDER BY + CASE + WHEN COALESCE(start_date, '') <= :start_date + AND COALESCE(end_date, '') >= :end_date + THEN 0 ELSE 1 + END, + start_date ASC, + updated_at DESC + LIMIT 10 + """ + ), + {"start_date": start_date.isoformat(), "end_date": end_date.isoformat()}, + ).mappings().all() + if not candidates: + return None + candidate_dicts = [dict(candidate) for candidate in candidates] + if prefer_member_grade: + for candidate in candidate_dicts: + if _cost_analysis_metric_has_member_grade(candidate.get("cache_key")): + return candidate + return candidate_dicts[0] + + +@lru_cache(maxsize=8) +def _cost_analysis_load_hanmac_member_rows_for_cache(cache_key: str) -> tuple[dict[str, Any], ...]: + normalized_key = normalize_text(cache_key) + if not normalized_key: + return tuple() + with engine.begin() as conn: + row_items = conn.execute( + text( + """ + SELECT row_json + FROM hanmac_aggregate_query_rows + WHERE cache_key = :cache_key + ORDER BY row_index + """ + ), + {"cache_key": normalized_key}, + ).scalars().all() + rows: list[dict[str, Any]] = [] + for item in row_items: + try: + row = json.loads(str(item or "{}")) + except Exception: + continue + if isinstance(row, dict): + rows.append(row) + return tuple(rows) + + +def _cost_analysis_load_hanmac_member_rows( + start_date: date, + end_date: date, + prefer_member_grade: bool = False, +) -> tuple[dict[str, Any] | None, tuple[dict[str, Any], ...]]: + metric = _cost_analysis_select_hanmac_member_metric(start_date, end_date, prefer_member_grade) + if not metric: + return None, tuple() + return metric, _cost_analysis_load_hanmac_member_rows_for_cache(normalize_text(metric.get("cache_key"))) + + +def _cost_analysis_metric_covers_range(metric: dict[str, Any] | None, start_date: date, end_date: date) -> bool: + if not metric: + return False + metric_start = _parse_iso_date(metric.get("start_date")) + metric_end = _parse_iso_date(metric.get("end_date")) + return bool(metric_start and metric_end and metric_start <= start_date and metric_end >= end_date) + + +def _cost_analysis_select_hanmac_prefix_metric( + start_date: date, + end_date: date, + prefer_member_grade: bool = False, +) -> dict[str, Any] | None: + with engine.begin() as conn: + candidates = conn.execute( + text( + """ + SELECT cache_key, start_date, end_date, updated_at + FROM hanmac_aggregate_query_metrics + WHERE view_mode = 'member' + 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 + LIMIT 10 + """ + ), + {"start_date": start_date.isoformat(), "end_date": end_date.isoformat()}, + ).mappings().all() + candidate_dicts = [dict(candidate) for candidate in candidates] + if prefer_member_grade: + for candidate in candidate_dicts: + if _cost_analysis_metric_has_member_grade(candidate.get("cache_key")): + return candidate + return candidate_dicts[0] if candidate_dicts else None + + +def _cost_analysis_load_hanmac_member_row_items( + start_date: date, + end_date: date, + prefer_member_grade: bool = False, +) -> tuple[dict[str, Any] | None, list[str]]: + metric = _cost_analysis_select_hanmac_member_metric(start_date, end_date, prefer_member_grade) + if not metric: + return None, [] + with engine.begin() as conn: + row_items = conn.execute( + text( + """ + SELECT row_json + FROM hanmac_aggregate_query_rows + WHERE cache_key = :cache_key + ORDER BY row_index + """ + ), + {"cache_key": metric["cache_key"]}, + ).scalars().all() + return metric, list(row_items) + + +def _cost_analysis_load_hanmac_labor_map( + start_date: date, + end_date: date, + project_meta: dict[str, dict[str, Any]], + allowed_codes: set[str] | None = None, +) -> dict[str, dict[str, float]]: + by_year = _cost_analysis_load_hanmac_labor_map_by_year(start_date, end_date, project_meta, allowed_codes) + result: dict[str, dict[str, float]] = {} + for code_map in by_year.values(): + for code, phase_amounts in code_map.items(): + target = result.setdefault(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) + return result + + +def _cost_analysis_load_hanmac_labor_map_by_year( + start_date: date, + end_date: date, + project_meta: dict[str, dict[str, Any]], + allowed_codes: set[str] | None = None, +) -> dict[int, dict[str, dict[str, 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 {} + + 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)) + result: dict[int, dict[str, dict[str, float]]] = {} + completion_dates = _cost_analysis_get_completion_billing_dates() + resolve_cache: dict[tuple[str, str, str], list[str]] = {} + + def add_amount(project: dict[str, Any], work_date_text: Any, member_grade: str, hours: float) -> None: + if hours <= 0: + return + work_date = _parse_iso_date(work_date_text) + if work_date and (work_date < start_date or work_date > end_date): + 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 or start_date).isoformat()}", + ) + if resolve_key in resolve_cache: + codes = resolve_cache[resolve_key] + else: + 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: + return + year_text = str((work_date or start_date).year) + split_hours = hours / len(codes) + for code in codes: + normalized_code = normalize_text(code).upper() + if allowed_codes is not None and normalized_code not in allowed_codes: + continue + rate = _resolve_labor_rate( + rates_by_year, + member_grade, + year_text, + year_text, + (project_meta.get(normalized_code) or {}).get("project_type"), + ) + if rate <= 0: + continue + phase = _cost_analysis_phase_for_transaction( + normalized_code, + (work_date or start_date).isoformat(), + completion_dates, + ) + result.setdefault((work_date or start_date).year, {}).setdefault( + normalized_code, + {"pre": 0.0, "during": 0.0, "post": 0.0}, + )[phase] += rate * split_hours + + 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 + 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 [] + if not projects: + continue + 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")) + hours = recognized_total * raw_hours / raw_total if raw_total > 0 and recognized_total > 0 else raw_hours + add_amount(project, detail.get("work_date"), member_grade, hours) + for detail in details.get("overtime_hours") or []: + add_amount(detail, detail.get("work_date"), member_grade, normalize_amount(detail.get("overtime_hours"))) + for detail in details.get("holiday_hours") or []: + projects = detail.get("projects") if isinstance(detail.get("projects"), list) else [] + if projects: + raw_total = sum(normalize_amount(project.get("hours")) for project in projects) + recognized_total = normalize_amount(detail.get("holiday_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_amount(project, detail.get("work_date"), member_grade, hours) + else: + add_amount(detail, detail.get("work_date"), member_grade, normalize_amount(detail.get("holiday_hours"))) + return result + + +def _cost_analysis_load_hanmac_labor_map_yearly( + start_date: date, + end_date: date, + project_meta: dict[str, dict[str, Any]], + allowed_codes: set[str] | None = None, +) -> dict[str, dict[str, float]]: + result: dict[str, dict[str, float]] = {} + 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")) + if prefix_metric and prefix_end and prefix_end >= start_date: + by_year = _cost_analysis_load_hanmac_labor_map_by_year(start_date, min(prefix_end, end_date), project_meta, allowed_codes) + for slice_map in by_year.values(): + for code, phase_amounts in slice_map.items(): + target = result.setdefault(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) + start_date = min(prefix_end, end_date) + timedelta(days=1) + if start_date > end_date: + return result + for year_slice in _iter_year_slices(start_date, end_date): + slice_map = _cost_analysis_load_hanmac_labor_map( + year_slice["start"], + year_slice["end"], + project_meta, + allowed_codes, + ) + for code, phase_amounts in slice_map.items(): + target = result.setdefault(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) + return result + + +def _cost_analysis_load_hanmac_labor_detail_rows( + start_date: date, + end_date: date, + requested_codes: list[str], + requested_phase: str, + 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 [] + + alias_to_code, title_to_codes = _cost_analysis_build_hanmac_matchers(project_meta) + metric, row_items = _cost_analysis_load_hanmac_member_row_items(start_date, end_date, prefer_member_grade=True) + if not metric: + return [] + + 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)) + completion_dates = _cost_analysis_get_completion_billing_dates() + normalized_phase = normalize_text(requested_phase).lower() + detail_by_member: dict[tuple[str, str, str], dict[str, Any]] = {} + grade_order = { + grade: index + for index, grade in enumerate(("회장", "부회장", "사장", "부사장", "전무", "상무", "이사", "부장", "차장", "과장", "대리", "사원")) + } + + def add_hours( + source_row: dict[str, Any], + project: dict[str, Any], + work_date_text: Any, + member_grade: str, + hours: float, + hour_kind: str, + ) -> None: + if hours <= 0: + return + work_date = _parse_iso_date(work_date_text) + if work_date and (work_date < start_date or work_date > end_date): + return + codes = _cost_analysis_resolve_hanmac_project_codes(project, work_date, alias_to_code, title_to_codes, project_meta) + if not codes: + return + year_text = str((work_date or start_date).year) + split_hours = hours / len(codes) + for code in codes: + normalized_code = normalize_text(code).upper() + if normalized_code not in normalized_requested_codes: + continue + phase = _cost_analysis_phase_for_transaction( + normalized_code, + (work_date or start_date).isoformat(), + completion_dates, + ) + if normalized_phase and normalized_phase != "all" and phase != normalized_phase: + continue + rate = _resolve_labor_rate( + rates_by_year, + member_grade, + year_text, + year_text, + (project_meta.get(normalized_code) or {}).get("project_type"), + ) + if rate <= 0: + 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) + detail = detail_by_member.setdefault( + key, + { + "member_no": member_no, + "dept_name": normalize_text(source_row.get("dept_name")), + "member_name": member_name, + "member_grade": member_grade, + "regular_hours": 0.0, + "overtime_hours": 0.0, + "holiday_hours": 0.0, + "amount": 0.0, + }, + ) + detail[f"{hour_kind}_hours"] += split_hours + detail["amount"] += rate * split_hours + + for item in row_items: + try: + row = json.loads(str(item or "{}")) + except Exception: + continue + if not isinstance(row, dict): + continue + 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 + 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 [] + if not projects: + continue + 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")) + hours = recognized_total * raw_hours / raw_total if raw_total > 0 and recognized_total > 0 else raw_hours + add_hours(row, project, detail.get("work_date"), member_grade, hours, "regular") + for detail in details.get("overtime_hours") or []: + add_hours(row, detail, detail.get("work_date"), member_grade, normalize_amount(detail.get("overtime_hours")), "overtime") + for detail in details.get("holiday_hours") or []: + projects = detail.get("projects") if isinstance(detail.get("projects"), list) else [] + if projects: + raw_total = sum(normalize_amount(project.get("hours")) for project in projects) + recognized_total = normalize_amount(detail.get("holiday_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_hours(row, project, detail.get("work_date"), member_grade, hours, "holiday") + else: + add_hours(row, detail, detail.get("work_date"), member_grade, normalize_amount(detail.get("holiday_hours")), "holiday") + + rows = [] + for detail in detail_by_member.values(): + regular_hours = normalize_amount(detail.get("regular_hours")) + overtime_hours = normalize_amount(detail.get("overtime_hours")) + 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")))) + rows.append(detail) + return sorted( + rows, + key=lambda item: ( + normalize_text(item.get("dept_name")), + grade_order.get(normalize_text(item.get("member_grade")), len(grade_order)), + normalize_text(item.get("member_no")), + ), + ) + + +def _cost_analysis_load_hanmac_labor_detail_rows_yearly( + start_date: date, + end_date: date, + requested_codes: list[str], + requested_phase: str, + project_meta: dict[str, dict[str, Any]], +) -> list[dict[str, Any]]: + merged: dict[tuple[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"], + year_slice["end"], + requested_codes, + requested_phase, + project_meta, + ): + key = ( + normalize_text(row.get("member_no")), + normalize_text(row.get("member_name")), + normalize_text(row.get("member_grade")), + ) + detail = merged.setdefault( + key, + { + "member_no": normalize_text(row.get("member_no")), + "dept_name": normalize_text(row.get("dept_name")), + "member_name": normalize_text(row.get("member_name")), + "member_grade": normalize_text(row.get("member_grade")), + "regular_hours": 0.0, + "overtime_hours": 0.0, + "holiday_hours": 0.0, + "amount": 0.0, + }, + ) + for field in ("regular_hours", "overtime_hours", "holiday_hours", "amount"): + detail[field] += normalize_amount(row.get(field)) + rows = [] + for detail in merged.values(): + regular_hours = normalize_amount(detail.get("regular_hours")) + overtime_hours = normalize_amount(detail.get("overtime_hours")) + 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")))) + rows.append(detail) + return sorted( + rows, + key=lambda item: ( + normalize_text(item.get("dept_name")), + normalize_text(item.get("member_grade")), + normalize_text(item.get("member_no")), + ), + ) + + +def _cost_analysis_collect_missing_hanmac_grade_rows( + start_date: date, + end_date: date, + project_meta: dict[str, dict[str, Any]], + allowed_codes: set[str] | None = None, +) -> list[dict[str, Any]]: + alias_to_code, title_to_codes = _cost_analysis_build_hanmac_matchers(project_meta) + metric, row_items = _cost_analysis_load_hanmac_member_row_items(start_date, end_date, prefer_member_grade=True) + if not metric: + return [] + + completion_dates = _cost_analysis_get_completion_billing_dates() + result_rows: list[dict[str, Any]] = [] + metric_range = f"{metric['start_date']}~{metric['end_date']}" + + def add_missing_grade_row( + source_row: dict[str, Any], + project: dict[str, Any], + work_date_text: Any, + hours: float, + hour_kind: str, + ) -> None: + if hours <= 0: + return + work_date = _parse_iso_date(work_date_text) + if work_date and (work_date < start_date or work_date > end_date): + return + raw_grade = ( + source_row.get("member_grade") + or source_row.get("grade") + or source_row.get("position") + or source_row.get("rank") + ) + if _normalize_labor_grade_name(raw_grade): + return + codes = _cost_analysis_resolve_hanmac_project_codes(project, work_date, alias_to_code, title_to_codes, project_meta) + if not codes: + return + split_hours = hours / len(codes) + effective_date = work_date or start_date + for code in codes: + normalized_code = normalize_text(code).upper() + if normalized_code in COST_ANALYSIS_COMMON_CODES: + continue + 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) + result_rows.append( + { + "work_date": effective_date.isoformat(), + "phase": {"pre": "사업전", "during": "사업중", "post": "사업후"}.get(phase, phase), + "support_dept_code": normalized_code, + "project_name": normalize_text(meta.get("support_dept_name")) or normalized_code, + "member_no": normalize_text(source_row.get("member_no")), + "member_name": normalize_text(source_row.get("member_name")), + "dept_name": normalize_text(source_row.get("dept_name")), + "raw_grade": normalize_text(raw_grade), + "hour_kind": {"regular": "정규", "overtime": "연장", "holiday": "휴일"}.get(hour_kind, hour_kind), + "hours": round(split_hours, 4), + "source_project_code": normalize_text(project.get("project_code")), + "source_project_name": normalize_text(project.get("project_name")), + "metric_range": metric_range, + "metric_cache_key": normalize_text(metric.get("cache_key")), + } + ) + + for item in row_items: + try: + row = json.loads(str(item or "{}")) + except Exception: + continue + if not isinstance(row, dict): + continue + details = row.get("aggregate_details") if isinstance(row.get("aggregate_details"), dict) else {} + for detail in details.get("regular_hours") or []: + projects = detail.get("projects") if isinstance(detail.get("projects"), list) else [] + if not projects: + continue + 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")) + hours = recognized_total * raw_hours / raw_total if raw_total > 0 and recognized_total > 0 else raw_hours + add_missing_grade_row(row, project, detail.get("work_date"), hours, "regular") + for detail in details.get("overtime_hours") or []: + add_missing_grade_row(row, detail, detail.get("work_date"), normalize_amount(detail.get("overtime_hours")), "overtime") + for detail in details.get("holiday_hours") or []: + projects = detail.get("projects") if isinstance(detail.get("projects"), list) else [] + if projects: + raw_total = sum(normalize_amount(project.get("hours")) for project in projects) + recognized_total = normalize_amount(detail.get("holiday_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_missing_grade_row(row, project, detail.get("work_date"), hours, "holiday") + else: + add_missing_grade_row(row, detail, detail.get("work_date"), normalize_amount(detail.get("holiday_hours")), "holiday") + + return sorted( + result_rows, + key=lambda item: ( + normalize_text(item.get("support_dept_code")), + normalize_text(item.get("work_date")), + normalize_text(item.get("member_name")), + normalize_text(item.get("hour_kind")), + ), + ) + + +def _cost_analysis_collect_missing_hanmac_grade_rows_yearly( + start_date: date, + end_date: date, + project_meta: dict[str, dict[str, Any]], + allowed_codes: set[str] | None = None, +) -> list[dict[str, Any]]: + rows: list[dict[str, Any]] = [] + for year_slice in _iter_year_slices(start_date, end_date): + rows.extend( + _cost_analysis_collect_missing_hanmac_grade_rows( + year_slice["start"], + year_slice["end"], + project_meta, + allowed_codes, + ) + ) + return sorted( + rows, + key=lambda item: ( + normalize_text(item.get("support_dept_code")), + normalize_text(item.get("work_date")), + normalize_text(item.get("member_name")), + normalize_text(item.get("hour_kind")), + ), + ) + + +def _cost_analysis_build_hanmac_matchers(project_meta: dict[str, dict[str, Any]]) -> tuple[dict[str, str], dict[str, list[str]]]: + title_to_codes: dict[str, list[str]] = {} + alias_to_code: dict[str, str] = {} + xyz_code_map = _cost_analysis_get_xyz_code_map() + + def alias_rank(code: str) -> tuple[int, int, str]: + normalized_code = normalize_text(code).upper() + prefix = normalized_code[:1] + if prefix in {"Y", "Z"}: + priority = 0 + elif prefix == "X": + priority = 1 + elif prefix in {"0", "9"}: + priority = 3 + else: + priority = 2 + return (priority, -(_extract_year_from_project_code(normalized_code) or 0), normalized_code) + + def add_alias(alias: str, code: str) -> None: + normalized_alias = normalize_text(alias).upper() + normalized_code = normalize_text(code).upper() + if not normalized_alias or not normalized_code: + return + current = alias_to_code.get(normalized_alias) + if not current or alias_rank(normalized_code) < alias_rank(current): + alias_to_code[normalized_alias] = normalized_code + + for code, meta in project_meta.items(): + normalized_code = normalize_text(code).upper() + if not normalized_code or normalized_code in COST_ANALYSIS_COMMON_CODES: + continue + add_alias(normalized_code, normalized_code) + if len(normalized_code) > 1 and normalized_code[0] in {"X", "Y", "Z"}: + numeric_alias = normalized_code[1:] + if numeric_alias: + add_alias(numeric_alias, normalized_code) + add_alias(f"0{numeric_alias}", normalized_code) + title_key = normalize_project_title_for_linking(meta.get("support_dept_name")) + if title_key: + title_to_codes.setdefault(title_key, []).append(normalized_code) + for source_code, target_code in xyz_code_map.items(): + add_alias(source_code, target_code) + for title_key, codes in list(title_to_codes.items()): + title_to_codes[title_key] = sorted(set(codes)) + return alias_to_code, title_to_codes + + +def _cost_analysis_resolve_hanmac_project_codes( + project: dict[str, Any], + work_date: date | None, + alias_to_code: dict[str, str], + title_to_codes: dict[str, list[str]], + project_meta: dict[str, dict[str, Any]], +) -> list[str]: + title_key = normalize_project_title_for_linking(project.get("project_name")) + 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() + if alias in COST_ANALYSIS_COMMON_CODES: + return [] + if alias in alias_to_code: + resolved_code = alias_to_code[alias] + if resolved_code in COST_ANALYSIS_COMMON_CODES: + return [] + if not title_candidates or _cost_analysis_active_on_date(project_meta.get(resolved_code) or {}, work_date): + return [resolved_code] + break + candidates = title_candidates + if not candidates: + return [] + xyz_candidates = [ + code + for code in candidates + if normalize_text(code).upper().startswith(("X", "Y", "Z")) + ] + if xyz_candidates: + candidates = xyz_candidates + if not work_date: + return [candidates[-1]] + work_year = work_date.year + + def code_year(code: str) -> int | None: + return _extract_year_from_project_code(code) + + active_on_date_candidates = [ + code + for code in candidates + if _cost_analysis_active_on_date(project_meta.get(code) or {}, work_date) + ] + if active_on_date_candidates: + return [ + sorted( + active_on_date_candidates, + key=lambda code: ( + _parse_iso_date((project_meta.get(code) or {}).get("project_start_date")) or date.min, + code, + ), + )[-1] + ] + + year_window_candidates = [ + code + for code in candidates + if (code_year(code) is not None and code_year(code) <= work_year <= code_year(code) + 1) + ] + if year_window_candidates: + return year_window_candidates + + active_candidates = [ + code + for code in candidates + if _cost_analysis_active_in_year(project_meta.get(code) or {}, work_year) + ] + if active_candidates: + return active_candidates + + past_candidates = [code for code in candidates if (code_year(code) or 0) <= work_year] + return [past_candidates[-1] if past_candidates else candidates[-1]] + + +def _cost_analysis_load_hanmac_project_hours( + start_date: date, + end_date: date, + project_meta: dict[str, dict[str, Any]], + allowed_codes: set[str] | None = None, +) -> dict[str, dict[str, float]]: + result: dict[str, dict[str, float]] = {} + by_year = _cost_analysis_load_hanmac_project_hours_by_year(start_date, end_date, project_meta, allowed_codes) + for code_map in by_year.values(): + for code, phase_hours in code_map.items(): + target = result.setdefault(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) + return result + + +def _cost_analysis_load_hanmac_project_hours_by_year( + start_date: date, + end_date: date, + project_meta: dict[str, dict[str, Any]], + allowed_codes: set[str] | None = None, +) -> dict[int, dict[str, dict[str, 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 {} + + completion_dates = _cost_analysis_get_completion_billing_dates() + result: dict[int, dict[str, dict[str, float]]] = {} + resolve_cache: dict[tuple[str, str, str], list[str]] = {} + + def add_hours(project: dict[str, Any], work_date_text: Any, hours: float) -> None: + if hours <= 0: + return + work_date = _parse_iso_date(work_date_text) + if work_date and (work_date < start_date or work_date > end_date): + return + effective_date = work_date or start_date + 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'))}|{effective_date.isoformat()}", + ) + if resolve_key in resolve_cache: + codes = resolve_cache[resolve_key] + else: + codes = _cost_analysis_resolve_hanmac_project_codes(project, effective_date, alias_to_code, title_to_codes, project_meta) + resolve_cache[resolve_key] = codes + if not codes: + return + split_hours = hours / len(codes) + 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) + result.setdefault(effective_date.year, {}).setdefault( + normalized_code, + {"pre": 0.0, "during": 0.0, "post": 0.0}, + )[phase] += split_hours + + for row in row_items: + 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")) + hours = recognized_total * raw_hours / raw_total if raw_total > 0 and recognized_total > 0 else raw_hours + add_hours(project, detail.get("work_date"), hours) + for detail in details.get("overtime_hours") or []: + add_hours(detail, detail.get("work_date"), normalize_amount(detail.get("overtime_hours"))) + for detail in details.get("holiday_hours") or []: + projects = detail.get("projects") if isinstance(detail.get("projects"), list) else [] + if projects: + raw_total = sum(normalize_amount(project.get("hours")) for project in projects) + recognized_total = normalize_amount(detail.get("holiday_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_hours(project, detail.get("work_date"), hours) + else: + add_hours(detail, detail.get("work_date"), normalize_amount(detail.get("holiday_hours"))) + return result + + +def _cost_analysis_get_annual_hanmac_total_hours(year: int) -> float: + 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, summary_json + FROM hanmac_aggregate_query_metrics + WHERE view_mode = 'member' + AND COALESCE(start_date, '') <= :year_start + AND COALESCE(end_date, '') >= :year_end + ORDER BY + 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}, + ).mappings().first() + if not metric: + return 0.0 + try: + summary = json.loads(metric["summary_json"] or "{}") + except Exception: + summary = {} + total_hours = normalize_amount(summary.get("total_hours")) + if total_hours > 0: + return total_hours + rows = conn.execute( + text( + """ + SELECT row_json + FROM hanmac_aggregate_query_rows + WHERE cache_key = :cache_key + """ + ), + {"cache_key": metric["cache_key"]}, + ).scalars().all() + 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: + normalized_code = normalize_text(code).upper() + if normalized_code.startswith("X"): + return "pre" + if normalized_code.startswith(("Y", "Z")): + completion_date = completion_dates.get(normalized_code, "") + if completion_date and posting_date and posting_date >= completion_date: + return "post" + return "during" + 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 { + "support_dept_code": code, + "pm_department": normalize_text(meta.get("pm_department")), + "year": selected_year or "", + "project_name": normalize_text(meta.get("support_dept_name")) or code, + "project_type": _cost_analysis_project_type(code, meta.get("project_type")), + "completion_status": normalize_text(meta.get("completion_status")), + "project_start_date": normalize_text(meta.get("project_start_date")), + "project_end_date": normalize_text(meta.get("project_end_date")), + "contract_amount": contract_amount, + "billing_amount": 0.0, + "collection_amount": 0.0, + "contract_balance_amount": contract_amount, + "collection_rate": 0.0, + "revenue_amount": 0.0, + "phases": _cost_analysis_empty_phase_totals(), + "allocated": _cost_analysis_empty_phase_totals(), + "direct_codes": [code], + "pre_codes": [], + "profit_amount": 0.0, + "contract_profit_rate": 0.0, + "revenue_profit_rate": 0.0, + "collection_profit_rate": 0.0, + "total_cost": 0.0, + "cost_total": 0.0, + "sga_total": 0.0, + "sales_total": 0.0, + } + + +def _cost_analysis_finalize_row(row: dict[str, Any]) -> None: + cost_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"}: + cost_total += amount + elif item_key == "sales": + sales_total += amount + else: + sga_total += amount + row["cost_total"] = cost_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["contract_balance_amount"] = max( + normalize_amount(row.get("contract_amount")) - normalize_amount(row.get("collection_amount")), + 0.0, + ) + row["collection_rate"] = _safe_ratio(row.get("collection_amount"), row.get("contract_amount")) + row["contract_profit_rate"] = _safe_ratio(row.get("profit_amount"), row.get("contract_amount")) + row["revenue_profit_rate"] = _safe_ratio(row.get("profit_amount"), row.get("revenue_amount")) + row["collection_profit_rate"] = _safe_ratio(row.get("profit_amount"), row.get("collection_amount")) + + +def _cost_analysis_active_in_year(meta: dict[str, Any], year: int) -> bool: + start_date = _parse_iso_date(meta.get("project_start_date")) + end_date = _parse_iso_date(meta.get("project_end_date")) + if start_date and start_date.year > year: + return False + if end_date and end_date.year < year: + return False + return True + + +def _cost_analysis_active_on_date(meta: dict[str, Any], work_date: date | None) -> bool: + if not work_date: + return True + start_date = _parse_iso_date(meta.get("project_start_date")) + end_date = _parse_iso_date(meta.get("project_end_date")) + if start_date and start_date > work_date: + return False + if end_date and end_date < work_date: + return False + return True + + +def _cost_analysis_project_group_key(code: str, row: dict[str, Any]) -> str: + normalized_code = normalize_text(code).upper() + project_name_key = normalize_project_title_for_linking(row.get("project_name")) + if project_name_key: + return project_name_key + return normalized_code + + +def _cost_analysis_get_link_representative_map() -> dict[str, str]: + with engine.begin() as conn: + rows = conn.execute( + text( + """ + SELECT base_support_dept_code, related_support_dept_code + FROM project_related_links + WHERE COALESCE(base_support_dept_code, '') <> '' + AND COALESCE(related_support_dept_code, '') <> '' + """ + ) + ).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: + continue + graph.setdefault(base, set()).add(related) + graph.setdefault(related, set()).add(base) + representative_map: dict[str, str] = {} + seen: set[str] = set() + for start_code in sorted(graph): + if start_code in seen: + continue + stack = [start_code] + component: set[str] = set() + while stack: + code = stack.pop() + if code in component: + continue + component.add(code) + stack.extend(sorted(graph.get(code, set()) - component)) + seen.update(component) + 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 + return representative_map + + +def _cost_analysis_latest_row(rows: list[dict[str, Any]]) -> dict[str, Any]: + def sort_key(row: dict[str, Any]) -> tuple[str, str]: + code = normalize_text(row.get("support_dept_code")).upper() + return (normalize_text(row.get("project_end_date")), code) + + yz_rows = [row for row in rows if normalize_text(row.get("support_dept_code")).upper().startswith(("Y", "Z"))] + return sorted(yz_rows or rows, key=sort_key)[-1] + + +def _cost_analysis_linked_group_key(row: dict[str, Any], representative_map: dict[str, str]) -> str: + 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 [])], + ] + for code in codes: + representative = representative_map.get(code) + if representative: + return representative + return _cost_analysis_project_group_key(row.get("support_dept_code", ""), row) + + +def _cost_analysis_aggregate_rows( + rows: list[dict[str, Any]], + project_meta: dict[str, dict[str, Any]], + representative_map: dict[str, str] | None = None, +) -> list[dict[str, Any]]: + representative_map = representative_map or _cost_analysis_get_link_representative_map() + + grouped: dict[str, list[dict[str, Any]]] = {} + for row in rows: + grouped.setdefault(_cost_analysis_linked_group_key(row, representative_map), []).append(row) + + result: list[dict[str, Any]] = [] + for group_key, group_rows in grouped.items(): + representative_code = normalize_text(group_key).upper() + representative_meta = project_meta.get(representative_code) if representative_code[:1] in {"0", "9"} else None + latest = _cost_analysis_latest_row(group_rows) + aggregate = copy.deepcopy(latest) + aggregate["view_mode"] = "aggregate" + aggregate["aggregate_project_count"] = len(group_rows) + 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( + [ + { + "support_dept_code": normalize_text(row.get("support_dept_code")), + "project_name": normalize_text(row.get("project_name")), + "project_start_date": normalize_text(row.get("project_start_date")), + "project_end_date": normalize_text(row.get("project_end_date")), + "contract_amount": normalize_amount(row.get("contract_amount")), + "billing_amount": normalize_amount(row.get("billing_amount")), + "collection_amount": normalize_amount(row.get("collection_amount")), + "total_cost": normalize_amount(row.get("total_cost")), + } + for row in group_rows + ], + key=lambda item: ( + normalize_text(item.get("project_start_date")) or "9999-12-31", + normalize_text(item.get("project_end_date")) or "9999-12-31", + normalize_text(item.get("support_dept_code")), + ), + ) + if representative_meta: + aggregate["support_dept_code"] = representative_code + aggregate["project_name"] = normalize_text(representative_meta.get("support_dept_name")) or representative_code + 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")) + aggregate["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) + }) + 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) + aggregate["project_start_date"] = aggregate["project_start_date"].isoformat() if aggregate["project_start_date"] else "" + latest_status = normalize_text(latest.get("completion_status")) + if "진행" in latest_status: + aggregate["project_end_date"] = "" + else: + latest_end = max([value for value in end_dates if value], default=None) + aggregate["project_end_date"] = latest_end.isoformat() if latest_end else "" + aggregate["contract_amount"] = sum(normalize_amount(row.get("contract_amount")) for row in group_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["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() + aggregate["allocated"] = _cost_analysis_empty_phase_totals() + for row in group_rows: + for phase, buckets in (row.get("phases") or {}).items(): + if phase not in aggregate["phases"]: + continue + for item_key, amount in buckets.items(): + if item_key in aggregate["phases"][phase]: + aggregate["phases"][phase][item_key] += normalize_amount(amount) + for phase, buckets in (row.get("allocated") or {}).items(): + if phase not in aggregate["allocated"]: + continue + for item_key, amount in buckets.items(): + if item_key in aggregate["allocated"][phase]: + aggregate["allocated"][phase][item_key] += normalize_amount(amount) + _cost_analysis_finalize_row(aggregate) + result.append(aggregate) + return result + + +def _cost_analysis_build_payload(start_date_text: str, end_date_text: str, mode: str = "individual") -> 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(), + ) + cached_payload = _get_deepcopy_ttl_cache_entry( + _COST_ANALYSIS_PAYLOAD_CACHE, + _COST_ANALYSIS_PAYLOAD_CACHE_LOCK, + cache_key, + COST_ANALYSIS_PAYLOAD_CACHE_TTL_SECONDS, + ) + if cached_payload is not None: + 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_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, + ) + 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) + + with engine.begin() as conn: + period_code_rows = conn.execute( + text( + f""" + SELECT DISTINCT support_dept_code + FROM transactions + WHERE {COST_ANALYSIS_TX_DATE_SQL} >= :start_date + AND {COST_ANALYSIS_TX_DATE_SQL} <= :end_date + AND (account_code LIKE '4%' OR account_code LIKE '5%' OR account_code LIKE '6%') + AND COALESCE(support_dept_code, '') <> '' + UNION + SELECT DISTINCT support_dept_code + FROM project_collection_entries + WHERE COALESCE(date, '') >= :start_date + AND COALESCE(date, '') <= :end_date + AND COALESCE(support_dept_code, '') <> '' + 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 + AND COALESCE(support_dept_code, '') <> '' + """ + ), + {"start_date": start_date.isoformat(), "end_date": end_date.isoformat()}, + ).mappings().all() + + visible_candidate_codes: set[str] = set() + period_source_codes = {normalize_text(row.get("support_dept_code")).upper() for row in period_code_rows} + 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))) + + period_hanmac_hours = _cost_analysis_load_hanmac_project_hours(start_date, end_date, project_meta) + visible_candidate_codes.update(period_hanmac_hours.keys()) + + 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) + + hanmac_labor_map = _cost_analysis_load_hanmac_labor_map_yearly( + start_date, + end_date, + project_meta, + visible_candidate_codes or None, + ) + + def ensure_row(code: str) -> dict[str, Any]: + normalized_code = normalize_text(code).upper() + if normalized_code in COST_ANALYSIS_COMMON_CODES: + raise ValueError("공통 코드는 프로젝트 행으로 생성할 수 없습니다.") + 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, [])] + return rows_by_code[normalized_code] + + with engine.begin() as conn: + if source_candidate_codes: + candidate_in_clause, candidate_params = build_in_clause("cost_analysis_source_code", sorted(source_candidate_codes)) + tx_code_filter = f"AND (UPPER(COALESCE(support_dept_code, '')) IN ({candidate_in_clause}) OR UPPER(COALESCE(support_dept_code, '')) IN ('', 'ZZZZZZ'))" + else: + candidate_params = {} + tx_code_filter = "" + tx_rows = conn.execute( + text( + f""" + SELECT + COALESCE(voucher_number, '') AS voucher_number, + {COST_ANALYSIS_TX_DATE_SQL} AS posting_date, + COALESCE(account_code, '') AS account_code, + COALESCE(account_name, '') AS account_name, + 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(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 '4%' OR account_code LIKE '5%' OR account_code LIKE '6%') + {tx_code_filter} + """ + ), + {**candidate_params, "start_date": start_date.isoformat(), "end_date": end_date.isoformat()}, + ).mappings().all() + collection_rows = conn.execute( + text( + """ + SELECT + support_dept_code, + SUM(COALESCE(amount, 0)) AS collection_amount, + SUM( + CASE + WHEN COALESCE(date, '') >= :start_date + AND COALESCE(date, '') <= :end_date + THEN COALESCE(amount, 0) + ELSE 0 + END + ) AS period_collection_amount + FROM project_collection_entries + WHERE COALESCE(date, '') <= :end_date + GROUP BY support_dept_code + """ + ), + {"start_date": start_date.isoformat(), "end_date": end_date.isoformat()}, + ).mappings().all() + billing_collection_rows = conn.execute( + text( + """ + SELECT + support_dept_code, + SUM(COALESCE(collected_amount, 0)) AS collection_amount, + SUM( + CASE + WHEN COALESCE(COALESCE(tax_invoice_date, billing_date), '') >= :start_date + AND COALESCE(COALESCE(tax_invoice_date, billing_date), '') <= :end_date + THEN COALESCE(collected_amount, 0) + ELSE 0 + END + ) AS period_collection_amount + FROM project_billing_entries + WHERE COALESCE(COALESCE(tax_invoice_date, billing_date), '') <= :end_date + GROUP BY support_dept_code + """ + ), + {"start_date": start_date.isoformat(), "end_date": end_date.isoformat()}, + ).mappings().all() + billing_amount_rows = conn.execute( + text( + """ + SELECT + support_dept_code, + 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 + 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 + GROUP BY support_dept_code + """ + ), + {"start_date": start_date.isoformat(), "end_date": end_date.isoformat()}, + ).mappings().all() + annual_sga_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 + 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%' + ) + ) + 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() + + visible_activity_codes: set[str] = set() + common_rows: list[dict[str, Any]] = [] + for raw_row in tx_rows: + row = dict(raw_row) + code = normalize_text(row.get("support_dept_code")).upper() + bucket = _cost_analysis_financial_bucket(row.get("account_code")) + if code in COST_ANALYSIS_COMMON_CODES: + if bucket in {"sga", "cost"}: + common_rows.append(row) + continue + target_code = x_owner_map.get(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) + 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 + 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) + 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: + 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) + for phase, amount in phase_amounts.items(): + if phase in report_row["phases"] and amount: + report_row["phases"][phase]["labor"] += amount + + 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: + 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")) + 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: + 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 + if normalize_amount(collection_row.get("period_collection_amount")): + visible_activity_codes.add(target_code) + + for billing_row in billing_amount_rows: + code = normalize_text(billing_row.get("support_dept_code")).upper() + if not code or code in COST_ANALYSIS_COMMON_CODES: + continue + amount = normalize_amount(billing_row.get("billing_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["billing_amount"] += amount + if normalize_amount(billing_row.get("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")} + annual_hanmac_project_hours_by_year: dict[int, dict[str, dict[str, float]]] = {} + annual_prefix_metric = _cost_analysis_select_hanmac_prefix_metric(start_date, end_date) + annual_prefix_end = _parse_iso_date((annual_prefix_metric or {}).get("end_date")) + annual_loop_start = start_date + if annual_prefix_metric and annual_prefix_end and annual_prefix_end >= start_date: + annual_hanmac_project_hours_by_year.update(_cost_analysis_load_hanmac_project_hours_by_year( + start_date, + min(annual_prefix_end, end_date), + project_meta, + visible_candidate_codes or None, + )) + annual_loop_start = min(annual_prefix_end, end_date) + timedelta(days=1) + if annual_loop_start <= end_date: + for year_slice in _iter_year_slices(annual_loop_start, end_date): + annual_hanmac_project_hours_by_year.update(_cost_analysis_load_hanmac_project_hours_by_year( + year_slice["start"], + year_slice["end"], + project_meta, + visible_candidate_codes or None, + )) + 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) + 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: + 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) + 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 + + all_finalized_rows = [] + period_finalized_rows = [] + for code, row in rows_by_code.items(): + _cost_analysis_finalize_row(row) + all_finalized_rows.append(row) + has_direct = code in visible_activity_codes + 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 + 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.sort(key=lambda item: (normalize_text(item.get("pm_department")), normalize_text(item.get("project_type")), normalize_text(item.get("project_name")))) + 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), + "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), + "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), + "project_count": len(final_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"]) + payload = { + "start_date": start_date.isoformat(), + "end_date": end_date.isoformat(), + "mode": "aggregate" if linked_mode else "individual", + "rows": final_rows, + "summary": summary, + } + _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], + }, + payload=payload, + row_count=len(final_rows), + signature=str(cache_key[3]), + ) + return _set_deepcopy_ttl_cache_entry( + _COST_ANALYSIS_PAYLOAD_CACHE, + _COST_ANALYSIS_PAYLOAD_CACHE_LOCK, + cache_key, + payload, + ) + + +def render_cost_analysis_page(request: Request, message: str = "") -> HTMLResponse: + init_db() + today = date.today() + context = { + **base_context(request, message), + "default_start_date": date(today.year, 1, 1).isoformat(), + "default_end_date": today.isoformat(), + } + return templates.TemplateResponse(request, "cost_analysis.html", context) + + def render_annual_summary_page(request: Request, message: str = "") -> HTMLResponse: init_db() context = { @@ -8885,22 +13486,35 @@ def render_annual_summary_page(request: Request, message: str = "") -> HTMLRespo def get_annual_summary_bootstrap_payload() -> dict[str, Any]: - now = time.time() - with _ANNUAL_SUMMARY_BOOTSTRAP_CACHE_LOCK: - cached_payload = _ANNUAL_SUMMARY_BOOTSTRAP_CACHE.get("payload") - cached_at = float(_ANNUAL_SUMMARY_BOOTSTRAP_CACHE.get("stored_at") or 0.0) - if cached_payload and now - cached_at <= ANNUAL_SUMMARY_BOOTSTRAP_CACHE_TTL_SECONDS: - return copy.deepcopy(cached_payload) + cached = _get_deepcopy_ttl_cache_entry( + _ANNUAL_SUMMARY_BOOTSTRAP_CACHE, + _ANNUAL_SUMMARY_BOOTSTRAP_CACHE_LOCK, + ("annual-summary",), + ANNUAL_SUMMARY_BOOTSTRAP_CACHE_TTL_SECONDS, + ) + if cached is not None: + return cached + + persistent_cache_key = _json_hash({"scope": "annual-summary"}) + persistent = _load_system_page_cache("annual_summary_bootstrap", persistent_cache_key) + if persistent is not None: + return _set_deepcopy_ttl_cache_entry( + _ANNUAL_SUMMARY_BOOTSTRAP_CACHE, + _ANNUAL_SUMMARY_BOOTSTRAP_CACHE_LOCK, + ("annual-summary",), + persistent, + ) payload = { "yearly_financial_series": get_financial_series("yearly"), "monthly_financial_series": get_financial_series("monthly"), } - - with _ANNUAL_SUMMARY_BOOTSTRAP_CACHE_LOCK: - _ANNUAL_SUMMARY_BOOTSTRAP_CACHE["stored_at"] = now - _ANNUAL_SUMMARY_BOOTSTRAP_CACHE["payload"] = copy.deepcopy(payload) - return payload + return _set_deepcopy_ttl_cache_entry( + _ANNUAL_SUMMARY_BOOTSTRAP_CACHE, + _ANNUAL_SUMMARY_BOOTSTRAP_CACHE_LOCK, + ("annual-summary",), + payload, + ) def render_wehago_compare_page( @@ -8916,7 +13530,7 @@ def render_wehago_compare_page( engine, start_year=start_year, end_year=end_year, - include_metric_counts=False, + include_metric_counts=True, warm_caches=False, ), } @@ -8999,9 +13613,16 @@ def _validate_hanmac_table_name(table_name: Any) -> str: return normalized +HANMAC_EXTERNAL_SCHEMAS = ("hanmac", "hanmac_manhour", "baron_manhour") +HANMAC_PRIMARY_MANHOUR_SCHEMA = "hanmac_manhour" +HANMAC_CENTER_MANHOUR_SCHEMA = "baron_manhour" +HANMAC_EXTERNAL_SCHEMA_SQL = ", ".join(f"'{schema}'" for schema in HANMAC_EXTERNAL_SCHEMAS) +HANMAC_EXTERNAL_SCHEMA_LABEL = " / ".join(HANMAC_EXTERNAL_SCHEMAS) + + def _validate_hanmac_schema_name(schema_name: Any) -> str: normalized = normalize_text(schema_name) - if normalized not in {"hanmac", "hanmac_manhour"}: + if normalized not in set(HANMAC_EXTERNAL_SCHEMAS): raise ValueError("스키마 이름이 올바르지 않습니다.") return normalized @@ -9014,10 +13635,10 @@ def get_hanmac_table_list(payload: dict[str, Any]) -> dict[str, Any]: with test_engine.connect() as connection: table_rows = connection.execute( text( - """ + f""" SELECT table_schema, table_name FROM information_schema.tables - WHERE table_schema IN ('hanmac', 'hanmac_manhour') + WHERE table_schema IN ({HANMAC_EXTERNAL_SCHEMA_SQL}) AND table_type = 'BASE TABLE' ORDER BY table_schema, table_name """ @@ -9047,7 +13668,7 @@ def get_hanmac_table_list(payload: dict[str, Any]) -> dict[str, Any]: } tables.sort( key=lambda item: ( - item.get("schema") != "hanmac_manhour", + {HANMAC_PRIMARY_MANHOUR_SCHEMA: 0, HANMAC_CENTER_MANHOUR_SCHEMA: 1, "hanmac": 2}.get(item.get("schema"), 9), preferred_tables.get(item.get("name", ""), 99), (item.get("row_count") is None), -(item.get("row_count") or 0), @@ -9056,7 +13677,7 @@ def get_hanmac_table_list(payload: dict[str, Any]) -> dict[str, Any]: ) return { "status": "ok", - "database": "hanmac / hanmac_manhour", + "database": HANMAC_EXTERNAL_SCHEMA_LABEL, "tables": tables, } finally: @@ -9227,10 +13848,10 @@ def get_hanmac_table_preview(payload: dict[str, Any]) -> dict[str, Any]: (str(row["table_schema"]), str(row["table_name"])) for row in connection.execute( text( - """ + f""" SELECT table_schema, table_name FROM information_schema.tables - WHERE table_schema IN ('hanmac', 'hanmac_manhour') + WHERE table_schema IN ({HANMAC_EXTERNAL_SCHEMA_SQL}) AND table_type = 'BASE TABLE' """ ) @@ -9287,6 +13908,176 @@ def _hanmac_find_column(columns: list[str], candidates: list[str]) -> str | None return None +def _hanmac_normalize_person_name(value: Any) -> str: + return re.sub(r"\s+", "", normalize_text(value)).lower() + + +def _hanmac_normalize_member_token(value: Any) -> str: + return normalize_text(value).lower() + + +def _hanmac_normalize_member_restore_keys(payload: dict[str, Any]) -> set[str]: + raw_items = payload.get("include_center_member_nos") + if raw_items is None: + raw_items = payload.get("restore_center_member_nos") + if isinstance(raw_items, str): + items = [item.strip() for item in raw_items.split(",")] + elif isinstance(raw_items, (list, tuple, set)): + items = list(raw_items) + else: + items = [] + return {_hanmac_normalize_member_token(item) for item in items if _hanmac_normalize_member_token(item)} + + +def _hanmac_load_member_info( + connection: Any, + schema_name: str, + metadata: dict[str, list[str]], +) -> tuple[dict[str, dict[str, Any]], dict[str, Any]]: + diagnostics: dict[str, Any] = { + "schema": schema_name, + "member_columns": [], + "member_name_col": "", + "member_name_fallback_rows": 0, + "member_group_col": "", + "dept_source_table": "", + "dept_code_col": "", + "dept_name_col": "", + "dept_mapped_rows": 0, + "available": False, + } + member_info: dict[str, dict[str, Any]] = {} + member_columns = metadata.get("member_tbl") or [] + diagnostics["member_columns"] = member_columns + member_no_col = _hanmac_find_column(member_columns, ["MemberNo", "member_no", "EmpNo", "UserID"]) + if not member_no_col: + return member_info, diagnostics + member_name_col = _hanmac_find_column(member_columns, ["Name", "MemberName", "member_name", "UserName", "KorName", "MemberNm", "member_nm", "EmpName", "emp_name", "UserNM", "user_nm", "KorNm", "kor_nm", "KoreanName", "DisplayName"]) + diagnostics["member_name_col"] = member_name_col or "" + entry_date_col = _hanmac_find_column(member_columns, ["EntryDate", "entry_date", "HireDate", "JoinDate", "InDate"]) + 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", "직급"]) + 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_group_col"] = member_group_col or "" + dept_name_by_code: dict[str, str] = {} + if not dept_name_col and member_group_col: + preferred_dept_tables = sorted( + ( + table_name + for table_name in metadata + if table_name != "member_tbl" + and any(token in table_name.lower() for token in ("group", "dept", "department", "team", "part")) + ), + key=lambda table_name: ( + not any(token in table_name.lower() for token in ("group", "dept")), + table_name.lower(), + ), + ) + for table_name in preferred_dept_tables: + table_columns = metadata.get(table_name) or [] + code_col = _hanmac_find_column( + table_columns, + ["GroupCode", "group_code", "DeptCode", "dept_code", "DepartmentCode", "TeamCode", "Code", "code"], + ) + name_col = _hanmac_find_column( + table_columns, + ["GroupName", "group_name", "DeptName", "dept_name", "Department", "DepartmentName", "PartName", "TeamName", "Name"], + ) + if not code_col or not name_col: + continue + dept_rows = connection.execute( + text( + f""" + SELECT + {_hanmac_build_select_alias(code_col, "dept_code")}, + {_hanmac_build_select_alias(name_col, "dept_name")} + FROM `{schema_name}`.`{table_name}` + """ + ) + ).mappings().all() + dept_name_by_code = { + normalize_text(row.get("dept_code")): normalize_text(row.get("dept_name")) + for row in dept_rows + if normalize_text(row.get("dept_code")) and normalize_text(row.get("dept_name")) + } + if dept_name_by_code: + diagnostics["dept_source_table"] = table_name + diagnostics["dept_code_col"] = code_col + diagnostics["dept_name_col"] = name_col + break + rank_code_names: dict[str, str] = {} + system_columns = metadata.get("systemconfig_tbl") or [] + if system_columns: + sys_key_col = _hanmac_find_column(system_columns, ["SysKey", "sys_key", "syskey"]) + code_col = _hanmac_find_column(system_columns, ["Code", "code"]) + name_col = _hanmac_find_column(system_columns, ["Name", "name", "CodeORName", "Description"]) + if sys_key_col and code_col and name_col: + try: + rank_rows = connection.execute( + text( + f""" + SELECT + {_hanmac_build_select_alias(code_col, "code")}, + {_hanmac_build_select_alias(name_col, "name")} + FROM `{schema_name}`.`systemconfig_tbl` + WHERE LOWER(CAST(`{sys_key_col}` AS CHAR)) LIKE '%rank%' + OR LOWER(CAST(`{sys_key_col}` AS CHAR)) LIKE '%position%' + OR LOWER(CAST(`{sys_key_col}` AS CHAR)) LIKE '%duty%' + OR CAST(`{sys_key_col}` AS CHAR) LIKE '%직급%' + """ + ) + ).mappings().all() + rank_code_names = { + normalize_text(row.get("code")): normalize_text(row.get("name")) + for row in rank_rows + if normalize_text(row.get("code")) and normalize_text(row.get("name")) + } + except Exception: + rank_code_names = {} + diagnostics["member_grade_code_map_rows"] = len(rank_code_names) + + member_rows = connection.execute( + text( + f""" + SELECT + {_hanmac_build_select_alias(member_no_col, "member_no")}, + {_hanmac_build_select_alias(member_name_col, "member_name")}, + {_hanmac_build_select_alias(entry_date_col, "entry_date")}, + {_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(member_group_col, "group_code")} + FROM `{schema_name}`.`member_tbl` + """ + ) + ).mappings().all() + diagnostics["available"] = True + for row in member_rows: + member_no = normalize_text(row.get("member_no")) + if not member_no: + continue + member_name = normalize_text(row.get("member_name")) or member_no + if member_name == member_no: + diagnostics["member_name_fallback_rows"] += 1 + dept_name = normalize_text(row.get("dept_name")) or dept_name_by_code.get(normalize_text(row.get("group_code")), "") + if dept_name and not normalize_text(row.get("dept_name")): + diagnostics["dept_mapped_rows"] += 1 + raw_grade = normalize_text(row.get("member_grade")) + member_grade = _normalize_labor_grade_name(rank_code_names.get(raw_grade) or raw_grade) + member_info[member_no] = { + "member_no": member_no, + "member_name": member_name, + "entry_date": _hanmac_parse_date_value(row.get("entry_date")), + "leave_date": _hanmac_parse_date_value(row.get("leave_date")), + "dept_name": dept_name, + "member_grade": member_grade, + "source_schema": schema_name, + } + return member_info, diagnostics + + def _hanmac_build_project_code_relation_maps( project_code_alias_groups: list[dict[str, Any]], ) -> dict[str, Any]: @@ -9429,6 +14220,14 @@ def _hanmac_parse_duration_hours(value: Any) -> float: return max(_hanmac_parse_float_value(text_value), 0.0) +def _hanmac_parse_hour_minute_fields(hour_value: Any, minute_value: Any = None) -> float: + hours = _hanmac_parse_duration_hours(hour_value) + minutes = _hanmac_parse_float_value(minute_value) + if minutes > 0: + hours += minutes / 60.0 + return round(max(hours, 0.0), 4) + + def _hanmac_calculate_regular_hours(entry_time: Any, leave_time: Any) -> float: started_at = _hanmac_parse_datetime_value(entry_time) ended_at = _hanmac_parse_datetime_value(leave_time) @@ -9437,9 +14236,345 @@ def _hanmac_calculate_regular_hours(entry_time: Any, leave_time: Any) -> float: hours = (ended_at - started_at).total_seconds() / 3600.0 if hours < 0 or hours > 24: return 0.0 + if ended_at.time() > datetime.strptime("12:30", "%H:%M").time(): + hours = max(0.0, hours - 1.0) return round(hours, 2) +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))) + + +def _hanmac_round_recognized_hours(hours: Any) -> float: + raw_hours = max(_hanmac_parse_float_value(hours), 0.0) + return float(Decimal(str(raw_hours)).quantize(Decimal("1"), rounding=ROUND_HALF_UP)) + + +def _hanmac_cap_weekday_overtime(hours: Any) -> float: + raw_hours = max(_hanmac_parse_float_value(hours), 0.0) + return _hanmac_round_recognized_hours(min(raw_hours, 3.0)) if raw_hours >= 2.0 else 0.0 + + +def _hanmac_cap_holiday_hours(hours: Any) -> float: + raw_hours = max(_hanmac_parse_float_value(hours), 0.0) + return _hanmac_round_recognized_hours(min(raw_hours, 5.0)) if raw_hours >= 3.0 else 0.0 + + +def _hanmac_allocate_recognized_hours(total_hours: Any, source_hours: dict[Any, float]) -> dict[Any, float]: + target_hours = int(_hanmac_round_recognized_hours(total_hours)) + positive_items = [ + (key, max(_hanmac_parse_float_value(hours), 0.0)) + for key, hours in source_hours.items() + if _hanmac_parse_float_value(hours) > 0 + ] + allocations = {key: 0.0 for key in source_hours} + source_total = sum(hours for _, hours in positive_items) + if target_hours <= 0 or source_total <= 0: + return allocations + remainders: list[tuple[float, str, Any]] = [] + allocated_total = 0 + for key, hours in positive_items: + quota = target_hours * (hours / source_total) + allocated = int(math.floor(quota)) + allocations[key] = float(allocated) + allocated_total += allocated + remainders.append((quota - allocated, str(key), key)) + for _, _, key in sorted(remainders, key=lambda item: (-item[0], item[1]))[: target_hours - allocated_total]: + allocations[key] += 1.0 + return allocations + + +def _hanmac_extract_leave_hours_from_text(value: Any) -> float: + text_value = normalize_text(value).replace("/", "/") + time_range = re.search( + r"(\d{1,2})(?:\s*시|:)(?:\s*(\d{1,2})\s*분?)?\s*[~~\-]\s*(\d{1,2})(?:\s*시|:)(?:\s*(\d{1,2})\s*분?)?", + text_value, + ) + if not time_range: + return 0.0 + start_hours = int(time_range.group(1)) + (int(time_range.group(2) or 0) / 60.0) + end_hours = int(time_range.group(3)) + (int(time_range.group(4) or 0) / 60.0) + if end_hours <= start_hours: + end_hours += 24.0 + return round(min(max(end_hours - start_hours, 0.0), 8.0), 4) + + +def _load_hanmac_holiday_dates(start_date: date, end_date: date) -> set[date]: + init_db() + with engine.begin() as conn: + rows = conn.execute( + text( + """ + SELECT holiday_date + FROM hanmac_holidays + WHERE holiday_date >= :start_date + AND holiday_date <= :end_date + """ + ), + {"start_date": start_date.isoformat(), "end_date": end_date.isoformat()}, + ).fetchall() + holiday_dates: set[date] = set() + for row in rows: + parsed = _hanmac_parse_date_value(row[0]) + if parsed: + holiday_dates.add(parsed) + return holiday_dates + + +def get_hanmac_holidays(start_date: date | None = None, end_date: date | None = None) -> list[dict[str, Any]]: + init_db() + filters: list[str] = [] + params: dict[str, Any] = {} + if start_date: + filters.append("holiday_date >= :start_date") + params["start_date"] = start_date.isoformat() + if end_date: + filters.append("holiday_date <= :end_date") + params["end_date"] = end_date.isoformat() + where_sql = f"WHERE {' AND '.join(filters)}" if filters else "" + with engine.begin() as conn: + rows = conn.execute( + text( + f""" + SELECT holiday_date, holiday_name, holiday_type, memo, updated_at + FROM hanmac_holidays + {where_sql} + ORDER BY holiday_date + """ + ), + params, + ).mappings().all() + return [dict(row) for row in rows] + + +def _clear_hanmac_aggregate_caches() -> None: + init_db() + with engine.begin() as conn: + conn.execute(text("DELETE FROM hanmac_aggregate_query_rows")) + conn.execute(text("DELETE FROM hanmac_aggregate_query_metrics")) + conn.execute(text("DELETE FROM hanmac_aggregate_query_cache")) + + +def get_hanmac_leave_rules() -> list[dict[str, Any]]: + init_db() + with engine.begin() as conn: + rows = conn.execute( + text( + """ + SELECT keyword, leave_label, rule_type, default_hours, enabled, priority, memo + FROM hanmac_leave_rules + WHERE enabled = 1 + ORDER BY priority, keyword + """ + ) + ).mappings().all() + return [dict(row) for row in rows] + + +def save_hanmac_leave_rule(payload: dict[str, Any]) -> dict[str, Any]: + keyword = normalize_text(payload.get("keyword")) + if not keyword: + raise ValueError("휴가 구분 키워드를 입력해주세요.") + leave_label = normalize_text(payload.get("leave_label")) or keyword + rule_type = normalize_text(payload.get("rule_type")) or "full_day" + if rule_type not in {"full_day", "fixed_hours", "explicit_hours"}: + raise ValueError("휴가 계산 방식은 full_day, fixed_hours, explicit_hours 중 하나여야 합니다.") + default_hours = _hanmac_parse_float_value(payload.get("default_hours")) + if default_hours <= 0 and rule_type != "explicit_hours": + default_hours = 8.0 + enabled = 1 if normalize_text(payload.get("enabled", "1")).lower() not in {"0", "false", "no", "off"} else 0 + try: + priority = int(payload.get("priority") or 100) + except Exception: + priority = 100 + memo = normalize_text(payload.get("memo")) + init_db() + with engine.begin() as conn: + conn.execute( + text( + """ + INSERT INTO hanmac_leave_rules ( + keyword, leave_label, rule_type, default_hours, enabled, priority, memo, created_at, updated_at + ) VALUES ( + :keyword, :leave_label, :rule_type, :default_hours, :enabled, :priority, :memo, + CURRENT_TIMESTAMP, CURRENT_TIMESTAMP + ) + ON CONFLICT(keyword) DO UPDATE SET + leave_label = excluded.leave_label, + rule_type = excluded.rule_type, + default_hours = excluded.default_hours, + enabled = excluded.enabled, + priority = excluded.priority, + memo = excluded.memo, + updated_at = CURRENT_TIMESTAMP + """ + ), + { + "keyword": keyword, + "leave_label": leave_label, + "rule_type": rule_type, + "default_hours": default_hours, + "enabled": enabled, + "priority": priority, + "memo": memo, + }, + ) + _clear_hanmac_aggregate_caches() + return {"ok": True, "rules": get_hanmac_leave_rules()} + + +def delete_hanmac_leave_rule(keyword_value: Any) -> dict[str, Any]: + keyword = normalize_text(keyword_value) + if not keyword: + raise ValueError("삭제할 휴가 구분 키워드가 올바르지 않습니다.") + init_db() + with engine.begin() as conn: + conn.execute(text("DELETE FROM hanmac_leave_rules WHERE keyword = :keyword"), {"keyword": keyword}) + _clear_hanmac_aggregate_caches() + return {"ok": True, "keyword": keyword, "rules": get_hanmac_leave_rules()} + + +def _hanmac_match_leave_rule(leave_type: Any, rules: list[dict[str, Any]]) -> dict[str, Any] | None: + normalized_leave_type = normalize_text(leave_type).lower() + if not normalized_leave_type: + return None + flexible_work_keywords = ("탄력", "단축근무", "근무시간조정", "출근시간조정", "유연근무") + if any(keyword in normalized_leave_type for keyword in flexible_work_keywords): + return None + for rule in rules: + keyword = normalize_text(rule.get("keyword")).lower() + if keyword and keyword in normalized_leave_type: + return rule + english_keywords = ("leave", "vacation", "holiday") + if any(keyword in normalized_leave_type for keyword in english_keywords): + return { + "keyword": "leave", + "leave_label": "휴가", + "rule_type": "full_day", + "default_hours": 8.0, + } + return None + + +def _hanmac_calculate_leave_amounts( + *, + leave_type: Any, + leave_value: Any, + leave_hour: Any, + leave_min: Any, + rule: dict[str, Any], + full_date_count: int, +) -> tuple[float, float, str]: + rule_type = normalize_text(rule.get("rule_type")) or "full_day" + default_hours = _hanmac_parse_float_value(rule.get("default_hours")) or 8.0 + explicit_hours = _hanmac_parse_float_value(leave_hour) + (_hanmac_parse_float_value(leave_min) / 60.0) + numeric_value = _hanmac_parse_float_value(leave_value) + date_count = max(int(full_date_count or 1), 1) + + if rule_type == "explicit_hours": + total_hours = explicit_hours if explicit_hours > 0 else numeric_value + total_hours = max(total_hours, 0.0) + return round(total_hours / 8.0, 4), round(total_hours, 4), "explicit_hours" + + if explicit_hours > 0: + return round(explicit_hours / 8.0, 4), round(explicit_hours, 4), "explicit_hours" + + if numeric_value > 0: + if rule_type == "fixed_hours": + total_hours = numeric_value if numeric_value > default_hours else numeric_value * default_hours + return round(total_hours / 8.0, 4), round(total_hours, 4), "numeric_fixed_hours" + total_days = numeric_value + return round(total_days, 4), round(total_days * default_hours, 4), "numeric_days" + + total_hours = default_hours * date_count + return round(total_hours / 8.0, 4), round(total_hours, 4), "default_rule" + + +def _hanmac_find_columns(columns: list[str], candidates: list[str]) -> list[str]: + candidate_keys = {str(candidate).lower() for candidate in candidates} + return [str(column) for column in columns if str(column).lower() in candidate_keys] + + +def _hanmac_build_text_concat_alias(columns: list[str], alias: str) -> str: + if not columns: + return f"NULL AS `{alias}`" + expressions = ", ".join(f"COALESCE(CAST(`{column}` AS CHAR), '')" for column in columns) + return f"CONCAT_WS(' ', {expressions}) AS `{alias}`" + + +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"]) + 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")) + supported_state_table = lower_name == "userstate_tbl" + if not member_col or not date_col or not type_cols or (not name_hint and table_name != "worker_tardy_tbl" and not supported_state_table): + return None + return { + "table": table_name, + "member_col": member_col, + "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"]), + "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"]), + "min_col": _hanmac_find_column(columns, ["tardy_m", "TardyM", "tardy_min", "TardyMin", "UseMin", "use_min"]), + } + + +def save_hanmac_holiday(payload: dict[str, Any]) -> dict[str, Any]: + holiday_date = _hanmac_parse_date_value(payload.get("holiday_date")) + if not holiday_date: + raise ValueError("휴일 날짜를 입력해주세요.") + holiday_type = normalize_text(payload.get("holiday_type")) or "company" + if holiday_type not in {"legal", "substitute", "company"}: + holiday_type = "company" + holiday_name = normalize_text(payload.get("holiday_name")) or "휴일" + memo = normalize_text(payload.get("memo")) + init_db() + with engine.begin() as conn: + conn.execute( + text( + """ + INSERT INTO hanmac_holidays ( + holiday_date, holiday_name, holiday_type, memo, created_at, updated_at + ) VALUES ( + :holiday_date, :holiday_name, :holiday_type, :memo, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP + ) + ON CONFLICT(holiday_date) DO UPDATE SET + holiday_name = excluded.holiday_name, + holiday_type = excluded.holiday_type, + memo = excluded.memo, + updated_at = CURRENT_TIMESTAMP + """ + ), + { + "holiday_date": holiday_date.isoformat(), + "holiday_name": holiday_name, + "holiday_type": holiday_type, + "memo": memo, + }, + ) + _clear_hanmac_aggregate_caches() + return {"ok": True, "holiday": get_hanmac_holidays(holiday_date, holiday_date)[0]} + + +def delete_hanmac_holiday(holiday_date_value: Any) -> dict[str, Any]: + holiday_date = _hanmac_parse_date_value(holiday_date_value) + if not holiday_date: + raise ValueError("삭제할 휴일 날짜가 올바르지 않습니다.") + init_db() + with engine.begin() as conn: + conn.execute( + text("DELETE FROM hanmac_holidays WHERE holiday_date = :holiday_date"), + {"holiday_date": holiday_date.isoformat()}, + ) + _clear_hanmac_aggregate_caches() + return {"ok": True, "holiday_date": holiday_date.isoformat()} + + def _hanmac_resolve_period(payload: dict[str, Any]) -> tuple[date, date]: today = date.today() start_date = _hanmac_parse_date_value(payload.get("start_date")) or date(today.year, 1, 1) @@ -9474,6 +14609,258 @@ def _hanmac_fetch_table_columns(connection: Any, schema_name: str) -> dict[str, return metadata +def _hanmac_load_joint_assignment_absent_codes(connection: Any, schema_name: str, metadata: dict[str, list[str]]) -> dict[str, str]: + fallback_codes = {"20": "경쟁합사", "21": "일반합사", "C1": "합사", "C3": "합사"} + columns = metadata.get("systemconfig_tbl") or [] + if not columns: + return fallback_codes + sys_key_col = _hanmac_find_column(columns, ["SysKey", "sys_key", "syskey"]) + code_col = _hanmac_find_column(columns, ["Code", "code"]) + name_col = _hanmac_find_column(columns, ["Name", "name", "CodeORName", "Description"]) + if not code_col: + return fallback_codes + where = [] + params: dict[str, Any] = {} + if sys_key_col: + where.append(f"`{sys_key_col}` = :sys_key") + params["sys_key"] = "AbsentCode" + query = f""" + SELECT + {_hanmac_build_select_alias(code_col, "code")}, + {_hanmac_build_select_alias(name_col, "name")} + FROM `{schema_name}`.`systemconfig_tbl` + {f"WHERE {' AND '.join(where)}" if where else ""} + """ + codes: dict[str, str] = {} + try: + rows = connection.execute(text(query), params).mappings().all() + except Exception: + return fallback_codes + for row in rows: + code = normalize_text(row.get("code")) + name = normalize_text(row.get("name")) + if code and "합사" in name: + codes[code] = name + return {**fallback_codes, **codes} + + +def _hanmac_load_rank_code_names(connection: Any, schema_name: str, metadata: dict[str, list[str]]) -> dict[str, str]: + system_columns = metadata.get("systemconfig_tbl") or [] + if not system_columns: + return {} + sys_key_col = _hanmac_find_column(system_columns, ["SysKey", "sys_key", "syskey"]) + code_col = _hanmac_find_column(system_columns, ["Code", "code"]) + name_col = _hanmac_find_column(system_columns, ["Name", "name", "CodeORName", "Description"]) + if not sys_key_col or not code_col or not name_col: + return {} + try: + rows = connection.execute( + text( + f""" + SELECT + {_hanmac_build_select_alias(code_col, "code")}, + {_hanmac_build_select_alias(name_col, "name")} + FROM `{schema_name}`.`systemconfig_tbl` + WHERE LOWER(CAST(`{sys_key_col}` AS CHAR)) LIKE '%rank%' + OR LOWER(CAST(`{sys_key_col}` AS CHAR)) LIKE '%position%' + OR LOWER(CAST(`{sys_key_col}` AS CHAR)) LIKE '%duty%' + OR CAST(`{sys_key_col}` AS CHAR) LIKE '%직급%' + """ + ) + ).mappings().all() + except Exception: + return {} + return { + normalize_text(row.get("code")): normalize_text(row.get("name")) + for row in rows + if normalize_text(row.get("code")) and normalize_text(row.get("name")) + } + + +def get_hanmac_grade_code_summary(payload: dict[str, Any]) -> dict[str, Any]: + connect_payload = dict(payload) + connect_payload["database"] = normalize_text(payload.get("database")) or HANMAC_PRIMARY_MANHOUR_SCHEMA + test_engine = _build_hanmac_mysql_engine(connect_payload) + schema_summaries: list[dict[str, Any]] = [] + try: + with test_engine.connect() as connection: + for schema_name in (HANMAC_PRIMARY_MANHOUR_SCHEMA, HANMAC_CENTER_MANHOUR_SCHEMA): + metadata = _hanmac_fetch_table_columns(connection, schema_name) + member_columns = metadata.get("member_tbl") or [] + member_no_col = _hanmac_find_column(member_columns, ["MemberNo", "member_no", "EmpNo", "UserID"]) + member_name_col = _hanmac_find_column(member_columns, ["Name", "MemberName", "member_name", "UserName", "KorName", "MemberNm", "member_nm", "EmpName", "emp_name", "UserNM", "user_nm", "KorNm", "kor_nm", "KoreanName", "DisplayName"]) + grade_col = _hanmac_find_column(member_columns, ["Grade", "grade", "Position", "position", "Rank", "rank", "Duty", "duty", "JobGrade", "job_grade", "RankCode", "rank_code", "WorkPosition", "work_position", "직급"]) + entry_date_col = _hanmac_find_column(member_columns, ["EntryDate", "entry_date", "HireDate", "JoinDate", "InDate"]) + leave_date_col = _hanmac_find_column(member_columns, ["LeaveDate", "leave_date", "RetireDate", "OutDate"]) + diagnostics = { + "schema": schema_name, + "member_table": "member_tbl" if member_columns else "", + "member_no_col": member_no_col or "", + "member_name_col": member_name_col or "", + "grade_col": grade_col or "", + "rank_code_map_rows": 0, + } + if not member_columns or not grade_col: + schema_summaries.append({**diagnostics, "rows": []}) + continue + rank_code_names = _hanmac_load_rank_code_names(connection, schema_name, metadata) + diagnostics["rank_code_map_rows"] = len(rank_code_names) + member_rows = connection.execute( + text( + f""" + SELECT + {_hanmac_build_select_alias(member_no_col, "member_no")}, + {_hanmac_build_select_alias(member_name_col, "member_name")}, + {_hanmac_build_select_alias(grade_col, "grade_code")}, + {_hanmac_build_select_alias(entry_date_col, "entry_date")}, + {_hanmac_build_select_alias(leave_date_col, "leave_date")} + FROM `{schema_name}`.`member_tbl` + """ + ) + ).mappings().all() + today = date.today() + buckets: dict[str, dict[str, Any]] = {} + for row in member_rows: + grade_code = normalize_text(row.get("grade_code")) or "(빈값)" + 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) + bucket = buckets.setdefault( + grade_code, + { + "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), + "member_count": 0, + "active_member_count": 0, + "examples": [], + }, + ) + 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) + rows = sorted( + buckets.values(), + key=lambda item: ( + item["grade_code"] == "(빈값)", + str(item["grade_code"]), + ), + ) + schema_summaries.append({**diagnostics, "rows": rows}) + return {"status": "ok", "schemas": schema_summaries} + finally: + test_engine.dispose() + + +def _hanmac_load_joint_assignment_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 = { + "joint_absent_codes": {}, + "joint_assignment_source_rows": 0, + "joint_assignment_records": 0, + "joint_assignment_code_matched_rows": 0, + "joint_assignment_text_matched_rows": 0, + "joint_assignment_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"]) + 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"]) + 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: + return [], diagnostics + + joint_codes = _hanmac_load_joint_assignment_absent_codes(connection, schema_name, metadata) + diagnostics["joint_absent_codes"] = joint_codes + code_values = sorted(joint_codes) + code_conditions: list[str] = [] + params: dict[str, Any] = { + "start_date": start_date.isoformat(), + "end_date": end_date.isoformat(), + } + for index, code in enumerate(code_values): + key = f"joint_code_{index}" + params[key] = code + if sub_code_col: + code_conditions.append(f"CAST(`{sub_code_col}` AS CHAR) = :{key}") + if active_code_col: + code_conditions.append(f"CAST(`{active_code_col}` AS CHAR) = :{key}") + text_conditions = [] + for column in (note_col,): + if column: + text_conditions.append(f"CAST(`{column}` AS CHAR) LIKE :joint_text") + params["joint_text"] = "%합사%" + filters = code_conditions + text_conditions + if not filters: + 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)" + query = f""" + SELECT + {_hanmac_build_select_alias(member_col, "member_no")}, + {_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")}, + {_hanmac_build_select_alias(sub_code_col, "sub_code")}, + {_hanmac_build_select_alias(active_code_col, "active_code")} + FROM `{schema_name}`.`userstate_tbl` + WHERE `{member_col}` IS NOT NULL + AND LEFT(CAST(`{start_col}` AS CHAR), 10) <= :end_date + AND {date_end_expr} >= :start_date + AND ({' OR '.join(filters)}) + """ + rows = connection.execute(text(query), params).mappings().all() + diagnostics["joint_assignment_source_rows"] = len(rows) + diagnostics["joint_assignment_table"] = "userstate_tbl" + records: list[dict[str, Any]] = [] + 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 + 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 + 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 "" + note = normalize_text(row.get("note")) + if matched_code: + diagnostics["joint_assignment_code_matched_rows"] += 1 + elif "합사" in note: + diagnostics["joint_assignment_text_matched_rows"] += 1 + records.append( + { + "member_no": member_no, + "start_date": max(record_start, start_date), + "end_date": min(record_end or record_start, end_date), + "project_code": normalize_text(row.get("project_code")) or normalize_text(row.get("fallback_project_code")), + "joint_code": matched_code, + "joint_label": joint_codes.get(matched_code) or "합사", + "note": note, + "source": "userstate_tbl", + } + ) + diagnostics["joint_assignment_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 "퇴사" @@ -9493,7 +14880,7 @@ def _hanmac_member_matches_filter( return True entry_date = _hanmac_parse_date_value(member_record.get("entry_date")) leave_date = _hanmac_parse_date_value(member_record.get("leave_date")) - if employment_filter == "current": + if employment_filter in {"current", "active"}: return (entry_date is None or entry_date <= today) and (leave_date is None or leave_date >= today) if employment_filter == "retired": return leave_date is not None and leave_date < today @@ -9516,16 +14903,42 @@ def _hanmac_member_is_active_on(member_record: dict[str, Any], work_date: date | return True +def _hanmac_expected_regular_hours_for_period( + member_record: dict[str, Any], + start_date: date, + end_date: date, + holiday_dates: set[date], +) -> float: + 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 + if effective_end < effective_start: + return 0.0 + work_days = 0 + for work_date in _hanmac_iter_dates(effective_start, effective_end): + if work_date.weekday() >= 5 or work_date in holiday_dates: + continue + work_days += 1 + return round(work_days * 8.0, 2) + + 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", "host": normalize_text(payload.get("host")), "port": normalize_text(payload.get("port")), "user": normalize_text(payload.get("user")), - "database": normalize_text(payload.get("database") or "hanmac_manhour"), + "database": normalize_text(payload.get("database") or HANMAC_PRIMARY_MANHOUR_SCHEMA), "view": normalize_text(payload.get("view") or "member"), - "employment": normalize_text(payload.get("employment") or "all"), + "employment": employment_value, "start_date": normalize_text(payload.get("start_date")), "end_date": normalize_text(payload.get("end_date")), + "include_center_member_nos": include_center_member_nos, } return hashlib.sha1(json.dumps(normalized, ensure_ascii=False, sort_keys=True).encode("utf-8")).hexdigest() @@ -9747,10 +15160,30 @@ def get_hanmac_aggregate_summary_cached(payload: dict[str, Any]) -> dict[str, An cache_key = _hanmac_aggregate_cache_key(payload) projection_payload, projection_fresh = _load_hanmac_aggregate_projection(cache_key) if projection_payload and projection_fresh: + if ( + "center_members" not in projection_payload + or "joint_members" not in projection_payload + or "source_diagnostics" not in projection_payload + ): + cached_payload, cached_fresh = _load_hanmac_aggregate_cache(cache_key) + if cached_payload and cached_fresh: + cached_payload.setdefault("cache_meta", {}) + cached_payload["cache_meta"]["pending_refresh"] = False + return cached_payload projection_payload.setdefault("cache_meta", {}) projection_payload["cache_meta"]["pending_refresh"] = False return projection_payload if projection_payload and not projection_fresh: + if ( + "center_members" not in projection_payload + or "joint_members" not in projection_payload + or "source_diagnostics" not in projection_payload + ): + cached_payload, cached_fresh = _load_hanmac_aggregate_cache(cache_key) + if cached_payload: + cached_payload.setdefault("cache_meta", {}) + cached_payload["cache_meta"]["pending_refresh"] = not cached_fresh + return cached_payload with _HANMAC_AGGREGATE_REFRESHING_LOCK: should_start = cache_key not in _HANMAC_AGGREGATE_REFRESHING if should_start: @@ -9813,14 +15246,16 @@ 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", "host": normalize_text(payload.get("host")), "port": normalize_text(payload.get("port")), "user": normalize_text(payload.get("user")), - "database": normalize_text(payload.get("database") or "hanmac_manhour"), + "database": normalize_text(payload.get("database") or HANMAC_PRIMARY_MANHOUR_SCHEMA), "view": normalize_text(payload.get("view") or "member"), "employment": normalize_text(payload.get("employment") or "all"), "start_date": normalize_text(payload.get("start_date")), "end_date": normalize_text(payload.get("end_date")), + "include_center_member_nos": sorted(_hanmac_normalize_member_restore_keys(payload)), "value_column": normalize_text(payload.get("value_column")), "value_search": normalize_text(payload.get("value_search")), "sort_key": normalize_text(payload.get("sort_key")), @@ -10232,6 +15667,7 @@ def _run_app_maintenance_once() -> None: export_retention_sec=EXPORT_RETENTION_SECONDS, cache_retention_sec=QUERY_CACHE_RETENTION_SECONDS, ) + _maybe_run_db_vacuum() def _app_maintenance_worker_loop() -> None: @@ -10245,6 +15681,15 @@ def _app_maintenance_worker_loop() -> None: def _ensure_app_maintenance_worker() -> None: global _APP_MAINTENANCE_WORKER_STARTED, _APP_MAINTENANCE_WORKER_THREAD + maintenance_enabled = normalize_text(os.getenv("HM_APP_MAINTENANCE_ENABLED", "1")).lower() not in { + "0", + "false", + "no", + "off", + } + if not maintenance_enabled: + logger.info("Skipping automatic cache maintenance; HM_APP_MAINTENANCE_ENABLED=0") + return with _APP_MAINTENANCE_WORKER_LOCK: if _APP_MAINTENANCE_WORKER_STARTED and _APP_MAINTENANCE_WORKER_THREAD and _APP_MAINTENANCE_WORKER_THREAD.is_alive(): return @@ -10258,12 +15703,1051 @@ def _ensure_app_maintenance_worker() -> None: _APP_MAINTENANCE_WORKER_STARTED = True +def _system_job_to_payload(row: Any | None) -> dict[str, Any] | None: + if row is None: + return None + item = dict(row) + for key in ("params_json", "result_json"): + try: + item[key.replace("_json", "")] = json.loads(str(item.get(key) or "{}")) + except Exception: + item[key.replace("_json", "")] = {} + return item + + +def _cleanup_stale_system_jobs(reason: str = "startup", *, all_running: bool = False) -> int: + global _SYSTEM_JOB_LAST_STALE_CLEANUP_AT + if not all_running: + now = time.time() + if now - _SYSTEM_JOB_LAST_STALE_CLEANUP_AT < 60.0: + return 0 + _SYSTEM_JOB_LAST_STALE_CLEANUP_AT = now + stale_seconds = max(60, int(os.getenv("HM_SYSTEM_JOB_STALE_SECONDS", str(SYSTEM_JOB_STALE_RUNNING_SECONDS)) or SYSTEM_JOB_STALE_RUNNING_SECONDS)) + message = ( + "자동 정리: 서버 재시작으로 중단된 실행 중 작업을 실패 처리했습니다." + if all_running + else f"자동 정리: {stale_seconds // 60}분 이상 갱신되지 않은 실행 중 작업을 실패 처리했습니다." + ) + error_message = f"stale running system job cleaned during {reason}" + where_clause = "status = 'running'" if all_running else "status = 'running' AND updated_at < datetime('now', :threshold)" + params = { + "message": message, + "error_message": error_message, + "threshold": f"-{stale_seconds} seconds", + } + try: + with engine.begin() as conn: + result = conn.execute( + text( + f""" + UPDATE system_jobs + SET status = 'failed', + message = :message, + error_message = :error_message, + finished_at = CURRENT_TIMESTAMP, + updated_at = CURRENT_TIMESTAMP + WHERE {where_clause} + """ + ), + params, + ) + return int(result.rowcount or 0) + except Exception as exc: + logger.warning("stale system job cleanup skipped: %s", exc) + return 0 + + +def _fetch_system_job(job_id: str) -> dict[str, Any] | None: + init_db() + with engine.begin() as conn: + row = conn.execute( + text( + """ + SELECT * + FROM system_jobs + WHERE id = :id + """ + ), + {"id": job_id}, + ).mappings().first() + return _system_job_to_payload(row) + + +def _fetch_latest_system_job( + page_key: str = "", + job_type: str = "", + start_year: int | None = None, + end_year: int | None = None, +) -> dict[str, Any] | None: + init_db() + filters = ["1 = 1"] + params: dict[str, Any] = {} + if page_key: + filters.append("page_key = :page_key") + params["page_key"] = page_key + if job_type: + filters.append("job_type = :job_type") + params["job_type"] = job_type + if start_year is not None: + filters.append("COALESCE(start_year, -1) = COALESCE(:start_year, -1)") + params["start_year"] = start_year + if end_year is not None: + filters.append("COALESCE(end_year, -1) = COALESCE(:end_year, -1)") + params["end_year"] = end_year + with engine.begin() as conn: + row = conn.execute( + text( + f""" + SELECT * + FROM system_jobs + WHERE {' AND '.join(filters)} + ORDER BY created_at DESC, id DESC + LIMIT 1 + """ + ), + params, + ).mappings().first() + return _system_job_to_payload(row) + + +def _create_system_job( + *, + page_key: str, + job_type: str, + start_year: int | None = None, + end_year: int | None = None, + params: dict[str, Any] | None = None, +) -> dict[str, Any]: + init_db() + normalized_page = normalize_text(page_key) + normalized_type = normalize_text(job_type) + if not normalized_page or not normalized_type: + raise ValueError("작업 페이지와 작업 종류가 필요합니다.") + params = dict(params or {}) + _cleanup_stale_system_jobs("job creation") + job_id = "" + for attempt in range(12): + try: + with engine.begin() as conn: + existing = conn.execute( + text( + """ + SELECT * + FROM system_jobs + WHERE page_key = :page_key + AND job_type = :job_type + AND COALESCE(start_year, -1) = COALESCE(:start_year, -1) + AND COALESCE(end_year, -1) = COALESCE(:end_year, -1) + AND status IN ('queued', 'running') + ORDER BY created_at DESC + LIMIT 1 + """ + ), + { + "page_key": normalized_page, + "job_type": normalized_type, + "start_year": start_year, + "end_year": end_year, + }, + ).mappings().first() + if existing: + _SYSTEM_JOB_EVENT.set() + payload = _system_job_to_payload(existing) or {} + payload["reused"] = True + return payload + + job_id = uuid.uuid4().hex + conn.execute( + text( + """ + INSERT INTO system_jobs ( + id, page_key, job_type, status, start_year, end_year, + params_json, progress_current, progress_total, message, + result_json, error_message, created_at, updated_at + ) VALUES ( + :id, :page_key, :job_type, 'queued', :start_year, :end_year, + :params_json, 0, 0, :message, '{}', '', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP + ) + """ + ), + { + "id": job_id, + "page_key": normalized_page, + "job_type": normalized_type, + "start_year": start_year, + "end_year": end_year, + "params_json": json.dumps(params, ensure_ascii=False), + "message": "작업 대기 중입니다.", + }, + ) + break + except OperationalError as exc: + if "database is locked" not in str(exc).lower() or attempt >= 11: + raise + time.sleep(1.0) + _ensure_system_job_worker() + _SYSTEM_JOB_EVENT.set() + return _fetch_system_job(job_id) or {"id": job_id, "status": "queued"} + + +def _update_system_job( + job_id: str, + *, + status: str | None = None, + message: str | None = None, + progress_current: int | None = None, + progress_total: int | None = None, + result: dict[str, Any] | None = None, + error_message: str | None = None, + started: bool = False, + finished: bool = False, +) -> None: + assignments = ["updated_at = CURRENT_TIMESTAMP"] + params: dict[str, Any] = {"id": job_id} + if status is not None: + assignments.append("status = :status") + params["status"] = status + if message is not None: + assignments.append("message = :message") + params["message"] = message + if progress_current is not None: + assignments.append("progress_current = :progress_current") + params["progress_current"] = int(progress_current) + if progress_total is not None: + assignments.append("progress_total = :progress_total") + params["progress_total"] = int(progress_total) + if result is not None: + assignments.append("result_json = :result_json") + params["result_json"] = json.dumps(result, ensure_ascii=False, default=str) + if error_message is not None: + assignments.append("error_message = :error_message") + params["error_message"] = error_message + if started: + assignments.append("started_at = CURRENT_TIMESTAMP") + if finished: + assignments.append("finished_at = CURRENT_TIMESTAMP") + with engine.begin() as conn: + conn.execute( + text(f"UPDATE system_jobs SET {', '.join(assignments)} WHERE id = :id"), + params, + ) + + +def _claim_next_system_job() -> dict[str, Any] | None: + init_db() + _cleanup_stale_system_jobs("worker claim") + with engine.begin() as conn: + row = conn.execute( + text( + """ + SELECT * + FROM system_jobs + WHERE status = 'queued' + ORDER BY created_at ASC, id ASC + LIMIT 1 + """ + ) + ).mappings().first() + if not row: + return None + conn.execute( + text( + """ + UPDATE system_jobs + SET status = 'running', + started_at = CURRENT_TIMESTAMP, + updated_at = CURRENT_TIMESTAMP, + message = '작업을 시작했습니다.' + WHERE id = :id + AND status = 'queued' + """ + ), + {"id": row["id"]}, + ) + return _fetch_system_job(str(row["id"])) + + +_WEHAGO_COMPARE_VOUCHER_STATUSES = ( + "voucher_matched", + "voucher_unmatched", + "voucher_recheck", + "voucher_excepted", + "hanmac_unconnected", + "erp_voucher_matched", + "erp_voucher_unmatched", +) + + +def _latest_year_query_source(conn: sqlite3.Connection, year: int) -> tuple[int, int, str]: + row = conn.execute( + f""" + SELECT start_year, end_year, signature, COUNT(DISTINCT status_key) AS status_count, MAX(updated_at) AS max_updated_at + FROM wehago_compare_query_groups + WHERE ? BETWEEN start_year AND end_year + AND status_key IN ({','.join('?' for _ in _WEHAGO_COMPARE_VOUCHER_STATUSES)}) + AND signature LIKE ? + GROUP BY start_year, end_year, signature + ORDER BY + CASE WHEN start_year = ? AND end_year = ? THEN 0 ELSE 1 END ASC, + CASE WHEN signature LIKE '%db-reconciled-v1%' THEN 0 ELSE 1 END ASC, + status_count DESC, + (end_year - start_year) ASC, + max_updated_at DESC + LIMIT 1 + """, + ( + year, + *_WEHAGO_COMPARE_VOUCHER_STATUSES, + f"{QUERY_PROJECTION_VERSION}|%", + year, + year, + ), + ).fetchone() + if row is None: + raise RuntimeError(f"{year}년 조회 projection이 없습니다. 먼저 해당 연도 계산을 실행해주세요.") + return int(row["start_year"] or year), int(row["end_year"] or year), str(row["signature"] or "") + + +def _project_current_year_query_range(start_year: int, end_year: int) -> dict[str, int]: + group_columns = [ + "start_year", + "end_year", + "status_key", + "signature", + "group_index", + "fiscal_year", + "ledger_date", + "proof_date", + "voucher_no", + "draft_no", + "ledger_row_count", + "voucher_row_count", + "ledger_debit", + "ledger_credit", + "voucher_debit", + "voucher_credit", + "ledger_accounts", + "voucher_accounts", + "ledger_vendors", + "voucher_vendors", + "review_reason", + "search_text", + ] + row_columns = [ + "start_year", + "end_year", + "status_key", + "signature", + "group_index", + "row_index", + "fiscal_year", + "status_label", + "ledger_date", + "proof_date", + "voucher_no", + "draft_no", + "ledger_account_name", + "voucher_account_name", + "ledger_vendor", + "voucher_vendor", + "ledger_debit", + "ledger_credit", + "voucher_debit", + "voucher_credit", + "ledger_desc", + "voucher_desc", + "review_reason", + "matched_case", + "ledger_row_key", + "voucher_row_key", + "match_identity_key", + ] + conn = sqlite3.connect(DB_PATH) + conn.row_factory = sqlite3.Row + try: + source_projections = { + year: _latest_year_query_source(conn, year) + for year in range(start_year, end_year + 1) + } + raw_signature = "|".join( + f"{year}:{source_projections[year][0]}-{source_projections[year][1]}:{source_projections[year][2]}" + for year in sorted(source_projections) + ) + signature = f"{QUERY_PROJECTION_VERSION}|year-current-projection-v1|{start_year}-{end_year}|{hashlib.sha1(raw_signature.encode('utf-8')).hexdigest()}" + counters = {status: 0 for status in _WEHAGO_COMPARE_VOUCHER_STATUSES} + conn.execute("BEGIN") + try: + conn.execute( + "DELETE FROM wehago_compare_query_rows WHERE start_year = ? AND end_year = ? AND signature = ?", + (start_year, end_year, signature), + ) + conn.execute( + "DELETE FROM wehago_compare_query_groups WHERE start_year = ? AND end_year = ? AND signature = ?", + (start_year, end_year, signature), + ) + for year in range(start_year, end_year + 1): + source_start_year, source_end_year, source_signature = source_projections[year] + source_groups = conn.execute( + f""" + SELECT * + FROM wehago_compare_query_groups + WHERE start_year = ? + AND end_year = ? + AND signature = ? + AND fiscal_year = ? + AND status_key IN ({','.join('?' for _ in _WEHAGO_COMPARE_VOUCHER_STATUSES)}) + ORDER BY status_key, group_index + """, + (source_start_year, source_end_year, source_signature, year, *_WEHAGO_COMPARE_VOUCHER_STATUSES), + ).fetchall() + for group in source_groups: + status_key = str(group["status_key"] or "") + counters[status_key] += 1 + target_group_index = counters[status_key] + values = {column: group[column] for column in group_columns} + values.update( + { + "start_year": start_year, + "end_year": end_year, + "signature": signature, + "group_index": target_group_index, + } + ) + conn.execute( + f""" + INSERT INTO wehago_compare_query_groups ( + {', '.join(group_columns)}, created_at, updated_at + ) VALUES ( + {', '.join('?' for _ in group_columns)}, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP + ) + """, + [values[column] for column in group_columns], + ) + source_rows = conn.execute( + """ + SELECT * + FROM wehago_compare_query_rows + WHERE start_year = ? + AND end_year = ? + AND signature = ? + AND status_key = ? + AND group_index = ? + AND fiscal_year = ? + ORDER BY row_index + """, + ( + source_start_year, + source_end_year, + source_signature, + status_key, + int(group["group_index"] or 0), + year, + ), + ).fetchall() + for source_row in source_rows: + row_values = {column: source_row[column] for column in row_columns} + row_values.update( + { + "start_year": start_year, + "end_year": end_year, + "signature": signature, + "group_index": target_group_index, + } + ) + conn.execute( + f""" + INSERT INTO wehago_compare_query_rows ( + {', '.join(row_columns)}, created_at, updated_at + ) VALUES ( + {', '.join('?' for _ in row_columns)}, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP + ) + """, + [row_values[column] for column in row_columns], + ) + conn.execute( + """ + DELETE FROM wehago_summary_range_cache + WHERE start_year = ? + AND end_year = ? + """, + (start_year, end_year), + ) + conn.commit() + except Exception: + conn.rollback() + raise + return counters + finally: + conn.close() + + +def _rebuild_wehago_compare_year_query_projections_for_range( + job_id: str, + start_year: int, + end_year: int, +) -> None: + from wehago_compare import _rebuild_compare_query_projection + + total = max(1, end_year - start_year + 1) + with engine.connect().execution_options(isolation_level="AUTOCOMMIT") as conn: + for index, year in enumerate(range(start_year, end_year + 1), start=1): + _update_system_job( + job_id, + message=f"{year}년 전표비교 조회 projection을 생성 중입니다.", + progress_current=index - 1, + progress_total=total, + ) + _rebuild_compare_query_projection(engine, conn, year, year) + _update_system_job( + job_id, + message=f"{year}년 전표비교 조회 projection 준비 완료.", + progress_current=index, + progress_total=total, + ) + + +def _rebuild_wehago_compare_year_snapshots_for_range( + job_id: str, + start_year: int, + end_year: int, +) -> None: + from scripts.project_export_cache_ranges import current_ready_export_signature + from wehago_compare import _refresh_year_resolved_sections + + total = max(1, end_year - start_year + 1) + with engine.connect().execution_options(isolation_level="AUTOCOMMIT") as conn: + for index, year in enumerate(range(start_year, end_year + 1), start=1): + try: + sqlite_conn = sqlite3.connect(DB_PATH) + sqlite_conn.row_factory = sqlite3.Row + try: + current_ready_export_signature(sqlite_conn, year) + _update_system_job( + job_id, + message=f"{year}년 현재 로직 전표 행 캐시가 이미 준비되어 있습니다.", + progress_current=index, + progress_total=total, + ) + continue + finally: + sqlite_conn.close() + except Exception: + pass + _update_system_job( + job_id, + message=f"{year}년 현재 로직 전표 스냅샷을 재생성 중입니다.", + progress_current=index - 1, + progress_total=total, + ) + _refresh_year_resolved_sections(conn, year) + _update_system_job( + job_id, + message=f"{year}년 현재 로직 전표 스냅샷 준비 완료.", + progress_current=index, + progress_total=total, + ) + + +def _run_wehago_compare_project_range_job(job: dict[str, Any]) -> dict[str, Any]: + from scripts.project_export_cache_ranges import project_range + + _assert_wal_allows_heavy_cache_write() + job_id = str(job.get("id") or "") + start_year = int(job.get("start_year") or 0) + end_year = int(job.get("end_year") or 0) + params = job.get("params") if isinstance(job.get("params"), dict) else {} + reuse_existing_projection = bool(params.get("reuse_existing_projection")) + if not start_year or not end_year: + raise ValueError("기간 정보가 없어 전표비교 조회 캐시를 만들 수 없습니다.") + if start_year > end_year: + start_year, end_year = end_year, start_year + _update_system_job( + job_id, + message=f"{start_year}~{end_year} 전표비교 조회 캐시를 생성 중입니다.", + progress_current=0, + progress_total=1, + ) + if reuse_existing_projection: + _update_system_job( + job_id, + message=f"{start_year}~{end_year} 현재 로직 전표 행 캐시로 빠른 조회 캐시를 생성 중입니다.", + ) + try: + conn = sqlite3.connect(DB_PATH) + conn.row_factory = sqlite3.Row + try: + counts = project_range(conn, start_year, end_year) + finally: + conn.close() + _clear_compare_runtime_caches() + return { + "start_year": start_year, + "end_year": end_year, + "counts": counts, + "source": "current_export_row_cache", + } + except Exception as exc: + _update_system_job( + job_id, + message=f"현재 로직 전표 행 캐시가 없어 조회 캐시만 빠르게 재생성할 수 없습니다: {exc}", + ) + raise RuntimeError( + "현재 로직 전표 행 캐시가 아직 준비되지 않아 조회 캐시를 갱신하지 않았습니다. " + "기존 projection을 재사용하면 새 로직이 반영되지 않으므로, 연도별 스냅샷을 별도 증분 작업으로 먼저 만들어야 합니다." + ) from exc + _update_system_job( + job_id, + message=f"{start_year}~{end_year} 현재 로직 전표 스냅샷을 준비 중입니다.", + ) + _rebuild_wehago_compare_year_snapshots_for_range(job_id, start_year, end_year) + _update_system_job( + job_id, + message=f"{start_year}~{end_year} 현재 로직 projection을 합산 중입니다.", + ) + conn = sqlite3.connect(DB_PATH) + conn.row_factory = sqlite3.Row + try: + counts = project_range(conn, start_year, end_year) + finally: + conn.close() + _clear_compare_runtime_caches() + return { + "start_year": start_year, + "end_year": end_year, + "counts": counts, + } + + +def _assert_wal_allows_heavy_cache_write() -> None: + wal_path = DB_PATH.with_name(f"{DB_PATH.name}-wal") + wal_bytes = wal_path.stat().st_size if wal_path.exists() else 0 + if wal_bytes < WAL_BLOCK_HEAVY_BYTES: + return + raise RuntimeError( + "현재 DB WAL 파일이 " + f"{wal_bytes / (1024 * 1024):,.0f}MB로 대량 캐시 작업 중단 기준 " + f"{WAL_BLOCK_HEAVY_BYTES / (1024 * 1024):,.0f}MB를 초과했습니다. " + "신규 캐시 생성을 시작하지 않고, 서버 중단 후 백업/checkpoint 및 캐시 분리 작업을 먼저 수행해야 합니다." + ) + + +def _load_existing_wehago_compare_export_projection_job(start_year: int, end_year: int) -> dict[str, Any] | None: + from scripts.project_export_cache_ranges import current_ready_export_signature, projection_signature + + conn = sqlite3.connect(DB_PATH) + conn.row_factory = sqlite3.Row + try: + try: + signatures = { + year: current_ready_export_signature(conn, year) + for year in range(start_year, end_year + 1) + } + except Exception: + return None + expected_signature = projection_signature(signatures, start_year, end_year) + row = conn.execute( + """ + SELECT signature, MAX(updated_at) AS updated_at + FROM wehago_compare_query_groups + WHERE start_year = ? + AND end_year = ? + AND signature = ? + GROUP BY signature + ORDER BY updated_at DESC + LIMIT 1 + """, + (start_year, end_year, expected_signature), + ).fetchone() + if row is None: + return None + counts = _empty_compare_metric_counts() + for status_key, row_count in conn.execute( + """ + SELECT status_key, COUNT(*) + FROM wehago_compare_query_groups + WHERE start_year = ? + AND end_year = ? + AND signature = ? + GROUP BY status_key + """, + (start_year, end_year, str(row["signature"] or "")), + ).fetchall(): + if str(status_key or "") in counts: + counts[str(status_key or "")] = int(row_count or 0) + for status_key, row_count in conn.execute( + """ + SELECT status, COUNT(*) + FROM wehago_comparison_results + WHERE fiscal_year BETWEEN ? AND ? + AND status IN ('matched', 'ledger_only', 'voucher_only', 'amount_mismatch') + GROUP BY status + """, + (start_year, end_year), + ).fetchall(): + if str(status_key or "") in counts: + counts[str(status_key or "")] = int(row_count or 0) + return { + "id": f"cached-{start_year}-{end_year}", + "page_key": "wehago_compare", + "job_type": "wehago_compare_project_range", + "status": "done", + "start_year": start_year, + "end_year": end_year, + "params": {"source": "existing_current_export_row_cache"}, + "progress_current": 1, + "progress_total": 1, + "message": "기존 조회 캐시가 이미 준비되어 있습니다.", + "result": { + "start_year": start_year, + "end_year": end_year, + "counts": counts, + "source": "existing_current_export_row_cache", + }, + "error_message": "", + "updated_at": str(row["updated_at"] or ""), + } + finally: + conn.close() + + +def _assert_wehago_compare_current_export_rows_ready(start_year: int, end_year: int) -> None: + from scripts.project_export_cache_ranges import current_ready_export_signature + + conn = sqlite3.connect(DB_PATH) + conn.row_factory = sqlite3.Row + try: + missing: list[str] = [] + for year in range(start_year, end_year + 1): + try: + current_ready_export_signature(conn, year) + except Exception as exc: + missing.append(f"{year}: {exc}") + if missing: + raise RuntimeError( + "현재 로직 전표 행 캐시가 아직 준비되지 않아 조회 캐시 작업을 등록하지 않았습니다. " + "기존 projection을 재사용하면 새 로직이 반영되지 않습니다. " + + " / ".join(missing) + ) + finally: + conn.close() + + +def _run_process_cost_bootstrap_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 {} + source = normalize_text(params.get("source")) or "hanmac" + start_year = parse_optional_year(params.get("start_year") or job.get("start_year")) + end_year = parse_optional_year(params.get("end_year") or job.get("end_year")) + code = normalize_text(params.get("code")) + include_related = bool(params.get("include_related")) + active_related = normalize_text(params.get("active_related")) + _update_system_job( + job_id, + message="프로젝트 원가 데이터를 서버에서 계산 중입니다.", + progress_current=0, + progress_total=1, + ) + payload = _build_process_cost_bootstrap_payload_uncached( + source, + start_year, + end_year, + code, + include_related, + active_related, + ) + cache_params = _process_cost_bootstrap_cache_params( + source, + start_year, + end_year, + code, + include_related, + active_related, + ) + cache_key = _process_cost_bootstrap_cache_key( + source, + start_year, + end_year, + code, + include_related, + active_related, + ) + row_count = len(payload.get("projects") or []) + _store_system_page_cache( + "process_cost_bootstrap", + cache_key, + params=cache_params, + payload=payload, + row_count=row_count, + signature=f"process-cost-bootstrap-v1|{cache_key}", + ) + memory_key = ( + cache_params["source"], + cache_params["start_year"], + cache_params["end_year"], + cache_params["code"], + cache_params["include_related"], + cache_params["active_related"], + ) + _set_runtime_cache_entry(_PROCESS_COST_PROJECT_DETAIL_CACHE, memory_key, payload) + return { + "source": cache_params["source"], + "start_year": payload.get("selectedStartYear"), + "end_year": payload.get("selectedEndYear"), + "code": payload.get("selectedCode"), + "project_count": row_count, + "cache_key": cache_key, + } + + +def _run_dashboard_bootstrap_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 {} + overview_year = parse_optional_year(params.get("overview_year") or job.get("start_year")) + _update_system_job( + job_id, + message="대시보드 데이터를 서버에서 계산 중입니다.", + progress_current=0, + progress_total=1, + ) + payload = { + "yearly_summary": get_yearly_summary(), + "monthly_summary": get_monthly_summary(), + "project_revenue_mix_yearly": get_project_revenue_mix(), + "project_revenue_mix_monthly": get_project_revenue_mix_monthly(), + "overview_selected_year": overview_year, + } + cache_params = {"overview_year": overview_year or 0} + cache_key = _json_hash(cache_params) + row_count = sum( + len(payload.get(key) or []) + for key in ( + "yearly_summary", + "monthly_summary", + "project_revenue_mix_yearly", + "project_revenue_mix_monthly", + ) + ) + _store_system_page_cache( + "dashboard_bootstrap", + cache_key, + params=cache_params, + payload=payload, + row_count=row_count, + signature=f"dashboard-bootstrap-v1|{cache_key}", + ) + _set_deepcopy_ttl_cache_entry( + _DASHBOARD_BOOTSTRAP_CACHE, + _DASHBOARD_BOOTSTRAP_CACHE_LOCK, + (overview_year or 0,), + payload, + ) + return { + "overview_year": overview_year, + "row_count": row_count, + "cache_key": cache_key, + } + + +def _run_annual_summary_bootstrap_job(job: dict[str, Any]) -> dict[str, Any]: + job_id = str(job.get("id") or "") + _update_system_job( + job_id, + message="연도별 수익/비용 데이터를 서버에서 계산 중입니다.", + progress_current=0, + progress_total=1, + ) + payload = { + "yearly_financial_series": get_financial_series("yearly"), + "monthly_financial_series": get_financial_series("monthly"), + } + cache_params = {"scope": "annual-summary"} + cache_key = _json_hash(cache_params) + row_count = len(payload.get("yearly_financial_series") or []) + len(payload.get("monthly_financial_series") or []) + _store_system_page_cache( + "annual_summary_bootstrap", + cache_key, + params=cache_params, + payload=payload, + row_count=row_count, + signature=f"annual-summary-bootstrap-v1|{cache_key}", + ) + _set_deepcopy_ttl_cache_entry( + _ANNUAL_SUMMARY_BOOTSTRAP_CACHE, + _ANNUAL_SUMMARY_BOOTSTRAP_CACHE_LOCK, + ("annual-summary",), + payload, + ) + return { + "row_count": row_count, + "cache_key": cache_key, + } + + +def _run_projects_bootstrap_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 {} + selected_year = parse_optional_year(params.get("selected_year") or job.get("start_year")) + _update_system_job( + job_id, + message="프로젝트 정보 데이터를 서버에서 계산 중입니다.", + progress_current=0, + progress_total=1, + ) + payload = { + "revenue_mix": get_project_revenue_mix(selected_year), + "project_cost_by_year": get_project_cost_by_year(None, all_years=True), + "project_status_rows": get_project_status_search_rows(), + } + cache_scope = "all-project-cost-v3-slim-status" + cache_params = {"selected_year": selected_year or 0, "project_cost_scope": cache_scope} + cache_key = _json_hash(cache_params) + row_count = sum( + len(payload.get(key) or []) + for key in ("revenue_mix", "project_cost_by_year", "project_status_rows") + ) + _store_system_page_cache( + "projects_bootstrap", + cache_key, + params=cache_params, + payload=payload, + row_count=row_count, + signature=f"projects-bootstrap-v1|{cache_key}", + ) + _set_deepcopy_ttl_cache_entry( + _PROJECT_BOOTSTRAP_CACHE, + _PROJECT_BOOTSTRAP_CACHE_LOCK, + (selected_year or 0, cache_scope), + payload, + ) + return { + "selected_year": selected_year, + "row_count": row_count, + "cache_key": cache_key, + } + + +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 {} + cache_key = _hanmac_preview_cache_key(params) + _update_system_job( + job_id, + message="hanmac 원본 테이블 미리보기 캐시를 서버에서 계산 중입니다.", + progress_current=0, + progress_total=1, + ) + payload = get_hanmac_table_preview(params) + _store_hanmac_preview_cache(cache_key, payload) + return { + "schema": payload.get("schema"), + "table": payload.get("table"), + "row_count": len(payload.get("rows") or []), + "cache_key": cache_key, + } + + +def _run_hanmac_aggregate_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 {} + cache_key = _hanmac_aggregate_cache_key(params) + _update_system_job( + job_id, + message="hanmac 근태 집계 캐시를 서버에서 계산 중입니다.", + progress_current=0, + progress_total=1, + ) + payload = get_hanmac_aggregate_summary(params) + _store_hanmac_aggregate_cache(cache_key, payload) + return { + "view": payload.get("view"), + "start_date": payload.get("start_date"), + "end_date": payload.get("end_date"), + "row_count": len(payload.get("rows") or []), + "cache_key": cache_key, + } + + +def _run_system_job(job: dict[str, Any]) -> None: + job_id = str(job.get("id") or "") + job_type = normalize_text(job.get("job_type")) + try: + if job_type == "wehago_compare_project_range": + result = _run_wehago_compare_project_range_job(job) + elif job_type == "process_cost_bootstrap": + result = _run_process_cost_bootstrap_job(job) + elif job_type == "dashboard_bootstrap": + result = _run_dashboard_bootstrap_job(job) + elif job_type == "annual_summary_bootstrap": + result = _run_annual_summary_bootstrap_job(job) + elif job_type == "projects_bootstrap": + result = _run_projects_bootstrap_job(job) + elif job_type == "hanmac_preview_cache": + result = _run_hanmac_preview_cache_job(job) + elif job_type == "hanmac_aggregate_cache": + result = _run_hanmac_aggregate_cache_job(job) + else: + raise ValueError(f"지원하지 않는 작업 종류입니다: {job_type}") + _update_system_job( + job_id, + status="done", + message="작업이 완료되었습니다.", + progress_current=1, + progress_total=1, + result=result, + error_message="", + finished=True, + ) + except Exception as exc: + logger.exception("system job failed(%s): %s", job_id, exc) + _update_system_job( + job_id, + status="failed", + message="작업이 실패했습니다.", + error_message=str(exc), + finished=True, + ) + + +def _system_job_worker_loop() -> None: + while True: + try: + job = _claim_next_system_job() + if job: + _run_system_job(job) + continue + except Exception as exc: + logger.warning("system job worker skipped due to error: %s", exc) + _SYSTEM_JOB_EVENT.wait(2.0) + _SYSTEM_JOB_EVENT.clear() + + +def _ensure_system_job_worker() -> None: + global _SYSTEM_JOB_WORKER_STARTED, _SYSTEM_JOB_WORKER_THREAD + with _SYSTEM_JOB_WORKER_LOCK: + if _SYSTEM_JOB_WORKER_STARTED and _SYSTEM_JOB_WORKER_THREAD and _SYSTEM_JOB_WORKER_THREAD.is_alive(): + return + worker = threading.Thread( + target=_system_job_worker_loop, + daemon=True, + name="system-job-worker", + ) + worker.start() + _SYSTEM_JOB_WORKER_THREAD = worker + _SYSTEM_JOB_WORKER_STARTED = True + + def get_hanmac_aggregate_summary(payload: dict[str, Any]) -> dict[str, Any]: global _HANMAC_LAST_AGGREGATE_DIAGNOSTICS - schema_name = "hanmac_manhour" + schema_name = HANMAC_PRIMARY_MANHOUR_SCHEMA start_date, end_date = _hanmac_resolve_period(payload) employment_filter = normalize_text(payload.get("employment")) or "all" + if employment_filter == "current": + employment_filter = "active" + if employment_filter == "period": + employment_filter = "all" view_mode = normalize_text(payload.get("view")) or "member" + restored_center_member_keys = _hanmac_normalize_member_restore_keys(payload) + configured_holiday_dates = _load_hanmac_holiday_dates(start_date, end_date) connect_payload = dict(payload) connect_payload["database"] = schema_name test_engine = _build_hanmac_mysql_engine(connect_payload) @@ -10278,49 +16762,158 @@ def get_hanmac_aggregate_summary(payload: dict[str, Any]) -> dict[str, Any]: "tardy_columns": [], "tardy_candidate_rows": 0, "leave_matched_rows": 0, + "leave_flexible_work_excluded_rows": 0, "leave_types": [], + "leave_candidate_tables": [], + "leave_source_stats": [], + "leave_rules": [], + "addwork_columns": [], + "addwork_member_col": "", + "addwork_date_col": "", + "addwork_project_col": "", + "addwork_hour_col": "", + "addwork_min_col": "", + "addwork_source_rows": 0, + "addwork_parsed_rows": 0, + "addwork_zero_hour_rows": 0, + "addwork_weekday_threshold_filtered_rows": 0, + "addwork_holiday_threshold_filtered_rows": 0, + "addwork_holiday_rows": 0, + "addwork_inactive_filtered_rows": 0, + "addwork_member_filtered_rows": 0, + "official_overtime_rows": 0, + "official_overtime_hours": 0.0, + "official_overtime_tables": [], + "addwork_overridden_by_official_rows": 0, "member_columns": [], "member_name_col": "", "member_name_fallback_rows": 0, + "canonical_member_no_rows": 0, + "member_group_col": "", + "dept_source_table": "", + "dept_code_col": "", + "dept_name_col": "", + "dept_mapped_rows": 0, + "center_schema_available": False, + "center_member_count": 0, + "center_duplicate_member_count": 0, + "center_same_member_hidden_count": 0, + "center_excluded_member_count": 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_text_matched_rows": 0, + "joint_assignment_regular_rows": 0, + "joint_assignment_overtime_rows": 0, + "joint_assignment_leave_skipped_days": 0, } - member_info: dict[str, dict[str, Any]] = {} - member_columns = metadata.get("member_tbl") or [] - source_diagnostics["member_columns"] = member_columns - member_no_col = _hanmac_find_column(member_columns, ["MemberNo", "member_no"]) - if member_no_col: - member_name_col = _hanmac_find_column(member_columns, ["Name", "MemberName", "member_name", "UserName", "KorName", "MemberNm", "member_nm", "EmpName", "emp_name", "UserNM", "user_nm", "KorNm", "kor_nm", "KoreanName", "DisplayName"]) - source_diagnostics["member_name_col"] = member_name_col or "" - entry_date_col = _hanmac_find_column(member_columns, ["EntryDate", "entry_date", "HireDate", "JoinDate", "InDate"]) - 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"]) - member_rows = connection.execute( - text( - f""" - SELECT - {_hanmac_build_select_alias(member_no_col, "member_no")}, - {_hanmac_build_select_alias(member_name_col, "member_name")}, - {_hanmac_build_select_alias(entry_date_col, "entry_date")}, - {_hanmac_build_select_alias(leave_date_col, "leave_date")}, - {_hanmac_build_select_alias(dept_name_col, "dept_name")} - FROM `{schema_name}`.`member_tbl` - """ - ) - ).mappings().all() - for row in member_rows: - member_no = normalize_text(row.get("member_no")) - if not member_no: - continue - member_name = normalize_text(row.get("member_name")) or member_no - if member_name == member_no: - source_diagnostics["member_name_fallback_rows"] += 1 - member_info[member_no] = { - "member_no": member_no, - "member_name": member_name, - "entry_date": _hanmac_parse_date_value(row.get("entry_date")), - "leave_date": _hanmac_parse_date_value(row.get("leave_date")), - "dept_name": normalize_text(row.get("dept_name")) or "", + member_info, member_diagnostics = _hanmac_load_member_info(connection, schema_name, metadata) + member_info_by_key = { + _hanmac_normalize_member_token(member_no): record + for member_no, record in member_info.items() + } + 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"): + 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 = {} + + primary_member_nos = set(member_info_by_key) + primary_member_no_by_key = { + _hanmac_normalize_member_token(member_no): member_no + for member_no in member_info + } + primary_member_names = { + _hanmac_normalize_person_name(record.get("member_name")): member_no + for member_no, record in member_info.items() + if _hanmac_normalize_person_name(record.get("member_name")) + } + 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")) + matched_member_no = "" + matched_by = "" + if center_no_key and center_no_key in primary_member_nos: + matched_member_no = primary_member_no_by_key[center_no_key] + matched_by = "사번" + elif center_name_key and center_name_key in primary_member_names: + matched_member_no = primary_member_names[center_name_key] + matched_by = "이름" + if not matched_member_no: + display_key = f"center:{center_no_key}" + center_member_rows_by_key[display_key] = { + "member_no": "", + "center_member_no": center_no, + "center_member_nos": [center_no], + "member_name": center_record.get("member_name") or center_no, + "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": "센터/총괄 전용", + "matched_by": "", + "source_schema": HANMAC_CENTER_MANHOUR_SCHEMA, + "table_label": "센터/총괄", + "restored": False, + "can_restore": False, } + continue + member_key = _hanmac_normalize_member_token(matched_member_no) + 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_excluded_member_nos.add(member_key) + display_key = f"member:{member_key}" + existing_center_row = center_member_rows_by_key.get(display_key) + if existing_center_row: + if center_no not in existing_center_row["center_member_nos"]: + 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 "기본 제외" + if existing_center_row["matched_by"] != matched_by: + existing_center_row["matched_by"] = "사번/이름" + continue + center_member_rows_by_key[display_key] = { + "member_no": matched_member_no, + "center_member_no": center_no, + "center_member_nos": [center_no], + "member_name": center_record.get("member_name") or matched_member_no, + "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 "기본 제외", + "matched_by": matched_by, + "source_schema": HANMAC_CENTER_MANHOUR_SCHEMA, + "table_label": "센터/총괄", + "restored": restored, + "can_restore": True, + } + center_member_rows = sorted( + center_member_rows_by_key.values(), + key=lambda item: (item.get("member_name") or "", item.get("member_no") or item.get("center_member_no") or ""), + ) + source_diagnostics["center_member_count"] = len(center_member_info) + source_diagnostics["center_duplicate_member_count"] = len(center_member_rows) + source_diagnostics["center_excluded_member_count"] = len(center_excluded_member_nos) + source_diagnostics["center_restored_member_count"] = sum(1 for item in center_member_rows if item.get("restored")) project_map: dict[str, str] = {} project_code_alias_groups: list[dict[str, Any]] = [] @@ -10357,6 +16950,15 @@ def get_hanmac_aggregate_summary(payload: dict[str, Any]) -> dict[str, Any]: } ) + joint_assignment_records, joint_assignment_diagnostics = _hanmac_load_joint_assignment_records( + connection, + schema_name, + metadata, + start_date, + end_date, + ) + source_diagnostics.update(joint_assignment_diagnostics) + regular_tables = [ table_name for table_name, columns in metadata.items() @@ -10369,6 +16971,7 @@ def get_hanmac_aggregate_summary(payload: dict[str, Any]) -> dict[str, Any]: regular_tables = ["dallyproject_tbl"] regular_records: list[dict[str, Any]] = [] + official_overtime_records: list[dict[str, Any]] = [] for table_name in regular_tables: columns = metadata.get(table_name) or [] member_col = _hanmac_find_column(columns, ["MemberNo", "member_no"]) @@ -10377,13 +16980,19 @@ def get_hanmac_aggregate_summary(payload: dict[str, Any]) -> dict[str, Any]: leave_col = _hanmac_find_column(columns, ["LeaveTime", "leave_time"]) work_time_col = _hanmac_find_column(columns, ["WorkTime", "work_time", "RegularTime", "regular_time"]) holiday_time_col = _hanmac_find_column(columns, ["HolidayTime", "holiday_time", "HolidayWorkTime", "holiday_work_time"]) + overtime_time_col = _hanmac_find_column(columns, ["OverTime", "over_time", "OverWorkTime", "overtime", "overtime_hour", "OverHour"]) source_diagnostics["regular_tables"].append( { "table": table_name, "work_time_col": work_time_col or "", "holiday_time_col": holiday_time_col or "", + "overtime_time_col": overtime_time_col or "", } ) + if overtime_time_col: + source_diagnostics["official_overtime_tables"].append( + {"table": table_name, "overtime_time_col": overtime_time_col} + ) if not member_col or not entry_col: continue where_clauses = [f"`{member_col}` IS NOT NULL"] @@ -10403,7 +17012,8 @@ def get_hanmac_aggregate_summary(payload: dict[str, Any]) -> dict[str, Any]: {_hanmac_build_select_alias(entry_col, "entry_time")}, {_hanmac_build_select_alias(leave_col, "leave_time")}, {_hanmac_build_select_alias(work_time_col, "work_time")}, - {_hanmac_build_select_alias(holiday_time_col, "holiday_time")} + {_hanmac_build_select_alias(holiday_time_col, "holiday_time")}, + {_hanmac_build_select_alias(overtime_time_col, "overtime_time")} FROM `{schema_name}`.`{table_name}` WHERE {' AND '.join(where_clauses)} """ @@ -10416,6 +17026,9 @@ def get_hanmac_aggregate_summary(payload: dict[str, Any]) -> dict[str, Any]: continue work_time_hours = _hanmac_parse_duration_hours(row.get("work_time")) 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")) 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) @@ -10424,19 +17037,43 @@ def get_hanmac_aggregate_summary(payload: dict[str, Any]) -> dict[str, Any]: "member_no": member_no, "project_code": normalize_text(row.get("project_code")) or "", "work_date": _hanmac_parse_date_value(row.get("entry_time")), - "regular_hours": work_time_hours if work_time_hours > 0 else (0.0 if holiday_time_hours > 0 else _hanmac_calculate_regular_hours(row.get("entry_time"), row.get("leave_time"))), + "entry_time": _hanmac_parse_datetime_value(row.get("entry_time")), + "leave_time": _hanmac_parse_datetime_value(row.get("leave_time")), + "regular_hours": 0.0 if holiday_time_hours > 0 else regular_hours, "holiday_hours": holiday_time_hours, + "source_label": "", } ) + if official_overtime_hours > 0: + official_overtime_records.append( + { + "member_no": member_no, + "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_diagnostics["official_overtime_rows"] += 1 + source_diagnostics["official_overtime_hours"] = round( + source_diagnostics["official_overtime_hours"] + official_overtime_hours, + 2, + ) addwork_columns = metadata.get("dallyproject_addwork_tbl") or [] overtime_records: list[dict[str, Any]] = [] - addwork_member_col = _hanmac_find_column(addwork_columns, ["MemberNo", "member_no"]) - addwork_date_col = _hanmac_find_column(addwork_columns, ["EntryTime", "entry_time", "work_date"]) + source_diagnostics["addwork_columns"] = addwork_columns + addwork_member_col = _hanmac_find_column(addwork_columns, ["MemberNo", "member_no", "EmpNo", "UserID", "MemberID", "member_id"]) + addwork_date_col = _hanmac_find_column(addwork_columns, ["EntryTime", "entry_time", "WorkDate", "work_date", "EntryDate", "entry_date", "Date", "date", "AddWorkDate", "addwork_date", "RegDate", "reg_date", "s_date", "SDate"]) if addwork_member_col and addwork_date_col: - addwork_project_col = _hanmac_find_column(addwork_columns, ["new_project_code", "project_code", "ProjectCode", "ProjectKey", "PCode"]) - addwork_hour_col = _hanmac_find_column(addwork_columns, ["work_hour", "WorkHour"]) - addwork_min_col = _hanmac_find_column(addwork_columns, ["work_min", "WorkMin"]) + addwork_project_col = _hanmac_find_column(addwork_columns, ["new_project_code", "project_code", "ProjectCode", "ProjectKey", "PCode", "EntryPCode"]) + addwork_hour_col = _hanmac_find_column(addwork_columns, ["work_hour", "WorkHour", "OverTime", "OverWorkTime", "overtime", "overtime_hour", "OverHour", "AddWorkHour", "addwork_hour", "WorkTime", "work_time"]) + addwork_min_col = _hanmac_find_column(addwork_columns, ["work_min", "WorkMin", "OverMin", "OverMinute", "overtime_min", "AddWorkMin", "addwork_min"]) + source_diagnostics["addwork_member_col"] = addwork_member_col or "" + source_diagnostics["addwork_date_col"] = addwork_date_col or "" + source_diagnostics["addwork_project_col"] = addwork_project_col or "" + source_diagnostics["addwork_hour_col"] = addwork_hour_col or "" + source_diagnostics["addwork_min_col"] = addwork_min_col or "" where_clauses = [f"`{addwork_member_col}` IS NOT NULL"] params = {} if addwork_date_col and start_date: @@ -10460,31 +17097,84 @@ def get_hanmac_aggregate_summary(payload: dict[str, Any]) -> dict[str, Any]: ), params, ).mappings().all() + source_diagnostics["addwork_source_rows"] = len(addwork_rows) for row in addwork_rows: member_no = normalize_text(row.get("member_no")) if not member_no: continue - overtime_hours = round(_hanmac_parse_float_value(row.get("work_hour")) + (_hanmac_parse_float_value(row.get("work_min")) / 60.0), 2) + overtime_hours = round(_hanmac_parse_hour_minute_fields(row.get("work_hour"), row.get("work_min")), 2) + if overtime_hours <= 0: + source_diagnostics["addwork_zero_hour_rows"] += 1 + source_diagnostics["addwork_parsed_rows"] += 1 overtime_records.append( { "member_no": member_no, "project_code": normalize_text(row.get("project_code")) or "", "work_date": _hanmac_parse_date_value(row.get("work_date")), "overtime_hours": overtime_hours, + "source": "dallyproject_addwork_tbl", } ) + official_overtime_keys = { + (_hanmac_normalize_member_token(row["member_no"]), row.get("work_date")) + for row in official_overtime_records + if row.get("work_date") + } + fallback_overtime_records = [] + for row in overtime_records: + row_key = (_hanmac_normalize_member_token(row["member_no"]), row.get("work_date")) + if row_key in official_overtime_keys: + source_diagnostics["addwork_overridden_by_official_rows"] += 1 + continue + fallback_overtime_records.append(row) + overtime_records = [*official_overtime_records, *fallback_overtime_records] + leave_records: list[dict[str, Any]] = [] + leave_rules = get_hanmac_leave_rules() + source_diagnostics["leave_rules"] = [ + { + "keyword": rule.get("keyword"), + "label": rule.get("leave_label"), + "rule_type": rule.get("rule_type"), + "default_hours": rule.get("default_hours"), + } + for rule in leave_rules + ] tardy_columns = metadata.get("worker_tardy_tbl") or [] source_diagnostics["tardy_columns"] = tardy_columns - tardy_member_col = _hanmac_find_column(tardy_columns, ["MemberNo", "member_no", "EmpNo", "UserID"]) - if tardy_member_col: - tardy_date_col = _hanmac_find_column(tardy_columns, ["work_date", "WorkDate", "EntryDate", "Date", "TardyDate", "s_date", "SDate", "StartDate", "start_date"]) - tardy_end_date_col = _hanmac_find_column(tardy_columns, ["e_date", "EDate", "EndDate", "end_date"]) - tardy_type_col = _hanmac_find_column(tardy_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", "memo", "Memo", "remark", "Remark"]) - tardy_value_col = _hanmac_find_column(tardy_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"]) - tardy_hour_col = _hanmac_find_column(tardy_columns, ["tardy_h", "TardyH", "tardy_hour", "TardyHour"]) - tardy_min_col = _hanmac_find_column(tardy_columns, ["tardy_m", "TardyM", "tardy_min", "TardyMin"]) + leave_source_profiles = [ + profile + for table_name, table_columns in metadata.items() + if (profile := _hanmac_leave_source_profile(table_name, table_columns)) + ] + source_diagnostics["leave_candidate_tables"] = [ + { + "table": profile["table"], + "member_col": profile["member_col"], + "date_col": profile["date_col"], + "project_col": profile["project_col"], + "type_cols": profile["type_cols"], + } + for profile in leave_source_profiles + ] + leave_type_values: set[str] = set() + flexible_work_keywords = ("탄력", "단축근무", "근무시간조정", "출근시간조정", "유연근무") + for leave_profile in leave_source_profiles: + leave_table = leave_profile["table"] + leave_source_stat = { + "table": leave_table, + "candidate_rows": 0, + "matched_rows": 0, + "flexible_work_excluded_rows": 0, + } + tardy_member_col = leave_profile["member_col"] + tardy_date_col = leave_profile["date_col"] + tardy_end_date_col = leave_profile["end_date_col"] + tardy_project_col = leave_profile["project_col"] + tardy_value_col = leave_profile["value_col"] + tardy_hour_col = leave_profile["hour_col"] + tardy_min_col = leave_profile["min_col"] where_clauses = [f"`{tardy_member_col}` IS NOT NULL"] params = {} if tardy_date_col and start_date: @@ -10503,34 +17193,34 @@ def get_hanmac_aggregate_summary(payload: dict[str, Any]) -> dict[str, Any]: {_hanmac_build_select_alias(tardy_member_col, "member_no")}, {_hanmac_build_select_alias(tardy_date_col, "work_date")}, {_hanmac_build_select_alias(tardy_end_date_col, "end_date")}, - {_hanmac_build_select_alias(tardy_type_col, "leave_type")}, + {_hanmac_build_select_alias(tardy_project_col, "project_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")}, {_hanmac_build_select_alias(tardy_min_col, "leave_min")} - FROM `{schema_name}`.`worker_tardy_tbl` + FROM `{schema_name}`.`{leave_table}` WHERE {' AND '.join(where_clauses)} """ ), params, ).mappings().all() - source_diagnostics["tardy_candidate_rows"] = len(tardy_rows) - leave_keywords = ("연차", "휴가", "휴직", "반차", "공가", "병가", "출산", "육아", "대체", "대휴", "보상", "휴일", "leave", "vacation", "holiday") - leave_type_values: set[str] = set() + source_diagnostics["tardy_candidate_rows"] += len(tardy_rows) + leave_source_stat["candidate_rows"] = len(tardy_rows) for row in tardy_rows: member_no = normalize_text(row.get("member_no")) leave_type = normalize_text(row.get("leave_type")) if not member_no or not leave_type: continue - if not any(keyword in leave_type.lower() for keyword in leave_keywords): + if any(keyword in leave_type.lower() for keyword in flexible_work_keywords): + 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 not leave_rule: continue source_diagnostics["leave_matched_rows"] += 1 + leave_source_stat["matched_rows"] += 1 leave_type_values.add(leave_type) - leave_amount = _hanmac_parse_float_value(row.get("leave_value")) - leave_hours = _hanmac_parse_float_value(row.get("leave_hour")) + (_hanmac_parse_float_value(row.get("leave_min")) / 60.0) - if leave_amount <= 0 and leave_hours > 0: - leave_amount = round(leave_hours / 8.0, 2) - if leave_amount <= 0: - leave_amount = 0.0 record_start = _hanmac_parse_date_value(row.get("work_date")) record_end = _hanmac_parse_date_value(row.get("end_date")) or record_start if not record_start: @@ -10543,8 +17233,18 @@ def get_hanmac_aggregate_summary(payload: dict[str, Any]) -> dict[str, Any]: if clipped_end < clipped_start: continue clipped_dates = _hanmac_iter_dates(clipped_start, clipped_end) - if leave_amount <= 0: - leave_amount = float(len(full_dates)) + embedded_leave_hours = _hanmac_extract_leave_hours_from_text(leave_type) + explicit_leave_hours = row.get("leave_hour") + if _hanmac_parse_float_value(explicit_leave_hours) <= 0 and embedded_leave_hours > 0: + explicit_leave_hours = embedded_leave_hours + leave_amount, leave_hours, leave_hours_source = _hanmac_calculate_leave_amounts( + leave_type=leave_type, + leave_value=row.get("leave_value"), + leave_hour=explicit_leave_hours, + leave_min=row.get("leave_min"), + rule=leave_rule, + full_date_count=len(full_dates), + ) daily_leave_amount = leave_amount / max(len(full_dates), 1) daily_leave_hours = leave_hours / max(len(full_dates), 1) if leave_hours > 0 else daily_leave_amount * 8.0 for leave_date in clipped_dates: @@ -10553,12 +17253,17 @@ def get_hanmac_aggregate_summary(payload: dict[str, Any]) -> dict[str, Any]: "member_no": member_no, "work_date": leave_date, "end_date": leave_date, + "project_code": normalize_text(row.get("project_code")) or "", "leave_days": round(daily_leave_amount, 4), "leave_hours": round(daily_leave_hours, 4), "leave_type": leave_type, + "leave_rule": leave_rule.get("leave_label") or leave_rule.get("keyword") or "", + "leave_hours_source": leave_hours_source, + "leave_source_table": leave_table, } ) - source_diagnostics["leave_types"] = sorted(leave_type_values) + source_diagnostics["leave_source_stats"].append(leave_source_stat) + source_diagnostics["leave_types"] = sorted(leave_type_values) project_relation_maps = _hanmac_build_project_code_relation_maps(project_code_alias_groups) project_canonical_map: dict[str, str] = project_relation_maps["canonical_map"] @@ -10591,28 +17296,83 @@ def get_hanmac_aggregate_summary(payload: dict[str, Any]) -> dict[str, Any]: project_aggregates: dict[str, dict[str, Any]] = {} leave_days_by_member_date: dict[tuple[str, date], float] = {} - def include_member(member_no: str) -> bool: - member_record = member_info.get(member_no) or { + 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 { "member_no": member_no, "member_name": member_no, "entry_date": None, "leave_date": None, "dept_name": "", } + + def canonical_member_no(member_no: Any) -> str: + raw_member_no = normalize_text(member_no) + member_record = member_info_by_key.get(_hanmac_normalize_member_token(raw_member_no)) + canonical_no = normalize_text((member_record or {}).get("member_no")) or raw_member_no + if raw_member_no and canonical_no and raw_member_no != canonical_no: + source_diagnostics["canonical_member_no_rows"] += 1 + return canonical_no + + 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 return _hanmac_member_matches_filter(member_record, employment_filter, start_date, end_date, today) + joint_member_map: dict[str, dict[str, Any]] = {} + for record in joint_assignment_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") + if not _hanmac_member_is_active_on(member_record, record_start): + continue + project_code = canonical_project_code(record.get("project_code")) + content_parts = [ + f"{record_start.isoformat() if record_start else ''}" + + ( + f"~{record.get('end_date').isoformat()}" + if record.get("end_date") and record.get("end_date") != record_start + else "" + ), + project_display_name(project_code), + project_code, + normalize_text(record.get("note")), + ] + content = " / ".join(part for part in content_parts if part) + bucket = joint_member_map.setdefault( + member_no, + { + "member_no": member_no, + "member_name": member_record.get("member_name") or member_no, + "member_grade": member_record.get("member_grade") or "", + "entry_date": member_record["entry_date"].isoformat() if member_record.get("entry_date") else "", + "leave_date": member_record["leave_date"].isoformat() if member_record.get("leave_date") else "", + "contents": [], + "info_count": 0, + }, + ) + if content and content not in bucket["contents"]: + bucket["contents"].append(content) + bucket["info_count"] += 1 + joint_members = sorted( + ( + { + **item, + "content": "\n".join(item.pop("contents", [])), + } + for item in joint_member_map.values() + ), + key=lambda item: (-int(item.get("info_count") or 0), item.get("member_no") or ""), + ) + for row in leave_records: - member_no = row["member_no"] + member_no = canonical_member_no(row["member_no"]) work_date = row.get("work_date") if not include_member(member_no): continue - member_record = member_info.get(member_no) or { - "member_no": member_no, - "member_name": member_no, - "entry_date": None, - "leave_date": None, - "dept_name": "", - } + member_record = get_member_record(member_no) if not _hanmac_member_is_active_on(member_record, work_date): continue if work_date: @@ -10622,14 +17382,61 @@ def get_hanmac_aggregate_summary(payload: dict[str, Any]) -> dict[str, Any]: 2, ) + for record in joint_assignment_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_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) + if regular_hours <= 0: + source_diagnostics["joint_assignment_leave_skipped_days"] += 1 + continue + regular_records.append( + { + "member_no": member_no, + "project_code": normalize_text(record.get("project_code")) or "", + "work_date": work_date, + "entry_time": None, + "leave_time": None, + "regular_hours": regular_hours, + "holiday_hours": 0.0, + "source_label": "합사", + "joint_label": record.get("joint_label") or "합사", + "joint_code": record.get("joint_code") or "", + "note": record.get("note") or "", + } + ) + source_diagnostics["joint_assignment_regular_rows"] += 1 + if leave_days <= 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, + "source": "합사", + "raw_overtime_hours": 3.0, + "joint_label": record.get("joint_label") or "합사", + "joint_code": record.get("joint_code") or "", + "note": record.get("note") or "", + } + ) + source_diagnostics["joint_assignment_overtime_rows"] += 1 + def ensure_member_bucket(member_no: str) -> dict[str, Any]: - member_record = member_info.get(member_no) or { - "member_no": member_no, - "member_name": member_no, - "entry_date": None, - "leave_date": None, - "dept_name": "", - } + member_record = get_member_record(member_no) return member_aggregates.setdefault( member_no, { @@ -10639,6 +17446,7 @@ def get_hanmac_aggregate_summary(payload: dict[str, Any]) -> dict[str, Any]: "entry_date": member_record["entry_date"].isoformat() if member_record["entry_date"] else "", "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", ""), "regular_hours": 0.0, "overtime_hours": 0.0, "holiday_hours": 0.0, @@ -10681,16 +17489,10 @@ def get_hanmac_aggregate_summary(payload: dict[str, Any]) -> dict[str, Any]: regular_day_groups: dict[tuple[str, date], dict[str, Any]] = {} for row in regular_records: - member_no = row["member_no"] + member_no = canonical_member_no(row["member_no"]) if not include_member(member_no): continue - member_record = member_info.get(member_no) or { - "member_no": member_no, - "member_name": member_no, - "entry_date": None, - "leave_date": None, - "dept_name": "", - } + member_record = get_member_record(member_no) work_date = row.get("work_date") if not work_date or not _hanmac_member_is_active_on(member_record, work_date): continue @@ -10702,13 +17504,16 @@ def get_hanmac_aggregate_summary(payload: dict[str, Any]) -> dict[str, Any]: "work_date": work_date, "project_hours": {}, "holiday_project_hours": {}, + "project_source_labels": {}, "entries": [], + "ordered_entries": [], }, ) raw_project_code = row["project_code"] project_code = canonical_project_code(raw_project_code) 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")) day_group["project_hours"][project_code] = round( day_group["project_hours"].get(project_code, 0.0) + raw_hours, 4, @@ -10717,6 +17522,8 @@ def get_hanmac_aggregate_summary(payload: dict[str, Any]) -> dict[str, Any]: 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) day_group["entries"].append( { "project_code": project_code or "(미지정)", @@ -10725,6 +17532,17 @@ def get_hanmac_aggregate_summary(payload: dict[str, Any]) -> dict[str, Any]: "equivalent_project_codes": equivalent_project_codes(project_code), "regular_hours": round(raw_hours, 2), "holiday_hours": round(raw_holiday_hours, 2), + "source_label": source_label, + } + ) + day_group["ordered_entries"].append( + { + "project_code": project_code, + "entry_time": row.get("entry_time"), + "leave_time": row.get("leave_time"), + "regular_hours": raw_hours, + "holiday_hours": raw_holiday_hours, + "source_label": source_label, } ) @@ -10733,9 +17551,23 @@ def get_hanmac_aggregate_summary(payload: dict[str, Any]) -> dict[str, Any]: 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))) - weekday_cap = 8.0 if work_date.weekday() < 5 else 0.0 - capped_regular_hours = round(min(raw_total_hours, max(0.0, weekday_cap * (1.0 - leave_days))), 2) - holiday_hours = round(raw_holiday_total_hours if raw_holiday_total_hours > 0 else (raw_total_hours if work_date.weekday() >= 5 else 0.0), 2) + 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)) + 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( + capped_regular_hours, + day_group["project_hours"], + ) + holiday_source_hours = ( + day_group["holiday_project_hours"] if raw_holiday_total_hours > 0 else day_group["project_hours"] + ) + allocated_project_holiday_hours = _hanmac_allocate_recognized_hours( + holiday_hours, + holiday_source_hours, + ) member_bucket["regular_hours"] += capped_regular_hours member_bucket["holiday_hours"] += holiday_hours @@ -10754,6 +17586,8 @@ def get_hanmac_aggregate_summary(payload: dict[str, Any]) -> dict[str, Any]: "project_name": project_display_name(project_code), "equivalent_project_codes": equivalent_project_codes(project_code), "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()))), } for project_code, project_hours in day_group["project_hours"].items() ], @@ -10766,6 +17600,7 @@ def get_hanmac_aggregate_summary(payload: dict[str, Any]) -> dict[str, Any]: { "work_date": work_date.isoformat(), "holiday_hours": holiday_hours, + "holiday_reason": "주말" if work_date.weekday() >= 5 else ("휴일표" if is_configured_holiday else "HolidayTime"), "projects": sorted( [ { @@ -10773,6 +17608,7 @@ def get_hanmac_aggregate_summary(payload: dict[str, Any]) -> dict[str, Any]: "project_name": project_display_name(project_code), "equivalent_project_codes": equivalent_project_codes(project_code), "hours": round(project_hours, 2), + "recognized_hours": allocated_project_holiday_hours.get(project_code, 0.0), } for project_code, project_hours in ( day_group["holiday_project_hours"].items() @@ -10790,16 +17626,42 @@ def get_hanmac_aggregate_summary(payload: dict[str, Any]) -> dict[str, Any]: member_bucket["project_codes"].add(project_code) if len(day_group["entries"]) > 1: + collapsed_entries: dict[tuple[str, str, str], dict[str, Any]] = {} + for entry in day_group["entries"]: + collapse_key = ( + canonical_project_code(entry.get("project_code")), + normalize_text(entry.get("source_label")), + canonical_project_code(entry.get("raw_project_code") or entry.get("project_code")), + ) + collapsed_entry = collapsed_entries.setdefault( + collapse_key, + { + **entry, + "regular_hours": 0.0, + "holiday_hours": 0.0, + "row_count": 0, + }, + ) + collapsed_entry["regular_hours"] = round( + collapsed_entry["regular_hours"] + float(entry.get("regular_hours") or 0.0), + 2, + ) + collapsed_entry["holiday_hours"] = round( + collapsed_entry["holiday_hours"] + float(entry.get("holiday_hours") or 0.0), + 2, + ) + collapsed_entry["row_count"] += 1 member_bucket["multi_entry_days"] += 1 member_bucket["multi_entry_details"].append( { "work_date": work_date.isoformat(), "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), "entries": sorted( - day_group["entries"], + collapsed_entries.values(), key=lambda item: (-item["regular_hours"], item["project_code"]), ), } @@ -10809,28 +17671,28 @@ def get_hanmac_aggregate_summary(payload: dict[str, Any]) -> dict[str, Any]: continue if holiday_hours > 0: - holiday_source_hours = day_group["holiday_project_hours"] if raw_holiday_total_hours > 0 else day_group["project_hours"] for project_code, project_hours in holiday_source_hours.items(): project_bucket = ensure_project_bucket(project_code) project_hours = round(project_hours, 2) - project_bucket["holiday_hours"] += project_hours - if project_hours > 0: + project_holiday_hours = allocated_project_holiday_hours.get(project_code, 0.0) + project_bucket["holiday_hours"] += project_holiday_hours + if project_holiday_hours > 0: project_bucket["holiday_details"].append( { "work_date": work_date.isoformat(), "member_no": member_no, "member_name": member_record["member_name"], - "holiday_hours": project_hours, + "holiday_hours": round(project_holiday_hours, 2), + "raw_project_hours": project_hours, } ) project_bucket["member_nos"].add(member_no) - if work_date.weekday() >= 5 and raw_holiday_total_hours <= 0: + if is_weekend_or_holiday and raw_holiday_total_hours <= 0: continue for project_code, project_hours in day_group["project_hours"].items(): project_bucket = ensure_project_bucket(project_code) - share_ratio = project_hours / raw_total_hours if raw_total_hours else 0.0 - project_regular_hours = round(capped_regular_hours * share_ratio, 4) + project_regular_hours = round(allocated_project_regular_hours.get(project_code, 0.0), 4) project_bucket["regular_hours"] += project_regular_hours if capped_regular_hours > 0 and project_hours > 0: project_bucket["regular_work_days"] += 1 @@ -10842,80 +17704,219 @@ def get_hanmac_aggregate_summary(payload: dict[str, Any]) -> dict[str, Any]: "regular_hours": round(project_regular_hours, 2), "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()))), } ) project_bucket["member_nos"].add(member_no) + overtime_day_groups: dict[tuple[str, date | None], dict[str, Any]] = {} for row in overtime_records: - member_no = row["member_no"] + member_no = canonical_member_no(row["member_no"]) if not include_member(member_no): + source_diagnostics["addwork_member_filtered_rows"] += 1 continue - raw_project_code = row["project_code"] - project_code = canonical_project_code(raw_project_code) - member_record = member_info.get(member_no) or { - "member_no": member_no, - "member_name": member_no, - "entry_date": None, - "leave_date": None, - "dept_name": "", - } + member_record = get_member_record(member_no) if not _hanmac_member_is_active_on(member_record, row["work_date"]): + source_diagnostics["addwork_inactive_filtered_rows"] += 1 continue - member_bucket = ensure_member_bucket(member_no) - member_bucket["overtime_hours"] += row["overtime_hours"] - if row["overtime_hours"] > 0: - member_bucket["overtime_work_day_keys"].add(row["work_date"]) - member_bucket["overtime_details"].append( - { - "work_date": row["work_date"].isoformat() if row.get("work_date") else "", - "project_code": project_code or "(미지정)", - "project_name": project_display_name(project_code), - "raw_project_code": raw_project_code, - "equivalent_project_codes": equivalent_project_codes(project_code), - "overtime_hours": round(row["overtime_hours"], 2), - } - ) - if project_code: - member_bucket["project_codes"].add(project_code) + group_key = (_hanmac_normalize_member_token(member_no), row.get("work_date")) + day_group = overtime_day_groups.setdefault( + group_key, + {"member_no": member_no, "member_record": member_record, "work_date": row.get("work_date"), "rows": []}, + ) + day_group["rows"].append(row) - project_bucket = ensure_project_bucket(project_code) - project_bucket["overtime_hours"] += row["overtime_hours"] - if row["overtime_hours"] > 0: - project_bucket["overtime_work_day_keys"].add((member_no, row["work_date"])) - project_bucket["overtime_details"].append( - { - "work_date": row["work_date"].isoformat() if row.get("work_date") else "", - "member_no": member_no, - "member_name": member_record["member_name"], - "overtime_hours": round(row["overtime_hours"], 2), - } + for day_group in overtime_day_groups.values(): + member_no = day_group["member_no"] + member_record = day_group["member_record"] + overtime_work_date = day_group["work_date"] + raw_day_overtime_hours = round( + sum(_hanmac_parse_float_value(row.get("overtime_hours")) for row in day_group["rows"]), + 4, + ) + is_overtime_holiday = bool( + overtime_work_date + and (overtime_work_date.weekday() >= 5 or overtime_work_date in configured_holiday_dates) + ) + recognized_day_overtime_hours = 0.0 if is_overtime_holiday else _hanmac_cap_weekday_overtime(raw_day_overtime_hours) + recognized_day_holiday_hours = _hanmac_cap_holiday_hours(raw_day_overtime_hours) if is_overtime_holiday else 0.0 + if is_overtime_holiday: + source_diagnostics["addwork_holiday_rows"] += 1 + if raw_day_overtime_hours > 0 and recognized_day_holiday_hours <= 0: + source_diagnostics["addwork_holiday_threshold_filtered_rows"] += 1 + elif raw_day_overtime_hours > 0 and recognized_day_overtime_hours <= 0: + source_diagnostics["addwork_weekday_threshold_filtered_rows"] += 1 + + overtime_allocation_source = { + row_index: _hanmac_parse_float_value(row.get("overtime_hours")) + for row_index, row in enumerate(day_group["rows"]) + } + allocated_overtime_hours = _hanmac_allocate_recognized_hours( + recognized_day_overtime_hours, + overtime_allocation_source, + ) + allocated_holiday_overtime_hours = _hanmac_allocate_recognized_hours( + recognized_day_holiday_hours, + overtime_allocation_source, + ) + collapsed_overtime_rows: dict[tuple[str, str, str], dict[str, Any]] = {} + for row_index, row in enumerate(day_group["rows"]): + raw_project_code = row["project_code"] + project_code = canonical_project_code(raw_project_code) + raw_overtime_hours = _hanmac_parse_float_value(row.get("raw_overtime_hours", row["overtime_hours"])) + overtime_hours = allocated_overtime_hours.get(row_index, 0.0) + holiday_overtime_hours = allocated_holiday_overtime_hours.get(row_index, 0.0) + collapsed_key = ( + project_code or "(미지정)", + normalize_text(row.get("source")) or "", + normalize_text(row.get("joint_code")) or "", ) - project_bucket["member_nos"].add(member_no) + collapsed_row = collapsed_overtime_rows.setdefault( + collapsed_key, + { + **row, + "project_code": raw_project_code, + "canonical_project_code": project_code, + "overtime_hours": 0.0, + "holiday_overtime_hours": 0.0, + "raw_overtime_hours": 0.0, + }, + ) + collapsed_row["overtime_hours"] += overtime_hours + collapsed_row["holiday_overtime_hours"] += holiday_overtime_hours + collapsed_row["raw_overtime_hours"] += raw_overtime_hours + + for row in collapsed_overtime_rows.values(): + raw_project_code = row["project_code"] + project_code = row.get("canonical_project_code") or canonical_project_code(raw_project_code) + raw_overtime_hours = _hanmac_parse_float_value(row.get("raw_overtime_hours")) + overtime_hours = _hanmac_parse_float_value(row.get("overtime_hours")) + holiday_overtime_hours = _hanmac_parse_float_value(row.get("holiday_overtime_hours")) + + member_bucket = ensure_member_bucket(member_no) + member_bucket["overtime_hours"] += overtime_hours + member_bucket["holiday_hours"] += holiday_overtime_hours + if overtime_hours > 0: + member_bucket["overtime_work_day_keys"].add(overtime_work_date) + member_bucket["overtime_details"].append( + { + "work_date": overtime_work_date.isoformat() if overtime_work_date else "", + "project_code": project_code or "(미지정)", + "project_name": project_display_name(project_code), + "raw_project_code": raw_project_code, + "equivalent_project_codes": equivalent_project_codes(project_code), + "overtime_hours": round(overtime_hours, 2), + "raw_overtime_hours": round(raw_overtime_hours, 2), + "raw_day_overtime_hours": round(raw_day_overtime_hours, 2), + "source": row.get("source") or "", + } + ) + if holiday_overtime_hours > 0: + member_bucket["holiday_details"].append( + { + "work_date": overtime_work_date.isoformat() if overtime_work_date else "", + "holiday_hours": round(holiday_overtime_hours, 2), + "raw_holiday_hours": round(raw_overtime_hours, 2), + "projects": [ + { + "project_code": project_code or "(미지정)", + "project_name": project_display_name(project_code), + "hours": round(holiday_overtime_hours, 2), + } + ], + } + ) + if project_code: + member_bucket["project_codes"].add(project_code) + + project_bucket = ensure_project_bucket(project_code) + project_bucket["overtime_hours"] += overtime_hours + project_bucket["holiday_hours"] += holiday_overtime_hours + if overtime_hours > 0: + project_bucket["overtime_work_day_keys"].add((member_no, overtime_work_date)) + project_bucket["overtime_details"].append( + { + "work_date": overtime_work_date.isoformat() if overtime_work_date else "", + "member_no": member_no, + "member_name": member_record["member_name"], + "overtime_hours": round(overtime_hours, 2), + "raw_overtime_hours": round(raw_overtime_hours, 2), + "raw_day_overtime_hours": round(raw_day_overtime_hours, 2), + "source": row.get("source") or "", + } + ) + if holiday_overtime_hours > 0: + project_bucket["holiday_details"].append( + { + "work_date": overtime_work_date.isoformat() if overtime_work_date else "", + "member_no": member_no, + "member_name": member_record["member_name"], + "holiday_hours": round(holiday_overtime_hours, 2), + "raw_holiday_hours": round(raw_overtime_hours, 2), + } + ) + project_bucket["member_nos"].add(member_no) for row in leave_records: - member_no = row["member_no"] + member_no = canonical_member_no(row["member_no"]) if not include_member(member_no): continue - member_record = member_info.get(member_no) or { - "member_no": member_no, - "member_name": member_no, - "entry_date": None, - "leave_date": None, - "dept_name": "", - } + member_record = get_member_record(member_no) if not _hanmac_member_is_active_on(member_record, row["work_date"]): continue member_bucket = ensure_member_bucket(member_no) member_bucket["legal_leave_days"] += row["leave_days"] member_bucket["legal_leave_hours"] += row.get("leave_hours", row["leave_days"] * 8.0) + leave_project_code = canonical_project_code(row.get("project_code")) member_bucket["leave_details"].append( { "work_date": row["work_date"].isoformat() if row.get("work_date") else "", "leave_type": row.get("leave_type") or "법정휴가", + "leave_rule": row.get("leave_rule") or "", + "leave_hours_source": row.get("leave_hours_source") or "", + "leave_source_table": row.get("leave_source_table") or "", + "project_code": leave_project_code or "", + "project_name": project_display_name(leave_project_code) if leave_project_code else "법정휴가", + "equivalent_project_codes": equivalent_project_codes(leave_project_code) if leave_project_code else [], "leave_days": round(row["leave_days"], 2), "leave_hours": round(row.get("leave_hours", row["leave_days"] * 8.0), 2), } ) + if leave_project_code: + 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) + project_bucket["leave_details"].append( + { + "work_date": row["work_date"].isoformat() if row.get("work_date") else "", + "member_no": member_no, + "member_name": member_record["member_name"], + "leave_type": row.get("leave_type") or "법정휴가", + "leave_rule": row.get("leave_rule") or "", + "leave_hours_source": row.get("leave_hours_source") or "", + "leave_source_table": row.get("leave_source_table") or "", + "project_code": leave_project_code, + "project_name": project_display_name(leave_project_code), + "equivalent_project_codes": equivalent_project_codes(leave_project_code), + "leave_days": round(row["leave_days"], 2), + "leave_hours": round(row.get("leave_hours", row["leave_days"] * 8.0), 2), + } + ) + + for member_no, bucket in member_aggregates.items(): + member_record = get_member_record(member_no) + expected_regular_hours = _hanmac_expected_regular_hours_for_period( + member_record, + start_date, + end_date, + configured_holiday_dates, + ) + expected_after_leave_hours = round(max(0.0, expected_regular_hours - bucket["legal_leave_hours"]), 2) + regular_hour_gap = round(bucket["regular_hours"] - expected_after_leave_hours, 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 if view_mode == "project": rows = [ @@ -10977,6 +17978,7 @@ def get_hanmac_aggregate_summary(payload: dict[str, Any]) -> dict[str, Any]: "entry_date": bucket["entry_date"], "leave_date": bucket["leave_date"], "dept_name": bucket["dept_name"], + "member_grade": bucket.get("member_grade", ""), "regular_hours": round(bucket["regular_hours"], 2), "overtime_hours": round(bucket["overtime_hours"], 2), "holiday_hours": round(bucket["holiday_hours"], 2), @@ -11021,6 +18023,21 @@ def get_hanmac_aggregate_summary(payload: dict[str, Any]) -> dict[str, Any]: ), }, "multi_entry_days": bucket["multi_entry_days"], + "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), + "remarks": " · ".join( + part + for part in ( + f"중복 {bucket['multi_entry_days']}일" if bucket["multi_entry_days"] else "", + ( + f"근무시간 {bucket.get('regular_hour_gap', 0.0):+,.2f}시간" + if abs(bucket.get("regular_hour_gap", 0.0)) >= 0.01 + else "" + ), + ) + if part + ), "multi_entry_details": sorted( bucket["multi_entry_details"], key=lambda item: item["work_date"], @@ -11032,6 +18049,7 @@ def get_hanmac_aggregate_summary(payload: dict[str, Any]) -> dict[str, Any]: columns = [ {"key": "member_no", "label": "사번"}, {"key": "member_name", "label": "이름"}, + {"key": "member_grade", "label": "직급"}, {"key": "status", "label": "구분"}, {"key": "entry_date", "label": "입사일"}, {"key": "leave_date", "label": "퇴사일"}, @@ -11041,6 +18059,7 @@ def get_hanmac_aggregate_summary(payload: dict[str, Any]) -> dict[str, Any]: {"key": "total_hours", "label": "총근로"}, {"key": "legal_leave_days", "label": "법정휴가"}, {"key": "project_count", "label": "프로젝트수"}, + {"key": "remarks", "label": "비고"}, ] summary = { @@ -11063,6 +18082,8 @@ def get_hanmac_aggregate_summary(payload: dict[str, Any]) -> dict[str, Any]: "columns": columns, "rows": rows, "summary": summary, + "center_members": center_member_rows, + "joint_members": joint_members, "source_diagnostics": source_diagnostics, } _HANMAC_LAST_AGGREGATE_DIAGNOSTICS = { @@ -11106,7 +18127,7 @@ def build_hanmac_mysql_error_message(exc: OperationalError) -> str: if "unknown character set" in lowered: return "MySQL 서버 문자셋 호환성 문제였습니다. 앱 쪽 설정을 조정했으니 다시 연결 확인을 시도해주세요." if "unknown database" in lowered: - return "선택한 DB 이름을 찾지 못했습니다. hanmac 또는 hanmac_manhour 선택을 다시 확인해주세요." + return f"선택한 DB 이름을 찾지 못했습니다. {HANMAC_EXTERNAL_SCHEMA_LABEL} 선택을 다시 확인해주세요." if "can't connect" in lowered or "connection refused" in lowered or "timed out" in lowered: return "MySQL 서버 포트에는 접근했지만 최종 연결에 실패했습니다. 서버 상태 또는 방화벽 설정을 확인해주세요." if "authentication plugin" in lowered: @@ -11157,12 +18178,36 @@ async def home(request: Request, edit_id: int | None = None, overview_year: int async def dashboard_bootstrap_data_api(overview_year: str | None = None): try: payload = await run_in_threadpool(get_dashboard_bootstrap_payload, parse_optional_year(overview_year)) - return JSONResponse(content=jsonable_encoder(payload)) + return JSONResponse( + content=jsonable_encoder(payload), + 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.post("/dashboard/api/rebuild-cache") +async def dashboard_rebuild_cache(request: Request): + try: + payload = await request.json() + if not isinstance(payload, dict): + payload = {} + overview_year = parse_optional_year(payload.get("overview_year")) + job = await run_in_threadpool( + _create_system_job, + page_key="dashboard", + job_type="dashboard_bootstrap", + start_year=overview_year, + end_year=overview_year, + params={"overview_year": overview_year}, + ) + 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("/projects") async def projects( request: Request, @@ -11182,6 +18227,374 @@ async def projects( return HTMLResponse("

서버 오류

로그를 확인해주세요.

", status_code=500) +@app.get("/cost-analysis") +async def cost_analysis(request: Request): + try: + return render_cost_analysis_page(request) + except Exception as exc: + logger.exception("프로젝트 손익분석 페이지 에러: %s", exc) + return HTMLResponse("

서버 오류

로그를 확인해주세요.

", status_code=500) + + +@app.get("/cost-analysis/data") +async def cost_analysis_data(start_date: str = "", end_date: str = "", mode: str = "individual"): + try: + 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"}, + ) + 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: + start = _parse_iso_date(start_date) or date(date.today().year, 1, 1) + end = _parse_iso_date(end_date) or date.today() + if end < start: + start, end = end, start + requested_codes = {code.upper() for code in parse_support_dept_codes_param(codes)} + project_meta = _cost_analysis_get_project_meta() + rows = await run_in_threadpool( + _cost_analysis_collect_missing_hanmac_grade_rows_yearly, + start, + end, + project_meta, + requested_codes or None, + ) + columns = [ + ("work_date", "일자"), + ("phase", "구분"), + ("support_dept_code", "프로젝트코드"), + ("project_name", "사업명"), + ("member_no", "사번"), + ("member_name", "성명"), + ("dept_name", "부서"), + ("raw_grade", "원천직급"), + ("hour_kind", "투입구분"), + ("hours", "투입시간"), + ("source_project_code", "원천프로젝트코드"), + ("source_project_name", "원천프로젝트명"), + ("metric_range", "한맥집계기간"), + ("metric_cache_key", "한맥캐시키"), + ] + csv_lines = [",".join(_hanmac_csv_escape(label) for _, label in columns)] + for row in rows: + csv_lines.append(",".join(_hanmac_csv_escape(row.get(key)) for key, _ in columns)) + csv_bytes = ("\ufeff" + "\n".join(csv_lines) + "\n").encode("utf-8") + file_name = f"cost_analysis_missing_grade_{end.isoformat()}.csv" + return Response( + content=csv_bytes, + media_type="text/csv; charset=utf-8", + headers={"Content-Disposition": f'attachment; filename="{file_name}"'}, + ) + except Exception as exc: + logger.exception("프로젝트 손익분석 직급 누락 다운로드 에러: %s", exc) + return JSONResponse(content={"error": str(exc)}, status_code=500) + + +@app.get("/cost-analysis/missing-grade") +async def cost_analysis_missing_grade(start_date: str = "", end_date: str = "", codes: str = ""): + try: + start = _parse_iso_date(start_date) or date(date.today().year, 1, 1) + end = _parse_iso_date(end_date) or date.today() + if end < start: + start, end = end, start + requested_codes = {code.upper() for code in parse_support_dept_codes_param(codes)} + project_meta = _cost_analysis_get_project_meta() + rows = await run_in_threadpool( + _cost_analysis_collect_missing_hanmac_grade_rows_yearly, + start, + end, + project_meta, + requested_codes or None, + ) + summary_by_project: dict[str, dict[str, Any]] = {} + for row in rows: + code = normalize_text(row.get("support_dept_code")).upper() + phase = normalize_text(row.get("phase")) + summary = summary_by_project.setdefault( + code, + { + "support_dept_code": code, + "project_name": normalize_text(row.get("project_name")), + "row_count": 0, + "hours": 0.0, + "pre_hours": 0.0, + "during_hours": 0.0, + "post_hours": 0.0, + }, + ) + hours = normalize_amount(row.get("hours")) + summary["row_count"] += 1 + summary["hours"] += hours + if phase == "사업전": + summary["pre_hours"] += hours + elif phase == "사업후": + summary["post_hours"] += hours + else: + summary["during_hours"] += hours + return JSONResponse( + content=jsonable_encoder( + { + "rows": rows[:5000], + "row_count": len(rows), + "summary": sorted(summary_by_project.values(), key=lambda item: (-normalize_amount(item.get("hours")), normalize_text(item.get("support_dept_code")))), + "truncated": len(rows) > 5000, + } + ) + ) + except Exception as exc: + logger.exception("프로젝트 손익분석 직급 누락 조회 에러: %s", exc) + return JSONResponse(content={"error": str(exc)}, status_code=500) + + +@app.post("/cost-analysis/hanmac-cache-rebuild") +async def cost_analysis_hanmac_cache_rebuild(request: Request): + try: + payload = await request.json() + if not isinstance(payload, dict): + payload = {} + start = _parse_iso_date(payload.get("start_date")) or date(date.today().year, 1, 1) + end = _parse_iso_date(payload.get("end_date")) or date.today() + if end < start: + start, end = end, start + base_payload = { + "host": normalize_text(payload.get("host")), + "port": normalize_text(payload.get("port")) or "3306", + "user": normalize_text(payload.get("user")), + "password": payload.get("password") or "", + "database": normalize_text(payload.get("database") or HANMAC_PRIMARY_MANHOUR_SCHEMA), + "view": "member", + "employment": normalize_text(payload.get("employment") or "all"), + "include_center_member_nos": payload.get("include_center_member_nos") or [], + } + if not base_payload["host"] or not base_payload["user"] or not base_payload["password"]: + raise ValueError("한맥 DB_external 접속 정보가 필요합니다. 한맥 DB_external 페이지에서 접속 정보를 저장한 뒤 다시 시도해주세요.") + jobs = [] + for year_slice in _iter_year_slices(start, end): + job_payload = { + **base_payload, + "start_date": year_slice["start"].isoformat(), + "end_date": year_slice["end"].isoformat(), + } + job = await run_in_threadpool( + _create_system_job, + page_key="hanmac_browser", + job_type="hanmac_aggregate_cache", + start_year=int(year_slice["year"]), + end_year=int(year_slice["year"]), + params=job_payload, + ) + jobs.append(job) + await run_in_threadpool(_clear_cost_analysis_payload_caches) + return JSONResponse(content=jsonable_encoder({"ok": True, "jobs": jobs, "job_count": len(jobs)})) + except Exception as exc: + logger.exception("프로젝트 손익분석 한맥 캐시 재생성 등록 에러: %s", exc) + return JSONResponse(content={"error": str(exc)}, status_code=500) + + +@app.get("/cost-analysis/detail") +async def cost_analysis_detail( + start_date: str = "", + end_date: str = "", + codes: str = "", + phase: str = "", + item: str = "", +): + try: + start = _parse_iso_date(start_date) or date(date.today().year, 1, 1) + end = _parse_iso_date(end_date) or date.today() + if end < start: + start, end = end, start + normalized_codes = [code.upper() for code in parse_support_dept_codes_param(codes)] + if not normalized_codes: + raise ValueError("조회할 프로젝트 코드가 필요합니다.") + normalized_phase = normalize_text(phase).lower() + normalized_item = normalize_text(item).lower() + completion_dates = _cost_analysis_get_completion_billing_dates() + + if normalized_item == "labor": + project_meta = _cost_analysis_get_project_meta() + rows = _cost_analysis_load_hanmac_labor_detail_rows_yearly( + start, + end, + normalized_codes, + 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), + } + ) + ) + + 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 = [ + { + "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() + ] + 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)})) + + 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, + '청구금액' 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(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() + ] + return JSONResponse(content=jsonable_encoder({"rows": rows, "total_amount": sum(row["amount"] for row in rows)})) + + in_clause, code_params = build_in_clause("cost_analysis_detail_code", normalized_codes) + query = text( + f""" + SELECT + COALESCE(voucher_number, '') AS voucher_number, + {COST_ANALYSIS_TX_DATE_SQL} AS posting_date, + COALESCE(account_code, '') AS account_code, + COALESCE(account_name, '') AS account_name, + 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(amount, 0) AS amount + FROM transactions + WHERE 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%' OR account_code LIKE '5%' OR account_code LIKE '6%') + ORDER BY posting_date DESC, voucher_number DESC, partner_name, account_code + """ + ) + params = {**code_params, "start_date": start.isoformat(), "end_date": end.isoformat()} + result_rows: list[dict[str, Any]] = [] + with engine.begin() as conn: + for raw_row in conn.execute(query, params).mappings(): + row = dict(raw_row) + 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_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": + 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"}: + 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"]), + "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)})) + except Exception as exc: + logger.exception("프로젝트 손익분석 상세 조회 에러: %s", exc) + return JSONResponse(content={"error": str(exc)}, status_code=500) + + @app.get("/projects/edit-data") async def project_edit_data(code: str | None = None): try: @@ -11191,13 +18604,26 @@ async def project_edit_data(code: str | None = None): return JSONResponse(content={"error": str(exc)}, status_code=500) +@app.get("/projects/status-detail") +async def project_status_detail(code: str | None = None): + try: + item = await run_in_threadpool(get_project_status_row_for_code, code) + return JSONResponse(content=jsonable_encoder({"ok": True, "item": item or None})) + except Exception as exc: + logger.exception("사업현황 상세 데이터 조회 에러: %s", exc) + return JSONResponse(content={"ok": False, "error": str(exc)}, status_code=500) + + @app.get("/projects/account-breakdowns") async def project_account_breakdowns_api(year: str | None = None, codes: str | None = None): try: selected_year = parse_optional_year(year) selected_codes = parse_support_dept_codes_param(codes) payload = await run_in_threadpool(get_project_account_breakdowns, selected_year, selected_codes) - return JSONResponse(content=jsonable_encoder(payload)) + return JSONResponse( + content=jsonable_encoder(payload), + 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) @@ -11207,19 +18633,37 @@ async def project_account_breakdowns_api(year: str | None = None, codes: str | N async def project_bootstrap_data_api(year: str | None = None): try: selected_year = parse_optional_year(year) - payload = await run_in_threadpool( - lambda: { - "revenue_mix": get_project_revenue_mix(selected_year), - "project_cost_by_year": get_project_cost_by_year(selected_year), - "project_status_rows": get_project_status_rows(), - } + payload = await run_in_threadpool(get_projects_bootstrap_payload, selected_year) + return JSONResponse( + content=jsonable_encoder(payload), + headers={"Cache-Control": "no-store, max-age=0"}, ) - return JSONResponse(content=jsonable_encoder(payload)) except Exception as exc: logger.exception("프로젝트 부트스트랩 데이터 조회 에러: %s", exc) return JSONResponse(content={"error": str(exc)}, status_code=500) +@app.post("/projects/api/rebuild-cache") +async def projects_rebuild_cache(request: Request): + try: + payload = await request.json() + if not isinstance(payload, dict): + payload = {} + selected_year = parse_optional_year(payload.get("year")) + job = await run_in_threadpool( + _create_system_job, + page_key="projects", + job_type="projects_bootstrap", + start_year=selected_year, + end_year=selected_year, + params={"selected_year": selected_year}, + ) + 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.post("/projects/page-state") async def project_page_state_save(request: Request): try: @@ -11688,16 +19132,79 @@ async def annual_summary(request: Request): return HTMLResponse("

서버 오류

로그를 확인해주세요.

", status_code=500) +@app.get("/biz-process") +async def biz_process(request: Request): + context = base_context(request) + context.update({"biz_process_src": "/static/hm-biz-process/flow_260320.html"}) + return templates.TemplateResponse(request, "biz_process.html", context) + + +@app.get("/biz-process-viewer") +async def biz_process_viewer_root(): + return RedirectResponse("/static/hm-biz-process/flow_260320.html") + + +@app.get("/biz-process-viewer/process-map") +async def biz_process_viewer_process_map(): + return RedirectResponse("/static/hm-biz-process/process_map.html") + + +@app.get("/biz-process-viewer/api/health") +async def biz_process_viewer_health(): + return {"ok": "true"} + + +@app.get("/biz-process-viewer/api/flow-data") +async def biz_process_viewer_flow_data(): + try: + payload = await run_in_threadpool(load_hmbiz_process_flow_data) + return JSONResponse(content=jsonable_encoder(payload)) + except Exception as exc: + logger.exception("HM-BIZ-PROCESS 데이터 조회 에러: %s", exc) + return JSONResponse(content={"error": str(exc)}, status_code=500) + + +@app.put("/biz-process-viewer/api/flow-data") +async def biz_process_viewer_save_flow_data(request: Request): + try: + payload = await request.json() + result = await run_in_threadpool(save_hmbiz_process_flow_data, payload) + return JSONResponse(content=jsonable_encoder(result)) + except Exception as exc: + logger.exception("HM-BIZ-PROCESS 데이터 저장 에러: %s", exc) + return JSONResponse(content={"error": str(exc)}, status_code=500) + + @app.get("/annual-summary/bootstrap-data") async def annual_summary_bootstrap_data_api(): try: payload = await run_in_threadpool(get_annual_summary_bootstrap_payload) - return JSONResponse(content=jsonable_encoder(payload)) + return JSONResponse( + content=jsonable_encoder(payload), + 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.post("/annual-summary/api/rebuild-cache") +async def annual_summary_rebuild_cache(): + try: + job = await run_in_threadpool( + _create_system_job, + page_key="annual_summary", + job_type="annual_summary_bootstrap", + start_year=None, + end_year=None, + params={"scope": "annual-summary"}, + ) + 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("/process-cost") async def process_cost( request: Request, @@ -11748,6 +19255,39 @@ async def process_cost_bootstrap_data( return JSONResponse(content={"error": str(exc)}, status_code=500) +@app.post("/process-cost/api/rebuild-cache") +async def process_cost_rebuild_cache(request: Request): + try: + payload = await request.json() + if not isinstance(payload, dict): + payload = {} + source = normalize_text(payload.get("source")) or "hanmac" + start_year = parse_optional_year(payload.get("start_year")) + end_year = parse_optional_year(payload.get("end_year")) + code = normalize_text(payload.get("code")) + include_related = normalize_text(payload.get("include_related")) in {"1", "true", "y", "yes", "on"} + active_related = normalize_text(payload.get("active_related")) + job = await run_in_threadpool( + _create_system_job, + page_key="process_cost", + job_type="process_cost_bootstrap", + start_year=start_year, + end_year=end_year, + params={ + "source": source, + "start_year": start_year, + "end_year": end_year, + "code": code, + "include_related": include_related, + "active_related": active_related, + }, + ) + 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("/hanmac-browser") async def hanmac_browser(request: Request): try: @@ -11799,6 +19339,22 @@ async def hanmac_browser_tables(request: Request): return JSONResponse(content={"status": "error", "message": str(exc)}, status_code=400) +@app.post("/hanmac-browser/api/grade-codes") +async def hanmac_browser_grade_codes(request: Request): + try: + payload = await request.json() + if not isinstance(payload, dict): + raise ValueError("잘못된 요청 형식입니다.") + result = await run_in_threadpool(get_hanmac_grade_code_summary, payload) + return JSONResponse(content=jsonable_encoder(result)) + except OperationalError as exc: + logger.exception("hanmac DB_external 직급 코드 조회 실패: %s", exc) + return JSONResponse(content={"status": "error", "message": build_hanmac_mysql_error_message(exc)}, status_code=400) + except Exception as exc: + logger.exception("hanmac DB_external 직급 코드 조회 에러: %s", exc) + return JSONResponse(content={"status": "error", "message": str(exc)}, status_code=400) + + @app.post("/hanmac-browser/api/preview") async def hanmac_browser_preview(request: Request): try: @@ -11814,6 +19370,26 @@ async def hanmac_browser_preview(request: Request): return JSONResponse(content={"status": "error", "message": str(exc)}, status_code=400) +@app.post("/hanmac-browser/api/preview/rebuild-cache") +async def hanmac_browser_preview_rebuild_cache(request: Request): + try: + payload = await request.json() + if not isinstance(payload, dict): + payload = {} + job = await run_in_threadpool( + _create_system_job, + page_key="hanmac_browser", + job_type="hanmac_preview_cache", + start_year=None, + end_year=None, + params=payload, + ) + return JSONResponse(content=jsonable_encoder({"ok": True, "job": job})) + except Exception as exc: + logger.exception("hanmac DB_external 미리보기 캐시 작업 등록 에러: %s", exc) + return JSONResponse(content={"ok": False, "error": str(exc)}, status_code=500) + + @app.post("/hanmac-browser/api/preview-export-jobs") async def hanmac_browser_preview_export_jobs(request: Request): try: @@ -11885,11 +19461,204 @@ async def hanmac_browser_aggregate(request: Request): return JSONResponse(content={"status": "error", "message": str(exc)}, status_code=400) +@app.post("/hanmac-browser/api/aggregate/rebuild-cache") +async def hanmac_browser_aggregate_rebuild_cache(request: Request): + try: + payload = await request.json() + if not isinstance(payload, dict): + payload = {} + job = await run_in_threadpool( + _create_system_job, + page_key="hanmac_browser", + job_type="hanmac_aggregate_cache", + start_year=None, + end_year=None, + params=payload, + ) + return JSONResponse(content=jsonable_encoder({"ok": True, "job": job})) + except Exception as exc: + logger.exception("hanmac DB_external 집계 캐시 작업 등록 에러: %s", exc) + return JSONResponse(content={"ok": False, "error": str(exc)}, status_code=500) + + @app.get("/hanmac-browser/api/aggregate-diagnostics") async def hanmac_browser_aggregate_diagnostics(): return JSONResponse(content=jsonable_encoder(_HANMAC_LAST_AGGREGATE_DIAGNOSTICS or {"status": "empty"})) +@app.get("/hanmac-browser/api/holidays") +async def hanmac_browser_holidays(start_date: str | None = None, end_date: str | None = None): + try: + rows = await run_in_threadpool( + get_hanmac_holidays, + _hanmac_parse_date_value(start_date), + _hanmac_parse_date_value(end_date), + ) + return JSONResponse(content=jsonable_encoder({"ok": True, "rows": rows})) + except Exception as exc: + logger.exception("hanmac 휴일 기준 조회 에러: %s", exc) + return JSONResponse(content={"ok": False, "error": str(exc)}, status_code=500) + + +@app.post("/hanmac-browser/api/holidays") +async def hanmac_browser_save_holiday(request: Request): + try: + payload = await request.json() + if not isinstance(payload, dict): + payload = {} + result = await run_in_threadpool(save_hanmac_holiday, payload) + return JSONResponse(content=jsonable_encoder(result)) + except Exception as exc: + logger.exception("hanmac 휴일 기준 저장 에러: %s", exc) + return JSONResponse(content={"ok": False, "error": str(exc)}, status_code=400) + + +@app.delete("/hanmac-browser/api/holidays/{holiday_date}") +async def hanmac_browser_delete_holiday(holiday_date: str): + try: + result = await run_in_threadpool(delete_hanmac_holiday, holiday_date) + return JSONResponse(content=jsonable_encoder(result)) + except Exception as exc: + logger.exception("hanmac 휴일 기준 삭제 에러: %s", exc) + return JSONResponse(content={"ok": False, "error": str(exc)}, status_code=400) + + +@app.get("/hanmac-browser/api/leave-rules") +async def hanmac_browser_leave_rules(): + try: + rows = await run_in_threadpool(get_hanmac_leave_rules) + return JSONResponse(content=jsonable_encoder({"ok": True, "rows": rows})) + except Exception as exc: + logger.exception("hanmac 휴가 계산 규칙 조회 에러: %s", exc) + return JSONResponse(content={"ok": False, "error": str(exc)}, status_code=500) + + +@app.post("/hanmac-browser/api/leave-rules") +async def hanmac_browser_save_leave_rule(request: Request): + try: + payload = await request.json() + if not isinstance(payload, dict): + payload = {} + result = await run_in_threadpool(save_hanmac_leave_rule, payload) + return JSONResponse(content=jsonable_encoder(result)) + except Exception as exc: + logger.exception("hanmac 휴가 계산 규칙 저장 에러: %s", exc) + return JSONResponse(content={"ok": False, "error": str(exc)}, status_code=400) + + +@app.delete("/hanmac-browser/api/leave-rules/{keyword}") +async def hanmac_browser_delete_leave_rule(keyword: str): + try: + result = await run_in_threadpool(delete_hanmac_leave_rule, keyword) + return JSONResponse(content=jsonable_encoder(result)) + except Exception as exc: + logger.exception("hanmac 휴가 계산 규칙 삭제 에러: %s", exc) + return JSONResponse(content={"ok": False, "error": str(exc)}, status_code=400) + + +@app.post("/api/system-jobs") +async def system_jobs_create(request: Request): + try: + payload = await request.json() + if not isinstance(payload, dict): + payload = {} + page_key = normalize_text(payload.get("page_key")) + job_type = normalize_text(payload.get("job_type")) + start_year = parse_optional_year(payload.get("start_year")) + end_year = parse_optional_year(payload.get("end_year")) + params = payload.get("params") if isinstance(payload.get("params"), dict) else {} + job = await run_in_threadpool( + _create_system_job, + page_key=page_key, + job_type=job_type, + start_year=start_year, + end_year=end_year, + params=params, + ) + 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("/api/system-jobs/latest") +async def system_jobs_latest( + page_key: str = "", + job_type: str = "", + start_year: str | None = None, + end_year: str | None = None, +): + try: + job = await run_in_threadpool( + _fetch_latest_system_job, + normalize_text(page_key), + normalize_text(job_type), + parse_optional_year(start_year), + parse_optional_year(end_year), + ) + 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("/api/system-jobs/{job_id}") +async def system_jobs_detail(job_id: str): + try: + job = await run_in_threadpool(_fetch_system_job, normalize_text(job_id)) + if not job: + return JSONResponse(content={"ok": False, "error": "작업을 찾을 수 없습니다."}, status_code=404) + 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.post("/wehago-compare/api/rebuild-query-cache") +async def wehago_compare_rebuild_query_cache(request: Request): + try: + payload = await request.json() + if not isinstance(payload, dict): + payload = {} + start_year = parse_optional_year(payload.get("start_year")) + end_year = parse_optional_year(payload.get("end_year")) + if start_year is None or end_year is None: + raise ValueError("조회 캐시를 생성할 기간을 선택해주세요.") + if start_year > end_year: + start_year, end_year = end_year, start_year + existing_job = await run_in_threadpool( + _load_existing_wehago_compare_export_projection_job, + start_year, + end_year, + ) + if existing_job: + return JSONResponse(content=jsonable_encoder({"ok": True, "job": existing_job})) + try: + await run_in_threadpool( + _assert_wehago_compare_current_export_rows_ready, + start_year, + end_year, + ) + except Exception as exc: + return JSONResponse(content={"ok": False, "error": str(exc)}, status_code=409) + try: + await run_in_threadpool(_assert_wal_allows_heavy_cache_write) + except Exception as exc: + return JSONResponse(content={"ok": False, "error": str(exc)}, status_code=409) + job = await run_in_threadpool( + _create_system_job, + page_key="wehago_compare", + job_type="wehago_compare_project_range", + start_year=start_year, + end_year=end_year, + params={"source": "wehago_compare_page", "reuse_existing_projection": True}, + ) + 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("/wehago-compare") async def wehago_compare(request: Request, start_year: str | None = None, end_year: str | None = None): try: @@ -11990,7 +19759,10 @@ async def wehago_compare_status_rows( limit=limit, cursor=cursor, ) - return JSONResponse(content=jsonable_encoder(payload)) + return JSONResponse( + content=jsonable_encoder(payload), + headers={"Cache-Control": "no-store, max-age=0"}, + ) except ValueError as exc: logger.warning("전표비교 상태 상세 조회 검증 오류: %s", exc) return JSONResponse(content={"error": str(exc)}, status_code=400) @@ -12103,12 +19875,15 @@ async def wehago_compare_summary( end_year: int | None = None, ): try: - payload = get_wehago_compare_summary( - engine, - start_year=start_year, - end_year=end_year, + payload = await run_in_threadpool( + _fast_wehago_compare_summary_payload, + start_year, + end_year, + ) + return JSONResponse( + content=jsonable_encoder(payload), + headers={"Cache-Control": "no-store, max-age=0"}, ) - return JSONResponse(content=jsonable_encoder(payload)) except Exception as exc: logger.exception("전표비교 현황 조회 에러: %s", exc) return JSONResponse(content={"error": str(exc)}, status_code=500) @@ -12141,7 +19916,17 @@ async def wehago_compare_snapshot_rebuild(request: Request): payload = {} start_year = payload.get("start_year") end_year = payload.get("end_year") - include_metric_counts = normalize_text(payload.get("include_metric_counts")) not in {"0", "false", "n", "no", "off"} + include_metric_counts_value = payload.get("include_metric_counts", True) + if isinstance(include_metric_counts_value, bool): + include_metric_counts = include_metric_counts_value + else: + include_metric_counts = normalize_text(include_metric_counts_value).lower() not in { + "0", + "false", + "n", + "no", + "off", + } response = request_compare_snapshot_rebuild( engine, start_year=int(start_year) if start_year is not None else None, @@ -12204,16 +19989,500 @@ async def wehago_compare_status_suggestions( return JSONResponse(content={"error": str(exc)}, status_code=500) +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 + + +def _ensure_wehago_manual_offset_excepted_table(conn: sqlite3.Connection) -> None: + conn.execute( + """ + CREATE TABLE IF NOT EXISTS wehago_manual_offset_excepted ( + pair_key TEXT PRIMARY KEY, + left_identity TEXT NOT NULL, + right_identity TEXT NOT NULL, + start_year INTEGER NOT NULL, + end_year INTEGER NOT NULL, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP + ) + """ + ) + + +def _wehago_projection_scope_for_range(conn: sqlite3.Connection, start_year: int, end_year: int) -> tuple[int, int, str]: + if start_year == end_year: + return _latest_year_query_source(conn, start_year) + row = conn.execute( + f""" + SELECT start_year, end_year, signature, COUNT(DISTINCT status_key) AS status_count, MAX(updated_at) AS max_updated_at + FROM wehago_compare_query_groups + WHERE start_year = ? + AND end_year = ? + AND status_key IN ({','.join('?' for _ in _WEHAGO_COMPARE_VOUCHER_STATUSES)}) + AND signature LIKE ? + GROUP BY start_year, end_year, signature + ORDER BY + CASE WHEN signature LIKE '%db-reconciled-v1%' THEN 0 ELSE 1 END ASC, + status_count DESC, + max_updated_at DESC + LIMIT 1 + """, + (start_year, end_year, *_WEHAGO_COMPARE_VOUCHER_STATUSES, f"{QUERY_PROJECTION_VERSION}|%"), + ).fetchone() + if row is None: + raise RuntimeError(f"{start_year}~{end_year}년 조회 projection이 없습니다. 먼저 해당 기간 계산을 실행해주세요.") + return int(row["start_year"]), int(row["end_year"]), str(row["signature"] or "") + + +def _wehago_offset_projection_context( + conn: sqlite3.Connection, + start_year: int, + end_year: int, +) -> tuple[tuple[int, int, str], list[tuple[tuple[int, int, str], int | None]], list[int]]: + primary = _wehago_projection_scope_for_range(conn, start_year, end_year) + scopes: list[tuple[tuple[int, int, str], int | None]] = [(primary, None)] + legacy_context_years: list[int] = [] + for context_year in sorted({start_year - 1, end_year + 1}): + try: + context_scope = _latest_year_query_source(conn, context_year) + except RuntimeError: + row = conn.execute( + f""" + SELECT start_year, end_year, signature, COUNT(DISTINCT status_key) AS status_count, MAX(updated_at) AS max_updated_at + FROM wehago_compare_query_groups + WHERE ? BETWEEN start_year AND end_year + AND status_key IN ({','.join('?' for _ in _WEHAGO_COMPARE_VOUCHER_STATUSES)}) + GROUP BY start_year, end_year, signature + ORDER BY + CASE WHEN start_year = ? AND end_year = ? THEN 0 ELSE 1 END ASC, + status_count DESC, + (end_year - start_year) ASC, + max_updated_at DESC + LIMIT 1 + """, + (context_year, *_WEHAGO_COMPARE_VOUCHER_STATUSES, context_year, context_year), + ).fetchone() + if row is None: + continue + context_scope = ( + int(row["start_year"] or context_year), + int(row["end_year"] or context_year), + str(row["signature"] or ""), + ) + legacy_context_years.append(context_year) + if context_scope != primary and (context_scope, context_year) not in scopes: + scopes.append((context_scope, context_year)) + return primary, scopes, legacy_context_years + + +def _wehago_group_identity(group: dict[str, Any]) -> str: + summary = group.get("summary") or {} + fiscal_year = int(summary.get("fiscal_year") or 0) + date_digits = re.findall(r"\d+", str(summary.get("ledger_date") or "")) + if len(date_digits) >= 3: + year, month, day = int(date_digits[-3]), int(date_digits[-2]), int(date_digits[-1]) + elif len(date_digits) >= 2 and fiscal_year: + year, month, day = fiscal_year, int(date_digits[-2]), int(date_digits[-1]) + else: + return "" + voucher_digits = re.sub(r"\D", "", str(summary.get("voucher_no") or "")) + if not voucher_digits: + return "" + return f"{year:04d}{month:02d}{day:02d}-{int(voucher_digits):05d}" + + +def _load_wehago_offset_projection_groups( + conn: sqlite3.Connection, + scope: tuple[int, int, str], + fiscal_year: int | None = None, +) -> dict[str, list[dict[str, Any]]]: + start_year, end_year, signature = scope + status_keys = ("voucher_matched", "voucher_unmatched", "voucher_recheck") + groups_by_key: dict[tuple[str, int], dict[str, Any]] = {} + placeholders = ",".join("?" for _ in status_keys) + fiscal_sql = " AND fiscal_year = ?" if fiscal_year is not None else "" + fiscal_params: tuple[int, ...] = (fiscal_year,) if fiscal_year is not None else () + for row in conn.execute( + f""" + SELECT * + FROM wehago_compare_query_groups + WHERE start_year = ? + AND end_year = ? + AND signature = ? + AND status_key IN ({placeholders}) + {fiscal_sql} + ORDER BY status_key, group_index + """, + (start_year, end_year, signature, *status_keys, *fiscal_params), + ).fetchall(): + summary = dict(row) + groups_by_key[(str(row["status_key"]), int(row["group_index"]))] = {"summary": summary, "rows": []} + for row in conn.execute( + f""" + SELECT * + FROM wehago_compare_query_rows + WHERE start_year = ? + AND end_year = ? + AND signature = ? + AND status_key IN ({placeholders}) + {fiscal_sql} + ORDER BY status_key, group_index, row_index + """, + (start_year, end_year, signature, *status_keys, *fiscal_params), + ).fetchall(): + key = (str(row["status_key"]), int(row["group_index"])) + group = groups_by_key.get(key) + if group is not None: + group["rows"].append(dict(row)) + sections = {status_key: [] for status_key in status_keys} + for (status_key, _group_index), group in groups_by_key.items(): + sections[status_key].append(group) + return sections + + +def _wehago_group_draft_keys(group: dict[str, Any]) -> set[str]: + values = [str((group.get("summary") or {}).get("draft_no") or "")] + values.extend(str(row.get("draft_no") or "") for row in list(group.get("rows") or [])) + return { + item.strip() + for value in values + for item in value.split(",") + if item.strip() + } + + +def _load_wehago_offset_management_items( + conn: sqlite3.Connection, + start_year: int, + end_year: int, +) -> dict[str, list[str]]: + by_draft: dict[str, list[str]] = {} + for row in conn.execute( + """ + SELECT draft_no, confirmed_no, management_item + FROM wehago_voucher_rows + WHERE fiscal_year BETWEEN ? AND ? + AND COALESCE(management_item, '') <> '' + """, + (start_year - 1, end_year + 1), + ).fetchall(): + item = str(row["management_item"] or "").strip() + if not item: + continue + for key in (str(row["draft_no"] or "").strip(), str(row["confirmed_no"] or "").strip()): + if key and item not in by_draft.setdefault(key, []): + by_draft[key].append(item) + return by_draft + + +def _offset_group_display( + group: dict[str, Any], + management_items_by_draft: dict[str, list[str]] | None = None, +) -> dict[str, Any]: + summary = group.get("summary") or {} + vector = _offset_group_vector(group) + net_amount = sum(vector.values()) + management_items: list[str] = [] + for draft_key in sorted(_wehago_group_draft_keys(group)): + for item in (management_items_by_draft or {}).get(draft_key, []): + if item not in management_items: + management_items.append(item) + return { + "identity": _wehago_group_identity(group), + "status_key": str(summary.get("status_key") or ""), + "group_index": int(summary.get("group_index") or 0), + "fiscal_year": int(summary.get("fiscal_year") or 0), + "ledger_date": str(summary.get("ledger_date") or ""), + "voucher_no": str(summary.get("voucher_no") or ""), + "ledger_accounts": str(summary.get("ledger_accounts") or ""), + "ledger_vendors": str(summary.get("ledger_vendors") or ""), + "draft_no": str(summary.get("draft_no") or ""), + "voucher_accounts": str(summary.get("voucher_accounts") or ""), + "voucher_vendors": str(summary.get("voucher_vendors") or ""), + "ledger_debit": float(summary.get("ledger_debit") or 0), + "ledger_credit": float(summary.get("ledger_credit") or 0), + "net_amount": float(net_amount), + "review_reason": str(summary.get("review_reason") or ""), + "erp_management_items": " / ".join(management_items), + } + + +def _load_wehago_offset_context_groups( + conn: sqlite3.Connection, + scopes: list[tuple[tuple[int, int, str], int | None]], +) -> dict[str, list[dict[str, Any]]]: + sections = {status_key: [] for status_key in ("voucher_matched", "voucher_unmatched", "voucher_recheck")} + seen: set[str] = set() + for scope, fiscal_year in scopes: + scoped_sections = _load_wehago_offset_projection_groups(conn, scope, fiscal_year) + for status_key, groups in scoped_sections.items(): + for group in groups: + identity = _wehago_group_identity(group) + if not identity or identity in seen: + continue + seen.add(identity) + sections[status_key].append(group) + return sections + + +def _get_wehago_offset_candidates(start_year: int, end_year: int) -> dict[str, Any]: + conn = sqlite3.connect(DB_PATH) + conn.row_factory = sqlite3.Row + try: + scope, context_scopes, legacy_context_years = _wehago_offset_projection_context(conn, start_year, end_year) + primary_sections = _load_wehago_offset_projection_groups(conn, scope) + sections = _load_wehago_offset_context_groups(conn, context_scopes) + management_items_by_draft = _load_wehago_offset_management_items(conn, start_year, end_year) + indexed_groups: dict[ + tuple[tuple[tuple[str, str, str], float], ...], + list[tuple[str, dict[str, Any], dict[tuple[str, str, str], float]]], + ] = {} + for status_key, groups in sections.items(): + for group in groups: + vector = _offset_group_vector(group) + if vector: + vector_key = tuple(sorted((key, round(value, 4)) for key, value in vector.items())) + indexed_groups.setdefault(vector_key, []).append((status_key, group, vector)) + + candidate_groups: list[dict[str, Any]] = [] + seen_pair_keys: set[tuple[str, str]] = set() + for source in primary_sections["voucher_recheck"]: + source_year = int((source.get("summary") or {}).get("fiscal_year") or 0) + if source_year < start_year or source_year > end_year: + continue + source_vector = _offset_group_vector(source) + if not source_vector or not any(value < -0.5 for value in source_vector.values()): + continue + if not (_group_has_tax_invoice_cancel_signal(source) or _group_has_offset_tax_invoice_structure(source)): + continue + source_identity = _wehago_group_identity(source) + partners: list[dict[str, Any]] = [] + opposite_key = tuple(sorted((key, round(-value, 4)) for key, value in source_vector.items())) + for status_key, partner, partner_vector in indexed_groups.get(opposite_key, []): + partner_identity = _wehago_group_identity(partner) + if not partner_identity or partner_identity == source_identity: + continue + if not _offset_vectors_cancel_each_other(source_vector, partner_vector): + continue + if not _voucher_groups_within_days(source, partner, 93): + continue + pair_key = tuple(sorted((source_identity, partner_identity))) + if pair_key in seen_pair_keys: + continue + payload = _offset_group_display(partner, management_items_by_draft) + payload["status_key"] = status_key + partners.append(payload) + if not partners: + continue + seen_pair_keys.update( + tuple(sorted((source_identity, str(partner["identity"])))) + for partner in partners + ) + partners.sort(key=lambda row: (row["ledger_date"], row["voucher_no"], row["status_key"])) + candidate_groups.append( + { + "source": _offset_group_display(source, management_items_by_draft), + "partners": partners, + "candidate_count": len(partners), + "recommendation": "복수 반전 후보 확인 필요" if len(partners) > 1 else "반전쌍 확인 후 이동", + } + ) + candidate_groups.sort(key=lambda item: (item["source"]["ledger_date"], item["source"]["voucher_no"])) + required_context_years = sorted({start_year - 1, end_year + 1}) + available_context_years = [ + year + for year in required_context_years + if any(scope_start <= year <= scope_end for (scope_start, scope_end, _signature), _fiscal_year in context_scopes) + ] + return { + "groups": candidate_groups, + "count": len(candidate_groups), + "pair_count": sum(len(group["partners"]) for group in candidate_groups), + "start_year": scope[0], + "end_year": scope[1], + "signature": scope[2], + "context_years": available_context_years, + "missing_context_years": sorted(set(required_context_years) - set(available_context_years)), + "legacy_context_years": legacy_context_years, + } + finally: + conn.close() + + +def _save_wehago_manual_offset_pairs(start_year: int, end_year: int, pairs: list[dict[str, Any]]) -> int: + conn = sqlite3.connect(DB_PATH) + conn.row_factory = sqlite3.Row + try: + scope, context_scopes, _legacy_context_years = _wehago_offset_projection_context(conn, start_year, end_year) + primary_sections = _load_wehago_offset_projection_groups(conn, scope) + sections = _load_wehago_offset_context_groups(conn, context_scopes) + current: dict[str, tuple[str, dict[str, Any]]] = {} + for status_key, groups in sections.items(): + for group in groups: + identity = _wehago_group_identity(group) + if identity: + current[identity] = (status_key, group) + valid_source_identities = { + _wehago_group_identity(group) + for group in primary_sections["voucher_recheck"] + if start_year <= int((group.get("summary") or {}).get("fiscal_year") or 0) <= end_year + } + _ensure_wehago_manual_offset_excepted_table(conn) + saved = 0 + selected_identities: set[str] = set() + for pair in pairs: + if not isinstance(pair, dict): + continue + source_identity = str(pair.get("source_identity") or "") + partner_identity = str(pair.get("partner_identity") or "") + source_entry = current.get(source_identity) + partner_entry = current.get(partner_identity) + if not source_entry or not partner_entry or source_identity == partner_identity: + raise ValueError("선택한 상계 후보가 현재 조회 결과와 일치하지 않습니다. 목록을 다시 열어 선택해주세요.") + if source_identity not in valid_source_identities: + raise ValueError("조회 기간의 Recheck 취소 전표만 이동 대상으로 선택할 수 있습니다.") + if source_identity in selected_identities or partner_identity in selected_identities: + raise ValueError("같은 전표를 둘 이상의 상계쌍에 동시에 사용할 수 없습니다.") + selected_identities.update((source_identity, partner_identity)) + source_status, source = source_entry + partner_status, partner = partner_entry + if source_status != "voucher_recheck": + raise ValueError("상계 이동 대상은 현재 Recheck에 있는 취소 전표여야 합니다.") + if not (_group_has_tax_invoice_cancel_signal(source) or _group_has_offset_tax_invoice_structure(source)): + raise ValueError("선택한 취소 전표는 상계 검토 조건을 충족하지 않습니다.") + if not _offset_vectors_cancel_each_other(_offset_group_vector(source), _offset_group_vector(partner)): + raise ValueError("선택한 두 전표의 금액·계정 구조가 서로 반전되지 않습니다.") + if not _voucher_groups_within_days(source, partner, 93): + raise ValueError("선택한 두 전표는 검토 기간(전후 3개월)을 벗어납니다.") + canonical = sorted((source_identity, partner_identity)) + pair_key = hashlib.sha256("|".join(canonical).encode("utf-8")).hexdigest() + result = conn.execute( + """ + INSERT OR IGNORE INTO wehago_manual_offset_excepted ( + pair_key, left_identity, right_identity, start_year, end_year + ) + VALUES (?, ?, ?, ?, ?) + """, + (pair_key, canonical[0], canonical[1], start_year, end_year), + ) + saved += max(int(result.rowcount or 0), 0) + conn.commit() + return saved + finally: + conn.close() + + +def _refresh_wehago_manual_offset_years(years: set[int]) -> dict[str, Any]: + output_by_year: dict[str, Any] = {} + script = Path("scripts/reconcile_wehago_projection_to_db.py") + for year in sorted(year for year in years if year > 0): + completed = subprocess.run( + [sys.executable, str(script), "--year", str(year)], + 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 completed.returncode != 0: + raise RuntimeError(output or f"{year}년 상계 분류 반영에 실패했습니다.") + last_line = output.splitlines()[-1].strip() if output else "" + try: + output_by_year[str(year)] = json.loads(last_line) if last_line else {"ok": True} + except Exception: + output_by_year[str(year)] = {"output": last_line} + _clear_compare_runtime_caches() + return {"years": output_by_year} + + +@app.get("/wehago-compare/api/offset-candidates") +async def wehago_compare_offset_candidates(start_year: int, end_year: int): + try: + payload = await run_in_threadpool(_get_wehago_offset_candidates, start_year, end_year) + return JSONResponse(content=jsonable_encoder(payload)) + except Exception as exc: + logger.exception("전표비교 상계 후보 조회 에러: %s", exc) + return JSONResponse(content={"error": str(exc)}, status_code=500) + + +@app.post("/wehago-compare/api/offset-except-save") +async def wehago_compare_offset_except_save(request: Request): + try: + payload = await request.json() + if not isinstance(payload, dict): + raise ValueError("저장할 상계 항목 형식이 올바르지 않습니다.") + pairs = payload.get("pairs") + if not isinstance(pairs, list) or not pairs: + raise ValueError("Excepted로 이동할 상계쌍을 선택해주세요.") + start_year = int(payload.get("start_year") or 0) + end_year = int(payload.get("end_year") or start_year) + saved = await run_in_threadpool(_save_wehago_manual_offset_pairs, start_year, end_year, pairs) + if not saved: + raise ValueError("새로 저장할 상계쌍이 없습니다. 이미 반영되었거나 목록을 다시 조회해주세요.") + affected_years = { + int(str(identity)[:4]) + for pair in pairs + for identity in (pair.get("source_identity"), pair.get("partner_identity")) + if str(identity or "")[:4].isdigit() + } + projection_result = await run_in_threadpool(_refresh_wehago_manual_offset_years, affected_years) + return JSONResponse(content={"saved_count": saved, "projection": projection_result}) + except Exception as exc: + logger.exception("전표비교 상계 Excepted 이동 에러: %s", exc) + return JSONResponse(content={"error": str(exc)}, status_code=500) + + @app.post("/wehago-compare/api/recheck-review-save") async def wehago_compare_recheck_review_save(request: Request): try: payload = await request.json() rows = payload.get("rows") if isinstance(payload, dict) else None + match_rows = payload.get("match_rows") if isinstance(payload, dict) else None + split_rows = payload.get("split_rows") if isinstance(payload, dict) else None if not isinstance(rows, list): raise ValueError("저장할 검토 항목 형식이 올바르지 않습니다.") - saved = save_recheck_review_rows(engine, rows) + if isinstance(match_rows, list) or isinstance(split_rows, list): + result = save_recheck_change_rows( + engine, + match_rows if isinstance(match_rows, list) else rows, + split_rows if isinstance(split_rows, list) else [], + ) + saved = int(result.get("count") or 0) + else: + saved = save_recheck_review_rows(engine, rows) + result = {"match_count": saved, "split_count": 0, "count": saved} + projection_result = await run_in_threadpool(_refresh_wehago_recheck_projection_after_change) enqueue_default_pair_recommend_precompute(engine) - return JSONResponse(content={"saved_count": saved}) + return JSONResponse(content={"saved_count": saved, **result, "projection": projection_result}) except Exception as exc: logger.exception("전표비교 검토 저장 에러: %s", exc) return JSONResponse(content={"error": str(exc)}, status_code=500) diff --git a/reports/my-intranet-app_architecture_report_20260522.docx b/reports/my-intranet-app_architecture_report_20260522.docx new file mode 100644 index 0000000..144fec0 Binary files /dev/null and b/reports/my-intranet-app_architecture_report_20260522.docx differ diff --git a/reports/my-intranet-app_architecture_report_20260522_v2.docx b/reports/my-intranet-app_architecture_report_20260522_v2.docx new file mode 100644 index 0000000..2335adb Binary files /dev/null and b/reports/my-intranet-app_architecture_report_20260522_v2.docx differ diff --git a/reports/my-intranet-app_architecture_report_20260522_v3.docx b/reports/my-intranet-app_architecture_report_20260522_v3.docx new file mode 100644 index 0000000..b2e9d9a Binary files /dev/null and b/reports/my-intranet-app_architecture_report_20260522_v3.docx differ diff --git a/reports/my-intranet-app_architecture_report_20260522_v4.docx b/reports/my-intranet-app_architecture_report_20260522_v4.docx new file mode 100644 index 0000000..2eab4cb Binary files /dev/null and b/reports/my-intranet-app_architecture_report_20260522_v4.docx differ diff --git a/requirements.txt b/requirements.txt index 1760573..2551712 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,7 +1,9 @@ fastapi +datasette jinja2 openpyxl pymysql +python-dotenv python-multipart sqlalchemy uvicorn diff --git a/runtime_config.py b/runtime_config.py new file mode 100644 index 0000000..d3072d1 --- /dev/null +++ b/runtime_config.py @@ -0,0 +1,92 @@ +from __future__ import annotations + +import os +import sqlite3 +from pathlib import Path +from typing import Any + +try: + from dotenv import load_dotenv +except ImportError: # pragma: no cover - keeps admin helpers usable before dependencies install. + load_dotenv = None + + +BASE_DIR = Path(__file__).resolve().parent +MIN_SAFE_SQLITE_VERSION = (3, 51, 3) + + +def load_runtime_env() -> None: + env_path = BASE_DIR / ".env" + if not env_path.exists(): + return + if load_dotenv is not None: + load_dotenv(env_path, override=False) + return + for raw_line in env_path.read_text(encoding="utf-8").splitlines(): + line = raw_line.strip() + if not line or line.startswith("#") or "=" not in line: + continue + key, value = line.split("=", 1) + key = key.strip() + if not key or key in os.environ: + continue + value = value.strip().strip('"').strip("'") + os.environ[key] = value + + +load_runtime_env() + + +def _path_from_env(name: str, default: Path) -> Path: + raw = str(os.environ.get(name, "") or "").strip() + return Path(raw).expanduser().resolve() if raw else default.resolve() + + +def _env_flag(name: str, default: bool = False) -> bool: + raw = str(os.environ.get(name, "") or "").strip().lower() + if not raw: + return default + return raw in {"1", "true", "yes", "y", "on"} + + +DB_PATH = _path_from_env("INTRANET_DB_PATH", BASE_DIR / "data.db") +BACKUP_DIR = _path_from_env("INTRANET_BACKUP_DIR", BASE_DIR / "backups") +HANMAC_EXPORT_DIR = _path_from_env("INTRANET_HANMAC_EXPORT_DIR", Path("/tmp") / "my_intranet_hanmac_exports") +WEHAGO_COMPARE_EXPORT_ROOT = _path_from_env( + "INTRANET_COMPARE_EXPORT_DIR", + BASE_DIR / "static" / "exports" / "wehago_compare", +) +WEHAGO_SOURCE_ROOT = _path_from_env("WEHAGO_SOURCE_ROOT", BASE_DIR.parent / "WEHAGO_DB") +CACHE_ROOT = _path_from_env("INTRANET_CACHE_ROOT", BASE_DIR / "runtime_cache") +REQUIRE_SAFE_SQLITE = _env_flag("INTRANET_REQUIRE_SAFE_SQLITE", default=False) +WAL_WARN_BYTES = int(os.environ.get("INTRANET_WAL_WARN_BYTES", str(256 * 1024 * 1024))) +WAL_BLOCK_HEAVY_BYTES = int(os.environ.get("INTRANET_WAL_BLOCK_HEAVY_BYTES", str(512 * 1024 * 1024))) + + +def sqlite_runtime_status() -> dict[str, Any]: + version = tuple(int(part) for part in sqlite3.sqlite_version.split(".")) + return { + "sqlite_version": sqlite3.sqlite_version, + "minimum_safe_version": ".".join(str(part) for part in MIN_SAFE_SQLITE_VERSION), + "meets_minimum_safe_version": version >= MIN_SAFE_SQLITE_VERSION, + "safe_version_required": REQUIRE_SAFE_SQLITE, + } + + +def validate_sqlite_runtime() -> dict[str, Any]: + status = sqlite_runtime_status() + if REQUIRE_SAFE_SQLITE and not status["meets_minimum_safe_version"]: + raise RuntimeError( + "SQLite 3.51.3 이상이 필요합니다. " + f"현재 런타임은 {status['sqlite_version']}입니다. " + "Docker 안전 런타임 또는 최신 SQLite가 연결된 Python으로 실행하세요." + ) + return status + + +def ensure_runtime_directories() -> None: + DB_PATH.parent.mkdir(parents=True, exist_ok=True) + BACKUP_DIR.mkdir(parents=True, exist_ok=True) + HANMAC_EXPORT_DIR.mkdir(parents=True, exist_ok=True) + WEHAGO_COMPARE_EXPORT_ROOT.mkdir(parents=True, exist_ok=True) + CACHE_ROOT.mkdir(parents=True, exist_ok=True) diff --git a/scripts/analyze_wehago_voucher_coverage.py b/scripts/analyze_wehago_voucher_coverage.py new file mode 100644 index 0000000..54a967b --- /dev/null +++ b/scripts/analyze_wehago_voucher_coverage.py @@ -0,0 +1,427 @@ +from __future__ import annotations + +import json +import re +import sqlite3 +from collections import Counter, defaultdict +from datetime import datetime +from pathlib import Path +from typing import Any + +from openpyxl import Workbook +from openpyxl.styles import Font, PatternFill +from openpyxl.utils import get_column_letter + +import sys + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT)) + +from runtime_config import DB_PATH # noqa: E402 + +EXPORT_DIR = ROOT / "static" / "exports" / "wehago_compare" +YEAR = 2025 +WEHAGO_STATUSES = ("voucher_matched", "voucher_unmatched", "voucher_recheck") + + +def clean(value: Any) -> str: + return "" if value is None else str(value).strip() + + +def key_from_display(year: int, ledger_date: str, voucher_no: str) -> str: + voucher = clean(voucher_no) + if re.fullmatch(r"\d{8}-\d{5}", voucher): + return voucher + match = re.fullmatch(r"(\d{4})-(\d{2})-(\d{5})", voucher) + if match: + return f"{match.group(1)}{match.group(2)}-{match.group(3)}" + date_text = clean(ledger_date) + date_match = re.search(r"(\d{1,2})[-./](\d{1,2})", date_text) + voucher_digits = re.sub(r"\D+", "", voucher) + if date_match and voucher_digits: + month = int(date_match.group(1)) + day = int(date_match.group(2)) + return f"{int(year):04d}{month:02d}{day:02d}-{int(voucher_digits):05d}" + return "" + + +def latest_projection_signature(cur: sqlite3.Cursor) -> str: + row = cur.execute( + """ + SELECT payload_json + FROM wehago_action_history + WHERE action_type = 'auto_recheck_promote' + ORDER BY id DESC + LIMIT 1 + """ + ).fetchone() + if row: + try: + payload = json.loads(row[0] or "{}") + signature = clean(payload.get("signature")) + if signature: + exists = cur.execute( + """ + SELECT 1 + FROM wehago_compare_query_groups + WHERE start_year = ? AND end_year = ? AND signature = ? + LIMIT 1 + """, + (YEAR, YEAR, signature), + ).fetchone() + if exists: + return signature + except Exception: + pass + row = cur.execute( + """ + SELECT signature, MAX(updated_at) AS max_updated_at + FROM wehago_compare_query_groups + WHERE start_year = ? AND end_year = ? AND signature LIKE 'compare-query-v5|%' + GROUP BY signature + ORDER BY max_updated_at DESC + LIMIT 1 + """, + (YEAR, YEAR), + ).fetchone() + return clean(row[0]) if row else "" + + +def append_rows(ws, rows: list[list[Any]]) -> None: + for row in rows: + ws.append(row) + + +def style_sheet(ws) -> None: + header_fill = PatternFill("solid", fgColor="D9EAF7") + for cell in ws[1]: + cell.font = Font(bold=True) + cell.fill = header_fill + ws.freeze_panes = "A2" + ws.auto_filter.ref = ws.dimensions + for col_idx, column_cells in enumerate(ws.columns, start=1): + max_len = 10 + for cell in column_cells: + max_len = max(max_len, min(len(clean(cell.value)), 80)) + ws.column_dimensions[get_column_letter(col_idx)].width = max_len + 2 + + +def main() -> None: + conn = sqlite3.connect(DB_PATH) + conn.row_factory = sqlite3.Row + cur = conn.cursor() + signature = latest_projection_signature(cur) + if not signature: + raise RuntimeError("compare query projection signature not found") + + db_rows = cur.execute( + """ + SELECT fiscal_year, voucher_no, status, voucher_row_count, ledger_row_count, + voucher_debit, voucher_credit, ledger_debit, ledger_credit, + voucher_accounts, ledger_accounts, voucher_vendors, ledger_vendors, notes + FROM wehago_comparison_results + WHERE fiscal_year = ? + AND status <> 'voucher_only' + ORDER BY voucher_no + """, + (YEAR,), + ).fetchall() + db_by_key = {clean(row["voucher_no"]): dict(row) for row in db_rows if clean(row["voucher_no"])} + comparison_total = cur.execute( + """ + SELECT COUNT(*) AS count_all, + SUM(CASE WHEN status = 'voucher_only' THEN 1 ELSE 0 END) AS voucher_only_count + FROM wehago_comparison_results + WHERE fiscal_year = ? + """, + (YEAR,), + ).fetchone() + + ledger_source = cur.execute( + """ + SELECT COUNT(*) AS row_count, + COUNT(DISTINCT fiscal_year || '|' || compare_voucher_no) AS voucher_count + FROM wehago_ledger_rows + WHERE fiscal_year = ? AND COALESCE(compare_voucher_no, '') <> '' + """, + (YEAR,), + ).fetchone() + voucher_source = cur.execute( + """ + SELECT COUNT(*) AS row_count, + COUNT(DISTINCT fiscal_year || '|' || compare_voucher_no) AS voucher_count + FROM wehago_voucher_rows + WHERE fiscal_year = ? AND COALESCE(compare_voucher_no, '') <> '' + """, + (YEAR,), + ).fetchone() + + ui_rows = cur.execute( + f""" + SELECT status_key, group_index, fiscal_year, ledger_date, proof_date, voucher_no, draft_no, + ledger_row_count, voucher_row_count, ledger_debit, ledger_credit, voucher_debit, voucher_credit, + ledger_accounts, voucher_accounts, ledger_vendors, voucher_vendors, review_reason + FROM wehago_compare_query_groups + WHERE start_year = ? AND end_year = ? AND signature = ? + AND status_key IN ({','.join('?' for _ in WEHAGO_STATUSES)}) + ORDER BY status_key, group_index + """, + (YEAR, YEAR, signature, *WEHAGO_STATUSES), + ).fetchall() + + ui_group_records: list[dict[str, Any]] = [] + for row in ui_rows: + record = dict(row) + record["compare_key"] = key_from_display(record["fiscal_year"], record["ledger_date"], record["voucher_no"]) + ui_group_records.append(record) + + section_counts = Counter(record["status_key"] for record in ui_group_records) + ui_by_key: dict[str, list[dict[str, Any]]] = defaultdict(list) + bad_key_records: list[dict[str, Any]] = [] + for record in ui_group_records: + key = clean(record.get("compare_key")) + if key: + ui_by_key[key].append(record) + else: + bad_key_records.append(record) + + db_keys = set(db_by_key) + ui_keys = set(ui_by_key) + duplicate_ui = {key: records for key, records in ui_by_key.items() if len(records) > 1} + ui_not_in_db = {key: records for key, records in ui_by_key.items() if key not in db_keys} + db_not_in_ui = {key: db_by_key[key] for key in sorted(db_keys - ui_keys)} + + section_sum = sum(section_counts.get(status, 0) for status in WEHAGO_STATUSES) + db_count = len(db_by_key) + ui_unique_count = len(ui_keys) + duplicate_extra_count = section_sum - ui_unique_count + + wb = Workbook() + ws = wb.active + ws.title = "요약" + append_rows( + ws, + [ + ["항목", "값", "설명"], + ["분석 기준 연도", YEAR, ""], + ["사용 projection signature", signature, "화면 WEHAGO/Recheck 섹션에 사용된 최신 projection"], + ["DB WEHAGO 전표 수", db_count, "wehago_comparison_results의 fiscal_year+voucher_no 기준"], + ["DB 비교결과 전체 전표 수", int(comparison_total["count_all"] or 0), "ERP-only(voucher_only)를 포함한 전체 비교 결과"], + ["DB 비교결과 중 ERP-only 수", int(comparison_total["voucher_only_count"] or 0), "WEHAGO 전표 수 비교에서는 제외"], + ["DB WEHAGO 원장 전표 수", int(ledger_source["voucher_count"] or 0), "wehago_ledger_rows의 fiscal_year+compare_voucher_no distinct"], + ["DB WEHAGO 원장 행 수", int(ledger_source["row_count"] or 0), "wehago_ledger_rows 행 수"], + ["보조: wehago_voucher_rows 전표 수", int(voucher_source["voucher_count"] or 0), "ERP/증빙성 원천 행으로 보이는 테이블의 distinct 전표 수"], + ["WEHAGO", section_counts.get("voucher_matched", 0), "화면 WEHAGO 그룹 수"], + ["WEHAGO Unmatched", section_counts.get("voucher_unmatched", 0), "화면 WEHAGO Unmatched 그룹 수"], + ["WEHAGO Recheck", section_counts.get("voucher_recheck", 0), "화면 WEHAGO Recheck 그룹 수"], + ["화면 3개 섹션 합계", section_sum, "WEHAGO + WEHAGO Unmatched + WEHAGO Recheck"], + ["차이(화면 합계 - DB WEHAGO)", section_sum - db_count, "양수면 화면 그룹 집계가 DB 전표 수보다 많음"], + ["화면 고유 WEHAGO 전표 수", ui_unique_count, "ledger_date+voucher_no를 YYYYMMDD-전표번호로 환산한 distinct"], + ["차이(화면 고유 전표 - DB WEHAGO)", ui_unique_count - db_count, "전표번호 기준 순수 누락/추가 차이"], + ["중복 그룹 초과분", duplicate_extra_count, "같은 WEHAGO 전표가 여러 그룹으로 나뉘어 합계에 중복 반영된 수"], + ["키 생성 불가 그룹", len(bad_key_records), "ledger_date/voucher_no 조합으로 DB 전표번호를 만들 수 없는 그룹"], + ], + ) + style_sheet(ws) + + ws = wb.create_sheet("차이 사유") + reason_rows = [ + ["사유", "건수", "해석"], + [ + "같은 WEHAGO 전표가 화면에서 복수 그룹으로 집계됨", + duplicate_extra_count, + "전표 하나가 복수 ERP 가전표/후보/라인 그룹으로 분리되면 화면 섹션 합계는 DB 전표 수보다 커집니다.", + ], + [ + "화면에는 있으나 DB comparison_results 전표번호와 직접 대응되지 않음", + len(ui_not_in_db), + "표시 전표번호를 YYYYMMDD-전표번호로 환산해도 DB 전표번호 집합에 없는 경우입니다.", + ], + [ + "DB에는 있으나 화면 3개 WEHAGO 섹션에 없음", + len(db_not_in_ui), + "DB 비교 결과에는 있으나 현재 projection의 WEHAGO/Unmatched/Recheck 그룹에는 없는 경우입니다.", + ], + [ + "전표번호 키 생성 불가", + len(bad_key_records), + "화면 그룹의 일자 또는 전표번호가 비어 있거나 형식이 달라 비교 키를 만들지 못한 경우입니다.", + ], + ] + append_rows(ws, reason_rows) + style_sheet(ws) + + ws = wb.create_sheet("중복 그룹 상세") + append_rows( + ws, + [[ + "compare_key", + "그룹 수", + "status 목록", + "전표 표시값", + "일자 목록", + "가전표번호/ERP 전표", + "WEHAGO 계정", + "ERP 계정", + "검토 사유", + ]], + ) + for key, records in sorted(duplicate_ui.items(), key=lambda item: (-len(item[1]), item[0])): + append_rows( + ws, + [[ + key, + len(records), + ", ".join(sorted({clean(r.get("status_key")) for r in records})), + ", ".join(sorted({clean(r.get("voucher_no")) for r in records if clean(r.get("voucher_no"))}))[:300], + ", ".join(sorted({clean(r.get("ledger_date")) for r in records if clean(r.get("ledger_date"))}))[:300], + ", ".join(clean(r.get("draft_no")) for r in records if clean(r.get("draft_no")))[:1000], + " | ".join(clean(r.get("ledger_accounts")) for r in records if clean(r.get("ledger_accounts")))[:1000], + " | ".join(clean(r.get("voucher_accounts")) for r in records if clean(r.get("voucher_accounts")))[:1000], + " | ".join(clean(r.get("review_reason")) for r in records if clean(r.get("review_reason")))[:1000], + ]], + ) + style_sheet(ws) + + ws = wb.create_sheet("화면만 있음") + append_rows( + ws, + [[ + "compare_key", + "그룹 수", + "status 목록", + "일자 목록", + "전표 표시값", + "가전표번호/ERP 전표", + "WEHAGO 계정", + "ERP 계정", + "검토 사유", + ]], + ) + for key, records in sorted(ui_not_in_db.items()): + append_rows( + ws, + [[ + key, + len(records), + ", ".join(sorted({clean(r.get("status_key")) for r in records})), + ", ".join(sorted({clean(r.get("ledger_date")) for r in records if clean(r.get("ledger_date"))}))[:300], + ", ".join(sorted({clean(r.get("voucher_no")) for r in records if clean(r.get("voucher_no"))}))[:300], + ", ".join(clean(r.get("draft_no")) for r in records if clean(r.get("draft_no")))[:1000], + " | ".join(clean(r.get("ledger_accounts")) for r in records if clean(r.get("ledger_accounts")))[:1000], + " | ".join(clean(r.get("voucher_accounts")) for r in records if clean(r.get("voucher_accounts")))[:1000], + " | ".join(clean(r.get("review_reason")) for r in records if clean(r.get("review_reason")))[:1000], + ]], + ) + style_sheet(ws) + + ws = wb.create_sheet("DB만 있음") + append_rows( + ws, + [[ + "compare_key", + "DB status", + "voucher_row_count", + "ledger_row_count", + "WEHAGO 차변", + "WEHAGO 대변", + "ERP 차변", + "ERP 대변", + "WEHAGO 계정", + "ERP 계정", + "비고", + ]], + ) + for key, row in db_not_in_ui.items(): + append_rows( + ws, + [[ + key, + clean(row.get("status")), + row.get("ledger_row_count"), + row.get("voucher_row_count"), + row.get("ledger_debit"), + row.get("ledger_credit"), + row.get("voucher_debit"), + row.get("voucher_credit"), + clean(row.get("ledger_accounts")), + clean(row.get("voucher_accounts")), + clean(row.get("notes")), + ]], + ) + style_sheet(ws) + + ws = wb.create_sheet("섹션 원자료") + append_rows( + ws, + [[ + "status", + "compare_key", + "fiscal_year", + "ledger_date", + "voucher_no", + "draft_no", + "ledger_row_count", + "voucher_row_count", + "WEHAGO 차변", + "WEHAGO 대변", + "ERP 차변", + "ERP 대변", + "WEHAGO 계정", + "ERP 계정", + "WEHAGO 거래처", + "ERP 거래처", + "review_reason", + ]], + ) + for record in ui_group_records: + append_rows( + ws, + [[ + record.get("status_key"), + record.get("compare_key"), + record.get("fiscal_year"), + record.get("ledger_date"), + record.get("voucher_no"), + record.get("draft_no"), + record.get("ledger_row_count"), + record.get("voucher_row_count"), + record.get("ledger_debit"), + record.get("ledger_credit"), + record.get("voucher_debit"), + record.get("voucher_credit"), + record.get("ledger_accounts"), + record.get("voucher_accounts"), + record.get("ledger_vendors"), + record.get("voucher_vendors"), + record.get("review_reason"), + ]], + ) + style_sheet(ws) + + EXPORT_DIR.mkdir(parents=True, exist_ok=True) + file_name = f"wehago_voucher_coverage_{YEAR}_{datetime.now().strftime('%Y%m%d_%H%M%S')}.xlsx" + output_path = EXPORT_DIR / file_name + wb.save(output_path) + conn.close() + print( + json.dumps( + { + "file": str(output_path), + "download_url": f"/static/exports/wehago_compare/{file_name}", + "db_count": db_count, + "section_sum": section_sum, + "diff": section_sum - db_count, + "ui_unique_count": ui_unique_count, + "unique_diff": ui_unique_count - db_count, + "duplicate_extra_count": duplicate_extra_count, + "ui_not_in_db": len(ui_not_in_db), + "db_not_in_ui": len(db_not_in_ui), + }, + ensure_ascii=False, + ) + ) + + +if __name__ == "__main__": + main() diff --git a/scripts/build_wehago_raw_trace_candidates.py b/scripts/build_wehago_raw_trace_candidates.py new file mode 100644 index 0000000..a7e5933 --- /dev/null +++ b/scripts/build_wehago_raw_trace_candidates.py @@ -0,0 +1,506 @@ +from __future__ import annotations + +import argparse +import hashlib +import json +import sqlite3 +import sys +from collections import defaultdict +from pathlib import Path +from typing import Any + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + +from runtime_config import DB_PATH # noqa: E402 +from scripts.project_export_cache_ranges import ( # noqa: E402 + current_ready_export_signature, + latest_export_signature, + project_range, + projection_signature, +) +from wehago_compare import ( # noqa: E402 + QUERY_PROJECTION_VERSION, + _parse_row_date_with_year, + _raw_erp_entry_date_values, + _raw_erp_trace_prefilter, + _raw_erp_trace_prefilter_score, + _raw_erp_trace_score, + clean, + parse_amount, +) + + +TRACE_LOGIC_VERSION = "raw-erp-trace-candidate-v1" +SOURCE_STATUSES = ("voucher_unmatched", "voucher_recheck") + + +def _connect(db_path: Path = DB_PATH) -> sqlite3.Connection: + conn = sqlite3.connect(db_path, timeout=30) + conn.row_factory = sqlite3.Row + conn.execute("PRAGMA busy_timeout = 30000") + return conn + + +def _ensure_schema(conn: sqlite3.Connection) -> None: + conn.execute( + """ + CREATE TABLE IF NOT EXISTS wehago_raw_erp_trace_candidate_cache ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + candidate_key TEXT NOT NULL DEFAULT '', + fiscal_year INTEGER NOT NULL, + source_mode TEXT NOT NULL DEFAULT '', + source_signature TEXT NOT NULL DEFAULT '', + logic_version TEXT NOT NULL DEFAULT '', + status_key TEXT NOT NULL DEFAULT '', + group_index INTEGER NOT NULL DEFAULT 0, + row_index INTEGER NOT NULL DEFAULT 0, + ledger_date TEXT NOT NULL DEFAULT '', + voucher_no TEXT NOT NULL DEFAULT '', + ledger_account_name TEXT NOT NULL DEFAULT '', + ledger_vendor TEXT NOT NULL DEFAULT '', + ledger_desc TEXT NOT NULL DEFAULT '', + ledger_amount REAL NOT NULL DEFAULT 0, + erp_draft_no TEXT NOT NULL DEFAULT '', + erp_confirmed_no TEXT NOT NULL DEFAULT '', + erp_account_name TEXT NOT NULL DEFAULT '', + erp_vendor TEXT NOT NULL DEFAULT '', + erp_desc TEXT NOT NULL DEFAULT '', + erp_amount REAL NOT NULL DEFAULT 0, + amount_field TEXT NOT NULL DEFAULT '', + score REAL NOT NULL DEFAULT 0, + matched_case TEXT NOT NULL DEFAULT '', + review_reason TEXT NOT NULL DEFAULT '', + candidate_json TEXT NOT NULL DEFAULT '{}', + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP + ) + """ + ) + columns = { + str(row["name"] or "") + for row in conn.execute("PRAGMA table_info(wehago_raw_erp_trace_candidate_cache)").fetchall() + } + if "candidate_key" not in columns: + conn.execute( + "ALTER TABLE wehago_raw_erp_trace_candidate_cache ADD COLUMN candidate_key TEXT NOT NULL DEFAULT ''" + ) + conn.execute( + """ + CREATE INDEX IF NOT EXISTS idx_wehago_raw_trace_candidate_source + ON wehago_raw_erp_trace_candidate_cache( + fiscal_year, source_mode, source_signature, logic_version, status_key, group_index + ) + """ + ) + conn.execute( + """ + CREATE INDEX IF NOT EXISTS idx_wehago_raw_trace_candidate_voucher + ON wehago_raw_erp_trace_candidate_cache(fiscal_year, ledger_date, voucher_no) + """ + ) + conn.execute( + """ + CREATE INDEX IF NOT EXISTS idx_wehago_raw_trace_candidate_score + ON wehago_raw_erp_trace_candidate_cache(fiscal_year, score DESC) + """ + ) + conn.execute( + """ + CREATE UNIQUE INDEX IF NOT EXISTS idx_wehago_raw_trace_candidate_key + ON wehago_raw_erp_trace_candidate_cache(candidate_key) + WHERE candidate_key <> '' + """ + ) + conn.commit() + + +def _source_signature(conn: sqlite3.Connection, year: int, source_mode: str) -> str: + if source_mode == "current": + yearly_signature = current_ready_export_signature(conn, year) + return projection_signature({year: yearly_signature}, year, year, allow_stale=False) + if source_mode == "stale-diagnostic": + yearly_signature = latest_export_signature(conn, year, allow_stale=True) + return projection_signature({year: yearly_signature}, year, year, allow_stale=True) + raise ValueError(f"Unknown source mode: {source_mode}") + + +def _ensure_projection(conn: sqlite3.Connection, year: int, source_mode: str) -> str: + signature = _source_signature(conn, year, source_mode) + exists = conn.execute( + """ + SELECT 1 + FROM wehago_compare_query_groups + WHERE start_year = ? + AND end_year = ? + AND signature = ? + LIMIT 1 + """, + (year, year, signature), + ).fetchone() + if exists is not None: + return signature + project_range(conn, year, year, allow_stale=(source_mode == "stale-diagnostic")) + return signature + + +def _entry_amount_index(conn: sqlite3.Connection, year: int) -> dict[float, list[dict[str, Any]]]: + rows = conn.execute( + """ + SELECT fiscal_year, proof_date, confirmed_no, draft_no, account_code, account_name, + debit_supply, debit_tax, credit_supply, credit_tax, + support_dept_name, cost_dept_name, desc1, desc2, vendor_name, management_item + FROM wehago_voucher_rows + WHERE fiscal_year = ? + """, + (year,), + ) + indexed: dict[float, list[dict[str, Any]]] = defaultdict(list) + fields = ( + ("debit_supply", "debit", False), + ("credit_supply", "credit", False), + ("debit_tax", "debit", True), + ("credit_tax", "credit", True), + ) + for row in rows: + raw = dict(row) + for field, side, tax_evidence in fields: + amount = parse_amount(raw.get(field)) + if abs(amount) < 0.5: + continue + entry = dict(raw) + entry["_raw_amount_field"] = field + entry["_raw_amount_side"] = side + entry["_raw_amount_value"] = amount + entry["_raw_tax_evidence"] = tax_evidence + entry["_raw_entry_dates"] = tuple(_raw_erp_entry_date_values(entry)) + indexed[round(abs(amount), 2)].append(entry) + return dict(indexed) + + +def _group_filters(args: argparse.Namespace) -> tuple[str, list[Any]]: + filters: list[str] = [] + params: list[Any] = [] + if args.voucher_no: + placeholders = ",".join("?" for _ in args.voucher_no) + filters.append(f"g.voucher_no IN ({placeholders})") + params.extend(args.voucher_no) + if args.ledger_date: + placeholders = ",".join("?" for _ in args.ledger_date) + filters.append(f"g.ledger_date IN ({placeholders})") + params.extend(args.ledger_date) + if not filters: + return "", [] + return " AND " + " AND ".join(filters), params + + +def _load_source_groups( + conn: sqlite3.Connection, + year: int, + signature: str, + args: argparse.Namespace, +) -> list[dict[str, Any]]: + filter_sql, filter_params = _group_filters(args) + limit_sql = " LIMIT ?" if args.limit_groups else "" + offset_sql = " OFFSET ?" if args.limit_groups and args.group_offset else "" + params: list[Any] = [year, year, signature, *SOURCE_STATUSES, *filter_params] + if args.limit_groups: + params.append(int(args.limit_groups)) + if args.group_offset: + params.append(int(args.group_offset)) + groups = conn.execute( + f""" + SELECT g.status_key, g.group_index, g.fiscal_year, g.ledger_date, g.voucher_no, g.draft_no + FROM wehago_compare_query_groups AS g + WHERE g.start_year = ? + AND g.end_year = ? + AND g.signature = ? + AND g.status_key IN ({','.join('?' for _ in SOURCE_STATUSES)}) + {filter_sql} + ORDER BY g.status_key ASC, g.group_index ASC + {limit_sql} + {offset_sql} + """, + params, + ).fetchall() + if not groups: + return [] + + result: list[dict[str, Any]] = [] + for group_row in groups: + row_items = conn.execute( + """ + SELECT * + FROM wehago_compare_query_rows + WHERE start_year = ? + AND end_year = ? + AND signature = ? + AND status_key = ? + AND group_index = ? + ORDER BY row_index ASC + """, + (year, year, signature, group_row["status_key"], group_row["group_index"]), + ).fetchall() + rows = [] + for row in row_items: + data = dict(row) + data.setdefault("group_voucher_no", group_row["voucher_no"]) + data.setdefault("group_draft_no", group_row["draft_no"]) + if not clean(data.get("voucher_no")): + data["voucher_no"] = group_row["voucher_no"] + if not clean(data.get("draft_no")): + data["draft_no"] = group_row["draft_no"] + rows.append(data) + result.append({"group": dict(group_row), "rows": rows}) + return result + + +def _ledger_amount(row: dict[str, Any]) -> float: + return round(max(abs(parse_amount(row.get("ledger_debit"))), abs(parse_amount(row.get("ledger_credit")))), 2) + + +def _entries_in_date_window( + ledger_row: dict[str, Any], + entries: list[dict[str, Any]], + window_days: int, +) -> list[dict[str, Any]]: + if window_days < 0: + return entries + ledger_dates = [ + value + for value in ( + _parse_row_date_with_year(ledger_row, "ledger_date"), + _parse_row_date_with_year(ledger_row, "proof_date"), + ) + if value + ] + if not ledger_dates: + return entries + filtered: list[dict[str, Any]] = [] + for entry in entries: + entry_dates = entry.get("_raw_entry_dates") or () + if any(abs((ledger_date - entry_date).days) <= window_days for ledger_date in ledger_dates for entry_date in entry_dates): + filtered.append(entry) + return filtered + + +def _matched_case(score: float, candidate: dict[str, Any]) -> str: + existing = clean(candidate.get("matched_case")) + if existing: + return existing + if score >= 105: + return "RAW_ERP_HIGH_CONFIDENCE_TRACE_CANDIDATE" + return "RAW_ERP_SOURCE_TRACE_CANDIDATE" + + +def _candidate_identity( + year: int, + source_mode: str, + source_signature: str, + logic_version: str, + status_key: str, + group_index: int, + row_index: int, + candidate: dict[str, Any], +) -> str: + raw = "|".join( + clean(part) + for part in ( + year, + source_mode, + source_signature, + logic_version, + status_key, + group_index, + row_index, + candidate.get("draft_no"), + candidate.get("voucher_confirmed_no"), + candidate.get("voucher_account_name"), + candidate.get("voucher_debit"), + candidate.get("voucher_credit"), + ) + ) + return hashlib.sha1(raw.encode("utf-8")).hexdigest() + + +def _insert_candidates( + conn: sqlite3.Connection, + year: int, + source_mode: str, + source_signature: str, + rows: list[dict[str, Any]], +) -> None: + conn.executemany( + """ + INSERT OR REPLACE INTO wehago_raw_erp_trace_candidate_cache ( + candidate_key, fiscal_year, source_mode, source_signature, logic_version, status_key, + group_index, row_index, ledger_date, voucher_no, + ledger_account_name, ledger_vendor, ledger_desc, ledger_amount, + erp_draft_no, erp_confirmed_no, erp_account_name, erp_vendor, erp_desc, + erp_amount, amount_field, score, matched_case, review_reason, candidate_json + ) + VALUES ( + :candidate_key, :fiscal_year, :source_mode, :source_signature, :logic_version, :status_key, + :group_index, :row_index, :ledger_date, :voucher_no, + :ledger_account_name, :ledger_vendor, :ledger_desc, :ledger_amount, + :erp_draft_no, :erp_confirmed_no, :erp_account_name, :erp_vendor, :erp_desc, + :erp_amount, :amount_field, :score, :matched_case, :review_reason, :candidate_json + ) + """, + rows, + ) + conn.commit() + + +def build_candidates(args: argparse.Namespace) -> dict[str, Any]: + conn = _connect(args.db) + try: + _ensure_schema(conn) + source_signature = _ensure_projection(conn, args.year, args.source) + if args.reset: + conn.execute( + """ + DELETE FROM wehago_raw_erp_trace_candidate_cache + WHERE fiscal_year = ? + AND source_mode = ? + AND source_signature = ? + AND logic_version = ? + """, + (args.year, args.source, source_signature, TRACE_LOGIC_VERSION), + ) + conn.commit() + + amount_index = _entry_amount_index(conn, args.year) + source_groups = _load_source_groups(conn, args.year, source_signature, args) + candidates: list[dict[str, Any]] = [] + scored_rows = 0 + skipped_common_amounts = 0 + + for source_group in source_groups: + group_meta = source_group["group"] + status_key = clean(group_meta.get("status_key")) + group_index = int(group_meta.get("group_index") or 0) + for ledger_row in source_group["rows"]: + amount = _ledger_amount(ledger_row) + if amount <= 0: + continue + entries = list(amount_index.get(amount, []) or []) + if not entries: + continue + if len(entries) > args.date_prefilter_threshold: + date_entries = _entries_in_date_window(ledger_row, entries, args.date_window_days) + if date_entries: + entries = date_entries + if len(entries) > args.prefilter_threshold: + entries = [entry for entry in entries if _raw_erp_trace_prefilter(ledger_row, entry)] + skipped_common_amounts += 1 + if len(entries) > args.max_candidates_per_row: + ranked = [ + (_raw_erp_trace_prefilter_score(ledger_row, entry), entry) + for entry in entries + ] + ranked = [item for item in ranked if item[0] > 0] + ranked.sort(key=lambda item: item[0], reverse=True) + entries = [entry for _score, entry in ranked[: args.max_candidates_per_row]] + + row_candidates: list[tuple[float, dict[str, Any], dict[str, Any]]] = [] + for entry in entries: + score, candidate = _raw_erp_trace_score(ledger_row, entry) + scored_rows += 1 + if score < args.min_score: + continue + row_candidates.append((score, entry, candidate)) + row_candidates.sort(key=lambda item: item[0], reverse=True) + for score, entry, candidate in row_candidates[: args.top_per_row]: + row_index = int(ledger_row.get("row_index") or 0) + matched_case = _matched_case(score, candidate) + payload = { + "candidate_id": _candidate_identity( + args.year, + args.source, + source_signature, + TRACE_LOGIC_VERSION, + status_key, + group_index, + row_index, + candidate, + ), + "ledger_row": ledger_row, + "erp_entry": entry, + "candidate_row": candidate, + } + candidates.append( + { + "candidate_key": payload["candidate_id"], + "fiscal_year": args.year, + "source_mode": args.source, + "source_signature": source_signature, + "logic_version": TRACE_LOGIC_VERSION, + "status_key": status_key, + "group_index": group_index, + "row_index": row_index, + "ledger_date": clean(ledger_row.get("ledger_date")), + "voucher_no": clean(ledger_row.get("voucher_no") or group_meta.get("voucher_no")), + "ledger_account_name": clean(ledger_row.get("ledger_account_name")), + "ledger_vendor": clean(ledger_row.get("ledger_vendor")), + "ledger_desc": clean(ledger_row.get("ledger_desc")), + "ledger_amount": amount, + "erp_draft_no": clean(candidate.get("draft_no")), + "erp_confirmed_no": clean(candidate.get("voucher_confirmed_no")), + "erp_account_name": clean(candidate.get("voucher_account_name")), + "erp_vendor": clean(candidate.get("voucher_vendor")), + "erp_desc": clean(candidate.get("voucher_desc")), + "erp_amount": max( + abs(parse_amount(candidate.get("voucher_debit"))), + abs(parse_amount(candidate.get("voucher_credit"))), + ), + "amount_field": clean(entry.get("_raw_amount_field")), + "score": float(score), + "matched_case": matched_case, + "review_reason": matched_case, + "candidate_json": json.dumps(payload, ensure_ascii=False, default=str), + } + ) + if candidates: + _insert_candidates(conn, args.year, args.source, source_signature, candidates) + return { + "year": args.year, + "source_mode": args.source, + "source_signature": source_signature, + "group_offset": args.group_offset, + "limit_groups": args.limit_groups, + "groups": len(source_groups), + "amount_buckets": len(amount_index), + "scored_rows": scored_rows, + "common_amount_prefilters": skipped_common_amounts, + "inserted_candidates": len(candidates), + } + finally: + conn.close() + + +def main() -> None: + parser = argparse.ArgumentParser(description="Build bounded raw ERP trace candidate cache for voucher compare review.") + parser.add_argument("--year", type=int, required=True) + parser.add_argument( + "--source", + choices=("current", "stale-diagnostic"), + default="current", + help="current는 현재 로직 ready projection만 사용합니다. stale-diagnostic은 진단용 후보 산출에만 사용하세요.", + ) + parser.add_argument("--db", type=Path, default=DB_PATH) + parser.add_argument("--reset", action="store_true") + parser.add_argument("--limit-groups", type=int, default=0) + parser.add_argument("--group-offset", type=int, default=0) + parser.add_argument("--ledger-date", action="append", default=[]) + parser.add_argument("--voucher-no", action="append", default=[]) + parser.add_argument("--min-score", type=float, default=70) + parser.add_argument("--top-per-row", type=int, default=5) + parser.add_argument("--max-candidates-per-row", type=int, default=30) + parser.add_argument("--date-prefilter-threshold", type=int, default=30) + parser.add_argument("--date-window-days", type=int, default=62) + parser.add_argument("--prefilter-threshold", type=int, default=30) + args = parser.parse_args() + print(json.dumps(build_candidates(args), ensure_ascii=False, indent=2)) + + +if __name__ == "__main__": + main() diff --git a/scripts/create_app_user.py b/scripts/create_app_user.py new file mode 100644 index 0000000..b80b504 --- /dev/null +++ b/scripts/create_app_user.py @@ -0,0 +1,72 @@ +#!/usr/bin/env python3 +import argparse +from pathlib import Path +import sys + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT)) + +from main import engine, hash_password, init_db # noqa: E402 +from sqlalchemy import text # noqa: E402 + + +def main() -> int: + parser = argparse.ArgumentParser(description="Create or update an intranet app user.") + parser.add_argument("username") + parser.add_argument("--password", required=True) + parser.add_argument("--display-name", default="") + parser.add_argument("--role", choices=["admin", "viewer"], default="viewer") + parser.add_argument("--inactive", action="store_true") + args = parser.parse_args() + + init_db() + with engine.begin() as conn: + conn.execute( + text( + """ + INSERT INTO app_users ( + username, password_hash, display_name, is_active, is_admin, created_at, updated_at + ) VALUES ( + :username, :password_hash, :display_name, :is_active, :is_admin, + CURRENT_TIMESTAMP, CURRENT_TIMESTAMP + ) + ON CONFLICT(username) DO UPDATE SET + password_hash = excluded.password_hash, + display_name = excluded.display_name, + is_active = excluded.is_active, + is_admin = excluded.is_admin, + updated_at = CURRENT_TIMESTAMP + """ + ), + { + "username": args.username, + "password_hash": hash_password(args.password), + "display_name": args.display_name or args.username, + "is_active": 0 if args.inactive else 1, + "is_admin": 1 if args.role == "admin" else 0, + }, + ) + user_id = conn.execute( + text("SELECT id FROM app_users WHERE username = :username"), + {"username": args.username}, + ).scalar_one() + role_id = conn.execute( + text("SELECT id FROM app_roles WHERE role_key = :role_key"), + {"role_key": args.role}, + ).scalar_one() + conn.execute(text("DELETE FROM app_user_roles WHERE user_id = :user_id"), {"user_id": user_id}) + conn.execute( + text( + """ + INSERT OR IGNORE INTO app_user_roles (user_id, role_id, created_at) + VALUES (:user_id, :role_id, CURRENT_TIMESTAMP) + """ + ), + {"user_id": user_id, "role_id": role_id}, + ) + print(f"ok: {args.username} ({args.role})") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/create_architecture_report.py b/scripts/create_architecture_report.py new file mode 100644 index 0000000..6684a5f --- /dev/null +++ b/scripts/create_architecture_report.py @@ -0,0 +1,397 @@ +from __future__ import annotations + +from datetime import datetime +from pathlib import Path +from xml.sax.saxutils import escape +import math +import struct +import zlib +import zipfile + + +BASE_DIR = Path(__file__).resolve().parent.parent +REPORT_DIR = BASE_DIR / "reports" +OUTPUT_PATH = REPORT_DIR / "my-intranet-app_architecture_report_20260522_v3.docx" +PAGE_WIDTH = 11906 +MARGIN = 620 +CONTENT_WIDTH = PAGE_WIDTH - (MARGIN * 2) + + +def esc(value: object) -> str: + return escape(str(value), {'"': """}) + + +def run(text: str, *, bold: bool = False, size: int | None = None, font: str | None = None) -> str: + props: list[str] = [] + if bold: + props.append("") + if size: + props.append(f'') + if font: + props.append(f'') + rpr = f"{''.join(props)}" if props else "" + preserve = ' xml:space="preserve"' if text[:1].isspace() or text[-1:].isspace() else "" + return f"{rpr}{esc(text)}" + + +def para(text: str = "", *, style: str | None = None, bold: bool = False, size: int | None = None, font: str | None = None, after: int | None = None) -> str: + pprops: list[str] = [] + if style: + pprops.append(f'') + if after is not None: + pprops.append(f'') + ppr = f"{''.join(pprops)}" if pprops else "" + return f"{ppr}{run(text, bold=bold, size=size, font=font)}" + + +def bullet(text: str) -> str: + return para("• " + text, after=60) + + +def table(headers: list[str], rows: list[list[str]], widths: list[int] | None = None) -> str: + if widths is None: + widths = [CONTENT_WIDTH // len(headers)] * len(headers) + grid = "".join(f'' for w in widths) + + def cell(text: str, width: int, header: bool = False) -> str: + fill = '' if header else "" + props = ( + f'{fill}' + '' + '' + ) + return f"{props}{para(text, bold=header, size=16, after=0)}" + + rows_xml = ["" + "".join(cell(h, widths[i], True) for i, h in enumerate(headers)) + ""] + rows_xml.extend( + "" + "".join(cell(c, widths[i]) for i, c in enumerate(row)) + "" + for row in rows + ) + return ( + '' + f'' + "" + f"{grid}{''.join(rows_xml)}" + ) + + +class PngCanvas: + def __init__(self, width: int, height: int, bg: tuple[int, int, int] = (255, 255, 255)): + self.width = width + self.height = height + self.px = bytearray(bg * width * height) + + def set(self, x: int, y: int, color: tuple[int, int, int]) -> None: + if 0 <= x < self.width and 0 <= y < self.height: + i = (y * self.width + x) * 3 + self.px[i : i + 3] = bytes(color) + + def rect(self, x: int, y: int, w: int, h: int, fill: tuple[int, int, int], border: tuple[int, int, int], bw: int = 3) -> None: + for yy in range(y, y + h): + for xx in range(x, x + w): + if x <= xx < x + w and y <= yy < y + h: + self.set(xx, yy, fill) + for n in range(bw): + self.line(x + n, y + n, x + w - 1 - n, y + n, border) + self.line(x + n, y + h - 1 - n, x + w - 1 - n, y + h - 1 - n, border) + self.line(x + n, y + n, x + n, y + h - 1 - n, border) + self.line(x + w - 1 - n, y + n, x + w - 1 - n, y + h - 1 - n, border) + + def line(self, x1: int, y1: int, x2: int, y2: int, color: tuple[int, int, int], width: int = 3) -> None: + dx = abs(x2 - x1) + dy = -abs(y2 - y1) + sx = 1 if x1 < x2 else -1 + sy = 1 if y1 < y2 else -1 + err = dx + dy + x, y = x1, y1 + while True: + r = width // 2 + for yy in range(y - r, y + r + 1): + for xx in range(x - r, x + r + 1): + self.set(xx, yy, color) + if x == x2 and y == y2: + break + e2 = 2 * err + if e2 >= dy: + err += dy + x += sx + if e2 <= dx: + err += dx + y += sy + + def arrow(self, x1: int, y1: int, x2: int, y2: int, color: tuple[int, int, int] = (75, 88, 99)) -> None: + self.line(x1, y1, x2, y2, color, 4) + ang = math.atan2(y2 - y1, x2 - x1) + for a in (ang + 2.55, ang - 2.55): + self.line(x2, y2, int(x2 + 22 * math.cos(a)), int(y2 + 22 * math.sin(a)), color, 4) + + def digit(self, x: int, y: int, digit: str, color: tuple[int, int, int] = (20, 39, 54), scale: int = 8) -> None: + glyphs = { + "0": ["111", "101", "101", "101", "111"], + "1": ["010", "110", "010", "010", "111"], + "2": ["111", "001", "111", "100", "111"], + "3": ["111", "001", "111", "001", "111"], + "4": ["101", "101", "111", "001", "001"], + "5": ["111", "100", "111", "001", "111"], + "6": ["111", "100", "111", "101", "111"], + "7": ["111", "001", "010", "010", "010"], + "8": ["111", "101", "111", "101", "111"], + "9": ["111", "101", "111", "001", "111"], + }[digit] + for gy, row in enumerate(glyphs): + for gx, v in enumerate(row): + if v == "1": + self.rect(x + gx * scale, y + gy * scale, scale - 1, scale - 1, color, color, 1) + + def number_badge(self, x: int, y: int, n: int) -> None: + self.rect(x - 28, y - 28, 56, 56, (255, 255, 255), (47, 111, 163), 4) + self.digit(x - 12, y - 18, str(n), scale=8) + + def png(self) -> bytes: + rows = bytearray() + stride = self.width * 3 + for y in range(self.height): + rows.append(0) + rows.extend(self.px[y * stride : (y + 1) * stride]) + + def chunk(kind: bytes, data: bytes) -> bytes: + return struct.pack(">I", len(data)) + kind + data + struct.pack(">I", zlib.crc32(kind + data) & 0xFFFFFFFF) + + return ( + b"\x89PNG\r\n\x1a\n" + + chunk(b"IHDR", struct.pack(">IIBBBBB", self.width, self.height, 8, 2, 0, 0, 0)) + + chunk(b"IDAT", zlib.compress(bytes(rows), 9)) + + chunk(b"IEND", b"") + ) + + +def architecture_png() -> bytes: + c = PngCanvas(1200, 640, (252, 254, 255)) + blue, green, gold, purple, gray = (232, 242, 252), (237, 248, 237), (255, 248, 230), (246, 239, 250), (78, 91, 104) + c.rect(55, 260, 170, 90, blue, (47, 111, 163)); c.number_badge(140, 305, 1) + c.rect(310, 215, 245, 180, green, (63, 143, 77)); c.number_badge(432, 305, 2) + c.rect(650, 45, 230, 90, gold, (176, 122, 26)); c.number_badge(765, 90, 3) + c.rect(650, 185, 230, 105, blue, (47, 111, 163)); c.number_badge(765, 238, 4) + c.rect(650, 340, 230, 105, purple, (127, 85, 160)); c.number_badge(765, 393, 5) + c.rect(650, 500, 230, 90, gold, (176, 122, 26)); c.number_badge(765, 545, 6) + c.rect(970, 230, 175, 145, green, (63, 143, 77)); c.number_badge(1058, 303, 7) + c.arrow(225, 305, 310, 305, gray); c.arrow(555, 270, 650, 92, gray); c.arrow(555, 305, 650, 238, gray) + c.arrow(555, 335, 650, 393, gray); c.arrow(555, 375, 650, 545, gray); c.arrow(880, 238, 970, 285, gray); c.arrow(880, 393, 970, 325, gray) + return c.png() + + +def flow_png() -> bytes: + c = PngCanvas(1200, 640, (255, 255, 255)) + colors = [(232, 242, 252), (237, 248, 237), (255, 248, 230), (246, 239, 250), (238, 242, 246)] + border = [(47, 111, 163), (63, 143, 77), (176, 122, 26), (127, 85, 160), (90, 105, 120)] + boxes = [(60, 90, 190, 105), (330, 90, 190, 105), (600, 90, 190, 105), (870, 90, 190, 105), + (330, 350, 190, 105), (600, 350, 190, 105), (870, 350, 190, 105)] + for i, (x, y, w, h) in enumerate(boxes, start=1): + c.rect(x, y, w, h, colors[(i - 1) % len(colors)], border[(i - 1) % len(border)]) + c.number_badge(x + w // 2, y + h // 2, i) + gray = (78, 91, 104) + c.arrow(250, 142, 330, 142, gray); c.arrow(520, 142, 600, 142, gray); c.arrow(790, 142, 870, 142, gray) + c.arrow(965, 195, 965, 350, gray); c.arrow(870, 402, 790, 402, gray); c.arrow(600, 402, 520, 402, gray) + c.arrow(425, 350, 425, 195, gray) + return c.png() + + +def image_paragraph(rel_id: str, width_emu: int = 6_950_000, height_emu: int = 3_700_000) -> str: + return f""" + + + + + + + + + +""" + + +def body_xml() -> str: + now = datetime.now().strftime("%Y-%m-%d %H:%M") + p: list[str] = [] + p.append(para("my-intranet-app 아키텍처 분석 보고서", style="Title")) + p.append(para(f"작성일: {now} / 기준 경로: {BASE_DIR}", style="Subtitle")) + p.append(para("본 보고서의 주 목적은 현재 my-intranet-app의 아키텍처, 데이터 흐름, 주요 결합도와 개선 포인트를 분석하는 것입니다. WSL 제로 세팅 시 보존 범위는 마지막 운영 고려사항으로 덧붙였습니다.")) + + p.append(para("1. 분석 결론", style="Heading1")) + p.append(bullet("현재 시스템은 FastAPI 단일 애플리케이션 안에 화면 렌더링, JSON API, SQLite 접근, 캐시, 백그라운드 작업, 외부 ERP/WEHAGO 연동이 결합된 내부 업무 앱입니다.")) + p.append(bullet("업무 데이터의 중심은 SQLite data.db이며, 프로젝트/회계/전표비교/캐시/작업 이력이 같은 DB에 함께 저장됩니다.")) + p.append(bullet("구조 안정성 측면의 가장 큰 리스크는 main.py의 과도한 책임 집중과 대용량 SQLite 파일에 운영 데이터와 캐시가 공존하는 점입니다.")) + p.append(bullet("속도는 런타임 캐시, system_page_cache, WEHAGO query/export projection, background job으로 보완하고 있으나, 장기적으로는 DB 슬림화와 모듈 분리가 필요합니다.")) + + p.append(para("2. 아키텍처 개요 이미지", style="Heading1")) + p.append(image_paragraph("rId10")) + p.append(table( + ["번호", "구성요소", "설명"], + [ + ["1", "Browser/UI", "Jinja2로 내려받은 HTML과 브라우저 fetch API가 화면 갱신을 담당"], + ["2", "FastAPI Runtime(main.py)", "라우트, HTML 렌더링, JSON API, DB 초기화, 캐시, 작업 큐 진입점"], + ["3", "Templates", "dashboard/projects/process_cost/wehago_compare 등 서버 렌더링 화면"], + ["4", "SQLite data.db", "업무 데이터, 설정, 캐시, 작업 상태의 중심 저장소"], + ["5", "WEHAGO Compare", "전표 비교 전문 로직. 파일 정규화, 매칭, 리뷰, 추천, export"], + ["6", "Hanmac External", "pymysql 기반 외부 ERP DB 조회 및 집계"], + ["7", "Workers/Jobs", "캐시 재생성, snapshot, export, 유지보수 작업"], + ], + [750, 2500, CONTENT_WIDTH - 3250], + )) + + p.append(para("3. 런타임/기술 스택", style="Heading1")) + p.append(table( + ["영역", "현재 구성", "역할/관찰"], + [ + ["Web", "FastAPI, Uvicorn", "ASGI 기반 단일 서버. HTML과 JSON API를 같은 앱에서 제공"], + ["UI", "Jinja2, CSS, Vanilla JS", "템플릿별 inline CSS/JS가 많고 fetch 기반 동적 로딩을 사용"], + ["DB", "SQLite, SQLAlchemy", "로컬 data.db를 중심으로 업무/캐시/작업 데이터 저장"], + ["Excel", "openpyxl", "업로드 파일 파싱, WEHAGO 상태별 xlsx 내보내기"], + ["External DB", "pymysql", "Hanmac 외부 MySQL 접속/preview/aggregate"], + ["DB Browser", "Datasette", "/db 및 /db-browser에서 내부 DB 조회 지원"], + ], + [1400, 2600, CONTENT_WIDTH - 4000], + )) + + p.append(para("4. 코드 구조 분석", style="Heading1")) + p.append(table( + ["파일/디렉터리", "아키텍처상 책임", "개선 관점"], + [ + ["main.py", "FastAPI app 생성, 라우트, DB 초기화, 화면별 bootstrap, 저장 API, system job, Hanmac 연동", "routers/services/repositories/jobs로 단계 분리 필요"], + ["wehago_compare.py", "WEHAGO/ERP 전표 비교 도메인. 매칭, 리뷰, 추천, projection/cache/export", "비교 도메인으로 분리된 점은 좋으나 내부 함수가 매우 크고 캐시 책임도 함께 큼"], + ["templates/*.html", "서버 렌더링 화면과 화면별 대형 JS/CSS", "공통 fetch/job polling/table rendering을 static 모듈로 분리 가능"], + ["scripts/", "서버 실행, WEHAGO 수집/검증/보정, Windows portproxy", "운영 자동화와 일회성 보정 스크립트 구분 필요"], + ["data.db", "업무 데이터와 캐시/작업 이력 저장", "운영 데이터와 재생성 캐시 분리 또는 보존 정책 필요"], + ["backups/", "수동/시점 백업", "복구 가치 기준으로 최신/중요 백업만 관리 권장"], + ], + [2000, 4300, CONTENT_WIDTH - 6300], + )) + + p.append(para("5. 주요 화면/API 경계", style="Heading1")) + p.append(table( + ["화면/도메인", "대표 라우트", "핵심 데이터 흐름"], + [ + ["Dashboard", "/, /bootstrap-data, /dashboard/api/rebuild-cache", "transactions/project 집계 -> bootstrap/cache -> 차트/KPI"], + ["Projects", "/projects, /projects/bootstrap-data, /projects/save-json", "project_* 조회/저장 -> 미계약/관련 프로젝트/비교 상세 API"], + ["Process Cost", "/process-cost, /process-cost/bootstrap-data", "Hanmac/WEHAGO 소스 선택 -> 프로젝트별 수익/비용/진척/비율 계산"], + ["Annual Summary", "/annual-summary, /annual-summary/bootstrap-data", "연도/월별 회계 집계 -> 차트 데이터"], + ["WEHAGO Compare", "/wehago-compare/api/*", "원천 rows -> 비교 결과 -> 상태별 상세 -> 리뷰/매칭/export"], + ["Hanmac Browser", "/hanmac-browser/api/*", "외부 MySQL 조회 -> preview/aggregate cache -> CSV export"], + ["System Jobs", "/api/system-jobs/*", "무거운 cache rebuild/export 작업 생성 및 진행률 조회"], + ], + [1900, 3300, CONTENT_WIDTH - 5200], + )) + + p.append(para("6. 데이터 흐름 이미지", style="Heading1")) + p.append(image_paragraph("rId11")) + p.append(table( + ["번호", "흐름 단계", "설명"], + [ + ["1", "원천 데이터", "Excel 업로드, WEHAGO_DB 파일, Hanmac 외부 DB, 사용자 입력"], + ["2", "수집/정규화", "main.py와 wehago_compare.py에서 날짜/금액/전표번호/프로젝트코드 정규화"], + ["3", "영속 저장", "transactions, project_*, wehago_* 테이블에 저장"], + ["4", "집계/비교 계산", "프로젝트 원가, 연도 집계, 전표 매칭, 상태별 metric 계산"], + ["5", "캐시/작업", "system_page_cache, wehago query cache, background jobs로 무거운 조회 완화"], + ["6", "API 응답", "bootstrap-data 및 상세 JSON API로 화면에 전달"], + ["7", "화면 표시/export", "Jinja2 화면, fetch 갱신, xlsx/csv 다운로드"], + ], + [750, 2200, CONTENT_WIDTH - 2950], + )) + + p.append(para("7. 데이터 아키텍처", style="Heading1")) + p.append(table( + ["테이블 그룹", "대표 테이블", "아키텍처 의미"], + [ + ["업무 원장", "transactions", "회계 전표/거래 행의 중심 원천"], + ["프로젝트", "project_basic_info, project_status, project_contract_info, project_billing_entries, project_collection_entries", "프로젝트 기본/계약/청구/수금/상태"], + ["프로젝트 분석", "project_exec_budget_entries, project_actual_input_entries, project_task_plan_entries, project_analysis_settings", "원가/투입/계획/분석 설정"], + ["WEHAGO 비교 원천", "wehago_source_files, wehago_voucher_rows, wehago_ledger_rows", "ERP 전표와 WEHAGO 원장 정규화 데이터"], + ["WEHAGO 비교 결과", "wehago_comparison_results, wehago_recheck_reviews, wehago_manual_pair_matches", "비교 결과와 사용자가 만든 검토/매칭 상태"], + ["캐시/작업", "system_page_cache, system_jobs, wehago_*_cache, hanmac_*_cache", "속도 보완용. 일부는 재생성 가능"], + ["설정/운영", "app_option_items, app_keyword_rules, hanmac_holidays, db_backup_history", "분류 규칙, 옵션, 휴일, 백업 이력"], + ], + [1800, 4300, CONTENT_WIDTH - 6100], + )) + + p.append(para("8. 구조 안정성/속도 개선 포인트", style="Heading1")) + p.append(table( + ["개선 영역", "현재 리스크", "권장 방향"], + [ + ["main.py 책임 분리", "라우트/DB/worker/비즈니스 로직 집중", "도메인별 router, service, repository, job 모듈로 점진 분리"], + ["DB 관리", "14GB 수준 SQLite에 운영 데이터와 캐시 공존", "캐시 보존 정책, VACUUM/ANALYZE, cache DB 분리 검토"], + ["WEHAGO 비교", "projection/cache가 많고 상태별 경로가 복잡", "상태별 query path 정리, 캐시 키 문서화, 재계산 CLI 표준화"], + ["Frontend", "템플릿별 inline JS/CSS가 큼", "공통 fetch/polling/render 유틸을 static JS/CSS로 이동"], + ["작업 큐", "DB 테이블 기반 작업 상태와 런타임 worker 결합", "작업 타입/상태 전이 규칙 문서화 및 stale job 정리 강화"], + ["테스트", "구조 변경 후 회귀 확인 경로 부족", "핵심 bootstrap API와 저장 API smoke test 추가"], + ], + [1800, 3300, CONTENT_WIDTH - 5100], + )) + + p.append(para("9. WSL 제로 세팅 시 보존 범위(부가 운영 고려사항)", style="Heading1")) + p.append(para("이 절은 이관 방법 보고서가 아니라, 현재 아키텍처를 보존 가능한 상태로 유지하려면 어떤 정보를 어느 수준까지 관리해야 하는지에 대한 부가 판단입니다.")) + p.append(table( + ["대상", "보존 수준", "이유"], + [ + ["코드", "필수", "main.py, wehago_compare.py, templates, scripts, requirements는 앱 동작의 본체"], + ["SQLite DB", "필수", "업무 데이터와 사용자 검토/매칭/설정이 data.db에 존재. 코드만으로 복구 불가"], + ["WAL/SHM", "조건부 필수", "실행 중 복사라면 data.db-wal 변경분 누락 위험. 서버 중지 또는 checkpoint/backup 필요"], + ["DB dump", "필수 또는 강력 권장", "새 환경 복원 검증용. 현재 dump.sql은 0 bytes라 유효하지 않음"], + ["원천 Excel/WEHAGO_DB", "강력 권장", "재검증/재처리/비교 로직 개선 시 기준 자료"], + ["사용자 검토/수동매칭", "필수", "wehago_recheck_reviews, wehago_manual_pair_matches 등은 재생성 어려움"], + ["캐시 테이블", "선택", "속도에는 도움되지만 구조 개선 후 재생성 가능. DB 슬림화 대상"], + ["backups", "선별", "최신 정상본과 구조 변경 직전본 위주로 보존"], + [".venv/__pycache__", "불필요", "새 WSL에서 재생성"], + ], + [2200, 1600, CONTENT_WIDTH - 3800], + )) + + p.append(para("10. 최종 권고", style="Heading1")) + p.append(bullet("아키텍처 개선의 1순위는 기능 추가보다 책임 분리와 DB/캐시 관리 기준 정립입니다.")) + p.append(bullet("WSL 제로 세팅을 하더라도 목표는 '코드 이관'이 아니라 '동일 업무 상태를 복원 가능한 형태로 보존'하는 것입니다.")) + p.append(bullet("구조 개선 시작 전에는 유효한 SQLite 백업/dump를 새로 만들고, 원천파일과 사용자 검토 데이터의 보존 여부를 반드시 확인해야 합니다.")) + + p.append(para("Appendix. 관찰된 현재 상태", style="Heading1")) + p.append(bullet("data.db 약 14GB, data.db-wal 약 183MB, dump.sql 0 bytes 상태를 확인했습니다.")) + p.append(bullet("현재 git working tree에는 기존 수정 파일과 미추적 파일이 존재합니다. 구조 변경 전 기준점을 별도로 고정하는 것이 좋습니다.")) + p.append(bullet("검토 파일: requirements.txt, main.py, wehago_compare.py, templates/*.html, data.db sqlite_master schema.")) + + sect = f'' + return "".join(p) + sect + + +def styles_xml() -> str: + return """ + + + + + + +""" + + +def write_docx() -> None: + created = datetime.utcnow().strftime("%Y-%m-%dT%H:%M:%SZ") + document = f""" +{body_xml()}""" + files = { + "[Content_Types].xml": """""", + "_rels/.rels": """""", + "word/_rels/document.xml.rels": """""", + "word/document.xml": document, + "word/styles.xml": styles_xml(), + "word/settings.xml": """""", + "word/media/architecture.png": architecture_png(), + "word/media/dataflow.png": flow_png(), + "docProps/core.xml": f"""my-intranet-app 아키텍처 분석 보고서CodexCodex{created}{created}""", + "docProps/app.xml": """Codex OOXML Generator""", + } + REPORT_DIR.mkdir(exist_ok=True) + with zipfile.ZipFile(OUTPUT_PATH, "w", compression=zipfile.ZIP_DEFLATED) as docx: + for name, content in files.items(): + docx.writestr(name, content) + + +if __name__ == "__main__": + write_docx() + print(OUTPUT_PATH) diff --git a/scripts/dev_down.ps1 b/scripts/dev_down.ps1 new file mode 100644 index 0000000..4e5220e --- /dev/null +++ b/scripts/dev_down.ps1 @@ -0,0 +1,6 @@ +$ErrorActionPreference = "Stop" + +$projectRoot = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path +Set-Location -LiteralPath $projectRoot + +docker compose -f compose.yaml -f compose.dev.yaml down diff --git a/scripts/dev_up.ps1 b/scripts/dev_up.ps1 new file mode 100644 index 0000000..3d32173 --- /dev/null +++ b/scripts/dev_up.ps1 @@ -0,0 +1,65 @@ +param( + [switch]$Build +) + +$ErrorActionPreference = "Stop" + +$projectRoot = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path +Set-Location -LiteralPath $projectRoot + +$imageInputFiles = @( + "Dockerfile", + "requirements.txt", + ".dockerignore", + "compose.yaml", + "compose.dev.yaml" +) +$fingerprintLines = foreach ($relativePath in $imageInputFiles) { + $filePath = Join-Path $projectRoot $relativePath + if (Test-Path -LiteralPath $filePath) { + "$relativePath=$((Get-FileHash -Algorithm SHA256 -LiteralPath $filePath).Hash)" + } else { + "$relativePath=MISSING" + } +} +$fingerprintText = $fingerprintLines -join "`n" +$hashAlgorithm = [System.Security.Cryptography.SHA256]::Create() +try { + $fingerprintBytes = [System.Text.Encoding]::UTF8.GetBytes($fingerprintText) + $currentFingerprint = ([System.BitConverter]::ToString($hashAlgorithm.ComputeHash($fingerprintBytes))).Replace("-", "").ToLowerInvariant() +} finally { + $hashAlgorithm.Dispose() +} +$stateDir = Join-Path $projectRoot ".dev-state" +$stateFile = Join-Path $stateDir "docker-image-inputs.sha256" +$previousFingerprint = if (Test-Path -LiteralPath $stateFile) { + (Get-Content -LiteralPath $stateFile -Raw).Trim() +} else { + "" +} +$inputsChanged = -not $previousFingerprint -or $previousFingerprint -ne $currentFingerprint +$shouldBuild = $Build -or $inputsChanged + +$composeArgs = @("-f", "compose.yaml", "-f", "compose.dev.yaml", "up", "-d") +if ($shouldBuild) { + $composeArgs += "--build" +} + +if ($Build) { + Write-Host "Image rebuild requested explicitly with -Build." +} elseif ($inputsChanged) { + Write-Host "Docker image inputs changed or were not recorded yet; rebuilding automatically." +} else { + Write-Host "Docker image inputs are unchanged; starting without rebuild." +} + +docker compose @composeArgs +New-Item -ItemType Directory -Force -Path $stateDir | Out-Null +Set-Content -LiteralPath $stateFile -Value $currentFingerprint -NoNewline +docker compose -f compose.yaml -f compose.dev.yaml ps + +Write-Host "" +Write-Host "Development server: http://127.0.0.1:8010" +Write-Host "Source edits reload automatically." +Write-Host "Dependency and Docker image-setting changes are detected and rebuilt automatically." +Write-Host "Use -Build only when you intentionally want to force a clean image check." diff --git a/scripts/import_hanmac_erp_260507.py b/scripts/import_hanmac_erp_260507.py index 4f0aea6..3b10521 100644 --- a/scripts/import_hanmac_erp_260507.py +++ b/scripts/import_hanmac_erp_260507.py @@ -24,9 +24,9 @@ from wehago_compare import ( rebuild_comparison_results, upsert_source_file, ) +from runtime_config import DB_PATH -DB_PATH = BASE_DIR / "data.db" ENGINE = create_engine(f"sqlite:///{DB_PATH}", connect_args={"check_same_thread": False}) LEDGER_FILES = { diff --git a/scripts/project_export_cache_ranges.py b/scripts/project_export_cache_ranges.py new file mode 100644 index 0000000..de08a33 --- /dev/null +++ b/scripts/project_export_cache_ranges.py @@ -0,0 +1,1039 @@ +from __future__ import annotations + +import argparse +import hashlib +import re +import sqlite3 +from collections import defaultdict +from pathlib import Path +from typing import Any + +import sys + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + +from runtime_config import DB_PATH +from wehago_compare import ( + QUERY_PROJECTION_VERSION, + _apply_wehago_cancel_reissue_recheck, + _is_wehago_excepted_voucher_group, + _move_wehago_confirmed_reversal_pairs_to_excepted, + _move_wehago_offset_tax_invoice_groups_to_excepted, + clean, + parse_amount, +) + + +STATUSES = ( + "voucher_matched", + "erp_voucher_matched", + "voucher_unmatched", + "erp_voucher_unmatched", + "voucher_recheck", + "voucher_excepted", +) +STANDARD_STATUSES = ("matched", "ledger_only", "voucher_only", "amount_mismatch") + + +def parse_range(value: str) -> tuple[int, int]: + raw = str(value or "").strip() + if "-" not in raw: + year = int(raw) + return year, year + left, right = raw.split("-", 1) + start_year = int(left) + end_year = int(right) + if start_year > end_year: + start_year, end_year = end_year, start_year + return start_year, end_year + + +def current_ready_export_signature(conn: sqlite3.Connection, year: int) -> str: + status_row = conn.execute( + """ + SELECT snapshot_signature + FROM wehago_snapshot_status + WHERE fiscal_year = ? + AND state = 'ready' + AND COALESCE(snapshot_signature, '') <> '' + LIMIT 1 + """, + (year,), + ).fetchone() + if status_row is not None: + ready_signature = str(status_row["snapshot_signature"] or "") + exists = conn.execute( + """ + SELECT 1 + FROM wehago_compare_export_row_cache + WHERE fiscal_year = ? + AND snapshot_signature = ? + LIMIT 1 + """, + (year, ready_signature), + ).fetchone() + if exists is not None: + return ready_signature + raise RuntimeError(f"{year}년 현재 로직 전표 행 캐시가 아직 준비되지 않았습니다.") + + +def latest_export_signature(conn: sqlite3.Connection, year: int, *, allow_stale: bool = False) -> str: + if not allow_stale: + return current_ready_export_signature(conn, year) + row = conn.execute( + """ + SELECT snapshot_signature, COUNT(*) AS row_count, MAX(rowid) AS max_rowid + FROM wehago_compare_export_row_cache + WHERE fiscal_year = ? + AND COALESCE(snapshot_signature, '') <> '' + GROUP BY snapshot_signature + ORDER BY + CASE + WHEN snapshot_signature LIKE 'voucher-summary-v8|recheck-v21%' THEN 0 + WHEN snapshot_signature LIKE 'voucher-summary-v7|recheck-v20%' THEN 1 + WHEN snapshot_signature LIKE 'voucher-summary-v7|recheck-v19%' THEN 2 + WHEN snapshot_signature LIKE 'voucher-summary-v7|recheck-v18%' THEN 3 + WHEN snapshot_signature LIKE 'voucher-summary-v7|recheck-v17%' THEN 4 + WHEN snapshot_signature LIKE '%recheck-v20%' THEN 5 + WHEN snapshot_signature LIKE '%recheck-v19%' THEN 6 + WHEN snapshot_signature LIKE '%recheck-v18%' THEN 7 + WHEN snapshot_signature LIKE '%recheck-v17%' THEN 8 + ELSE 9 + END ASC, + row_count DESC, + max_rowid DESC + LIMIT 1 + """, + (year,), + ).fetchone() + if row is None: + raise RuntimeError(f"No export cache found for {year}.") + return str(row["snapshot_signature"] or "") + + +def projection_signature( + signatures: dict[int, str], + start_year: int, + end_year: int, + *, + allow_stale: bool = False, + context_signatures: dict[int, str] | None = None, +) -> str: + raw = "|".join(f"{year}:{signatures[year]}" for year in sorted(signatures)) + if context_signatures: + context_raw = "|".join(f"{year}:{context_signatures[year]}" for year in sorted(context_signatures)) + raw = f"{raw}|context:{context_raw}" + digest = hashlib.sha1(raw.encode("utf-8")).hexdigest() + mode = "export-cache-stale" if allow_stale else "export-cache-current" + return f"{QUERY_PROJECTION_VERSION}|{mode}|{start_year}-{end_year}|{digest}" + + +def _context_year_signatures( + conn: sqlite3.Connection, + start_year: int, + end_year: int, + *, + allow_stale: bool, +) -> dict[int, str]: + signatures: dict[int, str] = {} + for year in range(start_year - 1, end_year + 2): + try: + signatures[year] = latest_export_signature(conn, year, allow_stale=allow_stale) + except Exception: + continue + return signatures + + +def group_key(row: sqlite3.Row) -> tuple[int, str, int]: + return int(row["fiscal_year"] or 0), str(row["status_key"] or ""), int(row["group_sort"] or 0) + + +def row_to_dict(row: sqlite3.Row) -> dict[str, Any]: + return {key: row[key] for key in row.keys()} + + +def _projection_source_table( + conn: sqlite3.Connection, + start_year: int, + end_year: int, + signatures: dict[int, str], + *, + source_start_year: int | None = None, + source_end_year: int | None = None, +) -> None: + conn.execute("DROP TABLE IF EXISTS temp._wehago_projection_signatures") + conn.execute("DROP TABLE IF EXISTS temp._wehago_projection_source") + conn.execute( + """ + CREATE TEMP TABLE _wehago_projection_signatures ( + fiscal_year INTEGER PRIMARY KEY, + snapshot_signature TEXT NOT NULL + ) + """ + ) + conn.executemany( + """ + INSERT INTO _wehago_projection_signatures (fiscal_year, snapshot_signature) + VALUES (?, ?) + """, + [(year, signature) for year, signature in sorted(signatures.items())], + ) + conn.execute( + f""" + CREATE TEMP TABLE _wehago_projection_source AS + SELECT c.* + FROM wehago_compare_export_row_cache AS c + JOIN _wehago_projection_signatures AS s + ON s.fiscal_year = c.fiscal_year + AND s.snapshot_signature = c.snapshot_signature + WHERE c.fiscal_year BETWEEN ? AND ? + AND c.status_key IN ({','.join('?' for _ in STATUSES)}) + """, + (source_start_year or start_year, source_end_year or end_year, *STATUSES), + ) + conn.execute( + """ + CREATE INDEX _idx_wehago_projection_source_group + ON _wehago_projection_source(status_key, fiscal_year, group_sort, row_sort) + """ + ) + + +def _prune_old_projection_signatures( + conn: sqlite3.Connection, + start_year: int, + end_year: int, + signature: str, + *, + keep: int = 3, +) -> None: + obsolete_rows = conn.execute( + """ + SELECT signature + FROM wehago_compare_query_groups + WHERE start_year = ? + AND end_year = ? + AND signature LIKE ? + AND signature <> ? + GROUP BY signature + ORDER BY MAX(updated_at) DESC + LIMIT -1 OFFSET ? + """, + (start_year, end_year, f"{QUERY_PROJECTION_VERSION}|%", signature, max(0, keep - 1)), + ).fetchall() + obsolete_signatures = [str(row["signature"] or "") for row in obsolete_rows if str(row["signature"] or "")] + if not obsolete_signatures: + return + placeholders = ",".join("?" for _ in obsolete_signatures) + params = (start_year, end_year, *obsolete_signatures) + conn.execute( + f""" + DELETE FROM wehago_compare_query_rows + WHERE start_year = ? + AND end_year = ? + AND signature IN ({placeholders}) + """, + params, + ) + conn.execute( + f""" + DELETE FROM wehago_compare_query_groups + WHERE start_year = ? + AND end_year = ? + AND signature IN ({placeholders}) + """, + params, + ) + + +def _projection_group_identity(group: dict[str, Any]) -> tuple[Any, ...]: + summary = group.get("summary") or group + return ( + int(summary.get("fiscal_year") or 0), + clean(summary.get("ledger_date")), + clean(summary.get("voucher_no")), + int(summary.get("group_index") or 0), + ) + + +def _projection_wehago_identity(group: dict[str, Any]) -> str: + summary = group.get("summary") or group + fiscal_year = int(summary.get("fiscal_year") or 0) + date_digits = re.findall(r"\d+", clean(summary.get("ledger_date"))) + if len(date_digits) >= 3: + year, month, day = int(date_digits[-3]), int(date_digits[-2]), int(date_digits[-1]) + elif len(date_digits) >= 2 and fiscal_year: + year, month, day = fiscal_year, int(date_digits[-2]), int(date_digits[-1]) + else: + return "" + voucher_digits = re.sub(r"\D", "", clean(summary.get("voucher_no"))) + if not voucher_digits: + return "" + return f"{year:04d}{month:02d}{day:02d}-{int(voucher_digits):05d}" + + +def _append_reason(existing: Any, reason: str) -> str: + existing_text = clean(existing) + reason_text = clean(reason) + if not reason_text: + return existing_text + parts = [part.strip() for part in existing_text.split("/") if part.strip()] + if reason_text not in parts: + parts.append(reason_text) + return " / ".join(parts) + + +def _load_projection_group( + conn: sqlite3.Connection, + start_year: int, + end_year: int, + signature: str, + status_key: str, + group_index: int, +) -> dict[str, Any]: + summary = dict( + conn.execute( + """ + SELECT * + FROM wehago_compare_query_groups + WHERE start_year = ? + AND end_year = ? + AND signature = ? + AND status_key = ? + AND group_index = ? + """, + (start_year, end_year, signature, status_key, group_index), + ).fetchone() + ) + rows = [ + dict(row) + for row in conn.execute( + """ + SELECT * + FROM wehago_compare_query_rows + WHERE start_year = ? + AND end_year = ? + AND signature = ? + AND status_key = ? + AND group_index = ? + ORDER BY row_index + """, + (start_year, end_year, signature, status_key, group_index), + ).fetchall() + ] + return {"summary": summary, "rows": rows} + + +def _move_projection_group_to_excepted( + conn: sqlite3.Connection, + start_year: int, + end_year: int, + signature: str, + old_group_index: int, + new_group_index: int, + reason: str, + old_status_key: str = "voucher_unmatched", +) -> None: + row = conn.execute( + """ + SELECT review_reason + FROM wehago_compare_query_groups + WHERE start_year = ? + AND end_year = ? + AND signature = ? + AND status_key = ? + AND group_index = ? + """, + (start_year, end_year, signature, old_status_key, old_group_index), + ).fetchone() + if row is None: + return + review_reason = _append_reason(row["review_reason"], reason) + conn.execute( + """ + UPDATE wehago_compare_query_groups + SET status_key = 'voucher_excepted', + group_index = ?, + review_reason = ?, + search_text = search_text || ' ' || ?, + updated_at = CURRENT_TIMESTAMP + WHERE start_year = ? + AND end_year = ? + AND signature = ? + AND status_key = ? + AND group_index = ? + """, + (new_group_index, review_reason, reason, start_year, end_year, signature, old_status_key, old_group_index), + ) + conn.execute( + """ + UPDATE wehago_compare_query_rows + SET status_key = 'voucher_excepted', + group_index = ?, + status_label = 'Excepted', + review_reason = CASE + WHEN COALESCE(review_reason, '') = '' THEN ? + WHEN INSTR(review_reason, ?) > 0 THEN review_reason + ELSE review_reason || ' / ' || ? + END, + updated_at = CURRENT_TIMESTAMP + WHERE start_year = ? + AND end_year = ? + AND signature = ? + AND status_key = ? + AND group_index = ? + """, + (new_group_index, reason, reason, reason, start_year, end_year, signature, old_status_key, old_group_index), + ) + + +def _move_projection_group_to_recheck( + conn: sqlite3.Connection, + start_year: int, + end_year: int, + signature: str, + old_status_key: str, + old_group_index: int, + new_group_index: int, + reason: str, +) -> None: + row = conn.execute( + """ + SELECT review_reason + FROM wehago_compare_query_groups + WHERE start_year = ? + AND end_year = ? + AND signature = ? + AND status_key = ? + AND group_index = ? + """, + (start_year, end_year, signature, old_status_key, old_group_index), + ).fetchone() + if row is None: + return + review_reason = _append_reason(row["review_reason"], reason) + conn.execute( + """ + UPDATE wehago_compare_query_groups + SET status_key = 'voucher_recheck', + group_index = ?, + review_reason = ?, + search_text = search_text || ' ' || ?, + updated_at = CURRENT_TIMESTAMP + WHERE start_year = ? + AND end_year = ? + AND signature = ? + AND status_key = ? + AND group_index = ? + """, + (new_group_index, review_reason, reason, start_year, end_year, signature, old_status_key, old_group_index), + ) + conn.execute( + """ + UPDATE wehago_compare_query_rows + SET status_key = 'voucher_recheck', + group_index = ?, + status_label = 'Recheck', + review_reason = CASE + WHEN COALESCE(review_reason, '') = '' THEN ? + WHEN INSTR(review_reason, ?) > 0 THEN review_reason + ELSE review_reason || ' / ' || ? + END, + updated_at = CURRENT_TIMESTAMP + WHERE start_year = ? + AND end_year = ? + AND signature = ? + AND status_key = ? + AND group_index = ? + """, + (new_group_index, reason, reason, reason, start_year, end_year, signature, old_status_key, old_group_index), + ) + + +def _load_projection_context_groups_from_source( + conn: sqlite3.Connection, + start_year: int, + end_year: int, +) -> dict[str, list[dict[str, Any]]]: + source_rows = conn.execute( + f""" + SELECT * + FROM _wehago_projection_source + WHERE fiscal_year NOT BETWEEN ? AND ? + AND status_key IN ({','.join('?' for _ in ('voucher_matched', 'voucher_unmatched', 'voucher_recheck'))}) + ORDER BY status_key, fiscal_year, group_sort, row_sort + """, + (start_year, end_year, "voucher_matched", "voucher_unmatched", "voucher_recheck"), + ).fetchall() + grouped: dict[tuple[str, int, int], list[sqlite3.Row]] = defaultdict(list) + for row in source_rows: + grouped[(clean(row["status_key"]), int(row["fiscal_year"] or 0), int(row["group_sort"] or 0))].append(row) + + groups_by_status: dict[str, list[dict[str, Any]]] = { + "voucher_matched": [], + "voucher_unmatched": [], + "voucher_recheck": [], + } + for (status_key, fiscal_year, group_sort), rows in grouped.items(): + if status_key not in groups_by_status: + continue + ledger_dates = [clean(row["ledger_date"]) for row in rows if clean(row["ledger_date"])] + summary = { + "status_key": status_key, + "group_index": group_sort, + "projection_context_only": "1", + "fiscal_year": fiscal_year, + "ledger_date": min(ledger_dates) if ledger_dates else "", + "proof_date": "", + "voucher_no": clean(next((row["group_voucher_no"] for row in rows if clean(row["group_voucher_no"])), "")) or clean(next((row["voucher_no"] for row in rows if clean(row["voucher_no"])), "")), + "draft_no": clean(next((row["group_draft_no"] for row in rows if clean(row["group_draft_no"])), "")) or clean(next((row["draft_no"] for row in rows if clean(row["draft_no"])), "")), + "ledger_debit": max(parse_amount(row["group_ledger_debit"]) for row in rows), + "ledger_credit": max(parse_amount(row["group_ledger_credit"]) for row in rows), + "voucher_debit": max(parse_amount(row["group_voucher_debit"]) for row in rows), + "voucher_credit": max(parse_amount(row["group_voucher_credit"]) for row in rows), + "ledger_accounts": clean(next((row["group_ledger_accounts"] for row in rows if clean(row["group_ledger_accounts"])), "")), + "voucher_accounts": clean(next((row["group_voucher_accounts"] for row in rows if clean(row["group_voucher_accounts"])), "")), + "ledger_vendors": clean(next((row["group_ledger_vendors"] for row in rows if clean(row["group_ledger_vendors"])), "")), + "voucher_vendors": clean(next((row["group_voucher_vendors"] for row in rows if clean(row["group_voucher_vendors"])), "")), + "review_reason": "EXPORT_CACHE_CONTEXT", + } + group_rows = [] + for row_index, row in enumerate(rows): + group_rows.append( + { + "status_key": status_key, + "row_index": row_index, + "fiscal_year": fiscal_year, + "status_label": "Matched" if status_key == "voucher_matched" else "Recheck" if status_key == "voucher_recheck" else "Unmatched", + "ledger_date": clean(row["ledger_date"]), + "proof_date": "", + "voucher_no": clean(row["voucher_no"]) or clean(row["group_voucher_no"]), + "draft_no": clean(row["draft_no"]) or clean(row["group_draft_no"]), + "ledger_account_name": clean(row["ledger_account_name"]), + "voucher_account_name": clean(row["voucher_account_name"]), + "ledger_vendor": clean(row["ledger_vendor"]), + "voucher_vendor": clean(row["voucher_vendor"]), + "ledger_debit": parse_amount(row["ledger_debit"]), + "ledger_credit": parse_amount(row["ledger_credit"]), + "voucher_debit": parse_amount(row["voucher_debit"]), + "voucher_credit": parse_amount(row["voucher_credit"]), + "ledger_desc": clean(row["ledger_desc"]), + "voucher_desc": clean(row["voucher_desc"]), + "review_reason": "EXPORT_CACHE_CONTEXT", + } + ) + groups_by_status[status_key].append({"summary": summary, "rows": group_rows}) + return groups_by_status + + +def _apply_projection_cancel_reissue_recheck(conn: sqlite3.Connection, start_year: int, end_year: int, signature: str) -> None: + statuses = ("voucher_matched", "voucher_unmatched", "voucher_recheck") + groups_by_status: dict[str, list[dict[str, Any]]] = {} + for status_key in statuses: + rows = conn.execute( + """ + SELECT group_index + FROM wehago_compare_query_groups + WHERE start_year = ? + AND end_year = ? + AND signature = ? + AND status_key = ? + ORDER BY group_index + """, + (start_year, end_year, signature, status_key), + ).fetchall() + groups_by_status[status_key] = [ + _load_projection_group(conn, start_year, end_year, signature, status_key, int(row["group_index"] or 0)) + for row in rows + ] + context_groups = _load_projection_context_groups_from_source(conn, start_year, end_year) + for status_key, groups in context_groups.items(): + groups_by_status.setdefault(status_key, []) + groups_by_status[status_key].extend(groups) + if not groups_by_status.get("voucher_matched"): + return + + rechecked = _apply_wehago_cancel_reissue_recheck( + {status_key: [dict(group, rows=list(group.get("rows") or [])) for group in groups] for status_key, groups in groups_by_status.items()} + ) + new_recheck_groups = rechecked.get("voucher_recheck") or [] + reasons = ( + "MATCHED_CANCEL_TARGET_RECHECK", + "CANCEL_TARGET_ALREADY_MATCHED_RECHECK", + "CANCEL_REISSUE_RETARGET_RECHECK", + ) + to_move: list[tuple[str, int, str]] = [] + for group in new_recheck_groups: + summary = group.get("summary") or {} + reason_text = clean(summary.get("review_reason")) + reason = next((item for item in reasons if item in reason_text), "") + if not reason: + continue + if clean(summary.get("projection_context_only")): + continue + old_status_key = clean(summary.get("status_key")) or "voucher_recheck" + old_group_index = int(summary.get("group_index") or 0) + if old_status_key == "voucher_recheck": + _move_projection_group_to_recheck( + conn, + start_year, + end_year, + signature, + old_status_key, + old_group_index, + old_group_index, + reason, + ) + continue + to_move.append((old_status_key, old_group_index, reason)) + if not to_move: + return + max_recheck = conn.execute( + """ + SELECT COALESCE(MAX(group_index), 0) + FROM wehago_compare_query_groups + WHERE start_year = ? + AND end_year = ? + AND signature = ? + AND status_key = 'voucher_recheck' + """, + (start_year, end_year, signature), + ).fetchone()[0] + next_group_index = int(max_recheck or 0) + 1 + for old_status_key, old_group_index, reason in to_move: + _move_projection_group_to_recheck( + conn, + start_year, + end_year, + signature, + old_status_key, + old_group_index, + next_group_index, + reason, + ) + next_group_index += 1 + + +def _apply_projection_confirmed_reversal_pairs(conn: sqlite3.Connection, start_year: int, end_year: int, signature: str) -> None: + statuses = ("voucher_matched", "voucher_unmatched", "voucher_recheck") + sections: dict[str, list[dict[str, Any]]] = {} + for status_key in statuses: + rows = conn.execute( + """ + SELECT group_index + FROM wehago_compare_query_groups + WHERE start_year = ? + AND end_year = ? + AND signature = ? + AND status_key = ? + ORDER BY group_index + """, + (start_year, end_year, signature, status_key), + ).fetchall() + sections[status_key] = [ + _load_projection_group(conn, start_year, end_year, signature, status_key, int(row["group_index"] or 0)) + for row in rows + ] + moved = _move_wehago_confirmed_reversal_pairs_to_excepted({**sections, "voucher_excepted": []}) + selected = [ + group for group in moved.get("voucher_excepted", []) or [] + if "WEHAGO_EXCEPTED_CONFIRMED_REVERSAL_PAIR" in clean((group.get("summary") or {}).get("review_reason")) + ] + if not selected: + return + max_excepted = conn.execute( + """ + SELECT COALESCE(MAX(group_index), 0) + FROM wehago_compare_query_groups + WHERE start_year = ? + AND end_year = ? + AND signature = ? + AND status_key = 'voucher_excepted' + """, + (start_year, end_year, signature), + ).fetchone()[0] + next_group_index = int(max_excepted or 0) + 1 + for group in selected: + summary = group.get("summary") or {} + _move_projection_group_to_excepted( + conn, + start_year, + end_year, + signature, + int(summary.get("group_index") or 0), + next_group_index, + "WEHAGO_EXCEPTED_CONFIRMED_REVERSAL_PAIR", + old_status_key=clean(summary.get("status_key")), + ) + next_group_index += 1 + + +def _apply_projection_manual_offset_excepted(conn: sqlite3.Connection, start_year: int, end_year: int, signature: str) -> None: + conn.execute( + """ + CREATE TABLE IF NOT EXISTS wehago_manual_offset_excepted ( + pair_key TEXT PRIMARY KEY, + left_identity TEXT NOT NULL, + right_identity TEXT NOT NULL, + start_year INTEGER NOT NULL, + end_year INTEGER NOT NULL, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP + ) + """ + ) + identities: set[str] = set() + for row in conn.execute( + """ + SELECT left_identity, right_identity + FROM wehago_manual_offset_excepted + WHERE start_year <= ? AND end_year >= ? + """, + (end_year, start_year), + ).fetchall(): + identities.update(filter(None, (clean(row["left_identity"]), clean(row["right_identity"])))) + if not identities: + return + candidates: list[tuple[str, int]] = [] + for status_key in ("voucher_matched", "voucher_unmatched", "voucher_recheck"): + rows = conn.execute( + """ + SELECT group_index + FROM wehago_compare_query_groups + WHERE start_year = ? + AND end_year = ? + AND signature = ? + AND status_key = ? + ORDER BY group_index + """, + (start_year, end_year, signature, status_key), + ).fetchall() + for row in rows: + group_index = int(row["group_index"] or 0) + group = _load_projection_group(conn, start_year, end_year, signature, status_key, group_index) + if _projection_wehago_identity(group) in identities: + candidates.append((status_key, group_index)) + if not candidates: + return + max_excepted = conn.execute( + """ + SELECT COALESCE(MAX(group_index), 0) + FROM wehago_compare_query_groups + WHERE start_year = ? + AND end_year = ? + AND signature = ? + AND status_key = 'voucher_excepted' + """, + (start_year, end_year, signature), + ).fetchone()[0] + next_group_index = int(max_excepted or 0) + 1 + for status_key, group_index in candidates: + _move_projection_group_to_excepted( + conn, + start_year, + end_year, + signature, + group_index, + next_group_index, + "MANUAL_OFFSET_PAIR_EXCEPTED", + old_status_key=status_key, + ) + next_group_index += 1 + + +def _apply_projection_excepted_rules(conn: sqlite3.Connection, start_year: int, end_year: int, signature: str) -> None: + summary_rows = conn.execute( + """ + SELECT group_index + FROM wehago_compare_query_groups + WHERE start_year = ? + AND end_year = ? + AND signature = ? + AND status_key = 'voucher_unmatched' + ORDER BY group_index + """, + (start_year, end_year, signature), + ).fetchall() + if not summary_rows: + return + groups = [ + _load_projection_group(conn, start_year, end_year, signature, "voucher_unmatched", int(row["group_index"] or 0)) + for row in summary_rows + ] + move_reasons: dict[tuple[Any, ...], str] = {} + for group in groups: + is_excepted, reason = _is_wehago_excepted_voucher_group(group) + if is_excepted: + move_reasons[_projection_group_identity(group)] = reason + + offset_sections = _move_wehago_offset_tax_invoice_groups_to_excepted( + {"voucher_unmatched": groups, "voucher_excepted": []} + ) + for group in offset_sections.get("voucher_excepted", []) or []: + reason = "WEHAGO_EXCEPTED_OFFSET_TAX_INVOICE_CANCEL" + move_reasons.setdefault(_projection_group_identity(group), reason) + + if not move_reasons: + return + max_excepted = conn.execute( + """ + SELECT COALESCE(MAX(group_index), 0) + FROM wehago_compare_query_groups + WHERE start_year = ? + AND end_year = ? + AND signature = ? + AND status_key = 'voucher_excepted' + """, + (start_year, end_year, signature), + ).fetchone()[0] + next_group_index = int(max_excepted or 0) + 1 + for group in groups: + identity = _projection_group_identity(group) + reason = move_reasons.get(identity) + if not reason: + continue + old_group_index = int((group.get("summary") or {}).get("group_index") or 0) + _move_projection_group_to_excepted( + conn, + start_year, + end_year, + signature, + old_group_index, + next_group_index, + reason, + ) + next_group_index += 1 + + +def project_range( + conn: sqlite3.Connection, + start_year: int, + end_year: int, + *, + allow_stale: bool = False, + prune_old: bool = False, +) -> dict[str, int]: + signatures = {year: latest_export_signature(conn, year, allow_stale=allow_stale) for year in range(start_year, end_year + 1)} + context_signatures = _context_year_signatures(conn, start_year, end_year, allow_stale=allow_stale) + signature = projection_signature( + signatures, + start_year, + end_year, + allow_stale=allow_stale, + context_signatures=context_signatures, + ) + conn.execute("BEGIN") + try: + _projection_source_table( + conn, + start_year, + end_year, + context_signatures or signatures, + source_start_year=start_year - 1, + source_end_year=end_year + 1, + ) + conn.execute( + "DELETE FROM wehago_compare_query_rows WHERE start_year = ? AND end_year = ? AND signature = ?", + (start_year, end_year, signature), + ) + conn.execute( + "DELETE FROM wehago_compare_query_groups WHERE start_year = ? AND end_year = ? AND signature = ?", + (start_year, end_year, signature), + ) + conn.execute( + """ + INSERT INTO wehago_compare_query_groups ( + start_year, end_year, status_key, signature, group_index, + fiscal_year, ledger_date, proof_date, voucher_no, draft_no, + ledger_row_count, voucher_row_count, + ledger_debit, ledger_credit, voucher_debit, voucher_credit, + ledger_accounts, voucher_accounts, ledger_vendors, voucher_vendors, + review_reason, search_text, created_at, updated_at + ) + WITH grouped AS ( + SELECT + status_key, + fiscal_year, + group_sort, + MIN(COALESCE(ledger_date, '')) AS ledger_date, + COALESCE(MAX(NULLIF(group_voucher_no, '')), MAX(NULLIF(voucher_no, '')), '') AS voucher_no, + COALESCE(MAX(NULLIF(group_draft_no, '')), MAX(NULLIF(draft_no, '')), '') AS draft_no, + SUM(CASE WHEN TRIM(COALESCE(ledger_account_name, '') || COALESCE(ledger_desc, '')) <> '' THEN 1 ELSE 0 END) AS ledger_row_count, + SUM(CASE WHEN TRIM(COALESCE(voucher_account_name, '') || COALESCE(voucher_desc, '')) <> '' THEN 1 ELSE 0 END) AS voucher_row_count, + MAX(COALESCE(group_ledger_debit, 0)) AS ledger_debit, + MAX(COALESCE(group_ledger_credit, 0)) AS ledger_credit, + MAX(COALESCE(group_voucher_debit, 0)) AS voucher_debit, + MAX(COALESCE(group_voucher_credit, 0)) AS voucher_credit, + MAX(COALESCE(group_ledger_accounts, '')) AS ledger_accounts, + MAX(COALESCE(group_voucher_accounts, '')) AS voucher_accounts, + MAX(COALESCE(group_ledger_vendors, '')) AS ledger_vendors, + MAX(COALESCE(group_voucher_vendors, '')) AS voucher_vendors, + GROUP_CONCAT(COALESCE(ledger_desc, ''), ' ') AS ledger_descs, + GROUP_CONCAT(COALESCE(voucher_desc, ''), ' ') AS voucher_descs + FROM _wehago_projection_source + WHERE fiscal_year BETWEEN ? AND ? + GROUP BY status_key, fiscal_year, group_sort + ), + ordered AS ( + SELECT + ROW_NUMBER() OVER (PARTITION BY status_key ORDER BY fiscal_year ASC, status_key ASC, group_sort ASC) AS group_index, + * + FROM grouped + ) + SELECT + ?, ?, status_key, ?, group_index, + fiscal_year, ledger_date, '', voucher_no, draft_no, + ledger_row_count, voucher_row_count, + ledger_debit, ledger_credit, voucher_debit, voucher_credit, + ledger_accounts, voucher_accounts, ledger_vendors, voucher_vendors, + 'EXPORT_CACHE_V20', + TRIM( + voucher_no || ' ' || draft_no || ' ' || + ledger_accounts || ' ' || voucher_accounts || ' ' || + ledger_vendors || ' ' || voucher_vendors || ' ' || + COALESCE(ledger_descs, '') || ' ' || COALESCE(voucher_descs, '') + ), + CURRENT_TIMESTAMP, CURRENT_TIMESTAMP + FROM ordered + ORDER BY status_key ASC, group_index ASC + """, + (start_year, end_year, start_year, end_year, signature), + ) + conn.execute( + """ + INSERT INTO wehago_compare_query_rows ( + start_year, end_year, status_key, signature, group_index, row_index, + fiscal_year, status_label, ledger_date, proof_date, voucher_no, draft_no, + ledger_account_name, voucher_account_name, ledger_vendor, voucher_vendor, + ledger_debit, ledger_credit, voucher_debit, voucher_credit, + ledger_desc, voucher_desc, review_reason, matched_case, + ledger_row_key, voucher_row_key, match_identity_key, created_at, updated_at + ) + WITH group_order AS ( + SELECT + status_key, + fiscal_year, + group_sort, + ROW_NUMBER() OVER (PARTITION BY status_key ORDER BY fiscal_year ASC, status_key ASC, group_sort ASC) AS group_index + FROM ( + SELECT DISTINCT status_key, fiscal_year, group_sort + FROM _wehago_projection_source + WHERE fiscal_year BETWEEN ? AND ? + ) + ), + ordered_rows AS ( + SELECT + c.*, + g.group_index, + ROW_NUMBER() OVER ( + PARTITION BY c.status_key, c.fiscal_year, c.group_sort + ORDER BY c.row_sort ASC + ) - 1 AS projected_row_index + FROM _wehago_projection_source AS c + JOIN group_order AS g + ON g.status_key = c.status_key + AND g.fiscal_year = c.fiscal_year + AND g.group_sort = c.group_sort + WHERE c.fiscal_year BETWEEN ? AND ? + ) + SELECT + ?, ?, status_key, ?, group_index, projected_row_index, + fiscal_year, + CASE + WHEN status_key IN ('voucher_matched', 'erp_voucher_matched') THEN 'Matched' + WHEN status_key = 'voucher_recheck' THEN 'Recheck' + ELSE 'Unmatched' + END, + COALESCE(ledger_date, ''), '', + COALESCE(NULLIF(voucher_no, ''), group_voucher_no, ''), + COALESCE(NULLIF(draft_no, ''), group_draft_no, ''), + COALESCE(ledger_account_name, ''), + COALESCE(voucher_account_name, ''), + COALESCE(ledger_vendor, ''), + COALESCE(voucher_vendor, ''), + COALESCE(ledger_debit, 0), + COALESCE(ledger_credit, 0), + COALESCE(voucher_debit, 0), + COALESCE(voucher_credit, 0), + COALESCE(ledger_desc, ''), + COALESCE(voucher_desc, ''), + 'EXPORT_CACHE_V20', '', + '', '', '', + CURRENT_TIMESTAMP, CURRENT_TIMESTAMP + FROM ordered_rows + ORDER BY status_key ASC, group_index ASC, projected_row_index ASC + """, + (start_year, end_year, start_year, end_year, start_year, end_year, signature), + ) + conn.execute( + """ + DELETE FROM wehago_summary_range_cache + WHERE start_year = ? + AND end_year = ? + """, + (start_year, end_year), + ) + _apply_projection_confirmed_reversal_pairs(conn, start_year, end_year, signature) + _apply_projection_manual_offset_excepted(conn, start_year, end_year, signature) + _apply_projection_cancel_reissue_recheck(conn, start_year, end_year, signature) + _apply_projection_excepted_rules(conn, start_year, end_year, signature) + counters = {status: 0 for status in STATUSES} + for status_key, row_count in conn.execute( + """ + SELECT status_key, COUNT(*) + FROM wehago_compare_query_groups + WHERE start_year = ? + AND end_year = ? + AND signature = ? + GROUP BY status_key + """, + (start_year, end_year, signature), + ).fetchall(): + counters[str(status_key or "")] = int(row_count or 0) + for status_key, row_count in conn.execute( + f""" + SELECT status, COUNT(*) + FROM wehago_comparison_results + WHERE fiscal_year BETWEEN ? AND ? + AND status IN ({','.join('?' for _ in STANDARD_STATUSES)}) + GROUP BY status + """, + (start_year, end_year, *STANDARD_STATUSES), + ).fetchall(): + counters[str(status_key or "")] = int(row_count or 0) + if prune_old: + _prune_old_projection_signatures(conn, start_year, end_year, signature) + conn.commit() + except Exception: + conn.rollback() + raise + finally: + conn.execute("DROP TABLE IF EXISTS temp._wehago_projection_source") + conn.execute("DROP TABLE IF EXISTS temp._wehago_projection_signatures") + return counters + + +def main() -> None: + parser = argparse.ArgumentParser(description="Project query rows/groups from recheck-v20 export row cache.") + parser.add_argument("ranges", nargs="+") + parser.add_argument( + "--allow-stale", + action="store_true", + help="현재 로직 ready 캐시가 없어도 최신 export row cache를 사용합니다. 새 로직 검증용 기본 경로에서는 사용하지 마세요.", + ) + parser.add_argument( + "--prune-old", + action="store_true", + help="같은 기간의 오래된 query projection을 함께 정리합니다. 대용량 DB에서는 별도 유지보수 시간에 실행하세요.", + ) + args = parser.parse_args() + conn = sqlite3.connect(DB_PATH) + conn.row_factory = sqlite3.Row + for item in args.ranges: + start_year, end_year = parse_range(item) + counts = project_range(conn, start_year, end_year, allow_stale=args.allow_stale, prune_old=args.prune_old) + print({"range": f"{start_year}-{end_year}", "counts": counts}, flush=True) + conn.close() + + +if __name__ == "__main__": + main() diff --git a/scripts/promote_wehago_recheck_projection.py b/scripts/promote_wehago_recheck_projection.py new file mode 100644 index 0000000..04280a5 --- /dev/null +++ b/scripts/promote_wehago_recheck_projection.py @@ -0,0 +1,901 @@ +from __future__ import annotations + +import sqlite3 +import sys +import re +from datetime import datetime +from pathlib import Path +from typing import Any + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + +from runtime_config import DB_PATH +from wehago_compare import ( + QUERY_PROJECTION_VERSION, + _account_base_names_compatible, + _contained_core_desc_match, + _erp_section_identity, + _is_obvious_recheck_group, + _recheck_group_rank_key, + _section_vat_exception_capacity, + _short_core_desc_fuzzy_match, + _same_or_similar_desc, + _wehago_section_identity, + clean, +) + + +TARGET_START_YEAR = 2025 +TARGET_END_YEAR = 2025 + + +GROUP_COLUMNS = ( + "start_year", + "end_year", + "status_key", + "signature", + "group_index", + "fiscal_year", + "ledger_date", + "proof_date", + "voucher_no", + "draft_no", + "ledger_row_count", + "voucher_row_count", + "ledger_debit", + "ledger_credit", + "voucher_debit", + "voucher_credit", + "ledger_accounts", + "voucher_accounts", + "ledger_vendors", + "voucher_vendors", + "review_reason", + "search_text", +) + +ROW_COLUMNS = ( + "start_year", + "end_year", + "status_key", + "signature", + "group_index", + "row_index", + "fiscal_year", + "status_label", + "ledger_date", + "proof_date", + "voucher_no", + "draft_no", + "ledger_account_name", + "voucher_account_name", + "ledger_vendor", + "voucher_vendor", + "ledger_debit", + "ledger_credit", + "voucher_debit", + "voucher_credit", + "ledger_desc", + "voucher_desc", + "review_reason", + "matched_case", + "ledger_row_key", + "voucher_row_key", + "match_identity_key", +) + + +def _dict(row: sqlite3.Row) -> dict[str, Any]: + return {key: row[key] for key in row.keys()} + + +def _amount_key(value: Any) -> str: + try: + amount = float(str(value or "0").replace(",", "")) + except Exception: + amount = 0.0 + if abs(amount - round(amount)) < 0.0001: + return str(int(round(amount))) + return f"{amount:.2f}".rstrip("0").rstrip(".") + + +def _parse_amount(value: Any) -> float: + try: + return float(str(value or "0").replace(",", "")) + except Exception: + return 0.0 + + +def _has_ledger_value(row: dict[str, Any]) -> bool: + return bool(clean(row.get("ledger_account_name"))) and ( + abs(_parse_amount(row.get("ledger_debit"))) > 0.0001 + or abs(_parse_amount(row.get("ledger_credit"))) > 0.0001 + ) + + +def _has_voucher_value(row: dict[str, Any]) -> bool: + return bool(clean(row.get("voucher_account_name"))) and ( + abs(_parse_amount(row.get("voucher_debit"))) > 0.0001 + or abs(_parse_amount(row.get("voucher_credit"))) > 0.0001 + ) + + +def _same_side_amount_match(ledger_row: dict[str, Any], voucher_row: dict[str, Any]) -> bool: + return ( + abs(_parse_amount(ledger_row.get("ledger_debit")) - _parse_amount(voucher_row.get("voucher_debit"))) < 0.5 + and abs(_parse_amount(ledger_row.get("ledger_credit")) - _parse_amount(voucher_row.get("voucher_credit"))) < 0.5 + and ( + abs(_parse_amount(ledger_row.get("ledger_debit"))) > 0.0001 + or abs(_parse_amount(ledger_row.get("ledger_credit"))) > 0.0001 + ) + ) + + +def _desc_core_match(ledger_row: dict[str, Any], voucher_row: dict[str, Any]) -> bool: + probe = { + "ledger_desc": ledger_row.get("ledger_desc"), + "voucher_desc": voucher_row.get("voucher_desc"), + } + matched = ( + _same_or_similar_desc(probe) + or _contained_core_desc_match(ledger_row.get("ledger_desc"), voucher_row.get("voucher_desc")) + or _short_core_desc_fuzzy_match(ledger_row.get("ledger_desc"), voucher_row.get("voucher_desc")) + ) + if matched: + return True + + stop_words = { + "관련", + "전표", + "처리", + "정산", + "금액", + "비용", + "지급", + "입금", + "출금", + "매입", + "매출", + "급여", + "제경비", + } + + def tokens(value: Any) -> set[str]: + found: set[str] = set() + for token in re.split(r"[^0-9A-Za-z가-힣]+", clean(value)): + token = token.strip() + if len(token) < 2 or token in stop_words or re.fullmatch(r"\d+월?", token): + continue + found.add(token) + return found + + shared = tokens(ledger_row.get("ledger_desc")) & tokens(voucher_row.get("voucher_desc")) + return len(shared) >= 2 or any(len(token) >= 3 for token in shared) + + +def _merge_internal_recheck_pairs(group: dict[str, Any]) -> dict[str, Any]: + rows = [dict(row) for row in group.get("rows") or []] + ledger_only = [row for row in rows if _has_ledger_value(row) and not _has_voucher_value(row)] + voucher_only = [row for row in rows if _has_voucher_value(row) and not _has_ledger_value(row)] + if not ledger_only or not voucher_only: + return group + + used_ledger: set[int] = set() + used_voucher: set[int] = set() + merged_rows: list[dict[str, Any]] = [] + candidates: list[tuple[float, int, int]] = [] + for ledger_index, ledger_row in enumerate(ledger_only): + for voucher_index, voucher_row in enumerate(voucher_only): + if not _same_side_amount_match(ledger_row, voucher_row): + continue + if not _account_base_names_compatible(ledger_row.get("ledger_account_name"), voucher_row.get("voucher_account_name")): + continue + if not _desc_core_match(ledger_row, voucher_row): + continue + amount = max(abs(_parse_amount(ledger_row.get("ledger_debit"))), abs(_parse_amount(ledger_row.get("ledger_credit")))) + candidates.append((amount, ledger_index, voucher_index)) + for _amount, ledger_index, voucher_index in sorted(candidates, reverse=True): + if ledger_index in used_ledger or voucher_index in used_voucher: + continue + ledger_row = ledger_only[ledger_index] + voucher_row = voucher_only[voucher_index] + merged = dict(ledger_row) + for field in ( + "proof_date", + "draft_no", + "voucher_account_name", + "voucher_vendor", + "voucher_debit", + "voucher_credit", + "voucher_desc", + "voucher_row_key", + "match_identity_key", + ): + merged[field] = voucher_row.get(field, "") + merged["review_reason"] = "RECHECK_INTERNAL_CORE_MATCH" + merged_rows.append(merged) + used_ledger.add(ledger_index) + used_voucher.add(voucher_index) + if not merged_rows: + return group + + remaining_rows: list[dict[str, Any]] = [] + ledger_ids = {id(row): index for index, row in enumerate(ledger_only)} + voucher_ids = {id(row): index for index, row in enumerate(voucher_only)} + for row in rows: + if _has_ledger_value(row) and not _has_voucher_value(row): + index = ledger_ids.get(id(row)) + if index is not None and index in used_ledger: + continue + if _has_voucher_value(row) and not _has_ledger_value(row): + index = voucher_ids.get(id(row)) + if index is not None and index in used_voucher: + continue + remaining_rows.append(row) + return {**group, "rows": merged_rows + remaining_rows} + + +def _group_manual_review_key(group: dict[str, Any]) -> str: + summary = group.get("summary") or {} + return "|".join( + [ + "manual-recheck", + clean(summary.get("fiscal_year")), + clean(summary.get("ledger_date")), + clean(summary.get("voucher_no")), + clean(summary.get("draft_no")), + "", + "", + _amount_key(summary.get("ledger_debit")), + _amount_key(summary.get("ledger_credit")), + _amount_key(summary.get("voucher_debit")), + _amount_key(summary.get("voucher_credit")), + ] + ) + + +def _row_change_key(row: dict[str, Any], change_type: str) -> str: + explicit_key = clean(row.get("review_key")) or clean(row.get("match_identity_key")) + if explicit_key: + return f"{change_type}:{explicit_key}" + return "|".join( + [ + change_type, + clean(row.get("fiscal_year")), + clean(row.get("ledger_date")), + clean(row.get("voucher_no")), + clean(row.get("draft_no")), + clean(row.get("ledger_account_name")), + clean(row.get("voucher_account_name")), + _amount_key(row.get("ledger_debit")), + _amount_key(row.get("ledger_credit")), + _amount_key(row.get("voucher_debit")), + _amount_key(row.get("voucher_credit")), + clean(row.get("ledger_desc")), + clean(row.get("voucher_desc")), + ] + ) + + +def _ensure_change_table(conn: sqlite3.Connection) -> None: + conn.execute( + """ + CREATE TABLE IF NOT EXISTS wehago_recheck_row_changes ( + change_key TEXT PRIMARY KEY, + change_type TEXT NOT NULL DEFAULT 'match', + fiscal_year INTEGER, + voucher_no TEXT NOT NULL DEFAULT '', + draft_no TEXT NOT NULL DEFAULT '', + ledger_date TEXT NOT NULL DEFAULT '', + proof_date TEXT NOT NULL DEFAULT '', + ledger_account_name TEXT NOT NULL DEFAULT '', + voucher_account_name TEXT NOT NULL DEFAULT '', + ledger_debit REAL NOT NULL DEFAULT 0, + ledger_credit REAL NOT NULL DEFAULT 0, + voucher_debit REAL NOT NULL DEFAULT 0, + voucher_credit REAL NOT NULL DEFAULT 0, + ledger_desc TEXT NOT NULL DEFAULT '', + voucher_desc TEXT NOT NULL DEFAULT '', + changed_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP + ) + """ + ) + + +def _load_manual_change_keys(conn: sqlite3.Connection) -> tuple[set[str], set[str], set[str]]: + _ensure_change_table(conn) + review_keys = { + str(row[0]) + for row in conn.execute( + """ + SELECT review_key + FROM wehago_recheck_reviews + WHERE fiscal_year BETWEEN ? AND ? + """, + (TARGET_START_YEAR, TARGET_END_YEAR), + ).fetchall() + } + match_change_keys = { + str(row[0]) + for row in conn.execute( + """ + SELECT change_key + FROM wehago_recheck_row_changes + WHERE fiscal_year BETWEEN ? AND ? + AND change_type = 'match' + """, + (TARGET_START_YEAR, TARGET_END_YEAR), + ).fetchall() + } + split_change_keys = { + str(row[0]) + for row in conn.execute( + """ + SELECT change_key + FROM wehago_recheck_row_changes + WHERE fiscal_year BETWEEN ? AND ? + AND change_type = 'split' + """, + (TARGET_START_YEAR, TARGET_END_YEAR), + ).fetchall() + } + return review_keys, match_change_keys, split_change_keys + + +def _snapshot_signature(conn: sqlite3.Connection) -> str: + row = conn.execute( + """ + SELECT snapshot_signature + FROM wehago_snapshot_status + WHERE fiscal_year = ? + AND state = 'ready' + LIMIT 1 + """, + (TARGET_START_YEAR,), + ).fetchone() + if row is not None and str(row["snapshot_signature"] or ""): + signature = str(row["snapshot_signature"]) + exists = conn.execute( + """ + SELECT 1 + FROM wehago_compare_export_row_cache + WHERE fiscal_year BETWEEN ? AND ? + AND snapshot_signature = ? + LIMIT 1 + """, + (TARGET_START_YEAR, TARGET_END_YEAR, signature), + ).fetchone() + if exists: + return signature + row = conn.execute( + """ + SELECT snapshot_signature, COUNT(*) AS row_count, MAX(rowid) AS max_rowid + FROM wehago_compare_export_row_cache + WHERE fiscal_year BETWEEN ? AND ? + GROUP BY snapshot_signature + ORDER BY + CASE WHEN snapshot_signature LIKE 'voucher-summary-v7|recheck-v20%' THEN 0 ELSE 1 END ASC, + row_count DESC, + max_rowid DESC + LIMIT 1 + """, + (TARGET_START_YEAR, TARGET_END_YEAR), + ).fetchone() + if row is None or not str(row["snapshot_signature"] or ""): + raise RuntimeError("No snapshot/export signature was found.") + return str(row["snapshot_signature"]) + + +def _latest_query_projection_signature(conn: sqlite3.Connection) -> str | None: + row = conn.execute( + """ + SELECT signature, COUNT(DISTINCT status_key) AS status_count, MAX(updated_at) AS updated_at + FROM wehago_compare_query_groups + WHERE start_year = ? + AND end_year = ? + AND signature LIKE ? + AND signature NOT LIKE '%|snapshot-recheck-promote|%' + GROUP BY signature + HAVING status_count >= 5 + ORDER BY updated_at DESC + LIMIT 1 + """, + (TARGET_START_YEAR, TARGET_END_YEAR, f"{QUERY_PROJECTION_VERSION}|%"), + ).fetchone() + return str(row["signature"] or "") if row is not None else None + + +def _load_groups_from_query_projection(conn: sqlite3.Connection, signature: str) -> dict[str, list[dict[str, Any]]]: + group_rows = conn.execute( + """ + SELECT * + FROM wehago_compare_query_groups + WHERE start_year = ? + AND end_year = ? + AND signature = ? + ORDER BY status_key, group_index + """, + (TARGET_START_YEAR, TARGET_END_YEAR, signature), + ).fetchall() + detail_rows = conn.execute( + """ + SELECT * + FROM wehago_compare_query_rows + WHERE start_year = ? + AND end_year = ? + AND signature = ? + ORDER BY status_key, group_index, row_index + """, + (TARGET_START_YEAR, TARGET_END_YEAR, signature), + ).fetchall() + rows_by_key: dict[tuple[str, int], list[dict[str, Any]]] = {} + for row in detail_rows: + payload = _dict(row) + rows_by_key.setdefault((str(payload["status_key"]), int(payload["group_index"])), []).append(payload) + groups: dict[str, list[dict[str, Any]]] = {} + for row in group_rows: + summary = _dict(row) + status_key = str(summary["status_key"]) + group_index = int(summary["group_index"]) + groups.setdefault(status_key, []).append( + { + "summary": summary, + "rows": rows_by_key.get((status_key, group_index), []), + "source_group_index": group_index, + } + ) + return groups + + +def _source_signature(conn: sqlite3.Connection) -> str: + row = conn.execute( + """ + SELECT signature, COUNT(DISTINCT status_key) AS status_count, COUNT(*) AS group_count + FROM wehago_compare_query_groups + WHERE fiscal_year BETWEEN ? AND ? + AND signature NOT LIKE ? + GROUP BY signature + HAVING status_count >= 5 + ORDER BY group_count DESC + LIMIT 1 + """, + (TARGET_START_YEAR, TARGET_END_YEAR, f"{QUERY_PROJECTION_VERSION}|%"), + ).fetchone() + if row is None: + raise RuntimeError("No source query projection with voucher status groups was found.") + return str(row["signature"]) + + +def _matched_source_signature(conn: sqlite3.Connection) -> str: + row = conn.execute( + """ + SELECT signature, COUNT(*) AS group_count + FROM wehago_compare_query_groups + WHERE fiscal_year BETWEEN ? AND ? + AND status_key = 'voucher_matched' + AND signature NOT LIKE ? + GROUP BY signature + ORDER BY group_count DESC + LIMIT 1 + """, + (TARGET_START_YEAR, TARGET_END_YEAR, f"{QUERY_PROJECTION_VERSION}|%"), + ).fetchone() + if row is None: + raise RuntimeError("No matched source query projection was found.") + return str(row["signature"]) + + +def _load_groups( + conn: sqlite3.Connection, + signature: str, + statuses: tuple[str, ...] | None = None, +) -> dict[str, list[dict[str, Any]]]: + status_filter = "" + params: list[Any] = [signature, TARGET_START_YEAR, TARGET_END_YEAR] + if statuses: + status_filter = f" AND status_key IN ({', '.join('?' for _ in statuses)})" + params.extend(statuses) + groups: dict[str, list[dict[str, Any]]] = {} + group_rows = conn.execute( + f""" + SELECT * + FROM wehago_compare_query_groups + WHERE signature = ? + AND fiscal_year BETWEEN ? AND ? + {status_filter} + ORDER BY status_key, group_index + """, + params, + ).fetchall() + detail_rows = conn.execute( + f""" + SELECT * + FROM wehago_compare_query_rows + WHERE signature = ? + AND fiscal_year BETWEEN ? AND ? + {status_filter} + ORDER BY status_key, group_index, row_index + """, + params, + ).fetchall() + rows_by_key: dict[tuple[str, int], list[dict[str, Any]]] = {} + for row in detail_rows: + item = _dict(row) + rows_by_key.setdefault((str(item["status_key"]), int(item["group_index"])), []).append(item) + for row in group_rows: + summary = _dict(row) + status_key = str(summary["status_key"]) + group_index = int(summary["group_index"]) + groups.setdefault(status_key, []).append( + { + "summary": summary, + "rows": rows_by_key.get((status_key, group_index), []), + "source_group_index": group_index, + } + ) + return groups + + +def _load_groups_from_export_cache(conn: sqlite3.Connection, snapshot_signature: str) -> dict[str, list[dict[str, Any]]]: + export_rows = conn.execute( + """ + SELECT * + FROM wehago_compare_export_row_cache + WHERE fiscal_year BETWEEN ? AND ? + AND snapshot_signature = ? + ORDER BY status_key, group_sort, row_sort + """, + (TARGET_START_YEAR, TARGET_END_YEAR, snapshot_signature), + ).fetchall() + grouped_rows: dict[tuple[str, int], list[dict[str, Any]]] = {} + for row in export_rows: + item = _dict(row) + grouped_rows.setdefault((str(item["status_key"]), int(item["group_sort"])), []).append(item) + + groups: dict[str, list[dict[str, Any]]] = {} + for (status_key, group_index), rows in grouped_rows.items(): + first = rows[0] + detail_rows: list[dict[str, Any]] = [] + ledger_accounts: list[str] = [] + voucher_accounts: list[str] = [] + ledger_vendors: list[str] = [] + voucher_vendors: list[str] = [] + + def append_unique(target: list[str], value: Any) -> None: + text_value = clean(value) + if text_value and text_value not in target: + target.append(text_value) + + ledger_row_count = 0 + voucher_row_count = 0 + for row_index, row in enumerate(rows): + detail = { + "fiscal_year": int(row.get("fiscal_year") or 0), + "status_label": "Matched" if status_key in {"voucher_matched", "erp_voucher_matched"} else "Recheck" if status_key == "voucher_recheck" else "Unmatched", + "ledger_date": clean(row.get("ledger_date")), + "proof_date": "", + "voucher_no": clean(row.get("voucher_no")), + "draft_no": clean(row.get("draft_no")), + "ledger_account_name": clean(row.get("ledger_account_name")), + "voucher_account_name": clean(row.get("voucher_account_name")), + "ledger_vendor": clean(row.get("ledger_vendor")), + "voucher_vendor": clean(row.get("voucher_vendor")), + "ledger_debit": float(row.get("ledger_debit") or 0), + "ledger_credit": float(row.get("ledger_credit") or 0), + "voucher_debit": float(row.get("voucher_debit") or 0), + "voucher_credit": float(row.get("voucher_credit") or 0), + "ledger_desc": clean(row.get("ledger_desc")), + "voucher_desc": clean(row.get("voucher_desc")), + "review_reason": "SNAPSHOT_EXPORT_CACHE", + "matched_case": "", + "ledger_row_key": "", + "voucher_row_key": "", + "match_identity_key": "", + "row_index": row_index, + } + if detail["ledger_account_name"] or detail["ledger_desc"]: + ledger_row_count += 1 + if detail["voucher_account_name"] or detail["voucher_desc"]: + voucher_row_count += 1 + append_unique(ledger_accounts, detail["ledger_account_name"]) + append_unique(voucher_accounts, detail["voucher_account_name"]) + append_unique(ledger_vendors, detail["ledger_vendor"]) + append_unique(voucher_vendors, detail["voucher_vendor"]) + detail_rows.append(detail) + + summary = { + "fiscal_year": int(first.get("fiscal_year") or 0), + "status_label": detail_rows[0]["status_label"] if detail_rows else "", + "ledger_date": clean(first.get("ledger_date")), + "proof_date": "", + "voucher_no": clean(first.get("group_voucher_no")) or clean(first.get("voucher_no")), + "draft_no": clean(first.get("group_draft_no")) or clean(first.get("draft_no")), + "ledger_row_count": ledger_row_count, + "voucher_row_count": voucher_row_count, + "ledger_debit": float(first.get("group_ledger_debit") or 0), + "ledger_credit": float(first.get("group_ledger_credit") or 0), + "voucher_debit": float(first.get("group_voucher_debit") or 0), + "voucher_credit": float(first.get("group_voucher_credit") or 0), + "ledger_accounts": clean(first.get("group_ledger_accounts")) or ", ".join(ledger_accounts), + "voucher_accounts": clean(first.get("group_voucher_accounts")) or ", ".join(voucher_accounts), + "ledger_vendors": clean(first.get("group_ledger_vendors")) or ", ".join(ledger_vendors), + "voucher_vendors": clean(first.get("group_voucher_vendors")) or ", ".join(voucher_vendors), + "review_reason": "SNAPSHOT_EXPORT_CACHE", + "search_text": "", + } + summary["search_text"] = _search_text(summary, detail_rows) + groups.setdefault(status_key, []).append( + { + "summary": summary, + "rows": detail_rows, + "source_group_index": group_index, + } + ) + return groups + + +def _seed_used_identities(groups: dict[str, list[dict[str, Any]]]) -> tuple[dict[str, int], dict[str, int]]: + used_wehago: dict[str, int] = {} + used_erp: dict[str, int] = {} + for group in groups.get("voucher_matched", []): + identity = _wehago_section_identity(group) + if identity: + used_wehago[identity] = used_wehago.get(identity, 0) + 1 + for group in groups.get("erp_voucher_matched", []): + identity = _erp_section_identity(group) + if identity: + used_erp[identity] = used_erp.get(identity, 0) + 1 + return used_wehago, used_erp + + +def _group_has_manual_match( + group: dict[str, Any], + review_keys: set[str], + match_change_keys: set[str], +) -> bool: + if _group_manual_review_key(group) in review_keys: + return True + for row in group.get("rows") or []: + if _row_change_key(row, "match") in match_change_keys: + return True + return False + + +def _select_promotions( + groups: dict[str, list[dict[str, Any]]], + review_keys: set[str], + match_change_keys: set[str], +) -> set[int]: + used_wehago, _used_erp = _seed_used_identities(groups) + candidates: list[dict[str, Any]] = [] + for group in groups.get("voucher_recheck", []): + manual_match = _group_has_manual_match(group, review_keys, match_change_keys) + if not manual_match and not _is_obvious_recheck_group(group): + continue + wehago_identity = _wehago_section_identity(group) + erp_identity = _erp_section_identity(group) + if not wehago_identity or not erp_identity: + continue + candidates.append( + { + "group": group, + "wehago_identity": wehago_identity, + "erp_identity": erp_identity, + "wehago_capacity": _section_vat_exception_capacity(group, side="wehago"), + "rank": (1 if manual_match else 0, *_recheck_group_rank_key(group)), + "manual_match": manual_match, + } + ) + candidates.sort(key=lambda item: item["rank"], reverse=True) + promoted: set[int] = set() + for candidate in candidates: + wehago_identity = candidate["wehago_identity"] + erp_identity = candidate["erp_identity"] + wehago_capacity = int(candidate["wehago_capacity"] or 1) + if used_wehago.get(wehago_identity, 0) >= wehago_capacity: + continue + promoted.add(id(candidate["group"])) + used_wehago[wehago_identity] = used_wehago.get(wehago_identity, 0) + 1 + return promoted + + +def _search_text(summary: dict[str, Any], rows: list[dict[str, Any]]) -> str: + parts = [ + summary.get("voucher_no"), + summary.get("draft_no"), + summary.get("ledger_accounts"), + summary.get("voucher_accounts"), + summary.get("ledger_vendors"), + summary.get("voucher_vendors"), + summary.get("review_reason"), + ] + for row in rows[:20]: + parts.extend([row.get("ledger_desc"), row.get("voucher_desc")]) + return " ".join(clean(part) for part in parts if clean(part)) + + +def _insert_group( + conn: sqlite3.Connection, + *, + signature: str, + status_key: str, + group_index: int, + group: dict[str, Any], + promoted: bool = False, + split_change_keys: set[str] | None = None, + omit_split_rows: bool = False, +) -> None: + summary = dict(group["summary"]) + rows = [dict(row) for row in group.get("rows") or []] + split_change_keys = split_change_keys or set() + if promoted and omit_split_rows: + rows = [row for row in rows if _row_change_key(row, "split") not in split_change_keys] + summary.update( + { + "start_year": TARGET_START_YEAR, + "end_year": TARGET_END_YEAR, + "status_key": status_key, + "signature": signature, + "group_index": group_index, + } + ) + if promoted: + summary["review_reason"] = clean(summary.get("review_reason")) or "RECHECK_PROMOTED_BY_USER_RULE" + summary["search_text"] = _search_text(summary, rows) + values = [summary.get(column, "") for column in GROUP_COLUMNS] + conn.execute( + f""" + INSERT INTO wehago_compare_query_groups ({', '.join(GROUP_COLUMNS)}, created_at, updated_at) + VALUES ({', '.join('?' for _ in GROUP_COLUMNS)}, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) + """, + values, + ) + for row_index, row in enumerate(rows): + split_row = promoted and _row_change_key(row, "split") in split_change_keys + row.update( + { + "start_year": TARGET_START_YEAR, + "end_year": TARGET_END_YEAR, + "status_key": status_key, + "signature": signature, + "group_index": group_index, + "row_index": row_index, + } + ) + if promoted: + if split_row: + row["status_label"] = "Unmatched" + row["review_reason"] = "MANUAL_SPLIT_FROM_RECHECK" + row["proof_date"] = "" + row["draft_no"] = "" + row["voucher_account_name"] = "" + row["voucher_vendor"] = "" + row["voucher_debit"] = 0 + row["voucher_credit"] = 0 + row["voucher_desc"] = "" + row["voucher_row_key"] = "" + row["match_identity_key"] = "" + else: + row["status_label"] = "Matched" + row["review_reason"] = clean(row.get("review_reason")) or "RECHECK_PROMOTED_BY_USER_RULE" + values = [row.get(column, "") for column in ROW_COLUMNS] + conn.execute( + f""" + INSERT INTO wehago_compare_query_rows ({', '.join(ROW_COLUMNS)}, created_at, updated_at) + VALUES ({', '.join('?' for _ in ROW_COLUMNS)}, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) + """, + values, + ) + + +def main() -> None: + conn = sqlite3.connect(DB_PATH) + conn.row_factory = sqlite3.Row + source_signature = _latest_query_projection_signature(conn) + source_kind = "query_projection" + if not source_signature: + source_signature = _snapshot_signature(conn) + source_kind = "export_cache" + target_signature = f"{QUERY_PROJECTION_VERSION}|snapshot-recheck-promote|{source_signature}" + groups = ( + _load_groups_from_query_projection(conn, source_signature) + if source_kind == "query_projection" + else _load_groups_from_export_cache(conn, source_signature) + ) + groups["voucher_recheck"] = [ + _merge_internal_recheck_pairs(group) + for group in groups.get("voucher_recheck", []) + ] + review_keys, match_change_keys, split_change_keys = _load_manual_change_keys(conn) + promoted_ids = _select_promotions(groups, review_keys, match_change_keys) + conn.execute("BEGIN") + try: + conn.execute( + "DELETE FROM wehago_compare_query_rows WHERE start_year = ? AND end_year = ? AND signature = ?", + (TARGET_START_YEAR, TARGET_END_YEAR, target_signature), + ) + conn.execute( + "DELETE FROM wehago_compare_query_groups WHERE start_year = ? AND end_year = ? AND signature = ?", + (TARGET_START_YEAR, TARGET_END_YEAR, target_signature), + ) + max_group_index = { + status_key: max([int(group["source_group_index"]) for group in status_groups] or [0]) + for status_key, status_groups in groups.items() + } + for status_key, status_groups in groups.items(): + for group in status_groups: + if status_key == "voucher_recheck" and id(group) in promoted_ids: + continue + _insert_group( + conn, + signature=target_signature, + status_key=status_key, + group_index=int(group["source_group_index"]), + group=group, + ) + for group in groups.get("voucher_recheck", []): + if id(group) not in promoted_ids: + continue + max_group_index["voucher_matched"] = max_group_index.get("voucher_matched", 0) + 1 + _insert_group( + conn, + signature=target_signature, + status_key="voucher_matched", + group_index=max_group_index["voucher_matched"], + group=group, + promoted=True, + split_change_keys=split_change_keys, + ) + max_group_index["erp_voucher_matched"] = max_group_index.get("erp_voucher_matched", 0) + 1 + _insert_group( + conn, + signature=target_signature, + status_key="erp_voucher_matched", + group_index=max_group_index["erp_voucher_matched"], + group=group, + promoted=True, + split_change_keys=split_change_keys, + omit_split_rows=True, + ) + conn.execute( + """ + INSERT INTO wehago_action_history (action_type, payload_json, created_at) + VALUES ('auto_recheck_promote', ?, CURRENT_TIMESTAMP) + """, + ( + f'{{"count": {len(promoted_ids)}, "start_year": {TARGET_START_YEAR}, ' + f'"manual_review_keys": {len(review_keys)}, "match_change_keys": {len(match_change_keys)}, ' + f'"split_change_keys": {len(split_change_keys)}, ' + f'"source_kind": "{source_kind}", ' + f'"end_year": {TARGET_END_YEAR}, "signature": "{target_signature}", ' + f'"created_at": "{datetime.now().isoformat(timespec="seconds")}"}}', + ), + ) + conn.commit() + except Exception: + conn.rollback() + raise + counts = conn.execute( + """ + SELECT status_key, COUNT(*) + FROM wehago_compare_query_groups + WHERE signature = ? + AND fiscal_year BETWEEN ? AND ? + GROUP BY status_key + ORDER BY status_key + """, + (target_signature, TARGET_START_YEAR, TARGET_END_YEAR), + ).fetchall() + print( + { + "source_signature": source_signature, + "source_kind": source_kind, + "target_signature": target_signature, + "promoted": len(promoted_ids), + "counts": {str(row[0]): int(row[1]) for row in counts}, + } + ) + + +if __name__ == "__main__": + main() diff --git a/scripts/rebuild_compare_ranges.py b/scripts/rebuild_compare_ranges.py new file mode 100644 index 0000000..b2dba5b --- /dev/null +++ b/scripts/rebuild_compare_ranges.py @@ -0,0 +1,95 @@ +from __future__ import annotations + +import argparse +import fcntl +from pathlib import Path + +import sys + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + +from main import engine +from wehago_compare import ( + _build_db_state_signature, + _get_fast_year_export_row_cache_signature, + _load_snapshot_status_map, + _project_year_export_row_cache_from_latest_resolved, + _rebuild_compare_query_projection, + _refresh_year_resolved_sections, + _status_row_counts_from_sections, + _upsert_snapshot_status, +) + + +def parse_range(value: str) -> tuple[int, int]: + raw = str(value or "").strip() + if "-" not in raw: + year = int(raw) + return year, year + left, right = raw.split("-", 1) + start_year = int(left) + end_year = int(right) + if start_year > end_year: + start_year, end_year = end_year, start_year + return start_year, end_year + + +def main() -> None: + parser = argparse.ArgumentParser(description="Rebuild WEHAGO compare snapshots and query projections.") + parser.add_argument("--years", nargs="*", type=int, default=[]) + parser.add_argument("--fast-years", nargs="*", type=int, default=[]) + parser.add_argument("--ranges", nargs="*", default=[]) + args = parser.parse_args() + + lock_path = Path("/tmp/wehago_compare_compute.lock") + lock_path.parent.mkdir(parents=True, exist_ok=True) + with lock_path.open("w") as lock_file: + fcntl.flock(lock_file.fileno(), fcntl.LOCK_EX) + with engine.connect().execution_options(isolation_level="AUTOCOMMIT") as conn: + for year in sorted({int(year) for year in args.fast_years if int(year or 0) > 0}): + signature = _build_db_state_signature(conn, year, year) + print({"step": "fast_year_projection_start", "year": year, "signature": signature}, flush=True) + projected = _project_year_export_row_cache_from_latest_resolved(conn, year, signature) + if not projected: + print({"step": "fast_year_projection_miss", "year": year}, flush=True) + _refresh_year_resolved_sections(conn, year) + else: + _upsert_snapshot_status( + conn, + year, + signature=signature, + state="ready", + row_counts={}, + built_now=True, + ) + selected_signature = _get_fast_year_export_row_cache_signature(conn, year) + print( + { + "step": "fast_year_projection_done", + "year": year, + "selected_signature": selected_signature, + }, + flush=True, + ) + for year in sorted({int(year) for year in args.years if int(year or 0) > 0}): + signature = _build_db_state_signature(conn, year, year) + print({"step": "year_snapshot_start", "year": year, "signature": signature}, flush=True) + _refresh_year_resolved_sections(conn, year) + print({"step": "year_snapshot_done", "year": year}, flush=True) + for start_year, end_year in [parse_range(item) for item in args.ranges]: + print({"step": "projection_start", "start_year": start_year, "end_year": end_year}, flush=True) + counts, snapshot_state = _rebuild_compare_query_projection(engine, conn, start_year, end_year) + print( + { + "step": "projection_done", + "start_year": start_year, + "end_year": end_year, + "counts": counts, + "snapshot_state": snapshot_state, + }, + flush=True, + ) + + +if __name__ == "__main__": + main() diff --git a/scripts/reconcile_wehago_projection_to_db.py b/scripts/reconcile_wehago_projection_to_db.py new file mode 100644 index 0000000..4333b8f --- /dev/null +++ b/scripts/reconcile_wehago_projection_to_db.py @@ -0,0 +1,1496 @@ +from __future__ import annotations + +import argparse +import json +import re +import sqlite3 +from collections import Counter, defaultdict +from datetime import datetime +from pathlib import Path +from typing import Any + +import sys + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + +from runtime_config import DB_PATH +from wehago_compare import ( + QUERY_PROJECTION_VERSION, + _account_category_pair_allowed, + _classify_account_category, + _classify_account_family, + _is_wehago_excepted_voucher_group, + _is_vat_family, + _move_wehago_confirmed_reversal_pairs_to_excepted, + _move_wehago_offset_tax_invoice_groups_to_excepted, + _nature_compatible, + build_voucher_row_key, + clean, +) + + +YEAR = 2025 +WEHAGO_STATUSES = ("voucher_matched", "voucher_unmatched", "voucher_recheck", "voucher_excepted") +ERP_STATUSES = ("erp_voucher_matched", "erp_voucher_unmatched") +ALL_VOUCHER_STATUSES = WEHAGO_STATUSES + ERP_STATUSES +RAW_ERP_ROWS_BY_DRAFT_BASE: dict[str, list[dict[str, Any]]] = defaultdict(list) +MANUAL_OFFSET_EXCEPTED_IDENTITIES: set[str] = set() + +GROUP_COLUMNS = ( + "start_year", + "end_year", + "status_key", + "signature", + "group_index", + "fiscal_year", + "ledger_date", + "proof_date", + "voucher_no", + "draft_no", + "ledger_row_count", + "voucher_row_count", + "ledger_debit", + "ledger_credit", + "voucher_debit", + "voucher_credit", + "ledger_accounts", + "voucher_accounts", + "ledger_vendors", + "voucher_vendors", + "review_reason", + "search_text", +) + +ROW_COLUMNS = ( + "start_year", + "end_year", + "status_key", + "signature", + "group_index", + "row_index", + "fiscal_year", + "status_label", + "ledger_date", + "proof_date", + "voucher_no", + "draft_no", + "ledger_account_name", + "voucher_account_name", + "ledger_vendor", + "voucher_vendor", + "ledger_debit", + "ledger_credit", + "voucher_debit", + "voucher_credit", + "ledger_desc", + "voucher_desc", + "review_reason", + "matched_case", + "ledger_row_key", + "voucher_row_key", + "match_identity_key", +) + +def parse_amount(value: Any) -> float: + try: + return float(str(value or "0").replace(",", "")) + except Exception: + return 0.0 + + +def amount_key(value: Any) -> str: + amount = parse_amount(value) + if abs(amount - round(amount)) < 0.0001: + return str(int(round(amount))) + return f"{amount:.2f}".rstrip("0").rstrip(".") + + +def db_key_from_display(year: int, ledger_date: Any, voucher_no: Any) -> str: + voucher = clean(voucher_no) + if re.fullmatch(r"\d{8}-\d{5}", voucher): + return voucher + date_text = clean(ledger_date) + full_date_match = re.search(r"((?:19|20)\d{2})[-./](\d{1,2})[-./](\d{1,2})", date_text) + date_match = re.search(r"(\d{1,2})[-./](\d{1,2})", date_text) + voucher_digits = re.sub(r"\D+", "", voucher) + if full_date_match and voucher_digits: + return f"{int(full_date_match.group(1)):04d}{int(full_date_match.group(2)):02d}{int(full_date_match.group(3)):02d}-{int(voucher_digits):05d}" + if date_match and voucher_digits: + return f"{int(year):04d}{int(date_match.group(1)):02d}{int(date_match.group(2)):02d}-{int(voucher_digits):05d}" + return "" + + +def display_date_from_db_key(db_key: str) -> str: + return f"{db_key[4:6]}-{db_key[6:8]}" if re.fullmatch(r"\d{8}-\d{5}", db_key) else "" + + +def display_voucher_from_db_key(db_key: str) -> str: + return db_key.split("-", 1)[1] if "-" in db_key else db_key + + +def erp_voucher_base(value: Any) -> str: + text = clean(value) + match = re.fullmatch(r"(11-\d{8}-[^-]+-\d+)-\d+", text) + if match: + return match.group(1) + return text + + +def dict_row(row: sqlite3.Row) -> dict[str, Any]: + return {key: row[key] for key in row.keys()} + + +def latest_projection_signature(cur: sqlite3.Cursor) -> str: + row = cur.execute( + """ + SELECT payload_json + FROM wehago_action_history + WHERE action_type = 'auto_recheck_promote' + ORDER BY id DESC + LIMIT 1 + """ + ).fetchone() + if row: + try: + payload = json.loads(row[0] or "{}") + signature = clean(payload.get("signature")) + if signature and "|db-reconciled-v1|" not in signature: + exists = cur.execute( + """ + SELECT 1 + FROM wehago_compare_query_groups + WHERE start_year = ? AND end_year = ? AND signature = ? + LIMIT 1 + """, + (YEAR, YEAR, signature), + ).fetchone() + if exists: + return signature + except Exception: + pass + row = cur.execute( + """ + SELECT signature, MAX(updated_at) AS max_updated_at + FROM wehago_compare_query_groups + WHERE start_year = ? AND end_year = ? AND signature LIKE ? + AND signature NOT LIKE '%|db-reconciled-v1|%' + GROUP BY signature + ORDER BY max_updated_at DESC + LIMIT 1 + """, + (YEAR, YEAR, f"{QUERY_PROJECTION_VERSION}|%"), + ).fetchone() + if not row: + row = cur.execute( + """ + SELECT signature, MAX(updated_at) AS max_updated_at + FROM wehago_compare_query_groups + WHERE start_year = ? AND end_year = ? + AND signature NOT LIKE '%|db-reconciled-v1|%' + GROUP BY signature + ORDER BY max_updated_at DESC + LIMIT 1 + """, + (YEAR, YEAR), + ).fetchone() + if not row: + raise RuntimeError("No current query projection was found.") + return clean(row["signature"]) + + +def load_groups(conn: sqlite3.Connection, signature: str) -> dict[str, list[dict[str, Any]]]: + group_rows = conn.execute( + f""" + SELECT * + FROM wehago_compare_query_groups + WHERE start_year = ? AND end_year = ? AND signature = ? + AND status_key IN ({','.join('?' for _ in ALL_VOUCHER_STATUSES)}) + ORDER BY status_key, group_index + """, + (YEAR, YEAR, signature, *ALL_VOUCHER_STATUSES), + ).fetchall() + detail_rows = conn.execute( + f""" + SELECT * + FROM wehago_compare_query_rows + WHERE start_year = ? AND end_year = ? AND signature = ? + AND status_key IN ({','.join('?' for _ in ALL_VOUCHER_STATUSES)}) + ORDER BY status_key, group_index, row_index + """, + (YEAR, YEAR, signature, *ALL_VOUCHER_STATUSES), + ).fetchall() + rows_by_key: dict[tuple[str, int], list[dict[str, Any]]] = defaultdict(list) + for row in detail_rows: + item = dict_row(row) + rows_by_key[(clean(item.get("status_key")), int(item.get("group_index") or 0))].append(item) + groups: dict[str, list[dict[str, Any]]] = defaultdict(list) + for row in group_rows: + summary = dict_row(row) + status_key = clean(summary.get("status_key")) + group_index = int(summary.get("group_index") or 0) + groups[status_key].append( + { + "summary": summary, + "rows": rows_by_key.get((status_key, group_index), []), + "source_group_index": group_index, + } + ) + return groups + + +def load_db_wehago(conn: sqlite3.Connection) -> dict[str, dict[str, Any]]: + rows = conn.execute( + """ + SELECT * + FROM wehago_comparison_results + WHERE fiscal_year = ? + AND status <> 'voucher_only' + ORDER BY voucher_no + """, + (YEAR,), + ).fetchall() + return {clean(row["voucher_no"]): dict_row(row) for row in rows if clean(row["voucher_no"])} + + +def load_raw_erp_rows_by_draft_base(conn: sqlite3.Connection) -> dict[str, list[dict[str, Any]]]: + result: dict[str, list[dict[str, Any]]] = defaultdict(list) + for row in conn.execute( + """ + SELECT * + FROM wehago_voucher_rows + WHERE fiscal_year BETWEEN ? AND ? + AND (COALESCE(draft_no, '') <> '' OR COALESCE(confirmed_no, '') <> '') + ORDER BY row_number + """, + (YEAR - 1, YEAR + 1), + ).fetchall(): + payload = dict_row(row) + keys = { + erp_voucher_base(payload.get("draft_no")), + erp_voucher_base(payload.get("confirmed_no")), + } + for key in keys: + if key: + result[key].append(payload) + return result + + +def group_identity(group: dict[str, Any]) -> str: + summary = group.get("summary") or {} + return db_key_from_display( + int(summary.get("fiscal_year") or YEAR), + summary.get("ledger_date"), + summary.get("voucher_no"), + ) + + +def load_manual_offset_excepted_identities(conn: sqlite3.Connection) -> set[str]: + conn.execute( + """ + CREATE TABLE IF NOT EXISTS wehago_manual_offset_excepted ( + pair_key TEXT PRIMARY KEY, + left_identity TEXT NOT NULL, + right_identity TEXT NOT NULL, + start_year INTEGER NOT NULL, + end_year INTEGER NOT NULL, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP + ) + """ + ) + identities: set[str] = set() + for row in conn.execute( + """ + SELECT left_identity, right_identity + FROM wehago_manual_offset_excepted + """ + ).fetchall(): + identities.update(filter(None, (clean(row["left_identity"]), clean(row["right_identity"])))) + return identities + + +def row_wehago_identity(row: dict[str, Any], fallback_group: dict[str, Any] | None = None) -> str: + row_key = db_key_from_display( + int(row.get("fiscal_year") or YEAR), + row.get("ledger_date"), + row.get("voucher_no"), + ) + if row_key: + return row_key + if fallback_group: + return group_identity(fallback_group) + return "" + + +def has_erp_value(row: dict[str, Any]) -> bool: + return bool(clean(row.get("voucher_account_name"))) and ( + abs(parse_amount(row.get("voucher_debit"))) > 0.0001 + or abs(parse_amount(row.get("voucher_credit"))) > 0.0001 + ) + + +def has_wehago_value(row: dict[str, Any]) -> bool: + return bool(clean(row.get("ledger_account_name"))) and ( + abs(parse_amount(row.get("ledger_debit"))) > 0.0001 + or abs(parse_amount(row.get("ledger_credit"))) > 0.0001 + ) + + +def blank_erp_side(row: dict[str, Any], reason: str) -> dict[str, Any]: + payload = dict(row) + payload["status_label"] = "Unmatched" + payload["proof_date"] = "" + payload["draft_no"] = "" + payload["voucher_account_name"] = "" + payload["voucher_vendor"] = "" + payload["voucher_debit"] = 0 + payload["voucher_credit"] = 0 + payload["voucher_desc"] = "" + payload["voucher_row_key"] = "" + payload["match_identity_key"] = "" + payload["review_reason"] = reason + return payload + + +def blank_wehago_side(row: dict[str, Any], reason: str) -> dict[str, Any]: + payload = dict(row) + payload["status_label"] = "ERP Unmatched" + payload["ledger_date"] = "" + payload["ledger_account_name"] = "" + payload["ledger_vendor"] = "" + payload["ledger_debit"] = 0 + payload["ledger_credit"] = 0 + payload["ledger_desc"] = "" + payload["ledger_row_key"] = "" + payload["match_identity_key"] = "" + payload["review_reason"] = reason + return payload + + +def ledger_row_identity(row: dict[str, Any]) -> tuple[Any, ...]: + row_key = clean(row.get("ledger_row_key")) + if row_key: + return ("key", row_key) + return ( + "side", + clean(row.get("ledger_account_name")), + clean(row.get("ledger_vendor")), + amount_key(row.get("ledger_debit")), + amount_key(row.get("ledger_credit")), + clean(row.get("ledger_desc")), + ) + + +def voucher_row_identity(row: dict[str, Any]) -> tuple[Any, ...]: + row_key = clean(row.get("voucher_row_key")) + if row_key: + return ("key", row_key) + return ( + "side", + clean(row.get("draft_no")), + clean(row.get("voucher_account_name")), + clean(row.get("voucher_vendor")), + amount_key(row.get("voucher_debit")), + amount_key(row.get("voucher_credit")), + clean(row.get("voucher_desc")), + ) + + +def direct_row_score(row: dict[str, Any]) -> tuple[int, float, int]: + same_amount = int( + abs(parse_amount(row.get("ledger_debit")) - parse_amount(row.get("voucher_debit"))) < 0.5 + and abs(parse_amount(row.get("ledger_credit")) - parse_amount(row.get("voucher_credit"))) < 0.5 + ) + amount = max( + abs(parse_amount(row.get("ledger_debit"))), + abs(parse_amount(row.get("ledger_credit"))), + abs(parse_amount(row.get("voucher_debit"))), + abs(parse_amount(row.get("voucher_credit"))), + ) + has_vendor = int(bool(clean(row.get("ledger_vendor"))) and clean(row.get("ledger_vendor")) == clean(row.get("voucher_vendor"))) + return same_amount, amount, has_vendor + + +def effective_account_side(row: dict[str, Any], prefix: str) -> str: + net_debit = parse_amount(row.get(f"{prefix}_debit")) - parse_amount(row.get(f"{prefix}_credit")) + if net_debit > 0.0001: + return "debit" + if net_debit < -0.0001: + return "credit" + return "either" + + +def direct_row_matches_account_nature(row: dict[str, Any]) -> bool: + if not (has_wehago_value(row) and has_erp_value(row)): + return True + if not _account_category_pair_allowed( + "", + row.get("ledger_account_name"), + "", + row.get("voucher_account_name"), + ): + return False + return _nature_compatible( + "", + row.get("ledger_account_name"), + effective_account_side(row, "ledger"), + "", + row.get("voucher_account_name"), + effective_account_side(row, "voucher"), + ) + + +def row_side_amount(row: dict[str, Any], prefix: str) -> float: + return max( + abs(parse_amount(row.get(f"{prefix}_debit"))), + abs(parse_amount(row.get(f"{prefix}_credit"))), + ) + + +def raw_erp_entry_amount_side(entry: dict[str, Any], category: str) -> tuple[float, str]: + fields = ( + ("debit_supply", "debit"), + ("credit_supply", "credit"), + ) + if category in {"asset", "liability"}: + fields = ( + ("debit_supply", "debit"), + ("credit_supply", "credit"), + ("debit_tax", "debit"), + ("credit_tax", "credit"), + ) + for field, side in fields: + amount = parse_amount(entry.get(field)) + if abs(amount) >= 0.5: + return abs(amount), side + return 0.0, "either" + + +def raw_erp_entry_to_row(entry: dict[str, Any], ledger_row: dict[str, Any], side: str, reason: str) -> dict[str, Any]: + amount, _entry_side = raw_erp_entry_amount_side( + entry, + _classify_account_category(entry.get("account_code"), entry.get("account_name")), + ) + desc = " ".join(part for part in (clean(entry.get("desc1")), clean(entry.get("desc2"))) if part) + row = dict(ledger_row) + row.update( + { + "proof_date": clean(entry.get("proof_date")), + "draft_no": clean(entry.get("draft_no")) or clean(entry.get("confirmed_no")), + "voucher_account_code": clean(entry.get("account_code")), + "voucher_account_name": clean(entry.get("account_name")), + "voucher_vendor": clean(entry.get("vendor_name")), + "voucher_debit": amount if side == "debit" else 0, + "voucher_credit": amount if side == "credit" else 0, + "voucher_desc": desc, + "status_label": "Matched", + "review_reason": reason, + } + ) + row["voucher_row_key"] = build_voucher_row_key(row) + row["match_identity_key"] = "|".join( + clean(part) + for part in ( + row.get("fiscal_year"), + row.get("voucher_no"), + row.get("ledger_row_key"), + row.get("draft_no"), + row.get("voucher_row_key"), + ) + if clean(part) + ) + return row + + +def retarget_to_same_draft_business_account(row: dict[str, Any]) -> dict[str, Any] | None: + if not (has_wehago_value(row) and clean(row.get("draft_no"))): + return None + ledger_category = _classify_account_category("", row.get("ledger_account_name")) + if ledger_category not in {"expense", "revenue"}: + return None + ledger_side = effective_account_side(row, "ledger") + ledger_amount = row_side_amount(row, "ledger") + if ledger_amount <= 0: + return None + candidate_bases = { + erp_voucher_base(part) + for part in re.split(r"[,/]\s*", clean(row.get("draft_no"))) + if clean(part) + } + best: tuple[int, dict[str, Any], str] | None = None + for base in candidate_bases: + for entry in RAW_ERP_ROWS_BY_DRAFT_BASE.get(base, []): + voucher_category = _classify_account_category(entry.get("account_code"), entry.get("account_name")) + if voucher_category != ledger_category: + continue + amount, side = raw_erp_entry_amount_side(entry, voucher_category) + if abs(amount - ledger_amount) >= 0.5: + continue + if not _nature_compatible("", row.get("ledger_account_name"), ledger_side, entry.get("account_code"), entry.get("account_name"), side): + continue + score = 0 + if clean(row.get("ledger_vendor")) and clean(row.get("ledger_vendor")) in clean(entry.get("vendor_name")): + score += 10 + if clean(entry.get("vendor_name")) and clean(entry.get("vendor_name")) in clean(row.get("ledger_vendor")): + score += 10 + if clean(row.get("ledger_desc")) and clean(row.get("ledger_desc")) == clean(" ".join(part for part in (entry.get("desc1"), entry.get("desc2")) if clean(part))): + score += 20 + if best is None or score > best[0]: + best = (score, entry, side) + if best is None: + if ledger_category != "expense": + return None + grouped_best: tuple[int, dict[str, Any], dict[str, Any], str] | None = None + for base in candidate_bases: + entries = RAW_ERP_ROWS_BY_DRAFT_BASE.get(base, []) + business_entries = [ + entry + for entry in entries + if _classify_account_category(entry.get("account_code"), entry.get("account_name")) == "expense" + ] + if not business_entries: + continue + for payable_entry in entries: + if _classify_account_family(payable_entry.get("account_code"), payable_entry.get("account_name")) != "payable": + continue + amount, _payable_side = raw_erp_entry_amount_side(payable_entry, "liability") + if abs(amount - ledger_amount) >= 0.5: + continue + for business_entry in business_entries: + business_amount, business_side = raw_erp_entry_amount_side(business_entry, "expense") + if business_amount + 0.5 < ledger_amount: + continue + if not _nature_compatible("", row.get("ledger_account_name"), ledger_side, business_entry.get("account_code"), business_entry.get("account_name"), business_side): + continue + score = 0 + payee = clean(payable_entry.get("vendor_name")) + ledger_text = f"{clean(row.get('ledger_vendor'))} {clean(row.get('ledger_desc'))}" + if payee and payee in ledger_text: + score += 40 + if clean(payable_entry.get("desc1")) and clean(payable_entry.get("desc1")) in ledger_text: + score += 20 + if clean(business_entry.get("account_name")) and "여비교통비" in clean(business_entry.get("account_name")) and "여비교통비" in clean(row.get("ledger_account_name")): + score += 20 + if grouped_best is None or score > grouped_best[0]: + grouped_best = (score, business_entry, payable_entry, business_side) + if grouped_best is None: + return None + business_entry = dict(grouped_best[1]) + payable_entry = grouped_best[2] + business_entry["debit_supply"] = ledger_amount if grouped_best[3] == "debit" else 0 + business_entry["credit_supply"] = ledger_amount if grouped_best[3] == "credit" else 0 + business_entry["vendor_name"] = clean(payable_entry.get("vendor_name")) or clean(business_entry.get("vendor_name")) + business_entry["desc1"] = clean(payable_entry.get("desc1")) or clean(business_entry.get("desc1")) + business_entry["management_item"] = clean(payable_entry.get("management_item")) or clean(business_entry.get("management_item")) + return raw_erp_entry_to_row( + business_entry, + row, + grouped_best[3], + "PROJECTION_RECONCILE_RETARGET_GROUPED_EXPENSE_PAYABLE_DETAIL", + ) + return raw_erp_entry_to_row(best[1], row, best[2], "PROJECTION_RECONCILE_RETARGET_SAME_DRAFT_BUSINESS_ACCOUNT") + + +def allowed_erp_drafts_for_group(rows: list[dict[str, Any]]) -> set[str]: + erp_rows = [row for row in rows if has_erp_value(row) and clean(row.get("draft_no"))] + exception_rows: dict[str, set[tuple[Any, ...]]] = defaultdict(set) + for row in rows: + family = _classify_account_family("", row.get("ledger_account_name")) + category = _classify_account_category("", row.get("ledger_account_name")) + bucket = "vat" if _is_vat_family(family) else category if category in {"expense", "asset"} else "" + if bucket: + exception_rows[bucket].add(ledger_row_identity(row)) + capacity = max(1, *(len(row_keys) for row_keys in exception_rows.values())) if exception_rows else 1 + drafts: dict[str, list[dict[str, Any]]] = defaultdict(list) + for row in erp_rows: + drafts[erp_voucher_base(row.get("draft_no"))].append(row) + ranked = sorted( + drafts, + key=lambda draft: ( + sum(direct_row_score(row)[0] for row in drafts[draft]), + sum(direct_row_score(row)[1] for row in drafts[draft]), + sum(direct_row_score(row)[2] for row in drafts[draft]), + -len(drafts[draft]), + draft, + ), + reverse=True, + ) + return set(ranked[: max(1, capacity)]) + + +def clean_group_rows(group: dict[str, Any], status_key: str) -> dict[str, Any]: + rows = [dict(row) for row in group.get("rows") or []] + if status_key == "voucher_matched": + normalized_rows: list[dict[str, Any]] = [] + for row in rows: + if direct_row_matches_account_nature(row): + normalized_rows.append(row) + continue + retargeted = retarget_to_same_draft_business_account(row) + if retargeted is not None: + normalized_rows.append(retargeted) + else: + normalized_rows.append(blank_erp_side(row, "PROJECTION_RECONCILE_INVALID_ACCOUNT_NATURE")) + rows = normalized_rows + allowed_drafts = allowed_erp_drafts_for_group(rows) + cleaned: list[dict[str, Any]] = [] + for row in rows: + if clean(row.get("voucher_account_name")) and ( + not has_erp_value(row) + or (clean(row.get("draft_no")) and erp_voucher_base(row.get("draft_no")) not in allowed_drafts) + ): + if has_wehago_value(row): + cleaned.append(blank_erp_side(row, "PROJECTION_RECONCILE_UNMATCHED_ERP_OVER_CAP")) + continue + else: + cleaned.append(row) + direct_drafts = { + erp_voucher_base(row.get("draft_no")) + for row in cleaned + if has_wehago_value(row) and has_erp_value(row) and clean(row.get("draft_no")) + } + rows = [ + row for row in cleaned + if has_wehago_value(row) + or not has_erp_value(row) + or erp_voucher_base(row.get("draft_no")) in direct_drafts + ] + elif status_key == "voucher_recheck": + cleaned = [] + for row in rows: + if clean(row.get("voucher_account_name")) and not has_erp_value(row): + if has_wehago_value(row): + cleaned.append(blank_erp_side(row, "PROJECTION_RECONCILE_RECHECK_NO_REAL_ERP_CANDIDATE")) + continue + cleaned.append(row) + rows = cleaned + + seen_ledger: set[tuple[Any, ...]] = set() + seen_voucher: set[tuple[Any, ...]] = set() + deduped: list[dict[str, Any]] = [] + for row in rows: + ledger_key = ledger_row_identity(row) if clean(row.get("ledger_account_name")) else None + voucher_key = voucher_row_identity(row) if clean(row.get("voucher_account_name")) else None + if ledger_key and ledger_key in seen_ledger: + continue + if voucher_key and voucher_key in seen_voucher: + continue + if ledger_key: + seen_ledger.add(ledger_key) + if voucher_key: + seen_voucher.add(voucher_key) + if clean(row.get("ledger_account_name")) or clean(row.get("voucher_account_name")): + deduped.append(row) + group = {"summary": dict(group.get("summary") or {}), "rows": deduped, "source_group_index": group.get("source_group_index")} + group["summary"] = rebuild_summary(group, status_key) + return group + + +def split_group_by_wehago_voucher(group: dict[str, Any], status_key: str) -> dict[str, dict[str, Any]]: + rows_by_identity: dict[str, list[dict[str, Any]]] = defaultdict(list) + for row in group.get("rows") or []: + identity = row_wehago_identity(row, group) + if identity: + rows_by_identity[identity].append(dict(row)) + if not rows_by_identity: + identity = group_identity(group) + if identity: + rows_by_identity[identity] = [dict(row) for row in group.get("rows") or []] + + split_groups: dict[str, dict[str, Any]] = {} + old_summary = dict(group.get("summary") or {}) + for identity, rows in rows_by_identity.items(): + if not rows: + continue + ledger_date = display_date_from_db_key(identity) + voucher_no = display_voucher_from_db_key(identity) + normalized_rows: list[dict[str, Any]] = [] + for row in rows: + payload = dict(row) + if clean(payload.get("ledger_account_name")): + payload["ledger_date"] = clean(payload.get("ledger_date")) or ledger_date + payload["voucher_no"] = clean(payload.get("voucher_no")) or voucher_no + elif clean(payload.get("voucher_account_name")): + payload["ledger_date"] = clean(payload.get("ledger_date")) or ledger_date + payload["voucher_no"] = clean(payload.get("voucher_no")) or voucher_no + normalized_rows.append(payload) + summary = { + **old_summary, + "fiscal_year": YEAR, + "ledger_date": ledger_date, + "voucher_no": voucher_no, + "draft_no": "", + "ledger_accounts": "", + "voucher_accounts": "", + "ledger_vendors": "", + "voucher_vendors": "", + "review_reason": clean(old_summary.get("review_reason")), + "search_text": "", + } + split_group = { + "summary": summary, + "rows": normalized_rows, + "source_group_index": group.get("source_group_index"), + } + split_group["summary"] = rebuild_summary(split_group, status_key) + split_groups[identity] = split_group + return split_groups + + +def normalized_wehago_status(status_key: str, group: dict[str, Any]) -> str: + if status_key == "voucher_matched": + if any( + has_wehago_value(row) and has_erp_value(row) + for row in group.get("rows") or [] + ): + return "voucher_matched" + review_text = " ".join( + clean(row.get("review_reason")) + for row in group.get("rows") or [] + ) + if any( + token in review_text + for token in ( + "PROJECTION_RECONCILE_INVALID_ACCOUNT_NATURE", + "PROJECTION_RECONCILE_UNMATCHED_ERP_OVER_CAP", + ) + ): + return "voucher_recheck" + return "voucher_unmatched" + if status_key != "voucher_recheck": + return status_key + review_text = " ".join( + [ + clean((group.get("summary") or {}).get("review_reason")), + *(clean(row.get("review_reason")) for row in group.get("rows") or []), + ] + ) + if any( + reason in review_text + for reason in ( + "MATCHED_CANCEL_TARGET_RECHECK", + "CANCEL_TARGET_ALREADY_MATCHED_RECHECK", + "CANCEL_REISSUE_RETARGET_RECHECK", + ) + ): + return "voucher_recheck" + has_real_erp_candidate = any(has_erp_value(row) for row in group.get("rows") or []) + return "voucher_recheck" if has_real_erp_candidate else "voucher_unmatched" + + +def mark_excepted(group: dict[str, Any], reason: str) -> dict[str, Any]: + summary = dict(group.get("summary") or {}) + for field in ("proof_date", "draft_no", "voucher_accounts", "voucher_vendors"): + summary[field] = "" + for field in ("voucher_row_count", "voucher_debit", "voucher_credit"): + summary[field] = 0 + rows: list[dict[str, Any]] = [] + for row in group.get("rows") or []: + if not clean(row.get("ledger_account_name")): + continue + row_payload = dict(row) + for field in ("proof_date", "draft_no", "voucher_account_name", "voucher_vendor", "voucher_desc", "voucher_row_key", "match_identity_key"): + row_payload[field] = "" + row_payload["voucher_debit"] = 0 + row_payload["voucher_credit"] = 0 + rows.append(row_payload) + payload = {"summary": summary, "rows": rows} + payload["summary"]["status_label"] = "Excepted" + existing_reason = clean(payload["summary"].get("review_reason")) + if reason not in existing_reason: + payload["summary"]["review_reason"] = " / ".join(item for item in (existing_reason, reason) if item) + for row in payload["rows"]: + row["status_label"] = "Excepted" + row_reason = clean(row.get("review_reason")) + if reason not in row_reason: + row["review_reason"] = " / ".join(item for item in (row_reason, reason) if item) + payload["summary"] = rebuild_summary(payload, "voucher_excepted") + return payload + + +def retag_group(group: dict[str, Any], status_key: str, reason: str = "") -> dict[str, Any]: + payload = { + "summary": dict(group.get("summary") or {}), + "rows": [dict(row) for row in group.get("rows") or []], + } + label = "Matched" if status_key == "voucher_matched" else "Recheck" if status_key == "voucher_recheck" else "Unmatched" + existing_reason = clean(payload["summary"].get("review_reason")) + if reason and reason not in existing_reason: + payload["summary"]["review_reason"] = " / ".join(item for item in (existing_reason, reason) if item) + for row in payload["rows"]: + row["status_label"] = label + row_reason = clean(row.get("review_reason")) + if reason and reason not in row_reason: + row["review_reason"] = " / ".join(item for item in (row_reason, reason) if item) + payload["summary"] = rebuild_summary(payload, status_key) + return payload + + +def group_ledger_net_by_account(group: dict[str, Any]) -> dict[str, float]: + result: dict[str, float] = defaultdict(float) + for row in group.get("rows") or []: + account = clean(row.get("ledger_account_name")).replace(" ", "") + if not account or not has_wehago_value(row): + continue + result[account] += parse_amount(row.get("ledger_debit")) - parse_amount(row.get("ledger_credit")) + return {key: value for key, value in result.items() if abs(value) >= 0.5} + + +def group_erp_net_mapped_by_ledger_account(group: dict[str, Any]) -> dict[str, float]: + result: dict[str, float] = defaultdict(float) + for row in group.get("rows") or []: + account = clean(row.get("ledger_account_name")).replace(" ", "") + if not account or not has_wehago_value(row) or not has_erp_value(row): + continue + result[account] += parse_amount(row.get("voucher_debit")) - parse_amount(row.get("voucher_credit")) + return {key: value for key, value in result.items() if abs(value) >= 0.5} + + +def group_ledger_vendors(group: dict[str, Any]) -> set[str]: + return { + clean(row.get("ledger_vendor")).replace(" ", "") + for row in group.get("rows") or [] + if clean(row.get("ledger_vendor")) + } + + +def groups_within_days(left: dict[str, Any], right: dict[str, Any], days: int) -> bool: + def as_date(group: dict[str, Any]) -> datetime | None: + summary = group.get("summary") or {} + year = int(summary.get("fiscal_year") or YEAR) + text_value = clean(summary.get("ledger_date")) + try: + return datetime.strptime(f"{year}-{text_value}", "%Y-%m-%d") + except ValueError: + return None + + left_date = as_date(left) + right_date = as_date(right) + return bool(left_date and right_date and abs((left_date - right_date).days) <= days) + + +def apply_net_adjustment_component_matches(status_groups: dict[str, list[dict[str, Any]]]) -> None: + matched_groups = list(status_groups.get("voucher_matched") or []) + candidate_statuses = ("voucher_unmatched", "voucher_recheck") + candidate_groups = [ + (status_key, group) + for status_key in candidate_statuses + for group in list(status_groups.get(status_key) or []) + ] + matched_index: dict[tuple[tuple[str, ...], str], list[tuple[dict[str, Any], dict[str, float], dict[str, float]]]] = defaultdict(list) + for matched in matched_groups: + matched_net = group_ledger_net_by_account(matched) + erp_net = group_erp_net_mapped_by_ledger_account(matched) + vendors = group_ledger_vendors(matched) + if not matched_net or set(erp_net) != set(matched_net) or not vendors: + continue + account_key = tuple(sorted(matched_net)) + for vendor in vendors: + matched_index[(account_key, vendor)].append((matched, matched_net, erp_net)) + moves: dict[int, tuple[dict[str, Any], str]] = {} + for candidate_status, candidate in candidate_groups: + candidate_net = group_ledger_net_by_account(candidate) + if not candidate_net or not any(value < -0.5 for value in candidate_net.values()): + continue + candidate_vendors = group_ledger_vendors(candidate) + if not candidate_vendors: + continue + possible: dict[int, tuple[dict[str, Any], dict[str, float], dict[str, float]]] = {} + account_key = tuple(sorted(candidate_net)) + for vendor in candidate_vendors: + for matched, matched_net, erp_net in matched_index.get((account_key, vendor), []): + possible[id(matched)] = (matched, matched_net, erp_net) + matches: list[dict[str, Any]] = [] + for matched, matched_net, erp_net in possible.values(): + if not groups_within_days(matched, candidate, 93): + continue + if all(abs(matched_net[key] + candidate_net[key] - erp_net[key]) < 0.5 for key in matched_net): + matches.append(matched) + if len(matches) != 1: + continue + matched_summary = matches[0].get("summary") or {} + draft_no = clean(matched_summary.get("draft_no")) + reason = "NET_ADJUSTMENT_COMPONENT_MATCH" + adjusted = retag_group(candidate, "voucher_matched", reason) + if draft_no: + adjusted["summary"]["draft_no"] = draft_no + adjusted["summary"]["search_text"] = f"{clean(adjusted['summary'].get('search_text'))} {draft_no} {reason}".strip() + moves[id(candidate)] = (adjusted, candidate_status) + + if not moves: + return + for status_key in candidate_statuses: + status_groups[status_key] = [ + group for group in list(status_groups.get(status_key) or []) if id(group) not in moves + ] + status_groups["voucher_matched"] = list(status_groups.get("voucher_matched") or []) + [ + adjusted for adjusted, _source_status in moves.values() + ] + + +def move_excepted_groups(status_groups: dict[str, list[dict[str, Any]]]) -> None: + excepted: list[dict[str, Any]] = [] + recheck_from_matched: list[dict[str, Any]] = [] + unmatched_from_excepted: list[dict[str, Any]] = [] + for group in list(status_groups.get("voucher_excepted") or []): + existing_reason = clean((group.get("summary") or {}).get("review_reason")) + if group_identity(group) in MANUAL_OFFSET_EXCEPTED_IDENTITIES: + excepted.append(mark_excepted(group, "MANUAL_OFFSET_PAIR_EXCEPTED")) + continue + if "WEHAGO_EXCEPTED_OFFSET" in existing_reason or "WEHAGO_EXCEPTED_CONFIRMED_REVERSAL_PAIR" in existing_reason: + excepted.append(mark_excepted(group, "")) + continue + is_excepted, reason = _is_wehago_excepted_voucher_group(group) + if is_excepted: + excepted.append(mark_excepted(group, reason)) + else: + recheck_from_matched.append( + retag_group(group, "voucher_recheck", "PROJECTION_RECONCILE_EXCEPTED_RULE_RELAXED_RECHECK") + ) + retained_by_status: dict[str, list[dict[str, Any]]] = {} + for status_key in ("voucher_matched", "voucher_unmatched", "voucher_recheck"): + retained: list[dict[str, Any]] = [] + for group in list(status_groups.get(status_key) or []): + if group_identity(group) in MANUAL_OFFSET_EXCEPTED_IDENTITIES: + excepted.append(mark_excepted(group, "MANUAL_OFFSET_PAIR_EXCEPTED")) + continue + is_excepted, reason = _is_wehago_excepted_voucher_group(group) + if is_excepted and status_key == "voucher_matched" and reason != "WEHAGO_EXCEPTED_DEPRECIATION_ACCOUNT": + recheck_from_matched.append( + retag_group(group, "voucher_recheck", f"PROJECTION_RECONCILE_EXCEPTED_CANDIDATE_RECHECK / {reason}") + ) + elif is_excepted: + excepted.append(mark_excepted(group, reason)) + else: + retained.append(group) + retained_by_status[status_key] = retained + retained_by_status["voucher_unmatched"].extend(unmatched_from_excepted) + retained_by_status["voucher_recheck"].extend(recheck_from_matched) + confirmed = _move_wehago_confirmed_reversal_pairs_to_excepted( + { + "voucher_matched": retained_by_status["voucher_matched"], + "voucher_unmatched": retained_by_status["voucher_unmatched"], + "voucher_recheck": retained_by_status["voucher_recheck"], + "voucher_excepted": excepted, + } + ) + retained_by_status["voucher_matched"] = list(confirmed.get("voucher_matched") or []) + retained_by_status["voucher_unmatched"] = list(confirmed.get("voucher_unmatched") or []) + retained_by_status["voucher_recheck"] = list(confirmed.get("voucher_recheck") or []) + excepted = list(confirmed.get("voucher_excepted") or []) + apply_net_adjustment_component_matches(retained_by_status) + moved = _move_wehago_offset_tax_invoice_groups_to_excepted( + { + "voucher_unmatched": retained_by_status["voucher_unmatched"], + "voucher_recheck": retained_by_status["voucher_recheck"], + "voucher_excepted": excepted, + } + ) + status_groups["voucher_matched"] = retained_by_status["voucher_matched"] + status_groups["voucher_unmatched"] = list(moved.get("voucher_unmatched") or []) + status_groups["voucher_recheck"] = list(moved.get("voucher_recheck") or []) + status_groups["voucher_excepted"] = [ + mark_excepted(group, "") for group in list(moved.get("voucher_excepted") or []) + ] + + +def append_unique(values: list[str], value: Any) -> None: + text = clean(value) + if text and text not in values: + values.append(text) + + +def rebuild_summary(group: dict[str, Any], status_key: str) -> dict[str, Any]: + old = dict(group.get("summary") or {}) + rows = list(group.get("rows") or []) + ledger_accounts: list[str] = [] + voucher_accounts: list[str] = [] + ledger_vendors: list[str] = [] + voucher_vendors: list[str] = [] + draft_nos: list[str] = [] + reasons: list[str] = [] + summary = { + **old, + "ledger_row_count": 0, + "voucher_row_count": 0, + "ledger_debit": 0.0, + "ledger_credit": 0.0, + "voucher_debit": 0.0, + "voucher_credit": 0.0, + } + for row in rows: + if clean(row.get("ledger_account_name")): + summary["ledger_row_count"] += 1 + summary["ledger_debit"] += parse_amount(row.get("ledger_debit")) + summary["ledger_credit"] += parse_amount(row.get("ledger_credit")) + append_unique(ledger_accounts, row.get("ledger_account_name")) + append_unique(ledger_vendors, row.get("ledger_vendor")) + if clean(row.get("voucher_account_name")): + summary["voucher_row_count"] += 1 + summary["voucher_debit"] += parse_amount(row.get("voucher_debit")) + summary["voucher_credit"] += parse_amount(row.get("voucher_credit")) + append_unique(voucher_accounts, row.get("voucher_account_name")) + append_unique(voucher_vendors, row.get("voucher_vendor")) + append_unique(draft_nos, row.get("draft_no")) + append_unique(reasons, row.get("review_reason")) + if not clean(summary.get("ledger_date")) and clean(row.get("ledger_date")): + summary["ledger_date"] = clean(row.get("ledger_date")) + if not clean(summary.get("proof_date")) and clean(row.get("proof_date")): + summary["proof_date"] = clean(row.get("proof_date")) + summary["status_label"] = "Matched" if status_key in {"voucher_matched", "erp_voucher_matched"} else "Recheck" if status_key == "voucher_recheck" else "Unmatched" + summary["ledger_accounts"] = ", ".join(ledger_accounts) + summary["voucher_accounts"] = ", ".join(voucher_accounts) + summary["ledger_vendors"] = ", ".join(ledger_vendors) + summary["voucher_vendors"] = ", ".join(voucher_vendors) + summary["draft_no"] = ", ".join(draft_nos) or clean(old.get("draft_no")) + summary["review_reason"] = " / ".join(reasons) or clean(old.get("review_reason")) + summary["search_text"] = " ".join( + clean(part) + for part in [ + summary.get("voucher_no"), + summary.get("draft_no"), + summary.get("ledger_accounts"), + summary.get("voucher_accounts"), + summary.get("ledger_vendors"), + summary.get("voucher_vendors"), + summary.get("review_reason"), + ] + if clean(part) + ) + return summary + + +def group_quality(group: dict[str, Any], status_key: str) -> tuple[int, int, float, int]: + priority = {"voucher_excepted": 4, "voucher_matched": 3, "voucher_recheck": 2, "voucher_unmatched": 1}.get(status_key, 0) + rows = list(group.get("rows") or []) + direct_rows = sum(1 for row in rows if has_wehago_value(row) and has_erp_value(row)) + amount = max( + parse_amount((group.get("summary") or {}).get("ledger_debit")), + parse_amount((group.get("summary") or {}).get("ledger_credit")), + parse_amount((group.get("summary") or {}).get("voucher_debit")), + parse_amount((group.get("summary") or {}).get("voucher_credit")), + ) + return priority, direct_rows, amount, -len(rows) + + +def build_missing_db_group(db_row: dict[str, Any], ledger_rows: list[sqlite3.Row]) -> dict[str, Any]: + key = clean(db_row.get("voucher_no")) + rows: list[dict[str, Any]] = [] + for index, row in enumerate(ledger_rows): + rows.append( + { + "fiscal_year": YEAR, + "status_label": "Unmatched", + "ledger_date": clean(row["ledger_date"]) or display_date_from_db_key(key), + "proof_date": "", + "voucher_no": display_voucher_from_db_key(key), + "draft_no": "", + "ledger_account_name": clean(row["account_name"]), + "voucher_account_name": "", + "ledger_vendor": clean(row["vendor_name"]), + "voucher_vendor": "", + "ledger_debit": parse_amount(row["debit"]), + "ledger_credit": parse_amount(row["credit"]), + "voucher_debit": 0, + "voucher_credit": 0, + "ledger_desc": clean(row["description"]), + "voucher_desc": "", + "review_reason": "PROJECTION_RECONCILE_DB_ONLY_WEHAGO", + "matched_case": "", + "ledger_row_key": f"ledger:{row['id']}", + "voucher_row_key": "", + "match_identity_key": "", + } + ) + if not rows: + rows.append( + { + "fiscal_year": YEAR, + "status_label": "Unmatched", + "ledger_date": display_date_from_db_key(key), + "proof_date": "", + "voucher_no": display_voucher_from_db_key(key), + "draft_no": "", + "ledger_account_name": clean(db_row.get("ledger_accounts")), + "voucher_account_name": "", + "ledger_vendor": clean(db_row.get("ledger_vendors")), + "voucher_vendor": "", + "ledger_debit": parse_amount(db_row.get("ledger_debit")), + "ledger_credit": parse_amount(db_row.get("ledger_credit")), + "voucher_debit": 0, + "voucher_credit": 0, + "ledger_desc": clean(db_row.get("notes")), + "voucher_desc": "", + "review_reason": "PROJECTION_RECONCILE_DB_ONLY_WEHAGO", + "matched_case": "", + "ledger_row_key": "", + "voucher_row_key": "", + "match_identity_key": "", + } + ) + summary = { + "fiscal_year": YEAR, + "status_label": "Unmatched", + "ledger_date": display_date_from_db_key(key), + "proof_date": "", + "voucher_no": display_voucher_from_db_key(key), + "draft_no": "", + "ledger_row_count": 0, + "voucher_row_count": 0, + "ledger_debit": 0.0, + "ledger_credit": 0.0, + "voucher_debit": 0.0, + "voucher_credit": 0.0, + "ledger_accounts": "", + "voucher_accounts": "", + "ledger_vendors": "", + "voucher_vendors": "", + "review_reason": "PROJECTION_RECONCILE_DB_ONLY_WEHAGO", + "search_text": "", + } + group = {"summary": summary, "rows": rows, "source_group_index": 0} + group["summary"] = rebuild_summary(group, "voucher_unmatched") + return group + + +def ledger_db_row_identity(row: sqlite3.Row) -> tuple[Any, ...]: + return ( + "side", + clean(row["account_name"]), + clean(row["vendor_name"]), + amount_key(row["debit"]), + amount_key(row["credit"]), + clean(row["description"]), + ) + + +def build_unmatched_ledger_row(row: sqlite3.Row, db_key: str, reason: str) -> dict[str, Any]: + return { + "fiscal_year": YEAR, + "status_label": "Unmatched", + "ledger_date": clean(row["ledger_date"]) or display_date_from_db_key(db_key), + "proof_date": "", + "voucher_no": display_voucher_from_db_key(db_key), + "draft_no": "", + "ledger_account_name": clean(row["account_name"]), + "voucher_account_name": "", + "ledger_vendor": clean(row["vendor_name"]), + "voucher_vendor": "", + "ledger_debit": parse_amount(row["debit"]), + "ledger_credit": parse_amount(row["credit"]), + "voucher_debit": 0, + "voucher_credit": 0, + "ledger_desc": clean(row["description"]), + "voucher_desc": "", + "review_reason": reason, + "matched_case": "", + "ledger_row_key": f"ledger:{row['id']}", + "voucher_row_key": "", + "match_identity_key": "", + } + + +def supplement_group_with_missing_wehago_rows( + group: dict[str, Any], + status_key: str, + db_key: str, + ledger_rows: list[sqlite3.Row], +) -> dict[str, Any]: + if status_key not in {"voucher_matched", "voucher_recheck"} or not ledger_rows: + return group + rows = [dict(row) for row in group.get("rows") or []] + seen = { + ledger_row_identity(row) + for row in rows + if clean(row.get("ledger_account_name")) + } + added = False + for ledger_row in ledger_rows: + identity = ledger_db_row_identity(ledger_row) + if identity in seen: + continue + rows.append(build_unmatched_ledger_row(ledger_row, db_key, "PROJECTION_RECONCILE_PARTIAL_MATCH_WEHAGO_ROW_UNMATCHED")) + seen.add(identity) + added = True + if not added: + return group + supplemented = { + "summary": dict(group.get("summary") or {}), + "rows": rows, + "source_group_index": group.get("source_group_index"), + } + supplemented["summary"] = rebuild_summary(supplemented, status_key) + return supplemented + + +def insert_group(conn: sqlite3.Connection, signature: str, status_key: str, group_index: int, group: dict[str, Any]) -> None: + summary = dict(group["summary"]) + summary.update( + { + "start_year": YEAR, + "end_year": YEAR, + "status_key": status_key, + "signature": signature, + "group_index": group_index, + } + ) + conn.execute( + f""" + INSERT INTO wehago_compare_query_groups ({', '.join(GROUP_COLUMNS)}, created_at, updated_at) + VALUES ({', '.join('?' for _ in GROUP_COLUMNS)}, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) + """, + [summary.get(column, "") for column in GROUP_COLUMNS], + ) + for row_index, row in enumerate(group.get("rows") or []): + row = dict(row) + row.update( + { + "start_year": YEAR, + "end_year": YEAR, + "status_key": status_key, + "signature": signature, + "group_index": group_index, + "row_index": row_index, + } + ) + conn.execute( + f""" + INSERT INTO wehago_compare_query_rows ({', '.join(ROW_COLUMNS)}, created_at, updated_at) + VALUES ({', '.join('?' for _ in ROW_COLUMNS)}, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) + """, + [row.get(column, "") for column in ROW_COLUMNS], + ) + + +def update_snapshot_row_counts(conn: sqlite3.Connection, counts: dict[str, int]) -> None: + row = conn.execute( + """ + SELECT row_counts_json + FROM wehago_snapshot_status + WHERE fiscal_year = ? + LIMIT 1 + """, + (YEAR,), + ).fetchone() + merged: dict[str, int] = {} + if row: + try: + parsed = json.loads(row["row_counts_json"] or "{}") + if isinstance(parsed, dict): + merged = {str(key): int(value or 0) for key, value in parsed.items()} + except Exception: + merged = {} + for status_key, count in counts.items(): + merged[status_key] = int(count or 0) + payload = json.dumps(merged, ensure_ascii=False) + if row: + conn.execute( + """ + UPDATE wehago_snapshot_status + SET state = 'ready', + row_counts_json = ?, + updated_at = CURRENT_TIMESTAMP, + last_built_at = CURRENT_TIMESTAMP, + error_message = '' + WHERE fiscal_year = ? + """, + (payload, YEAR), + ) + else: + conn.execute( + """ + INSERT INTO wehago_snapshot_status ( + fiscal_year, snapshot_signature, state, row_counts_json, + error_message, created_at, updated_at, last_requested_at, last_built_at + ) + VALUES (?, ?, 'ready', ?, '', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) + """, + (YEAR, "", payload), + ) + + +def main() -> None: + global MANUAL_OFFSET_EXCEPTED_IDENTITIES, RAW_ERP_ROWS_BY_DRAFT_BASE, YEAR + parser = argparse.ArgumentParser(description="Reconcile a WEHAGO comparison query projection to DB rows.") + parser.add_argument("--year", type=int, default=YEAR) + args = parser.parse_args() + YEAR = int(args.year) + + conn = sqlite3.connect(DB_PATH) + conn.row_factory = sqlite3.Row + cur = conn.cursor() + source_signature = latest_projection_signature(cur) + reconciled_prefix = f"{QUERY_PROJECTION_VERSION}|db-reconciled-v1|" + target_signature = source_signature if source_signature.startswith(reconciled_prefix) else f"{reconciled_prefix}{source_signature}" + RAW_ERP_ROWS_BY_DRAFT_BASE = load_raw_erp_rows_by_draft_base(conn) + MANUAL_OFFSET_EXCEPTED_IDENTITIES = load_manual_offset_excepted_identities(conn) + db_wehago = load_db_wehago(conn) + groups = load_groups(conn, source_signature) + + ledger_rows_by_key: dict[str, list[sqlite3.Row]] = defaultdict(list) + for row in conn.execute( + """ + SELECT * + FROM wehago_ledger_rows + WHERE fiscal_year = ? AND COALESCE(compare_voucher_no, '') <> '' + ORDER BY compare_voucher_no, row_number + """, + (YEAR,), + ).fetchall(): + ledger_rows_by_key[clean(row["compare_voucher_no"])].append(row) + + selected_by_key: dict[str, tuple[str, dict[str, Any]]] = {} + duplicate_counter = 0 + removed_non_db = 0 + for status_key in WEHAGO_STATUSES: + for group in groups.get(status_key, []): + for identity, split_group in split_group_by_wehago_voucher(group, status_key).items(): + split_group = clean_group_rows(split_group, status_key) + if not identity or identity not in db_wehago: + removed_non_db += 1 + continue + final_status_key = normalized_wehago_status(status_key, split_group) + if final_status_key != status_key: + split_group["summary"] = rebuild_summary(split_group, final_status_key) + current = selected_by_key.get(identity) + if current is None or group_quality(split_group, final_status_key) > group_quality(current[1], current[0]): + if current is not None: + duplicate_counter += 1 + selected_by_key[identity] = (final_status_key, split_group) + else: + duplicate_counter += 1 + + missing_db = sorted(set(db_wehago) - set(selected_by_key)) + for key in missing_db: + selected_by_key[key] = ( + "voucher_unmatched", + build_missing_db_group(db_wehago[key], ledger_rows_by_key.get(key, [])), + ) + + status_groups: dict[str, list[dict[str, Any]]] = defaultdict(list) + for key in sorted(selected_by_key): + status_key, group = selected_by_key[key] + group = supplement_group_with_missing_wehago_rows( + group, + status_key, + key, + ledger_rows_by_key.get(key, []), + ) + status_groups[status_key].append(group) + + move_excepted_groups(status_groups) + excepted_wehago_keys = { + group_identity(group) + for group in status_groups.get("voucher_excepted") or [] + if group_identity(group) + } + allowed_drafts_by_wehago = { + key: allowed_erp_drafts_for_group(group.get("rows") or []) + for group in status_groups.get("voucher_matched") or [] + for key in [group_identity(group)] + if key + } + for status_key in ERP_STATUSES: + seen_erp: set[str] = set() + for group in groups.get(status_key, []): + cleaned = clean_group_rows(group, status_key) + if status_key == "erp_voucher_matched": + normalized_rows: list[dict[str, Any]] = [] + for row in cleaned.get("rows") or []: + identity = row_wehago_identity(row, cleaned) + draft = erp_voucher_base(row.get("draft_no")) + allowed_drafts = allowed_drafts_by_wehago.get(identity) + if has_wehago_value(row) and has_erp_value(row) and not direct_row_matches_account_nature(row): + normalized_rows.append(blank_wehago_side(row, "PROJECTION_RECONCILE_INVALID_ACCOUNT_NATURE")) + continue + if has_wehago_value(row) and identity in excepted_wehago_keys: + if has_erp_value(row): + normalized_rows.append(blank_wehago_side(row, "PROJECTION_RECONCILE_WEHAGO_EXCEPTED")) + continue + if has_wehago_value(row) and allowed_drafts is not None and draft not in allowed_drafts: + if has_erp_value(row): + normalized_rows.append(blank_wehago_side(row, "PROJECTION_RECONCILE_UNMATCHED_WEHAGO_OVER_CAP")) + continue + normalized_rows.append(row) + cleaned["rows"] = normalized_rows + final_status_key = ( + "erp_voucher_matched" + if any(has_wehago_value(row) and has_erp_value(row) for row in normalized_rows) + else "erp_voucher_unmatched" + ) + cleaned["summary"] = rebuild_summary(cleaned, final_status_key) + else: + final_status_key = status_key + summary = cleaned.get("summary") or {} + identity = "|".join( + clean(part) + for part in (summary.get("fiscal_year"), summary.get("proof_date"), erp_voucher_base(summary.get("draft_no")) or summary.get("voucher_no")) + if clean(part) + ) + if not identity or identity in seen_erp: + continue + seen_erp.add(identity) + status_groups[final_status_key].append(cleaned) + + conn.execute("BEGIN") + try: + conn.execute( + "DELETE FROM wehago_compare_query_rows WHERE start_year = ? AND end_year = ? AND signature = ?", + (YEAR, YEAR, target_signature), + ) + conn.execute( + "DELETE FROM wehago_compare_query_groups WHERE start_year = ? AND end_year = ? AND signature = ?", + (YEAR, YEAR, target_signature), + ) + counts: dict[str, int] = {} + for status_key in ALL_VOUCHER_STATUSES: + for group_index, group in enumerate(status_groups.get(status_key, []), start=1): + insert_group(conn, target_signature, status_key, group_index, group) + counts[status_key] = len(status_groups.get(status_key, [])) + update_snapshot_row_counts(conn, counts) + conn.execute( + """ + INSERT INTO wehago_action_history (action_type, payload_json, created_at) + VALUES ('reconcile_wehago_projection', ?, CURRENT_TIMESTAMP) + """, + ( + json.dumps( + { + "start_year": YEAR, + "end_year": YEAR, + "signature": target_signature, + "source_signature": source_signature, + "db_wehago_count": len(db_wehago), + "counts": counts, + "removed_duplicate_groups": duplicate_counter, + "removed_non_db_groups": removed_non_db, + "added_db_only_groups": len(missing_db), + "created_at": datetime.now().isoformat(timespec="seconds"), + }, + ensure_ascii=False, + ), + ), + ) + conn.commit() + except Exception: + conn.rollback() + raise + print( + json.dumps( + { + "source_signature": source_signature, + "target_signature": target_signature, + "db_wehago_count": len(db_wehago), + "counts": counts, + "wehago_sum": sum(counts.get(status, 0) for status in WEHAGO_STATUSES), + "removed_duplicate_groups": duplicate_counter, + "removed_non_db_groups": removed_non_db, + "added_db_only_groups": len(missing_db), + }, + ensure_ascii=False, + ) + ) + conn.close() + + +if __name__ == "__main__": + main() diff --git a/scripts/run_server.sh b/scripts/run_server.sh index 1d1e24a..bfae811 100755 --- a/scripts/run_server.sh +++ b/scripts/run_server.sh @@ -35,7 +35,7 @@ if ss -ltn "( sport = :${PORT} )" | tail -n +2 | grep -q .; then exit 1 fi -export INTRANET_AUTO_RELOAD="${INTRANET_AUTO_RELOAD:-1}" +export INTRANET_AUTO_RELOAD="${INTRANET_AUTO_RELOAD:-0}" export INTRANET_PORT="$PORT" echo "서버를 시작합니다: http://127.0.0.1:${PORT} (auto_reload=${INTRANET_AUTO_RELOAD})" echo "예기치 않게 종료되면 2초 후 자동 재시작합니다." diff --git a/scripts/setup_windows_all_portproxies.ps1 b/scripts/setup_windows_all_portproxies.ps1 index 48d5a69..23b511a 100644 --- a/scripts/setup_windows_all_portproxies.ps1 +++ b/scripts/setup_windows_all_portproxies.ps1 @@ -81,6 +81,31 @@ function Get-WslListeningPorts { return $portMap.Values | Sort-Object Port } +function Get-DockerPublishedPorts { + $publishedPorts = New-Object System.Collections.Generic.HashSet[int] + try { + $raw = docker ps --format "{{.Ports}}" 2>$null + if (-not $raw) { + return $publishedPorts + } + + foreach ($line in ($raw -split "`r?`n")) { + if ([string]::IsNullOrWhiteSpace($line)) { + continue + } + foreach ($match in [regex]::Matches($line, "(?:0\.0\.0\.0|\[::\]):(?\d+)->")) { + $port = 0 + if ([int]::TryParse($match.Groups["port"].Value, [ref]$port)) { + [void]$publishedPorts.Add($port) + } + } + } + } catch { + return $publishedPorts + } + return $publishedPorts +} + if ([string]::IsNullOrWhiteSpace($WslIp)) { $detected = wsl.exe hostname -I 2>$null if (-not $detected) { @@ -110,6 +135,10 @@ $listenAddresses = $listenAddresses | Where-Object { -not [string]::IsNullOrWhit Write-Host "Setting Windows portproxy for all active WSL TCP ports..." -ForegroundColor Cyan Write-Host "WSL IP: $WslIp" $portEntries = @(Get-WslListeningPorts) +$dockerPublishedPorts = @(Get-DockerPublishedPorts) +if ($dockerPublishedPorts.Count -gt 0) { + Write-Host "Docker-published Windows ports: $((@($dockerPublishedPorts) | Sort-Object) -join ', ')" -ForegroundColor Cyan +} $knownPortEntries = @() foreach ($knownPort in ($knownPorts.Keys | Sort-Object)) { @@ -162,7 +191,13 @@ foreach ($entry in $reachableEntries) { foreach ($address in $listenAddresses) { netsh interface portproxy delete v4tov4 listenport=$port listenaddress=$address | Out-Null - netsh interface portproxy add v4tov4 listenport=$port listenaddress=$address connectport=$port connectaddress=$WslIp + if ($dockerPublishedPorts -notcontains $port) { + netsh interface portproxy add v4tov4 listenport=$port listenaddress=$address connectport=$port connectaddress=$WslIp + } + } + + if ($dockerPublishedPorts -contains $port) { + Write-Host " Docker already publishes this port on Windows; stale portproxy entries were removed and only firewall is refreshed." -ForegroundColor Yellow } $existingRule = Get-NetFirewallRule -DisplayName $ruleName -ErrorAction SilentlyContinue diff --git a/scripts/sqlite_runtime_admin.py b/scripts/sqlite_runtime_admin.py new file mode 100644 index 0000000..0042702 --- /dev/null +++ b/scripts/sqlite_runtime_admin.py @@ -0,0 +1,568 @@ +from __future__ import annotations + +import argparse +import json +import sqlite3 +import sys +from datetime import datetime +from pathlib import Path +from typing import Any + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + +from runtime_config import ( # noqa: E402 + BACKUP_DIR, + DB_PATH, + WAL_BLOCK_HEAVY_BYTES, + WAL_WARN_BYTES, + ensure_runtime_directories, + sqlite_runtime_status, +) + + +CACHE_TABLES = ( + "wehago_compare_export_row_cache", + "wehago_compare_query_page_cache", + "wehago_compare_query_groups", + "wehago_compare_query_rows", + "wehago_metric_count_cache", + "wehago_pair_recommend_cache", + "wehago_raw_erp_trace_candidate_cache", + "wehago_result_row_cache", + "wehago_summary_range_cache", +) +SOURCE_TABLES = ("wehago_voucher_rows", "wehago_ledger_rows") + + +def _size(path: Path) -> int: + return path.stat().st_size if path.exists() else 0 + + +def _table_counts(conn: sqlite3.Connection, tables: tuple[str, ...]) -> dict[str, int | None]: + existing = { + str(row[0]) + for row in conn.execute("SELECT name FROM sqlite_master WHERE type = 'table'").fetchall() + } + counts: dict[str, int | None] = {} + for table in tables: + if table not in existing: + counts[table] = None + continue + counts[table] = int(conn.execute(f"SELECT COUNT(*) FROM {table}").fetchone()[0]) + return counts + + +def _table_storage(conn: sqlite3.Connection, tables: tuple[str, ...]) -> dict[str, dict[str, int | None]]: + existing = { + str(row[0]) + for row in conn.execute("SELECT name FROM sqlite_master WHERE type = 'table'").fetchall() + } + has_dbstat = True + try: + conn.execute("SELECT 1 FROM dbstat LIMIT 1").fetchone() + except sqlite3.DatabaseError: + has_dbstat = False + page_size = int(conn.execute("PRAGMA page_size").fetchone()[0]) + storage: dict[str, dict[str, int | None]] = {} + for table in tables: + if table not in existing: + storage[table] = {"rows": None, "bytes": None} + continue + rows = int(conn.execute(f"SELECT COUNT(*) FROM {table}").fetchone()[0]) + bytes_used: int | None = None + if has_dbstat: + try: + stat = conn.execute( + "SELECT SUM(pgsize) FROM dbstat WHERE name = ?", + (table,), + ).fetchone() + bytes_used = int(stat[0] or 0) + except sqlite3.DatabaseError: + bytes_used = None + storage[table] = {"rows": rows, "bytes": bytes_used if has_dbstat else None} + if not has_dbstat: + for table in storage.values(): + table["estimated_page_size"] = page_size + return storage + + +def _table_exists(conn: sqlite3.Connection, table: str) -> bool: + return bool( + conn.execute( + "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?", + (table,), + ).fetchone() + ) + + +def cache_retention_report(limit: int) -> dict[str, Any]: + conn = sqlite3.connect(f"file:{DB_PATH}?mode=ro", uri=True, timeout=30) + try: + if not _table_exists(conn, "wehago_compare_export_row_cache"): + return {"export_row_cache": {"exists": False}} + signatures = [ + { + "fiscal_year": int(row[0]), + "snapshot_signature": str(row[1]), + "rows": int(row[2]), + } + for row in conn.execute( + """ + SELECT fiscal_year, snapshot_signature, COUNT(*) AS rows + FROM wehago_compare_export_row_cache + GROUP BY fiscal_year, snapshot_signature + ORDER BY fiscal_year DESC, rows DESC + LIMIT ? + """, + (limit,), + ).fetchall() + ] + orphan_rows = 0 + if _table_exists(conn, "wehago_snapshot_status"): + orphan_rows = int( + conn.execute( + """ + SELECT COUNT(*) + FROM wehago_compare_export_row_cache c + LEFT JOIN wehago_snapshot_status s + ON s.fiscal_year = c.fiscal_year + AND s.snapshot_signature = c.snapshot_signature + AND s.state = 'ready' + WHERE s.fiscal_year IS NULL + """ + ).fetchone()[0] + ) + return { + "export_row_cache": { + "exists": True, + "signature_groups_sample": signatures, + "orphan_rows_not_matching_ready_snapshot": orphan_rows, + } + } + finally: + conn.close() + + +def prune_orphan_export_cache(dry_run: bool, acknowledged: bool) -> dict[str, Any]: + if not dry_run and not acknowledged: + raise RuntimeError( + "실제 삭제는 `--ack-delete-rebuildable-cache`를 함께 지정해야 합니다. " + "삭제 대상은 현재 ready 스냅샷 서명과 맞지 않는 재생성 가능 export-row 캐시입니다." + ) + conn = sqlite3.connect(DB_PATH, timeout=30) + try: + if not _table_exists(conn, "wehago_compare_export_row_cache") or not _table_exists( + conn, + "wehago_snapshot_status", + ): + return {"dry_run": dry_run, "candidate_rows": 0, "deleted_rows": 0} + candidate_rows = int( + conn.execute( + """ + SELECT COUNT(*) + FROM wehago_compare_export_row_cache c + LEFT JOIN wehago_snapshot_status s + ON s.fiscal_year = c.fiscal_year + AND s.snapshot_signature = c.snapshot_signature + AND s.state = 'ready' + WHERE s.fiscal_year IS NULL + """ + ).fetchone()[0] + ) + deleted_rows = 0 + if not dry_run and candidate_rows: + conn.execute( + """ + DELETE FROM wehago_compare_export_row_cache + WHERE NOT EXISTS ( + SELECT 1 + FROM wehago_snapshot_status s + WHERE s.fiscal_year = wehago_compare_export_row_cache.fiscal_year + AND s.snapshot_signature = wehago_compare_export_row_cache.snapshot_signature + AND s.state = 'ready' + ) + """ + ) + deleted_rows = conn.total_changes + conn.commit() + return {"dry_run": dry_run, "candidate_rows": candidate_rows, "deleted_rows": deleted_rows} + finally: + conn.close() + + +def query_projection_retention_report(keep: int) -> dict[str, Any]: + conn = sqlite3.connect(f"file:{DB_PATH}?mode=ro", uri=True, timeout=30) + conn.row_factory = sqlite3.Row + try: + if not _table_exists(conn, "wehago_compare_query_groups"): + return {"query_projection_cache": {"exists": False}} + groups = conn.execute( + """ + SELECT start_year, end_year, signature, + COUNT(*) AS group_rows, + MAX(updated_at) AS max_updated_at + FROM wehago_compare_query_groups + GROUP BY start_year, end_year, signature + ORDER BY start_year DESC, end_year DESC, max_updated_at DESC + """ + ).fetchall() + keep = max(1, int(keep)) + by_scope: dict[tuple[int, int], list[sqlite3.Row]] = {} + for row in groups: + by_scope.setdefault((int(row["start_year"]), int(row["end_year"])), []).append(row) + obsolete: list[sqlite3.Row] = [] + retained: list[dict[str, Any]] = [] + for scope, rows in by_scope.items(): + for idx, row in enumerate(rows): + item = { + "start_year": int(row["start_year"]), + "end_year": int(row["end_year"]), + "signature": str(row["signature"] or ""), + "group_rows": int(row["group_rows"] or 0), + "max_updated_at": str(row["max_updated_at"] or ""), + "retained": idx < keep, + } + if idx < keep: + retained.append(item) + else: + obsolete.append(row) + obsolete_group_rows = sum(int(row["group_rows"] or 0) for row in obsolete) + obsolete_query_rows = 0 + obsolete_page_rows = 0 + for row in obsolete: + params = ( + int(row["start_year"]), + int(row["end_year"]), + str(row["signature"] or ""), + ) + if _table_exists(conn, "wehago_compare_query_rows"): + obsolete_query_rows += int( + conn.execute( + """ + SELECT COUNT(*) + FROM wehago_compare_query_rows + WHERE start_year = ? AND end_year = ? AND signature = ? + """, + params, + ).fetchone()[0] + ) + if _table_exists(conn, "wehago_compare_query_page_cache"): + obsolete_page_rows += int( + conn.execute( + """ + SELECT COUNT(*) + FROM wehago_compare_query_page_cache + WHERE start_year = ? AND end_year = ? AND signature = ? + """, + params, + ).fetchone()[0] + ) + return { + "query_projection_cache": { + "exists": True, + "keep_per_range": keep, + "range_signature_count": len(groups), + "obsolete_signature_count": len(obsolete), + "obsolete_group_rows": obsolete_group_rows, + "obsolete_query_rows": obsolete_query_rows, + "obsolete_page_rows": obsolete_page_rows, + "retained_sample": retained[:20], + } + } + finally: + conn.close() + + +def prune_old_query_projections(dry_run: bool, acknowledged: bool, keep: int) -> dict[str, Any]: + if not dry_run and not acknowledged: + raise RuntimeError( + "실제 삭제는 `--ack-delete-rebuildable-cache`를 함께 지정해야 합니다. " + "삭제 대상은 범위별 최신 N개를 제외한 재생성 가능 query projection 캐시입니다." + ) + conn = sqlite3.connect(DB_PATH, timeout=30) + conn.row_factory = sqlite3.Row + try: + if not _table_exists(conn, "wehago_compare_query_groups"): + return {"dry_run": dry_run, "candidate_group_rows": 0, "candidate_query_rows": 0, "candidate_page_rows": 0} + keep = max(1, int(keep)) + rows = conn.execute( + """ + SELECT start_year, end_year, signature, MAX(updated_at) AS max_updated_at + FROM wehago_compare_query_groups + GROUP BY start_year, end_year, signature + ORDER BY start_year, end_year, max_updated_at DESC + """ + ).fetchall() + by_scope: dict[tuple[int, int], list[sqlite3.Row]] = {} + for row in rows: + by_scope.setdefault((int(row["start_year"]), int(row["end_year"])), []).append(row) + obsolete = [row for scope_rows in by_scope.values() for row in scope_rows[keep:]] + candidate_group_rows = 0 + candidate_query_rows = 0 + candidate_page_rows = 0 + deleted_rows = 0 + for row in obsolete: + params = ( + int(row["start_year"]), + int(row["end_year"]), + str(row["signature"] or ""), + ) + candidate_group_rows += int( + conn.execute( + """ + SELECT COUNT(*) + FROM wehago_compare_query_groups + WHERE start_year = ? AND end_year = ? AND signature = ? + """, + params, + ).fetchone()[0] + ) + if _table_exists(conn, "wehago_compare_query_rows"): + candidate_query_rows += int( + conn.execute( + """ + SELECT COUNT(*) + FROM wehago_compare_query_rows + WHERE start_year = ? AND end_year = ? AND signature = ? + """, + params, + ).fetchone()[0] + ) + if _table_exists(conn, "wehago_compare_query_page_cache"): + candidate_page_rows += int( + conn.execute( + """ + SELECT COUNT(*) + FROM wehago_compare_query_page_cache + WHERE start_year = ? AND end_year = ? AND signature = ? + """, + params, + ).fetchone()[0] + ) + if not dry_run: + for table in ( + "wehago_compare_query_page_cache", + "wehago_compare_query_rows", + "wehago_compare_query_groups", + ): + if not _table_exists(conn, table): + continue + before = conn.total_changes + conn.execute( + f""" + DELETE FROM {table} + WHERE start_year = ? AND end_year = ? AND signature = ? + """, + params, + ) + deleted_rows += conn.total_changes - before + conn.commit() + return { + "dry_run": dry_run, + "keep_per_range": keep, + "obsolete_signature_count": len(obsolete), + "candidate_group_rows": candidate_group_rows, + "candidate_query_rows": candidate_query_rows, + "candidate_page_rows": candidate_page_rows, + "deleted_rows": deleted_rows, + } + finally: + conn.close() + + +def status(include_counts: bool) -> dict[str, Any]: + path = DB_PATH + payload: dict[str, Any] = { + "database_path": str(path), + "database_exists": path.exists(), + "files": { + "database_bytes": _size(path), + "wal_bytes": _size(path.with_name(path.name + "-wal")), + "shm_bytes": _size(path.with_name(path.name + "-shm")), + }, + "runtime": sqlite_runtime_status(), + "thresholds": { + "wal_warn_bytes": WAL_WARN_BYTES, + "wal_block_heavy_bytes": WAL_BLOCK_HEAVY_BYTES, + }, + } + if not path.exists(): + return payload + conn = sqlite3.connect(f"file:{path}?mode=ro", uri=True, timeout=5) + try: + payload["database"] = { + "journal_mode": str(conn.execute("PRAGMA journal_mode").fetchone()[0]), + "page_size": int(conn.execute("PRAGMA page_size").fetchone()[0]), + "page_count": int(conn.execute("PRAGMA page_count").fetchone()[0]), + "freelist_count": int(conn.execute("PRAGMA freelist_count").fetchone()[0]), + "wal_autocheckpoint": int(conn.execute("PRAGMA wal_autocheckpoint").fetchone()[0]), + } + if include_counts: + payload["source_table_counts"] = _table_counts(conn, SOURCE_TABLES) + payload["cache_table_counts"] = _table_counts(conn, CACHE_TABLES) + payload["cache_table_storage"] = _table_storage(conn, CACHE_TABLES) + finally: + conn.close() + wal_bytes = int(payload["files"]["wal_bytes"]) + warnings: list[str] = [] + if not payload["runtime"]["meets_minimum_safe_version"]: + warnings.append( + "SQLite 런타임이 3.51.3 미만입니다. WAL DB를 운영하기 전에 안전 런타임으로 교체하세요." + ) + if wal_bytes >= WAL_BLOCK_HEAVY_BYTES: + warnings.append("WAL이 중단 기준을 넘었습니다. 신규 대량 캐시 재생성을 보류하고 유지보수 창을 확보하세요.") + elif wal_bytes >= WAL_WARN_BYTES: + warnings.append("WAL이 경고 기준을 넘었습니다. 긴 조회/쓰기 작업과 checkpoint 상태를 점검하세요.") + payload["warnings"] = warnings + return payload + + +def backup(output: Path | None, verify: str) -> dict[str, Any]: + if not DB_PATH.exists(): + raise FileNotFoundError(DB_PATH) + ensure_runtime_directories() + output = output or BACKUP_DIR / f"data-migration-{datetime.now():%Y%m%d-%H%M%S}.sqlite3" + output.parent.mkdir(parents=True, exist_ok=True) + temp_path = output.with_name("." + output.name + ".tmp") + source_conn = sqlite3.connect(f"file:{DB_PATH}?mode=ro", uri=True, timeout=30) + target_conn = sqlite3.connect(temp_path) + last_reported_percent = -10 + + def report_progress(_status: int, remaining: int, total: int) -> None: + nonlocal last_reported_percent + percent = int((total - remaining) * 100 / total) if total else 100 + reported_percent = min(100, (percent // 10) * 10) + if reported_percent > last_reported_percent: + print(f"backup copy progress: {reported_percent}%", file=sys.stderr, flush=True) + last_reported_percent = reported_percent + + try: + source_conn.backup(target_conn, pages=8192, progress=report_progress) + check = "skipped" + if verify == "smoke": + target_conn.execute("PRAGMA schema_version").fetchone() + target_conn.execute("SELECT COUNT(*) FROM sqlite_master").fetchone() + check = "open-and-schema-readable" + elif verify != "none": + pragma = "integrity_check" if verify == "full" else "quick_check" + print(f"backup verification started: {pragma}", file=sys.stderr, flush=True) + check = str(target_conn.execute(f"PRAGMA {pragma}").fetchone()[0]) + if check.lower() != "ok": + raise RuntimeError(f"백업 {pragma} 실패: {check}") + finally: + target_conn.close() + source_conn.close() + temp_path.replace(output) + return {"backup_path": str(output), "backup_bytes": _size(output), "verification": verify, "check_result": check} + + +def checkpoint(mode: str, acknowledged: bool) -> dict[str, Any]: + if not acknowledged: + raise RuntimeError( + "checkpoint는 운영 서버와 대량 작업을 중단한 유지보수 창에서만 실행하세요. " + "`--ack-maintenance-window`를 함께 지정해야 합니다." + ) + conn = sqlite3.connect(DB_PATH, timeout=30) + try: + before = _size(DB_PATH.with_name(DB_PATH.name + "-wal")) + result = conn.execute(f"PRAGMA wal_checkpoint({mode.upper()})").fetchone() + after = _size(DB_PATH.with_name(DB_PATH.name + "-wal")) + finally: + conn.close() + return {"mode": mode, "checkpoint_result": list(result or ()), "wal_bytes_before": before, "wal_bytes_after": after} + + +def prepare_runtime_layout(root: Path) -> dict[str, str]: + paths = { + "db": root / "db", + "cache": root / "cache", + "backups": root / "backups", + "exports": root / "exports", + } + for path in paths.values(): + path.mkdir(parents=True, exist_ok=True) + env_file = root / "runtime.env.example" + env_file.write_text( + "\n".join( + [ + f"INTRANET_DB_PATH={paths['db'] / 'data.db'}", + f"INTRANET_BACKUP_DIR={paths['backups']}", + f"INTRANET_CACHE_ROOT={paths['cache']}", + f"INTRANET_COMPARE_EXPORT_DIR={paths['exports'] / 'wehago_compare'}", + "INTRANET_REQUIRE_SAFE_SQLITE=1", + f"WEHAGO_SOURCE_ROOT={Path.home() / 'WEHAGO_DB'}", + "", + ] + ), + encoding="utf-8", + ) + return {key: str(path) for key, path in paths.items()} | {"env_example": str(env_file)} + + +def main() -> None: + parser = argparse.ArgumentParser(description="SQLite/WAL runtime inspection and maintenance helpers.") + subparsers = parser.add_subparsers(dest="command", required=True) + + status_parser = subparsers.add_parser("status") + status_parser.add_argument("--include-counts", action="store_true") + + backup_parser = subparsers.add_parser("backup") + backup_parser.add_argument("--output", type=Path) + backup_parser.add_argument( + "--verify", + choices=("smoke", "quick", "full", "none"), + default="smoke", + help="smoke is suitable for container trials; use quick/full during a maintenance window.", + ) + + checkpoint_parser = subparsers.add_parser("checkpoint") + checkpoint_parser.add_argument("--mode", choices=("passive", "full", "restart", "truncate"), default="passive") + checkpoint_parser.add_argument("--ack-maintenance-window", action="store_true") + + layout_parser = subparsers.add_parser("prepare-layout") + layout_parser.add_argument("--root", type=Path, default=Path.home() / "intranet-runtime") + + report_parser = subparsers.add_parser("cache-retention-report") + report_parser.add_argument("--limit", type=int, default=20) + + prune_parser = subparsers.add_parser("prune-orphan-export-cache") + prune_parser.add_argument("--execute", action="store_true") + prune_parser.add_argument("--ack-delete-rebuildable-cache", action="store_true") + + query_report_parser = subparsers.add_parser("query-retention-report") + query_report_parser.add_argument("--keep", type=int, default=2) + + query_prune_parser = subparsers.add_parser("prune-old-query-projections") + query_prune_parser.add_argument("--keep", type=int, default=2) + query_prune_parser.add_argument("--execute", action="store_true") + query_prune_parser.add_argument("--ack-delete-rebuildable-cache", action="store_true") + + args = parser.parse_args() + if args.command == "status": + result = status(args.include_counts) + elif args.command == "backup": + result = backup(args.output, args.verify) + elif args.command == "checkpoint": + result = checkpoint(args.mode, args.ack_maintenance_window) + elif args.command == "cache-retention-report": + result = cache_retention_report(args.limit) + elif args.command == "prune-orphan-export-cache": + result = prune_orphan_export_cache( + dry_run=not args.execute, + acknowledged=args.ack_delete_rebuildable_cache, + ) + elif args.command == "query-retention-report": + result = query_projection_retention_report(args.keep) + elif args.command == "prune-old-query-projections": + result = prune_old_query_projections( + dry_run=not args.execute, + acknowledged=args.ack_delete_rebuildable_cache, + keep=args.keep, + ) + else: + result = prepare_runtime_layout(args.root) + print(json.dumps(result, ensure_ascii=False, indent=2)) + + +if __name__ == "__main__": + main() diff --git a/static/hanmac-joint-members-cache.json b/static/hanmac-joint-members-cache.json new file mode 100644 index 0000000..4d4e8e0 --- /dev/null +++ b/static/hanmac-joint-members-cache.json @@ -0,0 +1 @@ +{"status":"ok","generated_at":"2026-06-02T09:21:51","by_key":{"{\"start_date\":\"2024-01-01\",\"end_date\":\"2024-12-31\",\"employment\":\"all\",\"view\":\"member\",\"include_center_member_nos\":[]}":{"cache_key":"01f95128f2da5e035783631698c36969242a8501","start_date":"2024-01-01","end_date":"2024-12-31","employment":"all","view":"member","updated_at":"2026-06-01 23:58:20","joint_members":[{"member_no":"M10103","member_name":"윤성호","member_grade":"부사장","entry_date":"2019-01-01","leave_date":"","contents":["2024-03-14 / 동광주~광산 대안제시(경쟁) / H24-제안-01 / 합사","2024-03-12 / 동광주~광산 대안제시(경쟁) / H24-제안-01 / 합사","2024-03-13 / 동광주~광산 대안제시(경쟁) / H24-제안-01 / 합사","2024-03-18~2024-03-19 / 동광주~광산 대안제시(경쟁) / H24-제안-01 / 합사, 도로공사"],"info_count":4,"content":"2024-03-14 / 동광주~광산 대안제시(경쟁) / H24-제안-01 / 합사\n2024-03-12 / 동광주~광산 대안제시(경쟁) / H24-제안-01 / 합사\n2024-03-13 / 동광주~광산 대안제시(경쟁) / H24-제안-01 / 합사\n2024-03-18~2024-03-19 / 동광주~광산 대안제시(경쟁) / H24-제안-01 / 합사, 도로공사"},{"member_no":"M02213","member_name":"이영경","member_grade":"전무","entry_date":"1996-11-18","leave_date":"","contents":["2024-10-04 / 계양~강화 고속도로 건설공사 제7공구 T/K 기본설계 / H24-제안-15 / [승광빌딩 9층(삼성동)]합사 파견"],"info_count":1,"content":"2024-10-04 / 계양~강화 고속도로 건설공사 제7공구 T/K 기본설계 / H24-제안-15 / [승광빌딩 9층(삼성동)]합사 파견"},{"member_no":"M03201","member_name":"이동훈","member_grade":"전무","entry_date":"2003-02-25","leave_date":"","contents":["2024-01-08~2024-04-07 / 동광주~광산 대안제시(경쟁) / H24-제안-01 / 고속국도 제25호 호남선(동광주~광산) 경쟁합사"],"info_count":1,"content":"2024-01-08~2024-04-07 / 동광주~광산 대안제시(경쟁) / H24-제안-01 / 고속국도 제25호 호남선(동광주~광산) 경쟁합사"},{"member_no":"M03228","member_name":"김재용","member_grade":"상무","entry_date":"2003-01-02","leave_date":"","contents":["2024-01-08~2024-04-07 / 동광주~광산 대안제시(경쟁) / H24-제안-01 / 고속국도 제25호 호남선(동광주~광산) 경쟁합사"],"info_count":1,"content":"2024-01-08~2024-04-07 / 동광주~광산 대안제시(경쟁) / H24-제안-01 / 고속국도 제25호 호남선(동광주~광산) 경쟁합사"},{"member_no":"M07223","member_name":"이종익","member_grade":"상무","entry_date":"2007-08-06","leave_date":"","contents":["2024-01-08~2024-04-07 / 동광주~광산 대안제시(경쟁) / H24-제안-01 / 고속국도 제25호 호남선(동광주~광산) 경쟁합사"],"info_count":1,"content":"2024-01-08~2024-04-07 / 동광주~광산 대안제시(경쟁) / H24-제안-01 / 고속국도 제25호 호남선(동광주~광산) 경쟁합사"},{"member_no":"M07231","member_name":"송기영","member_grade":"상무","entry_date":"2007-11-05","leave_date":"","contents":["2024-01-08~2024-04-07 / 동광주~광산 대안제시(경쟁) / H24-제안-01 / 고속국도 제25호 호남선(동광주~광산) 경쟁합사"],"info_count":1,"content":"2024-01-08~2024-04-07 / 동광주~광산 대안제시(경쟁) / H24-제안-01 / 고속국도 제25호 호남선(동광주~광산) 경쟁합사"},{"member_no":"M07317","member_name":"전찬성","member_grade":"이사","entry_date":"2007-12-14","leave_date":"","contents":["2024-01-08~2024-04-07 / 동광주~광산 대안제시(경쟁) / H24-제안-01 / 고속국도 제25호 호남선(동광주~광산) 경쟁합사"],"info_count":1,"content":"2024-01-08~2024-04-07 / 동광주~광산 대안제시(경쟁) / H24-제안-01 / 고속국도 제25호 호남선(동광주~광산) 경쟁합사"},{"member_no":"M12203","member_name":"김상수","member_grade":"전무","entry_date":"2012-06-19","leave_date":"","contents":["2024-11-12 / 영종~청라 연결도로(제3연륙교) 건설공사 제2공구 유지관리방안 검토 / H22-교통-07 / 인천경자청, 춘천 합사"],"info_count":1,"content":"2024-11-12 / 영종~청라 연결도로(제3연륙교) 건설공사 제2공구 유지관리방안 검토 / H22-교통-07 / 인천경자청, 춘천 합사"},{"member_no":"M14101","member_name":"정혜연","member_grade":"전무","entry_date":"2014-06-01","leave_date":"","contents":["2024-12-19 / 춘천 기업혁신파크 조사설계 용역(가칭) / H24-제안-19 / 업무협의(남양주합사)"],"info_count":1,"content":"2024-12-19 / 춘천 기업혁신파크 조사설계 용역(가칭) / H24-제안-19 / 업무협의(남양주합사)"},{"member_no":"M15206","member_name":"이정원","member_grade":"이사","entry_date":"2015-10-01","leave_date":"","contents":["2024-02-07~2024-02-25 / 동광주~광산 대안제시(경쟁) / H24-제안-01 / 고속국도 제25호 호남선(동광주~광산) 경쟁합사"],"info_count":1,"content":"2024-02-07~2024-02-25 / 동광주~광산 대안제시(경쟁) / H24-제안-01 / 고속국도 제25호 호남선(동광주~광산) 경쟁합사"},{"member_no":"M15207","member_name":"이기주A","member_grade":"상무","entry_date":"2015-12-11","leave_date":"","contents":["2024-01-08~2024-03-04 / Web Solution / H05-IT-04 / 고속국도 제25호 호남선(동광주~광산) 경쟁합사"],"info_count":1,"content":"2024-01-08~2024-03-04 / Web Solution / H05-IT-04 / 고속국도 제25호 호남선(동광주~광산) 경쟁합사"},{"member_no":"M16216","member_name":"이병욱","member_grade":"이사","entry_date":"2016-09-01","leave_date":"","contents":["2024-08-13 / 춘천 기업혁신파크 조사설계 용역(가칭) / H24-제안-19 / [합사(경기도 남양주시)]업무협의"],"info_count":1,"content":"2024-08-13 / 춘천 기업혁신파크 조사설계 용역(가칭) / H24-제안-19 / [합사(경기도 남양주시)]업무협의"},{"member_no":"M16312","member_name":"정병진","member_grade":"과장","entry_date":"2016-03-21","leave_date":"2024-09-30","contents":["2024-02-07~2024-02-25 / 동광주~광산 대안제시(경쟁) / H24-제안-01 / 고속국도 제25호 호남선(동광주~광산) 경쟁합사"],"info_count":1,"content":"2024-02-07~2024-02-25 / 동광주~광산 대안제시(경쟁) / H24-제안-01 / 고속국도 제25호 호남선(동광주~광산) 경쟁합사"},{"member_no":"M18204","member_name":"이세화","member_grade":"부장","entry_date":"2018-02-20","leave_date":"","contents":["2024-01-08~2024-04-07 / 동광주~광산 대안제시(경쟁) / H24-제안-01 / 고속국도 제25호 호남선(동광주~광산) 경쟁합사"],"info_count":1,"content":"2024-01-08~2024-04-07 / 동광주~광산 대안제시(경쟁) / H24-제안-01 / 고속국도 제25호 호남선(동광주~광산) 경쟁합사"},{"member_no":"M20105","member_name":"이한민","member_grade":"상무","entry_date":"2020-06-01","leave_date":"","contents":["2024-01-01~2024-04-30 / Web Solution / H05-IT-04 / 충청내륙고속화도로 경쟁합사"],"info_count":1,"content":"2024-01-01~2024-04-30 / Web Solution / H05-IT-04 / 충청내륙고속화도로 경쟁합사"},{"member_no":"M20211","member_name":"유재극","member_grade":"이사","entry_date":"2020-06-15","leave_date":"","contents":["2024-01-01~2024-04-30 / Web Solution / H05-IT-04 / 충청내륙고속화도로 경쟁합사"],"info_count":1,"content":"2024-01-01~2024-04-30 / Web Solution / H05-IT-04 / 충청내륙고속화도로 경쟁합사"},{"member_no":"M20213","member_name":"임원석","member_grade":"차장","entry_date":"2020-07-01","leave_date":"2024-05-31","contents":["2024-01-08~2024-04-07 / 동광주~광산 대안제시(경쟁) / H24-제안-01 / 고속국도 제25호 호남선(동광주~광산) 경쟁합사"],"info_count":1,"content":"2024-01-08~2024-04-07 / 동광주~광산 대안제시(경쟁) / H24-제안-01 / 고속국도 제25호 호남선(동광주~광산) 경쟁합사"},{"member_no":"M20321","member_name":"박선우","member_grade":"대리","entry_date":"2020-09-01","leave_date":"","contents":["2024-07-02 / 도림천 일대 대심도 빗물배수터널 건설공사 기본설계용역(주설계) / H24-제안-23 / [과업구간]합사 현장확인"],"info_count":1,"content":"2024-07-02 / 도림천 일대 대심도 빗물배수터널 건설공사 기본설계용역(주설계) / H24-제안-23 / [과업구간]합사 현장확인"},{"member_no":"M20327","member_name":"김태우","member_grade":"과장","entry_date":"2020-12-01","leave_date":"","contents":["2024-01-01~2024-04-30 / Web Solution / H05-IT-04 / 충청내륙고속화도로 경쟁합사"],"info_count":1,"content":"2024-01-01~2024-04-30 / Web Solution / H05-IT-04 / 충청내륙고속화도로 경쟁합사"},{"member_no":"M21315","member_name":"장경호","member_grade":"과장","entry_date":"2021-02-15","leave_date":"2025-12-08","contents":["2024-12-31 / Web Solution / H05-IT-04 / 연차(합사 운영 일정에 따른 연차)"],"info_count":1,"content":"2024-12-31 / Web Solution / H05-IT-04 / 연차(합사 운영 일정에 따른 연차)"},{"member_no":"M21432","member_name":"최승범","member_grade":"대리","entry_date":"2021-07-01","leave_date":"","contents":["2024-10-04 / 계양~강화 고속도로 건설공사 제7공구 T/K 기본설계 / H24-제안-15 / [승광빌딩 9층(삼성동)]합사 파견"],"info_count":1,"content":"2024-10-04 / 계양~강화 고속도로 건설공사 제7공구 T/K 기본설계 / H24-제안-15 / [승광빌딩 9층(삼성동)]합사 파견"},{"member_no":"M21451","member_name":"김태식","member_grade":"차장","entry_date":"2021-09-27","leave_date":"","contents":["2024-07-02 / 도림천 일대 대심도 빗물배수터널 건설공사 기본설계용역(주설계) / H24-제안-23 / [과업구간]합사 현장확인"],"info_count":1,"content":"2024-07-02 / 도림천 일대 대심도 빗물배수터널 건설공사 기본설계용역(주설계) / H24-제안-23 / [과업구간]합사 현장확인"},{"member_no":"M22003","member_name":"안민형","member_grade":"부장","entry_date":"2022-02-08","leave_date":"","contents":["2024-01-01~2024-04-30 / Web Solution / H05-IT-04 / 충청내륙고속화도로 경쟁합사"],"info_count":1,"content":"2024-01-01~2024-04-30 / Web Solution / H05-IT-04 / 충청내륙고속화도로 경쟁합사"},{"member_no":"M22022","member_name":"송민제","member_grade":"대리","entry_date":"2022-05-02","leave_date":"2024-10-29","contents":["2024-01-01~2024-04-30 / Web Solution / H05-IT-04 / 충청내륙고속화도로 경쟁합사"],"info_count":1,"content":"2024-01-01~2024-04-30 / Web Solution / H05-IT-04 / 충청내륙고속화도로 경쟁합사"},{"member_no":"M22031","member_name":"이환섭","member_grade":"부사장","entry_date":"2022-07-06","leave_date":"","contents":["2024-01-01~2024-04-30 / Web Solution / H05-IT-04 / 충청내륙고속화도로 경쟁합사"],"info_count":1,"content":"2024-01-01~2024-04-30 / Web Solution / H05-IT-04 / 충청내륙고속화도로 경쟁합사"},{"member_no":"M22035","member_name":"김창섭","member_grade":"이사","entry_date":"2022-06-01","leave_date":"","contents":["2024-01-01~2024-04-30 / Web Solution / H05-IT-04 / 충청내륙고속화도로 경쟁합사"],"info_count":1,"content":"2024-01-01~2024-04-30 / Web Solution / H05-IT-04 / 충청내륙고속화도로 경쟁합사"},{"member_no":"M22053","member_name":"남창성","member_grade":"상무","entry_date":"2022-08-01","leave_date":"2025-11-30","contents":["2024-12-31 / Web Solution / H05-IT-04 / 연차(합사 휴무일정에 따른 연차)"],"info_count":1,"content":"2024-12-31 / Web Solution / H05-IT-04 / 연차(합사 휴무일정에 따른 연차)"},{"member_no":"M23005","member_name":"이교호","member_grade":"대리","entry_date":"2023-01-09","leave_date":"","contents":["2024-01-01~2024-04-30 / Web Solution / H05-IT-04 / 충청내륙고속화도로 경쟁합사"],"info_count":1,"content":"2024-01-01~2024-04-30 / Web Solution / H05-IT-04 / 충청내륙고속화도로 경쟁합사"},{"member_no":"M23015","member_name":"안홍균","member_grade":"부장","entry_date":"2023-03-01","leave_date":"2025-01-31","contents":["2024-01-08~2024-03-31 / Web Solution / H05-IT-04 / 고속국도 제25호 호남선(동광주~광산) 경쟁합사"],"info_count":1,"content":"2024-01-08~2024-03-31 / Web Solution / H05-IT-04 / 고속국도 제25호 호남선(동광주~광산) 경쟁합사"},{"member_no":"M23042","member_name":"황지만","member_grade":"과장","entry_date":"2023-07-03","leave_date":"","contents":["2024-01-08~2024-04-07 / 동광주~광산 대안제시(경쟁) / H24-제안-01 / 고속국도 제25호 호남선(동광주~광산) 경쟁합사"],"info_count":1,"content":"2024-01-08~2024-04-07 / 동광주~광산 대안제시(경쟁) / H24-제안-01 / 고속국도 제25호 호남선(동광주~광산) 경쟁합사"},{"member_no":"M23064","member_name":"진장현","member_grade":"과장","entry_date":"2023-09-04","leave_date":"","contents":["2024-01-08~2024-04-07 / 동광주~광산 대안제시(경쟁) / H24-제안-01 / 고속국도 제25호 호남선(동광주~광산) 경쟁합사"],"info_count":1,"content":"2024-01-08~2024-04-07 / 동광주~광산 대안제시(경쟁) / H24-제안-01 / 고속국도 제25호 호남선(동광주~광산) 경쟁합사"},{"member_no":"M23075","member_name":"공성빈","member_grade":"사원","entry_date":"2023-11-13","leave_date":"2025-01-31","contents":["2024-07-02 / 도림천 일대 대심도 빗물배수터널 건설공사 기본설계용역(주설계) / H24-제안-23 / [과업구간]합사 현장확인"],"info_count":1,"content":"2024-07-02 / 도림천 일대 대심도 빗물배수터널 건설공사 기본설계용역(주설계) / H24-제안-23 / [과업구간]합사 현장확인"},{"member_no":"M23076","member_name":"박상빈","member_grade":"과장","entry_date":"2023-11-27","leave_date":"","contents":["2024-01-01~2024-04-30 / Web Solution / H05-IT-04 / 충청내륙고속화도로 경쟁합사"],"info_count":1,"content":"2024-01-01~2024-04-30 / Web Solution / H05-IT-04 / 충청내륙고속화도로 경쟁합사"},{"member_no":"M24036","member_name":"심성보","member_grade":"이사","entry_date":"2024-05-02","leave_date":"","contents":["2024-07-02 / 도림천 일대 대심도 빗물배수터널 건설공사 기본설계용역(주설계) / H24-제안-23 / [과업구간]합사 현장확인"],"info_count":1,"content":"2024-07-02 / 도림천 일대 대심도 빗물배수터널 건설공사 기본설계용역(주설계) / H24-제안-23 / [과업구간]합사 현장확인"},{"member_no":"M24075","member_name":"송민제","member_grade":"과장","entry_date":"2022-05-02","leave_date":"","contents":["2024-01-01~2024-04-30 / Web Solution / H05-IT-04 / 충청내륙고속화도로 경쟁합사"],"info_count":1,"content":"2024-01-01~2024-04-30 / Web Solution / H05-IT-04 / 충청내륙고속화도로 경쟁합사"}]},"{\"start_date\":\"2022-01-01\",\"end_date\":\"2025-12-31\",\"employment\":\"all\",\"view\":\"member\",\"include_center_member_nos\":[]}":{"cache_key":"4a23d2dd112dd562aeb6fdad650ba35cddf49d57","start_date":"2022-01-01","end_date":"2025-12-31","employment":"all","view":"member","updated_at":"2026-06-01 23:57:39","joint_members":[{"member_no":"M21479","member_name":"조영주","member_grade":"차장","entry_date":"2022-03-01","leave_date":"2023-08-27","contents":["2022-07-13 / 울산외곽순환 고속도로 건설공사 기본 및 실시설계(1공구) / H21-고속-01 / 경유(울산외곽순환 합사 근무)","2022-09-02 / 울산외곽순환 고속도로 건설공사 기본 및 실시설계(1공구) / H21-고속-01 / 경유(울산합사(갱구형식 및 위치 비교안보고))","2022-09-23 / 울산외곽순환 고속도로 건설공사 기본 및 실시설계(1공구) / H21-고속-01 / [문정동 KG타워(울산외곽합사)]감독합사상주(매주 금요일)에 따른 합사근무 및 업무협의","2022-09-30 / 울산외곽순환 고속도로 건설공사 기본 및 실시설계(1공구) / H21-고속-01 / [KG타워(울산외곽합사)]감독합사상주(매주 금요일)에 따른 합사근무 및 업무협의","2022-10-07 / 울산외곽순환 고속도로 건설공사 기본 및 실시설계(1공구) / H21-고속-01 / [문정동 KG타워(울산외곽합사)]감독합사상주(매주 금요일)에 따른 합사근무 및 업무협의","2022-10-14 / 울산외곽순환 고속도로 건설공사 기본 및 실시설계(1공구) / H21-고속-01 / [문정동KG타워(울산외곽합사)]감독합사상주(매주금요일)에 따른 합사근무 및 업무협의","2022-10-21 / 울산외곽순환 고속도로 건설공사 기본 및 실시설계(1공구) / H21-고속-01 / [문정동KG타워(울산외곽합사)]감독합사상주(매주금요일)에 따른 합사근무 및 업무협의","2022-10-28 / 울산외곽순환 고속도로 건설공사 기본 및 실시설계(1공구) / H21-고속-01 / [문정동KG타워(울산외곽합사)]감독합사상주(매주금요일)에 따른 합사근무 및 업무협의","2022-11-04 / 울산외곽순환 고속도로 건설공사 기본 및 실시설계(1공구) / H21-고속-01 / [문정동KG타워(울산외곽합사)]감독합사상주(매주금요일)에따른합사근무및업무협의","2022-11-11 / 울산외곽순환 고속도로 건설공사 기본 및 실시설계(1공구) / H21-고속-01 / [문정동KG타워(울산외곽합사)]감독합사상주(매주금요일)에따른합사근무및업무협의","2022-11-18 / 울산외곽순환 고속도로 건설공사 기본 및 실시설계(1공구) / H21-고속-01 / [문정동KG타워(울산외곽합사)]감독합사상주(매주금요일)에따른합사근무및업무협의","2022-11-25 / 울산외곽순환 고속도로 건설공사 기본 및 실시설계(1공구) / H21-고속-01 / [문정동KG타워(울산외곽합사)]감독합사상주(매주금요일)에따른합사근무및업무협의","2022-12-02 / 울산외곽순환 고속도로 건설공사 기본 및 실시설계(1공구) / H21-고속-01 / [문정동KG타워(울산외곽합사)]감독합사상주(매주금요일)에따른합사근무및업무협의","2022-12-09 / 울산외곽순환 고속도로 건설공사 기본 및 실시설계(1공구) / H21-고속-01 / [문정동KG타워(울산외곽합사)]감독합사상주(매주금요일)에따른합사근무및업무협의","2022-12-16 / 울산외곽순환 고속도로 건설공사 기본 및 실시설계(1공구) / H21-고속-01 / [문정동KG타워(울산외곽합사)]감독합사상주(매주금요일)에따른합사근무및업무협의","2022-12-23 / 울산외곽순환 고속도로 건설공사 기본 및 실시설계(1공구) / H21-고속-01 / [문정동KG타워(울산외곽합사)]감독합사상주(매주금요일)에따른합사근무및업무협의","2023-01-06 / 울산외곽순환 고속도로 건설공사 기본 및 실시설계(1공구) / H21-고속-01 / [문정동KG타워(울산외곽합사)]감독합사상주(매주금요일)에따른합사근무및업무협의","2023-01-13 / 울산외곽순환 고속도로 건설공사 기본 및 실시설계(1공구) / H21-고속-01 / [문정동KG타워(울산외곽합사)]감독합사상주(매주금요일)에따른합사근무및업무협의","2023-01-27 / 울산외곽순환 고속도로 건설공사 기본 및 실시설계(1공구) / H21-고속-01 / [문정동KG타워(울산외곽합사)]감독합사상주(매주금요일)에따른합사근무및업무협의","2023-02-03 / 울산외곽순환 고속도로 건설공사 기본 및 실시설계(1공구) / H21-고속-01 / [문정동KG타워(울산외곽합사)]감독합사상주(매주금요일)에따른합사근무및업무협의","2023-02-10 / 울산외곽순환 고속도로 건설공사 기본 및 실시설계(1공구) / H21-고속-01 / [문정동KG타워(울산외곽합사)]감독합사상주(매주금요일)에따른합사근무및업무협의","2023-02-17 / 울산외곽순환 고속도로 건설공사 기본 및 실시설계(1공구) / H21-고속-01 / [문정동KG타워(울산외곽합사)]감독합사상주(매주금요일)에따른합사근무및업무협의","2023-02-24 / 울산외곽순환 고속도로 건설공사 기본 및 실시설계(1공구) / H21-고속-01 / [문정동KG타워(울산외곽합사)]감독합사상주(매주금요일)에따른합사근무및업무협의","2023-03-03 / 울산외곽순환 고속도로 건설공사 기본 및 실시설계(1공구) / H21-고속-01 / [문정동KG타워(울산외곽합사)]감독합사상주(매주금요일)에따른합사근무및업무협의","2023-03-10 / 울산외곽순환 고속도로 건설공사 기본 및 실시설계(1공구) / H21-고속-01 / [문정동KG타워(울산외곽합사)]감독합사상주(매주금요일)에따른합사근무및업무협의","2023-03-17 / 울산외곽순환 고속도로 건설공사 기본 및 실시설계(1공구) / H21-고속-01 / [문정동KG타워(울산외곽합사)]감독합사상주(매주금요일)에따른합사근무및업무협의","2023-03-24 / 울산외곽순환 고속도로 건설공사 기본 및 실시설계(1공구) / H21-고속-01 / [문정동KG타워(울산외곽합사)]감독합사상주(매주금요일)에따른합사근무및업무협의","2023-03-31 / 울산외곽순환 고속도로 건설공사 기본 및 실시설계(1공구) / H21-고속-01 / [문정동KG타워(울산외곽합사)]감독합사상주(매주금요일)에따른합사근무및업무협의","2023-04-07 / 울산외곽순환 고속도로 건설공사 기본 및 실시설계(1공구) / H21-고속-01 / [문정동KG타워(울산외곽합사)]감독합사상주(매주금요일)에따른합사근무및업무협의","2023-04-14 / 울산외곽순환 고속도로 건설공사 기본 및 실시설계(1공구) / H21-고속-01 / [문정동KG타워(울산외곽합사)]감독합사상주(매주금요일)에따른합사근무및업무협의","2023-04-21 / 울산외곽순환 고속도로 건설공사 기본 및 실시설계(1공구) / H21-고속-01 / [문정동KG타워(울산외곽합사)]감독합사상주(매주금요일)에따른합사근무및업무협의","2023-04-28 / 울산외곽순환 고속도로 건설공사 기본 및 실시설계(1공구) / H21-고속-01 / [문정동KG타워(울산외곽합사)]감독합사상주(매주금요일)에따른합사근무및업무협의","2023-05-12 / 울산외곽순환 고속도로 건설공사 기본 및 실시설계(1공구) / H21-고속-01 / [문정동KG타워(울산외곽합사)]감독합사상주(매주금요일)에따른합사근무및업무협의"],"info_count":33,"content":"2022-07-13 / 울산외곽순환 고속도로 건설공사 기본 및 실시설계(1공구) / H21-고속-01 / 경유(울산외곽순환 합사 근무)\n2022-09-02 / 울산외곽순환 고속도로 건설공사 기본 및 실시설계(1공구) / H21-고속-01 / 경유(울산합사(갱구형식 및 위치 비교안보고))\n2022-09-23 / 울산외곽순환 고속도로 건설공사 기본 및 실시설계(1공구) / H21-고속-01 / [문정동 KG타워(울산외곽합사)]감독합사상주(매주 금요일)에 따른 합사근무 및 업무협의\n2022-09-30 / 울산외곽순환 고속도로 건설공사 기본 및 실시설계(1공구) / H21-고속-01 / [KG타워(울산외곽합사)]감독합사상주(매주 금요일)에 따른 합사근무 및 업무협의\n2022-10-07 / 울산외곽순환 고속도로 건설공사 기본 및 실시설계(1공구) / H21-고속-01 / [문정동 KG타워(울산외곽합사)]감독합사상주(매주 금요일)에 따른 합사근무 및 업무협의\n2022-10-14 / 울산외곽순환 고속도로 건설공사 기본 및 실시설계(1공구) / H21-고속-01 / [문정동KG타워(울산외곽합사)]감독합사상주(매주금요일)에 따른 합사근무 및 업무협의\n2022-10-21 / 울산외곽순환 고속도로 건설공사 기본 및 실시설계(1공구) / H21-고속-01 / [문정동KG타워(울산외곽합사)]감독합사상주(매주금요일)에 따른 합사근무 및 업무협의\n2022-10-28 / 울산외곽순환 고속도로 건설공사 기본 및 실시설계(1공구) / H21-고속-01 / [문정동KG타워(울산외곽합사)]감독합사상주(매주금요일)에 따른 합사근무 및 업무협의\n2022-11-04 / 울산외곽순환 고속도로 건설공사 기본 및 실시설계(1공구) / H21-고속-01 / [문정동KG타워(울산외곽합사)]감독합사상주(매주금요일)에따른합사근무및업무협의\n2022-11-11 / 울산외곽순환 고속도로 건설공사 기본 및 실시설계(1공구) / H21-고속-01 / [문정동KG타워(울산외곽합사)]감독합사상주(매주금요일)에따른합사근무및업무협의\n2022-11-18 / 울산외곽순환 고속도로 건설공사 기본 및 실시설계(1공구) / H21-고속-01 / [문정동KG타워(울산외곽합사)]감독합사상주(매주금요일)에따른합사근무및업무협의\n2022-11-25 / 울산외곽순환 고속도로 건설공사 기본 및 실시설계(1공구) / H21-고속-01 / [문정동KG타워(울산외곽합사)]감독합사상주(매주금요일)에따른합사근무및업무협의\n2022-12-02 / 울산외곽순환 고속도로 건설공사 기본 및 실시설계(1공구) / H21-고속-01 / [문정동KG타워(울산외곽합사)]감독합사상주(매주금요일)에따른합사근무및업무협의\n2022-12-09 / 울산외곽순환 고속도로 건설공사 기본 및 실시설계(1공구) / H21-고속-01 / [문정동KG타워(울산외곽합사)]감독합사상주(매주금요일)에따른합사근무및업무협의\n2022-12-16 / 울산외곽순환 고속도로 건설공사 기본 및 실시설계(1공구) / H21-고속-01 / [문정동KG타워(울산외곽합사)]감독합사상주(매주금요일)에따른합사근무및업무협의\n2022-12-23 / 울산외곽순환 고속도로 건설공사 기본 및 실시설계(1공구) / H21-고속-01 / [문정동KG타워(울산외곽합사)]감독합사상주(매주금요일)에따른합사근무및업무협의\n2023-01-06 / 울산외곽순환 고속도로 건설공사 기본 및 실시설계(1공구) / H21-고속-01 / [문정동KG타워(울산외곽합사)]감독합사상주(매주금요일)에따른합사근무및업무협의\n2023-01-13 / 울산외곽순환 고속도로 건설공사 기본 및 실시설계(1공구) / H21-고속-01 / [문정동KG타워(울산외곽합사)]감독합사상주(매주금요일)에따른합사근무및업무협의\n2023-01-27 / 울산외곽순환 고속도로 건설공사 기본 및 실시설계(1공구) / H21-고속-01 / [문정동KG타워(울산외곽합사)]감독합사상주(매주금요일)에따른합사근무및업무협의\n2023-02-03 / 울산외곽순환 고속도로 건설공사 기본 및 실시설계(1공구) / H21-고속-01 / [문정동KG타워(울산외곽합사)]감독합사상주(매주금요일)에따른합사근무및업무협의\n2023-02-10 / 울산외곽순환 고속도로 건설공사 기본 및 실시설계(1공구) / H21-고속-01 / [문정동KG타워(울산외곽합사)]감독합사상주(매주금요일)에따른합사근무및업무협의\n2023-02-17 / 울산외곽순환 고속도로 건설공사 기본 및 실시설계(1공구) / H21-고속-01 / [문정동KG타워(울산외곽합사)]감독합사상주(매주금요일)에따른합사근무및업무협의\n2023-02-24 / 울산외곽순환 고속도로 건설공사 기본 및 실시설계(1공구) / H21-고속-01 / [문정동KG타워(울산외곽합사)]감독합사상주(매주금요일)에따른합사근무및업무협의\n2023-03-03 / 울산외곽순환 고속도로 건설공사 기본 및 실시설계(1공구) / H21-고속-01 / [문정동KG타워(울산외곽합사)]감독합사상주(매주금요일)에따른합사근무및업무협의\n2023-03-10 / 울산외곽순환 고속도로 건설공사 기본 및 실시설계(1공구) / H21-고속-01 / [문정동KG타워(울산외곽합사)]감독합사상주(매주금요일)에따른합사근무및업무협의\n2023-03-17 / 울산외곽순환 고속도로 건설공사 기본 및 실시설계(1공구) / H21-고속-01 / [문정동KG타워(울산외곽합사)]감독합사상주(매주금요일)에따른합사근무및업무협의\n2023-03-24 / 울산외곽순환 고속도로 건설공사 기본 및 실시설계(1공구) / H21-고속-01 / [문정동KG타워(울산외곽합사)]감독합사상주(매주금요일)에따른합사근무및업무협의\n2023-03-31 / 울산외곽순환 고속도로 건설공사 기본 및 실시설계(1공구) / H21-고속-01 / [문정동KG타워(울산외곽합사)]감독합사상주(매주금요일)에따른합사근무및업무협의\n2023-04-07 / 울산외곽순환 고속도로 건설공사 기본 및 실시설계(1공구) / H21-고속-01 / [문정동KG타워(울산외곽합사)]감독합사상주(매주금요일)에따른합사근무및업무협의\n2023-04-14 / 울산외곽순환 고속도로 건설공사 기본 및 실시설계(1공구) / H21-고속-01 / [문정동KG타워(울산외곽합사)]감독합사상주(매주금요일)에따른합사근무및업무협의\n2023-04-21 / 울산외곽순환 고속도로 건설공사 기본 및 실시설계(1공구) / H21-고속-01 / [문정동KG타워(울산외곽합사)]감독합사상주(매주금요일)에따른합사근무및업무협의\n2023-04-28 / 울산외곽순환 고속도로 건설공사 기본 및 실시설계(1공구) / H21-고속-01 / [문정동KG타워(울산외곽합사)]감독합사상주(매주금요일)에따른합사근무및업무협의\n2023-05-12 / 울산외곽순환 고속도로 건설공사 기본 및 실시설계(1공구) / H21-고속-01 / [문정동KG타워(울산외곽합사)]감독합사상주(매주금요일)에따른합사근무및업무협의"},{"member_no":"M20105","member_name":"이한민","member_grade":"상무","entry_date":"2020-06-01","leave_date":"","contents":["2022-01-01~2022-02-28 / Web Solution / H05-IT-04 / 동부간선도로 지하화 기본설계 합사파견","2022-09-02 / 울산외곽순환 고속도로 건설공사 기본 및 실시설계(1공구) / H21-고속-01 / 울산합사(갱구형식 및 위치 비교안보고)","2022-09-16 / 울산외곽순환 고속도로 건설공사 기본 및 실시설계(1공구) / H21-고속-01 / 울산합사(근접터널 및 피난연결통로 방침보고)","2022-09-16 / 금강수계 / H06-진단-13 / 경유(합사근무(방침보고 및 업무협의))","2022-09-23 / 울산외곽순환 고속도로 건설공사 기본 및 실시설계(1공구) / H21-고속-01 / [문정동 KG타워(울산외곽합사)]감독합사상주(매주 금요일)에 따른 합사근무 및 업무협의","2022-09-28 / 울산외곽순환 고속도로 건설공사 기본 및 실시설계(1공구) / H21-고속-01 / 세종청주합사(특정공법회의)","2022-09-30 / 울산외곽순환 고속도로 건설공사 기본 및 실시설계(1공구) / H21-고속-01 / [KG타워(울산외곽합사)]감독합사상주(매주 금요일)에 따른 합사근무 및 업무협의","2022-10-07 / 울산외곽순환 고속도로 건설공사 기본 및 실시설계(1공구) / H21-고속-01 / [문정동 KG타워(울산외곽합사)]감독합사상주(매주 금요일)에 따른 합사근무 및 업무협의","2022-10-14 / 울산외곽순환 고속도로 건설공사 기본 및 실시설계(1공구) / H21-고속-01 / [문정동KG타워(울산외곽합사)]감독합사상주(매주금요일)에 따른 합사근무 및 업무협의","2022-10-21 / 울산외곽순환 고속도로 건설공사 기본 및 실시설계(1공구) / H21-고속-01 / [문정동KG타워(울산외곽합사)]감독합사상주(매주금요일)에 따른 합사근무 및 업무협의","2022-10-28 / 울산외곽순환 고속도로 건설공사 기본 및 실시설계(1공구) / H21-고속-01 / [문정동KG타워(울산외곽합사)]감독합사상주(매주금요일)에 따른 합사근무 및 업무협의","2022-11-25 / 울산외곽순환 고속도로 건설공사 기본 및 실시설계(1공구) / H21-고속-01 / [문정동KG타워(울산외곽합사)]감독합사상주(매주금요일)에따른합사근무및업무협의","2022-12-02 / 울산외곽순환 고속도로 건설공사 기본 및 실시설계(1공구) / H21-고속-01 / [문정동KG타워(울산외곽합사)]감독합사상주(매주금요일)에따른합사근무및업무협의","2022-12-09 / 울산외곽순환 고속도로 건설공사 기본 및 실시설계(1공구) / H21-고속-01 / [문정동KG타워(울산외곽합사)]감독합사상주(매주금요일)에따른합사근무및업무협의","2022-12-16 / 울산외곽순환 고속도로 건설공사 기본 및 실시설계(1공구) / H21-고속-01 / [문정동KG타워(울산외곽합사)]감독합사상주(매주금요일)에따른합사근무및업무협의","2022-12-23 / 울산외곽순환 고속도로 건설공사 기본 및 실시설계(1공구) / H21-고속-01 / [문정동KG타워(울산외곽합사)]감독합사상주(매주금요일)에따른합사근무및업무협의","2023-01-06 / 울산외곽순환 고속도로 건설공사 기본 및 실시설계(1공구) / H21-고속-01 / [문정동KG타워(울산외곽합사)]감독합사상주(매주금요일)에따른합사근무및업무협의","2023-01-13 / 울산외곽순환 고속도로 건설공사 기본 및 실시설계(1공구) / H21-고속-01 / [문정동KG타워(울산외곽합사)]감독합사상주(매주금요일)에따른합사근무및업무협의","2023-01-27 / 울산외곽순환 고속도로 건설공사 기본 및 실시설계(1공구) / H21-고속-01 / [문정동KG타워(울산외곽합사)]감독합사상주(매주금요일)에따른합사근무및업무협의","2023-02-03 / 울산외곽순환 고속도로 건설공사 기본 및 실시설계(1공구) / H21-고속-01 / [문정동KG타워(울산외곽합사)]감독합사상주(매주금요일)에따른합사근무및업무협의","2023-02-10 / 울산외곽순환 고속도로 건설공사 기본 및 실시설계(1공구) / H21-고속-01 / [문정동KG타워(울산외곽합사)]감독합사상주(매주금요일)에따른합사근무및업무협의","2023-02-17 / 울산외곽순환 고속도로 건설공사 기본 및 실시설계(1공구) / H21-고속-01 / [문정동KG타워(울산외곽합사)]감독합사상주(매주금요일)에따른합사근무및업무협의","2023-02-24 / 울산외곽순환 고속도로 건설공사 기본 및 실시설계(1공구) / H21-고속-01 / [문정동KG타워(울산외곽합사)]감독합사상주(매주금요일)에따른합사근무및업무협의","2023-03-03 / 울산외곽순환 고속도로 건설공사 기본 및 실시설계(1공구) / H21-고속-01 / [문정동KG타워(울산외곽합사)]감독합사상주(매주금요일)에따른합사근무및업무협의","2023-03-17 / 울산외곽순환 고속도로 건설공사 기본 및 실시설계(1공구) / H21-고속-01 / [문정동KG타워(울산외곽합사)]감독합사상주(매주금요일)에따른합사근무및업무협의","2023-03-24 / 울산외곽순환 고속도로 건설공사 기본 및 실시설계(1공구) / H21-고속-01 / [문정동KG타워(울산외곽합사)]감독합사상주(매주금요일)에따른합사근무및업무협의","2023-03-31 / 울산외곽순환 고속도로 건설공사 기본 및 실시설계(1공구) / H21-고속-01 / [문정동KG타워(울산외곽합사)]감독합사상주(매주금요일)에따른합사근무및업무협의","2023-04-07 / 울산외곽순환 고속도로 건설공사 기본 및 실시설계(1공구) / H21-고속-01 / [문정동KG타워(울산외곽합사)]감독합사상주(매주금요일)에따른합사근무및업무협의","2023-04-14 / 울산외곽순환 고속도로 건설공사 기본 및 실시설계(1공구) / H21-고속-01 / [문정동KG타워(울산외곽합사)]감독합사상주(매주금요일)에따른합사근무및업무협의","2023-11-10~2024-04-30 / Web Solution / H05-IT-04 / 충청내륙고속화도로 경쟁합사"],"info_count":30,"content":"2022-01-01~2022-02-28 / Web Solution / H05-IT-04 / 동부간선도로 지하화 기본설계 합사파견\n2022-09-02 / 울산외곽순환 고속도로 건설공사 기본 및 실시설계(1공구) / H21-고속-01 / 울산합사(갱구형식 및 위치 비교안보고)\n2022-09-16 / 울산외곽순환 고속도로 건설공사 기본 및 실시설계(1공구) / H21-고속-01 / 울산합사(근접터널 및 피난연결통로 방침보고)\n2022-09-16 / 금강수계 / H06-진단-13 / 경유(합사근무(방침보고 및 업무협의))\n2022-09-23 / 울산외곽순환 고속도로 건설공사 기본 및 실시설계(1공구) / H21-고속-01 / [문정동 KG타워(울산외곽합사)]감독합사상주(매주 금요일)에 따른 합사근무 및 업무협의\n2022-09-28 / 울산외곽순환 고속도로 건설공사 기본 및 실시설계(1공구) / H21-고속-01 / 세종청주합사(특정공법회의)\n2022-09-30 / 울산외곽순환 고속도로 건설공사 기본 및 실시설계(1공구) / H21-고속-01 / [KG타워(울산외곽합사)]감독합사상주(매주 금요일)에 따른 합사근무 및 업무협의\n2022-10-07 / 울산외곽순환 고속도로 건설공사 기본 및 실시설계(1공구) / H21-고속-01 / [문정동 KG타워(울산외곽합사)]감독합사상주(매주 금요일)에 따른 합사근무 및 업무협의\n2022-10-14 / 울산외곽순환 고속도로 건설공사 기본 및 실시설계(1공구) / H21-고속-01 / [문정동KG타워(울산외곽합사)]감독합사상주(매주금요일)에 따른 합사근무 및 업무협의\n2022-10-21 / 울산외곽순환 고속도로 건설공사 기본 및 실시설계(1공구) / H21-고속-01 / [문정동KG타워(울산외곽합사)]감독합사상주(매주금요일)에 따른 합사근무 및 업무협의\n2022-10-28 / 울산외곽순환 고속도로 건설공사 기본 및 실시설계(1공구) / H21-고속-01 / [문정동KG타워(울산외곽합사)]감독합사상주(매주금요일)에 따른 합사근무 및 업무협의\n2022-11-25 / 울산외곽순환 고속도로 건설공사 기본 및 실시설계(1공구) / H21-고속-01 / [문정동KG타워(울산외곽합사)]감독합사상주(매주금요일)에따른합사근무및업무협의\n2022-12-02 / 울산외곽순환 고속도로 건설공사 기본 및 실시설계(1공구) / H21-고속-01 / [문정동KG타워(울산외곽합사)]감독합사상주(매주금요일)에따른합사근무및업무협의\n2022-12-09 / 울산외곽순환 고속도로 건설공사 기본 및 실시설계(1공구) / H21-고속-01 / [문정동KG타워(울산외곽합사)]감독합사상주(매주금요일)에따른합사근무및업무협의\n2022-12-16 / 울산외곽순환 고속도로 건설공사 기본 및 실시설계(1공구) / H21-고속-01 / [문정동KG타워(울산외곽합사)]감독합사상주(매주금요일)에따른합사근무및업무협의\n2022-12-23 / 울산외곽순환 고속도로 건설공사 기본 및 실시설계(1공구) / H21-고속-01 / [문정동KG타워(울산외곽합사)]감독합사상주(매주금요일)에따른합사근무및업무협의\n2023-01-06 / 울산외곽순환 고속도로 건설공사 기본 및 실시설계(1공구) / H21-고속-01 / [문정동KG타워(울산외곽합사)]감독합사상주(매주금요일)에따른합사근무및업무협의\n2023-01-13 / 울산외곽순환 고속도로 건설공사 기본 및 실시설계(1공구) / H21-고속-01 / [문정동KG타워(울산외곽합사)]감독합사상주(매주금요일)에따른합사근무및업무협의\n2023-01-27 / 울산외곽순환 고속도로 건설공사 기본 및 실시설계(1공구) / H21-고속-01 / [문정동KG타워(울산외곽합사)]감독합사상주(매주금요일)에따른합사근무및업무협의\n2023-02-03 / 울산외곽순환 고속도로 건설공사 기본 및 실시설계(1공구) / H21-고속-01 / [문정동KG타워(울산외곽합사)]감독합사상주(매주금요일)에따른합사근무및업무협의\n2023-02-10 / 울산외곽순환 고속도로 건설공사 기본 및 실시설계(1공구) / H21-고속-01 / [문정동KG타워(울산외곽합사)]감독합사상주(매주금요일)에따른합사근무및업무협의\n2023-02-17 / 울산외곽순환 고속도로 건설공사 기본 및 실시설계(1공구) / H21-고속-01 / [문정동KG타워(울산외곽합사)]감독합사상주(매주금요일)에따른합사근무및업무협의\n2023-02-24 / 울산외곽순환 고속도로 건설공사 기본 및 실시설계(1공구) / H21-고속-01 / [문정동KG타워(울산외곽합사)]감독합사상주(매주금요일)에따른합사근무및업무협의\n2023-03-03 / 울산외곽순환 고속도로 건설공사 기본 및 실시설계(1공구) / H21-고속-01 / [문정동KG타워(울산외곽합사)]감독합사상주(매주금요일)에따른합사근무및업무협의\n2023-03-17 / 울산외곽순환 고속도로 건설공사 기본 및 실시설계(1공구) / H21-고속-01 / [문정동KG타워(울산외곽합사)]감독합사상주(매주금요일)에따른합사근무및업무협의\n2023-03-24 / 울산외곽순환 고속도로 건설공사 기본 및 실시설계(1공구) / H21-고속-01 / [문정동KG타워(울산외곽합사)]감독합사상주(매주금요일)에따른합사근무및업무협의\n2023-03-31 / 울산외곽순환 고속도로 건설공사 기본 및 실시설계(1공구) / H21-고속-01 / [문정동KG타워(울산외곽합사)]감독합사상주(매주금요일)에따른합사근무및업무협의\n2023-04-07 / 울산외곽순환 고속도로 건설공사 기본 및 실시설계(1공구) / H21-고속-01 / [문정동KG타워(울산외곽합사)]감독합사상주(매주금요일)에따른합사근무및업무협의\n2023-04-14 / 울산외곽순환 고속도로 건설공사 기본 및 실시설계(1공구) / H21-고속-01 / [문정동KG타워(울산외곽합사)]감독합사상주(매주금요일)에따른합사근무및업무협의\n2023-11-10~2024-04-30 / Web Solution / H05-IT-04 / 충청내륙고속화도로 경쟁합사"},{"member_no":"M03216","member_name":"임재평","member_grade":"상무","entry_date":"2003-03-10","leave_date":"","contents":["2022-01-01~2022-02-28 / Web Solution / H05-IT-04 / 동부간선도로 지하화 기본설계 합사파견","2022-04-06 / 발안~남양 고속화도로 민간투자사업 실시설계 / H21-고속-08 / 발안~남양합사 업무협의","2022-11-29 / 발안~남양 고속화도로 민간투자사업 실시설계 / H21-고속-08 / 발안합사(특정공법 등 협의)","2022-07-18 / 발안~남양 고속화도로 민간투자사업 실시설계 / H21-고속-08 / 발안합사(공정회의 외)","2022-09-05 / 발안~남양 고속화도로 민간투자사업 실시설계 / H21-고속-08 / 합사(공정 및 지반분야 회의)","2022-09-05 / 발안~남양 고속화도로 민간투자사업 실시설계 / H21-고속-08 / 경유(발안합사(공정 및 지반분야 회의))","2022-09-26 / 발안~남양 고속화도로 민간투자사업 실시설계 / H21-고속-08 / 발안합사(공정 및 업무회의)","2022-10-11 / 발안~남양 고속화도로 민간투자사업 실시설계 / H21-고속-08 / 발안남양합사(공정회의 및 업무협의)","2022-10-17 / 발안~남양 고속화도로 민간투자사업 실시설계 / H21-고속-08 / 발안합사(공정회의 및 업무)","2022-10-24 / 발안~남양 고속화도로 민간투자사업 실시설계 / H21-고속-08 / 발안합사 공정회의 및 업무협의","2022-10-31 / 발안~남양 고속화도로 민간투자사업 실시설계 / H21-고속-08 / 발안남양합사(공정 및 업무회의)","2022-11-09~2022-11-10 / 발안~남양 고속화도로 민간투자사업 실시설계 / H21-고속-08 / [하남 합사]업무 및 협의","2022-11-14 / 발안~남양 고속화도로 민간투자사업 실시설계 / H21-고속-08 / 발안남양 합사업무 및 회의","2022-11-28 / 발안~남양 고속화도로 민간투자사업 실시설계 / H21-고속-08 / 발안합사(업무협의)","2022-12-05 / 발안~남양 고속화도로 민간투자사업 실시설계 / H21-고속-08 / 발안합사(공정 및 업무회의)","2022-12-19 / 발안~남양 고속화도로 민간투자사업 실시설계 / H21-고속-08 / 발안남양합사(공정 및 업무회의)","2022-12-26 / 발안~남양 고속화도로 민간투자사업 실시설계 / H21-고속-08 / 발안남양 합사(공정호의 및 업무)","2023-01-03 / 발안~남양 고속화도로 민간투자사업 실시설계 / H21-고속-08 / [하남시]합사업무 및 협의","2023-01-06 / 용인-안성 고속화도로 민간투자사업 / H23-제안-01 / [에프알텍 신사옥(관양동 1745-2)]합사이사","2023-06-28 / 용인-안성 고속화도로 민간투자사업 / H23-제안-01 / [평촌역]합사복귀"],"info_count":20,"content":"2022-01-01~2022-02-28 / Web Solution / H05-IT-04 / 동부간선도로 지하화 기본설계 합사파견\n2022-04-06 / 발안~남양 고속화도로 민간투자사업 실시설계 / H21-고속-08 / 발안~남양합사 업무협의\n2022-11-29 / 발안~남양 고속화도로 민간투자사업 실시설계 / H21-고속-08 / 발안합사(특정공법 등 협의)\n2022-07-18 / 발안~남양 고속화도로 민간투자사업 실시설계 / H21-고속-08 / 발안합사(공정회의 외)\n2022-09-05 / 발안~남양 고속화도로 민간투자사업 실시설계 / H21-고속-08 / 합사(공정 및 지반분야 회의)\n2022-09-05 / 발안~남양 고속화도로 민간투자사업 실시설계 / H21-고속-08 / 경유(발안합사(공정 및 지반분야 회의))\n2022-09-26 / 발안~남양 고속화도로 민간투자사업 실시설계 / H21-고속-08 / 발안합사(공정 및 업무회의)\n2022-10-11 / 발안~남양 고속화도로 민간투자사업 실시설계 / H21-고속-08 / 발안남양합사(공정회의 및 업무협의)\n2022-10-17 / 발안~남양 고속화도로 민간투자사업 실시설계 / H21-고속-08 / 발안합사(공정회의 및 업무)\n2022-10-24 / 발안~남양 고속화도로 민간투자사업 실시설계 / H21-고속-08 / 발안합사 공정회의 및 업무협의\n2022-10-31 / 발안~남양 고속화도로 민간투자사업 실시설계 / H21-고속-08 / 발안남양합사(공정 및 업무회의)\n2022-11-09~2022-11-10 / 발안~남양 고속화도로 민간투자사업 실시설계 / H21-고속-08 / [하남 합사]업무 및 협의\n2022-11-14 / 발안~남양 고속화도로 민간투자사업 실시설계 / H21-고속-08 / 발안남양 합사업무 및 회의\n2022-11-28 / 발안~남양 고속화도로 민간투자사업 실시설계 / H21-고속-08 / 발안합사(업무협의)\n2022-12-05 / 발안~남양 고속화도로 민간투자사업 실시설계 / H21-고속-08 / 발안합사(공정 및 업무회의)\n2022-12-19 / 발안~남양 고속화도로 민간투자사업 실시설계 / H21-고속-08 / 발안남양합사(공정 및 업무회의)\n2022-12-26 / 발안~남양 고속화도로 민간투자사업 실시설계 / H21-고속-08 / 발안남양 합사(공정호의 및 업무)\n2023-01-03 / 발안~남양 고속화도로 민간투자사업 실시설계 / H21-고속-08 / [하남시]합사업무 및 협의\n2023-01-06 / 용인-안성 고속화도로 민간투자사업 / H23-제안-01 / [에프알텍 신사옥(관양동 1745-2)]합사이사\n2023-06-28 / 용인-안성 고속화도로 민간투자사업 / H23-제안-01 / [평촌역]합사복귀"},{"member_no":"M20217","member_name":"길이원","member_grade":"차장","entry_date":"2020-10-05","leave_date":"","contents":["2022-01-01~2022-12-16 / Web Solution / H05-IT-04 / 공주시 지방상수도 현대화사업 기본및실시설계 합사","2023-07-04~2023-07-07 / 공주시 지방상수도 현대화사업 기본 및 실시설계 용역 / H20-상하-10 / [공주시]공주시 지방상수도 현대화사업 합사 재착수","2023-07-10~2023-07-14 / 공주시 지방상수도 현대화사업 기본 및 실시설계 용역 / H20-상하-10 / [공주시]공주시 지방상수도 현대화사업 합사 재착수","2023-07-17~2023-07-21 / 공주시 지방상수도 현대화사업 기본 및 실시설계 용역 / H20-상하-10 / [공주시]공주시 지방상수도 현대화사업 합사 재착수","2023-07-24~2023-07-28 / 공주시 지방상수도 현대화사업 기본 및 실시설계 용역 / H20-상하-10 / [공주시]공주시 지방상수도 현대화사업 합사 재착수","2023-08-09~2023-08-11 / 공주시 지방상수도 현대화사업 기본 및 실시설계 용역 / H20-상하-10 / [공주시]공주시 지방상수도 현대화사업 합사 재착수","2023-07-31~2023-08-01 / 공주시 지방상수도 현대화사업 기본 및 실시설계 용역 / H20-상하-10 / [공주시]공주시 지방상수도 현대화사업 합사 재착수","2023-08-16~2023-08-18 / 공주시 지방상수도 현대화사업 기본 및 실시설계 용역 / H20-상하-10 / [공주시]공주시 지방상수도 현대화사업 합사 재착수","2023-08-21~2023-08-25 / 공주시 지방상수도 현대화사업 기본 및 실시설계 용역 / H20-상하-10 / [공주시]공주시 지방상수도 현대화사업 합사 재착수","2023-08-28~2023-09-01 / 공주시 지방상수도 현대화사업 기본 및 실시설계 용역 / H20-상하-10 / [공주시]공주시 지방상수도 현대화사업 합사 재착수","2023-09-04~2023-09-08 / 공주시 지방상수도 현대화사업 기본 및 실시설계 용역 / H20-상하-10 / [공주시]공주시 지방상수도 현대화사업 합사 재착수"],"info_count":11,"content":"2022-01-01~2022-12-16 / Web Solution / H05-IT-04 / 공주시 지방상수도 현대화사업 기본및실시설계 합사\n2023-07-04~2023-07-07 / 공주시 지방상수도 현대화사업 기본 및 실시설계 용역 / H20-상하-10 / [공주시]공주시 지방상수도 현대화사업 합사 재착수\n2023-07-10~2023-07-14 / 공주시 지방상수도 현대화사업 기본 및 실시설계 용역 / H20-상하-10 / [공주시]공주시 지방상수도 현대화사업 합사 재착수\n2023-07-17~2023-07-21 / 공주시 지방상수도 현대화사업 기본 및 실시설계 용역 / H20-상하-10 / [공주시]공주시 지방상수도 현대화사업 합사 재착수\n2023-07-24~2023-07-28 / 공주시 지방상수도 현대화사업 기본 및 실시설계 용역 / H20-상하-10 / [공주시]공주시 지방상수도 현대화사업 합사 재착수\n2023-08-09~2023-08-11 / 공주시 지방상수도 현대화사업 기본 및 실시설계 용역 / H20-상하-10 / [공주시]공주시 지방상수도 현대화사업 합사 재착수\n2023-07-31~2023-08-01 / 공주시 지방상수도 현대화사업 기본 및 실시설계 용역 / H20-상하-10 / [공주시]공주시 지방상수도 현대화사업 합사 재착수\n2023-08-16~2023-08-18 / 공주시 지방상수도 현대화사업 기본 및 실시설계 용역 / H20-상하-10 / [공주시]공주시 지방상수도 현대화사업 합사 재착수\n2023-08-21~2023-08-25 / 공주시 지방상수도 현대화사업 기본 및 실시설계 용역 / H20-상하-10 / [공주시]공주시 지방상수도 현대화사업 합사 재착수\n2023-08-28~2023-09-01 / 공주시 지방상수도 현대화사업 기본 및 실시설계 용역 / H20-상하-10 / [공주시]공주시 지방상수도 현대화사업 합사 재착수\n2023-09-04~2023-09-08 / 공주시 지방상수도 현대화사업 기본 및 실시설계 용역 / H20-상하-10 / [공주시]공주시 지방상수도 현대화사업 합사 재착수"},{"member_no":"M20305","member_name":"박민수","member_grade":"과장","entry_date":"2020-03-02","leave_date":"","contents":["2023-01-20 / Web Solution / H05-IT-04 / 연차(합사일정으로 인한 변경)","2023-01-12 / Web Solution / H05-IT-04 / 연차(합사일정으로 인한 변경)","2023-01-10 / Web Solution / H05-IT-04 / 오후반차(합사일정으로 인한 변경)","2023-01-06 / 신규노선개발 프로젝트(22년 도로부) / H22-제안-01 / [에프알텍 신사옥(관양동 1745-2)]합사 이사","2023-06-28 / 용인-안성 고속화도로 민간투자사업 / H23-제안-01 / [평촌역]합사 복귀","2025-02-26 / Web Solution / H05-IT-04 / 연차(합사 운영 일정에 따른 연차계획 변경)","2025-02-25 / Web Solution / H05-IT-04 / 연차(합사 운영 일정에 따른 연차계획 변경)","2025-01-31 / Web Solution / H05-IT-04 / 연차(합사 운영 일정에 따른 연차계획 변경)","2025-01-23 / Web Solution / H05-IT-04 / 연차(합사 운영 일정에 따른 연차계획 변경)","2025-02-28 / 계양-강화간 고속도로 건설공사 기본 및 실시설계(5공구) / H22-고속-05 / [합사(가락동)]합사철수(계양강화)","2025-03-10~2025-03-11 / Web Solution / H05-IT-04 / 특별휴가(합사 일정에 의한 미사용 연차 소진)"],"info_count":11,"content":"2023-01-20 / Web Solution / H05-IT-04 / 연차(합사일정으로 인한 변경)\n2023-01-12 / Web Solution / H05-IT-04 / 연차(합사일정으로 인한 변경)\n2023-01-10 / Web Solution / H05-IT-04 / 오후반차(합사일정으로 인한 변경)\n2023-01-06 / 신규노선개발 프로젝트(22년 도로부) / H22-제안-01 / [에프알텍 신사옥(관양동 1745-2)]합사 이사\n2023-06-28 / 용인-안성 고속화도로 민간투자사업 / H23-제안-01 / [평촌역]합사 복귀\n2025-02-26 / Web Solution / H05-IT-04 / 연차(합사 운영 일정에 따른 연차계획 변경)\n2025-02-25 / Web Solution / H05-IT-04 / 연차(합사 운영 일정에 따른 연차계획 변경)\n2025-01-31 / Web Solution / H05-IT-04 / 연차(합사 운영 일정에 따른 연차계획 변경)\n2025-01-23 / Web Solution / H05-IT-04 / 연차(합사 운영 일정에 따른 연차계획 변경)\n2025-02-28 / 계양-강화간 고속도로 건설공사 기본 및 실시설계(5공구) / H22-고속-05 / [합사(가락동)]합사철수(계양강화)\n2025-03-10~2025-03-11 / Web Solution / H05-IT-04 / 특별휴가(합사 일정에 의한 미사용 연차 소진)"},{"member_no":"M10103","member_name":"윤성호","member_grade":"부사장","entry_date":"2019-01-01","leave_date":"","contents":["2022-10-14 / 동부 간선 지하화(영동대로)건설공사 기본설계 / H22-제안-21 / 설계자문회의(합사)","2022-12-13 / 개봉고가 성능개선공사외 1개소 감독권한대행 등 건설사업관리용역 / H19-감리-04 / 동부간선합사 및 개봉고가 현장","2022-12-19 / 동부간선 지하화(영동대로)건설공사 기본설계 기술제안 입찰(제안)설계 / H22-구조-07 / 기술제안 합사","2022-12-28~2023-01-03 / 동부간선 지하화(영동대로)건설공사 기본설계 기술제안 입찰(제안)설계 / H22-구조-07 / 동부간선 양재합사","2023-01-04 / 신규노선개발 프로젝트(22년 도로부) / H22-제안-01 / 건기연, 동부간선합사","2023-01-11~2023-01-12 / 동부간선 지하화(영동대로)건설공사 기본설계 기술제안 입찰(제안)설계 / H22-구조-07 / 동부간선합사, 서울시","2024-03-14 / 동광주~광산 대안제시(경쟁) / H24-제안-01 / 합사","2024-03-12 / 동광주~광산 대안제시(경쟁) / H24-제안-01 / 합사","2024-03-13 / 동광주~광산 대안제시(경쟁) / H24-제안-01 / 합사","2024-03-18~2024-03-19 / 동광주~광산 대안제시(경쟁) / H24-제안-01 / 합사, 도로공사"],"info_count":10,"content":"2022-10-14 / 동부 간선 지하화(영동대로)건설공사 기본설계 / H22-제안-21 / 설계자문회의(합사)\n2022-12-13 / 개봉고가 성능개선공사외 1개소 감독권한대행 등 건설사업관리용역 / H19-감리-04 / 동부간선합사 및 개봉고가 현장\n2022-12-19 / 동부간선 지하화(영동대로)건설공사 기본설계 기술제안 입찰(제안)설계 / H22-구조-07 / 기술제안 합사\n2022-12-28~2023-01-03 / 동부간선 지하화(영동대로)건설공사 기본설계 기술제안 입찰(제안)설계 / H22-구조-07 / 동부간선 양재합사\n2023-01-04 / 신규노선개발 프로젝트(22년 도로부) / H22-제안-01 / 건기연, 동부간선합사\n2023-01-11~2023-01-12 / 동부간선 지하화(영동대로)건설공사 기본설계 기술제안 입찰(제안)설계 / H22-구조-07 / 동부간선합사, 서울시\n2024-03-14 / 동광주~광산 대안제시(경쟁) / H24-제안-01 / 합사\n2024-03-12 / 동광주~광산 대안제시(경쟁) / H24-제안-01 / 합사\n2024-03-13 / 동광주~광산 대안제시(경쟁) / H24-제안-01 / 합사\n2024-03-18~2024-03-19 / 동광주~광산 대안제시(경쟁) / H24-제안-01 / 합사, 도로공사"},{"member_no":"M16216","member_name":"이병욱","member_grade":"이사","entry_date":"2016-09-01","leave_date":"","contents":["2024-08-13 / 춘천 기업혁신파크 조사설계 용역(가칭) / H24-제안-19 / [합사(경기도 남양주시)]업무협의","2025-05-26 / 춘천 기업혁신파크 선도사업 조사·설계 기술용역 / H24-도시-13 / [합사(경기도 남양주시)]업무회의","2025-10-24 / 춘천 기업혁신파크 선도사업 조사·설계 기술용역 / H24-도시-13 / [경기도 남양주시(합사)]업무회의","2025-10-29 / 춘천 기업혁신파크 선도사업 조사·설계 기술용역 / H24-도시-13 / [경기도 남양주시(합사)]업무회의","2025-11-07 / 춘천 기업혁신파크 선도사업 조사·설계 기술용역 / H24-도시-13 / [합사(남양주시), 춘천시청]업무회의(주민의견 수렴 관련)","2025-12-10 / 춘천 기업혁신파크 선도사업 조사·설계 기술용역 / H24-도시-13 / [경기도 남양주시(합사)]업무회의","2025-12-17 / 춘천 기업혁신파크 선도사업 조사·설계 기술용역 / H24-도시-13 / [경기도 남양주시(합사)]업무회의(평가서(초안) 검토의견 대응)","2025-12-30 / 춘천 기업혁신파크 선도사업 조사·설계 기술용역 / H24-도시-13 / [경기도 남양주시(합사)]업무회의"],"info_count":8,"content":"2024-08-13 / 춘천 기업혁신파크 조사설계 용역(가칭) / H24-제안-19 / [합사(경기도 남양주시)]업무협의\n2025-05-26 / 춘천 기업혁신파크 선도사업 조사·설계 기술용역 / H24-도시-13 / [합사(경기도 남양주시)]업무회의\n2025-10-24 / 춘천 기업혁신파크 선도사업 조사·설계 기술용역 / H24-도시-13 / [경기도 남양주시(합사)]업무회의\n2025-10-29 / 춘천 기업혁신파크 선도사업 조사·설계 기술용역 / H24-도시-13 / [경기도 남양주시(합사)]업무회의\n2025-11-07 / 춘천 기업혁신파크 선도사업 조사·설계 기술용역 / H24-도시-13 / [합사(남양주시), 춘천시청]업무회의(주민의견 수렴 관련)\n2025-12-10 / 춘천 기업혁신파크 선도사업 조사·설계 기술용역 / H24-도시-13 / [경기도 남양주시(합사)]업무회의\n2025-12-17 / 춘천 기업혁신파크 선도사업 조사·설계 기술용역 / H24-도시-13 / [경기도 남양주시(합사)]업무회의(평가서(초안) 검토의견 대응)\n2025-12-30 / 춘천 기업혁신파크 선도사업 조사·설계 기술용역 / H24-도시-13 / [경기도 남양주시(합사)]업무회의"},{"member_no":"M21315","member_name":"장경호","member_grade":"과장","entry_date":"2021-02-15","leave_date":"2025-12-08","contents":["2023-06-16 / 용인-안성 고속화도로 민간투자사업 / H23-제안-01 / [평촌(안양)]용인-안성 고속화도로 민간투자사업 합사 철수","2025-02-14 / Web Solution / H05-IT-04 / 연차(합사 운영 일정에 따른 연차변경)","2024-12-31 / Web Solution / H05-IT-04 / 연차(합사 운영 일정에 따른 연차)","2025-02-07 / Web Solution / H05-IT-04 / 연차(합사 운영 일정에 따른 연차변경)","2025-03-10 / Web Solution / H05-IT-04 / 특별휴가(합사 일정에 의한 미사용 연차소진)","2025-02-28 / 계양-강화간 고속도로 건설공사 기본 및 실시설계(5공구) / H22-고속-05 / [합사(가락동)]합사철수(계양강화)"],"info_count":6,"content":"2023-06-16 / 용인-안성 고속화도로 민간투자사업 / H23-제안-01 / [평촌(안양)]용인-안성 고속화도로 민간투자사업 합사 철수\n2025-02-14 / Web Solution / H05-IT-04 / 연차(합사 운영 일정에 따른 연차변경)\n2024-12-31 / Web Solution / H05-IT-04 / 연차(합사 운영 일정에 따른 연차)\n2025-02-07 / Web Solution / H05-IT-04 / 연차(합사 운영 일정에 따른 연차변경)\n2025-03-10 / Web Solution / H05-IT-04 / 특별휴가(합사 일정에 의한 미사용 연차소진)\n2025-02-28 / 계양-강화간 고속도로 건설공사 기본 및 실시설계(5공구) / H22-고속-05 / [합사(가락동)]합사철수(계양강화)"},{"member_no":"M21432","member_name":"최승범","member_grade":"대리","entry_date":"2021-07-01","leave_date":"","contents":["2022-11-09 / 발안~남양 고속화도로 민간투자사업 실시설계 / H21-고속-08 / 경유(자료전달(합사관련 협의자료))","2023-01-06 / 신규노선개발 프로젝트(22년 도로부) / H22-제안-01 / [에프알텍 신사옥(관양동 1745-2)]합사 이사","2023-02-03 / 용인-안성 고속화도로 민간투자사업 / H23-제안-01 / [에프알텍 신사옥(관양동 1745-2)]합사 복귀","2024-10-04 / 계양~강화 고속도로 건설공사 제7공구 T/K 기본설계 / H24-제안-15 / [승광빌딩 9층(삼성동)]합사 파견","2025-05-29 / 계양~강화 고속도로 건설공사(제7공구) 기본설계용역(2.4 도로공 설계) / H24-고속-03 / [사당역 합동사무실]합사 이사(선릉역 합동사무실에서 사당 합동사무실로 이전)로 인한 배차 신청"],"info_count":5,"content":"2022-11-09 / 발안~남양 고속화도로 민간투자사업 실시설계 / H21-고속-08 / 경유(자료전달(합사관련 협의자료))\n2023-01-06 / 신규노선개발 프로젝트(22년 도로부) / H22-제안-01 / [에프알텍 신사옥(관양동 1745-2)]합사 이사\n2023-02-03 / 용인-안성 고속화도로 민간투자사업 / H23-제안-01 / [에프알텍 신사옥(관양동 1745-2)]합사 복귀\n2024-10-04 / 계양~강화 고속도로 건설공사 제7공구 T/K 기본설계 / H24-제안-15 / [승광빌딩 9층(삼성동)]합사 파견\n2025-05-29 / 계양~강화 고속도로 건설공사(제7공구) 기본설계용역(2.4 도로공 설계) / H24-고속-03 / [사당역 합동사무실]합사 이사(선릉역 합동사무실에서 사당 합동사무실로 이전)로 인한 배차 신청"},{"member_no":"M20213","member_name":"임원석","member_grade":"차장","entry_date":"2020-07-01","leave_date":"2024-05-31","contents":["2022-01-01~2022-02-28 / Web Solution / H05-IT-04 / 동부간선도로 지하화 기본설계 합사파견","2023-02-10 / Web Solution / H05-IT-04 / 시차:합사 일정에 따른 연차변경(16시~18시)/n16/n18","2023-02-17 / Web Solution / H05-IT-04 / 시차:합사 일정에 따른 연차변경(16시~18시)/n16/n18","2024-01-08~2024-04-07 / 동광주~광산 대안제시(경쟁) / H24-제안-01 / 고속국도 제25호 호남선(동광주~광산) 경쟁합사"],"info_count":4,"content":"2022-01-01~2022-02-28 / Web Solution / H05-IT-04 / 동부간선도로 지하화 기본설계 합사파견\n2023-02-10 / Web Solution / H05-IT-04 / 시차:합사 일정에 따른 연차변경(16시~18시)/n16/n18\n2023-02-17 / Web Solution / H05-IT-04 / 시차:합사 일정에 따른 연차변경(16시~18시)/n16/n18\n2024-01-08~2024-04-07 / 동광주~광산 대안제시(경쟁) / H24-제안-01 / 고속국도 제25호 호남선(동광주~광산) 경쟁합사"},{"member_no":"M05207","member_name":"이찬우","member_grade":"전무","entry_date":"2005-04-18","leave_date":"","contents":["2025-05-26 / 춘천 기업혁신파크 선도사업 조사·설계 기술용역 / H24-도시-13 / [합사(경기도 남양주시)]업무회의","2025-11-07 / 춘천 기업혁신파크 선도사업 조사·설계 기술용역 / H24-도시-13 / [합사(남양주시), 춘천시청]업무회의(주민의견 수렴 관련)","2025-12-17 / 춘천 기업혁신파크 선도사업 조사·설계 기술용역 / H24-도시-13 / [경기도 남양주시(합사)]업무회의(평가서(초안) 검토의견 대응)"],"info_count":3,"content":"2025-05-26 / 춘천 기업혁신파크 선도사업 조사·설계 기술용역 / H24-도시-13 / [합사(경기도 남양주시)]업무회의\n2025-11-07 / 춘천 기업혁신파크 선도사업 조사·설계 기술용역 / H24-도시-13 / [합사(남양주시), 춘천시청]업무회의(주민의견 수렴 관련)\n2025-12-17 / 춘천 기업혁신파크 선도사업 조사·설계 기술용역 / H24-도시-13 / [경기도 남양주시(합사)]업무회의(평가서(초안) 검토의견 대응)"},{"member_no":"M07223","member_name":"이종익","member_grade":"상무","entry_date":"2007-08-06","leave_date":"","contents":["2023-01-02 / Web Solution / H05-IT-04 / 시차:송산그린1공구 합사 자료수집(17시~18시)/n17/n18","2023-01-10 / 발안~남양 고속화도로 민간투자사업 실시설계 / H21-고속-08 / [하남시]합사투입","2024-01-08~2024-04-07 / 동광주~광산 대안제시(경쟁) / H24-제안-01 / 고속국도 제25호 호남선(동광주~광산) 경쟁합사"],"info_count":3,"content":"2023-01-02 / Web Solution / H05-IT-04 / 시차:송산그린1공구 합사 자료수집(17시~18시)/n17/n18\n2023-01-10 / 발안~남양 고속화도로 민간투자사업 실시설계 / H21-고속-08 / [하남시]합사투입\n2024-01-08~2024-04-07 / 동광주~광산 대안제시(경쟁) / H24-제안-01 / 고속국도 제25호 호남선(동광주~광산) 경쟁합사"},{"member_no":"M13303","member_name":"민경선","member_grade":"차장","entry_date":"2013-03-18","leave_date":"2024-05-19","contents":["2022-09-15 / Web Solution / H05-IT-04 / 연차(합사투입으로 인한 연차소진)","2022-09-21 / Web Solution / H05-IT-04 / 연차(합사투입으로 인한 연차소진)","2022-09-26 / Web Solution / H05-IT-04 / 연차(합사투입으로 인한 연차소진)"],"info_count":3,"content":"2022-09-15 / Web Solution / H05-IT-04 / 연차(합사투입으로 인한 연차소진)\n2022-09-21 / Web Solution / H05-IT-04 / 연차(합사투입으로 인한 연차소진)\n2022-09-26 / Web Solution / H05-IT-04 / 연차(합사투입으로 인한 연차소진)"},{"member_no":"B15203","member_name":"서현옥","member_grade":"과장","entry_date":"2015-10-12","leave_date":"","contents":["2023-01-06 / 신규노선개발 프로젝트(22년 도로부) / H22-제안-01 / [에프알텍 신사옥(관양동 1745-2)]합사 이사","2023-06-28 / 용인-안성 고속화도로 민간투자사업 / H23-제안-01 / [평촌역]합사 복귀"],"info_count":2,"content":"2023-01-06 / 신규노선개발 프로젝트(22년 도로부) / H22-제안-01 / [에프알텍 신사옥(관양동 1745-2)]합사 이사\n2023-06-28 / 용인-안성 고속화도로 민간투자사업 / H23-제안-01 / [평촌역]합사 복귀"},{"member_no":"M03211","member_name":"박성웅","member_grade":"전무","entry_date":"2003-02-05","leave_date":"2026-03-31","contents":["2022-11-10 / 발안~남양 고속화도로 민간투자사업 실시설계 / H21-고속-08 / [화성시, 합사(하남)]민투심의 관련 업무협의","2023-02-20 / 용인-안성 고속화도로 민간투자사업 / H23-제안-01 / 경유(합사업무협의)"],"info_count":2,"content":"2022-11-10 / 발안~남양 고속화도로 민간투자사업 실시설계 / H21-고속-08 / [화성시, 합사(하남)]민투심의 관련 업무협의\n2023-02-20 / 용인-안성 고속화도로 민간투자사업 / H23-제안-01 / 경유(합사업무협의)"},{"member_no":"M12203","member_name":"김상수","member_grade":"전무","entry_date":"2012-06-19","leave_date":"","contents":["2024-11-12 / 영종~청라 연결도로(제3연륙교) 건설공사 제2공구 유지관리방안 검토 / H22-교통-07 / 인천경자청, 춘천 합사","2025-01-23 / 춘천 기업혁신파크 선도사업 조사·설계 기술용역 / H24-도시-13 / 합사 등"],"info_count":2,"content":"2024-11-12 / 영종~청라 연결도로(제3연륙교) 건설공사 제2공구 유지관리방안 검토 / H22-교통-07 / 인천경자청, 춘천 합사\n2025-01-23 / 춘천 기업혁신파크 선도사업 조사·설계 기술용역 / H24-도시-13 / 합사 등"},{"member_no":"M14101","member_name":"정혜연","member_grade":"전무","entry_date":"2014-06-01","leave_date":"","contents":["2024-12-19 / 춘천 기업혁신파크 조사설계 용역(가칭) / H24-제안-19 / 업무협의(남양주합사)","2025-04-03 / 춘천 기업혁신파크 선도사업 조사·설계 기술용역 / H24-도시-13 / 춘천합사방문"],"info_count":2,"content":"2024-12-19 / 춘천 기업혁신파크 조사설계 용역(가칭) / H24-제안-19 / 업무협의(남양주합사)\n2025-04-03 / 춘천 기업혁신파크 선도사업 조사·설계 기술용역 / H24-도시-13 / 춘천합사방문"},{"member_no":"M16208","member_name":"신영각","member_grade":"부사장","entry_date":"2016-07-04","leave_date":"","contents":["2022-10-14 / 동부 간선 지하화(영동대로)건설공사 기본설계 / H22-제안-21 / 설계자문회의(합사)","2023-02-16 / Web Solution / H05-IT-04 / YA합사경우"],"info_count":2,"content":"2022-10-14 / 동부 간선 지하화(영동대로)건설공사 기본설계 / H22-제안-21 / 설계자문회의(합사)\n2023-02-16 / Web Solution / H05-IT-04 / YA합사경우"},{"member_no":"M21413","member_name":"김춘근","member_grade":"전무","entry_date":"2021-03-26","leave_date":"","contents":["2022-09-13 / 송산그린시티 서측지구 1단계 제2공구 실시설계 기술제안 / H22-제안-24 / [합사사무실(용산전자오피스텔)]합사 투입(컴퓨터 등 셋팅)","2023-09-04 / Web Solution / H05-IT-04 / [안양]행복도시 6-2 공공주택지구 CMR 합사출장"],"info_count":2,"content":"2022-09-13 / 송산그린시티 서측지구 1단계 제2공구 실시설계 기술제안 / H22-제안-24 / [합사사무실(용산전자오피스텔)]합사 투입(컴퓨터 등 셋팅)\n2023-09-04 / Web Solution / H05-IT-04 / [안양]행복도시 6-2 공공주택지구 CMR 합사출장"},{"member_no":"M21424","member_name":"김기창","member_grade":"대리","entry_date":"2021-05-03","leave_date":"2023-08-17","contents":["2023-06-16 / 용인-안성 고속화도로 민간투자사업 / H23-제안-01 / [평촌(안양)]용인-안성 고속화도로 민간투자사업 합사 철수","2023-06-28 / 용인-안성 고속화도로 민간투자사업 / H23-제안-01 / [평촌역]합사복귀"],"info_count":2,"content":"2023-06-16 / 용인-안성 고속화도로 민간투자사업 / H23-제안-01 / [평촌(안양)]용인-안성 고속화도로 민간투자사업 합사 철수\n2023-06-28 / 용인-안성 고속화도로 민간투자사업 / H23-제안-01 / [평촌역]합사복귀"},{"member_no":"M21451","member_name":"김태식","member_grade":"차장","entry_date":"2021-09-27","leave_date":"","contents":["2024-07-02 / 도림천 일대 대심도 빗물배수터널 건설공사 기본설계용역(주설계) / H24-제안-23 / [과업구간]합사 현장확인","2025-09-17 / 도림천 일대 대심도 빗물배수터널 건설공사 실시설계용역(주설계) / H25-지반-01 / [도림천 합사]지하안전영향평가 현장조사"],"info_count":2,"content":"2024-07-02 / 도림천 일대 대심도 빗물배수터널 건설공사 기본설계용역(주설계) / H24-제안-23 / [과업구간]합사 현장확인\n2025-09-17 / 도림천 일대 대심도 빗물배수터널 건설공사 실시설계용역(주설계) / H25-지반-01 / [도림천 합사]지하안전영향평가 현장조사"},{"member_no":"M21468","member_name":"전수진","member_grade":"대리","entry_date":"2021-12-01","leave_date":"","contents":["2023-01-06 / 용인-안성 고속화도로 민간투자사업 / H23-제안-01 / [에프알텍 신사옥(관양동 1745-2)]합사이사","2023-06-28 / 용인-안성 고속화도로 민간투자사업 / H23-제안-01 / [평촌역]합사복귀"],"info_count":2,"content":"2023-01-06 / 용인-안성 고속화도로 민간투자사업 / H23-제안-01 / [에프알텍 신사옥(관양동 1745-2)]합사이사\n2023-06-28 / 용인-안성 고속화도로 민간투자사업 / H23-제안-01 / [평촌역]합사복귀"},{"member_no":"M22022","member_name":"송민제","member_grade":"대리","entry_date":"2022-05-02","leave_date":"2024-10-29","contents":["2023-06-16 / 용인-안성 고속화도로 민간투자사업 / H23-제안-01 / [평촌(안양)]용인-안성 고속화도로 민간투자사업 합사 철수","2023-11-10~2024-04-30 / Web Solution / H05-IT-04 / 충청내륙고속화도로 경쟁합사"],"info_count":2,"content":"2023-06-16 / 용인-안성 고속화도로 민간투자사업 / H23-제안-01 / [평촌(안양)]용인-안성 고속화도로 민간투자사업 합사 철수\n2023-11-10~2024-04-30 / Web Solution / H05-IT-04 / 충청내륙고속화도로 경쟁합사"},{"member_no":"M22053","member_name":"남창성","member_grade":"상무","entry_date":"2022-08-01","leave_date":"2025-11-30","contents":["2023-06-16 / 용인-안성 고속화도로 민간투자사업 / H23-제안-01 / [평촌(안양)]용인-안성 고속화도로 민간투자사업 합사 철수","2024-12-31 / Web Solution / H05-IT-04 / 연차(합사 휴무일정에 따른 연차)"],"info_count":2,"content":"2023-06-16 / 용인-안성 고속화도로 민간투자사업 / H23-제안-01 / [평촌(안양)]용인-안성 고속화도로 민간투자사업 합사 철수\n2024-12-31 / Web Solution / H05-IT-04 / 연차(합사 휴무일정에 따른 연차)"},{"member_no":"M23006","member_name":"김태욱","member_grade":"대리","entry_date":"2023-01-09","leave_date":"","contents":["2025-04-17 / 2025 QBS(구조) / H25-제안-13 / [삼보기술단]용산선로데크 합사 인원 복귀","2025-04-18 / 2025 QBS(구조) / H25-제안-13 / [삼보기술단]용산선로데크 합사"],"info_count":2,"content":"2025-04-17 / 2025 QBS(구조) / H25-제안-13 / [삼보기술단]용산선로데크 합사 인원 복귀\n2025-04-18 / 2025 QBS(구조) / H25-제안-13 / [삼보기술단]용산선로데크 합사"},{"member_no":"M23076","member_name":"박상빈","member_grade":"과장","entry_date":"2023-11-27","leave_date":"","contents":["2023-12-11 / 충청내륙고속화도로~충주역(검단대교) 도로연결사업 실시설계 기술제안입찰 / H23-제안-19 / [경기도 안양시 동안구 호계동 897-7]합사투입","2023-12-11~2024-04-30 / Web Solution / H05-IT-04 / 충청내륙고속화도로 경쟁합사"],"info_count":2,"content":"2023-12-11 / 충청내륙고속화도로~충주역(검단대교) 도로연결사업 실시설계 기술제안입찰 / H23-제안-19 / [경기도 안양시 동안구 호계동 897-7]합사투입\n2023-12-11~2024-04-30 / Web Solution / H05-IT-04 / 충청내륙고속화도로 경쟁합사"},{"member_no":"M24036","member_name":"심성보","member_grade":"이사","entry_date":"2024-05-02","leave_date":"","contents":["2024-07-02 / 도림천 일대 대심도 빗물배수터널 건설공사 기본설계용역(주설계) / H24-제안-23 / [과업구간]합사 현장확인","2025-12-10 / 도림천 일대 대심도 빗물배수터널 건설공사 실시설계용역(주설계) / H25-지반-01 / 도림천 주설계 합사회의 및 서울 도기본 공사중 변경설계 협의"],"info_count":2,"content":"2024-07-02 / 도림천 일대 대심도 빗물배수터널 건설공사 기본설계용역(주설계) / H24-제안-23 / [과업구간]합사 현장확인\n2025-12-10 / 도림천 일대 대심도 빗물배수터널 건설공사 실시설계용역(주설계) / H25-지반-01 / 도림천 주설계 합사회의 및 서울 도기본 공사중 변경설계 협의"},{"member_no":"M24075","member_name":"송민제","member_grade":"과장","entry_date":"2022-05-02","leave_date":"","contents":["2023-06-16 / 용인-안성 고속화도로 민간투자사업 / H23-제안-01 / [평촌(안양)]용인-안성 고속화도로 민간투자사업 합사 철수","2023-11-10~2024-04-30 / Web Solution / H05-IT-04 / 충청내륙고속화도로 경쟁합사"],"info_count":2,"content":"2023-06-16 / 용인-안성 고속화도로 민간투자사업 / H23-제안-01 / [평촌(안양)]용인-안성 고속화도로 민간투자사업 합사 철수\n2023-11-10~2024-04-30 / Web Solution / H05-IT-04 / 충청내륙고속화도로 경쟁합사"},{"member_no":"B21335","member_name":"이동원A","member_grade":"상무","entry_date":"2018-10-01","leave_date":"2023-02-28","contents":["2022-06-27~2023-01-20 / 동부 간선 지하화(영동대로)건설공사 기본설계 / H22-제안-21 / 동부간선도로 지하화(영동대로) 기본설계 기술제안입찰 합사"],"info_count":1,"content":"2022-06-27~2023-01-20 / 동부 간선 지하화(영동대로)건설공사 기본설계 / H22-제안-21 / 동부간선도로 지하화(영동대로) 기본설계 기술제안입찰 합사"},{"member_no":"M02213","member_name":"이영경","member_grade":"전무","entry_date":"1996-11-18","leave_date":"","contents":["2024-10-04 / 계양~강화 고속도로 건설공사 제7공구 T/K 기본설계 / H24-제안-15 / [승광빌딩 9층(삼성동)]합사 파견"],"info_count":1,"content":"2024-10-04 / 계양~강화 고속도로 건설공사 제7공구 T/K 기본설계 / H24-제안-15 / [승광빌딩 9층(삼성동)]합사 파견"},{"member_no":"M02259","member_name":"김영권","member_grade":"상무","entry_date":"2001-09-03","leave_date":"","contents":["2023-04-14 / 발안~남양 고속화도로 민간투자사업 실시설계 / H21-고속-08 / 업무협의(서경대, 합사)"],"info_count":1,"content":"2023-04-14 / 발안~남양 고속화도로 민간투자사업 실시설계 / H21-고속-08 / 업무협의(서경대, 합사)"},{"member_no":"M02308","member_name":"황승현","member_grade":"상무","entry_date":"2002-08-26","leave_date":"","contents":["2022-12-01~2022-12-20 / 동부간선 지하화(영동대로)건설공사 기본설계 기술제안 입찰(제안)설계 / H22-구조-07 / 동부간선도로 지하화(영동대로) 기본설계 기술제안입찰 합사"],"info_count":1,"content":"2022-12-01~2022-12-20 / 동부간선 지하화(영동대로)건설공사 기본설계 기술제안 입찰(제안)설계 / H22-구조-07 / 동부간선도로 지하화(영동대로) 기본설계 기술제안입찰 합사"},{"member_no":"M03201","member_name":"이동훈","member_grade":"전무","entry_date":"2003-02-25","leave_date":"","contents":["2024-01-08~2024-04-07 / 동광주~광산 대안제시(경쟁) / H24-제안-01 / 고속국도 제25호 호남선(동광주~광산) 경쟁합사"],"info_count":1,"content":"2024-01-08~2024-04-07 / 동광주~광산 대안제시(경쟁) / H24-제안-01 / 고속국도 제25호 호남선(동광주~광산) 경쟁합사"},{"member_no":"M03202","member_name":"오재범","member_grade":"전무","entry_date":"2003-05-29","leave_date":"","contents":["2025-02-14 / 춘천 기업혁신파크교량 구조물 / H25-제안-10 / 합사 업무협의"],"info_count":1,"content":"2025-02-14 / 춘천 기업혁신파크교량 구조물 / H25-제안-10 / 합사 업무협의"},{"member_no":"M03228","member_name":"김재용","member_grade":"상무","entry_date":"2003-01-02","leave_date":"","contents":["2024-01-08~2024-04-07 / 동광주~광산 대안제시(경쟁) / H24-제안-01 / 고속국도 제25호 호남선(동광주~광산) 경쟁합사"],"info_count":1,"content":"2024-01-08~2024-04-07 / 동광주~광산 대안제시(경쟁) / H24-제안-01 / 고속국도 제25호 호남선(동광주~광산) 경쟁합사"},{"member_no":"M07231","member_name":"송기영","member_grade":"상무","entry_date":"2007-11-05","leave_date":"","contents":["2024-01-08~2024-04-07 / 동광주~광산 대안제시(경쟁) / H24-제안-01 / 고속국도 제25호 호남선(동광주~광산) 경쟁합사"],"info_count":1,"content":"2024-01-08~2024-04-07 / 동광주~광산 대안제시(경쟁) / H24-제안-01 / 고속국도 제25호 호남선(동광주~광산) 경쟁합사"},{"member_no":"M07238","member_name":"곽태신","member_grade":"이사","entry_date":"2007-12-10","leave_date":"2024-03-31","contents":["2022-01-01~2022-07-31 / Web Solution / H05-IT-04 / 국제교류 복합지구 합사 파견"],"info_count":1,"content":"2022-01-01~2022-07-31 / Web Solution / H05-IT-04 / 국제교류 복합지구 합사 파견"},{"member_no":"M07317","member_name":"전찬성","member_grade":"이사","entry_date":"2007-12-14","leave_date":"","contents":["2024-01-08~2024-04-07 / 동광주~광산 대안제시(경쟁) / H24-제안-01 / 고속국도 제25호 호남선(동광주~광산) 경쟁합사"],"info_count":1,"content":"2024-01-08~2024-04-07 / 동광주~광산 대안제시(경쟁) / H24-제안-01 / 고속국도 제25호 호남선(동광주~광산) 경쟁합사"},{"member_no":"M08206","member_name":"이재진","member_grade":"이사","entry_date":"2008-04-07","leave_date":"","contents":["2023-05-12 / 송산그린시티 도시물순환 기본계획 수립 용역(서측지구 1단계) / H22-수자-11 / [광명 합사사무실]업무협의"],"info_count":1,"content":"2023-05-12 / 송산그린시티 도시물순환 기본계획 수립 용역(서측지구 1단계) / H22-수자-11 / [광명 합사사무실]업무협의"},{"member_no":"M09301","member_name":"성열은","member_grade":"부장","entry_date":"2009-01-05","leave_date":"2024-03-31","contents":["2022-06-27~2023-01-20 / 동부 간선 지하화(영동대로)건설공사 기본설계 / H22-제안-21 / 동부간선도로 지하화(영동대로) 기본설계 기술제안입찰 합사"],"info_count":1,"content":"2022-06-27~2023-01-20 / 동부 간선 지하화(영동대로)건설공사 기본설계 / H22-제안-21 / 동부간선도로 지하화(영동대로) 기본설계 기술제안입찰 합사"},{"member_no":"M15206","member_name":"이정원","member_grade":"이사","entry_date":"2015-10-01","leave_date":"","contents":["2024-02-07~2024-02-25 / 동광주~광산 대안제시(경쟁) / H24-제안-01 / 고속국도 제25호 호남선(동광주~광산) 경쟁합사"],"info_count":1,"content":"2024-02-07~2024-02-25 / 동광주~광산 대안제시(경쟁) / H24-제안-01 / 고속국도 제25호 호남선(동광주~광산) 경쟁합사"},{"member_no":"M15207","member_name":"이기주A","member_grade":"상무","entry_date":"2015-12-11","leave_date":"","contents":["2024-01-08~2024-03-04 / Web Solution / H05-IT-04 / 고속국도 제25호 호남선(동광주~광산) 경쟁합사"],"info_count":1,"content":"2024-01-08~2024-03-04 / Web Solution / H05-IT-04 / 고속국도 제25호 호남선(동광주~광산) 경쟁합사"},{"member_no":"M16222","member_name":"이현구","member_grade":"부사장","entry_date":"2016-10-31","leave_date":"","contents":["2023-06-21 / 발안~남양 고속화도로 민간투자사업 실시설계 / H21-고속-08 / 합사회의"],"info_count":1,"content":"2023-06-21 / 발안~남양 고속화도로 민간투자사업 실시설계 / H21-고속-08 / 합사회의"},{"member_no":"M16308","member_name":"유재범","member_grade":"차장","entry_date":"2016-03-01","leave_date":"","contents":["2022-09-29~2022-10-14 / 송산그린시티 도시물순환 기본계획 수립 용역(서측지구 1단계) / H22-수자-11 / [삼안]착수보고준비를위한 합사"],"info_count":1,"content":"2022-09-29~2022-10-14 / 송산그린시티 도시물순환 기본계획 수립 용역(서측지구 1단계) / H22-수자-11 / [삼안]착수보고준비를위한 합사"},{"member_no":"M16312","member_name":"정병진","member_grade":"과장","entry_date":"2016-03-21","leave_date":"2024-09-30","contents":["2024-02-07~2024-02-25 / 동광주~광산 대안제시(경쟁) / H24-제안-01 / 고속국도 제25호 호남선(동광주~광산) 경쟁합사"],"info_count":1,"content":"2024-02-07~2024-02-25 / 동광주~광산 대안제시(경쟁) / H24-제안-01 / 고속국도 제25호 호남선(동광주~광산) 경쟁합사"},{"member_no":"M17304","member_name":"최락준","member_grade":"과장","entry_date":"2017-02-01","leave_date":"2024-04-30","contents":["2022-06-27~2023-01-20 / 동부 간선 지하화(영동대로)건설공사 기본설계 / H22-제안-21 / 동부간선도로 지하화(영동대로) 기본설계 기술제안입찰 합사"],"info_count":1,"content":"2022-06-27~2023-01-20 / 동부 간선 지하화(영동대로)건설공사 기본설계 / H22-제안-21 / 동부간선도로 지하화(영동대로) 기본설계 기술제안입찰 합사"},{"member_no":"M18204","member_name":"이세화","member_grade":"부장","entry_date":"2018-02-20","leave_date":"","contents":["2024-01-08~2024-04-07 / 동광주~광산 대안제시(경쟁) / H24-제안-01 / 고속국도 제25호 호남선(동광주~광산) 경쟁합사"],"info_count":1,"content":"2024-01-08~2024-04-07 / 동광주~광산 대안제시(경쟁) / H24-제안-01 / 고속국도 제25호 호남선(동광주~광산) 경쟁합사"},{"member_no":"M20101","member_name":"이병도","member_grade":"전무","entry_date":"2020-01-01","leave_date":"","contents":["2025-12-30 / 춘천 기업혁신파크 선도사업 조사·설계 기술용역 / H24-도시-13 / [경기도 남양주시(합사)]업무회의"],"info_count":1,"content":"2025-12-30 / 춘천 기업혁신파크 선도사업 조사·설계 기술용역 / H24-도시-13 / [경기도 남양주시(합사)]업무회의"},{"member_no":"M20211","member_name":"유재극","member_grade":"이사","entry_date":"2020-06-15","leave_date":"","contents":["2023-11-10~2024-04-30 / Web Solution / H05-IT-04 / 충청내륙고속화도로 경쟁합사"],"info_count":1,"content":"2023-11-10~2024-04-30 / Web Solution / H05-IT-04 / 충청내륙고속화도로 경쟁합사"},{"member_no":"M20214","member_name":"김동원","member_grade":"대리","entry_date":"2020-07-01","leave_date":"2023-03-07","contents":["2022-06-27~2023-01-20 / 동부 간선 지하화(영동대로)건설공사 기본설계 / H22-제안-21 / 동부간선도로 지하화(영동대로) 기본설계 기술제안입찰 합사"],"info_count":1,"content":"2022-06-27~2023-01-20 / 동부 간선 지하화(영동대로)건설공사 기본설계 / H22-제안-21 / 동부간선도로 지하화(영동대로) 기본설계 기술제안입찰 합사"},{"member_no":"M20321","member_name":"박선우","member_grade":"대리","entry_date":"2020-09-01","leave_date":"","contents":["2024-07-02 / 도림천 일대 대심도 빗물배수터널 건설공사 기본설계용역(주설계) / H24-제안-23 / [과업구간]합사 현장확인"],"info_count":1,"content":"2024-07-02 / 도림천 일대 대심도 빗물배수터널 건설공사 기본설계용역(주설계) / H24-제안-23 / [과업구간]합사 현장확인"},{"member_no":"M20327","member_name":"김태우","member_grade":"과장","entry_date":"2020-12-01","leave_date":"","contents":["2023-11-10~2024-04-30 / Web Solution / H05-IT-04 / 충청내륙고속화도로 경쟁합사"],"info_count":1,"content":"2023-11-10~2024-04-30 / Web Solution / H05-IT-04 / 충청내륙고속화도로 경쟁합사"},{"member_no":"M21434","member_name":"김승일","member_grade":"이사","entry_date":"2018-03-19","leave_date":"","contents":["2023-01-04 / 세종-청주 고속도로 건설사업 환경영향평가 용역 / H21-환경-03 / [합사사무실]방음벽 관련 회의"],"info_count":1,"content":"2023-01-04 / 세종-청주 고속도로 건설사업 환경영향평가 용역 / H21-환경-03 / [합사사무실]방음벽 관련 회의"},{"member_no":"M21458","member_name":"신현호","member_grade":"상무","entry_date":"2021-10-04","leave_date":"","contents":["2022-09-13 / 송산그린시티 서측지구 1단계 제2공구 실시설계 기술제안 / H22-제안-24 / [합사사무실(용산전자오피스텔)]합사 투입(컴퓨터 등 셋팅)"],"info_count":1,"content":"2022-09-13 / 송산그린시티 서측지구 1단계 제2공구 실시설계 기술제안 / H22-제안-24 / [합사사무실(용산전자오피스텔)]합사 투입(컴퓨터 등 셋팅)"},{"member_no":"M21467","member_name":"조현성","member_grade":"대리","entry_date":"2021-11-22","leave_date":"2024-07-19","contents":["2022-09-29~2022-10-14 / 송산그린시티 도시물순환 기본계획 수립 용역(서측지구 1단계) / H22-수자-11 / [삼안]착수보고준비를위한 합사"],"info_count":1,"content":"2022-09-29~2022-10-14 / 송산그린시티 도시물순환 기본계획 수립 용역(서측지구 1단계) / H22-수자-11 / [삼안]착수보고준비를위한 합사"},{"member_no":"M22003","member_name":"안민형","member_grade":"부장","entry_date":"2022-02-08","leave_date":"","contents":["2023-11-10~2024-04-30 / Web Solution / H05-IT-04 / 충청내륙고속화도로 경쟁합사"],"info_count":1,"content":"2023-11-10~2024-04-30 / Web Solution / H05-IT-04 / 충청내륙고속화도로 경쟁합사"},{"member_no":"M22007","member_name":"정미희","member_grade":"대리","entry_date":"2005-05-18","leave_date":"","contents":["2022-09-27 / 덕산-고덕IC 도로건설공사 건설사업관리용역 / H15-감리-03 / [발안-남양 합사]덕산-고덕 준공검사조서 기술지원기술인 검토 확인"],"info_count":1,"content":"2022-09-27 / 덕산-고덕IC 도로건설공사 건설사업관리용역 / H15-감리-03 / [발안-남양 합사]덕산-고덕 준공검사조서 기술지원기술인 검토 확인"},{"member_no":"M22031","member_name":"이환섭","member_grade":"부사장","entry_date":"2022-07-06","leave_date":"","contents":["2023-11-10~2024-04-30 / Web Solution / H05-IT-04 / 충청내륙고속화도로 경쟁합사"],"info_count":1,"content":"2023-11-10~2024-04-30 / Web Solution / H05-IT-04 / 충청내륙고속화도로 경쟁합사"},{"member_no":"M22035","member_name":"김창섭","member_grade":"이사","entry_date":"2022-06-01","leave_date":"","contents":["2023-11-10~2024-04-30 / Web Solution / H05-IT-04 / 충청내륙고속화도로 경쟁합사"],"info_count":1,"content":"2023-11-10~2024-04-30 / Web Solution / H05-IT-04 / 충청내륙고속화도로 경쟁합사"},{"member_no":"M22037","member_name":"박현수A","member_grade":"과장","entry_date":"2022-06-07","leave_date":"2023-09-30","contents":["2022-06-27~2023-01-20 / 동부 간선 지하화(영동대로)건설공사 기본설계 / H22-제안-21 / 동부간선도로 지하화(영동대로) 기본설계 기술제안입찰 합사"],"info_count":1,"content":"2022-06-27~2023-01-20 / 동부 간선 지하화(영동대로)건설공사 기본설계 / H22-제안-21 / 동부간선도로 지하화(영동대로) 기본설계 기술제안입찰 합사"},{"member_no":"M22055","member_name":"손지민","member_grade":"사원","entry_date":"2022-08-22","leave_date":"","contents":["2023-06-16 / 용인-안성 고속화도로 민간투자사업 / H23-제안-01 / [평촌(안양)]용인-안성 고속화도로 민간투자사업 합사 철수"],"info_count":1,"content":"2023-06-16 / 용인-안성 고속화도로 민간투자사업 / H23-제안-01 / [평촌(안양)]용인-안성 고속화도로 민간투자사업 합사 철수"},{"member_no":"M22072","member_name":"최영준","member_grade":"대리","entry_date":"2022-11-01","leave_date":"2023-04-27","contents":["2023-01-06 / 용인-안성 고속화도로 민간투자사업 / H23-제안-01 / [에프알텍 신사옥(관양동 1745-2)]합사이사"],"info_count":1,"content":"2023-01-06 / 용인-안성 고속화도로 민간투자사업 / H23-제안-01 / [에프알텍 신사옥(관양동 1745-2)]합사이사"},{"member_no":"M22074","member_name":"정도영","member_grade":"차장","entry_date":"2022-11-21","leave_date":"","contents":["2023-01-04 / 세종-청주 고속도로 건설사업 환경영향평가 용역 / H21-환경-03 / [합사사무실]방음벽 관련 회의"],"info_count":1,"content":"2023-01-04 / 세종-청주 고속도로 건설사업 환경영향평가 용역 / H21-환경-03 / [합사사무실]방음벽 관련 회의"},{"member_no":"M23005","member_name":"이교호","member_grade":"대리","entry_date":"2023-01-09","leave_date":"","contents":["2023-11-10~2024-04-30 / Web Solution / H05-IT-04 / 충청내륙고속화도로 경쟁합사"],"info_count":1,"content":"2023-11-10~2024-04-30 / Web Solution / H05-IT-04 / 충청내륙고속화도로 경쟁합사"},{"member_no":"M23015","member_name":"안홍균","member_grade":"부장","entry_date":"2023-03-01","leave_date":"2025-01-31","contents":["2024-01-08~2024-03-31 / Web Solution / H05-IT-04 / 고속국도 제25호 호남선(동광주~광산) 경쟁합사"],"info_count":1,"content":"2024-01-08~2024-03-31 / Web Solution / H05-IT-04 / 고속국도 제25호 호남선(동광주~광산) 경쟁합사"},{"member_no":"M23042","member_name":"황지만","member_grade":"과장","entry_date":"2023-07-03","leave_date":"","contents":["2024-01-08~2024-04-07 / 동광주~광산 대안제시(경쟁) / H24-제안-01 / 고속국도 제25호 호남선(동광주~광산) 경쟁합사"],"info_count":1,"content":"2024-01-08~2024-04-07 / 동광주~광산 대안제시(경쟁) / H24-제안-01 / 고속국도 제25호 호남선(동광주~광산) 경쟁합사"},{"member_no":"M23064","member_name":"진장현","member_grade":"과장","entry_date":"2023-09-04","leave_date":"","contents":["2024-01-08~2024-04-07 / 동광주~광산 대안제시(경쟁) / H24-제안-01 / 고속국도 제25호 호남선(동광주~광산) 경쟁합사"],"info_count":1,"content":"2024-01-08~2024-04-07 / 동광주~광산 대안제시(경쟁) / H24-제안-01 / 고속국도 제25호 호남선(동광주~광산) 경쟁합사"},{"member_no":"M23075","member_name":"공성빈","member_grade":"사원","entry_date":"2023-11-13","leave_date":"2025-01-31","contents":["2024-07-02 / 도림천 일대 대심도 빗물배수터널 건설공사 기본설계용역(주설계) / H24-제안-23 / [과업구간]합사 현장확인"],"info_count":1,"content":"2024-07-02 / 도림천 일대 대심도 빗물배수터널 건설공사 기본설계용역(주설계) / H24-제안-23 / [과업구간]합사 현장확인"},{"member_no":"M24041","member_name":"박익수","member_grade":"부사장","entry_date":"2024-06-01","leave_date":"","contents":["2025-10-14~2025-10-15 / 2024 PQ(건설사업관리) / H24-제안-03 / [동일기술공사]대동초 감리 면접 준비(합사)"],"info_count":1,"content":"2025-10-14~2025-10-15 / 2024 PQ(건설사업관리) / H24-제안-03 / [동일기술공사]대동초 감리 면접 준비(합사)"}]},"{\"start_date\":\"2026-01-01\",\"end_date\":\"2026-05-29\",\"employment\":\"all\",\"view\":\"member\",\"include_center_member_nos\":[]}":{"cache_key":"5c069a2bbf81c71a374fa07e209af73d8c9ad1f5","start_date":"2026-01-01","end_date":"2026-05-29","employment":"all","view":"member","updated_at":"2026-06-01 23:58:34","joint_members":[{"member_no":"M16216","member_name":"이병욱","member_grade":"이사","entry_date":"2016-09-01","leave_date":"","contents":["2026-01-16 / 춘천 기업혁신파크 선도사업 조사·설계 기술용역 / H24-도시-13 / [경기도 남양주시(합사)]업무회의","2026-01-23 / 춘천 기업혁신파크 선도사업 조사·설계 기술용역 / H24-도시-13 / [합사(남양주시)]업무회의(평가서(초안) 검토의견 대응)","2026-03-31 / 춘천 기업혁신파크 선도사업 조사·설계 기술용역 / H24-도시-13 / [경기도 남양주시(합사)]업무회의"],"info_count":3,"content":"2026-01-16 / 춘천 기업혁신파크 선도사업 조사·설계 기술용역 / H24-도시-13 / [경기도 남양주시(합사)]업무회의\n2026-01-23 / 춘천 기업혁신파크 선도사업 조사·설계 기술용역 / H24-도시-13 / [합사(남양주시)]업무회의(평가서(초안) 검토의견 대응)\n2026-03-31 / 춘천 기업혁신파크 선도사업 조사·설계 기술용역 / H24-도시-13 / [경기도 남양주시(합사)]업무회의"},{"member_no":"M24036","member_name":"심성보","member_grade":"이사","entry_date":"2024-05-02","leave_date":"","contents":["2026-01-13 / 도림천 일대 대심도 빗물배수터널 건설공사 실시설계용역(주설계) / H25-지반-01 / 도림천 합사(대우) 동작구청/환경유역청 하천점유 인허가 협의","2026-01-21 / 도림천 일대 대심도 빗물배수터널 건설공사 실시설계용역(주설계) / H25-지반-01 / [서울시 동작구청/대우합사]PJ-도림천 일대 대심도 빗물배수터널 건설공사 실시설계 관계기관 협의(동작구청)","2026-03-13 / 도림천 일대 대심도 빗물배수터널 건설공사 실시설계용역(주설계) / H25-지반-01 / [대우합사/도기본]공사중 변경설계(추가과업) 협의(견적 진행중)"],"info_count":3,"content":"2026-01-13 / 도림천 일대 대심도 빗물배수터널 건설공사 실시설계용역(주설계) / H25-지반-01 / 도림천 합사(대우) 동작구청/환경유역청 하천점유 인허가 협의\n2026-01-21 / 도림천 일대 대심도 빗물배수터널 건설공사 실시설계용역(주설계) / H25-지반-01 / [서울시 동작구청/대우합사]PJ-도림천 일대 대심도 빗물배수터널 건설공사 실시설계 관계기관 협의(동작구청)\n2026-03-13 / 도림천 일대 대심도 빗물배수터널 건설공사 실시설계용역(주설계) / H25-지반-01 / [대우합사/도기본]공사중 변경설계(추가과업) 협의(견적 진행중)"},{"member_no":"M20101","member_name":"이병도","member_grade":"전무","entry_date":"2020-01-01","leave_date":"","contents":["2026-01-16 / 춘천 기업혁신파크 선도사업 조사·설계 기술용역 / H24-도시-13 / [경기도 남양주시(합사)]업무회의","2026-01-23 / 춘천 기업혁신파크 선도사업 조사·설계 기술용역 / H24-도시-13 / [합사(남양주시)]업무회의(평가서(초안) 검토의견 대응)"],"info_count":2,"content":"2026-01-16 / 춘천 기업혁신파크 선도사업 조사·설계 기술용역 / H24-도시-13 / [경기도 남양주시(합사)]업무회의\n2026-01-23 / 춘천 기업혁신파크 선도사업 조사·설계 기술용역 / H24-도시-13 / [합사(남양주시)]업무회의(평가서(초안) 검토의견 대응)"},{"member_no":"M21432","member_name":"최승범","member_grade":"대리","entry_date":"2021-07-01","leave_date":"","contents":["2026-01-15 / 계양~강화 고속도로 건설공사(제7공구)실시설계용역(2.4 도로공 설계) / H25-고속-01 / [서울시 동작구 동작대로 43 4층 합사]합사 철수","2026-02-13 / 계양~강화 고속도로 건설공사(제7공구)실시설계용역(2.4 도로공 설계) / H25-고속-01 / [서울시 동작구 동작대로 43 6층 합사]최종 성과품 1식 자료 백업"],"info_count":2,"content":"2026-01-15 / 계양~강화 고속도로 건설공사(제7공구)실시설계용역(2.4 도로공 설계) / H25-고속-01 / [서울시 동작구 동작대로 43 4층 합사]합사 철수\n2026-02-13 / 계양~강화 고속도로 건설공사(제7공구)실시설계용역(2.4 도로공 설계) / H25-고속-01 / [서울시 동작구 동작대로 43 6층 합사]최종 성과품 1식 자료 백업"},{"member_no":"M05207","member_name":"이찬우","member_grade":"전무","entry_date":"2005-04-18","leave_date":"","contents":["2026-03-31 / 춘천 기업혁신파크 선도사업 조사·설계 기술용역 / H24-도시-13 / [경기도 남양주시(합사)]업무회의"],"info_count":1,"content":"2026-03-31 / 춘천 기업혁신파크 선도사업 조사·설계 기술용역 / H24-도시-13 / [경기도 남양주시(합사)]업무회의"},{"member_no":"M25057","member_name":"조현경","member_grade":"차장","entry_date":"2025-09-08","leave_date":"2026-05-06","contents":["2026-03-16~2026-04-10 / Web Solution / H05-IT-04 / 청양군 노후상수도 합사"],"info_count":1,"content":"2026-03-16~2026-04-10 / Web Solution / H05-IT-04 / 청양군 노후상수도 합사"}]},"{\"start_date\":\"2022-01-03\",\"end_date\":\"2022-12-31\",\"employment\":\"all\",\"view\":\"member\",\"include_center_member_nos\":[]}":{"cache_key":"6e4befbb8be7402661e58bb21a729c54282733ee","start_date":"2022-01-03","end_date":"2022-12-31","employment":"all","view":"member","updated_at":"2026-06-01 23:57:59","joint_members":[{"member_no":"M03216","member_name":"임재평","member_grade":"상무","entry_date":"2003-03-10","leave_date":"","contents":["2022-01-03~2022-02-28 / Web Solution / H05-IT-04 / 동부간선도로 지하화 기본설계 합사파견","2022-04-06 / 발안~남양 고속화도로 민간투자사업 실시설계 / H21-고속-08 / 발안~남양합사 업무협의","2022-11-29 / 발안~남양 고속화도로 민간투자사업 실시설계 / H21-고속-08 / 발안합사(특정공법 등 협의)","2022-07-18 / 발안~남양 고속화도로 민간투자사업 실시설계 / H21-고속-08 / 발안합사(공정회의 외)","2022-09-05 / 발안~남양 고속화도로 민간투자사업 실시설계 / H21-고속-08 / 합사(공정 및 지반분야 회의)","2022-09-05 / 발안~남양 고속화도로 민간투자사업 실시설계 / H21-고속-08 / 경유(발안합사(공정 및 지반분야 회의))","2022-09-26 / 발안~남양 고속화도로 민간투자사업 실시설계 / H21-고속-08 / 발안합사(공정 및 업무회의)","2022-10-11 / 발안~남양 고속화도로 민간투자사업 실시설계 / H21-고속-08 / 발안남양합사(공정회의 및 업무협의)","2022-10-17 / 발안~남양 고속화도로 민간투자사업 실시설계 / H21-고속-08 / 발안합사(공정회의 및 업무)","2022-10-24 / 발안~남양 고속화도로 민간투자사업 실시설계 / H21-고속-08 / 발안합사 공정회의 및 업무협의","2022-10-31 / 발안~남양 고속화도로 민간투자사업 실시설계 / H21-고속-08 / 발안남양합사(공정 및 업무회의)","2022-11-09~2022-11-10 / 발안~남양 고속화도로 민간투자사업 실시설계 / H21-고속-08 / [하남 합사]업무 및 협의","2022-11-14 / 발안~남양 고속화도로 민간투자사업 실시설계 / H21-고속-08 / 발안남양 합사업무 및 회의","2022-11-28 / 발안~남양 고속화도로 민간투자사업 실시설계 / H21-고속-08 / 발안합사(업무협의)","2022-12-05 / 발안~남양 고속화도로 민간투자사업 실시설계 / H21-고속-08 / 발안합사(공정 및 업무회의)","2022-12-19 / 발안~남양 고속화도로 민간투자사업 실시설계 / H21-고속-08 / 발안남양합사(공정 및 업무회의)","2022-12-26 / 발안~남양 고속화도로 민간투자사업 실시설계 / H21-고속-08 / 발안남양 합사(공정호의 및 업무)"],"info_count":17,"content":"2022-01-03~2022-02-28 / Web Solution / H05-IT-04 / 동부간선도로 지하화 기본설계 합사파견\n2022-04-06 / 발안~남양 고속화도로 민간투자사업 실시설계 / H21-고속-08 / 발안~남양합사 업무협의\n2022-11-29 / 발안~남양 고속화도로 민간투자사업 실시설계 / H21-고속-08 / 발안합사(특정공법 등 협의)\n2022-07-18 / 발안~남양 고속화도로 민간투자사업 실시설계 / H21-고속-08 / 발안합사(공정회의 외)\n2022-09-05 / 발안~남양 고속화도로 민간투자사업 실시설계 / H21-고속-08 / 합사(공정 및 지반분야 회의)\n2022-09-05 / 발안~남양 고속화도로 민간투자사업 실시설계 / H21-고속-08 / 경유(발안합사(공정 및 지반분야 회의))\n2022-09-26 / 발안~남양 고속화도로 민간투자사업 실시설계 / H21-고속-08 / 발안합사(공정 및 업무회의)\n2022-10-11 / 발안~남양 고속화도로 민간투자사업 실시설계 / H21-고속-08 / 발안남양합사(공정회의 및 업무협의)\n2022-10-17 / 발안~남양 고속화도로 민간투자사업 실시설계 / H21-고속-08 / 발안합사(공정회의 및 업무)\n2022-10-24 / 발안~남양 고속화도로 민간투자사업 실시설계 / H21-고속-08 / 발안합사 공정회의 및 업무협의\n2022-10-31 / 발안~남양 고속화도로 민간투자사업 실시설계 / H21-고속-08 / 발안남양합사(공정 및 업무회의)\n2022-11-09~2022-11-10 / 발안~남양 고속화도로 민간투자사업 실시설계 / H21-고속-08 / [하남 합사]업무 및 협의\n2022-11-14 / 발안~남양 고속화도로 민간투자사업 실시설계 / H21-고속-08 / 발안남양 합사업무 및 회의\n2022-11-28 / 발안~남양 고속화도로 민간투자사업 실시설계 / H21-고속-08 / 발안합사(업무협의)\n2022-12-05 / 발안~남양 고속화도로 민간투자사업 실시설계 / H21-고속-08 / 발안합사(공정 및 업무회의)\n2022-12-19 / 발안~남양 고속화도로 민간투자사업 실시설계 / H21-고속-08 / 발안남양합사(공정 및 업무회의)\n2022-12-26 / 발안~남양 고속화도로 민간투자사업 실시설계 / H21-고속-08 / 발안남양 합사(공정호의 및 업무)"},{"member_no":"M20105","member_name":"이한민","member_grade":"상무","entry_date":"2020-06-01","leave_date":"","contents":["2022-01-03~2022-02-28 / Web Solution / H05-IT-04 / 동부간선도로 지하화 기본설계 합사파견","2022-09-02 / 울산외곽순환 고속도로 건설공사 기본 및 실시설계(1공구) / H21-고속-01 / 울산합사(갱구형식 및 위치 비교안보고)","2022-09-16 / 울산외곽순환 고속도로 건설공사 기본 및 실시설계(1공구) / H21-고속-01 / 울산합사(근접터널 및 피난연결통로 방침보고)","2022-09-16 / 금강수계 / H06-진단-13 / 경유(합사근무(방침보고 및 업무협의))","2022-09-23 / 울산외곽순환 고속도로 건설공사 기본 및 실시설계(1공구) / H21-고속-01 / [문정동 KG타워(울산외곽합사)]감독합사상주(매주 금요일)에 따른 합사근무 및 업무협의","2022-09-28 / 울산외곽순환 고속도로 건설공사 기본 및 실시설계(1공구) / H21-고속-01 / 세종청주합사(특정공법회의)","2022-09-30 / 울산외곽순환 고속도로 건설공사 기본 및 실시설계(1공구) / H21-고속-01 / [KG타워(울산외곽합사)]감독합사상주(매주 금요일)에 따른 합사근무 및 업무협의","2022-10-07 / 울산외곽순환 고속도로 건설공사 기본 및 실시설계(1공구) / H21-고속-01 / [문정동 KG타워(울산외곽합사)]감독합사상주(매주 금요일)에 따른 합사근무 및 업무협의","2022-10-14 / 울산외곽순환 고속도로 건설공사 기본 및 실시설계(1공구) / H21-고속-01 / [문정동KG타워(울산외곽합사)]감독합사상주(매주금요일)에 따른 합사근무 및 업무협의","2022-10-21 / 울산외곽순환 고속도로 건설공사 기본 및 실시설계(1공구) / H21-고속-01 / [문정동KG타워(울산외곽합사)]감독합사상주(매주금요일)에 따른 합사근무 및 업무협의","2022-10-28 / 울산외곽순환 고속도로 건설공사 기본 및 실시설계(1공구) / H21-고속-01 / [문정동KG타워(울산외곽합사)]감독합사상주(매주금요일)에 따른 합사근무 및 업무협의","2022-11-25 / 울산외곽순환 고속도로 건설공사 기본 및 실시설계(1공구) / H21-고속-01 / [문정동KG타워(울산외곽합사)]감독합사상주(매주금요일)에따른합사근무및업무협의","2022-12-02 / 울산외곽순환 고속도로 건설공사 기본 및 실시설계(1공구) / H21-고속-01 / [문정동KG타워(울산외곽합사)]감독합사상주(매주금요일)에따른합사근무및업무협의","2022-12-09 / 울산외곽순환 고속도로 건설공사 기본 및 실시설계(1공구) / H21-고속-01 / [문정동KG타워(울산외곽합사)]감독합사상주(매주금요일)에따른합사근무및업무협의","2022-12-16 / 울산외곽순환 고속도로 건설공사 기본 및 실시설계(1공구) / H21-고속-01 / [문정동KG타워(울산외곽합사)]감독합사상주(매주금요일)에따른합사근무및업무협의","2022-12-23 / 울산외곽순환 고속도로 건설공사 기본 및 실시설계(1공구) / H21-고속-01 / [문정동KG타워(울산외곽합사)]감독합사상주(매주금요일)에따른합사근무및업무협의"],"info_count":16,"content":"2022-01-03~2022-02-28 / Web Solution / H05-IT-04 / 동부간선도로 지하화 기본설계 합사파견\n2022-09-02 / 울산외곽순환 고속도로 건설공사 기본 및 실시설계(1공구) / H21-고속-01 / 울산합사(갱구형식 및 위치 비교안보고)\n2022-09-16 / 울산외곽순환 고속도로 건설공사 기본 및 실시설계(1공구) / H21-고속-01 / 울산합사(근접터널 및 피난연결통로 방침보고)\n2022-09-16 / 금강수계 / H06-진단-13 / 경유(합사근무(방침보고 및 업무협의))\n2022-09-23 / 울산외곽순환 고속도로 건설공사 기본 및 실시설계(1공구) / H21-고속-01 / [문정동 KG타워(울산외곽합사)]감독합사상주(매주 금요일)에 따른 합사근무 및 업무협의\n2022-09-28 / 울산외곽순환 고속도로 건설공사 기본 및 실시설계(1공구) / H21-고속-01 / 세종청주합사(특정공법회의)\n2022-09-30 / 울산외곽순환 고속도로 건설공사 기본 및 실시설계(1공구) / H21-고속-01 / [KG타워(울산외곽합사)]감독합사상주(매주 금요일)에 따른 합사근무 및 업무협의\n2022-10-07 / 울산외곽순환 고속도로 건설공사 기본 및 실시설계(1공구) / H21-고속-01 / [문정동 KG타워(울산외곽합사)]감독합사상주(매주 금요일)에 따른 합사근무 및 업무협의\n2022-10-14 / 울산외곽순환 고속도로 건설공사 기본 및 실시설계(1공구) / H21-고속-01 / [문정동KG타워(울산외곽합사)]감독합사상주(매주금요일)에 따른 합사근무 및 업무협의\n2022-10-21 / 울산외곽순환 고속도로 건설공사 기본 및 실시설계(1공구) / H21-고속-01 / [문정동KG타워(울산외곽합사)]감독합사상주(매주금요일)에 따른 합사근무 및 업무협의\n2022-10-28 / 울산외곽순환 고속도로 건설공사 기본 및 실시설계(1공구) / H21-고속-01 / [문정동KG타워(울산외곽합사)]감독합사상주(매주금요일)에 따른 합사근무 및 업무협의\n2022-11-25 / 울산외곽순환 고속도로 건설공사 기본 및 실시설계(1공구) / H21-고속-01 / [문정동KG타워(울산외곽합사)]감독합사상주(매주금요일)에따른합사근무및업무협의\n2022-12-02 / 울산외곽순환 고속도로 건설공사 기본 및 실시설계(1공구) / H21-고속-01 / [문정동KG타워(울산외곽합사)]감독합사상주(매주금요일)에따른합사근무및업무협의\n2022-12-09 / 울산외곽순환 고속도로 건설공사 기본 및 실시설계(1공구) / H21-고속-01 / [문정동KG타워(울산외곽합사)]감독합사상주(매주금요일)에따른합사근무및업무협의\n2022-12-16 / 울산외곽순환 고속도로 건설공사 기본 및 실시설계(1공구) / H21-고속-01 / [문정동KG타워(울산외곽합사)]감독합사상주(매주금요일)에따른합사근무및업무협의\n2022-12-23 / 울산외곽순환 고속도로 건설공사 기본 및 실시설계(1공구) / H21-고속-01 / [문정동KG타워(울산외곽합사)]감독합사상주(매주금요일)에따른합사근무및업무협의"},{"member_no":"M21479","member_name":"조영주","member_grade":"차장","entry_date":"2022-03-01","leave_date":"2023-08-27","contents":["2022-07-13 / 울산외곽순환 고속도로 건설공사 기본 및 실시설계(1공구) / H21-고속-01 / 경유(울산외곽순환 합사 근무)","2022-09-02 / 울산외곽순환 고속도로 건설공사 기본 및 실시설계(1공구) / H21-고속-01 / 경유(울산합사(갱구형식 및 위치 비교안보고))","2022-09-23 / 울산외곽순환 고속도로 건설공사 기본 및 실시설계(1공구) / H21-고속-01 / [문정동 KG타워(울산외곽합사)]감독합사상주(매주 금요일)에 따른 합사근무 및 업무협의","2022-09-30 / 울산외곽순환 고속도로 건설공사 기본 및 실시설계(1공구) / H21-고속-01 / [KG타워(울산외곽합사)]감독합사상주(매주 금요일)에 따른 합사근무 및 업무협의","2022-10-07 / 울산외곽순환 고속도로 건설공사 기본 및 실시설계(1공구) / H21-고속-01 / [문정동 KG타워(울산외곽합사)]감독합사상주(매주 금요일)에 따른 합사근무 및 업무협의","2022-10-14 / 울산외곽순환 고속도로 건설공사 기본 및 실시설계(1공구) / H21-고속-01 / [문정동KG타워(울산외곽합사)]감독합사상주(매주금요일)에 따른 합사근무 및 업무협의","2022-10-21 / 울산외곽순환 고속도로 건설공사 기본 및 실시설계(1공구) / H21-고속-01 / [문정동KG타워(울산외곽합사)]감독합사상주(매주금요일)에 따른 합사근무 및 업무협의","2022-10-28 / 울산외곽순환 고속도로 건설공사 기본 및 실시설계(1공구) / H21-고속-01 / [문정동KG타워(울산외곽합사)]감독합사상주(매주금요일)에 따른 합사근무 및 업무협의","2022-11-04 / 울산외곽순환 고속도로 건설공사 기본 및 실시설계(1공구) / H21-고속-01 / [문정동KG타워(울산외곽합사)]감독합사상주(매주금요일)에따른합사근무및업무협의","2022-11-11 / 울산외곽순환 고속도로 건설공사 기본 및 실시설계(1공구) / H21-고속-01 / [문정동KG타워(울산외곽합사)]감독합사상주(매주금요일)에따른합사근무및업무협의","2022-11-18 / 울산외곽순환 고속도로 건설공사 기본 및 실시설계(1공구) / H21-고속-01 / [문정동KG타워(울산외곽합사)]감독합사상주(매주금요일)에따른합사근무및업무협의","2022-11-25 / 울산외곽순환 고속도로 건설공사 기본 및 실시설계(1공구) / H21-고속-01 / [문정동KG타워(울산외곽합사)]감독합사상주(매주금요일)에따른합사근무및업무협의","2022-12-02 / 울산외곽순환 고속도로 건설공사 기본 및 실시설계(1공구) / H21-고속-01 / [문정동KG타워(울산외곽합사)]감독합사상주(매주금요일)에따른합사근무및업무협의","2022-12-09 / 울산외곽순환 고속도로 건설공사 기본 및 실시설계(1공구) / H21-고속-01 / [문정동KG타워(울산외곽합사)]감독합사상주(매주금요일)에따른합사근무및업무협의","2022-12-16 / 울산외곽순환 고속도로 건설공사 기본 및 실시설계(1공구) / H21-고속-01 / [문정동KG타워(울산외곽합사)]감독합사상주(매주금요일)에따른합사근무및업무협의","2022-12-23 / 울산외곽순환 고속도로 건설공사 기본 및 실시설계(1공구) / H21-고속-01 / [문정동KG타워(울산외곽합사)]감독합사상주(매주금요일)에따른합사근무및업무협의"],"info_count":16,"content":"2022-07-13 / 울산외곽순환 고속도로 건설공사 기본 및 실시설계(1공구) / H21-고속-01 / 경유(울산외곽순환 합사 근무)\n2022-09-02 / 울산외곽순환 고속도로 건설공사 기본 및 실시설계(1공구) / H21-고속-01 / 경유(울산합사(갱구형식 및 위치 비교안보고))\n2022-09-23 / 울산외곽순환 고속도로 건설공사 기본 및 실시설계(1공구) / H21-고속-01 / [문정동 KG타워(울산외곽합사)]감독합사상주(매주 금요일)에 따른 합사근무 및 업무협의\n2022-09-30 / 울산외곽순환 고속도로 건설공사 기본 및 실시설계(1공구) / H21-고속-01 / [KG타워(울산외곽합사)]감독합사상주(매주 금요일)에 따른 합사근무 및 업무협의\n2022-10-07 / 울산외곽순환 고속도로 건설공사 기본 및 실시설계(1공구) / H21-고속-01 / [문정동 KG타워(울산외곽합사)]감독합사상주(매주 금요일)에 따른 합사근무 및 업무협의\n2022-10-14 / 울산외곽순환 고속도로 건설공사 기본 및 실시설계(1공구) / H21-고속-01 / [문정동KG타워(울산외곽합사)]감독합사상주(매주금요일)에 따른 합사근무 및 업무협의\n2022-10-21 / 울산외곽순환 고속도로 건설공사 기본 및 실시설계(1공구) / H21-고속-01 / [문정동KG타워(울산외곽합사)]감독합사상주(매주금요일)에 따른 합사근무 및 업무협의\n2022-10-28 / 울산외곽순환 고속도로 건설공사 기본 및 실시설계(1공구) / H21-고속-01 / [문정동KG타워(울산외곽합사)]감독합사상주(매주금요일)에 따른 합사근무 및 업무협의\n2022-11-04 / 울산외곽순환 고속도로 건설공사 기본 및 실시설계(1공구) / H21-고속-01 / [문정동KG타워(울산외곽합사)]감독합사상주(매주금요일)에따른합사근무및업무협의\n2022-11-11 / 울산외곽순환 고속도로 건설공사 기본 및 실시설계(1공구) / H21-고속-01 / [문정동KG타워(울산외곽합사)]감독합사상주(매주금요일)에따른합사근무및업무협의\n2022-11-18 / 울산외곽순환 고속도로 건설공사 기본 및 실시설계(1공구) / H21-고속-01 / [문정동KG타워(울산외곽합사)]감독합사상주(매주금요일)에따른합사근무및업무협의\n2022-11-25 / 울산외곽순환 고속도로 건설공사 기본 및 실시설계(1공구) / H21-고속-01 / [문정동KG타워(울산외곽합사)]감독합사상주(매주금요일)에따른합사근무및업무협의\n2022-12-02 / 울산외곽순환 고속도로 건설공사 기본 및 실시설계(1공구) / H21-고속-01 / [문정동KG타워(울산외곽합사)]감독합사상주(매주금요일)에따른합사근무및업무협의\n2022-12-09 / 울산외곽순환 고속도로 건설공사 기본 및 실시설계(1공구) / H21-고속-01 / [문정동KG타워(울산외곽합사)]감독합사상주(매주금요일)에따른합사근무및업무협의\n2022-12-16 / 울산외곽순환 고속도로 건설공사 기본 및 실시설계(1공구) / H21-고속-01 / [문정동KG타워(울산외곽합사)]감독합사상주(매주금요일)에따른합사근무및업무협의\n2022-12-23 / 울산외곽순환 고속도로 건설공사 기본 및 실시설계(1공구) / H21-고속-01 / [문정동KG타워(울산외곽합사)]감독합사상주(매주금요일)에따른합사근무및업무협의"},{"member_no":"M10103","member_name":"윤성호","member_grade":"부사장","entry_date":"2019-01-01","leave_date":"","contents":["2022-10-14 / 동부 간선 지하화(영동대로)건설공사 기본설계 / H22-제안-21 / 설계자문회의(합사)","2022-12-13 / 개봉고가 성능개선공사외 1개소 감독권한대행 등 건설사업관리용역 / H19-감리-04 / 동부간선합사 및 개봉고가 현장","2022-12-19 / 동부간선 지하화(영동대로)건설공사 기본설계 기술제안 입찰(제안)설계 / H22-구조-07 / 기술제안 합사","2022-12-28~2022-12-31 / 동부간선 지하화(영동대로)건설공사 기본설계 기술제안 입찰(제안)설계 / H22-구조-07 / 동부간선 양재합사"],"info_count":4,"content":"2022-10-14 / 동부 간선 지하화(영동대로)건설공사 기본설계 / H22-제안-21 / 설계자문회의(합사)\n2022-12-13 / 개봉고가 성능개선공사외 1개소 감독권한대행 등 건설사업관리용역 / H19-감리-04 / 동부간선합사 및 개봉고가 현장\n2022-12-19 / 동부간선 지하화(영동대로)건설공사 기본설계 기술제안 입찰(제안)설계 / H22-구조-07 / 기술제안 합사\n2022-12-28~2022-12-31 / 동부간선 지하화(영동대로)건설공사 기본설계 기술제안 입찰(제안)설계 / H22-구조-07 / 동부간선 양재합사"},{"member_no":"M13303","member_name":"민경선","member_grade":"차장","entry_date":"2013-03-18","leave_date":"2024-05-19","contents":["2022-09-15 / Web Solution / H05-IT-04 / 연차(합사투입으로 인한 연차소진)","2022-09-21 / Web Solution / H05-IT-04 / 연차(합사투입으로 인한 연차소진)","2022-09-26 / Web Solution / H05-IT-04 / 연차(합사투입으로 인한 연차소진)"],"info_count":3,"content":"2022-09-15 / Web Solution / H05-IT-04 / 연차(합사투입으로 인한 연차소진)\n2022-09-21 / Web Solution / H05-IT-04 / 연차(합사투입으로 인한 연차소진)\n2022-09-26 / Web Solution / H05-IT-04 / 연차(합사투입으로 인한 연차소진)"},{"member_no":"B21335","member_name":"이동원A","member_grade":"상무","entry_date":"2018-10-01","leave_date":"2023-02-28","contents":["2022-06-27~2022-12-31 / 동부 간선 지하화(영동대로)건설공사 기본설계 / H22-제안-21 / 동부간선도로 지하화(영동대로) 기본설계 기술제안입찰 합사"],"info_count":1,"content":"2022-06-27~2022-12-31 / 동부 간선 지하화(영동대로)건설공사 기본설계 / H22-제안-21 / 동부간선도로 지하화(영동대로) 기본설계 기술제안입찰 합사"},{"member_no":"M02308","member_name":"황승현","member_grade":"상무","entry_date":"2002-08-26","leave_date":"","contents":["2022-12-01~2022-12-20 / 동부간선 지하화(영동대로)건설공사 기본설계 기술제안 입찰(제안)설계 / H22-구조-07 / 동부간선도로 지하화(영동대로) 기본설계 기술제안입찰 합사"],"info_count":1,"content":"2022-12-01~2022-12-20 / 동부간선 지하화(영동대로)건설공사 기본설계 기술제안 입찰(제안)설계 / H22-구조-07 / 동부간선도로 지하화(영동대로) 기본설계 기술제안입찰 합사"},{"member_no":"M03211","member_name":"박성웅","member_grade":"전무","entry_date":"2003-02-05","leave_date":"2026-03-31","contents":["2022-11-10 / 발안~남양 고속화도로 민간투자사업 실시설계 / H21-고속-08 / [화성시, 합사(하남)]민투심의 관련 업무협의"],"info_count":1,"content":"2022-11-10 / 발안~남양 고속화도로 민간투자사업 실시설계 / H21-고속-08 / [화성시, 합사(하남)]민투심의 관련 업무협의"},{"member_no":"M07238","member_name":"곽태신","member_grade":"이사","entry_date":"2007-12-10","leave_date":"2024-03-31","contents":["2022-01-03~2022-07-31 / Web Solution / H05-IT-04 / 국제교류 복합지구 합사 파견"],"info_count":1,"content":"2022-01-03~2022-07-31 / Web Solution / H05-IT-04 / 국제교류 복합지구 합사 파견"},{"member_no":"M09301","member_name":"성열은","member_grade":"부장","entry_date":"2009-01-05","leave_date":"2024-03-31","contents":["2022-06-27~2022-12-31 / 동부 간선 지하화(영동대로)건설공사 기본설계 / H22-제안-21 / 동부간선도로 지하화(영동대로) 기본설계 기술제안입찰 합사"],"info_count":1,"content":"2022-06-27~2022-12-31 / 동부 간선 지하화(영동대로)건설공사 기본설계 / H22-제안-21 / 동부간선도로 지하화(영동대로) 기본설계 기술제안입찰 합사"},{"member_no":"M16208","member_name":"신영각","member_grade":"부사장","entry_date":"2016-07-04","leave_date":"","contents":["2022-10-14 / 동부 간선 지하화(영동대로)건설공사 기본설계 / H22-제안-21 / 설계자문회의(합사)"],"info_count":1,"content":"2022-10-14 / 동부 간선 지하화(영동대로)건설공사 기본설계 / H22-제안-21 / 설계자문회의(합사)"},{"member_no":"M16308","member_name":"유재범","member_grade":"차장","entry_date":"2016-03-01","leave_date":"","contents":["2022-09-29~2022-10-14 / 송산그린시티 도시물순환 기본계획 수립 용역(서측지구 1단계) / H22-수자-11 / [삼안]착수보고준비를위한 합사"],"info_count":1,"content":"2022-09-29~2022-10-14 / 송산그린시티 도시물순환 기본계획 수립 용역(서측지구 1단계) / H22-수자-11 / [삼안]착수보고준비를위한 합사"},{"member_no":"M17304","member_name":"최락준","member_grade":"과장","entry_date":"2017-02-01","leave_date":"2024-04-30","contents":["2022-06-27~2022-12-31 / 동부 간선 지하화(영동대로)건설공사 기본설계 / H22-제안-21 / 동부간선도로 지하화(영동대로) 기본설계 기술제안입찰 합사"],"info_count":1,"content":"2022-06-27~2022-12-31 / 동부 간선 지하화(영동대로)건설공사 기본설계 / H22-제안-21 / 동부간선도로 지하화(영동대로) 기본설계 기술제안입찰 합사"},{"member_no":"M20213","member_name":"임원석","member_grade":"차장","entry_date":"2020-07-01","leave_date":"2024-05-31","contents":["2022-01-03~2022-02-28 / Web Solution / H05-IT-04 / 동부간선도로 지하화 기본설계 합사파견"],"info_count":1,"content":"2022-01-03~2022-02-28 / Web Solution / H05-IT-04 / 동부간선도로 지하화 기본설계 합사파견"},{"member_no":"M20214","member_name":"김동원","member_grade":"대리","entry_date":"2020-07-01","leave_date":"2023-03-07","contents":["2022-06-27~2022-12-31 / 동부 간선 지하화(영동대로)건설공사 기본설계 / H22-제안-21 / 동부간선도로 지하화(영동대로) 기본설계 기술제안입찰 합사"],"info_count":1,"content":"2022-06-27~2022-12-31 / 동부 간선 지하화(영동대로)건설공사 기본설계 / H22-제안-21 / 동부간선도로 지하화(영동대로) 기본설계 기술제안입찰 합사"},{"member_no":"M20217","member_name":"길이원","member_grade":"차장","entry_date":"2020-10-05","leave_date":"","contents":["2022-01-03~2022-12-16 / Web Solution / H05-IT-04 / 공주시 지방상수도 현대화사업 기본및실시설계 합사"],"info_count":1,"content":"2022-01-03~2022-12-16 / Web Solution / H05-IT-04 / 공주시 지방상수도 현대화사업 기본및실시설계 합사"},{"member_no":"M21413","member_name":"김춘근","member_grade":"전무","entry_date":"2021-03-26","leave_date":"","contents":["2022-09-13 / 송산그린시티 서측지구 1단계 제2공구 실시설계 기술제안 / H22-제안-24 / [합사사무실(용산전자오피스텔)]합사 투입(컴퓨터 등 셋팅)"],"info_count":1,"content":"2022-09-13 / 송산그린시티 서측지구 1단계 제2공구 실시설계 기술제안 / H22-제안-24 / [합사사무실(용산전자오피스텔)]합사 투입(컴퓨터 등 셋팅)"},{"member_no":"M21432","member_name":"최승범","member_grade":"대리","entry_date":"2021-07-01","leave_date":"","contents":["2022-11-09 / 발안~남양 고속화도로 민간투자사업 실시설계 / H21-고속-08 / 경유(자료전달(합사관련 협의자료))"],"info_count":1,"content":"2022-11-09 / 발안~남양 고속화도로 민간투자사업 실시설계 / H21-고속-08 / 경유(자료전달(합사관련 협의자료))"},{"member_no":"M21458","member_name":"신현호","member_grade":"상무","entry_date":"2021-10-04","leave_date":"","contents":["2022-09-13 / 송산그린시티 서측지구 1단계 제2공구 실시설계 기술제안 / H22-제안-24 / [합사사무실(용산전자오피스텔)]합사 투입(컴퓨터 등 셋팅)"],"info_count":1,"content":"2022-09-13 / 송산그린시티 서측지구 1단계 제2공구 실시설계 기술제안 / H22-제안-24 / [합사사무실(용산전자오피스텔)]합사 투입(컴퓨터 등 셋팅)"},{"member_no":"M21467","member_name":"조현성","member_grade":"대리","entry_date":"2021-11-22","leave_date":"2024-07-19","contents":["2022-09-29~2022-10-14 / 송산그린시티 도시물순환 기본계획 수립 용역(서측지구 1단계) / H22-수자-11 / [삼안]착수보고준비를위한 합사"],"info_count":1,"content":"2022-09-29~2022-10-14 / 송산그린시티 도시물순환 기본계획 수립 용역(서측지구 1단계) / H22-수자-11 / [삼안]착수보고준비를위한 합사"},{"member_no":"M22007","member_name":"정미희","member_grade":"대리","entry_date":"2005-05-18","leave_date":"","contents":["2022-09-27 / 덕산-고덕IC 도로건설공사 건설사업관리용역 / H15-감리-03 / [발안-남양 합사]덕산-고덕 준공검사조서 기술지원기술인 검토 확인"],"info_count":1,"content":"2022-09-27 / 덕산-고덕IC 도로건설공사 건설사업관리용역 / H15-감리-03 / [발안-남양 합사]덕산-고덕 준공검사조서 기술지원기술인 검토 확인"},{"member_no":"M22037","member_name":"박현수A","member_grade":"과장","entry_date":"2022-06-07","leave_date":"2023-09-30","contents":["2022-06-27~2022-12-31 / 동부 간선 지하화(영동대로)건설공사 기본설계 / H22-제안-21 / 동부간선도로 지하화(영동대로) 기본설계 기술제안입찰 합사"],"info_count":1,"content":"2022-06-27~2022-12-31 / 동부 간선 지하화(영동대로)건설공사 기본설계 / H22-제안-21 / 동부간선도로 지하화(영동대로) 기본설계 기술제안입찰 합사"}]},"{\"start_date\":\"2023-01-01\",\"end_date\":\"2023-12-31\",\"employment\":\"all\",\"view\":\"member\",\"include_center_member_nos\":[]}":{"cache_key":"829b417101e771331f4a315a23b1377e8f309bb6","start_date":"2023-01-01","end_date":"2023-12-31","employment":"all","view":"member","updated_at":"2026-06-01 23:58:09","joint_members":[{"member_no":"M21479","member_name":"조영주","member_grade":"차장","entry_date":"2022-03-01","leave_date":"2023-08-27","contents":["2023-01-06 / 울산외곽순환 고속도로 건설공사 기본 및 실시설계(1공구) / H21-고속-01 / [문정동KG타워(울산외곽합사)]감독합사상주(매주금요일)에따른합사근무및업무협의","2023-01-13 / 울산외곽순환 고속도로 건설공사 기본 및 실시설계(1공구) / H21-고속-01 / [문정동KG타워(울산외곽합사)]감독합사상주(매주금요일)에따른합사근무및업무협의","2023-01-27 / 울산외곽순환 고속도로 건설공사 기본 및 실시설계(1공구) / H21-고속-01 / [문정동KG타워(울산외곽합사)]감독합사상주(매주금요일)에따른합사근무및업무협의","2023-02-03 / 울산외곽순환 고속도로 건설공사 기본 및 실시설계(1공구) / H21-고속-01 / [문정동KG타워(울산외곽합사)]감독합사상주(매주금요일)에따른합사근무및업무협의","2023-02-10 / 울산외곽순환 고속도로 건설공사 기본 및 실시설계(1공구) / H21-고속-01 / [문정동KG타워(울산외곽합사)]감독합사상주(매주금요일)에따른합사근무및업무협의","2023-02-17 / 울산외곽순환 고속도로 건설공사 기본 및 실시설계(1공구) / H21-고속-01 / [문정동KG타워(울산외곽합사)]감독합사상주(매주금요일)에따른합사근무및업무협의","2023-02-24 / 울산외곽순환 고속도로 건설공사 기본 및 실시설계(1공구) / H21-고속-01 / [문정동KG타워(울산외곽합사)]감독합사상주(매주금요일)에따른합사근무및업무협의","2023-03-03 / 울산외곽순환 고속도로 건설공사 기본 및 실시설계(1공구) / H21-고속-01 / [문정동KG타워(울산외곽합사)]감독합사상주(매주금요일)에따른합사근무및업무협의","2023-03-10 / 울산외곽순환 고속도로 건설공사 기본 및 실시설계(1공구) / H21-고속-01 / [문정동KG타워(울산외곽합사)]감독합사상주(매주금요일)에따른합사근무및업무협의","2023-03-17 / 울산외곽순환 고속도로 건설공사 기본 및 실시설계(1공구) / H21-고속-01 / [문정동KG타워(울산외곽합사)]감독합사상주(매주금요일)에따른합사근무및업무협의","2023-03-24 / 울산외곽순환 고속도로 건설공사 기본 및 실시설계(1공구) / H21-고속-01 / [문정동KG타워(울산외곽합사)]감독합사상주(매주금요일)에따른합사근무및업무협의","2023-03-31 / 울산외곽순환 고속도로 건설공사 기본 및 실시설계(1공구) / H21-고속-01 / [문정동KG타워(울산외곽합사)]감독합사상주(매주금요일)에따른합사근무및업무협의","2023-04-07 / 울산외곽순환 고속도로 건설공사 기본 및 실시설계(1공구) / H21-고속-01 / [문정동KG타워(울산외곽합사)]감독합사상주(매주금요일)에따른합사근무및업무협의","2023-04-14 / 울산외곽순환 고속도로 건설공사 기본 및 실시설계(1공구) / H21-고속-01 / [문정동KG타워(울산외곽합사)]감독합사상주(매주금요일)에따른합사근무및업무협의","2023-04-21 / 울산외곽순환 고속도로 건설공사 기본 및 실시설계(1공구) / H21-고속-01 / [문정동KG타워(울산외곽합사)]감독합사상주(매주금요일)에따른합사근무및업무협의","2023-04-28 / 울산외곽순환 고속도로 건설공사 기본 및 실시설계(1공구) / H21-고속-01 / [문정동KG타워(울산외곽합사)]감독합사상주(매주금요일)에따른합사근무및업무협의","2023-05-12 / 울산외곽순환 고속도로 건설공사 기본 및 실시설계(1공구) / H21-고속-01 / [문정동KG타워(울산외곽합사)]감독합사상주(매주금요일)에따른합사근무및업무협의"],"info_count":17,"content":"2023-01-06 / 울산외곽순환 고속도로 건설공사 기본 및 실시설계(1공구) / H21-고속-01 / [문정동KG타워(울산외곽합사)]감독합사상주(매주금요일)에따른합사근무및업무협의\n2023-01-13 / 울산외곽순환 고속도로 건설공사 기본 및 실시설계(1공구) / H21-고속-01 / [문정동KG타워(울산외곽합사)]감독합사상주(매주금요일)에따른합사근무및업무협의\n2023-01-27 / 울산외곽순환 고속도로 건설공사 기본 및 실시설계(1공구) / H21-고속-01 / [문정동KG타워(울산외곽합사)]감독합사상주(매주금요일)에따른합사근무및업무협의\n2023-02-03 / 울산외곽순환 고속도로 건설공사 기본 및 실시설계(1공구) / H21-고속-01 / [문정동KG타워(울산외곽합사)]감독합사상주(매주금요일)에따른합사근무및업무협의\n2023-02-10 / 울산외곽순환 고속도로 건설공사 기본 및 실시설계(1공구) / H21-고속-01 / [문정동KG타워(울산외곽합사)]감독합사상주(매주금요일)에따른합사근무및업무협의\n2023-02-17 / 울산외곽순환 고속도로 건설공사 기본 및 실시설계(1공구) / H21-고속-01 / [문정동KG타워(울산외곽합사)]감독합사상주(매주금요일)에따른합사근무및업무협의\n2023-02-24 / 울산외곽순환 고속도로 건설공사 기본 및 실시설계(1공구) / H21-고속-01 / [문정동KG타워(울산외곽합사)]감독합사상주(매주금요일)에따른합사근무및업무협의\n2023-03-03 / 울산외곽순환 고속도로 건설공사 기본 및 실시설계(1공구) / H21-고속-01 / [문정동KG타워(울산외곽합사)]감독합사상주(매주금요일)에따른합사근무및업무협의\n2023-03-10 / 울산외곽순환 고속도로 건설공사 기본 및 실시설계(1공구) / H21-고속-01 / [문정동KG타워(울산외곽합사)]감독합사상주(매주금요일)에따른합사근무및업무협의\n2023-03-17 / 울산외곽순환 고속도로 건설공사 기본 및 실시설계(1공구) / H21-고속-01 / [문정동KG타워(울산외곽합사)]감독합사상주(매주금요일)에따른합사근무및업무협의\n2023-03-24 / 울산외곽순환 고속도로 건설공사 기본 및 실시설계(1공구) / H21-고속-01 / [문정동KG타워(울산외곽합사)]감독합사상주(매주금요일)에따른합사근무및업무협의\n2023-03-31 / 울산외곽순환 고속도로 건설공사 기본 및 실시설계(1공구) / H21-고속-01 / [문정동KG타워(울산외곽합사)]감독합사상주(매주금요일)에따른합사근무및업무협의\n2023-04-07 / 울산외곽순환 고속도로 건설공사 기본 및 실시설계(1공구) / H21-고속-01 / [문정동KG타워(울산외곽합사)]감독합사상주(매주금요일)에따른합사근무및업무협의\n2023-04-14 / 울산외곽순환 고속도로 건설공사 기본 및 실시설계(1공구) / H21-고속-01 / [문정동KG타워(울산외곽합사)]감독합사상주(매주금요일)에따른합사근무및업무협의\n2023-04-21 / 울산외곽순환 고속도로 건설공사 기본 및 실시설계(1공구) / H21-고속-01 / [문정동KG타워(울산외곽합사)]감독합사상주(매주금요일)에따른합사근무및업무협의\n2023-04-28 / 울산외곽순환 고속도로 건설공사 기본 및 실시설계(1공구) / H21-고속-01 / [문정동KG타워(울산외곽합사)]감독합사상주(매주금요일)에따른합사근무및업무협의\n2023-05-12 / 울산외곽순환 고속도로 건설공사 기본 및 실시설계(1공구) / H21-고속-01 / [문정동KG타워(울산외곽합사)]감독합사상주(매주금요일)에따른합사근무및업무협의"},{"member_no":"M20105","member_name":"이한민","member_grade":"상무","entry_date":"2020-06-01","leave_date":"","contents":["2023-01-06 / 울산외곽순환 고속도로 건설공사 기본 및 실시설계(1공구) / H21-고속-01 / [문정동KG타워(울산외곽합사)]감독합사상주(매주금요일)에따른합사근무및업무협의","2023-01-13 / 울산외곽순환 고속도로 건설공사 기본 및 실시설계(1공구) / H21-고속-01 / [문정동KG타워(울산외곽합사)]감독합사상주(매주금요일)에따른합사근무및업무협의","2023-01-27 / 울산외곽순환 고속도로 건설공사 기본 및 실시설계(1공구) / H21-고속-01 / [문정동KG타워(울산외곽합사)]감독합사상주(매주금요일)에따른합사근무및업무협의","2023-02-03 / 울산외곽순환 고속도로 건설공사 기본 및 실시설계(1공구) / H21-고속-01 / [문정동KG타워(울산외곽합사)]감독합사상주(매주금요일)에따른합사근무및업무협의","2023-02-10 / 울산외곽순환 고속도로 건설공사 기본 및 실시설계(1공구) / H21-고속-01 / [문정동KG타워(울산외곽합사)]감독합사상주(매주금요일)에따른합사근무및업무협의","2023-02-17 / 울산외곽순환 고속도로 건설공사 기본 및 실시설계(1공구) / H21-고속-01 / [문정동KG타워(울산외곽합사)]감독합사상주(매주금요일)에따른합사근무및업무협의","2023-02-24 / 울산외곽순환 고속도로 건설공사 기본 및 실시설계(1공구) / H21-고속-01 / [문정동KG타워(울산외곽합사)]감독합사상주(매주금요일)에따른합사근무및업무협의","2023-03-03 / 울산외곽순환 고속도로 건설공사 기본 및 실시설계(1공구) / H21-고속-01 / [문정동KG타워(울산외곽합사)]감독합사상주(매주금요일)에따른합사근무및업무협의","2023-03-17 / 울산외곽순환 고속도로 건설공사 기본 및 실시설계(1공구) / H21-고속-01 / [문정동KG타워(울산외곽합사)]감독합사상주(매주금요일)에따른합사근무및업무협의","2023-03-24 / 울산외곽순환 고속도로 건설공사 기본 및 실시설계(1공구) / H21-고속-01 / [문정동KG타워(울산외곽합사)]감독합사상주(매주금요일)에따른합사근무및업무협의","2023-03-31 / 울산외곽순환 고속도로 건설공사 기본 및 실시설계(1공구) / H21-고속-01 / [문정동KG타워(울산외곽합사)]감독합사상주(매주금요일)에따른합사근무및업무협의","2023-04-07 / 울산외곽순환 고속도로 건설공사 기본 및 실시설계(1공구) / H21-고속-01 / [문정동KG타워(울산외곽합사)]감독합사상주(매주금요일)에따른합사근무및업무협의","2023-04-14 / 울산외곽순환 고속도로 건설공사 기본 및 실시설계(1공구) / H21-고속-01 / [문정동KG타워(울산외곽합사)]감독합사상주(매주금요일)에따른합사근무및업무협의","2023-11-10~2023-12-31 / Web Solution / H05-IT-04 / 충청내륙고속화도로 경쟁합사"],"info_count":14,"content":"2023-01-06 / 울산외곽순환 고속도로 건설공사 기본 및 실시설계(1공구) / H21-고속-01 / [문정동KG타워(울산외곽합사)]감독합사상주(매주금요일)에따른합사근무및업무협의\n2023-01-13 / 울산외곽순환 고속도로 건설공사 기본 및 실시설계(1공구) / H21-고속-01 / [문정동KG타워(울산외곽합사)]감독합사상주(매주금요일)에따른합사근무및업무협의\n2023-01-27 / 울산외곽순환 고속도로 건설공사 기본 및 실시설계(1공구) / H21-고속-01 / [문정동KG타워(울산외곽합사)]감독합사상주(매주금요일)에따른합사근무및업무협의\n2023-02-03 / 울산외곽순환 고속도로 건설공사 기본 및 실시설계(1공구) / H21-고속-01 / [문정동KG타워(울산외곽합사)]감독합사상주(매주금요일)에따른합사근무및업무협의\n2023-02-10 / 울산외곽순환 고속도로 건설공사 기본 및 실시설계(1공구) / H21-고속-01 / [문정동KG타워(울산외곽합사)]감독합사상주(매주금요일)에따른합사근무및업무협의\n2023-02-17 / 울산외곽순환 고속도로 건설공사 기본 및 실시설계(1공구) / H21-고속-01 / [문정동KG타워(울산외곽합사)]감독합사상주(매주금요일)에따른합사근무및업무협의\n2023-02-24 / 울산외곽순환 고속도로 건설공사 기본 및 실시설계(1공구) / H21-고속-01 / [문정동KG타워(울산외곽합사)]감독합사상주(매주금요일)에따른합사근무및업무협의\n2023-03-03 / 울산외곽순환 고속도로 건설공사 기본 및 실시설계(1공구) / H21-고속-01 / [문정동KG타워(울산외곽합사)]감독합사상주(매주금요일)에따른합사근무및업무협의\n2023-03-17 / 울산외곽순환 고속도로 건설공사 기본 및 실시설계(1공구) / H21-고속-01 / [문정동KG타워(울산외곽합사)]감독합사상주(매주금요일)에따른합사근무및업무협의\n2023-03-24 / 울산외곽순환 고속도로 건설공사 기본 및 실시설계(1공구) / H21-고속-01 / [문정동KG타워(울산외곽합사)]감독합사상주(매주금요일)에따른합사근무및업무협의\n2023-03-31 / 울산외곽순환 고속도로 건설공사 기본 및 실시설계(1공구) / H21-고속-01 / [문정동KG타워(울산외곽합사)]감독합사상주(매주금요일)에따른합사근무및업무협의\n2023-04-07 / 울산외곽순환 고속도로 건설공사 기본 및 실시설계(1공구) / H21-고속-01 / [문정동KG타워(울산외곽합사)]감독합사상주(매주금요일)에따른합사근무및업무협의\n2023-04-14 / 울산외곽순환 고속도로 건설공사 기본 및 실시설계(1공구) / H21-고속-01 / [문정동KG타워(울산외곽합사)]감독합사상주(매주금요일)에따른합사근무및업무협의\n2023-11-10~2023-12-31 / Web Solution / H05-IT-04 / 충청내륙고속화도로 경쟁합사"},{"member_no":"M20217","member_name":"길이원","member_grade":"차장","entry_date":"2020-10-05","leave_date":"","contents":["2023-07-04~2023-07-07 / 공주시 지방상수도 현대화사업 기본 및 실시설계 용역 / H20-상하-10 / [공주시]공주시 지방상수도 현대화사업 합사 재착수","2023-07-10~2023-07-14 / 공주시 지방상수도 현대화사업 기본 및 실시설계 용역 / H20-상하-10 / [공주시]공주시 지방상수도 현대화사업 합사 재착수","2023-07-17~2023-07-21 / 공주시 지방상수도 현대화사업 기본 및 실시설계 용역 / H20-상하-10 / [공주시]공주시 지방상수도 현대화사업 합사 재착수","2023-07-24~2023-07-28 / 공주시 지방상수도 현대화사업 기본 및 실시설계 용역 / H20-상하-10 / [공주시]공주시 지방상수도 현대화사업 합사 재착수","2023-08-09~2023-08-11 / 공주시 지방상수도 현대화사업 기본 및 실시설계 용역 / H20-상하-10 / [공주시]공주시 지방상수도 현대화사업 합사 재착수","2023-07-31~2023-08-01 / 공주시 지방상수도 현대화사업 기본 및 실시설계 용역 / H20-상하-10 / [공주시]공주시 지방상수도 현대화사업 합사 재착수","2023-08-16~2023-08-18 / 공주시 지방상수도 현대화사업 기본 및 실시설계 용역 / H20-상하-10 / [공주시]공주시 지방상수도 현대화사업 합사 재착수","2023-08-21~2023-08-25 / 공주시 지방상수도 현대화사업 기본 및 실시설계 용역 / H20-상하-10 / [공주시]공주시 지방상수도 현대화사업 합사 재착수","2023-08-28~2023-09-01 / 공주시 지방상수도 현대화사업 기본 및 실시설계 용역 / H20-상하-10 / [공주시]공주시 지방상수도 현대화사업 합사 재착수","2023-09-04~2023-09-08 / 공주시 지방상수도 현대화사업 기본 및 실시설계 용역 / H20-상하-10 / [공주시]공주시 지방상수도 현대화사업 합사 재착수"],"info_count":10,"content":"2023-07-04~2023-07-07 / 공주시 지방상수도 현대화사업 기본 및 실시설계 용역 / H20-상하-10 / [공주시]공주시 지방상수도 현대화사업 합사 재착수\n2023-07-10~2023-07-14 / 공주시 지방상수도 현대화사업 기본 및 실시설계 용역 / H20-상하-10 / [공주시]공주시 지방상수도 현대화사업 합사 재착수\n2023-07-17~2023-07-21 / 공주시 지방상수도 현대화사업 기본 및 실시설계 용역 / H20-상하-10 / [공주시]공주시 지방상수도 현대화사업 합사 재착수\n2023-07-24~2023-07-28 / 공주시 지방상수도 현대화사업 기본 및 실시설계 용역 / H20-상하-10 / [공주시]공주시 지방상수도 현대화사업 합사 재착수\n2023-08-09~2023-08-11 / 공주시 지방상수도 현대화사업 기본 및 실시설계 용역 / H20-상하-10 / [공주시]공주시 지방상수도 현대화사업 합사 재착수\n2023-07-31~2023-08-01 / 공주시 지방상수도 현대화사업 기본 및 실시설계 용역 / H20-상하-10 / [공주시]공주시 지방상수도 현대화사업 합사 재착수\n2023-08-16~2023-08-18 / 공주시 지방상수도 현대화사업 기본 및 실시설계 용역 / H20-상하-10 / [공주시]공주시 지방상수도 현대화사업 합사 재착수\n2023-08-21~2023-08-25 / 공주시 지방상수도 현대화사업 기본 및 실시설계 용역 / H20-상하-10 / [공주시]공주시 지방상수도 현대화사업 합사 재착수\n2023-08-28~2023-09-01 / 공주시 지방상수도 현대화사업 기본 및 실시설계 용역 / H20-상하-10 / [공주시]공주시 지방상수도 현대화사업 합사 재착수\n2023-09-04~2023-09-08 / 공주시 지방상수도 현대화사업 기본 및 실시설계 용역 / H20-상하-10 / [공주시]공주시 지방상수도 현대화사업 합사 재착수"},{"member_no":"M20305","member_name":"박민수","member_grade":"과장","entry_date":"2020-03-02","leave_date":"","contents":["2023-01-20 / Web Solution / H05-IT-04 / 연차(합사일정으로 인한 변경)","2023-01-12 / Web Solution / H05-IT-04 / 연차(합사일정으로 인한 변경)","2023-01-10 / Web Solution / H05-IT-04 / 오후반차(합사일정으로 인한 변경)","2023-01-06 / 신규노선개발 프로젝트(22년 도로부) / H22-제안-01 / [에프알텍 신사옥(관양동 1745-2)]합사 이사","2023-06-28 / 용인-안성 고속화도로 민간투자사업 / H23-제안-01 / [평촌역]합사 복귀"],"info_count":5,"content":"2023-01-20 / Web Solution / H05-IT-04 / 연차(합사일정으로 인한 변경)\n2023-01-12 / Web Solution / H05-IT-04 / 연차(합사일정으로 인한 변경)\n2023-01-10 / Web Solution / H05-IT-04 / 오후반차(합사일정으로 인한 변경)\n2023-01-06 / 신규노선개발 프로젝트(22년 도로부) / H22-제안-01 / [에프알텍 신사옥(관양동 1745-2)]합사 이사\n2023-06-28 / 용인-안성 고속화도로 민간투자사업 / H23-제안-01 / [평촌역]합사 복귀"},{"member_no":"M03216","member_name":"임재평","member_grade":"상무","entry_date":"2003-03-10","leave_date":"","contents":["2023-01-03 / 발안~남양 고속화도로 민간투자사업 실시설계 / H21-고속-08 / [하남시]합사업무 및 협의","2023-01-06 / 용인-안성 고속화도로 민간투자사업 / H23-제안-01 / [에프알텍 신사옥(관양동 1745-2)]합사이사","2023-06-28 / 용인-안성 고속화도로 민간투자사업 / H23-제안-01 / [평촌역]합사복귀"],"info_count":3,"content":"2023-01-03 / 발안~남양 고속화도로 민간투자사업 실시설계 / H21-고속-08 / [하남시]합사업무 및 협의\n2023-01-06 / 용인-안성 고속화도로 민간투자사업 / H23-제안-01 / [에프알텍 신사옥(관양동 1745-2)]합사이사\n2023-06-28 / 용인-안성 고속화도로 민간투자사업 / H23-제안-01 / [평촌역]합사복귀"},{"member_no":"M10103","member_name":"윤성호","member_grade":"부사장","entry_date":"2019-01-01","leave_date":"","contents":["2023-01-01~2023-01-03 / 동부간선 지하화(영동대로)건설공사 기본설계 기술제안 입찰(제안)설계 / H22-구조-07 / 동부간선 양재합사","2023-01-04 / 신규노선개발 프로젝트(22년 도로부) / H22-제안-01 / 건기연, 동부간선합사","2023-01-11~2023-01-12 / 동부간선 지하화(영동대로)건설공사 기본설계 기술제안 입찰(제안)설계 / H22-구조-07 / 동부간선합사, 서울시"],"info_count":3,"content":"2023-01-01~2023-01-03 / 동부간선 지하화(영동대로)건설공사 기본설계 기술제안 입찰(제안)설계 / H22-구조-07 / 동부간선 양재합사\n2023-01-04 / 신규노선개발 프로젝트(22년 도로부) / H22-제안-01 / 건기연, 동부간선합사\n2023-01-11~2023-01-12 / 동부간선 지하화(영동대로)건설공사 기본설계 기술제안 입찰(제안)설계 / H22-구조-07 / 동부간선합사, 서울시"},{"member_no":"B15203","member_name":"서현옥","member_grade":"과장","entry_date":"2015-10-12","leave_date":"","contents":["2023-01-06 / 신규노선개발 프로젝트(22년 도로부) / H22-제안-01 / [에프알텍 신사옥(관양동 1745-2)]합사 이사","2023-06-28 / 용인-안성 고속화도로 민간투자사업 / H23-제안-01 / [평촌역]합사 복귀"],"info_count":2,"content":"2023-01-06 / 신규노선개발 프로젝트(22년 도로부) / H22-제안-01 / [에프알텍 신사옥(관양동 1745-2)]합사 이사\n2023-06-28 / 용인-안성 고속화도로 민간투자사업 / H23-제안-01 / [평촌역]합사 복귀"},{"member_no":"M07223","member_name":"이종익","member_grade":"상무","entry_date":"2007-08-06","leave_date":"","contents":["2023-01-02 / Web Solution / H05-IT-04 / 시차:송산그린1공구 합사 자료수집(17시~18시)/n17/n18","2023-01-10 / 발안~남양 고속화도로 민간투자사업 실시설계 / H21-고속-08 / [하남시]합사투입"],"info_count":2,"content":"2023-01-02 / Web Solution / H05-IT-04 / 시차:송산그린1공구 합사 자료수집(17시~18시)/n17/n18\n2023-01-10 / 발안~남양 고속화도로 민간투자사업 실시설계 / H21-고속-08 / [하남시]합사투입"},{"member_no":"M20213","member_name":"임원석","member_grade":"차장","entry_date":"2020-07-01","leave_date":"2024-05-31","contents":["2023-02-10 / Web Solution / H05-IT-04 / 시차:합사 일정에 따른 연차변경(16시~18시)/n16/n18","2023-02-17 / Web Solution / H05-IT-04 / 시차:합사 일정에 따른 연차변경(16시~18시)/n16/n18"],"info_count":2,"content":"2023-02-10 / Web Solution / H05-IT-04 / 시차:합사 일정에 따른 연차변경(16시~18시)/n16/n18\n2023-02-17 / Web Solution / H05-IT-04 / 시차:합사 일정에 따른 연차변경(16시~18시)/n16/n18"},{"member_no":"M21424","member_name":"김기창","member_grade":"대리","entry_date":"2021-05-03","leave_date":"2023-08-17","contents":["2023-06-16 / 용인-안성 고속화도로 민간투자사업 / H23-제안-01 / [평촌(안양)]용인-안성 고속화도로 민간투자사업 합사 철수","2023-06-28 / 용인-안성 고속화도로 민간투자사업 / H23-제안-01 / [평촌역]합사복귀"],"info_count":2,"content":"2023-06-16 / 용인-안성 고속화도로 민간투자사업 / H23-제안-01 / [평촌(안양)]용인-안성 고속화도로 민간투자사업 합사 철수\n2023-06-28 / 용인-안성 고속화도로 민간투자사업 / H23-제안-01 / [평촌역]합사복귀"},{"member_no":"M21432","member_name":"최승범","member_grade":"대리","entry_date":"2021-07-01","leave_date":"","contents":["2023-01-06 / 신규노선개발 프로젝트(22년 도로부) / H22-제안-01 / [에프알텍 신사옥(관양동 1745-2)]합사 이사","2023-02-03 / 용인-안성 고속화도로 민간투자사업 / H23-제안-01 / [에프알텍 신사옥(관양동 1745-2)]합사 복귀"],"info_count":2,"content":"2023-01-06 / 신규노선개발 프로젝트(22년 도로부) / H22-제안-01 / [에프알텍 신사옥(관양동 1745-2)]합사 이사\n2023-02-03 / 용인-안성 고속화도로 민간투자사업 / H23-제안-01 / [에프알텍 신사옥(관양동 1745-2)]합사 복귀"},{"member_no":"M21468","member_name":"전수진","member_grade":"대리","entry_date":"2021-12-01","leave_date":"","contents":["2023-01-06 / 용인-안성 고속화도로 민간투자사업 / H23-제안-01 / [에프알텍 신사옥(관양동 1745-2)]합사이사","2023-06-28 / 용인-안성 고속화도로 민간투자사업 / H23-제안-01 / [평촌역]합사복귀"],"info_count":2,"content":"2023-01-06 / 용인-안성 고속화도로 민간투자사업 / H23-제안-01 / [에프알텍 신사옥(관양동 1745-2)]합사이사\n2023-06-28 / 용인-안성 고속화도로 민간투자사업 / H23-제안-01 / [평촌역]합사복귀"},{"member_no":"M22022","member_name":"송민제","member_grade":"대리","entry_date":"2022-05-02","leave_date":"2024-10-29","contents":["2023-06-16 / 용인-안성 고속화도로 민간투자사업 / H23-제안-01 / [평촌(안양)]용인-안성 고속화도로 민간투자사업 합사 철수","2023-11-10~2023-12-31 / Web Solution / H05-IT-04 / 충청내륙고속화도로 경쟁합사"],"info_count":2,"content":"2023-06-16 / 용인-안성 고속화도로 민간투자사업 / H23-제안-01 / [평촌(안양)]용인-안성 고속화도로 민간투자사업 합사 철수\n2023-11-10~2023-12-31 / Web Solution / H05-IT-04 / 충청내륙고속화도로 경쟁합사"},{"member_no":"M23076","member_name":"박상빈","member_grade":"과장","entry_date":"2023-11-27","leave_date":"","contents":["2023-12-11 / 충청내륙고속화도로~충주역(검단대교) 도로연결사업 실시설계 기술제안입찰 / H23-제안-19 / [경기도 안양시 동안구 호계동 897-7]합사투입","2023-12-11~2023-12-31 / Web Solution / H05-IT-04 / 충청내륙고속화도로 경쟁합사"],"info_count":2,"content":"2023-12-11 / 충청내륙고속화도로~충주역(검단대교) 도로연결사업 실시설계 기술제안입찰 / H23-제안-19 / [경기도 안양시 동안구 호계동 897-7]합사투입\n2023-12-11~2023-12-31 / Web Solution / H05-IT-04 / 충청내륙고속화도로 경쟁합사"},{"member_no":"M24075","member_name":"송민제","member_grade":"과장","entry_date":"2022-05-02","leave_date":"","contents":["2023-06-16 / 용인-안성 고속화도로 민간투자사업 / H23-제안-01 / [평촌(안양)]용인-안성 고속화도로 민간투자사업 합사 철수","2023-11-10~2023-12-31 / Web Solution / H05-IT-04 / 충청내륙고속화도로 경쟁합사"],"info_count":2,"content":"2023-06-16 / 용인-안성 고속화도로 민간투자사업 / H23-제안-01 / [평촌(안양)]용인-안성 고속화도로 민간투자사업 합사 철수\n2023-11-10~2023-12-31 / Web Solution / H05-IT-04 / 충청내륙고속화도로 경쟁합사"},{"member_no":"B21335","member_name":"이동원A","member_grade":"상무","entry_date":"2018-10-01","leave_date":"2023-02-28","contents":["2023-01-01~2023-01-20 / 동부 간선 지하화(영동대로)건설공사 기본설계 / H22-제안-21 / 동부간선도로 지하화(영동대로) 기본설계 기술제안입찰 합사"],"info_count":1,"content":"2023-01-01~2023-01-20 / 동부 간선 지하화(영동대로)건설공사 기본설계 / H22-제안-21 / 동부간선도로 지하화(영동대로) 기본설계 기술제안입찰 합사"},{"member_no":"M02259","member_name":"김영권","member_grade":"상무","entry_date":"2001-09-03","leave_date":"","contents":["2023-04-14 / 발안~남양 고속화도로 민간투자사업 실시설계 / H21-고속-08 / 업무협의(서경대, 합사)"],"info_count":1,"content":"2023-04-14 / 발안~남양 고속화도로 민간투자사업 실시설계 / H21-고속-08 / 업무협의(서경대, 합사)"},{"member_no":"M03211","member_name":"박성웅","member_grade":"전무","entry_date":"2003-02-05","leave_date":"2026-03-31","contents":["2023-02-20 / 용인-안성 고속화도로 민간투자사업 / H23-제안-01 / 경유(합사업무협의)"],"info_count":1,"content":"2023-02-20 / 용인-안성 고속화도로 민간투자사업 / H23-제안-01 / 경유(합사업무협의)"},{"member_no":"M08206","member_name":"이재진","member_grade":"이사","entry_date":"2008-04-07","leave_date":"","contents":["2023-05-12 / 송산그린시티 도시물순환 기본계획 수립 용역(서측지구 1단계) / H22-수자-11 / [광명 합사사무실]업무협의"],"info_count":1,"content":"2023-05-12 / 송산그린시티 도시물순환 기본계획 수립 용역(서측지구 1단계) / H22-수자-11 / [광명 합사사무실]업무협의"},{"member_no":"M09301","member_name":"성열은","member_grade":"부장","entry_date":"2009-01-05","leave_date":"2024-03-31","contents":["2023-01-01~2023-01-20 / 동부 간선 지하화(영동대로)건설공사 기본설계 / H22-제안-21 / 동부간선도로 지하화(영동대로) 기본설계 기술제안입찰 합사"],"info_count":1,"content":"2023-01-01~2023-01-20 / 동부 간선 지하화(영동대로)건설공사 기본설계 / H22-제안-21 / 동부간선도로 지하화(영동대로) 기본설계 기술제안입찰 합사"},{"member_no":"M16208","member_name":"신영각","member_grade":"부사장","entry_date":"2016-07-04","leave_date":"","contents":["2023-02-16 / Web Solution / H05-IT-04 / YA합사경우"],"info_count":1,"content":"2023-02-16 / Web Solution / H05-IT-04 / YA합사경우"},{"member_no":"M16222","member_name":"이현구","member_grade":"부사장","entry_date":"2016-10-31","leave_date":"","contents":["2023-06-21 / 발안~남양 고속화도로 민간투자사업 실시설계 / H21-고속-08 / 합사회의"],"info_count":1,"content":"2023-06-21 / 발안~남양 고속화도로 민간투자사업 실시설계 / H21-고속-08 / 합사회의"},{"member_no":"M17304","member_name":"최락준","member_grade":"과장","entry_date":"2017-02-01","leave_date":"2024-04-30","contents":["2023-01-01~2023-01-20 / 동부 간선 지하화(영동대로)건설공사 기본설계 / H22-제안-21 / 동부간선도로 지하화(영동대로) 기본설계 기술제안입찰 합사"],"info_count":1,"content":"2023-01-01~2023-01-20 / 동부 간선 지하화(영동대로)건설공사 기본설계 / H22-제안-21 / 동부간선도로 지하화(영동대로) 기본설계 기술제안입찰 합사"},{"member_no":"M20211","member_name":"유재극","member_grade":"이사","entry_date":"2020-06-15","leave_date":"","contents":["2023-11-10~2023-12-31 / Web Solution / H05-IT-04 / 충청내륙고속화도로 경쟁합사"],"info_count":1,"content":"2023-11-10~2023-12-31 / Web Solution / H05-IT-04 / 충청내륙고속화도로 경쟁합사"},{"member_no":"M20214","member_name":"김동원","member_grade":"대리","entry_date":"2020-07-01","leave_date":"2023-03-07","contents":["2023-01-01~2023-01-20 / 동부 간선 지하화(영동대로)건설공사 기본설계 / H22-제안-21 / 동부간선도로 지하화(영동대로) 기본설계 기술제안입찰 합사"],"info_count":1,"content":"2023-01-01~2023-01-20 / 동부 간선 지하화(영동대로)건설공사 기본설계 / H22-제안-21 / 동부간선도로 지하화(영동대로) 기본설계 기술제안입찰 합사"},{"member_no":"M20327","member_name":"김태우","member_grade":"과장","entry_date":"2020-12-01","leave_date":"","contents":["2023-11-10~2023-12-31 / Web Solution / H05-IT-04 / 충청내륙고속화도로 경쟁합사"],"info_count":1,"content":"2023-11-10~2023-12-31 / Web Solution / H05-IT-04 / 충청내륙고속화도로 경쟁합사"},{"member_no":"M21315","member_name":"장경호","member_grade":"과장","entry_date":"2021-02-15","leave_date":"2025-12-08","contents":["2023-06-16 / 용인-안성 고속화도로 민간투자사업 / H23-제안-01 / [평촌(안양)]용인-안성 고속화도로 민간투자사업 합사 철수"],"info_count":1,"content":"2023-06-16 / 용인-안성 고속화도로 민간투자사업 / H23-제안-01 / [평촌(안양)]용인-안성 고속화도로 민간투자사업 합사 철수"},{"member_no":"M21413","member_name":"김춘근","member_grade":"전무","entry_date":"2021-03-26","leave_date":"","contents":["2023-09-04 / Web Solution / H05-IT-04 / [안양]행복도시 6-2 공공주택지구 CMR 합사출장"],"info_count":1,"content":"2023-09-04 / Web Solution / H05-IT-04 / [안양]행복도시 6-2 공공주택지구 CMR 합사출장"},{"member_no":"M21434","member_name":"김승일","member_grade":"이사","entry_date":"2018-03-19","leave_date":"","contents":["2023-01-04 / 세종-청주 고속도로 건설사업 환경영향평가 용역 / H21-환경-03 / [합사사무실]방음벽 관련 회의"],"info_count":1,"content":"2023-01-04 / 세종-청주 고속도로 건설사업 환경영향평가 용역 / H21-환경-03 / [합사사무실]방음벽 관련 회의"},{"member_no":"M22003","member_name":"안민형","member_grade":"부장","entry_date":"2022-02-08","leave_date":"","contents":["2023-11-10~2023-12-31 / Web Solution / H05-IT-04 / 충청내륙고속화도로 경쟁합사"],"info_count":1,"content":"2023-11-10~2023-12-31 / Web Solution / H05-IT-04 / 충청내륙고속화도로 경쟁합사"},{"member_no":"M22031","member_name":"이환섭","member_grade":"부사장","entry_date":"2022-07-06","leave_date":"","contents":["2023-11-10~2023-12-31 / Web Solution / H05-IT-04 / 충청내륙고속화도로 경쟁합사"],"info_count":1,"content":"2023-11-10~2023-12-31 / Web Solution / H05-IT-04 / 충청내륙고속화도로 경쟁합사"},{"member_no":"M22035","member_name":"김창섭","member_grade":"이사","entry_date":"2022-06-01","leave_date":"","contents":["2023-11-10~2023-12-31 / Web Solution / H05-IT-04 / 충청내륙고속화도로 경쟁합사"],"info_count":1,"content":"2023-11-10~2023-12-31 / Web Solution / H05-IT-04 / 충청내륙고속화도로 경쟁합사"},{"member_no":"M22037","member_name":"박현수A","member_grade":"과장","entry_date":"2022-06-07","leave_date":"2023-09-30","contents":["2023-01-01~2023-01-20 / 동부 간선 지하화(영동대로)건설공사 기본설계 / H22-제안-21 / 동부간선도로 지하화(영동대로) 기본설계 기술제안입찰 합사"],"info_count":1,"content":"2023-01-01~2023-01-20 / 동부 간선 지하화(영동대로)건설공사 기본설계 / H22-제안-21 / 동부간선도로 지하화(영동대로) 기본설계 기술제안입찰 합사"},{"member_no":"M22053","member_name":"남창성","member_grade":"상무","entry_date":"2022-08-01","leave_date":"2025-11-30","contents":["2023-06-16 / 용인-안성 고속화도로 민간투자사업 / H23-제안-01 / [평촌(안양)]용인-안성 고속화도로 민간투자사업 합사 철수"],"info_count":1,"content":"2023-06-16 / 용인-안성 고속화도로 민간투자사업 / H23-제안-01 / [평촌(안양)]용인-안성 고속화도로 민간투자사업 합사 철수"},{"member_no":"M22055","member_name":"손지민","member_grade":"사원","entry_date":"2022-08-22","leave_date":"","contents":["2023-06-16 / 용인-안성 고속화도로 민간투자사업 / H23-제안-01 / [평촌(안양)]용인-안성 고속화도로 민간투자사업 합사 철수"],"info_count":1,"content":"2023-06-16 / 용인-안성 고속화도로 민간투자사업 / H23-제안-01 / [평촌(안양)]용인-안성 고속화도로 민간투자사업 합사 철수"},{"member_no":"M22072","member_name":"최영준","member_grade":"대리","entry_date":"2022-11-01","leave_date":"2023-04-27","contents":["2023-01-06 / 용인-안성 고속화도로 민간투자사업 / H23-제안-01 / [에프알텍 신사옥(관양동 1745-2)]합사이사"],"info_count":1,"content":"2023-01-06 / 용인-안성 고속화도로 민간투자사업 / H23-제안-01 / [에프알텍 신사옥(관양동 1745-2)]합사이사"},{"member_no":"M22074","member_name":"정도영","member_grade":"차장","entry_date":"2022-11-21","leave_date":"","contents":["2023-01-04 / 세종-청주 고속도로 건설사업 환경영향평가 용역 / H21-환경-03 / [합사사무실]방음벽 관련 회의"],"info_count":1,"content":"2023-01-04 / 세종-청주 고속도로 건설사업 환경영향평가 용역 / H21-환경-03 / [합사사무실]방음벽 관련 회의"},{"member_no":"M23005","member_name":"이교호","member_grade":"대리","entry_date":"2023-01-09","leave_date":"","contents":["2023-11-10~2023-12-31 / Web Solution / H05-IT-04 / 충청내륙고속화도로 경쟁합사"],"info_count":1,"content":"2023-11-10~2023-12-31 / Web Solution / H05-IT-04 / 충청내륙고속화도로 경쟁합사"}]},"{\"start_date\":\"2025-01-01\",\"end_date\":\"2025-12-31\",\"employment\":\"all\",\"view\":\"member\",\"include_center_member_nos\":[]}":{"cache_key":"c67d8e0dcc613cc72a71eb174d5b62b97a74274f","start_date":"2025-01-01","end_date":"2025-12-31","employment":"all","view":"member","updated_at":"2026-06-02 00:14:22","joint_members":[{"member_no":"M16216","member_name":"이병욱","member_grade":"이사","entry_date":"2016-09-01","leave_date":"","contents":["2025-05-26 / 춘천 기업혁신파크 선도사업 조사·설계 기술용역 / H24-도시-13 / [합사(경기도 남양주시)]업무회의","2025-10-24 / 춘천 기업혁신파크 선도사업 조사·설계 기술용역 / H24-도시-13 / [경기도 남양주시(합사)]업무회의","2025-10-29 / 춘천 기업혁신파크 선도사업 조사·설계 기술용역 / H24-도시-13 / [경기도 남양주시(합사)]업무회의","2025-11-07 / 춘천 기업혁신파크 선도사업 조사·설계 기술용역 / H24-도시-13 / [합사(남양주시), 춘천시청]업무회의(주민의견 수렴 관련)","2025-12-10 / 춘천 기업혁신파크 선도사업 조사·설계 기술용역 / H24-도시-13 / [경기도 남양주시(합사)]업무회의","2025-12-17 / 춘천 기업혁신파크 선도사업 조사·설계 기술용역 / H24-도시-13 / [경기도 남양주시(합사)]업무회의(평가서(초안) 검토의견 대응)","2025-12-30 / 춘천 기업혁신파크 선도사업 조사·설계 기술용역 / H24-도시-13 / [경기도 남양주시(합사)]업무회의"],"info_count":7,"content":"2025-05-26 / 춘천 기업혁신파크 선도사업 조사·설계 기술용역 / H24-도시-13 / [합사(경기도 남양주시)]업무회의\n2025-10-24 / 춘천 기업혁신파크 선도사업 조사·설계 기술용역 / H24-도시-13 / [경기도 남양주시(합사)]업무회의\n2025-10-29 / 춘천 기업혁신파크 선도사업 조사·설계 기술용역 / H24-도시-13 / [경기도 남양주시(합사)]업무회의\n2025-11-07 / 춘천 기업혁신파크 선도사업 조사·설계 기술용역 / H24-도시-13 / [합사(남양주시), 춘천시청]업무회의(주민의견 수렴 관련)\n2025-12-10 / 춘천 기업혁신파크 선도사업 조사·설계 기술용역 / H24-도시-13 / [경기도 남양주시(합사)]업무회의\n2025-12-17 / 춘천 기업혁신파크 선도사업 조사·설계 기술용역 / H24-도시-13 / [경기도 남양주시(합사)]업무회의(평가서(초안) 검토의견 대응)\n2025-12-30 / 춘천 기업혁신파크 선도사업 조사·설계 기술용역 / H24-도시-13 / [경기도 남양주시(합사)]업무회의"},{"member_no":"M20305","member_name":"박민수","member_grade":"과장","entry_date":"2020-03-02","leave_date":"","contents":["2025-02-26 / Web Solution / H05-IT-04 / 연차(합사 운영 일정에 따른 연차계획 변경)","2025-02-25 / Web Solution / H05-IT-04 / 연차(합사 운영 일정에 따른 연차계획 변경)","2025-01-31 / Web Solution / H05-IT-04 / 연차(합사 운영 일정에 따른 연차계획 변경)","2025-01-23 / Web Solution / H05-IT-04 / 연차(합사 운영 일정에 따른 연차계획 변경)","2025-02-28 / 계양-강화간 고속도로 건설공사 기본 및 실시설계(5공구) / H22-고속-05 / [합사(가락동)]합사철수(계양강화)","2025-03-10~2025-03-11 / Web Solution / H05-IT-04 / 특별휴가(합사 일정에 의한 미사용 연차 소진)"],"info_count":6,"content":"2025-02-26 / Web Solution / H05-IT-04 / 연차(합사 운영 일정에 따른 연차계획 변경)\n2025-02-25 / Web Solution / H05-IT-04 / 연차(합사 운영 일정에 따른 연차계획 변경)\n2025-01-31 / Web Solution / H05-IT-04 / 연차(합사 운영 일정에 따른 연차계획 변경)\n2025-01-23 / Web Solution / H05-IT-04 / 연차(합사 운영 일정에 따른 연차계획 변경)\n2025-02-28 / 계양-강화간 고속도로 건설공사 기본 및 실시설계(5공구) / H22-고속-05 / [합사(가락동)]합사철수(계양강화)\n2025-03-10~2025-03-11 / Web Solution / H05-IT-04 / 특별휴가(합사 일정에 의한 미사용 연차 소진)"},{"member_no":"M21315","member_name":"장경호","member_grade":"과장","entry_date":"2021-02-15","leave_date":"2025-12-08","contents":["2025-02-14 / Web Solution / H05-IT-04 / 연차(합사 운영 일정에 따른 연차변경)","2025-02-07 / Web Solution / H05-IT-04 / 연차(합사 운영 일정에 따른 연차변경)","2025-03-10 / Web Solution / H05-IT-04 / 특별휴가(합사 일정에 의한 미사용 연차소진)","2025-02-28 / 계양-강화간 고속도로 건설공사 기본 및 실시설계(5공구) / H22-고속-05 / [합사(가락동)]합사철수(계양강화)"],"info_count":4,"content":"2025-02-14 / Web Solution / H05-IT-04 / 연차(합사 운영 일정에 따른 연차변경)\n2025-02-07 / Web Solution / H05-IT-04 / 연차(합사 운영 일정에 따른 연차변경)\n2025-03-10 / Web Solution / H05-IT-04 / 특별휴가(합사 일정에 의한 미사용 연차소진)\n2025-02-28 / 계양-강화간 고속도로 건설공사 기본 및 실시설계(5공구) / H22-고속-05 / [합사(가락동)]합사철수(계양강화)"},{"member_no":"M05207","member_name":"이찬우","member_grade":"전무","entry_date":"2005-04-18","leave_date":"","contents":["2025-05-26 / 춘천 기업혁신파크 선도사업 조사·설계 기술용역 / H24-도시-13 / [합사(경기도 남양주시)]업무회의","2025-11-07 / 춘천 기업혁신파크 선도사업 조사·설계 기술용역 / H24-도시-13 / [합사(남양주시), 춘천시청]업무회의(주민의견 수렴 관련)","2025-12-17 / 춘천 기업혁신파크 선도사업 조사·설계 기술용역 / H24-도시-13 / [경기도 남양주시(합사)]업무회의(평가서(초안) 검토의견 대응)"],"info_count":3,"content":"2025-05-26 / 춘천 기업혁신파크 선도사업 조사·설계 기술용역 / H24-도시-13 / [합사(경기도 남양주시)]업무회의\n2025-11-07 / 춘천 기업혁신파크 선도사업 조사·설계 기술용역 / H24-도시-13 / [합사(남양주시), 춘천시청]업무회의(주민의견 수렴 관련)\n2025-12-17 / 춘천 기업혁신파크 선도사업 조사·설계 기술용역 / H24-도시-13 / [경기도 남양주시(합사)]업무회의(평가서(초안) 검토의견 대응)"},{"member_no":"M23006","member_name":"김태욱","member_grade":"대리","entry_date":"2023-01-09","leave_date":"","contents":["2025-04-17 / 2025 QBS(구조) / H25-제안-13 / [삼보기술단]용산선로데크 합사 인원 복귀","2025-04-18 / 2025 QBS(구조) / H25-제안-13 / [삼보기술단]용산선로데크 합사"],"info_count":2,"content":"2025-04-17 / 2025 QBS(구조) / H25-제안-13 / [삼보기술단]용산선로데크 합사 인원 복귀\n2025-04-18 / 2025 QBS(구조) / H25-제안-13 / [삼보기술단]용산선로데크 합사"},{"member_no":"M03202","member_name":"오재범","member_grade":"전무","entry_date":"2003-05-29","leave_date":"","contents":["2025-02-14 / 춘천 기업혁신파크교량 구조물 / H25-제안-10 / 합사 업무협의"],"info_count":1,"content":"2025-02-14 / 춘천 기업혁신파크교량 구조물 / H25-제안-10 / 합사 업무협의"},{"member_no":"M12203","member_name":"김상수","member_grade":"전무","entry_date":"2012-06-19","leave_date":"","contents":["2025-01-23 / 춘천 기업혁신파크 선도사업 조사·설계 기술용역 / H24-도시-13 / 합사 등"],"info_count":1,"content":"2025-01-23 / 춘천 기업혁신파크 선도사업 조사·설계 기술용역 / H24-도시-13 / 합사 등"},{"member_no":"M14101","member_name":"정혜연","member_grade":"전무","entry_date":"2014-06-01","leave_date":"","contents":["2025-04-03 / 춘천 기업혁신파크 선도사업 조사·설계 기술용역 / H24-도시-13 / 춘천합사방문"],"info_count":1,"content":"2025-04-03 / 춘천 기업혁신파크 선도사업 조사·설계 기술용역 / H24-도시-13 / 춘천합사방문"},{"member_no":"M20101","member_name":"이병도","member_grade":"전무","entry_date":"2020-01-01","leave_date":"","contents":["2025-12-30 / 춘천 기업혁신파크 선도사업 조사·설계 기술용역 / H24-도시-13 / [경기도 남양주시(합사)]업무회의"],"info_count":1,"content":"2025-12-30 / 춘천 기업혁신파크 선도사업 조사·설계 기술용역 / H24-도시-13 / [경기도 남양주시(합사)]업무회의"},{"member_no":"M21432","member_name":"최승범","member_grade":"대리","entry_date":"2021-07-01","leave_date":"","contents":["2025-05-29 / 계양~강화 고속도로 건설공사(제7공구) 기본설계용역(2.4 도로공 설계) / H24-고속-03 / [사당역 합동사무실]합사 이사(선릉역 합동사무실에서 사당 합동사무실로 이전)로 인한 배차 신청"],"info_count":1,"content":"2025-05-29 / 계양~강화 고속도로 건설공사(제7공구) 기본설계용역(2.4 도로공 설계) / H24-고속-03 / [사당역 합동사무실]합사 이사(선릉역 합동사무실에서 사당 합동사무실로 이전)로 인한 배차 신청"},{"member_no":"M21451","member_name":"김태식","member_grade":"차장","entry_date":"2021-09-27","leave_date":"","contents":["2025-09-17 / 도림천 일대 대심도 빗물배수터널 건설공사 실시설계용역(주설계) / H25-지반-01 / [도림천 합사]지하안전영향평가 현장조사"],"info_count":1,"content":"2025-09-17 / 도림천 일대 대심도 빗물배수터널 건설공사 실시설계용역(주설계) / H25-지반-01 / [도림천 합사]지하안전영향평가 현장조사"},{"member_no":"M24036","member_name":"심성보","member_grade":"이사","entry_date":"2024-05-02","leave_date":"","contents":["2025-12-10 / 도림천 일대 대심도 빗물배수터널 건설공사 실시설계용역(주설계) / H25-지반-01 / 도림천 주설계 합사회의 및 서울 도기본 공사중 변경설계 협의"],"info_count":1,"content":"2025-12-10 / 도림천 일대 대심도 빗물배수터널 건설공사 실시설계용역(주설계) / H25-지반-01 / 도림천 주설계 합사회의 및 서울 도기본 공사중 변경설계 협의"},{"member_no":"M24041","member_name":"박익수","member_grade":"부사장","entry_date":"2024-06-01","leave_date":"","contents":["2025-10-14~2025-10-15 / 2024 PQ(건설사업관리) / H24-제안-03 / [동일기술공사]대동초 감리 면접 준비(합사)"],"info_count":1,"content":"2025-10-14~2025-10-15 / 2024 PQ(건설사업관리) / H24-제안-03 / [동일기술공사]대동초 감리 면접 준비(합사)"}]}},"latest":{"cache_key":"5c069a2bbf81c71a374fa07e209af73d8c9ad1f5","start_date":"2026-01-01","end_date":"2026-05-29","employment":"all","view":"member","updated_at":"2026-06-01 23:58:34","joint_members":[{"member_no":"M16216","member_name":"이병욱","member_grade":"이사","entry_date":"2016-09-01","leave_date":"","contents":["2026-01-16 / 춘천 기업혁신파크 선도사업 조사·설계 기술용역 / H24-도시-13 / [경기도 남양주시(합사)]업무회의","2026-01-23 / 춘천 기업혁신파크 선도사업 조사·설계 기술용역 / H24-도시-13 / [합사(남양주시)]업무회의(평가서(초안) 검토의견 대응)","2026-03-31 / 춘천 기업혁신파크 선도사업 조사·설계 기술용역 / H24-도시-13 / [경기도 남양주시(합사)]업무회의"],"info_count":3,"content":"2026-01-16 / 춘천 기업혁신파크 선도사업 조사·설계 기술용역 / H24-도시-13 / [경기도 남양주시(합사)]업무회의\n2026-01-23 / 춘천 기업혁신파크 선도사업 조사·설계 기술용역 / H24-도시-13 / [합사(남양주시)]업무회의(평가서(초안) 검토의견 대응)\n2026-03-31 / 춘천 기업혁신파크 선도사업 조사·설계 기술용역 / H24-도시-13 / [경기도 남양주시(합사)]업무회의"},{"member_no":"M24036","member_name":"심성보","member_grade":"이사","entry_date":"2024-05-02","leave_date":"","contents":["2026-01-13 / 도림천 일대 대심도 빗물배수터널 건설공사 실시설계용역(주설계) / H25-지반-01 / 도림천 합사(대우) 동작구청/환경유역청 하천점유 인허가 협의","2026-01-21 / 도림천 일대 대심도 빗물배수터널 건설공사 실시설계용역(주설계) / H25-지반-01 / [서울시 동작구청/대우합사]PJ-도림천 일대 대심도 빗물배수터널 건설공사 실시설계 관계기관 협의(동작구청)","2026-03-13 / 도림천 일대 대심도 빗물배수터널 건설공사 실시설계용역(주설계) / H25-지반-01 / [대우합사/도기본]공사중 변경설계(추가과업) 협의(견적 진행중)"],"info_count":3,"content":"2026-01-13 / 도림천 일대 대심도 빗물배수터널 건설공사 실시설계용역(주설계) / H25-지반-01 / 도림천 합사(대우) 동작구청/환경유역청 하천점유 인허가 협의\n2026-01-21 / 도림천 일대 대심도 빗물배수터널 건설공사 실시설계용역(주설계) / H25-지반-01 / [서울시 동작구청/대우합사]PJ-도림천 일대 대심도 빗물배수터널 건설공사 실시설계 관계기관 협의(동작구청)\n2026-03-13 / 도림천 일대 대심도 빗물배수터널 건설공사 실시설계용역(주설계) / H25-지반-01 / [대우합사/도기본]공사중 변경설계(추가과업) 협의(견적 진행중)"},{"member_no":"M20101","member_name":"이병도","member_grade":"전무","entry_date":"2020-01-01","leave_date":"","contents":["2026-01-16 / 춘천 기업혁신파크 선도사업 조사·설계 기술용역 / H24-도시-13 / [경기도 남양주시(합사)]업무회의","2026-01-23 / 춘천 기업혁신파크 선도사업 조사·설계 기술용역 / H24-도시-13 / [합사(남양주시)]업무회의(평가서(초안) 검토의견 대응)"],"info_count":2,"content":"2026-01-16 / 춘천 기업혁신파크 선도사업 조사·설계 기술용역 / H24-도시-13 / [경기도 남양주시(합사)]업무회의\n2026-01-23 / 춘천 기업혁신파크 선도사업 조사·설계 기술용역 / H24-도시-13 / [합사(남양주시)]업무회의(평가서(초안) 검토의견 대응)"},{"member_no":"M21432","member_name":"최승범","member_grade":"대리","entry_date":"2021-07-01","leave_date":"","contents":["2026-01-15 / 계양~강화 고속도로 건설공사(제7공구)실시설계용역(2.4 도로공 설계) / H25-고속-01 / [서울시 동작구 동작대로 43 4층 합사]합사 철수","2026-02-13 / 계양~강화 고속도로 건설공사(제7공구)실시설계용역(2.4 도로공 설계) / H25-고속-01 / [서울시 동작구 동작대로 43 6층 합사]최종 성과품 1식 자료 백업"],"info_count":2,"content":"2026-01-15 / 계양~강화 고속도로 건설공사(제7공구)실시설계용역(2.4 도로공 설계) / H25-고속-01 / [서울시 동작구 동작대로 43 4층 합사]합사 철수\n2026-02-13 / 계양~강화 고속도로 건설공사(제7공구)실시설계용역(2.4 도로공 설계) / H25-고속-01 / [서울시 동작구 동작대로 43 6층 합사]최종 성과품 1식 자료 백업"},{"member_no":"M05207","member_name":"이찬우","member_grade":"전무","entry_date":"2005-04-18","leave_date":"","contents":["2026-03-31 / 춘천 기업혁신파크 선도사업 조사·설계 기술용역 / H24-도시-13 / [경기도 남양주시(합사)]업무회의"],"info_count":1,"content":"2026-03-31 / 춘천 기업혁신파크 선도사업 조사·설계 기술용역 / H24-도시-13 / [경기도 남양주시(합사)]업무회의"},{"member_no":"M25057","member_name":"조현경","member_grade":"차장","entry_date":"2025-09-08","leave_date":"2026-05-06","contents":["2026-03-16~2026-04-10 / Web Solution / H05-IT-04 / 청양군 노후상수도 합사"],"info_count":1,"content":"2026-03-16~2026-04-10 / Web Solution / H05-IT-04 / 청양군 노후상수도 합사"}]}} \ No newline at end of file diff --git a/static/hm-biz-process/app.css b/static/hm-biz-process/app.css new file mode 100644 index 0000000..cbe97d0 --- /dev/null +++ b/static/hm-biz-process/app.css @@ -0,0 +1,2073 @@ + * { box-sizing: border-box; } + :root { + --step-height: 52px; + --bg-deep: #04140f; + --bg-mid: #0a2e23; + --bg-panel: rgba(7, 43, 33, 0.78); + --bg-panel-strong: rgba(8, 37, 29, 0.88); + --surface-soft: #f4f1e8; + --surface-plain: #fbf8f1; + --surface-tint: #eef4ef; + --line-soft: rgba(129, 201, 171, 0.18); + --line-strong: rgba(215, 166, 84, 0.42); + --text-main: #f3f1e8; + --text-soft: #bbd3c5; + --text-muted: #83a293; + --ink-strong: #183128; + --ink-soft: #51655b; + --accent: #d3a45f; + --accent-strong: #f0c574; + --accent-soft: rgba(211, 164, 95, 0.16); + --signal-blue: #62b2ff; + --shadow-deep: 0 24px 60px rgba(0, 0, 0, 0.34); + } + html { + min-height: 100%; + overflow-y: auto; + } + body { + margin: 0; + font-family: "Segoe UI", "Noto Sans KR", sans-serif; + color: var(--text-main); + min-height: 100vh; + background: + linear-gradient(rgba(120, 186, 158, 0.08) 1px, transparent 1px), + linear-gradient(90deg, rgba(120, 186, 158, 0.08) 1px, transparent 1px), + radial-gradient(circle at 74% 26%, rgba(38, 114, 88, 0.38), transparent 28%), + radial-gradient(circle at 20% 72%, rgba(191, 149, 82, 0.14), transparent 22%), + linear-gradient(135deg, #04120d 0%, #0a241b 45%, #0f4534 100%); + background-size: 40px 40px, 40px 40px, auto, auto, auto; + position: relative; + overflow-x: auto; + overflow-y: auto; + } + body::before, + body::after { + content: ""; + position: fixed; + inset: 0; + pointer-events: none; + z-index: 0; + } + body::before { + inset: 0; + background: + radial-gradient(circle at 28% 24%, rgba(239, 192, 109, 0.12), transparent 18%); + opacity: 1; + box-shadow: none; + } + body::after { + background: + linear-gradient(90deg, rgba(0, 0, 0, 0.34), transparent 22%, transparent 78%, rgba(0, 0, 0, 0.42)), + radial-gradient(circle at center, transparent 50%, rgba(0, 0, 0, 0.2) 100%); + } + + .board { + width: 100%; + max-width: 100%; + min-height: 100vh; + height: auto; + padding: 16px 12px 12px; + overflow-x: auto; + overflow-y: visible; + position: relative; + z-index: 1; + } + .sitemap-fab { + position: fixed; + left: 16px; + top: 16px; + z-index: 25; + width: 44px; + height: 44px; + border-radius: 999px; + border: 1px solid rgba(223, 182, 110, 0.52); + background: linear-gradient(180deg, rgba(10, 55, 42, 0.96) 0%, rgba(6, 35, 28, 0.96) 100%); + color: var(--accent-strong); + font-size: 11px; + font-weight: 900; + line-height: 1; + cursor: pointer; + box-shadow: 0 12px 24px rgba(0, 0, 0, 0.26); + } + .data-io { + position: fixed; + left: 16px; + bottom: 16px; + z-index: 24; + display: grid; + gap: 7px; + justify-items: start; + } + .floating-edit-btn { + width: 42px; + min-width: 42px; + height: 42px; + border: 1px solid rgba(133, 183, 160, 0.26); + background: linear-gradient(180deg, rgba(10, 51, 40, 0.96) 0%, rgba(5, 33, 26, 0.96) 100%); + color: var(--text-soft); + border-radius: 999px; + padding: 0; + font-size: 11px; + font-weight: 900; + letter-spacing: .01em; + cursor: pointer; + box-shadow: 0 10px 18px rgba(0, 0, 0, 0.22); + white-space: nowrap; + display: grid; + place-items: center; + } + .floating-edit-btn:hover { + border-color: rgba(229, 188, 114, 0.62); + background: linear-gradient(180deg, rgba(22, 72, 57, 0.98) 0%, rgba(8, 40, 31, 0.98) 100%); + color: var(--accent-strong); + } + .floating-edit-btn.active { + border-color: rgba(241, 202, 128, 0.7); + background: linear-gradient(135deg, #c08a3f 0%, #f1c16f 100%); + color: #11281f; + box-shadow: 0 12px 24px rgba(120, 74, 17, 0.34); + } + .sync-status { + width: 42px; + height: 42px; + min-height: 42px; + padding: 0; + border-radius: 999px; + border: 1px solid rgba(133, 183, 160, 0.26); + background: rgba(7, 41, 32, 0.92); + color: var(--text-soft); + font-size: 10px; + font-weight: 800; + box-shadow: 0 10px 18px rgba(0, 0, 0, 0.2); + white-space: nowrap; + display: grid; + place-items: center; + line-height: 1; + } + .sync-status[data-state="loading"] { + border-color: rgba(98, 178, 255, 0.45); + background: rgba(11, 44, 55, 0.96); + color: #b4deff; + } + .sync-status[data-state="saving"] { + border-color: rgba(240, 197, 116, 0.44); + background: rgba(69, 53, 22, 0.9); + color: #ffe29d; + } + .sync-status[data-state="ready"] { + border-color: rgba(98, 201, 160, 0.44); + background: rgba(7, 56, 43, 0.96); + color: #bcead4; + } + .sync-status[data-state="cache"] { + border-color: rgba(240, 197, 116, 0.44); + background: rgba(74, 56, 21, 0.9); + color: #ffd897; + } + .sync-status[data-state="error"] { + border-color: rgba(236, 119, 100, 0.42); + background: rgba(72, 21, 18, 0.9); + color: #ffb4a9; + } + .data-io-btn { + width: 38px; + height: 38px; + border: 1px solid rgba(133, 183, 160, 0.26); + background: rgba(7, 41, 32, 0.94); + color: var(--text-soft); + border-radius: 999px; + padding: 0; + font-weight: 800; + cursor: pointer; + display: grid; + place-items: center; + box-shadow: 0 10px 18px rgba(0, 0, 0, 0.2); + } + .data-io-btn svg { + width: 22px; + height: 22px; + stroke: currentColor; + stroke-width: 2.6; + fill: none; + stroke-linecap: round; + stroke-linejoin: round; + } + .data-io-btn:hover { + border-color: rgba(229, 188, 114, 0.62); + background: rgba(15, 61, 47, 0.96); + } + .data-io-btn.export { + color: #ffd793; + } + .data-io-btn.import { + color: #bfe8d6; + } + .data-io-input { + display: none; + } + .board-track { + width: 100%; + max-width: 100%; + min-width: 0; + min-height: calc(100vh - 20px); + margin: 0; + padding-left: var(--track-side-pad, 64px); + padding-right: var(--track-side-pad, 64px); + display: flex; + gap: 14px; + align-items: flex-start; + flex-wrap: nowrap; + justify-content: flex-start; + position: relative; + z-index: 1; + } + .board-track.map-bar-layout { + justify-content: flex-start; + } + #drillColumns { + display: flex; + gap: 14px; + align-items: flex-start; + } + + .flow-panel { + width: 188px; + min-width: 188px; + background: + linear-gradient(180deg, rgba(11, 54, 43, 0.82) 0%, rgba(7, 34, 28, 0.9) 100%); + border: 1px solid rgba(132, 187, 161, 0.18); + border-radius: 20px; + box-shadow: var(--shadow-deep); + backdrop-filter: blur(10px); + box-shadow: + var(--shadow-deep), + inset 0 1px 0 rgba(255, 245, 222, 0.06); + padding: 12px 12px 14px; + height: calc(100vh - 20px); + max-height: calc(100vh - 20px); + overflow: auto; + } + .flow-panel.flow-bar-mode { + --bar-panel-width: 58px; + width: var(--bar-panel-width); + min-width: var(--bar-panel-width); + padding: 10px 8px 12px; + background: linear-gradient(180deg, rgba(8, 45, 35, 0.96) 0%, rgba(4, 27, 22, 0.96) 100%); + border-color: rgba(132, 187, 161, 0.18); + box-shadow: inset 0 1px 0 rgba(255, 240, 214, 0.08), 0 12px 24px rgba(0, 0, 0, 0.26); + position: relative; + } + .bar-toggle-title { + cursor: pointer; + user-select: none; + } + .bar-toggle-title:hover { + color: #1d4ed8; + } + .bar-restore-btn { + border: 1px solid #cbd5e1; + background: #ffffff; + color: #334155; + border-radius: 12px; + width: 40px; + height: 40px; + font-size: 24px; + font-weight: 900; + line-height: 1; + cursor: pointer; + margin: 2px auto 8px; + display: grid; + place-items: center; + box-shadow: 0 8px 16px rgba(0, 0, 0, 0.16); + } + .bar-collapse-btn { + width: 26px; + height: 26px; + border: 1px solid #cbd5e1; + border-radius: 999px; + background: #ffffff; + color: #334155; + font-size: 18px; + font-weight: 900; + line-height: 1; + display: inline-flex; + align-items: center; + justify-content: center; + cursor: pointer; + padding: 0; + flex: 0 0 auto; + } + .bar-restore-btn:hover { + border-color: #93c5fd; + background: #eff6ff; + color: #1d4ed8; + } + .bar-collapse-btn:hover { + border-color: #93c5fd; + background: #eff6ff; + color: #1d4ed8; + } + .flow-panel.flow-bar-mode .project-title, + .flow-panel.flow-bar-mode .mode-btn, + .flow-panel.flow-bar-mode .panel-badge, + .flow-panel.flow-bar-mode .title { + display: none; + } + .flow-panel.flow-bar-mode .panel-head, + .flow-panel.flow-bar-mode .panel-title-row { + display: none; + } + .flow-panel.flow-bar-mode .flow-list { + gap: 10px; + justify-items: center; + position: relative; + padding: 6px 0 10px; + } + .flow-panel.flow-bar-mode .flow-list::before { + content: ""; + position: absolute; + left: 50%; + top: 8px; + bottom: 10px; + width: 1px; + transform: translateX(-50%); + background: repeating-linear-gradient( + to bottom, + #d8e2f1 0 4px, + transparent 4px 8px + ); + } + .flow-panel.flow-bar-mode .step-btn { + width: 42px; + min-width: 42px; + height: 42px; + border-radius: 12px; + padding: 0; + border: 1px solid #cbd5e1; + background: #ffffff; + box-shadow: 0 8px 16px rgba(0, 0, 0, 0.18); + font-size: 0; + color: transparent; + overflow: visible; + justify-content: center; + text-align: center; + position: relative; + z-index: 1; + transition: transform 0.14s ease, box-shadow 0.14s ease, border-color 0.14s ease, background 0.14s ease; + } + .flow-panel.flow-bar-mode .step-btn.with-meta { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 0; + } + .flow-panel.flow-bar-mode .step-main, + .flow-panel.flow-bar-mode .step-meta { + display: none; + } + .flow-panel.flow-bar-mode .step-btn::before { + content: ""; + width: 12px; + height: 12px; + border-radius: 999px; + background: rgba(8, 39, 31, 0.94); + box-shadow: inset 0 0 0 2px rgba(255, 255, 255, 0.14); + } + .flow-panel.flow-bar-mode .step-btn:hover { + transform: translateY(-1px); + border-color: rgba(229, 188, 114, 0.58); + box-shadow: 0 12px 22px rgba(0, 0, 0, 0.24); + } + .flow-panel.flow-bar-mode .step-btn.active { + background: linear-gradient(135deg, #b8833d 0%, #efc06d 100%); + border-color: rgba(241, 202, 128, 0.7); + box-shadow: 0 12px 24px rgba(120, 74, 17, 0.34); + } + .flow-panel.flow-bar-mode .step-btn.active::before { + background: rgba(8, 39, 31, 0.94); + } + .flow-panel.flow-bar-mode .down-arrow { + width: 1px; + height: 14px; + margin: 0 auto; + background: transparent; + color: transparent; + font-size: 0; + border-radius: 999px; + position: relative; + z-index: 1; + } + .flow-panel.flow-bar-mode .down-arrow.broken { + background: transparent; + } + .flow-panel.flow-bar-mode .down-arrow.broken::after { + content: ""; + position: absolute; + left: calc(50% + 6px); + top: 50%; + transform: translate(0, -50%); + width: 0; + height: 0; + border-top: 5px solid transparent; + border-bottom: 5px solid transparent; + border-right: 8px solid #d9b261; + } + .flow-panel.flow-bar-mode .link-reason, + .flow-panel.flow-bar-mode .insert-row, + .flow-panel.flow-bar-mode .step-actions { + display: none; + } + .flow-panel.flow-bar-mode .common-head, + .flow-panel.flow-bar-mode .common-title, + .flow-panel.flow-bar-mode .common-add-btn { + display: none; + } + .flow-panel.flow-bar-mode .common-section { + border-top: 1px solid rgba(132, 187, 161, 0.16); + margin-top: 10px; + padding-top: 10px; + gap: 12px; + justify-items: center; + } + .flow-panel.flow-bar-mode .common-list { + gap: 12px; + justify-items: center; + } + .flow-panel.flow-bar-mode .common-row { + grid-template-columns: 1fr; + gap: 0; + justify-items: center; + } + .flow-panel.flow-bar-mode .common-btn { + width: 42px; + min-width: 42px; + height: 42px; + border-radius: 12px; + padding: 0; + border: 1px solid #cbd5e1; + background: #ffffff; + box-shadow: 0 8px 16px rgba(0, 0, 0, 0.18); + font-size: 0; + color: transparent; + overflow: visible; + justify-content: center; + text-align: center; + position: relative; + z-index: 1; + transition: transform 0.14s ease, box-shadow 0.14s ease, border-color 0.14s ease, background 0.14s ease; + } + .flow-panel.flow-bar-mode .common-btn::before { + content: ""; + width: 12px; + height: 12px; + border-radius: 999px; + background: rgba(8, 39, 31, 0.94); + box-shadow: inset 0 0 0 2px rgba(255, 255, 255, 0.14); + } + .flow-panel.flow-bar-mode .common-btn:hover { + transform: translateY(-1px); + border-color: rgba(229, 188, 114, 0.58); + box-shadow: 0 12px 22px rgba(0, 0, 0, 0.24); + } + .flow-panel.flow-bar-mode .common-btn.active { + background: linear-gradient(135deg, #b8833d 0%, #efc06d 100%); + border-color: rgba(241, 202, 128, 0.7); + box-shadow: 0 12px 24px rgba(120, 74, 17, 0.34); + } + .flow-panel.flow-bar-mode .common-btn.active::before { + background: rgba(8, 39, 31, 0.94); + box-shadow: inset 0 0 0 2px rgba(255, 255, 255, 0.14); + } + .bar-step-tooltip { + position: fixed; + left: 0; + top: 0; + transform: translateY(-50%); + background: rgba(5, 24, 19, 0.94); + color: #f5efe1; + border-radius: 8px; + padding: 5px 8px; + font-size: 12px; + font-weight: 700; + line-height: 1.2; + white-space: nowrap; + letter-spacing: 0; + pointer-events: none; + z-index: 9999; + opacity: 0; + visibility: hidden; + transition: opacity 0.12s ease, visibility 0.12s ease; + } + .bar-step-tooltip.show { + opacity: 1; + visibility: visible; + } + #mainPanel { + display: block; + overflow: auto; + } + #mainFlow { + overflow: visible; + min-height: auto; + max-height: none; + padding-right: 2px; + } + .common-section { + border-top: 1px solid rgba(132, 187, 161, 0.16); + padding-top: 10px; + margin-top: 10px; + display: grid; + gap: 8px; + min-height: auto; + overflow: visible; + } + .common-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + } + .common-title { + margin: 0; + font-size: 11px; + color: var(--accent-strong); + font-weight: 900; + letter-spacing: .03em; + text-transform: uppercase; + } + .project-title { + margin: 0 0 8px; + font-size: 11px; + color: var(--accent-strong); + font-weight: 900; + letter-spacing: .03em; + text-transform: uppercase; + } + .common-add-btn { + border: 1px solid rgba(224, 181, 108, 0.45); + background: rgba(79, 58, 24, 0.52); + color: #ffe19f; + border-radius: 7px; + padding: 3px 8px; + font-size: 10px; + font-weight: 800; + cursor: pointer; + } + .common-list { + display: grid; + gap: 6px; + } + .common-row { + display: grid; + grid-template-columns: 1fr; + gap: 4px; + align-items: stretch; + } + .common-btn { + border: 1px solid rgba(184, 195, 185, 0.9); + background: var(--surface-soft); + border-radius: 10px; + padding: 0 10px; + text-align: left; + font-size: 13px; + font-weight: 800; + color: var(--ink-strong); + cursor: pointer; + width: 100%; + height: var(--step-height); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + display: flex; + align-items: center; + transition: all .15s ease; + } + .common-btn:hover { + border-color: rgba(229, 188, 114, 0.58); + } + .common-btn.active { + border-color: rgba(241, 202, 128, 0.7); + background: linear-gradient(135deg, #b8833d 0%, #efc06d 100%); + box-shadow: 0 12px 24px rgba(120, 74, 17, 0.32); + color: #10241c; + } + .common-empty { + border: 1px dashed rgba(132, 187, 161, 0.22); + border-radius: 8px; + padding: 8px; + font-size: 11px; + color: var(--text-muted); + font-weight: 700; + background: rgba(255, 255, 255, 0.04); + text-align: center; + } + + .title { + margin: 0 0 10px; + font-size: 13px; + font-weight: 900; + color: var(--accent-strong); + letter-spacing: .02em; + text-transform: uppercase; + } + .panel-title-row { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 10px; + gap: 8px; + } + .panel-badge { + border-radius: 999px; + background: rgba(208, 160, 88, 0.16); + color: #ffe0a3; + border: 1px solid rgba(224, 181, 108, 0.32); + padding: 4px 9px; + font-size: 11px; + font-weight: 900; + letter-spacing: .02em; + line-height: 1; + white-space: nowrap; + } + .panel-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + margin-bottom: 10px; + } + .panel-head .title { + margin: 0; + } + .mode-btn { + border: 1px solid rgba(132, 187, 161, 0.2); + background: rgba(255, 255, 255, 0.04); + color: var(--text-soft); + border-radius: 7px; + padding: 5px 8px; + font-size: 11px; + font-weight: 800; + cursor: pointer; + white-space: nowrap; + } + .mode-btn.active { + background: linear-gradient(135deg, #b8833d 0%, #efc06d 100%); + color: #10241c; + border-color: rgba(241, 202, 128, 0.7); + } + .panel-tools { + display: flex; + gap: 6px; + align-items: center; + } + .add-end-btn { + border: 1px solid rgba(224, 181, 108, 0.45); + background: rgba(79, 58, 24, 0.52); + color: #ffe19f; + border-radius: 7px; + padding: 4px 8px; + font-size: 11px; + font-weight: 800; + cursor: pointer; + } + + .flow-list { + display: grid; + gap: 6px; + align-content: start; + } + .step-row { + display: grid; + grid-template-columns: 1fr; + gap: 4px; + align-items: stretch; + } + .step-actions { + display: flex; + gap: 4px; + align-items: center; + justify-content: flex-end; + width: 100%; + } + .step-row > .step-actions, + .common-row > .step-actions { + grid-column: 1; + } + .step-edit-meta { + width: 100%; + font-size: 10px; + font-weight: 700; + color: var(--text-muted); + line-height: 1.25; + white-space: pre-line; + padding: 0 2px; + } + .icon-btn { + border: 1px solid rgba(132, 187, 161, 0.2); + background: rgba(255, 255, 255, 0.04); + color: var(--text-soft); + border-radius: 6px; + width: 26px; + height: 26px; + font-size: 13px; + font-weight: 900; + cursor: pointer; + line-height: 1; + display: grid; + place-items: center; + } + .icon-btn.delete { + color: #ffb4a9; + border-color: rgba(236, 119, 100, 0.42); + background: rgba(72, 21, 18, 0.9); + } + + .step-btn { + border: 1px solid rgba(184, 195, 185, 0.92); + background: linear-gradient(180deg, var(--surface-plain) 0%, var(--surface-soft) 100%); + border-radius: 11px; + padding: 0 10px; + text-align: left; + font-size: 14px; + font-weight: 800; + color: var(--ink-strong); + cursor: pointer; + transition: all .15s ease; + width: 100%; + height: var(--step-height); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + display: flex; + align-items: center; + } + .step-btn:hover { border-color: rgba(229, 188, 114, 0.58); } + .step-btn.active { + border-color: rgba(241, 202, 128, 0.7); + background: linear-gradient(135deg, #b8833d 0%, #efc06d 100%); + box-shadow: 0 12px 24px rgba(120, 74, 17, 0.32); + color: #10241c; + } + .step-btn.with-meta { + white-space: normal; + display: grid; + align-content: center; + gap: 1px; + height: var(--step-height); + } + .step-main { + font-size: 14px; + font-weight: 800; + color: var(--ink-strong); + line-height: 1.2; + } + .step-btn.active .step-main { + color: #10241c; + } + .step-meta { + font-size: 10px; + font-weight: 700; + color: var(--ink-soft); + line-height: 1.2; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + .step-btn.active .step-meta { + color: rgba(16, 36, 28, 0.72); + } + + .down-arrow { + text-align: center; + color: rgba(132, 187, 161, 0.42); + font-size: 18px; + line-height: 1; + user-select: none; + } + .insert-row { + text-align: center; + display: grid; + justify-items: center; + gap: 4px; + } + .insert-btn { + border: 1px dashed rgba(229, 188, 114, 0.56); + background: rgba(79, 58, 24, 0.38); + color: #ffe19f; + border-radius: 999px; + width: 26px; + height: 26px; + font-size: 14px; + font-weight: 900; + cursor: pointer; + line-height: 1; + } + .link-edit-btn { + border: 1px solid rgba(239, 192, 109, 0.62); + background: rgba(79, 58, 24, 0.62); + color: #ffe09b; + border-radius: 999px; + padding: 2px 7px; + font-size: 10px; + font-weight: 800; + cursor: pointer; + line-height: 1.2; + white-space: nowrap; + } + .link-clear-btn { + border: 1px solid rgba(236, 119, 100, 0.42); + background: rgba(72, 21, 18, 0.9); + color: #ffb4a9; + border-radius: 999px; + padding: 2px 7px; + font-size: 10px; + font-weight: 800; + cursor: pointer; + line-height: 1.2; + white-space: nowrap; + } + .down-arrow.broken { + color: #ffd18a; + font-weight: 900; + } + .link-reason { + width: fit-content; + max-width: 170px; + border: 1px solid rgba(247, 214, 143, 0.92); + background: linear-gradient(180deg, #f5d48d 0%, #e2b255 100%); + color: #3f2d08; + border-radius: 999px; + padding: 3px 10px; + font-size: 9px; + font-weight: 900; + line-height: 1.2; + text-align: center; + word-break: keep-all; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + justify-self: center; + box-shadow: + 0 6px 14px rgba(0, 0, 0, 0.18), + inset 0 1px 0 rgba(255, 248, 226, 0.7); + letter-spacing: .01em; + } + + .empty { + border: 1px dashed rgba(132, 187, 161, 0.22); + border-radius: 10px; + padding: 12px; + font-size: 12px; + color: var(--text-muted); + font-weight: 700; + background: rgba(255, 255, 255, 0.04); + line-height: 1.4; + } + .editor-panel { + --viewer-note-font-size: 18px; + --viewer-note-line-height: 1.35; + display: none; + width: 1396px; + min-width: 1396px; + max-width: 1396px; + flex: 0 0 1396px; + height: calc(100vh - 20px); + max-height: calc(100vh - 20px); + background: linear-gradient(180deg, rgba(10, 52, 41, 0.88) 0%, rgba(7, 34, 28, 0.95) 100%); + border: 1px solid rgba(132, 187, 161, 0.18); + border-radius: 20px; + box-shadow: var(--shadow-deep); + box-shadow: + var(--shadow-deep), + inset 0 1px 0 rgba(255, 245, 222, 0.06); + grid-template-rows: auto 1fr; + overflow: hidden; + overscroll-behavior: none; + } + .board-track.map-bar-layout .editor-panel { + --viewer-note-font-size: 18px; + --viewer-note-line-height: 1.35; + } + .board-track.map-bar-layout .editor-title { + font-size: 18px; + } + .board-track.map-bar-layout .editor-viewer-title { + font-size: var(--viewer-note-font-size); + } + .board-track.map-bar-layout .preview { border-width: 2px; padding: 0; } + .board-track.map-bar-layout .preview img { + width: 100%; + height: 100%; + max-width: 100%; + max-height: 100%; + } + .editor-panel.open { display: grid; } + .editor-panel.viewer-mode .uploader { + display: none; + } + .editor-input-only {} + .editor-panel .editor-view-only { + display: none !important; + } + .editor-panel.viewer-mode .editor-input-only { + display: none !important; + } + .editor-panel.viewer-mode .editor-view-only { + display: block !important; + } + .editor-panel.viewer-mode .path-input, + .editor-panel.viewer-mode .note-input, + .editor-panel.viewer-mode .mini-input { + background: rgba(255, 255, 255, 0.05); + color: var(--text-soft); + border-color: rgba(132, 187, 161, 0.16); + box-shadow: none; + } + .editor-panel.viewer-mode .path-input:focus, + .editor-panel.viewer-mode .note-input:focus, + .editor-panel.viewer-mode .mini-input:focus { + border-color: rgba(132, 187, 161, 0.16); + box-shadow: none; + } + .editor-head { + padding: 16px 16px 14px; + border-bottom: 1px solid rgba(132, 187, 161, 0.16); + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + background: rgba(8, 37, 29, 0.92); + flex-wrap: nowrap; + } + .editor-head-info { + min-width: 0; + flex: 1 1 auto; + display: flex; + flex-direction: column; + gap: 6px; + } + .editor-panel.viewer-mode .editor-head { + align-items: flex-start; + padding: 18px 18px 16px; + } + .editor-head-path { + display: flex; + align-items: center; + gap: 8px; + flex: 0 0 630px; + min-width: 420px; + } + .editor-head-path .label { + margin: 0; + white-space: nowrap; + } + .editor-head-path .path-wrap { + flex: 1 1 auto; + } + .editor-head-path .path-input { + padding: 8px 10px; + height: 34px; + } + .editor-title { + margin: 0; + font-size: 17px; + font-weight: 900; + color: var(--text-main); + line-height: 1.2; + } + .editor-sub { + margin: 4px 0 0; + font-size: 11px; + font-weight: 700; + color: var(--text-muted); + } + .editor-viewer-summary { + display: flex; + align-items: center; + justify-content: space-between; + gap: 40px; + flex-wrap: nowrap; + width: 100%; + } + .editor-viewer-title { + margin: 0; + flex: 1 1 auto; + min-width: 0; + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 12px; + font-size: var(--viewer-note-font-size); + line-height: var(--viewer-note-line-height); + font-weight: 800; + color: var(--accent-strong); + word-break: keep-all; + } + .viewer-title-line { + position: relative; + display: block; + width: 100%; + padding-left: 22px; + } + .viewer-title-line::before { + content: ""; + position: absolute; + left: 0; + top: 0.55em; + transform: translateY(-50%); + } + .viewer-title-line.is-primary::before { + width: 14px; + height: 14px; + border-radius: 3px; + background: linear-gradient(135deg, #f0c86f 0%, #c9973c 100%); + box-shadow: + 0 0 0 1px rgba(255, 236, 186, 0.22), + 0 4px 10px rgba(24, 10, 0, 0.18); + top: 50%; + transform: translateY(-50%); + } + .viewer-title-line.is-continuation { + padding-left: 0; + color: inherit; + font-size: inherit; + font-weight: inherit; + } + .viewer-title-line.is-continuation::before { + content: none; + } + .editor-viewer-meta { + display: flex; + flex: 0 0 auto; + flex-wrap: nowrap; + align-items: center; + justify-content: flex-end; + gap: 0; + margin-top: 13px; + margin-left: auto; + max-width: none; + white-space: nowrap; + } + .editor-viewer-chip { + display: inline-flex; + align-items: center; + padding: 0; + color: var(--text-soft); + font-size: 12px; + font-weight: 500; + white-space: nowrap; + } + .editor-viewer-chip + .editor-viewer-chip { + position: relative; + margin-left: 10px; + padding-left: 11px; + } + .editor-viewer-chip + .editor-viewer-chip::before { + content: ""; + position: absolute; + left: 0; + top: 50%; + width: 1px; + height: 12px; + background: rgba(132, 187, 161, 0.24); + transform: translateY(-50%); + } + .editor-viewer-chip.is-empty { + display: none; + } + .editor-close { + border: 1px solid rgba(132, 187, 161, 0.18); + background: rgba(255, 255, 255, 0.04); + color: var(--text-soft); + width: 34px; + height: 34px; + border-radius: 999px; + font-weight: 900; + cursor: pointer; + line-height: 1; + } + .editor-head-actions { + display: flex; + align-items: center; + gap: 4px; + flex: 0 0 auto; + } + .ci-logo { + display: block; + width: auto; + height: 30px; + max-width: 132px; + object-fit: contain; + flex: 0 0 auto; + } + .editor-ci-logo { + margin: 0; + } + .editor-panel.viewer-mode .editor-ci-logo, + .editor-panel.viewer-mode .editor-close { + display: none; + } + .editor-panel.viewer-mode .editor-body { + display: flex; + flex-direction: column; + min-height: 0; + } + .editor-panel.viewer-mode .flow-info-card.open { + margin-top: 17px; + } + .editor-panel.viewer-mode .flow-info-card.open .viewer-mini-grid { + margin-top: 13px; + } + .editor-panel.viewer-mode .flow-info-card.open + .section-divider { + margin-top: 15px; + } + .editor-panel.viewer-mode .flow-info-card.open + .section-divider + .screen-info-section { + margin-top: 13px; + } + .editor-body { + padding: 14px 16px 16px; + overflow: auto; + display: grid; + align-content: start; + gap: 12px; + background: linear-gradient(180deg, #f7f4ec 0%, #eef4ef 100%); + min-height: 0; + overscroll-behavior: none; + } + .editor-card { + background: transparent; + border: 0; + border-radius: 0; + padding: 0; + } + .image-card { + --preview-ratio: var(--dynamic-preview-ratio, 2); + min-height: 0; + display: grid; + grid-template-rows: auto auto auto auto; + gap: 10px; + align-content: start; + overscroll-behavior: none; + } + .image-card > .label-row, + .image-card > .image-nav, + .image-card > .uploader { + display: flex; + gap: 8px; + } + .image-card > .image-nav { + align-items: center; + justify-content: center; + flex-wrap: nowrap; + } + .image-note-field { + display: grid; + gap: 4px; + align-content: start; + } + .label { + margin: 0 0 4px; + font-size: 10px; + color: var(--accent-strong); + font-weight: 900; + letter-spacing: .03em; + text-transform: uppercase; + } + .label-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + margin-bottom: 0; + } + .label-row .label { + margin: 0; + white-space: nowrap; + } + .uploader { + display: flex; + gap: 6px; + align-items: center; + margin-bottom: 0; + width: 100%; + } + .file-input-hidden { + display: none; + } + .file-btn { + border: 1px solid #564cf5; + background: linear-gradient(135deg, #3f55f2 0%, #6b3cf1 100%); + color: #fff; + border-radius: 10px; + padding: 10px 14px; + font-size: 13px; + font-weight: 800; + cursor: pointer; + line-height: 1.2; + white-space: nowrap; + flex: 1; + text-align: center; + } + .file-status { + font-size: 10px; + font-weight: 700; + color: #475569; + white-space: nowrap; + display: none; + } + .path-wrap { margin-bottom: 0; } + .path-input { + width: 100%; + border: 1px solid #cbd5e1; + border-radius: 9px; + padding: 10px 12px; + font-size: 12px; + outline: none; + background: #fff; + } + .path-input:focus { + border-color: #3b82f6; + box-shadow: 0 0 0 2px #dbeafe; + } + .viewer-value, + .viewer-meta-value { + border: 1px solid #dbe5f2; + border-radius: 12px; + background: #ffffff; + color: #1e293b; + font-size: 13px; + line-height: 1.6; + font-weight: 700; + } + .viewer-value { + min-height: 44px; + padding: 12px 14px; + white-space: pre-wrap; + word-break: break-word; + box-shadow: inset 0 1px 0 rgba(255,255,255,0.7); + } + .viewer-value.is-empty, + .viewer-meta-value.is-empty { + color: #94a3b8; + font-weight: 600; + } + .viewer-mini-grid { + display: grid; + grid-template-columns: 1fr 1fr 1fr; + gap: 10px; + } + .editor-panel.viewer-mode .viewer-mini-grid { + display: grid !important; + } + .viewer-meta-card { + display: grid; + gap: 4px; + align-content: start; + } + .viewer-meta-value { + min-height: 42px; + padding: 10px 12px; + display: flex; + align-items: center; + } + .editor-panel.viewer-mode .step-info-section { + display: none; + } + .editor-panel.viewer-mode .screen-info-section .section-title, + .editor-panel.viewer-mode .screen-info-section .path-card > .label, + .editor-panel.viewer-mode .screen-info-section .image-note-card > .label, + .editor-panel.viewer-mode .screen-info-section .image-card > .label-row > .label { + display: none; + } + .editor-panel.viewer-mode .screen-info-section { + display: grid; + grid-template-rows: auto auto auto; + flex: 0 0 auto; + min-height: 0; + gap: 2px; + } + .editor-panel.viewer-mode .screen-info-section .path-card { + margin-top: -2px; + } + .editor-panel.viewer-mode #editorPathView { + border: 0; + border-radius: 0; + background: transparent; + box-shadow: none; + min-height: 1.35em; + padding: 0; + color: #475569; + font-size: 12px; + line-height: 1.35; + font-weight: 500; + text-align: right; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + .editor-panel.viewer-mode #editorNoteView, + .editor-panel.viewer-mode #editorImageNoteView { + font-family: "Segoe UI", "Noto Sans KR", sans-serif !important; + border: 0 !important; + border-radius: 0 !important; + background: transparent !important; + box-shadow: none !important; + min-height: 2.2em !important; + max-height: 2.2em !important; + padding: 0 !important; + color: #22483a !important; + font-size: var(--viewer-note-font-size) !important; + line-height: var(--viewer-note-line-height) !important; + font-weight: 800 !important; + letter-spacing: 0 !important; + text-align: left !important; + white-space: normal !important; + word-break: break-word !important; + overflow: hidden !important; + display: -webkit-box !important; + -webkit-line-clamp: 2 !important; + -webkit-box-orient: vertical !important; + } + .editor-panel.viewer-mode #editorImageNoteView { + position: relative; + padding-left: 18px !important; + } + .editor-panel.viewer-mode #editorImageNoteView:not(.is-empty)::before { + content: ""; + position: absolute; + left: 0; + top: calc(var(--viewer-note-line-height) * 0.5em); + width: 7px; + height: 7px; + border-radius: 999px; + background: #2d6758; + box-shadow: 0 0 0 2px rgba(45, 103, 88, 0.12); + transform: translateY(-50%); + } + .editor-panel.viewer-mode .screen-info-section .image-card { + height: auto; + min-height: 0; + grid-template-rows: auto auto; + align-content: start; + margin-top: 0; + gap: 4px; + } + .editor-panel.viewer-mode .screen-info-section .preview { + aspect-ratio: auto; + height: clamp(450px, 70vh, 828px); + min-height: 450px; + max-height: 828px; + } + .editor-panel.viewer-mode .screen-info-section .image-nav { + margin-top: 10px; + margin-bottom: 16px; + min-height: 34px; + display: flex !important; + visibility: visible !important; + opacity: 1 !important; + } + .clear-btn { + border: 1px solid #cfd7d0; + border-radius: 10px; + background: #f5f1e8; + color: var(--ink-strong); + font-size: 13px; + font-weight: 800; + padding: 10px 14px; + cursor: pointer; + white-space: nowrap; + } + .preview { + border: 1px solid #d7ddd6; + border-radius: 12px; + background: #fcfaf5; + width: 100%; + aspect-ratio: var(--preview-ratio); + display: grid; + place-items: center; + overflow: hidden; + flex-shrink: 0; + overscroll-behavior: none; + } + .image-nav { + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + margin-top: 2px; + } + .nav-btn { + border: 1px solid #cfd7d0; + background: #fffdf8; + color: var(--ink-strong); + border-radius: 9px; + padding: 8px 10px; + font-size: 11px; + font-weight: 800; + cursor: pointer; + } + .nav-btn:disabled { + opacity: 0.45; + cursor: default; + } + .page-info { + min-width: 54px; + text-align: center; + font-size: 11px; + font-weight: 800; + color: var(--ink-soft); + } + .editor-panel *::-webkit-scrollbar { + width: 0; + height: 0; + } + .image-modal { + position: fixed; + inset: 0; + background: rgba(15, 23, 42, 0.72); + display: none; + align-items: center; + justify-content: center; + z-index: 80; + padding: 24px; + } + .image-modal.open { + display: flex; + } + .image-modal-inner { + position: relative; + width: min(92vw, 1600px); + height: min(90vh, 980px); + background: #f6f2ea; + border: 1px solid #cfd7d0; + border-radius: 12px; + overflow: hidden; + display: grid; + place-items: center; + } + .image-modal-nav { + position: absolute; + left: 50%; + bottom: 12px; + transform: translateX(-50%); + display: flex; + align-items: center; + gap: 8px; + z-index: 2; + background: rgba(15, 23, 42, 0.55); + border: 1px solid rgba(148, 163, 184, 0.35); + border-radius: 999px; + padding: 6px 8px; + } + .image-modal-note { + position: absolute; + left: 50%; + bottom: 58px; + transform: translateX(-50%); + max-width: min(84vw, 1200px); + z-index: 2; + background: rgba(15, 23, 42, 0.62); + border: 1px solid rgba(148, 163, 184, 0.35); + border-radius: 12px; + color: #f7f3e8; + font-size: 13px; + font-weight: 700; + line-height: 1.35; + padding: 8px 12px; + text-align: left; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + display: none; + } + .image-modal-note.show { + display: block; + } + .image-modal-btn { + border: 1px solid #64748b; + background: rgba(15, 23, 42, 0.6); + color: #e2e8f0; + border-radius: 999px; + padding: 5px 10px; + font-size: 12px; + font-weight: 800; + cursor: pointer; + line-height: 1.1; + } + .image-modal-btn:disabled { + opacity: 0.4; + cursor: default; + } + .image-modal-page { + min-width: 56px; + text-align: center; + color: #e2e8f0; + font-size: 12px; + font-weight: 800; + } + .image-modal img { + width: 100%; + height: 100%; + object-fit: contain; + background: #f6f2ea; + } + .image-modal-close { + position: absolute; + top: 10px; + right: 10px; + border: 1px solid #475569; + background: rgba(15, 23, 42, 0.75); + color: #fff; + width: 32px; + height: 32px; + border-radius: 999px; + font-size: 18px; + font-weight: 900; + cursor: pointer; + line-height: 1; + } + .sitemap-modal { + position: fixed; + inset: 0; + background: rgba(15, 23, 42, 0.45); + display: none; + align-items: center; + justify-content: center; + z-index: 70; + padding: 18px; + } + .sitemap-modal.open { + display: flex; + } + .sitemap-modal-inner { + width: min(99.4vw, 1720px); + height: min(95vh, 1180px); + background: #ffffff; + border: 1px solid #dbe5f2; + border-radius: 28px; + box-shadow: 0 28px 60px rgba(15, 23, 42, 0.22); + overflow: hidden; + display: grid; + grid-template-rows: auto 1fr; + } + .sitemap-modal-head { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + grid-template-areas: + "title tools" + "legend legend"; + align-items: start; + column-gap: 20px; + row-gap: 14px; + padding: 32px 40px 26px; + border-bottom: 1px solid #dbe5f2; + background: #ffffff; + } + .sitemap-modal-head > div:first-child { + grid-area: title; + min-width: 0; + } + .sitemap-modal-title { + margin: 0; + font-size: 34px; + font-weight: 900; + color: #132949; + letter-spacing: .01em; + text-transform: none; + } + .sitemap-modal-sub { + margin: 6px 0 0; + font-size: 15px; + font-weight: 700; + color: #6f819d; + } + .sitemap-close-btn { + border: 1px solid #dbe5f2; + background: #f3f7fc; + color: #5f738f; + width: 40px; + height: 40px; + border-radius: 999px; + font-size: 24px; + font-weight: 900; + cursor: pointer; + line-height: 1; + flex: 0 0 auto; + } + .sitemap-toolbar { + grid-area: tools; + display: flex; + gap: 14px; + align-items: center; + flex-wrap: nowrap; + margin-top: 0; + align-self: start; + } + .sitemap-ci-logo { + height: 28px; + max-width: 124px; + margin-left: 4px; + } + .sm-select { + border: 1px solid #ced9e8; + background: #fff; + color: #0f172a; + border-radius: 12px; + padding: 11px 14px; + font-size: 15px; + font-weight: 700; + min-width: 220px; + } + .sm-legend { + grid-area: legend; + margin-top: 0; + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 10px 12px; + } + .sm-legend-item { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 5px 10px; + border: 1px solid #dbe5f2; + border-radius: 999px; + background: #fff; + font-size: 13px; + font-weight: 700; + color: #334155; + line-height: 1; + } + .sm-legend-swatch { + width: 12px; + height: 12px; + border-radius: 999px; + border: 1px solid #cbd5e1; + flex: 0 0 auto; + } + .sitemap-full { + display: grid; + gap: 0; + align-content: start; + font-size: 15px; + color: #0f172a; + font-weight: 800; + overflow-y: auto; + overflow-x: hidden; + background: #ffffff; + padding: 18px 40px 32px; + } + .sm-section-title { + margin: 0; + padding: 16px 0 10px; + font-size: 13px; + font-weight: 900; + color: #60738f; + letter-spacing: .03em; + text-transform: uppercase; + } + .sm-row { + display: flex; + align-items: flex-start; + gap: 24px; + flex-wrap: nowrap; + min-width: 100%; + padding: 18px 0; + border-bottom: 1px solid #e2e8f0; + } + .sm-main { + min-width: 156px; + font-weight: 900; + color: #0f2343; + font-size: 21px; + line-height: 1.25; + padding-top: 3px; + } + .sm-chain { + display: flex; + align-items: center; + gap: 0; + flex-wrap: nowrap; + min-width: 0; + flex: 1 1 auto; + } + .sm-arrow { + position: relative; + display: inline-block; + width: 34px; + height: 2px; + margin: 0; + background: #4b5563; + font-size: 0; + line-height: 0; + flex: 0 0 auto; + } + .sm-arrow::after { + content: ""; + position: absolute; + right: -1px; + top: -4px; + width: 0; + height: 0; + border-top: 5px solid transparent; + border-bottom: 5px solid transparent; + border-left: 8px solid #4b5563; + } + .sm-link-wrap { + display: inline-flex; + align-items: center; + gap: 0; + min-width: 34px; + flex: 0 0 auto; + justify-content: stretch; + } + .sm-link-arrow-frag { + color: #d97706; + font-size: 0; + font-weight: 800; + letter-spacing: .01em; + line-height: 0; + flex: 1 1 12px; + min-width: 12px; + height: 0; + border-top: 2px dashed #f59e0b; + display: inline-block; + } + .sm-arrow.broken { + color: #d97706; + width: 34px; + height: 2px; + margin: 0; + background: repeating-linear-gradient(90deg, #f59e0b 0 5px, transparent 5px 8px); + font-size: 0; + font-weight: 900; + line-height: 0; + } + .sm-arrow.broken::after { + display: block; + border-left-color: #f59e0b; + } + .sm-chip { + border: 1px solid #d2ddeb; + border-radius: 16px; + width: 108px; + min-width: 108px; + min-height: 44px; + padding: 6px 8px; + background: #f8fbff; + font-size: 13px; + font-weight: 800; + color: #3b5b80; + line-height: 1.25; + white-space: normal; + word-break: keep-all; + cursor: pointer; + display: inline-flex; + align-items: center; + justify-content: center; + text-align: center; + } + .sm-chip:hover { + box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.16) inset; + } + .sm-chip.match { + border-color: #2563eb; + box-shadow: 0 0 0 2px rgba(37, 99, 235, 0.18) inset; + } + .sm-chip.system-colored { + background: var(--sys-bg, #f8fbff); + border-color: var(--sys-border, #d2ddeb); + color: var(--sys-text, #1f2937); + } + .sm-chip.multi-system { + background: + linear-gradient(135deg, + var(--sys-bg-a, #eef2ff) 0 49.5%, + var(--sys-bg-b, #ecfeff) 50% 100%); + border-color: var(--sys-border-a, #b6c6e0); + color: #1f2937; + box-shadow: inset -1px -1px 0 0 var(--sys-border-b, #b6c6e0); + } + .sm-chip.no-system { + background: #e5e7eb; + border-color: #cbd5e1; + color: #6b7280; + } + .sm-chip.dim { + opacity: 0.14; + filter: grayscale(0.2); + } + .sm-link-reason { + border: 0; + background: #fffbeb; + color: #b45309; + border-radius: 12px; + padding: 2px 5px; + font-size: 10px; + font-weight: 700; + line-height: 1.2; + max-width: 86px; + white-space: normal; + overflow: visible; + text-overflow: clip; + text-align: center; + word-break: keep-all; + flex: 0 1 auto; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + } + .sm-link-wrap.manual { + gap: 0; + min-width: 0; + } + .sm-link-wrap.manual .sm-link-arrow-frag:first-child { + margin-right: 0; + } + .sm-link-wrap.manual .sm-link-arrow-frag:last-child { + position: relative; + margin-left: 0; + } + .sm-link-wrap.manual .sm-link-arrow-frag:last-child::after { + content: ""; + position: absolute; + right: -2px; + top: 50%; + transform: translateY(-50%); + width: 0; + height: 0; + border-top: 5px solid transparent; + border-bottom: 5px solid transparent; + border-left: 8px solid #f59e0b; + } + .sm-link-wrap:not(.manual) .sm-arrow { + width: 100%; + flex: 1 1 auto; + } + .sm-shared-box { + display: inline-flex; + align-items: center; + gap: 0; + flex-wrap: nowrap; + border: 1px dashed #f97316; + border-radius: 12px; + padding: 6px 8px; + background: #fff7ed; + } + .sm-arrow.dim, + .sm-main.dim { + opacity: 0.14; + } + .preview img { + width: 100%; + height: 100%; + max-width: 100%; + max-height: 100%; + object-fit: contain; + object-position: center center; + display: block; + background: transparent; + } + .note-card { + display: block; + } + .editor-section { + display: grid; + gap: 12px; + align-content: start; + } + .section-title { + margin: 0; + font-size: 11px; + font-weight: 900; + letter-spacing: .08em; + text-transform: uppercase; + color: #295240; + } + .flow-info-card { + display: none; + } + .flow-info-card.open { + display: grid; + gap: 6px; + align-content: start; + margin-top: 4px; + } + .section-divider { + display: block; + height: 1px; + border-top: 1px solid #dbe5f2; + margin: 2px 0; + } + .mini-grid { + display: grid; + grid-template-columns: 1fr 1fr 1fr; + gap: 8px; + } + .mini-label { + margin: 0 0 4px; + font-size: 10px; + color: #587064; + font-weight: 900; + letter-spacing: .03em; + text-transform: uppercase; + } + .mini-input { + width: 100%; + border: 1px solid #cbd5e1; + border-radius: 9px; + padding: 10px 12px; + font-size: 12px; + outline: none; + background: #fffdf8; + } + .mini-input:focus { + border-color: #3b82f6; + box-shadow: 0 0 0 2px #dbeafe; + } + .preview-empty { + margin: 0; + padding: 8px; + color: #6b7f76; + font-size: 11px; + font-weight: 700; + text-align: center; + line-height: 1.35; + } + .note-input { + width: 100%; + border: 1px solid #cbd5e1; + border-radius: 10px; + padding: 10px 12px; + font-size: 12px; + height: 42px; + outline: none; + background: #fff; + } + .note-input:focus { + border-color: #3b82f6; + box-shadow: 0 0 0 2px #dbeafe; + } + + @media (max-width: 1000px) { + .board-track { + padding-left: var(--track-side-pad, 15px); + padding-right: var(--track-side-pad, 15px); + } + .sitemap-fab { + left: 10px; + top: 10px; + width: 40px; + height: 40px; + font-size: 10px; + } + .data-io { + left: 10px; + bottom: 10px; + } + .data-io-btn { + width: 34px; + height: 34px; + font-size: 14px; + } + .floating-edit-btn { + width: 36px; + min-width: 36px; + height: 36px; + padding: 0; + font-size: 10px; + } + .flow-panel { + width: 165px; + min-width: 165px; + } + .flow-panel.flow-bar-mode { + width: var(--bar-panel-width, 58px); + min-width: var(--bar-panel-width, 58px); + } + .step-btn { font-size: 14px; } + .editor-panel { + width: 900px; + min-width: 900px; + max-width: 900px; + flex: 0 0 900px; + } + .board-track.map-bar-layout .editor-panel { } + .mini-grid { + grid-template-columns: 1fr; + } + .viewer-mini-grid { + grid-template-columns: 1fr; + } + .editor-viewer-summary { + flex-direction: column; + align-items: flex-start; + } + .editor-viewer-meta { + max-width: 100%; + justify-content: flex-start; + margin-left: 0; + white-space: normal; + } + .preview { + min-height: 240px; + } + .sitemap-modal-inner { + width: min(98vw, 1000px); + height: 92vh; + } + .editor-head-path { + flex: 1 1 auto; + min-width: 0; + } + .ci-logo { + height: 26px; + max-width: 108px; + } + .sitemap-modal-head { + padding: 18px 18px 14px; + } + .sitemap-modal-title { font-size: 22px; } + .sitemap-modal-sub { font-size: 14px; } + .sm-select { + min-width: 180px; + font-size: 14px; + } + .sitemap-full { + padding: 10px 18px 16px; + } + .sm-main { + min-width: 118px; + font-size: 18px; + padding-top: 2px; + } + .sm-chain { + gap: 0; + } + .sm-chip { + width: 96px; + min-width: 96px; + min-height: 40px; + font-size: 13px; + padding: 6px 8px; + } + .sm-link-reason { + max-width: 72px; + font-size: 10px; + padding: 2px 4px; + } + .sm-link-arrow-frag { + min-width: 8px; + } + .sm-arrow, + .sm-arrow.broken, + .sm-link-wrap { + min-width: 28px; + width: 28px; + } + .sm-link-wrap.manual { + min-width: 0; + } + } diff --git a/static/hm-biz-process/app_client_config.js b/static/hm-biz-process/app_client_config.js new file mode 100644 index 0000000..6ab191d --- /dev/null +++ b/static/hm-biz-process/app_client_config.js @@ -0,0 +1,54 @@ +const HMBIZ_LOCATION_HOST = String(window.location.hostname || '').trim(); +const HMBIZ_LOCATION_PORT = String(window.location.port || '').trim(); +const HMBIZ_LOCATION_PROTOCOL = String(window.location.protocol || 'http:'); +const HMBIZ_LOCATION_PATHNAME = String(window.location.pathname || '/'); + +const HMBIZ_IS_LOCAL_LIVE_SERVER = HMBIZ_LOCATION_PORT === '5500'; +const HMBIZ_IS_HMAC_HOST = /\.hmac\.kr$/i.test(HMBIZ_LOCATION_HOST) || HMBIZ_LOCATION_HOST === 'hmac.kr'; +const HMBIZ_IS_GITEA_HOST = /^gitea\.hmac\.kr$/i.test(HMBIZ_LOCATION_HOST); +const HMBIZ_IS_STATIC_HTML_VIEW = /\.html?$/i.test(HMBIZ_LOCATION_PATHNAME); +const HMBIZ_REMOTE_RAW_BASE = 'https://gitea.hmac.kr/tech-planning/hm-biz-process/raw/branch/feature/viewer-ui-cleanup'; + +function hmbizNormalizePath(path) { + const raw = String(path || '').trim(); + if (!raw) return '/'; + return raw.startsWith('/') ? raw : `/${raw}`; +} + +function hmbizDirectoryPath(pathname) { + const normalized = hmbizNormalizePath(pathname); + const idx = normalized.lastIndexOf('/'); + if (idx <= 0) return '/'; + return normalized.slice(0, idx); +} + +function hmbizJoinPath(baseDir, fileName) { + const dir = hmbizDirectoryPath(baseDir); + return `${dir}/${String(fileName || '').replace(/^\/+/, '')}`; +} + +const HMBIZ_CURRENT_DIR = hmbizDirectoryPath(HMBIZ_LOCATION_PATHNAME); + +const HMBIZ_API_BASE_URL = '/biz-process-viewer'; +const HMBIZ_PROCESS_MAP_ROUTE = '/biz-process-viewer/process-map'; +const HMBIZ_MAIN_APP_PATH = '/static/hm-biz-process/flow_260320.html'; +const HMBIZ_DEFAULT_JSON_CANDIDATES = []; + +window.HMBIZ_CONFIG = Object.freeze({ + apiBaseUrl: HMBIZ_API_BASE_URL, + forceRemoteSync: true, + seedVersion: 'flow-data-2026-03-23', + defaultJsonCandidates: HMBIZ_DEFAULT_JSON_CANDIDATES, + processMapRoute: HMBIZ_PROCESS_MAP_ROUTE, + processMapPopupName: 'hm-process-map', + processMapBroadcastChannel: 'hm-biz-process-map', + processMapStorageSignalKey: 'hm-biz-process-map:refresh', + processMapNavigationStorageKey: 'hm-biz-process-map:navigate', + processMapFocusStorageKey: 'hm-biz-process-map:focus', + processMapPollIntervalMs: 60000, + apiFetchTimeoutMs: 4500, + mainAppPath: HMBIZ_MAIN_APP_PATH, + sitemapAutoLinkWidth: 132, + sitemapManualLinkWidth: 132, + sharedSegment: ['전표작성', '검토', '출금'] +}); diff --git a/static/hm-biz-process/flow_260320.html b/static/hm-biz-process/flow_260320.html new file mode 100755 index 0000000..7c29f03 --- /dev/null +++ b/static/hm-biz-process/flow_260320.html @@ -0,0 +1,1430 @@ + + + + + + Flow Drilldown + + + + +
+
DB 불러오는 중
+ + + + +
+
+
+
+
+

PROJECT FLOW

+
+

프로젝트

+
+
+
+
+ +
+
+ + + + + + + + + + + + + diff --git a/static/hm-biz-process/flow_data_store.js b/static/hm-biz-process/flow_data_store.js new file mode 100644 index 0000000..04982bf --- /dev/null +++ b/static/hm-biz-process/flow_data_store.js @@ -0,0 +1,441 @@ +(function () { + function createFlowDataStore(options) { + const { + config, + getFlowModelPayload, + getStepMeta, + getEntityMeta = () => ({}), + setStepMeta, + setEntityMeta = () => {}, + applyFlowModelObject, + migrateStepMetaKeys = () => false, + setSyncStatus = () => {}, + onRemoteSaved = () => {} + } = options; + + const { + apiFlowDataUrl, + stepMetaKey, + flowModelKey, + entityMetaKey, + bootstrapMarkerKey, + bootstrapMarkerValue, + idbDbName, + idbStoreName, + defaultJsonCandidates = [] + } = config; + + let idbOpenPromise = null; + let remoteSaveTimer = null; + let remoteSaveInFlight = false; + let remoteSaveQueued = false; + + function buildNoCacheUrl(url) { + const target = String(url || '').trim(); + if (!target) return target; + const joiner = target.includes('?') ? '&' : '?'; + return `${target}${joiner}_ts=${Date.now()}`; + } + + function openIdb() { + if (!('indexedDB' in window)) return Promise.resolve(null); + if (idbOpenPromise) return idbOpenPromise; + idbOpenPromise = new Promise((resolve) => { + try { + const req = indexedDB.open(idbDbName, 1); + req.onupgradeneeded = () => { + const db = req.result; + if (!db.objectStoreNames.contains(idbStoreName)) { + db.createObjectStore(idbStoreName, { keyPath: 'k' }); + } + }; + req.onsuccess = () => resolve(req.result); + req.onerror = () => resolve(null); + } catch (error) { + resolve(null); + } + }); + return idbOpenPromise; + } + + async function idbSet(key, value) { + const db = await openIdb(); + if (!db) return false; + return new Promise((resolve) => { + try { + const tx = db.transaction(idbStoreName, 'readwrite'); + tx.objectStore(idbStoreName).put({ k: key, v: value }); + tx.oncomplete = () => resolve(true); + tx.onerror = () => resolve(false); + } catch (error) { + resolve(false); + } + }); + } + + async function idbGet(key) { + const db = await openIdb(); + if (!db) return null; + return new Promise((resolve) => { + try { + const tx = db.transaction(idbStoreName, 'readonly'); + const req = tx.objectStore(idbStoreName).get(key); + req.onsuccess = () => resolve(req.result ? req.result.v : null); + req.onerror = () => resolve(null); + } catch (error) { + resolve(null); + } + }); + } + + function persistMarker() { + try { + localStorage.setItem(bootstrapMarkerKey, bootstrapMarkerValue); + } catch (error) { + // ignore localStorage failures + } + idbSet(bootstrapMarkerKey, bootstrapMarkerValue); + } + + function persistLocalSnapshot( + flowModelPayload = getFlowModelPayload(), + stepMetaPayload = getStepMeta(), + entityMetaPayload = getEntityMeta() + ) { + try { + localStorage.setItem(flowModelKey, JSON.stringify(flowModelPayload)); + } catch (error) { + // localStorage quota exceeded; IndexedDB fallback handles persistence + } + try { + localStorage.setItem(stepMetaKey, JSON.stringify(stepMetaPayload)); + } catch (error) { + // localStorage quota exceeded; IndexedDB fallback handles persistence + } + try { + localStorage.setItem(entityMetaKey, JSON.stringify(entityMetaPayload)); + } catch (error) { + // localStorage quota exceeded; IndexedDB fallback handles persistence + } + idbSet(flowModelKey, flowModelPayload); + idbSet(stepMetaKey, stepMetaPayload); + idbSet(entityMetaKey, entityMetaPayload); + } + + function buildCurrentPayload() { + return { + version: 1, + exportedAt: new Date().toISOString(), + flowModel: getFlowModelPayload(), + stepMeta: getStepMeta(), + entityMeta: getEntityMeta() + }; + } + + function extractImportPayload(parsed) { + if (!parsed || typeof parsed !== 'object') return null; + const flowModel = (parsed.flowModel && typeof parsed.flowModel === 'object') + ? parsed.flowModel + : ((Array.isArray(parsed.mainSteps) || (parsed.subFlow && typeof parsed.subFlow === 'object')) + ? parsed + : null); + if (!flowModel || typeof flowModel !== 'object') return null; + return { + flowModel, + importedMeta: (parsed.stepMeta && typeof parsed.stepMeta === 'object') ? parsed.stepMeta : {}, + entityMeta: (parsed.entityMeta && typeof parsed.entityMeta === 'object') ? parsed.entityMeta : {} + }; + } + + function applyImportedData(flowModel, importedMeta, entityMeta, persistRemote = true) { + applyFlowModelObject(flowModel); + setStepMeta((importedMeta && typeof importedMeta === 'object') ? importedMeta : {}); + setEntityMeta((entityMeta && typeof entityMeta === 'object') ? entityMeta : {}); + migrateStepMetaKeys(); + persistLocalSnapshot(getFlowModelPayload(), getStepMeta(), getEntityMeta()); + if (persistRemote) scheduleRemoteSave(); + return true; + } + + async function persistRemoteData() { + if (remoteSaveInFlight) { + remoteSaveQueued = true; + return; + } + remoteSaveInFlight = true; + setSyncStatus('saving', 'DB 저장 중'); + try { + const res = await fetch(buildNoCacheUrl(apiFlowDataUrl), { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + 'Cache-Control': 'no-cache', + Pragma: 'no-cache' + }, + body: JSON.stringify(buildCurrentPayload()) + }); + if (!res.ok) { + console.warn('DB save failed', res.status); + setSyncStatus('error', 'DB 저장 실패'); + } else { + setSyncStatus('ready', 'DB 저장됨'); + onRemoteSaved(); + } + } catch (error) { + console.warn('DB save failed', error); + setSyncStatus('error', 'DB 저장 실패'); + } finally { + remoteSaveInFlight = false; + if (remoteSaveQueued) { + remoteSaveQueued = false; + persistRemoteData(); + } + } + } + + function scheduleRemoteSave() { + if (remoteSaveTimer) clearTimeout(remoteSaveTimer); + remoteSaveTimer = window.setTimeout(() => { + remoteSaveTimer = null; + persistRemoteData(); + }, 250); + } + + function loadStepMeta() { + try { + const raw = localStorage.getItem(stepMetaKey); + if (!raw) return; + const parsed = JSON.parse(raw); + if (!parsed || typeof parsed !== 'object') return; + setStepMeta(parsed); + migrateStepMetaKeys(); + } catch (error) { + setStepMeta({}); + } + } + + function loadFlowModel() { + try { + const raw = localStorage.getItem(flowModelKey); + if (!raw) return; + const parsed = JSON.parse(raw); + applyFlowModelObject(parsed); + } catch (error) { + // ignore malformed saved model + } + } + + async function loadFromIdbFallback() { + const hasLocalFlow = !!localStorage.getItem(flowModelKey); + const hasLocalMeta = !!localStorage.getItem(stepMetaKey); + const hasLocalEntityMeta = !!localStorage.getItem(entityMetaKey); + if (!hasLocalFlow) { + const flow = await idbGet(flowModelKey); + if (flow && typeof flow === 'object') applyFlowModelObject(flow); + } + if (!hasLocalMeta) { + const meta = await idbGet(stepMetaKey); + if (meta && typeof meta === 'object') { + setStepMeta(meta); + migrateStepMetaKeys(); + } + } + if (!hasLocalEntityMeta) { + const entityMeta = await idbGet(entityMetaKey); + if (entityMeta && typeof entityMeta === 'object') { + setEntityMeta(entityMeta); + } + } + if (localStorage.getItem(bootstrapMarkerKey) !== bootstrapMarkerValue) { + const marker = await idbGet(bootstrapMarkerKey); + if (marker === bootstrapMarkerValue) { + try { + localStorage.setItem(bootstrapMarkerKey, bootstrapMarkerValue); + } catch (error) { + // ignore localStorage failures + } + } + } + } + + async function loadRemoteData() { + setSyncStatus('loading', 'DB 불러오는 중'); + try { + const res = await fetch(buildNoCacheUrl(apiFlowDataUrl), { + cache: 'no-store', + headers: { + 'Cache-Control': 'no-cache', + Pragma: 'no-cache' + } + }); + if (!res.ok) { + if (res.status === 404) return false; + throw new Error(`HTTP ${res.status}`); + } + const parsed = await res.json(); + const extracted = extractImportPayload(parsed); + if (!extracted) return false; + applyImportedData(extracted.flowModel, extracted.importedMeta, extracted.entityMeta, false); + persistMarker(); + setSyncStatus('ready', 'DB 연결됨'); + return true; + } catch (error) { + console.warn('DB load failed', error); + const loadedFromJson = await loadFromDefaultJsonCandidates(false); + if (loadedFromJson) { + setSyncStatus('ready', 'Gitea 데이터 불러옴'); + return true; + } + return false; + } + } + + async function loadFromDefaultJsonCandidates(persistRemote = true) { + for (const path of defaultJsonCandidates) { + try { + let parsed = null; + try { + const res = await fetch(path, { cache: 'no-store' }); + if (res.ok) parsed = await res.json(); + } catch (error) { + // fall through to XHR + } + if (!parsed) { + parsed = await new Promise((resolve) => { + try { + const xhr = new XMLHttpRequest(); + xhr.open('GET', path, true); + xhr.onreadystatechange = () => { + if (xhr.readyState !== 4) return; + if (xhr.status === 200 || (xhr.status === 0 && xhr.responseText)) { + try { + resolve(JSON.parse(xhr.responseText)); + } catch (error) { + resolve(null); + } + } else { + resolve(null); + } + }; + xhr.send(); + } catch (error) { + resolve(null); + } + }); + } + if (!parsed) continue; + const extracted = extractImportPayload(parsed); + if (!extracted) continue; + applyImportedData( + extracted.flowModel, + extracted.importedMeta, + extracted.entityMeta, + persistRemote + ); + persistMarker(); + return true; + } catch (error) { + // ignore and try next candidate + } + } + return false; + } + + async function bootstrapDefaultJsonIfEmpty() { + let hasMarker = localStorage.getItem(bootstrapMarkerKey) === bootstrapMarkerValue; + if (!hasMarker) { + const marker = await idbGet(bootstrapMarkerKey); + hasMarker = marker === bootstrapMarkerValue; + } + if (hasMarker) return false; + + try { + const res = await fetch(buildNoCacheUrl(apiFlowDataUrl), { + cache: 'no-store', + headers: { + 'Cache-Control': 'no-cache', + Pragma: 'no-cache' + } + }); + if (res.ok) { + const parsed = await res.json(); + const extracted = extractImportPayload(parsed); + if (extracted) { + applyImportedData(extracted.flowModel, extracted.importedMeta, extracted.entityMeta, true); + persistMarker(); + setSyncStatus('ready', '기본 데이터 적재됨'); + return true; + } + } + } catch (error) { + // fallback to local json seed + } + + const loadedFromJson = await loadFromDefaultJsonCandidates(true); + if (loadedFromJson) { + setSyncStatus('ready', '기본 데이터 적재됨'); + return true; + } + return false; + } + + function saveStepMeta() { + persistLocalSnapshot(getFlowModelPayload(), getStepMeta(), getEntityMeta()); + scheduleRemoteSave(); + } + + function saveFlowModel() { + persistLocalSnapshot(getFlowModelPayload(), getStepMeta(), getEntityMeta()); + scheduleRemoteSave(); + } + + function exportAllData() { + const payload = buildCurrentPayload(); + const blob = new Blob([JSON.stringify(payload, null, 2)], { type: 'application/json' }); + const url = URL.createObjectURL(blob); + const anchor = document.createElement('a'); + const day = new Date().toISOString().slice(0, 10); + anchor.href = url; + anchor.download = `flow-data-${day}.json`; + document.body.appendChild(anchor); + anchor.click(); + anchor.remove(); + URL.revokeObjectURL(url); + } + + function importAllData(rawText) { + let parsed; + try { + parsed = JSON.parse(rawText); + } catch (error) { + return { ok: false, message: 'JSON 파일 형식이 올바르지 않습니다.' }; + } + if (!parsed || typeof parsed !== 'object') { + return { ok: false, message: '가져올 데이터 형식이 아닙니다.' }; + } + const extracted = extractImportPayload(parsed); + if (!extracted) { + return { ok: false, message: 'flowModel 데이터가 없습니다.' }; + } + applyImportedData(extracted.flowModel, extracted.importedMeta, extracted.entityMeta, true); + persistMarker(); + return { ok: true }; + } + + return { + persistMarker, + saveStepMeta, + saveFlowModel, + applyImportedData, + exportAllData, + importAllData, + loadFlowModel, + loadStepMeta, + loadFromIdbFallback, + loadRemoteData, + bootstrapDefaultJsonIfEmpty + }; + } + + window.createFlowDataStore = createFlowDataStore; +})(); diff --git a/static/hm-biz-process/flow_detail_view.js b/static/hm-biz-process/flow_detail_view.js new file mode 100644 index 0000000..f197198 --- /dev/null +++ b/static/hm-biz-process/flow_detail_view.js @@ -0,0 +1,50 @@ +window.createFlowDetailView = function createFlowDetailView(options) { + const { + elements, + getEditMode, + setEditMode, + flowEditorCore, + flowEditorMedia, + renderAll + } = options || {}; + + const { editModeBtnEl } = elements || {}; + + function applyEditModeUI() { + const editMode = Boolean(getEditMode && getEditMode()); + if (editModeBtnEl) { + editModeBtnEl.classList.toggle('active', editMode); + editModeBtnEl.textContent = '편집'; + } + if (flowEditorCore && typeof flowEditorCore.applyEditMode === 'function') { + flowEditorCore.applyEditMode(editMode); + } + if (flowEditorMedia && typeof flowEditorMedia.applyEditMode === 'function') { + flowEditorMedia.applyEditMode(editMode); + } + } + + function toggleEditMode() { + const nextMode = !Boolean(getEditMode && getEditMode()); + if (typeof setEditMode === 'function') { + setEditMode(nextMode); + } + applyEditModeUI(); + if (typeof renderAll === 'function') { + renderAll(); + } + } + + function bind() { + applyEditModeUI(); + if (editModeBtnEl) { + editModeBtnEl.addEventListener('click', toggleEditMode); + } + } + + return { + bind, + applyEditModeUI, + toggleEditMode + }; +}; diff --git a/static/hm-biz-process/flow_editor_core.js b/static/hm-biz-process/flow_editor_core.js new file mode 100644 index 0000000..cf984f5 --- /dev/null +++ b/static/hm-biz-process/flow_editor_core.js @@ -0,0 +1,181 @@ +(function () { + function createFlowEditorCore(options) { + const { + elements, + getEditingKey, + setEditingKey, + isEditMode, + getCurrentEditorLabel, + setCurrentEditorLabel, + setCurrentImageIndex, + ensureMeta, + isSecondFlowKey, + saveStepMeta, + renderAll, + renderEditorPreview + } = options; + + const { + editorPanelEl, + editorTitleEl, + editorSubEl, + editorViewerTitleEl, + editorViewerTeamEl, + editorViewerSystemEl, + editorViewerRemarkEl, + editorCloseEl, + editorImageInputEl, + editorNoteInputEl, + editorNoteViewEl, + flowInfoCardEl, + editorTeamInputEl, + editorRemarkInputEl, + editorSystemInputEl, + editorTeamViewEl, + editorRemarkViewEl, + editorSystemViewEl + } = elements; + + function setViewerValue(element, value, emptyText = '정보 없음') { + if (!element) return; + const text = String(value || '').trim(); + element.textContent = text || emptyText; + element.classList.toggle('is-empty', !text); + } + + function setViewerTitle(element, value, emptyText = '정보 없음') { + if (!element) return; + const text = String(value || '').trim(); + const finalText = text || emptyText; + const lines = finalText + .split(/\r?\n+/) + .map((line) => line.trim()) + .filter(Boolean); + + element.textContent = ''; + element.setAttribute('aria-label', finalText); + element.classList.toggle('is-empty', !text); + + (lines.length ? lines : [finalText]).forEach((line, index) => { + const lineEl = document.createElement('span'); + lineEl.className = `viewer-title-line ${index === 0 ? 'is-primary' : 'is-continuation'}`; + lineEl.textContent = line; + element.appendChild(lineEl); + }); + } + + function setViewerChip(element, value) { + if (!element) return; + const text = String(value || '').trim(); + element.textContent = text; + element.classList.toggle('is-empty', !text); + } + + function updateStepViewer(info, stepLabel = getCurrentEditorLabel()) { + setViewerValue(editorNoteViewEl, info.note, 'STEP NOTE가 없습니다.'); + setViewerValue(editorTeamViewEl, info.team, '담당팀 정보 없음'); + setViewerValue(editorRemarkViewEl, info.remark, '비고 없음'); + setViewerValue(editorSystemViewEl, info.system, '시스템 정보 없음'); + setViewerTitle(editorViewerTitleEl, info.note, stepLabel || '설명 없음'); + setViewerChip(editorViewerTeamEl, info.team); + setViewerChip(editorViewerSystemEl, info.system); + setViewerChip(editorViewerRemarkEl, info.remark); + } + + function applyEditMode(mode = isEditMode()) { + const isEditing = Boolean(mode); + editorPanelEl.classList.toggle('viewer-mode', !isEditing); + editorNoteInputEl.readOnly = !isEditing; + editorTeamInputEl.readOnly = !isEditing; + editorRemarkInputEl.readOnly = !isEditing; + editorSystemInputEl.readOnly = !isEditing; + if (editorSubEl) { + editorSubEl.textContent = isEditing + ? '편집 모드입니다. 변경사항은 자동 저장됩니다.' + : '뷰어 모드입니다. 편집모드에서만 수정할 수 있습니다.'; + } + } + + function openEditor(stepKey, stepLabel) { + setEditingKey(stepKey); + setCurrentEditorLabel(stepLabel); + const info = ensureMeta(stepKey); + editorTitleEl.textContent = `${stepLabel} DETAIL`; + editorNoteInputEl.value = info.note || ''; + editorTeamInputEl.value = info.team || ''; + editorRemarkInputEl.value = info.remark || ''; + editorSystemInputEl.value = info.system || ''; + updateStepViewer(info, stepLabel); + flowInfoCardEl.classList.toggle('open', isSecondFlowKey(stepKey)); + editorImageInputEl.value = ''; + setCurrentImageIndex(0); + renderEditorPreview(stepLabel); + applyEditMode(); + editorPanelEl.classList.add('open'); + editorPanelEl.setAttribute('aria-hidden', 'false'); + } + + function closeEditor() { + setCurrentEditorLabel(''); + editorPanelEl.classList.remove('open'); + editorPanelEl.setAttribute('aria-hidden', 'true'); + } + + function bind() { + editorCloseEl.addEventListener('click', closeEditor); + + editorNoteInputEl.addEventListener('input', () => { + if (!isEditMode()) return; + const editingKey = getEditingKey(); + if (!editingKey) return; + const info = ensureMeta(editingKey); + info.note = editorNoteInputEl.value; + updateStepViewer(info); + saveStepMeta(); + }); + + editorTeamInputEl.addEventListener('input', () => { + if (!isEditMode()) return; + const editingKey = getEditingKey(); + if (!editingKey || !isSecondFlowKey(editingKey)) return; + const info = ensureMeta(editingKey); + info.team = editorTeamInputEl.value; + updateStepViewer(info); + saveStepMeta(); + renderAll(); + }); + + editorRemarkInputEl.addEventListener('input', () => { + if (!isEditMode()) return; + const editingKey = getEditingKey(); + if (!editingKey || !isSecondFlowKey(editingKey)) return; + const info = ensureMeta(editingKey); + info.remark = editorRemarkInputEl.value; + updateStepViewer(info); + saveStepMeta(); + }); + + editorSystemInputEl.addEventListener('input', () => { + if (!isEditMode()) return; + const editingKey = getEditingKey(); + if (!editingKey || !isSecondFlowKey(editingKey)) return; + const info = ensureMeta(editingKey); + info.system = editorSystemInputEl.value; + updateStepViewer(info); + saveStepMeta(); + renderAll(); + }); + + applyEditMode(); + } + + return { + openEditor, + closeEditor, + bind, + applyEditMode + }; + } + + window.createFlowEditorCore = createFlowEditorCore; +})(); diff --git a/static/hm-biz-process/flow_editor_media.js b/static/hm-biz-process/flow_editor_media.js new file mode 100644 index 0000000..8729c32 --- /dev/null +++ b/static/hm-biz-process/flow_editor_media.js @@ -0,0 +1,401 @@ +(function () { + function createFlowEditorMedia(options) { + const { + elements, + getEditingKey, + getEditMode, + getCurrentEditorLabel, + getCurrentImageIndex, + setCurrentImageIndex, + getModalImageIndex, + setModalImageIndex, + ensureMeta, + getAllStepMeta, + syncLegacyMetaFromScreens, + saveStepMeta + } = options; + + const { + editorPathInputEl, + editorPathViewEl, + editorImageInputEl, + editorUploaderEl, + editorFileStatusEl, + editorClearImageEl, + editorPreviewEl, + editorPrevImageEl, + editorNextImageEl, + editorPageInfoEl, + editorImageNoteInputEl, + editorImageNoteViewEl, + imageModalEl, + imageModalImgEl, + imageModalCloseEl, + imageModalPrevEl, + imageModalNextEl, + imageModalPageEl, + imageModalNoteEl + } = elements; + const DEFAULT_PREVIEW_RATIO = 2; + const imageRatioCache = new Map(); + let previewRatioTaskId = 0; + + function setViewerValue(element, value, emptyText = '정보 없음') { + if (!element) return; + const text = String(value || '').trim(); + element.textContent = text || emptyText; + element.classList.toggle('is-empty', !text); + } + + function getScreens(info) { + if (!Array.isArray(info.screens)) { + info.screens = []; + } + return info.screens; + } + + function applyPreviewRatio(ratio) { + const safeRatio = Number.isFinite(ratio) && ratio > 0 ? ratio : DEFAULT_PREVIEW_RATIO; + if (editorPreviewEl) { + editorPreviewEl.style.setProperty('--preview-ratio', String(safeRatio)); + } + } + + function loadImageRatio(imageSrc) { + const src = String(imageSrc || '').trim(); + if (!src) return Promise.resolve(DEFAULT_PREVIEW_RATIO); + if (imageRatioCache.has(src)) return imageRatioCache.get(src); + const task = new Promise((resolve) => { + const img = new Image(); + img.onload = () => { + const width = Number(img.naturalWidth || 0); + const height = Number(img.naturalHeight || 0); + if (width > 0 && height > 0) { + resolve(width / height); + return; + } + resolve(DEFAULT_PREVIEW_RATIO); + }; + img.onerror = () => resolve(DEFAULT_PREVIEW_RATIO); + img.src = src; + }); + imageRatioCache.set(src, task); + return task; + } + + function collectAllImageSources() { + const allMeta = (typeof getAllStepMeta === 'function' ? getAllStepMeta() : null) || {}; + const sources = new Set(); + Object.values(allMeta).forEach((info) => { + const screens = Array.isArray(info && info.screens) ? info.screens : []; + screens.forEach((screen) => { + const src = String(screen && screen.image ? screen.image : '').trim(); + if (src) sources.add(src); + }); + }); + return Array.from(sources); + } + + function refreshGlobalPreviewRatio() { + applyPreviewRatio(2); + return; + const taskId = ++previewRatioTaskId; + const sources = collectAllImageSources(); + if (!sources.length) { + applyPreviewRatio(DEFAULT_PREVIEW_RATIO); + return; + } + Promise.all(sources.map((src) => loadImageRatio(src))) + .then((ratios) => { + if (taskId !== previewRatioTaskId) return; + const validRatios = ratios.filter((ratio) => Number.isFinite(ratio) && ratio > 0); + if (!validRatios.length) { + applyPreviewRatio(DEFAULT_PREVIEW_RATIO); + return; + } + // Use the most portrait image as the global frame ratio for consistency across all pages. + const minRatio = validRatios.reduce((acc, ratio) => Math.min(acc, ratio), validRatios[0]); + applyPreviewRatio(minRatio); + }) + .catch(() => { + if (taskId !== previewRatioTaskId) return; + applyPreviewRatio(DEFAULT_PREVIEW_RATIO); + }); + } + + function ensureScreenAt(info, index, { createIfMissing = false } = {}) { + const screens = getScreens(info); + if (createIfMissing && !screens.length) { + screens.push({ path: '', note: '', image: '' }); + } + while (createIfMissing && index >= screens.length) { + screens.push({ path: '', note: '', image: '' }); + } + const screen = screens[index] || null; + if (screen) { + screen.path = String(screen.path || ''); + screen.note = String(screen.note || ''); + screen.image = String(screen.image || ''); + } + return screen; + } + + function applyEditMode(mode = getEditMode()) { + const isEditing = Boolean(mode); + if (editorUploaderEl) { + editorUploaderEl.hidden = !isEditing; + } + editorPathInputEl.readOnly = !isEditing; + editorImageInputEl.disabled = !isEditing; + editorClearImageEl.disabled = !isEditing; + editorImageNoteInputEl.readOnly = !isEditing; + } + + function renderEditorPreview(label) { + const editingKey = getEditingKey(); + if (!editingKey) return; + refreshGlobalPreviewRatio(); + const info = ensureMeta(editingKey); + const screens = getScreens(info); + let currentIndex = getCurrentImageIndex(); + if (currentIndex >= screens.length) { + currentIndex = Math.max(0, screens.length - 1); + setCurrentImageIndex(currentIndex); + } + const currentScreen = screens[currentIndex] || null; + const imageSrc = currentScreen ? currentScreen.image : ''; + editorPreviewEl.innerHTML = ''; + if (!imageSrc) { + const empty = document.createElement('p'); + empty.className = 'preview-empty'; + empty.textContent = '이미지가 없습니다.'; + editorPreviewEl.appendChild(empty); + } else { + const img = document.createElement('img'); + img.src = imageSrc; + img.alt = `${label} 이미지`; + img.style.cursor = 'zoom-in'; + img.addEventListener('click', () => openImageModal(currentIndex, `${label} 확대 이미지`)); + editorPreviewEl.appendChild(img); + } + editorPageInfoEl.textContent = `${screens.length ? (currentIndex + 1) : 0} / ${screens.length}`; + editorFileStatusEl.textContent = screens.length ? `저장된 화면 ${screens.length}장` : '저장된 화면 0장'; + editorPrevImageEl.disabled = screens.length <= 1 || currentIndex <= 0; + editorNextImageEl.disabled = screens.length <= 1 || currentIndex >= screens.length - 1; + editorPathInputEl.value = currentScreen ? currentScreen.path : ''; + editorImageNoteInputEl.value = currentScreen ? currentScreen.note : ''; + setViewerValue(editorPathViewEl, currentScreen ? currentScreen.path : '', ''); + setViewerValue(editorImageNoteViewEl, currentScreen ? currentScreen.note : '', ''); + editorImageNoteInputEl.disabled = !currentScreen; + editorImageNoteInputEl.readOnly = !getEditMode(); + editorClearImageEl.disabled = !currentScreen || !getEditMode(); + } + + function updateImageModalView(altText) { + const editingKey = getEditingKey(); + if (!editingKey) return; + const info = ensureMeta(editingKey); + const screens = getScreens(info); + if (!screens.length) return; + let modalIndex = getModalImageIndex(); + if (modalIndex < 0) modalIndex = 0; + if (modalIndex > screens.length - 1) modalIndex = screens.length - 1; + setModalImageIndex(modalIndex); + const currentScreen = screens[modalIndex]; + imageModalImgEl.src = currentScreen.image || ''; + imageModalImgEl.alt = altText || '확대 이미지'; + imageModalPageEl.textContent = `${modalIndex + 1} / ${screens.length}`; + imageModalPrevEl.disabled = modalIndex <= 0; + imageModalNextEl.disabled = modalIndex >= screens.length - 1; + const note = String(currentScreen.note || '').trim(); + if (note) { + imageModalNoteEl.textContent = note; + imageModalNoteEl.classList.add('show'); + } else { + imageModalNoteEl.textContent = ''; + imageModalNoteEl.classList.remove('show'); + } + } + + function openImageModal(index, altText) { + const editingKey = getEditingKey(); + if (!editingKey) return; + const info = ensureMeta(editingKey); + const screens = getScreens(info); + if (!screens.length) return; + const targetScreen = screens[index] || null; + if (!targetScreen || !targetScreen.image) return; + setModalImageIndex(Number.isInteger(index) ? index : 0); + updateImageModalView(altText); + imageModalEl.classList.add('open'); + imageModalEl.setAttribute('aria-hidden', 'false'); + } + + function closeImageModal() { + imageModalEl.classList.remove('open'); + imageModalEl.setAttribute('aria-hidden', 'true'); + imageModalImgEl.src = ''; + imageModalPageEl.textContent = '0 / 0'; + imageModalNoteEl.textContent = ''; + imageModalNoteEl.classList.remove('show'); + } + + function bind() { + refreshGlobalPreviewRatio(); + editorClearImageEl.addEventListener('click', () => { + if (!getEditMode()) return; + const editingKey = getEditingKey(); + if (!editingKey) return; + const info = ensureMeta(editingKey); + const screens = getScreens(info); + const currentIndex = getCurrentImageIndex(); + if (!screens.length) return; + screens.splice(currentIndex, 1); + let nextIndex = currentIndex; + if (nextIndex >= screens.length) { + nextIndex = Math.max(0, screens.length - 1); + } + setCurrentImageIndex(nextIndex); + syncLegacyMetaFromScreens(info); + saveStepMeta(); + renderEditorPreview(getCurrentEditorLabel()); + }); + + editorImageInputEl.addEventListener('change', (event) => { + if (!getEditMode()) { + event.target.value = ''; + return; + } + const editingKey = getEditingKey(); + if (!editingKey) return; + const files = Array.from(event.target.files || []); + if (!files.length) return; + const imageFiles = files.filter((file) => file.type.startsWith('image/')); + if (!imageFiles.length) { + alert('이미지 파일만 선택할 수 있습니다.'); + event.target.value = ''; + return; + } + const targetKey = editingKey; + const readJobs = imageFiles.map((file) => new Promise((resolve) => { + const reader = new FileReader(); + reader.onload = () => resolve(String(reader.result || '')); + reader.onerror = () => resolve(''); + reader.readAsDataURL(file); + })); + Promise.all(readJobs).then((images) => { + const validImages = images.filter(Boolean); + if (!validImages.length) return; + const info = ensureMeta(targetKey); + const screens = getScreens(info); + const currentScreen = ensureScreenAt(info, getCurrentImageIndex()); + const pendingImages = [...validImages]; + if (currentScreen && !currentScreen.image && pendingImages.length) { + currentScreen.image = pendingImages.shift(); + } + pendingImages.forEach((imageSrc, offset) => { + screens.push({ + path: currentScreen && offset === 0 ? String(currentScreen.path || '') : '', + note: '', + image: imageSrc + }); + }); + syncLegacyMetaFromScreens(info); + if (getEditingKey() === targetKey) { + setCurrentImageIndex(Math.max(0, screens.length - Math.max(1, pendingImages.length))); + renderEditorPreview(getCurrentEditorLabel()); + } + saveStepMeta(); + }); + event.target.value = ''; + }); + + editorPrevImageEl.addEventListener('click', () => { + const editingKey = getEditingKey(); + let currentIndex = getCurrentImageIndex(); + if (!editingKey || currentIndex <= 0) return; + currentIndex -= 1; + setCurrentImageIndex(currentIndex); + renderEditorPreview(getCurrentEditorLabel()); + }); + + editorNextImageEl.addEventListener('click', () => { + const editingKey = getEditingKey(); + if (!editingKey) return; + const info = ensureMeta(editingKey); + const screens = getScreens(info); + let currentIndex = getCurrentImageIndex(); + if (currentIndex >= screens.length - 1) return; + currentIndex += 1; + setCurrentImageIndex(currentIndex); + renderEditorPreview(getCurrentEditorLabel()); + }); + + editorImageNoteInputEl.addEventListener('input', () => { + if (!getEditMode()) return; + const editingKey = getEditingKey(); + if (!editingKey) return; + const info = ensureMeta(editingKey); + const screen = ensureScreenAt(info, getCurrentImageIndex(), { createIfMissing: true }); + screen.note = editorImageNoteInputEl.value; + syncLegacyMetaFromScreens(info); + setViewerValue(editorImageNoteViewEl, screen.note, ''); + saveStepMeta(); + }); + + editorPathInputEl.addEventListener('input', () => { + if (!getEditMode()) return; + const editingKey = getEditingKey(); + if (!editingKey) return; + const info = ensureMeta(editingKey); + const screen = ensureScreenAt(info, getCurrentImageIndex(), { createIfMissing: true }); + screen.path = editorPathInputEl.value; + syncLegacyMetaFromScreens(info); + setViewerValue(editorPathViewEl, screen.path, ''); + saveStepMeta(); + }); + + imageModalCloseEl.addEventListener('click', closeImageModal); + imageModalPrevEl.addEventListener('click', () => { + if (!imageModalEl.classList.contains('open')) return; + setModalImageIndex(getModalImageIndex() - 1); + updateImageModalView('확대 이미지'); + }); + imageModalNextEl.addEventListener('click', () => { + if (!imageModalEl.classList.contains('open')) return; + setModalImageIndex(getModalImageIndex() + 1); + updateImageModalView('확대 이미지'); + }); + imageModalEl.addEventListener('click', (event) => { + if (event.target === imageModalEl) closeImageModal(); + }); + document.addEventListener('keydown', (event) => { + if (!imageModalEl.classList.contains('open')) return; + if (event.key === 'ArrowLeft') { + setModalImageIndex(getModalImageIndex() - 1); + updateImageModalView('확대 이미지'); + } + if (event.key === 'ArrowRight') { + setModalImageIndex(getModalImageIndex() + 1); + updateImageModalView('확대 이미지'); + } + if (event.key === 'Escape') { + closeImageModal(); + } + }); + + applyEditMode(); + } + + return { + renderEditorPreview, + updateImageModalView, + openImageModal, + closeImageModal, + bind, + applyEditMode + }; + } + + window.createFlowEditorMedia = createFlowEditorMedia; +})(); diff --git a/static/hm-biz-process/flow_render.js b/static/hm-biz-process/flow_render.js new file mode 100644 index 0000000..3e714aa --- /dev/null +++ b/static/hm-biz-process/flow_render.js @@ -0,0 +1,867 @@ +window.createFlowRenderer = function createFlowRenderer(options) { + const { + elements, + getState, + setSelectedChain, + openEditor, + closeEditor, + saveFlowModel, + closeSitemapModal, + askStepLabel, + getNextFlow, + getPivotLabel, + getSubFlowLinkInfo, + formatConnectorReason, + setSubFlowLinkReason, + remapSubFlowLinksOnRename, + syncSubFlowLinks, + getSubFlowLinks, + setSubFlowLinks, + getMainBarMode = () => false, + getLv2BarMode = () => false, + setMainBarMode = () => {}, + setLv2BarMode = () => {}, + onBarModeLayoutChange = () => {} + } = options || {}; + + const { + mainFlowEl, + mainPanelEl, + mainCommonSectionEl, + drillColumnsEl + } = elements || {}; + + let barHoverTooltipEl = null; + + function ensureBarHoverTooltip() { + if (barHoverTooltipEl && document.body.contains(barHoverTooltipEl)) { + return barHoverTooltipEl; + } + const existing = document.getElementById('barStepHoverTooltip'); + if (existing) { + barHoverTooltipEl = existing; + return barHoverTooltipEl; + } + const tooltip = document.createElement('div'); + tooltip.id = 'barStepHoverTooltip'; + tooltip.className = 'bar-step-tooltip'; + tooltip.setAttribute('role', 'tooltip'); + document.body.appendChild(tooltip); + barHoverTooltipEl = tooltip; + return barHoverTooltipEl; + } + + function hideBarStepTooltip() { + const tooltip = ensureBarHoverTooltip(); + tooltip.classList.remove('show'); + } + + function positionBarStepTooltip(btn) { + const tooltip = ensureBarHoverTooltip(); + const rect = btn.getBoundingClientRect(); + let left = rect.right + 10; + const top = rect.top + (rect.height / 2); + + tooltip.style.left = `${Math.round(left)}px`; + tooltip.style.top = `${Math.round(top)}px`; + + const tooltipRect = tooltip.getBoundingClientRect(); + const viewportPadding = 8; + if ((left + tooltipRect.width + viewportPadding) > window.innerWidth) { + left = Math.max(viewportPadding, rect.left - tooltipRect.width - 10); + tooltip.style.left = `${Math.round(left)}px`; + } + } + + function showBarStepTooltip(btn, label) { + const barPanel = btn.closest('.flow-panel.flow-bar-mode'); + if (!barPanel) return; + const text = String(label || '').trim(); + if (!text) return; + const tooltip = ensureBarHoverTooltip(); + tooltip.textContent = text; + tooltip.classList.add('show'); + positionBarStepTooltip(btn); + } + + function bindBarStepTooltip(btn, label) { + btn.addEventListener('mouseenter', () => showBarStepTooltip(btn, label)); + btn.addEventListener('mousemove', () => positionBarStepTooltip(btn)); + btn.addEventListener('mouseleave', hideBarStepTooltip); + btn.addEventListener('focus', () => showBarStepTooltip(btn, label)); + btn.addEventListener('blur', hideBarStepTooltip); + } + + function formatUpdatedAtText(value) { + const text = String(value || '').trim(); + if (!text) return ''; + const parsed = new Date(text); + if (Number.isNaN(parsed.getTime())) return text; + return `최종 수정 ${parsed.toLocaleString('ko-KR', { + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit' + })}`; + } + + function getEntityMetaValue(mapName, key) { + const state = getState(); + const entityMeta = state && state.entityMeta ? state.entityMeta : {}; + const map = entityMeta[mapName]; + if (!map || typeof map !== 'object') return ''; + return String(map[key] || '').trim(); + } + + function getStepMetaText(stepKey) { + const stableId = getEntityMetaValue('stepStableIdByKey', stepKey); + const parts = []; + if (stableId) parts.push(`ID: ${stableId}`); + return parts.join('\n'); + } + + function getFlowMetaText(flowKey) { + const stableId = getEntityMetaValue('flowStableIdByKey', flowKey); + const contentHash = getEntityMetaValue('flowContentHashByKey', flowKey); + const parts = []; + if (stableId) parts.push(`ID: ${stableId}`); + if (contentHash) parts.push(`HASH: ${contentHash}`); + return parts.join('\n'); + } + + function getLinkMetaText(mainStep, fromStep, toStep) { + const linkKey = `${mainStep}||${fromStep}>>${toStep}`; + const stableId = getEntityMetaValue('linkStableIdByKey', linkKey); + const parts = []; + if (stableId) parts.push(`ID: ${stableId}`); + return parts.join('\n'); + } + + function renderVerticalFlow(container, list, activeValue, onSelect, renderOptions) { + const listEl = document.createElement('div'); + listEl.className = 'flow-list'; + + const { + allowEdit = false, + onEdit = null, + onDelete = null, + onMoveUp = null, + onMoveDown = null, + onInsertAfter = null, + onAddEnd = null, + getMetaText = null, + getConnectorInfo = null, + onEditConnector = null, + onClearConnector = null + } = renderOptions || {}; + + list.forEach((step, idx) => { + if (allowEdit) { + const row = document.createElement('div'); + row.className = 'step-row'; + + const btn = document.createElement('button'); + btn.type = 'button'; + btn.className = 'step-btn'; + if (activeValue === step) btn.classList.add('active'); + const metaText = typeof getMetaText === 'function' ? getMetaText(step, idx) : ''; + if (metaText) { + btn.classList.add('with-meta'); + // 편집 모드에서는 메타 정보를 버튼 하단(step-edit-meta)에만 표시한다. + // 버튼 내부 중복 표시는 제거한다. + btn.innerHTML = `${step}`; + } else { + btn.textContent = step; + } + btn.setAttribute('data-step-label', String(step)); + btn.setAttribute('aria-label', String(step)); + btn.removeAttribute('title'); + bindBarStepTooltip(btn, step); + btn.addEventListener('click', () => onSelect(step)); + row.appendChild(btn); + + if (metaText) { + const meta = document.createElement('div'); + meta.className = 'step-edit-meta'; + meta.textContent = metaText; + row.appendChild(meta); + } + + const actions = document.createElement('div'); + actions.className = 'step-actions'; + + const upBtn = document.createElement('button'); + upBtn.type = 'button'; + upBtn.className = 'icon-btn'; + upBtn.title = '위로 이동'; + upBtn.textContent = '↑'; + upBtn.disabled = idx === 0; + upBtn.style.opacity = idx === 0 ? '0.4' : '1'; + upBtn.addEventListener('click', () => onMoveUp && onMoveUp(idx)); + actions.appendChild(upBtn); + + const downBtn = document.createElement('button'); + downBtn.type = 'button'; + downBtn.className = 'icon-btn'; + downBtn.title = '아래로 이동'; + downBtn.textContent = '↓'; + downBtn.disabled = idx === list.length - 1; + downBtn.style.opacity = idx === list.length - 1 ? '0.4' : '1'; + downBtn.addEventListener('click', () => onMoveDown && onMoveDown(idx)); + actions.appendChild(downBtn); + + const editBtn = document.createElement('button'); + editBtn.type = 'button'; + editBtn.className = 'icon-btn'; + editBtn.title = '이름 수정'; + editBtn.textContent = '✎'; + editBtn.addEventListener('click', () => onEdit && onEdit(idx)); + actions.appendChild(editBtn); + + const delBtn = document.createElement('button'); + delBtn.type = 'button'; + delBtn.className = 'icon-btn delete'; + delBtn.title = '삭제'; + delBtn.textContent = '×'; + delBtn.addEventListener('click', () => onDelete && onDelete(idx)); + actions.appendChild(delBtn); + + row.appendChild(actions); + listEl.appendChild(row); + } else { + const btn = document.createElement('button'); + btn.type = 'button'; + btn.className = 'step-btn'; + if (activeValue === step) btn.classList.add('active'); + const metaText = typeof getMetaText === 'function' ? getMetaText(step, idx) : ''; + if (metaText) { + btn.classList.add('with-meta'); + btn.innerHTML = `${step}${metaText}`; + } else { + btn.textContent = step; + } + btn.setAttribute('data-step-label', String(step)); + btn.setAttribute('aria-label', String(step)); + btn.removeAttribute('title'); + bindBarStepTooltip(btn, step); + btn.addEventListener('click', () => onSelect(step)); + listEl.appendChild(btn); + } + + if (idx < list.length - 1) { + const connectorInfo = typeof getConnectorInfo === 'function' + ? (getConnectorInfo(step, idx, list[idx + 1]) || { broken: false, reason: '' }) + : { broken: false, reason: '' }; + if (allowEdit) { + const insertWrap = document.createElement('div'); + insertWrap.className = 'insert-row'; + const arrow = document.createElement('div'); + arrow.className = `down-arrow${connectorInfo.broken ? ' broken' : ''}`; + arrow.textContent = '↓'; + insertWrap.appendChild(arrow); + if (connectorInfo.reason) { + const reason = document.createElement('div'); + reason.className = 'link-reason'; + reason.textContent = formatConnectorReason(connectorInfo.reason); + insertWrap.appendChild(reason); + } + const connectorMetaText = String(connectorInfo.metaText || '').trim(); + if (connectorMetaText) { + const meta = document.createElement('div'); + meta.className = 'step-edit-meta'; + meta.textContent = connectorMetaText; + insertWrap.appendChild(meta); + } + if (typeof onEditConnector === 'function') { + const linkBtn = document.createElement('button'); + linkBtn.type = 'button'; + linkBtn.className = 'link-edit-btn'; + linkBtn.textContent = connectorInfo.broken ? '수동 처리 수정' : '수동 처리 추가'; + linkBtn.addEventListener('click', () => onEditConnector(idx)); + insertWrap.appendChild(linkBtn); + if (connectorInfo.broken && typeof onClearConnector === 'function') { + const clearBtn = document.createElement('button'); + clearBtn.type = 'button'; + clearBtn.className = 'link-clear-btn'; + clearBtn.textContent = '자동 복구'; + clearBtn.addEventListener('click', () => onClearConnector(idx)); + insertWrap.appendChild(clearBtn); + } + } + const insertBtn = document.createElement('button'); + insertBtn.type = 'button'; + insertBtn.className = 'insert-btn'; + insertBtn.title = '아래에 삽입'; + insertBtn.textContent = '+'; + insertBtn.addEventListener('click', () => onInsertAfter && onInsertAfter(idx)); + insertWrap.appendChild(insertBtn); + listEl.appendChild(insertWrap); + } else { + const arrow = document.createElement('div'); + arrow.className = `down-arrow${connectorInfo.broken ? ' broken' : ''}`; + arrow.textContent = '↓'; + listEl.appendChild(arrow); + if (connectorInfo.reason) { + const reason = document.createElement('div'); + reason.className = 'link-reason'; + reason.textContent = connectorInfo.reason; + listEl.appendChild(reason); + } + } + } + }); + + if (allowEdit) { + const addEndWrap = document.createElement('div'); + addEndWrap.className = 'insert-row'; + const addEndBtn = document.createElement('button'); + addEndBtn.type = 'button'; + addEndBtn.className = 'add-end-btn'; + addEndBtn.textContent = '+ 마지막에 추가'; + addEndBtn.addEventListener('click', () => onAddEnd && onAddEnd()); + addEndWrap.appendChild(addEndBtn); + listEl.appendChild(addEndWrap); + } + + container.appendChild(listEl); + } + + function renderCommonSection() { + const state = getState(); + const { editMode, commonItems, commonSubFlow, selectedChain } = state; + mainCommonSectionEl.innerHTML = ''; + + const head = document.createElement('div'); + head.className = 'common-head'; + const title = document.createElement('p'); + title.className = 'common-title'; + title.textContent = '공통'; + head.appendChild(title); + + if (editMode) { + const addBtn = document.createElement('button'); + addBtn.type = 'button'; + addBtn.className = 'common-add-btn'; + addBtn.textContent = '+ 공통 추가'; + addBtn.addEventListener('click', () => { + const next = askStepLabel(''); + if (!next) return; + commonItems.push(next); + if (!commonSubFlow[next]) commonSubFlow[next] = []; + saveFlowModel(); + renderCommonSection(); + }); + head.appendChild(addBtn); + } + + mainCommonSectionEl.appendChild(head); + + const list = document.createElement('div'); + list.className = 'common-list'; + if (!commonItems.length) { + const empty = document.createElement('div'); + empty.className = 'common-empty'; + empty.textContent = '공통 항목이 없습니다.'; + list.appendChild(empty); + } else { + commonItems.forEach((item, idx) => { + const commonMetaText = editMode ? getStepMetaText(`common|${item}`) : ''; + const isActiveCommon = selectedChain[0] === `common::${item}`; + if (editMode) { + const row = document.createElement('div'); + row.className = 'common-row'; + const btn = document.createElement('button'); + btn.type = 'button'; + btn.className = 'common-btn'; + if (isActiveCommon) btn.classList.add('active'); + btn.textContent = item; + btn.setAttribute('data-step-label', String(item)); + btn.setAttribute('aria-label', String(item)); + btn.removeAttribute('title'); + bindBarStepTooltip(btn, item); + btn.addEventListener('click', () => { + api.openFirstSubStepFromCommonFlow(item); + }); + row.appendChild(btn); + + if (commonMetaText) { + const meta = document.createElement('div'); + meta.className = 'step-edit-meta'; + meta.textContent = commonMetaText; + row.appendChild(meta); + } + + const actions = document.createElement('div'); + actions.className = 'step-actions'; + + const upBtn = document.createElement('button'); + upBtn.type = 'button'; + upBtn.className = 'icon-btn'; + upBtn.title = '위로 이동'; + upBtn.textContent = '↑'; + upBtn.disabled = idx === 0; + upBtn.style.opacity = idx === 0 ? '0.4' : '1'; + upBtn.addEventListener('click', () => { + if (idx <= 0) return; + [commonItems[idx - 1], commonItems[idx]] = [commonItems[idx], commonItems[idx - 1]]; + saveFlowModel(); + renderCommonSection(); + }); + actions.appendChild(upBtn); + + const downBtn = document.createElement('button'); + downBtn.type = 'button'; + downBtn.className = 'icon-btn'; + downBtn.title = '아래로 이동'; + downBtn.textContent = '↓'; + downBtn.disabled = idx === commonItems.length - 1; + downBtn.style.opacity = idx === commonItems.length - 1 ? '0.4' : '1'; + downBtn.addEventListener('click', () => { + if (idx >= commonItems.length - 1) return; + [commonItems[idx + 1], commonItems[idx]] = [commonItems[idx], commonItems[idx + 1]]; + saveFlowModel(); + renderCommonSection(); + }); + actions.appendChild(downBtn); + + const editBtn = document.createElement('button'); + editBtn.type = 'button'; + editBtn.className = 'icon-btn'; + editBtn.title = '이름 수정'; + editBtn.textContent = '✎'; + editBtn.addEventListener('click', () => { + const next = askStepLabel(item); + if (!next || next === item) return; + commonItems[idx] = next; + if (commonSubFlow[item]) { + commonSubFlow[next] = commonSubFlow[item]; + delete commonSubFlow[item]; + } + saveFlowModel(); + renderCommonSection(); + }); + actions.appendChild(editBtn); + + const delBtn = document.createElement('button'); + delBtn.type = 'button'; + delBtn.className = 'icon-btn delete'; + delBtn.title = '삭제'; + delBtn.textContent = '×'; + delBtn.addEventListener('click', () => { + if (!window.confirm(`'${item}' 공통 항목을 삭제할까요?`)) return; + commonItems.splice(idx, 1); + delete commonSubFlow[item]; + if (selectedChain[0] === `common::${item}`) { + setSelectedChain([]); + closeEditor(); + } + saveFlowModel(); + renderCommonSection(); + }); + actions.appendChild(delBtn); + row.appendChild(actions); + list.appendChild(row); + } else { + const btn = document.createElement('button'); + btn.type = 'button'; + btn.className = 'common-btn'; + if (isActiveCommon) btn.classList.add('active'); + btn.textContent = item; + btn.setAttribute('data-step-label', String(item)); + btn.setAttribute('aria-label', String(item)); + btn.removeAttribute('title'); + bindBarStepTooltip(btn, item); + btn.addEventListener('click', () => { + api.openFirstSubStepFromCommonFlow(item); + }); + list.appendChild(btn); + } + }); + } + mainCommonSectionEl.appendChild(list); + } + + function openFirstSubStepFromCommonFlow(item, fromSitemap = false) { + const targetCommonItem = String(item || '').trim(); + if (!targetCommonItem) return; + const { commonSubFlow } = getState(); + const subSteps = commonSubFlow[targetCommonItem] || []; + if (subSteps.length) { + const firstSubStep = subSteps[0]; + setSelectedChain([`common::${targetCommonItem}`, firstSubStep]); + api.renderAll(); + openEditor(`0|${targetCommonItem}|${firstSubStep}`, firstSubStep); + } else { + setSelectedChain([`common::${targetCommonItem}`]); + api.renderAll(); + openEditor(`common|${targetCommonItem}`, targetCommonItem); + } + if (fromSitemap) closeSitemapModal(); + } + + function openFirstSubStepFromProjectFlow(mainStep) { + const targetMainStep = String(mainStep || '').trim(); + if (!targetMainStep) return; + const { subFlow } = getState(); + const subSteps = subFlow[targetMainStep] || []; + if (subSteps.length) { + const firstSubStep = subSteps[0]; + setSelectedChain([targetMainStep, firstSubStep]); + api.renderAll(); + openEditor(`0|${targetMainStep}|${firstSubStep}`, firstSubStep); + return; + } + setSelectedChain([targetMainStep]); + api.renderAll(); + openEditor(`main|${targetMainStep}`, targetMainStep); + } + + function renderMain() { + const { mainSteps, selectedChain, editMode, subFlow } = getState(); + const mainBarMode = Boolean(!editMode && getMainBarMode()); + mainFlowEl.innerHTML = ''; + mainFlowEl.scrollTop = 0; + mainPanelEl.scrollTop = 0; + mainPanelEl.classList.toggle('flow-bar-mode', mainBarMode); + mainPanelEl.removeAttribute('data-bar-level'); + const mainTitleEl = mainPanelEl.querySelector('.panel-head .title'); + const mainHeadEl = mainPanelEl.querySelector('.panel-head'); + if (mainHeadEl) { + const existingBtn = mainHeadEl.querySelector('.bar-collapse-btn'); + if (existingBtn) existingBtn.remove(); + if (!editMode && !mainBarMode) { + const collapseBtn = document.createElement('button'); + collapseBtn.type = 'button'; + collapseBtn.className = 'bar-collapse-btn'; + collapseBtn.title = '바 보기로 전환'; + collapseBtn.setAttribute('aria-label', '바 보기로 전환'); + collapseBtn.textContent = '−'; + collapseBtn.addEventListener('click', () => { + setMainBarMode(true); + onBarModeLayoutChange(); + api.renderAll(); + }); + mainHeadEl.appendChild(collapseBtn); + } + } + if (mainTitleEl) { + mainTitleEl.classList.remove('bar-toggle-title'); + mainTitleEl.title = editMode ? getFlowMetaText('main') : ''; + mainTitleEl.onclick = null; + } + if (mainBarMode) { + const restoreBtn = document.createElement('button'); + restoreBtn.type = 'button'; + restoreBtn.className = 'bar-restore-btn'; + restoreBtn.textContent = '+'; + restoreBtn.addEventListener('click', () => { + setMainBarMode(false); + onBarModeLayoutChange(); + api.renderAll(); + }); + mainFlowEl.appendChild(restoreBtn); + } + renderVerticalFlow(mainFlowEl, mainSteps, selectedChain[0], (step) => { + openFirstSubStepFromProjectFlow(step); + }, { + allowEdit: editMode, + getMetaText: editMode ? (step) => getStepMetaText(`main|${step}`) : null, + onEdit: (idx) => { + const oldStep = mainSteps[idx]; + const next = askStepLabel(oldStep); + if (!next || next === oldStep) return; + mainSteps[idx] = next; + if (subFlow[oldStep]) { + subFlow[next] = subFlow[oldStep]; + delete subFlow[oldStep]; + const migrated = {}; + Object.entries(getSubFlowLinks()).forEach(([k, v]) => { + if (k.startsWith(`${oldStep}||`)) { + migrated[k.replace(`${oldStep}||`, `${next}||`)] = v; + } else { + migrated[k] = v; + } + }); + setSubFlowLinks(migrated); + } else if (!subFlow[next]) { + subFlow[next] = []; + } + if (selectedChain[0] === oldStep) { + const nextChain = [...selectedChain]; + nextChain[0] = next; + setSelectedChain(nextChain); + } + saveFlowModel(); + api.renderAll(); + }, + onDelete: (idx) => { + const target = mainSteps[idx]; + if (!window.confirm(`'${target}' 스텝을 삭제할까요?`)) return; + mainSteps.splice(idx, 1); + delete subFlow[target]; + const nextLinks = { ...getSubFlowLinks() }; + Object.keys(nextLinks).forEach((k) => { + if (k.startsWith(`${target}||`)) delete nextLinks[k]; + }); + setSubFlowLinks(nextLinks); + if (selectedChain[0] === target) { + setSelectedChain([]); + closeEditor(); + } + saveFlowModel(); + api.renderAll(); + }, + onMoveUp: (idx) => { + if (idx <= 0) return; + [mainSteps[idx - 1], mainSteps[idx]] = [mainSteps[idx], mainSteps[idx - 1]]; + saveFlowModel(); + api.renderAll(); + }, + onMoveDown: (idx) => { + if (idx >= mainSteps.length - 1) return; + [mainSteps[idx + 1], mainSteps[idx]] = [mainSteps[idx], mainSteps[idx + 1]]; + saveFlowModel(); + api.renderAll(); + }, + onInsertAfter: (idx) => { + const next = askStepLabel(''); + if (!next) return; + mainSteps.splice(idx + 1, 0, next); + if (!subFlow[next]) subFlow[next] = []; + saveFlowModel(); + api.renderAll(); + }, + onAddEnd: () => { + const next = askStepLabel(''); + if (!next) return; + mainSteps.push(next); + if (!subFlow[next]) subFlow[next] = []; + saveFlowModel(); + api.renderAll(); + } + }); + renderCommonSection(); + } + + function renderDrillColumns() { + const state = getState(); + const { selectedChain, editMode, commonSubFlow, subFlow, drillFlow, stepMeta } = state; + drillColumnsEl.innerHTML = ''; + + if (!selectedChain[0]) return; + + let level = 0; + while (true) { + if (level >= 1) break; + const pivot = selectedChain[level]; + const list = getNextFlow(level, pivot); + if (!list.length) break; + + const panel = document.createElement('section'); + panel.className = 'flow-panel'; + const panelLevel = level; + const lv2BarMode = Boolean(!editMode && panelLevel === 0 && getLv2BarMode()); + panel.classList.toggle('flow-bar-mode', lv2BarMode); + panel.removeAttribute('data-bar-level'); + const panelPivot = getPivotLabel(pivot); + const isCommonPivot = String(pivot).startsWith('common::'); + + const titleRow = document.createElement('div'); + titleRow.className = 'panel-head'; + + const title = document.createElement('h2'); + title.className = 'title'; + title.textContent = panelLevel === 0 ? 'STEP FLOW' : `${panelPivot} FLOW`; + if (editMode && panelLevel === 0) { + title.title = getFlowMetaText(`0|${panelPivot}`); + } + if (!editMode && panelLevel === 0) { + title.classList.remove('bar-toggle-title'); + title.title = ''; + } + titleRow.appendChild(title); + if (!editMode && panelLevel === 0 && !lv2BarMode) { + const collapseBtn = document.createElement('button'); + collapseBtn.type = 'button'; + collapseBtn.className = 'bar-collapse-btn'; + collapseBtn.title = '바 보기로 전환'; + collapseBtn.setAttribute('aria-label', '바 보기로 전환'); + collapseBtn.textContent = '−'; + collapseBtn.addEventListener('click', () => { + setLv2BarMode(true); + onBarModeLayoutChange(); + api.renderAll(); + }); + titleRow.appendChild(collapseBtn); + } + + panel.appendChild(titleRow); + + const subtitleSpacer = document.createElement('p'); + subtitleSpacer.className = 'project-title'; + subtitleSpacer.textContent = panelLevel === 0 ? panelPivot : '프로젝트'; + panel.appendChild(subtitleSpacer); + if (lv2BarMode) { + const restoreBtn = document.createElement('button'); + restoreBtn.type = 'button'; + restoreBtn.className = 'bar-restore-btn'; + restoreBtn.textContent = '+'; + restoreBtn.addEventListener('click', () => { + setLv2BarMode(false); + onBarModeLayoutChange(); + api.renderAll(); + }); + panel.appendChild(restoreBtn); + } + + renderVerticalFlow(panel, list, selectedChain[panelLevel + 1], (step) => { + const nextChain = selectedChain.slice(0, panelLevel + 1); + nextChain[panelLevel + 1] = step; + setSelectedChain(nextChain); + api.renderAll(); + openEditor(`${panelLevel}|${panelPivot}|${step}`, step); + }, { + getMetaText: editMode + ? (step) => getStepMetaText(`${panelLevel}|${panelPivot}|${step}`) + : (panelLevel === 0 && !isCommonPivot ? (step) => { + const key = `0|${panelPivot}|${step}`; + const info = stepMeta[key] || {}; + const team = info.team || '-'; + const system = info.system || '-'; + return `${team} | ${system}`; + } : null), + getConnectorInfo: panelLevel === 0 ? (fromStep, idx, toStep) => { + const link = getSubFlowLinkInfo(panelPivot, fromStep, toStep); + return { + broken: Boolean(link.reason), + reason: link.reason || '', + metaText: editMode ? getLinkMetaText(panelPivot, fromStep, toStep) : '' + }; + } : null, + onEditConnector: panelLevel === 0 ? (idx) => { + const fromStep = list[idx]; + const toStep = list[idx + 1]; + if (!fromStep || !toStep) return; + const current = getSubFlowLinkInfo(panelPivot, fromStep, toStep).reason || ''; + const value = window.prompt(`'${fromStep}' -> '${toStep}' 사이의 수동 처리 내용을 입력하세요.\n(비우면 자동 연결로 복구)`, current); + if (value === null) return; + setSubFlowLinkReason(panelPivot, fromStep, toStep, value); + saveFlowModel(); + api.renderAll(); + } : null, + onClearConnector: panelLevel === 0 ? (idx) => { + const fromStep = list[idx]; + const toStep = list[idx + 1]; + if (!fromStep || !toStep) return; + setSubFlowLinkReason(panelPivot, fromStep, toStep, ''); + saveFlowModel(); + api.renderAll(); + } : null, + allowEdit: editMode, + onEdit: (idx) => { + const targetList = panelLevel === 0 + ? (isCommonPivot ? (commonSubFlow[panelPivot] || []) : (subFlow[panelPivot] || [])) + : (drillFlow[panelPivot] || []); + const oldStep = targetList[idx]; + const next = askStepLabel(oldStep); + if (!next || next === oldStep) return; + targetList[idx] = next; + if (panelLevel === 0) remapSubFlowLinksOnRename(panelPivot, oldStep, next); + if (drillFlow[oldStep]) { + drillFlow[next] = drillFlow[oldStep]; + delete drillFlow[oldStep]; + } + if (selectedChain[panelLevel + 1] === oldStep) { + const nextChain = [...selectedChain]; + nextChain[panelLevel + 1] = next; + setSelectedChain(nextChain); + } + if (panelLevel === 0) syncSubFlowLinks(panelPivot); + saveFlowModel(); + api.renderAll(); + }, + onDelete: (idx) => { + const targetList = panelLevel === 0 + ? (isCommonPivot ? (commonSubFlow[panelPivot] || []) : (subFlow[panelPivot] || [])) + : (drillFlow[panelPivot] || []); + const target = targetList[idx]; + if (!window.confirm(`'${target}' 스텝을 삭제할까요?`)) return; + targetList.splice(idx, 1); + if (selectedChain[panelLevel + 1] === target) { + setSelectedChain(selectedChain.slice(0, panelLevel + 1)); + closeEditor(); + } + if (panelLevel === 0) syncSubFlowLinks(panelPivot); + saveFlowModel(); + api.renderAll(); + }, + onMoveUp: (idx) => { + const targetList = panelLevel === 0 + ? (isCommonPivot ? (commonSubFlow[panelPivot] || []) : (subFlow[panelPivot] || [])) + : (drillFlow[panelPivot] || []); + if (idx <= 0) return; + [targetList[idx - 1], targetList[idx]] = [targetList[idx], targetList[idx - 1]]; + if (panelLevel === 0) syncSubFlowLinks(panelPivot); + saveFlowModel(); + api.renderAll(); + }, + onMoveDown: (idx) => { + const targetList = panelLevel === 0 + ? (isCommonPivot ? (commonSubFlow[panelPivot] || []) : (subFlow[panelPivot] || [])) + : (drillFlow[panelPivot] || []); + if (idx >= targetList.length - 1) return; + [targetList[idx + 1], targetList[idx]] = [targetList[idx], targetList[idx + 1]]; + if (panelLevel === 0) syncSubFlowLinks(panelPivot); + saveFlowModel(); + api.renderAll(); + }, + onInsertAfter: (idx) => { + const targetList = panelLevel === 0 + ? (isCommonPivot ? (commonSubFlow[panelPivot] || []) : (subFlow[panelPivot] || [])) + : (drillFlow[panelPivot] || []); + const next = askStepLabel(''); + if (!next) return; + targetList.splice(idx + 1, 0, next); + if (!drillFlow[next]) drillFlow[next] = []; + if (panelLevel === 0) syncSubFlowLinks(panelPivot); + saveFlowModel(); + api.renderAll(); + }, + onAddEnd: () => { + const targetList = panelLevel === 0 + ? (isCommonPivot ? (commonSubFlow[panelPivot] || []) : (subFlow[panelPivot] || [])) + : (drillFlow[panelPivot] || []); + const next = askStepLabel(''); + if (!next) return; + targetList.push(next); + if (!drillFlow[next]) drillFlow[next] = []; + if (panelLevel === 0) syncSubFlowLinks(panelPivot); + saveFlowModel(); + api.renderAll(); + } + }); + + drillColumnsEl.appendChild(panel); + + if (!selectedChain[panelLevel + 1]) break; + level += 1; + } + } + + function renderAll() { + renderMain(); + renderDrillColumns(); + } + + const api = { + renderVerticalFlow, + renderCommonSection, + openFirstSubStepFromCommonFlow, + openFirstSubStepFromProjectFlow, + renderMain, + renderDrillColumns, + renderAll + }; + + return api; +}; diff --git a/static/hm-biz-process/process_map.html b/static/hm-biz-process/process_map.html new file mode 100644 index 0000000..6504040 --- /dev/null +++ b/static/hm-biz-process/process_map.html @@ -0,0 +1,1284 @@ + + + + + + HM Biz Process Map + + + +
+
+
+
+

Process Map

+
+
+
DB 불러오는 중
+
+
+ +
+
+
+ + +
+
+ +
+
+
+
데이터를 불러오는 중입니다.
+
+
+
+
+
+ + + + + + diff --git a/static/hm-biz-process/process_map_shared.js b/static/hm-biz-process/process_map_shared.js new file mode 100644 index 0000000..b7cc8af --- /dev/null +++ b/static/hm-biz-process/process_map_shared.js @@ -0,0 +1,678 @@ +(function () { + function createProcessMapRenderer(options) { + const { + elements, + getState, + onMainClick, + onStepClick, + onCommonRootClick, + isActiveFocus = () => false, + linkWidths = { auto: 132, manual: 132 }, + sharedSegment = ['전표작성', '검토', '출금'] + } = options; + + const { + fullContentEl, + legendEl, + teamSelectEl, + systemSelectEl + } = elements; + + let systemColorMap = new Map(); + let filters = { team: '', system: '' }; + + function parseMultiValues(text) { + return String(text || '') + .split(',') + .map((value) => value.trim()) + .filter(Boolean); + } + + function getSecondFlowMeta(mainStep, step) { + const { stepMeta = {} } = getState(); + return stepMeta[`0|${mainStep}|${step}`] || {}; + } + + function collectSystemsForLegend() { + const { + mainSteps = [], + subFlow = {}, + commonItems = [], + commonSubFlow = {} + } = getState(); + const set = new Set(); + + mainSteps.forEach((mainStep) => { + (subFlow[mainStep] || []).forEach((step) => { + parseMultiValues(getSecondFlowMeta(mainStep, step).system).forEach((system) => set.add(system)); + }); + }); + + commonItems.forEach((item) => { + (commonSubFlow[item] || []).forEach((step) => { + parseMultiValues(getSecondFlowMeta(item, step).system).forEach((system) => set.add(system)); + }); + }); + + return Array.from(set).sort((a, b) => a.localeCompare(b, 'ko')); + } + + function normalizeSystemName(name) { + return String(name || '').trim().toLowerCase(); + } + + function getSystemCategory(systemName) { + const normalized = normalizeSystemName(systemName); + if (!normalized) return 'unknown'; + if (normalized.includes('미사용')) return 'unused'; + if (normalized.includes('외부')) return 'external'; + if (normalized.includes('내부') || normalized.includes('erp') || normalized.includes('pq')) return 'internal'; + return 'other'; + } + + const INTERNAL_PALETTE = [ + { bg: '#8CCBF7', border: '#69AFDF', text: '#173f6f' }, // pastel dodger + { bg: '#A7D7F9', border: '#84C0E5', text: '#173f6f' }, // pastel picton + { bg: '#BFE2FB', border: '#98CAE9', text: '#173f6f' }, // pastel maya + { bg: '#D3EBFC', border: '#AFCFE6', text: '#173f6f' }, // pastel uranian + { bg: '#E9F5FE', border: '#C5DBEC', text: '#173f6f' } // pastel beau + ]; + + const UNUSED_PALETTE = [ + { bg: '#fff1f1', border: '#e6a3a3', text: '#762222' }, // red-1 + { bg: '#fff3f3', border: '#e8acac', text: '#7a2424' }, // red-2 + { bg: '#fff5f5', border: '#e9b4b4', text: '#7f2f2f' } // rose-red + ]; + + const EXTERNAL_PALETTE = [ + { bg: '#eaf8ef', border: '#87ca9c', text: '#11492a' }, // green-1 + { bg: '#e8f7f1', border: '#7ac8ae', text: '#0f544d' }, // green-teal + { bg: '#eef9f4', border: '#8bcdb0', text: '#184f39' } // green-3 + ]; + + const OTHER_PALETTE = [ + { bg: '#edf3fb', border: '#9fb8d7', text: '#26384a' }, + { bg: '#f1f4fa', border: '#a9b8cd', text: '#2b3a4d' } + ]; + + const SPECIAL_STEP_CATEGORY = new Map([ + ['입찰공고', 'unused'], + ['외주발주의뢰', 'unused'], + ['외주발주검토', 'unused'] + ]); + + function pickColorFromCategory(category, seedName) { + let palette = OTHER_PALETTE; + if (category === 'internal') palette = INTERNAL_PALETTE; + if (category === 'unused') palette = UNUSED_PALETTE; + if (category === 'external') palette = EXTERNAL_PALETTE; + const seed = hashString(seedName || category) % palette.length; + return palette[seed]; + } + + function hashString(value) { + const str = String(value || ''); + let hash = 0; + for (let i = 0; i < str.length; i += 1) { + hash = ((hash << 5) - hash) + str.charCodeAt(i); + hash |= 0; + } + return Math.abs(hash); + } + + function makeDistinctSystemColor(systemName, index = 0) { + const category = getSystemCategory(systemName); + let palette = OTHER_PALETTE; + if (category === 'internal') palette = INTERNAL_PALETTE; + if (category === 'unused') palette = UNUSED_PALETTE; + if (category === 'external') palette = EXTERNAL_PALETTE; + const seed = (hashString(systemName) + (index * 7)) % palette.length; + return palette[seed]; + } + + function rebuildSystemColorMap() { + const systems = collectSystemsForLegend(); + systemColorMap = new Map(); + systems.forEach((system, index) => { + systemColorMap.set(system, makeDistinctSystemColor(system, index)); + }); + } + + function getRandomLikeColorBySystem(systemName) { + if (!systemColorMap.has(systemName)) { + systemColorMap.set(systemName, makeDistinctSystemColor(systemName, systemColorMap.size)); + } + return systemColorMap.get(systemName); + } + + function applyChipSystemColor(chipEl, info, stepLabel = '') { + const systems = parseMultiValues(info.system); + if (!systems.length) { + const specialCategory = SPECIAL_STEP_CATEGORY.get(String(stepLabel || '').trim()) || ''; + if (specialCategory) { + const color = pickColorFromCategory(specialCategory, `${specialCategory}:${stepLabel}`); + chipEl.classList.add('system-colored'); + chipEl.style.setProperty('--sys-bg', color.bg); + chipEl.style.setProperty('--sys-border', color.border); + chipEl.style.setProperty('--sys-text', color.text); + return; + } + const color = pickColorFromCategory('other', `no-system:${stepLabel}`); + chipEl.classList.add('system-colored', 'no-system'); + chipEl.style.setProperty('--sys-bg', color.bg); + chipEl.style.setProperty('--sys-border', color.border); + chipEl.style.setProperty('--sys-text', color.text); + return; + } + if (systems.length >= 2) { + const colorA = getRandomLikeColorBySystem(systems[0]); + const colorB = getRandomLikeColorBySystem(systems[1]); + chipEl.classList.add('system-colored', 'multi-system'); + chipEl.style.setProperty('--sys-bg-a', colorA.bg); + chipEl.style.setProperty('--sys-bg-b', colorB.bg); + chipEl.style.setProperty('--sys-border-a', colorA.border); + chipEl.style.setProperty('--sys-border-b', colorB.border); + chipEl.style.setProperty('--sys-text', colorA.text || '#1f4f83'); + return; + } + const color = getRandomLikeColorBySystem(systems[0]); + chipEl.classList.add('system-colored'); + chipEl.style.setProperty('--sys-bg', color.bg); + chipEl.style.setProperty('--sys-border', color.border); + chipEl.style.setProperty('--sys-text', color.text); + } + + function isMetaMatchedByFilter(info) { + const teams = parseMultiValues(info.team); + const systems = parseMultiValues(info.system); + const hasAnyMeta = teams.length > 0 || systems.length > 0; + if (!filters.team && !filters.system) return true; + if (!hasAnyMeta) return false; + const teamMatch = !filters.team || teams.includes(filters.team); + const systemMatch = !filters.system || systems.includes(filters.system); + return teamMatch && systemMatch; + } + + function findSharedSegmentStart(list) { + for (let index = 0; index <= list.length - sharedSegment.length; index += 1) { + const matched = sharedSegment.every((value, offset) => String(list[index + offset] || '').trim() === value); + if (matched) return index; + } + return -1; + } + + function subFlowLinkKey(mainStep, fromStep, toStep) { + return `${mainStep}||${fromStep}>>${toStep}`; + } + + function getSubFlowLinkInfo(mainStep, fromStep, toStep) { + const { subFlowLinks = {} } = getState(); + return subFlowLinks[subFlowLinkKey(mainStep, fromStep, toStep)] || { reason: '' }; + } + + function formatConnectorReason(reason) { + return String(reason || '').trim(); + } + + function renderLegend() { + if (!legendEl) return; + legendEl.innerHTML = ''; + const categoryOrder = { + internal: 0, + external: 1, + unused: 2, + other: 3, + unknown: 4 + }; + const systems = collectSystemsForLegend().sort((a, b) => { + const catA = getSystemCategory(a); + const catB = getSystemCategory(b); + const rankA = categoryOrder[catA] ?? 99; + const rankB = categoryOrder[catB] ?? 99; + if (rankA !== rankB) return rankA - rankB; + return a.localeCompare(b, 'ko'); + }); + + const applyLegendSystemFilter = (systemValue) => { + const nextSystem = String(systemValue || ''); + if (systemSelectEl) { + const hasOption = Array.from(systemSelectEl.options || []).some((option) => option.value === nextSystem); + systemSelectEl.value = hasOption ? nextSystem : ''; + systemSelectEl.dispatchEvent(new Event('change', { bubbles: true })); + return; + } + setFilters({ system: nextSystem }); + render(); + }; + + const makeItem = (label, bg, border, opts = {}) => { + const { clickable = false, selected = false, onClick = null } = opts; + const item = document.createElement('span'); + item.className = 'sm-legend-item'; + if (selected) item.classList.add('active-focus'); + const swatch = document.createElement('span'); + swatch.className = 'sm-legend-swatch'; + swatch.style.background = bg; + swatch.style.borderColor = border; + const text = document.createElement('span'); + text.textContent = label; + item.appendChild(swatch); + item.appendChild(text); + if (clickable && typeof onClick === 'function') { + item.style.cursor = 'pointer'; + item.title = `${label} 필터 적용`; + item.setAttribute('role', 'button'); + item.setAttribute('tabindex', '0'); + item.addEventListener('click', onClick); + item.addEventListener('keydown', (event) => { + if (event.key !== 'Enter' && event.key !== ' ') return; + event.preventDefault(); + onClick(); + }); + } + legendEl.appendChild(item); + }; + + systems.forEach((system) => { + const color = getRandomLikeColorBySystem(system); + makeItem(system, color.bg, color.border, { + clickable: true, + selected: filters.system === system, + onClick: () => applyLegendSystemFilter(system) + }); + }); + makeItem('시스템 미입력', '#e5e7eb', '#cbd5e1', { + clickable: true, + selected: !filters.system, + onClick: () => applyLegendSystemFilter('') + }); + } + + function refreshFilterOptions() { + if (!teamSelectEl || !systemSelectEl) return; + const { + mainSteps = [], + subFlow = {}, + commonItems = [], + commonSubFlow = {} + } = getState(); + + const teamSet = new Set(); + const systemSet = new Set(); + + mainSteps.forEach((mainStep) => { + (subFlow[mainStep] || []).forEach((step) => { + const info = getSecondFlowMeta(mainStep, step); + parseMultiValues(info.team).forEach((value) => teamSet.add(value)); + parseMultiValues(info.system).forEach((value) => systemSet.add(value)); + }); + }); + + commonItems.forEach((item) => { + (commonSubFlow[item] || []).forEach((step) => { + const info = getSecondFlowMeta(item, step); + parseMultiValues(info.team).forEach((value) => teamSet.add(value)); + parseMultiValues(info.system).forEach((value) => systemSet.add(value)); + }); + }); + + const previousTeam = filters.team; + const previousSystem = filters.system; + + teamSelectEl.innerHTML = ''; + Array.from(teamSet).sort((a, b) => a.localeCompare(b, 'ko')).forEach((value) => { + const option = document.createElement('option'); + option.value = value; + option.textContent = value; + teamSelectEl.appendChild(option); + }); + + systemSelectEl.innerHTML = ''; + Array.from(systemSet).sort((a, b) => a.localeCompare(b, 'ko')).forEach((value) => { + const option = document.createElement('option'); + option.value = value; + option.textContent = value; + systemSelectEl.appendChild(option); + }); + + filters.team = Array.from(teamSet).includes(previousTeam) ? previousTeam : ''; + filters.system = Array.from(systemSet).includes(previousSystem) ? previousSystem : ''; + teamSelectEl.value = filters.team; + systemSelectEl.value = filters.system; + } + + function markFlowItem(el, kind, colIndex) { + if (!el) return el; + el.dataset.flowKind = kind; + el.dataset.flowCol = String(colIndex); + return el; + } + + function markInteractiveTarget(el, action, mainStep, step = '', isCommon = false) { + if (!el) return el; + el.dataset.mapAction = action; + el.dataset.mainStep = String(mainStep || ''); + if (step) el.dataset.step = String(step); + if (isCommon) el.dataset.isCommon = '1'; + el.style.cursor = 'pointer'; + return el; + } + + const STEP_BOX_SPACING_MULTIPLIER = 1.7; + + function getSegmentBaseWidth(hasPreviousStep, isManual) { + if (!hasPreviousStep) return 0; + const baseWidth = isManual ? Number(linkWidths.manual || 156) : Number(linkWidths.auto || 132); + return baseWidth * STEP_BOX_SPACING_MULTIPLIER; + } + + function applyColumnAlignment() { + const segments = Array.from(fullContentEl.querySelectorAll('.sm-segment[data-flow-col]')); + if (!segments.length) return; + + const maxWidthByCol = new Map(); + segments.forEach((segment) => { + const colIndex = Number(segment.dataset.flowCol || '-1'); + const baseWidth = Number(segment.dataset.flowWidth || '0'); + if (colIndex < 0 || baseWidth <= 0) return; + const current = maxWidthByCol.get(colIndex) || 0; + if (baseWidth > current) maxWidthByCol.set(colIndex, baseWidth); + }); + + segments.forEach((segment) => { + const chip = segment.querySelector('.sm-chip'); + const chipWidth = chip ? Math.ceil(chip.getBoundingClientRect().width) : 102; + const hasLink = segment.dataset.hasLink === '1'; + const colIndex = Number(segment.dataset.flowCol || '-1'); + const totalWidth = maxWidthByCol.get(colIndex) || chipWidth; + const safeTotalWidth = Math.max(totalWidth, chipWidth); + const linkWidth = hasLink ? Math.max(safeTotalWidth - chipWidth, 24) : 0; + + segment.style.setProperty('--flow-col-width', `${safeTotalWidth}px`); + segment.style.setProperty('--flow-link-width', `${linkWidth}px`); + }); + } + + function buildLink(mainStep, fromStep, toStep, dim) { + const link = getSubFlowLinkInfo(mainStep, fromStep, toStep); + const linkWrap = document.createElement('span'); + linkWrap.className = `sm-link-wrap${link.reason ? ' manual' : ''}`; + if (dim) linkWrap.classList.add('dim'); + + if (link.reason) { + const prefix = document.createElement('span'); + prefix.className = 'sm-link-arrow-frag'; + const reason = document.createElement('span'); + reason.className = 'sm-link-reason'; + reason.textContent = formatConnectorReason(link.reason); + reason.title = link.reason; + const suffix = document.createElement('span'); + suffix.className = 'sm-link-arrow-frag'; + + if (dim) { + prefix.classList.add('dim'); + reason.classList.add('dim'); + suffix.classList.add('dim'); + } + + linkWrap.appendChild(prefix); + linkWrap.appendChild(reason); + linkWrap.appendChild(suffix); + } else { + const arrow = document.createElement('span'); + arrow.className = 'sm-arrow'; + if (dim) arrow.classList.add('dim'); + linkWrap.appendChild(arrow); + } + + return linkWrap; + } + + function buildStepChip(mainStep, step, info, isCommon, isMatch, hasFilter) { + const chip = document.createElement('span'); + chip.className = 'sm-chip'; + chip.textContent = step; + chip.title = `담당팀: ${info.team || '-'} / 시스템: ${info.system || '-'}`; + applyChipSystemColor(chip, info, step); + if (isActiveFocus('step', mainStep, step, isCommon)) chip.classList.add('active-focus'); + if (typeof onStepClick === 'function') { + markInteractiveTarget(chip, 'step', mainStep, step, isCommon); + } + if (hasFilter) chip.classList.add(isMatch ? 'match' : 'dim'); + return chip; + } + + function buildStepSegment(mainStep, step, info, isCommon, isMatch, hasFilter, previousStep = '', dimLink = false, colIndex = 0) { + const segment = document.createElement('span'); + segment.className = 'sm-segment'; + markFlowItem(segment, 'segment', colIndex); + segment.dataset.hasLink = previousStep ? '1' : '0'; + if (previousStep) { + const linkEl = buildLink(mainStep, previousStep, step, dimLink); + if (linkEl.classList.contains('manual')) segment.classList.add('has-manual-link'); + segment.dataset.flowWidth = String(getSegmentBaseWidth(true, linkEl.classList.contains('manual'))); + segment.appendChild(linkEl); + } else { + segment.classList.add('is-first'); + } + if (!segment.dataset.flowWidth) segment.dataset.flowWidth = '0'; + segment.appendChild(buildStepChip(mainStep, step, info, isCommon, isMatch, hasFilter)); + return segment; + } + + function render() { + fullContentEl.innerHTML = ''; + const { + mainSteps = [], + subFlow = {}, + commonItems = [], + commonSubFlow = {} + } = getState(); + + if (!mainSteps.length) { + const empty = document.createElement('div'); + empty.className = 'empty-state'; + empty.textContent = '표시할 프로세스 데이터가 없습니다.'; + fullContentEl.appendChild(empty); + return; + } + + rebuildSystemColorMap(); + renderLegend(); + const hasFilter = Boolean(filters.team || filters.system); + + const projectTitle = document.createElement('p'); + projectTitle.className = 'sm-section-title'; + projectTitle.textContent = '프로젝트'; + fullContentEl.appendChild(projectTitle); + + mainSteps.forEach((mainStep) => { + const row = document.createElement('div'); + row.className = 'sm-row'; + + const main = document.createElement('div'); + main.className = 'sm-main'; + main.textContent = mainStep; + if (isActiveFocus('main', mainStep)) main.classList.add('active-focus'); + if (typeof onMainClick === 'function') { + main.title = `${mainStep} DETAIL 열기`; + markInteractiveTarget(main, 'main', mainStep); + } + row.appendChild(main); + + const sub = subFlow[mainStep] || []; + let rowHasMatch = false; + let flowColIndex = 0; + + if (sub.length > 0) { + const chain = document.createElement('div'); + chain.className = 'sm-chain'; + const sharedStart = findSharedSegmentStart(sub); + + sub.forEach((step, index) => { + if (sharedStart >= 0 && index > sharedStart && index < sharedStart + sharedSegment.length) return; + + const info = getSecondFlowMeta(mainStep, step); + const isMatch = isMetaMatchedByFilter(info); + if (isMatch) rowHasMatch = true; + + if (sharedStart >= 0 && index === sharedStart) { + const box = document.createElement('span'); + box.className = 'sm-shared-box'; + const sharedMatches = sharedSegment.map((segmentName) => { + const segmentInfo = getSecondFlowMeta(mainStep, segmentName); + const segmentMatch = isMetaMatchedByFilter(segmentInfo); + if (segmentMatch) rowHasMatch = true; + return segmentMatch; + }); + + for (let offset = 0; offset < sharedSegment.length; offset += 1) { + const segmentName = sharedSegment[offset]; + const segmentInfo = getSecondFlowMeta(mainStep, segmentName); + const previousStep = offset > 0 + ? sharedSegment[offset - 1] + : (sharedStart > 0 ? sub[sharedStart - 1] : ''); + const previousMatch = offset > 0 + ? sharedMatches[offset - 1] + : (previousStep ? isMetaMatchedByFilter(getSecondFlowMeta(mainStep, previousStep)) : false); + const dimLink = Boolean(previousStep && hasFilter && !(previousMatch || sharedMatches[offset])); + box.appendChild(buildStepSegment(mainStep, segmentName, segmentInfo, false, sharedMatches[offset], hasFilter, previousStep, dimLink, flowColIndex)); + flowColIndex += 1; + } + + chain.appendChild(box); + } else { + const previousStep = index > 0 ? sub[index - 1] : ''; + const previousMatch = previousStep ? isMetaMatchedByFilter(getSecondFlowMeta(mainStep, previousStep)) : false; + const dimLink = Boolean(previousStep && hasFilter && !(previousMatch || isMatch)); + chain.appendChild(buildStepSegment(mainStep, step, info, false, isMatch, hasFilter, previousStep, dimLink, flowColIndex)); + flowColIndex += 1; + } + }); + + if (hasFilter && !rowHasMatch) main.classList.add('dim'); + row.appendChild(chain); + } + + fullContentEl.appendChild(row); + }); + + const commonTitle = document.createElement('p'); + commonTitle.className = 'sm-section-title'; + commonTitle.textContent = '공통'; + fullContentEl.appendChild(commonTitle); + + if (commonItems.length) { + commonItems.forEach((item) => { + const row = document.createElement('div'); + row.className = 'sm-row'; + + const main = document.createElement('div'); + main.className = 'sm-main'; + main.textContent = item; + if (isActiveFocus('common-root', item, '', true)) main.classList.add('active-focus'); + if (typeof onCommonRootClick === 'function') { + main.title = `${item} DETAIL 열기`; + markInteractiveTarget(main, 'common-root', item, '', true); + } + row.appendChild(main); + + const chain = document.createElement('div'); + chain.className = 'sm-chain'; + const sub = commonSubFlow[item] || []; + let rowHasMatch = false; + let flowColIndex = 0; + const isCommonStepMatch = (step) => isMetaMatchedByFilter(getSecondFlowMeta(item, step)); + + const getStepRenderMeta = (label) => { + const info = getSecondFlowMeta(item, label); + const isMatch = isCommonStepMatch(label); + if (isMatch) rowHasMatch = true; + return { info, isMatch }; + }; + + const sharedStart = findSharedSegmentStart(sub); + if (sharedStart < 0) { + sub.forEach((step, index) => { + const { info, isMatch } = getStepRenderMeta(step); + const previousStep = index > 0 ? sub[index - 1] : ''; + const previousMatch = previousStep ? isCommonStepMatch(previousStep) : false; + const dimLink = Boolean(previousStep && hasFilter && !(previousMatch || isMatch)); + chain.appendChild(buildStepSegment(item, step, info, true, isMatch, hasFilter, previousStep, dimLink, flowColIndex)); + flowColIndex += 1; + }); + } else { + const prefixSteps = sub.slice(0, sharedStart); + const sharedSteps = sub.slice(sharedStart, sharedStart + sharedSegment.length); + const suffixSteps = sub.slice(sharedStart + sharedSegment.length); + + prefixSteps.forEach((step, index) => { + const { info, isMatch } = getStepRenderMeta(step); + const previousStep = index > 0 ? prefixSteps[index - 1] : ''; + const previousMatch = previousStep ? isCommonStepMatch(previousStep) : false; + const dimLink = Boolean(previousStep && hasFilter && !(previousMatch || isMatch)); + chain.appendChild(buildStepSegment(item, step, info, true, isMatch, hasFilter, previousStep, dimLink, flowColIndex)); + flowColIndex += 1; + }); + + const box = document.createElement('span'); + box.className = 'sm-shared-box'; + sharedSteps.forEach((step, index) => { + const { info, isMatch } = getStepRenderMeta(step); + const previousStep = index > 0 + ? sharedSteps[index - 1] + : (prefixSteps.length ? prefixSteps[prefixSteps.length - 1] : ''); + const previousMatch = previousStep ? isCommonStepMatch(previousStep) : false; + const dimLink = Boolean(previousStep && hasFilter && !(previousMatch || isMatch)); + box.appendChild(buildStepSegment(item, step, info, true, isMatch, hasFilter, previousStep, dimLink, flowColIndex)); + flowColIndex += 1; + }); + if (hasFilter && !rowHasMatch) box.classList.add('dim'); + chain.appendChild(box); + + suffixSteps.forEach((step, index) => { + const { info, isMatch } = getStepRenderMeta(step); + const previousStep = index === 0 ? sharedSteps[sharedSteps.length - 1] : suffixSteps[index - 1]; + const previousMatch = previousStep ? isCommonStepMatch(previousStep) : false; + const dimLink = Boolean(previousStep && hasFilter && !(previousMatch || isMatch)); + chain.appendChild(buildStepSegment(item, step, info, true, isMatch, hasFilter, previousStep, dimLink, flowColIndex)); + flowColIndex += 1; + }); + } + + if (hasFilter && !rowHasMatch) main.classList.add('dim'); + row.appendChild(chain); + fullContentEl.appendChild(row); + }); + } else { + const row = document.createElement('div'); + row.className = 'sm-row'; + const main = document.createElement('div'); + main.className = 'sm-main'; + main.textContent = '공통'; + row.appendChild(main); + const empty = document.createElement('div'); + empty.className = 'empty-state'; + empty.style.minHeight = '120px'; + empty.textContent = '공통 플로우가 없습니다.'; + row.appendChild(empty); + fullContentEl.appendChild(row); + } + + requestAnimationFrame(() => applyColumnAlignment()); + } + + function setFilters(nextFilters) { + filters = { ...filters, ...nextFilters }; + } + + return { + render, + refreshFilterOptions, + setFilters, + realign: applyColumnAlignment + }; + } + + window.createProcessMapRenderer = createProcessMapRenderer; +})(); diff --git a/storage/hm-biz-process/flow.db b/storage/hm-biz-process/flow.db new file mode 100644 index 0000000..3425f72 Binary files /dev/null and b/storage/hm-biz-process/flow.db differ diff --git a/templates/admin_users.html b/templates/admin_users.html new file mode 100644 index 0000000..ae6d32c --- /dev/null +++ b/templates/admin_users.html @@ -0,0 +1,289 @@ +{% extends "base.html" %} + +{% block title %}사용자 관리{% endblock %} + +{% block head_extra %} + +{% endblock %} + +{% block content %} +
+
+
+

사용자 관리

+
+
+ +
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ +
+
+
+ +
+ {% for permission in permission_options %} + + {% endfor %} +
+
+ +
+
+ + + + + + + + + + + + + + + {% for user in users %} + + + + + + + + + + {% else %} + + {% endfor %} + +
아이디이름역할페이지 권한상태관리자수정일
{{ user.display_name }}{{ ', '.join(user.roles) }} +
+ {% for permission in permission_options %} + {% if permission.key in user.direct_permissions %} + {{ permission.label }} + {% endif %} + {% endfor %} + {% if user.is_admin %} + 전체 + {% elif not user.direct_permissions %} + 역할 기본값 + {% endif %} +
+
{{ '활성' if user.is_active else '비활성' }}{{ '예' if user.is_admin else '-' }}{{ user.updated_at }}
사용자가 없습니다.
+
+{% endblock %} + +{% block script %} + +{% endblock %} diff --git a/templates/annual_summary.html b/templates/annual_summary.html index f9863c2..a31de29 100644 --- a/templates/annual_summary.html +++ b/templates/annual_summary.html @@ -25,6 +25,49 @@ align-items: end; } + .summary-actions { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; + } + + .page-job-status { + display: inline-flex; + align-items: center; + min-height: 32px; + max-width: 320px; + padding: 0 10px; + border: 1px solid #d8e2ec; + border-radius: 999px; + background: #f8fafc; + color: #475569; + font-size: 12px; + font-weight: 800; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + .page-job-status[data-state="queued"], + .page-job-status[data-state="running"] { + border-color: #bae6fd; + background: #f0f9ff; + color: #075985; + } + + .page-job-status[data-state="done"] { + border-color: #bbf7d0; + background: #f0fdf4; + color: #166534; + } + + .page-job-status[data-state="failed"] { + border-color: #fecaca; + background: #fef2f2; + color: #991b1b; + } + .legend { display: flex; flex-wrap: wrap; @@ -207,6 +250,10 @@

수익/비용 현황

+
+ + 작업 상태 확인 전 +
@@ -253,6 +300,7 @@ let yearlySeries = []; let monthlySeries = []; let availableYears = []; + let annualJobPollTimer = null; const annualMetricCards = {{ annual_metric_cards | tojson }}; const annualExpenseChartMetrics = {{ annual_expense_chart_metrics | tojson }}; const annualBalanceChartMetrics = {{ annual_balance_chart_metrics | tojson }}; @@ -356,6 +404,89 @@ return String(availableYears[availableYears.length - 1] || "recent10"); } + async function fetchAnnualJson(url, options = {}) { + const response = await fetch(url, { + ...options, + cache: "no-store", + credentials: "same-origin", + headers: { + "Accept": "application/json", + "Cache-Control": "no-cache", + "X-Requested-With": "XMLHttpRequest", + ...(options.headers || {}), + }, + }); + const payload = await response.json().catch(() => ({})); + if (!response.ok || payload?.error || payload?.ok === false) { + throw new Error(payload?.error || `HTTP ${response.status}`); + } + return payload; + } + + function describeAnnualJob(job) { + if (!job) return { text: "최근 작업 없음", state: "" }; + const status = String(job.status || ""); + const message = job.error_message || job.message || ""; + if (status === "queued") return { text: "작업 대기 중", state: status }; + if (status === "running") return { text: `계산 중... ${message}`.trim(), state: status }; + if (status === "done") return { text: "계산 완료", state: status }; + if (status === "failed") return { text: `실패: ${message || "오류"}`, state: status }; + return { text: message || status || "작업 상태 확인 전", state: status }; + } + + function renderAnnualJob(job) { + const jobStatus = document.getElementById("annualJobStatus"); + const rebuildButton = document.getElementById("annualRebuildCacheBtn"); + if (!jobStatus) return; + const view = describeAnnualJob(job); + jobStatus.textContent = view.text; + jobStatus.dataset.state = view.state || ""; + if (rebuildButton) { + rebuildButton.disabled = view.state === "queued" || view.state === "running"; + } + } + + async function loadLatestAnnualJob() { + const params = new URLSearchParams({ + page_key: "annual_summary", + job_type: "annual_summary_bootstrap", + }); + const payload = await fetchAnnualJson(`/api/system-jobs/latest?${params.toString()}`); + renderAnnualJob(payload.job || null); + return payload.job || null; + } + + function pollAnnualJob(jobId) { + if (!jobId) return; + if (annualJobPollTimer) window.clearInterval(annualJobPollTimer); + annualJobPollTimer = window.setInterval(async () => { + try { + const payload = await fetchAnnualJson(`/api/system-jobs/${encodeURIComponent(jobId)}`); + const job = payload.job || null; + renderAnnualJob(job); + if (!job || ["done", "failed", "cancelled"].includes(String(job.status || ""))) { + window.clearInterval(annualJobPollTimer); + annualJobPollTimer = null; + if (job && job.status === "done") { + window.setTimeout(() => window.location.reload(), 600); + } + } + } catch (_error) { + // Keep existing charts visible during transient lock or network delays. + } + }, 2500); + } + + async function requestAnnualCacheRebuild() { + const rebuildButton = document.getElementById("annualRebuildCacheBtn"); + if (rebuildButton) rebuildButton.disabled = true; + const payload = await fetchAnnualJson("/annual-summary/api/rebuild-cache", { + method: "POST", + }); + renderAnnualJob(payload.job || null); + pollAnnualJob(payload.job?.id); + } + async function loadAnnualSummaryBootstrapData() { const response = await fetch("/annual-summary/bootstrap-data", { method: "GET", @@ -590,6 +721,21 @@ document.getElementById("granularity").addEventListener("change", renderAll); document.getElementById("yearFilter").addEventListener("change", renderAll); + document.getElementById("annualRebuildCacheBtn")?.addEventListener("click", async () => { + const confirmed = window.confirm("연도별 수익/비용 데이터를 서버에서 다시 계산할까요? 계산 중에도 다른 화면을 사용할 수 있습니다."); + if (!confirmed) return; + try { + await requestAnnualCacheRebuild(); + } catch (error) { + const jobStatus = document.getElementById("annualJobStatus"); + if (jobStatus) { + jobStatus.textContent = error.message || "작업 등록 실패"; + jobStatus.dataset.state = "failed"; + } + const rebuildButton = document.getElementById("annualRebuildCacheBtn"); + if (rebuildButton) rebuildButton.disabled = false; + } + }); const granularitySelect = document.getElementById("granularity"); if (granularitySelect) { granularitySelect.value = "yearly"; @@ -605,6 +751,19 @@ console.error("연도별 수익/비용 부트스트랩 데이터 조회 에러", error); } renderAll(); + loadLatestAnnualJob() + .then((job) => { + if (job && ["queued", "running"].includes(String(job.status || ""))) { + pollAnnualJob(job.id); + } + }) + .catch(() => { + const jobStatus = document.getElementById("annualJobStatus"); + if (jobStatus) { + jobStatus.textContent = "작업 상태 조회 실패"; + jobStatus.dataset.state = "failed"; + } + }); })(); {% endblock %} diff --git a/templates/base.html b/templates/base.html index f750e8b..3a55f35 100644 --- a/templates/base.html +++ b/templates/base.html @@ -520,7 +520,7 @@ .sync-status { margin-left: auto; - max-width: 240px; + max-width: 320px; border: 1px solid var(--line); border-radius: 999px; background: rgba(248, 250, 252, 0.92); @@ -645,6 +645,31 @@ font-weight: 700; } + .sync-refresh-button { + display: none; + height: calc(var(--status-widget-height) - 10px); + border: 1px solid #cbd5e1; + border-radius: 999px; + background: #ffffff; + color: #111827; + font-size: 11px; + font-weight: 800; + padding: 0 10px; + cursor: pointer; + white-space: nowrap; + } + + .sync-status.has-pending { + border-color: #f59e0b; + background: #fff7ed; + } + + .sync-status.has-pending .sync-refresh-button { + display: inline-flex; + align-items: center; + justify-content: center; + } + @media (max-width: 1200px) { .stats { grid-template-columns: repeat(2, minmax(0, 1fr)); @@ -682,14 +707,15 @@
@@ -1057,14 +1349,15 @@ +
+
작업 상태 확인 전
대상 인원 @@ -1139,6 +1432,7 @@
+ @@ -1190,6 +1484,7 @@
@@ -1203,8 +1498,10 @@
+
+
@@ -1220,6 +1517,100 @@
+ + + + + + {% endblock %} {% block script %} @@ -1232,6 +1623,8 @@ const statusBox = document.getElementById("hanmacConnectionStatus"); const testButton = document.getElementById("hanmacTestConnectionButton"); const loadTablesButton = document.getElementById("hanmacLoadTablesButton"); + const loadGradeCodesButton = document.getElementById("hanmacLoadGradeCodesButton"); + const gradeCodesPanel = document.getElementById("hanmacGradeCodesPanel"); const tableListMeta = document.getElementById("hanmacTableListMeta"); const tableList = document.getElementById("hanmacTableList"); const tableSearch = document.getElementById("hanmacTableSearch"); @@ -1239,6 +1632,8 @@ const aggregateEndDate = document.getElementById("hanmacAggregateEndDate"); const aggregateEmployment = document.getElementById("hanmacAggregateEmployment"); const aggregateLoadButton = document.getElementById("hanmacAggregateLoadButton"); + const aggregateRebuildCacheButton = document.getElementById("hanmacAggregateRebuildCacheButton"); + const systemJobStatus = document.getElementById("hanmacSystemJobStatus"); const aggregateMeta = document.getElementById("hanmacAggregateMeta"); const aggregateMemberCount = document.getElementById("hanmacAggregateMemberCount"); const aggregateRegularHours = document.getElementById("hanmacAggregateRegularHours"); @@ -1246,6 +1641,21 @@ const aggregateTotalHours = document.getElementById("hanmacAggregateTotalHours"); const aggregateLeaveDays = document.getElementById("hanmacAggregateLeaveDays"); const aggregateProjectCount = document.getElementById("hanmacAggregateProjectCount"); + const jointPanel = document.getElementById("hanmacJointPanel"); + const jointMeta = document.getElementById("hanmacJointMeta"); + const jointBody = document.getElementById("hanmacJointBody"); + const jointEmpty = document.getElementById("hanmacJointEmpty"); + const jointModal = document.getElementById("hanmacJointModal"); + const openJointButton = document.getElementById("hanmacOpenJointButton"); + const closeJointButton = document.getElementById("hanmacCloseJointButton"); + const centerPanel = document.getElementById("hanmacCenterPanel"); + const centerMeta = document.getElementById("hanmacCenterMeta"); + const centerApplyButton = document.getElementById("hanmacCenterApplyButton"); + const centerBody = document.getElementById("hanmacCenterBody"); + const centerEmpty = document.getElementById("hanmacCenterEmpty"); + const centerModal = document.getElementById("hanmacCenterModal"); + const openCenterButton = document.getElementById("hanmacOpenCenterButton"); + const closeCenterButton = document.getElementById("hanmacCloseCenterButton"); const aggregateEmpty = document.getElementById("hanmacAggregateEmpty"); const aggregateTable = document.getElementById("hanmacAggregateTable"); const aggregateHead = document.getElementById("hanmacAggregateHead"); @@ -1254,7 +1664,16 @@ const aggregateValueSearch = document.getElementById("hanmacAggregateValueSearch"); const aggregateValueOptions = document.getElementById("hanmacAggregateValueOptions"); const aggregateValueReset = document.getElementById("hanmacAggregateValueReset"); + const openHolidayButton = document.getElementById("hanmacOpenHolidayButton"); const exportAggregateButton = document.getElementById("hanmacExportAggregateButton"); + const holidayModal = document.getElementById("hanmacHolidayModal"); + const closeHolidayButton = document.getElementById("hanmacCloseHolidayButton"); + const holidayMeta = document.getElementById("hanmacHolidayMeta"); + const holidayList = document.getElementById("hanmacHolidayList"); + const holidayDateInput = document.getElementById("hanmacHolidayDate"); + const holidayTypeSelect = document.getElementById("hanmacHolidayType"); + const holidayNameInput = document.getElementById("hanmacHolidayName"); + const holidaySaveButton = document.getElementById("hanmacHolidaySaveButton"); const multiEntryModal = document.getElementById("hanmacMultiEntryModal"); const closeMultiEntryButton = document.getElementById("hanmacCloseMultiEntryButton"); const multiEntryTitle = document.getElementById("hanmacMultiEntryTitle"); @@ -1266,6 +1685,7 @@ const previewValueReset = document.getElementById("hanmacPreviewValueReset"); const previewLimit = document.getElementById("hanmacPreviewLimit"); const refreshPreviewButton = document.getElementById("hanmacRefreshPreviewButton"); + const previewRebuildCacheButton = document.getElementById("hanmacPreviewRebuildCacheButton"); const previewPrevButton = document.getElementById("hanmacPreviewPrevButton"); const previewNextButton = document.getElementById("hanmacPreviewNextButton"); const exportPreviewButton = document.getElementById("hanmacExportPreviewButton"); @@ -1283,7 +1703,7 @@ const summaryKey = document.getElementById("hanmacSummaryKey"); const schemaTabs = Array.from(document.querySelectorAll("[data-schema-filter]")); const focusButtons = Array.from(document.querySelectorAll("[data-focus]")); - if (!form || !modal || !openConfigButton || !closeConfigButton || !statusBox || !testButton || !loadTablesButton || !tableListMeta || !tableList || !tableSearch || !aggregateStartDate || !aggregateEndDate || !aggregateEmployment || !aggregateLoadButton || !aggregateMeta || !aggregateMemberCount || !aggregateRegularHours || !aggregateOvertimeHours || !aggregateTotalHours || !aggregateLeaveDays || !aggregateProjectCount || !aggregateEmpty || !aggregateTable || !aggregateHead || !aggregateBody || !aggregateValueColumn || !aggregateValueSearch || !aggregateValueOptions || !aggregateValueReset || !exportAggregateButton || !multiEntryModal || !closeMultiEntryButton || !multiEntryTitle || !multiEntryMeta || !multiEntryBody || !previewValueColumn || !previewValueSearch || !previewValueOptions || !previewValueReset || !previewLimit || !refreshPreviewButton || !previewPrevButton || !previewNextButton || !exportPreviewButton || !previewMeta || !previewEmpty || !previewControls || !previewTable || !previewHead || !previewBody || !previewToggleButton || !selectedTitle || !summarySchema || !summaryRows || !summaryColumns || !summaryKey) return; + if (!form || !modal || !openConfigButton || !closeConfigButton || !statusBox || !testButton || !loadTablesButton || !loadGradeCodesButton || !gradeCodesPanel || !tableListMeta || !tableList || !tableSearch || !aggregateStartDate || !aggregateEndDate || !aggregateEmployment || !aggregateLoadButton || !aggregateRebuildCacheButton || !systemJobStatus || !aggregateMeta || !aggregateMemberCount || !aggregateRegularHours || !aggregateOvertimeHours || !aggregateTotalHours || !aggregateLeaveDays || !aggregateProjectCount || !jointPanel || !jointMeta || !jointBody || !jointEmpty || !jointModal || !openJointButton || !closeJointButton || !centerPanel || !centerMeta || !centerApplyButton || !centerBody || !centerEmpty || !centerModal || !openCenterButton || !closeCenterButton || !aggregateEmpty || !aggregateTable || !aggregateHead || !aggregateBody || !aggregateValueColumn || !aggregateValueSearch || !aggregateValueOptions || !aggregateValueReset || !openHolidayButton || !holidayModal || !closeHolidayButton || !exportAggregateButton || !multiEntryModal || !closeMultiEntryButton || !multiEntryTitle || !multiEntryMeta || !multiEntryBody || !previewValueColumn || !previewValueSearch || !previewValueOptions || !previewValueReset || !previewLimit || !refreshPreviewButton || !previewRebuildCacheButton || !previewPrevButton || !previewNextButton || !exportPreviewButton || !previewMeta || !previewEmpty || !previewControls || !previewTable || !previewHead || !previewBody || !previewToggleButton || !selectedTitle || !summarySchema || !summaryRows || !summaryColumns || !summaryKey) return; let allTables = []; let visibleTables = []; @@ -1299,12 +1719,23 @@ let currentPreviewCursorHistory = []; let currentPreviewExportJobKey = ""; let currentAggregateExportJobKey = ""; + let systemJobPollTimer = null; let currentAggregateRows = []; let currentAggregateAllRows = []; + let currentCenterMembers = []; + let currentJointMembers = []; + let restoredCenterMemberNos = new Set(); let currentAggregateColumns = []; let currentAggregateView = "member"; let currentAggregateSort = { key: "", direction: "desc" }; + let currentAggregateResultKey = ""; + let currentAggregateRenderSignature = ""; + let aggregateInFlightKey = ""; + let aggregateLoadPromise = null; + let aggregateRequestSeq = 0; const credentialStorageKey = "hanmac-db-external-credentials-v1"; + const aggregateStateStorageKey = "hanmac-db-external-aggregate-state-v1"; + const jointMembersFallbackUrl = "/static/hanmac-joint-members-cache.json"; const priorityTableNames = ["dallyproject_tbl", "dallyproject_addwork_tbl", "member_tbl", "project_tbl", "worker_tardy_tbl"]; const focusColumnKeywords = { @@ -1357,6 +1788,44 @@ modal.hidden = true; }; + const openHolidayModal = () => { + holidayModal.hidden = false; + loadHolidays(); + }; + + const closeHolidayModal = () => { + holidayModal.hidden = true; + }; + + const openJointModal = async () => { + jointModal.hidden = false; + jointEmpty.hidden = true; + if (!currentJointMembers.length) { + jointPanel.hidden = false; + jointMeta.textContent = "합사 정보 불러오는 중..."; + jointBody.innerHTML = ""; + const payload = await loadAggregate(); + if (!currentJointMembers.length) { + await loadJointMembersFallback(); + } + if (!payload && !currentJointMembers.length) { + jointMeta.textContent = "합사 정보를 불러오지 못했습니다."; + } + } + }; + + const closeJointModal = () => { + jointModal.hidden = true; + }; + + const openCenterModal = () => { + centerModal.hidden = false; + }; + + const closeCenterModal = () => { + centerModal.hidden = true; + }; + const closeMultiEntryModal = () => { multiEntryModal.hidden = true; }; @@ -1379,6 +1848,83 @@ }; }; + const fetchJson = async (url, options = {}) => { + const response = await fetch(url, { + ...options, + cache: "no-store", + credentials: "same-origin", + headers: { + "Accept": "application/json", + "Cache-Control": "no-cache", + "X-Requested-With": "XMLHttpRequest", + ...(options.headers || {}), + }, + }); + const payload = await response.json().catch(() => ({})); + if (!response.ok || payload?.error || payload?.ok === false) { + throw new Error(payload?.error || payload?.message || `HTTP ${response.status}`); + } + return payload; + }; + + const describeSystemJob = (job) => { + if (!job) return { text: "최근 작업 없음", tone: "" }; + const status = String(job.status || ""); + const message = job.error_message || job.message || ""; + const type = String(job.job_type || "").includes("aggregate") ? "집계" : "미리보기"; + if (status === "queued") return { text: `${type} 캐시 대기 중`, tone: "" }; + if (status === "running") return { text: `${type} 캐시 계산 중... ${message}`.trim(), tone: "" }; + if (status === "done") return { text: `${type} 캐시 계산 완료`, tone: "success" }; + if (status === "failed") return { text: `${type} 캐시 실패: ${message || "오류"}`, tone: "error" }; + return { text: `${type} ${message || status || "작업 상태 확인 전"}`.trim(), tone: "" }; + }; + + const renderSystemJob = (job) => { + const view = describeSystemJob(job); + systemJobStatus.textContent = view.text; + systemJobStatus.classList.remove("is-success", "is-error"); + if (view.tone === "success") systemJobStatus.classList.add("is-success"); + if (view.tone === "error") systemJobStatus.classList.add("is-error"); + const busy = ["queued", "running"].includes(String(job?.status || "")); + aggregateRebuildCacheButton.disabled = busy; + previewRebuildCacheButton.disabled = busy; + }; + + const pollSystemJob = (jobId, afterDone) => { + if (!jobId) return; + if (systemJobPollTimer) window.clearInterval(systemJobPollTimer); + systemJobPollTimer = window.setInterval(async () => { + try { + const payload = await fetchJson(`/api/system-jobs/${encodeURIComponent(jobId)}`); + const job = payload.job || null; + renderSystemJob(job); + if (!job || ["done", "failed", "cancelled"].includes(String(job.status || ""))) { + window.clearInterval(systemJobPollTimer); + systemJobPollTimer = null; + if (job && job.status === "done" && typeof afterDone === "function") { + await afterDone(); + } + } + } catch (_error) { + // Keep the page usable during transient connection delays. + } + }, 2500); + }; + + const loadLatestSystemJob = async () => { + try { + const payload = await fetchJson("/api/system-jobs/latest?page_key=hanmac_browser"); + const job = payload.job || null; + renderSystemJob(job); + if (job && ["queued", "running"].includes(String(job.status || ""))) { + pollSystemJob(job.id); + } + } catch (_error) { + systemJobStatus.textContent = "작업 상태 조회 실패"; + systemJobStatus.classList.add("is-error"); + } + }; + const normalizeDateYear = (input) => { const rawValue = String(input?.value || ""); if (!rawValue) return; @@ -1538,6 +2084,7 @@ end_date: aggregateEndDate.value, employment: aggregateEmployment.value, view: currentAggregateView, + include_center_member_nos: getRestoredCenterMemberNos(), value_column: aggregateValueColumn.value, value_search: aggregateValueSearch.value, sort_key: currentAggregateSort.key, @@ -1594,12 +2141,19 @@ const isMatchFocus = (item) => { const joined = `${item.schema} ${item.name}`.toLowerCase(); if (focusFilter === "all") return true; - if (focusFilter === "yearly") return joined.includes("dallyproject") || joined.includes("tardy") || item.schema === "hanmac_manhour"; + if (focusFilter === "yearly") return joined.includes("dallyproject") || joined.includes("tardy") || item.schema === "hanmac_manhour" || item.schema === "baron_manhour"; if (focusFilter === "member") return joined.includes("member") || joined.includes("worker") || joined.includes("user") || joined.includes("tardy"); if (focusFilter === "project") return joined.includes("project") || joined.includes("dallyproject"); return true; }; + const getSchemaPriority = (schema) => { + if (schema === "hanmac_manhour") return 0; + if (schema === "baron_manhour") return 1; + if (schema === "hanmac") return 2; + return 9; + }; + const getVisibleTables = () => { const keyword = String(tableSearch.value || "").trim().toLowerCase(); return allTables @@ -1611,8 +2165,8 @@ return `${item.schema} ${item.name} ${profile.key}`.toLowerCase().includes(keyword); }) .sort((a, b) => { - const aSchemaPriority = a.schema === "hanmac_manhour" ? 0 : 1; - const bSchemaPriority = b.schema === "hanmac_manhour" ? 0 : 1; + const aSchemaPriority = getSchemaPriority(a.schema); + const bSchemaPriority = getSchemaPriority(b.schema); const aPriority = priorityTableNames.indexOf(a.name); const bPriority = priorityTableNames.indexOf(b.name); const aFocusBoost = focusFilter === "yearly" && a.name.includes("dallyproject") ? -1 : 0; @@ -1659,7 +2213,15 @@ previewNextButton.disabled = !currentPreviewNextCursor; }; - const setAggregateEmpty = (message) => { + const setAggregateEmpty = (message, options = {}) => { + const forceClear = options.forceClear === true; + const shouldPreserveExisting = currentAggregateAllRows.length > 0 && !forceClear; + if (options.preserveExisting || shouldPreserveExisting) { + aggregateMeta.textContent = message || aggregateMeta.textContent || "이전 조회 결과를 유지하고 있습니다."; + aggregateEmpty.hidden = true; + aggregateTable.hidden = false; + return; + } aggregateEmpty.hidden = false; aggregateEmpty.textContent = message; aggregateTable.hidden = true; @@ -1668,7 +2230,13 @@ currentAggregateColumns = []; currentAggregateRows = []; currentAggregateAllRows = []; + currentCenterMembers = []; + centerPanel.hidden = true; + centerEmpty.hidden = false; + centerMeta.textContent = ""; + centerBody.innerHTML = ""; currentAggregateView = "member"; + currentAggregateRenderSignature = ""; aggregateValueColumn.innerHTML = ``; aggregateValueSearch.value = ""; aggregateValueOptions.innerHTML = ""; @@ -1681,12 +2249,29 @@ aggregateProjectCount.textContent = "0"; }; + const getAggregateStateKey = (payload) => JSON.stringify({ + start_date: String(payload?.start_date || ""), + end_date: String(payload?.end_date || ""), + employment: String(payload?.employment || "all"), + view: String(payload?.view || "member"), + include_center_member_nos: Array.isArray(payload?.include_center_member_nos) + ? [...payload.include_center_member_nos].map(String).sort() + : [], + }); + const formatNumberText = (value) => { const number = Number(value || 0); if (!Number.isFinite(number)) return value == null ? "" : String(value); return number.toLocaleString("ko-KR", { maximumFractionDigits: 2 }); }; + const formatActualHoursText = (value) => { + if (value == null || value === "") return "-"; + const number = Number(value); + if (!Number.isFinite(number)) return "-"; + return number.toLocaleString("ko-KR", { minimumFractionDigits: 1, maximumFractionDigits: 1 }); + }; + const aggregateDayColumnMap = { regular_hours: "regular_work_days", overtime_hours: "overtime_work_days", @@ -1730,6 +2315,66 @@ return value == null ? "" : String(value); }; + const renderGradeCodes = (payload) => { + const schemas = Array.isArray(payload?.schemas) ? payload.schemas : []; + const rows = schemas.flatMap((schema) => { + const schemaRows = Array.isArray(schema.rows) ? schema.rows : []; + if (!schemaRows.length) { + return [{ + schema: schema.schema || "-", + grade_col: schema.grade_col || "-", + grade_code: "-", + mapped_name: "", + normalized_name: "", + member_count: 0, + active_member_count: 0, + examples: schema.grade_col ? "직급 코드 없음" : "직급 컬럼 없음", + }]; + } + return schemaRows.map((row) => ({ + schema: schema.schema || "-", + grade_col: schema.grade_col || "-", + grade_code: row.grade_code || "", + mapped_name: row.mapped_name || "", + normalized_name: row.normalized_name || "", + member_count: row.member_count || 0, + active_member_count: row.active_member_count || 0, + examples: Array.isArray(row.examples) ? row.examples.join(", ") : "", + })); + }); + gradeCodesPanel.classList.add("is-open"); + gradeCodesPanel.innerHTML = ` + + + + + + + + + + + + + + + ${rows.map((row) => ` + + + + + + + + + + + `).join("")} + +
스키마직급 컬럼직급 코드코드명정규화명전체 인원재직 인원예시
${escapeHtml(row.schema)}${escapeHtml(row.grade_col)}${escapeHtml(row.grade_code)}${escapeHtml(row.mapped_name || "-")}${escapeHtml(row.normalized_name || "-")}${formatNumberText(row.member_count)}${formatNumberText(row.active_member_count)}${escapeHtml(row.examples || "")}
+ `; + }; + const renderPreviewTableRows = (columns, rows) => { currentPreviewColumns = columns; currentPreviewRows = rows; @@ -1839,6 +2484,13 @@ }; const renderAggregateMetricCell = (row, column, rowIndex, view) => { + if (view === "member" && column.key === "remarks") { + const duplicateDays = Number(row.multi_entry_days || 0); + const detailButton = duplicateDays > 0 + ? `` + : ""; + return `${detailButton}`; + } const isNumeric = aggregateNumericColumns.has(column.key); const valueKey = aggregateValueColumnMap[column.key] || column.key; const valueText = formatNumberText(row[valueKey]); @@ -1848,13 +2500,6 @@ const dayKey = aggregateDayColumnMap[column.key]; const dayValue = Number(row[dayKey] || 0); const dayBadge = dayKey ? `${formatNumberText(dayValue)}일` : ""; - if (view === "member" && column.key === "regular_hours") { - const duplicateDays = Number(row.multi_entry_days || 0); - const detailButton = duplicateDays > 0 - ? `` - : ""; - return `
${valueHtml}${dayBadge}${detailButton}
`; - } if (dayKey) { return `
${valueHtml}${dayBadge}
`; } @@ -1867,9 +2512,46 @@ return `${row[column.key] == null ? "" : escapeHtml(String(row[column.key]))}`; }; + const getAggregateRowIdentity = (row) => { + if (!row || typeof row !== "object") return ""; + const identityKeys = [ + "member_no", + "member_name", + "member_grade", + "project_code", + "year", + "regular_hours", + "overtime_hours", + "holiday_hours", + "total_hours", + "legal_leave_hours", + "project_count", + "remarks", + ]; + return identityKeys.map((key) => `${key}:${String(row[key] ?? "")}`).join("|"); + }; + + const getAggregateRenderSignature = (columns, rows, view) => JSON.stringify({ + view, + sort: currentAggregateSort, + filter_column: aggregateValueColumn.value || "", + filter_value: aggregateValueSearch.value || "", + columns: columns.map((column) => column.key), + row_count: rows.length, + first: getAggregateRowIdentity(rows[0]), + last: getAggregateRowIdentity(rows[rows.length - 1]), + }); + const renderAggregateTableRows = (columns, rows, view) => { currentAggregateColumns = columns; currentAggregateRows = rows; + const renderSignature = getAggregateRenderSignature(columns, rows, view); + if (renderSignature === currentAggregateRenderSignature && !aggregateTable.hidden) { + aggregateEmpty.hidden = true; + aggregateTable.hidden = false; + return; + } + currentAggregateRenderSignature = renderSignature; aggregateHead.innerHTML = `${columns.map((column) => { const sortedClass = currentAggregateSort.key === column.key ? ` is-sorted-${currentAggregateSort.direction}` @@ -1900,6 +2582,125 @@ renderAggregateTableRows(currentAggregateColumns, getSortedAggregateRows(filteredRows), currentAggregateView); }; + const getRestoredCenterMemberNos = () => Array.from(restoredCenterMemberNos).filter(Boolean).sort((a, b) => a.localeCompare(b, "ko", { numeric: true })); + + const renderCenterMembers = (members = []) => { + currentCenterMembers = Array.isArray(members) ? members : []; + if (!currentCenterMembers.length) { + centerPanel.hidden = true; + centerEmpty.hidden = false; + centerMeta.textContent = ""; + centerBody.innerHTML = ""; + return; + } + const restoreEligible = currentCenterMembers.filter((member) => member.can_restore !== false && String(member.member_no || "")); + const restoredCount = restoreEligible.filter((member) => restoredCenterMemberNos.has(String(member.member_no || ""))).length; + const excludedCount = restoreEligible.length - restoredCount; + const centerOnlyCount = currentCenterMembers.length - restoreEligible.length; + centerPanel.hidden = false; + centerEmpty.hidden = true; + centerMeta.textContent = `전체 ${Number(currentCenterMembers.length).toLocaleString("ko-KR")}명 · 한맥 중복 제외 ${Number(excludedCount).toLocaleString("ko-KR")}명 · 복구 ${Number(restoredCount).toLocaleString("ko-KR")}명 · 센터 전용 ${Number(centerOnlyCount).toLocaleString("ko-KR")}명`; + centerBody.innerHTML = currentCenterMembers.map((member) => { + const memberNo = String(member.member_no || ""); + const canRestore = member.can_restore !== false && Boolean(memberNo); + const checked = restoredCenterMemberNos.has(memberNo); + return ` + + + ${escapeHtml(memberNo || "-")} + ${escapeHtml(member.center_member_no || "")} + ${escapeHtml(member.member_name || "")} + ${escapeHtml(member.dept_name || "")} + ${escapeHtml(member.matched_by || "")} + ${escapeHtml(canRestore ? (checked ? "복구" : "기본 제외") : (member.status || "센터/총괄 전용"))} + + `; + }).join(""); + }; + + const getJointContentLines = (member) => { + const sourceLines = Array.isArray(member?.contents) && member.contents.length + ? member.contents + : String(member?.content || "").split(/\n+/); + return sourceLines.map((line) => { + const normalized = String(line || "").trim(); + if (!normalized) return null; + const parts = normalized.split(/\s+\/\s+/); + const dateText = parts.shift() || ""; + return { + dateText, + bodyText: parts.join(" / ") || normalized, + rawText: normalized, + }; + }).filter(Boolean); + }; + + const renderJointContentLines = (member) => { + const lines = getJointContentLines(member); + if (!lines.length) return ""; + return ` +
+ ${lines.map((line) => ` +
+ ${escapeHtml(line.dateText || "-")} + ${escapeHtml(line.bodyText || line.rawText || "")} +
+ `).join("")} +
+ `; + }; + + const getJointDateCount = (member) => { + const dates = new Set(getJointContentLines(member).map((line) => line.dateText).filter(Boolean)); + return dates.size; + }; + + const renderJointMembers = (members = []) => { + currentJointMembers = Array.isArray(members) ? members : []; + if (!currentJointMembers.length) { + jointPanel.hidden = false; + jointEmpty.hidden = true; + jointMeta.textContent = "해당 조건의 합사 정보가 없습니다."; + jointBody.innerHTML = ""; + return; + } + jointPanel.hidden = false; + jointEmpty.hidden = true; + jointMeta.textContent = `전체 ${Number(currentJointMembers.length).toLocaleString("ko-KR")}명`; + jointBody.innerHTML = currentJointMembers.map((member) => { + const dateCount = getJointDateCount(member); + return ` + + ${escapeHtml(member.member_no || "")} + ${escapeHtml(member.member_name || "")} + ${escapeHtml(member.member_grade || "")} + ${escapeHtml(member.entry_date || "")} + ${escapeHtml(member.leave_date || "")} + ${renderJointContentLines(member)} + ${Number(dateCount || 0).toLocaleString("ko-KR")} + + `; + }).join(""); + }; + + const loadJointMembersFallback = async () => { + try { + const response = await fetch(`${jointMembersFallbackUrl}?v=${Date.now()}`, { cache: "no-store" }); + if (!response.ok) return false; + const payload = await response.json(); + const requestKey = getAggregateStateKey(getAggregateRequestPayload()); + const exact = payload?.by_key?.[requestKey]; + const fallback = exact || payload?.latest || null; + if (!fallback || !Array.isArray(fallback.joint_members)) return false; + renderJointMembers(fallback.joint_members); + const label = exact ? "현재 조건" : `${fallback.start_date || ""} ~ ${fallback.end_date || ""}`; + jointMeta.textContent = `전체 ${Number(fallback.joint_members.length).toLocaleString("ko-KR")}명 · ${label}`; + return true; + } catch (_error) { + return false; + } + }; + const openMultiEntryModal = (row) => { const details = Array.isArray(row?.multi_entry_details) ? row.multi_entry_details : []; multiEntryTitle.textContent = `${row?.member_name || row?.member_no || "사원"} 중복 근무 상세`; @@ -1911,16 +2712,18 @@
${escapeHtml(detail.work_date || "-")} - 원본 ${formatNumberText(detail.raw_total_hours)}시간 - 반영 ${formatNumberText(detail.capped_regular_hours)}시간 + 원본근로 ${formatNumberText(detail.raw_total_hours)}시간 + 집계반영 ${formatNumberText(detail.capped_regular_hours)}시간 ${Number(detail.row_count || 0).toLocaleString("ko-KR")}행 + 표시 ${Number(detail.display_row_count || detail.row_count || 0).toLocaleString("ko-KR")}행
- + + @@ -1929,6 +2732,7 @@ + `).join("")} @@ -1964,6 +2768,61 @@ `; }; + const getDetailOwnerText = (detail, fallback = "(미지정)") => currentAggregateView === "project" + ? `${detail.member_name || detail.member_no || fallback} ${detail.member_no ? `(${detail.member_no})` : ""}`.trim() + : `${detail.project_name || detail.project_code || fallback} ${detail.project_code ? `(${detail.project_code})` : ""}`.trim(); + + const flattenTimeDetails = (detailKey, details) => details.flatMap((detail) => { + const prefix = detail.detail_type ? `[${detail.detail_type}] ` : ""; + const sourcePrefix = (value) => value ? `[${value}] ` : prefix; + if (detailKey === "legal_leave_days") { + return [{ + workDate: detail.work_date, + project: getDetailOwnerText(detail, detail.leave_type || "법정휴가"), + recognizedHours: detail.leave_hours, + actualHours: null, + }]; + } + if (detailKey === "regular_hours" || (detailKey === "total_hours" && detail.detail_type === "정규근로")) { + if (currentAggregateView !== "project" && Array.isArray(detail.projects) && detail.projects.length) { + return detail.projects.map((project) => ({ + workDate: detail.work_date, + project: `${sourcePrefix(project.source_label)}${project.project_name || project.project_code || "(미지정)"}${project.project_code ? ` (${project.project_code})` : ""}`, + recognizedHours: project.recognized_hours ?? (detail.projects.length === 1 ? detail.regular_hours : 0), + actualHours: project.hours, + })); + } + return [{ + workDate: detail.work_date, + project: `${sourcePrefix(detail.source_label)}${getDetailOwnerText(detail)}`, + recognizedHours: detail.regular_hours, + actualHours: detail.raw_project_hours ?? detail.raw_total_hours, + }]; + } + if (detailKey === "holiday_hours" || (detailKey === "total_hours" && detail.detail_type === "휴일근로")) { + if (currentAggregateView !== "project" && Array.isArray(detail.projects) && detail.projects.length) { + return detail.projects.map((project) => ({ + workDate: detail.work_date, + project: `${prefix}${project.project_name || project.project_code || "(미지정)"}${project.project_code ? ` (${project.project_code})` : ""}`, + recognizedHours: project.recognized_hours ?? (detail.projects.length === 1 ? detail.holiday_hours : 0), + actualHours: project.hours, + })); + } + return [{ + workDate: detail.work_date, + project: `${prefix}${getDetailOwnerText(detail)}`, + recognizedHours: detail.holiday_hours, + actualHours: detail.raw_project_hours ?? detail.raw_holiday_hours, + }]; + } + return [{ + workDate: detail.work_date, + project: `${sourcePrefix(detail.source === "합사" ? "합사" : detail.source_label)}${getDetailOwnerText(detail)}`, + recognizedHours: detail.overtime_hours, + actualHours: detail.raw_overtime_hours, + }]; + }); + const renderAggregateDetailRows = (detailKey, details) => { if (detailKey === "project_count") { return ` @@ -1985,68 +2844,29 @@
프로젝트코드 프로젝트명행 정규시간행 원본근로비고
${renderProjectCodeText(entry)} ${escapeHtml(entry.project_name || entry.project_code || "(미지정)")} ${formatNumberText(entry.regular_hours)}${Number(entry.row_count || 0) > 1 ? `원본 ${Number(entry.row_count).toLocaleString("ko-KR")}행` : ""}
`; } - if (detailKey === "legal_leave_days") { - return ` - - + const detailRows = flattenTimeDetails(detailKey, details); + return ` +
+ + + + + + + + + + ${detailRows.map((detail) => ` - - - - + + + + - - - ${details.map((detail) => ` - - - - - - - `).join("")} - -
일자프로젝트집계반영원본근로
일자구분일수시간${escapeHtml(detail.workDate || "-")}${escapeHtml(detail.project || "(미지정)")}${formatNumberText(detail.recognizedHours)}${formatActualHoursText(detail.actualHours)}
${escapeHtml(detail.work_date || "-")}${escapeHtml(detail.leave_type || "법정휴가")}${formatNumberText(detail.leave_days)}${formatNumberText(detail.leave_hours)}
- `; - } - if (detailKey === "overtime_hours") { - return ` - - - - - - - - - - ${details.map((detail) => ` - - - - - - `).join("")} - -
일자${currentAggregateView === "project" ? "사원" : "프로젝트"}시간
${escapeHtml(detail.work_date || "-")}${escapeHtml(currentAggregateView === "project" ? `${detail.member_name || detail.member_no || ""} ${detail.member_no ? `(${detail.member_no})` : ""}`.trim() : `${detail.project_name || detail.project_code || "(미지정)"} ${detail.project_code ? `(${detail.project_code})` : ""}`.trim())}${formatNumberText(detail.overtime_hours)}
- `; - } - return details.map((detail) => ` -
-
- ${escapeHtml(detail.work_date || "-")} - ${detail.detail_type ? `${escapeHtml(detail.detail_type)}` : ""} - ${detail.member_name || detail.member_no ? `${escapeHtml(`${detail.member_name || detail.member_no || ""} ${detail.member_no ? `(${detail.member_no})` : ""}`.trim())}` : ""} - ${detail.regular_hours != null ? `정규 ${formatNumberText(detail.regular_hours)}시간` : ""} - ${detail.holiday_hours != null ? `휴일 ${formatNumberText(detail.holiday_hours)}시간` : ""} - ${detail.overtime_hours != null ? `연장 ${formatNumberText(detail.overtime_hours)}시간` : ""} - ${detail.raw_total_hours != null ? `원본 ${formatNumberText(detail.raw_total_hours)}시간` : ""} - ${detail.leave_days ? `휴가 ${formatNumberText(detail.leave_days)}일` : ""} - ${detail.leave_hours ? `휴가 ${formatNumberText(detail.leave_hours)}시간` : ""} -
- ${renderDetailProjectList(detail.projects)} -
- `).join(""); + `).join("")} + + + `; }; const openAggregateDetailModal = (row, detailKey) => { @@ -2140,12 +2960,22 @@ updatePreviewPager(); }; - const renderAggregate = (payload) => { + const renderAggregate = (payload, options = {}) => { const columns = Array.isArray(payload.columns) ? payload.columns : []; const rows = Array.isArray(payload.rows) ? payload.rows : []; + if (!columns.length || !rows.length) { + setAggregateEmpty( + "새 조회 응답이 비어 있어 이전 결과를 유지합니다.", + { preserveExisting: currentAggregateAllRows.length > 0 || options.preserveExistingOnEmpty }, + ); + return; + } const summary = payload.summary || {}; currentAggregateAllRows = rows; currentAggregateView = payload.view || "member"; + currentAggregateResultKey = options.requestKey || getAggregateStateKey(payload); + renderCenterMembers(payload.center_members || []); + renderJointMembers(payload.joint_members || []); aggregateMemberCount.textContent = Number(summary.member_count || 0).toLocaleString("ko-KR"); aggregateRegularHours.textContent = Number(summary.regular_hours || 0).toLocaleString("ko-KR"); aggregateOvertimeHours.textContent = Number(summary.overtime_hours || 0).toLocaleString("ko-KR"); @@ -2154,13 +2984,9 @@ aggregateProjectCount.textContent = Number(summary.project_count || 0).toLocaleString("ko-KR"); const diagnostics = payload.source_diagnostics || {}; const diagnosticText = diagnostics - ? ` · HolidayTime ${Number(diagnostics.holiday_time_rows || 0).toLocaleString("ko-KR")}행 · 휴가 ${Number(diagnostics.leave_matched_rows || 0).toLocaleString("ko-KR")}/${Number(diagnostics.tardy_candidate_rows || 0).toLocaleString("ko-KR")}행` + ? ` · 공식연장 ${Number(diagnostics.official_overtime_rows || 0).toLocaleString("ko-KR")}행 · addwork ${Number(diagnostics.addwork_parsed_rows || 0).toLocaleString("ko-KR")}/${Number(diagnostics.addwork_source_rows || 0).toLocaleString("ko-KR")}행 · 합사 ${Number(diagnostics.joint_assignment_records || 0).toLocaleString("ko-KR")}건(코드 ${Number(diagnostics.joint_assignment_code_matched_rows || 0).toLocaleString("ko-KR")}, 문구 ${Number(diagnostics.joint_assignment_text_matched_rows || 0).toLocaleString("ko-KR")})/${Number(diagnostics.joint_assignment_regular_rows || 0).toLocaleString("ko-KR")}일 · 합사연장 ${Number(diagnostics.joint_assignment_overtime_rows || 0).toLocaleString("ko-KR")}일 · 사번통합 ${Number(diagnostics.canonical_member_no_rows || 0).toLocaleString("ko-KR")}행 · 임계 제외 ${Number((diagnostics.addwork_weekday_threshold_filtered_rows || 0) + (diagnostics.addwork_holiday_threshold_filtered_rows || 0)).toLocaleString("ko-KR")}행 · 휴가 ${Number(diagnostics.leave_matched_rows || 0).toLocaleString("ko-KR")}/${Number(diagnostics.tardy_candidate_rows || 0).toLocaleString("ko-KR")}행 · 탄력 제외 ${Number(diagnostics.leave_flexible_work_excluded_rows || 0).toLocaleString("ko-KR")}행 · 부서 연결 ${Number(diagnostics.dept_mapped_rows || 0).toLocaleString("ko-KR")}명 · 바론 제외 ${Number(diagnostics.center_excluded_member_count || 0).toLocaleString("ko-KR")}명 (동일사번 ${Number(diagnostics.center_same_member_hidden_count || 0).toLocaleString("ko-KR")}명)` : ""; aggregateMeta.textContent = `${payload.start_date || ""} ~ ${payload.end_date || ""}${diagnosticText}`; - if (!columns.length || !rows.length) { - setAggregateEmpty(""); - return; - } if (currentAggregateSort.key && !columns.some((column) => column.key === currentAggregateSort.key)) { currentAggregateSort = { key: "", direction: "desc" }; } @@ -2170,6 +2996,115 @@ renderAggregateTableRows(columns, getSortedAggregateRows(rows), payload.view || "member"); }; + const getAggregateRequestPayload = () => ({ + ...getPayload(), + start_date: aggregateStartDate.value, + end_date: aggregateEndDate.value, + employment: aggregateEmployment.value, + view: getAggregateView(), + include_center_member_nos: getRestoredCenterMemberNos(), + }); + + const persistAggregateState = () => { + try { + window.sessionStorage.setItem(aggregateStateStorageKey, JSON.stringify({ + start_date: aggregateStartDate.value, + end_date: aggregateEndDate.value, + employment: aggregateEmployment.value, + view: getAggregateView(), + include_center_member_nos: getRestoredCenterMemberNos(), + saved_at: new Date().toISOString(), + })); + } catch (_error) { + // The page remains usable even when session storage is blocked. + } + }; + + const restoreAggregateState = () => { + try { + const raw = window.sessionStorage.getItem(aggregateStateStorageKey); + if (!raw) return false; + const state = JSON.parse(raw); + if (!state || typeof state !== "object") return false; + if (state.start_date) aggregateStartDate.value = state.start_date; + if (state.end_date) aggregateEndDate.value = state.end_date; + if (state.employment) aggregateEmployment.value = state.employment; + if (Array.isArray(state.include_center_member_nos)) { + restoredCenterMemberNos = new Set(state.include_center_member_nos.map(String).filter(Boolean)); + } + if (state.view) { + focusFilter = state.view === "project" ? "project" : focusFilter; + focusButtons.forEach((button) => { + button.classList.toggle("is-active", button.dataset.focus === focusFilter); + }); + } + return true; + } catch (_error) { + // Ignore corrupt saved state and use the default view. + } + return false; + }; + + const getHolidayTypeLabel = (value) => { + if (value === "substitute") return "대체"; + if (value === "company") return "선거/기타"; + return "법정"; + }; + + const renderHolidays = (rows = []) => { + if (!holidayList || !holidayMeta) return; + const sortedRows = [...rows].sort((a, b) => String(a.holiday_date || "").localeCompare(String(b.holiday_date || ""))); + holidayMeta.textContent = sortedRows.length + ? `휴일 기준 ${Number(sortedRows.length).toLocaleString("ko-KR")}건` + : "등록된 휴일 기준이 없습니다."; + holidayList.innerHTML = sortedRows.map((row) => ` + + ${escapeHtml(row.holiday_date || "")} + ${escapeHtml(row.holiday_name || "")} + ${escapeHtml(getHolidayTypeLabel(row.holiday_type || ""))} + + `).join(""); + }; + + const loadHolidays = async () => { + if (!holidayList || !holidayMeta) return; + try { + const payload = await fetchJson("/hanmac-browser/api/holidays"); + renderHolidays(Array.isArray(payload.rows) ? payload.rows : []); + } catch (error) { + holidayMeta.textContent = error.message || "휴일 기준을 불러오지 못했습니다."; + } + }; + + const saveHoliday = async () => { + if (!holidayDateInput || !holidayNameInput || !holidayTypeSelect || !holidaySaveButton) return; + const holidayDate = holidayDateInput.value || ""; + const holidayName = holidayNameInput.value.trim(); + if (!holidayDate || !holidayName) { + window.alert("휴일 날짜와 휴일명을 입력해주세요."); + return; + } + holidaySaveButton.disabled = true; + try { + await fetchJson("/hanmac-browser/api/holidays", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + holiday_date: holidayDate, + holiday_name: holidayName, + holiday_type: holidayTypeSelect.value || "company", + }), + }); + holidayNameInput.value = ""; + await loadHolidays(); + persistAggregateState(); + } catch (error) { + window.alert(error.message || "휴일 기준을 저장하지 못했습니다."); + } finally { + holidaySaveButton.disabled = false; + } + }; + const loadPreview = async (cursor = "", preserveHistory = false) => { if (!selectedTable || !selectedSchema) { setPreviewEmpty(""); @@ -2235,7 +3170,7 @@ } else { setPreviewEmpty(""); } - setStatus("hanmac / hanmac_manhour 테이블 목록을 불러왔습니다.", "success"); + setStatus("hanmac / hanmac_manhour / baron_manhour 테이블 목록을 불러왔습니다.", "success"); } catch (error) { tableListMeta.textContent = error.message || "테이블 목록 조회 실패"; tableList.innerHTML = ""; @@ -2248,30 +3183,106 @@ }; const loadAggregate = async () => { + const requestPayload = getAggregateRequestPayload(); + const requestKey = getAggregateStateKey(requestPayload); + if (aggregateInFlightKey === requestKey) { + aggregateMeta.textContent = currentAggregateAllRows.length + ? (aggregateMeta.textContent || "같은 조건의 조회를 유지하고 있습니다.") + : "같은 조건으로 조회 중입니다..."; + return aggregateLoadPromise; + } + aggregateInFlightKey = requestKey; + const requestSeq = ++aggregateRequestSeq; + const preserveExisting = currentAggregateAllRows.length > 0 && currentAggregateResultKey === requestKey; + persistAggregateState(); aggregateLoadButton.disabled = true; - aggregateMeta.textContent = "집계 조회 중..."; + aggregateMeta.textContent = preserveExisting + ? `${aggregateMeta.textContent || ""} · 갱신 중...`.trim() + : "집계 조회 중..."; + aggregateLoadPromise = (async () => { try { const response = await fetch("/hanmac-browser/api/aggregate", { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - ...getPayload(), - start_date: aggregateStartDate.value, - end_date: aggregateEndDate.value, - employment: aggregateEmployment.value, - view: getAggregateView(), - }), + body: JSON.stringify(requestPayload), }); const payload = await response.json(); + if (requestSeq !== aggregateRequestSeq) return; if (!response.ok || payload.status !== "ok") { throw new Error(payload.message || "집계 조회에 실패했습니다."); } - renderAggregate(payload); + renderAggregate(payload, { + requestKey, + preserveExistingOnEmpty: preserveExisting, + }); + persistAggregateState(); + return payload; } catch (error) { - setAggregateEmpty(error.message || "집계 조회 중 오류가 발생했습니다."); + if (requestSeq !== aggregateRequestSeq) return; + setAggregateEmpty( + preserveExisting + ? `새 조회에 실패했습니다. 이전 결과를 유지합니다. (${error.message || "집계 조회 중 오류가 발생했습니다."})` + : (error.message || "집계 조회 중 오류가 발생했습니다."), + { preserveExisting }, + ); + return null; } finally { - aggregateLoadButton.disabled = false; + if (requestSeq === aggregateRequestSeq) { + aggregateLoadButton.disabled = false; + } + if (aggregateInFlightKey === requestKey) { + aggregateInFlightKey = ""; + } + if (aggregateLoadPromise && aggregateInFlightKey === "") { + aggregateLoadPromise = null; + } } + })(); + return aggregateLoadPromise; + }; + + const requestPreviewCacheRebuild = async () => { + if (!selectedTable || !selectedSchema) { + throw new Error("캐시를 다시 계산할 테이블을 먼저 선택해주세요."); + } + previewRebuildCacheButton.disabled = true; + systemJobStatus.textContent = "미리보기 캐시 작업 등록 중..."; + systemJobStatus.classList.remove("is-success", "is-error"); + const payload = await fetchJson("/hanmac-browser/api/preview/rebuild-cache", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + ...getPayload(), + schema: selectedSchema, + table: selectedTable, + limit: previewLimit.value, + cursor: currentPreviewCursor, + }), + }); + renderSystemJob(payload.job || null); + pollSystemJob(payload.job?.id, async () => { + await loadPreview(currentPreviewCursor, true); + }); + }; + + const requestAggregateCacheRebuild = async () => { + aggregateRebuildCacheButton.disabled = true; + systemJobStatus.textContent = "집계 캐시 작업 등록 중..."; + systemJobStatus.classList.remove("is-success", "is-error"); + const payload = await fetchJson("/hanmac-browser/api/aggregate/rebuild-cache", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + ...getPayload(), + start_date: aggregateStartDate.value, + end_date: aggregateEndDate.value, + employment: aggregateEmployment.value, + view: getAggregateView(), + include_center_member_nos: getRestoredCenterMemberNos(), + }), + }); + renderSystemJob(payload.job || null); + pollSystemJob(payload.job?.id, loadAggregate); }; openConfigButton.addEventListener("click", openModal); @@ -2279,12 +3290,41 @@ modal.addEventListener("click", (event) => { if (event.target === modal) closeModal(); }); + openHolidayButton.addEventListener("click", openHolidayModal); + closeHolidayButton.addEventListener("click", closeHolidayModal); + holidayModal.addEventListener("click", (event) => { + if (event.target === holidayModal) closeHolidayModal(); + }); + openJointButton.addEventListener("click", openJointModal); + closeJointButton.addEventListener("click", closeJointModal); + jointModal.addEventListener("click", (event) => { + if (event.target === jointModal) closeJointModal(); + }); + openCenterButton.addEventListener("click", openCenterModal); + closeCenterButton.addEventListener("click", closeCenterModal); + centerModal.addEventListener("click", (event) => { + if (event.target === centerModal) closeCenterModal(); + }); multiEntryModal.addEventListener("click", (event) => { if (event.target === multiEntryModal) closeMultiEntryModal(); }); closeMultiEntryButton.addEventListener("click", closeMultiEntryModal); tableSearch.addEventListener("input", renderTableList); + centerBody.addEventListener("change", (event) => { + const checkbox = event.target.closest("[data-center-member-no]"); + if (!checkbox) return; + const memberNo = String(checkbox.dataset.centerMemberNo || ""); + if (!memberNo) return; + if (checkbox.checked) { + restoredCenterMemberNos.add(memberNo); + } else { + restoredCenterMemberNos.delete(memberNo); + } + renderCenterMembers(currentCenterMembers); + persistAggregateState(); + }); + centerApplyButton.addEventListener("click", loadAggregate); aggregateBody.addEventListener("click", (event) => { const detailButton = event.target.closest("[data-aggregate-detail-index]"); if (detailButton) { @@ -2351,8 +3391,53 @@ }); loadTablesButton.addEventListener("click", loadTables); + loadGradeCodesButton.addEventListener("click", async () => { + loadGradeCodesButton.disabled = true; + gradeCodesPanel.classList.add("is-open"); + gradeCodesPanel.innerHTML = '
직급 코드를 조회하고 있습니다.
'; + setStatus("직급 코드를 조회하고 있습니다..."); + try { + saveCredentials(); + const payload = await fetchJson("/hanmac-browser/api/grade-codes", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(getPayload()), + }); + renderGradeCodes(payload); + setStatus("직급 코드 조회가 완료되었습니다.", "success"); + } catch (error) { + gradeCodesPanel.classList.add("is-open"); + gradeCodesPanel.innerHTML = `
${escapeHtml(error.message || "직급 코드 조회 중 오류가 발생했습니다.")}
`; + setStatus(error.message || "직급 코드 조회 중 오류가 발생했습니다.", "error"); + } finally { + loadGradeCodesButton.disabled = false; + } + }); aggregateLoadButton.addEventListener("click", loadAggregate); + aggregateRebuildCacheButton.addEventListener("click", async () => { + const confirmed = window.confirm("현재 hanmac 집계 조건의 캐시를 서버에서 다시 계산할까요? 계산 중에도 다른 화면을 사용할 수 있습니다."); + if (!confirmed) return; + try { + await requestAggregateCacheRebuild(); + } catch (error) { + systemJobStatus.textContent = error.message || "집계 캐시 작업 등록 실패"; + systemJobStatus.classList.add("is-error"); + aggregateRebuildCacheButton.disabled = false; + } + }); + holidaySaveButton?.addEventListener("click", saveHoliday); refreshPreviewButton.addEventListener("click", () => loadPreview(currentPreviewCursor, true)); + previewRebuildCacheButton.addEventListener("click", async () => { + const confirmed = window.confirm("현재 원본 테이블 미리보기 캐시를 서버에서 다시 계산할까요? 계산 중에도 다른 화면을 사용할 수 있습니다."); + if (!confirmed) return; + try { + await requestPreviewCacheRebuild(); + } catch (error) { + systemJobStatus.textContent = error.message || "미리보기 캐시 작업 등록 실패"; + systemJobStatus.classList.add("is-error"); + previewRebuildCacheButton.disabled = false; + } + }); previewPrevButton.addEventListener("click", async () => { if (!currentPreviewCursorHistory.length) return; const previousCursor = currentPreviewCursorHistory[currentPreviewCursorHistory.length - 1] || ""; @@ -2404,16 +3489,22 @@ input.addEventListener("blur", () => normalizeDateYear(input)); }); updateSelectedSummary(null); - setAggregateEmpty(""); + setAggregateEmpty("", { forceClear: true }); setPreviewEmpty(""); setStatus(""); const today = new Date(); const startOfYear = new Date(today.getFullYear(), 0, 1); + restoreCredentials(); aggregateStartDate.value = startOfYear.toISOString().slice(0, 10); aggregateEndDate.value = today.toISOString().slice(0, 10); + const restoredAggregateState = restoreAggregateState(); - restoreCredentials(); + loadLatestSystemJob(); + if (restoredAggregateState) { + loadAggregate(); + } + loadHolidays(); })(); {% endblock %} diff --git a/templates/login.html b/templates/login.html new file mode 100644 index 0000000..87361bc --- /dev/null +++ b/templates/login.html @@ -0,0 +1,97 @@ + + + + + + 로그인 + + + + +

한맥 인트라넷 로그인

+ + + + + + + {% if error %} +
{{ error }}
+ {% endif %} +
계정은 관리자에게 요청하세요.
+ + + diff --git a/templates/process_cost.html b/templates/process_cost.html index 7b55f1c..e5c8997 100644 --- a/templates/process_cost.html +++ b/templates/process_cost.html @@ -67,6 +67,42 @@ min-width: 110px; } + .pc-job-status { + display: inline-flex; + align-items: center; + min-height: 32px; + max-width: 360px; + padding: 0 10px; + border: 1px solid #d8e2ec; + border-radius: 999px; + background: #f8fafc; + color: #475569; + font-size: 12px; + font-weight: 800; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + .pc-job-status[data-state="queued"], + .pc-job-status[data-state="running"] { + border-color: #bae6fd; + background: #f0f9ff; + color: #075985; + } + + .pc-job-status[data-state="done"] { + border-color: #bbf7d0; + background: #f0fdf4; + color: #166534; + } + + .pc-job-status[data-state="failed"] { + border-color: #fecaca; + background: #fef2f2; + color: #991b1b; + } + .pc-side-panel { display: grid; gap: 14px; @@ -793,6 +829,10 @@ +
+ + 작업 상태 확인 전 +
@@ -1081,6 +1121,9 @@ const processModalNote = document.getElementById("processFlowModalNote"); const processModalClose = document.getElementById("processFlowModalClose"); const processButtons = document.querySelectorAll("[data-process-modal]"); + const rebuildCacheButton = document.getElementById("processCostRebuildCacheBtn"); + const jobStatus = document.getElementById("processCostJobStatus"); + let jobPollTimer = null; let selectedStartYear = pageState.selectedStartYear; let selectedEndYear = pageState.selectedEndYear; let selectedSource = typeof pageState.source === "string" ? pageState.source : "hanmac"; @@ -1124,6 +1167,112 @@ monthlyRows = Array.isArray(pageState.monthlyRows) ? pageState.monthlyRows : []; } + async function fetchJson(url, options = {}) { + const response = await fetch(url, { + ...options, + cache: "no-store", + credentials: "same-origin", + headers: { + "Accept": "application/json", + "Cache-Control": "no-cache", + "X-Requested-With": "XMLHttpRequest", + ...(options.headers || {}), + }, + }); + const payload = await response.json().catch(() => ({})); + if (!response.ok || payload?.error || payload?.ok === false) { + throw new Error(payload?.error || `HTTP ${response.status}`); + } + return payload; + } + + function describeJob(job) { + if (!job) return { text: "최근 작업 없음", state: "" }; + const status = String(job.status || ""); + const source = String((job.params && job.params.source) || ""); + const range = job.start_year && job.end_year ? `${job.start_year}~${job.end_year}` : ""; + const label = [source ? source.toUpperCase() : "", range].filter(Boolean).join(" "); + const message = job.error_message || job.message || ""; + if (status === "queued") return { text: `${label} 대기 중`.trim(), state: status }; + if (status === "running") return { text: `${label} 계산 중... ${message}`.trim(), state: status }; + if (status === "done") return { text: `${label} 계산 완료`.trim(), state: status }; + if (status === "failed") return { text: `${label} 실패: ${message || "오류"}`.trim(), state: status }; + return { text: `${label} ${message || status || "작업 상태 확인 전"}`.trim(), state: status }; + } + + function renderJob(job) { + if (!jobStatus) return; + const view = describeJob(job); + jobStatus.textContent = view.text; + jobStatus.dataset.state = view.state || ""; + if (rebuildCacheButton) { + rebuildCacheButton.disabled = view.state === "queued" || view.state === "running"; + } + } + + async function loadLatestJob() { + const params = new URLSearchParams({ + page_key: "process_cost", + job_type: "process_cost_bootstrap", + }); + const payload = await fetchJson(`/api/system-jobs/latest?${params.toString()}`); + renderJob(payload.job || null); + return payload.job || null; + } + + function pollJob(jobId) { + if (!jobId) return; + if (jobPollTimer) window.clearInterval(jobPollTimer); + jobPollTimer = window.setInterval(async () => { + try { + const payload = await fetchJson(`/api/system-jobs/${encodeURIComponent(jobId)}`); + const job = payload.job || null; + renderJob(job); + if (!job || ["done", "failed", "cancelled"].includes(String(job.status || ""))) { + window.clearInterval(jobPollTimer); + jobPollTimer = null; + if (job && job.status === "done") { + window.setTimeout(() => window.location.reload(), 600); + } + } + } catch (_error) { + // Keep the current page usable while transient locks clear. + } + }, 2500); + } + + async function requestCacheRebuild() { + if (!rebuildCacheButton) return; + rebuildCacheButton.disabled = true; + if (jobStatus) { + jobStatus.textContent = "작업 등록 중..."; + jobStatus.dataset.state = "queued"; + } + try { + const params = new URLSearchParams(window.location.search); + const payload = await fetchJson("/process-cost/api/rebuild-cache", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + source: params.get("source") || selectedSource || "hanmac", + start_year: params.get("start_year") || selectedStartYear || null, + end_year: params.get("end_year") || selectedEndYear || null, + code: params.get("code") || selectedCode || "", + include_related: params.get("include_related") || (includeRelatedEnabled ? "1" : "0"), + active_related: params.get("active_related") || activeRelatedCodes.join(","), + }), + }); + renderJob(payload.job || null); + pollJob(payload.job?.id); + } catch (error) { + if (jobStatus) { + jobStatus.textContent = error.message || "작업 등록 실패"; + jobStatus.dataset.state = "failed"; + } + rebuildCacheButton.disabled = false; + } + } + function buildProcessCostUrl(code, options = {}) { const params = new URLSearchParams(); params.set("source", selectedSource); @@ -1539,6 +1688,14 @@ syncYearRangeOptions(); } + if (rebuildCacheButton) { + rebuildCacheButton.addEventListener("click", async () => { + const confirmed = window.confirm("현재 프로젝트 원가 조건의 데이터를 서버에서 다시 계산할까요? 계산 중에도 다른 화면을 사용할 수 있습니다."); + if (!confirmed) return; + await requestCacheRebuild(); + }); + } + const svg = document.getElementById("processMonthlyChart"); const renderMonthlyChart = () => { if (!svg || !Array.isArray(monthlyRows) || !monthlyRows.length) { @@ -1590,6 +1747,18 @@ filterProjectList(); renderRelatedChips(); renderMonthlyChart(); + loadLatestJob() + .then((job) => { + if (job && ["queued", "running"].includes(String(job.status || ""))) { + pollJob(job.id); + } + }) + .catch(() => { + if (jobStatus) { + jobStatus.textContent = "작업 상태 조회 실패"; + jobStatus.dataset.state = "failed"; + } + }); })(); })(); diff --git a/templates/projects.html b/templates/projects.html index 4ae0ea4..ed5892f 100644 --- a/templates/projects.html +++ b/templates/projects.html @@ -2640,6 +2640,42 @@ } } + + .project-job-status { + display: inline-flex; + align-items: center; + min-height: 32px; + max-width: 300px; + padding: 0 10px; + border: 1px solid #d8e2ec; + border-radius: 999px; + background: #f8fafc; + color: #475569; + font-size: 12px; + font-weight: 800; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + .project-job-status[data-state="queued"], + .project-job-status[data-state="running"] { + border-color: #bae6fd; + background: #f0f9ff; + color: #075985; + } + + .project-job-status[data-state="done"] { + border-color: #bbf7d0; + background: #f0fdf4; + color: #166534; + } + + .project-job-status[data-state="failed"] { + border-color: #fecaca; + background: #fef2f2; + color: #991b1b; + } {% endblock %} @@ -2654,6 +2690,8 @@
+ + 작업 상태 확인 전 + + 작업 상태 확인 전 {% if section.key == 'voucher_recheck' %} - + + {% endif %} {% if section.key in ['ledger_only', 'voucher_only'] %} @@ -1657,6 +1816,24 @@
+ +