# -*- coding: utf-8 -*-
"""
킨텍스 자동전시시스템 — DA(데이터 아키텍처) 산출물 생성기
  1) 데이터베이스설계서.pptx  (행안부 DB설계서 표준 톤 / 16:9)
  2) 테이블정의서.xlsx        (실제 Flyway DDL 역추출)

내용 소스(권위) = src/backend/.../db/migration/V1~V9 실제 DDL.
  → 테이블·컬럼·타입·NULL·PK/FK·기본값·인덱스·공간타입을 프로그램으로 파싱(수기 발명 금지).
  → 컬럼 설명은 DDL 인라인 주석·COMMENT ON 을 그대로 사용. 없으면 generic 표준 컬럼만 보강.

디자인 = KINTEX Blue #0066B3 / AI Purple #6D4AFF / 맑은 고딕. ERD·도식은 python-pptx 도형 직접 작도.
보안 불변 = 비밀번호/API키/서버IP/SSH 미기재. 암호화 컬럼은 정책만 기술(AES-256-GCM).

재실행: python gen_da_deliverables.py
"""
import os, re, glob

HERE = os.path.dirname(os.path.abspath(__file__))
OUT_DIR = os.path.dirname(HERE)                       # docs/deliverables/DA산출물
MIG_DIR = os.path.abspath(os.path.join(
    HERE, "..", "..", "..", "..", "src", "backend", "src", "main", "resources", "db", "migration"))
assert os.path.isdir(MIG_DIR), f"migration dir not found: {MIG_DIR}"

# ======================================================================
# 1. DDL 파서 (Flyway V1~V9 → 테이블 스키마 모델)
# ======================================================================
CONSTRAINT_STARTS = ("PRIMARY KEY", "UNIQUE", "FOREIGN KEY", "CHECK", "CONSTRAINT", "REFERENCES ")
GENERIC_DESC = {
    "id": "식별자(PK)", "created_at": "생성일시", "updated_at": "수정일시",
    "event_id": "행사 참조(FK event)", "status": "상태 코드", "sort_order": "정렬 순서",
    "use_yn": "사용 여부(Y/N)", "dept_id": "부서 식별자", "writer_id": "작성자",
    "writer_name": "작성자명", "author_id": "작성자", "author_name": "작성자명",
    "owner_id": "소유자", "owner_name": "소유자명", "title": "제목", "content": "내용",
    "read_at": "읽은 일시", "recipient_id": "수신자",
}

def _split_type(rest):
    """rest 문자열의 선두에서 데이터타입 토큰을 추출. geometry(Point,0)·numeric(8,2) 등 괄호 내부 콤마 허용."""
    m = re.match(r"\s*([A-Za-z_][A-Za-z0-9_]*(?:\([^)]*\))?)", rest)
    if not m:
        return "", rest
    return m.group(1), rest[m.end():]

def _parse_default(rest):
    m = re.search(r"\bDEFAULT\s+(now\(\)|gen_random_uuid\(\)|'[^']*'|true|false|[0-9]+\.?[0-9]*|[A-Za-z_][\w]*)",
                  rest, re.IGNORECASE)
    return m.group(1) if m else ""

def parse_ddl():
    tables = {}   # name -> {"cols":[...], "pk":set, "uniques":[...], "indexes":[...], "version":Vx, "order":int}
    order = 0
    files = sorted(glob.glob(os.path.join(MIG_DIR, "V*.sql")),
                   key=lambda p: int(re.match(r"V(\d+)", os.path.basename(p)).group(1)))
    comments = {}  # (table,col) -> desc  (COMMENT ON)
    for path in files:
        ver = re.match(r"(V\d+)", os.path.basename(path)).group(1)
        raw = open(path, encoding="utf-8").read()

        # COMMENT ON COLUMN t.c IS '...'
        for cm in re.finditer(r"COMMENT\s+ON\s+COLUMN\s+(\w+)\.(\w+)\s+IS\s+'((?:[^']|'')*)'", raw, re.IGNORECASE):
            comments[(cm.group(1), cm.group(2))] = cm.group(3).replace("''", "'")

        # CREATE TABLE IF NOT EXISTS name ( ... );
        for tm in re.finditer(r"CREATE\s+TABLE\s+IF\s+NOT\s+EXISTS\s+(\w+)\s*\((.*?)\n\)\s*;",
                              raw, re.IGNORECASE | re.DOTALL):
            tname, body = tm.group(1), tm.group(2)
            if tname not in tables:
                order += 1
                tables[tname] = {"cols": [], "pk": set(), "uniques": [], "indexes": [],
                                 "version": ver, "order": order}
            t = tables[tname]
            for line in body.split("\n"):
                code = line.split("--", 1)[0].strip()
                cmt = line.split("--", 1)[1].strip() if "--" in line else ""
                if not code:
                    continue
                code = code.rstrip(",").strip()
                up = code.upper()
                # 테이블 레벨 제약
                if up.startswith("PRIMARY KEY"):
                    for c in re.findall(r"\((.*?)\)", code)[0].split(","):
                        t["pk"].add(c.strip())
                    continue
                if up.startswith("UNIQUE"):
                    t["uniques"].append(re.findall(r"\((.*?)\)", code)[0].strip())
                    continue
                if up.startswith(CONSTRAINT_STARTS):
                    continue
                # 컬럼 정의
                cm2 = re.match(r"(\w+)\s+(.*)", code)
                if not cm2:
                    continue
                cname = cm2.group(1)
                dtype, rest = _split_type(cm2.group(2))
                ru = rest.upper()
                is_pk = "PRIMARY KEY" in ru
                if is_pk:
                    t["pk"].add(cname)
                fk = re.search(r"REFERENCES\s+(\w+)\s*\((\w+)\)", rest, re.IGNORECASE)
                nn = ("NOT NULL" in ru) or is_pk
                uniq = ("UNIQUE" in ru) and "PRIMARY KEY" not in ru
                dflt = _parse_default(rest)
                t["cols"].append({
                    "name": cname, "type": dtype,
                    "null": "N" if nn else "Y",
                    "pk": is_pk, "fk": (fk.group(1) if fk else None),
                    "fkcol": (fk.group(2) if fk else None),
                    "unique": uniq, "default": dflt, "desc": cmt,
                })

        # ALTER TABLE t ADD COLUMN IF NOT EXISTS c type ...
        for am in re.finditer(r"ALTER\s+TABLE\s+(\w+)\s+ADD\s+COLUMN\s+IF\s+NOT\s+EXISTS\s+(.+?);",
                              raw, re.IGNORECASE):
            tname = am.group(1)
            if tname not in tables:
                continue
            t = tables[tname]
            code = am.group(2).strip()
            cm2 = re.match(r"(\w+)\s+(.*)", code)
            if not cm2:
                continue
            cname = cm2.group(1)
            if any(c["name"] == cname for c in t["cols"]):
                continue
            dtype, rest = _split_type(cm2.group(2))
            ru = rest.upper()
            t["cols"].append({
                "name": cname, "type": dtype,
                "null": "N" if "NOT NULL" in ru else "Y",
                "pk": False, "fk": None, "fkcol": None,
                "unique": "UNIQUE" in ru, "default": _parse_default(rest),
                "desc": f"({ver} 순증 컬럼)",
            })

        # CREATE INDEX ... ON t ...
        for im in re.finditer(r"CREATE\s+INDEX\s+IF\s+NOT\s+EXISTS\s+(\w+)\s+ON\s+(\w+)\s*(.*?);",
                              raw, re.IGNORECASE | re.DOTALL):
            idxname, tname, spec = im.group(1), im.group(2), im.group(3).strip()
            if tname in tables:
                gist = "GIST" in spec.upper()
                cols = re.findall(r"\((.*?)\)", spec)
                tables[tname]["indexes"].append({
                    "name": idxname, "spec": (cols[0] if cols else spec),
                    "gist": gist,
                })

    # 설명 보강: COMMENT ON → generic
    for tname, t in tables.items():
        for c in t["cols"]:
            if not c["desc"]:
                c["desc"] = comments.get((tname, c["name"]), "")
            if not c["desc"] and c["name"] in GENERIC_DESC:
                c["desc"] = GENERIC_DESC[c["name"]]
    return tables

