VOC_Monitor/app/services/report_service.py

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