# -*- coding: utf-8 -*-
"""
킨텍스 자동전시시스템 구축 — 프로그램사양서 PPTX 생성기
- 소스: docs/PLANNING.md(v3.0 M1~M18 + §5B 공통레이어), docs/design.md(§2.8 화면 80종 SCR),
        docs/architecture/app.md(응답 봉투·에러·감사로그), IMPLEMENTATION_BACKLOG.md
- 디자인: 개발계획서(gen_dev_plan_pptx.py) 스타일 재사용 — KINTEX Blue #0066B3 / AI Purple #6D4AFF / 맑은 고딕 / 16:9
- 순서도는 python-pptx 도형(단자·처리·판단·데이터·커넥터)으로 직접 작도(외부 이미지 금지)
- 보안 불변: 비밀번호/API키/서버IP/SSH 계정 미기재
재실행: python gen_program_spec_pptx.py
"""
import os
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)
SLATE     = RGBColor(0x98, 0xA2, 0xB3)
CARD      = RGBColor(0xFF, 0xFF, 0xFF)
HALL_BG   = RGBColor(0xF2, 0xF4, 0xF7)
ROW_ALT   = RGBColor(0xFC, 0xFC, 0xFD)

FONT = "맑은 고딕"
EMU_W = 12192000
EMU_H = 6858000

prs = Presentation()
prs.slide_width = EMU_W
prs.slide_height = EMU_H
BLANK = prs.slide_layouts[6]
IN = 914400
def inch(v): return int(v * IN)

# ---------------- 기본 헬퍼 ----------------
def slide():
    return prs.slides.add_slide(BLANK)

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

def _no_line(shape):
    shape.line.fill.background()

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))
    _set_fill(sp, fill)
    if line is None:
        _no_line(sp)
    else:
        sp.line.color.rgb = line; sp.line.width = Pt(line_w)
    sp.shadow.inherit = False
    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':'90000','dist':'38100','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)
    # 텍스트 없는 도형은 자동맞춤 방지
    sp.text_frame.word_wrap = True
    return sp

def text(s, x, y, w, h, content, size=14, 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)
        if isinstance(para, str):
            segs = [{"t": para}]
        elif isinstance(para, dict):
            segs = [para]
        else:
            segs = 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()
            ea = rPr.makeelement(qn('a:ea'), {'typeface':FONT}); rPr.append(ea)
    return tb

def page_bg(s, color=BG):
    rect(s, -0.1, -0.1, 13.53, 7.7, fill=color)

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)

