# -*- 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 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)