Compare commits
2 Commits
1e82572e15
...
03e90d18a3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
03e90d18a3 | ||
|
|
57d9f630bc |
497
backend/app/admin_db_status.py
Normal file
497
backend/app/admin_db_status.py
Normal file
@@ -0,0 +1,497 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import date, datetime
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
from fastapi import HTTPException
|
||||||
|
|
||||||
|
from .db import get_conn
|
||||||
|
|
||||||
|
|
||||||
|
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": "원본·추적",
|
||||||
|
}
|
||||||
|
|
||||||
|
DB_STATUS_PRODUCT_GROUPS = {
|
||||||
|
"탭 데이터": [
|
||||||
|
"public.members",
|
||||||
|
"public.seat_maps",
|
||||||
|
"public.seat_slots",
|
||||||
|
"public.seat_positions",
|
||||||
|
"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.history_revisions",
|
||||||
|
"public.member_versions",
|
||||||
|
"public.seat_assignment_versions",
|
||||||
|
],
|
||||||
|
"로우데이터·적재": [
|
||||||
|
"public.integration_import_batches",
|
||||||
|
"public.integration_raw_organization_rows",
|
||||||
|
"public.integration_raw_mh_rows",
|
||||||
|
"public.integration_raw_mh_pm_rows",
|
||||||
|
"public.integration_raw_payment_rows",
|
||||||
|
],
|
||||||
|
"보정·보조": [
|
||||||
|
"public.member_overrides",
|
||||||
|
"public.member_retirements",
|
||||||
|
"public.member_aliases",
|
||||||
|
"public.integration_project_aliases",
|
||||||
|
"public.integration_project_category_mappings",
|
||||||
|
"public.integration_project_pm_assignments",
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
DB_STATUS_SCREEN_MAP = [
|
||||||
|
{
|
||||||
|
"screen": "조직 현황",
|
||||||
|
"tables": [
|
||||||
|
"public.members",
|
||||||
|
"public.member_overrides",
|
||||||
|
"public.member_retirements",
|
||||||
|
"public.member_aliases",
|
||||||
|
"public.member_versions",
|
||||||
|
"public.history_revisions",
|
||||||
|
],
|
||||||
|
"write_flow": "원본 조직 데이터 import 후 members 계열을 갱신하고, 수정/이력 기능은 revision 기반으로 누적합니다.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"screen": "자리배치도",
|
||||||
|
"tables": [
|
||||||
|
"public.seat_maps",
|
||||||
|
"public.seat_slots",
|
||||||
|
"public.seat_positions",
|
||||||
|
"public.seat_assignment_versions",
|
||||||
|
"public.history_revisions",
|
||||||
|
"public.members",
|
||||||
|
],
|
||||||
|
"write_flow": "고정 오피스 도면과 현재 좌석 배치를 읽고, 저장 시 현재 배치와 배치 이력을 함께 기록합니다.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"screen": "프로젝트별 분석",
|
||||||
|
"tables": [
|
||||||
|
"public.integration_import_batches",
|
||||||
|
"public.integration_projects",
|
||||||
|
"public.integration_vouchers",
|
||||||
|
"public.integration_project_aliases",
|
||||||
|
"public.integration_project_category_mappings",
|
||||||
|
"public.integration_project_pm_assignments",
|
||||||
|
],
|
||||||
|
"write_flow": "payment 원본 import 결과와 프로젝트 보정 테이블을 조합해 프로젝트 집계를 만듭니다.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"screen": "팀/개인별 분석",
|
||||||
|
"tables": [
|
||||||
|
"public.integration_import_batches",
|
||||||
|
"public.integration_projects",
|
||||||
|
"public.integration_work_logs",
|
||||||
|
"public.integration_work_log_segments",
|
||||||
|
"public.integration_raw_mh_rows",
|
||||||
|
"public.integration_raw_mh_pm_rows",
|
||||||
|
],
|
||||||
|
"write_flow": "MH 원본 row를 적재한 뒤 표준화 로그와 세그먼트로 분해해 화면 집계에 사용합니다.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"screen": "사업관리대장",
|
||||||
|
"tables": [
|
||||||
|
"public.integration_binary_sources",
|
||||||
|
],
|
||||||
|
"write_flow": "현재는 기본 바이너리 원본 보관 상태만 DB에 유지하며, 상세 계산 규칙은 별도 기준 정렬이 필요합니다.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"screen": "로그인 / 권한",
|
||||||
|
"tables": [
|
||||||
|
"auth.users",
|
||||||
|
"auth.sessions",
|
||||||
|
"auth.login_audit_logs",
|
||||||
|
],
|
||||||
|
"write_flow": "사용자 계정, 세션, 로그인 감사 로그를 auth 스키마에서 분리 운영합니다.",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
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), '') <> '퇴직'
|
||||||
|
)::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"] == "정리 후보"],
|
||||||
|
}
|
||||||
|
product_summary = {
|
||||||
|
group_name: table_refs
|
||||||
|
for group_name, table_refs in DB_STATUS_PRODUCT_GROUPS.items()
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
"generated_at": datetime.utcnow().isoformat() + "Z",
|
||||||
|
"overview": overview,
|
||||||
|
"tables": table_items,
|
||||||
|
"import_batches": import_batches,
|
||||||
|
"binary_sources": binary_sources,
|
||||||
|
"group_summary": group_summary,
|
||||||
|
"product_summary": product_summary,
|
||||||
|
"screen_map": DB_STATUS_SCREEN_MAP,
|
||||||
|
"notes": [
|
||||||
|
"members / seat_positions / seat_maps 는 현재 운영 상태를 나타냅니다.",
|
||||||
|
"member_versions / seat_assignment_versions / history_revisions 는 시점 조회와 변경 이력을 위한 테이블입니다.",
|
||||||
|
"integration_raw_* / integration_* 는 원본 적재와 표준화 결과를 분리해서 보관합니다.",
|
||||||
|
"integration_binary_sources 는 사업관리대장 같은 바이너리 원본 보관용입니다.",
|
||||||
|
"DB를 물리적으로 합치기보다, 화면/권한/이력/로우데이터 관점으로 묶어 보는 것이 현재 운영에 더 적합합니다.",
|
||||||
|
"재직 인원은 조직현황과 동일하게 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,
|
||||||
|
}
|
||||||
110
backend/app/ledger_runtime.py
Normal file
110
backend/app/ledger_runtime.py
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from fastapi import HTTPException
|
||||||
|
from fastapi.responses import FileResponse, Response
|
||||||
|
|
||||||
|
|
||||||
|
BUSINESS_LEDGER_DEFAULT_SOURCE_KEY = "business_ledger_default"
|
||||||
|
|
||||||
|
|
||||||
|
def sync_default_business_ledger_source(cur, incoming_files_dir: Path, served_dir: Path) -> 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
|
||||||
|
|
||||||
|
business_dashboard_dir = incoming_files_dir / "사업관리대장"
|
||||||
|
business_ledger_served_dir = served_dir / "ledger"
|
||||||
|
candidates = [
|
||||||
|
business_ledger_served_dir / "사업관리대장-1.xlsx",
|
||||||
|
business_dashboard_dir / "사업관리대장-1.xlsx",
|
||||||
|
business_dashboard_dir / "사업관리 대장-1.xlsx",
|
||||||
|
business_dashboard_dir / "사업관리대장.xlsx",
|
||||||
|
business_dashboard_dir / "사업관리 대장.xlsx",
|
||||||
|
]
|
||||||
|
source_path = next((candidate for candidate in candidates if candidate.exists()), None)
|
||||||
|
if source_path is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
content = source_path.read_bytes()
|
||||||
|
content_sha256 = hashlib.sha256(content).hexdigest()
|
||||||
|
meta_json = {
|
||||||
|
"byte_size": len(content),
|
||||||
|
"source_path": str(source_path),
|
||||||
|
"synced_from": "startup",
|
||||||
|
}
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO integration_binary_sources (
|
||||||
|
source_key, source_name, filename, mime_type, content, content_sha256, meta_json, imported_at
|
||||||
|
)
|
||||||
|
VALUES (%s, %s, %s, %s, %s, %s, %s::jsonb, NOW())
|
||||||
|
ON CONFLICT (source_key) DO UPDATE
|
||||||
|
SET source_name = EXCLUDED.source_name,
|
||||||
|
filename = EXCLUDED.filename,
|
||||||
|
mime_type = EXCLUDED.mime_type,
|
||||||
|
content = EXCLUDED.content,
|
||||||
|
content_sha256 = EXCLUDED.content_sha256,
|
||||||
|
meta_json = EXCLUDED.meta_json,
|
||||||
|
imported_at = NOW()
|
||||||
|
WHERE integration_binary_sources.content_sha256 IS DISTINCT FROM EXCLUDED.content_sha256
|
||||||
|
OR integration_binary_sources.filename IS DISTINCT FROM EXCLUDED.filename
|
||||||
|
OR integration_binary_sources.mime_type IS DISTINCT FROM EXCLUDED.mime_type
|
||||||
|
OR integration_binary_sources.meta_json IS DISTINCT FROM EXCLUDED.meta_json
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
BUSINESS_LEDGER_DEFAULT_SOURCE_KEY,
|
||||||
|
"사업관리대장 기본 원본",
|
||||||
|
source_path.name,
|
||||||
|
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||||
|
content,
|
||||||
|
content_sha256,
|
||||||
|
json.dumps(meta_json, ensure_ascii=False),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def build_business_ledger_default_response(cur) -> Response:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT filename, mime_type, content
|
||||||
|
FROM integration_binary_sources
|
||||||
|
WHERE source_key = %s
|
||||||
|
ORDER BY imported_at DESC
|
||||||
|
LIMIT 1
|
||||||
|
""",
|
||||||
|
(BUSINESS_LEDGER_DEFAULT_SOURCE_KEY,),
|
||||||
|
)
|
||||||
|
row = cur.fetchone()
|
||||||
|
if not row:
|
||||||
|
raise HTTPException(status_code=404, detail="Business ledger default source not found.")
|
||||||
|
|
||||||
|
filename = str(row["filename"] or "사업관리대장-1.xlsx")
|
||||||
|
headers = {
|
||||||
|
"Content-Disposition": 'inline; filename="business-ledger-default.xlsx"',
|
||||||
|
"X-Source-Filename": "business-ledger-default.xlsx",
|
||||||
|
"X-Original-Filename": filename,
|
||||||
|
"Cache-Control": "no-store, no-cache, must-revalidate, max-age=0",
|
||||||
|
"Pragma": "no-cache",
|
||||||
|
}
|
||||||
|
return Response(
|
||||||
|
content=bytes(row["content"]),
|
||||||
|
media_type=str(
|
||||||
|
row["mime_type"] or "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
|
||||||
|
),
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def build_ledger_index_response(ledger_index_path: Path) -> FileResponse:
|
||||||
|
if not ledger_index_path.exists():
|
||||||
|
raise HTTPException(status_code=404, detail="Business ledger integration file not found.")
|
||||||
|
response = FileResponse(ledger_index_path)
|
||||||
|
response.headers["Cache-Control"] = "no-store, no-cache, must-revalidate, max-age=0"
|
||||||
|
response.headers["Pragma"] = "no-cache"
|
||||||
|
return response
|
||||||
@@ -21,13 +21,19 @@ import ezdxf
|
|||||||
from ezdxf import recover
|
from ezdxf import recover
|
||||||
from fastapi import FastAPI, File, Form, Header, HTTPException, Request, UploadFile
|
from fastapi import FastAPI, File, Form, Header, HTTPException, Request, UploadFile
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
from fastapi.responses import FileResponse, HTMLResponse, Response
|
from fastapi.responses import HTMLResponse
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
from openpyxl import load_workbook
|
from openpyxl import load_workbook
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
from .config import BASE_DIR, LEGACY_DIR, MOCK_LOGIN_ENABLED, UPLOAD_DIR
|
from .config import BASE_DIR, LEGACY_DIR, MOCK_LOGIN_ENABLED, UPLOAD_DIR
|
||||||
from .db import get_conn, init_db
|
from .db import get_conn, init_db
|
||||||
|
from .ledger_runtime import (
|
||||||
|
build_business_ledger_default_response,
|
||||||
|
build_ledger_index_response,
|
||||||
|
sync_default_business_ledger_source,
|
||||||
|
)
|
||||||
|
from .system_routes import register_system_routes
|
||||||
|
|
||||||
|
|
||||||
app = FastAPI(title="MH Dashboard Organization API")
|
app = FastAPI(title="MH Dashboard Organization API")
|
||||||
@@ -45,7 +51,6 @@ INCOMING_FILES_DIR = BASE_DIR / "incoming-files"
|
|||||||
INCOMING_SERVED_DIR = INCOMING_FILES_DIR / "served"
|
INCOMING_SERVED_DIR = INCOMING_FILES_DIR / "served"
|
||||||
INCOMING_REFERENCE_DIR = INCOMING_FILES_DIR / "reference"
|
INCOMING_REFERENCE_DIR = INCOMING_FILES_DIR / "reference"
|
||||||
DB_STATUS_SERVED_DIR = INCOMING_SERVED_DIR / "db-status"
|
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_SERVED_DIR = INCOMING_SERVED_DIR / "ledger"
|
||||||
BUSINESS_LEDGER_INDEX_PATH = BUSINESS_LEDGER_SERVED_DIR / "index.html"
|
BUSINESS_LEDGER_INDEX_PATH = BUSINESS_LEDGER_SERVED_DIR / "index.html"
|
||||||
FIXED_OFFICE_SOURCE_KEY = "technical-development-center"
|
FIXED_OFFICE_SOURCE_KEY = "technical-development-center"
|
||||||
@@ -67,7 +72,6 @@ FIXED_OFFICE_CONFIGS = {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
_fixed_office_cache: dict[str, dict[str, object]] = {}
|
_fixed_office_cache: dict[str, dict[str, object]] = {}
|
||||||
BUSINESS_LEDGER_DEFAULT_SOURCE_KEY = "business_ledger_default"
|
|
||||||
AUTH_DEFAULT_PASSWORD = "1111"
|
AUTH_DEFAULT_PASSWORD = "1111"
|
||||||
AUTH_PASSWORD_ITERATIONS = 390000
|
AUTH_PASSWORD_ITERATIONS = 390000
|
||||||
AUTH_SESSION_HOURS = 12
|
AUTH_SESSION_HOURS = 12
|
||||||
@@ -89,449 +93,6 @@ 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",
|
|
||||||
BUSINESS_DASHBOARD_DIR / "사업관리 대장-1.xlsx",
|
|
||||||
BUSINESS_DASHBOARD_DIR / "사업관리대장.xlsx",
|
|
||||||
BUSINESS_DASHBOARD_DIR / "사업관리 대장.xlsx",
|
|
||||||
]
|
|
||||||
source_path = next((candidate for candidate in candidates if candidate.exists()), None)
|
|
||||||
if source_path is None:
|
|
||||||
return
|
|
||||||
content = source_path.read_bytes()
|
|
||||||
content_sha256 = hashlib.sha256(content).hexdigest()
|
|
||||||
meta_json = {
|
|
||||||
"byte_size": len(content),
|
|
||||||
"source_path": str(source_path),
|
|
||||||
"synced_from": "startup",
|
|
||||||
}
|
|
||||||
cur.execute(
|
|
||||||
"""
|
|
||||||
INSERT INTO integration_binary_sources (
|
|
||||||
source_key, source_name, filename, mime_type, content, content_sha256, meta_json, imported_at
|
|
||||||
)
|
|
||||||
VALUES (%s, %s, %s, %s, %s, %s, %s::jsonb, NOW())
|
|
||||||
ON CONFLICT (source_key) DO UPDATE
|
|
||||||
SET source_name = EXCLUDED.source_name,
|
|
||||||
filename = EXCLUDED.filename,
|
|
||||||
mime_type = EXCLUDED.mime_type,
|
|
||||||
content = EXCLUDED.content,
|
|
||||||
content_sha256 = EXCLUDED.content_sha256,
|
|
||||||
meta_json = EXCLUDED.meta_json,
|
|
||||||
imported_at = NOW()
|
|
||||||
WHERE integration_binary_sources.content_sha256 IS DISTINCT FROM EXCLUDED.content_sha256
|
|
||||||
OR integration_binary_sources.filename IS DISTINCT FROM EXCLUDED.filename
|
|
||||||
OR integration_binary_sources.mime_type IS DISTINCT FROM EXCLUDED.mime_type
|
|
||||||
OR integration_binary_sources.meta_json IS DISTINCT FROM EXCLUDED.meta_json
|
|
||||||
""",
|
|
||||||
(
|
|
||||||
BUSINESS_LEDGER_DEFAULT_SOURCE_KEY,
|
|
||||||
"사업관리대장 기본 원본",
|
|
||||||
source_path.name,
|
|
||||||
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
|
||||||
content,
|
|
||||||
content_sha256,
|
|
||||||
json.dumps(meta_json, ensure_ascii=False),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
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(
|
app.mount(
|
||||||
"/integrations/ledger-assets",
|
"/integrations/ledger-assets",
|
||||||
StaticFiles(directory=str(BUSINESS_LEDGER_SERVED_DIR), check_dir=False),
|
StaticFiles(directory=str(BUSINESS_LEDGER_SERVED_DIR), check_dir=False),
|
||||||
@@ -4366,85 +3927,25 @@ def startup() -> None:
|
|||||||
init_db()
|
init_db()
|
||||||
with get_conn() as conn:
|
with get_conn() as conn:
|
||||||
with conn.cursor() as cur:
|
with conn.cursor() as cur:
|
||||||
sync_default_business_ledger_source(cur)
|
sync_default_business_ledger_source(cur, INCOMING_FILES_DIR, INCOMING_SERVED_DIR)
|
||||||
sync_auth_users_from_members(cur)
|
sync_auth_users_from_members(cur)
|
||||||
conn.commit()
|
conn.commit()
|
||||||
|
|
||||||
|
|
||||||
app.mount("/legacy/static", StaticFiles(directory=LEGACY_STATIC_DIR, check_dir=False), name="legacy-static")
|
app.mount("/legacy/static", StaticFiles(directory=LEGACY_STATIC_DIR, check_dir=False), name="legacy-static")
|
||||||
|
|
||||||
|
register_system_routes(
|
||||||
@app.get("/api/health")
|
app,
|
||||||
def health() -> dict[str, object]:
|
upload_dir=UPLOAD_DIR,
|
||||||
checks = {
|
legacy_dir=LEGACY_DIR,
|
||||||
"upload_dir": UPLOAD_DIR.exists(),
|
incoming_files_dir=INCOMING_FILES_DIR,
|
||||||
}
|
incoming_served_dir=INCOMING_SERVED_DIR,
|
||||||
|
db_status_served_dir=DB_STATUS_SERVED_DIR,
|
||||||
try:
|
business_ledger_index_path=BUSINESS_LEDGER_INDEX_PATH,
|
||||||
member_count = get_member_count()
|
get_member_count=get_member_count,
|
||||||
checks["database"] = True
|
get_conn=get_conn,
|
||||||
except Exception:
|
build_business_ledger_default_response=build_business_ledger_default_response,
|
||||||
member_count = None
|
build_ledger_index_response=build_ledger_index_response,
|
||||||
checks["database"] = False
|
|
||||||
|
|
||||||
status = "ok" if all(checks.values()) else "degraded"
|
|
||||||
return {
|
|
||||||
"status": status,
|
|
||||||
"checks": checks,
|
|
||||||
"member_count": member_count,
|
|
||||||
"timestamp": datetime.utcnow().isoformat() + "Z",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@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:
|
|
||||||
with conn.cursor() as cur:
|
|
||||||
cur.execute(
|
|
||||||
"""
|
|
||||||
SELECT filename, mime_type, content
|
|
||||||
FROM integration_binary_sources
|
|
||||||
WHERE source_key = %s
|
|
||||||
ORDER BY imported_at DESC
|
|
||||||
LIMIT 1
|
|
||||||
""",
|
|
||||||
(BUSINESS_LEDGER_DEFAULT_SOURCE_KEY,),
|
|
||||||
)
|
|
||||||
row = cur.fetchone()
|
|
||||||
if not row:
|
|
||||||
raise HTTPException(status_code=404, detail="Business ledger default source not found.")
|
|
||||||
filename = str(row["filename"] or "사업관리대장-1.xlsx")
|
|
||||||
headers = {
|
|
||||||
"Content-Disposition": 'inline; filename="business-ledger-default.xlsx"',
|
|
||||||
"X-Source-Filename": "business-ledger-default.xlsx",
|
|
||||||
"Cache-Control": "no-store, no-cache, must-revalidate, max-age=0",
|
|
||||||
"Pragma": "no-cache",
|
|
||||||
}
|
|
||||||
return Response(
|
|
||||||
content=bytes(row["content"]),
|
|
||||||
media_type=str(row["mime_type"] or "application/octet-stream"),
|
|
||||||
headers=headers,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -4770,18 +4271,6 @@ def integration_mh_source() -> dict[str, object]:
|
|||||||
return fetch_mh_source_rows()
|
return fetch_mh_source_rows()
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/integration/mh-workbook")
|
|
||||||
def integration_mh_workbook() -> FileResponse:
|
|
||||||
target = INCOMING_FILES_DIR / "MH.xlsx"
|
|
||||||
if not target.exists():
|
|
||||||
raise HTTPException(status_code=404, detail="MH workbook not found.")
|
|
||||||
return FileResponse(
|
|
||||||
target,
|
|
||||||
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
|
||||||
filename="MH.xlsx",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/uploads/profile-photo")
|
@app.post("/api/uploads/profile-photo")
|
||||||
def upload_profile_photo(file: UploadFile = File(...), member_name: str = Form("")) -> dict[str, str]:
|
def upload_profile_photo(file: UploadFile = File(...), member_name: str = Form("")) -> dict[str, str]:
|
||||||
suffix = Path(file.filename or "").suffix.lower()
|
suffix = Path(file.filename or "").suffix.lower()
|
||||||
@@ -4989,58 +4478,3 @@ def get_seat_map_viewer(seat_map_id: int, as_of: str | None = None) -> HTMLRespo
|
|||||||
@app.put("/api/seat-maps/{seat_map_id}/layout")
|
@app.put("/api/seat-maps/{seat_map_id}/layout")
|
||||||
def update_seat_layout(seat_map_id: int, payload: SeatLayoutPayload) -> dict[str, list[dict[str, object]]]:
|
def update_seat_layout(seat_map_id: int, payload: SeatLayoutPayload) -> dict[str, list[dict[str, object]]]:
|
||||||
return {"items": save_seat_layout(seat_map_id, payload)}
|
return {"items": save_seat_layout(seat_map_id, payload)}
|
||||||
|
|
||||||
|
|
||||||
@app.get("/legacy/organization")
|
|
||||||
def legacy_organization() -> FileResponse:
|
|
||||||
target = LEGACY_DIR / "DashBoard-organization.html"
|
|
||||||
if not target.exists():
|
|
||||||
raise HTTPException(status_code=404, detail="Legacy dashboard file not found.")
|
|
||||||
return FileResponse(target)
|
|
||||||
|
|
||||||
|
|
||||||
@app.get("/legacy/organization-backup")
|
|
||||||
def legacy_organization_backup() -> FileResponse:
|
|
||||||
target = LEGACY_DIR / "DashBoard-organization-backup.html"
|
|
||||||
if not target.exists():
|
|
||||||
raise HTTPException(status_code=404, detail="Legacy dashboard backup not found.")
|
|
||||||
return FileResponse(target)
|
|
||||||
|
|
||||||
|
|
||||||
@app.get("/integrations/payment")
|
|
||||||
def integration_payment() -> FileResponse:
|
|
||||||
# 8081 phase-1 cleanup: integration HTML is served only from incoming-files/served.
|
|
||||||
target = INCOMING_SERVED_DIR / "payment.html"
|
|
||||||
if not target.exists():
|
|
||||||
raise HTTPException(status_code=404, detail="Payment integration file not found.")
|
|
||||||
return FileResponse(target)
|
|
||||||
|
|
||||||
|
|
||||||
@app.get("/integrations/ledger")
|
|
||||||
def integration_ledger() -> FileResponse:
|
|
||||||
# #21 phase-1: runtime no longer decodes reference wrapper HTML. Serve the promoted
|
|
||||||
# ledger entry file from incoming-files/served/ledger only.
|
|
||||||
target = BUSINESS_LEDGER_INDEX_PATH
|
|
||||||
if not target.exists():
|
|
||||||
raise HTTPException(status_code=404, detail="Business ledger integration 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("/integrations/mh")
|
|
||||||
def integration_mh() -> FileResponse:
|
|
||||||
# Keep the served path explicit so comparison/reference copies are never picked up by accident.
|
|
||||||
target = INCOMING_SERVED_DIR / "mh.html"
|
|
||||||
if not target.exists():
|
|
||||||
raise HTTPException(status_code=404, detail="MH integration file not found.")
|
|
||||||
return FileResponse(target)
|
|
||||||
|
|
||||||
|
|
||||||
@app.get("/uploads/{filename}")
|
|
||||||
def get_upload(filename: str) -> FileResponse:
|
|
||||||
target = UPLOAD_DIR / filename
|
|
||||||
if not target.exists():
|
|
||||||
raise HTTPException(status_code=404, detail="Upload not found.")
|
|
||||||
return FileResponse(target)
|
|
||||||
|
|||||||
120
backend/app/system_routes.py
Normal file
120
backend/app/system_routes.py
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Callable
|
||||||
|
|
||||||
|
from fastapi import FastAPI, HTTPException
|
||||||
|
from fastapi.responses import FileResponse, Response
|
||||||
|
|
||||||
|
from .admin_db_status import fetch_db_status_snapshot, fetch_db_table_preview
|
||||||
|
|
||||||
|
|
||||||
|
def register_system_routes(
|
||||||
|
app: FastAPI,
|
||||||
|
*,
|
||||||
|
upload_dir: Path,
|
||||||
|
legacy_dir: Path,
|
||||||
|
incoming_files_dir: Path,
|
||||||
|
incoming_served_dir: Path,
|
||||||
|
db_status_served_dir: Path,
|
||||||
|
business_ledger_index_path: Path,
|
||||||
|
get_member_count: Callable[[], int],
|
||||||
|
get_conn,
|
||||||
|
build_business_ledger_default_response: Callable[[object], Response],
|
||||||
|
build_ledger_index_response: Callable[[Path], FileResponse],
|
||||||
|
) -> None:
|
||||||
|
@app.get("/api/health")
|
||||||
|
def health() -> dict[str, object]:
|
||||||
|
checks = {
|
||||||
|
"upload_dir": upload_dir.exists(),
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
member_count = get_member_count()
|
||||||
|
checks["database"] = True
|
||||||
|
except Exception:
|
||||||
|
member_count = None
|
||||||
|
checks["database"] = False
|
||||||
|
|
||||||
|
status = "ok" if all(checks.values()) else "degraded"
|
||||||
|
return {
|
||||||
|
"status": status,
|
||||||
|
"checks": checks,
|
||||||
|
"member_count": member_count,
|
||||||
|
"timestamp": datetime.utcnow().isoformat() + "Z",
|
||||||
|
}
|
||||||
|
|
||||||
|
@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:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
return build_business_ledger_default_response(cur)
|
||||||
|
|
||||||
|
@app.get("/api/integration/mh-workbook")
|
||||||
|
def integration_mh_workbook() -> FileResponse:
|
||||||
|
target = incoming_files_dir / "MH.xlsx"
|
||||||
|
if not target.exists():
|
||||||
|
raise HTTPException(status_code=404, detail="MH workbook not found.")
|
||||||
|
return FileResponse(
|
||||||
|
target,
|
||||||
|
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||||
|
filename="MH.xlsx",
|
||||||
|
)
|
||||||
|
|
||||||
|
@app.get("/legacy/organization")
|
||||||
|
def legacy_organization() -> FileResponse:
|
||||||
|
target = legacy_dir / "DashBoard-organization.html"
|
||||||
|
if not target.exists():
|
||||||
|
raise HTTPException(status_code=404, detail="Legacy dashboard file not found.")
|
||||||
|
return FileResponse(target)
|
||||||
|
|
||||||
|
@app.get("/legacy/organization-backup")
|
||||||
|
def legacy_organization_backup() -> FileResponse:
|
||||||
|
target = legacy_dir / "DashBoard-organization-backup.html"
|
||||||
|
if not target.exists():
|
||||||
|
raise HTTPException(status_code=404, detail="Legacy dashboard backup not found.")
|
||||||
|
return FileResponse(target)
|
||||||
|
|
||||||
|
@app.get("/integrations/payment")
|
||||||
|
def integration_payment() -> FileResponse:
|
||||||
|
target = incoming_served_dir / "payment.html"
|
||||||
|
if not target.exists():
|
||||||
|
raise HTTPException(status_code=404, detail="Payment integration file not found.")
|
||||||
|
return FileResponse(target)
|
||||||
|
|
||||||
|
@app.get("/integrations/ledger")
|
||||||
|
def integration_ledger() -> FileResponse:
|
||||||
|
return build_ledger_index_response(business_ledger_index_path)
|
||||||
|
|
||||||
|
@app.get("/integrations/mh")
|
||||||
|
def integration_mh() -> FileResponse:
|
||||||
|
target = incoming_served_dir / "mh.html"
|
||||||
|
if not target.exists():
|
||||||
|
raise HTTPException(status_code=404, detail="MH integration file not found.")
|
||||||
|
return FileResponse(target)
|
||||||
|
|
||||||
|
@app.get("/uploads/{filename}")
|
||||||
|
def get_upload(filename: str) -> FileResponse:
|
||||||
|
target = upload_dir / filename
|
||||||
|
if not target.exists():
|
||||||
|
raise HTTPException(status_code=404, detail="Upload not found.")
|
||||||
|
return FileResponse(target)
|
||||||
@@ -109,7 +109,7 @@
|
|||||||
- [incoming-files/served/ledger/index.html](/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081/incoming-files/served/ledger/index.html)
|
- [incoming-files/served/ledger/index.html](/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081/incoming-files/served/ledger/index.html)
|
||||||
- `/integrations/mh`:
|
- `/integrations/mh`:
|
||||||
- [incoming-files/served/mh.html](/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081/incoming-files/served/mh.html)
|
- [incoming-files/served/mh.html](/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081/incoming-files/served/mh.html)
|
||||||
- `/admin/db-status`:
|
- `/db-status.html`:
|
||||||
- [incoming-files/served/db-status/index.html](/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081/incoming-files/served/db-status/index.html)
|
- [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
|
## Cross Checks Last Confirmed
|
||||||
@@ -153,6 +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) 확인
|
- 먼저 [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) 먼저 확인
|
- 디자인 수정이면 [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`
|
- 현재 구조 독립화 기준 이슈는 `#21`
|
||||||
- `#2` 기준 DB 상태 확인은 `/api/admin/db-status` 또는 허브의 `DB 상태` 탭을 먼저 본다.
|
- `#2` 기준 DB 상태 확인은 `/api/admin/db-status` 또는 허브의 `DB 상태` 탭(`/db-status.html`)을 먼저 본다.
|
||||||
- DB 테이블 유지/주의/원본·추적/정리 후보 분류는 [architecture/DB_TABLE_CATALOG.md](/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081/docs/architecture/DB_TABLE_CATALOG.md) 기준으로 본다.
|
- 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`를 먼저 확인
|
- 작업 전 `git status`, dev 컨테이너 상태, `/api/health`, `/legacy/organization`, `/integrations/payment`, `/integrations/ledger`, `/integrations/mh`, `/api/admin/db-status`를 먼저 확인
|
||||||
|
|||||||
@@ -47,7 +47,7 @@
|
|||||||
- 현재 실제 서빙 파일: `incoming-files/served/mh.html`
|
- 현재 실제 서빙 파일: `incoming-files/served/mh.html`
|
||||||
- 앱 소스 기준: `frontend/apps/team/index.html`
|
- 앱 소스 기준: `frontend/apps/team/index.html`
|
||||||
- publish 규칙: `scripts/publish_team_app.sh`
|
- publish 규칙: `scripts/publish_team_app.sh`
|
||||||
- URL: `/admin/db-status`
|
- URL: `/db-status.html`
|
||||||
- 현재 실제 서빙 파일: `incoming-files/served/db-status/index.html`
|
- 현재 실제 서빙 파일: `incoming-files/served/db-status/index.html`
|
||||||
- 앱 소스 기준: `frontend/apps/db-status/index.html`
|
- 앱 소스 기준: `frontend/apps/db-status/index.html`
|
||||||
- publish 규칙: `scripts/publish_db_status_app.sh`
|
- publish 규칙: `scripts/publish_db_status_app.sh`
|
||||||
@@ -55,6 +55,8 @@
|
|||||||
정리 원칙:
|
정리 원칙:
|
||||||
|
|
||||||
- `incoming-files` 아래에서는 `served/`를 실제 서빙 자산용으로 사용한다.
|
- `incoming-files` 아래에서는 `served/`를 실제 서빙 자산용으로 사용한다.
|
||||||
|
- `payment`, `mh`, `ledger`, `db-status`는 사람이 직접 `served/`를 먼저 수정하지 않는다.
|
||||||
|
- 이 4개 화면의 source-of-truth는 `frontend/apps/*`이고, publish 스크립트가 `served/`를 갱신한다.
|
||||||
- `reference/`는 원본 참고 파일, 복구 참고 파일, 비교용 자산만 둔다.
|
- `reference/`는 원본 참고 파일, 복구 참고 파일, 비교용 자산만 둔다.
|
||||||
- 1차 정리에서는 기존 실제 서빙 파일을 `served/`에 복사하고, backend 서빙 경로를 먼저 `served/`로 갱신한다.
|
- 1차 정리에서는 기존 실제 서빙 파일을 `served/`에 복사하고, backend 서빙 경로를 먼저 `served/`로 갱신한다.
|
||||||
- `사업관리대장`은 `#21`부터 wrapper decode 방식 대신 `served/ledger/index.html`과 `served/ledger/*`를 직접 서빙한다.
|
- `사업관리대장`은 `#21`부터 wrapper decode 방식 대신 `served/ledger/index.html`과 `served/ledger/*`를 직접 서빙한다.
|
||||||
@@ -113,5 +115,5 @@
|
|||||||
- 로그인은 `styles.css`만 본다.
|
- 로그인은 `styles.css`만 본다.
|
||||||
- 허브 8081 디자인은 `styles-8081-design.css`만 본다.
|
- 허브 8081 디자인은 `styles-8081-design.css`만 본다.
|
||||||
- `/integrations/payment`, `/integrations/mh`의 실제 서빙 파일 위치가 문서와 코드에서 일치한다.
|
- `/integrations/payment`, `/integrations/mh`의 실제 서빙 파일 위치가 문서와 코드에서 일치한다.
|
||||||
- `/admin/db-status`가 현재 DB 저장 구조와 import 상태를 화면에서 바로 보여준다.
|
- `/db-status.html`가 현재 DB 저장 구조와 import 상태를 화면에서 바로 보여준다.
|
||||||
- 기존 참고 자산을 지우지 않고도 실제 서빙 경로와 참고 경로를 구분할 수 있다.
|
- 기존 참고 자산을 지우지 않고도 실제 서빙 경로와 참고 경로를 구분할 수 있다.
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
## Purpose
|
## Purpose
|
||||||
|
|
||||||
이 문서는 `8081 / work-8081` 기준 현재 PostgreSQL 테이블 27개를 역할별로 분류한 운영 기준 문서다.
|
이 문서는 `8081 / work-8081` 기준 현재 PostgreSQL 테이블 26개를 역할별로 분류한 운영 기준 문서다.
|
||||||
|
|
||||||
핵심 원칙:
|
핵심 원칙:
|
||||||
|
|
||||||
@@ -130,6 +130,51 @@
|
|||||||
- `public.integration_raw_mh_pm_rows`
|
- `public.integration_raw_mh_pm_rows`
|
||||||
- `public.integration_raw_payment_rows`
|
- `public.integration_raw_payment_rows`
|
||||||
|
|
||||||
|
## Product View
|
||||||
|
|
||||||
|
운영자가 DB를 볼 때는 물리 테이블 수보다 아래 5개 묶음으로 보는 편이 더 이해하기 쉽다.
|
||||||
|
|
||||||
|
### 탭 데이터
|
||||||
|
|
||||||
|
- `public.members`
|
||||||
|
- `public.seat_maps`
|
||||||
|
- `public.seat_slots`
|
||||||
|
- `public.seat_positions`
|
||||||
|
- `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.history_revisions`
|
||||||
|
- `public.member_versions`
|
||||||
|
- `public.seat_assignment_versions`
|
||||||
|
|
||||||
|
### 로우데이터·적재
|
||||||
|
|
||||||
|
- `public.integration_import_batches`
|
||||||
|
- `public.integration_raw_organization_rows`
|
||||||
|
- `public.integration_raw_mh_rows`
|
||||||
|
- `public.integration_raw_mh_pm_rows`
|
||||||
|
- `public.integration_raw_payment_rows`
|
||||||
|
|
||||||
|
### 보정·보조
|
||||||
|
|
||||||
|
- `public.member_overrides`
|
||||||
|
- `public.member_retirements`
|
||||||
|
- `public.member_aliases`
|
||||||
|
- `public.integration_project_aliases`
|
||||||
|
- `public.integration_project_category_mappings`
|
||||||
|
- `public.integration_project_pm_assignments`
|
||||||
|
|
||||||
## Operational Guidance
|
## Operational Guidance
|
||||||
|
|
||||||
### 바로 줄이지 말아야 하는 것
|
### 바로 줄이지 말아야 하는 것
|
||||||
|
|||||||
@@ -219,6 +219,33 @@
|
|||||||
color: rgba(84, 65, 38, 0.84);
|
color: rgba(84, 65, 38, 0.84);
|
||||||
line-height: 1.55;
|
line-height: 1.55;
|
||||||
}
|
}
|
||||||
|
.mapping-list {
|
||||||
|
display: grid;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
.mapping-card {
|
||||||
|
padding: 14px 16px;
|
||||||
|
border-radius: 18px;
|
||||||
|
background: rgba(255, 249, 239, 0.92);
|
||||||
|
border: 1px solid rgba(132, 102, 54, 0.12);
|
||||||
|
}
|
||||||
|
.mapping-card h3 {
|
||||||
|
margin: 0 0 8px;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
}
|
||||||
|
.mapping-card p {
|
||||||
|
margin: 8px 0 0;
|
||||||
|
color: rgba(98, 75, 42, 0.74);
|
||||||
|
line-height: 1.55;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.mapping-table-list {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
.preview-meta {
|
.preview-meta {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
@@ -345,6 +372,9 @@
|
|||||||
<p>
|
<p>
|
||||||
`원본 import 배치`는 업로드한 원본 파일이 몇 행으로 적재됐는지 보여주고, `바이너리 원본 보관`은 엑셀 같은 파일 자체를 DB에 보관하는 상태를 보여줍니다.
|
`원본 import 배치`는 업로드한 원본 파일이 몇 행으로 적재됐는지 보여주고, `바이너리 원본 보관`은 엑셀 같은 파일 자체를 DB에 보관하는 상태를 보여줍니다.
|
||||||
</p>
|
</p>
|
||||||
|
<p>
|
||||||
|
아래 표는 전체 테이블을 보여주고, 오른쪽 패널은 화면별 데이터 소스와 저장 흐름을 운영 관점으로 묶어서 보여줍니다.
|
||||||
|
</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section id="overview" class="overview"></section>
|
<section id="overview" class="overview"></section>
|
||||||
@@ -354,7 +384,7 @@
|
|||||||
<div class="panel-head">
|
<div class="panel-head">
|
||||||
<div>
|
<div>
|
||||||
<h2>전체 테이블 현황</h2>
|
<h2>전체 테이블 현황</h2>
|
||||||
<p>전체 27개 테이블을 보여주며, 테이블명을 누르면 샘플 row를 바로 확인할 수 있습니다.</p>
|
<p>현재 운영 DB의 전체 26개 테이블을 보여주며, 테이블명을 누르면 샘플 row를 바로 확인할 수 있습니다.</p>
|
||||||
</div>
|
</div>
|
||||||
<span id="generated-at" class="meta-chip">로딩 중</span>
|
<span id="generated-at" class="meta-chip">로딩 중</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -438,11 +468,24 @@
|
|||||||
<article class="panel">
|
<article class="panel">
|
||||||
<div class="panel-head">
|
<div class="panel-head">
|
||||||
<div>
|
<div>
|
||||||
<h2>테이블 분류</h2>
|
<h2>운영 분류</h2>
|
||||||
<p>유지/주의/원본·추적/정리 후보로 나눈 현재 기준입니다.</p>
|
<p>유지/주의/원본·추적/정리 후보 기준과 제품 관점 묶음을 같이 봅니다.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div id="group-summary" class="panel-body"></div>
|
<div class="panel-body">
|
||||||
|
<div id="group-summary"></div>
|
||||||
|
<div id="product-summary" style="margin-top:18px;"></div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="panel">
|
||||||
|
<div class="panel-head">
|
||||||
|
<div>
|
||||||
|
<h2>화면별 데이터 소스</h2>
|
||||||
|
<p>각 탭/기능이 실제로 어떤 테이블을 읽고 저장하는지 빠르게 확인합니다.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="screen-map" class="panel-body mapping-list"></div>
|
||||||
</article>
|
</article>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
@@ -619,6 +662,42 @@
|
|||||||
`).join("");
|
`).join("");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function renderProductSummary(summary) {
|
||||||
|
const target = document.getElementById("product-summary");
|
||||||
|
const groups = [
|
||||||
|
"탭 데이터",
|
||||||
|
"로그인·권한",
|
||||||
|
"히스토리",
|
||||||
|
"로우데이터·적재",
|
||||||
|
"보정·보조",
|
||||||
|
];
|
||||||
|
target.innerHTML = groups.map((label) => `
|
||||||
|
<div style="display:grid; gap:8px; margin-bottom:16px;">
|
||||||
|
<div><span class="group-tag keep">${escapeHtml(label)}</span></div>
|
||||||
|
<div class="view-list">
|
||||||
|
${((summary && summary[label]) || []).map((item) => `<span class="view-pill">${escapeHtml(item)}</span>`).join("") || '<span class="muted">없음</span>'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`).join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderScreenMap(items) {
|
||||||
|
const target = document.getElementById("screen-map");
|
||||||
|
if (!items || !items.length) {
|
||||||
|
target.innerHTML = '<div class="empty">화면별 데이터 소스 정보가 없습니다.</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
target.innerHTML = items.map((item) => `
|
||||||
|
<article class="mapping-card">
|
||||||
|
<h3>${escapeHtml(item.screen || "")}</h3>
|
||||||
|
<div class="mapping-table-list">
|
||||||
|
${(item.tables || []).map((table) => `<span class="view-pill">${escapeHtml(table)}</span>`).join("")}
|
||||||
|
</div>
|
||||||
|
<p>${escapeHtml(item.write_flow || "")}</p>
|
||||||
|
</article>
|
||||||
|
`).join("");
|
||||||
|
}
|
||||||
|
|
||||||
function renderTablePreview(payload) {
|
function renderTablePreview(payload) {
|
||||||
const previewModal = document.getElementById("preview-modal");
|
const previewModal = document.getElementById("preview-modal");
|
||||||
const previewMeta = document.getElementById("preview-meta");
|
const previewMeta = document.getElementById("preview-meta");
|
||||||
@@ -695,6 +774,8 @@
|
|||||||
renderBinarySources(payload.binary_sources || []);
|
renderBinarySources(payload.binary_sources || []);
|
||||||
renderNotes(payload.notes || []);
|
renderNotes(payload.notes || []);
|
||||||
renderGroupSummary(payload.group_summary || {});
|
renderGroupSummary(payload.group_summary || {});
|
||||||
|
renderProductSummary(payload.product_summary || {});
|
||||||
|
renderScreenMap(payload.screen_map || []);
|
||||||
}
|
}
|
||||||
|
|
||||||
document.getElementById("preview-close").addEventListener("click", () => {
|
document.getElementById("preview-close").addEventListener("click", () => {
|
||||||
|
|||||||
@@ -219,6 +219,33 @@
|
|||||||
color: rgba(84, 65, 38, 0.84);
|
color: rgba(84, 65, 38, 0.84);
|
||||||
line-height: 1.55;
|
line-height: 1.55;
|
||||||
}
|
}
|
||||||
|
.mapping-list {
|
||||||
|
display: grid;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
.mapping-card {
|
||||||
|
padding: 14px 16px;
|
||||||
|
border-radius: 18px;
|
||||||
|
background: rgba(255, 249, 239, 0.92);
|
||||||
|
border: 1px solid rgba(132, 102, 54, 0.12);
|
||||||
|
}
|
||||||
|
.mapping-card h3 {
|
||||||
|
margin: 0 0 8px;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
}
|
||||||
|
.mapping-card p {
|
||||||
|
margin: 8px 0 0;
|
||||||
|
color: rgba(98, 75, 42, 0.74);
|
||||||
|
line-height: 1.55;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.mapping-table-list {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
.preview-meta {
|
.preview-meta {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
@@ -345,6 +372,9 @@
|
|||||||
<p>
|
<p>
|
||||||
`원본 import 배치`는 업로드한 원본 파일이 몇 행으로 적재됐는지 보여주고, `바이너리 원본 보관`은 엑셀 같은 파일 자체를 DB에 보관하는 상태를 보여줍니다.
|
`원본 import 배치`는 업로드한 원본 파일이 몇 행으로 적재됐는지 보여주고, `바이너리 원본 보관`은 엑셀 같은 파일 자체를 DB에 보관하는 상태를 보여줍니다.
|
||||||
</p>
|
</p>
|
||||||
|
<p>
|
||||||
|
아래 표는 전체 테이블을 보여주고, 오른쪽 패널은 화면별 데이터 소스와 저장 흐름을 운영 관점으로 묶어서 보여줍니다.
|
||||||
|
</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section id="overview" class="overview"></section>
|
<section id="overview" class="overview"></section>
|
||||||
@@ -354,7 +384,7 @@
|
|||||||
<div class="panel-head">
|
<div class="panel-head">
|
||||||
<div>
|
<div>
|
||||||
<h2>전체 테이블 현황</h2>
|
<h2>전체 테이블 현황</h2>
|
||||||
<p>전체 27개 테이블을 보여주며, 테이블명을 누르면 샘플 row를 바로 확인할 수 있습니다.</p>
|
<p>현재 운영 DB의 전체 26개 테이블을 보여주며, 테이블명을 누르면 샘플 row를 바로 확인할 수 있습니다.</p>
|
||||||
</div>
|
</div>
|
||||||
<span id="generated-at" class="meta-chip">로딩 중</span>
|
<span id="generated-at" class="meta-chip">로딩 중</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -438,11 +468,24 @@
|
|||||||
<article class="panel">
|
<article class="panel">
|
||||||
<div class="panel-head">
|
<div class="panel-head">
|
||||||
<div>
|
<div>
|
||||||
<h2>테이블 분류</h2>
|
<h2>운영 분류</h2>
|
||||||
<p>유지/주의/원본·추적/정리 후보로 나눈 현재 기준입니다.</p>
|
<p>유지/주의/원본·추적/정리 후보 기준과 제품 관점 묶음을 같이 봅니다.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div id="group-summary" class="panel-body"></div>
|
<div class="panel-body">
|
||||||
|
<div id="group-summary"></div>
|
||||||
|
<div id="product-summary" style="margin-top:18px;"></div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="panel">
|
||||||
|
<div class="panel-head">
|
||||||
|
<div>
|
||||||
|
<h2>화면별 데이터 소스</h2>
|
||||||
|
<p>각 탭/기능이 실제로 어떤 테이블을 읽고 저장하는지 빠르게 확인합니다.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="screen-map" class="panel-body mapping-list"></div>
|
||||||
</article>
|
</article>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
@@ -619,6 +662,42 @@
|
|||||||
`).join("");
|
`).join("");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function renderProductSummary(summary) {
|
||||||
|
const target = document.getElementById("product-summary");
|
||||||
|
const groups = [
|
||||||
|
"탭 데이터",
|
||||||
|
"로그인·권한",
|
||||||
|
"히스토리",
|
||||||
|
"로우데이터·적재",
|
||||||
|
"보정·보조",
|
||||||
|
];
|
||||||
|
target.innerHTML = groups.map((label) => `
|
||||||
|
<div style="display:grid; gap:8px; margin-bottom:16px;">
|
||||||
|
<div><span class="group-tag keep">${escapeHtml(label)}</span></div>
|
||||||
|
<div class="view-list">
|
||||||
|
${((summary && summary[label]) || []).map((item) => `<span class="view-pill">${escapeHtml(item)}</span>`).join("") || '<span class="muted">없음</span>'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`).join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderScreenMap(items) {
|
||||||
|
const target = document.getElementById("screen-map");
|
||||||
|
if (!items || !items.length) {
|
||||||
|
target.innerHTML = '<div class="empty">화면별 데이터 소스 정보가 없습니다.</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
target.innerHTML = items.map((item) => `
|
||||||
|
<article class="mapping-card">
|
||||||
|
<h3>${escapeHtml(item.screen || "")}</h3>
|
||||||
|
<div class="mapping-table-list">
|
||||||
|
${(item.tables || []).map((table) => `<span class="view-pill">${escapeHtml(table)}</span>`).join("")}
|
||||||
|
</div>
|
||||||
|
<p>${escapeHtml(item.write_flow || "")}</p>
|
||||||
|
</article>
|
||||||
|
`).join("");
|
||||||
|
}
|
||||||
|
|
||||||
function renderTablePreview(payload) {
|
function renderTablePreview(payload) {
|
||||||
const previewModal = document.getElementById("preview-modal");
|
const previewModal = document.getElementById("preview-modal");
|
||||||
const previewMeta = document.getElementById("preview-meta");
|
const previewMeta = document.getElementById("preview-meta");
|
||||||
@@ -695,6 +774,8 @@
|
|||||||
renderBinarySources(payload.binary_sources || []);
|
renderBinarySources(payload.binary_sources || []);
|
||||||
renderNotes(payload.notes || []);
|
renderNotes(payload.notes || []);
|
||||||
renderGroupSummary(payload.group_summary || {});
|
renderGroupSummary(payload.group_summary || {});
|
||||||
|
renderProductSummary(payload.product_summary || {});
|
||||||
|
renderScreenMap(payload.screen_map || []);
|
||||||
}
|
}
|
||||||
|
|
||||||
document.getElementById("preview-close").addEventListener("click", () => {
|
document.getElementById("preview-close").addEventListener("click", () => {
|
||||||
|
|||||||
@@ -14,7 +14,8 @@
|
|||||||
|
|
||||||
- backend `/integrations/payment`, `/integrations/mh`는 위 `served/*`만 읽는다.
|
- backend `/integrations/payment`, `/integrations/mh`는 위 `served/*`만 읽는다.
|
||||||
- backend `/integrations/ledger`와 `/integrations/ledger-assets/*`도 `served/ledger/*`만 읽는다.
|
- backend `/integrations/ledger`와 `/integrations/ledger-assets/*`도 `served/ledger/*`만 읽는다.
|
||||||
- 새 기능을 붙일 때도 실제 서비스 파일은 `served/` 기준으로 수정한다.
|
- 다만 `payment`, `mh`, `ledger`, `db-status`는 이제 앱 소스가 따로 있으므로, 실제 수정은 `frontend/apps/*`에서 하고 publish 스크립트로 `served/`에 반영한다.
|
||||||
|
- 즉 `served/`는 runtime 기준이고, 사람이 직접 먼저 수정하는 source-of-truth는 아니다.
|
||||||
|
|
||||||
## Reference
|
## Reference
|
||||||
|
|
||||||
@@ -30,6 +31,7 @@
|
|||||||
- 디자인 비교용 파일
|
- 디자인 비교용 파일
|
||||||
- `reference/ledger/MH 통합 대시보드_260320.html`
|
- `reference/ledger/MH 통합 대시보드_260320.html`
|
||||||
- `reference/ledger/MH 통합 대시보드_260320.css`
|
- `reference/ledger/MH 통합 대시보드_260320.css`
|
||||||
|
- `reference/ledger/사업관리대장-1.xlsx`
|
||||||
|
|
||||||
## Temporary Comparison Copies
|
## Temporary Comparison Copies
|
||||||
|
|
||||||
|
|||||||
21
incoming-files/reference/ledger/README.md
Normal file
21
incoming-files/reference/ledger/README.md
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
# reference/ledger
|
||||||
|
|
||||||
|
이 디렉터리는 `사업관리대장` 원본 참고 자산만 둔다.
|
||||||
|
|
||||||
|
원칙:
|
||||||
|
|
||||||
|
- 직접 서빙하지 않는다.
|
||||||
|
- 비교, 복구, 기준 확인이 필요할 때만 본다.
|
||||||
|
- 실제 수정 원본은 `frontend/apps/ledger/*`다.
|
||||||
|
- 실제 runtime 응답은 `incoming-files/served/ledger/*`다.
|
||||||
|
|
||||||
|
현재 포함:
|
||||||
|
|
||||||
|
- 원본 HTML/CSS
|
||||||
|
- 원본 XLSX
|
||||||
|
- 과거 override 참고 파일
|
||||||
|
|
||||||
|
주의:
|
||||||
|
|
||||||
|
- `reference/ledger` 아래에 다시 `ledger/` 같은 중첩 복사본을 만들지 않는다.
|
||||||
|
- 원본 정리가 필요하면 이 디렉터리에서만 구조를 맞춘다.
|
||||||
@@ -219,6 +219,33 @@
|
|||||||
color: rgba(84, 65, 38, 0.84);
|
color: rgba(84, 65, 38, 0.84);
|
||||||
line-height: 1.55;
|
line-height: 1.55;
|
||||||
}
|
}
|
||||||
|
.mapping-list {
|
||||||
|
display: grid;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
.mapping-card {
|
||||||
|
padding: 14px 16px;
|
||||||
|
border-radius: 18px;
|
||||||
|
background: rgba(255, 249, 239, 0.92);
|
||||||
|
border: 1px solid rgba(132, 102, 54, 0.12);
|
||||||
|
}
|
||||||
|
.mapping-card h3 {
|
||||||
|
margin: 0 0 8px;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
}
|
||||||
|
.mapping-card p {
|
||||||
|
margin: 8px 0 0;
|
||||||
|
color: rgba(98, 75, 42, 0.74);
|
||||||
|
line-height: 1.55;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.mapping-table-list {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
.preview-meta {
|
.preview-meta {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
@@ -345,6 +372,9 @@
|
|||||||
<p>
|
<p>
|
||||||
`원본 import 배치`는 업로드한 원본 파일이 몇 행으로 적재됐는지 보여주고, `바이너리 원본 보관`은 엑셀 같은 파일 자체를 DB에 보관하는 상태를 보여줍니다.
|
`원본 import 배치`는 업로드한 원본 파일이 몇 행으로 적재됐는지 보여주고, `바이너리 원본 보관`은 엑셀 같은 파일 자체를 DB에 보관하는 상태를 보여줍니다.
|
||||||
</p>
|
</p>
|
||||||
|
<p>
|
||||||
|
아래 표는 전체 테이블을 보여주고, 오른쪽 패널은 화면별 데이터 소스와 저장 흐름을 운영 관점으로 묶어서 보여줍니다.
|
||||||
|
</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section id="overview" class="overview"></section>
|
<section id="overview" class="overview"></section>
|
||||||
@@ -354,7 +384,7 @@
|
|||||||
<div class="panel-head">
|
<div class="panel-head">
|
||||||
<div>
|
<div>
|
||||||
<h2>전체 테이블 현황</h2>
|
<h2>전체 테이블 현황</h2>
|
||||||
<p>전체 27개 테이블을 보여주며, 테이블명을 누르면 샘플 row를 바로 확인할 수 있습니다.</p>
|
<p>현재 운영 DB의 전체 26개 테이블을 보여주며, 테이블명을 누르면 샘플 row를 바로 확인할 수 있습니다.</p>
|
||||||
</div>
|
</div>
|
||||||
<span id="generated-at" class="meta-chip">로딩 중</span>
|
<span id="generated-at" class="meta-chip">로딩 중</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -438,11 +468,24 @@
|
|||||||
<article class="panel">
|
<article class="panel">
|
||||||
<div class="panel-head">
|
<div class="panel-head">
|
||||||
<div>
|
<div>
|
||||||
<h2>테이블 분류</h2>
|
<h2>운영 분류</h2>
|
||||||
<p>유지/주의/원본·추적/정리 후보로 나눈 현재 기준입니다.</p>
|
<p>유지/주의/원본·추적/정리 후보 기준과 제품 관점 묶음을 같이 봅니다.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div id="group-summary" class="panel-body"></div>
|
<div class="panel-body">
|
||||||
|
<div id="group-summary"></div>
|
||||||
|
<div id="product-summary" style="margin-top:18px;"></div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="panel">
|
||||||
|
<div class="panel-head">
|
||||||
|
<div>
|
||||||
|
<h2>화면별 데이터 소스</h2>
|
||||||
|
<p>각 탭/기능이 실제로 어떤 테이블을 읽고 저장하는지 빠르게 확인합니다.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="screen-map" class="panel-body mapping-list"></div>
|
||||||
</article>
|
</article>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
@@ -619,6 +662,42 @@
|
|||||||
`).join("");
|
`).join("");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function renderProductSummary(summary) {
|
||||||
|
const target = document.getElementById("product-summary");
|
||||||
|
const groups = [
|
||||||
|
"탭 데이터",
|
||||||
|
"로그인·권한",
|
||||||
|
"히스토리",
|
||||||
|
"로우데이터·적재",
|
||||||
|
"보정·보조",
|
||||||
|
];
|
||||||
|
target.innerHTML = groups.map((label) => `
|
||||||
|
<div style="display:grid; gap:8px; margin-bottom:16px;">
|
||||||
|
<div><span class="group-tag keep">${escapeHtml(label)}</span></div>
|
||||||
|
<div class="view-list">
|
||||||
|
${((summary && summary[label]) || []).map((item) => `<span class="view-pill">${escapeHtml(item)}</span>`).join("") || '<span class="muted">없음</span>'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`).join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderScreenMap(items) {
|
||||||
|
const target = document.getElementById("screen-map");
|
||||||
|
if (!items || !items.length) {
|
||||||
|
target.innerHTML = '<div class="empty">화면별 데이터 소스 정보가 없습니다.</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
target.innerHTML = items.map((item) => `
|
||||||
|
<article class="mapping-card">
|
||||||
|
<h3>${escapeHtml(item.screen || "")}</h3>
|
||||||
|
<div class="mapping-table-list">
|
||||||
|
${(item.tables || []).map((table) => `<span class="view-pill">${escapeHtml(table)}</span>`).join("")}
|
||||||
|
</div>
|
||||||
|
<p>${escapeHtml(item.write_flow || "")}</p>
|
||||||
|
</article>
|
||||||
|
`).join("");
|
||||||
|
}
|
||||||
|
|
||||||
function renderTablePreview(payload) {
|
function renderTablePreview(payload) {
|
||||||
const previewModal = document.getElementById("preview-modal");
|
const previewModal = document.getElementById("preview-modal");
|
||||||
const previewMeta = document.getElementById("preview-meta");
|
const previewMeta = document.getElementById("preview-meta");
|
||||||
@@ -695,6 +774,8 @@
|
|||||||
renderBinarySources(payload.binary_sources || []);
|
renderBinarySources(payload.binary_sources || []);
|
||||||
renderNotes(payload.notes || []);
|
renderNotes(payload.notes || []);
|
||||||
renderGroupSummary(payload.group_summary || {});
|
renderGroupSummary(payload.group_summary || {});
|
||||||
|
renderProductSummary(payload.product_summary || {});
|
||||||
|
renderScreenMap(payload.screen_map || []);
|
||||||
}
|
}
|
||||||
|
|
||||||
document.getElementById("preview-close").addEventListener("click", () => {
|
document.getElementById("preview-close").addEventListener("click", () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user