TABLES = parse_ddl()

# ---- 테이블 한글명 · 주제영역 매핑 (라벨링) ----------------------------------
KO = {
    "app_user": "사용자", "company": "등록업체", "event": "행사",
    "hall": "전시홀 마스터", "hall_assignment": "행사-홀 배정", "event_member": "행사 참여자(RBAC)",
    "booth_standard": "부스 표준사양", "master_data": "마스터데이터(요율·규정)",
    "trench": "트렌치(급전 포인트)", "hall_exit": "비상구", "layout": "배치안(버전)",
    "booth": "부스(폴리곤)", "design_plan": "부스 설계안", "utility_order": "유틸리티 신청",
    "render_job": "AI 렌더잡", "common_code_group": "공통코드 그룹", "common_code": "공통코드",
    "sys_menu": "시스템 메뉴", "sys_role": "역할", "sys_permission": "권한",
    "sys_role_permission": "역할-권한 매핑", "sys_setting": "시스템 설정", "audit_log": "감사 로그",
    "worklog": "업무일지", "schedule": "일정", "message": "쪽지",
    "message_recipient": "쪽지 수신자", "notice": "공지", "opinion": "의견",
    "opinion_comment": "의견 댓글", "meeting": "회의", "meeting_action": "회의 액션아이템",
    "report": "보고서", "notification": "알림", "password_reset": "비밀번호 재설정 코드",
}
# 주제영역(Subject Area): (코드, 한글, 테이블목록, 색상키)
AREAS = [
    ("MASTER",   "행사·조직·권한·마스터",
     ["event", "hall", "hall_assignment", "event_member", "app_user", "company",
      "booth_standard", "master_data"]),
    ("SPATIAL",  "공간·설계·시각화(부스 코어)",
     ["trench", "hall_exit", "layout", "booth", "design_plan", "utility_order", "render_job"]),
    ("SYSTEM",   "시스템관리·인증",
     ["common_code_group", "common_code", "sys_menu", "sys_role", "sys_permission",
      "sys_role_permission", "sys_setting", "audit_log", "password_reset"]),
    ("COMMON",   "공통 업무 모듈",
     ["worklog", "schedule", "message", "message_recipient", "notice", "opinion",
      "opinion_comment", "meeting", "meeting_action", "report", "notification"]),
]
AREA_OF = {}
for code, ko, tlist in AREAS:
    for tn in tlist:
        AREA_OF[tn] = ko

TOTAL_TABLES = len(TABLES)
TOTAL_COLS = sum(len(t["cols"]) for t in TABLES.values())
TOTAL_IDX = sum(len(t["indexes"]) for t in TABLES.values())
SPATIAL_COLS = [(tn, c["name"], c["type"]) for tn, t in TABLES.items()
                for c in t["cols"] if c["type"].lower().startswith("geometry")]
FK_EDGES = [(tn, c["name"], c["fk"]) for tn, t in TABLES.items()
            for c in t["cols"] if c["fk"]]

# ======================================================================
# 2. PPTX — 데이터베이스설계서
# ======================================================================
from pptx import Presentation
from pptx.util import Pt
from pptx.dml.color import RGBColor
from pptx.enum.text import PP_ALIGN, MSO_ANCHOR
from pptx.enum.shapes import MSO_SHAPE, MSO_CONNECTOR
from pptx.oxml.ns import qn

BLUE      = RGBColor(0x00, 0x66, 0xB3)
BLUE_DK   = RGBColor(0x00, 0x44, 0x7A)
BLUE_LT   = RGBColor(0xE1, 0xEF, 0xF9)
PURPLE    = RGBColor(0x6D, 0x4A, 0xFF)
PURPLE_LT = RGBColor(0xEC, 0xE8, 0xFF)
INK       = RGBColor(0x10, 0x18, 0x28)
MUTED     = RGBColor(0x66, 0x70, 0x85)
LINE      = RGBColor(0xE4, 0xE7, 0xEC)
BG        = RGBColor(0xF9, 0xFA, 0xFB)
WHITE     = RGBColor(0xFF, 0xFF, 0xFF)
GREEN     = RGBColor(0x12, 0x9E, 0x63)
AMBER     = RGBColor(0xE0, 0x8A, 0x00)
TEAL      = RGBColor(0x0E, 0x7C, 0x86)
CARD      = RGBColor(0xFF, 0xFF, 0xFF)
FONT = "맑은 고딕"
IN = 914400
def inch(v): return int(v * IN)

prs = Presentation()
prs.slide_width = 12192000
prs.slide_height = 6858000
BLANK = prs.slide_layouts[6]
def slide(): return prs.slides.add_slide(BLANK)

def _fill(sp, color):
    if color is None: sp.fill.background()
    else:
        sp.fill.solid(); sp.fill.fore_color.rgb = color

def rect(s, x, y, w, h, fill=None, line=None, line_w=1.0, shape=MSO_SHAPE.RECTANGLE, shadow=False):
    sp = s.shapes.add_shape(shape, inch(x), inch(y), inch(w), inch(h))
    _fill(sp, fill)
    if line is None: sp.line.fill.background()
    else:
        sp.line.color.rgb = line; sp.line.width = Pt(line_w)
    sp.shadow.inherit = False  # creates an empty <a:effectLst/>
    if shadow:
        el = sp._element.spPr
        ef = el.find(qn('a:effectLst'))
        if ef is None:
            ef = el.makeelement(qn('a:effectLst'), {}); el.append(ef)
        sh = ef.makeelement(qn('a:outerShdw'),
                            {'blurRad':'80000','dist':'30000','dir':'5400000','rotWithShape':'0'})
        clr = sh.makeelement(qn('a:srgbClr'), {'val':'101828'})
        alp = clr.makeelement(qn('a:alpha'), {'val':'16000'})
        clr.append(alp); sh.append(clr); ef.append(sh)
    return sp

def text(s, x, y, w, h, content, size=13, color=INK, bold=False, align=PP_ALIGN.LEFT,
         anchor=MSO_ANCHOR.TOP, line_spacing=1.0, space_after=2, wrap=True):
    tb = s.shapes.add_textbox(inch(x), inch(y), inch(w), inch(h))
    tf = tb.text_frame; tf.word_wrap = wrap; tf.vertical_anchor = anchor
    tf.margin_left = 0; tf.margin_right = 0; tf.margin_top = 0; tf.margin_bottom = 0
    if isinstance(content, str): content = [content]
    for i, para in enumerate(content):
        p = tf.paragraphs[0] if i == 0 else tf.add_paragraph()
        p.alignment = align; p.line_spacing = line_spacing
        p.space_after = Pt(space_after); p.space_before = Pt(0)
        segs = [{"t": para}] if isinstance(para, str) else ([para] if isinstance(para, dict) else para)
        for seg in segs:
            r = p.add_run(); r.text = seg.get("t", "")
            r.font.name = FONT; r.font.size = Pt(seg.get("size", size))
            r.font.bold = seg.get("bold", bold); r.font.color.rgb = seg.get("color", color)
            rPr = r._r.get_or_add_rPr()
            rPr.append(rPr.makeelement(qn('a:ea'), {'typeface': FONT}))
    return tb

