diff --git a/backend/app/db.py b/backend/app/db.py index cb97550..6da9d02 100755 --- a/backend/app/db.py +++ b/backend/app/db.py @@ -281,6 +281,19 @@ CREATE TABLE IF NOT EXISTS integration_vouchers ( created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); +CREATE TABLE IF NOT EXISTS integration_binary_sources ( + id BIGSERIAL PRIMARY KEY, + source_key TEXT NOT NULL UNIQUE, + source_name TEXT NOT NULL, + filename TEXT NOT NULL DEFAULT '', + mime_type TEXT NOT NULL DEFAULT 'application/octet-stream', + content BYTEA NOT NULL, + content_sha256 TEXT NOT NULL DEFAULT '', + meta_json JSONB NOT NULL DEFAULT '{}'::jsonb, + imported_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + CREATE TABLE IF NOT EXISTS history_revisions ( id BIGSERIAL PRIMARY KEY, scope TEXT NOT NULL DEFAULT 'organization', @@ -329,18 +342,6 @@ CREATE TABLE IF NOT EXISTS seat_assignment_versions ( created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); -CREATE TABLE IF NOT EXISTS entity_change_events ( - id BIGSERIAL PRIMARY KEY, - entity_type TEXT NOT NULL, - entity_id BIGINT NOT NULL, - action_type TEXT NOT NULL, - revision_no BIGINT NOT NULL, - changed_by_user_id BIGINT, - changed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - change_reason TEXT NOT NULL DEFAULT '', - patch_json JSONB NOT NULL DEFAULT '{}'::jsonb -); - CREATE SCHEMA IF NOT EXISTS auth; CREATE TABLE IF NOT EXISTS auth.users ( @@ -534,6 +535,9 @@ ON integration_vouchers (project_code, project_name); CREATE UNIQUE INDEX IF NOT EXISTS integration_project_category_mappings_key_idx ON integration_project_category_mappings (source_key, normalized_project_key); +CREATE UNIQUE INDEX IF NOT EXISTS integration_binary_sources_source_key_idx +ON integration_binary_sources (source_key); + CREATE INDEX IF NOT EXISTS member_versions_member_time_idx ON member_versions (member_id, valid_from, valid_to); @@ -543,9 +547,6 @@ ON seat_assignment_versions (member_id, valid_from, valid_to); CREATE INDEX IF NOT EXISTS history_revisions_scope_created_idx ON history_revisions (scope, created_at DESC); -CREATE INDEX IF NOT EXISTS entity_change_events_entity_idx -ON entity_change_events (entity_type, entity_id, changed_at DESC); - DO $$ BEGIN IF NOT EXISTS ( @@ -611,6 +612,9 @@ ALTER TABLE auth.login_audit_logs ADD COLUMN IF NOT EXISTS user_id BIGINT NULL R ALTER TABLE auth.login_audit_logs ADD COLUMN IF NOT EXISTS failure_reason TEXT; ALTER TABLE auth.login_audit_logs ADD COLUMN IF NOT EXISTS ip_address INET; ALTER TABLE auth.login_audit_logs ADD COLUMN IF NOT EXISTS user_agent TEXT; + +DROP INDEX IF EXISTS entity_change_events_entity_idx; +DROP TABLE IF EXISTS entity_change_events; """ diff --git a/backend/app/main.py b/backend/app/main.py index ca329ee..b29c472 100755 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -44,6 +44,7 @@ LEGACY_STATIC_DIR = LEGACY_DIR / "static" INCOMING_FILES_DIR = BASE_DIR / "incoming-files" INCOMING_SERVED_DIR = INCOMING_FILES_DIR / "served" INCOMING_REFERENCE_DIR = INCOMING_FILES_DIR / "reference" +DB_STATUS_SERVED_DIR = INCOMING_SERVED_DIR / "db-status" BUSINESS_DASHBOARD_DIR = INCOMING_FILES_DIR / "사업관리대장" BUSINESS_LEDGER_SERVED_DIR = INCOMING_SERVED_DIR / "ledger" BUSINESS_LEDGER_INDEX_PATH = BUSINESS_LEDGER_SERVED_DIR / "index.html" @@ -88,7 +89,157 @@ MH_HEADER_ORDER = [ "사업 종류", "연장근무 프로젝트 코드", "연장근무 프로젝트명", "연장근무 서브코드", "연장근무 시간(실제)", "연장근무 시간(가공)" ] +DB_STATUS_TABLES = [ + { + "table_ref": "public.members", + "label": "구성원 마스터", + "domain": "organization", + "timestamp_column": "updated_at", + "related_views": ["조직 현황", "자리배치도"], + "description": "조직/구성원 화면의 기준이 되는 현재 인원 마스터", + }, + { + "table_ref": "public.member_versions", + "label": "구성원 이력", + "domain": "history", + "timestamp_column": "created_at", + "related_views": ["조직 현황", "이력 비교"], + "description": "as-of 조회와 변경 이력을 위한 시점 버전", + }, + { + "table_ref": "public.seat_maps", + "label": "자리배치도 도면", + "domain": "seatmap", + "timestamp_column": "updated_at", + "related_views": ["자리배치도"], + "description": "오피스별 도면 메타데이터와 활성 상태", + }, + { + "table_ref": "public.seat_positions", + "label": "현재 좌석 배치", + "domain": "seatmap", + "timestamp_column": "updated_at", + "related_views": ["자리배치도"], + "description": "현재 인원의 실제 배치 좌표/슬롯 연결", + }, + { + "table_ref": "public.seat_assignment_versions", + "label": "좌석 배치 이력", + "domain": "history", + "timestamp_column": "created_at", + "related_views": ["자리배치도", "이력 비교"], + "description": "자리 이동 이력과 시점 조회용 배치 버전", + }, + { + "table_ref": "public.integration_import_batches", + "label": "원본 업로드 배치", + "domain": "integration", + "timestamp_column": "imported_at", + "related_views": ["프로젝트별 분석", "팀/개인별 분석", "조직 현황"], + "description": "원본 파일 적재 단위와 최근 import 기록", + }, + { + "table_ref": "public.integration_projects", + "label": "통합 프로젝트 표준화", + "domain": "integration", + "timestamp_column": "updated_at", + "related_views": ["프로젝트별 분석", "팀/개인별 분석"], + "description": "프로젝트 코드/이름/카테고리 정규화 결과", + }, + { + "table_ref": "public.integration_work_logs", + "label": "근무 로그 표준화", + "domain": "integration", + "timestamp_column": "updated_at", + "related_views": ["팀/개인별 분석"], + "description": "MH workbook 기준 일자별 근무 로그 본체", + }, + { + "table_ref": "public.integration_work_log_segments", + "label": "근무 로그 세그먼트", + "domain": "integration", + "timestamp_column": "created_at", + "related_views": ["팀/개인별 분석"], + "description": "근무 로그를 프로젝트/활동 기준으로 분해한 상세 세그먼트", + }, + { + "table_ref": "public.integration_vouchers", + "label": "전표 표준화", + "domain": "integration", + "timestamp_column": "created_at", + "related_views": ["프로젝트별 분석"], + "description": "payment CSV 기준 프로젝트별 수입/지출 전표", + }, + { + "table_ref": "public.integration_binary_sources", + "label": "바이너리 원본 보관", + "domain": "integration", + "timestamp_column": "imported_at", + "related_views": ["사업관리대장"], + "description": "엑셀/바이너리 원본을 DB에 보관하는 저장소", + }, + { + "table_ref": "auth.users", + "label": "인증 사용자", + "domain": "auth", + "timestamp_column": "updated_at", + "related_views": ["로그인", "권한"], + "description": "로그인 계정, role, 활성 상태", + }, + { + "table_ref": "auth.sessions", + "label": "인증 세션", + "domain": "auth", + "timestamp_column": "created_at", + "related_views": ["로그인", "권한"], + "description": "현재/과거 로그인 세션과 만료 상태", + }, + { + "table_ref": "auth.login_audit_logs", + "label": "로그인 감사 로그", + "domain": "auth", + "timestamp_column": "created_at", + "related_views": ["로그인", "권한"], + "description": "로그인 성공/실패 기록", + }, +] + +DB_STATUS_TABLE_META = {str(item["table_ref"]): item for item in DB_STATUS_TABLES} +DB_STATUS_TABLE_GROUPS = { + "public.members": "유지", + "public.member_versions": "유지", + "public.seat_maps": "유지", + "public.seat_positions": "유지", + "public.seat_slots": "유지", + "public.seat_assignment_versions": "유지", + "public.history_revisions": "유지", + "public.integration_import_batches": "유지", + "public.integration_projects": "유지", + "public.integration_work_logs": "유지", + "public.integration_work_log_segments": "유지", + "public.integration_vouchers": "유지", + "public.integration_binary_sources": "유지", + "auth.users": "유지", + "auth.sessions": "유지", + "auth.login_audit_logs": "유지", + "public.member_overrides": "주의", + "public.member_retirements": "주의", + "public.member_aliases": "주의", + "public.integration_project_aliases": "주의", + "public.integration_project_category_mappings": "주의", + "public.integration_project_pm_assignments": "주의", + "public.integration_raw_organization_rows": "원본·추적", + "public.integration_raw_mh_rows": "원본·추적", + "public.integration_raw_mh_pm_rows": "원본·추적", + "public.integration_raw_payment_rows": "원본·추적", +} + def sync_default_business_ledger_source(cur) -> None: + cur.execute("SELECT to_regclass('public.integration_binary_sources') IS NOT NULL AS table_exists") + row = cur.fetchone() + table_exists = bool(row["table_exists"]) if row is not None else False + if not table_exists: + return candidates = [ BUSINESS_LEDGER_SERVED_DIR / "사업관리대장-1.xlsx", BUSINESS_DASHBOARD_DIR / "사업관리대장-1.xlsx", @@ -137,6 +288,250 @@ def sync_default_business_ledger_source(cur) -> None: ) +def make_json_safe(value: object) -> object: + if isinstance(value, datetime): + return value.isoformat() + if isinstance(value, date): + return value.isoformat() + if isinstance(value, Decimal): + return float(value) + if isinstance(value, bytes): + return f"<{len(value)} bytes>" + if isinstance(value, dict): + return {str(key): make_json_safe(val) for key, val in value.items()} + if isinstance(value, list): + return [make_json_safe(item) for item in value] + return value + + +def fetch_db_status_snapshot() -> dict[str, object]: + table_items: list[dict[str, object]] = [] + with get_conn() as conn: + with conn.cursor() as cur: + cur.execute( + """ + SELECT schemaname, tablename + FROM pg_tables + WHERE schemaname IN ('public', 'auth') + ORDER BY schemaname, tablename + """ + ) + all_tables = cur.fetchall() + for row in all_tables: + schema_name = str(row["schemaname"]) + table_name = str(row["tablename"]) + table_ref = f"{schema_name}.{table_name}" + spec = DB_STATUS_TABLE_META.get(table_ref, {}) + cur.execute("SELECT to_regclass(%s) IS NOT NULL AS table_exists", (table_ref,)) + exists_row = cur.fetchone() + exists = bool(exists_row["table_exists"]) if exists_row is not None else False + row_count = 0 + last_event_at = None + if exists: + timestamp_column = str(spec.get("timestamp_column") or "") + query = f"SELECT COUNT(*)::bigint AS row_count" + if timestamp_column: + query += f", MAX({timestamp_column}) AS last_event_at" + else: + query += ", NULL::timestamptz AS last_event_at" + query += f" FROM {schema_name}.{table_name}" + cur.execute(query) + metric_row = cur.fetchone() or {} + row_count = int(metric_row.get("row_count") or 0) + last_event_at = metric_row.get("last_event_at") + table_items.append( + { + "table_ref": table_ref, + "schema": schema_name, + "table_name": table_name, + "label": str(spec.get("label") or table_name), + "domain": str(spec.get("domain") or "other"), + "description": str(spec.get("description") or "세부 보조/원본/운영 테이블"), + "related_views": spec.get("related_views") or [], + "group": DB_STATUS_TABLE_GROUPS.get(table_ref, "주의"), + "exists": exists, + "row_count": row_count, + "last_event_at": last_event_at.isoformat() if last_event_at else None, + } + ) + + cur.execute( + """ + SELECT source_key, source_name, row_count, source_path, imported_at + FROM integration_import_batches + ORDER BY imported_at DESC, id DESC + """ + ) + import_batches = [ + { + "source_key": str(row["source_key"] or ""), + "source_name": str(row["source_name"] or ""), + "row_count": int(row["row_count"] or 0), + "source_path": str(row["source_path"] or ""), + "imported_at": row["imported_at"].isoformat() if row.get("imported_at") else None, + } + for row in cur.fetchall() + ] + + binary_sources: list[dict[str, object]] = [] + cur.execute("SELECT to_regclass('public.integration_binary_sources') IS NOT NULL AS table_exists") + binary_exists_row = cur.fetchone() + binary_exists = bool(binary_exists_row["table_exists"]) if binary_exists_row is not None else False + if binary_exists: + cur.execute( + """ + SELECT source_key, source_name, filename, mime_type, OCTET_LENGTH(content) AS byte_size, + content_sha256, imported_at + FROM integration_binary_sources + ORDER BY imported_at DESC, id DESC + """ + ) + binary_sources = [ + { + "source_key": str(row["source_key"] or ""), + "source_name": str(row["source_name"] or ""), + "filename": str(row["filename"] or ""), + "mime_type": str(row["mime_type"] or ""), + "byte_size": int(row["byte_size"] or 0), + "content_sha256": str(row["content_sha256"] or ""), + "imported_at": row["imported_at"].isoformat() if row.get("imported_at") else None, + } + for row in cur.fetchall() + ] + + cur.execute( + """ + SELECT COUNT(*)::bigint AS total_members, + COUNT(*) FILTER ( + WHERE COALESCE(BTRIM(work_status), '') NOT IN ('퇴직', '휴직') + )::bigint AS active_members + FROM members + """ + ) + member_row = cur.fetchone() or {} + + cur.execute( + """ + SELECT COUNT(*)::bigint AS active_seat_maps + FROM seat_maps + WHERE is_active = TRUE + """ + ) + seat_map_row = cur.fetchone() or {} + + cur.execute( + """ + SELECT COUNT(*)::bigint AS fixed_office_maps + FROM seat_maps + WHERE source_type = 'fixed_html' + """ + ) + fixed_office_row = cur.fetchone() or {} + + overview = { + "visible_tables": len(DB_STATUS_TABLES), + "total_tables": len(table_items), + "existing_tables": sum(1 for item in table_items if item["exists"]), + "registered_members": int(member_row.get("total_members") or 0), + "active_members": int(member_row.get("active_members") or 0), + "active_seat_maps": int(seat_map_row.get("active_seat_maps") or 0), + "fixed_office_maps": int(fixed_office_row.get("fixed_office_maps") or 0), + "import_batches": len(import_batches), + "binary_sources": len(binary_sources), + } + group_summary = { + "유지": [item["table_ref"] for item in table_items if item["group"] == "유지"], + "주의": [item["table_ref"] for item in table_items if item["group"] == "주의"], + "원본·추적": [item["table_ref"] for item in table_items if item["group"] == "원본·추적"], + "정리 후보": [item["table_ref"] for item in table_items if item["group"] == "정리 후보"], + } + return { + "generated_at": datetime.utcnow().isoformat() + "Z", + "overview": overview, + "tables": table_items, + "import_batches": import_batches, + "binary_sources": binary_sources, + "group_summary": group_summary, + "notes": [ + "members / seat_positions / seat_maps 는 현재 운영 상태를 나타냅니다.", + "member_versions / seat_assignment_versions / history_revisions 는 시점 조회와 변경 이력을 위한 테이블입니다.", + "integration_raw_* / integration_* 는 원본 적재와 표준화 결과를 분리해서 보관합니다.", + "integration_binary_sources 는 사업관리대장 같은 바이너리 원본 보관용입니다.", + "재직 인원은 work_status 값이 '퇴직' 또는 '휴직'이 아닌 구성원 기준입니다.", + ], + } + + +def fetch_db_table_preview(schema_name: str, table_name: str, limit: int = 50) -> dict[str, object]: + if schema_name not in {"public", "auth"}: + raise HTTPException(status_code=404, detail="Unknown schema.") + + with get_conn() as conn: + with conn.cursor() as cur: + cur.execute( + """ + SELECT tablename + FROM pg_tables + WHERE schemaname = %s + AND tablename = %s + """, + (schema_name, table_name), + ) + exists_row = cur.fetchone() + if exists_row is None: + raise HTTPException(status_code=404, detail="Unknown table.") + + table_ref = f"{schema_name}.{table_name}" + spec = DB_STATUS_TABLE_META.get(table_ref, {}) + + cur.execute( + """ + SELECT column_name, data_type + FROM information_schema.columns + WHERE table_schema = %s + AND table_name = %s + ORDER BY ordinal_position + """, + (schema_name, table_name), + ) + columns = [{"name": str(row["column_name"]), "type": str(row["data_type"])} for row in cur.fetchall()] + + cur.execute(f"SELECT COUNT(*)::bigint AS row_count FROM {schema_name}.{table_name}") + row_count = int((cur.fetchone() or {}).get("row_count") or 0) + + safe_limit = max(1, min(int(limit), 50)) + cur.execute(f"SELECT * FROM {schema_name}.{table_name} LIMIT {safe_limit}") + rows = [make_json_safe(dict(row)) for row in cur.fetchall()] + + return { + "table_ref": table_ref, + "schema": schema_name, + "table_name": table_name, + "label": str(spec.get("label") or table_name), + "domain": str(spec.get("domain") or "other"), + "description": str(spec.get("description") or "세부 보조/원본/운영 테이블"), + "related_views": spec.get("related_views") or [], + "row_count": row_count, + "limit": safe_limit, + "columns": columns, + "rows": rows, + } + return { + "generated_at": datetime.utcnow().isoformat() + "Z", + "overview": overview, + "tables": table_items, + "import_batches": import_batches, + "binary_sources": binary_sources, + "notes": [ + "members / seat_positions / seat_maps 는 현재 운영 상태를 나타냅니다.", + "member_versions / seat_assignment_versions / history_revisions 는 시점 조회와 변경 이력을 위한 테이블입니다.", + "integration_raw_* / integration_* 는 원본 적재와 표준화 결과를 분리해서 보관합니다.", + "integration_binary_sources 는 사업관리대장 같은 바이너리 원본 보관용입니다.", + "재직 인원은 work_status 값이 '퇴직' 또는 '휴직'이 아닌 구성원 기준입니다.", + ], + } + + app.mount( "/integrations/ledger-assets", StaticFiles(directory=str(BUSINESS_LEDGER_SERVED_DIR), check_dir=False), @@ -4001,6 +4396,27 @@ def health() -> dict[str, object]: } +@app.get("/api/admin/db-status") +def admin_db_status() -> dict[str, object]: + return fetch_db_status_snapshot() + + +@app.get("/api/admin/db-status/table") +def admin_db_status_table(schema: str, table: str, limit: int = 50) -> dict[str, object]: + return fetch_db_table_preview(schema, table, limit) + + +@app.get("/admin/db-status") +def admin_db_status_view() -> FileResponse: + target = DB_STATUS_SERVED_DIR / "index.html" + if not target.exists(): + raise HTTPException(status_code=404, detail="DB status dashboard file not found.") + response = FileResponse(target) + response.headers["Cache-Control"] = "no-store, no-cache, must-revalidate, max-age=0" + response.headers["Pragma"] = "no-cache" + return response + + @app.get("/api/integration/business-ledger-default") def integration_business_ledger_default() -> Response: with get_conn() as conn: diff --git a/docs/NEXT_SESSION_CHECKPOINT.md b/docs/NEXT_SESSION_CHECKPOINT.md index 03bae81..7677f56 100644 --- a/docs/NEXT_SESSION_CHECKPOINT.md +++ b/docs/NEXT_SESSION_CHECKPOINT.md @@ -53,12 +53,15 @@ - [frontend/public/design-patterns.css](/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081/frontend/public/design-patterns.css) - 디자인 기준 문서: - [architecture/DESIGN_SSOT.md](/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081/docs/architecture/DESIGN_SSOT.md) +- DB 테이블 분류 기준 문서: + - [architecture/DB_TABLE_CATALOG.md](/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081/docs/architecture/DB_TABLE_CATALOG.md) - 로그인 기본 스타일은 [frontend/public/styles.css](/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081/frontend/public/styles.css) 기준으로 유지 - `8081` 허브 전용 디자인은 [frontend/public/styles-8081-design.css](/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081/frontend/public/styles-8081-design.css)에서만 덮어씀 - 조직현황은 [legacy/static/common.css](/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081/legacy/static/common.css), [legacy/static/organization.css](/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081/legacy/static/organization.css), [legacy/static/organization.js](/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081/legacy/static/organization.js)를 사용 - 프로젝트별 분석 디자인은 [incoming-files/served/payment.html](/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081/incoming-files/served/payment.html) 내부에서 `design-tokens.css` + `design-patterns.css`를 참조 - 프로젝트별 분석 수정 원본은 [frontend/apps/payment/index.html](/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081/frontend/apps/payment/index.html) 이고, 반영은 [scripts/publish_payment_app.sh](/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081/scripts/publish_payment_app.sh)로 한다. - 팀/개인별 분석 수정 원본은 [frontend/apps/team/index.html](/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081/frontend/apps/team/index.html) 이고, 반영은 [scripts/publish_team_app.sh](/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081/scripts/publish_team_app.sh)로 한다. +- DB 상태 화면 수정 원본은 [frontend/apps/db-status/index.html](/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081/frontend/apps/db-status/index.html) 이고, 반영은 [scripts/publish_db_status_app.sh](/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081/scripts/publish_db_status_app.sh)로 한다. - 사업관리대장 실제 서비스 코드는 [incoming-files/served/ledger](/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081/incoming-files/served/ledger) 기준으로 본다. - 사업관리대장 앱 소스 기준은 [frontend/apps/ledger](/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081/frontend/apps/ledger) 이고, 반영은 [scripts/publish_ledger_app.sh](/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081/scripts/publish_ledger_app.sh)로 한다. - 사업관리대장 상세 팝업 디자인 수정 원본은 [frontend/apps/ledger/assets/ledger-override.js](/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081/frontend/apps/ledger/assets/ledger-override.js) 기준으로 본다. @@ -106,6 +109,8 @@ - [incoming-files/served/ledger/index.html](/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081/incoming-files/served/ledger/index.html) - `/integrations/mh`: - [incoming-files/served/mh.html](/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081/incoming-files/served/mh.html) +- `/admin/db-status`: + - [incoming-files/served/db-status/index.html](/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081/incoming-files/served/db-status/index.html) ## Cross Checks Last Confirmed @@ -126,20 +131,18 @@ ## Open Issues Relevant Now +- `#2` 백엔드 영속 저장 구조 운영 마무리 - `#14` 누적된 임시 로직 정리 및 중복 코드 제거 -- `#16` 사업관리대장 메인 연동 및 기본 원본 DB화 -- `#17` 8081 분리 worktree 기동 절차와 로컬 디자인 자산 복제 고정 -- `#18` 8081 파일 책임 맵 정리 및 프런트 서빙 경로 정돈 -- `#19` 8081 백엔드 라우터/서빙 책임 분리 -- `#20` 8081 worktree 준비 스크립트·문서·운영 규칙 정리 -- `#21` reference 의존 제거 및 8081 실제 서비스 코드 독립화 +- `#16` 사업관리대장 메인 후속 정리 및 기준 분석 +- `#19` 8081 백엔드 라우터/서빙 deeper 모듈 분리 +- `#21` organization 레거시 구조 승격 및 장기 고도화 ## Recommended Next Work Order -1. `#21` 이후 기준으로 실제 서비스 파일과 reference 파일 경계를 유지 -2. 사업관리대장 세부 데이터 정합성 보정 -3. 그 다음 화면별 앱 구조 승격 검토 -4. 필요 시 `#19`, `#20` 잔여 정리 항목 재평가 +1. `#2` 기준으로 DB 상태 화면과 저장 구조 검증 흐름 고도화 +2. `#21` 이후 기준으로 실제 서비스 파일과 reference 파일 경계를 유지 +3. 사업관리대장 세부 데이터 정합성 보정은 원본 규칙 분석 후 진행 +4. 필요 시 `#19` 잔여 정리 항목 재평가 ## Quick Resume Prompt @@ -150,4 +153,6 @@ - 먼저 [WORK_RULEBOOK.md](/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081/docs/WORK_RULEBOOK.md), [NEXT_SESSION_CHECKPOINT.md](/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081/docs/NEXT_SESSION_CHECKPOINT.md), [architecture/8081_SERVING_MAP.md](/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081/docs/architecture/8081_SERVING_MAP.md) 확인 - 디자인 수정이면 [frontend/public/design-tokens.css](/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081/frontend/public/design-tokens.css), [frontend/public/design-patterns.css](/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081/frontend/public/design-patterns.css), [architecture/DESIGN_SSOT.md](/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081/docs/architecture/DESIGN_SSOT.md) 먼저 확인 - 현재 구조 독립화 기준 이슈는 `#21` -- 작업 전 `git status`, dev 컨테이너 상태, `/api/health`, `/legacy/organization`, `/integrations/payment`, `/integrations/ledger`, `/integrations/mh`를 먼저 확인 +- `#2` 기준 DB 상태 확인은 `/api/admin/db-status` 또는 허브의 `DB 상태` 탭을 먼저 본다. +- DB 테이블 유지/주의/원본·추적/정리 후보 분류는 [architecture/DB_TABLE_CATALOG.md](/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081/docs/architecture/DB_TABLE_CATALOG.md) 기준으로 본다. +- 작업 전 `git status`, dev 컨테이너 상태, `/api/health`, `/legacy/organization`, `/integrations/payment`, `/integrations/ledger`, `/integrations/mh`, `/api/admin/db-status`를 먼저 확인 diff --git a/docs/architecture/8081_SERVING_MAP.md b/docs/architecture/8081_SERVING_MAP.md index f00447f..4a068ce 100644 --- a/docs/architecture/8081_SERVING_MAP.md +++ b/docs/architecture/8081_SERVING_MAP.md @@ -47,6 +47,10 @@ - 현재 실제 서빙 파일: `incoming-files/served/mh.html` - 앱 소스 기준: `frontend/apps/team/index.html` - publish 규칙: `scripts/publish_team_app.sh` +- URL: `/admin/db-status` + - 현재 실제 서빙 파일: `incoming-files/served/db-status/index.html` + - 앱 소스 기준: `frontend/apps/db-status/index.html` + - publish 규칙: `scripts/publish_db_status_app.sh` 정리 원칙: @@ -109,4 +113,5 @@ - 로그인은 `styles.css`만 본다. - 허브 8081 디자인은 `styles-8081-design.css`만 본다. - `/integrations/payment`, `/integrations/mh`의 실제 서빙 파일 위치가 문서와 코드에서 일치한다. +- `/admin/db-status`가 현재 DB 저장 구조와 import 상태를 화면에서 바로 보여준다. - 기존 참고 자산을 지우지 않고도 실제 서빙 경로와 참고 경로를 구분할 수 있다. diff --git a/docs/architecture/DB_TABLE_CATALOG.md b/docs/architecture/DB_TABLE_CATALOG.md new file mode 100644 index 0000000..7e2eb96 --- /dev/null +++ b/docs/architecture/DB_TABLE_CATALOG.md @@ -0,0 +1,160 @@ +# DB Table Catalog + +## Purpose + +이 문서는 `8081 / work-8081` 기준 현재 PostgreSQL 테이블 27개를 역할별로 분류한 운영 기준 문서다. + +핵심 원칙: + +- 테이블 수가 많다고 바로 줄이지 않는다. +- 먼저 `유지 / 주의 / 원본·추적 / 정리 후보`로 나눈다. +- 실제 운영 화면과 저장 흐름에 필요한 것은 유지한다. +- 의미가 불분명하거나 중복 역할만 하는 것은 후보로 남겨두고, 실제 삭제는 별도 검증 후 진행한다. + +## Summary + +- 전체 테이블: `26` +- 유지: `16` +- 주의: `6` +- 원본·추적: `4` +- 정리 후보: `0` + +## 1. 유지 + +현재 운영 화면, 인증, 이력, 적재 흐름에서 계속 필요하다. + +- `auth.users` +- `auth.sessions` +- `auth.login_audit_logs` +- `public.members` +- `public.member_versions` +- `public.history_revisions` +- `public.seat_maps` +- `public.seat_slots` +- `public.seat_positions` +- `public.seat_assignment_versions` +- `public.integration_import_batches` +- `public.integration_projects` +- `public.integration_work_logs` +- `public.integration_work_log_segments` +- `public.integration_vouchers` +- `public.integration_binary_sources` + +설명: + +- `members`, `seat_*`는 조직현황/자리배치도 핵심 +- `member_versions`, `seat_assignment_versions`, `history_revisions`는 as-of 조회와 이력 비교 핵심 +- `integration_*` 표준화 결과는 프로젝트별 분석 / 팀·개인별 분석 핵심 +- `integration_binary_sources`는 사업관리대장 같은 바이너리 원본 보관용 +- `auth.*`는 로그인과 권한 운영 핵심 + +## 2. 주의 + +현재도 역할은 있지만, 실제 운영에서 얼마나 계속 필요한지 주기적으로 점검해야 한다. + +- `public.member_overrides` +- `public.member_retirements` +- `public.member_aliases` +- `public.integration_project_aliases` +- `public.integration_project_category_mappings` +- `public.integration_project_pm_assignments` + +설명: + +- 이 테이블들은 핵심 마스터라기보다 “보정/매핑/예외 처리” 성격이 강하다. +- 운영상 필요할 수 있지만, 남용되면 기준 데이터가 흐려진다. +- 사용 규칙과 관리 책임을 분명히 해야 한다. + +## 3. 원본·추적 + +원본 적재와 검증을 위해 필요하다. 직접 서비스 화면의 주 출력원이 아니라, 적재 근거와 추적용이다. + +- `public.integration_raw_organization_rows` +- `public.integration_raw_mh_rows` +- `public.integration_raw_mh_pm_rows` +- `public.integration_raw_payment_rows` + +설명: + +- 원본 파일을 바로 표준화 테이블에만 넣으면, 나중에 적재 오류를 추적하기 어렵다. +- raw row 보관은 import 검증과 재현성 측면에서 의미가 있다. +- 단, 장기 보관 정책과 용량 관리는 별도 필요하다. + +## 4. 정리 후보 + +현재 기준 정리 후보 테이블은 없다. + +## Domain Map + +### 인증 + +- `auth.users` +- `auth.sessions` +- `auth.login_audit_logs` + +### 조직 / 구성원 + +- `public.members` +- `public.member_overrides` +- `public.member_retirements` +- `public.member_aliases` + +### 자리배치도 + +- `public.seat_maps` +- `public.seat_slots` +- `public.seat_positions` + +### 이력 + +- `public.history_revisions` +- `public.member_versions` +- `public.seat_assignment_versions` + +### integration 표준화 + +- `public.integration_import_batches` +- `public.integration_projects` +- `public.integration_project_aliases` +- `public.integration_project_category_mappings` +- `public.integration_project_pm_assignments` +- `public.integration_work_logs` +- `public.integration_work_log_segments` +- `public.integration_vouchers` +- `public.integration_binary_sources` + +### integration raw + +- `public.integration_raw_organization_rows` +- `public.integration_raw_mh_rows` +- `public.integration_raw_mh_pm_rows` +- `public.integration_raw_payment_rows` + +## Operational Guidance + +### 바로 줄이지 말아야 하는 것 + +- `integration_raw_*` +- `member_versions` +- `seat_assignment_versions` +- `auth.*` + +이건 지금 구조상 “많아 보여도 필요한 층”이다. + +### 먼저 점검할 것 + +- `member_overrides`, `member_aliases`, `project_aliases`의 실제 운영 빈도 +- `seat_maps`의 과거 실험 도면 정리 기준 + +### 정리 원칙 + +1. 테이블을 없애기 전에 실제 읽는 API/화면/스크립트를 확인한다. +2. 원본 추적용 테이블은 운영 출력용 테이블과 구분해서 판단한다. +3. 테이블 삭제보다 먼저 “사용 안 함” 상태를 문서화한다. +4. 삭제는 백업과 검증 절차가 준비된 뒤에만 한다. + +## Recommended Next Checks + +1. `seat_maps` 과거 DXF 시도본 정리 기준 수립 +2. `주의` 그룹 테이블의 입력/수정 주체 명확화 +3. `DB 상태` 화면에서 이 분류를 기준으로 계속 설명 유지 diff --git a/frontend/apps/db-status/README.md b/frontend/apps/db-status/README.md new file mode 100644 index 0000000..a434435 --- /dev/null +++ b/frontend/apps/db-status/README.md @@ -0,0 +1,7 @@ +## DB Status App + +- 수정 원본: `frontend/apps/db-status/index.html` +- 실제 서빙: `incoming-files/served/db-status/index.html` +- publish: `./scripts/publish_db_status_app.sh` + +`#2` 이슈용 관리자 화면으로, 현재 DB 저장 구조와 적재 상태를 사람이 읽을 수 있게 보여준다. diff --git a/frontend/apps/db-status/index.html b/frontend/apps/db-status/index.html new file mode 100644 index 0000000..e685ed3 --- /dev/null +++ b/frontend/apps/db-status/index.html @@ -0,0 +1,720 @@ + + + + + + DB 상태 + + + + + + + + +
+
+ #2 백엔드 영속 저장 구조 운영 +

DB 상태와 저장 구조를 화면에서 바로 확인

+

+ 이 화면은 현재 운영 DB의 핵심 테이블, 적재 상태, 최근 import 흐름을 SQL 없이 확인하기 위한 관리자용 뷰어입니다. + 이후 저장 구조 검증과 데이터 정합성 작업은 이 화면을 기준으로 진행합니다. +

+

+ `원본 import 배치`는 업로드한 원본 파일이 몇 행으로 적재됐는지 보여주고, `바이너리 원본 보관`은 엑셀 같은 파일 자체를 DB에 보관하는 상태를 보여줍니다. +

+
+ +
+ +
+
+
+
+

전체 테이블 현황

+

전체 27개 테이블을 보여주며, 테이블명을 누르면 샘플 row를 바로 확인할 수 있습니다.

+
+ 로딩 중 +
+
+ + + + + + + + + + + + + +
도메인테이블Rows최근 갱신연결 화면
DB 상태를 불러오는 중입니다.
+
+
+ +
+
+
+
+

원본 import 배치

+

현재 적재된 원본 파일 배치와 row 수입니다.

+
+
+
+ + + + + + + + + + + +
SourceRowsImported
로딩 중
+
+
+ +
+
+
+

바이너리 원본 보관

+

엑셀 같은 바이너리 원본의 DB 보관 상태입니다.

+
+
+
+ + + + + + + + + + + +
Source파일크기
로딩 중
+
+
+ +
+
+
+

운영 메모

+

#2에서 확인해야 할 저장 구조 핵심 포인트입니다.

+
+
+
+
    +
    +
    + +
    +
    +
    +

    테이블 분류

    +

    유지/주의/원본·추적/정리 후보로 나눈 현재 기준입니다.

    +
    +
    +
    +
    + +
    +
    +
    + + + + + + diff --git a/frontend/public/app.js b/frontend/public/app.js index 88e736a..f7814ee 100644 --- a/frontend/public/app.js +++ b/frontend/public/app.js @@ -23,6 +23,8 @@ const projectFrame = document.getElementById("project-frame"); const projectStage = document.getElementById("project-stage"); const teamFrame = document.getElementById("team-frame"); const teamStage = document.getElementById("team-stage"); +const dbStatusFrame = document.getElementById("db-status-frame"); +const dbStatusStage = document.getElementById("db-status-stage"); const seatMapAdminStage = document.getElementById("seatmap-admin-stage"); const seatMapReadonlyStage = document.getElementById("seatmap-readonly-stage"); const emptyStage = document.getElementById("empty-stage"); @@ -115,6 +117,7 @@ const viewLabels = { project: "프로젝트별 분석", team: "팀/개인별 분석", organization: "조직 현황", + "db-status": "DB 상태", "seatmap-admin": "자리배치도", "seatmap-readonly": "자리배치도", }; @@ -1623,6 +1626,7 @@ function setActiveView(view) { const isLedger = currentView === "ledger"; const isProject = currentView === "project"; const isTeam = currentView === "team"; + const isDbStatus = currentView === "db-status"; const isSeatMapAdmin = currentView === "seatmap-admin"; const isSeatMapReadonly = currentView === "seatmap-readonly"; if (ledgerStage) { @@ -1641,6 +1645,10 @@ function setActiveView(view) { teamStage.hidden = !isTeam; teamStage.style.display = isTeam ? "flex" : "none"; } + if (dbStatusStage) { + dbStatusStage.hidden = !isDbStatus; + dbStatusStage.style.display = isDbStatus ? "flex" : "none"; + } if (seatMapAdminStage) { seatMapAdminStage.hidden = !isSeatMapAdmin; seatMapAdminStage.style.display = isSeatMapAdmin ? "flex" : "none"; @@ -1650,7 +1658,7 @@ function setActiveView(view) { seatMapReadonlyStage.style.display = isSeatMapReadonly ? "flex" : "none"; } if (emptyStage) { - const showEmpty = !isLedger && !isOrganization && !isProject && !isTeam && !isSeatMapAdmin && !isSeatMapReadonly; + const showEmpty = !isLedger && !isOrganization && !isProject && !isTeam && !isDbStatus && !isSeatMapAdmin && !isSeatMapReadonly; emptyStage.hidden = !showEmpty; emptyStage.style.display = showEmpty ? "flex" : "none"; } @@ -1677,6 +1685,10 @@ function setActiveView(view) { } else if (isTeam) { postGlobalDateRangeToFrame(teamFrame); } + if (isDbStatus && previousView !== "db-status" && dbStatusFrame) { + const frameSrc = dbStatusFrame.dataset.src || dbStatusFrame.src; + dbStatusFrame.src = resolveAppUrl(frameSrc); + } if (isSeatMapAdmin || isSeatMapReadonly) { loadSeatMapData(); } diff --git a/frontend/public/db-status.html b/frontend/public/db-status.html new file mode 100644 index 0000000..e685ed3 --- /dev/null +++ b/frontend/public/db-status.html @@ -0,0 +1,720 @@ + + + + + + DB 상태 + + + + + + + + +
    +
    + #2 백엔드 영속 저장 구조 운영 +

    DB 상태와 저장 구조를 화면에서 바로 확인

    +

    + 이 화면은 현재 운영 DB의 핵심 테이블, 적재 상태, 최근 import 흐름을 SQL 없이 확인하기 위한 관리자용 뷰어입니다. + 이후 저장 구조 검증과 데이터 정합성 작업은 이 화면을 기준으로 진행합니다. +

    +

    + `원본 import 배치`는 업로드한 원본 파일이 몇 행으로 적재됐는지 보여주고, `바이너리 원본 보관`은 엑셀 같은 파일 자체를 DB에 보관하는 상태를 보여줍니다. +

    +
    + +
    + +
    +
    +
    +
    +

    전체 테이블 현황

    +

    전체 27개 테이블을 보여주며, 테이블명을 누르면 샘플 row를 바로 확인할 수 있습니다.

    +
    + 로딩 중 +
    +
    + + + + + + + + + + + + + +
    도메인테이블Rows최근 갱신연결 화면
    DB 상태를 불러오는 중입니다.
    +
    +
    + +
    +
    +
    +
    +

    원본 import 배치

    +

    현재 적재된 원본 파일 배치와 row 수입니다.

    +
    +
    +
    + + + + + + + + + + + +
    SourceRowsImported
    로딩 중
    +
    +
    + +
    +
    +
    +

    바이너리 원본 보관

    +

    엑셀 같은 바이너리 원본의 DB 보관 상태입니다.

    +
    +
    +
    + + + + + + + + + + + +
    Source파일크기
    로딩 중
    +
    +
    + +
    +
    +
    +

    운영 메모

    +

    #2에서 확인해야 할 저장 구조 핵심 포인트입니다.

    +
    +
    +
    +
      +
      +
      + +
      +
      +
      +

      테이블 분류

      +

      유지/주의/원본·추적/정리 후보로 나눈 현재 기준입니다.

      +
      +
      +
      +
      + +
      +
      +
      + + + + + + diff --git a/frontend/public/index.html b/frontend/public/index.html index 9e2b7b5..370eed6 100644 --- a/frontend/public/index.html +++ b/frontend/public/index.html @@ -79,6 +79,7 @@ +
      @@ -119,6 +120,11 @@
      +