Update intranet tools and voucher comparison

This commit is contained in:
b17301
2026-06-04 09:00:51 +09:00
parent 2ab74bac88
commit b93289bfa8
55 changed files with 32809 additions and 1063 deletions
+397
View File
@@ -0,0 +1,397 @@
from __future__ import annotations
from datetime import datetime
from pathlib import Path
from xml.sax.saxutils import escape
import math
import struct
import zlib
import zipfile
BASE_DIR = Path(__file__).resolve().parent.parent
REPORT_DIR = BASE_DIR / "reports"
OUTPUT_PATH = REPORT_DIR / "my-intranet-app_architecture_report_20260522_v3.docx"
PAGE_WIDTH = 11906
MARGIN = 620
CONTENT_WIDTH = PAGE_WIDTH - (MARGIN * 2)
def esc(value: object) -> str:
return escape(str(value), {'"': """})
def run(text: str, *, bold: bool = False, size: int | None = None, font: str | None = None) -> str:
props: list[str] = []
if bold:
props.append("<w:b/>")
if size:
props.append(f'<w:sz w:val="{size}"/><w:szCs w:val="{size}"/>')
if font:
props.append(f'<w:rFonts w:ascii="{font}" w:hAnsi="{font}" w:eastAsia="{font}" w:cs="{font}"/>')
rpr = f"<w:rPr>{''.join(props)}</w:rPr>" if props else ""
preserve = ' xml:space="preserve"' if text[:1].isspace() or text[-1:].isspace() else ""
return f"<w:r>{rpr}<w:t{preserve}>{esc(text)}</w:t></w:r>"
def para(text: str = "", *, style: str | None = None, bold: bool = False, size: int | None = None, font: str | None = None, after: int | None = None) -> str:
pprops: list[str] = []
if style:
pprops.append(f'<w:pStyle w:val="{style}"/>')
if after is not None:
pprops.append(f'<w:spacing w:after="{after}"/>')
ppr = f"<w:pPr>{''.join(pprops)}</w:pPr>" if pprops else ""
return f"<w:p>{ppr}{run(text, bold=bold, size=size, font=font)}</w:p>"
def bullet(text: str) -> str:
return para("" + text, after=60)
def table(headers: list[str], rows: list[list[str]], widths: list[int] | None = None) -> str:
if widths is None:
widths = [CONTENT_WIDTH // len(headers)] * len(headers)
grid = "".join(f'<w:gridCol w:w="{w}"/>' for w in widths)
def cell(text: str, width: int, header: bool = False) -> str:
fill = '<w:shd w:fill="E8EEF7"/>' if header else ""
props = (
f'<w:tcPr><w:tcW w:w="{width}" w:type="dxa"/>{fill}'
'<w:tcMar><w:top w:w="30" w:type="dxa"/><w:left w:w="45" w:type="dxa"/>'
'<w:bottom w:w="30" w:type="dxa"/><w:right w:w="45" w:type="dxa"/></w:tcMar></w:tcPr>'
)
return f"<w:tc>{props}{para(text, bold=header, size=16, after=0)}</w:tc>"
rows_xml = ["<w:tr>" + "".join(cell(h, widths[i], True) for i, h in enumerate(headers)) + "</w:tr>"]
rows_xml.extend(
"<w:tr>" + "".join(cell(c, widths[i]) for i, c in enumerate(row)) + "</w:tr>"
for row in rows
)
return (
'<w:tbl><w:tblPr><w:tblStyle w:val="TableGrid"/>'
f'<w:tblW w:w="{CONTENT_WIDTH}" w:type="dxa"/><w:tblLayout w:type="fixed"/>'
"</w:tblPr>"
f"<w:tblGrid>{grid}</w:tblGrid>{''.join(rows_xml)}</w:tbl>"
)
class PngCanvas:
def __init__(self, width: int, height: int, bg: tuple[int, int, int] = (255, 255, 255)):
self.width = width
self.height = height
self.px = bytearray(bg * width * height)
def set(self, x: int, y: int, color: tuple[int, int, int]) -> None:
if 0 <= x < self.width and 0 <= y < self.height:
i = (y * self.width + x) * 3
self.px[i : i + 3] = bytes(color)
def rect(self, x: int, y: int, w: int, h: int, fill: tuple[int, int, int], border: tuple[int, int, int], bw: int = 3) -> None:
for yy in range(y, y + h):
for xx in range(x, x + w):
if x <= xx < x + w and y <= yy < y + h:
self.set(xx, yy, fill)
for n in range(bw):
self.line(x + n, y + n, x + w - 1 - n, y + n, border)
self.line(x + n, y + h - 1 - n, x + w - 1 - n, y + h - 1 - n, border)
self.line(x + n, y + n, x + n, y + h - 1 - n, border)
self.line(x + w - 1 - n, y + n, x + w - 1 - n, y + h - 1 - n, border)
def line(self, x1: int, y1: int, x2: int, y2: int, color: tuple[int, int, int], width: int = 3) -> None:
dx = abs(x2 - x1)
dy = -abs(y2 - y1)
sx = 1 if x1 < x2 else -1
sy = 1 if y1 < y2 else -1
err = dx + dy
x, y = x1, y1
while True:
r = width // 2
for yy in range(y - r, y + r + 1):
for xx in range(x - r, x + r + 1):
self.set(xx, yy, color)
if x == x2 and y == y2:
break
e2 = 2 * err
if e2 >= dy:
err += dy
x += sx
if e2 <= dx:
err += dx
y += sy
def arrow(self, x1: int, y1: int, x2: int, y2: int, color: tuple[int, int, int] = (75, 88, 99)) -> None:
self.line(x1, y1, x2, y2, color, 4)
ang = math.atan2(y2 - y1, x2 - x1)
for a in (ang + 2.55, ang - 2.55):
self.line(x2, y2, int(x2 + 22 * math.cos(a)), int(y2 + 22 * math.sin(a)), color, 4)
def digit(self, x: int, y: int, digit: str, color: tuple[int, int, int] = (20, 39, 54), scale: int = 8) -> None:
glyphs = {
"0": ["111", "101", "101", "101", "111"],
"1": ["010", "110", "010", "010", "111"],
"2": ["111", "001", "111", "100", "111"],
"3": ["111", "001", "111", "001", "111"],
"4": ["101", "101", "111", "001", "001"],
"5": ["111", "100", "111", "001", "111"],
"6": ["111", "100", "111", "101", "111"],
"7": ["111", "001", "010", "010", "010"],
"8": ["111", "101", "111", "101", "111"],
"9": ["111", "101", "111", "001", "111"],
}[digit]
for gy, row in enumerate(glyphs):
for gx, v in enumerate(row):
if v == "1":
self.rect(x + gx * scale, y + gy * scale, scale - 1, scale - 1, color, color, 1)
def number_badge(self, x: int, y: int, n: int) -> None:
self.rect(x - 28, y - 28, 56, 56, (255, 255, 255), (47, 111, 163), 4)
self.digit(x - 12, y - 18, str(n), scale=8)
def png(self) -> bytes:
rows = bytearray()
stride = self.width * 3
for y in range(self.height):
rows.append(0)
rows.extend(self.px[y * stride : (y + 1) * stride])
def chunk(kind: bytes, data: bytes) -> bytes:
return struct.pack(">I", len(data)) + kind + data + struct.pack(">I", zlib.crc32(kind + data) & 0xFFFFFFFF)
return (
b"\x89PNG\r\n\x1a\n"
+ chunk(b"IHDR", struct.pack(">IIBBBBB", self.width, self.height, 8, 2, 0, 0, 0))
+ chunk(b"IDAT", zlib.compress(bytes(rows), 9))
+ chunk(b"IEND", b"")
)
def architecture_png() -> bytes:
c = PngCanvas(1200, 640, (252, 254, 255))
blue, green, gold, purple, gray = (232, 242, 252), (237, 248, 237), (255, 248, 230), (246, 239, 250), (78, 91, 104)
c.rect(55, 260, 170, 90, blue, (47, 111, 163)); c.number_badge(140, 305, 1)
c.rect(310, 215, 245, 180, green, (63, 143, 77)); c.number_badge(432, 305, 2)
c.rect(650, 45, 230, 90, gold, (176, 122, 26)); c.number_badge(765, 90, 3)
c.rect(650, 185, 230, 105, blue, (47, 111, 163)); c.number_badge(765, 238, 4)
c.rect(650, 340, 230, 105, purple, (127, 85, 160)); c.number_badge(765, 393, 5)
c.rect(650, 500, 230, 90, gold, (176, 122, 26)); c.number_badge(765, 545, 6)
c.rect(970, 230, 175, 145, green, (63, 143, 77)); c.number_badge(1058, 303, 7)
c.arrow(225, 305, 310, 305, gray); c.arrow(555, 270, 650, 92, gray); c.arrow(555, 305, 650, 238, gray)
c.arrow(555, 335, 650, 393, gray); c.arrow(555, 375, 650, 545, gray); c.arrow(880, 238, 970, 285, gray); c.arrow(880, 393, 970, 325, gray)
return c.png()
def flow_png() -> bytes:
c = PngCanvas(1200, 640, (255, 255, 255))
colors = [(232, 242, 252), (237, 248, 237), (255, 248, 230), (246, 239, 250), (238, 242, 246)]
border = [(47, 111, 163), (63, 143, 77), (176, 122, 26), (127, 85, 160), (90, 105, 120)]
boxes = [(60, 90, 190, 105), (330, 90, 190, 105), (600, 90, 190, 105), (870, 90, 190, 105),
(330, 350, 190, 105), (600, 350, 190, 105), (870, 350, 190, 105)]
for i, (x, y, w, h) in enumerate(boxes, start=1):
c.rect(x, y, w, h, colors[(i - 1) % len(colors)], border[(i - 1) % len(border)])
c.number_badge(x + w // 2, y + h // 2, i)
gray = (78, 91, 104)
c.arrow(250, 142, 330, 142, gray); c.arrow(520, 142, 600, 142, gray); c.arrow(790, 142, 870, 142, gray)
c.arrow(965, 195, 965, 350, gray); c.arrow(870, 402, 790, 402, gray); c.arrow(600, 402, 520, 402, gray)
c.arrow(425, 350, 425, 195, gray)
return c.png()
def image_paragraph(rel_id: str, width_emu: int = 6_950_000, height_emu: int = 3_700_000) -> str:
return f"""
<w:p><w:pPr><w:spacing w:after="100"/></w:pPr><w:r><w:drawing>
<wp:inline distT="0" distB="0" distL="0" distR="0" xmlns:wp="http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing">
<wp:extent cx="{width_emu}" cy="{height_emu}"/><wp:docPr id="{rel_id[3:]}" name="{rel_id}.png"/>
<a:graphic xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main"><a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/picture">
<pic:pic xmlns:pic="http://schemas.openxmlformats.org/drawingml/2006/picture">
<pic:nvPicPr><pic:cNvPr id="0" name="{rel_id}.png"/><pic:cNvPicPr/></pic:nvPicPr>
<pic:blipFill><a:blip r:embed="{rel_id}" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"/><a:stretch><a:fillRect/></a:stretch></pic:blipFill>
<pic:spPr><a:xfrm><a:off x="0" y="0"/><a:ext cx="{width_emu}" cy="{height_emu}"/></a:xfrm><a:prstGeom prst="rect"><a:avLst/></a:prstGeom></pic:spPr>
</pic:pic></a:graphicData></a:graphic></wp:inline></w:drawing></w:r></w:p>
"""
def body_xml() -> str:
now = datetime.now().strftime("%Y-%m-%d %H:%M")
p: list[str] = []
p.append(para("my-intranet-app 아키텍처 분석 보고서", style="Title"))
p.append(para(f"작성일: {now} / 기준 경로: {BASE_DIR}", style="Subtitle"))
p.append(para("본 보고서의 주 목적은 현재 my-intranet-app의 아키텍처, 데이터 흐름, 주요 결합도와 개선 포인트를 분석하는 것입니다. WSL 제로 세팅 시 보존 범위는 마지막 운영 고려사항으로 덧붙였습니다."))
p.append(para("1. 분석 결론", style="Heading1"))
p.append(bullet("현재 시스템은 FastAPI 단일 애플리케이션 안에 화면 렌더링, JSON API, SQLite 접근, 캐시, 백그라운드 작업, 외부 ERP/WEHAGO 연동이 결합된 내부 업무 앱입니다."))
p.append(bullet("업무 데이터의 중심은 SQLite data.db이며, 프로젝트/회계/전표비교/캐시/작업 이력이 같은 DB에 함께 저장됩니다."))
p.append(bullet("구조 안정성 측면의 가장 큰 리스크는 main.py의 과도한 책임 집중과 대용량 SQLite 파일에 운영 데이터와 캐시가 공존하는 점입니다."))
p.append(bullet("속도는 런타임 캐시, system_page_cache, WEHAGO query/export projection, background job으로 보완하고 있으나, 장기적으로는 DB 슬림화와 모듈 분리가 필요합니다."))
p.append(para("2. 아키텍처 개요 이미지", style="Heading1"))
p.append(image_paragraph("rId10"))
p.append(table(
["번호", "구성요소", "설명"],
[
["1", "Browser/UI", "Jinja2로 내려받은 HTML과 브라우저 fetch API가 화면 갱신을 담당"],
["2", "FastAPI Runtime(main.py)", "라우트, HTML 렌더링, JSON API, DB 초기화, 캐시, 작업 큐 진입점"],
["3", "Templates", "dashboard/projects/process_cost/wehago_compare 등 서버 렌더링 화면"],
["4", "SQLite data.db", "업무 데이터, 설정, 캐시, 작업 상태의 중심 저장소"],
["5", "WEHAGO Compare", "전표 비교 전문 로직. 파일 정규화, 매칭, 리뷰, 추천, export"],
["6", "Hanmac External", "pymysql 기반 외부 ERP DB 조회 및 집계"],
["7", "Workers/Jobs", "캐시 재생성, snapshot, export, 유지보수 작업"],
],
[750, 2500, CONTENT_WIDTH - 3250],
))
p.append(para("3. 런타임/기술 스택", style="Heading1"))
p.append(table(
["영역", "현재 구성", "역할/관찰"],
[
["Web", "FastAPI, Uvicorn", "ASGI 기반 단일 서버. HTML과 JSON API를 같은 앱에서 제공"],
["UI", "Jinja2, CSS, Vanilla JS", "템플릿별 inline CSS/JS가 많고 fetch 기반 동적 로딩을 사용"],
["DB", "SQLite, SQLAlchemy", "로컬 data.db를 중심으로 업무/캐시/작업 데이터 저장"],
["Excel", "openpyxl", "업로드 파일 파싱, WEHAGO 상태별 xlsx 내보내기"],
["External DB", "pymysql", "Hanmac 외부 MySQL 접속/preview/aggregate"],
["DB Browser", "Datasette", "/db 및 /db-browser에서 내부 DB 조회 지원"],
],
[1400, 2600, CONTENT_WIDTH - 4000],
))
p.append(para("4. 코드 구조 분석", style="Heading1"))
p.append(table(
["파일/디렉터리", "아키텍처상 책임", "개선 관점"],
[
["main.py", "FastAPI app 생성, 라우트, DB 초기화, 화면별 bootstrap, 저장 API, system job, Hanmac 연동", "routers/services/repositories/jobs로 단계 분리 필요"],
["wehago_compare.py", "WEHAGO/ERP 전표 비교 도메인. 매칭, 리뷰, 추천, projection/cache/export", "비교 도메인으로 분리된 점은 좋으나 내부 함수가 매우 크고 캐시 책임도 함께 큼"],
["templates/*.html", "서버 렌더링 화면과 화면별 대형 JS/CSS", "공통 fetch/job polling/table rendering을 static 모듈로 분리 가능"],
["scripts/", "서버 실행, WEHAGO 수집/검증/보정, Windows portproxy", "운영 자동화와 일회성 보정 스크립트 구분 필요"],
["data.db", "업무 데이터와 캐시/작업 이력 저장", "운영 데이터와 재생성 캐시 분리 또는 보존 정책 필요"],
["backups/", "수동/시점 백업", "복구 가치 기준으로 최신/중요 백업만 관리 권장"],
],
[2000, 4300, CONTENT_WIDTH - 6300],
))
p.append(para("5. 주요 화면/API 경계", style="Heading1"))
p.append(table(
["화면/도메인", "대표 라우트", "핵심 데이터 흐름"],
[
["Dashboard", "/, /bootstrap-data, /dashboard/api/rebuild-cache", "transactions/project 집계 -> bootstrap/cache -> 차트/KPI"],
["Projects", "/projects, /projects/bootstrap-data, /projects/save-json", "project_* 조회/저장 -> 미계약/관련 프로젝트/비교 상세 API"],
["Process Cost", "/process-cost, /process-cost/bootstrap-data", "Hanmac/WEHAGO 소스 선택 -> 프로젝트별 수익/비용/진척/비율 계산"],
["Annual Summary", "/annual-summary, /annual-summary/bootstrap-data", "연도/월별 회계 집계 -> 차트 데이터"],
["WEHAGO Compare", "/wehago-compare/api/*", "원천 rows -> 비교 결과 -> 상태별 상세 -> 리뷰/매칭/export"],
["Hanmac Browser", "/hanmac-browser/api/*", "외부 MySQL 조회 -> preview/aggregate cache -> CSV export"],
["System Jobs", "/api/system-jobs/*", "무거운 cache rebuild/export 작업 생성 및 진행률 조회"],
],
[1900, 3300, CONTENT_WIDTH - 5200],
))
p.append(para("6. 데이터 흐름 이미지", style="Heading1"))
p.append(image_paragraph("rId11"))
p.append(table(
["번호", "흐름 단계", "설명"],
[
["1", "원천 데이터", "Excel 업로드, WEHAGO_DB 파일, Hanmac 외부 DB, 사용자 입력"],
["2", "수집/정규화", "main.py와 wehago_compare.py에서 날짜/금액/전표번호/프로젝트코드 정규화"],
["3", "영속 저장", "transactions, project_*, wehago_* 테이블에 저장"],
["4", "집계/비교 계산", "프로젝트 원가, 연도 집계, 전표 매칭, 상태별 metric 계산"],
["5", "캐시/작업", "system_page_cache, wehago query cache, background jobs로 무거운 조회 완화"],
["6", "API 응답", "bootstrap-data 및 상세 JSON API로 화면에 전달"],
["7", "화면 표시/export", "Jinja2 화면, fetch 갱신, xlsx/csv 다운로드"],
],
[750, 2200, CONTENT_WIDTH - 2950],
))
p.append(para("7. 데이터 아키텍처", style="Heading1"))
p.append(table(
["테이블 그룹", "대표 테이블", "아키텍처 의미"],
[
["업무 원장", "transactions", "회계 전표/거래 행의 중심 원천"],
["프로젝트", "project_basic_info, project_status, project_contract_info, project_billing_entries, project_collection_entries", "프로젝트 기본/계약/청구/수금/상태"],
["프로젝트 분석", "project_exec_budget_entries, project_actual_input_entries, project_task_plan_entries, project_analysis_settings", "원가/투입/계획/분석 설정"],
["WEHAGO 비교 원천", "wehago_source_files, wehago_voucher_rows, wehago_ledger_rows", "ERP 전표와 WEHAGO 원장 정규화 데이터"],
["WEHAGO 비교 결과", "wehago_comparison_results, wehago_recheck_reviews, wehago_manual_pair_matches", "비교 결과와 사용자가 만든 검토/매칭 상태"],
["캐시/작업", "system_page_cache, system_jobs, wehago_*_cache, hanmac_*_cache", "속도 보완용. 일부는 재생성 가능"],
["설정/운영", "app_option_items, app_keyword_rules, hanmac_holidays, db_backup_history", "분류 규칙, 옵션, 휴일, 백업 이력"],
],
[1800, 4300, CONTENT_WIDTH - 6100],
))
p.append(para("8. 구조 안정성/속도 개선 포인트", style="Heading1"))
p.append(table(
["개선 영역", "현재 리스크", "권장 방향"],
[
["main.py 책임 분리", "라우트/DB/worker/비즈니스 로직 집중", "도메인별 router, service, repository, job 모듈로 점진 분리"],
["DB 관리", "14GB 수준 SQLite에 운영 데이터와 캐시 공존", "캐시 보존 정책, VACUUM/ANALYZE, cache DB 분리 검토"],
["WEHAGO 비교", "projection/cache가 많고 상태별 경로가 복잡", "상태별 query path 정리, 캐시 키 문서화, 재계산 CLI 표준화"],
["Frontend", "템플릿별 inline JS/CSS가 큼", "공통 fetch/polling/render 유틸을 static JS/CSS로 이동"],
["작업 큐", "DB 테이블 기반 작업 상태와 런타임 worker 결합", "작업 타입/상태 전이 규칙 문서화 및 stale job 정리 강화"],
["테스트", "구조 변경 후 회귀 확인 경로 부족", "핵심 bootstrap API와 저장 API smoke test 추가"],
],
[1800, 3300, CONTENT_WIDTH - 5100],
))
p.append(para("9. WSL 제로 세팅 시 보존 범위(부가 운영 고려사항)", style="Heading1"))
p.append(para("이 절은 이관 방법 보고서가 아니라, 현재 아키텍처를 보존 가능한 상태로 유지하려면 어떤 정보를 어느 수준까지 관리해야 하는지에 대한 부가 판단입니다."))
p.append(table(
["대상", "보존 수준", "이유"],
[
["코드", "필수", "main.py, wehago_compare.py, templates, scripts, requirements는 앱 동작의 본체"],
["SQLite DB", "필수", "업무 데이터와 사용자 검토/매칭/설정이 data.db에 존재. 코드만으로 복구 불가"],
["WAL/SHM", "조건부 필수", "실행 중 복사라면 data.db-wal 변경분 누락 위험. 서버 중지 또는 checkpoint/backup 필요"],
["DB dump", "필수 또는 강력 권장", "새 환경 복원 검증용. 현재 dump.sql은 0 bytes라 유효하지 않음"],
["원천 Excel/WEHAGO_DB", "강력 권장", "재검증/재처리/비교 로직 개선 시 기준 자료"],
["사용자 검토/수동매칭", "필수", "wehago_recheck_reviews, wehago_manual_pair_matches 등은 재생성 어려움"],
["캐시 테이블", "선택", "속도에는 도움되지만 구조 개선 후 재생성 가능. DB 슬림화 대상"],
["backups", "선별", "최신 정상본과 구조 변경 직전본 위주로 보존"],
[".venv/__pycache__", "불필요", "새 WSL에서 재생성"],
],
[2200, 1600, CONTENT_WIDTH - 3800],
))
p.append(para("10. 최종 권고", style="Heading1"))
p.append(bullet("아키텍처 개선의 1순위는 기능 추가보다 책임 분리와 DB/캐시 관리 기준 정립입니다."))
p.append(bullet("WSL 제로 세팅을 하더라도 목표는 '코드 이관'이 아니라 '동일 업무 상태를 복원 가능한 형태로 보존'하는 것입니다."))
p.append(bullet("구조 개선 시작 전에는 유효한 SQLite 백업/dump를 새로 만들고, 원천파일과 사용자 검토 데이터의 보존 여부를 반드시 확인해야 합니다."))
p.append(para("Appendix. 관찰된 현재 상태", style="Heading1"))
p.append(bullet("data.db 약 14GB, data.db-wal 약 183MB, dump.sql 0 bytes 상태를 확인했습니다."))
p.append(bullet("현재 git working tree에는 기존 수정 파일과 미추적 파일이 존재합니다. 구조 변경 전 기준점을 별도로 고정하는 것이 좋습니다."))
p.append(bullet("검토 파일: requirements.txt, main.py, wehago_compare.py, templates/*.html, data.db sqlite_master schema."))
sect = f'<w:sectPr><w:pgSz w:w="{PAGE_WIDTH}" w:h="16838"/><w:pgMar w:top="720" w:right="{MARGIN}" w:bottom="720" w:left="{MARGIN}" w:header="360" w:footer="360" w:gutter="0"/></w:sectPr>'
return "".join(p) + sect
def styles_xml() -> str:
return """<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<w:styles xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
<w:style w:type="paragraph" w:default="1" w:styleId="Normal"><w:name w:val="Normal"/><w:qFormat/><w:rPr><w:rFonts w:ascii="Arial" w:hAnsi="Arial" w:eastAsia="Malgun Gothic"/><w:sz w:val="20"/></w:rPr><w:pPr><w:spacing w:after="90" w:line="246" w:lineRule="auto"/></w:pPr></w:style>
<w:style w:type="paragraph" w:styleId="Title"><w:name w:val="Title"/><w:basedOn w:val="Normal"/><w:qFormat/><w:rPr><w:b/><w:sz w:val="34"/></w:rPr><w:pPr><w:spacing w:after="200"/></w:pPr></w:style>
<w:style w:type="paragraph" w:styleId="Subtitle"><w:name w:val="Subtitle"/><w:basedOn w:val="Normal"/><w:qFormat/><w:rPr><w:color w:val="5B6770"/><w:sz w:val="18"/></w:rPr></w:style>
<w:style w:type="paragraph" w:styleId="Heading1"><w:name w:val="heading 1"/><w:basedOn w:val="Normal"/><w:qFormat/><w:rPr><w:b/><w:color w:val="1F4E79"/><w:sz w:val="25"/></w:rPr><w:pPr><w:spacing w:before="250" w:after="100"/><w:outlineLvl w:val="0"/></w:pPr></w:style>
<w:style w:type="table" w:styleId="TableGrid"><w:name w:val="Table Grid"/><w:basedOn w:val="TableNormal"/><w:qFormat/><w:tblPr><w:tblBorders><w:top w:val="single" w:sz="4" w:color="AAB7C4"/><w:left w:val="single" w:sz="4" w:color="AAB7C4"/><w:bottom w:val="single" w:sz="4" w:color="AAB7C4"/><w:right w:val="single" w:sz="4" w:color="AAB7C4"/><w:insideH w:val="single" w:sz="4" w:color="D5DDE6"/><w:insideV w:val="single" w:sz="4" w:color="D5DDE6"/></w:tblBorders></w:tblPr></w:style>
</w:styles>"""
def write_docx() -> None:
created = datetime.utcnow().strftime("%Y-%m-%dT%H:%M:%SZ")
document = f"""<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"><w:body>{body_xml()}</w:body></w:document>"""
files = {
"[Content_Types].xml": """<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Default Extension="xml" ContentType="application/xml"/><Default Extension="png" ContentType="image/png"/><Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/><Override PartName="/word/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.styles+xml"/><Override PartName="/word/settings.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.settings+xml"/><Override PartName="/docProps/core.xml" ContentType="application/vnd.openxmlformats-package.core-properties+xml"/><Override PartName="/docProps/app.xml" ContentType="application/vnd.openxmlformats-officedocument.extended-properties+xml"/></Types>""",
"_rels/.rels": """<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/><Relationship Id="rId2" Type="http://schemas.openxmlformats.org/package/2006/relationships/metadata/core-properties" Target="docProps/core.xml"/><Relationship Id="rId3" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/extended-properties" Target="docProps/app.xml"/></Relationships>""",
"word/_rels/document.xml.rels": """<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/><Relationship Id="rId2" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/settings" Target="settings.xml"/><Relationship Id="rId10" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/image" Target="media/architecture.png"/><Relationship Id="rId11" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/image" Target="media/dataflow.png"/></Relationships>""",
"word/document.xml": document,
"word/styles.xml": styles_xml(),
"word/settings.xml": """<?xml version="1.0" encoding="UTF-8" standalone="yes"?><w:settings xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:zoom w:percent="100"/></w:settings>""",
"word/media/architecture.png": architecture_png(),
"word/media/dataflow.png": flow_png(),
"docProps/core.xml": f"""<?xml version="1.0" encoding="UTF-8" standalone="yes"?><cp:coreProperties xmlns:cp="http://schemas.openxmlformats.org/package/2006/metadata/core-properties" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:dcterms="http://purl.org/dc/terms/" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"><dc:title>my-intranet-app 아키텍처 분석 보고서</dc:title><dc:creator>Codex</dc:creator><cp:lastModifiedBy>Codex</cp:lastModifiedBy><dcterms:created xsi:type="dcterms:W3CDTF">{created}</dcterms:created><dcterms:modified xsi:type="dcterms:W3CDTF">{created}</dcterms:modified></cp:coreProperties>""",
"docProps/app.xml": """<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Properties xmlns="http://schemas.openxmlformats.org/officeDocument/2006/extended-properties"><Application>Codex OOXML Generator</Application></Properties>""",
}
REPORT_DIR.mkdir(exist_ok=True)
with zipfile.ZipFile(OUTPUT_PATH, "w", compression=zipfile.ZIP_DEFLATED) as docx:
for name, content in files.items():
docx.writestr(name, content)
if __name__ == "__main__":
write_docx()
print(OUTPUT_PATH)