def page_bg(s): rect(s, -0.1, -0.1, 13.53, 7.7, fill=BG)
def chip(s, x, y, w, h, label, fill, txtcolor=WHITE, size=9.5, bold=True):
    rect(s, x, y, w, h, fill=fill, shape=MSO_SHAPE.ROUNDED_RECTANGLE)
    text(s, x, y, w, h, label, size=size, color=txtcolor, bold=bold, align=PP_ALIGN.CENTER, anchor=MSO_ANCHOR.MIDDLE)
def card(s, x, y, w, h, fill=CARD, line=LINE):
    return rect(s, x, y, w, h, fill=fill, line=line, line_w=1.0, shape=MSO_SHAPE.ROUNDED_RECTANGLE, shadow=True)

def header(s, kicker, title, idx):
    page_bg(s)
    rect(s, 0, 0, 13.333, 0.12, fill=BLUE)
    rect(s, 0.55, 0.5, 0.62, 0.62, fill=BLUE, shape=MSO_SHAPE.ROUNDED_RECTANGLE)
    text(s, 0.55, 0.5, 0.62, 0.62, f"{idx:02d}", size=20, color=WHITE, bold=True, align=PP_ALIGN.CENTER, anchor=MSO_ANCHOR.MIDDLE)
    text(s, 1.32, 0.5, 10.5, 0.28, kicker, size=11, color=PURPLE, bold=True)
    text(s, 1.32, 0.74, 11.4, 0.44, title, size=22, color=INK, bold=True)
    rect(s, 0.55, 1.28, 12.23, 0.02, fill=LINE)
    text(s, 0.55, 7.06, 9, 0.3, "킨텍스 자동전시시스템 구축 · 데이터베이스 설계서", size=8.5, color=MUTED)
    text(s, 11.3, 7.06, 1.48, 0.3, "2026-07-11", size=8.5, color=MUTED, align=PP_ALIGN.RIGHT)

def bullets(s, x, y, w, items, size=12.5, gap=0.44, head=None):
    if head:
        text(s, x, y, w, 0.32, head, size=12, color=PURPLE, bold=True); y += 0.42
    for it in items:
        rect(s, x, y+0.07, 0.12, 0.12, fill=BLUE, shape=MSO_SHAPE.OVAL)
        if isinstance(it, tuple):
            text(s, x+0.28, y, w-0.28, gap, [[{"t": it[0]+"  ", "bold":True, "color":INK, "size":size},
                                             {"t": it[1], "color":MUTED, "size":size-1}]], line_spacing=1.05)
        else:
            text(s, x+0.28, y, w-0.28, gap, it, size=size, color=INK, line_spacing=1.05)
        y += gap
    return y

# ---- ERD 엔티티 박스 (파싱 데이터 구동) ----
def erd_entity(s, x, y, w, tname, maxrows=6, accent=BLUE):
    t = TABLES[tname]
    # 표시 컬럼: PK → FK → 대표 컬럼
    pk = [c for c in t["cols"] if c["pk"]]
    fk = [c for c in t["cols"] if c["fk"] and not c["pk"]]
    rest = [c for c in t["cols"] if not c["pk"] and not c["fk"]]
    show = (pk + fk + rest)[:maxrows]
    rh = 0.26
    h = 0.5 + len(show)*rh
    rect(s, x, y, w, h, fill=CARD, line=LINE, line_w=1.0, shape=MSO_SHAPE.ROUNDED_RECTANGLE, shadow=True)
    rect(s, x, y, w, 0.44, fill=accent, shape=MSO_SHAPE.ROUND_2_SAME_RECTANGLE)
    text(s, x+0.12, y, w-0.2, 0.44, [[{"t": tname, "size":10.5, "color":WHITE, "bold":True},
                                      {"t": "  "+KO.get(tname, ""), "size":8, "color":RGBColor(0xD6,0xE6,0xF6)}]],
         anchor=MSO_ANCHOR.MIDDLE)
    cy = y + 0.5
    for c in show:
        key = "PK" if c["pk"] else ("FK" if c["fk"] else "")
        kc = PURPLE if c["pk"] else (TEAL if c["fk"] else MUTED)
        geo = c["type"].lower().startswith("geometry")
        text(s, x+0.12, cy-0.03, 0.42, rh, key, size=7.5, color=kc, bold=True, anchor=MSO_ANCHOR.MIDDLE)
        text(s, x+0.54, cy-0.03, w-0.62, rh,
             [[{"t": c["name"], "size":8.6, "color":(INK if key else RGBColor(0x33,0x3B,0x4A)), "bold":bool(key)},
               {"t": " : "+c["type"], "size":7.6, "color":(GREEN if geo else MUTED)}]],
             anchor=MSO_ANCHOR.MIDDLE)
        cy += rh
    return {"x": x, "y": y, "w": w, "h": h, "cx": x+w/2, "cy": y+h/2}

def connect(s, a, b, color=RGBColor(0x9A,0xA6,0xB8)):
    cn = s.shapes.add_connector(MSO_CONNECTOR.STRAIGHT, inch(a["cx"]), inch(a["cy"]),
                                inch(b["cx"]), inch(b["cy"]))
    cn.line.color.rgb = color; cn.line.width = Pt(1.4)
    cn.shadow.inherit = False
    return cn

# ================= 슬라이드 1 : 표지 =================
s = slide()
rect(s, -0.1, -0.1, 13.53, 7.7, fill=BLUE_DK)
rect(s, 6.4, -1.5, 9, 11, fill=BLUE, shape=MSO_SHAPE.PARALLELOGRAM)
rect(s, 9.2, -2.0, 7, 12, fill=PURPLE, shape=MSO_SHAPE.PARALLELOGRAM)
rect(s, 0.85, 0.8, 0.42, 0.42, fill=WHITE, shape=MSO_SHAPE.OVAL)
rect(s, 0.97, 0.92, 0.18, 0.18, fill=PURPLE, shape=MSO_SHAPE.OVAL)
text(s, 1.42, 0.78, 6, 0.46, "KINTEX · WISE AI", size=15, color=WHITE, bold=True, anchor=MSO_ANCHOR.MIDDLE)
chip(s, 0.85, 2.35, 1.95, 0.42, "데이터베이스 설계서", PURPLE, WHITE, size=12)
chip(s, 2.92, 2.35, 2.2, 0.42, "Database Design", RGBColor(0x2B,0x4C,0x7E), WHITE, size=10.5)
text(s, 0.85, 2.95, 11, 1.9, [
    [{"t":"킨텍스 자동전시시스템", "size":44, "color":WHITE, "bold":True}],
    [{"t":"데이터베이스 설계서", "size":44, "color":WHITE, "bold":True}]], line_spacing=1.02)
text(s, 0.88, 4.7, 11, 0.5, "PostgreSQL 15 + PostGIS · Flyway 형상관리 · kintex 전용 DB",
     size=15.5, color=RGBColor(0xC7,0xDD,0xF2))