FOOT = "킨텍스 자동전시시스템 구축 · 프로그램사양서"
def content_header(s, kicker, title, idx, chip_txt=None, chip_col=BLUE):
    page_bg(s, BG)
    rect(s, 0, 0, 13.333, 0.12, fill=BLUE)
    rect(s, 0.55, 0.5, 0.62, 0.62, fill=chip_col, shape=MSO_SHAPE.ROUNDED_RECTANGLE)
    text(s, 0.55, 0.5, 0.62, 0.62, (chip_txt if chip_txt else f"{idx:02d}"),
         size=(20 if not chip_txt else 22), 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=21, color=INK, bold=True)
    rect(s, 0.55, 1.28, 12.23, 0.02, fill=LINE)
    text(s, 0.55, 7.06, 8, 0.3, FOOT, 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 divider(num, kr, en, points):
    s = slide()
    rect(s, -0.1, -0.1, 13.53, 7.7, fill=BLUE_DK)
    rect(s, 8.6, -1.5, 8, 11, fill=BLUE, shape=MSO_SHAPE.PARALLELOGRAM)
    rect(s, 10.9, -2, 6, 12, fill=PURPLE, shape=MSO_SHAPE.PARALLELOGRAM)
    text(s, 1.0, 2.15, 5, 2.2, num, size=150, color=RGBColor(0x2C,0x4E,0x82), bold=True)
    rect(s, 1.15, 3.9, 0.9, 0.09, fill=PURPLE)
    text(s, 1.15, 4.05, 8, 0.8, kr, size=34, color=WHITE, bold=True)
    text(s, 1.18, 4.85, 8, 0.4, en, size=13, color=RGBColor(0x9C,0xB4,0xD8), bold=True)
    for i, p in enumerate(points):
        yy = 2.35 + i*0.62
        rect(s, 8.05, yy+0.06, 0.16, 0.16, fill=PURPLE, shape=MSO_SHAPE.OVAL)
        text(s, 8.35, yy, 4.4, 0.5, p, size=12.5, color=WHITE, anchor=MSO_ANCHOR.MIDDLE)
    return s

# ---------------- 순서도(플로우차트) 도형 헬퍼 ----------------
def fnode(s, cx, cy, w, h, txt, kind="proc"):
    """중심좌표 기준 순서도 노드. return dict(cx,cy,l,r,t,b)."""
    x = cx - w/2; y = cy - h/2
    if kind == "term":       # 시작/끝 단자
        sh, fill, line, tcol = MSO_SHAPE.FLOWCHART_TERMINATOR, BLUE, BLUE, WHITE
    elif kind == "proc":     # 처리
        sh, fill, line, tcol = MSO_SHAPE.FLOWCHART_PROCESS, WHITE, BLUE, INK
    elif kind == "dec":      # 판단
        sh, fill, line, tcol = MSO_SHAPE.FLOWCHART_DECISION, PURPLE_LT, PURPLE, PURPLE
    elif kind == "io":       # 입출력(데이터)
        sh, fill, line, tcol = MSO_SHAPE.FLOWCHART_DATA, BLUE_LT, BLUE, BLUE_DK
    elif kind == "db":       # 데이터베이스
        sh, fill, line, tcol = MSO_SHAPE.FLOWCHART_MAGNETIC_DISK, HALL_BG, SLATE, INK
    elif kind == "wkr":      # 사전정의 처리(워커/외부)
        sh, fill, line, tcol = MSO_SHAPE.FLOWCHART_PREDEFINED_PROCESS, PURPLE_LT, PURPLE, PURPLE
    elif kind == "proc_ok":  # 성공 처리(강조)
        sh, fill, line, tcol = MSO_SHAPE.FLOWCHART_PROCESS, RGBColor(0xEC,0xF7,0xF0), GREEN, RGBColor(0x0C,0x6B,0x43)
    elif kind == "proc_bad": # 반려/차단 처리
        sh, fill, line, tcol = MSO_SHAPE.FLOWCHART_PROCESS, RGBColor(0xFD,0xF1,0xE3), AMBER, RGBColor(0x8A,0x54,0x00)
    else:
        sh, fill, line, tcol = MSO_SHAPE.FLOWCHART_PROCESS, WHITE, BLUE, INK
    sp = rect(s, x, y, w, h, fill=fill, line=line, line_w=1.3, shape=sh)
    sz = 8.4 if kind != "dec" else 8.0
    text(s, x, y, w, h, txt, size=sz, color=tcol, bold=(kind in ("term","dec")),
         align=PP_ALIGN.CENTER, anchor=MSO_ANCHOR.MIDDLE, line_spacing=0.95)
    return {"cx":cx,"cy":cy,"l":x,"r":x+w,"t":y,"b":y+h}

def _arrowline(cxn, color, w):
    cxn.line.color.rgb = color; cxn.line.width = Pt(w)
    ln = cxn.line._get_or_add_ln()
    tail = ln.makeelement(qn('a:tailEnd'), {'type':'triangle','w':'med','len':'med'})
    ln.append(tail)

def arrow(s, x1, y1, x2, y2, color=SLATE, w=1.6, label=None, lcol=MUTED):
    cxn = s.shapes.add_connector(MSO_CONNECTOR.STRAIGHT, inch(x1), inch(y1), inch(x2), inch(y2))
    _arrowline(cxn, color, w)
    if label:
        mx, my = (x1+x2)/2, (y1+y2)/2
        text(s, mx-0.35, my-0.16, 0.9, 0.24, label, size=8, color=lcol, bold=True,
             align=PP_ALIGN.CENTER, anchor=MSO_ANCHOR.MIDDLE)

def arrow_elbow(s, x1, y1, x2, y2, first='v', color=SLATE, w=1.6, label=None, lcol=MUTED):
    """L자 커넥터. first='v'면 수직→수평, 'h'면 수평→수직. 마지막 구간에 화살촉."""
    if first == 'v':
        seg1 = s.shapes.add_connector(MSO_CONNECTOR.STRAIGHT, inch(x1), inch(y1), inch(x1), inch(y2))
        seg1.line.color.rgb = color; seg1.line.width = Pt(w)
        cxn = s.shapes.add_connector(MSO_CONNECTOR.STRAIGHT, inch(x1), inch(y2), inch(x2), inch(y2))
    else:
        seg1 = s.shapes.add_connector(MSO_CONNECTOR.STRAIGHT, inch(x1), inch(y1), inch(x2), inch(y1))
        seg1.line.color.rgb = color; seg1.line.width = Pt(w)
        cxn = s.shapes.add_connector(MSO_CONNECTOR.STRAIGHT, inch(x2), inch(y1), inch(x2), inch(y2))
    _arrowline(cxn, color, w)
    if label:
        text(s, x1-0.05, (y1+y2)/2-0.12, 1.1, 0.24, label, size=8, color=lcol, bold=True,
             anchor=MSO_ANCHOR.MIDDLE)

def vchain(s, cx, y0, items, w=3.7, h=0.6, gap=0.34):
    """세로 스택 + 순차 연결. items=[(txt,kind),...] return nodes."""
    nodes = []; y = y0
    for txt, kind in items:
        n = fnode(s, cx, y + h/2, w, h, txt, kind); nodes.append(n); y += h + gap
    for a, b in zip(nodes, nodes[1:]):
        arrow(s, a["cx"], a["b"], b["cx"], b["t"])
    return nodes

def fc_legend(s):
    items = [("시작/끝",BLUE,"term"),("처리",WHITE,"proc"),("판단",PURPLE_LT,"dec"),("입출력",BLUE_LT,"io")]
    x0 = 8.0
    for i,(lb,cl,kd) in enumerate(items):
        x = x0 + i*1.2
        sh = {"term":MSO_SHAPE.FLOWCHART_TERMINATOR,"proc":MSO_SHAPE.FLOWCHART_PROCESS,
              "dec":MSO_SHAPE.FLOWCHART_DECISION,"io":MSO_SHAPE.FLOWCHART_DATA}[kd]
        ln = {"term":BLUE,"proc":BLUE,"dec":PURPLE,"io":BLUE}[kd]
        rect(s, x, 1.34, 0.26, 0.2, fill=cl, line=ln, line_w=1.0, shape=sh)
        text(s, x+0.31, 1.31, 0.85, 0.26, lb, size=8, color=MUTED, anchor=MSO_ANCHOR.MIDDLE)

# ================================================================
# 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.35, 0.42, "Program Specification", RGBColor(0x2B,0x4C,0x7E), WHITE, size=10.5)
text(s, 0.85, 2.95, 11, 1.9, [
        [{"t":"킨텍스 자동전시시스템", "size":46, "color":WHITE, "bold":True}],
        [{"t":"프로그램사양서", "size":46, "color":WHITE, "bold":True}],
     ], line_spacing=1.02)
text(s, 0.88, 4.78, 11, 0.5,
     "Exhibition Automation Platform — 기능별 상세 사양 + 업무 순서도",
     size=16, color=RGBColor(0xC7,0xDD,0xF2))
text(s, 0.88, 5.35, 11.4, 0.4,
     "프로그램 식별체계  PGM-ID = M{모듈}-{유형}-{일련} (예: M02-BATCH-01)",
     size=12.5, color=RGBColor(0xB9,0xC7,0xEA))
rect(s, 0.85, 6.15, 11.63, 0.02, fill=RGBColor(0x3A,0x5A,0x8C))
text(s, 0.85, 6.35, 9, 0.4, [
        [{"t":"대상  ","size":11,"color":RGBColor(0x90,0xA6,0xC9)},
         {"t":"모듈 M1~M18 + 공통·시스템관리 레이어(§5B)","size":11.5,"color":WHITE,"bold":True}]])
text(s, 0.85, 6.72, 9, 0.4, [
        [{"t":"기술스택  ","size":11,"color":RGBColor(0x90,0xA6,0xC9)},
         {"t":"React(Vite)+TS · Spring Boot 3.x(Java17)+MyBatis · PostgreSQL(PostGIS) · Redis · 나노바나나 워커","size":10.5,"color":WHITE}]])
text(s, 9.7, 6.35, 2.78, 0.4, "작성일 2026-07-11", size=11, color=WHITE, bold=True, align=PP_ALIGN.RIGHT)
text(s, 9.7, 6.72, 2.78, 0.4, "planner · dev team", size=9.5, color=RGBColor(0x90,0xA6,0xC9), align=PP_ALIGN.RIGHT)

# ================================================================
# 2. 목차
# ================================================================
s = slide()
content_header(s, "CONTENTS", "목차", 0, chip_txt="C", chip_col=PURPLE)
toc = [
    ("01","개요 · 식별체계","문서 목적 / PGM-ID 부여 체계 / 프로그램 유형"),
    ("02","프로그램 목록 총괄","모듈(M1~M18+공통)별 프로그램 ID·명·유형·화면"),
    ("03","핵심 프로그램 상세 사양","20개 대표 프로그램 — 입출력·처리로직·테이블·예외"),
    ("04","핵심 업무 순서도","8종 — 배치/검증/배선/렌더/옥션/등록/승인/배포"),
    ("05","공통 규약","API 응답 봉투 · 에러 처리 · 감사 로그"),
]
for i,(no,t,d) in enumerate(toc):
    y = 1.75 + i*1.0
    rect(s, 0.7, y, 11.9, 0.86, fill=CARD, line=LINE, line_w=1.0, shape=MSO_SHAPE.ROUNDED_RECTANGLE)
    rect(s, 0.7, y, 0.09, 0.86, fill=(BLUE if i%2==0 else PURPLE), shape=MSO_SHAPE.ROUNDED_RECTANGLE)
    text(s, 1.0, y, 0.95, 0.86, no, size=26, color=(BLUE if i%2==0 else PURPLE), bold=True, anchor=MSO_ANCHOR.MIDDLE)
    text(s, 2.1, y+0.15, 9.8, 0.34, t, size=15, color=INK, bold=True)
    text(s, 2.1, y+0.52, 9.8, 0.3, d, size=10, color=MUTED)

# ================================================================
# 3. 개요 · 식별체계
# ================================================================
divider("01", "개요 · 식별체계", "OVERVIEW & ID SCHEME",
        ["문서 목적 · 범위", "PGM-ID 부여 체계", "프로그램 유형 정의"])

s = slide()
content_header(s, "01 · 개요 · 식별체계", "프로그램 식별 체계 및 유형 정의", 1)
# 목적
card(s, 0.55, 1.5, 12.23, 1.02)
text(s, 0.8, 1.66, 11.7, 0.3, "문서 목적", size=12, color=BLUE, bold=True)
text(s, 0.8, 1.98, 11.75, 0.5,
     "본 사양서는 킨텍스 자동전시시스템(M1~M18 + 공통·시스템관리 §5B)의 프로그램 단위를 식별·정의하고, 핵심 프로그램의 상세 처리 사양과 주요 업무 순서도를 규정한다. 화면(SCR)·모듈 정의의 권위 출처는 PLANNING v3.0·design.md v2.0이며, 본 문서는 이를 프로그램 단위로 분해한다.",
     size=10, color=INK, line_spacing=1.02)
# ID 체계
text(s, 0.55, 2.78, 8, 0.3, "PGM-ID 부여 체계", size=12.5, color=PURPLE, bold=True)
seg = [("M02","모듈 코드 (M1~M18 / SYS·CMN=공통)",BLUE),
       ("BATCH","유형·업무 약어",PURPLE),
       ("01","모듈 내 일련번호",BLUE_DK)]
x = 0.55
for i,(v,d,cl) in enumerate(seg):
    w = 2.0 if i!=1 else 3.5
    rect(s, x, 3.14, w, 0.72, fill=WHITE, line=cl, line_w=1.4, shape=MSO_SHAPE.ROUNDED_RECTANGLE)
    text(s, x, 3.22, w, 0.34, v, size=15, color=cl, bold=True, align=PP_ALIGN.CENTER)
    text(s, x, 3.56, w, 0.26, d, size=8, color=MUTED, align=PP_ALIGN.CENTER)
    if i<2:
        text(s, x+w-0.02, 3.14, 0.34, 0.72, "-", size=20, color=SLATE, bold=True, align=PP_ALIGN.CENTER, anchor=MSO_ANCHOR.MIDDLE)
    x += w + 0.34
text(s, 8.5, 3.28, 4.3, 0.5, [[{"t":"예)  ","size":11,"color":MUTED},
     {"t":"M02-BATCH-01","size":13,"color":INK,"bold":True}],
     [{"t":"= 플로어플랜 배치 3안 생성 엔진","size":9,"color":MUTED}]], line_spacing=1.1)
# 유형 정의
text(s, 0.55, 4.1, 8, 0.3, "프로그램 유형", size=12.5, color=BLUE, bold=True)
types = [
    ("온라인","ONL","사용자 화면 트랜잭션(React↔REST), 조회·입력·처리",BLUE),
    ("API","API","엔진·서비스 API(룰·배치·배선·집계), 화면 비종속",BLUE_DK),
    ("배치","BAT","스케줄러/야간 적재·역산 알림·주기 수집",PURPLE),
    ("워커","WKR","Redis 큐 소비 비동기 워커(나노바나나 렌더 등)",PURPLE),
]
tw = 2.98
for i,(kr,ab,d,cl) in enumerate(types):
    x = 0.55 + i*(tw+0.11)
    card(s, x, 4.46, tw, 1.72)
    rect(s, x, 4.46, tw, 0.5, fill=cl, shape=MSO_SHAPE.ROUNDED_RECTANGLE)
    text(s, x, 4.46, tw, 0.5, f"{kr}  ({ab})", size=12, color=WHITE, bold=True, align=PP_ALIGN.CENTER, anchor=MSO_ANCHOR.MIDDLE)
    text(s, x+0.2, 5.1, tw-0.4, 1.0, d, size=9.5, color=INK, line_spacing=1.05)
text(s, 0.55, 6.4, 12.2, 0.3,
     "표기 원칙  온라인 프로그램은 관련 화면(SCR ID)을 명시. 하나의 화면이 복수 프로그램을, 하나의 엔진 API가 복수 화면을 지원할 수 있다(N:M).",
     size=9, color=MUTED)

# ================================================================
# 4. 프로그램 목록 총괄
# ================================================================
divider("02", "프로그램 목록 총괄", "PROGRAM CATALOG",
        ["모듈별 PGM-ID·명·유형", "관련 화면(SCR) 매핑", "약 60개 프로그램"])

# 총괄 통계
s = slide()
content_header(s, "02 · 프로그램 목록 총괄", "프로그램 총괄 개요", 2)
stats = [("18","도메인 모듈 (M1~M18)"),("60+","프로그램 (온라인·API·배치·워커)"),
         ("80","화면 (SCR·웹/모바일/공개/관리자)"),("4","프로그램 유형")]
mw = 2.94
for i,(v,l) in enumerate(stats):
    x = 0.55 + i*(mw+0.1)
    rect(s, x, 1.55, mw, 1.15, fill=(BLUE if i%2==0 else PURPLE), shape=MSO_SHAPE.ROUNDED_RECTANGLE)
    text(s, x, 1.72, mw, 0.6, v, size=34, color=WHITE, bold=True, align=PP_ALIGN.CENTER)
    text(s, x, 2.36, mw, 0.28, l, size=9, color=WHITE, align=PP_ALIGN.CENTER)
# 모듈군 구성 요약
groups = [
    ("공통 · 시스템관리 (§5B / SYS·CMN)", BLUE_DK,
     "인증(JWT+OTP)·사용자·RBAC·공통코드·메뉴·감사로그·설정 + 업무일지·일정·쪽지·공지·검색·회의록·보고·알림"),
    ("설계·시각화 코어 (M2~M5 · P0)", PURPLE,
     "배치 3안 생성·규정검증·부스설계·배선 최단경로·나노바나나 RenderJob 워커"),
    ("판매·발주·정산 (M1·M6~M9·M15)", BLUE,
     "자동 견적·서류/마일스톤·등록업체 매칭·물류 슬롯·정산결제·공사 옥션(역경매·낙찰)"),
    ("관람·참가·마케팅 (M10~M14)", BLUE,
     "관람객 등록·배지·체크인·리드캡처·비즈매칭·EDM/공개사이트·wayfinding·현장운영"),
    ("경영·콘텐츠·관리 (M16~M18)", PURPLE,
     "BI 데이터마트/대시보드·CMS/마이크로사이트·마스터데이터·룰셋·테넌트 관리"),
]
y = 2.92
for i,(t,cl,d) in enumerate(groups):
    rect(s, 0.55, y, 12.23, 0.72, fill=CARD, line=LINE, line_w=1.0, shape=MSO_SHAPE.ROUNDED_RECTANGLE)
    rect(s, 0.55, y, 0.1, 0.72, fill=cl, shape=MSO_SHAPE.ROUNDED_RECTANGLE)
    text(s, 0.82, y+0.1, 5.0, 0.5, t, size=11, color=cl, bold=True, anchor=MSO_ANCHOR.MIDDLE)
    text(s, 5.9, y+0.1, 6.75, 0.52, d, size=9, color=MUTED, anchor=MSO_ANCHOR.MIDDLE, line_spacing=0.95)
    y += 0.8

# --- 프로그램 목록 표 헬퍼 ---
LIST_COLS = [(0.6,1.55,"PGM-ID"),(2.2,3.35,"프로그램명"),(5.62,1.05,"유형"),
             (6.75,1.45,"화면(SCR)"),(8.25,4.5,"개요")]
def list_slide(title, rows, note=None):
    s = slide()
    content_header(s, "02 · 프로그램 목록 총괄", title, 2)
    # 헤더
    rect(s, 0.55, 1.5, 12.23, 0.42, fill=BLUE, shape=MSO_SHAPE.ROUNDED_RECTANGLE)
    for x,w,h in LIST_COLS:
        text(s, x+0.1, 1.5, w-0.12, 0.42, h, size=9.5, color=WHITE, bold=True, anchor=MSO_ANCHOR.MIDDLE)
    y = 1.96
    rh = 0.445 if len(rows) > 10 else 0.49
    for i,(pid,name,typ,scr,desc) in enumerate(rows):
        if i % 2 == 0:
            rect(s, 0.55, y, 12.23, rh, fill=ROW_ALT)
        rect(s, 0.55, y+rh, 12.23, 0.008, fill=LINE)
        text(s, 0.7, y, 1.5, rh, pid, size=8.6, color=BLUE_DK, bold=True, anchor=MSO_ANCHOR.MIDDLE)
        text(s, 2.3, y, 3.25, rh, name, size=8.8, color=INK, anchor=MSO_ANCHOR.MIDDLE, line_spacing=0.9)
        tcol = {"온라인":BLUE,"API":BLUE_DK,"배치":PURPLE,"워커":PURPLE,"온라인/WS":BLUE}.get(typ,MUTED)
        text(s, 5.62, y, 1.05, rh, typ, size=8.2, color=tcol, bold=True, anchor=MSO_ANCHOR.MIDDLE)
        text(s, 6.85, y, 1.35, rh, scr, size=8.0, color=MUTED, anchor=MSO_ANCHOR.MIDDLE)
        text(s, 8.35, y, 4.35, rh, desc, size=8.0, color=MUTED, anchor=MSO_ANCHOR.MIDDLE, line_spacing=0.9)
        y += rh
    if note:
        text(s, 0.55, 6.72, 12.2, 0.3, note, size=8.2, color=MUTED)
    return s

# 목록 1 — 공통 · 시스템관리 (SYS/CMN)
list_slide("프로그램 목록 (1/6) — 공통 · 시스템관리 (§5B)", [
    ("SYS-AUTH-01","로그인·JWT 발급","온라인","SCR-01","ID/PW 인증→JWT 발급, 행사 워크스페이스 선택"),
    ("SYS-AUTH-02","2차 인증 OTP(TOTP)","API","SCR-51","TOTP RFC6238 검증·QR 등록·재설정"),
    ("SYS-AUTH-03","로그인 실패 잠금·해제","API","SCR-01","실패 횟수 잠금 + 관리자 해제"),
    ("SYS-USER-01","사용자 관리","온라인","SCR-A1","6역할+등록업체·관람객 계정 CRUD·상태"),
    ("SYS-RBAC-01","역할·권한(RBAC) 관리","온라인","SCR-A2","플랫폼·행사 이중 평가 권한 매핑"),
    ("SYS-CODE-01","공통코드 관리","온라인","SCR-A3","홀·부스유형·공종14분류·요금코드"),
    ("SYS-MENU-01","메뉴 관리","온라인","SCR-A4","역할별 포털 IA 메뉴 트리·권한"),
    ("SYS-AUDIT-01","감사로그 조회","온라인","SCR-A5","승인·낙찰·룰셋개정·리드접근 전수 조회"),
    ("SYS-CONF-01","시스템설정","온라인","SCR-A6","요율/규정 룰셋 버전·마감정책·AI 게이트"),
], note="§5B = UIWS/WISE 표준 이식. 시스템관리는 M18 백오피스와 통합(중복 재구현 없음).")

list_slide("프로그램 목록 (2/6) — 공통 업무 기능 (§5B)", [
    ("CMN-WORK-01","업무일지(worklog)","온라인","SCR-39","홀매니저 현장일지·업체 시공일지·댓글"),
    ("CMN-SCHD-01","통합 일정·캘린더","온라인","SCR-40","마일스톤·옥션마감·반입슬롯 통합 뷰"),
    ("CMN-MSG-01","쪽지(message)","온라인","SCR-41","행사 내 주최자↔참가↔업체↔홀매니저"),
    ("CMN-NOTI-01","공지(notice)","온라인","SCR-42","킨텍스/행사 공지·팝업 (M17 CMS와 정합)"),
    ("CMN-SRCH-01","통합검색(search)","API","SCR-44","행사·부스·업체·문서·콘텐츠 크로스 검색"),
    ("CMN-MEET-01","회의록(STT→PDF)","배치","SCR-45","사전협의(D-30) 녹음→STT→Jasper PDF"),
    ("CMN-RPT-01","업무보고·통계","배치","SCR-46","일/주/월/분기/연 보고·통계 Jasper PDF"),
    ("CMN-ALRM-01","알림센터(notification)","온라인/WS","SCR-47","D-데이·낙찰·승인·결제 알림 WebSocket"),
    ("CMN-PREF-01","마이페이지·개인화","온라인","SCR-48","즐겨찾기·최근방문·환경설정"),
])

# 목록 3 — M1~M5 코어
list_slide("프로그램 목록 (3/6) — M1 배정견적 · M2~M5 코어 (P0)", [
    ("M01-QUOTE-01","자동 견적 계산 엔진","API","SCR-18","2,250원/㎡×면적×일수×성수기·전시장 계수"),
    ("M01-ONL-01","홀 배정 신청","온라인","SCR-18","가용성 캘린더→홀/반홀 선택→신청서 생성"),
    ("M01-ONL-02","부스 판매 인벤토리","온라인","SCR-19","부스 재고·상태·셀프선택 판매 관리"),
    ("M02-BATCH-01","부스 배치 3안 생성 엔진","배치","SCR-03","제약 솔버+휴리스틱, 목표 다양화 3안"),
    ("M02-RULE-01","배치 규정 자동검증","API","SCR-03","통로·비상구·바닥하중·복층 위반 플래깅"),
    ("M02-ONL-01","부스 배치 에디터","온라인","SCR-03","PostGIS 폴리곤 캔버스 편집·확정"),
    ("M02-ONL-02","배치안 비교·병합","온라인","SCR-04","레이어 토글·병합→규정 재검증(S7 조감)"),
    ("M03-DSN-01","부스 설계 3안 생성","API","SCR-06","레이아웃·파라메트릭 구조 3안(용도 다양화)"),
    ("M03-ONL-01","부스 설계 스튜디오","온라인","SCR-06","조립/독립 설계·3D 프리뷰·선택/병합"),
    ("M03-RULE-01","설계 규정 사전검증","API","SCR-09","높이5m·리깅·복층·방염·금지작업 플래깅"),
    ("M04-WIRE-01","배선 최단경로 산출 엔진","API","SCR-07","트렌치→분전반 통로횡단 최소 배선(PostGIS)"),
    ("M04-ONL-01","유틸리티 배선 뷰","온라인","SCR-07","전기/네트워크/급배수 배선·조명 배치"),
    ("M04-ONL-02","신청 요약·위치표시도","온라인","SCR-08","단말 클릭→위치표시도 자동생성·견적"),
    ("M05-API-01","RenderJob 큐 발행","API","SCR-12","씬 컴파일→스키마해시→Redis 큐 발행"),
    ("M05-WKR-01","나노바나나 렌더 워커","워커","SCR-12","Gemini image-to-image·방어·워터마크"),
    ("M05-ONL-01","시각화 갤러리","온라인","SCR-12","표준 샷 S1~S7·Before/After 슬라이더"),
], note="M2~M5 = 핵심 차별화 P0. 배치·배선은 결정적 알고리즘(솔버) 중심, LLM은 조건해석 보조.")

# 목록 4 — M6~M9, M15
list_slide("프로그램 목록 (4/6) — M6~M9 판매운영 · M15 옥션", [
    ("M06-ONL-01","서류·마일스톤 워크플로","온라인","SCR-22","D-150/30/25/7 마일스톤·서류 상태"),
    ("M06-DOC-01","신고서류 웹폼→HWP/PDF","API","SCR-23","7종 서식 자동생성·LLM 누락/불일치 검수"),
    ("M06-BAT-01","마일스톤 D-데이 알림","배치","SCR-40","역산 리마인더 스케줄 발행→알림센터"),
    ("M07-ONL-01","등록업체 매칭·RFQ","온라인","SCR-24","규모·업종·예산·지역 추천→RFQ 발송"),
    ("M07-BAT-01","등록업체 DB 주기 수집","배치","SCR-A7","14분류×739개 공개데이터 수집·정규화"),
    ("M08-ONL-01","반입/반출 슬롯 예약","온라인","SCR-25","하역장 슬롯·중량물 우선·통행증 QR"),
    ("M09-ONL-01","정산·결제 대시보드","온라인","SCR-21","납부 스케줄·PG 결제·예치금 정산 내역"),
    ("M09-BAT-01","납부 스케줄 자동 생성","배치","SCR-21","계약금20/중도금30+20/잔금30+예치금"),
    ("M15-ONL-01","옥션 목록·개설","온라인","SCR-26","유형·라운드·마감·낙찰기준·가중치 설정"),
    ("M15-ONL-02","옥션 상세·실시간 순위","온라인/WS","SCR-27","자료열람·응찰·실시간 순위 WebSocket"),
    ("M15-DOC-01","견적서 작성·PDF 산출","API","SCR-28","라인아이템·총액·부가세·버전·PDF"),
    ("M15-AWD-01","낙찰 비교·선정(Award)","API","SCR-29","최저가/종합점수(가격+평판+납기) 비교"),
], note="M15 = 폐루프 연결고리. M2~M5 산출물(물량서·시공 예상 이미지)이 응찰 근거 자료.")

# 목록 5 — M10~M14 관람·현장
list_slide("프로그램 목록 (5/6) — M10~M14 관람 · 참가 · 현장", [
    ("M10-ONL-01","관람객 등록 관리","온라인","SCR-30","유형별 사전등록 폼·현황 대시보드"),
    ("M10-BADGE-01","배지/QR 발급","API","SCR-P4","모바일 배지/QR 발급·배지 템플릿(M17)"),
    ("M10-ONL-02","현장 체크인","온라인","SCR-31","QR 체크인·즉석 배지·오프라인 폴백"),
    ("M10-LEAD-01","리드캡처·AI 스코어링","API","SCR-32","배지 QR 스캔→연락처·관심도·팔로업"),
    ("M11-MATCH-01","AI 미팅 추천","API","SCR-M8","프로필·intent 매칭→미팅 슬롯 예약"),
    ("M12-EDM-01","EDM·캠페인 관리","배치","SCR-33","세그먼트 캠페인·리마인더·수신동의"),
    ("M12-PUB-01","공개 홍보 사이트","온라인","SCR-P1","행사소개·일정·교통, SEO·다국어(한영중일)"),
    ("M12-ONL-01","스폰서십 패키지 판매","온라인","SCR-34","스폰서 등급·재고·판매 관리"),
    ("M13-NAV-01","실내 내비 경로 산출","API","SCR-M6","블루닷/존레벨 경로(부스·시설·비상구)"),
    ("M14-ONL-01","홀 현장운영 대시보드","온라인","SCR-14","입장·혼잡·전력부하·주차·안전 플래그"),
])

# 목록 6 — M16~M18 경영·콘텐츠·관리
list_slide("프로그램 목록 (6/6) — M16 BI · M17 CMS · M18 관리", [
    ("M16-ETL-01","BI 데이터마트 적재","배치","SCR-13","스타스키마 Fact/Dim 야간 적재(KpiSnapshot)"),
    ("M16-ONL-01","경영분석 대시보드","온라인","SCR-13","가동률·매출구성·P&L·리텐션·수요예측"),
    ("M17-ONL-01","CMS 콘텐츠·게시 워크플로","온라인","SCR-35","초안→검수→게시·버전·다국어"),
    ("M17-ONL-02","마이크로사이트 편집기","온라인","SCR-36","참가업체 부스소개·제품·예상샷 게시"),
    ("M17-ONL-03","다국어 콘텐츠 관리","온라인","SCR-37","한/영/중/일 콘텐츠·번역 검수"),
    ("M18-ONL-01","마스터데이터 관리","온라인","SCR-A7","홀·요율·유틸요금·부스표준·등록업체"),
    ("M18-ONL-02","규정 룰셋 버전 관리","온라인","SCR-A8","연 단위 요율·규정 개정 버전·이력"),
    ("M18-ONL-03","테넌트 관리·온보딩","온라인","SCR-A9","전시관(테넌트) 마스터·온보딩(플랫폼관)"),
    ("M18-ONL-04","AI 플랫폼 설정(AiConfig)","온라인","SCR-A10","Claude 기본·설정형 전환·폴백"),
], note="M18 = §5B-1 시스템관리 + 킨텍스 마스터데이터 확장. 테넌트/AiConfig는 v3.0·F100 반영.")

# ================================================================
# 5. 핵심 프로그램 상세 사양
# ================================================================
divider("03", "핵심 프로그램 상세 사양", "PROGRAM DETAIL SPEC",
        ["모듈 대표 20개 프로그램", "입력·출력·처리로직", "관련 테이블·예외 처리"])

def detail_slide(idx_total, d):
    s = slide()
    page_bg(s, BG)
    rect(s, 0, 0, 13.333, 0.12, fill=BLUE)
    cl = d.get("color", BLUE)
    # PGM-ID chip
    rect(s, 0.55, 0.42, 2.35, 0.62, fill=cl, shape=MSO_SHAPE.ROUNDED_RECTANGLE)
    text(s, 0.55, 0.42, 2.35, 0.62, d["id"], size=15, color=WHITE, bold=True,
         align=PP_ALIGN.CENTER, anchor=MSO_ANCHOR.MIDDLE)
    text(s, 3.02, 0.4, 8.5, 0.34, d["name"], size=18, color=INK, bold=True)
    text(s, 3.02, 0.78, 9.7, 0.26, d["module"], size=9.5, color=MUTED)
    # 메타 칩 (유형·화면)
    chip(s, 10.6, 0.42, 1.05, 0.3, d["type"], cl, WHITE, size=9)
    chip(s, 11.72, 0.42, 1.06, 0.3, d["scr"], RGBColor(0x2B,0x4C,0x7E), WHITE, size=9)
    text(s, 10.6, 0.78, 2.2, 0.24, f"상세사양 {idx_total}/20", size=8, color=MUTED, align=PP_ALIGN.RIGHT)
    rect(s, 0.55, 1.18, 12.23, 0.02, fill=LINE)
    text(s, 0.55, 7.06, 8, 0.3, FOOT, size=8.5, color=MUTED)
    text(s, 11.3, 7.06, 1.48, 0.3, d["id"], size=8.5, color=MUTED, align=PP_ALIGN.RIGHT)

    def block(x, y, w, h, title, tcol, items, numbered=False):
        card(s, x, y, w, h)
        rect(s, x, y+0.12, 0.09, h-0.24, fill=tcol, shape=MSO_SHAPE.ROUNDED_RECTANGLE)
        text(s, x+0.26, y+0.13, w-0.4, 0.3, title, size=10.5, color=tcol, bold=True)
        yy = y + 0.5
        for i,it in enumerate(items):
            if numbered:
                rect(s, x+0.26, yy+0.015, 0.26, 0.22, fill=tcol, shape=MSO_SHAPE.OVAL)
                text(s, x+0.26, yy, 0.26, 0.22, str(i+1), size=8, color=WHITE, bold=True,
                     align=PP_ALIGN.CENTER, anchor=MSO_ANCHOR.MIDDLE)
                text(s, x+0.62, yy-0.02, w-0.78, 0.44, it, size=8.6, color=INK, line_spacing=0.92)
            else:
                rect(s, x+0.28, yy+0.07, 0.08, 0.08, fill=tcol, shape=MSO_SHAPE.OVAL)
                text(s, x+0.5, yy-0.01, w-0.66, 0.44, it, size=8.7, color=INK, line_spacing=0.92)
            yy += d.get("lh", 0.315)
        return

    # 좌: 기능설명 / 입력 / 출력
    block(0.55, 1.32, 6.0, 1.42, "기능 설명", cl, d["desc"])
    block(0.55, 2.86, 2.9, 3.05, "입력 (Input)", BLUE_DK, d["input"])
    block(3.65, 2.86, 2.9, 3.05, "출력 (Output)", BLUE_DK, d["output"])
    # 우: 처리 로직 / 테이블 / 예외
    block(6.72, 1.32, 6.06, 3.05, "처리 로직", PURPLE, d["logic"], numbered=True)
    block(6.72, 4.49, 6.06, 1.42, "예외 처리", AMBER, d["exc"])
    # 하단: 관련 테이블 스트립
    rect(s, 0.55, 6.05, 6.0, 0.86, fill=HALL_BG, line=LINE, line_w=1.0, shape=MSO_SHAPE.ROUNDED_RECTANGLE)
    text(s, 0.75, 6.14, 5.7, 0.26, "관련 테이블 / 데이터", size=9.5, color=BLUE_DK, bold=True)
    text(s, 0.75, 6.42, 5.7, 0.44, "  ·  ".join(d["tables"]), size=8.6, color=INK, line_spacing=0.95)
    # 하단 우: 연동
    rect(s, 6.72, 6.05, 6.06, 0.86, fill=BLUE_LT, line=BLUE, line_w=1.0, shape=MSO_SHAPE.ROUNDED_RECTANGLE)
    text(s, 6.92, 6.14, 5.8, 0.26, "연동 / 비고", size=9.5, color=BLUE_DK, bold=True)
    text(s, 6.92, 6.42, 5.8, 0.44, d["link"], size=8.6, color=INK, line_spacing=0.95)

DETAILS = [
 {"id":"M01-QUOTE-01","name":"자동 견적 계산 엔진","type":"API","scr":"SCR-18","color":BLUE,
  "module":"M1 행사·홀 배정 및 자동 견적","lh":0.30,
  "desc":["규칙 엔진이 홀/반홀·기간·성수기 조건으로 임대 견적을 즉시 산정",
          "HWP 신청→협의→견적(수일)을 즉시 자동 회신으로 전환"],
  "input":["행사 기간(장치·전시·철거일)","홀/반홀 선택·면적(㎡)","부대(로비·옥외·회의실)","요율 룰셋 버전(테넌트)"],
  "output":["임대료·관리비 예치금 소계","성수기/전시장 계수 반영 총액","견적 명세(항목별) JSON","킨텍스 제출 신청서식"],
  "logic":["요율 마스터·성수기 규칙 로딩(tenant_id 스코프)",
           "기본액 = 2,250원/㎡ × 면적 × 일수",
           "계수 적용: 성수기 +10% / 비수기 -10% / 1전시장 +10%",
           "초과시간(1일 임대료 1/10)·용도외 30% 할증 가산",
           "예치금 15~20% 산출 → 견적 명세 조립·반환"],
  "tables":["hall_master","rate_master","event","quotation_estimate"],
  "exc":["요율 룰셋 미확정 → RULESET_NOT_FOUND 반환","면적/기간 유효성 위반 → VALIDATION 오류"],
  "link":"연동 M9 정산·결제 · 행사일정 DB. 최종 배정 확정은 킨텍스 내부 의사결정(가견적까지)."},

 {"id":"M02-BATCH-01","name":"부스 배치 3안 생성 엔진","type":"배치","scr":"SCR-03","color":PURPLE,
  "module":"M2 플로어플랜 스튜디오 (P0)","lh":0.30,
  "desc":["제약충족 솔버+휴리스틱으로 서로 다른 최적화 목표의 배치 정확히 3안 생성",
          "생성형 LLM이 아닌 결정적 알고리즘 중심(LLM은 조건 해석 보조)"],
  "input":["홀 선택·실측 지오메트리","목표 부스 수·프리미엄 비율","주출입구·무대·라운지 조건","통로폭·비상구·트렌치 제약"],
  "output":["배치 3안(부스 폴리곤 집합)","안별 최적화 지표(부스수·동선·안전)","안별 규정검증 결과","S7 조감 시각화 입력"],
  "logic":["홀 좌표계·제약(바닥하중·비상구·트렌치) 로딩",
           "목표별 파라미터 분기(1안 부스수 / 2안 동선 / 3안 안전)",
           "솔버로 부스 폴리곤 배치·통로 확보",
           "각 안 M02-RULE-01 규정검증 호출",
           "3안 + 지표·위반 요약 저장·반환"],
  "tables":["hall_master","booth(PostGIS polygon)","design_plan(버전·출처)","trench_grid"],
  "exc":["제약 충족 해 없음 → 부분안+완화 제안","트렌치 좌표 미확보 → 근사도면 경고 라벨"],
  "link":"연동 M02-RULE-01·M03·M5·M6(제출 서류). 소방 최종 유권해석은 관할기관."},

 {"id":"M02-RULE-01","name":"배치 규정 자동검증","type":"API","scr":"SCR-03·09","color":PURPLE,
  "module":"M2 플로어플랜 스튜디오 (P0)","lh":0.30,
  "desc":["배치안을 수치 규정에 대조해 위반을 차단(hard)·경고(soft)로 플래깅",
          "병합/편집 결과는 재검증 필수(제약 파손 가능)"],
  "input":["대상 배치안(부스 폴리곤)","홀 규정 룰셋(버전)","바닥하중·비상구·통로 기준","복층 가능 홀 매트릭스"],
  "output":["위반 목록(항목·좌표·등급)","차단/경고 분류","검증 통과 여부(boolean)","검수 리포트(SCR-09)"],
  "logic":["룰셋 로딩(tenant_id·버전 고정)",
           "피난 통로폭·비상구 접근 거리 판정",
           "홀별 바닥하중(2~5t/㎡)·복층 1/2 판정",
           "이격(벽30cm·천장60cm)·소방 체크리스트 대조",
           "위반 등급화 → hard=확정 차단, soft=경고"],
  "tables":["ruleset_version","booth","hall_master","compliance_result"],
  "exc":["룰셋 버전 불일치 → 최신 버전 강제·경고","좌표 결손 폴리곤 → 검증 제외·플래그"],
  "link":"연동 M2·M3(SCR-09)·M10 승인. 최종 판정은 사람(홀매니저·구조기술사)."},

 {"id":"M03-DSN-01","name":"부스 설계 3안 생성","type":"API","scr":"SCR-06","color":PURPLE,
  "module":"M3 부스 설계 스튜디오 (P0)","lh":0.30,
  "desc":["부스 크기·업종·예산 입력으로 레이아웃·파라메트릭 구조 초안 3안 생성",
          "조립부스는 옵션 선택형, 독립부스는 AI 초안+장치업체 편집"],
  "input":["부스 좌표·크기(M2)","업종·전시품·예산","용도존(시연/상담/창고)","조립/독립 유형"],
  "output":["설계 3안(벽체·트러스·사인)","존 배치·집기 블록","M5 예상 사진 프롬프트 입력","규정 사전검증 플래그"],
  "logic":["부스 골격·표준 자재 카탈로그 로딩",
           "목표별 3안 분기(시연/상담/예산절감)",
           "파라메트릭 구조·존 레이아웃 생성",
           "M03-RULE-01 사전검증 호출(높이·방염)",
           "3안 저장 → 선택/병합 대기"],
  "tables":["booth","design_plan","material_catalog","design_zone"],
  "exc":["예산/크기 비현실 → 경고·근사안","업로드 도면 인식 저정확 → 참고용 라벨"],
  "link":"연동 M7 매칭·M5 시각화·kxwp 제출. 시공 상세도·구조판정은 장치업체 책임."},

 {"id":"M04-WIRE-01","name":"배선 최단경로 산출 엔진","type":"API","scr":"SCR-07","color":PURPLE,
  "module":"M4 유틸리티 설계 (P0)","lh":0.30,
  "desc":["단말 위치에서 최근접 트렌치까지 통로 횡단 최소 배선 경로를 PostGIS로 산출",
          "위치표시도 수기 작도를 자동 생성으로 대체"],
  "input":["부스 내 단말 좌표(전기·LAN·급배수)","홀 트렌치 그리드 좌표","기기 목록·kW/회선","유틸리티 요금 마스터"],
  "output":["배선 LineString(전기적/망청/급배수녹)","분전반 용량·수량","위치표시도·자동 견적","S6 오버레이 입력"],
  "logic":["트렌치 그리드·공급 매트릭스 로딩",
           "단말→최근접 트렌치 매칭(ST_ClosestPoint)",
           "통로 횡단 최소 경로 산출(ST_ShortestLine)",
           "kW 합산→분전반 용량·수량 산정",
           "요금 견적·위치표시도 조립·반환"],
  "tables":["trench_grid","utility_order(LineString)","rate_utility","booth"],
  "exc":["트렌치 좌표 미확보 → 산출 보류·근사 경고","인터넷 마감(D-25) 초과 → 신청 차단 안내"],
  "link":"연동 M9 결제·M5(S6 오버레이는 백엔드 래스터 우선)·kxwp/사무국 릴레이."},

 {"id":"M05-WKR-01","name":"나노바나나 렌더 워커","type":"워커","scr":"SCR-12","color":PURPLE,
  "module":"M5 나노바나나 시각화 (P0·핵심 차별화)","lh":0.30,
  "desc":["Redis 큐의 RenderJob을 소비해 Gemini image-to-image로 시공 후 예상 사진 생성",
          "ReRoomAI 방어 로직 이식 + 워터마크·고지 강제"],
  "input":["RenderJob(씬 스키마·샷 프리셋)","참조 이미지(간이 렌더·홀 사진)","프롬프트 조각(유형·스타일·레이어)","행사별 렌더 쿼터"],
  "output":["생성 이미지(오브젝트 스토리지)","워터마크·메타데이터 임베드","WebSocket 완료 푸시","스키마 해시 캐시"],
  "logic":["큐 소비 → 크기 가드(8MB·base64 재검증)",
           "참조 이미지 1024px 정규화·mimeType 감지",
           "Gemini generateContent 호출(inlineData+text)",
           "SAFETY/quota 에러 분기→친화 메시지",
           "성공 시에만 쿼터 차감·워터마크→푸시"],
  "tables":["render_job(상태·이미지)","render_quota","object_storage(메타)"],
  "exc":["finishReason=SAFETY → 차단 에러","RESOURCE_EXHAUSTED/429 → 재시도·상한","한글 간판 렌더 불안정 → 후처리 합성"],
  "link":"연동 M2~M4(자료)·WebSocket 푸시·M15 응찰 근거. API키는 서버 env(GEMINI_API_KEY)."},

 {"id":"M06-DOC-01","name":"신고서류 웹폼→HWP/PDF","type":"API","scr":"SCR-23","color":BLUE,
  "module":"M6 서류·마일스톤 워크플로","lh":0.30,
  "desc":["7종 신고서류를 웹폼 입력으로 킨텍스 제출 형식(HWP/PDF) 자동 생성",
          "LLM이 누락 항목·배치도-운영계획서 불일치 검수"],
  "input":["서식 유형·행사·부스 데이터","배치도(M2)·정산(M9) 참조","작성자 입력 필드","서식 템플릿(마스터)"],
  "output":["HWP/PDF 제출 파일","검수 요약 리포트(누락·불일치)","마일스톤 제출 상태","업로드 안내 릴레이"],
  "logic":["서식 템플릿·행사 데이터 병합",
           "필수 항목 누락 검출",
           "배치도-운영계획서 부스수 등 교차 검증",
           "HWP/PDF 렌더링·서명 필드 생성",
           "검수 리포트→홀매니저 전달"],
  "tables":["document","doc_template","milestone","event"],
  "exc":["필수 필드 누락 → 생성 차단·목록 반환","AI 생성 이미지 포함 시 → 서류에서 자동 배제"],
  "link":"연동 kxwp(제출 릴레이·API 미공개)·M10 승인. 계약·심사 서류엔 도면만 유효."},

 {"id":"M07-ONL-01","name":"등록업체 매칭·RFQ","type":"온라인","scr":"SCR-24","color":BLUE,
  "module":"M7 등록업체 매칭·견적","lh":0.30,
  "desc":["14분류×739개 등록업체를 조건 기반 추천, 설계안 첨부 RFQ 발송·비교",
          "미등록 업체 시공 엄금 규정을 시스템이 강제"],
  "input":["부스 규모·업종·예산·지역","M3 설계안 첨부","공종 분류 필터","등록업체 DB(검증)"],
  "output":["추천 업체 목록·점수","RFQ 발송·수신 현황","견적 비교표","옥션 초대 자격(적격)"],
  "logic":["등록업체 DB 조회·자격 검증",
           "조건 매칭 점수화·정렬",
           "RFQ에 설계안·물량 첨부 발송",
           "회신 견적 취합·비교표 구성",
           "적격 업체만 M15 옥션 초대 허용"],
  "tables":["company(등록업체)","rfq","rfq_quote","booth"],
  "exc":["미등록 업체 초대 시도 → 원천 차단","응답 없음 → 리마인더·대체 추천"],
  "link":"연동 M3(설계)·M15(옥션 자격 게이트)·CCPY 공개 DB 수집(M07-BAT-01)."},

 {"id":"M09-BAT-01","name":"납부 스케줄 자동 생성","type":"배치","scr":"SCR-21","color":BLUE,
  "module":"M9 정산·결제","lh":0.30,
  "desc":["계약 확정 시 단계별 납부 스케줄을 자동 생성하고 역산 알림을 발행",
          "예치금 대비 실사용 정산 내역 투명화"],
  "input":["계약 총액·확정일","납부 규칙(20/30+20/30)","예치금 비율(15~20%)","행사 일정"],
  "output":["납부 스케줄(회차·금액·기일)","알림센터 리마인더 이벤트","예치금 정산 기준","결제 대기 항목"],
  "logic":["계약 총액·규칙 로딩",
           "계약금20→중도금30+20→잔금30 분할",
           "예치금 15~20% 별도 스케줄",
           "기일 역산 알림 이벤트 발행",
           "PG 결제 대기 항목 생성"],
  "tables":["payment","payment_schedule","event","rate_master"],
  "exc":["계약 미확정 → 스케줄 생성 보류","PG 실패 → 재시도·수동 처리 플래그"],
  "link":"연동 M1 견적·PG 결제·CMN-ALRM 알림. 세금계산서 등 재무연동 협의."},

 {"id":"M15-ONL-02","name":"옥션 상세·실시간 순위·응찰","type":"온라인/WS","scr":"SCR-27","color":BLUE,
  "module":"M15 공사/장치 옥션 (핵심 플로우)","lh":0.30,
  "desc":["적격 등록업체가 AI 자료 열람 후 견적서로 응찰, 실시간 순위를 WebSocket으로 노출",
          "역경매(라운드 내 인하)·마감 자동 처리"],
  "input":["옥션(유형·라운드·마감·기준)","M2~M5 자료·물량서","응찰 견적서(총액·납기)","업체 자격(M7 검증)"],
  "output":["실시간 순위(현재/최저/내 위치)","응찰 이력(버전)","라운드 마감 상태","비교표 입력(Award)"],
  "logic":["응찰 자격 검증(등록업체만)",
           "AI 자료 열람 권한 부여",
           "견적서 제출→종합점수 산정",
           "실시간 순위 WebSocket 브로드캐스트",
           "마감 도달→자동 마감·집계"],
  "tables":["auction","quotation(=bid·버전)","company","render_job"],
  "exc":["미등록 응찰 → 차단","마감 후 제출 → 거부","동점 → 납기·평판 tie-break"],
  "link":"연동 M2~M5·M7·M15-AWD(낙찰)·M6/M9. 익명 순위 옵션 지원."},

 {"id":"M15-AWD-01","name":"낙찰 비교·선정(Award)","type":"API","scr":"SCR-29","color":BLUE,
  "module":"M15 공사/장치 옥션 (핵심 플로우)","lh":0.30,
  "desc":["최저가 또는 종합평가(가격+평판+납기) 가중 스코어로 견적서를 비교·낙찰",
          "낙찰 견적서를 계약/발주 문서로 전환"],
  "input":["옥션 응찰 견적서 집합","낙찰기준·가중치","평판 데이터(규정준수·평점)","납기 조건"],
  "output":["항목별 비교표·순위","낙찰(Award) 결과·사유","계약/발주 링크(M6/M9)","시공 일정 연계(M8)"],
  "logic":["응찰 견적서 정규화(사양 정렬)",
           "가격/평판/납기 점수화·가중합",
           "최저가 or 종합점수 순위 산정",
           "전시업체 낙찰 선정(사유 기록)",
           "낙찰서→계약/발주 전환·감사기록"],
  "tables":["auction","quotation","award","company_reputation"],
  "exc":["유효 응찰 0건 → 유찰·재개설","가중치 합≠100% → 검증 오류"],
  "link":"연동 M6 계약·M9 발주정산·M8 시공일정·SYS-AUDIT(낙찰 전수기록)."},

 {"id":"M10-BADGE-01","name":"배지/QR 발급","type":"API","scr":"SCR-P4","color":BLUE,
  "module":"M10 관람객 등록·배지·체크인","lh":0.30,
  "desc":["사전등록 관람객에게 모바일 배지/QR을 발급, 현장 즉석 배지 인쇄 대비",
          "배지 템플릿은 M17 CMS 관리"],
  "input":["관람객 등록 정보(유형)","배지 템플릿(M17)","행사·부스 매핑","개인정보 동의 상태"],
  "output":["모바일 배지/QR(서명)","배지 인쇄 데이터","체크인 검증 토큰","리드 스캔 대상 매핑"],
  "logic":["등록·동의 상태 검증",
           "배지 템플릿 병합·QR 토큰 생성",
           "서명·만료 정책 적용",
           "모바일 발급·인쇄 데이터 산출",
           "체크인/리드 스캔 매핑 등록"],
  "tables":["visitor","registration","badge","badge_template"],
  "exc":["개인정보 미동의 → 발급 보류","중복 등록 → 기존 배지 재사용"],
  "link":"연동 M10 체크인·M10-LEAD·M13 wayfinding. PII 동의·보존정책 필수(R10)."},

 {"id":"M10-LEAD-01","name":"리드캡처·AI 스코어링","type":"API","scr":"SCR-32","color":BLUE,
  "module":"M10 관람객 (참가업체 리드)","lh":0.30,
  "desc":["참가업체가 배지 QR을 스캔해 연락처·관심도·메모를 저장하고 AI로 스코어링",
          "팔로업 EDM(M12)·ROI 측정(M16)으로 연계"],
  "input":["스캔 배지 QR 토큰","관심도 평점·메모","부스·참가업체 컨텍스트","동의 범위"],
  "output":["리드 레코드(연락처·점수)","AI 리드 스코어","팔로업 세그먼트","리드 접근 감사로그"],
  "logic":["QR 토큰 검증·관람객 해소",
           "동의 범위 내 연락처 취득",
           "관심도·행동신호 AI 스코어링",
           "리드 저장·세그먼트 분류",
           "리드 접근 감사로그 기록"],
  "tables":["lead","visitor","booth","audit_log"],
  "exc":["동의 범위 밖 필드 → 마스킹","오프라인 스캔 → 큐 저장 후 동기화"],
  "link":"연동 M12 EDM·M16 BI. 리드 접근은 개인정보 전수 감사(§5B-1)."},

 {"id":"M11-MATCH-01","name":"AI 미팅 추천","type":"API","scr":"SCR-M8","color":BLUE,
  "module":"M11 비즈니스 매칭","lh":0.30,
  "desc":["프로필·관심업종·의향(intent) 기반으로 미팅을 추천하고 슬롯 예약",
          "부스 위치(M2)·wayfinding(M13) 길안내 연계"],
  "input":["관람/참가 프로필","관심 업종·intent 신호","부스 업종 태그(M3)","가용 미팅 슬롯"],
  "output":["매칭 추천 목록·점수","미팅 슬롯 예약","길안내 경로(M13)","매칭 성과(M16)"],
  "logic":["프로필·행동신호 수집",
           "관심·의향 임베딩 유사도 산정",
           "상호 의향 매칭 랭킹",
           "미팅 슬롯 예약·충돌 해소",
           "부스 위치·경로 연계 반환"],
  "tables":["visitor","match_recommendation","meeting_slot","booth"],
  "exc":["프로필 결손 → 저신뢰 라벨","외부 모델 게이트 위반 → 승인 모델만"],
  "link":"연동 M10·M13·M16. AI 추천은 온프레미스/승인 모델 범위(R12)."},

 {"id":"M16-ETL-01","name":"BI 데이터마트 적재","type":"배치","scr":"SCR-13","color":PURPLE,
  "module":"M16 경영분석 BI (P1 승격)","lh":0.30,
  "desc":["운영 DB를 스타 스키마 데이터마트로 야간 집계해 운영 부하 없이 지표 제공",
          "운영사 관점·참가사 관점 지표 분리"],
  "input":["M1 배정·M9 정산 이력","M10 관람·M15 옥션","행사일정·비용 마스터","전 적재 스냅샷"],
  "output":["Fact/Dim 테이블 적재","KpiSnapshot(가동률·매출·P&L)","수요예측·수율 입력","이상탐지 대상"],
  "logic":["증분 소스 추출(읽기 복제)",
           "DimHall/Event/Date/Exhibitor 갱신",
           "FactBooking/Settlement/Utility/Auction/Visitor 적재",
           "KPI 집계·스냅샷 생성",
           "품질 검증→커밋(멱등)"],
  "tables":["fact_booking","fact_settlement","dim_hall","kpi_snapshot"],
  "exc":["소스 정합 결손 → 부분적재·경고","적재 실패 → 재시도·이전 스냅샷 유지"],
  "link":"연동 전 모듈(소비)·M18 권한. 크로스테넌트 집계는 플랫폼 슈퍼관리자 전용."},

 {"id":"M17-ONL-01","name":"CMS 콘텐츠·게시 워크플로","type":"온라인","scr":"SCR-35","color":PURPLE,
  "module":"M17 CMS","lh":0.30,
  "desc":["전시 콘텐츠·공지를 초안→검수→게시 워크플로와 버전관리로 발행",
          "다국어(한/영/중/일)·사이니지 연계"],
  "input":["콘텐츠 초안·미디어","게시 대상·채널","다국어 번역","승인자·게시 일정"],
  "output":["게시 콘텐츠(버전)","예약 게시 큐","다국어 렌더","사이니지 배포 콘텐츠"],
  "logic":["콘텐츠 작성·미디어 첨부",
           "검수 요청→승인/반려",
           "다국어 번역 검수",
           "게시/예약 게시 처리",
           "사이니지·공개사이트 배포"],
  "tables":["content(버전)","content_publish","content_i18n","microsite"],
  "exc":["승인 없이 게시 → 차단","번역 누락 → 기본 로케일 폴백"],
  "link":"연동 M12 마케팅·M13 wayfinding·M10 배지. 공지 채널은 CMN-NOTI와 정합."},

 {"id":"M18-ONL-02","name":"규정 룰셋 버전 관리","type":"온라인","scr":"SCR-A8","color":PURPLE,
  "module":"M18 관리자 백오피스","lh":0.30,
  "desc":["요율·규정 룰셋을 버전으로 관리해 연 단위 개정을 안전하게 반영",
          "룰 엔진(M1·M2 검증)이 버전 고정으로 참조"],
  "input":["룰셋 초안(요율·규정 값)","적용 시작일·테넌트","개정 사유·근거","승인자"],
  "output":["룰셋 버전(불변 스냅샷)","활성 버전 포인터","개정 이력·감사로그","엔진 참조 캐시 무효화"],
  "logic":["룰셋 초안 작성·검증",
           "버전 스냅샷 생성(불변)",
           "승인→활성 버전 전환(시작일)",
           "엔진 캐시 무효화·재로딩",
           "개정 감사로그 기록"],
  "tables":["ruleset_version","rate_master","audit_log","tenant"],
  "exc":["활성 버전 삭제 시도 → 차단","시작일 소급 → 경고·승인 필요"],
  "link":"연동 M1 견적·M2 검증·전 모듈. 테넌트 스코프 격리(§1A)."},

 {"id":"SYS-AUTH-02","name":"2차 인증 OTP(TOTP) 검증","type":"API","scr":"SCR-51","color":BLUE_DK,
  "module":"§5B-3 인증 (UIWS 표준 이식)","lh":0.30,
  "desc":["TOTP(RFC6238)로 로그인 2단계 인증을 수행하고 QR 등록·재설정을 제공",
          "관리자·홀매니저·주최자·업체 필수, 관람객 선택"],
  "input":["1차 인증 통과 컨텍스트","OTP 6자리 코드","OTP 시크릿(등록 시)","서버 시간"],
  "output":["OTP 검증 결과","QR 등록 데이터(최초)","실패 카운트·잠금 상태","인증 완료 토큰"],
  "logic":["사용자 OTP 시크릿 조회",
           "SHA1·30s·±1 윈도우 코드 산정",
           "입력 코드 대조",
           "실패 시 카운트 증가·잠금 판정",
           "성공→인증 완료·감사기록"],
  "tables":["user(otp_secret)","login_attempt","audit_log"],
  "exc":["코드 불일치 → 실패 카운트·잠금","관리자 초기화 → otp_secret=NULL 재등록"],
  "link":"연동 SYS-AUTH-01·SYS-AUTH-03. 시크릿·비번은 응답/로그 미노출."},

 {"id":"SYS-RBAC-01","name":"역할·권한(RBAC) 관리","type":"온라인","scr":"SCR-A2","color":BLUE_DK,
  "module":"§5B-1 시스템관리 / M18","lh":0.30,
  "desc":["플랫폼·행사 이중 평가 권한 모델을 관리(테넌트 격리 ⊃ 행사 RBAC)",
          "역할별 포털 IA·메뉴 접근을 구동"],
  "input":["역할 정의·계층","권한(리소스·액션) 매핑","테넌트·행사 스코프","사용자-역할 배정"],
  "output":["역할-권한 매트릭스","메뉴·API 게이트 규칙","이중 평가 정책","권한 변경 감사로그"],
  "logic":["역할 계층 로딩(플랫폼/테넌트/행사)",
           "권한 매핑 편집·검증",
           "테넌트 격리 제약 적용",
           "메뉴·API 게이트 규칙 생성",
           "변경 감사로그 기록"],
  "tables":["role","permission","user_role","menu"],
  "exc":["테넌트 경계 위반 배정 → 차단","슈퍼관리자 외 크로스테넌트 → 거부"],
  "link":"연동 SYS-MENU·전 포털. 행사 RBAC는 테넌트 내부로 스코프(§1A-4)."},

 {"id":"CMN-ALRM-01","name":"알림센터(notification)","type":"온라인/WS","scr":"SCR-47","color":BLUE,
  "module":"§5B-2 공통 업무 기능","lh":0.30,
  "desc":["마감(D-데이)·낙찰·승인·결제 등 이벤트를 단일 채널로 수집·푸시",
          "M10·M12·M15가 이벤트를 발행, 발송 채널은 공통 단일화"],
  "input":["이벤트(유형·대상·페이로드)","수신자·역할","알림 설정·구독","WebSocket 세션"],
  "output":["알림 레코드(읽음 상태)","실시간 WebSocket 푸시","배지 카운트","이메일/모바일 팬아웃"],
  "logic":["이벤트 수신·정규화",
           "수신자 해소(역할·구독)",
           "알림 저장·중복 억제",
           "WebSocket 실시간 푸시",
           "미접속 시 이메일/푸시 팬아웃"],
  "tables":["notification","notification_pref","event_outbox"],
  "exc":["WebSocket 미연결 → 오프라인 큐","발송 실패 → 재시도·데드레터"],
  "link":"연동 M6 마일스톤·M15 낙찰·M9 결제. 마케팅 발송은 M12(수신동의)."},
]
for i, d in enumerate(DETAILS):
    detail_slide(i+1, d)

# ================================================================
# 6. 핵심 업무 순서도 (도형 작도 8종)
# ================================================================
divider("04", "핵심 업무 순서도", "KEY WORKFLOW FLOWCHARTS",
        ["8종 핵심 업무 흐름", "python-pptx 도형 직접 작도", "단자·처리·판단·커넥터"])

# --- 순서도 배치 헬퍼 ---
def after(nodes, h, gap=0.28, cx=None):
    n = nodes[-1]
    return (cx if cx is not None else n["cx"], n["b"] + gap + h/2)

# --- FC1: 부스 배치 3안 생성 → 선택/병합 ---
s = slide()
content_header(s, "04 · 핵심 업무 순서도 (1/8)", "부스 배치 3안 AI 생성 → 선택 / 병합", 4, chip_txt="F1", chip_col=PURPLE)
fc_legend(s)
sp = vchain(s, 2.7, 1.55, [
    (["설계 조건 입력", "(홀·부스수·프리미엄)"], "term"),
    (["제약 로딩(통로·비상구", "·트렌치·바닥하중)"], "proc"),
    (["솔버+휴리스틱 3안 생성", "(목표 다양화)"], "proc"),
    (["3안 규정검증·시각화(S7)"], "io"),
], w=3.4, h=0.56, gap=0.22)
dcx, dcy = after(sp, 0.86, gap=0.26)
dec = fnode(s, dcx, dcy, 2.2, 0.86, ["선택 or", "병합?"], "dec")
arrow(s, sp[-1]["cx"], sp[-1]["b"], dec["cx"], dec["t"])
sel = fnode(s, 6.6, dcy-1.45, 3.0, 0.62, ["단일 안 선택"], "proc")
mrg = fnode(s, 6.6, dcy, 3.0, 0.62, ["레이어 병합 편집", "(안별 블록 조합)"], "proc")
rechk = fnode(s, 10.4, dcy-0.72, 2.8, 0.66, ["병합/선택안", "규정 재검증"], "proc")
fin = fnode(s, 7.6, 6.4, 7.4, 0.5, ["최종안 확정·버전기록 → 참가업체 초대링크(M3/M4)"], "proc_ok")
arrow(s, dec["r"], dec["cy"], sel["l"], sel["cy"], label="선택")
arrow(s, dec["r"], dec["cy"], mrg["l"], mrg["cy"], label="병합")
arrow(s, sel["r"], sel["cy"], rechk["l"], rechk["cy"])
arrow(s, mrg["r"], mrg["cy"], rechk["l"], rechk["cy"])
arrow(s, rechk["cx"], rechk["b"], rechk["cx"], fin["t"])
text(s, 0.55, 6.66, 12, 0.3, "메모  규정 재검증에서 위반 발견 시 병합 편집으로 되돌려 재수정 후 재검증(hard 위반은 확정 차단).", size=9, color=MUTED)

# --- FC2: 규정 자동검증 ---
s = slide()
content_header(s, "04 · 핵심 업무 순서도 (2/8)", "규정 자동검증 (위반 차단 / 경고)", 4, chip_txt="F2", chip_col=PURPLE)
fc_legend(s)
sp = vchain(s, 2.9, 1.6, [
    (["배치/설계안 제출"], "term"),
    (["룰셋 버전 로딩", "(tenant·버전 고정)"], "proc"),
    (["규정 항목 순차 판정", "(통로·하중·높이·방염·이격)"], "proc"),
], w=3.5, h=0.6, gap=0.3)
dcx, dcy = after(sp, 0.94, gap=0.28)
dec = fnode(s, 2.9, dcy, 2.4, 0.94, ["위반", "등급?"], "dec")
arrow(s, sp[-1]["cx"], sp[-1]["b"], dec["cx"], dec["t"])
hard = fnode(s, 6.95, dcy-1.3, 3.1, 0.66, ["HARD 위반", "→ 제출 차단"], "proc_bad")
soft = fnode(s, 6.95, dcy, 3.1, 0.66, ["SOFT 위반", "→ 경고(진행 가능)"], "proc")
none = fnode(s, 6.95, dcy+1.3, 3.1, 0.6, ["위반 없음 → 통과·확정"], "proc_ok")
rep = fnode(s, 11.45, dcy, 1.5, 2.0, ["검증", "리포트", "(SCR-09)", "위반좌표", "오버레이"], "io")
arrow(s, dec["r"], dec["cy"], hard["l"], hard["cy"], label="차단")
arrow(s, dec["r"], dec["cy"], soft["l"], soft["cy"], label="경고")
arrow(s, dec["r"], dec["cy"], none["l"], none["cy"], label="정상")
arrow(s, hard["r"], hard["cy"], rep["l"], rep["cy"]-0.6)
arrow(s, soft["r"], soft["cy"], rep["l"], rep["cy"])
arrow(s, none["r"], none["cy"], rep["l"], rep["cy"]+0.6)
text(s, 0.55, 6.66, 12, 0.3, "원칙  hard=확정 차단, soft=경고 후 진행 가능. 최종 판정은 홀매니저·구조기술사(사람).", size=9, color=MUTED)

# --- FC3: 배선 최단경로 산출 (단일 수직 흐름) ---
s = slide()
content_header(s, "04 · 핵심 업무 순서도 (3/8)", "유틸리티 배선 최단경로 산출 (PostGIS)", 4, chip_txt="F3", chip_col=PURPLE)
fc_legend(s)
vchain(s, 5.6, 1.66, [
    (["단말 위치 클릭(전기·LAN·급배수)"], "term"),
    (["기기 목록·kW/회선 입력"], "io"),
    (["트렌치 그리드·공급 매트릭스 로딩"], "proc"),
    (["최근접 트렌치 매칭(ST_ClosestPoint)"], "proc"),
    (["통로 횡단 최소 경로(ST_ShortestLine)"], "proc"),
    (["kW 합산 → 분전반 용량·수량 + 요금 견적"], "proc"),
    (["배선 LineString 저장·위치표시도 자동생성"], "proc_ok"),
    (["S6 오버레이(백엔드 래스터 합성)"], "term"),
], w=5.4, h=0.48, gap=0.14)
text(s, 0.6, 6.72, 12, 0.3, "예외  트렌치 좌표 미확보 → 산출 보류·근사도면 경고.  인터넷 마감(D-25) 초과 → 신청 차단.  S6는 생성모델 아닌 결정적 래스터 합성 우선.", size=8.6, color=MUTED)

# --- FC4: 나노바나나 RenderJob ---
s = slide()
content_header(s, "04 · 핵심 업무 순서도 (4/8)", "나노바나나 RenderJob (큐→워커→워터마크→푸시)", 4, chip_txt="F4", chip_col=PURPLE)
fc_legend(s)
sp = vchain(s, 3.0, 1.6, [
    (["시각화 요청(M2~M4)"], "term"),
    (["씬 컴파일·스키마 해시"], "proc"),
    (["Redis 큐 발행(RenderJob)"], "io"),
], w=3.4, h=0.58, gap=0.26)
dcx, dcy = after(sp, 0.86, gap=0.28)
dec = fnode(s, 3.0, dcy, 2.3, 0.86, ["캐시", "HIT?"], "dec")
arrow(s, sp[-1]["cx"], sp[-1]["b"], dec["cx"], dec["t"])
gem = fnode(s, 8.0, 1.95, 3.3, 0.64, ["워커 소비·Gemini 호출", "(inlineData + text)"], "wkr")
derr = fnode(s, 8.0, 3.25, 3.3, 0.86, ["응답", "상태?"], "dec")
bad = fnode(s, 11.6, 3.25, 1.4, 0.86, ["SAFETY/429", "친화 에러", "쿼터 미차감"], "proc_bad")
o1 = fnode(s, 8.0, 4.55, 3.3, 0.56, ["워터마크·메타 임베드"], "proc")
o2 = fnode(s, 8.0, 5.55, 3.3, 0.56, ["스토리지 저장 + 성공 시 쿼터 차감"], "proc")
o3 = fnode(s, 8.0, 6.55, 3.3, 0.56, ["WebSocket 완료 푸시(S1~S7)"], "proc_ok")
arrow(s, dec["r"], dec["cy"], gem["l"], gem["cy"], label="MISS")
arrow(s, gem["cx"], gem["b"], derr["cx"], derr["t"])
arrow(s, derr["r"], derr["cy"], bad["l"], bad["cy"], label="실패")
arrow(s, derr["cx"], derr["b"], o1["cx"], o1["t"], label="성공")
arrow(s, o1["cx"], o1["b"], o2["cx"], o2["t"])
arrow(s, o2["cx"], o2["b"], o3["cx"], o3["t"])
arrow_elbow(s, dec["cx"], dec["b"], o3["l"], o3["cy"], first='v', label="HIT")

# --- FC5: 공사 옥션 ---
s = slide()
content_header(s, "04 · 핵심 업무 순서도 (5/8)", "공사 옥션 (견적 → 역경매 → 낙찰)", 4, chip_txt="F5", chip_col=BLUE)
fc_legend(s)
sp = vchain(s, 3.0, 1.5, [
    (["옥션 개설(유형·라운드·마감)"], "term"),
    (["M2~M5 자료·물량서 첨부"], "io"),
    (["응찰 자격 검증(M7) 등록업체만"], "proc"),
    (["적격 업체 자료 열람"], "proc"),
    (["견적서 제출(응찰)"], "proc"),
], w=3.7, h=0.52, gap=0.18)
dcx, dcy = after(sp, 0.82, gap=0.24)
dec = fnode(s, 3.0, dcy, 2.2, 0.82, ["마감/라운드", "종료?"], "dec")
arrow(s, sp[-1]["cx"], sp[-1]["b"], dec["cx"], dec["t"])
rebid = fnode(s, 6.7, dcy-1.25, 3.0, 0.62, ["재응찰", "(라운드 내 인하)"], "proc")
arrow(s, dec["r"], dec["cy"], rebid["l"], rebid["cy"], label="No")
arrow_elbow(s, rebid["cx"], rebid["b"], dec["cx"]+1.1, dec["t"]+0.15, first='v')
yes = vchain(s, 9.4, 1.6, [
    (["종합점수 산정(가격+평판+납기)"], "proc"),
    (["견적서 비교표·순위"], "io"),
    (["전시업체 낙찰(Award)"], "proc"),
    (["계약/발주 전환(M6·M9)·감사기록"], "proc_ok"),
], w=3.5, h=0.58, gap=0.24)
arrow(s, dec["r"], dec["cy"]-0.1, yes[0]["l"], yes[0]["cy"], label="Yes")

# --- FC6: 관람객 등록 → 배지 → 체크인 ---
s = slide()
content_header(s, "04 · 핵심 업무 순서도 (6/8)", "관람객 등록 → 배지 → 체크인", 4, chip_txt="F6", chip_col=BLUE)
fc_legend(s)
sp = vchain(s, 3.0, 1.55, [
    (["온라인 사전등록(유형별 폼)"], "term"),
    (["개인정보 동의 확인"], "proc"),
    (["배지/QR 발급(서명·만료)"], "proc"),
    (["모바일 배지 수신"], "io"),
], w=3.4, h=0.54, gap=0.2)
dcx, dcy = after(sp, 0.86, gap=0.26)
dec = fnode(s, 3.0, dcy, 2.3, 0.86, ["현장", "네트워크?"], "dec")
arrow(s, sp[-1]["cx"], sp[-1]["b"], dec["cx"], dec["t"])
onl = fnode(s, 6.9, dcy, 3.0, 0.62, ["QR 온라인 체크인"], "proc")
off = fnode(s, 6.9, dcy-1.35, 3.0, 0.62, ["오프라인 폴백 큐"], "proc_bad")
arrow(s, dec["r"], dec["cy"], onl["l"], onl["cy"], label="정상")
arrow(s, dec["r"], dec["cy"], off["l"], off["cy"], label="불안정")
arrow(s, off["cx"], off["b"], onl["cx"], onl["t"], label="복구 동기화")
tail = vchain(s, 10.7, dcy-1.35, [
    (["즉석 배지 인쇄·입장 집계"], "proc"),
    (["리드캡처 매핑(M10-LEAD)"], "proc"),
    (["체크인 데이터 → M16 BI"], "proc_ok"),
], w=3.1, h=0.6, gap=0.24)
arrow(s, onl["r"], onl["cy"], tail[0]["l"], tail[0]["cy"])

# --- FC7: 승인 워크플로 (2FA 포함) ---
s = slide()
content_header(s, "04 · 핵심 업무 순서도 (7/8)", "승인 워크플로 (2FA 포함)", 4, chip_txt="F7", chip_col=BLUE_DK)
fc_legend(s)
sp = vchain(s, 3.0, 1.55, [
    (["로그인(ID/PW)"], "term"),
    (["JWT 발급"], "proc"),
], w=3.2, h=0.56, gap=0.22)
dcx, dcy = after(sp, 0.86, gap=0.24)
dec = fnode(s, 3.0, dcy, 2.3, 0.86, ["2FA 대상", "역할?"], "dec")
arrow(s, sp[-1]["cx"], sp[-1]["b"], dec["cx"], dec["t"])
otp = fnode(s, 3.0, dcy+1.35, 3.1, 0.6, ["OTP(TOTP) 검증·실패 잠금"], "proc")
arrow(s, dec["cx"], dec["b"], otp["cx"], otp["t"], label="필수")
que = fnode(s, 8.3, 2.2, 3.2, 0.62, ["승인 큐 진입(AI 사전심사 플래그)"], "io")
rev = fnode(s, 8.3, 3.4, 3.2, 0.6, ["검수 상세(도면+위반 오버레이)"], "proc")
dap = fnode(s, 8.3, 4.65, 2.4, 0.86, ["승인", "판정?"], "dec")
rej = fnode(s, 11.6, 4.65, 1.4, 0.86, ["반려", "사유 통지", "재제출"], "proc_bad")
ap1 = fnode(s, 8.3, 5.85, 3.2, 0.56, ["승인 확정·상태 전이"], "proc")
ap2 = fnode(s, 8.3, 6.6, 3.2, 0.5, ["감사로그 기록 + 알림센터 통지(CMN-ALRM)"], "proc_ok")
arrow(s, dec["r"], dec["cy"], que["l"], que["cy"], label="선택/면제")
arrow(s, otp["r"], otp["cy"], que["l"], que["cy"]+0.1)
arrow(s, que["cx"], que["b"], rev["cx"], rev["t"])
arrow(s, rev["cx"], rev["b"], dap["cx"], dap["t"])
arrow(s, dap["r"], dap["cy"], rej["l"], rej["cy"], label="반려")
arrow(s, dap["cx"], dap["b"], ap1["cx"], ap1["t"], label="승인")
arrow(s, ap1["cx"], ap1["b"], ap2["cx"], ap2["t"])

# --- FC8: CI/CD 배포 ---
s = slide()
content_header(s, "04 · 핵심 업무 순서도 (8/8)", "CI/CD 배포 (빌드 → health → 롤백)", 4, chip_txt="F8", chip_col=BLUE_DK)
fc_legend(s)
sp = vchain(s, 3.0, 1.55, [
    (["Gitea push(main)"], "term"),
    (["webhook 수신(deploy 서버)"], "proc"),
    (["백엔드 bootJar + 프론트 Vite 빌드"], "proc"),
    (["나노바나나 워커 패키징"], "proc"),
    (["배포·서비스 재기동"], "proc"),
], w=3.7, h=0.56, gap=0.22)
dcx, dcy = after(sp, 0.9, gap=0.24)
dec = fnode(s, 3.0, dcy, 2.3, 0.9, ["health", "check", "OK?"], "dec")
arrow(s, sp[-1]["cx"], sp[-1]["b"], dec["cx"], dec["t"])
okb = fnode(s, 7.3, dcy, 3.4, 0.62, ["트래픽 전환·배포 완료"], "proc_ok")
rb = fnode(s, 7.3, dcy-1.3, 3.4, 0.62, ["직전 버전 롤백"], "proc_bad")
arrow(s, dec["r"], dec["cy"], okb["l"], okb["cy"], label="UP")
arrow(s, dec["r"], dec["cy"], rb["l"], rb["cy"], label="FAIL")
arrow(s, rb["cx"], rb["b"], okb["cx"], okb["t"], label="복구")
fin8 = fnode(s, 7.3, dcy+1.1, 3.4, 0.56, ["배포 결과 알림 + 감사로그·배포 이력 기록"], "term")
arrow(s, okb["cx"], okb["b"], fin8["cx"], fin8["t"])
text(s, 0.55, 6.74, 12, 0.3, "보안 불변  API키·비밀번호·서버IP·SSH 계정은 코드·커밋·로그·응답에 미기재(서버 env 로드).", size=9, color=MUTED)

# ================================================================
# 7. 공통 규약
# ================================================================
divider("05", "공통 규약", "COMMON CONVENTIONS",
        ["API 응답 봉투(ApiResponse)", "에러 코드·처리", "감사 로그(@Audited)"])

s = slide()
content_header(s, "05 · 공통 규약", "API 응답 봉투 · 에러 처리 · 감사 로그", 5)
# 응답 봉투
card(s, 0.55, 1.5, 6.05, 2.55)
rect(s, 0.55, 1.5, 6.05, 0.46, fill=BLUE, shape=MSO_SHAPE.ROUNDED_RECTANGLE)
text(s, 0.55, 1.5, 6.05, 0.46, "API 응답 봉투 (ApiResponse<T>)", size=12, color=WHITE, bold=True, align=PP_ALIGN.CENTER, anchor=MSO_ANCHOR.MIDDLE)
text(s, 0.78, 2.05, 5.6, 0.28, "모든 REST 응답은 공통 봉투 사용(예외 없음)", size=9, color=MUTED)
rect(s, 0.78, 2.38, 5.6, 0.62, fill=RGBColor(0x0E,0x14,0x22), shape=MSO_SHAPE.ROUNDED_RECTANGLE)
text(s, 0.95, 2.44, 5.3, 0.52, [
    [{"t":'성공  ',"size":8.5,"color":GREEN,"bold":True},{"t":'{ "success": true, "data": {…}, "error": null }',"size":8.5,"color":RGBColor(0xD6,0xE2,0xF0)}],
    [{"t":'실패  ',"size":8.5,"color":AMBER,"bold":True},{"t":'{ "success": false, "data": null, "error": {code,message} }',"size":8.5,"color":RGBColor(0xD6,0xE2,0xF0)}],
], line_spacing=1.1)
for i,t in enumerate([
    "성공: 컨트롤러 ApiResponse.ok(dto)",
    "실패: ApiException throw → GlobalExceptionHandler 봉투 변환",
    "페이징: 공통 PageResponse<T>",
]):
    y=3.12+i*0.3
    rect(s, 0.82, y+0.06, 0.09, 0.09, fill=BLUE, shape=MSO_SHAPE.OVAL)
    text(s, 1.02, y, 5.4, 0.28, t, size=9, color=INK)
# 에러 처리
card(s, 6.75, 1.5, 6.03, 2.55)
rect(s, 6.75, 1.5, 6.03, 0.46, fill=AMBER, shape=MSO_SHAPE.ROUNDED_RECTANGLE)
text(s, 6.75, 1.5, 6.03, 0.46, "에러 처리 (ErrorCode·ApiException)", size=12, color=WHITE, bold=True, align=PP_ALIGN.CENTER, anchor=MSO_ANCHOR.MIDDLE)
for i,(h,d) in enumerate([
    ("단일 매핑","ErrorCode(enum)가 코드↔HTTP 단일 매핑, 신규는 여기에만 추가"),
    ("3자 세트","ErrorCode → ApiException(코드+요약) → GlobalExceptionHandler"),
    ("스택 미노출","error.message는 요약만, 상세는 서버 로그(include-* : never)"),
    ("남용 금지","RuntimeException 남발 금지 — 최종 방어선만 INTERNAL"),
    ("보안","IP·SSH·비번·PII를 응답·에러에 미노출"),
]):
    y=2.08+i*0.38
    rect(s, 7.0, y+0.04, 0.11, 0.11, fill=AMBER, shape=MSO_SHAPE.OVAL)
    text(s, 7.22, y, 5.4, 0.36, [[{"t":h+" — ","size":9.4,"color":INK,"bold":True},{"t":d,"size":8.8,"color":MUTED}]], line_spacing=0.92)
# 감사 로그
card(s, 0.55, 4.28, 12.23, 2.15)
rect(s, 0.55, 4.28, 12.23, 0.46, fill=BLUE_DK, shape=MSO_SHAPE.ROUNDED_RECTANGLE)
text(s, 0.55, 4.28, 12.23, 0.46, "감사 로그 (@Audited AOP → TB_AUDIT_LOG)", size=12, color=WHITE, bold=True, align=PP_ALIGN.CENTER, anchor=MSO_ANCHOR.MIDDLE)
audit = [
    ("기록 대상","상태 변경 API — 승인·낙찰(M15)·설계 변경·룰셋 개정·리드 접근(PII)"),
    ("기록 항목","액터 · 행사 · 대상 · before/after 요약 · 룰셋 버전 · 시각"),
    ("미기록","민감정보 · 비밀번호 · 스택트레이스 (보안 불변)"),
    ("스키마","WISE TB_AUDIT_LOG 이식 · 멱등 DDL(sql.init mode=always + continue-on-error)"),
]
for i,(h,d) in enumerate(audit):
    x=0.75+(i%2)*6.1
    y=4.9+(i//2)*0.72
    rect(s, x, y, 5.85, 0.62, fill=HALL_BG, line=LINE, line_w=1.0, shape=MSO_SHAPE.ROUNDED_RECTANGLE)
    text(s, x+0.18, y+0.1, 5.55, 0.26, h, size=10, color=BLUE_DK, bold=True)
    text(s, x+0.18, y+0.36, 5.55, 0.24, d, size=8.6, color=MUTED)
text(s, 0.55, 6.62, 12.2, 0.3,
     "적용 원칙  ApiResponse·ErrorCode·감사로그는 전 프로그램 공통(architecture/app.md §5-2·5-3·7-1 정본). 신규 프로그램도 예외 없이 준수.",
     size=9, color=MUTED)

# ================================================================
# 저장
# ================================================================
OUT = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "프로그램사양서.pptx")
prs.save(OUT)
print("SAVED:", OUT)
print("SLIDES:", len(prs.slides._sldIdLst))
