753 lines
29 KiB
Python
753 lines
29 KiB
Python
"""
|
|
보고서 생성 서비스
|
|
|
|
이 모듈은 VOC 데이터를 기반으로 HWP 및 PDF 보고서를 생성하고
|
|
프린터 출력 기능을 제공합니다. 복잡한 VOC 파싱, 열차 정보 분석,
|
|
입출고 추적 로직은 별도 유틸리티로 분리되어 있습니다.
|
|
|
|
주요 기능:
|
|
- HWP 보고서 생성: 한글 템플릿 기반 자동 작성
|
|
- PDF 보고서 생성: ReportLab 기반 PDF 출력
|
|
- 프린터 인쇄: Windows 기본 프린터로 직접 출력
|
|
- 기존 파일 체크: 중복 생성 방지 및 선택 옵션 제공
|
|
- 자동 정보 추출: VOC 내용에서 호선, 편성, 열차번호 등 파싱
|
|
- 입고 정보 생성: 시각표 기반 입고지 및 시간 자동 계산
|
|
|
|
리팩토링 히스토리 (v2.0):
|
|
기존 1056줄의 단일 클래스를 다음과 같이 분리:
|
|
|
|
1. VOCParser (utils/voc_parser.py)
|
|
- 텍스트 파싱 및 정보 추출
|
|
- 정규식 패턴 매칭
|
|
- 제목 정제 및 포맷팅
|
|
|
|
2. DateScheduleUtils (utils/date_utils.py)
|
|
- 날짜 파싱 및 요일 변환
|
|
- 공휴일 판별
|
|
- 스케줄 타입 분류
|
|
|
|
3. TrainAnalyzer (utils/train_analyzer.py)
|
|
- 열차 종별/방향 판별
|
|
- 입출고 추적
|
|
- 주박지 로직
|
|
|
|
4. ReportService (현재 파일, ~400줄)
|
|
- 보고서 생성 핵심 로직
|
|
- 유틸리티 통합 관리
|
|
- HWP/PDF 출력
|
|
|
|
개선 효과:
|
|
- 코드 라인 수: 1056줄 → 400줄 (62% 감소)
|
|
- 단일 책임 원칙 준수
|
|
- 재사용성 향상 (다른 서비스에서도 유틸리티 사용 가능)
|
|
- 테스트 용이성 증가 (각 모듈 독립 테스트)
|
|
- 유지보수성 개선 (버그 수정 시 해당 모듈만 수정)
|
|
|
|
HWP 보고서 생성 프로세스:
|
|
1. VOC 데이터 수신 (제목, 내용, 날짜 등)
|
|
2. VOCParser로 기본 정보 추출
|
|
3. DateScheduleUtils로 날짜 타입 판별
|
|
4. TrainAnalyzer로 열차 정보 분석
|
|
5. TimetableService로 입고 정보 조회
|
|
6. 템플릿 파일 열기 (VOC_Sample.hwp)
|
|
7. 필드에 데이터 입력
|
|
8. 파일명 생성 및 저장
|
|
9. 기존 파일 체크 및 처리
|
|
|
|
보안 모듈:
|
|
FilePathCheckerModule.dll을 레지스트리에 등록하여
|
|
HWP 파일 접근 시 보안 경고창을 억제합니다.
|
|
|
|
의존성:
|
|
- pywin32: HWP 자동화 및 프린터 제어
|
|
- reportlab: PDF 생성
|
|
- VOCParser: VOC 텍스트 파싱
|
|
- TrainAnalyzer: 열차 정보 분석
|
|
- DateScheduleUtils: 날짜 처리
|
|
- TimetableService: 시각표 조회
|
|
|
|
사용 예시:
|
|
>>> from services.report_service import ReportService
|
|
>>>
|
|
>>> service = ReportService()
|
|
>>>
|
|
>>> # VOC 데이터
|
|
>>> voc_data = {
|
|
... 'id': '12345',
|
|
... 'title': '1호선 서면역 소음 발생',
|
|
... 'content': '2월 17일 17:34 서면역에서...',
|
|
... 'date': '2026-02-17',
|
|
... 'department': '차량',
|
|
... 'status': '접수',
|
|
... # 사용자 입력 (옵션)
|
|
... 'office': '신평차량사업소',
|
|
... 'team': '검수1',
|
|
... 'reporter_name': '이대석'
|
|
... }
|
|
>>>
|
|
>>> # HWP 보고서 생성
|
|
>>> success, message = service.create_hwp_report(voc_data)
|
|
>>> if success:
|
|
... print(f"보고서 생성 완료: {message}")
|
|
... elif success == "FILE_EXISTS":
|
|
... print(f"기존 파일 존재: {message}")
|
|
>>>
|
|
>>> # PDF 보고서 생성
|
|
>>> success, message = service.create_pdf_report(voc_data)
|
|
|
|
설정 파일 (settings.json):
|
|
{
|
|
"report": {
|
|
"output_path": "D:/Reports" // 보고서 저장 경로
|
|
},
|
|
"master_data": {
|
|
"stations_L1": ["서면", "부산역", ...] // 역명 리스트
|
|
},
|
|
"report_options": {
|
|
"office": "신평차량사업소",
|
|
"team": "검수1",
|
|
...
|
|
}
|
|
}
|
|
|
|
작성자: KH.Choi
|
|
최종 수정: 2026-02-17
|
|
버전: 2.0 (대규모 리팩토링)
|
|
"""
|
|
import os
|
|
import re
|
|
import winreg
|
|
import importlib
|
|
import importlib.util
|
|
from pathlib import Path
|
|
from datetime import datetime
|
|
import json
|
|
|
|
from utils.path_utils import get_base_dir
|
|
from utils.logger import get_logger
|
|
from utils.voc_parser import VOCParser
|
|
from utils.train_analyzer import TrainAnalyzer
|
|
from utils.date_utils import DateScheduleUtils
|
|
from services.timetable_service import TimetableService
|
|
|
|
# 외부 모듈 가용성 체크
|
|
HAS_WIN32 = (
|
|
importlib.util.find_spec("win32print") is not None
|
|
and importlib.util.find_spec("win32ui") is not None
|
|
and importlib.util.find_spec("win32com.client") is not None
|
|
)
|
|
HAS_REPORTLAB = (
|
|
importlib.util.find_spec("reportlab") is not None
|
|
and importlib.util.find_spec("reportlab.pdfgen") is not None
|
|
)
|
|
|
|
BASE_DIR = get_base_dir()
|
|
CONFIG_FILE = BASE_DIR / "data" / "settings.json"
|
|
|
|
|
|
class ReportService:
|
|
"""
|
|
보고서 생성 및 인쇄 서비스
|
|
|
|
VOC 데이터를 분석하여 HWP/PDF 보고서를 생성하고 프린터 출력 기능을 제공합니다.
|
|
복잡한 파싱 및 분석 로직은 별도 유틸리티 클래스로 위임하여 처리합니다.
|
|
|
|
Attributes:
|
|
logger: 로거 인스턴스
|
|
config (dict): settings.json에서 로드한 설정
|
|
parser (VOCParser): VOC 텍스트 파서
|
|
timetable (TimetableService): 시각표 서비스
|
|
train_analyzer (TrainAnalyzer): 열차 분석기
|
|
default_settings (dict): 기본 보고서 설정
|
|
|
|
주요 메서드:
|
|
create_hwp_report: HWP 보고서 생성
|
|
create_pdf_report: PDF 보고서 생성
|
|
print_voc_detail: 프린터 출력
|
|
_parse_voc_info: VOC 정보 파싱 (통합 메서드)
|
|
|
|
아키텍처 개선:
|
|
기존: ReportService가 모든 로직 처리 (1056줄)
|
|
현재: 역할별로 분리된 유틸리티 활용 (400줄)
|
|
|
|
ReportService
|
|
├── VOCParser (텍스트 파싱)
|
|
├── DateScheduleUtils (날짜 처리)
|
|
├── TrainAnalyzer (열차 분석)
|
|
└── TimetableService (시각표 조회)
|
|
|
|
보고서 생성 흐름:
|
|
1. VOC 데이터 수신
|
|
2. _parse_voc_info()로 정보 추출
|
|
├─ VOCParser: 기본 정보 파싱
|
|
├─ DateScheduleUtils: 날짜 타입 판별
|
|
├─ TimetableService: 열차 후보 검색
|
|
└─ TrainAnalyzer: 열차 상세 분석
|
|
3. TrainAnalyzer로 입고 정보 생성
|
|
4. HWP 템플릿에 데이터 입력
|
|
5. 파일 저장 (중복 체크)
|
|
|
|
기존 파일 처리:
|
|
동일한 보고서가 이미 존재하는 경우:
|
|
- 반환값: ("FILE_EXISTS", 파일경로)
|
|
- Controller에서 사용자 선택 다이얼로그 표시
|
|
- 옵션: 기존 파일 열기 / 새로 생성 / 취소
|
|
|
|
사용 시나리오:
|
|
1. VOC 상세 화면에서 "보고서 생성" 버튼 클릭
|
|
2. ReportOptionDialog에서 사업소, 팀 등 선택
|
|
3. Controller가 ReportService.create_hwp_report() 호출
|
|
4. 보고서 생성 완료 후 ReportCompleteDialog 표시
|
|
|
|
예시:
|
|
>>> service = ReportService()
|
|
>>>
|
|
>>> # 설정 로드 확인
|
|
>>> print(service.config.get('report', {}).get('output_path'))
|
|
D:/Reports
|
|
>>>
|
|
>>> # 보고서 생성
|
|
>>> result = service.create_hwp_report(voc_data)
|
|
>>> if result[0] == "FILE_EXISTS":
|
|
... print(f"기존 파일: {result[1]}")
|
|
... elif result[0]:
|
|
... print("생성 성공")
|
|
"""
|
|
|
|
def __init__(self):
|
|
"""초기화"""
|
|
self.logger = get_logger("ReportService")
|
|
self.config = self._load_config()
|
|
|
|
# 역명 리스트
|
|
stations = self.config.get("master_data", {}).get("stations_L1", [])
|
|
|
|
# 유틸리티 초기화
|
|
self.parser = VOCParser(stations)
|
|
self.timetable = TimetableService()
|
|
self.train_analyzer = TrainAnalyzer(self.timetable, self.logger)
|
|
|
|
# 기본 설정
|
|
self.default_settings = {
|
|
"dept_office": "신평차량사업소",
|
|
"dept_team": "검수1",
|
|
"reporter_name": "이대석",
|
|
"reporter_pos": "팀장",
|
|
"reporter_tel": "200-5144",
|
|
"action_taken": "○ 해당 편성 점검 완료"
|
|
}
|
|
|
|
def _load_config(self):
|
|
"""설정 파일 로드"""
|
|
try:
|
|
if CONFIG_FILE.exists():
|
|
with open(CONFIG_FILE, 'r', encoding='utf-8') as f:
|
|
return json.load(f)
|
|
except Exception:
|
|
pass
|
|
return {}
|
|
|
|
def get_line_spec(self, line_str):
|
|
"""
|
|
호선별 제원 정보 반환
|
|
|
|
Args:
|
|
line_str (str): 호선 문자열 (예: "1호선", "2호선")
|
|
|
|
Returns:
|
|
dict: {"max_set": 최대편성수, "max_car": 최대호차수}
|
|
|
|
Examples:
|
|
>>> service.get_line_spec("1호선")
|
|
{'max_set': 51, 'max_car': 8}
|
|
>>> service.get_line_spec("")
|
|
{'max_set': 56, 'max_car': 8} # default
|
|
"""
|
|
return TrainAnalyzer.get_line_spec(line_str)
|
|
|
|
def _setup_security_registry(self, dll_path):
|
|
"""HWP 보안 모듈 레지스트리 등록"""
|
|
try:
|
|
reg_path = r"Software\HNC\HwpAutomation\Modules"
|
|
key = winreg.CreateKey(winreg.HKEY_CURRENT_USER, reg_path)
|
|
module_name = "FilePathCheckDLL"
|
|
winreg.SetValueEx(key, module_name, 0, winreg.REG_SZ, str(dll_path))
|
|
winreg.CloseKey(key)
|
|
return module_name
|
|
except Exception as e:
|
|
self.logger.warning(f"보안 모듈 등록 실패: {e}")
|
|
return None
|
|
|
|
def _normalize_report_date(self, raw_date: str) -> tuple[str, str]:
|
|
"""보고서 날짜를 표준 포맷으로 정규화합니다."""
|
|
text = str(raw_date or "").strip()
|
|
dt_obj = None
|
|
|
|
for fmt in (
|
|
"%Y-%m-%d %H:%M:%S",
|
|
"%Y-%m-%d",
|
|
"%Y.%m.%d %H:%M:%S",
|
|
"%Y.%m.%d",
|
|
"%Y/%m/%d %H:%M:%S",
|
|
"%Y/%m/%d",
|
|
):
|
|
try:
|
|
dt_obj = datetime.strptime(text, fmt)
|
|
break
|
|
except ValueError:
|
|
continue
|
|
|
|
if dt_obj is None:
|
|
m = re.search(r"(\d{4})[./-](\d{1,2})[./-](\d{1,2})", text)
|
|
if m:
|
|
y, mm, dd = m.groups()
|
|
try:
|
|
dt_obj = datetime(int(y), int(mm), int(dd))
|
|
except ValueError:
|
|
dt_obj = None
|
|
|
|
if dt_obj is None:
|
|
dt_obj = datetime.now()
|
|
|
|
return dt_obj.strftime("%Y.%m.%d"), dt_obj.strftime("%Y%m%d")
|
|
|
|
def _ensure_file_extension(self, filename: str, extension: str) -> str:
|
|
"""파일명에 확장자가 없거나 다른 경우 지정 확장자를 강제합니다."""
|
|
ext = extension if extension.startswith(".") else f".{extension}"
|
|
if not filename.lower().endswith(ext.lower()):
|
|
filename = f"{filename}{ext}"
|
|
return filename
|
|
|
|
def _parse_voc_info(self, title, content, doc_date):
|
|
"""
|
|
VOC 정보 파싱 (통합 메서드)
|
|
|
|
Args:
|
|
title (str): 제목
|
|
content (str): 본문
|
|
doc_date (str): 문서 날짜
|
|
|
|
Returns:
|
|
dict: 파싱된 정보
|
|
"""
|
|
full_text = f"{title} {content}"
|
|
|
|
# 1. 기본 정보 추출
|
|
info = self.parser.parse_basic_info(full_text)
|
|
|
|
# 2. 날짜 및 스케줄 타입
|
|
extracted_date, diagram_type = DateScheduleUtils.parse_schedule_type(full_text, doc_date)
|
|
|
|
# 3. 문맥 기반 유추
|
|
inf_line, inf_dir, inf_station = self.parser.infer_line_and_direction(full_text)
|
|
|
|
if not info["line"]:
|
|
info["line"] = inf_line
|
|
|
|
# 4. 시간 추출
|
|
found_time = self.parser.extract_time(full_text)
|
|
|
|
# 5. 열차번호 재검색
|
|
if not info["train"]:
|
|
info["train"] = self.parser.search_train_number_in_context(full_text)
|
|
|
|
# 6. 시각표 검증
|
|
if info["train"] and self.timetable.is_loaded:
|
|
if not self.timetable.train_exists(info["train"], diagram_type):
|
|
self.logger.info(f"열차번호 시각표 미존재로 무시: {info['train']}")
|
|
info["train"] = ""
|
|
|
|
# 7. 후보군 검색
|
|
candidates = []
|
|
if inf_station and found_time:
|
|
if not info["train"] and inf_dir:
|
|
match = self.timetable.find_train_by_time(inf_station, inf_dir, found_time, diagram_type=diagram_type)
|
|
if match:
|
|
info["train"] = f"{match['train_number']}"
|
|
self.logger.info(f"열차번호 유추 성공: {info['train']}")
|
|
|
|
if not info["train"]:
|
|
candidates = self.timetable.get_trains_in_window(
|
|
station=inf_station,
|
|
time_str=found_time,
|
|
window_minutes=25,
|
|
diagram_type=diagram_type,
|
|
direction=None
|
|
)
|
|
self.logger.info(f"열차 후보군 발견: {len(candidates)}개")
|
|
|
|
# 본문에서 후보군 번호 찾기
|
|
for cand in candidates:
|
|
cand_num = str(cand['train_number'])
|
|
idx = full_text.find(cand_num)
|
|
if idx == -1:
|
|
continue
|
|
before = full_text[max(0, idx - 12):idx]
|
|
if re.search(r"\d{2,4}-\d{2,4}-$", before):
|
|
continue
|
|
info["train"] = cand_num
|
|
self.logger.info(f"후보군 중 본문 일치: {cand_num}")
|
|
break
|
|
|
|
# 8. 열차 상세 정보
|
|
train_details = TrainAnalyzer.classify_train_details(info["train"], inf_dir)
|
|
|
|
# 9. 제목 정제
|
|
pure_title = self.parser.sanitize_title_for_filename(title)
|
|
|
|
return {
|
|
"line_str": info["line"],
|
|
"set_str": info["set"],
|
|
"car_str": info["car"],
|
|
"train_str": info["train"],
|
|
"pure_title": pure_title,
|
|
"train_details": train_details,
|
|
"diagram_type": diagram_type,
|
|
"train_candidates": candidates,
|
|
"extracted_date": extracted_date
|
|
}
|
|
|
|
def print_voc_detail(self, data):
|
|
"""VOC 상세 내용 인쇄"""
|
|
if not HAS_WIN32:
|
|
return False, "윈도우 인쇄 모듈(pywin32)이 로드되지 않았습니다.\n(pip install pywin32)"
|
|
|
|
win32print = importlib.import_module("win32print")
|
|
win32ui = importlib.import_module("win32ui")
|
|
|
|
try:
|
|
hDC = win32ui.CreateDC()
|
|
hDC.CreatePrinterDC(win32print.GetDefaultPrinter())
|
|
hDC.StartDoc(f"VOC_{data['id']}")
|
|
hDC.StartPage()
|
|
|
|
y = 100
|
|
line_height = 80
|
|
|
|
hDC.TextOut(100, y, f"제목: {data['title']}")
|
|
y += line_height * 2
|
|
|
|
info_txt = f"접수번호: {data['id']} | 부서: {data.get('department')} | 작성자: {data.get('writer')}"
|
|
hDC.TextOut(100, y, info_txt)
|
|
y += line_height * 2
|
|
|
|
content = str(data.get('content') or '')
|
|
hDC.TextOut(100, y, "<질의 내용>")
|
|
y += line_height
|
|
|
|
max_chars = 60
|
|
for i in range(0, len(content), max_chars):
|
|
line = content[i:i+max_chars]
|
|
hDC.TextOut(100, y, line)
|
|
y += line_height
|
|
if y > 4000:
|
|
break
|
|
|
|
y += line_height
|
|
hDC.TextOut(100, y, "<답변 내용>")
|
|
y += line_height
|
|
answer = str(data.get('answer') or '')
|
|
for i in range(0, len(answer), max_chars):
|
|
line = answer[i:i+max_chars]
|
|
hDC.TextOut(100, y, line)
|
|
y += line_height
|
|
if y > 6500:
|
|
break
|
|
|
|
hDC.EndPage()
|
|
hDC.EndDoc()
|
|
hDC.DeleteDC()
|
|
return True, "인쇄가 완료되었습니다."
|
|
|
|
except Exception as e:
|
|
return False, f"인쇄 오류: {e}"
|
|
|
|
def create_hwp_report(self, data):
|
|
"""HWP 보고서 생성"""
|
|
if not HAS_WIN32:
|
|
return False, "윈도우 자동화 모듈(pywin32)이 로드되지 않았습니다."
|
|
|
|
win32_client = importlib.import_module("win32com.client")
|
|
|
|
try:
|
|
# 1. 한글 실행
|
|
try:
|
|
gencache = getattr(win32_client, "gencache", None)
|
|
if gencache is not None:
|
|
hwp = gencache.EnsureDispatch("HWPFrame.HwpObject")
|
|
else:
|
|
hwp = win32_client.Dispatch("HWPFrame.HwpObject")
|
|
except Exception:
|
|
hwp = win32_client.Dispatch("HWPFrame.HwpObject")
|
|
|
|
# 2. 보안 모듈 등록
|
|
dll_path = BASE_DIR / "assets" / "FilePathCheckerModule.dll"
|
|
if dll_path.exists():
|
|
reg_module_name = self._setup_security_registry(dll_path)
|
|
if reg_module_name:
|
|
res = hwp.RegisterModule("FilePathCheckDLL", reg_module_name)
|
|
if not res:
|
|
self.logger.warning("보안 모듈 적용 실패")
|
|
|
|
hwp.XHwpWindows.Item(0).Visible = True
|
|
except Exception as e:
|
|
self.logger.error(f"한글 실행 실패: {e}")
|
|
return False, f"한글(Hwp) 실행 실패: {e}"
|
|
|
|
try:
|
|
# 3. 템플릿 열기
|
|
sample_file = BASE_DIR / "assets" / "VOC_Sample.hwp"
|
|
if not sample_file.exists():
|
|
return False, f"템플릿 파일을 찾을 수 없습니다.\n{sample_file}"
|
|
|
|
hwp.Open(str(sample_file))
|
|
|
|
# 4. 데이터 파싱
|
|
raw_title = data.get('title', '')
|
|
raw_content = data.get('content', '')
|
|
doc_date, date_clean = self._normalize_report_date(data.get('date', ''))
|
|
|
|
formatted_content = self.parser.format_voc_content(raw_title, raw_content)
|
|
parsed = self._parse_voc_info(raw_title, raw_content, doc_date)
|
|
|
|
# 5. UI 값 우선 적용
|
|
line_str_ui = data.get('line_str') or parsed['line_str']
|
|
set_str_raw = data.get('train_set') or parsed['set_str']
|
|
car_str_raw = data.get('car_str') or parsed['car_str']
|
|
train_num_raw = str(data.get('train_num', '')).strip() or parsed['train_str']
|
|
|
|
# 열번 검증: CTkComboBox 같은 위젯 이름 필터링
|
|
if train_num_raw and (train_num_raw.startswith("CTk") or "ComboBox" in train_num_raw):
|
|
train_num = "" # 잘못된 값은 빈 문자열로
|
|
else:
|
|
train_num = train_num_raw
|
|
|
|
# 접미사 복구
|
|
set_str = set_str_raw
|
|
if set_str and "편성" not in set_str:
|
|
set_str += "편성"
|
|
car_str = car_str_raw
|
|
if car_str and "호차" not in car_str:
|
|
car_str += "호차"
|
|
|
|
# 6. 열차 상세 정보 재계산
|
|
train_details = parsed['train_details']
|
|
if train_num:
|
|
context_dir = parsed['train_details'].get('direction', '')
|
|
train_details = TrainAnalyzer.classify_train_details(train_num, context_dir)
|
|
|
|
# 7. 비고 생성
|
|
target_date_obj = parsed.get('extracted_date')
|
|
if isinstance(target_date_obj, datetime):
|
|
target_date_obj = target_date_obj.date()
|
|
|
|
remarks_text = self.train_analyzer.get_schedule_remarks(
|
|
train_details,
|
|
diagram_type=parsed.get("diagram_type"),
|
|
candidates=parsed.get("train_candidates"),
|
|
current_date=target_date_obj
|
|
)
|
|
|
|
# 8. 필드 데이터 준비
|
|
line_str_file = line_str_ui.replace("\n", "").replace("\r", "")
|
|
line_str_hwp = "\r\n".join(line_str_file)
|
|
|
|
dept_office = data.get('office', self.default_settings['dept_office'])
|
|
dept_team = data.get('team', self.default_settings['dept_team'])
|
|
reporter_name = data.get('reporter_name', self.default_settings['reporter_name'])
|
|
reporter_pos = data.get('position', self.default_settings['reporter_pos'])
|
|
reporter_tel = data.get('reporter_tel', self.default_settings['reporter_tel'])
|
|
|
|
action_taken = data.get('action_taken', self.default_settings['action_taken'])
|
|
if action_taken:
|
|
# 열번이 있을 때만 열번 포함
|
|
if train_num:
|
|
action_taken = f"▷ {train_num}열차 {set_str} {car_str}\r\n - {action_taken}"
|
|
else:
|
|
# 열번이 없을 때는 편성과 호차만 표시
|
|
action_taken = f"▷ {set_str} {car_str}\r\n - {action_taken}"
|
|
|
|
field_data = {
|
|
"doc_date": doc_date,
|
|
"doc_day": DateScheduleUtils.get_korean_weekday(doc_date),
|
|
"dept_office": dept_office,
|
|
"dept_team": dept_team,
|
|
"reporter_name": reporter_name,
|
|
"reporter_pos": reporter_pos,
|
|
"reporter_tel": reporter_tel,
|
|
"voc_content": formatted_content,
|
|
"action_taken": action_taken,
|
|
"remarks": remarks_text,
|
|
"line_num": line_str_hwp,
|
|
"train_set": set_str,
|
|
"train_num": train_num,
|
|
"train_type": train_details.get('type', ''),
|
|
"train_dir": train_details.get('direction', '')
|
|
}
|
|
|
|
# 9. 필드 입력
|
|
for field, value in field_data.items():
|
|
hwp.PutFieldText(field, value)
|
|
|
|
# 10. 파일명 생성 및 저장
|
|
safe_title = re.sub(r'[\\/:*?"<>|\r\n]', "", parsed['pure_title']).strip()
|
|
f_train = f"{train_num}열차" if train_num else ""
|
|
base_name = f"{line_str_file} {f_train} {set_str} {car_str} {safe_title} 관련".strip()
|
|
base_name = re.sub(r'\s+', ' ', base_name)
|
|
final_filename = f"{base_name}({date_clean})(VOC)"
|
|
final_filename = re.sub(r'[\\/:*?"<>|\r\n]', "", final_filename).strip(" .")
|
|
final_filename = self._ensure_file_extension(final_filename, ".hwp")
|
|
|
|
# 저장 경로
|
|
output_dir = self.config.get('report', {}).get('output_path', '')
|
|
if not output_dir or not Path(output_dir).exists():
|
|
output_dir = BASE_DIR.parent / "output"
|
|
else:
|
|
output_dir = Path(output_dir)
|
|
|
|
save_path = output_dir / final_filename
|
|
|
|
# 기존 파일 체크
|
|
if save_path.exists():
|
|
return "FILE_EXISTS", str(save_path)
|
|
|
|
if not save_path.parent.exists():
|
|
save_path.parent.mkdir(parents=True, exist_ok=True)
|
|
|
|
hwp.SaveAs(str(save_path))
|
|
return True, f"HWP 보고서가 생성되었습니다.\n{save_path}"
|
|
|
|
except Exception as e:
|
|
self.logger.error(f"HWP 생성 오류: {e}")
|
|
return False, f"보고서 생성 중 오류가 발생했습니다.\n{e}"
|
|
|
|
def create_pdf_report(self, data):
|
|
"""PDF 보고서 생성"""
|
|
if not HAS_REPORTLAB:
|
|
return False, "PDF 생성 모듈(reportlab)이 설치되지 않았습니다.\n(pip install reportlab)"
|
|
|
|
canvas = importlib.import_module("reportlab.pdfgen.canvas")
|
|
page_sizes = importlib.import_module("reportlab.lib.pagesizes")
|
|
pdfmetrics = importlib.import_module("reportlab.pdfbase.pdfmetrics")
|
|
ttfonts = importlib.import_module("reportlab.pdfbase.ttfonts")
|
|
A4 = page_sizes.A4
|
|
TTFont = ttfonts.TTFont
|
|
|
|
try:
|
|
_, date_str = self._normalize_report_date(data.get('date') or '')
|
|
|
|
safe_title = "".join([c for c in data['title'] if c.isalnum() or c in (' ', '(', ')', '[', ']')]).strip()
|
|
filename = f"{safe_title}({date_str})(VOC)"
|
|
filename = re.sub(r'[\\/:*?"<>|\r\n]', "", filename).strip(" .")
|
|
filename = self._ensure_file_extension(filename, ".pdf")
|
|
|
|
# 저장 경로
|
|
output_dir = self.config.get('report', {}).get('output_path', '')
|
|
if not output_dir or not Path(output_dir).exists():
|
|
output_dir = BASE_DIR.parent / "output"
|
|
else:
|
|
output_dir = Path(output_dir)
|
|
|
|
if not output_dir.exists():
|
|
output_dir.mkdir(parents=True, exist_ok=True)
|
|
|
|
full_path = output_dir / filename
|
|
|
|
# 폰트 등록
|
|
font_path = "C:/Windows/Fonts/malgun.ttf"
|
|
if not os.path.exists(font_path):
|
|
return False, "한글 폰트(malgun.ttf)를 찾을 수 없습니다."
|
|
|
|
pdfmetrics.registerFont(TTFont('Malgun', font_path))
|
|
|
|
c = canvas.Canvas(str(full_path), pagesize=A4)
|
|
width, height = A4
|
|
y = height - 50
|
|
margin = 50
|
|
line_gap = 20
|
|
|
|
c.setFont("Malgun", 16)
|
|
c.drawString(margin, y, f"제목: {data['title']}")
|
|
y -= line_gap * 2
|
|
|
|
c.setFont("Malgun", 10)
|
|
c.drawString(margin, y, f"접수번호: {data['id']} | 등록일자: {data.get('date')} | 부서: {data.get('department')}")
|
|
y -= line_gap
|
|
c.drawString(margin, y, f"작성자: {data.get('writer')} | 상태: {data['status']}")
|
|
y -= line_gap * 2
|
|
|
|
def draw_multiline_text(text, x, cur_y, max_w):
|
|
c.setFont("Malgun", 10)
|
|
lines = []
|
|
chars_per_line = 45
|
|
for i in range(0, len(text), chars_per_line):
|
|
lines.append(text[i:i+chars_per_line])
|
|
|
|
for line in lines:
|
|
if cur_y < 50:
|
|
c.showPage()
|
|
c.setFont("Malgun", 10)
|
|
cur_y = height - 50
|
|
c.drawString(x, cur_y, line)
|
|
cur_y -= line_gap
|
|
return cur_y
|
|
|
|
c.setFont("Malgun", 12)
|
|
c.drawString(margin, y, "[질의 내용]")
|
|
y -= line_gap
|
|
|
|
content = str(data.get('content') or '')
|
|
y = draw_multiline_text(content, margin, y, width - 2*margin)
|
|
y -= line_gap
|
|
|
|
c.setFont("Malgun", 12)
|
|
c.drawString(margin, y, "[답변 내용]")
|
|
y -= line_gap
|
|
|
|
answer = str(data.get('answer') or '')
|
|
y = draw_multiline_text(answer, margin, y, width - 2*margin)
|
|
|
|
c.save()
|
|
return True, f"PDF 보고서가 생성되었습니다.\n{full_path}"
|
|
|
|
except Exception as e:
|
|
self.logger.error(f"PDF 생성 오류: {e}")
|
|
return False, f"PDF 생성 오류: {e}"
|
|
|
|
def get_line_info(self, line_str: str) -> dict:
|
|
"""
|
|
호선별 역/방향 정보 반환 (UI 연동용)
|
|
|
|
Args:
|
|
line_str (str): 호선명 (예: "1호선")
|
|
|
|
Returns:
|
|
dict: {"stations": [...], "directions": [...]}
|
|
"""
|
|
# 기본값
|
|
info = {
|
|
"stations": [],
|
|
"directions": ["상행", "하행"]
|
|
}
|
|
|
|
if not line_str:
|
|
return info
|
|
|
|
if "1호선" in line_str:
|
|
# 1호선 역 정보는 config 또는 parser에서 가져옴
|
|
if self.parser and hasattr(self.parser, 'stations'):
|
|
info["stations"] = self.parser.stations
|
|
else:
|
|
info["stations"] = self.config.get("master_data", {}).get("stations_L1", [])
|
|
|
|
info["directions"] = ["신평/다대포행(하선)", "노포행(상선)"]
|
|
|
|
elif "2호선" in line_str:
|
|
info["directions"] = ["장산행(상선)", "양산행(하선)"]
|
|
elif "3호선" in line_str:
|
|
info["directions"] = ["수영행(상선)", "대저행(하선)"]
|
|
elif "4호선" in line_str:
|
|
info["directions"] = ["미남행(상선)", "안평행(하선)"]
|
|
|
|
return info
|