text(s, 0.88, 5.24, 11.4, 0.4,
     "공간데이터(부스 POLYGON · 트렌치 POINT · 배선 LINESTRING) 일원화 · 행사 단위 격리 · 감사추적",
     size=12, color=RGBColor(0xB9,0xC7,0xEA))
rect(s, 0.85, 6.05, 11.63, 0.02, fill=RGBColor(0x3A,0x5A,0x8C))
text(s, 0.85, 6.25, 8.6, 0.4, [[{"t":"산출물  ","size":11,"color":RGBColor(0x90,0xA6,0xC9)},
     {"t":f"개념·논리·물리 데이터 모델 / 테이블 {TOTAL_TABLES} · 컬럼 {TOTAL_COLS} · 인덱스 {TOTAL_IDX}","size":11,"color":WHITE,"bold":True}]])
text(s, 0.85, 6.62, 9.6, 0.4, [[{"t":"근거  ","size":11,"color":RGBColor(0x90,0xA6,0xC9)},
     {"t":"Flyway V1~V10 실제 DDL 역추출 (스키마 권위)","size":10.5,"color":WHITE}]])
text(s, 9.7, 6.25, 2.78, 0.4, "작성일 2026-07-11", size=11, color=WHITE, bold=True, align=PP_ALIGN.RIGHT)
text(s, 9.7, 6.62, 2.78, 0.4, "DA · kintex-da", size=9.5, color=RGBColor(0x90,0xA6,0xC9), align=PP_ALIGN.RIGHT)

# ================= 슬라이드 2 : 목차 =================
s = slide()
header(s, "CONTENTS", "목차", 0)
rect(s, 0.55, 0.5, 0.62, 0.62, fill=PURPLE, shape=MSO_SHAPE.ROUNDED_RECTANGLE)
text(s, 0.55, 0.5, 0.62, 0.62, "C", size=22, color=WHITE, bold=True, align=PP_ALIGN.CENTER, anchor=MSO_ANCHOR.MIDDLE)
toc = [
    ("01","개요","PostgreSQL+PostGIS · Flyway · kintex 전용 DB"),
    ("02","데이터 아키텍처 원칙","행사 단위 격리 · 공간타입 선정 · 감사로그"),
    ("03","개념 데이터 모델","핵심 주제영역 관계 개요도"),
    ("04","논리 모델(ERD)","주제영역별 엔터티·관계 ERD"),
    ("05","물리 모델 요약","테이블 그룹 · 공간컬럼 · 인덱스 전략"),
    ("06","데이터 표준","명명 규칙 · 공통코드 · 날짜/통화"),
    ("07","데이터 보안","암호화·PII·응답제외·감사추적"),
    ("08","백업·이관 전략","Flyway 마이그레이션 운영 원칙"),
]
cw = 5.85
for i,(no,t,d) in enumerate(toc):
    col = i // 4; row = i % 4
    x = 0.7 + col*(cw+0.35); y = 1.62 + row*1.28
    rect(s, x, y, cw, 1.1, fill=CARD, line=LINE, line_w=1.0, shape=MSO_SHAPE.ROUNDED_RECTANGLE)
    rect(s, x, y, 0.09, 1.1, fill=BLUE if col==0 else PURPLE, shape=MSO_SHAPE.ROUNDED_RECTANGLE)
    text(s, x+0.28, y, 1.0, 1.1, no, size=26, color=(BLUE if col==0 else PURPLE), bold=True, anchor=MSO_ANCHOR.MIDDLE)
    text(s, x+1.3, y+0.24, cw-1.5, 0.4, t, size=15, color=INK, bold=True)
    text(s, x+1.3, y+0.64, cw-1.5, 0.34, d, size=9.5, color=MUTED)

# ================= 슬라이드 3 : 01 개요 =================
s = slide()
header(s, "OVERVIEW", "01. 개요 — DBMS · 형상관리 · DB 구성", 1)
text(s, 0.55, 1.45, 12.2, 0.5,
     "킨텍스 자동전시시스템은 전시 생애주기(판매→AI 설계·시각화→공사 옥션→운영→경영분석) 전 과정을 단일 DB에 담는다. "
     "공간데이터를 애플리케이션과 분리하지 않고 PostGIS로 DB에 일원화하여 배치·배선·규정검증을 ST_* 연산으로 수행한다.",
     size=12, color=INK, line_spacing=1.12)
# 좌: 스택 카드
cards = [
    ("DBMS", "PostgreSQL 15", "관계형 + 트랜잭션 정합", BLUE),
    ("공간확장", "PostGIS", "POLYGON·POINT·LINESTRING 지오메트리", GREEN),
    ("암호화", "pgcrypto", "gen_random_uuid() · 해시", PURPLE),
    ("형상관리", "Flyway", "V1~V10 버전 마이그레이션(불변·순증)", AMBER),
]
for i,(k,v,d,c) in enumerate(cards):
    x = 0.55 + i*3.06
    card(s, x, 2.2, 2.86, 1.35)
    rect(s, x, 2.2, 2.86, 0.1, fill=c, shape=MSO_SHAPE.ROUNDED_RECTANGLE)
    text(s, x+0.22, 2.4, 2.5, 0.3, k, size=10, color=c, bold=True)
    text(s, x+0.22, 2.72, 2.5, 0.4, v, size=15, color=INK, bold=True)
    text(s, x+0.22, 3.16, 2.5, 0.34, d, size=9, color=MUTED, line_spacing=1.05)
# 통계 스트립
stats = [(f"{TOTAL_TABLES}", "테이블"), (f"{TOTAL_COLS}", "컬럼"), (f"{len(AREAS)}", "주제영역"),
         (f"{len(SPATIAL_COLS)}", "공간 컬럼"), (f"{TOTAL_IDX}", "인덱스"), ("V1–V10", "Flyway")]
for i,(v,l) in enumerate(stats):
    x = 0.55 + i*2.03
    rect(s, x, 3.85, 1.9, 1.0, fill=BLUE_LT, line=LINE, shape=MSO_SHAPE.ROUNDED_RECTANGLE)
    text(s, x, 4.0, 1.9, 0.44, v, size=23, color=BLUE_DK, bold=True, align=PP_ALIGN.CENTER)
    text(s, x, 4.5, 1.9, 0.3, l, size=10, color=MUTED, align=PP_ALIGN.CENTER)
# DB 구성 원칙
bullets(s, 0.55, 5.15, 12.2, [
    ("kintex 전용 DB", "GUARDiA 타 솔루션과 물리 DB 분리(kintex 전용 스키마). 접속·권한은 서비스 계정 최소권한."),
    ("공간·업무 단일 저장", "부스 폴리곤·배선 라인·요율·관람 데이터를 한 트랜잭션 경계에서 일관 관리."),
    ("멱등·순증 마이그레이션", "V7 이후는 CREATE IF NOT EXISTS·ADD COLUMN IF NOT EXISTS로 재적용 안전(운영 무중단)."),
], size=11.5, gap=0.5)

# ================= 슬라이드 4 : 02 아키텍처 원칙 =================
s = slide()
header(s, "PRINCIPLES", "02. 데이터 아키텍처 원칙", 2)
princ = [
    ("행사 단위 격리(멀티테넌시)", BLUE,
     ["물리 tenant_id 컬럼 대신 event_id + event_member(RBAC)로 논리 격리",
      "핵심 트랜잭션 테이블은 event_id FK·ON DELETE CASCADE로 행사 경계 정합",
      "hall·company·master_data 등 마스터는 전 행사 공유(참조 무결성)"]),
    ("공간데이터 PostGIS 일원화", GREEN,
     ["부스=POLYGON, 트렌치·비상구=POINT, 배선=MultiLineString (SRID 0 홀 로컬 m)",
      "GiST 공간 인덱스로 ST_Intersects/Area/Distance 고속 연산",
      "배치·배선 재저장 대비 booth_id 소프트 참조(감사·쿼터 보존)"]),
    ("감사 추적·이력 보존", PURPLE,
     ["audit_log에 actor·action·target·ruleset_version·result 기록",
      "render_job은 이력·쿼터 정본(부스 교체와 무관하게 감사 보존)",
      "layout·design_plan·booth_standard 버전(version) 관리"]),
    ("보안 내재화", AMBER,
     ["password_hash·otp_secret·code_hash 컬럼은 API 응답/로그 완전 제외",
      "민감 시크릿 설정은 sys_setting.secret_yn='Y'로 마스킹",
      "error_message는 사용자 요약만(스택트레이스 저장 금지)"]),
]
for i,(title,c,items) in enumerate(princ):
    x = 0.55 + (i%2)*6.15; y = 1.5 + (i//2)*2.6
    card(s, x, y, 5.95, 2.4)
    rect(s, x, y, 0.11, 2.4, fill=c, shape=MSO_SHAPE.ROUNDED_RECTANGLE)
    text(s, x+0.35, y+0.2, 5.4, 0.4, title, size=14, color=INK, bold=True)
    yy = y+0.72
    for it in items:
        rect(s, x+0.4, yy+0.06, 0.1, 0.1, fill=c, shape=MSO_SHAPE.OVAL)
        text(s, x+0.62, yy, 5.15, 0.5, it, size=10.3, color=MUTED, line_spacing=1.05)
        yy += 0.52

# ================= 슬라이드 5 : 03 개념 데이터 모델 =================
s = slide()
header(s, "CONCEPTUAL", "03. 개념 데이터 모델 — 주제영역 관계 개요", 3)
text(s, 0.55, 1.42, 12.2, 0.4,
     "구현된 4개 주제영역과 소유 데이터의 관계. 행사(EVENT)를 축으로 공간·설계 코어와 공통·시스템 레이어가 결합한다.",
     size=11.5, color=MUTED)
# 중앙 EVENT 허브
hub = {"cx":6.6, "cy":3.9}
rect(s, 5.75, 3.3, 1.7, 1.2, fill=BLUE_DK, shape=MSO_SHAPE.ROUNDED_RECTANGLE, shadow=True)
text(s, 5.75, 3.3, 1.7, 1.2, [[{"t":"EVENT","size":15,"color":WHITE,"bold":True}],
     [{"t":"행사(테넌트 축)","size":9,"color":RGBColor(0xC7,0xDD,0xF2)}]], align=PP_ALIGN.CENTER, anchor=MSO_ANCHOR.MIDDLE)
sa = [
    ("MASTER","행사·조직·권한·마스터","event · hall · company · event_member",8,BLUE, 0.7, 1.95),
    ("SPATIAL","공간·설계·시각화","layout · booth · utility_order · render_job",7,GREEN, 9.35, 1.95),
    ("COMMON","공통 업무 모듈","worklog · schedule · notice · meeting …",11,PURPLE, 0.7, 4.75),
    ("SYSTEM","시스템관리·인증","sys_* · common_code · audit_log · password_reset",9,AMBER, 9.35, 4.75),
]
for code,ko,ex,n,c,x,y in sa:
    # 연결선 (먼저)
    box_cx = x+1.6; box_cy = y+0.75
    cn = s.shapes.add_connector(MSO_CONNECTOR.STRAIGHT, inch(box_cx), inch(box_cy), inch(hub["cx"]), inch(hub["cy"]))
    cn.line.color.rgb = RGBColor(0xC2,0xCC,0xDA); cn.line.width = Pt(1.6); cn.shadow.inherit=False
for code,ko,ex,n,c,x,y in sa:
    card(s, x, y, 3.2, 1.5)
    rect(s, x, y, 3.2, 0.12, fill=c, shape=MSO_SHAPE.ROUNDED_RECTANGLE)
    text(s, x+0.24, y+0.24, 2.7, 0.34, ko, size=13, color=INK, bold=True)
    chip(s, x+2.45, y+0.24, 0.62, 0.3, f"{n}T", c, WHITE, size=9)
    text(s, x+0.24, y+0.68, 2.9, 0.7, ex, size=9.3, color=MUTED, line_spacing=1.08)
text(s, 0.55, 6.5, 12.2, 0.4,
     "관계 요지: EVENT 1─N HallAssignment/EventMember/Layout/Notice/Worklog(event_id). Layout 1─N Booth(POLYGON) 1─N DesignPlan·UtilityOrder(배선)·RenderJob. Company·User ─ EventMember로 행사 RBAC 성립.",
     size=9.8, color=INK, line_spacing=1.1)

# ================= 슬라이드 6~9 : 04 논리 모델 ERD (주제영역별) =================
def erd_slide(area_ko, idx, placements, extra_note, cross_notes=None):
    s = slide()
    header(s, "LOGICAL ERD", f"04. 논리 모델 — {area_ko}", 4)
    # 연결선 먼저(엔티티 아래로 깔림)
    boxes = {}
    # 1차: 좌표만 계산해 중심 확보
    for tn, x, y, w in placements:
        t = TABLES[tn]
        pk=[c for c in t["cols"] if c["pk"]]; fk=[c for c in t["cols"] if c["fk"] and not c["pk"]]
        rest=[c for c in t["cols"] if not c["pk"] and not c["fk"]]
        show=(pk+fk+rest); show=show[:6]
        h = 0.5+len(show)*0.26
        boxes[tn]={"x":x,"y":y,"w":w,"h":h,"cx":x+w/2,"cy":y+h/2}
    for tn,_,_,_ in placements:
        for c in TABLES[tn]["cols"]:
            if c["fk"] and c["fk"] in boxes and c["fk"]!=tn:
                connect(s, boxes[tn], boxes[c["fk"]])
    accents={"MASTER":BLUE,"SPATIAL":GREEN,"SYSTEM":AMBER,"COMMON":PURPLE}
    acc = accents.get(idx, BLUE)
    for tn,x,y,w in placements:
        erd_entity(s, x, y, w, tn, accent=acc)
    if extra_note:
        text(s, 0.55, 6.78, 12.2, 0.3, extra_note, size=9, color=MUTED, line_spacing=1.05)
    return s

# ERD-1 MASTER
erd_slide("행사·조직·권한·마스터", "MASTER", [
    ("event",         5.6, 1.5,  2.5),
    ("hall",          9.9, 1.5,  2.8),
    ("hall_assignment", 9.9, 4.05, 2.8),
    ("event_member",  5.6, 3.7,  2.85),
    ("app_user",      0.6, 1.5,  2.75),
    ("company",       0.6, 4.55, 2.75),
], "관계: event 1─N hall_assignment·event_member. app_user·company ─ event_member(행사 RBAC). 독립 마스터 booth_standard·master_data 는 물리 모델 목록(슬라이드 10) 참조.")

# ERD-2 SPATIAL
erd_slide("공간·설계·시각화(부스 코어)", "SPATIAL", [
    ("hall",          0.6, 1.5,  2.5),
    ("trench",        0.6, 4.2,  2.5),
    ("hall_exit",     3.45, 4.2, 2.4),
    ("layout",        3.6, 1.5,  2.5),
    ("booth",         6.55, 1.5, 2.6),
    ("design_plan",   9.65, 1.5, 2.7),
    ("utility_order", 6.55, 4.35,2.9),
    ("render_job",    9.85, 4.35,2.75),
], "관계: hall 1─N trench·hall_exit·layout. layout 1─N booth(POLYGON) 1─N design_plan. booth ─ utility_order(배선 LineString)·render_job(AI 샷). booth_id 소프트 참조.")

# ERD-3 SYSTEM
erd_slide("시스템관리·인증", "SYSTEM", [
    ("common_code_group", 0.6, 1.5, 2.7),
    ("common_code",       0.6, 4.0, 2.7),
    ("sys_role",          3.6, 1.5, 2.6),
    ("sys_role_permission",3.6,4.0, 2.75),
    ("sys_permission",    6.55, 1.5, 2.6),
    ("sys_menu",          9.6, 1.5, 2.8),
    ("sys_setting",       6.55, 4.05, 2.6),
    ("audit_log",         9.6, 4.05, 2.8),
], "관계: common_code_group 1─N common_code. sys_role ─ sys_role_permission ─ sys_permission(M:N). sys_menu 자기참조(parent_id). password_reset(공개 인증)은 별도. audit_log 전 모듈 공유.")

# ERD-4 COMMON
s = slide()
header(s, "LOGICAL ERD", "04. 논리 모델 — 공통 업무 모듈", 4)
# 부모-자식 3쌍 + 독립 목록
pairs = [("message","message_recipient"),("opinion","opinion_comment"),("meeting","meeting_action")]
px = 0.6
for i,(p,ch) in enumerate(pairs):
    x = 0.6 + i*4.15
    bp = erd_entity(s, x, 1.5, 3.0, p, accent=PURPLE)
    bc = erd_entity(s, x+0.3, 3.9, 3.0, ch, accent=RGBColor(0x8B,0x6B,0xE0))
# 연결(부모→자식) 먼저 그리지 못했으니 라벨로 대체
text(s, 0.6, 6.05, 11.9, 0.3,
     "부모 1─N 자식(FK·ON DELETE CASCADE): message→message_recipient · opinion→opinion_comment · meeting→meeting_action",
     size=9.5, color=TEAL, bold=True)
# 독립 테이블 스트립
indep = ["worklog","schedule","notice","report","notification"]
text(s, 0.6, 6.42, 3, 0.3, "행사 스코프 독립 테이블", size=10, color=MUTED, bold=True)
for i,tn in enumerate(indep):
    x = 3.0 + i*1.85
    rect(s, x, 6.4, 1.75, 0.4, fill=BLUE_LT, line=LINE, shape=MSO_SHAPE.ROUNDED_RECTANGLE)
    text(s, x, 6.4, 1.75, 0.4, tn, size=9.5, color=BLUE_DK, bold=True, align=PP_ALIGN.CENTER, anchor=MSO_ANCHOR.MIDDLE)

# ================= 슬라이드 10 : 05 물리 모델 요약 (테이블 그룹) =================
s = slide()
header(s, "PHYSICAL", "05. 물리 모델 요약 — 테이블 그룹", 5)
col_x = [0.55, 6.7]
for gi,(code,ko,tlist) in enumerate(AREAS):
    x = col_x[gi%2]; y = 1.5 + (gi//2)*2.65
    card(s, x, y, 5.9, 2.45)
    acc = {"MASTER":BLUE,"SPATIAL":GREEN,"SYSTEM":AMBER,"COMMON":PURPLE}[code]
    rect(s, x, y, 5.9, 0.5, fill=acc, shape=MSO_SHAPE.ROUND_2_SAME_RECTANGLE)
    text(s, x+0.25, y, 4.8, 0.5, ko, size=13, color=WHITE, bold=True, anchor=MSO_ANCHOR.MIDDLE)
    text(s, x+5.0, y, 0.7, 0.5, f"{len(tlist)}T", size=13, color=WHITE, bold=True, align=PP_ALIGN.RIGHT, anchor=MSO_ANCHOR.MIDDLE)
    yy = y+0.62
    for j,tn in enumerate(tlist):
        cx = x+0.25 + (j%2)*2.9
        cyy = yy + (j//2)*0.315
        text(s, cx, cyy, 2.85, 0.3, [[{"t":"· ","color":acc,"bold":True,"size":10},
             {"t":tn,"size":9.3,"color":INK,"bold":True},
             {"t":"  "+KO.get(tn,""),"size":7.8,"color":MUTED}]], anchor=MSO_ANCHOR.MIDDLE)

# ================= 슬라이드 11 : 05b 공간 컬럼·인덱스 전략 =================
s = slide()
header(s, "PHYSICAL — SPATIAL & INDEX", "05. 공간 컬럼 · 인덱스 전략", 5)
# 공간 컬럼 표
text(s, 0.55, 1.45, 6, 0.32, "공간(PostGIS) 컬럼", size=13, color=GREEN, bold=True)
sp_rows = [("테이블","컬럼","지오메트리","용도")]
sp_meta = {
    ("trench","geom"):"급전/급수/네트워크 공급 포인트",
    ("hall_exit","geom"):"비상구 위치(이격 버퍼 검증)",
    ("booth","geom"):"부스 경계 폴리곤(면적·간섭)",
    ("utility_order","wiring"):"전기/네트워크/급배수 배선 경로",
}
for tn,cn,ty in SPATIAL_COLS:
    sp_rows.append((tn, cn, ty, sp_meta.get((tn,cn),"")))
ry = 1.85
for i,row in enumerate(sp_rows):
    fill = BLUE if i==0 else (WHITE if i%2 else BLUE_LT)
    tc = WHITE if i==0 else INK
    rect(s, 0.55, ry, 6.1, 0.4, fill=fill, line=LINE)
    xs=[0.65,2.05,3.35,4.75]; ws=[1.35,1.25,1.4,1.85]
    for k,val in enumerate(row):
        text(s, xs[k], ry, ws[k], 0.4, val, size=8.6, color=(tc if k<3 else (tc if i==0 else MUTED)),
             bold=(i==0 or k==0), anchor=MSO_ANCHOR.MIDDLE)
    ry += 0.4
text(s, 0.55, ry+0.1, 6.1, 0.6,
     "SRID 0 = 홀 로컬 평면(미터). ST_Area/Distance/Length 결과 단위 = m·㎡.",
     size=9, color=MUTED, line_spacing=1.1)
# 인덱스 전략
text(s, 6.95, 1.45, 5.8, 0.32, "인덱스 전략", size=13, color=BLUE, bold=True)
idx_items = [
    ("GiST 공간 인덱스", f"geometry 컬럼 4종(booth·trench·hall_exit·utility_order.wiring) 전부 GiST"),
    ("B-Tree 조회 인덱스", "FK·정렬 축: writer_id+work_date, recipient_id, event_id 등"),
    ("복합 인덱스", "render_job(booth_id,shot_preset) · (event_id,status) 쿼터·상태 조회"),
    ("정렬 최적화", "audit_log(created_at DESC) · notice(event_id,pinned DESC,created_at DESC)"),
    (f"총 인덱스 {TOTAL_IDX}개", "PK 자동 인덱스 별도 · 공간 4 · B-Tree 다수"),
]
yy = 1.9
for k,v in idx_items:
    card(s, 6.95, yy, 5.75, 0.78)
    text(s, 7.15, yy+0.12, 5.4, 0.3, k, size=11, color=INK, bold=True)
    text(s, 7.15, yy+0.44, 5.4, 0.3, v, size=8.8, color=MUTED, line_spacing=1.05)
    yy += 0.9

# ================= 슬라이드 12 : 06 데이터 표준 =================
s = slide()
header(s, "STANDARDS", "06. 데이터 표준 — 명명 · 코드 · 날짜/통화", 6)
std = [
    ("명명 규칙(실제 DDL 관찰)", BLUE, [
        "테이블·컬럼 = snake_case 단수형 (app_user, booth, render_job)",
        "PK = id (varchar 애플리케이션 채번) / 대량 로그 = bigserial (audit_log·password_reset)",
        "FK = <참조테이블>_id (event_id, booth_id, hall_id, layout_id)",
        "불리언 = is_/has_/*_yn 혼용 · 금액/치수 = numeric(정밀도,스케일)",
        "타임스탬프 = *_at (timestamptz) / 날짜 = *_date (date)",
    ]),
    ("공통코드 체계(common_code)", PURPLE, [
        "grp_code(그룹) 1─N code — 14개 그룹 시드(USE_YN·USER_ROLE·WORK_STATUS …)",
        "화면 드롭다운·상태값을 코드로 표준화 (하드코딩 배제)",
        "정렬 sort_order · 사용여부 use_yn(Y/N) · 확장 attr1",
    ]),
    ("날짜·통화·단위 표준", GREEN, [
        "일시 = timestamptz(UTC 저장, 표시 KST) · 날짜 = date",
        "통화 = KRW 정수(원) 원칙, 요율/정산은 numeric — 부동소수 금지",
        "치수 = 미터(m)·면적 ㎡ · 하중 t/㎡ (numeric 고정 스케일)",
    ]),
]
yy = 1.5
for title,c,items in std:
    h = 0.5 + len(items)*0.34 + 0.15
    card(s, 0.55, yy, 12.2, h)
    rect(s, 0.55, yy, 0.11, h, fill=c, shape=MSO_SHAPE.ROUNDED_RECTANGLE)
    text(s, 0.85, yy+0.16, 11.6, 0.34, title, size=13.5, color=INK, bold=True)
    iy = yy+0.6
    for it in items:
        rect(s, 0.95, iy+0.06, 0.09, 0.09, fill=c, shape=MSO_SHAPE.OVAL)
        text(s, 1.14, iy, 11.4, 0.32, it, size=10.3, color=MUTED)
        iy += 0.34
    yy += h + 0.16

# ================= 슬라이드 13 : 07 데이터 보안 =================
s = slide()
header(s, "SECURITY", "07. 데이터 보안 — 암호화 · PII · 응답제외 · 감사", 7)
# 응답 제외/암호화 컬럼 표
text(s, 0.55, 1.45, 7, 0.32, "민감 컬럼 처리 정책", size=13, color=BLUE, bold=True)
sec_rows = [("테이블.컬럼","정책")]
sec_rows += [
    ("app_user.password_hash","BCrypt 해시 · API 응답/로그 완전 제외"),
    ("app_user.otp_secret","TOTP 시크릿 · AES-256-GCM 암호화 저장 · 응답 제외"),
    ("password_reset.code_hash","BCrypt(6자리) · 만료 10분·1회성 · 응답/로그 제외"),
    ("sys_setting.secret_yn='Y'","시크릿 설정값 마스킹 반환"),
    ("render_job.error_message","사용자 요약만 · 스택트레이스 저장 금지"),
    ("booth.assigned_company_name","표시용(내부 식별자·민감정보 미포함)"),
]
ry = 1.85
for i,(a,b) in enumerate(sec_rows):
    fill = BLUE if i==0 else (WHITE if i%2 else BLUE_LT)
    tc = WHITE if i==0 else INK
    rect(s, 0.55, ry, 6.5, 0.52, fill=fill, line=LINE)
    text(s, 0.68, ry, 2.75, 0.52, a, size=8.4, color=tc, bold=(i==0), anchor=MSO_ANCHOR.MIDDLE)
    text(s, 3.5, ry, 3.45, 0.52, b, size=8.3, color=(tc if i==0 else MUTED), bold=(i==0),
         anchor=MSO_ANCHOR.MIDDLE, line_spacing=1.0)
    ry += 0.52
# 우측 원칙 카드
text(s, 7.3, 1.45, 5.4, 0.32, "보안 불변 원칙", size=13, color=PURPLE, bold=True)
sec_cards = [
    ("PII 최소화·격리", "관람/리드 PII 집중영역 접근 제한 · BI 마트는 집계/익명키만 반입(개인식별자 0)"),
    ("암호화 저장", "비밀번호·OTP·재설정코드는 해시/AES-256-GCM · 평문 저장 금지"),
    ("API 응답 스키마 통제", "ServerOut류 민감 컬럼 응답 완전 제외 · 스택트레이스 미노출"),
    ("감사 추적", "audit_log(actor·action·target·ruleset_version·result·ip_hint) 전 변경 기록"),
    ("행사 격리 접근제어", "event_member 역할(ORGANIZER·EXHIBITOR·CONTRACTOR·HALL_MANAGER) 스코프"),
]
yy = 1.85
for k,v in sec_cards:
    card(s, 7.3, yy, 5.45, 0.92)
    rect(s, 7.3, yy, 0.09, 0.92, fill=PURPLE, shape=MSO_SHAPE.ROUNDED_RECTANGLE)
    text(s, 7.55, yy+0.13, 5.1, 0.3, k, size=11, color=INK, bold=True)
    text(s, 7.55, yy+0.45, 5.1, 0.42, v, size=8.7, color=MUTED, line_spacing=1.05)
    yy += 1.02

# ================= 슬라이드 14 : 08 백업·이관 전략 =================
s = slide()
header(s, "BACKUP & MIGRATION", "08. 백업 · 이관 전략 (Flyway 형상관리)", 8)
mig = [
    ("Flyway 버전 마이그레이션", BLUE, [
        "V1 확장 → V2 신원·마스터 → V3 공간코어 → V4~V6 시드 → V7 시스템관리 → V8 공통업무 → V9 공개인증 → V10 10년 시드",
        "적용 이력은 flyway_schema_history 테이블에 체크섬으로 기록·검증",
        "V1~V6 불변 원칙 · V7 이후 순증(additive)·멱등만 수행(운영 무중단)",
    ]),
    ("백업 전략", GREEN, [
        "일 1회 논리 백업(pg_dump) + WAL 아카이브 기반 PITR(시점복구)",
        "PostGIS 지오메트리·공간 인덱스 포함 전체 스키마 백업",
        "오브젝트 스토리지(렌더 이미지·도면)는 서명·만료 URL로 별도 보존",
    ]),
    ("이관·복구 절차", PURPLE, [
        "복구 = 최신 백업 복원 → Flyway migrate로 최신 버전까지 재적용(정본 순서 보장)",
        "환경 승격(dev→운영): 동일 마이그레이션 스크립트 적용으로 스키마 동형 보장",
        "시드(V4~V6·V10)와 스키마(V1~V3·V7~V9) 분리로 데이터 오염 없이 재구축",
    ]),
]
yy = 1.5
for title,c,items in mig:
    h = 0.5 + len(items)*0.4 + 0.1
    card(s, 0.55, yy, 12.2, h)
    rect(s, 0.55, yy, 0.11, h, fill=c, shape=MSO_SHAPE.ROUNDED_RECTANGLE)
    text(s, 0.85, yy+0.16, 11.6, 0.34, title, size=13.5, color=INK, bold=True)
    iy = yy+0.62
    for it in items:
        rect(s, 0.95, iy+0.07, 0.09, 0.09, fill=c, shape=MSO_SHAPE.OVAL)
        text(s, 1.14, iy, 11.4, 0.38, it, size=10.2, color=MUTED, line_spacing=1.08)
        iy += 0.4
    yy += h + 0.16
text(s, 0.55, 6.75, 12.2, 0.3,
     "보안 불변: 본 설계서·백업 매뉴얼에 비밀번호·API키·서버 IP·SSH 접속정보를 기재하지 않는다.",
     size=9, color=MUTED, bold=True)

# ---- 저장 ----
PPTX_PATH = os.path.join(OUT_DIR, "데이터베이스설계서.pptx")
prs.save(PPTX_PATH)
N_SLIDES = len(prs.slides._sldIdLst)

# ======================================================================
# 3. XLSX — 테이블정의서
# ======================================================================
import openpyxl
from openpyxl.styles import Font, PatternFill, Alignment, Border, Side
from openpyxl.utils import get_column_letter

BLUE_HEX = "0066B3"; BLUEDK_HEX = "00447A"; BAND_HEX = "E1EFF9"; ROW_HEX = "F4F8FC"
thin = Side(style="thin", color="D0D7E2")
BORDER = Border(left=thin, right=thin, top=thin, bottom=thin)
HFONT = Font(name="맑은 고딕", size=10, bold=True, color="FFFFFF")
BFONT = Font(name="맑은 고딕", size=10)
BOLD = Font(name="맑은 고딕", size=10, bold=True, color="101828")
TITLE_FONT = Font(name="맑은 고딕", size=11, bold=True, color="FFFFFF")
HEAD_FILL = PatternFill("solid", fgColor=BLUE_HEX)
BAND_FILL = PatternFill("solid", fgColor=BLUEDK_HEX)
ALT_FILL = PatternFill("solid", fgColor=ROW_HEX)
CEN = Alignment(horizontal="center", vertical="center", wrap_text=True)
LEFT = Alignment(horizontal="left", vertical="center", wrap_text=True)

wb = openpyxl.Workbook()

# ---- 시트1: 테이블목록 ----
ws = wb.active; ws.title = "테이블목록"
ws.sheet_view.showGridLines = False
cols1 = [("No",6),("테이블명",22),("한글명",24),("주제영역",24),("Flyway",10),("컬럼수",8),("비고",40)]
for j,(h,w) in enumerate(cols1, start=1):
    c = ws.cell(1, j, h); c.font=HFONT; c.fill=HEAD_FILL; c.alignment=CEN; c.border=BORDER
    ws.column_dimensions[get_column_letter(j)].width = w
ordered = sorted(TABLES.items(), key=lambda kv: kv[1]["order"])
for i,(tn,t) in enumerate(ordered, start=1):
    note = []
    if any(c["type"].lower().startswith("geometry") for c in t["cols"]): note.append("PostGIS 공간")
    if t["pk"] and len(t["pk"])>1: note.append("복합 PK("+",".join(sorted(t["pk"]))+")")
    if t["uniques"]: note.append("UNIQUE("+"; ".join(t["uniques"])+")")
    if any(c["name"] in ("password_hash","otp_secret","code_hash") for c in t["cols"]): note.append("민감:응답제외")
    row = [i, tn, KO.get(tn,""), AREA_OF.get(tn,""), t["version"], len(t["cols"]), " · ".join(note)]
    for j,val in enumerate(row, start=1):
        c = ws.cell(i+1, j, val)
        c.font = BOLD if j==2 else BFONT
        c.alignment = CEN if j in (1,5,6) else LEFT
        c.border = BORDER
        if i%2==0: c.fill = ALT_FILL
ws.freeze_panes = "A2"
ws.auto_filter.ref = f"A1:G{len(ordered)+1}"

# ---- 주제영역별 시트 ----
DATA_COLS = [("No",5),("컬럼명",26),("데이터타입",20),("NULL",7),("키",13),("기본값",18),("설명",56)]
def key_str(c):
    parts=[]
    if c["pk"]: parts.append("PK")
    if c["fk"]: parts.append(f"FK→{c['fk']}")
    if c["unique"]: parts.append("U")
    return " ".join(parts)

for code, ko, tlist in AREAS:
    title = ko.split("(")[0].strip()
    sname = {"MASTER":"마스터·조직·권한","SPATIAL":"공간·설계·시각화",
             "SYSTEM":"시스템관리·인증","COMMON":"공통업무"}[code]
    ws = wb.create_sheet(sname)
    ws.sheet_view.showGridLines = False
    for j,(h,w) in enumerate(DATA_COLS, start=1):
        c = ws.cell(1, j, h); c.font=HFONT; c.fill=HEAD_FILL; c.alignment=CEN; c.border=BORDER
        ws.column_dimensions[get_column_letter(j)].width = w
    r = 2
    ncol = len(DATA_COLS)
    for tn in tlist:
        t = TABLES[tn]
        # 밴드 행(테이블 헤더)
        ws.merge_cells(start_row=r, start_column=1, end_row=r, end_column=ncol)
        bc = ws.cell(r, 1, f"◆ {tn}  —  {KO.get(tn,'')}   [{t['version']}]"
                     + ("   · 인덱스 "+", ".join(ix["name"] for ix in t["indexes"]) if t["indexes"] else ""))
        bc.font = TITLE_FONT; bc.fill = BAND_FILL; bc.alignment = LEFT
        for j in range(1, ncol+1):
            ws.cell(r, j).border = BORDER
            ws.cell(r, j).fill = BAND_FILL
        r += 1
        for k,c in enumerate(t["cols"], start=1):
            vals = [k, c["name"], c["type"], c["null"], key_str(c), c["default"], c["desc"]]
            for j,val in enumerate(vals, start=1):
                cc = ws.cell(r, j, val)
                cc.font = BOLD if (j==2 and c["pk"]) else BFONT
                cc.alignment = CEN if j in (1,4) else LEFT
                cc.border = BORDER
                if k%2==0: cc.fill = ALT_FILL
                if j==5 and c["fk"]:
                    cc.font = Font(name="맑은 고딕", size=9, color="0E7C86", bold=True)
            r += 1
        r += 1  # 테이블 간 공백
    ws.freeze_panes = "A2"

XLSX_PATH = os.path.join(OUT_DIR, "테이블정의서.xlsx")
wb.save(XLSX_PATH)

print("=" * 60)
print(f"[PPTX] {PPTX_PATH}")
print(f"       슬라이드 {N_SLIDES}장")
print(f"[XLSX] {XLSX_PATH}")
print(f"       시트 {len(wb.sheetnames)} : {wb.sheetnames}")
print(f"[SCHEMA] 테이블 {TOTAL_TABLES} · 컬럼 {TOTAL_COLS} · 인덱스 {TOTAL_IDX} · 공간컬럼 {len(SPATIAL_COLS)} · FK {len(FK_EDGES)}")
print("=" * 60)
